summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore47
-rw-r--r--Dockerfile21
-rw-r--r--Makefile383
-rw-r--r--README.md269
-rw-r--r--api-kana.md1704
-rw-r--r--api-nyan.md975
-rw-r--r--conf_example.pl (renamed from data/conf_example.pl)8
-rw-r--r--css/blendbg.css6
-rw-r--r--css/forms.css282
-rw-r--r--css/layout.css158
-rw-r--r--css/skins/air.sass45
-rw-r--r--css/skins/angel.sass48
-rw-r--r--css/skins/aselia_01.sass37
-rw-r--r--css/skins/carnevale.sass37
-rw-r--r--css/skins/eiel.sass41
-rw-r--r--css/skins/ever17_01.sass37
-rw-r--r--css/skins/fate_01.sass37
-rw-r--r--css/skins/fate_02.sass37
-rw-r--r--css/skins/grey.sass39
-rw-r--r--css/skins/higanbana.sass45
-rw-r--r--css/skins/higu.sass37
-rw-r--r--css/skins/lb.sass34
-rw-r--r--css/skins/lb_02.sass37
-rw-r--r--css/skins/primitive.sass37
-rw-r--r--css/skins/saya.sass37
-rw-r--r--css/skins/seinarukana.sass37
-rw-r--r--css/skins/taka.sass37
-rw-r--r--css/skins/teal.sass35
-rw-r--r--css/skins/term.sass34
-rw-r--r--css/skins/tsukihime.sass42
-rw-r--r--css/skins/tsukihime_02.sass37
-rw-r--r--css/staffedit.css7
-rw-r--r--css/v2.css1103
-rw-r--r--css/vngraph.css27
-rw-r--r--data/icons/feed.pngbin519 -> 0 bytes
-rw-r--r--data/icons/lang/ar.pngbin313 -> 0 bytes
-rw-r--r--data/icons/lang/bg.pngbin107 -> 0 bytes
-rw-r--r--data/icons/lang/ca.pngbin129 -> 0 bytes
-rw-r--r--data/icons/lang/cs.pngbin138 -> 0 bytes
-rw-r--r--data/icons/lang/da.pngbin150 -> 0 bytes
-rw-r--r--data/icons/lang/de.pngbin92 -> 0 bytes
-rw-r--r--data/icons/lang/el.pngbin120 -> 0 bytes
-rw-r--r--data/icons/lang/en.pngbin215 -> 0 bytes
-rw-r--r--data/icons/lang/es.pngbin93 -> 0 bytes
-rw-r--r--data/icons/lang/fi.pngbin148 -> 0 bytes
-rw-r--r--data/icons/lang/fr.pngbin91 -> 0 bytes
-rw-r--r--data/icons/lang/he.pngbin189 -> 0 bytes
-rw-r--r--data/icons/lang/hr.pngbin262 -> 0 bytes
-rw-r--r--data/icons/lang/hu.pngbin108 -> 0 bytes
-rw-r--r--data/icons/lang/id.pngbin96 -> 0 bytes
-rw-r--r--data/icons/lang/it.pngbin91 -> 0 bytes
-rw-r--r--data/icons/lang/ja.pngbin112 -> 0 bytes
-rw-r--r--data/icons/lang/ko.pngbin164 -> 0 bytes
-rw-r--r--data/icons/lang/ms.pngbin352 -> 0 bytes
-rw-r--r--data/icons/lang/nl.pngbin94 -> 0 bytes
-rw-r--r--data/icons/lang/no.pngbin125 -> 0 bytes
-rw-r--r--data/icons/lang/pl.pngbin89 -> 0 bytes
-rw-r--r--data/icons/lang/pt-br.pngbin338 -> 0 bytes
-rw-r--r--data/icons/lang/pt-pt.pngbin217 -> 0 bytes
-rw-r--r--data/icons/lang/ro.pngbin114 -> 0 bytes
-rw-r--r--data/icons/lang/ru.pngbin96 -> 0 bytes
-rw-r--r--data/icons/lang/sk.pngbin260 -> 0 bytes
-rw-r--r--data/icons/lang/sv.pngbin326 -> 0 bytes
-rw-r--r--data/icons/lang/ta.pngbin291 -> 0 bytes
-rw-r--r--data/icons/lang/th.pngbin132 -> 0 bytes
-rw-r--r--data/icons/lang/tr.pngbin160 -> 0 bytes
-rw-r--r--data/icons/lang/uk.pngbin105 -> 0 bytes
-rw-r--r--data/icons/lang/vi.pngbin215 -> 0 bytes
-rw-r--r--data/icons/lang/zh.pngbin121 -> 0 bytes
-rw-r--r--data/icons/plat/and.pngbin378 -> 0 bytes
-rw-r--r--data/icons/plat/bdp.pngbin389 -> 0 bytes
-rw-r--r--data/icons/plat/dos.pngbin801 -> 0 bytes
-rw-r--r--data/icons/plat/drc.pngbin678 -> 0 bytes
-rw-r--r--data/icons/plat/dvd.pngbin293 -> 0 bytes
-rw-r--r--data/icons/plat/fmt.pngbin430 -> 0 bytes
-rw-r--r--data/icons/plat/gba.pngbin724 -> 0 bytes
-rw-r--r--data/icons/plat/gbc.pngbin623 -> 0 bytes
-rw-r--r--data/icons/plat/ios.pngbin584 -> 0 bytes
-rw-r--r--data/icons/plat/lin.pngbin503 -> 0 bytes
-rw-r--r--data/icons/plat/mac.pngbin546 -> 0 bytes
-rw-r--r--data/icons/plat/msx.pngbin389 -> 0 bytes
-rw-r--r--data/icons/plat/n3d.pngbin593 -> 0 bytes
-rw-r--r--data/icons/plat/nds.pngbin294 -> 0 bytes
-rw-r--r--data/icons/plat/nes.pngbin369 -> 0 bytes
-rw-r--r--data/icons/plat/p88.pngbin409 -> 0 bytes
-rw-r--r--data/icons/plat/p98.pngbin651 -> 0 bytes
-rw-r--r--data/icons/plat/pce.pngbin3467 -> 0 bytes
-rw-r--r--data/icons/plat/pcf.pngbin320 -> 0 bytes
-rw-r--r--data/icons/plat/ps1.pngbin449 -> 0 bytes
-rw-r--r--data/icons/plat/ps2.pngbin125 -> 0 bytes
-rw-r--r--data/icons/plat/ps3.pngbin226 -> 0 bytes
-rw-r--r--data/icons/plat/ps4.pngbin162 -> 0 bytes
-rw-r--r--data/icons/plat/psp.pngbin118 -> 0 bytes
-rw-r--r--data/icons/plat/psv.pngbin248 -> 0 bytes
-rw-r--r--data/icons/plat/sat.pngbin649 -> 0 bytes
-rw-r--r--data/icons/plat/sfc.pngbin387 -> 0 bytes
-rw-r--r--data/icons/plat/swi.pngbin129 -> 0 bytes
-rw-r--r--data/icons/plat/web.pngbin801 -> 0 bytes
-rw-r--r--data/icons/plat/wii.pngbin625 -> 0 bytes
-rw-r--r--data/icons/plat/win.pngbin707 -> 0 bytes
-rw-r--r--data/icons/plat/wiu.pngbin116 -> 0 bytes
-rw-r--r--data/icons/plat/x68.pngbin486 -> 0 bytes
-rw-r--r--data/icons/plat/xb1.pngbin647 -> 0 bytes
-rw-r--r--data/icons/plat/xb3.pngbin586 -> 0 bytes
-rw-r--r--data/icons/plat/xbo.pngbin777 -> 0 bytes
-rw-r--r--data/js/charops.js88
-rw-r--r--data/js/chartraits.js123
-rw-r--r--data/js/charvns.js194
-rw-r--r--data/js/dateselector.js84
-rw-r--r--data/js/dropdown.js91
-rw-r--r--data/js/dropdownsearch.js204
-rw-r--r--data/js/filter.js727
-rw-r--r--data/js/iv.js138
-rw-r--r--data/js/lib.js179
-rw-r--r--data/js/main.js57
-rw-r--r--data/js/misc.js275
-rw-r--r--data/js/polls.js21
-rw-r--r--data/js/prodrel.js108
-rw-r--r--data/js/relmedia.js68
-rw-r--r--data/js/relprod.js105
-rw-r--r--data/js/relvns.js88
-rw-r--r--data/js/tabs.js49
-rw-r--r--data/js/vncast.js112
-rw-r--r--data/js/vnrel.js124
-rw-r--r--data/js/vnreldropdown.js36
-rw-r--r--data/js/vnscr.js206
-rw-r--r--data/js/vnstaff.js123
-rw-r--r--data/js/vntagmod.js163
-rw-r--r--data/style.css1121
-rw-r--r--elm/AdvSearch/Anime.elm93
-rw-r--r--elm/AdvSearch/Birthday.elm67
-rw-r--r--elm/AdvSearch/DRM.elm78
-rw-r--r--elm/AdvSearch/Engine.elm79
-rw-r--r--elm/AdvSearch/Fields.elm784
-rw-r--r--elm/AdvSearch/Lib.elm185
-rw-r--r--elm/AdvSearch/Main.elm267
-rw-r--r--elm/AdvSearch/Producers.elm93
-rw-r--r--elm/AdvSearch/RDate.elm99
-rw-r--r--elm/AdvSearch/Range.elm215
-rw-r--r--elm/AdvSearch/Resolution.elm85
-rw-r--r--elm/AdvSearch/Set.elm565
-rw-r--r--elm/AdvSearch/Staff.elm94
-rw-r--r--elm/AdvSearch/Tags.elm127
-rw-r--r--elm/AdvSearch/Traits.elm123
-rw-r--r--elm/CharEdit.elm524
-rw-r--r--elm/ColSelect.elm78
-rw-r--r--elm/Discussions/Edit.elm171
-rw-r--r--elm/Discussions/Poll.elm8
-rw-r--r--elm/Discussions/PostEdit.elm112
-rw-r--r--elm/Discussions/Reply.elm82
-rw-r--r--elm/DocEdit.elm102
-rw-r--r--elm/ImageFlagging.elm353
-rw-r--r--elm/Lib/Api.elm54
-rw-r--r--elm/Lib/Autocomplete.elm273
-rw-r--r--elm/Lib/DropDown.elm25
-rw-r--r--elm/Lib/Editsum.elm41
-rw-r--r--elm/Lib/Ffi.elm13
-rw-r--r--elm/Lib/Ffi.js13
-rw-r--r--elm/Lib/Html.elm47
-rw-r--r--elm/Lib/Image.elm184
-rw-r--r--elm/Lib/RDate.elm61
-rw-r--r--elm/Lib/TextPreview.elm23
-rw-r--r--elm/Lib/Util.elm95
-rw-r--r--elm/Reviews/Edit.elm199
-rw-r--r--elm/StaffEdit/Main.elm221
-rw-r--r--elm/StaffEdit/New.elm12
-rw-r--r--elm/TagEdit.elm237
-rw-r--r--elm/Tagmod.elm303
-rw-r--r--elm/TraitEdit.elm205
-rw-r--r--elm/UList/DateEdit.elm4
-rw-r--r--elm/UList/LabelEdit.elm54
-rw-r--r--elm/UList/LabelEdit.js11
-rw-r--r--elm/UList/ManageLabels.elm19
-rw-r--r--elm/UList/ManageLabels.js13
-rw-r--r--elm/UList/Opt.elm94
-rw-r--r--elm/UList/Opt.js36
-rw-r--r--elm/UList/ReleaseEdit.elm70
-rw-r--r--elm/UList/SaveDefault.elm6
-rw-r--r--elm/UList/SaveDefault.js7
-rw-r--r--elm/UList/VNPage.elm141
-rw-r--r--elm/UList/VoteEdit.elm4
-rw-r--r--elm/UList/VoteEdit.js9
-rw-r--r--elm/UList/Widget.elm316
-rw-r--r--elm/UList/labelfilters.js17
-rw-r--r--elm/User/Edit.elm217
-rw-r--r--elm/User/Login.elm136
-rw-r--r--elm/User/PassReset.elm77
-rw-r--r--elm/User/PassSet.elm85
-rw-r--r--elm/User/Register.elm97
-rw-r--r--elm/VNEdit.elm788
-rw-r--r--elm/VNLengthVote.elm216
-rw-r--r--elm/checkall.js16
-rw-r--r--elm/checkhidden.js17
-rw-r--r--elm/elm-init.js34
-rw-r--r--elm/elm.json1
-rw-r--r--elm/pagevars.js5
-rw-r--r--elm/polyfills.js33
-rw-r--r--elm/spoilset.js27
-rw-r--r--icons/README.md47
-rw-r--r--icons/drm/account.svg4
-rw-r--r--icons/drm/activate.svg8
-rw-r--r--icons/drm/alimit.svg6
-rw-r--r--icons/drm/cdkey.svg5
-rw-r--r--icons/drm/cloud.svg4
-rw-r--r--icons/drm/disc.svg8
-rw-r--r--icons/drm/free.svg4
-rw-r--r--icons/drm/online.svg4
-rw-r--r--icons/drm/physical.svg6
-rw-r--r--icons/external.png (renamed from data/icons/external.png)bin239 -> 239 bytes
-rw-r--r--icons/gender.png (renamed from data/icons/gender.png)bin567 -> 567 bytes
-rw-r--r--icons/lang/ar.pngbin0 -> 178 bytes
-rw-r--r--icons/lang/be.pngbin0 -> 194 bytes
-rw-r--r--icons/lang/bg.pngbin0 -> 106 bytes
-rw-r--r--icons/lang/ca.pngbin0 -> 120 bytes
-rw-r--r--icons/lang/ck.pngbin0 -> 326 bytes
-rw-r--r--icons/lang/cs.pngbin0 -> 108 bytes
-rw-r--r--icons/lang/da.pngbin0 -> 108 bytes
-rw-r--r--icons/lang/de.pngbin0 -> 79 bytes
-rw-r--r--icons/lang/el.pngbin0 -> 102 bytes
-rw-r--r--icons/lang/en.pngbin0 -> 127 bytes
-rw-r--r--icons/lang/eo.png (renamed from data/icons/lang/eo.png)bin134 -> 134 bytes
-rw-r--r--icons/lang/es.pngbin0 -> 79 bytes
-rw-r--r--icons/lang/eu.pngbin0 -> 339 bytes
-rw-r--r--icons/lang/fa.pngbin0 -> 312 bytes
-rw-r--r--icons/lang/fi.pngbin0 -> 88 bytes
-rw-r--r--icons/lang/fr.pngbin0 -> 81 bytes
-rw-r--r--icons/lang/ga.pngbin0 -> 88 bytes
-rw-r--r--icons/lang/gd.png (renamed from data/icons/lang/gd.png)bin321 -> 321 bytes
-rw-r--r--icons/lang/he.pngbin0 -> 137 bytes
-rw-r--r--icons/lang/hi.pngbin0 -> 123 bytes
-rw-r--r--icons/lang/hr.pngbin0 -> 206 bytes
-rw-r--r--icons/lang/hu.pngbin0 -> 98 bytes
-rw-r--r--icons/lang/id.pngbin0 -> 85 bytes
-rw-r--r--icons/lang/it.pngbin0 -> 81 bytes
-rw-r--r--icons/lang/iu.pngbin0 -> 271 bytes
-rw-r--r--icons/lang/ja.pngbin0 -> 88 bytes
-rw-r--r--icons/lang/ko.pngbin0 -> 122 bytes
-rw-r--r--icons/lang/la.pngbin0 -> 314 bytes
-rw-r--r--icons/lang/lt.png (renamed from data/icons/lang/lt.png)bin100 -> 100 bytes
-rw-r--r--icons/lang/lv.png (renamed from data/icons/lang/lv.png)bin88 -> 88 bytes
-rw-r--r--icons/lang/mk.png (renamed from data/icons/lang/mk.png)bin313 -> 313 bytes
-rw-r--r--icons/lang/ms.pngbin0 -> 352 bytes
-rw-r--r--icons/lang/nl.pngbin0 -> 83 bytes
-rw-r--r--icons/lang/no.pngbin0 -> 92 bytes
-rw-r--r--icons/lang/pl.pngbin0 -> 77 bytes
-rw-r--r--icons/lang/pt-br.pngbin0 -> 198 bytes
-rw-r--r--icons/lang/pt-pt.pngbin0 -> 137 bytes
-rw-r--r--icons/lang/ro.pngbin0 -> 102 bytes
-rw-r--r--icons/lang/ru.pngbin0 -> 85 bytes
-rw-r--r--icons/lang/sk.pngbin0 -> 208 bytes
-rw-r--r--icons/lang/sl.png (renamed from data/icons/lang/sl.png)bin169 -> 169 bytes
-rw-r--r--icons/lang/sr.pngbin0 -> 262 bytes
-rw-r--r--icons/lang/sv.pngbin0 -> 281 bytes
-rw-r--r--icons/lang/ta.pngbin0 -> 260 bytes
-rw-r--r--icons/lang/th.pngbin0 -> 120 bytes
-rw-r--r--icons/lang/tr.pngbin0 -> 101 bytes
-rw-r--r--icons/lang/uk.pngbin0 -> 96 bytes
-rw-r--r--icons/lang/ur.pngbin0 -> 172 bytes
-rw-r--r--icons/lang/vi.pngbin0 -> 149 bytes
-rw-r--r--icons/lang/zh-Hans.pngbin0 -> 174 bytes
-rw-r--r--icons/lang/zh-Hant.pngbin0 -> 105 bytes
-rw-r--r--icons/lang/zh.pngbin0 -> 93 bytes
-rw-r--r--icons/list/add.svg6
-rw-r--r--icons/list/l1.svg6
-rw-r--r--icons/list/l2.svg6
-rw-r--r--icons/list/l3.svg6
-rw-r--r--icons/list/l4.svg6
-rw-r--r--icons/list/l5.svg6
-rw-r--r--icons/list/l6.svg6
-rw-r--r--icons/list/unknown.svg7
-rw-r--r--icons/plat/and.svg3
-rw-r--r--icons/plat/bdp.svg6
-rw-r--r--icons/plat/dos.svg6
-rw-r--r--icons/plat/drc.svg3
-rw-r--r--icons/plat/dvd.svg3
-rw-r--r--icons/plat/fm7.svg3
-rw-r--r--icons/plat/fm8.svg3
-rw-r--r--icons/plat/fmt.svg3
-rw-r--r--icons/plat/gba.svg4
-rw-r--r--icons/plat/gbc.svg5
-rw-r--r--icons/plat/ios.svg10
-rw-r--r--icons/plat/lin.svg7
-rw-r--r--icons/plat/mac.svg9
-rw-r--r--icons/plat/mob.svg22
-rw-r--r--icons/plat/msx.svg4
-rw-r--r--icons/plat/n3d.svg4
-rw-r--r--icons/plat/nds.svg3
-rw-r--r--icons/plat/nes.svg3
-rw-r--r--icons/plat/oth.svg3
-rw-r--r--icons/plat/p88.svg6
-rw-r--r--icons/plat/p98.svg5
-rw-r--r--icons/plat/pce.svg4
-rw-r--r--icons/plat/pcf.svg13
-rw-r--r--icons/plat/ps1.svg10
-rw-r--r--icons/plat/ps2.svg3
-rw-r--r--icons/plat/ps3.svg3
-rw-r--r--icons/plat/ps4.svg3
-rw-r--r--icons/plat/ps5.svg3
-rw-r--r--icons/plat/psp.svg3
-rw-r--r--icons/plat/psv.svg3
-rw-r--r--icons/plat/sat.svg18
-rw-r--r--icons/plat/scd.svg8
-rw-r--r--icons/plat/sfc.svg6
-rw-r--r--icons/plat/smd.svg6
-rw-r--r--icons/plat/swi.svg6
-rw-r--r--icons/plat/tdo.svg6
-rw-r--r--icons/plat/vnd.svg5
-rw-r--r--icons/plat/web.svg3
-rw-r--r--icons/plat/wii.svg3
-rw-r--r--icons/plat/win.svg6
-rw-r--r--icons/plat/wiu.svg6
-rw-r--r--icons/plat/x1s.svg3
-rw-r--r--icons/plat/x68.svg3
-rw-r--r--icons/plat/xb1.svg65
-rw-r--r--icons/plat/xb3.svg37
-rw-r--r--icons/plat/xbo.svg3
-rw-r--r--icons/plat/xxs.svg4
-rw-r--r--icons/rel/ani-ero.svg7
-rw-r--r--icons/rel/ani-story.svg6
-rw-r--r--icons/rel/cartridge.svg7
-rw-r--r--icons/rel/disk.svg4
-rw-r--r--icons/rel/download.svg6
-rw-r--r--icons/rel/free.svg3
-rw-r--r--icons/rel/nonfree.svg7
-rw-r--r--icons/rel/notes.svg6
-rw-r--r--icons/rel/reso-169.svg6
-rw-r--r--icons/rel/reso-43.svg6
-rw-r--r--icons/rel/reso-custom.svg7
-rw-r--r--icons/rel/voiced.svg7
-rw-r--r--icons/rss.svg8
-rw-r--r--icons/rtcomplete.png (renamed from data/icons/rtcomplete.png)bin89 -> 89 bytes
-rw-r--r--icons/rtpartial.png (renamed from data/icons/rtpartial.png)bin102 -> 102 bytes
-rw-r--r--icons/rttrial.png (renamed from data/icons/rttrial.png)bin114 -> 114 bytes
-rw-r--r--js/README.md70
-rw-r--r--js/basic/TableOpts.js132
-rw-r--r--js/basic/api.js73
-rw-r--r--js/basic/checkall.js16
-rw-r--r--js/basic/checkhidden.js11
-rw-r--r--js/basic/components.js433
-rw-r--r--js/basic/ds.js450
-rw-r--r--js/basic/elm-support.js112
-rw-r--r--js/basic/index.js55
-rw-r--r--js/basic/iv.js220
-rw-r--r--js/basic/mainbox-summarize.js31
-rw-r--r--js/basic/polyfills.js24
-rw-r--r--js/basic/searchtabs.js9
-rw-r--r--js/basic/sethash.js8
-rw-r--r--js/basic/ulist-actiontabs.js6
-rw-r--r--js/basic/ulist-labelfilters.js11
-rw-r--r--js/basic/utils.js63
-rw-r--r--js/contrib/DRMEdit.js44
-rw-r--r--js/contrib/DocEdit.js29
-rw-r--r--js/contrib/ProducerEdit.js119
-rw-r--r--js/contrib/ReleaseEdit.js479
-rw-r--r--js/contrib/Report.js105
-rw-r--r--js/contrib/StaffEdit.js133
-rw-r--r--js/contrib/index.js133
-rw-r--r--js/graph/index.js12
-rw-r--r--js/graph/vn.js266
-rw-r--r--js/user/DiscussionReply.js29
-rw-r--r--js/user/QuoteEdit.js56
-rw-r--r--js/user/QuoteVote.js18
-rw-r--r--js/user/ReviewComment.js21
-rw-r--r--js/user/ReviewsVote.js25
-rw-r--r--js/user/Subscribe.js61
-rw-r--r--js/user/UserAdmin.js58
-rw-r--r--js/user/UserEdit.js573
-rw-r--r--js/user/UserLogin.js71
-rw-r--r--js/user/UserPassReset.js30
-rw-r--r--js/user/UserPassSet.js34
-rw-r--r--js/user/UserRegister.js71
-rw-r--r--js/user/index.js27
-rw-r--r--lib/Multi/API.pm670
-rw-r--r--lib/Multi/Anime.pm108
-rw-r--r--lib/Multi/Core.pm56
-rw-r--r--lib/Multi/DLsite.pm18
-rw-r--r--lib/Multi/Denpa.pm39
-rw-r--r--lib/Multi/Feed.pm155
-rw-r--r--lib/Multi/IRC.pm187
-rw-r--r--lib/Multi/JASTUSA.pm87
-rw-r--r--lib/Multi/JList.pm48
-rw-r--r--lib/Multi/Maintenance.pm126
-rw-r--r--lib/Multi/RG.pm347
-rw-r--r--lib/Multi/Wikidata.pm2
-rw-r--r--lib/PWLookup.pm155
-rw-r--r--lib/SkinFile.pm74
-rw-r--r--lib/VNDB/BBCode.pm183
-rw-r--r--lib/VNDB/Config.pm62
-rw-r--r--lib/VNDB/DB/Chars.pm201
-rw-r--r--lib/VNDB/DB/Discussions.pm176
-rw-r--r--lib/VNDB/DB/Misc.pm119
-rw-r--r--lib/VNDB/DB/Producers.pm131
-rw-r--r--lib/VNDB/DB/Releases.pm269
-rw-r--r--lib/VNDB/DB/Staff.pm79
-rw-r--r--lib/VNDB/DB/Tags.pm288
-rw-r--r--lib/VNDB/DB/Traits.pm113
-rw-r--r--lib/VNDB/DB/ULists.pm77
-rw-r--r--lib/VNDB/DB/Users.pm49
-rw-r--r--lib/VNDB/DB/VN.pm369
-rw-r--r--lib/VNDB/ExtLinks.pm387
-rw-r--r--lib/VNDB/Func.pm413
-rw-r--r--lib/VNDB/Handler/Chars.pm621
-rw-r--r--lib/VNDB/Handler/Misc.pm252
-rw-r--r--lib/VNDB/Handler/Producers.pm500
-rw-r--r--lib/VNDB/Handler/Releases.pm846
-rw-r--r--lib/VNDB/Handler/Staff.pm116
-rw-r--r--lib/VNDB/Handler/Tags.pm678
-rw-r--r--lib/VNDB/Handler/Traits.pm457
-rw-r--r--lib/VNDB/Handler/ULists.pm51
-rw-r--r--lib/VNDB/Handler/VNBrowse.pm143
-rw-r--r--lib/VNDB/Handler/VNEdit.pm541
-rw-r--r--lib/VNDB/Handler/VNPage.pm1062
-rw-r--r--lib/VNDB/Schema.pm38
-rw-r--r--lib/VNDB/Skins.pm27
-rw-r--r--lib/VNDB/Types.pm246
-rw-r--r--lib/VNDB/Util/Auth.pm129
-rw-r--r--lib/VNDB/Util/BrowseHTML.pm190
-rw-r--r--lib/VNDB/Util/CommonHTML.pm327
-rw-r--r--lib/VNDB/Util/FormHTML.pm282
-rw-r--r--lib/VNDB/Util/LayoutHTML.pm43
-rw-r--r--lib/VNDB/Util/Misc.pm122
-rw-r--r--lib/VNDB/Util/ValidateTemplates.pm110
-rw-r--r--lib/VNDBUtil.pm145
-rw-r--r--lib/VNWeb/API.pm1085
-rw-r--r--lib/VNWeb/AdvSearch.pm963
-rw-r--r--lib/VNWeb/Auth.pm271
-rw-r--r--lib/VNWeb/Chars/Edit.pm163
-rw-r--r--lib/VNWeb/Chars/Elm.pm23
-rw-r--r--lib/VNWeb/Chars/List.pm146
-rw-r--r--lib/VNWeb/Chars/Page.pm340
-rw-r--r--lib/VNWeb/Chars/VNTab.pm68
-rw-r--r--lib/VNWeb/DB.pm163
-rw-r--r--lib/VNWeb/Discussions/Board.pm23
-rw-r--r--lib/VNWeb/Discussions/Edit.pm139
-rw-r--r--lib/VNWeb/Discussions/Elm.pm40
-rw-r--r--lib/VNWeb/Discussions/Index.pm10
-rw-r--r--lib/VNWeb/Discussions/Lib.pm61
-rw-r--r--lib/VNWeb/Discussions/PostEdit.pm89
-rw-r--r--lib/VNWeb/Discussions/Search.pm123
-rw-r--r--lib/VNWeb/Discussions/Thread.pm127
-rw-r--r--lib/VNWeb/Discussions/UPosts.pm44
-rw-r--r--lib/VNWeb/Docs/Edit.pm35
-rw-r--r--lib/VNWeb/Docs/Lib.pm71
-rw-r--r--lib/VNWeb/Docs/Page.pm26
-rw-r--r--lib/VNWeb/Elm.pm310
-rw-r--r--lib/VNWeb/Filters.pm246
-rw-r--r--lib/VNWeb/Graph.pm119
-rw-r--r--lib/VNWeb/HTML.pm824
-rw-r--r--lib/VNWeb/Images/Lib.pm166
-rw-r--r--lib/VNWeb/Images/List.pm209
-rw-r--r--lib/VNWeb/Images/Upload.pm86
-rw-r--r--lib/VNWeb/Images/Vote.pm138
-rw-r--r--lib/VNWeb/JS.pm73
-rw-r--r--lib/VNWeb/Misc/AdvSearch.pm31
-rw-r--r--lib/VNWeb/Misc/BBCode.pm10
-rw-r--r--lib/VNWeb/Misc/ElmAnime.pm25
-rw-r--r--lib/VNWeb/Misc/Feeds.pm80
-rw-r--r--lib/VNWeb/Misc/History.pm148
-rw-r--r--lib/VNWeb/Misc/HomePage.pm286
-rw-r--r--lib/VNWeb/Misc/Lockdown.pm54
-rw-r--r--lib/VNWeb/Misc/OpenSearch.pm22
-rw-r--r--lib/VNWeb/Misc/Redirects.pm46
-rw-r--r--lib/VNWeb/Misc/Reports.pm271
-rw-r--r--lib/VNWeb/Prelude.pm71
-rw-r--r--lib/VNWeb/Producers/Edit.pm114
-rw-r--r--lib/VNWeb/Producers/Elm.pm34
-rw-r--r--lib/VNWeb/Producers/Graph.pm72
-rw-r--r--lib/VNWeb/Producers/List.pm75
-rw-r--r--lib/VNWeb/Producers/Page.pm183
-rw-r--r--lib/VNWeb/Releases/DRM.pm120
-rw-r--r--lib/VNWeb/Releases/Edit.pm220
-rw-r--r--lib/VNWeb/Releases/Elm.pm61
-rw-r--r--lib/VNWeb/Releases/Engines.pm43
-rw-r--r--lib/VNWeb/Releases/Lib.pm185
-rw-r--r--lib/VNWeb/Releases/List.pm92
-rw-r--r--lib/VNWeb/Releases/Page.pm312
-rw-r--r--lib/VNWeb/Releases/VNTab.pm263
-rw-r--r--lib/VNWeb/Reviews/Edit.pm122
-rw-r--r--lib/VNWeb/Reviews/JS.pm24
-rw-r--r--lib/VNWeb/Reviews/Lib.pm30
-rw-r--r--lib/VNWeb/Reviews/List.pm87
-rw-r--r--lib/VNWeb/Reviews/Page.pm166
-rw-r--r--lib/VNWeb/Reviews/VNTab.pm93
-rw-r--r--lib/VNWeb/Staff/Edit.pm80
-rw-r--r--lib/VNWeb/Staff/Elm.pm34
-rw-r--r--lib/VNWeb/Staff/List.pm94
-rw-r--r--lib/VNWeb/Staff/Page.pm137
-rw-r--r--lib/VNWeb/TT/Elm.pm56
-rw-r--r--lib/VNWeb/TT/Index.pm88
-rw-r--r--lib/VNWeb/TT/Lib.pm102
-rw-r--r--lib/VNWeb/TT/List.pm102
-rw-r--r--lib/VNWeb/TT/TagEdit.pm154
-rw-r--r--lib/VNWeb/TT/TagLinks.pm (renamed from lib/VNWeb/Tags/Links.pm)49
-rw-r--r--lib/VNWeb/TT/TagPage.pm161
-rw-r--r--lib/VNWeb/TT/TraitEdit.pm134
-rw-r--r--lib/VNWeb/TT/TraitPage.pm149
-rw-r--r--lib/VNWeb/TableOpts.pm297
-rw-r--r--lib/VNWeb/Tags/Lib.pm16
-rw-r--r--lib/VNWeb/TimeZone.pm512
-rw-r--r--lib/VNWeb/TitlePrefs.pm217
-rw-r--r--lib/VNWeb/ULists/Elm.pm297
-rw-r--r--lib/VNWeb/ULists/Export.pm127
-rw-r--r--lib/VNWeb/ULists/Lib.pm96
-rw-r--r--lib/VNWeb/ULists/List.pm348
-rw-r--r--lib/VNWeb/User/Admin.pm74
-rw-r--r--lib/VNWeb/User/Css.pm37
-rw-r--r--lib/VNWeb/User/Delete.pm214
-rw-r--r--lib/VNWeb/User/Edit.pm274
-rw-r--r--lib/VNWeb/User/List.pm47
-rw-r--r--lib/VNWeb/User/Lists.pm588
-rw-r--r--lib/VNWeb/User/Login.pm57
-rw-r--r--lib/VNWeb/User/Notifications.pm149
-rw-r--r--lib/VNWeb/User/Page.pm118
-rw-r--r--lib/VNWeb/User/PassReset.pm52
-rw-r--r--lib/VNWeb/User/PassSet.pm32
-rw-r--r--lib/VNWeb/User/Register.pm67
-rw-r--r--lib/VNWeb/VN/Edit.pm239
-rw-r--r--lib/VNWeb/VN/Elm.pm37
-rw-r--r--lib/VNWeb/VN/Graph.pm143
-rw-r--r--lib/VNWeb/VN/Length.pm213
-rw-r--r--lib/VNWeb/VN/List.pm450
-rw-r--r--lib/VNWeb/VN/Page.pm1036
-rw-r--r--lib/VNWeb/VN/Quotes.pm399
-rw-r--r--lib/VNWeb/VN/Tagmod.pm121
-rw-r--r--lib/VNWeb/VN/Votes.pm24
-rw-r--r--lib/VNWeb/Validation.pm325
-rw-r--r--sql/all.sql12
-rw-r--r--sql/c/Makefile4
-rw-r--r--sql/c/test.sql53
-rw-r--r--sql/c/vndbfuncs.c224
-rw-r--r--sql/data.sql13
-rw-r--r--sql/editfunc.sql4
-rw-r--r--sql/func.sql1197
-rw-r--r--sql/perms.sql (renamed from util/sql/perms.sql)135
-rw-r--r--sql/rebuild-search-cache.sql60
-rw-r--r--sql/schema.sql1641
-rw-r--r--sql/superuser_init.sql (renamed from util/sql/superuser_init.sql)2
-rw-r--r--sql/tableattrs.sql (renamed from util/sql/tableattrs.sql)191
-rw-r--r--sql/triggers.sql (renamed from util/sql/triggers.sql)246
-rw-r--r--sql/util.sql157
-rw-r--r--sql/vndbid.sql88
-rw-r--r--static/f/16-9.svg9
-rw-r--r--static/f/4-3.svg8
-rw-r--r--static/f/cartridge.svg22
-rw-r--r--static/f/commercial.svg9
-rw-r--r--static/f/disk.svg12
-rw-r--r--static/f/doujin.svg11
-rw-r--r--static/f/download.svg8
-rw-r--r--static/f/ero_animated.svg9
-rw-r--r--static/f/free.svg12
-rw-r--r--static/f/imgvote-keybindings.svg2
-rw-r--r--static/f/nonfree.svg12
-rw-r--r--static/f/notes.svg10
-rw-r--r--static/f/patreon.pngbin2481 -> 0 bytes
-rw-r--r--static/f/resolution_16-9.svg9
-rw-r--r--static/f/resolution_4-3.svg8
-rw-r--r--static/f/resolution_custom.svg9
-rw-r--r--static/f/story_animated.svg8
-rw-r--r--static/f/subscribestar.pngbin2900 -> 0 bytes
-rw-r--r--static/f/uncensor.svg2
-rw-r--r--static/f/voiced.svg33
-rw-r--r--static/s/air-bg.jpg (renamed from static/s/air/bg.jpg)bin78066 -> 78066 bytes
-rw-r--r--static/s/air-right.png (renamed from static/s/air/bgright.png)bin27189 -> 27189 bytes
-rw-r--r--static/s/air/conf32
-rw-r--r--static/s/angel-bg-xmas.jpg (renamed from static/s/angel/bg-xmas.jpg)bin45511 -> 45511 bytes
-rw-r--r--static/s/angel-bg.jpg (renamed from static/s/angel/bg.jpg)bin36419 -> 36419 bytes
-rw-r--r--static/s/angel-right.jpg (renamed from static/s/angel/bgright.jpg)bin4366 -> 4366 bytes
-rw-r--r--static/s/angel/conf37
-rw-r--r--static/s/aselia_01-right.jpg (renamed from static/s/aselia_01/bgright.jpg)bin107272 -> 107272 bytes
-rw-r--r--static/s/aselia_01/conf39
-rw-r--r--static/s/carnevale-right.jpg (renamed from static/s/carnevale/bgright.jpg)bin74285 -> 74285 bytes
-rw-r--r--static/s/carnevale/conf40
-rw-r--r--static/s/eiel-bg.jpg (renamed from static/s/eiel/bg.jpg)bin111656 -> 111656 bytes
-rw-r--r--static/s/eiel/conf40
-rw-r--r--static/s/ever17_01-right.jpg (renamed from static/s/ever17_01/bgright.jpg)bin73060 -> 73060 bytes
-rw-r--r--static/s/ever17_01/conf39
-rw-r--r--static/s/fate_01-bg.jpg (renamed from static/s/fate_01/bg.jpg)bin70918 -> 70918 bytes
-rw-r--r--static/s/fate_01/conf39
-rw-r--r--static/s/fate_02-bg.jpg (renamed from static/s/fate_02/bg.jpg)bin71968 -> 71968 bytes
-rw-r--r--static/s/fate_02/conf39
-rw-r--r--static/s/grey-bg.jpg (renamed from static/s/grey/bg.jpg)bin33434 -> 33434 bytes
-rw-r--r--static/s/grey-right.jpg (renamed from static/s/grey/bgright.jpg)bin3062 -> 3062 bytes
-rw-r--r--static/s/grey/conf37
-rw-r--r--static/s/higanbana-bg.jpg (renamed from static/s/higanbana/bg.jpg)bin71567 -> 71567 bytes
-rw-r--r--static/s/higanbana-right.png (renamed from static/s/higanbana/bgright.png)bin41295 -> 41295 bytes
-rw-r--r--static/s/higanbana/conf32
-rw-r--r--static/s/higu-bg.jpg (renamed from static/s/higu/bg.jpg)bin72231 -> 72231 bytes
-rw-r--r--static/s/higu/conf39
-rw-r--r--static/s/lb-bg.jpg (renamed from static/s/lb/bg.jpg)bin89041 -> 89041 bytes
-rw-r--r--static/s/lb/conf37
-rw-r--r--static/s/lb_02-bg.jpg (renamed from static/s/lb_02/bg.jpg)bin111856 -> 111856 bytes
-rw-r--r--static/s/lb_02/conf38
-rw-r--r--static/s/primitive-right.jpg (renamed from static/s/primitive/bgright.jpg)bin107494 -> 107494 bytes
-rw-r--r--static/s/primitive/conf40
-rw-r--r--static/s/saya-right.jpg (renamed from static/s/saya/bgright.jpg)bin73950 -> 73950 bytes
-rw-r--r--static/s/saya/conf40
-rw-r--r--static/s/seinarukana-bg.jpg (renamed from static/s/seinarukana/bg.jpg)bin70934 -> 70934 bytes
-rw-r--r--static/s/seinarukana/conf38
-rw-r--r--static/s/taka-right.jpg (renamed from static/s/taka/bgright.jpg)bin89059 -> 89059 bytes
-rw-r--r--static/s/taka/conf40
-rw-r--r--static/s/term/conf37
-rw-r--r--static/s/tsukihime-bg.jpg (renamed from static/s/tsukihime/bg.jpg)bin70806 -> 70806 bytes
-rw-r--r--static/s/tsukihime-right.jpg (renamed from static/s/tsukihime/bgright.jpg)bin71879 -> 71879 bytes
-rw-r--r--static/s/tsukihime/conf39
-rw-r--r--static/s/tsukihime_02-bg.jpg (renamed from static/s/tsukihime_02/bg.jpg)bin103162 -> 103162 bytes
-rw-r--r--static/s/tsukihime_02/conf39
-rw-r--r--util/README.md65
-rwxr-xr-xutil/dbdump.pl306
-rwxr-xr-xutil/devdump.pl152
-rwxr-xr-xutil/dl-cron.sh44
-rwxr-xr-xutil/dl-gendir.pl51
-rwxr-xr-xutil/docker-init.sh51
-rwxr-xr-xutil/hibp-dl.pl89
-rw-r--r--util/imgproc.c252
-rwxr-xr-xutil/jsgen.pl114
-rwxr-xr-xutil/multi.pl4
-rwxr-xr-xutil/pngsprite.pl122
-rwxr-xr-xutil/revision-integrity.pl39
-rwxr-xr-xutil/setup-var.sh21
-rwxr-xr-xutil/skingen.pl100
-rwxr-xr-xutil/spritegen.pl136
-rw-r--r--util/sql/all.sql11
-rw-r--r--util/sql/data.sql15
-rw-r--r--util/sql/func.sql676
-rw-r--r--util/sql/schema.sql953
-rwxr-xr-xutil/sqleditfunc.pl47
-rwxr-xr-xutil/svgsprite.pl54
-rw-r--r--util/test/basn4a08.pngbin0 -> 126 bytes
-rw-r--r--util/test/basn6a16.pngbin0 -> 3435 bytes
-rwxr-xr-xutil/test/bbcode.pl (renamed from util/bbcode-test.pl)99
-rwxr-xr-xutil/test/imgproc-custom.pl76
-rw-r--r--util/test/xd9n2c08.pngbin0 -> 145 bytes
-rwxr-xr-xutil/unusedimages.pl159
-rwxr-xr-xutil/update-docs-html-cache.pl16
-rw-r--r--util/updates/2020-02-06-docs-html-cache.sql5
-rw-r--r--util/updates/2020-02-09-tags-vn-notes.sql1
-rw-r--r--util/updates/2020-02-21-tags-vn-null-users.sql8
-rw-r--r--util/updates/2020-03-06-images-table.sql58
-rwxr-xr-xutil/updates/2020-03-12-image-sizes.pl26
-rw-r--r--util/updates/2020-03-13-image-flagging.sql30
-rw-r--r--util/updates/2020-03-17-imgvote-permission.sql3
-rw-r--r--util/updates/2020-03-23-release-extlinks.sql9
-rw-r--r--util/updates/2020-03-26-users-imgvotes.sql6
-rw-r--r--util/updates/2020-04-02-releases-original-title-size.sql2
-rw-r--r--util/updates/2020-04-05-vndbid-for-images.sql51
-rw-r--r--util/updates/2020-04-06-drop-relgraphs.sql9
-rw-r--r--util/updates/2020-04-09-stats-cleanup.sql10
-rw-r--r--util/updates/2020-04-15-users-permflags.sql26
-rw-r--r--util/updates/2020-04-16-imgflag-user-deletion.sql4
-rw-r--r--util/updates/2020-04-25-imgflag-overrule.sql4
-rw-r--r--util/updates/2020-04-26-perm-imgmod.sql3
-rw-r--r--util/updates/2020-04-27-audit-logging.sql11
-rw-r--r--util/updates/2020-05-11-imgflag-preferences.sql4
-rw-r--r--util/updates/2020-06-04-vn-ranking-cache.sql4
-rw-r--r--util/updates/2020-06-13-spoil-gender.sql4
-rw-r--r--util/updates/2020-06-15-custom-resolutions.sql39
-rw-r--r--util/updates/2020-07-23-reports.sql19
-rw-r--r--util/updates/2020-07-25-report-db.sql2
-rw-r--r--util/updates/2020-07-29-reports-last-seen.sql5
-rw-r--r--util/updates/2020-08-07-schema-sync.sql14
-rw-r--r--util/updates/2020-08-07-threads.sql46
-rw-r--r--util/updates/2020-08-17-reviews.sql71
-rw-r--r--util/updates/2020-08-19-reviews-caches.sql14
-rw-r--r--util/updates/2020-08-24-reviews-nosummary.sql6
-rw-r--r--util/updates/2020-08-25-reviews-fixups.sql5
-rw-r--r--util/updates/2020-09-03-reviews-flagging.sql5
-rw-r--r--util/updates/2020-09-05-notifications.sql16
-rw-r--r--util/updates/2020-09-20-reviews-locked.sql1
-rw-r--r--util/updates/2020-10-08-extra-notifications.sql45
-rw-r--r--util/updates/2020-10-13-notifications-subapply.sql3
-rw-r--r--util/updates/2020-10-15-reviews-anonymous-votes.sql4
-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
-rw-r--r--util/updates/2020-12-14-release-extlinks.sql46
-rw-r--r--util/updates/2020-12-15-release-extlinks.sql16
-rw-r--r--util/updates/2021-01-03-advsearch-saved-queries.sql10
-rwxr-xr-xutil/updates/2021-01-10-advsearch-convert-saved-filters.pl46
-rw-r--r--util/updates/2021-01-17-irish-language.sql1
-rwxr-xr-xutil/updates/2021-01-21-update-saved-queries.pl66
-rw-r--r--util/updates/2021-01-30-vn-olang.sql35
-rw-r--r--util/updates/2021-02-02-cleanup.sql8
-rw-r--r--util/updates/2021-02-08-user-lookup-by-mail.sql2
-rw-r--r--util/updates/2021-02-13-uid-0.sql11
-rw-r--r--util/updates/2021-02-22-tableopts-char.sql2
-rw-r--r--util/updates/2021-03-01-entries-to-vndbid.sql245
-rw-r--r--util/updates/2021-03-02-reviews-modnote.sql4
-rw-r--r--util/updates/2021-03-04-releases-minage.sql2
-rw-r--r--util/updates/2021-03-06-medium-cassette-tape.sql1
-rw-r--r--util/updates/2021-03-07-platforms.sql9
-rw-r--r--util/updates/2021-03-11-platform-mobile.sql1
-rw-r--r--util/updates/2021-03-11-tag-history.sql89
-rw-r--r--util/updates/2021-03-16-release-dlsiteen.sql16
-rw-r--r--util/updates/2021-03-23-trait-history.sql74
-rw-r--r--util/updates/2021-04-09-item-info.sql2
-rw-r--r--util/updates/2021-05-05-latin-language.sql1
-rw-r--r--util/updates/2021-05-14-releases-lang-mtl.sql4
-rw-r--r--util/updates/2021-05-21-tt-primary-parent.sql17
-rw-r--r--util/updates/2021-05-25-users-shadow.sql19
-rw-r--r--util/updates/2021-05-25-users-vnlang.sql1
-rw-r--r--util/updates/2021-06-04-vn-developers-and-average-cache.sql11
-rw-r--r--util/updates/2021-06-22-indi-urdu-languages.sql2
-rw-r--r--util/updates/2021-06-28-lockdown-mode.sql13
-rw-r--r--util/updates/2021-07-24-more-wikidata-ids.sql3
-rw-r--r--util/updates/2021-07-28-merge-imgmod.sql2
-rw-r--r--util/updates/2021-07-30-vn-length-voting.sql17
-rw-r--r--util/updates/2021-08-03-vnlength-speed.sql6
-rw-r--r--util/updates/2021-08-04-vnlength-index.sql2
-rw-r--r--util/updates/2021-08-08-lengthvote-ignore.sql1
-rw-r--r--util/updates/2021-08-09-vnlength-multirelease.sql4
-rw-r--r--util/updates/2021-08-09b-vnlength-primarykey.sql28
-rw-r--r--util/updates/2021-09-02-some-foreign-key-stuff.sql5
-rw-r--r--util/updates/2021-09-26-vn-length-cache.sql6
-rw-r--r--util/updates/2021-10-27-freegame-mugen.sql3
-rw-r--r--util/updates/2021-10-28-username-casefold.sql2
-rw-r--r--util/updates/2021-10-28-username-history.sql16
-rw-r--r--util/updates/2021-10-28-website-length.sql4
-rwxr-xr-xutil/updates/2021-10-29-fix-thumbnail-resolution.pl50
-rw-r--r--util/updates/2021-11-07-posts-hidden-msg.sql17
-rw-r--r--util/updates/2021-11-07-threads-board-lock.sql1
-rw-r--r--util/updates/2021-11-15-release-vn-type.sql12
-rw-r--r--util/updates/2021-11-15-reviews-fulltext-search.sql2
-rw-r--r--util/updates/2021-11-18-release-search.sql3
-rw-r--r--util/updates/2021-11-19-more-search.sql9
-rw-r--r--util/updates/2021-11-19-vn-search.sql7
-rw-r--r--util/updates/2021-11-24-tagtrait-search.sql2
-rw-r--r--util/updates/2021-11-29-release-unknown-uncensored.sql5
-rw-r--r--util/updates/2021-12-06-extlinks-playstation-stores.sql13
-rw-r--r--util/updates/2021-12-15-api-sessions.sql3
-rw-r--r--util/updates/2022-02-05-popularity-non-null.sql7
-rw-r--r--util/updates/2022-02-11-vn-titles.sql41
-rw-r--r--util/updates/2022-02-12-chinese-languages.sql30
-rw-r--r--util/updates/2022-02-19-vnt-sorttitle.sql3
-rw-r--r--util/updates/2022-03-23-vn-length-votes-uncounted.sql6
-rw-r--r--util/updates/2022-03-29-lengthvotes-private.sql3
-rw-r--r--util/updates/2022-03-29-release-animation.sql29
-rw-r--r--util/updates/2022-04-01-user-traits.sql8
-rw-r--r--util/updates/2022-04-05-releases-has-ero.sql5
-rw-r--r--util/updates/2022-04-19-vn-default-poprank.sql1
-rw-r--r--util/updates/2022-04-23-inuktitut-language.sql1
-rw-r--r--util/updates/2022-06-16-users-debloat.sql90
-rw-r--r--util/updates/2022-06-18-user-prefs-prodrelexpand.sql1
-rw-r--r--util/updates/2022-06-19-user-prefs-vnrel.sql31
-rw-r--r--util/updates/2022-06-20-changes-patrolling.sql8
-rw-r--r--util/updates/2022-06-21-tags-vn-lie.sql1
-rw-r--r--util/updates/2022-07-31-vn-devstatus.sql24
-rw-r--r--util/updates/2022-08-03-tags_vn_direct.sql10
-rw-r--r--util/updates/2022-08-24-ipinfo.sql17
-rw-r--r--util/updates/2022-08-25-customcss-csum.sql3
-rw-r--r--util/updates/2022-08-25-staff-editions.sql43
-rw-r--r--util/updates/2022-08-28-basque-language.sql1
-rw-r--r--util/updates/2022-08-30-tag-trait-prefs.sql23
-rw-r--r--util/updates/2022-09-28-release-titles.sql81
-rw-r--r--util/updates/2022-10-08-images-smallints.sql19
-rw-r--r--util/updates/2022-10-16-release-shop-links.sql11
-rw-r--r--util/updates/2022-10-22-tags_vn_inherit-lie.sql4
-rw-r--r--util/updates/2022-10-27-trait-lies.sql5
-rw-r--r--util/updates/2022-10-31-ulist-vns-labels.sql137
-rw-r--r--util/updates/2022-11-11-serbian-language.sql1
-rw-r--r--util/updates/2022-11-29-api2-tokens.sql9
-rw-r--r--util/updates/2022-12-13-users-prefs-timezone.sql1
-rw-r--r--util/updates/2022-12-18-sql-tags-cache-merge.sql8
-rw-r--r--util/updates/2022-12-19-sql-traits-chars-cache-merge.sql5
-rw-r--r--util/updates/2022-12-19-sql-unique-null-not-distinct.sql12
-rw-r--r--util/updates/2023-01-08-cherokee-language.sql1
-rw-r--r--util/updates/2023-01-17-api2-listwrite.sql3
-rw-r--r--util/updates/2023-01-19-delete-admin-setpass.sql1
-rw-r--r--util/updates/2023-02-01-sql-titleprefs.sql67
-rw-r--r--util/updates/2023-02-02-sql-titleprefs.sql5
-rw-r--r--util/updates/2023-02-04-producerst.sql15
-rw-r--r--util/updates/2023-02-19-title-langs.sql5
-rw-r--r--util/updates/2023-02-20-titleprefs-staff.sql18
-rw-r--r--util/updates/2023-02-21-tt-prefs.sql7
-rw-r--r--util/updates/2023-03-09-chars-lang.sql10
-rw-r--r--util/updates/2023-03-09b-chars-titleprefs.sql14
-rw-r--r--util/updates/2023-03-20-producer-name-swap.sql13
-rw-r--r--util/updates/2023-03-20b-chars-name-swap.sql12
-rw-r--r--util/updates/2023-03-20c-staff-name-swap.sql14
-rw-r--r--util/updates/2023-03-24-search-cache.sql44
-rw-r--r--util/updates/2023-04-03-extlinks-booth.sql57
-rw-r--r--util/updates/2023-04-05-extlinks-patreon-substar.sql102
-rw-r--r--util/updates/2023-04-19-images-uploader.sql29
-rw-r--r--util/updates/2023-04-19-jastusa-shoplinks.sql8
-rw-r--r--util/updates/2023-05-03-sql-noquote.sql23
-rw-r--r--util/updates/2023-06-19-tags-vn-direct-count.sql4
-rw-r--r--util/updates/2023-07-11-vn-rating.sql8
-rw-r--r--util/updates/2023-09-15-quotes-rand.sql37
-rw-r--r--util/updates/2023-09-17-wikidata-props.sql3
-rw-r--r--util/updates/2023-09-21-reset-throttle.sql5
-rw-r--r--util/updates/2023-10-14-drm.sql7
-rw-r--r--util/updates/2023-12-03-staff-aid.sql11
-rw-r--r--util/updates/2023-12-03-staff-extlinks.sql24
-rw-r--r--util/updates/2024-02-23-quotes.sql89
-rw-r--r--util/updates/2024-02-26-quotes-adjustments.sql12
-rw-r--r--util/updates/2024-03-01-reports-log.sql14
-rw-r--r--util/updates/2024-03-08-belarusian-language.sql1
-rw-r--r--util/updates/2024-03-14-sql-email-normalization.sql9
-rw-r--r--util/updates/2024-03-20-account-softdelete.sql11
-rw-r--r--util/updates/2024-03-22-delayed-account-deletion.sql4
-rw-r--r--util/updates/README.md51
-rwxr-xr-xutil/vndb-dev-server.pl10
-rwxr-xr-xutil/vndb.pl147
802 files changed, 42788 insertions, 22288 deletions
diff --git a/.gitignore b/.gitignore
index 53652ecf..ca825c86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,40 +1,9 @@
-/data/conf.pl
-/data/docker-pg
-/data/icons/icons.css
-/data/log/
-/data/multi.pid
-/data/passwords.dat
-/elm/elm-stuff/
-/elm/Gen/
-/elm3/elm-stuff/
-/elm3/Lib/Gen.elm
-/static/f/js/
-/static/f/icons.png
-/static/f/icons.opt.png
-/static/f/vndb.js
-/static/f/vndb.min.js
-/static/f/vndb.min.js.gz
-/static/f/v2rw.js
-/static/f/v2rw.min.js
-/static/f/v2rw.min.js.gz
-/static/feeds/
-/static/s/*/style.css
-/static/s/*/style.min.css
-/static/s/*/style.min.css.gz
-/static/s/*/boxbg.png
-/static/v3/elm.js
-/static/v3/elm-opt.js
-/static/v3/min.js
-/static/v3/min.js.gz
-/static/v3/min.css
-/static/v3/min.css.gz
-/static/v3/style.css
-/static/ch
-/static/cv
-/static/sf
-/static/st
-/static/robots.txt
-/static/api
-/util/sql/editfunc.sql
-/www/
+/docker/
+/gen/
+/var/
*.swp
+*.o
+*.so
+*.dll
+*.bc
+*~
diff --git a/Dockerfile b/Dockerfile
index 855c9411..9738ccc5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,34 +1,37 @@
-FROM alpine:3.11
-MAINTAINER Yoran Heling <contact@vndb.org>
+FROM alpine:3.17
+MAINTAINER Yorhel <contact@vndb.org>
-ENV VNDB_DOCKER_VERSION=1
-CMD /var/www/util/docker-init.sh
+ENV VNDB_DOCKER_VERSION=14
+ENV VNDB_GEN=/vndb/docker/gen
+ENV VNDB_VAR=/vndb/docker/var
+CMD /vndb/util/docker-init.sh
RUN apk add --no-cache \
build-base \
curl \
git \
graphviz \
- imagemagick \
- imagemagick-perlmagick \
+ vips-dev \
+ perl-algorithm-diff-xs \
perl-anyevent \
perl-app-cpanminus \
perl-dbd-pg \
perl-dev \
+ perl-http-server-simple \
perl-json-xs \
perl-module-build \
- perl-xml-parser \
postgresql \
+ postgresql-contrib \
postgresql-dev \
+ sassc \
+ wget \
zlib-dev \
&& cpanm -nq \
- Algorithm::Diff::XS \
AnyEvent::HTTP \
AnyEvent::IRC \
AnyEvent::Pg \
Crypt::ScryptKDF \
Crypt::URandom \
- HTTP::Server::Simple \
PerlIO::gzip \
SQL::Interp \
Text::MultiMarkdown \
diff --git a/Makefile b/Makefile
index 60f36ed2..6184d3e9 100644
--- a/Makefile
+++ b/Makefile
@@ -3,182 +3,277 @@
#
# prod
# Create static assets for production. Requires the following additional dependencies:
-# - CSS::Minifier::XS
# - uglifyjs
# - zopfli
+# - zopflipng
+# - brotli
+# - pandoc
#
-# chmod
-# For when the http process is run from a different user than the files are
-# chown'ed to. chmods all files and directories written to from vndb.pl.
-#
-# multi-start, multi-stop, multi-restart:
-# Start/stop/restart the Multi daemon. Provided for convenience, a proper initscript
-# probably makes more sense.
-#
-# NOTE: This Makefile has only been tested using a recent version of GNU make
-# in a relatively up-to-date Arch/Gentoo Linux environment, and may not work in
-# other environments. Patches to improve the portability are always welcome.
-
-
-.PHONY: all prod chmod multi-stop multi-start multi-restart
-
-ALL_KEEP=\
- static/ch static/cv static/sf static/st \
- data/log static/f www www/feeds www/api \
- data/conf.pl \
- www/robots.txt static/robots.txt
-
-ALL_CLEAN=\
- static/f/vndb.js \
- static/f/v2rw.js \
- data/icons/icons.css \
- util/sql/editfunc.sql \
- $(shell ls static/s | sed -e 's/\(.\+\)/static\/s\/\1\/style.css/g')
-
-PROD=\
- static/f/vndb.min.js static/f/vndb.min.js.gz \
- static/f/v2rw.min.js static/f/v2rw.min.js.gz \
- static/f/icons.opt.png \
- $(shell ls static/s | sed -e 's/\(.\+\)/static\/s\/\1\/style.min.css/g') \
- $(shell ls static/s | sed -e 's/\(.\+\)/static\/s\/\1\/style.min.css.gz/g')
-
-all: ${ALL_KEEP} ${ALL_CLEAN}
-prod: all ${PROD}
+# test
+# Run the few unit tests that we do have.
+
+.PHONY: all prod clean test multi-stop multi-start multi-restart
+.DELETE_ON_ERROR:
+
+VNDB_GEN ?= gen
+export VNDB_GEN
+GEN=${VNDB_GEN}
+
+CFLAGS ?= -O3 -Wall
+
+ifdef V
+Q=
+T=@\#
+E=@\#
+else
+Q=@
+E=@echo
+T=@printf "%s $@\n"
+endif
+
+JS_OUT=$(patsubst js/%/index.js,${GEN}/static/%.js,$(wildcard js/*/index.js))
+CSS_OUT=$(patsubst css/skins/%.sass,${GEN}/static/%.css,$(wildcard css/skins/*.sass))
+
+all: \
+ ${GEN}/editfunc.sql \
+ ${GEN}/static/icons.svg \
+ ${GEN}/static/icons.png \
+ ${GEN}/static/elm.js \
+ ${GEN}/imgproc \
+ ${JS_OUT} \
+ ${CSS_OUT}
+
+prod: all \
+ ${GEN}/api-nyan.html ${GEN}/api-kana.html \
+ ${GEN}/static/icons.svg.gz ${GEN}/static/icons.svg.br \
+ ${GEN}/static/icons.opt.png \
+ ${GEN}/static/elm.min.js ${GEN}/static/elm.min.js.gz ${GEN}/static/elm.min.js.br \
+ ${JS_OUT:js=min.js} ${JS_OUT:js=min.js} \
+ ${JS_OUT:js=min.js.gz} ${JS_OUT:js=min.js.gz} \
+ ${JS_OUT:js=min.js.br} ${JS_OUT:js=min.js.br} \
+ ${CSS_OUT:css=css.gz} ${CSS_OUT:css=css.br}
clean:
- rm -f ${ALL_CLEAN} ${PROD}
- rm -f static/f/icons.png
- rm -rf elm/Gen/
- rm -rf elm/elm-stuff/build-artifacts
-
-cleaner: clean
- rm -rf elm/elm-stuff
-
-util/sql/editfunc.sql: util/sqleditfunc.pl util/sql/schema.sql
- util/sqleditfunc.pl
-
-static/ch static/cv static/sf static/st:
- mkdir -p $@;
- for i in $$(seq -w 0 1 99); do mkdir -p "$@/$$i"; done
-
-data/log www www/feeds www/api static/f:
- mkdir -p $@
-
-data/conf.pl:
- cp -n data/conf_example.pl data/conf.pl
-
-%/robots.txt: | www
- echo 'User-agent: *' > $@
- echo 'Disallow: /' >> $@
+ rm -rf "${GEN}"
%.gz: %
zopfli $<
-chmod: all
- chmod -R a-x+rwX static/{ch,cv,sf,st}
-
-
-
-# v2 & v2-rw
-
-data/icons/icons.css static/f/icons.png: data/icons/*.png data/icons/*/*.png util/spritegen.pl | static/f
- util/spritegen.pl
-static/f/icons.png: data/icons/icons.css
-
-static/f/icons.opt.png: static/f/icons.png
- zopflipng -m --lossy_transparent $< $@
+%.br: %
+ brotli -f $<
+ @touch $@
-static/s/%/style.css: static/s/%/conf util/skingen.pl data/style.css data/icons/icons.css
- util/skingen.pl $*
-
-static/s/%/style.min.css: static/s/%/style.css
- perl -MCSS::Minifier::XS -e 'undef $$/; print CSS::Minifier::XS::minify(scalar <>)' <$< >$@
-
-
-
-# v2
-
-static/f/vndb.js: data/js/*.js lib/VNDB/Types.pm util/jsgen.pl data/conf.pl | static/f
- util/jsgen.pl
-
-static/f/vndb.min.js: static/f/vndb.js
- uglifyjs $< --compress --mangle --comments '/(@license|@source|SPDX-)/' -o $@
-
-
-
-# v2-rw
+${GEN} ${GEN}/static ${GEN}/js ${GEN}/elm/Gen:
+ mkdir -p $@
-# Order of JS files matters, so we read an '//order:x' comment from the files and sort by that.
-# Files without that comment are assumed to have '//order:4'.
-# (This trick will not work if we ever add JS files generated by this Makefile)
-JS_FILES=$(shell find elm \! -path 'elm/elm-stuff/*' -name '*.js' -exec sh -c "echo \`grep -Eo '^// *order: *[0-9]+' \"{}\" || echo 4\` \"{}\"" \; | sed -E 's/\/\/ *order: *//' | sort | sed 's/..//')
-ELM_FILES=elm/*.elm elm/*/*.elm
+${GEN}/editfunc.sql: util/sqleditfunc.pl sql/schema.sql | ${GEN}
+ util/sqleditfunc.pl >$@
+
+${GEN}/api-%.html: api-%.md | ${GEN}
+ $T DOC
+ $Q pandoc "$<" -st html5 --toc -o "$@"
+
+test: all
+ prove util/test/bbcode.pl
+ if [ -e ${GEN}/imgproc-custom ]; then util/test/imgproc-custom.pl; fi
+
+
+
+
+###### Icons & CSS #####
+
+# Single rule for svg & png sprites. This uses a GNU multiple pattern rule in
+# order to have it parallelize correctly - splitting this up into two
+# individual rules is buggy.
+${GEN}/%.css ${GEN}/static/icons.%: util/%sprite.pl icons icons/* icons/*/* | ${GEN}/static
+ $<
+
+${GEN}/static/png.css ${GEN}/static/icons.png: ${GEN}/imgproc
+
+${GEN}/static/icons.opt.png: ${GEN}/static/icons.png
+ $T PNGOPT
+ $Q zopflipng -ym --lossy_transparent "$<" "$@" >/dev/null
+
+${GEN}/static/%.css: css/skins/%.sass css/*.css ${GEN}/png.css ${GEN}/svg.css
+ $T SASS
+ $Q ( echo '$$png-version: "$(shell sha1sum ${GEN}/static/icons.png | head -c8)";'; \
+ echo '$$svg-version: "$(shell sha1sum ${GEN}/static/icons.svg | head -c8)";'; \
+ echo '@import "css/skins/$*";'; \
+ echo '@import "${GEN}/png";'; \
+ echo '@import "${GEN}/svg";'; \
+ ) | sassc --stdin -I. --style compressed >$@
+
+
+
+
+###### imgproc #####
+
+${GEN}/imgproc: util/imgproc.c
+ $T CC
+ $Q ${CC} ${CFLAGS} $< -DDISABLE_SECCOMP `pkg-config --cflags --libs vips` -o $@
+
+VIPS_VER := 8.15.1
+# TODO: switch to a proper release when it includes this commit
+JXL_VER := 5e7560d9e431b40159cf688b9d9be6c0f2e229a1
+
+VIPS_DIR := ${GEN}/build/vips-${VIPS_VER}
+JXL_DIR := ${GEN}/build/libjxl-${JXL_VER}
+
+${GEN}/imgproc-custom: util/imgproc.c ${VIPS_DIR}/done Makefile
+ ${CC} ${CFLAGS} $< `pkg-config --cflags --libs libseccomp` `PKG_CONFIG_PATH="$(realpath ${GEN})/build/inst/lib64/pkgconfig" pkg-config --cflags --libs vips` -o $@
+ @# Make sure we're not accidentally linking against system libjpeg
+ ! ldd $@ | grep -q libjpeg
+
+# jpeg, jpgeg-xl and highway are provided by jxl
+${VIPS_DIR}/done: ${JXL_DIR}/done
+ mkdir -p ${VIPS_DIR}
+ curl -Ls https://github.com/libvips/libvips/releases/download/v${VIPS_VER}/vips-${VIPS_VER}.tar.xz | tar -C ${VIPS_DIR} --strip-components 1 -xJf-
+ PKG_CONFIG_PATH="$(realpath ${GEN})/build/inst/lib64/pkgconfig" meson \
+ setup --wipe --default-library=static --prefix="$(realpath ${GEN})/build/inst" \
+ -Dpng=enabled -Djpeg=enabled -Djpeg-xl=enabled -Dwebp=enabled -Dheif=enabled -Dlcms=enabled -Dhighway=enabled \
+ -Ddeprecated=false -Dexamples=false -Dcplusplus=false \
+ -Dmodules=disabled -Dintrospection=disabled -Dcfitsio=disabled -Dcgif=disabled \
+ -Dexif=disabled -Dfftw=disabled -Dfontconfig=disabled -Darchive=disabled \
+ -Dimagequant=disabled -Dmagick=disabled -Dmatio=disabled -Dnifti=disabled -Dopenjpeg=disabled \
+ -Dopenslide=disabled -Dorc=disabled -Dpangocairo=disabled \
+ -Dpdfium=disabled -Dpoppler=disabled -Dquantizr=disabled -Drsvg=disabled \
+ -Dspng=disabled -Dtiff=disabled -Dzlib=disabled \
+ -Dnsgif=false -Dppm=false -Danalyze=false -Dradiance=false \
+ ${VIPS_DIR}/build ${VIPS_DIR}
+ cd ${VIPS_DIR}/build && meson compile && meson install
+ touch $@
+
+${JXL_DIR}/done:
+ mkdir -p ${JXL_DIR}
+ @#curl -Ls https://github.com/libjxl/libjxl/archive/refs/tags/v${JXL_VER}.tar.gz | tar -C $@ --strip-components 1 -xzf-
+ curl -Ls https://github.com/libjxl/libjxl/tarball/${JXL_VER} | tar -C ${JXL_DIR} --strip-components 1 -xzf-
+ cd ${JXL_DIR} && ./deps.sh
+ @# there's no option to build a static jpegli, patch the cmake file instead
+ sed -i 's/add_library(jpeg SHARED/add_library(jpeg STATIC/' ${JXL_DIR}/lib/jpegli.cmake
+ cd ${JXL_DIR} && cmake -L \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DBUILD_TESTING=OFF \
+ -DBUILD_SHARED_LIBS=OFF \
+ -DCMAKE_INSTALL_PREFIX="$(realpath ${GEN})/build/inst" \
+ -DJPEGXL_ENABLE_BENCHMARK=OFF \
+ -DJPEGXL_ENABLE_DOXYGEN=OFF \
+ -DJPEGXL_ENABLE_EXAMPLES=OFF \
+ -DJPEGXL_ENABLE_FUZZERS=OFF \
+ -DJPEGXL_ENABLE_JNI=OFF \
+ -DJPEGXL_ENABLE_JPEGLI=ON \
+ -DJPEGXL_ENABLE_JPEGLI_LIBJPEG=ON \
+ -DJPEGXL_INSTALL_JPEGLI_LIBJPEG=OFF \
+ -DJPEGXL_ENABLE_MANPAGES=OFF \
+ -DJPEGXL_ENABLE_OPENEXR=OFF \
+ -DJPEGXL_ENABLE_PLUGINS=OFF \
+ -DJPEGXL_ENABLE_SJPEG=OFF \
+ -DJPEGXL_ENABLE_TOOLS=OFF .
+ cd ${JXL_DIR} && cmake --build . -- -j`nproc`
+ cd ${JXL_DIR} && cmake --install .
+ @# jxl doesn't install a libjpeg.pc
+ @# It doesn't even install a static libjpeg.a at all, so we'll just grab it from the build dir directly.
+ @( \
+ echo "Name: libjpeg"; \
+ echo "Description: Actually jpegli"; \
+ echo "Version: 1.0"; \
+ echo "Libs: -L$(realpath ${JXL_DIR})/lib -L$(realpath ${GEN})/build/inst/lib64 -ljpeg -ljpegli-static -lhwy -lm -lstdc++"; \
+ echo "Cflags: -I$(realpath ${JXL_DIR})/lib/include/jpegli" \
+ ) >${GEN}/build/inst/lib64/pkgconfig/libjpeg.pc
+ @# Additionally, pkg-config doesn't know we're linking these libs statically, so
+ @# make sure that libs in Requires.private are also included.
+ sed -i 's/Requires.private/Requires/' ${GEN}/build/inst/lib64/pkgconfig/*.pc
+ touch $@
+
+
+
+
+###### Elm #####
+
+ELM_FILES=elm/elm.json $(wildcard elm/*.elm elm/*/*.elm)
+ELM_CPFILES=${ELM_FILES:%=${GEN}/%}
ELM_MODULES=$(shell grep -l '^main =' ${ELM_FILES} | sed 's/^elm\///')
-# Patch the Javascript generated by Elm and append the $JS_FILES to it.
-# Patches include:
+# Patch the Javascript generated by Elm:
# - Add @license and @source comments
# - Redirect calls from Lib.Ffi.* to window.elmFfi_*
# - Patch the virtualdom diffing algorithm to always apply the 'selected' attribute
-define fix-js
- ( echo '// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only'; \
+# - Patch the Regex library to always enable the 'u' flag
+define fix-elm
+ $Q ( echo '// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only'; \
echo '// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause'; \
echo '// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/elm'; \
echo '// SPDX-License-Identifier: AGPL-3.0-only and BSD-3-Clause'; \
- cat $@ \
+ cat $@; \
+ echo; \
+ echo '// @license-end' \
) | sed 's/var \$$author\$$project\$$Lib\$$Ffi\$$/var __unused__/g' \
| sed -E 's/\$$author\$$project\$$Lib\$$Ffi\$$([a-zA-Z0-9_]+)/window.elmFfi_\1(_Json_wrap,_Browser_call)/g' \
- | sed -E "s/([^ ]+) !== 'checked'/\\1 !== 'checked' \&\& \\1 !== 'selected'/g" >$@~
- for fn in ${JS_FILES}; do \
- echo; \
- echo "(function(){'use strict'; /* $$fn */"; \
- cat $$fn; \
- echo "})();"; \
- done >>$@~
- echo '// @license-end' >>$@~
- mv $@~ $@
+ | sed -E "s/([^ ]+) !== 'checked'/\\1 !== 'checked' \&\& \\1 !== 'selected'/g" \
+ | sed -E "s/var flags = 'g'/var flags = 'gu'/g" >$@~
+ $Q mv $@~ $@
endef
-elm/Gen/.generated: lib/VNWeb/*.pm lib/VNWeb/*/*.pm lib/VNDB/Types.pm lib/VNDB/Config.pm data/conf.pl
- util/vndb.pl elmgen
+${ELM_CPFILES}: ${GEN}/%: %
+ $Q mkdir -p $(dir $@)
+ $Q cp $< $@
-static/f/v2rw.js: ${ELM_FILES} ${JS_FILES} elm/Gen/.generated | static/f
- cd elm && ELM_HOME=elm-stuff elm make ${ELM_MODULES} --output ../$@
- ${fix-js}
+${GEN}/elm/Gen/.generated: lib/VNWeb/*.pm lib/VNWeb/*/*.pm lib/VNDB/Types.pm lib/VNDB/ExtLinks.pm | ${GEN}/elm/Gen
+ util/vndb.pl elmgen
-static/f/v2rw.min.js: ${ELM_FILES} ${JS_FILES} elm/Gen/.generated | static/f
- cd elm && ELM_HOME=elm-stuff elm make --optimize ${ELM_MODULES} --output ../$@
- ${fix-js}
- uglifyjs $@ --comments '/(@license|@source|SPDX-)/' --compress \
+${GEN}/static/elm.js: ${ELM_CPFILES} ${GEN}/elm/Gen/.generated | ${GEN}/static
+ $T ELM
+ $Q cd ${GEN}/elm && ELM_HOME=elm-stuff elm make ${ELM_MODULES} --output ../static/elm.js >/dev/null
+ ${fix-elm}
+
+${GEN}/static/elm.min.js: ${ELM_CPFILES} ${GEN}/elm/Gen/.generated | ${GEN}/static
+ $T ELM
+ $Q cd ${GEN}/elm && ELM_HOME=elm-stuff elm make --optimize ${ELM_MODULES} --output ../static/elm.min.js >/dev/null
+ ${fix-elm}
+ $T MINIFY
+ $Q uglifyjs $@ --comments '/(@license|@source|SPDX-)/' --compress \
'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe'\
| uglifyjs --mangle --comments all -o $@~
- mv $@~ $@
+ $Q mv $@~ $@
-# Multi
-# may wait indefinitely, ^C and kill -9 in that case
-define multi-stop
- if [ -s data/multi.pid ]; then\
- kill `cat data/multi.pid`;\
- while [ -s data/multi.pid ]; do\
- if kill -0 `cat data/multi.pid`; then sleep 1;\
- else rm -f data/multi.pid; fi\
- done;\
- fi
-endef
+###### Javascript #####
-define multi-start
- util/multi.pl
-endef
+${GEN}/jsdeps.mk: js/*/index.js | ${GEN}
+ $E JSDEP
+ $Q for f in $(patsubst js/%/index.js,%,$(wildcard js/*/index.js)); do \
+ deps=$$(grep '^@include ' js/$$f/index.js | sed -e "s/@include / js\\/$$f\\//" -e "s/js\\/$$f\\/\.gen/\$${GEN}/" | tr -d '\n'); \
+ echo "\$${GEN}/static/$$f.js: js/$$f/index.js$$deps";echo; \
+ done >$@
+
+include ${GEN}/jsdeps.mk
+
+${GEN}/mithril.js:
+ $T FETCH
+ $Q curl -s 'https://code.blicky.net/yorhel/mithril-vndb/raw/branch/next/mithril.js' -o $@
+
+# TODO: Custom bundle with only the stuff we use
+${GEN}/d3.js:
+ $T FETCH
+ $Q curl -s 'https://d3js.org/d3.v7.min.js' -o $@
+
+${GEN}/types.js: util/jsgen.pl lib/VNDB/Types.pm lib/VNWeb/Validation.pm
+ util/jsgen.pl types >$@
+
+${GEN}/user.js: util/jsgen.pl lib/VNWeb/TimeZone.pm
+ util/jsgen.pl user >$@
-multi-stop:
- $(multi-stop)
+${GEN}/extlinks.js: util/jsgen.pl lib/VNDB/ExtLinks.pm
+ util/jsgen.pl extlinks >$@
-multi-start:
- $(multi-start)
+${JS_OUT}: ${GEN}/static/%.js: | ${GEN}/static
+ $T JS
+ $Q perl -Mautodie -pe 'if(/^\@include (.+)/) { #\
+ $$n=$$1; open F, $$n =~ m#^\.gen/# ? $$n =~ s#^\.gen/#$$ENV{VNDB_GEN}/#r : "js/$*/$$n"; #\
+ local$$/=undef; $$_="/* start of $$n */\n(()=>{\n".<F>."})();\n/* end of $$1 */\n\n" #\
+ }' js/$*/index.js >$@
-multi-restart:
- $(multi-stop)
- $(multi-start)
+${JS_OUT:js=min.js}: %.min.js: %.js
+ $T MINIFY
+ $Q uglifyjs $< --comments '/(@license|@source|SPDX-)/' --compress 'pure_getters,keep_fargs=false,unsafe_comps,unsafe' | uglifyjs --mangle --comments all -o $@
diff --git a/README.md b/README.md
index 61c76c0f..49b6fdb1 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,126 @@
# The VNDB.org Source Code
+## How to contribute
+
+First, a warning: VNDB's code base is a ~~little~~ *very* weird when compared
+to many other web projects, don't expect to be productive very fast or
+solutions to be very obvious. This is by design; VNDB's code is optimized so
+that **I** can reason about its reliability and performance while being
+productive. Also unlike many other open source software projects, don't expect
+me to hold your hand during the process. You're the one who wants to implement
+something, so you better be motivated to see it through.
+
+Second, another warning: don't send me a pull request out of the blue and
+expect me to merge it. Before you start coding, it's often best to open an
+issue to discuss what you want to do and how you plan to implement it. There's
+a good chance I already have some ideas on the topic. For larger and more
+impactful changes to the database schema or the UI, it's often best to discuss
+these on the [discussion board](https://vndb.org/t/db) first so everyone can
+chime in with ideas.
+
+
+## Directory layout
+
+css/
+: CSS files. The files in *css/skins/* are processed with *sassc* and bunbled
+ into a single minified CSS file for each skin.
+
+elm/
+: Front-end code written in [Elm](https://elm-lang.org/). These files are
+ compiled and bundled into a single minified *elm.js* file. Elm is on the
+ way out, though, and this code is slowly rewritten into plain Javascript.
+
+icons/
+: SVG & PNG icons that are merged into a *icons.svg* and *icons.png* sprite
+ file. See *icons/README.md* for more details.
+
+js/
+: Front-end code written in Javascript. See *js/README.md* for more details.
+
+lib/
+: This is where all the backend Perl code lives. Notable subdirectories:
+
+ Multi/
+ : Single-process event-based application that runs the old API and
+ various background services.
+
+ VNDB/
+ : General utility modules shared between *Multi*, *VNWeb* and some
+ tools in *util/*.
+
+ VNWeb/
+ : The VNDB website backend, this code makes heavy use of
+ [TUWF](https://dev.yorhel.nl/tuwf).
+
+sql/
+: PostgreSQL script files to initialize a fresh database schema with all
+ assorted tables, functions, indices and attributes. Most of these scripts
+ are idempotent and can also be used to load new features into an existing
+ database, but see the *util/updates/README.md* for more details.
+
+static/
+: Static assets. *static/s/* contains images used by CSS skins and
+ miscellaneous files go into *static/f/*.
+
+util/
+: Command-line utilities for various tasks. See *util/README.md* for details.
+
+With some exceptions, commands and scripts generally assume that they are run
+from this top-level source directory.
+
+Directories not in this source repository, but still very important:
+
+gen/ (or `$VNDB_GEN`)
+: This is where all build-time generated files go, such as optimized static
+ assets, compiled code and intermediate build artifacts. This is essentially
+ the output directory for everything created by the top-level `Makefile`.
+
+ This directory can be freely deleted at any time, it can be recreated with
+ `make`.
+
+ This directory can be changed by setting the `VNDB_GEN` environment
+ variable. Just be sure to have this variable set and pointed to the same
+ directory for every VNDB-related command you run. This variable and the
+ full path it points to must not contain any spaces since the Makefile can't
+ handle that.
+
+var/ (or `$VNDB_VAR`)
+: The directory for run-time managed files, such as configuration, logs and
+ uploaded images. This is also where you can store other site-specific
+ files. Additional public assets can be saved into *var/static/*.
+
+
## Quick and dirty setup using Docker
Setup:
```
- docker build -t vndb .
+docker build --progress=plain -t vndb .
```
Run (will run on the foreground):
```
- docker run -ti --name vndb -p 3000:3000 -v "`pwd`":/var/www --rm vndb
+docker run -ti --name vndb -p 3000:3000 -v "`pwd`":/vndb --rm vndb
```
If you need another terminal into the container while it's running:
```
- docker exec -ti vndb su -l devuser # development shell (files are at /var/www)
- docker exec -ti vndb psql -U vndb # postgres shell
+docker exec -ti vndb su -l devuser # development shell (files are at /vndb)
+docker exec -ti vndb psql -U vndb # postgres shell
```
To start Multi, the optional application server:
```
- docker exec -ti vndb su -l devuser -c 'make -C /var/www multi-restart'
+docker exec -ti vndb su -l devuser -c /vndb/util/multi.pl
```
-It will run in the background for as long as the container is alive. Logs are
-written to `data/log/multi.log`.
-
-The PostgreSQL database will be stored in `data/docker-pg/` and the uploaded
-files in `static/{ch,cv,sf,st}`. If you want to restart with a clean slate, you
-can stop the container and run:
-
-```
- # Might want to make a backup of these dirs first if you have any interesting data.
- rm -rf data/docker-pg static/{ch,cv,sf,st}
-```
+All data is stored in the *docker/* directory. The `$VNDB_GEN` and `$VNDB_VAR`
+environment variables inside the container point into this directory and the
+PostgreSQL data files are also in there. If you want to restart with a clean
+slate, you can stop the container and delete or rename that directory.
## Requirements (when not using Docker)
@@ -45,18 +128,22 @@ can stop the container and run:
Global requirements:
- Linux, or an OS that resembles Linux. Chances are VNDB won't run on Windows.
-- PostgreSQL 10 (older versions may work)
-- Perl 5.26+
+- A standard C build system (GNU make, gcc/clang, etc)
+- PostgreSQL 15+ (including development files)
+- Perl 5.28+
- Elm 0.19.1
+- Graphviz
+- libvips
+- sassc
**Perl modules** (core modules are not listed):
General:
+- AnyEvent
- Crypt::ScryptKDF
- Crypt::URandom
- DBD::Pg
- DBI
-- Image::Magick
- JSON::XS
- PerlIO::gzip
@@ -68,12 +155,9 @@ util/vndb.pl (the web backend):
- HTTP::Server::Simple
util/multi.pl (application server, optional):
-- AnyEvent
- AnyEvent::HTTP
- AnyEvent::IRC
- AnyEvent::Pg
-- XML::Parser
-- graphviz (/usr/bin/dot is used by default)
## Manual setup
@@ -84,84 +168,117 @@ util/multi.pl (application server, optional):
- Run the build system:
```
- make
+make -j8
```
-- Setup a PostgreSQL server and make sure you can login with some admin user
-- Initialize the VNDB database (assuming 'postgres' is a superuser):
+- Initialize your *var/* directory:
```
- # Create the database & roles
- psql -U postgres -f util/sql/superuser_init.sql
+util/setup-var.sh
+```
- # Set a password for each database role:
- echo "ALTER ROLE vndb LOGIN PASSWORD 'pwd1'" | psql -U postgres
- echo "ALTER ROLE vndb_site LOGIN PASSWORD 'pwd2'" | psql -U postgres
- echo "ALTER ROLE vndb_multi LOGIN PASSWORD 'pwd3'" | psql -U postgres
+- Setup a PostgreSQL server and make sure you can login with some admin user
+- Build the *vndbfuncs* PostgreSQL library:
- # OPTION 1: Create an empty database:
- psql -U vndb -f util/sql/all.sql
+```
+make -C sql/c
+```
- # OPTION 2: Import the development database (https://vndb.org/d8#3):
- curl -L https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -xzf-
- psql -U vndb -f dump.sql
- rm dump.sql
+- Copy *sql/c/vndbfuncs.so* to the appropriate directory (either run
+ `sudo make -C sql/c install` or see `pg_config --pkglibdir` or
+ `SHOW dynamic_library_path`)
+- Initialize the VNDB database (assuming 'postgres' is a superuser):
+
+```
+# Create the database & roles
+psql -U postgres -f sql/superuser_init.sql
+psql -U postgres vndb -f sql/vndbid.sql
+
+# Set a password for each database role:
+echo "ALTER ROLE vndb LOGIN PASSWORD 'pwd1'" | psql -U postgres
+echo "ALTER ROLE vndb_site LOGIN PASSWORD 'pwd2'" | psql -U postgres
+echo "ALTER ROLE vndb_multi LOGIN PASSWORD 'pwd3'" | psql -U postgres
+
+# OPTION 1: Create an empty database:
+psql -U vndb -f sql/all.sql
+
+# OPTION 2: Import the development database (https://vndb.org/d8#3):
+curl -L https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -C var -xzf-
+psql -U vndb -f var/dump.sql
+rm var/dump.sql
```
-- Update `data/conf.pl` with the proper credentials for *vndb_site* and
+- Update *var/conf.pl* with the proper credentials for *vndb_site* and
*vndb_multi*.
- Now simply run:
```
- util/vndb-dev-server.pl
+util/vndb-dev-server.pl
```
- (Optional) To start Multi, the application server:
```
- make multi-restart
+make multi-restart
```
-# Rewrites, rewrites, rewrites
-
-The VNDB website is currently (like every project beyond a certain age) in a
-transitional state of rewrites. There are three "versions" and coding styles
-across this repository:
-
-**Version 2**
-
-This is the code that powers the actual website. It lives in `lib/VNDB/` and
-has `util/vndb.pl` as entry point. Front-end assets are in `data/js/`,
-`data/style.css`, `data/icons/`, `static/f/` and `static/s/`.
-
-**Version 2-rw**
+## Production Deployment
-This is a (recently started) backend rewrite of version 2. It lives in
-`lib/VNWeb/` with Elm and Javascript code in `elm/`. Individual parts of the
-website are gradually being moved into this new coding style and structure.
-Version 2 and 2-rw run side-by-side in the same process and share a common
-route table and database connection, so the entry point is still
-`util/vndb.pl`. The primary goal of this rewrite is to make use of the clearer
-version 3 structure and to slowly migrate the brittle frontend Javascript parts
-to Elm and JSON APIs.
+The above instructions are suitable for a development environment. For a
+production environment, you'll really want to use FastCGI instead of the shitty
+built-in web server. Make sure you have the `FCGI` Perl module installed, then
+point a FastCGI-capable web server to *util/vndb.pl*. Apache (with
+`mod_fcgid`) and Lighttpd can be used for this, but my current setup is based
+on nginx. Since nginx does not come with a FastCGI process manager, I use
+[spawn-fcgi](https://git.lighttpd.net/lighttpd/spawn-fcgi) in combination with
+[multiwatch](https://git.lighttpd.net/lighttpd/multiwatch):
-**Version 3**
-
-There also used to be a "version 3" rewrite with a completely new user
-interface. All of the improvements developed in version 3 are slowly being
-backported and improved upon in version 2-rw and version 3 does not exist
-anymore (though it can still be found in the version history).
-
-**Non-rewrites**
-
-Some parts of this repository are not affected by these rewrites. These include
-the database structure, most of the scripts in `util/`, some common modules
-spread across `lib/` and Multi, which resides in `lib/Multi/`. That's not to
-say these are *final* or *stable*, but they're largely independent from the
-website code.
+```sh
+spawn-fcgi -s /tmp/vndb-fastcgi.sock -u vndb -g vndb -- \
+ /usr/bin/multiwatch -f 6 -r 10000 -s TERM /path/to/vndb/util/vndb.pl
+```
+There is a slow memory "leak" in the Perl backend, so you'll want to reload the
+vndb.pl processes once in a while. One way to do that is by setting
+`fastcgi_max_requests` in *var/conf.pl*, but it is also safe to reload the
+processes by running a `pkill vndb.pl` at any time.
+
+For optimized static assets, run `make prod` as part of your deployment
+procedure. This has some additional dependencies, see the Makefile for details.
+
+With the above taken care of, the nginx configuration for a single-domain setup
+looks something like this:
+
+```nginx
+map $uri $opt_asset {
+ ~^/(.+)\.png$ /$1.opt.png;
+ ~^/(.+)\.js$ /$1.min.js;
+ default $uri;
+}
+
+server {
+ ...
+
+ root /path/to/vndb;
+ expires 1y;
+ gzip_static on;
+ gzip_http_version 1.0;
+ brotli_static on;
+ try_files /var/static$uri /gen/static$opt_asset /gen/static$uri /static$uri @fcgi;
+
+ location @fcgi {
+ expires off;
+ include /etc/nginx/fastcgi_params;
+ # The following can be used to trick TUWF into thinking we're running on
+ # HTTPS, useful if this nginx instance is behind a reverse proxy that does
+ # the HTTPS termination.
+ #fastcgi_param HTTPS 1;
+ fastcgi_pass unix:/tmp/vndb-fastcgi.sock;
+ }
+}
+```
# License
-GNU AGPL, see COPYING file for details.
+AGPL-3.0-only, see COPYING file for details.
diff --git a/api-kana.md b/api-kana.md
new file mode 100644
index 00000000..dc66cc05
--- /dev/null
+++ b/api-kana.md
@@ -0,0 +1,1704 @@
+---
+title: VNDB.org API v2 (Kana)
+header-includes: |
+ <style>
+ body { max-width: 900px }
+ td { vertical-align: top }
+ header, header h1 { margin: 0 }
+ @media (min-width: 1100px) {
+ body { margin: 0 0 0 270px }
+ nav { box-sizing: border-box; position: fixed; padding: 50px 20px 10px 10px; top: 0; left: 0; height: 100%; overflow: scroll }
+ }
+ </style>
+---
+
+# Introduction
+
+This document describes the HTTPS API to query information from the
+[VNDB](https://vndb.org/) database and manage user lists.
+
+This version of the API is intended to replace the [old TCP-based
+API](https://vndb.org/d11), although the old API will likely remain available
+for the forseeable future.
+
+**Status**: Stable, but still missing some functionality.
+
+**API endpoint**: `%endpoint%`
+
+A sandbox endpoint is available for testing and development at
+[https://beta.vndb.org/api/kana](https://beta.vndb.org/api/kana), for more
+information see [the sandbox](https://beta.vndb.org/about-sandbox).
+
+# Usage Terms
+
+This service is free for non-commercial use. The API is provided on a
+best-effort basis, no guarantees are made about the stability or applicability
+of this service.
+
+The data obtained through this API is subject to our [Data
+License](https://vndb.org/d17#4).
+
+API access is rate-limited in order to keep server resources in check. The
+server will allow up to 200 requests per 5 minutes and up to 1 second of
+execution time per minute. Requests taking longer than 3 seconds will be
+aborted. These limits should be more than enough for most applications, but if
+this is still too limiting for you, don't hesitate to get in touch.
+
+This API intentionally does not expose *all* functionality provided by VNDB.
+Some site features, such as forums, database editing or account creation will
+not be exposed through the API, other features may be missing simply because
+nobody has asked for it yet. If you need anything not yet provided by the API
+or if you have any other questions, feel free to post on [the
+forums](https://vndb.org/t/db), [the issue
+tracker](https://code.blicky.net/yorhel/vndb/issues) or mail
+[contact@vndb.org](mailto:contact@vndb.org).
+
+
+# Common Data Types
+
+vndbid
+: A 'vndbid' is an identifier for an entry in the database, typically
+ formatted as a number with a one or two character prefix, e.g. "v17" refers
+ to [this visual novel](https://vndb.org/v17) and "sf190" refers to [this
+ screenshot](https://vndb.org/img/sf190).
+: The API will return vndbids as a JSON string, but the filters also accept
+ bare integers if the prefix is unambiguous from the context.
+
+release date
+: Release dates are represented as JSON strings as either `"YYYY-MM-DD"`,
+ `"YYYY-MM"` or `"YYYY"` formats, depending on whether the day and month are
+ known. Unspecified future dates are returned as `"TBA"`. The values
+ `"unknown"` and `"today"` are also supported in filters.
+: Partial dates are ordered *after* complete dates for the same year/month,
+ i.e. `"2022"` is ordered after `"2022-12"`, which in turn is ordered after
+ `"2022-12-31"`. This can be unintuitive when writing filters: `["released",
+ "<", "2022-01"]` also matches all complete dates in Jan 2022. Likewise,
+ `["released", "=", "2022"]` only matches items for which the release date
+ is exactly `"2022"`, not any other date in that year.
+
+enumeration types
+: Several fields in the database are represented as an integer or string with
+ a limited number of possible values. These values are either documented for
+ the particular field or listed separately in the [schema JSON](#get-schema).
+
+
+# User Authentication
+
+The majority of the API endpoints below are usable without any form of
+authentication, but some user-related actions - in particular, list management
+- require the calls to be authenticated with the respective VNDB user account.
+
+The API understands cookies originating from the main `vndb.org` domain, so
+user scripts running from the site only have to ensure that
+[XMLHttpRequest.withCredentials](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials)
+or [the Fetch API "credentials"
+parameter](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#sending_a_request_with_credentials_included)
+is set.
+
+In all other cases, token authentication should to be used. Users can obtain a
+token by opening their "My Profile" form and going to the "Applications" tab.
+The URL `https://vndb.org/u/tokens` can also be used to redirect users to this
+form. Tokens look like `xxxx-xxxxx-xxxxx-xxxx-xxxxx-xxxxx-xxxx`, with each `x`
+representing a lowercase z-base-32 character. The dashes in between are
+optional.
+
+Tokens may be included in API requests using the `Authorization` header with
+the `Token` type, for example:
+
+```
+Authorization: Token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk
+```
+
+A HTTP 401 error is returned if the token is invalid. The [GET
+/authinfo](#get-authinfo) endpoint can be used validate and extract information
+from tokens.
+
+
+# Simple Requests
+
+## GET /schema
+
+Returns a [JSON object](%endpoint%/schema) with metadata about several API
+objects, including enumeration values, which fields are available for querying
+and a list of supported external links. The JSON structure is hopefully
+self-explanatory.
+
+This information does not change very often and can safely be used for code
+generation or dynamic API introspection.
+
+## GET /stats
+
+Returns a few overall database statistics.
+
+`curl %endpoint%/stats`
+
+```json
+{
+ "chars": 112347,
+ "producers": 14789,
+ "releases": 91490,
+ "staff": 27929,
+ "tags": 2783,
+ "traits": 3115,
+ "vn": 36880
+}
+```
+
+## GET /user
+
+Lookup users by id or username. Accepts two query parameters:
+
+q
+: User ID or username to look up, can be given multiple times to look up
+ multiple users.
+
+fields
+: List of fields to select. The 'id' and 'username' fields are always
+ selected and should not be specified here.
+
+The response object contains one key for each given `q` parameter, its value is
+either `null` if no such user was found or otherwise an object with the
+following fields:
+
+id
+: String in `"u123"` format.
+
+username
+: String.
+
+lengthvotes
+: Integer, number of play time votes this user has submitted.
+
+lengthvotes\_sum
+: Integer, sum of the user's play time votes, in minutes.
+
+Strings that look like user IDs are not valid usernames, so the lookup is
+unambiguous. Usernames matching is case-insensitive.
+
+`curl '%endpoint%/user?q=NoUserWithThisNameExists&q=AYO&q=u3'`
+
+```json
+{
+ "AYO": {
+ "id": "u3",
+ "username": "ayo"
+ },
+ "NoUserWithThisNameExists": null,
+ "u3": {
+ "id": "u3",
+ "username": "ayo"
+ }
+}
+```
+
+`curl '%endpoint%/user?q=yorhel&fields=lengthvotes,lengthvotes_sum'`
+
+```json
+{
+ "yorhel": {
+ "id": "u2",
+ "lengthvotes": 9,
+ "lengthvotes_sum": 9685,
+ "username": "Yorhel"
+ }
+}
+```
+
+## GET /authinfo
+
+Validates and returns information about the given [API
+token](#user-authentication). The JSON object has the following members:
+
+id
+: String, user ID.
+
+username
+: String, username.
+
+permissions
+: Array of strings, permissions granted to this token.
+
+The following permissions are currently implemented:
+
+listread
+: Allows read access to private labels and entries in the user's visual novel
+ list.
+
+listwrite
+: Allows write access to the user's visual novel list.
+
+```sh
+curl %endpoint%/authinfo\
+ --header 'Authorization: token cdhy-bqy1q-6zobu-8w9k-xobxh-wzz4o-84fn'
+```
+
+```json
+{
+ "id": "u3",
+ "username": "ayo",
+ "permissions": [
+ "listread"
+ ]
+}
+```
+
+
+# Database Querying
+
+## API Structure
+
+Searching for and fetching database entries is done through a custom query
+format^[Yes, sorry, I know every API having its own query system sucks, but I
+couldn't find an existing solution that works well for VNDB.]. Queries are sent
+as `POST` requests, but I expect to also support the `QUERY` HTTP method once
+that gains more software support.
+
+### Query format
+
+A query is a JSON object that looks like this:
+
+```json
+{
+ "filters": [],
+ "fields": "",
+ "sort": "id",
+ "reverse": false,
+ "results": 10,
+ "page": 1,
+ "user": null,
+ "count": false,
+ "compact_filters": false,
+ "normalized_filters": false
+}
+```
+
+All members are optional, defaults are shown above.
+
+filters
+: Filters are used to determine which database items to fetch, see the
+ section on [Filters](#filters) below.
+
+fields
+: String. Comma-separated list of fields to fetch for each database item. Dot
+ notation can be used to select nested JSON objects, e.g. `"image.url"` will
+ select the `url` field inside the `image` object. Multiple nested fields
+ can be selected with brackets, e.g. `"image{id,url,dims}"` is equivalent to
+ `"image.id, image.url, image.dims"`.
+: Every field of interest must be explicitely mentioned, there is no support
+ for wildcard matching. The same applies to nested objects, it is an error
+ to list `image` without sub-fields in the example above.
+: The top-level `id` field is always selected by default and does not have to
+ be mentioned in this list.
+
+sort
+: Field to sort on. Supported values depend on the type of data being queried
+ and are documented separately.
+
+reverse
+: Set to true to sort in descending order.
+
+results
+: Number of results per page, max 100. Can also be set to `0` if you're not
+ interested in the results at all, but just want to verify your query or get
+ the `count`, `compact_filters` or `normalized_filters`.
+
+page
+: Page number to request, starting from 1. See also the [note on
+ pagination](#pagination) below.
+
+user
+: User ID. This field is mainly used for `POST /ulist`, but it also
+ sets the default user ID to use for the visual novel "label" filter.
+ Defaults to the currently authenticated user.
+
+count
+: Whether the response should include the `count` field (see below). This
+ option should be avoided when the count is not needed since it has a
+ considerable performance impact.
+
+compact\_filters
+: Whether the response should include the `compact_filters` field (see below).
+
+normalized\_filters
+: Whether the response should include the `normalized_filters` field (see below).
+
+
+### Response format
+
+```json
+{
+ "results": [],
+ "more": false,
+ "count": 1,
+ "compact_filters": "",
+ "normalized_filters": [],
+}
+```
+
+results
+: Array of objects representing the query results.
+
+more
+: When `true`, repeating the query with an incremented `page` number will
+ yield more results. This is a cheaper form of pagination than using the
+ `count` field.
+
+count
+: Only present if the query contained `"count":true`. Indicates the total
+ number of entries that matched the given filters.
+
+compact\_filters
+: Only present if the query contained `"compact_filters":true`. This is a
+ compact string representation of the filters given in the query.
+
+normalized\_filters
+: Only present if the query contained `"normalized_filters":true`. This is
+ a normalized JSON representation of the filters given in the query.
+
+### Filters
+
+Simple predicates are represented as a three-element JSON array containing a
+filter name, operator and value, e.g. `[ "id", "=", "v17" ]`. All filters
+accept the (in)equality operators `=` and `!=`. Filters that support ordering
+also accept `>=`, `>`, `<=` and `<`. The full list of accepted filter names
+and values is documented below for each type of database item.
+
+Simple predicates can be combined into larger queries with and/or predicates.
+These are represented as JSON arrays where the first element is either `"and"`
+or `"or"`, followed by two or more other predicates.
+
+Full example of a more complex visual novel filter (which, as of writing,
+doesn't actually match anything in the database):
+
+```json
+[ "and"
+, [ "or"
+ , [ "lang", "=", "en" ]
+ , [ "lang", "=", "de" ]
+ , [ "lang", "=", "fr" ]
+ ]
+, [ "olang", "!=", "ja" ]
+, [ "release", "=", [ "and"
+ , [ "released", ">=", "2020-01-01" ]
+ , [ "producer", "=", [ "id", "=", "p30" ] ]
+ ]
+ ]
+]
+```
+
+Besides the above JSON format, filters can also be represented as a more
+compact string. This representation is used in the URLs for the advanced search
+web interface^[Fun fact: the web interface also accepts filters in JSON form,
+but that tends to result in long and ugly URLs.] and is also accepted as value
+to the `"filters"` field. Since actually working with the compact string
+representation is kind of annoying, this API can convert between the two
+representations, so you can freely copy filters from the website to the API and
+the other way around.^[There is also a third representation for filters, which
+the API also accepts, but I won't bother you with that. It's only useful as an
+intermediate representation when converting between the JSON and string format,
+which you shouldn't be doing manually.]
+
+The compact representation of the above example is
+`"03132gen2gde2gfr3hjaN180272_0c2vQN6830u"` and can be seen in action in [the web
+UI](https://vndb.org/v?f=03132gen2gde2gfr3hjaN180272_0c2vQN6830u). The following
+command will convert that string back into the above JSON:
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "filters": "03132gen2gde2gfr3hjaN180272_0c2vQN6830u",
+ "normalized_filters": true
+}'
+```
+
+Note that the advanced search editing UI on the site does not support all
+filter types, for unsupported filters you will see an "Unrecognized filter"
+block. These are pretty harmless, the filter still works.
+
+#### Filter flags
+
+These flags are used in the documentation below to describe a few common filter
+properties.
+
+------------------------------------------------------------------------
+ Flag Description
+----- -----------------------------------------------------------------
+ o Ordering operators (such as `>` and `<`) can be used with this filter.
+
+ n This filter accepts `null` as value.
+
+ m A single entry can match multiple values. For example, a visual novel
+ available in both English and Japanese matches both `["lang","=","en"]`
+ and `["lang","=","ja"]`.
+
+ i Inverting or negating this filter (e.g. by changing the operator from
+ '=' to '!=' or from '>' to '<=') is not always equivalent to inverting
+ the selection of matching entries. This often means that the filter
+ implies another requirement (e.g. that the information must be known in
+ the first place), but the exact details depend on the filter.
+------------------------------------------------------------------------
+
+Be careful with applying boolean algebra to filters with the 'm' or 'i' flags,
+the results may be unintuitive. For example, searching for releases matching
+`["or",["minage","=",0],["minage","!=",0]]` will **not** find all releases in
+the database, but only those for which the `minage` field is known. Exact
+semantics regarding unknown or missing information often depends on how the
+filter is implemented and may be subject to change.
+
+## POST /vn
+
+Query visual novel entries.
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "filters": ["id", "=", "v17"],
+ "fields": "title, image.url"
+}'
+```
+
+Accepted values for `"sort"`: `id`, `title`, `released`, `rating`, `votecount`, `searchrank`.
+
+### Filters {#vn-filters}
+
+-----------------------------------------------------------------------------
+Name [F] Description
+---------------- ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search, matches on the VN titles, aliases and release titles.
+ The search algorithm is the same as used on the site.
+
+`lang` m Language availability.
+
+`olang` Original language.
+
+`platform` m Platform availability.
+
+`length` o Play time estimate, integer between 1 (Very short) and 5 (Very long).
+ This filter uses the length votes average when available but
+ falls back to the entries' `length` field when there are no votes.
+
+`released` o,n Release date.
+
+`rating` o,i Bayesian rating, integer between 10 and 100.
+
+`votecount` o Integer, number of votes.
+
+`has_description` Only accepts a single value, integer `1`.
+ Can of course still be negated with the `!=` operator.
+
+`has_anime` See `has_description`.
+
+`has_screenshot` See `has_description`.
+
+`has_review` See `has_description`.
+
+`devstatus` Development status, integer. See `devstatus` field.
+
+`tag` m Tags applied to this VN, also matches parent tags. See below for more details.
+
+`dtag` m Tags applied directly to this VN, does not match parent tags. See below for details.
+
+`anime_id` Integer, AniDB anime identifier.
+
+`label` m User labels applied to this VN. Accepts a two-element
+ array containing a user ID and label ID. When
+ authenticated or if the `"user"` request parameter has
+ been set, then it also accepts just a label ID.
+
+`release` m Match visual novels that have at least one release
+ matching the given [release filters](#release-filters).
+
+`character` m Match visual novels that have at least one character
+ matching the given [character filters](#character-filters).
+
+`staff` m Match visual novels that have at least one staff member
+ matching the given [staff filters](#staff-filters).
+
+`developer` m Match visual novels developed by the given [producer filters](#producer-filters).
+------------------------------------------------------------------------------
+
+The `tag` and `dtag` filters accept either a plain tag ID or a three-element
+array containing the tag ID, maximum spoiler level (0, 1 or 2) and minimum tag
+level (number between 0 and 3, inclusive), for example
+`["tag","=",["g505",2,1.2]]` matches all visual novels that have a [Donkan
+Protagonist](https://vndb.org/g505) with a vote of at least 1.2 at any spoiler
+level. If only an ID is given, `0` is assumed for both the spoiler and tag
+levels. For example, `["tag","=","g505"]` is equivalent to
+`["tag","=",["g505",0,0]]`.
+
+### Fields {#vn-fields}
+
+id
+: vndbid.
+
+title
+: String, main title as displayed on the site, typically romanized from the
+ original script.
+
+alttitle
+: String, can be null. Alternative title, typically the same as `title` but
+ in the original script.
+
+titles
+: Array of objects, full list of titles associated with the VN, always
+ contains at least one title.
+
+titles.lang
+: String, language. Each language appears at most once in the titles list.
+
+titles.title
+: String, title in the original script.
+
+titles.latin
+: String, can be null, romanized version of `title`.
+
+titles.official
+: Boolean.
+
+titles.main
+: Boolean, whether this is the "main" title for the visual novel entry.
+ Exactly one title has this flag set in the `titles` array and it's always
+ the title whose `lang` matches the VN's `olang` field. This field is
+ included for convenience, you can of course also use the `olang` field to
+ grab the main title.
+
+aliases
+: Array of strings, list of aliases.
+
+olang
+: String, language the VN has originally been written in.
+
+devstatus
+: Integer, development status. 0 meaning 'Finished', 1 is 'In development'
+ and 2 for 'Cancelled'.
+
+released
+: Release date, possibly null.
+
+languages
+: Array of strings, list of languages this VN is available in. Does not
+ include machine translations.
+
+platforms
+: Array of strings, list of platforms for which this VN is available.
+
+image
+: Object, can be null.
+
+image.id
+: String, image identifier.
+
+image.url
+: String.
+
+image.dims
+: Pixel dimensions of the image, array with two integer elements indicating
+ the width and height.
+
+image.sexual
+: Number between 0 and 2 (inclusive), average image flagging vote for sexual
+ content.
+
+image.violence
+: Number between 0 and 2 (inclusive), average image flagging vote for violence.
+
+image.votecount
+: Integer, number of image flagging votes.
+
+length
+: Integer, possibly null, rough length estimate of the VN between 1 (very
+ short) and 5 (very long). This field is only used as a fallback for when
+ there are no length votes, so you'll probably want to fetch
+ `length_minutes` too.
+
+length\_minutes
+: Integer, possibly null, average of user-submitted play times in minutes.
+
+length\_votes
+: Integer, number of submitted play times.
+
+description
+: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+
+rating
+: Number between 10 and 100, null if nobody voted.
+
+votecount
+: Integer, number of votes.
+
+screenshots
+: Array of objects, possibly empty.
+
+screenshots.\*
+: The above `image.*` fields are also available for screenshots.
+
+screenshots.thumbnail
+: String, URL to the thumbnail.
+
+screenshots.thumbnail\_dims
+: Pixel dimensions of the thumbnail, array with two integer elements.
+
+screenshots.release.\*
+: Release object. All [release fields](#release-fields) can be selected. It
+ is very common for all screenshots of a VN to be assigned to the same
+ release, so the fields you select here are likely to get duplicated several
+ times in the response. If you want to fetch more than just a few fields, it
+ is more efficient to only select `release.id` here and then grab detailed
+ release info with a separate request.
+
+relations
+: Array of objects, list of VNs directly related to this entry.
+
+relations.relation
+: String, relation type.
+
+relations.relation\_official
+: Boolean, whether this VN relation is official.
+
+relations.\*
+: All [visual novel fields](#vn-fields) can be selected here.
+
+tags
+: Array of objects, possibly empty. Only directly applied tags are returned,
+ parent tags are not included.
+
+tags.rating
+: Number, tag rating between 0 (exclusive) and 3 (inclusive).
+
+tags.spoiler
+: Integer, 0, 1 or 2, spoiler level.
+
+tags.lie
+: Boolean.
+
+tags.\*
+: All [tag fields](#tag-fields) can be used here. If you're fetching tags for
+ more than a single visual novel, it's usually more efficient to only select
+ `tags.id` here and then fetch (and cache) further tag information as a
+ separate request. Otherwise the same tag info may get duplicated many times
+ in the response.
+
+developers
+: Array of objects. The developers of a VN are all producers with a
+ "developer" role on a release linked to the VN. You can get this same
+ information by fetching all relevant release entries, but if all you need
+ is the list of developers then querying this field is faster.
+
+developers.\*
+: All [producer fields](#producer-fields) can be used here.
+
+editions
+: Array of objects, possibly empty.
+
+editions.eid
+: Integer, edition identifier. This identifier is local to the
+ visual novel and not stable across edits of the VN entry, it's only used
+ for organizing the staff listing (see below) and has no meaning beyond
+ that. But this is subject to change in the future.
+
+editions.lang
+: String, possibly null, language.
+
+editions.name
+: String, English name / label identifying this edition.
+
+editions.official
+: Boolean.
+
+staff
+: Array of objects, possibly empty.
+
+staff.eid
+: Integer, edition identifier or *null* when the staff has worked on the
+ "original" version of the visual novel.
+
+staff.role
+: String, see `enums.staff_role` in the [schema JSON](#get-schema) for
+ possible values.
+
+staff.note
+: String, possibly null.
+
+staff.*
+: All [staff fields](#staff-fields) can be used here.
+
+*Currently missing from the old API: voice actors, anime relations and external
+links. Can add if there's interest.*
+
+
+## POST /release
+
+Accepted values for `"sort"`: `id`, `title`, `released`, `searchrank`.
+
+### Filters {#release-filters}
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search.
+
+`lang` m Match on available languages.
+
+`platform` m Match on available platforms.
+
+`released` o Release date.
+
+`resolution` o,i Match on the image resolution, in pixels. Value must
+ be a two-element integer array to which the width and
+ height, respectively, are compared. For example,
+ `["resolution","<=",[640,480]]` matches releases with a
+ resolution smaller than or equal to 640x480.
+
+`resolution_aspect` o,i Same as the `resolution` filter, but additionally
+ requires that the aspect ratio matches that of the
+ given resolution.
+
+`minage` o,n,i Integer (0-18), age rating.
+
+`medium` m,n String.
+
+`voiced` n Integer, see `voiced` field.
+
+`engine` n String.
+
+`rtype` m String, see `vns.rtype` field. If this filter is used
+ when nested inside a visual novel filter, then this
+ matches the `rtype` of the particular visual novel.
+ Otherwise, this matches the `rtype` of any linked
+ visual novel.
+
+`extlink` m Match on external links, see below for details.
+
+`patch` Integer, only accepts the value `1`.
+
+`freeware` See `patch`.
+
+`uncensored` i See `patch`.
+
+`official` See `patch`.
+
+`has_ero` See `patch`.
+
+`vn` m Match releases that are linked to at least one visual novel
+ matching the given [visual novel filters](#vn-filters).
+
+`producer` m Match releases that have at least one producer
+ matching the given [producer filters](#producer-filters).
+-----------------------------------------------------------------------------
+
+The `extlink` filter can be used with three types of values:
+
+- Just a site name, e.g. `["extlink","=","steam"]` matches all releases that
+ have a steam ID.
+- A two-element array indicating the site name and the remote identifier, e.g.
+ `["extlink","=",["steam",702050]]` to match the Saya no Uta release on Steam.
+ The second element can be either an int or a string, depending on the site,
+ but integer identifiers are also accepted when formatted as a string.
+- A URL, e.g. `["extlink","=","https://store.steampowered.com/app/702050/"]` is
+ equivalent to the above example.
+
+In all of the above forms, an error is returned if the site is not known in the
+database or if the URL format is not recognized. The list of supported sites
+and URL formats tends to change over time, see [GET /schema](#get-schema) for
+the current list of supported sites.
+
+*Undocumented: animation*
+
+### Fields {#release-fields}
+
+id
+: vndbid.
+
+title
+: String, main title as displayed on the site, typically romanized from the
+ original script.
+
+alttitle
+: String, can be null. Alternative title, typically the same as `title` but
+ in the original script.
+
+languages
+: Array of objects, languages this release is available in. There is always
+ exactly one language that is considered the "main" language of this
+ release, which is only used to select the titles for the `title` and
+ `alttitle` fields.
+
+languages.lang
+: String, language. Each language appears at most once.
+
+languages.title
+: String, title in the original script. Can be null, in which case the title
+ for this language is the same as the "main" language.
+
+languages.latin
+: String, can be null, romanized version of `title`.
+
+languages.mtl
+: Boolean, whether this is a machine translation.
+
+languages.main
+: Boolean, whether this language is used to determine the "main" title for
+ the release entry.
+
+platforms
+: Array of strings.
+
+media
+: Array of objects.
+
+media.medium
+: String.
+
+media.qty
+: Integer, quantity. This is `0` for media where a quantity does not make
+ sense, like "internet download".
+
+vns
+: Array of objects, the list of visual novels this release is linked to.
+
+vns.rtype
+: The release type for this visual novel, can be `"trial"`, `"partial"` or
+ `"complete"`.
+
+vns.\*
+: All [visual novel fields](#vn-fields) are available.
+
+producers
+: Array of objects.
+
+producers.developer
+: Boolean.
+
+producers.publisher
+: Boolean.
+
+producers.\*
+: All [producer fields](#producer-fields) are available.
+
+released
+: Release date.
+
+minage
+: Integer, possibly null, age rating.
+
+patch
+: Boolean.
+
+freeware
+: Boolean.
+
+uncensored
+: Boolean, can be null.
+
+official
+: Boolean.
+
+has\_ero
+: Boolean.
+
+resolution
+: Can either be null, the string `"non-standard"` or an array of two integers
+ indicating the width and height.
+
+engine
+: String, possibly null.
+
+voiced
+: Int, possibly null, 1 = not voiced, 2 = only ero scenes voiced, 3 =
+ partially voiced, 4 = fully voiced.
+
+notes
+: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+
+gtin
+: JAN/EAN/UPC code, formatted as a string, possibly null.
+
+catalog
+: String, possibly null, catalog number.
+
+extlinks
+: Array, links to external websites. This list is equivalent to the links
+ displayed on the release pages on the site, so it may include redundant
+ entries (e.g. if a Steam ID is known, links to both Steam and SteamDB are
+ included) and links that are automatically fetched from external resources
+ (e.g. PlayAsia, for which a GTIN lookup is performed). These extra sites
+ are not listed in the `extlinks` list of [the schema](#get-schema).
+
+extlinks.url
+: String, URL.
+
+extlinks.label
+: String, English human-readable label for this link.
+
+extlinks.name
+: Internal identifier of the site, intended for applications that want to
+ localize the label or to parse/format/extract remote identifiers. Keep in
+ mind that the list of supported sites, their internal names and their ID
+ types are subject to change, but I'll try to keep things stable.
+
+extlinks.id
+: Remote identifier for this link. Not all sites have a sensible identifier
+ as part of their URL format, in such cases this field is simply equivalent
+ to the URL.
+
+*Missing: animation.*
+
+
+
+## POST /producer
+
+Accepted values for `"sort"`: `id`, `name`, `searchrank`.
+
+### Filters {#producer-filters}
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search.
+
+`lang` Language.
+
+`type` Producer type, see the `type` field below.
+-----------------------------------------------------------------------------
+
+### Fields {#producer-fields}
+
+id
+: vndbid.
+
+name
+: String.
+
+original
+: String, possibly null, name in the original script.
+
+aliases
+: Array of strings.
+
+lang
+: String, primary language.
+
+type
+: String, producer type, `"co"` for company, `"in"` for individual and `"ng"`
+ for amateur group.
+
+description
+: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+
+*Missing: External links, relations.*
+
+
+## POST /character
+
+Accepted values for `"sort"`: `id`, `name`, `searchrank`.
+
+### Filters {#character-filters}
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search.
+
+`role` m String, see `vns.role` field. If this filter is used
+ when nested inside a visual novel filter, then this
+ matches the `role` of the particular visual novel.
+ Otherwise, this matches the `role` of any linked
+ visual novel.
+
+`blood_type` String.
+
+`sex` String.
+
+`height` o,n,i Integer, cm.
+
+`weight` o,n,i Integer, kg.
+
+`bust` o,n,i Integer, cm.
+
+`waist` o,n,i Integer, cm.
+
+`hips` o,n,i Integer, cm.
+
+`cup` o,n,i String, cup size.
+
+`age` o,n,i Integer.
+
+`trait` m Traits applied to this character, also matches parent
+ traits. See below for more details.
+
+`dtrait` m Traits applied directly to this character, does not
+ match parent traits. See below for details.
+
+`birthday` n Array of two integers, month and day. Day may be `0`
+ to find characters whose birthday is in a given month.
+
+`seiyuu` m Match characters that are voiced by the matching
+ [staff filters](#staff-filters). Voice actor
+ information is actually specific to visual novels,
+ but this filter does not (currently) correlate
+ against the parent entry when nested inside a visual
+ novel filter.
+
+`vn` m Match characters linked to visual novels described by
+ [visual novel filters](#vn-filters).
+-----------------------------------------------------------------------------
+
+The `trait` and `dtrait` filters accept either a plain trait ID or a
+two-element array containing the trait ID and maximum spoiler level. These work
+similar to the tag filters for [visual novels](#vn-filters), except that traits
+don't have a rating.
+
+### Fields
+
+id
+: vndbid.
+
+name
+: String.
+
+original
+: String, possibly null, name in the original script.
+
+aliases
+: Array of strings.
+
+description
+: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+
+image.\*
+: Object, possibly null, same sub-fields as the `image` [visual novel field](#vn-fields).
+
+blood\_type
+: String, possibly null, `"a"`, `"b"`, `"ab"` or `"o"`.
+
+height
+: Integer, possibly null, cm.
+
+weight
+: Integer, possibly null, kg.
+
+bust
+: Integer, possibly null, cm.
+
+waist
+: Integer, possibly null, cm.
+
+hips
+: Integer, possibly null, cm.
+
+cup
+: String, possibly null, `"AAA"`, `"AA"`, or any single letter in the alphabet.
+
+age
+: Integer, possibly null, years.
+
+birthday
+: Possibly null, otherwise an array of two integers: month and day,
+ respectively.
+
+sex
+: Possibly null, otherwise an array of two strings: the character's apparent
+ (non-spoiler) sex and the character's real (spoiler) sex. Possible values
+ are `null`, `"m"`, `"f"` or `"b"` (meaning "both").
+
+vns
+: Array of objects, visual novels this character appears in. The same visual
+ novel may be listed multiple times with a different release; the spoiler
+ level and role can be different per release.
+
+vns.spoiler
+: Integer.
+
+vns.role
+: String, `"main"` for protagonist, `"primary"` for main characters, `"side"`
+ or `"appears"`.
+
+vns.\*
+: All [visual novel fields](#vn-fields) are available here.
+
+vns.release.\*
+: Object, usually null, specific release that this character appears in. All
+ [release fields](#release-fields) are available here.
+
+traits
+: Array of objects, possibly empty.
+
+traits.spoiler
+: Integer, 0, 1 or 2, spoiler level.
+
+traits.lie
+: Boolean.
+
+traits.\*
+: All [trait fields](#trait-fields) are available here.
+
+*Missing: instances, voice actor*
+
+
+## POST /staff
+
+Unlike other database entries, staff have more than one unique identifier.
+There is the main 'staff ID', which uniquely identifies a person and is what
+a staff page on the site represents.
+
+Additionally, every staff alias also has its own unique identifier, which is
+referenced from other database entries to identify which alias was used. This
+identifier is generally hidden on the site and aliases do not have their own
+page, but the IDs are exposed in this API in order to facilitate linking
+VNs/characters to staff names.
+
+This particular API queries staff *names*, not just staff *entries*, which
+means that a staff entry with multiple names can be included multiple times in
+the API results, once for each name they are known as. When searching or
+listing staff entries, this is usually what you want. When fetching more
+detailed information about specific staff entries, this is very much not what
+you want. The `ismain` filter can be used to remove this duplication and ensure
+you get at most one result per staff entry, for example:
+
+```sh
+curl %endpoint%/staff --header 'Content-Type: application/json' --data '{
+ "filters": ["and", ["ismain", "=", 1], ["id", "=", "s81"] ],
+ "fields": "lang,aliases{name,latin,ismain},description,extlinks{url,label}"
+}'
+```
+
+Accepted values for `"sort"`: `id`, `name`, `searchrank`.
+
+### Filters {#staff-filters}
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`aid` integer, alias identifier
+
+`search` m String search.
+
+`lang` Language.
+
+`gender` Gender.
+
+`role` m String, can either be `"seiyuu"` or one of the values
+ from `enums.staff_role` in the [schema JSON](#get-schema).
+ If this filter is used when nested inside a visual
+ novel filter, then this matches the `role` of the
+ particular visual novel. Otherwise, this matches the
+ `role` of any linked visual novel.
+
+`extlink` m Match on external links, works similar to the `exlink`
+ filter for [releases](#release-filters).
+
+`ismain` Only accepts a single value, integer `1`.
+-----------------------------------------------------------------------------
+
+### Fields {#staff-fields}
+
+id
+: vndbid.
+
+aid
+: Integer, alias id.
+
+ismain
+: Boolean, whether the 'name' and 'original' fields represent the main name
+ for this staff entry.
+
+name
+: String, possibly romanized name.
+
+original
+: String, possibly null, name in original script.
+
+lang
+: String, staff's primary language.
+
+gender
+: String, possibly null, `"m"` or `"f"`.
+
+description
+: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+
+extlinks
+: Array, links to external websites. Works the same as the 'extlinks'
+ [release field](#release-fields).
+
+aliases
+: Array, list of names used by this person.
+
+aliases.aid
+: Integer, alias id.
+
+aliases.name
+: String, name in original script.
+
+aliases.latin
+: String, possibly null, romanized version of 'name'.
+
+aliases.ismain
+: Boolean, whether this alias is used as "main" name for the staff entry.
+
+
+## POST /tag
+
+Accepted values for `"sort"`: `id`, `name`, `vn_count`, `searchrank`.
+
+### Filters
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search.
+
+`category` String, see `category` field.
+-----------------------------------------------------------------------------
+
+### Fields {#tag-fields}
+
+id
+: vndbid.
+
+name
+: String.
+
+aliases
+: Array of strings.
+
+description
+: String, may contain [formatting codes](https://vndb.org/d9#4).
+
+category
+: String, `"cont"` for content, `"ero"` for sexual content and `"tech"` for technical tags.
+
+searchable
+: Bool.
+
+applicable
+: Bool.
+
+vn\_count
+: Integer, number of VNs this tag has been applied to, including any child tags.
+
+*Missing: some way to fetch parent/child tags. Not obvious how to do this
+efficiently because tags form a DAG rather than a tree.*
+
+
+## POST /trait
+
+Accepted values for `"sort"`: `id`, `name`, `char_count`, `searchrank`.
+
+### Filters
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search.
+-----------------------------------------------------------------------------
+
+### Fields {#trait-fields}
+
+id
+: vndbid
+
+name
+: String. Trait names are not necessarily self-describing, so they should
+ always be displayed together with their "group" (see below), which is the
+ top-level parent that the trait belongs to.
+
+aliases
+: Array of strings.
+
+description
+: String, may contain [formatting codes](https://vndb.org/d9#4).
+
+searchable
+: Bool.
+
+applicable
+: Bool.
+
+group\_id
+: vndbid
+
+group\_name
+: String
+
+char\_count
+: Integer, number of characters this trait has been applied to, including
+ child traits.
+
+
+
+# List Management
+
+## POST /ulist
+
+Fetch a user's list. This API is very much like `POST /vn`, except it requires
+the `"user"` parameter to be set and it has a different response structure. All
+[visual novel filters](#vn-filters) can be used here.
+
+If the user has visual novel entires on their list that have been deleted from
+the database, these will not be returned through the API even though they do
+show up on the website.
+
+Accepted values for `"sort"`: `id`, `title`, `released`, `rating`, `votecount`,
+`voted`, `vote`, `added`, `lastmod`, `started`, `finished`, `searchrank`.
+
+Very important example on how to fetch Yorhel's top 10 voted visual novels:
+
+```sh
+curl %endpoint%/ulist --header 'Content-Type: application/json' --data '{
+ "user": "u2",
+ "fields": "id, vote, vn.title",
+ "filters": [ "label", "=", 7 ],
+ "sort": "vote",
+ "reverse": true,
+ "results": 10
+}'
+```
+
+### Fields {#ulist-fields}
+
+id
+: Visual novel ID.
+
+added
+: Integer, unix timestamp.
+
+voted
+: Integer, can be null, unix timestamp of when the user voted on this VN.
+
+lastmod
+: Integer, unix timestamp when the user last modified their list for this VN.
+
+vote
+: Integer, can be null, 10 - 100.
+
+started
+: String, start date, can be null, "YYYY-MM-DD" format.
+
+finished
+: String, finish date, can be null.
+
+notes
+: String, can be null.
+
+labels
+: Array of objects, user labels assigned to this VN. Private labels are only
+ listed when the user is authenticated.
+
+labels.id
+: Integer.
+
+labels.label
+: String.
+
+vn\.*
+: Visual novel info, all [visual novel fields](#vn-fields) can be selected
+ here.
+
+releases
+: Array of objects, releases of this VN that the user has added to their list.
+
+releases.list\_status
+: Integer, 0 for "Unknown", 1 for "Pending", 2 for "Obtained", 3 for "On
+ loan", 4 for "Deleted".
+
+releases.\*
+: All [release fields](#release-fields) can be selected here.
+
+
+## GET /ulist\_labels
+
+Fetch the list labels for a certain user. Accepts two query parameters:
+
+user
+: The user ID to fetch the labels for. If the parameter is missing, the
+ labels for the currently authenticated user are fetched instead.
+
+fields
+: List of fields to select. Currently only `count` may be specified, the
+ other fields are always selected.
+
+Returns a JSON object with a single key, `"labels"`, which is an array of
+objects with the following members:
+
+id
+: Integer identifier of the label.
+
+private
+: Boolean, whether this label is private. Private labels are only included
+ when authenticated with the `listread` permission. The 'Voted' label (id=7)
+ is always included even when private.
+
+label
+: String.
+
+count
+: Integer. The 'Voted' label may have different counts depending on whether
+ the user has authenticated.
+
+Labels with an id below 10 are the pre-defined labels and are the same for
+everyone, though even pre-defined labels are excluded if they are marked
+private.
+
+Example: [Multi](https://vndb.org/u1) has only the default labels.
+
+```sh
+curl '%endpoint%/ulist_labels?user=u1'
+```
+
+## PATCH /ulist/\<id\>
+
+Add or update a visual novel in the user's list. Requires the `listwrite`
+permission. The JSON body accepts the following members:
+
+vote
+: Integer between 10 and 100.
+
+notes
+: String.
+
+started
+: Date.
+
+finished
+: Date.
+
+labels
+: Array of integers, label ids. Setting this will overwrite any existing
+ labels assigned to the VN with the given array.
+
+labels\_set
+: Array of label ids to add to the VN, any already existing labels will
+ be unaffected.
+
+labels\_unset
+: Array of label ids to remove from the VN.
+
+All members are be optional, missing members are not modified. A `null`
+value can be used to unset a field (except for labels).
+
+The virtual labels with id 0 ("No label") and 7 ("Voted") can not be set. The
+"voted" label is automatically added/removed based on the `vote` field.
+
+Wonky behavior alert: this API does not verify label ids and lets you add
+non-existent labels. These are not displayed on the website and not returned by
+[POST /ulist](#post-ulist), but they're still stored in the database and may
+magically show up if a label with that id is created in the future. Don't rely
+on this behavior, it's a bug.
+
+More wonky behavior: the website automatically unsets the other
+Playing/Finished/Stalled/Dropped labels when you select one of those, but this
+is not enforced server-side and the API lets you set all labels at the same
+time. This is totally not a bug.
+
+Example to remove the "Playing" label, add the "Finished" label and vote a 6:
+
+```sh
+curl -XPATCH %endpoint%/ulist/v17 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk' \
+ --header 'Content-Type: application/json' \
+ --data '{"labels_unset":[1],"labels_set":[2],"vote":60}'
+```
+
+Or to remove an existing vote without affecting any of the other fields:
+
+```sh
+curl -XPATCH %endpoint%/ulist/v17 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk' \
+ --header 'Content-Type: application/json' \
+ --data '{"vote":null}'
+```
+
+Slightly unintuitive behavior alert: this API *always* adds the visual novel to
+the user's list if it's not already present, and that also applies to the above
+"removing a vote" example. Use [DELETE](#delete-ulistid) if you want to remove
+a VN from the list.
+
+## PATCH /rlist/\<id\>
+
+Add or update a release in the user's list. Requires the `listwrite`
+permission. All visual novels linked to the release are also added to the
+user's visual novel list, if they aren't in the list yet. The JSON body
+accepts the following members:
+
+status
+: Release status, integer. See `releases.list_status` in the [POST /ulist
+ fields](#ulist-fields) for the list of possible values. Defaults to 0.
+
+Example, to mark `r12` as obtained:
+
+```sh
+curl -XPATCH %endpoint%/rlist/r12 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk' \
+ --header 'Content-Type: application/json' \
+ --data '{"status":2}'
+```
+
+## DELETE /ulist/\<id\>
+
+Remove a visual novel from the user's list. Returns success even if the VN is
+not on the user's list. Removing a VN also removes any associated releases from
+the user's list.
+
+```sh
+curl -XDELETE %endpoint%/ulist/v17 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk'
+```
+
+## DELETE /rlist/\<id\>
+
+Remove a release from the user's list. Returns success even if the release is
+not on the user's list. Removing a release does not remove the associated
+visual novels from the user's visual novel list, that requires separate calls
+to [DELETE /ulist/\<id\>](#delete-ulistid).
+
+```sh
+curl -XDELETE %endpoint%/rlist/r12 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk'
+```
+
+
+# HTTP Response Codes
+
+Successful responses always return either `200 OK` with a JSON body or `204 No
+Content` in the case of DELETE/PATCH requests, but errors may happen. Error
+response codes are typically followed with a `text/plain` or `text/html` body.
+The following is a non-exhaustive list of error codes you can expect to see:
+
+ Code Reason
+------ -------
+ 400 Invalid request body or query, the included error message hopefully points at the problem.
+ 401 Invalid authentication token.
+ 404 Invalid API path or HTTP method
+ 429 Throttled
+ 500 Server error, usually points to a bug if this persists
+ 502 Server is down, should be temporary
+
+# Tips & Troubleshooting
+
+## "Too much data selected"
+
+The server calculates a rough estimate of the number of JSON keys it would
+generate in response to your query and throws an error if that estimation
+exceeds a certain threshold, i.e. if the response is expected to be rather
+large. This estimation is entirely based on the `"fields"` and `"results"`
+parameters, so you can work around this error by either selecting fewer fields
+or fewer results.
+
+## List of identifiers
+
+If you have a (potentially large) list of database identifiers you'd like to
+fetch, it is faster and more efficient to fetch 100 entries in a single API
+call than it is to make 100 separate API calls. Simply create a filter
+containing the identifiers, like in the following example:
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "fields": "title",
+ "filters": ["or"
+ , ["id","=","v1"]
+ , ["id","=","v2"]
+ , ["id","=","v3"]
+ , ["id","=","v4"]
+ , ["id","=","v5"] ],
+ "results": 100
+}'
+```
+
+Do not add more than 100 identifiers in a single query. You'll especially want
+to avoid sending the same list of identifiers multiple times but with higher
+`"page"` numbers, see also the next point.
+
+## Pagination
+
+While the API supports pagination through the `"page"` parameter, this is often
+not the most efficient way to retrieve a large list of entries. Results are
+sorted on `"id"` by default so you can also implement pagination by filtering
+on this field. For example, if the last item you've received had id `"v123"`,
+you can fetch the next page by filtering on `["id",">","v123"]`.
+
+This approach tends to not work as well when sorting on other fields, so
+`"page"`-based pagination is often still the better solution in those cases.
+
+## Random entry
+
+Fetching a random entry from a database is, in general, pretty challenging to
+do in a performant way. Here's one approach that can be used with the API:
+first grab the highest database identifier, then select a random number between
+`1` and the highest identifier (both inclusive) and then fetch the entry with
+that or the nearest increasing id, e.g.:
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "sort": "id",
+ "reverse": true,
+ "results": 1
+}'
+```
+
+Then, assuming you've randomly chosen id `v4567`:
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "filters": [ "id", ">=", "v4567" ],
+ "fields": "title",
+ "results": 1
+}'
+```
+
+The result of the first query can be cached. Additional filters can be added to
+both queries if you want to narrow down the selection. This method has a slight
+bias in its selection due to the presence of id gaps, but you most likely don't
+need perfect uniform random selection anyway.
+
+# Change Log
+
+**2024-03-13**
+
+- Add [POST /staff](#post-staff).
+- Add `editions` and `staff` fields to [POST /vn](#post-vn).
+- Add `enums.staff_role` and `extlinks./staff` members to [GET /schema](#get-schema).
+
+**2023-11-20**
+
+- Add `relations` field to [POST /vn](#post-vn).
+
+**2023-08-02**
+
+- Add `developers` field to [POST /vn](#post-vn).
+
+**2023-07-11**
+
+- Deprecated `popularity` sort options for [POST /ulist](#post-ulist) and [POST
+ /vn](#post-vn), it's now equivalent to sorting on the reverse of `votecount`.
+- Deprecated `popularity` filter and field for [POST /vn](#post-vn).
+
+**2023-04-05**
+
+- Add `searchrank` sort option to all endpoints that have a `search` filter.
+
+**2023-03-19**
+
+- Add `voiced`, `gtin` and `catalog` fields to [POST /release](#post-release).
+
+**2023-01-17**
+
+- Add `listwrite` permission to API tokens.
+- Add [PATCH /ulist/\<id>](#patch-ulistid).
+- Add [PATCH /rlist/\<id>](#patch-rlistid).
+- Add [DELETE /ulist/\<id>](#delete-ulistid).
+- Add [DELETE /rlist/\<id>](#delete-rlistid).
+
+[F]: #filter-flags
diff --git a/api-nyan.md b/api-nyan.md
new file mode 100644
index 00000000..b6775326
--- /dev/null
+++ b/api-nyan.md
@@ -0,0 +1,975 @@
+---
+title: VNDB.org API v1 (Nyan)
+header-includes: |
+ <style>
+ body { max-width: 900px }
+ td { vertical-align: top }
+ header, header h1 { margin: 0 }
+ @media (min-width: 1100px) {
+ body { margin: 0 0 0 270px }
+ nav { box-sizing: border-box; position: fixed; padding: 50px 20px 10px 10px; top: 0; left: 0; height: 100%; overflow: scroll }
+ }
+ </style>
+---
+
+# Introduction
+
+This document describes the legacy TCP API of VNDB. Usage of this TCP API in
+new applications in discouraged, refer to the [new HTTPS
+API](https://api.vndb.org/kana) for a more modern replacement.
+
+**Usage terms**
+
+This service is free for non-commercial use. The API is provided on a
+best-effort basis, no guarantees are made about the stability or applicability
+of this service.
+
+The data obtained through this API is subject to our [Data
+License](https://vndb.org/d17#4).
+
+**Design goals**
+
+- Simple in implementation of both client and server. "Simple" here means that
+ it shouldn't take much code to write a secure and full implementation and
+ that client applications shouldn't require huge dependency trees just to use
+ this API.
+- Powerful: Not as powerful as raw SQL, but not as rigid as commonly used REST
+ or RPC protocols.
+- High-level: common applications need to perform only few actions to get what
+ they want.
+- Fast: minimal bandwidth overhead and simple and customizable queries.
+
+**Design overview**
+
+- TCP-based, all communication between the client and the server is done using
+ one TCP connection. This connection stays alive until it is explicitely
+ closed by either the client or the server.
+- Request/response, client sends a request and server replies with a response.
+- Session-based: clients are required to login before issuing commands to the
+ server. A session is created by issuing the 'login' command, this session
+ stays valid for the lifetime of the TCP connection.
+- **Everything** sent between the client and the server is encoded in UTF-8.
+
+**Limits**
+
+The following limits are enforced by the server, in order to limit the server
+resources and prevent abuse of this service.
+
+- 10 connections per IP. All connections that are opened after reaching this
+ limit will be immediately closed.
+- 200 commands per 10 minutes per ip. Server will reply with a 'throttled'
+ error (type="cmd") when reaching this limit.
+- 1 second of SQL time per minute per ip. SQL time is the total time taken to
+ run the database queries for each command. This depends on both the command
+ (filters and get flags) and server load, and is thus not very predictable.
+ Server will reply with a 'throttled' error with type="sql" upon reaching this
+ limit.
+- Each command returns at most 25 results, with the exception of get
+ votelist/vnlist/wishlist/ulist, which returns at most 100 results.
+
+These limits may sound strict, but in practice you won't have to worry much
+about it. As long as your application properly waits when the server replies
+with a "throttle" error, everything will be handled automatically. In the event
+that your application does require more resources, don't hesitate to ask.
+
+**Connection info:**
+
+Host
+: api.vndb.org
+
+Port (plain tcp)
+: 19534 ('VN')
+
+Port (TLS)
+: 19535
+: For improved security, make sure to verify that the certificate is valid for
+'api.vndb.org' and is signed by a trusted root (in particular, by [Let's
+Encrypt](https://letsencrypt.org/certificates/)).
+
+
+
+# Request/response syntax
+
+The VNDB API uses the JSON format for data in various places, this document
+assumes you are familiar with it. See
+[JSON.org](https://www.json.org/json-en.html) for a quick overview and [RFC
+4627](https://www.ietf.org/rfc/rfc4627.txt?number=4627) for the glory details.
+
+The words _object_, _array_, _value_, _string_, _number_ and _integer_ refer to
+the JSON data types. In addition the following definitions are used in this
+document:
+
+_request_ or _command_
+: Message sent from the client to the server.
+
+_response_
+: Message sent from the server to the client.
+
+_whitespace_
+: Any sequence of the following characters: space, tab, line feed and carriage
+return. (hexadecimal: 20, 09, 0A, 0D, respectively). This is in line with the
+definition of whitespace in the JSON specification.
+
+_date_
+: A _string_ signifying a date (in particular: release date). The following
+formats are used: "yyyy" (when day and month are unknown), "yyyy-mm" (when day
+is unknown) "yyyy-mm-dd", and "tba" (To Be Announced). If the year is not known
+and the date is not "tba", the special value **null** is used.
+
+**Message format**
+
+A message is formatted as a command or response name, followed by any number of
+arguments, followed by the End Of Transmission character (04 in hexadecimal).
+Arguments are separated by one or more whitespace characters, and any sequence
+of whitespace characters is allowed before and after the message.
+
+The command or response name is an unescaped string containing only lowercase
+alphabetical ASCII characters, and indicates what kind of command or response
+this message contains.
+
+An argument can either be an unescaped string (not containing whitespace), any
+JSON value, or a filter string. The following two examples demonstrate a
+'login' command, with an object as argument. Both messages are equivalent, as
+the whitespace is ignored. '0x04' is used to indicate the End Of Transmission
+character.
+
+```
+login {"protocol":1,"username":"ayo"}0x04
+```
+```
+login {
+ "protocol" : 1,
+ "username" : "ayo"
+}
+0x04
+```
+
+The 0x04 byte will be ommitted in the other examples in this document. It is
+however still required.
+
+**Filter string syntax**
+
+Some commands accept a filter string as argument. This argument is formatted
+similar to boolean expressions in most programming languages. A filter consists
+of one or more _expressions_, separated by the boolean operators "and" or "or"
+(lowercase). Each filter expression can be surrounded by parentheses to
+indicate precedence, the filter argument itself must be surrounded by
+parentheses.
+
+An _expression_ consists of a _field name_, followed by an _operator_ and a
+_value_. The field name must consist entirely of lowercase alphanumeric
+characters and can also contain an underscore. The operator must be one of the
+following characters: =, !=, <, <=, >, >= or ~. The _value_ can be any valid
+JSON value. Whitespace characters are allowed, but not required, between all
+expressions, field names, operators and values.
+
+The following two filters are equivalent:
+
+```
+ (title~"osananajimi"or(id=2))
+```
+```
+ (
+ id = 2
+ or
+ title ~ "osananajimi"
+ )
+```
+
+More complex filters are also possible:
+
+```
+ ((platforms = ["win", "ps2"] or languages = "ja") and released > "2009-01-10")
+```
+
+See the individual commands for more details.
+
+
+# The 'login' command
+```
+ login {"protocol":1,"client":"test","clientver":0.1,"username":"ayo","password":"hi-mi-tsu!"}
+```
+
+Every client is required to login before issuing other commands. The login
+command accepts a JSON object as argument. This object has the following
+members:
+
+protocol
+: An integer that indicates which protocol version the client implements. Must be 1.
+
+client
+: A string identifying the client application. Between the 3 and 50 characters,
+must contain only alphanumeric ASCII characters, space, underscore and hyphens.
+When writing a client, think of a funny (unique) name and hardcode it into your
+application.
+
+clientver
+: A number or string indicating the software version of the client.
+
+username
+: (optional) String containing the username of the person using the client.
+When this field is provided, the client must also provide either a "password"
+or "sessiontoken".
+
+password
+: (optional) String, password of that user in plain text.
+
+sessiontoken
+: (optional) String, to log in with a session token instead of password.
+
+createsession
+: (optional) Boolean, only available when logging in with a password. This will
+create a new session token so that future logins can be done with the
+"sessiontoken" field instead of providing a password.
+
+The server can reply with one of the following responses:
+
+ok
+: No arguments, returned when the login command is successful and
+"createsession" was not specified.
+
+session
+: Returned when the login is successful and the "createsession" field was
+specified. The response has one argument: the session token encoded as a hex
+string. The token will automatically expire one month after its last use, when
+the 'logout' command is used (see below) or when the user changes their
+password.
+
+error
+: Login failed, see below for error codes.
+
+Note that logging in using a username is optional, but some commands are only
+available when logged in. It is strongly recommended to connect with TLS when
+logging into an account.
+
+Example login request and response without authentication:
+
+```
+ login {"protocol":1,"client":"Awesome Client","clientver":"1.0"}
+```
+```
+ ok
+```
+
+Example login to obtain a session token:
+
+```
+ login {"protocol":1,"client":"Awesome Client","clientver":"1.0","username":"ayo","password":"xyz","createsession":true}
+```
+```
+ session df0cc97e1f0c9f1d59ab67d2be3bb1d437892505
+```
+
+Later connections can use that token to log in:
+
+```
+ login {"protocol":1,"client":"Awesome Client","clientver":"1.0","username":"ayo","sessiontoken":"df0cc97e1f0c9f1d59ab67d2be3bb1d437892505"}
+```
+```
+ ok
+```
+
+## logout
+
+When logged in with a session (either by specifying "createsession" or
+"sessiontoken" in the login command), the client can invalidate the token
+associated with the session by sending the 'logout' command without arguments:
+
+```
+ logout
+```
+
+The server will respond with 'ok' and disconnect.
+
+
+# The 'dbstats' command
+
+This command gives the global database statistics that are visible in the main
+menu of the site. The command is simply:
+
+```
+ dbstats
+```
+
+And the response has the following format:
+
+```
+ dbstats stats
+```
+
+Where _stats_ is a JSON object with integer values. Example response:
+
+```
+ dbstats {"users":0,
+ "threads":0,
+ "tags":1627,
+ "releases":28071,
+ "producers":3456,
+ "chars":14046,
+ "posts":0,
+ "vn":13051,
+ "traits":1272}
+```
+
+The *users*, *threads* and *posts* stats are always '0' and only included for
+backwards compatibility.
+
+# The 'get' command
+
+This command is used to fetch data from the database. It accepts 4 arguments:
+the type of data to fetch (e.g. visual novels or producers), what part of that
+data to fetch (e.g. only the VN titles, or the descriptions and relations as
+well), a filter expression, and lastly some options.
+
+```
+ get type flags filters options
+```
+
+_type_ and _flags_ are unescaped strings. The accepted values for _type_ are
+documented below. _flags_ is a comma-separated list of flags indicating what
+info to fetch. The filters, available flags and their meaning are documented
+separately for each type. The last _options_ argument is optional, and
+influences the behaviour of the returned results. When present, _options_
+should be a JSON object with the following members (all are optional):
+
+page
+: integer, used for pagination. Page 1 (the default) returns the first 10
+results (1-10), page 2 returns the following 10 (11-20), etc. (The actual
+number of results per page can be set with the "results" option below).
+
+results
+: integer, maximum number of results to return. Also affects the "page" option
+above. For example: with "page" set to 2 and "results" set to 5, the second
+five results (that is, results 6-10) will be returned. Default: 10.
+
+sort
+: string, the field to order the results by. The accepted field names differ
+per type, the default sort field is the ID of the database entry.
+
+reverse
+: boolean, default false. Set to true to reverse the order of the results.
+
+The following example will fetch basic information and information about the
+related anime of the visual novel with id = 17:
+
+```
+ get vn basic,anime (id = 17)
+```
+
+The server will reply with a 'results' message, this message is followed by a
+JSON object describing the results. This object has three members: 'num', which
+is an integer indicating the number of results returned, 'more', which is true
+when there are more results available (i.e. increasing the _page_ option
+described above will give new results) and 'items', which contains the results
+as an array of objects. For example, the server could reply to the previous
+command with the following message:
+
+```
+ results {"num":1, "more":false, "items":[{
+ "id": 17, "title": "Ever17 -the out of infinity-", "original": null,
+ "released": "2002-08-29", "languages": ["en","ja","ru","zh"],
+ "platforms": ["drc","ps2","psp","win"],"anime": []
+ }]}
+```
+
+Note that the actual result from the server can (and likely will) be formatted
+differently and that the order of the members may not be the same. What each
+member means and what possible values they can have differs per type and is
+documented below.
+
+
+## get vn
+
+The following members are returned from a 'get vn' command:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-----------
+id | - | integer | no | Visual novel ID
+title | basic | string | no | Main title
+original | basic | string | yes | Original/official title.
+released | basic | date (string) | yes | Date of the first release.
+languages | basic | array of strings | no | Can be an empty array when nothing has been released yet.
+orig\_lang | basic | array of strings | no | Original language of the VN. Always contains a single language,
+platforms | basic | array of strings | no | Can be an empty array when unknown or nothing has been released yet.
+aliases | details | string | yes | Aliases, separated by newlines.
+length | details | integer | yes | Length of the game, 1-5, broad category between "very short" and "very long". This field is not displayed on the site if there are length votes available (see below)
+length\_minutes | details | integer | yes | Average play time from length votes
+length\_votes | details | integer | no | Number of length votes
+description | details | string | yes | Description of the VN. Can include formatting codes as described in [d9#3](https://vndb.org/d9#3).
+links | details | object | no | Contains the following members: <br>"wikipedia", string, name of the related article on the English Wikipedia (deprecated, use wikidata instead).<br>"encubed", string, the URL-encoded tag used on [encubed](http://novelnews.net/) (deprecated).<br>"renai", string, the name part of the url on [renai.us](http://renai.us/).<br>"wikidata", string, Wikidata identifier.<br>All members can be **null** when no links are available or known to us.
+image | details | string | yes | HTTP link to the VN image.
+image\_nsfw | details | boolean | no | (deprecated) Whether the VN image is flagged as NSFW or not.
+image\_flagging | details | object | yes | Image flagging summary of the main VN image, object with the following fields:<br>"votecount", integer, number of flagging votes.<br>"sexual\_avg", number, sexual score between 0 (safe) and 2 (explicit).<br>"violence\_avg", number, violence score between 0 (tame) and 2 (brutal).<br>The two averages may be **null** if no votes have been cast yet.
+image\_width | details | integer | yes |
+image\_height | details | integer | yes |
+titles | titles | array of objects | no | Full list of titles associated with this VN. Each language is included only once, the "main" title is the one indicated by the "orig\_lang" member. Each object has the following members:<br>"lang": string, language of this title.<br>"title", string, title in the original script<br>"latin", string, possibly null, romanized version of "title"<br>"official", boolean, whether this is an official title.
+anime | anime | array of objects | no | (Possibly empty) list of anime related to the VN, each object has the following members:<br>"id", integer, [AniDB](http://anidb.net/) ID<br>"ann\_id", integer, [AnimeNewsNetwork](http://animenewsnetwork.com/) ID<br>"nfo\_id", string, [AnimeNfo](http://animenfo.com/) ID<br>"title\_romaji", string<br>"title\_kanji", string<br>"year", integer, year in which the anime was aired<br>"type", string<br>All members except the "id" can be **null**. Note that this data is courtesy of AniDB, and may not reflect the latest state of their information due to caching.
+relations | relations | array of objects | no | (Possibly empty) list of related visual novels, each object has the following members:<br>"id", integer<br>"relation", string, relation to the VN<br>"title", string, (romaji) title<br>"original", string, original/official title, can be **null**<br>"official", boolean.
+tags | tags | array of arrays | no | (Possibly empty) list of tags linked to this VN. Each tag is represented as an array with three elements:<br> tag id (integer),<br>score (number between 0 and 3),<br>spoiler level (integer, 0=none, 1=minor, 2=major)<br>Only tags with a positive score are included. Note that this list may be relatively large - more than 50 tags for a VN is quite possible.<br>General information for each tag is available in the [tags dump](https://vndb.org/d14#2). Keep in mind that it is possible that a tag has only recently been added and is not available in the dump yet, though this doesn't happen often.
+rating | stats | number | no | Bayesian rating, between 1 and 10.
+votecount | stats | integer | no | Number of votes.
+screens | screens | array of objects | no | (Possibly empty) list of screenshots, each object has the following members:<br>"id", string, image ID<br>"image", string, URL of the full-size screenshot<br>"rid", integer, release ID<br>"nsfw", boolean (depecated)<br>"flagging", object, same format as "image\_flagging" field mentioned above<br>"height", integer, height of the full-size screenshot<br>"width", integer, width of the full-size screenshot<br>"thumbnail", string, URL to the thumbnail<br>"thumbnail\_width", integer<br>"thumbnail\_height", integer
+staff | staff | array of objects | no | (Possibly empty) list of staff related to the VN, each object has the following members:<br>"sid", integer, staff ID<br>"aid", integer, alias ID<br>"name", string<br>"original", string, possibly null<br>"role", string<br>"note", string, possibly null
+
+Sorting is possible on the following fields: id, title, released, rating, votecount.
+
+'get vn' accepts the following filter expressions:
+
+| Field | Value | Operators | Notes |
+|--|--|--|---------------
+id | integer<br>array of integers | = != > >= < <=<br>= != | When you need to fetch info about multiple VNs, it is recommended to do so in one command using an array of integers as value. e.g. (id = [7,11,17]).
+title | string | = != ~ |
+original | null<br>string | = !=<br>= != ~ |
+firstchar | null<br><string> | = !=<br>= != | Filter by the first character of the title, similar to the [VN browser interface](http://vndb.org/v/all). The character must either be a lowercase 'a' to 'z', or null to match all titles not starting with an alphabetic character.
+released | null<br>date (string) | = !=<br>= != > >= < <= | Note that matching on partial dates (released = "2009") doesn't do what you want, use ranges instead, e.g. (released > "2008" and released <= "2009").
+platforms | null<br>string<br>array of strings | <br>= != |
+languages | null<br>string<br>array of strings | <br>= != |
+orig\_lang | string<br>array of strings | = != |
+search | string | ~ | This is not an actual field, but performs a search on the titles of the visual novel and its releases. Note that the algorithm of this search may change and that it can use a different algorithm than the search function on the website.
+tags | int<br>array of ints | = != | Find VNs by tag. When providing an array of ints, the '=' filter will return VNs that are linked to any (not all) of the given tags, the '!=' filter will return VNs that are not linked to any of the given tags. You can combine multiple tags filters with 'and' and 'or' to get the exact behavior you need.<br> This filter may used cached data, it may take up to 24 hours before a VN will have its tag updated with respect to this filter.<br> VNs that are linked to childs of the given tag are also included.<br> Be warned that this filter ignores spoiler settings, fetch the tags associated with the returned VN to verify the spoiler level.
+
+
+## get release
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|---------------
+id | - | integer | no | Release ID
+title | basic | string | no | Release title (romaji)
+original | basic | string | yes | Original/official title of the release.
+released | basic | date (string) | yes | Release date
+type | basic | string | no | (deprecated) "complete", "partial" or "trial". For releases linked to multiple VNs, the most-complete type will be selected.
+patch | basic | boolean | no |
+freeware | basic | boolean | no |
+doujin | basic | boolean | no | Deprecated and meaningless, don't use.
+official | basic | boolean | no |
+languages | basic | array of strings | no |
+website | details | string | yes | Official website URL
+notes | details | string | yes | Random notes, can contain formatting codes as described in [d9#3](https://vndb.org/d9#3)
+minage | details | integer | yes | Age rating, 0 = all ages.
+gtin | details | string | yes | JAN/UPC/EAN code. This is actually an integer, but formatted as a string to avoid an overflow on 32bit platforms.
+catalog | details | string | yes | Catalog number.
+platforms | details | array of strings | no | Empty array when platform is unknown.
+media | details | array of objects | no | Objects have the following two members:<br> "medium", string<br> "qty", integer, the quantity. **null** when it is not applicable for the medium.<br> An empty array is returned when the media are unknown.
+resolution | details | string | yes |
+voiced | details | integer | yes | 1 = Not voiced, 2 = Only ero scenes voiced, 3 = Partially voiced, 4 = Fully voiced
+animation | details | array of integers | no | The array has two integer members, the first one indicating the story animations, the second the ero scene animations. Both members can be null if unknown or not applicable.<br> <br> When not null, the number indicates the following: 1 = No animations, 2 = Simple animations, 3 = Some fully animated scenes, 4 = All scenes fully animated.
+lang | lang | array of objects | no | List of languages with associated metadata. Each object has the following members:<br>"lang": string, language the release is available in<br>"title", string, possibly null, title in the original script<br>"latin", string, possibly null, romanized version of "title"<br>"mtl", boolean, whether this is a machine translation<br>"main", boolean, whether this title is used as main title for the release entry.<br>There is always exactly one object where "main" is true.
+vn | vn | array of objects | no | Array of visual novels linked to this release. Objects have the following members: id, rtype, title and original. The "rtype" field indicates whether the release is a "trial", "partial" or "complete" for the given VN. The other fields are the same as the members of the "get vn" command.
+producers | producers | array of objects | no | (Possibly empty) list of producers involved in this release. Objects have the following members:<br> "id", integer<br> "developer", boolean,<br> "publisher", boolean,<br> "name", string, romaji name<br> "original", string, official/original name, can be **null**<br> "type", string, producer type
+links | links | array of objects | no | List of external links, each represented as an object with string members "label" and "url". Multiple links with the same label may be present. The official website is also included in this list, if one is known.
+
+Sorting is possible on the 'id', 'title' and 'released' fields.
+
+Accepted filters:
+
+| Field | Value | Operators | Notes |
+|--|--|--|-------------
+id | integer<br>array of integers | = != > >= < <=<br>= != |
+vn | integer<br>array of integers | = != | Find releases linked to the given visual novel ID.
+producer | integer | = | Find releases linked to the given producer ID.
+title | string | = != ~ |
+original | null<br>string | = !=<br>= != ~ |
+released | null<br>date (string) | = !=<br>= != > >= < <= | Note about released filter for the vn type also applies here.
+patch | boolean | = |
+freeware | boolean | = |
+doujin | boolean | = |
+type | string | = != |
+gtin | int | = != | Value can also be escaped as a string (if you risk an integer overflow otherwise)
+catalog | string | = != |
+languages | string<br>array of strings | = != |
+platforms | string<br>array of strings | = != |
+
+
+## get producer
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|--------------
+id | - | integer | no | Producer ID
+name | basic | string | no | (romaji) producer name
+original | basic | string | yes | Original/official name
+type | basic | string | no | Producer type
+language | basic | string | no | Primary language
+links | details | object | no | External links, object has the following members:<br> "homepage", official homepage,<br>"wikipedia", string, name of the related article on the English Wikipedia (deprecated, use wikidata instead).<br>"wikidata", string, Wikidata identifier.<br>All members can be **null**.
+aliases | details | string | yes | List of alternative names, separated by a newline
+description | details | string | yes | Description/notes of the producer, can contain formatting codes as described in [d9#3](https://vndb.org/d9#3)
+relations | relations | array of objects | no | (possibly empty) list of related producers, each object has the following members:<br> "id", integer, producer ID,<br> "relation", string, relation to the current producer,<br> "name", string,<br> "original", string, can be **null**
+
+Sorting is possible on the 'id' and 'name' fields.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|-----------------
+id | integer<br>array of integers | = != > >= < <=<br>= != |
+name | string | = != ~ |
+original | null<br>string | = !=<br>= != ~ |
+type | string | = != |
+language | string<br>array of strings | = != |
+search | string | ~ | Not an actual field. Performs a search on the name, original and aliases fields.
+
+
+## get character
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-----------------
+id | - | integer | no | Character ID
+name | basic | string | no | (romaji) name
+original | basic | string | yes | Original (kana/kanji) name
+gender | basic | string | yes | Character's sex (not gender); "m" (male), "f" (female) or "b" (both)
+spoil\_gender | basic | string | yes | Actual sex, if this is a spoiler. Can also be "unknown" if their actual sex is not known but different from their apparent sex.
+bloodt | basic | string | yes | Blood type, "a", "b", "ab" or "o"
+birthday | basic | array | no | Array of two numbers: day of the month (1-31) and the month (1-12). Either can be null.
+aliases | details | string | yes | Alternative names, separated with a newline.
+description | details | string | yes | Description/notes, can contain formatting codes as described in [d9#3](https://vndb.org/d9#3). May also include [spoiler] tags!
+age | details | int | yes | years
+image | details | string | yes | HTTP link to the character image.
+image\_flagging | details | object | yes | Image flagging summary, see the similar "image\_flagging" field of "get vn".
+image\_width | details | integer | yes |
+image\_height | details | integer | yes |
+bust | meas | integer | yes | cm
+waist | meas | integer | yes | cm
+hip | meas | integer | yes | cm
+height | meas | integer | yes | cm
+weight | meas | integer | yes | kg
+cup\_size | meas | string | yes |
+traits | traits | array of arrays | no | (Possibly empty) list of traits linked to this character. Each trait is represented as an array of two elements: The trait id (integer) and the spoiler level (integer, 0-2). General information for each trait is available in the [traits dump](https://vndb.org/d14#3).
+vns | vns | array of arrays | no | List of VNs linked to this character. Each VN is an array of 4 elements: VN id, release ID (0 = "all releases"), spoiler level (0-2) and the role (string).<br> Available roles: "main", "primary", "side" and "appears".
+voiced | voiced | array of objects | no | List of voice actresses (staff) that voiced this character, per VN. Each staff/VN is represented as a object with the following members:<br> "id", integer, staff ID<br> "aid", integer, the staff alias ID being used<br> "vid", integer, VN id<br> "note", string<br> The same voice actor may be listed multiple times if this entry is character to multiple visual novels. Similarly, the same visual novel may be listed multiple times if this character has multiple voice actors in the same VN.
+instances | instances | array of objects | no | List of instances of this character (excluding the character entry itself). Each instance is represented as an object with the following members:<br> "id", integer, character ID<br> "spoiler", integer, 0=none, 1=minor, 2=major<br> "name", string, character name<br> "original", string, character's original name.
+
+Sorting is possible on the 'id' and 'name' fields.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|-----------------
+id | integer<br>array of integers | = != > >= < <=<br>= != |
+name | string | = != ~ |
+original | null<br>string | = !=<br>= != ~ |
+search | string | ~ | Not an actual field. Performs a search on the name, original and aliases fields.
+vn | integer<br>array of integers | = | Find characters linked to the given visual novel ID(s). Note that this may also include characters that are normally hidden by spoiler settings.
+traits | int<br>array of ints | = != | Find chars by traits. When providing an array of ints, the '=' filter will return chars that are linked to any (not all) of the given traits, the '!=' filter will return chars that are not linked to any of the given traits. You can combine multiple trait filters with 'and' and 'or' to get the exact behavior you need.<br> This filter may use cached data, it may take up to 24 hours before a char entry will have its traits updated with respect to this filter.<br> Chars that are linked to childs of the given trait are also included.<br> Be warned that this filter ignores spoiler settings, fetch the traits associated with the returned char to verify the spoiler level.
+
+
+## get staff
+
+Unlike other database entries, staff have more than one unique identifier.
+
+There is the main 'staff ID', which uniquely identifies a person and is what
+the staff pages on the site represent.
+
+Additionally, every staff name and alias also has its own unique identifier,
+which is referenced from other database entries to identify which alias was
+used. This identifier is generally hidden on the site and aliases do not have
+their own page, but the IDs are exposed in this API in order to facilitate
+linking between VNs/characters and staff.
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-------------------
+id | - | integer | no | Staff ID
+name | basic | string | no | Primary (romaji) staff name
+original | basic | string | yes | Primary original name
+gender | basic | string | yes |
+language | basic | string | no | Primary language
+links | details | object | no | External links, object has the following members:<br> "homepage", official homepage,<br>"wikipedia", string, name of the related article on the English Wikipedia (deprecated, use wikidata instead).<br> "twitter", name of the twitter account.<br> "anidb", [AniDB](http://anidb.net/) creator ID.<br> "pixiv", integer, id of the pixiv account.<br> "wikidata", string, Wikidata identifier.<br>All values can be **null**.
+description | details | string | yes | Description/notes of the staff, can contain formatting codes as described in [d9#3](https://vndb.org/d9#3)
+aliases | aliases | array of arrays | no | List of names and aliases. Each name is represented as an array with the following elements: Alias ID, name (romaji) and the original name.<br> This list also includes the "primary" name.
+main\_alias | aliases | integer | no | ID of the alias that is the "primary" name of the entry
+vns | vns | array of objects | no | List of visual novels that this staff entry has been credited in (excluding character voicing). Each vn is represented as an object with the following members:<br> "id", integer, visual novel id<br> "aid", integer, alias ID of this staff entry<br> "role", string<br> "note", string, may be null if unset<br> The same VN entry may appear multiple times if the staff has been credited for multiple roles.
+voiced | voiced | array of objects | no | List of characters that this staff entry has voiced. Each object has the following members:<br> "id", integer, visual novel id<br> "aid", integer, alias ID of this staff entry<br> "cid", integer, character ID<br> "note", string, may be null if unset<br> The same VN entry may appear multiple times if the staff has been credited for multiple characters. Similarly, the same character may appear multiple times if it has been linked to multiple VNs.
+
+Sorting is possible on the 'id' field.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|---------------
+id | integer<br>array of integers | = != > >= < <=<br>= != |
+aid | integer<br>array of integers | =<br>= |
+search | string | ~ | Searched through all aliases, both the romanized and original names.
+
+
+## get quote
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|----------------
+id | - | integer | no | VN ID
+title | basic | string | no | VN title
+quote | basic | string | no |
+
+Sorting is possible on the 'id' and the pseudo 'random' field (default).
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|---------------
+id | integer<br>array of integers | = != > >= < <=<br>= != |
+
+Note that a filter is required for all *get* commands, so to get a random quote, use:
+```
+get quote basic (id>=1) {"results":1}
+```
+
+## get user
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-----------------
+id | basic | integer | no | User ID
+username | basic | string | no
+
+The returned list is always sorted on the 'id' field.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|----------------
+id | integer<br>array of integers | = | The special value '0' is recognized as the currently logged in user.
+username | string<br>array of strings | = != ~<br>= |
+
+
+## get ulist-labels
+
+Fetch the labels for a user. Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-----------------
+uid | basic | integer | no | User ID
+id | basic | integer | no | Label ID
+label | basic | string | no |
+private | basic | boolean | no |
+
+The returned list is always sorted on the 'id' field.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|-------------------
+uid | integer | = | The special value '0' is recognized as the currently logged in user.
+
+Labels marked as private are only returned for the currently logged in user.
+
+Label ids are local to the user, id < 10 are built-in labels and are the same
+for every user, id >= 10 or above are custom labels created by the user or a
+migration script.
+
+
+## get ulist
+
+This command replaces the (obsolete and now undocumented) "get votelist", "get
+vnlist" and "get wishlist" commands.
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-----------------
+uid | basic | integer | no | User ID
+vn | basic | integer | no | Visual Novel ID
+added | basic | integer | no | Unix timestamp of when this item has been added.
+lastmod | basic | integer | no | Unix timestamp of when this item has been last modified.
+voted | basic | integer | yes | Unix timestamp when the vote has been cast.
+vote | basic | integer | yes | Vote between 10 and 100.
+notes | basic | string | yes |
+started | basic | string | yes | YYYY-MM-DD
+finished | basic | string | yes | YYYY-MM-DD
+labels | labels | array of objects | no | List of labels assigned to this VN entry, each object has the following fields:<br>"id", integer, label ID<br>"label", string, label name.
+
+Sorting is possible on the following fields: uid, vn, added, lastmod, voted, vote.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|-------------------------
+uid | integer | = | The special value '0' is recognized as the currently logged in user.
+vn | integer<br>array of integers | = != > < >= <=<br>= != | Visual novel ID.
+label | integer | = | Label assigned to the VN. As a technical limitation, this filter does not return private labels even when the user is logged in.
+
+
+# The 'set' command
+
+The set command can be used to modify stuff in the database. It can only be
+used when logged in as a user. The command has the following syntax:
+
+```
+ set type id fields
+```
+
+Here, _type_ is similar to the type argument to the 'get' command, _id_ is the
+(integer) identifier of the database entry to change, and _fields_ is an object
+with the fields to set or modify. If the _fields_ object is not present, the
+set command works as a 'delete'. The interpretation of the _id_ and _fields_
+arguments depend on the _type_, and are documented in the sections below.
+
+But before that, let me present some examples to get a feel on what the
+previous paragraph meant. The following example adds a '10' vote on
+[v17](https://vndb.org/v17), or changes the vote to a 10 if a previous vote was
+already present:
+
+```
+ set ulist 17 {"vote":100}
+```
+
+And here's how to remove Ever17 from the list:
+
+```
+ set ulist 17
+```
+
+'set' replies with a simple 'ok' on success, or with an 'error' (see below) on
+failure. Note that, due to my laziness, no error is currently returned if the
+identifier does not exist. So voting on a VN that does not exist will return an
+'ok', but no vote is actually added. This behaviour may change in the future.
+Note that this API doesn't care whether the VN has been deleted or not, so you
+can manage votes and stuff for deleted VNs (Which isn't very helpful, because
+'get vn' won't return a thing for deleted VNs).
+
+
+## set ulist
+
+This command replaces the "set votelist", "set vnlist" and "set wishlist"
+commands.
+
+This command facilitates adding, removing and modifying your VN list. The
+_identifier_ argument is the visual novel ID, and the following fields are
+recognized:
+
+| Field | Type | Description
+|--|--|----------------
+notes | string | Same as the 'notes' member returned by 'get ulist'. An empty string is considered equivalent to 'null'.
+started | string | Same as the 'started' member returned by 'get ulist'.
+finished | string | Same as the 'started' member returned by 'get ulist'.
+vote | integer | Same as the 'vote' member returned by 'get ulist', in the range 10 to 100.
+labels | array of integers | List of label IDs to assign to this VN. This will overwrite any previously assigned labels. Label id 7 ("Voted") is automatically assigned based on whether the vote field is set, so it does not need to be included here. An attempt to assign it anyway will be ignored. Attempts to assign an unknown label ID will be silently ignored, but this is subject to change.
+
+When removing a ulist item, any releases associated with the VN will be removed
+from the users' list as well. The release list functionality is not currently
+exposed to the API, so is only visible when the web interface is used.
+
+
+
+# The 'error' response
+
+Every command to the server can receive an 'error' response, this response has
+one argument: a JSON object containing at least a member named "id", which
+identifies the error, and a "msg", which contains a human readable message
+explaining what went wrong. Other members are also possible, depending on the
+value of "id". Example error message:
+
+```
+ error {"id":"parse", "msg":"Invalid command or argument"}
+```
+
+Note that the value of "msg" is not directly linked to the error identifier:
+the message explains what went wrong in more detail, there are several
+different messages for the same id. The following error identifiers are
+currently defined:
+
+parse
+: Syntax error, unknown command or invalid argument type.
+
+missing
+: A JSON object argument is missing a required member. The name of which is
+given in the additional "field" member.
+
+badarg
+: A JSON value is of the wrong type or in the wrong format. The name of the
+incorrect field is given in a "field" member.
+
+needlogin
+: Need to be logged in to issue this command.
+
+throttled
+: You have used too many server resources within a short time, and need to wait
+a bit before sending the next command. The type of throttle is given in the
+"type" member, and the "minwait" and "fullwait" members tell you how long you
+need to wait before sending the next command and when you can start bursting
+again (this is the recommended waiting time), respectively. Both values are in
+seconds, with a precision of 0.1 seconds.
+
+auth
+: (login) Incorrect username/password combination.
+
+loggedin
+: (login) Already logged in. Only one successful login command can be issues on
+one connection.
+
+gettype
+: (get) Unknown type argument to the 'get' command.
+
+getinfo
+: (get) Unknown info flag to the 'get' command. The name of the unrecognised
+flag is given in an additional "flag" member.
+
+filter
+: (get) Unknown filter field or invalid combination of field/operator/argument
+type. Includes three additional members: "field", "op" and "value" of the
+incorrect expression.
+
+settype
+: (set) Unknown type argument to the 'set' command.
+
+
+
+# Change Log
+
+This section lists the changes made in each version of the VNDB code. Check out
+the [announcements board](https://vndb.org/t/an) for more information about
+updates.
+
+**2023-07-11**
+
+- Deprecated "popularity" member of "get vn stats"
+- Deprecated "popularity" sort option of "get vn"
+
+**2022-10-04**
+
+- Add "official" member to "get release basic"
+- Add "id" and "thumbnail(|\_width|height)" members to "get vn screens"
+- Add "image\_(width|height)" members to "get vn details"
+- Add "image\_(width|height)" members to "get character details"
+
+**2022-10-02**
+
+- Add "get vn titles"
+- Add "length\_minutes" and "length\_votes" members to "get vn basic"
+- Add "get release lang"
+- Add "get release links"
+
+**2021-12-15**
+
+- Add support for creating and logging in with session tokens in the "login" command.
+
+**2021-11-15**
+
+- The "vn" object returned by "get release" now includes an "rtype" field.
+- The "type" field returned by "get release" has been deprecated in favor of the above.
+
+**2021-01-30**
+
+- The "orig\_lang" field in "get vn" now always returns exactly one language.
+
+**2020-12-29**
+
+- Add "get quote" command.
+
+**2020-11-13**
+
+- New fields for "get character": age, cup\_size and spoil\_gender.
+
+**2020-07-09**
+
+- Deprecated the "image\_nsfw" and "nsfw" flags given by "get vn details,screens"
+- Added "image\_flagging" fields to "get vn details" and "get character details"
+- Added "flagging" field to "get vn screens"
+
+**2020-04-09**
+
+- The "dbstats" command no longer returns stats for *users*, *threads* and *posts*.
+
+**2020-01-01**
+
+- Deprecated the get/set votelist/wishlist/vnlists commands
+- The "get ulist-labels", "get ulist" and "set ulist" commands should now be
+ used in new code
+- See [t13365](https://vndb.org/t13365) for more details
+
+**2019-12-05**
+
+- Early API support for the [new lists feature](https://vndb.org/t13136). The
+ votelist/wishlist/vnlist commands will be updated when it goes out of beta.
+- Add "get ulist-labels"
+- Add "get ulist"
+- Add "set ulist"
+
+**2019-10-07**
+
+- Add wikidata links to "get vn/producer/staff"
+- Add pixiv links to "get staff"
+
+**2018-06-13**
+
+- Add "get character instances"
+
+**2018-02-07**
+
+- The 'aliases' member for "get producer" is now uses newline as separation rather than a comma
+
+**2017-08-14**
+
+- Add 'uid' field to "get votelist/vnlist/wishlist" commands
+- Add 'vn' filter to the same commands
+- The 'uid' filter for these commands is now optional, making it possible to find all list entries for a particular VN
+
+**2017-06-21**
+
+- Add "resolution", "voiced", "animation" members to "get release" command
+- Add "platforms" filter to "get release" command
+- Accept arrays for the "vn" filter to the "get release" command
+- Add "search" filter to "get staff"
+
+**2017-05-22**
+
+- Add "vns" and "voiced" flags to "get staff" command
+- Add "voiced" flag to "get character" command
+
+**2017-04-28**
+
+- Add "get staff" command
+- Add "staff" flag to "get vn" command
+
+**2.27**
+
+- Add "username" filter to "get user"
+- Add "traits" filter to "get character"
+
+**2.25**
+
+- Add "tags" filter to "get vn"
+- Increased connection limit per IP from 5 to 10
+- Increased command limit from 100 to 200 commands per 10 minutes
+- Added support for TLS
+- Added "screens" flag and member to "get vn"
+- Added "vns" flag and member to "get character"
+- Allow sorting "get vn" on popularity, rating and votecount
+- Added basic "get user" command
+- Added "official" field to "get vn relations"
+
+**2.23**
+
+- Added new 'dbstats' command
+- Added new 'get' types: character, votelist, vnlist and wishlist
+- Added 'set' command, with types: votelist, vnlist and wishlist
+- New error id: 'settype'
+- Added "tags" flag and member to "get vn"
+- Added "stats" flag to "get vn"
+- Added "firstchar" filter to "get vn"
+- Added "vn" filter to "get character"
+
+**2.15**
+
+- Fixed a bug with the server not allowing extra whitespace after a "get .. " command
+- Allow non-numbers as "clientver" for the login command
+- Added "image\_nsfw" member to "get vn"
+- Added "results" option to the "get .. {<options>}"
+- Increased the maximum number of results for the "get .." command to 25
+- Added "orig\_lang" member and filter to the "get vn .." command
+- Throttle the commands and sqltime per IP instead of per user
+- Removed the limit on the number of open sessions per user
+- Allow the API to be used without logging in with a username/password
+
+**2.12**
+
+- Added "image" member to "get vn"
+- A few minor fixes in some error messages
+- Switched to a different (and faster) search algorithm for "get vn .. (search ~ ..)"
diff --git a/data/conf_example.pl b/conf_example.pl
index 5885e8e9..c4788971 100644
--- a/data/conf_example.pl
+++ b/conf_example.pl
@@ -9,6 +9,9 @@
# Global salt used to hash user passwords (used in addition to a user-specific salt)
scrypt_salt => '<another unique string>',
+ # Use the more secure imgproc
+ #imgproc_path => "$main::ROOT/imgproc/imgproc-custom",
+
# TUWF configuration options, see the TUWF::set() documentation for options.
tuwf => {
db_login => [ 'dbi:Pg:dbname=vndb', 'vndb_site', 'vndb_site' ],
@@ -17,12 +20,9 @@
debug => 1,
cookie_defaults => { domain => 'localhost', path => '/' },
mail_sendmail => 'log',
+ #fastcgi_max_requests => 1000 + int(rand(1000)),
},
- # Uncomment if you want to test password strength against a dictionary. See
- # lib/PWLookup.pm for instructions on how to create the database file.
- #password_db => 'data/passwords.dat',
-
# Options for Multi, the background server.
Multi => {
# Each module in lib/Multi/ can be enabled and configured here.
diff --git a/css/blendbg.css b/css/blendbg.css
new file mode 100644
index 00000000..ab4ba6e8
--- /dev/null
+++ b/css/blendbg.css
@@ -0,0 +1,6 @@
+$blendbg: rgb(
+ (red($boxbg) * alpha($boxbg) + red($bodybg) * (1 - alpha($boxbg))),
+ (green($boxbg) * alpha($boxbg) + green($bodybg) * (1 - alpha($boxbg))),
+ (blue($boxbg) * alpha($boxbg) + blue($bodybg) * (1 - alpha($boxbg)))
+);
+html { --blendbg: #{$blendbg} }
diff --git a/css/forms.css b/css/forms.css
new file mode 100644
index 00000000..ca4defbc
--- /dev/null
+++ b/css/forms.css
@@ -0,0 +1,282 @@
+/***** general form markup *****/
+
+/* TODO: The .text/.submit classes should be removed */
+
+input.text, input.submit, input[type=text], input[type=password], input[type=email], input[type=button], input[type=submit], select, textarea, button {
+ background-color: var(--boxbg);
+ color: var(--maintext);
+ border: 1px solid var(--secborder);
+ font: 14px "Tahoma", "Arial", sans-serif;
+ padding: 1px;
+ margin: 1px;
+ &:hover, &:focus { background-color: var(--secbg) }
+}
+input.submit, input[type=submit], input[type=button], button { padding: 1px 5px; cursor: pointer }
+form, fieldset { border: 0; display: block }
+select[multiple] option {
+ & { background: inherit; padding-left: 12px; position: relative }
+ &:checked:before { position: absolute; left: 0; top: 0; content: '✓' }
+}
+optgroup option { padding-left: 10px }
+button { text-align: left }
+button svg { height: 14px; width: 14px; margin-top: 1px; margin-bottom: -1px }
+button.ds svg { float: right; margin-top: 3px; margin-right: -2px; margin-left: -5px }
+button span { white-space: nowrap }
+input.obscured { color: transparent; text-shadow: 0 0 8px var(--maintext); }
+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 }
+input[type=color] { width: 30px; height: 21px }
+input[type=checkbox], input[type=radio] {
+ appearance: none; width: 15px; height: 15px; position: relative; top: 2px; margin-bottom: -1px; border: 2px solid var(--border);
+ &:focus { outline: 1px dotted #fff }
+ &:checked::before {
+ content: '';
+ display: block; width: 80%; height: 80%;
+ position: relative; top: 10%; left: 10%;
+ background-color: var(--maintext);
+ /* Path from https://moderncss.dev/pure-css-custom-checkbox-style/ */
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
+ }
+}
+input[type=radio] { border-radius: 50% }
+
+/* Only used on js/graph/vn.js for now.
+ * Based on https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ */
+input[type=range] {
+ -webkit-appearance: none;
+ width: 200px;
+ height: 14px;
+ margin: 1px;
+ background: transparent;
+ &::-webkit-slider-thumb, &::-moz-range-thumb {
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 7px;
+ background: var(--maintext);
+ cursor: pointer;
+ }
+ &::-webkit-slider-runnable-track, &::-moz-range-track {
+ width: 100%;
+ height: 4px;
+ background: var(--grayedout);
+ cursor: pointer;
+ }
+}
+
+
+/* Old table-based form layout, used in Elm */
+
+td.label, td.label label { width: 130px; }
+td.label label { display: block; }
+td.field label { margin: 0 5px 0 5px; }
+table.formtable { margin: 0 20px 20px 20px; }
+table.formtable td { padding: 0; }
+table.formtable tr.newfield > td { padding-top: 5px; }
+table.formtable tr.newpart td { padding-top: 20px; font-weight: bold; }
+table.formtable td table td { padding: 1px 15px 1px 0px }
+table.formtable td table { margin-bottom: 5px }
+table.formtable input.text, table.formtable select { width: 200px; }
+
+table.formimage > tr > td:nth-child(1) { width: 300px; height: 300px; text-align: center }
+table.formimage > tr > td:nth-child(1) img { max-width: 290px; max-height: 500px }
+table.formimage h2 { margin: 0 }
+
+
+
+/* New fielset-based form layout, used in JS */
+
+fieldset.form {
+ margin-left: 140px; margin-bottom: 20px;
+ > fieldset { margin-bottom: 5px }
+ > fieldset:disabled > label { color: var(--grayedout) }
+ > legend { margin-left: -130px; margin-bottom: 5px; font-weight: bold }
+ > label:not(.check), > fieldset > label:not(.check) { display: block; float: left; margin-left: -130px; width: 130px }
+ table.full { margin-left: -130px }
+ .xw { width: 100%; max-width: 500px } /* for long inputs */
+ .lw { width: 100%; max-width: 300px } /* for slightly longer inputs */
+ .mw { width: 100%; max-width: 200px } /* for most inputs */
+ .sw { width: 50px } /* for shorter inputs, like numbers */
+ td { padding: 1px 15px 1px 0px; line-height: 1.8 }
+ table { margin-bottom: 5px }
+ p + table { margin-top: 5px }
+ a.help {
+ border: none; margin-left: 3px; color: var(--helpbutton, var(--link));
+ svg { width: 16px; height: 16px; margin-bottom: -2px }
+ }
+ section.help {
+ margin: -5px 0 5px -130px;
+ max-width: 700px;
+ background-color: var(--noticebg); border: 1px solid var(--noticeborder);
+ position: relative; padding: 5px;
+ > a:first-child {
+ float: right; margin-top: -4px; margin-right: -4px;
+ text-decoration: none; color: var(--maintext);
+ svg { width: 18px; height: 18px; border: none; }
+ &:hover { border: none }
+ }
+ p + p { padding-top: 10px }
+ dl { margin: 10px 0; display: grid; grid-template-columns: auto 1fr; grid-gap: 3px 10px }
+ dt { font-weight: bold }
+ }
+ .textpreview { max-width: 600px }
+ .textpreview.full { max-width: 730px; margin-left: -130px }
+ .release-animation td { width: 180px; line-height: normal }
+}
+
+p.invalid { display: none }
+.formerror { display: none }
+
+/* 'invalid-form' class is set on the parent <form> element after attempted
+ * submission, so that error states and messages don't distract when working on
+ * an empty form */
+form.invalid-form {
+ p.invalid { display: block; color: var(--standout) }
+ input.invalid, textarea.invalid { border-color: var(--standout) }
+ label.invalid { color: var(--standout) }
+ .invalid-tab a { color: var(--standout) }
+ .formerror { display: block; color: var(--standout) }
+}
+
+/* TODO: responsive form layout for mobile */
+
+
+/* Form submit box, with optional edit summmary */
+article.submit {
+ text-align: center;
+ input[type=submit] { width: 150px }
+ .textpreview, textarea { text-align: left; margin: 0 auto; width: 600px }
+}
+
+
+
+/* Format checkboxes and radio buttons as if they were normal links with unicode icons.
+ * Usage:
+ *
+ * <container class="linkradio">
+ * <input type="checkbox|radio" id="xyz">
+ * <label for="xyz">Text</label>
+ * <em>(optional option separator)</em>
+ * </container>
+ *
+ * TODO: Get rid of these and just use checkboxes/radio inputs.
+ */
+p.linkradio { padding: 2px }
+.linkradio label { color: var(--link); cursor: pointer }
+.linkradio label:before { content: '✗' }
+.linkradio input { display: none }
+.linkradio input:checked + label { color: var(--maintext) }
+.linkradio input:checked + label:before { content: '✓' }
+.linkradio input:focus + label { outline: 1px dotted var(--link) }
+.linkradio input:focus:checked + label { outline: 1px dotted var(--maintext) }
+.linkradio em { font-weight: normal; font-style: normal; color: var(--grayedout) }
+
+/* Same styling, but for regular links.
+ * Usage:
+ *
+ * <a href="#" class="linkradio">Unchecked option</a>
+ * <a href="#" class="linkradio checked">Checked option</a>
+ */
+a.linkradio:before { content: '✗' }
+a.linkradio.checked:before { content: '✓' }
+a.checked { color: var(--maintext) }
+
+
+/* Spinner, <div class="spinner"></div> for a large one, <span> for a smaller inline-text version */
+.spinner { content: ''; border: 3px solid #9eaebd; border-bottom-color: transparent; border-radius: 100%; animation: spin 1s infinite linear; width: 16px; height: 16px; display: inline-block; margin: auto }
+span.spinner { width: 1em; height: 1em }
+@keyframes spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
+
+
+.textpreview {
+ > div:first-child {
+ display: flex; justify-content: space-between; width: 100%; align-items: flex-end;
+ > div:last-child > * { margin-left: 10px }
+ }
+ textarea { width: 100%; }
+ .preview { width: 100%; border: 1px solid var(--secborder); margin: 1px; padding: 5px }
+}
+
+
+/* .compact input elements are smaller and can be embedded in tables/inline text
+ * .stealth input elements pretend to be just regular text, but turn into visibile input elements on mouse-over */
+.compact input.text, .compact select, .compact textarea { margin: -2px -1px; padding: 1px 0 }
+.compact input.submit { margin: -2px -1px; padding: 1px 3px }
+.stealth input, .stealth select { font: inherit; background: none; border: 1px solid transparent; -moz-appearance: none; -webkit-appearance: none; appearance: none }
+.stealth input:hover, .stealth input:focus,
+.stealth select:hover, .stealth select:focus { border: 1px solid var(--secborder); background: var(--secbg) }
+
+
+
+
+/* Elm dropdowns (also in perl: VNWeb::Releases::Lib) */
+
+.elm_dd > a { color: var(--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 .arrow { visibility: hidden }
+.elm_dd > a .nowrap { display: block; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.elm_dd > a:hover > span:last-child > .arrow,
+.elm_dd > a:focus > span:last-child > .arrow { visibility: visible }
+.elm_dd > div { position: relative; float: right; width: 0; height: 0 }
+.elm_dd > div > div { position: absolute; right: -10px; top: 0; border: 1px solid var(--border); background-color: var(--secbg); z-index: 1000; margin: 0; padding: 0; max-width: 400px }
+.elm_dd.search > div { float: left }
+.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: var(--boxbg) }
+.elm_dd ul li p { white-space: normal; padding: 3px 5px 3px 3px }
+.elm_dd ul li.separator { margin-bottom: 22px }
+
+.elm_votedd .elm_dd ul li { text-align: left }
+.elm_dd_input .elm_dd > a,
+.elm_dd_button .elm_dd > a { background-color: var(--boxbg); color: var(--maintext); border: 1px solid var(--secborder); font: 14px "Tahoma", "Arial", sans-serif; padding: 1px 15px 1px 5px; margin: -1px }
+.elm_dd_input .elm_dd > a { padding: 1px 15px 1px 2px }
+.elm_dd_input .elm_dd > a:hover,
+.elm_dd_button .elm_dd > a:hover { background-color: var(--secbg) }
+.elm_dd_input.elm_dd_noarrow .elm_dd > a { padding-right: 2px }
+.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 > div { right: 0; top: -20px }
+.elm_dd_rightish .elm_dd > div > div { right: auto; left: -30px }
+.elm_dd_relextlink .elm_dd > a { padding-left: 4px; color: var(--link) }
+.elm_dd_relextlink ul a { text-align: right }
+.elm_dd_relextlink ul span { color: var(--maintext); padding-right: 10px }
+
+
+/***** JS dropdown (ds.js) *****/
+
+#ds {
+ position: absolute; z-index: 2; background: var(--secbg); border: 1px solid var(--secborder);
+ > div:first-child {
+ display: flex;
+ > div:first-child {
+ flex: 1; position: relative;
+ input { width: 100%; outline: none; border-top: none; border-right: none; border-left: none; margin: 0; padding: 2px }
+ span { position: absolute; top: 4px; right: 5px }
+ svg { width: 14px; height: 14px }
+ }
+ }
+ > div:last-child {
+ overflow-x: hidden; overflow-y: scroll; width: 100%; position: relative;
+ > ul {
+ margin: 0; list-style-type: none; width: 100%; height: 100%;
+ > li {
+ white-space: nowrap; cursor: pointer; padding: 3px 3px 3px 3px;
+ > span:first-child { display: inline-block; width: 1em; visibility: hidden }
+ &.active {
+ background: var(--boxbg);
+ > span:first-child { visibility: visible }
+ }
+ &.unselectable {
+ color: var(--grayedout);
+ > span:first-child { visibility: visible }
+ }
+ }
+ }
+ }
+}
diff --git a/css/layout.css b/css/layout.css
new file mode 100644
index 00000000..7ab8046c
--- /dev/null
+++ b/css/layout.css
@@ -0,0 +1,158 @@
+/* We reserve some tags for layouting:
+ * - <article> always represents a box
+ * - <nav> is reserved for either the main menu (top-level tag) or tabs (otherwise)
+ * - <main> - there can only be one in a valid document anyway
+ *
+ * Any other tags should still be re-usable (and unstyled) inside <article>.
+ */
+body {
+ display: grid;
+ grid-template-columns: 170px minmax(0, 1fr);
+ grid-template-rows: 189px auto;
+ padding: 0 30px 30px 30px;
+}
+
+article { border: 1px solid var(--border); background: var(--boxbg) }
+
+
+/* Header */
+
+body > header {
+ grid-area: 1/1/2/3;
+ display: flex; flex-direction: column;
+ h1 {
+ flex: 1; display: flex; align-items: center; justify-content: flex-end;
+ padding-right: unquote('max(30px, calc(100% - 700px))'); padding-top: 30px;
+ font: bold italic 24px "Futura", "Century New Gothic", "Arial", Serif; color: var(--maintitle);
+ a { color: inherit; border-bottom: none }
+ a:hover { border-bottom: none }
+ }
+ nav > label { display: block; border: 1px solid var(--border); background: var(--boxbg); cursor: pointer; padding: 3px 15px; margin: 0 10px 10px 0; visibility: hidden }
+}
+#bgright { position: absolute; top: 0px; right: 0px; z-index: -10 }
+#readonlymode {
+ position: absolute; top: 0px; left: 0px; width: 100%;
+ text-align: center; padding: 3px;
+ background: var(--warnbg); border-bottom: 1px solid var(--warnborder);
+}
+
+
+
+
+/* Main navigation */
+
+body > nav {
+ grid-area: 2/1/3/2;
+ margin-right: 10px;
+ article { margin: 0 0 10px 0 }
+ article div { padding: 2px 7px }
+ fieldset { padding: 0 7px 3px 6px }
+ a { color: var(--maintext) }
+ a:hover { border-bottom: 1px dotted var(--maintext); }
+ h2 { border-bottom: 1px solid var(--border); background: var(--boxbg); padding: 1px 3px; }
+ input.text { width: 100% }
+ .notifyget { display: inline-block; width: 95%; padding: 4px; background: var(--warnbg); border: 1px solid var(--warnborder); }
+ .logout { border: 0; border-bottom: 1px solid transparent; background: none; cursor: pointer; font: inherit; padding: 0; margin: 0 }
+ .logout:hover { border-bottom: 1px dotted var(--maintext) }
+
+ dl { display: flex; flex-wrap: wrap }
+ dt { width: 60%; font-style: italic }
+ dd { width: 40%; text-align: right }
+}
+
+#support { background: var(--boxbg); font-size: 92%; padding: 4px; margin-bottom: 5px; text-align: center }
+#support p { display: flex; justify-content: space-between }
+
+
+
+/* Main content boxes */
+
+main {
+ grid-area: 2 / 2 / 3 / 3;
+ padding: 0 0 50px 0;
+ h1, h2 { font-family: "Futura", "Century New Gothic", "Arial", serif; }
+ h1 { color: var(--boxtitle); font-size: 24px; font-weight: normal; margin: -5px 0 15px 0 }
+ h2.alttitle { color: var(--alttitle); font-size: 120%; font-weight: normal; margin: -17px 0 15px 15px }
+
+ /* Box */
+ article {
+ margin-bottom: 10px; padding: 5px;
+ &.browse {
+ padding: 0;
+ > table { width: 100% }
+ }
+ &.overflow-hack { overflow: hidden; width: 100% }
+ &.relgraph { float: left; min-width: 100% }
+ }
+}
+
+/* Tabs */
+body > * nav {
+ display: flex; justify-content: space-between; align-items: flex-end;
+ &.right { justify-content: flex-end; }
+
+ > menu > li {
+ display: inline-block;
+ &:not(:first-child) { margin: 0 0 0 10px }
+ > a { display: inline-block; height: 21px; padding: 1px 7px 0 7px; border: 1px solid var(--border); border-bottom: none; background-color: var(--tabbg); color: var(--grayedout); position: relative }
+ > a:hover { border-bottom: none }
+ &.tabselected > a, > a:hover { background: var(--blendbg); color: var(--maintext); height: 22px; margin-bottom: -1px; }
+ > a.highlightselected { background: var(--secbg); color: var(--maintext); height: 22px; margin-bottom: -1px }
+ > a > svg { margin-top: 2px; margin-bottom: -2px }
+ }
+ .browsetabs > li {
+ > a { color: inherit }
+ &:not(:first-child) { margin-left: 5px }
+ }
+ &.bottom {
+ margin: -10px 0 10px 0; align-items: flex-start;
+ > menu {
+ > li {
+ > a { padding: 4px 7px 2px 7px; border-bottom: 1px solid var(--border); border-top: none }
+ &.tabselected > a, > a:hover { padding-top: 5px; height: 22px; margin-top: -1px }
+ }
+ }
+ }
+ .ellipsis { font-weight: bold; height: 19px }
+
+ > h1 {
+ font-size: 17px; color: var(--grayedout); margin: 0;
+ a { color: inherit }
+ }
+}
+
+.threelayout {
+ display: flex; column-gap: 10px;
+ article { flex: 1 1 0; padding: 0 2px 10px 2px; min-width: 30% }
+ h1 { margin: 0; font-size: 18px; font-weight: bold; color: var(--boxtitle) }
+ a.right { float: right; }
+ ul { list-style-type: none; margin-left: 10px; }
+ h1 a { color: inherit }
+}
+@media (max-width: 900px) {
+ .threelayout { flex-wrap: wrap }
+ .threelayout article { min-width: 90% }
+}
+
+.summarize_more { height: 20px; margin: -11px 0 11px 0; border: 1px solid var(--border); border-top: none; background: var(--boxbg); text-align: center }
+
+
+
+/* Mobile view */
+
+@media (max-width: 800px) {
+ #mainmenu:not(:checked) ~ nav { display: none }
+ #mainmenu:not(:checked) ~ main { grid-area: 2/1/3/3; }
+ body { padding: 0 10px; }
+ #bgright { display: none /* XXX: This is theme-specific */ }
+ body > header nav > label { visibility: visible }
+}
+
+
+
+
+main > footer {
+ margin: -7px auto 0 auto; text-align: center; color: var(--footer);
+ a { color: inherit; text-decoration: underline }
+ span a { text-decoration: none }
+}
diff --git a/css/skins/air.sass b/css/skins/air.sass
new file mode 100644
index 00000000..9371b1a0
--- /dev/null
+++ b/css/skins/air.sass
@@ -0,0 +1,45 @@
+// userid: u13885 name: AIR (sky blue)
+
+////////////////////////////////////////////////////////////////
+// 'AIR' skin for VNDB.org //
+// Created by Yirba <yirba AT spiderlilytranslations DOT com> //
+// Some portions (c)2000 Key/VisualArt's //
+////////////////////////////////////////////////////////////////
+
+$bodybg: #ffffff
+$boxbg: #ccddeebc
+
+html
+ --maintext: #222222
+ --grayedout: #77a9dd
+ --standout: #bb1511
+ --link: #226588
+ --statok: #33ff38
+ --statnok: #ff3833
+ --footer: #226588
+ --maintitle: #226588
+ --boxtitle: #77a9dd
+ --alttitle: #000000
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #ddeeff
+ --secbg: #bed8f2
+ --secborder: #5677cc
+ --border: #77a9dd
+ --diffadd: #aaccbc
+ --diffdel: #ccaabb
+ --warnbg: #ccaabb
+ --warnborder: #ff3833
+ --noticebg: #aaccbc
+ --noticeborder: #33ff38
+
+body
+ background: var(--bodybg) url(/s/air-bg.jpg) no-repeat
+
+#bgright
+ background: url(/s/air-right.png) no-repeat
+ width: 197px
+ height: 140px
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/angel.sass b/css/skins/angel.sass
new file mode 100644
index 00000000..1a40ba00
--- /dev/null
+++ b/css/skins/angel.sass
@@ -0,0 +1,48 @@
+// userid: u2 name: Angelic Serenade (dark blue)
+// ^ Must be the first line of skin files, read by VNDB::Skins.
+
+$bodybg: #000 // main background color
+$boxbg: #071c30bc // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
+
+html
+ // text
+ --maintext: #ddd // primary text color (also used for the menu links)
+ --grayedout: #37a // color used for grayed-out/non-important things
+ --standout: #e44 // color of 'stand-out' text
+ --link: #7bd // primary link color (not used for the menu links)
+ --statok: #0c0 // generic 'ok' text color (used for vnlist statuses & category browser)
+ --statnok: #c00 // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
+ --footer: #247 // text color of the footer
+
+ // titles
+ --maintitle: #135 // text color of the site title, set to '0' to hide the main title
+ --boxtitle: #258 // box titles
+ --alttitle: #fff // alternative title
+
+ // bg colors
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #012 // background color of inactive tabs
+ --secbg: #0d2741 // secondary background color (used on input fields and table headers)
+ --secborder: #35A // secondary border color (used on input fields)
+ --border: #258 // primary border color
+
+ // misc colors
+ --diffadd: #354 // background color of changes in the diff viewer
+ --diffdel: #534
+ --warnbg: #534 // background color of a warning box
+ --warnborder: #c00 // ..border
+ --noticebg: #052d2e // notice box
+ --noticeborder: #227688 // ...and border
+ --helpbutton: #06ffc5 // The (i) button in forms
+
+body
+ background: var(--bodybg) url(/s/angel-bg.jpg) no-repeat
+
+#bgright
+ background: url(/s/angel-right.jpg) no-repeat
+ width: 433px
+ height: 140px
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/aselia_01.sass b/css/skins/aselia_01.sass
new file mode 100644
index 00000000..1ea6e2eb
--- /dev/null
+++ b/css/skins/aselia_01.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Eien no Aselia (falu red)
+
+// Eien no Aselia skin made using Minitokyo.Eien.no.Aselia.Scans_373967
+// created: 09/27/2009 by echomateria
+
+$bodybg: #8a3c35
+$boxbg: #ac759595
+
+html
+ --maintext: #ffffff
+ --grayedout: #eee388
+ --standout: #e96e73
+ --link: #4a2b33
+ --statok: #ffcbee
+ --statnok: #e96e73
+ --footer: #f4f1e6
+ --maintitle: #fce5e5
+ --boxtitle: #f4e1b5
+ --alttitle: #ed9f92
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #ac7595
+ --secbg: #8a3c35
+ --secborder: #e86a76
+ --border: #f1c0b2
+ --diffadd: #833d71
+ --diffdel: #6c3a55
+ --warnbg: #6b3b51
+ --warnborder: #e37192
+ --noticebg: #cc8487
+ --noticeborder: #975352
+
+body
+ background: var(--bodybg) url(/s/aselia_01-right.jpg) right top no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/carnevale.sass b/css/skins/carnevale.sass
new file mode 100644
index 00000000..e2401954
--- /dev/null
+++ b/css/skins/carnevale.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Gekkou no Carnevale (black)
+
+// Gekkou no Carnevale skin made using a wallpaper comes with the game
+// created: 22/01/2009 by echomateria
+
+$bodybg: #030708
+$boxbg: #12162290
+
+html
+ --maintext: #b9c7ae
+ --grayedout: #6f7a68
+ --standout: #f5aaaa
+ --link: #ffffff
+ --statok: #dab0fc
+ --statnok: #768b78
+ --footer: #b9c7ae
+ --maintitle: #b9c7ae
+ --boxtitle: #c7d3bf
+ --alttitle: #6f8578
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #1f272a
+ --secbg: #121622
+ --secborder: #ffffff
+ --border: #b9c7ae
+ --diffadd: #76a3b8
+ --diffdel: #354554
+ --warnbg: #5d182b
+ --warnborder: #829076
+ --noticebg: #263a45
+ --noticeborder: #21343a
+
+body
+ background: var(--bodybg) url(/s/carnevale-right.jpg) right top no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/eiel.sass b/css/skins/eiel.sass
new file mode 100644
index 00000000..0df7a1b2
--- /dev/null
+++ b/css/skins/eiel.sass
@@ -0,0 +1,41 @@
+// userid: u51 name: ELEL (peach-orange)
+
+// A skin made using an image I had for a long time without knowing it's source,
+// thankfully this skin finally brought out the answer that it was from Jingai Makyo.
+//
+// ...turns out it isn't from Jingai Makyo after all. https://vndb.org/t16934
+//
+// created: 24/01/2009 by echomateria
+
+$bodybg: #fdd298
+$boxbg: #36214299
+
+html
+ --maintext: #f8cb8a
+ --grayedout: #f26a7e
+ --standout: #ff435f
+ --link: #ffffff
+ --statok: #ffcbee
+ --statnok: #ff435f
+ --footer: #362142
+ --maintitle: #676082
+ --boxtitle: #ffcbee
+ --alttitle: #f26a7e
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #5a3a49
+ --secbg: #584563
+ --secborder: #c42b5a
+ --border: #362142
+ --diffadd: #2bc88b
+ --diffdel: #ca4a4d
+ --warnbg: #c42b5a
+ --warnborder: #f8cb8a
+ --noticebg: #b48ab2
+ --noticeborder: #f8cb8a
+
+body
+ background: var(--bodybg) url(/s/eiel-bg.jpg) no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/ever17_01.sass b/css/skins/ever17_01.sass
new file mode 100644
index 00000000..63c51499
--- /dev/null
+++ b/css/skins/ever17_01.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Ever17 (bondi blue)
+
+// Ever 17 skin made using the images from the extras section of the game
+// created: 01/01/2009 by echomateria
+
+$bodybg: #00879b
+$boxbg: #d1ebee77
+
+html
+ --maintext: #3c363f
+ --grayedout: #785a5b
+ --standout: #966932
+ --link: #013f7a
+ --statok: #9c4b00
+ --statnok: #eb0e4c
+ --footer: #e2cfa6
+ --maintitle: #f0a260
+ --boxtitle: #f0a260
+ --alttitle: #ff9013
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #81d5ea
+ --secbg: #bcd1e4
+ --secborder: #00627e
+ --border: #016c80
+ --diffadd: #f1a361
+ --diffdel: #9a7071
+ --warnbg: #c43f5c
+ --warnborder: #951924
+ --noticebg: #fefbf6
+ --noticeborder: #f1a360
+
+body
+ background: var(--bodybg) url(/s/ever17_01-right.jpg) right top no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/fate_01.sass b/css/skins/fate_01.sass
new file mode 100644
index 00000000..01b9e17a
--- /dev/null
+++ b/css/skins/fate_01.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Fate/stay night (seal brown)
+
+// FSN skin skin made using a popular fanart
+// created: 12/31/2008 by echomateria
+
+$bodybg: #200b0c
+$boxbg: #4d3c3abc
+
+html
+ --maintext: #ab928d
+ --grayedout: #916858
+ --standout: #72322b
+ --link: #eee0da
+ --statok: #efdab9
+ --statnok: #b07a6b
+ --footer: #642924
+ --maintitle: #200b0c
+ --boxtitle: #d56243
+ --alttitle: #d7926e
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #130504
+ --secbg: #7c362f
+ --secborder: #33261d
+ --border: #9e4a47
+ --diffadd: #4d2c25
+ --diffdel: #6f5347
+ --warnbg: #882f27
+ --warnborder: #fbf1e0
+ --noticebg: #882f27
+ --noticeborder: #fbf1e0
+
+body
+ background: var(--bodybg) url(/s/fate_01-bg.jpg) no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/fate_02.sass b/css/skins/fate_02.sass
new file mode 100644
index 00000000..17993c9a
--- /dev/null
+++ b/css/skins/fate_02.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Fate/stay night (pale carmine)
+
+// FSN skin made using a popular fanart
+// created: 01/01/2009 by echomateria
+
+$bodybg: #ac4b47
+$boxbg: #c6093366
+
+html
+ --maintext: #fcfbfb
+ --grayedout: #ff9d82
+ --standout: #ffc98f
+ --link: #48b2c1
+ --statok: #ff7682
+ --statnok: #9f72ff
+ --footer: #fffed5
+ --maintitle: #ffffff
+ --boxtitle: #ffffff
+ --alttitle: #c4a9a7
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #723033
+ --secbg: #792447
+ --secborder: #a68483
+ --border: #452d2c
+ --diffadd: #3d3231
+ --diffdel: #01023f
+ --warnbg: #772446
+ --warnborder: #35152d
+ --noticebg: #5c151f
+ --noticeborder: #460015
+
+body
+ background: var(--bodybg) url(/s/fate_02-bg.jpg) no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/grey.sass b/css/skins/grey.sass
new file mode 100644
index 00000000..514894bd
--- /dev/null
+++ b/css/skins/grey.sass
@@ -0,0 +1,39 @@
+// userid: u2 name: Touhou (grey)
+
+$bodybg: #fff
+$boxbg: #ddddddcc
+
+html
+ --maintext: #222
+ --grayedout: #666
+ --standout: #500
+ --link: #005
+ --statok: #050
+ --statnok: #500
+ --footer: #999
+ --maintitle: #ccc
+ --boxtitle: #444
+ --alttitle: #000
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #ddd
+ --secbg: #ccc
+ --secborder: #000
+ --border: #999
+ --diffadd: #cfc
+ --diffdel: #fcc
+ --warnbg: #fcc
+ --warnborder: #c00
+ --noticebg: #cfc
+ --noticeborder: #0c0
+
+body
+ background: var(--bodybg) url(/s/grey-bg.jpg) no-repeat;
+
+#bgright
+ background: url(/s/grey-right.jpg) no-repeat
+ width: 130px
+ height: 120px
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/higanbana.sass b/css/skins/higanbana.sass
new file mode 100644
index 00000000..ddfd1fc1
--- /dev/null
+++ b/css/skins/higanbana.sass
@@ -0,0 +1,45 @@
+// userid: u13885 name: Higanbana no Saku Yoru ni (maroon)
+
+////////////////////////////////////////////////////////////////
+// 'Higanbana no Saku Yoru ni' skin for VNDB.org //
+// Created by Yirba <yirba AT spiderlilytranslations DOT com> //
+// Some portions (c)2011 Ryukishi07/07th Expansion //
+////////////////////////////////////////////////////////////////
+
+$bodybg: #000
+$boxbg: #331111bc
+
+html
+ --maintext: #ddd
+ --grayedout: #822
+ --standout: #ec4
+ --link: #d77
+ --statok: #0c0
+ --statnok: #c00
+ --footer: #722
+ --maintitle: #511
+ --boxtitle: #822
+ --alttitle: #fff
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #200
+ --secbg: #410d0d
+ --secborder: #a33
+ --border: #822
+ --diffadd: #354
+ --diffdel: #534
+ --warnbg: #534
+ --warnborder: #c00
+ --noticebg: #354
+ --noticeborder: #0c0
+
+body
+ background: var(--bodybg) url(/s/higanbana-bg.jpg) no-repeat
+
+#bgright
+ background: url(/s/higanbana-right.png) no-repeat
+ width: 197px
+ height: 140px
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/higu.sass b/css/skins/higu.sass
new file mode 100644
index 00000000..151afc02
--- /dev/null
+++ b/css/skins/higu.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Higurashi no Naku Koro ni (orange)
+
+// Higurashi no Naku Koro ni skin made using an image I found in MiniTokyo
+// created: 22/01/2009 by echomateria
+
+$bodybg: #f89e7e
+$boxbg: #f7c7bb80
+
+html
+ --maintext: #2c1a18
+ --grayedout: #8c5b3b
+ --standout: #c24857
+ --link: #3c549c
+ --statok: #8c6290
+ --statnok: #824e52
+ --footer: #2e2536
+ --maintitle: #e5d3e1
+ --boxtitle: #1b1b51
+ --alttitle: #35346d
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #9b8587
+ --secbg: #f7c7bb
+ --secborder: #3c549c
+ --border: #2c1a18
+ --diffadd: #c2bcc6
+ --diffdel: #8c5b3b
+ --warnbg: #ae6866
+ --warnborder: #612028
+ --noticebg: #9b8587
+ --noticeborder: #dc9b7f
+
+body
+ background: var(--bodybg) url(/s/higu-bg.jpg) no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/lb.sass b/css/skins/lb.sass
new file mode 100644
index 00000000..f74b2e91
--- /dev/null
+++ b/css/skins/lb.sass
@@ -0,0 +1,34 @@
+// userid: u93 name: Little Busters! (pink)
+
+$bodybg: #fff
+$boxbg: #f7b6edcc
+
+html
+ --maintext: #408
+ --grayedout: #670159
+ --standout: #e44
+ --link: #a2d
+ --statok: #0c0
+ --statnok: #c00
+ --footer: #f76ee2
+ --maintitle: #f78de7
+ --boxtitle: #670159
+ --alttitle: #5328a7
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #f78de7
+ --secbg: #f78de7
+ --secborder: #670159
+ --border: #f76ee2
+ --diffadd: #cfc
+ --diffdel: #fcc
+ --warnbg: #fff
+ --warnborder: #c00
+ --noticebg: #f7b6ed
+ --noticeborder: #670159
+
+body
+ background: var(--bodybg) url(/s/lb-bg.jpg) no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/lb_02.sass b/css/skins/lb_02.sass
new file mode 100644
index 00000000..3ce73d1b
--- /dev/null
+++ b/css/skins/lb_02.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Little Busters! (lemon chiffon)
+
+// Little Busters! skin made using the Minitokyo.Little.Busters.Scans_316439
+// created: 09/27/2009 by echomateria
+
+$bodybg: #fff4d4
+$boxbg: #d1ebee99
+
+html
+ --maintext: #3c363f
+ --grayedout: #785a5b
+ --standout: #eb0e4c
+ --link: #04529b
+ --statok: #d87417
+ --statnok: #eb0e4c
+ --footer: #eb0e4c
+ --maintitle: #fffeff
+ --boxtitle: #ff9013
+ --alttitle: #f0a260
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #9aa4d7
+ --secbg: #bcd1e4
+ --secborder: #966932
+ --border: #016c80
+ --diffadd: #3689b5
+ --diffdel: #b7adc6
+ --warnbg: #e97a9a
+ --warnborder: #f1a360
+ --noticebg: #fefbf6
+ --noticeborder: #951924
+
+body
+ background: var(--bodybg) url(/s/lb_02-bg.jpg) no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/primitive.sass b/css/skins/primitive.sass
new file mode 100644
index 00000000..598194a6
--- /dev/null
+++ b/css/skins/primitive.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Primitive Link (pale chestnut)
+
+// Primitive Link skin made using an image that I liked without knowing what it's based on for a long time
+// created: 23/01/2009 by echomateria
+
+$bodybg: #ddac9b
+$boxbg: #935a4099
+
+html
+ --maintext: #f8eacd
+ --grayedout: #ff9d8a
+ --standout: #642a12
+ --link: #ffda63
+ --statok: #edc176
+ --statnok: #d63f21
+ --footer: #935a40
+ --maintitle: #f8eacd
+ --boxtitle: #f8eacd
+ --alttitle: #f19d20
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #935a40
+ --secbg: #be8f9f
+ --secborder: #642a12
+ --border: #edc176
+ --diffadd: #bd7f98
+ --diffdel: #c6562d
+ --warnbg: #7a3313
+ --warnborder: #ff392a
+ --noticebg: #d4aab4
+ --noticeborder: #edc176
+
+body
+ background: var(--bodybg) url(/s/primitive-right.jpg) right top no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/saya.sass b/css/skins/saya.sass
new file mode 100644
index 00000000..9ca9d699
--- /dev/null
+++ b/css/skins/saya.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Saya no Uta (dark scarlet)
+
+// Saya no Uta skin made using a criminally cute fanart
+// created: 22/01/2009 by echomateria
+
+$bodybg: #25010f
+$boxbg: #437f6388
+
+html
+ --maintext: #ffffff
+ --grayedout: #ecbc93
+ --standout: #d75f25
+ --link: #ffcb3a
+ --statok: #a55a3d
+ --statnok: #281e14
+ --footer: #e07340
+ --maintitle: #ebb48b
+ --boxtitle: #de9670
+ --alttitle: #ebb48b
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #575c51
+ --secbg: #437f63
+ --secborder: #ffcb3a
+ --border: #ebb48b
+ --diffadd: #f59731
+ --diffdel: #f2c5a3
+ --warnbg: #d45628
+ --warnborder: #fbab34
+ --noticebg: #c5af88
+ --noticeborder: #56714e
+
+body
+ background: var(--bodybg) url(/s/saya-right.jpg) right top no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/seinarukana.sass b/css/skins/seinarukana.sass
new file mode 100644
index 00000000..41660091
--- /dev/null
+++ b/css/skins/seinarukana.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Seinarukana (white)
+
+// Seinarukana skin made using a callendar image
+// created: 12/31/2008 by echomateria
+
+$bodybg: #ffffff
+$boxbg: #fde9e688
+
+html
+ --maintext: #131838
+ --grayedout: #fc8e77
+ --standout: #e93d71
+ --link: #5a5fc7
+ --statok: #424d81
+ --statnok: #a43462
+ --footer: #324978
+ --maintitle: #99c9dd
+ --boxtitle: #e93d71
+ --alttitle: #983666
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #bfd2e3
+ --secbg: #bcd1e4
+ --secborder: #7a88a5
+ --border: #324978
+ --diffadd: #3689b5
+ --diffdel: #b7adc6
+ --warnbg: #ee3970
+ --warnborder: #451f4b
+ --noticebg: #fdf1e8
+ --noticeborder: #d4aba2
+
+body
+ background: var(--bodybg) url(/s/seinarukana-bg.jpg) no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/taka.sass b/css/skins/taka.sass
new file mode 100644
index 00000000..7e046fd9
--- /dev/null
+++ b/css/skins/taka.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Sora no Iro, Mizu no Iro (turquoise)
+
+// A Sora no Iro, Mizu no Iro skin based on a wallpaper named My Perfect Day
+// created: 23/01/2009 by echomateria
+
+$bodybg: #4bb3ae
+$boxbg: #48878c92
+
+html
+ --maintext: #fefefc
+ --grayedout: #4b2427
+ --standout: #ffaa88
+ --link: #f4d926
+ --statok: #f8a022
+ --statnok: #4b2427
+ --footer: #f6ffff
+ --maintitle: #f6ffff
+ --boxtitle: #943048
+ --alttitle: #a93f56
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #3c6d69
+ --secbg: #48878c
+ --secborder: #f4d926
+ --border: #48878c
+ --diffadd: #b2f68f
+ --diffdel: #5ed8e5
+ --warnbg: #008278
+ --warnborder: #fcfefd
+ --noticebg: #5bdebe
+ --noticeborder: #a9fdc1
+
+body
+ background: var(--bodybg) url(/s/taka-right.jpg) right top no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/teal.sass b/css/skins/teal.sass
new file mode 100644
index 00000000..b843d227
--- /dev/null
+++ b/css/skins/teal.sass
@@ -0,0 +1,35 @@
+// userid: u163596 name: Teal (teal)
+// by sw1tchbl4d3
+
+$bodybg: #000
+$boxbg: #002525bc
+
+html
+ --maintext: #ddd
+ --grayedout: #007070
+ --standout: #e44
+ --link: #00c9c9
+ --statok: #0c0
+ --statnok: #c00
+ --footer: #004040
+ --maintitle: #003535
+ --boxtitle: #007070
+ --alttitle: #fff
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #001010
+ --secbg: #003434
+ --secborder: #008080
+ --border: #007070
+ --diffadd: #354
+ --diffdel: #534
+ --warnbg: #534
+ --warnborder: #c00
+ --noticebg: #354
+ --noticeborder: #0c0
+
+body
+ background-color: var(--bodybg)
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/term.sass b/css/skins/term.sass
new file mode 100644
index 00000000..a8036e7e
--- /dev/null
+++ b/css/skins/term.sass
@@ -0,0 +1,34 @@
+// userid: u93 name: Neon (black)
+
+$bodybg: #000
+$boxbg: #000
+
+html
+ --maintext: #0f0
+ --grayedout: #aaa
+ --standout: #f00
+ --link: #ff0
+ --statok: #0c0
+ --statnok: #c00
+ --footer: #fff
+ --maintitle: #0f0
+ --boxtitle: #0f0
+ --alttitle: #0f0
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #000
+ --secbg: #000
+ --secborder: #0f0
+ --border: #fff
+ --diffadd: #cfc
+ --diffdel: #fcc
+ --warnbg: #000
+ --warnborder: #c00
+ --noticebg: #000
+ --noticeborder: #0f0
+
+body
+ background-color: var(--bodybg)
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/tsukihime.sass b/css/skins/tsukihime.sass
new file mode 100644
index 00000000..cf67c5ed
--- /dev/null
+++ b/css/skins/tsukihime.sass
@@ -0,0 +1,42 @@
+// userid: u51 name: Tsukihime (midnight blue)
+
+// Tsukihime skin made using an image from the Tsukihime Plus+Disc
+// created: 02/01/2009 by echomateria
+
+$bodybg: #29345f
+$boxbg: #6a4668bb
+
+html
+ --maintext: #ffffff
+ --grayedout: #abcdff
+ --standout: #ffffff
+ --link: #0be0e9
+ --statok: #55dfaa
+ --statnok: #e30b47
+ --footer: #0cacf3
+ --maintitle: #a9bbfb
+ --boxtitle: #e3ecff
+ --alttitle: #c6d7ff
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #5a3a63
+ --secbg: #9b8494
+ --secborder: #605567
+ --border: #b791f3
+ --diffadd: #87705c
+ --diffdel: #374d77
+ --warnbg: #76a1cd
+ --warnborder: #decdcd
+ --noticebg: #b50439
+ --noticeborder: #decdcd
+
+body
+ background: var(--bodybg) url(/s/tsukihime-bg.jpg) no-repeat
+
+#bgright
+ background: url(/s/tsukihime-right.jpg) no-repeat
+ width: 500px
+ height: 718px
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/skins/tsukihime_02.sass b/css/skins/tsukihime_02.sass
new file mode 100644
index 00000000..b9f8a2d1
--- /dev/null
+++ b/css/skins/tsukihime_02.sass
@@ -0,0 +1,37 @@
+// userid: u51 name: Tsukihime (black)
+
+// Tsukihime skin made with an awesome Akiha artwork from Tsukihime PLUS disc
+// created: 23/01/2009 by echomateria
+
+$bodybg: #000000
+$boxbg: #35020990
+
+html
+ --maintext: #fa4347
+ --grayedout: #54459a
+ --standout: #fd3fa9
+ --link: #b768aa
+ --statok: #d79a7e
+ --statnok: #412651
+ --footer: #fa4347
+ --maintitle: #fa4347
+ --boxtitle: #d79a7e
+ --alttitle: #c17e61
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #2e0106
+ --secbg: #2e0106
+ --secborder: #b768aa
+ --border: #000000
+ --diffadd: #d79a7e
+ --diffdel: #412651
+ --warnbg: #46285a
+ --warnborder: #c0959f
+ --noticebg: #4f3246
+ --noticeborder: #94769a
+
+body
+ background: var(--bodybg) url(/s/tsukihime_02-bg.jpg) no-repeat
+
+@import 'css/blendbg'
+@import 'css/v2'
diff --git a/css/staffedit.css b/css/staffedit.css
new file mode 100644
index 00000000..54deed48
--- /dev/null
+++ b/css/staffedit.css
@@ -0,0 +1,7 @@
+/* Corresponds to js/contrib/StaffEdit.js */
+
+.staffedit {
+ .tc_name, .tc_name input { width: 200px }
+ .names td { padding: 1px 2px }
+ .names tr.alias_new td { padding-top: 8px }
+}
diff --git a/css/v2.css b/css/v2.css
new file mode 100644
index 00000000..aacd3282
--- /dev/null
+++ b/css/v2.css
@@ -0,0 +1,1103 @@
+html { box-sizing: border-box }
+*, *:before, *:after { box-sizing: inherit }
+* { margin: 0; padding: 0; box-sizing: border-box; font: inherit; color: inherit; text-size-adjust: none; -webkit-text-size-adjust: none; -moz-text-size-adjust: none }
+body { font: 13px "Tahoma", "Arial", sans-serif; color: var(--maintext) }
+table { border-collapse: collapse; }
+table td,
+table th { vertical-align: top; padding: 3px; }
+img { border: none; box-sizing: content-box }
+svg { fill: currentColor; }
+b,strong,h1,h2,h3,h4,h5{ font-weight: bold }
+i,em { font-style: italic }
+summary { cursor: pointer }
+
+a,
+.fake_link { color: var(--link); text-decoration: none; cursor:pointer; border-bottom: 1px solid transparent }
+
+a:hover,
+.fake_link:hover { border-bottom: 1px dotted var(--maintext); }
+
+table tr.odd,
+table.stripe tbody tr:nth-child(odd):not(.nostripe),
+.docs table tbody tr:nth-child(odd) { background: var(--boxbg); }
+
+
+/* Warning/Notice Box */
+div.warning, div.notice { margin: 5px 10%; padding: 15px; background-color: var(--warnbg); border: 1px solid var(--warnborder); }
+div.notice { background-color: var(--noticebg); border: 1px solid var(--noticeborder); }
+div.warning ul, div.notice ul { margin-left: 0; }
+div.warning li, div.notice li { margin-left: 20px; }
+div.warning h2, div.notice h2 { font-size: inherit; margin: 0; }
+
+a.addnew, p.addnew { float: right; margin: 0 }
+a.mainopts, p.mainopts { float: right; margin: 0 }
+p.mainopts a, p.mainopts label { margin: 0 5px }
+
+/* general text formatting */
+
+ul, ol { margin-left: 35px; }
+p.itemmsg { float: right; color: var(--standout); font-style: italic; margin: 0!important }
+small, .grayedout { color: var(--grayedout) }
+.underline { text-decoration: underline }
+.monospace { font-family: monospace!important }
+p.description, div.description { margin: 10px auto!important; max-width: 800px; }
+.done { color: var(--statok) }
+.todo { color: var(--statnok) }
+p.center { text-align: center; }
+b, .standout { font-weight: normal; color: var(--standout)!important }
+.clearfloat { clear: both; height: 0; }
+.hidden { display: none!important }
+.invisible { visibility: hidden }
+.linethrough { text-decoration: line-through }
+.nowrap { white-space: nowrap }
+.spoiler, .spoiler a { color: #000!important; background-color: #000 }
+.spoiler:hover, .spoiler:focus { color: var(--maintext)!important; background-color: inherit }
+.spoiler:hover a, .spoiler:focus a { color: var(--link)!important; background-color: inherit }
+.small { font-size: 11px }
+main p { margin: 3px 20px; }
+main div p,
+main fieldset p,
+main table p { margin: 0; }
+
+
+.quote {
+ padding: 1px 5px;
+ margin: 0px 10px;
+ color: var(--grayedout);
+ border: none;
+ border-left: 1px dotted var(--border);
+ text-align: left;
+}
+pre {
+ padding:1px 5px;
+ margin: 5px 15px;
+ border: 1px dotted var(--border);
+ border-right: none;
+ border-left: 1px solid var(--border);
+ background: var(--boxbg);
+ overflow-x: auto;
+}
+
+
+
+
+@import "css/layout";
+@import "css/forms";
+@import "css/vngraph";
+@import "css/staffedit";
+
+
+
+/***** Homepage ******/
+
+p.screenshots { text-align: center; margin-top: 10px; padding: 0; height: 105px; overflow: hidden }
+p.screenshots img { margin: 2px; }
+li.announcement { margin-bottom: 3px; margin-top: 3px }
+li.announcement a { font-weight: bold; font-size: 15px; color: var(--maintext) }
+
+.homepage { display: flex; flex-wrap: wrap; column-gap: 10px }
+.homepage article { flex: 1 1 0; min-width: 30%; padding: 0 2px 10px 2px; }
+@media (max-width: 1300px) { .homepage article { min-width: 45% } }
+@media (max-width: 900px) { .homepage article { min-width: 90% } }
+.homepage h1 { margin: 0 0 5px 0; font-size: 18px; font-weight: bold; color: var(--boxtitle) }
+.homepage h1 a { color: inherit; }
+.homepage h2 { margin-top: 3px; }
+.homepage ul { list-style-type: none; margin-left: 10px; }
+.homepage li { display: flex; line-height: 1.1 }
+.homepage li span { white-space: nowrap; padding-right: 4px; padding-bottom: 3px }
+.homepage li span:first-child { overflow: hidden; text-overflow: ellipsis }
+
+
+
+
+/***** Browsing ******/
+
+.browseopts a, .browseopts button {
+ padding: 1px 3px;
+ color: inherit;
+ border: 1px solid var(--border);
+ margin: 0 2px;
+ white-space: nowrap;
+}
+p.browseopts { text-align: center; padding: 2px; }
+span.browseopts { text-align: center; padding: 10px; display: inline-block }
+.browseopts .optselected,
+.browseopts a:hover,
+.browseopts button:hover { border: 0; padding: 2px 4px; }
+.browse table td.tc1 { padding-left: 25px; }
+table thead td, table thead th { font-weight: bold; background-color: var(--secbg); }
+table thead th { text-align: left }
+fieldset.search { display: block; width: 100%; text-align: center; margin: 0 0 10px 0; }
+fieldset.search .submit { padding: 0 1px; }
+#searchtabs {
+ display: flex; align-items: flex-end; justify-content: center; padding-right: 70px;
+ a {
+ padding: 2px 6px 0px 6px;
+ border: 1px solid transparent;
+ border-bottom: none;
+ margin: 0 2px;
+ color: inherit;
+ }
+ a:hover, a.sel {
+ border: 1px solid var(--secborder);
+ border-bottom: none;
+ background: var(--boxbg);
+ }
+}
+#q { width: 600px }
+#bq { width: 300px }
+
+
+
+/* history browser */
+
+.histoptions { margin: 0 auto }
+.histoptions select { width: 150px; scrollbar-width: none }
+.histoptions select::-webkit-scrollbar { display: none }
+
+.history td { white-space: nowrap; padding-left: 15px }
+.history td.tc1_0 { padding-right: 0; padding-left: 0 }
+.history td.tc1_0 a { color: inherit; display: inline-block; width: 15px; border-bottom: 0; text-align: center }
+.history td.tc1_1 { width: 70px; padding-right: 0; text-align: right }
+.history td.tc1_2 { width: 30px; padding-left: 0 }
+.history td.tc2 { width: 140px }
+.history td.tc4 { width: 100% }
+.history td.tc4 small { margin-left: 10px }
+
+
+
+
+/***** Discussions ******/
+
+/* threads page */
+.thread { padding: 0; }
+.thread table { width: 100%; table-layout: fixed }
+.thread tr:not(:last-child) td { border-bottom: 1px solid var(--border); }
+.thread td.tc1 { width: 190px; padding: 5px 10px; border-right: 1px solid var(--border); }
+.thread td.tc2 { padding: 10px 20px 10px 10px; }
+.thread tr.deleted td { padding: 1px 10px; }
+.thread tr:target, .thread tr.target { outline: 1px dotted var(--standout) }
+.thread .lastmod { float: right; font-size: 11px; color: var(--grayedout); margin: 0 -10px -5px 0; }
+.thread .edit { float: right; color: var(--grayedout); margin: -10px -10px 0 0; visibility: hidden }
+.thread td:hover .edit,
+.thread td:active .edit { visibility: visible }
+
+/* threads browser */
+.discussions td.tc4 { text-align: right; }
+.discussions a.locked { text-decoration: line-through; }
+.discussions .boards { padding-left: 10px }
+.discussions .boards a { color: var(--grayedout) }
+.discussions td.tc2 { width: 66px; text-align: right }
+.discussions td.tc3 { width: 116px; }
+.discussions td.tc4 { width: 256px; }
+.discussions .pollflag { color: var(--grayedout); padding-right: 6px; }
+.boardsearchoptions {
+ margin: 0 auto;
+ td { padding: 10px }
+ select { width: 150px; scrollbar-width: none }
+ select::-webkit-scrollbar { display: none }
+}
+.postsearch td.tc1_1 { width: 60px; padding-left: 0; padding-right: 0; text-align: right }
+.postsearch td.tc1_2 { width: 25px; padding-left: 0 }
+.postsearch td.tc2 { width: 65px; }
+.postsearch td.tc3 { width: 90px; }
+
+
+/***** Release listings on VN & producer pages */
+
+.releases { width: 100%; }
+.releases tr.vn > td { background: var(--boxbg); font-weight: bold; }
+.releases tr.vn .ulist-widget-icon { padding-right: 10px }
+.releases td.tc1 { padding-left: 30px; width: 110px; white-space: nowrap }
+.releases td.tc2 { width: 46px; white-space: nowrap }
+.releases td.tc3 { text-align: right; padding: 0; width: 120px; }
+.releases td.tc_icons { text-align: right; padding: 0 4px }
+.releases td.tc_prod { color: var(--grayedout); white-space: nowrap; width: 56px }
+.releases td.tc5 { width: 76px; text-align: right }
+.releases td.tc6 { text-align: right; width: 35px; padding: 0; white-space: nowrap }
+.releases tr.mtl a,
+.releases tr.mtl td { color: var(--grayedout) }
+.releaseero { cursor: default; color: transparent; opacity: 0.7 }
+.releaseero_no { text-shadow: 0 0 var(--grayedout) }
+.releaseero_yes { text-shadow: 0 0 var(--maintext) }
+.releaseero_cen { text-shadow: 0 0 var(--statnok) }
+.releaseero_unc { text-shadow: 0 0 var(--statok) }
+
+.vnreleases details { margin: 7px 0 3px 0 }
+.vnreleases details[open] summary small { display: none }
+.vnreleases summary { background: var(--boxbg); font-weight: bold; width: 100% }
+.vnreleases summary.mtl { color: var(--grayedout); }
+
+/***** VN page *******/
+
+div.vndetails { margin: 0 auto; max-width: 820px; }
+div.vnimg { float: left; width: 250px; margin: 0 10px; }
+div.vnimg p { text-align: center; padding: 0px; margin: 0; }
+div.vndetails h2 { margin: 5px 0 0 0; }
+.vndesc p { padding: 0 0 0 5px; }
+div.vndetails > table { float: left; width: 100%; max-width: 500px; }
+div.vndetails td.key,
+div.vndetails > table > tbody > tr > td:first-child { width: 96px; }
+div.vndetails > table dt { float: left; font-style: italic; }
+div.vndetails > table dd { margin-left: 90px; }
+div.vndetails td.titles div { display: inline-block }
+div.vndetails td.titles summary table { margin-top: -18px }
+div.vndetails td.titles table { margin-left: 96px }
+div.vndetails tr.title td { padding: 0 0px }
+div.vndetails td.relations dt { float: none; font-style: normal; }
+div.vndetails td.relations dd { margin-left: 15px; }
+div.vndetails td.relations label { float: right }
+div.vndetails td.relations input:not(:checked) ~ dl .unofficial { display: none }
+#buynow .ad { float: right }
+div.vndetails td.anime span { font-size: 10px; padding-right: 4px; }
+div.vndetails .lengthvotefrm {
+ margin-top: -18px; text-align: right;
+ form { text-align: left }
+ td {
+ padding: 0;
+ input[type=button] { width: 25px; margin-right: -2px }
+ }
+ table, select { width: 100% }
+}
+.ulistvn { padding: 5px 0 0 0 }
+.ulistvn > strong { font-size: 14px }
+.ulistvn > span { float: right }
+.ulistvn textarea { width: 100% }
+.ulistvn .date span:not(.spinner) { display: none }
+
+div#vntags { margin: 0 30px 0 30px; border-top: 1px solid var(--border); padding: 3px 5% 0 5%; text-align: center; }
+#vntags span { white-space: nowrap; margin-left: 15px; }
+#vntags small { font-size: 10px }
+#vntags .lieo { text-decoration: line-through }
+#tagops { text-align: right; width: auto; }
+
+#tagops label { margin: 0 0 0 10px; border: 0; outline: none; color: var(--link); cursor: pointer; }
+#tagops label.sec { border-left: 1px solid var(--border); padding-left: 10px }
+#tagops label.lst { margin: 0 30px 0 10px; }
+#tagops input:checked + label { color: var(--maintext); }
+
+/* tag filter machinery; the order of declarations is important */
+
+#tag_spoil_all:checked ~ #vntags .cut2 { display: none; }
+#tag_spoil_some:checked ~ #vntags .cut1 { display: none; }
+#tag_spoil_none:checked ~ #vntags .cut0 { display: none; }
+
+#tag_toggle_all:checked ~ #vntags .cut { display: inline }
+
+#cat_cont:not(:checked) ~ #vntags .cat_cont { display: none; }
+#cat_tech:not(:checked) ~ #vntags .cat_tech { display: none; }
+#cat_ero:not(:checked) ~ #vntags .cat_ero { display: none; }
+
+#tag_spoil_none:checked ~ #vntags .tagspl1 { display: none; }
+#tag_spoil_none:checked ~ #vntags .tagspl2 { display: none; }
+#tag_spoil_some:checked ~ #vntags .tagspl2 { display: none; }
+
+#tag_spoil_all:checked ~ #vntags .lie { text-decoration: line-through }
+
+/* end of tag filter machinery */
+
+#screenshots table { width: 100%; }
+#screenshots tr.rel td { background: var(--boxbg); font-weight: bold; }
+
+#screenshots p.rel {
+ background: var(--boxbg);
+ margin: 0;
+ padding: 2px;
+ font-weight: bold;
+ text-align: center;
+}
+#screenshots a.scrlnk { margin: 2px; border: none }
+#screenshots div.scr { display: block; padding: 0 15px; text-align: center }
+#screenshots a img { border: 3px solid transparent; }
+#screenshots a.nsfw img { border: 3px solid var(--statnok); }
+#screenshots a:hover img { border: 3px solid var(--border); }
+
+#scrhide_s0:checked ~ #screenshots label[for=scrhide_s0],
+#scrhide_s1:checked ~ #screenshots label[for=scrhide_s1],
+#scrhide_s2:checked ~ #screenshots label[for=scrhide_s2],
+#scrhide_v0:checked ~ #screenshots label[for=scrhide_v0],
+#scrhide_v1:checked ~ #screenshots label[for=scrhide_v1],
+#scrhide_v2:checked ~ #screenshots label[for=scrhide_v2] { color: var(--maintext) }
+
+#screenshots .scrlnk { display: none }
+#scrhide_s0:checked ~ #screenshots .scrlnk_s0 { display: inline }
+#scrhide_s1:checked ~ #screenshots .scrlnk_s1 { display: inline }
+#scrhide_s2:checked ~ #screenshots .scrlnk { display: inline }
+#scrhide_v0:checked ~ #screenshots .scrlnk_v0 { display: none }
+#scrhide_v1:checked ~ #screenshots .scrlnk_v1 { display: none }
+
+.reviews {
+ display: flex; column-gap: 10px; flex-wrap: wrap;
+ > article {
+ flex: 1 1 0; flex-basis: 450px; padding: 0;
+ display: flex; flex-direction: column;
+ > div:nth-child(3) > span:first-child { float: right; color: var(--grayedout); font-style: normal; margin: -5px 0 0 0; visibility: hidden }
+ > div:nth-child(3):hover > span:first-child,
+ > div:nth-child(3):active > span:first-child { visibility: visible }
+ .review_spoil input:checked ~ span { display: none }
+ .review_spoil input:not(:checked) ~ div { display: none }
+ > div:first-child { padding: 3px 5px; display: flex; justify-content: space-between; background: var(--secbg); font-weight: bold }
+ > div:first-child > span:first-child { font-weight: bold }
+ > div:nth-child(2) { background: var(--secbg) }
+ > div:nth-child(2) p { padding: 2px; text-align: center }
+ > div:nth-child(3) { flex: 1; padding: 10px }
+ > div:last-child { padding: 2px 5px; display: flex; justify-content: space-between; background: var(--boxbg) }
+ .myvote { font-weight: bold; text-decoration: underline }
+ }
+}
+
+.quote-score {
+ white-space: nowrap;
+ a { border: 0!important; color: var(--grayedout) }
+ a:hover, a.active { color: var(--maintext) }
+ svg { width: 14px; height: 14px }
+}
+
+.browse.quotes {
+ .tc1, .tc2, .tc3 { white-space: nowrap }
+ .tc1 { width: 90px }
+ .tc2 { width: 130px }
+ .tc3 { width: 140px }
+ .setfil { font-size: 10px; padding-right: 3px }
+}
+
+
+/***** 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:not(.grayedout) { color: var(--maintext); font-weight: bold }
+.vntaglist li.tagvnlist-inherited a { color: var(--grayedout) }
+.vntaglist li:not(.tagvnlist-inherited) small { color: var(--link) }
+.vntaglist h3 a { color: var(--maintext) }
+.vntaglist .lie { text-decoration: line-through }
+.vntaglist li { list-style-type: none; padding-right: 30px }
+.vntaglist li .tagscore { margin-right: 10px }
+
+
+
+/***** Vote stats ****/
+
+.votestats { width: 100%; max-width: 630px; margin: 0 auto; }
+.votegraph { float: left; margin-right: 20px }
+.votegraph td { padding: 0 2px }
+.votegraph td.number { text-align: right }
+.votegraph td div { float: left; height: 16px; background-color: var(--border); margin-right: 2px; padding: 0; }
+.votestats thead td { background: transparent; text-align: center; padding: 2px; }
+.votestats tfoot td { text-align: right }
+.votestats div { text-align: center; padding-top: 5px; }
+
+.recentvotes { width: 300px }
+.recentvotes thead tr td span { font-weight: normal; padding-left: 5px }
+
+
+
+/***** Polls ****/
+
+.votebooth thead td { font-weight: normal; background: transparent; padding-bottom: 5px; }
+.votebooth tfoot td { padding-top: 5px }
+.votebooth td { vertical-align: middle; padding: 0 8px; }
+.votebooth { margin: 0 30px }
+.votebooth td.tc1 { padding-right: 20px }
+.votebooth td.tc2 { min-width: 240px }
+.votebooth td.tc2 div { margin: 2px; }
+.votebooth td.tc2 div.graph { float: left; height: 14px; background-color: var(--border); padding: 0; }
+.votebooth td.tc3 { text-align: right; padding-right: 16px; }
+.votebooth .submit { width: 100px }
+.votebooth .option { margin-left: 8px }
+.votebooth .option.own { font-weight: bold }
+
+
+
+/***** VN edit *****/
+
+.vnedit_scr { width: 95%; margin: auto }
+.vnedit_scr > tr:nth-child(odd) > td { background: var(--boxbg) }
+.vnedit_scr > tr > td { border-bottom: 1px solid var(--border) }
+.vnedit_scr > tr > td:nth-child(1) { padding: 10px; width: 136px }
+.vnedit_scr > tr > td:nth-child(2) { width: 10px; padding-top: 20px }
+
+
+/***** VN Release tab *****/
+
+.releases_compare table { margin: 0 auto; }
+.releases_compare td { margin: 0 auto; border: 1px solid var(--border); }
+.releases_compare td.bg { background: var(--boxbg); }
+.releases_compare td.multi { vertical-align:middle; }
+.releases_compare .key { background: var(--boxbg); }
+
+/****** VN browse ********/
+
+.vnbrowse .tc_score { padding-left: 30px; width: 70px }
+.vnbrowse .tc_score + td { padding-left: 0 }
+.vnbrowse .tc_ulist { padding-left: 20px; width: 16px }
+.vnbrowse .tc_ulist + td.tc_title { padding-left: 10px }
+.vnbrowse .tc_title { padding-left: 30px }
+.vnbrowse .tc_plat { text-align: right; padding: 0; }
+.vnbrowse .tc_lang { padding: 0; }
+.vnbrowse .tc_rating { width: 100px; white-space: nowrap }
+.vnbrowse .tc_average { width: 80px; white-space: nowrap }
+
+.vncards { padding: 0; display: flex; flex-wrap: wrap }
+.vncards > div { padding: 2px 2px 10px 2px; display: flex; flex: 1; min-width: 380px }
+.vncards > div:hover { background-color: var(--secbg) }
+.vncards > div > div:first-child { flex-shrink: 0; width: 90px; height: 120px; text-align: center }
+.vncards > div > div:nth-child(2) { height: 120px; padding-left: 5px; overflow-y: hidden; width: 100% }
+.vncards table td { padding: 0 5px 0 0 }
+.vncards .ulist-widget-icon { float: right; margin: 2px 5px 0 5px!important }
+
+.vngrid { padding: 10px; display: flex; flex-wrap: wrap; justify-content: space-evenly }
+.vngrid > div { margin: 5px 10px 15px 10px; flex: 1 0 200px; max-width: 256px; height: 300px; background-size: cover; background-repeat: no-repeat; background-position: top }
+.vngrid > div > a { display: none }
+.vngrid > div > a:hover { border-bottom: none }
+.vngrid > div:hover > a, .vngrid > div.noimage > a { padding: 5px; display: block; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); color: var(--maintext) }
+.vngrid > div > a > span:first-child { display: block; font-weight: bold }
+.vngrid table { margin: 10px 0 }
+.vngrid table td { padding: 0 5px 0 0 }
+.vngrid .ulist-widget-icon { float: right; margin: 273px 0 -300px 0px!important; padding: 10px 5px 0 10px; background-color: rgba(0,0,0,0.8); border-radius: 15px 0 0 0 } /* Horrible hacks everywhere */
+
+
+
+/***** Producer page *******/
+
+.prodvns { list-style-type: none }
+.prodvns li > span:first-child { display: inline-block; width: 95px; text-align: right; padding-right: 15px }
+.prodvns li > span:last-child { color: var(--grayedout); padding-left: 15px }
+.prodvns .ulist-widget-icon { padding-right: 5px }
+
+
+/***** Producer list ******/
+
+.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; }
+
+
+
+
+/***** Release page *****/
+
+.release table { width: 500px; margin: 0 auto; }
+.release td.titles table { margin-left: 96px }
+.release tr.title td { padding: 0 0px }
+.release tr.title td:first-child { width: 20px }
+.release .key { width: 110px; }
+.release dt { float: none; font-style: normal; }
+.release dd { margin-left: 15px; }
+
+
+/***** Review page *****/
+
+.fullreview td { padding: 0 0 15px 10px }
+.fullreview tr > td:first-child { width: 140px; text-align: right; font-weight: bold }
+.fullreview tr > td:last-child { max-width: 700px }
+.fullreview .myvote { font-weight: bold; text-decoration: underline }
+
+#reviewspoil:not(:checked) ~ .fullreview .reviewspoil { display: none }
+#reviewspoil:checked ~ .fullreview .reviewnotspoil { display: none }
+
+
+/***** Review browser *****/
+
+.reviewlist td.tc1 { width: 120px }
+.reviewlist td.tc2 { width: 110px; }
+.reviewlist td.tc3 { width: 50px; text-align: right }
+.reviewlist td.tc4 { width: 50px }
+.reviewlist td.tc6 { width: 140px }
+.reviewlist td.tc7 { width: 30px; text-align: right }
+.reviewlist td.tc8 { width: 260px; text-align: right }
+
+
+/***** Release browser *****/
+
+.relbrowse .tc1 { width: 80px }
+.relbrowse .tc2 { width: 60px; text-align: center; }
+.relbrowse .tc3 { width: 85px; text-align: right; padding: 0; }
+
+
+/***** DRM List ****/
+
+.drmlist > div { margin: 3px 20px 10px 20px }
+
+/***** Image hover thingy (VNWeb::Images::Lib::images_) ****/
+
+.imghover { margin: 0 auto; display: block; text-align: center }
+.imghover input:checked ~ div.imghover--warning { display: none }
+.imghover input:not(:checked) ~ div.imghover--visible { display: none }
+.imghover div.imghover--visible { position: relative }
+.imghover div.imghover--visible > a { border-bottom: 0 }
+.imghover div.imghover--visible .imghover--overlay { display: none; white-space: nowrap; font-size: 11px }
+.imghover:hover div.imghover--visible .imghover--overlay { display: block; position: absolute; right: 0; bottom: 0; padding: 5px 10px; background: var(--secbg); border: 0 }
+.imghover div.imghover--warning { border: 1px solid var(--border); background: var(--secbg); padding: 10px 5px }
+
+
+
+/***** Char page (also used on VN page) *****/
+
+p.chardetailopts { margin: -10px auto 7px auto; width: 800px; text-align: right }
+p.chardetailopts a { margin: 0 5px }
+p.chardetailopts a:last-child { margin: 0 0 0 5px }
+div.chardetails { margin: 0 auto; width: 800px; }
+div.charimg { float: left; width: 256px; margin: 0 15px 0 0; text-align: center }
+div.charimg p { text-align: center; padding: 0px; margin: 0; }
+.chardesc h2 { margin: 0; }
+.chardesc p { padding: 0 0 0 5px; }
+div.chardetails table { float: left; width: 525px; }
+div.chardetails table td.key { width: 106px; }
+div.chardetails.charsep { margin-top: 30px }
+div.chardetails .lie { text-decoration: line-through }
+div.charquotes { margin: 0 auto; width: 800px; }
+
+
+
+/***** Char edit *****/
+
+
+table.chare_traits .buts { padding: 0 20px }
+table.chare_traits .buts a { display: block; width: 15px; height: 14px; border: 1px solid var(--border); margin: 0; float: left }
+table.chare_traits .buts a.s0 { border: none; background-color: #0f0 }
+table.chare_traits .buts a.s1 { border: none; background-color: #f80 }
+table.chare_traits .buts a.s2 { border: none; background-color: #f40 }
+table.chare_traits .buts a:nth-child(4) { margin-left: 20px }
+table.chare_traits .buts a.sl { border: none; background-color: #f40 }
+
+
+/***** Char browse *****/
+
+.charb table { table-layout: fixed }
+.charb td { white-space: nowrap }
+.charb td.tc1 { text-align: right; width: 40px; padding-left: 0!important; padding-bottom: 0 }
+.charb td.tc2 { overflow: hidden }
+.charb td.tc2 small { margin-left: 10px }
+.charb td.tc2 small a { color: inherit }
+
+.charbcard { padding: 0; display: flex; flex-wrap: wrap }
+.charbcard > div { padding: 2px 2px 10px 2px; display: flex; flex: 1; min-width: 280px }
+.charbcard > div:hover { background-color: var(--secbg) }
+.charbcard > div > div:first-child { flex-shrink: 0; width: 90px; height: 120px; text-align: center }
+.charbcard > div > div:nth-child(2) { height: 120px; padding-left: 5px; overflow-y: hidden }
+.charbcard small a { color: inherit }
+
+.charbgrid { padding: 10px; display: flex; flex-wrap: wrap; justify-content: space-evenly }
+.charbgrid a { margin: 5px 10px 15px 10px; flex: 1 0 200px; max-width: 256px; height: 300px; background-size: cover; background-repeat: no-repeat; background-position: top; text-align: center; opacity: 0.9 }
+.charbgrid a:hover { border-bottom: none; opacity: 1 }
+.charbgrid span { display: block; margin: 0 auto; padding: 2px; font-size: 15px; font-weight: bold; background-color: rgba(0,0,0,0.4); color: var(--maintext) }
+
+
+/***** Staff browse *****/
+
+.staffbrowse { padding-bottom: 10px }
+.staffbrowse ul { margin-top: -5px; margin-left: 3%; column-width: 300px }
+.staffbrowse ul li { list-style-type: none; margin-bottom: 2px; }
+.staffbrowse ul li abbr { margin-right: 5px; margin-top: 1px; }
+.staffpage table.stripe { width: 450px; margin: 0 auto; }
+.staffpage .key { width: 76px; }
+.staffroles td.tc_ulist { padding-left: 15px; width: 40px }
+.staffroles td.tc_ulist + td { padding-left: 0!important }
+.staffroles td.tc2 { white-space: nowrap; width: 100px }
+.staffroles td.tc3 { white-space: nowrap; width: 100px }
+.staffroles td.tc4 { white-space: nowrap; padding-right: 10px }
+table.aliases td { padding: 0 5px; }
+table.aliases td.key { padding: 0 5px 0 0; width: auto }
+
+
+/***** Staff display on VN pages *****/
+
+.vnstaff div { width: 97%; margin: 0 auto 5px auto; justify-content: space-between }
+.vnstaff ul { list-style: none; margin: 0 }
+.vnstaff-2 ul { width: 100% } .vnstaff-2 { flex-wrap: wrap }
+.vnstaff-3 ul { width: 32% }
+.vnstaff-4 ul { width: 24% }
+.vnstaff li { padding-bottom: 1px; padding-left: 10px; }
+.vnstaff li a { display: inline-block; margin-left: -10px }
+.vnstaff li small { padding-left: 10px }
+.vnstaff li.vnstaff_head { font-weight: bold; padding-left: 0 }
+.vnstaff li:not(:first-child).vnstaff_head { margin-top: 15px }
+.vnstaff summary { background: var(--boxbg); font-weight: bold; width: 100% }
+@media(min-width: 0px) { .vnstaff-2{display:flex} .vnstaff-3{display:none} .vnstaff-4{display:none} }
+@media(min-width: 850px) { .vnstaff-2{display:flex} .vnstaff-3{display:none} .vnstaff-4{display:none} .vnstaff-2 ul {width:49%} .vnstaff-2{flex-wrap:nowrap} }
+@media(min-width:1000px) { .vnstaff-2{display:none} .vnstaff-3{display:flex} .vnstaff-4{display:none} }
+@media(min-width:1250px) { .vnstaff-2{display:none} .vnstaff-3{display:none} .vnstaff-4{display:flex} }
+
+div.charsum_list { text-align: center }
+div.charsum_list .name { white-space: nowrap; display: flex; justify-content: space-between }
+div.charsum_list .name span { overflow: hidden; text-overflow: ellipsis; padding-bottom: 1px }
+div.charsum_list .name a { font-weight: bold }
+div.charsum_list .actor { border-top: 1px solid var(--border); padding-top: 3px }
+div.charsum_list .actor small { margin-left: 10px }
+div.charsum_list .charsum_bubble {
+ background: var(--boxbg);
+ display: inline-block;
+ text-align: left;
+ vertical-align: top;
+ width: 100%;
+ max-width: 340px;
+ margin: 3px;
+ padding: 3px 10px;
+}
+
+
+/***** Documentation pages *****/
+
+.docs { padding: 0 15% 20px 15%; line-height: 1.4 }
+.docs h3 { margin: 30px 0 5px; font-size: 16px }
+.docs h4 { margin-top: 15px; font-size: 14px }
+.docs h3 a:target,
+.docs h4 a:target { color: var(--standout) }
+.docs dd { margin: 5px 0 5px 120px }
+.docs dt { float: left }
+.docs ul, .docs ol { margin: 5px 0 5px 20px }
+.docs table { margin: 10px; width: 95% }
+.docs td { white-space: nowrap }
+.docs td { white-space: nowrap }
+.docs td+td+td+td,
+.docs td[colspan],
+.docs td[colspan]+td,
+.docs td[colspan]+td+td { white-space: normal }
+.docs p + p { padding-top: 10px }
+.docs ul.index { display: block; float: right; width: 190px; padding: 2px; margin: 0 0 10px 5px; background: var(--boxbg); border: 1px solid var(--border); }
+.docs ul.index li { list-style-type: none; }
+.docs ul.index li a { margin: 0 0 0 10px; }
+.docs img { margin: 5px }
+
+
+
+/* vote lists */
+
+.votelist td.tc1 { width: 100px; padding-top: 0; padding-bottom: 0 }
+.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px }
+
+
+
+/* vn/user length vote list */
+
+.lengthlist {
+ .tc1 { width: 120px }
+ .tc3 { width: 60px; white-space: nowrap }
+ .tc4 { width: 100px; padding-left: 10px }
+ .tc5 { width: 70px; white-space: nowrap }
+ .tc7 { width: 10px; text-align: right; padding: 0 }
+ select { width: 70px }
+}
+
+
+/***** User's VN list *****/
+
+.labelfilters { text-align: center }
+.labelfilters .xsearch { text-align: left }
+.labelfilters .linkradio { padding: 5px }
+
+.managelabels > div { width: 600px; margin: 10px auto }
+.managelabels table { margin: 0 auto }
+.managelabels tbody td:nth-child(1) { text-align: right }
+.managelabels tbody td:nth-child(4) { padding-left: 10px; width: 300px}
+.managelabels select { width: 100% }
+.managelabels tfoot div { float: right; text-align: right }
+
+.savedefault { width: 600px; margin: 10px auto }
+.exportlist { width: 600px; margin: 10px auto }
+
+.ulist .tc1 { white-space: nowrap; width: 100px }
+.ulist .tc1 label { cursor: pointer }
+.ulist .tc1 input { display: none }
+.ulist .tc1 label:before { content: '▸ ' }
+.ulist .tc1 input:checked + label:before { content: '▾ ' }
+.ulist .tc_title small { margin-left: 10px }
+.ulist .tc_vote { white-space: nowrap; width: 60px; text-align: right; padding-right: 10px }
+.ulist .tc_vote input { width: 55px; text-align: right }
+.ulist .tc_voted,
+.ulist .tc_added,
+.ulist .tc_modified,
+.ulist .tc_started,
+.ulist .tc_finished,
+.ulist .tc_rdate { white-space: nowrap; width: 100px }
+.ulist .tc_rating { white-space: nowrap; width: 80px }
+
+.ulist .tc_started div, .ulist .tc_finished div { height: 1em; padding-bottom: 4px }
+.ulist .tc_started div span, .ulist .tc_finished div span { position: absolute; z-index: 0 }
+.ulist .tc_started div input, .ulist .tc_finished div input { position: absolute; z-index: 900; width: 110px; visibility: hidden }
+.ulist .tc_started div:hover input, .ulist .tc_finished div:hover input,
+.ulist .tc_started div input:focus, .ulist .tc_finished div input:focus { visibility: visible }
+
+.ulist .tc_opt { padding: 0 0 5px 70px }
+.ulist .tc_opt textarea { width: 500px; height: 18px; border: none }
+.ulist .tc_opt textarea:focus { height: 50px; border: 1px solid var(--secborder) }
+.ulist .tc_opt textarea + div { display: inline-block; padding-left: 10px }
+.ulist .tc_opt .tco1 { white-space: nowrap; width: 100px }
+.ulist .tc_opt .tco2 { white-space: nowrap; width: 100px }
+.ulist .tc_opt .tco3 { white-space: nowrap; width: 60px; text-align: right; padding-bottom: 0 }
+
+
+/***** ulist-widget (elm/Ulist/Widget) *****/
+
+.ulist-widget-icon { cursor: pointer }
+.ulist-widget {
+ z-index: 100; position: fixed; height: 100%; width: 100%; top: 0; right: 0; display: flex; align-items: center; justify-content: center; text-align: left; white-space: normal; background: var(--boxbg); overflow: auto; font-weight: normal;
+ > div {
+ background: var(--blendbg); width: 624px; min-height: 324px; border: 2px solid var(--border); padding: 10px;
+ > div.spinner { position: absolute; top: unquote('calc(50% - 8px)'); right: unquote('calc(50% - 8px)'); }
+ }
+ table { width: 100% }
+ table tr { background: none!important }
+ table td:first-child { width: 106px }
+ textarea, select { margin: 0 -1px; width: 100% }
+ .date span:not(.spinner) { display: none }
+ .tco1 { white-space: nowrap; width: 100px }
+ .tco2 { white-space: nowrap; width: 100px }
+ .tco3 { width: 60px; text-align: right; padding-bottom: 0 }
+}
+
+/* Just kill me already */
+[id^=ulist_labeledit] li > a {
+ padding-right: 30px!important;
+ abbr, .spinner { float: right; margin-right: -26px; margin-top: 2px }
+}
+
+
+/***** User notifications *****/
+
+.browse.notifies td.tc1 { width: 14px }
+.browse.notifies td.tc3 { width: 100px }
+.browse.notifies td.tc4 { width: 75px }
+.browse.notifies tbody td.tc5 a { color: var(--grayedout) }
+.browse.notifies td.tc5 span { color: var(--maintext) }
+.browse.notifies .unread td { font-weight: bold }
+.browse.notifies tfoot td { padding: 0 0 0 25px }
+
+
+/***** Maintabs dropdown thingy used by js Subscribe & TableOpts ****/
+
+.maintabs-dd p, .maintabs-dd h4, .maintabs-dd label { display: block; margin-bottom: 3px }
+.maintabs-dd h4 { text-align: center }
+.maintabs-dd > div { position: absolute; width: 1px }
+.maintabs-dd > div > div { width: 500px; border: 1px solid var(--border); background: var(--secbg); position: relative; bottom: 0; left: -470px; z-index: 100 }
+
+.tableopts > li > a > svg { width: 14px; height: 14px }
+.tableopts-results > div > div { width: 180px; left: -140px; text-align: center }
+.tableopts-save > div > div { width: 250px; left: -210px; text-align: center }
+.tableopts-save input { width: 97% }
+.tableopts-save input:nth-of-type(2) { margin-top: 15px }
+.tableopts-cols > div > div { width: 150px; left: -110px; text-align: right; padding: 0 10px }
+.tableopts-sort > div > div { width: 250px; left: -210px; text-align: right; padding: 0 10px }
+.tableopts-sort table { margin: 0 0 0 auto }
+.tableopts-sort td { padding: 0 0 0 15px }
+
+.subscribe .inactive { color: transparent; text-shadow: 0 0 var(--grayedout) }
+.subscribe .active { color: transparent; text-shadow: 0 0 var(--maintext) }
+.subscribe > div > div { padding: 10px }
+
+/***** User list *****/
+.browse.userlist .tc3,
+.browse.userlist .tc4,
+.browse.userlist .tc5,
+.browse.userlist .tc6,
+.browse.userlist .tc7,
+.browse.userlist .tc8 { text-align: right; padding-right: 10px; padding-left: 0 }
+
+
+/***** Userpage *****/
+
+.userpage table { width: 600px; margin: 0 auto; }
+.userpage .key { width: 106px; }
+
+
+/***** User posts browser ****/
+.uposts table { table-layout: fixed }
+.uposts td { white-space: nowrap }
+.uposts td.tc1 { width: 60px; padding-left: 0!important; padding-right: 0; text-align: right }
+.uposts td.tc2 { width: 40px; padding-left: 0 }
+.uposts td.tc3 { width: 80px; }
+.uposts td.tc4 { overflow: hidden }
+.uposts td.tc4 small { margin-left: 10px }
+
+
+
+
+/***** Tag page *****/
+
+.tagtree { margin-left: 20px; margin-top: -20px; list-style-type: none; }
+.tagtree li { float: left; width: 230px; margin-top: 10px; }
+.tagtree li li { float: none; width: auto; margin-top: 0; }
+.tagtree ul { margin-left: 10px; list-style-type: none; }
+.tagvnlist .tc1 { width: 105px; }
+.tagvnlist .tc1 i { font-style: normal; font-size: 10px }
+.tagvnlist .tc3 { text-align: right; padding: 0; }
+.tagvnlist .tc4 { padding: 0; }
+.tagvnlist .tc6 { text-align: right; padding-right: 10px; }
+
+
+/***** Tag list (/g/list) *****/
+
+.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 *****/
+
+.browse.taglinks .tc1 { width: 100px }
+.browse.taglinks .tc3 { width: 80px }
+.browse.taglinks .setfil { font-size: 10px; padding-right: 3px }
+
+.tagscore { white-space: nowrap; display: inline-block; width: 58px; }
+.tagscore > span { display: inline-block; width: 25px; text-align: right; padding-right: 3px; font-size: 11px }
+.tagscore > div { display: inline-block; height: 13px; background: linear-gradient(90deg, #cf0 0px, #0f0 30px) }
+.tagscore.negative > div { background: #f00 }
+.tagscore.negative > span { color: var(--standout) }
+.tagscore.ignored > div { background: #222 }
+.tagscore.ignored > span { color: var(--grayedout) }
+
+
+/***** VN tagmod *****/
+
+table.tgl { margin: 10px 0 0 20px }
+table.tgl td { padding: 1px 5px }
+table.tgl tfoot td { padding-top: 20px }
+table.tgl .tc_you { border-right: 1px solid var(--border); border-left: 1px solid var(--border); text-align: center }
+table.tgl .tc_others { border-left: 1px solid var(--border); text-align: center }
+table.tgl .tc_tagname { min-width: 200px; border-right: 1px solid var(--border) }
+table.tgl tbody .tc_tagname { padding-left: 15px }
+table.tgl .tc_myvote { padding: 0 0 0 10px; min-width: 90px }
+table.tgl .tc_mynote { min-width: 25px }
+table.tgl .tc_mynote span { cursor: pointer }
+table.tgl .noteview { position: absolute; max-width: 410px; padding: 0 5px 5px 5px; background: var(--blendbg) }
+table.tgl .buts a { display: block; width: 15px; height: 14px; border: 1px solid var(--border); margin: 0; float: left }
+table.tgl .buts a.l0 { border: none; background-color: var(--border) }
+table.tgl .buts a.l1 { border: none; background-color: #cf0 }
+table.tgl .buts a.l2 { border: none; background-color: #8f0 }
+table.tgl .buts a.l3 { border: none; background-color: #0f0 }
+table.tgl .buts a.ld { border: none; background-color: #f00 }
+table.tgl tbody .tc_myover { padding: 0 }
+table.tgl .buts a.ov { border: none; background-color: #f00 }
+table.tgl .tc_myspoil { padding: 0; min-width: 75px }
+table.tgl .buts a.sn { border: none; background-color: var(--border) }
+table.tgl .buts a.s0 { border: none; background-color: #0f0 }
+table.tgl .buts a.s1 { border: none; background-color: #f80 }
+table.tgl .buts a.s2 { border: none; background-color: #f40 }
+table.tgl .buts a.s3 { border: none; background-color: #cf0 }
+table.tgl .tc_mylie { padding: 0; min-width: 55px }
+table.tgl .buts a.fn { border: none; background-color: var(--border) }
+table.tgl .buts a.f0 { border: none; background-color: #0f0 }
+table.tgl .buts a.f1 { border: none; background-color: #f00 }
+table.tgl .tc_allvote { border-left: 1px solid var(--border); padding: 1px 0 0 30px; }
+table.tgl .tc_allvote i { font-style: normal; font-size: 10px }
+table.tgl .tc_allspoil { text-align: right; padding-right: 5px }
+table.tgl .tagmod_cat td { font-weight: bold; padding-top: 10px }
+
+
+
+
+/****** Revision information ******/
+
+.revision div.rev, .revision table {
+ border: 1px solid var(--border);
+ margin: 0 auto;
+ width: 90%;
+ background-color: var(--secbg);
+ clear: both;
+}
+.revision { padding-bottom: 10px; }
+.revision table thead tr td { background-color: transparent!important; text-align: center; font-weight: normal; }
+.revision table td { border-right: 1px solid var(--border); padding: 5px; }
+.revision td.tcval { width: 44%; }
+.revision div.rev { padding: 5px; text-align: center; }
+.diff_add { background-color: var(--diffadd); }
+.diff_del { background-color: var(--diffdel); }
+.revision .next { float: right; margin-right: 5%; }
+.revision .prev { float: left; margin-left: 5%; }
+.revision .item { text-align: center; }
+
+
+
+/****** Image Viewer *****/
+
+div#iv_view {
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ background: var(--boxbg);
+ border: 1px solid var(--border);
+ padding: 5px;
+ text-align: center;
+}
+#iv_view a { border: 0; font-weight: bold; font-size: 14px }
+#iv_view img { cursor: pointer }
+#ivclose { float: right; padding-left: 10px }
+#ivnext { padding-left: 5px; }
+#ivprev { padding-right: 5px; }
+#ivfull { float: left; padding-right: 10px; }
+#ivimgload {
+ position: absolute;
+ display: block;
+ left: 0;
+ top: 0;
+ width: 100px;
+ padding: 3px;
+ background-color: #f5f5f5; /* no real need to skin this */
+ text-align: center;
+ border: 1px solid #ccc;
+ color: #000;
+}
+
+/* ivview childs:
+ * 1 div -> loading
+ * 2 div -> img
+ * 3 div -> links
+ * 1 a -> full
+ * 2 a -> prev
+ * 3 a -> next
+ * 4 a -> flagging
+ */
+.ivview { position: fixed; background: var(--boxbg); border: 2px solid var(--border); padding: 5px; text-align: center; box-sizing: content-box }
+.ivview img { cursor: pointer }
+.ivview > div:nth-child(1) { position: absolute; left: 48%; top: 48%; width: 30px; height: 30px }
+.ivview > div:nth-child(2) { position: relative }
+.ivview > div:nth-child(2) .left-pane {
+ position: absolute;
+ border: none;
+ height: 100%;
+ width: 25%;
+ top: 0;
+ transition: opacity 0.25s ease-in-out;
+ opacity: 0;
+ background: linear-gradient(90deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 100%);
+}
+.ivview > div:nth-child(2) .left-pane:hover {
+ opacity: 1;
+}
+.ivview > div:nth-child(2) .right-pane {
+ position: absolute;
+ border: none;
+ height: 100%;
+ width: 25%;
+ top: 0;
+ right: 0;
+ transition: opacity 0.25s ease-in-out;
+ opacity: 0;
+ background: linear-gradient(270deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 100%);
+}
+.ivview > div:nth-child(2) .right-pane:hover {
+ opacity: 1;
+}
+.ivview > div:nth-child(3) { width: 100%; display: flex }
+.ivview > div:nth-child(3) > a { flex: 1; text-align: left; border: 0; font-weight: bold; font-size: 14px; white-space: nowrap }
+.ivview > div:nth-child(3) > a:nth-child(2) { text-align: right; padding-right: 5px }
+.ivview > div:nth-child(3) > a:nth-child(3) { padding-left: 5px }
+.ivview > div:nth-child(3) > a:nth-child(4) { text-align: right; font-size: 11px; font-weight: normal }
+
+
+
+/****** Advanced Search *******/
+
+/* The classes are called 'xsearch' instead of 'advsearch' because the latter triggers some ad blockers. :( */
+
+.xsearch { max-width: 850px; margin: 0 auto; display: flex; flex-direction: row; justify-content: center }
+.xsearch .advrow > tr > td { padding: 0 2px 0 0 }
+.xsearch .advrow > tr > td:first-child { text-align: right; white-space: nowrap; }
+.xsearch .advrow > tr > td:first-child > div { width: auto }
+.xsearch .advnest > tr > td { padding: 0 2px 0 0 }
+.xsearch .advnest > tr > td:first-child { text-align: right; white-space: nowrap; }
+.xsearch .advnest > tr > td:first-child > div { width: auto }
+.xsearch .advnest > tr > td:first-child > b { display: block; margin: 6px 3px 0 0 }
+
+/* Line drawing. This is awful */
+.xsearch .advnest > tr > td:nth-child(2) { position: relative; width: 15px; padding: 0 }
+.xsearch .advnest > tr > td:nth-child(2) div { border-left: 1px solid var(--border); width: 15px; position: absolute; left: 5px; top: 0; bottom: 0 }
+.xsearch .advnest > tr > td:nth-child(2).start { top: 13px }
+.xsearch .advnest > tr > td:nth-child(2).start div { border-top: 1px solid var(--border) }
+.xsearch .advnest > tr > td:nth-child(2).start span { display: block; position: absolute; left: -5px; top: 0; width: 15px; border-top: 1px solid var(--border); height: 1px }
+.xsearch .advnest > tr > td:nth-child(2).end div { bottom: 13px; border-bottom: 1px solid var(--border) }
+.xsearch .advnest > tr > td:nth-child(2).mid span { display: block; position: absolute; left: 5px; top: 13px; width: 15px; border-top: 1px solid var(--border); height: 1px }
+
+.xsearch .elm_dd_input { display: inline-block; margin: 5px 4px; width: 150px; vertical-align: middle }
+.xsearch .elm_dd_input.short { width: auto }
+.xsearch .advbut { width: 100%; background-color: var(--blendbg); text-align: right; white-space: nowrap }
+.xsearch .advbut > * { display: inline-block; height: 20px; padding: 3px 5px 0 2px; cursor: pointer; border-bottom: none; font-size: 16px }
+.xsearch .advheader { background-color: var(--blendbg); padding: 3px; width: 100%; margin-bottom: 2px }
+.xsearch .advheader > h3 { text-align: center; font-weight: bold; font-size: inherit; margin-bottom: 3px }
+.xsearch .advheader .opts { display: flex; justify-content: space-between; align-items: flex-end; min-width: 170px }
+.xsearch .advheader .opts > * { margin: 0; white-space: nowrap }
+.xsearch .advheader .opselect > * { display: inline-block; font-size: 18px; padding: 0 5px }
+
+.xsearch svg { width: 15px; height: 15px; margin: 2px 0 -2px 0 }
+
+
+
+/****** Image flagging *******/
+
+/* divs:
+ * 1: header
+ * 2: image
+ * 3: metadata
+ * 4: vote box
+ * 6: other user's votes */
+.imageflag { margin: auto }
+.imageflag > ul:nth-child(1) { margin-left: 15px }
+.imageflag > div:nth-child(1) { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 2px }
+.imageflag > div:nth-child(1) span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 0px 20px }
+.imageflag > div:nth-child(2) { border: 1px solid var(--border); padding: 5px; display: flex; justify-content: center; align-items: center; background: #000 }
+.imageflag > div:nth-child(2) a { border: none; display: inline-block; width: 100%; height: 100%; background-repeat: no-repeat; background-position: center }
+.imageflag > div:nth-child(3) { display: flex; justify-content: space-between; }
+.imageflag > div:nth-child(4) { display: flex; margin-bottom: 5px }
+.imageflag > div:nth-child(4) p { flex: 1 0; padding-top: 15px }
+.imageflag > div:nth-child(4) ul { list-style-type: none; margin: 0 5px 0 0 }
+.imageflag > div:nth-child(4) ul li:first-child { text-align: center }
+.imageflag > div:nth-child(4) ul li label { display: inline-block; border: 1px solid var(--secborder); padding: 7px; width: 116px; white-space: nowrap; margin: 2px 0; cursor: pointer }
+.imageflag > div:nth-child(4) ul li.sel label,
+.imageflag > div:nth-child(4) ul li label:hover { background-color: var(--secbg) }
+.imageflag > div:nth-child(4) ul li.overrule label { position: relative; left: 80px; border: none; padding: 0 }
+.imageflag > div:nth-child(6) { min-height: 200px; padding: 15px 0 }
+.imageflag > div:nth-child(6) table { margin: 0 auto }
+.imageflag > div:nth-child(6) table tr.ignored td:nth-child(2),
+.imageflag > div:nth-child(6) table tr.ignored td:nth-child(3) { text-decoration: line-through; color: var(--grayedout) }
+.imageflag > div:nth-child(6) table td { min-width: 50px }
+.imageflag .fullscreen { position: fixed; left: 0; top: 0; width: 100%; height: 100%; background-repeat: no-repeat; background-position: center; background-size: contain; background-color: #000 }
+
+
+/****** Image browser (/img/browse) ******/
+
+.imagebrowse { padding: 0; display: flex; flex-wrap: wrap }
+.imagebrowse .imagecard { padding: 2px; display: flex; flex: 1 }
+.imagebrowse .imagecard:hover { background-color: var(--secbg) }
+.imagebrowse .imagecard > a { flex-shrink: 0; display: block; width: 150px; height: 120px; background-size: contain; background-repeat: no-repeat; background-position: top right; border: none }
+.imagebrowse .imagecard > div { padding: 0 0 0 4px }
+.imagebrowse .imagecard > div svg { font-size: 11px }
+.imagebrowse .imagecard > div svg .errorbar { stroke: var(--standout); stroke-width: 2 }
+.imagebrowse .imagecard > div svg .ruler { stroke: var(--border); stroke-width: 1; stroke-dasharray: 3 }
+.imagebrowse .imagecard > div svg rect { fill: var(--maintext) }
+
+
+/****** Icons *******/
+
+/* XXX: Icon elements MUST have their 'icon-*' class as the first in the list */
+[class^=icon-] { cursor: inherit; margin: 0 2px 0 0; display: inline-block; text-decoration: none; margin: 0 2px 0 0 }
+[class^=icon-lang-],
+[class^=icon-gen-],
+.icon-external,
+.icon-rtcomplete, .icon-rtpartial, .icon-rttrial { background-image: url(/icons.png?#{$png-version}) }
+[class^=icon-plat-],
+[class^=icon-drm-],
+[class^=icon-rel-],
+[class^=icon-list-],
+.icon-rss { background-image: url(/icons.svg?#{$svg-version}) }
+
+[class^=icon-lang-] { opacity: 0.5 }
+[class^=icon-lang-].mtl { filter: grayscale(1); opacity: 0.4 }
+[class^=icon-plat-] { margin: -1px 2px -1px 0 }
+[class^=icon-list-] { margin: -1px 0 }
+
+.icon-rel-v2, .icon-rel-a2 { filter: hue-rotate(30deg); }
+.icon-rel-v3, .icon-rel-a3 { filter: invert(100%) hue-rotate(240deg); }
+.icon-rel-v4, .icon-rel-a4 { filter: hue-rotate(80deg); }
+
+
+/* Relation graph */
+svg .border { fill: none; stroke: var(--border) }
+svg .edge polygon.border { fill: var(--border) }
+svg .nodebg { fill: var(--tabbg); stroke: var(--tabbg) }
+svg text { fill: var(--maintext); font: 8px "Tahoma", "Arial", sans-serif }
+svg .title { font-size: 9px }
+#graph_current .border { stroke: var(--warnborder) }
+#graph_current .nodebg { stroke: var(--warnborder); fill: var(--warnbg) }
+
diff --git a/css/vngraph.css b/css/vngraph.css
new file mode 100644
index 00000000..05a370fb
--- /dev/null
+++ b/css/vngraph.css
@@ -0,0 +1,27 @@
+/* Styles js/graph/vn.js */
+#vn-graph {
+ > div { /* options div */
+ width: 100%;
+ padding-top: 2px;
+ display: flex;
+ justify-content: space-between;
+ > small { font-size: 80% }
+ }
+ > svg { /* the graph */
+ width: 100%;
+ .edges line {
+ stroke-width: 5;
+ stroke: var(--grayedout);
+ }
+ .main circle { fill: var(--maintext) }
+ pattern circle { fill: var(--maintext) } /* No image */
+ }
+}
+.vn-rel-icon svg { width: 14px; height: 14px; margin-right: 5px }
+#vn-graph-arrow { stroke: var(--grayedout); fill: none; stroke-width: 2 }
+#vn-graph-sel {
+ width: 400px; height: 80px; padding-top: 50px;
+ display: flex; justify-content: center; align-items: flex-end;
+ > div { padding: 5px; background: var(--secbg) }
+ a { font-size: 18px; }
+}
diff --git a/data/icons/feed.png b/data/icons/feed.png
deleted file mode 100644
index 22b1e844..00000000
--- a/data/icons/feed.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ar.png b/data/icons/lang/ar.png
deleted file mode 100644
index da06bd16..00000000
--- a/data/icons/lang/ar.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/bg.png b/data/icons/lang/bg.png
deleted file mode 100644
index c115806a..00000000
--- a/data/icons/lang/bg.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ca.png b/data/icons/lang/ca.png
deleted file mode 100644
index 97612ceb..00000000
--- a/data/icons/lang/ca.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/cs.png b/data/icons/lang/cs.png
deleted file mode 100644
index a4a2f6cd..00000000
--- a/data/icons/lang/cs.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/da.png b/data/icons/lang/da.png
deleted file mode 100644
index 7b7070ea..00000000
--- a/data/icons/lang/da.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/de.png b/data/icons/lang/de.png
deleted file mode 100644
index a9155cfc..00000000
--- a/data/icons/lang/de.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/el.png b/data/icons/lang/el.png
deleted file mode 100644
index a8402131..00000000
--- a/data/icons/lang/el.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/en.png b/data/icons/lang/en.png
deleted file mode 100644
index b1cf3674..00000000
--- a/data/icons/lang/en.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/es.png b/data/icons/lang/es.png
deleted file mode 100644
index f461c138..00000000
--- a/data/icons/lang/es.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/fi.png b/data/icons/lang/fi.png
deleted file mode 100644
index 5f5d8fed..00000000
--- a/data/icons/lang/fi.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/fr.png b/data/icons/lang/fr.png
deleted file mode 100644
index 5f0589c8..00000000
--- a/data/icons/lang/fr.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/he.png b/data/icons/lang/he.png
deleted file mode 100644
index 01a62f56..00000000
--- a/data/icons/lang/he.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/hr.png b/data/icons/lang/hr.png
deleted file mode 100644
index c20821b4..00000000
--- a/data/icons/lang/hr.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/hu.png b/data/icons/lang/hu.png
deleted file mode 100644
index 68df6f90..00000000
--- a/data/icons/lang/hu.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/id.png b/data/icons/lang/id.png
deleted file mode 100644
index 3ab93ee7..00000000
--- a/data/icons/lang/id.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/it.png b/data/icons/lang/it.png
deleted file mode 100644
index 91f98e2b..00000000
--- a/data/icons/lang/it.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ja.png b/data/icons/lang/ja.png
deleted file mode 100644
index ef982e2c..00000000
--- a/data/icons/lang/ja.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ko.png b/data/icons/lang/ko.png
deleted file mode 100644
index de15c590..00000000
--- a/data/icons/lang/ko.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ms.png b/data/icons/lang/ms.png
deleted file mode 100644
index 6e033649..00000000
--- a/data/icons/lang/ms.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/nl.png b/data/icons/lang/nl.png
deleted file mode 100644
index 046cf74d..00000000
--- a/data/icons/lang/nl.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/no.png b/data/icons/lang/no.png
deleted file mode 100644
index ccad6bfe..00000000
--- a/data/icons/lang/no.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/pl.png b/data/icons/lang/pl.png
deleted file mode 100644
index bba98646..00000000
--- a/data/icons/lang/pl.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/pt-br.png b/data/icons/lang/pt-br.png
deleted file mode 100644
index f4094097..00000000
--- a/data/icons/lang/pt-br.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/pt-pt.png b/data/icons/lang/pt-pt.png
deleted file mode 100644
index ae293629..00000000
--- a/data/icons/lang/pt-pt.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ro.png b/data/icons/lang/ro.png
deleted file mode 100644
index bf52726f..00000000
--- a/data/icons/lang/ro.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ru.png b/data/icons/lang/ru.png
deleted file mode 100644
index 8926f08d..00000000
--- a/data/icons/lang/ru.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/sk.png b/data/icons/lang/sk.png
deleted file mode 100644
index 1511820d..00000000
--- a/data/icons/lang/sk.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/sv.png b/data/icons/lang/sv.png
deleted file mode 100644
index 512e100f..00000000
--- a/data/icons/lang/sv.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ta.png b/data/icons/lang/ta.png
deleted file mode 100644
index ab44ca0a..00000000
--- a/data/icons/lang/ta.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/th.png b/data/icons/lang/th.png
deleted file mode 100644
index bef862a9..00000000
--- a/data/icons/lang/th.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/tr.png b/data/icons/lang/tr.png
deleted file mode 100644
index 004b9c83..00000000
--- a/data/icons/lang/tr.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/uk.png b/data/icons/lang/uk.png
deleted file mode 100644
index 5645f271..00000000
--- a/data/icons/lang/uk.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/vi.png b/data/icons/lang/vi.png
deleted file mode 100644
index 65c59ea5..00000000
--- a/data/icons/lang/vi.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/zh.png b/data/icons/lang/zh.png
deleted file mode 100644
index 8dafa06d..00000000
--- a/data/icons/lang/zh.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/and.png b/data/icons/plat/and.png
deleted file mode 100644
index 648af428..00000000
--- a/data/icons/plat/and.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/bdp.png b/data/icons/plat/bdp.png
deleted file mode 100644
index 4999f524..00000000
--- a/data/icons/plat/bdp.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/dos.png b/data/icons/plat/dos.png
deleted file mode 100644
index 3adf5bff..00000000
--- a/data/icons/plat/dos.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/drc.png b/data/icons/plat/drc.png
deleted file mode 100644
index 9fe59cbc..00000000
--- a/data/icons/plat/drc.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/dvd.png b/data/icons/plat/dvd.png
deleted file mode 100644
index 9b71645f..00000000
--- a/data/icons/plat/dvd.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/fmt.png b/data/icons/plat/fmt.png
deleted file mode 100644
index c9625e91..00000000
--- a/data/icons/plat/fmt.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/gba.png b/data/icons/plat/gba.png
deleted file mode 100644
index f9601915..00000000
--- a/data/icons/plat/gba.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/gbc.png b/data/icons/plat/gbc.png
deleted file mode 100644
index b93df0a9..00000000
--- a/data/icons/plat/gbc.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/ios.png b/data/icons/plat/ios.png
deleted file mode 100644
index 8521a741..00000000
--- a/data/icons/plat/ios.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/lin.png b/data/icons/plat/lin.png
deleted file mode 100644
index 7a294fe6..00000000
--- a/data/icons/plat/lin.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/mac.png b/data/icons/plat/mac.png
deleted file mode 100644
index 1e01c433..00000000
--- a/data/icons/plat/mac.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/msx.png b/data/icons/plat/msx.png
deleted file mode 100644
index 9cd32ced..00000000
--- a/data/icons/plat/msx.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/n3d.png b/data/icons/plat/n3d.png
deleted file mode 100644
index 9782f832..00000000
--- a/data/icons/plat/n3d.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/nds.png b/data/icons/plat/nds.png
deleted file mode 100644
index 77fc8bea..00000000
--- a/data/icons/plat/nds.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/nes.png b/data/icons/plat/nes.png
deleted file mode 100644
index 30e06943..00000000
--- a/data/icons/plat/nes.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/p88.png b/data/icons/plat/p88.png
deleted file mode 100644
index 1da9c6e5..00000000
--- a/data/icons/plat/p88.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/p98.png b/data/icons/plat/p98.png
deleted file mode 100644
index bebad893..00000000
--- a/data/icons/plat/p98.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/pce.png b/data/icons/plat/pce.png
deleted file mode 100644
index b8b42d4d..00000000
--- a/data/icons/plat/pce.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/pcf.png b/data/icons/plat/pcf.png
deleted file mode 100644
index 87bd6eee..00000000
--- a/data/icons/plat/pcf.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/ps1.png b/data/icons/plat/ps1.png
deleted file mode 100644
index 80b4b950..00000000
--- a/data/icons/plat/ps1.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/ps2.png b/data/icons/plat/ps2.png
deleted file mode 100644
index 79008351..00000000
--- a/data/icons/plat/ps2.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/ps3.png b/data/icons/plat/ps3.png
deleted file mode 100644
index 60c2d731..00000000
--- a/data/icons/plat/ps3.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/ps4.png b/data/icons/plat/ps4.png
deleted file mode 100644
index e04e5cb1..00000000
--- a/data/icons/plat/ps4.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/psp.png b/data/icons/plat/psp.png
deleted file mode 100644
index 574f5ba6..00000000
--- a/data/icons/plat/psp.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/psv.png b/data/icons/plat/psv.png
deleted file mode 100644
index 57b09185..00000000
--- a/data/icons/plat/psv.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/sat.png b/data/icons/plat/sat.png
deleted file mode 100644
index 7e0afe3c..00000000
--- a/data/icons/plat/sat.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/sfc.png b/data/icons/plat/sfc.png
deleted file mode 100644
index 909e006a..00000000
--- a/data/icons/plat/sfc.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/swi.png b/data/icons/plat/swi.png
deleted file mode 100644
index 06f258ce..00000000
--- a/data/icons/plat/swi.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/web.png b/data/icons/plat/web.png
deleted file mode 100644
index a5a2ff05..00000000
--- a/data/icons/plat/web.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/wii.png b/data/icons/plat/wii.png
deleted file mode 100644
index 3f7a4d2b..00000000
--- a/data/icons/plat/wii.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/win.png b/data/icons/plat/win.png
deleted file mode 100644
index ccede0aa..00000000
--- a/data/icons/plat/win.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/wiu.png b/data/icons/plat/wiu.png
deleted file mode 100644
index 3d8a7b82..00000000
--- a/data/icons/plat/wiu.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/x68.png b/data/icons/plat/x68.png
deleted file mode 100644
index 4731e374..00000000
--- a/data/icons/plat/x68.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/xb1.png b/data/icons/plat/xb1.png
deleted file mode 100644
index 23d722a8..00000000
--- a/data/icons/plat/xb1.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/xb3.png b/data/icons/plat/xb3.png
deleted file mode 100644
index 600315ab..00000000
--- a/data/icons/plat/xb3.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/plat/xbo.png b/data/icons/plat/xbo.png
deleted file mode 100644
index 8beb3781..00000000
--- a/data/icons/plat/xbo.png
+++ /dev/null
Binary files differ
diff --git a/data/js/charops.js b/data/js/charops.js
deleted file mode 100644
index 9478ca95..00000000
--- a/data/js/charops.js
+++ /dev/null
@@ -1,88 +0,0 @@
-var spoil, sexual, t;
-
-
-// Fixes the commas between trait names and the hidden status of the entire row
-function fixrow(c) {
- var l = byName(byName(c, 'td')[1], 'span');
- var first = 1;
- for(var i=0; i<l.length; i++)
- if(!hasClass(l[i], 'ishidden')) {
- first = 0;
- break;
- }
- setClass(c, 'hidden', first);
-}
-
-
-function restripe() {
- for(var i=0; i<t.length; i++) {
- var b = byName(t[i], 'tbody');
- if(!b.length)
- continue;
- setClass(t[i], 'stripe', false);
- var r = 1;
- var rows = byName(b[0], 'tr');
- for(var j=0; j<rows.length; j++) {
- if(hasClass(rows[j], 'traitrow'))
- fixrow(rows[j]);
- if(!hasClass(rows[j], 'nostripe') && !hasClass(rows[j], 'hidden'))
- setClass(rows[j], 'odd', r++&1);
- }
- }
-}
-
-
-function setall() {
- var k = byClass('charspoil');
- for(var i=0; i<k.length; i++)
- setClass(k[i], 'ishidden',
- !sexual && hasClass(k[i], 'sexual') ? true :
- hasClass(k[i], 'charspoil_0') ? false :
- hasClass(k[i], 'charspoil_-1') ? spoil > 1 :
- hasClass(k[i], 'charspoil_1') ? spoil < 1 : spoil < 2);
-
- if(k.length)
- restripe();
- return false;
-}
-
-
-function init() {
- var opsParent = byId('charops');
- if(!opsParent)
- return;
-
- t = byClass('table', 'stripe');
-
- // Spoiler level
- for(var i=0; i<3; i++) {
- var splChk = byClass(opsParent, 'radio_spoil' + i)[0];
- if(!splChk)
- continue;
-
- splChk.num = i;
- splChk.onchange = function() {
- spoil = this.num;
- return setall();
- };
- if(splChk.checked)
- spoil = i;
- };
-
- // Sexual toggle
- var sexChk = byClass(opsParent, 'sexual_check');
- if(sexChk.length) {
- sexChk = sexChk[0]
-
- sexChk.onchange = function() {
- sexual = !sexual;
- return setall();
- };
- sexual = sexChk.checked;
- }
- setall();
-}
-
-
-if(byId('charops'))
- init();
diff --git a/data/js/chartraits.js b/data/js/chartraits.js
deleted file mode 100644
index 968b68ee..00000000
--- a/data/js/chartraits.js
+++ /dev/null
@@ -1,123 +0,0 @@
-function ctrLoad() {
- // load current traits
- var l = byId('traits').value.split(' ');
- var v = {}; // tag id -> spoiler lookup table
- var q = []; // list of id=X parameters
- for(var i=0; i<l.length; i++) {
- if(l[i]) {
- var m = l[i].split(/-/);
- v[m[0]] = Math.floor(m[1]);
- q[i] = 'id='+m[0];
- }
- }
- if(q.length > 0)
- ajax('/xml/traits.xml?r=200;'+q.join(';'), function (ht) {
- var t = ht.responseXML.getElementsByTagName('item');
- for(var i=0; i<t.length; i++)
- ctrAdd(t[i], v[t[i].getAttribute('id')]);
- }, 1);
- else
- ctrEmpty();
-
- // dropdown
- dsInit(byId('trait_input'), '/xml/traits.xml?q=', function(item, tr) {
- var g = item.getAttribute('groupname');
- g = g ? g+' / ' : '';
- tr.appendChild(tag('td', { style: 'text-align: right; padding-right: 5px'}, 'i'+item.getAttribute('id')));
- tr.appendChild(tag('td',
- tag('b', {'class':'grayedout'}, g), item.firstChild.nodeValue,
- tag('b', {'class':'grayedout'}, item.getAttribute('applicable')=='no' ? 'not applicable' : '')));
- }, ctrFormAdd);
-}
-
-function ctrEmpty() {
- var x = byId('traits_loading');
- var t = byId('traits_tbl');
- if(x)
- t.removeChild(x);
- var l = byName(t, 'tr');
- var e = byId('traits_empty');
- if(e && l.length > 1)
- t.removeChild(e);
- else if(!e && l.length < 1)
- t.appendChild(tag('tr', {id:'traits_empty',colspan:3}, tag('td', 'No traits present yet.')));
-}
-
-function ctrAdd(item, spoil) {
- var id = item.getAttribute('id');
- var name = item.firstChild.nodeValue;
- var group = item.getAttribute('groupname');
- var sp = tag('td', {'class':'tc_spoil', onclick:ctrSpoilNext, ctr_spoil:spoil}, fmtspoil(spoil));
- ddInit(sp, 'left', ctrSpoilDD);
- byId('traits_tbl').appendChild(tag('tr', {ctr_id:id, ctr_spoiler:spoil},
- tag('td', {'class':'tc_name'},
- tag('b', {'class':'grayedout'}, group?group+' / ':''),
- tag('a', {'href':'/i'+id}, name)),
- sp,
- tag('td', {'class':'tc_del'}, tag('a', {href:'#', onclick:ctrDel}, 'remove'))
- ));
- ctrEmpty();
- ctrSerialize();
-}
-
-function ctrFormAdd(item) {
- var l = byName(byId('traits_tbl'), 'tr');
- for(var i=0; i<l.length; i++)
- if(l[i].ctr_id && l[i].ctr_id == item.getAttribute('id'))
- break;
- if(i < l.length)
- alert('Selected trait is already present.');
- else if(item.getAttribute('applicable') == 'no')
- alert('This trait can\'t be used here.');
- else
- ctrAdd(item, Math.floor(item.getAttribute('defaultspoil')));
- return '';
-}
-
-function ctrSpoilNext() {
- if(++this.ctr_spoil > 2)
- this.ctr_spoil = 0;
- setText(this, fmtspoil(this.ctr_spoil));
- ddRefresh();
- ctrSerialize();
-}
-
-function ctrSpoilDD(lnk) {
- var lst = tag('ul', null);
- for(var i=0; i<=2; i++)
- lst.appendChild(tag('li', i == lnk.ctr_spoil
- ? tag('i', fmtspoil(i))
- : tag('a', {href: '#', onclick:ctrSpoilSet, ctr_td:lnk, ctr_sp:i}, fmtspoil(i))
- ));
- return lst;
-}
-
-function ctrSpoilSet() {
- this.ctr_td.ctr_spoil = this.ctr_sp;
- setText(this.ctr_td, fmtspoil(this.ctr_sp));
- ddHide();
- ctrSerialize();
- return false;
-}
-
-function ctrDel() {
- var tr = this;
- while(tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- tr.parentNode.removeChild(tr);
- ctrEmpty();
- ctrSerialize();
- return false
-}
-
-function ctrSerialize() {
- var l = byName(byId('traits_tbl'), 'tr');
- var v = [];
- for(var i=0; i<l.length; i++)
- if(l[i].ctr_id)
- v.push(l[i].ctr_id+'-'+byClass(l[i], 'tc_spoil')[0].ctr_spoil);
- byId('traits').value = v.join(' ');
-}
-
-if(byId('traits_tbl'))
- ctrLoad();
diff --git a/data/js/charvns.js b/data/js/charvns.js
deleted file mode 100644
index dcac2950..00000000
--- a/data/js/charvns.js
+++ /dev/null
@@ -1,194 +0,0 @@
-function cvnLoad() {
- // load current links
- var l = byId('vns').value.split(' ');
- var v = {}; // vid -> { rid: [ role, spoil ], .. }
- var q = []; // list of v=X parameters
- for(var i=0; i<l.length; i++) {
- if(!l[i])
- continue;
- var m = l[i].split(/-/); // vid, rid, spoil, role
- if(!v[m[0]]) {
- q.push('v='+m[0]);
- v[m[0]] = {};
- }
- v[m[0]][m[1]] = [ m[3], m[2] ];
- }
- if(q.length > 0)
- ajax('/xml/releases.xml?'+q.join(';'), function(hr) {
- var vns = byName(hr.responseXML, 'vn');
- for(var i=0; i<vns.length; i++) {
- var vid = vns[i].getAttribute('id');
- cvnVNAdd(vns[i]);
- var rels = byName(vns[i], 'release');
- for(var r=0; r<rels.length; r++) {
- var rid = rels[r].getAttribute('id');
- if(v[vid][rid])
- cvnRelAdd(vid, rid, v[vid][rid][0], v[vid][rid][1]);
- }
- if(v[vid][0])
- cvnRelAdd(vid, 0, v[vid][0][0], v[vid][0][1]);
- }
- cvnEmpty();
- }, 1);
- else
- cvnEmpty();
-
- // dropdown search
- dsInit(byId('vns_input'), '/xml/vn.xml?q=', function(item, tr) {
- tr.appendChild(tag('td', { style: 'text-align: right; padding-right: 5px'}, 'v'+item.getAttribute('id')));
- tr.appendChild(tag('td', shorten(item.firstChild.nodeValue, 40)));
- }, cvnFormAdd);
-}
-
-function cvnEmpty() {
- var x = byId('vns_loading');
- var t = byId('vns_tbl');
- if(x)
- t.removeChild(x);
- var l = byName(t, 'tr');
- var e = byId('vns_empty');
- if(e && l.length > 1)
- t.removeChild(e);
- else if(!e && l.length < 1)
- t.appendChild(tag('tr', {id:'vns_empty',colspan:3}, tag('td', 'No visual novels selected.')));
-}
-
-function cvnVNAdd(vn, rel) {
- var vid = vn.getAttribute('id');
- var rels = byName(vn, 'release');
- byId('vns_tbl').appendChild(tag('tr', {id:'cvn_v'+vid, cvn_vid:vid, cvn_rels:rels},
- tag('td', {'class':'tc_vn',colspan:4}, 'v'+vid+':',
- tag('a', {href:'/v'+vid}, vn.getAttribute('title')),
- tag('i', '(', tag('a', {href:'#', onclick:cvnRelNew}, 'add release'), ')')
- )
- ));
- if(rel)
- cvnRelAdd(vid, 0, 'primary', 0);
- cvnEmpty();
-}
-
-function cvnRelAdd(vid, rid, role, spoil) {
- var rels = byId('cvn_v'+vid).cvn_rels;
- var rsel = tag('select', {onchange:cvnRelChange}, tag('option', {value:0}, 'All / others'));
- for(var i=0; i<rels.length; i++) {
- var id = rels[i].getAttribute('id');
- rsel.appendChild(tag('option', {value: id, selected:id==rid},
- '['+rels[i].getAttribute('lang')+'] '+rels[i].firstChild.nodeValue+' (r'+id+')'));
- }
-
- var lsel = tag('select', {onchange:cvnSerialize});
- for(var i=0; i<VARS.char_roles.length; i++)
- lsel.appendChild(tag('option', {value: VARS.char_roles[i][0], selected:VARS.char_roles[i][0]==role}, VARS.char_roles[i][1]));
-
- var ssel = tag('select', {onchange:cvnSerialize});
- for(var i=0; i<3; i++)
- ssel.appendChild(tag('option', {value:i, selected:i==spoil}, fmtspoil(i)));
-
- var tbl = byId('vns_tbl');
- var l = byName(tbl, 'tr');
- var last = null;
- for(var i=1; i<l.length; i++)
- if(l[i-1].cvn_vid == vid && l[i].cvn_vid != vid)
- last = l[i-1];
- tbl.insertBefore(tag('tr', {id:'cvn_v'+vid+'r'+rid, cvn_vid:vid, cvn_rid:rid},
- tag('td', {'class':'tc_rel'}, rsel),
- tag('td', {'class':'tc_rol'}, lsel),
- tag('td', {'class':'tc_spl'}, ssel),
- tag('td', {'class':'tc_del'}, tag('a', {href:'#', onclick:cvnRelDel}, 'remove'))
- ), last);
-}
-
-function cvnRelChange() {
- // look for duplicates and disallow the change
- var val = this.options[this.selectedIndex].value;
- var tr = this;
- while(tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- if(byId('cvn_v'+tr.cvn_vid+'r'+val)) {
- alert('Release already present.');
- for(var i=0; i<this.options.length; i++)
- this.options[i].selected = this.options[i].value == tr.cvn_rid;
- return;
- }
- // otherwise, 'rename' this entry
- tr.id = 'cvn_v'+tr.cvn_vid+'r'+val;
- tr.cvn_rid = val;
- cvnSerialize();
-}
-
-function cvnRelNew() {
- var tr = this;
- while(tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- var id = 0;
- if(byId('cvn_v'+tr.cvn_vid+'r0')) {
- for(var i=0; i<tr.cvn_rels.length; i++) {
- id = tr.cvn_rels[i].getAttribute('id');
- if(!byId('cvn_v'+tr.cvn_vid+'r'+id))
- break;
- }
- if(i == tr.cvn_rels.length) {
- alert('All releases already selected.');
- return false;
- }
- }
- cvnRelAdd(tr.cvn_vid, id, 'primary', 0);
- cvnSerialize();
- return false;
-}
-
-function cvnRelDel() {
- var tbl = byId('vns_tbl');
- var tr = this;
- while(tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- tbl.removeChild(tr);
- var l = byName(tbl, 'tr');
- var c = 0;
- for(var i=0; i<l.length; i++)
- if(l[i].cvn_vid == tr.cvn_vid)
- c++;
- if(c <= 1)
- tbl.removeChild(byId('cvn_v'+tr.cvn_vid));
- cvnSerialize();
- cvnEmpty();
- return false;
-}
-
-function cvnFormAdd(item) {
- var inpt = byId('vns_input');
- inpt.disabled = true;
-
- ajax('/xml/releases.xml?v='+item.getAttribute('id'), function(hr) {
- inpt.disabled = false;
- inpt.value = '';
-
- var items = byName(hr.responseXML, 'vn');
- if(items.length < 1) // shouldn't happen
- return alert('Oops! Error!');
-
- var id = items[0].getAttribute('id');
- if(byId('cvn_v'+id))
- return alert('VN already present.');
- cvnVNAdd(items[0], 1);
- cvnSerialize();
- }, 1);
- return 'Loading...';
-}
-
-function cvnSerialize() {
- var l = byName(byId('vns_tbl'), 'tr');
- var v = [];
- for(var i=0; i<l.length; i++)
- if(l[i].cvn_rid != null) {
- var rol = byName(byClass(l[i], 'tc_rol')[0], 'select')[0];
- var spl = byName(byClass(l[i], 'tc_spl')[0], 'select')[0];
- v.push(l[i].cvn_vid+'-'+l[i].cvn_rid+'-'+
- spl.options[spl.selectedIndex].value+'-'+
- rol.options[rol.selectedIndex].value);
- }
- byId('vns').value = v.join(' ');
-}
-
-if(byId('jt_box_chare_vns'))
- cvnLoad();
diff --git a/data/js/dateselector.js b/data/js/dateselector.js
deleted file mode 100644
index ed96ab8a..00000000
--- a/data/js/dateselector.js
+++ /dev/null
@@ -1,84 +0,0 @@
-/* Date selector widget for the 'release date'-style dates, with support for
- * TBA and unknown month or day. Usage:
- *
- * <input type="hidden" class="dateinput" .. />
- *
- * Will add a date selector to the HTML at that place, and automatically
- * read/write the value of the hidden field. Alternative usage:
- *
- * var obj = dateLoad(ref, serfunc);
- *
- * If 'ref' is set, it will behave as above with 'ref' being the input object.
- * Otherwise it will return the widget object. The setfunc, if set, will be
- * called whenever the date widget is focussed or its value is changed.
- *
- * The object returned by dateLoad() can be used as follows:
- * obj.date_val: Always contains the currently selected date.
- * obj.dateSet(val): Change the selected date
- */
-function load(obj, serfunc) {
- var i;
- var selops = {style: 'width: 70px', onfocus:serfunc, onchange: serialize, tabIndex: 10};
-
- var year = tag('select', selops,
- tag('option', {value:0}, '-year-'),
- tag('option', {value:9999}, 'TBA')
- );
- for(i=(new Date()).getFullYear()+5; i>=1980; i--)
- year.appendChild(tag('option', {value: i}, i));
-
- var month = tag('select', selops,
- tag('option', {value:99}, '-month-')
- );
- for(i=1; i<=12; i++)
- month.appendChild(tag('option', {value: i}, i));
-
- var day = tag('select', selops,
- tag('option', {value:99}, '-day-')
- );
- for(i=1; i<=31; i++)
- day.appendChild(tag('option', {value: i}, i));
-
- var div = tag('div', {
- date_obj: obj,
- date_serfunc: serfunc,
- date_val: obj ? obj.value : 0
- }, year, month, day);
- div.dateSet = function(v){ set(div, v) };
-
- set(div, div.date_val);
- return obj ? obj.parentNode.insertBefore(div, obj) : div;
-}
-
-function set(div, val) {
- val = +val || 0;
- val = [ Math.floor(val/10000), Math.floor(val/100)%100, val%100 ];
- if(val[1] == 0) val[1] = 99;
- if(val[2] == 0) val[2] = 99;
- var l = byName(div, 'select');
- for(var i=0; i<l.length; i++)
- for(var j=0; j<l[i].options.length; j++)
- l[i].options[j].selected = l[i].options[j].value == val[i];
- serialize(div, true);
-}
-
-function serialize(div, nonotify) {
- div = div.dateSet ? div : this.parentNode;
- var sel = byName(div, 'select');
- var val = [
- sel[0].options[sel[0].selectedIndex].value*1,
- sel[1].options[sel[1].selectedIndex].value*1,
- sel[2].options[sel[2].selectedIndex].value*1
- ];
- div.date_val = val[0] == 0 ? 0 : val[0] == 9999 ? 99999999 : val[0]*10000+val[1]*100+(val[1]==99?99:val[2]);
- if(div.date_obj)
- div.date_obj.value = div.date_val;
- if(!nonotify && div.date_serfunc)
- div.date_serfunc(div);
-}
-
-var l = byClass('input', 'dateinput');
-for(var i=0; i<l.length; i++)
- load(l[i]);
-
-window.dateLoad = load;
diff --git a/data/js/dropdown.js b/data/js/dropdown.js
deleted file mode 100644
index 76be3f35..00000000
--- a/data/js/dropdown.js
+++ /dev/null
@@ -1,91 +0,0 @@
-/* Dropdown widget, used as follows:
- *
- * ddInit(obj, align, func);
- *
- * Show a dropdown box on mouse-over on 'obj'. 'func' should generate and
- * return the contents of the box as a DOM node, or null to not show a dropdown
- * box at all. The 'align' argument indicates where the box should be shown,
- * relative to the obj:
- *
- * left: To the left of obj
- * bottom: To the bottom of obj
- * tagmod: Special alignment for tagmod page
- *
- * Other functions:
- *
- * ddHide(); Hides the box
- * ddRefresh(); Refreshes the box contents
- */
-var box;
-
-function init(obj, align, contents) {
- obj.dd_align = align;
- obj.dd_contents = contents;
- obj.onmouseover = show;
-}
-
-function show() {
- if(!box) {
- box = tag('div', {id:'dd_box', 'class':'hidden'});
- addBody(box);
- }
- box.dd_lnk = this;
- document.onmousemove = mouse;
- document.onscroll = hide;
- refresh();
-}
-
-function hide() {
- if(box) {
- setText(box, '');
- setClass(box, 'hidden', true);
- box.dd_lnk = document.onmousemove = document.onscroll = null;
- }
-}
-
-function mouse(e) {
- e = e || window.event;
- // Don't hide if the cursor is on the link
- for(var lnk = e.target || e.srcElement; lnk; lnk=lnk.parentNode)
- if(lnk == box.dd_lnk)
- return;
-
- // Hide if it's 10px outside of the box
- var mouseX = e.pageX || (e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft);
- var mouseY = e.pageY || (e.clientY + document.body.scrollTop + document.documentElement.scrollTop);
- if(mouseX < box.dd_x-10 || mouseX > box.dd_x+box.offsetWidth+10 || mouseY < box.dd_y-10 || mouseY > box.dd_y+box.offsetHeight+10)
- hide();
-}
-
-function refresh() {
- if(!box || !box.dd_lnk)
- return hide();
- var lnk = box.dd_lnk;
- var content = lnk.dd_contents(lnk, box);
- if(content == null)
- return hide();
- setContent(box, content);
- setClass(box, 'hidden', false);
-
- var o = lnk;
- ddx = ddy = 0;
- do {
- ddx += o.offsetLeft;
- ddy += o.offsetTop;
- } while(o = o.offsetParent);
-
- if(lnk.dd_align == 'left')
- ddx -= box.offsetWidth;
- if(lnk.dd_align == 'tagmod')
- ddx += lnk.offsetWidth-35;
- if(lnk.dd_align == 'bottom')
- ddy += lnk.offsetHeight;
- box.dd_x = ddx;
- box.dd_y = ddy;
- box.style.left = ddx+'px';
- box.style.top = ddy+'px';
-}
-
-window.ddInit = init;
-window.ddHide = hide;
-window.ddRefresh = refresh;
diff --git a/data/js/dropdownsearch.js b/data/js/dropdownsearch.js
deleted file mode 100644
index 428858cd..00000000
--- a/data/js/dropdownsearch.js
+++ /dev/null
@@ -1,204 +0,0 @@
-/* Interactive drop-down search widget. Usage:
- *
- * dsInit(obj, url, trfunc, serfunc, retfunc);
- *
- * obj: An <input type="text"> object.
- *
- * url: The base URL of the XML API, e.g. "/xml/tags.xml?q=", the search query is appended to this URL.
- * The resource at the URL should return an XML document with a
- * <item id="something" ..>..</item>
- * element for each result.
- *
- * trfunc(item, tr): Function that is given an <item> object given by the XML
- * document and an empty <tr> object. The function should format the data of
- * the item to be shown in the tr.
- *
- * serfunc(item, obj): Called whenever a user selects an item from the search
- * results. Should return a string, which will be used as the new value of the
- * input object.
- *
- * retfunc(obj): Called whenever the user selects an item from the search
- * results (after setfunc()) or when enter is pressed (even if nothing is
- * selected).
- *
- * setfunc and retfunc can be null.
- *
- * TODO: Some users of this widget consider serfunc() as their final "apply
- * this selection" function, whereas others use retfunc() for this. Might be
- * worth investigating whether the additional flexibility offered by
- * retfunc() is actually necessary, and remove the callback if not.
- */
-var boxobj;
-
-function box() {
- if(!boxobj) {
- boxobj = tag('div', {id: 'ds_box', 'class':'hidden'}, tag('b', 'Loading...'));
- addBody(boxobj);
- }
- return boxobj;
-}
-
-function init(obj, url, trfunc, serfunc, retfunc) {
- obj.setAttribute('autocomplete', 'off');
- obj.onkeydown = keydown;
- obj.onclick = obj.onchange = obj.oninput = function() { return textchanged(obj); };
- obj.onblur = blur;
- obj.ds_returnFunc = retfunc;
- obj.ds_trFunc = trfunc;
- obj.ds_serFunc = serfunc;
- obj.ds_searchURL = url;
- obj.ds_selectedId = 0;
- obj.ds_dosearch = null;
- obj.ds_lastVal = obj.value;
-}
-
-function blur() {
- setTimeout(function () {
- setClass(box(), 'hidden', true)
- }, 500)
-}
-
-function setselected(obj, id) {
- obj.ds_selectedId = id;
- var l = byName(box(), 'tr');
- for(var i=0; i<l.length; i++)
- setClass(l[i], 'selected', id && l[i].id == 'ds_box_'+id);
-}
-
-function setvalue(obj) {
- if(obj.ds_selectedId != 0)
- obj.value = obj.ds_lastVal = obj.ds_serFunc(byId('ds_box_'+obj.ds_selectedId).ds_itemData, obj);
- if(obj.ds_returnFunc)
- obj.ds_returnFunc(obj);
-
- setClass(box(), 'hidden', true);
- setContent(box(), tag('b', 'Loading...'));
- setselected(obj, 0);
- if(obj.ds_dosearch) {
- clearTimeout(obj.ds_dosearch);
- obj.ds_dosearch = null;
- }
-}
-
-function enter(obj) {
- // Make sure the form doesn't submit when enter is pressed.
- // This solution is a hack, but it's simple and reliable.
- var frm = obj;
- while(frm && frm.nodeName.toLowerCase() != 'form')
- frm = frm.parentNode;
- if(frm) {
- var oldsubmit = frm.onsubmit;
- frm.onsubmit = function() { return false };
- setTimeout(function() { frm.onsubmit = oldsubmit }, 100);
- }
-
- setvalue(obj);
- return false;
-}
-
-function updown(obj, up) {
- var i, sel, l = byName(box(), 'tr');
- if(l.length < 1)
- return true;
-
- if(obj.ds_selectedId == 0)
- sel = up ? l.length-1 : 0;
- else
- for(i=0; i<l.length; i++)
- if(l[i].id == 'ds_box_'+obj.ds_selectedId)
- sel = up ? (i>0 ? i-1 : l.length-1) : (l[i+1] ? i+1 : 0);
-
- setselected(obj, l[sel].id.substr(7));
- return false;
-}
-
-function textchanged(obj) {
- // Ignore this event if the text hasn't actually changed.
- if(obj.ds_lastVal == obj.value)
- return true;
- obj.ds_lastVal = obj.value;
-
- // perform search after a timeout
- if(obj.ds_dosearch)
- clearTimeout(obj.ds_dosearch);
- obj.ds_dosearch = setTimeout(function() {
- search(obj);
- }, 500);
- return true;
-}
-
-function keydown(ev) {
- var c = document.layers ? ev.which : document.all ? event.keyCode : ev.keyCode;
- var obj = this;
-
- if(c == 9) // tab
- return true;
-
- if(c == 13) // enter
- return enter(obj);
-
- if(c == 38 || c == 40) // up / down
- return updown(obj, c == 38);
-
- return textchanged(obj);
-}
-
-function search(obj) {
- var b = box();
- var val = obj.value;
-
- clearTimeout(obj.ds_dosearch);
- obj.ds_dosearch = null;
-
- // hide the ds_box div if the search string is too short
- if(val.length < 2) {
- setClass(b, 'hidden', true);
- setContent(b, tag('b', 'Loading...'));
- setselected(obj, 0);
- return;
- }
-
- // position the div
- var ddx=0;
- var ddy=obj.offsetHeight;
- var o = obj;
- do {
- ddx += o.offsetLeft;
- ddy += o.offsetTop;
- } while(o = o.offsetParent);
-
- b.style.position = 'absolute';
- b.style.left = ddx+'px';
- b.style.top = ddy+'px';
- b.style.width = obj.offsetWidth+'px';
- setClass(b, 'hidden', false);
-
- // perform search
- ajax(obj.ds_searchURL + encodeURIComponent(val), function(hr) { results(hr, obj) });
-}
-
-function results(hr, obj) {
- var lst = hr.responseXML.getElementsByTagName('item');
- var b = box();
- if(lst.length < 1) {
- setContent(b, tag('b', 'No results...'));
- setselected(obj, 0);
- return;
- }
-
- var tb = tag('tbody', null);
- for(var i=0; i<lst.length; i++) {
- var id = lst[i].getAttribute('id');
- var tr = tag('tr', {id: 'ds_box_'+id, ds_itemData: lst[i]} );
-
- tr.onmouseover = function() { setselected(obj, this.id.substr(7)) };
- tr.onmousedown = function() { setselected(obj, this.id.substr(7)); setvalue(obj) };
-
- obj.ds_trFunc(lst[i], tr);
- tb.appendChild(tr);
- }
- setContent(b, tag('table', tb));
- setselected(obj, obj.ds_selectedId != 0 && !byId('ds_box_'+obj.ds_selectedId) ? 0 : obj.ds_selectedId);
-}
-
-window.dsInit = init;
diff --git a/data/js/filter.js b/data/js/filter.js
deleted file mode 100644
index 8c2edb6d..00000000
--- a/data/js/filter.js
+++ /dev/null
@@ -1,727 +0,0 @@
-/* Filter box definition:
- * [ <title>,
- * [ <category_name>,
- * [ <fieldcode>, <fieldname>, <fieldcontents>, <fieldreadfunc>, <fieldwritefunc>, <fieldshowfunc> ], ..
- * ], ..
- * ]
- * Where:
- * <title> human-readable title of the filter box
- * <category_name> human-readable name of the category. ignored if there's only one category
- * <fieldcode> code of this field, refers to the <field> in the filter format. Empty string for just a <tr>
- * <fieldname> human-readanle name of the field. Empty to not display a label. Space for always-enabled items (without checkbox)
- * <fieldcontents> tag() object, or an array of tag() objects
- * <fieldreadfunc> function reference. argument: <fieldcontents>; must return data to be used in the filter format
- * <fieldwritefunc> function reference, argument: <fieldcontents>, data from filter format; must update the contents with the passed data
- * <fieldshowfunc> function reference, argument: <fieldcontents>, called when the field is displayed
- *
- * Filter string format:
- * <field>-<value1>~<value2>.<field2>-<value>.<field3>-<value1>~<value2>
- * Where:
- * <field> = [a-z0-9]+
- * <value> = [a-zA-Z0-9_]+ and any UTF-8 characters not in the ASCII range
- * Escaping of the <value>:
- * "_<two-number-code>"
- * Where <two-number-code> is the decimal index to the following array:
- * _ <space> ! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \ ] ^ ` { | } ~
- * For boolean fields, the <value> is either 0 or 1.
- */
-
-var fil_escape = "_ !\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~".split('');
-var fil_objs = [];
-
-function getObj(obj) {
- while(!obj.fil_fields)
- obj = obj.parentNode;
- return obj;
-}
-
-
-function filLoad(lnk, serobj) {
- var type = lnk.href.match(/#r$/) ? 'r' : lnk.href.match(/#c$/) ? 'c' : lnk.href.match(/#s$/) ? 's' : 'v';
- var l = {r: filReleases, c: filChars, s: filStaff, v: filVN}[type]();
-
- var fields = {};
- var cats = [];
- var p = tag('p', {'class':'browseopts'});
- var c = tag('div', null);
- var idx = 0;
- for(var i=1; i<l.length; i++) {
- if(!l[i])
- continue;
-
- // category link
- var a = tag('a', { href: '#', onclick: selectCat, fil_onshow:[] }, l[i][0]);
- cats.push(a);
- p.appendChild(a);
- p.appendChild(tag(' '));
-
- // category contents
- var t = tag('table', {'class':'formtable hidden', fil_a: a}, null);
- a.fil_t = t;
- for(var j=1; j<l[i].length; j++) {
- var fd = l[i][j];
- var lab = typeof fd[1] == 'object' ? fd[1][0] : fd[1];
- var name = 'fil_check_'+type+'_'+fd[0];
- var f = tag('tr', {'class':'newfield', fil_code: fd[0], fil_readfunc: fd[3], fil_writefunc: fd[4]},
- // Checkbox
- fd[0] ? tag('td', {'class':'check'},
- tag('input', {type:'checkbox', id:name, name:name, 'class': 'enabled_check'+(fd[1]==' '?' hidden':''), onclick: selectField }))
- : tag('td', null),
- // Label
- fd[1] ? tag('td', {'class':'label'},
- tag('label', {'for':name}, lab),
- typeof fd[1] == 'object' ? tag('b', fd[1][1]) : null
- ) : null,
- // Contents
- tag('td', {'class':'cont' }, fd[2]));
- if(fd[0])
- fields[fd[0]] = f;
- if(fd[5])
- a.fil_onshow.push([ fd[5], fd[2] ]);
- t.appendChild(f);
- }
- c.appendChild(t);
- idx++;
- }
-
- var savenote = tag('p', {'class':'hidden'}, '')
- var obj = tag('div', {
- 'class': 'fil_div hidden',
- fil_fields: fields,
- fil_cats: cats,
- fil_savenote: savenote,
- fil_serobj: serobj,
- fil_lnk: lnk,
- fil_type: type
- },
- tag('a', {href:'#', onclick:show, 'class':'close'}, 'close'),
- tag('h3', l[0]),
- p,
- tag('b', {'class':'ruler'}, null),
- c,
- tag('b', {'class':'ruler'}, null),
- tag('input', {type:'button', 'class':'submit', value: 'Apply', onclick:function () {
- var f = serobj;
- while(f.nodeName.toLowerCase() != 'form')
- f = f.parentNode;
- f.submit();
- }}),
- tag('input', {type:'button', 'class':'submit', value: 'Reset', onclick:function () { serobj.value = ''; deSerialize(obj) } }),
- byId('pref_code') && lnk.id == 'filselect' ? tag('input', {type:'button', 'class':'submit', value: 'Save as default', onclick:saveDefault }) : null,
- savenote
- );
- lnk.fil_obj = obj;
- lnk.onclick = show;
-
- addBody(obj);
- fil_objs.push(obj);
- deSerialize(obj);
- selectCat(obj.fil_cats[0]);
-}
-
-
-function saveDefault() {
- var but = this;
- var obj = getObj(this);
- var note = obj.fil_savenote;
- setText(note, 'Loading...');
- but.enabled = false;
- setClass(note, 'hidden', false);
- var type = obj.fil_type == 'r' ? 'release' : 'vn';
- ajax('/xml/prefs.xml?formcode='+byId('pref_code').title+';key=filter_'+type+';value='+obj.fil_serobj.value, function (hr) {
- setText(note, 'Your saved filters will be applied automatically to several other parts of the site as well, such as the homepage.'+
- ' To change these filters, come back to this page and use the "Save as default" button again.'+
- ' To remove your saved filters, hit "Reset" and then save.');
- but.enable = true;
- });
-}
-
-
-function selectCat(n) {
- var lnk = n.fil_onshow ? n : this;
- var obj = getObj(lnk);
- setClass(obj.fil_savenote, 'hidden', true);
- for(var i=0; i<obj.fil_cats.length; i++) {
- var n = obj.fil_cats[i];
- setClass(n, 'optselected', n == lnk);
- setClass(n.fil_t, 'hidden', n != lnk);
- }
- for(var i=0; i<lnk.fil_onshow.length; i++)
- lnk.fil_onshow[i][0](lnk.fil_onshow[i][1]);
- return false
-}
-
-
-function selectField(f) {
- if(!f.parentNode)
- f = this;
- setClass(getObj(f).fil_savenote, 'hidden', true);
-
- // update checkbox and label
- var o = f;
- while(o.nodeName.toLowerCase() != 'tr')
- o = o.parentNode;
- var c = byClass(o, 'enabled_check')[0];
- if(c != f)
- c.checked = true;
- if(hasClass(c, 'hidden')) // When there's no label (e.g. tagspoil selector)
- c.checked = true;
- var l = byName(o, 'label')[0];
- if(l)
- setClass(l, 'active', c.checked);
-
- // update category link
- while(o.nodeName.toLowerCase() != 'table')
- o = o.parentNode;
- var l = byName(o, 'tr');
- var n=0;
- for(var i=0; i<l.length; i++) {
- var ch = byClass(l[i], 'enabled_check')[0];
- if(ch && !hasClass(ch, 'hidden') && ch.checked)
- n++;
- }
- setClass(o.fil_a, 'active', n>0);
-
- // serialize
- serialize(getObj(o));
- return true;
-}
-
-
-function escapeVal(val) {
- var r = [];
- for(var h=0; h<val.length; h++) {
- var vs = (''+val[h]).split('');
- r[h] = '';
-
- // this isn't a very fast escaping method, blame JavaScript for inflexible search/replace support
- for(var i=0; i<vs.length; i++) {
- for(var j=0; j<fil_escape.length; j++)
- if(vs[i] == fil_escape[j])
- break;
- r[h] += j == fil_escape.length ? vs[i] : '_'+(j<10?'0'+j:j);
- }
- }
-
- return r[0] == '' ? '' : r.join('~');
-}
-
-
-function serialize(obj) {
- if(!obj.fil_fields)
- obj = getObj(this);
- var num = 0;
- var values = {};
-
- for(var f in obj.fil_fields) {
- var fo = obj.fil_fields[f];
- var ch = byClass(fo, 'enabled_check')[0];
- if(!ch || !ch.checked)
- continue;
- if(!hasClass(ch, 'hidden'))
- num++;
-
- var v = escapeVal(fo.fil_readfunc(byClass(fo, 'cont')[0].childNodes[0]));
- if(v != '')
- values[fo.fil_code] = v;
- }
-
- var l = [];
- for(var f in values)
- l.push(f+'-'+values[f]);
-
- obj.fil_serobj.value = l.join('.');
- setText(byName(obj.fil_lnk, 'i')[1], num > 0 ? ' ('+num+')' : '');
-}
-
-
-function deSerialize(obj) {
- var d = obj.fil_serobj.value;
- var fs = d.split('.');
-
- var f = {};
- for(var i=0; i<fs.length; i++) {
- var v = fs[i].split('-');
- if(obj.fil_fields[v[0]])
- f[v[0]] = v[1];
- }
-
- for(var fn in obj.fil_fields)
- if(!f[fn])
- f[fn] = '';
- for(var fn in f) {
- var c = byClass(obj.fil_fields[fn], 'enabled_check')[0];
- if(!c)
- continue;
- c.checked = f[fn] != '';
-
- var v = f[fn].split('~');
- for(var i=0; i<v.length; i++)
- v[i] = v[i].replace(/_([0-9]{2})/g, function (a, e) { return fil_escape[Math.floor(e)] });
-
- obj.fil_fields[fn].fil_writefunc(byClass(obj.fil_fields[fn], 'cont')[0].childNodes[0], v);
- // not very efficient: selectField() does a lot of things that can be
- // batched after all fields have been updated, and in some cases the
- // writefunc() triggers the same selectField() as well
- selectField(c);
- }
-}
-
-
-function show() {
- var obj = this.fil_obj || getObj(this);
-
- // Hide other filter objects
- for(var i=0; i<fil_objs.length; i++)
- if(fil_objs[i] != obj) {
- setClass(fil_objs[i], 'hidden', true);
- setText(byName(fil_objs[i].fil_lnk, 'i')[0], collapsed_icon);
- }
-
- var hid = !hasClass(obj, 'hidden');
- setClass(obj, 'hidden', hid);
- setText(byName(obj.fil_lnk, 'i')[0], hid ? collapsed_icon : expanded_icon);
- setClass(obj.fil_savenote, 'hidden', true);
-
- var o = obj.fil_lnk;
- ddx = ddy = 0;
- do {
- ddx += o.offsetLeft;
- ddy += o.offsetTop;
- } while(o = o.offsetParent);
- ddy += obj.fil_lnk.offsetHeight+2;
- ddx += (obj.fil_lnk.offsetWidth-obj.offsetWidth)/2;
- obj.style.left = ddx+'px';
- obj.style.top = ddy+'px';
-
- return false;
-}
-
-
-var curSlider = null;
-function filFSlider(c, n, min, max, def, unit, ser, deser) {
- // min/max/def/fil_val are in serialized form (i.e. a "value")
- if(!ser) ser = function(v) { return v }; // integer -> value
- if(!deser) deser = function(v) { return parseInt(v) }; // value -> integer
-
- min = deser(min);
- max = deser(max);
- var bw = 200; var pw = 1; // slidebar width and pointer width
- var s = tag('p', {fil_val:def, 'class':'slider'});
- var b = tag('div', {style:'width:'+(bw-2)+'px;', s:s});
- var p = tag('div', {style:'width:'+pw+'px;', s:s});
- var v = tag('span', def+' '+unit);
- s.appendChild(b);
- b.appendChild(p);
- s.appendChild(v);
-
- var set = function (e, v) {
- var w = bw-pw-6;
- var s,x;
-
- if(v) {
- s = e;
- x = deser(v[0] == '' ? def : v[0]);
- x = (x-min)*w/(max-min);
- } else {
- s = curSlider;
- if(!e) e = window.event;
- x = (!e) ? (deser(def)-min)*w/(max-min)
- : (e.pageX || e.clientX + document.body.scrollLeft - document.body.clientLeft)-5;
- var o = s.childNodes[0];
- while(o.offsetParent) {
- x -= o.offsetLeft;
- o = o.offsetParent;
- }
- }
-
- if(x<0) x = 0; if(x>w) x = w;
- s.fil_val = ser(min + Math.floor(x*(max-min)/w));
- s.childNodes[1].innerHTML = s.fil_val+' '+unit;
- s.childNodes[0].childNodes[0].style.left = x+'px';
- return false;
- }
-
- b.onmousedown = p.onmousedown = function (e) {
- curSlider = this.s;
- if(!curSlider.oldmousemove) curSlider.oldmousemove = document.onmousemove;
- if(!curSlider.oldmouseup) curSlider.oldmouseup = document.onmouseup;
- document.onmouseup = function () {
- document.onmousemove = curSlider.oldmousemove;
- curSlider.oldmousemove = null;
- document.onmouseup = curSlider.oldmouseup;
- curSlider.oldmouseup = null;
- selectField(curSlider);
- return false;
- }
- document.onmousemove = set;
- return set(e);
- }
-
- return [c, n, s, function (c) { return [ c.fil_val ]; }, set ];
-}
-
-function filFSelect(c, n, lines, opts) {
- var s = tag('select', {onfocus: selectField, onchange: serialize, multiple: lines > 1, size: lines});
- for(var i=0; i<opts.length; i++) {
- if(typeof opts[i][1] != 'object')
- s.appendChild(tag('option', {name: opts[i][0]}, opts[i][1]));
- else {
- var g = tag('optgroup', {label: opts[i][0]});
- for(var j=1; j<opts[i].length; j++)
- g.appendChild(tag('option', {name: opts[i][j][0]}, opts[i][j][1]));
- s.appendChild(g);
- }
- }
- return [ c, lines > 1 ? [ n, 'Boolean or, selecting more gives more results' ] : n, s,
- function (c) {
- var l = [];
- for(var i=0; i<c.options.length; i++)
- if(c.options[i].selected)
- l.push(c.options[i].name);
- return l;
- },
- function (c, f) {
- for(var i=0; i<c.options.length; i++) {
- for(var j=0; j<f.length; j++)
- if(c.options[i].name+'' == f[j]+'') // beware of JS logic: 0 == '', but '0' != ''
- break;
- c.options[i].selected = j != f.length;
- }
- }
- ];
-}
-
-function filFInput(c, n) {
- return [ c, n,
- tag('input', {type: 'text', 'class': 'text', onfocus: selectField, onchange: serialize}),
- function (c) { return [c.value] },
- function (c, f) { c.value = f }
- ]
-}
-
-function filFEngine(c, n) {
- var input = tag('input', {type: 'text', 'class': 'text', onfocus: selectField, onchange: serialize});
- dsInit(input, '/xml/engines.xml?q=',
- function(item, tr) { tr.appendChild(tag('td', shorten(item.firstChild.nodeValue, 40))); },
- function(item, obj) { return item.firstChild.nodeValue; },
- function(o) { selectField(o) }
- );
- return [ c, n, input,
- function (c) { return [c.value] },
- function (c, f) { c.value = f }
- ]
-}
-
-
-function filFOptions(c, n, opts) {
- var p = tag('p', {'class':'opts', fil_val:opts[0][0]});
- var sel = function (e) {
- var o = typeof e == 'string' ? e : this.fil_n;
- var l = byName(p, 'a');
- for(var i=0; i<l.length; i++)
- setClass(l[i], 'tsel', l[i].fil_n+'' == o+'');
- p.fil_val = o;
- if(typeof e != 'string')
- selectField(p);
- return false
- };
- for(var i=0; i<opts.length; i++) {
- p.appendChild(tag('a', {href:'#', fil_n: opts[i][0], onclick:sel}, opts[i][1]));
- if(i<opts.length-1)
- p.appendChild(tag('b', '|'));
- }
- return [ c, n, p,
- function (c) { return [ c.fil_val ] },
- function (c, v) { sel(v[0]) }
- ];
-}
-
-
-// fieldcode -> See <fieldcode> in filter definitions
-// fieldname -> See <fieldname> in filter definitions
-// src -> The API URL where to get items, must work with dropdownsearch.js and support appending ';q=', ';id=' and ';r=' to it.
-// fmtlist -> Called with item id + XML data, should return an inline element to inject into the list view.
-function filFDList(fieldcode, fieldname, src, fmtds, fmtlist) {
- var visible = false;
- var addel = function(ul, id, data) {
- ul.appendChild(
- tag('li', { fil_id: id },
- data ? fmtlist(id, data) : null,
- ' (', tag('a', {href:'#',
- onclick:function () {
- // a -> li -> ul -> div
- var ul = this.parentNode.parentNode;
- ul.removeChild(this.parentNode);
- selectField(ul.parentNode);
- return false
- }
- }, 'remove'), ')'
- ));
- }
- var fetch = function(c) {
- var v = c.fil_val;
- var ul = byName(c, 'ul')[0];
- var txt = byName(c, 'input')[0];
- if(v == null)
- return;
- if(!v[0]) {
- setText(ul, '');
- txt.disabled = false;
- txt.value = '';
- return;
- }
- if(!visible)
- setText(ul, '');
- var q = [];
- for(var i=0; i<v.length; i++) {
- q.push('id='+v[i]);
- if(!visible)
- addel(ul, v[i]);
- }
- txt.value = 'Loading...';
- txt.disabled = true;
- if(visible)
- ajax(src+';r=50;'+q.join(';'), function (hr) {
- var items = hr.responseXML.getElementsByTagName('item');
- setText(ul, '');
- for(var i=0; i<items.length; i++)
- addel(ul, items[i].getAttribute('id'), items[i]);
- txt.value = '';
- txt.disabled = false;
- c.fil_val = null;
- }, 1);
- };
- var input = tag('input', {type:'text', 'class':'text', style:'width:300px', onfocus:selectField});
- var list = tag('ul', null);
- dsInit(input, src+';q=', fmtds,
- function(item, obj) {
- if(byName(obj.parentNode, 'li').length >= 50)
- alert('Too many items selected');
- else {
- obj.parentNode.fil_val = null;
- addel(byName(obj.parentNode, 'ul')[0], item.getAttribute('id'), item);
- selectField(obj);
- }
- return '';
- },
- function(o) { selectField(o) }
- );
-
- return [
- fieldcode, fieldname, tag('div', list, input),
- function(c) {
- var v = []; var l = byName(c, 'li');
- for(var i=0; i<l.length; i++)
- v.push(l[i].fil_id);
- return v;
- },
- function(c,v) { c.fil_val = v; fetch(c) },
- function(c) { visible = true; fetch(c); }
- ];
-}
-
-function filFTagInput(code, name) {
- return filFDList(code, name, '/xml/tags.xml?searchable=1',
- function(item, tr) {
- tr.appendChild(tag('td', shorten(item.firstChild.nodeValue, 40)));
- },
- function(id, item) {
- return tag('span', tag('a', {href:'/g'+id}, item.firstChild.nodeValue||'g'+id))
- }
- )
-}
-
-function filFTraitInput(code, name) {
- return filFDList(code, name, '/xml/traits.xml?searchable=1',
- function(item, tr) {
- var g = item.getAttribute('groupname');
- tr.appendChild(tag('td',
- g ? tag('b', {'class':'grayedout'}, g+' / ') : null,
- shorten(item.firstChild.nodeValue, 40)
- ));
- },
- function(id, item) {
- var g = item.getAttribute('groupname');
- return tag('span',
- g ? tag('b', {'class':'grayedout'}, g+' / ') : null,
- tag('a', {href:'/i'+id}, item.firstChild.nodeValue||'i'+id)
- )
- }
- )
-}
-
-function filFProducerInput(code, name) {
- return filFDList(code, name, '/xml/producers.xml?',
- function(item, tr) {
- tr.appendChild(tag('td', shorten(item.firstChild.nodeValue, 40)));
- },
- function(id, item) {
- return tag('span', tag('a', {href:'/p'+id}, item.firstChild.nodeValue||'p'+id))
- }
- )
-}
-
-function filFStaffInput(code, name) {
- return filFDList(code, name, '/xml/staff.xml?staffid=1',
- function(item, tr) {
- tr.appendChild(tag('td', shorten(item.firstChild.nodeValue, 40)));
- },
- function(id, item) {
- return tag('span', tag('a', {href:'/s'+id}, item.firstChild.nodeValue||'s'+id))
- }
- )
-}
-
-
-function filChars() {
- var ontraitpage = location.pathname.indexOf('/i') == 0;
-
- var cup_ser = function(v) { return VARS.cup_size[parseInt(v)] };
- var cup_deser = function(v) {
- for(var i=0; i<VARS.cup_size.length; i++)
- if(VARS.cup_size[i] == v)
- return i;
- return 0;
- };
-
- return [
- 'Character filters',
- [ 'General',
- filFSelect('gender', 'Gender', 4, VARS.genders),
- filFSelect('bloodt', 'Blood type', 5, VARS.blood_types),
- '',
- filFSlider('bust_min', 'Bust min', 20, 120, 40, 'cm'),
- filFSlider('bust_max', 'Bust max', 20, 120, 100, 'cm'),
- filFSlider('waist_min', 'Waist min', 20, 120, 40, 'cm'),
- filFSlider('waist_max', 'Waist max', 20, 120, 100, 'cm'),
- filFSlider('hip_min', 'Hips min', 20, 120, 40, 'cm'),
- filFSlider('hip_max', 'Hips max', 20, 120, 100, 'cm'),
- '',
- filFSlider('height_min', 'Height min', 0, 300, 60, 'cm'),
- filFSlider('height_max', 'Height max', 0, 300, 240, 'cm'),
- filFSlider('weight_min', 'Weight min', 0, 400, 80, 'kg'),
- filFSlider('weight_max', 'Weight max', 0, 400, 320, 'kg'),
- filFSlider('cup_min', 'Cup size min', 'AAA', 'Z', 'AAA', '', cup_ser, cup_deser),
- filFSlider('cup_max', 'Cup size max', 'AAA', 'Z', 'E', '', cup_ser, cup_deser)
- ],
- ontraitpage ? [ 'Traits',
- [ '', ' ', tag('Additional trait filters are not available on this page. Use the character browser instead (available from the main menu -> characters).') ],
- ] : [ 'Traits',
- [ '', ' ', tag('Boolean and, selecting more gives less results') ],
- filFTraitInput('trait_inc', 'Traits to include'),
- filFTraitInput('trait_exc', 'Traits to exclude'),
- filFOptions('tagspoil', ' ', [[0, 'Hide spoilers'],[1, 'Show minor spoilers'],[2, 'Spoil me!']]),
- ],
- [ 'Roles', filFSelect('role', 'Roles', 4, VARS.char_roles) ],
- [ 'Seiyuu',
- [ '', ' ', tag('Boolean or, selecting more gives more results') ],
- filFStaffInput('va_inc', 'Seiyuu to include'),
- filFStaffInput('va_exc', 'Seiyuu to exclude')
- ],
- ];
-}
-
-function filReleases() {
- var plat = VARS.platforms;
- plat.splice(0, 0, [ 'unk', 'Unknown' ]);
- var med = VARS.media;
- med.splice(0, 0, [ 'unk', 'Unknown' ]);
- return [
- 'Release filters',
- [ 'General',
- filFOptions('type', 'Release type', VARS.release_types),
- filFOptions('patch', 'Patch status', [ [1, 'Patch'], [0, 'Standalone'] ]),
- filFOptions('freeware', 'Freeware', [ [1, 'Only freeware'], [0, 'Only non-free releases'] ]),
- filFOptions('doujin', 'Doujin', [ [1, 'Only doujin releases'], [0, 'Only commercial releases'] ]),
- filFOptions('uncensored','Censoring', [ [1, 'Only uncensored releases'], [0, 'Censored or non-erotic releases'] ]),
- [ 'date_after', 'Released after', dateLoad(null, selectField), function (c) { return [c.date_val] }, function(o,v) { o.dateSet(v) } ],
- [ 'date_before', 'Released before', dateLoad(null, selectField), function (c) { return [c.date_val] }, function(o,v) { o.dateSet(v) } ],
- filFOptions('released', 'Release date', [ [1, 'Past (already released)'], [0, 'Future (to be released)'] ])
- ],
- [ 'Age rating', filFSelect('minage', 'Age rating', 15, VARS.age_ratings) ],
- [ 'Language', filFSelect('lang', 'Language', 20, VARS.languages) ],
- byId('rfilselect') ? null :
- [ 'Original language', filFSelect('olang', 'Original language', 20, VARS.languages) ],
- [ 'Screen resolution', filFSelect('resolution', 'Screen resolution', 15, VARS.resolutions) ],
- [ 'Platform', filFSelect('plat', 'Platform', 20, plat) ],
- [ 'Producer',
- [ '', ' ', tag('Boolean or, selecting more gives more results') ],
- filFProducerInput('prod_inc', 'Producers to include'),
- filFProducerInput('prod_exc', 'Producers to exclude')
- ],
- [ 'Misc',
- filFSelect('med', 'Medium', 10, med),
- filFSelect('voiced', 'Voiced', 5, VARS.voiced),
- filFSelect('ani_story', 'Story animation', 5, VARS.animated),
- filFSelect('ani_ero', 'Ero animation', 5, VARS.animated),
- filFEngine('engine', 'Engine')
- ]
- ];
-}
-
-function filVN() {
- var ontagpage = location.pathname.indexOf('/v/') < 0;
-
- return [
- 'Visual Novel Filters',
- [ 'General',
- filFSelect( 'length', 'Length', 6, VARS.vn_lengths),
- filFOptions('hasani', 'Anime', [[1, 'Has anime'], [0, 'Does not have anime']]),
- filFOptions('hasshot','Screenshots', [[1, 'Has screenshot'],[0, 'Does not have a screenshot']]),
- [ 'date_after', 'Released after', dateLoad(null, selectField), function (c) { return [c.date_val] }, function(o,v) { o.dateSet(v) } ],
- [ 'date_before', 'Released before', dateLoad(null, selectField), function (c) { return [c.date_val] }, function(o,v) { o.dateSet(v) } ],
- filFOptions('released', 'Release date', [ [1, 'Past (already released)'], [0, 'Future (to be released)'] ])
- ],
- ontagpage ? [ 'Tags',
- [ '', ' ', tag('Additional tag filters are not available on this page. Use the visual novel browser instead (available from the main menu -> visual novels).') ],
- ] : [ 'Tags',
- [ '', ' ', tag('Boolean and, selecting more gives less results') ],
- [ '', ' ', byId('pref_code') ? tag('These filters are ignored on tag pages (when set as default).') : null ],
- filFTagInput('tag_inc', 'Tags to include'),
- filFTagInput('tag_exc', 'Tags to exclude'),
- filFOptions('tagspoil', ' ', [[0, 'Hide spoilers'],[1, 'Show minor spoilers'],[2, 'Spoil me!']])
- ],
- [ 'Language', filFSelect('lang', 'Language', 20, VARS.languages) ],
- [ 'Original language', filFSelect('olang','Original language', 20, VARS.languages) ],
- [ 'Platform', filFSelect('plat', 'Platform', 20, VARS.platforms) ],
- [ 'Staff',
- [ '', ' ', tag('Boolean or, selecting more gives more results') ],
- filFStaffInput('staff_inc', 'Staff to include'),
- filFStaffInput('staff_exc', 'Staff to exclude')
- ],
- !byId('pref_code') ? null : [
- 'My lists',
- filFOptions('ul_notblack', 'Blacklist', [[1, 'Exclude VNs on my blacklist']]),
- filFOptions('ul_onwish', 'Wishlist', [[0, 'Not on my wishlist'],[1, 'On my wishlist']]),
- filFOptions('ul_voted', 'Voted', [[0, 'Not voted on'], [1, 'Voted on' ]]),
- filFOptions('ul_onlist', 'VN list', [[0, 'Not on my VN list'],[1, 'On my VN list']])
- ],
- ];
-}
-
-function filStaff() {
- var gend = VARS.genders.slice(0, 3);
-
- // Insert seiyuu into the list of roles, before the "staff" role.
- var roles = VARS.credit_type;
- roles.splice(-1, 0, ['seiyuu', 'Voice actor']);
-
- return [
- 'Staff filters',
- [ 'General',
- filFOptions('truename', 'Names', [[1, 'Primary names only'],[0, 'Include aliases']]),
- filFSelect('role', 'Roles', roles.length, roles),
- '',
- filFSelect('gender', 'Gender', gend.length, gend),
- ],
- [ 'Language', filFSelect('lang', 'Language', 20, VARS.languages) ],
- ];
-}
-
-if(byId('filselect'))
- filLoad(byId('filselect'), byId('fil'));
-if(byId('rfilselect'))
- filLoad(byId('rfilselect'), byId('rfil'));
-if(byId('cfilselect'))
- filLoad(byId('cfilselect'), byId('cfil'));
diff --git a/data/js/iv.js b/data/js/iv.js
deleted file mode 100644
index 98889c5e..00000000
--- a/data/js/iv.js
+++ /dev/null
@@ -1,138 +0,0 @@
-/* Simple image viewer widget. Usage:
- *
- * <a href="full_image.jpg" data-iv="{width}x{height}:{category}">..</a>
- *
- * Clicking on the above link will cause the image viewer to open
- * full_image.jpg. The {category} part can be empty or absent. If it is not
- * empty, next/previous links will show up to point to the other images within
- * the same category.
- *
- * ivInit() should be called when links with "data-iv" attributes are
- * dynamically added or removed from the DOM.
- */
-
-// Cache of image categories and the list of associated link objects. Used to
-// quickly generate the next/prev links.
-var cats;
-
-function init() {
- cats = {};
- var n = 0;
- var l = byName('a');
- for(var i=0;i<l.length;i++) {
- var o = l[i];
- if(o.getAttribute('data-iv') && o.id != 'ivprev' && o.id != 'ivnext') {
- n++;
- o.onclick = show;
- var cat = o.getAttribute('data-iv').split(':')[1];
- if(cat) {
- if(!cats[cat])
- cats[cat] = [];
- o.iv_i = cats[cat].length;
- cats[cat].push(o);
- }
- }
- }
-
- if(n && !byId('iv_view')) {
- addBody(tag('div', {id: 'iv_view','class':'hidden', onclick: function(ev) { ev.stopPropagation(); return true } },
- tag('b', {id:'ivimg'}, ''),
- tag('br', null),
- tag('a', {href:'#', id:'ivfull'}, ''),
- tag('a', {href:'#', onclick: close, id:'ivclose'}, 'close'),
- tag('a', {href:'#', onclick: show, id:'ivprev'}, '« previous'),
- tag('a', {href:'#', onclick: show, id:'ivnext'}, 'next »')
- ));
- addBody(tag('b', {id:'ivimgload','class':'hidden'}, 'Loading...'));
- }
-}
-
-// Find the next (dir=1) or previous (dir=-1) non-hidden link object for the category.
-function findnav(cat, i, dir) {
- for(var j=i+dir; j>=0 && j<cats[cat].length; j+=dir)
- if(!hasClass(cats[cat][j], 'hidden') && cats[cat][j].offsetWidth > 0 && cats[cat][j].offsetHeight > 0)
- return cats[cat][j];
- return 0
-}
-
-// fix properties of the prev/next links
-function fixnav(lnk, cat, i, dir) {
- var a = cat ? findnav(cat, i, dir) : 0;
- lnk.style.visibility = a ? 'visible' : 'hidden';
- lnk.href = a ? a.href : '#';
- lnk.iv_i = a ? a.iv_i : 0;
- lnk.setAttribute('data-iv', a ? a.getAttribute('data-iv') : '');
-}
-
-function show(ev) {
- var u = this.href;
- var opt = this.getAttribute('data-iv').split(':');
- var idx = this.iv_i;
- var view = byId('iv_view');
- var full = byId('ivfull');
-
- fixnav(byId('ivprev'), opt[1], idx, -1);
- fixnav(byId('ivnext'), opt[1], idx, 1);
-
- // calculate dimensions
- var w = Math.floor(opt[0].split('x')[0]);
- var h = Math.floor(opt[0].split('x')[1]);
- var ww = typeof(window.innerWidth) == 'number' ? window.innerWidth : document.documentElement.clientWidth;
- var wh = typeof(window.innerHeight) == 'number' ? window.innerHeight : document.documentElement.clientHeight;
- var st = typeof(window.pageYOffset) == 'number' ? window.pageYOffset : document.body && document.body.scrollTop ? document.body.scrollTop : document.documentElement.scrollTop;
- if(w+100 > ww || h+70 > wh) {
- full.href = u;
- setText(full, w+'x'+h);
- full.style.visibility = 'visible';
- if(w/h > ww/wh) { // width++
- h *= (ww-100)/w;
- w = ww-100;
- } else { // height++
- w *= (wh-70)/h;
- h = wh-70;
- }
- } else
- full.style.visibility = 'hidden';
- var dw = w;
- var dh = h+20;
- dw = dw < 200 ? 200 : dw;
-
- // update document
- setClass(view, 'hidden', false);
- setContent(byId('ivimg'), tag('img', {src:u, onclick:close,
- onload: function() { setClass(byId('ivimgload'), 'hidden', true); },
- style: 'width: '+w+'px; height: '+h+'px'
- }));
- view.style.width = dw+'px';
- view.style.height = dh+'px';
- view.style.left = ((ww - dw) / 2 - 10)+'px';
- view.style.top = ((wh - dh) / 2 + st - 20)+'px';
- byId('ivimgload').style.left = ((ww - 100) / 2 - 10)+'px';
- byId('ivimgload').style.top = ((wh - 20) / 2 + st)+'px';
- setClass(byId('ivimgload'), 'hidden', false);
-
- document.onclick = close;
- // Capture left/right arrow keys
- document.onkeydown = function(e) {
- if(e.keyCode == 37 && byId('ivprev').style.visibility == 'visible') {
- byId('ivprev').click();
- }
- if(e.keyCode == 39 && byId('ivnext').style.visibility == 'visible') {
- byId('ivnext').click();
- }
- };
- ev.stopPropagation();
- return false;
-}
-
-function close() {
- document.onclick = null;
- document.onkeydown = null;
- setClass(byId('iv_view'), 'hidden', true);
- setClass(byId('ivimgload'), 'hidden', true);
- setText(byId('ivimg'), '');
- return false;
-}
-
-window.ivInit = init;
-init();
diff --git a/data/js/lib.js b/data/js/lib.js
deleted file mode 100644
index a4921d3e..00000000
--- a/data/js/lib.js
+++ /dev/null
@@ -1,179 +0,0 @@
-window.expanded_icon = '▾',
-window.collapsed_icon = '▸';
-
-
-var ajax_req;
-window.ajax = function(url, func, async, body) {
- if(!async && ajax_req)
- ajax_req.abort();
- var req = window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject('Microsoft.XMLHTTP');
- if(!async)
- ajax_req = req;
- req.onreadystatechange = function() {
- if(!req || req.readyState != 4 || !req.responseText)
- return;
- if(req.status != 200)
- return alert('Whoops, error! :(');
- func(req);
- };
- if(!body)
- url += (url.indexOf('?')>=0 ? ';' : '?')+(Math.floor(Math.random()*999)+1);
- req.open(body ? 'POST' : 'GET', url, true);
- req.send(body);
- return req;
-};
-
-
-window.setCookie = function(n,v) {
- var date = new Date();
- date.setTime(date.getTime()+(365*24*60*60*1000));
- document.cookie = VARS.cookie_prefix+n+'='+v+'; expires='+date.toGMTString()+'; path=/';
-};
-
-window.getCookie = function(n) {
- var l = document.cookie.split(';');
- n = VARS.cookie_prefix+n;
- for(var i=0; i<l.length; i++) {
- var c = l[i];
- while(c.charAt(0) == ' ')
- c = c.substring(1,c.length);
- if(c.indexOf(n+'=') == 0)
- return c.substring(n.length+1,c.length);
- }
- return null;
-};
-
-
-window.byId = function(n) {
- return document.getElementById(n)
-};
-
-window.byName = function(){
- var d = arguments.length > 1 ? arguments[0] : document;
- var n = arguments.length > 1 ? arguments[1] : arguments[0];
- return d.getElementsByTagName(n);
-};
-
-window.byClass = function() { // [class], [parent, class], [tagname, class], [parent, tagname, class]
- var par = typeof arguments[0] == 'object' ? arguments[0] : document;
- var t = arguments.length == 2 && typeof arguments[0] == 'string' ? arguments[0] : arguments.length == 3 ? arguments[1] : '*';
- var c = arguments[arguments.length-1];
- var l = byName(par, t);
- var ret = [];
- for(var i=0; i<l.length; i++)
- if(hasClass(l[i], c))
- ret[ret.length] = l[i];
- return ret;
-};
-
-
-/* wrapper around DOM element creation
- * tag('string') -> createTextNode
- * tag('tagname', tag(), 'string', ..) -> createElement(), appendChild(), ..
- * tag('tagname', { class: 'meh', title: 'Title' }) -> createElement(), setAttribute()..
- * tag('tagname', { <attributes> }, <elements>) -> create, setattr, append */
-window.tag = function() {
- if(arguments.length == 1)
- return typeof arguments[0] != 'object' ? document.createTextNode(arguments[0]) : arguments[0];
- var el = typeof document.createElementNS != 'undefined'
- ? document.createElementNS('http://www.w3.org/1999/xhtml', arguments[0])
- : document.createElement(arguments[0]);
- for(var i=1; i<arguments.length; i++) {
- if(arguments[i] == null)
- continue;
- if(typeof arguments[i] == 'object' && !arguments[i].appendChild) {
- for(attr in arguments[i]) {
- if(attr == 'style' || attr.match(/^data-/))
- el.setAttribute(attr, arguments[i][attr]);
- else
- el[ attr == 'class' ? 'className' : attr == 'for' ? 'htmlFor' : attr ] = arguments[i][attr];
- }
- } else
- el.appendChild(tag(arguments[i]));
- }
- return el;
-};
-
-
-window.addBody = function(el) {
- if(document.body.appendChild)
- document.body.appendChild(el);
- else if(document.documentElement.appendChild)
- document.documentElement.appendChild(el);
- else if(document.appendChild)
- document.appendChild(el);
-};
-
-window.setContent = function() {
- setText(arguments[0], '');
- for(var i=1; i<arguments.length; i++)
- if(arguments[i] != null)
- arguments[0].appendChild(tag(arguments[i]));
-};
-
-window.getText = function(obj) {
- return obj.textContent || obj.innerText || '';
-};
-
-window.setText = function(obj, txt) {
- if(obj.textContent != null)
- obj.textContent = txt;
- else
- obj.innerText = txt;
-};
-
-
-window.listClass = function(obj) {
- var n = obj.className;
- if(!n)
- return [];
- return n.split(/ /);
-};
-
-window.hasClass = function(obj, c) {
- var l = listClass(obj);
- for(var i=0; i<l.length; i++)
- if(l[i] == c)
- return true;
- return false;
-};
-
-window.setClass = function(obj, c, set) {
- var l = listClass(obj);
- var n = [];
- if(set) {
- n = l;
- if(!hasClass(obj, c))
- n[n.length] = c;
- } else {
- for(var i=0; i<l.length; i++)
- if(l[i] != c)
- n[n.length] = l[i];
- }
- obj.className = n.join(' ');
-};
-
-window.onSubmit = function(form, handler) {
- var prev_handler = form.onsubmit;
- form.onsubmit = function(e) {
- if(prev_handler)
- if(!prev_handler(e))
- return false;
- return handler(e);
- }
-};
-
-
-window.shorten = function(v, l) {
- return v.length > l ? v.substr(0, l-3)+'...' : v;
-};
-
-
-window.fmtspoil = function(s) {
- return ['neutral', 'no spoiler', 'minor spoiler', 'major spoiler'][s+1];
-}
-
-
-window.jsonParse = function(s) {
- return s ? JSON.parse(s) : '';
-};
diff --git a/data/js/main.js b/data/js/main.js
deleted file mode 100644
index de2fba2e..00000000
--- a/data/js/main.js
+++ /dev/null
@@ -1,57 +0,0 @@
-// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only
-// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/data/js
-// SPDX-License-Identifier: AGPL-3.0-only
-
-
-/* This is the main Javascript file. This file is processed by util/jsgen.pl to
- * generate the final JS file(s) used by the site. */
-
-// Variables from jsgen.pl
-VARS = /*VARS*/;
-
-/* The include directives below automatically wrap the file contents inside an
- * anonymous function, so each file has its own local namespace. Included files
- * can't access variables or functions from other files, unless these variables
- * are explicitely shared in DOM objects or (more commonly) the global 'window'
- * object.
- */
-
-// Reusable library functions
-//include lib.js
-
-// Reusable widgets
-//include iv.js
-//include dropdown.js
-//include dateselector.js
-//include dropdownsearch.js
-//include tabs.js
-
-// Page/functionality-specific widgets
-//include vnreldropdown.js
-//include charops.js
-//include filter.js
-//include misc.js
-//include polls.js
-
-// VN editing (/v+/edit)
-//include vnrel.js
-//include vnscr.js
-//include vnstaff.js
-//include vncast.js
-
-// VN tag editing (/v+/tagmod)
-//include vntagmod.js
-
-// Release editing (/r+/edit)
-//include relmedia.js
-//include relvns.js
-//include relprod.js
-
-// Producer editing (/p+/edit)
-//include prodrel.js
-
-// Character editing (/c+/edit)
-//include chartraits.js
-//include charvns.js
-
-// @license-end
diff --git a/data/js/misc.js b/data/js/misc.js
deleted file mode 100644
index fd042524..00000000
--- a/data/js/misc.js
+++ /dev/null
@@ -1,275 +0,0 @@
-function ulist_redirect(type, path, formcode, args) {
- var r = new RegExp('/('+type+'[0-9]+).*$');
- location.href = location.href.replace(r, '/$1')+path
- +'?formcode='+formcode
- +';ref='+encodeURIComponent(location.pathname+location.search)
- +';'+args;
-}
-
-
-function vote_validate(s) {
- if(s < 1)
- s = prompt('Please input your vote as a number between 1 and 10. One digit after the decimal is allowed, for example: 8.6 or 7.3.', '');
- if(!s)
- return 0;
- s = s.replace(',', '.');
- if(!s.match(/^([1-9]|10)([\.,][0-9])?$/) || s > 10 || s < 1) {
- alert('Invalid number.');
- return 0;
- }
- if(s == 1 && !confirm('You are about to give this visual novel a 1 out of 10.'+
- ' This is a rather extreme rating, meaning this game has absolutely nothing to offer, and that it\'s the worst game you have ever played.'+
- ' Are you really sure this visual novel matches that description?'))
- return 0;
- if(s == 10 && !confirm('You are about to give this visual novel a 10 out of 10.'+
- ' This is a rather extreme rating, meaning this is one of the best visual novels you\'ve ever played and it\'s unlikely that any other game could ever be better than this one.'+
- ' It is generally a bad idea to have more than three games in your vote list with this rating, choose carefully!'))
- return 0;
- return s;
-}
-
-
-// VN Voting (/v+)
-if(byId('votesel'))
- byId('votesel').onchange = function() {
- var s = this.options[this.selectedIndex].value;
- if(s == -3)
- return;
- if(s != -1)
- s = vote_validate(s);
- if(!s)
- this.selectedIndex = 0;
- else
- ulist_redirect('v', '/vote', this.name, 'v='+s);
- };
-
-
-// VN voting from list (/u+/votes)
-if(byId('batchvotes'))
- byId('batchvotes').onchange = function() {
- var s = this.options[this.selectedIndex].value;
- if(s == -2)
- return;
- if(s != -1)
- s = vote_validate(s);
- if(!s) {
- this.selectedIndex = 0;
- return;
- }
- this.options[this.selectedIndex].value = s;
- var frm = this;
- while(frm.nodeName.toLowerCase() != 'form')
- frm = frm.parentNode;
- frm.submit();
- };
-
-
-// VN Wishlist dropdown box (/v+)
-if(byId('wishsel'))
- byId('wishsel').onchange = function() {
- if(this.selectedIndex != 0)
- ulist_redirect('v', '/wish', this.name, ';s='+this.options[this.selectedIndex].value);
- };
-
-
-// Release & VN list dropdown box (/r+ and /v+)
-if(byId('listsel'))
- byId('listsel').onchange = function() {
- if(this.selectedIndex != 0)
- ulist_redirect('[rv]', '/list', this.name, 'e='+this.options[this.selectedIndex].value);
- };
-
-
-// 'more' / 'less' summarization of some boxes on VN pages
-(function(){
- function set(o, h) {
- var a = tag('a', {href:'#', summarizeOn:false}, '');
- var toggle = function() {
- a.summarizeOn = !a.summarizeOn;
- o.style.maxHeight = a.summarizeOn ? h+'px' : null;
- o.style.overflowY = a.summarizeOn ? 'hidden' : null;
- setText(a, a.summarizeOn ? '⇓ more ⇓' : '⇑ less ⇑');
- return false;
- };
- a.onclick = toggle;
- var t = tag('div', {'class':'summarize_more'}, a);
- l[i].parentNode.insertBefore(t, l[i].nextSibling);
- toggle();
- }
-
- var l = byClass(document, 'summarize');
-
- for(var i=0; i<l.length; i++) {
- var h = Math.floor(l[i].getAttribute('data-summarize-height') || 150);
- if(l[i].offsetHeight > h+100)
- set(l[i], h);
- }
-})();
-
-
-// make some fields readonly when patch flag is set and hide uncensored
-// checkbox when age rating isn't 18+ (/r+/edit)
-(function(){
- function sync() {
- byId('doujin').disabled =
- byId('resolution').disabled =
- byId('voiced').disabled =
- byId('ani_story').disabled =
- byId('ani_ero').disabled =
- byId('engine').disabled =
- byId('engine_oth').disabled =
- byId('patch').checked;
-
- setClass(
- byId('uncensored').parentNode.parentNode,
- 'hidden',
- byId('minage').options[byId('minage').selectedIndex].value != 18
- );
- };
- if(byId('jt_box_rel_geninfo')) {
- sync();
- byId('patch').onclick = byId('minage').onclick = sync;
- }
-})();
-
-
-// Release edit engine selection (/r+/edit)
-(function(){
- var en = byId('engine');
- var en_other = byId('engine_oth');
- if(en && en_other) {
- en.onchange = function() {
- setClass(en_other, 'hidden', en.options[en.selectedIndex].value != '_other_');
- return true;
- };
- }
-})();
-
-
-// Batch edit dropdown box (/u+/wish)
-if(byId('batchedit'))
- byId('batchedit').onchange = function() {
- if(this.selectedIndex == 0)
- return true;
- var frm = this;
- while(frm.nodeName.toLowerCase() != 'form')
- frm = frm.parentNode;
- frm.submit();
- };
-
-
-// collapse/expand row groups (/u+/list)
-(function(){
- var table = byId('expandall');
- if(!table)
- return;
- while(table.nodeName.toLowerCase() != 'table')
- table = table.parentNode;
- var heads = byClass(table, 'td', 'collapse_but');
- var allhid = false;
-
- function sethid(l, h, hid) {
- var i;
- for(i=0; i<l.length; i++) {
- setClass(l[i], 'hidden', hid);
- // Set the hidden class on the input checkbox, if it exists. This
- // prevents the "select all" functionality from selecting it if the row
- // is not visible.
- var sel = byName(l[i], 'input')[0];
- if(sel)
- setClass(sel, 'hidden', hid);
- }
- for(i=0; i<h.length; i++)
- setText(h[i], allhid ? collapsed_icon : expanded_icon);
- }
-
- function alltoggle() {
- allhid = !allhid;
- setText(byId('expandall'), allhid ? collapsed_icon : expanded_icon);
- sethid(byClass(table, 'tr', 'collapse'), heads, allhid);
- return false;
- }
-
- function singletoggle() {
- var l = byClass(table, 'tr', 'collapse_'+this.id);
- sethid(l, [this], !hasClass(l[0], 'hidden'));
- }
-
- byId('expandall').onclick = alltoggle;
- for(var i=0; i<heads.length; i++)
- heads[i].onclick = singletoggle;
- alltoggle();
-})();
-
-
-// external links dropdown for releases (/v+)
-(function(){
- var l = byClass('rllinks');
- for(var i=0; i<l.length; i++) {
- var o = byName(l[i].parentNode, 'ul')[0];
- if(o) {
- l[i].links_ul = l[i].parentNode.removeChild(o);
- setClass(l[i].links_ul, 'hidden', false);
- ddInit(l[i], 'left', function(acr) {
- return acr.links_ul;
- });
- if(l[i].href.match(/#$/)) {
- l[i].onclick = function() { return false; };
- }
- }
- }
-})();
-
-// set note input box (/u+/list)
-if(byId('not') && byId('vns'))
- byId('vns').onchange = function () {
- if(this.options[this.selectedIndex].value == 999)
- byId('not').value = prompt('Set notes (leave empty to remove note)', '');
- return true;
- };
-
-
-// expand/collapse release listing (/p+)
-(function(){
- var lnk = byId('expandprodrel');
- if(!lnk)
- return;
- function setexpand() {
- var exp = !(getCookie('prodrelexpand') == 1);
- setText(lnk, exp ? 'collapse' : 'expand');
- setClass(byId('prodrel'), 'collapse', !exp);
- };
- lnk.onclick = function () {
- setCookie('prodrelexpand', getCookie('prodrelexpand') == 1 ? 0 : 1);
- setexpand();
- return false;
- };
- setexpand();
-})();
-
-
-// search tabs
-(function(){
- function click() {
- var str = byId('q').value;
- if(str.length > 1) {
- this.href = this.href.split('?')[0];
- if(this.href.indexOf('/g') >= 0 || this.href.indexOf('/i') >= 0)
- this.href += '/list';
- this.href += '?q=' + encodeURIComponent(str);
- }
- return true;
- };
- if(byId('searchtabs')) {
- var l = byName(byId('searchtabs'), 'a');
- for(var i=0; i<l.length; i++)
- l[i].onclick = click;
- }
-})();
-
-
-// spam protection on all forms
-setTimeout(function() {
- for(var i=1; i<document.forms.length; i++)
- document.forms[i].action = document.forms[i].action.replace(/\/nospam\?/,'');
-}, 500);
diff --git a/data/js/polls.js b/data/js/polls.js
deleted file mode 100644
index 94a0bba2..00000000
--- a/data/js/polls.js
+++ /dev/null
@@ -1,21 +0,0 @@
-// Discussion board polls
-if(byId('jt_box_postedit') && byId('poll')) {
- var c = byId('poll');
- var parentNode = function(n, tag) {
- while(n && n.nodeName.toLowerCase() != tag)
- n = n.parentNode;
- return n;
- };
- var show = function(v) {
- setClass(parentNode(byId('poll_question'), 'tr'), 'hidden', !v);
- setClass(parentNode(byId('poll_options'), 'tr'), 'hidden', !v);
- setClass(parentNode(byId('poll_max_options'), 'tr'), 'hidden', !v);
- setClass(parentNode(byId('poll_preview'), 'tr'), 'hidden', !v);
- setClass(parentNode(byId('poll_recast'), 'tr'), 'hidden', !v);
- };
- c.onclick = function() {
- show(this.checked);
- return true;
- };
- show(c.checked);
-}
diff --git a/data/js/prodrel.js b/data/js/prodrel.js
deleted file mode 100644
index ec6082e3..00000000
--- a/data/js/prodrel.js
+++ /dev/null
@@ -1,108 +0,0 @@
-function prrLoad() {
- // read the current relations
- var rels = byId('prodrelations').value.split('|||');
- for(var i=0; i<rels.length && rels[0].length>1; i++) {
- var rel = rels[i].split(',', 3);
- prrAdd(rel[0], rel[1], rel[2]);
- }
- prrEmpty();
-
- // bind the add-link
- byName(byClass(byId('relation_new'), 'td', 'tc_add')[0], 'a')[0].onclick = prrFormAdd;
-
- // dropdown
- dsInit(byName(byClass(byId('relation_new'), 'td', 'tc_prod')[0], 'input')[0], '/xml/producers.xml?q=', function(item, tr) {
- tr.appendChild(tag('td', { style: 'text-align: right; padding-right: 5px'}, 'p'+item.getAttribute('id')));
- tr.appendChild(tag('td', shorten(item.firstChild.nodeValue, 40)));
- }, function(item) {
- return 'p'+item.getAttribute('id')+':'+item.firstChild.nodeValue;
- }, prrFormAdd);
-}
-
-function prrAdd(rel, pid, title) {
- var sel = tag('select', {onchange: prrSerialize});
- var ops = byName(byClass(byId('relation_new'), 'td', 'tc_rel')[0], 'select')[0].options;
- for(var i=0; i<ops.length; i++)
- sel.appendChild(tag('option', {value: ops[i].value, selected: ops[i].value==rel}, getText(ops[i])));
-
- byId('relation_tbl').appendChild(tag('tr', {id:'relation_tr_'+pid},
- tag('td', {'class':'tc_prod' }, 'p'+pid+':', tag('a', {href:'/p'+pid}, shorten(title, 40))),
- tag('td', {'class':'tc_rel' }, sel),
- tag('td', {'class':'tc_add' }, tag('a', {href:'#', onclick:prrDel}, 'remove'))
- ));
-
- prrEmpty();
-}
-
-function prrEmpty() {
- var tbl = byId('relation_tbl');
- if(byName(tbl, 'tr').length < 1)
- tbl.appendChild(tag('tr', {id:'relation_tr_none'}, tag('td', {colspan:4}, 'Nothing selected.')));
- else if(byId('relation_tr_none'))
- tbl.removeChild(byId('relation_tr_none'));
-}
-
-function prrSerialize() {
- var r = [];
- var trs = byName(byId('relation_tbl'), 'tr');
- for(var i=0; i<trs.length; i++) {
- if(trs[i].id == 'relation_tr_none')
- continue;
- var rel = byName(byClass(trs[i], 'td', 'tc_rel')[0], 'select')[0];
- r[r.length] = [
- rel.options[rel.selectedIndex].value,
- trs[i].id.substr(12),
- getText(byName(byClass(trs[i], 'td', 'tc_prod')[0], 'a')[0])
- ].join(',');
- }
- byId('prodrelations').value = r.join('|||');
-}
-
-function prrDel() {
- var tr = this;
- while(tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- byId('relation_tbl').removeChild(tr);
- prrSerialize();
- prrEmpty();
- return false;
-}
-
-function prrFormAdd() {
- var relnew = byId('relation_new');
- var txt = byName(byClass(relnew, 'td', 'tc_prod')[0], 'input')[0];
- var sel = byName(byClass(relnew, 'td', 'tc_rel')[0], 'select')[0];
- var lnk = byName(byClass(relnew, 'td', 'tc_add')[0], 'a')[0];
- var input = txt.value;
-
- if(!input.match(/^p[0-9]+/)) {
- alert('Producer textbox should start with an ID (e.g. "p7:")');
- return false;
- }
-
- txt.disabled = sel.disabled = true;
- txt.value = 'Loading...';
- setText(lnk, 'Loading...');
-
- ajax('/xml/producers.xml?q='+encodeURIComponent(input), function(hr) {
- txt.disabled = sel.disabled = false;
- txt.value = '';
- setText(lnk, 'add');
-
- var items = hr.responseXML.getElementsByTagName('item');
- if(items.length < 1)
- return alert('Producer not found');
-
- var id = items[0].getAttribute('id');
- if(byId('relation_tr_'+id))
- return alert('Producer already selected!');
-
- prrAdd(sel.options[sel.selectedIndex].value, id, items[0].firstChild.nodeValue);
- sel.selectedIndex = 0;
- prrSerialize();
- });
- return false;
-}
-
-if(byId('prodrelations'))
- prrLoad();
diff --git a/data/js/relmedia.js b/data/js/relmedia.js
deleted file mode 100644
index 4068d499..00000000
--- a/data/js/relmedia.js
+++ /dev/null
@@ -1,68 +0,0 @@
-function medLoad() {
- // load the selected media
- var med = byId('media').value.split(',');
- for(var i=0; i<med.length && med[i].length > 1; i++)
- medAdd(med[i].split(' ')[0], Math.floor(med[i].split(' ')[1]));
-
- medAdd('', 0);
-}
-
-function medAdd(med, qty) {
- var qsel = tag('select', {'class':'qty', onchange:medSerialize}, tag('option', {value:0}, '-quantity-'));
- for(var i=1; i<=20; i++)
- qsel.appendChild(tag('option', {value:i, selected: qty==i}, i));
-
- var msel = tag('select', {'class':'medium', onchange: med == '' ? medFormAdd : medSerialize});
- if(med == '')
- msel.appendChild(tag('option', {value:''}, '-medium-'));
- for(var i=0; i<VARS.media.length; i++)
- msel.appendChild(tag('option', {value:VARS.media[i][0], selected: med==VARS.media[i][0]}, VARS.media[i][1]));
-
- byId('media_div').appendChild(tag('span', qsel, msel,
- med != '' ? tag('input', {type: 'button', 'class':'submit', onclick:medDel, value:'remove'}) : null
- ));
-}
-
-function medDel() {
- var span = this;
- while(span.nodeName.toLowerCase() != 'span')
- span = span.parentNode;
- byId('media_div').removeChild(span);
- medSerialize();
- return false;
-}
-
-function medFormAdd() {
- var span = this;
- while(span.nodeName.toLowerCase() != 'span')
- span = span.parentNode;
- var med = byClass(span, 'select', 'medium')[0];
- var qty = byClass(span, 'select', 'qty')[0];
- if(!med.selectedIndex)
- return;
- medAdd(med.options[med.selectedIndex].value, qty.options[qty.selectedIndex].value);
- byId('media_div').removeChild(span);
- medAdd('', 0);
- medSerialize();
-}
-
-function medSerialize() {
- var r = [];
- var meds = byName(byId('media_div'), 'span');
- for(var i=0; i<meds.length-1; i++) {
- var med = byClass(meds[i], 'select', 'medium')[0];
- var qty = byClass(meds[i], 'select', 'qty')[0];
-
- /* correct quantity if necessary */
- if(VARS.media[med.selectedIndex][2] && !qty.selectedIndex)
- qty.selectedIndex = 1;
- if(!VARS.media[med.selectedIndex][2] && qty.selectedIndex)
- qty.selectedIndex = 0;
-
- r[r.length] = VARS.media[med.selectedIndex][0] + ' ' + qty.selectedIndex;
- }
- byId('media').value = r.join(',');
-}
-
-if(byId('jt_box_rel_format'))
- medLoad();
diff --git a/data/js/relprod.js b/data/js/relprod.js
deleted file mode 100644
index 3292ca0f..00000000
--- a/data/js/relprod.js
+++ /dev/null
@@ -1,105 +0,0 @@
-function rprLoad() {
- var ps = byId('producers').value.split('|||');
- for(var i=0; i<ps.length && ps[i].length>1; i++) {
- var val = ps[i].split(',',3);
- rprAdd(val[0], val[1], val[2]);
- }
- rprEmpty();
-
- dsInit(byId('producer_input'), '/xml/producers.xml?q=',
- function(item, tr) {
- tr.appendChild(tag('td', {style:'text-align: right; padding-right: 5px'}, 'p'+item.getAttribute('id')));
- tr.appendChild(tag('td', shorten(item.firstChild.nodeValue, 40)));
- }, function(item) {
- return 'p'+item.getAttribute('id')+':'+item.firstChild.nodeValue;
- },
- rprFormAdd
- );
- byId('producer_add').onclick = rprFormAdd;
-}
-
-function rprAdd(id, role, name) {
- var roles = byId('producer_role').options;
- var rl = tag('select', {onchange:rprSerialize});
- for(var i=0; i<roles.length; i++)
- rl.appendChild(tag('option', {value: roles[i].value, selected:role==roles[i].value}, getText(roles[i])));
-
- byId('producer_tbl').appendChild(tag('tr', {id:'rpr_'+id, rpr_id:id},
- tag('td', {'class':'tc_name'}, 'p'+id+':', tag('a', {href:'/p'+id}, shorten(name, 40))),
- tag('td', {'class':'tc_role'}, rl),
- tag('td', {'class':'tc_rm'}, tag('a', {href:'#', onclick:rprDel}, 'remove'))
- ));
- rprEmpty();
-}
-
-function rprDel() {
- var tr = this;
- while(tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- tr.parentNode.removeChild(tr);
- rprEmpty();
- rprSerialize();
- return false;
-}
-
-function rprEmpty() {
- var tbl = byId('producer_tbl');
- if(byName(tbl, 'tr').length < 1)
- tbl.appendChild(tag('tr', {id:'rpr_tr_none'}, tag('td', {colspan:2}, 'Nothing selected.')));
- else if(byId('rpr_tr_none'))
- tbl.removeChild(byId('rpr_tr_none'));
-}
-
-function rprFormAdd() {
- var txt = byId('producer_input');
- var lnk = byId('producer_add');
- var val = txt.value;
-
- if(!val.match(/^p[0-9]+/)) {
- alert('Producer textbox must start with an ID (e.g. p17)');
- return false;
- }
-
- txt.disabled = true;
- txt.value = 'Loading...';
- setText(lnk, 'Loading...');
-
- ajax('/xml/producers.xml?q='+encodeURIComponent(val), function(hr) {
- txt.disabled = false;
- txt.value = '';
- setText(lnk, 'add');
-
- var items = hr.responseXML.getElementsByTagName('item');
- if(items.length < 1)
- return alert('Producer not found!');
-
- var id = items[0].getAttribute('id');
- if(byId('rpr_'+id))
- return alert('Producer already selected!');
-
- var role = byId('producer_role');
- role = role[role.selectedIndex].value;
-
- rprAdd(id, role, items[0].firstChild.nodeValue);
- rprSerialize();
- });
- return false;
-}
-
-function rprSerialize() {
- var r = [];
- var l = byName(byId('producer_tbl'), 'tr');
- for(var i=0; i<l.length; i++)
- if(l[i].rpr_id) {
- var role = byName(byClass(l[i], 'td', 'tc_role')[0], 'select')[0];
- r[r.length] = [
- l[i].rpr_id,
- role.options[role.selectedIndex].value,
- getText(byName(byClass(l[i], 'td', 'tc_name')[0], 'a')[0])
- ].join(',');
- }
- byId('producers').value = r.join('|||');
-}
-
-if(byId('jt_box_rel_prod'))
- rprLoad();
diff --git a/data/js/relvns.js b/data/js/relvns.js
deleted file mode 100644
index f27e530b..00000000
--- a/data/js/relvns.js
+++ /dev/null
@@ -1,88 +0,0 @@
-function rvnLoad() {
- var vns = byId('vn').value.split('|||');
- for(var i=0; i<vns.length && vns[i].length>1; i++)
- rvnAdd(vns[i].split(',',2)[0], vns[i].split(',',2)[1]);
- rvnEmpty();
-
- dsInit(byId('vn_input'), '/xml/vn.xml?q=',
- function(item, tr) {
- tr.appendChild(tag('td', {style:'text-align: right; padding-right: 5px'}, 'v'+item.getAttribute('id')));
- tr.appendChild(tag('td', shorten(item.firstChild.nodeValue, 40)));
- }, function(item) {
- return 'v'+item.getAttribute('id')+':'+item.firstChild.nodeValue;
- },
- rvnFormAdd
- );
- byId('vn_add').onclick = rvnFormAdd;
-}
-
-function rvnAdd(id, title) {
- byId('vn_tbl').appendChild(tag('tr', {id:'rvn_'+id, rvn_id:id},
- tag('td', {'class':'tc_title'}, 'v'+id+':', tag('a', {href:'/v'+id}, shorten(title, 40))),
- tag('td', {'class':'tc_rm'}, tag('a', {href:'#', onclick:rvnDel}, 'remove'))
- ));
- rvnEmpty();
-}
-
-function rvnDel() {
- var tr = this;
- while(tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- tr.parentNode.removeChild(tr);
- rvnEmpty();
- rvnSerialize();
- return false;
-}
-
-function rvnEmpty() {
- var tbl = byId('vn_tbl');
- if(byName(tbl, 'tr').length < 1)
- tbl.appendChild(tag('tr', {id:'rvn_tr_none'}, tag('td', {colspan:2}, 'Nothing selected.')));
- else if(byId('rvn_tr_none'))
- tbl.removeChild(byId('rvn_tr_none'));
-}
-
-function rvnFormAdd() {
- var txt = byId('vn_input');
- var lnk = byId('vn_add');
- var val = txt.value;
-
- if(!val.match(/^v[0-9]+/)) {
- alert('Visual novel textbox must start with an ID (e.g. v17)');
- return false;
- }
-
- txt.disabled = true;
- txt.value = 'Loading...';
- setText(lnk, 'Loading...');
-
- ajax('/xml/vn.xml?q='+encodeURIComponent(val), function(hr) {
- txt.disabled = false;
- txt.value = '';
- setText(lnk, 'add');
-
- var items = hr.responseXML.getElementsByTagName('item');
- if(items.length < 1)
- return alert('Visual novel not found!');
-
- var id = items[0].getAttribute('id');
- if(byId('rvn_'+id))
- return alert('VN already selected!');
-
- rvnAdd(id, items[0].firstChild.nodeValue);
- rvnSerialize();
- });
- return false;
-}
-
-function rvnSerialize() {
- var r = [];
- var l = byName(byId('vn_tbl'), 'tr');
- for(var i=0; i<l.length; i++)
- if(l[i].rvn_id)
- r[r.length] = l[i].rvn_id + ',' + getText(byName(byClass(l[i], 'td', 'tc_title')[0], 'a')[0]);
- byId('vn').value = r.join('|||');
-}
-
-if(byId('jt_box_rel_vn'))
- rvnLoad();
diff --git a/data/js/tabs.js b/data/js/tabs.js
deleted file mode 100644
index 470bd077..00000000
--- a/data/js/tabs.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/* Javascript tabs. General usage:
- *
- * <ul id="jt_select">
- * <li><a href="#<name>" id="jt_sel_<name>">..</a></li>
- * ..
- * </ul>
- *
- * Can then be used to show/hide the following box:
- *
- * <div id="jt_box_<name>"> .. </div>
- *
- * The name of the active box will be set to and (at page load) read from
- * location.hash. The parent node of the active link will get the 'tabselected'
- * class. A link with the special name "all" will display all boxes associated
- * with jt_select links.
- *
- * Only one jt_select list-of-tabs can be used on a single page.
- */
-var links = byId('jt_select') ? byName(byId('jt_select'), 'a') : [];
-
-function init() {
- var sel;
- var first;
- for(var i=0; i<links.length; i++) {
- links[i].onclick = function() { set(this.id); return false };
- if(!first)
- first = links[i].id;
- if(location.hash && links[i].id == 'jt_sel_'+location.hash.substr(1))
- sel = links[i].id;
- }
- if(first)
- set(sel||first, 1);
-}
-
-function set(which, nolink) {
- which = which.substr(7);
-
- for(var i=0; i<links.length; i++) {
- var name = links[i].id.substr(7);
- if(name != 'all')
- setClass(byId('jt_box_'+name), 'hidden', which != 'all' && which != name);
- setClass(links[i].parentNode, 'tabselected', name == which);
- }
-
- if(!nolink)
- location.href = '#'+which;
-}
-
-init();
diff --git a/data/js/vncast.js b/data/js/vncast.js
deleted file mode 100644
index 20d7fb39..00000000
--- a/data/js/vncast.js
+++ /dev/null
@@ -1,112 +0,0 @@
-function vncLoad() {
- var cast = jsonParse(byId('seiyuu').value) || [];
- var copt = byId('cast_chars').options;
- var chars = {};
- for(var i = 0; i < copt.length; i++) {
- if(copt[i].value)
- chars[copt[i].value] = copt[i].text;
- }
- cast.sort(function(a, b) {
- if(chars[a.cid] < chars[b.cid]) return -1;
- if(chars[a.cid] > chars[b.cid]) return 1;
- return 0;
- });
- for(var i = 0; i < cast.length; i++) {
- var aid = cast[i].aid;
- if(vnsStaffData[aid]) // vnsStaffData is filled by vnsLoad()
- vncAdd(vnsStaffData[aid], cast[i].cid, cast[i].note);
- }
- vncEmpty();
-
- onSubmit(byName(byId('maincontent'), 'form')[0], vncSerialize);
-
- // dropdown search
- dsInit(byId('cast_input'), '/xml/staff.xml?q=', function(item, tr) {
- tr.appendChild(tag('td', { style: 'text-align: right; padding-right: 5px'}, 's'+item.getAttribute('sid')));
- tr.appendChild(tag('td', item.firstChild.nodeValue));
- tr.appendChild(tag('td', item.getAttribute('orig')));
- }, vncFormAdd);
-}
-
-function vncAdd(seiyuu, chr, note) {
- var tbl = byId('cast_tbl');
-
- var csel = byId('cast_chars').cloneNode(true);
- csel.removeAttribute('id');
- csel.value = chr;
-
- tbl.appendChild(tag('tr', {id:'vnc_a'+seiyuu.aid},
- tag('td', {'class':'tc_char'}, csel),
- tag('td', {'class':'tc_name'},
- tag('input', {type:'hidden', value:seiyuu.aid}),
- tag('a', {href:'/s'+seiyuu.id}, seiyuu.name)),
- tag('td', {'class':'tc_note'}, tag('input', {type:'text', 'class':'text', value:note})),
- tag('td', {'class':'tc_del'}, tag('a', {href:'#', onclick:vncDel}, 'remove'))
- ));
- vncEmpty();
- vncSerialize();
-}
-
-function vncFormAdd(item) {
- var chr = byId('cast_chars').value;
- if (chr) {
- var s = { id:item.getAttribute('sid'), aid:item.getAttribute('id'), name:item.firstChild.nodeValue };
- vncAdd(s, chr, '');
- } else
- alert('Select character first please.');
- return '';
-}
-
-function vncEmpty() {
- var x = byId('cast_loading');
- var tbody = byId('cast_tbl');
- var tbl = tbody.parentNode;
- var thead = byName(tbl, 'thead');
- if(x)
- tbody.removeChild(x);
- if(byName(tbody, 'tr').length < 1) {
- tbody.appendChild(tag('tr', {id:'cast_tr_none'},
- tag('td', {colspan:4}, 'None')));
- if (thead.length)
- tbl.removeChild(thead[0]);
- } else {
- if(byId('cast_tr_none'))
- tbody.removeChild(byId('cast_tr_none'));
- if (thead.length < 1) {
- thead = tag('thead', tag('tr',
- tag('td', {'class':'tc_char'}, 'Character'),
- tag('td', {'class':'tc_name'}, 'Seiyuu'),
- tag('td', {'class':'tc_note'}, 'Note'),
- tag('td', '')));
- tbl.insertBefore(thead, tbody);
- }
- }
-}
-
-function vncSerialize() {
- var l = byName(byId('cast_tbl'), 'tr');
- var c = [];
- for (var i = 0; i < l.length; i++) {
- if(l[i].id == 'cast_tr_none')
- continue;
- var aid = byName(byClass(l[i], 'tc_name')[0], 'input')[0];
- var role = byName(byClass(l[i], 'tc_char')[0], 'select')[0];
- var note = byName(byClass(l[i], 'tc_note')[0], 'input')[0];
- c.push({ aid:Number(aid.value), cid:Number(role.value), note:note.value });
- }
- byId('seiyuu').value = JSON.stringify(c);
- return true;
-}
-
-function vncDel() {
- var tr = this;
- while (tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- byId('cast_tbl').removeChild(tr);
- vncEmpty();
- vncSerialize();
- return false;
-}
-
-if(byId('jt_box_vn_cast'))
- vncLoad();
diff --git a/data/js/vnrel.js b/data/js/vnrel.js
deleted file mode 100644
index 2ccb91bb..00000000
--- a/data/js/vnrel.js
+++ /dev/null
@@ -1,124 +0,0 @@
-function vnrLoad() {
- // read the current relations
- var rels = byId('vnrelations').value.split('|||');
- for(var i=0; i<rels.length && rels[0].length>1; i++) {
- var rel = rels[i].split(',');
- // fix for titles containing commas
- rel[3] = rel.splice(3).join();
- vnrAdd(rel[0], rel[1], rel[2]==1?true:false, rel[3]);
- }
- vnrEmpty();
-
- // make sure the title is up-to-date
- byId('title').onchange = function() {
- var l = byClass(byId('jt_box_vn_rel'), 'td', 'tc_title');
- for(i=0; i<l.length; i++)
- setText(l[i], shorten(this.value, 40));
- };
-
- // bind the add-link
- byName(byClass(byId('relation_new'), 'td', 'tc_add')[0], 'a')[0].onclick = vnrFormAdd;
-
- // dropdown
- dsInit(byName(byClass(byId('relation_new'), 'td', 'tc_vn')[0], 'input')[0], '/xml/vn.xml?q=', function(item, tr) {
- tr.appendChild(tag('td', { style: 'text-align: right; padding-right: 5px'}, 'v'+item.getAttribute('id')));
- tr.appendChild(tag('td', shorten(item.firstChild.nodeValue, 40)));
- }, function(item) {
- return 'v'+item.getAttribute('id')+':'+item.firstChild.nodeValue;
- }, vnrFormAdd);
-}
-
-function vnrAdd(rel, vid, official, title) {
- var sel = tag('select', {onchange: vnrSerialize});
- var ops = byName(byClass(byId('relation_new'), 'td', 'tc_rel')[0], 'select')[0].options;
- for(var i=0; i<ops.length; i++)
- sel.appendChild(tag('option', {value: ops[i].value, selected: ops[i].value==rel}, getText(ops[i])));
-
- byId('relation_tbl').appendChild(tag('tr', {id:'relation_tr_'+vid},
- tag('td', {'class':'tc_vn' }, 'v'+vid+':', tag('a', {href:'/v'+vid}, shorten(title, 40))),
- tag('td', {'class':'tc_rel' },
- 'is an ',
- tag('input', {type: 'checkbox', onclick:vnrSerialize, id:'official_'+vid, checked:official}),
- tag('label', {'for':'official_'+vid}, 'official'),
- sel, ' of'),
- tag('td', {'class':'tc_title'}, shorten(byId('title').value, 40)),
- tag('td', {'class':'tc_add' }, tag('a', {href:'#', onclick:vnrDel}, 'remove'))
- ));
-
- vnrEmpty();
-}
-
-function vnrEmpty() {
- var tbl = byId('relation_tbl');
- if(byName(tbl, 'tr').length < 1)
- tbl.appendChild(tag('tr', {id:'relation_tr_none'}, tag('td', {colspan:4}, 'No relations selected.')));
- else if(byId('relation_tr_none'))
- tbl.removeChild(byId('relation_tr_none'));
-}
-
-function vnrSerialize() {
- var r = [];
- var trs = byName(byId('relation_tbl'), 'tr');
- for(var i=0; i<trs.length; i++) {
- if(trs[i].id == 'relation_tr_none')
- continue;
- var rel = byName(byClass(trs[i], 'td', 'tc_rel')[0], 'select')[0];
- r[r.length] = [
- rel.options[rel.selectedIndex].value, // relation
- trs[i].id.substr(12), // vid
- byName(byClass(trs[i], 'td', 'tc_rel')[0], 'input')[0].checked ? '1' : '0', // official
- getText(byName(byClass(trs[i], 'td', 'tc_vn')[0], 'a')[0]) // title
- ].join(',');
- }
- byId('vnrelations').value = r.join('|||');
-}
-
-function vnrDel() {
- var tr = this;
- while(tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- byId('relation_tbl').removeChild(tr);
- vnrSerialize();
- vnrEmpty();
- return false;
-}
-
-function vnrFormAdd() {
- var relnew = byId('relation_new');
- var txt = byName(byClass(relnew, 'td', 'tc_vn')[0], 'input')[0];
- var off = byName(byClass(relnew, 'td', 'tc_rel')[0], 'input')[0];
- var sel = byName(byClass(relnew, 'td', 'tc_rel')[0], 'select')[0];
- var lnk = byName(byClass(relnew, 'td', 'tc_add')[0], 'a')[0];
- var input = txt.value;
-
- if(!input.match(/^v[0-9]+/)) {
- alert('Visual novel textbox must start with an ID (e.g. v17)');
- return false;
- }
-
- txt.disabled = sel.disabled = off.disabled = true;
- txt.value = 'Loading...';
- setText(lnk, 'Loading...');
-
- ajax('/xml/vn.xml?q='+encodeURIComponent(input), function(hr) {
- txt.disabled = sel.disabled = off.disabled = false;
- txt.value = '';
- setText(lnk, 'add');
-
- var items = hr.responseXML.getElementsByTagName('item');
- if(items.length < 1)
- return alert('Visual novel not found!');
-
- var id = items[0].getAttribute('id');
- if(byId('relation_tr_'+id))
- return alert('This visual novel has already been selected!');
-
- vnrAdd(sel.options[sel.selectedIndex].value, id, off.checked, items[0].firstChild.nodeValue);
- sel.selectedIndex = 0;
- vnrSerialize();
- });
- return false;
-}
-
-if(byId('vnrelations'))
- vnrLoad();
diff --git a/data/js/vnreldropdown.js b/data/js/vnreldropdown.js
deleted file mode 100644
index 16a1dbcd..00000000
--- a/data/js/vnreldropdown.js
+++ /dev/null
@@ -1,36 +0,0 @@
-function dropdown(lnk) {
- var relid = lnk.id.substr(6);
- var st = getText(lnk);
- if(st == 'Loading...')
- return null;
-
- var o = tag('ul', null);
- for(var i=0; i<VARS.rlist_status.length; i++) {
- var val = VARS.rlist_status[i];
- o.appendChild(tag('li', st == val[1]
- ? tag('i', val[1])
- : tag('a', {href:'#', rl_rid:relid, rl_act:val[0], onclick:change}, val[1])));
- }
- if(st != '--')
- o.appendChild(tag('li', tag('a', {href:'#', rl_rid:relid, rl_act:-1, onclick:change}, 'Remove from list')));
-
- return tag('div', o);
-}
-
-function change() {
- var lnk = byId('rlsel_'+this.rl_rid);
- var code = getText(byId('vnrlist_code'));
- var act = this.rl_act;
- ddHide();
- setContent(lnk, tag('b', {'class': 'grayedout'}, 'Loading...'));
- ajax('/xml/rlist.xml?formcode='+code+';id='+this.rl_rid+';e='+act, function(hr) {
- setText(lnk, act == -1 ? '--' : VARS.rlist_status[act][1]);
- });
- return false;
-}
-
-if(byId('vnrlist_code')) {
- var l = byClass('a', 'vnrlsel');
- for(var i=0; i<l.length; i++)
- ddInit(l[i], 'left', dropdown);
-}
diff --git a/data/js/vnscr.js b/data/js/vnscr.js
deleted file mode 100644
index bf583e15..00000000
--- a/data/js/vnscr.js
+++ /dev/null
@@ -1,206 +0,0 @@
-var rels;
-var defRid = 0;
-var staticUrl;
-
-function init() {
- var data = jsonParse(getText(byId('screendata'))) || {};
- rels = data.rel;
- rels.unshift([ 0, '-- select release --' ]);
- staticUrl = data.staticurl;
-
- var scr = jsonParse(byId('screenshots').value) || {};
- for(i=0; i<scr.length; i++) {
- var r = scr[i];
- var s = data.size[r.id];
- loaded(add(r.nsfw, r.rid), r.id, s[0], s[1]);
- }
-
- var frm = byId('screenshots');
- while(frm.nodeName.toLowerCase() != 'form')
- frm = frm.parentNode;
- onSubmit(frm, handleSubmit);
-
- addLast();
- ivInit();
-}
-
-function handleSubmit() {
- var loading = 0;
- var norelease = 0;
-
- var r = [];
- var l = byName(byId('scr_table'), 'tr');
- for(var i=0; i<l.length-1; i++)
- if(l[i].scr_loading)
- loading = 1;
- else if(l[i].scr_rid == 0)
- norelease = 1;
- else
- r.push({ rid: l[i].scr_rid, nsfw: l[i].scr_nsfw, id: l[i].scr_id });
-
- if(loading)
- alert('Please wait for the screenshots to be uploaded before submitting the form.');
- else if(norelease)
- alert('Please select the appropriate release for every screenshot.');
- else
- byId('screenshots').value = JSON.stringify(r);
- return !loading && !norelease;
-}
-
-function genRels(sel) {
- var r = tag('select', {'class':'scr_relsel'});
- for(var i=0; i<rels.length; i++)
- r.appendChild(tag('option', {value: rels[i][0], selected: rels[i][0] == sel}, rels[i][1]));
- return r;
-}
-
-function URL(id, t) {
- return staticUrl+'/s'+t+'/'+(id%100<10?'0':'')+(id%100)+'/'+id+'.jpg';
-}
-
-// Need to run addLast() after this function
-function add(nsfw, rid) {
- var tr = tag('tr', { scr_id: 0, scr_loading: 1, scr_rid: rid, scr_nsfw: nsfw?1:0},
- tag('td', { 'class': 'thumb'}, 'Loading...'),
- tag('td',
- tag('b', 'Uploading screenshot'),
- tag('br', null),
- 'This can take a while, depending on the file size and your upload speed.',
- tag('br', null),
- tag('a', {href:'#', onclick:del}, 'cancel')
- )
- );
- byId('scr_table').appendChild(tr);
- return tr;
-}
-
-function oddDim(dim) {
- if(dim == '256x384') // special-case NDS resolution (not in the DB)
- return false;
- for(var j=0; j<VARS.resolutions.length; j++) {
- if(typeof VARS.resolutions[j][1] != 'object') {
- if(VARS.resolutions[j][0] == dim)
- return false;
- } else {
- for(var k=1; k<VARS.resolutions[j].length; k++)
- if(VARS.resolutions[j][k][1] == dim)
- return false;;
- }
- }
- return true;
-}
-
-// Need to run ivInit() after this function
-function loaded(tr, id, width, height) {
- var dim = width+'x'+height;
- tr.id = 'scr_tr_'+id;
- tr.scr_id = id;
- tr.scr_loading = 0;
-
- setContent(byName(tr, 'td')[0],
- tag('a', {href: URL(tr.scr_id, 'f'), 'data-iv':dim+':edit'},
- tag('img', {src: URL(tr.scr_id, 't')})
- )
- );
-
- var rel = genRels(tr.scr_rid);
- rel.onchange = function() { tr.scr_rid = this.options[this.selectedIndex].value };
-
- var nsfwid = 'scr_nsfw_'+id;
- setContent(byName(tr, 'td')[1],
- tag('b', 'Screenshot #'+id),
- ' (', tag('a', {href: '#', onclick:del}, 'remove'), ')',
- tag('br', null),
- 'Full size: '+dim,
- !oddDim(dim) ? null : tag('b', {'class':'standout', 'style':'font-weight: bold'},
- ' WARNING: Odd resolution! Please check whether the image has been cropped correctly.'),
- tag('br', null),
- tag('br', null),
- tag('input', {type:'checkbox', name:nsfwid, id:nsfwid, checked: tr.scr_nsfw!=0, onclick: function() { tr.scr_nsfw = this.checked?1:0 }, 'class':'scr_nsfw'}),
- tag('label', {'for':nsfwid}, 'This screenshot is NSFW'),
- tag('br', null),
- rel
- );
-}
-
-function addLast() {
- if(byId('scr_last'))
- byId('scr_table').removeChild(byId('scr_last'));
- var full = byName(byId('scr_table'), 'tr').length >= 10;
-
- var rel = genRels(defRid);
- rel.onchange = function() { defRid = this.options[this.selectedIndex].value };
- rel.id = 'scradd_relsel';
-
- byId('scr_table').appendChild(tag('tr', {id:'scr_last'},
- tag('td', {'class': 'thumb'}),
- full ? tag('td',
- tag('b', 'Enough screenshots'),
- tag('br', null),
- 'The limit of 10 screenshots per visual novel has been reached.\nIf you want to add a new screenshot, please remove an existing one first.'
- ) : tag('td',
- tag('b', 'Add screenshot'),
- tag('br', null),
- 'Image must be smaller than 5MB and in PNG or JPEG format.',
- tag('br', null),
- rel,
- tag('br', null),
- tag('input', {name:'scr_upload', id:'scr_upload', type:'file', 'class':'text', multiple:true}),
- tag('br', null),
- tag('input', {type:'button', value:'Upload!', 'class':'submit', onclick:upload})
- )
- ));
-}
-
-function del(what) {
- var tr = what && what.scr_id != null ? what : this;
- while(tr.scr_id == null)
- tr = tr.parentNode;
- if(tr.scr_ajax)
- tr.scr_ajax.abort();
- byId('scr_table').removeChild(tr);
- addLast();
- ivInit();
- return false;
-}
-
-function uploadFile(f) {
- var tr = add(0, defRid);
- var fname = f.name;
- var frm = new FormData();
- frm.append('file', f);
- tr.scr_ajax = ajax('/xml/screenshots.xml', function(hr) {
- tr.scr_ajax = null;
- var img = hr.responseXML.getElementsByTagName('image')[0];
- var id = img.getAttribute('id');
- if(id < 0) {
- alert(fname + ":\n" + (
- id == -1 ? 'Upload failed!\nOnly JPEG or PNG images are accepted.'
- : 'Upload failed!\nNo file selected, or an empty file?'));
- del(tr);
- } else {
- loaded(tr, id, img.getAttribute('width'), img.getAttribute('height'));
- ivInit();
- }
- }, true, frm);
-}
-
-function upload() {
- var files = byId('scr_upload').files;
-
- if(files.length < 1) {
- alert('Upload failed!\nNo file selected, or an empty file?');
- return false;
- } else if(files.length + byName(byId('scr_table'), 'tr').length - 1 > 10) {
- alert('Too many files selected. The total number of screenshots may not exceed 10.');
- return false;
- }
-
- for(var i=0; i<files.length; i++)
- uploadFile(files[i]);
- addLast();
- return false;
-}
-
-if(byId('jt_box_vn_scr') && byId('screenshots'))
- init();
diff --git a/data/js/vnstaff.js b/data/js/vnstaff.js
deleted file mode 100644
index 62e262e9..00000000
--- a/data/js/vnstaff.js
+++ /dev/null
@@ -1,123 +0,0 @@
-// vnsStaffData maps alias id to staff data { NNN: { id: ..., aid: NNN, name: ...} }
-// used to fill form fields instead of ajax queries in vnsLoad() and vncLoad()
-// Also used by vncast.js
-window.vnsStaffData = {};
-
-function vnsLoad() {
- window.vnsStaffData = jsonParse(getText(byId('staffdata'))) || {};
- var credits = jsonParse(byId('credits').value) || [];
- for(var i = 0; i < credits.length; i++) {
- var aid = credits[i].aid;
- if(window.vnsStaffData[aid])
- vnsAdd(window.vnsStaffData[aid], credits[i].role, credits[i].note);
- }
- vnsEmpty();
-
- onSubmit(byName(byId('maincontent'), 'form')[0], vnsCheckAndSerialize);
-
- // dropdown search
- dsInit(byId('credit_input'), '/xml/staff.xml?q=', function(item, tr) {
- tr.appendChild(tag('td', { style: 'text-align: right; padding-right: 5px'}, 's'+item.getAttribute('sid')));
- tr.appendChild(tag('td', item.firstChild.nodeValue));
- tr.appendChild(tag('td', item.getAttribute('orig')));
- }, vnsFormAdd);
-}
-
-function vnsAdd(staff, role, note) {
- var tbl = byId('credits_tbl');
-
- var rlist = tag('select', {onchange:vnsSerialize});
- var r = VARS.credit_type;
- for (var i = 0; i<r.length; i++)
- rlist.appendChild(tag('option', {value:r[i][0], selected:r[i][0]==role}, r[i][1]));
-
- tbl.appendChild(tag('tr', {id:'vns_a'+staff.aid},
- tag('td', {'class':'tc_name'},
- tag('input', {type:'hidden', value:staff.aid}),
- tag('a', {href:'/s'+staff.id}, staff.name)),
- tag('td', {'class':'tc_role'}, rlist),
- tag('td', {'class':'tc_note'}, tag('input', {type:'text', 'class':'text', value:note})),
- tag('td', {'class':'tc_del'}, tag('a', {href:'#', onclick:vnsDel}, 'remove'))
- ));
- vnsEmpty();
- vnsSerialize();
-}
-
-function vnsEmpty() {
- var x = byId('credits_loading');
- var tbody = byId('credits_tbl');
- var tbl = tbody.parentNode;
- var thead = byName(tbl, 'thead');
- if(x)
- tbody.removeChild(x);
- if(byName(tbody, 'tr').length < 1) {
- tbody.appendChild(tag('tr', {id:'credits_tr_none'},
- tag('td', {colspan:4}, 'None')));
- if (thead.length)
- tbl.removeChild(thead[0]);
- } else {
- if(byId('credits_tr_none'))
- tbody.removeChild(byId('credits_tr_none'));
- if (thead.length < 1) {
- thead = tag('thead', tag('tr',
- tag('td', {'class':'tc_name'}, 'Staff'),
- tag('td', {'class':'tc_role'}, 'Credit'),
- tag('td', {'class':'tc_note'}, 'Note'),
- tag('td', '')));
- tbl.insertBefore(thead, tbody);
- }
- }
-}
-
-function vnsSerialize() {
- var l = byName(byId('credits_tbl'), 'tr');
- var c = [];
- for (var i = 0; i < l.length; i++) {
- if(l[i].id == 'credits_tr_none')
- continue;
- var aid = byName(byClass(l[i], 'tc_name')[0], 'input')[0];
- var role = byName(byClass(l[i], 'tc_role')[0], 'select')[0];
- var note = byName(byClass(l[i], 'tc_note')[0], 'input')[0];
- c.push({ aid:Number(aid.value), role:role.value, note:note.value });
- }
- byId('credits').value = JSON.stringify(c);
- return true;
-}
-
-function vnsCheckAndSerialize() {
- var l = byName(byId('credits_tbl'), 'tr');
- var tbl = {};
- for (var i = 0; i < l.length; i++) {
- if(l[i].id == 'credits_tr_none')
- continue;
- var aid = byName(byClass(l[i], 'tc_name')[0], 'input')[0];
- var name = byName(byClass(l[i], 'tc_name')[0], 'a')[0];
- var role = byName(byClass(l[i], 'tc_role')[0], 'select')[0];
- var idx = aid.value + ' ' + role.value;
- if(tbl[idx]) {
- alert("Invalid input in staff listing: '" + name.innerText + "' is credited multiple times with '" + role.options[role.selectedIndex].value + "'.");
- return false;
- }
- tbl[idx] = 1;
- }
- return vnsSerialize();
-}
-
-function vnsDel() {
- var tr = this;
- while (tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- byId('credits_tbl').removeChild(tr);
- vnsEmpty();
- vnsSerialize();
- return false;
-}
-
-function vnsFormAdd(item) {
- var s = { id:item.getAttribute('sid'), aid:item.getAttribute('id'), name:item.firstChild.nodeValue };
- vnsAdd(s, 'staff', '');
- return '';
-}
-
-if(byId('jt_box_vn_staff'))
- vnsLoad();
diff --git a/data/js/vntagmod.js b/data/js/vntagmod.js
deleted file mode 100644
index 69c579fd..00000000
--- a/data/js/vntagmod.js
+++ /dev/null
@@ -1,163 +0,0 @@
-var tglSpoilers = [];
-
-function tglLoad() {
- for(var i=0; i<=3; i++)
- tglSpoilers[i] = fmtspoil(i-1);
-
- // tag dropdown search
- dsInit(byId('tagmod_tag'), '/xml/tags.xml?q=', function(item, tr) {
- tr.appendChild(tag('td',
- shorten(item.firstChild.nodeValue, 40),
- item.getAttribute('applicable') == 'no' ? tag('b', {'class':'grayedout'}, ' not applicable') :
- item.getAttribute('state') == 0 ? tag('b', {'class':'grayedout'}, ' awaiting moderation') : null
- ));
- }, function(item) {
- return item.firstChild.nodeValue;
- }, tglAdd);
- byId('tagmod_add').onclick = tglAdd;
-
- // JS'ify the voting bar and spoiler setting
- var trs = byName(byId('tagtable'), 'tr');
- for(var i=0; i<trs.length; i++) {
- if(hasClass(trs[i], 'tagmod_cat'))
- continue;
- var vote = byClass(trs[i], 'td', 'tc_myvote')[0];
- vote.tgl_vote = getText(vote)*1;
- tglVoteBar(vote);
-
- var spoil = byClass(trs[i], 'td', 'tc_myspoil')[0];
- spoil.tgl_spoil = getText(spoil)*1+1;
- setText(spoil, tglSpoilers[spoil.tgl_spoil]);
- ddInit(spoil, 'tagmod', tglSpoilDD);
- spoil.onclick = tglSpoilNext;
- }
- tglSerialize();
-}
-
-function tglSpoilNext() {
- if(++this.tgl_spoil >= tglSpoilers.length)
- this.tgl_spoil = 0;
- setText(this, tglSpoilers[this.tgl_spoil]);
- tglSerialize();
- ddRefresh();
-}
-
-function tglSpoilDD(lnk) {
- var lst = tag('ul', null);
- for(var i=0; i<tglSpoilers.length; i++)
- lst.appendChild(tag('li', i == lnk.tgl_spoil
- ? tag('i', tglSpoilers[i])
- : tag('a', {href: '#', onclick:tglSpoilSet, tgl_td:lnk, tgl_sp:i}, tglSpoilers[i])
- ));
- return lst;
-}
-
-function tglSpoilSet() {
- this.tgl_td.tgl_spoil = this.tgl_sp;
- setText(this.tgl_td, tglSpoilers[this.tgl_sp]);
- ddHide();
- tglSerialize();
- return false;
-}
-
-function tglVoteBar(td, vote) {
- setText(td, '');
- for(var i=-3; i<=3; i++)
- td.appendChild(tag('a', {
- 'class':'taglvl taglvl'+i, tgl_num: i,
- onmouseover:tglVoteBarSel, onmouseout:tglVoteBarSel, onclick:tglVoteBarSel
- }, ' '));
- tglVoteBarSel(td, td.tgl_vote);
- return false;
-}
-
-function tglVoteBarSel(td, vote) {
- // nasty trick to make this function multifunctional
- if(this && this.tgl_num != null) {
- var e = td || window.event;
- td = this.parentNode;
- vote = this.tgl_num;
- if(e.type.toLowerCase() == 'click') {
- td.tgl_vote = vote;
- tglSerialize();
- }
- if(e.type.toLowerCase() == 'mouseout')
- vote = td.tgl_vote;
- }
- var l = byName(td, 'a');
- var num;
- for(var i=0; i<l.length; i++) {
- num = l[i].tgl_num;
- if(num == 0)
- setText(l[i], vote || '-');
- else
- setClass(l[i], 'taglvlsel', num<0&&vote<=num || num>0&&vote>=num);
- }
-}
-
-function tglAdd() {
- var tg = byId('tagmod_tag');
- var add = byId('tagmod_add');
- tg.disabled = add.disabled = true;
- add.value = 'Loading...';
-
- ajax('/xml/tags.xml?q=='+encodeURIComponent(tg.value), function(hr) {
- tg.disabled = add.disabled = false;
- tg.value = '';
- add.value = 'Add tag';
-
- var items = hr.responseXML.getElementsByTagName('item');
- if(items.length < 1)
- return alert('Item not found!');
- if(items[0].getAttribute('applicable') == 'no')
- return alert('This tag may not be applied to visual novels.');
-
- var name = items[0].firstChild.nodeValue;
- var id = items[0].getAttribute('id');
- if(byId('tgl_'+id))
- return alert('Tag is already present!');
-
- if(!byId('tagmod_newtags'))
- byId('tagtable').appendChild(tag('tr', {'class':'tagmod_cat', id:'tagmod_newtags'},
- tag('td', {colspan:7}, 'Newly added')));
-
- var vote = tag('td', {'class':'tc_myvote', tgl_vote: 2}, '');
- tglVoteBar(vote);
- var spoil = tag('td', {'class':'tc_myspoil', tgl_spoil: 0}, tglSpoilers[0]);
- ddInit(spoil, 'tagmod', tglSpoilDD);
- spoil.onclick = tglSpoilNext;
-
- var ismod = byClass(byId('tagtable').parentNode, 'td', 'tc_myover').length;
-
- byId('tagtable').appendChild(tag('tr', {id:'tgl_'+id},
- tag('td', {'class':'tc_tagname'}, tag('a', {href:'/g'+id}, name)),
- vote,
- ismod ? tag('td', {'class':'tc_myover'}, ' ') : null,
- spoil,
- tag('td', {'class':'tc_allvote'}, ' '),
- tag('td', {'class':'tc_allspoil'}, ' '),
- tag('td', {'class':'tc_allwho'}, '')
- ));
- tglSerialize();
- });
-}
-
-function tglSerialize() {
- var r = [];
- var l = byName(byId('tagtable'), 'tr');
- for(var i=0; i<l.length; i++) {
- if(hasClass(l[i], 'tagmod_cat'))
- continue;
- var vote = byClass(l[i], 'td', 'tc_myvote')[0].tgl_vote;
- if(vote != 0)
- r[r.length] = [
- l[i].id.substr(4),
- vote,
- byClass(l[i], 'td', 'tc_myspoil')[0].tgl_spoil-1
- ].join(',');
- }
- byId('taglinks').value = r.join(' ');
-}
-
-if(byId('taglinks'))
- tglLoad();
diff --git a/data/style.css b/data/style.css
deleted file mode 100644
index a5b201f7..00000000
--- a/data/style.css
+++ /dev/null
@@ -1,1121 +0,0 @@
-* { margin: 0; padding: 0; }
-body, td { font: 13px "Tahoma", "Arial", sans-serif; }
-body { $_bodybg$; color: $maintext$ }
-table { border-collapse: collapse; }
-table td,
-table th { vertical-align: top; padding: 3px; }
-img { border: none; }
-
-a,
-.fake_link { color: $link$; text-decoration: none; cursor:pointer; }
-
-a:hover,
-.fake_link:hover { border-bottom: 1px dotted $maintext$; }
-
-table tr.odd,
-table.stripe tbody tr:nth-child(odd):not(.nostripe),
-.docs table tbody tr:nth-child(odd) { background: $boxbg$; }
-
-#bgright { position: absolute; top: 0px; right: 0px; $_bgright$ }
-#header { position: absolute; top: 80px; left: 400px; }
-#header h1, #header h1 a {
- font-family: "Futura", "Century New Gothic", "Arial", Serif;
- font-size: 24px;
- font-style: italic;
- border: none!important;
- color: $maintitle$;
-}
-#footer { margin: 15px auto 0 auto; text-align: center; color: $footer$; }
-#footer a { color: $footer$; text-decoration: underline; }
-
-#debug {
- position: fixed;
- left: 0;
- bottom: 0;
- background-color: #600;
- border-right: 1px solid #c00;
- border-top: 1px solid #c00;
- width: 200px;
- height: 50px;
- text-align: center;
-}
-#debug h2 { color: #f00!important; font-size: 20px; }
-#debug, #debug a { color: #fff!important; }
-
-/* https://snook.ca/archives/html_and_css/hiding-content-for-accessibility */
-.visuallyhidden, .linkradio input {
- position: absolute !important;
- left: 0;
- height: 1px; width: 1px;
- border: 0; padding: 0;
- overflow: hidden;
- clip: rect(1px 1px 1px 1px);
-}
-
-/* Generic spoiler level hiding
- * .spoillvl-[012] -> parent tag indicating currently visible level
- * .spoil-[012] -> child tags indicating item spoiler level */
-.spoillvl-0 .spoil-1,
-.spoillvl-0 .spoil-2,
-.spoillvl-1 .spoil-2 { display: none }
-
-/* Warning/Notice Box */
-div.warning, div.notice { margin: 5px 10%; padding: 15px; background-color: $warnbg$; border: 1px solid $warnborder$; }
-div.notice { background-color: $noticebg$; border: 1px solid $noticeborder$; }
-div.warning ul, div.notice ul { margin-left: 0; }
-div.warning li, div.notice li { margin-left: 20px; }
-div.warning h2, div.notice h2 { font-size: 13px; font-weight: bold; margin: 0; }
-
-/* dropdown box */
-#dd_box { position: absolute; left: 0px; border: 1px solid $border$; background-color: $secbg$; z-index: 2 }
-#dd_box ul { list-style-type: none; margin: 0; padding: 0 }
-#dd_box li b { display: block; font-weight: normal; padding-left: 5px; }
-#dd_box li i { display: block; font-style: normal; padding-left: 10px; padding-right: 5px }
-#dd_box li a { display: block; padding-left: 10px; border: 0; padding: 3px 5px 3px 3px }
-#dd_box li a:hover { background: $boxbg$ }
-
-/* dropdown search */
-#ds_box {
- position: absolute;
- top: 0;
- border: 1px solid $border$;
- border-top: none;
- background-color: $secbg$;
- cursor: pointer;
- z-index: 2
-}
-#ds_box b { padding: 2px 0 0 10px; }
-#ds_box tr.selected { background: $boxbg$; }
-#ds_box table { width: 100%; }
-
-/* Elm dropdowns */
-.elm_dd > a { color: $maintext$; display: block; border: none; padding-right: 15px; position: relative }
-.elm_dd > a > span { position: absolute; right: 5px; top: 0; width: 16px; text-align: right; display: block }
-.elm_dd > a > span i { visibility: hidden; font-style: normal }
-.elm_dd > a:hover > span > i,
-.elm_dd > a:focus > span > 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.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 }
-.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_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 }
-
-
-
-/* general text formatting */
-
-ul, ol { margin-left: 35px; }
-p.locked { float: right; color: $standout$; font-style: italic; margin: 0!important; }
-b.grayedout { font-weight: normal; color: $grayedout$ }
-i.grayedout { font-style: normal; color: $grayedout$ }
-#maincontent h2 b { font: 13px "Tahoma", "Arial", sans-serif; font-weight: normal; }
-p.description { margin: 10px 100px!important; }
-b.done { font-weight: normal; color: $statok$ }
-b.todo { font-weight: normal; color: $statnok$ }
-b.neutral { font-weight: normal }
-p.center { text-align: center; }
-b.future,
-b.standout,
-a.standout { font-weight: normal; color: $standout$; }
-.clearfloat { clear: both; height: 0; }
-.hidden { display: none!important }
-.invisible { visibility: hidden }
-.linethrough { text-decoration: line-through }
-b.spoiler, b.spoiler a { color: #000!important; background-color: #000; font-weight: normal; }
-b.spoiler:hover, b.spoiler:focus { color: $maintext$!important; background-color: transparent }
-b.spoiler:hover a, b.spoiler:focus a { color: $link$!important; background-color: transparent }
-
-#maincontent div.quote {
- padding: 1px 5px;
- margin: 0px 10px;
- color: $grayedout$;
- border: none;
- border-left: 1px dotted $border$;
- text-align: left;
-}
-pre {
- padding:1px 5px;
- margin: 5px 15px;
- border: 1px dotted $border$;
- border-right: none;
- border-left: 1px solid $border$;
- background: $boxbg$;
- overflow-x: auto;
-}
-
-
-
-
-/***** general form markup *****/
-
-input.text, input.submit, select, textarea {
- background-color: $secbg$;
- color: $maintext$;
- border: 1px solid $secborder$;
- font: 14px "Tahoma", "Arial", sans-serif;
- padding: 0 1px 1px 1px;
- margin: 1px;
-}
-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.text, select { width: 200px; }
-fieldset.submit { width: 100%; text-align: center; margin: 5px; }
-fieldset.submit input[type=submit] { width: 150px; }
-fieldset.submit input[type=checkbox] { margin: 0 5px 0 15px; }
-fieldset.submit h2 { font-size: 13px!important; }
-td.label, td.label label { width: 130px; }
-td.label label { display: block; }
-td.field label { margin: 0 5px 0 5px; }
-table.formtable { margin: 0 20px 20px 20px; }
-table.formtable td { padding: 0; }
-table.formtable tr.newfield td { padding-top: 5px; }
-table.formtable tr.newpart td { padding-top: 20px; font-weight: bold; }
-
-/* Format checkboxes and radio buttons as if they were normal links with unicode icons.
- * Usage:
- *
- * <container class="linkradio">
- * <input type="checkbox|radio" id="xyz">
- * <label for="xyz">Text</label>
- * <em>(optional option separator)</em>
- * </container>
- */
-p.linkradio { padding: 2px }
-.linkradio label { color: $link$; cursor: pointer }
-.linkradio label:before { content: '✗' }
-.linkradio input:checked + label { color: $maintext$ }
-.linkradio input:checked + label:before { content: '✓' }
-.linkradio input:focus + label { outline: 1px dotted $link$ }
-.linkradio input:focus:checked + label { outline: 1px dotted $maintext$ }
-.linkradio em { font-weight: normal; font-style: normal; color: $grayedout$ }
-
-/* Same styling, but for regular links.
- * Usage:
- *
- * <a href="#" class="linkradio">Unchecked option</a>
- * <a href="#" class="linkradio checked">Checked option</a>
- */
-a.linkradio:before { content: '✗' }
-a.linkradio.checked { color: $maintext$ }
-a.linkradio.checked:before { content: '✓' }
-
-/* Spinner, <div class="spinner"></div> for a large one, <span> for a smaller inline-text version */
-.spinner { content: ''; box-sizing: border-box; border: 3px solid #9eaebd; border-bottom-color: transparent; border-radius: 100%; animation: spin 1s infinite linear; width: 16px; height: 16px; display: inline-block; margin: auto }
-span.spinner { width: 1em; height: 1em }
-@keyframes spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
-
-.textpreview > span { display: flex; justify-content: space-between; width: 100% }
-.textpreview > span > p { align-self: flex-end }
-.textpreview > span > p.right > * { margin-left: 10px; font-style: normal }
-.textpreview textarea { width: 100%; box-sizing: border-box }
-.textpreview .preview { width: 100%; box-sizing: border-box; border: 1px solid $secborder$; margin: 1px; padding: 5px; text-align: left }
-fieldset.submit .textpreview { margin: 0 auto }
-
-/* .compact input elements are smaller and can be embedded in tables/inline text
- * .stealth input elements pretend to be just regular text, but turn into visibile input elements on mouse-over */
-.compact input.text, .compact select { margin: -2px -1px; padding: 1px 0 }
-.stealth input, .stealth select { font: inherit; background: none; border: 1px solid transparent; -moz-appearance: none; -webkit-appearance: none; appearance: none }
-.stealth input:hover, .stealth input:focus,
-.stealth select:hover, .stealth select:focus { border: 1px solid $secborder$; background: $secbg$ }
-
-
-/***** menu *****/
-
-
-#menulist a { color: $maintext$; text-decoration: none; }
-#menulist a:hover { border-bottom: 1px dotted $maintext$; }
-#menulist { position: absolute; left: 30px; top: 190px; width: 160px; }
-#menulist div.menubox { margin: 0 0 10px 0; border: 1px solid $border$; background: $boxbg$; }
-#menulist div.menubox div { padding: 2px 7px; }
-#menulist h2 { border-bottom: 1px solid $border$; background: $boxbg$; padding: 1px 3px; }
-#menulist h2, #menulist h2 a { font-size: 13px; color: $maintext$; }
-#menulist h2 #lang_select { float: right; padding-top: 1px; }
-#menulist dt { display: block; float: left; width: 97px; font-style: italic; }
-#menulist dd { width: 45px; float: left; text-align: right; }
-#menulist p { text-align: center; }
-#menulist #search input.text { width: 141px; margin: 0 0 3px 7px }
-#menulist #search input.submit { display: none; }
-#dd_box abbr { margin: 2px 5px 2px 0!important; }
-#menulist .notifyget { display: inline-block; width: 135px; padding: 4px; background: $warnbg$; border: 1px solid $warnborder$; }
-#menulist .logout { border: 0; background: none; color: $maintext$; cursor: pointer }
-#menulist .logout:hover { text-decoration: underline }
-
-#support { line-height: 0; height: 41px }
-#support img { height: 19px; object-fit: none }
-#support a { opacity: 0.7; border-bottom: none!important; display: inline-block }
-#support a:hover { opacity: 1 }
-#support a:hover img { position: absolute; left: 0; top: 0; height: 38px; z-index: 99 }
-
-
-
-
-/***** main content *****/
-
-#maincontent {
- position: absolute;
- top: 169px;
- left: 200px;
- right: 30px;
- margin: 0;
- padding-bottom: 50px!important;
-}
-.mainbox h1, .mainbox h2 {
- font-family: "Futura", "Century New Gothic", "Arial", Serif;
- font-weight: normal;
- font-size: 14px;
-}
-div.mainbox, table.mainbox td {
- border: 1px solid $border$;
- margin: 21px 0 -10px 0;
- padding: 5px;
- background: $boxbg$;
-}
-div.mainbox-overflow-hack { overflow: hidden; width: 100%; box-sizing: border-box }
-.mainbox h1 { color: $boxtitle$; font-size: 24px; margin: -5px 0 15px 0; }
-.mainbox h2.alttitle { color: $alttitle$; margin: -17px 0 15px 15px; font-weight: normal; }
-.mainbox p { margin: 3px 20px; }
-.mainbox div p,
-.mainbox table p { margin: 0; }
-.mainbox h2 { font-weight: bold; font-size: 16px; margin: 10px 0 0 5px; }
-a.addnew, p.addnew { float: right; margin: 0 }
-
-.mainbox.threelayout { border-collapse: separate; border-spacing: 10px; margin: 10px -10px -20px -10px; min-width: 100%; }
-.mainbox.threelayout td { width: 32%; padding: 0 2px 10px 2px; }
-.mainbox.threelayout h1 { margin: 0; font-size: 18px; font-weight: bold; }
-.mainbox.threelayout h2 { font-size: 14px; margin-top: 3px; }
-.mainbox.threelayout a.right { float: right; }
-.mainbox.threelayout ul { list-style-type: none; margin-left: 10px; }
-.mainbox.threelayout h1 a { color: $boxtitle$; }
-
-
-
-/***** main tabs *****/
-
-div.maintabs { display: flex; justify-content: space-between; position: relative; width: 100%; height: 22px; margin: 20px 0 -22px 0; padding: 0 }
-#maincontent div:nth-child(1).maintabs { margin-top: 0 }
-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.browsetabs > ul li a { color: $maintext$ }
-div.maintabs.browsetabs > ul li { margin-left: 5px }
-div.maintabs.bottom { margin-top: 10px; /* WHY!? */ margin-bottom: -10px }
-div.maintabs.bottom > ul li a { padding: 4px 7px 2px 7px; border-bottom: 1px solid $border$; border-top: none }
-div.maintabs.bottom > ul li.tabselected a,
-div.maintabs.bottom > ul li a:hover { padding-top: 5px; height: 22px; margin-top: -1px }
-
-h1.boxtitle, h1.boxtitle a, div.maintabs h1 {
- font-family: "Futura", "Century New Gothic", "Arial", Serif;
- font-weight: bold;
- font-style: italic;
- color: $grayedout$;
- font-size: 17px;
-}
-h1.boxtitle, h1.boxtitle a { margin: 20px 0 -20px 0 }
-
-
-
-
-/***** Homepage ******/
-
-p.screenshots { text-align: center; margin-top: 10px; padding: 0; height: 105px; overflow: hidden }
-p.screenshots img { margin: 2px; }
-a.feed { float: right }
-
-
-
-
-/***** Browsing ******/
-
-.browseopts a {
- padding: 1px 3px;
- color: $maintext$;
- border: 1px solid $border$;
- margin: 0 2px;
- white-space: nowrap;
-}
-p.browseopts { text-align: center; padding: 2px; }
-span.browseopts { text-align: center; padding: 10px; display: inline-block }
-.browseopts a.optselected,
-.browseopts a:hover { border: 0; padding: 2px 4px; }
-div.mainbox.browse { padding: 0; }
-div.mainbox.browse table { width: 100%; }
-div.mainbox.browse table td.tc1 { padding-left: 25px; }
-table thead td, table thead th { font-weight: bold; background-color: $secbg$; }
-table thead th { text-align: left }
-fieldset.search { display: block; width: 100%; text-align: center; margin: 0 0 10px 0; }
-fieldset.search .submit { padding: 0 1px; }
-p#searchtabs { height: 13px; padding-right: 70px; }
-p#searchtabs a { padding: 2px 6px 2px 6px; margin: 0 2px; color: $maintext$; }
-p#searchtabs a:hover, p#searchtabs a.sel {
- border: 1px solid $secborder$;
- border-bottom: none;
- padding: 1px 5px 2px 5px;
- background: $boxbg$;
-}
-#q { width: 600px }
-#bq { width: 300px }
-
-
-
-/* history browser */
-
-div.history td { white-space: nowrap; padding-left: 15px }
-div.history td.tc1_1 { width: 70px; padding-right: 0; text-align: right }
-div.history td.tc1_2 { width: 30px; padding-left: 0 }
-div.history td.tc2 { width: 140px }
-div.history td.tc4 { width: 100% }
-div.history td.tc4 b { margin-left: 10px }
-
-
-
-
-/***** Discussions ******/
-
-/* threads page */
-#maincontent div.thread { padding: 0; }
-div.thread table { width: 100%; table-layout: fixed }
-div.thread tr:not(:last-child) td { border-bottom: 1px solid $border$; }
-div.thread td.tc1 { width: 170px; padding: 5px 10px; border-right: 1px solid $border$; }
-div.thread td.tc2 { padding: 10px 20px 10px 10px; }
-div.thread tr.deleted td { padding: 1px 10px; }
-div.thread tr:target { outline: 1px dotted $standout$ }
-div.thread i.deleted { font-style: normal; color: $grayedout$; }
-div.thread i.lastmod { float: right; font-size: 11px; color: $grayedout$; margin: 0 -10px -5px 0; }
-div.thread i.edit { float: right; color: $grayedout$; font-style: normal; margin: -10px -10px 0 0; }
-
-/* threads browser */
-div.mainbox.discussions td.tc4 { text-align: right; }
-div.mainbox.discussions a.locked { text-decoration: line-through; }
-div.mainbox.discussions b.boards { padding-left: 10px; font-weight: normal; }
-div.mainbox.discussions b.boards a { color: $grayedout$; }
-div.discussions td.tc2 { width: 60px; text-align: right }
-div.discussions td.tc3 { width: 110px; }
-div.discussions td.tc4 { width: 250px; }
-div.discussions .pollflag { color: $grayedout$; padding-right: 6px; }
-div.postsearch td.tc1_1 { width: 60px; padding-left: 0; padding-right: 0; text-align: right }
-div.postsearch td.tc1_2 { width: 25px; padding-left: 0 }
-div.postsearch td.tc2 { width: 65px; }
-div.postsearch td.tc3 { width: 90px; }
-
-
-
-
-/***** VN page *******/
-
-#nsfw_chk:checked ~ * { cursor: pointer; }
-#nsfw_chk:checked ~ * > #nsfw_show { display: none; }
-#nsfw_chk:not(:checked) ~ * > #nsfw_hid { display: none; }
-
-#nsfwhide_chk:checked ~ * #nsfwshown { display: none; }
-#nsfwhide_chk:not(:checked) ~ * .nsfw { display: none; }
-
-div.vndetails { margin: 0 auto; max-width: 820px; }
-div.vnimg { float: left; width: 250px; margin: 0 10px; }
-div.vnimg i { display: block; width: 100%; text-align: center; font-size: 11px; }
-div.vnimg p { text-align: center; padding: 0px; margin: 0; }
-div.vndetails h2 { margin: 5px 0 0 0; }
-.vndesc p { padding: 0 0 0 5px; }
-p#nsfw_hid { display: block; cursor: pointer; }
-div.vndetails table { float: left; width: 500px; }
-div.vndetails table td.key { width: 90px; }
-div.vndetails table dt { float: left; font-style: italic; }
-div.vndetails table dd { margin-left: 90px; }
-div.vndetails td.relations dt { float: none; font-style: normal; }
-div.vndetails td.relations dd { margin-left: 15px; }
-div.vndetails td.anime b { font-size: 10px; font-weight: normal; padding-right: 4px; }
-.ulistvn { padding: 5px 0 0 0 }
-.ulistvn > b { font-size: 14px }
-.ulistvn > span { float: right }
-
-div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $border$; padding: 3px 5% 0 5%; text-align: center; }
-#vntags span { white-space: nowrap; margin-left: 15px; }
-#vntags b { color: $grayedout$; font-weight: normal; font-size: 10px }
-#tagops { text-align: right; width: auto; }
-
-#tagops label { margin: 0 0 0 10px; border: 0; outline: none; color: $link$; cursor: pointer; }
-#tagops label.sec { border-left: 1px solid $border$; padding-left: 10px }
-#tagops label.lst { margin: 0 30px 0 10px; }
-#tagops input:checked + label { color: $maintext$; }
-
-/* tag filter machinery; the order of declarations is important */
-
-#tag_spoil_all:checked ~ #vntags .cut2 { display: none; }
-#tag_spoil_some:checked ~ #vntags .cut1 { display: none; }
-#tag_spoil_none:checked ~ #vntags .cut0 { display: none; }
-
-#tag_toggle_all:checked ~ #vntags .cut { display: inline }
-
-#cat_cont:not(:checked) ~ #vntags .cat_cont { display: none; }
-#cat_tech:not(:checked) ~ #vntags .cat_tech { display: none; }
-#cat_ero:not(:checked) ~ #vntags .cat_ero { display: none; }
-
-#tag_spoil_none:checked ~ #vntags .tagspl1 { display: none; }
-#tag_spoil_none:checked ~ #vntags .tagspl2 { display: none; }
-#tag_spoil_some:checked ~ #vntags .tagspl2 { display: none; }
-
-/* end of tag filter machinery */
-
-.releases table,
-#screenshots table { width: 100%; }
-.releases tr.lang td,
-#screenshots tr.rel td { background: $boxbg$; font-weight: bold; }
-.releases td.tc1 { padding-left: 30px; width: 80px; white-space: nowrap }
-.releases td.tc2 { text-align: center; width: 50px; white-space: nowrap }
-.releases td.tc3 { text-align: right; padding: 0; width: 90px; }
-.releases td.tc_icons { padding: 0 4px }
-.releases td.tc5 { width: 70px; }
-.releases td.tc5 a { color: $maintext$; border: 0; }
-.releases td.tc6 { text-align: right; width: 25px; padding: 0; white-space: nowrap }
-
-.rllinks_dd a { text-align: right }
-.rllinks_dd span { color: $maintext$; padding-right: 10px }
-
-#screenshots p.rel {
- background: $boxbg$;
- margin: 0;
- padding: 2px;
- font-weight: bold;
- text-align: center;
-}
-#screenshots a.scrlnk { margin: 2px; border: none }
-#screenshots div.scr { display: block; padding-left: 30px; text-align: center }
-#screenshots img { border: 3px solid transparent; }
-#screenshots a.nsfw img { border: 3px solid $statnok$; }
-#screenshots a:hover img { border: 3px solid $border$; }
-#screenshots #nsfwshown { font-style: normal }
-#screenshots p.nsfwtoggle { float: right; margin: 0; }
-
-.summarize_more {
- margin-top: 9px; margin-bottom: -10px; padding: 0; height: 15px;
- border: 1px solid $border$; border-top: none;
- background: $boxbg$;
- text-align: center
-}
-
-
-
-/***** Vote stats ****/
-
-.votestats { width: 630px; margin: 0 auto; }
-.votegraph { float: left; margin-right: 20px }
-.votegraph td { padding: 0 2px }
-.votegraph td.number { text-align: right }
-.votegraph td div { float: left; height: 16px; background-color: $border$; margin-right: 2px; padding: 0; }
-.votestats thead td { background: transparent; text-align: center; padding: 2px; }
-.votestats tfoot td { text-align: right }
-.votestats div { text-align: center; padding-top: 5px; }
-
-.recentvotes { width: 300px }
-.recentvotes thead tr td b { font-weight: normal; padding-left: 5px }
-
-
-
-/***** Polls ****/
-
-.votebooth thead td { font-weight: normal; background: transparent; padding-bottom: 5px; }
-.votebooth tfoot td { padding-top: 5px }
-.votebooth td { vertical-align: middle; padding: 0 8px; }
-.votebooth { margin: 0 30px }
-.votebooth td.tc1 { padding-right: 20px }
-.votebooth td.tc2 { min-width: 240px }
-.votebooth td.tc2 div { margin: 2px; }
-.votebooth td.tc2 div.graph { float: left; height: 14px; background-color: $border$; padding: 0; }
-.votebooth td.tc3 { text-align: right; padding-right: 16px; }
-.votebooth .submit { width: 100px }
-.votebooth .option { margin-left: 8px }
-.votebooth .option.own { font-weight: bold }
-
-
-
-/***** VN edit *****/
-
-#jt_box_vn_rel table { margin-bottom: 10px; }
-#jt_box_vn_rel h2 { margin: 0 0 3px 0px; }
-#jt_box_vn_rel td { padding: 1px 2px; vertical-align: middle; }
-#jt_box_vn_rel td.tc_vn { width: 300px; text-align: right }
-#jt_box_vn_rel td.tc_rel { width: 220px; white-space: nowrap }
-#jt_box_vn_rel td.tc_title { width: 200px; }
-#jt_box_vn_rel td.tc_add { width: 40px; text-align: right }
-#jt_box_vn_rel td.tc_vn input { width: 280px; }
-#jt_box_vn_rel td.tc_rel select { width: 130px; }
-
-#jt_box_vn_img div.img { float: left; height: 400px; padding-right: 20px; }
-#jt_box_vn_img h2 { margin: 0; }
-
-#jt_box_vn_scr table { width: 95% }
-#scr_table td { height: 108px; border-top: 1px solid #258; padding: 0; padding-right: 5px }
-#scr_table td.thumb { width: 136px; vertical-align: middle }
-#scr_table select { width: 400px; }
-div.scr_uploader { visibility: hidden; overflow: hidden; width: 1px; height: 1px; position: absolute; display: none; left: 0; top: 0; }
-
-
-/***** VN Release tab *****/
-
-.releases_compare table { margin: 0 auto; }
-.releases_compare td { margin: 0 auto; border: 1px solid $border$; }
-.releases_compare td.bg { background: $boxbg$; }
-.releases_compare td.multi { vertical-align:middle; }
-.releases_compare .key { background: $boxbg$; }
-
-/****** VN browse ********/
-
-.vnbrowse .tc_s { padding-left: 30px; width: 70px }
-.vnbrowse .tc2 { text-align: right; padding: 0; }
-.vnbrowse .tc3 { padding: 0; }
-.vnbrowse .tc5 { text-align: right; padding-right: 10px }
-.vnbrowse .tc6 { width: 80px }
-.vnbrowse .tc7 { text-align: right; width: 8px; white-space: nowrap }
-.vnbrowse .tc7 abbr { display: inline-block; width: 20px; }
-
-
-
-/***** Producer page/list *******/
-
-#prodrel { width: 100%; }
-#prodrel tr.vn td { background: $boxbg$; font-weight: bold; }
-#prodrel tr.vn i,
-#prodrel tr.vn span { display: none }
-#prodrel.collapse tr.vn td { padding: 1px; background: none; font-weight: normal }
-#prodrel.collapse tr.vn i { font-style: normal; display: block; float: left; width: 80px; padding: 0 0 0 40px; }
-#prodrel.collapse tr.vn span { display: inline; font-weight: normal; color: $grayedout$; padding: 0 0 0 5px }
-#prodrel.collapse tr.rel { display: none }
-#prodrel td.tc1 { width: 80px; padding-left: 30px; white-space: nowrap }
-#prodrel td.tc2 { width: 50px; text-align: center; white-space: nowrap }
-#prodrel td.tc3 { width: 120px; text-align: right; padding: 0; }
-#prodrel td.tc5 { width: 120px; color: $grayedout$; }
-#prodrel td.tc6 { width: 25px; text-align: right; padding: 0; white-space: nowrap }
-#expandprodrel { float: right; font-weight: bold; padding-bottom: 2px; border: none }
-
-div.producerbrowse { padding-bottom: 10px }
-.producerbrowse ul { float: left; margin-top: -5px; margin-left: 3%; width: 28%; }
-.producerbrowse ul li { list-style-type: none; }
-.producerbrowse ul li abbr { margin-right: 5px; margin-top: 1px; }
-
-
-/***** Producer edit *****/
-
-#jt_box_pedit_rel table { margin-bottom: 10px; }
-#jt_box_pedit_rel h2 { margin: 0 0 3px 0px; }
-#jt_box_pedit_rel td { padding: 1px 2px; vertical-align: middle; }
-#jt_box_pedit_rel td.tc_prod { width: 290px; padding-left: 10px }
-#jt_box_pedit_rel td.tc_add { width: 40px; text-align: left }
-#jt_box_pedit_rel td.tc_prod input { width: 280px; }
-#jt_box_pedit_rel td.tc_rel select { width: 130px; }
-
-
-
-
-/***** Release page *****/
-
-.release table { width: 400px; margin: 0 auto; }
-.release .key { width: 90px; }
-
-
-/* Release edit */
-
-.platforms { padding-left: 20px; }
-.platforms span { display: block; float: left; width: 150px; }
-
-#jt_box_rel_format h2 { clear: left; padding-top: 10px; }
-#media_div select.qty { width: 90px; }
-#media_div select.medium { width: 150px }
-#media_div { padding-left: 20px; }
-#media_div span { display: block }
-
-#jt_box_rel_vn h2, #jt_box_rel_prod h2 { clear: left; padding-top: 10px; }
-#jt_box_rel_vn div, #jt_box_rel_vn table,
-#jt_box_rel_prod div, #jt_box_rel_prod table { margin-left: 20px }
-#jt_box_rel_vn input, #jt_box_rel_prod input { margin-right: 10px; width: 295px }
-#jt_box_rel_vn .tc_title, #jt_box_rel_prod .tc_name { width: 310px; padding: 2px }
-#jt_box_rel_prod .tc_role select { width: 100px; margin-right: 10px; }
-
-
-
-/***** Release browser *****/
-
-.relbrowse .tc1 { width: 80px }
-.relbrowse .tc2 { width: 60px; text-align: center; }
-.relbrowse .tc3 { width: 85px; text-align: right; padding: 0; }
-
-
-
-/***** Char page (also used on VN page) *****/
-
-div.chardetails { margin: 0 auto; width: 800px; }
-div.charimg { float: left; width: 250px; margin: 0 10px; text-align: center }
-div.charimg p { text-align: center; padding: 0px; margin: 0; }
-.chardesc h2 { margin: 0; }
-.chardesc p { padding: 0 0 0 5px; }
-div.chardetails table { float: left; width: 530px; }
-div.chardetails table td.key { width: 100px; }
-div.chardetails.charsep { margin-top: 30px }
-
-.charops label { float: right; margin: 8px 0 8px 10px; border: 0; outline: none; color: $link$; cursor: pointer; }
-.charops label.sec { border-left: 1px solid $border$; padding-left: 10px }
-.charops label.lst { margin: 8px 25px 8px 10px; }
-.charops input:checked + label { color: $maintext$; }
-
-.charops .radio_spoil0:checked ~ div .charspoil_1 { display: none; }
-.charops .radio_spoil0:checked ~ div .charspoil_2 { display: none; }
-.charops .radio_spoil1:checked ~ div .charspoil_2 { display: none; }
-.charops .radio_spoil2:checked ~ div .charspoil_-1 { display: none; }
-
-.charops .radio_spoil0:checked ~ .charspoil_1 { display: none; }
-.charops .radio_spoil0:checked ~ .charspoil_2 { display: none; }
-.charops .radio_spoil1:checked ~ .charspoil_2 { display: none; }
-.charops .radio_spoil2:checked ~ .charspoil_-1 { display: none; }
-
-.sexual_check + label { clear: both; }
-
-.charops .sexual_check:not(:checked) ~ div .sexual { display: none; }
-
-.traitrow td { overflow: hidden; }
-
-.traitrow .charspoil { margin-left: -1ch; margin-right: 1ch; }
-
-/***** Char edit *****/
-
-#jt_box_chare_img div.img { float: left; height: 300px; padding-right: 20px; }
-#jt_box_chare_img h2 { margin: 0; }
-#jt_box_chare_traits table { margin-bottom: 10px; margin-left: 10px; }
-#jt_box_chare_traits h2 { margin: 0 0 3px 0px; }
-#jt_box_chare_traits td.tc_name { width: 200px }
-#jt_box_chare_traits td.tc_name input { width: 280px; }
-#jt_box_chare_traits td.tc_spoil { width: 80px; }
-#jt_box_chare_vns table { margin-bottom: 10px; margin-left: 10px; }
-#jt_box_chare_vns h2 { margin: 0 0 3px 0px; }
-#jt_box_chare_vns td.tc_vn { font-weight: bold; padding: 5px 0 3px 0 }
-#jt_box_chare_vns td.tc_vn i { font-weight: normal; padding-left: 5px; font-style: normal }
-#jt_box_chare_vns td.tc_rel { width: 340px; padding-left: 15px }
-#jt_box_chare_vns td.tc_rel select { width: 340px; }
-#jt_box_chare_vns td.tc_rol,
-#jt_box_chare_vns td.tc_rol select { width: 150px }
-#jt_box_chare_vns td.tc_spl,
-#jt_box_chare_vns td.tc_spl select { width: 100px }
-#jt_box_chare_vns td.tc_del { padding-left: 5px }
-#jt_box_chare_vns td.tc_vnadd input { width: 280px }
-
-
-/***** Char browse *****/
-
-div.charb table { table-layout: fixed }
-div.charb td { white-space: nowrap }
-div.charb td.tc1 { text-align: right; width: 40px; padding-left: 0!important; padding-bottom: 0 }
-div.charb td.tc2 { overflow: hidden }
-div.charb td.tc2 b { margin-left: 10px }
-div.charb td.tc2 b a { color: $grayedout$!important }
-
-
-/***** Staff browse *****/
-
-div.staffbrowse { padding-bottom: 10px }
-.staffbrowse ul { float: left; margin-top: -5px; margin-left: 3%; width: 28%; }
-.staffbrowse ul li { list-style-type: none; margin-bottom: 2px; }
-.staffbrowse ul li abbr { margin-right: 5px; margin-top: 1px; }
-.staffpage table.stripe { width: 450px; margin: 0 auto; }
-.staffpage .key { width: 70px; }
-.staffroles td.tc2 { white-space: nowrap; width: 80px }
-.staffroles td.tc3 { white-space: nowrap; width: 100px }
-.staffroles td.tc4 { white-space: nowrap; padding-right: 10px }
-table.aliases td { padding: 0 5px; }
-table.aliases td.key { padding: 0 5px 0 0; width: auto }
-
-
-/***** Staff display on VN pages *****/
-
-div.staff ul { list-style: none; margin: 5px 15px; float: left; min-width: 300px }
-div.staff li b.grayedout { margin-left: 10px }
-
-div.charsum_list { text-align: center }
-div.charsum_list .name { white-space: nowrap; }
-div.charsum_list .name a { font-weight: bold }
-div.charsum_list .name i { float: right }
-div.charsum_list .actor { border-top: 1px solid $border$; padding-top: 3px }
-div.charsum_list .actor b.grayedout { margin-left: 10px }
-div.charsum_list .charsum_bubble {
- background: $boxbg$;
- display: inline-block;
- text-align: left;
- vertical-align: top;
- width: 320px;
- margin: 3px;
- padding: 3px 10px;
-}
-
-
-/***** Staff edit *****/
-
-#jt_box_vn_cast #cast_import { clear: right; float: right; }
-#jt_box_vn_cast table,
-#jt_box_vn_staff table { margin-bottom: 10px; margin-left: 20px }
-#jt_box_vn_cast h2,
-#jt_box_vn_staff h2 { margin: 0 0 3px 0px; }
-#jt_box_vn_cast td,
-#jt_box_vn_staff td { padding: 1px 2px; vertical-align: middle; }
-#jt_box_vn_cast td.tc_role,
-#jt_box_vn_cast td.tc_role select,
-#jt_box_vn_staff td.tc_role,
-#jt_box_vn_staff td.tc_role select { width: 120px }
-#jt_box_vn_cast td.tc_staff,
-#jt_box_vn_staff td.tc_staff,
-.staffedit td.tc_name,
-.staffedit td.tc_original { width: 200px }
-#jt_box_vn_cast td.tc_staff input,
-#jt_box_vn_staff td.tc_staff input,
-.staffedit td.tc_name input,
-.staffedit td.tc_original input { width: 200px }
-#jt_box_vn_cast td.tc_note,
-#jt_box_vn_cast td.tc_note input,
-#jt_box_vn_staff td.tc_note,
-#jt_box_vn_staff td.tc_note input { width: 250px }
-#jt_box_vn_cast td.tc_add,
-#jt_box_vn_staff td.tc_add,
-.staffedit td.tc_add { width: 40px; text-align: left }
-.staffedit table.names td { padding: 1px 2px; vertical-align: middle; }
-.staffedit table.names tr.alias_new td { padding-top: 8px }
-
-/***** Documentation pages *****/
-
-.docs { padding: 0 15% 20px 15%; line-height: 1.4 }
-.docs h3 { margin: 30px 0 5px; font-size: 16px }
-.docs h4 { margin-top: 15px; font-size: 14px }
-.docs dd { margin: 5px 0 5px 120px }
-.docs dt { float: left }
-.docs ul, .docs ol { margin: 5px 0 5px 20px }
-.docs table { margin: 10px; width: 95% }
-.docs td { white-space: nowrap }
-.docs td { white-space: nowrap }
-.docs td+td+td+td,
-.docs td[colspan],
-.docs td[colspan]+td,
-.docs td[colspan]+td+td { white-space: normal }
-.docs p + p { padding-top: 10px }
-.docs ul.index { display: block; float: right; width: 190px; padding: 2px; margin: 0 0 10px 5px; background: $boxbg$; border: 1px solid $border$; }
-.docs ul.index li { list-style-type: none; }
-.docs ul.index li a { margin: 0 0 0 10px; }
-.docs img { margin: 5px }
-
-
-
-/* vote lists */
-
-div.votelist td.tc1 { width: 100px; padding-top: 0; padding-bottom: 0 }
-div.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px }
-
-
-/***** Wishlist browser ******/
-
-.wishlist .tc1 { padding-top: 0; padding-bottom: 0; }
-.wishlist tfoot td { padding: 0 0 0 25px }
-
-
-/***** New User's VN list *****/
-
-.labelfilters { text-align: center }
-.labelfilters input.submit { margin-top: 5px }
-
-.managelabels > div { width: 600px; margin: 10px auto }
-.managelabels table { margin: 0 auto }
-.managelabels tbody td:nth-child(1) { text-align: right }
-.managelabels tbody td:nth-child(4) { padding-left: 10px; width: 300px}
-.managelabels select { width: 100% }
-.managelabels tfoot div { float: right; text-align: right }
-
-.savedefault { width: 600px; margin: 10px auto }
-
-.ulist .tc1 { white-space: nowrap; width: 70px }
-.ulist .tc1 label { cursor: pointer }
-.ulist .tc1 input { display: none }
-.ulist .tc1 label:before { content: '▸ ' }
-.ulist .tc1 input:checked + label:before { content: '▾ ' }
-.ulist .tc_title b { margin-left: 10px }
-.ulist .tc_vote { white-space: nowrap; width: 60px; text-align: right; padding-right: 10px }
-.ulist .tc_vote input { width: 55px; text-align: right }
-.ulist .tc_voted,
-.ulist .tc_added,
-.ulist .tc_modified,
-.ulist .tc_started,
-.ulist .tc_finished,
-.ulist .tc_rdate { white-space: nowrap; width: 100px }
-.ulist .tc_rating { white-space: nowrap; width: 80px }
-
-.ulist .tc_started div, .ulist .tc_finished div { height: 1em; padding-bottom: 4px }
-.ulist .tc_started div span, .ulist .tc_finished div span { position: absolute; z-index: 0 }
-.ulist .tc_started div input, .ulist .tc_finished div input { position: absolute; z-index: 900; width: 110px; visibility: hidden }
-.ulist .tc_started div:hover input, .ulist .tc_finished div:hover input,
-.ulist .tc_started div input:focus, .ulist .tc_finished div input:focus { visibility: visible }
-
-.ulist .tc_opt { padding: 0 0 5px 70px }
-.ulist .tc_opt textarea { width: 500px; height: 18px; border: none }
-.ulist .tc_opt textarea:focus { height: 50px; border: 1px solid $secborder$ }
-.ulist .tc_opt textarea + div { display: inline-block; padding-left: 10px }
-.ulist .tc_opt .tco1 { white-space: nowrap; width: 100px }
-.ulist .tc_opt .tco2 { white-space: nowrap; width: 100px }
-.ulist .tc_opt .tco3 { white-space: nowrap; width: 60px; text-align: right; padding-bottom: 0 }
-
-
-/***** User VN list browser ******/
-
-#expandall, .collapse_but { cursor: pointer }
-.browse.rlist .tc1 { width: 16px; padding-bottom: 0 }
-.browse.rlist .tc2 { width: 16px; padding-bottom: 0 }
-.browse.rlist .tc3 { width: 60px }
-.browse.rlist .tc3_5 b { margin-left: 10px }
-.browse.rlist .tc4 { width: 60px; text-align: right; padding-top: 0; padding-bottom: 0 }
-.browse.rlist .tc6 { width: 100px }
-.browse.rlist .tc7 { width: 90px }
-.browse.rlist .tc8 { width: 70px }
-.browse.rlist tfoot select { width: 200px }
-.browse.rlist .relhid .tc6 { padding-left: 15px; width: auto }
-
-
-/***** User notifications *****/
-
-.browse.notifies td.tc1 { width: 14px }
-.browse.notifies td.tc3 { width: 100px }
-.browse.notifies td.tc4 { width: 75px }
-.browse.notifies tbody td.tc5 a { color: $grayedout$ }
-.browse.notifies td.tc5 i { font-style: normal; color: $maintext$ }
-.browse.notifies .unread td { font-weight: bold }
-.browse.notifies tfoot td { padding: 0 0 0 25px }
-
-
-
-/***** Userpage *****/
-
-.userpage table { width: 600px; margin: 0 auto; }
-.userpage .key { width: 100px; }
-
-
-/***** User posts browser ****/
-div.uposts table { table-layout: fixed }
-div.uposts td { white-space: nowrap }
-div.uposts td.tc1 { width: 60px; padding-left: 0!important; padding-right: 0; text-align: right }
-div.uposts td.tc2 { width: 40px; padding-left: 0 }
-div.uposts td.tc3 { width: 80px; }
-div.uposts td.tc4 { overflow: hidden }
-div.uposts td.tc4 b { margin-left: 10px }
-
-
-
-
-/***** Tag page *****/
-
-.tagtree { margin-left: 20px; margin-top: -20px; list-style-type: none; }
-.tagtree li { float: left; width: 230px; margin-top: 10px; }
-.tagtree li li { float: none; width: auto; margin-top: 0; }
-.tagtree ul { margin-left: 10px; list-style-type: none; }
-.tagvnlist .tc1 { width: 105px; }
-.tagvnlist .tc1 i { font-style: normal; font-size: 10px }
-.tagvnlist .tc3 { text-align: right; padding: 0; }
-.tagvnlist .tc4 { padding: 0; }
-.tagvnlist .tc6 { text-align: right; padding-right: 10px; }
-
-
-/***** Tag/trait list (/g/list, /i/list) *****/
-
-.browse.taglist .tc1 { width: 100px; white-space: nowrap }
-
-
-/***** Tag links *****/
-
-.browse.taglinks .tc1 { width: 70px }
-.browse.taglinks .tc3 { width: 80px }
-.browse.taglinks .setfil { font-size: 10px; padding-right: 3px }
-
-.tagscore { white-space: nowrap; display: inline-block; width: 58px; }
-.tagscore > span { display: inline-block; width: 25px; text-align: right; padding-right: 3px; font-size: 11px }
-.tagscore > div { display: inline-block; height: 13px; background: linear-gradient(90deg, #cf0 0px, #0f0 30px) }
-.tagscore.negative > div { background: #f00 }
-.tagscore.negative > span { color: $standout$ }
-.tagscore.ignored > div { background: #222 }
-.tagscore.ignored > span { color: $grayedout$ }
-
-
-/***** VN tagmod *****/
-
-#jt_box_tagmod .formtable table td { padding: 1px 5px }
-table.tgl tfoot td { padding-top: 20px!important; }
-table.tgl .tc_you { border-right: 1px solid $border$; border-left: 1px solid $border$; width: 150px; text-align: center }
-table.tgl .tc_others { border-left: 1px solid $border$; width: 150px; text-align: center }
-table.tgl .tc_tagname { min-width: 200px; border-right: 1px solid $border$ }
-table.tgl tbody .tc_tagname { padding-left: 15px!important }
-table.tgl .tc_myvote { padding-left: 30px!important }
-table.tgl .tc_myover { padding: 0!important }
-table.tgl .tc_myspoil { border-right: 1px solid $border$; padding-right: 30px!important; text-align: right; padding-left: 10px!important; cursor: pointer }
-table.tgl .tc_allvote { padding-left: 30px!important; }
-table.tgl .tc_allvote i { font-style: normal; font-size: 10px }
-table.tgl .tc_allspoil { text-align: right; padding-right: 15px!important; }
-table.tgl .tagmod_cat td { font-weight: bold }
-.taglvl { display: block; float: left; width: 8px; height: 13px; border: 1px solid $border$; font-size: 1px; color: $maintext$!important }
-.taglvl0 { width: 15px; border: none; font-size: 10px; text-align: center; }
-div.taglvl0 { font-size: 10px; width: 20px!important }
-div.taglvl { border: none; width: 10px; height: 14px }
-a.taglvl:hover { border-bottom: 1px solid transparent }
-.taglvlsel.taglvl-3 { background-color: #f00; border-color: #f00 }
-.taglvlsel.taglvl-2 { background-color: #f40; border-color: #f40 }
-.taglvlsel.taglvl-1 { background-color: #f80; border-color: #f80 }
-.taglvlsel.taglvl1 { background-color: #cf0; border-color: #cf0 }
-.taglvlsel.taglvl2 { background-color: #8f0; border-color: #8f0 }
-.taglvlsel.taglvl3 { background-color: #0f0; border-color: #0f0 }
-
-
-
-
-/****** Revision information ******/
-
-div.revision div.rev, div.revision table {
- border: 1px solid $border$;
- margin: 0 auto;
- width: 90%;
- background-color: $secbg$;
- clear: both;
-}
-div.revision { padding-bottom: 10px; }
-div.revision table thead tr td { background-color: transparent!important; text-align: center; font-weight: normal; }
-div.revision table td { border-right: 1px solid $border$; padding: 5px; }
-div.revision td.tcval { width: 44%; }
-div.revision div.rev { padding: 5px; text-align: center; }
-.diff_add { font-weight: normal; background-color: $diffadd$; }
-.diff_del { font-weight: normal; background-color: $diffdel$; }
-div.revision .next { float: right; margin-right: 5%; }
-div.revision .prev { float: left; margin-left: 5%; }
-div.revision .item { text-align: center; }
-
-
-
-/****** Image Viewer *****/
-
-div#iv_view {
- position: absolute;
- top: 0px;
- left: 0px;
- background: $boxbg$;
- border: 1px solid $border$;
- padding: 5px;
- text-align: center;
-}
-#iv_view a { border: 0; font-weight: bold; font-size: 14px }
-#iv_view img { cursor: pointer }
-#ivclose { float: right; padding-left: 10px }
-#ivnext { padding-left: 5px; }
-#ivprev { padding-right: 5px; }
-#ivfull { float: left; padding-right: 10px; }
-#ivimgload {
- position: absolute;
- display: block;
- left: 0;
- top: 0;
- width: 100px;
- padding: 3px;
- background-color: #f5f5f5; /* no real need to skin this */
- text-align: center;
- border: 1px solid #ccc;
- color: #000;
-}
-
-
-
-/****** filter selector *****/
-
-.fil_div {
- position: absolute;
- top: 0px;
- left: 0px;
- background: $tabbg$;
- border: 1px solid $border$;
- padding: 5px;
- width: 600px;
- text-align: center;
-}
-.fil_div a.close { float: right; border: 0; font-weight: bold }
-.fil_div p.browseopts { padding: 2px 20px; line-height: 23px }
-.fil_div .browseopts a { outline: none; color: $maintext$ }
-.fil_div .browseopts a.active { font-weight: bold }
-.fil_div b.ruler { display: block; margin: auto; width: 93%; height: 1px; border-bottom: 1px solid $border$; margin-bottom: 5px }
-.fil_div h3 { width: 100%; text-align: center; font-size: 13px }
-.fil_div table { width: 93%; text-align: left; margin: 0 auto 5px auto }
-.fil_div table td.label label { width: 120px }
-.fil_div table td.label b { display: block; font-weight: normal; padding: 10px 5px 0 0 }
-.fil_div table td.check { width: 15px }
-.fil_div label.active { font-weight: bold }
-.fil_div .opts a { border: 0; outline: none }
-.fil_div .opts b { margin: 0 7px; font-weight: normal }
-.fil_div .opts a.tsel { color: $maintext$; }
-.fil_div table ul { margin: 0 0 0 15px }
-.fil_div .slider p { margin: 1px; }
-.fil_div .slider div { margin: 1px; border: 1px solid $secborder$; float: left; height: 12px; }
-.fil_div .slider div div { border-top: none; border-bottom: none; cursor: default; position: relative; height: 10px; margin: 1px; }
-.fil_div .slider span { margin-left: 5px }
-
-p.filselect {
- text-align: center;
- display: block;
- margin: 10px auto 3px auto;
- border: none;
- outline: none;
-}
-p.filselect a { margin: 0 5px }
-p.filselect i { font-style: normal }
-
-
-
-/****** Icons *******/
-
-.icons {
- background: url(/f/icons.png?$icons_version$) no-repeat;
- width: 16px;
- height: 14px;
- margin: 0 2px 0 0;
- margin-top: 0px!important;
- overflow: hidden;
- display:-moz-inline-stack;
- display: inline-block;
- padding: 0;
- border: 0;
- text-decoration: none;
-}
-.icons.lang { width: 13px; height: 11px }
-.icons.feed { width: 12px; height: 12px }
-.icons.gen { width: 14px; height: 14px }
-.icons.gen.b { width: 28px }
-.icons.rtcomplete, .icons.rtpartial, .icons.rttrial { width: 11px; }
-abbr.icons, abbr.uicons { cursor: default; }
-a .icons { cursor: pointer }
-.icons.oth { background: none; }
-$iconcss$
-
-
-.release_icons_container { width: 16px; height: 16px; float: right; margin-left: 4px; }
-.release_icons { width: 16px; height: 16px; }
-.release_icon_not_voiced, .release_icon_story_not_animated, .release_icon_ero_not_animated { }
-.release_icon_ero_voiced, .release_icon_story_simple_animated, .release_icon_ero_simple_animated { filter: hue-rotate(30deg); }
-.release_icon_partially_voiced, .release_icon_story_some_fully_animated, .release_icon_ero_some_fully_animated { filter: invert(100%) hue-rotate(240deg); }
-.release_icon_fully_voiced, .release_icon_story_all_fully_animated, .release_icon_ero_all_fully_animated { filter: hue-rotate(80deg); }
-.release_icon_notes, .release_icon_unknown, .release_icon_freeware, .release_icon_nonfree,
- .release_icon_commercial, .release_icon_doujin, .release_icon_res16-9, .release_icon_res4-3,
- .release_icon_disk, .release_icon_cartridge, .release_icon_download { }
-
-/* Relation graph colors */
-svg .border { fill: none; stroke: $border$ }
-svg .edge polygon.border { fill: $border$ }
-svg .nodebg { fill: $tabbg$; stroke: $tabbg$ }
-svg text { fill: $maintext$ }
-svg .edge text { font: 8px "Tahoma", "Arial", sans-serif }
-#graph_current .border { stroke: $warnborder$ }
-#graph_current .nodebg { stroke: $warnborder$; fill: $warnbg$ }
-
diff --git a/elm/AdvSearch/Anime.elm b/elm/AdvSearch/Anime.elm
new file mode 100644
index 00000000..8d0882dc
--- /dev/null
+++ b/elm/AdvSearch/Anime.elm
@@ -0,0 +1,93 @@
+module AdvSearch.Anime 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.Lib exposing (..)
+import AdvSearch.Set as S
+
+
+
+type alias Model =
+ { sel : S.Model Int
+ , conf : A.Config Msg GApi.ApiAnimeResult
+ , search : A.Model GApi.ApiAnimeResult
+ }
+
+type Msg
+ = Sel (S.Msg Int)
+ | Search (A.Msg GApi.ApiAnimeResult)
+
+
+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 = "xsearch_anime" ++ String.fromInt ndat.objid, source = A.animeSource True }
+ , 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 s ->
+ if Set.member s.id model.sel.sel then (dat, { model | search = nm }, c)
+ else ( { dat | anime = Dict.insert s.id s dat.anime }
+ , { model | search = A.clear nm "", sel = S.update (S.Sel s.id True) model.sel }
+ , c )
+
+
+toQuery m = S.toQuery (QInt 13) m.sel
+
+fromQuery dat qf = S.fromQuery (\q ->
+ case q of
+ QInt 13 op v -> Just (op, v)
+ _ -> Nothing) dat qf
+ |> Maybe.map (\(ndat,sel) ->
+ ( { ndat | objid = ndat.objid+1 }
+ , { sel = { sel | single = False }
+ , conf = { wrap = Search, id = "xsearch_anime" ++ String.fromInt ndat.objid, source = A.animeSource True }
+ , search = A.init ""
+ }
+ ))
+
+
+
+view : Data -> Model -> (Html Msg, () -> List (Html Msg))
+view dat model =
+ ( case Set.toList model.sel.sel of
+ [] -> small [] [ text "Anime" ]
+ [s] -> span [ class "nowrap" ]
+ [ S.lblPrefix model.sel
+ , small [] [ text <| "a" ++ String.fromInt s ++ ":" ]
+ , Dict.get s dat.anime |> Maybe.map (\e -> e.title) |> Maybe.withDefault "" |> text
+ ]
+ l -> span [] [ S.lblPrefix model.sel, text <| "Anime (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Anime" ]
+ , Html.map Sel (S.opts model.sel True True)
+ ]
+ , ul [] <| List.map (\s ->
+ li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ]
+ [ inputButton "X" (Sel (S.Sel s False)) []
+ , small [] [ text <| " a" ++ String.fromInt s ++ ": " ]
+ , Dict.get s dat.anime |> Maybe.map (\e -> e.title) |> Maybe.withDefault "" |> text
+ ]
+ ) (Set.toList model.sel.sel)
+ , A.view model.conf model.search [ placeholder "Search..." ]
+ ]
+ )
diff --git a/elm/AdvSearch/Birthday.elm b/elm/AdvSearch/Birthday.elm
new file mode 100644
index 00000000..a03b124f
--- /dev/null
+++ b/elm/AdvSearch/Birthday.elm
@@ -0,0 +1,67 @@
+module AdvSearch.Birthday exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Lib.Html exposing (..)
+import Lib.RDate as RDate
+import AdvSearch.Lib exposing (..)
+
+
+type alias Model =
+ { op : Op
+ , month : Int
+ , day : Int
+ }
+
+
+type Msg
+ = MOp Op
+ | Month Int
+ | Day Int
+
+
+update : Msg -> Model -> Model
+update msg model =
+ case msg of
+ MOp o -> { model | op = o }
+ Month m -> { model | month = m, day = if m == 0 then 0 else model.day }
+ Day d -> { model | day = d }
+
+
+init : Data -> (Data, Model)
+init dat = (dat,
+ { op = Eq
+ , month = 0
+ , day = 0
+ })
+
+
+
+toQuery : Model -> Maybe Query
+toQuery model = Just <| QTuple 14 model.op model.month model.day
+
+
+fromQuery : Data -> Query -> Maybe (Data, Model)
+fromQuery dat q =
+ case q of
+ QTuple 14 o m d -> Just (dat, { op = o, month = m, day = d })
+ _ -> Nothing
+
+
+view : Model -> (Html Msg, () -> List (Html Msg))
+view model =
+ ( text <| showOp model.op ++ " "
+ ++ (if model.month == 0 then "Unknown"
+ else List.drop (model.month-1) RDate.monthList |> List.head |> Maybe.withDefault "")
+ ++ (if model.day == 0 then "" else " " ++ String.fromInt model.day)
+ , \() ->
+ [ div [ class "advheader", style "width" "290px" ]
+ [ h3 [] [ text "Birthday" ]
+ , div [ class "opts" ] [ inputOp True model.op MOp ]
+ ]
+ , inputSelect "" model.month Month [style "width" "128px"] <| (0, "Unknown") :: RDate.monthSelect
+ , if model.month == 0 then text ""
+ else inputSelect "" model.day Day [style "width" "70px"]
+ <| (0, "- day -") :: List.map (\i -> (i, String.fromInt i)) (List.range 1 31)
+ ]
+ )
diff --git a/elm/AdvSearch/DRM.elm b/elm/AdvSearch/DRM.elm
new file mode 100644
index 00000000..ccf64328
--- /dev/null
+++ b/elm/AdvSearch/DRM.elm
@@ -0,0 +1,78 @@
+module AdvSearch.DRM exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Set
+import Lib.Autocomplete as A
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Gen.Api as GApi
+import AdvSearch.Lib exposing (..)
+import AdvSearch.Set as S
+
+
+
+type alias Model =
+ { sel : S.Model String
+ , conf : A.Config Msg GApi.ApiDRM
+ , search : A.Model GApi.ApiDRM
+ }
+
+type Msg
+ = Sel (S.Msg String)
+ | Search (A.Msg GApi.ApiDRM)
+
+
+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 = "xsearch_drm" ++ String.fromInt ndat.objid, source = A.drmSource }
+ , 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 e -> (dat, { model | search = A.clear nm "", sel = S.update (S.Sel e.name True) model.sel }, c)
+
+
+toQuery m = S.toQuery (QStr 20) m.sel
+
+fromQuery dat q =
+ let f q2 = case q2 of
+ QStr 20 op v -> Just (op, v)
+ _ -> Nothing
+ in S.fromQuery f dat q |> Maybe.map (\(ndat,sel) ->
+ ( { ndat | objid = ndat.objid+1 }
+ , { sel = { sel | single = False }
+ , conf = { wrap = Search, id = "xsearch_drm" ++ String.fromInt ndat.objid, source = A.drmSource }
+ , search = A.init ""
+ }
+ ))
+
+view : Model -> (Html Msg, () -> List (Html Msg))
+view model =
+ ( case Set.toList model.sel.sel of
+ [] -> small [] [ text "DRM implementation" ]
+ [s] -> span [ class "nowrap" ] [ S.lblPrefix model.sel, text s ]
+ l -> span [] [ S.lblPrefix model.sel, text <| "DRM (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "DRM implementation" ]
+ , Html.map Sel (S.opts model.sel False False)
+ ]
+ , ul [] <| List.map (\s ->
+ li [] [ inputButton "X" (Sel (S.Sel s False)) [], text " ", text s ]
+ ) <| List.filter (\x -> x /= "") <| Set.toList model.sel.sel
+ , A.view model.conf model.search [ placeholder "Search..." ]
+ ]
+ )
diff --git a/elm/AdvSearch/Engine.elm b/elm/AdvSearch/Engine.elm
new file mode 100644
index 00000000..8214cae2
--- /dev/null
+++ b/elm/AdvSearch/Engine.elm
@@ -0,0 +1,79 @@
+module AdvSearch.Engine exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Set
+import Lib.Autocomplete as A
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Gen.Api as GApi
+import AdvSearch.Lib exposing (..)
+import AdvSearch.Set as S
+
+
+
+type alias Model =
+ { sel : S.Model String
+ , conf : A.Config Msg GApi.ApiEngines
+ , search : A.Model GApi.ApiEngines
+ }
+
+type Msg
+ = Sel (S.Msg String)
+ | Search (A.Msg GApi.ApiEngines)
+
+
+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 = "xsearch_eng" ++ String.fromInt ndat.objid, source = A.engineSource }
+ , 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 e -> (dat, { model | search = A.clear nm "", sel = S.update (S.Sel e.engine True) model.sel }, c)
+
+
+toQuery m = S.toQuery (QStr 15) m.sel
+
+fromQuery dat q =
+ let f q2 = case q2 of
+ QStr 15 op v -> Just (op, v)
+ _ -> Nothing
+ in S.fromQuery f dat q |> Maybe.map (\(ndat,sel) ->
+ ( { ndat | objid = ndat.objid+1 }
+ , { sel = { sel | single = False }
+ , conf = { wrap = Search, id = "xsearch_eng" ++ String.fromInt ndat.objid, source = A.engineSource }
+ , search = A.init ""
+ }
+ ))
+
+view : Model -> (Html Msg, () -> List (Html Msg))
+view model =
+ ( case Set.toList model.sel.sel of
+ [] -> small [] [ text "Engine" ]
+ [s] -> span [ class "nowrap" ] [ S.lblPrefix model.sel, text (if s == "" then "Unknown engine" else s) ]
+ l -> span [] [ S.lblPrefix model.sel, text <| "Engines (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Engine" ]
+ , Html.map Sel (S.opts model.sel False False)
+ ]
+ , ul [] <| List.map (\s ->
+ li [] [ inputButton "X" (Sel (S.Sel s False)) [], text " ", text s ]
+ ) <| List.filter (\x -> x /= "") <| Set.toList model.sel.sel
+ , A.view model.conf model.search [ placeholder "Search..." ]
+ , label [] [ inputCheck "" (Set.member "" model.sel.sel) (Sel << S.Sel ""), text " Unknown" ]
+ ]
+ )
diff --git a/elm/AdvSearch/Fields.elm b/elm/AdvSearch/Fields.elm
new file mode 100644
index 00000000..2ec6e205
--- /dev/null
+++ b/elm/AdvSearch/Fields.elm
@@ -0,0 +1,784 @@
+module AdvSearch.Fields exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Array
+import Set
+import Lib.Util exposing (..)
+import Lib.Html exposing (..)
+import Lib.DropDown as DD
+import Lib.Api as Api
+import Lib.Autocomplete as A
+import AdvSearch.Anime as AA
+import AdvSearch.Set as AS
+import AdvSearch.Producers as AP
+import AdvSearch.Staff as AT
+import AdvSearch.Tags as AG
+import AdvSearch.Traits as AI
+import AdvSearch.RDate as AD
+import AdvSearch.Range as AR
+import AdvSearch.Resolution as AE
+import AdvSearch.Engine as AEng
+import AdvSearch.DRM as ADRM
+import AdvSearch.Birthday as AB
+import AdvSearch.Lib exposing (..)
+import Gen.ExtLinks as GEL
+
+
+-- "Nested" fields are a container for other fields.
+-- The code for nested fields is tightly coupled with the generic 'Field' abstraction below.
+
+type alias NestModel =
+ { ptype : QType -- type of the parent field
+ , qtype : QType -- type of the child fields
+ , fields : List Field
+ , and : Bool
+ , andDd : DD.Config FieldMsg
+ , addDd : DD.Config FieldMsg
+ , addtype : List QType
+ , neg : Bool -- only if ptype /= qtype
+ }
+
+
+type NestMsg
+ = NAndToggle Bool
+ | NAnd Bool Bool
+ | NAddToggle Bool
+ | NAdd Int
+ | NAddType (List QType)
+ | NField Int FieldMsg
+ | NNeg Bool Bool
+
+
+nestInit : Bool -> QType -> QType -> List Field -> Data -> (Data, NestModel)
+nestInit and ptype qtype list dat =
+ ( { dat | objid = dat.objid+2 }
+ , { ptype = ptype
+ , qtype = qtype
+ , fields = list
+ , and = and
+ , andDd = DD.init ("xsearch_field"++String.fromInt (dat.objid+0)) (FSNest << NAndToggle)
+ , addDd = DD.init ("xsearch_field"++String.fromInt (dat.objid+1)) (FSNest << NAddToggle)
+ , addtype = [qtype]
+ , neg = False
+ }
+ )
+
+
+nestUpdate : Data -> NestMsg -> NestModel -> (Data, NestModel, Cmd NestMsg)
+nestUpdate dat msg model =
+ case msg of
+ NAndToggle b -> (dat, { model | andDd = DD.toggle model.andDd b, addtype = [model.qtype] }, Cmd.none)
+ NAnd b _ -> (dat, { model | and = b, andDd = DD.toggle model.andDd False }, Cmd.none)
+ NAddToggle b -> (dat, { model | addDd = DD.toggle model.addDd b, addtype = [model.qtype] }, Cmd.none)
+ NAdd n ->
+ let addPar lst (ndat,f) =
+ case lst of
+ (a::b::xs) ->
+ -- Don't add the child field if it's an And/Or, the parent field covers that already.
+ let nf = case f of
+ (_,_,FMNest m) -> if m.ptype == m.qtype then [] else [f]
+ _ -> [f]
+ in addPar (b::xs) (nestInit True b a nf ndat |> Tuple.mapSecond FMNest |> fieldCreate -1)
+ _ -> (ndat,f)
+ (ndat2,f2) = addPar model.addtype (fieldInit n dat)
+ nestMsg lst i =
+ case lst of
+ (a::xs) -> NField i (FSNest (nestMsg xs 0))
+ _ -> NField i (FToggle True)
+ in (ndat2, { model | addDd = DD.toggle model.addDd False, addtype = [model.qtype], fields = model.fields ++ [f2] }
+ , selfCmd (nestMsg (List.drop 1 model.addtype) (List.length model.fields)))
+ 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 (not model.and) model.qtype 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)
+ NNeg b _ -> (dat, { model | neg = b }, Cmd.none)
+
+
+nestToQuery : Data -> NestModel -> Maybe Query
+nestToQuery dat model =
+ let op = if model.neg then Ne else Eq
+ com = if model.and then QAnd else QOr
+ wrap f =
+ case List.filterMap (fieldToQuery dat) model.fields of
+ [] -> Nothing
+ [x] -> Just (f x)
+ xs -> Just (f (com xs))
+ in case (model.ptype, model.qtype) of
+ (V, R) -> wrap (QQuery 50 op)
+ (V, C) -> wrap (QQuery 51 op)
+ (V, S) -> wrap (QQuery 52 op)
+ (V, P) -> wrap (QQuery 55 op)
+ (C, S) -> wrap (QQuery 52 op)
+ (C, V) -> wrap (QQuery 53 op)
+ (R, V) -> wrap (QQuery 53 op)
+ (R, P) -> wrap (QQuery 55 op)
+ _ -> wrap identity
+
+
+nestFromQuery : QType -> QType -> Data -> Query -> Maybe (Data, NestModel)
+nestFromQuery ptype qtype dat q =
+ let init and l =
+ let (ndat,fl) = List.foldr (\f (d,a) -> let (nd,fm) = fieldFromQuery qtype d f in (nd,(fm::a))) (dat,[]) l
+ in nestInit and ptype qtype fl ndat
+
+ initSub op val = if op /= Eq && op /= Ne then Nothing else Just <|
+ let (ndat,f) = fieldFromQuery qtype dat val
+ (ndat2,m) = nestInit True ptype qtype [f] ndat
+ -- If there is only a single nested query and it's an and/or nest, merge it into this node.
+ m2 = case m.fields of
+ [(_,_,FMNest cm)] -> if cm.ptype == cm.qtype then { m | fields = cm.fields, and = cm.and } else m
+ _ -> m
+ in (ndat2, { m2 | neg = op == Ne })
+
+ in case (ptype, qtype, q) of
+ (V, R, QQuery 50 op r) -> initSub op r
+ (V, C, QQuery 51 op r) -> initSub op r
+ (V, S, QQuery 52 op r) -> initSub op r
+ (V, P, QQuery 55 op r) -> initSub op r
+ (C, S, QQuery 52 op r) -> initSub op r
+ (C, V, QQuery 53 op r) -> initSub op r
+ (R, V, QQuery 53 op r) -> initSub op r
+ (R, P, QQuery 55 op r) -> initSub op r
+ (_, _, QAnd l) -> if ptype == qtype then Just (init True l) else Nothing
+ (_, _, QOr l) -> if ptype == qtype then Just (init False l) else Nothing
+ _ -> Nothing
+
+
+nestView : Data -> DD.Config FieldMsg -> NestModel -> Html FieldMsg
+nestView dat dd model =
+ let
+ isNest (_,_,f) =
+ case f of
+ FMNest _ -> True
+ _ -> False
+ hasNest = List.any isNest model.fields
+ filterDat =
+ { dat
+ | level = if model.ptype /= model.qtype then 1 else dat.level+1
+ , parentTypes = if model.ptype /= model.qtype then Set.insert (showQType model.ptype) dat.parentTypes else dat.parentTypes
+ }
+ filters = List.indexedMap (\i f ->
+ Html.map (FSNest << NField i) <| fieldView filterDat f
+ ) model.fields
+
+ add =
+ let parents = Set.union filterDat.parentTypes <| Set.fromList <| List.map showQType <| List.drop 1 model.addtype
+ lst = Array.toIndexedList fields |> List.filter (\(_,f) ->
+ Just f.ptype == List.head model.addtype
+ && f.title /= ""
+ && (dat.uid /= Nothing || f.title /= "My Labels")
+ && (dat.uid /= Nothing || f.title /= "My List")
+ && (f.title /= "Name" || not (Set.isEmpty parents))
+ && not (f.title == "Role" && (List.head (List.drop 1 model.addtype)) == Just C) -- No "role" filter for character seiyuu (the seiyuu role is implied, after all)
+ && not (Set.member (showQType f.qtype) parents))
+ showT par t =
+ case (par,t) of
+ (_,V) -> "VN"
+ (_,R) -> "Release"
+ (_,C) -> "Character"
+ (C,S) -> "VA"
+ (_,S) -> "Staff"
+ (V,P) -> "Developer"
+ (_,P) -> "Producer"
+ breads pre par l =
+ case l of
+ [] -> []
+ [x] -> [ strong [] [ text (showT par x) ] ]
+ (x::xs) -> a [ href "#", onClickD (FSNest (NAddType (x::pre))) ] [ text (showT par x) ] :: text " » " :: breads (x::pre) x xs
+ in
+ div [ class "elm_dd_input elm_dd_noarrow short" ]
+ [ DD.view model.addDd Api.Normal (text "+") <| \() ->
+ [ div [ class "advheader", style "min-width" "200px" ]
+ [ h3 [] [ text "Add filter" ]
+ , if List.length model.addtype <= 1 then text "" else
+ div [] <| breads [] model.qtype (List.reverse model.addtype)
+ ]
+ , ul (if List.length lst > 6 then [ style "columns" "2" ] else []) <|
+ List.map (\(n,f) ->
+ li [] [ a [ href "#", onClickD (FSNest <| if f.qtype /= f.ptype then NAddType (f.qtype :: model.addtype) else NAdd n)] [ text f.title ] ]
+ ) lst
+ ]
+ ]
+
+ andcont () = [ ul []
+ [ li [] [ linkRadio ( model.and) (FSNest << NAnd True ) [ text "And: All filters must match" ] ]
+ , li [] [ linkRadio (not model.and) (FSNest << NAnd False) [ text "Or: At least one filter must match" ] ]
+ ] ]
+
+ andlbl = text <| if model.and then "And" else "Or"
+
+ and = div [ class "elm_dd_input short" ] [ DD.view model.andDd Api.Normal andlbl andcont ]
+
+ negcont () =
+ let (a,b) =
+ case (model.ptype, model.qtype) of
+ (_, C) -> ("Has a character that matches these filters", "Does not have a character that matches these filters")
+ (_, R) -> ("Has a release that matches these filters", "Does not have a release that matches these filters")
+ (_, V) -> ("Linked to a visual novel that matches these filters", "Not linked to a visual novel that matches these filters")
+ (V, S) -> ("Has staff that matches these filters", "Does not have staff that matches these filters")
+ (V, P) -> ("Has a developer that matches these filters", "Does not have a developer that matches these filters")
+ (C, S) -> ("Has a voice actor that matches these filters", "Does not have a voice actor that matches these filters")
+ (R, P) -> ("Has a producer that matches these filters", "Does not have a producer that matches these filters")
+ _ -> ("","")
+ in [ ul []
+ [ li [] [ linkRadio (not model.neg) (FSNest << NNeg False) [ text a ] ]
+ , li [] [ linkRadio ( model.neg) (FSNest << NNeg True ) [ text b ] ]
+ ] ]
+
+ neglbl = text <| (if model.neg then "¬" else "") ++
+ case (model.ptype, model.qtype) of
+ (_, C) -> "Char"
+ (_, R) -> "Rel"
+ (_, V) -> "VN"
+ (V, S) -> "Staff"
+ (V, P) -> "Developer"
+ (R, P) -> "Producer"
+ (C, S) -> "VA"
+ _ -> ""
+
+ ourdd =
+ if model.qtype == model.ptype
+ then fieldViewDd dat dd andlbl andcont
+ else fieldViewDd dat dd neglbl negcont
+
+ initialdd = if model.ptype == model.qtype || List.length model.fields == 1 then [ ourdd ] else [ ourdd, and ]
+
+ in
+ if hasNest
+ then table [ class "advnest" ] <| List.indexedMap (\i f -> tr []
+ [ td [] <| if i == 0 then initialdd else []
+ , td [ class (if i == 0 then "start" else "mid") ] [ div [] [], span [] [] ]
+ , td [] [ f ]
+ ]) filters
+ ++ [ tr []
+ [ td [] []
+ , td [ class "end" ] [ div [] [], span [] [] ]
+ , td [] [ add ]
+ ]
+ ]
+ else table [ class "advrow" ] [ tr []
+ [ td [] (initialdd ++ [small [] [ text " → " ]])
+ , td [] (filters ++ [add]) ] ]
+
+
+
+
+
+-- 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 alias ListModel =
+ { val : Int
+ , lst : List (Query, String)
+ }
+
+type FieldModel
+ = FMCustom Query -- A read-only placeholder for Query values that failed to parse into a Field
+ | FMNest NestModel
+ | FMList ListModel
+ | FMLang AS.LangModel
+ | FMRPlatform (AS.Model String)
+ | FMVPlatform (AS.Model String)
+ | FMLength (AS.Model Int)
+ | FMDevStatus (AS.Model Int)
+ | FMRole (AS.Model String)
+ | FMBlood (AS.Model String)
+ | FMSex (AS.SexModel)
+ | FMGender (AS.Model String)
+ | FMMedium (AS.Model String)
+ | FMVoiced (AS.Model Int)
+ | FMAniEro (AS.Model Int)
+ | FMAniStory (AS.Model Int)
+ | FMRType (AS.Model String)
+ | FMLabel (AS.Model Int)
+ | FMRList (AS.Model Int)
+ | FMSRole (AS.Model String)
+ | FMPType (AS.Model String)
+ | FMRExtLinks (AS.Model String)
+ | FMSExtLinks (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)
+ | FMMinAge (AR.Model Int)
+ | FMProdId AP.Model
+ | FMProducer AP.Model
+ | FMDeveloper AP.Model
+ | FMStaff AT.Model
+ | FMAnime AA.Model
+ | FMRDate AD.Model
+ | FMResolution AE.Model
+ | FMEngine AEng.Model
+ | FMDRMType ADRM.Model
+ | FMTag AG.Model
+ | FMTrait AI.Model
+ | FMBirthday AB.Model
+
+type FieldMsg
+ = FSCustom () -- Not actually used at the moment
+ | FSNest NestMsg
+ | FSList Int
+ | FSLang (AS.Msg String)
+ | FSRPlatform (AS.Msg String)
+ | FSVPlatform (AS.Msg String)
+ | FSLength (AS.Msg Int)
+ | FSDevStatus (AS.Msg Int)
+ | FSRole (AS.Msg String)
+ | FSBlood (AS.Msg String)
+ | FSSex AS.SexMsg
+ | FSGender (AS.Msg String)
+ | FSMedium (AS.Msg String)
+ | FSVoiced (AS.Msg Int)
+ | FSAniEro (AS.Msg Int)
+ | FSAniStory (AS.Msg Int)
+ | FSRType (AS.Msg String)
+ | FSLabel (AS.Msg Int)
+ | FSRList (AS.Msg Int)
+ | FSSRole (AS.Msg String)
+ | FSPType (AS.Msg String)
+ | FSRExtLinks (AS.Msg String)
+ | FSSExtLinks (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
+ | FSMinAge AR.Msg
+ | FSProdId AP.Msg
+ | FSProducer AP.Msg
+ | FSDeveloper AP.Msg
+ | FSStaff AT.Msg
+ | FSAnime AA.Msg
+ | FSRDate AD.Msg
+ | FSResolution AE.Msg
+ | FSEngine AEng.Msg
+ | FSDRMType ADRM.Msg
+ | FSTag AG.Msg
+ | FSTrait AI.Msg
+ | FSBirthday AB.Msg
+ | FToggle Bool
+ | FDel -- intercepted in nestUpdate
+ | FMoveSub -- intercepted in nestUpdate
+ | FMovePar
+
+type alias FieldDesc =
+ { qtype : QType
+ , ptype : QType
+ , title : String -- How it's listed in the field selection menu.
+ , quick : Int -- Whether it should be included in the default set of fields (>0) ("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 : Array.Array FieldDesc
+fields =
+ let f qtype title quick wrap init fromq =
+ { qtype = qtype
+ , ptype = qtype
+ , title = title
+ , quick = quick
+ , init = \d -> (Tuple.mapSecond wrap (init d))
+ , fromQuery = \d q -> Maybe.map (Tuple.mapSecond wrap) (fromq d q)
+ }
+ -- List type queries are fully defined here for convenience
+ l qtype title quick lst =
+ f qtype title quick FMList (\d -> (d, { val = 0, lst = lst }))
+ (\d q -> List.indexedMap (\i (k,v) -> (i,k,v)) lst |> List.filter (\(i,k,_) -> k == q) |> List.head |> Maybe.map (\(i,_,_) -> (d, { val = i, lst = lst })))
+ -- Nested queries
+ n ptype qtype title =
+ { qtype = qtype
+ , ptype = ptype
+ , title = title
+ , quick = 0
+ , init = nestInit True ptype qtype [] >> Tuple.mapSecond FMNest
+ , fromQuery = \d -> nestFromQuery ptype qtype d >> Maybe.map (Tuple.mapSecond FMNest)
+ }
+ in Array.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 qtype == ptype go before everything else.
+
+ -- T TITLE QUICK WRAP INIT FROM_QUERY
+ [ n V V "And/Or"
+ , n V R "Release »"
+ , n V S "Staff »"
+ , n V C "Character »"
+ , n V P "Developer »"
+ , f V "Language" 1 FMLang (AS.langInit AS.LangVN) (AS.langFromQuery AS.LangVN)
+ , f V "Original language" 2 FMLang (AS.langInit AS.LangVNO) (AS.langFromQuery AS.LangVNO)
+ , f V "Platform" 3 FMVPlatform AS.init AS.platformFromQuery
+ , f V "Tags" 4 FMTag AG.init (AG.fromQuery -1 True False)
+ , f V "" -4 FMTag AG.init (AG.fromQuery 0 True False)
+ , f V "" -4 FMTag AG.init (AG.fromQuery 1 True False)
+ , f V "" -4 FMTag AG.init (AG.fromQuery 2 True False)
+ , f V "" -4 FMTag AG.init (AG.fromQuery 2 True True )
+ , f V "" -4 FMTag AG.init (AG.fromQuery 0 False False)
+ , f V "" -4 FMTag AG.init (AG.fromQuery 1 False False)
+ , f V "" -4 FMTag AG.init (AG.fromQuery 2 False False)
+ , f V "" -4 FMTag AG.init (AG.fromQuery 2 False True )
+ , f V "My Labels" 0 FMLabel AS.init AS.labelFromQuery
+ , l V "My List" 0 [(QInt 65 Eq 1, "On my list"), (QInt 65 Ne 1, "Not on my list")]
+ , f V "Length" 0 FMLength AS.init AS.lengthFromQuery
+ , f V "Development status" 0 FMDevStatus AS.init AS.devStatusFromQuery
+ , f V "Release date" 0 FMRDate AD.init AD.fromQuery
+ , f V "" -1 FMPopularity AR.popularityInit AR.popularityFromQuery
+ , f V "Rating" 0 FMRating AR.ratingInit AR.ratingFromQuery
+ , f V "Number of votes" 0 FMVotecount AR.votecountInit AR.votecountFromQuery
+ , f V "Anime" 0 FMAnime AA.init AA.fromQuery
+ , l V "Has description" 0 [(QInt 61 Eq 1, "Has description"), (QInt 61 Ne 1, "No description")]
+ , l V "Has anime" 0 [(QInt 62 Eq 1, "Has anime relation"), (QInt 62 Ne 1, "No anime relation")]
+ , l V "Has screenshot" 0 [(QInt 63 Eq 1, "Has screenshot(s)"), (QInt 63 Ne 1, "No screenshot(s)")]
+ , l V "Has review" 0 [(QInt 64 Eq 1, "Has review(s)"), (QInt 64 Ne 1, "No review(s)")]
+ -- Deprecated
+ , f V "" 0 FMDeveloper AP.init (AP.fromQuery 6)
+
+ , n R R "And/Or"
+ , n R V "Visual Novel »"
+ , n R P "Producer »"
+ , f R "Language" 1 FMLang (AS.langInit AS.LangRel) (AS.langFromQuery AS.LangRel)
+ , f R "Platform" 2 FMRPlatform AS.init AS.platformFromQuery
+ , f R "Type" 3 FMRType AS.init AS.rtypeFromQuery
+ , l R "Patch" 0 [(QInt 61 Eq 1, "Patch to another release"),(QInt 61 Ne 1, "Standalone release")]
+ , l R "Freeware" 0 [(QInt 62 Eq 1, "Freeware"), (QInt 62 Ne 1, "Non-free")]
+ , l R "Erotic scenes" 0 [(QInt 66 Eq 1, "Has erotic scenes"), (QInt 66 Ne 1, "No erotic scenes")]
+ , l R "Uncensored" 0 [(QInt 64 Eq 1, "Uncensored (no mosaic)"), (QInt 64 Ne 1, "Censored (or no erotic content to censor)")]
+ , l R "Official" 0 [(QInt 65 Eq 1, "Official"), (QInt 65 Ne 1, "Unofficial")]
+ , f R "Release date" 0 FMRDate AD.init AD.fromQuery
+ , f R "Resolution" 0 FMResolution AE.init AE.fromQuery
+ , f R "Age rating" 0 FMMinAge AR.minageInit AR.minageFromQuery
+ , f R "Medium" 0 FMMedium AS.init AS.mediumFromQuery
+ , f R "Voiced" 0 FMVoiced AS.init AS.voicedFromQuery
+ , f R "Ero animation" 0 FMAniEro AS.init (AS.animatedFromQuery False)
+ , f R "Story animation" 0 FMAniStory AS.init (AS.animatedFromQuery True)
+ , f R "Engine" 0 FMEngine AEng.init AEng.fromQuery
+ , f R "DRM implementation" 0 FMDRMType ADRM.init ADRM.fromQuery
+ , f R "External links" 0 FMRExtLinks AS.init (AS.extlinkFromQuery 19)
+ , f R "My List" 0 FMRList AS.init AS.rlistFromQuery
+ -- Deprecated
+ , f R "" 0 FMDeveloper AP.init (AP.fromQuery 6)
+ , f R "" 0 FMProducer AP.init (AP.fromQuery 17)
+
+
+ , n C C "And/Or"
+ , n C S "Voice Actor »"
+ , n C V "Visual Novel »"
+ , f C "Role" 1 FMRole AS.init AS.roleFromQuery
+ , f C "Age" 0 FMAge AR.ageInit AR.ageFromQuery
+ , f C "Birthday" 0 FMBirthday AB.init AB.fromQuery
+ , f C "Sex" 2 FMSex (AS.sexInit False) (AS.sexFromQuery False)
+ , f C "" 0 FMSex (AS.sexInit True) (AS.sexFromQuery True)
+ , f C "Traits" 3 FMTrait AI.init (AI.fromQuery -1 True False)
+ , f C "" 0 FMTrait AI.init (AI.fromQuery 0 True False)
+ , f C "" 0 FMTrait AI.init (AI.fromQuery 1 True False)
+ , f C "" 0 FMTrait AI.init (AI.fromQuery 2 True False)
+ , f C "" 0 FMTrait AI.init (AI.fromQuery 2 True True)
+ , f C "" 0 FMTrait AI.init (AI.fromQuery 0 False False)
+ , f C "" 0 FMTrait AI.init (AI.fromQuery 1 False False)
+ , f C "" 0 FMTrait AI.init (AI.fromQuery 2 False False)
+ , f C "" 0 FMTrait AI.init (AI.fromQuery 2 False True)
+ , f C "Blood type" 0 FMBlood AS.init AS.bloodFromQuery
+ , f C "Height" 0 FMHeight AR.heightInit AR.heightFromQuery
+ , f C "Weight" 0 FMWeight AR.weightInit AR.weightFromQuery
+ , f C "Bust" 0 FMBust AR.bustInit AR.bustFromQuery
+ , f C "Waist" 0 FMWaist AR.waistInit AR.waistFromQuery
+ , f C "Hips" 0 FMHips AR.hipsInit AR.hipsFromQuery
+ , f C "Cup size" 0 FMCup AR.cupInit AR.cupFromQuery
+
+ , n S S "And/Or"
+ , f S "Name" 0 FMStaff AT.init AT.fromQuery
+ , f S "Language" 1 FMLang (AS.langInit AS.LangStaff) (AS.langFromQuery AS.LangStaff)
+ , f S "Gender" 2 FMGender AS.init AS.genderFromQuery
+ , f S "Role" 3 FMSRole AS.init AS.sroleFromQuery
+ , f S "External links" 0 FMSExtLinks AS.init (AS.extlinkFromQuery 6)
+
+ , n P P "And/Or"
+ , f P "Name" 0 FMProdId AP.init (AP.fromQuery 3)
+ , f P "Language" 1 FMLang (AS.langInit AS.LangProd) (AS.langFromQuery AS.LangProd)
+ , f P "Type" 2 FMPType AS.init AS.ptypeFromQuery
+ ]
+
+
+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)
+
+ -- Called when opening a dropdown, can be used to focus an input element
+ focus =
+ case model of
+ FMTag m -> Cmd.map FSTag (A.refocus m.conf)
+ FMTrait m -> Cmd.map FSTrait (A.refocus m.conf)
+ FMProdId m -> Cmd.map FSProdId (A.refocus m.conf)
+ FMProducer m -> Cmd.map FSProducer (A.refocus m.conf)
+ FMDeveloper m -> Cmd.map FSDeveloper (A.refocus m.conf)
+ FMStaff m -> Cmd.map FSStaff (A.refocus m.conf)
+ FMAnime m -> Cmd.map FSAnime (A.refocus m.conf)
+ FMResolution m -> Cmd.map FSResolution (A.refocus m.conf)
+ FMEngine m -> Cmd.map FSEngine (A.refocus m.conf)
+ FMDRMType m -> Cmd.map FSDRMType (A.refocus m.conf)
+ _ -> 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 }
+ addGrandFields = List.take parentNum grandModel.fields ++ newFields ++ List.drop parentNum grandModel.fields
+ newGrandFields =
+ if List.isEmpty newParentModel.fields
+ then delidx (parentNum+1) addGrandFields
+ else modidx (parentNum+1) (\(pid,pdd,_) -> (pid,pdd,FMNest newParentModel)) addGrandFields
+ newGrandModel = { grandModel | fields = newGrandFields }
+ in (dat, (num,dd,FMNest newGrandModel), Cmd.none)
+ _ -> noop
+
+ -- Move root node to sub; for child nodes this is handled in nestUpdate, but the root node must be handled separately
+ (FMoveSub, FMNest m) ->
+ let subfields = [(num,DD.toggle dd False,model)]
+ (ndat,subm) = nestInit True m.qtype m.qtype subfields dat
+ (ndat2,subf) = fieldCreate -1 (ndat, FMNest subm)
+ in (ndat2, subf, Cmd.none)
+
+ (FSNest (NAnd a b), FMNest m) -> mapc FMNest FSNest (nestUpdate dat (NAnd a b) m)
+ (FSNest (NNeg a b), FMNest m) -> mapc FMNest FSNest (nestUpdate dat (NNeg a b) m)
+ (FSNest msg, FMNest m) -> mapf FMNest FSNest (nestUpdate dat msg m)
+ (FSList msg, FMList m) -> (dat, (num,DD.toggle dd False,FMList { m | val = msg }), Cmd.none)
+ (FSLang msg, FMLang m) -> maps FMLang (AS.langUpdate msg m)
+ (FSRPlatform msg,FMRPlatform m)-> maps FMRPlatform(AS.update msg m)
+ (FSVPlatform msg,FMVPlatform m)-> maps FMVPlatform(AS.update msg m)
+ (FSLength msg, FMLength m) -> maps FMLength (AS.update msg m)
+ (FSDevStatus msg,FMDevStatus m)-> maps FMDevStatus(AS.update msg m)
+ (FSRole msg, FMRole m) -> maps FMRole (AS.update msg m)
+ (FSBlood msg, FMBlood m) -> maps FMBlood (AS.update msg m)
+ (FSSex msg, FMSex m) -> maps FMSex (AS.sexUpdate msg m)
+ (FSGender msg, FMGender m) -> maps FMGender (AS.update msg m)
+ (FSMedium msg, FMMedium m) -> maps FMMedium (AS.update msg m)
+ (FSVoiced msg, FMVoiced m) -> maps FMVoiced (AS.update msg m)
+ (FSAniEro msg, FMAniEro m) -> maps FMAniEro (AS.update msg m)
+ (FSAniStory msg, FMAniStory m) -> maps FMAniStory (AS.update msg m)
+ (FSRType msg, FMRType m) -> maps FMRType (AS.update msg m)
+ (FSLabel msg, FMLabel m) -> maps FMLabel (AS.update msg m)
+ (FSRList msg, FMRList m) -> maps FMRList (AS.update msg m)
+ (FSSRole msg, FMSRole m) -> maps FMSRole (AS.update msg m)
+ (FSPType msg, FMPType m) -> maps FMPType (AS.update msg m)
+ (FSRExtLinks msg,FMRExtLinks m)-> maps FMRExtLinks (AS.update msg m)
+ (FSSExtLinks msg,FMSExtLinks m)-> maps FMSExtLinks (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)
+ (FSMinAge msg ,FMMinAge m) -> maps FMMinAge (AR.update msg m)
+ (FSProdId msg, FMProdId m) -> mapf FMProdId FSProdId (AP.update dat msg m)
+ (FSProducer msg, FMProducer m) -> mapf FMProducer FSProducer (AP.update dat msg m)
+ (FSDeveloper msg,FMDeveloper m)-> mapf FMDeveloper FSDeveloper (AP.update dat msg m)
+ (FSStaff msg, FMStaff m) -> mapf FMStaff FSStaff (AT.update dat msg m)
+ (FSAnime msg, FMAnime m) -> mapf FMAnime FSAnime (AA.update dat msg m)
+ (FSRDate msg, FMRDate m) -> maps FMRDate (AD.update msg m)
+ (FSResolution msg,FMResolution m)->mapf FMResolution FSResolution (AE.update dat msg m)
+ (FSEngine msg, FMEngine m) -> mapf FMEngine FSEngine (AEng.update dat msg m)
+ (FSDRMType msg, FMDRMType m) -> mapf FMDRMType FSDRMType (ADRM.update dat msg m)
+ (FSTag msg, FMTag m) -> mapf FMTag FSTag (AG.update dat msg m)
+ (FSTrait msg, FMTrait m) -> mapf FMTrait FSTrait (AI.update dat msg m)
+ (FSBirthday msg, FMBirthday m) -> maps FMBirthday (AB.update msg m)
+ (FToggle b, _) -> (dat, (num, DD.toggle dd b, model), if b then focus else Cmd.none)
+ _ -> noop
+
+
+fieldViewDd : Data -> DD.Config FieldMsg -> Html FieldMsg -> (() -> List (Html FieldMsg)) -> Html FieldMsg
+fieldViewDd dat dd lbl cont =
+ div [ class "elm_dd_input" ]
+ [ DD.view dd Api.Normal lbl <| \() ->
+ div [ class "advbut" ]
+ [ if dat.level == 0
+ then small [ title "Can't delete the top-level filter" ] [ text "⊗" ]
+ else a [ href "#", onClickD FDel, title "Delete this filter" ] [ text "⊗" ]
+ , if dat.level <= 1
+ then small [ title "Can't move this filter to parent branch" ] [ text "↰" ]
+ else a [ href "#", onClickD FMovePar, title "Move this filter to parent branch" ] [ text "↰" ]
+ , a [ href "#", onClickD FMoveSub, title "Create new branch for this filter" ] [ text "↳" ]
+ ] :: cont ()
+ ]
+
+fieldView : Data -> Field -> Html FieldMsg
+fieldView dat (_, dd, model) =
+ let f wrap (lbl,cont) = fieldViewDd dat dd (Html.map wrap lbl) <| \() -> List.map (Html.map wrap) (cont ())
+ l m = ( span [ class "nowrap" ] [ text <| Maybe.withDefault "" <| Maybe.map Tuple.second <| List.head <| List.drop m.val m.lst ]
+ , \() -> [ ul [] <| List.indexedMap (\n (_,v) -> li [] [ linkRadio (n == m.val) (\_ -> n) [ text v ] ]) m.lst ]
+ )
+ in case model of
+ FMCustom m -> f FSCustom (text "Unrecognized query", \() -> [text ""]) -- TODO: Display the Query
+ FMList m -> f FSList (l m)
+ FMLang m -> f FSLang (AS.langView m)
+ FMVPlatform m -> f FSVPlatform (AS.platformView False m)
+ FMRPlatform m -> f FSRPlatform (AS.platformView True m)
+ FMLength m -> f FSLength (AS.lengthView m)
+ FMDevStatus m -> f FSDevStatus (AS.devStatusView m)
+ FMRole m -> f FSRole (AS.roleView m)
+ FMBlood m -> f FSBlood (AS.bloodView m)
+ FMSex m -> f FSSex (AS.sexView m)
+ FMGender m -> f FSGender (AS.genderView m)
+ FMMedium m -> f FSMedium (AS.mediumView m)
+ FMVoiced m -> f FSVoiced (AS.voicedView m)
+ FMAniEro m -> f FSAniEro (AS.animatedView False m)
+ FMAniStory m -> f FSAniStory (AS.animatedView True m)
+ FMRType m -> f FSRType (AS.rtypeView m)
+ FMLabel m -> f FSLabel (AS.labelView dat m)
+ FMRList m -> f FSRList (AS.rlistView m)
+ FMSRole m -> f FSSRole (AS.sroleView m)
+ FMPType m -> f FSPType (AS.ptypeView m)
+ FMRExtLinks m -> f FSRExtLinks (AS.extlinkView GEL.releaseSites m)
+ FMSExtLinks m -> f FSSExtLinks (AS.extlinkView GEL.staffSites m)
+ FMHeight m -> f FSHeight (AR.heightView m)
+ FMWeight m -> f FSWeight (AR.weightView m)
+ FMBust m -> f FSBust (AR.bustView m)
+ FMWaist m -> f FSWaist (AR.waistView m)
+ FMHips m -> f FSHips (AR.hipsView m)
+ FMCup m -> f FSCup (AR.cupView m)
+ FMAge m -> f FSAge (AR.ageView m)
+ FMPopularity m -> f FSPopularity (AR.popularityView m)
+ FMRating m -> f FSRating (AR.ratingView m)
+ FMVotecount m -> f FSVotecount (AR.votecountView m)
+ FMMinAge m -> f FSMinAge (AR.minageView m)
+ FMProdId m -> f FSProdId (AP.view "Name" dat m)
+ FMProducer m -> f FSProducer (AP.view "Producer" dat m)
+ FMDeveloper m -> f FSDeveloper (AP.view "Developer" dat m)
+ FMStaff m -> f FSStaff (AT.view dat m)
+ FMAnime m -> f FSAnime (AA.view dat m)
+ FMRDate m -> f FSRDate (AD.view m)
+ FMResolution m -> f FSResolution (AE.view m)
+ FMEngine m -> f FSEngine (AEng.view m)
+ FMDRMType m -> f FSDRMType (ADRM.view m)
+ FMTag m -> f FSTag (AG.view dat m)
+ FMTrait m -> f FSTrait (AI.view dat m)
+ FMBirthday m -> f FSBirthday (AB.view m)
+ FMNest m -> nestView dat dd m
+
+
+fieldToQuery : Data -> Field -> Maybe Query
+fieldToQuery dat (_, _, model) =
+ case model of
+ FMCustom m -> Just m
+ FMList m -> List.drop m.val m.lst |> List.head |> Maybe.map Tuple.first
+ FMNest m -> nestToQuery dat m
+ FMLang m -> AS.langToQuery m
+ FMRPlatform m-> AS.toQuery (QStr 4) m
+ FMVPlatform m-> AS.toQuery (QStr 4) m
+ FMLength m -> AS.toQuery (QInt 5) m
+ FMDevStatus m-> AS.toQuery (QInt 66) m
+ FMRole m -> AS.toQuery (QStr 2) m
+ FMBlood m -> AS.toQuery (QStr 3) m
+ FMSex (s,m) -> AS.toQuery (QStr (if s then 5 else 4)) m
+ FMGender m -> AS.toQuery (QStr 4) m
+ FMMedium m -> AS.toQuery (QStr 11) m
+ FMVoiced m -> AS.toQuery (QInt 12) m
+ FMAniEro m -> AS.toQuery (QInt 13) m
+ FMAniStory m -> AS.toQuery (QInt 14) m
+ FMRType m -> AS.toQuery (QStr 16) m
+ FMLabel m -> AS.toQuery (\op v -> QTuple 12 op (Maybe.withDefault 0 (Maybe.map vndbidNum dat.uid)) v) m
+ FMRList m -> AS.toQuery (QInt 18) m
+ FMSRole m -> AS.toQuery (QStr 5) m
+ FMPType m -> AS.toQuery (QStr 4) m
+ FMRExtLinks m-> AS.toQuery (QStr 19) m
+ FMSExtLinks m-> AS.toQuery (QStr 6) m
+ FMHeight m -> AR.toQuery (QInt 6) (QStr 6) m
+ FMWeight m -> AR.toQuery (QInt 7) (QStr 7) m
+ FMBust m -> AR.toQuery (QInt 8) (QStr 8) m
+ FMWaist m -> AR.toQuery (QInt 9) (QStr 9) m
+ FMHips m -> AR.toQuery (QInt 10) (QStr 10) m
+ FMCup m -> AR.toQuery (QStr 11) (QStr 11) m
+ FMAge m -> AR.toQuery (QInt 12) (QStr 12) m
+ FMPopularity m->AR.toQuery (QInt 9) (QStr 9) m
+ FMRating m -> AR.toQuery (QInt 10) (QStr 10) m
+ FMVotecount m-> AR.toQuery (QInt 11) (QStr 11) m
+ FMMinAge m -> AR.toQuery (QInt 10) (QStr 10) m
+ FMProdId m -> AP.toQuery 3 m
+ FMProducer m -> AP.toQuery 17 m
+ FMDeveloper m-> AP.toQuery 6 m
+ FMStaff m -> AT.toQuery m
+ FMAnime m -> AA.toQuery m
+ FMRDate m -> AD.toQuery m
+ FMResolution m-> AE.toQuery m
+ FMEngine m -> AEng.toQuery m
+ FMDRMType m -> ADRM.toQuery m
+ FMTag m -> AG.toQuery m
+ FMTrait m -> AI.toQuery m
+ FMBirthday m -> AB.toQuery m
+
+
+fieldCreate : Int -> (Data,FieldModel) -> (Data,Field)
+fieldCreate fid (dat,fm) =
+ ( {dat | objid = dat.objid + 1}
+ , (fid, DD.init ("xsearch_field" ++ String.fromInt dat.objid) FToggle, fm)
+ )
+
+
+fieldInit : Int -> Data -> (Data,Field)
+fieldInit n dat =
+ case Array.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, _) =
+ Array.foldr (\f (af,n) ->
+ case (if af /= Nothing || f.ptype /= qtype then Nothing else f.fromQuery dat q) of
+ Nothing -> (af,n-1)
+ Just ret -> (Just (fieldCreate n ret), 0)
+ ) (Nothing,Array.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
+ :: DD.sub m.addDd
+ :: DD.sub m.andDd
+ :: List.indexedMap (\i -> Sub.map (FSNest << NField i) << fieldSub) m.fields
+ _ -> DD.sub dd
diff --git a/elm/AdvSearch/Lib.elm b/elm/AdvSearch/Lib.elm
new file mode 100644
index 00000000..2841acce
--- /dev/null
+++ b/elm/AdvSearch/Lib.elm
@@ -0,0 +1,185 @@
+module AdvSearch.Lib exposing (..)
+
+import Json.Encode as JE
+import Json.Decode as JD
+import Html
+import Html.Attributes
+import Lib.Html
+import Dict
+import Set
+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 | S | P
+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
+
+
+showQType : QType -> String
+showQType q =
+ case q of
+ V -> "v"
+ R -> "r"
+ C -> "c"
+ S -> "s"
+ P -> "p"
+
+showOp : Op -> String
+showOp op =
+ case op of
+ Eq -> "="
+ Ne -> "≠"
+ Le -> "≤"
+ Lt -> "<"
+ Ge -> "≥"
+ Gt -> ">"
+
+
+inputOp : Bool -> Op -> (Op -> a) -> Html.Html a
+inputOp onlyEq val msg =
+ Html.div [ Html.Attributes.class "opselect" ] <|
+ List.map (\op ->
+ if val == op then Html.strong [] [ Html.text (showOp op) ] else Html.a [ Html.Attributes.href "#", Lib.Html.onClickD (msg op) ] [ Html.text (showOp op) ]
+ ) <| if onlyEq then [Eq, Ne] else [Eq, Ne, Ge, Gt, Le, Lt]
+
+
+-- Global data that's passed around for Fields
+type alias Data =
+ { objid : Int -- Incremental integer for global identifiers
+ , level : Int -- Nesting level of the field being processed
+ , parentTypes : Set.Set String -- Only used for 'view' functions: query types that the current field is a subquery of
+ , uid : Maybe String
+ , labels : List (Int, String)
+ , defaultSpoil : Int
+ , producers : Dict.Dict String GApi.ApiProducerResult
+ , staff : Dict.Dict String GApi.ApiStaffResult
+ , tags : Dict.Dict String GApi.ApiTagResult
+ , traits : Dict.Dict String GApi.ApiTraitResult
+ , anime : Dict.Dict Int GApi.ApiAnimeResult
+ }
diff --git a/elm/AdvSearch/Main.elm b/elm/AdvSearch/Main.elm
new file mode 100644
index 00000000..31331692
--- /dev/null
+++ b/elm/AdvSearch/Main.elm
@@ -0,0 +1,267 @@
+module AdvSearch.Main exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Set
+import Dict
+import Task
+import Browser.Dom as Dom
+import Array as Array
+import Json.Encode as JE
+import Json.Decode as JD
+import Gen.Api as GApi
+import Gen.AdvSearchSave as GASS
+import Gen.AdvSearchDel as GASD
+import Lib.Html exposing (..)
+import Lib.Api as Api
+import Lib.Ffi as Ffi
+import Lib.DropDown as DD
+import Lib.Autocomplete as A
+import AdvSearch.Lib 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.batch [ DD.sub m.saveDd, Sub.map Field (fieldSub m.query) ]
+ }
+
+type alias SQuery = { name: String, query: String }
+type alias Recv =
+ { uid : Maybe String
+ , labels : List { id: Int, label: String }
+ , defaultSpoil : Int
+ , saved : List SQuery
+ , error : Bool
+ , query : GApi.ApiAdvSearchQuery
+ }
+
+type SaveAct = Save | Load | Delete | Default
+
+type alias Model =
+ { query : Field
+ , qtype : QType
+ , data : Data
+ , error : Bool
+ , saved : List SQuery
+ , saveState : Api.State
+ , saveDd : DD.Config Msg
+ , saveAct : SaveAct
+ , saveName : String
+ , saveDel : Set.Set String
+ , loadQuery : Maybe String
+ }
+
+type Msg
+ = Noop
+ | Field FieldMsg
+ | SaveToggle Bool
+ | SaveAct SaveAct
+ | SaveName String
+ | SaveSave String
+ | SaveSaved SQuery GApi.Response
+ | SaveLoad String
+ | SaveDelSel String
+ | SaveDel (Set.Set String)
+ | SaveDeleted (Set.Set String) GApi.Response
+
+
+-- If the query only contains "quick" selection fields, add the remaining quick fields and sort them.
+normalize : QType -> Field -> Data -> (Field, Data)
+normalize qtype query odat =
+ let quickFromId (n,_,_) = Array.get n fields |> Maybe.map (\f -> abs f.quick) |> Maybe.withDefault 0
+ present = List.foldl (\f a -> Set.insert (quickFromId f) a) Set.empty
+ defaults pres = Array.foldl (\f (al,dat,an) ->
+ if f.qtype == qtype && f.quick > 0 && not (Set.member (abs f.quick) pres)
+ then let (ndat, nf) = fieldInit an dat
+ in (nf::al, ndat, an+1)
+ else (al,dat,an+1)
+ ) ([],odat,0) fields
+ cmp a b = compare (quickFromId a) (quickFromId b)
+ in case query of
+ (qid, qdd, FMNest qm) ->
+ let pres = present qm.fields
+ (nl, ndat, _) = defaults pres
+ nqm = { qm | fields = List.sortWith cmp (nl++qm.fields) }
+ in if Set.member 0 pres || List.length nqm.fields > 4 then (query, odat) else ((qid, qdd, FMNest nqm), ndat)
+ _ -> (query, odat)
+
+
+loadQuery : Data -> GApi.ApiAdvSearchQuery -> (QType, Field, Data)
+loadQuery odat arg =
+ let dat = { objid = 0
+ , level = 0
+ , parentTypes = Set.empty
+ , uid = odat.uid
+ , labels = odat.labels
+ , defaultSpoil = odat.defaultSpoil
+ , producers = Dict.union (Dict.fromList <| List.map (\p -> (p.id,p)) <| arg.producers) odat.producers
+ , staff = Dict.union (Dict.fromList <| List.map (\s -> (s.id,s)) <| arg.staff ) odat.staff
+ , tags = Dict.union (Dict.fromList <| List.map (\t -> (t.id,t)) <| arg.tags ) odat.tags
+ , traits = Dict.union (Dict.fromList <| List.map (\t -> (t.id,t)) <| arg.traits ) odat.traits
+ , anime = Dict.union (Dict.fromList <| List.map (\a -> (a.id,a)) <| arg.anime ) odat.anime
+ }
+ qtype =
+ case arg.qtype of
+ "v" -> V
+ "c" -> C
+ "s" -> S
+ "p" -> P
+ _ -> R
+
+ (dat2, 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 True qtype qtype [query] dat2)) in m
+ query2 = case query of
+ (_,_,FMNest m) -> if m.qtype == qtype then query else addtoplvl
+ _ -> addtoplvl
+ dat3 = { dat2 | objid = dat2.objid + 5 } -- +5 for the creation of query2
+
+ (query3, dat4) = normalize qtype query2 dat3
+ in (qtype, query3, dat4)
+
+
+init : Recv -> Model
+init arg =
+ let dat = { objid = 0
+ , level = 0
+ , parentTypes = Set.empty
+ , uid = arg.uid
+ , labels = (0, "Unlabeled") :: List.map (\e -> (e.id, e.label)) arg.labels
+ , defaultSpoil = arg.defaultSpoil
+ , producers = Dict.empty
+ , staff = Dict.empty
+ , tags = Dict.empty
+ , traits = Dict.empty
+ , anime = Dict.empty
+ }
+ (qtype, query, ndat) = loadQuery dat arg.query
+ in { query = query
+ , qtype = qtype
+ , data = ndat
+ , error = arg.error
+ , saved = arg.saved
+ , saveState = Api.Normal
+ , saveDd = DD.init "xsearch_save" SaveToggle
+ , saveAct = Save
+ , saveName = ""
+ , saveDel = Set.empty
+ , loadQuery = Nothing
+ }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Noop -> (model, Cmd.none)
+ Field m ->
+ let (ndat, nm, nc) = fieldUpdate model.data m model.query
+ in ({ model | data = ndat, query = nm, error = False }, Cmd.map Field nc)
+ SaveToggle b ->
+ let act = if model.saveAct == Save && not (List.isEmpty model.saved) && fieldToQuery model.data model.query == Nothing then Load else model.saveAct
+ in ( { model | saveDd = DD.toggle model.saveDd b, saveAct = act, saveDel = Set.empty }
+ , if b && act == Save then Task.attempt (always Noop) (Dom.focus "xsearch_saveinput") else Cmd.none)
+ SaveAct n -> ({ model | saveAct = n, saveDel = Set.empty }, Cmd.none)
+ SaveName n -> ({ model | saveName = n }, Cmd.none)
+ SaveSave s ->
+ case Maybe.map encQuery (fieldToQuery model.data model.query) of
+ Just q -> ({ model | saveState = Api.Loading }, GASS.send { name = s, qtype = showQType model.qtype, query = q } (SaveSaved { name = s, query = q }) )
+ Nothing -> (model, Cmd.none)
+ SaveSaved q GApi.Success ->
+ let f rep lst = case lst of
+ (x::xs) ->
+ if x.name == q.name then q :: f True xs
+ else if not rep && x.name > q.name then q :: x :: f True xs
+ else x :: f rep xs
+ [] -> if rep then [] else [q]
+ in ({ model | saveState = Api.Normal, saveDd = DD.toggle model.saveDd False, saved = f False model.saved }, Cmd.none)
+ SaveSaved _ e -> ({ model | saveState = Api.Error e }, Cmd.none)
+ SaveLoad q -> ({ model | saveState = Api.Loading, saveDd = DD.toggle model.saveDd False, loadQuery = Just q }, Task.attempt (always Noop) (Ffi.elemCall "click" "advsubmit"))
+ SaveDelSel s -> ({ model | saveDel = (if Set.member s model.saveDel then Set.remove else Set.insert) s model.saveDel }, Cmd.none)
+ SaveDel d -> ({ model | saveState = Api.Loading }, GASD.send { qtype = showQType model.qtype, name = Set.toList d } (SaveDeleted d))
+ SaveDeleted d GApi.Success -> ({ model | saveState = Api.Normal, saveDel = Set.empty, saved = List.filter (\e -> not (Set.member e.name d)) model.saved }, Cmd.none)
+ SaveDeleted _ e -> ({ model | saveState = Api.Error e }, Cmd.none)
+
+
+saveIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z\"></path><polyline points=\"17 21 17 13 7 13 7 21\"></polyline><polyline points=\"7 3 7 8 15 8\"></polyline></g></svg>"
+
+view : Model -> Html Msg
+view model = div [ class "xsearch" ] <|
+ let encQ = Maybe.withDefault "" <| Maybe.map encQuery (fieldToQuery model.data model.query)
+ in
+ [ input [ type_ "hidden", id "f", name "f", value (Maybe.withDefault encQ model.loadQuery) ] []
+ , input [ type_ "submit", id "advsubmit", class "hidden" ] []
+ , if model.data.uid == Nothing then text "" else div [ class "elm_dd_input elm_dd_noarrow elm_dd_rightish short" ]
+ [ DD.view model.saveDd model.saveState (span [ Ffi.innerHtml saveIcon ] []) <| \() ->
+ [ div [ class "advheader", style "min-width" "300px" ]
+ [ div [ class "opts", style "margin-bottom" "5px" ]
+ [ if model.saveAct == Save then strong [] [ text "Save" ] else a [ href "#", onClickD (SaveAct Save ) ] [ text "Save" ]
+ , if model.saveAct == Load then strong [] [ text "Load" ] else a [ href "#", onClickD (SaveAct Load ) ] [ text "Load" ]
+ , if model.saveAct == Delete then strong [] [ text "Delete" ] else a [ href "#", onClickD (SaveAct Delete ) ] [ text "Delete" ]
+ , if model.saveAct == Default then strong [] [ text "Default"] else a [ href "#", onClickD (SaveAct Default) ] [ text "Default" ]
+ ]
+ , h3 [] [ text <| case model.saveAct of
+ Save -> "Save current filter"
+ Load -> "Load filter"
+ Delete -> "Delete saved filter"
+ Default -> "Default filter" ]
+ ]
+ , case (List.filter (\e -> e.name /= "") model.saved, model.saveAct) of
+ (_, Save) ->
+ if encQ == "" then text "Nothing to save." else
+ form_ "" (SaveSave model.saveName) False
+ [ inputText "xsearch_saveinput" model.saveName SaveName [ required True, maxlength 50, placeholder "Name...", style "width" "290px" ]
+ , if model.saveName /= "" && List.any (\e -> e.name == model.saveName) model.saved
+ then text "You already have a filter by that name, click save to overwrite it."
+ else text ""
+ , submitButton "Save" model.saveState True
+ ]
+ (_, Default) ->
+ div []
+ [ p [ class "center", style "padding" "0px 5px" ] <|
+ case model.qtype of
+ V -> [ text "You can set a default filter that will be applied automatically to most listings on the site,"
+ , text " this includes the \"Random visual novel\" button, lists on the homepage, tag pages, etc."
+ , text " This feature is mainly useful to filter out tags, languages or platforms that you are not interested in seeing."
+ ]
+ R -> [ text "You can set a default filter that will be applied automatically to this release browser and the listings on the homepage."
+ , text " This feature is mainly useful to filter out tags, languages or platforms that you are not interested in seeing."
+ ]
+ _ -> [ text "You can set a default filter that will be applied automatically when you open this listing." ]
+ , br [] []
+ , case List.filter (\e -> e.name == "") model.saved of
+ [d] -> span []
+ [ inputButton "Load my default filters" (SaveLoad d.query) [style "width" "100%"]
+ , br [] []
+ , br [] []
+ , inputButton "Delete my default filters" (SaveDel (Set.fromList [""])) [style "width" "100%"]
+ ]
+ _ -> text "You don't have a default filter set."
+ , if encQ /= "" then inputButton "Save current filters as default" (SaveSave "") [ style "width" "100%" ] else text ""
+ ]
+ ([], _) -> text "You don't have any saved queries."
+ (l, Load) ->
+ div []
+ [ if encQ == "" || List.any (\e -> encQ == e.query) l
+ then text "" else text "Unsaved changes will be lost when loading a saved filter."
+ , ul [] <| List.map (\e -> li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ] [ a [ href "#", onClickD (SaveLoad e.query) ] [ text e.name ] ]) l
+ ]
+ (l, Delete) ->
+ div []
+ [ ul [] <| List.map (\e -> li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ] [ linkRadio (Set.member e.name model.saveDel) (always (SaveDelSel e.name)) [ text e.name ] ]) l
+ , inputButton "Delete selected" (SaveDel model.saveDel) [ disabled (Set.isEmpty model.saveDel) ]
+ ]
+ ]
+ ]
+ , Html.map Field (fieldView model.data model.query)
+ , if model.error
+ then b [] [ text "Error parsing search query. The URL was probably corrupted in some way. "
+ , text "Please report a bug if you opened this page from VNDB (as opposed to getting here from an external site)." ]
+ else text ""
+ ]
diff --git a/elm/AdvSearch/Producers.elm b/elm/AdvSearch/Producers.elm
new file mode 100644
index 00000000..5d34aeb0
--- /dev/null
+++ b/elm/AdvSearch/Producers.elm
@@ -0,0 +1,93 @@
+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.Lib 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 = "xsearch_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 (vndbidNum 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 (vndbidNum p.id) True) model.sel }
+ , c )
+
+
+toQuery n m = S.toQuery (QInt n) m.sel
+
+fromQuery n dat qf = S.fromQuery (\q ->
+ case q of
+ QInt id op v -> if id == n then Just (op, v) else Nothing
+ _ -> Nothing) dat qf
+ |> Maybe.map (\(ndat,sel) ->
+ ( { ndat | objid = ndat.objid+1 }
+ , { sel = { sel | single = False }
+ , conf = { wrap = Search, id = "xsearch_prod" ++ String.fromInt ndat.objid, source = A.producerSource }
+ , search = A.init ""
+ }
+ ))
+
+
+
+view : String -> Data -> Model -> (Html Msg, () -> List (Html Msg))
+view lbl dat model =
+ ( case Set.toList model.sel.sel of
+ [] -> small [] [ text lbl ]
+ [s] -> span [ class "nowrap" ]
+ [ S.lblPrefix model.sel
+ , small [] [ text <| "p" ++ String.fromInt s ++ ":" ]
+ , Dict.get (vndbid 'p' s) dat.producers |> Maybe.map (\p -> p.name) |> Maybe.withDefault "" |> text
+ ]
+ l -> span [] [ S.lblPrefix model.sel, text <| lbl ++ "s (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Producer identifier" ]
+ , Html.map Sel (S.opts model.sel False True)
+ ]
+ , ul [] <| List.map (\s ->
+ li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ]
+ [ inputButton "X" (Sel (S.Sel s False)) []
+ , small [] [ text <| " p" ++ String.fromInt s ++ ": " ]
+ , Dict.get (vndbid 'p' s) dat.producers |> Maybe.map (\p -> a [ href ("/" ++ p.id), target "_blank", style "display" "inline" ] [ text p.name ]) |> Maybe.withDefault (text "")
+ ]
+ ) (Set.toList model.sel.sel)
+ , A.view model.conf model.search [ placeholder "Search..." ]
+ ]
+ )
diff --git a/elm/AdvSearch/RDate.elm b/elm/AdvSearch/RDate.elm
new file mode 100644
index 00000000..7dc6f88b
--- /dev/null
+++ b/elm/AdvSearch/RDate.elm
@@ -0,0 +1,99 @@
+module AdvSearch.RDate exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Lib.Html exposing (..)
+import Lib.RDate as R
+import AdvSearch.Lib exposing (..)
+
+
+type alias Model =
+ { op : Op
+ , fuzzy : Bool
+ , date : R.RDate
+ }
+
+
+type Msg
+ = MOp Op
+ | Fuzzy Bool
+ | Date R.RDate
+
+
+onlyEq : Int -> Bool
+onlyEq d = d == 99999999 || d == 0
+
+
+update : Msg -> Model -> Model
+update msg model =
+ case msg of
+ MOp o -> { model | op = o }
+ Fuzzy f -> { model | fuzzy = f }
+ Date d -> { model | op = if onlyEq d && model.op /= Eq && model.op /= Ne then Eq else model.op, 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" ]
+ [ inputOp (onlyEq model.date) model.op MOp
+ , if (R.expand model.date).d /= 99 || model.date == 99999999 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..89ab3a16
--- /dev/null
+++ b/elm/AdvSearch/Range.elm
@@ -0,0 +1,215 @@
+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.Lib exposing (..)
+
+
+type alias Model a =
+ { op : Op
+ , val : Int
+ , unk : Bool
+ , lst : Array.Array a
+ }
+
+
+type Msg
+ = MOp Op
+ | Val String
+ | Unknown Bool
+
+
+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) }
+ Unknown b -> { model | unk = b, op = if b && model.op /= Ne && model.op /= Eq then Eq else model.op }
+
+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, unk = False }))
+
+fromQueryUnk : (Data, Model comparable) -> Op -> Maybe (Data, Model comparable)
+fromQueryUnk (dat,m) op = Just (dat, { m | unk = True, op = if op == Eq then Eq else Ne })
+
+toQuery : (Op -> a -> Query) -> (Op -> String -> Query) -> Model a -> Maybe Query
+toQuery k u m = if m.unk then Just (u m.op "") else Array.get m.val m.lst |> Maybe.map (\v -> k m.op v)
+
+view : Bool -> String -> (a -> String) -> Model a -> (Html Msg, () -> List (Html Msg))
+view canUnk lbl fmt model =
+ let val n = Array.get n model.lst |> Maybe.map fmt |> Maybe.withDefault ""
+ in
+ ( span [ class "nowrap" ] [ text <| lbl ++ " " ++ showOp model.op ++ " " ++ if model.unk then "Unknown" else val model.val ]
+ , \() ->
+ [ div [ class "advheader", style "width" "290px" ]
+ [ h3 [] [ text lbl ]
+ , div [ class "opts" ]
+ [ inputOp model.unk model.op MOp
+ , if canUnk then linkRadio model.unk Unknown [text "Unknown"] else text ""
+ ]
+ ]
+ , if model.unk
+ then p [ class "center" ] [ text <| lbl ++ " is " ++ (if model.op /= Eq then "known/set." else "unknown/unset.") ]
+ else
+ div [ style "display" "flex", style "justify-content" "space-between", style "margin-top" "5px" ]
+ [ small [] [ text (val 0) ]
+ , strong [] [ text (val model.val) ]
+ , small [] [ text (val (Array.length model.lst - 1)) ]
+ ]
+ , if model.unk then text "" else
+ 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, unk = False, lst = Array.initialize 300 (\n -> n+1) })
+
+heightFromQuery d q =
+ case q of
+ QInt 6 op v -> fromQuery (heightInit d) op v
+ QStr 6 op "" -> fromQueryUnk (heightInit d) op
+ _ -> Nothing
+
+heightView = view True "Height" (\v -> String.fromInt v ++ "cm")
+
+
+
+
+weightInit dat = (dat, { op= Ge, val = 60, unk = False, lst = Array.initialize 401 identity })
+
+weightFromQuery d q =
+ case q of
+ QInt 7 op v -> fromQuery (weightInit d) op v
+ QStr 7 op "" -> fromQueryUnk (weightInit d) op
+ _ -> Nothing
+
+weightView = view True "Weight" (\v -> String.fromInt v ++ "kg")
+
+
+
+
+bustInit dat = (dat, { op = Ge, val = 40, unk = False, lst = Array.initialize 101 (\n -> n+20) })
+
+bustFromQuery d q =
+ case q of
+ QInt 8 op v -> fromQuery (bustInit d) op v
+ QStr 8 op "" -> fromQueryUnk (bustInit d) op
+ _ -> Nothing
+
+bustView = view True "Bust" (\v -> String.fromInt v ++ "cm")
+
+
+
+
+waistInit dat = (dat, { op = Ge, val = 40, unk = False, lst = Array.initialize 101 (\n -> n+20) })
+
+waistFromQuery d q =
+ case q of
+ QInt 9 op v -> fromQuery (waistInit d) op v
+ QStr 9 op "" -> fromQueryUnk (waistInit d) op
+ _ -> Nothing
+
+waistView = view True "Waist" (\v -> String.fromInt v ++ "cm")
+
+
+
+
+hipsInit dat = (dat, { op = Ge, val = 40, unk = False, lst = Array.initialize 101 (\n -> n+20) })
+
+hipsFromQuery d q =
+ case q of
+ QInt 10 op v -> fromQuery (hipsInit d) op v
+ QStr 10 op "" -> fromQueryUnk (hipsInit d) op
+ _ -> Nothing
+
+hipsView = view True "Hips" (\v -> String.fromInt v ++ "cm")
+
+
+
+
+cupInit dat = (dat, { op = Ge, val = 3, unk = False, lst = Array.fromList (List.map Tuple.first (List.drop 1 GT.cupSizes)) })
+
+cupFromQuery d q =
+ case q of
+ QStr 11 op "" -> fromQueryUnk (cupInit d) op
+ QStr 11 op v -> fromQuery (cupInit d) op v
+ _ -> Nothing
+
+cupView = view True "Cup size" identity
+
+
+
+
+ageInit dat = (dat, { op = Ge, val = 17, unk = False, lst = Array.initialize 121 identity })
+
+ageFromQuery d q =
+ case q of
+ QInt 12 op v -> fromQuery (ageInit d) op v
+ QStr 12 op "" -> fromQueryUnk (ageInit d) op
+ _ -> Nothing
+
+ageView = view True "Age" (\v -> if v == 1 then "1 year" else String.fromInt v ++ " years")
+
+
+
+
+popularityInit dat = (dat, { op = Ge, val = 10, unk = False, lst = Array.initialize 101 identity })
+
+popularityFromQuery d q =
+ case q of
+ QInt 9 op v -> fromQuery (popularityInit d) op v
+ _ -> Nothing
+
+popularityView = view False "Popularity" String.fromInt
+
+
+
+
+ratingInit dat = (dat, { op = Ge, val = 40, unk = False, 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 False "Rating" (\v -> Ffi.fmtFloat (toFloat v / 10) 1)
+
+
+
+
+votecountInit dat = (dat, { op = Ge, val = 10, unk = False, 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 False "# Votes" String.fromInt
+
+
+
+
+minageInit dat = (dat, { op = Lt, val = 13, unk = False, lst = Array.fromList <| List.map Tuple.first GT.ageRatings })
+
+minageFromQuery d q =
+ case q of
+ QInt 10 op v -> fromQuery (minageInit d) op v
+ QStr 10 op "" -> fromQueryUnk (minageInit d) op
+ _ -> Nothing
+
+minageView = view True "Age rating" <| \v -> Maybe.withDefault "" <| List.head <| String.split " (" <| Maybe.withDefault "" <| lookup v GT.ageRatings
diff --git a/elm/AdvSearch/Resolution.elm b/elm/AdvSearch/Resolution.elm
new file mode 100644
index 00000000..7617d02c
--- /dev/null
+++ b/elm/AdvSearch/Resolution.elm
@@ -0,0 +1,85 @@
+module AdvSearch.Resolution exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Lib.Autocomplete as A
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Gen.Api as GApi
+import AdvSearch.Lib exposing (..)
+
+
+type alias Model =
+ { op : Op
+ , reso : Maybe (Int,Int)
+ , conf : A.Config Msg GApi.ApiResolutions
+ , search : A.Model GApi.ApiResolutions
+ , aspect : Bool
+ }
+
+
+type Msg
+ = MOp Op
+ | Search (A.Msg GApi.ApiResolutions)
+ | Aspect Bool
+
+
+onlyEq : Maybe (Int,Int) -> Bool
+onlyEq reso = reso == Just (0,0) || reso == Just (0,1)
+
+
+update : Data -> Msg -> Model -> (Data, Model, Cmd Msg)
+update dat msg model =
+ case msg of
+ MOp o -> (dat, { model | op = o, aspect = o /= Eq && o /= Ne && model.aspect }, Cmd.none)
+ Aspect b -> (dat, { model | aspect = b }, Cmd.none)
+ Search m ->
+ let (nm, c, en) = A.update model.conf m model.search
+ search = Maybe.withDefault nm <| Maybe.map (\e -> A.clear nm e.resolution) en
+ reso = resoParse True search.value
+ op = if onlyEq reso && model.op /= Eq && model.op /= Ne then Eq else model.op
+ in (dat, { model | search = search, reso = reso, op = op, aspect = op /= Eq && op /= Ne && model.aspect }, c)
+
+
+init : Data -> (Data, Model)
+init dat =
+ ( { dat | objid = dat.objid+1 }
+ , { op = Ge
+ , reso = Nothing
+ , conf = { wrap = Search, id = "xsearch_reso" ++ String.fromInt dat.objid, source = A.resolutionSource }
+ , search = A.init ""
+ , aspect = False
+ }
+ )
+
+
+toQuery : Model -> Maybe Query
+toQuery model = Maybe.map (\(x,y) -> QTuple (if model.aspect then 9 else 8) model.op x y) model.reso
+
+fromQuery : Data -> Query -> Maybe (Data, Model)
+fromQuery dat q =
+ let m op x y aspect = Just <| Tuple.mapSecond (\mod -> { mod | op = op, reso = Just (x,y), search = A.init (resoFmt False x y), aspect = aspect }) <| init dat
+ in
+ case q of
+ QTuple 8 op x y -> m op x y False
+ QTuple 9 op x y -> m op x y True
+ _ -> Nothing
+
+
+view : Model -> (Html Msg, () -> List (Html Msg))
+view model =
+ ( case model.reso of
+ Nothing -> small [] [ text "Resolution" ]
+ Just (x,y) -> span [ class "nowrap" ] [ text <| (if x > 0 && model.aspect then "A " else "R ") ++ showOp model.op ++ " " ++ resoFmt False x y ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Resolution" ]
+ , div [ class "opts" ]
+ [ div [ class "opselect" ] [ inputOp (onlyEq model.reso) model.op MOp ]
+ , if model.op == Eq || model.op == Ne then text "" else
+ linkRadio model.aspect Aspect [ span [ title "Aspect ratio must be the same" ] [ text "aspect" ] ]
+ ]
+ ]
+ , A.view model.conf model.search [ placeholder "width x height" ]
+ ]
+ )
diff --git a/elm/AdvSearch/Set.elm b/elm/AdvSearch/Set.elm
new file mode 100644
index 00000000..f5f2897c
--- /dev/null
+++ b/elm/AdvSearch/Set.elm
@@ -0,0 +1,565 @@
+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 Gen.ExtLinks as GEL
+import AdvSearch.Lib 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 qs = f qs |> Maybe.andThen (\(op,v) ->
+ if op /= Ne && op /= Eq
+ then Nothing
+ else Just (dat, { sel = Set.fromList [v], and = False, neg = (op == Ne), single = True, last = Set.empty }))
+ lst and 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 and (Just (dat, {m | and = xor and (op == Ne), single = False, sel = Set.insert v m.sel})) xs)
+ in case q of
+ QAnd (x::xs) -> lst True (single x) xs
+ QOr (x::xs) -> lst False (single x) xs
+ _ -> single 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 =
+ if not canAnd && not canSingle then span [] [] else
+ 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
+
+type LangField
+ = LangVN
+ | LangVNO
+ | LangRel
+ | LangProd
+ | LangStaff
+
+type alias LangModel = (LangField, Model String)
+
+langInit field dat = init dat |> Tuple.mapSecond (\m -> (field,m))
+
+langUpdate msg (field, model) = (field, update msg model)
+
+langView (field, model) =
+ let tprefix = if field == LangVNO then "O " else "L "
+ label = if field == LangVNO then "Orig language" else "Language"
+ msg = case field of
+ LangVN -> "Language(s) in which the visual novel is available."
+ LangVNO -> "Language the visual novel has been originally written in."
+ LangRel -> "Language(s) in which the release is available."
+ LangProd -> "Primary language of the producer."
+ LangStaff -> "Primary language of the staff."
+ canAnd = case field of
+ LangVN -> True
+ LangVNO -> False
+ LangRel -> True
+ LangProd -> False
+ LangStaff -> False
+ lst = case field of
+ LangVN -> scriptLangs
+ LangVNO -> scriptLangs
+ LangRel -> scriptLangs
+ LangProd -> locLangs
+ LangStaff -> locLangs
+ in
+ ( case Set.toList model.sel of
+ [] -> small [] [ text label ]
+ [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 msg ]
+ , opts model canAnd True
+ ]
+ , ul [ style "columns" "2"] <| List.map (\(l,t) -> li [] [ linkRadio (Set.member l model.sel) (Sel l) [ langIcon l, text t ] ]) lst
+ ]
+ )
+
+langFromQuery field dat qs = Maybe.map (\(d,m) -> (d,(field,m))) <| fromQuery (\q ->
+ case (field, q) of
+ (LangVNO, QStr 3 op v) -> Just (op, v)
+ (LangVNO, _) -> Nothing
+ (_, QStr 2 op v) -> Just (op, v)
+ _ -> Nothing) dat qs
+
+langToQuery (field, model) = toQuery (QStr (if field == LangVNO then 3 else 2)) model
+
+
+
+-- Platform
+
+platformView unk model =
+ let lst = if unk then ("", "Unknown") :: GT.platforms else GT.platforms
+ fmt p t = [ if p == "" then text "" else platformIcon p, text t ]
+ in
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "Platform" ]
+ [v] -> span [ class "nowrap" ] <| lblPrefix model :: fmt v (Maybe.withDefault "" (lookup v lst))
+ l -> span [ class "nowrap" ] <| lblPrefix model :: List.intersperse (text "") (List.map platformIcon 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) (fmt p t) ]
+ ) lst
+ ]
+ )
+
+platformFromQuery = fromQuery (\q ->
+ case q of
+ QStr 4 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Length
+
+lengthView model =
+ ( case Set.toList model.sel of
+ [] -> small [] [ 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)
+
+
+
+
+-- Development status
+
+devStatusView model =
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "Status" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.devStatus) ]
+ l -> span [] [ lblPrefix model, text <| "Dev Status (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Development status" ]
+ , opts model False True ]
+ , ul [] <| List.map (\(l,t) -> li [] [ linkRadio (Set.member l model.sel) (Sel l) [ text t ] ]) GT.devStatus
+ ]
+ )
+
+devStatusFromQuery = fromQuery (\q ->
+ case q of
+ QInt 66 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Character role
+
+roleView model =
+ ( case Set.toList model.sel of
+ [] -> small [] [ 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
+ [] -> small [] [ 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)
+
+
+
+
+-- Character sex
+
+type alias SexModel = (Bool, Model String)
+
+type SexMsg = SexSpoil | SexSel (Msg String)
+
+sexInit spoil dat = init dat |> Tuple.mapSecond (\m -> (spoil,m))
+
+sexFromQuery spoil dat qf = Maybe.map (Tuple.mapSecond (\m -> (spoil,m))) <| fromQuery (\q ->
+ case (spoil, q) of
+ (False, QStr 4 op v) -> Just (op, v)
+ (True, QStr 5 op v) -> Just (op, v)
+ _ -> Nothing) dat qf
+
+sexUpdate msg (spoil,model) =
+ case msg of
+ SexSpoil -> (not spoil, model)
+ SexSel m -> (spoil, update m model)
+
+sexView (spoil,model) =
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "Sex" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| "Sex: " ++ Maybe.withDefault "" (lookup v GT.genders) ]
+ l -> span [] [ lblPrefix model, text <| "Sex (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader", style "width" "280px" ]
+ [ h3 [] [ text "Sex" ]
+ , div [ class "opts" ]
+ [ Html.map SexSel (optsMode model False True)
+ , a [ href "#", onClickD SexSpoil ] [ text <| if spoil then "spoilers" else "no spoilers" ]
+ , linkRadio model.neg (SexSel << Neg) [ text "invert" ]
+ ]
+ ]
+ , ul [] <| List.map (\(l,t) -> li [] [ linkRadio (Set.member l model.sel) (SexSel << Sel l) [ text t ] ]) GT.genders
+ ]
+ )
+
+
+
+
+-- Staff gender
+
+genderView model =
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "Gender" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.genders) ]
+ l -> span [] [ lblPrefix model, text <| "Gender (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Gender" ]
+ , opts model False True ]
+ , ul [] <| List.map (\(k,l) -> li [] [ if k == "b" then text "" else linkRadio (Set.member k model.sel) (Sel k) [ text l ] ]) GT.genders
+ ]
+ )
+
+genderFromQuery = fromQuery (\q ->
+ case q of
+ QStr 4 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Release medium
+
+mediumView model =
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "Medium" ]
+ [v] -> span [ class "nowrap" ]
+ [ lblPrefix model
+ , text <| if v == "" then "Medium: Unknown" else
+ Maybe.withDefault "" <| List.head <| List.filterMap (\(k,l,_) -> if v == k then Just l else Nothing) GT.media
+ ]
+ l -> span [] [ lblPrefix model, text <| "Media (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Medium" ]
+ , opts model True True ]
+ , ul [] <| List.map
+ (\(k,l,_) -> li [] [ linkRadio (Set.member k model.sel) (Sel k) [ text l ] ])
+ (("", "Unknown", True) :: GT.media)
+ ]
+ )
+
+mediumFromQuery = fromQuery (\q ->
+ case q of
+ QStr 11 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Release voiced
+
+voicedView model =
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "Voiced" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.voiced) ]
+ l -> span [] [ lblPrefix model, text <| "Voiced (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Voiced" ]
+ , opts model False True ]
+ , ul [] <| List.map (\(k,l) -> li [] [ linkRadio (Set.member k model.sel) (Sel k) [ text l ] ]) GT.voiced
+ ]
+ )
+
+voicedFromQuery = fromQuery (\q ->
+ case q of
+ QInt 12 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Release animation
+
+animatedView story model =
+ let lbl = (if story then "Story" else "Ero") ++ " animation"
+ in
+ ( case Set.toList model.sel of
+ [] -> small [] [ text lbl ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| (if story then "S " else "E ") ++ Maybe.withDefault "" (lookup v GT.animated) ]
+ l -> span [ class "nowrap" ] [ lblPrefix model, text <| lbl ++ " (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text lbl ]
+ , opts model False True ]
+ , ul [] <| List.map (\(k,l) -> li [] [ linkRadio (Set.member k model.sel) (Sel k) [ text l ] ]) GT.animated
+ ]
+ )
+
+animatedFromQuery story = fromQuery (\q ->
+ case q of
+ QInt 13 op v -> if not story then Just (op, v) else Nothing
+ QInt 14 op v -> if story then Just (op, v) else Nothing
+ _ -> Nothing)
+
+
+
+
+-- Release type
+
+rtypeView model =
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "Type" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.releaseTypes) ]
+ l -> span [ class "nowrap" ] [ lblPrefix model, text <| "Types (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Release type" ]
+ , opts model False True ]
+ , ul [] <| List.map (\(k,l) -> li [] [ linkRadio (Set.member k model.sel) (Sel k) [ text l ] ]) GT.releaseTypes
+ ]
+ )
+
+rtypeFromQuery = fromQuery (\q ->
+ case q of
+ QStr 16 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Labels
+-- TODO: Do something with labels from other users - if only to display them correctly.
+
+labelView dat model =
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "Labels" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v dat.labels) ]
+ l -> span [ class "nowrap" ] [ lblPrefix model, text <| "Labels (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "VN labels" ]
+ , opts model True True ]
+ , ul [] <| List.map (\(k,l) -> li [] [ linkRadio (Set.member k model.sel) (Sel k) [ text l ] ]) dat.labels
+ ]
+ )
+
+labelFromQuery dat q =
+ fromQuery (\qs ->
+ case qs of
+ QTuple 12 op uid l -> if Just (vndbid 'u' uid) == dat.uid then Just (op, l) else Nothing
+ _ -> Nothing) dat q
+
+
+
+
+-- Staff role
+
+sroleView model =
+ let lst = ("seiyuu","Voice actor") :: GT.creditTypes
+ in
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "Role" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" <| lookup v lst ]
+ l -> span [ class "nowrap" ] [ lblPrefix model, text <| "Roles (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Role" ]
+ , opts model True True ]
+ , ul [] <| List.map (\(k,l) -> li [] [ linkRadio (Set.member k model.sel) (Sel k) [ text l ] ]) lst
+ ]
+ )
+
+sroleFromQuery = fromQuery (\q ->
+ case q of
+ QStr 5 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Release list status
+
+rlistView model =
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "List status" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" <| lookup v GT.rlistStatus ]
+ l -> span [ class "nowrap" ] [ lblPrefix model, text <| "List (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "List status" ]
+ , opts model False True ]
+ , ul [] <| List.map (\(k,l) -> li [] [ linkRadio (Set.member k model.sel) (Sel k) [ text l ] ]) GT.rlistStatus
+ ]
+ )
+
+rlistFromQuery = fromQuery (\q ->
+ case q of
+ QInt 18 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Producer type
+
+ptypeView model =
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "Type" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.producerTypes) ]
+ l -> span [ class "nowrap" ] [ lblPrefix model, text <| "Types (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Producer type" ]
+ , opts model False True ]
+ , ul [] <| List.map (\(k,l) -> li [] [ linkRadio (Set.member k model.sel) (Sel k) [ text l ] ]) GT.producerTypes
+ ]
+ )
+
+ptypeFromQuery = fromQuery (\q ->
+ case q of
+ QStr 4 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Extlinks (releases only, for now)
+
+extlinkView links model =
+ let lst = List.map (\l -> (l.advid, l.name)) links
+ in
+ ( case Set.toList model.sel of
+ [] -> small [] [ text "External links" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v lst) ]
+ l -> span [ class "nowrap" ] [ lblPrefix model, text <| "Links (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "External links" ]
+ , opts model True True ]
+ , ul [ style "columns" "2" ] <| List.map (\(k,l) -> li [] [ linkRadio (Set.member k model.sel) (Sel k) [ text l ] ]) lst
+ ]
+ )
+
+extlinkFromQuery num = fromQuery (\q ->
+ case q of
+ QStr n op v -> if n == num then Just (op, v) else Nothing
+ _ -> Nothing)
diff --git a/elm/AdvSearch/Staff.elm b/elm/AdvSearch/Staff.elm
new file mode 100644
index 00000000..7365419e
--- /dev/null
+++ b/elm/AdvSearch/Staff.elm
@@ -0,0 +1,94 @@
+module AdvSearch.Staff 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.Lib exposing (..)
+import AdvSearch.Set as S
+
+
+
+type alias Model =
+ { sel : S.Model Int
+ , conf : A.Config Msg GApi.ApiStaffResult
+ , search : A.Model GApi.ApiStaffResult
+ }
+
+type Msg
+ = Sel (S.Msg Int)
+ | Search (A.Msg GApi.ApiStaffResult)
+
+
+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 = "xsearch_staff" ++ String.fromInt ndat.objid, source = A.staffSource }
+ , 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 s ->
+ if Set.member (vndbidNum s.id) model.sel.sel then (dat, { model | search = nm }, c)
+ else ( { dat | staff = Dict.insert s.id s dat.staff }
+ , { model | search = A.clear nm "", sel = S.update (S.Sel (vndbidNum s.id) True) model.sel }
+ , c )
+
+
+toQuery m = S.toQuery (QInt 3) m.sel
+
+fromQuery dat qf = S.fromQuery (\q ->
+ case q of
+ QInt 3 op v -> Just (op, v)
+ _ -> Nothing) dat qf
+ |> Maybe.map (\(ndat,sel) ->
+ ( { ndat | objid = ndat.objid+1 }
+ , { sel = { sel | single = False }
+ , conf = { wrap = Search, id = "xsearch_staff" ++ String.fromInt ndat.objid, source = A.staffSource }
+ , search = A.init ""
+ }
+ ))
+
+
+
+view : Data -> Model -> (Html Msg, () -> List (Html Msg))
+view dat model =
+ ( case Set.toList model.sel.sel of
+ [] -> small [] [ text "Name" ]
+ [s] -> span [ class "nowrap" ]
+ [ S.lblPrefix model.sel
+ , small [] [ text <| "s" ++ String.fromInt s ++ ":" ]
+ , Dict.get (vndbid 's' s) dat.staff |> Maybe.map (\e -> e.title) |> Maybe.withDefault "" |> text
+ ]
+ l -> span [] [ S.lblPrefix model.sel, text <| "Names (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Staff identifier" ]
+ , Html.map Sel (S.opts model.sel False True)
+ ]
+ , ul [] <| List.map (\s ->
+ li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ]
+ [ inputButton "X" (Sel (S.Sel s False)) []
+ , small [] [ text <| " s" ++ String.fromInt s ++ ": " ]
+ , Dict.get (vndbid 's' s) dat.staff |> Maybe.map (\e -> a [ href ("/" ++ e.id), target "_blank", style "display" "inline" ] [ text e.title ]) |> Maybe.withDefault (text "")
+ ]
+ ) (Set.toList model.sel.sel)
+ , A.view model.conf model.search [ placeholder "Search..." ]
+ , small [] [ text "All aliases of the selected staff entries are searched, not just the names you specified." ]
+ ]
+ )
diff --git a/elm/AdvSearch/Tags.elm b/elm/AdvSearch/Tags.elm
new file mode 100644
index 00000000..001890ee
--- /dev/null
+++ b/elm/AdvSearch/Tags.elm
@@ -0,0 +1,127 @@
+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.Lib 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
+ , inherit : Bool
+ , exclie : Bool
+ }
+
+type Msg
+ = Sel (S.Msg (Int,Int))
+ | Level (Int,Int) Int
+ | Spoiler
+ | Inherit Bool
+ | ExcLie Bool
+ | 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 = "xsearch_tag" ++ String.fromInt ndat.objid, source = A.tagSource }
+ , search = A.init ""
+ , spoiler = dat.defaultSpoil
+ , inherit = True
+ , exclie = False
+ }
+ )
+
+
+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, exclie = False }, Cmd.none)
+ Inherit b -> (dat, { model | inherit = b }, Cmd.none)
+ ExcLie b -> (dat, { model | exclie = b }, 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 (vndbidNum t.id,0) True) model.sel }
+ , c )
+
+
+toQuery m = S.toQuery (\o (t,l) ->
+ let id = if m.inherit then 8 else 14
+ in if m.spoiler == 0 && not m.exclie && l == 0 then QInt id o t else QTuple id o t ((if m.exclie then 16*3 else 0) + l*3 + m.spoiler)) m.sel
+
+fromQuery spoil inherit exclie dat q =
+ let id = if inherit then 8 else 14
+ f qr = case qr of
+ QInt x op t -> if id == x && spoil == 0 && not exclie then Just (op, (t,0)) else Nothing
+ QTuple x op t v -> if id == x && modBy 3 v == spoil && exclie == ((v // (16*3)) == 1) then Just (op, (t, modBy 16 (v//3))) else Nothing
+ _ -> Nothing
+ in
+ S.fromQuery f dat q |> Maybe.map (\(ndat,sel) ->
+ ( { ndat | objid = ndat.objid+1 }
+ , { sel = { sel | single = False, and = sel.and || Set.size sel.sel == 1 }
+ , conf = { wrap = Search, id = "xsearch_tag" ++ String.fromInt ndat.objid, source = A.tagSource }
+ , search = A.init ""
+ , spoiler = spoil
+ , inherit = inherit
+ , exclie = exclie
+ }
+ ))
+
+
+view : Data -> Model -> (Html Msg, () -> List (Html Msg))
+view dat model =
+ ( case Set.toList model.sel.sel of
+ [] -> small [] [ text "Tags" ]
+ [(s,_)] -> span [ class "nowrap" ]
+ [ S.lblPrefix model.sel
+ , small [] [ text <| "g" ++ String.fromInt s ++ ":" ]
+ , Dict.get (vndbid 'g' 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" ]
+ ]
+ , div [ class "opts" ]
+ [ if model.spoiler < 2 then span [] [] else
+ linkRadio model.exclie ExcLie [ text "exclude lies" ]
+ , linkRadio model.inherit Inherit [ text "child tags" ]
+ ]
+ ]
+ , ul [] <| List.map (\(t,l) ->
+ li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ]
+ [ 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")]
+ , small [] [ text <| " g" ++ String.fromInt t ++ ": " ]
+ , Dict.get (vndbid 'g' t) dat.tags |> Maybe.map (\e -> a [ href ("/" ++ e.id), target "_blank", style "display" "inline" ] [ text e.name ]) |> Maybe.withDefault (text "")
+ ]
+ ) (Set.toList model.sel.sel)
+ , A.view model.conf model.search [ placeholder "Search..." ]
+ ]
+ )
diff --git a/elm/AdvSearch/Traits.elm b/elm/AdvSearch/Traits.elm
new file mode 100644
index 00000000..db9b5f84
--- /dev/null
+++ b/elm/AdvSearch/Traits.elm
@@ -0,0 +1,123 @@
+module AdvSearch.Traits 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.Lib exposing (..)
+import AdvSearch.Set as S
+
+
+
+type alias Model =
+ { sel : S.Model Int
+ , conf : A.Config Msg GApi.ApiTraitResult
+ , search : A.Model GApi.ApiTraitResult
+ , spoiler : Int
+ , inherit : Bool
+ , exclie : Bool
+ }
+
+type Msg
+ = Sel (S.Msg Int)
+ | Spoiler
+ | Inherit Bool
+ | ExcLie Bool
+ | Search (A.Msg GApi.ApiTraitResult)
+
+
+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 = "xsearch_trait" ++ String.fromInt ndat.objid, source = A.traitSource }
+ , search = A.init ""
+ , spoiler = dat.defaultSpoil
+ , inherit = True
+ , exclie = False
+ }
+ )
+
+
+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)
+ Spoiler -> (dat, { model | spoiler = if model.spoiler < 2 then model.spoiler + 1 else 0, exclie = False }, Cmd.none)
+ Inherit b -> (dat, { model | inherit = b }, Cmd.none)
+ ExcLie b -> (dat, { model | exclie = b }, 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 | traits = Dict.insert t.id t dat.traits }
+ , { model | search = A.clear nm "", sel = S.update (S.Sel (vndbidNum t.id) True) model.sel }
+ , c )
+
+
+toQuery m = S.toQuery (\o t ->
+ let id = if m.inherit then 13 else 15
+ in if m.spoiler == 0 && not m.exclie then QInt id o t else QTuple id o t ((if m.exclie then 3 else 0) + m.spoiler)) m.sel
+
+fromQuery spoil inherit exclie dat q =
+ let id = if inherit then 13 else 15
+ f qr = case qr of
+ QInt x op t -> if id == x && spoil == 0 then Just (op, t) else Nothing
+ QTuple x op t v -> if id == x && modBy 3 v == spoil && exclie == ((v // 3) == 1) then Just (op, t) else Nothing
+ _ -> Nothing
+ in
+ S.fromQuery f dat q |> Maybe.map (\(ndat,sel) ->
+ ( { ndat | objid = ndat.objid+1 }
+ , { sel = { sel | single = False, and = sel.and || Set.size sel.sel == 1 }
+ , conf = { wrap = Search, id = "xsearch_trait" ++ String.fromInt ndat.objid, source = A.traitSource }
+ , search = A.init ""
+ , spoiler = spoil
+ , inherit = inherit
+ , exclie = exclie
+ }
+ ))
+
+
+view : Data -> Model -> (Html Msg, () -> List (Html Msg))
+view dat model =
+ ( case Set.toList model.sel.sel of
+ [] -> small [] [ text "Traits" ]
+ [s] -> span [ class "nowrap" ]
+ [ S.lblPrefix model.sel
+ , small [] [ text <| "i" ++ String.fromInt s ++ ":" ]
+ , Dict.get (vndbid 'i' s) dat.traits |> Maybe.map (\t -> t.name) |> Maybe.withDefault "" |> text
+ ]
+ l -> span [] [ S.lblPrefix model.sel, text <| "Traits (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Traits" ]
+ , 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" ]
+ ]
+ , div [ class "opts" ]
+ [ if model.spoiler < 2 then span [] [] else
+ linkRadio model.exclie ExcLie [ text "exclude lies" ]
+ , linkRadio model.inherit Inherit [ text "child traits" ]
+ ]
+ ]
+ , ul [] <| List.map (\t ->
+ li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ]
+ [ inputButton "X" (Sel (S.Sel t False)) []
+ , small [] [ text <| " i" ++ String.fromInt t ++ ": " ]
+ , Dict.get (vndbid 'i' t) dat.traits |> Maybe.map (\e -> span []
+ [ Maybe.withDefault (text "") <| Maybe.map (\g -> small [] [ text (g ++ " / ") ]) e.group_name
+ , a [ href ("/" ++ e.id), target "_blank", style "display" "inline" ] [ text 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
new file mode 100644
index 00000000..e8b8d420
--- /dev/null
+++ b/elm/CharEdit.elm
@@ -0,0 +1,524 @@
+module CharEdit exposing (main)
+
+import Html exposing (..)
+import Html.Events exposing (..)
+import Html.Keyed as K
+import Html.Attributes exposing (..)
+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
+import Lib.Autocomplete as A
+import Lib.Api as Api
+import Lib.Editsum as Editsum
+import Lib.RDate as RDate
+import Lib.Image as Img
+import Gen.Release as GR
+import Gen.CharEdit as GCE
+import Gen.Types as GT
+import Gen.Api as GApi
+
+
+main : Program GCE.Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type Tab
+ = General
+ | Image
+ | Traits
+ | VNs
+ | All
+
+type SelOpt = Spoil Int | Lie
+
+type alias Model =
+ { state : Api.State
+ , tab : Tab
+ , invalidDis : Bool
+ , editsum : Editsum.Model
+ , name : String
+ , latin : Maybe String
+ , alias : String
+ , description : TP.Model
+ , gender : String
+ , spoilGender : Maybe String
+ , bMonth : Int
+ , bDay : Int
+ , age : Maybe Int
+ , sBust : Int
+ , sWaist : Int
+ , sHip : Int
+ , height : Int
+ , weight : Maybe Int
+ , bloodt : String
+ , cupSize : String
+ , main : Maybe String
+ , mainRef : Bool
+ , mainHas : Bool
+ , mainName : String
+ , mainSearch : A.Model GApi.ApiCharResult
+ , mainSpoil : Int
+ , image : Img.Image
+ , traits : List GCE.RecvTraits
+ , traitSearch : A.Model GApi.ApiTraitResult
+ , traitSel : (String, SelOpt)
+ , vns : List GCE.RecvVns
+ , vnSearch : A.Model GApi.ApiVNResult
+ , releases : Dict.Dict String (List GCE.RecvReleasesRels) -- vid -> list of releases
+ , id : Maybe String
+ }
+
+
+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, hasawait = False }
+ , name = d.name
+ , latin = d.latin
+ , alias = d.alias
+ , description = TP.bbcode d.description
+ , gender = d.gender
+ , spoilGender = d.spoil_gender
+ , bMonth = d.b_month
+ , bDay = if d.b_day == 0 then 1 else d.b_day
+ , age = d.age
+ , sBust = d.s_bust
+ , sWaist = d.s_waist
+ , sHip = d.s_hip
+ , height = d.height
+ , weight = d.weight
+ , bloodt = d.bloodt
+ , cupSize = d.cup_size
+ , main = d.main
+ , mainRef = d.main_ref
+ , mainHas = d.main /= Nothing
+ , mainName = d.main_name
+ , mainSearch = A.init ""
+ , mainSpoil = d.main_spoil
+ , image = Img.info d.image_info
+ , traits = d.traits
+ , traitSearch = A.init ""
+ , traitSel = ("", Spoil 0)
+ , vns = d.vns
+ , vnSearch = A.init ""
+ , releases = Dict.fromList <| List.map (\v -> (v.id, v.rels)) d.releases
+ , id = d.id
+ }
+
+
+encode : Model -> GCE.Send
+encode model =
+ { id = model.id
+ , editsum = model.editsum.editsum.data
+ , hidden = model.editsum.hidden
+ , locked = model.editsum.locked
+ , name = model.name
+ , latin = model.latin
+ , alias = model.alias
+ , description = model.description.data
+ , gender = model.gender
+ , spoil_gender= model.spoilGender
+ , b_month = model.bMonth
+ , b_day = model.bDay
+ , age = model.age
+ , s_bust = model.sBust
+ , s_waist = model.sWaist
+ , s_hip = model.sHip
+ , height = model.height
+ , weight = model.weight
+ , bloodt = model.bloodt
+ , cup_size = model.cupSize
+ , main = if model.mainHas then model.main else Nothing
+ , main_spoil = model.mainSpoil
+ , image = model.image.id
+ , traits = List.map (\t -> { tid = t.tid, spoil = t.spoil, lie = t.lie }) model.traits
+ , vns = List.map (\v -> { vid = v.vid, rid = v.rid, spoil = v.spoil, role = v.role }) model.vns
+ }
+
+mainConfig : A.Config Msg GApi.ApiCharResult
+mainConfig = { wrap = MainSearch, id = "mainadd", source = A.charSource }
+
+traitConfig : A.Config Msg GApi.ApiTraitResult
+traitConfig = { wrap = TraitSearch, id = "traitadd", source = A.traitSource }
+
+vnConfig : A.Config Msg GApi.ApiVNResult
+vnConfig = { wrap = VnSearch, id = "vnadd", source = A.vnSource }
+
+type Msg
+ = Editsum Editsum.Msg
+ | Tab Tab
+ | Invalid Tab
+ | InvalidEnable
+ | Submit
+ | Submitted GApi.Response
+ | Name String
+ | Latin String
+ | Alias String
+ | Desc TP.Msg
+ | Gender String
+ | SpoilGender (Maybe String)
+ | BMonth Int
+ | BDay Int
+ | Age (Maybe Int)
+ | SBust (Maybe Int)
+ | SWaist (Maybe Int)
+ | SHip (Maybe Int)
+ | Height (Maybe Int)
+ | Weight (Maybe Int)
+ | BloodT String
+ | CupSize String
+ | MainHas Bool
+ | MainSearch (A.Msg GApi.ApiCharResult)
+ | MainSpoil Int
+ | ImageSet String Bool
+ | ImageSelect
+ | ImageSelected File
+ | ImageMsg Img.Msg
+ | TraitDel Int
+ | TraitSel String SelOpt
+ | TraitSpoil Int Int
+ | TraitLie Int Bool
+ | TraitSearch (A.Msg GApi.ApiTraitResult)
+ | VnRel Int (Maybe String)
+ | VnRole Int String
+ | VnSpoil Int Int
+ | VnDel Int
+ | VnRelAdd String String
+ | VnSearch (A.Msg GApi.ApiVNResult)
+ | VnRelGet String GApi.Response
+
+
+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)
+ Name s -> ({ model | name = s }, Cmd.none)
+ Latin s -> ({ model | latin = if s == "" then Nothing else Just s }, Cmd.none)
+ Alias s -> ({ model | alias = s }, Cmd.none)
+ Desc m -> let (nm,nc) = TP.update m model.description in ({ model | description = nm }, Cmd.map Desc nc)
+ Gender s -> ({ model | gender = s }, Cmd.none)
+ SpoilGender s->({model | spoilGender = s }, Cmd.none)
+ BMonth n -> ({ model | bMonth = n }, Cmd.none)
+ BDay n -> ({ model | bDay = n }, Cmd.none)
+ Age s -> ({ model | age = s }, Cmd.none)
+ SBust s -> ({ model | sBust = Maybe.withDefault 0 s }, Cmd.none)
+ SWaist s -> ({ model | sWaist = Maybe.withDefault 0 s }, Cmd.none)
+ SHip s -> ({ model | sHip = Maybe.withDefault 0 s }, Cmd.none)
+ Height s -> ({ model | height = Maybe.withDefault 0 s }, Cmd.none)
+ Weight s -> ({ model | weight = s }, Cmd.none)
+ BloodT s -> ({ model | bloodt = s }, Cmd.none)
+ CupSize s -> ({ model | cupSize= s }, Cmd.none)
+
+ MainHas b -> ({ model | mainHas = b }, Cmd.none)
+ MainSearch m ->
+ let (nm, c, res) = A.update mainConfig m model.mainSearch
+ in case res of
+ Nothing -> ({ model | mainSearch = nm }, c)
+ Just m1 ->
+ case m1.main of
+ Just m2 -> ({ model | mainSearch = A.clear nm "", main = Just m2.id, mainName = m2.title }, c)
+ Nothing -> ({ model | mainSearch = A.clear nm "", main = Just m1.id, mainName = m1.title }, c)
+ MainSpoil n -> ({ model | mainSpoil = n }, Cmd.none)
+
+ ImageSet s b -> let (nm, nc) = Img.new b s in ({ model | image = nm }, Cmd.map ImageMsg nc)
+ ImageSelect -> (model, FSel.file ["image/png", "image/jpeg", "image/webp", "image/avif", "image/jxl"] ImageSelected)
+ ImageSelected f -> let (nm, nc) = Img.upload Api.Ch f in ({ model | image = nm }, Cmd.map ImageMsg nc)
+ ImageMsg m -> let (nm, nc) = Img.update m model.image in ({ model | image = nm }, Cmd.map ImageMsg nc)
+
+ TraitDel idx -> ({ model | traits = delidx idx model.traits }, Cmd.none)
+ TraitSel id opt -> ({ model | traitSel = (id, opt) }, Cmd.none)
+ TraitSpoil idx spl -> ({ model | traits = modidx idx (\t -> { t | spoil = spl }) model.traits }, Cmd.none)
+ TraitLie idx v -> ({ model | traits = modidx idx (\t -> { t | lie = v }) model.traits }, Cmd.none)
+ TraitSearch m ->
+ let (nm, c, res) = A.update traitConfig m model.traitSearch
+ in case res of
+ Nothing -> ({ model | traitSearch = nm }, c)
+ Just t ->
+ let n = { tid = t.id, spoil = t.defaultspoil, lie = False, new = True
+ , name = t.name, group = t.group_name
+ , hidden = t.hidden, locked = t.locked, applicable = t.applicable }
+ in
+ if not t.applicable || t.hidden || List.any (\l -> l.tid == t.id) model.traits
+ then ({ model | traitSearch = A.clear nm "" }, c)
+ else ({ model | traitSearch = A.clear nm "", traits = model.traits ++ [n] }, c)
+
+ VnRel idx r -> ({ model | vns = modidx idx (\v -> { v | rid = r }) model.vns }, Cmd.none)
+ VnRole idx s -> ({ model | vns = modidx idx (\v -> { v | role = s }) model.vns }, Cmd.none)
+ VnSpoil idx n -> ({ model | vns = modidx idx (\v -> { v | spoil = n }) model.vns }, Cmd.none)
+ VnDel idx -> ({ model | vns = delidx idx model.vns }, Cmd.none)
+ VnRelAdd vid title ->
+ let rid = Dict.get vid model.releases |> Maybe.andThen (\rels -> List.filter (\r -> not (List.any (\v -> v.vid == vid && v.rid == Just r.id) model.vns)) rels |> List.head |> Maybe.map (\r -> r.id))
+ in ({ model | vns = model.vns ++ [{ vid = vid, title = title, rid = rid, spoil = 0, role = "primary" }] }, Cmd.none)
+ VnSearch m ->
+ let (nm, c, res) = A.update vnConfig m model.vnSearch
+ in case res of
+ Nothing -> ({ model | vnSearch = nm }, c)
+ Just vn ->
+ if List.any (\v -> v.vid == vn.id) model.vns
+ then ({ model | vnSearch = A.clear nm "" }, c)
+ else ({ model | vnSearch = A.clear nm "", vns = model.vns ++ [{ vid = vn.id, title = vn.title, rid = Nothing, spoil = 0, role = "primary" }] }
+ , Cmd.batch [c, if Dict.member vn.id model.releases then Cmd.none else GR.send { vid = vn.id } (VnRelGet vn.id)])
+ VnRelGet vid (GApi.Releases r) -> ({ model | releases = Dict.insert vid r model.releases }, Cmd.none)
+ VnRelGet _ r -> ({ model | state = Api.Error r }, Cmd.none) -- XXX
+
+ Submit -> ({ model | state = Api.Loading }, GCE.send (encode model) Submitted)
+ Submitted (GApi.Redirect s) -> (model, load s)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+isValid : Model -> Bool
+isValid model = not
+ ( (model.name /= "" && Just model.name == model.latin)
+ || hasDuplicates (List.map (\v -> (v.vid, Maybe.withDefault "" v.rid)) model.vns)
+ || not (Img.isValid model.image)
+ || (model.mainHas && model.main /= Nothing && model.main == model.id)
+ )
+
+
+spoilOpts =
+ [ (0, "Not a spoiler")
+ , (1, "Minor spoiler")
+ , (2, "Major spoiler")
+ ]
+
+
+view : Model -> Html Msg
+view model =
+ let
+ geninfo =
+ [ formField "name::Name (original)" [ inputText "name" model.name Name (onInvalid (Invalid General) :: GCE.valName) ]
+ , if not (model.latin /= Nothing || containsNonLatin model.name) then text "" else
+ formField "latin::Name (latin)"
+ [ inputText "latin" (Maybe.withDefault "" model.latin) Latin (onInvalid (Invalid General) :: required True :: placeholder "Romanization" :: GCE.valLatin)
+ , case model.latin of
+ Just s -> if containsNonLatin s
+ then b [] [ br [] [], text "Romanization should only consist of characters in the latin alphabet." ] else text ""
+ Nothing -> text ""
+ ]
+ , formField "alias::Aliases"
+ [ 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.description Desc 600 (style "height" "150px" :: onInvalid (Invalid General) :: GCE.valDescription)
+ [ b [] [ text "English please!" ] ] ]
+ , formField "bmonth::Birthday"
+ [ inputSelect "bmonth" model.bMonth BMonth [style "width" "128px"] <| (0, "Unknown") :: RDate.monthSelect
+ , 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 (onInvalid (Invalid General) :: GCE.valAge), text " years" ]
+
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Body" ] ]
+ , formField "gender::Sex"
+ [ inputSelect "gender" model.gender Gender [] GT.genders
+ , label [] [ inputCheck "" (isJust model.spoilGender) (\b -> SpoilGender <| if b then (Just "unknown") else Nothing), text " spoiler" ]
+ , case model.spoilGender of
+ Nothing -> text ""
+ Just gen -> span []
+ [ br [] []
+ , text "▲ apparent (non-spoiler) sex"
+ , br [] []
+ , text "▼ actual (spoiler) sex"
+ , br [] []
+ , inputSelect "" gen (\s -> SpoilGender (Just s)) [] GT.genders
+ ]
+ ]
+ , 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
+ then
+ [ formField "" [ text "This character is already used as an instance for another character. If you want to link more characters to this one, please edit the other characters instead." ] ]
+ else
+ [ formField "" [ label [] [ inputCheck "" model.mainHas MainHas, text " This character is an instance of another character." ] ]
+ , formField "" <| if not model.mainHas then [] else
+ [ inputSelect "" model.mainSpoil MainSpoil [] spoilOpts
+ , br_ 2
+ , Maybe.withDefault (text "No character selected") <| Maybe.map (\m -> span []
+ [ text "Selected character: "
+ , small [] [ text <| m ++ ": " ]
+ , a [ href <| "/" ++ m ] [ text model.mainName ]
+ , if Just m == model.id then b [] [ 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..."]
+ ]
+ ]
+
+ image =
+ table [ class "formimage" ] [ tr []
+ [ td [] [ Img.viewImg model.image ]
+ , td []
+ [ h2 [] [ text "Image ID" ]
+ , 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
+ , h2 [] [ text "Upload new image" ]
+ , inputButton "Browse image" ImageSelect []
+ , br [] []
+ , text "Supported file types: JPEG, PNG, WebP, AVIF or JXL, at most 10 MiB."
+ , br [] []
+ , text "Images larger than 256x300 are automatically resized."
+ , 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)"
+ , v
+ ]
+ ]
+ ] ]
+
+ traits =
+ let
+ old = List.filter (\(_,t) -> not t.new) <| List.indexedMap (\i t -> (i,t)) model.traits
+ new = List.filter (\(_,t) -> t.new) <| List.indexedMap (\i t -> (i,t)) model.traits
+ spoil t = case model.traitSel of
+ (x,Spoil s) -> if t.tid == x then s else t.spoil
+ _ -> t.spoil
+ lie t = case model.traitSel of
+ (x,Lie) -> if t.tid == x then True else t.lie
+ _ -> t.lie
+ trait (i,t) = (t.tid,
+ tr []
+ [ td [ style "padding" "0 0 0 10px", style "text-decoration" (if t.applicable && not t.hidden then "none" else "line-through") ]
+ [ Maybe.withDefault (text "") <| Maybe.map (\g -> small [] [ text <| g ++ " / " ]) t.group
+ , a [ href <| "/" ++ t.tid ] [ text t.name ]
+ , if t.hidden && not t.locked then b [] [ text " (awaiting moderation)" ]
+ else if t.hidden then b [] [ text " (deleted)" ]
+ else if not t.applicable then b [] [ text " (not applicable)" ]
+ else text ""
+ ]
+ , td [ class "buts" ]
+ [ a [ href "#", onMouseOver (TraitSel t.tid (Spoil 0)), onMouseOut (TraitSel "" (Spoil 0)), onClickD (TraitSpoil i 0), classList [("s0", spoil t == 0 )], title "Not a spoiler" ] []
+ , a [ href "#", onMouseOver (TraitSel t.tid (Spoil 1)), onMouseOut (TraitSel "" (Spoil 0)), onClickD (TraitSpoil i 1), classList [("s1", spoil t == 1 )], title "Minor spoiler" ] []
+ , a [ href "#", onMouseOver (TraitSel t.tid (Spoil 2)), onMouseOut (TraitSel "" (Spoil 0)), onClickD (TraitSpoil i 2), classList [("s2", spoil t == 2 )], title "Major spoiler" ] []
+ , a [ href "#", onMouseOver (TraitSel t.tid Lie), onMouseOut (TraitSel "" (Spoil 0)), onClickD (TraitLie i (not t.lie)), classList [("sl", lie t)], title "Lie" ] []
+ ]
+ , td [ style "width" "150px", style "white-space" "nowrap" ]
+ [ case (t.tid == Tuple.first model.traitSel, Tuple.second model.traitSel) of
+ (True, Spoil 0) -> text "Not a spoiler"
+ (True, Spoil 1) -> text "Minor spoiler"
+ (True, Spoil 2) -> text "Major spoiler"
+ (True, Lie) -> text "This turns out to be false"
+ _ -> a [ href "#", onClickD (TraitDel i)] [ text "remove" ]
+ ]
+ ])
+ in
+ K.node "table" [ class "formtable chare_traits" ] <|
+ (if List.isEmpty old then []
+ else ("head", tr [ class "newpart" ] [ td [ colspan 3 ] [text "Current traits" ]]) :: List.map trait old)
+ ++
+ (if List.isEmpty new then []
+ else ("added", tr [ class "newpart" ] [ td [ colspan 3 ] [text "Newly added traits" ]]) :: List.map trait new)
+ ++
+ [ ("add", tr [] [ td [ colspan 3 ] [ br_ 1, A.view traitConfig model.traitSearch [placeholder "Add trait..."] ] ])
+ ]
+
+ -- XXX: This function has quite a few nested loops, prolly rather slow with many VNs/releases
+ vns =
+ let
+ uniq lst set =
+ case lst of
+ (x::xs) -> if Set.member x set then uniq xs set else x :: uniq xs (Set.insert x set)
+ [] -> []
+ vn vid lst rels =
+ let title = Maybe.withDefault "<unknown>" <| Maybe.map (\(_,v) -> v.title) <| List.head lst
+ in
+ [ ( vid
+ , tr [ class "newpart" ] [ td [ colspan 4, style "padding-bottom" "5px" ]
+ [ small [] [ text <| vid ++ ":" ]
+ , a [ href <| "/" ++ vid ] [ text title ]
+ ]]
+ )
+ ] ++ List.map (\(idx,item) ->
+ ( vid ++ "i" ++ Maybe.withDefault "r0" item.rid
+ , tr []
+ [ td [] [ inputSelect "" item.rid (VnRel idx) [ style "width" "400px", style "margin" "0 15px" ] <|
+ (Nothing, if List.length lst == 1 then "All (full) releases" else "Other releases")
+ :: List.map (\r -> (Just r.id, RDate.showrel r)) rels
+ ++ if isJust item.rid && List.isEmpty (List.filter (\r -> Just r.id == item.rid) rels)
+ then [(item.rid, "Deleted release: " ++ Maybe.withDefault "" item.rid)] else []
+ ]
+ , td [] [ inputSelect "" item.role (VnRole idx) [] GT.charRoles ]
+ , td [] [ inputSelect "" item.spoil (VnSpoil idx) [ style "width" "130px", style "margin" "0 5px" ] spoilOpts ]
+ , td [] [ inputButton "remove" (VnDel idx) [] ]
+ ]
+ )
+ ) lst
+ ++ (if List.map (\(_,r) -> Maybe.withDefault "" r.rid) lst |> hasDuplicates |> not then [] else [
+ ( vid ++ "dup"
+ , td [] [ td [ colspan 4, style "padding" "0 15px" ] [ b [] [ text "List contains duplicate releases." ] ] ]
+ )
+ ])
+ ++ (if 1 /= List.length (List.filter (\(_,r) -> isJust r.rid) lst) then [] else [
+ ( vid ++ "warn"
+ , tr [] [ td [ colspan 4, style "padding" "0 15px" ]
+ [ b [] [ text "Note: " ]
+ , text "Only select specific releases if the character has a significantly different role in those releases. "
+ , br [] []
+ , text "If the character's role is mostly the same in all releases (ignoring trials), then just select \"All (full) releases\"." ]
+ ])
+ ])
+ ++ (if List.length lst > List.length rels then [] else [
+ ( vid ++ "add"
+ , tr [] [ td [ colspan 4 ] [ inputButton "add release" (VnRelAdd vid title) [style "margin" "0 15px"] ] ]
+ )
+ ])
+ in
+ K.node "table" [ class "formtable" ] <|
+ List.concatMap
+ (\vid -> vn vid (List.filter (\(_,r) -> r.vid == vid) (List.indexedMap (\i r -> (i,r)) model.vns)) (Maybe.withDefault [] (Dict.get vid model.releases)))
+ (uniq (List.map (\v -> v.vid) model.vns) Set.empty)
+ ++
+ [ ("add", tr [] [ td [ colspan 4 ] [ br_ 1, A.view vnConfig model.vnSearch [placeholder "Add visual novel..."] ] ]) ]
+
+ in
+ form_ "mainform" Submit (model.state == Api.Loading)
+ [ nav []
+ [ menu []
+ [ li [ classList [("tabselected", model.tab == General)] ] [ a [ href "#", onClickD (Tab General) ] [ text "General info" ] ]
+ , li [ classList [("tabselected", model.tab == Image )] ] [ a [ href "#", onClickD (Tab Image ) ] [ text "Image" ] ]
+ , li [ classList [("tabselected", model.tab == Traits )] ] [ a [ href "#", onClickD (Tab Traits ) ] [ text "Traits" ] ]
+ , li [ classList [("tabselected", model.tab == VNs )] ] [ a [ href "#", onClickD (Tab VNs ) ] [ text "Visual Novels"] ]
+ , li [ classList [("tabselected", model.tab == All )] ] [ a [ href "#", onClickD (Tab All ) ] [ text "All items" ] ]
+ ]
+ ]
+ , article [ classList [("hidden", model.tab /= General && model.tab /= All)] ] [ h1 [] [ text "General info" ], table [ class "formtable" ] geninfo ]
+ , article [ classList [("hidden", model.tab /= Image && model.tab /= All)] ] [ h1 [] [ text "Image" ], image ]
+ , article [ classList [("hidden", model.tab /= Traits && model.tab /= All)] ] [ h1 [] [ text "Traits" ], traits ]
+ , article [ classList [("hidden", model.tab /= VNs && model.tab /= All)] ] [ h1 [] [ text "Visual Novels" ], vns ]
+ , article [ class "submit" ]
+ [ Html.map Editsum (Editsum.view model.editsum)
+ , submitButton "Submit" model.state (isValid model)
+ ]
+ ]
diff --git a/elm/ColSelect.elm b/elm/ColSelect.elm
deleted file mode 100644
index 93c9a093..00000000
--- a/elm/ColSelect.elm
+++ /dev/null
@@ -1,78 +0,0 @@
--- Column selection dropdown for tables. Assumes that the currently selected
--- columns are in the query string as the 'c' parameter, e.g.:
---
--- ?c=column_id&c=modified&...
---
--- Accepts a [ $current_url, [ list of columns ] ] from Perl, e.g.:
---
--- [ '?c=column_id', [
--- [ 'column_id', 'Column Label' ],
--- [ 'modified', 'Date modified' ],
--- ...
--- ] ]
-module ColSelect exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Set
-import Erl -- elm/url can't extract a full list of query parameters and hence can't be used to modify a parameter without removing all others.
-import Lib.DropDown as DD
-import Lib.Api as Api
-import Lib.Html exposing (..)
-
-
-main : Program (String, Columns) Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = \model -> DD.sub model.dd
- }
-
-
-type alias Columns = List (String, String)
-
-type alias Model =
- { cols : Columns
- , url : Erl.Url -- Without the "c" parameter
- , sel : Set.Set String
- , dd : DD.Config Msg
- }
-
-
-init : (String, Columns) -> Model
-init (u, c) =
- { cols = c
- , url = Erl.removeQuery "c" <| Erl.parse u
- , sel = Set.fromList <| Erl.getQueryValuesForKey "c" <| Erl.parse u
- , dd = DD.init "colselect" Open
- }
-
-
-type Msg
- = Open Bool
- | Toggle String Bool
- | Update
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Open b -> ({ model | dd = DD.toggle model.dd b }, Cmd.none)
- Toggle s b -> ({ model | sel = if b then Set.insert s model.sel else Set.remove s model.sel }, Cmd.none)
- Update -> (model, load <| Erl.toString <| List.foldl (\s u -> Erl.addQuery "c" s u) model.url <| Set.toList model.sel)
-
-
-view : Model -> Html Msg
-view model =
- let item (cid, cname) = li [ ] [ linkRadio (Set.member cid model.sel) (Toggle cid) [ text cname ] ]
- in
- DD.view model.dd Api.Normal
- (text "Select columns")
- (\_ -> [ ul []
- <| List.map item model.cols
- ++ [ li [ ] [ input [ type_ "button", class "submit", value "update", onClick Update ] [] ] ]
- ])
diff --git a/elm/Discussions/Edit.elm b/elm/Discussions/Edit.elm
index 711e96ea..f4899e95 100644
--- a/elm/Discussions/Edit.elm
+++ b/elm/Discussions/Edit.elm
@@ -24,43 +24,45 @@ main = Browser.element
type alias Model =
- { state : Api.State
- , tid : Maybe Int
- , num : Maybe Int
- , can_mod : Bool
- , can_private : Bool
- , locked : Bool
- , hidden : Bool
- , private : Bool
- , nolastmod : Bool
- , title : Maybe String
- , boards : Maybe (List GDE.SendBoards)
- , boardAdd : A.Model GApi.ApiBoardResult
- , msg : TP.Model
- , poll : GDE.SendPoll
- , pollEnabled : Bool
- , pollEdit : Bool
+ { state : Api.State
+ , tid : Maybe String
+ , can_mod : Bool
+ , can_private : Bool
+ , locked : Bool
+ , hidden : Bool
+ , private : Bool
+ , nolastmod : Bool
+ , delete : Bool
+ , title : Maybe String
+ , boards : Maybe (List GDE.SendBoards)
+ , boardAdd : A.Model GApi.ApiBoardResult
+ , boardsLocked : Bool
+ , msg : TP.Model
+ , poll : Maybe GDE.SendPoll
+ , pollEnabled : Bool
+ , pollEdit : Bool
}
init : GDE.Recv -> Model
init d =
- { state = Api.Normal
- , can_mod = d.can_mod
- , can_private = d.can_private
- , tid = d.tid
- , num = d.num
- , locked = d.locked
- , hidden = d.hidden
- , private = d.private
- , nolastmod = False
- , title = d.title
- , boards = d.boards
- , boardAdd = A.init
- , msg = TP.bbcode d.msg
- , poll = d.poll
- , pollEnabled = isJust d.poll
- , pollEdit = isJust d.poll
+ { state = Api.Normal
+ , can_mod = d.can_mod
+ , can_private = d.can_private
+ , tid = d.tid
+ , locked = d.locked
+ , hidden = d.hidden
+ , private = d.private
+ , nolastmod = False
+ , delete = False
+ , title = d.title
+ , boards = d.boards
+ , boardAdd = A.init ""
+ , boardsLocked = d.boards_locked
+ , msg = TP.bbcode d.msg
+ , poll = d.poll
+ , pollEnabled = isJust d.poll
+ , pollEdit = isJust d.poll
}
@@ -70,16 +72,17 @@ searchConfig = { wrap = BoardSearch, id = "boardadd", source = A.boardSource }
encode : Model -> GDE.Send
encode m =
- { tid = m.tid
- , num = m.num
- , locked = m.locked
- , hidden = m.hidden
- , private = m.private
- , nolastmod = m.nolastmod
- , boards = m.boards
- , poll = if m.pollEnabled then m.poll else Nothing
- , title = m.title
- , msg = m.msg.data
+ { tid = m.tid
+ , locked = m.locked
+ , hidden = m.hidden
+ , private = m.private
+ , nolastmod = m.nolastmod
+ , delete = m.delete
+ , boards = m.boards
+ , boards_locked = m.boardsLocked
+ , poll = if m.pollEnabled then m.poll else Nothing
+ , title = m.title
+ , msg = m.msg.data
}
@@ -87,7 +90,7 @@ numPollOptions : Model -> Int
numPollOptions model = Maybe.withDefault 0 (Maybe.map (\o -> List.length o.options) model.poll)
dupBoards : Model -> Bool
-dupBoards model = hasDuplicates (List.map (\b -> (b.btype, b.iid)) (Maybe.withDefault [] model.boards))
+dupBoards model = hasDuplicates (List.map (\b -> (b.btype, Maybe.withDefault "" b.iid)) (Maybe.withDefault [] model.boards))
isValid : Model -> Bool
isValid model = not (model.boards == Just [] || dupBoards model || Maybe.map (\p -> p.max_options < 1 || p.max_options > numPollOptions model) model.poll == Just True)
@@ -98,13 +101,15 @@ type Msg
| Hidden Bool
| Private Bool
| Nolastmod Bool
+ | Delete Bool
| Content TP.Msg
| Title String
+ | BoardsLocked Bool
| BoardDel Int
| BoardSearch (A.Msg GApi.ApiBoardResult)
| PollEnabled Bool
| PollQ String
- | PollMax Int
+ | PollMax (Maybe Int)
| PollOpt Int String
| PollRem Int
| PollAdd
@@ -119,21 +124,23 @@ update msg model =
Hidden b -> ({ model | hidden = b }, Cmd.none)
Private b -> ({ model | private = b }, Cmd.none)
Nolastmod b -> ({ model | nolastmod=b }, Cmd.none)
+ Delete b -> ({ model | delete = b }, Cmd.none)
Content m -> let (nm,nc) = TP.update m model.msg in ({ model | msg = nm }, Cmd.map Content nc)
Title s -> ({ model | title = Just s }, Cmd.none)
PollEnabled b -> ({ model | pollEnabled = b, poll = if model.poll == Nothing then Just { question = "", max_options = 1, options = ["",""] } else model.poll }, Cmd.none)
PollQ s -> ({ model | poll = Maybe.map (\p -> { p | question = s}) model.poll }, Cmd.none)
- PollMax n -> ({ model | poll = Maybe.map (\p -> { p | max_options = n}) model.poll }, Cmd.none)
+ PollMax n -> ({ model | poll = Maybe.map (\p -> { p | max_options = Maybe.withDefault 0 n}) model.poll }, Cmd.none)
PollOpt n s -> ({ model | poll = Maybe.map (\p -> { p | options = modidx n (always s) p.options }) model.poll }, Cmd.none)
PollRem n -> ({ model | poll = Maybe.map (\p -> { p | options = delidx n p.options }) model.poll }, Cmd.none)
PollAdd -> ({ model | poll = Maybe.map (\p -> { p | options = p.options ++ [""] }) model.poll }, Cmd.none)
+ BoardsLocked b-> ({ model | boardsLocked = b }, Cmd.none)
BoardDel i -> ({ model | boards = Maybe.map (\b -> delidx i b) model.boards }, Cmd.none)
BoardSearch m ->
let (nm, c, res) = A.update searchConfig m model.boardAdd
in case res of
Nothing -> ({ model | boardAdd = nm }, c)
- Just r -> ({ model | boardAdd = A.clear nm, boards = Maybe.map (\b -> b ++ [r]) model.boards }, c)
+ Just r -> ({ model | boardAdd = A.clear nm "", boards = Maybe.map (\b -> b ++ [r]) model.boards }, c)
Submit -> ({ model | state = Api.Loading }, GDE.send (encode model) Submitted)
Submitted (GApi.Redirect s) -> (model, load s)
@@ -143,32 +150,36 @@ update msg model =
view : Model -> Html Msg
view model =
let
- thread = model.tid == Nothing || model.num == Just 1
-
board n bd =
li [] <|
- [ text "["
- , a [ href "#", onClickD (BoardDel n), tabindex 10 ] [ text "remove" ]
- , text "] "
+ [ if model.boardsLocked then text "" else span []
+ [ text "["
+ , a [ href "#", onClickD (BoardDel n), tabindex 10 ] [ text "remove" ]
+ , text "] "
+ ]
, text (Maybe.withDefault "" (lookup bd.btype boardTypes))
- ] ++ case (bd.btype, bd.title) of
- (_, Just title) ->
- [ b [ class "grayedout" ] [ text " > " ]
- , a [ href <| bd.btype ++ String.fromInt bd.iid ] [ text title ]
+ ] ++ case (bd.btype, bd.iid, bd.title) of
+ (_, Just iid, Just title) ->
+ [ small [] [ text " > " ]
+ , a [ href <| "/" ++ iid ] [ text title ]
]
- ("u", _) -> [ b [ class "grayedout" ] [ text " > " ], text <| bd.btype ++ String.fromInt bd.iid ++ " (deleted)" ]
- (_, _) -> []
+ ("u", Just iid, _) -> [ small [] [ text " > " ], text <| iid ++ " (deleted)" ]
+ _ -> []
boards () =
- [ text "You can link this thread to multiple boards. Every visual novel, producer and user in the database has its own board,"
+ [ if not model.can_mod then text ""
+ else label [] [ inputCheck "" model.boardsLocked BoardsLocked, text " Lock boards.", br [] [] ]
+ , text "You can link this thread to multiple boards. Every visual novel, producer and user in the database has its own board,"
, text " but you can also use the \"General Discussions\" and \"VNDB Discussions\" boards for threads that do not fit at a particular database entry."
, ul [ style "list-style-type" "none", style "margin" "10px" ] <| List.indexedMap board (Maybe.withDefault [] model.boards)
- , A.view searchConfig model.boardAdd [placeholder "Add boards..."]
+ , if model.boardsLocked
+ then text "Boards are locked, only a moderator can move this thread."
+ else A.view searchConfig model.boardAdd [placeholder "Add boards..."]
] ++
if model.boards == Just []
- then [ b [ class "standout" ] [ text "Please add at least one board." ] ]
+ then [ b [] [ text "Please add at least one board." ] ]
else if dupBoards model
- then [ b [ class "standout" ] [ text "List contains duplicates." ] ]
+ then [ b [] [ text "List contains duplicates." ] ]
else []
pollOpt n p =
@@ -179,14 +190,14 @@ view model =
else text ""
]
- poll () =
+ poll =
[ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "" [ label [] [ inputCheck "" model.pollEnabled PollEnabled, text " Add poll" ] ]
] ++
case (model.pollEnabled, model.poll) of
(True, Just p) ->
[ if model.pollEdit
- then formField "" [ b [ class "standout" ] [ text "Votes will be reset if any changes are made to these options!" ] ]
+ then formField "" [ b [] [ text "Votes will be reset if any changes are made to these options!" ] ]
else text ""
, formField "pollq::Poll question" [ inputText "pollq" p.question PollQ (style "width" "400px" :: GDE.valPollQuestion) ]
, formField "Options"
@@ -196,7 +207,7 @@ view model =
else text ""
]
, formField ""
- [ inputNumber "" p.max_options PollMax <| GDE.valPollMax_Options ++ [ Html.Attributes.max <| String.fromInt <| List.length p.options ]
+ [ inputNumber "" (Just p.max_options) PollMax <| GDE.valPollMax_Options ++ [ Html.Attributes.max <| String.fromInt <| List.length p.options ]
, text " Number of options people are allowed to choose."
]
]
@@ -204,37 +215,37 @@ view model =
in
- form_ Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text <| if model.tid == Nothing then "Create new thread" else "Edit post" ]
+ form_ "" Submit (model.state == Api.Loading)
+ [ article []
+ [ h1 [] [ text <| if model.tid == Nothing then "Create new thread" else "Edit thread" ]
, table [ class "formtable" ] <|
- [ if thread
- then formField "title::Thread title" [ inputText "title" (Maybe.withDefault "" model.title) Title (style "width" "400px" :: GDE.valTitle) ]
- else formField "Topic" [ a [ href <| "/t" ++ String.fromInt (Maybe.withDefault 0 model.tid) ] [ text (Maybe.withDefault "" model.title) ] ]
- , if thread && model.can_mod
+ [ formField "title::Thread title" [ inputText "title" (Maybe.withDefault "" model.title) Title (style "width" "400px" :: required True :: GDE.valTitle) ]
+ , if model.can_mod
then formField "" [ label [] [ inputCheck "" model.locked Locked, text " Locked" ] ]
else text ""
, if model.can_mod
then formField "" [ label [] [ inputCheck "" model.hidden Hidden, text " Hidden" ] ]
else text ""
- , if thread && model.can_private
+ , if model.can_private
then formField "" [ label [] [ inputCheck "" model.private Private, text " Private" ] ]
else text ""
, if model.tid /= Nothing && model.can_mod
then formField "" [ label [] [ inputCheck "" model.nolastmod Nolastmod, text " Don't update last modification timestamp" ] ]
else text ""
- , if thread
- then formField "boardadd::Boards" (boards ())
- else text ""
+ , formField "boardadd::Boards" (boards ())
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "msg::Message"
[ TP.view "msg" model.msg Content 700 ([rows 12, cols 50] ++ GDE.valMsg)
- [ b [ class "standout" ] [ text " (English please!) " ]
- , a [ href "/d9#3" ] [ text "Formatting" ]
+ [ b [] [ text " (English please!) " ]
+ , a [ href "/d9#4" ] [ text "Formatting" ]
]
]
- ] ++ if thread then poll () else []
+ ]
+ ++ poll
+ ++ (if not model.can_mod || model.tid == Nothing then [] else
+ [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "DANGER ZONE" ] ]
+ , formField "" [ inputCheck "" model.delete Delete, text " Permanently delete this thread and all replies. This action can not be reverted, only do this with obvious spam!" ]
+ ])
]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state (isValid model) ] ]
+ , article [ class "submit" ] [ submitButton "Submit" model.state (isValid model) ]
]
diff --git a/elm/Discussions/Poll.elm b/elm/Discussions/Poll.elm
index 04761530..6764bfbd 100644
--- a/elm/Discussions/Poll.elm
+++ b/elm/Discussions/Poll.elm
@@ -109,8 +109,8 @@ view model =
else text ""
]
in
- form_ Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ form_ "" Submit (model.state == Api.Loading)
+ [ article []
[ h1 [] [ text model.data.question ]
, table [ class "votebooth" ]
[ if model.data.can_vote && model.data.max_options > 1
@@ -120,9 +120,9 @@ view model =
[ td [ class "tc1" ]
[ if model.data.can_vote
then submitButton "Vote" model.state True
- else b [ class "standout" ] [ text "You must be logged in to be able to vote." ]
+ else b [] [ text "You must be logged in to be able to vote." ]
, if toomany model
- then b [ class "standout" ] [ text "Too many options selected." ]
+ then b [] [ text "Too many options selected." ]
else text ""
]
, td [ class "tc2" ]
diff --git a/elm/Discussions/PostEdit.elm b/elm/Discussions/PostEdit.elm
new file mode 100644
index 00000000..00b833ba
--- /dev/null
+++ b/elm/Discussions/PostEdit.elm
@@ -0,0 +1,112 @@
+module Discussions.PostEdit 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 Gen.Api as GApi
+import Gen.DiscussionsPostEdit as GPE
+
+
+main : Program GPE.Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { state : Api.State
+ , id : String
+ , num : Int
+ , can_mod : Bool
+ , hidden : Maybe String
+ , nolastmod : Bool
+ , delete : Bool
+ , msg : TP.Model
+ }
+
+
+init : GPE.Recv -> Model
+init d =
+ { state = Api.Normal
+ , id = d.id
+ , num = d.num
+ , can_mod = d.can_mod
+ , hidden = d.hidden
+ , nolastmod = False
+ , delete = False
+ , msg = TP.bbcode d.msg
+ }
+
+encode : Model -> GPE.Send
+encode m =
+ { id = m.id
+ , num = m.num
+ , hidden = m.hidden
+ , nolastmod = m.nolastmod
+ , delete = m.delete
+ , msg = m.msg.data
+ }
+
+
+type Msg
+ = Hidden (Maybe String)
+ | Nolastmod Bool
+ | Delete Bool
+ | Content TP.Msg
+ | Submit
+ | Submitted GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Hidden s -> ({ model | hidden = s }, Cmd.none)
+ Nolastmod b -> ({ model | nolastmod = b }, Cmd.none)
+ Delete b -> ({ model | delete = b }, Cmd.none)
+ Content m -> let (nm,nc) = TP.update m model.msg in ({ model | msg = nm }, Cmd.map Content nc)
+
+ Submit -> ({ model | state = Api.Loading }, GPE.send (encode model) Submitted)
+ Submitted (GApi.Redirect s) -> (model, load s)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ form_ "" Submit (model.state == Api.Loading)
+ [ article []
+ [ h1 [] [ text "Edit post" ]
+ , table [ class "formtable" ] <|
+ [ formField "Post" [ a [ href <| "/" ++ model.id ++ "." ++ String.fromInt model.num ] [ text <| "#" ++ String.fromInt model.num ++ " on " ++ model.id ] ]
+ , if model.can_mod
+ then formField ""
+ [ label [] [ inputCheck "" (model.hidden /= Nothing) (\b -> Hidden (if b then Just "" else Nothing)), text " Hidden" ]
+ , Maybe.withDefault (text "") <| Maybe.map (\msg ->
+ span [] [ br [] [], inputText "" msg (Just >> Hidden) [placeholder "(Optional) reason for deletion", style "width" "500px"] ]
+ ) model.hidden
+ ]
+ else text ""
+ , if model.can_mod
+ then formField "" [ label [] [ inputCheck "" model.nolastmod Nolastmod, text " Don't update last modification timestamp" ] ]
+ else text ""
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , formField "msg::Message"
+ [ TP.view "msg" model.msg Content 700 ([rows 12, cols 50] ++ GPE.valMsg)
+ [ b [] [ text " (English please!) " ]
+ , a [ href "/d9#4" ] [ text "Formatting" ]
+ ]
+ ]
+ ]
+ ++ (if not model.can_mod then [] else
+ [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "DANGER ZONE" ] ]
+ , formField "" [ inputCheck "" model.delete Delete, text " Permanently delete this post. This action can not be reverted, only do this with obvious spam!" ]
+ ])
+ ]
+ , article [ class "submit" ] [ submitButton "Submit" model.state True ]
+ ]
diff --git a/elm/Discussions/Reply.elm b/elm/Discussions/Reply.elm
deleted file mode 100644
index a8d25434..00000000
--- a/elm/Discussions/Reply.elm
+++ /dev/null
@@ -1,82 +0,0 @@
-module Discussions.Reply exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load,reload)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.DiscussionsReply as GDR
-
-
-main : Program GDR.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-
-type alias Model =
- { state : Api.State
- , tid : Int
- , old : Bool
- , msg : TP.Model
- }
-
-
-init : GDR.Recv -> Model
-init e =
- { state = Api.Normal
- , tid = e.tid
- , old = e.old
- , msg = TP.bbcode ""
- }
-
-
-type Msg
- = NotOldAnymore
- | Content TP.Msg
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- NotOldAnymore -> ({ model | old = False }, Cmd.none)
- Content m -> let (nm,nc) = TP.update m model.msg in ({ model | msg = nm }, Cmd.map Content nc)
-
- Submit -> ({ model | state = Api.Loading }, GDR.send { msg = model.msg.data, tid = model.tid } Submitted)
- -- Reload is necessary because s may be the same as the current URL (with a location.hash)
- Submitted (GApi.Redirect s) -> (model, Cmd.batch [ load s, reload ])
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- form_ Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ] <| [
- if model.old
- then
- p [ class "center" ]
- [ text "This thread has not seen any activity for more than 6 months, but you may still "
- , a [ href "#", onClickD NotOldAnymore ] [ text "reply" ]
- , text " if you have something relevant to add."
- , text " If your message is not directly relevant to this thread, perhaps it's better to "
- , a [ href "/t/ge/new" ] [ text "create a new thread" ]
- , text " instead."
- ]
- else
- fieldset [ class "submit" ]
- [ TP.view "msg" model.msg Content 600 ([rows 4, cols 50] ++ GDR.valMsg)
- [ b [] [ text "Quick reply" ]
- , b [ class "standout" ] [ text " (English please!) " ]
- , a [ href "/d9#3" ] [ text "Formatting" ]
- ]
- , submitButton "Submit" model.state True
- ]
- ] ]
diff --git a/elm/DocEdit.elm b/elm/DocEdit.elm
deleted file mode 100644
index 9fbea631..00000000
--- a/elm/DocEdit.elm
+++ /dev/null
@@ -1,102 +0,0 @@
-module DocEdit exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Lib.Ffi as Ffi
-import Lib.Editsum as Editsum
-import Gen.Api as GApi
-import Gen.DocEdit as GD
-
-
-main : Program GD.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-
-type alias Model =
- { state : Api.State
- , editsum : Editsum.Model
- , title : String
- , content : TP.Model
- , id : Int
- }
-
-
-init : GD.Recv -> Model
-init d =
- { state = Api.Normal
- , editsum = { authmod = True, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden }
- , title = d.title
- , content = TP.markdown d.content
- , id = d.id
- }
-
-
-encode : Model -> GD.Send
-encode model =
- { id = model.id
- , editsum = model.editsum.editsum.data
- , hidden = model.editsum.hidden
- , locked = model.editsum.locked
- , title = model.title
- , content = model.content.data
- }
-
-
-type Msg
- = Editsum Editsum.Msg
- | Submit
- | Submitted GApi.Response
- | Title String
- | Content TP.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)
- Title s -> ({ model | title = s }, Cmd.none)
- Content m -> let (nm,nc) = TP.update m model.content in ({ model | content = nm }, Cmd.map Content nc)
-
- Submit -> ({ model | state = Api.Loading }, GD.send (encode model) Submitted)
- Submitted (GApi.Redirect s) -> (model, load s)
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- form_ Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text <| "Edit d" ++ String.fromInt model.id ]
- , table [ class "formtable" ]
- [ formField "title::Title" [ inputText "title" model.title Title (style "width" "300px" :: GD.valTitle) ]
- , formField "none"
- [ br_ 1
- , b [] [ text "Contents" ]
- , TP.view "content" model.content Content 850 ([rows 50, cols 90] ++ GD.valContent)
- [ text "HTML and MultiMarkdown supported, which is "
- , a [ href "https://daringfireball.net/projects/markdown/basics", target "_blank" ] [ text "Markdown" ]
- , text " with some "
- , a [ href "http://fletcher.github.io/MultiMarkdown-5/syntax.html", target "_blank" ][ text "extensions" ]
- , text "."
- ]
- ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state True
- ]
- ]
- ]
diff --git a/elm/ImageFlagging.elm b/elm/ImageFlagging.elm
new file mode 100644
index 00000000..7f829f95
--- /dev/null
+++ b/elm/ImageFlagging.elm
@@ -0,0 +1,353 @@
+port module ImageFlagging exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Array
+import Dict
+import Browser
+import Browser.Events as EV
+import Browser.Dom as DOM
+import Task
+import Process
+import Json.Decode as JD
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Lib.Api as Api
+import Lib.Ffi as Ffi
+import Gen.Api as GApi
+import Gen.Images as GI
+import Gen.ImageVote as GIV
+
+
+main : Program GI.Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = \m -> Sub.batch <| EV.onResize Resize :: if m.warn || m.myVotes < 100 then [] else [ EV.onKeyDown (keydown m), EV.onKeyUp (keyup m) ]
+ }
+
+
+port preload : String -> Cmd msg
+
+
+type alias Model =
+ { warn : Bool
+ , single : Bool
+ , fullscreen: Bool
+ , showVotes : Bool
+ , myVotes : Int
+ , nsfwToken : String
+ , mod : Bool
+ , exclVoted : Bool
+ , images : Array.Array GApi.ApiImageResult
+ , index : Int
+ , desc : (Maybe Int, Maybe Int)
+ , changes : Dict.Dict String GIV.SendVotes
+ , saved : Bool
+ , saveTimer : Bool
+ , saveState : Api.State
+ , loadState : Api.State
+ , loadDone : Bool -- If we have received the last batch of images
+ , pWidth : Int
+ , pHeight : Int
+ }
+
+init : GI.Recv -> Model
+init d =
+ { warn = d.warn
+ , single = d.single
+ , fullscreen= False
+ , showVotes = d.single
+ , myVotes = d.my_votes
+ , nsfwToken = d.nsfw_token
+ , mod = d.mod
+ , exclVoted = True
+ , images = Array.fromList d.images
+ , index = if d.single then 0 else List.length d.images
+ , desc = Maybe.withDefault (Nothing,Nothing) <| Maybe.map (\i -> (i.my_sexual, i.my_violence)) <| if d.single then List.head d.images else Nothing
+ , changes = Dict.empty
+ , saved = False
+ , saveTimer = False
+ , saveState = Api.Normal
+ , loadState = Api.Normal
+ , loadDone = False
+ , pWidth = d.pWidth
+ , pHeight = d.pHeight
+ }
+
+
+keyToVote : Model -> String -> Maybe (Maybe Int, Maybe Int, Bool)
+keyToVote model k =
+ let (s,v,o) = Maybe.withDefault (Nothing,Nothing,False) <| Maybe.map (\i -> (i.my_sexual, i.my_violence, i.my_overrule)) <| Array.get model.index model.images
+ in case k of
+ "1" -> Just (Just 0, Just 0, o)
+ "2" -> Just (Just 1, Just 0, o)
+ "3" -> Just (Just 2, Just 0, o)
+ "4" -> Just (Just 0, Just 1, o)
+ "5" -> Just (Just 1, Just 1, o)
+ "6" -> Just (Just 2, Just 1, o)
+ "7" -> Just (Just 0, Just 2, o)
+ "8" -> Just (Just 1, Just 2, o)
+ "9" -> Just (Just 2, Just 2, o)
+ "s" -> Just (Just 0, v, o)
+ "d" -> Just (Just 1, v, o)
+ "f" -> Just (Just 2, v, o)
+ "j" -> Just (s, Just 0, o)
+ "k" -> Just (s, Just 1, o)
+ "l" -> Just (s, Just 2, o)
+ _ -> Nothing
+
+keydown : Model -> JD.Decoder Msg
+keydown model = JD.andThen (\k -> keyToVote model k |> Maybe.map (\(s,v,_) -> JD.succeed (Desc s v)) |> Maybe.withDefault (JD.fail "")) (JD.field "key" JD.string)
+
+keyup : Model -> JD.Decoder Msg
+keyup model =
+ JD.andThen (\k ->
+ case k of
+ "ArrowLeft" -> JD.succeed Prev
+ "ArrowRight" -> JD.succeed Next
+ "v" -> JD.succeed (Fullscreen (not model.fullscreen))
+ "Escape" -> JD.succeed (Fullscreen False)
+ _ -> keyToVote model k |> Maybe.map (\(s,v,o) -> JD.succeed (Vote s v o True)) |> Maybe.withDefault (JD.fail "")
+ ) (JD.field "key" JD.string)
+
+
+type Msg
+ = SkipWarn
+ | ExclVoted Bool
+ | ShowVotes
+ | Fullscreen Bool
+ | Desc (Maybe Int) (Maybe Int)
+ | Load GApi.Response
+ | Vote (Maybe Int) (Maybe Int) Bool Bool
+ | Save
+ | Saved GApi.Response
+ | Prev
+ | Next
+ | Focus String
+ | Resize Int Int
+
+
+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.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
+ save (m,c) =
+ if not m.saveTimer && not (Dict.isEmpty m.changes) && m.saveState /= Api.Loading
+ then ({ m | saveTimer = True }, Cmd.batch [ c, Task.perform (always Save) (Process.sleep (if m.single then 500 else 5000)) ])
+ else (m,c)
+ -- Set desc and showVotes to current image
+ desc (m,c) =
+ let v = Maybe.withDefault (Nothing,Nothing) <| Maybe.map (\i -> (i.my_sexual, i.my_violence)) <| Array.get m.index m.images
+ in ({ m | desc = v, showVotes = m.single || (Tuple.first v /= Nothing && Tuple.second v /= Nothing)}, c)
+ -- Preload next image
+ pre (m, c) =
+ case Array.get (m.index+1) m.images of
+ Just i -> (m, Cmd.batch [ c, preload (imageUrl "" i.id) ])
+ Nothing -> (m, c)
+ in
+ case msg of
+ SkipWarn -> load ({ model | warn = False }, Cmd.none)
+ ExclVoted b -> ({ model | exclVoted = b }, Cmd.none)
+ ShowVotes -> ({ model | showVotes = not model.showVotes }, Cmd.none)
+ Fullscreen b -> ({ model | fullscreen = b }, Cmd.none)
+ Desc s v -> ({ model | desc = (s,v) }, Cmd.none)
+
+ Load (GApi.ImageResult 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)
+ Load e -> ({ model | loadState = Api.Error e }, Cmd.none)
+
+ Vote s v o _ ->
+ case Array.get model.index model.images of
+ Nothing -> (model, Cmd.none)
+ Just i ->
+ let m = { model | saved = False, images = Array.set model.index { i | my_sexual = s, my_violence = v, my_overrule = o } model.images }
+ adv = if not m.single && (not model.exclVoted || i.my_sexual == Nothing || i.my_violence == Nothing) then 1 else 0
+ in case (i.token,s,v) of
+ -- Complete vote, mark it as a change and go to next image
+ (Just token, Just xs, Just xv) -> desc <| pre <| save <| load
+ ({ m | index = m.index + adv
+ , myVotes = m.myVotes + adv
+ , changes = Dict.insert i.id { id = i.id, token = token, sexual = xs, violence = xv, overrule = o } m.changes
+ }, Cmd.none)
+ -- Otherwise just save it internally
+ _ -> (m, Cmd.none)
+
+ Save -> ({ model | saveTimer = False, saveState = Api.Loading, changes = Dict.empty }, GIV.send { votes = Dict.values model.changes } Saved)
+ Saved r -> save ({ model | saved = True, saveState = if r == GApi.Success then Api.Normal else Api.Error r }, Cmd.none)
+
+ Prev -> desc ({ model | saved = False, index = model.index - (if model.index == 0 then 0 else 1) }, Cmd.none)
+ Next -> desc <| pre <| load ({ model | saved = False, index = model.index + (if model.single then 0 else 1) }, Cmd.none)
+
+ -- Unfocus a vote radio button when it is focussed in order to prevent arrow keys from changing selection.
+ Focus s -> (model, Task.attempt (always SkipWarn) (DOM.blur s))
+
+ Resize width height -> ({ model | pWidth = width, pHeight = height }, Cmd.none)
+
+
+
+view : Model -> Html Msg
+view model =
+ let
+ boxwidth = clamp 600 1200 <| model.pWidth - 300
+ boxheight = clamp 300 700 <| model.pHeight - clamp 200 350 (model.pHeight - 500)
+ px n = String.fromInt n ++ "px"
+ stat avg stddev =
+ case (avg, stddev) of
+ (Just a, Just s) -> Ffi.fmtFloat a 2 ++ " σ " ++ Ffi.fmtFloat s 2
+ _ -> "-"
+
+ but i s v lid lbl =
+ let sel = i.my_sexual == s && i.my_violence == v
+ in li [ classList [("sel", sel || (s /= i.my_sexual && Tuple.first model.desc == s) || (v /= i.my_violence && Tuple.second model.desc == v))] ]
+ [ label [ onMouseOver (Desc s v), onMouseOut (Desc i.my_sexual i.my_violence) ]
+ [ input [ type_ "radio", onCheck (Vote s v i.my_overrule), checked sel, onFocus (Focus lid), id lid ] [], text lbl ]
+ ]
+
+ votestats i =
+ let num = String.fromInt i.votecount ++ (if i.votecount == 1 then " vote" else " votes")
+ in div [] <|
+ if List.isEmpty i.votes
+ then [ p [ class "center" ] [ text "No other votes on this image yet." ] ]
+ else if not model.showVotes
+ then [ p [ class "center" ] [ text num, text ", ", a [ href "#", onClickD ShowVotes ] [ text "show »" ] ] ]
+ else
+ [ p [ class "center" ]
+ [ text num
+ , small [] [ text " / " ], text <| "sexual: " ++ stat i.sexual_avg i.sexual_stddev
+ , small [] [ text " / " ], text <| "violence: " ++ stat i.violence_avg i.violence_stddev
+ ]
+ , table [] <|
+ List.map (\v ->
+ tr [ classList [("ignored", v.ignore)]]
+ [ td [ Ffi.innerHtml v.user ] []
+ , td [] [ text <| if v.sexual == 0 then "Safe" else if v.sexual == 1 then "Suggestive" else "Explicit" ]
+ , td [] [ text <| if v.violence == 0 then "Tame" else if v.violence == 1 then "Violent" else "Brutal" ]
+ , td [] <| Maybe.withDefault [] <| Maybe.map (\u -> [ a [ href <| "/img/list?view=" ++ model.nsfwToken ++ "&u=" ++ u ] [ text "votes" ] ]) v.uid
+ ]
+ ) i.votes
+ ]
+
+ imgView i =
+ [ div []
+ [ inputButton "««" Prev [ classList [("invisible", model.index == 0)] ]
+ , span [] <|
+ case i.entry of
+ Nothing -> []
+ Just e ->
+ [ small [] [ text (e.id ++ ":") ]
+ , a [ href ("/" ++ e.id) ] [ text e.title ]
+ ]
+ , inputButton "»»" Next [ classList [("invisible", model.single)] ]
+ ]
+ , div [ style "width" (px (boxwidth + 10)), style "height" (px boxheight) ] <|
+ -- Don't use an <img> here, changing the src= causes the old image to be displayed with the wrong dimensions while the new image is being loaded.
+ [ a [ href (imageUrl "" i.id), style "background-image" ("url("++imageUrl "" i.id++")")
+ , style "background-size" (if i.width > boxwidth || i.height > boxheight then "contain" else "auto")
+ ] [ text "" ] ]
+ , div []
+ [ span [] <|
+ case model.saveState of
+ Api.Error e -> [ b [] [ text <| "Save failed: " ++ Api.showResponse e ] ]
+ _ ->
+ [ span [ class "spinner", classList [("invisible", model.saveState == Api.Normal)] ] []
+ , small [] [ text <|
+ if not (Dict.isEmpty model.changes)
+ then "Unsaved votes: " ++ String.fromInt (Dict.size model.changes)
+ else if model.saved then "Saved!" else "" ]
+ ]
+ , span []
+ [ a [ href <| "/img/" ++ i.id ] [ text i.id ]
+ , small [] [ text " / " ]
+ , a [ href (imageUrl "" i.id) ] [ text <| String.fromInt i.width ++ "x" ++ String.fromInt i.height ]
+ ]
+ ]
+ , div [] <| if i.token == Nothing then [] else
+ [ p [] <|
+ case Tuple.first model.desc of
+ Just 0 -> [ strong [] [ text "Safe" ], br [] []
+ , text "- No nudity", br [] []
+ , text "- No (implied) sexual actions", br [] []
+ , text "- No suggestive clothing or visible underwear", br [] []
+ , text "- No sex toys" ]
+ Just 1 -> [ strong [] [ text "Suggestive" ], br [] []
+ , text "- Visible underwear or skimpy clothing", br [] []
+ , text "- Erotic posing", br [] []
+ , text "- Sex toys (but not visibly being used)", br [] []
+ , text "- No visible genitals or female nipples" ]
+ Just 2 -> [ strong [] [ text "Explicit" ], br [] []
+ , text "- Visible genitals or female nipples", br [] []
+ , text "- Penetrative sex (regardless of clothing)", br [] []
+ , text "- Visible use of sex toys" ]
+ _ -> []
+ , ul []
+ [ li [] [ strong [] [ text "Sexual" ] ]
+ , but i (Just 0) i.my_violence "vio0" " Safe"
+ , but i (Just 1) i.my_violence "vio1" " Suggestive"
+ , but i (Just 2) i.my_violence "vio2" " Explicit"
+ , if model.mod then li [ class "overrule" ] [ label [ title "Overrule" ] [ inputCheck "" i.my_overrule (\b -> Vote i.my_sexual i.my_violence b True), text " Overrule" ] ] else text ""
+ ]
+ , ul []
+ [ li [] [ strong [] [ text "Violence" ] ]
+ , but i i.my_sexual (Just 0) "sex0" " Tame"
+ , but i i.my_sexual (Just 1) "sex1" " Violent"
+ , but i i.my_sexual (Just 2) "sex2" " Brutal"
+ ]
+ , p [] <|
+ case Tuple.second model.desc of
+ Just 0 -> [ strong [] [ text "Tame" ], br [] []
+ , text "- No visible violence", br [] []
+ , text "- Tame slapstick comedy", br [] []
+ , text "- Weapons, but not used to harm anyone", br [] []
+ , text "- Only very minor visible blood or bruises", br [] [] ]
+ Just 1 -> [ strong [] [ text "Violent" ], br [] []
+ , text "- Visible blood", br [] []
+ , text "- Non-comedic fight scenes", br [] []
+ , text "- Physically harmful activities" ]
+ Just 2 -> [ strong [] [ text "Brutal" ], br [] []
+ , text "- Excessive amounts of blood", br [] []
+ , text "- Cut off limbs", br [] []
+ , text "- Sliced-open bodies", br [] []
+ , text "- Harmful activities leading to death" ]
+ _ -> []
+ ]
+ , p [ class "center" ] <| if i.token == Nothing then [] else
+ [ text "Not sure? Read the ", a [ href "/d19" ] [ text "full guidelines" ], text " for more detailed guidance."
+ , if model.myVotes < 100 then text "" else
+ span [] [ text " (", a [ href <| Ffi.urlStatic ++ "/f/imgvote-keybindings.svg" ] [ text "keyboard shortcuts" ], text ")" ]
+ ]
+ , votestats i
+ , if model.fullscreen -- really lazy fullscreen mode
+ then div [ class "fullscreen", style "background-image" ("url("++imageUrl "" i.id++")"), onClick (Fullscreen False) ] [ text "" ]
+ else text ""
+ ]
+
+ in article []
+ [ h1 [] [ text "Image flagging" ]
+ , div [ class "imageflag", style "width" (px (boxwidth + 10)) ] <|
+ if model.warn
+ then [ ul []
+ [ li [] [ text "Make sure you are familiar with the ", a [ href "/d19" ] [ text "image flagging guidelines" ], text "." ]
+ , li [] [ b [] [ text "WARNING: " ], text "Images shown may include spoilers, be highly offensive and/or contain very explicit depictions of sexual acts." ]
+ ]
+ , br [] []
+ , if model.single
+ then text ""
+ else label [] [ inputCheck "" (not model.exclVoted) (\b -> ExclVoted (not b)), text " Include images I already voted on.", br [] [] ]
+ , inputButton "Continue" SkipWarn []
+ ]
+ else case (Array.get model.index model.images, model.loadState) of
+ (Just i, _) -> imgView i
+ (_, Api.Loading) -> [ span [ class "spinner" ] [] ]
+ (_, Api.Error e) -> [ b [] [ text <| Api.showResponse e ] ]
+ (_, Api.Normal) -> [ text "No more images to vote on!" ]
+ ]
diff --git a/elm/Lib/Api.elm b/elm/Lib/Api.elm
index 112dcc81..5b1bf583 100644
--- a/elm/Lib/Api.elm
+++ b/elm/Lib/Api.elm
@@ -1,6 +1,7 @@
module Lib.Api exposing (..)
import Json.Encode as JE
+import File exposing (File)
import Http
import Gen.Api exposing (..)
@@ -22,28 +23,37 @@ showResponse res =
in case res of
HTTPError (Http.Timeout) -> "Network timeout, please try again later."
HTTPError (Http.NetworkError) -> "Network error, please try again later."
- HTTPError (Http.BadStatus r) -> "Server error " ++ String.fromInt r ++ ", please try again later, or report an issue if this persists."
+ HTTPError (Http.BadStatus 403) -> "Permission denied. Your session may have expired, try reloading the page."
+ HTTPError (Http.BadStatus 413) -> "File upload too large."
+ HTTPError (Http.BadStatus 429) -> "Action throttled, please try again later."
+ HTTPError (Http.BadStatus r) -> "Server error " ++ String.fromInt r ++ ", please try again later or report an issue if this persists."
HTTPError (Http.BadBody r) -> "Invalid response from the server, please report a bug (debug info: " ++ r ++")."
HTTPError (Http.BadUrl _) -> unexp
Success -> unexp
Redirect _ -> unexp
- CSRF -> "Invalid CSRF token, please refresh the page and try again."
Invalid -> "Invalid form data, please report a bug."
+ Editsum -> "Invalid edit summary."
Unauth -> "You do not have the permission to perform this action."
Unchanged -> "No changes"
Content _ -> unexp
- BadLogin -> "Invalid username or password."
- LoginThrottle -> "Action throttled, too many failed login attempts."
- InsecurePass -> "Your chosen password is in a database of leaked passwords, please choose another one."
- BadEmail -> "Unknown email address."
- Bot -> "Invalid answer to the anti-bot question."
- Taken -> "Username already taken, please choose a different name."
- DoubleEmail -> "Email address already used for another account."
- DoubleIP -> "You can only register one account from the same IP within 24 hours."
- BadCurPass -> "Current password is invalid."
- MailChange -> unexp
+ ImgFormat -> "Unrecognized image format, only JPEG, PNG and WebP are accepted."
+ LabelId _ -> unexp
+ DupNames _ -> "Name or alias already in the database."
Releases _ -> unexp
+ Resolutions _ -> unexp
+ Engines _ -> unexp
+ DRM _ -> unexp
BoardResult _ -> unexp
+ TagResult _ -> unexp
+ TraitResult _ -> unexp
+ VNResult _ -> unexp
+ ProducerResult _ -> unexp
+ StaffResult _ -> unexp
+ CharResult _ -> unexp
+ AnimeResult _ -> unexp
+ ImageResult _ -> unexp
+ UListWidget _ -> unexp
+ AdvSearchQuery _ -> unexp
expectResponse : (Response -> msg) -> Http.Expect msg
@@ -64,3 +74,23 @@ post name body msg =
, body = Http.jsonBody body
, expect = expectResponse msg
}
+
+
+type ImageType
+ = Ch
+ | Cv
+ | Sf
+
+postImage : ImageType -> File -> (Response -> msg) -> Cmd msg
+postImage ty file msg =
+ Http.post
+ { url = "/elm/ImageUpload.json"
+ , body = Http.multipartBody
+ [ Http.stringPart "type" <| case ty of
+ Cv -> "cv"
+ Sf -> "sf"
+ Ch -> "ch"
+ , Http.filePart "img" file
+ ]
+ , expect = expectResponse msg
+ }
diff --git a/elm/Lib/Autocomplete.elm b/elm/Lib/Autocomplete.elm
index a478921b..4c465d7c 100644
--- a/elm/Lib/Autocomplete.elm
+++ b/elm/Lib/Autocomplete.elm
@@ -1,11 +1,23 @@
module Lib.Autocomplete exposing
( Config
+ , SearchSource(..)
, SourceConfig
, Model
, Msg
, boardSource
+ , tagSource
+ , traitSource
+ , vnSource
+ , producerSource
+ , staffSource
+ , charSource
+ , animeSource
+ , resolutionSource
+ , engineSource
+ , drmSource
, init
, clear
+ , refocus
, update
, view
)
@@ -25,6 +37,16 @@ import Lib.Api as Api
import Gen.Types exposing (boardTypes)
import Gen.Api as GApi
import Gen.Boards as GB
+import Gen.Tags as GT
+import Gen.Traits as GTR
+import Gen.VN as GV
+import Gen.Producers as GP
+import Gen.Staff as GS
+import Gen.Chars as GC
+import Gen.Anime as GA
+import Gen.Resolutions as GR
+import Gen.Engines as GE
+import Gen.DRM as GDRM
type alias Config m a =
@@ -37,11 +59,18 @@ type alias Config m a =
}
+type SearchSource m a
+ -- API endpoint to query for completion results + Function to decode results from the API
+ = Endpoint (String -> (GApi.Response -> m) -> Cmd m) (GApi.Response -> Maybe (List a))
+ -- API endpoint that returns the full list of possible results + Function to decode results from the API + Function to match results against a query
+ | LazyList ((GApi.Response -> m) -> Cmd m) (GApi.Response -> Maybe (List a)) (String -> List a -> List a)
+ -- Pure function for instant completion results
+ | Func (String -> List a)
+
+
type alias SourceConfig m a =
- -- API endpoint to query for completion results.
- { endpoint : String -> (GApi.Response -> m) -> Cmd m
- -- How to decode results from the API
- , decode : GApi.Response -> Maybe (List a)
+ -- Where to get completion results from
+ { source : SearchSource m a
-- How to display the decoded results
, view : a -> List (Html m)
-- Unique ID of an item (must not be an empty string).
@@ -51,20 +80,168 @@ type alias SourceConfig m a =
}
-
boardSource : SourceConfig m GApi.ApiBoardResult
boardSource =
- { endpoint = \s -> GB.send { search = s }
- , decode = \x -> case x of
+ { source = Endpoint (\s -> GB.send { search = s })
+ <| \x -> case x of
GApi.BoardResult e -> Just e
_ -> Nothing
, view = (\i ->
[ text <| Maybe.withDefault "" (lookup i.btype boardTypes)
] ++ case i.title of
- Just title -> [ b [ class "grayedout" ] [ text " > " ], text title ]
+ Just title -> [ small [] [ text " > " ], text title ]
_ -> []
)
- , key = \i -> i.btype ++ String.fromInt i.iid
+ , key = \i -> Maybe.withDefault i.btype i.iid
+ }
+
+
+ttStatus i =
+ case ((i.hidden, i.locked), i.searchable, i.applicable) of
+ ((True, False), _, _ ) -> small [] [ text " (awaiting approval)" ]
+ ((True, True ), _, _ ) -> small [] [ text " (deleted)" ] -- (not returned by the API for now)
+ (_, False, False) -> small [] [ text " (meta)" ]
+ (_, True, False) -> small [] [ text " (not applicable)" ]
+ (_, False, True ) -> small [] [ text " (not searchable)" ]
+ _ -> text ""
+
+
+tagSource : SourceConfig m GApi.ApiTagResult
+tagSource =
+ { source = Endpoint (\s -> GT.send { search = s })
+ <| \x -> case x of
+ GApi.TagResult e -> Just e
+ _ -> Nothing
+ , view = \i -> [ text i.name, ttStatus i ]
+ , key = \i -> i.id
+ }
+
+
+traitSource : SourceConfig m GApi.ApiTraitResult
+traitSource =
+ { source = Endpoint (\s -> GTR.send { search = s })
+ <| \x -> case x of
+ GApi.TraitResult e -> Just e
+ _ -> Nothing
+ , view = \i ->
+ [ case i.group_name of
+ Nothing -> text ""
+ Just g -> small [] [ text <| g ++ " / " ]
+ , text i.name
+ , ttStatus i
+ ]
+ , key = \i -> i.id
+ }
+
+
+vnSource : SourceConfig m GApi.ApiVNResult
+vnSource =
+ { source = Endpoint (\s -> GV.send { search = [s], hidden = False })
+ <| \x -> case x of
+ GApi.VNResult e -> Just e
+ _ -> Nothing
+ , view = \i ->
+ [ small [] [ text <| i.id ++ ": " ]
+ , text i.title ]
+ , key = \i -> i.id
+ }
+
+
+producerSource : SourceConfig m GApi.ApiProducerResult
+producerSource =
+ { source = Endpoint (\s -> GP.send { search = [s] })
+ <| \x -> case x of
+ GApi.ProducerResult e -> Just e
+ _ -> Nothing
+ , view = \i ->
+ [ small [] [ text <| i.id ++ ": " ]
+ , text i.name ]
+ , key = \i -> i.id
+ }
+
+
+staffSource : SourceConfig m GApi.ApiStaffResult
+staffSource =
+ { source = Endpoint (\s -> GS.send { search = [s] })
+ <| \x -> case x of
+ GApi.StaffResult e -> Just e
+ _ -> Nothing
+ , view = \i ->
+ [ langIcon i.lang
+ , small [] [ text <| i.id ++ ": " ]
+ , text i.title
+ , if i.alttitle == i.title then text "" else small [] [ text " ", text i.alttitle ]
+ ]
+ , key = \i -> String.fromInt i.aid
+ }
+
+
+charSource : SourceConfig m GApi.ApiCharResult
+charSource =
+ { source = Endpoint (\s -> GC.send { search = s })
+ <| \x -> case x of
+ GApi.CharResult e -> Just e
+ _ -> Nothing
+ , view = \i ->
+ [ small [] [ text <| i.id ++ ": " ]
+ , text i.title
+ , Maybe.withDefault (text "") <| Maybe.map (\m ->
+ small [] [ text <| " (instance of " ++ m.id ++ ": " ++ m.title ]
+ ) i.main
+ ]
+ , key = \i -> i.id
+ }
+
+
+animeSource : Bool -> SourceConfig m GApi.ApiAnimeResult
+animeSource ref =
+ { source = Endpoint (\s -> GA.send { search = s, ref = ref })
+ <| \x -> case x of
+ GApi.AnimeResult e -> Just e
+ _ -> Nothing
+ , view = \i ->
+ [ small [] [ text <| "a" ++ String.fromInt i.id ++ ": " ]
+ , text i.title ]
+ , key = \i -> String.fromInt i.id
+ }
+
+
+resolutionSource : SourceConfig m GApi.ApiResolutions
+resolutionSource =
+ { source = LazyList
+ (GR.send {})
+ (\x -> case x of
+ GApi.Resolutions e -> Just e
+ _ -> Nothing)
+ (\s l -> List.filter (\v -> String.contains (String.toLower s) (String.toLower v.resolution)) l |> List.take 10)
+ , view = \i -> [ text i.resolution, small [] [ text <| " (" ++ String.fromInt i.count ++ ")" ] ]
+ , key = \i -> i.resolution
+ }
+
+
+engineSource : SourceConfig m GApi.ApiEngines
+engineSource =
+ { source = LazyList
+ (GE.send {})
+ (\x -> case x of
+ GApi.Engines e -> Just e
+ _ -> Nothing)
+ (\s l -> List.filter (\v -> String.contains (String.toLower s) (String.toLower v.engine)) l |> List.take 10)
+ , view = \i -> [ text i.engine, small [] [ text <| " (" ++ String.fromInt i.count ++ ")" ] ]
+ , key = \i -> i.engine
+ }
+
+
+drmSource : SourceConfig m GApi.ApiDRM
+drmSource =
+ { source = LazyList
+ (GDRM.send {})
+ (\x -> case x of
+ GApi.DRM e -> Just e
+ _ -> Nothing)
+ (\s l -> List.filter (\v -> String.contains (String.toLower s) (String.toLower v.name)) l |> List.take 10)
+ , view = \i -> [ text i.name, small [] [ text <| " (" ++ String.fromInt i.count ++ ")" ] ]
+ , key = \i -> i.name
}
@@ -72,28 +249,33 @@ type alias Model a =
{ visible : Bool
, value : String
, results : List a
+ , all : Maybe (List a) -- Used by LazyList
, sel : String
+ , default : String
, loading : Bool
, wait : Int
}
-init : Model a
-init =
+init : String -> Model a
+init s =
{ visible = False
- , value = ""
+ , value = s
, results = []
+ , all = Nothing
, sel = ""
+ , default = s
, loading = False
, wait = 0
}
-clear : Model a -> Model a
-clear m = { m
- | value = ""
+clear : Model a -> String -> Model a
+clear m v = { m
+ | value = v
, results = []
, sel = ""
+ , default = v
, loading = False
}
@@ -123,26 +305,26 @@ select cfg offset model =
{ model | sel = Maybe.withDefault "" <| Maybe.map cfg.source.key <| get nextsel }
+-- Blur and focus the input on enter.
+refocus : Config m a -> Cmd m
+refocus cfg = Dom.blur cfg.id
+ |> Task.andThen (always (Dom.focus cfg.id))
+ |> Task.attempt (always (cfg.wrap Noop))
+
+
update : Config m a -> Msg a -> Model a -> (Model a, Cmd m, Maybe a)
update cfg msg model =
let
mod m = (m, Cmd.none, Nothing)
- -- Ugly hack: blur and focus the input on enter. This does two things:
- -- 1. If the user clicked on an entry (resulting in the 'Enter' message),
- -- then this will cause the input to be focussed again. This is
- -- convenient when adding multiple entries.
- refocus = Dom.blur cfg.id
- |> Task.andThen (always (Dom.focus cfg.id))
- |> Task.attempt (always (cfg.wrap Noop))
in
case msg of
Noop -> mod model
Blur -> mod { model | visible = False }
Focus -> mod { model | loading = False, visible = True }
Sel s -> mod { model | sel = s }
- Enter r -> (model, refocus, Just r)
+ Enter r -> (model, refocus cfg, Just r)
- Key "Enter" -> (model, refocus,
+ Key "Enter" -> (model, refocus cfg,
case List.filter (\i -> cfg.source.key i == model.sel) model.results |> List.head of
Just x -> Just x
Nothing -> List.head model.results)
@@ -151,23 +333,35 @@ update cfg msg model =
Key _ -> mod model
Input s ->
- if s == ""
- then mod { model | value = s, loading = False, results = [] }
- else ( { model | value = s, loading = True, wait = model.wait + 1 }
- , Task.perform (always <| cfg.wrap <| Search <| model.wait + 1) (Process.sleep 500)
- , Nothing )
+ let m = { model | value = s, default = "" }
+ in
+ if String.trim s == ""
+ then mod { m | loading = False, results = [] }
+ else case (cfg.source.source) of
+ Endpoint _ _ ->
+ ( { m | loading = True, wait = model.wait + 1 }
+ , Task.perform (always <| cfg.wrap <| Search <| model.wait + 1) (Process.sleep 500)
+ , Nothing )
+ LazyList e _ f ->
+ case (model.loading, model.all) of
+ (_, Just l) -> mod { m | results = f s l }
+ (True, _) -> mod m
+ (False, _) -> ({ m | loading = True }, e (cfg.wrap << Results ""), Nothing)
+ Func f -> mod { m | results = f s }
Search i ->
if model.value == "" || model.wait /= i
then mod model
- else ( model
- , cfg.source.endpoint model.value (cfg.wrap << Results model.value)
- , Nothing )
+ else case cfg.source.source of
+ Endpoint e _ -> (model, e model.value (cfg.wrap << Results model.value), Nothing)
+ LazyList _ _ _ -> mod model
+ Func _ -> mod model
Results s r -> mod <|
- if s == model.value
- then { model | loading = False, results = cfg.source.decode r |> Maybe.withDefault [] }
- else model -- Discard stale results
+ case cfg.source.source of
+ Endpoint _ d -> if s /= model.value then model else { model | loading = False, results = d r |> Maybe.withDefault [] }
+ LazyList _ d f -> let all = d r in { model | loading = False, all = all, results = Maybe.map (\l -> f model.value l) all |> Maybe.withDefault [] }
+ Func _ -> model
view : Config m a -> Model a -> List (Attribute m) -> Html m
@@ -175,7 +369,8 @@ view cfg model attrs =
let
input =
inputText cfg.id model.value (cfg.wrap << Input) <|
- [ onFocus <| cfg.wrap Focus
+ [ autocomplete False
+ , onFocus <| cfg.wrap Focus
, onBlur <| cfg.wrap Blur
, style "width" "270px"
, custom "keydown" <| JD.map (\c ->
@@ -185,7 +380,7 @@ view cfg model attrs =
) <| JD.field "key" JD.string
] ++ attrs
- visible = model.visible && model.value /= "" && not (model.loading && List.isEmpty model.results)
+ visible = model.visible && model.value /= model.default && not (model.loading && List.isEmpty model.results)
msg = [("",
if List.isEmpty model.results
@@ -206,7 +401,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 7fee4e96..050dcfac 100644
--- a/elm/Lib/DropDown.elm
+++ b/elm/Lib/DropDown.elm
@@ -1,9 +1,10 @@
-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
import Html exposing (..)
import Html.Attributes exposing (..)
+import Html.Events exposing (..)
import Lib.Api as Api
import Lib.Html exposing (..)
@@ -11,11 +12,12 @@ import Lib.Html exposing (..)
type alias Config msg =
{ id : String
, opened : Bool
+ , hover : Bool -- if true, the dropdown opens on mouse-over rather than click (not currently used)
, toggle : Bool -> msg
}
--- Returns True if the element matches the target id.
+-- Returns True if the element or any of its parents has the given id
onClickOutsideParse : String -> JD.Decoder Bool
onClickOutsideParse id =
JD.oneOf
@@ -34,12 +36,13 @@ init : String -> (Bool -> msg) -> Config msg
init id msg =
{ id = id
, opened = False
+ , hover = False
, toggle = msg
}
sub : Config msg -> Sub msg
-sub conf = if conf.opened then onClickOutside conf.id (conf.toggle False) else Sub.none
+sub conf = if conf.opened && not conf.hover then onClickOutside conf.id (conf.toggle False) else Sub.none
toggle : Config msg -> Bool -> Config msg
@@ -48,12 +51,18 @@ toggle conf opened = { conf | opened = opened }
view : Config msg -> Api.State -> Html msg -> (() -> List (Html msg)) -> Html msg
view conf status lbl cont =
- div [ class "elm_dd", id conf.id ]
- [ a [ href "#", onClickD (conf.toggle (not conf.opened)) ] <|
+ div
+ ( [ class "elm_dd", id conf.id
+ ] ++ if conf.hover then [ onMouseLeave (conf.toggle False) ] else []
+ )
+ [ a
+ ( [ href "#", onClickD (conf.toggle (if conf.hover then conf.opened else not conf.opened))
+ ] ++ if conf.hover then [ onMouseEnter (conf.toggle True) ] else []
+ ) <|
case status of
- Api.Normal -> [ lbl, span [] [ i [] [ text "▾" ] ] ]
+ Api.Normal -> [ lbl, span [] [ span [ class "arrow" ] [ text "▾" ] ] ]
Api.Loading -> [ lbl, span [] [ span [ class "spinner" ] [] ] ]
- Api.Error e -> [ b [ class "standout" ] [ text "error" ], span [] [ i [] [ text "▾" ] ] ]
+ Api.Error e -> [ b [] [ text "error" ], span [] [ span [ class "arrow" ] [ 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/Editsum.elm b/elm/Lib/Editsum.elm
index 09553a40..7320d66a 100644
--- a/elm/Lib/Editsum.elm
+++ b/elm/Lib/Editsum.elm
@@ -1,5 +1,5 @@
--- This module provides an the 'Edit summary' box, including the 'hidden' and
--- 'locked' moderation checkboxes.
+-- This module provides an the 'Edit summary' box, including the entry state
+-- option for moderators.
module Lib.Editsum exposing (Model, Msg, new, update, view)
@@ -11,6 +11,7 @@ import Lib.TextPreview as TP
type alias Model =
{ authmod : Bool
+ , hasawait : Bool
, locked : Bool
, hidden : Bool
, editsum : TP.Model
@@ -18,25 +19,24 @@ type alias Model =
type Msg
- = Locked Bool
- | Hidden Bool
+ = State Bool Bool Bool
| Editsum TP.Msg
new : Model
new =
- { authmod = False
- , locked = False
- , hidden = False
- , editsum = TP.bbcode ""
+ { authmod = False
+ , hasawait = False
+ , locked = False
+ , hidden = False
+ , editsum = TP.bbcode ""
}
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
- Locked b -> ({ model | locked = b }, Cmd.none)
- Hidden b -> ({ model | hidden = b }, Cmd.none)
+ State hid lock _ -> ({ model | hidden = hid, locked = lock }, Cmd.none)
Editsum m -> let (nm,nc) = TP.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
@@ -44,19 +44,22 @@ view : Model -> Html Msg
view model =
let
lockhid =
- [ label []
- [ inputCheck "" model.hidden Hidden
- , text " Deleted" ]
- , label []
- [ inputCheck "" model.locked Locked
- , text " Locked" ]
- , br [] []
- , text "Note: edit summary of the last edit should indicate the reason for the deletion."
+ [ label [] [ inputRadio "entry_state" (not model.hidden && not model.locked) (State False False), text " Normal " ]
+ , label [] [ inputRadio "entry_state" (not model.hidden && model.locked) (State False True ), text " Locked " ]
+ , label [] [ inputRadio "entry_state" ( model.hidden && model.locked) (State True True ), text " Deleted " ]
+ , if not model.hasawait then text "" else
+ label [] [ inputRadio "entry_state" ( model.hidden && not model.locked) (State True False), text " Awaiting approval" ]
, br [] []
+ , if model.hidden && model.locked
+ then span [] [ text "Note: edit summary of the last edit should indicate the reason for the deletion.", br [] [] ]
+ else text ""
]
in fieldset [] <|
(if model.authmod then lockhid else [])
++
[ TP.view "" model.editsum Editsum 600 [rows 4, cols 50, minlength 2, maxlength 5000, required True]
- [ b [class "title"] [ text "Edit summary", b [class "standout"] [text " (English please!)"] ] ]
+ [ strong [] [ text "Edit summary", b [] [ text " (English please!)" ] ]
+ , br [] []
+ , text "Summarize the changes you have made, including links to source(s)."
+ ]
]
diff --git a/elm/Lib/Ffi.elm b/elm/Lib/Ffi.elm
index c073b4a6..af8c963a 100644
--- a/elm/Lib/Ffi.elm
+++ b/elm/Lib/Ffi.elm
@@ -5,7 +5,7 @@
-- This module is a hack to work around the lack of an FFI (Foreign Function
-- Interface) in Elm. The functions in this module are stubs, their
-- implementations are replaced by the Makefile with calls to
--- window.elmFfi_<name> and the actual implementations are in Ffi.js.
+-- window.elmFfi_<name> and the actual implementations are in elm-support.js.
--
-- Use sparingly, all of this will likely break in future Elm versions.
module Lib.Ffi exposing (..)
@@ -22,3 +22,14 @@ innerHtml s = Html.Attributes.title ""
-- Like Browser.Dom.focus, except it can call any function (without arguments)
elemCall : String -> String -> Task.Task Browser.Dom.Error ()
elemCall s = Browser.Dom.focus
+
+-- Format a floating point number with a fixed number of fractional digits.
+-- (The coinop-logan/elm-format-number package seems to be the go-to way to do
+-- this in Elm, but why reimplement float operations when the browser can do it
+-- just as well?)
+fmtFloat : Float -> Int -> String
+fmtFloat _ _ = ""
+
+-- Base URL for static files (e.g. "https://s.vndb.org")
+urlStatic : String
+urlStatic = ""
diff --git a/elm/Lib/Ffi.js b/elm/Lib/Ffi.js
deleted file mode 100644
index c06314ff..00000000
--- a/elm/Lib/Ffi.js
+++ /dev/null
@@ -1,13 +0,0 @@
-window.elmFfi_innerHtml = function(wrap,call) { // \s -> _VirtualDom_property('innerHTML', _Json_wrap(s))
- return function(s) {
- return {
- $: 'a2',
- n: 'innerHTML',
- o: wrap(s)
- }
- }
-};
-
-window.elmFfi_elemCall = function(wrap,call) { // _Browser_call
- return call
-};
diff --git a/elm/Lib/Html.elm b/elm/Lib/Html.elm
index 2e534246..7ec8dacc 100644
--- a/elm/Lib/Html.elm
+++ b/elm/Lib/Html.elm
@@ -7,6 +7,7 @@ import Json.Decode as JD
import List
import Lib.Api as Api
import Lib.Util exposing (..)
+import Lib.Ffi as Ffi
import Gen.Types as T
@@ -25,6 +26,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 +36,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 ]
@@ -46,13 +49,13 @@ inputButton val onch attrs =
-- Submit button with loading indicator and error message display
submitButton : String -> Api.State -> Bool -> Html m
-submitButton val state valid = div []
+submitButton val state valid = span []
[ input [ type_ "submit", class "submit", tabindex 10, value val, disabled (state == Api.Loading || not valid) ] []
, case state of
- Api.Error r -> p [] [ b [class "standout" ] [ text <| Api.showResponse r ] ]
+ Api.Error r -> p [] [ b [] [ text <| Api.showResponse r ] ]
_ -> if valid
then text ""
- else p [] [ b [class "standout" ] [ text "The form contains errors, please fix these before submitting. " ] ]
+ else p [] [ b [] [ text "The form contains errors, please fix these before submitting. " ] ]
, if state == Api.Loading
then div [ class "spinner" ] []
else text ""
@@ -79,14 +82,14 @@ inputSelect nam sel onch attrs lst =
) <| List.indexedMap opt lst
-inputNumber : String -> Int -> (Int -> m) -> List (Attribute m) -> Html m
+inputNumber : String -> Maybe Int -> (Maybe Int -> m) -> List (Attribute m) -> Html m
inputNumber nam val onch attrs = input (
[ type_ "number"
, class "text"
, tabindex 10
, style "width" "40px"
- , value <| String.fromInt val
- , onInput (\s -> onch <| Maybe.withDefault 0 <| String.toInt s)
+ , value <| Maybe.withDefault "" <| Maybe.map String.fromInt val
+ , onInput (\s -> onch <| String.toInt s)
]
++ attrs
++ (if nam == "" then [] else [ id nam, name nam ])
@@ -125,10 +128,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 +158,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.
@@ -187,7 +191,7 @@ formField lbl cont =
else
let
(nlbl, eng) = if String.endsWith "#eng" lbl then (String.dropRight 4 lbl, True) else (lbl, False)
- genlbl str = text str :: if eng then [ br [] [], b [ class "standout" ] [ text "English please!" ] ] else []
+ genlbl str = text str :: if eng then [ br [] [], b [] [ text "English please!" ] ] else []
in
td [ class "label" ] <|
case String.split "::" nlbl of
@@ -199,10 +203,19 @@ formField lbl cont =
langIcon : String -> Html m
-langIcon l = abbr [ class "icons lang", class l, title (Maybe.withDefault "" <| lookup l T.languages) ] [ text " " ]
+langIcon l = abbr [ class ("icon-lang-"++l), title (Maybe.withDefault "" <| lookup l T.languages) ] [ text " " ]
platformIcon : String -> Html m
-platformIcon l = abbr [ class "icons", class l, title (Maybe.withDefault "" <| lookup l T.platforms) ] [ text " " ]
+platformIcon l = abbr [ class ("icon-plat-"++l), title (Maybe.withDefault "" <| lookup l T.platforms) ] [ text " " ]
releaseTypeIcon : String -> Html m
-releaseTypeIcon t = abbr [ class ("icons rt"++t), title (Maybe.withDefault "" <| lookup t T.releaseTypes) ] [ text " " ]
+releaseTypeIcon t = abbr [ class ("icon-rt"++t), title (Maybe.withDefault "" <| lookup t T.releaseTypes) ] [ text " " ]
+
+-- Special values: -1 = "add to list", not 1-6 = unknown
+-- (Because why use the type system to encode special values?)
+ulistIcon : Int -> String -> Html m
+ulistIcon n lbl =
+ let fn = if n == -1 then "add"
+ else if n >= 1 && n <= 6 then "l" ++ String.fromInt n
+ else "unknown"
+ in abbr [ class ("icon-list-"++fn), title lbl ] []
diff --git a/elm/Lib/Image.elm b/elm/Lib/Image.elm
new file mode 100644
index 00000000..14eca441
--- /dev/null
+++ b/elm/Lib/Image.elm
@@ -0,0 +1,184 @@
+module Lib.Image exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Process
+import Task
+import File exposing (File)
+import Lib.Html exposing (..)
+import Lib.Api as Api
+import Lib.Util exposing (imageUrl)
+import Gen.Api as GApi
+import Gen.Image as GI
+import Gen.ImageVote as GIV
+
+
+type State
+ = Normal
+ | Invalid
+ | NotFound
+ | Loading
+ | Error GApi.Response
+
+type alias Image =
+ { id : Maybe String
+ , img : Maybe GApi.ApiImageResult
+ , imgState : State
+ , saveState : Api.State
+ , saveTimer : Bool
+ }
+
+
+info : Maybe GApi.ApiImageResult -> Image
+info img =
+ { id = Maybe.map (\i -> i.id) img
+ , img = img
+ , imgState = Normal
+ , saveState = Api.Normal
+ , saveTimer = False
+ }
+
+
+-- Fetch image info from the ID
+new : Bool -> String -> (Image, Cmd Msg)
+new valid id =
+ ( { id = if id == "" then Nothing else Just id
+ , img = Nothing
+ , imgState = if id == "" then Normal else if valid then Loading else Invalid
+ , saveState = Api.Normal
+ , saveTimer = False
+ }
+ , if valid && id /= "" then GI.send { id = id } Loaded else Cmd.none
+ )
+
+
+-- Upload a new image from a form
+upload : Api.ImageType -> File -> (Image, Cmd Msg)
+upload t f =
+ ( { id = Nothing
+ , img = Nothing
+ , imgState = Loading
+ , saveState = Api.Normal
+ , saveTimer = False
+ }
+ , Api.postImage t f Loaded)
+
+
+type Msg
+ = Loaded GApi.Response
+ | MySex Int Bool
+ | MyVio Int Bool
+ | Save
+ | Saved GApi.Response
+
+
+update : Msg -> Image -> (Image, Cmd Msg)
+update msg model =
+ let
+ save m =
+ if m.saveTimer || Maybe.withDefault True (Maybe.map (\i -> i.token == Nothing || i.my_sexual == Nothing || i.my_violence == Nothing) m.img)
+ then (m, Cmd.none)
+ else ({ m | saveTimer = True }, Task.perform (always Save) (Process.sleep 1000))
+ in
+ case msg of
+ Loaded (GApi.ImageResult [i]) -> ({ model | id = Just i.id, img = Just i, imgState = Normal}, Cmd.none)
+ Loaded (GApi.ImageResult []) -> ({ model | imgState = NotFound}, Cmd.none)
+ Loaded e -> ({ model | imgState = Error e }, Cmd.none)
+
+ MySex v _ -> save { model | img = Maybe.map (\i -> { i | my_sexual = Just v }) model.img }
+ MyVio v _ -> save { model | img = Maybe.map (\i -> { i | my_violence = Just v }) model.img }
+
+ Save ->
+ case Maybe.map (\i -> (i.token, i.my_sexual, i.my_violence)) model.img of
+ Just (Just token, Just sex, Just vio) ->
+ ( { model | saveTimer = False, saveState = Api.Loading }
+ , GIV.send { votes = [{ id = Maybe.withDefault "" model.id, token = token, sexual = sex, violence = vio, overrule = False }] } Saved)
+ _ -> (model, Cmd.none)
+ Saved (GApi.Success) -> ({ model | saveState = Api.Normal}, Cmd.none)
+ Saved e -> ({ model | saveState = Api.Error e }, Cmd.none)
+
+
+
+isValid : Image -> Bool
+isValid img = img.imgState == Normal
+
+
+viewImg : Image -> Html m
+viewImg image =
+ case (image.imgState, image.img) of
+ (Loading, _) -> div [ class "spinner" ] []
+ (NotFound, _) ->b [] [ text "Image not found." ]
+ (Invalid, _) -> b [] [ text "Invalid image ID." ]
+ (Error e, _) -> b [] [ text <| Api.showResponse e ]
+ (_, Nothing) -> text "No image."
+ (_, Just i) ->
+ let
+ maxWidth = toFloat <| if String.startsWith "sf" i.id then 136 else 10000
+ maxHeight = toFloat <| if String.startsWith "sf" i.id then 102 else 10000
+ sWidth = maxWidth / toFloat i.width
+ sHeight = maxHeight / toFloat i.height
+ scale = Basics.min 1 <| if sWidth < sHeight then sWidth else sHeight
+ imgWidth = round <| scale * toFloat i.width
+ imgHeight = round <| scale * toFloat i.height
+ in
+ -- TODO: Onclick iv.js support for screenshot thumbnails
+ label [ class "imghover", style "width" (String.fromInt imgWidth++"px"), style "height" (String.fromInt imgHeight++"px") ]
+ [ div [ class "imghover--visible" ]
+ [ if String.startsWith "sf" i.id
+ then a [ href (imageUrl "" i.id), attribute "data-iv" <| String.fromInt i.width ++ "x" ++ String.fromInt i.height ++ ":scr" ]
+ [ img [ src <| imageUrl ".t" i.id ] [] ]
+ else img [ src <| imageUrl "" i.id ] []
+ , a [ class "imghover--overlay", href <| "/img/"++i.id ] <|
+ case (i.sexual_avg, i.violence_avg) of
+ (Just sex, Just vio) ->
+ -- XXX: These thresholds are subject to change, maybe just show the numbers here?
+ [ text <| if sex > 1.3 then "Explicit" else if sex > 0.4 then "Suggestive" else "Safe"
+ , text " / "
+ , text <| if vio > 1.3 then "Brutal" else if vio > 0.4 then "Violent" else "Tame"
+ , text <| " (" ++ String.fromInt i.votecount ++ ")"
+ ]
+ _ -> [ text "Not flagged" ]
+ ]
+ ]
+
+
+viewVote : Image -> (Msg -> a) -> a -> Maybe (Html a)
+viewVote model wrap msg =
+ let
+ rad i sex val = input
+ [ type_ "radio"
+ , tabindex 10
+ , required True
+ , 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
+ ] []
+ vote i = table []
+ [ thead [] [ tr []
+ [ td [] [ text "Sexual ", if model.saveState == Api.Loading then span [ class "spinner" ] [] else text "" ]
+ , td [] [ text "Violence" ]
+ ] ]
+ , tfoot [] <|
+ case model.saveState of
+ Api.Error e -> [ tr [] [ td [ colspan 2 ] [ b [] [ text (Api.showResponse e) ] ] ] ]
+ _ -> []
+ , tr []
+ [ td [ style "white-space" "nowrap" ]
+ [ label [] [ rad i True 0, text " Safe" ], br [] []
+ , label [] [ rad i True 1, text " Suggestive" ], br [] []
+ , label [] [ rad i True 2, text " Explicit" ]
+ ]
+ , td [ style "white-space" "nowrap" ]
+ [ label [] [ rad i False 0, text " Tame" ], br [] []
+ , label [] [ rad i False 1, text " Violent" ], br [] []
+ , label [] [ rad i False 2, text " Brutal" ]
+ ]
+ ]
+ ]
+ in case model.img of
+ Nothing -> Nothing
+ Just i ->
+ if i.token == Nothing then Nothing
+ else Just (vote i)
diff --git a/elm/Lib/RDate.elm b/elm/Lib/RDate.elm
index 7af8f89f..3eca4cfa 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
@@ -12,6 +13,9 @@ import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Date
+import Lib.Html exposing (..)
+import Gen.Types as GT
+import Gen.Api as GApi
type alias RDate = Int
@@ -42,18 +46,23 @@ fromDate d =
, d = Date.day d
}
+maxDayInMonth : Int -> Int -> Int
+maxDayInMonth y m = Date.fromCalendarDate y (Date.numberToMonth m) 1 |> Date.add Date.Months 1 |> Date.add Date.Days -1 |> Date.day
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.d }
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 if r.d /= 99 && r.d > 28 then { r | d = Basics.min r.d (maxDayInMonth r.y r.m) } -- Make sure the day field is in range
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
@@ -66,3 +75,47 @@ display today d =
let fmt = expand d |> format
future = d > (fromDate today |> compact)
in if future then b [ class "future" ] [ text fmt ] else text fmt
+
+
+monthList : List String
+monthList =
+ [ "Jan"
+ , "Feb"
+ , "Mar"
+ , "Apr"
+ , "May"
+ , "Jun"
+ , "Jul"
+ , "Aug"
+ , "Sep"
+ , "Oct"
+ , "Nov"
+ , "Dec"
+ ]
+
+monthSelect : List (Int, String)
+monthSelect = List.indexedMap (\m s -> (m+1, String.fromInt (m+1) ++ " (" ++ s ++ ")")) monthList
+
+-- Input widget.
+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 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 -") :: List.map (\(m,s) -> (compact (normalize {r|m=m}), s)) monthSelect
+ dl = ({r|d=99} |> normalize |> compact, "- day -") :: range 1 (maxDayInMonth r.y r.m) (\n -> {r|d=n})
+ in div []
+ [ inputSelect "" ro msg [ style "width" "100px" ] yl
+ , if r.y == 0 || r.y == 9999 then text "" else inputSelect "" ro msg [ style "width" "90px" ] ml
+ , if r.m == 0 || r.m == 99 then text "" else inputSelect "" ro msg [ style "width" "90px" ] dl
+ ]
+
+
+-- Handy function for formatting release info as a string
+-- (Typically used in selection boxes)
+-- (Why is that in this module, you ask? Well, where else do I put it?)
+showrel : GApi.ApiReleases -> String
+showrel r = "[" ++ (format (expand r.released)) ++ " " ++ (String.join "," r.lang) ++ "] " ++ r.title ++ " (" ++ r.id ++ ")"
diff --git a/elm/Lib/TextPreview.elm b/elm/Lib/TextPreview.elm
index 9431848a..dc876048 100644
--- a/elm/Lib/TextPreview.elm
+++ b/elm/Lib/TextPreview.elm
@@ -7,7 +7,6 @@ import Lib.Html exposing (..)
import Lib.Ffi as Ffi
import Lib.Api as Api
import Gen.Api as GApi
-import Gen.Markdown as GM
import Gen.BBCode as GB
@@ -32,16 +31,6 @@ bbcode data =
}
-markdown : String -> Model
-markdown data =
- { state = Api.Normal
- , data = data
- , preview = ""
- , display = False
- , endpoint = GM.send
- , class = "preview docs"
- }
-
type Msg
= Edit String
@@ -73,18 +62,18 @@ view name model cmdmap width attr header =
display = model.display && model.preview /= ""
in
div [ class "textpreview", style "width" (String.fromInt width ++ "px") ]
- [ span []
- [ p [] header
- , p [ class "right", classList [("invisible", model.data == "")] ]
+ [ div []
+ [ div [] header
+ , div [ classList [("invisible", model.data == "")] ]
[ case model.state of
Api.Loading -> span [ class "spinner" ] []
- Api.Error _ -> b [ class "grayedout" ] [ text "Error loading preview. " ]
+ Api.Error _ -> small [] [ text "Error loading preview. " ]
Api.Normal -> text ""
, if display
then a [ onClickN (cmdmap TextArea) ] [ text "Edit" ]
- else i [] [text "Edit"]
+ else span [] [text "Edit"]
, if display
- then i [] [text "Preview"]
+ then span [] [text "Preview"]
else a [ onClickN (cmdmap Preview) ] [ text "Preview" ]
]
]
diff --git a/elm/Lib/Util.elm b/elm/Lib/Util.elm
index 7a120c7d..edde2e37 100644
--- a/elm/Lib/Util.elm
+++ b/elm/Lib/Util.elm
@@ -1,7 +1,12 @@
module Lib.Util exposing (..)
-import Dict
+import Set
import Task
+import Process
+import Regex
+import Lib.Ffi as Ffi
+import Gen.Api as GApi
+import Gen.Types as GT
-- Delete an element from a List
delidx : Int -> List a -> List a
@@ -26,17 +31,99 @@ hasDuplicates l =
step e acc =
case acc of
Nothing -> Nothing
- Just m -> if Dict.member e m then Nothing else Just (Dict.insert e True m)
+ Just m -> if Set.member e m then Nothing else Just (Set.insert e m)
in
- case List.foldr step (Just Dict.empty) l of
+ case List.foldr step (Just Set.empty) l of
Nothing -> True
Just _ -> False
+-- Returns true if list a contains elements also in list b
+contains : List comparable -> List comparable -> Bool
+contains a b =
+ let d = Set.fromList b
+ in List.any (\e -> Set.member e d) a
+
+
-- Haskell's 'lookup' - find an entry in an association list
lookup : a -> List (a,b) -> Maybe b
lookup n l = List.filter (\(a,_) -> a == n) l |> List.head |> Maybe.map Tuple.second
+-- Have to use Process.sleep instead of Task.succeed here, otherwise any
+-- subscriptions are not updated.
selfCmd : msg -> Cmd msg
-selfCmd m = Task.perform (always m) (Task.succeed True)
+selfCmd m = Task.perform (always m) (Process.sleep 1.0)
+
+
+-- Convert a dir suffix ("" or ".t") and an image ID (e.g. "sf500") into a URL.
+imageUrl : String -> String -> String
+imageUrl suff id =
+ let num = String.dropLeft 2 id |> String.toInt |> Maybe.withDefault 0
+ in Ffi.urlStatic ++ "/" ++ String.left 2 id ++ suff ++ "/" ++ String.fromInt (modBy 10 (num // 10)) ++ String.fromInt (modBy 10 num) ++ "/" ++ String.fromInt num ++ ".jpg"
+
+
+vndbidNum : String -> Int
+vndbidNum = String.dropLeft 1 >> String.toInt >> Maybe.withDefault 0
+
+
+vndbid : Char -> Int -> String
+vndbid c n = String.fromChar c ++ String.fromInt n
+
+
+jap_ : Regex.Regex
+jap_ = Maybe.withDefault Regex.never (Regex.fromString "[\\u3000-\\u9fff\\uff00-\\uff9f]")
+
+-- Not even close to comprehensive, just excludes a few scripts commonly found on VNDB.
+nonlatin_ : Regex.Regex
+nonlatin_ = Maybe.withDefault Regex.never (Regex.fromString "[\\u0400-\\u04ff\\u0600-\\u06ff\\u0e00-\\u0e7f\\u1100-\\u11ff\\u1400-\\u167f\\u3040-\\u3099\\u30a1-\\u30fa\\u3100-\\u9fff\\uac00-\\ud7af\\uff66-\\uffdc\\u{20000}-\\u{323af}]")
+
+-- This regex can't differentiate between Japanese and Chinese, so has a good chance of returning true for Chinese as well.
+containsJapanese : String -> Bool
+containsJapanese = Regex.contains jap_
+
+containsNonLatin : String -> Bool
+containsNonLatin = Regex.contains nonlatin_
+
+
+-- List of script-languages (i.e. not the generic "Chinese" option), with JA and EN ordered first.
+scriptLangs : List (String, String)
+scriptLangs =
+ (List.filter (\(l,_) -> l == "ja") GT.languages)
+ ++ (List.filter (\(l,_) -> l == "en") GT.languages)
+ ++ (List.filter (\(l,_) -> l /= "zh" && l /= "en" && l /= "ja") GT.languages)
+
+-- "Location languages", i.e. generic language without script indicator, again with JA and EN ordered first.
+locLangs : List (String, String)
+locLangs =
+ (List.filter (\(l,_) -> l == "ja") GT.languages)
+ ++ (List.filter (\(l,_) -> l == "en") GT.languages)
+ ++ (List.filter (\(l,_) -> l /= "zh-Hans" && l /= "zh-Hant" && l /= "en" && l /= "ja") GT.languages)
+
+
+-- Format a release resolution, first argument indicates whether empty string is to be used for "unknown"
+resoFmt : Bool -> Int -> Int -> String
+resoFmt empty x y =
+ case (x,y) of
+ (0,0) -> if empty then "" else "Unknown"
+ (0,1) -> "Non-standard"
+ _ -> String.fromInt x ++ "x" ++ String.fromInt y
+
+-- Inverse of resoFmt
+resoParse : Bool -> String -> Maybe (Int, Int)
+resoParse empty s =
+ let t = String.replace "*" "x" s
+ |> String.replace "×" "x"
+ |> String.replace " " ""
+ |> String.replace "\t" ""
+ |> String.toLower |> String.trim
+ in
+ case (t, String.split "x" t) of
+ ("", _) -> if empty then Just (0,0) else Nothing
+ ("unknown", _) -> Just (0,0)
+ ("non-standard", _) -> Just (0,1)
+ (_, [sx,sy]) ->
+ case (String.toInt sx, String.toInt sy) of
+ (Just ix, Just iy) -> if ix < 1 || ix > 32767 || iy < 1 || iy > 32767 then Nothing else Just (ix,iy)
+ _ -> Nothing
+ _ -> Nothing
diff --git a/elm/Reviews/Edit.elm b/elm/Reviews/Edit.elm
new file mode 100644
index 00000000..b122d1ba
--- /dev/null
+++ b/elm/Reviews/Edit.elm
@@ -0,0 +1,199 @@
+module Reviews.Edit 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.RDate as RDate
+import Gen.Api as GApi
+import Gen.ReviewsEdit as GRE
+import Gen.ReviewsDelete as GRD
+
+
+main : Program GRE.Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { state : Api.State
+ , id : Maybe String
+ , vid : String
+ , vntitle : String
+ , rid : Maybe String
+ , spoiler : Bool
+ , locked : Bool
+ , isfull : Bool
+ , modnote : String
+ , text : TP.Model
+ , releases : List GRE.RecvReleases
+ , delete : Bool
+ , delState : Api.State
+ , mod : Bool
+ }
+
+
+init : GRE.Recv -> Model
+init d =
+ { state = Api.Normal
+ , id = d.id
+ , vid = d.vid
+ , vntitle = d.vntitle
+ , rid = d.rid
+ , spoiler = d.spoiler
+ , locked = d.locked
+ , isfull = d.isfull
+ , modnote = d.modnote
+ , text = TP.bbcode d.text
+ , releases = d.releases
+ , delete = False
+ , delState = Api.Normal
+ , mod = d.mod
+ }
+
+
+encode : Model -> GRE.Send
+encode m =
+ { id = m.id
+ , vid = m.vid
+ , rid = m.rid
+ , spoiler = m.spoiler
+ , locked = m.locked
+ , modnote = m.modnote
+ , isfull = m.isfull
+ , text = m.text.data
+ }
+
+
+type Msg
+ = Release (Maybe String)
+ | Full Bool
+ | Spoiler Bool
+ | Locked Bool
+ | Modnote String
+ | Text TP.Msg
+ | Submit
+ | Submitted GApi.Response
+ | Delete Bool
+ | DoDelete
+ | Deleted GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Release i -> ({ model | rid = i }, Cmd.none)
+ Full b -> ({ model | isfull = b }, Cmd.none)
+ Spoiler b -> ({ model | spoiler = b }, Cmd.none)
+ Locked b -> ({ model | locked = b }, Cmd.none)
+ Modnote s -> ({ model | modnote = s }, Cmd.none)
+ Text m -> let (nm,nc) = TP.update m model.text in ({ model | text = nm }, Cmd.map Text nc)
+
+ Submit -> ({ model | state = Api.Loading }, GRE.send (encode model) Submitted)
+ Submitted (GApi.Redirect s) -> (model, load s)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+ Delete b -> ({ model | delete = b }, Cmd.none)
+ DoDelete -> ({ model | delState = Api.Loading }, GRD.send ({ id = Maybe.withDefault "" model.id }) Deleted)
+ Deleted GApi.Success -> (model, load <| "/" ++ model.vid)
+ Deleted r -> ({ model | delState = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ let minChars = if model.isfull then 1000 else 200
+ maxChars = if model.isfull then 100000 else 800
+ len = String.length model.text.data
+ in
+ form_ "" Submit (model.state == Api.Loading)
+ [ article []
+ [ h1 [] [ text <| if model.id == Nothing then "Submit a review" else "Edit review" ]
+ , p [] [ strong [] [ text "Rules" ] ]
+ , ul []
+ [ li [] [ text "Submit only reviews you have written yourself!" ]
+ , li [] [ text "Reviews must be in English." ]
+ , li [] [ text "Try to be as objective as possible." ]
+ , li [] [ text "If you have published the review elsewhere (e.g. a personal blog), feel free to include a link at the end of the review. Formatting tip: ", em [] [ text "[Originally published at <link>]" ] ]
+ , li [] [ text "Your vote (if any) will be displayed alongside the review, even if you have marked your list as private." ]
+ ]
+ , br [] []
+ ]
+ , article []
+ [ table [ class "formtable" ]
+ [ formField "Subject" [ a [ href <| "/"++model.vid ] [ text model.vntitle ] ]
+ , formField ""
+ [ inputSelect "" model.rid Release [style "width" "500px" ] <|
+ (Nothing, "No release selected")
+ :: List.map (\r -> (Just r.id, RDate.showrel r)) model.releases
+ ++ if model.rid == Nothing || List.any (\r -> Just r.id == model.rid) model.releases then [] else [(model.rid, "Deleted or moved release: r"++Maybe.withDefault "" model.rid)]
+ , br [] []
+ , text "You do not have to select a release, but indicating which release your review is based on gives more context."
+ ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , formField "Review type"
+ [ label [] [ inputRadio "type" (model.isfull == False) (\_ -> Full False), strong [] [ text " Mini review" ]
+ , text <| " - Recommendation-style, maximum 800 characters." ]
+ , br [] []
+ , label [] [ inputRadio "type" (model.isfull == True ) (\_ -> Full True ), strong [] [ text " Full review" ]
+ , text " - Longer, more detailed." ]
+ , br [] []
+ , small [] [ text "You can always switch between review types later." ]
+ ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , formField ""
+ [ label [] [ inputCheck "" model.spoiler Spoiler, text " This review contains spoilers." ]
+ , br [] []
+ , small [] [ text "You do not have to check this option if all spoilers in your review are marked with [spoiler] tags." ]
+ ]
+ , if not model.mod then text "" else
+ formField "" [ label [] [ inputCheck "" model.locked Locked, text " Locked for commenting." ] ]
+ , if not model.mod then text "" else
+ formField "modnote::Mod note"
+ [ inputText "modnote" model.modnote Modnote (style "width" "500px" :: GRE.valModnote)
+ , br [] [], text "Moderation note intended to inform readers of the review that its author may be biased and failed to disclose that." ]
+
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , formField "text::Review"
+ [ TP.view "sum" model.text Text 700 ([rows (if model.isfull then 30 else 10), cols 50] ++ GRE.valText)
+ [ a [ href "/d9#4" ] [ text "BBCode formatting supported" ] ]
+ , div [ style "width" "700px", style "text-align" "right" ] <|
+ let num c s = if c then b [] [ text s ] else text s
+ in
+ [ num (len < minChars) (String.fromInt minChars)
+ , text " / "
+ , strong [] [ text (String.fromInt len) ]
+ , text " / "
+ , num (len > maxChars) (if model.isfull then "∞" else String.fromInt maxChars)
+ ]
+ ]
+ ]
+ ]
+ , article [ class "submit" ] [ submitButton "Submit" model.state (len <= maxChars && len >= minChars) ]
+ , if model.id == Nothing then text "" else
+ article []
+ [ h1 [] [ text "Delete review" ]
+ , table [ class "formtable" ] [ formField ""
+ [ label [] [ inputCheck "" model.delete Delete, text " Delete this review." ]
+ , if not model.delete then text "" else span []
+ [ br [] []
+ , b [] [ text "WARNING:" ]
+ , text " Deleting this review is a permanent action and can not be reverted!"
+ , br [] []
+ , br [] []
+ , inputButton "Confirm delete" DoDelete []
+ , case model.delState of
+ Api.Loading -> span [ class "spinner" ] []
+ Api.Error e -> b [] [ text <| Api.showResponse e ]
+ Api.Normal -> text ""
+ ]
+ ] ]
+ ]
+ ]
diff --git a/elm/StaffEdit/Main.elm b/elm/StaffEdit/Main.elm
deleted file mode 100644
index 423e088d..00000000
--- a/elm/StaffEdit/Main.elm
+++ /dev/null
@@ -1,221 +0,0 @@
-module StaffEdit.Main exposing (Model, Msg, main, new, view, update)
-
-import Html exposing (..)
-import Html.Events exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Util exposing (..)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Lib.Editsum as Editsum
-import Gen.StaffEdit as GSE
-import Gen.Types as GT
-import Gen.Api as GApi
-
-
-main : Program GSE.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-
-type alias Model =
- { state : Api.State
- , editsum : Editsum.Model
- , alias : List GSE.RecvAlias
- , aliasDup : Bool
- , aid : Int
- , desc : TP.Model
- , gender : String
- , lang : String
- , l_site : String
- , l_wikidata : Maybe Int
- , l_twitter : String
- , l_anidb : Maybe Int
- , l_pixiv : Int
- , id : Maybe Int
- }
-
-
-init : GSE.Recv -> Model
-init d =
- { state = Api.Normal
- , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden }
- , alias = d.alias
- , aliasDup = False
- , aid = d.aid
- , desc = TP.bbcode d.desc
- , gender = d.gender
- , lang = d.lang
- , l_site = d.l_site
- , l_wikidata = d.l_wikidata
- , l_twitter = d.l_twitter
- , l_anidb = d.l_anidb
- , l_pixiv = d.l_pixiv
- , id = d.id
- }
-
-
-new : Model
-new =
- { state = Api.Normal
- , editsum = Editsum.new
- , alias = [ { aid = -1, name = "", original = "", inuse = False } ]
- , aliasDup = False
- , aid = -1
- , desc = TP.bbcode ""
- , gender = "unknown"
- , lang = "ja"
- , l_site = ""
- , l_wikidata = Nothing
- , l_twitter = ""
- , l_anidb = Nothing
- , l_pixiv = 0
- , id = Nothing
- }
-
-
-encode : Model -> GSE.Send
-encode model =
- { id = model.id
- , editsum = model.editsum.editsum.data
- , hidden = model.editsum.hidden
- , locked = model.editsum.locked
- , aid = model.aid
- , alias = List.map (\e -> { aid = e.aid, name = e.name, original = e.original }) model.alias
- , desc = model.desc.data
- , gender = model.gender
- , lang = model.lang
- , l_site = model.l_site
- , l_wikidata = model.l_wikidata
- , l_twitter = model.l_twitter
- , l_anidb = model.l_anidb
- , l_pixiv = model.l_pixiv
- }
-
-
-newAid : Model -> Int
-newAid model =
- let id = Maybe.withDefault 0 <| List.minimum <| List.map .aid model.alias
- in if id >= 0 then -1 else id - 1
-
-
-type Msg
- = Editsum Editsum.Msg
- | Submit
- | Submitted GApi.Response
- | Lang String
- | Gender String
- | Website String
- | LWikidata (Maybe Int)
- | LTwitter String
- | LAnidb String
- | LPixiv String
- | Desc TP.Msg
- | AliasDel Int
- | AliasName Int String
- | AliasOrig Int String
- | AliasMain Int Bool
- | AliasAdd
-
-
-validate : Model -> Model
-validate model = { model | aliasDup = hasDuplicates <| List.map (\e -> (e.name, e.original)) model.alias }
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
- Lang s -> ({ model | lang = s }, Cmd.none)
- Gender s -> ({ model | gender = s }, Cmd.none)
- Website s -> ({ model | l_site = s }, Cmd.none)
- LWikidata n-> ({ model | l_wikidata= n }, Cmd.none)
- LTwitter s -> ({ model | l_twitter = s }, Cmd.none)
- LAnidb s -> ({ model | l_anidb = if s == "" then Nothing else String.toInt s }, Cmd.none)
- LPixiv s -> ({ model | l_pixiv = Maybe.withDefault model.l_pixiv (String.toInt s) }, Cmd.none)
- Desc m -> let (nm,nc) = TP.update m model.desc in ({ model | desc = nm }, Cmd.map Desc nc)
-
- AliasDel i -> (validate { model | alias = delidx i model.alias }, Cmd.none)
- AliasName i s -> (validate { model | alias = modidx i (\e -> { e | name = s }) model.alias }, Cmd.none)
- AliasOrig i s -> (validate { model | alias = modidx i (\e -> { e | original = s }) model.alias }, Cmd.none)
- AliasMain n _ -> ({ model | aid = n }, Cmd.none)
- AliasAdd -> ({ model | alias = model.alias ++ [{ aid = newAid model, name = "", original = "", inuse = False }] }, Cmd.none)
-
- Submit -> ({ model | state = Api.Loading }, GSE.send (encode model) Submitted)
- Submitted (GApi.Redirect s) -> (model, load s)
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-isValid : Model -> Bool
-isValid model = not model.aliasDup
-
-
-view : Model -> Html Msg
-view model =
- let
- nameEntry n e =
- tr []
- [ td [ class "tc_id" ] [ inputRadio "main" (e.aid == model.aid) (AliasMain e.aid) ]
- , td [ class "tc_name" ] [ inputText "" e.name (AliasName n) GSE.valAliasName ]
- , td [ class "tc_original" ] [ inputText "" e.original (AliasOrig n) GSE.valAliasOriginal ]
- , td [ class "tc_add" ]
- [ if model.aid == e.aid then b [ class "grayedout" ] [ text " primary" ]
- else if e.inuse then b [ class "grayedout" ] [ text " referenced" ]
- else a [ onClick (AliasDel n) ] [ text " remove" ]
- ]
- ]
-
- names =
- table [ class "names" ] <|
- [ thead []
- [ tr []
- [ td [ class "tc_id" ] []
- , td [ class "tc_name" ] [ text "Name (romaji)" ]
- , td [ class "tc_original" ] [ text "Original" ]
- , td [] []
- ]
- ]
- ] ++ List.indexedMap nameEntry model.alias ++
- [ tr [ class "alias_new" ]
- [ td [] []
- , td [ colspan 3 ]
- [ if not model.aliasDup then text ""
- else b [ class "standout" ] [ text "The list contains duplicate aliases.", br_ 1 ]
- , a [ onClick AliasAdd ] [ text "Add alias" ]
- ]
- ]
- ]
-
- in
- form_ Submit (model.state == Api.Loading)
- [ div [ class "mainbox staffedit" ]
- [ h1 [] [ text "General info" ]
- , table [ class "formtable" ]
- [ formField "Names" [ names, br_ 1 ]
- , formField "desc::Biography" [ TP.view "desc" model.desc Desc 500 GSE.valDesc [ b [ class "standout" ] [ text "English please!" ] ] ]
- , formField "gender::Gender" [ inputSelect "gender" model.gender Gender []
- [ ("unknown", "Unknown or N/A")
- , ("f", "Female")
- , ("m", "Male")
- ] ]
- , formField "lang::Primary Language" [ inputSelect "lang" model.lang Lang [] GT.languages ]
- , formField "l_site::Official page" [ inputText "l_site" model.l_site Website (style "width" "400px" :: GSE.valL_Site) ]
- , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.l_wikidata LWikidata ]
- , formField "l_twitter::Twitter username" [ inputText "l_twitter" model.l_twitter LTwitter GSE.valL_Twitter ]
- , formField "l_anidb::AniDB Creator ID" [ inputText "l_anidb" (Maybe.withDefault "" (Maybe.map String.fromInt model.l_anidb)) LAnidb GSE.valL_Anidb ]
- , formField "l_pixiv::Pixiv ID" [ inputText "l_pixiv" (if model.l_pixiv == 0 then "" else String.fromInt model.l_pixiv) LPixiv GSE.valL_Pixiv ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
- ]
- ]
diff --git a/elm/StaffEdit/New.elm b/elm/StaffEdit/New.elm
deleted file mode 100644
index 64e58517..00000000
--- a/elm/StaffEdit/New.elm
+++ /dev/null
@@ -1,12 +0,0 @@
-module StaffEdit.New exposing (main)
-
-import Browser
-import StaffEdit.Main as Main
-
-main : Program () Main.Model Main.Msg
-main = Browser.element
- { init = always (Main.new, Cmd.none)
- , view = Main.view
- , update = Main.update
- , subscriptions = always Sub.none
- }
diff --git a/elm/TagEdit.elm b/elm/TagEdit.elm
new file mode 100644
index 00000000..d1bcbef1
--- /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 Lib.Editsum as Editsum
+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 =
+ { state : Api.State
+ , editsum : Editsum.Model
+ , id : Maybe String
+ , name : String
+ , alias : String
+ , cat : String
+ , description : TP.Model
+ , searchable : Bool
+ , applicable : Bool
+ , defaultspoil : Int
+ , parents : List GTE.RecvParents
+ , parentAdd : A.Model GApi.ApiTagResult
+ , wipevotes : Bool
+ , merge : List GTE.RecvMerge
+ , mergeAdd : A.Model GApi.ApiTagResult
+ , dupNames : List GApi.ApiDupNames
+ }
+
+
+init : GTE.Recv -> Model
+init d =
+ { state = Api.Normal
+ , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = True }
+ , id = d.id
+ , name = d.name
+ , alias = d.alias
+ , cat = d.cat
+ , description = TP.bbcode d.description
+ , searchable = d.searchable
+ , applicable = d.applicable
+ , defaultspoil = d.defaultspoil
+ , parents = d.parents
+ , parentAdd = A.init ""
+ , wipevotes = False
+ , merge = []
+ , mergeAdd = A.init ""
+ , 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.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
+ , editsum = m.editsum.editsum.data
+ , hidden = m.editsum.hidden
+ , locked = m.editsum.locked
+ , name = m.name
+ , alias = m.alias
+ , cat = m.cat
+ , description = m.description.data
+ , searchable = m.searchable
+ , applicable = m.applicable
+ , defaultspoil = m.defaultspoil
+ , parents = List.map (\l -> {parent=l.parent, main=l.main}) m.parents
+ , wipevotes = m.wipevotes
+ , merge = List.map (\l -> {id=l.id}) m.merge
+ }
+
+
+type Msg
+ = Name String
+ | Alias String
+ | Searchable Bool
+ | Applicable Bool
+ | Cat String
+ | DefaultSpoil Int
+ | Description TP.Msg
+ | Editsum Editsum.Msg
+ | ParentMain Int Bool
+ | 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)
+ Alias s -> ({ model | alias = String.replace "," "\n" s }, 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)
+ Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
+
+ ParentMain i _-> ({ model | parents = List.indexedMap (\n p -> { p | main = i == n }) model.parents }, Cmd.none)
+ ParentDel i ->
+ let np = delidx i model.parents
+ nnp = if List.any (\p -> p.main) np then np else List.indexedMap (\n p -> { p | main = n == 0 }) np
+ in ({ model | parents = nnp }, 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.parent == p.id) model.parents
+ then ({ model | parentAdd = nm }, c)
+ else ({ model | parentAdd = A.clear nm "", parents = model.parents ++ [{ parent = p.id, main = List.isEmpty model.parents, 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 | state = Api.Loading }, GTE.send (encode model) Submitted)
+ Submitted (GApi.DupNames l) -> ({ model | dupNames = l, state = Api.Normal }, Cmd.none)
+ Submitted (GApi.Redirect s) -> (model, load s)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ form_ "" Submit (model.state == Api.Loading)
+ [ article []
+ [ h1 [] [ text <| if model.id == Nothing then "Submit new tag" else "Edit tag" ]
+ , table [ class "formtable" ] <|
+ [ 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 "Tag name and aliases must be unique and self-describing." ]
+ else div []
+ [ b [] [ text "The following tag names are already present in the database:" ]
+ , ul [] <| List.map (\t ->
+ li [] [ a [ href ("/"++t.id) ] [ text t.name ] ]
+ ) dups
+ ]
+ ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , formField "" [ label [] [ inputCheck "" model.searchable Searchable, text " Searchable (people can use this tag to find VNs)" ] ]
+ , 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" ] [ small [] [ text <| p.parent ++ ":" ] ]
+ , td [] [ a [ href <| "/" ++ p.parent ] [ text p.name ] ]
+ , td [] [ label [] [ inputRadio "parentprimary" p.main (ParentMain i), text " primary" ] ]
+ , td [] [ inputButton "remove" (ParentDel i) [] ]
+ ]
+ ) model.parents
+ , A.view parentConfig model.parentAdd [placeholder "Add parent tag..."]
+ ]
+ ]
+ ++ if not model.editsum.authmod || model.id == Nothing then [] else
+ [ tr [ class "newpart" ] [ td [ colspan 2 ]
+ [ text "DANGER ZONE"
+ , small [] [ text " (The options in this section are not visible in the edit history. Your edit summary will not be visible anywhere unless you also changed something in the above fields)" ]
+ , br_ 2
+ ] ]
+ , formField ""
+ [ inputCheck "" model.wipevotes WipeVotes
+ , text " Delete all direct votes on this tag. WARNING: cannot be undone!", br [] []
+ , small [] [ 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" ] [ small [] [ text <| p.id ++ ":" ] ]
+ , td [] [ a [ href <| "/" ++ p.id ] [ text p.name ] ]
+ , td [] [ inputButton "remove" (MergeDel i) [] ]
+ ]
+ ) model.merge
+ , A.view mergeConfig model.mergeAdd [placeholder "Add tag to merge..."]
+ ]
+ ]
+ ]
+ , article [ class "submit" ]
+ [ Html.map Editsum (Editsum.view model.editsum)
+ , submitButton "Submit" model.state (isValid model)
+ ]
+ ]
diff --git a/elm/Tagmod.elm b/elm/Tagmod.elm
new file mode 100644
index 00000000..de82f77f
--- /dev/null
+++ b/elm/Tagmod.elm
@@ -0,0 +1,303 @@
+module Tagmod exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Html.Lazy
+import Browser
+import Browser.Navigation exposing (reload)
+import Browser.Dom exposing (focus)
+import Task
+import Lib.Html exposing (..)
+import Lib.Api as Api
+import Lib.Ffi as Ffi
+import Lib.Autocomplete as A
+import Gen.Api as GApi
+import Gen.Tagmod as GT
+
+
+main : Program GT.Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+type alias Tag = GT.RecvTags
+
+type Sel
+ = NoSel
+ | Vote Int
+ | Over
+ | Spoil (Maybe Int)
+ | Lie (Maybe Bool)
+ | Note
+ | NoteSet
+
+type alias Model =
+ { state : Api.State
+ , title : String
+ , id : String
+ , mod : Bool
+ , tags : List Tag
+ , saved : List Tag
+ , changed : Bool
+ , selId : String
+ , selType : Sel
+ , negCount : Int
+ , negShow : Bool
+ , add : A.Model GApi.ApiTagResult
+ , addMsg : String
+ }
+
+
+init : GT.Recv -> Model
+init f =
+ { state = Api.Normal
+ , title = f.title
+ , id = f.id
+ , mod = f.mod
+ , tags = f.tags
+ , saved = f.tags
+ , changed = False
+ , selId = ""
+ , selType = NoSel
+ , negCount = List.length <| List.filter (\t -> t.rating <= 0) f.tags
+ , negShow = False
+ , add = A.init ""
+ , addMsg = ""
+ }
+
+searchConfig : A.Config Msg GApi.ApiTagResult
+searchConfig = { wrap = TagSearch, id = "tagadd", source = A.tagSource }
+
+
+type Msg
+ = Noop
+ | SetSel String Sel
+ | SetVote String Int
+ | SetOver String Bool
+ | SetSpoil String (Maybe Int)
+ | SetLie String (Maybe Bool)
+ | SetNote String String
+ | NegShow Bool
+ | TagSearch (A.Msg GApi.ApiTagResult)
+ | Submit
+ | Submitted GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ let
+ changed m = { m | changed = m.saved /= m.tags }
+ modtag id f = changed { model | addMsg = "", tags = List.map (\t -> if t.id == id then f t else t) model.tags }
+ in
+ case msg of
+ Noop -> (model, Cmd.none)
+ SetSel id v ->
+ ( if model.selType == NoteSet && not (id == model.selId && v == NoSel) then model else { model | selId = id, selType = v }
+ , if v == NoteSet then Task.attempt (always Noop) (focus "tag_note") else Cmd.none)
+
+ SetVote id v -> (modtag id (\t -> { t | vote = v }), Cmd.none)
+ SetOver id b -> (modtag id (\t -> { t | overrule = b }), Cmd.none)
+ SetSpoil id s -> (modtag id (\t -> { t | spoil = s }), Cmd.none)
+ SetLie id s -> (modtag id (\t -> { t | lie = s }), Cmd.none)
+ SetNote id s -> (modtag id (\t -> { t | notes = s }), Cmd.none)
+ NegShow b -> ({ model | negShow = b }, Cmd.none)
+
+ TagSearch m ->
+ let (nm, c, res) = A.update searchConfig m model.add
+ in case res of
+ Nothing -> ({ model | add = nm }, c)
+ Just t ->
+ let (nl, ms) =
+ if t.hidden && t.locked then ([], "Can't add deleted tags")
+ else if not t.applicable then ([], "Tag is not applicable")
+ else if List.any (\it -> it.id == t.id) model.tags then ([], "Tag is already in the list")
+ else ([{ id = t.id, vote = 0, spoil = Nothing, lie = Nothing, overrule = False, notes = "", cat = "new", name = t.name
+ , rating = 0, count = 0, spoiler = 0, islie = False, overruled = False, othnotes = "", hidden = t.hidden, locked = t.locked, applicable = t.applicable }], "")
+ in (changed { model | add = if ms == "" then A.clear nm "" else nm, tags = model.tags ++ nl, addMsg = ms }, c)
+
+ Submit ->
+ ( { model | state = Api.Loading, addMsg = "" }
+ , GT.send { id = model.id, tags = List.map (\t -> { id = t.id, vote = t.vote, spoil = t.spoil, lie = t.lie, overrule = t.overrule, notes = t.notes }) model.tags } Submitted)
+ Submitted GApi.Success -> (model, reload)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+
+viewTag : Tag -> Sel -> String -> Bool -> Html Msg
+viewTag t sel vid mod =
+ let
+ -- Similar to VNWeb::Tags::Lib::tagscore_
+ tagscore s =
+ div [ class "tagscore", classList [("negative", s <= 0)] ]
+ [ span [] [ text <| Ffi.fmtFloat s 1 ]
+ , div [ style "width" <| String.fromFloat (abs (s/3*30)) ++ "px" ] []
+ ]
+ msg s = [ td [ colspan 4 ] [ text s ] ]
+ vote = case sel of Vote v -> v
+ _ -> t.vote
+ spoil = case sel of Spoil s -> s
+ _ -> t.spoil
+ lie = case sel of Lie l -> l
+ _ -> t.lie
+ in
+ tr [] <|
+ [ td [ class "tc_tagname" ]
+ [ a [ href <| "/"++t.id, style "text-decoration" (if t.applicable && not (t.hidden && t.locked) then "none" else "line-through") ] [ text t.name ]
+ , case (t.hidden, t.locked, t.applicable) of
+ (True, False, _) -> small [] [ text " (awaiting approval)" ]
+ (True, True, _) -> small [] [ text " (deleted)" ]
+ (_, _, False) -> small [] [ text " (not applicable)" ]
+ _ -> text ""
+ ]
+ , td [ class "tc_myvote buts" ]
+ [ a [ href "#", onMouseOver (SetSel t.id (Vote -3)), onMouseOut (SetSel "" NoSel), onClickD (SetVote t.id -3), classList [("ld", vote < 0)], title "Downvote" ] []
+ , a [ href "#", onMouseOver (SetSel t.id (Vote 0)), onMouseOut (SetSel "" NoSel), onClickD (SetVote t.id 0), classList [("l0", vote == 0)], title "Remove vote" ] []
+ , a [ href "#", onMouseOver (SetSel t.id (Vote 1)), onMouseOut (SetSel "" NoSel), onClickD (SetVote t.id 1), classList [("l1", vote >= 1)], title "+1" ] []
+ , a [ href "#", onMouseOver (SetSel t.id (Vote 2)), onMouseOut (SetSel "" NoSel), onClickD (SetVote t.id 2), classList [("l2", vote >= 2)], title "+2" ] []
+ , a [ href "#", onMouseOver (SetSel t.id (Vote 3)), onMouseOut (SetSel "" NoSel), onClickD (SetVote t.id 3), classList [("l3", vote == 3)], title "+3" ] []
+ ]
+ ] ++ (if t.vote == 0 && t.count == 0 then
+ [ td [ colspan 4 ] [ text "<- don't forget to rate" ]
+ ] else
+ [ td [ class "tc_myover buts" ] <|
+ if t.vote == 0 || not mod then [] else
+ [ a [ href "#", onMouseOver (SetSel t.id Over), onMouseOut (SetSel "" NoSel), onClickD (SetOver t.id (not t.overrule)), classList [("ov", t.overrule || sel == Over)], title "Overrule" ] [] ]
+ , td [ class "tc_myspoil buts" ] <|
+ if t.vote <= 0 then [] else
+ [ a [ href "#", onMouseOver (SetSel t.id (Spoil Nothing)), onMouseOut (SetSel "" NoSel), onClickD (SetSpoil t.id Nothing), classList [("sn", spoil == Nothing)], title "Unknown" ] []
+ , a [ href "#", onMouseOver (SetSel t.id (Spoil (Just 0))), onMouseOut (SetSel "" NoSel), onClickD (SetSpoil t.id (Just 0)), classList [("s0", spoil == Just 0 )], title "Not a spoiler" ] []
+ , a [ href "#", onMouseOver (SetSel t.id (Spoil (Just 1))), onMouseOut (SetSel "" NoSel), onClickD (SetSpoil t.id (Just 1)), classList [("s1", spoil == Just 1 )], title "Minor spoiler" ] []
+ , a [ href "#", onMouseOver (SetSel t.id (Spoil (Just 2))), onMouseOut (SetSel "" NoSel), onClickD (SetSpoil t.id (Just 2)), classList [("s2", spoil == Just 2 )], title "Major spoiler" ] []
+ ]
+ , td [ class "tc_mylie buts" ] <|
+ if t.vote <= 0 then [] else
+ [ a [ href "#", onMouseOver (SetSel t.id (Lie Nothing)), onMouseOut (SetSel "" NoSel), onClickD (SetLie t.id Nothing ), classList [("fn", lie == Nothing )], title "Unknown" ] []
+ , a [ href "#", onMouseOver (SetSel t.id (Lie (Just False))), onMouseOut (SetSel "" NoSel), onClickD (SetLie t.id (Just False)), classList [("f0", lie == Just False)], title "This tag is not a lie" ] []
+ , a [ href "#", onMouseOver (SetSel t.id (Lie (Just True))), onMouseOut (SetSel "" NoSel), onClickD (SetLie t.id (Just True )), classList [("f1", lie == Just True )], title "This tag is a lie"] []
+ ]
+ , td [ class "tc_mynote" ] <|
+ if t.vote == 0 then [] else
+ [ span
+ [ onMouseOver (SetSel t.id Note)
+ , onMouseOut (SetSel "" NoSel)
+ , onClickD (SetSel t.id NoteSet)
+ , style "opacity" <| if t.notes == "" then "0.5" else "1.0"
+ ] [ text "💬" ]
+ ]
+ ]) ++
+ case sel of
+ Vote 0 -> msg "Remove vote"
+ Vote 1 -> msg "Vote +1"
+ Vote 2 -> msg "Vote +2"
+ Vote 3 -> msg "Vote +3"
+ Vote _ -> msg "Downvote (-3)"
+ Over -> msg "Mod overrule (only your vote counts)"
+ Spoil Nothing -> msg "Spoiler status not known"
+ Spoil (Just 0) -> msg "This is not a spoiler"
+ Spoil (Just 1) -> msg "This is a minor spoiler"
+ Spoil (Just 2) -> msg "This is a major spoiler"
+ Lie Nothing -> msg "Truth status not known"
+ Lie (Just True)-> msg "This tag turns out to be false"
+ Lie (Just False)->msg "This tag is not a lie"
+ Note -> [ td [ colspan 4 ] [ if t.notes == "" then text "Set note" else div [ class "noteview" ] [ text t.notes ] ] ]
+ NoteSet ->
+ [ td [ colspan 4, class "compact" ]
+ [ Html.form [ onSubmit (SetSel t.id NoSel) ]
+ [ inputText "tag_note" t.notes (SetNote t.id) (onBlur (SetSel t.id NoSel) :: style "width" "400px" :: style "position" "absolute" :: placeholder "Set note..." :: GT.valTagsNotes) ]
+ ]
+ ]
+ _ ->
+ if t.count == 0 then [ td [ colspan 4 ] [] ]
+ else
+ [ td [ class "tc_allvote" ]
+ [ tagscore t.rating
+ , i [ classList [("grayedout", t.overruled)] ] [ text <| " (" ++ String.fromInt t.count ++ ")" ]
+ , if not t.overruled then text ""
+ else strong [ class "standout", title "Tag overruled. All votes other than that of the moderator who overruled it will be ignored." ] [ text "!" ]
+ ]
+ , td [ class "tc_allspoil"] [ text <| Ffi.fmtFloat t.spoiler 2 ]
+ , td [ class "tc_alllie"] [ text <| if t.islie then "lie" else "" ]
+ , td [ class "tc_allwho" ]
+ [ span [ style "opacity" <| if t.othnotes == "" then "0" else "1", style "cursor" "default", title t.othnotes ] [ text "💬 " ]
+ , a [ href <| "/g/links?v="++vid++"&t="++t.id ] [ text "Who?" ]
+ ]
+ ]
+
+viewHead : Bool -> Int -> Bool -> Html Msg
+viewHead mod negCount negShow =
+ thead []
+ [ tr []
+ [ td [ style "font-weight" "normal", style "text-align" "right" ] <|
+ if negCount == 0 then []
+ else [ linkRadio negShow NegShow [ text "Show downvoted tags " ], i [] [ text <| " (" ++ String.fromInt negCount ++ ")" ] ]
+ , td [ colspan 5, class "tc_you" ] [ text "You" ]
+ , td [ colspan 4, class "tc_others" ] [ text "Others" ]
+ ]
+ , tr []
+ [ td [ class "tc_tagname" ] [ text "Tag" ]
+ , td [ class "tc_myvote" ] [ text "Rating" ]
+ , td [ class "tc_myover" ] [ text (if mod then "O" else "") ]
+ , td [ class "tc_myspoil" ] [ text "Spoiler" ]
+ , td [ class "tc_mylie" ] [ text "Lie" ]
+ , td [ class "tc_mynote" ] []
+ , td [ class "tc_allvote" ] [ text "Rating" ]
+ , td [ class "tc_allspoil"] [ text "Spoiler" ]
+ , td [ class "tc_alllie" ] []
+ , td [ class "tc_allwho" ] []
+ ]
+ ]
+
+viewFoot : Api.State -> Bool -> A.Model GApi.ApiTagResult -> String -> Html Msg
+viewFoot state changed add addMsg =
+ tfoot [] [ tr [] [ td [ colspan 10 ]
+ [ div [ style "display" "flex", style "justify-content" "space-between" ]
+ [ A.view searchConfig add [placeholder "Add tags..."]
+ , if addMsg /= ""
+ then b [] [ text addMsg ]
+ else if changed
+ then b [] [ text "You have unsaved changes" ]
+ else text ""
+ , submitButton "Save changes" state True
+ ]
+ , text "Check the ", a [ href "/g" ] [ text "tag list" ], text " to browse all available tags."
+ , br [] []
+ , text "Can't find what you're looking for? ", a [ href "/g/new" ] [ text "Request a new tag" ]
+ ] ] ]
+
+
+-- 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)
+ [ article []
+ [ h1 [] [ text <| "Edit tags for " ++ model.title ]
+ , p []
+ [ text "This is where you can add tags to the visual novel and vote on the existing tags."
+ , br [] []
+ , text "Don't forget to also select the appropriate spoiler option for each tag."
+ , br [] []
+ , text "For more information, check out the ", a [ href "/d10" ] [ text "guidelines." ]
+ ]
+ , table [ class "tgl stripe" ]
+ [ Html.Lazy.lazy3 viewHead model.mod model.negCount model.negShow
+ , Html.Lazy.lazy4 viewFoot model.state model.changed model.add model.addMsg
+ , tbody []
+ <| List.concatMap (\(id,nam) ->
+ let lst = List.filter (\t -> t.cat == id && (t.cat == "new" || t.rating > 0 || t.vote > 0 || model.negShow)) model.tags
+ in
+ if List.length lst == 0
+ then []
+ else tr [class "tagmod_cat"] [ td [] [text nam], td [ class "tc_you", colspan 5 ] [], td [ class "tc_others", colspan 4 ] [] ]
+ :: List.map (\t -> Html.Lazy.lazy4 viewTag t (if t.id == model.selId then model.selType else NoSel) model.id model.mod) lst)
+ [ ("cont", "Content")
+ , ("ero", "Sexual content")
+ , ("tech", "Technical")
+ , ("new", "Newly added tags")
+ ]
+ ]
+ ]
+ ]
diff --git a/elm/TraitEdit.elm b/elm/TraitEdit.elm
new file mode 100644
index 00000000..14b9d263
--- /dev/null
+++ b/elm/TraitEdit.elm
@@ -0,0 +1,205 @@
+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 Lib.Editsum as Editsum
+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 =
+ { state : Api.State
+ , editsum : Editsum.Model
+ , id : Maybe String
+ , name : String
+ , alias : String
+ , sexual : Bool
+ , description : TP.Model
+ , searchable : Bool
+ , applicable : Bool
+ , defaultspoil : Int
+ , parents : List GTE.RecvParents
+ , parentAdd : A.Model GApi.ApiTraitResult
+ , gorder : Int
+ , dupNames : List GApi.ApiDupNames
+ }
+
+
+init : GTE.Recv -> Model
+init d =
+ { state = Api.Normal
+ , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = True }
+ , id = d.id
+ , name = d.name
+ , alias = d.alias
+ , sexual = d.sexual
+ , description = TP.bbcode d.description
+ , searchable = d.searchable
+ , applicable = d.applicable
+ , defaultspoil = d.defaultspoil
+ , parents = d.parents
+ , parentAdd = A.init ""
+ , gorder = d.gorder
+ , 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
+ , editsum = m.editsum.editsum.data
+ , hidden = m.editsum.hidden
+ , locked = m.editsum.locked
+ , name = m.name
+ , alias = m.alias
+ , sexual = m.sexual
+ , description = m.description.data
+ , searchable = m.searchable
+ , applicable = m.applicable
+ , defaultspoil = m.defaultspoil
+ , parents = List.map (\l -> {parent=l.parent, main=l.main}) m.parents
+ , gorder = m.gorder
+ }
+
+
+type Msg
+ = Name String
+ | Alias String
+ | Searchable Bool
+ | Applicable Bool
+ | Sexual Bool
+ | DefaultSpoil Int
+ | Description TP.Msg
+ | Editsum Editsum.Msg
+ | ParentMain Int Bool
+ | 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)
+ 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 | gorder = 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)
+ Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
+
+ ParentMain i _-> ({ model | parents = List.indexedMap (\n p -> { p | main = i == n }) model.parents }, Cmd.none)
+ ParentDel i ->
+ let np = delidx i model.parents
+ nnp = if List.any (\p -> p.main) np then np else List.indexedMap (\n p -> { p | main = n == 0 }) np
+ in ({ model | parents = nnp }, 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.parent == p.id) model.parents
+ then ({ model | parentAdd = nm }, c)
+ else ({ model | parentAdd = A.clear nm "", parents = model.parents ++ [{ parent = p.id, main = List.isEmpty model.parents, name = p.name, group = p.group_name }] }, c)
+
+ Submit -> ({ model | state = Api.Loading }, GTE.send (encode model) Submitted)
+ Submitted (GApi.DupNames l) -> ({ model | dupNames = l, state = Api.Normal }, Cmd.none)
+ Submitted (GApi.Redirect s) -> (model, load s)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ form_ "" Submit (model.state == Api.Loading)
+ [ article []
+ [ h1 [] [ text <| if model.id == Nothing then "Submit new trait" else "Edit trait" ]
+ , table [ class "formtable" ]
+ [ 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 [] [ text "The following trait names are already present in the same group:" ]
+ , ul [] <| List.map (\t ->
+ li [] [ a [ href ("/"++t.id) ] [ text t.name ] ]
+ ) dups
+ ]
+ ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , formField "" [ label [] [ inputCheck "" model.searchable Searchable, text " Searchable (people can use this trait to find characters)" ] ]
+ , 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" ] [ small [] [ text <| p.parent ++ ":" ] ]
+ , td []
+ [ Maybe.withDefault (text "") <| Maybe.map (\g -> small [] [ text (g ++ " / ") ]) p.group
+ , a [ href <| "/" ++ p.parent ] [ text p.name ]
+ ]
+ , td [] [ label [] [ inputRadio "parentprimary" p.main (ParentMain i), text " primary" ] ]
+ , 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.gorder) Order (style "width" "50px" :: GTE.valGorder)
+ , text " Only meaningful if this trait is a \"group\", i.e. a trait without any parents."
+ , text " This number determines the order in which the groups are displayed on character pages."
+ ]
+ ]
+ ]
+ , article [ class "submit" ]
+ [ Html.map Editsum (Editsum.view model.editsum)
+ , submitButton "Submit" model.state (isValid model)
+ ]
+ ]
diff --git a/elm/UList/DateEdit.elm b/elm/UList/DateEdit.elm
index d20dbba7..72f1b87d 100644
--- a/elm/UList/DateEdit.elm
+++ b/elm/UList/DateEdit.elm
@@ -1,4 +1,4 @@
-module UList.DateEdit exposing (main)
+module UList.DateEdit exposing (main,init,view,update,Model,Msg)
import Html exposing (..)
import Html.Attributes exposing (..)
@@ -76,7 +76,7 @@ view : Model -> Html Msg
view model = div (class "compact" :: if model.visible then [] else [onMouseOver Show]) <|
case model.state of
Api.Loading -> [ span [ class "spinner" ] [] ]
- Api.Error _ -> [ b [ class "standout" ] [ text "error" ] ] -- Argh
+ Api.Error _ -> [ b [] [ text "error" ] ] -- Argh
Api.Normal ->
[ if model.visible
then input ([ type_ "date", class "text", value model.val, onInputValidation Val, onBlur (Save model.debnum), placeholder "yyyy-mm-dd" ] ++ GDE.valDate) []
diff --git a/elm/UList/LabelEdit.elm b/elm/UList/LabelEdit.elm
index 1011d950..153fad8c 100644
--- a/elm/UList/LabelEdit.elm
+++ b/elm/UList/LabelEdit.elm
@@ -12,6 +12,7 @@ import Lib.Html exposing (..)
import Lib.Api as Api
import Lib.DropDown as DD
import Gen.Api as GApi
+import Gen.UListLabelAdd as GLA
import Gen.UListLabelEdit as GLE
@@ -26,29 +27,36 @@ main = Browser.element
port ulistLabelChanged : Bool -> Cmd msg
type alias Model =
- { uid : Int
- , vid : Int
+ { uid : String
+ , vid : String
, labels : List GLE.RecvLabels
, sel : Set Int -- Set of label IDs applied on the server
, tsel : Set Int -- Set of label IDs applied on the client
, state : Dict Int Api.State -- Only for labels that are being changed
, dd : DD.Config Msg
+ , custom : String
+ , customSt : Api.State
}
init : GLE.Recv -> Model
init f =
{ uid = f.uid
, vid = f.vid
- , labels = f.labels
+ , labels = List.filter (\l -> l.id > 0) f.labels
, sel = Set.fromList f.selected
, tsel = Set.fromList f.selected
, state = Dict.empty
- , dd = DD.init ("ulist_labeledit_dd" ++ String.fromInt f.vid) Open
+ , dd = DD.init ("ulist_labeledit_dd" ++ f.vid) Open
+ , custom = ""
+ , customSt = Api.Normal
}
type Msg
= Open Bool
| Toggle Int Bool Bool
+ | Custom String
+ | CustomSubmit
+ | CustomSaved GApi.Response
| Saved Int Bool GApi.Response
@@ -69,10 +77,21 @@ update msg model =
GLE.send { uid = model.uid, vid = model.vid, label = l, applied = b } (Saved l b)
-- Unselect other progress labels (1..4) when setting a progress label
:: if cascade
- then (List.map (\i -> selfCmd (Toggle i False False)) <| List.filter (\i -> l >= 0 && l <= 4 && i >= 0 && i <= 4 && i /= l) <| Set.toList model.tsel)
+ then (List.map (\i -> selfCmd (Toggle i False False)) <| List.filter (\i -> l >= 1 && l <= 4 && i >= 1 && i <= 4 && i /= l) <| Set.toList model.tsel)
else []
)
+ Custom t -> ({ model | custom = t }, Cmd.none)
+ CustomSubmit -> ({ model | customSt = Api.Loading }, GLA.send { uid = model.uid, vid = model.vid, label = model.custom } CustomSaved)
+ CustomSaved (GApi.LabelId id) ->
+ let new = List.filter (\l -> l.id == id) model.labels |> List.isEmpty
+ in ({ model | labels = if new then model.labels ++ [{ id = id, label = model.custom, private = True }] else model.labels
+ , customSt = Api.Normal, custom = ""
+ , sel = Set.insert id model.sel
+ , tsel = Set.insert id model.tsel
+ }, Cmd.none)
+ CustomSaved e -> ({ model | customSt = Api.Error e }, Cmd.none)
+
Saved l b (GApi.Success) ->
let nmodel = { model | sel = if b then Set.insert l model.sel else Set.remove l model.sel, state = Dict.remove l model.state }
in (nmodel, ulistLabelChanged (isPublic nmodel))
@@ -82,21 +101,34 @@ update msg model =
view : Model -> String -> Html Msg
view model txt =
let
- str = String.join ", " <| List.filterMap (\l -> if l.id /= 7 && Set.member l.id model.sel then Just l.label else Nothing) model.labels
+ lbl = List.intersperse (text ", ") <| List.filterMap (\l ->
+ if l.id /= 7 && Set.member l.id model.sel
+ then Just <| span []
+ [ if l.id <= 6 && txt /= "-" then ulistIcon l.id l.label else text ""
+ , text (" " ++ l.label) ]
+ else Nothing) model.labels
item l =
li [ ]
[ linkRadio (Set.member l.id model.tsel) (Toggle l.id True)
[ text l.label
, text " "
- , span [ class "spinner", classList [("invisible", Dict.get l.id model.state /= Just Api.Loading)] ] []
, case Dict.get l.id model.state of
- Just (Api.Error _) -> b [ class "standout" ] [ text "error" ] -- Need something better
- _ -> text ""
+ Just Api.Loading -> span [ class "spinner" ] []
+ Just (Api.Error _) -> b [] [ text "error" ] -- Need something better
+ _ -> if l.id <= 6 then ulistIcon l.id l.label else text ""
]
]
+
+ custom =
+ li [] [
+ case model.customSt of
+ Api.Normal -> Html.form [ onSubmit CustomSubmit ]
+ [ inputText "" model.custom Custom ([placeholder "new label", style "width" "150px"] ++ GLA.valLabel) ]
+ Api.Loading -> span [ class "spinner" ] []
+ Api.Error _ -> b [] [ text "error" ] ]
in
DD.view model.dd
(if List.any (\s -> s == Api.Loading) <| Dict.values model.state then Api.Loading else Api.Normal)
- (text <| if str == "" then txt else str)
- (\_ -> [ ul [] <| List.map item <| List.filter (\l -> l.id /= 7) model.labels ])
+ (if List.isEmpty lbl then text txt else span [] lbl)
+ (\_ -> [ ul [] <| List.map item (List.filter (\l -> l.id /= 7) model.labels) ++ [ custom ] ])
diff --git a/elm/UList/LabelEdit.js b/elm/UList/LabelEdit.js
deleted file mode 100644
index 6617bf11..00000000
--- a/elm/UList/LabelEdit.js
+++ /dev/null
@@ -1,11 +0,0 @@
-var init = Elm.UList.LabelEdit.init;
-Elm.UList.LabelEdit.init = function(opt) {
- opt.flags.uid = pageVars.uid;
- opt.flags.labels = pageVars.labels;
- var app = init(opt);
- app.ports.ulistLabelChanged.subscribe(function(pub) {
- var l = document.getElementById('ulist_public_'+opt.flags.vid);
- l.setAttribute('data-publabel', pub?1:'');
- l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
- });
-};
diff --git a/elm/UList/ManageLabels.elm b/elm/UList/ManageLabels.elm
index de56844a..8a5533d7 100644
--- a/elm/UList/ManageLabels.elm
+++ b/elm/UList/ManageLabels.elm
@@ -24,7 +24,7 @@ main = Browser.element
}
type alias Model =
- { uid : Int
+ { uid : String
, state : Api.State
, labels : List GML.SendLabels
, editing : Maybe Int
@@ -34,7 +34,7 @@ init : GML.Send -> Model
init d =
{ uid = d.uid
, state = Api.Normal
- , labels = d.labels
+ , labels = List.filter (\l -> l.id > 0) d.labels
, editing = Nothing
}
@@ -76,8 +76,8 @@ view model =
]
, td [ ] [ linkRadio l.private (Private n) [ text "private" ] ]
, td [ class "stealth" ]
- [ if l.id == 7 then b [ class "grayedout" ] [ text "applied when you vote" ]
- else if l.id > 0 && l.id < 10 then b [ class "grayedout" ] [ text "built-in" ]
+ [ if l.id == 7 then small [] [ text "applied when you vote" ]
+ else if l.id > 0 && l.id < 10 then small [] [ text "built-in" ]
else if l.delete == Nothing then a [ onClick (Delete n (Just 1)) ] [ text "remove" ]
else inputSelect "" l.delete (Delete n) []
[ (Nothing, "Keep label")
@@ -87,10 +87,12 @@ view model =
]
]
]
+
+ hasDup = hasDuplicates <| List.map (\l -> l.label) model.labels
in
Html.form [ onSubmit Submit, class "managelabels hidden" ]
[ div [ ]
- [ b [] [ text "How to use labels" ]
+ [ strong [] [ text "How to use labels" ]
, ul []
[ li [] [ text "You can assign multiple labels to a visual novel" ]
, li [] [ text "You can create custom labels or just use the built-in labels" ]
@@ -108,16 +110,15 @@ view model =
, tfoot []
[ if List.any (\l -> l.id == 7 && l.private) model.labels && List.any (\l -> not l.private) model.labels
then tr [] [ td [ colspan 4 ]
- [ b [ class "standout" ] [ text "WARNING: " ]
+ [ b [] [ text "WARNING: " ]
, text "Your vote is still public if you assign a non-private label to the visual novel."
] ]
else text ""
, tr []
[ td [] []
, td [ colspan 3 ]
- [ a [ onClick Add ] [ text "New label" ]
- --, inputButton "Save changes" Noop []
- , submitButton "Save changes" model.state True
+ [ if List.length model.labels < 500 then inputButton "New label" Add [] else text ""
+ , submitButton "Save changes" model.state (not hasDup)
]
]
]
diff --git a/elm/UList/ManageLabels.js b/elm/UList/ManageLabels.js
deleted file mode 100644
index 6f762bd8..00000000
--- a/elm/UList/ManageLabels.js
+++ /dev/null
@@ -1,13 +0,0 @@
-document.querySelectorAll('#managelabels').forEach(function(b) {
- b.onclick = function() {
- document.querySelectorAll('.managelabels').forEach(function(e) { e.classList.toggle('hidden') })
- document.querySelectorAll('.savedefault').forEach(function(e) { e.classList.add('hidden') })
- };
- return false;
-});
-
-var init = Elm.UList.ManageLabels.init;
-Elm.UList.ManageLabels.init = function(opt) {
- opt.flags = { uid: pageVars.uid, labels: pageVars.labels };
- init(opt);
-};
diff --git a/elm/UList/Opt.elm b/elm/UList/Opt.elm
index 10e41f7d..e909f2d8 100644
--- a/elm/UList/Opt.elm
+++ b/elm/UList/Opt.elm
@@ -13,17 +13,17 @@ import Lib.Html exposing (..)
import Lib.Api as Api
import Lib.RDate as RDate
import Lib.DropDown as DD
+import UList.ReleaseEdit as RE
import Gen.Types as T
import Gen.Api as GApi
import Gen.UListVNNotes as GVN
import Gen.UListDel as GDE
-import Gen.UListRStatus as GRS
import Gen.Release as GR
main : Program GVN.Recv Model Msg
main = Browser.element
{ init = \f -> (init f, Date.today |> Task.perform Today)
- , subscriptions = \model -> Sub.batch (List.map (\r -> DD.sub r.dd) <| model.rels)
+ , subscriptions = \model -> List.map (\r -> Sub.map (Rel r.rid) (DD.sub r.dd)) model.rels |> Sub.batch
, view = view
, update = update
}
@@ -32,21 +32,6 @@ port ulistVNDeleted : Bool -> Cmd msg
port ulistNotesChanged : String -> Cmd msg
port ulistRelChanged : (Int, Int) -> Cmd msg
-type alias Rel =
- { id : Int
- , status : Int -- Special value -1 means 'delete this release from my list'
- , state : Api.State
- , dd : DD.Config Msg
- }
-
-newrel : Int -> Int -> Int -> Rel
-newrel rid vid st =
- { id = rid
- , status = st
- , state = Api.Normal
- , dd = DD.init ("ulist_reldd" ++ String.fromInt vid ++ "_" ++ String.fromInt rid) (RelOpen rid)
- }
-
type alias Model =
{ flags : GVN.Recv
, today : Date.Date
@@ -55,9 +40,9 @@ type alias Model =
, notes : String
, notesRev : Int
, notesState : Api.State
- , rels : List Rel
- , relNfo : Dict Int GApi.ApiReleases
- , relOptions : Maybe (List (Int, String))
+ , rels : List RE.Model
+ , relNfo : Dict String GApi.ApiReleases
+ , relOptions : Maybe (List (String, String))
, relState : Api.State
}
@@ -70,7 +55,7 @@ init f =
, notes = f.notes
, notesRev = 0
, notesState = Api.Normal
- , rels = List.map2 (\st nfo -> newrel nfo.id f.vid st) f.relstatus f.rels
+ , rels = List.map2 (\st nfo -> RE.init f.vid { uid = f.uid, rid = nfo.id, status = Just st, empty = "" }) f.relstatus f.rels
, relNfo = Dict.fromList <| List.map (\r -> (r.id, r)) f.rels
, relOptions = Nothing
, relState = Api.Normal
@@ -84,20 +69,10 @@ type Msg
| Notes String
| NotesSave Int
| NotesSaved Int GApi.Response
- | RelOpen Int Bool
- | RelSet Int Int Bool
- | RelSaved Int Int GApi.Response
+ | Rel String RE.Msg
| RelLoad
| RelLoaded GApi.Response
- | RelAdd Int
-
-
-modrel : Int -> (Rel -> Rel) -> List Rel -> List Rel
-modrel rid f = List.map (\r -> if r.id == rid then f r else r)
-
-
-showrel : GApi.ApiReleases -> String
-showrel r = "[" ++ (RDate.format (RDate.expand r.released)) ++ " " ++ (String.join "," r.lang) ++ "] " ++ r.title ++ " (r" ++ String.fromInt r.id ++ ")"
+ | RelAdd String
update : Msg -> Model -> (Model, Cmd Msg)
@@ -128,16 +103,19 @@ update msg model =
else ({model | flags = nf, notesState = Api.Normal }, ulistNotesChanged model.notes)
NotesSaved _ e -> ({ model | notesState = Api.Error e }, Cmd.none)
- RelOpen rid b -> ({ model | rels = modrel rid (\r -> { r | dd = DD.toggle r.dd b }) model.rels }, Cmd.none)
- RelSet rid st _ ->
- ( { model | rels = modrel rid (\r -> { r | dd = DD.toggle r.dd False, status = st, state = Api.Loading }) model.rels }
- , GRS.send { uid = model.flags.uid, rid = rid, status = st } (RelSaved rid st) )
- RelSaved rid st GApi.Success ->
- let nr = if st == -1 then List.filter (\r -> r.id /= rid) model.rels
- else modrel rid (\r -> { r | state = Api.Normal }) model.rels
- in ( { model | rels = nr }
- , ulistRelChanged (List.length <| List.filter (\r -> r.status == 2) nr, List.length nr) )
- RelSaved rid _ e -> ({ model | rels = modrel rid (\r -> { r | state = Api.Error e }) model.rels }, Cmd.none)
+ Rel rid m ->
+ case List.filterMap (\r -> if r.rid == rid then Just (RE.update m r) else Nothing) model.rels |> List.head of
+ Nothing -> (model, Cmd.none)
+ Just (rm, rc) ->
+ let
+ nr = if rm.state == Api.Normal && rm.status == Nothing
+ then List.filter (\r -> r.rid /= rid) model.rels
+ else List.map (\r -> if r.rid == rid then rm else r) model.rels
+ nm = { model | rels = nr }
+ nc = Cmd.batch
+ [ Cmd.map (Rel rid) rc
+ , ulistRelChanged (List.length <| List.filter (\r -> r.status == Just 2) nr, List.length nr) ]
+ in (nm, nc)
RelLoad ->
( { model | relState = Api.Loading }
@@ -146,12 +124,12 @@ update msg model =
( { model
| relState = Api.Normal
, relNfo = Dict.union (Dict.fromList <| List.map (\r -> (r.id, r)) rels) model.relNfo
- , relOptions = Just <| List.map (\r -> (r.id, showrel r)) rels
+ , relOptions = Just <| List.map (\r -> (r.id, RDate.showrel r)) rels
}, Cmd.none)
RelLoaded e -> ({ model | relState = Api.Error e }, Cmd.none)
RelAdd rid ->
- ( { model | rels = model.rels ++ (if rid == 0 then [] else [newrel rid model.flags.vid 2]) }
- , Task.perform (RelSet rid 2) <| Task.succeed True)
+ ( { model | rels = model.rels ++ (if rid == "" then [] else [RE.init model.flags.vid { rid = rid, uid = model.flags.uid, status = Just 2, empty = "" }]) }
+ , Task.perform (always <| Rel rid <| RE.Set (Just 2) True) <| Task.succeed True)
view : Model -> Html Msg
@@ -174,7 +152,7 @@ view model =
else []
) ++ (
case model.notesState of
- Api.Error e -> [ br [] [], b [ class "standout" ] [ text <| Api.showResponse e ] ]
+ Api.Error e -> [ br [] [], b [] [ text <| Api.showResponse e ] ]
_ -> []
)
]
@@ -187,37 +165,29 @@ view model =
-- TODO: This <select> solution is ugly as hell, a Lib.DropDown-based solution would be nicer.
-- Or just throw all releases in the table and use the status field for add stuff.
case (model.relOptions, model.relState) of
- (Just opts, _) -> [ inputSelect "" 0 RelAdd [ style "width" "500px" ]
- <| (0, "-- add release --") :: List.filter (\(rid,_) -> not <| List.any (\r -> r.id == rid) model.rels) opts ]
+ (Just opts, _) -> [ inputSelect "" "" RelAdd [ style "width" "500px" ]
+ <| ("", "-- add release --") :: List.filter (\(rid,_) -> not <| List.any (\r -> r.rid == rid) model.rels) opts ]
(_, Api.Normal) -> []
(_, Api.Loading) -> [ span [ class "spinner" ] [], text "Loading releases..." ]
- (_, Api.Error e) -> [ b [ class "standout" ] [ text <| Api.showResponse e ], text ". ", a [ href "#", onClickD RelLoad ] [ text "Try again" ] ]
+ (_, Api.Error e) -> [ b [] [ text <| Api.showResponse e ], text ". ", a [ href "#", onClickD RelLoad ] [ text "Try again" ] ]
]
]
]
rel r =
- case Dict.get r.id model.relNfo of
+ case Dict.get r.rid model.relNfo of
Nothing -> text ""
Just nfo -> relnfo r nfo
relnfo r nfo =
tr []
- [ td [ class "tco1" ]
- [ DD.view r.dd r.state (text <| Maybe.withDefault "removing" <| lookup r.status T.rlistStatus)
- <| \_ ->
- [ ul [] <| List.map (\(n, status) ->
- li [ ] [ linkRadio (n == r.status) (RelSet r.id n) [ text status ] ]
- ) T.rlistStatus
- ++ [ li [] [ a [ href "#", onClickD (RelSet r.id -1 True) ] [ text "remove" ] ] ]
- ]
- ]
+ [ td [ class "tco1" ] [ Html.map (Rel r.rid) (RE.view r) ]
, td [ class "tco2" ] [ RDate.display model.today nfo.released ]
, td [ class "tco3" ]
<| List.map platformIcon nfo.platforms
++ List.map langIcon nfo.lang
++ [ releaseTypeIcon nfo.rtype ]
- , td [ class "tco4" ] [ a [ href ("/r"++String.fromInt nfo.id), title nfo.original ] [ text nfo.title ] ]
+ , td [ class "tco4" ] [ a [ href ("/"++nfo.id), title nfo.alttitle ] [ text nfo.title ] ]
]
confirm =
@@ -232,4 +202,4 @@ view model =
(False, _) -> table [] <| (if model.flags.own then opt else []) ++ List.map rel model.rels
(_, Api.Normal) -> confirm
(_, Api.Loading) -> div [ class "spinner" ] []
- (_, Api.Error e) -> b [ class "standout" ] [ text <| "Error removing item: " ++ Api.showResponse e ]
+ (_, Api.Error e) -> b [] [ text <| "Error removing item: " ++ Api.showResponse e ]
diff --git a/elm/UList/Opt.js b/elm/UList/Opt.js
deleted file mode 100644
index 75a302b9..00000000
--- a/elm/UList/Opt.js
+++ /dev/null
@@ -1,36 +0,0 @@
-var init = Elm.UList.Opt.init;
-
-var actualInit = function(opt) {
- var app = init(opt);
-
- app.ports.ulistVNDeleted.subscribe(function(b) {
- var e = document.getElementById('ulist_tr_'+opt.flags.vid);
- e.parentNode.removeChild(e.nextElementSibling);
- e.parentNode.removeChild(e);
-
- // Have to restripe after deletion :(
- var rows = document.querySelectorAll('.ulist > table > tbody > tr');
- for(var i=0; i<rows.length; i++)
- rows[i].classList.toggle('odd', Math.floor(i/2) % 2 == 0);
- });
-
- app.ports.ulistNotesChanged.subscribe(function(n) {
- document.getElementById('ulist_notes_'+opt.flags.vid).innerText = n;
- });
-
- app.ports.ulistRelChanged.subscribe(function(rels) {
- var e = document.getElementById('ulist_relsum_'+opt.flags.vid);
- e.classList.toggle('todo', rels[0] != rels[1]);
- e.classList.toggle('done', rels[1] > 0 && rels[0] == rels[1]);
- e.innerText = rels[0] + '/' + rels[1];
- });
-};
-
-// This module is typically hidden, lazily load it only when the module is visible to speed up page load time.
-Elm.UList.Opt.init = function(opt) {
- var e = document.getElementById('collapse_vid'+opt.flags.vid);
- if(e.checked)
- actualInit(opt);
- else
- e.addEventListener('click', function() { actualInit(opt) }, { once: true });
-};
diff --git a/elm/UList/ReleaseEdit.elm b/elm/UList/ReleaseEdit.elm
new file mode 100644
index 00000000..7f901d67
--- /dev/null
+++ b/elm/UList/ReleaseEdit.elm
@@ -0,0 +1,70 @@
+module UList.ReleaseEdit exposing (main, init, update, view, Model, Msg(..))
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Lib.Util exposing (..)
+import Lib.Html exposing (..)
+import Lib.Api as Api
+import Lib.DropDown as DD
+import Gen.Types exposing (rlistStatus)
+import Gen.Api as GApi
+import Gen.UListRStatus as GRS
+
+
+main : Program GRS.Send Model Msg
+main = Browser.element
+ { init = \f -> (init "" f, Cmd.none)
+ , subscriptions = \model -> DD.sub model.dd
+ , view = view
+ , update = update
+ }
+
+type alias Model =
+ { uid : String
+ , rid : String
+ , status : Maybe Int
+ , empty : String
+ , state : Api.State
+ , dd : DD.Config Msg
+ }
+
+init : String -> GRS.Send -> Model
+init vid f =
+ { uid = f.uid
+ , rid = f.rid
+ , status = f.status
+ , empty = f.empty
+ , state = Api.Normal
+ , dd = DD.init ("ulist_reldd" ++ vid ++ "_" ++ f.rid) Open
+ }
+
+type Msg
+ = Open Bool
+ | Set (Maybe Int) Bool
+ | Saved GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Open b -> ({ model | dd = DD.toggle model.dd b }, Cmd.none)
+ Set st _ ->
+ ( { model | dd = DD.toggle model.dd False, status = st, state = Api.Loading }
+ , GRS.send { uid = model.uid, rid = model.rid, status = st, empty = "" } Saved )
+
+ Saved GApi.Success -> ({ model | state = Api.Normal }, Cmd.none)
+ Saved e -> ({ model | state = Api.Error e }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ DD.view model.dd model.state
+ (text <| Maybe.withDefault model.empty <| Maybe.andThen (\s -> lookup s rlistStatus) model.status)
+ <| \_ ->
+ [ ul [] <| List.map (\(n, status) ->
+ li [ ] [ linkRadio (Just n == model.status) (Set (Just n)) [ text status ] ]
+ ) rlistStatus
+ ++ [ li [] [ a [ href "#", onClickD (Set Nothing True) ] [ text "remove" ] ] ]
+ ]
diff --git a/elm/UList/SaveDefault.elm b/elm/UList/SaveDefault.elm
index a0945c4b..cf7ab13b 100644
--- a/elm/UList/SaveDefault.elm
+++ b/elm/UList/SaveDefault.elm
@@ -21,7 +21,7 @@ main = Browser.element
type alias Model =
{ state : Api.State
- , uid : Int
+ , uid : String
, opts : GUSD.SendOpts
, field : String -- Ewwww stringly typed enum
, hid : Bool
@@ -56,9 +56,9 @@ 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" ]
+ [ strong [] [ text "Save as default" ]
, br [] []
, text "This will change the default label selection, visible columns and table sorting options for the selected page to the currently applied settings."
, text " The saved view will also apply to users visiting your lists."
diff --git a/elm/UList/SaveDefault.js b/elm/UList/SaveDefault.js
deleted file mode 100644
index a253680f..00000000
--- a/elm/UList/SaveDefault.js
+++ /dev/null
@@ -1,7 +0,0 @@
-document.querySelectorAll('#savedefault').forEach(function(b) {
- b.onclick = function() {
- document.querySelectorAll('.savedefault').forEach(function(e) { e.classList.toggle('hidden') })
- document.querySelectorAll('.managelabels').forEach(function(e) { e.classList.add('hidden') })
- };
- return false;
-});
diff --git a/elm/UList/VNPage.elm b/elm/UList/VNPage.elm
index 8e827224..63a1136d 100644
--- a/elm/UList/VNPage.elm
+++ b/elm/UList/VNPage.elm
@@ -1,129 +1,70 @@
+-- This is basically the same thing as UList.Widget, but with a slightly different UI.
+-- Release options are not available in this mode, as VN pages have a separate
+-- release listing anyway.
module UList.VNPage exposing (main)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Browser
-import Set
+import Task
+import Date
import Lib.Html exposing (..)
import Lib.Util exposing (..)
import Lib.Api as Api
import Lib.DropDown as DD
-import Gen.Api as GApi
-import Gen.UListDel as GDE
+import Gen.UListWidget as GUW
+import Gen.UListVNNotes as GVN
import UList.LabelEdit as LE
import UList.VoteEdit as VE
+import UList.DateEdit as DE
+import UList.Widget as UW
--- We don't have a Gen.* module for this (yet), so define these manually
-type alias RecvLabels =
- { id : Int
- , label : String
- , private : Bool
- }
-
-type alias Recv =
- { uid : Int
- , vid : Int
- , onlist : Bool
- , canvote : Bool
- , vote : Maybe String
- , labels : List RecvLabels
- , selected : List Int
- }
-
-
-main : Program Recv Model Msg
+main : Program GUW.Recv UW.Model UW.Msg
main = Browser.element
- { init = \f -> (init f, Cmd.none)
- , subscriptions = \model -> Sub.batch [ Sub.map Labels (DD.sub model.labels.dd), Sub.map Vote (DD.sub model.vote.dd) ]
+ { init = \f -> (UW.init f, Date.today |> Task.perform UW.Today)
+ , subscriptions = \m -> Sub.batch
+ [ Sub.map UW.Label (DD.sub m.labels.dd)
+ , Sub.map UW.Vote (DD.sub m.vote.dd) ]
, view = view
- , update = update
+ , update = UW.update
}
-type alias Model =
- { flags : Recv
- , onlist : Bool
- , del : Bool
- , state : Api.State -- For adding/deleting; Vote and label edit widgets have their own state
- , labels : LE.Model
- , vote : VE.Model
- }
-
-init : Recv -> Model
-init f =
- { flags = f
- , onlist = f.onlist
- , del = False
- , state = Api.Normal
- , labels = LE.init { uid = f.uid, vid = f.vid, labels = f.labels, selected = f.selected }
- , vote = VE.init { uid = f.uid, vid = f.vid, vote = f.vote }
- }
-
-type Msg
- = Labels LE.Msg
- | Vote VE.Msg
- | Del Bool
- | Delete
- | Deleted GApi.Response
-
-setOnList : Model -> Model
-setOnList model = { model | onlist = model.onlist || model.vote.ovote /= Nothing || not (Set.isEmpty model.labels.sel) }
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Labels m -> let (nm, cmd) = LE.update m model.labels in (setOnList { model | labels = nm}, Cmd.map Labels cmd)
- Vote m -> let (nm, cmd) = VE.update m model.vote in (setOnList { model | vote = nm}, Cmd.map Vote cmd)
-
- Del b -> ({ model | del = b }, Cmd.none)
- Delete -> ({ model | state = Api.Loading }, GDE.send { uid = model.flags.uid, vid = model.flags.vid } Deleted)
- Deleted GApi.Success ->
- ( { model
- | state = Api.Normal, onlist = False, del = False
- , labels = LE.init { uid = model.flags.uid, vid = model.flags.vid, labels = model.flags.labels, selected = [] }
- , vote = VE.init { uid = model.flags.uid, vid = model.flags.vid, vote = Nothing }
- }
- , Cmd.none)
- Deleted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-isPublic : Model -> Bool
-isPublic model =
- LE.isPublic model.labels
- || (isJust model.vote.vote && List.any (\l -> l.id == 7 && not l.private) model.labels.labels)
-
-
-view : Model -> Html Msg
+view : UW.Model -> Html UW.Msg
view model =
- div [ class "ulistvn elm_dd_input" ]
- [ span [] <|
- case (model.state, model.del, model.onlist) of
- (Api.Loading, _, _) -> [ span [ class "spinner" ] [] ]
- (Api.Error e, _, _) -> [ b [ class "standout" ] [ text <| Api.showResponse e ] ]
- (Api.Normal, _, False) -> [ b [ class "grayedout" ] [ text "not on your list" ] ]
- (Api.Normal, True, _) ->
- [ a [ onClickD Delete ] [ text "Yes, delete" ]
- , text " | "
- , a [ onClickD (Del False) ] [ text "Cancel" ]
- ]
- (Api.Normal, False, True) ->
- [ span [ classList [("hidden", not (isPublic model))], title "This visual novel is on your public list" ] [ text "👁 " ]
- , text "On your list | "
- , a [ onClickD (Del True) ] [ text "Remove from list" ]
+ let notesBut =
+ [ a [ href "#", onClickD UW.NotesToggle ] [ text "💬" ]
+ , span [ class "spinner", classList [("hidden", model.notesState /= Api.Loading)] ] []
+ , case model.notesState of
+ Api.Error e -> b [] [ text <| Api.showResponse e ]
+ _ -> text ""
]
- , b [] [ text "User options" ]
- , table [ style "margin" "4px 0 0 0" ]
+ in
+ div [ class "ulistvn elm_dd_input" ]
+ [ span [] (UW.viewStatus model)
+ , strong [] [ text "User options" ]
+ , table [ style "margin" "4px 0 0 0", style "width" "100%" ] <|
[ tr [ class "odd" ]
[ td [ class "key" ] [ text "My labels" ]
- , td [ colspan 2 ] [ Html.map Labels (LE.view model.labels "- select label -") ]
+ , td [ colspan (if model.canvote then 2 else 1) ] [ Html.map UW.Label (LE.view model.labels "- select label -") ]
+ , if model.canvote then text "" else td [] notesBut
]
- , if model.flags.canvote || (Maybe.withDefault "-" model.flags.vote /= "-")
+ , if model.canvote
then tr [ class "nostripe compact" ]
[ td [] [ text "My vote" ]
- , td [ style "width" "80px" ] [ Html.map Vote (VE.view model.vote "- vote -") ]
- , td [] []
+ , td [ style "width" "80px" ] [ Html.map UW.Vote (VE.view model.vote "- vote -") ]
+ , td [] <| notesBut ++ [ UW.viewReviewLink model ]
]
else text ""
+ ] ++ if not model.notesVis then [] else
+ [ tr [ class "nostripe compact" ]
+ [ td [] [ text "Notes" ]
+ , td [ colspan 2 ]
+ [ textarea ([ id "widget-notes", placeholder "Notes", rows 2, cols 30, onInput UW.Notes, onBlur (UW.NotesSave model.notesRev)] ++ GVN.valNotes) [ text model.notes ] ]
+ ]
+ ] ++ if not model.onlist then [] else
+ [ tr [] [ td [] [ text "Start date" ], td [ colspan 2, class "date" ] [ Html.map UW.Started (DE.view model.started ) ] ]
+ , tr [] [ td [] [ text "Finish date" ], td [ colspan 2, class "date" ] [ Html.map UW.Finished (DE.view model.finished) ] ]
]
]
diff --git a/elm/UList/VoteEdit.elm b/elm/UList/VoteEdit.elm
index 2ecdde10..2f57dca8 100644
--- a/elm/UList/VoteEdit.elm
+++ b/elm/UList/VoteEdit.elm
@@ -42,12 +42,12 @@ init f =
in
{ state = Api.Normal
, flags = f
- , dd = DD.init ("vote_edit_dd_" ++ String.fromInt f.vid) Open
+ , dd = DD.init ("vote_edit_dd_" ++ f.vid) Open
, text = if List.any (\n -> v == Just (String.fromInt n)) (List.indexedMap (\a b -> a+1) ratings) then "" else Maybe.withDefault "" v
, vote = v
, ovote = v
, isvalid = True
- , fieldId = "vote_edit_" ++ String.fromInt f.vid
+ , fieldId = "vote_edit_" ++ f.vid
}
type Msg
diff --git a/elm/UList/VoteEdit.js b/elm/UList/VoteEdit.js
deleted file mode 100644
index 99d5b31b..00000000
--- a/elm/UList/VoteEdit.js
+++ /dev/null
@@ -1,9 +0,0 @@
-var init = Elm.UList.VoteEdit.init;
-Elm.UList.VoteEdit.init = function(opt) {
- var app = init(opt);
- app.ports.ulistVoteChanged.subscribe(function(voted) {
- var l = document.getElementById('ulist_public_'+opt.flags.vid);
- l.setAttribute('data-voted', voted?1:'');
- l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
- });
-};
diff --git a/elm/UList/Widget.elm b/elm/UList/Widget.elm
new file mode 100644
index 00000000..ac5e0d70
--- /dev/null
+++ b/elm/UList/Widget.elm
@@ -0,0 +1,316 @@
+-- This module provides a ulist management widget. By default it shows as a
+-- small icon indicating the list status, which can be clicked on to open a
+-- full management modal for the VN.
+--
+-- It is also used by UList.VNPage to provide a different view for essentially
+-- the same functionality.
+module UList.Widget exposing (Model, Msg(..), main, init, update, viewStatus, viewReviewLink)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Browser.Dom exposing (focus)
+import Task
+import Process
+import Set
+import Date
+import Dict exposing (Dict)
+import Lib.Util exposing (..)
+import Lib.Html exposing (..)
+import Lib.Ffi as Ffi
+import Lib.Api as Api
+import Lib.RDate as RDate
+import Lib.DropDown as DD
+import Gen.Api as GApi
+import Gen.UListWidget as UW
+import Gen.UListVNNotes as GVN
+import Gen.UListDel as GDE
+import UList.LabelEdit as LE
+import UList.VoteEdit as VE
+import UList.DateEdit as DE
+import UList.ReleaseEdit as RE
+
+
+main : Program UW.Recv Model Msg
+main = Browser.element
+ { init = \f -> (init f, Date.today |> Task.perform Today)
+ , subscriptions = \m -> if not m.open then Sub.none else Sub.batch <|
+ [ DD.onClickOutside "ulist-widget-box" (Open False)
+ , Sub.map Label (DD.sub m.labels.dd)
+ , Sub.map Vote (DD.sub m.vote.dd)
+ ] ++ List.map (\r -> Sub.map (Rel r.rid) (DD.sub r.dd)) m.rels
+ , view = view
+ , update = update
+ }
+
+type alias Model =
+ { uid : String
+ , vid : String
+ , loadState : Api.State
+ , today : Date.Date
+ , title : Maybe String -- Nothing is used here to indicate that we haven't loaded the full data yet.
+ , open : Bool
+ , onlist : Bool
+ , del : Bool
+ , labels : LE.Model
+ , vote : VE.Model
+ , canvote : Bool
+ , canreview : Bool
+ , review : Maybe String
+ , notes : String
+ , notesRev : Int
+ , notesSaved : String
+ , notesState : Api.State
+ , notesVis : Bool -- For UList.VNPage
+ , started : DE.Model
+ , finished : DE.Model
+ , rels : List RE.Model
+ , relNfo : Dict String GApi.ApiReleases
+ , relOptions : List (String, String)
+ }
+
+init : UW.Recv -> Model
+init f =
+ { uid = f.uid
+ , vid = f.vid
+ , loadState = Api.Normal
+ , today = Date.fromOrdinalDate 2100 1
+ , title = Maybe.map (\full -> full.title) f.full
+ , open = False
+ , onlist = f.labels /= Nothing
+ , del = False
+ -- TODO: LabelEdit and VoteEdit create an internal vid-based ID, so this widget can't be used on VN pages or UList listings. Need to fix that.
+ , labels = LE.init
+ { uid = f.uid
+ , vid = f.vid
+ , selected = List.map (\l -> l.id) (Maybe.withDefault [] f.labels)
+ , labels = Maybe.withDefault
+ (List.map (\l -> {id = l.id, label = l.label, private = True}) (Maybe.withDefault [] f.labels))
+ (Maybe.map (\full -> full.labels) f.full)
+ }
+ , vote = VE.init { uid = f.uid, vid = f.vid, vote = Maybe.andThen (\full -> full.vote) f.full }
+ , canvote = Maybe.map (\full -> full.canvote ) f.full |> Maybe.withDefault False
+ , canreview = Maybe.map (\full -> full.canreview ) f.full |> Maybe.withDefault False
+ , review = Maybe.andThen (\full -> full.review) f.full
+ , notes = Maybe.map (\full -> full.notes ) f.full |> Maybe.withDefault ""
+ , notesRev = 0
+ , notesSaved = Maybe.map (\full -> full.notes ) f.full |> Maybe.withDefault ""
+ , notesState = Api.Normal
+ , notesVis = Maybe.map (\full -> full.notes /= "") f.full == Just True
+ , started = let m = DE.init { uid = f.uid, vid = f.vid, date = Maybe.map (\full -> full.started ) f.full |> Maybe.withDefault "", start = True } in { m | visible = True }
+ , finished = let m = DE.init { uid = f.uid, vid = f.vid, date = Maybe.map (\full -> full.finished) f.full |> Maybe.withDefault "", start = False } in { m | visible = True }
+ , rels = List.map (\st -> RE.init ("widget-" ++ f.vid) { uid = f.uid, rid = st.id, status = Just st.status, empty = "" }) <| Maybe.withDefault [] <| Maybe.map (\full -> full.rlist) f.full
+ , relNfo = Dict.fromList <| List.map (\r -> (r.id, r)) <| Maybe.withDefault [] <| Maybe.map (\full -> full.releases) f.full
+ , relOptions = Maybe.withDefault [] <| Maybe.map (\full -> List.map (\r -> (r.id, RDate.showrel r)) full.releases) f.full
+ }
+
+reset : Model -> Model
+reset m = init
+ { uid = m.uid
+ , vid = m.vid
+ , labels = Nothing
+ , full = Maybe.map (\t ->
+ { title = t
+ , labels = m.labels.labels
+ , canvote = m.canvote
+ , canreview = m.canreview
+ , vote = Nothing
+ , review = m.review
+ , notes = ""
+ , started = ""
+ , finished = ""
+ , releases = Dict.values m.relNfo
+ , rlist = []
+ }) m.title
+ }
+
+
+type Msg
+ = Noop
+ | Today Date.Date
+ | Open Bool
+ | Loaded GApi.Response
+ | Label LE.Msg
+ | Vote VE.Msg
+ | Notes String
+ | NotesSave Int
+ | NotesSaved Int GApi.Response
+ | NotesToggle
+ | Started DE.Msg
+ | Finished DE.Msg
+ | Del Bool
+ | Delete
+ | Deleted GApi.Response
+ | Rel String RE.Msg
+ | RelAdd String
+
+
+setOnList : Model -> Model
+setOnList model =
+ { model | onlist = model.onlist
+ || model.vote.ovote /= Nothing
+ || not (Set.isEmpty model.labels.sel)
+ || model.notes /= ""
+ || model.started.val /= ""
+ || model.finished.val /= ""
+ || not (List.isEmpty model.rels)
+ }
+
+
+isPublic : Model -> Bool
+isPublic model =
+ LE.isPublic model.labels
+ || (isJust model.vote.vote && List.any (\l -> l.id == 7 && not l.private) model.labels.labels)
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Noop -> (model, Cmd.none)
+ Today d -> ({ model | today = d }, Cmd.none)
+ Open b ->
+ if b && model.title == Nothing
+ then ({ model | open = b, loadState = Api.Loading }, UW.send { uid = model.uid, vid = model.vid } Loaded)
+ else ({ model | open = b }, Cmd.none)
+
+ Loaded (GApi.UListWidget w) -> let m = init w in ({ m | open = True }, Cmd.none)
+ Loaded e -> ({ model | loadState = Api.Error e }, Cmd.none)
+
+ Label m -> let (nm, nc) = LE.update m model.labels in (setOnList { model | labels = nm }, Cmd.map Label nc)
+ Vote m -> let (nm, nc) = VE.update m model.vote in (setOnList { model | vote = nm }, Cmd.map Vote nc)
+ Started m -> let (nm, nc) = DE.update m model.started in (setOnList { model | started = nm }, Cmd.map Started nc)
+ Finished m -> let (nm, nc) = DE.update m model.finished in (setOnList { model | finished = nm }, Cmd.map Finished nc)
+
+ Notes s ->
+ ( { model | notes = s, notesRev = model.notesRev + 1 }
+ , Task.perform (\_ -> NotesSave (model.notesRev+1)) <| Process.sleep 1000)
+ NotesSave rev ->
+ if rev /= model.notesRev || model.notes == model.notesSaved
+ then (model, Cmd.none)
+ else ( { model | notesState = Api.Loading }
+ , GVN.send { uid = model.uid, vid = model.vid, notes = model.notes } (NotesSaved rev))
+ NotesSaved rev GApi.Success ->
+ if model.notesRev /= rev
+ then (model, Cmd.none)
+ else (setOnList {model | notesSaved = model.notes, notesState = Api.Normal }, Cmd.none)
+ NotesSaved _ e -> ({ model | notesState = Api.Error e }, Cmd.none)
+ NotesToggle ->
+ ( { model | notesVis = not model.notesVis }
+ , if model.notesVis then Cmd.none else Task.attempt (always Noop) (focus "widget-notes"))
+
+ Del b -> ({ model | del = b }, Cmd.none)
+ Delete -> ({ model | loadState = Api.Loading }, GDE.send { uid = model.uid, vid = model.vid } Deleted)
+ Deleted GApi.Success -> (reset model, Cmd.none)
+ Deleted e -> ({ model | loadState = Api.Error e }, Cmd.none)
+
+ Rel rid m ->
+ case List.filterMap (\r -> if r.rid == rid then Just (RE.update m r) else Nothing) model.rels |> List.head of
+ Nothing -> (model, Cmd.none)
+ Just (rm, rc) ->
+ let
+ nr = if rm.state == Api.Normal && rm.status == Nothing
+ then List.filter (\r -> r.rid /= rid) model.rels
+ else List.map (\r -> if r.rid == rid then rm else r) model.rels
+ in ({ model | rels = nr }, Cmd.map (Rel rid) rc)
+ RelAdd rid ->
+ ( setOnList { model | rels = model.rels ++ (if rid == "" then [] else [RE.init model.vid { rid = rid, uid = model.uid, status = Just 2, empty = "" }]) }
+ , Task.perform (always <| Rel rid <| RE.Set (Just 2) True) <| Task.succeed True)
+
+
+viewStatus : Model -> List (Html Msg)
+viewStatus model =
+ case (model.loadState, model.del, model.onlist) of
+ (Api.Loading, _, _) -> [ span [ class "spinner" ] [] ]
+ (Api.Error e, _, _) -> [ b [] [ text <| Api.showResponse e ] ]
+ (_, _, False) -> [ small [] [ text "not on your list" ] ]
+ (_, True, _) ->
+ [ a [ onClickD Delete ] [ text "Yes, delete" ]
+ , text " | "
+ , a [ onClickD (Del False) ] [ text "Cancel" ]
+ ]
+ (_, False, True) ->
+ [ span [ classList [("hidden", not (isPublic model))], title "This visual novel is on your public list" ] [ text "👁 " ]
+ , text "On your list | "
+ , a [ onClickD (Del True) ] [ text "Remove from list" ]
+ ]
+
+viewReviewLink : Model -> Html Msg
+viewReviewLink model =
+ case (model.vote.vote /= Nothing && model.canreview, model.review) of
+ (False, _) -> text ""
+ (True, Nothing) -> a [ href ("/" ++ model.vid ++ "/addreview") ] [ text " write a review »" ]
+ (True, Just w) -> a [ href ("/" ++ w ++ "/edit") ] [ text " edit review »" ]
+
+
+
+view : Model -> Html Msg
+view model =
+ let
+ icon () =
+ let fn = if not model.onlist then -1
+ else List.range 1 6
+ |> List.filter (\n -> Set.member n model.labels.tsel)
+ |> List.maximum
+ |> Maybe.withDefault 0
+ lbl = if not model.onlist then "Add to list"
+ else String.join ", " <| List.filterMap (\l -> if Set.member l.id model.labels.tsel && l.id /= 7 then Just l.label else Nothing) model.labels.labels
+ in span [ onClickN (Open True), class "ulist-widget-icon" ] [ ulistIcon fn lbl ]
+
+ rel r =
+ case Dict.get r.rid model.relNfo of
+ Nothing -> text ""
+ Just nfo -> relnfo r nfo
+
+ relnfo r nfo =
+ tr []
+ [ td [ class "tco1" ] [ Html.map (Rel r.rid) (RE.view r) ]
+ , td [ class "tco2" ] [ RDate.display model.today nfo.released ]
+ , td [ class "tco3" ]
+ <| List.map platformIcon nfo.platforms
+ ++ List.map langIcon nfo.lang
+ ++ [ releaseTypeIcon nfo.rtype ]
+ , td [ class "tco4" ] [ a [ href ("/"++nfo.id), title nfo.alttitle ] [ text nfo.title ] ]
+ ]
+
+ box () =
+ [ h2 [] [ text (Maybe.withDefault "" model.title) ]
+ , div [ style "text-align" "right", style "margin" "3px 0" ] (viewStatus model)
+ , table [] <|
+ [ tr [] [ td [] [ text "Labels" ], td [] [ Html.map Label (LE.view model.labels "- select label -") ] ]
+ , if not model.canvote then text "" else
+ tr []
+ [ td [] [ text "Vote" ]
+ , td []
+ [ div [ style "width" "80px", style "display" "inline-block" ] [ Html.map Vote (VE.view model.vote "- vote -") ]
+ , viewReviewLink model ]
+ ]
+ , tr [] [ td [] [ text "Start date" ], td [ class "date" ] [ Html.map Started (DE.view model.started ) ] ]
+ , tr [] [ td [] [ text "Finish date" ], td [ class "date" ] [ Html.map Finished (DE.view model.finished) ] ]
+ , tr []
+ [ td [] [ text "Notes ", span [ class "spinner", classList [("hidden", model.notesState /= Api.Loading)] ] [] ]
+ , td [] <|
+ [ textarea ([ rows 2, cols 40, onInput Notes, onBlur (NotesSave model.notesRev)] ++ GVN.valNotes) [ text model.notes ]
+ ] ++ case model.notesState of
+ Api.Error e -> [ br [] [], b [] [ text <| Api.showResponse e ] ]
+ _ -> []
+ ]
+ ]
+ , if List.isEmpty model.relOptions then text "" else h2 [] [ text "Releases" ]
+ , table [] <|
+ (if List.isEmpty model.relOptions then text "" else tfoot [] [ tr []
+ [ td [] []
+ , td [ colspan 3 ]
+ [ inputSelect "" "" RelAdd [] <| ("", "-- add release --") :: List.filter (\(rid,_) -> not <| List.any (\r -> r.rid == rid) model.rels) model.relOptions ]
+ ] ]
+ ) :: List.map rel model.rels
+ ]
+ in
+ if model.open
+ then div [ class "ulist-widget elm_dd_input" ]
+ [ div [ id "ulist-widget-box" ] <|
+ case model.loadState of
+ Api.Loading -> [ div [ class "spinner" ] [] ]
+ Api.Error e -> [ b [] [ text <| Api.showResponse e ] ]
+ Api.Normal -> box () ]
+ else icon ()
diff --git a/elm/UList/labelfilters.js b/elm/UList/labelfilters.js
deleted file mode 100644
index dfec97c6..00000000
--- a/elm/UList/labelfilters.js
+++ /dev/null
@@ -1,17 +0,0 @@
-var p = document.querySelectorAll('.labelfilters')[0];
-if(p) {
- var multi = document.getElementById('form_l_multi');
- multi.parentNode.classList.remove('hidden');
- var l = document.querySelectorAll('.labelfilters input[name=l]');
- l.forEach(function(el) {
- el.addEventListener('click', function() {
- if(multi.checked)
- return true;
- l.forEach(function(el2) { el2.checked = el2 == el });
- var n=el;
- while(n && n.nodeName.toLowerCase() != 'form')
- n=n.parentNode;
- n.submit();
- });
- });
-}
diff --git a/elm/User/Edit.elm b/elm/User/Edit.elm
deleted file mode 100644
index ddd9f78e..00000000
--- a/elm/User/Edit.elm
+++ /dev/null
@@ -1,217 +0,0 @@
-module User.Edit exposing (main)
-
-import Bitwise exposing (..)
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Html exposing (..)
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.Types as GT
-import Gen.UserEdit as GUE
-
-
-main : Program GUE.Send Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-
-type alias Model =
- { state : Api.State
- , data : GUE.Send
- , cpass : Bool
- , pass1 : String
- , pass2 : String
- , opass : String
- , passNeq : Bool
- , mailConfirm : Bool
- }
-
-
-init : GUE.Send -> Model
-init d =
- { state = Api.Normal
- , data = d
- , cpass = False
- , pass1 = ""
- , pass2 = ""
- , opass = ""
- , passNeq = False
- , mailConfirm = False
- }
-
-
-type Data
- = Username String
- | EMail String
- | Perm Int Bool
- | IgnVotes Bool
- | ShowNsfw Bool
- | TraitsSexual Bool
- | Spoilers Int
- | TagsAll Bool
- | TagsCont Bool
- | TagsEro Bool
- | TagsTech Bool
- | Skin String
- | Css String
- | NoAds Bool
- | NoFancy Bool
- | Support Bool
- | PubSkin Bool
- | Uniname String
-
-
-updateData : Data -> GUE.Send -> GUE.Send
-updateData msg model =
- case msg of
- Username n -> { model | username = n }
- EMail n -> { model | email = n }
- Perm n b -> { model | perm = if b then or model.perm n else and model.perm (complement n) }
- IgnVotes n -> { model | ign_votes = n }
- ShowNsfw b -> { model | show_nsfw = b }
- TraitsSexual b -> { model | traits_sexual = b }
- Spoilers n -> { model | spoilers = n }
- TagsAll b -> { model | tags_all = b }
- TagsCont b -> { model | tags_cont = b }
- TagsEro b -> { model | tags_ero = b }
- TagsTech b -> { model | tags_tech = b }
- Skin n -> { model | skin = n }
- Css n -> { model | customcss = n }
- NoAds b -> { model | nodistract_noads = b }
- NoFancy b -> { model | nodistract_nofancy = b }
- Support b -> { model | support_enabled = b }
- PubSkin b -> { model | pubskin_enabled = b }
- Uniname n -> { model | uniname = n }
-
-
-type Msg
- = Set Data
- | CPass Bool
- | OPass String
- | Pass1 String
- | Pass2 String
- | Submit
- | Submitted GApi.Response
-
-
--- Synchronizes model.data.password with model.stuff
-fixup : Model -> Model
-fixup model =
- let
- data = model.data
- ndata = { data | password = if model.cpass && model.pass1 == model.pass2 then Just { old = model.opass, new = model.pass1 } else Nothing }
- in { model | data = ndata }
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Set d -> ({ model | data = updateData d model.data }, Cmd.none)
- CPass b -> (fixup { model | cpass = b, passNeq = False }, Cmd.none)
- OPass n -> (fixup { model | opass = n, passNeq = False }, Cmd.none)
- Pass1 n -> (fixup { model | pass1 = n, passNeq = False }, Cmd.none)
- Pass2 n -> (fixup { model | pass2 = n, passNeq = False }, Cmd.none)
-
- Submit ->
- if model.cpass && model.pass1 /= model.pass2
- then ({ model | passNeq = True }, Cmd.none )
- else ({ model | state = Api.Loading }, GUE.send model.data Submitted)
-
- -- TODO: This reload is only necessary for the skin and customcss options to apply, but it's nicer to do that directly from JS.
- Submitted GApi.Success -> (model, load <| "/u" ++ String.fromInt model.data.id ++ "/edit")
- Submitted GApi.MailChange -> ({ model | mailConfirm = True, state = Api.Normal }, Cmd.none)
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-
-view : Model -> Html Msg
-view model =
- let
- data = model.data
-
- modform =
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Admin options" ] ]
- , formField "username::Username" [ inputText "username" data.username (Set << Username) GUE.valUsername ]
- , formField "Permissions"
- <| List.intersperse (br_ 1)
- <| List.map (\(n,s) -> label [] [ inputCheck "" (and data.perm n > 0) (Set << Perm n), text (" " ++ s) ])
- GT.userPerms
- , formField "Other" [ label [] [ inputCheck "" data.ign_votes (Set << IgnVotes), text " Ignore votes in VN statistics" ] ]
- ]
-
- passform =
- [ formField "opass::Old password" [ inputPassword "opass" model.opass OPass GUE.valPasswordOld ]
- , formField "pass1::New password" [ inputPassword "pass1" model.pass1 Pass1 GUE.valPasswordNew ]
- , formField "pass2::Repeat"
- [ inputPassword "pass2" model.pass2 Pass2 GUE.valPasswordNew
- , br_ 1
- , if model.passNeq
- then b [ class "standout" ] [ text "Passwords do not match" ]
- else text ""
- ]
- ]
-
- supportform =
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Supporter options⭐" ] ]
- , if not data.nodistract_can && not data.authmod then text ""
- else formField "" [ label [] [ inputCheck "" data.nodistract_noads (Set << NoAds), text " Disable advertising and other distractions (only hides the support icons for the moment)" ] ]
- , if not data.nodistract_can && not data.authmod then text ""
- else formField "" [ label [] [ inputCheck "" data.nodistract_nofancy (Set << NoFancy), text " Disable supporters badges, custom display names and profile skins" ] ]
- , if not data.support_can && not data.authmod then text ""
- else formField "" [ label [] [ inputCheck "" data.support_enabled (Set << Support), text " Display my supporters badge" ] ]
- , if not data.pubskin_can && not data.authmod then text ""
- else formField "" [ label [] [ inputCheck "" data.pubskin_enabled (Set << PubSkin), text " Apply my skin and custom CSS when others visit my profile" ] ]
- , if not data.uniname_can && not data.authmod then text ""
- else formField "uniname::Display name" [ inputText "uniname" (if data.uniname == "" then data.username else data.uniname) (Set << Uniname) GUE.valUniname ]
- ]
-
- in form_ Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text <| if data.authmod then "Edit " ++ data.username else "My preferences" ]
- , table [ class "formtable" ] <|
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "General" ] ]
- , formField "Username" [ text data.username ]
- , formField "email::E-Mail" [ inputText "email" data.email (Set << EMail) GUE.valEmail ]
- ]
- ++ (if data.authmod then modform else [])
- ++ (if data.authmod || data.nodistract_can || data.support_can || data.uniname_can || data.pubskin_can then supportform else []) ++
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Password" ] ]
- , formField "" [ label [] [ inputCheck "" model.cpass CPass, text " Change password" ] ]
- ] ++ (if model.cpass then passform else [])
- ++
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Preferences" ] ]
- , formField "NSFW" [ label [] [ inputCheck "" data.show_nsfw (Set << ShowNsfw), text " Show NSFW images by default" ] ]
- , formField "" [ label [] [ inputCheck "" data.traits_sexual (Set << TraitsSexual), text " Show sexual traits by default on character pages" ] ]
- , formField "Tags" [ label [] [ inputCheck "" data.tags_all (Set << TagsAll), text " Show all tags by default on visual novel pages (don't summarize)" ] ]
- , formField ""
- [ text "Default tag categories on visual novel pages:", br_ 1
- , label [] [ inputCheck "" data.tags_cont (Set << TagsCont), text " Content" ], br_ 1
- , label [] [ inputCheck "" data.tags_ero (Set << TagsEro ), text " Sexual content" ], br_ 1
- , label [] [ inputCheck "" data.tags_tech (Set << TagsTech), text " Technical" ]
- ]
- , formField "spoil::Spoiler level"
- [ inputSelect "spoil" data.spoilers (Set << Spoilers) []
- [ (0, "Hide spoilers")
- , (1, "Show only minor spoilers")
- , (2, "Show all spoilers")
- ]
- ]
- , formField "skin::Skin" [ inputSelect "skin" data.skin (Set << Skin) [ style "width" "300px" ] GT.skins ]
- , formField "css::Custom CSS" [ inputTextArea "css" data.customcss (Set << Css) ([ rows 5, cols 60 ] ++ GUE.valCustomcss) ]
- ]
-
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state (not model.passNeq) ]
- , if not model.mailConfirm then text "" else
- div [ class "notice" ]
- [ text "A confirmation email has been sent to your new address. Your address will be updated after following the instructions in that mail." ]
- ]
- ]
diff --git a/elm/User/Login.elm b/elm/User/Login.elm
deleted file mode 100644
index 0b560cbc..00000000
--- a/elm/User/Login.elm
+++ /dev/null
@@ -1,136 +0,0 @@
-module User.Login exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserLogin as GUL
-import Gen.UserChangePass as GUCP
-import Gen.Types exposing (adminEMail)
-import Lib.Html exposing (..)
-
-
-main : Program String Model Msg
-main = Browser.element
- { init = \ref -> (init ref, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { ref : String
- , username : String
- , password : String
- , newpass1 : String
- , newpass2 : String
- , state : Api.State
- , insecure : Bool
- , noteq : Bool
- }
-
-
-init : String -> Model
-init ref =
- { ref = ref
- , username = ""
- , password = ""
- , newpass1 = ""
- , newpass2 = ""
- , state = Api.Normal
- , insecure = False
- , noteq = False
- }
-
-
-type Msg
- = Username String
- | Password String
- | Newpass1 String
- | Newpass2 String
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Username n -> ({ model | username = String.toLower n }, Cmd.none)
- Password n -> ({ model | password = n }, Cmd.none)
- Newpass1 n -> ({ model | newpass1 = n, noteq = False }, Cmd.none)
- Newpass2 n -> ({ model | newpass2 = n, noteq = False }, Cmd.none)
-
- Submit ->
- if not model.insecure
- then ( { model | state = Api.Loading }
- , GUL.send { username = model.username, password = model.password } Submitted )
- else if model.newpass1 /= model.newpass2
- then ( { model | noteq = True }, Cmd.none )
- else ( { model | state = Api.Loading }
- , GUCP.send { username = model.username, oldpass = model.password, newpass = model.newpass1 } Submitted )
-
- Submitted GApi.Success -> (model, load model.ref)
- Submitted GApi.InsecurePass -> ({ model | insecure = True, state = if model.insecure then Api.Error GApi.InsecurePass else Api.Normal }, Cmd.none)
- Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- let
- loginBox =
- div [ class "mainbox" ]
- [ h1 [] [ text "Login" ]
- , table [ class "formtable" ]
- [ formField "username::Username"
- [ inputText "username" model.username Username GUL.valUsername
- , br_ 1
- , a [ href "/u/register" ] [ text "No account yet?" ]
- ]
- , formField "password::Password"
- [ inputPassword "password" model.password Password GUL.valPassword
- , br_ 1
- , a [ href "/u/newpass" ] [ text "Forgot your password?" ]
- ]
- ]
- , if model.state == Api.Normal || model.state == Api.Loading
- then text ""
- else div [ class "notice" ]
- [ h2 [] [ text "Trouble logging in?" ]
- , text "If you have not used this login form since October 2014, your account has likely been disabled. You can "
- , a [ href "/u/newpass" ] [ text "reset your password" ]
- , text " to regain access."
- , br_ 2
- , text "Still having trouble? Send a mail to "
- , a [ href <| "mailto:" ++ adminEMail ] [ text adminEMail ]
- , text ". But keep in mind that I can only help you if the email address associated with your account is correct"
- , text " and you still have access to it. Without that, there is no way to prove that the account is yours."
- ]
- ]
-
- changeBox =
- div [ class "mainbox" ]
- [ h1 [] [ text "Change your password" ]
- , div [ class "warning" ]
- [ h2 [] [ text "Your current password is not secure" ]
- , text "Your current password is in a public database of leaked passwords. You need to change it before you can continue."
- ]
- , table [ class "formtable" ]
- [ formField "newpass1::New password" [ inputPassword "newpass1" model.newpass1 Newpass1 GUCP.valNewpass ]
- , formField "newpass2::Repeat"
- [ inputPassword "newpass2" model.newpass2 Newpass2 GUCP.valNewpass
- , br_ 1
- , if model.noteq then b [ class "standout" ] [ text "Passwords do not match" ] else text ""
- ]
- ]
- ]
-
- in form_ Submit (model.state == Api.Loading)
- [ if model.insecure then changeBox else loginBox
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ]
- ]
- ]
diff --git a/elm/User/PassReset.elm b/elm/User/PassReset.elm
deleted file mode 100644
index 641767d4..00000000
--- a/elm/User/PassReset.elm
+++ /dev/null
@@ -1,77 +0,0 @@
-module User.PassReset exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserPassReset as GUPR
-import Lib.Html exposing (..)
-
-
-main : Program () Model Msg
-main = Browser.element
- { init = always (init, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { email : String
- , state : Api.State
- , success : Bool
- }
-
-
-init : Model
-init =
- { email = ""
- , state = Api.Normal
- , success = False
- }
-
-
-type Msg
- = EMail String
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- EMail n -> ({ model | email = n }, Cmd.none)
- Submit -> ({ model | state = Api.Loading }, GUPR.send { email = model.email } Submitted)
- Submitted GApi.Success -> ({ model | success = True }, Cmd.none)
- Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- if model.success
- then
- div [ class "mainbox" ]
- [ h1 [] [ text "New password" ]
- , div [ class "notice" ]
- [ 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)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "Forgot Password" ]
- , p []
- [ text "Forgot your password and can't login to VNDB anymore? "
- , text "Don't worry! Just give us the email address you used to register on VNDB "
- , text " and we'll send you instructions to set a new password within a few minutes!"
- ]
- , table [ class "formtable" ]
- [ formField "email::E-Mail" [ inputText "email" model.email EMail GUPR.valEmail ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ]
- ]
- ]
diff --git a/elm/User/PassSet.elm b/elm/User/PassSet.elm
deleted file mode 100644
index 618b4ba1..00000000
--- a/elm/User/PassSet.elm
+++ /dev/null
@@ -1,85 +0,0 @@
-module User.PassSet exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserPassSet as GUPS
-import Lib.Html exposing (..)
-
-
-main : Program GUPS.Recv Model Msg
-main = Browser.element
- { init = \f -> (init f, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { token : String
- , uid : Int
- , newpass1 : String
- , newpass2 : String
- , state : Api.State
- , noteq : Bool
- }
-
-
-init : GUPS.Recv -> Model
-init f =
- { token = f.token
- , uid = f.uid
- , newpass1 = ""
- , newpass2 = ""
- , state = Api.Normal
- , noteq = False
- }
-
-
-type Msg
- = Newpass1 String
- | Newpass2 String
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Newpass1 n -> ({ model | newpass1 = n, noteq = False }, Cmd.none)
- Newpass2 n -> ({ model | newpass2 = n, noteq = False }, Cmd.none)
-
- Submit ->
- if model.newpass1 /= model.newpass2
- then ( { model | noteq = True }, Cmd.none)
- else ( { model | state = Api.Loading }
- , GUPS.send { token = model.token, uid = model.uid, password = model.newpass1 } Submitted )
-
- Submitted GApi.Success -> (model, load "/")
- Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- 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." ]
- , table [ class "formtable" ]
- [ formField "newpass1::New password" [ inputPassword "newpass1" model.newpass1 Newpass1 GUPS.valPassword ]
- , formField "newpass2::Repeat"
- [ inputPassword "newpass2" model.newpass2 Newpass2 GUPS.valPassword
- , br_ 1
- , if model.noteq then b [ class "standout" ] [ text "Passwords do not match" ] else text ""
- ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ]
- ]
- ]
diff --git a/elm/User/Register.elm b/elm/User/Register.elm
deleted file mode 100644
index 9afdded4..00000000
--- a/elm/User/Register.elm
+++ /dev/null
@@ -1,97 +0,0 @@
-module User.Register exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserRegister as GUR
-import Lib.Html exposing (..)
-
-
-main : Program () Model Msg
-main = Browser.element
- { init = always (init, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { username : String
- , email : String
- , vns : Int
- , state : Api.State
- , success : Bool
- }
-
-
-init : Model
-init =
- { username = ""
- , email = ""
- , vns = 0
- , state = Api.Normal
- , success = False
- }
-
-
-type Msg
- = Username String
- | EMail String
- | VNs String
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Username n -> ({ model | username = String.toLower n }, Cmd.none)
- EMail n -> ({ model | email = n }, Cmd.none)
- VNs n -> ({ model | vns = Maybe.withDefault model.vns (String.toInt n) }, Cmd.none)
-
- Submit -> ( { model | state = Api.Loading }
- , GUR.send { username = model.username, email = model.email, vns = model.vns } Submitted )
-
- Submitted GApi.Success -> ({ model | success = True }, Cmd.none)
- Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- if model.success
- then
- div [ class "mainbox" ]
- [ h1 [] [ text "Account created" ]
- , div [ class "notice" ]
- [ 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)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "Create an account" ]
- , table [ class "formtable" ]
- [ formField "username::Username"
- [ inputText "username" model.username Username GUR.valUsername
- , br_ 1
- , text "Preferred username. Must be lowercase, between 2 and 15 characters long and consist entirely of alphanumeric characters or a dash."
- , text " Names that look like database identifiers (i.e. a single letter followed by several numbers) are also disallowed."
- ]
- , formField "email::E-Mail"
- [ inputText "email" model.email EMail GUR.valEmail
- , br_ 1
- , text "Your email address will only be used in case you lose your password. "
- , text "We will never send spam or newsletters unless you explicitly ask us for it or we get hacked."
- , br_ 3
- , text "Anti-bot question: How many visual novels do we have in the database? (Hint: look to your left)"
- ]
- , formField "vns::Answer" [ inputText "vns" (if model.vns == 0 then "" else String.fromInt model.vns) VNs [] ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ]
- ]
- ]
diff --git a/elm/VNEdit.elm b/elm/VNEdit.elm
new file mode 100644
index 00000000..751cab61
--- /dev/null
+++ b/elm/VNEdit.elm
@@ -0,0 +1,788 @@
+port module VNEdit exposing (main)
+
+import Html exposing (..)
+import Html.Events exposing (..)
+import Html.Keyed as K
+import Html.Attributes exposing (..)
+import Browser
+import Browser.Navigation exposing (load)
+import Browser.Dom as Dom
+import Dict
+import Set
+import Task
+import Date
+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
+import Lib.Autocomplete as A
+import Lib.RDate as RDate
+import Lib.Api as Api
+import Lib.Editsum as Editsum
+import Lib.Image as Img
+import Gen.VN as GV
+import Gen.VNEdit as GVE
+import Gen.Types as GT
+import Gen.Api as GApi
+
+
+main : Program GVE.Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Date.today |> Task.perform Today)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+port ivRefresh : Bool -> Cmd msg
+
+type Tab
+ = General
+ | Image
+ | Staff
+ | Cast
+ | Screenshots
+ | All
+
+type alias Model =
+ { state : Api.State
+ , tab : Tab
+ , today : Int
+ , invalidDis : Bool
+ , editsum : Editsum.Model
+ , titles : List GVE.RecvTitles
+ , alias : String
+ , description : TP.Model
+ , devStatus : Int
+ , olang : String
+ , length : Int
+ , lWikidata : Maybe Int
+ , lRenai : String
+ , vns : List GVE.RecvRelations
+ , vnSearch : A.Model GApi.ApiVNResult
+ , anime : List GVE.RecvAnime
+ , animeSearch : A.Model GApi.ApiAnimeResult
+ , image : Img.Image
+ , editions : List GVE.RecvEditions
+ , staff : List GVE.RecvStaff
+ -- Search boxes matching the list of editions (n+1), first entry is for the NULL edition.
+ , staffSearch : List (A.Config Msg GApi.ApiStaffResult, A.Model GApi.ApiStaffResult)
+ , seiyuu : List GVE.RecvSeiyuu
+ , seiyuuSearch: A.Model GApi.ApiStaffResult
+ , seiyuuDef : String -- character id for newly added seiyuu
+ , screenshots : List (Int,Img.Image,Maybe String) -- internal id, img, rel
+ , scrQueue : List File
+ , scrUplRel : Maybe String
+ , scrUplNum : Maybe Int
+ , scrId : Int -- latest used internal id
+ , releases : List GVE.RecvReleases
+ , reltitles : List { id: String, title: String }
+ , chars : List GVE.RecvChars
+ , id : Maybe String
+ , dupCheck : Bool
+ , dupVNs : List GApi.ApiVNResult
+ }
+
+
+init : GVE.Recv -> Model
+init d =
+ { state = Api.Normal
+ , tab = General
+ , today = 0
+ , invalidDis = False
+ , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = False }
+ , titles = d.titles
+ , alias = d.alias
+ , description = TP.bbcode d.description
+ , devStatus = d.devstatus
+ , olang = d.olang
+ , length = d.length
+ , lWikidata = d.l_wikidata
+ , lRenai = d.l_renai
+ , vns = d.relations
+ , vnSearch = A.init ""
+ , anime = d.anime
+ , animeSearch = A.init ""
+ , image = Img.info d.image_info
+ , editions = d.editions
+ , staff = d.staff
+ , staffSearch = (staffConfig Nothing, A.init "") :: List.map (\e -> (staffConfig (Just e.eid), A.init "")) d.editions
+ , seiyuu = d.seiyuu
+ , seiyuuSearch= A.init ""
+ , seiyuuDef = Maybe.withDefault "" <| 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
+ , releases = d.releases
+ , reltitles = d.reltitles
+ , chars = d.chars
+ , id = d.id
+ , dupCheck = False
+ , dupVNs = []
+ }
+
+
+encode : Model -> GVE.Send
+encode model =
+ { id = model.id
+ , editsum = model.editsum.editsum.data
+ , hidden = model.editsum.hidden
+ , locked = model.editsum.locked
+ , titles = model.titles
+ , alias = model.alias
+ , devstatus = model.devStatus
+ , description = model.description.data
+ , olang = model.olang
+ , length = model.length
+ , l_wikidata = model.lWikidata
+ , l_renai = model.lRenai
+ , relations = List.map (\v -> { vid = v.vid, relation = v.relation, official = v.official }) model.vns
+ , anime = List.map (\a -> { aid = a.aid }) model.anime
+ , image = model.image.id
+ , editions = model.editions
+ , staff = List.map (\s -> { aid = s.aid, eid = s.eid, note = s.note, role = s.role }) model.staff
+ , seiyuu = List.map (\s -> { aid = s.aid, cid = s.cid, note = s.note }) model.seiyuu
+ , screenshots = List.map (\(_,i,r) -> { scr = Maybe.withDefault "" i.id, rid = r }) model.screenshots
+ }
+
+vnConfig : A.Config Msg GApi.ApiVNResult
+vnConfig = { wrap = VNSearch, id = "relationadd", source = A.vnSource }
+
+animeConfig : A.Config Msg GApi.ApiAnimeResult
+animeConfig = { wrap = AnimeSearch, id = "animeadd", source = A.animeSource False }
+
+staffConfig : Maybe Int -> A.Config Msg GApi.ApiStaffResult
+staffConfig eid =
+ { wrap = (StaffSearch eid)
+ , id = "staffadd-" ++ Maybe.withDefault "" (Maybe.map String.fromInt eid)
+ , source = A.staffSource
+ }
+
+seiyuuConfig : A.Config Msg GApi.ApiStaffResult
+seiyuuConfig = { wrap = SeiyuuSearch, id = "seiyuuadd", source = A.staffSource }
+
+type Msg
+ = Noop
+ | Today Date.Date
+ | Editsum Editsum.Msg
+ | Tab Tab
+ | Invalid Tab
+ | InvalidEnable
+ | Submit
+ | Submitted GApi.Response
+ | Alias String
+ | Desc TP.Msg
+ | DevStatus Int
+ | Length Int
+ | LWikidata (Maybe Int)
+ | LRenai String
+ | TitleAdd String
+ | TitleDel Int
+ | TitleLang Int String
+ | TitleTitle Int String
+ | TitleLatin Int String
+ | TitleOfficial Int Bool
+ | TitleMain Int String
+ | VNDel Int
+ | VNRel Int String
+ | VNOfficial Int Bool
+ | VNSearch (A.Msg GApi.ApiVNResult)
+ | AnimeDel Int
+ | AnimeSearch (A.Msg GApi.ApiAnimeResult)
+ | ImageSet String Bool
+ | ImageSelect
+ | ImageSelected File
+ | ImageMsg Img.Msg
+ | EditionAdd
+ | EditionLang Int (Maybe String)
+ | EditionName Int String
+ | EditionOfficial Int Bool
+ | EditionDel Int Int
+ | StaffDel Int
+ | StaffRole Int String
+ | StaffNote Int String
+ | StaffSearch (Maybe Int) (A.Msg GApi.ApiStaffResult)
+ | SeiyuuDef String
+ | SeiyuuDel Int
+ | SeiyuuChar Int String
+ | SeiyuuNote Int String
+ | SeiyuuSearch (A.Msg GApi.ApiStaffResult)
+ | ScrUplRel (Maybe String)
+ | ScrUplSel
+ | ScrUpl File (List File)
+ | ScrMsg Int Img.Msg
+ | ScrRel Int (Maybe String)
+ | ScrDel Int
+ | DupSubmit
+ | 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
+ Noop -> (model, Cmd.none)
+ Today d -> ({ model | today = RDate.fromDate d |> RDate.compact }, Cmd.none)
+ 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)
+ Alias s -> ({ model | alias = s, dupVNs = [] }, Cmd.none)
+ Desc m -> let (nm,nc) = TP.update m model.description in ({ model | description = nm }, Cmd.map Desc nc)
+ DevStatus b-> ({ model | devStatus = b }, Cmd.none)
+ Length n -> ({ model | length = n }, Cmd.none)
+ LWikidata n-> ({ model | lWikidata = n }, Cmd.none)
+ LRenai s -> ({ model | lRenai = s }, Cmd.none)
+
+ TitleAdd s ->
+ ({ model | titles = model.titles ++ [{ lang = s, title = "", latin = Nothing, official = True }], olang = if List.isEmpty model.titles then s else model.olang }
+ , Task.attempt (always Noop) (Dom.focus ("title_" ++ s)))
+ TitleDel i -> ({ model | titles = delidx i model.titles }, Cmd.none)
+ TitleLang i s -> ({ model | titles = modidx i (\e -> { e | lang = s }) model.titles }, Cmd.none)
+ TitleTitle i s -> ({ model | titles = modidx i (\e -> { e | title = s }) model.titles }, Cmd.none)
+ TitleLatin i s -> ({ model | titles = modidx i (\e -> { e | latin = if s == "" then Nothing else Just s }) model.titles }, Cmd.none)
+ TitleOfficial i s -> ({ model | titles = modidx i (\e -> { e | official = s }) model.titles }, Cmd.none)
+ TitleMain i s -> ({ model | olang = s, titles = modidx i (\e -> { e | official = True }) model.titles }, Cmd.none)
+
+ VNDel idx -> ({ model | vns = delidx idx model.vns }, Cmd.none)
+ VNRel idx rel -> ({ model | vns = modidx idx (\v -> { v | relation = rel }) model.vns }, Cmd.none)
+ VNOfficial idx o -> ({ model | vns = modidx idx (\v -> { v | official = o }) model.vns }, Cmd.none)
+ VNSearch m ->
+ let (nm, c, res) = A.update vnConfig m model.vnSearch
+ in case res of
+ Nothing -> ({ model | vnSearch = nm }, c)
+ Just v ->
+ if List.any (\l -> l.vid == v.id) model.vns
+ then ({ model | vnSearch = A.clear nm "" }, c)
+ else ({ model | vnSearch = A.clear nm "", vns = model.vns ++ [{ vid = v.id, title = v.title, relation = "seq", official = True }] }, c)
+
+ AnimeDel i -> ({ model | anime = delidx i model.anime }, Cmd.none)
+ AnimeSearch m ->
+ let (nm, c, res) = A.update animeConfig m model.animeSearch
+ in case res of
+ Nothing -> ({ model | animeSearch = nm }, c)
+ Just a ->
+ if List.any (\l -> l.aid == a.id) model.anime
+ then ({ model | animeSearch = A.clear nm "" }, c)
+ else ({ model | animeSearch = A.clear nm "", anime = model.anime ++ [{ aid = a.id, title = a.title, original = a.original }] }, c)
+
+ ImageSet s b -> let (nm, nc) = Img.new b s in ({ model | image = nm }, Cmd.map ImageMsg nc)
+ ImageSelect -> (model, FSel.file ["image/png", "image/jpeg", "image/webp", "image/avif", "image/jxl"] ImageSelected)
+ ImageSelected f -> let (nm, nc) = Img.upload Api.Cv f in ({ model | image = nm }, Cmd.map ImageMsg nc)
+ ImageMsg m -> let (nm, nc) = Img.update m model.image in ({ model | image = nm }, Cmd.map ImageMsg nc)
+
+ EditionAdd ->
+ let f n acc =
+ case acc of
+ Just x -> Just x
+ Nothing -> if not (List.isEmpty (List.filter (\i -> i.eid == n) model.editions)) then Nothing else Just n
+ newid = List.range 0 500 |> List.foldl f Nothing |> Maybe.withDefault 0
+ in ({ model
+ | editions = model.editions ++ [{ eid = newid, lang = Nothing, name = "", official = True }]
+ , staffSearch = model.staffSearch ++ [(staffConfig (Just newid), A.init "")]
+ }, Cmd.none)
+ EditionDel idx eid ->
+ ({ model
+ | editions = delidx idx model.editions
+ , staffSearch = delidx (idx + 1) model.staffSearch
+ , staff = List.filter (\s -> s.eid /= Just eid) model.staff
+ }, Cmd.none)
+ EditionLang idx v -> ({ model | editions = modidx idx (\s -> { s | lang = v }) model.editions }, Cmd.none)
+ EditionName idx v -> ({ model | editions = modidx idx (\s -> { s | name = v }) model.editions }, Cmd.none)
+ EditionOfficial idx v -> ({ model | editions = modidx idx (\s -> { s | official = v }) model.editions }, Cmd.none)
+
+ StaffDel idx -> ({ model | staff = delidx idx model.staff }, Cmd.none)
+ StaffRole idx v -> ({ model | staff = modidx idx (\s -> { s | role = v }) model.staff }, Cmd.none)
+ StaffNote idx v -> ({ model | staff = modidx idx (\s -> { s | note = v }) model.staff }, Cmd.none)
+ StaffSearch eid m ->
+ let idx = List.indexedMap Tuple.pair model.editions
+ |> List.filterMap (\(n,e) -> if Just e.eid == eid then Just (n+1) else Nothing)
+ |> List.head |> Maybe.withDefault 0
+ in case List.drop idx model.staffSearch |> List.head of
+ Nothing -> (model, Cmd.none)
+ Just (sconfig, smodel) ->
+ let (nm, c, res) = A.update sconfig m smodel
+ nnm = if res == Nothing then nm else A.clear nm ""
+ nsearch = modidx idx (\(oc,om) -> (oc,nnm)) model.staffSearch
+ nstaff s = [{ id = s.id, aid = s.aid, eid = eid, title = s.title, alttitle = s.alttitle, role = "staff", note = "" }]
+ in case res of
+ Nothing -> ({ model | staffSearch = nsearch }, c)
+ Just s -> ({ model | staffSearch = nsearch, staff = model.staff ++ nstaff s }, c)
+
+ SeiyuuDef c -> ({ model | seiyuuDef = c }, Cmd.none)
+ SeiyuuDel idx -> ({ model | seiyuu = delidx idx model.seiyuu }, Cmd.none)
+ SeiyuuChar idx v -> ({ model | seiyuu = modidx idx (\s -> { s | cid = v }) model.seiyuu }, Cmd.none)
+ SeiyuuNote idx v -> ({ model | seiyuu = modidx idx (\s -> { s | note = v }) model.seiyuu }, Cmd.none)
+ SeiyuuSearch m ->
+ let (nm, c, res) = A.update seiyuuConfig m model.seiyuuSearch
+ in case res of
+ Nothing -> ({ model | seiyuuSearch = nm }, c)
+ Just s -> ({ model | seiyuuSearch = A.clear nm "", seiyuu = model.seiyuu ++ [{ id = s.id, aid = s.aid, title = s.title, alttitle = s.alttitle, cid = model.seiyuuDef, note = "" }] }, c)
+
+ ScrUplRel s -> ({ model | scrUplRel = s }, Cmd.none)
+ ScrUplSel -> (model, FSel.files ["image/png", "image/jpeg", "image/webp", "image/avif", "image/jxl"] ScrUpl)
+ ScrUpl f1 fl ->
+ if 1 + List.length fl > 10 - List.length model.screenshots
+ then ({ model | scrUplNum = Just (1 + List.length fl) }, Cmd.none)
+ 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 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)
+
+ DupSubmit ->
+ if List.isEmpty model.dupVNs
+ then ({ model | state = Api.Loading }, GV.send { hidden = True, search = (List.concatMap (\e -> [e.title, Maybe.withDefault "" e.latin]) model.titles) ++ String.lines model.alias } DupResults)
+ else ({ model | dupCheck = True, dupVNs = [] }, Cmd.none)
+ DupResults (GApi.VNResult vns) ->
+ if List.isEmpty vns
+ then ({ model | state = Api.Normal, dupCheck = True, dupVNs = [] }, Cmd.none)
+ else ({ model | state = Api.Normal, dupVNs = vns }, Cmd.none)
+ DupResults r -> ({ model | state = Api.Error r }, Cmd.none)
+
+ Submit -> ({ model | state = Api.Loading }, GVE.send (encode model) Submitted)
+ Submitted (GApi.Redirect s) -> (model, load s)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+-- TODO: Fuzzier matching? Exclude stuff like 'x Edition', etc.
+relAlias : Model -> Maybe { id: String, title: String }
+relAlias model =
+ let a = String.toLower model.alias |> String.lines |> List.filter (\l -> l /= "") |> Set.fromList
+ in List.filter (\r -> Set.member (String.toLower r.title) a) model.reltitles |> List.head
+
+
+isValid : Model -> Bool
+isValid model = not
+ ( List.any (\e -> e.title /= "" && Just e.title == e.latin) model.titles
+ || List.isEmpty model.titles
+ || 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 (\e -> (Maybe.withDefault "" e.lang, e.name)) model.editions)
+ || hasDuplicates (List.map (\s -> (s.aid, Maybe.withDefault -1 s.eid, s.role)) model.staff)
+ || hasDuplicates (List.map (\s -> (s.aid, s.cid)) model.seiyuu)
+ )
+
+
+view : Model -> Html Msg
+view model =
+ let
+ title i e = tr []
+ [ td [] [ langIcon e.lang ]
+ , td []
+ [ inputText ("title_"++e.lang) e.title (TitleTitle i) (style "width" "500px" :: onInvalid (Invalid General) :: placeholder "Title (in the original script)" :: GVE.valTitlesTitle)
+ , if not (e.latin /= Nothing || containsNonLatin e.title) then text "" else span []
+ [ br [] []
+ , inputText "" (Maybe.withDefault "" e.latin) (TitleLatin i) (style "width" "500px" :: required True :: onInvalid (Invalid General) :: placeholder "Romanization" :: GVE.valTitlesLatin)
+ , case e.latin of
+ Just s -> if containsNonLatin s then b [] [ br [] [], text "Romanization should only consist of characters in the latin alphabet." ] else text ""
+ Nothing -> text ""
+ ]
+ , if List.length model.titles == 1 then text "" else span []
+ [ br [] []
+ , label [] [ inputRadio "olang" (e.lang == model.olang) (\_ -> TitleMain i e.lang), text " main title (the language the VN was originally written in)" ]
+ ]
+ , if e.lang == model.olang then text "" else span []
+ [ br [] []
+ , label [] [ inputCheck "" e.official (TitleOfficial i), text " official title (from the developer or licensed localization; not from a fan translation)" ]
+ , br [] []
+ , inputButton "remove" (TitleDel i) []
+ ]
+ , br_ 2
+ ]
+ ]
+
+ titles =
+ let lines = List.filter (\e -> e /= "") <| String.lines <| String.toLower model.alias
+ in
+ [ formField "Title(s)"
+ [ table [] <| List.indexedMap title model.titles
+ , inputSelect "" "" TitleAdd [] <| ("", "- Add title -") :: List.filter (\(l,_) -> not (List.any (\e -> e.lang == l) model.titles)) scriptLangs
+ , br_ 2
+ ]
+ , formField "alias::Aliases"
+ [ inputTextArea "alias" model.alias Alias (rows 3 :: onInvalid (Invalid General) :: GVE.valAlias)
+ , br [] []
+ , if hasDuplicates lines
+ then b [] [ text "List contains duplicate aliases.", br [] [] ]
+ else if contains lines <| List.map String.toLower <| List.concatMap (\e -> [e.title, Maybe.withDefault "" e.latin]) model.titles
+ then b [] [ text "Titles listed above should not also be added as alias.", br [] [] ]
+ else
+ case relAlias model of
+ Nothing -> text ""
+ Just r -> span []
+ [ b [] [ text "Release titles should not be added as alias." ]
+ , br [] []
+ , text "Release: "
+ , a [ href <| "/"++r.id ] [ text r.title ]
+ , br [] [], br [] []
+ ]
+ , text "List of additional titles or abbreviations. One line for each alias. Can include both official (japanese/english) titles and unofficial titles used around net."
+ , br [] []
+ , text "Titles that are listed in the releases should not be added here!"
+ ]
+ ]
+
+ geninfo = titles ++
+ [ formField "desc::Description"
+ [ TP.view "desc" model.description Desc 600 (style "height" "180px" :: onInvalid (Invalid General) :: GVE.valDescription) [ b [] [ 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 "devstatus::Development status"
+ [ inputSelect "devstatus" model.devStatus DevStatus [] GT.devStatus
+ , if model.devStatus == 0
+ && not (List.isEmpty model.releases)
+ && List.isEmpty (List.filter (\r -> r.rtype == "complete" && r.released <= model.today) model.releases)
+ then span []
+ [ br [] []
+ , b [] [ text "Development is marked as finished, but there is no complete release in the database." ]
+ , br [] []
+ , text "Please adjust the development status or ensure there is a completed release."
+ ]
+ else text ""
+ , if model.devStatus /= 0
+ && not (List.isEmpty (List.filter (\r -> r.rtype == "complete" && r.released <= model.today) model.releases))
+ then span []
+ [ br [] []
+ , b [] [ text "Development is not marked as finished, but there is a complete release in the database." ]
+ , br [] []
+ , text "Please adjust the development status or set the release to partial or TBA."
+ ]
+ else text ""
+ ]
+ , formField "length::Length"
+ [ inputSelect "length" model.length Length [] GT.vnLengths
+ , text " (only displayed if there are no length votes)" ]
+ , 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"
+ [ if List.isEmpty model.vns then text ""
+ else table [] <| List.indexedMap (\i v -> tr []
+ [ td [ style "text-align" "right" ] [ small [] [ text <| v.vid ++ ":" ] ]
+ , td [ style "text-align" "right"] [ a [ href <| "/" ++ v.vid ] [ text v.title ] ]
+ , td []
+ [ text "is an "
+ , label [] [ inputCheck "" v.official (VNOfficial i), text " official" ]
+ , inputSelect "" v.relation (VNRel i) [] GT.vnRelations
+ , text " of this VN"
+ ]
+ , td [] [ inputButton "remove" (VNDel i) [] ]
+ ]
+ ) model.vns
+ , A.view vnConfig model.vnSearch [placeholder "Add visual novel..."]
+ ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [] ]
+ , formField "Related anime"
+ [ if List.isEmpty model.anime then text ""
+ else table [] <| List.indexedMap (\i e -> tr []
+ [ td [ style "text-align" "right" ] [ small [] [ text <| "a" ++ String.fromInt e.aid ++ ":" ] ]
+ , td [] [ a [ href <| "https://anidb.net/anime/" ++ String.fromInt e.aid ] [ text e.title ] ]
+ , td [] [ inputButton "remove" (AnimeDel i) [] ]
+ ]
+ ) model.anime
+ , A.view animeConfig model.animeSearch [placeholder "Add anime..."]
+ ]
+ ]
+
+ image =
+ table [ class "formimage" ] [ tr []
+ [ 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, 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
+ , h2 [] [ text "Upload new image" ]
+ , inputButton "Browse image" ImageSelect []
+ , br [] []
+ , text "Preferably the cover of the CD/DVD/package."
+ , br [] []
+ , text "Supported file types: JPEG, PNG, WebP, AVIF or JXL, at most 10 MiB."
+ , br [] []
+ , text "Images larger than 256x400 are automatically resized."
+ , 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)"
+ , v
+ ]
+ ]
+ ] ]
+
+ staff =
+ let
+ head lst =
+ if List.isEmpty lst then text "" else
+ thead [] [ tr []
+ [ td [] []
+ , td [] [ text "Staff" ]
+ , td [] [ text "Role" ]
+ , td [] [ text "Note" ]
+ , td [] []
+ ] ]
+ foot searchn lst (sconfig, smodel) =
+ tfoot [] [ tr [] [ td [] [], td [ colspan 4 ]
+ [ text ""
+ , if hasDuplicates (List.map (\(_,s) -> (s.aid, s.role)) lst)
+ then b [] [ text "List contains duplicate staff roles.", br [] [] ]
+ else text ""
+ , A.view sconfig smodel [placeholder "Add staff..."]
+ , if searchn > 0 then text "" else span []
+ [ text "Can't find the person you're looking for? You can "
+ , a [ href "/s/new" ] [ text "create a new entry" ]
+ , text ", but "
+ , a [ href "/s/all" ] [ text "please check for aliasses first." ]
+ , br [] []
+ , text "If one person performed several roles, you can add multiple entries with different major roles."
+ ]
+ ] ] ]
+ item (n,s) = tr []
+ [ td [ style "text-align" "right" ] [ small [] [ text <| s.id ++ ":" ] ]
+ , td [] [ a [ href <| "/" ++ s.id ] [ text s.title ], text <| if s.alttitle == s.title then "" else " " ++ s.alttitle ]
+ , td [] [ inputSelect "" s.role (StaffRole n) [style "width" "150px" ] GT.creditTypes ]
+ , td [] [ inputText "" s.note (StaffNote n) (style "width" "300px" :: onInvalid (Invalid Staff) :: GVE.valStaffNote) ]
+ , td [] [ inputButton "remove" (StaffDel n) [] ]
+ ]
+ edition searchn edi =
+ let eid = Maybe.map (\e -> e.eid) edi
+ lst = List.indexedMap Tuple.pair model.staff |> List.filter (\(_,s) -> s.eid == eid)
+ sch = List.drop searchn model.staffSearch |> List.head
+ in div [style "margin" "0 0 30px 0"]
+ [ Maybe.withDefault (if List.isEmpty model.editions then text "" else h2 [] [ text "Original edition" ])
+ <| Maybe.map (\e -> h2 [] [ text (if e.name == "" then "New edition" else e.name) ]) edi
+ , case edi of
+ Nothing -> text ""
+ Just e ->
+ div [style "margin" "5px 0 0 15px"]
+ [ inputText "" e.name (EditionName (searchn-1)) (placeholder "Edition title" :: style "width" "300px" :: onInvalid (Invalid Staff) :: GVE.valEditionsName)
+ , inputSelect "" e.lang (EditionLang (searchn-1)) [style "width" "150px"]
+ ((Nothing, "Original language") :: List.map (\(i,l) -> (Just i, l)) scriptLangs)
+ , text " ", label [] [ inputCheck "" e.official (EditionOfficial (searchn-1)), text " official" ]
+ , inputButton "remove edition" (EditionDel (searchn-1) e.eid) [style "margin-left" "30px"]
+ ]
+ , table [style "margin" "5px 0 0 15px"]
+ <| head lst
+ :: Maybe.withDefault (text "") (Maybe.map (foot searchn lst) sch)
+ :: List.map item lst
+ ]
+ in edition 0 Nothing
+ :: List.indexedMap (\n e -> edition (n+1) (Just e)) model.editions
+ ++ [ br [] [], inputButton "Add edition" EditionAdd [] ]
+
+
+
+ cast =
+ let
+ chars = List.map (\c -> (c.id, c.title ++ " (" ++ c.id ++ ")")) model.chars
+ head =
+ if List.isEmpty model.seiyuu then [] else [
+ thead [] [ tr []
+ [ td [] [ text "Character" ]
+ , td [] [ text "Cast" ]
+ , td [] [ text "Note" ]
+ , td [] []
+ ] ] ]
+ foot =
+ tfoot [] [ tr [] [ td [ colspan 4 ]
+ [ br [] []
+ , strong [] [ text "Add cast" ]
+ , br [] []
+ , if hasDuplicates (List.map (\s -> (s.aid, s.cid)) model.seiyuu)
+ then b [] [ text "List contains duplicate cast roles.", br [] [] ]
+ else text ""
+ , inputSelect "" model.seiyuuDef SeiyuuDef [] chars
+ , text " voiced by "
+ , div [ style "display" "inline-block" ] [ A.view seiyuuConfig model.seiyuuSearch [] ]
+ , br [] []
+ , text "Can't find the person you're looking for? You can "
+ , a [ href "/s/new" ] [ text "create a new entry" ]
+ , text ", but "
+ , a [ href "/s/all" ] [ text "please check for aliasses first." ]
+ ] ] ]
+ item n s = tr []
+ [ td [] [ inputSelect "" s.cid (SeiyuuChar n) []
+ <| chars ++ if List.any (\c -> c.id == s.cid) model.chars then [] else [(s.cid, "[deleted/moved character: " ++ s.cid ++ "]")] ]
+ , td []
+ [ small [] [ text <| s.id ++ ":" ]
+ , a [ href <| "/" ++ s.id ] [ text s.title ], text <| if s.title == s.alttitle then "" else " " ++ s.alttitle ]
+ , td [] [ inputText "" s.note (SeiyuuNote n) (style "width" "300px" :: onInvalid (Invalid Cast) :: GVE.valSeiyuuNote) ]
+ , td [] [ inputButton "remove" (SeiyuuDel n) [] ]
+ ]
+ in
+ if model.id == Nothing
+ then text <| "Voice actors can be added to this visual novel once it has character entries associated with it. "
+ ++ "To do so, first create this entry without cast, then create the appropriate character entries, and finally come back to this form by editing the visual novel."
+ else if List.isEmpty model.chars && List.isEmpty model.seiyuu
+ then p []
+ [ text "This visual novel does not have any characters associated with it (yet). Please "
+ , a [ href <| "/" ++ Maybe.withDefault "" model.id ++ "/addchar" ] [ text "add the appropriate character entries" ]
+ , text " first and then come back to this form to assign voice actors."
+ ]
+ else table [] <| head ++ [ foot ] ++ List.indexedMap item model.seiyuu
+
+ screenshots =
+ let
+ rellist = List.map (\r -> (Just r.id, RDate.showrel r)) model.releases
+ scr n (id, i, rel) = (String.fromInt id, tr [] <|
+ let getdim img = Maybe.map (\nfo -> (nfo.width, nfo.height)) img |> Maybe.withDefault (0,0)
+ imgdim = getdim i.img
+ relnfo = List.filter (\r -> Just r.id == rel) model.releases |> List.head
+ reldim = relnfo |> Maybe.andThen (\r -> if r.reso_x == 0 then Nothing else Just (r.reso_x, r.reso_y))
+ dimstr (x,y) = String.fromInt x ++ "x" ++ String.fromInt y
+ in
+ [ td [] [ Img.viewImg i ]
+ , td [] [ Img.viewVote i (ScrMsg id) (Invalid Screenshots) |> Maybe.withDefault (text "") ]
+ , td []
+ [ strong [] [ text <| "Screenshot #" ++ String.fromInt (n+1) ]
+ , text " (", a [ href "#", onClickD (ScrDel id) ] [ text "remove" ], text ")"
+ , br [] []
+ , text <| "Image resolution: " ++ dimstr imgdim
+ , br [] []
+ , text <| Maybe.withDefault "" <| Maybe.map (\dim -> "Release resolution: " ++ dimstr dim) reldim
+ , span [] <|
+ if reldim == Just imgdim then [ text " ✔", br [] [] ]
+ else if reldim /= Nothing
+ then [ text " ❌"
+ , br [] []
+ , b [] [ text "WARNING: Resolutions do not match, please take screenshots with the correct resolution and make sure to crop them correctly!" ]
+ ]
+ else if i.img /= Nothing && rel /= Nothing && List.any (\(_,si,sr) -> sr == rel && si.img /= Nothing && imgdim /= getdim si.img) model.screenshots
+ then [ b [] [ text "WARNING: Inconsistent image resolutions for the same release, please take screenshots with the correct resolution and make sure to crop them correctly!" ]
+ , br [] []
+ ]
+ else [ br [] [] ]
+ , br [] []
+ , inputSelect "" rel (ScrRel id) [style "width" "500px"] <| rellist ++
+ case (relnfo, rel) of
+ (_, Nothing) -> [(Nothing, "[No release selected]")]
+ (Nothing, Just r) -> [(Just r, "[Deleted or unlinked release: " ++ r ++ "]")]
+ _ -> []
+ ]
+ ])
+
+ add =
+ let free = 10 - List.length model.screenshots
+ in
+ if not (List.isEmpty model.scrQueue)
+ then [ strong [] [ text "Uploading screenshots" ]
+ , br [] []
+ , text <| (String.fromInt (List.length model.scrQueue)) ++ " remaining... "
+ , span [ class "spinner" ] []
+ ]
+ else if free <= 0
+ then [ strong [] [ 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."
+ ]
+ else
+ [ strong [] [ text "Add screenshots" ]
+ , br [] []
+ , text <| String.fromInt free ++ " more screenshot" ++ (if free == 1 then "" else "s") ++ " can be added."
+ , br [] []
+ , inputSelect "" model.scrUplRel ScrUplRel [style "width" "500px"] ((Nothing, "-- select release --") :: rellist)
+ , br [] []
+ , if model.scrUplRel == Nothing then text "" else span []
+ [ inputButton "Select images" ScrUplSel []
+ , case model.scrUplNum of
+ Just num -> text " Too many images selected."
+ Nothing -> text ""
+ , br [] []
+ ]
+ , br [] []
+ , strong [] [ text "Important reminder" ]
+ , ul []
+ [ li [] [ text "Screenshots must be in the native resolution of the game" ]
+ , li [] [ text "Screenshots must not include window borders and should not have copyright markings" ]
+ , li [] [ text "Don't only upload event CGs" ]
+ ]
+ , text "Read the ", a [ href "/d2#6" ] [ text "full guidelines" ], text " for more information."
+ ]
+ in
+ if model.id == Nothing
+ then text <| "Screenshots can be uploaded to this visual novel once it has a release entry associated with it. "
+ ++ "To do so, first create this entry without screenshots, then create the appropriate release entries, and finally come back to this form by editing the visual novel."
+ else if List.isEmpty model.screenshots && List.isEmpty model.releases
+ then p []
+ [ text "This visual novel does not have any releases associated with it (yet). Please "
+ , a [ href <| "/" ++ Maybe.withDefault "" model.id ++ "/add" ] [ text "add the appropriate release entries" ]
+ , text " first and then come back to this form to upload screenshots."
+ ]
+ else
+ table [ class "vnedit_scr" ]
+ [ tfoot [] [ tr [] [ td [] [], td [ colspan 2 ] add ] ]
+ , K.node "tbody" [] <| List.indexedMap scr model.screenshots
+ ]
+
+ newform () =
+ form_ "" DupSubmit (model.state == Api.Loading)
+ [ article [] [ h1 [] [ text "Add a new visual novel" ], table [ class "formtable" ] titles ]
+ , if List.isEmpty model.dupVNs then text "" else
+ article []
+ [ div []
+ [ h1 [] [ text "Possible duplicates" ]
+ , text "The following is a list of visual novels that match the title(s) you gave. "
+ , text "Please check this list to avoid creating a duplicate visual novel entry. "
+ , text "Be especially wary of items that have been deleted! To see why an entry has been deleted, click on its title."
+ , ul [] <| List.map (\v -> li []
+ [ a [ href <| "/" ++ v.id ] [ text v.title ]
+ , if v.hidden then b [] [ text " (deleted)" ] else text ""
+ ]
+ ) model.dupVNs
+ ]
+ ]
+ , article [ class "submit" ] [ submitButton (if List.isEmpty model.dupVNs then "Continue" else "Continue anyway") model.state (isValid model) ]
+ ]
+
+ fullform () =
+ form_ "mainform" Submit (model.state == Api.Loading)
+ [ nav []
+ [ menu []
+ [ li [ classList [("tabselected", model.tab == General )] ] [ a [ href "#", onClickD (Tab General ) ] [ text "General info" ] ]
+ , li [ classList [("tabselected", model.tab == Image )] ] [ a [ href "#", onClickD (Tab Image ) ] [ text "Image" ] ]
+ , li [ classList [("tabselected", model.tab == Staff )] ] [ a [ href "#", onClickD (Tab Staff ) ] [ text "Staff" ] ]
+ , li [ classList [("tabselected", model.tab == Cast )] ] [ a [ href "#", onClickD (Tab Cast ) ] [ text "Cast" ] ]
+ , li [ classList [("tabselected", model.tab == Screenshots)] ] [ a [ href "#", onClickD (Tab Screenshots) ] [ text "Screenshots" ] ]
+ , li [ classList [("tabselected", model.tab == All )] ] [ a [ href "#", onClickD (Tab All ) ] [ text "All items" ] ]
+ ]
+ ]
+ , article [ classList [("hidden", model.tab /= General && model.tab /= All)] ] [ h1 [] [ text "General info" ], table [ class "formtable" ] geninfo ]
+ , article [ classList [("hidden", model.tab /= Image && model.tab /= All)] ] [ h1 [] [ text "Image" ], image ]
+ , article [ classList [("hidden", model.tab /= Staff && model.tab /= All)] ] ( h1 [] [ text "Staff" ] :: staff )
+ , article [ classList [("hidden", model.tab /= Cast && model.tab /= All)] ] [ h1 [] [ text "Cast" ], cast ]
+ , article [ classList [("hidden", model.tab /= Screenshots && model.tab /= All)] ] [ h1 [] [ text "Screenshots" ], screenshots ]
+ , article [ class "submit" ]
+ [ Html.map Editsum (Editsum.view model.editsum)
+ , submitButton "Submit" model.state (isValid model)
+ ]
+ ]
+ in if model.id == Nothing && not model.dupCheck then newform () else fullform ()
diff --git a/elm/VNLengthVote.elm b/elm/VNLengthVote.elm
new file mode 100644
index 00000000..ceafe05a
--- /dev/null
+++ b/elm/VNLengthVote.elm
@@ -0,0 +1,216 @@
+module VNLengthVote exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Browser.Dom exposing (focus)
+import Task
+import Date
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Lib.Api as Api
+import Lib.RDate as RDate
+import Gen.Api as GApi
+import Gen.VNLengthVote as GV
+import Gen.Release as GR
+
+
+main : Program GV.Send Model Msg
+main = Browser.element
+ { init = \e -> (init e, Date.today |> Task.perform Today)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+type alias Model =
+ { state : Api.State
+ , open : Bool
+ , today : Int
+ , uid : String
+ , vid : String
+ , rid : List String
+ , maycount: Bool
+ , defrid : String
+ , hours : Maybe Int
+ , minutes : Maybe Int
+ , speed : Maybe Int
+ , length : Int -- last saved length
+ , notes : String
+ , rels : Maybe (List (String, String))
+ }
+
+init : GV.Send -> Model
+init f =
+ { state = Api.Normal
+ , today = 0
+ , open = False
+ , uid = f.uid
+ , vid = f.vid
+ , rid = Maybe.map (\v -> v.rid) f.vote |> Maybe.withDefault []
+ , maycount= f.maycount
+ , defrid = ""
+ , hours = Maybe.map (\v -> v.length // 60 ) f.vote
+ , minutes = Maybe.andThen (\v -> let n = modBy 60 v.length in if n == 0 then Nothing else Just n) f.vote
+ , speed = Maybe.map (\v -> if v.private then Just 8 else v.speed) f.vote |> Maybe.withDefault (Just 9)
+ , length = Maybe.map (\v -> v.length) f.vote |> Maybe.withDefault 0
+ , notes = Maybe.map (\v -> v.notes) f.vote |> Maybe.withDefault ""
+ , rels = Nothing
+ }
+
+enclen : Model -> Int
+enclen m = (Maybe.withDefault 0 m.hours) * 60 + Maybe.withDefault 0 m.minutes
+
+encode : Model -> GV.Send
+encode m =
+ { uid = m.uid
+ , vid = m.vid
+ , maycount = m.maycount
+ , vote = if enclen m == 0 then Nothing else Just
+ { rid = m.rid
+ , notes = m.notes
+ , speed = if m.speed == Just 8 then Nothing else m.speed
+ , length = enclen m
+ , private = m.speed == Just 8
+ }
+ }
+
+type Msg
+ = Noop
+ | Open Bool
+ | Today Date.Date
+ | Hours (Maybe Int)
+ | Minutes (Maybe Int)
+ | Speed (Maybe Int)
+ | Release Int String
+ | ReleaseAdd
+ | ReleaseDel Int
+ | Notes String
+ | RelLoaded GApi.Response
+ | Delete
+ | Submit
+ | Submitted GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Noop -> (model, Cmd.none)
+ Open b ->
+ if b && model.rels == Nothing
+ then ({ model | open = b, state = Api.Loading }, GR.send { vid = model.vid } RelLoaded)
+ else ({ model | open = b }, Cmd.none)
+ Today d -> ({ model | today = RDate.fromDate d |> RDate.compact }, Cmd.none)
+ Hours n -> ({ model | hours = n }, Cmd.none)
+ Minutes n -> ({ model | minutes = n }, Cmd.none)
+ Speed n -> ({ model | speed = n }, Cmd.none)
+ Release n s -> ({ model | rid = modidx n (always s) model.rid }, Cmd.none)
+ ReleaseAdd -> ({ model | rid = model.rid ++ [""] }, Cmd.none)
+ ReleaseDel n -> ({ model | rid = delidx n model.rid }, Cmd.none)
+ Notes s -> ({ model | notes = s }, Cmd.none)
+ RelLoaded (GApi.Releases rels) ->
+ let rel r = if r.rtype /= "trial" && r.released <= model.today then Just (r.id, RDate.showrel r) else Nothing
+ frels = List.filterMap rel rels
+ def = case frels of
+ [(r,_)] -> r
+ _ -> ""
+ in ({ model | state = Api.Normal
+ , rels = Just frels
+ , defrid = def
+ , rid = if not (List.isEmpty model.rid) then model.rid else [def]
+ }, if model.hours == Nothing then Task.attempt (always Noop) (focus "vnlengthhours") else Cmd.none)
+ RelLoaded e -> ({ model | state = Api.Error e }, Cmd.none)
+ Delete -> let m = { model | hours = Nothing, minutes = Nothing, rid = [model.defrid], notes = "", state = Api.Loading } in (m, GV.send (encode m) Submitted)
+ Submit -> ({ model | state = Api.Loading }, GV.send (encode model) Submitted)
+ Submitted (GApi.Success) -> ({ model | open = False, state = Api.Normal, length = enclen model }, Cmd.none)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model = div [class "lengthvotefrm"] <|
+ let
+ selcounted =
+ [ (Just 9, "-- how do you estimate your read/play speed? --")
+ , (Just 0, "Slow (e.g. low language proficiency or extra time spent on gameplay)")
+ , (Just 1, "Normal (no content skipped, all voices listened to end)")
+ , (Just 2, "Fast (e.g. fast reader or skipping through voices and gameplay)")
+ , (Nothing, "Don't count my play time (public)")
+ , (Just 8, "Don't count my play time (private)")
+ ]
+ seluncounted =
+ [ (Just 9, "-- visibility --")
+ , (Nothing, "Public (everyone can see your vote)")
+ , (Just 8, "Private (for your own administration)")
+ ]
+ cansubmit = enclen model > 0 && model.speed /= Just 9
+ && not (List.isEmpty model.rid)
+ && not (List.any (\r -> r == "") model.rid)
+ rels = Maybe.withDefault [] model.rels
+ frm = [ form_ "" (if cansubmit then Submit else Noop) False
+ [ br [] []
+ , if model.maycount then text "" else span []
+ [ b [] [ text "This visual novel is still in development." ]
+ , br [] []
+ , text "Which means your vote will not count towards the VN's length statistics."
+ , br_ 2
+ ]
+ , text "How long did you take to finish this VN?"
+ , br [] []
+ , text "Play time: "
+ , inputNumber "vnlengthhours" model.hours Hours [ Html.Attributes.min "0", Html.Attributes.max "435" ]
+ , text " hours "
+ , inputNumber "" model.minutes Minutes [ Html.Attributes.min "0", Html.Attributes.max "59" ]
+ , text " minutes"
+ , br [] []
+ , if model.defrid /= "" then text "" else table [] <| List.indexedMap (\n rid -> tr []
+ [ td [] [
+ inputSelect "" rid (Release n) []
+ <| ("", "-- select release --") :: rels
+ ++ if rid == "" || List.any (\(r,_) -> r == rid) rels then [] else [(rid, "[deleted/moved release: " ++ rid ++ "]")]
+ ]
+ , td []
+ [ if n == 0
+ then inputButton "+" ReleaseAdd [title "Add release"]
+ else inputButton "-" (ReleaseDel n) [title "Remove release"]
+ ]
+ ]) model.rid
+ , inputSelect "" model.speed Speed [] (if model.maycount then selcounted else seluncounted)
+ , case model.speed of
+ Just 9 -> span [] []
+ Just 8 -> span []
+ [ text "Your play time is not counted towards the VN's average and is not visible in the listings."
+ , text " It is only saved for your own administration and counted towards the personal play time displayed on your profile."
+ , br [] []
+ ]
+ Nothing -> span []
+ [ text "Your play time is not counted towards the VN's average, but is still visible in the listings and saved for your own administration."
+ , br [] []
+ ]
+ _ -> span []
+ [ text "- Only vote if you've completed all normal/true endings."
+ , br [] []
+ , text "- Exact measurements preferred, but rough estimates are accepted too."
+ , br [] []
+ ]
+ , inputTextArea "" model.notes Notes
+ [rows 2, cols 30, style "width" "100%", placeholder "(Optional) comments that may be helpful. For example, did you complete all the bad endings, how did you measure? etc." ]
+ , if model.length == 0 then text "" else inputButton "Delete my vote" Delete [style "float" "right"]
+ , if cansubmit then submitButton "Save" model.state True else text ""
+ , inputButton "Cancel" (Open False) []
+ , br_ 2
+ ] ]
+ in
+ [ text " "
+ , a [ onClickD (Open (not model.open)), href "#" ]
+ [ text <| if model.length == 0 then "Vote »"
+ else "My vote: " ++ String.fromInt (model.length // 60) ++ "h"
+ ++ if modBy 60 model.length /= 0 then String.fromInt (modBy 60 model.length) ++ "m" else "" ]
+ ] ++ case (model.open, model.state) of
+ (False, _) -> []
+ (_, Api.Normal) ->
+ if model.length == 0 && List.isEmpty (Maybe.withDefault [] model.rels)
+ then [ br_ 2, b [] [ text "There are no releases eligible for voting." ] ]
+ else frm
+ (_, Api.Error e) -> [ br_ 2, b [] [ text ("Error: " ++ Api.showResponse e) ] ]
+ (_, Api.Loading) -> [ span [ style "float" "right", class "spinner" ] [] ]
diff --git a/elm/checkall.js b/elm/checkall.js
deleted file mode 100644
index bc87bad4..00000000
--- a/elm/checkall.js
+++ /dev/null
@@ -1,16 +0,0 @@
-//order:9 - After Elm initialization
-
-/* "checkall" checkbox, usage:
- *
- * <input type="checkbox" class="checkall" name="$somename">
- *
- * Checking that will synchronize all other checkboxes with name="$somename".
- */
-document.querySelectorAll('input[type=checkbox].checkall').forEach(function(el) {
- el.addEventListener('click', function() {
- document.querySelectorAll('input[type=checkbox][name="'+el.name+'"]').forEach(function(el2) {
- if(el2.checked != el.checked)
- el2.click();
- });
- });
-});
diff --git a/elm/checkhidden.js b/elm/checkhidden.js
deleted file mode 100644
index 486b3c1d..00000000
--- a/elm/checkhidden.js
+++ /dev/null
@@ -1,17 +0,0 @@
-//order:9 - After Elm initialization
-
-/* "checkhidden" checkbox, usage:
- *
- * <input type="checkbox" class="checkhidden" value="$somename">
- *
- * Checking that will toggle the 'hidden' class of all elements with the "$somename" class.
- */
-document.querySelectorAll('input[type=checkbox].checkhidden').forEach(function(el) {
- var f = function() {
- document.querySelectorAll('.'+el.value).forEach(function(el2) {
- el2.classList.toggle('hidden', !el.checked);
- });
- };
- f();
- el.addEventListener('click', f);
-});
diff --git a/elm/elm-init.js b/elm/elm-init.js
deleted file mode 100644
index e48a94f3..00000000
--- a/elm/elm-init.js
+++ /dev/null
@@ -1,34 +0,0 @@
-//order:8 - After all regular JS, as other files may modify pageVars or modules in the Elm.* namespace.
-
-/* Add the X-CSRF-Token header to every POST request. Based on:
- * https://stackoverflow.com/questions/24196140/adding-x-csrf-token-header-globally-to-all-instances-of-xmlhttprequest/24196317#24196317
- */
-(function() {
- var open = XMLHttpRequest.prototype.open,
- token = document.querySelector('meta[name=csrf-token]').content;
-
- XMLHttpRequest.prototype.open = function(method, url) {
- var ret = open.apply(this, arguments);
- this.dataUrl = url;
- if(method.toLowerCase() == 'post' && /^\//.test(url))
- this.setRequestHeader('X-CSRF-Token', token);
- return ret;
- };
-})();
-
-
-/* Load all Elm modules listed in the pageVars.elm array */
-if(pageVars.elm) {
- //var t0 = performance.now();
- for(var i=0; i<pageVars.elm.length; i++) {
- var e = pageVars.elm[i];
- //if(e[0] != 'UList.DateEdit') continue;
- var mod = e[0].split('.').reduce(function(p, c) { return p[c] }, Elm);
- var node = document.getElementById('elm'+i);
- if(e.length > 1)
- mod.init({ node: node, flags: e[1] });
- else
- mod.init({ node: node });
- }
- //console.log("Elm modules initialized in " + (performance.now() - t0) + " milliseconds.");
-}
diff --git a/elm/elm.json b/elm/elm.json
index 3db9993a..6c052936 100644
--- a/elm/elm.json
+++ b/elm/elm.json
@@ -6,7 +6,6 @@
"elm-version": "0.19.1",
"dependencies": {
"direct": {
- "RomanErnst/erl": "2.1.1",
"elm/browser": "1.0.1",
"elm/core": "1.0.2",
"elm/file": "1.0.1",
diff --git a/elm/pagevars.js b/elm/pagevars.js
deleted file mode 100644
index e36ed7da..00000000
--- a/elm/pagevars.js
+++ /dev/null
@@ -1,5 +0,0 @@
-//order:0 - Before anything else that may use window.pageVars
-
-/* Load global page-wide variables from <script id="pagevars">...</script> and store them into window.pageVars */
-var e = document.getElementById('pagevars');
-window.pageVars = e ? JSON.parse(e.innerHTML) : {};
diff --git a/elm/polyfills.js b/elm/polyfills.js
deleted file mode 100644
index 4bb85105..00000000
--- a/elm/polyfills.js
+++ /dev/null
@@ -1,33 +0,0 @@
-//order:0 - Must be loaded before anything else.
-
-/* classList.toggle() */
-(function() {
- var historic = DOMTokenList.prototype.toggle;
- DOMTokenList.prototype.toggle = function(token, force) {
- if(arguments.length > 0 && this.contains(token) === force) {
- return force;
- }
- return historic.call(this, token);
- };
-})();
-
-
-/* Element.matches() and Element.closest() */
-if(!Element.prototype.matches)
- Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
-if(!Element.prototype.closest)
- Element.prototype.closest = function(s) {
- var el = this;
- if(!document.documentElement.contains(el)) return null;
- do {
- if(el.matches(s)) return el;
- el = el.parentElement || el.parentNode;
- } while(el !== null && el.nodeType === 1);
- return null;
- };
-
-
-/* NodeList.forEach */
-if(window.NodeList && !NodeList.prototype.forEach) {
- NodeList.prototype.forEach = Array.prototype.forEach;
-}
diff --git a/elm/spoilset.js b/elm/spoilset.js
deleted file mode 100644
index 69129f6d..00000000
--- a/elm/spoilset.js
+++ /dev/null
@@ -1,27 +0,0 @@
-/* Spoiler hiding, works in tandem with the .spoillvl-x and .spoil-x classes.
- * Usage:
- * <a href="#" class="spoilset-0" data-target="someclass">hide spoilers</a>
- * <div class="someclass spoillvl-0">
- * <span class="spoil-1">minor spoiler</span>
- * </div>
- */
-document.querySelectorAll('.spoilset-0, .spoilset-1, .spoilset-2').forEach(function(a) {
- a.addEventListener('click', function(ev) {
- var lvl = a.classList.contains('spoilset-0') ? 0 : a.classList.contains('spoilset-1') ? 1 : 2;
- var t = document.querySelector('.'+a.getAttribute('data-target'));
- t.classList.toggle('spoillvl-0', lvl == 0);
- t.classList.toggle('spoillvl-1', lvl == 1);
- t.classList.toggle('spoillvl-2', lvl == 2);
-
- // Updating the visual selected status of the links depends on context, the following is for use in 'maintabs' links.
- // XXX: This would be nicer when done in CSS.
- var p = a.closest('div.maintabs');
- if(p) {
- p.querySelector('.spoilset-0').parentNode.classList.toggle('tabselected', lvl == 0);
- p.querySelector('.spoilset-1').parentNode.classList.toggle('tabselected', lvl == 1);
- p.querySelector('.spoilset-2').parentNode.classList.toggle('tabselected', lvl == 2);
- }
- ev.preventDefault();
- return false;
- });
-});
diff --git a/icons/README.md b/icons/README.md
new file mode 100644
index 00000000..ed9ba3bb
--- /dev/null
+++ b/icons/README.md
@@ -0,0 +1,47 @@
+# VNDB Icons
+
+This directory contains SVG and PNG icons that are merged into a single
+*icons.svg* and *icons.png*, respectively, through *util/svgsprite.pl* and
+*util/pngsprite.pl*. These icons can be imported by the front-end with the
+respective *icon-* classes. For example, to reference a platform icon in
+*plat/*, the following HTML will work:
+
+```html
+<abbr class="icon-plat-lin">
+```
+
+Not all the necessary CSS for the icons is auto-generated, some properties
+still need to be set in *css/v2.css*.
+
+This icon sprite approach is just a silly optimization to improve compression
+efficiency and reduce the number of HTTP requests. It works fine for small
+and/or commonly used images to improve page loads, but less common or larger
+images are better thrown in *static/f/* instead.
+
+
+## SVG Icons
+
+*svgsprite.pl* is very picky about the format of SVG icons; they must adhere to
+the following rules:
+
+- Must have a global `viewBox` property that starts at (0,0)
+- The viewbox dimensions must match the pixel dimensions when rendered on the site
+- Must have at most one `<defs>` element
+- Must not have any `<style>` elements
+- Must not have any 'xlink' properties (plain 'href' works fine)
+- The drawing elements don't go too far outside of the global viewbox
+
+Converting existing images to adhere to these rules can be somewhat tricky, my
+general approach is as follows:
+
+- Open image in Inkscape to simplify paths, remove excess drawing elements and
+ convert some shapes into paths when that reduces file size
+- Simplify the SVG through SVGO ([SVGOMG](https://svgomg.net/) is handy)
+- Convert any CSS and styles to plain SVG attributes
+- Move the viewbox to the (0,0) coordinates by adding a top-level
+ `<g transform="translate(-x -y)">` element
+- Adjust the size of the viewbox to the pixel dimensions we want by adding a top-level
+ `<g transform="scale(..)">` element
+- Run the file through SVGO again to propagate the above transforms into the paths
+- Manually remove attributes that don't affect the visual output (may take some
+ trial and error to see which attributes are necessary)
diff --git a/icons/drm/account.svg b/icons/drm/account.svg
new file mode 100644
index 00000000..44cb73ae
--- /dev/null
+++ b/icons/drm/account.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<circle cx="8" cy="8" r="7.41" fill="none" stroke="#e44" stroke-width="1.17"/>
+<path fill="#e44" d="M9.96 4.55c1.16-.45 1.82.76 1.44 1.58a64 64 0 0 1-2.33 2.35c-.36.2-.84.21-1.16.03-.6-.4-1.4.36-.79.84.92.66 2 .5 2.82-.1.15-.14 2.15-2.16 2.24-2.28.8-1.03.53-2.53-.38-3.24-.85-.5-1.58-.6-2.36-.19-.88.63-1.03.88-1.87 1.73.46.02.84.12 1.3.28l1.09-1zm-3.92 6.9c-1.16.45-1.82-.76-1.44-1.58a64 64 0 0 1 2.33-2.35c.36-.2.84-.21 1.16-.03.6.4 1.4-.36.79-.84-.92-.66-2-.5-2.82.1-.15.14-2.15 2.16-2.24 2.28a2.38 2.38 0 0 0 .38 3.24c.85.5 1.58.6 2.36.19.88-.63 1.03-.88 1.87-1.73a4.38 4.38 0 0 1-1.3-.28l-1.09 1z"/>
+</svg>
diff --git a/icons/drm/activate.svg b/icons/drm/activate.svg
new file mode 100644
index 00000000..d2b9f247
--- /dev/null
+++ b/icons/drm/activate.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#e44">
+<path d="M8 .01a8 8 0 1 0 .02 16A8 8 0 0 0 8 0zM8 15A7 7 0 1 1 8.01.99a7 7 0 0 1-.01 14z"/>
+<path d="M8.13 10.78c-.47-.1-.95.38-.85.85.06.3.32.55.62.6.2 0 .42-.04.57-.18.24-.2.35-.54.21-.82a.76.76 0 0 0-.55-.45Zm2.22-1.31a3.22 3.22 0 0 0-3.08-1 3.21 3.21 0 0 0-1.64 1.02.56.56 0 0 0 .01.64c.14.18.36.29.58.26.32-.06.48-.39.75-.53a2.02 2.02 0 0 1 1.83-.12c.2.11.4.2.55.38.17.2.45.35.7.24.3-.1.51-.46.37-.76l-.07-.13Z"/>
+<path d="M11.85 7.7c-.22-.2-.43-.42-.68-.58a6.18 6.18 0 0 0-1.47-.77c-.32-.08-.64-.2-.98-.2-.33-.08-.67-.04-1-.05-.35.01-.7.05-1.03.14A5.13 5.13 0 0 0 4.14 7.7c-.2.23-.16.58.05.78.2.21.55.24.78.04.4-.35.82-.7 1.33-.9.5-.22 1.05-.36 1.6-.35a3.8 3.8 0 0 1 1.61.26c.47.17.92.42 1.3.76.19.17.41.4.7.36.3-.07.56-.36.5-.69 0-.11-.08-.2-.16-.26Z"/>
+<path d="M13.46 6.12c-.2-.35-.58-.56-.88-.82a7.78 7.78 0 0 0-9.9.64c-.34.3-.2.88.23 1 .41.13.7-.3 1-.52a6.56 6.56 0 0 1 8.57.34c.27.32.8.27.99-.11.1-.16.07-.37 0-.53Z"/>
+</g>
+</svg>
diff --git a/icons/drm/alimit.svg b/icons/drm/alimit.svg
new file mode 100644
index 00000000..bdc56ecf
--- /dev/null
+++ b/icons/drm/alimit.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="none" stroke="#e44">
+<path stroke-linejoin="bevel" stroke-width=".87" d="M5.97 7.78q.45.1.7.48.26.37.26.9 0 .84-.48 1.3-.47.44-1.52.44-.35 0-.72-.1-.38-.12-.78-.35v-.4q.32.27.7.4.37.15.76.15.8 0 1.24-.37.42-.36.42-1.06 0-.57-.4-.9-.4-.31-1.1-.31H4.4v-.32h.68q.65 0 1-.26.34-.26.34-.75 0-.59-.35-.9-.36-.32-1.02-.32-.3 0-.66.1-.36.12-.79.35v-.4q.43-.18.8-.26.39-.1.72-.1.86 0 1.27.39.41.39.41 1.06 0 .44-.22.78-.2.33-.61.45zM8.4 5.2h.43l1.57 2.35 1.57-2.35h.44l-1.8 2.67 1.95 2.93h-.44l-1.75-2.64-1.76 2.64h-.43l1.98-2.96z"/>
+<circle cx="8" cy="8" r="7.36" stroke-width="1.19"/>
+</g>
+</svg>
diff --git a/icons/drm/cdkey.svg b/icons/drm/cdkey.svg
new file mode 100644
index 00000000..30789774
--- /dev/null
+++ b/icons/drm/cdkey.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<circle cx="8" cy="8" r="7.41" fill="none" stroke="#e44" stroke-width="1.17"/>
+<circle cx="9.54" cy="6.39" r=".49" fill="#e44"/>
+<path fill="#e44" d="M11.82 5.03a2.66 2.66 0 0 0-4.38-.12A2.3 2.3 0 0 0 7 6.88L3.79 10.1v2.1h3.15v-1.05H8V10.1h1.05V9c2.48.55 3.9-1.86 2.78-3.96zM8.86 8.12l-.62.62v.43l-.12.12h-.93v.93l-.12.12h-.93v.93l-.12.12H4.78l-.12-.12v-.87l3.27-3.27A1.8 1.8 0 0 1 9.6 4.6c.99 0 1.85.8 1.85 1.85-.12 1.67-1.3 2.22-2.6 1.67z"/>
+</svg>
diff --git a/icons/drm/cloud.svg b/icons/drm/cloud.svg
new file mode 100644
index 00000000..ee913d74
--- /dev/null
+++ b/icons/drm/cloud.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<circle cx="8" cy="8" r="7.41" fill="none" stroke="#e44" stroke-width="1.17"/>
+<path fill="#e44" d="M13.3 6.54c-.22-.32-.67-.7-1.05-.87l-.15-.07-.02-.11a3 3 0 0 0-.06-.4c-.2-1.15-.84-2-1.78-2.38-.71-.28-1.5-.3-2.2-.07-.36.12-.8.4-1.08.67l-.12.1-.16-.07a2.24 2.24 0 0 0-2.53.41c-.21.2-.34.37-.47.63a2.16 2.16 0 0 0-.21 1.4c0 .07 0 .08-.1.14A2.54 2.54 0 0 0 2.52 9c.2.44.56.81 1 1.04.23.11.64.24.95.27.1.02.24.01.46.02h.43V9.24h-.42c-.5 0-.65-.02-.9-.15-.31-.14-.5-.35-.6-.66a1.9 1.9 0 0 1 0-.82c.11-.43.43-.8.87-1l.13-.06-.03-.08A1.74 1.74 0 0 1 5.6 4.24c.22-.06.66-.06.89 0 .2.06.45.18.61.32l.14.11.1-.15a1.92 1.92 0 0 1 2.08-.93c1.69.34 1.71 2.73 1.71 2.73s1.4.13 1.47 1.43c.05.99-.4 1.23-.79 1.4-.19.07-.38.1-.8.1h-.37v1.07h.47c.5-.02.69-.05 1.03-.17a2.3 2.3 0 0 0 1.55-2.08c.02-.6-.1-1.05-.4-1.52zM8.53 7.8H7.49v2.99H5.97l2.09 2.73 2-2.73h-1.5l-.01-3z"/>
+</svg>
diff --git a/icons/drm/disc.svg b/icons/drm/disc.svg
new file mode 100644
index 00000000..b1b76248
--- /dev/null
+++ b/icons/drm/disc.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="none" stroke="#e44" stroke-width=".31">
+<circle cx="8" cy="8" r="1.36"/>
+<circle cx="8" cy="8" r="2.29"/>
+<circle cx="8" cy="8" r="7.41" stroke-width="1.17"/>
+</g>
+<path fill="#e44" d="m5.81 9.14-4.35 1.54 1.88 2.62 3.01-3.47m6.3-7.11L9.58 6.2l.56.74 4.4-1.57-1.9-2.65zm-3.37 3.3 2.42-3.89-.8-.44-1.85 4.2M6.7 10.03l-2.44 3.84.78.44 1.9-4.14"/>
+</svg>
diff --git a/icons/drm/free.svg b/icons/drm/free.svg
new file mode 100644
index 00000000..f8695ec9
--- /dev/null
+++ b/icons/drm/free.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<circle cx="8" cy="8" r="7.6" fill="none" stroke="#0c0" stroke-width=".81"/>
+<path fill="#0c0" d="M10.55 6.89V4.82c0-1.19-1.23-2.26-2-2.35a2.55 2.55 0 0 0-3 1.85c-.06.26-.05.43-.07.66h.95c.1 0 .12-.04.13-.13.04-.43.15-.72.4-.98.89-.82 2.33-.28 2.57.77.2.84.14 1.24.14 2.25l-4.61.03c-.69 0-1.1.56-1.1 1.1l.44 4.6c.07.55.3.97.78.96h5.46c.6 0 .8-.38.91-.97l.46-4.6c.1-.59-.15-1.05-.72-1.09l-.74-.03zm-.06 5.6H5.5l-.4-4.47h5.78l-.4 4.48z"/>
+</svg>
diff --git a/icons/drm/online.svg b/icons/drm/online.svg
new file mode 100644
index 00000000..6e844789
--- /dev/null
+++ b/icons/drm/online.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<circle cx="8" cy="8" r="7.41" fill="none" stroke="#e44" stroke-width="1.17"/>
+<path fill="#e44" d="m6.64 7.4.75-.77 2.05 2-.8.76-2-1.99zm1.97 3.3-3.28 1.1-1.12-1.06 1.12-3.3L8.6 10.7zM7.45 5.34l3.22-1.09 1.07 1.08-1.08 3.23c-.03 0-3.23-3.2-3.21-3.22zm-3.84 8.11.97-.93.55.44 4.26-1.43.43.37.73-.72-.84-.86.62-.63.85.85.72-.74-.38-.43 1.41-4.29-.45-.48.96-.99-1.07-1.06-.98.98-.5-.47-4.27 1.42-.43-.38-.72.72.87.88-.62.63-.87-.87-.74.75.36.4-1.42 4.37.45.47-.93.96 1.04 1.04z"/>
+</svg>
diff --git a/icons/drm/physical.svg b/icons/drm/physical.svg
new file mode 100644
index 00000000..428a4cec
--- /dev/null
+++ b/icons/drm/physical.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="none" stroke="#e44">
+<circle cx="8" cy="8" r="7.41" stroke-width="1.17"/>
+<path stroke-width=".86" d="M7.97 4.19c-2.15-1.26-2.6.72-4.05.62m4.04-.62c2.15-1.25 2.68.73 4.12.63m-8.16-.44v7.85m4.04-8.14v7.05m-.21.05c2.07-1.25 2.64.7 4.04.6m.23-7.41v7.85M8.2 11.19c-2.07-1.25-2.64.7-4.04.6"/>
+</g>
+</svg>
diff --git a/data/icons/external.png b/icons/external.png
index 43b45cfa..43b45cfa 100644
--- a/data/icons/external.png
+++ b/icons/external.png
Binary files differ
diff --git a/data/icons/gender.png b/icons/gender.png
index 3870edc6..3870edc6 100644
--- a/data/icons/gender.png
+++ b/icons/gender.png
Binary files differ
diff --git a/icons/lang/ar.png b/icons/lang/ar.png
new file mode 100644
index 00000000..24ead6fb
--- /dev/null
+++ b/icons/lang/ar.png
Binary files differ
diff --git a/icons/lang/be.png b/icons/lang/be.png
new file mode 100644
index 00000000..c2b46837
--- /dev/null
+++ b/icons/lang/be.png
Binary files differ
diff --git a/icons/lang/bg.png b/icons/lang/bg.png
new file mode 100644
index 00000000..dfec1362
--- /dev/null
+++ b/icons/lang/bg.png
Binary files differ
diff --git a/icons/lang/ca.png b/icons/lang/ca.png
new file mode 100644
index 00000000..0e3d7e94
--- /dev/null
+++ b/icons/lang/ca.png
Binary files differ
diff --git a/icons/lang/ck.png b/icons/lang/ck.png
new file mode 100644
index 00000000..5ec1e292
--- /dev/null
+++ b/icons/lang/ck.png
Binary files differ
diff --git a/icons/lang/cs.png b/icons/lang/cs.png
new file mode 100644
index 00000000..ed8774ec
--- /dev/null
+++ b/icons/lang/cs.png
Binary files differ
diff --git a/icons/lang/da.png b/icons/lang/da.png
new file mode 100644
index 00000000..73fda2d3
--- /dev/null
+++ b/icons/lang/da.png
Binary files differ
diff --git a/icons/lang/de.png b/icons/lang/de.png
new file mode 100644
index 00000000..2a607750
--- /dev/null
+++ b/icons/lang/de.png
Binary files differ
diff --git a/icons/lang/el.png b/icons/lang/el.png
new file mode 100644
index 00000000..9260f8a8
--- /dev/null
+++ b/icons/lang/el.png
Binary files differ
diff --git a/icons/lang/en.png b/icons/lang/en.png
new file mode 100644
index 00000000..ff869038
--- /dev/null
+++ b/icons/lang/en.png
Binary files differ
diff --git a/data/icons/lang/eo.png b/icons/lang/eo.png
index a28d3463..a28d3463 100644
--- a/data/icons/lang/eo.png
+++ b/icons/lang/eo.png
Binary files differ
diff --git a/icons/lang/es.png b/icons/lang/es.png
new file mode 100644
index 00000000..42995518
--- /dev/null
+++ b/icons/lang/es.png
Binary files differ
diff --git a/icons/lang/eu.png b/icons/lang/eu.png
new file mode 100644
index 00000000..9364015c
--- /dev/null
+++ b/icons/lang/eu.png
Binary files differ
diff --git a/icons/lang/fa.png b/icons/lang/fa.png
new file mode 100644
index 00000000..32aa1c44
--- /dev/null
+++ b/icons/lang/fa.png
Binary files differ
diff --git a/icons/lang/fi.png b/icons/lang/fi.png
new file mode 100644
index 00000000..7ac075cd
--- /dev/null
+++ b/icons/lang/fi.png
Binary files differ
diff --git a/icons/lang/fr.png b/icons/lang/fr.png
new file mode 100644
index 00000000..2f551dc7
--- /dev/null
+++ b/icons/lang/fr.png
Binary files differ
diff --git a/icons/lang/ga.png b/icons/lang/ga.png
new file mode 100644
index 00000000..9885f597
--- /dev/null
+++ b/icons/lang/ga.png
Binary files differ
diff --git a/data/icons/lang/gd.png b/icons/lang/gd.png
index d0fb86c3..d0fb86c3 100644
--- a/data/icons/lang/gd.png
+++ b/icons/lang/gd.png
Binary files differ
diff --git a/icons/lang/he.png b/icons/lang/he.png
new file mode 100644
index 00000000..78362695
--- /dev/null
+++ b/icons/lang/he.png
Binary files differ
diff --git a/icons/lang/hi.png b/icons/lang/hi.png
new file mode 100644
index 00000000..3ee25fad
--- /dev/null
+++ b/icons/lang/hi.png
Binary files differ
diff --git a/icons/lang/hr.png b/icons/lang/hr.png
new file mode 100644
index 00000000..f13e48a7
--- /dev/null
+++ b/icons/lang/hr.png
Binary files differ
diff --git a/icons/lang/hu.png b/icons/lang/hu.png
new file mode 100644
index 00000000..ae3bef6c
--- /dev/null
+++ b/icons/lang/hu.png
Binary files differ
diff --git a/icons/lang/id.png b/icons/lang/id.png
new file mode 100644
index 00000000..4aa86adf
--- /dev/null
+++ b/icons/lang/id.png
Binary files differ
diff --git a/icons/lang/it.png b/icons/lang/it.png
new file mode 100644
index 00000000..0557e8ed
--- /dev/null
+++ b/icons/lang/it.png
Binary files differ
diff --git a/icons/lang/iu.png b/icons/lang/iu.png
new file mode 100644
index 00000000..60dca43e
--- /dev/null
+++ b/icons/lang/iu.png
Binary files differ
diff --git a/icons/lang/ja.png b/icons/lang/ja.png
new file mode 100644
index 00000000..f84d065c
--- /dev/null
+++ b/icons/lang/ja.png
Binary files differ
diff --git a/icons/lang/ko.png b/icons/lang/ko.png
new file mode 100644
index 00000000..eb0945b5
--- /dev/null
+++ b/icons/lang/ko.png
Binary files differ
diff --git a/icons/lang/la.png b/icons/lang/la.png
new file mode 100644
index 00000000..0082d99f
--- /dev/null
+++ b/icons/lang/la.png
Binary files differ
diff --git a/data/icons/lang/lt.png b/icons/lang/lt.png
index eb50db98..eb50db98 100644
--- a/data/icons/lang/lt.png
+++ b/icons/lang/lt.png
Binary files differ
diff --git a/data/icons/lang/lv.png b/icons/lang/lv.png
index e5d45b33..e5d45b33 100644
--- a/data/icons/lang/lv.png
+++ b/icons/lang/lv.png
Binary files differ
diff --git a/data/icons/lang/mk.png b/icons/lang/mk.png
index e3fd792d..e3fd792d 100644
--- a/data/icons/lang/mk.png
+++ b/icons/lang/mk.png
Binary files differ
diff --git a/icons/lang/ms.png b/icons/lang/ms.png
new file mode 100644
index 00000000..89d12c22
--- /dev/null
+++ b/icons/lang/ms.png
Binary files differ
diff --git a/icons/lang/nl.png b/icons/lang/nl.png
new file mode 100644
index 00000000..5b9ee268
--- /dev/null
+++ b/icons/lang/nl.png
Binary files differ
diff --git a/icons/lang/no.png b/icons/lang/no.png
new file mode 100644
index 00000000..f6f50ecc
--- /dev/null
+++ b/icons/lang/no.png
Binary files differ
diff --git a/icons/lang/pl.png b/icons/lang/pl.png
new file mode 100644
index 00000000..c567328a
--- /dev/null
+++ b/icons/lang/pl.png
Binary files differ
diff --git a/icons/lang/pt-br.png b/icons/lang/pt-br.png
new file mode 100644
index 00000000..2e7da252
--- /dev/null
+++ b/icons/lang/pt-br.png
Binary files differ
diff --git a/icons/lang/pt-pt.png b/icons/lang/pt-pt.png
new file mode 100644
index 00000000..b83ff833
--- /dev/null
+++ b/icons/lang/pt-pt.png
Binary files differ
diff --git a/icons/lang/ro.png b/icons/lang/ro.png
new file mode 100644
index 00000000..9caab41d
--- /dev/null
+++ b/icons/lang/ro.png
Binary files differ
diff --git a/icons/lang/ru.png b/icons/lang/ru.png
new file mode 100644
index 00000000..de447035
--- /dev/null
+++ b/icons/lang/ru.png
Binary files differ
diff --git a/icons/lang/sk.png b/icons/lang/sk.png
new file mode 100644
index 00000000..18cd9ed0
--- /dev/null
+++ b/icons/lang/sk.png
Binary files differ
diff --git a/data/icons/lang/sl.png b/icons/lang/sl.png
index 0f096cee..0f096cee 100644
--- a/data/icons/lang/sl.png
+++ b/icons/lang/sl.png
Binary files differ
diff --git a/icons/lang/sr.png b/icons/lang/sr.png
new file mode 100644
index 00000000..1d44d8f7
--- /dev/null
+++ b/icons/lang/sr.png
Binary files differ
diff --git a/icons/lang/sv.png b/icons/lang/sv.png
new file mode 100644
index 00000000..fb00fe65
--- /dev/null
+++ b/icons/lang/sv.png
Binary files differ
diff --git a/icons/lang/ta.png b/icons/lang/ta.png
new file mode 100644
index 00000000..c95b6b23
--- /dev/null
+++ b/icons/lang/ta.png
Binary files differ
diff --git a/icons/lang/th.png b/icons/lang/th.png
new file mode 100644
index 00000000..993113f8
--- /dev/null
+++ b/icons/lang/th.png
Binary files differ
diff --git a/icons/lang/tr.png b/icons/lang/tr.png
new file mode 100644
index 00000000..e2553714
--- /dev/null
+++ b/icons/lang/tr.png
Binary files differ
diff --git a/icons/lang/uk.png b/icons/lang/uk.png
new file mode 100644
index 00000000..5229c989
--- /dev/null
+++ b/icons/lang/uk.png
Binary files differ
diff --git a/icons/lang/ur.png b/icons/lang/ur.png
new file mode 100644
index 00000000..1ff90dbb
--- /dev/null
+++ b/icons/lang/ur.png
Binary files differ
diff --git a/icons/lang/vi.png b/icons/lang/vi.png
new file mode 100644
index 00000000..81fd0110
--- /dev/null
+++ b/icons/lang/vi.png
Binary files differ
diff --git a/icons/lang/zh-Hans.png b/icons/lang/zh-Hans.png
new file mode 100644
index 00000000..138a8397
--- /dev/null
+++ b/icons/lang/zh-Hans.png
Binary files differ
diff --git a/icons/lang/zh-Hant.png b/icons/lang/zh-Hant.png
new file mode 100644
index 00000000..31b90ef5
--- /dev/null
+++ b/icons/lang/zh-Hant.png
Binary files differ
diff --git a/icons/lang/zh.png b/icons/lang/zh.png
new file mode 100644
index 00000000..d06effec
--- /dev/null
+++ b/icons/lang/zh.png
Binary files differ
diff --git a/icons/list/add.svg b/icons/list/add.svg
new file mode 100644
index 00000000..d6f8f285
--- /dev/null
+++ b/icons/list/add.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g opacity="0.4" fill="#fff">
+<path d="M8.25 6.75v-3h-1.5v3h-3v1.5h3v3h1.5v-3h3v-1.5Z"/>
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+</g>
+</svg>
diff --git a/icons/list/l1.svg b/icons/list/l1.svg
new file mode 100644
index 00000000..7ccedc5c
--- /dev/null
+++ b/icons/list/l1.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g opacity="0.9" fill="#00b9f9">
+<path d="M7.5 0C3.37 0 0 3.37 0 7.5S3.37 15 7.5 15 15 11.62 15 7.5 11.62 0 7.5 0zm0 13.65c-3.38 0-6.15-2.77-6.15-6.15S4.12 1.35 7.5 1.35s6.15 2.78 6.15 6.15-2.78 6.15-6.15 6.15z"/>
+<path d="M5.25 3.07v8.85l6.6-4.42-6.6-4.43zm1.5 2.63 2.7 1.8-2.7 1.72V5.7z"/>
+</g>
+</svg>
diff --git a/icons/list/l2.svg b/icons/list/l2.svg
new file mode 100644
index 00000000..4ae634cd
--- /dev/null
+++ b/icons/list/l2.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#00cf00">
+<path d="m10.31 4.75-3.63 3.7-1.86-1.87-1 1 2.94 2.92 4.7-4.57z"/>
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+</g>
+</svg>
diff --git a/icons/list/l3.svg b/icons/list/l3.svg
new file mode 100644
index 00000000..df8185cd
--- /dev/null
+++ b/icons/list/l3.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#ff7819" opacity=".9">
+<path d="M7.5 0C3.37 0 0 3.37 0 7.5S3.37 15 7.5 15 15 11.62 15 7.5 11.62 0 7.5 0zm0 13.65c-3.38 0-6.15-2.77-6.15-6.15S4.12 1.35 7.5 1.35s6.15 2.78 6.15 6.15-2.78 6.15-6.15 6.15z"/>
+<path d="M5.25 4.5h1.5v6h-1.5zm3 0h1.5v6h-1.5z"/>
+</g>
+</svg>
diff --git a/icons/list/l4.svg b/icons/list/l4.svg
new file mode 100644
index 00000000..4c35bb5a
--- /dev/null
+++ b/icons/list/l4.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#f80000" opacity=".9">
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+<path d="M9 4.5H4.5v6h6v-6H9zM9 9H6V6h3v3z"/>
+</g>
+</svg>
diff --git a/icons/list/l5.svg b/icons/list/l5.svg
new file mode 100644
index 00000000..694fc174
--- /dev/null
+++ b/icons/list/l5.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#ffe700" opacity=".9">
+<path d="M8.74 8.47A1.25 1.25 0 0 1 7.5 9.72a1.25 1.25 0 0 1-1.25-1.25A1.25 1.25 0 0 1 7.5 7.22a1.25 1.25 0 0 1 1.25 1.25z"/>
+<path d="m15 5.83-5-1.02L7.5.35 5 4.81 0 5.82l3.46 3.75-.6 5.08 4.63-1.96 4.64 1.96-.59-5.07L15 5.83zm-7.5 5.7-3.38 1.43.44-3.72-2.51-2.75 3.63-.73L7.5 2.53l1.82 3.24 3.63.72-2.51 2.72.44 3.77z"/>
+</g>
+</svg>
diff --git a/icons/list/l6.svg b/icons/list/l6.svg
new file mode 100644
index 00000000..e3a04a4f
--- /dev/null
+++ b/icons/list/l6.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#b60700">
+<path d="M11.57 1.8a3.4 3.4 0 0 0-2.9 1.67 3.4 3.4 0 0 0-2.9-1.66c-.43 0-.86.08-1.26.25l.85.87a3 3 0 0 1 .42-.05c.81.01 1.56.45 1.98 1.15l.91 1.51.92-1.51a2.32 2.32 0 0 1 1.98-1.15 2.42 2.42 0 0 1 2.35 2.48c0 1.04-.88 2.53-2.05 4.07l.76.77C13.9 8.57 15 6.76 15 5.36a3.5 3.5 0 0 0-3.43-3.55zm-.71 8.87a35.6 35.6 0 0 1-2.19 2.32C6.4 10.78 3.42 7.26 3.42 5.36c0-.6.22-1.2.62-1.65a1.9 1.9 0 0 0-.88-.64 3.6 3.6 0 0 0-.81 2.29c0 3.4 6.32 9.1 6.32 9.1s1.19-1.07 2.53-2.56c.21-.23-.27-1.32-.34-1.23z"/>
+<path d="m0 .99.9-.9 13.9 13.92-.9.9z"/>
+</g>
+</svg>
diff --git a/icons/list/unknown.svg b/icons/list/unknown.svg
new file mode 100644
index 00000000..309ed63f
--- /dev/null
+++ b/icons/list/unknown.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#fff" opacity=".9">
+<path d="M6.75 10.5h1.5V12h-1.5z"/>
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+<path d="M10.42 5.29A3 3 0 0 0 4.5 6H6a1.5 1.5 0 0 1 3 .25A1.56 1.56 0 0 1 7.42 7.5c-.37 0-.67.3-.67.67v1.58h1.5v-.86a3 3 0 0 0 2.17-3.6z"/>
+</g>
+</svg>
diff --git a/icons/plat/and.svg b/icons/plat/and.svg
new file mode 100644
index 00000000..d4fe2f30
--- /dev/null
+++ b/icons/plat/and.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m11.44 3.32 1.08-1.99c.07-.13.04-.23-.08-.3-.13-.06-.23-.03-.3.1l-1.09 2a7.43 7.43 0 0 0-3.04-.64 7.3 7.3 0 0 0-3.04.63l-1.1-2C3.8 1 3.7.97 3.58 1.03c-.12.07-.14.17-.07.3l1.07 1.99a6.64 6.64 0 0 0-2.6 2.33C1.32 6.65 1 7.73 1 8.92h14c0-1.19-.32-2.27-.97-3.27-.64-1-1.5-1.77-2.59-2.33zM5.23 6.21a.57.57 0 0 1-.42.17.54.54 0 0 1-.4-.17.58.58 0 0 1-.17-.42c0-.16.05-.3.16-.41a.54.54 0 0 1 .41-.18c.16 0 .3.06.42.18.12.11.17.25.17.41 0 .16-.05.3-.17.42zm6.38 0a.54.54 0 0 1-.4.17.57.57 0 0 1-.43-.17.57.57 0 0 1-.17-.42c0-.16.06-.3.17-.41a.57.57 0 0 1 .42-.18c.16 0 .3.06.4.18.12.11.18.25.18.41a.6.6 0 0 1-.17.42zM1 15h14V9.46H1z" fill="#839e2e"/>
+</svg>
diff --git a/icons/plat/bdp.svg b/icons/plat/bdp.svg
new file mode 100644
index 00000000..cb84b1a8
--- /dev/null
+++ b/icons/plat/bdp.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#00b2ff">
+<path d="m1.68 4.59-.02.01A22.1 22.1 0 0 0 .08 7.13l-.04.09-.02.03a.23.23 0 0 0 .03.25c.26.42 1.73.9 5 .9 2.46 0 5.05-.4 5.05-1.14 0-.72-2.56-1.14-5.04-1.14-.82 0-1.64.11-1.87.15a61.54 61.54 0 0 1 1.16-1.66l-.02-.02zm.96 2.67c0-.15.92-.37 2.42-.37s2.42.22 2.42.37-.92.37-2.42.37-2.42-.22-2.42-.37z"/>
+<path d="M3.54 10.99s12.2.5 12.46-3.82c.2-3.5-8.45-3.16-8.46-3.16-.01 0-.07 0-.07.05s.03.06.06.06c2.4 0 6.31.96 6.19 3.06-.1 1.7-3.16 3.7-10.17 3.7-.05 0-.07.02-.07.05 0 .03.01.05.06.06z"/>
+</g>
+</svg>
diff --git a/icons/plat/dos.svg b/icons/plat/dos.svg
new file mode 100644
index 00000000..fb36f899
--- /dev/null
+++ b/icons/plat/dos.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m0 3.15 3.29-.08c.64-.02 1.23.05 1.83.26v.12c-.52.45-.89.98-1.23 1.58l-2 .02.02 5.79 1.18.08 1.2-.01c.73-.01 1.4-1.19 1.46-1.83l.18-2.01C6 6.18 6.5 5.5 7.28 5.14c.49.83.7 1.76.7 2.73 0 3.07-1.86 5-4.8 4.9L0 12.64z" fill="#c00"/>
+<path d="M9.71 3.04a4.3 4.3 0 0 0-1.35 1.58l-.33-.01c-1.16 0-2.22 1.04-2.36 2.2l-.3 2.54c-.04.36-.34.72-.57.98-.2.21-.42.25-.7.25h-.03a6.66 6.66 0 0 1-.55-2.63c0-2.88 1.58-5.17 4.52-5.17.58 0 1.13.11 1.67.26zm-3.59 9.54.95-.92.44-.63.72.01c.34.76.9 1.28 1.59 1.73-.5.17-1 .26-1.52.26-.76 0-1.47-.18-2.18-.45z" fill="#f0f"/>
+<path d="m8.3 9.65 1.84.01c.2 1.2.97 1.53 2.12 1.53.73 0 1.8-.17 1.8-1.11 0-.5-.37-.75-.76-.96l.1-1.88c1.46.4 2.6.97 2.6 2.73 0 2.07-1.94 2.95-3.72 2.95-1.28 0-2.93-.38-3.61-1.61a2.88 2.88 0 0 1-.42-1.32l.02-.1zM15.68 6H13.8c-.15-.94-.92-1.4-1.83-1.4-.63 0-1.65.28-1.68 1.08-.01.04 0 .07.01.1l.37 1.1c.08.22.1.47.1.7 0 .28-.04.55-.08.83-1.32-.38-2.24-1.03-2.24-2.55 0-2.04 1.61-3 3.45-3 2.05 0 3.65.98 3.78 3.13z" fill="#cca300"/>
+<path d="M12.28 4.97c.46.89.7 1.86.7 2.89 0 1.07-.26 2.08-.71 3.02l-.35.02c-.38 0-1.28-.27-1.28-.77 0-.09.01-.17.04-.25l.34-1.24c.07-.25.04-.54.04-.79 0-.96-.4-1.56-.43-2.22-.02-.47.79-.68 1.07-.68.2 0 .38 0 .58.02z" fill="#f0f"/>
+</svg>
diff --git a/icons/plat/drc.svg b/icons/plat/drc.svg
new file mode 100644
index 00000000..fcd9a78d
--- /dev/null
+++ b/icons/plat/drc.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.74.98c.6.09 1.2.3 1.52.4.64.19 1.67.8 2.04 1.08.17.14.54.5.68.68l.56.8c.27.39.58.97.85 1.5.3.6.32.77.4 1.26l.2.88c.02.15.04.71-.3.79a.72.72 0 0 1-.76-.3c-.2-.3-.17-.73-.4-1.23-.17-.4-.24-.98-.52-1.4l-.22-.37c-.38-.59-.79-1.06-1.03-1.37-.2-.25-.6-.6-.97-.85a6.65 6.65 0 0 0-2.33-.82c-.46-.06-.76-.13-1.2-.16-.55-.04-.8-.09-1.51 0-.27.07-.7.1-1.07.24a9.4 9.4 0 0 0-2.74 1.76c-.46.49-.69.75-1 1.37-.14.3-.41.78-.52 1.12-.08.34-.22.97-.2 1.28.02.55-.03.66 0 1.11.01.4.14.69.2 1.02.05.23.27 1.02.4 1.2A4.36 4.36 0 0 0 4.2 13.3c.26.14 1.07.35 1.3.42.41.07.94.12 1.45.13.54 0 1.28-.06 1.73-.23.48-.18 1.04-.43 1.04-.42.22-.13.57-.33.67-.42.21-.13.33-.19.46-.31.17-.16.28-.25.42-.44l.3-.42c.13-.2.2-.42.32-.68.15-.4.12-.38.15-.8.03-.11 0-.52-.02-.59-.07-.37-.05-.7-.13-1.07-.12-.49-.21-.86-.36-1.33a3.73 3.73 0 0 0-.86-1.37 4.51 4.51 0 0 0-1.6-1.06c-.22-.08-.6-.16-.84-.2-.5-.06-.6-.03-1.08 0-.17.04-.56.12-.7.17-.42.15-.58.24-1 .5-.2.12-.36.2-.56.4-.16.15-.28.4-.41.56-.18.24-.38.57-.52.93-.14.35-.2.84-.17 1.16 0 .25.07.7.1.79l.1.46c.01.06.17.38.2.42.08.2.25.46.54.74.2.23.62.46.92.55.27.15.74.17 1 .17.12 0 .44-.03.6-.07.35-.08.4-.08.76-.23.31-.15.53-.2.74-.36 0 0 .3-.24.44-.4.07-.1.2-.53.18-.8-.02-.37.04-.58-.04-.88A3.2 3.2 0 0 0 9 7.75a2.02 2.02 0 0 0-.78-.6c-.2-.1-.65-.18-.85-.2-.36-.05-.67.12-.86.3-.16.2-.48.58-.35.98.11.35.25.74.8.71.36.08.79-.04.93.03.21.12.3.39.22.57-.06.36-.4.4-.76.43-.33.05-1.16-.1-1.34-.24-.12-.08-.3-.25-.44-.37-.1-.08-.2-.27-.28-.4a1.49 1.49 0 0 1-.15-.4c-.03-.14-.09-.38-.07-.63.02-.16.06-.23.1-.48.1-.27.2-.48.38-.72.23-.23.52-.51.8-.64.26-.13.7-.18 1.1-.16.32.03.7.07.95.16.43.15.56.18.91.4.3.2.6.53.77.81.36.74.56 1.55.41 2.65 0 .18-.07.36-.18.6-.1.23-.3.51-.58.72-.1.1-.29.23-.43.3-.2.13-.35.2-.56.3-.2.1-.41.2-.7.27-.3.13-.55.13-.94.19-.38.1-1.44.03-2.06-.25-.43-.18-.91-.5-1.28-.88-.2-.22-.4-.6-.46-.68-.29-.56-.34-1.08-.39-1.2-.06-.14-.1-.48-.14-.7a5.38 5.38 0 0 1 .25-2.04c.14-.28.23-.52.33-.65.18-.26.26-.42.41-.6.23-.27.4-.5.62-.66.57-.46 1.04-.7 1.69-1 .8-.33 1.03-.27 1.79-.25.34 0 .83.1 1.07.15.45.06.71.17 1.02.25.32.1.51.24.78.4.22.14.48.34.65.5.44.32.65.66.92 1.07.05.07.37.75.42.94l.14.52.15.76c.06.3.16.85.2 1.37.08.44-.04 1.35-.1 1.63a4.63 4.63 0 0 1-1.72 2.46c-.21.18-.4.28-.73.48-.44.27-.79.44-1.41.69-.26.08-.58.19-.93.26a9.8 9.8 0 0 1-2.12.03 11.2 11.2 0 0 1-1.96-.44c-.25-.1-.73-.3-.93-.4a6.8 6.8 0 0 1-2.04-1.84c-.1-.18-.44-.73-.51-.97-.2-.4-.34-.89-.42-1.1C.25 9.7.11 9.38.05 8.9c0-.11-.02-.38-.05-.53.04-.34-.01-.65.05-1.02.06-.3.14-.43.16-.71.02-.12.17-.48.2-.64.13-.26.16-.47.32-.77.24-.45.48-.98.85-1.44.1-.14.41-.48.51-.6.25-.24.39-.28.67-.52.02-.02.2-.23.4-.3.31-.24.59-.33.89-.56.6-.3.82-.4 1.42-.65.35-.12.83-.19 1-.26C6.8.87 7.14.77 7.5.8c.39 0 .78.02 1.17.06.27.01.61.03 1.08.13z" fill="#cf3311"/>
+</svg>
diff --git a/icons/plat/dvd.svg b/icons/plat/dvd.svg
new file mode 100644
index 00000000..aeb5f8ad
--- /dev/null
+++ b/icons/plat/dvd.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path fill="#ddd" d="M9.11 5.46S8.01 6.81 8.07 6.9c.08-.1-.4-1.46-.4-1.46l-.4-1.23h-5.9l-.2.89h1.91c1 0 1.6.4 1.43 1.13-.18.8-1.05 1.13-1.97 1.13h-.35l.45-1.93H1.09L.44 8.26h2.19c1.65 0 3.21-.89 3.5-2.03.04-.2.04-.73-.09-1.04l-.01-.04c-.01-.01-.02-.06.01-.07l.04.03.03.06 1.4 4.04 3.55-4.1h1.86c1 0 1.61.4 1.44 1.13-.18.8-1.05 1.13-1.98 1.13h-.35l.45-1.93h-1.54l-.66 2.83h2.2c1.64 0 3.22-.89 3.48-2.03.27-1.13-.89-2.03-2.55-2.03h-3.27a52.65 52.65 0 0 0-1.03 1.25zM7.55 9.21c-4.17 0-7.55.5-7.55 1.1 0 .6 3.38 1.1 7.55 1.1 4.18 0 7.57-.5 7.57-1.1 0-.6-3.39-1.1-7.57-1.1zm-.25 1.5c-.96 0-1.73-.17-1.73-.37 0-.2.77-.37 1.73-.37.95 0 1.72.17 1.72.37 0 .2-.77.37-1.72.37z"/>
+</svg>
diff --git a/icons/plat/fm7.svg b/icons/plat/fm7.svg
new file mode 100644
index 00000000..e818b620
--- /dev/null
+++ b/icons/plat/fm7.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12.76h2.1v-1h-.55v-1.62l1.21 2.62h.73l1-2.54v1.54h-.37v1h2.5v-1h-.57V9.01h.57v-.9H4.36l-1 2.36-1.1-2.36H0v.9h.53v2.75H0Zm0-9.52v.97h.52v2.72H0v.94h2.71v-.94h-.55v-.7h1.58v-.96H2.18V4.22h1.95v.9h1.3V3.24Zm7.37.01L7.35 6.5h1.52v-.36a1 1 0 0 1 .05-.23.5.5 0 0 1 .09-.15.7.7 0 0 1 .15-.15.7.7 0 0 1 .2-.09c.07-.02.22-.04.82-.04l2.99.01a10.9 10.9 0 0 0-.83.52c-.13.1-.25.2-.37.34a14.48 14.48 0 0 0-.75 1.19l-.22.48a13.07 13.07 0 0 0-.78 1.93c-.12.37-.23.77-.3 1.24-.06.47-.07 1.02-.09 1.57h2.96v-1a6.6 6.6 0 0 1 .14-1.26l.22-.93.27-.97.15-.56a5.94 5.94 0 0 1 .32-.8 3.57 3.57 0 0 1 .55-.75l.41-.45c.14-.14.27-.24.4-.32.11-.09.22-.15.34-.19.13-.04.26-.05.4-.06l.02-2.25-8.64.02z" fill="#85c20a"/>
+</svg>
diff --git a/icons/plat/fm8.svg b/icons/plat/fm8.svg
new file mode 100644
index 00000000..7d9f6c09
--- /dev/null
+++ b/icons/plat/fm8.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12.76h2.1v-1h-.55v-1.62l1.21 2.62h.73l1-2.54v1.55h-.37v.99h2.5v-1h-.57V9.03h.57v-.9H4.36l-1 2.36-1.1-2.37H0v.9h.53v2.76H0Zm0-9.52v.98h.52v2.7H0v.96h2.71v-.95h-.55v-.7h1.58v-.96H2.18V4.22h1.95v.9h1.3V3.24Zm11.52.01-1.04.05c-.29.02-.42.04-.56.06a25.02 25.02 0 0 0-.73.12 5.7 5.7 0 0 0-.57.17l-.17.07-.18.08-.15.09-.11.08-.1.07-.1.12a2.72 2.72 0 0 0-.27.43 2.02 2.02 0 0 0-.11.52 10.35 10.35 0 0 0-.04 1.36l.06.3a1.24 1.24 0 0 0 .3.49c.05.05.1.1.17.13a2.94 2.94 0 0 0 .57.22l.4.1.32.09c.07.02.08.04.09.05l-.01.04a.26.26 0 0 1-.08.02l-.2.04a6.05 6.05 0 0 0-.68.19c-.1.04-.18.08-.26.14l-.24.2a1.19 1.19 0 0 0-.28.47l-.1.32-.05.35-.04.41a5.2 5.2 0 0 0 .07 1.13 1.3 1.3 0 0 0 .26.5 2.56 2.56 0 0 0 .54.48 2.94 2.94 0 0 0 .95.36 10.86 10.86 0 0 0 3.28.25c.3 0 .51-.03.86-.08.34-.05.83-.14 1.13-.2.31-.08.44-.12.55-.18.1-.05.2-.1.29-.18a1.64 1.64 0 0 0 .47-.56 3.51 3.51 0 0 0 .24-.85 9.75 9.75 0 0 0-.03-.9 1.98 1.98 0 0 0-.1-.42c-.03-.13-.08-.25-.11-.33l-.09-.2a1.48 1.48 0 0 0-.2-.3.66.66 0 0 0-.14-.15.95.95 0 0 0-.2-.11 3.37 3.37 0 0 0-.5-.2 3 3 0 0 0-.23-.07l-.24-.05-.1-.04v-.04c0-.01 0-.03.02-.04a.4.4 0 0 1 .13-.02l.33-.04a3.4 3.4 0 0 0 1.05-.39c.07-.06.14-.14.2-.22.05-.09.1-.17.13-.26s.06-.18.07-.42v-.91a3.22 3.22 0 0 0-.23-1.02 1.79 1.79 0 0 0-.27-.41l-.2-.16a2.6 2.6 0 0 0-.78-.37 5.21 5.21 0 0 0-1.33-.27 16.14 16.14 0 0 0-1.66-.1zm.21.74c.14 0 .3.02.45.05a1.35 1.35 0 0 1 .65.33c.07.1.13.2.17.32a4.65 4.65 0 0 1 .11.97 17.64 17.64 0 0 1-.06 1.31.87.87 0 0 1-.08.28.64.64 0 0 1-.18.21c-.08.06-.18.12-.27.16a1.41 1.41 0 0 1-.63.16 2.5 2.5 0 0 1-.6-.04 2.05 2.05 0 0 1-.45-.14 1.4 1.4 0 0 1-.34-.25.37.37 0 0 1-.06-.08.82.82 0 0 1-.07-.14 6.33 6.33 0 0 1-.08-.84v-.67c0-.23.02-.43.03-.56 0-.13.01-.18.02-.24a.77.77 0 0 1 .22-.44 1.54 1.54 0 0 1 .8-.38h.37zm-.03 4.26a2.47 2.47 0 0 1 .55.09c.12.03.26.08.36.13a.83.83 0 0 1 .36.36 5.18 5.18 0 0 1 .13 1.33 13.75 13.75 0 0 1-.12 1.25.86.86 0 0 1-.35.46 1.84 1.84 0 0 1-1.33.2 2.2 2.2 0 0 1-.68-.28.78.78 0 0 1-.24-.28 1.27 1.27 0 0 1-.1-.39c-.03-.13-.03-.27-.03-.45a15.52 15.52 0 0 1 .01-1.03l.04-.31a1.4 1.4 0 0 1 .14-.58c.04-.06.09-.1.13-.13l.15-.1c.06-.04.12-.09.19-.12l.2-.07a1.55 1.55 0 0 1 .49-.08h.1z" fill="#abad1f"/>
+</svg>
diff --git a/icons/plat/fmt.svg b/icons/plat/fmt.svg
new file mode 100644
index 00000000..f27cec1b
--- /dev/null
+++ b/icons/plat/fmt.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.72 5.2H16V3.25H7.14v1.97h3.24v2.67h2.34Zm.01 2.91h-2.35v4.65h2.35zM0 12.76h2.1v-1h-.55v-1.62l1.21 2.62h.73l1-2.54v1.55h-.37v.99h2.5v-1h-.57V9.03h.57v-.9H4.36l-1 2.36-1.1-2.37H0v.9h.53v2.76H0Zm0-9.52v.98h.52v2.71H0v.95h2.71v-.95h-.55v-.7h1.58v-.96H2.18V4.22h1.95v.9h1.3V3.25Z" fill="#2eb85c"/>
+</svg>
diff --git a/icons/plat/gba.svg b/icons/plat/gba.svg
new file mode 100644
index 00000000..78516d84
--- /dev/null
+++ b/icons/plat/gba.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path fill="#1900cd" d="M0 4h16v8H0z"/>
+<path d="M8.1 9.74H7.03V6.2h1.5c.79 0 1.18.53 1.18 1.68 0 1.4-.42 1.85-1.63 1.85zm7.33-5.01H14.2l-.04.1-1.37 4.18-1.47-4.17-.04-.09H10l.1.28.26.68a2.02 2.02 0 0 0-1.82-.98H5.91v5.42L3.96 4.84l-.03-.09h-1l-.04.09-2.22 6.15-.1.28h2.8V9.82H2.23l1.14-3.39 1.6 4.73.04.09h3.27c1.71 0 2.6-1.12 2.6-3.3 0-.4-.04-.73-.1-1.04l1.57 4.25.03.09h.76l.04-.09 2.15-6.15z" fill="#fff"/>
+</svg>
diff --git a/icons/plat/gbc.svg b/icons/plat/gbc.svg
new file mode 100644
index 00000000..8deac3c0
--- /dev/null
+++ b/icons/plat/gbc.svg
@@ -0,0 +1,5 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M15.5 9.99c-.34-.1-.72-.04-.72-.04s-1.09.11-1.66-.01c-.88-.16-.85-1.14-.85-1.14s-.03-.65.5-1.53C13.43 6.2 14.15 6 14.15 6s.18-.07.35.02c.1.63.7.63.7.63s.84.05.8-1c-.1-.88-.91-1-1.26-1.08-1.02-.2-1.68.37-1.68.37s-.7.39-1.51 1.73a3.61 3.61 0 0 0-.63 1.48s-.07.15-.04.84c-.02.54.18.93.18.93s.3.93 1.23 1.31c.57.25 1.4.24 1.4.24s.81 0 1.43-.04c.34.02.5-.16.5-.16s.38-.25.34-.7c0 0-.07-.46-.46-.58z" fill="#c10b44"/>
+<path d="M1.75 5.28A3.72 3.72 0 0 0 .03 9.04c.19 1.97 2.65 3.2 4.73 1.96.19-.1.12-.1.18-.15l.42-2.75H2.87l-.19 1.27h1.04l-.12.67c-.6.25-1.6.16-2.04-.64-.27-.51-.53-1.64.63-2.68.94-.85 2.56-1 3.5-.56 0 0 .12-.7.2-1.37a4.5 4.5 0 0 0-4.14.5z" fill="#6aab21"/>
+<path d="m6.55 4.65-1.03 6.7h3.02c1.24 0 3.06-1.9 1.4-3.44 1.7-1.82.02-3.24-.89-3.26H6.54zm.74 4.06h1.13c1.06 0 1.03 1.34-.15 1.34H7.09zm.42-2.63h.87c.95 0 .76 1.25-.15 1.25H7.5z" fill="#c79d05"/>
+</svg>
diff --git a/icons/plat/ios.svg b/icons/plat/ios.svg
new file mode 100644
index 00000000..67f742e1
--- /dev/null
+++ b/icons/plat/ios.svg
@@ -0,0 +1,10 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M.06 6.88h1.16V1.97H.06zm.58-5.56c.36 0 .64-.27.64-.62A.63.63 0 0 0 .64.07.63.63 0 0 0 0 .7c0 .35.28.62.64.62zM5.06.08C3.11.08 1.9 1.41 1.9 3.54s1.2 3.45 3.16 3.45 3.17-1.32 3.17-3.45S7.01.08 5.06.08zm0 1.02c1.2 0 1.95.95 1.95 2.44s-.76 2.43-1.95 2.43c-1.2 0-1.95-.94-1.95-2.43 0-1.5.76-2.44 1.95-2.44zM8.72 5c.05 1.23 1.06 2 2.6 2 1.62 0 2.63-.8 2.63-2.07 0-1-.57-1.56-1.93-1.87l-.77-.18c-.82-.2-1.16-.45-1.16-.9 0-.55.51-.92 1.27-.92s1.29.37 1.34 1h1.14c-.02-1.18-1-1.98-2.47-1.98-1.46 0-2.49.8-2.49 2 0 .95.58 1.54 1.82 1.82l.86.2c.85.2 1.19.48 1.19.96 0 .56-.56.96-1.37.96S9.95 5.62 9.88 5H8.72z" fill="url(#a)" style="fill:url(#a)" transform="translate(0 3.95) scale(1.14651)"/>
+<defs>
+<linearGradient id="a" x1=".65" x2="12.67" y1=".71" y2="6.06" gradientUnits="userSpaceOnUse">
+<stop stop-color="#3367ff" offset="0"/>
+<stop stop-color="#8be250" offset=".71"/>
+<stop stop-color="#dbf141" offset="1"/>
+</linearGradient>
+</defs>
+</svg>
diff --git a/icons/plat/lin.svg b/icons/plat/lin.svg
new file mode 100644
index 00000000..ea15db59
--- /dev/null
+++ b/icons/plat/lin.svg
@@ -0,0 +1,7 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M16.005 14.85c-.882-1.195-1.556-2.474-1.538-4.147.037-3.373.37-9.628-5.533-9.636-.24 0-.49.01-.752.03-6.596.535-4.846 7.542-4.944 9.888-.12 1.716-.64 3.453-1.874 5.016L16 16c0-.483.004-1.002.005-1.15z" fill="#4d4d4d"/>
+<path d="M11.521 11.214c-.115-.225-.348-.44-.747-.604l-.002-.001c-.828-.357-1.187-.382-1.65-.684-.751-.486-1.373-.657-1.89-.654-.27 0-.512.049-.728.124-.63.218-1.047.672-1.309.921v.001c-.052.05-.118.094-.279.212-.161.119-.403.298-.752.56-.31.234-.41.538-.303.894.107.356.448.767 1.073 1.122l.002.001c.388.23.652.538.956.784.152.123.312.232.505.315s.417.139.698.155c.66.039 1.147-.16 1.576-.407.43-.247.794-.549 1.212-.685h.001c.857-.27 1.467-.812 1.658-1.326.096-.258.093-.502-.021-.728z" fill="#f3d427"/>
+<path d="M9.349 12.485c-.682.357-1.477.79-2.324.79-.846 0-1.515-.393-1.996-.776-.24-.192-.435-.382-.582-.52-.256-.204-.225-.488-.12-.48.176.023.202.255.313.36.15.14.338.323.565.505.455.362 1.06.714 1.82.714.758 0 1.642-.447 2.183-.752.305-.172.695-.481 1.012-.716.244-.18.235-.395.435-.372s.053.24-.228.486c-.282.246-.721.574-1.078.76z" fill="#202020"/>
+<path d="M3.707 16.07a9.167 9.167 0 0 0 .637-3.015c.076.055.335.231.45.297h.001c.338.2.592.493.921.76.33.265.742.495 1.363.532.06.003.118.005.176.005.64 0 1.14-.21 1.557-.45.453-.26.814-.548 1.157-.66h.001c.724-.228 1.3-.631 1.627-1.1.27 1.067.71 2.36 1.187 3.59l-9.077.041zm7.89-7.425a2.83 2.83 0 0 1-.24 1.202 2.33 2.33 0 0 1-.335.563 10.973 10.973 0 0 0-.583-.242c-.131-.05-.234-.084-.34-.12.077-.094.228-.204.285-.342.085-.208.127-.411.135-.654 0-.01.003-.018.003-.029a1.82 1.82 0 0 0-.094-.634 1.172 1.172 0 0 0-.29-.494.595.595 0 0 0-.416-.19h-.021a.608.608 0 0 0-.406.16 1.17 1.17 0 0 0-.325.472c-.085.208-.13.43-.135.655-.002.01-.002.018-.002.028-.003.134.006.257.027.376-.3-.15-.683-.26-.948-.323a3.242 3.242 0 0 1-.027-.359v-.033c-.005-.441.067-.82.236-1.203.168-.384.377-.66.67-.884a1.47 1.47 0 0 1 .925-.331h.016c.335 0 .62.099.915.313.298.218.513.49.687.872.17.371.251.734.26 1.165 0 .011 0 .02.003.032zm-5.056.44a2.617 2.617 0 0 0-.742.338c.017-.128.02-.257.006-.402l-.001-.023a1.756 1.756 0 0 0-.127-.516.994.994 0 0 0-.259-.381.42.42 0 0 0-.317-.12c-.113.01-.206.065-.294.173a1.003 1.003 0 0 0-.188.42c-.042.18-.054.367-.035.552l.002.022c.019.194.057.355.126.518a.986.986 0 0 0 .311.421c-.11.086-.183.146-.274.213l-.207.153a1.9 1.9 0 0 1-.43-.645 2.898 2.898 0 0 1-.24-1.026V8.78c-.02-.38.017-.708.121-1.047.105-.34.244-.585.447-.786.202-.202.405-.304.651-.316l.057-.002c.222 0 .42.075.626.24.223.18.392.408.533.731.142.323.217.646.238 1.027v.003a3 3 0 0 1-.004.456z" fill="#ccc"/>
+<path d="M7.658 9.997c.029.09.174.075.259.12.073.037.133.121.216.124.08.002.203-.028.213-.107.014-.105-.138-.17-.236-.21-.125-.048-.286-.073-.404-.008-.027.016-.057.051-.048.08zm-.86 0c-.029.09-.174.075-.258.12-.074.037-.134.121-.217.124-.08.002-.203-.028-.213-.107-.013-.105.138-.17.236-.21.126-.048.287-.073.404-.008.027.016.057.051.048.08z" fill="#202020"/>
+</svg>
diff --git a/icons/plat/mac.svg b/icons/plat/mac.svg
new file mode 100644
index 00000000..e71e68a6
--- /dev/null
+++ b/icons/plat/mac.svg
@@ -0,0 +1,9 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m1.89 5.8.01-.02c.82-1.27 2.12-2 3.33-2 1.24 0 2.02.67 3.04.67 1 0 1.6-.68 3.03-.68 1.08 0 2.23.6 3.04 1.61-.22.13-.42.27-.6.42H1.88z" fill="#4d9537"/>
+<path d="M10.21 2.63A3.5 3.5 0 0 0 11 .05c-.85.05-1.85.6-2.43 1.3-.52.65-.96 1.6-.79 2.52.93.03 1.89-.52 2.44-1.24z" fill="#4d9537"/>
+<path d="M1.89 5.8c-.35.56-.6 1.34-.66 2.03h11.36c.12-.76.5-1.47 1.14-2.03H1.9z" fill="#ca8a02"/>
+<path d="M1.29 9.87a7.73 7.73 0 0 1-.06-2.04h11.36c-.11.69-.02 1.4.26 2.04H1.3z" fill="#c35f09"/>
+<path d="M1.84 11.9a9.6 9.6 0 0 1-.55-2.03h11.56c.36.8 1.02 1.5 1.96 1.85l-.08.18H1.84z" fill="#b01c1f"/>
+<path d="M14.73 11.9a10.85 10.85 0 0 1-1.14 2.03H2.92l-.1-.16c-.4-.6-.73-1.24-.98-1.87h12.89z" fill="#903b91"/>
+<path d="M13.6 13.93c-.66.96-1.54 2.01-2.6 2.02-1.04.01-1.3-.67-2.71-.67-1.41.01-1.7.69-2.74.68-1.11-.01-1.98-1.05-2.63-2.03h10.67z" fill="#0092cc"/>
+</svg>
diff --git a/icons/plat/mob.svg b/icons/plat/mob.svg
new file mode 100644
index 00000000..6a615342
--- /dev/null
+++ b/icons/plat/mob.svg
@@ -0,0 +1,22 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<linearGradient id="a" x1="2.15" x2="2.99" y1=".98" y2="2.57" gradientTransform="matrix(.73825 .61946 -.57392 .68397 1.84 -.98)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ccccf4" offset="0"/>
+<stop stop-color="#9ac2ff" offset="1"/>
+</linearGradient>
+</defs>
+<g transform="scale(3.77956)">
+<path d="M2.06.26.14 2.55c-.04.05-.09.1-.07.16.02.06.1.13.13.16l.34.29.78.65.25.2c.03.03.1.09.15.08.06 0 .1-.06.45-.46L3.78 1.7c.06-.06.1-.12.1-.18s-.08-.12-.34-.33L2.6.39C2.35.17 2.28.13 2.22.13s-.11.07-.16.13z" fill="#666"/>
+<path d="m2.02.8-.43.52c-.13.15-.18.21-.17.27 0 .06.07.12.25.26l.62.53c.17.14.24.2.3.2.05 0 .09-.06.22-.2l.47-.57c.13-.15.17-.2.16-.26-.02-.05-.09-.1-.26-.25L2.56.78c-.17-.15-.24-.2-.3-.2-.05 0-.1.04-.11.07L2.02.8z" fill="url(#a)"/>
+<path d="m3.54 1.2.2-.23c.05-.06.07-.1.1-.1.02 0 .04.01.07.04l.1.08.05.06c0 .02 0 .04-.06.1l-.22.26-.25-.2z" fill="#8c8c8c"/>
+<rect transform="rotate(40)" x="2.06" y=".58" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="3.23" y=".58" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.06" y="1.03" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.06" y="1.46" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="3.23" y="1.03" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="3.23" y="1.46" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.64" y="1.46" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.64" y="1.03" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.64" y=".58" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+</g>
+</svg>
diff --git a/icons/plat/msx.svg b/icons/plat/msx.svg
new file mode 100644
index 00000000..b3a7f8c9
--- /dev/null
+++ b/icons/plat/msx.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 3.8h16v8.4H0z"/>
+<path d="m.86 11.34 1.24-6.7h1.15l.6 2.92.61-2.92h1.08l1.02 4.93h2.47c.26 0 .28-.74-.02-.74L7.57 8.8c-.62 0-1.27-.91-1.24-2.13 0-1.13.72-2.01 1.63-2.01h3.68l.93 1.74.92-1.76 1.52.01-1.67 3.22 1.8 3.5-1.51-.01-1.05-2-1 2h-1.54L11.8 7.9l-.77-1.53h-3.2c-.34.01-.33.77 0 .75l1.35.03c.57 0 1.37.82 1.35 2.08.01 1.48-.95 2.15-1.42 2.11l-3.52.01L5 8.34l-.58 3H3.3l-.62-2.99-.53 3z" fill="#e4e4e4"/>
+</svg>
diff --git a/icons/plat/n3d.svg b/icons/plat/n3d.svg
new file mode 100644
index 00000000..2dbf8ed4
--- /dev/null
+++ b/icons/plat/n3d.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.62 10.1c.4.26 1.25.46 1.9.46.73 0 1.03-.4 1.03-.88 0-.44-.28-.7-1.08-1.15-1.06-.62-1.84-1.1-1.84-2.2 0-1.13.94-1.8 2.37-1.8.77 0 1.04.07 1.53.21v1.08c-.48-.14-.9-.39-1.57-.39-.7 0-1 .35-1 .72 0 .53.46.78 1.27 1.23 1.14.64 1.77 1.13 1.77 2.2 0 1.11-.8 1.9-2.58 1.9-.73 0-1.23-.07-1.8-.21zm-3.8-4.62h-.85v5.06h.85c1.3 0 2.13-.88 2.13-2.52 0-1.64-.82-2.54-2.13-2.54zm2.28 5.53c-.42.3-1.21.5-1.9.5H5.53V4.53h2.65c.7 0 1.5.2 1.92.5 1.02.72 1.35 1.88 1.35 2.98s-.33 2.27-1.36 3z" fill="#b1b1b4"/>
+<path d="M3.68 7.66s1.24-.3 1.24-1.5c0-1.18-1.32-1.66-2.73-1.66-1.26 0-2.1.24-2.1.24V5.8c.58-.22 1.13-.4 1.88-.4.8 0 1.42.37 1.42.9 0 .63-.6 1-1.92 1h-.6v.96h.56c1.38 0 2.16.32 2.16 1.1 0 .7-.7 1.14-1.57 1.14-.76 0-1.46-.26-2.02-.5v1.15c.27.07.99.3 2.33.3 1.48 0 2.77-.74 2.77-2.05 0-1.1-.9-1.75-1.42-1.75z" fill="#d0000f"/>
+</svg>
diff --git a/icons/plat/nds.svg b/icons/plat/nds.svg
new file mode 100644
index 00000000..78dd015b
--- /dev/null
+++ b/icons/plat/nds.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.3 10.05a9.5 9.5 0 0 0 2.92.44c1.1 0 1.56-.37 1.56-.85 0-.43-.42-.68-1.64-1.13-1.63-.6-2.83-1.07-2.83-2.15 0-1.1 1.44-1.76 3.63-1.76 1.18 0 1.59.07 2.33.2l.01 1.06c-.74-.13-1.39-.37-2.4-.37-1.08 0-1.54.34-1.54.7 0 .51.7.76 1.96 1.2 1.73.62 2.7 1.1 2.7 2.15 0 1.08-1.21 1.86-3.94 1.86-1.12 0-1.9-.07-2.75-.22zM3.5 5.53H2.2v4.93h1.3c1.99 0 3.25-.86 3.25-2.46S5.49 5.53 3.49 5.53zm3.48 5.38c-.64.29-1.86.48-2.92.48H0V4.62h4.06c1.06 0 2.28.18 2.92.47C8.55 5.8 9.06 6.93 9.06 8s-.5 2.2-2.08 2.91z" fill="#ccc"/>
+</svg>
diff --git a/icons/plat/nes.svg b/icons/plat/nes.svg
new file mode 100644
index 00000000..e634e660
--- /dev/null
+++ b/icons/plat/nes.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.14 3.87a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.52 0zM4.46 2.2c.54.17.84.76.67 1.3l-3.09 9.61a1.04 1.04 0 1 1-1.98-.63l3.09-9.61c.18-.55.76-.85 1.3-.67zM8 7.45a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.53 0zm8.04-3.58a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.52 0zM11.36 2.2c.54.17.84.76.67 1.3l-3.09 9.61a1.04 1.04 0 1 1-1.98-.63l3.09-9.61c.18-.55.76-.85 1.3-.67zm3.54 5.25a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.53 0z" fill="#ca1c02"/>
+</svg>
diff --git a/icons/plat/oth.svg b/icons/plat/oth.svg
new file mode 100644
index 00000000..26d15959
--- /dev/null
+++ b/icons/plat/oth.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 12.53a.98.98 0 1 1 0-1.96.98.98 0 0 1 0 1.96zm1.36-4.45-.02.01a1.1 1.1 0 0 0-.62.97.72.72 0 0 1-1.44 0c0-.96.56-1.85 1.43-2.27l.01-.01A1.7 1.7 0 1 0 6.3 6.24a.72.72 0 0 1-1.44 0 3.15 3.15 0 1 1 4.5 2.84z" fill="#bec10b"/>
+</svg>
diff --git a/icons/plat/p88.svg b/icons/plat/p88.svg
new file mode 100644
index 00000000..cc5cd463
--- /dev/null
+++ b/icons/plat/p88.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.14 6.3c2.4-.29 4.5 1.41 3.76 3.62-.11.34-.77 1.08-.77 1.08s1 .84 1.12 1.37c.45 2.25-1.44 3.58-3.36 3.63-2.03.05-4.27-1.19-3.83-3.5.06-.32.29-.61.4-.8.13-.19.69-.7.69-.7s-.75-1.06-.82-1.51c-.26-1.64.83-2.97 2.8-3.2zM10.66 9c0 1.18 1.98 1.16 1.98.06 0-1.25-1.98-1.27-1.98-.06zm1.02 5.16c1.56 0 1.57-2.1-.02-2.1-1.6 0-1.55 2.1.02 2.1z" fill="#cc6700"/>
+<path d="M12.3.12v1.2c-3.77-.89-3.74 4.41-.04 3.53v1.2C6.53 7.3 6.5-1.03 12.3.12z" fill="#e6e6e6"/>
+<path d="m5.27 3.7.01-1.19c.67 0 1.07-.2 1.07-.62 0-.34-.07-.69-1.42-.69v4.97H3.5V0h1.4c2.7 0 3 .95 3 1.85 0 1.3-1.04 1.86-2.63 1.86z" fill="#e6e6e6"/>
+<path d="M3.7 6.3C6.13 6 8.22 7.7 7.48 9.91 7.36 10.26 6.7 11 6.7 11s1 .84 1.12 1.37c.46 2.25-1.44 3.58-3.36 3.63-2.03.05-4.26-1.2-3.83-3.5.06-.32.29-.61.4-.8.13-.19.69-.7.69-.7S.97 9.94.9 9.49c-.26-1.64.83-2.97 2.8-3.2zM3.24 9c0 1.18 1.98 1.16 1.98.06 0-1.25-1.98-1.27-1.98-.06zm1.03 5.16c1.55 0 1.56-2.1-.03-2.1-1.6 0-1.55 2.1.03 2.1z" fill="#cc6700"/>
+</svg>
diff --git a/icons/plat/p98.svg b/icons/plat/p98.svg
new file mode 100644
index 00000000..d39ea0ea
--- /dev/null
+++ b/icons/plat/p98.svg
@@ -0,0 +1,5 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.7 16H2.2l2.03-3.63c-.55 0-2.07-.26-2.63-.7a2.9 2.9 0 0 1-.9-3.1c.45-1.53 2.38-2.67 4.49-2.2 1.5.34 2.38 1 2.64 2.46a3.4 3.4 0 0 1-.43 2.31zM2.85 9.48c0 1.58 2.76 1.62 2.76.06 0-1.63-2.76-1.65-2.76-.06zm8.29-3.18c2.4-.29 4.5 1.41 3.76 3.62-.11.34-.77 1.08-.77 1.08s1 .83 1.12 1.37c.45 2.25-1.44 3.58-3.36 3.63-2.03.05-4.27-1.19-3.83-3.5.06-.32.29-.61.4-.8.13-.19.69-.7.69-.7s-.75-1.06-.82-1.51c-.26-1.64.83-2.97 2.8-3.2zM10.66 9c0 1.18 1.98 1.16 1.98.06 0-1.25-1.98-1.27-1.98-.06zm1.02 5.16c1.56 0 1.57-2.1-.02-2.1-1.6 0-1.55 2.1.02 2.1z" fill="#c00"/>
+<path d="M12.42.12v1.2c-3.8-.89-3.78 4.41-.05 3.53v1.2c-5.78 1.25-5.8-7.08.05-5.93z" fill="#e6e6e6"/>
+<path d="M5.32 3.7V2.52c.68 0 1.08-.2 1.08-.62 0-.35-.06-.69-1.42-.69v4.97H3.52L3.53 0h1.42c2.71 0 3.03.95 3.03 1.85 0 1.3-1.06 1.86-2.66 1.86z" fill="#e6e6e6"/>
+</svg>
diff --git a/icons/plat/pce.svg b/icons/plat/pce.svg
new file mode 100644
index 00000000..afef2f82
--- /dev/null
+++ b/icons/plat/pce.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.03 12.32s1.16.04 1.34-.7l.07-1.4 1.35-.5A2.84 2.84 0 0 0 5.6 6.9V5.43c0-1.2-1.17-1.46-1.92-1.21L.04 5.53 0 11.26c.05 1.11 1.03 1.06 1.03 1.06zM2.14 6.8c0-.08.05-.13.2-.2l.82-.3c.26-.1.34-.07.34.18V7.7c0 .15-.15.27-.32.33l-.79.28c-.18.06-.25.07-.25-.13zm7.39-1.92 1.75-.62c.18-.07.14-.45.14-.53 0-1.16-1.01-1.7-1.73-1.43l-1.7.58c-1 .38-2.02.92-2.24 2.6v3.26c0 2 1.56 1.64 2.18 1.41L10.8 9.1c.48-.19.84-.68.77-1.32-.05-.4-.1-.79-.17-1.18-.02-.12-.02-.23-.16-.18l-1.7.62c-.16.06-.26.09-.26.28 0 .2.02.36-.27.46l-.9.31c-.1.04-.25.05-.25-.23V5.04c0-.13.05-.2.23-.26l.96-.33c.15-.05.26-.05.26.1v.18c0 .23.1.2.22.15zM14.8.66l-2.8.98c-.11.04-.14.14-.14.29V8.5c0 .21.06.23.22.18l3.6-1.3c.08-.02.11-.01.11-.13v-.6c0-.22-.03-.3-.22-.23L13 7.36c-.18.06-.25.1-.25-.14V5.5c0-.17.04-.18.1-.2l1.5-.55c.1-.04.24-.09.24-.22v-.56c0-.12-.06-.2-.32-.1L13 4.33c-.22.09-.25.04-.25-.16v-1.7c0-.07-.02-.11.17-.18l2-.75c.15-.05.18-.03.18-.18V.8c0-.17-.05-.22-.3-.14z" fill="#cc2000"/>
+<path d="M10.34 11.45 6.9 15.22c-.07.09-.03.12.06.07l4.46-2.78c.08-.05.1-.07.16-.05.07.02 1.67 1.17 1.67 1.17.11.08.17.04.13-.05l-.83-1.81c-.04-.1-.06-.17-.01-.21l3.36-3.67c.08-.12.03-.18-.1-.1l-4.17 2.58c-.1.06-.15.06-.21.02L9.87 9.34c-.16-.09-.21-.05-.17.13l.7 1.72c.05.1.02.17-.06.26z" fill="#cc2000" stroke="#fff" stroke-miterlimit="2.61" stroke-width=".12"/>
+</svg>
diff --git a/icons/plat/pcf.svg b/icons/plat/pcf.svg
new file mode 100644
index 00000000..f834e2a3
--- /dev/null
+++ b/icons/plat/pcf.svg
@@ -0,0 +1,13 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<g fill="#666">
+<path d="M7.91 1.1a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5.5.5 0 0 1 .5.5z"/>
+<path d="m7.4 12.79-3.47-1.98.94-.5c.69 1 2.07 1.08 3.16.98.65-.2-.13.25-.1.43.13.34.51.14.7.09.46-.27.93-.57 1.22-1.1.16-.48.1-.22.47-.63.35-.36.38-.92.78-1.23.43-.39.63-.94.86-1.46.78-.72 1.5-1.67 1.47-2.78.04-.85-.48-1.6-1.1-2.12a7.49 7.49 0 0 0-3.64-1.47c-.3-.1-.6.25-.39.52.6.24 1.26.24 1.86.49.95.35 1.93.88 2.45 1.8.44.89-.02 1.97-.7 2.61l-.02.03a3.76 3.76 0 0 0-.92-1.12A6.5 6.5 0 0 0 7.4 4.22v-2.4L1.14 5.2v7.12h.01v.13L7.41 16l6.25-3.56V9.23zm0-5.44c.71.04 1.44.15 2.05.53.08.06.18.1-.06.17a9.7 9.7 0 0 1-1.24.24s-.42.03-.56.02H7.4zm-.44 1.83c.38-.04.77-.02 1.13.07.21.06.48.06.62.26-.7.12-1.42.13-2.12.09l-.32-.05zm.38 1.48c-.62-.03-1.35-.08-1.8-.57a7.3 7.3 0 0 0 3.8-.06c-.53.48-1.3.61-2 .63zm2.24-1.48c-.17-.2-.4-.32-.63-.38l1.12-.31c.21.39-.16.6-.49.7zm1.2-3.15c.29.26.44.62.54.99-.22.16-.45.32-.69.46-.16.05-.34.25-.5.08a4.94 4.94 0 0 0-2.73-.89V4.9c1.2.02 2.47.3 3.37 1.13z"/>
+</g>
+<path d="M8.8.82c-.2-.27.1-.63.4-.51 1.29.2 2.6.62 3.62 1.46.63.52 1.15 1.27 1.1 2.12.03 1.11-.68 2.06-1.46 2.78-.23.52-.43 1.07-.86 1.46-.4.31-.43.87-.78 1.23-.38.41-.3.15-.47.64a3 3 0 0 1-1.23 1.09c-.18.05-.56.25-.68-.08-.04-.19.74-.65.09-.44-1.1.1-2.47.03-3.15-.97-.13-.3-.11-.4-.11-.57-.5-.36-.85-.9-.8-1.53-.55-.38-1.07-.92-1.1-1.63-.09-.69.45-1.25 1-1.58a6.8 6.8 0 0 1 3.52-.79c1.25.08 2.56.35 3.58 1.13.4.29.67.7.92 1.12l.02-.03c.68-.64 1.14-1.72.7-2.62a4.48 4.48 0 0 0-2.45-1.79c-.6-.25-1.26-.25-1.86-.49zM4.34 5.14c-.63.5-.17 1.51.47 1.71.86-.7 2-.9 3.08-.9.97.06 1.96.28 2.74.89.16.17.34-.03.5-.07.24-.15.47-.3.7-.47a1.9 1.9 0 0 0-.56-.98A5.06 5.06 0 0 0 7.9 4.19c-1.23-.03-2.58.13-3.56.95zm4.3 2.43a9.68 9.68 0 0 0 1.25-.24c.24-.07.14-.1.06-.17a4.28 4.28 0 0 0-2.04-.53c-.77 0-1.58.08-2.27.46-.07.05-.3.09 0 .17.67.23 2.3.33 2.45.33l.56-.02zm-3.52.25c.06.16.19.4.44.55.1-.07.27-.15.41-.22L6.25 8H6c-.31-.04-.58-.1-.88-.18zm4.33.26c.24.07.46.17.63.38.33-.09.7-.3.5-.7-.38.12-.76.22-1.13.32zm-3.09.64c.21.12.5.11.73.16.7.05 1.42.03 2.12-.09-.14-.2-.4-.2-.62-.26a3.94 3.94 0 0 0-2.23.19zm-.33.65c.46.49 1.19.54 1.81.57.7-.02 1.47-.15 2-.63a7.26 7.26 0 0 1-3.8.06z" fill="#01015b"/>
+<path d="M8.41.5a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5.5.5 0 0 1 .5.5zM7.9 15.28l-6.25-3.56V8.51l6.26 3.56z" fill="#01015b"/>
+<path d="m7.9 15.28 6.26-3.56V8.51l-6.25 3.56z" fill="#f00020"/>
+<path d="M1.64 11.6 7.9 8.22V1.1L1.64 4.48z" fill="#cb9a01"/>
+<path d="M8.8.82c-.2-.27.1-.63.4-.51 1.29.2 2.6.62 3.62 1.46.63.52 1.15 1.27 1.1 2.12.03 1.11-.68 2.06-1.46 2.78-.23.52-.43 1.07-.86 1.46-.4.31-.43.87-.78 1.23-.38.41-.3.15-.47.64a3 3 0 0 1-1.23 1.09c-.18.05-.56.25-.68-.08-.04-.19.74-.65.09-.44-1.1.1-2.47.03-3.15-.97-.13-.3-.11-.4-.11-.57-.5-.36-.85-.9-.8-1.53-.55-.38-1.07-.92-1.1-1.63-.09-.69.45-1.25 1-1.58a6.8 6.8 0 0 1 3.52-.79c1.25.08 2.56.35 3.58 1.13.4.29.67.7.92 1.12l.02-.03c.68-.64 1.14-1.72.7-2.62a4.48 4.48 0 0 0-2.45-1.79c-.6-.25-1.26-.25-1.86-.49zM4.34 5.14c-.63.5-.17 1.51.47 1.71.86-.7 2-.9 3.08-.9.97.06 1.96.28 2.74.89.16.17.34-.03.5-.07.24-.15.47-.3.7-.47a1.9 1.9 0 0 0-.56-.98A5.06 5.06 0 0 0 7.9 4.19c-1.23-.03-2.58.13-3.56.95zm4.3 2.43a9.68 9.68 0 0 0 1.25-.24c.24-.07.14-.1.06-.17a4.28 4.28 0 0 0-2.04-.53c-.77 0-1.58.08-2.27.46-.07.05-.3.09 0 .17.67.23 2.3.33 2.45.33l.56-.02zm-3.52.25c.06.16.19.4.44.55.1-.07.27-.15.41-.22L6.25 8H6c-.31-.04-.58-.1-.88-.18zm4.33.26c.24.07.46.17.63.38.33-.09.7-.3.5-.7-.38.12-.76.22-1.13.32zm-3.09.64c.21.12.5.11.73.16.7.05 1.42.03 2.12-.09-.14-.2-.4-.2-.62-.26a3.94 3.94 0 0 0-2.23.19zm-.33.65c.46.49 1.19.54 1.81.57.7-.02 1.47-.15 2-.63a7.26 7.26 0 0 1-3.8.06z" fill="#3737fd"/>
+<path d="M8.41.5a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5.5.5 0 0 1 .5.5zM7.9 15.28l-6.25-3.56V8.51l6.26 3.56z" fill="#3737fd"/>
+<path d="m7.9 15.28 6.26-3.56V8.51l-6.25 3.56z" fill="#cc001b"/>
+</svg>
diff --git a/icons/plat/ps1.svg b/icons/plat/ps1.svg
new file mode 100644
index 00000000..99780486
--- /dev/null
+++ b/icons/plat/ps1.svg
@@ -0,0 +1,10 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m8.97 3.72-.01 11.6-3.11-1V.66l3.97 1.05c2.54.69 4.1 2.01 4.07 4.34-.03 2.7-1.28 3.8-3.72 3.08V3.81c0-.65-1.2-.68-1.2-.09z" fill="#de0029"/>
+<path d="m4.42 12.06-1.47.5c-.95.33-1.76-.45-.88-.76l.7-.25-2.66-.85c-.82.28-1.6.87-1.55 1.7.06.85 1.99 1.05 3.48 1.3a7.6 7.6 0 0 0 3.8-.3v-.88zm4.56 3.27 2.72-.95-2.74-.87v1.75z" fill="#f3c202"/>
+<path d="m16.21 12.79.05-.02c1.19-.41 1.7-1 1.57-1.54-.2-.91-1.66-1.4-3.9-1.57a12 12 0 0 0-4.72.77l-.25.1 2.76.85 1.62-.54c1.7-.32 2.38.24.75.76l-.81.27zM5.85 8.77l-1.23.41 1.23.38z" fill="#326db3"/>
+<path d="m11.7 14.38 4.51-1.6-2.93-.9-4.33 1.47v.16zm-5.85-2.8-1.43.48 1.43.46zm3.1.75v-1.8l2.77.85zm-6.18-.78 3.07-1.1v-.89l-1.22-.38-4.45 1.5-.06.02z" fill="#00aa9e"/>
+<path d="m8.97 3.72-.01 11.6-3.11-1V.66l3.97 1.05c2.54.69 4.1 2.01 4.07 4.34-.03 2.7-1.28 3.8-3.72 3.08V3.81c0-.65-1.2-.68-1.2-.09z" fill="#cc0026"/>
+<path d="m4.42 12.06-1.47.5c-.95.33-1.76-.45-.88-.76l.7-.25-2.66-.85c-.82.28-1.6.87-1.55 1.7.06.85 1.99 1.05 3.48 1.3a7.6 7.6 0 0 0 3.8-.3v-.88zm4.56 3.27 2.72-.95-2.74-.87v1.75z" fill="#caa202"/>
+<path d="m16.21 12.79.05-.02c1.19-.41 1.7-1 1.57-1.54-.2-.91-1.66-1.4-3.9-1.57a12 12 0 0 0-4.72.77l-.25.1 2.76.85 1.62-.54c1.7-.32 2.38.24.75.76l-.81.27zM5.85 8.77l-1.23.41 1.23.38z" fill="#2d639f"/>
+<path d="m11.7 14.38 4.51-1.6-2.93-.9-4.33 1.47v.16zm-5.85-2.8-1.43.48 1.43.46zm3.1.75v-1.8l2.77.85zm-6.18-.78 3.07-1.1v-.89l-1.22-.38-4.45 1.5-.06.02z" fill="#00ccbe"/>
+</svg>
diff --git a/icons/plat/ps2.svg b/icons/plat/ps2.svg
new file mode 100644
index 00000000..40b9e7e4
--- /dev/null
+++ b/icons/plat/ps2.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12h1V9h3V5H0v1h3v2H0Zm8-6h2V5H7v6H4v1h4zm8-1h-5v1h4v2h-4v4h5v-1h-4V9h4z" fill="#7c72b6"/>
+</svg>
diff --git a/icons/plat/ps3.svg b/icons/plat/ps3.svg
new file mode 100644
index 00000000..ac763eec
--- /dev/null
+++ b/icons/plat/ps3.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 5h4v1h1v5h-1v1h-4v-1h4V9h-4V8h4V6h-4ZM0 12h1V9h3V6H3V5H0v1h3v2H0Zm8-6h2V5H8v1H7v5H4v1h4z" fill="#999"/>
+</svg>
diff --git a/icons/plat/ps4.svg b/icons/plat/ps4.svg
new file mode 100644
index 00000000..867244fb
--- /dev/null
+++ b/icons/plat/ps4.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9 11h5v1h1v-1h1v-1h-1V5h-1l-5 5Zm5-5v4h-4zM0 12h1V9h3V6H3V5H0v1h3v2H0Zm8-6h2V5H8v1H7v5H4v1h4Z" fill="#0185cf"/>
+</svg>
diff --git a/icons/plat/ps5.svg b/icons/plat/ps5.svg
new file mode 100644
index 00000000..81d14366
--- /dev/null
+++ b/icons/plat/ps5.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.2 12.27c-1.26-.35-1.47-1.08-.9-1.5a6.18 6.18 0 0 1 1.44-.68l3.74-1.3v1.48l-2.7.95c-.47.17-.54.4-.15.53.38.12 1.08.09 1.55-.08l1.3-.46v1.33l-.26.04a8.6 8.6 0 0 1-4.02-.31zm7.88.15 4.2-1.46c.47-.17.54-.4.16-.53a2.88 2.88 0 0 0-1.56.08l-2.8.97V9.94l.16-.06c.64-.2 1.29-.33 1.95-.4a8.45 8.45 0 0 1 3.61.42c1.23.38 1.37.95 1.06 1.33-.31.39-1.08.66-1.08.66l-5.7 2.01zm.5-9.53c2.2.75 2.95 1.68 2.95 3.77 0 2.04-1.28 2.81-2.9 2.04V4.9c0-.45-.09-.86-.51-.98-.33-.1-.53.2-.53.65v9.5l-2.6-.8V1.91c1.1.2 2.71.68 3.58.97z" fill="#999"/>
+</svg>
diff --git a/icons/plat/psp.svg b/icons/plat/psp.svg
new file mode 100644
index 00000000..808669c3
--- /dev/null
+++ b/icons/plat/psp.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12h1V9h3V5H0v1h3v2H0Zm8-6h2V5H7v6H4v1h4zm4 6h1V9h3V5h-4v1h3v2h-3z" fill="#dedede"/>
+</svg>
diff --git a/icons/plat/psv.svg b/icons/plat/psv.svg
new file mode 100644
index 00000000..a7b919d2
--- /dev/null
+++ b/icons/plat/psv.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12h1V9h3V6H3V5H0v1h3v2H0zm8-6h2V5H8v1H7v5H4v1h4zm2-1 2.5 7h1L16 5h-1l-2 6-2-6Z" fill="#33b4ff"/>
+</svg>
diff --git a/icons/plat/sat.svg b/icons/plat/sat.svg
new file mode 100644
index 00000000..a0a821f3
--- /dev/null
+++ b/icons/plat/sat.svg
@@ -0,0 +1,18 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<radialGradient id="a" cx="113.5" cy="35.63" r="47.61" gradientTransform="translate(-1.84 -.35) scale(.08452)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#fff" offset=".04"/>
+<stop stop-color="#6E93E1" offset=".23"/>
+<stop stop-color="#6867cb" offset="1"/>
+</radialGradient>
+</defs>
+<g transform="translate(0 2.78) scale(1.14286)">
+<path d="M9.49 1.76a13.88 13.88 0 0 1 2.67-.09.52.52 0 0 0 .46-.85.49.49 0 0 0-.35-.18A9.58 9.58 0 0 0 10.4.62c-.44.03-.9.08-1.38.15-.22.03.16 1.02.47.99z" fill="#ccc"/>
+<path d="M9.49 1.76a13.88 13.88 0 0 1 2.67-.09.52.52 0 0 0 .46-.85.49.49 0 0 0-.35-.18A9.58 9.58 0 0 0 10.4.62c-.44.03-.9.08-1.38.15-.22.03.16 1.02.47.99z" fill="#717171"/>
+<path d="M12.28 3.57h.29v.01c.2.12.3.24.3.38-.06.22-.28.47-.63.74-.42.3-1 .63-1.7.96-1.8.86-1.46 1.76.44.95a10.5 10.5 0 0 0 1.9-1.09c.6-.47.95-.91 1.01-1.34a1.1 1.1 0 0 0-.3-1.11c-.12-.13-1.37.5-1.31.5zM4.33 7.46l-1.55.14h-.01c-.39 0-.68-.03-.87-.1a.47.47 0 0 0-.4.02.48.48 0 0 0-.27.29.5.5 0 0 0 .01.4c.06.13.16.22.29.28.28.1.69.15 1.26.15.47 0 1.03-.05 1.68-.15l.33-.04Z" fill="#252525" stroke-width=".12" stroke="#000"/>
+<path d="M2.52 2.78h.01a7.5 7.5 0 0 0-1.38.86 3.1 3.1 0 0 0-.9.98C.02 5.08 0 5.49.2 5.87c.12.38.5.58 1.13.61.37.03.86-.02 1.49-.14l1.08-.27c.24-.06-.05-1.07-.29-1-.37.1-.7.18-1 .24-.51.1-.91.14-1.22.13l-.24-.01-.01-.01a.37.37 0 0 1 .04-.3c.12-.2.32-.42.61-.65.32-.25.73-.5 1.21-.75 2.87-1.13 3.02-2.56-.48-.93Z" fill="#f1f1f1" stroke-width=".12" stroke="#000"/>
+<path d="m1.15 5.42-.01-.01a.37.37 0 0 1 .04-.3c.12-.2.32-.42.61-.65A14.3 14.3 0 0 1 3 3.71l.55-.85s-1.08.51-1.23.6C.12 4.93.79 5.22.72 6.31c.35.1.2.16 1.15.01.08-.01-.78-.8-.72-.9Z" fill="#2f2f2f"/>
+<circle cx="7.03" cy="4.57" fill="url(#a)" stroke="#33317a" stroke-width=".24" r="4.44"/>
+<path d="M2.57 5.31c-.05.01-1.4.3-1.62.08-.06-.06.15.1.36.38.02.03 0 .09-.03.16l-.02.04-.01.05c0 .03-.02.05-.03.07 0 .08-.05.14-.06.19l.02.05c0 .01-.04.02-.03.03l-.04.03v.01l.1.02c.02 0 .14.02.3.02a5.03 5.03 0 0 0 .92-.07l.37-.05c.09-.02.13 0 .14 0a31.21 31.21 0 0 0 3.8-1.12l2.08-.68a49.25 49.25 0 0 1 2.57-.77c.26-.07.49-.11.68-.14.24-.03.41-.04.5-.02.07.05.4.01.55.26.26.4.4 1.1.5.96.15-.21.24-.41.27-.61.16-.58-.1-1.05-.77-1.47-.2-.17-.6-.21-1.19-.13-.24.03-.52.09-.85.17-.2.05-.44.09-.7.16a39.4 39.4 0 0 0-1.9.6l-2.07.67-1.52.5z" fill="#b9b9b9" stroke-width=".12" stroke="#000"/>
+</g>
+</svg>
diff --git a/icons/plat/scd.svg b/icons/plat/scd.svg
new file mode 100644
index 00000000..48dafdbe
--- /dev/null
+++ b/icons/plat/scd.svg
@@ -0,0 +1,8 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 .95h16v14.1H0z"/>
+<path d="m5.7 12.33 2.64-2.6.02-.06-2.66 2 2.7-2.8-2.7 2.06 2.6-2.74-2.6 2.11 2.5-2.8c-.9-.76-3.03-.1-4.88 1.56-1.94 1.73-2.84 3.88-2.01 4.8.82.93 3.06.28 5-1.45.52-.47.97-.97 1.33-1.47z" fill="#609ad2"/>
+<path d="m4.52 10.08-.9-.01v2.28h.9v-.63h.89v1.39H3.18c-.22 0-.43-.22-.43-.43V9.75c0-.31.15-.43.42-.43h2.24v1.51h-.89Z" fill="#080808"/>
+<path d="m10.3 6.62-2.64 2.6-.02.06 2.66-2-2.7 2.8 2.7-2.06-2.6 2.74 2.6-2.11-2.5 2.8c.9.76 3.03.1 4.89-1.56 1.93-1.73 2.83-3.88 2-4.8-.82-.93-3.06-.28-5 1.45-.52.47-.97.97-1.33 1.47z" fill="#e4bb4e"/>
+<path d="M13.31 9.23c0 .22-.22.43-.43.43h-2.23v-3.8h2.23c.22.03.43.22.43.44zm-1.77-.33h.9V6.62h-.9Z"/>
+<path d="M2.93 2.17c.07-.1.18-.22.31-.21h.8l-.01.26c.1-.12.22-.27.4-.26h1.1v2.13h-.75V2.57c-.14-.02-.2.12-.27.21-.26.44-.55.87-.8 1.32h-.78c.31-.53.64-1.04.96-1.56-.16.01-.37-.04-.49.1L2.49 4.1H1.7l1.22-1.93zm2.91.05c.1-.13.3-.28.49-.26h1.63v.47l-1.22.01c-.15.06-.18.25-.28.36H8l-.3.42H6.47v.41h1.5v.47H5.7V2.8a.92.92 0 0 1 .15-.58zm2.46-.07c.07-.09.26-.2.39-.2h1.76v.49H9.3c-.26-.02-.37.27-.45.47 0 .32-.06.32-.02.72.43.03.53.02 1.07 0 .03-.1.01-.22.02-.34-.19-.03-.15-.02-.5-.03l.25-.46h.92v1.07c0 .08-.03.14-.05.21-.7.03-1.4.01-2.1.02-.2.03-.32-.17-.3-.33V2.6c0-.18.07-.3.17-.45zm2.49-.19h1.19c.2-.03.36.15.46.31l1.14 1.83h-.81c-.1-.14-.18-.3-.32-.4h-.72a5.72 5.72 0 0 1-.2-.35v.75h-.74V1.95zm.75.46v.8h.65c-.14-.2-.26-.44-.4-.65a.37.37 0 0 0-.25-.15z" fill="#fff"/>
+</svg>
diff --git a/icons/plat/sfc.svg b/icons/plat/sfc.svg
new file mode 100644
index 00000000..c39ae480
--- /dev/null
+++ b/icons/plat/sfc.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 9.05c0-.44.34-.78.68-1a4.63 4.63 0 0 1 2.24-.61c.93-.03 1.9.1 2.71.57.4.23.8.63.75 1.12-.07.53-.54.87-.99 1.09a5.6 5.6 0 0 1-3.07.38 3.55 3.55 0 0 1-2-.85 1.02 1.02 0 0 1-.32-.7Z" fill="#5f9933"/>
+<path d="M5.9 12.04c0-.46.36-.82.73-1.06a5 5 0 0 1 2.46-.64c.97 0 1.97.14 2.8.65.41.25.8.7.7 1.22-.13.56-.65.9-1.14 1.11-1.05.44-2.24.5-3.35.31a3.4 3.4 0 0 1-1.94-.93 1.03 1.03 0 0 1-.26-.66Z" fill="#c1bc0b"/>
+<path d="M7.27 8.32a3.04 3.04 0 0 0 2.54-1.58c.56-1 .48-2.31-.2-3.23a3.04 3.04 0 0 0-2.99-1.2 3.04 3.04 0 0 0-2.44 2.38l-.12.57h.26a3.04 3.04 0 0 1 2.95 3.06" fill="#1489b8"/>
+<path d="M13.17 11.31a2.96 2.96 0 0 0 2.48-1.55c.55-1 .44-2.32-.28-3.22A2.96 2.96 0 0 0 12.4 5.5a2.96 2.96 0 0 0-2.25 2.43c-.03.12-.06.4-.06.4l.3.01a2.96 2.96 0 0 1 2.79 2.98z" fill="#bd0f14"/>
+</svg>
diff --git a/icons/plat/smd.svg b/icons/plat/smd.svg
new file mode 100644
index 00000000..483e4ebd
--- /dev/null
+++ b/icons/plat/smd.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 3.64h16v8.72H0z"/>
+<path d="M5.06 4.67v6.58H1.02z" fill="#c00"/>
+<path d="M8.44 4.67v6.58H4.4z" fill="#c00"/>
+<path fill="#0c0" d="M9.34 4.8h1.95v6.5H9.34zm2.36 0h-.06l-.02 2.23.1-.01.42-2.18c-.16-.03-.31-.03-.43-.03zm.76.07-.58 2.16c.05 0 .1.02.15.04L12.98 5a2.7 2.7 0 0 0-.52-.14zm.83.29-1.13 1.96.11.07 1.45-1.75c-.13-.1-.28-.2-.43-.28zm.7.53-1.62 1.58c.04.04.08.08.1.12l1.92-1.21a3.17 3.17 0 0 0-.4-.5zm.55.74L12.55 7.5l.06.12 2.18-.57a3.38 3.38 0 0 0-.25-.62zm.34.95-2.23.35.05.24h2.24c0-.2-.03-.4-.06-.6zm-2.18.78c-.05.53-.46.94-.95.94h-.12v-.05 2.25H12c1.59 0 2.88-1.4 2.93-3.14z"/>
+</svg>
diff --git a/icons/plat/swi.svg b/icons/plat/swi.svg
new file mode 100644
index 00000000..6c9b9329
--- /dev/null
+++ b/icons/plat/swi.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<rect x="2.1" y="2.09" width="4.52" height="11.78" ry="0" fill="#cc0010"/>
+<path d="M4.25 3.9c-.19.04-.46.18-.61.3-.3.27-.46.64-.43 1.08 0 .23.02.3.11.48.14.28.35.49.63.63.2.1.24.1.5.11.22.01.3 0 .46-.05.63-.21 1-.82.9-1.45a1.33 1.33 0 0 0-1.56-1.1zM9.2 1.02A370 370 0 0 0 9.17 8c0 6.33 0 6.97.05 6.98.08.03 2.33.02 2.6 0a3.62 3.62 0 0 0 3-2.43c.19-.54.18-.4.18-4.56 0-3.33 0-3.82-.05-4.04A3.6 3.6 0 0 0 12 1.05C11.8 1 11.5 1 10.48 1c-.7 0-1.28 0-1.29.02z" fill="#818990"/>
+<rect x="10.63" y="7.41" width="2.56" height="2.67" fill="#cc0010" ry="1.28"/>
+<path d="M4 1.05a3.62 3.62 0 0 0-2.87 2.63C1 4.18.99 4.46 1 8.26c0 3.5 0 3.57.06 3.84a3.63 3.63 0 0 0 2.83 2.83c.19.04.43.05 2 .06 1.62.01 1.8.01 1.83-.03.05-.05.05-.6.05-6.95 0-4.7 0-6.92-.03-6.96C7.72 1 7.67 1 5.97 1 4.6 1 4.18 1.01 4 1.05zM6.62 8v5.88l-1.18-.02a6.1 6.1 0 0 1-1.42-.07 2.46 2.46 0 0 1-1.82-1.9c-.06-.29-.06-7.5 0-7.79.17-.81.74-1.49 1.5-1.8.38-.15.56-.16 1.8-.17h1.12z" fill="#818990"/>
+</svg>
diff --git a/icons/plat/tdo.svg b/icons/plat/tdo.svg
new file mode 100644
index 00000000..0b918ebb
--- /dev/null
+++ b/icons/plat/tdo.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M4 15.55c0-.25 1.79-.45 4-.45 2.2 0 4 .2 4 .45S10.21 16 8 16s-4-.2-4-.45z" fill="#888"/>
+<path d="M4.6 11.3c0-1.2 1.52-2.18 3.4-2.18s3.4.98 3.4 2.19c0 1.2-1.52 2.18-3.4 2.18s-3.4-.98-3.4-2.18z" fill="#e5ca00"/>
+<path d="M4.83 5.6c-.5.22-.63 1.46-.63 1.46s.13 1.25.63 1.46c.5.21 3.17.34 3.17.34s2.67-.13 3.17-.34c.5-.21.63-1.46.63-1.46s-.13-1.24-.63-1.46C10.67 5.4 8 5.26 8 5.26s-2.67.13-3.17.34z" fill="#77f"/>
+<path fill="#c00" d="M4.02 2.56 8 0l3.98 2.56L8 5.12z"/>
+</svg>
diff --git a/icons/plat/vnd.svg b/icons/plat/vnd.svg
new file mode 100644
index 00000000..108fc3bb
--- /dev/null
+++ b/icons/plat/vnd.svg
@@ -0,0 +1,5 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.14 2h9.71c.63 0 1.13.5 1.13 1.14v9.71c0 .63-.5 1.13-1.13 1.13H3.14c-.63 0-1.14-.5-1.14-1.13V3.14C2 2.5 2.5 2 3.14 2z" fill="#303030" stroke="#bfbfbf" stroke-width="2.08" paint-order="stroke fill markers"/>
+<path d="m2.5 2 2.3 6.01h.83l2.42-6.04H6.8L5.3 6.43 3.66 2z" fill="#bfbfbf"/>
+<path d="M7.94 2v5.94h.9v-4.5l3.03 4.53h.87V2h-.87v4.4L8.84 2zM6.8 8.01H3.06v5.97h3.78v-.71h.87V8.8h-.9zm-2.76.76h1.9v4.53h-1.9zm8.73.04v1.13h-1.73V8.81h-1.6v1.81h3.33v2.65h-.94v.75H8.69v-.75h-.98v-1.33h1.74v1.33h2.38v-1.33h-.8v-.6H8.7v-.68h-.98V8.84h.98v-.79h3.14v.76z" fill="#bfbfbf"/>
+</svg>
diff --git a/icons/plat/web.svg b/icons/plat/web.svg
new file mode 100644
index 00000000..d7064070
--- /dev/null
+++ b/icons/plat/web.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m.75 8.2 1.08.9c.47-.72 1-1.38 1.55-1.98a1.42 1.42 0 0 1-.12-1.01c-.59-.38-1.2-.8-1.82-1.28A7.24 7.24 0 0 0 .72 8v.23l.03-.04zm1.04-4c.63.47 1.23.9 1.81 1.28a1.42 1.42 0 0 1 1.91-.16 11.45 11.45 0 0 1 2.92-1.4 1.57 1.57 0 0 1 .12-.78c-.87-.74-1.9-1.4-3.09-1.97A7.3 7.3 0 0 0 1.79 4.2zM6.46.88c.94.5 1.77 1.06 2.5 1.67.25-.21.56-.35.9-.38l.22-1.15A7.3 7.3 0 0 0 6.46.88zm4.3.38c-.05.34-.12.67-.18 1 .47.18.84.58.98 1.08.64-.05 1.31-.06 2.01-.03a7.42 7.42 0 0 0-2.8-2.05zm3.37 2.8a17 17 0 0 0-2.55 0 1.6 1.6 0 0 1-.49.87 9.68 9.68 0 0 1 1.2 2.62h.14c.64 0 1.18.42 1.36 1a51 51 0 0 0 1.47.03c.02-.2.02-.39.02-.58 0-1.45-.42-2.8-1.15-3.94zm1.04 5.23c-.46 0-.9-.01-1.34-.03-.12.56-.56 1-1.11 1.11.02.67 0 1.36-.07 2.1.4-.05.82-.11 1.24-.19a7.25 7.25 0 0 0 1.28-2.99zm-.87 3.64A7.97 7.97 0 0 1 8 16a7.97 7.97 0 0 1-8-8 7.97 7.97 0 0 1 8-8 7.97 7.97 0 0 1 8 8 8 8 0 0 1-1.7 4.93zm-1.12.19-.62.07-.09.56c.24-.19.47-.39.68-.6l.03-.03zm-1.54 1.19c.08-.37.14-.72.19-1.07-1.3.07-2.55-.02-3.76-.28a1.28 1.28 0 0 1-1.07.46l-.65 1.68a7.24 7.24 0 0 0 5.3-.79zm-5.98.6c.22-.6.44-1.17.67-1.74-.28-.2-.48-.53-.52-.9a16.95 16.95 0 0 1-3.79-2.13c-.2.34-.39.69-.57 1.04a7.31 7.31 0 0 0 4.21 3.72zm-4.56-4.6.36-.61-.64-.52c.07.39.16.77.28 1.13zm4.86-4.44a1.42 1.42 0 0 1 .07.97c.78.37 1.55.67 2.33.9.28-.85.56-1.73.81-2.65a1.6 1.6 0 0 1-.5-.5 10.94 10.94 0 0 0-2.7 1.28zm-.3 1.58a1.42 1.42 0 0 1-1.78.19c-.53.57-1.02 1.2-1.47 1.88a16.4 16.4 0 0 0 3.54 2.03 1.29 1.29 0 0 1 1.29-.7c.3-.78.6-1.59.89-2.43-.83-.25-1.64-.57-2.47-.97zm4.81-2.18a1.6 1.6 0 0 1-.62.06c-.25.9-.52 1.77-.8 2.6.69.18 1.38.3 2.11.4.1-.21.26-.4.46-.53a9.2 9.2 0 0 0-1.15-2.53zM12 10.33a1.43 1.43 0 0 1-1-1.29 17.3 17.3 0 0 1-2.19-.42c-.3.87-.6 1.7-.92 2.51a1.28 1.28 0 0 1 .47 1.16c1.14.23 2.32.3 3.56.23.08-.77.1-1.5.08-2.19z" fill="#5f57ff"/>
+</svg>
diff --git a/icons/plat/wii.svg b/icons/plat/wii.svg
new file mode 100644
index 00000000..3f203b89
--- /dev/null
+++ b/icons/plat/wii.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.74 4.6 7.4 9.88S6.36 5.9 6.2 5.35c-.17-.57-.51-.81-1-.81-.5 0-.84.24-1.01.8-.17.57-1.2 4.55-1.2 4.55l-1.35-5.3H0s1.56 5.65 1.78 6.3c.16.52.55.94 1.13.94.67 0 .98-.49 1.12-.93.14-.44 1.16-4.18 1.16-4.18s1 3.74 1.15 4.18c.14.44.45.93 1.12.93.58 0 .97-.42 1.14-.93.2-.66 1.77-6.3 1.77-6.3zm5.58 7.17h1.54V6.8h-1.54zm-.15-6.73c0 .48.41.87.9.87.52 0 .93-.38.93-.87 0-.48-.4-.87-.92-.87-.5 0-.9.4-.9.87zm-2.98 6.73h1.54V6.8H11.2Zm-.15-6.73c0 .48.4.87.9.87.52 0 .93-.38.93-.87 0-.48-.4-.87-.93-.87-.5 0-.9.4-.9.87z" fill="#b1b1b4"/>
+</svg>
diff --git a/icons/plat/win.svg b/icons/plat/win.svg
new file mode 100644
index 00000000..c14beaf9
--- /dev/null
+++ b/icons/plat/win.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.92 7.12c1.9-.88 3.87-.73 5.92.25l1.6-5.64C7.52.78 5.57.36 3.47 1.54Z" fill="#cc3000"/>
+<path d="M9.93 1.87c2.7 1.23 4.62.8 6.07.4l-1.55 5.4c-2.81 1.37-4.49.5-6.13-.12z" fill="#90c200"/>
+<path d="M8.2 8.06c1.8.76 3.7 1.24 6.08.26l-1.93 6.16c-2.27 1.14-4 .75-5.94-.15z" fill="#cc9f00"/>
+<path d="M0 13.93c1.92-1 3.9-.78 5.92.22L7.7 7.86c-.82-.37-2.7-1.4-5.98-.07z" fill="#028bca"/>
+</svg>
diff --git a/icons/plat/wiu.svg b/icons/plat/wiu.svg
new file mode 100644
index 00000000..8490868c
--- /dev/null
+++ b/icons/plat/wiu.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<g fill="#009bc8">
+<path d="M6.14 7.46c0 3.15 3.72 2.7 3.72.5V2.74H6.14Z"/>
+<path d="M1 10.78c0 1.55 1.08 2.48 2.65 2.48h8.95c1.33 0 2.4-.92 2.4-2.24V4.48c0-.8-.59-1.74-1.32-1.74H11.6v5.47c0 4.09-7.13 4.06-7.13.08V2.74H2.9C1.88 2.74 1 3.39 1 4.4z"/>
+</g>
+</svg>
diff --git a/icons/plat/x1s.svg b/icons/plat/x1s.svg
new file mode 100644
index 00000000..e724053d
--- /dev/null
+++ b/icons/plat/x1s.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.8 3.42 9.88 0h5.3zM10.7.5l1.2 2.13L14 .49zM8.94 0H3.08l4.41 7.6h5.82m-6.71 0H.82l3.65-3.58zm-4.58-.5h3.72L4.37 4.8zM2 16h5.8l4.36-7.6H4.92l-1.68 1.77h2.04" fill="#b2b2b2"/>
+</svg>
diff --git a/icons/plat/x68.svg b/icons/plat/x68.svg
new file mode 100644
index 00000000..97f22e3c
--- /dev/null
+++ b/icons/plat/x68.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m2.16 3.69 5.03 8.62H14L8.98 3.7zm1.79 4.62-3.95 4h6.29zm8.1-.62 3.95-4H9.71l2.34 4" fill="#c00"/>
+</svg>
diff --git a/icons/plat/xb1.svg b/icons/plat/xb1.svg
new file mode 100644
index 00000000..94fec9e4
--- /dev/null
+++ b/icons/plat/xb1.svg
@@ -0,0 +1,65 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<linearGradient id="c" x2="1" gradientTransform="scale(179.49 -179.49) rotate(-51 -2.04 -2.71)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#9dff00" offset=".5"/>
+<stop stop-color="#458c41" offset="1"/>
+</linearGradient>
+<linearGradient id="d" x2="1" gradientTransform="scale(247.4 -247.4) rotate(56 3.22 -.6)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#4a7d62" offset="0"/>
+<stop offset="1"/>
+</linearGradient>
+<linearGradient id="e" x2="1" gradientTransform="scale(266.72 -266.72) rotate(-34 -1.53 -1.47)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#9dff00" offset=".42"/>
+<stop stop-color="#5c5c5c" offset="1"/>
+</linearGradient>
+<linearGradient id="f" x2="1" gradientTransform="scale(358.07 -358.07) rotate(-60 -.29 -.65)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#4a7d62" offset="0"/>
+<stop stop-color="#182920" offset="1"/>
+</linearGradient>
+<linearGradient id="g" x2="1" gradientTransform="scale(-171.77 171.77) rotate(44 -5.21 -2.83)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#9dff00" offset=".48"/>
+<stop stop-color="#4a4a4a" offset="1"/>
+</linearGradient>
+<linearGradient id="h" x2="1" gradientTransform="scale(161.685 -161.685) rotate(-83 -.25 -3.52)" gradientUnits="userSpaceOnUse">
+<stop offset="0"/>
+<stop stop-color="#45755c" offset=".87"/>
+<stop stop-color="#45755c" offset="1"/>
+</linearGradient>
+<linearGradient id="i" x2="1" gradientTransform="scale(135.836 -135.836) rotate(-65 .22 -4.28)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#aeff00" offset=".42"/>
+<stop stop-color="#575757" offset="1"/>
+</linearGradient>
+<linearGradient id="j" x2="1" gradientTransform="scale(-320.164 320.164) rotate(80 -1.36 -.84)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#4a7d62" offset="0"/>
+<stop offset=".83"/>
+<stop offset="1"/>
+</linearGradient>
+<radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="matrix(207.43 -21.51 -19.788 -190.88 415.48 501.29)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#fff" offset="0"/>
+<stop offset=".96"/>
+<stop offset="1"/>
+</radialGradient>
+<radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="matrix(-59.414 -151.8 -183.86 71.965 430.59 518.58)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#fff" offset="0"/>
+<stop offset=".96"/>
+<stop offset="1"/>
+</radialGradient>
+</defs>
+<path d="M0 14.78h16V1.22H0z"/>
+<g transform="matrix(.02503 0 0 -.02503 -2.5 19.48)">
+<path d="M417.09 553.03 242.8 636.76s175.19 135.82 336.52-5.99z" fill="url(#a)"/>
+<path d="m481.12 501.93 114.42 108s101.09-174.09-67.99-254.9z" fill="url(#b)" />
+<path d="M344.64 507.72v-125.5a1488.22 1488.22 0 0 1 72.24 64.1v108.35s-131.23 43.75-214.07 95.92c34.13-38.85 90.55-100.24 141.83-142.88" fill="url(#c)"/>
+<path d="M202.81 650.6c82.85-52.17 214.07-95.92 214.07-95.92v14.45s-170.67 56.89-245.62 118.3c0 0 12.19-14.79 31.55-36.83" fill="url(#d)"/>
+<path d="M176.8 256.1c42.3 28.87 104.91 73.64 167.84 126.12v102.02S258.94 354.18 176.8 256.1" fill="url(#e)" />
+<path d="M124.31 221.47s20.41 12.74 52.5 34.64c82.14 98.07 167.83 228.14 167.83 228.14v23.47S217.32 314.48 124.31 221.46" fill="url(#f)" />
+<path d="M416.89 554.68V446.32a1488.71 1488.71 0 0 1 72.24-64.1v125.5c51.28 42.65 107.71 104.03 141.84 142.89-82.85-52.18-214.08-95.92-214.08-95.92" fill="url(#g)" />
+<path d="M416.89 569.13v-14.45s131.23 43.75 214.08 95.92a1687.36 1687.36 0 0 1 31.54 36.83c-74.95-61.41-245.62-118.3-245.62-118.3" fill="url(#h)" />
+<path d="M489.13 382.23c62.93-52.48 125.54-97.26 167.84-126.12-82.14 98.08-167.84 228.14-167.84 228.14Z" fill="url(#i)" />
+<path d="M489.13 484.25s85.7-130.06 167.84-228.14c32.08-21.9 52.5-34.64 52.5-34.64-93.02 93.02-220.35 286.26-220.35 286.26z" fill="url(#j)" />
+</g>
+</svg>
diff --git a/icons/plat/xb3.svg b/icons/plat/xb3.svg
new file mode 100644
index 00000000..27afd4a9
--- /dev/null
+++ b/icons/plat/xb3.svg
@@ -0,0 +1,37 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<linearGradient id="d" gradientUnits="userSpaceOnUse" x1="10.08" y1="9.83" x2="11.87" y2="8.38">
+<stop offset="0" stop-color="#e6edae"/>
+<stop offset="1" stop-color="#359644"/>
+</linearGradient>
+<linearGradient id="c" gradientUnits="userSpaceOnUse" gradientTransform="matrix(.41805 0 0 .41527 -1.2 .47)" x1="17.96" y1="19.43" x2="15" y2="17.36">
+<stop stop-color="#e5edae" offset="0"/>
+<stop stop-color="#249b47" offset="1"/>
+</linearGradient>
+<linearGradient id="b" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-.41668 0 0 .41565 17.22 .47)" x1="20.3" y1="15.11" x2="22.04" y2="7.83">
+<stop stop-color="#e6eead" offset="0"/>
+<stop stop-color="#46873f" offset="1"/>
+</linearGradient>
+<linearGradient id="a" gradientUnits="userSpaceOnUse" gradientTransform="matrix(.41805 0 0 .41527 -1.24 .47)" x1="21.34" y1="15.45" x2="22.08" y2="7.5">
+<stop stop-color="#e6edae" offset="0"/>
+<stop stop-color="#459743" offset="1"/>
+</linearGradient>
+<radialGradient id="e" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 1.8458 -1.5756 0 41.66 -29.48)" cx="23.69" cy="12.77" r="14.35">
+<stop stop-color="#fff" offset="0"/>
+<stop stop-color="#fff" stop-opacity="0" offset="1"/>
+</radialGradient>
+<radialGradient id="f" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 1.1525 -.98381 0 34.1 -13.06)" cx="23.69" cy="12.77" r="14.35">
+<stop stop-color="#fff" offset="0"/>
+<stop stop-color="#666" stop-opacity="0" offset="1"/>
+</radialGradient>
+</defs>
+<g transform="matrix(.4878 0 0 .46892 -3.38 -.23)">
+<ellipse cx="23.33" cy="17.39" rx="14.35" ry="14.76" fill="#666"/>
+<ellipse cx="23.33" cy="17.39" rx="14.35" ry="14.76" fill="url(#e)"/>
+<ellipse cx="23.33" cy="17.39" rx="14.35" ry="14.76" fill="url(#f)"/>
+</g>
+<path d="M6.23 4.89c-.77.94-4.01 4.74-3.72 7.32.2.25.42.49.65.71-.03-1.97 2.28-4.08 4.08-5.67Z" fill="url(#c)"/>
+<path d="M4.34 2.03c-.4.24-.78.53-1.13.84a5.3 5.3 0 0 1 2.84 2c.53.83.82 1.6 1.2 2.38l.79-.66V3.18a6.89 6.89 0 0 0-3.7-1.15z" fill="url(#a)"/>
+<path d="M9.78 4.89c.77.94 4 4.74 3.72 7.32-.2.25-.42.49-.65.71.03-1.97-2.28-4.08-4.09-5.67z" fill="url(#d)"/>
+<path d="M11.66 2.02c.4.25.78.53 1.12.85a5.29 5.29 0 0 0-2.82 2c-.54.83-.82 1.6-1.2 2.38l-.79-.66V3.17a6.85 6.85 0 0 1 3.68-1.15h.01z" fill="url(#b)"/>
+</svg>
diff --git a/icons/plat/xbo.svg b/icons/plat/xbo.svg
new file mode 100644
index 00000000..e1b7f6c7
--- /dev/null
+++ b/icons/plat/xbo.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.7 14.97a6.99 6.99 0 0 0 3.1-1.1c.8-.51.97-.72.97-1.14 0-.85-.93-2.33-2.52-4.01-.9-.96-2.16-2.08-2.3-2.05-.26.05-2.37 2.12-3.16 3.09-1.26 1.53-1.83 2.79-1.54 3.35.22.42 1.6 1.25 2.61 1.57.84.27 1.94.38 2.84.3zm5.13-3.12a7.3 7.3 0 0 0 1.14-3.42c.06-.47.04-.74-.12-1.7a6.97 6.97 0 0 0-1.7-3.46c-.35-.36-.38-.37-.8-.23-.53.18-1.08.56-1.94 1.34l-.5.46.27.33a21.72 21.72 0 0 1 3.12 5.15c.28.75.39 1.5.27 1.8-.08.22 0 .14.26-.27zm-11.46.17c-.07-.32.01-.9.2-1.48.42-1.26 1.8-3.61 3.08-5.2l.4-.51-.44-.4a9.14 9.14 0 0 0-1.38-1.1 2.7 2.7 0 0 0-1.02-.38c-.12 0-.57.46-.93.96a7.32 7.32 0 0 0-1.17 2.72c-.14.64-.15 2-.02 2.63.1.53.31 1.2.52 1.66.16.35.55 1.01.72 1.23.09.11.09.11.04-.13zM8.58 2.7c.59-.3 1.5-.62 2-.7.17-.04.47-.05.66-.04.41.02.4 0-.27-.32a6.8 6.8 0 0 0-4.34-.54c-.74.15-1.62.48-2.11.78l-.15.1.34-.02c.67-.04 1.64.23 2.69.74.32.16.59.28.61.28l.57-.28z" fill="#1daf1e"/>
+</svg>
diff --git a/icons/plat/xxs.svg b/icons/plat/xxs.svg
new file mode 100644
index 00000000..72d558ac
--- /dev/null
+++ b/icons/plat/xxs.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 4.07h16v7.86H0Z" fill="#0f7a10"/>
+<path fill="#fff" d="m11.21 8.2.68-.93a1.92 1.92 0 0 1-.63-.27.71.71 0 0 1-.28-.6.68.68 0 0 1 .3-.6c.27-.16.57-.23.88-.21 1.19 0 1.5.62 1.7.89l.65-.88a1.98 1.98 0 0 0-.4-.46c-.45-.37-1.1-.55-1.97-.55a2.8 2.8 0 0 0-1.78.5 1.6 1.6 0 0 0-.64 1.34c0 .9.5 1.46 1.49 1.77zm3.28-.05c-.3-.26-.76-.47-1.39-.62l-.68.94c.36.07.7.21 1 .41.2.13.3.36.3.59a.81.81 0 0 1-.35.7c-.3.18-.64.27-.98.24-.99 0-1.46-.32-1.93-1.18l-.68.94c.12.25.3.47.5.65.5.4 1.18.6 2.07.6a3.2 3.2 0 0 0 1.95-.53c.46-.35.73-.9.7-1.49a1.6 1.6 0 0 0-.51-1.25zM8.6 11.27h-.29V4.73h.29zm-7.61 0h1.4L3.48 9.8l-.7-.97zm6.16-6.54h-1.4l-.88 1.2.7.97zm-4.56 0H1.2l4.75 6.54h1.4z"/>
+</svg>
diff --git a/icons/rel/ani-ero.svg b/icons/rel/ani-ero.svg
new file mode 100644
index 00000000..f8fe6d00
--- /dev/null
+++ b/icons/rel/ani-ero.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#914040">
+<path d="M.92.53h14.16c.23.07.47.16.64.35.14.16.22.37.28.57v13.1a1.3 1.3 0 0 1-.35.64c-.16.14-.37.22-.57.28H.92a1.3 1.3 0 0 1-.64-.35 1.41 1.41 0 0 1-.28-.57V1.45c.06-.2.14-.4.28-.57C.45.7.68.6.92.53M2.13 1.6v1.07h2.14V1.6H2.13m3.2 0v1.07h2.14V1.6H5.33m3.2 0v1.07h2.14V1.6H8.53m3.2 0v1.07h2.14V1.6h-2.14m-9.6 2.13v8.54h11.74V3.73H2.13m0 9.6v1.07h2.14v-1.07H2.13m3.2 0v1.07h2.14v-1.07H5.33m3.2 0v1.07h2.14v-1.07H8.53m3.2 0v1.07h2.14v-1.07h-2.14z"/>
+<path d="M3.35 5.28C3.97 4.7 5 4.5 5.72 5.05c.44.3.66.82.97 1.24.3-.04.5-.34.79-.45.51-.3 1.17-.4 1.71-.11a2 2 0 0 1 .95.97c-.36-.17-.72-.37-1.13-.35-1.05-.01-1.94.96-1.92 2-.03.7.35 1.35.89 1.78.25.2-.23.35-.5.44-.55.28-1.1.55-1.7.7-.34.04-.57-.3-.8-.52a14.4 14.4 0 0 1-1.97-2.7A2.36 2.36 0 0 1 3 5.7c.09-.16.2-.3.34-.43Z"/>
+<path d="M11.4 6.3a1.5 1.5 0 0 1 1.66.67c.34.51.3 1.2.02 1.73-.39.73-.9 1.4-1.44 2-.2.23-.46.55-.8.38-.82-.27-1.57-.7-2.3-1.16-.48-.33-.92-.83-.94-1.45-.07-.78.57-1.57 1.36-1.6.48-.03.89.27 1.27.52.22.08.28-.36.44-.5.19-.25.42-.5.73-.59z"/>
+</g>
+</svg>
diff --git a/icons/rel/ani-story.svg b/icons/rel/ani-story.svg
new file mode 100644
index 00000000..f0f4e45f
--- /dev/null
+++ b/icons/rel/ani-story.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#914040">
+<path d="M.85.53h14.27c.43.1.78.45.88.88V14.6c-.1.43-.45.78-.88.88H.88A1.2 1.2 0 0 1 0 14.6V1.4C.1 1 .43.66.85.53M2.13 1.6v1.07h2.14V1.6H2.13m3.2 0v1.07h2.14V1.6H5.33m3.2 0v1.07h2.14V1.6H8.53m3.2 0v1.07h2.14V1.6h-2.14m-9.6 2.13v8.54h11.74V3.73H2.13m0 9.6v1.07h2.14v-1.07H2.13m3.2 0v1.07h2.14v-1.07H5.33m3.2 0v1.07h2.14v-1.07H8.53m3.2 0v1.07h2.14v-1.07z"/>
+<path d="M5.33 4.8 11.72 8l-6.39 3.2z"/>
+</g>
+</svg>
diff --git a/icons/rel/cartridge.svg b/icons/rel/cartridge.svg
new file mode 100644
index 00000000..2693ed7e
--- /dev/null
+++ b/icons/rel/cartridge.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<path fill="#eaeaea" d="M1.64.73h12.72v14.55l-12.72-.02V.73"/>
+<path fill="#706f6f" d="M3.27 2H6v.55H3.27V2zm0 1.45H6V4H3.27v-.55zm0 1.45H6v.55H3.27v-.54zm0 1.46H6v.55H3.27v-.55zm0 1.46H6v.54H3.27v-.54z"/>
+<path fill="#706f6f" d="M1.1 0h13.8v13.08l-.53.02V16H1.63v-2.9l-.55-.02V0m.55.73v11.82h.72v2.72h11.28v-2.72h.72V.73h-1.09v10.36H6.55V.73H1.64"/>
+<path fill="#706f6f" d="M3.27 9.27H6v.55H3.27v-.55zm0 1.46H6v.54H3.27v-.54zm0 1.45H6v.55H3.27v-.55zm0 1.46H6v.54H3.27v-.54z"/>
+<path fill="#939292" d="M6.9.73h6v10h-6v-10z"/>
+</svg>
diff --git a/icons/rel/disk.svg b/icons/rel/disk.svg
new file mode 100644
index 00000000..6975df79
--- /dev/null
+++ b/icons/rel/disk.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<path fill="#bdc3c7" d="M7.16.06a7.95 7.95 0 0 1 6.37 13.67 7.93 7.93 0 0 1-8.47 1.69 8.09 8.09 0 0 1-5.04-7.7A7.94 7.94 0 0 1 7.16.07m.58 5.78a2.17 2.17 0 1 0 2.4 1.79"/>
+<path fill="#ecf0f1" d="M7.74 5.84a2.17 2.17 0 1 1-1.87 2.51 2.16 2.16 0 0 1 1.88-2.51Zm-.03.72c-.8.15-1.37 1.07-1.09 1.85.17.53.66.96 1.23.99.33.03.66-.07.96-.24.3-.23.46-.46.56-.78a1.41 1.41 0 0 0-1.09-1.83 1.51 1.51 0 0 0-.57 0ZM2.4 3.32c.36-.44.78-.85 1.24-1.19.92 1.18 1.88 2.47 2.65 3.51A2.95 2.95 0 0 0 5.13 7.6C3.67 7.44 2.09 7.22.8 7.05a7.24 7.24 0 0 1 1.6-3.73Zm8.5 4.95c1.45.13 3.02.37 4.29.52 0 .37-.11.73-.2 1.09a7.31 7.31 0 0 1-2.64 3.84c-.89-1.13-1.8-2.4-2.58-3.4.79-.55.98-1.29 1.13-2.05z"/>
+</svg>
diff --git a/icons/rel/download.svg b/icons/rel/download.svg
new file mode 100644
index 00000000..f9e0652f
--- /dev/null
+++ b/icons/rel/download.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M5.13.75c.16-.35.5-.61.88-.68C7.62 0 9.4.03 10.82.05c.02 1.68.01 3.36 0 5.04.61.02 1.22 0 1.83 0 .15.02.34.02.41.18.02.15-.08.27-.16.4L8.48 11.4c-.13.16-.32.35-.55.29-.25-.08-.4-.33-.55-.53L3.14 5.72c-.09-.13-.22-.28-.2-.45.06-.17.26-.16.41-.17.55-.01 1.1.01 1.66-.01V1.5c0-.25 0-.52.12-.76z"/>
+<path d="M.9 11.01c.38-.1.78-.04 1.16-.04.02 1.06.01 2.13 0 3.19 4.09 0 8.08.04 11.87 0v-3.2c.4 0 .79-.04 1.17.05.32.09.52.4.63.69v3.9c-.1.22-.3.38-.54.36-5.17-.1-10.1.13-14.58-.03-.17-.03-.26-.19-.34-.32v-3.9c.1-.3.3-.61.63-.7z"/>
+</g>
+</svg>
diff --git a/icons/rel/free.svg b/icons/rel/free.svg
new file mode 100644
index 00000000..cc73c8e3
--- /dev/null
+++ b/icons/rel/free.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<path fill="#b5b5b5" d="M3.8 0c1.5 0 2.8.8 3.8 1.9.4.6-.1 1.3-.8 1.4-1.2.4-2.7.5-3.9 0-1-.7-1-2.2 0-3 .2-.1.6-.3 1-.3Zm0 1.2c-.7.1-.5 1.3.3 1.2h2c0-.7-.8-.7-1.2-1a3 3 0 0 0-1-.2ZM12 0c1.3-.2 2.4 1.6 1.6 2.7-.8 1.1-2.3 1-3.5.9-.9 0-2.3-.7-1.6-1.8A5.6 5.6 0 0 1 11.9 0ZM9.6 2.3c1 .1 2.3.5 3-.5-.4-1.1-1.8-.4-2.4 0l-.6.5zm-8.8 2c.7-.2 1.3-.1 2-.1h4.5V8H1.2c-.4.1-.7-.2-.9-.5V4.8c.2-.2.3-.5.5-.5zm7.9-.1h6.5l.5.6v2.7c-.2.2-.3.5-.6.5H8.7V4.2zM1.2 9.3c.2-.4.7-.5 1-.4h5.1V16H1.8c-.3-.1-.6-.3-.6-.6V9.3zM8.7 9c.9-.2 1.8-.1 2.8-.1h2.7c.3 0 .6.3.6.6v6c-.1.3-.4.4-.6.5H8.7V9z"/>
+</svg>
diff --git a/icons/rel/nonfree.svg b/icons/rel/nonfree.svg
new file mode 100644
index 00000000..ac90e26b
--- /dev/null
+++ b/icons/rel/nonfree.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M7.35.25a5.6 5.6 0 0 1 3.12.62c.18.32-.2.65-.25.99L9.6 3.39c-.9.01-1.79-.03-2.68-.04-.48.07-.44-.52-.6-.83-.1-.45-.48-.93-.44-1.36.32-.5.88-.81 1.46-.9Z"/>
+<path d="M3.96 2.04c.2.06.34.26.52.37.5.41 1.03.81 1.53 1.23.03.14-.07.53.14.3.15-.18.34-.32.58-.26l2.71.05c.18.1.42.27.5.43-.12.14-.22.32-.37.42l-3.15-.06c-.22-.09-.3-.4-.53-.5l-2.26-1.4c.05-.2.23-.4.33-.6ZM3.73 4.2c.13-.03.26 0 .4 0l1.46.09c.09 0 .17 0 .25.02l.18.42c-.2.03-.4.03-.61.06l-1.87.18c.06-.26.11-.52.19-.78zm2.76.5c.03-.02.08-.01.12-.01l2.74.1c.07 0 .12.05.17.1.85.68 1.63 1.46 2.33 2.3.48.6.92 1.24 1.24 1.94a4.44 4.44 0 0 1 .3 3.26 4.28 4.28 0 0 1-2.19 2.77c-.25.13-.53.2-.8.3a9 9 0 0 1-5.47-.16 3.98 3.98 0 0 1-1.23-.65 3.23 3.23 0 0 1-1.03-1.45c-.32-.9-.3-1.88-.08-2.8.02-.16.03-.31.07-.47a6.4 6.4 0 0 1 .8-1.8 16.1 16.1 0 0 1 2.87-3.3c.05-.04.1-.1.16-.13m1.06 1.38c-.04.17 0 .34-.02.51-.58.07-1.18.18-1.7.48-.41.24-.78.6-.95 1.07-.15.42-.16.9.02 1.31.2.43.58.74 1 .95.5.26 1.07.37 1.63.44v1.95c-.05.02-.1 0-.16-.01-.43-.08-.87-.2-1.21-.48-.15-.12-.22-.31-.4-.4a.74.74 0 0 0-.9.19.5.5 0 0 0 0 .58 2 2 0 0 0 .61.62 4.3 4.3 0 0 0 1.8.65l.26.04v.42c.01.11.09.21.17.29.22.16.57.12.72-.12.13-.17.03-.4.09-.58a4.6 4.6 0 0 0 1.83-.5c.42-.25.77-.6.95-1.06a1.9 1.9 0 0 0-.08-1.5c-.23-.4-.63-.7-1.06-.88a5.34 5.34 0 0 0-1.65-.33c-.02-.17 0-.35-.01-.52V7.76c.3 0 .57.06.85.15.26.09.52.2.72.4.1.1.17.24.3.33.26.18.65.15.88-.08a.5.5 0 0 0 .11-.58c-.1-.2-.26-.38-.43-.53a4.01 4.01 0 0 0-2.22-.84c-.07 0-.15 0-.2-.03-.03-.2.05-.44-.1-.6-.2-.3-.73-.24-.85.1z"/>
+<path d="M6.28 8.16c.35-.28.8-.36 1.24-.41.02.04.01.08.01.12V9.7c-.14 0-.27-.03-.4-.05a2.06 2.06 0 0 1-.82-.28.67.67 0 0 1-.28-.34.8.8 0 0 1 .25-.86zm2.22 2.72c.35.02.7.06 1.04.17.25.08.49.25.55.52.05.26 0 .55-.18.75-.19.22-.47.3-.74.39-.22.05-.45.1-.67.12-.02-.43 0-.85-.01-1.27v-.68z"/>
+</g>
+</svg>
diff --git a/icons/rel/notes.svg b/icons/rel/notes.svg
new file mode 100644
index 00000000..2a81a238
--- /dev/null
+++ b/icons/rel/notes.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M0 0h16v16H0V0m1.33 1.33v13.34h13.34V1.33Z"/>
+<path d="M2.67 5.33h10.66v1.34H2.67V5.33Zm0 2.67h10.66v1.33H2.67V8Zm0 2.67h8V12h-8v-1.33Z"/>
+</g>
+</svg>
diff --git a/icons/rel/reso-169.svg b/icons/rel/reso-169.svg
new file mode 100644
index 00000000..480d5aba
--- /dev/null
+++ b/icons/rel/reso-169.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M.63 1.72h14.74c.3.06.56.27.63.57v11.42c-.1.29-.32.54-.63.57H.63a.81.81 0 0 1-.63-.57V2.29c.07-.3.34-.51.63-.57m4.03 4.5c-.42.78-.42 1.7-.35 2.56.05.61.21 1.27.7 1.68.45.43 1.13.5 1.7.35.53-.15.94-.58 1.1-1.09a2.4 2.4 0 0 0-.03-1.44c-.25-.63-.93-1.13-1.62-1-.32.01-.57.22-.8.41.03-.38.06-.78.23-1.13a.68.68 0 0 1 .77-.36c.33.05.39.53.73.55a.5.5 0 0 0 .54-.6c-.09-.28-.34-.49-.58-.64a1.85 1.85 0 0 0-2.39.71m7.64-.67c-.32.19-.56.49-.7.82-.14.33-.14.69-.13 1.04.03.72.6 1.4 1.31 1.52.45.08.9-.1 1.23-.4-.05.43-.05.91-.34 1.26-.21.27-.67.34-.9.07-.16-.2-.37-.47-.66-.38-.2.02-.32.2-.38.37-.05.29.14.53.34.7.4.34.97.4 1.46.3a1.8 1.8 0 0 0 1.28-1.07c.28-.63.3-1.35.28-2.03-.04-.64-.14-1.33-.57-1.84a1.8 1.8 0 0 0-2.22-.36m-9.92-.08c-.23.24-.39.55-.66.75-.25.23-.59.34-.84.57-.23.3.1.8.47.66.3-.12.56-.34.8-.55.02 1.19-.02 2.37.02 3.56.07.38.61.51.87.24.21-.18.13-.49.15-.73V5.9c.05-.41-.5-.7-.81-.44m7.26.58c-.36.08-.66.45-.6.82-.01.46.46.86.91.77.45-.05.8-.54.68-.98a.82.82 0 0 0-1-.61m.04 2.53c-.2.04-.4.17-.5.36-.3.38-.1 1 .34 1.18.47.24 1.1-.13 1.14-.66.08-.53-.47-1-.98-.88z"/>
+<path d="M13.01 6.2c.39-.09.76.23.85.6.1.39.11.87-.18 1.2-.23.21-.6.26-.86.06-.35-.27-.35-.76-.33-1.16.02-.3.2-.64.52-.7zm-7 1.84c.42-.1.82.26.85.67.03.38.08.87-.24 1.16a.6.6 0 0 1-.8 0c-.34-.24-.39-.7-.37-1.08.02-.33.22-.68.56-.74z"/>
+</g>
+</svg>
diff --git a/icons/rel/reso-43.svg b/icons/rel/reso-43.svg
new file mode 100644
index 00000000..89e665bb
--- /dev/null
+++ b/icons/rel/reso-43.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M.54.56h14.92c.26.07.45.3.54.54v13.8c-.06.11-.1.24-.2.34-.1.08-.22.15-.34.2H.54A.84.84 0 0 1 0 14.9V1.1C.1.85.28.63.54.56m11.29 3.96a1.9 1.9 0 0 0-1.52.98.59.59 0 0 0 .2.81c.2.15.48.11.68-.03.19-.18.3-.45.53-.6.34-.2.87-.11 1.07.25.17.36.1.88-.26 1.1-.2.15-.48.16-.68.32-.17.2-.15.51.05.7.22.22.6.1.85.3.3.2.43.6.4.96.02.4-.18.83-.56 1-.38.2-.92.07-1.14-.32-.13-.2-.27-.45-.53-.5-.38-.11-.79.22-.78.61.02.3.22.56.43.77.78.76 2.08.82 2.97.24.42-.28.74-.7.89-1.18.15-.57.1-1.23-.3-1.7-.2-.3-.55-.42-.88-.54.22-.18.49-.33.65-.57.2-.24.31-.56.3-.87 0-.7-.5-1.33-1.14-1.6a2.35 2.35 0 0 0-1.23-.13m-7.93.5-2.3 3.4c-.17.25-.4.53-.33.85.02.43.4.77.83.8.65.02 1.3 0 1.96 0 .04.38-.1.82.15 1.14.2.27.66.3.91.08.32-.32.2-.8.23-1.2.24-.04.54-.02.7-.24.2-.23.17-.63-.08-.8-.17-.17-.42-.13-.63-.15-.01-1.22.03-2.44-.01-3.66-.01-.38-.38-.73-.77-.67-.3-.01-.5.24-.66.47m3.86.08c-.36.07-.67.36-.78.7a1.08 1.08 0 0 0 1.64 1.25c.44-.29.6-.91.35-1.37-.2-.46-.73-.69-1.2-.58m.21 3.62a1.1 1.1 0 0 0-.92.55c-.22.37-.18.88.1 1.22a1.08 1.08 0 0 0 1.9-.95c-.12-.48-.6-.84-1.08-.82z"/>
+<path d="m2.5 8.9 1.56-2.35v2.36H2.5Z"/>
+</g>
+</svg>
diff --git a/icons/rel/reso-custom.svg b/icons/rel/reso-custom.svg
new file mode 100644
index 00000000..2e7f9a51
--- /dev/null
+++ b/icons/rel/reso-custom.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M.5.58h9.6l-.01 3.82-.71.02V1.27H1.19v4.56h4.78v1.62h-3.6l-.02-.32H4.4l.02-.4H.51Z"/>
+<path d="M6.4 4.81H16v6.12h-4.02v.4l2.02.01.01.32-3.97-.01.2-.65-.18-.97h5.22V5.5H7.1v2.57h-.7z"/>
+<path d="m0 8.56 9.6-.02v6.16h-4v.4h2v.32H1.87v-.32h2.07v-.4H0V8.56m.72.7v4.53H8.9V9.27Z"/>
+</g>
+</svg>
diff --git a/icons/rel/voiced.svg b/icons/rel/voiced.svg
new file mode 100644
index 00000000..9f109a1f
--- /dev/null
+++ b/icons/rel/voiced.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#914040">
+<path d="M7.8 0c1.47-.1 2.93.83 3.45 2.21.39.93.2 1.95.25 2.93-.01 1.24.02 2.47-.02 3.7a3.52 3.52 0 0 1-3 3.13 3.51 3.51 0 0 1-3.74-2.2c-.37-.9-.2-1.9-.24-2.84.01-1.27-.02-2.54.02-3.81A3.52 3.52 0 0 1 7.8 0Zm-.29 1.04c-.94.19-1.8.98-1.94 1.96-.13.43-.04.92-.07 1.37V8.7a2.52 2.52 0 0 0 2.11 2.28c1.17.2 2.4-.54 2.76-1.67.22-.66.1-1.37.13-2.05 0-1.33.02-2.66-.01-4A2.47 2.47 0 0 0 8.5 1.08c-.3-.1-.67-.07-.99-.03z"/>
+<path d="M2.77 8.05a.5.5 0 0 1 .64.16c.1.15.09.34.1.51a4.45 4.45 0 0 0 2.7 3.91 4.5 4.5 0 0 0 6.28-3.85c.01-.19-.02-.4.1-.57a.5.5 0 0 1 .64-.16c.13.07.21.2.27.34v.37c-.03.28-.06.57-.11.85a5.48 5.48 0 0 1-4.03 4.22c-.28.08-.57.1-.85.15-.02.35 0 .7 0 1.01.13.02.26.01.4.01h2c.13 0 .28.01.4.1a.5.5 0 0 1 .14.63.66.66 0 0 1-.34.27H4.9a.66.66 0 0 1-.34-.27.5.5 0 0 1 .15-.63c.1-.09.25-.1.4-.1h2.4a17 17 0 0 0-.01-1.02l-.41-.06A5.47 5.47 0 0 1 2.5 8.79v-.4a.66.66 0 0 1 .27-.34zM8.5.96V2h-1V.96zM6.5 2h1v1h-1zm2 0h1v1h-1zm-3 1h1v1h-1c-.02-.01 0-1 0-1Zm2 0h1v1h-1V3zm2 0h1.01l-.01 1h-1Zm-3 1h1v1h-1zm2 0h1v1h-1V4zM5.42 5H6.5v1h-1ZM7.5 5h1v1h-1V5zm2 0h1l.1 1H9.5Zm-3 1h1v1h-1V6zm2 0h1v1h-1V6z"/>
+<path d="M5.4 7h1.1v1H5.4Zm2.1 0h1v1h-1V7zm2 0h1.1l-.1 1h-1Zm-3 1h1v1h-1V8zm2 0h1v1h-1V8zM5.46 9H6.5v1h-.8zM7.5 9h1v1h-1V9zm2 0h1.06l-.37 1H9.5Zm-3 1h1v1.04l-1-.45zm2 0h1v.74l-1 .32Z"/>
+</g>
+</svg>
diff --git a/icons/rss.svg b/icons/rss.svg
new file mode 100644
index 00000000..e57b91ec
--- /dev/null
+++ b/icons/rss.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14">
+<path fill="#F78422" d="M0 0h14v14H0z"/>
+<g fill="#fff">
+<path d="M2 2v2c4 0 8 3 8 8h2A10 10 0 0 0 2 2Z"/>
+<path d="M2 5.3v2c2.6 0 4.7 2 4.7 4.7h2A6.7 6.7 0 0 0 2 5.3z"/>
+<path d="M4.8 10.6a1.4 1.4 0 0 1-1.4 1.5A1.4 1.4 0 0 1 2 10.6a1.4 1.4 0 0 1 1.4-1.4 1.4 1.4 0 0 1 1.4 1.4z"/>
+</g>
+</svg>
diff --git a/data/icons/rtcomplete.png b/icons/rtcomplete.png
index cd9c640b..cd9c640b 100644
--- a/data/icons/rtcomplete.png
+++ b/icons/rtcomplete.png
Binary files differ
diff --git a/data/icons/rtpartial.png b/icons/rtpartial.png
index 89a9b21e..89a9b21e 100644
--- a/data/icons/rtpartial.png
+++ b/icons/rtpartial.png
Binary files differ
diff --git a/data/icons/rttrial.png b/icons/rttrial.png
index 5838692d..5838692d 100644
--- a/data/icons/rttrial.png
+++ b/icons/rttrial.png
Binary files differ
diff --git a/js/README.md b/js/README.md
new file mode 100644
index 00000000..2cbccbc0
--- /dev/null
+++ b/js/README.md
@@ -0,0 +1,70 @@
+# VNDB's JavaScript Mess
+
+(Because there's no way to do JS without it being a mess)
+
+This is very much a work in progress.
+
+
+## Organization
+
+Each subdirectory represents a JS bundle. Each bundle has an `index.js` file
+which is processed by the top-level Makefile and then converted into
+`static/g/<bundle>.js`. `index.js` can include other files with `@include
+file.js` lines, these are substituted with the contents of `file.js` and
+wrapped inside anonymous JS functions for scoping. File names are resolved
+relative to the index.js file itself, the special virtual `.gen/` directory
+resolves to `$VNDB_GEN`, see the top-level Makefile for the generated JS files.
+
+Scripts use the global `window` object to share functions and data, but apart
+from a bit of common library code, most scripts ought to be fairly
+self-contained.
+
+It's up to `index.js` to ensure dependent scripts are included in the proper
+order and it's up to the Perl backend to load the bundles in the proper order.
+This is somewhat brittle, but such is life.
+
+(Why this weird setup instead of CJS or ES6 modules and a proper bundler?
+Because I'm very picky about the software that I run on my dev system and
+there's no bundler included in my Linux distro's package repositories.)
+
+
+## Compatibility
+
+All JS code should be compatible with any 3-year old version of Firefox,
+Chrome, Blink and Safari, and a recent version of Pale Moon. The latter tends
+to be the most limiting, but they've been doing a lot of catching up on modern
+web standards. ES6 is generally no problem.
+
+Specific features to avoid:
+
+- class fields (not supported by Pale Moon 32.1)
+
+
+## Bundles
+
+- `basic`: Primary bundle for functionality and library code common to popular
+ pages on the site. The goal is to keep this below 20kB minified+gzipped.
+- `user`: Bundle for functionality that is commonly used by users with an
+ account.
+- `contrib`: Bundle for edit forms and other database contributions.
+- `graph`: D3.js-based graphs.
+- `search`: *TODO*, the advanced search filter selection thing.
+
+## Widgets
+
+...is the name I chose for components that can be instantiated from the Perl
+backend by adding a `widget($name, $data)` attribute to a HTML tag. They're
+similar to "modules" in Elm.
+
+A widget is a mithril.js component that can be registered anywhere in JS with
+the following line:
+
+```js
+widget('Name', vnode => {
+ let data = vnode.attrs.data;
+ // ...rest of the mithril component
+});
+```
+
+Where `data` is whatever the Perl backend passed to it. Objects and arrays
+referenced by `data` are not used elsewhere and can be freely mutated.
diff --git a/js/basic/TableOpts.js b/js/basic/TableOpts.js
new file mode 100644
index 00000000..89daf9dc
--- /dev/null
+++ b/js/basic/TableOpts.js
@@ -0,0 +1,132 @@
+// JS Widget corresponding to VNWeb::TableOpts, see the Perl implementation for
+// encoding & option details.
+
+// Simple wrapper to abstract away the bitwise crap.
+// These operate on 32bit integers, BigInts are a bit too recent to use I
+// think, but we don't need those yet.
+class Opts {
+ constructor(num) { this.n = num }
+
+ //get view() { return this.n & 3 }
+ get results() { return (this.n >> 2) & 7 }
+ get order() { return (this.n & 32) > 0 }
+ get sortCol() { return (this.n >> 6) & 63 }
+ isVis(v) { return (this.n & (1 << (v + 12))) > 0 }
+
+ //set view(v) { this.n = (this.n & ~3) | v }
+ set results(v) { this.n = (this.n & ~28) | (v << 2) }
+ set order(v) { this.n = v ? (this.n | 32) : (this.n & ~32) }
+ set sortCol(v) { this.n = (this.n & ~4032) | (v << 6) }
+ setVis(v,b) { this.n = b ? (this.n | (1 << (v + 12))) : (this.n & ~(1 << (v + 12))) }
+
+ encode() {
+ const alpha = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-';
+ let n = this.n;
+ let v = n ? '' : alpha[0];
+ while(n > 0) {
+ v = alpha[n & 63].concat(v);
+ n >>= 6;
+ }
+ return v;
+ }
+}
+
+const resultOptions = [50,10,25,100,200];
+
+widget('TableOpts', (vnode) => {
+ const conf = vnode.attrs.data;
+ const opts = new Opts(conf.value);
+ const save = new Api('TableOptsSave');
+
+ // This widget is loaded into an <ul> that contains a hidden input element
+ // - which we don't need, we create our own - and potentially some view
+ // buttons that we do like to keep. This is an ugly hack to load the
+ // pre-existing elements into our vdom by going through an HTML parse.
+ // Would be nicer if we can just pass DOM nodes directly to the vdom, but
+ // Mithril doesn't support that. Maybe contribute that as a new feature?
+ // Doesn't sound too complicated given that "trust" nodes already have the
+ // infrastructure for it.
+ const oldNodes = m.trust(vnode.attrs.oldContents.filter(e => !e.querySelector('input[name=s]')).map(e => e.outerHTML).join(''));
+
+ const submit = (o) => {
+ const e = vnode.dom.parentNode.querySelector('input[name=s]');
+ e.value = o.encode();
+ e.form.submit();
+ };
+
+ const sortBut = (s,desc) => m('a[href=#]', {
+ onclick: ev => {
+ ev.preventDefault();
+ let o = new Opts(opts.n);
+ o.sortCol = s.id;
+ o.order = desc;
+ submit(o);
+ },
+ class: opts.sortCol == s.id && opts.order == desc ? 'checked' : null,
+ title: s.name + ' ' + (desc ? 'descending' : 'ascending'),
+ }, s.num ? (desc ? '9→1' : '1→9') : (desc ? 'Z→A' : 'A→Z'));
+
+ const view = () => [
+ m('li.hidden', m('input[type=hidden][name=s]', { value: opts.encode() })),
+ !conf.save ? null : m('li.maintabs-dd.tableopts-save', m(MainTabsDD, {
+ a_body: m(Icon.Save),
+ a_attrs: { title: 'save display settings' },
+ content: () => [
+ m('h4', 'save display settings'),
+ save.saved({ save: conf.save, value: opts.n }) ? 'Saved!'
+ : save.loading() ? m('span.spinner')
+ : save.error ? m('b', save.error)
+ : m('input[type=button]', {
+ value: 'Save current settings as default',
+ onclick: () => save.call({ save: conf.save, value: opts.n })
+ }),
+ conf.default == opts.n ? null : m('input[type=button]', {
+ value: 'Load default view',
+ onclick: () => submit(new Opts(conf.default)),
+ }),
+ conf.usaved === null || conf.usaved == opts.n ? null : m('input[type=button]', {
+ value: 'Load my saved settings',
+ onclick: () => submit(new Opts(conf.usaved)),
+ }),
+ ],
+ })),
+ m('li.maintabs-dd.tableopts-results', m(MainTabsDD, {
+ a_body: resultOptions[opts.results],
+ a_attrs: { title: 'results per page' },
+ content: () => [
+ m('h4', 'results per page'),
+ [1,2,0,3,4].flatMap(n => [' | ',
+ m('a[href=#]', {
+ onclick: (ev) => { ev.preventDefault(); let o = new Opts(opts.n); o.results = n; submit(o) },
+ }, resultOptions[n])
+ ]).slice(1),
+ ]
+ })),
+ conf.vis.length == 0 ? null : m('li.maintabs-dd.tableopts-cols', m(MainTabsDD, {
+ a_body: m(Icon.Eye),
+ a_attrs: { title: 'visible columns' },
+ content: () => [
+ m('h4', 'visible columns'),
+ conf.vis.map(c => m('label', c.name, ' ', m('input[type=checkbox]', {
+ checked: opts.isVis(c.id),
+ oninput: ev => opts.setVis(c.id, ev.target.checked),
+ }))),
+ m('input[type=submit][value=Update]'),
+ ]
+ })),
+ conf.sorts.length == 0 ? null : m('li.maintabs-dd.tableopts-sort', m(MainTabsDD, {
+ a_body: m(Icon.ArrowDownUp),
+ a_attrs: { title: 'sort options' },
+ content: () => [
+ m('h4', 'sort options'),
+ m('table', conf.sorts.map(s => m('tr',
+ m('td', s.name),
+ m('td', sortBut(s,false)),
+ m('td', sortBut(s,true)),
+ ))),
+ ]
+ })),
+ oldNodes,
+ ];
+ return {view};
+})
diff --git a/js/basic/api.js b/js/basic/api.js
new file mode 100644
index 00000000..0d53b401
--- /dev/null
+++ b/js/basic/api.js
@@ -0,0 +1,73 @@
+// Simple wrapper around XHR to call into the backend, provide friendly error
+// messages and integrate with mithril.js.
+// Can only handle one request at a time.
+// Reports results back with a plain old callback instead of a promise, because
+// VNDB's XHR use is too simple for anything more complex to add much value.
+class Api {
+ constructor(endpoint) {
+ this.endpoint = endpoint;
+ this.abort();
+ }
+
+ loading() {
+ return this.xhr && this.xhr.readyState != 4;
+ }
+
+ abort() {
+ this.error = null;
+ if (this.xhr) this.xhr.abort();
+ this.xhr = null;
+ this._saved = false;
+ this._lastdata = null;
+ }
+
+ _err(cb, msg) {
+ this.error = msg;
+ cb && cb(this.xhr && this.xhr.response);
+ m.redraw();
+ }
+
+ _load(cb, errcb, xhr) {
+ if (xhr.status == 403) return this._err(errcb, 'Permission denied. Your session may have expired, try reloading the page.');
+ if (xhr.status == 413) return this._err(errcb, 'File upload too large.');
+ if (xhr.status == 429) return this._err(errcb, 'Action throttled, please try again later.');
+ if (xhr.status != 200) return this._err(errcb, 'Server error '+xhr.status+', please try again later or report a bug if this persists.');
+ if (xhr.response === null || "object" != typeof xhr.response) return this._err(errcb, 'Invalid response from the server, please report a bug.');
+ if (xhr.response._err) return this._err(errcb, xhr.response._err);
+ if (xhr.response._redir) { location.href = xhr.response._redir; return }
+ this.error = null;
+ this._saved = this._lastdata;
+ cb && cb(xhr.response);
+ m.redraw();
+ }
+
+ // The parsed response JSON is passed as argument to the callback.
+ call(data, cb, errcb) {
+ this.abort();
+
+ var xhr = new XMLHttpRequest();
+ xhr.ontimeout = () => this._err(errcb, 'Network timeout, please try again later.');
+ xhr.onerror = () => this._err(errcb, 'Network error, please try again later.');
+ xhr.onload = () => this._load(cb, errcb, xhr);
+ xhr.open('POST', '/js/'+this.endpoint+'.json', true);
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.responseType = 'json';
+ xhr.send(this._lastdata = JSON.stringify(data));
+ this.xhr = xhr;
+ }
+
+ // Returns true if the given 'data' has been "saved" by the most recent
+ // successful call(). This is level-triggered, once the 'data' is seen as
+ // being different it will remember that state till the next call().
+ saved(data) {
+ if (this._saved === false) return false;
+ if (this._saved !== JSON.stringify(data)) return (this._saved = false);
+ return true;
+ }
+
+ // Manually override the data that's considered as "saved".
+ setsaved(data) {
+ this._saved = JSON.stringify(data);
+ }
+};
+window.Api = Api;
diff --git a/js/basic/checkall.js b/js/basic/checkall.js
new file mode 100644
index 00000000..f74a74f1
--- /dev/null
+++ b/js/basic/checkall.js
@@ -0,0 +1,16 @@
+/* "checkall" checkbox, usage:
+ *
+ * <input type="checkbox" class="checkall" name="$somename">
+ *
+ * Checking that will synchronize all other checkboxes with name="$somename".
+ * The "x-checkall" attribute may also be used instead of "name".
+ */
+$$('input[type=checkbox].checkall').forEach(el =>
+ el.addEventListener('click', () => {
+ const name = el.getAttribute('x-checkall') || el.name;
+ $$('input[type=checkbox][name="'+name+'"], input[type=checkbox][x-checkall="'+name+'"]').forEach(el2 => {
+ if(el2.checked != el.checked)
+ el2.click();
+ });
+ })
+);
diff --git a/js/basic/checkhidden.js b/js/basic/checkhidden.js
new file mode 100644
index 00000000..df1a2679
--- /dev/null
+++ b/js/basic/checkhidden.js
@@ -0,0 +1,11 @@
+/* "checkhidden" checkbox, usage:
+ *
+ * <input type="checkbox" class="checkhidden" value="$somename">
+ *
+ * Checking that will toggle the 'hidden' class of all elements with the "$somename" class.
+ */
+$$('input[type=checkbox].checkhidden').forEach(el => {
+ const f = () => $$('.'+el.value).forEach(el2 => el2.classList.toggle('hidden', !el.checked));
+ f();
+ el.addEventListener('click', f);
+});
diff --git a/js/basic/components.js b/js/basic/components.js
new file mode 100644
index 00000000..96182be1
--- /dev/null
+++ b/js/basic/components.js
@@ -0,0 +1,433 @@
+const langs = Object.fromEntries(vndbTypes.language);
+const plats = Object.fromEntries(vndbTypes.platform);
+window.LangIcon = id => m('abbr', { class: 'icon-lang-'+id, title: langs[id] });
+window.PlatIcon = id => m('abbr', { class: 'icon-plat-'+id, title: plats[id] });
+
+
+// SVG icons from: https://lucide.dev/
+// License: MIT
+// The nice thing about these is that they all have the same viewbox and fill/stroke options.
+// Icon size should be set in CSS.
+const icon = svg => ({
+ view: () => m.trust('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+svg+'</g></svg>'),
+ raw: svg,
+});
+window.Icon = {
+ ArrowBigDown: icon('<path d="M15 6v6h4l-7 7-7-7h4V6h6z"/>'),
+ ArrowBigUp: icon('<path d="M9 18v-6H5l7-7 7 7h-4v6H9z"/>'),
+ ArrowDownUp: icon('<path d="m3 16 4 4 4-4"></path><path d="M7 20V4"></path><path d="m21 8-4-4-4 4"></path><path d="M17 4v16"></path>'),
+ Ban: icon('<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>'),
+ CheckSquare: icon('<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>'),
+ ChevronDown: icon('<polyline points="6 9 12 15 18 9">'),
+ Copy: icon('<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>'),
+ Eye: icon('<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle>'),
+ FolderHeart: icon('<path d="M11 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v1.5"/><path d="M13.9 17.45c-1.2-1.2-1.14-2.8-.2-3.73a2.43 2.43 0 0 1 3.44 0l.36.34.34-.34a2.43 2.43 0 0 1 3.45-.01v0c.95.95 1 2.53-.2 3.74L17.5 21Z"/>'),
+ Globe: icon('<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>'),
+ Info: icon('<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>'),
+ MinusSquare: icon('<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><line x1="8" x2="16" y1="12" y2="12"/>'),
+ Pencil: icon('<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>'),
+ Redo2: icon('<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13"/>'),
+ Replace: icon('<path d="M14 4c0-1.1.9-2 2-2"/><path d="M20 2c1.1 0 2 .9 2 2"/><path d="M22 8c0 1.1-.9 2-2 2"/><path d="M16 10c-1.1 0-2-.9-2-2"/><path d="m3 7 3 3 3-3"/><path d="M6 10V5c0-1.7 1.3-3 3-3h1"/><rect width="8" height="8" x="2" y="14" rx="2"/>'),
+ Save: icon('<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline>'),
+ Search: icon('<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/>'),
+ StepForward: icon('<line x1="6" x2="6" y1="4" y2="20"/><polygon points="10,4 20,12 10,20"/>'),
+ Trash2: icon('<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2M10 11v6M14 11v6"/>'),
+ Tv: icon('<rect width="20" height="15" x="2" y="7" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/>'),
+ Users2: icon('<path d="M14 19a6 6 0 0 0-12 0"/><circle cx="8" cy="9" r="4"/><path d="M22 19a6 6 0 0 0-6-6 4 4 0 1 0 0-8"/>'),
+ X: icon('<line x1="18" x2="6" y1="6" y2="18"/><line x1="6" x2="18" y1="6" y2="18"/>'),
+};
+
+const but = (icon, title) => ({view: vnode => m('button[type=button].icon', { title,
+ onclick: ev => { ev.preventDefault(); vnode.attrs.onclick(ev) },
+ style: !('visible' in vnode.attrs) || vnode.attrs.visible ? null : 'visibility:hidden',
+ }, m(icon)
+)});
+window.Button = {
+ Edit: but(Icon.Pencil, 'Edit'),
+ Del: but(Icon.Trash2, 'Delete item'),
+ Cancel: but(Icon.Ban, 'Cancel'),
+ Up: but(Icon.ArrowBigUp, 'Move up'),
+ Down: but(Icon.ArrowBigDown, 'Move down'),
+ Copy: but(Icon.Copy, 'Copy'),
+ CheckAll: but(Icon.CheckSquare, 'Check all'),
+ UncheckAll: but(Icon.MinusSquare, 'Uncheck all'),
+};
+
+const helpState = {};
+window.HelpButton = id => m('a.help[href=#][title=Info]',
+ { onclick: ev => { ev.preventDefault(); helpState[id] = !helpState[id]; } },
+ m(Icon.Info)
+);
+window.Help = (id, ...content) => helpState[id] ? m('section.help',
+ { oncreate: vnode => vnode.dom.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'}) },
+ m('a[href=#]', { onclick: ev => { ev.preventDefault(); helpState[id] = false; } }, m(Icon.X)),
+ content
+) : null;
+
+
+// Dropdown box for use in a <li class="maintabs-dd">.
+// (This would be trivial enough to inline if it weren't for how tricky it is
+// to get the toggle functionality working as it should)
+window.MainTabsDD = (initVnode) => {
+ let open = false;
+
+ const toggle = (ev) => {
+ if (open && initVnode.dom.nextSibling.contains(ev.target)) return;
+ open = !open;
+ // Defer the listener, otherwise this current event will trigger it.
+ if (open) requestAnimationFrame(() => document.addEventListener('click', toggle));
+ else document.removeEventListener('click', toggle);
+ m.redraw();
+ };
+
+ const view = vnode => [
+ m('a[href=#]', {
+ onclick: (ev) => { ev.preventDefault(); toggle(ev) },
+ ...vnode.attrs.a_attrs,
+ }, vnode.attrs.a_body),
+ open ? m('div', m('div', vnode.attrs.content())) : null,
+ ];
+
+ return {view};
+};
+
+
+const focusElem = el => {
+ if (el.tagName === 'LABEL' && el.htmlFor) el = $('#'+el.htmlFor);
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') el.focus();
+ else el.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'});
+};
+
+// Wrapper around a <form> with a <fieldset> element and some magic.
+// Attrs:
+// - onsubmit - submit event, already has preventDefault()
+// - disabled - set 'disabled' attribute on the fieldset
+// - api - Api object, see below, also sets 'disabled' when api.loading()
+//
+// The .invalid-form class is set on an invalid <form> *after* the user
+// attempts to submit it, to help with styling invalid inputs. The onsubmit
+// event is not dispatched when the form contains a .invalid element.
+window.Form = () => {
+ let submitted = false, report;
+ return { view: vnode => {
+ const api = vnode.attrs.api;
+ return m('form[novalidate]', {
+ onsubmit: ev => {
+ ev.preventDefault();
+ report = true;
+ submitted = api;
+ if (ev.target.querySelector('.invalid')) return;
+ const x = vnode.attrs.onsubmit;
+ x && x(ev);
+ },
+ onupdate: v => requestAnimationFrame(() => {
+ const inv = v.dom.querySelector('.invalid');
+ v.dom.classList.toggle('invalid-form', submitted === api && (inv || (api && api.error)));
+ if (inv && report) {
+ // If we have a FormTabs child, let that component do the reporting.
+ const t = $('#js-formtabs');
+ if (t) t.dispatchEvent(new Event('formerror'));
+ else focusElem(inv);
+ }
+ report = false;
+ }),
+ }, m('fieldset',
+ { disabled: vnode.attrs.disabled || (api && api.loading()) },
+ vnode.children
+ ))
+ }};
+};
+
+
+// Draw a form with multiple tabs, attrs:
+// - tabs - Array of tabs, each tab is a 3-element arrays:
+// [ id, label, func ]
+// func should return the contents of the tab.
+// - sel - Id of initially selected tab.
+//
+// The currently selected tab is tracked in location.hash, so linking to a
+// specific tab is possible.
+//
+// The tabs integrate with a parent Form component to properly report errors:
+// on submission and if there's no error on the currently opened tab, it
+// automatically switches to the first tab with an error and focuses the
+// .invalid element.
+//
+// The list of tabs must be static and known at component creation time,
+// dynamically adding/removing tabs is not supported.
+window.FormTabs = initVnode => {
+ const tabs = initVnode.attrs.tabs;
+ const h = location.hash.replace('#', '');
+ let sel = initVnode.attrs.sel || (
+ h && (h === 'all' || tabs.find(t => t[0] === h)) ? h : tabs[0][0]
+ );
+ let report;
+ const set = n => location.replace('#'+(sel=n));
+ const onclick = ev => {
+ ev.preventDefault();
+ set(ev.target.href.replace(/^.+#/, ''));
+ };
+ const onformerror = ev => {
+ report = true;
+ // Make sure we have a tab open with an error
+ if (tabs.length > 1 && sel !== 'all' && !$('#formtabs_'+sel+' .invalid')) {
+ for (const t of tabs) {
+ if (sel === t[0]) continue;
+ if ($('#formtabs_'+t[0]+' .invalid')) {
+ set(t[0]);
+ break;
+ }
+ }
+ }
+ };
+ const view = () => [
+ tabs.length > 1 ? m('nav', {id: 'js-formtabs', onformerror}, m('menu',
+ tabs.concat([['all', 'All items']]).map(t =>
+ m('li', { key: t[0], id: 'formtabst_'+t[0], class: sel === t[0] ? 'tabselected' : ''},
+ m('a', {onclick, href: '#'+t[0]}, t[1])
+ )
+ ),
+ )) : null,
+ tabs.map(t => m('article',
+ { key: t[0], class: sel === t[0] || sel === 'all' ? '' : 'hidden' },
+ m('fieldset', {id: 'formtabs_'+t[0]}, t[2]())
+ )),
+ ];
+ const onupdate = () => requestAnimationFrame(() => {
+ // Set the 'invalid-tab' class on the tabs. The form state is not known
+ // during the view function, so this has to be done in an onupdate hook.
+ let inv;
+ if (tabs.length > 1)
+ for (const t of tabs) {
+ const el = $('#formtabs_'+t[0]+' .invalid');
+ if (!inv && (sel === 'all' || t[0] === sel)) inv = el;
+ $('#formtabst_'+t[0]).classList.toggle('invalid-tab', !!el);
+ }
+ if (report && inv) requestAnimationFrame(() => focusElem(inv));
+ report = false;
+ });
+ return {view,onupdate};
+};
+
+
+
+// Text input field.
+// Attrs:
+// - class
+// - id
+// - tabindex
+// - placeholder
+// - type
+// 'email', 'password', 'username', 'weburl'
+// 'number' -> Only digits allowed
+// 'textarea' -> <textarea>
+// otherwise -> regular text input field
+// - invalid -> Custom HTML validation message
+// - data + field -> input value is read from and written to 'data[field]'
+// - oninput -> called after 'data[field]' has been modified, takes new value as argument
+// - required / minlength / maxlength / pattern
+// HTML5 validation properties, except with a custom implementation.
+// The length is properly counted in Unicode points rather than UTF-16 digits.
+// - focus -> Bool, set input focus on create
+// - rows / cols -> For texarea
+// - onfocus
+//
+// The HTML5 validity API has some annoying limitations and is not always
+// honored, so this component simply re-implements validation and reporting of
+// errors. When the field fails validation, the following happens:
+// - The input element gets a .invalid class
+// - The input element is followed by a 'p.invalid' element containing the message
+// - If a 'label[for=$id]' exists, that label is also given the .invalid class
+//
+// The Form and FormTabs components detect and handle .invalid inputs.
+window.Input = () => {
+ const validate = a => {
+ const v_ = a.data[a.field];
+ const v = v_ === null ? '' : String(v_).trim();
+ if (a.invalid) return a.invalid;
+ if (!v.length) return a.required ? 'This field is required.' : '';
+ if (a.type === 'username') { a.minlength = 2; a.maxlength = 15; }
+ if (a.type === 'password') { a.minlength = 4; a.maxlength = 500; }
+ if (a.minlength && [...v].length < a.minlength) return 'Please use at least '+a.minlength+' characters.';
+ if (a.maxlength && [...v].length > a.maxlength) return 'Please use at most '+a.maxlength+' characters.';
+ if (a.type === 'username') {
+ if (/^[a-zA-Z][0-9]+$/.test(v)) return 'Username must not look like a VNDB identifier (single alphabetic character followed only by digits).';
+ const dup = {};
+ const chrs = v.replace(/[a-zA-Z0-9-]/g, '').split('').sort().filter(c => !dup[c] && (dup[c]=1));
+ if (chrs.length === 1) return 'The character "'+chrs[0]+'" can not be used.';
+ if (chrs.length) return 'The following characters can not be used: '+chrs.join(', ')+'.';
+ }
+ if (a.type === 'email' && !new RegExp(formVals.email).test(v)) return 'Invalid email address.';
+ if (a.type === 'weburl') {
+ if (!/^https?:\/\//.test(v)) return 'URL must start with http:// or https://.';
+ if (/^https?:\/\/[^/]+$/.test(v)) return "URL must have a path component (hint: add a '/'?).";
+ if (!new RegExp(formVals.weburl).test(v)) return 'Invalid URL.';
+ }
+ if (a.pattern && !new RegExp(a.pattern).test(v)) return 'Invalid format.';
+ return '';
+ };
+ const view = vnode => {
+ const a = vnode.attrs;
+ const invalid = validate(a);
+ const attrs = {
+ id: a.id, tabindex: a.tabindex, placeholder: a.placeholder,
+ rows: a.rows, cols: a.cols, onfocus: a.onfocus,
+ class: (a.class||'') + (invalid ? ' invalid' : ''),
+ oninput: ev => {
+ let v = ev.target.value;
+ if (a.type === 'number') v = Math.floor(v.replace(/[^0-9]+/g, '')||0);
+ a.data[a.field] = v;
+ a.oninput && a.oninput(v);
+ },
+ oncreate: a.focus ? v => v.dom.focus() : null,
+ };
+ return [
+ a.type === 'textarea'
+ ? m('textarea', { ...attrs }, a.data[a.field])
+ : m('input', { ...attrs, value: a.data[a.field] === null ? '' : a.data[a.field],
+ type: a.type === 'email' ? 'email' : a.type === 'password' ? 'password' : 'text',
+ }),
+ invalid ? m('p.invalid', invalid) : null,
+ ];
+ };
+ // Searching the DOM for labels on every update isn't very optimal, but it hasn't been an issue so far.
+ const onupdate = vnode => vnode.attrs.id && $$('label[for='+vnode.attrs.id+']')
+ .map(el => el.classList.toggle('invalid', !!validate(vnode.attrs)));
+ return {view,onupdate};
+};
+
+
+// Handy <select> abstraction.
+// Attrs:
+// - data + field -> value is read from and written to data[field]
+// - value -> alternative to data+field
+// - options -> array of [value,label] options
+// - oninput -> called after value has changed
+// - id
+// - class
+//
+// 'value's are compared with '===' for equality, arrays are recursed into.
+const _eql = (a,b) => a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x,i) => _eql(x,b[i])));
+window.Select = { view: v => m('select',
+ {
+ id: v.attrs.id, class: v.attrs.class,
+ oninput: ev => {
+ const val = v.attrs.options[ev.target.selectedIndex][0];
+ if (v.attrs.data) v.attrs.data[v.attrs.field] = val;
+ v.attrs.oninput && v.attrs.oninput(val);
+ },
+ }, v.attrs.options.map(([value,label]) => m('option', { selected: _eql(v.attrs.data ? v.attrs.data[v.attrs.field] : v.attrs.value, value) }, label))
+)};
+
+
+
+
+// BBCode & Markdown editor with preview button.
+// Attrs:
+// - data + field -> raw text is read from and written to data[field]
+// - header -> element to draw at the top-left
+// - attrs -> attrs to add to the Input
+// - type -> 'bb' || 'markdown', defaults to bb
+// - full -> Add 'full' class for full-width input
+window.TextPreview = initVnode => {
+ var preview = false;
+ var html = null;
+ const {data,field} = initVnode.attrs;
+ const api = new Api(initVnode.attrs.type === 'markdown' ? 'Markdown' : 'BBCode');
+
+ const unload = () => {
+ api.abort();
+ preview = false;
+ return false;
+ };
+
+ const load = () => {
+ if (html) {
+ preview = true;
+ } else {
+ api.call({content: data[field]},
+ res => { preview = true; html = res.html; },
+ () => { preview = true; html = '<b>'+api.error+'</b>'; },
+ );
+ }
+ return false;
+ };
+
+ const view = vnode => m('div.textpreview', { class: vnode.attrs.full ? 'full' : null },
+ m('div',
+ m('div', vnode.attrs.header),
+ m('div', data[field].length == 0 ? {class:'invisible'}:null,
+ api.loading() ? m('span.spinner') : null,
+ preview ? m('a[href=#]', {onclick: unload}, 'Edit') : m('span', 'Edit'),
+ preview ? m('span', 'Preview') : m('a[href=#]', {onclick: load}, 'Preview'),
+ ),
+ ),
+ m(Input, { ...vnode.attrs.attrs,
+ type: 'textarea',
+ class: (vnode.attrs.attrs.class||'') + (preview ? ' hidden' : ''),
+ data, field, oninput: e => html = null
+ }),
+ preview ? m('div.preview', { class: vnode.attrs.type === 'markdown' ? 'docs' : null }, m.trust(html)) : null,
+ );
+ return {view};
+};
+
+
+// Release dates are integers with the following format: 0, 1 or yyyymmdd
+// Special values
+// 0 -> unknown
+// 1 -> "today" (only used as filter)
+// 99999999 -> TBA
+// yyyy9999 -> year known, month & day unknown
+// yyyymm99 -> year & month known, day unknown
+//
+// This component provides a friendly input for such dates.
+// Attrs:
+// - value
+// - oninput -> callback accepting the new value
+// - id -> id of the first select input
+// - today -> bool, whether "today" should be accepted as an option
+// - unknown -> bool, whether "unknown" should be accepted as an option
+window.RDate = {
+ expand: v => ({
+ y: Math.floor(v / 10000),
+ m: Math.floor(v / 100) % 100,
+ d: v % 100,
+ }),
+ compact: ({y,m,d}) => y * 10000 + m * 100 + d,
+ maxDay: ({y,m}) => new Date(y, m, 0).getDate(),
+ normalize: ({y,m,d}) =>
+ y === 0 ? { y: 0, m: 0, d: d?1:0 } :
+ y === 9999 ? { y: 9999, m: 99, d: 99 } :
+ m === 0 || m === 99 ? { y, m: 99, d: 99 } :
+ { y,m, d: d === 0 || d === 99 ? 99 : Math.min(d, RDate.maxDay({y,m})) },
+ months: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ],
+ fmt: ({y,m,d}) =>
+ y === 0 ? (d ? 'Today' : 'Unknown') :
+ y === 9999 ? 'TBA' :
+ String(y) + (m === 0 ? '' : '-'+String(m).padStart(2,0) + (d === 0 ? '' : '-'+String(d).padStart(2,0))),
+ view: vnode => {
+ const v = RDate.expand(vnode.attrs.value);
+ const oninput = ev => vnode.attrs.oninput && vnode.attrs.oninput(Math.floor(ev.target.options[ev.target.selectedIndex].value));
+ const o = (e,l) => {
+ const value = RDate.compact(RDate.normalize({...v, ...e}));
+ return m('option', { value, selected: value === vnode.attrs.value }, l);
+ };
+ return [
+ m('select', {oninput, id: vnode.attrs.id},
+ vnode.attrs.today ? o({y:1}, 'Today') : null,
+ vnode.attrs.unknown ? o({y:0}, 'Unknown') : null,
+ o({y:9999}, 'TBA'),
+ range(new Date().getFullYear()+5, 1980, -1).map(y => o({y},y)),
+ ),
+ v.y > 0 && v.y < 9999 ? m('select', {oninput},
+ o({m:99}, '- month -'),
+ range(1, 12).map(m => o({m}, m + ' (' + RDate.months[m-1] + ')')),
+ ) : null,
+ v.m > 0 && v.m < 99 ? m('select', {oninput},
+ o({d:99}, '- day -'),
+ range(1, RDate.maxDay(v)).map(d => o({d},d)),
+ ) : null,
+ ];
+ },
+};
diff --git a/js/basic/ds.js b/js/basic/ds.js
new file mode 100644
index 00000000..f4756daf
--- /dev/null
+++ b/js/basic/ds.js
@@ -0,0 +1,450 @@
+// Dialog/Dropdown Select/Search.
+// i.e. a selection thingy component.
+
+// global dialog element, initialized lazily, reused by different instances as
+// there can only be one dialog open at a time.
+let globalObj;
+// Points to the DS object that is currently active (or null).
+let activeInstance;
+
+const setupObj = () => {
+ if (globalObj) return;
+ globalObj = document.createElement('div');
+ document.body.appendChild(globalObj);
+ m.mount(globalObj, {
+ view: v => activeInstance ? activeInstance.view(v) : [],
+ });
+};
+
+const keydown = ev => {
+ if (activeInstance) activeInstance.keydown(ev);
+ m.redraw();
+};
+
+const position = () => {
+ const obj = $('#ds');
+ if(!obj) return;
+
+ const margin = 5;
+
+ const inst = activeInstance;
+ const opener = inst.opener.getBoundingClientRect(); // BUG: this doesn't work if ev.target is inside a positioned element
+ const header = obj.children[0].getBoundingClientRect().height;
+ const cols = Math.max(1, Math.min(Math.floor((window.innerWidth - margin*2) / inst.width), inst.maxCols||1));
+ const width = Math.min(window.innerWidth - margin*2, inst.width*cols);
+ const left = Math.max(margin,
+ opener.x + opener.width - width,
+ Math.min(window.innerWidth - width - 2*margin, opener.x),
+ );
+
+ const top = opener.y + opener.height;
+ const height = Math.max(header + 20, Math.min(window.innerHeight - margin*2, window.innerHeight - top - margin));
+
+ obj.style.top = (top + window.scrollY) + 'px';
+ obj.style.left = (left + window.scrollX) + 'px';
+ obj.style.width = width + 'px';
+ const d = obj.children[1];
+ if (d && d.tagName == 'DIV') {
+ d.style.maxHeight = (height - header) + 'px';
+ d.children[0].style.columnCount = cols;
+ }
+
+ const e = obj.querySelector('li.active');
+ if (e) d.scrollTop = Math.max(Math.min(e.offsetTop, d.scrollTop), e.offsetTop + e.offsetHeight - d.offsetHeight);
+};
+
+const close = ev => {
+ if (!activeInstance) return;
+ if (ev && (globalObj.contains(ev.target) || activeInstance.opener.contains(ev.target))) return;
+ if (!ev) activeInstance.opener.focus();
+ if (activeInstance) activeInstance.abort();
+ activeInstance = null;
+ document.removeEventListener('click', close);
+ document.removeEventListener('keydown', keydown);
+ document.removeEventListener('scroll', position);
+ removeEventListener('resize', position);
+ m.redraw();
+};
+
+
+// Constructor options (all optional):
+// - width
+// - maxCols
+// - placeholder
+// - more
+// Adds a "type for more options" as last option if search is empty.
+// - nosearch
+// Disable search input
+// - onselect(obj,checked)
+// Called when an item has been selected. 'checked' is always true for
+// single-selection dropdowns.
+// - props(obj)
+// Called on each displayed object, should return null if the object should
+// be filtered out or an object otherwise. The object supports the
+// following options:
+// - selectable: boolean, default true
+// - append: vdom node to append to the item
+// - checked(obj)
+// Set for multiselection dropdowns.
+// Called on each displayed object, should return whether this item is
+// checked or not.
+// - checkall()
+// Adds a "check all" button.
+// - uncheckall()
+// Adds an "uncheck all" button.
+//
+// To use a DS object as a selection dropdown:
+// m(DS.Button, {ds}, ...)
+// Or to autocomplete an input field:
+// m(DS.Input, {ds, ...})
+// Most of the above constructor options are ignored for autocompletion.
+class DS {
+ constructor(source, opts) {
+ this.width = 400;
+ this.input = '';
+ this.source = source;
+ if (source.opts) Object.assign(this, source.opts);
+ if (opts) Object.assign(this, opts);
+ this.open = this.open.bind(this);
+ this.list = [];
+ }
+
+ open(opener, autocomplete) {
+ if (activeInstance === this) return close();
+ setupObj();
+ activeInstance = this;
+ this.autocomplete = autocomplete;
+ this.opener = opener;
+ this.focus = v => { this.focus = null; v.dom.focus() };
+ document.addEventListener('click', close);
+ document.addEventListener('keydown', keydown);
+ document.addEventListener('scroll', position);
+ addEventListener('resize', position);
+ this.setInput(this.input);
+ }
+
+ select() {
+ const obj = this.list.find(e => e.id === this.selId);
+ if (!obj) return;
+ if (this.autocomplete) this.autocomplete(this.source.stringify ? this.source.stringify(obj) : obj.id);
+ else if (this.onselect) this.onselect(obj, !this.checked || !this.checked(obj));
+ if (!this.checked) {
+ close();
+ this.setInput('');
+ this.selId = null;
+ }
+ }
+
+ setSel(dir=1) {
+ let i = this.list.findIndex(e => e.id === this.selId) + dir;
+ for (; i >= 0 && i < this.list.length; i+=dir)
+ if (this.list[i]._props.selectable) {
+ this.selId = this.list[i].id;
+ return;
+ }
+ }
+
+ // Ignore the hover event for 200ms after calling this. In some cases a
+ // redraw/reselect is done that changes the positioning of the item
+ // currently under the cursor; that will fire an onmouseover event without
+ // it being the user's intent.
+ // The 200ms is a weird magic number that will not work reliably.
+ // This is an ugly hack, I'd rather see a better solution. :/
+ skipHover() {
+ this.doSkipHover = new Date();
+ }
+
+ keydown(ev) {
+ if (ev.key == 'ArrowDown') {
+ this.setSel();
+ this.skipHover();
+ ev.preventDefault();
+ } else if (ev.key == 'ArrowUp') {
+ this.setSel(-1);
+ this.skipHover();
+ ev.preventDefault();
+ } else if (ev.key == 'Escape' || ev.key == 'Esc') {
+ close();
+ } else if (ev.key == 'Tab') {
+ const f = this.list.find(e => e.id === this.selId);
+ ev.shiftKey || !f ? close() : this.select();
+ if (this.checked) close(); // Tab always closes, even on multiselection boxes
+ if (!this.autocomplete) ev.preventDefault();
+ }
+ }
+
+ setList(lst) {
+ this.list = [];
+ this.skipHover();
+ let hasSel = false;
+ for (const e of lst) {
+ e._props = this.props ? this.props(e) : {};
+ if (e._props === null) continue;
+ if (!('selectable' in e._props)) e._props.selectable = true;
+ this.list.push(e);
+ if (e.id === this.selId) hasSel = true;
+ }
+ if(!hasSel && (!this.autocomplete || this.input !== '')) this.setSel();
+ }
+
+ abort() {
+ clearTimeout(this.loadingTimer);
+ this.loadingStr = this.loadingTimer = null;
+ if (this.source.api) this.source.api.abort();
+ }
+
+ setInput(str_, skipTimer) {
+ this.input = str_;
+ if (activeInstance !== this) return;
+ const src = this.source;
+ const str = str_.trim();
+ if (src.init && src._initState !== 2) {
+ src._initState = 1;
+ src.init(src, () => {
+ src._initState = 2;
+ this.setInput(this.input);
+ });
+ return;
+ }
+ if (this.loadingStr === str && !skipTimer) return;
+ this.abort();
+ if (src.cache && src.cache[str]) {
+ this.setList(src.cache[str]);
+ return;
+ }
+ this.loadingStr = str;
+ if (src.api && !skipTimer) {
+ this.loadingTimer = setTimeout(() => { this.setInput(this.input, true); m.redraw() }, 500);
+ return;
+ }
+ src.list(src, str, res => {
+ this.loadingStr = null;
+ this.setList(res);
+ if (src.cache) src.cache[str] = res;
+ });
+ }
+
+ loading() {
+ return this.loadingTimer || (this.source.api && this.source.api.loading());
+ }
+
+ view() {
+ const item = e => {
+ const p = e._props;
+ return m('li', {
+ key: e.id,
+ class: this.selId === e.id ? 'active' : !p.selectable ? 'unselectable' : null,
+ onmouseover: p.selectable ? () => {
+ if (this.doSkipHover && ((new Date()).getTime()-this.doSkipHover.getTime()) < 200) return;
+ this.selId = e.id;
+ } : null,
+ onclick: p.selectable ? () => this.select(this.selId = e.id) : null,
+ }, m('span', p.selectable ? '» ' : 'x '),
+ this.checked ? [ m('input[type=checkbox]', { style: { visible: p.selectable ? 'visible' : 'hidden' }, checked: this.checked(e) }), ' ' ] : null,
+ this.source.view(e),
+ p.append,
+ );
+ };
+ return m('form#ds', {
+ onsubmit: ev => { ev.preventDefault(); this.select() },
+ onupdate: position,
+ oncreate: position,
+ }, m('div', this.nosearch || this.autocomplete ? [] : [
+ m('div',
+ m('input[type=text]', {
+ oncreate: this.focus, onupdate: this.focus,
+ value: this.input,
+ oninput: ev => this.setInput(ev.target.value),
+ placeholder: this.placeholder,
+ }),
+ m('span', {class: this.loading() ? 'spinner' : ''}, this.loading() ? null : m(Icon.Search)),
+ ),
+ this.checkall ? m('div', m(Button.CheckAll, { onclick: this.checkall })) : null,
+ this.uncheckall ? m('div', m(Button.UncheckAll, { onclick: this.uncheckall })) : null,
+ ]),
+ this.source.api && this.source.api.error
+ ? m('b', this.source.api.error)
+ : this.autocomplete && this.loading() ? m('span.spinner')
+ : !this.loading() && this.input.trim() !== '' && this.list.length == 0
+ ? m('em', 'No results')
+ : m('div', m('ul',
+ this.list.map(item),
+ this.more && this.input === '' ? m('li', m('small', 'Type for more options')) : null,
+ )),
+ );
+ }
+};
+
+
+DS.Button = {view: vnode => m('button.ds[type=button]', {
+ class: vnode.attrs.class,
+ onclick: ev => { ev.preventDefault(); vnode.attrs.onclick ? vnode.attrs.onclick(ev) : vnode.attrs.ds && vnode.attrs.ds.open(ev.target, null) },
+ }, vnode.children, m('span.invisible', 'X'), m(Icon.ChevronDown)
+)};
+
+
+// Wrapper around an Input component, accepts the same attrs as an Input in
+// addition to a 'ds' attribute to provide autocompletion.
+DS.Input = {view: vnode => {
+ const a = vnode.attrs;
+ const open = () => {
+ a.ds.setInput(a.data[a.field]);
+ a.ds.open(vnode.dom.childNodes[0], v => {
+ a.data[a.field] = v;
+ a.oninput && a.oninput(v);
+ });
+ };
+ return m('form.ds', {
+ onsubmit: ev => {
+ ev.preventDefault();
+ const par = ev.target.parentNode.closest('form');
+ if (activeInstance === a.ds) { activeInstance.select(); close(); }
+ // requestSubmit() is a fairly recent browser addition, need to test for it.
+ // Browsers without it will simply ignore the enter key, which is 'kay-ish as well.
+ else if (par && par.requestSubmit) par.requestSubmit();
+ } },
+ m(Input, {
+ ...a,
+ onfocus: ev => {
+ open();
+ a.ds.selId = null; // Don't select anything yet, we don't want tabbing in and out of the input to change anything
+ a.ds.setInput(''); // Pretend the input is empty, so we get the default listing when the input hasn't been modified
+ a.onfocus && a.onfocus(ev);
+ },
+ oninput: v => {
+ if (activeInstance !== a.ds) open();
+ a.ds.setInput(v);
+ a.oninput && a.oninput(v);
+ },
+ }),
+ );
+}};
+
+
+// Source interface:
+// - cache
+// Optional cache object, will be used to memoize calls to list()
+// - opts
+// Default DS constructor options.
+// - api
+// Optional Api object.
+// Used for a loading indicator & error reporting.
+// abort() is called whenever the input is changed.
+// If present, calls to list() will be delayed/throttled.
+// - init(source, callback)
+// Optional, called when the source is first used.
+// Should call callback() to signal that list() is ready to be used.
+// - list(source, str, callback)
+// Should run callback([objects]).
+// Each object must have a string 'id'
+// - view(obj)
+// Should return a vnode for the given object
+// - stringify(obj)
+// Should return a string representation of the given object.
+// Only used for autocompletion, defaults to obj.id.
+
+const tt_view = obj => [
+ obj.group_name ? m('small', obj.group_name, ' / ') : null,
+ obj.name,
+ obj.hidden && !obj.locked ? m('small', ' (awaiting approval)') : obj.hidden ? m('small', ' (deleted)') :
+ !obj.searchable && !obj.applicable ? m('small', ' (meta)') :
+ !obj.searchable ? m('small', ' (not searchable)') : !obj.applicable ? m('small', ' (not applicable)') : null
+];
+
+DS.Tags = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search tags...' },
+ api: new Api('Tags'),
+ list: (src, str, cb) => src.api.call({ search: str }, res => cb(res.results)),
+ view: tt_view,
+};
+
+DS.Traits = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search traits...' },
+ api: new Api('Traits'),
+ list: (src, str, cb) => src.api.call({ search: str }, res => cb(res.results)),
+ view: tt_view,
+};
+
+DS.VNs = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search visual novels...' },
+ api: new Api('VN'),
+ list: (src, str, cb) => src.api.call({ search: [str] }, res => cb(res.results)),
+ view: obj => [ m('small', obj.id, ': '), obj.title ],
+};
+
+DS.Producers = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search producers...' },
+ api: new Api('Producers'),
+ list: (src, str, cb) => src.api.call({ search: [str] }, res => cb(res.results)),
+ view: obj => [ m('small', obj.id, ': '), obj.name ],
+};
+
+DS.Engines = {
+ api: new Api('Engines'),
+ opts: { width: 250 },
+ init: (src, cb) => src.api.call({}, res => cb(src.res = res.results, src.api = null)),
+ list: (src, str, cb) => cb(src.res.filter(e => e.id.toLowerCase().includes(str.toLowerCase())).slice(0,30)),
+ view: obj => [ obj.id, m('small', ' ('+obj.count+')') ],
+};
+
+DS.DRM = {
+ api: new Api('DRM'),
+ opts: { width: 250 },
+ init: (src, cb) => src.api.call({}, res => cb(src.res = res.results, src.api = null)),
+ list: (src, str, cb) => cb(src.res.filter(e => e.id.toLowerCase().includes(str.toLowerCase())).slice(0,30)),
+ view: obj => [ obj.id, m('small', obj.state === 2 ? ' (deleted)' : ' ('+obj.count+')') ],
+};
+
+DS.Resolutions = {
+ api: new Api('Resolutions'),
+ opts: { width: 200 },
+ init: (src, cb) => src.api.call({}, res => cb(src.res = res.results, src.api = null)),
+ list: (src, str, cb) => cb(src.res.filter(e => e.id.toLowerCase().includes(str.toLowerCase())).slice(0,30)),
+ view: obj => [ obj.id, m('small', ' ('+obj.count+')') ],
+};
+
+const Lang = f => ({
+ opts: { width: 250, maxCols: 3 },
+ list: (src, str, cb) => cb(vndbTypes.language
+ .filter(([id,label]) => f(id) && (str === id.toLowerCase() || label.toLowerCase().includes(str.toLowerCase())))
+ .anySort(([id,label,,rank]) => [id.toLowerCase() !== str.toLowerCase(), !label.toLowerCase().startsWith(str.toLowerCase()), 99-rank])
+ .map(([id,label]) => ({id,label}))
+ ),
+ view: obj => [ LangIcon(obj.id), obj.label ]
+});
+
+DS.Lang = Lang(() => true);
+// Chinese has separate language entries for the scripts
+DS.ScriptLang = Lang(l => l !== 'zh');
+DS.LocLang = Lang(l => l !== 'zh-Hans' && l !== 'zh-Hant');
+
+DS.Platforms = {
+ opts: { width: 250, maxCols: 3 },
+ list: (src, str, cb) => cb(vndbTypes.platform
+ .filter(([id,label]) => str.toLowerCase() === id.toLowerCase() || label.toLowerCase().includes(str.toLowerCase()))
+ .anySort(([id,label]) => str ? [id.toLowerCase() !== str.toLowerCase(), !label.toLowerCase().startsWith(str.toLowerCase()), label] : 0)
+ .map(([id,label]) => ({id,label}))
+ ),
+ view: obj => [ PlatIcon(obj.id), obj.label ]
+};
+
+
+// Wrap a source to add a "Create new entry" option.
+// Args:
+// - source
+// - createobj: (str) => obj, should return an obj to add an option or null to not add anything.
+// - view: obj => html
+DS.New = (src, createobj, view) => ({...src,
+ list: (x, str, cb) => src.list(x, str, lst => {
+ const obj = createobj(str);
+ if (obj && !lst.find(o => o.id === obj.id)) lst.unshift({...obj, _create:true});
+ cb(lst);
+ }),
+ view: obj => obj._create === true ? view(obj) : src.view(obj),
+});
+
+window.DS = DS;
diff --git a/js/basic/elm-support.js b/js/basic/elm-support.js
new file mode 100644
index 00000000..1f78f2f9
--- /dev/null
+++ b/js/basic/elm-support.js
@@ -0,0 +1,112 @@
+if(!pageVars.elm) return;
+
+// See Lib/Ffi.elm
+window.elmFfi_innerHtml = (wrap) => s => ({$: 'a2', n: 'innerHTML', o: wrap(s)});
+window.elmFfi_elemCall = (wrap,call) => call;
+window.elmFfi_fmtFloat = () => val => prec => val.toLocaleString('en-US', { minimumFractionDigits: prec, maximumFractionDigits: prec });
+
+const url_static = $('link[rel=stylesheet]').href.replace(/^(https?:\/\/[^/]+)\/.*$/, '$1');
+window.elmFfi_urlStatic = () => url_static;
+
+
+
+var preload_urls = {};
+
+const ports = Object.entries({
+ // ImageFlagging
+ preload: () => url => {
+ if(Object.keys(preload_urls).length > 100)
+ preload_urls = {};
+ if(!preload_urls[url]) {
+ preload_urls[url] = new Image();
+ preload_urls[url].src = url;
+ }
+ },
+
+ // UList.LabelEdit
+ ulistLabelChanged: flags => pub => {
+ const l = $('#ulist_public_'+flags.vid);
+ if (l) {
+ l.setAttribute('data-publabel', pub?1:'');
+ l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
+ }
+ },
+
+ // UList.Opt
+ ulistVNDeleted: flags => b => {
+ const e = $('#ulist_tr_'+flags.vid);
+ e.parentNode.removeChild(e.nextElementSibling);
+ e.parentNode.removeChild(e);
+
+ // Have to restripe after deletion :(
+ const rows = $$('.ulist > table > tbody > tr');
+ for(var i=0; i<rows.length; i++)
+ rows[i].classList.toggle('odd', Math.floor(i/2) % 2 == 0);
+ },
+
+ ulistNotesChanged: flags => n => {
+ $('#ulist_notes_'+flags.vid).innerText = n;
+ },
+
+ ulistRelChanged: flags => rels => {
+ const e = $('#ulist_relsum_'+flags.vid);
+ e.classList.toggle('todo', rels[0] != rels[1]);
+ e.classList.toggle('done', rels[1] > 0 && rels[0] == rels[1]);
+ e.innerText = rels[0] + '/' + rels[1];
+ },
+
+ // UList.VoteEdit
+ ulistVoteChanged: flags => voted => {
+ const l = $('#ulist_public_'+flags.vid);
+ if (l) {
+ l.setAttribute('data-voted', voted?1:'');
+ l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
+ }
+ },
+
+ // VNEdit
+ ivRefresh: () => () => setTimeout(ivInit, 10),
+});
+
+
+// Some modules need a wrapper around their init() method.
+const wrap = {
+ ImageFlagging: (init, opt) => {
+ opt.flags.pWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
+ opt.flags.pHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
+ init(opt);
+ },
+
+ 'UList.LabelEdit': (init, opt) => {
+ opt.flags.uid = pageVars.uid;
+ opt.flags.labels = pageVars.labels;
+ init(opt);
+ },
+
+ 'UList.ManageLabels': (init, opt) => {
+ opt.flags = { uid: pageVars.uid, labels: pageVars.labels };
+ init(opt);
+ },
+
+ // This module is typically hidden, lazily load it only when the module is visible to speed up page load time.
+ 'UList.Opt': (init, opt) => {
+ const e = $('#collapse_vid'+opt.flags.vid);
+ if(e.checked) init(opt);
+ else e.addEventListener('click', () => init(opt), { once: true });
+ },
+}
+
+
+pageVars.elm.forEach((e,i) => {
+ const mod = e[0].split('.').reduce((p, c) => p[c], window.Elm);
+ const node = $('#elm'+i);
+ var opt = { node };
+ if (e.length > 1) opt.flags = e[1];
+ const init = o => {
+ var app = mod.init(o);
+ ports.forEach(([port, callback]) => {
+ if (app.ports[port]) app.ports[port].subscribe(callback(opt.flags));
+ });
+ };
+ wrap[e[0]] ? wrap[e[0]](init, opt) : init(opt)
+});
diff --git a/js/basic/index.js b/js/basic/index.js
new file mode 100644
index 00000000..5f599100
--- /dev/null
+++ b/js/basic/index.js
@@ -0,0 +1,55 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only
+// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/js
+// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt Expat
+// @source: https://code.blicky.net/yorhel/mithril-vndb
+// SPDX-License-Identifier: AGPL-3.0-only AND Expat
+
+// ^ LibreJS browser plugin only recognizes the first license tag in the file,
+// so it's kind of incorrect. Their spec doesn't appear to support bundling.
+
+"use strict";
+
+// Log errors to the server. This intentionally uses old-ish syntax and APIs.
+// (though it still won't catch parsing/syntax errors in this bundle...)
+window.onerror = function(ev, source, lineno, colno, error) {
+ if (/\/g\/[a-z]+\.js/.test(source)
+ // No clue what's up with these, sometimes happens in FF. Is Elm being initialized before the DOM is ready or something?
+ && !(/elm\.js/.test(source) && /InvalidStateError/.test(ev))
+ ) {
+ var h = new XMLHttpRequest();
+ var e = encodeURIComponent;
+ h.open('POST', '/js-error?2', true);
+ h.send('ev='+e(ev)+'&source='+e(source)+'&lineno='+e(lineno)+'&colno='+e(colno)+'&stack='+e(error.stack));
+ window.onerror = null; // One error per page is enough
+ }
+ return false;
+};
+
+@include .gen/mithril.js
+@include .gen/types.js
+@include polyfills.js
+
+// Library stuff
+@include utils.js
+@include api.js
+@include components.js
+@include ds.js
+
+// A bunch of old fashioned DOM manipulation features.
+@include checkall.js
+@include checkhidden.js
+@include mainbox-summarize.js
+@include searchtabs.js
+@include sethash.js
+@include ulist-actiontabs.js
+@include ulist-labelfilters.js
+
+@include elm-support.js
+
+// Widgets
+@include TableOpts.js
+
+// Image viewer; after loading Elm modules to ensure it sees the screenshots in VNEdit.
+@include iv.js
+
+// @license-end
diff --git a/js/basic/iv.js b/js/basic/iv.js
new file mode 100644
index 00000000..772a4f42
--- /dev/null
+++ b/js/basic/iv.js
@@ -0,0 +1,220 @@
+/* Simple image viewer widget. Usage:
+ *
+ * <a href="full_image.jpg" data-iv="{width}x{height}:{category}:{flagging}">..</a>
+ *
+ * Clicking on the above link will cause the image viewer to open
+ * full_image.jpg. The {category} part can be empty or absent. If it is not
+ * empty, next/previous links will show up to point to the other images within
+ * the same category. The {flagging} part can also be empty or absent,
+ * otherwise it should be a string in the format "svn", where s and v indicate
+ * the sexual/violence scores (0-2) and n the number of votes.
+ *
+ * ivInit() should be called when links with "data-iv" attributes are
+ * dynamically added or removed from the DOM.
+ */
+
+// Cache of image categories and the list of associated link objects. Used to
+// quickly generate the next/prev links.
+var cats;
+
+// DOM elements, lazily initialized in create_div()
+var ivparent = null;
+var ivimg;
+var ivfull;
+var ivnext;
+var ivprev;
+var ivhovernext;
+var ivhoverprev;
+var ivload;
+var ivflag;
+
+var imgw;
+var imgh;
+
+function create_div() {
+ if(ivparent)
+ return;
+ ivparent = document.createElement('div');
+ ivparent.className = 'ivview';
+ ivparent.style.display = 'none';
+ ivparent.onclick = function(ev) { ev.stopPropagation(); return true };
+
+ ivload = document.createElement('div');
+ ivload.className = 'spinner';
+ ivload.style.display = 'none';
+ ivparent.appendChild(ivload);
+
+ ivimg = document.createElement('div');
+ ivparent.appendChild(ivimg);
+
+ var ivlinks = document.createElement('div');
+ ivparent.appendChild(ivlinks);
+
+ ivfull = document.createElement('a');
+ ivlinks.appendChild(ivfull);
+
+ ivprev = document.createElement('a');
+ ivprev.onclick = show;
+ ivprev.textContent = '« previous';
+ ivlinks.appendChild(ivprev);
+
+ ivnext = document.createElement('a');
+ ivnext.onclick = show;
+ ivnext.textContent = 'next »';
+ ivlinks.appendChild(ivnext);
+
+ ivhoverprev = document.createElement('a');
+ ivhoverprev.onclick = show;
+ ivhoverprev.className = "left-pane";
+ ivimg.appendChild(ivhoverprev);
+
+ ivhovernext = document.createElement('a');
+ ivhovernext.onclick = show;
+ ivhovernext.className = "right-pane";
+ ivimg.appendChild(ivhovernext);
+
+ ivflag = document.createElement('a');
+ ivlinks.appendChild(ivflag);
+
+ $('body').appendChild(ivparent);
+}
+
+
+// Find the next (dir=1) or previous (dir=-1) non-hidden link object for the category.
+function findnav(cat, i, dir) {
+ for(var j=i+dir; j>=0 && j<cats[cat].length; j+=dir)
+ if(cats[cat][j].offsetWidth > 0 && cats[cat][j].offsetHeight > 0)
+ return cats[cat][j];
+ return 0
+}
+
+
+// fix properties of the prev/next links
+function fixnav(lnk, cat, i, dir) {
+ var a = cat ? findnav(cat, i, dir) : 0;
+ lnk.style.visibility = a ? 'visible' : 'hidden';
+ lnk.href = a ? a.href : '#';
+ lnk.iv_i = a ? a.iv_i : 0;
+ lnk.setAttribute('data-iv', a ? a.getAttribute('data-iv') : '');
+}
+
+
+function keydown(e) {
+ if(e.key == 'ArrowLeft' && ivprev.style.visibility == 'visible')
+ ivprev.click();
+ else if(e.key == 'ArrowRight' && ivnext.style.visibility == 'visible')
+ ivnext.click();
+ else if(e.key == 'Escape' || e.key == 'Esc')
+ ivClose();
+}
+
+
+function resize() {
+ var w = imgw;
+ var h = imgh;
+ var ww = typeof(window.innerWidth) == 'number' ? window.innerWidth : document.documentElement.clientWidth;
+ var wh = typeof(window.innerHeight) == 'number' ? window.innerHeight : document.documentElement.clientHeight;
+ if(w+100 > ww || imgh+70 > wh) {
+ ivfull.textContent = w+'x'+h;
+ ivfull.style.visibility = 'visible';
+ if(w/h > ww/wh) { // width++
+ h *= (ww-100)/w;
+ w = ww-100;
+ } else { // height++
+ w *= (wh-70)/h;
+ h = wh-70;
+ }
+ } else
+ ivfull.style.visibility = 'hidden';
+ var dw = w;
+ var dh = h+20;
+ dw = dw < 200 ? 200 : dw;
+
+ ivparent.style.width = dw+'px';
+ ivparent.style.height = dh+'px';
+ ivparent.style.left = ((ww - dw) / 2 - 10)+'px';
+ ivparent.style.top = ((wh - dh) / 2 - 20)+'px';
+ var img = ivimg.querySelector('img');
+ img.style.width = w+'px';
+ img.style.height = h+'px';
+}
+
+
+function show(ev) {
+ var u = this.href;
+ var opt = this.getAttribute('data-iv').split(':'); // 0:reso, 1:category, 2:flagging
+ var idx = this.iv_i;
+ imgw = Math.floor(opt[0].split('x')[0]);
+ imgh = Math.floor(opt[0].split('x')[1]);
+
+ create_div();
+ var imgs = ivimg.getElementsByTagName("img")
+ if (imgs.length !== 0)
+ ivimg.getElementsByTagName("img")[0].remove()
+
+ var img = document.createElement('img');
+ img.src = u;
+ ivfull.href = u;
+ img.onclick = ivClose;
+ img.onload = function() { ivload.style.display = 'none' };
+ ivimg.appendChild(img);
+
+ var flag = opt[2] ? opt[2].match(/^([0-2])([0-2])([0-9]+)$/) : null;
+ var imgid = u.match(/\/([a-z]{2})\/[0-9]{2}\/([0-9]+)\./);
+ if(flag && imgid) {
+ ivflag.href = '/'+imgid[1]+imgid[2];
+ ivflag.textContent = flag[3] == 0 ? 'Not flagged' :
+ (flag[1] == 0 ? 'Safe' : flag[1] == 1 ? 'Suggestive' : 'Explicit') + ' / ' +
+ (flag[2] == 0 ? 'Tame' : flag[2] == 1 ? 'Violent' : 'Brutal' ) + ' (' + flag[3] + ')';
+ ivflag.style.visibility = 'visible';
+ } else
+ ivflag.style.visibility = 'hidden';
+
+ ivparent.style.display = 'block';
+ ivload.style.display = 'block';
+ fixnav(ivprev, opt[1], idx, -1);
+ fixnav(ivnext, opt[1], idx, 1);
+ fixnav(ivhoverprev, opt[1], idx, -1);
+ fixnav(ivhovernext, opt[1], idx, 1);
+ resize();
+
+ document.addEventListener('click', ivClose);
+ document.addEventListener('keydown', keydown);
+ window.addEventListener('resize', resize);
+ ev.preventDefault();
+}
+
+
+window.ivClose = function(ev) {
+ var targetlink = ev ? ev.target : null;
+ while(targetlink && targetlink.nodeName.toLowerCase() != 'a')
+ targetlink = targetlink.parentNode;
+ if(targetlink && targetlink.getAttribute('data-iv'))
+ return false;
+ document.removeEventListener('click', ivClose);
+ document.removeEventListener('keydown', keydown);
+ window.removeEventListener('resize', resize);
+ ivparent.style.display = 'none';
+ var imgs = ivimg.getElementsByTagName("img")
+ if (imgs.length !== 0)
+ ivimg.getElementsByTagName("img")[0].remove()
+ return false;
+};
+
+
+window.ivInit = function() {
+ cats = {};
+ $$('a[data-iv]').forEach(function(o) {
+ if(o == ivnext || o == ivprev || o == ivfull || o == ivhoverprev || o == ivhovernext)
+ return;
+ o.addEventListener('click', show);
+ var cat = o.getAttribute('data-iv').split(':')[1];
+ if(cat) {
+ if(!cats[cat])
+ cats[cat] = [];
+ o.iv_i = cats[cat].length;
+ cats[cat].push(o);
+ }
+ });
+};
+ivInit();
diff --git a/js/basic/mainbox-summarize.js b/js/basic/mainbox-summarize.js
new file mode 100644
index 00000000..d6ece92d
--- /dev/null
+++ b/js/basic/mainbox-summarize.js
@@ -0,0 +1,31 @@
+// Adds a "more"/"less" link to the bottom of a mainbox depending on the
+// height of its contents.
+//
+// Usage:
+//
+// <article data-mainbox-summarize="200"> .. </div>
+
+const set = (d, h) => {
+ let expanded = true;
+ const a = document.createElement('a');
+ a.href = '#';
+ a.onclick = ev => {
+ ev && ev.preventDefault();
+ expanded = !expanded;
+ d.style.maxHeight = expanded ? null : h+'px';
+ d.style.overflowY = expanded ? null : 'hidden';
+ a.textContent = expanded ? '⇑ less ⇑' : '⇓ more ⇓';
+ };
+
+ const t = document.createElement('div');
+ t.className = 'summarize_more';
+ t.appendChild(a);
+ d.parentNode.insertBefore(t, d.nextSibling);
+ a.click();
+};
+
+$$('article[data-mainbox-summarize]').forEach(d => {
+ const h = Math.floor(d.getAttribute('data-mainbox-summarize'));
+ if(d.offsetHeight > h+100)
+ set(d, h)
+});
diff --git a/js/basic/polyfills.js b/js/basic/polyfills.js
new file mode 100644
index 00000000..f9cc5742
--- /dev/null
+++ b/js/basic/polyfills.js
@@ -0,0 +1,24 @@
+if (!Object.fromEntries)
+ Object.fromEntries = lst => {
+ let obj = {};
+ for (let [key, value] of lst) obj[key] = value;
+ return obj;
+ };
+
+if (!Array.prototype.flat)
+ Array.prototype.flat = function (depth=1) {
+ return depth < 1 ? this.slice() : this.reduce((acc,val) =>
+ acc.concat(Array.isArray(val) ? Array.prototype.flat.call(val, depth-1) : val),
+ []);
+ };
+
+if (!Array.prototype.flatMap)
+ Array.prototype.flatMap = function(f) { return this.map(f).flat(1) };
+
+if (!String.prototype.padStart)
+ String.prototype.padStart = function (len,s=' ') {
+ if (this.length > len) return this;
+ len -= this.length;
+ if (len > s.length) s += s.repeat(len/s.length);
+ return s.slice(0,len) + this;
+ };
diff --git a/js/basic/searchtabs.js b/js/basic/searchtabs.js
new file mode 100644
index 00000000..104e1fbe
--- /dev/null
+++ b/js/basic/searchtabs.js
@@ -0,0 +1,9 @@
+$$('#searchtabs a').forEach(l => l.onclick = ev => {
+ const str = $('#q').value;
+ const el = ev.target;
+ if(str.length > 0) {
+ if(el.href.indexOf('/g') >= 0 || el.href.indexOf('/i') >= 0)
+ el.href += '/list';
+ el.href += '?q=' + encodeURIComponent(str);
+ }
+});
diff --git a/js/basic/sethash.js b/js/basic/sethash.js
new file mode 100644
index 00000000..8a3a8ec8
--- /dev/null
+++ b/js/basic/sethash.js
@@ -0,0 +1,8 @@
+// Emulate setting a location.hash if none has been set.
+if(pageVars.sethash && location.hash.length <= 1) {
+ const e = $('#'+pageVars.sethash);
+ if(e) {
+ e.scrollIntoView();
+ e.classList.add('target');
+ }
+}
diff --git a/js/basic/ulist-actiontabs.js b/js/basic/ulist-actiontabs.js
new file mode 100644
index 00000000..eb4ef615
--- /dev/null
+++ b/js/basic/ulist-actiontabs.js
@@ -0,0 +1,6 @@
+const buttons = ['managelabels', 'savedefault', 'exportlist'];
+
+buttons.forEach(but => $$('#'+but).forEach(b => b.onclick = ev => {
+ ev.preventDefault();
+ buttons.forEach(but2 => $$('.'+but2).forEach(e => e.classList.toggle('hidden', but !== but2)))
+}))
diff --git a/js/basic/ulist-labelfilters.js b/js/basic/ulist-labelfilters.js
new file mode 100644
index 00000000..12ca5597
--- /dev/null
+++ b/js/basic/ulist-labelfilters.js
@@ -0,0 +1,11 @@
+const p = $('.labelfilters');
+if(!p) return;
+const multi = $('#form_l_multi');
+multi.parentNode.classList.remove('hidden');
+
+const l = $$('.labelfilters input[name=l]');
+l.forEach(el => el.addEventListener('click', () => {
+ if(multi.checked) return true;
+ l.forEach(el2 => el2.checked = el2 == el);
+ el.closest('form').submit();
+}));
diff --git a/js/basic/utils.js b/js/basic/utils.js
new file mode 100644
index 00000000..105a3dcb
--- /dev/null
+++ b/js/basic/utils.js
@@ -0,0 +1,63 @@
+// Because I'm lazy.
+window.$ = sel => document.querySelector(sel);
+window.$$ = sel => Array.from(document.querySelectorAll(sel));
+
+
+// Load global page-wide variables from <script id="pagevars">...</script> and
+// store them into window.pageVars.
+window.pageVars = (e => e ? JSON.parse(e.innerHTML) : {})($('#pagevars'));
+
+
+// Widget initialization, see README.md
+window.widget = (name, fun) =>
+ ((pageVars.widget || {})[name] || []).forEach(([id, data]) => {
+ const e = $('#widget'+id);
+ // m.mount() instantly wipes the contents of e, let's make a copy in case the widget needs something from it.
+ const oldContents = Array.from(e.childNodes);
+ m.mount(e, {view: ()=>m(fun, {data, oldContents})})
+ });
+
+
+// Return an array for the given (inclusive) range.
+window.range = (start, end, skip=1) => {
+ let a = [];
+ for (; skip > 0 ? start <= end : end <= start; start += skip) a.push(start);
+ return a;
+};
+
+
+// Compare two JS values, for the purpose of sorting.
+// Should only be used to compare values of identical types (or null).
+// Supports arrays, numbers, strings, bools and null.
+// Recurses into arrays.
+// Null always sorts last.
+const anyCmp = (a, b) => {
+ if (a === b) return 0;
+ if (a === null && b !== null) return 1;
+ if (b === null && a !== null) return -1;
+ if (typeof a === typeof b && (typeof a === 'number' || typeof a === 'string' || typeof a === 'boolean'))
+ return a < b ? -1 : 1;
+ if (Array.isArray(a) && Array.isArray(b)) {
+ let r = 0;
+ for (let i=0; !r && i<a.length && i<b.length; i++)
+ r = anyCmp(a[i], b[i]);
+ return r || anyCmp(a.length, b.length);
+ }
+ throw new Error('anyCmp(' + a + ', ' + b + ')');
+};
+
+
+// Return a sorted array according to anyCmp().
+// The optional 'f' argument can be used to transform elements for comparison.
+Array.prototype.anySort = function(f=x=>x) { return [...this].sort((a,b) => anyCmp(f(a),f(b))) };
+
+// Check whether an array has duplicates according to anyCmp().
+// Also accepts an optional 'f' argument.
+Array.prototype.anyDup = function(f=x=>x) {
+ const lst = this.anySort(f);
+ for (let i=1; i<lst.length; i++)
+ if (!anyCmp(f(lst[i-1]), f(lst[i]))) return true;
+ return false;
+};
+
+Array.prototype.intersperse = function(sep) { return this.reduce((a,v)=>[...a,v,sep],[]).slice(0,-1) };
diff --git a/js/contrib/DRMEdit.js b/js/contrib/DRMEdit.js
new file mode 100644
index 00000000..9c5c1135
--- /dev/null
+++ b/js/contrib/DRMEdit.js
@@ -0,0 +1,44 @@
+widget('DRMEdit', vnode => {
+ const data = vnode.attrs.data;
+ const api = new Api('DRMEdit');
+ const view = () => m(Form, {api, onsubmit: () => api.call(data)}, m('article',
+ m('h1', 'Edit DRM: '+data.name),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=name]', 'Name'),
+ m(Input, { id: 'name', class: 'mw', required: true, maxlength: 128, data, field: 'name' }),
+ m('p', 'May not have the same name as another DRM type.'),
+ m('p', 'Warning: changing the name affects all releases that have this DRM type assigned to it, including older revisions.'),
+ ),
+ m('fieldset',
+ m('label', 'State'),
+ m('label.check', m('input[type=radio]', { checked: data.state === 0, oninput: () => data.state = 0 }), ' New '),
+ m('label.check', m('input[type=radio]', { checked: data.state === 1, oninput: () => data.state = 1 }), ' Approved '),
+ m('label.check', m('input[type=radio]', { checked: data.state === 2, oninput: () => data.state = 2 }), ' Deleted'),
+ m('p', '"New" and "Approved" are functionally the same thing, but the distinction may be helpful with moderating new entries.'),
+ m('p', '"Deleted" entries are not available when editing a release entry, but may still be associated with existing releases and older revisions.'),
+ ),
+ m('fieldset',
+ m('label', 'Properties'),
+ vndbTypes.drmProperty.map(([id,name]) => m('label.check',
+ m('input[type=checkbox]', { checked: data[id], oninput: ev => data[id] = ev.target.checked }),
+ ' ', name
+ )).intersperse(m('br')),
+ ),
+ m('fieldset',
+ m('label[for=description]', 'Description'),
+ m(TextPreview, {
+ attrs: { id: 'description', maxlength: 10240, rows: 5 },
+ data, field: 'description',
+ }),
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Save]'),
+ m('input[type=button][value=Cancel]', { onclick: () => location.href = '/r/drm?'+data.ref }),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ),
+ ));
+ return {view};
+});
diff --git a/js/contrib/DocEdit.js b/js/contrib/DocEdit.js
new file mode 100644
index 00000000..e11c8369
--- /dev/null
+++ b/js/contrib/DocEdit.js
@@ -0,0 +1,29 @@
+widget('DocEdit', initVnode => {
+ const data = initVnode.attrs.data;
+ const api = new Api('DocEdit');
+ const view = () => m(Form, {api, onsubmit: () => api.call(data) },
+ m('article',
+ m('h1', 'Edit '+data.id),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=title]', 'Title'),
+ m(Input, { class: 'xw', required: true, maxlength: 200, data, field: 'title' }),
+ ),
+ ),
+ m('fieldset.form', m(TextPreview, {
+ data, field: 'content',
+ type: 'markdown', full: true,
+ attrs: { rows: 50 },
+ header: [
+ 'HTML and MultiMarkdown supported, which is ',
+ m('a[href=https://daringfireball.net/projects/markdown/basics][target=_blank]', 'Markdown'),
+ ' with some ',
+ m('a[href=http://fletcher.github.io/MultiMarkdown-5/syntax.html][target=_blank]', 'extensions'),
+ '.'
+ ]
+ })),
+ ),
+ m(EditSum, {data,api}),
+ );
+ return {view};
+});
diff --git a/js/contrib/ProducerEdit.js b/js/contrib/ProducerEdit.js
new file mode 100644
index 00000000..1c134db7
--- /dev/null
+++ b/js/contrib/ProducerEdit.js
@@ -0,0 +1,119 @@
+widget('ProducerEdit', initVnode => {
+ const data = initVnode.attrs.data;
+ const api = new Api('ProducerEdit');
+
+ const dupApi = new Api('Producers');
+ let dupCheck = !data.id;
+ const names = () => ([data.name, data.latin].concat(data.alias.split("\n")).map(s => s?s.trim():'').filter(s => s.length > 0));
+ const nameChange = () => {dupCheck = !!dupCheck};
+ const onsubmit = () => !dupCheck ? api.call(data) : dupApi.call(
+ {search: names()},
+ res => dupCheck = res.results.length ? res.results : false,
+ );
+
+ const lang = new DS(DS.LocLang, {onselect: obj => data.lang = obj.id});
+ const wikidata = { v: data.l_wikidata === null ? '' : 'Q'+data.l_wikidata };
+ const fields = () => [
+ m('fieldset',
+ m('label[for=type]', 'Type'),
+ m(Select, { id: 'type', class: 'mw', data, field: 'type', options: vndbTypes.producerType }),
+ ),
+ m('fieldset',
+ m('label[for=lang]', { class: data.lang ? null : 'invalid' }, 'Primary language'),
+ m(DS.Button, {class: 'mw', ds:lang}, data.lang ? Object.fromEntries(vndbTypes.language)[data.lang] : '-- select --'),
+ data.lang ? null : m('p.invalid', 'No language selected.'),
+ ),
+ m('fieldset',
+ m('label[for=website]', 'Website'),
+ m(Input, { id: 'website', class: 'xw', type: 'weburl', data, field: 'website' }),
+ ),
+ m('fieldset',
+ m('label[for=wikidata]', 'Wikidata ID'),
+ m(Input, { id: 'wikidata', class: 'mw',
+ data: wikidata, field: 'v',
+ pattern: '^Q?[1-9][0-9]{0,8}$',
+ oninput: v => { v = v.replace(/[^0-9]/g, ''); data.l_wikidata = v?v:null; wikidata.v = v?'Q'+v:''; },
+ }),
+ ),
+ m('fieldset',
+ m('label[for=description]', 'Description'),
+ m(TextPreview, {
+ data, field: 'description',
+ header: m('b', '(English please!)'),
+ attrs: { id: 'description', rows: 6, maxlength: 5000 },
+ }),
+ ),
+ ];
+
+ const prod = new DS(DS.Producers, {
+ onselect: obj => data.relations.push({pid: obj.id, name: obj.name, relation: 'old' }),
+ props: obj =>
+ obj.id === data.id ? { selectable: false, append: m('small', ' (this producer)') } :
+ data.relations.find(p => p.pid === obj.id) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ });
+ const relations = () => m('fieldset',
+ m('label', 'Related producers'),
+ data.relations.length === 0
+ ? m('p', 'No producers selected.')
+ : m('table', data.relations.map(p => m('tr', {key: p.pid},
+ m('td',
+ m(Button.Del, { onclick: () => data.relations = data.relations.filter(x => x !== p) }), ' ',
+ m(Select, { data: p, field: 'relation', options: vndbTypes.producerRelation }),
+ ),
+ m('td', m('small', p.pid, ': '), m('a[target=_blank]', { href: '/'+p.pid }, p.name)),
+ ))),
+ m(DS.Button, { ds: prod, class: 'mw' }, 'Add producer'),
+ );
+
+ const view = () => m(Form, {api: dupCheck ? dupApi : api, onsubmit},
+ m('article',
+ m('h1', data.id ? 'Edit producer' : 'Add producer'),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=name]', 'Name (original)'),
+ m(Input, { class: 'xw', required: true, maxlength: 200, data, field: 'name', oninput: nameChange }),
+ ),
+ !data.latin && !mayRomanize.test(data.name) ? null : m('fieldset',
+ m('label[for=name]', 'Name (latin)'),
+ m(Input, {
+ class: 'xw', required: mustRomanize.test(data.name), maxlength: 200, data, field: 'latin', placeholder: 'Romanization', oninput: nameChange,
+ invalid: data.latin === data.name || mustRomanize.test(data.latin) ? 'Romanization should only contain characters in the latin alphabet.' : null,
+ }),
+ ),
+ m('fieldset',
+ m('label[for=alias]', 'Aliases'),
+ m(Input, {
+ class: 'xw', type: 'textarea', rows: 3, maxlength: 500, data, field: 'alias', oninput: nameChange,
+ invalid: names().anyDup() ? 'List contains duplicate aliases.' : '',
+ }),
+ m('p', '(Un)official aliases, separated by a newline.'),
+ ),
+ dupCheck === false ? fields() : [],
+ ),
+ dupCheck === false ? m('fieldset.form',
+ m('legend', 'Database relations'),
+ relations()
+ ) : null,
+ ),
+ dupCheck === false ? [
+ m(EditSum, {data,api})
+ ] : dupCheck === true ? [m('article.submit',
+ m('input[type=submit][value=Continue]'),
+ dupApi.loading() ? m('span.spinner') : null,
+ dupApi.error ? m('b', m('br'), dupApi.error) : null,
+ )] : [
+ m('article',
+ m('h1', 'Possible duplicates'),
+ m('p',
+ 'The following is a list of producers that match the name(s) you gave. ',
+ 'Please check this list to avoid creating a duplicate producer entry.',
+ ),
+ m('ul', dupCheck.map(p => m('li', m('a[target=_blank]', { href: '/'+p.id }, p.name)))),
+ ),
+ m('article.submit',
+ m('input[type=button][value=Continue anyway]', { onclick: () => dupCheck = false }),
+ ),
+ ],
+ );
+ return {view};
+});
diff --git a/js/contrib/ReleaseEdit.js b/js/contrib/ReleaseEdit.js
new file mode 100644
index 00000000..441e6954
--- /dev/null
+++ b/js/contrib/ReleaseEdit.js
@@ -0,0 +1,479 @@
+const Titles = initVnode => {
+ const {data} = initVnode.attrs;
+ const ds = new DS(DS.ScriptLang, {
+ onselect: obj => {
+ const p = data.vntitles.find(t => t.lang === obj.id);
+ data.titles.push({ lang: obj.id, mtl: false, title: p?p.title:'', latin: p?p.latin:'', new: true });
+ if (data.titles.length === 1) data.olang = data.titles[0].lang;
+ },
+ props: obj => data.titles.find(t => t.lang === obj.id) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ });
+ const langs = Object.fromEntries(vndbTypes.language);
+ const view = () => m('fieldset.form',
+ m('legend', 'Titles & languages', HelpButton('titles')),
+ Help('titles',
+ m('p', 'List of languages that this release is available in.'),
+ m('p',
+ 'A release can have different titles for different languages. ',
+ 'The main language should always have a title, but this field can be left empty for other languages if it is the same as the main title.'
+ ),
+ m('p', m('strong', 'Main title: '),
+ 'The title for the language that the script was originally authored in, ',
+ 'or for translations, the primary language of the publisher.'
+ ),
+ m('p', m('strong', 'Machine translation: '),
+ 'Should be checked if automated programs, such as AI tools, were used to implement support for this language, either partially or fully. ',
+ 'Should be checked even if the translation has been edited by a human. ',
+ 'Should NOT be checked if the translation was entirely done by humans, even when its quality happens to be worse than machine translation.'
+ ),
+ ),
+ data.titles.map(t => m('fieldset', {key: t.lang},
+ m('label', { for: 'title-'+t.lang }, LangIcon(t.lang), langs[t.lang]),
+ m(Input, {
+ id: 'title-'+t.lang, class: 'xw',
+ maxlength: 300, required: t.lang === data.olang,
+ placeholder: t.lang === data.olang ? 'Title (in the original script)' : 'Title (leave empty if equivalent to the main title)',
+ data: t, field: 'title', focus: t.new,
+ }),
+ !t.latin && !mayRomanize.test(t.title) ? m('br') : m('span',
+ m('br'),
+ m(Input, {
+ class: 'xw', maxlength: 300, required: mustRomanize.test(t.title),
+ data: t, field: 'latin', placeholder: 'Romanization',
+ invalid: t.latin === t.title || mustRomanize.test(t.latin) ? 'Romanization should only contain characters in the latin alphabet.' : null,
+ }),
+ m('br'),
+ ),
+ data.titles.length === 1 ? [] : [
+ m('span', m('label.check',
+ m('input[type=radio]', { checked: t.lang === data.olang, oninput: ev => data.olang = t.lang }),
+ ' Main title '
+ )),
+ ],
+ m('span', m('label.check',
+ m('input[type=checkbox]', { checked: t.mtl, oninput: ev => t.mtl = ev.target.checked }),
+ ' Machine translation '
+ )),
+ m('input[type=button][value=Remove]', {
+ class: t.lang === data.olang ? 'invisible' : null,
+ onclick: () => data.titles = data.titles.filter(x => x !== t)
+ }),
+ )),
+ m(DS.Button, {ds}, 'Add language'),
+ data.titles.length > 0 ? null : m('p.invalid', 'At least one language must be selected.'),
+ );
+ return {view};
+};
+
+
+const Status = initVnode => {
+ const {data} = initVnode.attrs;
+ const view = () => m('fieldset.form',
+ m('legend', 'Status'),
+ m('fieldset', m('label.check',
+ m('input[type=checkbox]', { checked: data.official, oninput: ev => data.official = ev.target.checked }),
+ ' Official ', HelpButton('official'),
+ )),
+ Help('official',
+ 'Whether the release is official, i.e. made or sanctioned by the original developer. ',
+ 'The official status is in relation to the visual novel that the release is linked to, ',
+ 'so even if the visual novel itself is an unofficial fanfic in some franchise, the release can still be official.'
+ ),
+ m('fieldset', m('label.check',
+ m('input[type=checkbox]', { checked: data.patch, oninput: ev => data.patch = ev.target.checked }),
+ ' Patch (*)', HelpButton('patch'),
+ )),
+ Help('patch',
+ m('p',
+ 'A patch is not a standalone release, but instead requires another release in order to be used. ',
+ 'It may be helpful to indicate which releases this patch applies to in the notes.'
+ ),
+ m('p',
+ '*) The following release fields are unavailable for patch releases: Engine, Resolution, Voiced and Animation. ',
+ 'These fields are automatically reset on form submission when the patch flag is set.'
+ ),
+ ),
+ m('fieldset', m('label.check',
+ m('input[type=checkbox]', { checked: data.freeware, oninput: ev => data.freeware = ev.target.checked }),
+ ' Freeware', HelpButton('freeware'),
+ )),
+ Help('freeware', 'Set if this release is available at no cost.'),
+ m('fieldset', m('label.check',
+ m('input[type=checkbox]', { checked: data.has_ero, oninput: ev => data.has_ero = ev.target.checked }),
+ ' Contains erotic scenes (*)', HelpButton('has_ero'),
+ )),
+ Help('has_ero',
+ m('p',
+ 'Not all 18+ titles have erotic content and not all sub-18+ titles are free of it, ',
+ 'hence the presence of a checkbox which signals that the game contains erotic content. ',
+ 'Refer to the ', m('a[href=/d3#2.1][target=_blank]', 'detailed guidelines'), ' for what should (not) be considered "erotic scenes".'
+ ),
+ m('p',
+ '*) The censoring and erotic scene animation fields are only available for releases that contain erotic scenes. ',
+ 'These fields are automatically reset on form submission when the checkbox is unset.'
+ ),
+ ),
+ m('fieldset',
+ m('label[for=minage]', 'Age rating', HelpButton('minage')),
+ m(Select, { id: 'minage', class: 'mw', data, field: 'minage', options: [[ null, 'Unknown' ]].concat(vndbTypes.ageRating) }),
+ ),
+ Help('minage',
+ m('p',
+ 'The minimum official age rating for the release. For most releases, this is specified on the packaging or on product web pages. ',
+ 'For indie or doujin projects, this is usually a recommended age stated by a developer or publisher. ',
+ ),
+ m('p', 'ONLY use official sources for this field - don\'t just assume "All ages" just because there\'s no erotic content!'),
+ ),
+ m('fieldset',
+ m('label[for=released]', 'Release date'),
+ m(RDate, { id: 'released', value: data.released, oninput: v => data.released = v }),
+ ),
+ );
+ return {view};
+}
+
+
+const Format = initVnode => {
+ const {data} = initVnode.attrs;
+ const plat = new DS(DS.Platforms, {
+ checked: ({id}) => !!data.platforms.find(p => p.platform === id),
+ onselect: ({id},sel) => { if (sel) data.platforms.push({platform:id}); else data.platforms = data.platforms.filter(p => p.platform !== id)},
+ checkall: () => data.platforms = vndbTypes.platform.map(([platform]) => ({platform})),
+ uncheckall: () => data.platforms = [],
+ });
+ const media = Object.fromEntries(vndbTypes.medium.map(([id,label,qty]) => [id,{label,qty}]));
+
+ const engines = new DS(DS.New(DS.Engines,
+ id => ({id}),
+ obj => m('em', obj.id ? 'Add new engine: ' + obj.id : 'Empty / unknown'),
+ ), { more: true });
+
+ const resoParse = str => {
+ const v = str.toLowerCase().replace(/\*/g, 'x').replace(/×/g, 'x').replace(/[-\s]+/g, '');
+ if (v === '' || v === 'unknown') return [0,0];
+ if (v === 'nonstandard') return [0,1];
+ const a = /^([0-9]+)x([0-9]+)$/.exec(v);
+ if (!a) return null;
+ const r = [Math.floor(a[1]), Math.floor(a[2])];
+ return r[0] > 0 && r[0] <= 32767 && r[1] > 0 && r[1] <= 32767 ? r : null;
+ };
+ const resoFmt = (x,y) => x ? x+'x'+y : y ? 'Non-standard' : '';
+
+ const resolutions = new DS(DS.New(DS.Resolutions,
+ str => { const r = resoParse(str); return r ? {id:resoFmt(...r)} : null },
+ obj => m('em', obj.id ? 'Custom resolution: ' + resoFmt(...resoParse(obj.id)) : 'Empty / unknown'),
+ ), { more: true });
+ const resolution = {v:resoFmt(data.reso_x,data.reso_y)};
+
+ const view = () => m('fieldset.form',
+ m('legend', 'Format'),
+ m('fieldset',
+ m('label', 'Platforms'),
+ m(DS.Button, { class: 'xw', ds: plat },
+ data.platforms.length === 0 ? 'No platforms selected' :
+ data.platforms.map(p => m('span', PlatIcon(p.platform), vndbTypes.platform.find(([x]) => x === p.platform)[1])).intersperse(' '),
+ ),
+ ),
+ m('fieldset',
+ m('label[for=addmedia]', 'Media'),
+ data.media.map(x => m('div',
+ m(Button.Del, { onclick: () => data.media = data.media.filter(y => x !== y) }), ' ',
+ m(Select, { class: media[x.medium].qty ? 'sw' : 'sw invisible', data: x, field: 'qty', options: range(1, 40).map(i=>[i,i]) }), ' ',
+ media[x.medium].label, m('br'),
+ )),
+ m(Select, {
+ class: 'mw', id: 'addmedia', value: null,
+ oninput: v => v !== null && data.media.push({medium: v, qty:1}),
+ options: [[null, '- Add medium -']].concat(vndbTypes.medium)
+ }),
+ data.media.anyDup(({medium,qty}) => [medium, media[medium].qty ? qty : null])
+ ? m('p.invalid', 'List contains duplicates') : null,
+ ),
+ data.patch ? null : m('fieldset',
+ m('label[for=engine]', 'Engine'),
+ m(DS.Input, { id: 'engine', class: 'mw', maxlength: 50, ds: engines, data, field: 'engine', onfocus: ev => ev.target.select() }),
+ ),
+ data.patch ? null : m('fieldset',
+ m('label[for=resolution]', 'Resolution'),
+ m(DS.Input, {
+ id: 'resolution', class: 'mw', data: resolution, field: 'v', ds: resolutions,
+ placeholder: 'width x height',
+ onfocus: ev => ev.target.select(),
+ oninput: v => { const r = resoParse(v); data.reso_x = r?r[0]:0; data.reso_y = r?r[1]:0; },
+ invalid: resoParse(resolution.v) ? null : 'Invalid resolution, expected format is "{width}x{height}".',
+ }),
+ ),
+ data.patch ? null : m('fieldset',
+ m('label[for=voiced]', 'Voiced'),
+ m(Select, { id: 'voiced', class: 'mw', data, field: 'voiced', options: vndbTypes.voiced.map((l,i)=>[i,l]) }),
+ ),
+ data.has_ero ? m('fieldset',
+ m('label[for=uncensored]', 'Censoring'),
+ m(Select, { id: 'uncensored', class: 'mw', data, field: 'uncensored', options: [
+ [ null, 'Unknown' ],
+ [ false, 'Censored graphics' ],
+ [ true, 'Uncensored graphics' ],
+ ]}),
+ ) : null,
+ );
+ return {view};
+};
+
+
+const DRM = initVnode => {
+ const {data} = initVnode.attrs;
+ const ds = new DS(DS.New(DS.DRM,
+ id => id.length > 0 && id.length <= 128 ? {id,create:true} : null,
+ obj => m('em', 'Add new DRM: ' + obj.id),
+ ), {
+ more: true,
+ placeholder: 'Search or add new DRM',
+ props: obj =>
+ obj.state === 2 ? { selectable: false } :
+ data.drm.find(d => d.name === obj.id) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ onselect: obj => data.drm.push({ create: obj.create, name: obj.id, ...Object.fromEntries(vndbTypes.drmProperty.map(([id])=>[id,false])) }),
+ });
+ const view = () => m('fieldset.form',
+ m('legend', 'DRM'),
+ m('table', data.drm.map(d => m('tr',
+ m('td', m(Button.Del, {onclick: () => data.drm = data.drm.filter(x => x !== d)})),
+ m('td.nowrap', d.create ? d.name : m('a[target=_blank]', { href: '/r/drm?s='+encodeURIComponent(d.name) }, d.name)),
+ m('td.lw',
+ m(Input, { class: 'lw', placeholder: 'Notes (optional)', data: d, field: 'notes' }),
+ !d.create ? [] : [
+ m('br'),
+ m('strong', 'New DRM implementation will be added when you submit the form.'),
+ m('br'),
+ 'Please check the properties that apply:',
+ vndbTypes.drmProperty.map(([id,name]) => [ m('br'), m('label.check',
+ m('input[type=checkbox]', { checked: d[id], oninput: ev => d[id] = ev.target.checked }),
+ ' ', name
+ )]),
+ m('br'),
+ m(Input, {class: 'lw', rows: 2, type: 'textarea', data: d, field: 'description', placeholder: 'Description (optional)'}),
+ ],
+ ),
+ ))),
+ m(DS.Button, {ds}, 'Add DRM'),
+ );
+ return {view};
+};
+
+
+const Animation = initVnode => {
+ const {data} = initVnode.attrs;
+ const hasAni = v => v !== null && v !== 0 && v !== 1;
+ let some = hasAni(data.ani_story_sp) || hasAni(data.ani_story_cg) || hasAni(data.ani_cutscene)
+ || hasAni(data.ani_ero_sp) || hasAni(data.ani_ero_cg)
+ || (data.ani_face !== null && data.ani_face !== null)
+ || (data.ani_bg !== null && data.ani_bg !== null);
+
+ const flagmask = 4+8+16+32;
+ const freqmask = 256+512;
+ const lbl = (key, bit, name) => m('label.check',
+ { class: data[key] === null || data[key] === bit || (bit > 2 && data[key] > 2) ? null : 'grayedout' },
+ m('input[type=checkbox]', {
+ checked: data[key] === bit || (bit > 2 && (data[key] & bit) > 0),
+ onclick: ev => data[key] = bit <= 2
+ ? (ev.target.checked ? bit : null)
+ : (ev.target.checked ? ((data[key]||0) & ~3) | bit : ((data[key]||0) & flagmask) === bit ? null : ((data[key]||0) & ~bit))
+ }),
+ ' ', name, m('br')
+ );
+ const ani = (key, na) => ([
+ key === 'ani_cutscene' ? null : lbl(key, 0, 'Not animated'),
+ lbl(key, 1, na),
+ lbl(key, 4, 'Hand drawn'),
+ lbl(key, 8, 'Vectorial'),
+ lbl(key, 16, '3D'),
+ lbl(key, 32, 'Live action'),
+ key === 'ani_cutscene' || data[key] === null || data[key] <= 2 ? null : m(Select, { class: 'mw',
+ oninput: v => data[key] = (data[key] & ~freqmask) | v,
+ value: data[key] & freqmask,
+ options: [ [0, '- frequency -'], [256, 'Some scenes'], [512, 'All scenes'] ]
+ }),
+ ]);
+
+ const view = () => data.patch ? null : m('fieldset.form',
+ m('legend', 'Animation'),
+ m('fieldset',
+ m('label', 'Preset'),
+ m('label.check',
+ m('input[type=radio]', { checked: !some && data.ani_face === null, onclick: () => { some = false; Object.assign(data, {
+ ani_story_sp: null, ani_story_cg: null, ani_cutscene: null,
+ ani_ero_sp: null, ani_ero_cg: null, ani_face: null, ani_bg: null
+ })}}),
+ ' Unknown'
+ ),
+ ' / ',
+ m('label.check',
+ m('input[type=radio]', { checked: !some && data.ani_face === false, onclick: () => { some = false; Object.assign(data, {
+ ani_story_sp: 0, ani_story_cg: 0, ani_cutscene: 1,
+ ani_ero_sp: data.has_ero ? 1 : null, ani_ero_cg: data.has_ero ? 0 : null,
+ ani_face: false, ani_bg: false
+ })}}),
+ ' No animation'
+ ),
+ ' / ',
+ m('label.check',
+ m('input[type=radio]', { checked: some, onclick: () => some = true }),
+ ' Some animation'
+ ),
+ ),
+ !some ? [] : [
+ m('fieldset',
+ m('label', 'Story scenes'),
+ m('table.release-animation', m('tr',
+ m('td', m('strong', 'Character sprites:'), m('br'), ani('ani_story_sp', 'No sprites')),
+ m('td', m('strong', 'CGs:'), m('br'), ani('ani_story_cg', 'No CGs')),
+ m('td', m('strong', 'Cutscenes:'), m('br'), ani('ani_cutscene', 'No cutscenes')),
+ )),
+ ),
+ data.has_ero ? m('fieldset',
+ m('label', 'Erotic scenes'),
+ m('table.release-animation', m('tr',
+ m('td', m('strong', 'Character sprites:'), m('br'), ani('ani_ero_sp', 'No sprites')),
+ m('td', m('strong', 'CGs:'), m('br'), ani('ani_ero_cg', 'No CGs')),
+ )),
+ ) : null,
+ m('fieldset',
+ m('label', 'Effects'),
+ m('table',
+ m('tr', m('td', 'Character lip movement and/or eye blink:'), m('td',
+ m('label.check', m('input[type=radio]', { checked: data.ani_face === null, onclick: () => data.ani_face = null }), ' Unknown or N/A'), ' / ',
+ m('label.check', m('input[type=radio]', { checked: data.ani_face === false, onclick: () => data.ani_face = false }), ' No'), ' / ',
+ m('label.check', m('input[type=radio]', { checked: data.ani_face === true, onclick: () => data.ani_face = true }), ' Yes'),
+ )),
+ m('tr', m('td', 'Background effects:'), m('td',
+ m('label.check', m('input[type=radio]', { checked: data.ani_bg === null, onclick: () => data.ani_bg = null }), ' Unknown or N/A'), ' / ',
+ m('label.check', m('input[type=radio]', { checked: data.ani_bg === false, onclick: () => data.ani_bg = false }), ' No'), ' / ',
+ m('label.check', m('input[type=radio]', { checked: data.ani_bg === true, onclick: () => data.ani_bg = true }), ' Yes'),
+ )),
+ ),
+ ),
+ ]
+ );
+ return {view};
+};
+
+
+const VNs = initVnode => {
+ const {data} = initVnode.attrs;
+ const ds = new DS(DS.VNs, {
+ onselect: obj => data.vn.push({vid: obj.id, title: obj.title, rtype: 'complete' }),
+ props: obj => data.vn.find(v => v.vid === obj.id) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ });
+ const view = () => m('fieldset',
+ m('label', 'Visual novels'),
+ data.vn.length === 0
+ ? m('p.invalid', 'No visual novels selected.')
+ : m('table', data.vn.map(v => m('tr', {key: v.vid},
+ m('td',
+ m(Button.Del, { onclick: () => data.vn = data.vn.filter(x => x !== v) }), ' ',
+ m(Select, { data: v, field: 'rtype', options: vndbTypes.releaseType }),
+ ),
+ m('td', m('small', v.vid, ': '), m('a[target=_blank]', { href: '/'+v.vid }, v.title)),
+ ))),
+ m(DS.Button, { ds, class: 'mw' }, 'Add visual novel'),
+ );
+ return {view};
+};
+
+
+const Producers = initVnode => {
+ const {data} = initVnode.attrs;
+ const ds = new DS(DS.Producers, {
+ onselect: obj => data.producers.push({pid: obj.id, name: obj.name, developer: true, publisher: true }),
+ props: obj => data.producers.find(p => p.pid === obj.id) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ });
+ const view = () => m('fieldset',
+ m('label', 'Producers'),
+ m('table', data.producers.map(p => m('tr', {key: p.pid},
+ m('td',
+ m(Button.Del, { onclick: () => data.producers = data.producers.filter(x => x !== p) }), ' ',
+ m(Select, {
+ oninput: v => { p.developer = v[0]; p.publisher = v[1] },
+ value: [p.developer,p.publisher],
+ options: [
+ [ [true, false], 'Developer' ],
+ [ [false, true], 'Publisher' ],
+ [ [true, true ], 'Both' ],
+ ],
+ }),
+ ),
+ m('td', m('small', p.pid, ': '), m('a[target=_blank]', { href: '/'+p.pid }, p.name)),
+ ))),
+ m(DS.Button, { ds, class: 'mw' }, 'Add producer'),
+ );
+ return {view};
+};
+
+
+widget('ReleaseEdit', initVnode => {
+ const data = initVnode.attrs.data;
+ const api = new Api('ReleaseEdit');
+ const gtin = {v: data.gtin === '0' ? '' : data.gtin};
+
+ // Lazy port of VNDB::Func::gtintype()
+ const validateGtin = v => {
+ if (!/^[0-9]{10,13}$/.test(v)) return false;
+ v = v.padStart(13, '0'); // GTIN-13
+
+ const n = v.split('').reverse();
+ let check = +n.shift();
+ n.forEach((v,i) => check += v * ((i % 2) !== 0 ? 1 : 3));
+ if ((check % 10) !== 0) return false;
+
+ if (/^4[59]/.test(v)) return true;
+ if (/^(?:0[01]|0[6-9]|13|75[45])/.test(v)) return true;
+ if (/^97[89]/.test(v)) return true;
+ if (/^(?:0[2-5]|2|9[6-9])/.test(v)) return false;
+ return true;
+ };
+
+ const view = () => m(Form, {api, onsubmit: () => api.call(data)},
+ m('article',
+ m('h1', 'General info'),
+ m(Titles, {data}),
+ m(Status, {data}),
+ m(Format, {data}),
+ m(DRM, {data}),
+ m(Animation, {data}),
+ m('fieldset.form',
+ m('legend', 'External identifiers & links'),
+ m('fieldset',
+ m('label[for=gtin]', 'JAN/UPC/EAN/ISBN'),
+ m(Input, {
+ id: 'gtin', class: 'mw', type: 'number', data: gtin, field: 'v',
+ oninput: v => { data.gtin = v; gtin.v = v === 0 ? '' : v },
+ invalid: data.gtin !== '' && data.gtin !== '0' && data.gtin !== 0 && !validateGtin(String(data.gtin)) ? 'Invalid JAN/UPC/EAN/ISBN code.' : '',
+ }),
+ ),
+ m('fieldset',
+ m('label[for=catalog]', 'Catalog number'),
+ m(Input, { id: 'catalog', class: 'mw', maxlength: 50, data, field: 'catalog' }),
+ ),
+ m('fieldset',
+ m('label[for=website]', 'Website'),
+ m(Input, { id: 'website', class: 'xw', type: 'weburl', data, field: 'website' }),
+ ),
+ m(ExtLinks, {type: 'release', data}),
+ ),
+ m('fieldset.form',
+ m('legend', 'Database relations'),
+ m(VNs, {data}),
+ m(Producers, {data}),
+ ),
+ m('fieldset.form',
+ m('label[for=notes]', 'Notes'),
+ m(TextPreview, {
+ data, field: 'notes',
+ header: m('b', '(English please!)'),
+ attrs: { id: 'notes', rows: 5, maxlength: 10240 },
+ }),
+ ),
+ ),
+ m(EditSum, {data,api}),
+ );
+ return {view};
+});
diff --git a/js/contrib/Report.js b/js/contrib/Report.js
new file mode 100644
index 00000000..dfd346eb
--- /dev/null
+++ b/js/contrib/Report.js
@@ -0,0 +1,105 @@
+const editable = /^[vrpcs]/;
+// Name, Objtypes, cansubmit, msg
+const reasons = [
+ [ '-- Select --' ],
+ [ 'Spam', /^[^du]/, true ],
+ [ 'Links to piracy or illegal content', /^[^u]/, true ],
+ [ 'Off-topic', /^[tw]/, true ],
+ [ 'Unwelcome behavior', /^[tw]/, true ],
+ [ 'Unmarked spoilers', /^[^u]/, true, id => (editable.test(id) ? [
+ 'VNDB is an open wiki, it is often easier if you removed the spoilers yourself by ',
+ m('a', { href: '/'+id+'/edit' }, 'editing the entry'),
+ '. You likely know more about this entry than our moderators, after all.',
+ m('br'),
+ "If you're not sure whether something is a spoiler or if you need help with editing, you can also report this issue on the ",
+ m('a[href=/t/db]', 'discussion board'),
+ ' so that others may be able to help you.',
+ ] : 'Please clearly explain what the spoiler is.') ],
+ [ 'Unmarked or improperly flagged NSFW image', /^[vc]/, true ],
+ [ 'Incorrect information', editable, false, id => [
+ 'VNDB is an open wiki, you can correct the information in this database yourself by ',
+ m('a', { href: '/'+id+'/edit' }, 'editing the entry'),
+ '. You likely know more about this entry than our moderators, after all.',
+ m('br'),
+ 'If you need help with editing, you can also report this issue on the ',
+ m('a[href=/t/db]', 'discussion board'),
+ ' so that others may be able to help you.'
+ ] ],
+ [ 'Missing information', editable, false, () => [
+ 'VNDB is an open wiki, you can add any missing information to this database yourself. ',
+ 'You likely know more about this entry than our moderators, after all.',
+ m('br'),
+ 'If you need help with contributing information, feel free to ask around on the ',
+ m('a[href=/t/db]', 'discussion board'),
+ ' so that others may be able to help you.'
+ ] ],
+ [ 'Not a visual novel', /^v/, false, () => [
+ 'If you suspect that this entry does not adhere to our ',
+ m('a[href=/d2#1]', 'Inclusion criteria'),
+ ', please report it in ',
+ m('a[href=/t2108]', 'this thread'),
+ ', so that other users have a chance to provide feedback before a moderator makes their decision.',
+ ] ],
+ [ 'Does not belong here', /^[rpcs]/, true ],
+ [ 'Duplicate entry', editable, true, () => 'Please include a link to the entry that this is a duplicate of.' ],
+ [ 'Personal information removal request', editable, false, () => [
+ "If the page contains personal information about you (as a developer, translator or otherwise) ",
+ "that you're not comfortable with, please contact us at contact@vndb.org."
+ ] ],
+ [ 'Engages in vote manipulation', /^u/, true ],
+ [ 'Other', null, true, id => editable.test(id) ? [
+ 'Keep in mind that VNDB is an open wiki, you can edit most of the information in this database.',
+ m('br'),
+ 'Reports for issues that do not require a moderator to get involved will most likely be ignored.',
+ m('br'),
+ 'If you need help with contributing to the database, feel free to ask around on the ',
+ m('a[href=/t/db]', 'discussion board', '.'),
+ ] : null ],
+];
+
+
+widget('Report', vnode => {
+ const data = vnode.attrs.data;
+ const api = new Api('Report');
+ const list = reasons.filter(([_,re]) => !re || re.test(data.object));
+
+ var ok = false;
+ const onsubmit = () => api.call(data, () => ok = true);
+ const view = () => m(Form, {api, onsubmit}, m('article',
+ m('h1', 'Submit report'),
+ ok
+ ? m('p', 'Your report has been submitted, a moderator will look at it as soon as possible.')
+ : m('fieldset.form',
+ m('fieldset',
+ m('label', 'Subject'),
+ m.trust(data.title),
+ m('br'),
+ 'Your report will be forwarded to a moderator.',
+ m('br'),
+ data.loggedin
+ ? 'We usually do not provide feedback on reports, but a moderator may contact you for clarification.'
+ : 'We usually do not provide feedback on reports, but you may leave your email address in the message if you wish to be available for clarification.',
+ m('br'),
+ 'Keep in mind that not every report is acted upon, we may decide that the problem you ',
+ 'reported is not serious enough or does not require moderator intervention.',
+ ),
+ m('fieldset',
+ m('label[for=reason]', 'Reason'),
+ m(Select, { id: 'reason', class: 'xw', data, field: 'reason', options: list.map(([t]) => [t,t]) }),
+ ),
+ (([a,b,cansubmit,msg]) => [
+ msg ? m('fieldset', msg(data.object)) : null,
+ cansubmit ? m('fieldset',
+ m('label[for=message]', 'Message'),
+ m(Input, { id: 'message', class: 'xw', type: 'textarea', rows: 5, data, field: 'message' }),
+ ) : null,
+ cansubmit ? [
+ m('input[type=submit][value=Submit]'),
+ m('span.spinner', { class: api.loading() ? '' : 'invisible' }),
+ api.error ? m('p.formerror', api.error) : null,
+ ] : [],
+ ])(list.filter(([l]) => l === data.reason)[0] || reasons[0]),
+ ),
+ ));
+ return {view};
+});
diff --git a/js/contrib/StaffEdit.js b/js/contrib/StaffEdit.js
new file mode 100644
index 00000000..ae9b872a
--- /dev/null
+++ b/js/contrib/StaffEdit.js
@@ -0,0 +1,133 @@
+widget('StaffEdit', initVnode => {
+ const data = initVnode.attrs.data;
+ const api = new Api('StaffEdit');
+ if (!data.l_pixiv) data.l_pixiv = '';
+
+ const dupApi = new Api('Staff');
+ let dupCheck = !data.id;
+ const nameChange = () => {dupCheck = !!dupCheck};
+ const onsubmit = () => !dupCheck ? api.call(data) : dupApi.call(
+ {search: data.alias.flatMap(({name,latin}) => [name,latin]).filter(x => x)},
+ res => dupCheck = res.results.length ? res.results : false,
+ );
+
+ const names = () => m('table.names',
+ m('thead', m('tr',
+ m('td'),
+ m('td.tc_name', 'Name (original script)'),
+ m('td.tc_name', 'Romanization'),
+ m('td'),
+ )),
+ m('tfoot', m('tr.alias_new',
+ m('td'),
+ m('td[colspan=3]',
+ data.alias.anyDup(({name,latin}) => [name,latin])
+ ? m('p.invalid', 'There are duplicate aliases.') : null,
+ m('a[href=#]', { onclick: () => {
+ data.alias.push({
+ aid: Math.min(0, ...data.alias.map(a => a.aid)) - 1,
+ name: '',
+ latin: '',
+ focus: true,
+ });
+ return false;
+ }}, 'Add alias'),
+ ),
+ )),
+ m('tbody', data.alias.flatMap(a => [
+ m('tr', {key: a.aid},
+ m('td', m('input[type=radio]', { checked: a.aid === data.main, onclick: () => data.main = a.aid })),
+ m('td.tc_name', a.editable || !a.inuse ? m('span', m(Input,
+ { required: true, maxlength: 200, data: a, field: 'name', oninput: nameChange, focus: a.focus }
+ )) : a.name),
+ m('td.tc_name', !a.latin && !mayRomanize.test(a.name) ? m('br') : a.editable || !a.inuse ? m('span', m(Input, {
+ required: mustRomanize.test(a.name), maxlength: 200, data: a, field: 'latin', placeholder: 'Romanization', oninput: nameChange,
+ invalid: a.latin === a.name || mustRomanize.test(a.latin) ? 'Romanization should only contain characters in the latin alphabet.' : null,
+ })) : a.latin),
+ m('td',
+ a.editable ? m(Button.Cancel, { onclick: () => { a.name = a.orig_name; a.latin = a.orig_latin; a.editable = false } }) :
+ a.inuse ? m(Button.Edit, { onclick: () => { a.orig_name = a.name; a.orig_latin = a.latin; a.editable = true } }) : null,
+ a.aid === data.main ? m('small', ' primary') :
+ a.wantdel ? m('b', ' still referenced') :
+ a.inuse ? m('small', ' referenced') :
+ m(Button.Del, { onclick: () => nameChange(data.alias = data.alias.filter(x => x !== a)) }),
+ ),
+ ),
+ a.editable ? m('tr', {key: 'w'+a.aid},
+ m('td'),
+ m('td[colspan=3]',
+ m('b', 'WARNING: '),
+ 'You are editing an alias that is used in the credits of a visual novel. ',
+ 'Changing this name also changes the credits. Only do this for simple corrections!'
+ ),
+ ) : null,
+ ]).filter(x => x)),
+ );
+
+ const lang = new DS(DS.LocLang, {onselect: obj => data.lang = obj.id});
+ const wikidata = { v: data.l_wikidata === null ? '' : 'Q'+data.l_wikidata };
+ const fields = () => [
+ m('fieldset',
+ m('label[for=gender]', 'Gender'),
+ m(Select, { id: 'gender', class: 'mw', data, field: 'gender', options: [
+ [ 'unknown', 'Unknown or N/A' ],
+ [ 'm', 'Male' ],
+ [ 'f', 'Female' ],
+ ] }),
+ ),
+ m('fieldset',
+ m('label', { class: data.lang ? null : 'invalid' }, 'Primary language'),
+ m(DS.Button, {class: 'mw', ds:lang}, data.lang ? Object.fromEntries(vndbTypes.language)[data.lang] : '-- select --'),
+ data.lang ? null : m('p.invalid', 'No language selected.'),
+ ),
+ m('fieldset',
+ m('label[for=l_site]', 'Website'),
+ m(Input, { id: 'l_site', class: 'xw', type: 'weburl', data, field: 'l_site' }),
+ ),
+ m(ExtLinks, {data, type: 'staff'}),
+ m('fieldset',
+ m('label[for=description]', 'Notes / Biography'),
+ m(TextPreview, {
+ data, field: 'description',
+ header: m('b', '(English please!)'),
+ attrs: { id: 'description', rows: 6, maxlength: 5000 },
+ }),
+ ),
+ ];
+
+ const view = () => m(Form, {api: dupCheck ? dupApi : api, onsubmit},
+ m('article.staffedit',
+ m('h1', data.id ? 'Edit staff' : 'Add staff'),
+ m('fieldset.form',
+ m('fieldset',
+ m('label', 'Names'),
+ names(),
+ ),
+ dupCheck === false ? fields() : [],
+ ),
+ ),
+ dupCheck === false ? [
+ m(EditSum, {data,api})
+ ] : dupCheck === true ? [m('article.submit',
+ m('input[type=submit][value=Continue]'),
+ dupApi.loading() ? m('span.spinner') : null,
+ dupApi.error ? m('b', m('br'), dupApi.error) : null,
+ )] : [
+ m('article',
+ m('h1', 'Possible duplicates'),
+ m('p',
+ 'The following is a list of staff that match the name(s) you gave. ',
+ 'Please check this list to avoid creating a duplicate staff entry.',
+ ),
+ m('ul', dupCheck.map(s => m('li',
+ m('a[target=_blank]', { href: '/'+s.id }, s.title),
+ s.alttitle ? m('small', ' (', s.alttitle, ')') : null,
+ ))),
+ ),
+ m('article.submit',
+ m('input[type=button][value=Continue anyway]', { onclick: () => dupCheck = false }),
+ ),
+ ],
+ );
+ return {view};
+});
diff --git a/js/contrib/index.js b/js/contrib/index.js
new file mode 100644
index 00000000..3786d633
--- /dev/null
+++ b/js/contrib/index.js
@@ -0,0 +1,133 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only
+// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/js
+// SPDX-License-Identifier: AGPL-3.0-only
+"use strict";
+
+@include .gen/extlinks.js
+
+
+// This list is incomplete, just an assortment of names and titles found in the DB
+const _greek = '\u0370-\u03ff\u1f00-\u1fff';
+const _cyrillic = '\u0400-\u04ff';
+const _arabic = '\u0600-\u06ff';
+const _thai = '\u0e00-\u0e7f';
+const _hangul = '\u1100-\u11ff\uac00-\ud7af';
+const _canadian = '\u1400-\u167f'; // Unified Canadian Aboriginal Syllabics, we have an actual Inuktitut title in the database
+const _kana = '\u3040-\u3099\u30a1-\u30fa\uff66-\uffdc'; // Hiragana + Katakana + Half/Full-width forms
+const _cjk = '\u3100-\u9fff\u{20000}-\u{323af}'; // Whole range of CJK blocks
+const mustRomanize = new RegExp('[' + _cyrillic + _arabic + _thai + _hangul + _canadian + _kana + _cjk + ']', 'u');
+// Greek characters are often used for styling and don't always need romanizing.
+const mayRomanize = new RegExp('[' + _greek + _cyrillic + _arabic + _thai + _hangul + _canadian + _kana + _cjk + ']', 'u');
+
+
+
+// Edit summary & submit button box for DB entry edit forms.
+// Attrs:
+// - data -> form data containing editsum, hidden & locked
+// - api -> Api object for loading & error status
+//
+// TODO: Support for "awaiting approval" state.
+// TODO: Better feedback on pointless edit summaries like "-", "..", etc
+const EditSum = vnode => {
+ const {api,data} = vnode.attrs;
+ const rad = (l,h,lab) => m('label',
+ m('input[type=radio]', {
+ checked: l === data.locked && h === data.hidden,
+ oninput: () => { data.locked = l; data.hidden = h }
+ }), lab
+ );
+ const view = () => m('article.submit',
+ pageVars.dbmod ? m('fieldset',
+ rad(false, false, ' Normal '),
+ rad(true , false, ' Locked '),
+ rad(true , true , ' Deleted '),
+ data.locked && data.hidden ? m('span',
+ m('br'), 'Note: edit summary of the last edit should indicate the reason for the deletion.', m('br')
+ ) : null,
+ ) : null,
+ m(TextPreview, {
+ data, field: 'editsum',
+ attrs: { rows: 4, cols: 50, minlength: 2, maxlength: 5000, required: true },
+ header: [
+ m('strong', 'Edit summary'),
+ m('b', ' (English please!)'),
+ m('br'),
+ 'Summarize the changes you have made, including links to source(s).',
+ ]
+ }),
+ m('input[type=submit][value=Submit]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error
+ ? m('b', m('br'), api.error)
+ : m('p.formerror', 'The form contains errors'),
+ );
+ return {view};
+};
+
+
+
+const ExtLinks = initVnode => {
+ const links = initVnode.attrs.data;
+ const extlinks = extLinks[initVnode.attrs.type];
+ const split = (fmt,v) => fmt.split(/(%[0-9]*[sd])/)
+ .map((p,i) => i !== 1 ? p : String(v).padStart(p.match(/%(?:0([0-9]+))?/)[1]||0, '0'));
+ let str = ''; // input string
+ let lnk = null; // link object, if matched
+ let val = null; // extracted value, if matched
+ let dup = false; // if link is already present
+ extlinks.forEach(l => l.multi = Array.isArray(l.default));
+ const add = () => {
+ if (lnk.multi) links[lnk.id].push(val);
+ else links[lnk.id] = val;
+ str = '';
+ lnk = val = null;
+ dup = false;
+ };
+ const view = () => m('fieldset',
+ m('label[for=extlinks]', 'External links', HelpButton('extlinks')),
+ m('table', extlinks.flatMap(l =>
+ (l.multi ? links[l.id] : links[l.id] ? [links[l.id]] : []).map(v =>
+ m('tr', {key: l.id + '-' + v },
+ m('td', m(Button.Del, {onclick: () => links[l.id] = l.multi ? links[l.id].filter(x => x !== v) : l.default})),
+ m('td', m('a[target=_blank]', { href: split(l.fmt, v).join('') }, l.name)),
+ m('td', split(l.fmt, v).map((p,i) => m(i === 1 ? 'span' : 'small', p))),
+ )
+ )
+ )),
+ m('form', { onsubmit: ev => { ev.preventDefault(); if (lnk && !dup) add(); } },
+ m('input#extlinks.xw[type=text][placeholder=Add URL...]', { value: str, oninput: ev => {
+ str = ev.target.value;
+ lnk = extlinks.find(l => new RegExp(l.regex).test(str));
+ val = lnk && (v => lnk.int ? +v : ''+v)(str.match(new RegExp(lnk.regex)).filter(x => x !== undefined)[1]);
+ dup = lnk && (lnk.multi ? links[lnk.id].find(x => x === val) : links[lnk.id] === val);
+ if (lnk && !dup && (lnk.multi || links[lnk.id] === null || links[lnk.id] === 0 || links[lnk.id] === '')) add();
+ }}),
+ str.length > 0 && !lnk ? [ m('p', ('small', '>>> '), m('b.invalid', 'Invalid or unrecognized URL.')) ] :
+ dup ? [ m('p', m('small', '>>> '), m('b.invalid', ' URL already listed.')) ] :
+ lnk ? [
+ m('p', m('input[type=submit][value=Update]'), m('span.invalid', ' URL recognized as: ', lnk.name)),
+ m('p.invalid', 'Did you mean to update the URL?'),
+ ] : [],
+ ),
+ Help('extlinks',
+ m('p', 'Links to external websites. The following sites and URL formats are supported:'),
+ m('dl', extlinks.flatMap(e => [
+ m('dt', e.name),
+ m('dd', e.patt.map((p,i) => m(i % 2 ? 'strong' : 'span', p))),
+ ])),
+ m('p', 'Links to sites that are not in the above list can still be added in the notes field below.'),
+ ),
+ );
+ return {view};
+};
+
+
+
+@include ReleaseEdit.js
+@include DRMEdit.js
+@include ProducerEdit.js
+@include StaffEdit.js
+@include DocEdit.js
+@include Report.js
+
+// @license-end
diff --git a/js/graph/index.js b/js/graph/index.js
new file mode 100644
index 00000000..428f2028
--- /dev/null
+++ b/js/graph/index.js
@@ -0,0 +1,12 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only
+// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/js
+// @license magnet:?xt=urn:btih:b8999bbaf509c08d127678643c515b9ab0836bae&dn=ISC.txt ISC
+// @source: https://github.com/d3/d3
+// SPDX-License-Identifier: AGPL-3.0-only AND ISC
+
+"use strict";
+
+@include .gen/d3.js
+@include vn.js
+
+// @license-end
diff --git a/js/graph/vn.js b/js/graph/vn.js
new file mode 100644
index 00000000..2a1f7373
--- /dev/null
+++ b/js/graph/vn.js
@@ -0,0 +1,266 @@
+const relIcons = {
+ seq: Icon.StepForward,
+ set: Icon.Globe,
+ alt: Icon.Replace,
+ char: Icon.Users2,
+ side: Icon.Redo2,
+ ser: Icon.Tv,
+ fan: Icon.FolderHeart,
+};
+
+widget('VNGraph', initVnode => {
+ const {data} = initVnode.attrs;
+
+ let nodes, links;
+
+ const hasUnoff = !!data.rels.find(([,,,o]) => !o);
+ const foundRelTypes = Object.fromEntries(data.rels.map(([,,r,]) => [r,true]));
+ // Excludes reverse relations, as those are filtered on the backend.
+ const relTypes = vndbTypes.vnRelation.filter(
+ ([id,lbl,rev,pref]) => foundRelTypes[id] && (id === rev || pref)
+ );
+ const relTypesObj = Object.fromEntries(relTypes.map(([id,label,reverse,pref]) => [id,{id,label,reverse,pref}]));
+
+ let optMain = data.main;
+ let optOfficial = false;
+ let optTypes = Object.fromEntries(relTypes);
+ let optDistance = 9999;
+ let defaultDistance = 0;
+ let maxDistance = 0;
+ let optSel = null;
+
+ const imgprefs = [
+ { id: 0, field: 'sexual', label: 'Safe' },
+ { id: 1, field: 'sexual', label: 'Suggestive' },
+ { id: 2, field: 'sexual', label: 'Explicit' },
+ { id: 3, field: 'violence', label: 'Tame' },
+ { id: 4, field: 'violence', label: 'Violent' },
+ { id: 5, field: 'violence', label: 'Brutal' },
+ ];
+ const dsImgPref = new DS({
+ list: (a,b,cb) => cb(imgprefs),
+ view: obj => obj.label,
+ }, {
+ onselect: obj => data[obj.field] = obj.id % 3,
+ checked: obj => data[obj.field] === obj.id % 3,
+ width: 130, nosearch: true,
+ });
+ const needImgPrefs = !!data.nodes.find(n => n.image && (n.image[1] > 0 || n.image[2] > 0));
+
+ let svg;
+ let autoscale = true;
+ let height = 100, width = 100;
+ const resize = () => {
+ height = Math.max(200, window.innerHeight - 40);
+ width = svg.clientWidth;
+ m.redraw();
+ };
+ window.addEventListener('resize', resize);
+
+ // TODO: Tuning, this simulation is somewhat unstable for large graphs
+ const simulationLinks = d3.forceLink().distance(500).id(n => n.id);
+ const simulation = d3.forceSimulation()
+ //.alphaMin(0.001)
+ //.alphaDecay(0.001)
+ .force('link', simulationLinks)
+ .force('charge', d3.forceManyBody().strength(-5000))
+ //.force('collision', d3.forceCollide(100))
+ .force('x', d3.forceX().strength(0.1))
+ .force('y', d3.forceY().strength(0.1))
+ .on('tick', () => {
+ let minX = 0, maxX = 0, minY = 0, maxY = 0;
+ nodes.forEach(n => {
+ if (n.x < minX) minX = n.x;
+ if (n.y < minY) minY = n.y;
+ if (n.x > maxX) maxX = n.x;
+ if (n.y > maxY) maxY = n.y;
+ });
+ const margin = 100;
+ zoom.translateExtent([[minX-margin,minY-margin],[maxX+margin,maxY+margin]]);
+ const scale = Math.min(1, width / (maxX - minX + 2*margin), height / (maxY - minY + 2*margin));
+ zoom.scaleExtent([scale, 1]);
+ // TODO: Even if autoscale is off, we might want to ensure the
+ // current view fits inside the given Extents. Might not be the
+ // case anymore after dragging.
+ if (autoscale) {
+ const obj = d3.select(svg);
+ zoom.scaleTo(obj, scale);
+ zoom.translateTo(obj, 0, 0);
+ }
+ m.redraw();
+ });
+
+ const nodeById = Object.fromEntries(data.nodes.map(n => ([n.id,n])));
+ const linkObjects = data.rels.map(([a,b,relation,official]) => ({source: nodeById[a], target: nodeById[b], relation, official}));
+ const setGraph = () => {
+ links = linkObjects.filter(l => (!optOfficial || l.official) && optTypes[l.relation]);
+ data.nodes.forEach(n => {n.dist = null; n.included = false; n.links = []});
+ links.forEach(({source,target}) => {
+ source.links.push(target);
+ target.links.push(source);
+ });
+ let lst = [ nodeById[optMain] ];
+ lst[0].dist = 0;
+ maxDistance = 0;
+ for (let i=0; i<lst.length; i++) {
+ const n = lst[i];
+ if (maxDistance < n.dist) maxDistance = n.dist;
+ const l = n.links.filter(x => x.dist === null);
+ l.forEach(x => x.dist = n.dist+1);
+ lst.push(...l);
+ if (lst.length < 50 && defaultDistance < n.dist) defaultDistance = n.dist;
+ delete(n.links);
+ n.included = n.dist <= optDistance;
+ }
+ nodes = data.nodes.filter(n => { if (!n.included) { delete(n.x); delete(n.y) } return n.included; });
+ links = links.filter(({source,target}) => source.included && target.included);
+ autoscale = true;
+ simulation.nodes(nodes);
+ simulationLinks.links(links);
+ simulation.alpha(1).restart();
+ };
+ setGraph();
+ if (optDistance > maxDistance) optDistance = maxDistance;
+ if (defaultDistance > maxDistance) defaultDistance = maxDistance;
+
+ const drag = vnode => d3.select(vnode.dom).call(d3.drag()
+ .subject(vnode.dom.dataset.nodeid ? nodeById[vnode.dom.dataset.nodeid] : nodes[vnode.dom.dataset.nodeidx])
+ .on("start", ev => {
+ autoscale = false;
+ if (!ev.active) simulation.alphaTarget(0.3).restart();
+ ev.subject.fx = ev.subject.x;
+ ev.subject.fy = ev.subject.y;
+ }).on("drag", ev => {
+ ev.subject.fx = ev.x;
+ ev.subject.fy = ev.y;
+ }).on("end", ev => {
+ if (!ev.active) simulation.alphaTarget(0);
+ ev.subject.fx = null;
+ ev.subject.fy = null;
+ }));
+
+ // Should be called whenever opt* variables are changed.
+ const save = reload => {
+ const types = relTypes.map(([id]) => optTypes[id] ? id : null).filter(v=>v);
+ const opts = [
+ optMain === data.main ? null : optMain,
+ optOfficial ? 'o1' : null,
+ optDistance === defaultDistance ? null : 'd'+optDistance,
+ types.length === relTypes.length ? null : types,
+ ].flat().filter(v => v);
+ history.replaceState(null, "", '#'+opts.join(','));
+ if (reload) {
+ setGraph();
+ simulation.restart();
+ }
+ };
+
+ optDistance = defaultDistance;
+ if (location.hash.length > 1) {
+ let types = {};
+ location.hash.substr(1).split(/,/).forEach(s => {
+ if (s === 'o1') optOfficial = true;
+ else if (s === 'o0') optOfficial = false;
+ else if (s.match(/^d[0-9]+$/)) optDistance = 1*s.substr(1);
+ else if (s.match(/^v[0-9]+$/)) optMain = s;
+ else if (relTypesObj[s]) types[s] = true;
+ });
+ if (Object.keys(types).length) optTypes = types;
+ }
+ save(true);
+
+ const newmain = ev => {
+ optMain = nodes[ev.target.dataset.nodeidx].id;
+ // XXX: Restart simulation only when we hide/unhide entries. At least,
+ // that's the intention, but because maxDistance can change depending
+ // on which entry is 'main', this behavior is weird and wonky instead.
+ save(optDistance < maxDistance);
+ };
+ const newsel = ev => optSel = ev.currentTarget.dataset.nodeid || nodes[ev.currentTarget.dataset.nodeidx].id;
+ const resetsel = ev => optSel = null;
+ const noscale = () => autoscale = false;
+
+ const dsTypes = new DS({
+ list: (a,b,cb) => cb(relTypes.map(([id,label]) => ({id,label}))),
+ view: obj => [ m('span.vn-rel-icon', m(relIcons[obj.id])), obj.label ]
+ }, {
+ onselect: (obj, v) => { optTypes[obj.id] = v; save(true); },
+ checked: obj => optTypes[obj.id],
+ width: 160, nosearch: true,
+ });
+
+ const zoom = d3.zoom()
+ .on("zoom", ev => svg.childNodes[0].setAttribute('transform', ev.transform));
+
+ const view = () => m('div#vn-graph',
+ m('div', { oncreate: v => v.dom.scrollIntoView() },
+ m('div', m('a', { href: '/'+data.main+'/rg' }, '« static graph')),
+ m('div',
+ m('input[type=range][min=0]', {
+ max: maxDistance, value: optDistance,
+ oninput: ev => { optDistance = ev.target.value; save(true) },
+ style: { width: maxDistance <= 3 ? '100px' : maxDistance <= 10 ? '150px' : '200px' },
+ }),
+ hasUnoff ? m('label',
+ m('input[type=checkbox]', { checked: optOfficial, oninput: ev => { optOfficial = ev.target.checked; save(true); }}),
+ ' official only '
+ ) : null,
+ m(DS.Button, {ds: dsTypes}, 'relations'),
+ needImgPrefs ? m(DS.Button, {ds: dsImgPref}, 'nsfw') : null,
+ ),
+ ),
+ m('svg', {
+ height, viewBox: '0 0 '+width+' '+height,
+ oncreate: v => { svg = v.dom; resize(); d3.select(svg).call(zoom).on("dblclick.zoom", null); },
+ onmousedown: () => autoscale = false,
+ onwheel: () => autoscale = false,
+ }, m('g',
+ m('defs',
+ // TODO: Better handle nsfw or missing images; blurhash or something? Title?
+ nodes.map(n => m('pattern', { id: 'p'+n.id, width: '100%', height: '100%' },
+ n.image && n.image[1] <= data.sexual && n.image[2] <= data.violence
+ ? m('image', { href: n.image[0], x: -20, y: -20, width: 240, height: 240 })
+ : m('circle', { r: 80, cx: 100, cy: 100 })
+ )),
+ m('g.rels[fill=none][stroke=currentColor][stroke-width=2][stroke-linecap=round][stroke-linejoin=round]',
+ relTypes.map(([id]) => m('g', {id: 'r'+id}, m.trust(relIcons[id].raw))),
+ ),
+ m('path#vn-graph-arrow[d=m13.5 27 9-9-9-9]')
+ ),
+ m('g.edges', links.map(l => m('line', {
+ key: l.source.id+l.target.id,
+ x1: l.source.x, y1: l.source.y,
+ x2: l.target.x, y2: l.target.y,
+ 'stroke-dasharray': l.official ? 1 : '3,10',
+ }))),
+ m('g.rels[fill=none][stroke=currentColor][stroke-width=2][stroke-linecap=round][stroke-linejoin=round]', links.map(l =>
+ m('use', { href: '#r'+l.relation, x: (l.source.x+l.target.x)/2-12, y: (l.source.y+l.target.y)/2-12 }),
+ )),
+ m('g.arrows', links.map(l => relTypesObj[l.relation].reverse === l.relation ? null : m('use[href=#vn-graph-arrow]', {
+ transform: 'translate(' + ((l.source.x+l.target.x)/2) + ' ' + ((l.source.y+l.target.y)/2) + ') '
+ + 'rotate(' + (Math.atan2(l.target.y-l.source.y, l.target.x-l.source.x)*180/3.1415) + ') '
+ + 'translate(10 -18)'
+ }))),
+ m('g.main', (n => m('circle', { r: 110, cx: n.x, cy: n.y }))(nodeById[optMain])),
+ m('g.nodes', nodes.map((n,i) => m('circle', {
+ key: n.id,
+ 'data-nodeidx': i, oncreate: drag, onclick: newsel, onmouseover: newsel, onmouseout: resetsel, ondblclick: newmain,
+ r: 100, cx: n.x, cy: n.y,
+ fill: 'url(#p'+n.id+')',
+ }))),
+ optSel ? (n => m('foreignObject',
+ { 'data-nodeid': n.id, x: n.x-200, y: n.y+50, width: 400, height: 80, oncreate: drag, onmouseover: newsel, onmouseout: resetsel },
+ m('div#vn-graph-sel[xmlns=http://www.w3.org/1999/xhtml]',
+ m('div',
+ m('a', { href: '/'+n.id, title: n.alttitle }, n.title),
+ m('div',
+ RDate.fmt(RDate.expand(n.released)), ' ',
+ n.languages.map(LangIcon),
+ )
+ ),
+ ),
+ ))(nodeById[optSel]) : null,
+ )),
+ );
+ return {view};
+});
diff --git a/js/user/DiscussionReply.js b/js/user/DiscussionReply.js
new file mode 100644
index 00000000..cd0e1dfe
--- /dev/null
+++ b/js/user/DiscussionReply.js
@@ -0,0 +1,29 @@
+widget('DiscussionReply', vnode => {
+ const data = vnode.attrs.data;
+ data.msg = '';
+ const api = new Api('DiscussionReply');
+ const view = () => m(Form, {api, onsubmit: () => api.call(data)}, m('article.submit',
+ data.old ? [ m('p.center',
+ 'This thread has not seen any activity for more than 6 months, but you may still ',
+ m('a[href=#]', { onclick: ev => {ev.preventDefault(); data.old = false} }, 'reply'),
+ ' if you have something relevant to add.',
+ m('br'),
+ 'If your message is not directly relevant to this thread, perhaps it\'s better to ',
+ m('a[href=/t/ge/new]', 'create a new thread'), ' instead.'
+ )] : [
+ m(TextPreview, {
+ data, field: 'msg',
+ attrs: { rows: 4, cols: 50, required: true, maxlength: 32768 },
+ header: [
+ m('strong', 'Quick reply'),
+ m('b', ' (English please!) '),
+ m('a[href=/d9#4][target=_blank]', 'Formatting'),
+ ],
+ }),
+ m('input[type=submit][value=Submit]'),
+ m('span.spinner', { class: api.loading() ? '' : 'invisible' }),
+ api.error ? m('p.formerror', api.error) : null,
+ ]
+ ));
+ return {view};
+});
diff --git a/js/user/QuoteEdit.js b/js/user/QuoteEdit.js
new file mode 100644
index 00000000..0424d263
--- /dev/null
+++ b/js/user/QuoteEdit.js
@@ -0,0 +1,56 @@
+widget('QuoteEdit', vnode => {
+ const data = vnode.attrs.data;
+ const api = new Api('QuoteEdit');
+ const chr = new DS({
+ list: (src, str, cb) => cb(data.chars.filter(c =>
+ (c.title + ' ' + c.alttitle).toLowerCase().includes(str.toLowerCase())
+ )),
+ view: c => [ m('small', c.id, ': '), c.title, m('small', ' ', c.alttitle) ],
+ }, { onselect: obj => {
+ data.cid = obj.id;
+ data.title = obj.title;
+ data.alttitle = obj.alttitle;
+ }});
+
+ let del = false;
+ const delApi = new Api('QuoteDel');
+
+ const redir = () => location.href = '/'+data.vid+'/quotes#quotes';
+ return {view: () => [
+ m(Form, {api, onsubmit: () => api.call(data, redir) }, m('fieldset.form',
+ m('fieldset',
+ m('label[for=quote]', 'Quote'),
+ m(Input, {id: 'quote', class: 'xw', data, field: 'quote', required: true, maxlength: 170 }),
+ ),
+ m('fieldset',
+ m('label', 'Character', HelpButton('chr')),
+ !data.cid ? [] : [
+ m(Button.Del, {onclick: () => data.cid = null }), ' ',
+ m('a[target=_blank]', { href: '/'+data.cid, title: data.alttitle }, data.title),
+ m('br'),
+ ],
+ m(DS.Button, {ds:chr}, 'Set character'),
+ ),
+ Help('chr', 'Story character who said this quote. Leave empty for narration or quotes that involve multiple characters.'),
+ !pageVars.dbmod ? null : m('fieldset',
+ m('label', 'State'),
+ m('label.check', m('input[type=radio]', { checked: !data.hidden, oninput: () => data.hidden = false }), ' Visible '),
+ m('label.check', m('input[type=radio]', { checked: data.hidden, oninput: () => data.hidden = true }), ' Deleted '),
+ ),
+ m('input[type=submit][value=Submit]'),
+ m('span.spinner', { class: api.loading() ? '' : 'invisible' }),
+ api.error ? m('p.formerror', api.error) : null,
+
+ )), !data.delete ? null : m(Form, {api: delApi, onsubmit: () => delApi.call({id:data.id}, redir) }, m('fieldset.form',
+ m('fieldset',
+ m('input[type=checkbox]', { checked: del, onclick: ev => del = ev.target.checked }),
+ ' Delete this quote',
+ ),
+ !del ? null : m('fieldset',
+ m('input[type=submit][value=Delete]'),
+ m('span.spinner', { class: delApi.loading() ? '' : 'invisible' }),
+ delApi.error ? m('p.formerror', delApi.error) : null,
+ ),
+ )),
+ ]};
+});
diff --git a/js/user/QuoteVote.js b/js/user/QuoteVote.js
new file mode 100644
index 00000000..7d266659
--- /dev/null
+++ b/js/user/QuoteVote.js
@@ -0,0 +1,18 @@
+widget('QuoteVote', vnode => {
+ let [id,score,vote,hidden,edit] = vnode.attrs.data;
+ const api = new Api('QuoteVote');
+ const set = v => () => {
+ if (vote) score -= vote;
+ vote = vote === v ? null : v;
+ if (vote) score += v;
+ api.call({id: id, vote: vote});
+ return false;
+ };
+ return {view: () => [
+ m('a[title=Edit]', { href: '/editquote/'+id, class: edit ? '' : 'invisible' }, m(Icon.Pencil)),
+ ' ',
+ m('a[title=Upvote][href=#]', { class: vote === 1 ? 'active' : null, onclick: set(1) }, m(Icon.ArrowBigUp)),
+ m(hidden ? 'small[title=Deleted]' : 'span', score),
+ m('a[title=Downvote][href=#]', { class: vote === -1 ? 'active' : null, onclick: set(-1) }, m(Icon.ArrowBigDown)),
+ ]};
+});
diff --git a/js/user/ReviewComment.js b/js/user/ReviewComment.js
new file mode 100644
index 00000000..fe1f38cf
--- /dev/null
+++ b/js/user/ReviewComment.js
@@ -0,0 +1,21 @@
+widget('ReviewComment', vnode => {
+ const data = vnode.attrs.data;
+ data.msg = '';
+ const api = new Api('ReviewComment');
+ const view = () => m(Form, {api, onsubmit: () => api.call(data)}, m('article.submit',
+ m(TextPreview, {
+ data, field: 'msg',
+ attrs: { rows: 4, cols: 50, required: true, maxlength: 32768 },
+ header: [
+ m('strong', 'Comment'),
+ m('b', ' (English please!) '),
+ m('a[href=/d9#4][target=_blank]', 'Formatting'),
+ ],
+ }),
+ m('input[type=submit][value=Submit]'),
+ m('span.spinner', { class: api.loading() ? '' : 'invisible' }),
+ api.error ? m('p.formerror', api.error) : null,
+ ));
+ return {view};
+});
+
diff --git a/js/user/ReviewsVote.js b/js/user/ReviewsVote.js
new file mode 100644
index 00000000..5a2f28c2
--- /dev/null
+++ b/js/user/ReviewsVote.js
@@ -0,0 +1,25 @@
+widget('ReviewsVote', vnode => {
+ const data = vnode.attrs.data;
+ const api = new Api('ReviewsVote');
+ const but = (v,label) =>
+ m('a[href=#].votebut', {
+ class: data.my === v ? 'myvote' : null,
+ onclick: ev => { ev.preventDefault(); data.my = data.my === v ? null : v; api.call(data) },
+ }, label);
+ const view = () => [
+ api.loading() ? m('span.spinner') :
+ api.error ? m('b', api.error) : 'Was this review helpful?',
+ ' ',
+ but(true, 'yes'),
+ ' / ',
+ but(false, 'no'),
+ data.mod ? [
+ ' / ',
+ m('label',
+ m('input[type=checkbox]', { checked: data.overrule, oninput: ev => { data.overrule = ev.target.checked; data.my !== null && api.call(data); } }),
+ ' O'
+ ),
+ ] : null,
+ ];
+ return {view};
+});
diff --git a/js/user/Subscribe.js b/js/user/Subscribe.js
new file mode 100644
index 00000000..8b569223
--- /dev/null
+++ b/js/user/Subscribe.js
@@ -0,0 +1,61 @@
+widget('Subscribe', vnode => {
+ let {id, subnum, subreview, subapply, noti} = vnode.attrs.data;
+ let saveApi = new Api('Subscribe');
+ const t = id.substring(0,1);
+
+ const msg = txt => m('p', txt, ' These can be disabled globally in your ', m('a[href=/u/notifies]', 'notification settings'), '.');
+
+ const save = f => () => {
+ f();
+ saveApi.call({ id, subnum, subreview, subapply });
+ };
+
+ const view = () => m(MainTabsDD, {
+ a_attrs: { class: (noti > 0 && subnum !== false) || subnum === true || subreview || subapply ? 'active' : 'inactive' },
+ a_body: '🔔',
+ content: () => [
+ m('h4',
+ saveApi.loading() ? m('span.spinner[style=float:right]') : null,
+ 'Manage Notifications'
+ ),
+
+ t == 't' && noti == 1 ? msg("You receive notifications for replies because you have posted in this thread.") :
+ t == 't' && noti == 2 ? msg("You receive notifications for replies because this thread is linked to your personal board.") :
+ t == 't' && noti == 3 ? msg("You receive notifications for replies because you have posted in this thread and it is linked to your personal board.") :
+ t == 'w' && noti == 1 ? msg("You receive notifications for new comments because you have commented on this review.") :
+ t == 'w' && noti == 2 ? msg("You receive notifications for new comments because this is your review.") :
+ t == 'w' && noti == 3 ? msg("You receive notifications for new comments because this is your review and you have commented it.") :
+ noti == 1 ? msg("You receive edit notifications for this entry because you have contributed to it.") :
+ null,
+
+ noti == 0 ? null : m('label',
+ m('input[type=checkbox][tabindex=10]', { checked: subnum === false, oninput: save(() => subnum = subnum === false ? null : false) }),
+ t == 't' ? ' Disable notifications only for this thread.' :
+ t == 'w' ? ' Disable notifications only for this review.'
+ : ' Disable edit notifications only for this entry.'
+ ),
+
+ m('label',
+ m('input[type=checkbox][tabindex=10]', { checked: subnum === true, oninput: save(() => subnum = subnum === true ? null : true) }),
+ t == 't' ? ' Enable notifications for new replies' :
+ t == 'w' ? ' Enable notifications for new comments'
+ : ' Enable notifications for new edits',
+ noti == 0 ? '.' : ', regardless of the global setting.'
+ ),
+
+ t == 'v' ? m('label',
+ m('input[type=checkbox][tabindex=10]', { checked: subreview, oninput: save(() => subreview = !subreview) }),
+ ' Enable notifications for new reviews.'
+ ) : null,
+
+ t == 'i' ? m('label',
+ m('input[type=checkbox][tabindex=10]', { checked: subapply, oninput: save(() => subapply = !subapply) }),
+ ' Enable notifications when this trait is applied or removed from a character.'
+ ) : null,
+
+ saveApi.error ? m('b', saveApi.error) : null,
+ ]
+ });
+
+ return {view};
+})
diff --git a/js/user/UserAdmin.js b/js/user/UserAdmin.js
new file mode 100644
index 00000000..70fdf43f
--- /dev/null
+++ b/js/user/UserAdmin.js
@@ -0,0 +1,58 @@
+widget('UserAdmin', initVnode => {
+ const data = initVnode.attrs.data;
+ const api = new Api('UserAdmin');
+ const chk = (opt, perm, label) => !data['editor_'+perm] ? null : m('label.check',
+ m('input[type=checkbox]', { checked: data['perm_'+opt], oninput: e => data['perm_'+opt] = e.target.checked }),
+ ' ', opt, m('small', ' (', label, ')'), m('br')
+ );
+ const none = {
+ perm_board: false, perm_review: false, perm_edit: false, perm_imgvote: false, perm_lengthvote: false, perm_tag: false,
+ perm_boardmod: false, perm_usermod: false, perm_tagmod: false, perm_dbmod: false
+ };
+ const def = {
+ perm_board: true, perm_review: true, perm_edit: true, perm_imgvote: true, perm_lengthvote: true, perm_tag: true,
+ perm_boardmod: false, perm_usermod: false, perm_tagmod: false, perm_dbmod: false
+ };
+ const view = () => m(Form, {api, onsubmit: () => api.call(data)},
+ m('article',
+ m('h1', 'Admin settings for '+(data.username||data.id)),
+ m('fieldset.form',
+ m('fieldset',
+ m('label', 'Preset'),
+ m('input[type=button][value=None]', { onclick: () => Object.assign(data, none) }),
+ m('input[type=button][value=Default]', { onclick: () => Object.assign(data, def) }),
+ ),
+ m('fieldset',
+ m('label', data.editor_usermod ? 'User perms' : 'Permissions'),
+ chk('board', 'boardmod', 'creating new threads and replying to existing threads and reviews'),
+ chk('review', 'boardmod', 'submitting new reviews'),
+ chk('edit', 'dbmod', 'database editing & tag voting'),
+ chk('imgvote', 'dbmod', 'flagging images - existing votes stop counting when unset'),
+ chk('lengthvote', 'dbmod', 'submitting VN play times - existing votes stop counting when unset'),
+ chk('tag', 'tagmod', 'voting on VN tags - existing votes stop counting when unset'),
+ ),
+ !data.editor_usermod ? null : m('fieldset',
+ m('label', 'Mod perms'),
+ chk('dbmod', 'usermod', 'database moderation'),
+ chk('tagmod', 'usermod', 'tags'),
+ chk('boardmod', 'usermod', 'forums & reviews'),
+ chk('usermod', 'usermod', 'full user editing'),
+ ),
+ !data.editor_usermod ? null : m('fieldset',
+ m('label', 'Other'),
+ m('label.check',
+ m('input[type=checkbox]', { checked: data.ign_votes, oninput: e => data.ign_votes = e.target.checked }),
+ ' Ignore votes in VN statistics'
+ ),
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Update]'),
+ api.loading() ? m('span.spinner')
+ : api.error ? m('p', api.error)
+ : api.saved(data) ? 'Saved!' : null
+ ),
+ )
+ )
+ );
+ return {view};
+});
diff --git a/js/user/UserEdit.js b/js/user/UserEdit.js
new file mode 100644
index 00000000..f2a26725
--- /dev/null
+++ b/js/user/UserEdit.js
@@ -0,0 +1,573 @@
+const DSTimeZone = {
+ list: (src, str, cb) => cb(timeZones.filter(z => z.toLowerCase().includes(str)).map(id => ({id}))),
+ view: ({id}) => {
+ const [,region,place] = id.replace('_', ' ').match(/([^\/]+)\/(.+)/) || [0,'',id];
+ return [ region ? m('small', region, ' / ') : null, place ];
+ },
+};
+
+let username_edit = false;
+let username_taken = {};
+const Username = () => {
+ let old = '';
+ return {view: v => m('fieldset.form',
+ // Explicit keys to work around https://github.com/MithrilJS/mithril.js/issues/2842
+ m('legend', {key:1}, 'Username'),
+ !username_edit ? m('fieldset', {key:2},
+ m('label', 'Current'),
+ v.attrs.data.username,
+ ' ',
+ v.attrs.data.username_throttled
+ ? m('small', '(changed within the past 24 hours)')
+ : m('input[type=button][value=Edit]', { onclick: () => { old = v.attrs.data.username; username_edit = true } }),
+ ) : m('fieldset', {key:3},
+ m('label[for=username]', 'New username'),
+ m(Input, {
+ id: 'username', class: 'mw', type: 'username', required: true, data: v.attrs.data, field: 'username', focus: true,
+ invalid: username_taken[v.attrs.data.username] ? 'Username already taken.' : null,
+ }),
+ m('input[type=button][value=Cancel]', { onclick: () => { v.attrs.data.username = old; username_edit = false } }),
+ m('p',
+ username_reqs, m('br'),
+ 'Things to keep in mind:', m('br'),
+ '- Your old username(s) will be displayed on your profile for a month after the change.', m('br'),
+ '- You will not be able to log in with your old username(s).', m('br'),
+ '- Your old username will become available for other people to claim.', m('br'),
+ '- You may only change your username at once per day.',
+ ),
+ ),
+ )};
+};
+
+let email_edit = false, email_old = '', email_taken = {};
+const Email = () => {
+ return {view: v => m('fieldset.form',
+ m('legend', {key:1}, 'E-Mail'),
+ !email_edit ? m('fieldset', {key:2},
+ m('label', 'Current'), v.attrs.data.email, ' ',
+ m('input[type=button][value=Edit]', { onclick: () => { email_old = v.attrs.data.email; email_edit = true } }),
+ ) : m('fieldset', {key:3},
+ m('label[for=email]', 'New email'),
+ m(Input, {
+ id: 'email', class: 'mw', type: 'email', data: v.attrs.data, required: true, field: 'email', focus: true,
+ invalid: email_taken[v.attrs.data.email] ? 'Email already used by another account.' : null,
+ }),
+ m('input[type=button][value=Cancel]', { onclick: () => { v.attrs.data.email = email_old; email_edit = false } }),
+ m('p', 'A verification mail will be send to your new address.'),
+ ),
+ )};
+};
+
+let password_repeat = {v:''}, password_leaked = {}, password_invalid = false;
+const Password = () => {
+ return {view: v => m('fieldset.form',
+ m('legend', 'Password'),
+ m('label.check',
+ m('input[type=checkbox]', { checked: !!v.attrs.data.password, oninput: e => {
+ if (e.target.checked) v.attrs.data.password = { old: '', new: '' };
+ else { v.attrs.data.password = null; password_repeat.v = ''; }
+ }}),
+ ' Change password'
+ ),
+ !v.attrs.data.password ? [] : [
+ m('fieldset',
+ m('label[for=opass]', 'Current password'),
+ m(Input, {
+ id: 'opass', class: 'mw', type: 'password', required: true, data: v.attrs.data.password, field: 'old', focus: 1,
+ invalid: password_invalid ? 'Invalid password' : null,
+ oninput: () => password_invalid = false,
+ }),
+ ),
+ m('fieldset',
+ m('label[for=npass]', 'New password'),
+ m(Input, {
+ id: 'npass', class: 'mw', type: 'password', required: true, data: v.attrs.data.password, field: 'new',
+ invalid: password_leaked[v.attrs.data.password.new] ? 'Your new password is in a public database of leaked passwords, please choose a different password.' : null,
+ }),
+ ),
+ m('fieldset',
+ m('label[for=rpass]', 'Repeat'),
+ m(Input, {
+ id: 'rpass', class: 'mw', type: 'password', required: true, data: password_repeat, field: 'v',
+ invalid: v.attrs.data.password.new !== password_repeat.v ? 'Passwords do not match.' : null,
+ }),
+ ),
+ ]
+ )};
+};
+
+let uniname_taken = {};
+const Support = initVnode => {
+ const data = initVnode.attrs.data;
+ return {view: () => data.editor_usermod || data.nodistract_can || data.support_can || data.uniname_can || data.pubskin_can ? m('fieldset.form',
+ m('legend', 'Supporter options⭐'),
+ data.editor_usermod ? m('p',
+ 'Enabled options: ' + (['nodistract', 'support', 'uniname', 'pubskin'].filter(x => data[x+'_can']).join(', ')||'none') + '.'
+ ) : null,
+ data.editor_usermod || data.nodistract_can ? m('fieldset',
+ m('label.check',
+ m('input[type=checkbox]', { checked: data.nodistract_noads, oninput: e => data.nodistract_noads = e.target.checked }),
+ ' Disable advertising and other distractions (only hides the support box for the moment)',
+ ),
+ m('br'),
+ m('label.check',
+ m('input[type=checkbox]', { checked: data.nodistract_nofancy, oninput: e => data.nodistract_nofancy = e.target.checked }),
+ ' Disable supporters badges, custom display names and profile skins',
+ ),
+ ) : null,
+ data.editor_usermod || data.support_can ? m('fieldset',
+ m('label.check',
+ m('input[type=checkbox]', { checked: data.support_enabled, oninput: e => data.support_enabled = e.target.checked }),
+ ' Display my supporters badge',
+ )
+ ) : null,
+ data.editor_usermod || data.pubskin_can ? m('fieldset',
+ m('label.check',
+ m('input[type=checkbox]', { checked: data.pubskin_enabled, oninput: e => data.pubskin_enabled = e.target.checked }),
+ ' Apply my skin and custom CSS when others visit my profile',
+ )
+ ) : null,
+ data.editor_usermod || data.uniname_can ? m('fieldset',
+ m('label[for=uniname]', 'Display name'),
+ m(Input, {
+ id: 'uniname', class: 'mw', minlength: 2, maxlength: 15, data, field: 'uniname', placeholder: data.username,
+ invalid: uniname_taken[data.uniname] ? 'This name is already taken' : null,
+ }),
+ m('p', 'Between 2 and 15 characters, all unicode characters are accepted.'),
+ ) : null,
+ ) : null};
+};
+
+const Traits = initVnode => {
+ const data = initVnode.attrs.data;
+ const lookup = Object.fromEntries(data.traits.map(x => [x.tid,true]));
+ const ds = new DS(DS.Traits, {
+ props: obj =>
+ lookup[obj.id]
+ ? { selectable: false, append: m('small', ' (already listed)') }
+ : obj.hidden ? null : { selectable: obj.applicable },
+ onselect: obj => {
+ lookup[obj.id] = true;
+ data.traits.push({ tid: obj.id, group: obj.group_name, name: obj.name });
+ },
+ });
+ return {view: () => m('fieldset.form',
+ m('label', 'Traits'),
+ m('p', 'You can add up to 100 ', m('a[href=/i][target=_blank]', 'character traits'), ' to your account. These are displayed on your public profile.'),
+ m('table.stripe',
+ m('tbody', data.traits.map(t => m('tr', { key: t.tid },
+ m('td', m(Button.Del, {onclick: () => {
+ delete lookup[t.tid];
+ data.traits = data.traits.filter(x => x.tid !== t.tid);
+ }})),
+ m('td', t.group ? m('small', t.group, ' / ') : null, m('a[target=_blank]', { href: '/'+t.tid }, t.name)),
+ ))),
+ m('tfoot', m('tr', m('td[colspan=2]',
+ data.traits.length >= 100
+ ? 'Maximum number of traits reached.'
+ : m(DS.Button, {ds}, 'Add trait'),
+ ))),
+ ),
+ )}
+};
+
+
+const Titles = initVnode => {
+ const lst = initVnode.attrs.lst;
+ const langs = Object.fromEntries(vndbTypes.language);
+ const nonlatin = Object.fromEntries(vndbTypes.language.filter(l => !l[2]).map(l => [l[0],true]).concat([['',true]]));
+ const ds = new DS(DS.Lang, { onselect: obj => {
+ const o = lst.pop();
+ lst.push({lang: obj.id, latin: false, official: true });
+ lst.push(o);
+ }});
+ return {view: () => m('table.stripe',
+ m('tbody', lst.map((t,n) => m('tr',
+ m('td', '#'+(n+1)),
+ m('td', t.lang ? [LangIcon(t.lang), langs[t.lang]] : ['Original language']),
+ m('td', nonlatin[t.lang || ''] ? m('label',
+ m('input[type=checkbox]', { checked: t.latin, oninput: ev => t.latin = ev.target.checked }),
+ ' romanized'
+ ) : null),
+ m('td', t.lang ? m(Select, { class: 'mw', data: t, field: 'official', options: [
+ [ null, 'Original only' ],
+ [ true, 'Official only' ],
+ [ false, 'Any' ],
+ ]}) : null),
+ m('td',
+ m(Button.Up, {visible: t.lang && n > 0, onclick: () => {
+ lst[n] = lst[n-1];
+ lst[n-1] = t;
+ }}),
+ m(Button.Down, {visible: n < lst.length-2, onclick: () => {
+ lst[n] = lst[n+1];
+ lst[n+1] = t;
+ }}),
+ m(Button.Del, {visible: !!t.lang, onclick: () => lst.splice(n,1)}),
+ ),
+ ))),
+ m('tfoot', m('tr', m('td[colspan=5]',
+ lst.length >= 5 ? null
+ : m(DS.Button, {ds}, 'Add language'),
+ )))
+ )};
+};
+
+const display = data => {
+ const tz = new DS(DSTimeZone, { onselect: ({id}) => data.timezone = id });
+ const brtz = (e => timeZones.includes(e) && e)(window.Intl && Intl.DateTimeFormat().resolvedOptions().timeZone);
+
+ const vl = new DS(DS.Lang, {
+ checked: ({id}) => data.vnrel_langs.includes(id),
+ onselect: ({id},sel) => {if (sel) data.vnrel_langs.push(id); else data.vnrel_langs = data.vnrel_langs.filter(x => x !== id)},
+ checkall: () => data.vnrel_langs = vndbTypes.language.map(([x])=>x),
+ uncheckall: () => data.vnrel_langs = [],
+ });
+ let vlangs = data.vnrel_langs || [];
+
+ const sl = new DS(DS.Lang, {
+ checked: ({id}) => data.staffed_langs.includes(id),
+ onselect: ({id},sel) => {if (sel) data.staffed_langs.push(id); else data.staffed_langs = data.staffed_langs.filter(x => x !== id)},
+ checkall: () => data.staffed_langs = vndbTypes.language.map(([x])=>x),
+ uncheckall: () => data.staffed_langs = [],
+ });
+ let slangs = data.staffed_langs || [];
+
+ return () => [
+ m('h1', 'Display preferences'),
+ m('fieldset.form',
+ m('legend', 'Global'),
+ m('fieldset',
+ m('label[for=skin]', 'Skin'),
+ m(Select, {
+ id: 'skin', class: 'lw', data, field: 'skin',
+ oninput: v => (s => s.href = s.href.replace(/[^\/]+\.css/, v+'.css'))($('link[rel=stylesheet]')),
+ options: vndbSkins,
+ }), ' ',
+ m('label.check', m('input[type=checkbox]', { checked: data.customcss_csum, oninput: ev => data.customcss_csum = ev.target.checked }), 'Custom css'),
+ ),
+ data.customcss_csum ? m('fieldset',
+ m('label[for=customcss]', 'Custom CSS'),
+ m('textarea#customcss.xw[rows=5][cols=60][maxlength=262144]', { oninput: ev => data.customcss = ev.target.value }, data.customcss),
+ m('p.grayedout', '(@import statements do not work; future site updates may break your customizations)'),
+ ) : null,
+ m('fieldset',
+ m('label', 'Time zone', HelpButton('timezone')),
+ m(DS.Button, { class: 'lw', ds: tz }, data.timezone),
+ ' ', brtz && brtz != data.timezone
+ ? m('a[href=#]', { onclick: ev => { ev.preventDefault(); data.timezone = brtz }}, 'Set to '+brtz)
+ : null,
+ ),
+ Help('timezone', 'Select the city that is nearest to you in terms of time zone and all dates & times on the site are adjusted.'),
+ m('fieldset',
+ m('label', 'Image display'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: data.max_sexual === -1, oninput: ev => data.max_sexual = ev.target.checked ? -1 : 0 }),
+ ' Hide all images by default'
+ ),
+ ),
+ data.max_sexual === -1 ? null : m('fieldset',
+ 'Maximum sexual level:', m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_sexual === 0, onchange: () => data.max_sexual = 0 }), ' Safe'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_sexual === 1, onchange: () => data.max_sexual = 1 }), ' Suggestive'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_sexual === 2, onchange: () => data.max_sexual = 2 }), ' Explicit'),
+ ),
+ data.max_sexual === -1 ? null : m('fieldset',
+ 'Maximum violence level:', m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_violence === 0, onchange: () => data.max_violence = 0 }), ' Tame'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_violence === 1, onchange: () => data.max_violence = 1 }), ' Violent'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_violence === 2, onchange: () => data.max_violence = 2 }), ' Brutal'),
+ ),
+ m('fieldset',
+ m('label', 'Spoiler level'),
+ m('label.check', m('input[type=radio]', { checked: data.spoilers === 0, onchange: () => data.spoilers = 0 }), ' No spoilers'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.spoilers === 1, onchange: () => data.spoilers = 1 }), ' Minor spoilers'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.spoilers === 2, onchange: () => data.spoilers = 2 }), ' Major spoilers'),
+ ),
+ ),
+ m('fieldset.form',
+ m('legend', 'Titles', HelpButton('titles')),
+ Help('titles',
+ m('p',
+ 'Database entries can have different titles in different languages. ',
+ 'Here you can choose which languages you prefer to see across the site.',
+ ), m('p',
+ 'You can select multiple languages, ordered by preference. ',
+ 'If an entry does not have a title for the first language, the second one will be chosen, etc. ',
+ 'The language that the entry was originally published in is always used as fallback.'
+ ), m('p',
+ 'For each language you can indicate whether you want the title in the original script or romanized. ',
+ 'You can also limit the selection of titles with the following options:',
+ ), m('dl',
+ m('dt', 'Original only'),
+ m('dd',
+ "Only select this title if it is the entry's original language. ",
+ "The original language is always used as fallback, but with this option you can use a different ",
+ "romanized flag or prevent a lower priority language from being selected."
+ ),
+ m('dt', 'Official only'),
+ m('dd', "Don't use this language if only an unofficial title is available."),
+ m('dt', 'Any'),
+ m('dd', 'Use this language even if only an unofficial title is available.'),
+ ),
+ ),
+ m('fieldset',
+ m('label', 'Title'),
+ m(Titles, {lst: data.titles}),
+ ),
+ m('fieldset',
+ m('label', 'Alternative title'),
+ m('p', 'The alternative title is used as tooltip for links or displayed next to the main title.'),
+ m(Titles, {lst: data.alttitles}),
+ )
+ ),
+ m('fieldset.form',
+ m('legend', 'Visual novel pages'),
+ m('label', 'Tags'),
+ m('fieldset', m('label.check', m('input[type=checkbox]',
+ { checked: data.tags_all, onchange: ev => data.tags_all = ev.target.checked },
+ ), " Show all tags by default (don't summarize)"
+ )),
+ m('fieldset',
+ 'Default tag categories:', m('br'),
+ m('label.check', m('input[type=checkbox]', { checked: data.tags_cont, onchange: ev => data.tags_cont = ev.target.checked }), ' Content'), m('br'),
+ m('label.check', m('input[type=checkbox]', { checked: data.tags_ero, onchange: ev => data.tags_ero = ev.target.checked }), ' Sexual content'), m('br'),
+ m('label.check', m('input[type=checkbox]', { checked: data.tags_tech, onchange: ev => data.tags_tech = ev.target.checked }), ' Technical'),
+ ),
+
+ m('fieldset',
+ m('label', 'Releases'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: data.vnrel_langs === null, onchange: ev => {
+ if (ev.target.checked) { vlangs = data.vnrel_langs; data.vnrel_langs = null }
+ else data.vnrel_langs = vlangs
+ }}),
+ ' Expand all languages'
+ ),
+ ),
+ data.vnrel_langs === null ? null : m('fieldset',
+ m(DS.Button, { ds: vl }, 'Select languages'),
+ data.vnrel_langs.map(LangIcon)
+ ),
+ m('fieldset',
+ data.vnrel_langs === null ? null : m('label.check', m('input[type=checkbox]',
+ { checked: data.vnrel_olang, onchange: ev => data.vnrel_olang = ev.target.checked }),
+ ' Always expand original language', m('br'),
+ ),
+ m('label.check', m('input[type=checkbox]', { checked: data.vnrel_mtl, onchange: ev => data.vnrel_mtl = ev.target.checked }), ' Expand machine translations'),
+ ),
+
+ m('fieldset',
+ m('label', 'Staff'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: data.staffed_langs === null, onchange: ev => {
+ if (ev.target.checked) { slangs = data.staffed_langs; data.staffed_langs = null }
+ else data.staffed_langs = slangs
+ }}),
+ ' Expand all languages'
+ ),
+ ),
+ data.staffed_langs === null ? null : m('fieldset',
+ m(DS.Button, { ds: sl }, 'Select languages'),
+ data.staffed_langs.map(LangIcon)
+ ),
+ m('fieldset',
+ data.staffed_langs === null ? null : m('label.check', m('input[type=checkbox]',
+ { checked: data.staffed_olang, onchange: ev => data.staffed_olang = ev.target.checked }),
+ ' Always expand original edition', m('br'),
+ ),
+ m('label.check', m('input[type=checkbox]', { checked: data.staffed_unoff, onchange: ev => data.staffed_unoff = ev.target.checked }), ' Expand unofficial editions'),
+ ),
+ ),
+ m('fieldset.form',
+ m('legend', 'Other pages'),
+ m('fieldset',
+ m('label', 'Characters'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: data.traits_sexual, onchange: ev => data.traits_sexual = ev.target.checked }),
+ ' Display sexual traits by default'
+ ),
+ ),
+ m('fieldset',
+ m('label', 'Producers'),
+ 'Default tab:', m('br'),
+ m('label.check', m('input[type=radio]', { checked: !data.prodrelexpand, onchange: () => data.prodrelexpand = false }), ' Visual novels'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.prodrelexpand, onchange: () => data.prodrelexpand = true }), ' Releases'),
+ ),
+ ),
+ ];
+};
+
+const TTPrefs = initVnode => {
+ const {data,prefix} = initVnode.attrs;
+ const pref = prefix === 'g' ? 'tagprefs' : 'traitprefs';
+ const ds = new DS(prefix === 'g' ? DS.Tags : DS.Traits, {
+ onselect: obj => data[pref].push({tid: obj.id, name: obj.name, group: obj.group_name, spoil: null, color: null, childs: true }),
+ props: obj => data[pref].find(o => obj.id === o.tid) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ });
+ return {view: () => m('fieldset.form',
+ m('legend', prefix === 'g' ? 'Tags' : 'Traits'),
+ m('table.full.stripe',
+ m('tbody', data[pref].map(t => m('tr', {key: t.tid},
+ m('td', m(Button.Del, { onclick: () => data[pref] = data[pref].filter(o => o.tid !== t.tid) })),
+ m('td',
+ t.group ? m('small', t.group + ' / ') : null,
+ m('a[target=_blank]', { href: '/'+t.tid }, t.name)
+ ),
+ m('td', m(Select, { class: 'mw', data: t, field: 'spoil', options: [
+ [ null, 'Keep spoiler level' ],
+ [ 0, 'Always show' ],
+ [ 1, 'Force minor spoiler' ],
+ [ 2, 'Force major spoiler' ],
+ [ 3, 'Always hide' ],
+ ]})),
+ m('td', t.spoil === 3 ? null : m(Select, { class: 'mw', data: t, field: 'color', options: [
+ [ null, "Don't highlight" ],
+ [ 'standout', 'Stand out' ],
+ [ 'grayedout', 'Grayed out' ],
+ [ t.color && t.color.startsWith('#') ? t.color : '#ffffff', 'Custom color' ],
+ ]})),
+ m('td', t.spoil === 3 || !t.color || !t.color.startsWith('#') ? null :
+ m('input[type=color]', { value: t.color, oninput: ev => t.color = ev.target.value })
+ ),
+ m('td', m('label.check',
+ m('input[type=checkbox]', { checked: t.childs, oninput: ev => t.childs = ev.target.checked }),
+ ' also apply to child ', prefix === 'g' ? 'tags' : 'traits',
+ )),
+ ))),
+ m('tfoot', m('tr', m('td[colspan=6]',
+ data[pref].length >= 500 ? null
+ : m(DS.Button, {ds}, prefix === 'g' ? 'Add tag' : 'Add trait')
+ ))),
+ ),
+ )};
+};
+
+const applications = data => {
+ const api = new Api('UserApi2New');
+ const clip = navigator.clipboard;
+ let copied;
+ return () => [
+ m('h1', 'Applications'),
+ m('p.description',
+ 'Here you can create and manage tokens for use with ', m('a[href=/d11][target=_blank]', 'the API'), '.', m('br'),
+ "It's strongly recommended that you create a separate token for each application that you use,",
+ " so that you can easily change or revoke permissions on a per-application level.", m('br'),
+ 'Tokens without permissions can still be used for identification.'
+ ),
+ data.api2.map(t => m('fieldset.form', {key: t.token},
+ m('legend', t.notes || (t.token.replace(/-.+/, '')+'-...')),
+ t.delete ? [ m('fieldset',
+ m('p',
+ 'This token is deleted on form submission. ',
+ m('a[href=#]', { onclick: ev => { ev.preventDefault(); t.delete = false } }, 'Undo'), '.'
+ ),
+ )] : [ m('fieldset',
+ m('label', 'Token'),
+ m('input.lw.monospace.obscured[type=text][readonly]', {
+ value: t.token,
+ onfocus: ev => { ev.target.select(); ev.target.classList.remove('obscured') },
+ onblur: ev => ev.target.classList.add('obscured'),
+ }),
+ clip ? m(Button.Copy, { onclick: () => clip.writeText(t.token).then(() => { copied = t.token; m.redraw() }) }) : null,
+ copied === t.token ? 'copied!' : null,
+ ),
+ m('fieldset',
+ m('label', { for: 'name'+t.token }, 'Name'),
+ m(Input, { id: 'name'+t.token, class: 'mw', maxlength: 200, data: t, field: 'notes' }),
+ ' (optional, for personal use)'
+ ),
+ m('fieldset',
+ m('label', 'Permissions'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: t.listread, oninput: ev => { t.listread = ev.target.checked; if (!t.listread) t.listwrite = false } }),
+ ' Access private items on my list'
+ ), m('br'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: t.listwrite, oninput: ev => { t.listwrite = ev.target.checked; if (t.listwrite) t.listread = true } }),
+ ' Add/remove/edit items on my list',
+ ),
+ ),
+ m('fieldset',
+ m(Button.Del, { onclick: () => t.delete = true }),
+ m('small', ' Created on ', t.added, ', ', t.lastused ? 'last used on '+t.lastused : 'never used', '.')
+ ),
+ ],
+ )),
+ m('fieldset.form', { disabled: api.loading() },
+ m('input[type=button][value=Create new token]', { onclick: () => api.call({id:data.id}, res =>
+ data.api2.push({token: res.token, added: res.added, notes: '', listread: false, listwrite: false })
+ )}),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ];
+};
+
+widget('UserEdit', initVnode => {
+ let msg = '';
+ const data = initVnode.attrs.data;
+ const api = new Api('UserEdit');
+ const onsubmit = ev => { msg = ''; api.call(data,
+ res => {
+ msg = res.email
+ ? 'A confirmation email has been sent to your new address. Your address will be updated after following the instructions in that mail.'
+ : 'Saved!';
+ username_edit = false;
+ if (email_edit) data.email = email_old;
+ email_edit = false;
+ password_repeat.v = ''; data.password = null;
+ data.api2 = data.api2.filter(x => !x.delete);
+ api.setsaved(data);
+ },
+ err => {
+ const c = err && err.code;
+ if (c === 'username_taken') username_taken[data.username] = 1;
+ if (c === 'email_taken') email_taken[data.email] = 1;
+ if (c === 'opass') password_invalid = 1;
+ if (c === 'npass') password_leaked[data.password.new] = 1;
+ if (c === 'uniname') uniname_taken[data.uniname] = 1;
+ },
+ )};
+
+ const account = () => [
+ m('h1', 'Account'),
+ m(Username, {data}),
+ m(Email, {data}),
+ m(Password, {data}),
+ m(Support, {data}),
+ m('fieldset.form',
+ m('legend', 'Account deletion'),
+ m('button[type=button]', { onclick: () => location.href = '/'+data.id+'/del' }, 'Delete my account'),
+ ),
+ ];
+
+ const tt = () => [
+ m('h1', 'Tags & traits'),
+ m('p.description',
+ "Here you can set display preferences for individual tags & traits.",
+ " This feature can be used to completely hide tags/traits you'd rather not see at all or you'd like to highlight as a possible trigger warning instead.",
+ m('br'),
+ "These settings are applied on visual novel and character pages, other listings on the site are unaffected."
+ ),
+ m(TTPrefs, {data, prefix: 'g'}),
+ m(TTPrefs, {data, prefix: 'i'}),
+ ];
+
+ const tabs = [
+ [ 'account', 'Account', account ],
+ [ 'profile', 'Public Profile', () => [ m('h1', 'Public Profile'), m(Traits, {data}) ] ],
+ [ 'display', 'Display Preferences', display(data) ],
+ [ 'tt', 'Tags & Traits', tt ],
+ [ 'api', 'Applications', applications(data) ],
+ ];
+ const view = () => m(Form, {onsubmit,api},
+ m(FormTabs, {tabs}),
+ m('article.submit',
+ m('input[type=submit][value=Submit]'),
+ m('span.spinner', { class: api.loading() ? '' : 'invisible' }),
+ api.error ? m('p.formerror', api.error) : msg && api.saved(data) ? m('p', msg) : null,
+ ),
+ );
+ return {view};
+});
diff --git a/js/user/UserLogin.js b/js/user/UserLogin.js
new file mode 100644
index 00000000..7afc24f2
--- /dev/null
+++ b/js/user/UserLogin.js
@@ -0,0 +1,71 @@
+let needChange = false, uid, password;
+
+const ChangePass = vnode => {
+ let data = { pass1: '', pass2: '' };
+ const ref = vnode.attrs.data.ref;
+ const api = new Api('UserChangePass');
+ const onsubmit = () => api.call({ uid, oldpass: password, newpass: data.pass1 }, res => location.href = ref);
+ const view = () => m(Form, {api,onsubmit}, m('article',
+ m('h1', 'Change password'),
+ m('div.warning',
+ m('h2', 'Your current password is insecure.'),
+ 'Your password is listed in a ',
+ m('a[href=https://haveibeenpwned.com/][target=_blank]', 'database of leaked passwords'),
+ ', please set a new password to continue using your account.'
+ ),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=pass1]', 'New password'),
+ m(Input, { id: 'pass1', class: 'mw', type: 'password', required: 'true', data, field: 'pass1', focus: 1 }),
+ ),
+ m('fieldset',
+ m('label[for=pass2]', 'Repeat'),
+ m(Input, { id: 'pass2', class: 'mw', type: 'password', required: 'true', data, field: 'pass2' }),
+ data.pass1 !== data.pass2 ? m('p.invalid', 'Passwords do not match') : null,
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Update]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ),
+ ));
+ return {view};
+};
+
+const Login = vnode => {
+ let data = { username: '', password: '' };
+ const ref = vnode.attrs.data.ref;
+ const api = new Api('UserLogin');
+ const onsubmit = () => api.call(data, res => {
+ if (res.ok) location.href = ref;
+ if (res.insecurepass) {
+ needChange = true;
+ uid = res.uid;
+ password = data.password;
+ }
+ });
+ const view = () => m(Form, {onsubmit, api}, m('article',
+ m('h1', 'Login'),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=username]', 'Username or email'),
+ m(Input, { id: 'username', class: 'mw', tabindex: 1, required: true, data, field: 'username' }),
+ m('p', m('a[href=/u/register]', 'No account yet?')),
+ ),
+ m('fieldset',
+ m('label[for=password]', 'Password'),
+ m(Input, { id: 'password', class: 'mw', tabindex: 1, required: true, type: 'password', data, field: 'password' }),
+ m('p', m('a[href=/u/newpass]', 'Lost your password?')),
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Submit][tabindex=1]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ),
+ ));
+ return {view};
+};
+
+widget('UserLogin', { view: v => m(needChange ? ChangePass : Login, v.attrs) });
diff --git a/js/user/UserPassReset.js b/js/user/UserPassReset.js
new file mode 100644
index 00000000..bfd3b53e
--- /dev/null
+++ b/js/user/UserPassReset.js
@@ -0,0 +1,30 @@
+widget('UserPassReset', () => {
+ const api = new Api('UserPassReset');
+ const data = {email:''};
+ let done = false;
+ const onsubmit = () => api.call(data, () => done = true);
+ const view = () => m(Form, {api, onsubmit}, m('article',
+ m('h1', 'Forgot password'),
+ done ? m('div.notice',
+ m('h2', 'Check your email'),
+ m('p', 'Instructions to set a new password should reach your mailbox in a few minutes.'),
+ m('p', '(make sure to check your spam box if the mail doesn\'t seem to be arriving)'),
+ ) : m('fieldset.form',
+ m('p',
+ 'Forgot your password and can\'t login to VNDB anymore? ',
+ 'Don\'t worry! Just give us the email address you used to register on VNDB ',
+ ' and we\'ll send you instructions to set a new password within a few minutes!'
+ ),
+ m('fieldset',
+ m('label[for=email]', 'E-Mail'),
+ m(Input, { id: 'email', type: 'email', class: 'mw', required: true, data, field: 'email' }),
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Submit]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ )
+ ));
+ return {view};
+});
diff --git a/js/user/UserPassSet.js b/js/user/UserPassSet.js
new file mode 100644
index 00000000..918f5a8a
--- /dev/null
+++ b/js/user/UserPassSet.js
@@ -0,0 +1,34 @@
+widget('UserPassSet', vnode => {
+ const api = new Api('UserPassSet');
+ const data = vnode.attrs.data;
+ data.password = data.repeat = '';
+ const onsubmit = () => api.call(data, null,
+ err => err && err.insecure && $('#password').focus()
+ );
+ const view = () => m(Form, {api, onsubmit}, m('article',
+ m('h1', 'Set your password'),
+ m('fieldset.form',
+ m('p', 'Now you can set a password for your account. You will be logged in automatically after your password has been saved.'),
+ m('fieldset',
+ m('label[for=password]', 'New password'),
+ m(Input, {
+ id: 'password', class: 'mw', type: 'password', required: true, data, field: 'password',
+ oninput: () => api.abort(),
+ }),
+ ),
+ m('fieldset',
+ m('label[for=repeat]', 'Repeat'),
+ m(Input, {
+ id: 'repeat', class: 'mw', type: 'password', required: true, data, field: 'repeat',
+ invalid: data.password !== '' && data.password === data.repeat ? '' : 'Passwords do not match.',
+ }),
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Submit]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ),
+ ));
+ return {view};
+});
diff --git a/js/user/UserRegister.js b/js/user/UserRegister.js
new file mode 100644
index 00000000..8f1b0597
--- /dev/null
+++ b/js/user/UserRegister.js
@@ -0,0 +1,71 @@
+widget('UserRegister', vnode => {
+ let c18 = false, cpolicy = false, ccheck = false, success = false;
+ const api = new Api('UserRegister');
+ const data = { username: '', email: '' };
+ const dupnames = {};
+ const onsubmit = ev => api.call(data, res => {
+ if (res && res.err === 'username') dupnames[data.username] = true;
+ success = res && res.ok;
+ });
+ const donemsg = m('article',
+ m('h1', 'Account created'),
+ m('div.notice', m('p',
+ 'Your account has been created!', m('br'),
+ 'Check your inbox for an email with instructions to activate your account.', m('br'),
+ "(also make sure to check your spam box if it doesn't seem to be arriving)", m('br'),
+ m('br'),
+ "If the email does not arrive within a few hours, please send a mail to contact@vndb.org so we can investigate.",
+ ))
+ );
+ const view = () => success ? donemsg : m(Form, {onsubmit, api}, m('article',
+ m('h1', 'Create an account'),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=username]', 'Username'),
+ m(Input, {
+ id: 'username', type: 'username', class: 'mw', required: true, data, field: 'username',
+ invalid: dupnames[data.username] ? 'Username already taken' : null,
+ }),
+ m('p', username_reqs),
+ ),
+ m('fieldset',
+ m('label[for=email]', 'E-Mail'),
+ m(Input, {
+ id: 'email', type: 'email', class: 'mw', required: true, data, field: 'email',
+ }),
+ m('p',
+ 'A valid address is required in order to activate and use your account. ',
+ 'Other than that, your address is only used in case you lose your password, ',
+ 'we will never send spam or newsletters unless you explicitly ask us for it or we get hacked.',
+ ),
+ ),
+ m('fieldset',
+ m('label.check',
+ m('input#c18[type=checkbox]', { checked: c18, oninput: ev => c18 = ev.target.checked }),
+ ' I am 18 years or older.'
+ ),
+ c18 ? null : m('p.invalid', 'You must be 18 years or older to use this site.'),
+ ),
+ m('fieldset',
+ m('label.check',
+ m('input#cpolicy[type=checkbox]', { checked: cpolicy, oninput: ev => cpolicy = ev.target.checked }),
+ ' I have read the ', m('a[href=/d17]', 'privacy policy and contributor license agreement'), '.'
+ ),
+ cpolicy ? null : m('p.invalid', "You can at least pretend you've read it."),
+ ),
+ m('fieldset',
+ m('label.check',
+ m('input#ccheck[type=checkbox]', { checked: ccheck, oninput: ev => ccheck = ev.target.checked }),
+ ' I click checkboxes without reading the label.'
+ ),
+ ccheck ? m('p.invalid', "*sigh* don't do that.") : null,
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Submit]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ),
+ ));
+ return {view};
+});
diff --git a/js/user/index.js b/js/user/index.js
new file mode 100644
index 00000000..28b81456
--- /dev/null
+++ b/js/user/index.js
@@ -0,0 +1,27 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only
+// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/js
+// SPDX-License-Identifier: AGPL-3.0-only
+"use strict";
+
+const username_reqs = [
+ 'Username requirements:', m('br'),
+ '- Between 2 and 15 characters long.', m('br'),
+ '- Permitted characters: alphabetic, numbers and dash (-).', m('br'),
+ '- No spaces, diacritics or fancy Unicode characters.', m('br'),
+ '- May not look like a VNDB identifier (i.e. an alphabetic character followed only by numbers).',
+];
+@include .gen/user.js
+@include Subscribe.js
+@include UserLogin.js
+@include UserEdit.js
+@include UserRegister.js
+@include UserPassReset.js
+@include UserPassSet.js
+@include UserAdmin.js
+@include DiscussionReply.js
+@include ReviewComment.js
+@include ReviewsVote.js
+@include QuoteEdit.js
+@include QuoteVote.js
+
+// @license-end
diff --git a/lib/Multi/API.pm b/lib/Multi/API.pm
index d7c59378..8b9dfdbb 100644
--- a/lib/Multi/API.pm
+++ b/lib/Multi/API.pm
@@ -5,7 +5,7 @@
package Multi::API;
-use strict;
+use v5.26;
use warnings;
use Multi::Core;
use Socket 'SO_KEEPALIVE', 'SOL_SOCKET', 'IPPROTO_TCP';
@@ -15,11 +15,12 @@ use POE::Filter::VNDBAPI 'encode_filters';
use Encode 'encode_utf8', 'decode_utf8';
use Crypt::URandom 'urandom';
use Crypt::ScryptKDF 'scrypt_raw';;
-use VNDBUtil 'normalize_query', 'norm_ip';
+use VNDB::Func 'imgurl', 'imgsize', 'norm_ip', 'resolution', 'is_insecurepass';
use VNDB::Types;
use VNDB::Config;
use JSON::XS;
-use PWLookup;
+use List::Util 'min', 'max';
+use VNDB::ExtLinks 'sql_extlinks';
# Linux-specific, not exported by the Socket module.
sub TCP_KEEPIDLE () { 4 }
@@ -146,7 +147,8 @@ sub cres {
writelog $c, '[%2d/%4.0fms %5.0f] %s',
$c->{sqlq}, $c->{sqlt}*1000, length($msg),
@arg ? sprintf $log, @arg : $log;
- cmd_read($c);
+ if($c->{disconnect}) { $c->{h}->push_shutdown() }
+ else { cmd_read($c); }
}
@@ -229,6 +231,16 @@ sub cmd_handle {
return login($c, @arg) if $cmd eq 'login';
return cerr $c, needlogin => 'Not logged in.' if !$c->{client};
+ # logout
+ if($cmd eq 'logout') {
+ return cerr $c, parse => 'Too many arguments to logout command' if @arg > 0;
+ return cerr $c, needlogin => 'No session token associated with this connection' if !$c->{sessiontoken};
+ return pg_cmd 'SELECT user_logout($1, decode($2, \'hex\'))', [ $c->{uid}, $c->{sessiontoken} ], sub {
+ $c->{disconnect} = 1;
+ cres $c, ['ok'], 'Logged out, session invalidated';
+ }
+ }
+
# dbstats
if($cmd eq 'dbstats') {
return cerr $c, parse => 'Too many arguments to dbstats command' if @arg > 0;
@@ -260,42 +272,64 @@ sub login {
!exists $arg->{$_} && return cerr $c, missing => "Required field '$_' is missing", field => $_
for(qw|protocol client clientver|);
- for(qw|protocol client clientver username password|) {
+ for(qw|protocol client clientver username password sessiontoken|) {
exists $arg->{$_} && !defined $arg->{$_} && return cerr $c, badarg => "Field '$_' cannot be null", field => $_;
exists $arg->{$_} && ref $arg->{$_} && return cerr $c, badarg => "Field '$_' must be a scalar", field => $_;
}
return cerr $c, badarg => 'Unknown protocol version', field => 'protocol' if $arg->{protocol} ne '1';
- return cerr $c, badarg => 'The fields "username" and "password" must either both be present or both be missing.', field => 'username'
- if exists $arg->{username} && !exists $arg->{password} || exists $arg->{password} && !exists $arg->{username};
return cerr $c, badarg => 'Invalid client name', field => 'client' if $arg->{client} !~ /^[a-zA-Z0-9 _-]{3,50}$/;
return cerr $c, badarg => 'Invalid client version', field => 'clientver' if $arg->{clientver} !~ /^[a-zA-Z0-9_.\/-]{1,25}$/;
+ return cerr $c, badarg => '"createsession" can only be used when logging in with a password.' if !exists $arg->{password} && exists $arg->{createsession};
+ return cerr $c, badarg => 'Missing "username" field.', field => 'username' if !exists $arg->{username} && (exists $arg->{password} || exists $arg->{sessiontoken});
+
if(!exists $arg->{username}) {
$c->{client} = $arg->{client};
$c->{clientver} = $arg->{clientver};
cres $c, ['ok'], 'Login using client "%s" ver. %s', $c->{client}, $c->{clientver};
- return;
- } else {
- $arg->{username} = lc $arg->{username};
+
+ } elsif(exists $arg->{password}) {
return cerr $c, auth => "Password too weak, please log in on the site and change your password"
- if config->{password_db} && PWLookup::lookup(config->{password_db}, $arg->{password});
- }
+ if is_insecurepass($arg->{password});
+ login_auth($c, $arg);
+
+ } elsif(exists $arg->{sessiontoken}) {
+ return cerr $c, badarg => 'Invalid session token', field => 'sessiontoken' if $arg->{sessiontoken} !~ /^[a-fA-F0-9]{40}$/;
+ cpg $c,
+ 'SELECT u.id, u.username FROM users u JOIN users_shadow us ON us.id = u.id
+ WHERE lower(u.username) = lower($1) AND us.delete_at IS NULL AND user_validate_session(u.id, decode($2, \'hex\'), \'api\') IS DISTINCT FROM NULL',
+ [ $arg->{username}, $arg->{sessiontoken} ], sub {
+ if($_[0]->nRows == 1) {
+ $c->{uid} = $_[0]->value(0,0);
+ $c->{username} = $_[0]->value(0,1);
+ $c->{client} = $arg->{client};
+ $c->{clientver} = $arg->{clientver};
+ $c->{sessiontoken} = $arg->{sessiontoken};
+ cres $c, ['ok'], 'Successful login with session by %s (%s) using client "%s" ver. %s', $c->{username}, $c->{uid}, $c->{client}, $c->{clientver};
+ } else {
+ cerr $c, auth => "Wrong session token for user '$arg->{username}'";
+ }
+ };
- login_auth($c, $arg);
+ } else {
+ return cerr $c, badarg => 'Missing "password" or "sessiontoken" field.';
+ }
}
sub login_auth {
my($c, $arg) = @_;
- # check login throttle
+ # check login throttle (also used when logging in with a session... oh well)
cpg $c, 'SELECT extract(\'epoch\' from timeout) FROM login_throttle WHERE ip = $1', [ norm_ip($c->{ip}) ], sub {
my $tm = $_[0]->nRows ? $_[0]->value(0,0) : AE::time;
return cerr $c, auth => "Too many failed login attempts"
if $tm-AE::time() > config->{login_throttle}[1];
# Fetch user info
- cpg $c, 'SELECT id, encode(user_getscryptargs(id), \'hex\') FROM users WHERE username = $1', [ $arg->{username} ], sub {
+ cpg $c, '
+ SELECT u.id, u.username, encode(user_getscryptargs(u.id), \'hex\') FROM users u JOIN users_shadow us ON us.id = u.id
+ WHERE us.delete_at IS NULL AND lower(u.username) = lower($1)', [ $arg->{username} ], sub {
login_verify($c, $arg, $tm, $_[0]);
};
};
@@ -307,26 +341,32 @@ sub login_verify {
return cerr $c, auth => "No user with the name '$arg->{username}'" if $res->nRows == 0;
my $uid = $res->value(0,0);
- my $sargs = $res->value(0,1);
+ my $username = $res->value(0,1);
+ my $sargs = $res->value(0,2);
return cerr $c, auth => "Account disabled" if !$sargs || length($sargs) != 14*2;
- my $token = urandom(20);
+ my $token = unpack 'H*', urandom(20);
my($N, $r, $p, $salt) = unpack 'NCCa8', pack 'H*', $sargs;
my $passwd = pack 'NCCa8a*', $N, $r, $p, $salt, scrypt_raw(encode_utf8($arg->{password}), config->{scrypt_salt} . $salt, $N, $r, $p, 32);
- cpg $c, 'SELECT user_login($1, decode($2, \'hex\'), decode($3, \'hex\'))', [ $uid, unpack('H*', $passwd), unpack('H*', $token) ], sub {
+ cpg $c, 'SELECT user_login($1, \'api\', decode($2, \'hex\'), decode($3, \'hex\'))', [ $uid, unpack('H*', $passwd), $token ], sub {
if($_[0]->nRows == 1 && ($_[0]->value(0,0)||'') =~ /t/) {
$c->{uid} = $uid;
- $c->{username} = $arg->{username};
+ $c->{username} = $username;
$c->{client} = $arg->{client};
$c->{clientver} = $arg->{clientver};
- pg_cmd 'SELECT user_logout($1, decode($2, \'hex\'))', [ $uid, unpack('H*', $token) ];
- cres $c, ['ok'], 'Successful login by %s (%s) using client "%s" ver. %s', $arg->{username}, $c->{uid}, $c->{client}, $c->{clientver};
+ if($arg->{createsession}) {
+ $c->{sessiontoken} = $token;
+ cres $c, ['session', $token], 'Successful login with password+session by %s (%s) using client "%s" ver. %s', $username, $c->{uid}, $c->{client}, $c->{clientver};
+ } else {
+ pg_cmd 'SELECT user_logout($1, decode($2, \'hex\'))', [ $uid, $token ];
+ cres $c, ['ok'], 'Successful login with password by %s (%s) using client "%s" ver. %s', $username, $c->{uid}, $c->{client}, $c->{clientver};
+ }
} else {
my @a = ( $tm + config->{login_throttle}[0], norm_ip($c->{ip}) );
pg_cmd 'UPDATE login_throttle SET timeout = to_timestamp($1) WHERE ip = $2', \@a;
pg_cmd 'INSERT INTO login_throttle (ip, timeout) SELECT $2, to_timestamp($1) WHERE NOT EXISTS(SELECT 1 FROM login_throttle WHERE ip = $2)', \@a;
- cerr $c, auth => "Wrong password for user '$arg->{username}'";
+ cerr $c, auth => "Wrong password for user '$username'";
}
};
}
@@ -337,8 +377,7 @@ sub dbstats {
cpg $c, 'SELECT section, count FROM stats_cache', undef, sub {
my $res = shift;
- cres $c, [ dbstats => { map {
- $_->{section} =~ s/^threads_//;
+ cres $c, [ dbstats => { users => 0, threads => 0, posts => 0, map {
($_->{section}, 1*$_->{count})
} $res->rowsAsHashes } ], 'dbstats';
};
@@ -363,6 +402,8 @@ sub parsedate {
sub formatwd { $_[0] ? "Q$_[0]" : undef }
+sub idnum { defined $_[0] ? 1*($_[0] =~ s/^[a-z]+//r) : undef }
+
sub splitarray {
(my $s = shift) =~ s/^{(.*)}$/$1/;
@@ -370,6 +411,23 @@ sub splitarray {
}
+# Returns an image flagging structure or undef if $image is false.
+# Assumes $obj has c_votecount, c_sexual_avg and c_violence_avg.
+# Those fields are removed from $obj.
+sub image_flagging {
+ my($image, $obj) = @_;
+ my $flag = {
+ votecount => delete $obj->{c_votecount},
+ sexual_avg => delete $obj->{c_sexual_avg},
+ violence_avg => delete $obj->{c_violence_avg},
+ };
+ $flag->{votecount} *= 1 if defined $flag->{votecount};
+ $flag->{sexual_avg} /= 100 if defined $flag->{sexual_avg};
+ $flag->{violence_avg} /= 100 if defined $flag->{violence_avg};
+ $image ? $flag : undef;
+}
+
+
# sql => str: Main sql query, three printf args: select, where part, order by and limit clauses
# sqluser => str: Alternative to 'sql' if the user is logged in. One additional printf arg: user id.
# If sql is undef and sqluser isn't, the command is only available to logged in users.
@@ -391,63 +449,82 @@ sub splitarray {
# }
# filters => filters args for get_filters() (TODO: Document)
my %GET_VN = (
- sql => 'SELECT %s FROM vn v WHERE NOT v.hidden AND (%s) %s',
+ sql => 'SELECT %s FROM vnt v LEFT JOIN images i ON i.id = v.image WHERE NOT v.hidden AND (%s) %s',
select => 'v.id',
proc => sub {
- $_[0]{id} *= 1
+ $_[0]{id} = idnum $_[0]{id};
},
sortdef => 'id',
sorts => {
id => 'v.id %s',
- title => 'v.title %s',
- released => 'v.c_released %s',
- popularity => 'v.c_popularity %s NULLS LAST',
- rating => 'v.c_rating %s NULLS LAST',
- votecount => 'v.c_votecount %s',
+ title => 'v.sorttitle %s, v.id',
+ released => 'v.c_released %s, v.id',
+ popularity => '-v.c_pop_rank %s NULLS LAST, v.id',
+ rating => '-v.c_rat_rank %s NULLS LAST, v.id',
+ votecount => 'v.c_votecount %s, v.id',
},
flags => {
basic => {
- select => 'v.title, v.original, v.c_released, v.c_languages, v.c_olang, v.c_platforms',
+ select => 'v.title[2], v.title[4] AS original, v.c_released, v.c_languages, v.olang, v.c_platforms',
proc => sub {
$_[0]{original} ||= undef;
$_[0]{platforms} = splitarray delete $_[0]{c_platforms};
$_[0]{languages} = splitarray delete $_[0]{c_languages};
- $_[0]{orig_lang} = splitarray delete $_[0]{c_olang};
+ $_[0]{orig_lang} = [ delete $_[0]{olang} ];
$_[0]{released} = formatdate delete $_[0]{c_released};
},
},
details => {
- select => 'v.image, v.img_nsfw, v.alias AS aliases, v.length, v.desc AS description, v.l_wp, v.l_encubed, v.l_renai, l_wikidata',
+ select => 'v.image, i.c_sexual_avg, i.c_violence_avg, i.c_votecount, i.width AS image_width, i.height AS image_height, v.alias AS aliases,
+ v.length, v.c_length AS length_minutes, v.c_lengthnum AS length_votes, v.description, v.l_wp, v.l_encubed, v.l_renai, l_wikidata',
proc => sub {
$_[0]{aliases} ||= undef;
$_[0]{length} *= 1;
$_[0]{length} ||= undef;
+ $_[0]{length_votes}*= 1;
+ $_[0]{length_minutes}*=1 if defined $_[0]{length_minutes};
$_[0]{description} ||= undef;
- $_[0]{image_nsfw} = delete($_[0]{img_nsfw}) =~ /t/ ? TRUE : FALSE;
$_[0]{links} = {
wikipedia => delete($_[0]{l_wp}) ||undef,
encubed => delete($_[0]{l_encubed})||undef,
renai => delete($_[0]{l_renai}) ||undef,
wikidata => formatwd(delete $_[0]{l_wikidata}),
};
- $_[0]{image} = $_[0]{image} ? sprintf '%s/cv/%02d/%d.jpg', config->{url_static}, $_[0]{image}%100, $_[0]{image} : undef;
+ $_[0]{image} = $_[0]{image} ? imgurl $_[0]{image} : undef;
+ $_[0]{image_nsfw} = !$_[0]{image} ? FALSE : !$_[0]{c_votecount} || $_[0]{c_sexual_avg} > 40 || $_[0]{c_violence_avg} > 40 ? TRUE : FALSE;
+ $_[0]{image_flagging} = image_flagging $_[0]{image}, $_[0];
+ $_[0]{image_width} *= 1 if defined $_[0]{image_width};
+ $_[0]{image_height} *= 1 if defined $_[0]{image_height};
},
},
stats => {
- select => 'v.c_popularity, v.c_rating, v.c_votecount',
+ select => 'v.c_rating, v.c_votecount as votecount',
proc => sub {
- $_[0]{popularity} = 1 * sprintf '%.2f', 100*(delete $_[0]{c_popularity} or 0);
- $_[0]{rating} = 1 * sprintf '%.2f', 0.1*(delete $_[0]{c_rating} or 0);
- $_[0]{votecount} = 1 * delete $_[0]{c_votecount};
+ $_[0]{popularity} = 1 * sprintf '%.2f', min(100, ($_[0]{votecount} or 0)/150);
+ $_[0]{rating} = 1 * sprintf '%.2f', (delete $_[0]{c_rating} or 0)/100;
+ $_[0]{votecount} *= 1;
},
},
+ titles => {
+ fetch => [[ 'id', 'SELECT id, lang, title, latin, official FROM vn_titles WHERE id IN(%s)',
+ sub { my($r, $n) = @_;
+ for my $i (@$r) {
+ $i->{titles} = [ grep $i->{id} eq $_->{id}, @$n ];
+ }
+ for (@$n) {
+ delete $_->{id};
+ $_->{official} = $_->{official} =~ /t/ ? TRUE : FALSE,
+ }
+ }
+ ]],
+ },
anime => {
fetch => [[ 'id', 'SELECT va.id AS vid, a.id, a.year, a.ann_id, a.nfo_id, a.type, a.title_romaji, a.title_kanji
FROM anime a JOIN vn_anime va ON va.aid = a.id WHERE va.id IN(%s)',
sub { my($r, $n) = @_;
# link
for my $i (@$r) {
- $i->{anime} = [ grep $i->{id} == $_->{vid}, @$n ];
+ $i->{anime} = [ grep $i->{id} eq $_->{vid}, @$n ];
}
# cleanup
for (@$n) {
@@ -460,14 +537,14 @@ my %GET_VN = (
]],
},
relations => {
- fetch => [[ 'id', 'SELECT vr.id AS vid, v.id, vr.relation, v.title, v.original, vr.official FROM vn_relations vr
- JOIN vn v ON v.id = vr.vid WHERE vr.id IN(%s)',
+ fetch => [[ 'id', 'SELECT vr.id AS vid, v.id, vr.relation, v.title[2], v.title[4] AS original, vr.official FROM vn_relations vr
+ JOIN vnt v ON v.id = vr.vid WHERE vr.id IN(%s)',
sub { my($r, $n) = @_;
for my $i (@$r) {
- $i->{relations} = [ grep $i->{id} == $_->{vid}, @$n ];
+ $i->{relations} = [ grep $i->{id} eq $_->{vid}, @$n ];
}
for (@$n) {
- $_->{id} *= 1;
+ $_->{id} = idnum $_->{id};
$_->{original} ||= undef;
$_->{official} = $_->{official} =~ /t/ ? TRUE : FALSE,
delete $_->{vid};
@@ -483,42 +560,45 @@ my %GET_VN = (
sub { my($r, $n) = @_;
for my $i (@$r) {
$i->{tags} = [ map
- [ $_->{id}*1, 1*sprintf('%.2f', $_->{score}), 1*sprintf('%.0f', $_->{spoiler}) ],
- grep $i->{id} == $_->{vid}, @$n ];
+ [ idnum($_->{id}), 1*sprintf('%.2f', $_->{score}), 1*sprintf('%.0f', $_->{spoiler}) ],
+ grep $i->{id} eq $_->{vid}, @$n ];
}
},
]],
},
screens => {
- fetch => [[ 'id', 'SELECT vs.id AS vid, vs.scr AS image, vs.rid, vs.nsfw, s.width, s.height
- FROM vn_screenshots vs JOIN screenshots s ON s.id = vs.scr WHERE vs.id IN(%s)',
+ fetch => [[ 'id', 'SELECT vs.id AS vid, vs.scr, vs.rid, s.width, s.height, s.c_sexual_avg, s.c_violence_avg, s.c_votecount
+ FROM vn_screenshots vs JOIN images s ON s.id = vs.scr WHERE vs.id IN(%s)',
sub { my($r, $n) = @_;
for my $i (@$r) {
- $i->{screens} = [ grep $i->{id} == $_->{vid}, @$n ];
+ $i->{screens} = [ grep $i->{id} eq $_->{vid}, @$n ];
}
for (@$n) {
- $_->{image} = sprintf '%s/sf/%02d/%d.jpg', config->{url_static}, $_->{image}%100, $_->{image};
- $_->{rid} *= 1;
- $_->{nsfw} = $_->{nsfw} =~ /t/ ? TRUE : FALSE;
+ $_->{id} = $_->{scr};
+ $_->{thumbnail} = imgurl($_->{scr}, 't');
+ $_->{image} = imgurl delete $_->{scr};
+ $_->{rid} = idnum $_->{rid};
+ $_->{nsfw} = !$_->{c_votecount} || $_->{c_sexual_avg} > 40 || $_->{c_violence_avg} > 40 ? TRUE : FALSE;
$_->{width} *= 1;
$_->{height} *= 1;
+ ($_->{thumbnail_width}, $_->{thumbnail_height}) = imgsize $_->{width}, $_->{height}, config->{scr_size}->@*;
+ $_->{flagging} = image_flagging(1, $_);
delete $_->{vid};
}
},
]]
},
staff => {
- fetch => [[ 'id', 'SELECT vs.id, vs.aid, vs.role, vs.note, sa.id AS sid, sa.name, sa.original
- FROM vn_staff vs JOIN staff_alias sa ON sa.aid = vs.aid JOIN staff s ON s.id = sa.id
- WHERE vs.id IN(%s) AND NOT s.hidden',
+ fetch => [[ 'id', 'SELECT vs.id, vs.aid, vs.role, vs.note, s.id AS sid, s.title[2] AS name, s.title[4] AS original
+ FROM vn_staff vs JOIN staff_aliast s ON s.aid = vs.aid WHERE vs.id IN(%s) AND NOT s.hidden',
sub { my($r, $n) = @_;
for my $i (@$r) {
- $i->{staff} = [ grep $i->{id} == $_->{id}, @$n ];
+ $i->{staff} = [ grep $i->{id} eq $_->{id}, @$n ];
}
for (@$n) {
$_->{aid} *= 1;
- $_->{sid} *= 1;
- $_->{original} ||= undef;
+ $_->{sid} = idnum $_->{sid};
+ $_->{original} = undef if $_->{original} eq $_->{name};
$_->{note} ||= undef;
delete $_->{id};
}
@@ -528,21 +608,21 @@ my %GET_VN = (
},
filters => {
id => [
- [ 'int' => 'v.id :op: :value:', {qw|= = != <> > > < < <= <= >= >=|}, range => [1,1e6] ],
- [ inta => 'v.id :op:(:value:)', {'=' => 'IN', '!= ' => 'NOT IN'}, range => [1,1e6], join => ',' ],
+ [ 'int' => 'v.id :op: :value:', {qw|= = != <> > > < < <= <= >= >=|}, process => \'v' ],
+ [ inta => 'v.id :op:(:value:)', {'=' => 'IN', '!= ' => 'NOT IN'}, process => \'v', join => ',' ],
],
title => [
- [ str => 'v.title :op: :value:', {qw|= = != <>|} ],
- [ str => 'v.title ILIKE :value:', {'~',1}, process => \'like' ],
+ [ str => 'v.sorttitle :op: :value:', {qw|= = != <>|} ],
+ [ str => 'v.sorttitle ILIKE :value:', {'~',1}, process => \'like' ],
],
original => [
- [ undef, "v.original :op: ''", {qw|= = != <>|} ],
- [ str => 'v.original :op: :value:', {qw|= = != <>|} ],
- [ str => 'v.original ILIKE :value:', {'~',1}, process => \'like' ]
+ [ undef, "v.title[4] :op: ''", {qw|= = != <>|} ],
+ [ str => 'v.title[4] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'v.title[4] ILIKE :value:', {'~',1}, process => \'like' ]
],
firstchar => [
- [ undef, '(:op: ((ASCII(v.title) < 97 OR ASCII(v.title) > 122) AND (ASCII(v.title) < 65 OR ASCII(v.title) > 90)))', {'=', '', '!=', 'NOT'} ],
- [ str => 'LOWER(SUBSTR(v.title, 1, 1)) :op: :value:' => {qw|= = != <>|}, process => sub { shift =~ /^([a-z])$/ ? $1 : \'Invalid character' } ],
+ [ undef, ':op: match_firstchar(v.sorttitle, \'0\')', {'=', '', '!=', 'NOT'} ],
+ [ str => ':op: match_firstchar(v.sorttitle, :value:)', {'=', '', '!=', 'NOT'}, process => sub { shift =~ /^([a-z])$/ ? $1 : \'Invalid character' } ],
],
released => [
[ undef, 'v.c_released :op: 0', {qw|= = != <>|} ],
@@ -559,59 +639,64 @@ my %GET_VN = (
[ stra => ':op: (v.c_languages && ARRAY[:value:]::language[])', {'=' => '', '!=' => 'NOT'}, join => ',', process => \'lang' ],
],
orig_lang => [
- [ str => ':op: (v.c_olang && ARRAY[:value:]::language[])', {'=' => '', '!=' => 'NOT'}, process => \'lang' ],
- [ stra => ':op: (v.c_olang && ARRAY[:value:]::language[])', {'=' => '', '!=' => 'NOT'}, join => ',', process => \'lang' ],
+ [ str => 'v.olang :op: :value:', {qw|= = != <>|}, process => \'lang' ],
+ [ stra => 'v.olang :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'lang' ],
],
search => [
- [ str => '(:value:)', {'~',1}, split => \&normalize_query,
- join => ' AND ', serialize => 'v.c_search LIKE :value:', process => \'like' ],
+ [ str => 'EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = v.id AND sc.label LIKE ALL (search_query(:value:)))', {'~',1} ],
],
tags => [
- [ int => 'v.id :op:(SELECT vid FROM tags_vn_inherit WHERE tag = :value:)', {'=' => 'IN', '!=' => 'NOT IN'}, range => [1,1e6] ],
- [ inta => 'v.id :op:(SELECT vid FROM tags_vn_inherit WHERE tag IN(:value:))', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', range => [1,1e6] ],
+ [ int => 'v.id :op:(SELECT vid FROM tags_vn_inherit WHERE tag = :value:)', {'=' => 'IN', '!=' => 'NOT IN'}, process => \'g' ],
+ [ inta => 'v.id :op:(SELECT vid FROM tags_vn_inherit WHERE tag IN(:value:))', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'g' ],
],
},
);
my %GET_RELEASE = (
- sql => 'SELECT %s FROM releases r WHERE NOT hidden AND (%s) %s',
+ sql => 'SELECT %s FROM releasest r WHERE NOT hidden AND (%s) %s',
select => 'r.id',
sortdef => 'id',
sorts => {
id => 'r.id %s',
- title => 'r.title %s',
- released => 'r.released %s',
+ title => 'r.sorttitle %s, r.id',
+ released => 'r.released %s, r.id',
},
proc => sub {
- $_[0]{id} *= 1
+ $_[0]{id} = idnum $_[0]{id};
},
flags => {
basic => {
- select => 'r.title, r.original, r.released, r.type, r.patch, r.freeware, r.doujin',
+ select => 'r.title[2], r.title[4] AS original, r.released, r.patch, r.freeware, r.doujin, r.official',
proc => sub {
$_[0]{original} ||= undef;
$_[0]{released} = formatdate($_[0]{released});
$_[0]{patch} = $_[0]{patch} =~ /^t/ ? TRUE : FALSE;
$_[0]{freeware} = $_[0]{freeware} =~ /^t/ ? TRUE : FALSE;
$_[0]{doujin} = $_[0]{doujin} =~ /^t/ ? TRUE : FALSE;
+ $_[0]{official} = $_[0]{official} =~ /^t/ ? TRUE : FALSE;
},
- fetch => [[ 'id', 'SELECT id, lang FROM releases_lang WHERE id IN(%s)',
+ fetch => [[ 'id', 'SELECT id, lang FROM releases_titles WHERE id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{languages} = [ map $i->{id} == $_->{id} ? $_->{lang} : (), @$r ];
+ $i->{languages} = [ map $i->{id} eq $_->{id} ? $_->{lang} : (), @$r ];
}
},
+ ], ['id', 'SELECT id, MAX(rtype) AS type FROM releases_vn WHERE id IN(%s) GROUP BY id',
+ sub { my($n, $r) = @_;
+ my %t = map +($_->{id},$_->{type}), @$r;
+ $_->{type} = $t{$_->{id}} for @$n;
+ },
]],
},
details => {
- select => 'r.website, r.notes, r.minage, r.gtin, r.catalog, r.resolution, r.voiced, r.ani_story, r.ani_ero',
+ select => 'r.website, r.notes, r.minage, r.gtin, r.catalog, r.reso_x, r.reso_y, r.voiced, r.ani_story, r.ani_ero',
proc => sub {
$_[0]{website} ||= undef;
$_[0]{notes} ||= undef;
- $_[0]{minage} = $_[0]{minage} < 0 ? undef : $_[0]{minage}*1;
+ $_[0]{minage} *= 1 if defined $_[0]{minage};
$_[0]{gtin} ||= undef;
$_[0]{catalog} ||= undef;
- $_[0]{resolution} = $_[0]{resolution} eq 'unknown' ? undef : $RESOLUTION{ $_[0]{resolution} }{txt};
+ $_[0]{resolution} = resolution $_[0];
$_[0]{voiced} = $_[0]{voiced} ? $_[0]{voiced}*1 : undef;
$_[0]{animation} = [
$_[0]{ani_story} ? $_[0]{ani_story}*1 : undef,
@@ -619,18 +704,20 @@ my %GET_RELEASE = (
];
delete($_[0]{ani_story});
delete($_[0]{ani_ero});
+ delete($_[0]{reso_x});
+ delete($_[0]{reso_y});
},
fetch => [
[ 'id', 'SELECT id, platform FROM releases_platforms WHERE id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{platforms} = [ map $i->{id} == $_->{id} ? $_->{platform} : (), @$r ];
+ $i->{platforms} = [ map $i->{id} eq $_->{id} ? $_->{platform} : (), @$r ];
}
} ],
[ 'id', 'SELECT id, medium, qty FROM releases_media WHERE id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{media} = [ grep $i->{id} == $_->{id}, @$r ];
+ $i->{media} = [ grep $i->{id} eq $_->{id}, @$r ];
}
for (@$r) {
delete $_->{id};
@@ -639,15 +726,30 @@ my %GET_RELEASE = (
} ],
]
},
+ lang => {
+ fetch => [[ 'id', 'SELECT rt.id, rt.lang, rt.title, rt.latin, rt.mtl, rt.lang = r.olang AS main
+ FROM releases_titles rt JOIN releases r ON r.id = rt.id WHERE rt.id IN(%s)',
+ sub { my($r, $n) = @_;
+ for my $i (@$r) {
+ $i->{lang} = [ grep $i->{id} eq $_->{id}, @$n ];
+ }
+ for (@$n) {
+ delete $_->{id};
+ $_->{mtl} = $_->{mtl} =~ /t/ ? TRUE : FALSE,
+ $_->{main} = $_->{main} =~ /t/ ? TRUE : FALSE,
+ }
+ }
+ ]],
+ },
vn => {
- fetch => [[ 'id', 'SELECT rv.id AS rid, v.id, v.title, v.original FROM releases_vn rv JOIN vn v ON v.id = rv.vid
+ fetch => [[ 'id', 'SELECT rv.id AS rid, rv.rtype, v.id, v.title[2], v.title[4] AS original FROM releases_vn rv JOIN vnt v ON v.id = rv.vid
WHERE NOT v.hidden AND rv.id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{vn} = [ grep $i->{id} == $_->{rid}, @$r ];
+ $i->{vn} = [ grep $i->{id} eq $_->{rid}, @$r ];
}
for (@$r) {
- $_->{id}*=1;
+ $_->{id} = idnum $_->{id};
$_->{original} ||= undef;
delete $_->{rid};
}
@@ -655,43 +757,58 @@ my %GET_RELEASE = (
]],
},
producers => {
- fetch => [[ 'id', 'SELECT rp.id AS rid, rp.developer, rp.publisher, p.id, p.type, p.name, p.original FROM releases_producers rp
- JOIN producers p ON p.id = rp.pid WHERE NOT p.hidden AND rp.id IN(%s)',
+ fetch => [[ 'id', 'SELECT rp.id AS rid, rp.developer, rp.publisher, p.id, p.type, p.title[2] AS name, p.title[4] AS original FROM releases_producers rp
+ JOIN producerst p ON p.id = rp.pid WHERE NOT p.hidden AND rp.id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{producers} = [ grep $i->{id} == $_->{rid}, @$r ];
+ $i->{producers} = [ grep $i->{id} eq $_->{rid}, @$r ];
}
for (@$r) {
- $_->{id}*=1;
- $_->{original} ||= undef;
+ $_->{id} = idnum $_->{id};
+ $_->{original} = undef if $_->{original} eq $_->{name};
$_->{developer} = $_->{developer} =~ /^t/ ? TRUE : FALSE;
$_->{publisher} = $_->{publisher} =~ /^t/ ? TRUE : FALSE;
delete $_->{rid};
}
}
]],
- }
+ },
+ links => {
+ select => sql_extlinks('r'),
+ proc => sub {
+ my($e) = @_;
+ $e->{links} = [];
+ for my $l (keys $VNDB::ExtLinks::LINKS{r}->%*) {
+ my $i = $VNDB::ExtLinks::LINKS{r}{$l};
+ my $v = $e->{$l};
+ push $e->{links}->@*,
+ map +{ label => $i->{label}, url => sprintf($i->{fmt}, $_) },
+ !$v || $v eq '{}' ? () : $v =~ /^{(.+)}$/ ? split /,/, $1 : ($v);
+ delete $e->{$l};
+ }
+ },
+ },
},
filters => {
id => [
- [ 'int' => 'r.id :op: :value:', {qw|= = != <> > > >= >= < < <= <=|}, range => [1,1e6] ],
- [ inta => 'r.id :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', range => [1,1e6] ],
+ [ 'int' => 'r.id :op: :value:', {qw|= = != <> > > >= >= < < <= <=|}, process => \'r' ],
+ [ inta => 'r.id :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'r' ],
],
vn => [
- [ 'int' => 'r.id :op:(SELECT rv.id FROM releases_vn rv WHERE rv.vid = :value:)', {'=' => 'IN', '!=' => 'NOT IN'}, range => [1,1e6] ],
- [ inta => 'r.id :op:(SELECT rv.id FROM releases_vn rv WHERE rv.vid IN(:value:))', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', range => [1,1e6] ],
+ [ 'int' => 'r.id :op:(SELECT rv.id FROM releases_vn rv WHERE rv.vid = :value:)', {'=' => 'IN', '!=' => 'NOT IN'}, process => \'v' ],
+ [ inta => 'r.id :op:(SELECT rv.id FROM releases_vn rv WHERE rv.vid IN(:value:))', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'v' ],
],
producer => [
- [ 'int' => 'r.id IN(SELECT rp.id FROM releases_producers rp WHERE rp.pid = :value:)', {'=',1}, range => [1,1e6] ],
+ [ 'int' => 'r.id IN(SELECT rp.id FROM releases_producers rp WHERE rp.pid = :value:)', {'=',1}, process => \'p' ],
],
title => [
- [ str => 'r.title :op: :value:', {qw|= = != <>|} ],
- [ str => 'r.title ILIKE :value:', {'~',1}, process => \'like' ],
+ [ str => 'r.sorttitle :op: :value:', {qw|= = != <>|} ],
+ [ str => 'r.sorttitle ILIKE :value:', {'~',1}, process => \'like' ],
],
original => [
- [ undef, "r.original :op: ''", {qw|= = != <>|} ],
- [ str => 'r.original :op: :value:', {qw|= = != <>|} ],
- [ str => 'r.original ILIKE :value:', {'~',1}, process => \'like' ]
+ [ undef, "r.title[4] :op: ''", {qw|= = != <>|} ],
+ [ str => 'r.title[4] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'r.title[4] ILIKE :value:', {'~',1}, process => \'like' ]
],
released => [
[ undef, 'r.released :op: 0', {qw|= = != <>|} ],
@@ -701,7 +818,7 @@ my %GET_RELEASE = (
freeware => [ [ bool => 'r.freeware = :value:', {'=',1} ] ],
doujin => [ [ bool => 'r.doujin = :value:', {'=',1} ] ],
type => [
- [ str => 'r.type :op: :value:', {qw|= = != <>|},
+ [ str => 'r.id :op:(SELECT rv.id FROM releases_vn rv WHERE rv.rtype = :value:)', {'=' => 'IN', '!=' => 'NOT IN'},
process => sub { !$RELEASE_TYPE{$_[0]} ? \'No such release type' : $_[0] } ],
],
gtin => [
@@ -711,8 +828,8 @@ my %GET_RELEASE = (
[ str => 'r.catalog :op: :value:', {qw|= = != <>|} ],
],
languages => [
- [ str => 'r.id :op:(SELECT rl.id FROM releases_lang rl WHERE rl.lang = :value:)', {'=' => 'IN', '!=' => 'NOT IN'}, process => \'lang' ],
- [ stra => 'r.id :op:(SELECT rl.id FROM releases_lang rl WHERE rl.lang IN(:value:))', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'lang' ],
+ [ str => 'r.id :op:(SELECT rl.id FROM releases_titles rl WHERE rl.lang = :value:)', {'=' => 'IN', '!=' => 'NOT IN'}, process => \'lang' ],
+ [ stra => 'r.id :op:(SELECT rl.id FROM releases_titles rl WHERE rl.lang IN(:value:))', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'lang' ],
],
platforms => [
[ str => 'r.id :op:(SELECT rp.id FROM releases_platforms rp WHERE rp.platform = :value:)', {'=' => 'IN', '!=' => 'NOT IN'}, process => \'plat' ],
@@ -722,25 +839,25 @@ my %GET_RELEASE = (
);
my %GET_PRODUCER = (
- sql => 'SELECT %s FROM producers p WHERE NOT p.hidden AND (%s) %s',
+ sql => 'SELECT %s FROM producerst p WHERE NOT p.hidden AND (%s) %s',
select => 'p.id',
proc => sub {
- $_[0]{id} *= 1
+ $_[0]{id} = idnum $_[0]{id}
},
sortdef => 'id',
sorts => {
id => 'p.id %s',
- name => 'p.name %s',
+ name => 'p.name %s, p.id',
},
flags => {
basic => {
- select => 'p.type, p.name, p.original, p.lang AS language',
+ select => 'p.type, p.title[2] AS name, p.title[4] AS original, p.lang AS language',
proc => sub {
- $_[0]{original} ||= undef;
+ $_[0]{original} = undef if $_[0]{name} eq $_[0]{original};
},
},
details => {
- select => 'p.website, p.l_wp, p.l_wikidata, p.desc AS description, p.alias AS aliases',
+ select => 'p.website, p.l_wp, p.l_wikidata, p.description, p.alias AS aliases',
proc => sub {
$_[0]{description} ||= undef;
$_[0]{aliases} ||= undef;
@@ -752,15 +869,15 @@ my %GET_PRODUCER = (
},
},
relations => {
- fetch => [[ 'id', 'SELECT pl.id AS pid, p.id, pl.relation, p.name, p.original FROM producers_relations pl
- JOIN producers p ON p.id = pl.pid WHERE pl.id IN(%s)',
+ fetch => [[ 'id', 'SELECT pl.id AS pid, p.id, pl.relation, p.title[2] AS name, p.title[4] AS original FROM producers_relations pl
+ JOIN producerst p ON p.id = pl.pid WHERE pl.id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{relations} = [ grep $i->{id} == $_->{pid}, @$r ];
+ $i->{relations} = [ grep $i->{id} eq $_->{pid}, @$r ];
}
for (@$r) {
- $_->{id}*=1;
- $_->{original} ||= undef;
+ $_->{id} = idnum $_->{id};
+ $_->{original} = undef if $_->{name} eq $_->{original};
delete $_->{pid};
}
},
@@ -769,17 +886,17 @@ my %GET_PRODUCER = (
},
filters => {
id => [
- [ 'int' => 'p.id :op: :value:', {qw|= = != <> > > < < <= <= >= >=|}, range => [1,1e6] ],
- [ inta => 'p.id :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', range => [1,1e6] ],
+ [ 'int' => 'p.id :op: :value:', {qw|= = != <> > > < < <= <= >= >=|}, process => \'p' ],
+ [ inta => 'p.id :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'p' ],
],
name => [
- [ str => 'p.name :op: :value:', {qw|= = != <>|} ],
- [ str => 'p.name ILIKE :value:', {'~',1}, process => \'like' ],
+ [ str => 'p.title[2] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'p.title[2] ILIKE :value:', {'~',1}, process => \'like' ],
],
original => [
- [ undef, "p.original :op: ''", {qw|= = != <>|} ],
- [ str => 'p.original :op: :value:', {qw|= = != <>|} ],
- [ str => 'p.original ILIKE :value:', {'~',1}, process => \'like' ]
+ [ undef, "p.title[4] :op: ''", {qw|= = != <>|} ],
+ [ str => 'p.title[4] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'p.title[4] ILIKE :value:', {'~',1}, process => \'like' ]
],
type => [
[ str => 'p.type :op: :value:', {qw|= = != <>|},
@@ -790,51 +907,56 @@ my %GET_PRODUCER = (
[ stra => 'p.lang :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'lang' ],
],
search => [
- [ str => '(p.name ILIKE :value: OR p.original ILIKE :value: OR p.alias ILIKE :value:)', {'~',1}, process => \'like' ],
+ [ str => 'EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = p.id AND sc.label LIKE ALL (search_query(:value:)))', {'~',1} ],
],
},
);
my %GET_CHARACTER = (
- sql => 'SELECT %s FROM chars c WHERE NOT c.hidden AND (%s) %s',
+ sql => 'SELECT %s FROM charst c LEFT JOIN images i ON i.id = c.image WHERE NOT c.hidden AND (%s) %s',
select => 'c.id',
proc => sub {
- $_[0]{id} *= 1
+ $_[0]{id} = idnum $_[0]{id};
},
sortdef => 'id',
sorts => {
id => 'c.id %s',
- name => 'c.name %s',
+ name => 'c.name %s, c.id',
},
flags => {
basic => {
- select => 'c.name, c.original, c.gender, c.bloodt, c.b_day, c.b_month',
+ select => 'c.title[2] AS name, c.title[4] AS original, c.gender, c.spoil_gender, c.bloodt, c.b_day, c.b_month',
proc => sub {
- $_[0]{original} ||= undef;
+ $_[0]{original} = undef if $_[0]{original} eq $_[0]{name};
$_[0]{gender} = undef if $_[0]{gender} eq 'unknown';
$_[0]{bloodt} = undef if $_[0]{bloodt} eq 'unknown';
$_[0]{birthday} = [ delete($_[0]{b_day})*1||undef, delete($_[0]{b_month})*1||undef ];
},
},
details => {
- select => 'c.alias AS aliases, c.image, c."desc" AS description',
+ select => 'c.alias AS aliases, c.image, i.c_sexual_avg, i.c_violence_avg, i.c_votecount, i.width AS image_width, i.height AS image_height, c.description, c.age',
proc => sub {
$_[0]{aliases} ||= undef;
- $_[0]{image} = $_[0]{image} ? sprintf '%s/ch/%02d/%d.jpg', config->{url_static}, $_[0]{image}%100, $_[0]{image} : undef;
$_[0]{description} ||= undef;
+ $_[0]{image} = $_[0]{image} ? imgurl $_[0]{image} : undef;
+ $_[0]{image_flagging} = image_flagging $_[0]{image}, $_[0];
+ $_[0]{image_width} *=1 if defined $_[0]{image_width};
+ $_[0]{image_height} *=1 if defined $_[0]{image_height};
+ $_[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 => {
fetch => [[ 'id', 'SELECT id, tid, spoil FROM chars_traits WHERE id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{traits} = [ map [ $_->{tid}*1, $_->{spoil}*1 ], grep $i->{id} == $_->{id}, @$r ];
+ $i->{traits} = [ map [ idnum($_->{tid}), $_->{spoil}*1 ], grep $i->{id} eq $_->{id}, @$r ];
}
},
]],
@@ -843,7 +965,7 @@ my %GET_CHARACTER = (
fetch => [[ 'id', 'SELECT id, vid, rid, spoil, role FROM chars_vns WHERE id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{vns} = [ map [ $_->{vid}*1, ($_->{rid}||0)*1, $_->{spoil}*1, $_->{role} ], grep $i->{id} == $_->{id}, @$r ];
+ $i->{vns} = [ map [ idnum($_->{vid}), idnum($_->{rid}||0), $_->{spoil}*1, $_->{role} ], grep $i->{id} eq $_->{id}, @$r ];
}
},
]],
@@ -854,12 +976,12 @@ my %GET_CHARACTER = (
WHERE vs.cid IN(%s) AND NOT v.hidden AND NOT s.hidden',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{voiced} = [ grep $i->{id} == $_->{cid}, @$r ];
+ $i->{voiced} = [ grep $i->{id} eq $_->{cid}, @$r ];
}
for (@$r) {
- $_->{id}*=1;
+ $_->{id} = idnum $_->{id};
$_->{aid}*=1;
- $_->{vid}*=1;
+ $_->{vid} = idnum $_->{vid};
$_->{note} ||= undef;
delete $_->{cid};
}
@@ -867,15 +989,15 @@ my %GET_CHARACTER = (
]]
},
instances => {
- fetch => [[ 'id', 'SELECT c2.id AS cid, c.id, c.name, c.original, c2.main_spoil AS spoiler FROM chars c2 JOIN chars c ON c.id = c2.main OR c.main = c2.main WHERE c2.id IN(%s)
- UNION SELECT c.main AS cid, c.id, c.name, c.original, c.main_spoil AS spoiler FROM chars c WHERE c.main IN(%1$s)',
+ fetch => [[ 'id', 'SELECT c2.id AS cid, c.id, c.title[2] AS name, c.title[4] AS original, c2.main_spoil AS spoiler FROM chars c2 JOIN charst c ON c.id = c2.main OR c.main = c2.main WHERE c2.id IN(%s)
+ UNION SELECT c.main AS cid, c.id, c.title[2] AS name, c.title[4] AS original, c.main_spoil AS spoiler FROM charst c WHERE c.main IN(%1$s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{instances} = [ grep $i->{id} == $_->{cid} && $_->{id} != $i->{id}, @$r ];
+ $i->{instances} = [ grep $i->{id} eq $_->{cid} && $_->{id} ne $i->{id}, @$r ];
}
for (@$r) {
- $_->{id}*=1;
- $_->{original} ||= undef;
+ $_->{id} = idnum $_->{id};
+ $_->{original} = undef if $_->{original} eq $_->{name};
$_->{spoiler}*=1;
delete $_->{cid};
}
@@ -885,38 +1007,38 @@ my %GET_CHARACTER = (
},
filters => {
id => [
- [ 'int' => 'c.id :op: :value:', {qw|= = != <> > > < < <= <= >= >=|}, range => [1,1e6] ],
- [ inta => 'c.id :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, range => [1,1e6], join => ',' ],
+ [ 'int' => 'c.id :op: :value:', {qw|= = != <> > > < < <= <= >= >=|}, process => \'c' ],
+ [ inta => 'c.id :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, process => \'c', join => ',' ],
],
name => [
- [ str => 'c.name :op: :value:', {qw|= = != <>|} ],
- [ str => 'c.name ILIKE :value:', {'~',1}, process => \'like' ],
+ [ str => 'c.title[2] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'c.title[2] ILIKE :value:', {'~',1}, process => \'like' ],
],
original => [
- [ undef, "c.original :op: ''", {qw|= = != <>|} ],
- [ str => 'c.original :op: :value:', {qw|= = != <>|} ],
- [ str => 'c.original ILIKE :value:', {'~',1}, process => \'like' ]
+ [ undef, "c.title[4] :op: ''", {qw|= = != <>|} ],
+ [ str => 'c.title[4] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'c.title[4] ILIKE :value:', {'~',1}, process => \'like' ]
],
search => [
- [ str => '(c.name ILIKE :value: OR c.original ILIKE :value: OR c.alias ILIKE :value:)', {'~',1}, process => \'like' ],
+ [ str => 'EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = c.id AND sc.label LIKE ALL (search_query(:value:)))', {'~',1} ],
],
vn => [
- [ 'int' => 'c.id IN(SELECT cv.id FROM chars_vns cv WHERE cv.vid = :value:)', {'=',1}, range => [1,1e6] ],
- [ inta => 'c.id IN(SELECT cv.id FROM chars_vns cv WHERE cv.vid IN(:value:))', {'=',1}, range => [1,1e6], join => ',' ],
+ [ 'int' => 'c.id IN(SELECT cv.id FROM chars_vns cv WHERE cv.vid = :value:)', {'=',1}, process => \'v' ],
+ [ inta => 'c.id IN(SELECT cv.id FROM chars_vns cv WHERE cv.vid IN(:value:))', {'=',1}, process => \'v', join => ',' ],
],
traits => [
- [ int => 'c.id :op:(SELECT tc.cid FROM traits_chars tc WHERE tc.tid = :value:)', {'=' => 'IN', '!=' => 'NOT IN'}, range => [1,1e6] ],
- [ inta => 'c.id :op:(SELECT tc.cid FROM traits_chars tc WHERE tc.tid IN(:value:))', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', range => [1,1e6] ],
+ [ int => 'c.id :op:(SELECT tc.cid FROM traits_chars tc WHERE tc.tid = :value:)', {'=' => 'IN', '!=' => 'NOT IN'}, process => \'i' ],
+ [ inta => 'c.id :op:(SELECT tc.cid FROM traits_chars tc WHERE tc.tid IN(:value:))', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'i' ],
],
},
);
my %GET_STAFF = (
- sql => 'SELECT %s FROM staff s JOIN staff_alias sa ON sa.aid = s.aid WHERE NOT s.hidden AND (%s) %s',
+ sql => 'SELECT %s FROM staff_aliast s WHERE s.aid = s.main AND NOT s.hidden AND (%s) %s',
select => 's.id',
proc => sub {
- $_[0]{id} *= 1
+ $_[0]{id} = idnum $_[0]{id};
},
sortdef => 'id',
sorts => {
@@ -924,14 +1046,14 @@ my %GET_STAFF = (
},
flags => {
basic => {
- select => 'sa.name, sa.original, s.gender, s.lang AS language',
+ select => 's.title[2] AS name, s.title[4] AS original, s.gender, s.lang AS language',
proc => sub {
- $_[0]{original} ||= undef;
+ $_[0]{original} = undef if $_[0]{original} eq $_[0]{name};
$_[0]{gender} = undef if $_[0]{gender} eq 'unknown';
},
},
details => {
- select => 's."desc" AS description, s.l_wp, s.l_site, s.l_twitter, s.l_anidb, s.l_wikidata, s.l_pixiv',
+ select => 's.description, s.l_wp, s.l_site, s.l_twitter, s.l_anidb, s.l_wikidata, s.l_pixiv',
proc => sub {
$_[0]{description} ||= undef;
$_[0]{links} = {
@@ -949,10 +1071,10 @@ my %GET_STAFF = (
proc => sub {
$_[0]{main_alias} = delete($_[0]{aid})*1;
},
- fetch => [[ 'id', 'SELECT id, aid, name, original FROM staff_alias WHERE id IN(%s)',
+ fetch => [[ 'id', 'SELECT id, aid, title[2] AS name, title[4] AS original FROM staff_aliast WHERE id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{aliases} = [ map [ $_->{aid}*1, $_->{name}, $_->{original}||undef ], grep $i->{id} == $_->{id}, @$r ];
+ $i->{aliases} = [ map [ $_->{aid}*1, $_->{name}, $_->{original} eq $_->{name} ? undef : $_->{original} ], grep $i->{id} eq $_->{id}, @$r ];
}
},
]],
@@ -963,10 +1085,10 @@ my %GET_STAFF = (
WHERE sa.id IN(%s) AND NOT v.hidden',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{vns} = [ grep $i->{id} == $_->{sid}, @$r ];
+ $i->{vns} = [ grep $i->{id} eq $_->{sid}, @$r ];
}
for (@$r) {
- $_->{id}*=1;
+ $_->{id} = idnum $_->{id};
$_->{aid}*=1;
$_->{note} ||= undef;
delete $_->{sid};
@@ -980,12 +1102,12 @@ my %GET_STAFF = (
WHERE sa.id IN(%s) AND NOT v.hidden',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{voiced} = [ grep $i->{id} == $_->{sid}, @$r ];
+ $i->{voiced} = [ grep $i->{id} eq $_->{sid}, @$r ];
}
for (@$r) {
- $_->{id}*=1;
+ $_->{id} = idnum $_->{id};
$_->{aid}*=1;
- $_->{cid}*=1;
+ $_->{cid} = idnum $_->{cid};
$_->{note} ||= undef;
delete $_->{sid};
}
@@ -995,36 +1117,54 @@ my %GET_STAFF = (
},
filters => {
id => [
- [ 'int' => 's.id :op: :value:', {qw|= = != <> > > < < <= <= >= >=|}, range => [1,1e6] ],
- [ inta => 's.id :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, range => [1,1e6], join => ',' ],
+ [ 'int' => 's.id :op: :value:', {qw|= = != <> > > < < <= <= >= >=|}, process => \'s' ],
+ [ inta => 's.id :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'s' ],
],
aid => [
[ 'int' => 's.id IN(SELECT sa.id FROM staff_alias sa WHERE sa.aid = :value:)', {'=',1}, range => [1,1e6] ],
[ inta => 's.id IN(SELECT sa.id FROM staff_alias sa WHERE sa.aid IN(:value:))', {'=',1}, range => [1,1e6], join => ',' ],
],
search => [
- [ str => 's.id IN(SELECT sa.id FROM staff_alias sa WHERE sa.name ILIKE :value: OR sa.original ILIKE :value:)', {'~',1}, process => \'like' ],
+ [ str => 'EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = s.id AND sc.label LIKE ALL (search_query(:value:)))', {'~',1} ],
],
},
);
+my %GET_QUOTE = (
+ sql => "SELECT %s FROM quotes q JOIN vnt v ON v.id = q.vid WHERE q.rand IS NOT NULL AND NOT v.hidden AND (%s) %s",
+ select => "v.id, v.title[2], q.quote",
+ proc => sub {
+ $_[0]{id} = idnum $_[0]{id};
+ },
+ sortdef => 'random',
+ sorts => { id => 'q.vid %s', random => 'RANDOM() %s' },
+ flags => { basic => {} },
+ filters => {
+ id => [
+ [ 'int' => 'q.vid :op: :value:', {qw|= = != <> > > >= >= < < <= <=|}, process => \'v' ],
+ [ inta => 'q.vid :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'v' ],
+ ]
+ },
+);
+
+
# All user ID filters consider uid=0 to be the logged in user. Needs a special processing function to handle that.
-sub subst_user_id { my($id, $c) = @_; !$id && !$c->{uid} ? \'Not logged in.' : $id || $c->{uid} }
+sub subst_user_id { my($id, $c) = @_; $id && $id =~ /^[1-9][0-9]{0,6}$/ ? "u$id" : ($c->{uid} || \'Not logged in.') }
my %GET_USER = (
sql => "SELECT %s FROM users u WHERE (%s) %s",
select => "id, username",
proc => sub {
- $_[0]{id}*=1;
+ $_[0]{id} = idnum $_[0]{id};
},
sortdef => 'id',
sorts => { id => 'id %s' },
flags => { basic => {} },
filters => {
id => [
- [ 'int' => 'u.id :op: :value:', {qw|= =|}, range => [0,1e6], process => \&subst_user_id ],
- [ inta => 'u.id IN(:value:)', {'=',1}, range => [0,1e6], join => ',', process => \&subst_user_id ],
+ [ 'int' => 'u.id :op: :value:', {qw|= =|}, process => \&subst_user_id ],
+ [ inta => 'u.id IN(:value:)', {'=',1}, join => ',', process => \&subst_user_id ],
],
username => [
[ str => 'u.username :op: :value:', {qw|= = != <>|} ],
@@ -1036,25 +1176,23 @@ my %GET_USER = (
# the uid filter for votelist/vnlist/wishlist
-my $UID_FILTER = [ 'int' => 'uv.uid :op: :value:', {qw|= =|}, range => [0,1e6], process => \&subst_user_id ];
+my $UID_FILTER = [ 'int' => 'uv.uid :op: :value:', {qw|= =|}, process => \&subst_user_id ];
# Similarly, a filter for 'vid'
my $VN_FILTER = [
- [ 'int' => 'uv.vid :op: :value:', {qw|= = != <> > > < < <= <= >= >=|}, range => [1,1e6] ],
- [ inta => 'uv.vid :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, range => [1,1e6], join => ',' ],
+ [ 'int' => 'uv.vid :op: :value:', {qw|= = != <> > > < < <= <= >= >=|}, process => \'v' ],
+ [ inta => 'uv.vid :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, process => \'v', join => ',' ],
];
-my $UV_PUBLIC = 'EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private)';
-
my %GET_VOTELIST = (
islist => 1,
- sql => "SELECT %s FROM ulist_vns uv WHERE uv.vote IS NOT NULL AND (%s) AND $UV_PUBLIC %s",
- sqluser => "SELECT %1\$s FROM ulist_vns uv WHERE uv.vote IS NOT NULL AND (%2\$s) AND (uid = %4\$d OR $UV_PUBLIC) %3\$s",
- select => "uid, vid as vn, vote, extract('epoch' from vote_date) AS added",
+ sql => "SELECT %s FROM ulist_vns uv WHERE vote IS NOT NULL AND (%s ) AND NOT c_private %s",
+ sqluser => "SELECT %1\$s FROM ulist_vns uv WHERE vote IS NOT NULL AND (%2\$s) AND (uid = %4\$s OR NOT c_private) %3\$s",
+ select => "uid AS uid, vid as vn, vote, extract('epoch' from vote_date) AS added",
proc => sub {
- $_[0]{uid}*=1;
- $_[0]{vn}*=1;
+ $_[0]{uid} = idnum $_[0]{uid};
+ $_[0]{vn} = idnum $_[0]{vn};
$_[0]{vote}*=1;
$_[0]{added} = int $_[0]{added};
},
@@ -1064,44 +1202,40 @@ my %GET_VOTELIST = (
filters => { uid => [ $UID_FILTER ], vn => $VN_FILTER }
);
-my $SQL_VNLIST = 'FROM ulist_vns uv LEFT JOIN ulist_vns_labels uvl ON uvl.uid = uv.uid AND uvl.vid = uv.vid AND uvl.lbl IN(1,2,3,4)'
- .' WHERE (EXISTS(SELECT 1 FROM ulist_vns_labels uvl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND uvl.lbl IN(1,2,3,4))'
- .' OR NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid))';
+my $SQL_VNLIST = "FROM ulist_vns uv WHERE (labels IN('{}','{7}') OR labels && ARRAY[1,2,3,4]::smallint[])";
my %GET_VNLIST = (
islist => 1,
- sql => "SELECT %s $SQL_VNLIST AND (%s) AND $UV_PUBLIC GROUP BY uv.uid, uv.vid, uv.added, uv.notes %s",
- sqluser => "SELECT %1\$s $SQL_VNLIST AND (%2\$s) AND (uv.uid = %4\$d OR $UV_PUBLIC) GROUP BY uv.uid, uv.vid, uv.added, uv.notes %3\$s",
- select => "uv.uid, uv.vid as vn, MAX(uvl.lbl) AS status, extract('epoch' from uv.added) AS added, uv.notes",
+ sql => "SELECT %s $SQL_VNLIST AND (%s) AND NOT c_private %s",
+ sqluser => "SELECT %1\$s $SQL_VNLIST AND (%2\$s) AND (uid = %4\$s OR NOT c_private) %3\$s",
+ select => "uid AS uid, vid as vn, labels, extract('epoch' from added) AS added, notes",
proc => sub {
- $_[0]{uid}*=1;
- $_[0]{vn}*=1;
- $_[0]{status} = defined $_[0]{status} ? $_[0]{status}*1 : 0;
+ $_[0]{uid} = idnum $_[0]{uid};
+ $_[0]{vn} = idnum $_[0]{vn};
+ my @labels = delete($_[0]{labels}) =~ /^{(.+)}$/ ? split /,/, $1 : ();
+ $_[0]{status} = 1*(max(grep $_ <= 4, @labels) || 0);
$_[0]{added} = int $_[0]{added};
$_[0]{notes} ||= undef;
},
sortdef => 'vn',
- sorts => { vn => 'uv.vid %s' },
+ sorts => { vn => 'vid %s' },
flags => { basic => {} },
filters => { uid => [ $UID_FILTER ], vn => $VN_FILTER }
);
-my $SQL_WISHLIST = "FROM ulist_vns uv JOIN ulist_vns_labels uvl ON uvl.uid = uv.uid AND uvl.vid = uv.vid JOIN ulist_labels ul ON ul.uid = uv.uid AND ul.id = uvl.lbl"
- ." WHERE (uvl.lbl IN(5,6) OR ul.label IN('Wishlist-Low','Wishlist-Medium','Wishlist-High'))";
-
my %GET_WISHLIST = (
islist => 1,
- sql => "SELECT %s $SQL_WISHLIST AND (%s) AND NOT ul.private GROUP BY uv.uid, uv.vid, uv.added %s",
- sqluser => "SELECT %1\$s $SQL_WISHLIST AND (%2\$s) AND (uv.uid = %4\$d OR NOT ul.private) GROUP BY uv.uid, uv.vid, uv.added %3\$s",
- select => "uv.uid, uv.vid AS vn, MAX(ul.label) AS priority, extract('epoch' from uv.added) AS added",
+ sql => "SELECT %s FROM ulist_vns uv WHERE labels && ARRAY[5,6]::smallint[] AND (%s) AND NOT c_private %s",
+ sqluser => "SELECT %1\$s FROM ulist_vns uv WHERE labels && ARRAY[5,6]::smallint[] AND (%2\$s) AND (uid = %4\$s OR NOT c_private) %3\$s",
+ select => "uid AS uid, vid AS vn, CASE WHEN labels && ARRAY[6]::smallint[] THEN 3 ELSE 1 END AS priority, extract('epoch' from added) AS added",
proc => sub {
- $_[0]{uid}*=1;
- $_[0]{vn}*=1;
- $_[0]{priority} = {'Wishlist-High' => 0, 'Wishlist-Medium' => 1, 'Wishlist-Low' => 2, 'Blacklist' => 3}->{$_[0]{priority}}//1;
+ $_[0]{uid} = idnum $_[0]{uid};
+ $_[0]{vn} = idnum $_[0]{vn};
+ $_[0]{priority} *= 1;
$_[0]{added} = int $_[0]{added};
},
sortdef => 'vn',
- sorts => { vn => 'uv.vid %s' },
+ sorts => { vn => 'vid %s' },
flags => { basic => {} },
filters => { uid => [ $UID_FILTER ], vn => $VN_FILTER }
);
@@ -1109,11 +1243,11 @@ my %GET_WISHLIST = (
my %GET_ULIST_LABELS = (
islist => 1,
sql => 'SELECT %s FROM ulist_labels uv WHERE (%s) AND NOT uv.private %s',
- sqluser => 'SELECT %1$s FROM ulist_labels uv WHERE (%2$s) AND (uv.uid = %4$d OR NOT uv.private) %3$s',
- select => 'uid, id, label, private',
+ sqluser => 'SELECT %1$s FROM ulist_labels uv WHERE (%2$s) AND (uv.uid = %4$s OR NOT uv.private) %3$s',
+ select => 'uid AS uid, id, label, private',
proc => sub {
- $_[0]{uid}*=1;
- $_[0]{id}*=1;
+ $_[0]{uid} = idnum $_[0]{uid};
+ $_[0]{id} = idnum $_[0]{id};
$_[0]{private} = $_[0]{private} =~ /^t/ ? TRUE : FALSE;
},
sortdef => 'id',
@@ -1122,15 +1256,14 @@ my %GET_ULIST_LABELS = (
filters => { uid => [ $UID_FILTER ] },
);
-my $ULIST_PUBLIC = 'EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private)';
my %GET_ULIST = (
islist => 1,
- sql => "SELECT %s FROM ulist_vns uv WHERE (%s) AND ($ULIST_PUBLIC) %s",
- sqluser => "SELECT %1\$s FROM ulist_vns uv WHERE (%2\$s) AND (uv.uid = %4\$d OR $ULIST_PUBLIC) %3\$s",
- select => "uid, vid as vn, extract('epoch' from added) AS added, extract('epoch' from lastmod) AS lastmod, extract('epoch' from vote_date) AS voted, vote, started, finished, notes",
+ sql => "SELECT %s FROM ulist_vns uv WHERE (%s ) AND NOT c_private %s",
+ sqluser => "SELECT %1\$s FROM ulist_vns uv WHERE (%2\$s) AND (uid = %4\$s OR NOT uv.c_private) %3\$s",
+ select => "uid AS uid, vid as vn, extract('epoch' from added) AS added, extract('epoch' from lastmod) AS lastmod, extract('epoch' from vote_date) AS voted, vote, started, finished, notes",
proc => sub {
- $_[0]{uid}*=1;
- $_[0]{vn}*=1;
+ $_[0]{uid} = idnum $_[0]{uid};
+ $_[0]{vn} = idnum $_[0]{vn};
$_[0]{added} = int $_[0]{added};
$_[0]{lastmod} = int $_[0]{lastmod};
$_[0]{voted} = int $_[0]{voted} if $_[0]{voted};
@@ -1149,15 +1282,17 @@ my %GET_ULIST = (
flags => {
basic => {},
labels => {
- fetch => [[ ['uid','vn'], 'SELECT uvl.uid, uvl.vid, ul.id, ul.label
- FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl
- WHERE (uvl.uid,uvl.vid) IN(%s) AND (NOT ul.private OR uvl.uid = %s)',
+ fetch => [[ ['uid','vn'], 'SELECT uv.uid, uv.vid, ul.id, ul.label
+ FROM ulist_vns uv
+ JOIN unnest(uv.labels) l(id) ON true
+ JOIN ulist_labels ul ON ul.uid = uv.uid AND ul.id = l.id
+ WHERE (uv.uid,uv.vid) IN(%s) AND (NOT ul.private OR uv.uid = %s OR ul.id = 7)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{labels} = [ grep $i->{uid} == $_->{uid} && $i->{vn} == $_->{vid}, @$r ];
+ $i->{labels} = [ grep $i->{uid} eq $_->{uid} && $i->{vn} eq $_->{vid}, @$r ];
}
for (@$r) {
- $_->{id}*=1;
+ $_->{id} = idnum $_->{id};
delete $_->{uid};
delete $_->{vid};
}
@@ -1169,8 +1304,7 @@ my %GET_ULIST = (
uid => [ $UID_FILTER ],
vn => $VN_FILTER,
label => [
- [ 'int' => 'EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl
- WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND uvl.lbl = :value: AND NOT ul.private)', {'=',1}, range => [1,1e6] ],
+ [ 'int' => '(:value: = 7 OR EXISTS(SELECT 1 FROM ulist_labels ul WHERE ul.uid = uv.uid AND ul.id = :value: AND NOT ul.private)) AND labels && ARRAY[:value:]::smallint[]', {'=',1}, range => [1,32000] ],
],
},
);
@@ -1182,6 +1316,7 @@ my %GET = (
producer => \%GET_PRODUCER,
character => \%GET_CHARACTER,
staff => \%GET_STAFF,
+ quote => \%GET_QUOTE,
user => \%GET_USER,
votelist => \%GET_VOTELIST,
vnlist => \%GET_VNLIST,
@@ -1285,6 +1420,9 @@ sub get_filters {
return cerr $c, filter => 'Invalid language code', %e if !$LANGUAGE{$v};
} elsif(${$o{process}} eq 'plat') {
return cerr $c, filter => 'Invalid platform code', %e if !$PLATFORM{$v};
+ } elsif(length ${$o{process}} == 1) {
+ return cerr $c, filter => 'Invalid identifier', %e if $v !~ /^[1-9][0-9]{0,6}$/;
+ $v = ${$o{process}}.$v;
}
}
@@ -1344,7 +1482,7 @@ sub get_mainsql {
$sql = $type->{sqluser} if $c->{uid} && $type->{sqluser};
no if $] >= 5.022, warnings => 'redundant';
- cpg $c, sprintf($sql, $select, $where, $last, $c->{uid}), \@placeholders, sub {
+ cpg $c, sprintf($sql, $select, $where, $last, $c->{uid} ? "'$c->{uid}'" : 'NULL'), \@placeholders, sub {
my @res = $_[0]->rowsAsHashes;
$get->{more} = pop(@res)&&1 if @res > $get->{opt}{results};
$get->{list} = \@res;
@@ -1369,7 +1507,7 @@ sub get_fetch {
my @ids = map { my $d=$_; ref $field ? @{$d}{@$field} : ($d->{$field}) } @{$get->{list}};
my $ids = join ',', map { ref $field ? '('.join(',', map '$'.$ref++, @$field).')' : '$'.$ref++ } 1..@{$get->{list}};
no warnings 'redundant';
- cpg $c, sprintf($need{$n}[1], $ids, $c->{uid}||'NULL'), \@ids, sub {
+ cpg $c, sprintf($need{$n}[1], $ids, $c->{uid} ? "'$c->{uid}'" : 'NULL'), \@ids, sub {
$get->{fetched}{$n} = [ $need{$n}[2], [$_[0]->rowsAsHashes] ];
delete $need{$n};
get_final($c, $type, $get) if !keys %need;
@@ -1438,14 +1576,16 @@ sub setpg {
sub set_ulist_ret {
my($c, $obj) = @_;
- setpg $obj, 'SELECT update_users_ulist_stats($1)', [ $c->{uid} ]; # XXX: This can be deferred, to speed up batch updates over the same connection
+ cpg $obj->{c}, 'SELECT update_users_ulist_private($1, $2)', [ $c->{uid}, 'v'.$obj->{id} ], sub {
+ setpg $obj, 'SELECT update_users_ulist_stats($1)', [ $c->{uid} ];
+ };
}
sub set_votelist {
my($c, $obj) = @_;
- return cpg $c, 'UPDATE ulist_vns SET vote = NULL, vote_date = NULL WHERE uid = $1 AND vid = $2', [ $c->{uid}, $obj->{id} ], sub {
+ return cpg $c, 'UPDATE ulist_vns SET vote = NULL, vote_date = NULL WHERE uid = $1 AND vid = $2', [ $c->{uid}, 'v'.$obj->{id} ], sub {
set_ulist_ret $c, $obj
} if !$obj->{opt};
@@ -1454,7 +1594,7 @@ sub set_votelist {
return cerr $c, badarg => 'Invalid vote', field => 'vote' if ref($vv) || !defined($vv) || $vv !~ /^\d+$/ || $vv < 10 || $vv > 100;
cpg $c, 'INSERT INTO ulist_vns (uid, vid, vote, vote_date) VALUES ($1, $2, $3, NOW()) ON CONFLICT (uid, vid) DO UPDATE SET vote = $3, vote_date = NOW(), lastmod = NOW()',
- [ $c->{uid}, $obj->{id}, $vv ], sub { set_ulist_ret $c, $obj; }
+ [ $c->{uid}, 'v'.$obj->{id}, $vv ], sub { set_ulist_ret $c, $obj; }
}
@@ -1462,7 +1602,7 @@ sub set_vnlist {
my($c, $obj) = @_;
# Bug: Also removes from wishlist and votelist.
- return cpg $c, 'DELETE FROM ulist_vns WHERE uid = $1 AND vid = $2', [ $c->{uid}, $obj->{id} ], sub {
+ return cpg $c, 'DELETE FROM ulist_vns WHERE uid = $1 AND vid = $2', [ $c->{uid}, 'v'.$obj->{id} ], sub {
set_ulist_ret $c, $obj;
} if !$obj->{opt};
@@ -1474,33 +1614,24 @@ sub set_vnlist {
$vs ||= 0;
$vn ||= '';
- cpg $c, 'INSERT INTO ulist_vns (uid, vid, notes) VALUES ($1, $2, $3) ON CONFLICT (uid, vid) DO UPDATE SET lastmod = NOW()'.($en ? ', notes = $3' : ''),
- [ $c->{uid}, $obj->{id}, $vn ], sub {
- if($es) {
- cpg $c, 'DELETE FROM ulist_vns_labels WHERE uid = $1 AND vid = $2 AND lbl IN(1,2,3,4)', [ $c->{uid}, $obj->{id} ], sub {
- if($vs) {
- cpg $c, 'INSERT INTO ulist_vns_labels (uid, vid, lbl) VALUES($1, $2, $3)', [ $c->{uid}, $obj->{id}, $vs ], sub {
- set_ulist_ret $c, $obj;
- }
- } else {
- set_ulist_ret $c, $obj;
- }
- }
- } else {
- set_ulist_ret $c, $obj;
- }
- }
+ my $l = 'array_remove(array_remove(array_remove(array_remove(ulist_vns.labels, 1), 2), 3), 4)';
+ cpg $c, q{
+ INSERT INTO ulist_vns (uid, vid, notes, labels)
+ VALUES ($1, $2, $3, CASE WHEN $4 = 0 THEN '{}' ELSE ARRAY[$4]::smallint[] END)
+ ON CONFLICT (uid, vid) DO UPDATE SET lastmod = NOW()}
+ .($en ? ', notes = $3' : '')
+ .($es ? ', labels = CASE WHEN $4 = 0 THEN '.$l.' ELSE array_set('.$l.', $4) END' : ''),
+ [ $c->{uid}, 'v'.$obj->{id}, $vn, $vs ], sub { set_ulist_ret $c, $obj; };
}
sub set_wishlist {
my($c, $obj) = @_;
-
- my $sql_label = "(lbl IN(5,6) OR lbl IN(SELECT id FROM ulist_labels WHERE uid = \$1 AND label IN('Wishlist-Low','Wishlist-High','Wishlist-Medium')))";
+ my $l = 'array_remove(array_remove(ulist_vns.labels,5),6)';
# Bug: This will make it appear in the vnlist
- return cpg $c, "DELETE FROM ulist_vns_labels WHERE uid = \$1 AND vid = \$2 AND $sql_label",
- [ $c->{uid}, $obj->{id} ], sub {
+ return cpg $c, "UPDATE ulist_vns SET labels = $l, lastmod = NOW() WHERE uid = \$1 AND vid = \$2",
+ [ $c->{uid}, 'v'.$obj->{id} ], sub {
set_ulist_ret $c, $obj;
} if !$obj->{opt};
@@ -1508,33 +1639,25 @@ sub set_wishlist {
return cerr $c, missing => 'No priority given', field => 'priority' if !$ep;
return cerr $c, badarg => 'Invalid priority', field => 'priority' if ref($vp) || !defined($vp) || $vp !~ /^[0-3]$/;
- # Bug: High/Med/Low statuses are only set if a Wishlist-(High|Medium|Low) label exists; These should probably be created if they don't.
- cpg $c, 'INSERT INTO ulist_vns (uid, vid) VALUES ($1, $2) ON CONFLICT DO NOTHING', [ $c->{uid}, $obj->{id} ], sub {
- cpg $c, "DELETE FROM ulist_vns_labels WHERE uid = \$1 AND vid = \$2 AND $sql_label", [ $c->{uid}, $obj->{id} ], sub {
- cpg $c, 'INSERT INTO ulist_vns_labels (uid, vid, lbl) VALUES($1, $2, $3)', [ $c->{uid}, $obj->{id}, $vp == 3 ? 6 : 5 ], sub {
- if($vp != 3) {
- cpg $c, 'INSERT INTO ulist_vns_labels (uid, vid, lbl) SELECT $1, $2, id FROM ulist_labels WHERE uid = $1 AND label = $3',
- [ $c->{uid}, $obj->{id}, ['Wishlist-High', 'Wishlist-Medium', 'Wishlist-Low']->[$vp] ], sub {
- set_ulist_ret $c, $obj;
- }
- } else {
- set_ulist_ret $c, $obj;
- }
- }
- }
- }
+ my $label = $vp == 3 ? 6 : 5; # Other statuses are not supported anymore.
+ cpg $c,
+ 'INSERT INTO ulist_vns (uid, vid, labels) VALUES ($1, $2, ARRAY[$3]::smallint[])
+ ON CONFLICT (uid,vid) DO UPDATE SET lastmod = NOW(), labels = array_set('.$l.', $3)',
+ [ $c->{uid}, 'v'.$obj->{id}, $label ],
+ sub { set_ulist_ret $c, $obj };
}
+
sub set_ulist {
my($c, $obj) = @_;
- return cpg $c, 'DELETE FROM ulist_vns WHERE uid = $1 AND vid = $2', [ $c->{uid}, $obj->{id} ], sub {
+ return cpg $c, 'DELETE FROM ulist_vns WHERE uid = $1 AND vid = $2', [ $c->{uid}, 'v'.$obj->{id} ], sub {
set_ulist_ret $c, $obj;
} if !$obj->{opt};
my $opt = $obj->{opt};
my @set;
- my @bind = ($c->{uid}, $obj->{id});
+ my @bind = ($c->{uid}, 'v'.$obj->{id});
if(exists $opt->{vote}) {
return cerr $c, badarg => 'Invalid vote', field => 'vote' if defined($opt->{vote}) && (ref $opt->{vote} || $opt->{vote} !~ /^[0-9]+$/ || $opt->{vote} < 10 || $opt->{vote} > 100);
@@ -1564,20 +1687,15 @@ sub set_ulist {
return cerr $c, badarg => "Labels field expects an array", field => 'labels' if ref $opt->{labels} ne 'ARRAY';
return cerr $c, badarg => "Invalid label: '$_'", field => 'labels' for grep !defined($_) || ref($_) || !/^[0-9]+$/, $opt->{labels}->@*;
my %l = map +($_,1), grep $_ != 7, $opt->{labels}->@*;
- # XXX: This is ugly. Errors (especially: unknown labels) are ignored and
- # the entire set operation ought to run in a single transaction.
- pg_cmd 'SELECT lbl FROM ulist_vns_labels WHERE uid = $1 AND vid = $2', [ $c->{uid}, $obj->{id} ], sub {
- return if pg_expect $_[0];
- my %ids = map +($_->{lbl}, 1), $_[0]->rowsAsHashes;
- pg_cmd 'INSERT INTO ulist_vns_labels (uid, vid, lbl) VALUES ($1,$2,$3)', [ $c->{uid}, $obj->{id}, $_ ] for grep !$ids{$_}, keys %l;
- pg_cmd 'DELETE FROM ulist_vns_labels WHERE uid = $1 AND vid = $2 AND lbl = $3', [ $c->{uid}, $obj->{id}, $_ ] for grep !$l{$_}, keys %ids;
- };
+ # XXX: Labels aren't validated here, so we might actually be writing garbage into the DB. Rest of the code doesn't mind that too much, though.
+ push @bind, '{'.join(',',sort { $a <=> $b } keys %l).'}';
+ push @set, 'labels = $'.@bind;
}
- push @set, 'lastmod = NOW()' if @set || $opt->{labels};
+ push @set, 'lastmod = NOW()' if @set;
return cerr $c, missing => 'No fields to change' if !@set;
- cpg $c, 'INSERT INTO ulist_vns (uid, vid) VALUES ($1, $2) ON CONFLICT (uid, vid) DO NOTHING', [ $c->{uid}, $obj->{id} ], sub {
+ cpg $c, 'INSERT INTO ulist_vns (uid, vid) VALUES ($1, $2) ON CONFLICT (uid, vid) DO NOTHING', [ $c->{uid}, 'v'.$obj->{id} ], sub {
cpg $c, 'UPDATE ulist_vns SET '.join(',', @set).' WHERE uid = $1 AND vid = $2', \@bind, sub {
set_ulist_ret $c, $obj;
}
diff --git a/lib/Multi/Anime.pm b/lib/Multi/Anime.pm
index d286a657..b9db5003 100644
--- a/lib/Multi/Anime.pm
+++ b/lib/Multi/Anime.pm
@@ -10,8 +10,10 @@ use warnings;
use Multi::Core;
use AnyEvent::Socket;
use AnyEvent::Util;
+use AnyEvent::HTTP;
use Encode 'decode_utf8', 'encode_utf8';
use VNDB::Types;
+use VNDB::Config;
sub LOGIN_ACCEPTED () { 200 }
@@ -33,6 +35,7 @@ my @handled_codes = (
my %O = (
+ titlesurl => 'https://anidb.net/api/anime-titles.dat.gz',
apihost => 'api.anidb.net',
apiport => 9000,
# AniDB UDP API options
@@ -45,6 +48,7 @@ my %O = (
maxtimeoutdelay => 2*3600,
check_delay => 3600,
resolve_delay => 3*3600,
+ titles_delay => 48*3600,
cachetime => '3 months',
);
@@ -63,9 +67,11 @@ my %C = (
sub run {
shift;
+ $O{ua} = sprintf 'VNDB.org Anime Fetcher (Multi v%s; contact@vndb.org)', config->{version};
%O = (%O, @_);
die "No AniDB user/pass configured!" if !$O{user} || !$O{pass};
+ push_watcher schedule 0, $O{titles_delay}, \&titles_import;
push_watcher schedule 0, $O{resolve_delay}, \&resolve;
resolve();
}
@@ -76,8 +82,76 @@ sub unload {
}
+
+# BUGs, kind of:
+# - If the 'ja' title is not present in the titles dump, the title_kanji column will not be set to NULL.
+# - This doesn't attempt to delete rows from the anime table that aren't present in the titles dump.
+# Both can be 'solved' by periodically pruning unreferenced rows from the anime
+# table and setting all title_kanji columns to NULL.
+
+my %T;
+
+sub titles_import {
+ %T = (
+ titles => 0,
+ updates => 0,
+ start_dl => AE::now(),
+ );
+ http_get $O{titlesurl}, headers => {'User-Agent' => $O{ua} }, timeout => 60, sub {
+ my($body, $hdr) = @_;
+ return AE::log warn => "Error fetching titles dump: $hdr->{Status} $hdr->{Reason}" if $hdr->{Status} !~ /^2/;
+
+ $T{start_insert} = AE::now();
+ if(!open $T{fh}, '<:gzip:utf8', \$body) {
+ AE::log warn => "Error parsing titles dump: $!";
+ return;
+ }
+ titles_insert();
+ };
+}
+
+sub titles_next {
+ my $F = $T{fh};
+ while(local $_ = <$F>) {
+ chomp;
+ next if /^#/;
+ my($id,$type,$lang,$title) = split /\|/, $_, 4;
+ return (0, $id, $title) if $type eq '1';
+ return (1, $id, $title) if $type eq '4' && $lang eq 'ja';
+ }
+ ()
+}
+
+sub titles_insert {
+ my($orig, $id, $title) = titles_next();
+
+ if(!defined $orig) {
+ AE::log info => sprintf 'AniDB title import: %d titles, %d updates in %.1fs (fetch) + %.1fs (insert)',
+ $T{titles}, $T{updates}, $T{start_insert}-$T{start_dl}, AE::now()-$T{start_insert};
+ %T = ();
+ return;
+ }
+
+ my $col = $orig ? 'title_kanji' : 'title_romaji';
+ pg_cmd "INSERT INTO anime (id, $col) VALUES (\$1, \$2) ON CONFLICT (id) DO UPDATE SET $col = excluded.$col WHERE anime.$col IS DISTINCT FROM excluded.$col", [ $id, $title ], sub {
+ my($res) = @_;
+ return if pg_expect $res, 0;
+ $T{titles}++;
+ $T{updates} += $res->cmdRows;
+ titles_insert();
+ }
+}
+
+
+
+
+
sub resolve {
AnyEvent::Socket::resolve_sockaddr $O{apihost}, $O{apiport}, 'udp', 0, undef, sub {
+ if(!@_) {
+ AE::log warn => "Unable to resolve '$O{apihost}'";
+ return; # Re-use old socket address or try again after resolve_delay.
+ }
my($fam, $type, $proto, $saddr) = @{$_[0]};
my $sock;
socket $sock, $fam, $type, $proto or die "Can't create UDP socket: $!";
@@ -100,7 +174,10 @@ sub resolve {
sub check_anime {
return if $C{aid} || $C{tw};
- pg_cmd 'SELECT id FROM anime WHERE lastfetch IS NULL OR lastfetch < NOW() - $1::interval ORDER BY lastfetch DESC NULLS FIRST LIMIT 1', [ $O{cachetime} ], sub {
+ pg_cmd 'SELECT id FROM anime
+ WHERE EXISTS(SELECT 1 FROM vn_anime WHERE aid = anime.id)
+ AND (lastfetch IS NULL OR lastfetch < NOW() - $1::interval)
+ ORDER BY lastfetch DESC NULLS FIRST LIMIT 1', [ $O{cachetime} ], sub {
my $res = shift;
return if pg_expect $res, 1 or $C{aid} or $C{tw} or !$res->rows;
$C{aid} = $res->value(0,0);
@@ -125,7 +202,8 @@ sub nextcmd {
) : ( # logged in, get anime
command => 'ANIME',
aid => $C{aid},
- acode => 3973121, # aid, ANN id, NFO id, year, type, romaji, kanji
+ # aid, year, type, ann, nfo
+ amask => sprintf('%02x%02x%02x%02x%02x%02x%02x', 128+32+16, 0, 0, 0, 64+16, 0, 0),
);
# XXX: We don't have a writability watcher, but since we're only ever sending
@@ -226,27 +304,25 @@ sub handlemsg {
sub update_anime {
my $r = shift;
- # aid, ANN id, NFO id, year, type, romaji, kanji
- my @col = split(/\|/, $r, 7);
+ # aid, year, type, ann, nfo
+ my @col = split(/\|/, $r, 5);
for(@col) {
$_ =~ s/<br \/>/\n/g;
$_ =~ s/`/'/g;
}
- $col[1] = undef if !$col[1];
- $col[2] = undef if !$col[2] || $col[2] =~ /^0,/;
- $col[3] = $col[3] =~ /^([0-9]+)/ ? $1 : undef;
- ($col[4]) = grep lc($col[4]) eq lc($ANIME_TYPE{$_}{anidb}), keys %ANIME_TYPE;
- $col[5] = undef if !$col[5];
- $col[6] = undef if !$col[6];
+ if($col[0] ne $C{aid}) {
+ AE::log warn => sprintf 'Received from aid (%s) for a%d', $col[0], $C{aid};
+ return;
+ }
+ $col[1] = $col[1] =~ /^([0-9]+)/ ? $1 : undef;
+ ($col[2]) = grep lc($col[2]) eq lc($ANIME_TYPE{$_}{anidb}), keys %ANIME_TYPE;
+ $col[3] = undef if !$col[3];
+ $col[4] = undef if !$col[4] || $col[2] =~ /^0,/;
pg_cmd 'UPDATE anime
- SET id = $1, ann_id = $2, nfo_id = $3, year = $4, type = $5,
- title_romaji = $6, title_kanji = $7, lastfetch = NOW()
- WHERE id = $8',
- [ @col, $C{aid} ];
+ SET id = $1, year = $2, type = $3, ann_id = $4, nfo_id = $5, lastfetch = NOW()
+ WHERE id = $1', \@col;
AE::log info => "Fetched anime info for a$C{aid}";
- AE::log warn => "a$C{aid} doesn't have a title or year!"
- if !$col[3] || !$col[5];
}
diff --git a/lib/Multi/Core.pm b/lib/Multi/Core.pm
index f8b277bf..ea1aeb97 100644
--- a/lib/Multi/Core.pm
+++ b/lib/Multi/Core.pm
@@ -12,7 +12,7 @@ use AnyEvent::Log;
use AnyEvent::Pg::Pool;
use Pg::PQ ':pgres';
use DBI;
-use POSIX 'setsid', 'pause', 'SIGUSR1';
+use Fcntl 'LOCK_EX', 'LOCK_NB';
use Exporter 'import';
use VNDB::Config;
@@ -20,9 +20,6 @@ our @EXPORT = qw|pg pg_cmd pg_expect schedule push_watcher throttle|;
my $PG;
-my $logger;
-my $pidfile;
-my $stopcv;
my %throttle; # id => timeout
my @watchers;
@@ -37,41 +34,6 @@ sub push_watcher {
}
-sub daemon_init {
- my $pid = fork();
- die "fork(): $!" if !defined $pid or $pid < 0;
-
- # parent process, log PID and wait for child to initialize
- if($pid > 0) {
- $SIG{CHLD} = sub { die "Initialization failed.\n"; };
- $SIG{ALRM} = sub { kill $pid, 9; die "Initialization timeout.\n"; };
- $SIG{USR1} = sub {
- open my $P, '>', $pidfile or kill($pid, 9) && die $!;
- print $P $pid;
- close $P;
- exit;
- };
- alarm(10);
- pause();
- exit 1;
- }
-}
-
-
-sub daemon_done {
- kill SIGUSR1, getppid();
- setsid();
- chdir '/';
- umask 0022;
- open STDIN, '/dev/null';
- tie *STDOUT, 'Multi::Core::STDIO', 'STDOUT';
- tie *STDERR, 'Multi::Core::STDIO', 'STDERR';
-
- push_watcher AE::signal TERM => sub { $stopcv->send };
- push_watcher AE::signal INT => sub { $stopcv->send };
-}
-
-
sub load_pg {
$PG = AnyEvent::Pg::Pool->new(
config->{Multi}{Core}{db_login},
@@ -117,24 +79,26 @@ sub unload {
sub run {
- my $p = shift;
- $pidfile = config->{root}."/data/multi.pid";
- die "PID file already exists\n" if -e $pidfile;
+ my($quiet) = @_;
- $stopcv = AE::cv;
+ open my $LOCK, '>', config->{var_path}.'/multi.lock' or die "multi.lock: $!\n";
+ flock $LOCK, LOCK_EX|LOCK_NB or die "multi.lock: $!\n";
+
+ my $stopcv = AE::cv;
AnyEvent::Log::ctx('Multi')->attach(AnyEvent::Log::Ctx->new(level => config->{Multi}{Core}{log_level}||'trace',
# Don't use log_to_file, it doesn't accept perl's unicode strings (and, in fact, crashes on them without logging anything).
log_cb => sub {
open(my $F, '>>:utf8', config->{Multi}{Core}{log_dir}.'/multi.log');
print $F $_[0];
+ print $_[0] unless $quiet;
}
));
$AnyEvent::Log::FILTER->level('fatal');
- daemon_init;
load_pg;
load_mods;
- daemon_done;
+ push_watcher AE::signal TERM => sub { $stopcv->send };
+ push_watcher AE::signal INT => sub { $stopcv->send };
AE::log info => "Starting Multi ".config->{version};
push_watcher(schedule(60, 10*60, \&throttle_gc));
@@ -148,7 +112,7 @@ sub run {
# Eg. daily at 12:00 GMT: schedule 24*3600, 12*3600, sub { .. }.
sub schedule {
my($o, $i, $s) = @_;
- AE::timer($i - ((AE::time() + $o) % $i), $i, $s);
+ AE::timer($i - ((AE::time() - $o) % $i), $i, $s);
}
diff --git a/lib/Multi/DLsite.pm b/lib/Multi/DLsite.pm
index 46a0263c..a09f0325 100644
--- a/lib/Multi/DLsite.pm
+++ b/lib/Multi/DLsite.pm
@@ -12,7 +12,7 @@ use VNDB::Config;
my %C = (
url => 'https://www.dlsite.com/%s/work/=/product_id/%s.html',
clean_timeout => 48*3600,
- check_timeout => 5*60,
+ check_timeout => 1*60,
);
@@ -22,10 +22,7 @@ sub run {
%C = (%C, @_);
push_watcher schedule 0, $C{clean_timeout}, sub {
- pg_cmd q{DELETE FROM shop_dlsite WHERE id NOT IN(
- SELECT l_dlsite FROM releases WHERE NOT hidden
- UNION ALL
- SELECT l_dlsiteen FROM releases WHERE NOT hidden)};
+ pg_cmd q{DELETE FROM shop_dlsite WHERE id NOT IN(SELECT l_dlsite FROM releases WHERE NOT hidden)};
};
push_watcher schedule 0, $C{check_timeout}, sub {
pg_cmd q{
@@ -34,15 +31,7 @@ sub run {
FROM releases
WHERE NOT hidden AND l_dlsite <> ''
AND NOT EXISTS(SELECT 1 FROM shop_dlsite WHERE id = l_dlsite)
- }, [], sub {
- pg_cmd q{
- INSERT INTO shop_dlsite (id)
- SELECT DISTINCT l_dlsiteen
- FROM releases
- WHERE NOT hidden AND l_dlsiteen <> ''
- AND NOT EXISTS(SELECT 1 FROM shop_dlsite WHERE id = l_dlsiteen)
- }, [], \&sync
- }
+ }, [], \&sync
}
}
@@ -61,7 +50,6 @@ sub data {
$body =~ m{<i class="work_jpy">([0-9,]+) JPY</i></span>} ? sprintf('JP¥ %d', $1 =~ s/,//gr) : '';
$shop = $body =~ /,"category":"([^"]+)"/ ? $1 : '';
- $shop = 'ecchi-eng' if $shop eq 'ecchieng'; # Both work, but DLsite seems to prefer a dash.
return AE::log warn => "$prefix Product found, but no price ($price) or shop ($shop)" if $found && (!$price || !$shop);
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/Multi/Feed.pm b/lib/Multi/Feed.pm
deleted file mode 100644
index 626e837b..00000000
--- a/lib/Multi/Feed.pm
+++ /dev/null
@@ -1,155 +0,0 @@
-
-#
-# Multi::Feed - Generates and updates Atom feeds
-#
-
-package Multi::Feed;
-
-use strict;
-use warnings;
-use TUWF::XML;
-use Multi::Core;
-use POSIX 'strftime';
-use VNDB::BBCode;
-use VNDB::Config;
-
-my %stats; # key = feed, value = [ count, total, max ]
-
-
-sub run {
- my $p = shift;
- my %o = (
- regenerate_interval => 600, # 10 min.
- stats_interval => 86400, # daily
- @_
- );
- push_watcher schedule 0, $o{regenerate_interval}, \&generate;
- push_watcher schedule 0, $o{stats_interval}, \&stats;
-}
-
-
-sub generate {
- # announcements
- pg_cmd q{
- SELECT '/t'||t.id AS id, t.title, extract('epoch' from tp.date) AS published,
- extract('epoch' from tp.edited) AS updated, u.username, u.id AS uid, tp.msg AS summary
- FROM threads t
- JOIN threads_posts tp ON tp.tid = t.id AND tp.num = 1
- JOIN threads_boards tb ON tb.tid = t.id AND tb.type = 'an'
- JOIN users u ON u.id = tp.uid
- WHERE NOT t.hidden AND NOT t.private
- ORDER BY t.id DESC
- LIMIT $1},
- [10],
- sub { write_atom(announcements => '/t/an', 'VNDB Site Announcements', @_) };
-
- # changes
- pg_cmd q{
- SELECT '/'||c.type||COALESCE(v.id, r.id, p.id, ca.id, s.id, d.id)||'.'||c.rev AS id,
- COALESCE(v.title, r.title, p.name, ca.name, sa.name, d.title) AS title, extract('epoch' from c.added) AS updated,
- u.username, u.id AS uid, c.comments AS summary
- FROM changes c
- LEFT JOIN vn v ON c.type = 'v' AND c.itemid = v.id
- LEFT JOIN releases r ON c.type = 'r' AND c.itemid = r.id
- LEFT JOIN producers p ON c.type = 'p' AND c.itemid = p.id
- LEFT JOIN chars ca ON c.type = 'c' AND c.itemid = ca.id
- LEFT JOIN docs d ON c.type = 'd' AND c.itemid = d.id
- LEFT JOIN staff s ON c.type = 's' AND c.itemid = s.id
- LEFT JOIN staff_alias sa ON sa.id = s.id AND sa.aid = s.aid
- JOIN users u ON u.id = c.requester
- WHERE c.requester <> 1
- ORDER BY c.id DESC
- LIMIT $1},
- [25],
- sub { write_atom(changes => '/hist', 'VNDB Recent Changes', @_); };
-
- # posts
- pg_cmd q{
- SELECT '/t'||t.id||'.'||tp.num AS id, t.title||' (#'||tp.num||')' AS title, extract('epoch' from tp.date) AS published,
- extract('epoch' from tp.edited) AS updated, u.username, u.id AS uid, tp.msg AS summary
- FROM threads_posts tp
- JOIN threads t ON t.id = tp.tid
- JOIN users u ON u.id = tp.uid
- WHERE NOT tp.hidden AND NOT t.hidden AND NOT t.private
- ORDER BY tp.date DESC
- LIMIT $1},
- [25],
- sub { write_atom(posts => '/t', 'VNDB Recent Posts', @_); };
-}
-
-
-sub write_atom {
- my($feed, $path, $title, $res, $sqltime) = @_;
- return if pg_expect $res, 1;
-
- my $start = AE::time;
-
- my @r = $res->rowsAsHashes;
- my $updated = 0;
- for(@r) {
- $updated = $_->{published} if $_->{published} && $_->{published} > $updated;
- $updated = $_->{updated} if $_->{updated} && $_->{updated} > $updated;
- }
-
- my $data;
- my $x = TUWF::XML->new(write => sub { $data .= shift }, pretty => 2);
- $x->xml();
- $x->tag(feed => xmlns => 'http://www.w3.org/2005/Atom', 'xml:lang' => 'en', 'xml:base' => config->{url}.'/');
- $x->tag(title => $title);
- $x->tag(updated => datetime($updated));
- $x->tag(id => config->{url}.$path);
- $x->tag(link => rel => 'self', type => 'application/atom+xml', href => config->{url}."/feeds/$feed.atom", undef);
- $x->tag(link => rel => 'alternate', type => 'text/html', href => config->{url}.$path, undef);
-
- for(@r) {
- $x->tag('entry');
- $x->tag(id => config->{url}.$_->{id});
- $x->tag(title => $_->{title});
- $x->tag(updated => datetime($_->{updated} || $_->{published}));
- $x->tag(published => datetime($_->{published})) if $_->{published};
- if($_->{username}) {
- $x->tag('author');
- $x->tag(name => $_->{username});
- $x->tag(uri => config->{url}.'/u'.$_->{uid}) if $_->{uid};
- $x->end;
- }
- $x->tag(link => rel => 'alternate', type => 'text/html', href => config->{url}.$_->{id}, undef);
- $x->tag('summary', type => 'html', bb2html $_->{summary}) if $_->{summary};
- $x->end('entry');
- }
-
- $x->end('feed');
-
- open my $f, '>:utf8', config->{root}."/www/feeds/$feed.atom" || die $!;
- print $f $data;
- close $f;
-
- AE::log debug => sprintf 'Wrote %16s.atom (%d entries, sql:%4dms, perl:%4dms)',
- $feed, scalar(@r), $sqltime*1000, (AE::time-$start)*1000;
-
- my $time = ((AE::time-$start)+$sqltime)*1000;
- $stats{$feed} = [ 0, 0, 0 ] if !$stats{$feed};
- $stats{$feed}[0]++;
- $stats{$feed}[1] += $time;
- $stats{$feed}[2] = $time if $stats{$feed}[2] < $time;
-}
-
-
-sub stats {
- for (keys %stats) {
- my $v = $stats{$_};
- next if !$v->[0];
- AE::log info => sprintf 'Stats summary for %16s.atom: total:%5dms, avg:%4dms, max:%4dms, size: %.1fkB',
- $_, $v->[1], $v->[1]/$v->[0], $v->[2], (-s config->{root}."/www/feeds/$_.atom")/1024;
- }
- %stats = ();
-}
-
-
-sub datetime {
- strftime('%Y-%m-%dT%H:%M:%SZ', gmtime shift);
-}
-
-
-1;
-
diff --git a/lib/Multi/IRC.pm b/lib/Multi/IRC.pm
index 503a1543..df055b93 100644
--- a/lib/Multi/IRC.pm
+++ b/lib/Multi/IRC.pm
@@ -10,7 +10,6 @@ use warnings;
use Multi::Core;
use AnyEvent::IRC::Client;
use AnyEvent::IRC::Util 'prefix_nick';
-use VNDBUtil 'normalize_query';
use VNDB::Config;
use TUWF::Misc 'uri_escape';
use POSIX 'strftime';
@@ -19,9 +18,9 @@ use Encode 'decode_utf8', 'encode_utf8';
# long subquery used in several places
my $GETBOARDS = q{array_to_string(array(
- SELECT tb.type||COALESCE(':'||COALESCE(u.username, v.title, p.name), '')
+ SELECT tb.type||COALESCE(':'||COALESCE(u.username, v.title[1+1], p.name), '')
FROM threads_boards tb
- LEFT JOIN vn v ON tb.type = 'v' AND v.id = tb.iid
+ LEFT JOIN vnt v ON tb.type = 'v' AND v.id = tb.iid
LEFT JOIN producers p ON tb.type = 'p' AND p.id = tb.iid
LEFT JOIN users u ON tb.type = 'u' AND u.id = tb.iid
WHERE tb.tid = t.id
@@ -37,7 +36,6 @@ my $LIGHT_GREY = "\x0315";
my $irc;
my $connecttimer;
-my @quotew;
my %lastnotify;
@@ -62,7 +60,6 @@ sub run {
set_cbs();
set_logger();
- set_quotew($_) for (0..$#{$O{channels}});
set_notify();
ircconnect();
@@ -86,7 +83,6 @@ sub run {
sub unload {
- @quotew = ();
# TODO: Wait until we've nicely disconnected?
$irc->disconnect('Closing...');
undef $connecttimer;
@@ -107,24 +103,6 @@ sub reconnect {
}
-sub send_quote {
- my $chan = shift;
- pg_cmd 'SELECT quote FROM quotes ORDER BY random() LIMIT 1', undef, sub {
- return if pg_expect $_[0], 1 or !$_[0]->nRows;
- $irc->send_msg(PRIVMSG => $chan, encode_utf8 $_[0]->value(0,0));
- };
-}
-
-
-sub set_quotew {
- my $idx = shift;
- $quotew[$idx] = AE::timer +(18*3600)+rand()*(72*3600), 0, sub {
- send_quote($O{channels}[$idx]) if $irc->registered;
- set_quotew($idx);
- };
-}
-
-
sub set_cbs {
$irc->reg_cb(connect => sub {
return if !$_[1];
@@ -199,19 +177,17 @@ sub set_logger {
sub set_notify {
pg_cmd q{SELECT
(SELECT id FROM changes ORDER BY id DESC LIMIT 1) AS rev,
- (SELECT id FROM tags ORDER BY id DESC LIMIT 1) AS tag,
- (SELECT id FROM traits ORDER BY id DESC LIMIT 1) AS trait,
- (SELECT date FROM threads_posts ORDER BY date DESC LIMIT 1) AS post
+ (SELECT date FROM threads_posts ORDER BY date DESC LIMIT 1) AS post,
+ (SELECT id FROM reviews ORDER BY id DESC LIMIT 1) AS review
}, undef, sub {
return if pg_expect $_[0], 1;
%lastnotify = %{($_[0]->rowsAsHashes())[0]};
- push_watcher pg->listen($_, on_notify => \&notify) for qw{newrevision newpost newtag newtrait};
+ push_watcher pg->listen($_, on_notify => \&notify) for qw{newrevision newpost newreview};
};
}
# formats and posts database items listed in @res, where each item is a hashref with:
-# type database item in [dvprtug]
# id database id
# title main name or title of the DB entry
# rev (optional) revision, post number
@@ -234,19 +210,23 @@ sub formatid {
i => 'trait',
t => 'thread',
d => 'doc',
+ w => 'review',
);
for (@$res) {
- my $id = $_->{type}.$_->{id} . ($_->{rev} ? '.'.$_->{rev} : '');
+ my $id = $_->{id} . ($_->{rev} ? '.'.$_->{rev} : '');
+ my $type = $types{ substr $id, 0, 1 };
# (always) [x+.+]
my @msg = ("$BOLD$c"."[$NORMAL$BOLD$id$c]$NORMAL");
# (only if username key is present) Edit of / New item / reply to / whatever
push @msg, $c.(
- ($_->{rev}||1) == 1 ? "New $types{$_->{type}}" :
- $_->{type} eq 't' ? 'Reply to' : 'Edit of'
- ).$NORMAL if $_->{username};
+ $id =~ /^w/ && !$_->{rev} ? 'Review of' :
+ $id =~ /^w/ ? 'Comment to review of' :
+ ($_->{rev}||1) == 1 ? "New $type" :
+ $id =~ /^t/ ? 'Reply to' : 'Edit of'
+ ).$NORMAL if exists $_->{username};
# (always) main title
push @msg, $_->{title};
@@ -255,7 +235,7 @@ sub formatid {
push @msg, $c."Posted in$NORMAL $_->{boards}" if $_->{boards};
# (only if username key is present) By [username]
- push @msg, $c."By$NORMAL $_->{username}" if $_->{username};
+ push @msg, $c."By$NORMAL ".($_->{username}//'deleted') if exists $_->{username};
# (only if comments key is present) Summary:
$_->{comments} =~ s/\n/ /g if $_->{comments};
@@ -273,13 +253,13 @@ sub formatid {
sub handleid {
- my($chan, $t, $id, $rev) = @_;
+ my($chan, $id, $rev) = @_;
# Some common exceptions
- return if grep "$t$id$rev" eq $_, qw|v1 v2 v3 v4 u2 i3 i5 i7 c64|;
+ return if grep $id eq $_, qw|v1 v2 v3 v4 u2 i3 i5 i7 c64|;
return if throttle $O{throt_vndbid}, 'irc_vndbid';
- return if throttle $O{throt_sameid}, "irc_sameid_$t$id$rev";
+ return if throttle $O{throt_sameid}, "irc_sameid_$id.$rev";
my $c = sub {
return if pg_expect $_[0], 1;
@@ -287,29 +267,18 @@ sub handleid {
};
# plain vn/user/producer/thread/tag/trait/release
- pg_cmd 'SELECT $1::text AS type, $2::integer AS id, '.(
- $t eq 'v' ? 'v.title FROM vn v WHERE v.id = $2' :
- $t eq 'u' ? 'u.username AS title FROM users u WHERE u.id = $2' :
- $t eq 'p' ? 'p.name AS title FROM producers p WHERE p.id = $2' :
- $t eq 'c' ? 'c.name AS title FROM chars c WHERE c.id = $2' :
- $t eq 's' ? 'sa.name AS title FROM staff s JOIN staff_alias sa ON sa.aid = s.aid AND sa.id = s.id WHERE s.id = $2' :
- $t eq 't' ? 'title, '.$GETBOARDS.' FROM threads t WHERE NOT t.hidden AND NOT t.private AND t.id = $2' :
- $t eq 'g' ? 'name AS title FROM tags WHERE id = $2' :
- $t eq 'i' ? 'name AS title FROM traits WHERE id = $2' :
- $t eq 'd' ? 'title FROM docs WHERE id = $2' :
- 'r.title FROM releases r WHERE r.id = $2'),
- [ $t, $id ], $c if !$rev && $t =~ /[dvprtugics]/;
+ pg_cmd 'SELECT $1::vndbid AS id, '.(
+ $id =~ /^t/ ? 'title, '.$GETBOARDS.' FROM threads t WHERE NOT t.hidden AND NOT t.private AND t.id = $1' :
+ $id =~ /^w/ ? 'v.title[1+1], u.username FROM reviews w JOIN vnt v ON v.id = w.vid LEFT JOIN users u ON u.id = w.uid WHERE w.id = $1' :
+ 'title[1+1] FROM item_info(NULL,$1,NULL) x'),
+ [ $id ], $c if !$rev && $id =~ /^[dvprtugicsw]/;
# edit/insert of vn/release/producer or discussion board post
- pg_cmd 'SELECT $1::text AS type, $2::integer AS id, $3::integer AS rev, '.(
- $t eq 'v' ? 'vh.title, u.username, c.comments FROM changes c JOIN vn_hist vh ON c.id = vh.chid JOIN users u ON u.id = c.requester WHERE c.type = \'v\' AND c.itemid = $2 AND c.rev = $3' :
- $t eq 'r' ? 'rh.title, u.username, c.comments FROM changes c JOIN releases_hist rh ON c.id = rh.chid JOIN users u ON u.id = c.requester WHERE c.type = \'r\' AND c.itemid = $2 AND c.rev = $3' :
- $t eq 'p' ? 'ph.name AS title, u.username, c.comments FROM changes c JOIN producers_hist ph ON c.id = ph.chid JOIN users u ON u.id = c.requester WHERE c.type = \'p\' AND c.itemid = $2 AND c.rev = $3' :
- $t eq 'c' ? 'ch.name AS title, u.username, c.comments FROM changes c JOIN chars_hist ch ON c.id = ch.chid JOIN users u ON u.id = c.requester WHERE c.type = \'c\' AND c.itemid = $2 AND c.rev = $3' :
- $t eq 's' ? 'sah.name AS title, u.username, c.comments FROM changes c JOIN staff_hist sh ON c.id = sh.chid JOIN users u ON u.id = c.requester JOIN staff_alias_hist sah ON sah.chid = c.id AND sah.aid = sh.aid WHERE c.type = \'s\' AND c.itemid = $2 AND c.rev = $3' :
- $t eq 'd' ? 'dh.title, u.username, c.comments FROM changes c JOIN docs_hist dh ON c.id = dh.chid JOIN users u ON u.id = c.requester WHERE c.type = \'d\' AND c.itemid = $2 AND c.rev = $3' :
- 't.title, u.username, '.$GETBOARDS.' FROM threads t JOIN threads_posts tp ON tp.tid = t.id JOIN users u ON u.id = tp.uid WHERE NOT t.hidden AND NOT t.private AND t.id = $2 AND tp.num = $3'),
- [ $t, $id, $rev], $c if $rev && $t =~ /[dvprtcs]/;
+ pg_cmd 'SELECT $1::vndbid AS id, $2::integer AS rev, '.(
+ $id =~ /^t/ ? 't.title, u.username, '.$GETBOARDS.' FROM threads t JOIN threads_posts tp ON tp.tid = t.id LEFT JOIN users u ON u.id = tp.uid WHERE NOT t.hidden AND NOT t.private AND t.id = $1 AND tp.num = $2' :
+ $id =~ /^w/ ? 'v.title[1+1], u.username FROM reviews_posts wp JOIN reviews w ON w.id = wp.id JOIN vnt v ON v.id = w.vid LEFT JOIN users u ON u.id = wp.uid WHERE wp.id = $1 AND wp.num = $2' :
+ 'x.title[1+1], u.username, c.comments FROM changes c JOIN item_info(NULL,$1,$2) x ON true JOIN users u ON u.id = c.requester WHERE c.itemid = $1 AND c.rev = $2'),
+ [ $id, $rev], $c if $rev && $id =~ /^[dvprtcsgiw]/;
}
@@ -321,8 +290,8 @@ sub vndbid {
my @id; # [ type, id, ref ]
for (split /[, ]/, $msg) {
next if length > 15 or m{[a-z]{3,6}://}i; # weed out URLs and too long things
- push @id, /^(?:.*[^\w]|)([dvprtcs])([1-9][0-9]*)\.([1-9][0-9]*)(?:[^\w].*|)$/ ? [ $1, $2, $3 ] # x+.+
- : /^(?:.*[^\w]|)([dvprtugics])([1-9][0-9]*)(?:[^\w].*|)$/ ? [ $1, $2, '' ] : (); # x+
+ push @id, /^(?:.*[^\w]|)([wdvprtcsgi][1-9][0-9]*)\.([1-9][0-9]*)(?:[^\w].*|)$/ ? [ $1, $2 ] # x+.+
+ : /^(?:.*[^\w]|)([wdvprtcsgiu][1-9][0-9]*)(?:[^\w].*|)$/ ? [ $1, '' ] : (); # x+
}
handleid($chan, @$_) for @id;
}
@@ -332,43 +301,31 @@ sub vndbid {
sub notify {
my(undef, $sel) = @_;
- my $k = {qw|newrevision rev newpost post newtrait trait newtag tag|}->{$sel};
+ my $k = {qw|newrevision rev newpost post newreview review|}->{$sel};
return if !$k || !$lastnotify{$k};
my $q = {
rev => q{
- SELECT c.type, c.rev, c.comments, c.id AS lastid, c.itemid AS id,
- COALESCE(vh.title, rh.title, ph.name, ch.name, sah.name, dh.title) AS title, u.username
+ SELECT c.rev, c.comments, c.id AS lastid, c.itemid AS id, x.title[1+1], u.username
FROM changes c
- LEFT JOIN vn_hist vh ON c.type = 'v' AND c.id = vh.chid
- LEFT JOIN releases_hist rh ON c.type = 'r' AND c.id = rh.chid
- LEFT JOIN producers_hist ph ON c.type = 'p' AND c.id = ph.chid
- LEFT JOIN chars_hist ch ON c.type = 'c' AND c.id = ch.chid
- LEFT JOIN staff_hist sh ON c.type = 's' AND c.id = sh.chid
- LEFT JOIN staff_alias_hist sah ON c.type = 's' AND sah.aid = sh.aid AND sah.chid = c.id
- LEFT JOIN docs_hist dh ON c.type = 'd' AND c.id = dh.chid
+ JOIN item_info(NULL, c.itemid, c.rev) x ON true
JOIN users u ON u.id = c.requester
- WHERE c.id > $1 AND c.requester <> 1
+ WHERE c.id > $1 AND c.requester <> 'u1'
ORDER BY c.id},
post => q{
- SELECT 't' AS type, tp.tid AS id, tp.num AS rev, t.title, u.username, tp.date AS lastid, }.$GETBOARDS.q{
+ SELECT tp.tid AS id, tp.num AS rev, t.title, COALESCE(u.username, 'deleted') AS username, tp.date AS lastid, }.$GETBOARDS.q{
FROM threads_posts tp
JOIN threads t ON t.id = tp.tid
- JOIN users u ON u.id = tp.uid
+ LEFT JOIN users u ON u.id = tp.uid
WHERE tp.date > $1 AND tp.num = 1 AND NOT t.hidden AND NOT t.private
ORDER BY tp.date},
- trait => q{
- SELECT 'i' AS type, t.id, t.name AS title, u.username, t.id AS lastid
- FROM traits t
- JOIN users u ON u.id = t.addedby
- WHERE t.id > $1
- ORDER BY t.id},
- tag => q{
- SELECT 'g' AS type, t.id, t.name AS title, u.username, t.id AS lastid
- FROM tags t
- JOIN users u ON u.id = t.addedby
- WHERE t.id > $1
- ORDER BY t.id}
+ review => q{
+ SELECT w.id, v.title[1+1], u.username, w.id AS lastid
+ FROM reviews w
+ JOIN vnt v ON v.id = w.vid
+ LEFT JOIN users u ON u.id = w.uid
+ WHERE w.id > $1
+ ORDER BY w.id}
}->{$k};
pg_cmd $q, [ $lastnotify{$k} ], sub {
@@ -396,29 +353,30 @@ list => [ 0, 0, sub {
$irc->is_channel_name($_[1]) ? 'This is not a warez channel!' : 'I am not a warez bot!');
}],
-quote => [ 1, 0, sub { send_quote($_[1]) } ],
+quote => [ 1, 0, sub {
+ my(undef, $chan) = @_;
+ pg_cmd 'SELECT quote FROM quotes ORDER BY random() LIMIT 1', undef, sub {
+ return if pg_expect $_[0], 1 or !$_[0]->nRows;
+ $irc->send_msg(PRIVMSG => $chan, encode_utf8 $_[0]->value(0,0));
+ };
+} ],
vn => [ 0, 0, sub {
my($nick, $chan, $q) = @_;
return $irc->send_msg(PRIVMSG => $chan, 'You forgot the search query, dummy~~!') if !$q;
- my @q = normalize_query($q);
- return $irc->send_msg(PRIVMSG => $chan,
- "Couldn't do anything with that search query, you might want to add quotes or use longer words.") if !@q;
-
- my $w = join ' AND ', map "c_search LIKE \$$_", 1..@q;
- pg_cmd qq{
- SELECT 'v'::text AS type, id, title
- FROM vn
- WHERE NOT hidden AND $w
- ORDER BY title
+ pg_cmd q{
+ SELECT id, title[1+1]
+ FROM vnt v
+ WHERE NOT hidden AND EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = v.id AND sc.label LIKE ALL (search_query($1)))
+ ORDER BY sorttitle
LIMIT 6
- }, [ map "%$_%", @q ], sub {
+ }, [ $q ], sub {
my $res = shift;
return if pg_expect $res, 1;
return $irc->send_msg(PRIVMSG => $chan, 'No visual novels found.') if !$res->nRows;
return $irc->send_msg(PRIVMSG => $chan,
- sprintf 'Too many results found, see %s/v/all?q=%s', config->{url}, uri_escape($q)) if $res->nRows > 5;
+ sprintf 'Too many results found, see %s/v?q=%s', config->{url}, uri_escape($q)) if $res->nRows > 5;
formatid([$res->rowsAsHashes()], $chan, 0);
};
}],
@@ -427,12 +385,12 @@ p => [ 0, 0, sub {
my($nick, $chan, $q) = @_;
return $irc->send_msg(PRIVMSG => $chan, 'You forgot the search query, dummy~~!') if !$q;
pg_cmd q{
- SELECT 'p'::text AS type, id, name AS title
- FROM producers p
- WHERE hidden = FALSE AND (name ILIKE $1 OR original ILIKE $1 OR alias ILIKE $1)
- ORDER BY name
- LIMIT 6
- }, [ "%$q%" ], sub {
+ SELECT id, name AS title
+ FROM producers p
+ WHERE NOT hidden AND EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = p.id AND sc.label LIKE ALL (search_query($1)))
+ ORDER BY name
+ LIMIT 6
+ }, [ $q ], sub {
my $res = shift;
return if pg_expect $res, 1;
return $irc->send_msg(PRIVMSG => $chan, 'No producers novels found.') if !$res->nRows;
@@ -442,27 +400,6 @@ p => [ 0, 0, sub {
};
}],
-scr => [ 0, 0, sub {
- my($nick, $chan, $q) = @_;
- return $irc->send_msg(PRIVMSG => $chan,
- q|Sorry, I failed to comprehend which screenshot you'd like me to lookup for you,|
- .q| please understand that Yorhel was not willing to supply me with mind reading capabilities.|)
- if !$q || $q !~ /([0-9]+)\.jpg/;
- $q = $1;
- pg_cmd q{
- SELECT 'v'::text AS type, v.id, v.title
- FROM changes c
- JOIN vn_screenshots_hist vsh ON vsh.chid = c.id
- JOIN vn v ON v.id = c.itemid
- WHERE vsh.scr = $1 LIMIT 1
- }, [ $q ], sub {
- my $res = shift;
- return if pg_expect $res, 1;
- return $irc->send_msg(PRIVMSG => $chan, "Couldn't find a VN with that screenshot ID.") if !$res->nRows;
- formatid([$res->rowsAsHashes()], $chan, 0);
- };
-}],
-
die => [ 1, 1, sub {
kill 'TERM', 0;
}],
diff --git a/lib/Multi/JASTUSA.pm b/lib/Multi/JASTUSA.pm
new file mode 100644
index 00000000..bf4b88f8
--- /dev/null
+++ b/lib/Multi/JASTUSA.pm
@@ -0,0 +1,87 @@
+package Multi::JASTUSA;
+
+use v5.28;
+use Multi::Core;
+use AnyEvent::HTTP;
+use JSON::XS 'decode_json';
+use VNDB::Config;
+
+
+my %C = (
+ sync_timeout => 6*3600,
+ url => 'https://app.jastusa.com/api/v2/shop/es?channelCode=JASTUSA&currency=USD&limit=50&localeCode=en_US&sale=false&sort=newest&zone=US&page=%d',
+);
+
+
+sub run {
+ shift;
+ $C{ua} = sprintf 'VNDB.org Affiliate Crawler (Multi v%s; contact@vndb.org)', config->{version};
+ %C = (%C, @_);
+
+ push_watcher schedule 35*60, $C{sync_timeout}, \&sync;
+}
+
+
+sub slug {
+ # The slug is not included in the API, so presumably generated in JS.
+ # This is reverse engineering attempt based on titles in the store, most likely missing a whole lot of symbols.
+ lc($_[0]) =~ s/[-, \[\]]+/-/rg =~ s/^-//r =~ s/-$//r =~ s/&/and/rg =~ s/♥/love/rg =~ tr/–ω锓*³★・;\/?/-we""/rd
+}
+
+
+sub item {
+ my($prefix, $p) = @_;
+ return 'Invalid object' if !$p->{code} || !$p->{variants}[0] || !$p->{translations}{en_US}{name};
+ my $slug = slug $p->{translations}{en_US}{name};
+ my $var = $p->{variants}[0];
+ return 'Not in stock' if !$var->{inStock};
+ return 'No price info' if !defined $var->{price};
+ my $price = $var->{price} ? sprintf 'US$ %.2f', $var->{price}/100 : 'free';
+ AE::log info => "$prefix $p->{code} at $slug for $price";
+ pg_cmd 'UPDATE shop_jastusa SET lastfetch = NOW(), deadsince = NULL, price = $1, slug = $2 WHERE id = $3',
+ [ $price, $slug, $p->{code} ];
+ 0
+}
+
+
+sub data {
+ my($page, $time, $body, $hdr) = @_;
+ my $prefix = sprintf '[%.1fs] %d', $time, $page;
+ return AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}" if $hdr->{Status} !~ /^2/;
+ my $nfo = decode_json $body;
+ return AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}" if ref $nfo ne 'HASH' || !$nfo->{pages};
+
+ for my $p ($nfo->{products}->@*) {
+ my $r = item($prefix, $p);
+ AE::log warn => "$prefix $p->{code}: $r" if $r;
+ }
+
+ if($page < $nfo->{pages}) {
+ fetch($page+1);
+ } else {
+ pg_cmd "UPDATE shop_jastusa SET deadsince = NOW(), price = '' WHERE deadsince IS NULL AND (lastfetch IS NULL OR lastfetch < NOW()-'1 hour'::interval)";
+ }
+}
+
+
+sub fetch {
+ my($page) = @_;
+ my $ts = AE::now;
+ http_get sprintf($C{url}, $page),
+ headers => {'User-Agent' => $C{ua}},
+ timeout => 60,
+ sub { data($page, AE::now-$ts, @_) };
+}
+
+sub sync {
+ pg_cmd 'DELETE FROM shop_jastusa WHERE id NOT IN(SELECT l_jastusa FROM releases WHERE NOT hidden)';
+ pg_cmd q{
+ INSERT INTO shop_jastusa (id)
+ SELECT DISTINCT l_jastusa
+ FROM releases
+ WHERE NOT hidden AND l_jastusa <> ''
+ AND NOT EXISTS(SELECT 1 FROM shop_jastusa WHERE id = l_jastusa)
+ }, [], sub { fetch(1) }
+}
+
+1;
diff --git a/lib/Multi/JList.pm b/lib/Multi/JList.pm
index 515a34b5..60ce2c1e 100644
--- a/lib/Multi/JList.pm
+++ b/lib/Multi/JList.pm
@@ -5,11 +5,11 @@ use warnings;
use Multi::Core;
use AnyEvent::HTTP;
use VNDB::Config;
+use VNDB::ExtLinks;
my %C = (
- jbox => 'https://www.jbox.com/',
- jlist => 'https://www.jlist.com/',
+ url => 'https://jlist.com/shop/product/%s',
clean_timeout => 48*3600,
check_timeout => 10*60, # Minimum time between fetches.
);
@@ -35,45 +35,34 @@ sub run {
}
-sub trysite {
- my($jbox, $id) = @_;
- my $ts = AE::now;
- my $url = ($jbox eq 't' ? $C{jbox} : $C{jlist}).$id;
- http_get $url, headers => {'User-Agent' => $C{ua} }, timeout => 60,
- sub { data($jbox, AE::now-$ts, $id, @_) };
-}
-
-
sub data {
- my($jbox, $time, $id, $body, $hdr) = @_;
+ 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/ && $hdr->{Status} ne '404';
- return AE::log warn => "$prefix ERROR: Blocked by StackPath" if $body =~ /StackPath/;
- my $found = $hdr->{Status} ne '404' && $body =~ /fancybox mainProductImage/;
- my $outofstock = $body =~ /<div class="statusBox-detail">[\s\r\n]*Out of stock[\s\r\n]*<\/div>/im;
- my $price = $body =~ /<span class="price"(?: id="product-price-\d+")?>\s*\$(\d+\.\d+)(?:\/\$\d+\.\d+)?\s*<\/span>/ ? sprintf('US$ %.2f', $1) : '';
+ # Extract info from the JSON-LD embedded on the page. Assumes there's either
+ # a single "Product" or none. Also assumes specific JSON formatting, because
+ # I'm too lazy to properly extract out and parse the JSON.
+ my $found = $hdr->{Status} ne '404' && $body =~ /"\@type":"Product"/;
+ my $outofstock = $body !~ m{"availability":"https://schema.org/InStock"};
+ my $price = $body =~ /"price":"([0-9\.]+)"/ ? sprintf('US$ %.2f', $1) : '';
return AE::log warn => "$prefix Product found, but no price" if !$price && $found && !$outofstock;
# Out of stock? Update database.
if($outofstock) {
- pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, jbox = $2, price = '', lastfetch = NOW() WHERE id = $1}, [ $id, $jbox ];
- AE::log debug => "$prefix is out of stock on jbox=$jbox";
+ pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, price = '', lastfetch = NOW() WHERE id = $1}, [ $id ];
+ AE::log debug => "$prefix is out of stock";
# We have a price? Update database.
} elsif($price) {
- pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, jbox = $2, price = $3, lastfetch = NOW() WHERE id = $1}, [ $id, $jbox, $price ];
- AE::log debug => "$prefix for $price on jbox=$jbox";
-
- # No price or stock info? Try J-List
- } elsif($jbox eq 't') {
- trysite 'f', $id;
+ pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, price = $2, lastfetch = NOW() WHERE id = $1}, [ $id, $price ];
+ AE::log debug => "$prefix for $price";
- # Nothing at all? Update database.
+ # Not found? Update database.
} else {
- pg_cmd q{UPDATE shop_jlist SET deadsince = coalesce(deadsince, NOW()), lastfetch = NOW() WHERE id = $1}, [ $id ];
- AE::log info => "$prefix not found on either JBOX or J-List.";
+ pg_cmd q{UPDATE shop_jlist SET deadsince = NOW() WHERE deadsince IS NULL AND id = $1}, [ $id ];
+ AE::log info => "$prefix not found.";
}
}
@@ -82,6 +71,9 @@ sub sync {
pg_cmd 'SELECT id FROM shop_jlist ORDER BY lastfetch ASC NULLS FIRST LIMIT 1', [], sub {
my($res, $time) = @_;
return if pg_expect $res, 1 or !$res->nRows;
- trysite 't', $res->value(0,0);
+ my $id = $res->value(0,0);
+ my $ts = AE::now;
+ http_get sprintf($C{url}, $id), headers => {'User-Agent' => $C{ua} }, timeout => 60,
+ sub { data(AE::now-$ts, $id, @_) };
};
}
diff --git a/lib/Multi/Maintenance.pm b/lib/Multi/Maintenance.pm
index abed87a6..728bcd20 100644
--- a/lib/Multi/Maintenance.pm
+++ b/lib/Multi/Maintenance.pm
@@ -8,8 +8,7 @@ package Multi::Maintenance;
use strict;
use warnings;
use Multi::Core;
-use PerlIO::gzip;
-use VNDBUtil 'normalize_titles';
+use POSIX 'strftime';
use VNDB::Config;
@@ -17,10 +16,10 @@ my $monthly;
sub run {
- push_watcher schedule 12*3600, 24*3600, \&daily;
- push_watcher schedule 0, 3600, \&vnsearch_check;
- push_watcher pg->listen(vnsearch => on_notify => \&vnsearch_check);
+ push_watcher schedule 57*60, 3600, \&hourly; # Every hour at xx:57
+ push_watcher schedule 7*3600+1800, 24*3600, \&daily; # 7:30 UTC, 30 minutes before the daily DB dumps are created
set_monthly();
+ logrotate();
}
@@ -48,12 +47,45 @@ sub log_res {
}
+sub hourly {
+ pg_cmd 'SELECT update_vnvotestats()', undef, sub { log_res vnstats => @_ };
+}
+
+
+
#
# D A I L Y J O B S
#
+sub logrotate {
+ my $today = strftime '%Y%m%d', localtime;
+ my $oldest = strftime '%Y%m%d', localtime(time() - 30*24*3600);
+
+ my $dir = config->{Multi}{Core}{log_dir};
+ opendir my $D, $dir or AE::log warn => "Unable to read $dir: $!";
+ while (local $_ = readdir $D) {
+ next if /^\./ || /~$/ || !-f "$dir/$_";
+ if (/-([0-9]{8})$/) {
+ unlink "$dir/$_" or AE::log warn => "Unable to rm $dir/$_: $!" if $1 lt $oldest;
+ } elsif (!-f "$dir/$_-$today") {
+ rename "$dir/$_", "$dir/$_-$today" or AE::log warn => "Unable to move $dir/$_: $!";
+ }
+ }
+ AE::log info => 'Logs rotated.';
+}
+
+
my %dailies = (
+ # Delete tags assigned to Multi that also have (possibly inherited) votes from other users.
+ cleanmultitags => q|
+ WITH RECURSIVE
+ t_votes(tag,vid,uid) AS (SELECT tv.tag, tv.vid, tv.uid FROM tags_vn tv LEFT JOIN users u ON u.id = tv.uid WHERE tv.uid IS DISTINCT FROM 'u1' AND (u.id IS NULL OR u.perm_tag)),
+ t_inherit(tag,vid,uid) AS (SELECT * FROM t_votes UNION SELECT tp.parent, th.vid, th.uid FROM t_inherit th JOIN tags_parents tp ON tp.id = th.tag),
+ t_nonmulti(tag,vid) AS (SELECT DISTINCT tag, vid FROM t_inherit),
+ t_del(tag,vid) AS (SELECT tv.tag, tv.vid FROM tags_vn tv JOIN t_nonmulti tn ON (tn.tag,tn.vid) = (tv.tag,tv.vid) WHERE tv.uid = 'u1')
+ DELETE FROM tags_vn tv WHERE tv.uid = 'u1' AND EXISTS(SELECT 1 FROM t_del td WHERE (td.tag,td.vid) = (tv.tag,tv.vid))|,
+
# takes about 50ms to 500ms to complete, depending on how many releases have been released within the past 5 days
vncache_inc => q|
SELECT update_vncache(id)
@@ -65,27 +97,30 @@ my %dailies = (
AND r.released <= TO_CHAR(NOW(), 'YYYYMMDD')::integer
) AS r(id)|,
- # takes about 15 seconds max, still OK
+ # takes about 6 seconds, OK
tagcache => 'SELECT tag_vn_calc(NULL)',
- # takes about 25 seconds, OK
+ # takes about 11 seconds, OK
traitcache => 'SELECT traits_chars_calc(NULL)',
- # takes about 4 seconds, OK
- vnstats => 'SELECT update_vnvotestats()',
+ lengthcache => 'SELECT update_vn_length_cache(NULL)',
- # should be pretty fast
- cleangraphs => q|
- DELETE FROM relgraphs vg
- WHERE NOT EXISTS(SELECT 1 FROM vn WHERE rgraph = vg.id)
- AND NOT EXISTS(SELECT 1 FROM producers WHERE rgraph = vg.id)|,
+ # takes about 10 seconds, OK
+ imagecache => 'SELECT update_images_cache(NULL)',
- cleansessions => q|DELETE FROM sessions WHERE expires < NOW()|,
+ reviewcache => 'SELECT update_reviews_votes_cache(NULL)',
+
+ quotescache => 'SELECT quotes_rand_calc()',
+
+ deleteusers => q|SELECT user_delete()|,
+ cleansessions => q|DELETE FROM sessions WHERE expires < NOW() AND type <> 'api2'|,
cleannotifications => q|DELETE FROM notifications WHERE read < NOW()-'1 month'::interval|,
cleannotifications2=> q|DELETE FROM notifications WHERE id IN (
SELECT id FROM (SELECT id, row_number() OVER (PARTITION BY uid ORDER BY id DESC) > 500 from notifications) AS x(id,del) WHERE x.del)|,
rmunconfirmusers => q|DELETE FROM users WHERE registered < NOW()-'1 week'::interval AND NOT email_confirmed|,
cleanthrottle => q|DELETE FROM login_throttle WHERE timeout < NOW()|,
+ cleanresthrottle => q|DELETE FROM reset_throttle WHERE timeout < NOW()|,
+ cleanregthrottle => q|DELETE FROM registration_throttle WHERE timeout < NOW()|,
);
@@ -104,6 +139,7 @@ sub daily {
run_daily shift(@l), $s if @l;
};
$s->();
+ logrotate;
}
@@ -124,27 +160,6 @@ my %monthlies = (
);
-sub logrotate {
- my $dir = sprintf '%s/old', config->{Multi}{Core}{log_dir};
- mkdir $dir if !-d $dir;
-
- for (glob sprintf '%s/*', config->{Multi}{Core}{log_dir}) {
- next if /^\./ || /~$/ || !-f;
- my $f = /([^\/]+)$/ ? $1 : $_;
- my $n = sprintf '%s/%s.%04d-%02d-%02d.gz', $dir, $f, (localtime)[5]+1900, (localtime)[4]+1, (localtime)[3];
- return if -f $n;
- open my $I, '<', sprintf '%s/%s', config->{Multi}{Core}{log_dir}, $f;
- open my $O, '>:gzip', $n;
- print $O $_ while <$I>;
- close $O;
- close $I;
- open $I, '>', sprintf '%s/%s', config->{Multi}{Core}{log_dir}, $f;
- close $I;
- }
- AE::log info => 'Logs rotated.';
-}
-
-
sub run_monthly {
my($d, $sub) = @_;
pg_cmd $monthlies{$d}, undef, sub {
@@ -160,47 +175,8 @@ sub monthly {
run_monthly shift(@l), $s if @l;
};
$s->();
-
- logrotate;
set_monthly;
}
-
-#
-# V N S E A R C H C A C H E
-#
-
-
-sub vnsearch_check {
- pg_cmd 'SELECT id FROM vn WHERE c_search IS NULL LIMIT 1', undef, sub {
- my $res = shift;
- return if pg_expect $res, 1 or !$res->rows;
-
- my $id = $res->value(0,0);
- pg_cmd q|SELECT title, original, alias FROM vn WHERE id = $1
- UNION SELECT r.title, r.original, NULL FROM releases r JOIN releases_vn rv ON rv.id = r.id WHERE rv.vid = $1 AND NOT r.hidden|,
- [ $id ], sub { vnsearch_update($id, @_) };
- };
-}
-
-
-sub vnsearch_update { # id, res, time
- my($id, $res, $time) = @_;
- return if pg_expect $res, 1;
-
- my $t = normalize_titles(grep length, map
- +($_->{title}, $_->{original}, split /[\n,]/, $_->{alias}||''),
- $res->rowsAsHashes
- );
-
- pg_cmd 'UPDATE vn SET c_search = $1 WHERE id = $2', [ $t, $id ], sub {
- my($res, $t2) = @_;
- return if pg_expect $res, 0;
- AE::log info => sprintf 'Updated search cache for v%d (%3dms SQL)', $id, ($time+$t2)*1000;
- vnsearch_check;
- };
-}
-
-
1;
diff --git a/lib/Multi/RG.pm b/lib/Multi/RG.pm
deleted file mode 100644
index 0a6039a3..00000000
--- a/lib/Multi/RG.pm
+++ /dev/null
@@ -1,347 +0,0 @@
-
-#
-# Multi::RG - Relation graph generator
-#
-
-package Multi::RG;
-
-use strict;
-use warnings;
-use Multi::Core;
-use AnyEvent::Util;
-use Encode 'encode_utf8';
-use XML::Parser;
-use TUWF::XML;
-use VNDB::Types;
-
-
-my %O = (
- font => 'Arial',
- fsize => [ 9, 7, 10 ], # nodes, edges, node_title
- dot => '/usr/bin/dot',
- check_delay => 3600,
-);
-
-
-my %C;
-
-
-sub run {
- shift;
- %O = (%O, @_);
- push_watcher schedule 0, $O{check_delay}, \&check_rg;
- push_watcher pg->listen(relgraph => on_notify => \&check_rg);
-}
-
-
-sub check_rg {
- # Only process one at a time, we don't know how many other entries the
- # current graph will affect.
- return if $C{id};
-
- AE::log debug => 'Checking for new graphs to create.';
- pg_cmd q|
- SELECT 'v', v.id FROM vn v JOIN vn_relations vr ON vr.id = v.id WHERE v.rgraph IS NULL AND v.hidden = FALSE
- UNION
- SELECT 'p', p.id FROM producers p JOIN producers_relations pr ON pr.id = p.id WHERE p.rgraph IS NULL AND p.hidden = FALSE
- LIMIT 1|, undef, sub {
- my($res, $time) = @_;
- return if pg_expect $res, 1 or !$res->rows;
- creategraph(scalar $res->value(0, 0), scalar $res->value(0, 1), 0, $time);
- }
-}
-
-
-sub creategraph {
- my($type, $id, $official, $sqlt) = @_;
-
- %C = (
- start => scalar AE::time(),
- type => $type,
- id => $id,
- sqlt => $sqlt,
- offi => $official,
- rels => {}, # relations (key=id1-id2, value=[relation,official])
- nodes => {}, # nodes (key=id, value= 0:found, 1:processed)
- );
-
- AE::log debug => "Generating graph for $C{type}$C{id}";
- getrelid($C{id});
-}
-
-
-sub getrelid {
- my $id = shift;
- AE::log debug => "Fetching relations for $C{type}$id";
- pg_cmd $C{type} eq 'p'
- ? 'SELECT pid, relation FROM producers_relations WHERE id = $1'
- : $C{offi} ? 'SELECT vid, relation, official FROM vn_relations WHERE id = $1 AND official'
- : 'SELECT vid, relation, official FROM vn_relations WHERE id = $1',
- [ $id ], sub { getrel($id, @_) };
-}
-
-
-sub getrel { # id, res, time
- my($id, $res, $time) = @_;
- return if pg_expect $res, 1, $id;
-
- $C{sqlt} += $time;
- $C{nodes}{$id} = 1;
-
- for($res->rows) {
- my($xid, $xrel, $xoff) = @$_;
- $xoff = 0 if $xoff && $xoff =~ /^f/;
-
- $C{rels}{$id.'-'.$xid} = [ ($C{type} eq 'v' ? \%VN_RELATION : \%PRODUCER_RELATION)->{$xrel}{reverse}, $xoff ] if $id < $xid;
- $C{rels}{$xid.'-'.$id} = [ $xrel, $xoff ] if $id > $xid;
-
- # New node? Get its relations too.
- if(!exists $C{nodes}{$xid}) {
- $C{nodes}{$xid} = 0;
- getrelid $xid;
- }
- }
-
- # Wait for other node relations to come in.
- return if grep !$_, values %{$C{nodes}};
-
- # For VNs: If the graph has more than 30 nodes and there are unofficial
- # links, start again, this time throwing away the unofficial links.
- # XXX: This is an ugly hack.
- # - This would remove unofficial links between VNs that are in the graph anyway.
- # - It can result in graphs with just a single VN node and no links.
- # - How well does this work together with the current caching mechanism? It's
- # possible that a distant VN doesn't get its relation graph updated because
- # it's being excluded here.
- if($C{type} eq 'v' && scalar keys %{$C{nodes}} > 30 && grep !$_->[1], values %{$C{rels}}) {
- AE::log info => "Graph for $C{type}$C{id} is too large, re-creating graph without unofficial links";
- return creategraph v => $C{id}, 1, $C{sqlt};
- }
-
- # do we have all relations now? get node info
- my @ids = keys %{$C{nodes}};
- my $ids = join(', ', map '$'.$_, 1..@ids);
- AE::log debug => "Fetching node information for $C{type}:".join ', ', @ids;
- pg_cmd $C{type} eq 'v'
- ? "SELECT id, title, c_released AS date, array_to_string(c_languages, '/') AS lang FROM vn WHERE id IN($ids) ORDER BY c_released"
- : "SELECT id, name, lang, type FROM producers WHERE id IN($ids) ORDER BY name",
- [ @ids ], \&builddot;
-}
-
-
-sub builddot {
- my($res, $time) = @_;
- return if pg_expect $res, 1, $C{id};
- $C{sqlt} += $time;
-
- my $gv =
- qq|graph rgraph {\n|.
- qq|\tnode [ fontname = "$O{font}", shape = "plaintext",|.
- qq| fontsize = $O{fsize}[0], fontcolor = "#333333", color = "#111111" ]\n|.
- qq|\tedge [ labeldistance = 2.5, labelangle = -20, labeljust = 1, minlen = 2, dir = "both",|.
- qq| fontname = $O{font}, fontsize = $O{fsize}[1], arrowsize = 0.7, color = "#111111", fontcolor = "#333333" ]\n|;
-
- # insert all nodes and relations
- my %nodes = map +($_->{id}, $_), $res->rowsAsHashes;
- $gv .= $C{type} eq 'v' ? gv_vnnode($nodes{$_}) : gv_prodnode($nodes{$_}) for keys %nodes;
- $gv .= $C{type} eq 'v' ? gv_vnrels($C{rels}, \%nodes) : gv_prodrels($C{rels}, \%nodes);
-
- $gv .= "}\n";
-
- rundot($gv);
-}
-
-
-sub gv_vnnode {
- my $n = shift;
-
- my $date = sprintf '%08d', $n->{date};
- $date =~ s{^([0-9]{4})([0-9]{2})([0-9]{2})$}{
- $1 == 0 ? 'unknown'
- : $1 == 9999 ? 'TBA'
- : $2 == 99 ? $1
- : $3 == 99 ? "$1-$2" : "$1-$2-$3"
- }e;
-
- my $title = $n->{title};
- $title = substr($title, 0, 27).'...' if length($title) > 30;
- $title =~ s/&/&amp;/g;
- $title =~ s/>/&gt;/g;
- $title =~ s/</&lt;/g;
-
- my $tooltip = $n->{title};
- $tooltip =~ s/\\/\\\\/g;
- $tooltip =~ s/"/\\"/g;
-
- return sprintf
- qq|\tv%d [ id = "node_v%1\$d", URL = "/v%1\$d", tooltip = "%s", label=<|.
- q|<TABLE CELLSPACING="0" CELLPADDING="1" BORDER="0" CELLBORDER="1" BGCOLOR="#222222">|.
- q|<TR><TD COLSPAN="2" ALIGN="CENTER" CELLPADDING="2"><FONT POINT-SIZE="%d"> %s </FONT></TD></TR>|.
- q|<TR><TD> %s </TD><TD> %s </TD></TR>|.
- qq|</TABLE>> ]\n|,
- $n->{id}, encode_utf8($tooltip), $O{fsize}[2], encode_utf8($title), $date, $n->{lang}||'N/A';
-}
-
-
-sub gv_vnrels {
- my($rels, $vns) = @_;
- my $r = '';
-
- # @rels = ([ vid1, vid2, relation, official, date1, date2 ], ..), for easier processing
- my @rels = map {
- /^([0-9]+)-([0-9]+)$/;
- [ $1, $2, @{$rels->{$_}}, $vns->{$1}{date}, $vns->{$2}{date} ]
- } keys %$rels;
-
- # insert all edges, ordered by release date
- for (sort { ($a->[4]>$a->[5]?$a->[5]:$a->[4]) <=> ($b->[4]>$b->[5]?$b->[5]:$b->[4]) } @rels) {
- # [older game] -> [newer game]
- if($_->[5] > $_->[4]) {
- ($_->[0], $_->[1]) = ($_->[1], $_->[0]);
- $_->[2] = $VN_RELATION{$_->[2]}{reverse};
- }
- my $rel = $VN_RELATION{$_->[2]}{txt};
- my $rev = $VN_RELATION{ $VN_RELATION{$_->[2]}{reverse} }{txt};
- my $style = $_->[3] ? '' : ', style="dotted"';
- my $label = $rev ne $rel
- ? qq|headlabel = "$rel" taillabel = "${rev}" $style|
- : qq|label = "$rel" $style|;
- $r .= qq|\tv$$_[1] -- v$$_[0] [ $label ]\n|;
- }
- $r;
-}
-
-
-sub gv_prodnode {
- my $n = shift;
-
- my $name = $n->{name};
- $name = substr($name, 0, 27).'...' if length($name) > 30;
- $name =~ s/&/&amp;/g;
- $name =~ s/>/&gt;/g;
- $name =~ s/</&lt;/g;
-
- my $tooltip = $n->{name};
- $tooltip =~ s/\\/\\\\/g;
- $tooltip =~ s/"/\\"/g;
-
- return sprintf
- qq|\tp%d [ id = "node_p%1\$d", URL = "/p%1\$d", tooltip = "%s", label=<|.
- q|<TABLE CELLSPACING="0" CELLPADDING="1" BORDER="0" CELLBORDER="1" BGCOLOR="#222222">|.
- q|<TR><TD COLSPAN="2" ALIGN="CENTER" CELLPADDING="2"><FONT POINT-SIZE="%d"> %s </FONT></TD></TR>|.
- q|<TR><TD ALIGN="CENTER"> %s </TD><TD ALIGN="CENTER"> %s </TD></TR>|.
- qq|</TABLE>> ]\n|,
- $n->{id}, encode_utf8($tooltip), $O{fsize}[2], encode_utf8($name),
- $LANGUAGE{$n->{lang}}, $PRODUCER_TYPE{$n->{type}};
-}
-
-
-sub gv_prodrels {
- my($rels, $prods) = @_;
- my $r = '';
-
- for (keys %$rels) {
- /^([0-9]+)-([0-9]+)$/;
- my $p1 = $prods->{$1};
- my $p2 = $prods->{$2};
-
- my $rel = $PRODUCER_RELATION{$rels->{$_}[0]}{txt};
- my $rev = $PRODUCER_RELATION{ $PRODUCER_RELATION{$rels->{$_}[0]}{reverse} }{txt};
- my $label = $rev ne $rel
- ? qq|headlabel = "$rev", taillabel = "$rel"|
- : qq|label = "$rel"|;
- $r .= qq|\tp$p1->{id} -- p$p2->{id} [ $label ]\n|;
- }
- $r;
-}
-
-
-sub rundot {
- my $gv = shift;
- AE::log trace => "Running graphviz, dot:\n$gv";
-
- my $svg;
- my $cv = run_cmd [ $O{dot}, '-Tsvg' ],
- '<', \$gv,
- '>', \$svg,
- '2>', sub { AE::log warn => "STDERR from graphviz: $_[0]" if $_[0]; };
-
- $cv->cb(sub {
- return AE::log warn => 'graphviz failed' if shift->recv;
- processgraph($svg);
- });
-}
-
-
-sub processgraph {
- my $data = shift;
-
- # Before saving the SVG output, we'll modify it a little:
- # - Remove comments
- # - Remove <title> elements (unused)
- # - Remove id attributes (unused)
- # - Remove first <polygon> element (emulates the background color)
- # - Replace stroke and fill attributes with classes (so that coloring is done in CSS)
- my $svg = '';
- my $w = TUWF::XML->new(write => sub { $svg .= shift });
- my $p = XML::Parser->new;
- $p->setHandlers(
- Start => sub {
- my($expat, $el, %attr) = @_;
- return if $el eq 'title' || $expat->in_element('title');
- return if $el eq 'polygon' && $expat->depth == 2;
-
- $attr{class} = 'border' if $attr{stroke} && $attr{stroke} eq '#111111';
- $attr{class} = 'nodebg' if $attr{fill} && $attr{fill} eq '#222222';
-
- delete @attr{qw|stroke fill|};
- delete $attr{id} if $attr{id} && $attr{id} !~ /^node_[vp]\d+$/;
- $w->tag($el, %attr, $el eq 'path' || $el eq 'polygon' ? undef : ());
- },
- End => sub {
- my($expat, $el) = @_;
- return if $el eq 'title' || $expat->in_element('title');
- return if $el eq 'polygon' && $expat->depth == 2;
- $w->end($el) if $el ne 'path' && $el ne 'polygon';
- },
- Char => sub {
- my($expat, $str) = @_;
- return if $expat->in_element('title');
- $w->txt($str) if $str !~ /^[\s\t\r\n]*$/s;
- }
- );
- $p->parsestring($data);
-
- # save the processed SVG in the database and fetch graph ID
- AE::log trace => "Processed SVG:\n$svg";
- pg_cmd 'INSERT INTO relgraphs (svg) VALUES ($1) RETURNING id', [ $svg ], \&save_rgraph;
-}
-
-
-sub save_rgraph {
- my($res, $time) = @_;
- return if pg_expect $res, 1;
- $C{sqlt} += $time;
-
- my $graphid = $res->value(0,0);
- my @ids = sort keys %{$C{nodes}};
- my $ids = join ',', map '$'.$_, 2..@ids+1;
- my $table = $C{type} eq 'v' ? 'vn' : 'producers';
-
- pg_cmd "UPDATE $table SET rgraph = \$1 WHERE id IN($ids)",
- [ $graphid, @ids ],
- sub {
- my($res, $time) = @_;
- return if pg_expect $res, 0;
- $C{sqlt} += $time;
-
- AE::log info => sprintf 'Generated relation graph #%d in %.2fs (%.2fs SQL), %s: %s',
- $graphid, AE::time-$C{start}, $C{sqlt}, $C{type}, join ',', @ids;
-
- %C = ();
- check_rg;
- };
-}
-
-
-1;
diff --git a/lib/Multi/Wikidata.pm b/lib/Multi/Wikidata.pm
index d54fbc8b..44f49a43 100644
--- a/lib/Multi/Wikidata.pm
+++ b/lib/Multi/Wikidata.pm
@@ -94,7 +94,7 @@ sub save {
my $v = $_->{mainsnak}{datavalue}{value};
if(ref $v) {
AE::log warn => "Q$id has a non-scalar value for '$p'";
- } elsif($_->{qualifiers}{P582}) {
+ } elsif($_->{qualifiers}{P582} || $_->{qualifiers}{P8554}) {
AE::log info => "Q$id excluding property '$p' because it has an 'end time'";
} elsif(defined $v) {
push @val, $v;
diff --git a/lib/PWLookup.pm b/lib/PWLookup.pm
deleted file mode 100644
index 6e2f03e4..00000000
--- a/lib/PWLookup.pm
+++ /dev/null
@@ -1,155 +0,0 @@
-#!/usr/bin/perl
-
-# This script is based on the btree.pl that I wrote as part of a little
-# experiment: https://dev.yorhel.nl/doc/pwlookup
-#
-# It is hardcoded to use gzip (because that's available in a standard Perl
-# distribution) compression level 9 (saves a few MiB with no noticable impact
-# on lookup performance) with 4k block sizes (because that is fast enough and
-# offers good compression).
-#
-# Creating the database:
-#
-# perl PWlookup.pm create <sorted-dictionary >dbfile
-#
-# Extracting all passwords from the database:
-#
-# perl PWLookup.pm extract dbfile >sorted-dictionary
-#
-# Performing lookups (from the CLI):
-#
-# perl PWLookup.pm lookup dbfile query
-#
-# Performing lookups (from Perl):
-#
-# use PWLookup;
-# my $pw_exists = PWLookup::lookup($dbfile, $query);
-
-package PWLookup;
-
-use strict;
-use warnings;
-use v5.10;
-use Compress::Zlib qw/compress uncompress/;
-use Encode qw/encode_utf8 decode_utf8/;
-
-my $blocksize = 4096;
-
-# Encode/decode a block reference, [ leaf, length, offset ]. Encoded in a single 64bit integer as (leaf | length << 1 | offset << 16)
-sub eref($) { pack 'Q', ($_[0][0]?1:0) | $_[0][1]<<1 | $_[0][2]<<16 }
-sub dref($) { my $v = unpack 'Q', $_[0]; [$v&1, ($v>>1)&((1<<15)-1), $v>>16] }
-
-# Write a block and return its reference.
-sub writeblock {
- state $off = 0;
- my $buf = compress($_[0], 9);
- my $len = length $buf;
- print $buf;
- my $oldoff = $off;
- $off += $len;
- [$_[1], $len, $oldoff]
-}
-
-# Read a block given a file handle and a reference.
-sub readblock {
- my($F, $ref) = @_;
- die $! if !sysseek $F, $ref->[2], 0;
- die $! if $ref->[1] != sysread $F, (my $buf), $ref->[1];
- uncompress($buf)
-}
-
-sub encode {
- my $leaf = "\0";
- my @nodes = ('');
- my $ref;
-
- my $flush = sub {
- my $minsize = $_[0];
- return if $minsize > length $leaf;
-
- my $str = $leaf =~ /^\x00([^\x00]*)/ && $1;
- $ref = writeblock $leaf, 1;
- $leaf = "\0";
- $nodes[0] .= "$str\x00".eref($ref);
-
- for(my $i=0; $i <= $#nodes && $minsize < length $nodes[$i]; $i++) {
- my $str = $nodes[$i] =~ s/^([^\x00]*)\x00// && $1;
- $ref = writeblock $nodes[$i], 0;
- $nodes[$i] = '';
- if($minsize || $nodes[$i+1]) {
- $nodes[$i+1] ||= '';
- $nodes[$i+1] .= "$str\x00".eref($ref);
- }
- }
- };
-
- my $last;
- while((my $p = <STDIN>)) {
- chomp($p);
- # No need to store passwords that are rejected by form validation
- if(!length($p) || length($p) > 500 || !eval { decode_utf8((local $_=$p), Encode::FB_CROAK); 1 } || $p =~ /\x00/) {
- warn sprintf "Rejecting: %s\n", ($p =~ s/([^\x21-\x7e])/sprintf '%%%02x', ord $1/ger);
- next;
- }
- # Extra check to make sure the input is unique and sorted according to Perl's string comparison
- if(defined($last) && $last ge $p) {
- warn "Rejecting due to uniqueness or incorrect sorting: $p\n";
- next;
- }
- $leaf .= "$p\0";
- $flush->($blocksize);
- }
- $flush->(0);
- print eref $ref;
-}
-
-
-sub lookup_rec {
- my($F, $q, $ref) = @_;
- my $buf = readblock $F, $ref;
- if($ref->[0]) {
- return $buf =~ /\x00\Q$q\E\x00/;
- } else {
- while($buf =~ /(.{8})([^\x00]+)\x00/sg) {
- return lookup_rec($F, $q, dref $1) if $q lt $2;
- }
- return lookup_rec($F, $q, dref substr $buf, -8)
- }
-}
-
-sub lookup {
- my($f, $q) = @_;
- open my $F, '<', $f or die $!;
- sysseek $F, -8, 2 or die $!;
- die $! if 8 != sysread $F, (my $buf), 8;
- lookup_rec($F, encode_utf8($q), dref $buf)
-}
-
-
-sub extract_rec {
- my($F, $ref) = @_;
- my $buf = readblock $F, $ref;
- if($ref->[0]) {
- print "$1\n" while $buf =~ /\x00([^\x00]+)/g;
- } else {
- extract_rec($F, dref $1) while $buf =~ /(.{8})[^\x00]+\x00/sg;
- extract_rec($F, dref substr $buf, -8)
- }
-}
-
-sub extract {
- my($f) = @_;
- open my $F, '<', $f or die $!;
- sysseek $F, -8, 2 or die $!;
- die $! if 8 != sysread $F, (my $buf), 8;
- extract_rec($F, dref $buf)
-}
-
-
-if(!caller) {
- encode() if $ARGV[0] eq 'create';
- extract($ARGV[1]) if $ARGV[0] eq 'extract';
- printf "%s\n", lookup($ARGV[1], decode_utf8 $ARGV[2]) ? 'Found' : 'Not found' if $ARGV[0] eq 'lookup';
-}
-
-1;
diff --git a/lib/SkinFile.pm b/lib/SkinFile.pm
deleted file mode 100644
index 78608f89..00000000
--- a/lib/SkinFile.pm
+++ /dev/null
@@ -1,74 +0,0 @@
-
-package SkinFile;
-
-use strict;
-use warnings;
-use Fcntl 'LOCK_SH', 'SEEK_SET';
-
-
-sub new {
- my($class, $root, $open) = @_;
- my $self = bless { root => $root }, $class;
- $self->open($open) if $open;
- return $self;
-}
-
-
-sub list {
- return map /\/([^\/]+)\/conf/?$1:(), glob "$_[0]{root}/*/conf";
-}
-
-
-sub open {
- my($self, $dir, $force) = @_;
- return if $self->{"s_$dir"} && !$force;
- my %o;
- open my $F, '<:utf8', "$self->{root}/$dir/conf" or die $!;
- flock $F, LOCK_SH or die $!;
- seek $F, 0, SEEK_SET or die $!;
- local $_;
- while(<$F>) {
- chomp;
- s/\r//g;
- s{[\t\s]*//.+$}{};
- next if !/^([a-z0-9]+)[\t\s]+(.+)$/;
- $o{$1} = $2;
- }
- close $F;
- $self->{"s_$dir"} = \%o;
- $self->{opened} = $dir;
-}
-
-
-sub get {
- my($self, $dir, $var) = @_;
- $self->open($dir) if defined $var;
- $var = $dir if !defined $var;
- $var ? $self->{"s_$self->{opened}"}{$var} : keys %{$self->{"s_$self->{opened}"}};
-}
-
-
-1;
-
-
-__END__
-
-=pod
-
-=head1 NAME
-
-SkinFile - Simple object oriented interface to parsing skin configuration files
-
-=head1 USAGE
-
- use SkinFile;
- my $s = SkinFile->new($dir);
- my @skins = $s->list;
-
- $s->open($skins[0]);
- my $name = $s->get('name');
-
- # same as above, but in one function
- my $name = $s->get($skins[0], 'name');
-
-
diff --git a/lib/VNDB/BBCode.pm b/lib/VNDB/BBCode.pm
index 3c8964dc..950dcb8b 100644
--- a/lib/VNDB/BBCode.pm
+++ b/lib/VNDB/BBCode.pm
@@ -5,9 +5,13 @@ use warnings;
use Exporter 'import';
use TUWF::XML 'xml_escape';
-our @EXPORT = qw/bb2html bb2text bb_subst_links/;
+our @EXPORT = qw/bb_format bb_subst_links/;
# Supported BBCode:
+# [b] .. [/b]
+# [i] .. [/i]
+# [u] .. [/u]
+# [s] .. [/s]
# [spoiler] .. [/spoiler]
# [quote] .. [/quote]
# [code] .. [/code]
@@ -17,7 +21,8 @@ our @EXPORT = qw/bb2html bb2text bb_subst_links/;
# dblink: v+, v+.+, d+#+, d+#+.+
#
# Permitted nesting of formatting codes:
-# spoiler -> url, raw, link, dblink
+# inline = b,i,u,s,spoiler
+# inline -> inline, url, raw, link, dblink
# quote -> anything
# code -> nothing
# url -> raw
@@ -29,10 +34,18 @@ our @EXPORT = qw/bb2html bb2text bb_subst_links/;
# Returns: ($token, @arg) on successful parse, () otherwise.
# Trivial open and close actions
+sub _b_start { if(lc$_[1] eq '[b]') { push @{$_[0]}, 'b'; ('b_start') } else { () } }
+sub _i_start { if(lc$_[1] eq '[i]') { push @{$_[0]}, 'i'; ('i_start') } else { () } }
+sub _u_start { if(lc$_[1] eq '[u]') { push @{$_[0]}, 'u'; ('u_start') } else { () } }
+sub _s_start { if(lc$_[1] eq '[s]') { push @{$_[0]}, 's'; ('s_start') } else { () } }
sub _spoiler_start { if(lc$_[1] eq '[spoiler]') { push @{$_[0]}, 'spoiler'; ('spoiler_start') } else { () } }
sub _quote_start { if(lc$_[1] eq '[quote]') { push @{$_[0]}, 'quote'; ('quote_start') } else { () } }
sub _code_start { if(lc$_[1] eq '[code]') { push @{$_[0]}, 'code'; ('code_start') } else { () } }
sub _raw_start { if(lc$_[1] eq '[raw]') { push @{$_[0]}, 'raw'; ('raw_start') } else { () } }
+sub _b_end { if(lc$_[1] eq '[/b]') { pop @{$_[0]}; ('b_end' ) } else { () } }
+sub _i_end { if(lc$_[1] eq '[/i]') { pop @{$_[0]}; ('i_end' ) } else { () } }
+sub _u_end { if(lc$_[1] eq '[/u]') { pop @{$_[0]}; ('u_end' ) } else { () } }
+sub _s_end { if(lc$_[1] eq '[/s]') { pop @{$_[0]}; ('s_end' ) } else { () } }
sub _spoiler_end { if(lc$_[1] eq '[/spoiler]') { pop @{$_[0]}; ('spoiler_end') } else { () } }
sub _quote_end { if(lc$_[1] eq '[/quote]' ) { pop @{$_[0]}; ('quote_end' ) } else { () } }
sub _code_end { if(lc$_[1] eq '[/code]' ) { pop @{$_[0]}; ('code_end' ) } else { () } }
@@ -65,10 +78,15 @@ sub _link {
# Permitted actions to take in each state. The actions are run in order, if
# none succeed then the token is passed through as text.
# The "current state" is the most recent tag in the stack, or '' if no tags are open.
+my @INLINE = (\&_link, \&_url_start, \&_raw_start, \&_b_start, \&_i_start, \&_u_start, \&_s_start, \&_spoiler_start);
my %STATE = (
- '' => [ \&_link, \&_url_start, \&_raw_start, \&_spoiler_start, \&_quote_start, \&_code_start],
- spoiler => [\&_spoiler_end, \&_link, \&_url_start, \&_raw_start],
- quote => [\&_quote_end, \&_link, \&_url_start, \&_raw_start, \&_spoiler_start, \&_quote_start, \&_code_start],
+ '' => [ @INLINE, \&_quote_start, \&_code_start],
+ b => [\&_b_end, @INLINE],
+ i => [\&_i_end, @INLINE],
+ u => [\&_u_end, @INLINE],
+ s => [\&_s_end, @INLINE],
+ spoiler => [\&_spoiler_end, @INLINE],
+ quote => [\&_quote_end, @INLINE, \&_quote_start, \&_code_start],
code => [\&_code_end ],
url => [\&_url_end, \&_raw_start],
raw => [\&_raw_end ],
@@ -88,6 +106,14 @@ my %STATE = (
#
# Tags:
# text -> literal text, $raw is the text to display
+# b_start -> start bold
+# b_end -> end
+# i_start -> start italic
+# i_end -> end
+# u_start -> start underline
+# u_end -> end
+# s_start -> start strike
+# s_end -> end
# spoiler_start -> start a spoiler
# spoiler_end -> end
# quote_start -> start a quote
@@ -111,11 +137,11 @@ sub parse {
my @stack;
while($raw =~ m{(?:
- \[ \/? (?i: spoiler|quote|code|url|raw ) [^\s\]]* \] | # tag
- d[1-9][0-9]* \# [1-9][0-9]* (?: \.[1-9][0-9]* )? | # d+#+[.+]
- [tdvprcs][1-9][0-9]*\.[1-9][0-9]* | # v+.+
- [tdvprcsugi][1-9][0-9]* | # v+
- (?:https?|ftp)://[^><"\n\s\]\[]+[\d\w=/-] # link
+ \[ \/? (?i: b|i|u|s|spoiler|quote|code|url|raw ) [^\s\]]* \] | # tag
+ d[1-9][0-9]* \# [1-9][0-9]* (?: \.[1-9][0-9]* )? | # d+#+[.+]
+ [tdvprcswgi][1-9][0-9]*\.[1-9][0-9]* | # v+.+
+ [tdvprcsugiw][1-9][0-9]* | # v+
+ (?:https?|ftp)://[^><"\n\s\]\[]+[\d\w=/-] # link
)}xg) {
my $token = $&;
my $pre = substr $raw, $last, $-[0]-$last;
@@ -147,103 +173,111 @@ FINAL:
}
-sub bb2html {
- my($input, $maxlength, $charspoil) = @_;
+# Options:
+# maxlength => 0/$n - truncate after $n visible characters
+# inline => 0/1 - don't insert line breaks and don't format block elements
+#
+# One of:
+# text => 0/1 - format as plain text, no tags
+# onlyids => 0/1 - format as HTML, but only convert VNDBIDs, leave the rest alone (including [spoiler]s)
+# default: format all to HTML.
+#
+# One of:
+# delspoil => 0/1 - delete [spoiler] tags and its contents
+# replacespoil => 0/1 - replace [spoiler] tags with a "hidden by spoiler settings" message
+# keepsoil => 0/1 - keep the contents of spoiler tags without any special formatting
+# default: format as <span class="spoiler">..
+sub bb_format {
+ my($input, %opt) = @_;
+ $opt{delspoil} = 1 if $opt{text} && !$opt{keepspoil};
my $incode = 0;
+ my $inspoil = 0;
my $rmnewline = 0;
my $length = 0;
my $ret = '';
# escapes, returns string, and takes care of $length and $maxlength; also
# takes care to remove newlines and double spaces when necessary
- my $e = sub {
+ my sub e {
local $_ = shift;
s/^\n// if $rmnewline && $rmnewline--;
s/\n{5,}/\n\n/g if !$incode;
s/ +/ /g if !$incode;
$length += length $_;
- if($maxlength && $length > $maxlength) {
- $_ = substr($_, 0, $maxlength-$length);
+ if($opt{maxlength} && $length > $opt{maxlength}) {
+ $_ = substr($_, 0, $opt{maxlength}-$length);
s/\W+\w*$//; # cleanly cut off on word boundary
}
- s/&/&amp;/g;
- s/>/&gt;/g;
- s/</&lt;/g;
- s/\n/<br>/g if !$maxlength;
- s/\n/ /g if $maxlength;
+ if(!$opt{text}) {
+ s/&/&amp;/g;
+ s/>/&gt;/g;
+ s/</&lt;/g;
+ s/\n/<br>/g if !$opt{inline};
+ }
+ s/\n/ /g if $opt{inline};
$_;
};
parse $input, sub {
my($raw, $tag, @arg) = @_;
- #$ret .= "$tag {$raw}\n";
- #return 1;
+ return 1 if $inspoil && $tag ne 'spoiler_end' && ($opt{delspoil} || $opt{replacespoil});
if($tag eq 'text') {
- $ret .= $e->($raw);
-
- } elsif($tag eq 'spoiler_start') {
- $ret .= !$charspoil
- ? '<b class="spoiler">'
- : '<b class="grayedout charspoil charspoil_-1">&lt;hidden by spoiler settings&gt;</b><span class="charspoil charspoil_2">';
- } elsif($tag eq 'spoiler_end') {
- $ret .= !$charspoil ? '</b>' : '</span>';
+ $ret .= e $raw;
+ } elsif($tag eq 'dblink') {
+ (my $link = $raw) =~ s/^d(\d+)\.(\d+)\.(\d+)$/d$1#$2.$3/;
+ $ret .= $opt{text} ? e $raw : sprintf '<a href="/%s">%s</a>', $link, e $raw;
+
+ } elsif($opt{idonly}) {
+ $ret .= e $raw;
+
+ } elsif($tag eq 'b_start') { $ret .= $opt{text} ? e '*' : '<strong>'
+ } elsif($tag eq 'b_end') { $ret .= $opt{text} ? e '*' : '</strong>'
+ } elsif($tag eq 'i_start') { $ret .= $opt{text} ? e '/' : '<em>'
+ } elsif($tag eq 'i_end') { $ret .= $opt{text} ? e '/' : '</em>'
+ } elsif($tag eq 'u_start') { $ret .= $opt{text} ? e '_' : '<span class="underline">'
+ } elsif($tag eq 'u_end') { $ret .= $opt{text} ? e '_' : '</span>'
+ } elsif($tag eq 's_start') { $ret .= $opt{text} ? e '-' : '<s>'
+ } elsif($tag eq 's_end') { $ret .= $opt{text} ? e '-' : '</s>'
} elsif($tag eq 'quote_start') {
- $ret .= '<div class="quote">' if !$maxlength;
+ $ret .= $opt{text} || $opt{inline} ? e '"' : '<div class="quote">';
$rmnewline = 1;
} elsif($tag eq 'quote_end') {
- $ret .= '</div>' if !$maxlength;
+ $ret .= $opt{text} || $opt{inline} ? e '"' : '</div>';
$rmnewline = 1;
} elsif($tag eq 'code_start') {
- $ret .= '<pre>' if !$maxlength;
+ $ret .= $opt{text} || $opt{inline} ? e '`' : '<pre>';
$rmnewline = 1;
$incode = 1;
} elsif($tag eq 'code_end') {
- $ret .= '</pre>' if !$maxlength;
+ $ret .= $opt{text} || $opt{inline} ? e '`' : '</pre>';
$rmnewline = 1;
$incode = 0;
+ } elsif($tag eq 'spoiler_start') {
+ $inspoil = 1;
+ $ret .= $opt{delspoil} || $opt{keepspoil} ? ''
+ : $opt{replacespoil} ? '<small>&lt;hidden by spoiler settings&gt;</small>'
+ : '<span class="spoiler">';
+ } elsif($tag eq 'spoiler_end') {
+ $inspoil = 0;
+ $ret .= $opt{delspoil} || $opt{keepspoil} || $opt{replacespoil} ? '' : '</span>';
+
} elsif($tag eq 'url_start') {
- $ret .= sprintf '<a href="%s" rel="nofollow">', xml_escape($arg[0]);
+ $ret .= $opt{text} ? '' : sprintf '<a href="%s" rel="nofollow">', xml_escape($arg[0]);
} elsif($tag eq 'url_end') {
- $ret .= '</a>';
+ $ret .= $opt{text} ? '' : '</a>';
} elsif($tag eq 'link') {
- $ret .= sprintf '<a href="%s" rel="nofollow">%s</a>', xml_escape($raw), $e->('link');
-
- } elsif($tag eq 'dblink') {
- (my $link = $raw) =~ s/^d(\d+)\.(\d+)\.(\d+)$/d$1#$2.$3/;
- $ret .= sprintf '<a href="/%s">%s</a>', $link, $e->($raw);
+ $ret .= $opt{text} ? e $raw : sprintf '<a href="%s" rel="nofollow">%s</a>', xml_escape($raw), e 'link';
}
- !$maxlength || $length < $maxlength;
- };
- $ret;
-}
-
-
-# Convert bbcode into plain text, stripping all tags and spoilers. [url] tags
-# only display the title.
-sub bb2text {
- my $input = shift;
-
- my $inspoil = 0;
- my $ret = '';
- parse $input, sub {
- my($raw, $tag, @arg) = @_;
- if($tag eq 'spoiler_start') {
- $inspoil = 1;
- } elsif($tag eq 'spoiler_end') {
- $inspoil = 0;
- } else {
- $ret .= $raw if !$inspoil && $tag !~ /_(start|end)$/;
- }
- 1;
+ !$opt{maxlength} || $length < $opt{maxlength};
};
$ret;
}
@@ -261,26 +295,15 @@ sub bb_subst_links {
my %lookup;
parse $msg, sub {
my($code, $tag) = @_;
- $lookup{$1}{$2} = 1 if $tag eq 'dblink' && $code =~ /^(.)(\d+)/;
+ $lookup{$1} = 1 if $tag eq 'dblink' && $code =~ /^([vcpgis]\d+)$/;
1;
};
return $msg unless %lookup;
- # Now resolve the links
- state $types = { # Query must return 'id' and 'name' columns, list of IDs will be appended to it.
- v => 'SELECT id, title AS name FROM vn WHERE id IN',
- c => 'SELECT id, name FROM chars WHERE id IN',
- p => 'SELECT id, name FROM producers WHERE id IN',
- g => 'SELECT id, name FROM tags WHERE id IN',
- i => 'SELECT id, name FROM traits WHERE id IN',
- s => 'SELECT s.id, sa.name FROM staff_alias sa JOIN staff s ON s.aid = sa.aid WHERE s.id IN',
- };
- my %links;
- for my $type (keys %$types) {
- next if !$lookup{$type};
- my $lst = $TUWF::OBJ->dbAlli($types->{$type}, [keys %{$lookup{$type}}]);
- $links{$type . $_->{id}} = $_->{name} for @$lst;
- }
+ my $first = 0;
+ my %links = map +($_->{id}, $_->{title}), $TUWF::OBJ->dbAlli(
+ 'SELECT id, title[1+1] FROM (VALUES', (map +($first++ ? ',(' : '(', \"$_", '::vndbid)'), sort keys %lookup), ') n(id), item_info(NULL, n.id, NULL)'
+ )->@*;
return $msg unless %links;
# Now substitute
diff --git a/lib/VNDB/Config.pm b/lib/VNDB/Config.pm
index 11f1822a..050a0124 100644
--- a/lib/VNDB/Config.pm
+++ b/lib/VNDB/Config.pm
@@ -3,13 +3,19 @@ package VNDB::Config;
use strict;
use warnings;
use Exporter 'import';
+use Cwd 'abs_path';
our @EXPORT = ('config');
my $ROOT = $INC{'VNDB/Config.pm'} =~ s{/lib/VNDB/Config\.pm$}{}r;
+my $GEN = abs_path($ENV{VNDB_GEN} // "$ROOT/gen");
+my $VAR = abs_path($ENV{VNDB_VAR} // "$ROOT/var");
# Default config options
my $config = {
- url => 'http://localhost:3000',
+ gen_path => $GEN,
+ var_path => $VAR,
+
+ url => 'http://localhost:3000',
tuwf => {
db_login => [ 'dbi:Pg:dbname=vndb', 'vndb_site', undef ],
@@ -17,55 +23,37 @@ my $config = {
},
skin_default => 'angel',
- placeholder_img => 'http://s.vndb.org/s/angel/bg.jpg', # Used in the og:image meta tag
+ placeholder_img => 'https://s.vndb.org/s/angel-bg.jpg', # Used in the og:image meta tag
scrypt_args => [ 65536, 8, 1 ], # N, r, p
scrypt_salt => 'another-random-string',
form_salt => 'a-private-string-here',
source_url => 'https://code.blicky.net/yorhel/vndb',
admin_email => 'contact@vndb.org',
login_throttle => [ 24*3600/10, 24*3600 ], # interval between attempts, max burst (10 a day)
+ reset_throttle => [ 24*3600/2, 24*3600 ], # interval between attempts, max burst (2 a day)
board_edit_time => 7*24*3600, # Time after which posts become immutable
- poll_options => 20, # max number of options in discussion board polls
-
- engines => [ grep $_, split /\s*\n\s*/, q{
- BGI/Ethornell
- CatSystem2
- codeX RScript
- EntisGLS
- Flash Player
- Ikura GDL
- KiriKiri
- LiveMaker
- Majiro
- NScripter
- QLIE
- RPG Maker
- RealLive
- Ren'Py
- Shiina Rio
- SiglusEngine
- TyranoScript
- Unity
- YU-RIS
- }],
-
- dlsite_url => 'https://www.dlsite.com/%s/work/=/product_id/%%s.html',
- denpa_url => 'https://denpasoft.com/products/%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',
- mg_main_url => 'https://www.mangagamer.com/detail.php?product_code=%d',
+ graphviz_path => '/usr/bin/dot',
+ imgproc_path => "$GEN/imgproc",
+ trace_log => 0,
+ # Put the site in full read-only mode; Login is disabled and nothing is written to the DB. Handy for migrations.
+ read_only => 0,
+
+ location_db => undef, # Optional path to a libloc database for IP geolocation
+
+ scr_size => [ 136, 102 ], # w*h of screenshot thumbnails
+ ch_size => [ 256, 300 ], # max. w*h of char images
+ cv_size => [ 256, 400 ], # max. w*h of cover images
+
+ api_throttle => [ 60, 5 ], # execution time multiplier, allowed burst
Multi => {
Core => {},
- Feed => {},
Maintenance => {},
- RG => {},
},
};
-my $config_file = do $ROOT.'/data/conf.pl';
+my $config_file = -e "$VAR/conf.pl" ? do("$VAR/conf.pl") || die $! : {};
my $config_merged;
sub config {
@@ -76,10 +64,10 @@ sub config {
$c->{tuwf}{$_} = $config_file->{tuwf}{$_} for keys %{ $config_file->{tuwf} || {} };
$c->{url_static} ||= $c->{url};
- $c->{version} ||= `git -C "$ROOT" describe` =~ /^(.+)\-g[0-9a-f]+$/ && $1;
+ $c->{version} ||= `git -C "$ROOT" describe` =~ s/\-g[0-9a-f]+$//rg =~ s/\r?\n//rg;
$c->{root} = $ROOT;
$c->{Multi}{Core}{log_level} ||= 'debug';
- $c->{Multi}{Core}{log_dir} ||= $ROOT.'/data/log';
+ $c->{Multi}{Core}{log_dir} ||= $VAR.'/log';
$c
};
$config_merged
diff --git a/lib/VNDB/DB/Chars.pm b/lib/VNDB/DB/Chars.pm
deleted file mode 100644
index a93ad28c..00000000
--- a/lib/VNDB/DB/Chars.pm
+++ /dev/null
@@ -1,201 +0,0 @@
-
-package VNDB::DB::Chars;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|dbCharFilters dbCharGet dbCharGetRev dbCharRevisionInsert dbCharImageId|;
-
-
-# Character filters shared by dbCharGet and dbVNGet
-sub dbCharFilters {
- my($self, %o) = @_;
- return (
- defined $o{gender} ? ( 'c.gender IN(!l)' => [ ref $o{gender} ? $o{gender} : [$o{gender}] ]) : (),
- defined $o{bloodt} ? ( 'c.bloodt IN(!l)' => [ ref $o{bloodt} ? $o{bloodt} : [$o{bloodt}] ]) : (),
- defined $o{bust_min} ? ( 'c.s_bust >= ?' => $o{bust_min} ) : (),
- defined $o{bust_max} ? ( 'c.s_bust <= ? AND c.s_bust > 0' => $o{bust_max} ) : (),
- defined $o{waist_min} ? ( 'c.s_waist >= ?' => $o{waist_min} ) : (),
- defined $o{waist_max} ? ( 'c.s_waist <= ? AND c.s_waist > 0' => $o{waist_max} ) : (),
- defined $o{hip_min} ? ( 'c.s_hip >= ?' => $o{hip_min} ) : (),
- defined $o{hip_max} ? ( 'c.s_hip <= ? AND c.s_hip > 0' => $o{hip_max} ) : (),
- defined $o{height_min} ? ( 'c.height >= ?' => $o{height_min} ) : (),
- defined $o{height_max} ? ( 'c.height <= ? AND c.height > 0' => $o{height_max} ) : (),
- defined $o{weight_min} ? ( 'c.weight >= ?' => $o{weight_min} ) : (),
- defined $o{weight_max} ? ( 'c.weight <= ?' => $o{weight_max} ) : (),
- defined $o{cup_min} ? ( 'c.cup_size >= ?' => $o{cup_min} ) : (),
- defined $o{cup_max} ? ( 'c.cup_size <= ?' => $o{cup_max} ) : (),
- $o{role} ? (
- 'EXISTS(SELECT 1 FROM chars_vns cvi WHERE cvi.id = c.id AND cvi.role IN(!l))',
- [ ref $o{role} ? $o{role} : [$o{role}] ] ) : (),
- $o{trait_inc} ? (
- 'c.id IN(SELECT cid FROM traits_chars WHERE tid IN(!l) AND spoil <= ? GROUP BY cid HAVING COUNT(tid) = ?)',
- [ ref $o{trait_inc} ? $o{trait_inc} : [$o{trait_inc}], $o{tagspoil}, ref $o{trait_inc} ? $#{$o{trait_inc}}+1 : 1 ]) : (),
- $o{trait_exc} ? (
- 'c.id NOT IN(SELECT cid FROM traits_chars WHERE tid IN(!l))' => [ ref $o{trait_exc} ? $o{trait_exc} : [$o{trait_exc}] ] ) : (),
- $o{va_inc} ? ( 'c.id IN(SELECT ivs.cid FROM vn_seiyuu ivs JOIN staff_alias isa ON isa.aid = ivs.aid WHERE isa.id IN(!l))' => [ ref $o{va_inc} ? $o{va_inc} : [$o{va_inc}] ] ) : (),
- $o{va_exc} ? ( 'c.id NOT IN(SELECT ivs.cid FROM vn_seiyuu ivs JOIN staff_alias isa ON isa.aid = ivs.aid WHERE isa.id IN(!l))' => [ ref $o{va_exc} ? $o{va_exc} : [$o{va_exc}] ] ) : (),
- )
-}
-
-
-# options: id instance tagspoil trait_inc trait_exc char what results page gender bloodt
-# bust_min bust_max waist_min waist_max hip_min hip_max height_min height_max weight_min weight_max role
-# what: extended traits vns changes
-sub dbCharGet {
- my $self = shift;
- my %o = (
- page => 1,
- results => 10,
- what => '',
- tagspoil => 0,
- @_
- );
-
- $o{search} =~ s/%//g if $o{search};
-
- my %where = (
- !$o{id} ? ( 'c.hidden = FALSE' => 1 ) : (),
- $o{id} ? ( 'c.id IN(!l)' => [ ref $o{id} ? $o{id} : [$o{id}] ] ) : (),
- $o{notid} ? ( 'c.id <> ?' => $o{notid} ) : (),
- $o{instance} ? ( 'c.main = ?' => $o{instance} ) : (),
- $o{vid} ? ( 'c.id IN(SELECT id FROM chars_vns WHERE vid = ?)' => $o{vid} ) : (),
- $o{search} ? (
- "(c.name ILIKE ? OR translate(c.original,' ','') ILIKE translate(?,' ','') OR c.alias ILIKE ?)", [ map '%'.$o{search}.'%', 1..3 ] ) : (),
- $o{char} ? (
- 'LOWER(SUBSTR(c.name, 1, 1)) = ?' => $o{char} ) : (),
- defined $o{char} && !$o{char} ? (
- '(ASCII(c.name) < 97 OR ASCII(c.name) > 122) AND (ASCII(c.name) < 65 OR ASCII(c.name) > 90)' => 1 ) : (),
- $self->dbCharFilters(%o),
- );
-
- my @select = (qw|c.id c.name c.original c.gender|);
- push @select, qw|c.hidden c.locked c.alias c.desc c.image c.b_month c.b_day c.s_bust c.s_waist c.s_hip c.height c.weight c.bloodt c.cup_size c.age c.main c.main_spoil| if $o{what} =~ /extended/;
-
- my($r, $np) = $self->dbPage(\%o, q|
- SELECT !s
- FROM chars c
- !W
- ORDER BY c.name|,
- join(', ', @select), \%where
- );
-
- return _enrich($self, $r, $np, 0, $o{what}, $o{vid});
-}
-
-
-sub dbCharGetRev {
- my $self = shift;
- my %o = (what => '', @_);
-
- $o{rev} ||= $self->dbRow('SELECT MAX(rev) AS rev FROM changes WHERE type = \'c\' AND itemid = ?', $o{id})->{rev};
-
- my $select = 'c.itemid AS id, ch.name, ch.original, ch.gender';
- $select .= ', extract(\'epoch\' from c.added) as added, c.comments, c.rev, c.ihid, c.ilock, '.VNWeb::DB::sql_user();
- $select .= ', c.id AS cid, NOT EXISTS(SELECT 1 FROM changes c2 WHERE c2.type = c.type AND c2.itemid = c.itemid AND c2.rev = c.rev+1) AS lastrev';
- $select .= ', ch.alias, ch.desc, ch.image, ch.b_month, ch.b_day, ch.s_bust, ch.s_waist, ch.s_hip, ch.height, ch.weight, ch.bloodt, ch.cup_size, ch.age, ch.main, ch.main_spoil, co.hidden, co.locked' if $o{what} =~ /extended/;
-
- my $r = $self->dbAll(q|
- SELECT !s
- FROM changes c
- JOIN chars co ON co.id = c.itemid
- JOIN chars_hist ch ON ch.chid = c.id
- JOIN users u ON u.id = c.requester
- WHERE c.type = 'c' AND c.itemid = ? AND c.rev = ?|,
- $select, $o{id}, $o{rev}
- );
-
- return _enrich($self, $r, 0, 1, $o{what});
-}
-
-
-sub _enrich {
- my($self, $r, $np, $rev, $what, $vid) = @_;
-
- if(@$r && $what =~ /vns|traits/) {
- my($col, $hist, $colname) = $rev ? ('cid', '_hist', 'chid') : ('id', '', 'id');
- my %r = map {
- $_->{traits} = [];
- $_->{vns} = [];
- ($_->{$col}, $_)
- } @$r;
-
- if($what =~ /traits/) {
- push @{$r{ delete $_->{xid} }{traits}}, $_ for (@{$self->dbAll(qq|
- SELECT ct.$colname AS xid, ct.tid, ct.spoil, t.name, t.sexual, t."group", tg.name AS groupname
- FROM chars_traits$hist ct
- JOIN traits t ON t.id = ct.tid
- JOIN traits tg ON tg.id = t."group"
- WHERE ct.$colname IN(!l)
- ORDER BY tg."order", t.name|, [ keys %r ]
- )});
- }
-
- if($what =~ /vns(?:\((\d+)\))?/) {
- push @{$r{ delete $_->{xid} }{vns}}, $_ for (@{$self->dbAll("
- SELECT cv.$colname AS xid, cv.vid, cv.rid, cv.spoil, cv.role, v.title AS vntitle, r.title AS rtitle
- FROM chars_vns$hist cv
- JOIN vn v ON cv.vid = v.id
- LEFT JOIN releases r ON cv.rid = r.id
- !W
- ORDER BY v.c_released",
- { "cv.$colname IN(!l)" => [[keys %r]], $1 ? ('cv.vid = ?', $1) : () }
- )});
- }
- }
-
- # Depends on the VN revision rather than char revision
- if(@$r && $what =~ /seiyuu/) {
- my %r = map {
- $_->{seiyuu} = [];
- ($_->{id}, $_)
- } @$r;
-
- push @{$r{ delete $_->{cid} }{seiyuu}}, $_ for (@{$self->dbAll(q|
- SELECT vs.cid, s.id AS sid, sa.name, sa.original, vs.note, v.id AS vid, v.title AS vntitle
- FROM vn_seiyuu vs
- JOIN staff_alias sa ON sa.aid = vs.aid
- JOIN staff s ON s.id = sa.id
- JOIN vn v ON v.id = vs.id
- !W
- ORDER BY v.c_released, sa.name|, {
- 's.hidden = FALSE' => 1,
- 'vs.cid IN(!l)' => [[ keys %r ]],
- $vid ? ('v.id = ?' => $vid) : (),
- }
- )});
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Updates the edit_* tables, used from dbItemEdit()
-# Arguments: { columns in chars_rev + traits + vns },
-sub dbCharRevisionInsert {
- my($self, $o) = @_;
-
- my %set = map exists($o->{$_}) ? (qq|"$_" = ?|, $o->{$_}) : (),
- qw|name original alias desc image b_month b_day s_bust s_waist s_hip height weight bloodt cup_size age gender main main_spoil|;
- $self->dbExec('UPDATE edit_chars !H', \%set) if keys %set;
-
- if($o->{traits}) {
- $self->dbExec('DELETE FROM edit_chars_traits');
- $self->dbExec('INSERT INTO edit_chars_traits (tid, spoil) VALUES (?,?)', $_->[0],$_->[1]) for (@{$o->{traits}});
- }
- if($o->{vns}) {
- $self->dbExec('DELETE FROM edit_chars_vns');
- $self->dbExec('INSERT INTO edit_chars_vns (vid, rid, spoil, role) VALUES(!l)', $_) for (@{$o->{vns}});
- }
-}
-
-
-# fetches an ID for a new image
-sub dbCharImageId {
- return shift->dbRow("SELECT nextval('charimg_seq') AS ni")->{ni};
-}
-
-
-1;
-
diff --git a/lib/VNDB/DB/Discussions.pm b/lib/VNDB/DB/Discussions.pm
deleted file mode 100644
index 442f8032..00000000
--- a/lib/VNDB/DB/Discussions.pm
+++ /dev/null
@@ -1,176 +0,0 @@
-
-package VNDB::DB::Discussions;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|dbThreadGet dbPostGet|;
-
-
-# Options: id, type, iid, results, page, what, asuser, notusers, search, sort, reverse
-# What: boards, boardtitles, firstpost, lastpost, poll
-# Sort: id lastpost
-sub dbThreadGet {
- my($self, %o) = @_;
- $o{results} ||= 50;
- $o{page} ||= 1;
- $o{what} ||= '';
-
- my @where = (
- $o{id} ? (
- 't.id = ?' => $o{id}
- ) : (
- 'NOT t.hidden' => 0,
- q{(NOT t.private OR EXISTS(SELECT 1 FROM threads_boards WHERE tid = t.id AND type = 'u' AND iid = ?))} => $o{asuser}
- ),
- $o{type} && !$o{iid} ? (
- 'EXISTS(SELECT 1 FROM threads_boards WHERE tid = t.id AND type IN(!l))' => [ ref $o{type} ? $o{type} : [ $o{type} ] ] ) : (),
- $o{type} && $o{iid} ? (
- 'tb.type = ?' => $o{type}, 'tb.iid = ?' => $o{iid} ) : (),
- $o{notusers} ? (
- 'NOT EXISTS(SELECT 1 FROM threads_boards WHERE type = \'u\' AND tid = t.id)' => 1) : (),
- );
-
- if($o{search}) {
- for (split /[ -,._]/, $o{search}) {
- s/%//g;
- push @where, 't.title ilike ?', "%$_%" if length($_) > 0;
- }
- }
-
- my @select = (
- qw|t.id t.title t.count t.locked t.hidden t.private|, 't.poll_question IS NOT NULL AS haspoll',
- $o{what} =~ /lastpost/ ? (q|EXTRACT('epoch' from tpl.date) AS lastpost_date|, VNWeb::DB::sql_user('ul', 'lastpost_')) : (),
- $o{what} =~ /poll/ ? (qw|t.poll_question t.poll_max_options t.poll_preview t.poll_recast|) : (),
- );
-
- my @join = (
- $o{what} =~ /lastpost/ ? (
- 'JOIN threads_posts tpl ON tpl.tid = t.id AND tpl.num = t.count',
- 'JOIN users ul ON ul.id = tpl.uid'
- ) : (),
- $o{type} && $o{iid} ?
- 'JOIN threads_boards tb ON tb.tid = t.id' : (),
- );
-
- my $order = sprintf {
- id => 't.id %s',
- lastpost => 'tpl.date %s',
- }->{ $o{sort}||'id' }, $o{reverse} ? 'DESC' : 'ASC';
-
- my($r, $np) = $self->dbPage(\%o, q|
- SELECT !s
- FROM threads t
- !s
- !W
- ORDER BY !s|,
- join(', ', @select), join(' ', @join), \@where, $order
- );
-
- if($o{what} =~ /(boards|boardtitles|poll)/ && $#$r >= 0) {
- my %r = map {
- $r->[$_]{boards} = [];
- $r->[$_]{poll_options} = [];
- ($r->[$_]{id}, $_)
- } 0..$#$r;
-
- if($o{what} =~ /boards/) {
- push(@{$r->[$r{$_->{tid}}]{boards}}, [ $_->{type}, $_->{iid} ]) for (@{$self->dbAll(q|
- SELECT tid, type, iid
- FROM threads_boards
- WHERE tid IN(!l)|,
- [ keys %r ]
- )});
- }
-
- if($o{what} =~ /poll/) {
- push(@{$r->[$r{$_->{tid}}]{poll_options}}, [ $_->{id}, $_->{option} ]) for (@{$self->dbAll(q|
- SELECT tid, id, option
- FROM threads_poll_options
- WHERE tid IN(!l)|,
- [ keys %r ]
- )});
- }
-
- if($o{what} =~ /firstpost/) {
- do { my $idx = $r{ delete $_->{tid} }; $r->[$idx] = { $r->[$idx]->%*, %$_ } } for (@{$self->dbAll(q|
- SELECT tpf.tid, EXTRACT('epoch' from tpf.date) AS firstpost_date, !s
- FROM threads_posts tpf
- JOIN users uf ON tpf.uid = uf.id
- WHERE tpf.num = 1 AND tpf.tid IN(!l)|,
- VNWeb::DB::sql_user('uf', 'firstpost_'), [ keys %r ]
- )});
- }
-
- if($o{what} =~ /boardtitles/) {
- push(@{$r->[$r{$_->{tid}}]{boards}}, $_) for (@{$self->dbAll(q|
- SELECT tb.tid, tb.type, tb.iid, COALESCE(u.username, v.title, p.name) AS title, COALESCE(u.username, v.original, p.original) AS original
- FROM threads_boards tb
- LEFT JOIN vn v ON tb.type = 'v' AND v.id = tb.iid
- LEFT JOIN producers p ON tb.type = 'p' AND p.id = tb.iid
- LEFT JOIN users u ON tb.type = 'u' AND u.id = tb.iid
- WHERE tb.tid IN(!l)|,
- [ keys %r ]
- )});
- }
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Options: tid, num, what, uid, mindate, hide, search, type, page, results, sort, reverse
-# what: user thread
-sub dbPostGet {
- my($self, %o) = @_;
- $o{results} ||= 50;
- $o{page} ||= 1;
- $o{what} ||= '';
-
- my %where = (
- $o{tid} ? (
- 'tp.tid = ?' => $o{tid} ) : (),
- $o{num} ? (
- 'tp.num = ?' => $o{num} ) : (),
- $o{uid} ? (
- 'tp.uid = ?' => $o{uid} ) : (),
- $o{mindate} ? (
- 'tp.date > to_timestamp(?)' => $o{mindate} ) : (),
- $o{hide} ? (
- 'tp.hidden = FALSE' => 1 ) : (),
- $o{hide} && $o{what} =~ /thread/ ? (
- 't.hidden = FALSE AND t.private = FALSE' => 1 ) : (),
- $o{type} ? (
- 'tp.tid IN(SELECT tid FROM threads_boards WHERE type IN(!l))' => [ ref $o{type} ? $o{type} : [ $o{type} ] ] ) : (),
- );
-
- my @select = (
- qw|tp.tid tp.num tp.hidden|, q|extract('epoch' from tp.date) as date|, q|extract('epoch' from tp.edited) as edited|,
- $o{search} ? () : 'tp.msg',
- $o{what} =~ /user/ ? (VNWeb::DB::sql_user()) : (),
- $o{what} =~ /thread/ ? ('t.title', 't.hidden AS thread_hidden') : (),
- );
- my @join = (
- $o{what} =~ /user/ ? 'JOIN users u ON u.id = tp.uid' : (),
- $o{what} =~ /thread/ ? 'JOIN threads t ON t.id = tp.tid' : (),
- );
-
- my $order = sprintf {
- num => 'tp.num %s',
- date => 'tp.date %s',
- }->{ $o{sort}||'num' }, $o{reverse} ? 'DESC' : 'ASC';
-
- my($r, $np) = $self->dbPage(\%o, q|
- SELECT !s
- FROM threads_posts tp
- !s
- !W
- ORDER BY !s|,
- join(', ', @select), join(' ', @join), \%where, $order
- );
-
- return wantarray ? ($r, $np) : $r;
-}
-
-1;
diff --git a/lib/VNDB/DB/Misc.pm b/lib/VNDB/DB/Misc.pm
deleted file mode 100644
index cd290d61..00000000
--- a/lib/VNDB/DB/Misc.pm
+++ /dev/null
@@ -1,119 +0,0 @@
-
-package VNDB::DB::Misc;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|
- dbStats dbItemEdit dbRevisionGet dbWikidata
-|;
-
-
-# Returns: hashref, key = section, value = number of (visible) entries
-# Sections: vn, producers, releases, users, threads, posts
-sub dbStats {
- my $s = shift;
- return { map {
- $_->{section} eq 'threads_posts' ? 'posts' : $_->{section}, $_->{count}
- } @{$s->dbAll('SELECT * FROM stats_cache')}};
-}
-
-
-# Inserts a new revision into the database
-# Arguments: type [vrpcsd], itemid, rev, %options->{ editsum uid ihid ilock + db[item]RevisionInsert }
-# rev = changes.rev of the revision this edit is based on, undef to create a new DB item
-# Returns: { itemid, chid, rev }
-sub dbItemEdit {
- my($self, $type, $itemid, $rev, %o) = @_;
-
- $self->dbExec('SELECT edit_!s_init(?, ?)', $type, $itemid, $rev);
- $self->dbExec('UPDATE edit_revision !H', {
- 'requester = ?' => $o{uid}||$self->authInfo->{id},
- 'ip = ?' => $self->reqIP,
- 'comments = ?' => $o{editsum},
- exists($o{ihid}) ? ('ihid = ?' => $o{ihid} ?1:0) : (),
- exists($o{ilock}) ? ('ilock = ?' => $o{ilock}?1:0) : (),
- });
-
- $self->dbVNRevisionInsert( \%o) if $type eq 'v';
- $self->dbProducerRevisionInsert(\%o) if $type eq 'p';
- $self->dbReleaseRevisionInsert( \%o) if $type eq 'r';
- $self->dbCharRevisionInsert( \%o) if $type eq 'c';
-
- return $self->dbRow('SELECT * FROM edit_!s_commit()', $type);
-}
-
-
-# Options: type, itemid, uid, auto, hidden, edit, page, results, releases
-sub dbRevisionGet {
- my($self, %o) = @_;
- $o{results} ||= 10;
- $o{page} ||= 1;
- $o{auto} ||= 0; # 0:show, -1:only, 1:hide
- $o{hidden} ||= 0;
- $o{edit} ||= 0; # 0:both, -1:new, 1:edits
- $o{releases} = 0 if !$o{type} || $o{type} ne 'v' || !$o{itemid};
-
- my %where = (
- $o{releases} ? (
- # This selects all changes of releases that are currently linked to the VN, not release revisions that are linked to the VN.
- # The latter seems more useful, but is also a lot more expensive.
- q{((c.type = 'v' AND c.itemid = ?) OR (c.type = 'r' AND c.itemid = ANY(ARRAY(SELECT rv.id FROM releases_vn rv WHERE rv.vid = ?))))} => [$o{itemid}, $o{itemid}],
- ) : (
- $o{type} ? (
- 'c.type IN(!l)' => [ ref($o{type})?$o{type}:[$o{type}] ] ) : (),
- $o{itemid} ? (
- 'c.itemid = ?' => [ $o{itemid} ] ) : (),
- ),
- $o{uid} ? (
- 'c.requester = ?' => $o{uid} ) : (),
- $o{auto} ? (
- 'c.requester !s 1' => $o{auto} < 0 ? '=' : '<>' ) : (),
- $o{hidden} ? (
- '!s EXISTS(SELECT 1 FROM changes c2 WHERE c2.type = c.type AND c2.itemid = c.itemid AND c2.ihid AND'.
- ' c2.rev = (SELECT MAX(c3.rev) FROM changes c3 WHERE c3.type = c.type AND c3.itemid = c.itemid))' => $o{hidden} == 1 ? 'NOT' : '') : (),
- $o{edit} ? (
- 'c.rev !s 1' => $o{edit} < 0 ? '=' : '>' ) : (),
- );
-
- my($r, $np) = $self->dbPage(\%o, q|
- SELECT c.id, c.type, c.itemid, c.comments, c.rev, extract('epoch' from c.added) as added, !s
- FROM changes c
- JOIN users u ON c.requester = u.id
- !W
- ORDER BY c.id DESC|, VNWeb::DB::sql_user(), \%where
- );
-
- # I couldn't find a way to fetch the titles the main query above without slowing it down considerably, so let's just do it this way.
- if(@$r) {
- my %r = map +($_->{id}, $_), @$r;
- my $w = join ' OR ', ('(type = ? AND id = ?)') x @$r;
- my @w = map +($_->{type}, $_->{id}), @$r;
-
- $r{ $_->{id} }{ititle} = $_->{title}, $r{ $_->{id} }{ioriginal} = $_->{original} for(@{$self->dbAll("
- SELECT id, title, original FROM (
- SELECT 'v'::dbentry_type, chid, title, original FROM vn_hist
- UNION ALL SELECT 'r'::dbentry_type, chid, title, original FROM releases_hist
- UNION ALL SELECT 'p'::dbentry_type, chid, name, original FROM producers_hist
- UNION ALL SELECT 'c'::dbentry_type, chid, name, original FROM chars_hist
- UNION ALL SELECT 'd'::dbentry_type, chid, title, '' AS original FROM docs_hist
- UNION ALL SELECT 's'::dbentry_type, sh.chid, name, original FROM staff_hist sh JOIN staff_alias_hist sah ON sah.chid = sh.chid AND sah.aid = sh.aid
- ) x(type, id, title, original)
- WHERE $w
- ", @w
- )});
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Returns a row from wikidata
-sub dbWikidata {
- return $_[0]->dbRow('SELECT * FROM wikidata WHERE id = ?', $_[1]);
-}
-
-
-1;
-
diff --git a/lib/VNDB/DB/Producers.pm b/lib/VNDB/DB/Producers.pm
deleted file mode 100644
index 0caf0ece..00000000
--- a/lib/VNDB/DB/Producers.pm
+++ /dev/null
@@ -1,131 +0,0 @@
-
-package VNDB::DB::Producers;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|dbProducerGet dbProducerGetRev dbProducerRevisionInsert|;
-
-
-# options: results, page, id, search, char, sort, inc_hidden
-# what: extended relations relgraph
-sub dbProducerGet {
- my $self = shift;
- my %o = (
- results => 10,
- page => 1,
- what => '',
- @_
- );
-
- $o{search} =~ s/%//g if $o{search};
-
- my %where = (
- !$o{id} && !$o{inc_hidden} ? (
- 'p.hidden = FALSE' => 1 ) : (),
- $o{id} ? (
- 'p.id IN(!l)' => [ ref $o{id} ? $o{id} : [$o{id}] ] ) : (),
- $o{search} ? (
- '(p.name ILIKE ? OR p.original ILIKE ? OR p.alias ILIKE ?)', [ map '%'.$o{search}.'%', 1..3 ] ) : (),
- $o{char} ? (
- 'LOWER(SUBSTR(p.name, 1, 1)) = ?' => $o{char} ) : (),
- defined $o{char} && !$o{char} ? (
- '(ASCII(p.name) < 97 OR ASCII(p.name) > 122) AND (ASCII(p.name) < 65 OR ASCII(p.name) > 90)' => 1 ) : (),
- );
-
- my $join = $o{what} =~ /relgraph/ ? 'JOIN relgraphs pg ON pg.id = p.rgraph' : '';
-
- my $select = 'p.id, p.type, p.name, p.original, p.lang, p.rgraph';
- $select .= ', p.desc, p.alias, p.website, p.l_wp, p.l_wikidata, p.hidden, p.locked' if $o{what} =~ /extended/;
- $select .= ', pg.svg' if $o{what} =~ /relgraph/;
-
- my($order, @order) = ('p.name');
- if($o{sort} && $o{sort} eq 'search') {
- $order = 'least(substr_score(p.name, ?), substr_score(p.original, ?)), p.name';
- @order = ($o{search}) x 2;
- }
-
- my($r, $np) = $self->dbPage(\%o, qq|
- SELECT !s
- FROM producers p
- !s
- !W
- ORDER BY $order|,
- $select, $join, \%where, @order
- );
-
- return _enrich($self, $r, $np, 0, $o{what});
-}
-
-
-# options: id, rev, what
-# what: extended relations
-sub dbProducerGetRev {
- my $self = shift;
- my %o = (what => '', @_);
-
- $o{rev} ||= $self->dbRow('SELECT MAX(rev) AS rev FROM changes WHERE type = \'p\' AND itemid = ?', $o{id})->{rev};
-
- my $select = 'c.itemid AS id, p.type, p.name, p.original, p.lang, po.rgraph';
- $select .= ', extract(\'epoch\' from c.added) as added, c.comments, c.rev, c.ihid, c.ilock, '.VNWeb::DB::sql_user();
- $select .= ', c.id AS cid, NOT EXISTS(SELECT 1 FROM changes c2 WHERE c2.type = c.type AND c2.itemid = c.itemid AND c2.rev = c.rev+1) AS lastrev';
- $select .= ', p.desc, p.alias, p.website, p.l_wp, p.l_wikidata, po.hidden, po.locked' if $o{what} =~ /extended/;
-
- my $r = $self->dbAll(q|
- SELECT !s
- FROM changes c
- JOIN producers po ON po.id = c.itemid
- JOIN producers_hist p ON p.chid = c.id
- JOIN users u ON u.id = c.requester
- WHERE c.type = 'p' AND c.itemid = ? AND c.rev = ?|,
- $select, $o{id}, $o{rev}
- );
-
- return _enrich($self, $r, 0, 1, $o{what});
-}
-
-
-sub _enrich {
- my($self, $r, $np, $rev, $what) = @_;
-
- if(@$r && $what =~ /relations/) {
- my($col, $hist, $colname) = $rev ? ('cid', '_hist', 'chid') : ('id', '', 'id');
- my %r = map {
- $r->[$_]{relations} = [];
- ($r->[$_]{$col}, $_)
- } 0..$#$r;
-
- push @{$r->[$r{$_->{xid}}]{relations}}, $_ for(@{$self->dbAll(qq|
- SELECT rel.$colname AS xid, rel.pid AS id, rel.relation, p.name, p.original
- FROM producers_relations$hist rel
- JOIN producers p ON rel.pid = p.id
- WHERE rel.$colname IN(!l)|,
- [ keys %r ]
- )});
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Updates the edit_* tables, used from dbItemEdit()
-# Arguments: { columns in producers_rev + relations },
-sub dbProducerRevisionInsert {
- my($self, $o) = @_;
-
- my %set = map exists($o->{$_}) ? (qq|"$_" = ?|, $o->{$_}) : (),
- qw|name original website l_wp l_wikidata type lang desc alias|;
- $self->dbExec('UPDATE edit_producers !H', \%set) if keys %set;
-
- if($o->{relations}) {
- $self->dbExec('DELETE FROM edit_producers_relations');
- my $q = join ',', map '(?,?)', @{$o->{relations}};
- my @q = map +($_->[1], $_->[0]), @{$o->{relations}};
- $self->dbExec("INSERT INTO edit_producers_relations (pid, relation) VALUES $q", @q) if @q;
- }
-}
-
-
-1;
-
diff --git a/lib/VNDB/DB/Releases.pm b/lib/VNDB/DB/Releases.pm
deleted file mode 100644
index 9813029d..00000000
--- a/lib/VNDB/DB/Releases.pm
+++ /dev/null
@@ -1,269 +0,0 @@
-
-package VNDB::DB::Releases;
-
-use strict;
-use warnings;
-use POSIX 'strftime';
-use Exporter 'import';
-use VNDB::Func 'gtintype';
-
-our @EXPORT = qw|dbReleaseFilters dbReleaseGet dbReleaseGetRev dbReleaseRevisionInsert dbReleaseEngines|;
-
-
-# Release filters shared by dbReleaseGet and dbVNGet
-sub dbReleaseFilters {
- my($self, %o) = @_;
- $o{plat} = [ $o{plat} ] if $o{plat} && !ref $o{plat};
- $o{med} = [ $o{med} ] if $o{med} && !ref $o{med};
- return (
- defined $o{patch} ? ( 'r.patch = ?' => $o{patch} == 1 ? 1 : 0) : (),
- defined $o{freeware} ? ( 'r.freeware = ?' => $o{freeware} == 1 ? 1 : 0) : (),
- defined $o{uncensored} ? ( 'r.uncensored = ?' => $o{uncensored} == 1 ? 1 : 0) : (),
- defined $o{type} ? ( 'r.type = ?' => $o{type} ) : (),
- defined $o{date_before} ? ( 'r.released <= ?' => $o{date_before} ) : (),
- defined $o{date_after} ? ( 'r.released >= ?' => $o{date_after} ) : (),
- defined $o{minage} ? ( 'r.minage IN(!l)' => [ ref $o{minage} ? $o{minage} : [$o{minage}] ] ) : (),
- defined $o{doujin} ? ( 'NOT r.patch AND r.doujin = ?' => $o{doujin} == 1 ? 1 : 0) : (),
- defined $o{resolution} ? ( 'NOT r.patch AND r.resolution IN(!l)' => [ ref $o{resolution} ? $o{resolution} : [$o{resolution}] ] ) : (),
- defined $o{voiced} ? ( 'NOT r.patch AND r.voiced IN(!l)' => [ ref $o{voiced} ? $o{voiced} : [$o{voiced}] ] ) : (),
- defined $o{ani_story} ? ( 'NOT r.patch AND r.ani_story IN(!l)' => [ ref $o{ani_story} ? $o{ani_story} : [$o{ani_story}] ] ) : (),
- defined $o{ani_ero} ? ( 'NOT r.patch AND r.ani_ero IN(!l)' => [ ref $o{ani_ero} ? $o{ani_ero} : [$o{ani_ero}] ] ) : (),
- defined $o{engine} ? ( 'r.engine = ?' => $o{engine} ) : (),
- defined $o{released} ? ( 'r.released !s ?' => [ $o{released} ? '<=' : '>', strftime('%Y%m%d', gmtime) ] ) : (),
- $o{lang} ? (
- 'r.id IN(SELECT irl.id FROM releases_lang irl WHERE irl.lang IN(!l))' => [ ref $o{lang} ? $o{lang} : [ $o{lang} ] ] ) : (),
- $o{olang} ? (
- 'r.id IN(SELECT irv.id FROM releases_vn irv JOIN vn v ON irv.vid = v.id WHERE v.c_olang && ARRAY[!l]::language[])' => [ ref $o{olang} ? $o{olang} : [ $o{olang} ] ] ) : (),
- $o{plat} ? ('('.join(' OR ',
- grep(/^unk$/, @{$o{plat}}) ? 'NOT EXISTS(SELECT 1 FROM releases_platforms irp WHERE irp.id = r.id)' : (),
- grep(!/^unk$/, @{$o{plat}}) ? 'r.id IN(SELECT irp.id FROM releases_platforms irp WHERE irp.platform IN(!l))' : (),
- ).')', [ [ grep !/^unk$/, @{$o{plat}} ] ]) : (),
- $o{med} ? ('('.join(' OR ',
- grep(/^unk$/, @{$o{med}}) ? 'NOT EXISTS(SELECT 1 FROM releases_media irm WHERE irm.id = r.id)' : (),
- grep(!/^unk$/, @{$o{med}}) ? 'r.id IN(SELECT irm.id FROM releases_media irm WHERE irm.medium IN(!l))' : ()
- ).')', [ [ grep(!/^unk$/, @{$o{med}}) ] ]) : (),
- $o{prod_inc} ? ('r.id IN(SELECT irp.id FROM releases_producers irp WHERE irp.pid IN(!l))' => [ ref $o{prod_inc} ? $o{prod_inc} : [$o{prod_inc}] ]) : (),
- $o{prod_exc} ? ('r.id NOT IN(SELECT irp.id FROM releases_producers irp WHERE irp.pid IN(!l))' => [ ref $o{prod_exc} ? $o{prod_exc} : [$o{prod_exc}] ]) : (),
- );
-}
-
-
-# Options: id vid pid released page results what med sort reverse date_before date_after
-# plat prod_inc prod_exc lang olang type minage search resolution freeware doujin voiced uncensored ani_story ani_ero hidden_only
-# What: extended vn producers platforms media
-# Sort: title released minage
-sub dbReleaseGet {
- my($self, %o) = @_;
- $o{results} ||= 50;
- $o{page} ||= 1;
- $o{what} ||= '';
-
- my @where = (
- !$o{id} && !$o{hidden_only} ? ( 'r.hidden = FALSE' => 0 ) : (),
- $o{hidden_only} ? ('r.hidden = TRUE' => 1) : (),
- $o{id} ? ( 'r.id = ?' => $o{id} ) : (),
- $o{pid} ? ( 'rp.pid = ?' => $o{pid} ) : (),
- $o{vid} ? ( 'r.id IN(SELECT id FROM releases_vn WHERE vid IN(!l))' => [ ref $o{vid} ? $o{vid} : [$o{vid}] ] ) : (),
- $self->dbReleaseFilters(%o),
- );
-
- if($o{search}) {
- for (split /[ -,._]/, $o{search}) {
- s/%//g;
- if(/^\d+$/ && gtintype($_)) {
- push @where, 'r.gtin = ?', $_;
- } elsif(length($_) > 0) {
- $_ = "%$_%";
- push @where, '(r.title ILIKE ? OR r.original ILIKE ? OR r.catalog = ?)',
- [ $_, $_, $_ ];
- }
- }
- }
-
- my @join = (
- $o{pid} ? 'JOIN releases_producers rp ON rp.id = r.id' : (),
- );
-
- my @select = (
- qw|r.id r.title r.original r.website r.released r.minage r.type r.patch|,
- $o{what} =~ /extended/ ? qw|
- r.notes r.catalog r.gtin r.resolution r.voiced r.freeware r.doujin r.uncensored r.ani_story r.ani_ero r.engine r.hidden r.locked
- | : (),
- $o{pid} ? ('rp.developer', 'rp.publisher') : (),
- $o{what} =~ /links/ ? qw|
- r.gtin r.l_steam r.l_gog r.l_gyutto r.l_digiket r.l_melon r.l_getchu r.l_getchudl r.l_dmm r.l_itch r.l_jastusa r.l_egs r.l_erotrail r.l_mg r.l_denpa r.l_jlist r.l_dlsite r.l_dlsiteen
- | : ()
- );
-
- my $order = sprintf {
- title => 'r.title %s, r.released %1$s',
- type => 'r.patch %s, r.type %1$s, r.released %1$s, r.title %1$s',
- publication => 'r.doujin %s, r.freeware %1$s, r.patch %1$s, r.released %1$s, r.title %1$s',
- resolution => 'r.resolution %s, r.patch %2$s, r.released %1$s, r.title %1$s',
- voiced => 'r.voiced %s, r.patch %2$s, r.released %1$s, r.title %1$s',
- ani_ero => 'r.ani_story %s, r.ani_ero %1$s, r.patch %2$s, r.released %1$s, r.title %1$s',
- released => 'r.released %s, r.id %1$s',
- minage => 'r.minage %s, r.released %1$s, r.title %1$s',
- notes => 'r.notes %s, r.released %1$s, r.title %1$s',
- }->{ $o{sort}||'released' }, $o{reverse} ? 'DESC' : 'ASC', !$o{reverse} ? 'DESC' : 'ASC';
-
- my($r, $np) = $self->dbPage(\%o, q|
- SELECT !s
- FROM releases r
- !s
- !W
- ORDER BY !s|,
- join(', ', @select), join(' ', @join), \@where, $order
- );
-
- return _enrich($self, $r, $np, 0, $o{what});
-}
-
-
-# options: id, rev, what
-# what: extended vn producers platforms media
-sub dbReleaseGetRev {
- my $self = shift;
- my %o = (what => '', @_);
-
- $o{rev} ||= $self->dbRow('SELECT MAX(rev) AS rev FROM changes WHERE type = \'r\' AND itemid = ?', $o{id})->{rev};
-
- my $select = 'c.itemid AS id, r.title, r.original, r.website, r.released, r.minage, r.type, r.patch';
- $select .= ', r.notes, r.catalog, r.gtin, r.resolution, r.voiced, r.freeware, r.doujin, r.uncensored, r.ani_story, r.ani_ero, r.engine, ro.hidden, ro.locked' if $o{what} =~ /extended/;
- $select .= ', extract(\'epoch\' from c.added) as added, c.comments, c.rev, c.ihid, c.ilock, '.VNWeb::DB::sql_user();
- $select .= ', c.id AS cid, NOT EXISTS(SELECT 1 FROM changes c2 WHERE c2.type = c.type AND c2.itemid = c.itemid AND c2.rev = c.rev+1) AS lastrev';
- $select .= ', r.gtin, r.l_steam, r.l_gog, r.l_gyutto, r.l_digiket, r.l_melon, r.l_getchu, r.l_getchudl, r.l_dmm, r.l_itch, r.l_jastusa, r.l_egs, r.l_erotrail, r.l_mg, r.l_denpa, r.l_jlist, r.l_dlsite, r.l_dlsiteen' if $o{what} =~ /links/;
-
- my $r = $self->dbAll(q|
- SELECT !s
- FROM changes c
- JOIN releases ro ON ro.id = c.itemid
- JOIN releases_hist r ON r.chid = c.id
- JOIN users u ON u.id = c.requester
- WHERE c.type = 'r' AND c.itemid = ? AND c.rev = ?|,
- $select, $o{id}, $o{rev}
- );
-
- return _enrich($self, $r, 0, 1, $o{what});
-}
-
-
-sub _enrich {
- my($self, $r, $np, $rev, $what) = @_;
-
- if(@$r) {
- my($col, $hist, $colname) = $rev ? ('cid', '_hist', 'chid') : ('id', '', 'id');
- my %r = map {
- $r->[$_]{producers} = [];
- $r->[$_]{platforms} = [];
- $r->[$_]{media} = [];
- $r->[$_]{vn} = [];
- $r->[$_]{languages} = [];
- ($r->[$_]{$col}, $_)
- } 0..$#$r;
-
- push(@{$r->[$r{$_->{xid}}]{languages}}, $_->{lang}) for (@{$self->dbAll("
- SELECT $colname AS xid, lang
- FROM releases_lang$hist
- WHERE $colname IN(!l)",
- [ keys %r ]
- )});
-
- if($what =~ /vn/) {
- push(@{$r->[$r{$_->{xid}}]{vn}}, $_) for (@{$self->dbAll("
- SELECT rv.$colname AS xid, v.id AS vid, v.title, v.original
- FROM releases_vn$hist rv
- JOIN vn v ON v.id = rv.vid
- WHERE rv.$colname IN(!l)
- ORDER BY v.title",
- [ keys %r ]
- )});
- }
-
- if($what =~ /producers/) {
- push(@{$r->[$r{$_->{xid}}]{producers}}, $_) for (@{$self->dbAll("
- SELECT rp.$colname AS xid, rp.developer, rp.publisher, p.id, p.name, p.original, p.type
- FROM releases_producers$hist rp
- JOIN producers p ON rp.pid = p.id
- WHERE rp.$colname IN(!l)
- ORDER BY p.name",
- [ keys %r ]
- )});
- }
-
- if($what =~ /platforms/) {
- push(@{$r->[$r{$_->{xid}}]{platforms}}, $_->{platform}) for (@{$self->dbAll("
- SELECT $colname AS xid, platform
- FROM releases_platforms$hist
- WHERE $colname IN(!l)",
- [ keys %r ]
- )});
- }
-
- if($what =~ /media/) {
- push(@{$r->[$r{$_->{xid}}]{media}}, $_) for (@{$self->dbAll("
- SELECT $colname AS xid, medium, qty
- FROM releases_media$hist
- WHERE $colname IN(!l)",
- [ keys %r ]
- )});
- }
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Updates the edit_* tables, used from dbItemEdit()
-# Arguments: { columns in releases_rev + languages + vn + producers + media + platforms }
-sub dbReleaseRevisionInsert {
- my($self, $o) = @_;
-
- my %set = map exists($o->{$_}) ? ("$_ = ?", $o->{$_}) : (),
- qw|title original gtin catalog website released notes minage type
- l_steam l_dlsite l_dlsiteen l_gog l_denpa l_jlist l_digiket l_melon l_mg l_getchu l_getchudl l_itch l_jastusa l_egs l_erotrail
- patch resolution voiced freeware doujin uncensored ani_story ani_ero engine|;
- $set{'l_dmm = ARRAY[!l]::text[]'} = [ $o->{l_dmm} ] if exists $o->{l_dmm};
- $set{'l_gyutto = ARRAY[!l]::integer[]'} = [ $o->{l_gyutto} ] if exists $o->{l_gyutto};
- $self->dbExec('UPDATE edit_releases !H', \%set) if keys %set;
-
- if($o->{languages}) {
- $self->dbExec('DELETE FROM edit_releases_lang');
- my $q = join ',', map '(?)', @{$o->{languages}};
- $self->dbExec("INSERT INTO edit_releases_lang (lang) VALUES $q", @{$o->{languages}}) if @{$o->{languages}};
- }
-
- if($o->{producers}) {
- $self->dbExec('DELETE FROM edit_releases_producers');
- my $q = join ',', map '(?,?,?)', @{$o->{producers}};
- my @q = map +($_->[0], $_->[1]?1:0, $_->[2]?1:0), @{$o->{producers}};
- $self->dbExec("INSERT INTO edit_releases_producers (pid, developer, publisher) VALUES $q", @q) if @q;
- }
-
- if($o->{platforms}) {
- $self->dbExec('DELETE FROM edit_releases_platforms');
- my $q = join ',', map '(?)', @{$o->{platforms}};
- $self->dbExec("INSERT INTO edit_releases_platforms (platform) VALUES $q", @{$o->{platforms}}) if @{$o->{platforms}};
- }
-
- if($o->{vn}) {
- $self->dbExec('DELETE FROM edit_releases_vn');
- my $q = join ',', map '(?)', @{$o->{vn}};
- $self->dbExec("INSERT INTO edit_releases_vn (vid) VALUES $q", @{$o->{vn}}) if @{$o->{vn}};
- }
-
- if($o->{media}) {
- $self->dbExec('DELETE FROM edit_releases_media');
- my $q = join ',', map '(?,?)', @{$o->{media}};
- my @q = map +($_->[0], $_->[1]), @{$o->{media}};
- $self->dbExec("INSERT INTO edit_releases_media (medium, qty) VALUES $q", @q) if @q;
- }
-}
-
-
-sub dbReleaseEngines {
- shift->dbAll(q{SELECT engine, count(*) as cnt FROM releases WHERE engine <> '' GROUP BY engine ORDER BY COUNT(*) desc, engine});
-}
-
-1;
-
diff --git a/lib/VNDB/DB/Staff.pm b/lib/VNDB/DB/Staff.pm
deleted file mode 100644
index 5a393dbb..00000000
--- a/lib/VNDB/DB/Staff.pm
+++ /dev/null
@@ -1,79 +0,0 @@
-
-package VNDB::DB::Staff;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|dbStaffGet |;
-
-# options: results, page, id, aid, search, exact, truename, role, gender
-sub dbStaffGet {
- my $self = shift;
- my %o = (
- results => 10,
- page => 1,
- what => '',
- @_
- );
- my(@roles, $seiyuu);
- if(defined $o{role}) {
- if(ref $o{role}) {
- $seiyuu = grep /^seiyuu$/, @{$o{role}};
- @roles = grep !/^seiyuu$/, @{$o{role}};
- } else {
- $seiyuu = $o{role} eq 'seiyuu';
- @roles = $o{role} unless $seiyuu;
- }
- }
-
- $o{search} =~ s/%//g if $o{search};
-
- my %where = (
- !$o{id} ? ( 's.hidden = FALSE' => 1 ) : (),
- $o{id} ? ( ref $o{id} ? ('s.id IN(!l)' => [$o{id}]) : ('s.id = ?' => $o{id}) ) : (),
- $o{aid} ? ( ref $o{aid} ? ('sa.aid IN(!l)' => [$o{aid}]) : ('sa.aid = ?' => $o{aid}) ) : (),
- $o{id} || $o{truename} ? ( 's.aid = sa.aid' => 1 ) : (),
- defined $o{gender} ? ( 's.gender IN(!l)' => [ ref $o{gender} ? $o{gender} : [$o{gender}] ]) : (),
- defined $o{lang} ? ( 's.lang IN(!l)' => [ ref $o{lang} ? $o{lang} : [$o{lang}] ]) : (),
- defined $o{role} ? (
- '('.join(' OR ',
- @roles ? ( 'EXISTS(SELECT 1 FROM vn_staff vs JOIN vn v ON v.id = vs.id WHERE vs.aid = sa.aid AND vs.role IN(!l) AND NOT v.hidden)' ) : (),
- $seiyuu ? ( 'EXISTS(SELECT 1 FROM vn_seiyuu vsy JOIN vn v ON v.id = vsy.id WHERE vsy.aid = sa.aid AND NOT v.hidden)' ) : ()
- ).')' => ( @roles ? [ \@roles ] : 1 ),
- ) : (),
- $o{exact} ? ( '(lower(sa.name) = lower(?) OR lower(sa.original) = lower(?))' => [ ($o{exact}) x 2 ] ) : (),
- $o{search} ?
- $o{search} =~ /[\x{3000}-\x{9fff}\x{ff00}-\x{ff9f}]/ ?
- # match against 'original' column only if search string contains any
- # japanese character.
- # note: more precise regex would be /[\p{Hiragana}\p{Katakana}\p{Han}]/
- ( q|(sa.original LIKE ? OR translate(sa.original,' ','') LIKE ?)| => [ '%'.$o{search}.'%', ($o{search} =~ s/\s+//gr).'%' ] ) :
- ( '(sa.name ILIKE ? OR sa.original ILIKE ?)' => [ map '%'.$o{search}.'%', 1..2 ] ) : (),
- $o{char} ? ( 'LOWER(SUBSTR(sa.name, 1, 1)) = ?' => $o{char} ) : (),
- defined $o{char} && !$o{char} ?
- ( '(ASCII(sa.name) < 97 OR ASCII(sa.name) > 122) AND (ASCII(sa.name) < 65 OR ASCII(sa.name) > 90)' => 1 ) : (),
- );
-
- my $select = 's.id, sa.aid, sa.name, sa.original, s.gender, s.lang';
-
- my($order, @order) = ('sa.name');
- if($o{sort} && $o{sort} eq 'search') {
- $order = 'least(substr_score(sa.name, ?), substr_score(sa.original, ?)), sa.name';
- @order = ($o{search}) x 2;
- }
-
- my($r, $np) = $self->dbPage(\%o, qq|
- SELECT !s
- FROM staff s
- JOIN staff_alias sa ON sa.id = s.id
- !W
- ORDER BY $order|,
- $select, \%where, @order
- );
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-1;
diff --git a/lib/VNDB/DB/Tags.pm b/lib/VNDB/DB/Tags.pm
deleted file mode 100644
index 875ff6e9..00000000
--- a/lib/VNDB/DB/Tags.pm
+++ /dev/null
@@ -1,288 +0,0 @@
-
-package VNDB::DB::Tags;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|dbTagGet dbTTTree dbTagEdit dbTagAdd dbTagMerge dbTagLinks dbTagLinkEdit dbTagStats dbTagWipeVotes|;
-
-
-# %options->{ id noid name search state searchable applicable page results what sort reverse }
-# what: parents childs(n) aliases addedby
-# sort: id name added items search
-sub dbTagGet {
- my $self = shift;
- my %o = (
- page => 1,
- results => 10,
- what => '',
- @_
- );
-
- $o{search} =~ s/%//g if $o{search};
-
- my %where = (
- $o{id} ? (
- 't.id IN(!l)' => [ ref $o{id} ? $o{id} : [$o{id}] ] ) : (),
- $o{noid} ? (
- 't.id <> ?' => $o{noid} ) : (),
- $o{name} ? (
- 't.id = (SELECT id FROM tags LEFT JOIN tags_aliases ON id = tag WHERE lower(name) = ? OR lower(alias) = ? LIMIT 1)' => [ lc $o{name}, lc $o{name} ]) : (),
- defined $o{state} && $o{state} != -1 ? (
- 't.state = ?' => $o{state} ) : (),
- !defined $o{state} && !$o{id} && !$o{name} ? (
- 't.state <> 1' => 1 ) : (),
- $o{search} ? (
- 't.id IN (SELECT id FROM tags LEFT JOIN tags_aliases ON id = tag WHERE name ILIKE ? OR alias ILIKE ?)' => [ "%$o{search}%", "%$o{search}%" ] ) : (),
- defined $o{searchable} ? ('t.searchable = ?' => $o{searchable}?1:0 ) : (),
- defined $o{applicable} ? ('t.applicable = ?' => $o{applicable}?1:0 ) : (),
- );
- my @select = (
- qw|t.id t.searchable t.applicable t.name t.description t.state t.cat t.c_items t.defaultspoil|,
- q|extract('epoch' from t.added) as added|,
- $o{what} =~ /addedby/ ? (VNWeb::DB::sql_user()) : (),
- );
- my @join = $o{what} =~ /addedby/ ? 'JOIN users u ON u.id = t.addedby' : ();
-
- my $order = sprintf {
- id => 't.id %s',
- name => 't.name %s',
- added => 't.added %s',
- items => 't.c_items %s',
- search=> 'substr_score(t.name, ?) ASC, t.name %s', # Assigning a matching score for aliases is also possible, but more involved
- }->{ $o{sort}||'id' }, $o{reverse} ? 'DESC' : 'ASC';
- my @order = $o{sort} && $o{sort} eq 'search' ? ($o{search}) : ();
-
-
- my($r, $np) = $self->dbPage(\%o, qq|
- SELECT !s
- FROM tags t
- !s
- !W
- ORDER BY $order|,
- join(', ', @select), join(' ', @join), \%where, @order
- );
-
- if(@$r && $o{what} =~ /aliases/) {
- my %r = map {
- $_->{aliases} = [];
- ($_->{id}, $_->{aliases})
- } @$r;
-
- push @{$r{$_->{tag}}}, $_->{alias} for (@{$self->dbAll(q|
- SELECT tag, alias FROM tags_aliases WHERE tag IN(!l)|, [ keys %r ]
- )});
- }
-
- if($o{what} =~ /parents\((\d+)\)/) {
- $_->{parents} = $self->dbTTTree(tag => $_->{id}, $1, 1) for(@$r);
- }
-
- if($o{what} =~ /childs\((\d+)\)/) {
- $_->{childs} = $self->dbTTTree(tag => $_->{id}, $1) for(@$r);
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Walks the tag/trait tree
-# type = tag | trait
-# id = tag to start with, or 0 to start with top-level tags
-# lvl = max. recursion level
-# back = false for parent->child, true for child->parent
-# Returns: [ { id, name, c_items, sub => [ { id, name, c_items, sub => [..] }, .. ] }, .. ]
-sub dbTTTree {
- my($self, $type, $id, $lvl, $back) = @_;
- $lvl ||= 15;
- my $xtra = $type eq 'trait' ? ', "order"' : '';
- my $xtra2 = $type eq 'trait' ? ', t."order"' : '';
- my $r = $self->dbAll(qq|
- WITH RECURSIVE thetree(lvl, id, parent, name, c_items) AS (
- SELECT ?::integer, id, 0, name, c_items$xtra
- FROM ${type}s
- !W
- UNION ALL
- SELECT tt.lvl-1, t.id, tt.id, t.name, t.c_items$xtra2
- FROM thetree tt
- JOIN ${type}s_parents tp ON !s
- JOIN ${type}s t ON !s
- WHERE tt.lvl > 0
- AND t.state = 2
- ) SELECT DISTINCT id, parent, name, c_items$xtra FROM thetree ORDER BY name|, $lvl,
- $id ? {'id = ?' => $id} : {"NOT EXISTS(SELECT 1 FROM ${type}s_parents WHERE $type = id)" => 1, 'state = 2' => 1},
- !$back ? ('tp.parent = tt.id', "t.id = tp.$type") : ("tp.$type = tt.id", 't.id = tp.parent')
- );
-
- my %pars; # parent-id -> [ child-object, .. ]
- push @{$pars{$_->{parent}}}, $_ for(@$r);
- $_->{'sub'} = $pars{$_->{id}} || [] for(@$r);
- my @r = grep !delete($_->{parent}), @$r;
- return $id ? $r[0]{'sub'} : \@r;
-}
-
-
-# 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);
-}
-
-
-# Directly fetch rows from tags_vn
-# Options: vid uid tag page results what sort reverse
-# What: details
-sub dbTagLinks {
- my($self, %o) = @_;
- $o{results} ||= 999;
- $o{page} ||= 1;
- $o{what} ||= '';
-
- my %where = (
- $o{vid} ? ('tv.vid = ?' => $o{vid}) : (),
- $o{uid} ? ('tv.uid = ?' => $o{uid}) : (),
- $o{tag} ? ('tv.tag = ?' => $o{tag}) : (),
- );
-
- my @select = (
- qw|tv.tag tv.vid tv.uid tv.vote tv.spoiler tv.ignore|, "EXTRACT('epoch' from tv.date) AS date",
- $o{what} =~ /details/ ? (qw|v.title t.name|, VNWeb::DB::sql_user()) : (),
- );
-
- my @join = $o{what} =~ /details/ ? (
- 'JOIN vn v ON v.id = tv.vid',
- 'JOIN users u ON u.id = tv.uid',
- 'JOIN tags t ON t.id = tv.tag'
- ) : ();
-
- my $order = !$o{sort} ? '' : 'ORDER BY '.{
- username => 'u.username',
- date => 'tv.date',
- title => 'v.title',
- tag => 't.name',
- }->{$o{sort}}.($o{reverse} ? ' DESC' : ' ASC');
-
- my($r, $np) = $self->dbPage(\%o,
- 'SELECT !s FROM tags_vn tv !s !W !s',
- join(', ', @select), join(' ', @join), \%where, $order
- );
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Change a user's tags for a VN entry
-sub dbTagLinkEdit {
- my($self, $uid, $vid, $insert, $update, $delete, $overrule) = @_;
-
- # overrule
- # 1. set ignore flag for everyone except $uid
- $self->dbExec('UPDATE tags_vn SET ignore = ? WHERE tag = ? AND vid = ? AND uid <> ?',
- $overrule->{$_}?1:0, $_, $vid, $uid) for(keys %$overrule);
- # 2. make sure $uid isn't ignored when others are set to ignore
- # (this happens when a mod takes over an other mods' overrule)
- $self->dbExec('UPDATE tags_vn SET ignore = false WHERE tag = ? AND vid = ? AND uid = ?',
- $_, $vid, $uid) for(grep $overrule->{$_}, keys %$overrule);
-
- # delete
- $self->dbExec('DELETE FROM tags_vn WHERE vid = ? AND uid = ? AND tag IN(!l)',
- $vid, $uid, [ keys %$delete ]) if keys %$delete;
-
- # insert
- my $val = join ',', map '(?,?,?,?,?,?)', keys %$insert;
- $self->dbExec("INSERT INTO tags_vn (tag, vid, uid, vote, spoiler, ignore) VALUES $val", map
- +($_, $vid, $uid, $insert->{$_}[0], $insert->{$_}[1]<0?undef:$insert->{$_}[1], $insert->{$_}[2]?1:0),
- keys %$insert) if keys %$insert;
-
- # update
- $self->dbExec('UPDATE tags_vn SET vote = ?, spoiler = ?, date = NOW() WHERE tag = ? AND vid = ? AND uid = ?',
- $update->{$_}[0], $update->{$_}[1]<0?undef:$update->{$_}[1], $_, $vid, $uid) for (keys %$update);
-
- # Update cache
- $self->dbExec('SELECT tag_vn_calc(?)', $vid);
-}
-
-
-# Fetch all tags related to a VN
-# Argument: %options->{ vid minrating state results what page sort reverse }
-# sort: name, rating
-sub dbTagStats {
- my($self, %o) = @_;
- $o{results} ||= 10;
- $o{page} ||= 1;
-
- my $rating = 'avg(CASE WHEN tv.ignore THEN NULL ELSE tv.vote END)';
- my $order = sprintf {
- name => 't.name %s',
- rating => "$rating %s",
- }->{ $o{sort}||'name' }, $o{reverse} ? 'DESC' : 'ASC';
-
- my %where = (
- 'tv.vid = ?' => $o{vid},
- defined $o{state} ? ('t.state = ?', $o{state}) : (),
- );
-
- my($r, $np) = $self->dbPage(\%o, qq|
- SELECT t.id, t.name, t.cat, count(*) as cnt, $rating as rating,
- COALESCE(avg(CASE WHEN tv.ignore THEN NULL ELSE tv.spoiler END), t.defaultspoil) as spoiler,
- bool_or(tv.ignore) AS overruled
- FROM tags t
- JOIN tags_vn tv ON tv.tag = t.id
- !W
- GROUP BY t.id, t.name, t.cat
- !s
- ORDER BY !s|,
- \%where, defined $o{minrating} ? "HAVING $rating > $o{minrating}" : '', $order
- );
-
- 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
deleted file mode 100644
index 019f512f..00000000
--- a/lib/VNDB/DB/Traits.pm
+++ /dev/null
@@ -1,113 +0,0 @@
-
-package VNDB::DB::Traits;
-
-# This module is for a large part a copy of VNDB::DB::Tags. I could have chosen
-# to modify that module to work for both traits and tags but that would have
-# complicated the code, so I chose to maintain two versions with similar
-# functionality instead.
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|dbTraitGet dbTraitEdit dbTraitAdd|;
-
-
-# Options: id noid search name state searchable applicable what results page sort reverse
-# what: parents childs(n) addedby
-# sort: id name name added items search
-sub dbTraitGet {
- my $self = shift;
- my %o = (
- page => 1,
- results => 10,
- what => '',
- @_,
- );
-
- $o{search} =~ s/%//g if $o{search};
-
- my %where = (
- $o{id} ? ( 't.id IN(!l)' => [ ref($o{id}) ? $o{id} : [$o{id}] ]) : (),
- $o{group} ? ( 't.group = ?' => $o{group} ) : (),
- $o{noid} ? ( 't.id <> ?' => $o{noid} ) : (),
- defined $o{state} && $o{state} != -1 ? (
- 't.state = ?' => $o{state} ) : (),
- !defined $o{state} && !$o{id} && !$o{name} ? (
- 't.state = 2' => 1 ) : (),
- $o{search} ? (
- '(t.name ILIKE ? OR t.alias ILIKE ?)' => [ "%$o{search}%", "%$o{search}%" ] ) : (),
- $o{name} ? ( # TODO: This is terribly ugly, use an aliases table.
- q{(LOWER(t.name) = LOWER(?) OR t.alias ~ ('(!sin)^'||?||'$'))} => [ $o{name}, '?', quotemeta $o{name} ] ) : (),
- defined $o{applicable} ? ('t.applicable = ?' => $o{applicable}?1:0 ) : (),
- defined $o{searchable} ? ('t.searchable = ?' => $o{searchable}?1:0 ) : (),
- );
-
- my @select = (
- qw|t.id t.searchable t.applicable t.name t.description t.state t.alias t."group" t."order" t.sexual t.c_items t.defaultspoil|,
- 'tg.name AS groupname', 'tg."order" AS grouporder', q|extract('epoch' from t.added) as added|,
- $o{what} =~ /addedby/ ? (VNWeb::DB::sql_user()) : (),
- );
- my @join = $o{what} =~ /addedby/ ? 'JOIN users u ON u.id = t.addedby' : ();
- push @join, 'LEFT JOIN traits tg ON tg.id = t."group"';
-
- my $order = sprintf {
- id => 't.id %s',
- name => 't.name %s',
- group => 'tg."order" %s, t.name %1$s',
- added => 't.added %s',
- items => 't.c_items %s',
- search=> 'substr_score(t.name, ?) ASC, t.name %s', # Can't score aliases at the moment
- }->{ $o{sort}||'id' }, $o{reverse} ? 'DESC' : 'ASC';
- my @order = $o{sort} && $o{sort} eq 'search' ? ($o{search}) : ();
-
- my($r, $np) = $self->dbPage(\%o, qq|
- SELECT !s
- FROM traits t
- !s
- !W
- ORDER BY $order|,
- join(', ', @select), join(' ', @join), \%where, @order,
- );
-
- if($o{what} =~ /parents\((\d+)\)/) {
- $_->{parents} = $self->dbTTTree(trait => $_->{id}, $1, 1) for(@$r);
- }
-
- if($o{what} =~ /childs\((\d+)\)/) {
- $_->{childs} = $self->dbTTTree(trait => $_->{id}, $1) for(@$r);
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# 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/DB/ULists.pm b/lib/VNDB/DB/ULists.pm
deleted file mode 100644
index 4c1d10ae..00000000
--- a/lib/VNDB/DB/ULists.pm
+++ /dev/null
@@ -1,77 +0,0 @@
-
-package VNDB::DB::ULists;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-
-our @EXPORT = qw|
- dbRListGet dbRListAdd dbRListDel
- dbVoteStats
-|;
-
-
-# Options: uid rid
-sub dbRListGet {
- my($self, %o) = @_;
-
- my %where = (
- 'uid = ?' => $o{uid},
- $o{rid} ? ('rid IN(!l)' => [ ref $o{rid} ? $o{rid} : [$o{rid}] ]) : (),
- );
-
- return $self->dbAll(q|
- SELECT uid, rid, status
- FROM rlists
- !W|,
- \%where
- );
-}
-
-
-# Arguments: uid rid status
-# rid can be an arrayref only when the rows are already present, in which case an update is done
-sub dbRListAdd {
- my($self, $uid, $rid, $stat) = @_;
- $self->dbExec(
- 'UPDATE rlists SET status = ? WHERE uid = ? AND rid IN(!l)',
- $stat, $uid, ref($rid) ? $rid : [ $rid ]
- )
- ||
- $self->dbExec(
- 'INSERT INTO rlists (uid, rid, status) VALUES(?, ?, ?)',
- $uid, $rid, $stat
- );
-}
-
-
-# Arguments: uid, rid
-sub dbRListDel {
- my($self, $uid, $rid) = @_;
- $self->dbExec(
- 'DELETE FROM rlists WHERE uid = ? AND rid IN(!l)',
- $uid, ref($rid) ? $rid : [ $rid ]
- );
-}
-
-
-# Arguments: 'vid', id
-# Returns an arrayref with 10 elements containing the [ count(vote), sum(vote) ]
-# for votes in the range of ($index+0.5) .. ($index+1.4)
-sub dbVoteStats {
- my($self, $col, $id, $ign) = @_;
- my $r = [ map [0,0], 0..9 ];
- $r->[$_->{idx}] = [ $_->{votes}, $_->{total} ] for (@{$self->dbAll(q|
- SELECT (vote::numeric/10)::int-1 AS idx, COUNT(vote) as votes, SUM(vote) AS total
- FROM ulist_vns uv
- WHERE uv.vote IS NOT NULL AND NOT EXISTS(SELECT 1 FROM users u WHERE u.id = uv.uid AND u.ign_votes)
- AND uv.vid = ?
- GROUP BY (vote::numeric/10)::int|,
- $id
- )});
- return $r;
-}
-
-1;
-
diff --git a/lib/VNDB/DB/Users.pm b/lib/VNDB/DB/Users.pm
deleted file mode 100644
index 2f7d8e5c..00000000
--- a/lib/VNDB/DB/Users.pm
+++ /dev/null
@@ -1,49 +0,0 @@
-
-package VNDB::DB::Users;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|
- dbUserGet
-|;
-
-
-# %options->{ uid results page what }
-# sort: username registered votes changes tags
-sub dbUserGet {
- my $s = shift;
- my %o = (
- page => 1,
- results => 10,
- what => '',
- @_
- );
-
- my %where = (
- $o{uid} && !ref($o{uid}) ? (
- 'id = ?' => $o{uid} ) : (),
- $o{uid} && ref($o{uid}) ? (
- 'id IN(!l)' => [ $o{uid} ]) : (),
- );
-
- my @select = (
- qw|id username c_votes c_changes c_tags hide_list|,
- VNWeb::DB::sql_user(), # XXX: This duplicates id and username, but updating all the code isn't going to be easy
- q|extract('epoch' from registered) as registered|,
- );
-
- my($r, $np) = $s->dbPage(\%o, q|
- SELECT !s
- FROM users u
- !W
- ORDER BY id DESC|,
- join(', ', @select), \%where
- );
-
- return wantarray ? ($r, $np) : $r;
-}
-
-1;
-
diff --git a/lib/VNDB/DB/VN.pm b/lib/VNDB/DB/VN.pm
deleted file mode 100644
index d099b6ff..00000000
--- a/lib/VNDB/DB/VN.pm
+++ /dev/null
@@ -1,369 +0,0 @@
-
-package VNDB::DB::VN;
-
-use strict;
-use warnings;
-use TUWF 'sqlprint';
-use POSIX 'strftime';
-use Exporter 'import';
-use VNDB::Func 'normalize_query', 'gtintype';
-
-our @EXPORT = qw|dbVNGet dbVNGetRev dbVNRevisionInsert dbVNImageId dbScreenshotAdd dbScreenshotGet dbScreenshotRandom|;
-
-
-# Options: id, char, search, gtin, length, lang, olang, plat, tag_inc, tag_exc, tagspoil,
-# hasani, hasshot, ul_notblack, ul_onwish, results, page, what, sort,
-# reverse, inc_hidden, date_before, date_after, released, release, character
-# What: extended anime staff seiyuu relations screenshots relgraph rating ranking vnlist
-# Note: vnlist is ignored (no db search) unless a user is logged in
-# Sort: id rel pop rating title tagscore rand
-sub dbVNGet {
- my($self, %o) = @_;
- $o{results} ||= 10;
- $o{page} ||= 1;
- $o{what} ||= '';
- $o{sort} ||= 'title';
- $o{tagspoil} //= 2;
-
- # user input that is literally added to the query should be checked...
- die "Invalid input for tagspoil or tag_inc at dbVNGet()\n" if
- grep !defined($_) || $_!~/^\d+$/, $o{tagspoil},
- !$o{tag_inc} ? () : (ref($o{tag_inc}) ? @{$o{tag_inc}} : $o{tag_inc});
-
- my $uid = $self->authInfo->{id};
-
- $o{gtin} = delete $o{search} if $o{search} && $o{search} =~ /^\d+$/ && gtintype(local $_ = $o{search});
-
- my @where = (
- $o{id} ? (
- 'v.id IN(!l)' => [ ref $o{id} ? $o{id} : [$o{id}] ] ) : (),
- $o{char} ? (
- 'LOWER(SUBSTR(v.title, 1, 1)) = ?' => $o{char} ) : (),
- defined $o{char} && !$o{char} ? (
- '(ASCII(v.title) < 97 OR ASCII(v.title) > 122) AND (ASCII(v.title) < 65 OR ASCII(v.title) > 90)' => 1 ) : (),
- defined $o{length} ? (
- 'v.length IN(!l)' => [ ref $o{length} ? $o{length} : [$o{length}] ]) : (),
- $o{lang} ? (
- 'v.c_languages && ARRAY[!l]::language[]' => [ ref $o{lang} ? $o{lang} : [$o{lang}] ]) : (),
- $o{olang} ? (
- 'v.c_olang && ARRAY[!l]::language[]' => [ ref $o{olang} ? $o{olang} : [$o{olang}] ]) : (),
- $o{plat} ? (
- 'v.c_platforms && ARRAY[!l]::platform[]' => [ ref $o{plat} ? $o{plat} : [$o{plat}] ]) : (),
- defined $o{hasani} ? (
- '!sEXISTS(SELECT 1 FROM vn_anime va WHERE va.id = v.id)' => [ $o{hasani} ? '' : 'NOT ' ]) : (),
- defined $o{hasshot} ? (
- '!sEXISTS(SELECT 1 FROM vn_screenshots vs WHERE vs.id = v.id)' => [ $o{hasshot} ? '' : 'NOT ' ]) : (),
- $o{tag_inc} ? (
- 'v.id IN(SELECT vid FROM tags_vn_inherit WHERE tag IN(!l) AND spoiler <= ? GROUP BY vid HAVING COUNT(tag) = ?)',
- [ ref $o{tag_inc} ? $o{tag_inc} : [$o{tag_inc}], $o{tagspoil}, ref $o{tag_inc} ? $#{$o{tag_inc}}+1 : 1 ]) : (),
- $o{tag_exc} ? (
- 'v.id NOT IN(SELECT vid FROM tags_vn_inherit WHERE tag IN(!l))' => [ ref $o{tag_exc} ? $o{tag_exc} : [$o{tag_exc}] ] ) : (),
- $o{search} ? (
- map +('v.c_search like ?', "%$_%"), normalize_query($o{search})) : (),
- $o{gtin} ? (
- 'v.id IN(SELECT irv.vid FROM releases_vn irv JOIN releases ir ON ir.id = irv.id WHERE ir.gtin = ?)' => $o{gtin}) : (),
- $o{staff_inc} ? ( 'v.id IN(SELECT ivs.id FROM vn_staff ivs JOIN staff_alias isa ON isa.aid = ivs.aid WHERE isa.id IN(!l))' => [ ref $o{staff_inc} ? $o{staff_inc} : [$o{staff_inc}] ] ) : (),
- $o{staff_exc} ? ( 'v.id NOT IN(SELECT ivs.id FROM vn_staff ivs JOIN staff_alias isa ON isa.aid = ivs.aid WHERE isa.id IN(!l))' => [ ref $o{staff_exc} ? $o{staff_exc} : [$o{staff_exc}] ] ) : (),
- $uid && $o{ul_notblack} ? (
- 'v.id NOT IN(SELECT vid FROM ulist_vns_labels WHERE uid = ? AND lbl = 6)' => $uid ) : (),
- $uid && defined $o{ul_onwish} ? (
- 'v.id !s IN(SELECT vid FROM ulist_vns_labels WHERE uid = ? AND lbl = 5)' => [ $o{ul_onwish} ? '' : 'NOT', $uid ] ) : (),
- $uid && defined $o{ul_voted} ? (
- 'v.id !s IN(SELECT vid FROM ulist_vns_labels WHERE uid = ? AND lbl = 7)' => [ $o{ul_voted} ? '' : 'NOT', $uid ] ) : (),
- $uid && defined $o{ul_onlist} ? (
- 'v.id !s IN(SELECT vid FROM ulist_vns WHERE uid = ?)' => [ $o{ul_onlist} ? '' : 'NOT', $uid ] ) : (),
- !$o{id} && !$o{inc_hidden} ? (
- 'v.hidden = FALSE' => 0 ) : (),
- # optimize fetching random entries (only when there are no other filters present, otherwise this won't work well)
- $o{sort} eq 'rand' && $o{results} <= 10 && !grep(!/^(?:results|page|what|sort|tagspoil)$/, keys %o) ? (
- 'v.id IN(SELECT floor(random() * last_value)::integer FROM generate_series(1,20), (SELECT MAX(id) AS last_value FROM vn) s1 LIMIT 20)' ) : (),
- defined $o{date_before} ? ( 'v.c_released <= ?' => $o{date_before} ) : (),
- defined $o{date_after} ? ( 'v.c_released >= ?' => $o{date_after} ) : (),
- defined $o{released} ? ( 'v.c_released !s ?' => [ $o{released} ? '<=' : '>', strftime('%Y%m%d', gmtime) ] ) : (),
- );
-
- if($o{release}) {
- my($q, @p) = sqlprint
- 'v.id IN(SELECT rv.vid FROM releases r JOIN releases_vn rv ON rv.id = r.id !W)',
- [ 'NOT r.hidden' => 1, $self->dbReleaseFilters(%{$o{release}}), ];
- push @where, $q, \@p;
- }
- if($o{character}) {
- my($q, @p) = sqlprint
- 'v.id IN(SELECT cv.vid FROM chars c JOIN chars_vns cv ON cv.id = c.id !W)',
- [ 'NOT c.hidden' => 1, $self->dbCharFilters(%{$o{character}}) ];
- push @where, $q, \@p;
- }
-
- my @join = (
- $o{what} =~ /relgraph/ ? 'JOIN relgraphs vg ON vg.id = v.rgraph' : (),
- $uid && $o{what} =~ /vnlist/ ? ("LEFT JOIN (
- SELECT irv.vid, COUNT(*) AS userlist_all,
- SUM(CASE WHEN irl.status = 2 THEN 1 ELSE 0 END) AS userlist_obtained
- FROM rlists irl
- JOIN releases_vn irv ON irv.id = irl.rid
- WHERE irl.uid = $uid
- GROUP BY irv.vid
- ) AS vnlist ON vnlist.vid = v.id") : (),
- );
-
- my $tag_ids = $o{tag_inc} && join ',', ref $o{tag_inc} ? @{$o{tag_inc}} : $o{tag_inc};
- my @select = ( # see https://rt.cpan.org/Ticket/Display.html?id=54224 for the cast on c_languages and c_platforms
- qw|v.id v.locked v.hidden v.c_released v.c_languages::text[] v.c_olang::text[] v.c_platforms::text[] v.title v.original v.rgraph|,
- $o{what} =~ /extended/ ? (
- qw|v.alias v.image v.img_nsfw v.length v.desc v.l_wp v.l_encubed v.l_renai v.l_wikidata| ) : (),
- $o{what} =~ /relgraph/ ? 'vg.svg' : (),
- $o{what} =~ /rating/ ? (qw|v.c_popularity v.c_rating v.c_votecount|) : (),
- $o{what} =~ /ranking/ ? (
- '(SELECT COUNT(*)+1 FROM vn iv WHERE iv.hidden = false AND iv.c_popularity > COALESCE(v.c_popularity, 0.0)) AS p_ranking',
- '(SELECT COUNT(*)+1 FROM vn iv WHERE iv.hidden = false AND iv.c_rating > COALESCE(v.c_rating, 0.0)) AS r_ranking',
- ) : (),
- $uid && $o{what} =~ /vnlist/ ? (qw|vnlist.userlist_all vnlist.userlist_obtained|) : (),
- # TODO: optimize this, as it will be very slow when the selected tags match a lot of VNs (>1000)
- $tag_ids ?
- qq|(SELECT AVG(tvh.rating) FROM tags_vn_inherit tvh WHERE tvh.tag IN($tag_ids) AND tvh.vid = v.id AND spoiler <= $o{tagspoil} GROUP BY tvh.vid) AS tagscore| : (),
- );
-
- no if $] >= 5.022, warnings => 'redundant';
- my $order = sprintf {
- id => 'v.id %s',
- rel => 'v.c_released %s, v.title ASC',
- pop => 'v.c_popularity %s NULLS LAST',
- rating => 'v.c_rating %s NULLS LAST',
- title => 'v.title %s',
- tagscore => 'tagscore %s, v.title ASC',
- rand => 'RANDOM()',
- }->{$o{sort}}, $o{reverse} ? 'DESC' : 'ASC';
-
- my($r, $np) = $self->dbPage(\%o, q|
- SELECT !s
- FROM vn v
- !s
- !W
- ORDER BY !s|,
- join(', ', @select), join(' ', @join), \@where, $order,
- );
-
- return _enrich($self, $r, $np, 0, $o{what});
-}
-
-
-sub dbVNGetRev {
- my $self = shift;
- my %o = (what => '', @_);
-
- $o{rev} ||= $self->dbRow('SELECT MAX(rev) AS rev FROM changes WHERE type = \'v\' AND itemid = ?', $o{id})->{rev};
-
- # XXX: Too much duplication with code in dbVNGet() here. Can we combine some code here?
- my $uid = $self->authInfo->{id};
-
- my $select = 'c.itemid AS id, vo.c_released, vo.c_languages::text[], vo.c_olang::text[], vo.c_platforms::text[], v.title, v.original, vo.rgraph';
- $select .= ', extract(\'epoch\' from c.added) as added, c.comments, c.rev, c.ihid, c.ilock, '.VNWeb::DB::sql_user();
- $select .= ', c.id AS cid, NOT EXISTS(SELECT 1 FROM changes c2 WHERE c2.type = c.type AND c2.itemid = c.itemid AND c2.rev = c.rev+1) AS lastrev';
- $select .= ', v.alias, v.image, v.img_nsfw, v.length, v.desc, v.l_wp, v.l_encubed, v.l_renai, v.l_wikidata, vo.hidden, vo.locked' if $o{what} =~ /extended/;
- $select .= ', vo.c_popularity, vo.c_rating, vo.c_votecount' if $o{what} =~ /rating/;
- $select .= ', (SELECT COUNT(*)+1 FROM vn iv WHERE iv.hidden = false AND iv.c_popularity > COALESCE(vo.c_popularity, 0.0)) AS p_ranking'
- .', (SELECT COUNT(*)+1 FROM vn iv WHERE iv.hidden = false AND iv.c_rating > COALESCE(vo.c_rating, 0.0)) AS r_ranking' if $o{what} =~ /ranking/;
-
- my $r = $self->dbAll(q|
- SELECT !s
- FROM changes c
- JOIN vn vo ON vo.id = c.itemid
- JOIN vn_hist v ON v.chid = c.id
- JOIN users u ON u.id = c.requester
- WHERE c.type = 'v' AND c.itemid = ? AND c.rev = ?|,
- $select, $o{id}, $o{rev}
- );
-
- return _enrich($self, $r, 0, 1, $o{what});
-}
-
-
-sub _enrich {
- my($self, $r, $np, $rev, $what) = @_;
-
- if(@$r && $what =~ /anime|relations|screenshots|staff|seiyuu/) {
- my($col, $hist, $colname) = $rev ? ('cid', '_hist', 'chid') : ('id', '', 'id');
- my %r = map {
- $r->[$_]{anime} = [];
- $r->[$_]{credits} = [];
- $r->[$_]{seiyuu} = [];
- $r->[$_]{relations} = [];
- $r->[$_]{screenshots} = [];
- ($r->[$_]{$col}, $_)
- } 0..$#$r;
-
- if($what =~ /staff/) {
- push(@{$r->[$r{ delete $_->{xid} }]{credits}}, $_) for (@{$self->dbAll("
- SELECT vs.$colname AS xid, s.id, vs.aid, sa.name, sa.original, s.gender, s.lang, vs.role, vs.note
- FROM vn_staff$hist vs
- JOIN staff_alias sa ON vs.aid = sa.aid
- JOIN staff s ON s.id = sa.id
- WHERE vs.$colname IN(!l)
- ORDER BY vs.role ASC, sa.name ASC",
- [ keys %r ]
- )});
- }
-
- if($what =~ /seiyuu/) {
- # The seiyuu query needs the VN id to get the VN<->Char spoiler level.
- # Obtaining this ID is different when using the hist table.
- my($vid, $join) = $rev ? ('h.itemid', 'JOIN changes h ON h.id = vs.chid') : ('vs.id', '');
- push(@{$r->[$r{ delete $_->{xid} }]{seiyuu}}, $_) for (@{$self->dbAll("
- SELECT vs.$colname AS xid, s.id, vs.aid, sa.name, sa.original, s.gender, s.lang, c.id AS cid, c.name AS cname, vs.note,
- (SELECT MAX(spoil) FROM chars_vns cv WHERE cv.vid = $vid AND cv.id = c.id) AS spoil
- FROM vn_seiyuu$hist vs
- JOIN staff_alias sa ON vs.aid = sa.aid
- JOIN staff s ON s.id = sa.id
- JOIN chars c ON c.id = vs.cid
- $join
- WHERE vs.$colname IN(!l)
- ORDER BY c.name",
- [ keys %r ]
- )});
- }
-
- if($what =~ /anime/) {
- push(@{$r->[$r{ delete $_->{xid} }]{anime}}, $_) for (@{$self->dbAll("
- SELECT va.$colname AS xid, a.id, a.year, a.ann_id, a.nfo_id, a.type, a.title_romaji, a.title_kanji, extract('epoch' from a.lastfetch) AS lastfetch
- FROM vn_anime$hist va
- JOIN anime a ON va.aid = a.id
- WHERE va.$colname IN(!l)",
- [ keys %r ]
- )});
- }
-
- if($what =~ /relations/) {
- push(@{$r->[$r{ delete $_->{xid} }]{relations}}, $_) for(@{$self->dbAll("
- SELECT rel.$colname AS xid, rel.vid AS id, rel.relation, rel.official, v.title, v.original
- FROM vn_relations$hist rel
- JOIN vn v ON rel.vid = v.id
- WHERE rel.$colname IN(!l)",
- [ keys %r ]
- )});
- }
-
- if($what =~ /screenshots/) {
- push(@{$r->[$r{ delete $_->{xid} }]{screenshots}}, $_) for (@{$self->dbAll("
- SELECT vs.$colname AS xid, s.id, vs.nsfw, vs.rid, s.width, s.height
- FROM vn_screenshots$hist vs
- JOIN screenshots s ON vs.scr = s.id
- WHERE vs.$colname IN(!l)
- ORDER BY vs.scr",
- [ keys %r ]
- )});
- }
- }
-
- VNWeb::DB::enrich_flatten(vnlist_labels => id => vid => sub { VNWeb::DB::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 =', \$self->authInfo->{id}, 'AND uvl.vid IN', $_[0], '
- ORDER BY CASE WHEN ul.id < 10 THEN ul.id ELSE 10 END, ul.label'
- )}, $r) if $what =~ /vnlist/ && $self->authInfo->{id};
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Updates the edit_* tables, used from dbItemEdit()
-# Arguments: { columns in producers_rev + anime + relations + screenshots }
-# screenshots = [ [ scrid, nsfw, rid ], .. ]
-# relations = [ [ rel, vid ], .. ]
-# anime = [ aid, .. ]
-sub dbVNRevisionInsert {
- my($self, $o) = @_;
-
- $o->{img_nsfw} = $o->{img_nsfw}?1:0 if exists $o->{img_nsfw};
- my %set = map exists($o->{$_}) ? (qq|"$_" = ?| => $o->{$_}) : (),
- qw|title original desc alias image img_nsfw length l_wp l_encubed l_renai l_wikidata|;
- $self->dbExec('UPDATE edit_vn !H', \%set) if keys %set;
-
- if($o->{screenshots}) {
- $self->dbExec('DELETE FROM edit_vn_screenshots');
- my $q = join ',', map '(?, ?, ?)', @{$o->{screenshots}};
- my @val = map +($_->{id}, $_->{nsfw}?1:0, $_->{rid}), @{$o->{screenshots}};
- $self->dbExec("INSERT INTO edit_vn_screenshots (scr, nsfw, rid) VALUES $q", @val) if @val;
- }
-
- if($o->{relations}) {
- $self->dbExec('DELETE FROM edit_vn_relations');
- my $q = join ',', map '(?, ?, ?)', @{$o->{relations}};
- my @val = map +($_->[1], $_->[0], $_->[2]?1:0), @{$o->{relations}};
- $self->dbExec("INSERT INTO edit_vn_relations (vid, relation, official) VALUES $q", @val) if @val;
- }
-
- if($o->{anime}) {
- $self->dbExec('DELETE FROM edit_vn_anime');
- my $q = join ',', map '(?)', @{$o->{anime}};
- $self->dbExec("INSERT INTO edit_vn_anime (aid) VALUES $q", @{$o->{anime}}) if @{$o->{anime}};
- }
-
- if($o->{credits}) {
- $self->dbExec('DELETE FROM edit_vn_staff');
- my $q = join ',', ('(?, ?, ?)') x @{$o->{credits}};
- my @val = map +($_->{aid}, $_->{role}, $_->{note}), @{$o->{credits}};
- $self->dbExec("INSERT INTO edit_vn_staff (aid, role, note) VALUES $q", @val) if @val;
- }
-
- if($o->{seiyuu}) {
- $self->dbExec('DELETE FROM edit_vn_seiyuu');
- my $q = join ',', ('(?, ?, ?)') x @{$o->{seiyuu}};
- my @val = map +($_->{aid}, $_->{cid}, $_->{note}), @{$o->{seiyuu}};
- $self->dbExec("INSERT INTO edit_vn_seiyuu (aid, cid, note) VALUES $q", @val) if @val;
- }
-}
-
-
-# fetches an ID for a new image
-sub dbVNImageId {
- return shift->dbRow("SELECT nextval('covers_seq') AS ni")->{ni};
-}
-
-
-# insert a new screenshot and return it's ID
-sub dbScreenshotAdd {
- my($s, $width, $height) = @_;
- return $s->dbRow(q|INSERT INTO screenshots (width, height) VALUES (?, ?) RETURNING id|, $width, $height)->{id};
-}
-
-
-# arrayref of screenshot IDs as argument
-sub dbScreenshotGet {
- return shift->dbAll(q|SELECT * FROM screenshots WHERE id IN(!l)|, shift);
-}
-
-
-# Fetch random VN + screenshots
-# if any arguments are given, it will return one random screenshot for each VN
-sub dbScreenshotRandom {
- my($self, @vids) = @_;
- return $self->dbAll(q|
- SELECT s.id AS scr, s.width, s.height, v.id AS vid, v.title
- FROM screenshots s
- JOIN vn_screenshots vs ON vs.scr = s.id
- JOIN vn v ON v.id = vs.id
- WHERE NOT v.hidden AND NOT vs.nsfw
- AND s.id IN(
- SELECT floor(random() * last_value)::integer
- FROM generate_series(1,20), (SELECT MAX(id) AS last_value FROM screenshots) s1
- LIMIT 20
- )
- LIMIT 4|
- ) if !@vids;
- # this query is faster than it looks
- return $self->dbAll(join(' UNION ALL ', map
- q|SELECT s.id AS scr, s.width, s.height, v.id AS vid, v.title, RANDOM() AS position
- FROM (
- SELECT vs2.id, vs2.scr FROM vn_screenshots vs2
- WHERE vs2.id = ? AND NOT vs2.nsfw
- ORDER BY RANDOM() LIMIT 1
- ) vs
- JOIN vn v ON v.id = vs.id
- JOIN screenshots s ON s.id = vs.scr
- |, @vids).' ORDER BY position', @vids);
-}
-
-
-1;
diff --git a/lib/VNDB/ExtLinks.pm b/lib/VNDB/ExtLinks.pm
index 332351c1..7d22ec32 100644
--- a/lib/VNDB/ExtLinks.pm
+++ b/lib/VNDB/ExtLinks.pm
@@ -3,9 +3,15 @@ package VNDB::ExtLinks;
use v5.26;
use warnings;
use VNDB::Config;
+use VNDB::Schema;
use Exporter 'import';
-our @EXPORT = ('enrich_extlinks', 'revision_extlinks');
+our @EXPORT = qw/
+ sql_extlinks
+ enrich_extlinks
+ revision_extlinks
+ validate_extlinks
+/;
# column name in wikidata table => \%info
@@ -39,21 +45,35 @@ our %WIKIDATA = (
crunchyroll => { type => 'text[]', property => 'P4110', label => undef, fmt => undef },
igdb_game => { type => 'text[]', property => 'P5794', label => 'IGDB', fmt => 'https://www.igdb.com/games/%s' },
giantbomb => { type => 'text[]', property => 'P5247', label => undef, fmt => undef },
- pcgamingwiki => { type => 'text[]', property => 'P6337', label => undef, fmt => undef },
+ pcgamingwiki => { type => 'text[]', property => 'P6337', label => 'PCGamingWiki', fmt => 'https://www.pcgamingwiki.com/wiki/%s' },
steam => { type => 'integer[]', property => 'P1733', label => undef, fmt => undef },
gog => { type => 'text[]', property => 'P2725', label => 'GOG', fmt => 'https://www.gog.com/game/%s' },
pixiv_user => { type => 'integer[]', property => 'P5435', label => 'Pixiv', fmt => 'https://www.pixiv.net/member.php?id=%d' },
doujinshi_author => { type => 'integer[]', property => 'P7511', label => 'Doujinshi.org', fmt => 'https://www.doujinshi.org/browse/author/%d/' },
+ soundcloud => { type => 'text[]', property => 'P3040', label => 'Soundcloud', fmt => 'https://soundcloud.com/%s' },
+ humblestore => { type => 'text[]', property => 'P4477', label => undef, fmt => undef },
+ itchio => { type => 'text[]', property => 'P7294', label => undef, fmt => undef },
+ playstation_jp => { type => 'text[]', property => 'P5999', label => undef, fmt => undef },
+ playstation_na => { type => 'text[]', property => 'P5944', label => undef, fmt => undef },
+ playstation_eu => { type => 'text[]', property => 'P5971', label => undef, fmt => undef },
+ lutris => { type => 'text[]', property => 'P7597', label => 'Lutris', fmt => 'https://lutris.net/games/%s' },
+ wine => { type => 'integer[]', property => 'P600', label => 'Wine AppDB', fmt => 'https://appdb.winehq.org/appview.php?iAppId=%d' },
);
# dbentry_type => column name => \%info
+# Column names are also used for AdvSearch filters, so they should be stable.
# info keys:
# label Name of the link
# fmt How to generate a url (basic version, printf-style only)
# fmt2 How to generate a better url
# (printf-style string or subroutine, given a hashref of the DB entry and returning a new 'fmt' string)
# ("better" meaning proper store section, affiliate link)
+# regex Regex to detect a URL and extract the database value (the first non-empty placeholder).
+# Excludes a leading qr{^https?://} match and is anchored on both sites, see full_regex() below.
+# (A valid DB value must survive a 'fmt' -> 'regex' round trip)
+# (Only set for links that should be autodetected in the edit form)
+# patt Human-readable URL pattern that corresponds to 'fmt' and 'regex'; Automatically derived from 'fmt' if not set.
our %LINKS = (
v => {
l_renai => { label => 'Renai.us', fmt => 'https://renai.us/game/%s' },
@@ -64,33 +84,175 @@ our %LINKS = (
},
r => {
website => { label => 'Official website', fmt => '%s' },
- l_egs => { label => 'ErogameScape', fmt => 'https://erogamescape.dyndns.org/~ap2/ero/toukei_kaiseki/game.php?game=%d' },
- l_erotrail => { label => 'ErogeTrailers', fmt => 'http://erogetrailers.com/soft/%d' },
- l_steam => { label => 'Steam', fmt => 'https://store.steampowered.com/app/%d/' },
- l_dlsite => { label => 'DLsite (jpn)', fmt => 'https://www.dlsite.com/home/work/=/product_id/%s.html'
- , fmt2 => sub { sprintf config->{dlsite_url}, shift->{l_dlsite_shop}||'home' } },
- l_dlsiteen => { label => 'DLsite (eng)', fmt => 'https://www.dlsite.com/home/eng/=/product_id/%s.html'
- , fmt2 => sub { sprintf config->{dlsite_url}, shift->{l_dlsiteen_shop}||'eng' } },
- l_gog => { label => 'GOG', fmt => 'https://www.gog.com/game/%s' },
- l_itch => { label => 'Itch.io', fmt => 'https://%s' },
- l_denpa => { label => 'Denpasoft', fmt => 'https://denpasoft.com/products/%s', fmt2 => config->{denpa_url} },
- l_jlist => { label => 'J-List', fmt => 'https://www.jlist.com/%s', fmt2 => sub { config->{ shift->{l_jlist_jbox} ? 'jbox_url' : 'jlist_url' } } },
- l_jastusa => { label => 'JAST USA', fmt => 'https://jastusa.com/%s' },
- l_gyutto => { label => 'Gyutto', fmt => 'https://gyutto.com/i/item%d' },
- l_digiket => { label => 'Digiket', fmt => 'https://www.digiket.com/work/show/_data/ID=ITM%07d/' },
- l_melon => { label => 'Melonbooks', fmt => 'https://www.melonbooks.com/index.php?main_page=product_info&products_id=IT%010d' },
- l_mg => { label => 'MangaGamer', fmt => 'https://www.mangagamer.com/r18/detail.php?product_code=%d'
- , fmt2 => sub { config->{ !defined($_[0]{l_mg_r18}) || $_[0]{l_mg_r18} ? 'mg_r18_url' : 'mg_main_url' } } },
- l_getchu => { label => 'Getchu', fmt => 'http://www.getchu.com/soft.phtml?id=%d' },
- l_getchudl => { label => 'DL.Getchu', fmt => 'http://dl.getchu.com/i/item%d' },
- l_dmm => { label => 'DMM', fmt => 'https://%s' },
+ l_egs => { label => 'ErogameScape'
+ , fmt => 'https://erogamescape.dyndns.org/~ap2/ero/toukei_kaiseki/game.php?game=%d'
+ , regex => qr{erogamescape\.dyndns\.org/~ap2/ero/toukei_kaiseki/(?:before_)?game\.php\?(?:.*&)?game=([0-9]+)(?:&.*)?} },
+ l_steam => { label => 'Steam'
+ , fmt => 'https://store.steampowered.com/app/%d/'
+ , fmt2 => 'https://store.steampowered.com/app/%d/?utm_source=vndb'
+ , regex => qr{(?:www\.)?(?:store\.steampowered\.com/app/([0-9]+)(?:/.*)?|steamcommunity\.com/(?:app|games)/([0-9]+)(?:/.*)?|steamdb\.info/app/([0-9]+)(?:/.*)?)} },
+ l_dlsite => { label => 'DLsite'
+ , fmt => 'https://www.dlsite.com/home/work/=/product_id/%s.html'
+ , fmt2 => sub { config->{dlsite_url} && sprintf config->{dlsite_url}, shift->{l_dlsite_shop}||'home' }
+ , regex => qr{(?:www\.)?dlsite\.com/.*/(?:dlaf/=/link/work/aid/.*/id|work/=/product_id)/([VR]J[0-9]{6,8}).*}
+ , patt => 'https://www.dlsite.com/<store>/work/=/product_id/<VJ or RJ-code>' },
+ l_gog => { label => 'GOG'
+ , fmt => 'https://www.gog.com/game/%s'
+ , regex => qr{(?:www\.)?gog\.com/(?:[a-z]{2}/)?game/([a-z0-9_]+).*} },
+ l_itch => { label => 'Itch.io'
+ , fmt => 'https://%s'
+ , regex => qr{([a-z0-9_-]+\.itch\.io/[a-z0-9_-]+)}
+ , patt => 'https://<artist>.itch.io/<product>' },
+ l_patreonp => { label => 'Patreon post'
+ , fmt => 'https://www.patreon.com/posts/%d'
+ , regex => qr{(?:www\.)?patreon\.com/posts/(?:[^/?]+-)?([0-9]+).*} },
+ l_patreon => { label => 'Patreon'
+ , fmt => 'https://www.patreon.com/%s'
+ , regex => qr{(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+).*} },
+ l_substar => { label => 'SubscribeStar'
+ , fmt => 'https://subscribestar.%s'
+ , regex => qr{(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+).*}
+ , patt => 'https://subscribestar.<adult or com>/<name>' },
+ l_denpa => { label => 'Denpasoft'
+ , fmt => 'https://denpasoft.com/product/%s/'
+ , fmt2 => config->{denpa_url}
+ , regex => qr{(?:www\.)?denpasoft\.com/products?/([^/&#?:]+).*} },
+ l_jlist => { label => 'J-List'
+ , fmt => 'https://www.jlist.com/shop/product/%s'
+ , fmt2 => config->{jlist_url},
+ , regex => qr{(?:www\.)?(?:jlist|jbox)\.com/shop/product/([^/#?]+).*} },
+ l_jastusa => { label => 'JAST USA'
+ , fmt => 'https://jastusa.com/games/%s/vndb'
+ , fmt2 => sub { config->{jastusa_url} && sprintf config->{jastusa_url}, shift->{l_jast_slug}||'vndb' },
+ , regex => qr{(?:www\.)?jastusa\.com/games/([a-z0-9_-]+)/[^/]+}
+ , patt => 'https://jastusa.com/games/<code>/<title>' },
+ l_fakku => { label => 'Fakku'
+ , fmt => 'https://www.fakku.net/games/%s'
+ , regex => qr{(?:www\.)?fakku\.(?:net|com)/games/([^/]+)(?:[/\?].*)?} },
+ l_googplay => { label => 'Google Play'
+ , fmt => 'https://play.google.com/store/apps/details?id=%s'
+ , regex => qr{play\.google\.com/store/apps/details\?id=([^/&\?]+)(?:&.*)?} },
+ l_appstore => { label => 'App Store'
+ , fmt => 'https://apps.apple.com/app/id%d'
+ , regex => qr{(?:itunes|apps)\.apple\.com/(?:[^/]+/)?app/(?:[^/]+/)?id([0-9]+)([/\?].*)?} },
+ l_animateg => { label => 'Animate Games'
+ , fmt => 'https://www.animategames.jp/home/detail/%d'
+ , regex => qr{(?:www\.)?animategames\.jp/home/detail/([0-9]+)} },
+ l_freem => { label => 'Freem!'
+ , fmt => 'https://www.freem.ne.jp/win/game/%d'
+ , regex => qr{(?:www\.)?freem\.ne\.jp/win/game/([0-9]+)} },
+ l_freegame => { label => 'Freegame Mugen'
+ , fmt => 'https://freegame-mugen.jp/%s.html'
+ , regex => qr{(?:www\.)?freegame-mugen\.jp/([^/]+/game_[0-9]+)\.html}
+ , patt => 'https://freegame-mugen.jp/<genre>/game_<id>.html' },
+ l_novelgam => { label => 'NovelGame'
+ , fmt => 'https://novelgame.jp/games/show/%d'
+ , regex => qr{(?:www\.)?novelgame\.jp/games/show/([0-9]+)} },
+ l_gyutto => { label => 'Gyutto'
+ , fmt => 'https://gyutto.com/i/item%d'
+ , regex => qr{(?:www\.)?gyutto\.(?:com|jp|me)/(?:.+\/)?i/item([0-9]+).*} },
+ l_digiket => { label => 'Digiket'
+ , fmt => 'https://www.digiket.com/work/show/_data/ID=ITM%07d/'
+ , regex => qr{(?:www\.)?digiket\.com/.*ITM([0-9]{7}).*} },
+ l_melon => { label => 'Melonbooks.com'
+ , fmt => 'https://www.melonbooks.com/index.php?main_page=product_info&products_id=IT%010d'
+ , regex => qr{(?:www\.)?melonbooks\.com/.*products_id=IT([0-9]{10}).*} },
+ l_melonjp => { label => 'Melonbooks.co.jp'
+ , fmt => 'https://www.melonbooks.co.jp/detail/detail.php?product_id=%d',
+ , regex => qr{(?:www\.)?melonbooks\.co\.jp/detail/detail\.php\?product_id=([0-9]+)(&:?.*)?} },
+ l_mg => { label => 'MangaGamer'
+ , fmt => 'https://www.mangagamer.com/r18/detail.php?product_code=%d'
+ , fmt2 => sub { config->{ !defined($_[0]{l_mg_r18}) || $_[0]{l_mg_r18} ? 'mg_r18_url' : 'mg_main_url' } }
+ , regex => qr{(?:www\.)?mangagamer\.com/.*product_code=([0-9]+).*} },
+ l_getchu => { label => 'Getchu'
+ , fmt => 'http://www.getchu.com/soft.phtml?id=%d'
+ , regex => qr{(?:www\.)?getchu\.com/soft\.phtml\?id=([0-9]+).*} },
+ l_getchudl => { label => 'DL.Getchu'
+ , fmt => 'http://dl.getchu.com/i/item%d'
+ , regex => qr{(?:dl|order)\.getchu\.com/(?:i/item|(?:r|index).php.*[?&]gcd=D?0*)([0-9]+).*} },
+ l_dmm => { label => 'DMM'
+ , fmt => 'https://%s'
+ , regex => qr{((?:www\.|dlsoft\.)?dmm\.(?:com|co\.jp)/[^\s?]+)(?:\?.*)?}
+ , patt => 'https://<any link to dmm.com or dmm.co.jp>' },
+ l_toranoana=> { label => 'Toranoana'
+ # ec.* is for 18+, ecs.toranoana.jp is for non-18+.
+ # ec.toranoana.shop will redirect to ecs.* as appropriate for the product ID, but ec.toranoana.jp won't.
+ , fmt => 'https://ec.toranoana.shop/tora/ec/item/%012d/'
+ , regex => qr{(?:www\.)?ecs?\.toranoana\.(?:shop|jp)/(?:aqua/ec|(?:tora|joshi)(?:/ec|_r/ec|_d/digi|_rd/digi)?)/item/([0-9]{12}).*}
+ , patt => 'https://ec.toranoana.<shop or jp>/<shop>/item/<number>/' },
+ l_booth => { label => 'BOOTH'
+ , fmt => 'https://booth.pm/en/items/%d'
+ , regex => qw{(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+).*}
+ , patt => 'https://booth.pm/<language>/items/<id> OR https://<publisher>.booth.pm/items/<id>' },
+ l_gamejolt => { label => 'Game Jolt'
+ , fmt => 'https://gamejolt.com/games/vn/%d', # /vn/ should be the game title, but it doesn't matter
+ , regex => qr{(?:www\.)?gamejolt\.com/games/(?:[^/]+)/([0-9]+)(?:/.*)?} },
+ l_nutaku => { label => 'Nutaku'
+ , fmt => 'https://www.nutaku.net/games/%s/'
+ , regex => qr{(?:www\.)?nutaku\.net/games/(?:mobile/|download/|app/)?([a-z0-9-]+)/?} }, # The section part does sometimes link to different pages, but it's the same game and the non-section link always works.
+ l_playstation_jp => { label => 'PlayStation Store (JP)'
+ , fmt => 'https://store.playstation.com/ja-jp/product/%s'
+ , regex => qr{store\.playstation\.com/(?:[-a-z]+\/)?product\/(JP\d{4}-[A-Z]{4}\d{5}_00-[\dA-Z_]{16})} },
+ l_playstation_na => { label => 'PlayStation Store (NA)'
+ , fmt => 'https://store.playstation.com/en-us/product/%s'
+ , regex => qr{store\.playstation\.com/(?:[-a-z]+\/)?product\/(UP\d{4}-[A-Z]{4}\d{5}_00-[\dA-Z_]{16})} },
+ l_playstation_eu => { label => 'PlayStation Store (EU)'
+ , fmt => 'https://store.playstation.com/en-gb/product/%s'
+ , regex => qr{store\.playstation\.com/(?:[-a-z]+\/)?product\/(EP\d{4}-[A-Z]{4}\d{5}_00-[\dA-Z_]{16})} },
+ l_playstation_hk => { label => 'PlayStation Store (HK)'
+ , fmt => 'https://store.playstation.com/en-hk/product/%s'
+ , regex => qr{store\.playstation\.com/(?:[-a-z]+\/)?product\/(HP\d{4}-[A-Z]{4}\d{5}_00-[\dA-Z_]{16})} },
+ l_nintendo => { label => 'Nintendo'
+ , fmt => 'https://www.nintendo.com/store/products/%s/'
+ , regex => qr{www\.nintendo\.com\/store\/products\/([-a-z0-9]+-(?:switch|wii-u|3ds))\/} },
+ l_nintendo_jp => { label => 'Nintendo (JP)'
+ , fmt => 'https://store-jp.nintendo.com/list/software/%d.html'
+ , regex => qr{store-jp\.nintendo\.com/list/software/([0-9]+).html} },
+ l_nintendo_hk => { label => 'Nintendo (HK)'
+ , fmt => 'https://store.nintendo.com.hk/%d'
+ , regex => qr{store\.nintendo\.com\.hk/([0-9]+)} },
+ # deprecated
+ l_dlsiteen => { label => 'DLsite (eng)', fmt => 'https://www.dlsite.com/eng/work/=/product_id/%s.html' },
+ l_erotrail => { label => 'ErogeTrailers', fmt => 'http://erogetrailers.com/soft/%d' },
},
s => {
l_site => { label => 'Official website', fmt => '%s' },
- l_wikidata => { label => 'Wikidata', fmt => 'https://www.wikidata.org/wiki/Q%d' },
- l_twitter => { label => 'Twitter', fmt => 'https://twitter.com/%s' },
- l_anidb => { label => 'AniDB', fmt => 'https://anidb.net/cr%s' },
- l_pixiv => { label => 'Pixiv', fmt => 'https://www.pixiv.net/member.php?id=%d' },
+ l_wikidata => { label => 'Wikidata'
+ , fmt => 'https://www.wikidata.org/wiki/Q%d'
+ , regex => qr{www\.wikidata\.org/wiki/Q([1-9][0-9]*)} },
+ l_twitter => { label => 'Xitter'
+ , fmt => 'https://twitter.com/%s'
+ , regex => qr{(?:(?:www\.)?twitter\.com|nitter\.[^/]+)/([^?\/ ]{1,16})(?:[?/].*)?} },
+ l_anidb => { label => 'AniDB'
+ , fmt => 'https://anidb.net/cr%s'
+ , regex => qr{anidb\.net/(?:cr|creator/)([1-9][0-9]*)} },
+ l_pixiv => { label => 'Pixiv'
+ , fmt => 'https://www.pixiv.net/member.php?id=%d'
+ , regex => qr{www\.pixiv\.net/(?:member\.php\?id=|en/users/|users/)([0-9]+)} },
+ l_vgmdb => { label => 'VGMdb'
+ , fmt => 'https://vgmdb.net/artist/%d'
+ , regex => qr{vgmdb\.net/artist/([0-9]+)} },
+ l_discogs => { label => 'Discogs'
+ , fmt => 'https://www.discogs.com/artist/%d'
+ , regex => qr{(?:www\.)?discogs\.com/artist/([0-9]+)(?:[?/-].*)?} },
+ l_mobygames=> { label => 'MobyGames'
+ , fmt => 'https://www.mobygames.com/person/%d'
+ , regex => qr{(?:www\.)?mobygames\.com/person/([0-9]+)(?:[?/].*)?} },
+ l_bgmtv => { label => 'Bangumi'
+ , fmt => 'https://bgm.tv/person/%d'
+ , regex => qr{(?:www\.)?(?:bgm|bangumi)\.tv/person/([0-9]+)(?:[?/].*)?} },
+ l_imdb => { label => 'IMDb'
+ , fmt => 'https://www.imdb.com/name/nm%07d'
+ , regex => qr{(?:www\.)?imdb\.com/name/nm([0-9]{7,8})(?:[?/].*)?} },
+ l_mbrainz => { label => 'MusicBrainz'
+ , fmt => 'https://musicbrainz.org/artist/%s'
+ , regex => qr{musicbrainz\.org/artist/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})} },
+ l_scloud => { label => 'SoundCloud'
+ , fmt => 'https://soundcloud.com/%s'
+ , regex => qr{soundcloud\.com/([a-z0-9-]+)} },
+ l_vndb => { label => 'VNDB user'
+ , fmt => 'https://vndb.org/%s'
+ , regex => qr{vndb\.org/(u[1-9][0-9]*)} },
# deprecated
l_wp => { label => 'Wikipedia', fmt => 'https://en.wikipedia.org/wiki/%s' },
},
@@ -103,17 +265,27 @@ our %LINKS = (
);
+# Return a list of columns to fetch all external links for a database entry.
+sub sql_extlinks {
+ my($type, $prefix) = @_;
+ $prefix ||= '';
+ my $l = $LINKS{$type} || die "DB entry type $type has no links";
+ join ',', map $prefix.$_, sort keys %$l
+}
+
+
# Fetch a list of links to display at the given database entries, adds the
# following field to each object:
#
# extlinks => [
-# [ $title, $url, $price ],
+# { name, label, id, url, url2, price }, # depending on which fields are $enabled
# ..
# ]
#
-# (It also adds a few other fields in some cases, but you can ignore those)
+# Assumes the columns returned by sql_extlinks() are already available.
sub enrich_extlinks {
- my($type, @obj) = @_;
+ my($type, $enabled, @obj) = @_;
+ $enabled ||= { label => 1, url2 => 1, price => 1 };
@obj = map ref $_ eq 'ARRAY' ? @$_ : ($_), @obj;
my $l = $LINKS{$type} || die "DB entry type $type has no links";
@@ -122,27 +294,38 @@ sub enrich_extlinks {
my $w = @w_ids ? { map +($_->{id}, $_), $TUWF::OBJ->dbAlli('SELECT * FROM wikidata WHERE id IN', \@w_ids)->@* } : {};
# Fetch shop info for releases
+ my @cleanup;
if($type eq 'r') {
VNWeb::DB::enrich_merge(id => q{
SELECT r.id
, smg.price AS l_mg_price, smg.r18 AS l_mg_r18
, sdenpa.price AS l_denpa_price
- , sjlist.price AS l_jlist_price, sjlist.jbox AS l_jlist_jbox
+ , sjast.price AS l_jast_price, sjast.slug AS l_jast_slug
+ , sjlist.price AS l_jlist_price
, sdlsite.price AS l_dlsite_price, sdlsite.shop AS l_dlsite_shop
- , sdlsiteen.price AS l_dlsiteen_price, sdlsiteen.shop AS l_dlsiteen_shop
FROM releases r
LEFT JOIN shop_denpa sdenpa ON sdenpa.id = r.l_denpa AND sdenpa.lastfetch IS NOT NULL AND sdenpa.deadsince IS NULL
LEFT JOIN shop_dlsite sdlsite ON sdlsite.id = r.l_dlsite AND sdlsite.lastfetch IS NOT NULL AND sdlsite.deadsince IS NULL
- LEFT JOIN shop_dlsite sdlsiteen ON sdlsiteen.id = r.l_dlsiteen AND sdlsiteen.lastfetch IS NOT NULL AND sdlsiteen.deadsince IS NULL
+ LEFT JOIN shop_jastusa sjast ON sjast.id = r.l_jastusa AND sjast.lastfetch IS NOT NULL AND sjast.deadsince IS NULL
LEFT JOIN shop_jlist sjlist ON sjlist.id = r.l_jlist AND sjlist.lastfetch IS NOT NULL AND sjlist.deadsince IS NULL
LEFT JOIN shop_mg smg ON smg.id = r.l_mg AND smg.lastfetch IS NOT NULL AND smg.deadsince IS NULL
WHERE r.id IN},
- grep $_->{l_mg}||$_->{l_denpa}||$_->{l_jlist}||$_->{l_dlsite}||$_->{l_dlsiteen}, @obj
- );
- VNWeb::DB::enrich(l_playasia => gtin => gtin =>
- "SELECT gtin, price, url FROM shop_playasia WHERE price <> '' AND gtin IN",
- grep $_->{gtin}, @obj
- );
+ grep $_->{l_mg}||$_->{l_denpa}||$_->{l_jastusa}||$_->{l_jlist}||$_->{l_dlsite}, @obj
+ ) if $enabled->{price} || $enabled->{url2};
+
+ if(grep exists $_->{gtin}, @obj) {
+ VNWeb::DB::enrich(l_playasia => gtin => gtin =>
+ "SELECT gtin, price, url FROM shop_playasia WHERE price <> '' AND gtin IN",
+ grep $_->{gtin}, @obj
+ );
+ } else {
+ VNWeb::DB::enrich(l_playasia => id => id =>
+ "SELECT r.id, s.gtin, s.price, s.url FROM releases r JOIN shop_playasia s ON s.gtin = r.gtin WHERE s.price <> '' AND r.id IN",
+ @obj
+ );
+ }
+
+ @cleanup = qw{l_mg_price l_mg_r18 l_denpa_price l_jast_price l_jast_slug l_jlist_price l_dlsite_price l_dlsite_shop l_playasia};
}
for my $obj (@obj) {
@@ -150,12 +333,36 @@ sub enrich_extlinks {
my sub w {
return if !$obj->{l_wikidata};
my($v, $fmt, $label) = ($w->{$obj->{l_wikidata}}{$_[0]}, @{$WIKIDATA{$_[0]}}{'fmt', 'label'});
- push @links, map [ $label, ref $fmt ? $fmt->($_) : sprintf $fmt, $_ ], ref $v ? @$v : $v ? $v : ()
+ push @links, map +{
+ $enabled->{name} ? (name => $_[0]) : (),
+ $enabled->{label} ? (label => $label) : (),
+ $enabled->{id} ? (id => $_) : (),
+ $enabled->{url} ? (url => ref $fmt ? $fmt->($_) : sprintf $fmt, $_) : (),
+ $enabled->{url2} ? (url2 => ref $fmt ? $fmt->($_) : sprintf $fmt, $_) : (),
+ }, ref $v ? @$v : $v ? $v : ()
}
my sub l {
my($f, $price) = @_;
- my($v, $fmt, $fmt2, $label) = ($obj->{$f}, @{$l->{$f}}{'fmt', 'fmt2', 'label'});
- push @links, map [ $label, sprintf(ref $fmt2 ? $fmt2->($obj) : $fmt2 || $fmt, $_), $price ], ref $v ? @$v : $v ? $v : ()
+ my($v, $fmt, $fmt2, $label) = ($obj->{$f}, $l->{$f} ? @{$l->{$f}}{'fmt', 'fmt2', 'label'} : ());
+ push @links, map +{
+ $enabled->{name} ? (name => $_[0] =~ s/^l_//r) : (),
+ $enabled->{label} ? (label => $label) : (),
+ $enabled->{id} ? (id => $_) : (),
+ $enabled->{url} ? (url => sprintf($fmt, $_)) : (),
+ $enabled->{url2} ? (url2 => sprintf((ref $fmt2 ? $fmt2->($obj) : $fmt2) || $fmt, $_)) : (),
+ $enabled->{price} && length $price ? (price => $price) : (),
+ }, ref $v ? @$v : $v ? $v : ()
+ }
+ my sub c {
+ my($name, $label, $fmt, $id, $price) = @_;
+ push @links, {
+ $enabled->{name} ? (name => $name) : (),
+ $enabled->{label} ? (label => $label) : (),
+ $enabled->{id} ? (id => $id) : (),
+ $enabled->{url} ? (url => sprintf($fmt, $id)) : (),
+ $enabled->{url2} ? (url2 => sprintf($fmt, $id)) : (),
+ $enabled->{price} && length $price ? (price => $price) : (),
+ }
}
l 'l_site';
@@ -173,31 +380,54 @@ sub enrich_extlinks {
w 'indiedb_game';
w 'howlongtobeat';
w 'igdb_game';
+ w 'pcgamingwiki';
+ w 'lutris';
+ w 'wine';
l 'l_renai';
- push @links, [ 'VNStat', sprintf 'https://vnstat.net/novel/%d', $obj->{id} ] if $obj->{c_votecount}>=20;
+ c 'vnstat', 'VNStat', 'https://vnstat.net/novel/%d', $obj->{id} =~ s/^.//r if $obj->{c_votecount}>=20;
}
# Release links
if($type eq 'r') {
l 'l_egs';
- l 'l_erotrail';
l 'l_steam';
- push @links, [ 'SteamDB', sprintf 'https://steamdb.info/app/%d/info', $obj->{l_steam} ] if $obj->{l_steam};
+ c 'steamdb', 'SteamDB', 'https://steamdb.info/app/%d/info', $obj->{l_steam} if $obj->{l_steam};
l 'l_dlsite', $obj->{l_dlsite_price};
- l 'l_dlsiteen', $obj->{l_dlsiteen_price};
l 'l_gog';
l 'l_itch';
+ l 'l_patreonp';
+ l 'l_patreon';
+ l 'l_substar';
+ l 'l_gamejolt';
l 'l_denpa', $obj->{l_denpa_price};
l 'l_jlist', $obj->{l_jlist_price};
- l 'l_jastusa';
+ l 'l_jastusa', $obj->{l_jast_price};
+ l 'l_fakku';
+ l 'l_appstore';
+ l 'l_googplay';
+ l 'l_animateg';
+ l 'l_freem';
+ l 'l_freegame';
+ l 'l_novelgam';
l 'l_gyutto';
l 'l_digiket';
l 'l_melon';
+ l 'l_melonjp';
l 'l_mg', $obj->{l_mg_price};
+ l 'l_nutaku';
l 'l_getchu';
l 'l_getchudl';
l 'l_dmm';
- push @links, map [ 'PlayAsia', $_->{url}, $_->{price} ], @{$obj->{l_playasia}} if $obj->{l_playasia};
+ l 'l_toranoana';
+ l 'l_booth';
+ l 'l_playstation_jp';
+ l 'l_playstation_na';
+ l 'l_playstation_eu';
+ l 'l_playstation_hk';
+ l 'l_nintendo';
+ l 'l_nintendo_jp';
+ l 'l_nintendo_hk';
+ c 'playasia', 'PlayAsia', '%s', $_->{url}, $_->{price} for $obj->{l_playasia}->@*;
}
# Staff links
@@ -205,10 +435,15 @@ sub enrich_extlinks {
l 'l_twitter'; w 'twitter' if !$obj->{l_twitter};
l 'l_anidb'; w 'anidb_person' if !$obj->{l_anidb};
l 'l_pixiv'; w 'pixiv_user' if !$obj->{l_pixiv};
- w 'musicbrainz_artist';
- w 'vgmdb_artist';
- w 'discogs_artist';
- w 'doujinshi_author';
+ l 'l_mbrainz'; w 'musicbrainz_artist' if !$obj->{l_mbrainz};
+ l 'l_vgmdb'; w 'vgmdb_artist' if !$obj->{l_vgmdb};
+ l 'l_discogs'; w 'discogs_artist' if !$obj->{l_discogs};
+ l 'l_scloud'; w 'soundcloud' if !$obj->{l_scloud};
+ l 'l_mobygames';
+ l 'l_bgmtv';
+ l 'l_imdb';
+ l 'l_vndb';
+ #w 'doujinshi_author';
}
# Producer links
@@ -216,11 +451,13 @@ sub enrich_extlinks {
w 'twitter';
w 'mobygames_company';
w 'gamefaqs_company';
- w 'doujinshi_author';
- push @links, [ 'VNStat', sprintf 'https://vnstat.net/developer/%d', $obj->{id} ];
+ #w 'doujinshi_author';
+ w 'soundcloud';
+ c 'vnstat', 'VNStat', 'https://vnstat.net/developer/%d', $obj->{id} =~ s/^.//r;
}
- $obj->{extlinks} = \@links
+ $obj->{extlinks} = \@links;
+ delete @{$obj}{ @cleanup };
}
}
@@ -235,4 +472,46 @@ sub revision_extlinks {
}
+# Turn a 'regex' value in %LINKS into a full proper regex.
+sub full_regex { qr{^(?:https?://)?$_[0](?:\#.*)?$} }
+
+
+# Returns a list of keys for inclusion into a TUWF::Validate schema.
+# Only includes links for which a 'regex' has been set.
+sub validate_extlinks {
+ my($type) = @_;
+ my($schema) = grep +($_->{dbentry_type}||'') eq $type, values VNDB::Schema::schema->%*;
+
+ map {
+ my($f, $p) = ($_, $LINKS{$type}{$_});
+ my($s) = grep $_->{name} eq $f, $schema->{cols}->@*;
+
+ my %val;
+ $val{int} = 1 if $s->{type} =~ /^(big)?int/;
+ $val{maxlength} = 512 if !$val{int};
+ $val{func} = sub { $val{int} && !$_[0] ? 1 : sprintf($p->{fmt}, $_[0]) =~ full_regex $p->{regex} };
+ ($f, $s->{type} =~ /\[\]/
+ ? { type => 'array', values => \%val }
+ : { default => $s->{decl} !~ /not\s+null/i ? undef : $val{int} ? 0 : '', %val }
+ )
+ } sort grep $LINKS{$type}{$_}{regex}, keys $LINKS{$type}->%*
+}
+
+
+# Returns a list of sites for use in VNWeb::Elm and util/jsgen.pl:
+# { id => $id, name => $label, fmt => $label, regex => $regex, int => $bool, default => undef||0||''||[], pattern => [..] }
+sub extlinks_sites {
+ my($type) = @_;
+ my($schema) = grep +($_->{dbentry_type}||'') eq $type, values VNDB::Schema::schema->%*;
+ map {
+ my($f, $p) = ($_, $LINKS{$type}{$_});
+ my($s) = grep $_->{name} eq $f, $schema->{cols}->@*;
+ my $patt = $p->{patt} || ($p->{fmt} =~ s/%s/<code>/rg =~ s/%[0-9]*d/<number>/rg);
+ +{ id => $f, name => $p->{label}, fmt => $p->{fmt}, regex => full_regex($p->{regex})
+ , int => $s->{type} =~ /^(big)?int/ ? 1 : 0,
+ , default => $s->{type} =~ /\[\]/ ? [] : $s->{decl} !~ /not\s+null/i ? undef : $s->{type} =~ /^(big)?int/ ? 0 : ''
+ , pattern => [ split /(<[^>]+>)/, $patt ] }
+ } sort grep $LINKS{$type}{$_}{regex}, keys $LINKS{$type}->%*
+}
+
1;
diff --git a/lib/VNDB/Func.pm b/lib/VNDB/Func.pm
index 94d1281e..8c448ad8 100644
--- a/lib/VNDB/Func.pm
+++ b/lib/VNDB/Func.pm
@@ -1,166 +1,160 @@
-
package VNDB::Func;
use strict;
use warnings;
-use TUWF ':html', 'kv_validate', 'xml_escape', 'uri_escape';
+use TUWF::Misc 'uri_escape';
use Exporter 'import';
-use POSIX 'strftime', 'ceil', 'floor';
-use JSON::XS;
-use VNDBUtil;
+use POSIX 'strftime', 'floor';
+use Socket 'inet_pton', 'inet_ntop', 'AF_INET', 'AF_INET6';
+use Digest::SHA 'sha1';
+use VNDB::Config;
use VNDB::Types;
use VNDB::BBCode;
-our @EXPORT = (@VNDBUtil::EXPORT, 'bb2html', 'bb2text', qw|
- clearfloat cssicon minage fil_parse fil_serialize parenttags
- childtags charspoil imgpath imgurl
- fmtvote fmtmedia fmtvnlen fmtage fmtdatestr fmtdate fmtrating fmtspoil
- lang_attr
- json_encode json_decode script_json
- form_compare
+our @EXPORT = ('bb_format', qw|
+ in
+ idcmp
+ shorten
+ resolution
+ gtintype
+ imgsize
+ norm_ip
+ minage
+ fmtvote fmtmedia fmtage fmtdate fmtrating fmtspoil fmtanimation
+ rdate
+ imgpath imgurl
+ tlang tattr
query_encode
+ md2html
+ is_insecurepass
|);
-# three ways to represent the same information
-our $fil_escape = '_ !"#$%&\'()*+,-./:;<=>?@[\]^`{}~';
-our @fil_escape = split //, $fil_escape;
-our %fil_escape = map +($fil_escape[$_], sprintf '%02d', $_), 0..$#fil_escape;
-
-
-# Clears a float, to make sure boxes always have the correct height
-sub clearfloat {
- div class => 'clearfloat', '';
+# Simple "is this element in the array?" function, using 'eq' to test equality.
+# Supports both an @array and \@array.
+# Usage:
+#
+# my $contains_hi = in 'hi', qw/ a b hi c /; # true
+#
+sub in {
+ my($q, @a) = @_;
+ $_ eq $q && return 1 for map ref $_ eq 'ARRAY' ? @$_ : ($_), @a;
+ 0
}
-# Draws a CSS icon, arguments: class, title
-sub cssicon {
- abbr class => "icons $_[0]", title => $_[1];
- lit '&#xa0;';
- end;
+# Compare two vndbids, using proper numeric order
+sub idcmp($$) {
+ my($a1, $a2) = $_[0] =~ /^([a-z]+)([0-9]+)$/;
+ my($b1, $b2) = $_[1] =~ /^([a-z]+)([0-9]+)$/;
+ $a1 cmp $b1 || $a2 <=> $b2
}
-sub minage {
- my($a, $ex) = @_;
- $a = $AGE_RATING{$a};
- $ex && $a->{ex} ? "$a->{txt} (e.g. $a->{ex})" : $a->{txt}
+sub shorten {
+ my($str, $len) = @_;
+ return length($str) > $len ? substr($str, 0, $len-3).'...' : $str;
}
-# arguments: $filter_string, @allowed_keys
-sub fil_parse {
- my $str = shift;
- my %keys = map +($_,1), @_;
- my %r;
- for (split /\./, $str) {
- next if !/^([a-z0-9_]+)-([a-zA-Z0-9_~\x81-\x{ffffff}]+)$/ || !$keys{$1};
- my($f, $v) = ($1, $2);
- my @v = split /~/, $v;
- s/_([0-9]{2})/$1 > $#fil_escape ? '' : $fil_escape[$1]/eg for(@v);
- $r{$f} = @v > 1 ? \@v : $v[0]
- }
- return \%r;
+sub resolution {
+ my($x,$y) = @_;
+ ($x,$y) = ($x->{reso_x}, $x->{reso_y}) if ref $x;
+ $x ? "${x}x${y}" : $y == 1 ? 'Non-standard' : undef
}
-sub fil_serialize {
- my $fil = shift;
- my $e = qr/([\Q$fil_escape\E])/;
- return join '.', map {
- my @v = ref $fil->{$_} ? @{$fil->{$_}} : ($fil->{$_});
- s/$e/_$fil_escape{$1}/g for(@v);
- $_.'-'.join '~', @v
- } grep defined($fil->{$_}), keys %$fil;
+# GTIN code as argument,
+# Returns 'JAN', 'EAN', 'UPC', 'ISBN' or undef,
+# Also 'normalizes' the first argument in place
+sub gtintype {
+ $_[0] =~ s/[^\d]+//g;
+ $_[0] =~ s/^0+//;
+ return undef if $_[0] !~ /^[0-9]{10,13}$/; # I've yet to see a UPC code shorter than 10 digits assigned to a game
+ $_[0] = ('0'x(12-length $_[0])) . $_[0] if length($_[0]) < 12; # pad with zeros to GTIN-12
+ my $c = shift;
+ return undef if $c !~ /^[0-9]{12,13}$/;
+ $c = "0$c" if length($c) == 12; # pad with another zero for GTIN-13
+
+ # calculate check digit according to
+ # http://www.gs1.org/productssolutions/barcodes/support/check_digit_calculator.html#how
+ my @n = reverse split //, $c;
+ my $n = shift @n;
+ $n += $n[$_] * ($_ % 2 != 0 ? 1 : 3) for (0..$#n);
+ return undef if $n % 10 != 0;
+
+ # Do some rough guesses based on:
+ # http://www.gs1.org/productssolutions/barcodes/support/prefix_list.html
+ # and http://en.wikipedia.org/wiki/List_of_GS1_country_codes
+ local $_ = $c;
+ return 'JAN' if /^4[59]/; # prefix code 450-459 & 490-499
+ return 'UPC' if /^(?:0[01]|0[6-9]|13|75[45])/; # prefix code 000-019 & 060-139 & 754-755
+ return 'ISBN' if /^97[89]/;
+ return undef if /^(?:0[2-5]|2|9[6-9])/; # some codes we don't want: 020–059 & 200-299 & non-ISBN 977-999
+ return 'EAN'; # let's just call everything else EAN :)
}
-# generates a parent tags/traits listing
-sub parenttags {
- my($t, $index, $type) = @_;
- p;
- my @p = _parenttags(@{$t->{parents}});
- for my $p (@p ? @p : []) {
- a href => "/$type", $index;
- for (reverse @$p) {
- txt ' > ';
- a href => "/$type$_->{id}", $_->{name};
- }
- txt " > $t->{name}";
- br;
- }
- end 'p';
-}
-
-# arg: tag/trait hashref
-# returns: [ [ tag1, tag2, tag3 ], [ tag1, tag2, tag5 ] ]
-sub _parenttags {
- my @r;
- for my $t (@_) {
- for (@{$t->{'sub'}}) {
- push @r, [ $t, @$_ ] for _parenttags($_);
- }
- push @r, [$t] if !@{$t->{'sub'}};
+# arguments: <image size>, <max dimensions>
+# returns the size of the thumbnail with the same aspect ratio as the full-size
+# image, but fits within the specified maximum dimensions
+sub imgsize {
+ my($ow, $oh, $sw, $sh) = @_;
+ return ($ow, $oh) if $ow <= $sw && $oh <= $sh;
+ if($ow/$oh > $sw/$sh) { # width is the limiting factor
+ $oh *= $sw/$ow;
+ $ow = $sw;
+ } else {
+ $ow *= $sh/$oh;
+ $oh = $sh;
}
- return @r;
+ return (int ($ow+0.5), int ($oh+0.5));
}
-# a child tags/traits box
-sub childtags {
- my($self, $title, $type, $t, $order) = @_;
-
- div class => 'mainbox';
- h1 $title;
- ul class => 'tagtree';
- for my $p (sort { !$order ? @{$b->{'sub'}} <=> @{$a->{'sub'}} : $a->{$order} <=> $b->{$order} } @{$t->{childs}}) {
- li;
- a href => "/$type$p->{id}", $p->{name};
- b class => 'grayedout', " ($p->{c_items})" if $p->{c_items};
- end, next if !@{$p->{'sub'}};
- ul;
- for (0..$#{$p->{'sub'}}) {
- last if $_ >= 5 && @{$p->{'sub'}} > 6;
- li;
- txt '> ';
- a href => "/$type$p->{sub}[$_]{id}", $p->{'sub'}[$_]{name};
- b class => 'grayedout', " ($p->{sub}[$_]{c_items})" if $p->{'sub'}[$_]{c_items};
- end;
- }
- if(@{$p->{'sub'}} > 6) {
- my $c = @{$p->{'sub'}}-5;
- li;
- txt '> ';
- a href => "/$type$p->{id}", style => 'font-style: italic',
- sprintf '%d more %s%s', $c, $type eq 'g' ? 'tag' : 'trait', $c==1 ? '' : 's';
- end;
- }
- end;
- end 'li';
+# Normalized IP address to use for duplicate detection/throttling. For IPv4
+# this is the /23 subnet (is this enough?), for IPv6 the /48 subnet, with the
+# least significant bits of the address zero'd.
+sub norm_ip {
+ my $ip = shift;
+
+ # There's a whole bunch of IP manipulation modules on CPAN, but many seem
+ # quite bloated and still don't offer the functionality to return an IP
+ # with its mask applied (admittedly not a common operation). The libc
+ # socket functions will do fine in parsing and formatting addresses, and
+ # the actual masking is quite trivial in binary form.
+ my $v4 = inet_pton AF_INET, $ip;
+ if($v4) {
+ $v4 =~ s/(..)(.)./$1 . chr(ord($2) & 254) . "\0"/se;
+ return inet_ntop AF_INET, $v4;
}
- end 'ul';
- clearfloat;
- br;
- end 'div';
+
+ $ip = inet_pton AF_INET6, $ip;
+ return '::' if !$ip;
+ $ip =~ s/^(.{6}).+$/$1 . "\0"x10/se;
+ return inet_ntop AF_INET6, $ip;
}
-# generates the class elements for character spoiler hiding
-sub charspoil {
- return "charspoil charspoil_$_[0]";
+sub minage {
+ my($a, $ex) = @_;
+ return 'Unknown' if !defined $a;
+ $a = $AGE_RATING{$a};
+ $ex && $a->{ex} ? "$a->{txt} (e.g. $a->{ex})" : $a->{txt}
}
-# generates a local path to an image in static/
-sub imgpath { # <type>, <id>
- return sprintf '%s/static/%s/%02d/%d.jpg', $TUWF::OBJ->{root}, $_[0], $_[1]%100, $_[1];
+sub _path {
+ my($t, $id) = $_[1] =~ /([a-z]+)([0-9]+)/;
+ sprintf '%s/%s%s/%02d/%d.%s', $_[0], $t, $_[2] ? ".$_[2]" : '', $id%100, $id, $_[3]||'jpg';
}
+# imgpath($image_id, $dir, $format)
+# $dir = empty || 't' || 'orig'
+# $format = empty || $file_ext
+sub imgpath { _path config->{var_path}.'/static', @_ }
-# generates a URL for an image in static/
-sub imgurl {
- return sprintf '%s/%s/%02d/%d.jpg', $TUWF::OBJ->{url_static}, $_[0], $_[1]%100, $_[1];
-}
+# imgurl($image_id, $dir, $format)
+sub imgurl { _path config->{url_static}, @_ }
# Formats a vote number.
@@ -177,13 +171,6 @@ sub fmtmedia {
$med->{ $med->{qty} && $qty > 1 ? 'plural' : 'txt' };
}
-# Formats a VN length (xtra = time indication)
-sub fmtvnlen {
- my($len, $xtra) = @_;
- $len = $VN_LENGTH{$len};
- $len->{txt}.($xtra && $len->{time} ? " ($len->{time})" : '');
-}
-
# Formats a UNIX timestamp as a '<number> <unit> ago' string
sub fmtage {
my $a = time-shift;
@@ -199,32 +186,12 @@ sub fmtage {
sprintf '%d %s ago', $t, $t == 1 ? $single : $plural;
}
-# argument: database release date format (yyyymmdd)
-# y = 0000 -> unknown
-# y = 9999 -> TBA
-# m = 99 -> month+day unknown
-# d = 99 -> day unknown
-# return value: (unknown|TBA|yyyy|yyyy-mm|yyyy-mm-dd)
-# if date > now: <b class="future">str</b>
-sub fmtdatestr {
- my $date = sprintf '%08d', shift||0;
- my $future = $date > strftime '%Y%m%d', gmtime;
- my($y, $m, $d) = ($1, $2, $3) if $date =~ /^([0-9]{4})([0-9]{2})([0-9]{2})$/;
-
- my $str = $y == 0 ? 'unknown' : $y == 9999 ? 'TBA' :
- $m == 99 ? sprintf('%04d', $y) :
- $d == 99 ? sprintf('%04d-%02d', $y, $m) :
- sprintf('%04d-%02d-%02d', $y, $m, $d);
-
- return $str if !$future;
- return qq|<b class="future">$str</b>|;
-}
# argument: unix timestamp and optional format (compact/full)
sub fmtdate {
my($t, $f) = @_;
- return strftime '%Y-%m-%d', gmtime $t if !$f || $f eq 'compact';
- return strftime '%Y-%m-%d at %R', gmtime $t;
+ return strftime '%Y-%m-%d', localtime $t if !$f || $f eq 'compact';
+ return strftime '%Y-%m-%d at %R', localtime $t;
}
# Turn a (natural number) vote into a rating indication
@@ -250,72 +217,118 @@ sub fmtspoil {
}
-# Generates a HTML 'lang' attribute given a list of possible languages.
-# This is used for the 'original language' field, which we can safely assume is not used for latin-alphabet languages.
-sub lang_attr {
- my @l = ref $_[0] ? $_[0]->@* : @_;
- # Choose Japanese, Chinese or Korean (in order of likelyness) if those are in the list.
- return (lang => 'ja') if grep $_ eq 'ja', @l;
- return (lang => 'zh') if grep $_ eq 'zh', @l;
- return (lang => 'ko') if grep $_ eq 'ko', @l;
- return (lang => $l[0]) if @l == 1;
- ()
+sub fmtanimation {
+ my($a, $cat) = @_;
+ return if !defined $a;
+ return $cat ? ucfirst "$cat not animated" : 'Not animated' if !$a;
+ return $cat ? "No $cat" : 'Not applicable' if $a == 1;
+ ($a & 256 ? 'Some scenes ' : $a & 512 ? 'All scenes ' : '').join('/',
+ $a & 4 ? 'Hand drawn' : (),
+ $a & 8 ? 'Vectorial' : (),
+ $a & 16 ? '3D' : (),
+ $a & 32 ? 'Live action' : ()
+ ).($cat ? " $cat" : '');
}
-
-# JSON::XS::encode_json converts input to utf8, whereas the below functions
-# operate on wide character strings. Canonicalization is enabled to allow for
-# proper comparison of serialized objects.
-my $JSON = JSON::XS->new;
-$JSON->canonical(1);
-
-sub json_encode ($) {
- $JSON->encode(@_);
+# Format a release date as a string.
+sub rdate {
+ my($y, $m, $d) = ($1, $2, $3) if sprintf('%08d', shift||0) =~ /^([0-9]{4})([0-9]{2})([0-9]{2})$/;
+ $y == 0 ? 'unknown' :
+ $y == 9999 ? 'TBA' :
+ $m == 99 ? sprintf('%04d', $y) :
+ $d == 99 ? sprintf('%04d-%02d', $y, $m) :
+ sprintf('%04d-%02d-%02d', $y, $m, $d);
}
-sub json_decode ($) {
- $JSON->decode(@_);
-}
-# Insert JSON-encoded data as script, arguments: id, object
-sub script_json {
- script id => $_[0], type => 'application/json';
- my $js = json_encode $_[1];
- $js =~ s/</\\u003C/g; # escape HTML tags like </script> and <!--
- lit $js;
- end;
+# Given a language code & title, returns a (lang => $x) html property.
+sub tlang {
+ my($lang, $title) = @_;
+ # TODO: The -Latn suffix is redundant for languages that use the Latin script by default, need to check with a list.
+ # English is the site's default, so no need to specify that.
+ $lang && $lang ne 'en'
+ ? (lang => $lang . ($title =~ /[\x{0400}-\x{04ff}\x{0600}-\x{06ff}\x{0e00}-\x{0e7f}\x{1100}-\x{11ff}\x{1400}-\x{167f}\x{3040}-\x{3099}\x{30a1}-\x{30fa}\x{3100}-\x{9fff}\x{ac00}-\x{d7af}\x{ff66}-\x{ffdc}\x{20000}-\x{323af}]/ ? '' : '-Latn'))
+ : ();
}
-
-# Compare the keys in %$old with the keys in %$new. Returns 1 if a difference was found, 0 otherwise.
-sub form_compare {
- my($old, $new) = @_;
- for my $k (keys %$old) {
- my($o, $n) = ($old->{$k}, $new->{$k});
- return 1 if defined $n ne defined $o || ref $o ne ref $n;
- if(!defined $o) {
- # must be equivalent
- } elsif(!ref $o) {
- return 1 if $o ne $n;
- } else { # 'json' template
- return 1 if @$o != @$n;
- return 1 if grep form_compare($o->[$_], $n->[$_]), 0..$#$o;
- }
- }
- return 0;
+# Given an SQL titles array, returns element attributes & content.
+sub tattr {
+ my $title = ref $_[0] eq 'HASH' ? $_[0]{title} : $_[0];
+ (tlang($title->[0],$title->[1]), title => $title->[3], $title->[1])
}
-# 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;
}
-1;
+sub md2html {
+ require Text::MultiMarkdown;
+ my $html = Text::MultiMarkdown::markdown(shift, {
+ strip_metadata => 1,
+ img_ids => 0,
+ disable_footnotes => 1,
+ disable_bibliography => 1,
+ });
+
+ # Number sections and turn them into links
+ my($sec, $subsec) = (0,0);
+ $html =~ s{<h([1-2])[^>]+>(.*?)</h\1>}{
+ if($1 == 1) {
+ $sec++;
+ $subsec = 0;
+ qq{<h3><a href="#$sec" name="$sec">$sec. $2</a></h3>}
+ } elsif($1 == 2) {
+ $subsec++;
+ qq|<h4><a href="#$sec.$subsec" name="$sec.$subsec">$sec.$subsec. $2</a></h4>\n|
+ }
+ }ge;
+
+ # Text::MultiMarkdown doesn't handle fenced code blocks properly. The
+ # following solution breaks inline code blocks, but I don't use those anyway.
+ $html =~ s/<code>/<pre>/g;
+ $html =~ s#</code>#</pre>#g;
+ $html
+}
+
+
+sub is_insecurepass {
+ utf8::encode(local $_ = shift);
+ my $hash = sha1 $_;
+ my $dir = config->{var_path}.'/hibp';
+ return 0 if !-d $dir;
+
+ my $prefix = uc unpack 'H4', $hash;
+ my $data = substr $hash, 2, 10;
+ my $F;
+ if(!open $F, '<', "$dir/$prefix") {
+ warn "Unable to lookup password prefix $prefix: $!";
+ return 0;
+ }
+
+ # Plain old binary search.
+ # Would be nicer to search through an mmap'ed view of the file, or at least
+ # use pread(), but alas, neither are easily available in Perl.
+ my($left, $right) = (0, -10 + -s $F);
+ while($left <= $right) {
+ my $off = floor(($left+$right)/20)*10;
+ sysseek $F, $off, 0 or die $!;
+ 10 == sysread $F, my $buf, 10 or die $!;
+ return 1 if $buf eq $data;
+ if($buf lt $data) { $left = $off + 10; }
+ else { $right = $off - 10; }
+ }
+ 0;
+}
+
+1;
diff --git a/lib/VNDB/Handler/Chars.pm b/lib/VNDB/Handler/Chars.pm
deleted file mode 100644
index cff84607..00000000
--- a/lib/VNDB/Handler/Chars.pm
+++ /dev/null
@@ -1,621 +0,0 @@
-
-package VNDB::Handler::Chars;
-
-use strict;
-use warnings;
-use TUWF ':html', 'uri_escape';
-use Exporter 'import';
-use VNDB::Func;
-use VNDB::Types;
-use List::Util 'min';
-
-our @EXPORT = ('charOps', 'charTable', 'charBrowseTable');
-
-TUWF::register(
- qr{c([1-9]\d*)(?:\.([1-9]\d*))?} => \&page,
- qr{c(?:([1-9]\d*)(?:\.([1-9]\d*))?/(edit|copy)|/new)}
- => \&edit,
- qr{c/([a-z0]|all)} => \&list,
-);
-
-
-sub page {
- my($self, $id, $rev) = @_;
-
- my $method = $rev ? 'dbCharGetRev' : 'dbCharGet';
- my $r = $self->$method(
- id => $id,
- what => 'extended traits vns seiyuu',
- $rev ? ( rev => $rev ) : ()
- )->[0];
- return $self->resNotFound if !$r->{id};
-
- my $metadata = {
- 'og:title' => $r->{name},
- 'og:description' => bb2text($r->{desc}),
- 'og:image' => $r->{image} && imgurl(ch => $r->{image}),
- };
-
- $self->htmlHeader(title => $r->{name}, noindex => $rev, metadata => $metadata);
- $self->htmlMainTabs(c => $r);
- return if $self->htmlHiddenMessage('c', $r);
-
- if($rev) {
- my $prev = $rev && $rev > 1 && $self->dbCharGetRev(id => $id, rev => $rev-1, what => 'extended traits vns')->[0];
- $self->htmlRevision('c', $prev, $r,
- [ name => 'Name', diff => 1 ],
- [ original => 'Original name', diff => 1 ],
- [ alias => 'Aliases', diff => qr/[ ,\n\.]/ ],
- [ desc => 'Description', diff => qr/[ ,\n\.]/ ],
- [ gender => 'Sex', serialize => sub { $GENDER{$_[0]} } ],
- [ b_month => 'Birthday/month',serialize => sub { $_[0]||'[empty]' } ],
- [ b_day => 'Birthday/day', serialize => sub { $_[0]||'[empty]' } ],
- [ s_bust => 'Bust', serialize => sub { $_[0]||'[empty]' } ],
- [ s_waist => 'Waist', serialize => sub { $_[0]||'[empty]' } ],
- [ s_hip => 'Hip', serialize => sub { $_[0]||'[empty]' } ],
- [ height => 'Height', serialize => sub { $_[0]||'[empty]' } ],
- [ weight => 'Weight', serialize => sub { $_[0]//'[empty]' } ],
- [ bloodt => 'Blood type', serialize => sub { $BLOOD_TYPE{$_[0]} } ],
- [ cup_size => 'Cup size', serialize => sub { $CUP_SIZE{$_[0]} } ],
- [ age => 'Age', serialize => sub { $_[0]//'[empty]' } ],
- [ main => 'Main character',htmlize => sub { $_[0] ? sprintf '<a href="/c%d">c%d</a>', $_[0], $_[0] : '[empty]' } ],
- [ main_spoil=> 'Spoiler', serialize => \&fmtspoil ],
- [ image => 'Image', htmlize => sub {
- return $_[0] ? sprintf '<img src="%s" />', imgurl(ch => $_[0]) : 'No image';
- }],
- [ traits => 'Traits', join => '<br />', split => sub {
- map sprintf('%s<a href="/i%d">%s</a> (%s)', $_->{group}?qq|<b class="grayedout">$_->{groupname} / </b> |:'',
- $_->{tid}, $_->{name}, fmtspoil $_->{spoil}), @{$_[0]}
- }],
- [ vns => 'Visual novels', join => '<br />', split => sub {
- map sprintf('<a href="/v%d">v%d</a> %s %s (%s)', $_->{vid}, $_->{vid},
- $_->{rid}?sprintf('[<a href="/r%d">r%d</a>]', $_->{rid}, $_->{rid}):'',
- $CHAR_ROLE{$_->{role}}{txt}, fmtspoil $_->{spoil}), @{$_[0]};
- }],
- );
- }
-
- div class => 'charops', id => 'charops';
- $self->charOps(1, 'char');
-
- div class => 'mainbox';
- $self->htmlItemMessage('c', $r);
- h1 $r->{name};
- h2 class => 'alttitle', $r->{original} if $r->{original};
- $self->charTable($r);
- end;
-
- # TODO: ordering of these instances?
- my $inst = [];
- if(!$r->{main}) {
- $inst = $self->dbCharGet(instance => $r->{id}, what => 'extended traits vns seiyuu');
- } else {
- $inst = $self->dbCharGet(instance => $r->{main}, notid => $r->{id}, what => 'extended traits vns seiyuu');
- push @$inst, $self->dbCharGet(id => $r->{main}, what => 'extended traits vns seiyuu')->[0];
- }
- if(@$inst) {
- my $spoil = sub { local $_=shift; !$r->{main} ? $_->{main_spoil} : $_->{main_spoil} > $r->{main_spoil} ? $_->{main_spoil} : $r->{main_spoil} };
- my $minspoil = min map $spoil->($_), @$inst;
- div class => 'mainbox '.charspoil($minspoil);
- h1 'Other instances';
- $self->charTable($_, 1, $_ != $inst->[0], 0, $spoil->($_)) for @$inst;
- end;
- }
-
- end;
-
- $self->htmlFooter;
-}
-
-
-sub charOps {
- my($self, $sexual, $blockId) = @_;
- $blockId ||= 'charops_block';
- my $spoil = $self->authPref('spoilers')||0;
-
- if($sexual) {
- my $id_sex = $blockId.'_sex';
- input type => 'checkbox', class => 'visuallyhidden sexual_check', id => $id_sex, ($self->authPref('traits_sexual') ? (checked => 'checked') : ());
- label for => $id_sex, class => 'lst sec', 'Show sexual traits';
- }
-
- my $id_2 = $blockId.'_2';
- input type => 'radio', class => 'visuallyhidden radio_spoil2', name => $blockId, id => $id_2, $spoil == 2 ? (checked => 'checked') : ();
- label for => $id_2, $sexual ? () : (class => 'lst'), 'Spoil me!';
-
- my $id_1 = $blockId.'_1';
- input type => 'radio', class => 'visuallyhidden radio_spoil1', name => $blockId, id => $id_1, $spoil == 1 ? (checked => 'checked') : ();
- label for => $id_1, 'Show minor spoilers';
-
- my $id_0 = $blockId.'_0';
- input type => 'radio', class => 'visuallyhidden radio_spoil0', name => $blockId, id => $id_0, $spoil == 0 ? (checked => 'checked') : ();
- label for => $id_0, 'Hide spoilers';
-}
-
-
-# Also used from Handler::VNPage
-sub charTable {
- my($self, $r, $link, $sep, $vn, $spoil) = @_;
- $spoil ||= 0;
-
- div class => 'chardetails '.charspoil($spoil).($sep ? ' charsep' : '');
-
- # image
- div class => 'charimg';
- if(!$r->{image}) {
- p 'No image uploaded yet';
- } else {
- img src => imgurl(ch => $r->{image}), alt => $r->{name};
- }
- end 'div';
-
- # info table
- table class => 'stripe';
- thead;
- Tr;
- td colspan => 2;
- if($link) {
- a href => "/c$r->{id}", style => 'margin-right: 10px; font-weight: bold', $r->{name};
- } else {
- b style => 'margin-right: 10px', $r->{name};
- }
- b class => 'grayedout', style => 'margin-right: 10px', $r->{original} if $r->{original};
- cssicon "gen $r->{gender}", $GENDER{$r->{gender}} if $r->{gender} ne 'unknown';
- span $BLOOD_TYPE{$r->{bloodt}} if $r->{bloodt} ne 'unknown';
- end;
- end;
- end;
-
- if($r->{alias}) {
- $r->{alias} =~ s/\n/, /g;
- Tr;
- td class => 'key', 'Aliases';
- td $r->{alias};
- end;
- }
- if(defined($r->{weight}) || $r->{height} || $r->{s_bust} || $r->{s_waist} || $r->{s_hip} || $r->{cup_size}) {
- Tr;
- td class => 'key', 'Measurements';
- td join ', ',
- $r->{height} ? "Height: $r->{height}cm" : (),
- defined($r->{weight}) ? "Weight: $r->{weight}kg" : (),
- $r->{s_bust} || $r->{s_waist} || $r->{s_hip} ?
- sprintf 'Bust-Waist-Hips: %s-%s-%scm', $r->{s_bust}||'??', $r->{s_waist}||'??', $r->{s_hip}||'??' : (),
- $r->{cup_size} ? "$CUP_SIZE{$r->{cup_size}} cup" : ();
- end;
- }
- if($r->{b_month} && $r->{b_day}) {
- Tr;
- td class => 'key', 'Birthday';
- td $r->{b_day}.' '.[qw{January February March April May June July August September October November December}]->[$r->{b_month}-1];
- end;
- }
- if(defined $r->{age}) {
- Tr;
- td class => 'key', 'Age';
- td $r->{age};
- end;
- }
-
- # traits
- my %groups;
- my @groups;
- for (@{$r->{traits}}) {
- my $g = $_->{group}||$_->{tid};
- push @groups, $g if !$groups{$g};
- push @{$groups{ $g }}, $_
- }
- for my $g (@groups) {
- Tr class => 'traitrow';
- td class => 'key'; a href => '/i'.($groups{$g}[0]{group}||$groups{$g}[0]{tid}), $groups{$g}[0]{groupname} || $groups{$g}[0]{name}; end;
- td;
- for (0..$#{$groups{$g}}) {
- my $t = $groups{$g}[$_];
- span class => charspoil($t->{spoil}).($t->{sexual} ? ' sexual' : '');
- txt ', ';
- a href => "/i$t->{tid}", $t->{name};
- end;
- }
- end;
- end;
- }
-
- # vns
- if(@{$r->{vns}} && (!$vn || $vn && (@{$r->{vns}} > 1 || $r->{vns}[0]{rid}))) {
- my %vns;
- push @{$vns{$_->{vid}}}, $_ for(sort { !defined($a->{rid})?1:!defined($b->{rid})?-1:$a->{rtitle} cmp $b->{rtitle} } @{$r->{vns}});
- Tr;
- td class => 'key', $vn ? 'Releases' : 'Visual novels';
- td;
- my $first = 0;
- for my $g (sort { $vns{$a}[0]{vntitle} cmp $vns{$b}[0]{vntitle} } keys %vns) {
- my @r = @{$vns{$g}};
- # special case: all releases, no exceptions
- if(!$vn && @r == 1 && !$r[0]{rid}) {
- span class => charspoil $r[0]{spoil};
- txt $CHAR_ROLE{$r[0]{role}}{txt}.' - ';
- a href => "/v$r[0]{vid}/chars", $r[0]{vntitle};
- br;
- end;
- next;
- }
- # otherwise, print VN title and list releases separately
- my $minspoil = 5;
- $minspoil = $minspoil > $_->{spoil} ? $_->{spoil} : $minspoil for (@r);
- span class => charspoil $minspoil;
- a href => "/v$r[0]{vid}/chars", $r[0]{vntitle} if !$vn;
- for(@r) {
- span class => charspoil $_->{spoil};
- br if !$vn || $_ != $r[0];
- b class => 'grayedout', '> ';
- txt $CHAR_ROLE{$_->{role}}{txt}.' - ';
- if($_->{rid}) {
- b class => 'grayedout', "r$_->{rid}:";
- a href => "/r$_->{rid}", $_->{rtitle};
- } else {
- txt 'All other releases';
- }
- end;
- }
- br;
- end;
- }
- end;
- end;
- }
-
- if(@{$r->{seiyuu}}) {
- Tr;
- td class => 'key', 'Voiced by';
- td;
- my $last_name = '';
- for my $s (sort { $a->{name} cmp $b->{name} } @{$r->{seiyuu}}) {
- next if $s->{name} eq $last_name;
- a href => "/s$s->{sid}", title => $s->{original}||$s->{name}, $s->{name};
- txt ' ('.$s->{note}.')' if $s->{note};
- br;
- $last_name = $s->{name};
- }
- end;
- end;
- }
-
- # description
- if($r->{desc}) {
- Tr class => 'nostripe';
- td class => 'chardesc', colspan => 2;
- h2 'Description';
- p;
- lit bb2html $r->{desc}, 0, 1;
- end;
- end;
- end;
- }
-
- end 'table';
- end;
- clearfloat;
-}
-
-
-
-sub edit {
- my($self, $id, $rev, $copy) = @_;
-
- $copy = $rev && $rev eq 'copy' || $copy && $copy eq 'copy';
- $rev = undef if defined $rev && $rev !~ /^\d+$/;
-
- my $r = $id && $self->dbCharGetRev(id => $id, what => 'extended vns traits', $rev ? (rev => $rev) : ())->[0];
- return $self->resNotFound if $id && !$r->{id};
- $rev = undef if !$r || $r->{lastrev};
-
- return $self->htmlDenied if !$self->authCan('edit')
- || $id && (($r->{locked} || $r->{hidden}) && !$self->authCan('dbmod'));
-
- my %b4 = !$id ? () : (
- (map +($_ => $r->{$_}), qw|name original alias desc image ihid ilock s_bust s_waist s_hip height weight bloodt cup_size age gender main_spoil|),
- main => $r->{main}||0,
- bday => $r->{b_month} ? sprintf('%02d-%02d', $r->{b_month}, $r->{b_day}) : '',
- traits => join(' ', map sprintf('%d-%d', $_->{tid}, $_->{spoil}), sort { $a->{tid} <=> $b->{tid} } @{$r->{traits}}),
- vns => join(' ', map sprintf('%d-%d-%d-%s', $_->{vid}, $_->{rid}||0, $_->{spoil}, $_->{role}),
- sort { $a->{vid} <=> $b->{vid} || ($a->{rid}||0) <=> ($b->{rid}||0) } @{$r->{vns}}),
- );
- my $frm;
-
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'name', maxlength => 200 },
- { post => 'original', required => 0, maxlength => 200, default => '' },
- { post => 'alias', required => 0, maxlength => 500, default => '' },
- { post => 'desc', required => 0, maxlength => 5000, default => '' },
- { post => 'gender', required => 0, default => 'unknown', enum => [ keys %GENDER ] },
- { post => 'image', required => 0, default => 0, template => 'id' },
- { post => 'bday', required => 0, default => '', regex => [ qr/^(?:[01]?[0-9])-(?:[0123]?[0-9])$/, 'Birthday must be in MM-DD format.' ] },
- { post => 's_bust', required => 0, default => 0, template => 'uint', max => 32767 },
- { post => 's_waist', required => 0, default => 0, template => 'uint', max => 32767 },
- { post => 's_hip', required => 0, default => 0, template => 'uint', max => 32767 },
- { post => 'height', required => 0, default => 0, template => 'uint', max => 32767 },
- { post => 'weight', required => 0, default => undef, template => 'uint', max => 32767 },
- { post => 'bloodt', required => 0, default => 'unknown', enum => [ keys %BLOOD_TYPE ] },
- { post => 'cup_size', required => 0, default => '', enum => [ keys %CUP_SIZE ] },
- { post => 'age', required => 0, default => undef, template => 'uint', max => 32767 },
- { post => 'main', required => 0, default => 0, template => 'id' },
- { post => 'main_spoil', required => 0, default => 0, enum => [ 0..2 ] },
- { post => 'traits', required => 0, default => '', regex => [ qr/^(?:[1-9]\d*-[0-2])(?: +[1-9]\d*-[0-2])*$/, 'Incorrect trait format.' ] },
- { post => 'vns', required => 0, default => '', regex => [ qr/^(?:[1-9]\d*-\d+-[0-2]-[a-z]+)(?: +[1-9]\d*-\d+-[0-2]-[a-z]+)*$/, 'Incorrect VN format.' ] },
- { post => 'editsum', template => 'editsum' },
- { post => 'ihid', required => 0 },
- { post => 'ilock', required => 0 },
- );
- $frm->{original} = '' if $frm->{original} eq $frm->{name};
-
- # handle image upload
- $frm->{image} = _uploadimage($self, $frm);
-
- # validate main character
- if(!$frm->{_err} && $frm->{main}) {
- my $m = $self->dbCharGet(id => $frm->{main}, what => 'extended')->[0];
- push @{$frm->{_err}}, 'Invalid main character. Make sure the ID is correct,'
- .' that the main character itself is not an instance of an other character,'
- .' and that this entry is not used as a main character elsewhere.'
- if !$m || $m->{main} || $r && !$copy && ($m->{id} == $r->{id} || $self->dbCharGet(instance => $r->{id})->[0]);
- }
-
- my(@traits, @vns);
- if(!$frm->{_err}) {
- # parse and normalize
- @vns = sort { $a->[0] <=> $b->[0] || $a->[1] <=> $b->[1] } map [split /-/], split / /, $frm->{vns};
- $frm->{vns} = join(' ', map sprintf('%d-%d-%d-%s', @$_), @vns);
- $frm->{ihid} = $frm->{ihid} ?1:0;
- $frm->{ilock} = $frm->{ilock}?1:0;
- $frm->{desc} = $self->bbSubstLinks($frm->{desc});
- $frm->{main_spoil} = 0 if !$frm->{main};
-
- @traits = sort { $a->[0] <=> $b->[0] } map /^(\d+)-(\d+)$/&&[$1,$2], split / /, $frm->{traits};
- my %traits = @traits ? map +($_->{id}, 1), @{$self->dbTraitGet(results => 500, state => 2, applicable => 1, id => [ map $_->[0], @traits ])} : ();
- @traits = grep $traits{$_->[0]}, @traits;
- $frm->{traits} = join(' ', map sprintf('%d-%d', @$_), @traits);
-
- # check for changes
- my $same = $id && !grep +($frm->{$_}//'') ne ($b4{$_}//''), keys %b4;
- return $self->resRedirect("/c$id", 'post') if !$copy && $same;
- $frm->{_err} = ["No changes, please don't create an entry that is fully identical to another"] if $copy && $same;
- }
-
- if(!$frm->{_err}) {
- # modify for dbCharRevisionInsert
- ($frm->{b_month}, $frm->{b_day}) = delete($frm->{bday}) =~ /^(\d{2})-(\d{2})$/ ? ($1, $2) : (0, 0);
- $frm->{main} ||= undef;
- $frm->{traits} = \@traits;
- $_->[1]||=undef for (@vns);
- $frm->{vns} = \@vns;
-
- my $nrev = $self->dbItemEdit(c => !$copy && $id ? ($r->{id}, $r->{rev}) : (undef, undef), %$frm);
- return $self->resRedirect("/c$nrev->{itemid}.$nrev->{rev}", 'post');
- }
- }
-
- if(!$id) {
- my $vid = $self->formValidate({ get => 'vid', required => 1, template => 'id'});
- $frm->{vns} //= "$vid->{vid}-0-0-primary" if !$vid->{_err};
- }
- $frm->{$_} //= $b4{$_} for keys %b4;
- $frm->{editsum} //= sprintf 'Reverted to revision c%d.%d', $id, $rev if !$copy && $rev;
- $frm->{editsum} = sprintf 'New character based on c%d.%d', $id, $r->{rev} if $copy;
-
- my $title = !$r ? 'Add new character' : $copy ? "Copy $r->{name}" : "Edit $r->{name}";
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('c', $r, $copy ? 'copy' : 'edit') if $r;
- $self->htmlEditMessage('c', $r, $title, $copy);
- $self->htmlForm({ frm => $frm, action => $r ? "/c$id/".($copy ? 'copy' : 'edit') : '/c/new', editsum => 1, upload => 1 },
- chare_geninfo => [ 'General info',
- [ input => name => 'Name (romaji)', short => 'name' ],
- [ input => name => 'Original name', short => 'original' ],
- [ static => content => 'The original name of the character, leave blank if it is already in the Latin alphabet.' ],
- [ text => name => 'Aliases', short => 'alias', rows => 3 ],
- [ static => content => '(Un)official aliases, separated by a newline.' ],
- [ text => name => 'Description<br /><b class="standout">English please!</b>', short => 'desc', rows => 6 ],
- [ select => name => 'Sex', short => 'gender', options => [
- map [ $_, $GENDER{$_} ], keys %GENDER ] ],
- [ input => name => 'Birthday', short => 'bday', width => 100,post => ' MM-DD (e.g. "01-26" for the 26th of January)' ],
- [ input => name => 'Age', short => 'age', width => 50, post => ' years', allow0 => 1 ],
- [ input => name => 'Bust', short => 's_bust', width => 50, post => ' cm' ],
- [ input => name => 'Waist', short => 's_waist',width => 50, post => ' cm' ],
- [ input => name => 'Hips', short => 's_hip', width => 50, post => ' cm' ],
- [ input => name => 'Height', short => 'height', width => 50, post => ' cm' ],
- [ input => name => 'Weight', short => 'weight', width => 50, post => ' kg', allow0 => 1 ],
- [ select => name => 'Blood type',short => 'bloodt', options => [
- map [ $_, $BLOOD_TYPE{$_} ], keys %BLOOD_TYPE ] ],
- [ select => name => 'Cup size', short => 'cup_size', options => [
- map [ $_, $CUP_SIZE{$_} ], keys %CUP_SIZE ] ],
- [ static => content => '<br />' ],
- [ input => name => 'Instance of',short => 'main', width => 50, post => ' ID of the main character - the character of which this is an instance of.' ],
- [ select => name => 'Spoiler', short => 'main_spoil', options => [
- map [$_, fmtspoil $_], 0..2 ] ],
- ],
-
- chare_img => [ 'Image', [ static => nolabel => 1, content => sub {
- div class => 'img';
- p 'No image uploaded yet' if !$frm->{image};
- img src => imgurl(ch => $frm->{image}) if $frm->{image};
- end;
-
- div;
- h2 'Image ID';
- input type => 'text', class => 'text', name => 'image', id => 'image', value => $frm->{image}||'';
- p 'Use a character image that is already on the server. Set to \'0\' to remove the current image.';
- br; br;
-
- h2 'Upload new image';
- input type => 'file', class => 'text', name => 'img', id => 'img';
- p 'Image must be in JPEG or PNG format and at most 1MiB. Images larger than 256x300 will automatically be resized. Image must be safe for work!';
- end;
- }]],
-
- chare_traits => [ 'Traits',
- [ hidden => short => 'traits' ],
- [ static => nolabel => 1, content => sub {
- h2 'Current traits';
- table; tbody id => 'traits_tbl';
- Tr id => 'traits_loading'; td colspan => '3', 'Loading...'; end;
- end; end;
- h2 'Add trait';
- table; Tr;
- td class => 'tc_name'; input id => 'trait_input', type => 'text', class => 'text'; end;
- td colspan => 2, '';
- end; end 'table';
- }],
- ],
-
- chare_vns => [ 'Visual novels',
- [ hidden => short => 'vns' ],
- [ static => nolabel => 1, content => sub {
- h2 'Selected visual novels';
- table; tbody id => 'vns_tbl';
- Tr id => 'vns_loading'; td colspan => '4', 'Loading...'; end;
- end; end;
- h2 'Add visual novel';
- table; Tr;
- td class => 'tc_vnadd'; input id => 'vns_input', type => 'text', class => 'text'; end;
- td colspan => 3, '';
- end; end;
- }],
- ]);
- $self->htmlFooter;
-}
-
-
-sub _uploadimage {
- my($self, $frm) = @_;
-
- if($frm->{_err} || !$self->reqPost('img')) {
- return 0 if !$frm->{image};
- push @{$frm->{_err}}, 'No image with that ID' if !-s imgpath(ch => $frm->{image});
- return $frm->{image};
- }
-
- # perform some elementary checks
- my $imgdata = $self->reqUploadRaw('img');
- $frm->{_err} = [ 'Image must be in JPEG or PNG format' ] if $imgdata !~ /^(\xff\xd8|\x89\x50)/; # JPG or PNG headers
- $frm->{_err} = [ 'Image is too large, only 1MB allowed' ] if length($imgdata) > 1024*1024;
- return undef if $frm->{_err};
-
- # resize/compress
- my $im = Image::Magick->new;
- $im->BlobToImage($imgdata);
- my($ow, $oh) = ($im->Get('width'), $im->Get('height'));
- my($nw, $nh) = imgsize($ow, $oh, @{$self->{ch_size}});
- $im->Set(background => '#ffffff');
- $im->Set(alpha => 'Remove');
- if($ow != $nw || $oh != $nh) {
- $im->GaussianBlur(geometry => '0.5x0.5');
- $im->Resize(width => $nw, height => $nh);
- $im->UnsharpMask(radius => 0, sigma => 0.75, amount => 0.75, threshold => 0.008);
- }
- $im->Set(magick => 'JPEG', quality => 90);
-
- # Get ID and save
- my $imgid = $self->dbCharImageId;
- my $fn = imgpath(ch => $imgid);
- $im->Write($fn);
- chmod 0666, $fn;
-
- return $imgid;
-}
-
-
-sub list {
- my($self, $fch) = @_;
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'q', required => 0, default => '' },
- { get => 'fil', required => 0, default => '' },
- );
- return $self->resNotFound if $f->{_err};
-
- my($list, $np) = $self->filFetchDB(char => $f->{fil}, {
- tagspoil => $self->authPref('spoilers')||0,
- }, {
- $fch ne 'all' ? ( char => $fch ) : (),
- $f->{q} ? ( search => $f->{q} ) : (),
- results => 50,
- page => $f->{p},
- what => 'vns',
- });
-
- $self->htmlHeader(title => 'Browse characters');
-
- my $quri = uri_escape($f->{q});
- form action => '/c/all', 'accept-charset' => 'UTF-8', method => 'get';
- div class => 'mainbox';
- h1 'Browse characters';
- $self->htmlSearchBox('c', $f->{q});
- p class => 'browseopts';
- for ('all', 'a'..'z', 0) {
- a href => "/c/$_?q=$quri;fil=$f->{fil}", $_ eq $fch ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#';
- }
- end;
-
- p class => 'filselect';
- a id => 'filselect', href => '#c';
- lit '<i>&#9656;</i> Filters<i></i>';
- end;
- end;
- input type => 'hidden', class => 'hidden', name => 'fil', id => 'fil', value => $f->{fil};
- end;
- end 'form';
-
- if(!@$list) {
- div class => 'mainbox';
- h1 'No results';
- p 'No characters found that matched your criteria.';
- end;
- }
-
- @$list && $self->charBrowseTable($list, $np, $f, "/c/$fch?q=$quri;fil=$f->{fil}");
-
- $self->htmlFooter;
-}
-
-
-# Also used on Handler::Traits
-sub charBrowseTable {
- my($self, $list, $np, $f, $uri) = @_;
-
- $self->htmlBrowse(
- class => 'charb',
- items => $list,
- options => $f,
- nextpage => $np,
- pageurl => $uri,
- sorturl => $uri,
- header => [ [ '' ], [ '' ] ],
- row => sub {
- my($s, $n, $l) = @_;
- Tr;
- td class => 'tc1';
- cssicon "gen $l->{gender}", $GENDER{$l->{gender}} if $l->{gender} ne 'unknown';
- end;
- td class => 'tc2';
- a href => "/c$l->{id}", title => $l->{original}||$l->{name}, shorten $l->{name}, 50;
- b class => 'grayedout';
- my $i = 1;
- my %vns;
- for (@{$l->{vns}}) {
- next if $_->{spoil} || $vns{$_->{vid}}++;
- last if $i++ > 4;
- txt ', ' if $i > 2;
- a href => "/v$_->{vid}/chars", title => $_->{vntitle}, shorten $_->{vntitle}, 30;
- }
- end;
- end;
- end;
- }
- )
-}
-
-
-1;
-
diff --git a/lib/VNDB/Handler/Misc.pm b/lib/VNDB/Handler/Misc.pm
deleted file mode 100644
index 25d10c39..00000000
--- a/lib/VNDB/Handler/Misc.pm
+++ /dev/null
@@ -1,252 +0,0 @@
-
-package VNDB::Handler::Misc;
-
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml', 'uri_escape';
-use VNDB::Func;
-use VNDB::Types;
-
-
-TUWF::register(
- qr{}, \&homepage,
- 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') },
-);
-
-
-sub homepage {
- my $self = shift;
-
- my $title = 'The Visual Novel Database';
- my $desc = 'VNDB.org strives to be a comprehensive database for information about visual novels.';
-
- my $metadata = {
- 'og:type' => 'website',
- 'og:title' => $title,
- 'og:description' => $desc,
- };
-
- $self->htmlHeader(title => $title, feeds => 1, metadata => $metadata);
-
- div class => 'mainbox';
- h1 $title;
- p class => 'description';
- txt $desc;
- br;
- txt 'This website is built as a wiki, meaning that anyone can freely add'
- .' and contribute information to the database, allowing us to create the'
- .' largest, most accurate and most up-to-date visual novel database on the web.';
- end;
-
- # with filters applied it's signifcantly slower, so special-code the situations with and without filters
- my @vns;
- if($self->authPref('filter_vn')) {
- my $r = $self->filFetchDB(vn => undef, undef, {hasshot => 1, results => 4, sort => 'rand'});
- @vns = map $_->{id}, @$r;
- }
- my $scr = $self->dbScreenshotRandom(@vns);
- p class => 'screenshots';
- for (@$scr) {
- my($w, $h) = imgsize($_->{width}, $_->{height}, @{$self->{scr_size}});
- a href => "/v$_->{vid}", title => $_->{title};
- img src => imgurl(st => $_->{scr}), alt => $_->{title}, width => $w, height => $h;
- end;
- }
- end;
- end 'div';
-
- table class => 'mainbox threelayout';
- Tr;
-
- # Recent changes
- td;
- h1;
- a href => '/hist', 'Recent Changes'; txt ' ';
- a href => '/feeds/changes.atom'; cssicon 'feed', 'Atom Feed'; end;
- end;
- my $changes = $self->dbRevisionGet(results => 10, auto => 1);
- ul;
- for (@$changes) {
- li;
- txt "$_->{type}:";
- a href => "/$_->{type}$_->{itemid}.$_->{rev}", title => $_->{ioriginal}||$_->{ititle}, shorten $_->{ititle}, 33;
- lit " by ";
- VNWeb::HTML::user_($_);
- end;
- }
- end;
- end 'td';
-
- # Announcements
- td;
- my $an = $self->dbThreadGet(type => 'an', sort => 'id', reverse => 1, results => 2);
- h1;
- a href => '/t/an', 'Announcements'; txt ' ';
- a href => '/feeds/announcements.atom'; cssicon 'feed', 'Atom Feed'; end;
- end;
- for (@$an) {
- my $post = $self->dbPostGet(tid => $_->{id}, num => 1)->[0];
- h2;
- a href => "/t$_->{id}", $_->{title};
- end;
- p;
- lit bb2html $post->{msg}, 150;
- end;
- }
- end 'td';
-
- # Recent posts
- td;
- h1;
- a href => '/t/all', 'Recent Posts'; txt ' ';
- a href => '/feeds/posts.atom'; cssicon 'feed', 'Atom Feed'; end;
- end;
- my $posts = $self->dbThreadGet(what => 'lastpost boardtitles', results => 10, sort => 'lastpost', reverse => 1, notusers => 1);
- ul;
- for (@$posts) {
- my $boards = join ', ', map $BOARD_TYPE{$_->{type}}{txt}.($_->{iid}?' > '.$_->{title}:''), @{$_->{boards}};
- li;
- txt fmtage($_->{lastpost_date}).' ';
- a href => VNWeb::Discussions::Lib::post_url($_->{id}, $_->{count}, 'last'), title => "Posted in $boards", shorten $_->{title}, 25;
- lit ' by ';
- VNWeb::HTML::user_($_, 'lastpost_');
- end;
- }
- end;
- end 'td';
-
- end 'tr';
- Tr;
-
- # Random visual novels
- td;
- h1;
- a href => '/v/rand', 'Random visual novels';
- end;
- my $random = $self->filFetchDB(vn => undef, undef, {results => 10, sort => 'rand'});
- ul;
- for (@$random) {
- li;
- a href => "/v$_->{id}", title => $_->{original}||$_->{title}, shorten $_->{title}, 40;
- end;
- }
- end;
- end 'td';
-
- # Upcoming releases
- td;
- h1;
- a href => '/r?fil=released-0;o=a;s=released', 'Upcoming releases';
- end;
- my $upcoming = $self->filFetchDB(release => undef, undef, {results => 10, released => 0, what => 'platforms'});
- ul;
- for (@$upcoming) {
- li;
- lit fmtdatestr $_->{released};
- txt ' ';
- cssicon $_, $PLATFORM{$_} for (@{$_->{platforms}});
- cssicon "lang $_", $LANGUAGE{$_} for (@{$_->{languages}});
- txt ' ';
- a href => "/r$_->{id}", title => $_->{original}||$_->{title}, shorten $_->{title}, 30;
- end;
- }
- end;
- end 'td';
-
- # Just released
- td;
- h1;
- a href => '/r?fil=released-1;o=d;s=released', 'Just released';
- end;
- my $justrel = $self->filFetchDB(release => undef, undef, {results => 10, sort => 'released', reverse => 1, released => 1, what => 'platforms'});
- ul;
- for (@$justrel) {
- li;
- lit fmtdatestr $_->{released};
- txt ' ';
- cssicon $_, $PLATFORM{$_} for (@{$_->{platforms}});
- cssicon "lang $_", $LANGUAGE{$_} for (@{$_->{languages}});
- txt ' ';
- a href => "/r$_->{id}", title => $_->{original}||$_->{title}, shorten $_->{title}, 30;
- end;
- }
- end;
- end 'td';
-
- end 'tr';
- end 'table';
-
- $self->htmlFooter;
-}
-
-
-sub nospam {
- my $self = shift;
- $self->htmlHeader(title => 'Could not send form', noindex => 1);
-
- div class => 'mainbox';
- h1 'Could not send form';
- div class => 'warning';
- h2 'Error';
- p 'The form could not be sent, please make sure you have Javascript enabled in your browser.';
- end;
- end;
-
- $self->htmlFooter;
-}
-
-
-sub prefs {
- my $self = shift;
- return if !$self->authCheckCode;
- return $self->resNotFound if !$self->authInfo->{id};
- my $f = $self->formValidate(
- { get => 'key', enum => [qw|filter_vn filter_release|] },
- { get => 'value', required => 0, maxlength => 2000 },
- );
- return $self->resNotFound if $f->{_err};
- $self->authPref($f->{key}, $f->{value});
-
- # doesn't really matter what we return, as long as it's XML
- $self->resHeader('Content-type' => 'text/xml');
- xml;
- tag 'done', '';
-}
-
-
-sub opensearch {
- my $self = shift;
- my $h = $self->reqBaseURI();
- $self->resHeader('Content-Type' => 'application/opensearchdescription+xml');
- xml;
- tag 'OpenSearchDescription',
- xmlns => 'http://a9.com/-/spec/opensearch/1.1/', 'xmlns:moz' => 'http://www.mozilla.org/2006/browser/search/';
- tag 'ShortName', 'VNDB';
- tag 'LongName', 'VNDB.org visual novel search';
- tag 'Description', 'Search visual vovels on VNDB.org';
- tag 'Image', width => 16, height => 16, type => 'image/x-icon', "$h/favicon.ico";
- tag 'Url', type => 'text/html', method => 'get', template => "$h/v/all?q={searchTerms}", undef;
- tag 'Url', type => 'application/opensearchdescription+xml', rel => 'self', template => "$h/opensearch.xml", undef;
- tag 'Query', role => 'example', searchTerms => 'Tsukihime', undef;
- tag 'moz:SearchForm', "$h/v/all";
- end 'OpenSearchDescription';
-}
-
-
-1;
-
diff --git a/lib/VNDB/Handler/Producers.pm b/lib/VNDB/Handler/Producers.pm
deleted file mode 100644
index 7a1a287c..00000000
--- a/lib/VNDB/Handler/Producers.pm
+++ /dev/null
@@ -1,500 +0,0 @@
-
-package VNDB::Handler::Producers;
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml', 'xml_escape', 'html_escape';
-use VNDB::Func;
-use VNDB::Types;
-use VNDB::ExtLinks;
-
-
-TUWF::register(
- qr{p([1-9]\d*)/rg} => \&rg,
- qr{p([1-9]\d*)(?:\.([1-9]\d*))?} => \&page,
- qr{p/add} => \&addform,
- qr{p(?:([1-9]\d*)(?:\.([1-9]\d*))?/edit|/new)}
- => \&edit,
- qr{p/([a-z0]|all)} => \&list,
- qr{xml/producers\.xml} => \&pxml,
-);
-
-
-sub rg {
- my($self, $pid) = @_;
-
- my $p = $self->dbProducerGet(id => $pid, what => 'relgraph')->[0];
- return $self->resNotFound if !$p->{id} || !$p->{rgraph};
-
- my $title = "Relation graph for $p->{name}";
- return if $self->htmlRGHeader($title, 'p', $p);
-
- $p->{svg} =~ s/id="node_p$pid"/id="graph_current"/;
-
- div class => 'mainbox';
- h1 $title;
- p class => 'center';
- lit $p->{svg};
- end;
- end;
- $self->htmlFooter;
-}
-
-
-sub page {
- my($self, $pid, $rev) = @_;
-
- my $method = $rev ? 'dbProducerGetRev' : 'dbProducerGet';
- my $p = $self->$method(
- id => $pid,
- what => 'extended relations',
- $rev ? ( rev => $rev ) : ()
- )->[0];
- return $self->resNotFound if !$p->{id};
- enrich_extlinks p => $p;
-
- my $metadata = {
- 'og:title' => $p->{name},
- 'og:description' => bb2text $p->{desc},
- };
-
- $self->htmlHeader(title => $p->{name}, noindex => $rev, metadata => $metadata);
- $self->htmlMainTabs(p => $p);
- return if $self->htmlHiddenMessage('p', $p);
-
- if($rev) {
- my $prev = $rev && $rev > 1 && $self->dbProducerGetRev(id => $pid, rev => $rev-1, what => 'extended relations')->[0];
- $self->htmlRevision('p', $prev, $p,
- [ type => 'Type', serialize => sub { $PRODUCER_TYPE{$_[0]} } ],
- [ name => 'Name (romaji)', diff => 1 ],
- [ original => 'Original name', diff => 1 ],
- [ alias => 'Aliases', diff => qr/[ ,\n\.]/ ],
- [ lang => 'Language', serialize => sub { "$_[0] ($LANGUAGE{$_[0]})" } ],
- [ website => 'Website', diff => 1 ],
- [ l_wp => 'Wikipedia link',htmlize => sub {
- $_[0] ? sprintf '<a href="http://en.wikipedia.org/wiki/%s">%1$s</a>', xml_escape $_[0] : '[empty]'
- }],
- [ l_wikidata=> 'Wikidata ID', htmlize => sub { $_[0] ? sprintf '<a href="https://www.wikidata.org/wiki/Q%d">Q%1$d</a>', $_[0] : '[empty]' } ],
- [ desc => 'Description', diff => qr/[ ,\n\.]/ ],
- [ relations => 'Relations', join => '<br />', split => sub {
- my @r = map sprintf('%s: <a href="/p%d" title="%s">%s</a>',
- $PRODUCER_RELATION{$_->{relation}}{txt}, $_->{id}, xml_escape($_->{original}||$_->{name}), xml_escape shorten $_->{name}, 40
- ), sort { $a->{id} <=> $b->{id} } @{$_[0]};
- return @r ? @r : ('[empty]');
- }],
- );
- }
-
- div class => 'mainbox';
- $self->htmlItemMessage('p', $p);
- h1 $p->{name};
- h2 class => 'alttitle', lang => $p->{lang}, $p->{original} if $p->{original};
- p class => 'center';
- txt "$LANGUAGE{$p->{lang}} $PRODUCER_TYPE{$p->{type}}";
- if($p->{alias}) {
- (my $alias = $p->{alias}) =~ s/\n/, /g;
- br;
- txt "a.k.a. $alias";
- }
-
- br if $p->{extlinks}->@*;
- for($p->{extlinks}->@*) {
- a href => $_->[1], $_->[0];
- txt ' - ' if $_ ne $p->{extlinks}[$#{$p->{extlinks}}];
- }
- end 'p';
-
- if(@{$p->{relations}}) {
- my %rel;
- push @{$rel{$_->{relation}}}, $_
- for (sort { $a->{name} cmp $b->{name} } @{$p->{relations}});
- p class => 'center';
- br;
- for my $r (keys %PRODUCER_RELATION) {
- next if !$rel{$r};
- txt $PRODUCER_RELATION{$r}{txt}.': ';
- for (@{$rel{$r}}) {
- a href => "/p$_->{id}", title => $_->{original}||$_->{name}, shorten $_->{name}, 40;
- txt ', ' if $_ ne $rel{$r}[$#{$rel{$r}}];
- }
- br;
- }
- end 'p';
- }
-
- if($p->{desc}) {
- p class => 'description';
- lit bb2html $p->{desc};
- end;
- }
- end 'div';
-
- _releases($self, $p);
-
- $self->htmlFooter;
-}
-
-sub _releases {
- my($self, $p) = @_;
-
- # prodpage_(dev|pub)
- my $r = $self->dbReleaseGet(pid => $p->{id}, results => 999, what => 'vn platforms links');
- enrich_extlinks r => $r;
-
- div class => 'mainbox';
- a href => '#', id => 'expandprodrel', 'collapse';
- h1 'Releases';
- if(!@$r) {
- p 'We have currently no visual novels by this producer.';
- end;
- return;
- }
-
- my %vn; # key = vid, value = [ $r1, $r2, $r3, .. ]
- my @vn; # $vn objects in order of first release
- for my $rel (@$r) {
- for my $v (@{$rel->{vn}}) {
- push @vn, $v if !$vn{$v->{vid}};
- push @{$vn{$v->{vid}}}, $rel;
- }
- }
-
- table id => 'prodrel';
- for my $v (@vn) {
- Tr class => 'vn';
- td colspan => 6;
- i; lit fmtdatestr $vn{$v->{vid}}[0]{released}; end;
- a href => "/v$v->{vid}", title => $v->{original}, $v->{title};
- span '('.join(', ',
- (grep($_->{developer}, @{$vn{$v->{vid}}}) ? 'developer' : ()),
- (grep($_->{publisher}, @{$vn{$v->{vid}}}) ? 'publisher' : ())
- ).')';
- end;
- end;
- for my $rel (@{$vn{$v->{vid}}}) {
- Tr class => 'rel';
- td class => 'tc1'; lit fmtdatestr $rel->{released}; end;
- td class => 'tc2', $rel->{minage} < 0 ? '' : minage $rel->{minage};
- td class => 'tc3';
- for (sort @{$rel->{platforms}}) {
- next if $_ eq 'oth';
- cssicon $_, $PLATFORM{$_};
- }
- cssicon "lang $_", $LANGUAGE{$_} for (@{$rel->{languages}});
- cssicon "rt$rel->{type}", $rel->{type};
- end;
- td class => 'tc4';
- a href => "/r$rel->{id}", title => $rel->{original}||$rel->{title}, $rel->{title};
- b class => 'grayedout', ' (patch)' if $rel->{patch};
- end;
- td class => 'tc5', join ', ',
- ($rel->{developer} ? 'developer' : ()), ($rel->{publisher} ? 'publisher' : ());
- td class => 'tc6';
- $self->releaseExtLinks($rel);
- end;
- end 'tr';
- }
- }
- end 'table';
- end 'div';
-}
-
-
-sub addform {
- my $self = shift;
- return $self->htmlDenied if !$self->authCan('edit');
-
- my $frm;
- my $l = [];
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'name', maxlength => 200 },
- { post => 'original', required => 0, maxlength => 200, default => '' },
- { post => 'alias', required => 0, maxlength => 500, default => '' },
- { post => 'continue_ign',required => 0 },
- );
-
- # look for duplicates
- if(!$frm->{_err} && !$frm->{continue_ign}) {
- $l = $self->dbProducerGet(search => $frm->{name}, what => 'extended', results => 50, inc_hidden => 1);
- push @$l, @{$self->dbProducerGet(search => $frm->{original}, what => 'extended', results => 50, inc_hidden => 1)} if $frm->{original};
- $_ && push @$l, @{$self->dbProducerGet(search => $_, what => 'extended', results => 50, inc_hidden => 1)} for(split /\n/, $frm->{alias});
- my %ids = map +($_->{id}, $_), @$l;
- $l = [ map $ids{$_}, sort { $ids{$a}{name} cmp $ids{$b}{name} } keys %ids ];
- }
-
- return edit($self, undef, undef, 1) if !@$l && !$frm->{_err};
- }
-
- $self->htmlHeader(title => 'Add a new producer', noindex => 1);
- if(@$l) {
- div class => 'mainbox';
- h1 'Possible duplicates found';
- div class => 'warning';
- p;
- txt 'The following is a list of producers that match the name(s) you gave.'
- .' Please check this list to avoid creating a duplicate producer entry.'
- .' Be especially wary of items that have been deleted! To see why an entry has been deleted, click on its title.';
- br; br;
- txt 'To add the producer anyway, hit the "Continue and ignore duplicates" button below.';
- end;
- end;
- ul;
- for(@$l) {
- li;
- a href => "/p$_->{id}", title => $_->{original}||$_->{name}, "p$_->{id}: ".shorten($_->{name}, 50);
- b class => 'standout', ' deleted' if $_->{hidden};
- end;
- }
- end;
- end 'div';
- }
-
- $self->htmlForm({ frm => $frm, action => '/p/add', continue => @$l ? 2 : 1 },
- vn_add => [ 'Add a new producer',
- [ input => name => 'Name (romaji)', short => 'name' ],
- [ input => name => 'Original name', short => 'original' ],
- [ static => content => 'The original name of the producer, leave blank if it is already in the Latin alphabet.' ],
- [ textarea => short => 'alias', name => 'Aliases', rows => 4 ],
- [ static => content => '(Un)official aliases, separated by a newline.' ],
- ]);
- $self->htmlFooter;
-}
-
-
-# pid as argument = edit producer
-# no arguments = add new producer
-sub edit {
- my($self, $pid, $rev, $nosubmit) = @_;
-
- my $p = $pid && $self->dbProducerGetRev(id => $pid, what => 'extended relations', rev => $rev)->[0];
- return $self->resNotFound if $pid && !$p->{id};
- $rev = undef if !$p || $p->{lastrev};
-
- return $self->htmlDenied if !$self->authCan('edit')
- || $pid && (($p->{locked} || $p->{hidden}) && !$self->authCan('dbmod'));
-
- my %b4 = !$pid ? () : (
- (map { $_ => $p->{$_} } qw|type name original lang website l_wikidata desc alias ihid ilock|),
- prodrelations => join('|||', map $_->{relation}.','.$_->{id}.','.$_->{name}, sort { $a->{id} <=> $b->{id} } @{$p->{relations}}),
- );
- my $frm;
-
- if($self->reqMethod eq 'POST') {
- return if !$nosubmit && !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'type', required => !$nosubmit, enum => [ keys %PRODUCER_TYPE ] },
- { post => 'name', maxlength => 200 },
- { post => 'original', required => 0, maxlength => 200, default => '' },
- { post => 'alias', required => 0, maxlength => 500, default => '' },
- { post => 'lang', required => !$nosubmit, enum => [ keys %LANGUAGE ] },
- { post => 'website', required => 0, maxlength => 250, default => '', template => 'weburl' },
- { post => 'l_wikidata', required => 0, template => 'wikidata' },
- { post => 'desc', required => 0, maxlength => 5000, default => '' },
- { post => 'prodrelations', required => 0, maxlength => 5000, default => '' },
- { post => 'editsum', required => !$nosubmit, template => 'editsum' },
- { post => 'ihid', required => 0 },
- { post => 'ilock', required => 0 },
- );
- $frm->{original} = '' if $frm->{original} eq $frm->{name};
- if(!$nosubmit && !$frm->{_err}) {
- # parse
- my $relations = [ map { /^([a-z]+),([0-9]+),(.+)$/ && (!$pid || $2 != $pid) ? [ $1, $2, $3 ] : () } split /\|\|\|/, $frm->{prodrelations} ];
-
- # normalize
- $frm->{ihid} = $frm->{ihid}?1:0;
- $frm->{ilock} = $frm->{ilock}?1:0;
- $frm->{desc} = $self->bbSubstLinks($frm->{desc});
- $relations = [] if $frm->{ihid};
- $frm->{prodrelations} = join '|||', map $_->[0].','.$_->[1].','.$_->[2], sort { $a->[1] <=> $b->[1]} @{$relations};
-
- return $self->resRedirect("/p$pid", 'post')
- if $pid && !grep +(($frm->{$_}//'') ne ($b4{$_}//'')), keys %b4;
-
- $frm->{relations} = $relations;
- my $nrev = $self->dbItemEdit(p => $pid||undef, $pid ? $p->{rev} : undef, %$frm);
-
- # update reverse relations
- if(!$pid && $#$relations >= 0 || $pid && $frm->{prodrelations} ne $b4{prodrelations}) {
- my %old = $pid ? (map { $_->{id} => $_->{relation} } @{$p->{relations}}) : ();
- my %new = map { $_->[1] => $_->[0] } @$relations;
- _updreverse($self, \%old, \%new, $nrev->{itemid}, $nrev->{rev});
- }
-
- return $self->resRedirect("/p$nrev->{itemid}.$nrev->{rev}", 'post');
- }
- }
-
- !defined $frm->{$_} && ($frm->{$_} = $b4{$_}) for keys %b4;
- $frm->{lang} = 'ja' if !$pid && !defined $frm->{lang};
- $frm->{editsum} = sprintf 'Reverted to revision p%d.%d', $pid, $rev if $rev && !defined $frm->{editsum};
-
- my $title = $pid ? "Edit $p->{name}" : 'Add new producer';
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('p', $p, 'edit') if $pid;
- $self->htmlEditMessage('p', $p, $title);
- $self->htmlForm({ frm => $frm, action => $pid ? "/p$pid/edit" : '/p/new', editsum => 1 },
- 'pedit_geninfo' => [ 'General info',
- [ select => name => 'Type', short => 'type',
- options => [ map [ $_, $PRODUCER_TYPE{$_} ], keys %PRODUCER_TYPE ] ],
- [ input => name => 'Name (romaji)', short => 'name' ],
- [ input => name => 'Original name', short => 'original' ],
- [ static => content => 'The original name of the producer, leave blank if it is already in the Latin alphabet.' ],
- [ textarea => short => 'alias', name => 'Aliases', rows => 4 ],
- [ static => content => '(Un)official aliases, separated by a newline.' ],
- [ select => name => 'Primary language', short => 'lang',
- options => [ map [ $_, "$LANGUAGE{$_} ($_)" ], sort { $LANGUAGE{$a} cmp $LANGUAGE{$b} } keys %LANGUAGE ] ],
- [ input => name => 'Website', short => 'website' ],
- [ input => short => 'l_wikidata',name => 'Wikidata ID',
- value => $frm->{l_wikidata} ? "Q$frm->{l_wikidata}" : '',
- post => qq{ (<a href="$self->{url_static}/f/wikidata.png">How to find this</a>)}
- ],
- [ text => name => 'Description<br /><b class="standout">English please!</b>', short => 'desc', rows => 6 ],
- ], 'pedit_rel' => [ 'Relations',
- [ hidden => short => 'prodrelations' ],
- [ static => nolabel => 1, content => sub {
- h2 'Selected producers';
- table;
- tbody id => 'relation_tbl';
- # to be filled using javascript
- end;
- end;
-
- h2 'Add producer';
- table;
- Tr id => 'relation_new';
- td class => 'tc_prod';
- input type => 'text', class => 'text';
- end;
- td class => 'tc_rel';
- Select;
- option value => $_, $PRODUCER_RELATION{$_}{txt}
- for (keys %PRODUCER_RELATION);
- end;
- end;
- td class => 'tc_add';
- a href => '#', 'add';
- end;
- end;
- end 'table';
- }],
- ]);
- $self->htmlFooter;
-}
-
-sub _updreverse {
- my($self, $old, $new, $pid, $rev) = @_;
- my %upd;
-
- # compare %old and %new
- for (keys %$old, keys %$new) {
- if(exists $$old{$_} and !exists $$new{$_}) {
- $upd{$_} = undef;
- } elsif((!exists $$old{$_} and exists $$new{$_}) || ($$old{$_} ne $$new{$_})) {
- $upd{$_} = $PRODUCER_RELATION{$$new{$_}}{reverse};
- }
- }
- return if !keys %upd;
-
- # edit all related producers
- for my $i (keys %upd) {
- my $r = $self->dbProducerGetRev(id => $i, what => 'relations')->[0];
- my @newrel = map $_->{id} != $pid ? [ $_->{relation}, $_->{id} ] : (), @{$r->{relations}};
- push @newrel, [ $upd{$i}, $pid ] if $upd{$i};
- $self->dbItemEdit(p => $i, $r->{rev},
- relations => \@newrel,
- editsum => "Reverse relation update caused by revision p$pid.$rev",
- uid => 1,
- );
- }
-}
-
-
-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;
-
- my $f = $self->formValidate(
- { get => 'q', required => 0, maxlength => 500 },
- { get => 'id', required => 0, multi => 1, template => 'id' },
- { get => 'r', required => 0, template => 'uint', min => 1, max => 50, default => 10 },
- );
- return $self->resNotFound if $f->{_err} || (!$f->{q} && !$f->{id} && !$f->{id}[0]);
-
- my($list, $np) = $self->dbProducerGet(
- !$f->{q} ? () : $f->{q} =~ /^p([1-9]\d*)/ ? (id => $1) : (search => $f->{q}, sort => 'search'),
- $f->{id} && $f->{id}[0] ? (id => $f->{id}) : (),
- results => $f->{r},
- page => 1,
- );
-
- $self->resHeader('Content-type' => 'text/xml; charset=UTF-8');
- xml;
- tag 'producers', more => $np ? 'yes' : 'no', query => $f->{q}||'';
- for(@$list) {
- tag 'item', id => $_->{id}, $_->{name};
- }
- end;
-}
-
-
-1;
-
diff --git a/lib/VNDB/Handler/Releases.pm b/lib/VNDB/Handler/Releases.pm
deleted file mode 100644
index 4b850e58..00000000
--- a/lib/VNDB/Handler/Releases.pm
+++ /dev/null
@@ -1,846 +0,0 @@
-
-package VNDB::Handler::Releases;
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml', 'uri_escape', 'xml_escape';
-use VNDB::Func;
-use VNDB::Types;
-use VNDB::ExtLinks;
-use Exporter 'import';
-
-our @EXPORT = ('releaseExtLinks');
-
-
-TUWF::register(
- qr{r([1-9]\d*)(?:\.([1-9]\d*))?} => \&page,
- qr{(v)([1-9]\d*)/add} => \&edit,
- qr{r} => \&browse,
- qr{r(?:([1-9]\d*)(?:\.([1-9]\d*))?/(edit|copy))}
- => \&edit,
- qr{r/engines} => \&engines,
- qr{xml/releases.xml} => \&relxml,
- qr{xml/engines.xml} => \&enginexml,
-);
-
-
-sub page {
- my($self, $rid, $rev) = @_;
-
- my $method = $rev ? 'dbReleaseGetRev' : 'dbReleaseGet';
- my $r = $self->$method(
- id => $rid,
- what => 'vn extended links producers platforms media',
- $rev ? (rev => $rev) : (),
- )->[0];
- return $self->resNotFound if !$r->{id};
- enrich_extlinks r => $r;
-
- my $metadata = {
- 'og:title' => $r->{title},
- 'og:description' => bb2text $r->{notes},
- };
-
- $self->htmlHeader(title => $r->{title}, noindex => $rev, metadata => $metadata);
- $self->htmlMainTabs('r', $r);
- return if $self->htmlHiddenMessage('r', $r);
-
- if($rev) {
- my $prev = $rev && $rev > 1 && $self->dbReleaseGetRev(
- id => $rid, rev => $rev-1,
- what => 'vn extended links producers platforms media changes'
- )->[0];
- $self->htmlRevision('r', $prev, $r,
- [ vn => 'Relations', join => '<br />', split => sub {
- map sprintf('<a href="/v%d" title="%s">%s</a>', $_->{vid}, $_->{original}||$_->{title}, shorten $_->{title}, 50), @{$_[0]};
- } ],
- [ type => 'Type' ],
- [ patch => 'Patch', serialize => sub { $_[0] ? 'Yes' : 'No' } ],
- [ freeware => 'Freeware', serialize => sub { $_[0] ? 'Yes' : 'No' } ],
- [ doujin => 'Doujin', serialize => sub { $_[0] ? 'Yes' : 'No' } ],
- [ uncensored => 'Uncensored', serialize => sub { $_[0] ? 'Yes' : 'No' } ],
- [ title => 'Title (romaji)', diff => 1 ],
- [ original => 'Original title', diff => 1 ],
- [ gtin => 'JAN/UPC/EAN', serialize => sub { $_[0]||'[empty]' } ],
- [ catalog => 'Catalog number', serialize => sub { $_[0]||'[empty]' } ],
- [ languages => 'Language', join => ', ', split => sub { map $LANGUAGE{$_}, @{$_[0]} } ],
- [ website => 'Website' ],
- [ l_egs => 'ErogameScape', htmlize => sub { $_[0] ? sprintf '<a href="https://erogamescape.dyndns.org/~ap2/ero/toukei_kaiseki/game.php?game=%d">%1$d</a>', $_[0] : '[empty]' } ],
- [ l_erotrail => 'ErogeTrailers', htmlize => sub { $_[0] ? sprintf '<a href="http://erogetrailers.com/soft/%d">%1$d</a>', $_[0] : '[empty]' } ],
- [ l_steam => 'Steam AppId', htmlize => sub { $_[0] ? sprintf '<a href="https://store.steampowered.com/app/%d/">%1$d</a>', $_[0] : '[empty]' } ],
- [ l_dlsite => 'DLsite (jpn)', htmlize => sub { $_[0] ? sprintf '<a href="'.sprintf($self->{dlsite_url}, 'home').'">%1$s</a>', $_[0] : '[empty]' } ],
- [ l_dlsiteen => 'DLsite (eng)', htmlize => sub { $_[0] ? sprintf '<a href="'.sprintf($self->{dlsite_url}, 'eng').'">%1$s</a>', $_[0] : '[empty]' } ],
- [ l_gog => 'GOG.com', htmlize => sub { $_[0] ? sprintf '<a href="https://www.gog.com/game/%s">%1$s</a>', $_[0] : '[empty]' } ],
- [ l_denpa => 'Denpasoft', htmlize => sub { $_[0] ? sprintf qq{<a href="$self->{denpa_url}">%1\$s</a>}, $_[0] : '[empty]' } ],
- [ l_jlist => 'J-List', htmlize => sub { $_[0] ? sprintf qq{<a href="$self->{jlist_url}">%1\$s</a>}, $_[0] : '[empty]' } ],
- [ l_gyutto => 'Gyutto', htmlize => sub { join ', ', map sprintf('<a href="https://gyutto.com/i/item%d">%1$s</a>', xml_escape $_), sort @{$_[0]} } ],
- [ l_digiket => 'Digiket', htmlize => sub { $_[0] ? sprintf '<a href="https://www.digiket.com/work/show/_data/ID=ITM%07d/">%1$d</a>', $_[0] : '[empty]' } ],
- [ l_melon => 'Melonbooks', htmlize => sub { $_[0] ? sprintf '<a href="https://www.melonbooks.com/index.php?main_page=product_info&products_id=IT%010d">%1$d</a>', $_[0] : '[empty]' } ],
- [ l_mg => 'MangaGamer', htmlize => sub { $_[0] ? sprintf qq{<a href="$self->{mg_r18_url}">%1\$d</a>}, $_[0] : '[empty]' } ],
- [ l_getchu => 'Getchu', htmlize => sub { $_[0] ? sprintf '<a href="http://www.getchu.com/soft.phtml?id=%d">%1$d</a>', $_[0] : '[empty]' } ],
- [ l_getchudl => 'DL.Getchu', htmlize => sub { $_[0] ? sprintf '<a href="http://dl.getchu.com/i/item%d">%1$d</a>', $_[0] : '[empty]' } ],
- [ l_dmm => 'DMM', htmlize => sub { join ', ', map sprintf('<a href="https://%s">%1$s</a>', xml_escape $_), sort @{$_[0]} } ],
- [ l_itch => 'Itch.io', htmlize => sub { $_[0] ? sprintf '<a href="https://%s">%1$s</a>', xml_escape $_[0] : '[empty]' } ],
- [ l_jastusa => 'JAST USA', htmlize => sub { $_[0] ? sprintf '<a href="https://jastusa.com/%s">%1$s</a>', xml_escape $_[0] : '[empty]' } ],
- [ released => 'Release date', htmlize => \&fmtdatestr ],
- [ minage => 'Age rating', serialize => \&minage ],
- [ notes => 'Notes', diff => qr/[ ,\n\.]/ ],
- [ platforms => 'Platforms', join => ', ', split => sub { map $PLATFORM{$_}, @{$_[0]} } ],
- [ media => 'Media', join => ', ', split => sub { map fmtmedia($_->{medium}, $_->{qty}), @{$_[0]} } ],
- [ resolution => 'Resolution', serialize => sub { $RESOLUTION{$_[0]}{txt}; } ],
- [ voiced => 'Voiced', serialize => sub { $VOICED{$_[0]}{txt} } ],
- [ ani_story => 'Story animation', serialize => sub { $ANIMATED{$_[0]}{txt} } ],
- [ ani_ero => 'Ero animation', serialize => sub { $ANIMATED{$_[0]}{txt} } ],
- [ engine => 'Engine' ],
- [ producers => 'Producers', join => '<br />', split => sub {
- map sprintf('<a href="/p%d" title="%s">%s</a> (%s)', $_->{id}, xml_escape($_->{original}||$_->{name}), xml_escape(shorten($_->{name}, 50)),
- join(', ', $_->{developer} ? 'developer' :(), $_->{publisher} ? 'publisher' :())
- ), @{$_[0]};
- } ],
- );
- }
-
- div class => 'mainbox release';
- $self->htmlItemMessage('r', $r);
- h1 $r->{title};
- h2 class => 'alttitle', lang_attr($r->{languages}), $r->{original} if $r->{original};
-
- _infotable($self, $r);
-
- if($r->{notes}) {
- p class => 'description';
- lit bb2html $r->{notes};
- end;
- }
-
- end;
- $self->htmlFooter;
-}
-
-
-sub _infotable {
- my($self, $r) = @_;
- table class => 'stripe';
-
- Tr;
- td class => 'key', 'Relation';
- td;
- for (@{$r->{vn}}) {
- a href => "/v$_->{vid}", title => $_->{original}||$_->{title}, shorten $_->{title}, 60;
- br if $_ != $r->{vn}[$#{$r->{vn}}];
- }
- end;
- end;
-
- Tr;
- td 'Title';
- td $r->{title};
- end;
-
- if($r->{original}) {
- Tr;
- td 'Original title';
- td lang_attr($r->{languages}), $r->{original};
- end;
- }
-
- Tr;
- td 'Type';
- td;
- cssicon "rt$r->{type}", $r->{type};
- txt sprintf ' %s%s', $RELEASE_TYPE{$r->{type}}, $r->{patch} ? ', patch' : '';
- end;
- end;
-
- Tr;
- td 'Language';
- td;
- for (@{$r->{languages}}) {
- cssicon "lang $_", $LANGUAGE{$_};
- txt ' '.$LANGUAGE{$_};
- br if $_ ne $r->{languages}[$#{$r->{languages}}];
- }
- end;
- end;
-
- Tr;
- td 'Publication';
- td join ', ',
- $r->{freeware} ? 'Freeware' : 'Non-free',
- $r->{patch} ? () : ($r->{doujin} ? 'doujin' : 'commercial');
- end;
-
- if(@{$r->{platforms}}) {
- Tr;
- td 'Platform'.(@{$r->{platforms}} == 1 ? '' : 's');
- td;
- for(@{$r->{platforms}}) {
- cssicon $_, $PLATFORM{$_};
- txt ' '.$PLATFORM{$_};
- br if $_ ne $r->{platforms}[$#{$r->{platforms}}];
- }
- end;
- end;
- }
-
- if(@{$r->{media}}) {
- Tr;
- td @{$r->{media}} == 1 ? 'Medium' : 'Media';
- td join ', ', map fmtmedia($_->{medium}, $_->{qty}), @{$r->{media}};
- end;
- }
-
- if($r->{resolution} ne 'unknown') {
- Tr;
- td 'Resolution';
- td $RESOLUTION{$r->{resolution}}{txt};
- end;
- }
-
- if($r->{voiced}) {
- Tr;
- td 'Voiced';
- td $VOICED{$r->{voiced}}{txt};
- end;
- }
-
- if($r->{ani_story} || $r->{ani_ero}) {
- Tr;
- td 'Animation';
- td join ', ',
- $r->{ani_story} ? "Story: $ANIMATED{$r->{ani_story}}{txt}" : (),
- $r->{ani_ero} ? "Ero scenes: $ANIMATED{$r->{ani_ero}}{txt}" : ();
- end;
- }
-
- if(length $r->{engine}) {
- Tr;
- td 'Engine';
- td; a href => '/r?fil='.fil_serialize({engine => $r->{engine}}), $r->{engine}; end;
- end;
- }
-
- Tr;
- td 'Released';
- td;
- lit fmtdatestr $r->{released};
- end;
- end;
-
- if($r->{minage} >= 0) {
- Tr;
- td 'Age rating';
- td minage $r->{minage};
- end;
- }
-
- if($r->{minage} == 18) {
- Tr;
- td 'Censoring';
- td $r->{uncensored} ? 'No optical censoring (e.g. mosaics)' : 'May include optical censoring (e.g. mosaics)';
- end;
- }
-
- for my $t (qw|developer publisher|) {
- my @prod = grep $_->{$t}, @{$r->{producers}};
- if(@prod) {
- Tr;
- td ucfirst($t).(@prod == 1 ? '' : 's');
- td;
- for (@prod) {
- a href => "/p$_->{id}", title => $_->{original}||$_->{name}, shorten $_->{name}, 60;
- br if $_ != $prod[$#prod];
- }
- end;
- end;
- }
- }
-
- if($r->{gtin}) {
- Tr;
- td gtintype($r->{gtin}) || 'GTIN';
- td $r->{gtin};
- end;
- }
-
- if($r->{catalog}) {
- Tr;
- td 'Catalog no.';
- td $r->{catalog};
- end;
- }
-
- if($r->{extlinks}->@*) {
- Tr;
- td 'Links';
- td;
- for($r->{extlinks}->@*) {
- a href => $_->[1], $_->[0];
- txt ', ' if $_ ne $r->{extlinks}[$#{$r->{extlinks}}];
- }
- end;
- end;
- }
-
- if($self->authInfo->{id}) {
- my $rl = $self->dbRListGet(uid => $self->authInfo->{id}, rid => $r->{id})->[0];
- Tr;
- td 'User options';
- td;
- Select id => 'listsel', name => $self->authGetCode("/r$r->{id}/list");
- option value => -2, !$rl ? 'not on your list' : "Status: $RLIST_STATUS{$rl->{status}}";
- optgroup label => 'Set status';
- option value => $_, $RLIST_STATUS{$_}
- for (keys %RLIST_STATUS);
- end;
- option value => -1, 'remove from list' if $rl;
- end;
- end;
- end 'tr';
- }
-
- end 'table';
-}
-
-
-# rid = \d -> edit/copy release
-# rid = 'v' -> add release to VN with id $rev
-sub edit {
- my($self, $rid, $rev, $copy) = @_;
-
- my $vid = 0;
- $copy = $rev && $rev eq 'copy' || $copy && $copy eq 'copy';
- $rev = undef if defined $rev && $rev !~ /^\d+$/;
- if($rid eq 'v') {
- $vid = $rev;
- $rev = undef;
- $rid = 0;
- }
-
- my $r = $rid && $self->dbReleaseGetRev(id => $rid, what => 'vn extended links producers platforms media', $rev ? (rev => $rev) : ())->[0];
- return $self->resNotFound if $rid && !$r->{id};
- $rev = undef if !$r || $r->{lastrev};
-
- my $v = $vid && $self->dbVNGet(id => $vid)->[0];
- return $self->resNotFound if $vid && !$v->{id};
-
- return $self->htmlDenied if !$self->authCan('edit')
- || $rid && (($r->{locked} || $r->{hidden}) && !$self->authCan('dbmod'));
-
- my $vn = $rid ? $r->{vn} : [{ vid => $vid, title => $v->{title} }];
- my %b4 = !$rid ? () : (
- (map { $_ => $r->{$_} } (qw|type title original languages website released minage
- notes platforms patch resolution voiced freeware doujin uncensored ani_story ani_ero engine ihid ilock|,
- $copy ? () : (qw|
- gtin catalog l_steam l_dlsite l_dlsiteen l_gog l_denpa l_jlist l_digiket l_melon l_mg l_getchu l_getchudl l_itch l_jastusa l_egs l_erotrail
- |)
- )),
- $copy ? () : (
- l_gyutto => join(' ', sort @{$r->{l_gyutto}}),
- l_dmm => join(' ', sort @{$r->{l_dmm}}),
- ),
- media => join(',', sort map "$_->{medium} $_->{qty}", @{$r->{media}}),
- producers => join('|||', map
- sprintf('%d,%d,%s', $_->{id}, ($_->{developer}?1:0)+($_->{publisher}?2:0), $_->{name}),
- sort { $a->{id} <=> $b->{id} } @{$r->{producers}}
- ),
- );
- gtintype($b4{gtin}) if $b4{gtin}; # normalize gtin code
- $b4{vn} = join('|||', map "$_->{vid},$_->{title}", @$vn);
- my $frm;
-
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- my $dmm_re = qr{(?:https?://)?(?:www|dlsoft)\.dmm\.(?:com|co\.jp)/[^\s]+};
- $frm = $self->formValidate(
- { post => 'type', enum => [ keys %RELEASE_TYPE ] },
- { post => 'patch', required => 0, default => 0 },
- { post => 'freeware', required => 0, default => 0 },
- { post => 'doujin', required => 0, default => 0 },
- { post => 'uncensored',required => 0, default => 0 },
- { post => 'title', maxlength => 250 },
- { post => 'original', required => 0, default => '', maxlength => 250 },
- { post => 'gtin', required => 0, default => '0', template => 'gtin' },
- { post => 'catalog', required => 0, default => '', maxlength => 50 },
- { post => 'languages', multi => 1, enum => [ keys %LANGUAGE ] },
- { post => 'website', required => 0, default => '', maxlength => 250, template => 'weburl' },
- { post => 'l_steam', required => 0, default => 0, template => 'uint' },
- { post => 'l_dlsite', required => 0, default => '', regex => [ qr/^[VR]J[0-9]{6}$/, 'Invalid DLsite ID' ] },
- { post => 'l_dlsiteen',required => 0, default => '', regex => [ qr/^[VR]E[0-9]{6}$/, 'Invalid DLsite ID' ] },
- { post => 'l_gog', required => 0, default => '', regex => [ qr/^[a-z0-9_]+$/, 'Invalid GOG.com ID' ] },
- { post => 'l_denpa', required => 0, default => '', regex => [ qr/^[a-z0-9-]+$/, 'Invalid Denpasoft ID' ] },
- { post => 'l_jlist', required => 0, default => '', regex => [ qr/^[a-z0-9-]+$/, 'Invalid J-List ID' ] },
- { post => 'l_gyutto', required => 0, default => '', regex => [ qr/^([0-9]+(\s+[0-9]+)*)?$/, 'Invalid Gyutto id' ] },
- { post => 'l_digiket', required => 0, default => 0, func => [ sub { $_[0] =~ s/^(?:ITM)?0+//; $_[0] =~ /^[0-9]+$/ }, 'Invalid Digiket ID' ] },
- { post => 'l_melon', required => 0, default => 0, func => [ sub { $_[0] =~ s/^(?:IT)?0+//; $_[0] =~ /^[0-9]+$/ }, 'Invalid Melonbooks.com ID' ] },
- { post => 'l_mg', required => 0, default => 0, template => 'uint' },
- { post => 'l_getchu', required => 0, default => 0, template => 'uint' },
- { post => 'l_getchudl',required => 0, default => 0, template => 'uint' },
- { post => 'l_dmm', required => 0, default => '', regex => [ qr/^($dmm_re(\s+$dmm_re)*)?$/, 'Invalid DMM URL' ] },
- { post => 'l_itch', required => 0, default => '', regex => [ qr{^(?:https?://)?([a-z0-9_-]+)\.itch\.io/([a-z0-9_-]+)$}, 'Invalid Itch.io URL' ] },
- { post => 'l_jastusa', required => 0, default => '', regex => [ qr/^[a-z0-9-]+$/, 'Invalid JAST USA ID' ] },
- { post => 'l_egs', required => 0, default => 0, template => 'uint' },
- { post => 'l_erotrail',required => 0, default => 0, template => 'uint' },
- { post => 'released', required => 0, default => 0, template => 'rdate' },
- { post => 'minage' , required => 0, default => -1, enum => [ keys %AGE_RATING ] },
- { post => 'notes', required => 0, default => '', maxlength => 10240 },
- { post => 'platforms', required => 0, default => '', multi => 1, enum => [ keys %PLATFORM ] },
- { post => 'media', required => 0, default => '' },
- { post => 'resolution',required => 0, default => 0, enum => [ keys %RESOLUTION ] },
- { post => 'voiced', required => 0, default => 0, enum => [ keys %VOICED ] },
- { post => 'ani_story', required => 0, default => 0, enum => [ keys %ANIMATED ] },
- { post => 'ani_ero', required => 0, default => 0, enum => [ keys %ANIMATED ] },
- { post => 'engine', required => 0, default => '', maxlength => 50 },
- { post => 'engine_oth',required => 0, default => '', maxlength => 50 },
- { post => 'producers', required => 0, default => '' },
- { post => 'vn', maxlength => 50000 },
- { post => 'editsum', template => 'editsum' },
- { post => 'ihid', required => 0 },
- { post => 'ilock', required => 0 },
- );
-
- $frm->{engine} = $frm->{engine_oth} if $frm->{engine} eq '_other_';
- delete $frm->{engine_oth};
-
- my $l_dmm = [ split /\s+/, $frm->{l_dmm} ];
- my $l_gyutto = [ split /\s+/, $frm->{l_gyutto} ];
-
- $frm->{original} = '' if $frm->{original} eq $frm->{title};
- $_ =~ s{^https?://}{} for @$l_dmm;
- $frm->{l_itch} =~ s{^https?://}{};
-
- push @{$frm->{_err}}, [ 'released', 'required', 1 ] if !$frm->{released};
-
- my($media, $producers, $new_vn);
- if(!$frm->{_err}) {
- # de-serialize
- $media = [ map [ split / / ], split /,/, $frm->{media} ];
- $producers = [ map { /^([0-9]+),([1-3])/ ? [ $1, $2&1?1:0, $2&2?1:0] : () } split /\|\|\|/, $frm->{producers} ];
- $new_vn = [ map { /^([0-9]+)/ ? $1 : () } split /\|\|\|/, $frm->{vn} ];
- $frm->{platforms} = [ grep $_, @{$frm->{platforms}} ];
- $frm->{$_} = $frm->{$_} ? 1 : 0 for (qw|patch freeware doujin uncensored ihid ilock|);
-
- # reset some fields when the patch flag is set
- if($frm->{patch}) {
- $frm->{doujin} = $frm->{voiced} = $frm->{ani_story} = $frm->{ani_ero} = 0;
- $frm->{resolution} = 'unknown';
- $frm->{engine} = '';
- }
- $frm->{uncensored} = 0 if $frm->{minage} != 18;
- $frm->{l_dmm} = join ' ', sort @$l_dmm;
- $frm->{l_gyutto} = join ' ', sort @$l_gyutto;
-
- my $same = $rid &&
- (join(',', sort @{$b4{platforms}}) eq join(',', sort @{$frm->{platforms}})) &&
- (join(',', map join(' ', @$_), sort { $a->[0] <=> $b->[0] } @$producers) eq join(',', map sprintf('%d %d %d',$_->{id}, $_->{developer}?1:0, $_->{publisher}?1:0), sort { $a->{id} <=> $b->{id} } @{$r->{producers}})) &&
- (join(',', sort @$new_vn) eq join(',', sort map $_->{vid}, @$vn)) &&
- (join(',', sort @{$b4{languages}}) eq join(',', sort @{$frm->{languages}})) &&
- !grep !/^(platforms|producers|vn|languages)$/ && $frm->{$_} ne $b4{$_}, keys %b4;
- return $self->resRedirect("/r$rid", 'post') if !$copy && $same;
- $frm->{_err} = [ "No changes, please don't create an entry that is fully identical to another" ] if $copy && $same;
- }
-
- if(!$frm->{_err}) {
- my $nrev = $self->dbItemEdit(r => !$copy && $rid ? ($r->{id}, $r->{rev}) : (undef, undef),
- (map { $_ => $frm->{$_} } qw| type title original gtin catalog languages website released minage
- l_steam l_dlsite l_dlsiteen l_gog l_denpa l_jlist l_digiket l_melon l_mg l_getchu l_getchudl l_itch l_jastusa l_egs l_erotrail
- notes platforms resolution editsum patch voiced freeware doujin uncensored ani_story ani_ero engine ihid ilock|),
- l_gyutto => $l_gyutto,
- l_dmm => $l_dmm,
- vn => $new_vn,
- producers => $producers,
- media => $media,
- );
-
- return $self->resRedirect("/r$nrev->{itemid}.$nrev->{rev}", 'post');
- }
- }
-
- !defined $frm->{$_} && ($frm->{$_} = $b4{$_}) for keys %b4;
- $frm->{languages} = ['ja'] if !$rid && !defined $frm->{languages};
- $frm->{editsum} = sprintf 'Reverted to revision r%d.%d', $rid, $rev if !$copy && $rev && !defined $frm->{editsum};
- $frm->{editsum} = sprintf 'New release based on r%d.%d', $rid, $r->{rev} if $copy && !defined $frm->{editsum};
- $frm->{title} = $v->{title} if !defined $frm->{title} && !$r;
- $frm->{original} = $v->{original} if !defined $frm->{original} && !$r;
-
- my $title = !$rid ? "Add release to $v->{title}" : $copy ? "Copy $r->{title}" : "Edit $r->{title}";
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('r', $r, $copy ? 'copy' : 'edit') if $rid;
- $self->htmlMainTabs('v', $v, 'edit') if $vid;
- $self->htmlEditMessage('r', $r, $title, $copy);
- _listrel($self, $vid) if $vid && $self->reqMethod ne 'POST';
- _form($self, $r, $v, $frm, $copy);
- $self->htmlFooter;
-}
-
-
-sub _form {
- my($self, $r, $v, $frm, $copy) = @_;
-
- $self->htmlForm({ frm => $frm, action => $r ? "/r$r->{id}/".($copy ? 'copy' : 'edit') : "/v$v->{id}/add", editsum => 1 },
- rel_geninfo => [ 'General info',
- [ select => short => 'type', name => 'Type',
- options => [ map [ $_, $RELEASE_TYPE{$_} ], keys %RELEASE_TYPE ] ],
- [ check => short => 'patch', name => 'This release is a patch to another release.' ],
- [ check => short => 'freeware', name => 'Freeware (i.e. available at no cost)' ],
- [ check => short => 'doujin', name => 'Doujin (self-published, not by a company)' ],
- [ input => short => 'title', name => 'Title (romaji)', width => 450 ],
- [ input => short => 'original', name => 'Original title', width => 450 ],
- [ static => content => 'The original title of this release, leave blank if it already is in the Latin alphabet.' ],
- [ select => short => 'languages', name => 'Language(s)', multi => 1, size => 10,
- options => [ map [ $_, "$LANGUAGE{$_} ($_)" ], sort { $LANGUAGE{$a} cmp $LANGUAGE{$b} } keys %LANGUAGE ] ],
- [ input => short => 'gtin', name => 'JAN/UPC/EAN' ],
- [ input => short => 'catalog', name => 'Catalog number' ],
- [ input => short => 'website', name => 'Official website' ],
- [ date => short => 'released', name => 'Release date' ],
- [ static => content => 'Leave month or day blank if they are unknown' ],
- [ select => short => 'minage', name => 'Age rating',
- options => [ map [ $_, minage $_, 1 ], keys %AGE_RATING ] ],
- [ check => short => 'uncensored',name => 'No mosaic or other optical censoring (only check if this release has erotic content)' ],
-
- [ static => nolabel => 1, content => '<br><b>Links</b>' ],
- [ input => short => 'l_egs', name => 'ErogameScape', pre => 'erogamescape.dyndns.org/..?game=', width => 100 ],
- [ input => short => 'l_erotrail',name => 'ErogeTrailers', pre => 'erogetrailers.com/soft/', width => 100 ],
- [ input => short => 'l_steam', name => 'Steam AppID', pre => 'store.steampowered.com/app/', width => 100 ],
- [ input => short => 'l_jlist', name => 'J-List', pre => 'www.jlist.com/', post => ' (the last part of the URL, e.g. "np004")', width => 100 ],
- [ input => short => 'l_jastusa', name => 'JAST USA', pre => 'jastusa.com/' ],
- [ input => short => 'l_mg', name => 'MangaGamer', pre => 'mangagamer.com/..&product_code=', width => 100 ],
- [ input => short => 'l_denpa', name => 'Denpasoft', pre => 'denpasoft.com/products/' ],
- [ input => short => 'l_gog', name => 'GOG.com', pre => 'www.gog.com/game/' ],
- [ input => short => 'l_itch', name => 'Itch.io', post => ' (e.g. "author.itch.io/title")', width => 300 ],
- [ input => short => 'l_dlsiteen',name => 'DLsite (eng)', pre => 'www.dlsite.com/../product_id/', post => ' e.g. "RE083922"', width => 100 ],
- [ input => short => 'l_dlsite', name => 'DLsite (jpn)', pre => 'www.dlsite.com/../product_id/', post => ' e.g. "RJ083922"', width => 100 ],
- [ input => short => 'l_digiket', name => 'Digiket', pre => 'www.digiket.com/work/show/_data/ID=ITM', width => 100 ],
- [ input => short => 'l_gyutto', name => 'Gyutto', pre => 'gyutto.com/i/item', post => ' (item number, space separated)', width => 100 ],
- [ input => short => 'l_getchudl',name => 'DL.Getchu', pre => 'dl.getchu.com/i/item', post => ' (item number)', width => 100 ],
- [ input => short => 'l_getchu', name => 'Getchu', pre => 'www.getchu.com/soft.phtml?id=', width => 100 ],
- [ input => short => 'l_melon', name => 'Melonbooks.com', pre => 'www.melonbooks.com/..&products_id=IT', width => 100 ],
- [ input => short => 'l_dmm', name => 'DMM', post => ' (full URL, space separated)', width => 400 ],
-
- [ static => nolabel => 1, content => '<br>' ],
- [ textarea => short => 'notes', name => 'Notes<br /><b class="standout">English please!</b>' ],
- [ static => content =>
- 'Miscellaneous notes/comments, information that does not fit in the above fields.'
- .' E.g.: Types of censoring or for which releases this patch applies.' ],
- ],
-
- rel_format => [ 'Format',
- [ select => short => 'resolution', name => 'Resolution', options => [
- map [ $_, $RESOLUTION{$_}{txt}, $RESOLUTION{$_}{cat} ], keys %RESOLUTION ] ],
- [ static => label => 'Engine', content => sub {
- my $other = $frm->{engine} && !grep($_ eq $frm->{engine}, @{$self->{engines}});
- Select name => 'engine', id => 'engine', tabindex => 10;
- option value => $_, ($frm->{engine}||'') eq $_ ? (selected => 'selected') : (), $_ || 'Unknown'
- for ('', @{$self->{engines}});
- option value => '_other_', $other ? (selected => 'selected') : (), 'Other';
- end;
- input type => 'text', name => 'engine_oth', id => 'engine_oth', tabindex => 10, class => 'text '.($other ? '' : 'hidden'), value => $frm->{engine}||'';
- } ],
- [ static => content => 'Try to use a name from the <a href="/r/engines">engine list</a>.' ],
- [ select => short => 'voiced', name => 'Voiced', options => [
- map [ $_, $VOICED{$_}{txt} ], keys %VOICED ] ],
- [ select => short => 'ani_story', name => 'Story animation', options => [
- map [ $_, $ANIMATED{$_}{txt} ], keys %ANIMATED ] ],
- [ select => short => 'ani_ero', name => 'Ero animation', options => [
- map [ $_, $_ ? $ANIMATED{$_}{txt} : 'Unknown / no ero scenes' ], keys %ANIMATED ] ],
- [ static => content => 'Animation in erotic scenes, leave to unknown if there are no ero scenes.' ],
- [ hidden => short => 'media' ],
- [ static => nolabel => 1, content => sub {
- h2 'Platforms';
- div class => 'platforms';
- for my $p (sort keys %PLATFORM) {
- span;
- input type => 'checkbox', name => 'platforms', value => $p, id => $p,
- $frm->{platforms} && grep($_ eq $p, @{$frm->{platforms}}) ? (checked => 'checked') : ();
- label for => $p;
- cssicon $p, $PLATFORM{$p};
- txt ' '.$PLATFORM{$p};;
- end;
- end;
- }
- end;
-
- h2 'Media';
- div id => 'media_div', '';
- }],
- ],
-
- rel_prod => [ 'Producers',
- [ hidden => short => 'producers' ],
- [ static => nolabel => 1, content => sub {
- h2 'Selected producers';
- table; tbody id => 'producer_tbl'; end; end;
- h2 'Add producer';
- table; Tr;
- td class => 'tc_name'; input id => 'producer_input', type => 'text', class => 'text'; end;
- td class => 'tc_role'; Select id => 'producer_role';
- option value => 1, 'Developer';
- option value => 2, selected => 'selected', 'Publisher';
- option value => 3, 'Both';
- end; end;
- td class => 'tc_add'; a id => 'producer_add', href => '#', 'add'; end;
- end; end 'table';
- }],
- ],
-
- rel_vn => [ 'Visual novels',
- [ hidden => short => 'vn' ],
- [ static => nolabel => 1, content => sub {
- h2 'Selected visual novels';
- table class => 'stripe'; tbody id => 'vn_tbl'; end; end;
- h2 'Add visual novel';
- div;
- input id => 'vn_input', type => 'text', class => 'text';
- a href => '#', id => 'vn_add', 'add';
- end;
- }],
- ],
- );
-}
-
-sub _listrel {
- my($self, $vid) = @_;
- my $l = $self->dbReleaseGet(vid => $vid, hidden_only => 1, results => 50);
- return if !@$l;
- div class => 'mainbox';
- h1 'Deleted releases';
- div class => 'warning';
- p q{This visual novel has releases that have been deleted before. Please
- review this list to make sure you're not adding a release that has already
- been deleted before.};
- br;
- ul;
- for(@$l) {
- li;
- txt '['.join(',', @{$_->{languages}}).'] ';
- a href => "/r$_->{id}", title => $_->{original}||$_->{title}, "$_->{title} (r$_->{id})";
- end;
- }
- end;
- end;
- end;
-}
-
-sub browse {
- my $self = shift;
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'o', required => 0, default => 'a', enum => ['a', 'd'] },
- { get => 'q', required => 0, default => '', maxlength => 500 },
- { get => 's', required => 0, default => 'title', enum => [qw|released minage title|] },
- { get => 'fil',required => 0 },
- );
- return $self->resNotFound if $f->{_err};
- $f->{fil} //= $self->authPref('filter_release');
-
- my %compat = _fil_compat($self);
- my($list, $np) = !$f->{q} && !$f->{fil} && !keys %compat ? ([], 0) : $self->filFetchDB(release => $f->{fil}, \%compat, {
- sort => $f->{s}, reverse => $f->{o} eq 'd',
- page => $f->{p},
- results => 50,
- what => 'platforms',
- $f->{q} ? ( search => $f->{q} ) : (),
- });
-
- $self->htmlHeader(title => 'Browse releases');
-
- form method => 'get', action => '/r', 'accept-charset' => 'UTF-8';
- div class => 'mainbox';
- h1 'Browse releases';
- $self->htmlSearchBox('r', $f->{q});
- p class => 'filselect';
- a id => 'filselect', href => '#r';
- lit '<i>&#9656;</i> Filters<i></i>';
- end;
- end;
- input type => 'hidden', class => 'hidden', name => 'fil', id => 'fil', value => $f->{fil};
- end;
- end 'form';
-
- my $uri = sprintf '/r?q=%s;fil=%s', uri_escape($f->{q}), $f->{fil};
- $self->htmlBrowse(
- class => 'relbrowse',
- items => $list,
- options => $f,
- nextpage => $np,
- pageurl => "$uri;s=$f->{s};o=$f->{o}",
- sorturl => $uri,
- header => [
- [ 'Released', 'released' ],
- [ 'Rating', 'minage' ],
- [ '', '' ],
- [ 'Title', 'title' ],
- ],
- row => sub {
- my($s, $n, $l) = @_;
- Tr;
- td class => 'tc1';
- lit fmtdatestr $l->{released};
- end;
- td class => 'tc2', $l->{minage} < 0 ? '' : minage $l->{minage};
- td class => 'tc3';
- $_ ne 'oth' && cssicon $_, $PLATFORM{$_} for (@{$l->{platforms}});
- cssicon "lang $_", $LANGUAGE{$_} for (@{$l->{languages}});
- cssicon "rt$l->{type}", $l->{type};
- end;
- td class => 'tc4';
- a href => "/r$l->{id}", title => $l->{original}||$l->{title}, shorten $l->{title}, 90;
- b class => 'grayedout', ' (patch)' if $l->{patch};
- end;
- end 'tr';
- },
- ) if @$list;
- if(($f->{q} || $f->{fil}) && !@$list) {
- div class => 'mainbox';
- h1 'No results found';
- div class => 'notice';
- p;
- txt 'Sorry, couldn\'t find anything that comes through your filters. You might want to disable a few filters to get more results.';
- br; br;
- txt 'Also, keep in mind that we don\'t have all information about all releases.'
- .' So e.g. filtering on screen resolution will exclude all releases of which we don\'t know it\'s resolution,'
- .' even though it might in fact be in the resolution you\'re looking for.';
- end
- end;
- end;
- }
- $self->htmlFooter(pref_code => 1);
-}
-
-
-# provide compatibility with old URLs
-sub _fil_compat {
- my $self = shift;
- my %c;
- my $f = $self->formValidate(
- { get => 'ln', required => 0, multi => 1, default => '', enum => [ keys %LANGUAGE ] },
- { get => 'pl', required => 0, multi => 1, default => '', enum => [ keys %PLATFORM ] },
- { get => 'me', required => 0, multi => 1, default => '', enum => [ keys %MEDIUM ] },
- { get => 'tp', required => 0, default => '', enum => [ '', keys %RELEASE_TYPE ] },
- { get => 'pa', required => 0, default => 0, enum => [ 0..2 ] },
- { get => 'fw', required => 0, default => 0, enum => [ 0..2 ] },
- { get => 'do', required => 0, default => 0, enum => [ 0..2 ] },
- { get => 'ma_m', required => 0, default => 0, enum => [ 0, 1 ] },
- { get => 'ma_a', required => 0, default => 0, enum => [ keys %AGE_RATING ] },
- { get => 'mi', required => 0, default => 0, template => 'uint' },
- { get => 'ma', required => 0, default => 99999999, template => 'uint' },
- );
- return () if $f->{_err};
- $c{minage} = [ grep $_ >= 0 && ($f->{ma_m} ? $f->{ma_a} >= $_ : $f->{ma_a} <= $_), keys %AGE_RATING ] if $f->{ma_a} || $f->{ma_m};
- $c{date_after} = $f->{mi} if $f->{mi};
- $c{date_before} = $f->{ma} if $f->{ma} < 99990000;
- $c{plat} = $f->{pl} if $f->{pl}[0];
- $c{lang} = $f->{ln} if $f->{ln}[0];
- $c{med} = $f->{me} if $f->{me}[0];
- $c{type} = $f->{tp} if $f->{tp};
- $c{patch} = $f->{pa} == 2 ? 0 : 1 if $f->{pa};
- $c{freeware} = $f->{fw} == 2 ? 0 : 1 if $f->{fw};
- $c{doujin} = $f->{do} == 2 ? 0 : 1 if $f->{do};
- return %c;
-}
-
-
-sub engines {
- my $self = shift;
- my $lst = $self->dbReleaseEngines();
- $self->htmlHeader(title => 'Engine list', noindex => 1);
-
- div class => 'mainbox';
- h1 'Engine list';
- p;
- lit q{
- This is a list of all engines currently associated with releases. This
- list can be used as reference when filling out the engine field for a
- release and to find inconsistencies in the engine names. See the <a
- href="/d3#3">releases guidelines</a> for more information.
- };
- end;
- ul;
- for my $e (@$lst) {
- li;
- a href => '/r?fil='.fil_serialize({engine => $e->{engine}}), $e->{engine};
- b class => 'grayedout', " $e->{cnt}";
- end;
- }
- end;
-
- end;
-}
-
-
-sub relxml {
- my $self = shift;
-
- my $f = $self->formValidate(
- { get => 'v', required => 1, multi => 1, mincount => 1, template => 'id' }
- );
- return $self->resNotFound if $f->{_err};
-
- my $vns = $self->dbVNGet(id => $f->{v}, order => 'title', results => 100);
- my $rel = $self->dbReleaseGet(vid => $f->{v}, results => 100, what => 'vn');
-
- $self->resHeader('Content-type' => 'text/xml; charset=UTF-8');
- xml;
- tag 'vns';
- for my $v (@$vns) {
- tag 'vn', id => $v->{id}, title => $v->{title};
- tag 'release', id => $_->{id}, lang => join(',', @{$_->{languages}}), $_->{title}
- for (grep (grep $_->{vid} == $v->{id}, @{$_->{vn}}), @$rel);
- end;
- }
- end;
-}
-
-
-sub enginexml {
- my $self = shift;
-
- # The list of engines happens to be small enough for this to make sense, and
- # fetching all unique engines from the releases table also happens to be fast
- # enough right now, but this may need a separate cache or index in the future.
- my $lst = $self->dbReleaseEngines();
-
- my $f = $self->formValidate(
- { get => 'q', required => 1, maxlength => 500 },
- );
- return $self->resNotFound if $f->{_err};
-
- $self->resHeader('Content-type' => 'text/xml; charset=UTF-8');
- xml;
- tag 'engines';
- for(grep $lst->[$_]{engine} =~ /\Q$f->{q}\E/i, 0..$#$lst) {
- tag 'item', count => $lst->[$_]{cnt}, id => $_+1, $lst->[$_]{engine};
- }
- end;
-}
-
-
-# Generate the html for an 'external links' dropdown, assumes enrich_extlinks() has already been called on this object.
-sub releaseExtLinks {
- my($self, $r) = @_;
- my $has_dd = $r->{extlinks}->@* > ($r->{website} ? 1 : 0);
- if($r->{extlinks}->@*) {
- a href => $r->{website}||'#', class => 'rllinks';
- txt scalar $r->{extlinks}->@* if $has_dd;
- cssicon 'external', 'External link';
- end;
- if($has_dd) {
- ul class => 'hidden rllinks_dd';
- for ($r->{extlinks}->@*) {
- li;
- a href => $_->[1];
- span $_->[2] if $_->[2];
- txt $_->[0];
- end;
- end;
- };
- end;
- }
- } else {
- txt ' ';
- }
-}
-
-1;
-
diff --git a/lib/VNDB/Handler/Staff.pm b/lib/VNDB/Handler/Staff.pm
deleted file mode 100644
index adab2be8..00000000
--- a/lib/VNDB/Handler/Staff.pm
+++ /dev/null
@@ -1,116 +0,0 @@
-
-package VNDB::Handler::Staff;
-
-use strict;
-use warnings;
-use TUWF qw(:html :xml uri_escape);
-use VNDB::Func;
-use VNDB::Types;
-use List::Util qw(first);
-
-TUWF::register(
- qr{s/([a-z0]|all)} => \&list,
- qr{xml/staff\.xml} => \&staffxml,
-);
-
-
-sub list {
- my ($self, $char) = @_;
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'q', required => 0, default => '' },
- { get => 'fil', required => 0, default => '' },
- );
- return $self->resNotFound if $f->{_err};
-
- my ($list, $np) = $self->filFetchDB(staff => $f->{fil}, {}, {
- $char ne 'all' ? ( char => $char ) : (),
- $f->{q} ? ($f->{q} =~ /^=(.+)$/ ? (exact => $1) : (search => $f->{q})) : (),
- results => 150,
- page => $f->{p}
- });
-
- return $self->resRedirect('/s'.$list->[0]{id}, 'temp')
- if $f->{q} && @$list && (!first { $_->{id} != $list->[0]{id} } @$list) && $f->{p} == 1 && !$f->{fil};
- # redirect to the staff page if all results refer to the same entry
-
- my $quri = join(';', $f->{q} ? 'q='.uri_escape($f->{q}) : (), $f->{fil} ? "fil=$f->{fil}" : ());
- $quri = '?'.$quri if $quri;
- my $pageurl = "/s/$char$quri";
-
- $self->htmlHeader(title => 'Browse staff');
-
- form action => '/s/all', 'accept-charset' => 'UTF-8', method => 'get';
- div class => 'mainbox';
- h1 'Browse staff';
- $self->htmlSearchBox('s', $f->{q});
- p class => 'browseopts';
- for ('all', 'a'..'z', 0) {
- a href => "/s/$_$quri", $_ eq $char ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#';
- }
- end;
-
- p class => 'filselect';
- a id => 'filselect', href => '#s';
- lit '<i>&#9656;</i> Filters<i></i>';
- end;
- end;
- input type => 'hidden', class => 'hidden', name => 'fil', id => 'fil', value => $f->{fil};
- end;
- end 'form';
-
- $self->htmlBrowseNavigate($pageurl, $f->{p}, $np, 't');
- div class => 'mainbox staffbrowse';
- h1 $f->{q} ? 'Search results' : 'Staff 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 => "/s$list->[$_]{id}",
- title => $list->[$_]{original}, $list->[$_]{name};
- end;
- }
- end;
- }
- }
- clearfloat;
- end 'div';
- $self->htmlBrowseNavigate($pageurl, $f->{p}, $np, 'b');
- $self->htmlFooter;
-}
-
-
-sub staffxml {
- my $self = shift;
-
- my $f = $self->formValidate(
- { get => 'q', required => 0, maxlength => 500 },
- { get => 'id', required => 0, multi => 1, template => 'id' },
- { get => 'staffid', required => 0, default => 0 }, # The returned id = staff id when set, otherwise it's the alias id
- { get => 'r', required => 0, template => 'uint', min => 1, max => 50, default => 10 },
- );
- return $self->resNotFound if $f->{_err} || (!$f->{q} && !$f->{id} && !$f->{id}[0]);
-
- my($list, $np) = $self->dbStaffGet(
- !$f->{q} ? () : $f->{q} =~ /^s([1-9]\d*)/ ? (id => $1) : $f->{q} =~ /^=(.+)/ ? (exact => $1) : (search => $f->{q}, sort => 'search'),
- $f->{id} && $f->{id}[0] ? (id => $f->{id}) : (),
- results => $f->{r}, page => 1,
- );
-
- $self->resHeader('Content-type' => 'text/xml; charset=UTF-8');
- xml;
- tag 'staff', more => $np ? 'yes' : 'no';
- for(@$list) {
- tag 'item', sid => $_->{id}, id => $f->{staffid} ? $_->{id} : $_->{aid}, orig => $_->{original}, $_->{name};
- }
- end;
-}
-
-1;
diff --git a/lib/VNDB/Handler/Tags.pm b/lib/VNDB/Handler/Tags.pm
deleted file mode 100644
index 5acc948f..00000000
--- a/lib/VNDB/Handler/Tags.pm
+++ /dev/null
@@ -1,678 +0,0 @@
-
-package VNDB::Handler::Tags;
-
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml', 'xml_escape';
-use VNDB::Func;
-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{v([1-9]\d*)/tagmod}, \&vntagmod,
- qr{u([1-9]\d*)/tags}, \&usertags,
- qr{g}, \&tagindex,
- qr{g/debug}, \&fulltree,
- qr{xml/tags\.xml}, \&tagxml,
-);
-
-
-sub tagpage {
- my($self, $tag) = @_;
-
- my $t = $self->dbTagGet(id => $tag, what => 'parents(0) childs(2) aliases')->[0];
- return $self->resNotFound if !$t;
-
- my $f = $self->formValidate(
- { get => 's', required => 0, default => 'tagscore', enum => [ qw|title rel pop tagscore rating| ] },
- { get => 'o', required => 0, default => 'd', enum => [ 'a','d' ] },
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'm', required => 0, default => $self->authPref('spoilers') || 0, enum => [qw|0 1 2|] },
- { get => 'fil', required => 0 },
- );
- return $self->resNotFound if $f->{_err};
- $f->{fil} //= $self->authPref('filter_vn');
-
- my($list, $np) = !$t->{searchable} || $t->{state} != 2 ? ([],0) : $self->filFetchDB(vn => $f->{fil}, undef, {
- what => 'rating',
- results => 50,
- page => $f->{p},
- sort => $f->{s}, reverse => $f->{o} eq 'd',
- tagspoil => $f->{m},
- tag_inc => $tag,
- tag_exc => undef,
- });
-
- my $title = "Tag: $t->{name}";
- $self->htmlHeader(title => $title, noindex => $t->{state} != 2);
- $self->htmlMainTabs('g', $t);
-
- if($t->{state} != 2) {
- div class => 'mainbox';
- h1 $title;
- if($t->{state} == 1) {
- div class => 'warning';
- h2 'Tag deleted';
- p;
- txt 'This tag has been removed from the database, and cannot be used or re-added.';
- br;
- txt 'File a request on the ';
- a href => '/t/db', 'discussion board';
- txt ' if you disagree with this.';
- end;
- end;
- } else {
- div class => 'notice';
- h2 'Waiting for approval';
- p 'This tag is waiting for a moderator to approve it. You can still use it to tag VNs as you would with a normal tag.';
- end;
- }
- end 'div';
- }
-
- div class => 'mainbox';
- a class => 'addnew', href => "/g$tag/add", 'Create child tag' if $self->authCan('tag') && $t->{state} != 1;
- h1 $title;
-
- parenttags($t, 'Tags', 'g');
-
- if($t->{description}) {
- p class => 'description';
- lit bb2html $t->{description};
- end;
- }
- if(!$t->{applicable} || !$t->{searchable}) {
- p class => 'center';
- b 'Properties';
- br;
- txt 'Not searchable.' if !$t->{searchable};
- br;
- txt 'Can not be directly applied to visual novels.' if !$t->{applicable};
- end;
- }
- p class => 'center';
- b 'Category';
- br;
- txt $TAG_CATEGORY{$t->{cat}};
- end;
- if(@{$t->{aliases}}) {
- p class => 'center';
- b 'Aliases';
- br;
- lit xml_escape($_).'<br />' for (@{$t->{aliases}});
- end;
- }
- end 'div';
-
- childtags($self, 'Child tags', 'g', $t) if @{$t->{childs}};
-
- if($t->{searchable} && $t->{state} == 2) {
- form action => "/g$t->{id}", 'accept-charset' => 'UTF-8', method => 'get';
- div class => 'mainbox';
- a class => 'addnew', href => "/g/links?t=$tag", 'Recently tagged';
- h1 'Visual novels';
-
- p class => 'browseopts';
- a href => "/g$t->{id}?fil=$f->{fil};s=$f->{s};o=$f->{o};m=0", $f->{m} == 0 ? (class => 'optselected') : (), 'Hide spoilers';
- a href => "/g$t->{id}?fil=$f->{fil};s=$f->{s};o=$f->{o};m=1", $f->{m} == 1 ? (class => 'optselected') : (), 'Show minor spoilers';
- a href => "/g$t->{id}?fil=$f->{fil};s=$f->{s};o=$f->{o};m=2", $f->{m} == 2 ? (class => 'optselected') : (), 'Spoil me!';
- end;
-
- p class => 'filselect';
- a id => 'filselect', href => '#v';
- lit '<i>&#9656;</i> Filters<i></i>';
- end;
- end;
- input type => 'hidden', class => 'hidden', name => 'fil', id => 'fil', value => $f->{fil};
- input type => 'hidden', class => 'hidden', name => 'm', id => 'm', value => $f->{m};
-
- if(!@$list) {
- p; br; br; txt 'This tag has not been linked to any visual novels yet, or they were hidden because of your spoiler settings or default filters.'; end;
- }
- if(@{$t->{childs}}) {
- p; br; txt 'The list below also includes all visual novels linked to child tags.'; end;
- }
- end 'div';
- end 'form';
- $self->htmlBrowseVN($list, $f, $np, "/g$t->{id}?fil=$f->{fil};m=$f->{m}", 1) if @$list;
- }
-
- $self->htmlFooter(pref_code => 1);
-}
-
-
-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 vntagmod {
- my($self, $vid) = @_;
-
- my $v = $self->dbVNGet(id => $vid)->[0];
- return $self->resNotFound if !$v || $v->{hidden};
-
- return $self->htmlDenied if !$self->authCan('tag');
-
- my $tags = $self->dbTagStats(vid => $vid, results => 9999);
- my $my = $self->dbTagLinks(vid => $vid, uid => $self->authInfo->{id});
-
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- my $frm = $self->formValidate(
- { post => 'taglinks', required => 0, default => '', maxlength => 10240, regex => [ qr/^[1-9][0-9]*,-?[1-3],-?[0-2]( [1-9][0-9]*,-?[1-3],-?[0-2])*$/, 'meh' ] },
- { post => 'overrule', required => 0, multi => 1, template => 'id' },
- );
- return $self->resNotFound if $frm->{_err};
-
- # convert some data in a more convenient structure for faster lookup
- my %tags = map +($_->{id} => $_), @$tags;
- my %old = map +($_->{tag} => $_), @$my;
- my %new = map { my($tag, $vote, $spoiler) = split /,/; ($tag => [ $vote, $spoiler ]) } split / /, $frm->{taglinks};
- my %over = !$self->authCan('tagmod') || !$frm->{overrule}[0] ? () : (map $new{$_} ? ($_ => 1) : (), @{$frm->{overrule}});
-
- # hashes which need to be filled, indicating what should be changed to the DB
- my %delete; # tag => 1
- my %update; # tag => [ vote, spoiler ] (ignore flag is untouched)
- my %insert; # tag => [ vote, spoiler, ignore ]
- my %overrule; # tag => 0/1
-
- # remove tags in the deleted state
- delete $new{$_->{id}} for(keys %new ? @{$self->dbTagGet(id => [ keys %new ], state => 1)} : ());
- # and not-applicable tags
- delete $new{$_->{id}} for(keys %new ? @{$self->dbTagGet(id => [ keys %new ], applicable => 0)} : ());
-
- for my $t (keys %old, keys %new) {
- my $prev_over = $old{$t} && !$old{$t}{ignore} && $tags{$t}{overruled};
-
- # overrule checkbox has changed? make sure to (de-)overrule the tag votes
- $overrule{$t} = $over{$t}?1:0 if (!$prev_over && $over{$t}) || ($prev_over && !$over{$t});
-
- # tag deleted?
- if($old{$t} && !$new{$t}) {
- $delete{$t} = 1;
- next;
- }
-
- # and insert or update the vote
- if(!$old{$t} && $new{$t}) {
- # determine whether this vote is going to be ignored or not
- my $ign = $tags{$t}{overruled} && !$prev_over && !$over{$t};
- $insert{$t} = [ $new{$t}[0], $new{$t}[1], $ign ];
- } elsif($old{$t}{vote} != $new{$t}[0] || (defined $old{$t}{spoiler} ? $old{$t}{spoiler} : -1) != $new{$t}[1]) {
- $update{$t} = [ $new{$t}[0], $new{$t}[1] ];
- }
- }
-
- $self->dbTagLinkEdit($self->authInfo->{id}, $vid, \%insert, \%update, \%delete, \%overrule);
-
- # need to re-fetch the tags and tag links, as these have been modified
- $tags = $self->dbTagStats(vid => $vid, results => 9999);
- $my = $self->dbTagLinks(vid => $vid, uid => $self->authInfo->{id});
- }
-
-
- my $title = "Add/remove tags for $v->{title}";
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('v', $v, 'tagmod');
- div class => 'mainbox';
- h1 $title;
- div class => 'notice';
- h2 'Tagging';
- ul;
- li; txt 'Make sure you have read the '; a href => '/d10', 'guidelines'; txt '!'; end;
- li 'Don\'t forget to hit the submit button on the bottom of the page to make your changes permanent.';
- end;
- end;
- end 'div';
- $self->htmlForm({ action => "/v$vid/tagmod", nosubmit => 1 }, tagmod => [ 'Tags',
- [ hidden => short => 'taglinks', value => '' ],
- [ static => nolabel => 1, content => sub {
- table class => 'tgl stripe';
- thead;
- Tr;
- td '';
- td colspan => $self->authCan('tagmod') ? 3 : 2, class => 'tc_you', 'You';
- td colspan => 3, class => 'tc_others', 'Others';
- end;
- Tr;
- td class => 'tc_tagname', 'Tag';
- td class => 'tc_myvote', 'Rating';
- td class => 'tc_myover', 'O' if $self->authCan('tagmod');
- td class => 'tc_myspoil', 'Spoiler';
- td class => 'tc_allvote', 'Rating';
- td class => 'tc_allspoil', 'Spoiler';
- td class => 'tc_allwho', '';
- end;
- end 'thead';
- tfoot; Tr;
- td colspan => 6;
- input type => 'submit', class => 'submit', value => 'Save changes', style => 'float: right';
- input id => 'tagmod_tag', type => 'text', class => 'text', value => '';
- input id => 'tagmod_add', type => 'button', class => 'submit', value => 'Add tag';
- br;
- p;
- txt 'Check the '; a href => '/g', 'tag list'; txt ' to browse all available tags.';
- br;
- txt 'Can\'t find what you\'re looking for? '; a href => '/g/new', 'Request a new tag'; txt '.';
- end;
- end;
- end; end 'tfoot';
- tbody id => 'tagtable';
- _tagmod_list($self, $vid, $tags, $my);
- end 'tbody';
- end 'table';
- } ],
- ]);
- $self->htmlFooter;
-}
-
-sub _tagmod_list {
- my($self, $vid, $tags, $my) = @_;
-
- my %my = map +($_->{tag} => $_), @$my;
-
- for my $cat (keys %TAG_CATEGORY) {
- my @tags = grep $_->{cat} eq $cat, @$tags;
- next if !@tags;
- Tr class => 'tagmod_cat';
- td colspan => 7, $TAG_CATEGORY{$cat};
- end;
- for my $t (@tags) {
- my $m = $my{$t->{id}};
- Tr id => "tgl_$t->{id}";
- td class => 'tc_tagname'; a href => "/g$t->{id}", $t->{name}; end;
- td class => 'tc_myvote', $m->{vote}||0;
- if($self->authCan('tagmod')) {
- td class => 'tc_myover';
- input type => 'checkbox', name => 'overrule', value => $t->{id},
- $m->{vote} && !$m->{ignore} && $t->{overruled} ? (checked => 'checked') : ()
- if $t->{cnt} > 1;
- end;
- }
- td class => 'tc_myspoil', defined $m->{spoiler} ? $m->{spoiler} : -1;
- td class => 'tc_allvote';
- VNWeb::Tags::Lib::tagscore_($t->{rating});
- i $t->{overruled} ? (class => 'grayedout') : (), " ($t->{cnt})";
- b class => 'standout', style => 'font-weight: bold', title => 'Tag overruled. All votes other than that of the moderator who overruled it will be ignored.', ' !' if $t->{overruled};
- end;
- td class => 'tc_allspoil', sprintf '%.2f', $t->{spoiler};
- td class => 'tc_allwho';
- a href => "/g/links?v=$vid;t=$t->{id}", 'Who?';
- end;
- end;
- }
- }
-}
-
-
-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;
- return $self->htmlDenied if !$self->authCan('tagmod');
-
- my $e;
- $e = sub {
- my $lst = shift;
- ul style => 'list-style-type: none; margin-left: 15px';
- for (@$lst) {
- li;
- txt '> ';
- a href => "/g$_->{id}", $_->{name};
- b class => 'grayedout', " ($_->{c_items})" if $_->{c_items};
- end;
- $e->($_->{sub}) if $_->{sub};
- }
- end;
- };
-
- my $tags = $self->dbTTTree(tag => 0, 25);
- $self->htmlHeader(title => '[DEBUG] Tag tree', noindex => 1);
- div class => 'mainbox';
- h1 '[DEBUG] Tag tree';
- $e->($tags);
- end;
- $self->htmlFooter;
-}
-
-
-sub tagxml {
- my $self = shift;
-
- my $f = $self->formValidate(
- { get => 'q', required => 0, maxlength => 500 },
- { get => 'id', required => 0, multi => 1, template => 'id' },
- { get => 'searchable', required => 0, default => 0 },
- { get => 'r', required => 0, template => 'uint', min => 1, max => 50, default => 15 },
- );
- return $self->resNotFound if $f->{_err} || (!$f->{q} && !$f->{id} && !$f->{id}[0]);
-
- my($list, $np) = $self->dbTagGet(
- !$f->{q} ? () : $f->{q} =~ /^g([1-9]\d*)/ ? (id => $1) : $f->{q} =~ /^=(.+)$/ ? (name => $1) : (search => $f->{q}, sort => 'search'),
- $f->{id} && $f->{id}[0] ? (id => $f->{id}) : (),
- results => $f->{r},
- page => 1,
- $f->{searchable} ? (state => 2, searchable => 1) : (),
- );
-
- $self->resHeader('Content-type' => 'text/xml; charset=UTF-8');
- xml;
- tag 'tags', more => $np ? 'yes' : 'no', $f->{q} ? (query => $f->{q}) : ();
- for(@$list) {
- tag 'item', id => $_->{id}, searchable => $_->{searchable} ? 'yes' : 'no', applicable => $_->{applicable} ? 'yes' : 'no', state => $_->{state}, $_->{name};
- }
- end;
-}
-
-
-1;
diff --git a/lib/VNDB/Handler/Traits.pm b/lib/VNDB/Handler/Traits.pm
deleted file mode 100644
index f9802cff..00000000
--- a/lib/VNDB/Handler/Traits.pm
+++ /dev/null
@@ -1,457 +0,0 @@
-
-package VNDB::Handler::Traits;
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml', 'html_escape', 'xml_escape';
-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,
-);
-
-
-sub traitpage {
- my($self, $trait) = @_;
-
- my $t = $self->dbTraitGet(id => $trait, what => 'parents(0) childs(2)')->[0];
- return $self->resNotFound if !$t;
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'm', required => 0, default => $self->authPref('spoilers')||0, enum => [qw|0 1 2|] },
- { get => 'fil', required => 0, default => '' },
- );
- return $self->resNotFound if $f->{_err};
-
- my $title = "Trait: $t->{name}";
- $self->htmlHeader(title => $title, noindex => $t->{state} != 2);
- $self->htmlMainTabs('i', $t);
-
- if($t->{state} != 2) {
- div class => 'mainbox';
- h1 $title;
- if($t->{state} == 1) {
- div class => 'warning';
- h2 'Trait deleted';
- p;
- txt 'This trait has been removed from the database, and cannot be used or re-added. File a request on the ';
- a href => '/t/db', 'discussion board';
- txt ' if you disagree with this.';
- end;
- end;
- } else {
- div class => 'notice';
- h2 'Waiting for approval';
- p 'This trait is waiting for a moderator to approve it.';
- end;
- }
- end 'div';
- }
-
- div class => 'mainbox';
- a class => 'addnew', href => "/i$trait/add", 'Create child trait' if $self->authCan('edit') && $t->{state} != 1;
- h1 $title;
-
- parenttags($t, 'Traits', 'i');
-
- if($t->{description}) {
- p class => 'description';
- lit bb2html $t->{description};
- end;
- }
- if(!$t->{applicable} || !$t->{searchable}) {
- p class => 'center';
- b 'Properties';
- br;
- txt 'Not searchable.' if !$t->{searchable};
- br;
- txt 'Can not be directly applied to characters.' if !$t->{applicable};
- end;
- }
- if($t->{sexual}) {
- p class => 'center';
- b 'Sexual content';
- end;
- }
- if($t->{alias}) {
- p class => 'center';
- b 'Aliases';
- br;
- lit html_escape($t->{alias});
- end;
- }
- end 'div';
-
- childtags($self, 'Child traits', 'i', $t) if @{$t->{childs}};
-
- if($t->{searchable} && $t->{state} == 2) {
- my($chars, $np) = $self->filFetchDB(char => $f->{fil}, {}, {
- trait_inc => $trait,
- tagspoil => $f->{m},
- results => 50,
- page => $f->{p},
- what => 'vns',
- });
-
- form action => "/i$t->{id}", 'accept-charset' => 'UTF-8', method => 'get';
- div class => 'mainbox';
- h1 'Characters';
-
- p class => 'browseopts';
- a href => "/i$trait?fil=$f->{fil};m=0", $f->{m} == 0 ? (class => 'optselected') : (), 'Hide spoilers';
- a href => "/i$trait?fil=$f->{fil};m=1", $f->{m} == 1 ? (class => 'optselected') : (), 'Show minor spoilers';
- a href => "/i$trait?fil=$f->{fil};m=2", $f->{m} == 2 ? (class => 'optselected') : (), 'Spoil me!';
- end;
-
- p class => 'filselect';
- a id => 'filselect', href => '#c';
- lit '<i>&#9656;</i> Filters<i></i>';
- end;
- end;
- input type => 'hidden', class => 'hidden', name => 'fil', id => 'fil', value => $f->{fil};
- input type => 'hidden', class => 'hidden', name => 'm', id => 'm', value => $f->{m};
-
- if(!@$chars) {
- p; br; br; txt 'This trait has not been linked to any characters yet, or they were hidden because of your spoiler settings.'; end;
- }
- if(@{$t->{childs}}) {
- p; br; txt 'The list below also includes all characters linked to child traits.'; end;
- }
- end 'div';
- end 'form';
- @$chars && $self->charBrowseTable($chars, $np, $f, "/i$trait?m=$f->{m};fil=$f->{fil}");
- }
-
- $self->htmlFooter;
-}
-
-
-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;
-
- my $f = $self->formValidate(
- { get => 'q', required => 0, maxlength => 500 },
- { get => 'id', required => 0, multi => 1, template => 'id' },
- { get => 'r', required => 0, default => 15, template => 'uint', min => 1, max => 200 },
- { get => 'searchable', required => 0, default => 0 },
- );
- return $self->resNotFound if $f->{_err} || (!$f->{q} && !$f->{id} && !$f->{id}[0]);
-
- my($list, $np) = $self->dbTraitGet(
- results => $f->{r},
- page => 1,
- sort => 'group',
- state => 2,
- $f->{searchable} ? (searchable => 1) : (),
- !$f->{q} ? () : $f->{q} =~ /^i([1-9]\d*)/ ? (id => $1) : (search => $f->{q}, sort => 'search'),
- $f->{id} && $f->{id}[0] ? (id => $f->{id}) : (),
- );
-
- $self->resHeader('Content-type' => 'text/xml; charset=UTF-8');
- xml;
- tag 'traits', more => $np ? 'yes' : 'no';
- for(@$list) {
- tag 'item', id => $_->{id}, searchable => $_->{searchable} ? 'yes' : 'no', applicable => $_->{applicable} ? 'yes' : 'no', group => $_->{group}||'',
- groupname => $_->{groupname}||'', state => $_->{state}, defaultspoil => $_->{defaultspoil}, $_->{name};
- }
- end;
-}
-
-
-1;
-
diff --git a/lib/VNDB/Handler/ULists.pm b/lib/VNDB/Handler/ULists.pm
deleted file mode 100644
index 03c079b1..00000000
--- a/lib/VNDB/Handler/ULists.pm
+++ /dev/null
@@ -1,51 +0,0 @@
-
-package VNDB::Handler::ULists;
-
-use strict;
-use warnings;
-use TUWF ':xml';
-use VNDB::Func;
-use VNDB::Types;
-
-
-TUWF::register(
- qr{r([1-9]\d*)/list}, \&rlist_e,
- qr{xml/rlist.xml}, \&rlist_e,
-);
-
-
-sub rlist_e {
- my($self, $id) = @_;
-
- my $rid = $id;
- if(!$rid) {
- my $f = $self->formValidate({ get => 'id', required => 1, template => 'id' });
- return $self->resNotFound if $f->{_err};
- $rid = $f->{id};
- }
-
- my $uid = $self->authInfo->{id};
- return $self->htmlDenied() if !$uid;
-
- return if !$self->authCheckCode;
- my $f = $self->formValidate(
- { get => 'e', required => 1, enum => [ -1, keys %RLIST_STATUS ] },
- { get => 'ref', required => 0, default => "/r$rid" }
- );
- return $self->resNotFound if $f->{_err};
-
- $self->dbRListDel($uid, $rid) if $f->{e} == -1;
- $self->dbRListAdd($uid, $rid, $f->{e}) if $f->{e} >= 0;
-
- if($id) {
- $self->resRedirect($f->{ref}, 'temp');
- } else {
- # doesn't really matter what we return, as long as it's XML
- $self->resHeader('Content-type' => 'text/xml');
- xml;
- tag 'done', '';
- }
-}
-
-1;
-
diff --git a/lib/VNDB/Handler/VNBrowse.pm b/lib/VNDB/Handler/VNBrowse.pm
deleted file mode 100644
index 64cc57d4..00000000
--- a/lib/VNDB/Handler/VNBrowse.pm
+++ /dev/null
@@ -1,143 +0,0 @@
-
-package VNDB::Handler::VNBrowse;
-
-use strict;
-use warnings;
-use TUWF ':html', 'uri_escape';
-use VNDB::Func;
-use VNDB::Types;
-
-
-TUWF::register(
- qr{v/([a-z0]|all)} => \&list,
-);
-
-
-sub list {
- my($self, $char) = @_;
-
- my $f = $self->formValidate(
- { get => 's', required => 0, default => 'tagscore', enum => [ qw|title rel pop tagscore rating| ] },
- { get => 'o', required => 0, enum => [ 'a','d' ] },
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'q', required => 0, default => '' },
- { get => 'sq', required => 0, default => '' },
- { get => 'fil',required => 0 },
- { get => 'rfil', required => 0, default => '' },
- { get => 'cfil', required => 0, default => '' },
- { get => 'vnlist', required => 0, default => 2, enum => [ '0', '1' ] }, # 2: use pref
- );
- return $self->resNotFound if $f->{_err};
- $f->{q} ||= $f->{sq};
- $f->{fil} //= $self->authPref('filter_vn');
- my %compat = _fil_compat($self);
- my $uid = $self->authInfo->{id};
-
- my $read_write_pref = sub {
- my($type, $pref_name) = @_;
-
- return 0 if !$uid; # no data to display anyway
- return $self->authPref($pref_name)?1:0 if $f->{$type} == 2;
-
- $self->authPref($pref_name => $f->{$type}?1:0) if ($self->authPref($pref_name)?1:0) != $f->{$type};
- return $f->{$type};
- };
-
- $f->{vnlist} = $read_write_pref->('vnlist', 'vn_list_own');
-
- return $self->resRedirect('/'.$1.$2.(!$3 ? '' : $1 eq 'd' ? '#'.$3 : '.'.$3), 'temp')
- if $f->{q} && $f->{q} =~ /^([gvrptudcis])([0-9]+)(?:\.([0-9]+))?$/;
-
- $f->{s} = 'title' if $f->{fil} !~ /tag_inc-/ && $f->{s} eq 'tagscore';
- $f->{o} = $f->{s} eq 'tagscore' ? 'd' : 'a' if !$f->{o};
-
- my $rfil = fil_parse $f->{rfil}, @{$VNDB::Util::Misc::filfields{release}};
- $self->filCompat(release => $rfil);
- $f->{rfil} = fil_serialize $rfil, @{$VNDB::Util::Misc::filfields{release}};
-
- my $cfil = fil_parse $f->{cfil}, @{$VNDB::Util::Misc::filfields{char}};
- $cfil->{tagspoil} //= $self->authPref('spoilers')||0 if keys %$cfil;
-
- my($list, $np) = $self->filFetchDB(vn => $f->{fil}, {
- %compat,
- tagspoil => $self->authPref('spoilers')||0,
- }, {
- what => ' rating'.($f->{vnlist} ? ' vnlist' : ''),
- $char ne 'all' ? ( char => $char ) : (),
- $f->{q} ? ( search => $f->{q} ) : (),
- keys %$rfil ? ( release => $rfil ) : (),
- keys %$cfil ? ( character => $cfil ) : (),
- results => 50,
- page => $f->{p},
- sort => $f->{s}, reverse => $f->{o} eq 'd',
- });
-
- $self->resRedirect('/v'.$list->[0]{id}, 'temp')
- if $f->{q} && @$list == 1 && $f->{p} == 1;
-
- $self->htmlHeader(title => 'Browse visual novels', search => $f->{q});
-
- my $quri = uri_escape($f->{q});
- form action => '/v/all', 'accept-charset' => 'UTF-8', method => 'get';
-
- # url generator
- my $url = sub {
- my($char, $toggle) = @_;
-
- return "/v/$char?q=$quri;fil=$f->{fil};rfil=$f->{rfil};cfil=$f->{cfil};s=$f->{s};o=$f->{o}" .
- ($toggle ? ";$toggle=".($f->{$toggle}?0:1) : '');
- };
-
- div class => 'mainbox';
- h1 'Browse visual novels';
- $self->htmlSearchBox('v', $f->{q});
- p class => 'browseopts';
- for ('all', 'a'..'z', 0) {
- a href => $url->($_), $_ eq $char ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#';
- }
- end;
- if($uid) {
- p class => 'browseopts';
- a href => $url->($char, 'vnlist'), $f->{vnlist} ? (class => 'optselected') : (), 'User VN list';
- end 'p';
- }
-
- p class => 'filselect';
- a id => 'filselect', href => '#v';
- lit '<i>&#9656;</i> Visual Novel Filters<i></i>';
- end;
- a id => 'rfilselect', href => '#r';
- lit '<i>&#9656;</i> Release filters<i></i>';
- end;
- a id => 'cfilselect', href => '#c';
- lit '<i>&#9656;</i> Character filters<i></i>';
- end;
- end;
- input type => 'hidden', class => 'hidden', name => $_, id => $_, value => $f->{$_}
- for (qw{fil rfil cfil s o});
- end;
- end 'form';
-
- $self->htmlBrowseVN($list, $f, $np, "/v/$char?q=$quri;fil=$f->{fil};rfil=$f->{rfil};cfil=$f->{cfil}", $f->{fil} =~ /tag_inc-/);
- $self->htmlFooter(pref_code => 1);
-}
-
-
-sub _fil_compat {
- my $self = shift;
- my %c;
- my $f = $self->formValidate(
- { get => 'ln', required => 0, multi => 1, enum => [ keys %LANGUAGE ], default => '' },
- { get => 'pl', required => 0, multi => 1, enum => [ keys %PLATFORM ], default => '' },
- { get => 'sp', required => 0, default => ($self->reqCookie('tagspoil')||'') =~ /^([0-2])$/ ? $1 : 0, enum => [0..2] },
- );
- return () if $f->{_err};
- $c{lang} //= $f->{ln} if $f->{ln}[0];
- $c{plat} //= $f->{pl} if $f->{pl}[0];
- $c{tagspoil} //= $f->{sp};
- return %c;
-}
-
-
-1;
-
diff --git a/lib/VNDB/Handler/VNEdit.pm b/lib/VNDB/Handler/VNEdit.pm
deleted file mode 100644
index 932a07f9..00000000
--- a/lib/VNDB/Handler/VNEdit.pm
+++ /dev/null
@@ -1,541 +0,0 @@
-
-package VNDB::Handler::VNEdit;
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml';
-use Image::Magick;
-use VNDB::Func;
-use VNDB::Types;
-
-
-TUWF::register(
- qr{v(?:([1-9]\d*)(?:\.([1-9]\d*))?/edit|/new)}
- => \&edit,
- qr{v/add} => \&addform,
- qr{xml/vn\.xml} => \&vnxml,
- qr{xml/screenshots\.xml} => \&scrxml,
-);
-
-
-sub addform {
- my $self = shift;
- return $self->htmlDenied if !$self->authCan('edit');
-
- my $frm;
- my $l = [];
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'title', maxlength => 250 },
- { post => 'original', required => 0, maxlength => 250, default => '' },
- { post => 'alias', required => 0, maxlength => 500, default => '' },
- { post => 'continue_ign',required => 0 },
- );
-
- # look for duplicates
- if(!$frm->{_err} && !$frm->{continue_ign}) {
- $l = $self->dbVNGet(search => $frm->{title}, what => 'changes', results => 50, inc_hidden => 1);
- push @$l, @{$self->dbVNGet(search => $frm->{original}, what => 'changes', results => 50, inc_hidden => 1)} if $frm->{original};
- $_ && push @$l, @{$self->dbVNGet(search => $_, what => 'changes', results => 50, inc_hidden => 1)} for(split /\n/, $frm->{alias});
- my %ids = map +($_->{id}, $_), @$l;
- $l = [ map $ids{$_}, sort { $ids{$a}{title} cmp $ids{$b}{title} } keys %ids ];
- }
-
- return edit($self, undef, undef, 1) if !@$l && !$frm->{_err};
- }
-
- $self->htmlHeader(title => 'Add a new visual novel', noindex => 1);
- if(@$l) {
- div class => 'mainbox';
- h1 'Possible duplicates found';
- div class => 'warning';
- p;
- txt 'The following is a list of visual novels that match the title(s) you gave.'
- .' Please check this list to avoid creating a duplicate visual novel entry.'
- .' Be especially wary of items that have been deleted! To see why an entry has been deleted, click on its title.';
- br; br;
- txt 'To add the visual novel anyway, hit the "Continue and ignore duplicates" button below.';
- end;
- end;
- ul;
- for(@$l) {
- li;
- a href => "/v$_->{id}", title => $_->{original}||$_->{title}, "v$_->{id}: ".shorten($_->{title}, 50);
- b class => 'standout', ' deleted' if $_->{hidden};
- end;
- }
- end;
- end 'div';
- }
-
- $self->htmlForm({ frm => $frm, action => '/v/add', continue => @$l ? 2 : 1 },
- vn_add => [ 'Add a new visual novel',
- [ input => short => 'title', name => 'Title (romaji)', width => 450 ],
- [ input => short => 'original', name => 'Original title', width => 450 ],
- [ static => content => 'The original title of this visual novel, leave blank if it already is in the Latin alphabet.' ],
- [ textarea => short => 'alias', name => 'Aliases', rows => 4 ],
- [ static => content => 'List of alternative titles or abbreviations. One line for each alias.' ],
- ]);
- $self->htmlFooter;
-}
-
-
-sub edit {
- my($self, $vid, $rev, $nosubmit) = @_;
-
- my $v = $vid && $self->dbVNGetRev(id => $vid, what => 'extended screenshots relations anime staff seiyuu changes', $rev ? (rev => $rev) : ())->[0];
- return $self->resNotFound if $vid && !$v->{id};
- $rev = undef if !$vid || $v->{lastrev};
-
- return $self->htmlDenied if !$self->authCan('edit')
- || $vid && (($v->{locked} || $v->{hidden}) && !$self->authCan('dbmod'));
-
- my $r = $v ? $self->dbReleaseGet(vid => $v->{id}) : [];
- my $chars = $v ? $self->dbCharGet(vid => $v->{id}, results => 500) : [];
-
- my %b4 = !$vid ? () : (
- (map { $_ => $v->{$_} } qw|title original desc alias length l_renai l_wikidata image img_nsfw ihid ilock|),
- credits => [
- map { my $c = $_; +{ map { $_ => $c->{$_} } qw|aid role note| } }
- sort { $a->{aid} <=> $b->{aid} || $a->{role} cmp $b->{role} } @{$v->{credits}}
- ],
- seiyuu => [
- map { my $c = $_; +{ map { $_ => $c->{$_} } qw|aid cid note| } }
- sort { $a->{aid} <=> $b->{aid} || $a->{cid} <=> $b->{cid} } @{$v->{seiyuu}}
- ],
- anime => join(' ', sort { $a <=> $b } map $_->{id}, @{$v->{anime}}),
- vnrelations => join('|||', map $_->{relation}.','.$_->{id}.','.($_->{official}?1:0).','.$_->{title}, sort { $a->{id} <=> $b->{id} } @{$v->{relations}}),
- screenshots => [
- map +{ id => $_->{id}, nsfw => $_->{nsfw}?1:0, rid => $_->{rid} },
- sort { $a->{id} <=> $b->{id} } @{$v->{screenshots}}
- ]
- );
-
- my $frm;
- if($self->reqMethod eq 'POST') {
- return if !$nosubmit && !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'title', maxlength => 250 },
- { post => 'original', required => 0, maxlength => 250, default => '' },
- { post => 'alias', required => 0, maxlength => 500, default => '' },
- { post => 'desc', required => 0, default => '', maxlength => 10240 },
- { post => 'length', required => 0, default => 0, enum => [ keys %VN_LENGTH ] },
- { post => 'l_renai', required => 0, default => '', maxlength => 100 },
- { post => 'l_wikidata', required => 0, template => 'wikidata' },
- { post => 'anime', required => 0, default => '' },
- { post => 'image', required => 0, default => 0, template => 'id' },
- { post => 'img_nsfw', required => 0, default => 0 },
- { post => 'credits', required => 0, template => 'json', json_unique => ['aid','role'], json_sort => ['aid','role'], json_fields => [
- { field => 'aid', required => 1, template => 'id' },
- { field => 'role', required => 1, enum => [ keys %CREDIT_TYPE ] },
- { field => 'note', required => 0, maxlength => 250, default => '' },
- ]},
- { post => 'seiyuu', required => 0, template => 'json', json_unique => ['aid','cid'], json_sort => ['aid','cid'], json_fields => [
- { field => 'aid', required => 1, template => 'id' },
- { field => 'cid', required => 1, template => 'id' },
- { field => 'note', required => 0, maxlength => 250, default => '' },
- ]},
- { post => 'vnrelations', required => 0, default => '', maxlength => 5000 },
- { post => 'screenshots', required => 0, template => 'json', json_maxitems => 10, json_unique => 'id', json_sort => 'id', json_fields => [
- { field => 'id', required => 1, template => 'id' },
- { field => 'rid', required => 1, template => 'id' },
- { field => 'nsfw', required => 1, template => 'uint', enum => [0,1] },
- ]},
- { post => 'editsum', required => !$nosubmit, template => 'editsum' },
- { post => 'ihid', required => 0 },
- { post => 'ilock', required => 0 },
- );
- $frm->{original} = '' if $frm->{original} eq $frm->{title};
-
- # handle image upload
- $frm->{image} = _uploadimage($self, $frm) if !$nosubmit;
-
- if(!$nosubmit && !$frm->{_err}) {
- # normalize aliases
- $frm->{alias} = join "\n", map { s/^ +//g; s/ +$//g; $_?($_):() } split /\n/, $frm->{alias};
- # throw error on duplicate/existing aliases
- my %alias = map +(lc($_),1), $frm->{title}, $frm->{original}, map +($_->{title}, $_->{original}), @$r;
- my @e = map $alias{ lc($_) }++ ? "Duplicate alias '$_', or the alias is already used as a release title" : (), split /\n/, $frm->{alias};
- $frm->{_err} = \@e if @e;
- }
- if(!$nosubmit && !$frm->{_err}) {
- # parse and re-sort fields that have multiple representations of the same information
- my $anime = { map +($_=>1), grep /^[0-9]+$/, split /[ ,]+/, $frm->{anime} };
- my $relations = [ map { /^([a-z]+),([0-9]+),([01]),(.+)$/ && (!$vid || $2 != $vid) ? [ $1, $2, $3, $4 ] : () } split /\|\|\|/, $frm->{vnrelations} ];
-
- # Ensure submitted alias / character IDs exist within database
- my @alist = map $_->{aid}, @{$frm->{credits}}, @{$frm->{seiyuu}};
- my %staff = @alist ? map +($_->{aid}, 1), @{$self->dbStaffGet(aid => \@alist, results => 200)} : ();
- my %vn_chars = map +($_->{id} => 1), @$chars;
- $frm->{credits} = [ grep $staff{$_->{aid}}, @{$frm->{credits}} ];
- $frm->{seiyuu} = [ grep $staff{$_->{aid}} && $vn_chars{$_->{cid}}, @$chars ? @{$frm->{seiyuu}} : () ];
-
- $frm->{ihid} = $frm->{ihid}?1:0;
- $frm->{ilock} = $frm->{ilock}?1:0;
- $frm->{desc} = $self->bbSubstLinks($frm->{desc});
- $relations = [] if $frm->{ihid};
- $frm->{anime} = join ' ', sort { $a <=> $b } keys %$anime;
- $frm->{vnrelations} = join '|||', map $_->[0].','.$_->[1].','.($_->[2]?1:0).','.$_->[3], sort { $a->[1] <=> $b->[1]} @{$relations};
- $frm->{img_nsfw} = $frm->{img_nsfw} ? 1 : 0;
- $frm->{screenshots} = [ sort { $a->{id} <=> $b->{id} } @{$frm->{screenshots}} ];
-
- # nothing changed? just redirect
- return $self->resRedirect("/v$vid", 'post') if $vid && !form_compare(\%b4, $frm);
-
- # perform the edit/add
- my $nrev = $self->dbItemEdit(v => $vid ? ($v->{id}, $v->{rev}) : (undef, undef),
- (map { $_ => $frm->{$_} } qw|title original image alias desc length l_renai l_wikidata editsum img_nsfw ihid ilock credits seiyuu screenshots|),
- anime => [ keys %$anime ],
- relations => $relations,
- );
-
- # update reverse relations & relation graph
- if(!$vid && $#$relations >= 0 || $vid && $frm->{vnrelations} ne $b4{vnrelations}) {
- my %old = $vid ? (map +($_->{id} => [ $_->{relation}, $_->{official} ]), @{$v->{relations}}) : ();
- my %new = map +($_->[1] => [ $_->[0], $_->[2] ]), @$relations;
- _updreverse($self, \%old, \%new, $nrev->{itemid}, $nrev->{rev});
- }
-
- return $self->resRedirect("/v$nrev->{itemid}.$nrev->{rev}", 'post');
- }
- }
-
- !exists $frm->{$_} && ($frm->{$_} = $b4{$_}) for (keys %b4);
- $frm->{editsum} = sprintf 'Reverted to revision v%d.%d', $vid, $rev if $rev && !defined $frm->{editsum};
-
- my $title = $vid ? "Edit $v->{title}" : 'Add a new visual novel';
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('v', $v, 'edit') if $vid;
- $self->htmlEditMessage('v', $v, $title);
- _form($self, $v, $frm, $r, $chars);
- $self->htmlFooter;
-}
-
-
-sub _uploadimage {
- my($self, $frm) = @_;
-
- if($frm->{_err} || !$self->reqPost('img')) {
- return 0 if !$frm->{image};
- push @{$frm->{_err}}, 'No image with that ID' if !-s imgpath(cv => $frm->{image});
- return $frm->{image};
- }
-
- # perform some elementary checks
- my $imgdata = $self->reqUploadRaw('img');
- $frm->{_err} = [ 'Image must be in JPEG or PNG format' ] if $imgdata !~ /^(\xff\xd8|\x89\x50)/; # JPG or PNG headers
- $frm->{_err} = [ 'Image is too large, only 5MB allowed' ] if length($imgdata) > 5*1024*1024;
- return undef if $frm->{_err};
-
- # resize/compress
- my $im = Image::Magick->new;
- $im->BlobToImage($imgdata);
- $im->Set(magick => 'JPEG');
- my($ow, $oh) = ($im->Get('width'), $im->Get('height'));
- my($nw, $nh) = imgsize($ow, $oh, @{$self->{cv_size}});
- $im->Set(background => '#ffffff');
- $im->Set(alpha => 'Remove');
- if($ow != $nw || $oh != $nh) {
- $im->GaussianBlur(geometry => '0.5x0.5');
- $im->Resize(width => $nw, height => $nh);
- $im->UnsharpMask(radius => 0, sigma => 0.75, amount => 0.75, threshold => 0.008);
- }
- $im->Set(quality => 90);
-
- # Get ID and save
- my $imgid = $self->dbVNImageId;
- my $fn = imgpath(cv => $imgid);
- $im->Write($fn);
- chmod 0666, $fn;
-
- return $imgid;
-}
-
-
-sub _form {
- my($self, $v, $frm, $r, $chars) = @_;
- $self->htmlForm({ frm => $frm, action => $v ? "/v$v->{id}/edit" : '/v/new', editsum => 1, upload => 1 },
- vn_geninfo => [ 'General info',
- [ input => short => 'title', name => 'Title (romaji)', width => 450 ],
- [ input => short => 'original', name => 'Original title', width => 450 ],
- [ static => content => 'The original title of this visual novel, leave blank if it already is in the Latin alphabet.' ],
- [ textarea => short => 'alias', name => 'Aliases', rows => 4 ],
- [ static => content =>
- 'List of alternative titles or abbreviations. One line for each alias.'
- .' Can include both official (japanese/english) titles and unofficial titles used around net.<br />'
- .' Titles that are listed in the releases should not be added here!' ],
- [ textarea => short => 'desc', name => 'Description<br /><b class="standout">English please!</b>', rows => 10 ],
- [ static => content =>
- '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. Formatting codes are allowed.' ],
- [ select => short => 'length', name => 'Length', options =>
- [ map [ $_ => fmtvnlen $_, 1 ], keys %VN_LENGTH ] ],
-
- [ input => short => 'l_wikidata',name => 'Wikidata ID',
- pre => 'https://www.wikidata.org/wiki/',
- value => $frm->{l_wikidata} ? "Q$frm->{l_wikidata}" : '',
- post => qq{ (<a href="$self->{url_static}/f/wikidata.png">How to find this</a>)}
- ],
- [ input => short => 'l_renai', name => 'Renai.us link', pre => 'http://renai.us/game/', post => '.shtml' ],
-
- [ input => short => 'anime', name => 'Anime' ],
- [ static => content =>
- 'Whitespace separated list of <a href="http://anidb.net/">AniDB</a> anime IDs.'
- .' E.g. "1015 3348" will add <a href="http://anidb.net/a1015">Shingetsutan Tsukihime</a>'
- .' and <a href="http://anidb.net/a3348">Fate/stay night</a> as related anime.<br />'
- .' Note: It can take a few minutes for the anime titles to appear on the VN page.' ],
- ],
-
- vn_img => [ 'Image', [ static => nolabel => 1, content => sub {
- div class => 'img';
- p 'No image uploaded yet' if !$frm->{image};
- img src => imgurl(cv => $frm->{image}) if $frm->{image};
- end;
-
- div;
- h2 'Image ID';
- input type => 'text', class => 'text', name => 'image', id => 'image', value => $frm->{image}||'';
- p 'Use a VN image that is already on the server. Set to \'0\' to remove the current image.';
- br; br;
-
- h2 'Upload new image';
- input type => 'file', class => 'text', name => 'img', id => 'img';
- p 'Preferably the cover of the CD/DVD/package. Image must be in JPEG or PNG format'
- .' and at most 5MB. Images larger than 256x400 will automatically be resized.';
- br; br; br;
-
- h2 'NSFW';
- input type => 'checkbox', class => 'checkbox', id => 'img_nsfw', name => 'img_nsfw',
- $frm->{img_nsfw} ? (checked => 'checked') : ();
- label class => 'checkbox', for => 'img_nsfw', 'Not Safe For Work';
- p 'Please check this option if the image contains nudity, gore, or is otherwise not safe in a work-friendly environment.';
- end 'div';
- }]],
-
- vn_staff => [ 'Staff',
- [ json => short => 'credits' ],
- [ static => nolabel => 1, content => sub {
- # propagate staff ids and names to javascript
- my @alist = map $_->{aid}, @{$frm->{credits}}, @{$frm->{seiyuu}};
- script_json staffdata => {
- map +($_->{aid}, {id => $_->{id}, aid => $_->{aid}, name => $_->{name}}),
- @alist ? @{$self->dbStaffGet(aid => \@alist, results => 200)} : ()
- };
- div class => 'warning';
- lit 'Please check the <a href="/d2#3">staff editing guidelines</a>. You can'
- .' <a href="/s/new">create a new staff entry</a> if it is not in the database yet,'
- .' but please <a href="/s/all">check for aliasses first</a>.';
- end;
- br;
- table; tbody id => 'credits_tbl';
- Tr id => 'credits_loading'; td colspan => '4', 'Loading...'; end;
- end; end;
- h2 'Add staff';
- table; Tr;
- td class => 'tc_staff';
- input id => 'credit_input', type => 'text', class => 'text', style => 'width: 300px'; end;
- td colspan => 3, '';
- end; end;
- }]],
-
- # Cast tab is only shown for VNs with some characters listed.
- # There's no way to add voice actors in new VN edits since character list
- # would be empty anyway.
- @{$chars} ? (vn_cast => [ 'Cast',
- [ json => short => 'seiyuu' ],
- [ static => nolabel => 1, content => sub {
- table; tbody id => 'cast_tbl';
- Tr id => 'cast_loading'; td colspan => '4', 'Loading...'; end;
- end; end;
- h2 'Add cast';
- table; Tr;
- td class => 'tc_char';
- Select id =>'cast_chars';
- option value => '', 'Select character';
- for my $i (0..$#$chars) {
- my($name, $id) = @{$chars->[$i]}{qw|name id|};
- # append character IDs to coinciding names
- # (assume dbCharGet sorted characters by name)
- $name .= ' - c'.$id if $name eq ($chars->[$i+1]{name}//'')
- .. $name ne ($chars->[$i+1]{name}//'');
- option value => $id, $name;
- }
- end;
- txt ' voiced by';
- end;
- td class => 'tc_staff';
- input id => 'cast_input', type => 'text', class => 'text', style => 'width: 300px';
- end;
- td colspan => 2, '';
- end; end;
- }]]) : (),
-
- vn_rel => [ 'Relations',
- [ hidden => short => 'vnrelations' ],
- [ static => nolabel => 1, content => sub {
- h2 'Selected relations';
- table;
- tbody id => 'relation_tbl';
- # to be filled using javascript
- end;
- end;
-
- h2 'Add relation';
- table;
- Tr id => 'relation_new';
- td class => 'tc_vn';
- input type => 'text', class => 'text';
- end;
- td class => 'tc_rel';
- txt 'is an ';
- input type => 'checkbox', id => 'official', checked => 'checked';
- label for => 'official', 'official';
- Select;
- option value => $_, $VN_RELATION{$_}{txt}
- for (keys %VN_RELATION);
- end;
- txt ' of';
- end;
- td class => 'tc_title', $v ? $v->{title} : '';
- td class => 'tc_add';
- a href => '#', 'add';
- end;
- end;
- end 'table';
- }],
- ],
-
- vn_scr => [ 'Screenshots', !@$r ? (
- [ static => nolabel => 1, content => 'No releases in the database yet. Screenshots can only be uploaded after a release has been added.' ],
- ) : (
- [ json => short => 'screenshots' ],
- [ static => nolabel => 1, content => sub {
- my @scr = map $_->{id}, @{$frm->{screenshots}};
- my %scr = map +($_->{id}, [ $_->{width}, $_->{height}]), @scr ? @{$self->dbScreenshotGet(\@scr)} : ();
- my @rels = map [ $_->{id}, sprintf '[%s] %s (r%d)', join(',', @{$_->{languages}}), $_->{title}, $_->{id} ], @$r;
- script_json screendata => {
- size => \%scr,
- rel => \@rels,
- staticurl => $self->{url_static},
- };
- div class => 'warning';
- lit 'Please keep the following in mind when uploading screenshots:<br />'
- .'- Screenshots have to be in the native resolution of the game,<br />'
- .'- Remove any window borders and make sure the image is unmarked,<br />'
- .'- Don\'t only upload event CGs.<br />'
- .'Please read the <a href="/d2#6">guidelines</a> for more information.<br />'
- .'Make sure to submit the form after the upload has finished!';
- end;
- br;
- table class => 'stripe';
- tbody id => 'scr_table', '';
- end;
- }],
- )]
-
- );
-}
-
-
-# Update reverse relations and regenerate relation graph
-# Arguments: %old. %new, vid, rev
-# %old,%new -> { vid => [ relation, official ], .. }
-# from the perspective of vid
-# rev is of the related edit
-sub _updreverse {
- my($self, $old, $new, $vid, $rev) = @_;
- my %upd;
-
- # compare %old and %new
- for (keys %$old, keys %$new) {
- if(exists $$old{$_} and !exists $$new{$_}) {
- $upd{$_} = undef;
- } elsif((!exists $$old{$_} and exists $$new{$_}) || ($$old{$_}[0] ne $$new{$_}[0] || !$$old{$_}[1] != !$$new{$_}[1])) {
- $upd{$_} = [ $VN_RELATION{ $$new{$_}[0] }{reverse}, $$new{$_}[1] ];
- }
- }
- return if !keys %upd;
-
- # edit all related VNs
- for my $i (keys %upd) {
- my $r = $self->dbVNGetRev(id => $i, what => 'relations')->[0];
- my @newrel = map $_->{id} != $vid ? [ $_->{relation}, $_->{id}, $_->{official} ] : (), @{$r->{relations}};
- push @newrel, [ $upd{$i}[0], $vid, $upd{$i}[1] ] if $upd{$i};
- $self->dbItemEdit(v => $r->{id}, $r->{rev},
- relations => \@newrel,
- editsum => "Reverse relation update caused by revision v$vid.$rev",
- uid => 1, # Multi
- );
- }
-}
-
-
-# peforms a (simple) search and returns the results in XML format
-sub vnxml {
- my $self = shift;
-
- my $q = $self->formValidate({ get => 'q', maxlength => 500 });
- return $self->resNotFound if $q->{_err};
- $q = $q->{q};
-
- my($list, $np) = $self->dbVNGet(
- $q =~ /^v([1-9]\d*)/ ? (id => $1) : (search => $q),
- results => 10,
- page => 1,
- );
-
- $self->resHeader('Content-type' => 'text/xml; charset=UTF-8');
- xml;
- tag 'vns', more => $np ? 'yes' : 'no', query => $q;
- for(@$list) {
- tag 'item', id => $_->{id}, $_->{title};
- }
- end;
-}
-
-
-# handles uploading screenshots and fetching information about them
-sub scrxml {
- my $self = shift;
- return $self->htmlDenied if !$self->authCan('edit') || $self->reqMethod ne 'POST';
-
- # upload new screenshot
- my $id = 0;
- my $imgdata = $self->reqUploadRaw('file');
- $id = -2 if !$imgdata;
- $id = -1 if !$id && $imgdata !~ /^(\xff\xd8|\x89\x50)/; # JPG or PNG headers
-
- # no error? process it
- my($ow, $oh);
- if(!$id) {
- my $im = Image::Magick->new;
- $im->BlobToImage($imgdata);
- $im->Set(background => '#000000');
- $im->Set(alpha => 'Remove');
- $im->Set(magick => 'JPEG');
- $im->Set(quality => 90);
- ($ow, $oh) = ($im->Get('width'), $im->Get('height'));
-
- $id = $self->dbScreenshotAdd($ow, $oh);
- my $fn = imgpath(sf => $id);
- $im->Write($fn);
- chmod 0666, $fn;
-
- # thumbnail
- my($nw, $nh) = imgsize($ow, $oh, @{$self->{scr_size}});
- $im->Thumbnail(width => $nw, height => $nh);
- $im->Set(quality => 90);
- $fn = imgpath(st => $id);
- $im->Write($fn);
- chmod 0666, $fn;
- }
-
- $self->resHeader('Content-type' => 'text/xml; charset=UTF-8');
- xml;
- tag 'image', id => $id, $id > 0 ? (width => $ow, height => $oh) : (), undef;
-}
-
-
-1;
-
diff --git a/lib/VNDB/Handler/VNPage.pm b/lib/VNDB/Handler/VNPage.pm
deleted file mode 100644
index 8b01fabc..00000000
--- a/lib/VNDB/Handler/VNPage.pm
+++ /dev/null
@@ -1,1062 +0,0 @@
-
-package VNDB::Handler::VNPage;
-
-use strict;
-use warnings;
-use TUWF ':html', 'xml_escape';
-use VNDB::Func;
-use VNDB::Types;
-use VNDB::ExtLinks;
-use List::Util 'min';
-use POSIX 'strftime';
-
-
-TUWF::register(
- qr{v/rand} => \&rand,
- qr{v([1-9]\d*)/rg} => \&rg,
- qr{v([1-9]\d*)/releases} => \&releases,
- qr{v([1-9]\d*)/(chars)} => \&page,
- qr{v([1-9]\d*)/staff} => sub { $_[0]->resRedirect("/v$_[1]#staff") },
- qr{v([1-9]\d*)(?:\.([1-9]\d*))?} => \&page,
-);
-
-
-sub rand {
- my $self = shift;
- $self->resRedirect('/v'.$self->filFetchDB(vn => undef, undef, {results => 1, sort => 'rand'})->[0]{id}, 'temp');
-}
-
-
-sub rg {
- my($self, $vid) = @_;
-
- my $v = $self->dbVNGet(id => $vid, what => 'relgraph')->[0];
- return $self->resNotFound if !$v->{id} || !$v->{rgraph};
-
- my $title = "Relation graph for $v->{title}";
- return if $self->htmlRGHeader($title, 'v', $v);
-
- $v->{svg} =~ s/id="node_v$vid"/id="graph_current"/;
-
- div class => 'mainbox';
- h1 $title;
- p 'Note: Unofficial relations are excluded if the graph would otherwise be too large.';
- p class => 'center';
- lit $v->{svg};
- end;
- end;
- $self->htmlFooter;
-}
-
-
-# Description of each column, field:
-# id: Identifier used in URLs
-# sort_field: Name of the field when sorting
-# what: Required dbReleaseGet 'what' flag
-# column_string: String to use as column header
-# column_width: Maximum width (in pixels) of the column in 'restricted width' mode
-# button_string: String to use for the hide/unhide button
-# na_for_patch: When the field is N/A for patch releases
-# default: Set when it's visible by default
-# has_data: Subroutine called with a release object, should return true if the release has data for the column
-# draw: Subroutine called with a release object, should draw its column contents
-my @rel_cols = (
- { # Title
- id => 'tit',
- sort_field => 'title',
- column_string => 'Title',
- draw => sub { a href => "/r$_[0]{id}", shorten $_[0]{title}, 60 },
- }, { # Type
- id => 'typ',
- sort_field => 'type',
- button_string => 'Type',
- default => 1,
- draw => sub { cssicon "rt$_[0]{type}", $_[0]{type}; txt '(patch)' if $_[0]{patch} },
- }, { # Languages
- id => 'lan',
- button_string => 'Language',
- default => 1,
- has_data => sub { !!@{$_[0]{languages}} },
- draw => sub {
- for(@{$_[0]{languages}}) {
- cssicon "lang $_", $LANGUAGE{$_};
- br if $_ ne $_[0]{languages}[$#{$_[0]{languages}}];
- }
- },
- }, { # Publication
- id => 'pub',
- sort_field => 'publication',
- column_string => 'Publication',
- column_width => 70,
- button_string => 'Publication',
- default => 1,
- what => 'extended',
- draw => sub { txt join ', ', $_[0]{freeware} ? 'Freeware' : 'Non-free', $_[0]{patch} ? () : ($_[0]{doujin} ? 'doujin' : 'commercial') },
- }, { # Platforms
- id => 'pla',
- button_string => 'Platforms',
- default => 1,
- what => 'platforms',
- has_data => sub { !!@{$_[0]{platforms}} },
- draw => sub {
- for(@{$_[0]{platforms}}) {
- cssicon $_, $PLATFORM{$_};
- br if $_ ne $_[0]{platforms}[$#{$_[0]{platforms}}];
- }
- txt 'Unknown' if !@{$_[0]{platforms}};
- },
- }, { # Media
- id => 'med',
- column_string => 'Media',
- button_string => 'Media',
- what => 'media',
- has_data => sub { !!@{$_[0]{media}} },
- draw => sub {
- for(@{$_[0]{media}}) {
- txt fmtmedia($_->{medium}, $_->{qty});
- br if $_ ne $_[0]{media}[$#{$_[0]{media}}];
- }
- txt 'Unknown' if !@{$_[0]{media}};
- },
- }, { # Resolution
- id => 'res',
- sort_field => 'resolution',
- column_string => 'Resolution',
- button_string => 'Resolution',
- na_for_patch => 1,
- default => 1,
- what => 'extended',
- has_data => sub { $_[0]{resolution} ne 'unknown' },
- draw => sub {
- txt $_[0]{resolution} eq 'unknown' ? 'Unknown' : $RESOLUTION{$_[0]{resolution}}{txt};
- },
- }, { # Voiced
- id => 'voi',
- sort_field => 'voiced',
- column_string => 'Voiced',
- column_width => 70,
- button_string => 'Voiced',
- na_for_patch => 1,
- default => 1,
- what => 'extended',
- has_data => sub { !!$_[0]{voiced} },
- draw => sub { txt $VOICED{$_[0]{voiced}}{txt} },
- }, { # Animation
- id => 'ani',
- sort_field => 'ani_ero',
- column_string => 'Animation',
- column_width => 110,
- button_string => 'Animation',
- na_for_patch => '1',
- what => 'extended',
- has_data => sub { !!($_[0]{ani_story} || $_[0]{ani_ero}) },
- draw => sub {
- txt join ', ',
- $_[0]{ani_story} ? "Story: $ANIMATED{$_[0]{ani_story}}{txt}" :(),
- $_[0]{ani_ero} ? "Ero scenes: $ANIMATED{$_[0]{ani_ero}}{txt}":();
- txt 'Unknown' if !$_[0]{ani_story} && !$_[0]{ani_ero};
- },
- }, { # Released
- id => 'rel',
- sort_field => 'released',
- column_string => 'Released',
- button_string => 'Released',
- default => 1,
- draw => sub { lit fmtdatestr $_[0]{released} },
- }, { # Age rating
- id => 'min',
- sort_field => 'minage',
- button_string => 'Age rating',
- default => 1,
- has_data => sub { $_[0]{minage} != -1 },
- draw => sub { txt minage $_[0]{minage} },
- }, { # Notes
- id => 'not',
- sort_field => 'notes',
- column_string => 'Notes',
- column_width => 400,
- button_string => 'Notes',
- default => 1,
- what => 'extended',
- has_data => sub { !!$_[0]{notes} },
- draw => sub { lit bb2html $_[0]{notes} },
- }
-);
-
-
-sub releases {
- my($self, $vid) = @_;
-
- my $v = $self->dbVNGet(id => $vid)->[0];
- return $self->resNotFound if !$v->{id};
-
- my $title = "Releases for $v->{title}";
- $self->htmlHeader(title => $title);
- $self->htmlMainTabs('v', $v, 'releases');
-
- my $f = $self->formValidate(
- map({ get => $_->{id}, required => 0, default => $_->{default}||0, enum => [0,1] }, grep $_->{button_string}, @rel_cols),
- { get => 'cw', required => 0, default => 0, enum => [0,1] },
- { get => 'o', required => 0, default => 0, enum => [0,1] },
- { get => 's', required => 0, default => 'released', enum => [ map $_->{sort_field}, grep $_->{sort_field}, @rel_cols ]},
- { get => 'os', required => 0, default => 'all', enum => [ 'all', keys %PLATFORM ] },
- { get => 'lang', required => 0, default => 'all', enum => [ 'all', keys %LANGUAGE ] },
- );
- return $self->resNotFound if $f->{_err};
-
- # Get the release info
- my %what = map +($_->{what}, 1), grep $_->{what} && $f->{$_->{id}}, @rel_cols;
- my $r = $self->dbReleaseGet(vid => $vid, what => join(' ', keys %what), sort => $f->{s}, reverse => $f->{o}, results => 200);
-
- # url generator
- my $url = sub {
- my %u = (%$f, @_);
- return "/v$vid/releases?".join(';', map "$_=$u{$_}", sort keys %u);
- };
-
- div class => 'mainbox releases_compare';
- h1 $title;
-
- if(!@$r) {
- td 'We don\'t have any information about releases of this visual novel yet...';
- } else {
- _releases_buttons($self, $f, $url, $r);
- }
- end 'div';
-
- _releases_table($self, $f, $url, $r) if @$r;
- $self->htmlFooter;
-}
-
-
-sub _releases_buttons {
- my($self, $f, $url, $r) = @_;
-
- # Column visibility
- p class => 'browseopts';
- a href => $url->($_->{id}, $f->{$_->{id}} ? 0 : 1), $f->{$_->{id}} ? (class => 'optselected') : (), $_->{button_string}
- for (grep $_->{button_string}, @rel_cols);
- end;
-
- # Misc options
- my $all_selected = !grep $_->{button_string} && !$f->{$_->{id}}, @rel_cols;
- my $all_unselected = !grep $_->{button_string} && $f->{$_->{id}}, @rel_cols;
- my $all_url = sub { $url->(map +($_->{id},$_[0]), grep $_->{button_string}, @rel_cols); };
- p class => 'browseopts';
- a href => $all_url->(1), $all_selected ? (class => 'optselected') : (), 'All on';
- a href => $all_url->(0), $all_unselected ? (class => 'optselected') : (), 'All off';
- a href => $url->('cw', $f->{cw} ? 0 : 1), $f->{cw} ? (class => 'optselected') : (), 'Restrict column width';
- end;
-
- # Platform/language filters
- my $plat_lang_draw = sub {
- my($row, $option, $txt, $csscat) = @_;
- my %opts = map +($_,1), map @{$_->{$row}}, @$r;
- return if !keys %opts;
- p class => 'browseopts';
- for('all', sort keys %opts) {
- a href => $url->($option, $_), $_ eq $f->{$option} ? (class => 'optselected') : ();
- $_ eq 'all' ? txt 'All' : cssicon "$csscat $_", $txt->{$_};
- end 'a';
- }
- end 'p';
- };
- $plat_lang_draw->('platforms', 'os', \%PLATFORM, '') if $f->{pla};
- $plat_lang_draw->('languages', 'lang',\%LANGUAGE, 'lang') if $f->{lan};
-}
-
-
-sub _releases_table {
- my($self, $f, $url, $r) = @_;
-
- # Apply language and platform filters
- my @r = grep +
- ($f->{os} eq 'all' || ($_->{platforms} && grep $_ eq $f->{os}, @{$_->{platforms}})) &&
- ($f->{lang} eq 'all' || ($_->{languages} && grep $_ eq $f->{lang}, @{$_->{languages}})), @$r;
-
- # Figure out which columns to display
- my @col;
- for my $c (@rel_cols) {
- next if $c->{button_string} && !$f->{$c->{id}}; # Hidden by settings
- push @col, $c if !@r || !$c->{has_data} || grep $c->{has_data}->($_), @r; # Must have relevant data
- }
-
- div class => 'mainbox releases_compare';
- table;
-
- thead;
- Tr;
- for my $c (@col) {
- td class => 'key';
- txt $c->{column_string} if $c->{column_string};
- for($c->{sort_field} ? (0,1) : ()) {
- my $active = $f->{s} eq $c->{sort_field} && !$f->{o} == !$_;
- a href => $url->(o => $_, s => $c->{sort_field}) if !$active;
- lit $_ ? "\x{25BE}" : "\x{25B4}";
- end 'a' if !$active;
- }
- end 'td';
- }
- end 'tr';
- end 'thead';
-
- for my $r (@r) {
- Tr;
- # Combine "N/A for patches" columns
- my $cspan = 1;
- for my $c (0..$#col) {
- if($r->{patch} && $col[$c]{na_for_patch} && $c < $#col && $col[$c+1]{na_for_patch}) {
- $cspan++;
- next;
- }
- td $cspan > 1 ? (colspan => $cspan) : (),
- $col[$c]{column_width} && $f->{cw} ? (style => "max-width: $col[$c]{column_width}px") : ();
- if($r->{patch} && $col[$c]{na_for_patch}) {
- txt 'NA for patches';
- } else {
- $col[$c]{draw}->($r);
- }
- end;
- $cspan = 1;
- }
- end;
- }
- end 'table';
- end 'div';
-}
-
-
-sub page {
- my($self, $vid, $rev) = @_;
-
- my $char = $rev && $rev eq 'chars';
- $rev = undef if $char;
-
- my $method = $rev ? 'dbVNGetRev' : 'dbVNGet';
- my $v = $self->$method(
- id => $vid,
- what => 'extended anime relations screenshots rating ranking staff'.($rev ? ' seiyuu' : ''),
- $rev ? (rev => $rev) : (),
- )->[0];
- return $self->resNotFound if !$v->{id};
-
- my $r = $self->dbReleaseGet(vid => $vid, what => 'extended links vns producers platforms media', results => 200);
-
- enrich_extlinks v => $v;
- enrich_extlinks r => $r;
-
- my $metadata = {
- 'og:title' => $v->{title},
- 'og:description' => bb2text $v->{desc},
- };
-
- if($v->{image} && !$v->{img_nsfw}) {
- $metadata->{'og:image'} = imgurl(cv => $v->{image});
- } elsif(my ($ss) = grep !$_->{nsfw}, @{$v->{screenshots}}) {
- $metadata->{'og:image'} = imgurl(st => $ss->{id});
- }
-
- $self->htmlHeader(title => $v->{title}, noindex => $rev, metadata => $metadata);
- $self->htmlMainTabs('v', $v);
- return if $self->htmlHiddenMessage('v', $v);
-
- _revision($self, $v, $rev);
-
- div class => 'mainbox';
- $self->htmlItemMessage('v', $v);
- h1 $v->{title};
- h2 class => 'alttitle', lang_attr($v->{c_olang}), $v->{original} if $v->{original};
-
- div class => 'vndetails';
-
- # image
- div class => 'vnimg';
- if(!$v->{image}) {
- p 'No image uploaded yet';
- } else {
- if($v->{img_nsfw}) {
- p class => 'nsfw_pic';
- input id => 'nsfw_chk', type => 'checkbox', class => 'visuallyhidden', $self->authPref('show_nsfw') ? (checked => 'checked') : ();
- label for => 'nsfw_chk';
- span id => 'nsfw_show';
- txt 'This image has been flagged as Not Safe For Work.';
- br; br;
- span class => 'fake_link', 'Show me anyway';
- br; br;
- txt '(This warning can be disabled in your account)';
- end;
- span id => 'nsfw_hid';
- img src => imgurl(cv => $v->{image}), alt => $v->{title};
- i 'Flagged as NSFW';
- end;
- end;
- end;
- } else {
- img src => imgurl(cv => $v->{image}), alt => $v->{title};
- }
- }
- end 'div'; # /vnimg
-
- # general info
- table class => 'stripe';
- Tr;
- td class => 'key', 'Title';
- td $v->{title};
- end;
- if($v->{original}) {
- Tr;
- td 'Original title';
- td lang_attr($v->{c_olang}), $v->{original};
- end;
- }
- if($v->{alias}) {
- $v->{alias} =~ s/\n/, /g;
- Tr;
- td 'Aliases';
- td $v->{alias};
- end;
- }
- if($v->{length}) {
- Tr;
- td 'Length';
- td fmtvnlen $v->{length}, 1;
- end;
- }
-
- _producers($self, $r);
- _relations($self, $v) if @{$v->{relations}};
-
- if($v->{extlinks}->@*) {
- Tr;
- td 'Links';
- td;
- for($v->{extlinks}->@*) {
- a href => $_->[1], $_->[0];
- txt ', ' if $_ ne $v->{extlinks}[$#{$v->{extlinks}}];
- }
- end;
- end;
- }
- _affiliate_links($self, $r);
-
- _anime($self, $v) if @{$v->{anime}};
-
- _useroptions($self, $v, $r) if $self->authInfo->{id};
-
- Tr class => 'nostripe';
- td class => 'vndesc', colspan => 2;
- h2 'Description';
- p;
- lit $v->{desc} ? bb2html $v->{desc} : '-';
- end;
- end;
- end;
-
- end 'table';
- end 'div';
- div class => 'clearfloat', style => 'height: 5px', ''; # otherwise the tabs below aren't positioned correctly
-
- # tags
- my $t = $self->dbTagStats(vid => $v->{id}, sort => 'rating', reverse => 1, minrating => 0, results => 999, state => 2);
- if(@$t) {
- div id => 'tagops';
- for (keys %TAG_CATEGORY) {
- input id => "cat_$_", type => 'checkbox', class => 'visuallyhidden',
- ($self->authInfo->{id} ? $self->authPref("tags_$_") : $_ ne 'ero') ? (checked => 'checked') : ();
- label for => "cat_$_", lc $TAG_CATEGORY{$_};
- }
- my $spoiler = $self->authPref('spoilers') || 0;
- input id => 'tag_spoil_none', type => 'radio', class => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 0 ? (checked => 'checked') : ();
- label for => 'tag_spoil_none', class => 'sec', lc 'Hide spoilers';
- input id => 'tag_spoil_some', type => 'radio', class => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 1 ? (checked => 'checked') : ();
- label for => 'tag_spoil_some', lc 'Show minor spoilers';
- input id => 'tag_spoil_all', type => 'radio', class => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 2 ? (checked => 'checked') : ();
- label for => 'tag_spoil_all', lc 'Spoil me!';
-
- input id => 'tag_toggle_summary', type => 'radio', class => 'visuallyhidden', name => 'tag_all', $self->authPref('tags_all') ? () : (checked => 'checked');
- label for => 'tag_toggle_summary', class => 'sec', lc 'summary';
- input id => 'tag_toggle_all', type => 'radio', class => 'visuallyhidden', name => 'tag_all', $self->authPref('tags_all') ? (checked => 'checked') : ();
- label for => 'tag_toggle_all', class => 'lst', lc 'all';
- div id => 'vntags';
- my %counts = ();
- for (@$t) {
- my $cnt0 = $counts{$_->{cat} . '0'} || 0;
- my $cnt1 = $counts{$_->{cat} . '1'} || 0;
- my $cnt2 = $counts{$_->{cat} . '2'} || 0;
- my $spoil = $_->{spoiler} > 1.3 ? 2 : $_->{spoiler} > 0.4 ? 1 : 0;
- SWITCH: {
- $counts{$_->{cat} . '2'} = ++$cnt2;
- if ($spoil == 2) { last SWITCH; }
- $counts{$_->{cat} . '1'} = ++$cnt1;
- if ($spoil == 1) { last SWITCH; }
- $counts{$_->{cat} . '0'} = ++$cnt0;
- }
- my $cut = $cnt0 > 15 ? ' cut cut2 cut1 cut0' : ($cnt1 > 15 ? ' cut cut2 cut1' : ($cnt2 > 15 ? ' cut cut2' : ''));
- span class => sprintf 'tagspl%d cat_%s%s', $spoil, $_->{cat}, $cut;
- a href => "/g$_->{id}", style => sprintf('font-size: %dpx', $_->{rating}*3.5+6), $_->{name};
- b class => 'grayedout', sprintf ' %.1f', $_->{rating};
- end;
- txt ' ';
- }
- end;
- end;
- }
- end 'div'; # /mainbox
-
- my $chars = $self->dbCharGet(vid => $v->{id}, what => "seiyuu vns($v->{id})".($char ? ' extended traits' : ''), results => 500);
- if(@$chars || $self->authCan('edit')) {
- clearfloat; # fix tabs placement when tags are hidden
- div class => 'maintabs';
- ul;
- if(@$chars) {
- li class => (!$char ? ' tabselected' : ''); a href => "/v$v->{id}#main", name => 'main', 'main'; end;
- li class => ($char ? ' tabselected' : ''); a href => "/v$v->{id}/chars#chars", name => 'chars', 'characters'; end;
- }
- end;
- ul;
- if($self->authCan('edit')) {
- li; a href => "/v$v->{id}/add", 'add release'; end;
- li; a href => "/c/new?vid=$v->{id}", 'add character'; end;
- }
- end;
- end;
- }
-
- if($char) {
- _chars($self, $chars, $v);
- } else {
- _releases($self, $v, $r);
- _staff($self, $v);
- _charsum($self, $chars, $v);
- _stats($self, $v);
- _screenshots($self, $v, $r) if @{$v->{screenshots}};
- }
-
- $self->htmlFooter(v2rwjs => $self->authInfo->{id});
-}
-
-
-sub _revision {
- my($self, $v, $rev) = @_;
- return if !$rev;
-
- my $prev = $rev && $rev > 1 && $self->dbVNGetRev(
- id => $v->{id}, rev => $rev-1, what => 'extended anime relations screenshots staff seiyuu'
- )->[0];
-
- $self->htmlRevision('v', $prev, $v,
- [ title => 'Title (romaji)', diff => 1 ],
- [ original => 'Original title', diff => 1 ],
- [ alias => 'Alias', diff => qr/[ ,\n\.]/ ],
- [ desc => 'Description', diff => qr/[ ,\n\.]/ ],
- [ length => 'Length', serialize => sub { fmtvnlen $_[0] } ],
- [ l_wp => 'Wikipedia link', htmlize => sub {
- $_[0] ? sprintf '<a href="http://en.wikipedia.org/wiki/%s">%1$s</a>', xml_escape $_[0] : '[empty]'
- }],
- [ l_wikidata => 'Wikidata ID', htmlize => sub { $_[0] ? sprintf '<a href="https://www.wikidata.org/wiki/Q%d">Q%1$d</a>', $_[0] : '[empty]' } ],
- [ l_encubed => 'Encubed tag', htmlize => sub {
- $_[0] ? sprintf '<a href="http://novelnews.net/tag/%s/">%1$s</a>', xml_escape $_[0] : '[empty]'
- }],
- [ l_renai => 'Renai.us link', htmlize => sub {
- $_[0] ? sprintf '<a href="https://renai.us/game/%s">%1$s</a>', xml_escape $_[0] : '[empty]'
- }],
- [ credits => 'Credits', join => '<br />', split => sub {
- my @r = map sprintf('<a href="/s%d" title="%s">%s</a> [%s]%s', $_->{id},
- xml_escape($_->{original}||$_->{name}), xml_escape($_->{name}), xml_escape($CREDIT_TYPE{$_->{role}}),
- $_->{note} ? ' ['.xml_escape($_->{note}).']' : ''),
- sort { $a->{id} <=> $b->{id} || $a->{role} cmp $b->{role} } @{$_[0]};
- return @r ? @r : ('[empty]');
- }],
- [ seiyuu => 'Seiyuu', join => '<br />', split => sub {
- my @r = map sprintf('<a href="/s%d" title="%s">%s</a> as <a href="/c%d">%s</a>%s',
- $_->{id}, xml_escape($_->{original}||$_->{name}), xml_escape($_->{name}), $_->{cid}, xml_escape($_->{cname}),
- $_->{note} ? ' ['.xml_escape($_->{note}).']' : ''),
- sort { $a->{id} <=> $b->{id} || $a->{cid} <=> $b->{cid} || $a->{note} cmp $b->{note} } @{$_[0]};
- return @r ? @r : ('[empty]');
- }],
- [ relations => 'Relations', join => '<br />', split => sub {
- my @r = map sprintf('[%s] %s: <a href="/v%d" title="%s">%s</a>',
- $_->{official} ? 'official' : 'unofficial', $VN_RELATION{$_->{relation}}{txt},
- $_->{id}, xml_escape($_->{original}||$_->{title}), xml_escape shorten $_->{title}, 40
- ), sort { $a->{id} <=> $b->{id} } @{$_[0]};
- return @r ? @r : ('[empty]');
- }],
- [ anime => 'Anime', join => ', ', split => sub {
- my @r = map sprintf('<a href="http://anidb.net/a%d">a%1$d</a>', $_->{id}), sort { $a->{id} <=> $b->{id} } @{$_[0]};
- return @r ? @r : ('[empty]');
- }],
- [ screenshots => 'Screenshots', join => '<br />', split => sub {
- my @r = map sprintf('[%s] <a href="%s" data-iv="%dx%d">%d</a> (%s)',
- $_->{rid} ? qq|<a href="/r$_->{rid}">r$_->{rid}</a>| : 'no release',
- imgurl(sf => $_->{id}), $_->{width}, $_->{height}, $_->{id},
- $_->{nsfw} ? 'Not safe' : 'Safe'
- ), @{$_[0]};
- return @r ? @r : ('[empty]');
- }],
- [ image => 'Image', htmlize => sub {
- my $url = imgurl(cv => $_[0]);
- if($_[0]) {
- return $_[1]->{img_nsfw} && !$self->authPref('show_nsfw') ? "<a href=\"$url\">(NSFW)</a>" : "<img src=\"$url\" />";
- } else {
- return 'No image';
- }
- }],
- [ img_nsfw => 'Image NSFW', serialize => sub { $_[0] ? 'Not safe' : 'Safe' } ],
- );
-}
-
-
-sub _producers {
- my($self, $r) = @_;
-
- my %lang;
- my @lang = grep !$lang{$_}++, map @{$_->{languages}}, @$r;
-
- if(grep $_->{developer}, map @{$_->{producers}}, @$r) {
- my %dev = map $_->{developer} ? ($_->{id} => $_) : (), map @{$_->{producers}}, @$r;
- my @dev = sort { $a->{name} cmp $b->{name} } values %dev;
- Tr;
- td 'Developer';
- td;
- for (@dev) {
- a href => "/p$_->{id}", title => $_->{original}||$_->{name}, shorten $_->{name}, 30;
- txt ' & ' if $_ != $dev[$#dev];
- }
- end;
- end;
- }
-
- if(grep $_->{publisher}, map @{$_->{producers}}, @$r) {
- Tr;
- td 'Publishers';
- td;
- for my $l (@lang) {
- my %p = map $_->{publisher} ? ($_->{id} => $_) : (), map @{$_->{producers}}, grep grep($_ eq $l, @{$_->{languages}}), @$r;
- my @p = sort { $a->{name} cmp $b->{name} } values %p;
- next if !@p;
- cssicon "lang $l", $LANGUAGE{$l};
- for (@p) {
- a href => "/p$_->{id}", title => $_->{original}||$_->{name}, shorten $_->{name}, 30;
- txt ' & ' if $_ != $p[$#p];
- }
- br;
- }
- end;
- end 'tr';
- }
-}
-
-
-sub _relations {
- my($self, $v) = @_;
-
- my %rel;
- push @{$rel{$_->{relation}}}, $_
- for (sort { $a->{title} cmp $b->{title} } @{$v->{relations}});
-
-
- Tr;
- td 'Relations';
- td class => 'relations';
- dl;
- for(sort keys %rel) {
- dt $VN_RELATION{$_}{txt};
- dd;
- for (@{$rel{$_}}) {
- b class => 'grayedout', '[unofficial] ' if !$_->{official};
- a href => "/v$_->{id}", title => $_->{original}||$_->{title}, shorten $_->{title}, 40;
- br;
- }
- end;
- }
- end;
- end;
- end 'tr';
-}
-
-
-sub _anime {
- my($self, $v) = @_;
-
- Tr;
- td 'Related anime';
- td class => 'anime';
- for (sort { ($a->{year}||9999) <=> ($b->{year}||9999) } @{$v->{anime}}) {
- if(!$_->{lastfetch} || !$_->{year} || !$_->{title_romaji}) {
- b;
- lit sprintf '[no information available at this time: <a href="http://anidb.net/a%d">%1$d</a>]', $_->{id};
- end;
- } else {
- b;
- txt '[';
- a href => "http://anidb.net/a$_->{id}", title => 'AniDB', 'DB';
- # AnimeNFO links seem to be broken at the moment. TODO: Completely remove?
- #if($_->{nfo_id}) {
- # txt '-';
- # a href => "http://animenfo.com/animetitle,$_->{nfo_id},a.html", title => 'AnimeNFO', 'NFO';
- #}
- if($_->{ann_id}) {
- txt '-';
- a href => "http://www.animenewsnetwork.com/encyclopedia/anime.php?id=$_->{ann_id}", title => 'Anime News Network', 'ANN';
- }
- txt '] ';
- end;
- abbr title => $_->{title_kanji}||$_->{title_romaji}, shorten $_->{title_romaji}, 50;
- b ' ('.(defined $_->{type} ? $ANIME_TYPE{$_->{type}}{txt}.', ' : '').$_->{year}.')';
- br;
- }
- }
- end;
- end 'tr';
-}
-
-
-sub _useroptions {
- my($self, $v, $r) = @_;
-
- # Voting option is hidden if nothing has been released yet
- my $minreleased = min grep $_, map $_->{released}, @$r;
-
- my $labels = tuwf->dbAlli(
- 'SELECT l.id, l.label, l.private, uvl.vid IS NOT NULL as assigned
- FROM ulist_labels l
- LEFT JOIN ulist_vns_labels uvl ON uvl.uid = l.uid AND uvl.lbl = l.id AND uvl.vid =', \$v->{id}, '
- WHERE l.uid =', \$self->authInfo->{id}, '
- ORDER BY CASE WHEN l.id < 10 THEN l.id ELSE 10 END, l.label'
- );
- my $lst = tuwf->dbRowi('SELECT vid, vote FROM ulist_vns WHERE uid =', \$self->authInfo->{id}, 'AND vid =', \$v->{id});
-
- Tr class => 'nostripe';
- td colspan => 2;
- VNWeb::HTML::elm_('UList.VNPage', undef, {
- uid => 1*$self->authInfo->{id},
- vid => 1*$v->{id},
- onlist => $lst->{vid}?\1:\0,
- canvote => $minreleased && $minreleased < strftime('%Y%m%d', gmtime) ? \1 : \0,
- vote => fmtvote($lst->{vote}).'',
- labels => [ map +{ id => 1*$_->{id}, label => $_->{label}, private => $_->{private}?\1:\0 }, @$labels ],
- selected => [ map $_->{id}, grep $_->{assigned}, @$labels ],
- });
- end;
- end;
-}
-
-
-sub _affiliate_links {
- my($self, $r) = @_;
-
- # If the same shop link has been added to multiple releases, use the 'first' matching type in this list.
- my @type = ('bundle', '', 'partial', 'trial', 'patch');
-
- # url => [$title, $url, $price, $type]
- my %links;
- for my $rel (@$r) {
- my $type = $rel->{patch} ? 4 :
- $rel->{type} eq 'trial' ? 3 :
- $rel->{type} eq 'partial' ? 2 :
- @{$rel->{vn}} > 1 ? 0 : 1;
-
- for my $l (grep $_->[2], $rel->{extlinks}->@*) {
- $links{$l->[1]} = [ @$l, min $type, $links{$l->[1]}[3]||9 ];
- }
- }
- return if !keys %links;
-
- use utf8;
- Tr id => 'buynow';
- td 'Shops';
- td;
- for my $l (sort { $a->[0] cmp $b->[0] || $a->[2] cmp $b->[2] } values %links) {
- b class => 'standout', '» ';
- a href => $l->[1];
- txt $l->[2];
- b class => 'grayedout', " @ ";
- txt $l->[0];
- b class => 'grayedout', " ($type[$l->[3]])" if $l->[3] != 1;
- end;
- br;
- }
- end;
- end;
-}
-
-
-sub _releases {
- my($self, $v, $r) = @_;
-
- div class => 'mainbox releases';
- h1 'Releases';
- if(!@$r) {
- p 'We don\'t have any information about releases of this visual novel yet...';
- end;
- return;
- }
-
- if($self->authInfo->{id}) {
- my $l = $self->dbRListGet(uid => $self->authInfo->{id}, rid => [map $_->{id}, @$r]);
- for my $i (@$l) {
- [grep $i->{rid} == $_->{id}, @$r]->[0]{ulist} = $i;
- }
- div id => 'vnrlist_code', class => 'hidden', $self->authGetCode('/xml/rlist.xml');
- }
-
- my %lang;
- my @lang = grep !$lang{$_}++, map @{$_->{languages}}, @$r;
-
- table;
- for my $l (@lang) {
- Tr class => 'lang';
- td colspan => 7;
- cssicon "lang $l", $LANGUAGE{$l};
- txt $LANGUAGE{$l};
- end;
- end;
- for my $rel (grep grep($_ eq $l, @{$_->{languages}}), @$r) {
- Tr;
- td class => 'tc1'; lit fmtdatestr $rel->{released}; end;
- td class => 'tc2', $rel->{minage} < 0 ? '' : minage $rel->{minage};
- td class => 'tc3';
- for (sort @{$rel->{platforms}}) {
- next if $_ eq 'oth';
- cssicon $_, $PLATFORM{$_};
- }
- cssicon "rt$rel->{type}", $rel->{type};
- end;
- td class => 'tc4';
- a href => "/r$rel->{id}", title => $rel->{original}||$rel->{title}, $rel->{title};
- b class => 'grayedout', ' (patch)' if $rel->{patch};
- end;
-
- td class => 'tc_icons';
- _release_icons($self, $rel);
- end;
-
- td class => 'tc5';
- if($self->authInfo->{id}) {
- a href => "/r$rel->{id}", id => "rlsel_$rel->{id}", class => 'vnrlsel',
- $rel->{ulist} ? $RLIST_STATUS{ $rel->{ulist}{status} } : '--';
- } else {
- txt ' ';
- }
- end;
- td class => 'tc6';
- $self->releaseExtLinks($rel);
- end;
- end 'tr';
- }
- }
- end 'table';
- end 'div';
-}
-
-
-# Creates an small sized img inside an abbr tag. Used for per-release information icons.
-sub _release_icon {
- my($class, $title, $img) = @_;
- abbr class => "release_icons_container release_icon_$class", title => $title;
- img src=> "$TUWF::OBJ->{url_static}/f/$img.svg", class => "release_icons", alt => $title;
- end;
-}
-
-sub _release_icons {
- my($self, $rel) = @_;
-
- # Voice column
- my $voice = $rel->{voiced};
- _release_icon $VOICED{$voice}{icon}, $VOICED{$voice}{txt}, 'voiced' if $voice;
-
- # Animations columns
- my $story_anim = $rel->{ani_story};
- _release_icon $ANIMATED{$story_anim}{story_icon}, "Story: $ANIMATED{$story_anim}{txt}", 'story_animated' if $story_anim;
-
- my $ero_anim = $rel->{ani_ero};
- _release_icon $ANIMATED{$ero_anim}{ero_icon}, "Ero: $ANIMATED{$ero_anim}{txt}", 'ero_animated' if $ero_anim;
-
- # Cost column
- _release_icon 'freeware', 'Freeware', 'free' if $rel->{freeware};
- _release_icon 'nonfree', 'Non-free', 'nonfree' unless $rel->{freeware};
-
- # Publisher type column
- if(!$rel->{patch}) {
- _release_icon 'doujin', 'Doujin', 'doujin' if $rel->{doujin};
- _release_icon 'commercial', 'Commercial', 'commercial' unless $rel->{doujin};
- }
-
- # Resolution column
- my $resolution = $rel->{resolution};
- if($resolution ne 'unknown') {
- my $resolution_type = $resolution eq 'nonstandard' ? 'custom' : $RESOLUTION{$resolution}{cat} eq 'widescreen' ? '16-9' : '4-3';
- # Ugly workaround: PC-98 has non-square pixels, thus not widescreen
- $resolution_type = '4-3' if $resolution_type eq '16-9' && grep $_ eq 'p98', @{$rel->{platforms}};
- _release_icon "res$resolution_type", $RESOLUTION{$resolution}{txt}, "resolution_$resolution_type";
- }
-
- # Media column
- if(@{$rel->{media}}) {
- my $icon = $MEDIUM{ $rel->{media}[0]{medium} }{icon};
- my $media_detail = join ', ', map fmtmedia($_->{medium}, $_->{qty}), @{$rel->{media}};
- _release_icon $icon, $media_detail, $icon;
- }
-
- _release_icon 'uncensor', 'Uncensored', 'uncensor' if $rel->{uncensored};
-
- # Notes column
- _release_icon 'notes', bb2text($rel->{notes}), 'notes' if $rel->{notes};
-}
-
-
-sub _screenshots {
- my($self, $v, $r) = @_;
-
- input id => 'nsfwhide_chk', type => 'checkbox', class => 'visuallyhidden', $self->authPref('show_nsfw') ? (checked => 'checked') : ();
- div class => 'mainbox', id => 'screenshots';
-
- if(grep $_->{nsfw}, @{$v->{screenshots}}) {
- p class => 'nsfwtoggle';
- txt 'Showing ';
- i id => 'nsfwshown', scalar grep(!$_->{nsfw}, @{$v->{screenshots}});
- span class => 'nsfw', scalar @{$v->{screenshots}};
- txt sprintf ' out of %d screenshot%s. ', scalar @{$v->{screenshots}}, @{$v->{screenshots}} == 1 ? '' : 's';
- label for => 'nsfwhide_chk', class => 'fake_link', 'show/hide NSFW';
- end;
- }
-
- h1 'Screenshots';
-
- for my $rel (@$r) {
- my @scr = grep $_->{rid} && $rel->{id} == $_->{rid}, @{$v->{screenshots}};
- next if !@scr;
- p class => 'rel';
- cssicon "lang $_", $LANGUAGE{$_} for (@{$rel->{languages}});
- cssicon $_, $PLATFORM{$_} for (@{$rel->{platforms}});
- a href => "/r$rel->{id}", $rel->{title};
- end;
- div class => 'scr';
- for (@scr) {
- my($w, $h) = imgsize($_->{width}, $_->{height}, @{$self->{scr_size}});
- a href => imgurl(sf => $_->{id}),
- class => sprintf('scrlnk%s', $_->{nsfw} ? ' nsfw':''),
- 'data-iv' => "$_->{width}x$_->{height}:scr";
- img src => imgurl(st => $_->{id}),
- width => $w, height => $h, alt => "Screenshot #$_->{id}";
- end;
- }
- end;
- }
- end 'div';
-}
-
-
-sub _stats {
- my($self, $v) = @_;
-
- my $stats = $self->dbVoteStats(vid => $v->{id}, 1);
- div class => 'mainbox';
- h1 'User stats';
- if(!grep $_->[0] > 0, @$stats) {
- p 'Nobody has voted on this visual novel yet...';
- } else {
- $self->htmlVoteStats(v => $v, $stats);
- }
- end;
-}
-
-
-sub _charspoillvl {
- my($vid, $c) = @_;
- my $minspoil = 5;
- $minspoil = $_->{vid} == $vid && $_->{spoil} < $minspoil ? $_->{spoil} : $minspoil
- for(@{$c->{vns}});
- return $minspoil;
-}
-
-
-sub _chars {
- my($self, $l, $v) = @_;
- return if !@$l;
- my %done;
- my %rol;
- for my $r (keys %CHAR_ROLE) {
- $rol{$r} = [ grep grep($_->{role} eq $r, @{$_->{vns}}) && !$done{$_->{id}}++, @$l ];
- }
- div class => 'charops', id => 'charops';
- $self->charOps(1, 'chars');
- for my $r (keys %CHAR_ROLE) {
- next if !@{$rol{$r}};
- div class => 'mainbox';
- h1 $CHAR_ROLE{$r}{ @{$rol{$r}} > 1 ? 'plural' : 'txt' };
- $self->charTable($_, 1, $_ != $rol{$r}[0], 1, _charspoillvl $v->{id}, $_) for (@{$rol{$r}});
- end;
- }
- end;
-}
-
-
-sub _charsum {
- my($self, $l, $v) = @_;
- return if !@$l;
-
- my(@l, %done, $has_spoilers);
- for my $r (keys %CHAR_ROLE) {
- last if $r eq 'appears';
- for (grep grep($_->{role} eq $r, @{$_->{vns}}) && !$done{$_->{id}}++, @$l) {
- $_->{role} = $r;
- $has_spoilers = $has_spoilers || _charspoillvl $v->{id}, $_;
- push @l, $_;
- }
- }
-
- div class => 'mainbox charsum summarize charops', 'data-summarize-height' => 200, id => 'charops';
- $self->charOps(0, 'charsum') if $has_spoilers;
- h1 'Character summary';
- div class => 'charsum_list';
- for my $c (@l) {
- div class => 'charsum_bubble'.($has_spoilers ? ' '.charspoil(_charspoillvl $v->{id}, $c) : '');
- div class => 'name';
- i $CHAR_ROLE{$c->{role}}{txt};
- cssicon "gen $c->{gender}", $GENDER{$c->{gender}} if $c->{gender} ne 'unknown';
- a href => "/c$c->{id}", title => $c->{original}||$c->{name}, $c->{name};
- end;
- if(@{$c->{seiyuu}}) {
- div class => 'actor';
- txt 'Voiced by';
- @{$c->{seiyuu}} > 1 ? br : txt ' ';
- for my $s (sort { $a->{name} cmp $b->{name} } @{$c->{seiyuu}}) {
- a href => "/s$s->{sid}", title => $s->{original}||$s->{name}, $s->{name};
- b class => 'grayedout', $s->{note} if $s->{note};
- br;
- }
- end;
- }
- end;
- }
- end;
- end;
-}
-
-
-sub _staff {
- my ($self, $v) = @_;
- return if !@{$v->{credits}};
-
- div class => 'mainbox staff summarize', 'data-summarize-height' => 200, id => 'staff';
- h1 'Staff';
- for my $r (keys %CREDIT_TYPE) {
- my @s = grep $_->{role} eq $r, @{$v->{credits}};
- next if !@s;
- ul;
- li; b $CREDIT_TYPE{$r}; end;
- for(@s) {
- li;
- a href => "/s$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
- b class => 'grayedout', $_->{note} if $_->{note};
- end;
- }
- end;
- }
- clearfloat;
- end;
-}
-
-1;
-
diff --git a/lib/VNDB/Schema.pm b/lib/VNDB/Schema.pm
index b6e476b6..ffc80e77 100644
--- a/lib/VNDB/Schema.pm
+++ b/lib/VNDB/Schema.pm
@@ -1,4 +1,4 @@
-# Utility functions to parse the files in util/sql/ and extract information and
+# Utility functions to parse the files in sql/ and extract information and
# perform a few simple sanity checks.
#
# This is not a full-blown SQL parser. The code makes all kinds of assumptions
@@ -23,54 +23,54 @@ my $ROOT = $INC{'VNDB/Schema.pm'} =~ s{/lib/VNDB/Schema\.pm}{}r;
# type => 'serial',
# decl => 'id SERIAL', # full declaration, exluding comments and PRIMARY KEY marker
# pub => 1,
+# comment => '',
# }, ...
# ],
# primary => ['id'],
+# comment => '',
# }
# }
sub schema {
my %schema;
my $table;
- open my $F, '<', "$ROOT/util/sql/schema.sql" or die "schema.sql: $!";
+ open my $F, '<', "$ROOT/sql/schema.sql" or die "schema.sql: $!";
while(<$F>) {
chomp;
next if /^\s*--/ || /^\s*$/;
- next if /^\s*CREATE\s+TYPE/;
- next if /^\s*CREATE\s+SEQUENCE/;
+ next if /^\s*CREATE\s+(?:TYPE|SEQUENCE|FUNCTION|DOMAIN|VIEW)/;
if(/^\s*CREATE\s+TABLE\s+([^ ]+)/) {
die "Unexpected 'CREATE TABLE $1'\n" if $table;
+ next if /PARTITION OF/;
$table = $1;
$schema{$table}{name} = $table;
- $schema{$table}{dbentry_type} = $1 if /--.*\s+dbentry_type=(.)/;
+ $schema{$table}{comment} = /--\s*(.*)\s*/ ? $1 : '';
+ $schema{$table}{dbentry_type} = $1 if $schema{$table}{comment} =~ s/\s*dbentry_type=(.)\s*//;
$schema{$table}{cols} = [];
- } elsif(/^\s*\);/) {
+ } elsif(/^\s*\)(?: PARTITION .+)?;/) {
$table = undef;
- } elsif(/^\s+CHECK/) {
+ } elsif(/^\s+(?:CHECK|CONSTRAINT)/) {
# ignore
} elsif($table && /^\s+PRIMARY\s+KEY\s*\(([^\)]+)\)/i) {
die "Double primary key for '$table'?\n" if $schema{$table}{primary};
$schema{$table}{primary} = [ map s/\s*"?([^\s"]+)"?\s*/$1/r, split /,/, $1 ];
- } elsif($table && s/^\s+"?([^"\( ]+)"?\s+//) {
+ } elsif($table && s/^\s+([^"\( ]+)\s+//) {
my $col = { name => $1 };
push @{$schema{$table}{cols}}, $col;
- $col->{pub} = /--.*\[pub\]/;
- s/,?\s*(?:--.*)?$//;
+ $col->{comment} = (s/,?\s*(?:--(.*))?$// && $1) || '';
+ $col->{pub} = $col->{comment} =~ s/\s*\[pub\]\s*//;
if(s/\s+PRIMARY\s+KEY//i) {
die "Double primary key for '$table'?\n" if $schema{$table}{primary};
$schema{$table}{primary} = [ $col->{name} ];
}
- $col->{decl} = "\"$col->{name}\" $_";
+ $col->{decl} = "$col->{name} $_";
$col->{type} = lc s/^([^ ]+)\s.+/$1/r;
-
- } else {
- die "Unrecognized line in schema.sql: $_\n";
}
}
@@ -86,10 +86,10 @@ sub schema {
# }
sub types {
my %types;
- open my $F, '<', "$ROOT/util/sql/schema.sql" or die "schema.sql: $!";
+ open my $F, '<', "$ROOT/sql/schema.sql" or die "schema.sql: $!";
while(<$F>) {
chomp;
- if(/^CREATE TYPE ([^ ]+)/) {
+ if(/^CREATE (?:TYPE|DOMAIN) ([^ ]+)/) {
$types{$1} = { decl => $_ };
}
}
@@ -110,7 +110,7 @@ sub types {
# ]
sub references {
my @ref;
- open my $F, '<', "$ROOT/util/sql/tableattrs.sql" or die "tableattrs.sql: $!";
+ open my $F, '<', "$ROOT/sql/tableattrs.sql" or die "tableattrs.sql: $!";
while(<$F>) {
chomp;
next if !/^\s*ALTER\s+TABLE\s+([^ ]+)\s+ADD\s+CONSTRAINT\s+([^ ]+)\s+FOREIGN\s+KEY\s+\(([^\)]+)\)\s*REFERENCES\s+([^ ]+)\s*\(([^\)]+)\)/;
@@ -118,9 +118,9 @@ sub references {
decl => $_,
from_table => $1,
name => $2,
- from_cols => [ map s/"//r, split /\s*,\s*/, $3 ],
+ from_cols => [ split /\s*,\s*/, $3 ],
to_table => $4,
- to_cols => [ map s/"//r, split /\s*,\s*/, $5 ]
+ to_cols => [ split /\s*,\s*/, $5 ]
};
}
\@ref
diff --git a/lib/VNDB/Skins.pm b/lib/VNDB/Skins.pm
new file mode 100644
index 00000000..d53eec5b
--- /dev/null
+++ b/lib/VNDB/Skins.pm
@@ -0,0 +1,27 @@
+package VNDB::Skins;
+
+use v5.26;
+use warnings;
+use Exporter 'import';
+our @EXPORT = ('skins');
+
+my $ROOT = $INC{'VNDB/Skins.pm'} =~ s{/lib/VNDB/Skins\.pm$}{}r;
+
+my $skins;
+
+sub skins {
+ $skins ||= do { +{ map {
+ my $skin = /\/([^\/]+)\.sass/ ? $1 : die;
+ my %o;
+ open my $F, '<:utf8', $_ or die $!;
+ if(<$F> !~ qr{^// *userid: *(u[0-9]+) *name: *(.+)}) {
+ warn "Invalid skin: $skin\n";
+ ()
+ } else {
+ +( $skin, { userid => $1, name => $2 })
+ }
+ } glob "$ROOT/css/skins/*.sass" } };
+ $skins;
+}
+
+1;
diff --git a/lib/VNDB/Types.pm b/lib/VNDB/Types.pm
index 3341343d..16f730c5 100644
--- a/lib/VNDB/Types.pm
+++ b/lib/VNDB/Types.pm
@@ -15,47 +15,61 @@ sub hash {
# SQL: ENUM language
+# 'latin' indicates whether the language is primarily written in a latin-ish script.
+# 'rank' is for quick selection of commonly used languages.
hash LANGUAGE =>
- ar => 'Arabic',
- bg => 'Bulgarian',
- ca => 'Catalan',
- cs => 'Czech',
- da => 'Danish',
- de => 'German',
- el => 'Greek',
- en => 'English',
- eo => 'Esperanto',
- es => 'Spanish',
- fi => 'Finnish',
- fr => 'French',
- gd => 'Scottish Gaelic',
- he => 'Hebrew',
- hr => 'Croatian',
- hu => 'Hungarian',
- id => 'Indonesian',
- it => 'Italian',
- ja => 'Japanese',
- ko => 'Korean',
- mk => 'Macedonian',
- ms => 'Malay',
- lt => 'Lithuanian',
- lv => 'Latvian',
- nl => 'Dutch',
- no => 'Norwegian',
- pl => 'Polish',
- 'pt-br' => 'Portuguese (Brazil)',
- 'pt-pt' => 'Portuguese (Portugal)',
- ro => 'Romanian',
- ru => 'Russian',
- sk => 'Slovak',
- sl => 'Slovene',
- sv => 'Swedish',
- ta => 'Tagalog',
- th => 'Thai',
- tr => 'Turkish',
- uk => 'Ukrainian',
- vi => 'Vietnamese',
- zh => 'Chinese';
+ ar => { latin => 0, rank => 0, txt => 'Arabic' },
+ eu => { latin => 1, rank => 0, txt => 'Basque' },
+ be => { latin => 0, rank => 0, txt => 'Belarusian' },
+ bg => { latin => 1, rank => 0, txt => 'Bulgarian' },
+ ca => { latin => 1, rank => 0, txt => 'Catalan' },
+ ck => { latin => 0, rank => 0, txt => 'Cherokee' }, # 'chr' in ISO 639-2 but not present in ISO 639-1, let's just use an unassigned code
+ zh => { latin => 0, rank => 2, txt => 'Chinese' },
+ 'zh-Hans'=> { latin => 0, rank => 2, txt => 'Chinese (simplified)' },
+ 'zh-Hant'=> { latin => 0, rank => 2, txt => 'Chinese (traditional)' },
+ hr => { latin => 1, rank => 0, txt => 'Croatian' },
+ cs => { latin => 1, rank => 0, txt => 'Czech' },
+ da => { latin => 1, rank => 0, txt => 'Danish' },
+ nl => { latin => 1, rank => 0, txt => 'Dutch' },
+ en => { latin => 1, rank => 3, txt => 'English' },
+ eo => { latin => 1, rank => 0, txt => 'Esperanto' },
+ fi => { latin => 1, rank => 0, txt => 'Finnish' },
+ fr => { latin => 1, rank => 1, txt => 'French' },
+ de => { latin => 1, rank => 1, txt => 'German' },
+ el => { latin => 0, rank => 0, txt => 'Greek' },
+ he => { latin => 0, rank => 0, txt => 'Hebrew' },
+ hi => { latin => 0, rank => 0, txt => 'Hindi' },
+ hu => { latin => 1, rank => 0, txt => 'Hungarian' },
+ ga => { latin => 1, rank => 0, txt => 'Irish' },
+ id => { latin => 1, rank => 0, txt => 'Indonesian' },
+ it => { latin => 1, rank => 0, txt => 'Italian' },
+ iu => { latin => 1, rank => 0, txt => 'Inuktitut' },
+ ja => { latin => 0, rank => 4, txt => 'Japanese' },
+ ko => { latin => 0, rank => 1, txt => 'Korean' },
+ la => { latin => 1, rank => 0, txt => 'Latin' },
+ lv => { latin => 1, rank => 0, txt => 'Latvian' },
+ lt => { latin => 1, rank => 0, txt => 'Lithuanian' },
+ mk => { latin => 1, rank => 0, txt => 'Macedonian' },
+ ms => { latin => 1, rank => 0, txt => 'Malay' },
+ no => { latin => 1, rank => 0, txt => 'Norwegian' },
+ fa => { latin => 0, rank => 0, txt => 'Persian' },
+ pl => { latin => 1, rank => 0, txt => 'Polish' },
+ 'pt-br' => { latin => 1, rank => 1, txt => 'Portuguese (Brazil)' },
+ 'pt-pt' => { latin => 1, rank => 1, txt => 'Portuguese (Portugal)' },
+ ro => { latin => 1, rank => 0, txt => 'Romanian' },
+ ru => { latin => 0, rank => 2, txt => 'Russian' },
+ gd => { latin => 1, rank => 0, txt => 'Scottish Gaelic' },
+ sr => { latin => 1, rank => 0, txt => 'Serbian' },
+ sk => { latin => 0, rank => 0, txt => 'Slovak' },
+ sl => { latin => 1, rank => 0, txt => 'Slovene' },
+ es => { latin => 1, rank => 1, txt => 'Spanish' },
+ sv => { latin => 1, rank => 0, txt => 'Swedish' },
+ ta => { latin => 1, rank => 0, txt => 'Tagalog' },
+ th => { latin => 0, rank => 0, txt => 'Thai' },
+ tr => { latin => 1, rank => 0, txt => 'Turkish' },
+ uk => { latin => 0, rank => 1, txt => 'Ukrainian' },
+ ur => { latin => 0, rank => 0, txt => 'Urdu' },
+ vi => { latin => 1, rank => 1, txt => 'Vietnamese' };
@@ -63,19 +77,29 @@ hash LANGUAGE =>
# The 'unk' platform is used to mean "Unknown" in various places (not in the DB).
hash PLATFORM =>
win => 'Windows',
- dos => 'DOS',
lin => 'Linux',
mac => 'Mac OS',
+ web => 'Website',
+ tdo => '3DO',
ios => 'Apple iProduct',
and => 'Android',
- dvd => 'DVD Player',
bdp => 'Blu-ray Player',
+ dos => 'DOS',
+ dvd => 'DVD Player',
+ drc => 'Dreamcast',
+ nes => 'Famicom',
+ sfc => 'Super Famicom',
+ fm7 => 'FM-7',
+ fm8 => 'FM-8',
fmt => 'FM Towns',
gba => 'Game Boy Advance',
gbc => 'Game Boy Color',
msx => 'MSX',
nds => 'Nintendo DS',
- nes => 'Famicom',
+ swi => 'Nintendo Switch',
+ wii => 'Nintendo Wii',
+ wiu => 'Nintendo Wii U',
+ n3d => 'Nintendo 3DS',
p88 => 'PC-88',
p98 => 'PC-98',
pce => 'PC Engine',
@@ -85,48 +109,65 @@ hash PLATFORM =>
ps2 => 'PlayStation 2',
ps3 => 'PlayStation 3',
ps4 => 'PlayStation 4',
+ ps5 => 'PlayStation 5',
psv => 'PlayStation Vita',
- drc => 'Dreamcast',
+ smd => 'Sega Mega Drive',
+ scd => 'Sega Mega-CD',
sat => 'Sega Saturn',
- sfc => 'Super Nintendo',
- swi => 'Nintendo Switch',
- wii => 'Nintendo Wii',
- wiu => 'Nintendo Wii U',
- n3d => 'Nintendo 3DS',
- x68 => 'X68000',
+ vnd => 'VNDS',
+ x1s => 'Sharp X1',
+ x68 => 'Sharp X68000',
xb1 => 'Xbox',
xb3 => 'Xbox 360',
xbo => 'Xbox One',
- web => 'Website',
+ xxs => 'Xbox X/S',
+ mob => 'Other (mobile)',
oth => 'Other';
# SQL: ENUM vn_relation
hash VN_RELATION =>
- seq => { reverse => 'preq', txt => 'Sequel' },
- preq => { reverse => 'seq', txt => 'Prequel' },
- set => { reverse => 'set', txt => 'Same setting' },
- alt => { reverse => 'alt', txt => 'Alternative version' },
- char => { reverse => 'char', txt => 'Shares characters' },
- side => { reverse => 'par', txt => 'Side story' },
- par => { reverse => 'side', txt => 'Parent story' },
- ser => { reverse => 'ser', txt => 'Same series' },
- fan => { reverse => 'orig', txt => 'Fandisc' },
- orig => { reverse => 'fan', txt => 'Original game' };
-
+ seq => { reverse => 'preq', pref => 1, txt => 'Sequel' },
+ preq => { reverse => 'seq', pref => 0, txt => 'Prequel' },
+ set => { reverse => 'set', pref => 0, txt => 'Same setting' },
+ alt => { reverse => 'alt', pref => 0, txt => 'Alternative version' },
+ char => { reverse => 'char', pref => 0, txt => 'Shares characters' },
+ side => { reverse => 'par', pref => 1, txt => 'Side story' },
+ par => { reverse => 'side', pref => 0, txt => 'Parent story' },
+ ser => { reverse => 'ser', pref => 0, txt => 'Same series' },
+ fan => { reverse => 'orig', pref => 1, txt => 'Fandisc' },
+ orig => { reverse => 'fan', pref => 0, txt => 'Original game' };
+
+
+hash DEVSTATUS =>
+ 0 => 'Finished',
+ 1 => 'In development',
+ 2 => 'Cancelled';
+
+
+hash DRM_PROPERTY => # No DRM: https://lucide.dev/icons/unlock (needs circle?)
+ disc => 'Disc check', # https://lucide.dev/icons/disc-3
+ cdkey => 'CD-key', # https://lucide.dev/icons/key-round (needs circle?)
+ activate => 'Online activation', # https://lucide.dev/icons/wifi (needs circle?)
+ alimit => 'Activation limit',
+ account => 'Account-based', # https://lucide.dev/icons/link (needs circle?)
+ online => 'Always online',
+ cloud => 'Cloud gaming',
+ physical => 'Physical'; # XXX: How does this relate to cdkey?
# SQL: ENUM producer_relation
+# "Pref" relations are considered the "preferred" relation to show (as opposed to their reverse)
hash PRODUCER_RELATION =>
- old => { reverse => 'new', txt => 'Formerly' },
- new => { reverse => 'old', txt => 'Succeeded by' },
- spa => { reverse => 'ori', txt => 'Spawned' },
- ori => { reverse => 'spa', txt => 'Originated from' },
- sub => { reverse => 'par', txt => 'Subsidiary' },
- par => { reverse => 'sub', txt => 'Parent producer' },
- imp => { reverse => 'ipa', txt => 'Imprint' },
- ipa => { reverse => 'imp', txt => 'Parent brand' };
+ old => { reverse => 'new', pref => 0, txt => 'Formerly' },
+ new => { reverse => 'old', pref => 1, txt => 'Succeeded by' },
+ spa => { reverse => 'ori', pref => 1, txt => 'Spawned' },
+ ori => { reverse => 'spa', pref => 0, txt => 'Originated from' },
+ sub => { reverse => 'par', pref => 1, txt => 'Subsidiary' },
+ par => { reverse => 'sub', pref => 0, txt => 'Parent producer' },
+ imp => { reverse => 'ipa', pref => 1, txt => 'Imprint' },
+ ipa => { reverse => 'imp', pref => 0, txt => 'Parent brand' };
@@ -141,22 +182,25 @@ hash PRODUCER_TYPE =>
# SQL: ENUM credit_type
hash CREDIT_TYPE =>
scenario => 'Scenario',
+ director => 'Director',
chardesign => 'Character design',
art => 'Artist',
music => 'Composer',
songs => 'Vocals',
- director => 'Director',
+ translator => 'Translator',
+ editor => 'Editor',
+ qa => 'Quality assurance',
staff => 'Staff';
hash VN_LENGTH =>
- 0 => { txt => 'Unknown', time => '' },
- 1 => { txt => 'Very short', time => '< 2 hours' },
- 2 => { txt => 'Short', time => '2 - 10 hours' },
- 3 => { txt => 'Medium', time => '10 - 30 hours' },
- 4 => { txt => 'Long', time => '30 - 50 hours' },
- 5 => { txt => 'Very long', time => '> 50 hours' };
+ 0 => { txt => 'Unknown', time => '', low => 0, high => 0 },
+ 1 => { txt => 'Very short', time => '< 2 hours', low => 1, high => 2*60 },
+ 2 => { txt => 'Short', time => '2 - 10 hours', low => 2*60, high => 10*60 },
+ 3 => { txt => 'Medium', time => '10 - 30 hours', low => 10*60, high => 30*60 },
+ 4 => { txt => 'Long', time => '30 - 50 hours', low => 30*60, high => 50*60 },
+ 5 => { txt => 'Very long', time => '> 50 hours', low => 50*60, high => 32767 };
@@ -181,28 +225,26 @@ hash TAG_CATEGORY =>
hash ANIMATED =>
- 0 => { txt => 'Unknown', story_icon => 'unknown', ero_icon => 'unknown' },
- 1 => { txt => 'No animations', story_icon => 'story_not_animated', ero_icon => 'ero_not_animated' },
- 2 => { txt => 'Simple animations', story_icon => 'story_simple_animated', ero_icon => 'ero_simple_animated' },
- 3 => { txt => 'Some fully animated scenes', story_icon => 'story_some_fully_animated', ero_icon => 'ero_some_fully_animated' },
- 4 => { txt => 'All scenes fully animated', story_icon => 'story_all_fully_animated', ero_icon => 'ero_all_fully_animated' };
+ 0 => { txt => 'Unknown' },
+ 1 => { txt => 'Not animated' },
+ 2 => { txt => 'Simple animations' },
+ 3 => { txt => 'Some fully animated scenes' },
+ 4 => { txt => 'All scenes fully animated' };
hash VOICED =>
- 0 => { txt => 'Unknown', icon => 'unknown' },
- 1 => { txt => 'Not voiced', icon => 'not_voiced' },
- 2 => { txt => 'Only ero scenes voiced', icon => 'ero_voiced' },
- 3 => { txt => 'Partially voiced', icon => 'partially_voiced' },
- 4 => { txt => 'Fully voiced', icon => 'fully_voiced' };
+ 0 => { txt => 'Unknown' },
+ 1 => { txt => 'Not voiced' },
+ 2 => { txt => 'Only ero scenes voiced' },
+ 3 => { txt => 'Partially voiced' },
+ 4 => { txt => 'Fully voiced' };
-# TODO: For some reason the minage column in SQL is nullable but still stores 'unknown' as -1.
-# This should be cleaned up at some point.
hash AGE_RATING =>
- -1 => { txt => 'Unknown', ex => '' },
0 => { txt => 'All ages', ex => 'CERO A' },
+ 3 => { txt => '3+', ex => '' },
6 => { txt => '6+', ex => '' },
7 => { txt => '7+', ex => '' },
8 => { txt => '8+', ex => '' },
@@ -227,6 +269,7 @@ hash MEDIUM =>
gdr => { qty => 1, txt => 'GD-ROM', plural => 'GD-ROMs', icon => 'disk' },
blr => { qty => 1, txt => 'Blu-ray disc', plural => 'Blu-ray discs', icon => 'disk' },
flp => { qty => 1, txt => 'Floppy', plural => 'Floppies', icon => 'cartridge' },
+ cas => { qty => 1, txt => 'Cassette tape', plural => 'Cassette tapes', icon => 'cartridge' },
mrt => { qty => 1, txt => 'Cartridge', plural => 'Cartridges', icon => 'cartridge' },
mem => { qty => 1, txt => 'Memory card', plural => 'Memory cards', icon => 'cartridge' },
umd => { qty => 1, txt => 'UMD', plural => 'UMDs', icon => 'disk' },
@@ -236,29 +279,6 @@ hash MEDIUM =>
-# SQL: ENUM resolution
-hash RESOLUTION =>
- unknown => { txt => 'Unknown / console / handheld', cat => '' }, # hardcoded in many places
- nonstandard => { txt => 'Non-standard', cat => '' }, # hardcoded in VNPage.pm
- '640x480' => { txt => '640x480', cat => '4:3' },
- '800x600' => { txt => '800x600', cat => '4:3' },
- '1024x768' => { txt => '1024x768', cat => '4:3' },
- '1280x960' => { txt => '1280x960', cat => '4:3' },
- '1600x1200' => { txt => '1600x1200', cat => '4:3' },
- '640x400' => { txt => '640x400', cat => 'widescreen' },
- '960x600' => { txt => '960x600', cat => 'widescreen' },
- '960x640' => { txt => '960x640', cat => 'widescreen' },
- '1024x576' => { txt => '1024x576', cat => 'widescreen' },
- '1024x600' => { txt => '1024x600', cat => 'widescreen' },
- '1024x640' => { txt => '1024x640', cat => 'widescreen' },
- '1280x720' => { txt => '1280x720', cat => 'widescreen' },
- '1280x800' => { txt => '1280x800', cat => 'widescreen' },
- '1366x768' => { txt => '1366x768', cat => 'widescreen' },
- '1600x900' => { txt => '1600x900', cat => 'widescreen' },
- '1920x1080' => { txt => '1920x1080', cat => 'widescreen' };
-
-
-
# SQL: ENUM release_type
hash RELEASE_TYPE =>
complete => 'Complete',
diff --git a/lib/VNDB/Util/Auth.pm b/lib/VNDB/Util/Auth.pm
deleted file mode 100644
index 4394149f..00000000
--- a/lib/VNDB/Util/Auth.pm
+++ /dev/null
@@ -1,129 +0,0 @@
-# Compatibility shim around VNWeb::Auth, new code should use that instead.
-package VNDB::Util::Auth;
-
-
-use strict;
-use warnings;
-use Exporter 'import';
-use TUWF ':html';
-use VNWeb::Auth;
-
-
-our @EXPORT = qw|
- authInit authLogin authLogout authInfo authCan authSetPass authAdminSetPass
- authResetPass authIsValidToken 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)
- +{ id => auth->uid }
-}
-
-
-# returns whether the currently loggedin or anonymous user can perform
-# a certain action.
-sub authCan {
- my(undef, $act) = @_;
- auth->perm() & auth->listPerms->{$act}
-}
-
-
-# Generate a code to be used later on to validate that the form was indeed
-# submitted from our site and by the same user/visitor. Not limited to
-# logged-in users.
-# Arguments:
-# form-id (ignored nowadyas)
-# time (also ignored)
-sub authGetCode {
- auth->csrftoken;
-}
-
-
-# Validates the correctness of the returned code, creates an error page and
-# returns false if it's invalid, returns true otherwise. Codes are valid for at
-# least two and at most three hours.
-# Arguments:
-# [ form-id, [ code ] ]
-# If the code is not given, uses the 'formcode' form parameter instead. If
-# form-id is not given, the path of the current requests is used.
-sub authCheckCode {
- my $self = shift;
- my $id = shift;
- my $code = shift || $self->reqParam('formcode');
- return _incorrectcode($self) if !auth->csrfcheck($code);
- 1;
-}
-
-
-sub _incorrectcode {
- my $self = shift;
- $self->resInit;
- $self->htmlHeader(title => 'Validation code expired', noindex => 1);
-
- div class => 'mainbox';
- h1 'Validation code expired';
- div class => 'warning';
- p 'Please hit the back-button of your browser, refresh the page and try again.';
- end;
- end;
-
- $self->htmlFooter;
- return 0;
-}
-
-
-sub authPref {
- my(undef, $key, $val) = @_;
- @_ == 2 ? auth->pref($key)||'' : auth->prefSet($key, $val);
-}
-
-1;
diff --git a/lib/VNDB/Util/BrowseHTML.pm b/lib/VNDB/Util/BrowseHTML.pm
deleted file mode 100644
index 29d131c5..00000000
--- a/lib/VNDB/Util/BrowseHTML.pm
+++ /dev/null
@@ -1,190 +0,0 @@
-
-package VNDB::Util::BrowseHTML;
-
-use strict;
-use warnings;
-use TUWF ':html', 'xml_escape';
-use Exporter 'import';
-use VNDB::Func;
-use VNDB::Types;
-use POSIX 'ceil';
-
-
-our @EXPORT = qw| htmlBrowse htmlBrowseNavigate htmlBrowseVN |;
-
-
-# generates a browse box, arguments:
-# items => arrayref with the list items
-# options => hashref containing at least the keys s (sort key), o (order) and p (page)
-# nextpage => whether there's a next page or not
-# sorturl => base URL to append the sort options to (if there are any sortable columns)
-# pageurl => base URL to append the page option to
-# class => classname of the mainbox
-# header =>
-# can be either an arrayref or subroutine reference,
-# in the case of a subroutine, it will be called when the header should be written,
-# in the case of an arrayref, the array should contain the header items. Each item
-# can again be either an arrayref or subroutine ref. The arrayref would consist of
-# two elements: the name of the header, and the name of the sorting column if it can
-# be sorted
-# row => subroutine ref, which is called for each item in $list, arguments will be
-# $self, $item_number (starting from 0), $item_value
-# footer => subroutine ref, called after all rows have been processed
-sub htmlBrowse {
- my($self, %opt) = @_;
-
- $opt{sorturl} .= $opt{sorturl} =~ /\?/ ? ';' : '?' if $opt{sorturl};
-
- # top navigation
- $self->htmlBrowseNavigate($opt{pageurl}, $opt{options}{p}, $opt{nextpage}, 't') if $opt{pageurl};
-
- div class => 'mainbox browse'.($opt{class} ? ' '.$opt{class} : '');
- table class => 'stripe';
-
- # header
- thead;
- Tr;
- if(ref $opt{header} eq 'CODE') {
- $opt{header}->($self);
- } else {
- for(0..$#{$opt{header}}) {
- if(ref $opt{header}[$_] eq 'CODE') {
- $opt{header}[$_]->($self, $_+1);
- } else {
- td class => $opt{header}[$_][3]||'tc'.($_+1), $opt{header}[$_][2] ? (colspan => $opt{header}[$_][2]) : ();
- lit $opt{header}[$_][0];
- if($opt{header}[$_][1]) {
- lit ' ';
- $opt{options}{s} eq $opt{header}[$_][1] && $opt{options}{o} eq 'a' ? lit "\x{25B4}" : a href => "$opt{sorturl}o=a;s=$opt{header}[$_][1]", "\x{25B4}";
- $opt{options}{s} eq $opt{header}[$_][1] && $opt{options}{o} eq 'd' ? lit "\x{25BE}" : a href => "$opt{sorturl}o=d;s=$opt{header}[$_][1]", "\x{25BE}";
- }
- end;
- }
- }
- }
- end;
- end 'thead';
-
- # footer
- if($opt{footer}) {
- tfoot;
- $opt{footer}->($self);
- end;
- }
-
- # rows
- $opt{row}->($self, $_+1, $opt{items}[$_])
- for 0..$#{$opt{items}};
-
- end 'table';
- end 'div';
-
- # bottom navigation
- $self->htmlBrowseNavigate($opt{pageurl}, $opt{options}{p}, $opt{nextpage}, 'b') if $opt{pageurl};
-}
-
-
-# creates next/previous buttons (tabs), if needed
-# Arguments: page url, current page (1..n), nextpage (0/1 or [$total, $perpage]), alignment (t/b), noappend (0/1)
-sub htmlBrowseNavigate {
- my($self, $url, $p, $np, $al, $na) = @_;
- my($cnt, $pp) = ref($np) ? @$np : ($p+$np, 1);
- return if $p == 1 && $cnt <= $pp;
-
- $url .= $url =~ /\?/ ? ';p=' : '?p=' unless $na;
-
- my $tab = sub {
- my($page, $label) = @_;
- li;
- a href => $url.$page; lit $label; end;
- end;
- };
- my $ell = sub {
- use utf8;
- li class => 'ellipsis';
- b '⋯';
- end;
- };
- my $nc = 5; # max. number of buttons on each side
-
- div class => 'maintabs browsetabs '.($al eq 't' ? '' : 'bottom');
- ul;
- $p > 2 and ref $np and $tab->(1, '&laquo; first');
- $p > $nc+1 and ref $np and $ell->();
- $p > $_ and ref $np and $tab->($p-$_, $p-$_) for (reverse 2..($nc>$p-2?$p-2:$nc-1));
- $p > 1 and $tab->($p-1, '&lsaquo; previous');
- end;
-
- ul;
- my $l = ceil($cnt/$pp)-$p+1;
- $l > 1 and $tab->($p+1, 'next &rsaquo;');
- $l > $_ and $tab->($p+$_, $p+$_) for (2..($nc>$l-2?$l-2:$nc-1));
- $l > $nc+1 and $ell->();
- $l > 2 and $tab->($l+$p-1, 'last &raquo;');
- end;
- end 'div';
-}
-
-
-sub htmlBrowseVN {
- my($self, $list, $f, $np, $url, $tagscore) = @_;
- $self->htmlBrowse(
- class => 'vnbrowse',
- items => $list,
- options => $f,
- nextpage => $np,
- pageurl => "$url;o=$f->{o};s=$f->{s}",
- sorturl => $url,
- header => [
- $tagscore ? [ 'Score', 'tagscore', undef, 'tc_s' ] : (),
- [ 'Title', 'title', undef, $tagscore ? 'tc_t' : 'tc1' ],
- $f->{vnlist} ? [ '', 0, undef, 'tc7' ] : (),
- $f->{wish} ? [ '', 0, undef, 'tc8' ] : (),
- [ '', 0, undef, 'tc2' ],
- [ '', 0, undef, 'tc3' ],
- [ 'Released', 'rel', undef, 'tc4' ],
- [ 'Popularity', 'pop', undef, 'tc5' ],
- [ 'Rating', 'rating', undef, 'tc6' ],
- ],
- row => sub {
- my($s, $n, $l) = @_;
- Tr;
- if($tagscore) {
- td class => 'tc_s';
- VNWeb::Tags::Lib::tagscore_($l->{tagscore});
- end;
- }
- td class => $tagscore ? 'tc_t' : 'tc1';
- a href => '/v'.$l->{id}, title => $l->{original}||$l->{title}, shorten $l->{title}, 100;
- end;
- if($f->{vnlist}) {
- td class => 'tc7';
- lit sprintf '<b class="%s">%d/%d</b>', $l->{userlist_obtained} == $l->{userlist_all} ? 'done' : 'todo', $l->{userlist_obtained}, $l->{userlist_all} if $l->{userlist_all};
- abbr title => join(', ', $l->{vnlist_labels}->@*), scalar $l->{vnlist_labels}->@* if $l->{vnlist_labels} && $l->{vnlist_labels}->@*;
- abbr title => 'No labels', ' ' if $l->{vnlist_labels} && !$l->{vnlist_labels}->@*;
- end 'td';
- }
- td class => 'tc2';
- $_ ne 'oth' && cssicon $_, $PLATFORM{$_}
- for (sort @{$l->{c_platforms}});
- end;
- td class => 'tc3';
- cssicon "lang $_", $LANGUAGE{$_}
- for (reverse sort @{$l->{c_languages}});
- end;
- td class => 'tc4';
- lit fmtdatestr $l->{c_released};
- end;
- td class => 'tc5', sprintf '%.2f', ($l->{c_popularity}||0)*100;
- td class => 'tc6';
- txt sprintf '%.2f', ($l->{c_rating}||0)/10;
- b class => 'grayedout', sprintf ' (%d)', $l->{c_votecount};
- end;
- end 'tr';
- },
- );
-}
-
-
-1;
-
diff --git a/lib/VNDB/Util/CommonHTML.pm b/lib/VNDB/Util/CommonHTML.pm
deleted file mode 100644
index 7a3d554c..00000000
--- a/lib/VNDB/Util/CommonHTML.pm
+++ /dev/null
@@ -1,327 +0,0 @@
-
-package VNDB::Util::CommonHTML;
-
-use strict;
-use warnings;
-use TUWF ':html', 'xml_escape', 'html_escape';
-use Exporter 'import';
-use Algorithm::Diff::XS 'compact_diff';
-use Encode 'encode_utf8', 'decode_utf8';
-use VNDB::Func;
-use POSIX 'ceil';
-
-our @EXPORT = qw|
- htmlMainTabs htmlDenied htmlHiddenMessage htmlRevision
- htmlEditMessage htmlItemMessage htmlVoteStats htmlSearchBox htmlRGHeader
-|;
-
-
-# generates the "main tabs". These are the commonly used tabs for
-# 'objects', i.e. VN/producer/release entries and users
-# Arguments: u/v/r/p/g/i/c/d, object, currently selected item (empty=main)
-sub htmlMainTabs {
- my($self, $type, $obj, $sel) = @_;
- $obj->{entry_hidden} = $obj->{hidden};
- $obj->{entry_locked} = $obj->{locked};
- VNWeb::HTML::_maintabs_({ type => $type, dbobj => $obj, tab => $sel||''});
-}
-
-
-# generates a full error page, including header and footer
-sub htmlDenied { shift->resDenied }
-
-
-# Generates message saying that the current item has been deleted,
-# Arguments: [pvrc], obj
-# Returns 1 if the use doesn't have access to the page, 0 otherwise
-sub htmlHiddenMessage {
- my($self, $type, $obj) = @_;
- return 0 if !$obj->{hidden};
- my $board = $type =~ /[csd]/ ? 'db' : $type eq 'r' ? 'v'.$obj->{vn}[0]{vid} : $type.$obj->{id};
- # fetch edit summary (not present in $obj, requires the db*GetRev() methods)
- my $editsum = $type eq 'v' ? $self->dbVNGetRev(id => $obj->{id})->[0]{comments}
- : $type eq 'r' ? $self->dbReleaseGetRev(id => $obj->{id})->[0]{comments}
- : $type eq 'c' ? $self->dbCharGetRev(id => $obj->{id})->[0]{comments}
- : $self->dbProducerGetRev(id => $obj->{id})->[0]{comments};
- div class => 'mainbox';
- h1 $obj->{title}||$obj->{name};
- div class => 'warning';
- h2 'Item deleted';
- p;
- lit 'This item has been deleted from the database. File a request on the <a href="/t/'.$board.'">discussion board</a> to undelete this page.';
- br; br;
- lit bb2html $editsum;
- end;
- end;
- end 'div';
- return $self->htmlFooter() || 1 if !$self->authCan('dbmod');
- return 0;
-}
-
-
-# Shows a revision, including diff if there is a previous revision.
-# Arguments: v|p|r|c|d, old revision, new revision, @fields
-# Where @fields is a list of fields as arrayrefs with:
-# [ shortname, displayname, %options ],
-# Where %options:
-# diff => 1/0/regex, whether to show a diff on this field, and what to split it with (1 = character-level diff)
-# short_diff=> 1/0, when set, cut off long context in diffs
-# serialize => coderef, should convert the field into a readable string, no HTML allowed
-# htmlize => same as serialize, but HTML is allowed and this can't be diff'ed
-# split => coderef, should return an array of HTML strings that can be diff'ed. (implies diff => 1)
-# join => used in combination with split, specifies the string used for joining the HTML strings
-sub htmlRevision {
- my($self, $type, $old, $new, @fields) = @_;
- div class => 'mainbox revision';
- h1 "Revision $new->{rev}";
-
- # previous/next revision links
- a class => 'prev', href => sprintf('/%s%d.%d', $type, $new->{id}, $new->{rev}-1), '<- earlier revision' if $new->{rev} > 1;
- a class => 'next', href => sprintf('/%s%d.%d', $type, $new->{id}, $new->{rev}+1), 'later revision ->' if !$new->{lastrev};
- p class => 'center';
- a href => "/$type$new->{id}", "$type$new->{id}";
- end;
-
- # no previous revision, just show info about the revision itself
- if(!$old) {
- div class => 'rev';
- revheader($self, $type, $new);
- br;
- b 'Edit summary';
- br; br;
- lit bb2html($new->{comments})||'-';
- end;
- }
-
- # otherwise, compare the two revisions
- else {
- table class => 'stripe';
- thead;
- Tr;
- td; lit '&#xa0;'; end;
- td; revheader($self, $type, $old); end;
- td; revheader($self, $type, $new); end;
- end;
- Tr;
- td; lit '&#xa0;'; end;
- td colspan => 2;
- b "Edit summary of revision $new->{rev}:";
- br; br;
- lit bb2html($new->{comments})||'-';
- end;
- end;
- end;
- revdiff($type, $old, $new, @$_) for (
- [ ihid => 'Deleted', serialize => sub { $_[0] ? 'Yes' : 'No' } ],
- [ ilock => 'Locked', serialize => sub { $_[0] ? 'Yes' : 'No' } ],
- @fields
- );
- end 'table';
- }
- end 'div';
-}
-
-sub revheader { # type, obj
- my($self, $type, $obj) = @_;
- b "Revision $obj->{rev}";
- txt ' (';
- a href => "/$type$obj->{id}.$obj->{rev}/edit", 'revert to';
- if($obj->{user_id} && $self->authCan('board')) {
- lit ' / ';
- a href => "/t/u$obj->{user_id}/new?title=Regarding%20$type$obj->{id}.$obj->{rev}", 'msg user';
- }
- txt ')';
- br;
- txt 'By ';
- VNWeb::HTML::user_($obj);
- txt ' on ';
- txt fmtdate $obj->{added}, 'full';
-}
-
-sub revdiff {
- my($type, $old, $new, $short, $display, %o) = @_;
-
- $o{serialize} ||= $o{htmlize};
- $o{diff} = 1 if $o{split};
- $o{join} ||= '';
-
- my $ser1 = $o{serialize} ? $o{serialize}->($old->{$short}, $old) : $old->{$short};
- my $ser2 = $o{serialize} ? $o{serialize}->($new->{$short}, $new) : $new->{$short};
- return if $ser1 eq $ser2;
-
- if($o{diff} && $ser1 && $ser2) {
- my $sep = ref $o{diff} ? qr/($o{diff})/ : qr//;
- my @ser1 = map encode_utf8($_), $o{split} ? $o{split}->($ser1) : map html_escape($_), split $sep, $ser1;
- my @ser2 = map encode_utf8($_), $o{split} ? $o{split}->($ser2) : map html_escape($_), split $sep, $ser2;
- return if $o{split} && $#ser1 == $#ser2 && !grep $ser1[$_] ne $ser2[$_], 0..$#ser1;
-
- $ser1 = $ser2 = '';
- my @d = compact_diff(\@ser1, \@ser2);
- my $lastchunk = int (($#d-2)/2);
- for my $i (0..$lastchunk) {
- # $i % 2 == 0 -> equal, otherwise it's different
- my $a = join($o{join}, @ser1[ $d[$i*2] .. $d[$i*2+2]-1 ]);
- my $b = join($o{join}, @ser2[ $d[$i*2+1] .. $d[$i*2+3]-1 ]);
- # Reduce context if we have too much
- if($o{short_diff} && $i % 2 == 0 && length($a) > 300) {
- my $sep = '<b class="standout">&lt;...&gt;</b>';
- my $ctx = 100;
- $a = $i == 0 ? $sep.'<br>'.substr $a, -$ctx :
- $i == $lastchunk ? substr($a, 0, $ctx).'<br>'.$sep :
- substr($a, 0, $ctx)."<br><br>$sep<br><br>".substr($a, -$ctx);
- $b = $a;
- }
- $ser1 .= ($ser1?$o{join}:'').($i % 2 ? qq|<b class="diff_del">$a</b>| : $a) if $a ne '';
- $ser2 .= ($ser2?$o{join}:'').($i % 2 ? qq|<b class="diff_add">$b</b>| : $b) if $b ne '';
- }
- $ser1 = decode_utf8($ser1);
- $ser2 = decode_utf8($ser2);
- } elsif(!$o{htmlize}) {
- $ser1 = html_escape $ser1;
- $ser2 = html_escape $ser2;
- }
-
- $ser1 = '[empty]' if !$ser1 && $ser1 ne '0';
- $ser2 = '[empty]' if !$ser2 && $ser2 ne '0';
-
- Tr;
- td $display;
- td class => 'tcval'; lit $ser1; end;
- td class => 'tcval'; lit $ser2; end;
- end;
-}
-
-
-# Generates a generic message to show as the header of the edit forms
-# Arguments: v/r/p, obj, title, copy
-sub htmlEditMessage {
- shift; VNWeb::HTML::editmsg_(@_);
-}
-
-
-# Generates a small message when the user can't edit the item,
-# or the item is locked.
-# Arguments: v/r/p/c, obj
-sub htmlItemMessage {
- my($self, $type, $obj) = @_;
- # $type isn't being used at all... oh well.
-
- if($obj->{locked}) {
- p class => 'locked', 'Locked for editing';
- } elsif($self->authInfo->{id} && !$self->authCan('edit')) {
- p class => 'locked', 'You are not allowed to edit this page';
- }
-}
-
-
-# generates two tables, one with a vote graph, other with recent votes
-# Only supports $type eq 'v' now.
-sub htmlVoteStats {
- my($self, $type, $obj, $stats) = @_;
-
- my($max, $count, $total) = (0, 0, 0);
- for (0..$#$stats) {
- $max = $stats->[$_][0] if $stats->[$_][0] > $max;
- $count += $stats->[$_][0];
- $total += $stats->[$_][1];
- }
- div class => 'votestats';
- table class => 'votegraph';
- thead; Tr;
- td colspan => 2, 'Vote stats';
- end; end;
- tfoot; Tr;
- td colspan => 2, sprintf '%d vote%s total, average %.2f%s', $count, $count == 1 ? '' : 's', $total/$count/10,
- $type eq 'v' ? ' ('.fmtrating(ceil($total/$count/10-1)||1).')' : '';
- end; end;
- for (reverse 0..$#$stats) {
- Tr;
- td class => 'number', $_+1;
- td class => 'graph';
- div style => 'width: '.($stats->[$_][0]/$max*250).'px', ' ';
- txt $stats->[$_][0];
- end;
- end;
- }
- end 'table';
-
- my $recent = $self->dbAlli('
- SELECT uv.vote,', VNWeb::DB::sql_totime('uv.vote_date '), 'as date, ', VNWeb::DB::sql_user(), '
- , NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private) AS hide_list
- FROM ulist_vns uv
- JOIN users u ON u.id = uv.uid
- WHERE uv.vid =', \$obj->{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
- );
-
- if(@$recent) {
- table class => 'recentvotes stripe';
- thead; Tr;
- td colspan => 3;
- txt 'Recent votes';
- b;
- txt '(';
- a href => "/$type$obj->{id}/votes", 'show all';
- txt ')';
- end;
- end;
- end; end;
- for (@$recent) {
- Tr;
- td;
- if($_->{hide_list}) {
- b class => 'grayedout', 'hidden';
- } else {
- VNWeb::HTML::user_($_);
- }
- end;
- td fmtvote $_->{vote};
- td fmtdate $_->{date};
- end;
- }
- end 'table';
- }
-
- clearfloat;
- if($type eq 'v' && $obj->{c_votecount}) {
- div;
- h3 'Ranking';
- p sprintf 'Popularity: ranked #%d with a score of %.2f', $obj->{p_ranking}, ($obj->{c_popularity}||0)*100;
- p sprintf 'Bayesian rating: ranked #%d with a rating of %.2f', $obj->{r_ranking}, $obj->{c_rating}/10;
- end;
- }
- end 'div';
-}
-
-
-sub htmlSearchBox {
- shift; VNWeb::HTML::searchbox_(@_);
-}
-
-
-sub htmlRGHeader {
- my($self, $title, $type, $obj) = @_;
-
- # This used to be a good test for inline SVG support, but I'm not sure it is nowadays.
- if(($self->reqHeader('Accept')||'') !~ /application\/xhtml\+xml/) {
- $self->htmlHeader(title => $title);
- $self->htmlMainTabs($type, $obj, 'rg');
- div class => 'mainbox';
- h1 $title;
- div class => 'warning';
- h2 'Not supported';
- p 'Your browser sucks, it doesn\'t have the functionality to render our nice relation graphs.';
- end;
- end;
- $self->htmlFooter;
- return 1;
- }
- $self->htmlHeader(title => $title);
- $self->htmlMainTabs($type, $obj, 'rg');
- return 0;
-}
-
-
-1;
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
deleted file mode 100644
index 6bafbeda..00000000
--- a/lib/VNDB/Util/LayoutHTML.pm
+++ /dev/null
@@ -1,43 +0,0 @@
-
-package VNDB::Util::LayoutHTML;
-
-use strict;
-use warnings;
-use TUWF ':html';
-use VNWeb::HTML;
-use Exporter 'import';
-
-our @EXPORT = qw|htmlHeader htmlFooter|;
-
-sub htmlHeader { # %options->{ title, noindex, search, feeds, metadata }
- my($self, %o) = @_;
- %VNWeb::HTML::pagevars = ();
-
- $o{og} = $o{metadata} ? +{ map +(s/og://r, $o{metadata}{$_}), keys $o{metadata}->%* } : undef;
- $o{index} = !$o{noindex};
-
- html lang => 'en';
- head sub { VNWeb::HTML::_head_(\%o) };
- body;
- div id => 'bgright', ' ';
- div id => 'header', sub { h1 sub { a href => '/', 'the visual novel database' } };
- div id => 'menulist', sub { VNWeb::HTML::_menu_(\%o) };
- div id => 'maincontent';
-}
-
-
-sub htmlFooter { # %options => { pref_code => 1 }
- my($self, %o) = @_;
- div id => 'footer', sub { VNWeb::HTML::_footer_ };
- end 'div'; # maincontent
-
- # Abuse an empty noscript tag for the formcode to update a preference setting, if the page requires one.
- 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::v2rwjs_() if $o{v2rwjs};
- end 'body';
- end 'html';
-}
-
-1;
diff --git a/lib/VNDB/Util/Misc.pm b/lib/VNDB/Util/Misc.pm
deleted file mode 100644
index b314bf08..00000000
--- a/lib/VNDB/Util/Misc.pm
+++ /dev/null
@@ -1,122 +0,0 @@
-
-package VNDB::Util::Misc;
-
-use strict;
-use warnings;
-use Exporter 'import';
-use TUWF ':html';
-use VNDB::Func;
-use VNDB::Types;
-use VNDB::BBCode;
-
-our @EXPORT = qw|filFetchDB filCompat bbSubstLinks|;
-
-
-our %filfields = (
- vn => [qw|date_before date_after released length hasani hasshot tag_inc tag_exc taginc tagexc tagspoil lang olang plat staff_inc staff_exc ul_notblack ul_onwish ul_voted ul_onlist|],
- release => [qw|type patch freeware doujin uncensored date_before date_after released minage lang olang resolution plat prod_inc prod_exc med voiced ani_story ani_ero engine|],
- char => [qw|gender bloodt bust_min bust_max waist_min waist_max hip_min hip_max height_min height_max va_inc va_exc weight_min weight_max cup_min cup_max trait_inc trait_exc tagspoil role|],
- staff => [qw|gender role truename lang|],
-);
-
-
-# Arguments:
-# type ('vn', 'release' or 'char'),
-# filter overwrite (string or undef),
-# when defined, these filters will be used instead of the preferences,
-# must point to a variable, will be modified in-place with the actually used filters
-# options to pass to db*Get() before the filters (hashref or undef)
-# these options can be overwritten by the filters or the next option
-# options to pass to db*Get() after the filters (hashref or undef)
-# these options overwrite all other options (pre-options and filters)
-
-sub filFetchDB {
- my($self, $type, $overwrite, $pre, $post) = @_;
- $pre = {} if !$pre;
- $post = {} if !$post;
- my $dbfunc = $self->can($type eq 'vn' ? 'dbVNGet' : $type eq 'release' ? 'dbReleaseGet' : $type eq 'char' ? 'dbCharGet' : 'dbStaffGet');
- my $prefname = 'filter_'.$type;
- my $pref = $self->authPref($prefname);
-
- my $filters = fil_parse $overwrite // $pref, @{$filfields{$type}};
-
- # compatibility
- my $compat = $self->filCompat($type, $filters);
- $self->authPref($prefname => fil_serialize $filters) if $compat && !defined $overwrite;
-
- # write the definite filter string in $overwrite
- $_[2] = fil_serialize({map +(
- exists($post->{$_}) ? ($_ => $post->{$_}) :
- exists($filters->{$_}) ? ($_ => $filters->{$_}) :
- exists($pre->{$_}) ? ($_ => $pre->{$_}) : (),
- ), @{$filfields{$type}}}) if defined $overwrite;
-
- return $dbfunc->($self, %$pre, %$filters, %$post) if defined $overwrite or !keys %$filters;;
-
- # since incorrect filters can throw a database error, we have to special-case
- # filters that originate from a preference setting, so that in case these are
- # the cause of an error, they are removed. Not doing this will result in VNDB
- # throwing 500's even for non-browse pages. We have to do some low-level
- # PostgreSQL stuff with savepoints to ensure that an error won't affect our
- # existing transaction.
- my $dbh = $self->dbh;
- $dbh->pg_savepoint('filter');
- my($r, $np);
- my $OK = eval {
- ($r, $np) = $dbfunc->($self, %$pre, %$filters, %$post);
- 1;
- };
- $dbh->pg_rollback_to('filter') if !$OK;
- $dbh->pg_release('filter');
-
- # error occured, let's try again without filters. if that succeeds we know
- # it's the fault of the filter preference, and we should remove it.
- if(!$OK) {
- ($r, $np) = $dbfunc->($self, %$pre, %$post);
- # if we're here, it means the previous function didn't die() (duh!)
- $self->authPref($prefname => '');
- warn sprintf "Reset filter preference for userid %d. Old: %s\n", $self->authInfo->{id}||0, $pref;
- }
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Compatibility with old filters. Modifies the filter in-place and returns the number of changes made.
-sub filCompat {
- my($self, $type, $fil) = @_;
- my $mod = 0;
-
- # older tag specification (by name rather than ID)
- if($type eq 'vn' && ($fil->{taginc} || $fil->{tagexc})) {
- my $tagfind = sub {
- return map {
- my $i = $self->dbTagGet(name => $_)->[0];
- $i && $i->{searchable} ? $i->{id} : ();
- } grep $_, ref $_[0] ? @{$_[0]} : ($_[0]||'')
- };
- $fil->{tag_inc} //= [ $tagfind->(delete $fil->{taginc}) ] if $fil->{taginc};
- $fil->{tag_exc} //= [ $tagfind->(delete $fil->{tagexc}) ] if $fil->{tagexc};
- $mod++;
- }
-
- if($type eq 'release' && $fil->{resolution}) {
- $fil->{resolution} = [ map {
- if(/^[0-9]+$/) {
- $mod++;
- (keys %RESOLUTION)[$_] || 'unknown'
- } else { $_ }
- } ref $fil->{resolution} ? @{$fil->{resolution}} : $fil->{resolution} ];
- }
-
- $mod;
-}
-
-
-
-sub bbSubstLinks {
- shift; bb_subst_links @_;
-}
-
-
-1;
-
diff --git a/lib/VNDB/Util/ValidateTemplates.pm b/lib/VNDB/Util/ValidateTemplates.pm
deleted file mode 100644
index 7966b319..00000000
--- a/lib/VNDB/Util/ValidateTemplates.pm
+++ /dev/null
@@ -1,110 +0,0 @@
-# This module implements various templates for formValidate()
-
-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;
-}
diff --git a/lib/VNDBUtil.pm b/lib/VNDBUtil.pm
deleted file mode 100644
index 5d7850bc..00000000
--- a/lib/VNDBUtil.pm
+++ /dev/null
@@ -1,145 +0,0 @@
-# Misc. utility functions, do not rely on YAWF or POE and can be used from any script
-
-package VNDBUtil;
-
-use strict;
-use warnings;
-use Exporter 'import';
-use Encode 'encode_utf8';
-use Unicode::Normalize 'NFKD', 'compose';
-use Socket 'inet_pton', 'inet_ntop', 'AF_INET', 'AF_INET6';
-
-our @EXPORT = qw|shorten gtintype normalize_titles normalize_query imgsize norm_ip|;
-
-
-sub shorten {
- my($str, $len) = @_;
- return length($str) > $len ? substr($str, 0, $len-3).'...' : $str;
-}
-
-
-# GTIN code as argument,
-# Returns 'JAN', 'EAN', 'UPC' or undef,
-# Also 'normalizes' the first argument in place
-sub gtintype {
- $_[0] =~ s/[^\d]+//g;
- return undef if $_[0] !~ /^[0-9]{10,13}$/; # I've yet to see a UPC code shorter than 10 digits assigned to a game
- $_[0] = ('0'x(12-length $_[0])) . $_[0] if length($_[0]) < 12; # pad with zeros to GTIN-12
- my $c = shift;
- return undef if $c !~ /^[0-9]{12,13}$/;
- $c = "0$c" if length($c) == 12; # pad with another zero for GTIN-13
-
- # calculate check digit according to
- # http://www.gs1.org/productssolutions/barcodes/support/check_digit_calculator.html#how
- my @n = reverse split //, $c;
- my $n = shift @n;
- $n += $n[$_] * ($_ % 2 != 0 ? 1 : 3) for (0..$#n);
- return undef if $n % 10 != 0;
-
- # Do some rough guesses based on:
- # http://www.gs1.org/productssolutions/barcodes/support/prefix_list.html
- # and http://en.wikipedia.org/wiki/List_of_GS1_country_codes
- local $_ = $c;
- return 'JAN' if /^4[59]/; # prefix code 450-459 & 490-499
- return 'UPC' if /^(?:0[01]|0[6-9]|13|75[45])/; # prefix code 000-019 & 060-139 & 754-755
- return undef if /^(?:0[2-5]|2|97[789]|9[6-9])/; # some codes we don't want: 020–059 & 200-299 & 977-999
- return 'EAN'; # let's just call everything else EAN :)
-}
-
-
-# a rather aggressive normalization
-sub normalize {
- local $_ = lc shift;
- use utf8;
- # Remove combining markings, except for kana.
- # This effectively removes all accents from the characters (e.g. é -> e)
- $_ = compose(NFKD($_) =~ s/(?<=[^ア-ンあ-ん])\pM//rg);
- # remove some characters that have no significance when searching
- tr/\r\n\t,_\-.~~〜∼ー῀:[]()%+!?#$"'`♥★☆♪†「」『』【】・‟“”‛’‘‚„«‹»›//d;
- tr/@/a/;
- tr/ı/i/; # Turkish lowercase i
- s/&/and/;
- # Consider wo and o the same thing (when used as separate word)
- s/(?:^| )o(?:$| )/wo/g;
- # Remove spaces. We're doing substring search, so let it cross word boundary to find more stuff
- tr/ //d;
- # remove commonly used release titles ("x Edition" and "x Version")
- # this saves some space and speeds up the search
- s/(?:
- first|firstpress|firstpresslimited|limited|regular|standard
- |package|boxed|download|complete|popular
- |lowprice|best|cheap|budget
- |special|trial|allages|fullvoice
- |cd|cdr|cdrom|dvdrom|dvd|dvdpack|dvdpg|windows
- |初回限定|初回|限定|通常|廉価|パッケージ|ダウンロード
- )(?:edition|version|版|生産)//xg;
- # other common things
- s/fandisk/fandisc/g;
- s/sempai/senpai/g;
- no utf8;
- return $_;
-}
-
-
-# normalizes each title and returns a concatenated string of unique titles
-sub normalize_titles {
- my %t = map +(normalize($_), 1), @_;
- return join ' ', grep $_, keys %t;
-}
-
-
-sub normalize_query {
- my $q = shift;
- # Consider wo and o the same thing (when used as separate word). Has to be
- # done here (in addition to normalize()) to make it work in combination with
- # double quote search.
- $q =~ s/(^| )o($| )/$1wo$2/ig;
- # remove spaces within quotes, so that it's considered as one search word
- $q =~ s/"([^"]+)"/(my $s=$1)=~y{ }{}d;$s/ge;
- # split into search words, normalize, and remove too short words
- return map length($_)>=(/^[\x01-\x7F]+$/?2:1) ? quotemeta($_) : (), map normalize($_), split / /, $q;
-}
-
-
-# arguments: <image size>, <max dimensions>
-# returns the size of the thumbnail with the same aspect ratio as the full-size
-# image, but fits within the specified maximum dimensions
-sub imgsize {
- my($ow, $oh, $sw, $sh) = @_;
- return ($ow, $oh) if $ow <= $sw && $oh <= $sh;
- if($ow/$oh > $sw/$sh) { # width is the limiting factor
- $oh *= $sw/$ow;
- $ow = $sw;
- } else {
- $ow *= $sh/$oh;
- $oh = $sh;
- }
- return (int $ow, int $oh);
-}
-
-
-# Normalized IP address to use for duplicate detection/throttling. For IPv4
-# this is the /23 subnet (is this enough?), for IPv6 the /48 subnet, with the
-# least significant bits of the address zero'd.
-sub norm_ip {
- my $ip = shift;
-
- # There's a whole bunch of IP manipulation modules on CPAN, but many seem
- # quite bloated and still don't offer the functionality to return an IP
- # with its mask applied (admittedly not a common operation). The libc
- # socket functions will do fine in parsing and formatting addresses, and
- # the actual masking is quite trivial in binary form.
- my $v4 = inet_pton AF_INET, $ip;
- if($v4) {
- $v4 =~ s/(..)(.)./$1 . chr(ord($2) & 254) . "\0"/se;
- return inet_ntop AF_INET, $v4;
- }
-
- $ip = inet_pton AF_INET6, $ip;
- return '::' if !$ip;
- $ip =~ s/^(.{6}).+$/$1 . "\0"x10/se;
- return inet_ntop AF_INET6, $ip;
-}
-
-1;
-
diff --git a/lib/VNWeb/API.pm b/lib/VNWeb/API.pm
new file mode 100644
index 00000000..8dad8277
--- /dev/null
+++ b/lib/VNWeb/API.pm
@@ -0,0 +1,1085 @@
+package VNWeb::API;
+
+use v5.26;
+use warnings;
+use TUWF;
+use Time::HiRes 'time', 'alarm';
+use List::Util 'min';
+use VNDB::Config;
+use VNDB::Func;
+use VNDB::ExtLinks;
+use VNDB::Types;
+use VNWeb::Auth;
+use VNWeb::DB;
+use VNWeb::Validation;
+use VNWeb::AdvSearch;
+use VNWeb::ULists::Lib 'ulist_filtlabels';
+
+return 1 if $main::NOAPI;
+
+
+TUWF::get qr{/api/(nyan|kana)}, sub {
+ state %data;
+ my $ver = tuwf->capture(1);
+ $data{$ver} ||= do {
+ open my $F, '<', config->{gen_path}.'/api-'.$ver.'.html' or die $!;
+ local $/=undef;
+ my $url = config->{api_endpoint}||tuwf->reqURI;
+ <$F> =~ s/%endpoint%/$url/rg;
+ };
+ tuwf->resHeader('Content-Type' => "text/html; charset=UTF-8");
+ tuwf->resBinary($data{$ver}, 'auto');
+};
+
+
+sub cors {
+ return if !tuwf->reqHeader('Origin');
+ if(tuwf->reqHeader('Cookie') || tuwf->reqHeader('Authorization')) {
+ tuwf->resHeader('Access-Control-Allow-Origin', tuwf->reqHeader('Origin'));
+ tuwf->resHeader('Access-Control-Allow-Credentials', 'true');
+ } else {
+ tuwf->resHeader('Access-Control-Allow-Origin', '*');
+ }
+}
+
+
+TUWF::options qr{/api/kana.*}, sub {
+ tuwf->resStatus(204);
+ tuwf->resHeader('Access-Control-Allow-Origin', tuwf->reqHeader('origin'));
+ tuwf->resHeader('Access-Control-Allow-Credentials', 'true');
+ tuwf->resHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
+ tuwf->resHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
+ tuwf->resHeader('Access-Control-Max-Age', 86400);
+};
+
+
+
+# Production API is currently running as a single process, so we can safely and
+# efficiently keep the throttle state as a local variable.
+# This throttle state only handles execution time limiting; request limiting
+# is done in nginx.
+my %throttle; # IP -> SQL time
+
+sub add_throttle {
+ my $now = time;
+ my $time = $now - (tuwf->req->{throttle_start}||$now);
+ my $norm = norm_ip tuwf->reqIP();
+ $throttle{$norm} = $now if !$throttle{$norm} || $throttle{$norm} < $now;
+ $throttle{$norm} += $time * config->{api_throttle}[0];
+}
+
+sub check_throttle {
+ tuwf->req->{throttle_start} = time;
+ err(429, 'Throttled on query execution time.')
+ if ($throttle{ norm_ip tuwf->reqIP }||0) >= time + (config->{api_throttle}[0] * config->{api_throttle}[1]);
+}
+
+sub logreq {
+ tuwf->log(sprintf '%4dms %s [%s] "%s" "%s"',
+ tuwf->req->{throttle_start} ? (time - tuwf->req->{throttle_start})*1000 : 0,
+ $_[0],
+ tuwf->reqIP(),
+ tuwf->reqHeader('origin')||'-',
+ tuwf->reqHeader('user-agent')||'');
+}
+
+sub err {
+ my($status, $msg) = @_;
+ add_throttle;
+ tuwf->resStatus($status);
+ tuwf->resHeader('Content-type', 'text');
+ tuwf->resHeader('WWW-Authenticate', 'Token') if $status == 401;
+ cors;
+ print { tuwf->resFd } $msg, "\n";
+ logreq "$status $msg";
+ tuwf->done;
+}
+
+sub count_request {
+ my($rows, $call) = @_;
+ close tuwf->resFd;
+ add_throttle;
+ logreq sprintf "%3dr%6db %s", $rows, length(tuwf->{_TUWF}{Res}{content}), $call;
+}
+
+
+sub api_get {
+ my($path, $schema, $sub) = @_;
+ my $s = tuwf->compile({ type => 'hash', keys => $schema });
+ TUWF::get qr{/api/kana\Q$path}, sub {
+ check_throttle;
+ my $res = $sub->();
+ tuwf->resJSON($s->analyze->coerce_for_json($res, unknown => 'pass'));
+ cors;
+ count_request(1, '-');
+ };
+}
+
+
+sub api_del {
+ my($path, $sub) = @_;
+ TUWF::del qr{/api/kana$path}, sub {
+ check_throttle;
+ my $del = $sub->();
+ tuwf->resStatus(204);
+ cors;
+ count_request($del?1:0, 'DELETE');
+ };
+}
+
+
+sub api_patch {
+ my($path, $req_schema, $sub) = @_;
+ $req_schema->{$_}{missing} = 'ignore' for keys $req_schema->%*;
+ my $s = tuwf->compile({ type => 'hash', unknown => 'reject', keys => $req_schema });
+ TUWF::patch qr{/api/kana$path}, sub {
+ check_throttle;
+ my $req = tuwf->validate(json => $s);
+ if(!$req) {
+ eval { $req->data }; warn $@;
+ my $err = $req->err;
+ if(!$err->{errors}) {
+ err 400, 'Missing request body.' if !$err->{keys};
+ err 400, "Unknown member '$err->{keys}[0]'." if $err->{keys};
+ }
+ $err = $err->{errors}[0]//{};
+ err 400, "Invalid '$err->{key}' member." if $err->{key};
+ err 400, 'Invalid request body.';
+ };
+ $req = $req->data;
+
+ # TUWF::Validate always creates a field, even if it was missing in the
+ # original body, but we want to differentiate between non-existent
+ # fields and empty ones, so we'll check with the raw body and delete
+ # the missing ones.
+ my $raw_input = tuwf->reqJSON();
+ delete $req->{$_} for grep !exists $raw_input->{$_}, keys $req->%*;
+
+ $sub->($req);
+ tuwf->resStatus(204);
+ cors;
+ count_request(1, 'PATCH');
+ };
+}
+
+
+# %opt:
+# filters => AdvSearch query type
+# sql => sub { sql 'SELECT id', $_[0], 'FROM x', $_[1], 'WHERE', $_[2] },
+# Main query to fetch items,
+# $_[0] is the list of fields to fetch (including a preceding comma)
+# $_[1] is a list of JOIN clauses
+# $_[2] the filters for in the WHERE clause
+# $_[3] points to the request parameters
+# 'ORDER BY' and 'LIMIT' clauses are appended to the returned query.
+# Query must always return a column named 'id'.
+# joins => {
+# $name => $sql,
+# # List of optional JOIN clauses that can be referenced by fields.
+# # These should always be 1-to-1 joins, i.e. no filtering or expansion may take place.
+# },
+# search => [ $type, $id, $subid ],
+# Whether sorting on "searchrank" is available, arguments are same as SearchQuery::sql_join().
+# fields => {
+# $name => { %field_definition },
+# },
+# sort => [
+# $name => $sql,
+# SQL may include '?o' and '!o' placeholders, see TableOpts.pm.
+# First sort option listed is the default.
+# ],
+#
+# %field_definition for simple fields:
+# select => 'SQL string',
+# col => 'name', # Name of the column returned by 'SQL string',
+# # if it does not match the $name of the field.
+# join => 'name', # This field requires a JOIN clause, refers to the 'joins' list above.
+# proc => sub {}, # Subroutine to do some formatting/processing of the value.
+# # $_[0] is the value as returned from the DB, should be modified in-place.
+#
+# %field_definition for nested 1-to-1 objects:
+# fields => {}, # Same as the parents' "fields" definitions.
+# # Can only be used to nest simple fields at a single level.
+# nullif => 'SQL string',
+# # The entire object itself is set to null if this SQL value is true.
+# # The SQL string must return a column named "${fieldname}_nullif}".
+#
+# %field_definition for nested 1-to-many objects:
+# enrich => sub { sql 'SELECT id', $_[0], 'FROM x', $_[1], 'WHERE id IN', $_[2] },
+# # Subroutine that returns an SQL statement
+# # $_[0] is the list of fields to fetch
+# # $_[1] is a list of JOIN clauses
+# # $_[2] is a list of identifiers to fetch
+# # $_[3] points to the request parameters
+# key => 'id', # $key argument to enrich()
+# col => 'id', # $merge_col argument to enrich()
+# select => 'SQL', # SQL to return $key, if it's not already part of the object.
+# # (The $key will then not be included in the output)
+# atmostone=> 1, # If this is a 1-to-[01] relation, removes the array in JSON output
+# # and sets the object to null if there's no result.
+# joins => {}, # Nested join definitions
+# fields => {}, # Nested field definitions
+# inherit => '/path'# Inherit joins+fields from another API.
+# proc => sub {} # Subroutine to do processing on the final value.
+# num => 1, # Estimate of the number of objects that will be returned.
+my %OBJS;
+sub api_query {
+ my($path, %opt) = @_;
+
+ $OBJS{$path} = \%opt;
+
+ my %sort = ($opt{sort}->@*, $opt{search} ? (searchrank => 'sc.score !o, sc.id, sc.subid') : ());
+ my $req_schema = tuwf->compile({ type => 'hash', unknown => 'reject', keys => {
+ filters => { advsearch => $opt{filters} },
+ fields => { default => {}, func => sub { parse_fields($opt{fields}, $_[0]) } },
+ sort => { default => $opt{sort}[0], enum => [ keys %sort ] },
+ reverse => { default => 0, jsonbool => 1 },
+ results => { default => 10, uint => 1, range => [0,100] },
+ page => { default => 1, uint => 1, range => [1,1e6] },
+ count => { default => 0, jsonbool => 1 },
+ user => { default => undef, vndbid => 'u' },
+ time => { default => 0, jsonbool => 1 },
+ compact_filters => { default => 0, jsonbool => 1 },
+ normalized_filters => { default => 0, jsonbool => 1 },
+ }});
+
+ TUWF::post qr{/api/kana\Q$path}, sub {
+ check_throttle;
+ tuwf->req->{advsearch_uid} = eval { tuwf->reqJSON->{user} };
+ my $req = tuwf->validate(json => $req_schema);
+ if(!$req) {
+ eval { $req->data }; warn $@;
+ my $err = $req->err;
+ if(!$err->{errors}) {
+ err 400, 'Missing request body.' if !$err->{keys};
+ err 400, "Unknown member '$err->{keys}[0]'." if $err->{keys};
+ }
+ $err = $err->{errors}[0]//{};
+ err 400, "Invalid '$err->{field}' filter: $err->{msg}." if $err->{key} eq 'filters' && $err->{msg} && $err->{field};
+ err 400, "Invalid '$err->{key}' member: $err->{msg}" if $err->{key} && $err->{msg};
+ err 400, "Invalid '$err->{key}' member." if $err->{key};
+ err 400, 'Invalid query.';
+ };
+ $req = $req->data;
+ $req->{user} //= auth->uid;
+
+ my $numfields = count_fields($opt{fields}, $req->{fields}, $req->{results});
+ err 400, sprintf 'Too much data selected (estimated %.0f fields)', $numfields if $numfields > 100_000;
+
+ my($filt, $searchquery) = $req->{sort} eq 'searchrank' ? $req->{filters}->extract_searchquery : ($req->{filters});
+ err 400, '"searchrank" sort is only available when the top-level filter is "search", or an "and" with at most one "search".'
+ if $req->{sort} eq 'searchrank' && !$searchquery;
+
+ my $sort = $sort{$req->{sort}};
+ my $order = $req->{reverse} ? 'DESC' : 'ASC';
+ my $opposite_order = $req->{reverse} ? 'ASC' : 'DESC';
+ $sort = $sort =~ /[?!]o/ ? ($sort =~ s/\?o/$order/rg =~ s/!o/$opposite_order/rg) : "$sort $order";
+
+ my($select, $joins) = prepare_fields($opt{fields}, $opt{joins}, $req->{fields});
+ $joins = sql $joins, $searchquery->sql_join($opt{search}->@*) if $searchquery;
+
+ my($results,$more,$count);
+ eval {
+ local $SIG{ALRM} = sub { die "Timeout\n"; };
+ alarm 3;
+ ($results, $more) = $req->{results} == 0 ? ([], 0) :
+ tuwf->dbPagei($req, $opt{sql}->($select, $joins, $filt->sql_where(), $req), 'ORDER BY', $sort);
+ $count = $req->{count} && (
+ !$more && $req->{results} && @$results <= $req->{results} ? ($req->{results}*($req->{page}-1))+@$results :
+ tuwf->dbVali('SELECT count(*) FROM (', $opt{sql}->('', '', $req->{filters}->sql_where), ') x')
+ );
+ proc_results($opt{fields}, $req->{fields}, $req, $results);
+ alarm 0;
+ 1;
+ } || do {
+ alarm 0;
+ err 500, 'Processing timeout' if $@ =~ /^Timeout/ || $@ =~ /canceling statement due to statement timeout/;
+ die $@;
+ };
+
+ tuwf->resJSON({
+ results => $results,
+ more => $more?\1:\0,
+ $req->{count} ? (count => $count) : (),
+ $req->{compact_filters} ? (compact_filters => $req->{filters}->query_encode) : (),
+ $req->{normalized_filters} ? (normalized_filters => $req->{filters}->json) : (),
+ $req->{time} ? (time => int(1000*(time() - tuwf->req->{throttle_start}))) : (),
+ });
+ cors;
+ count_request(scalar @$results, sprintf '[%s] {%s %s r%dp%d%s%s} %s', fmt_fields($req->{fields}),
+ $req->{sort}, lc($order), $req->{results}, $req->{page}, $req->{count}?'c':'', $req->{user}?" $req->{user}":'',
+ $req->{filters}->query_encode()||'-');
+ };
+}
+
+
+sub parse_fields {
+ my @tokens = split /\s*([,.{}])\s*/, $_[1];
+ $_[1] = {};
+ return (sub {
+ my($lvl, $f, $out) = @_;
+ my $nf = $f;
+ my $of = $out;
+ my $ln;
+ while(defined (my $t = shift @tokens)) {
+ next if !length $t;
+ if($t eq '}') {
+ return { msg => $ln ? "The '$ln' object requires specifying sub-field(s)." : "Expected (sub)field, got '}'" } if $nf;
+ return $lvl > 0 ? 1 : { msg => "Unmatched '}'" } ;
+ } elsif($t eq '{') {
+ return { msg => "Unexpected '{' after non-object field".($ln ? " '$ln'":'') } if !$nf;
+ my $r = __SUB__->($lvl+1, $nf, $of);
+ return $r if ref $r;
+ ($nf, $of, $ln) = ();
+ } elsif($t eq ',') {
+ return { msg => $ln ? "The '$ln' object requires specifying sub-field(s)." : 'Expected (sub)field, got comma' } if $nf;
+ ($nf, $of, $ln) = ($f, $out);
+ } else {
+ return { msg => $ln ? "Sub-field specified for non-object '$ln'" : 'Unexpected (sub)field after non-object field' } if !$nf;
+ if($t eq '.') {
+ $t = shift(@tokens) // return { msg => "Expected name after '.'" };
+ }
+ my $d = $nf->{$t} // return { msg => "Field '$t' not found", name => $t };
+ $ln = $t;
+ $nf = $d->{fields};
+ $of->{$t} ||= {};
+ $of = $of->{$t};
+ }
+ }
+ return { msg => "The '$ln' object requires specifying sub-field(s)." } if $nf;
+ return $lvl > 0 ? { msg => "Unmatched '{'" } : 1;
+ })->(0, $_[0], $_[1]);
+}
+
+sub fmt_fields {
+ (sub {
+ join ',', map $_ . (
+ keys $_[0]{$_}->%* == 0 ? '' :
+ keys $_[0]{$_}->%* == 1 ? '.'.__SUB__->($_[0]{$_}) : '{'.__SUB__->($_[0]{$_}).'}'
+ ), sort keys $_[0]->%*;
+ })->($_[0]);
+}
+
+
+# Calculate an estimate of how many fields will be returned in the response,
+# based on which fields are enabled.
+sub count_fields {
+ my($fields, $enabled, $num) = @_;
+ my $n = ($fields->{id} && !$enabled->{id} ? 1 : 0) + keys %$enabled;
+ $n += count_fields($fields->{$_}{fields}, $enabled->{$_}, $fields->{$_}{num})
+ for (grep $fields->{$_}{fields}, keys %$enabled);
+ $n * ($num // 1);
+}
+
+
+sub prepare_fields {
+ my($fields, $joins, $enabled) = @_;
+ my(@select, %join);
+ (sub {
+ for my $f (keys $_[1]->%*) {
+ my $d = $_[0]{$f};
+ $join{$d->{join}} = 1 if $d->{join};
+ push @select, $d->{select} if $d->{select};
+ push @select, $d->{nullif} if $d->{nullif};
+ push @select, sql_extlinks $d->{extlinks}, $d->{extlinks}.'.' if $d->{extlinks};
+ __SUB__->($d->{fields}, $_[1]{$f}) if $d->{fields} && !$d->{enrich};
+ }
+ })->($fields, $enabled);
+ return (
+ join('', map ",$_", @select),
+ join(' ', map $joins->{$_}, keys %join),
+ );
+}
+
+
+sub proc_field {
+ my($n, $d, $obj, $out) = @_;
+ $out->{$n} = delete $obj->{$d->{col}} if $d->{col};
+ $d->{proc}->($out->{$n}) if $d->{proc};
+}
+
+
+sub proc_results {
+ my($fields, $enabled, $req, $results) = @_;
+ for my $f (keys %$enabled) {
+ my $d = $fields->{$f};
+
+ # extlinks
+ if($d->{extlinks}) {
+ enrich_extlinks $d->{extlinks}, $enabled->{$f}, $results;
+ delete @{$_}{ keys $VNDB::ExtLinks::LINKS{$d->{extlinks}}->%* } for @$results;
+
+ # nested 1-to-many objects
+ } elsif($d->{enrich}) {
+ my($select, $join) = prepare_fields($d->{fields}, $d->{joins}, $enabled->{$f});
+ # DB::enrich() logic has been duplicated here to allow for
+ # efficient handling of nested proc_results() and `atmostone`.
+ my %ids = map defined($_->{$d->{key}}) ? ($_->{$d->{key}},[]) : (), @$results;
+ my $rows = keys %ids ? tuwf->dbAlli($d->{enrich}->($select, $join, [keys %ids], $req)) : [];
+ proc_results($d->{fields}, $enabled->{$f}, $req, $rows);
+ push $ids{ delete $_->{$d->{col}} }->@*, $_ for @$rows;
+ if($d->{atmostone}) {
+ if($d->{select}) { $_->{$f} = $ids{ delete $_->{$d->{key}} // '' }[0] for @$results }
+ else { $_->{$f} = $ids{ $_->{$d->{key}} // '' }[0] for @$results }
+ } else {
+ if($d->{select}) { $_->{$f} = $ids{ delete $_->{$d->{key}} // '' }||[] for @$results }
+ else { $_->{$f} = $ids{ $_->{$d->{key}} // '' }||[] for @$results }
+ }
+ $d->{proc}->($_->{$f}) for $d->{proc} ? @$results : ();
+
+ # nested 1-to-1 objects
+ } elsif($d->{fields}) {
+ for my $o (@$results) {
+ if($d->{nullif} && delete $o->{"${f}_nullif"}) {
+ $o->{$f} = undef;
+ delete $o->{ $d->{fields}{$_}{col}||$_ } for keys $enabled->{$f}->%*;
+ } else {
+ $o->{$f} = {};
+ proc_field($_, $d->{fields}{$_}, $o, $o->{$f}) for keys $enabled->{$f}->%*;
+ }
+ }
+
+ # simple fields
+ } else {
+ proc_field($f, $d, $_, $_) for @$results;
+ }
+ }
+}
+
+
+api_get '/schema', {}, sub {
+ my sub el {
+ my $l = $VNDB::ExtLinks::LINKS{$_[0]};
+ [ map +{ name => $_ =~ s/^l_//r, label => $l->{$_}{label}, url_format => $l->{$_}{fmt} },
+ grep $l->{$_}{regex}, keys %$l ]
+ }
+ state $s = {
+ enums => {
+ language => [ map +{ id => $_, label => $LANGUAGE{$_}{txt} }, keys %LANGUAGE ],
+ platform => [ map +{ id => $_, label => $PLATFORM{$_} }, keys %PLATFORM ],
+ medium => [ map +{ id => $_, label => $MEDIUM{$_}{txt}, plural => $MEDIUM{$_}{plural}||undef }, keys %MEDIUM ],
+ staff_role => [ map +{ id => $_, label => $CREDIT_TYPE{$_} }, keys %CREDIT_TYPE ],
+ },
+ api_fields => { map +($_, (sub {
+ +{ map {
+ my $f = $_[0]{$_};
+ my $s = $f->{fields} ? __SUB__->($f->{fields}, $f->{inherit} ? $OBJS{$f->{inherit}}{fields} : {}) : {};
+ $s->{_inherit} = $f->{inherit} if $f->{inherit};
+ ($_, keys %$s ? $s : undef)
+ } grep !$_[1]{$_}, keys $_[0]->%* }
+ })->($OBJS{$_}{fields}, {})), keys %OBJS },
+ extlinks => {
+ '/release' => el('r'),
+ '/staff' => el('s'),
+ },
+ }
+};
+
+
+my @STATS = qw{traits producers tags chars staff vn releases};
+api_get '/stats', { map +($_, { uint => 1 }), @STATS }, sub {
+ +{ map +($_->{section}, $_->{count}),
+ tuwf->dbAlli('SELECT * FROM stats_cache WHERE section IN', \@STATS)->@* };
+};
+
+
+api_get '/authinfo', {}, sub {
+ err 401, 'Unauthorized' if !auth;
+ +{
+ id => auth->uid,
+ username => auth->user->{user_name},
+ permissions => [
+ auth->api2Listread ? 'listread' : (),
+ auth->api2Listwrite ? 'listwrite' : (),
+ ]
+ }
+};
+
+
+api_get '/user', {}, sub {
+ my $data = tuwf->validate(get =>
+ q => { type => 'array', scalar => 1, maxlength => 100, values => {} },
+ fields => { fields => ['lengthvotes', 'lengthvotes_sum'] },
+ );
+ err 400, 'Invalid argument' if !$data;
+ my ($q, $f) = @{ $data->data }{qw{ q fields }};
+ my $regex = '^u[1-9][0-9]{0,6}$';
+ +{ map +(delete $_->{q}, $_->{id} ? $_ : undef), tuwf->dbAlli('
+ WITH u AS (
+ SELECT x.q, u.id, u.username
+ FROM unnest(', sql_array(@$q), ') x(q)
+ LEFT JOIN users u ON u.id = CASE WHEN x.q ~', \$regex, 'THEN x.q::vndbid ELSE NULL END
+ OR LOWER(u.username) = LOWER(x.q)
+ ) SELECT u.*',
+ $f->{lengthvotes} ? ', coalesce(l.count,0) AS lengthvotes' : (),
+ $f->{lengthvotes_sum} ? ', coalesce(l.sum,0) AS lengthvotes_sum' : (),
+ 'FROM u',
+ $f->{lengthvotes} || $f->{lengthvotes_sum} ? ('LEFT JOIN (
+ SELECT uid, count(*) AS count, sum(length) AS sum
+ FROM vn_length_votes
+ WHERE uid IN(SELECT id FROM u)
+ GROUP BY uid
+ ) l ON l.uid = u.id'
+ ) : (),
+ )->@* }
+};
+
+
+api_get '/ulist_labels', { labels => { aoh => {
+ id => { uint => 1 },
+ private => { anybool => 1 },
+ label => {},
+}}}, sub {
+ my $data = tuwf->validate(get =>
+ user => { vndbid => 'u', default => auth->uid||\'required' },
+ fields => { default => undef, enum => ['count'] },
+ );
+ err 400, 'Invalid argument' if !$data;
+ $data = $data->data;
+ +{ labels => ulist_filtlabels $data->{user}, $data->{fields} };
+};
+
+
+api_patch qr{/ulist/$RE{vid}}, {
+ vote => { uint => 1, range => [10,100] },
+ notes => { default => '', maxlength => 2000 },
+ started => { caldate => 1 },
+ finished => { caldate => 1 },
+ labels => { default => [], type => 'array', values => { uint => 1, range => [1,1600] } },
+ labels_set => { default => [], type => 'array', values => { uint => 1, range => [1,1600] } },
+ labels_unset => { default => [], type => 'array', values => { uint => 1, range => [1,1600] } },
+}, sub {
+ my($upd) = @_;
+ my $vid = tuwf->capture('id');
+ err 401, 'Unauthorized' if !auth->api2Listwrite;
+ err 404, 'Visual novel not found' if !tuwf->dbExeci('SELECT 1 FROM vn WHERE NOT hidden AND id =', \$vid);
+
+ my $newlabels = sql "'{}'::smallint[]";
+ if($upd->{labels} || $upd->{labels_set} || $upd->{labels_unset}) {
+ my @all = $upd->{labels} ? $upd->{labels}->@* : ();
+ my @set = $upd->{labels_set} ? $upd->{labels_set}->@* : ();
+ my @unset = $upd->{labels_unset} ? $upd->{labels_unset}->@* : ();
+ my %labels = map +($_, 1), @all, @set;
+ delete $labels{$_} for @unset;
+ err 400, 'Label id 7 cannot be used here' if $labels{7} || grep $_ == 7, @unset;
+
+ $upd->{labels} = $upd->{labels} ? sql(sql_array(sort { $a <=> $b } keys %labels),'::smallint[]') : do {
+ my $l = 'ulist_vns.labels';
+ $l = sql 'array_set(', $l, ',', \(0+$_), ')' for @set;
+ $l = sql 'array_remove(', $l, ',', \(0+$_), ')' for @unset;
+ $l
+ };
+
+ delete $upd->{labels_set};
+ delete $upd->{labels_unset};
+ $newlabels = sql(sql_array(sort { $a <=> $b } keys %labels),'::smallint[]');
+ }
+ $upd->{lastmod} = sql 'NOW()';
+ $upd->{vote_date} = sql $upd->{vote} ? 'CASE WHEN ulist_vns.vote IS NULL THEN NOW() ELSE ulist_vns.vote_date END' : 'NULL'
+ if exists $upd->{vote};
+
+ my $done = tuwf->dbExeci(
+ 'INSERT INTO ulist_vns', { %$upd,
+ labels => $newlabels,
+ vote_date => sql($upd->{vote} ? 'NOW()' : 'NULL'),
+ uid => auth->uid,
+ vid => $vid
+ },
+ 'ON CONFLICT (uid, vid) DO', keys %$upd ? ('UPDATE SET', $upd) : 'NOTHING'
+ );
+ if($done > 0) {
+ tuwf->dbExeci(SELECT => sql_func update_users_ulist_private => \auth->uid, \$vid);
+ tuwf->dbExeci(SELECT => sql_func update_users_ulist_stats => \auth->uid);
+ }
+};
+
+
+api_patch qr{/rlist/$RE{rid}}, {
+ status => { uint => 1, default => 0, enum => \%RLIST_STATUS },
+}, sub {
+ my($upd) = @_;
+ my $rid = tuwf->capture('id');
+ err 401, 'Unauthorized' if !auth->api2Listwrite;
+ err 404, 'Release not found' if !tuwf->dbExeci('SELECT 1 FROM releases WHERE NOT hidden AND id =', \$rid);
+ tuwf->dbExeci(
+ 'INSERT INTO rlists', { %$upd, uid => auth->uid, rid => $rid },
+ 'ON CONFLICT (uid, rid) DO', keys %$upd ? ('UPDATE SET', $upd) : 'NOTHING'
+ );
+};
+
+
+api_del qr{/ulist/$RE{vid}}, sub {
+ err 401, 'Unauthorized' if !auth->api2Listwrite;
+ tuwf->dbExeci('DELETE FROM ulist_vns WHERE uid =', \auth->uid, 'AND vid =', \tuwf->capture('id'));
+ tuwf->dbExeci(SELECT => sql_func update_users_ulist_stats => \auth->uid);
+};
+
+
+api_del qr{/rlist/$RE{rid}}, sub {
+ err 401, 'Unauthorized' if !auth->api2Listwrite;
+ tuwf->dbExeci('DELETE FROM rlists WHERE uid =', \auth->uid, 'AND rid =', \tuwf->capture('id'));
+};
+
+
+
+my @BOOL = (proc => sub { $_[0] = $_[0] ? \1 : \0 if defined $_[0] });
+my @INT = (proc => sub { $_[0] *= 1 if defined $_[0] }); # Generally unnecessary, DBD::Pg does this already
+my @RDATE = (proc => sub { $_[0] = $_[0] ? rdate $_[0] : undef });
+my @NSTR = (proc => sub { $_[0] = undef if !length $_[0] }); # Empty string -> null
+my @MSTR = (proc => sub { $_[0] = [ grep length($_), split /\n/, $_[0] ] }); # Multiline string -> array
+my @NINT = (proc => sub { $_[0] = $_[0] ? $_[0]*1 : undef }); # 0 -> null
+
+sub IMG {
+ my($main_col, $join_id, $join_prefix) = @_;
+ return (
+ id => { select => "$main_col AS image_id", col => 'image_id' },
+ url => { select => "$main_col AS image_url", col => 'image_url', proc => sub { $_[0] = imgurl $_[0] } },
+ dims => { join => $join_id, col => 'image_dims', select => "ARRAY[${join_prefix}width, ${join_prefix}height] AS image_dims" },
+ sexual => { join => $join_id, select => "${join_prefix}c_sexual_avg::real/100 AS image_sexual", col => 'image_sexual' },
+ violence => { join => $join_id, select => "${join_prefix}c_violence_avg::real/100 AS image_violence", col => 'image_violence' },
+ votecount => { join => $join_id, select => "${join_prefix}c_votecount AS image_votecount", col => 'image_votecount' },
+ );
+}
+
+# Extracts the alttitle from a 'vnt.titles'-like array column, returns null if equivalent to the main title.
+sub ALTTITLE { my($t,$col) = @_; +(select => "CASE WHEN $t"."[1+1] = $t"."[1+1+1+1] THEN NULL ELSE $t"."[1+1+1+1] END AS ".($col // 'alttitle')) }
+
+
+api_query '/vn',
+ filters => 'v',
+ sql => sub { sql 'SELECT v.id', $_[0], 'FROM vnt v', $_[1], 'WHERE NOT v.hidden AND (', $_[2], ')' },
+ joins => {
+ image => 'LEFT JOIN images i ON i.id = v.image',
+ },
+ search => [ 'v', 'v.id' ],
+ fields => {
+ id => {},
+ title => { select => 'v.title[1+1]' },
+ alttitle => { ALTTITLE 'v.title' },
+ titles => {
+ enrich => sub { sql 'SELECT vt.id', $_[0], 'FROM vn_titles vt', $_[1], 'WHERE vt.id IN', $_[2] },
+ key => 'id', col => 'id', num => 3,
+ joins => {
+ main => 'JOIN vn v ON v.id = vt.id',
+ },
+ fields => {
+ lang => { select => 'vt.lang' },
+ title => { select => 'vt.title' },
+ latin => { select => 'vt.latin' },
+ official => { select => 'vt.official', @BOOL },
+ main => { join => 'main', select => 'vt.lang = v.olang AS main', @BOOL },
+ },
+ },
+ aliases => { select => 'v.alias AS aliases', @MSTR },
+ olang => { select => 'v.olang' },
+ devstatus => { select => 'v.devstatus' },
+ released => { select => 'v.c_released AS released', @RDATE },
+ languages => { select => 'v.c_languages::text[] AS languages' },
+ platforms => { select => 'v.c_platforms::text[] AS platforms' },
+ image => {
+ fields => { IMG 'v.image', 'image', 'i.' },
+ nullif => 'v.image IS NULL AS image_nullif',
+ },
+ length => { select => 'v.length', proc => sub { $_[0] = undef if !$_[0] } },
+ length_minutes => { select => 'v.c_length AS length_minutes' },
+ length_votes => { select => 'v.c_lengthnum AS length_votes' },
+ description => { select => 'v.description', @NSTR },
+ rating => { select => 'v.c_rating AS rating', proc => sub { $_[0] /= 10 if defined $_[0] } },
+ popularity => { select => 'v.c_votecount AS popularity', proc => sub { $_[0] = min(100, $_[0]/150) if defined $_[0] } },
+ votecount => { select => 'v.c_votecount AS votecount' },
+ screenshots => {
+ enrich => sub { sql 'SELECT vs.id AS vid', $_[0], 'FROM vn_screenshots vs', $_[1], 'WHERE vs.id IN', $_[2] },
+ key => 'id', col => 'vid', num => 10,
+ joins => {
+ image => 'JOIN images i ON i.id = vs.scr',
+ },
+ fields => {
+ IMG('vs.scr', 'image', 'i.'),
+ thumbnail => { select => "vs.scr AS thumbnail", col => 'thumbnail', proc => sub { $_[0] = imgurl $_[0], 't' } },
+ thumbnail_dims => { join => 'image', col => 'thumbnail_dims'
+ , select => "ARRAY[i.width, i.height] AS thumbnail_dims"
+ , proc => sub { @{$_[0]} = imgsize @{$_[0]}, config->{scr_size}->@* } },
+ release => {
+ select => 'vs.rid AS screen_rid',
+ enrich => sub { sql 'SELECT r.id AS screen_rid, r.id', $_[0], 'FROM releasest r', $_[1], 'WHERE NOT r.hidden AND r.id IN', $_[2] },
+ key => 'screen_rid', col => 'screen_rid', atmostone => 1,
+ inherit => '/release',
+ }
+ },
+ },
+ relations => {
+ enrich => sub { sql 'SELECT vr.id AS vid, v.id', $_[0], 'FROM vn_relations vr JOIN vnt v ON v.id = vr.vid', $_[1], 'WHERE vr.id IN', $_[2] },
+ key => 'id', col => 'vid', num => 3,
+ inherit => '/vn',
+ fields => {
+ relation => { select => 'vr.relation' },
+ relation_official => { select => 'vr.official AS relation_official', @BOOL },
+ },
+ },
+ tags => {
+ enrich => sub { sql 'SELECT tv.vid, t.id', $_[0], 'FROM tags_vn_direct tv JOIN tags t ON t.id = tv.tag', $_[1], 'WHERE NOT t.hidden AND tv.vid IN', $_[2] },
+ key => 'id', col => 'vid', num => 50,
+ inherit => '/tag',
+ fields => {
+ rating => { select => 'tv.rating' },
+ spoiler => { select => 'tv.spoiler' },
+ lie => { select => 'tv.lie', @BOOL },
+ },
+ },
+ developers => {
+ enrich => sub { sql 'SELECT v.id AS vid, p.id', $_[0], 'FROM vn v, unnest(v.c_developers) vp(id), producerst p', $_[1], 'WHERE p.id = vp.id AND v.id IN', $_[2] },
+ key => 'id', col => 'vid', num => 2,
+ inherit => '/producer',
+ },
+ editions => {
+ enrich => sub { sql 'SELECT id', $_[0], 'FROM vn_editions WHERE id IN', $_[2] },
+ key => 'id', col => 'id', num => 3,
+ fields => {
+ eid => { select => 'eid' },
+ lang => { select => 'lang' },
+ name => { select => 'name' },
+ official => { select => 'official', @BOOL },
+ },
+ },
+ staff => {
+ enrich => sub { sql 'SELECT vs.id AS vid, s.id', $_[0], 'FROM vn_staff vs JOIN staff_aliast s ON s.aid = vs.aid', $_[1], 'WHERE NOT s.hidden AND vs.id IN', $_[2] },
+ key => 'id', col => 'vid', num => 20,
+ inherit => '/staff',
+ fields => {
+ eid => { select => 'vs.eid' },
+ role => { select => 'vs.role' },
+ note => { select => 'vs.note', @NSTR },
+ },
+ }
+ },
+ sort => [
+ id => 'v.id',
+ title => 'v.sorttitle ?o, v.id',
+ released => 'v.c_released ?o, v.id',
+ popularity => 'v.c_pop_rank !o NULLS LAST, v.id',
+ rating => 'v.c_rat_rank !o NULLS LAST, v.id',
+ votecount => 'v.c_votecount ?o, v.id',
+ ];
+
+
+api_query '/release',
+ filters => 'r',
+ sql => sub { sql 'SELECT r.id', $_[0], 'FROM releasest r', $_[1], 'WHERE NOT r.hidden AND (', $_[2], ')' },
+ search => [ 'r', 'r.id' ],
+ fields => {
+ id => {},
+ title => { select => 'r.title[1+1]' },
+ alttitle => { ALTTITLE 'r.title' },
+ languages => {
+ enrich => sub { sql 'SELECT rt.id', $_[0], 'FROM releases_titles rt', $_[1], 'WHERE rt.id IN', $_[2] },
+ key => 'id', col => 'id', num => 3,
+ joins => {
+ main => 'JOIN releases r ON r.id = rt.id',
+ },
+ fields => {
+ lang => { select => 'rt.lang' },
+ title => { select => 'rt.title' },
+ latin => { select => 'rt.latin' },
+ mtl => { select => 'rt.mtl', @BOOL },
+ main => { join => 'main', select => 'rt.lang = r.olang AS main', @BOOL },
+ },
+ },
+ platforms => {
+ enrich => sub { sql 'SELECT id, platform FROM releases_platforms WHERE id IN', $_[2] },
+ key => 'id', col => 'id', proc => sub { $_[0] = [ map $_->{platform}, $_[0]->@* ] },
+ },
+ media => {
+ enrich => sub { sql 'SELECT id', $_[0], 'FROM releases_media WHERE id IN', $_[2] },
+ key => 'id', col => 'id', num => 3,
+ fields => {
+ medium => { select => 'medium' },
+ qty => { select => 'qty' },
+ },
+ },
+ vns => {
+ enrich => sub { sql 'SELECT rv.id AS rid, v.id', $_[0], 'FROM releases_vn rv JOIN vnt v ON v.id = rv.vid', $_[1], 'WHERE rv.id IN', $_[2] },
+ key => 'id', col => 'rid', num => 3,
+ inherit => '/vn',
+ fields => {
+ rtype => { select => 'rv.rtype' },
+ },
+ },
+ producers => {
+ enrich => sub { sql 'SELECT rp.id AS rid, p.id', $_[0], 'FROM releases_producers rp JOIN producerst p ON p.id = rp.pid', $_[1], 'WHERE rp.id IN', $_[2] },
+ key => 'id', col => 'rid', num => 3,
+ inherit => '/producer',
+ fields => {
+ developer => { select => 'rp.developer', @BOOL },
+ publisher => { select => 'rp.publisher', @BOOL },
+ },
+ },
+ released => { select => 'r.released', @RDATE },
+ minage => { select => 'r.minage' },
+ patch => { select => 'r.patch', @BOOL },
+ freeware => { select => 'r.freeware', @BOOL },
+ uncensored => { select => 'r.uncensored', @BOOL },
+ official => { select => 'r.official', @BOOL },
+ has_ero => { select => 'r.has_ero', @BOOL },
+ resolution => { select => 'ARRAY[r.reso_x,r.reso_y] AS resolution'
+ , proc => sub { $_[0] = $_[0][1] == 0 ? undef : 'non-standard' if $_[0][0] == 0 } },
+ engine => { select => 'r.engine', @NSTR },
+ voiced => { select => 'r.voiced', @NINT },
+ notes => { select => 'r.notes', @NSTR },
+ gtin => { select => 'r.gtin', proc => sub { $_[0] = undef if !gtintype $_[0] } },
+ catalog => { select => 'r.catalog', @NSTR },
+ extlinks => { extlinks => 'r' },
+ },
+ sort => [
+ id => 'r.id',
+ title => 'r.sorttitle ?o, r.id',
+ released => 'r.released ?o, r.id',
+ ];
+
+
+api_query '/producer',
+ filters => 'p',
+ sql => sub { sql 'SELECT p.id', $_[0], 'FROM producerst p', $_[1], 'WHERE NOT p.hidden AND (', $_[2], ')' },
+ search => [ 'p', 'p.id' ],
+ fields => {
+ id => {},
+ name => { select => 'p.title[1+1] AS name' },
+ original => { ALTTITLE 'p.title', 'original' },
+ aliases => { select => 'p.alias AS aliases', @MSTR },
+ lang => { select => 'p.lang' },
+ type => { select => 'p.type' },
+ description => { select => 'p.description', @NSTR },
+ },
+ sort => [
+ id => 'p.id',
+ name => 'p.sorttitle ?o, p.id',
+ ];
+
+
+api_query '/character',
+ filters => 'c',
+ sql => sub { sql 'SELECT c.id', $_[0], 'FROM charst c', $_[1], 'WHERE NOT c.hidden AND (', $_[2], ')' },
+ search => [ 'c', 'c.id' ],
+ joins => {
+ image => 'LEFT JOIN images i ON i.id = c.image',
+ },
+ fields => {
+ id => {},
+ name => { select => 'c.title[1+1] AS name' },
+ original => { ALTTITLE 'c.title', 'original' },
+ aliases => { select => 'c.alias AS aliases', @MSTR },
+ description => { select => 'c.description', @NSTR },
+ image => {
+ fields => { IMG 'c.image', 'image', 'i.' },
+ nullif => 'c.image IS NULL AS image_nullif',
+ },
+ blood_type => { select => 'c.bloodt AS blood_type', proc => sub { $_[0] = undef if $_[0] eq 'unknown' } },
+ height => { select => 'c.height', @NINT },
+ weight => { select => 'c.weight' },
+ bust => { select => 'c.s_bust AS bust', @NINT },
+ waist => { select => 'c.s_waist AS waist', @NINT },
+ hips => { select => 'c.s_hip AS hips', @NINT },
+ cup => { select => 'c.cup_size AS cup', @NSTR },
+ age => { select => 'c.age' },
+ birthday => { select => 'CASE WHEN c.b_month = 0 THEN NULL ELSE ARRAY[c.b_month, NULLIF(c.b_day, 0)]::int[] END AS birthday' },
+ sex => { select => "NULLIF(ARRAY[NULLIF(c.gender, 'unknown'), NULLIF(COALESCE(c.spoil_gender, c.gender), 'unknown')]::text[], '{NULL,NULL}') AS sex" },
+ vns => {
+ enrich => sub { sql 'SELECT cv.id AS cid, v.id', $_[0], 'FROM chars_vns cv JOIN vnt v ON v.id = cv.vid', $_[1], 'WHERE NOT v.hidden AND cv.id IN', $_[2] },
+ key => 'id', col => 'cid', num => 3,
+ inherit => '/vn',
+ fields => {
+ spoiler => { select => 'cv.spoil AS spoiler' },
+ role => { select => 'cv.role' },
+ release => {
+ select => 'cv.rid',
+ enrich => sub { sql 'SELECT r.id AS rid, r.id', $_[0], 'FROM releasest r', $_[1], 'WHERE NOT r.hidden AND r.id IN', $_[2] },
+ key => 'rid', col => 'rid', atmostone => 1,
+ inherit => '/release',
+ }
+ },
+ },
+ traits => {
+ enrich => sub { sql 'SELECT ct.id AS cid, t.id', $_[0], 'FROM chars_traits ct JOIN traits t ON t.id = ct.tid', $_[1], 'WHERE NOT t.hidden AND ct.id IN', $_[2] },
+ key => 'id', col => 'cid', num => 30,
+ inherit => '/trait',
+ fields => {
+ spoiler => { select => 'ct.spoil AS spoiler' },
+ lie => { select => 'ct.lie', @BOOL },
+ },
+ },
+ },
+ sort => [
+ id => 'c.id',
+ name => 'c.name ?o, c.id',
+ ];
+
+
+api_query '/staff',
+ filters => 's',
+ sql => sub { sql 'SELECT s.id', $_[0], 'FROM staff_aliast s', $_[1], 'WHERE NOT s.hidden AND (', $_[2], ')' },
+ search => [ 's', 's.id', 's.aid' ],
+ fields => {
+ id => {},
+ aid => { select => 's.aid' },
+ ismain => { select => 's.main = s.aid AS ismain', @BOOL },
+ name => { select => 's.title[1+1] AS name' },
+ original => { ALTTITLE 's.title', 'original' },
+ lang => { select => 's.lang' },
+ gender => { select => "NULLIF(s.gender, 'unknown') AS gender" },
+ description => { select => 's.description', @NSTR },
+ extlinks => { extlinks => 's' },
+ aliases => {
+ enrich => sub { sql 'SELECT sa.id', $_[0], 'FROM staff_alias sa', $_[1], 'WHERE sa.id IN', $_[2] },
+ key => 'id', col => 'id', num => 3,
+ joins => {
+ main => 'JOIN staff s ON s.id = sa.id',
+ },
+ fields => {
+ aid => { select => 'sa.aid' },
+ name => { select => 'sa.name' },
+ latin => { select => 'sa.latin' },
+ ismain => { join => 'main', select => 'sa.aid = s.main AS ismain', @BOOL },
+ },
+ },
+ },
+ sort => [
+ id => 's.id',
+ name => 's.sorttitle ?o, s.id',
+ ];
+
+
+api_query '/tag',
+ filters => 'g',
+ sql => sub { sql 'SELECT t.id', $_[0], 'FROM tags t', $_[1], 'WHERE NOT t.hidden AND (', $_[2], ')' },
+ search => [ 'g', 't.id' ],
+ fields => {
+ id => {},
+ name => { select => 't.name' },
+ aliases => { select => 't.alias AS aliases', @MSTR },
+ description => { select => 't.description' },
+ category => { select => 't.cat AS category' },
+ searchable => { select => 't.searchable', @BOOL },
+ applicable => { select => 't.applicable', @BOOL },
+ vn_count => { select => 't.c_items AS vn_count' },
+ },
+ sort => [
+ id => 't.id',
+ name => 't.name',
+ vn_count => 't.c_items ?o, t.id',
+ ];
+
+
+api_query '/trait',
+ filters => 'i',
+ sql => sub { sql 'SELECT t.id', $_[0], 'FROM traits t', $_[1], 'WHERE NOT t.hidden AND (', $_[2], ')' },
+ search => [ 'i', 't.id' ],
+ joins => {
+ group => 'LEFT JOIN traits g ON g.id = t.gid',
+ },
+ fields => {
+ id => {},
+ name => { select => 't.name' },
+ aliases => { select => 't.alias AS aliases', @MSTR },
+ description => { select => 't.description' },
+ searchable => { select => 't.searchable', @BOOL },
+ applicable => { select => 't.applicable', @BOOL },
+ group_id => { join => 'group', select => 't.gid AS group_id' },
+ group_name => { join => 'group', select => 'g.name AS group_name' },
+ char_count => { select => 't.c_items AS char_count' },
+ },
+ sort => [
+ id => 't.id',
+ name => 't.name ?o, t.id',
+ char_count => 't.c_items ?o, t.id',
+ ];
+
+
+api_query '/ulist',
+ filters => 'v',
+ sql => sub {
+ err 400, 'Missing "user" parameter and not authenticated.' if !$_[3]{user};
+ sql 'SELECT v.id', $_[0], '
+ FROM ulist_vns uv
+ JOIN vnt v ON v.id = uv.vid', $_[1], '
+ WHERE', sql_and
+ 'NOT v.hidden',
+ sql('uv.uid =', \$_[3]{user}),
+ auth->api2Listread($_[3]{user}) ? () : 'NOT uv.c_private',
+ $_[2];
+ },
+ search => [ 'v', 'v.id' ],
+ fields => {
+ id => {},
+ added => { select => "extract('epoch' from uv.added)::bigint AS added" },
+ lastmod => { select => "extract('epoch' from uv.lastmod)::bigint AS lastmod" },
+ voted => { select => "extract('epoch' from uv.vote_date)::bigint AS voted" },
+ vote => { select => 'uv.vote' },
+ started => { select => 'uv.started' },
+ finished => { select => 'uv.finished' },
+ notes => { select => 'uv.notes', @NSTR },
+ labels => {
+ enrich => sub { sql 'SELECT uv.vid', $_[0], '
+ FROM ulist_vns uv, unnest(uv.labels) l(id), ulist_labels ul
+ WHERE', sql_and
+ sql('uv.uid =', \$_[3]{user}),
+ sql('ul.uid =', \$_[3]{user}),
+ 'ul.id = l.id',
+ auth->api2Listread($_[3]{user}) ? () : 'NOT ul.private',
+ sql('uv.vid IN', $_[2]) },
+ key => 'id', col => 'vid', num => 3,
+ fields => {
+ id => { select => 'l.id' },
+ label => { select => 'ul.label' },
+ },
+ },
+ vn => {
+ enrich => sub { sql 'SELECT v.id', $_[0], 'FROM vnt v', $_[1], 'WHERE v.id IN', $_[2] },
+ key => 'id', col => 'id', atmostone => 1, inherit => '/vn',
+ },
+ releases => {
+ enrich => sub { sql 'SELECT irv.vid, r.id', $_[0], '
+ FROM rlists rl
+ JOIN releasest r ON rl.rid = r.id', $_[1], '
+ JOIN (SELECT DISTINCT id, vid FROM releases_vn rv WHERE rv.vid IN', $_[2], ') AS irv(id,vid) ON rl.rid = irv.id
+ WHERE NOT r.hidden
+ AND rl.uid =', \$_[3]{user} },
+ key => 'id', col => 'vid', num => 3, inherit => '/release',
+ fields => {
+ list_status => { select => 'rl.status AS list_status' },
+ },
+ },
+ },
+ sort => [
+ id => 'v.id',
+ title => 'v.sorttitle ?o, v.id',
+ released => 'v.c_released ?o, v.id',
+ popularity => 'v.c_pop_rank !o NULLS LAST, v.id',
+ rating => 'v.c_rat_rank !o NULLS LAST, v.id',
+ votecount => 'v.c_votecount ?o, v.id',
+ voted => 'uv.vote_date ?o, v.id',
+ vote => 'uv.vote ?o, v.id',
+ added => 'uv.added',
+ lastmod => 'uv.lastmod',
+ started => 'uv.started ?o, v.id',
+ finished => 'uv.finished ?o, v.id',
+ ];
+
+
+
+
+
+# Now that all APIs have been defined, go over the definitions and:
+# - Resolve 'inherit' fields
+# - Expand 'extlinks' fields
+(sub {
+ for my $f (values $_[0]->%*) {
+ if($f->{inherit}) {
+ my $o = $OBJS{$f->{inherit}};
+ $f->{fields}{$_} = $o->{fields}{$_} for keys %{ $o->{fields}||{} };
+ $f->{joins}{$_} = $o->{joins}{$_} for keys %{ $o->{joins}||{} };
+ }
+ $f->{fields} ||= { map +($_,{}), qw{name label id url} } if $f->{extlinks};
+ __SUB__->($f->{fields}) if $f->{fields} && !$f->{_expand_done}++;
+ }
+})->($_->{fields}) for values %OBJS;
+
+1;
diff --git a/lib/VNWeb/AdvSearch.pm b/lib/VNWeb/AdvSearch.pm
new file mode 100644
index 00000000..6f226b7f
--- /dev/null
+++ b/lib/VNWeb/AdvSearch.pm
@@ -0,0 +1,963 @@
+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 List::Util 'max';
+use TUWF ':html5_';
+use VNWeb::Auth;
+use VNWeb::DB;
+use VNWeb::Validation;
+use VNWeb::HTML ();
+use VNDB::Types;
+use VNDB::ExtLinks ();
+use Exporter 'import';
+our @EXPORT = qw/advsearch_default/;
+
+
+
+# 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 _substr { $_[1]+$_[2] <= length $_[0] ? substr $_[0], $_[1], $_[2] : undef }
+
+sub _dec_int {
+ my($s, $i) = @_;
+ my $c1 = ($alpha{_substr($s, $$i++, 1) // return} // return);
+ return $c1 if $c1 < 49;
+ my $n = ($alpha{_substr($s, $$i++, 1) // return} // return);
+ return 49 + ($c1-49)*64 + $n if $c1 < 59;
+ $n = $n*64 + ($alpha{_substr($s, $$i++, 1) // return} // 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) // return; $$i += 2; $v } :
+ $type == 3 ? do { my $v = _unescape_str(_substr($s, $$i, 3) // return) // 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.
+# sql_list -> Alternative to the '=' and '!=' $op definitions to optimize lists of (in)equality queries.
+# sql() generation function that is called with the following arguments:
+# - negate, 1/0 - whether the entire query should be negated
+# - all, 1/0 - whether all values must match, 1=all, 0=any
+# - arrayref of values to compare for equality
+# sql_list_grp -> When using sql_list, a subroutine that returns a grouping identifier for the given value.
+# Only values with the same group identifier will be given to a single sql_list call.
+# May return to disable sql_list support for specific values.
+# 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.
+our(%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 { $f{sql_list}->(0,0,[$_]) } if !$f{'='} && $f{sql_list};
+ $f{'!='} = sub { $f{sql_list}->(1,0,[$_]) } if !$f{'!='} && $f{sql_list};
+ $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;
+ die "Duplicate number $num for $t\n" if $NUMFIELDS{$t}{$num};
+ $NUMFIELDS{$t}{$num} = $n;
+}
+
+my @TYPE; # stack of query types, $TYPE[0] is the top-level query, $TYPE[$#TYPE] the query currently being processed.
+
+
+f v => 80 => 'id', { vndbid => 'v' }, sql => sub { sql 'v.id', $_[0], \$_ };
+f v => 81 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('v', 'v.id') };
+f v => 2 => 'lang', { enum => \%LANGUAGE }, '=' => sub { sql 'v.c_languages && ARRAY', \$_, '::language[]' };
+f v => 3 => 'olang', { enum => \%LANGUAGE }, '=' => sub { sql 'v.olang =', \$_ };
+f v => 4 => 'platform', { enum => \%PLATFORM }, '=' => sub { sql 'v.c_platforms && ARRAY', \$_, '::platform[]' };
+f v => 5 => 'length', { uint => 1, enum => \%VN_LENGTH },
+ '=' => sub { sql 'COALESCE(v.c_length BETWEEN', \$VN_LENGTH{$_}{low}, 'AND', \$VN_LENGTH{$_}{high}, ', 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_votecount', $_[0], \($_*150) }; # XXX: Deprecated
+f v => 10 => 'rating', { uint => 1, range => [10, 100] }, sql => sub { sql 'v.c_rating', $_[0], \($_*10) };
+f v => 11 => 'votecount', { uint => 1, range => [ 0,1<<30] }, sql => sub { sql 'v.c_votecount', $_[0], \$_ };
+f v => 61 => 'has_description', { uint => 1, range => [1,1] }, '=' => sub { 'v.description <> \'\'' };
+f v => 62 => 'has_anime', { uint => 1, range => [1,1] }, '=' => sub { 'EXISTS(SELECT 1 FROM vn_anime va WHERE va.id = v.id)' };
+f v => 63 => 'has_screenshot', { uint => 1, range => [1,1] }, '=' => sub { 'EXISTS(SELECT 1 FROM vn_screenshots vs WHERE vs.id = v.id)' };
+f v => 64 => 'has_review', { uint => 1, range => [1,1] }, '=' => sub { 'EXISTS(SELECT 1 FROM reviews r WHERE r.vid = v.id AND NOT r.c_flagged)' };
+f v => 65 => 'on_list', { uint => 1, range => [1,1] },
+ '=' => sub { auth ? sql 'v.id IN(SELECT vid FROM ulist_vns WHERE uid =', \auth->uid, auth->api2Listread ? () : 'AND NOT c_private', ')' : '1=0' };
+f v => 66 => 'devstatus', { uint => 1, enum => \%DEVSTATUS }, '=' => sub { 'v.devstatus =', \$_ };
+
+f v => 8 => 'tag', { type => 'any', func => \&_validate_tag }, compact => \&_compact_tag, sql_list => _sql_where_tag('tags_vn_inherit');
+f v => 14 => 'dtag', { type => 'any', func => \&_validate_tag }, compact => \&_compact_tag, sql_list => _sql_where_tag('tags_vn_direct');
+
+f v => 12 => 'label', { type => 'any', func => \&_validate_label },
+ compact => sub { [ ($_->[0] =~ s/^u//r)*1, $_->[1]*1 ] },
+ sql_list => \&_sql_where_label, sql_list_grp => sub { $_->[1] == 0 ? undef : $_->[0] };
+
+f v => 13 => 'anime_id', { id => 1 },
+ sql_list => sub {
+ my($neg, $all, $val) = @_;
+ sql 'v.id', $neg ? 'NOT' : '', 'IN(SELECT id FROM vn_anime WHERE aid IN', $val, $all && @$val > 1 ? ('GROUP BY id HAVING COUNT(aid) =', \scalar @$val) : (), ')';
+ };
+
+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 v => 52 => 'staff', 's', '=' => sub {
+ # The "Staff" filter includes both vn_staff and vn_seiyuu. Union those tables together and filter on that.
+ sql 'v.id IN(SELECT vs.id
+ FROM (SELECT id, aid, role FROM vn_staff UNION ALL SELECT id, aid, NULL FROM vn_seiyuu) vs
+ JOIN staff_aliast s ON s.aid = vs.aid
+ WHERE NOT s.hidden AND', $_, ')' };
+f v => 55 => 'developer', 'p', '=' => sub { sql 'EXISTS(SELECT 1 FROM producers p, unnest(v.c_developers) vcd(x) WHERE p.id = vcd.x AND NOT p.hidden AND', $_, ')' };
+
+# Deprecated.
+f v => 6 => 'developer-id', { vndbid => 'p' }, '=' => sub { sql 'v.c_developers && ARRAY', \$_, '::vndbid[]' };
+
+
+
+f r => 80 => 'id', { vndbid => 'r' }, sql => sub { sql 'r.id', $_[0], \$_ };
+f r => 81 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('r', 'r.id') };
+f r => 2 => 'lang', { enum => \%LANGUAGE },
+ sql_list => sub {
+ my($neg, $all, $val) = @_;
+ sql 'r.id', $neg ? 'NOT' : '', 'IN(SELECT id FROM releases_titles WHERE NOT mtl AND lang IN', $val, $all && @$val > 1 ? ('GROUP BY id HAVING COUNT(lang) =', \scalar @$val) : (), ')';
+ };
+
+f r => 4 => 'platform', { default => undef, enum => \%PLATFORM },
+ sql_list_grp => sub { defined $_ },
+ sql_list => sub {
+ my($neg, $all, $val) = @_;
+ return sql $neg ? '' : 'NOT', 'EXISTS(SELECT 1 FROM releases_platforms WHERE id = r.id)' if !defined $val->[0];
+ sql 'r.id', $neg ? 'NOT' : '', 'IN(SELECT id FROM releases_platforms WHERE platform IN', $val, $all && @$val > 1 ? ('GROUP BY id HAVING COUNT(platform) =', \scalar @$val) : (), ')';
+ };
+
+f r => 7 => 'released', { fuzzyrdate => 1 }, sql => sub { sql 'r.released', $_[0], \($_ == 1 ? strftime('%Y%m%d', gmtime) : $_) };
+f r => 8 => 'resolution', { type => 'array', length => 2, values => { uint => 1, max => 32767 } },
+ sql => sub { sql 'NOT r.patch AND r.reso_x', $_[0], \$_->[0], 'AND r.reso_y', $_[0], \$_->[1], $_->[0] ? 'AND r.reso_x > 0' : () };
+f r => 9 => 'resolution-aspect', { type => 'array', length => 2, values => { uint => 1, max => 32767 } },
+ sql => sub { sql 'NOT r.patch AND r.reso_x', $_[0], \$_->[0], 'AND r.reso_y', $_[0], \$_->[1], 'AND r.reso_x*1000/GREATEST(1, r.reso_y) =', \(int ($_->[0]*1000/max(1,$_->[1]))), $_->[0] ? 'AND r.reso_x > 0' : () };
+f r => 10 => 'minage', { default => undef, uint => 1, enum => \%AGE_RATING },
+ sql => sub { defined $_ ? sql 'r.minage', $_[0], \$_ : $_[0] eq '=' ? 'r.minage IS NULL' : 'r.minage IS NOT NULL' };
+f r => 11 => 'medium', { default => undef, enum => \%MEDIUM },
+ '=' => sub { !defined $_ ? 'NOT EXISTS(SELECT 1 FROM releases_media rm WHERE rm.id = r.id)' : sql 'EXISTS(SELECT 1 FROM releases_media rm WHERE rm.id = r.id AND rm.medium =', \$_, ')' };
+f r => 12 => 'voiced', { default => 0, uint => 1, enum => \%VOICED }, '=' => sub { sql 'NOT r.patch AND r.voiced =', \$_ };
+f r => 13 => 'animation-ero', { uint => 1, enum => \%ANIMATED }, '=' => sub { sql 'NOT r.patch AND r.ani_ero =', \$_ };
+f r => 14 => 'animation-story', { uint => 1, enum => \%ANIMATED }, '=' => sub { sql 'NOT r.patch AND r.ani_story =', \$_ };
+f r => 15 => 'engine', { default => '' }, '=' => sub { sql 'r.engine =', \$_ };
+f r => 16 => 'rtype', { enum => \%RELEASE_TYPE }, '=' => sub { $#TYPE && $TYPE[$#TYPE-1] eq 'v' ? sql 'rv.rtype =', \$_ : sql 'r.id IN(SELECT id FROM releases_vn WHERE rtype =', \$_, ')' };
+f r => 18 => 'rlist', { uint => 1, enum => \%RLIST_STATUS }, sql_list => sub {
+ my($neg, $all, $val) = @_;
+ return '1=0' if !auth;
+ sql 'r.id', $neg ? 'NOT' : '', 'IN(SELECT rid FROM rlists WHERE uid =', \auth->uid, 'AND status IN', $val, $all && @$val > 1 ? ('GROUP BY rid HAVING COUNT(status) =', \scalar @$val) : (), ')';
+ };
+f r => 19 => 'extlink', _extlink_filter('r');
+f r => 20 => 'drm', { default => '' }, '=' => sub { sql 'EXISTS(SELECT 1 FROM drm JOIN releases_drm rd ON rd.drm = drm.id WHERE drm.name =', \$_, 'AND rd.id = r.id)' };
+f r => 61 => 'patch', { uint => 1, range => [1,1] }, '=' => sub { 'r.patch' };
+f r => 62 => 'freeware', { uint => 1, range => [1,1] }, '=' => sub { 'r.freeware' };
+f r => 64 => 'uncensored',{uint => 1, range => [1,1] }, '=' => sub { 'r.uncensored' };
+f r => 65 => 'official', { uint => 1, range => [1,1] }, '=' => sub { 'r.official' };
+f r => 66 => 'has_ero', { uint => 1, range => [1,1] }, '=' => sub { 'r.has_ero' };
+f r => 53 => 'vn', 'v', '=' => sub { sql 'r.id IN(SELECT rv.id FROM releases_vn rv JOIN vn v ON v.id = rv.vid WHERE NOT v.hidden AND', $_, ')' };
+f r => 55 => 'producer', 'p', '=' => sub { sql 'r.id IN(SELECT rp.id FROM releases_producers rp JOIN producers p ON p.id = rp.pid WHERE NOT p.hidden AND', $_, ')' };
+
+# Deprecated.
+f r => 6 => 'developer-id',{ vndbid => 'p' }, '=' => sub { sql 'r.id IN(SELECT id FROM releases_producers WHERE developer AND pid =', \$_, ')' }; # Does not have a new equivalent
+f r => 17 => 'producer-id', { vndbid => 'p' }, '=' => sub { sql 'r.id IN(SELECT id FROM releases_producers WHERE pid =', \$_, ')' };
+f r => 63 => 'doujin', { uint => 1, range => [1,1] }, '=' => sub { 'r.doujin' }; # Not recognized by Elm anymore.
+
+
+
+f c => 80 => 'id', { vndbid => 'c' }, sql => sub { sql 'c.id', $_[0], \$_ };
+f c => 81 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('c', 'c.id') };
+f c => 2 => 'role', { enum => \%CHAR_ROLE }, '=' => sub { $#TYPE && $TYPE[$#TYPE-1] eq 'v' ? sql 'cv.role =', \$_ : sql 'c.id IN(SELECT id FROM chars_vns WHERE role =', \$_, ')' };
+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', { default => undef, uint => 1, max => 32767 },
+ sql => sub { !defined $_ ? sql 'c.height', $_[0], 0 : sql 'c.height <> 0 AND c.height', $_[0], \$_ };
+f c => 7 => 'weight', { default => undef, uint => 1, max => 32767 },
+ sql => sub { !defined $_ ? sql('c.weight IS', $_[0] eq '=' ? '' : 'NOT', 'NULL') : sql 'c.weight', $_[0], \$_ };
+f c => 8 => 'bust', { default => undef, uint => 1, max => 32767 },
+ sql => sub { !defined $_ ? sql 'c.s_bust', $_[0], 0 : sql 'c.s_bust <> 0 AND c.s_bust', $_[0], \$_ };
+f c => 9 => 'waist', { default => undef, uint => 1, max => 32767 },
+ sql => sub { !defined $_ ? sql 'c.s_waist', $_[0], 0 : sql 'c.s_waist <> 0 AND c.s_waist', $_[0], \$_ };
+f c => 10 => 'hips', { default => undef, uint => 1, max => 32767 },
+ sql => sub { !defined $_ ? sql 'c.s_hip', $_[0], 0 : sql 'c.s_hip <> 0 AND c.s_hip', $_[0], \$_ };
+f c => 11 => 'cup', { default => undef, enum => \%CUP_SIZE },
+ sql => sub { !defined $_ ? sql 'c.cup_size', $_[0], "''" : sql 'c.cup_size <> \'\' AND c.cup_size', $_[0], \$_ };
+f c => 12 => 'age', { default => undef, uint => 1, max => 32767 },
+ sql => sub { !defined $_ ? sql('c.age IS', $_[0] eq '=' ? '' : 'NOT', 'NULL') : sql 'c.age', $_[0], \$_ };
+f c => 13 => 'trait', { type => 'any', func => \&_validate_trait }, compact => \&_compact_trait, sql_list => _sql_where_trait('traits_chars', 'cid');
+f c => 15 => 'dtrait', { type => 'any', func => \&_validate_trait }, compact => \&_compact_trait, sql_list => _sql_where_trait('chars_traits', 'id');
+f c => 14 => 'birthday', { default => [0,0], type => 'array', length => 2, values => { uint => 1, max => 31 } },
+ '=' => sub { sql 'c.b_month =', \$_->[0], $_->[1] ? ('AND c.b_day =', \$_->[1]) : () };
+
+# XXX: When this field is nested inside a VN query, it may match seiyuu linked to other VNs.
+# This can be trivially fixed by adding an (AND vs.id = v.id) clause, but that results in extremely slow queries that I've no clue how to optimize.
+f c => 52 => 'seiyuu', 's', '=' => sub { sql 'c.id IN(SELECT vs.cid FROM vn_seiyuu vs JOIN staff_aliast s ON s.aid = vs.aid WHERE NOT s.hidden AND', $_, ')' };
+f c => 53 => 'vn', 'v', '=' => sub { sql 'c.id IN(SELECT cv.id FROM chars_vns cv JOIN vn v ON v.id = cv.vid WHERE NOT v.hidden AND', $_, ')' };
+
+
+
+# Staff filters need 'staff_aliast s', aliases are treated as separate rows.
+f s => 2 => 'lang', { enum => \%LANGUAGE }, '=' => sub { sql 's.lang =', \$_ };
+f s => 3 => 'id', { vndbid => 's' }, sql => sub { sql 's.id', $_[0], \$_ };
+f s => 4 => 'gender', { enum => \%GENDER }, '=' => sub { sql 's.gender =', \$_ };
+f s => 5 => 'role', { enum => [ 'seiyuu', keys %CREDIT_TYPE ] },
+ sql_list_grp => sub { $_ eq 'seiyuu' ? undef : '' },
+ sql_list => sub {
+ my($neg, $all, $val) = @_;
+ my @grp = $all && @$val > 1 ? ('GROUP BY vs.aid HAVING COUNT(vs.role) =', \scalar @$val) : ();
+ if($#TYPE && $TYPE[$#TYPE-1] eq 'v') {
+ # Shortcut referencing the vn_staff table we're already querying
+ return $val->[0] eq 'seiyuu' ? 'vs.role IS NULL' : sql 'vs.role IN', $val if !@grp && !$neg;
+ return sql $neg ? 'NOT' : '', 'EXISTS(SELECT 1 FROM vn_seiyuu vs WHERE vs.id = v.id AND vs.aid = s.aid)' if $val->[0] eq 'seiyuu';
+ sql 's.aid', $neg ? 'NOT' : '', 'IN(SELECT vs.aid FROM vn_staff vs WHERE vs.id = v.id AND vs.role IN', $val, @grp, ')';
+ } else {
+ return sql $neg ? 'NOT' : '', 'EXISTS(SELECT 1 FROM vn_seiyuu vs JOIN vn v ON v.id = vs.id WHERE NOT v.hidden AND vs.aid = s.aid)' if $val->[0] eq 'seiyuu';
+ sql 's.aid', $neg ? 'NOT' : '', 'IN(SELECT vs.aid FROM vn_staff vs JOIN vn v ON v.id = vs.id WHERE NOT v.hidden AND vs.role IN', $val, @grp, ')';
+ }
+ };
+f s => 6 => 'extlink', _extlink_filter('s');
+f s => 61 => 'ismain', { uint => 1, range => [1,1] }, '=' => sub { 's.aid = s.main' };
+f s => 80 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('s', 's.id', 's.aid') };
+f s => 81 => 'aid', { id => 1 }, '=' => sub { sql 's.aid =', \$_ };
+
+f p => 2 => 'lang', { enum => \%LANGUAGE }, '=' => sub { sql 'p.lang =', \$_ };
+f p => 3 => 'id', { vndbid => 'p' }, sql => sub { sql 'p.id', $_[0], \$_ };
+f p => 4 => 'type', { enum => \%PRODUCER_TYPE }, '=' => sub { sql 'p.type =', \$_ };
+f p => 80 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('p', 'p.id') };
+
+
+f g => 2 => 'id', { vndbid => 'g' }, sql => sub { sql 't.id', $_[0], \$_ };
+f g => 3 => 'category', { enum => \%TAG_CATEGORY }, '=' => sub { sql 't.cat =', \$_ };
+f g => 80 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('g', 't.id') };
+
+
+f i => 2 => 'id', { vndbid => 'i' }, sql => sub { sql 't.id', $_[0], \$_ };
+f i => 80 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('i', 't.id') };
+
+
+
+# 'extlink' filter accepts the following values:
+# - $name - Whether the entry has a link of site $name
+# - [ $name, $val ] - Whether the entry has a link of site $name with the given $val
+# - "$name,$val" - Compact version of above (not really *compact* by any means, but this filter isn't common anyway)
+# - "http://..." - Auto-detect version of [$name,$val]
+# TODO: This only handles links defined in %LINKS, but it would be nice to also support links from Wikidata & PlayAsia.
+sub _extlink_filter {
+ my($type) = @_;
+ my $schema = (grep +($_->{dbentry_type}||'') eq $type, values VNDB::Schema::schema->%*)[0];
+ my %links = map {
+ my $n = $_;
+ my $l = $VNDB::ExtLinks::LINKS{$type}{$n};
+ my $s = (grep $_->{name} eq $n, $schema->{cols}->@*)[0];
+ (s/^l_//r, +{ %$l,
+ _col => $n,
+ _schema => $s,
+ _regex => $l->{regex} && VNDB::ExtLinks::full_regex($l->{regex}),
+ _hasval => $s->{type} =~ /\[\]/ ? "<> '{}'" : $s->{decl} !~ /not\s+null/i ? 'is not null' : $s->{type} =~ /^(big)?int/i ? '<> 0' : "<> ''"
+ })
+ } keys $VNDB::ExtLinks::LINKS{$type}->%*;
+
+ my sub _val {
+ return 1 if !ref $_[0] && $links{$_[0]}; # just $name
+ if(!ref $_[0] && $_[0] =~ /^https?:/) { # URL
+ for (keys %links) {
+ if($links{$_}{_regex} && $_[0] =~ $links{$_}{_regex}) {
+ $_[0] = [ $_, $1 ];
+ last;
+ }
+ }
+ return { msg => 'Unrecognized URL format' } if !ref $_[0];
+ }
+ $_[0] = [ split /,/, $_[0], 2 ] if !ref $_[0]; # compact $name,$val form
+
+ # normalized $name,$val form
+ return 0 if ref $_[0] ne 'ARRAY' || $_[0]->@* != 2 || ref $_[0][0] || ref $_[0][1] || !defined $_[0][1];
+ my $l = $links{$_[0][0]};
+ return { msg => "Unknown field '$_[0][0]'" } if !$l;
+ return { msg => "Invalid value '$_[0][1]'" } if $l->{_schema}{type} =~ /^int/i && ($_[0][1] !~ /^-?[0-9]+$/ || $_[0][1] >= (1<<31) || $_[0][1] <= -(1<<31));
+ return { msg => "Invalid value '$_[0][1]'" } if $l->{_schema}{type} =~ /^bigint/i && ($_[0][1] !~ /^-?[0-9]+$/ || $_[0][1] >= (1<<63) || $_[0][1] <= -(1<<63));
+ $_[0][1] *= 1 if $l->{_schema}{type} =~ /^(big)?int/i;
+ 1
+ }
+
+ my sub _sql {
+ return "$type.$links{$_}{_col} $links{$_}{_hasval}" if !ref; # just name
+ my($l, $v) = ($links{$_->[0]}, $_->[1]);
+ sql "$type.$l->{_col}", $l->{_schema}{type} =~ /\[\]/ ? ('&& ARRAY[', \$v, ']::', $l->{_schema}{type}) : ('=', \$v);
+ }
+ my sub _comp { ref $_ ? $_->[0].','.(my $x=$_->[1]) : $_ }
+ ({ type => 'any', func => \&_val }, '=' => \&_sql, compact => \&_comp)
+}
+
+
+# Accepts either:
+# - $tag
+# - [$tag, $exclude_lies*16*3 + int($minlevel*5)*3 + $maxspoil] (compact form)
+# - [$tag, $maxspoil, $minlevel]
+# - [$tag, $maxspoil, $minlevel, $exclude_lies]
+# Normalizes to the latter two.
+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][3]) = ($_[0][1]%3, int($_[0][1]%(3*16)/3)/5, int($_[0][1]/3/16) == 1 ? 1 : 0);
+ }
+ # normalized form
+ return 0 if $_[0]->@* < 3 || $_[0]->@* > 4;
+ 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+)?)$/;
+ $_[0][1] *= 1;
+ $_[0][2] *= 1;
+ if ($_[0]->@* == 4) {
+ return 0 if !defined $_[0][3] || ref $_[0][3] || $_[0][3] !~ /^[0-1]$/;
+ $_[0][3] *= 1;
+ pop $_[0]->@* if !$_[0][3];
+ }
+ 1
+}
+
+sub _compact_tag { my $id = ($_->[0] =~ s/^g//r)*1; $_->[1] == 0 && $_->[2] == 0 && !$_->[3] ? $id : [ $id, ($_->[3]?16*3:0) + int($_->[2]*5)*3 + $_->[1] ] }
+sub _compact_trait { my $id = ($_->[0] =~ s/^i//r)*1; $_->[1] == 0 && !$_->[2] ? $id : [ $id, ($_->[2]?3:0) + $_->[1] ] }
+
+# Accepts either:
+# - $trait
+# - [$trait, $exclude_lies*3 + $maxspoil] (compact form)
+# - [$trait, $maxspoil]
+# - [$trait, $maxspoil, $exclude_lies]
+# Normalizes to the latter two.
+sub _validate_trait {
+ $_[0] = [$_[0],0] if ref $_[0] ne 'ARRAY'; # just a trait id
+ my $v = tuwf->compile({ vndbid => 'i' })->validate($_[0][0]);
+ return 0 if $v->err;
+ $_[0][0] = $v->data;
+ return 0 if !defined $_[0][1] || ref $_[0][1] || $_[0][1] !~ /^[0-9]+$/;
+ ($_[0][1], $_[0][2]) = ($_[0][1]%3, int($_[0][1]/3) == 1 ? 1 : 0) if $_[0]->@* == 2;
+ return 0 if $_[0]->@* != 3;
+ return 0 if $_[0][1] > 2;
+ return 0 if !defined $_[0][2] || ref $_[0][2] || $_[0][2] !~ /^[01]$/;
+ $_[0][1] *= 1;
+ $_[0][2] *= 1;
+ pop $_[0]->@* if $_[0]->@* == 3 && !$_[0][2];
+ 1
+}
+
+
+# Accepts either $label or [$uid, $label]. Normalizes to the latter. $label=0 is used for 'Unlabeled'.
+sub _validate_label {
+ $_[0] = [tuwf->req->{advsearch_uid}||auth->uid(), $_[0]] if ref $_[0] ne 'ARRAY';
+ my $v = tuwf->compile({ vndbid => 'u' })->validate($_[0][0]);
+ return 0 if $v->err;
+ $_[0][0] = $v->data;
+ $_[0]->@* == 2 && defined $_[0][1] && !ref $_[0][1] && $_[0][1] =~ /^(?:0|[1-9][0-9]{0,5})$/
+}
+
+
+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
+}
+
+
+sub _validate_adv {
+ my $t = shift;
+ 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;
+ }
+ if(ref $_[0] eq 'ARRAY' && $_[0]->@* == 0) {
+ $_[0] = bless {type=>$t}, __PACKAGE__;
+ return 1;
+ }
+ my $v = _validate($t, @_);
+ $_[0] = bless { type => $t, query => $_[0] }, __PACKAGE__ if $v;
+ $v
+}
+
+
+
+# 'advsearch' validation, accepts either a compact encoded string, JSON string or an already decoded array.
+TUWF::set('custom_validations')->{advsearch} = sub { my($t) = @_; +{ type => 'any', default => bless({type=>$t}, __PACKAGE__), func => sub { _validate_adv $t, @_ } } };
+
+# 'advsearch_err' validation; Same as the 'advsearch' validation except it never throws an error.
+# If the validation failed, this will log a warning and return an empty query that will cause elm_() to display a warning message.
+TUWF::set('custom_validations')->{advsearch_err} = sub {
+ my ($t) = @_;
+ +{ type => 'any', default => bless({type=>$t}, __PACKAGE__), func => sub {
+ my $r = _validate_adv $t, @_;
+ $_[0] = bless {type=>$t,error=>1}, __PACKAGE__ if !$r || ref $r eq 'HASH';
+ 1
+ } }
+};
+
+
+# "Canonicalize"/simplify a query (in Normalized JSON form):
+# - Merges nested and/or's where possible
+# - Removes duplicate filters where possible
+# - Sorts fields and values, for deterministic processing
+#
+# This function is unaware of the behavior of individual filters, so it can't
+# currently simplify a query like "(a < 10) and (a < 9)" into "a < 9".
+#
+# The returned query is suitable for generating SQL and comparison of different
+# queries, but should not be given to the Elm UI as it changes the way fields
+# are merged.
+sub _canon {
+ my($t, $q) = @_;
+ return [ $q->[0], $q->[1], _canon($_->{value}, $q->[2]) ] if (local $_ = $FIELDS{$t}{$q->[0]}) && !ref $_->{value};
+ return $q if $q->[0] ne 'or' && $q->[0] ne 'and';
+ my @l = map _canon($t, $_), @$q[1..$#$q];
+ @l = map $_->[0] eq $q->[0] ? @$_[1..$#$_] : $_, @l; # Merge nested and/or's
+ return $l[0] if @l == 1; # and/or with a single field -> flatten
+
+ sub _stringify { ref $_[0] ? join ':', map _stringify($_//''), $_[0]->@* : $_[0] }
+ my %l = map +(_stringify($_),$_), @l;
+ [ $q->[0], map $l{$_}, sort keys %l ]
+}
+
+
+# returns an sql_list function for tags
+sub _sql_where_tag {
+ my($table) = @_;
+ sub {
+ my($neg, $all, $val) = @_;
+ my %f; # spoiler -> rating -> lie -> list
+ my @l;
+ push $f{$_->[1]*1}{$_->[2]*1}{$_->[3]?1:''}->@*, $_->[0] for @$val;
+ for my $s (keys %f) {
+ for my $r (keys $f{$s}->%*) {
+ for my $l (keys $f{$s}{$r}->%*) {
+ push @l, sql_and
+ $s < 2 ? sql('spoiler <=', \$s) : (),
+ $r > 0 ? sql('rating >=', \$r) : (),
+ $l ? ('NOT lie') : (),
+ sql('tag IN', $f{$s}{$r}{$l});
+ }
+ }
+ }
+ sql 'v.id', $neg ? 'NOT' : (), 'IN(SELECT vid FROM', $table, 'WHERE', sql_or(@l), $all && @$val > 1 ? ('GROUP BY vid HAVING COUNT(tag) =', \scalar @$val) : (), ')'
+ }
+}
+
+sub _sql_where_trait {
+ my($table, $cid) = @_;
+ sub {
+ my($neg, $all, $val) = @_;
+ my %f; # spoiler -> list
+ my @l;
+ push $f{$_->[1]*1}{$_->[2]?1:''}->@*, $_->[0] for @$val;
+ for my $s (keys %f) {
+ for my $l (keys $f{$s}->%*) {
+ push @l, sql_and
+ $s < 2 ? sql('spoil <=', \$s) : (),
+ $l ? ('NOT lie') : (),
+ sql('tid IN', $f{$s}{$l});
+ }
+ }
+ sql 'c.id', $neg ? 'NOT' : (), 'IN(SELECT', $cid, 'FROM', $table, 'WHERE', sql_or(@l), $all && @$val > 1 ? ('GROUP BY', $cid, 'HAVING COUNT(tid) =', \scalar @$val) : (), ')'
+ }
+}
+
+
+# Assumption: All labels in a group are for the same uid and label==0 has its own group.
+sub _sql_where_label {
+ my($neg, $all, $val) = @_;
+ my $uid = $val->[0][0];
+ require VNWeb::ULists::Lib;
+ my $own = VNWeb::ULists::Lib::ulists_own($uid);
+ my @lbl = map $_->[1], @$val;
+
+ # Unlabeled
+ if($lbl[0] == 0) {
+ return '1=0' if !$own;
+ return sql $neg ? 'NOT' : (), 'EXISTS(SELECT 1 FROM ulist_vns WHERE vid = v.id AND uid =', \$uid, "AND labels IN('{}','{7}'))";
+ }
+
+ if(!$own) {
+ # Label 7 can always be queried, do a lookup for the rest.
+ tuwf->req->{lblvis}{$uid} ||= { 7, 1, map +($_->{id},1), tuwf->dbAlli('SELECT id FROM ulist_labels WHERE NOT private AND uid =', \$uid)->@* };
+ my $vis = tuwf->req->{lblvis}{$uid};
+ return $neg ? '1=1' : '1=0' if $all && grep !$vis->{$_}, @lbl; # AND query but one label is private -> no match
+ @lbl = grep $vis->{$_}, @lbl;
+ return $neg ? '1=1' : '1=0' if !@lbl; # All requested labels are private -> no match
+ }
+
+ sql 'v.id', $neg ? 'NOT' : (), 'IN(
+ SELECT vid
+ FROM ulist_vns
+ WHERE uid =', \$uid,
+ 'AND labels', $all ? '@>' : '&&', sql_array(@lbl), '::smallint[]',
+ $own ? () : 'AND NOT c_private',
+ ')'
+}
+
+
+sub _sql_where {
+ my($t, $q) = @_;
+
+ if($q->[0] eq 'and' || $q->[0] eq 'or') {
+ my %f; # For sql_list; field -> op -> group -> list of values
+ my @l; # Remaining non-batched queries
+ for my $cq (@$q[1..$#$q]) {
+ my $cf = $FIELDS{$t}{$cq->[0]};
+ my $grp = !$cf || !$cf->{sql_list} || ($cq->[1] ne '=' && $cq->[1] ne '!=') ? undef
+ : !$cf->{sql_list_grp} ? ''
+ : do { local $_ = $cq->[2]; $cf->{sql_list_grp}->($_) };
+ if(defined $grp) {
+ push $f{$cq->[0]}{$cq->[1]}{$grp}->@*, $cq->[2];
+ } else {
+ push @l, _sql_where($t, $cq);
+ }
+ }
+
+ for my $field (keys %f) {
+ for my $op (keys $f{$field}->%*) {
+ push @l, $FIELDS{$t}{$field}{sql_list}->(
+ $q->[0] eq 'and' ? ($op eq '=' ? (0, 1) : (1, 0)) : $op eq '=' ? (0, 0) : (1, 1),
+ $_
+ ) for values $f{$field}{$op}->%*;
+ }
+ }
+
+ return sql '(', ($q->[0] eq 'and' ? sql_and @l : sql_or @l), ')';
+ }
+
+ my $f = $FIELDS{$t}{$q->[0]};
+ my $func = $f->{$q->[1]} || $f->{sql};
+ local $_ = ref $f->{value} ? $q->[2] : do {
+ push @TYPE, $f->{value};
+ my $v = _sql_where($f->{value}, $q->[2]);
+ pop @TYPE;
+ $v;
+ };
+ sql '(', $func->($q->[1]), ')';
+}
+
+
+sub sql_where {
+ my($self) = @_;
+ @TYPE = ($self->{type});
+ $self->{query} ? _sql_where $self->{type}, _canon $self->{type}, $self->{query} : '1=1';
+}
+
+
+sub json { shift->{query} }
+
+
+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}->($_) }
+ : !defined $q->[2] ? ''
+ : _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->{"anime$q->[2]"} = 1 if $q->[0] eq 'anime_id';
+ $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};
+ }
+}
+
+
+# Returns a JSON object suitable for the AdvSearchQuery API response.
+sub elm_search_query {
+ my($self) = @_;
+
+ my(%o,%ids);
+ _extract_ids($self->{type}, $self->{query}, \%ids) if $self->{query};
+
+ $o{producers} = [ map +{id => $_}, grep /^p/, keys %ids ];
+ enrich_merge id => sql('SELECT id, title[1+1] AS name, title[1+1+1+1] AS altname FROM', VNWeb::TitlePrefs::producerst(), 'p WHERE id IN'), $o{producers};
+
+ $o{staff} = [ map +{id => $_}, grep /^s/, keys %ids ];
+ enrich_merge id => sql('SELECT id, lang, aid, title[1+1], title[1+1+1+1] AS alttitle FROM', VNWeb::TitlePrefs::staff_aliast(), 's WHERE aid = main AND id IN'), $o{staff};
+
+ $o{tags} = [ map +{id => $_}, grep /^g/, keys %ids ];
+ enrich_merge id => 'SELECT id, name, searchable, applicable, hidden, locked FROM tags WHERE id IN', $o{tags};
+
+ $o{traits} = [ map +{id => $_}, grep /^i/, keys %ids ];
+ enrich_merge id => 'SELECT t.id, t.name, t.searchable, t.applicable, t.defaultspoil, t.hidden, t.locked, g.id AS group_id, g.name AS group_name
+ FROM traits t LEFT JOIN traits g ON g.id = t.gid WHERE t.id IN', $o{traits};
+
+ $o{anime} = [ map +{id => $_=~s/^anime//rg}, grep /^anime/, keys %ids ];
+ enrich_merge id => 'SELECT id, title_romaji AS title, title_kanji AS original FROM anime WHERE id IN', $o{anime};
+
+ $o{qtype} = $self->{type};
+ $o{query} = $self->compact_json;
+ \%o
+}
+
+
+sub elm_ {
+ my($self, $count, $time) = @_;
+
+ # TODO: labels can be lazily loaded to reduce page weight
+ state $schema ||= tuwf->compile({ type => 'hash', keys => {
+ uid => { vndbid => 'u', default => undef },
+ labels => { aoh => { id => { uint => 1 }, label => {} } },
+ defaultSpoil => { uint => 1 },
+ saved => { aoh => { name => {}, query => {} } },
+ error => { anybool => 1 },
+ query => $VNWeb::Elm::apis{AdvSearchQuery}[0],
+ }});
+ VNWeb::HTML::elm_ 'AdvSearch.Main', $schema, {
+ uid => auth->uid,
+ labels => auth ? tuwf->dbAlli('SELECT id, label FROM ulist_labels WHERE uid =', \auth->uid, 'ORDER BY CASE WHEN id < 10 THEN id ELSE 10 END, label') : [],
+ defaultSpoil => auth->pref('spoilers')||0,
+ saved => auth ? tuwf->dbAlli('SELECT name, query FROM saved_queries WHERE uid =', \auth->uid, ' AND qtype =', \$self->{type}, 'ORDER BY name') : [],
+ error => $self->{error}?1:0,
+ query => $self->elm_search_query(),
+ };
+
+ if (@_ > 1) {
+ p_ class => 'center', sub {
+ input_ type => 'submit', value => 'Search';
+ txt_ sprintf ' %d result%s in %.3fs', $count, $count == 1 ? '' : 's', $time if defined $count;
+ };
+ div_ class => 'warning', sub {
+ h2_ 'ERROR: Query timed out.';
+ p_ q{
+ This usually happens when your combination of filters is too complex for the server to handle.
+ This may also happen when the server is overloaded with other work, but that's much less common.
+ You can adjust your filters or try again later.
+ };
+ } if !defined $count;
+ }
+}
+
+
+sub query_encode {
+ my($self) = @_;
+ return '' if !$self->{query};
+ $self->{query_encode} //= _enc_query $self->compact_json;
+ $self->{query_encode};
+}
+
+
+sub extract_searchquery {
+ my($self) = @_;
+ my $q = $self->{query};
+ return ($self) if !$q;
+ return (bless({type => $self->{type}}, __PACKAGE__), $q->[2]) if @$q == 3 && $q->[1] eq '=' && ref $q->[2] eq 'VNWeb::Validate::SearchQuery';
+ if($q->[0] eq 'and') {
+ my(@newq, $s);
+ for (@{$q}[1..$#$q]) {
+ if(@$_ == 3 && $_->[1] eq '=' && ref $_->[2] eq 'VNWeb::Validate::SearchQuery') {
+ return ($self) if $s;
+ $s = $_->[2];
+ } else {
+ push @newq, $_;
+ }
+ }
+ return (bless({type => $self->{type}, query => ['and',@newq]}, __PACKAGE__), $s) if $s;
+ }
+ return ($self);
+}
+
+
+# Returns the saved default query for the current user, or an empty query if none has been set.
+sub advsearch_default {
+ my($t) = @_;
+ if(auth) {
+ my $def = tuwf->dbVali('SELECT query FROM saved_queries WHERE qtype =', \$t, 'AND name = \'\' AND uid =', \auth->uid);
+ return tuwf->compile({ advsearch => $t })->validate($def)->data if $def;
+ }
+ bless {type=>$t}, __PACKAGE__;
+}
+
+1;
diff --git a/lib/VNWeb/Auth.pm b/lib/VNWeb/Auth.pm
index 35587c8d..442d46f4 100644
--- a/lib/VNWeb/Auth.pm
+++ b/lib/VNWeb/Auth.pm
@@ -7,7 +7,7 @@
# ..user is logged in
# }
#
-# my $success = auth->login($user, $pass);
+# my $success = auth->login($uid, $pass);
# auth->logout;
#
# my $uid = auth->uid;
@@ -23,39 +23,47 @@ use warnings;
use TUWF;
use Exporter 'import';
+use Carp 'croak';
use Digest::SHA qw|sha1 sha1_hex|;
use Crypt::URandom 'urandom';
use Crypt::ScryptKDF 'scrypt_raw';
-use Encode 'encode_utf8';
+use MIME::Base64 'encode_base64url';
+use POSIX 'strftime';
-use VNDBUtil 'norm_ip';
+use VNDB::Func 'norm_ip';
use VNDB::Config;
use VNWeb::DB;
our @EXPORT = ('auth');
-my $auth;
-sub auth { $auth }
-
-
-TUWF::hook before => sub {
- my $cookie = tuwf->reqCookie('auth')||'';
- my($uid, $token_e) = $cookie =~ /^([a-fA-F0-9]{40})\.?(\d+)$/ ? ($2, sha1_hex pack 'H*', $1) : (0, '');
-
- $auth = __PACKAGE__->new();
- $auth->_load_session($uid, $token_e);
- 1;
-};
-
-
-TUWF::hook after => sub { $auth = __PACKAGE__->new() };
+sub auth {
+ tuwf->req->{auth} ||= do {
+ my $auth = __PACKAGE__->new();
+ if(config->{read_only}) {
+ # Account functionality disabled in read-only mode.
+
+ # API requests have two authentication methods:
+ # - If the origin equals the site, use the same Cookie auth as the rest of the site (handy for userscripts)
+ # - Otherwise, a custom token-based auth, but this hasn't been implemented yet
+ } elsif(VNWeb::Validation::is_api() && (tuwf->reqHeader('Origin')//'_') ne config->{url}) {
+ # XXX: User prefs and permissions are not loaded in this case - they're not used.
+ $auth->_load_api2(tuwf->reqHeader('authorization'));
+
+ } else {
+ my $cookie = tuwf->reqCookie('auth')||'';
+ my($uid, $token_e) = $cookie =~ /^([a-fA-F0-9]{40})\.?u?(\d+)$/ ? ('u'.$2, sha1_hex pack 'H*', $1) : (0, '');
+ $auth->_load_session($uid, $token_e);
+ }
+ $auth
+ };
+}
# log user IDs (necessary for determining performance issues, user preferences
# have a lot of influence in this)
TUWF::set log_format => sub {
my(undef, $uri, $msg) = @_;
- sprintf "[%s] %s %s: %s\n", scalar localtime(), $uri, auth ? 'u'.auth->uid : '-', $msg;
+ sprintf "[%s UTC] %s %s: %s\n", strftime('%Y-%m-%d %H:%M:%S', gmtime), $uri, tuwf->req && tuwf->req->{auth} ? auth->uid : '-', $msg;
};
@@ -63,39 +71,37 @@ TUWF::set log_format => sub {
use overload bool => sub { defined shift->{user}{user_id} };
sub uid { shift->{user}{user_id} }
-sub perm { shift->{user}{perm}||0 }
sub user { shift->{user} }
sub token { shift->{token} }
+sub isMod { auth->permUsermod || auth->permDbmod || auth->permBoardmod || auth->permTagmod }
-# The 'perm' field is a bit field, with the following bits.
-# The 'usermod' flag is hardcoded in sql/func.sql for the user_* functions.
-# Flag 8 was used for 'staffedit', but is now free for re-use.
-# Flag 256 was used for 'affiliates', now also free.
-my %perms = qw{
- board 1
- boardmod 2
- edit 4
- tag 16
- dbmod 32
- tagmod 64
- usermod 128
-};
-
-sub defaultPerms { $perms{board} + $perms{edit} + $perms{tag} }
-sub allPerms { my $i = 0; $i |= $_ for values %perms; $i }
-sub listPerms { \%perms }
+my @perms = qw/board boardmod edit imgvote tag dbmod tagmod usermod review lengthvote/;
+sub listPerms { @perms }
# Create a read-only accessor to check if the current user is authorized to
# perform a particular action.
-for my $perm (keys %perms) {
+for my $perm (@perms) {
no strict 'refs';
- *{ "perm".ucfirst($perm) } = sub { (shift->perm() & $perms{$perm}) > 0 }
+ *{ 'perm'.ucfirst($perm) } = sub { shift->{user}{"perm_$perm"} }
}
+
+# Pref(erences) are like permissions, we load these columns eagerly so they can
+# be accessed through auth->pref().
+my @pref_columns = qw/
+ timezone skin customcss_csum titles
+ notify_dbedit notify_post notify_comment
+ tags_all tags_cont tags_ero tags_tech
+ spoilers traits_sexual max_sexual max_violence
+ tableopts_c tableopts_v tableopts_vt
+ nodistract_can nodistract_noads nodistract_nofancy
+/;
+
+
sub _randomascii {
return join '', map chr($_%92+33), unpack 'C*', urandom shift;
}
@@ -108,7 +114,8 @@ sub _preparepass {
my($self, $pass, $salt, $N, $r, $p) = @_;
($N, $r, $p) = @{$self->{scrypt_args}} if !$N;
$salt ||= urandom(8);
- unpack 'H*', pack 'NCCa8a*', $N, $r, $p, $salt, scrypt_raw(encode_utf8($pass), $self->{scrypt_salt} . $salt, $N, $r, $p, 32);
+ utf8::encode(my $utf8pass = $pass);
+ unpack 'H*', pack 'NCCa8a*', $N, $r, $p, $salt, scrypt_raw($utf8pass, $self->{scrypt_salt} . $salt, $N, $r, $p, 32);
}
@@ -125,23 +132,23 @@ sub _encpass {
# Arguments: self, uid, encpass
-# Returns: 0 on error, 1 on success
+# Returns: 0 on error, 1 on success, token on !pretend && deleted account
sub _create_session {
my($self, $uid, $encpass, $pretend) = @_;
my $token = urandom 20;
my $token_db = sha1_hex $token;
return 0 if !tuwf->dbVali('SELECT ',
- sql_func(user_login => \$uid, sql_fromhex($encpass), sql_fromhex $token_db)
+ sql_func(user_login => \$uid, \'web', sql_fromhex($encpass), sql_fromhex $token_db)
);
if($pretend) {
tuwf->dbExeci('SELECT', sql_func user_logout => \$uid, sql_fromhex $token_db);
+ return 1;
} else {
tuwf->resCookie(auth => unpack('H*', $token).'.'.$uid, httponly => 1, expires => time + 31536000);
- $self->_load_session($uid, $token_db);
+ return $self->_load_session($uid, $token_db) ? 1 : $token_db;
}
- return 1;
}
@@ -149,9 +156,13 @@ sub _load_session {
my($self, $uid, $token_db) = @_;
my $user = $uid ? tuwf->dbRowi(
- 'SELECT perm, ', sql_user(), ' FROM users u
- WHERE id = ', \$uid,
- 'AND', sql_func(user_isvalidsession => 'id', sql_fromhex($token_db), \'web')
+ 'SELECT ', sql_user(), ',', sql_comma(@pref_columns, map "perm_$_", @perms), '
+ FROM users u
+ JOIN users_shadow us ON us.id = u.id
+ JOIN users_prefs up ON up.id = u.id
+ WHERE u.id = ', \$uid, '
+ AND us.delete_at IS NULL
+ AND', sql_func(user_validate_session => 'u.id', sql_fromhex($token_db), \'web'), 'IS DISTINCT FROM NULL'
) : {};
# Drop the cookie if it's not valid
@@ -159,7 +170,7 @@ sub _load_session {
$self->{user} = $user;
$self->{token} = $token_db;
- delete $self->{pref};
+ $user->{user_id};
}
@@ -168,19 +179,17 @@ sub new {
scrypt_salt => config->{scrypt_salt}||die(),
scrypt_args => config->{scrypt_args}||[ 65536, 8, 1 ],
csrf_key => config->{form_salt}||die(),
+ user => {},
}, shift;
}
# Returns 1 on success, 0 on failure
-# When $pretend is true, it only tests if the user/pass combination is correct,
+# When $pretend is true, it only tests if the uid/pass combination is correct,
# but doesn't actually create a session.
sub login {
- my($self, $user, $pass, $pretend) = @_;
- return 0 if $self->uid || !$user || !$pass;
-
- my $uid = tuwf->dbVali('SELECT id FROM users WHERE username =', \$user);
- return 0 if !$uid;
+ my($self, $uid, $pass, $pretend) = @_;
+ return 0 if $self->uid || !$uid || !$pass;
my $encpass = $self->_encpass($uid, $pass);
return 0 if !$encpass;
$self->_create_session($uid, $encpass, $pretend);
@@ -195,24 +204,28 @@ sub logout {
}
+sub wasteTime {
+ my $self = shift;
+ $self->_preparepass(urandom(20));
+}
+
+
# Create a random token that can be used to reset the password.
-# Returns ($uid, $token) if the email address is found in the DB, () otherwise.
+# Returns ($uid, $email, $token) if the email address is found in the DB, () otherwise.
sub resetpass {
my(undef, $mail) = @_;
my $token = unpack 'H*', urandom(20);
- my $id = tuwf->dbVali(
- select => sql_func(user_resetpass => \$mail, sql_fromhex sha1_hex lc $token)
+ my $u = tuwf->dbRowi(
+ 'SELECT uid, mail FROM', sql_func(user_resetpass => \$mail, sql_fromhex sha1_hex lc $token), 'x(uid, mail)'
);
- return $id ? ($id, $token) : ();
+ return $u->{uid} ? ($u->{uid}, $u->{mail}, $token) : ();
}
# Checks if the password reset token is valid
sub isvalidtoken {
my(undef, $uid, $token) = @_;
- tuwf->dbVali(
- select => sql_func(user_isvalidsession => \$uid, sql_fromhex(sha1_hex lc $token), \'pass')
- );
+ tuwf->dbVali('SELECT', sql_func(user_validate_session => \$uid, sql_fromhex(sha1_hex lc $token), \'pass'), 'IS DISTINCT FROM NULL');
}
@@ -253,49 +266,139 @@ sub setmail_confirm {
# less secure). The key is only valid for the current hour, tokens for previous
# hours can be generated by passing a negative $hour_offset.
sub csrftoken {
- my($self, $hour_offset) = @_;
- sha1_hex sprintf '%s%s%d',
+ my($self, $hour_offset, $purpose) = @_;
+ # 6 bytes (8 characters in base64) gives 48 bits of security; That's
+ # not the 160 bits of a full sha1 hash, but still more than good enough
+ # to make random guesses impractical.
+ encode_base64url substr sha1(sprintf 'p=%s;k=%s;s=%s;t=%d;',
+ $purpose||'', # Purpose
$self->{csrf_key} || 'csrf-token', # Server secret
$self->{token} || norm_ip(tuwf->reqIP), # User secret
- (time/3600)+($hour_offset||0); # Time limitation
+ (time/3600)+($hour_offset||0) # Time limitation
+ ), 0, 6
}
# Returns 1 if the given CSRF token is still valid (meaning: created for this
# user within the past 12 hours), 0 otherwise.
sub csrfcheck {
- my($self, $token) = @_;
- $self->csrftoken($_) eq $token && return 1 for reverse -11..0;
+ my($self, $token, $purpose) = @_;
+ $self->csrftoken($_, $purpose) eq $token && return 1 for reverse -11..0;
return 0;
}
-# TODO: Measure global usage of the pref() and prefSet() calls to see if this cache is actually necessary.
-
-my @pref_columns = qw/
- email_confirmed skin customcss filter_vn filter_release show_nsfw notify_dbedit notify_announce
- vn_list_own vn_list_wish tags_all tags_cont tags_ero tags_tech spoilers traits_sexual
- nodistract_can nodistract_noads nodistract_nofancy
-/;
-
-# Returns a user preference column for the current user. Lazily loads all
-# preferences to speed of subsequent calls.
sub pref {
my($self, $key) = @_;
return undef if !$self->uid;
+ croak "Pref key not loaded: $key" if !exists $self->{user}{$key};
+ $self->{user}{$key};
+}
+
+
+# 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) = @_;
+ tuwf->dbExeci('INSERT INTO audit_log', {
+ by_uid => $self->uid(),
+ by_name => $self->{user}{user_name},
+ by_ip => VNWeb::Validation::ipinfo(),
+ affected_uid => $affected_uid||undef,
+ affected_name => $affected_uid ? sql('(SELECT username FROM users WHERE id =', \$affected_uid, ')') : undef,
+ action => $action,
+ detail => $detail,
+ });
+}
+
+
+
+my $api2_alpha = "ybndrfg8ejkmcpqxot1uwisza345h769"; # z-base-32
+
+# Converts from hex to encoded form
+sub _api2_encode {
+ state %l = map +(substr(unpack('B*', chr $_), 3, 8), substr($api2_alpha, $_, 1)), 0..(length($api2_alpha)-1);
+ (unpack('B*', pack('H*', $_[0])) =~ s/(.....)/$l{$1}/erg)
+ =~ s/(....)(.....)(.....)(....)(.....)(.....)(....)/$1-$2-$3-$4-$5-$6-$7/r;
+}
+# Converts from encoded form to hex
+sub _api2_decode {
+ state %l = ('-', '', map +(substr($api2_alpha, $_, 1), substr unpack('B*', chr $_), 3, 8), 0..(length($api2_alpha)-1));
+ unpack 'H*', pack 'B*', $_[0] =~ s{(.)}{$l{$1} // return}erg
+}
+
+# Takes a UID, returns hex value
+sub _api2_gen_token {
+ # Scramble for cosmetic reasons. This bytewise scramble still leaves an obvious pattern, but w/e.
+ unpack 'H*', (pack('N', $_[0] =~ s/^u//r).urandom(16))
+ =~ s/^(.)(.)(.)(.)(..)(....)(....)(....)(..)$/$5$1$6$2$7$3$8$4$9/sr;
+}
+
+# Extract UID from hex-encoded token
+sub _api2_get_uid {
+ 'u'.unpack 'N', pack('H*', $_[0]) =~ s/^..(.)....(.)....(.)....(.)..$/$1$2$3$4/sr;
+}
- $self->{pref} ||= tuwf->dbRowi('SELECT', sql_comma(map "\"$_\"", @pref_columns), 'FROM users WHERE id =', \$self->uid);
- $self->{pref}{$key};
+
+sub _load_api2 {
+ my($self, $header) = @_;
+ return if !$header;
+ return VNWeb::API::err(401, 'Invalid Authorization header format.') if $header !~ /^(?i:Token) +([-$api2_alpha]+)$/;
+ my $token_enc = $1;
+ return VNWeb::API::err(401, 'Invalid token format.') if length($token_enc =~ s/-//rg) != 32 || !length(my $token = _api2_decode $token_enc);
+ my $uid = _api2_get_uid $token;
+ my $user = tuwf->dbRowi(
+ 'SELECT ', sql_user(), ', x.listread, x.listwrite
+ FROM users u, users_shadow us, ', sql_func(user_validate_session => \$uid, sql_fromhex($token), \'api2'), 'x
+ WHERE u.id = ', \$uid, 'AND x.uid = u.id AND us.id = u.id AND us.delete_at IS NULL'
+ );
+ return VNWeb::API::err(401, 'Invalid token.') if !$user->{user_id};
+ $self->{token} = $token;
+ $self->{user} = $user;
+ $self->{api2} = 1;
+}
+
+sub api2_tokens {
+ my($self, $uid) = @_;
+ return [] if !$self;
+ my $r = tuwf->dbAlli("
+ SELECT coalesce(notes, '') AS notes, listread, listwrite, added::date,", sql_tohex('token'), "AS token
+ , (CASE WHEN expires = added THEN '' ELSE expires::date::text END) AS lastused
+ FROM", sql_func(user_api2_tokens => \$uid, \$self->uid, sql_fromhex($self->{token})), '
+ ORDER BY added');
+ $_->{token} = _api2_encode($_->{token}) for @$r;
+ $r;
}
+sub api2_set_token {
+ my($self, $uid, %o) = @_;
+ return if !auth;
+ my $token = $o{token} ? _api2_decode($o{token}) : _api2_gen_token($uid);
+ tuwf->dbExeci(select => sql_func user_api2_set_token => \$uid, \$self->uid, sql_fromhex($self->{token}),
+ sql_fromhex($token), \$o{notes}, \($o{listread}//0), \($o{listwrite}//0));
+ _api2_encode($token);
+}
-sub prefSet {
- my($self, $key, $value, $uid) = @_;
- die "Unknown pref key: $_" if !grep $key eq $_, @pref_columns;
- $uid //= $self->uid;
- $self->{pref}{$key} = $value;
- tuwf->dbExeci(qq{UPDATE users SET "$key" =}, \$value, 'WHERE id =', \$self->uid);
+sub api2_del_token {
+ my($self, $uid, $token) = @_;
+ return if !$self;
+ tuwf->dbExeci(select => sql_func user_api2_del_token => \$uid, \$self->uid, sql_fromhex($self->{token}), sql_fromhex(_api2_decode($token)));
}
+# API-specific permission checks
+# (Always return true for cookie-based auth)
+sub api2Listread { $_[0]{user}{user_id} && (!$_[1] || $_[0]{user}{user_id} eq $_[1]) && (!$_[0]{api2} || $_[0]{user}{listread}) }
+sub api2Listwrite { $_[0]{user}{user_id} && (!$_[1] || $_[0]{user}{user_id} eq $_[1]) && (!$_[0]{api2} || $_[0]{user}{listwrite}) }
+
1;
diff --git a/lib/VNWeb/Chars/Edit.pm b/lib/VNWeb/Chars/Edit.pm
new file mode 100644
index 00000000..5927ccaf
--- /dev/null
+++ b/lib/VNWeb/Chars/Edit.pm
@@ -0,0 +1,163 @@
+package VNWeb::Chars::Edit;
+
+use VNWeb::Prelude;
+use VNWeb::Images::Lib 'enrich_image';
+use VNWeb::Releases::Lib;
+
+
+my $FORM = {
+ id => { default => undef, vndbid => 'c' },
+ name => { sl => 1, maxlength => 200 },
+ latin => { default => undef, sl => 1, maxlength => 200 },
+ alias => { default => '', maxlength => 500 },
+ description=> { default => '', maxlength => 5000 },
+ gender => { default => 'unknown', enum => \%GENDER },
+ spoil_gender=>{ default => undef, enum => \%GENDER },
+ b_month => { default => 0, uint => 1, range => [ 0, 12 ] },
+ b_day => { default => 0, uint => 1, range => [ 0, 31 ] },
+ age => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ s_bust => { default => 0, uint => 1, range => [ 0, 32767 ] },
+ s_waist => { default => 0, uint => 1, range => [ 0, 32767 ] },
+ s_hip => { default => 0, uint => 1, range => [ 0, 32767 ] },
+ height => { default => 0, uint => 1, range => [ 0, 32767 ] },
+ weight => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ bloodt => { default => 'unknown', enum => \%BLOOD_TYPE },
+ cup_size => { default => '', enum => \%CUP_SIZE },
+ main => { default => undef, vndbid => 'c' },
+ main_spoil => { uint => 1, range => [0,2] },
+ main_ref => { _when => 'out', anybool => 1 },
+ main_name => { _when => 'out', default => '' },
+ image => { default => undef, vndbid => 'ch' },
+ image_info => { _when => 'out', default => undef, type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
+ traits => { sort_keys => 'id', aoh => {
+ tid => { vndbid => 'i' },
+ spoil => { uint => 1, range => [0,2] },
+ lie => { anybool => 1 },
+ name => { _when => 'out' },
+ group => { _when => 'out', default => undef },
+ hidden => { _when => 'out', anybool => 1 },
+ locked => { _when => 'out', anybool => 1 },
+ applicable => { _when => 'out', anybool => 1 },
+ new => { _when => 'out', anybool => 1 },
+ } },
+ vns => { sort_keys => ['vid', 'rid'], aoh => {
+ vid => { vndbid => 'v' },
+ rid => { vndbid => 'r', default => undef },
+ spoil => { uint => 1, range => [0,2] },
+ role => { enum => \%CHAR_ROLE },
+ title => { _when => 'out' },
+ } },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+
+ authmod => { _when => 'out', anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+ releases => { _when => 'out', aoh => {
+ id => { vndbid => 'r' },
+ rels => $VNWeb::Elm::apis{Releases}[0]
+ } },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_CMP = form_compile cmp => $FORM;
+
+
+TUWF::get qr{/$RE{crev}/(?<action>edit|copy)} => sub {
+ my $e = db_entry tuwf->captures('id', 'rev') or return tuwf->resNotFound;
+ my $copy = tuwf->capture('action') eq 'copy';
+ return tuwf->resDenied if !can_edit c => $copy ? {} : $e;
+
+ $e->{main_name} = $e->{main} ? tuwf->dbVali('SELECT title[1+1] FROM', charst, 'c WHERE id =', \$e->{main}) : '';
+ $e->{main_ref} = tuwf->dbVali('SELECT 1 FROM chars WHERE main =', \$e->{id})||0;
+
+ enrich_merge tid => sql(
+ 'SELECT t.id AS tid, t.name, t.hidden, t.locked, t.applicable, g.name AS group, g.gorder AS order, false AS new
+ FROM traits t
+ LEFT JOIN traits g ON g.id = t.gid
+ WHERE', $copy ? 'NOT t.hidden AND t.applicable AND' : (), 't.id IN'), $e->{traits};
+ $e->{traits} = [ sort { ($a->{order}//99) <=> ($b->{order}//99) || $a->{name} cmp $b->{name} } grep !$copy || $_->{applicable}, $e->{traits}->@* ];
+
+ enrich_merge vid => sql('SELECT id AS vid, title[1+1] AS title, sorttitle FROM', vnt, 'v WHERE id IN'), $e->{vns};
+ $e->{vns} = [ sort { $a->{sorttitle} cmp $b->{sorttitle} || idcmp($a->{vid}, $b->{vid}) || idcmp($a->{rid}||'r0', $b->{rid}||'r0') } $e->{vns}->@* ];
+
+ my %vns;
+ $e->{releases} = [ map !$vns{$_->{vid}}++ ? { id => $_->{vid}, rels => releases_by_vn $_->{vid} } : (), $e->{vns}->@* ];
+
+ if($e->{image}) {
+ $e->{image_info} = { id => $e->{image} };
+ enrich_image 0, [$e->{image_info}];
+ } else {
+ $e->{image_info} = undef;
+ }
+
+ $e->{authmod} = auth->permDbmod;
+ $e->{editsum} = $copy ? "Copied from $e->{id}.$e->{chrev}" : $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
+
+ my $title = ($copy ? 'Copy ' : 'Edit ').dbobj($e->{id})->{title}[1];
+ framework_ title => $title, dbobj => $e, tab => tuwf->capture('action'),
+ sub {
+ editmsg_ c => $e, $title, $copy;
+ elm_ CharEdit => $FORM_OUT, $copy ? {%$e, id=>undef} : $e;
+ };
+};
+
+
+TUWF::get qr{/$RE{vid}/addchar}, sub {
+ return tuwf->resDenied if !can_edit c => undef;
+ my $v = tuwf->dbRowi('SELECT id, title[1+1] AS title FROM', vnt, 'v WHERE NOT hidden AND id =', \tuwf->capture('id'));
+ return tuwf->resNotFound if !$v->{id};
+
+ my $e = elm_empty($FORM_OUT);
+ $e->{vns} = [{ vid => $v->{id}, title => $v->{title}, rid => undef, spoil => 0, role => 'primary' }];
+ $e->{releases} = [{ id => $v->{id}, rels => releases_by_vn $v->{id} }];
+
+ framework_ title => 'Add character',
+ sub {
+ editmsg_ c => undef, 'Add character';
+ elm_ CharEdit => $FORM_OUT, $e;
+ };
+};
+
+
+elm_api CharEdit => $FORM_OUT, $FORM_IN, sub {
+ my $data = shift;
+ my $new = !$data->{id};
+ my $e = $new ? {} : db_entry $data->{id} or return tuwf->resNotFound;
+ return elm_Unauth if !can_edit c => $e;
+
+ if(!auth->permDbmod) {
+ $data->{hidden} = $e->{hidden}||0;
+ $data->{locked} = $e->{locked}||0;
+ }
+ $data->{description} = bb_subst_links $data->{description};
+ $data->{b_day} = 0 if !$data->{b_month};
+
+ $data->{main} = undef if $data->{hidden};
+ die "Attempt to set main to self" if $data->{main} && $e->{id} && $data->{main} eq $e->{id};
+ die "Attempt to set main while this character is already referenced." if $data->{main} && tuwf->dbVali('SELECT 1 AS ref FROM chars WHERE main =', \$e->{id});
+ # It's possible that the referenced character has been deleted since it was added as main, so don't die() on this one, just unset main.
+ $data->{main} = undef if $data->{main} && !tuwf->dbVali('SELECT 1 FROM chars WHERE NOT hidden AND main IS NULL AND id =', \$data->{main});
+ $data->{main_spoil} = 0 if !$data->{main};
+
+ validate_dbid 'SELECT id FROM images WHERE id IN', $data->{image} if $data->{image};
+
+ # Allow non-applicable or non-approved traits only when they were already applied to this character.
+ validate_dbid
+ sql('SELECT id FROM traits t WHERE ((NOT hidden AND applicable) OR EXISTS(SELECT 1 FROM chars_traits ct WHERE ct.tid = t.id AND ct.id =', \$e->{id}, ')) AND id IN'),
+ map $_->{tid}, $data->{traits}->@*;
+
+ validate_dbid 'SELECT id FROM vn WHERE id IN', map $_->{vid}, $data->{vns}->@*;
+ # XXX: This will also die when the release has been moved to a different VN
+ # and the char hasn't been updated yet. Would be nice to give a better
+ # error message in that case.
+ for($data->{vns}->@*) {
+ die "Bad release for $_->{vid}: $_->{rid}\n" if defined $_->{rid} && !tuwf->dbVali('SELECT 1 FROM releases_vn WHERE id =', \$_->{rid}, 'AND vid =', \$_->{vid});
+ }
+
+ return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
+ my $ch = db_edit c => $e->{id}, $data;
+ elm_Redirect "/$ch->{nitemid}.$ch->{nrev}";
+};
+
+1;
diff --git a/lib/VNWeb/Chars/Elm.pm b/lib/VNWeb/Chars/Elm.pm
new file mode 100644
index 00000000..ad8d723c
--- /dev/null
+++ b/lib/VNWeb/Chars/Elm.pm
@@ -0,0 +1,23 @@
+package VNWeb::Chars::Elm;
+
+use VNWeb::Prelude;
+
+elm_api Chars => undef, { search => { searchquery => 1 } }, sub {
+ my $q = shift->{search};
+
+ my $l = $q ? tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT c.id, c.title[1+1] AS title, c.title[1+1+1+1] AS alttitle, c.main, cm.title[1+1] AS main_title, cm.title[1+1+1+1] AS main_alttitle
+ FROM', charst, 'c', $q->sql_join('c', 'c.id'), '
+ LEFT JOIN', charst, 'cm ON cm.id = c.main
+ WHERE NOT c.hidden
+ ORDER BY sc.score DESC, c.sorttitle
+ ') : [];
+ for (@$l) {
+ $_->{main} = { id => $_->{main}, title => $_->{main_title}, alttitle => $_->{main_alttitle} } if $_->{main};
+ delete $_->{main_title};
+ delete $_->{main_alttitle};
+ }
+ elm_CharResult $l;
+};
+
+1;
diff --git a/lib/VNWeb/Chars/List.pm b/lib/VNWeb/Chars/List.pm
new file mode 100644
index 00000000..87172f4a
--- /dev/null
+++ b/lib/VNWeb/Chars/List.pm
@@ -0,0 +1,146 @@
+package VNWeb::Chars::List;
+
+use VNWeb::Prelude;
+use VNWeb::AdvSearch;
+use VNWeb::Filters;
+use VNWeb::Images::Lib;
+
+our $TABLEOPTS = tableopts
+ _pref => 'tableopts_c',
+ _views => [qw|rows cards grid|];
+
+
+# Also used by VNWeb::TT::TraitPage
+sub listing_ {
+ my($opt, $list, $count) = @_;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+ paginate_ \&url, $opt->{p}, [$count, $opt->{s}->results], 't', $opt->{s};
+
+ article_ class => 'browse charb', sub {
+ table_ class => 'stripe', sub {
+ tr_ sub {
+ td_ class => 'tc1', sub {
+ abbr_ class => "icon-gen-$_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
+ };
+ td_ class => 'tc2', sub {
+ a_ href => "/$_->{id}", tattr $_;
+ small_ sub {
+ join_ ', ', sub { a_ href => "/$_->{id}", tattr $_ }, $_->{vn}->@*;
+ };
+ };
+ } for @$list;
+ }
+ } if $opt->{s}->rows;
+
+ article_ class => 'charbcard', sub {
+ my($w,$h) = (90,120);
+ div_ sub {
+ div_ sub {
+ if($_->{image}) {
+ my($iw,$ih) = imgsize $_->{image}{width}*100, $_->{image}{height}*100, $w, $h;
+ image_ $_->{image}, alt => $_->{title}[1], width => $iw, height => $ih, url => "/$_->{id}", overlay => undef;
+ } else {
+ txt_ 'no image';
+ }
+ };
+ div_ sub {
+ abbr_ class => "icon-gen-$_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
+ a_ href => "/$_->{id}", tattr $_;
+ br_;
+ small_ sub {
+ join_ ', ', sub { a_ href => "/$_->{id}", tattr $_ }, $_->{vn}->@*;
+ };
+ };
+ } for @$list;
+ } if $opt->{s}->cards;
+
+
+ article_ class => 'charbgrid', sub {
+ a_ href => "/$_->{id}", title => $_->{title}[3],
+ !$_->{image} || image_hidden($_->{image}) ? () : (style => 'background-image: url("'.imgurl($_->{image}{id}).'")'),
+ sub {
+ span_ $_->{title}[1];
+ } for @$list;
+ } if $opt->{s}->grid;
+
+ paginate_ \&url, $opt->{p}, [$count, $opt->{s}->results], 'b';
+}
+
+
+# Also used by VNWeb::TT::TraitPage
+sub enrich_listing {
+ enrich vn => id => cid => sub { sql '
+ SELECT DISTINCT cv.id AS cid, v.id, v.title, v.sorttitle
+ FROM chars_vns cv
+ JOIN', vnt, 'v ON v.id = cv.vid
+ WHERE NOT v.hidden AND cv.spoil = 0 AND cv.id IN', $_, '
+ ORDER BY v.sorttitle'
+ }, @_;
+}
+
+
+TUWF::get qr{/c(?:/(?<char>all|[a-z0]))?}, sub {
+ my $opt = tuwf->validate(get =>
+ q => { searchquery => 1 },
+ p => { upage => 1 },
+ f => { advsearch_err => 'c' },
+ ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
+ fil=>{ onerror => '' },
+ s => { tableopts => $TABLEOPTS },
+ )->data;
+ $opt->{ch} = $opt->{ch}[0];
+
+ # compat with old URLs
+ my $oldch = tuwf->capture('char');
+ $opt->{ch} //= $oldch if defined $oldch && $oldch ne 'all';
+
+ # URL compatibility with old filters
+ if(!$opt->{f}->{query} && $opt->{fil}) {
+ my $q = eval {
+ my $f = filter_char_adv filter_parse c => $opt->{fil};
+ tuwf->compile({ advsearch => 'c' })->validate(@$f > 1 ? $f : undef)->data;
+ };
+ return tuwf->resRedirect(tuwf->reqPath().'?'.query_encode(%$opt, fil => undef, f => $q), 'perm') if $q;
+ }
+
+ $opt->{f} = advsearch_default 'c' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
+
+ my $where = sql_and
+ 'NOT c.hidden', $opt->{f}->sql_where(),
+ defined($opt->{ch}) ? sql 'match_firstchar(c.sorttitle, ', \$opt->{ch}, ')' : ();
+
+ my $time = time;
+ my($count, $list);
+ db_maytimeout {
+ $count = tuwf->dbVali('SELECT count(*) FROM', charst, 'c WHERE', sql_and $where, $opt->{q}->sql_where('c', 'c.id'));
+ $list = $count ? tuwf->dbPagei({results => $opt->{s}->results(), page => $opt->{p}}, '
+ SELECT c.id, c.title, c.gender, c.image
+ FROM', charst, 'c', $opt->{q}->sql_join('c', 'c.id'), '
+ WHERE', $where, '
+ ORDER BY', $opt->{q} ? 'sc.score DESC, ' : (), 'c.sorttitle, c.id'
+ ) : [];
+ } || (($count, $list) = (undef, []));
+
+ enrich_listing $list;
+ enrich_image_obj image => $list if !$opt->{s}->rows;
+ $time = time - $time;
+
+ framework_ title => 'Browse characters', sub {
+ form_ action => '/c', method => 'get', sub {
+ article_ sub {
+ h1_ 'Browse characters';
+ searchbox_ c => $opt->{q}//'';
+ p_ class => 'browseopts', sub {
+ button_ type => 'submit', name => 'ch', value => ($_//''), ($_//'') eq ($opt->{ch}//'') ? (class => 'optselected') : (), !defined $_ ? 'ALL' : $_ ? uc $_ : '#'
+ for (undef, 'a'..'z', 0);
+ };
+ input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
+ $opt->{f}->elm_($count, $time);
+ };
+ listing_ $opt, $list, $count if $count;
+ }
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Chars/Page.pm b/lib/VNWeb/Chars/Page.pm
new file mode 100644
index 00000000..e6ffc7e7
--- /dev/null
+++ b/lib/VNWeb/Chars/Page.pm
@@ -0,0 +1,340 @@
+package VNWeb::Chars::Page;
+
+use VNWeb::Prelude;
+use VNWeb::Images::Lib qw/image_ enrich_image_obj/;
+
+
+sub enrich_seiyuu {
+ my($vid, @chars) = @_;
+ enrich seiyuu => id => cid => sub { sql '
+ SELECT DISTINCT vs.cid, sa.id, sa.title, sa.sorttitle, vs.note
+ FROM vn_seiyuu vs
+ ', $vid ? () : ('JOIN vn v ON v.id = vs.id'), '
+ JOIN', staff_aliast, 'sa ON sa.aid = vs.aid
+ WHERE ', $vid ? ('vs.id =', \$vid) : ('NOT v.hidden'), 'AND vs.cid IN', $_, '
+ ORDER BY sa.sorttitle'
+ }, @chars;
+}
+
+sub sql_trait_overrides {
+ sql '(
+ WITH RECURSIVE trait_overrides (tid, spoil, color, childs, lvl) AS (
+ SELECT tid, spoil, color, childs, 0 FROM users_prefs_traits WHERE id =', \auth->uid, '
+ UNION ALL
+ SELECT tp.id, x.spoil, x.color, true, lvl+1
+ FROM trait_overrides x
+ JOIN traits_parents tp ON tp.parent = x.tid
+ WHERE x.childs
+ ) SELECT DISTINCT ON(tid) tid, spoil, color FROM trait_overrides ORDER BY tid, lvl
+ )';
+}
+
+sub enrich_item {
+ my($c) = @_;
+
+ enrich_image_obj image => $c;
+ enrich_merge vid => sql('SELECT id AS vid, title, sorttitle, c_released AS vn_released FROM', vnt, 'v WHERE id IN'), $c->{vns};
+ enrich_merge rid => sql('SELECT id AS rid, title AS rtitle, released AS rel_released FROM', releasest, 'r WHERE id IN'), grep $_->{rid}, $c->{vns}->@*;
+
+ # Even with trait overrides, we'll want to see the raw data in revision diffs,
+ # so fetch the raw spoil as a separate column and do filtering/processing later.
+ enrich_merge tid => sub { sql '
+ SELECT t.id AS tid, t.name, t.hidden, t.locked, t.applicable, t.sexual, x.spoil AS override, x.color
+ , coalesce(g.id, t.id) AS group, coalesce(g.name, t.name) AS groupname, coalesce(g.gorder,0) AS order
+ FROM traits t
+ LEFT JOIN traits g ON t.gid = g.id
+ LEFT JOIN', sql_trait_overrides(), 'x ON x.tid = t.id
+ WHERE t.id IN', $_
+ }, $c->{traits};
+
+ $c->{vns} = [ sort { $a->{vn_released} <=> $b->{vn_released} || ($a->{rel_released}||0) <=> ($b->{rel_released}||0)
+ || $a->{sorttitle} cmp $b->{sorttitle} || idcmp($a->{vid}, $b->{vid}) || idcmp($a->{rid}||'r999999', $b->{rid}||'r999999') } $c->{vns}->@* ];
+ $c->{traits} = [ sort { $a->{order} <=> $b->{order} || $a->{groupname} cmp $b->{groupname} || $a->{name} cmp $b->{name} } $c->{traits}->@* ];
+
+ $c->{quotes} = tuwf->dbAlli('
+ SELECT q.vid, q.id, q.score, q.quote,', sql_totime('q.added'), 'AS added, q.addedby
+ FROM quotes q
+ WHERE NOT q.hidden AND vid IN', [map $_->{vid}, $c->{vns}->@*], 'AND q.cid =', \$c->{id}, '
+ ORDER BY q.score DESC, q.quote
+ ');
+ enrich_merge id => sql('SELECT id, vote FROM quotes_votes WHERE uid =', \auth->uid, 'AND id IN'), $c->{quotes} if auth;
+}
+
+
+# Fetch multiple character entries with a format suitable for chartable_()
+# Also used by Chars::VNTab.
+sub fetch_chars {
+ my($vid, $where) = @_;
+ my $l = tuwf->dbAlli('
+ SELECT id, title, alias, description, gender, spoil_gender, b_month, b_day, s_bust, s_waist, s_hip, height, weight, bloodt, cup_size, age, image
+ FROM', charst, 'c WHERE NOT hidden AND (', $where, ')
+ ORDER BY sorttitle
+ ');
+
+ enrich vns => id => id => sub { sql '
+ SELECT cv.id, cv.vid, cv.rid, cv.spoil, cv.role, v.title, r.title AS rtitle
+ FROM chars_vns cv
+ JOIN', vnt, 'v ON v.id = cv.vid
+ LEFT JOIN', releasest, 'r ON r.id = cv.rid
+ WHERE cv.id IN', $_, $vid ? ('AND cv.vid =', \$vid) : (), '
+ ORDER BY v.c_released, r.released, v.sorttitle, cv.vid, cv.rid NULLS LAST'
+ }, $l;
+
+ enrich traits => id => id => sub { sql '
+ SELECT ct.id, ct.tid, ct.spoil, x.spoil AS override, x.color, ct.lie, t.name, t.hidden, t.locked, t.sexual
+ , coalesce(g.id, t.id) AS group, coalesce(g.name, t.name) AS groupname, coalesce(g.gorder,0) AS order
+ FROM chars_traits ct
+ JOIN traits t ON t.id = ct.tid
+ LEFT JOIN traits g ON t.gid = g.id
+ LEFT JOIN', sql_trait_overrides(), 'x ON x.tid = ct.tid
+ WHERE x.spoil IS DISTINCT FROM 1+1+1 AND ct.id IN', $_, '
+ ORDER BY g.gorder NULLS FIRST, coalesce(g.name, t.name), t.name'
+ }, $l;
+
+ enrich_seiyuu $vid, $l;
+ enrich_image_obj image => $l;
+ $l
+}
+
+
+sub _rev_ {
+ my($c) = @_;
+ revision_ $c, \&enrich_item,
+ [ name => 'Name' ],
+ [ latin => 'Name (latin)' ],
+ [ alias => 'Aliases' ],
+ [ description=> 'Description' ],
+ [ gender => 'Sex', fmt => \%GENDER ],
+ [ spoil_gender=> 'Sex (spoiler)',fmt => \%GENDER ],
+ [ b_month => 'Birthday/month',empty => 0 ],
+ [ b_day => 'Birthday/day', empty => 0 ],
+ [ s_bust => 'Bust', empty => 0 ],
+ [ s_waist => 'Waist', empty => 0 ],
+ [ s_hip => 'Hips', empty => 0 ],
+ [ height => 'Height', empty => 0 ],
+ [ weight => 'Weight', ],
+ [ bloodt => 'Blood type', fmt => \%BLOOD_TYPE ],
+ [ cup_size => 'Cup size', fmt => \%CUP_SIZE ],
+ [ age => 'Age', ],
+ [ main => 'Instance of', empty => 0, fmt => sub {
+ my $c = tuwf->dbRowi('SELECT id, title FROM', charst, 'c WHERE id =', \$_);
+ a_ href => "/$c->{id}", title => $c->{title}[1], $c->{id}
+ } ],
+ [ main_spoil => 'Spoiler', fmt => sub { txt_ fmtspoil $_ } ],
+ [ image => 'Image', fmt => sub { image_ $_ } ],
+ [ vns => 'Visual novels', fmt => sub {
+ a_ href => "/$_->{vid}", tlang(@{$_->{title}}[0,1]), title => $_->{title}[1], $_->{vid};
+ if($_->{rid}) {
+ txt_ ' ['; a_ href => "/$_->{rid}", $_->{rid}; txt_ ']';
+ }
+ txt_ " $CHAR_ROLE{$_->{role}}{txt} (".fmtspoil($_->{spoil}).')';
+ } ],
+ [ traits => 'Traits', fmt => sub {
+ small_ "$_->{groupname} / " if $_->{group} ne $_->{tid};
+ a_ href => "/$_->{tid}", $_->{name};
+ txt_ ' ('.fmtspoil($_->{spoil}).($_->{lie} ? ', lie':'').')';
+ b_ ' (awaiting moderation)' if $_->{hidden} && !$_->{locked};
+ b_ ' (trait deleted)' if $_->{hidden} && $_->{locked};
+ b_ ' (not applicable)' if !$_->{applicable};
+ } ],
+}
+
+
+# Also used by Chars::VNTab
+sub chartable_ {
+ my($c, $link, $sep, $vn) = @_;
+ my $view = viewget;
+
+ my @visvns = grep $_->{spoil} <= $view->{spoilers}, $c->{vns}->@*;
+
+ div_ mkclass(chardetails => 1, charsep => $sep), sub {
+ div_ class => 'charimg', sub { image_ $c->{image}, alt => $c->{title}[1] };
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub { td_ colspan => 2, sub {
+ $link
+ ? a_ href => "/$c->{id}", style => 'margin-right: 10px; font-weight: bold', tlang($c->{title}[0], $c->{title}[1]), $c->{title}[1]
+ : span_ style => 'margin-right: 10px', tlang($c->{title}[0], $c->{title}[1]), $c->{title}[1];
+ small_ style => 'margin-right: 10px', tlang($c->{title}[2], $c->{title}[3]), $c->{title}[3] if $c->{title}[3] ne $c->{title}[1];
+ abbr_ class => "icon-gen-$c->{gender}", title => $GENDER{$c->{gender}}, '' if $c->{gender} ne 'unknown';
+ if($view->{spoilers} == 2 && defined $c->{spoil_gender}) {
+ txt_ '(';
+ abbr_ class => "icon-gen-$c->{spoil_gender}", title => $GENDER{$c->{spoil_gender}}, '' if $c->{spoil_gender} ne 'unknown';
+ txt_ 'unknown' if $c->{spoil_gender} eq 'unknown';
+ spoil_ 2;
+ txt_ ')';
+ }
+ span_ $BLOOD_TYPE{$c->{bloodt}} if $c->{bloodt} ne 'unknown';
+ debug_ $c;
+ }}};
+
+ tr_ sub {
+ td_ class => 'key', 'Aliases';
+ td_ $c->{alias} =~ s/\n/, /rg;
+ } if $c->{alias};
+
+ tr_ sub {
+ td_ class => 'key', 'Measurements';
+ td_ join ', ',
+ $c->{height} ? "Height: $c->{height}cm" : (),
+ defined($c->{weight}) ? "Weight: $c->{weight}kg" : (),
+ $c->{s_bust} || $c->{s_waist} || $c->{s_hip} ?
+ sprintf 'Bust-Waist-Hips: %s-%s-%scm', $c->{s_bust}||'??', $c->{s_waist}||'??', $c->{s_hip}||'??' : (),
+ $c->{cup_size} ? "$CUP_SIZE{$c->{cup_size}} cup" : ();
+ } if defined($c->{weight}) || $c->{height} || $c->{s_bust} || $c->{s_waist} || $c->{s_hip} || $c->{cup_size};
+
+ tr_ sub {
+ td_ class => 'key', 'Birthday';
+ td_ $c->{b_day}.' '.[qw{January February March April May June July August September October November December}]->[$c->{b_month}-1];
+ } if $c->{b_day} && $c->{b_month};
+
+ tr_ sub {
+ td_ class => 'key', 'Age';
+ td_ $c->{age};
+ } if defined $c->{age};
+
+ my @groups;
+ for(grep !$_->{hidden} && ($_->{override}//$_->{spoil}) <= $view->{spoilers} && (!$_->{sexual} || $view->{traits_sexual}), $c->{traits}->@*) {
+ push @groups, $_ if !@groups || $groups[$#groups]{group} ne $_->{group};
+ push $groups[$#groups]{traits}->@*, $_;
+ }
+ tr_ class => "trait_group_$_->{group}", sub {
+ td_ class => 'key', sub { a_ href => "/$_->{group}", $_->{groupname} };
+ td_ sub { join_ ', ', sub {
+ a_ href => "/$_->{tid}", mkclass(
+ $_->{color} ? ($_->{color}, $_->{color} =~ /standout|grayedout/ ? 1 : 0) : (),
+ lie => $_->{lie} && (($_->{override}//1) <= 0 || $view->{spoilers} >= 2),
+ ), ($_->{color}//'') =~ /^#/ ? (style => "color: $_->{color}") : (),
+ $_->{name};
+ spoil_ $_->{spoil};
+ }, $_->{traits}->@* };
+ } for @groups;
+
+ tr_ sub {
+ td_ class => 'key', $vn ? 'Releases' : 'Visual novels';
+ td_ sub {
+ my @vns;
+ for(@visvns) {
+ push @vns, $_ if !@vns || $vns[$#vns]{vid} ne $_->{vid};
+ push $vns[$#vns]{rels}->@*, $_;
+ }
+ join_ \&br_, sub {
+ my $v = $_;
+ # Just a VN link, no releases
+ if(!$vn && $v->{rels}->@* == 1 && !$v->{rels}[0]{rid}) {
+ txt_ $CHAR_ROLE{$v->{role}}{txt}.' - ';
+ a_ href => "/$v->{vid}", tattr $v;
+ spoil_ $v->{spoil};
+ # With releases
+ } else {
+ a_ href => "/$v->{vid}", tattr $v if !$vn;
+ br_ if !$vn;
+ join_ \&br_, sub {
+ small_ '> ';
+ txt_ $CHAR_ROLE{$_->{role}}{txt}.' - ';
+ if($_->{rid}) {
+ small_ "$_->{rid}:";
+ a_ href => "/$_->{rid}", tattr $_->{rtitle};
+ } else {
+ txt_ 'All other releases';
+ }
+ spoil_ $_->{spoil};
+ }, $v->{rels}->@*;
+ }
+ }, @vns;
+ };
+ } if @visvns && (!$vn || $vn && (@visvns > 1 || $visvns[0]{rid}));
+
+ tr_ sub {
+ td_ class => 'key', 'Voiced by';
+ td_ sub {
+ join_ \&br_, sub {
+ a_ href => "/$_->{id}", tattr $_;
+ txt_ " ($_->{note})" if $_->{note};
+ }, $c->{seiyuu}->@*;
+ };
+ } if $c->{seiyuu}->@*;
+
+ tr_ class => 'nostripe', sub {
+ td_ colspan => 2, class => 'chardesc', sub {
+ h2_ 'Description';
+ p_ sub { lit_ bb_format $c->{description}, replacespoil => $view->{spoilers} != 2, keepspoil => $view->{spoilers} == 2 };
+ };
+ } if $c->{description};
+
+ };
+ };
+ clearfloat_;
+
+ my %visvns = map +($_->{vid}, 1), @visvns;
+ my @quotes = grep $visvns{$_->{vid}}, $c->{quotes}->@*;
+ div_ class => 'charquotes', sub {
+ h2_ 'Quotes';
+ table_ sub {
+ tr_ sub {
+ td_ sub { VNWeb::VN::Quotes::votething_($_) };
+ td_ $_->{quote};
+ } for @quotes;
+ };
+ } if @quotes;
+}
+
+
+TUWF::get qr{/$RE{crev}} => sub {
+ my $c = db_entry tuwf->captures('id','rev');
+ return tuwf->resNotFound if !$c;
+
+ enrich_item $c;
+ enrich_seiyuu undef, $c;
+ my $view = viewget;
+
+ my $inst_maxspoil = tuwf->dbVali('SELECT MAX(main_spoil) FROM chars WHERE NOT hidden AND main IN', [ $c->{id}, $c->{main}||() ]);
+
+ my $inst = !defined($inst_maxspoil) || ($c->{main} && $c->{main_spoil} > $view->{spoilers}) ? []
+ : fetch_chars undef, sql
+ # If this entry doesn't have a 'main', look for other entries with a 'main' referencing this entry
+ !$c->{main} ? ('main =', \$c->{id}, 'AND main_spoil <=', \$view->{spoilers}) :
+ # Otherwise, look for other entries with the same 'main', and also fetch the 'main' entry itself
+ ('(id <>', \$c->{id}, 'AND main =', \$c->{main}, 'AND main_spoil <=', \$view->{spoilers}, ') OR id =', \$c->{main});
+
+ my $max_spoil = max(
+ $inst_maxspoil||0,
+ (map $_->{override}//($_->{lie}?2:$_->{spoil}), grep !$_->{hidden} && !(($_->{override}//0) == 3), $c->{traits}->@*),
+ (map $_->{spoil}, $c->{vns}->@*),
+ defined $c->{spoil_gender} ? 2 : 0,
+ $c->{description} =~ /\[spoiler\]/i ? 2 : 0, # crude
+ );
+ # Only display the sexual traits toggle when there are sexual traits within the current spoiler level.
+ my $has_sex = grep !$_->{hidden} && $_->{sexual} && ($_->{override}//$_->{spoil}) <= $view->{spoilers}, map $_->{traits}->@*, $c, @$inst;
+
+ $c->{title} = titleprefs_swap tuwf->dbVali('SELECT c_lang FROM chars WHERE id =', \$c->{id}), @{$c}{qw/ name latin /};
+ framework_ title => $c->{title}[1], index => !tuwf->capture('rev'), dbobj => $c, hiddenmsg => 1,
+ og => {
+ description => bb_format($c->{description}, text => 1),
+ image => $c->{image} && $c->{image}{votecount} && !$c->{image}{sexual} && !$c->{image}{violence} ? imgurl($c->{image}{id}) : undef,
+ },
+ sub {
+ _rev_ $c if tuwf->capture('rev');
+ article_ sub {
+ itemmsg_ $c;
+ h1_ tlang(@{$c->{title}}[0,1]), $c->{title}[1];
+ h2_ class => 'alttitle', tlang(@{$c->{title}}[2,3]), $c->{title}[3] if $c->{title}[3] && $c->{title}[3] ne $c->{title}[1];
+ p_ class => 'chardetailopts', sub {
+ if($max_spoil) {
+ a_ mkclass(checked => $view->{spoilers} == 0), href => '?view='.viewset(spoilers=>0, traits_sexual => $view->{traits_sexual}), 'Hide spoilers';
+ a_ mkclass(checked => $view->{spoilers} == 1), href => '?view='.viewset(spoilers=>1, traits_sexual => $view->{traits_sexual}), 'Show minor spoilers';
+ a_ mkclass(standout =>$view->{spoilers} == 2), href => '?view='.viewset(spoilers=>2, traits_sexual => $view->{traits_sexual}), 'Spoil me!' if $max_spoil == 2;
+ }
+ small_ ' | ' if $has_sex && $max_spoil;
+ a_ mkclass(checked => $view->{traits_sexual}), href => '?view='.viewset(spoilers => $view->{spoilers}, traits_sexual=>!$view->{traits_sexual}), 'Show sexual traits' if $has_sex;
+ };
+ chartable_ $c;
+ };
+
+ article_ sub {
+ h1_ 'Other instances';
+ chartable_ $_, 1, $_ != $inst->[0] for @$inst;
+ } if @$inst;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Chars/VNTab.pm b/lib/VNWeb/Chars/VNTab.pm
new file mode 100644
index 00000000..bea983a6
--- /dev/null
+++ b/lib/VNWeb/Chars/VNTab.pm
@@ -0,0 +1,68 @@
+package VNWeb::Chars::VNTab;
+
+use VNWeb::Prelude;
+
+sub chars_ {
+ my($v) = @_;
+ my $view = viewget;
+ my $chars = VNWeb::Chars::Page::fetch_chars($v->{id}, sql('id IN(SELECT id FROM chars_vns WHERE vid =', \$v->{id}, ')'));
+ return if !@$chars;
+
+ my $max_spoil = max(
+ map max(
+ (map $_->{override}//($_->{lie}?2:$_->{spoil}), grep !$_->{hidden} && !(($_->{override}//0) == 3), $_->{traits}->@*),
+ (map $_->{spoil}, $_->{vns}->@*),
+ defined $_->{spoil_gender} ? 2 : 0,
+ $_->{description} =~ /\[spoiler\]/i ? 2 : 0,
+ ), @$chars
+ );
+ $chars = [ grep +grep($_->{spoil} <= $view->{spoilers}, $_->{vns}->@*), @$chars ];
+ my $has_sex = grep !$_->{hidden} && $_->{sexual} && ($_->{override}//$_->{spoil}) <= $view->{spoilers}, map $_->{traits}->@*, @$chars;
+
+ my sub opts_ {
+ p_ class => 'mainopts', sub {
+ debug_ $chars;
+ if($max_spoil) {
+ a_ mkclass(checked => $view->{spoilers} == 0), href => '?view='.viewset(spoilers=>0,traits_sexual=>$view->{traits_sexual}).'#chars', 'Hide spoilers';
+ a_ mkclass(checked => $view->{spoilers} == 1), href => '?view='.viewset(spoilers=>1,traits_sexual=>$view->{traits_sexual}).'#chars', 'Show minor spoilers';
+ a_ mkclass(standout =>$view->{spoilers} == 2), href => '?view='.viewset(spoilers=>2,traits_sexual=>$view->{traits_sexual}).'#chars', 'Spoil me!' if $max_spoil == 2;
+ }
+ small_ ' | ' if $has_sex && $max_spoil;
+ a_ mkclass(checked => $view->{traits_sexual}), href => '?view='.viewset(spoilers=>$view->{spoilers},traits_sexual=>!$view->{traits_sexual}).'#chars', 'Show sexual traits' if $has_sex;
+ };
+ }
+
+ my %done;
+ my $first = 0;
+ for my $r (keys %CHAR_ROLE) {
+ my @c = grep grep($_->{role} eq $r, $_->{vns}->@*) && !$done{$_->{id}}++, @$chars;
+ next if !@c;
+ article_ sub {
+ opts_ if !$first++;
+ h1_ $CHAR_ROLE{$r}{ @c > 1 ? 'plural' : 'txt' };
+ VNWeb::Chars::Page::chartable_($_, 1, $_ != $c[0], 1) for @c;
+ }
+ }
+
+ article_ sub {
+ opts_;
+ h1_ '(Characters hidden by spoiler settings)';
+ } if !$first;
+}
+
+
+TUWF::get qr{/$RE{vid}/chars}, sub {
+ my $v = db_entry tuwf->capture('id');
+ return tuwf->resNotFound if !$v;
+
+ VNWeb::VN::Page::enrich_vn($v);
+
+ framework_ title => $v->{title}[1], index => 1, dbobj => $v, hiddenmsg => 1,
+ sub {
+ VNWeb::VN::Page::infobox_($v);
+ VNWeb::VN::Page::tabs_($v, 'chars');
+ chars_ $v;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/DB.pm b/lib/VNWeb/DB.pm
index e4905bf0..7eae6db8 100644
--- a/lib/VNWeb/DB.pm
+++ b/lib/VNWeb/DB.pm
@@ -10,9 +10,10 @@ use VNDB::Schema;
our @EXPORT = qw/
sql
- sql_identifier sql_join sql_comma sql_and sql_or sql_array sql_func sql_fromhex sql_tohex sql_fromtime sql_totime sql_user
- enrich enrich_merge enrich_flatten
- db_entry db_edit
+ global_settings
+ sql_join sql_comma sql_and sql_or sql_array sql_func sql_fromhex sql_tohex sql_fromtime sql_totime sql_like sql_user
+ enrich enrich_merge enrich_flatten enrich_obj
+ db_maytimeout db_entry db_edit
/;
@@ -25,7 +26,9 @@ our @EXPORT = qw/
# (and who'd put effort into escaping strings when placeholders are easier?).
sub interp_warn {
my @r = sql_interp @_;
- carp "Possible SQL injection in '$r[0]'" if tuwf->debug && $r[0] =~ /[2-9](?<!r18)/; # 0 and 1 aren't interesting, "SELECT 1" is a common pattern and so is "x > 0"
+ # 0 and 1 aren't interesting, "SELECT 1" is a common pattern and so is "x > 0".
+ # '{7}' is commonly used in ulist filtering and r18/api2 are a valid database identifiers.
+ carp "Possible SQL injection in '$r[0]'" if tuwf->debug && ($r[0] =~ s/(?:r18|\{7\}|api2)//rg) =~ /[2-9]/;
return @r;
}
@@ -45,13 +48,6 @@ $Carp::Internal{ (__PACKAGE__) }++;
# sql_* are macros for SQL::Interp use
-# A table, column or function name
-sub sql_identifier($) {
- carp "Invalid identifier '$_[0]'" if $_[0] !~ /^[a-z_][a-z0-9_]*$/; # This regex is specific to VNDB
- $_[0] =~ /^(?:desc|group|order)$/ ? qq{"$_[0]"} : $_[0]
-}
-
-
# join(), but for sql objects.
sub sql_join {
my $sep = shift;
@@ -72,7 +68,7 @@ sub sql_array { 'ARRAY[', sql_join(',', map \$_, @_), ']' }
# Call an SQL function
sub sql_func {
my($funcname, @args) = @_;
- sql sql_identifier($funcname), '(', sql_comma(@args), ')';
+ sql $funcname, '(', sql_comma(@args), ')';
}
# Convert a Perl hex value into Postgres bytea
@@ -95,11 +91,16 @@ sub sql_totime($) {
sql "extract('epoch' from ", $_[0], ')';
}
+# Escape a string to be used as a literal match in a LIKE pattern.
+sub sql_like($) {
+ $_[0] =~ s/([%_\\])/\\$1/rg
+}
+
# Returns a list of column names to fetch for displaying a username with HTML::user_().
# Arguments: Name of the 'users' table (default: 'u'), prefix for the fetched fields (default: 'user_').
# (This function returns a plain string so that old non-SQL-Interp functions can also use it)
sub sql_user {
- my $tbl = sql_identifier(shift||'u');
+ my $tbl = shift||'u';
my $prefix = shift||'user_';
join ', ',
"$tbl.id as ${prefix}id",
@@ -107,7 +108,17 @@ sub sql_user {
"$tbl.support_can as ${prefix}support_can",
"$tbl.support_enabled as ${prefix}support_enabled",
"$tbl.uniname_can as ${prefix}uniname_can",
- "$tbl.uniname as ${prefix}uniname";
+ "$tbl.uniname as ${prefix}uniname",
+ tuwf->req->{auth} && VNWeb::Auth::auth()->isMod ? (
+ "$tbl.perm_board as ${prefix}perm_board",
+ "$tbl.perm_edit as ${prefix}perm_edit"
+ ) : (),
+}
+
+
+# Returns a (potentially cached) version of the global_settings table.
+sub global_settings {
+ tuwf->req->{global_settings} //= tuwf->dbRowi('SELECT * FROM global_settings');
}
@@ -119,18 +130,21 @@ sub sql_user {
#
# enrich $name, $key, $merge_col, $sql, @objects;
#
-# Add a $name field each item in @objects,
+# Add a $name field to each item in @objects,
# Its value is a (possibly empty) array of hashes with data from $sql,
#
# enrich_flatten $name, $key, $merge_col, $sql, @objects;
#
-# Add a $name field each item in @objects,
+# Add a $name field to each item in @objects,
# Its value is a (possibly empty) array of values from a single column from $sql,
#
# enrich_merge $key, $sql, @objects;
#
# Merge all columns returned by $sql into @objects;
#
+# enrich_obj $key, $merge_col, $sql, @objects;
+#
+# Replace all non-undef $key fields in @objects with an object returned by $sql.
#
# Arguments:
#
@@ -157,7 +171,7 @@ sub _enrich {
@array = map +(ref $_ eq 'ARRAY' ? @$_ : $_), @array;
# Create a list of unique identifiers to fetch, do nothing if there's nothing to fetch
- my %ids = map +($_->{$key},1), @array;
+ my %ids = map defined($_->{$key}) ? ($_->{$key},1) : (), @array;
return if !keys %ids;
# Fetch the data
@@ -185,7 +199,7 @@ sub enrich_merge {
_enrich sub {
my($data, $array) = @_;
my %ids = map +(delete($_->{$key}), $_), @$data;
- %$_ = (%$_, $ids{ $_->{$key} }->%*) for @$array;
+ %$_ = (%$_, ($ids{ $_->{$key} }||{})->%*) for @$array;
}, $key, $sql, @array;
}
@@ -201,6 +215,35 @@ sub enrich_flatten {
}
+sub enrich_obj {
+ my($key, $merge_col, $sql, @array) = @_;
+ _enrich sub {
+ my($data, $array) = @_;
+ my %ids = map +($_->{$merge_col}, $_), @$data;
+ $_->{$key} = defined $_->{$key} ? $ids{ $_->{$key} } : undef for @$array;
+ }, $key, $sql, @array;
+}
+
+
+
+# Run the given subroutine inside a savepoint and capture an SQL timeout.
+# Returns false and logs a warning on timeout.
+sub db_maytimeout(&) {
+ my($f) = @_;
+ tuwf->dbh->pg_savepoint('maytimeout');
+ my $r = eval { $f->(); 1 };
+
+ if(!$r && $@ =~ /canceling statement due to statement timeout/) {
+ tuwf->dbh->pg_rollback_to('maytimeout');
+ warn "Query timed out\n";
+ return 0;
+ }
+ carp $@ if !$r;
+ tuwf->dbh->pg_release('maytimeout');
+ 1;
+}
+
+
# Database entry API: Intended to provide a low-level read/write interface for
# versioned database entires. The same data structure is used for reading and
@@ -224,11 +267,10 @@ my $entry_types = do {
my %types = map +($_->{dbentry_type}, { prefix => $_->{name} }), grep $_->{dbentry_type}, values %$schema;
for my $t (values %$schema) {
my $n = $t->{name};
- my($type) = grep $n =~ s/^$_->{prefix}_//, values %types;
- next if !$type;
- $type->{base} = $t if $n eq 'hist';
- next if $n !~ s/_hist$//;
- $type->{tables}{$n} = $t;
+ my($type) = grep $n =~ /^$_->{prefix}_/, values %types;
+ next if !$type || $n !~ s/^$type->{prefix}_?(.*)_hist$/$1/;
+ if($n eq '') { $type->{base} = $t }
+ else { $type->{tables}{$n} = $t }
}
\%types;
};
@@ -240,45 +282,35 @@ my $entry_types = do {
# id, chid, chrev, maxrev, hidden, locked, entry_hidden, entry_locked
#
# (Ordering of arrays is unspecified)
-#
-# TODO:
-# - Use non _hist tables if $maxrev == $rev (should be faster)
-# - Combine the enrich_merge() calls into a single query.
-# - Fixed ordering of arrays (use primary keys)
sub db_entry {
- my($type, $id, $rev) = @_;
- my $t = $entry_types->{$type}||die;
-
- my $maxrev = tuwf->dbVali('SELECT MAX(rev) FROM changes WHERE type =', \$type, ' AND itemid =', \$id);
- return undef if !$maxrev;
- $rev ||= $maxrev;
- my $entry = tuwf->dbRowi(q{
- SELECT itemid AS id, id AS chid, rev AS chrev, ihid AS hidden, ilock AS locked
- FROM changes
- WHERE}, { type => $type, itemid => $id, rev => $rev }
+ my($id, $rev) = @_;
+ my $t = $entry_types->{ substr $id, 0, 1 }||die;
+
+ my $entry = tuwf->dbRowi('
+ WITH maxrev (iid, maxrev) AS (SELECT itemid, MAX(rev) FROM changes WHERE itemid =', \$id, 'GROUP BY itemid)
+ , lastrev (entry_hidden, entry_locked) AS (SELECT ihid, ilock FROM maxrev, changes WHERE itemid = iid AND rev = maxrev)
+ SELECT itemid AS id, id AS chid, rev AS chrev, ihid AS hidden, ilock AS locked, maxrev, entry_hidden, entry_locked
+ FROM changes, maxrev, lastrev
+ WHERE itemid = iid AND rev = ', $rev ? \$rev : 'maxrev'
);
return undef if !$entry->{id};
- $entry->{maxrev} = $maxrev;
- if($maxrev == $rev) {
- $entry->{entry_hidden} = $entry->{hidden};
- $entry->{entry_locked} = $entry->{locked};
- } else {
- my $base = $t->{base}{name} =~ s/_hist$//r;
- enrich_merge id => sql('SELECT id, hidden AS entry_hidden, locked AS entry_locked FROM', sql_identifier($base), 'WHERE id IN'), $entry;
+ # Fetch data from the main entry tables if rev == maxrev, from the _hist
+ # tables otherwise. This should improve caching a bit.
+ my sub data_table {
+ $entry->{chrev} == $entry->{maxrev} ? sql $_[0] =~ s/_hist$//r, 'WHERE id =', \$id
+ : sql $_[0], 'WHERE chid =', \$entry->{chid}
}
- enrich_merge chid => sql(
- SELECT => sql_comma(map sql_identifier($_->{name}), $t->{base}{cols}->@*),
- FROM => sql_identifier($t->{base}{name}),
- 'WHERE chid IN'
- ), $entry;
+ %$entry = (%$entry, tuwf->dbRowi(
+ SELECT => sql_comma(map $_->{name}, grep $_->{name} ne 'chid', $t->{base}{cols}->@*),
+ FROM => data_table $t->{base}{name}
+ )->%*);
while(my($name, $tbl) = each $t->{tables}->%*) {
$entry->{$name} = tuwf->dbAlli(
- SELECT => sql_comma(map sql_identifier($_->{name}), grep $_->{name} ne 'chid', $tbl->{cols}->@*),
- FROM => sql_identifier($tbl->{name}),
- WHERE => { chid => $entry->{chid} }
+ SELECT => sql_comma(map $_->{name}, grep $_->{name} ne 'chid', $tbl->{cols}->@*),
+ FROM => data_table($tbl->{name}),
);
}
$entry
@@ -299,38 +331,43 @@ sub db_edit {
$id ||= undef;
my $t = $entry_types->{$type}||die;
- tuwf->dbExeci("SELECT edit_${type}_init(", \$id, ', (SELECT MAX(rev) FROM changes WHERE type = ', \$type, ' AND itemid = ', \$id, '))');
+ tuwf->dbExeci("SELECT edit_${type}_init(", \$id, ', (SELECT MAX(rev) FROM changes WHERE itemid = ', \$id, '))');
tuwf->dbExeci('UPDATE edit_revision SET', {
requester => $uid // scalar VNWeb::Auth::auth()->uid(),
- ip => scalar tuwf->reqIP(),
comments => $data->{editsum},
ihid => $data->{hidden},
ilock => $data->{locked},
});
+ # Array columns need special care; SQL::Interp and DBD::Pg don't like them
+ # as single bind params and Postgres can't infer their type.
+ my sub val {
+ my($v, $col) = @_;
+ ref $v ? (sql_array(@$v), '::'.$col->{type}) : \$v
+ }
+
{
my $base = $t->{base}{name} =~ s/_hist$//r;
tuwf->dbExeci("UPDATE edit_${base} SET ", sql_comma(
- map sql(sql_identifier($_->{name}), ' = ', \$data->{$_->{name}}),
- grep exists $data->{$_->{name}}, $t->{base}{cols}->@*
+ map sql($_->{name}, ' = ', val $data->{$_->{name}}, $_),
+ grep $_->{name} ne 'chid' && exists $data->{$_->{name}}, $t->{base}{cols}->@*
));
}
while(my($name, $tbl) = each $t->{tables}->%*) {
my $base = $tbl->{name} =~ s/_hist$//r;
- my @colnames = grep $_ ne 'chid', map $_->{name}, $tbl->{cols}->@*;
- my @cols = sql_comma(map sql_identifier($_), @colnames);
+ my @cols = grep $_->{name} ne 'chid', $tbl->{cols}->@*;
+ my @colnames = sql_comma(map $_->{name}, @cols);
my @rows = map {
my $d = $_;
- sql '(', sql_comma(map \$d->{$_}, @colnames), ')'
+ sql '(', sql_comma(map val($d->{$_->{name}}, $_), @cols), ')'
} $data->{$name}->@*;
tuwf->dbExeci("DELETE FROM edit_${base}");
- tuwf->dbExeci("INSERT INTO edit_${base} (", @cols, ') VALUES ', sql_comma @rows) if @rows;
+ tuwf->dbExeci("INSERT INTO edit_${base} (", @colnames, ') VALUES ', sql_comma @rows) if @rows;
}
- my $r = tuwf->dbRow("SELECT * FROM edit_${type}_commit()");
- ($r->{itemid}, $r->{chid}, $r->{rev})
+ tuwf->dbRow("SELECT * FROM edit_${type}_commit()");
}
1;
diff --git a/lib/VNWeb/Discussions/Board.pm b/lib/VNWeb/Discussions/Board.pm
index edce6789..9fa9e304 100644
--- a/lib/VNWeb/Discussions/Board.pm
+++ b/lib/VNWeb/Discussions/Board.pm
@@ -5,23 +5,22 @@ use VNWeb::Discussions::Lib;
TUWF::get qr{/t/(all|$BOARD_RE)}, sub {
- my($type, $id) = tuwf->capture(1) =~ /^([^0-9]+)([0-9]*)$/;
+ my $id = tuwf->capture(1);
+ my($type) = $id =~ /^([^0-9]+)/;
+ $id = undef if $id !~ /[0-9]$/;
my $page = tuwf->validate(get => p => { upage => 1 })->data;
- my $obj = !$id ? undef :
- $type eq 'v' ? tuwf->dbRowi('SELECT id, title, original, hidden AS entry_hidden, locked AS entry_locked FROM vn WHERE id =', \$id) :
- $type eq 'p' ? tuwf->dbRowi('SELECT id, name, original, hidden AS entry_hidden, locked AS entry_locked FROM producers WHERE id =', \$id) :
- $type eq 'u' ? tuwf->dbRowi('SELECT id,', sql_user(), 'FROM users u WHERE id =', \$id) : undef;
+ my $obj = $id ? dbobj $id : undef;
return tuwf->resNotFound if $id && !$obj->{id};
+ return tuwf->resNotFound if $id && $id =~ /^u/ && $obj->{entry_hidden} && !auth->isMod;
- my $ititle = $obj && ($obj->{title} || $obj->{name} || user_displayname $obj);
- my $title = $obj ? "Related discussions for $ititle" : $type eq 'all' ? 'All boards' : $BOARD_TYPE{$type}{txt};
- my $createurl = '/t/'.($id ? $type.$id : $type eq 'db' ? 'db' : 'ge').'/new';
+ my $title = $obj ? "Related discussions for $obj->{title}[1]" : $type eq 'all' ? 'All boards' : $BOARD_TYPE{$type}{txt};
+ my $createurl = '/t/'.($id || ($type eq 'db' ? 'db' : 'ge')).'/new';
- framework_ title => $title, type => $type, dbobj => $obj, tab => 'disc',
+ framework_ title => $title, dbobj => $obj, tab => 'disc',
sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
boardtypes_ $type;
boardsearch_ $type if !$id;
@@ -32,12 +31,12 @@ TUWF::get qr{/t/(all|$BOARD_RE)}, sub {
threadlist_
where => $type ne 'all' && sql('t.id IN(SELECT tid FROM threads_boards WHERE type =', \$type, $id ? ('AND iid =', \$id) : (), ')'),
- boards => $type ne 'all' && sql('NOT (tb.type =', \$type, 'AND tb.iid =', \($id||0), ')'),
+ boards => $type ne 'all' && sql('NOT (tb.type =', \$type, 'AND tb.iid IS NOT DISTINCT FROM', \$id, ')'),
results => 50,
sort => $type eq 'an' ? 't.id DESC' : undef,
page => $page,
paginate => sub { "?p=$_" }
- or div_ class => 'mainbox', sub {
+ or article_ sub {
h1_ 'An empty board';
p_ class => 'center', sub {
txt_ "Nobody's started a discussion on this board yet. Why not ";
diff --git a/lib/VNWeb/Discussions/Edit.pm b/lib/VNWeb/Discussions/Edit.pm
index 550be76c..06fb2397 100644
--- a/lib/VNWeb/Discussions/Edit.pm
+++ b/lib/VNWeb/Discussions/Edit.pm
@@ -5,30 +5,26 @@ use VNWeb::Discussions::Lib;
my $FORM = {
- tid => { required => 0, id => 1 }, # Thread ID, only when editing a post
- num => { required => 0, id => 1 }, # Post number, only when editing
-
- # Only when num = 1 || tid = undef
- title => { required => 0, maxlength => 50 },
- boards => { required => 0, sort_keys => [ 'boardtype', 'iid' ], aoh => {
- btype => { enum => \%BOARD_TYPE },
- iid => { required => 0, default => 0, id => 1 }, #
- title => { required => 0 },
- } },
- poll => { required => 0, type => 'hash', keys => {
- question => { maxlength => 100 },
+ tid => { default => undef, vndbid => 't' }, # Thread ID, only when editing a post
+
+ title => { default => undef, sl => 1, maxlength => 50 },
+ boards => { default => undef, sort_keys => [ 'boardtype', 'iid' ], aoh => $VNWeb::Elm::apis{BoardResult}[0]{aoh} },
+ poll => { default => undef, type => 'hash', keys => {
+ question => { sl => 1, maxlength => 100 },
max_options => { uint => 1, min => 1, max => 20 }, #
- options => { type => 'array', values => { maxlength => 100 }, minlength => 2, maxlength => 20 },
+ options => { type => 'array', values => { sl => 1, maxlength => 100 }, minlength => 2, maxlength => 20 },
} },
- can_mod => { anybool => 1, _when => 'out' },
- can_private => { anybool => 1, _when => 'out' },
- locked => { anybool => 1 }, # When can_mod && (num = 1 || tid = undef)
- hidden => { anybool => 1 }, # When can_mod
- private => { anybool => 1 }, # When can_private && (num = 1 || tid = undef)
- nolastmod => { anybool => 1, _when => 'in' }, # When can_mod
+ can_mod => { anybool => 1, _when => 'out' },
+ can_private => { anybool => 1, _when => 'out' },
+ locked => { anybool => 1 }, # When can_mod
+ hidden => { anybool => 1 }, # When can_mod
+ boards_locked => { anybool => 1 }, # When can_mod
+ private => { anybool => 1 }, # When can_private
+ nolastmod => { anybool => 1, _when => 'in' }, # When can_mod
+ delete => { anybool => 1 }, # When can_mod
- msg => { maxlength => 32768 },
+ msg => { maxlength => 32768 },
};
my $FORM_OUT = form_compile out => $FORM;
@@ -38,54 +34,60 @@ my $FORM_IN = form_compile in => $FORM;
elm_api DiscussionsEdit => $FORM_OUT, $FORM_IN, sub {
my($data) = @_;
my $tid = $data->{tid};
- my $num = $data->{num} || 1;
my $t = !$tid ? {} : tuwf->dbRowi('
- SELECT t.id, tp.num, t.poll_question, t.poll_max_options, tp.hidden, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
+ SELECT t.id, t.poll_question, t.poll_max_options, t.boards_locked, t.hidden, tp.num, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
FROM threads t
- JOIN threads_posts tp ON tp.tid = t.id AND tp.num =', \$num,
- 'WHERE t.id =', \$tid,
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num = 1
+ WHERE t.id =', \$tid,
'AND', sql_visible_threads());
return tuwf->resNotFound if $tid && !$t->{id};
return elm_Unauth if !can_edit t => $t;
- my $pollchanged = !$data->{tid} && $data->{poll};
- if($num == 1) {
- die "Invalid title" if !length $data->{title};
- die "Invalid boards" if !$data->{boards} || grep +(!$BOARD_TYPE{$_->{btype}}{dbitem})^(!$_->{iid}), $data->{boards}->@*;
-
- validate_dbid 'SELECT id FROM vn WHERE id IN', map $_->{btype} eq 'v' ? $_->{iid} : (), $data->{boards}->@*;
- validate_dbid 'SELECT id FROM producers WHERE id IN', map $_->{btype} eq 'p' ? $_->{iid} : (), $data->{boards}->@*;
- # Do not validate user boards here, it's possible to have threads assigned to deleted users.
-
- die "Invalid max_options" if $data->{poll} && $data->{poll}{max_options} > $data->{poll}{options}->@*;
- $pollchanged = 1 if $tid && $data->{poll} && (
- $data->{poll}{question} ne ($t->{poll_question}||'')
- || $data->{poll}{max_options} != $t->{poll_max_options}
- || join("\n", $data->{poll}{options}->@*) ne
- join("\n", map $_->{option}, tuwf->dbAlli('SELECT option FROM threads_poll_options WHERE tid =', \$tid, 'ORDER BY id')->@*)
- )
+ 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);
+ return elm_Redirect '/t';
}
+ auth->audit($t->{user_id}, 'post edit', "edited $tid.1") if $tid && $t->{user_id} ne auth->uid;
+
+
+ die "Invalid title" if !length $data->{title};
+ die "Invalid boards" if !$data->{boards} || grep +(!$BOARD_TYPE{$_->{btype}}{dbitem})^(!$_->{iid}), $data->{boards}->@*;
+
+ validate_dbid 'SELECT id FROM vn WHERE id IN', map $_->{btype} eq 'v' ? $_->{iid} : (), $data->{boards}->@*;
+ validate_dbid 'SELECT id FROM producers WHERE id IN', map $_->{btype} eq 'p' ? $_->{iid} : (), $data->{boards}->@*;
+ # Do not validate user boards here, it's possible to have threads assigned to deleted users.
+
+ die "Invalid max_options" if $data->{poll} && $data->{poll}{max_options} > $data->{poll}{options}->@*;
+ my $pollchanged = (!$tid && $data->{poll}) || ($tid && $data->{poll} && (
+ $data->{poll}{question} ne ($t->{poll_question}||'')
+ || $data->{poll}{max_options} != $t->{poll_max_options}
+ || join("\n", $data->{poll}{options}->@*) ne
+ join("\n", map $_->{option}, tuwf->dbAlli('SELECT option FROM threads_poll_options WHERE tid =', \$tid, 'ORDER BY id')->@*)
+ ));
my $thread = {
title => $data->{title},
poll_question => $data->{poll} ? $data->{poll}{question} : undef,
poll_max_options => $data->{poll} ? $data->{poll}{max_options} : 1,
- $tid ? () : (count => 1),
auth->permBoardmod ? (
hidden => $data->{hidden},
locked => $data->{locked},
+ boards_locked => $data->{boards_locked},
) : (),
- auth->permBoardmod || auth->permDbmod || auth->permUsermod ? (
+ auth->isMod ? (
private => $data->{private}
) : (),
};
- tuwf->dbExeci('UPDATE threads SET', $thread, 'WHERE id =', \$tid) if $tid && $num == 1;
+ tuwf->dbExeci('UPDATE threads SET', $thread, 'WHERE id =', \$tid) if $tid;
$tid = tuwf->dbVali('INSERT INTO threads', $thread, 'RETURNING id') if !$tid;
- if($num == 1) {
+ if(auth->permBoardmod || !$t->{boards_locked}) {
tuwf->dbExeci('DELETE FROM threads_boards WHERE tid =', \$tid);
- tuwf->dbExeci('INSERT INTO threads_boards', { tid => $tid, type => $_->{btype}, iid => $_->{iid}//0 }) for $data->{boards}->@*;
+ tuwf->dbExeci('INSERT INTO threads_boards', { tid => $tid, type => $_->{btype}, iid => $_->{iid} }) for $data->{boards}->@*;
}
if($pollchanged) {
@@ -95,30 +97,33 @@ elm_api DiscussionsEdit => $FORM_OUT, $FORM_IN, sub {
my $post = {
tid => $tid,
- num => $num,
+ num => 1,
msg => bb_subst_links($data->{msg}),
$data->{tid} ? () : (uid => auth->uid),
- auth->permBoardmod && $num != 1 ? (hidden => $data->{hidden}) : (),
- auth->permBoardmod && $data->{nolastmod} ? () : (edited => sql 'NOW()')
+ !$data->{tid} || (auth->permBoardmod && $data->{nolastmod}) ? () : (edited => sql 'NOW()')
};
tuwf->dbExeci('INSERT INTO threads_posts', $post) if !$data->{tid};
- tuwf->dbExeci('UPDATE threads_posts SET', $post, 'WHERE', { tid => $tid, num => $num }) if $data->{tid};
+ tuwf->dbExeci('UPDATE threads_posts SET', $post, 'WHERE', { tid => $tid, num => 1 }) if $data->{tid};
- elm_Redirect post_url $tid, $num, $num;
+ elm_Redirect "/$tid.1";
};
-TUWF::get qr{(?:/t/(?<board>$BOARD_RE)/new|/$RE{postid}/edit)}, sub {
- my($board_type, $board_id) = (tuwf->capture('board')||'') =~ /^([^0-9]+)([0-9]*)$/;
- my($tid, $num) = (tuwf->capture('id'), tuwf->capture('num'));
+TUWF::get qr{(?:/t/(?<board>$BOARD_RE)/new|/$RE{tid}\.1/edit)}, sub {
+ my $board_id = tuwf->capture('board')||'';
+ my($board_type) = $board_id =~ /^([^0-9]+)/;
+ $board_id = $board_id =~ /[0-9]$/ ? dbobj $board_id : undef;
+ my $tid = tuwf->capture('id');
+
+ return tuwf->resNotFound if $board_id && !$board_id->{id};
$board_type = 'ge' if $board_type && $board_type eq 'an' && !auth->permBoardmod;
my $t = !$tid ? {} : tuwf->dbRowi('
- SELECT t.id, tp.tid, tp.num, t.title, t.locked, t.private, t.poll_question, t.poll_max_options, tp.hidden, tp.msg, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
+ SELECT t.id, tp.tid, t.title, t.locked, t.boards_locked, t.private, t.hidden, t.poll_question, t.poll_max_options, tp.msg, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
FROM threads t
- JOIN threads_posts tp ON tp.tid = t.id AND tp.num =', \$num,
- 'WHERE t.id =', \$tid,
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num = 1
+ WHERE t.id =', \$tid,
'AND', sql_visible_threads());
return tuwf->resNotFound if $tid && !$t->{id};
return tuwf->resDenied if !can_edit t => $t;
@@ -133,27 +138,27 @@ TUWF::get qr{(?:/t/(?<board>$BOARD_RE)/new|/$RE{postid}/edit)}, sub {
} else {
$t->{boards} = [ {
btype => $board_type,
- iid => $board_id||0,
- title => !$board_id ? undef :
- tuwf->dbVali('SELECT title FROM', sql_boards(), 'x WHERE btype =', \$board_type, 'AND iid =', \$board_id)
+ iid => $board_id ? $board_id->{id} : undef,
+ title => $board_id ? $board_id->{title} : undef,
} ];
- return tuwf->resNotFound if $board_id && !length $t->{boards}[0]{title};
- push $t->{boards}->@*, { btype => 'u', iid => auth->uid, title => auth->user->{user_name} }
- if $board_type eq 'u' && $board_id != auth->uid;
+ push $t->{boards}->@*, { btype => 'u', iid => auth->uid, title => [undef,auth->user->{user_name}] }
+ if $board_type eq 'u' && $board_id->{id} ne auth->uid;
}
+ $_->{title} = $_->{title} && $_->{title}[1] for $t->{boards}->@*;
$t->{can_mod} = auth->permBoardmod;
- $t->{can_private} = auth->permBoardmod || auth->permDbmod || auth->permUsermod;
+ $t->{can_private} = auth->isMod;
+ $t->{hidden} //= 0;
$t->{msg} //= '';
$t->{title} //= tuwf->reqGet('title');
$t->{tid} //= undef;
- $t->{num} //= undef;
- $t->{private} //= 0;
- $t->{hidden} //= 0;
+ $t->{private} //= auth->isMod && tuwf->reqGet('priv') ? 1 : 0;
$t->{locked} //= 0;
+ $t->{boards_locked} //= 0;
+ $t->{delete} = 0;
- framework_ title => $tid ? 'Edit post' : 'Create new thread', sub {
+ framework_ title => $tid ? 'Edit thread' : 'Create new thread', sub {
elm_ 'Discussions.Edit' => $FORM_OUT, $t;
};
};
diff --git a/lib/VNWeb/Discussions/Elm.pm b/lib/VNWeb/Discussions/Elm.pm
index 77944926..500cc3b9 100644
--- a/lib/VNWeb/Discussions/Elm.pm
+++ b/lib/VNWeb/Discussions/Elm.pm
@@ -1,44 +1,32 @@
package VNWeb::Discussions::Elm;
use VNWeb::Prelude;
-use VNWeb::Discussions::Lib;
# Autocompletion search results for boards
elm_api Boards => undef, {
- search => {},
+ search => { searchquery => 1 },
}, sub {
return elm_Unauth if !auth->permBoard;
my $q = shift->{search};
- my $qs = $q =~ s/[%_]//gr;
+ my $qs = sql_like "$q";
- my sub subq {
- my($prio, $where) = @_;
- sql 'SELECT', $prio, ' AS prio, btype, iid, CASE WHEN iid = 0 THEN NULL ELSE title END AS title
- FROM (',
- sql_join('UNION ALL',
- sql('SELECT btype, iid, title, original FROM', sql_boards(), 'a'),
- map sql('SELECT', \$_, '::board_type, 0,', \$BOARD_TYPE{$_}{txt}, q{, ''}),
- grep !$BOARD_TYPE{$_}{dbitem} && ($BOARD_TYPE{$_}{post_perm} eq 'board' || auth->permBoardmod),
- keys %BOARD_TYPE
- ),
- ') x WHERE', $where
- }
+ my $uscore = sql 'similarity(username, ', \$qs, ')';
+ $uscore = sql 'CASE WHEN id =', \$qs, 'THEN 1+1 ELSE', $uscore, 'END' if $qs =~ /^u$RE{num}$/;
- # This query is SLOW :(
elm_BoardResult tuwf->dbPagei({ results => 10, page => 1 },
'SELECT btype, iid, title
FROM (',
sql_join('UNION ALL',
- # ID match
- $q =~ /^($BOARD_RE)$/ && $q =~ /^([a-z]+)([0-9]*)$/
- ? subq(0, sql_and sql('btype =', \"$1"), $2 ? sql('iid =', \"$2") : ()) : (),
- subq(
- sql('1+LEAST(substr_score(lower(title),', \$qs, '), substr_score(lower(original),', \$qs, '))'),
- sql('title ILIKE', \"%$qs%", ' OR original ILIKE', \"%$qs%")
- )
- ), ') x
- GROUP BY btype, iid, title
- ORDER BY MIN(prio), btype, iid'
+ (map sql('SELECT 10, ', \"$_", '::board_type, NULL::vndbid, NULL'),
+ grep $qs eq $_ || $BOARD_TYPE{$_}{txt} =~ /\Q$qs/i,
+ grep !$BOARD_TYPE{$_}{dbitem} && ($BOARD_TYPE{$_}{post_perm} eq 'board' || auth->permBoardmod),
+ keys %BOARD_TYPE),
+ sql('SELECT score, \'v\', v.id, title[1+1] FROM', vnt, 'v', $q->sql_join('v', 'v.id'), 'WHERE NOT v.hidden'),
+ sql('SELECT score, \'p\', p.id, title[1+1] FROM', producerst, 'p', $q->sql_join('p', 'p.id'), 'WHERE NOT p.hidden'),
+ sql('SELECT', $uscore, ', \'u\', id, username FROM users WHERE lower(username) LIKE', \lc "%$qs%",
+ $qs =~ /^u$RE{num}$/ ? ('OR id =', \$qs) : ())
+ ), ') x(score, btype, iid, title)
+ ORDER BY score DESC, btype, title'
)
};
diff --git a/lib/VNWeb/Discussions/Index.pm b/lib/VNWeb/Discussions/Index.pm
index 90ac31b1..1e797d31 100644
--- a/lib/VNWeb/Discussions/Index.pm
+++ b/lib/VNWeb/Discussions/Index.pm
@@ -7,7 +7,7 @@ use VNWeb::Discussions::Lib;
TUWF::get qr{/t}, sub {
framework_ title => 'Discussion board index', sub {
form_ method => 'get', action => '/t/search', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Discussion board index';
boardtypes_ 'index';
boardsearch_;
@@ -18,12 +18,14 @@ TUWF::get qr{/t}, sub {
};
for my $b (keys %BOARD_TYPE) {
- h1_ class => 'boxtitle', sub {
- a_ href => "/t/$b", $BOARD_TYPE{$b}{txt};
+ nav_ sub {
+ h1_ sub {
+ a_ href => "/t/$b", $BOARD_TYPE{$b}{txt};
+ };
};
threadlist_
where => sql('t.id IN(SELECT tid FROM threads_boards WHERE type =', \$b, ')'),
- boards => sql('NOT (tb.type =', \$b, 'AND tb.iid = 0)'),
+ boards => sql('NOT (tb.type =', \$b, 'AND tb.iid IS NULL)'),
results => $BOARD_TYPE{$b}{index_rows},
page => 1;
}
diff --git a/lib/VNWeb/Discussions/Lib.pm b/lib/VNWeb/Discussions/Lib.pm
index 9f77397e..d4e8146a 100644
--- a/lib/VNWeb/Discussions/Lib.pm
+++ b/lib/VNWeb/Discussions/Lib.pm
@@ -3,47 +3,30 @@ package VNWeb::Discussions::Lib;
use VNWeb::Prelude;
use Exporter 'import';
-our @EXPORT = qw/$BOARD_RE post_url sql_visible_threads sql_boards enrich_boards threadlist_ boardsearch_ boardtypes_/;
+our @EXPORT = qw/$BOARD_RE sql_visible_threads enrich_boards threadlist_ boardsearch_ boardtypes_/;
our $BOARD_RE = join '|', map $_.($BOARD_TYPE{$_}{dbitem}?'(?:[1-9][0-9]{0,5})?':''), keys %BOARD_TYPE;
-# Returns the URL to the thread page holding the given post (with optional location.hash)
-sub post_url {
- my($id, $num, $hash) = @_;
- "/t$id".($num > 25 ? '/'.ceil($num/25) : '').($hash ? "#$hash" : '');
-}
-
-
# Returns a WHERE condition to filter threads that the current user is allowed to see.
sub sql_visible_threads {
- return '1=1' if auth && auth->uid == 2; # Yorhel sees everything
+ return '1=1' if auth && auth->uid eq 'u2'; # Yorhel sees everything
sql_and
auth->permBoardmod ? () : ('NOT t.hidden'),
sql('NOT t.private OR EXISTS(SELECT 1 FROM threads_boards WHERE tid = t.id AND type = \'u\' AND iid =', \auth->uid, ')');
}
-# Returns a SELECT subquery with all board IDs
-sub sql_boards {
- sql q{( SELECT 'v'::board_type AS btype, id AS iid, title, original FROM vn
- UNION ALL SELECT 'p'::board_type AS btype, id AS iid, name, original FROM producers
- UNION ALL SELECT 'u'::board_type AS btype, id AS iid, username, NULL FROM users
- )}
-}
-
-
# Adds a 'boards' array to threads.
sub enrich_boards {
my($filt, @lst) = @_;
- enrich boards => id => tid => sub { sql q{
- SELECT tb.tid, tb.type AS btype, tb.iid, b.title, b.original
- FROM threads_boards tb
- LEFT JOIN }, sql_boards(), q{b ON b.btype = tb.type AND b.iid = tb.iid
- WHERE }, sql_and(sql('tb.tid IN', $_[0]), $filt||()), q{
+ enrich boards => id => tid => sub { sql '
+ SELECT tb.tid, tb.type AS btype, tb.iid, x.title
+ FROM threads_boards tb, ', item_info('tb.iid', 'NULL'), 'x
+ WHERE ', sql_and(sql('tb.tid IN', $_[0]), $filt||()), '
ORDER BY tb.type, tb.iid
- }}, @lst;
+ '}, @lst;
}
@@ -65,14 +48,14 @@ sub threadlist_ {
return 0 if $opt{paginate} && !$count;
my $lst = tuwf->dbPagei(\%opt, q{
- SELECT t.id, t.title, t.count, t.locked, t.private, t.hidden, t.poll_question IS NOT NULL AS haspoll
+ SELECT t.id, t.title, t.c_count, t.c_lastnum, t.locked, t.private, t.hidden, t.poll_question IS NOT NULL AS haspoll
, }, sql_user('tfu', 'firstpost_'), ',', sql_totime('tf.date'), q{ as firstpost_date
, }, sql_user('tlu', 'lastpost_'), ',', sql_totime('tl.date'), q{ as lastpost_date
FROM threads t
JOIN threads_posts tf ON tf.tid = t.id AND tf.num = 1
- JOIN threads_posts tl ON tl.tid = t.id AND tl.num = t.count
- JOIN users tfu ON tfu.id = tf.uid
- JOIN users tlu ON tlu.id = tl.uid
+ JOIN threads_posts tl ON tl.tid = t.id AND tl.num = t.c_lastnum
+ LEFT JOIN users tfu ON tfu.id = tf.uid
+ LEFT JOIN users tlu ON tlu.id = tl.uid
WHERE }, $where, q{
ORDER BY}, $opt{sort}||'tl.date DESC'
);
@@ -81,7 +64,7 @@ sub threadlist_ {
enrich_boards $opt{boards}, $lst;
paginate_ $opt{paginate}, $opt{page}, [ $count, $opt{results} ], 't' if $opt{paginate};
- div_ class => 'mainbox browse discussions', sub {
+ article_ class => 'browse discussions', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Topic'; debug_ $lst };
@@ -92,27 +75,29 @@ sub threadlist_ {
tr_ sub {
my $l = $_;
td_ class => 'tc1', sub {
- a_ mkclass(locked => $l->{locked}), href => "/t$l->{id}", sub {
+ my $system = $l->{private} && $l->{firstpost_id} && $l->{firstpost_id} eq 'u1';
+ a_ mkclass(locked => !$system && $l->{locked}), href => "/$l->{id}", sub {
span_ class => 'pollflag', '[poll]' if $l->{haspoll};
- span_ class => 'pollflag', '[private]' if $l->{private};
+ span_ class => 'pollflag', $system ? '[system]' : '[private]' if $l->{private};
span_ class => 'pollflag', '[hidden]' if $l->{hidden};
txt_ shorten $l->{title}, 50;
};
- b_ class => 'boards', sub {
+ span_ class => 'boards', sub {
join_ ', ', sub {
- a_ href => "/t/$_->{btype}".($_->{iid}||''),
- title => $_->{original}||$BOARD_TYPE{$_->{btype}}{txt},
- shorten $_->{title}||$BOARD_TYPE{$_->{btype}}{txt}, 30;
+ a_ href => '/t/'.($_->{iid}||$_->{btype}),
+ $_->{title} ? tlang(@{$_->{title}}[0,1]) : (),
+ title => $_->{title} ? $_->{title}[3] : $BOARD_TYPE{$_->{btype}}{txt},
+ shorten $_->{title} ? $_->{title}[1] : $BOARD_TYPE{$_->{btype}}{txt}, 30;
}, $l->{boards}->@[0 .. min 4, $#{$l->{boards}}];
txt_ ', ...' if $l->{boards}->@* > 4;
- };
+ } if !$system;
};
- td_ class => 'tc2', $l->{count}-1;
+ td_ class => 'tc2', $l->{c_count}-1;
td_ class => 'tc3', sub { user_ $l, 'firstpost_' };
td_ class => 'tc4', sub {
user_ $l, 'lastpost_';
txt_ ' @ ';
- a_ href => post_url($l->{id}, $l->{count}, 'last'), fmtdate $l->{lastpost_date}, 'full';
+ a_ href => "/$l->{id}.$l->{c_lastnum}#last", fmtdate $l->{lastpost_date}, 'full';
};
} for @$lst;
}
diff --git a/lib/VNWeb/Discussions/PostEdit.pm b/lib/VNWeb/Discussions/PostEdit.pm
new file mode 100644
index 00000000..d0e4e1d2
--- /dev/null
+++ b/lib/VNWeb/Discussions/PostEdit.pm
@@ -0,0 +1,89 @@
+package VNWeb::Discussions::PostEdit;
+# Also used for editing review comments, which follow the exact same format.
+
+use VNWeb::Prelude;
+use VNWeb::Discussions::Lib;
+
+
+my $FORM = {
+ id => { vndbid => ['t','w'] },
+ num => { id => 1 },
+
+ can_mod => { anybool => 1, _when => 'out' },
+ hidden => { default => sub { $_[0] } }, # When can_mod
+ nolastmod => { anybool => 1, _when => 'in' }, # When can_mod
+ delete => { anybool => 1 }, # When can_mod
+
+ msg => { maxlength => 32768 },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+
+
+sub _info {
+ my($id,$num) = @_;
+ tuwf->dbRowi('
+ SELECT t.id, tp.num, tp.hidden, tp.msg, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
+ FROM threads t
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num =', \$num, '
+ WHERE t.id =', \$id, 'AND', sql_visible_threads(),'
+ UNION ALL
+ SELECT id, num, hidden, msg, uid AS user_id,', sql_totime('date'), 'AS date
+ FROM reviews_posts WHERE id =', \$id, 'AND num =', \$num
+ );
+}
+
+
+elm_api DiscussionsPostEdit => $FORM_OUT, $FORM_IN, sub {
+ my($data) = @_;
+ my $id = $data->{id};
+ my $num = $data->{num};
+
+ my $t = _info $id, $num;
+ 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} || defined $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);
+ return elm_Redirect "/$id";
+ }
+ auth->audit($t->{user_id}, 'post edit', "edited $id.$num") if $t->{user_id} ne auth->uid;
+
+ my $post = {
+ tid => $id,
+ num => $num,
+ msg => bb_subst_links($data->{msg}),
+ auth->permBoardmod ? (hidden => $data->{hidden}) : (),
+ (auth->permBoardmod && $data->{nolastmod}) ? () : (edited => sql 'NOW()')
+ };
+ tuwf->dbExeci('UPDATE threads_posts SET', $post, 'WHERE', { tid => $id, num => $num });
+ $post->{id} = delete $post->{tid};
+ tuwf->dbExeci('UPDATE reviews_posts SET', $post, 'WHERE', { id => $id, num => $num });
+
+ elm_Redirect "/$id.$num";
+};
+
+
+TUWF::get qr{/(?:$RE{tid}|$RE{wid})\.$RE{num}/edit}, sub {
+ my($id, $num) = (tuwf->capture('id'), tuwf->capture('num'));
+ tuwf->pass if $id =~ /^t/ && $num == 1; # t#.1 goes to Discussions::Edit.
+
+ my $t = _info $id, $num;
+ return tuwf->resNotFound if $id && !$t->{id};
+ return tuwf->resDenied if !can_edit t => $t;
+
+ $t->{can_mod} = auth->permBoardmod;
+ $t->{delete} = 0;
+
+ framework_ title => 'Edit post', sub {
+ elm_ 'Discussions.PostEdit' => $FORM_OUT, $t;
+ };
+};
+
+
+1;
diff --git a/lib/VNWeb/Discussions/Search.pm b/lib/VNWeb/Discussions/Search.pm
index 06366caf..79db2823 100644
--- a/lib/VNWeb/Discussions/Search.pm
+++ b/lib/VNWeb/Discussions/Search.pm
@@ -3,30 +3,34 @@ package VNWeb::Discussions::Search;
use VNWeb::Prelude;
use VNWeb::Discussions::Lib;
+my @BOARDS = (keys %BOARD_TYPE, 'w');
sub filters_ {
state $schema = tuwf->compile({ type => 'hash', keys => {
- bq => { required => 0, default => '' },
- b => { type => 'array', scalar => 1, onerror => [keys %BOARD_TYPE], values => { enum => \%BOARD_TYPE } },
+ bq => { default => '' },
+ uq => { default => '' },
+ b => { type => 'array', scalar => 1, onerror => \@BOARDS, values => { enum => \@BOARDS } },
t => { anybool => 1 },
p => { page => 1 },
}});
my $filt = tuwf->validate(get => $schema)->data;
my %boards = map +($_,1), $filt->{b}->@*;
+ my $u = $filt->{uq} && tuwf->dbVali('SELECT id FROM users WHERE', $filt->{uq} =~ /^u$RE{num}$/ ? 'id = ' : 'lower(username) =', \lc $filt->{uq});
+
form_ method => 'get', action => tuwf->reqPath(), sub {
boardtypes_;
- table_ style => 'margin: 0 auto', sub { tr_ sub {
- td_ style => 'padding: 10px', sub {
- p_ class => 'linkradio', sub {
- join_ \&br_, sub {
- input_ type => 'checkbox', name => 'b', id => "b_$_", value => $_, $boards{$_} ? (checked => 'checked') : ();
- label_ for => "b_$_", $BOARD_TYPE{$_}{txt};
- }, keys %BOARD_TYPE;
+ table_ class => 'boardsearchoptions', sub { tr_ sub {
+ td_ sub {
+ select_ multiple => 1, size => scalar @BOARDS, name => 'b', sub {
+ option_ $boards{$_} ? (selected => 1) : (), value => $_, $_ eq 'w' ? 'Reviews' : $BOARD_TYPE{$_}{txt} for @BOARDS;
}
};
- td_ style => 'padding: 10px', sub {
+ td_ sub {
input_ type => 'text', class => 'text', name => 'bq', style => 'width: 400px', placeholder => 'Search', value => $filt->{bq};
+ br_;
+ input_ type => 'text', class => 'text', name => 'uq', style => 'width: 150px', placeholder => 'Username or id', value => $filt->{uq};
+ b_ 'User not found.' if $filt->{uq} && !$u;
p_ class => 'linkradio', sub {
input_ type => 'checkbox', name => 't', id => 't', value => 1, $filt->{t} ? (checked => 'checked') : ();
@@ -39,12 +43,12 @@ sub filters_ {
};
}
};
- $filt
+ ($filt, $u)
}
sub noresults_ {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'No results';
p_ 'No threads or messages found matching your criteria.';
};
@@ -52,16 +56,18 @@ sub noresults_ {
sub posts_ {
- my($filt) = @_;
+ my($filt, $u) = @_;
- # Turn query into something suitable for to_tsquery()
- # TODO: Use Postgres 11 websearch_to_tsquery() instead.
- (my $ts = $filt->{bq}) =~ y{+|&:*()="';!?$%^\\[]{}<>~` }{ }s;
- $ts =~ s/ +/ /;
- $ts =~ s/^ //;
- $ts =~ s/ $//;
- $ts =~ s/ / & /g;
- $ts =~ s/(?:^| )-([^ ]+)/ !$1 /;
+ # Use websearch_to_tsquery() to convert the query string into a tsquery.
+ # Also match against an empty string to see if the query doesn't consist of only negative matches.
+ my $ts = tuwf->dbVali('
+ WITH q(q) AS (SELECT websearch_to_tsquery(', \$filt->{bq}, '))
+ SELECT CASE WHEN numnode(q) = 0 OR q @@ \'\' THEN NULL ELSE q END FROM q');
+ return noresults_ if !$ts;
+
+ my $reviews = grep $_ eq 'w', $filt->{b}->@*;
+ my @tboards = grep $_ ne 'w', $filt->{b}->@*;
+ return noresults_ if !$reviews && !@tboards;
# HACK: The bbcodes are stripped from the original messages when creating
# the headline, so they are guaranteed not to show up in the message. This
@@ -69,26 +75,43 @@ sub posts_ {
# conflict with the message contents.
my($posts, $np) = tuwf->dbPagei({ results => 20, page => $filt->{p} }, q{
- SELECT tp.tid, tp.num, t.title
+ SELECT m.id, m.num, m.title
, }, sql_user(), q{
- , }, sql_totime('tp.date'), q{as date
- , ts_headline('english', strip_bb_tags(strip_spoilers(tp.msg)), to_tsquery(}, \$ts, '),',
+ , }, sql_totime('m.date'), q{as date
+ , ts_headline('english', strip_bb_tags(strip_spoilers(m.msg)),}, \$ts, ',',
\'MaxFragments=2,MinWords=15,MaxWords=40,StartSel=[raw],StopSel=[/raw],FragmentDelimiter=[code]',
- q{) as headline
- FROM threads_posts tp
- JOIN threads t ON t.id = tp.tid
- JOIN users u ON u.id = tp.uid
- WHERE NOT t.hidden AND NOT t.private AND NOT tp.hidden
- AND bb_tsvector(tp.msg) @@ to_tsquery(}, \$ts, ')',
- $filt->{b}->@* < keys %BOARD_TYPE ? ('AND t.id IN(SELECT tid FROM threads_boards WHERE type IN', $filt->{b}, ')') : (), q{
- ORDER BY tp.date DESC
- });
+ ') as headline
+ FROM (', sql_join('UNION',
+ @tboards ?
+ sql('SELECT tp.tid, tp.num, t.title, tp.uid, tp.date, tp.msg
+ FROM threads_posts tp
+ JOIN threads t ON t.id = tp.tid
+ WHERE NOT t.hidden AND NOT t.private AND tp.hidden IS NULL
+ AND bb_tsvector(tp.msg) @@', \$ts,
+ $u ? ('AND tp.uid =', \$u) : (),
+ @tboards < keys %BOARD_TYPE ? ('AND t.id IN(SELECT tid FROM threads_boards WHERE type IN', \@tboards, ')') : ()
+ ) : (), $reviews ? (
+ sql('SELECT w.id, 0, v.title[1+1], w.uid, w.date, w.text
+ FROM reviews w
+ JOIN', vnt, 'v ON v.id = w.vid
+ WHERE NOT w.c_flagged AND bb_tsvector(w.text) @@', \$ts,
+ $u ? ('AND w.uid =', \$u) : ()),
+ sql('SELECT wp.id, wp.num, v.title[1+1], wp.uid, wp.date, wp.msg
+ FROM reviews_posts wp
+ JOIN reviews w ON w.id = wp.id
+ JOIN', vnt, 'v ON v.id = w.vid
+ WHERE NOT w.c_flagged AND wp.hidden IS NULL AND bb_tsvector(wp.msg) @@', \$ts,
+ $u ? ('AND wp.uid =', \$u) : ()),
+ ) : ()), ') m (id, num, title, uid, date, msg)
+ LEFT JOIN users u ON u.id = m.uid
+ ORDER BY m.date DESC'
+ );
return noresults_ if !@$posts;
my sub url { '?'.query_encode %$filt, @_ }
paginate_ \&url, $filt->{p}, $np, 't';
- div_ class => 'mainbox browse postsearch', sub {
+ article_ class => 'browse postsearch', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1_1', 'Id';
@@ -99,18 +122,18 @@ sub posts_ {
}};
tr_ sub {
my $l = $_;
- my $link = "/t$l->{tid}.$l->{num}";
- td_ class => 'tc1_1', sub { a_ href => $link, 't'.$l->{tid} };
- td_ class => 'tc1_2', sub { a_ href => $link, '.'.$l->{num} };
+ my $link = "/$l->{id}".($l->{num}?".$l->{num}":'');
+ td_ class => 'tc1_1', sub { a_ href => $link, $l->{id} };
+ td_ class => 'tc1_2', sub { a_ href => $link, '.'.$l->{num} if $l->{num} };
td_ class => 'tc2', fmtdate $l->{date};
td_ class => 'tc3', sub { user_ $l };
td_ class => 'tc4', sub {
div_ class => 'title', sub { a_ href => $link, $l->{title} };
div_ class => 'thread', sub { lit_(
- TUWF::XML::xml_escape($l->{headline})
- =~ s/\[raw\]/<b class="standout">/gr
+ xml_escape($l->{headline})
+ =~ s/\[raw\]/<b>/gr
=~ s/\[\/raw\]/<\/b>/gr
- =~ s/\[code\]/<b class="grayedout">...<\/b><br \/>/gr
+ =~ s/\[code\]/<small>...<\/small><br \/>/gr
)};
};
} for @$posts;
@@ -121,11 +144,15 @@ sub posts_ {
sub threads_ {
- my($filt) = @_;
+ my($filt, $u) = @_;
+
+ my @boards = grep $_ ne 'w', $filt->{b}->@*; # Can't search reviews by title
+ return noresults_ if !@boards;
my $where = sql_and
- $filt->{b}->@* < keys %BOARD_TYPE ? sql('t.id IN(SELECT tid FROM threads_boards WHERE type IN', $filt->{b}, ')') : (),
- map sql('t.title ilike', \('%'.($_ =~ s/%//gr).'%')), grep length($_) > 0, split /[ -,._]/, $filt->{bq};
+ @boards < keys %BOARD_TYPE ? sql('t.id IN(SELECT tid FROM threads_boards WHERE type IN', \@boards, ')') : (),
+ $u ? sql('EXISTS(SELECT 1 FROM threads_posts tp WHERE tp.tid = t.id AND tp.num = 1 AND tp.uid =', \$u, ')') : (),
+ map sql('t.title ilike', \('%'.sql_like($_).'%')), grep length($_) > 0, split /[ ,._-]/, $filt->{bq};
noresults_ if !threadlist_
where => $where,
@@ -138,13 +165,13 @@ sub threads_ {
TUWF::get qr{/t/search}, sub {
framework_ title => 'Search the discussion board',
sub {
- my $filt;
- div_ class => 'mainbox', sub {
+ my($filt, $u);
+ article_ sub {
h1_ 'Search the discussion board';
- $filt = filters_;
+ ($filt, $u) = filters_;
};
- posts_ $filt if $filt->{bq} && !$filt->{t};
- threads_ $filt if $filt->{bq} && $filt->{t};
+ posts_ $filt, $u if $filt->{bq} && !$filt->{t};
+ threads_ $filt, $u if $filt->{bq} && $filt->{t};
};
};
diff --git a/lib/VNWeb/Discussions/Thread.pm b/lib/VNWeb/Discussions/Thread.pm
index e410c920..b3820dd7 100644
--- a/lib/VNWeb/Discussions/Thread.pm
+++ b/lib/VNWeb/Discussions/Thread.pm
@@ -10,7 +10,7 @@ my $POLL_OUT = form_compile any => {
num_votes => { uint => 1 },
can_vote => { anybool => 1 },
preview => { anybool => 1 },
- tid => { id => 1 },
+ tid => { vndbid => 't' },
options => { aoh => {
id => { id => 1 },
option => {},
@@ -20,7 +20,7 @@ my $POLL_OUT = form_compile any => {
};
my $POLL_IN = form_compile any => {
- tid => { id => 1 },
+ tid => { vndbid => 't' },
options => { type => 'array', values => { id => 1 } },
};
@@ -32,59 +32,62 @@ elm_api DiscussionsPoll => $POLL_OUT, $POLL_IN, sub {
return tuwf->resNotFound if !$t->{poll_question};
die 'Too many options' if $data->{options}->@* > $t->{poll_max_options};
- validate_dbid sql('SELECT id FROM threads_poll_options WHERE tid =', \$data->{tid}, 'AND id IN'), $data->{options}->@*;
+ my %opt = map +($_->{id},1), tuwf->dbAlli('SELECT id FROM threads_poll_options WHERE tid =', \$data->{tid})->@*;
+ die 'Invalid option' if grep !$opt{$_}, $data->{options}->@*;
- tuwf->dbExeci('DELETE FROM threads_poll_votes WHERE tid =', \$data->{tid}, 'AND uid =', \auth->uid);
- tuwf->dbExeci('INSERT INTO threads_poll_votes', { tid => $data->{tid}, uid => auth->uid, optid => $_ }) for $data->{options}->@*;
+ tuwf->dbExeci('DELETE FROM threads_poll_votes WHERE optid IN', [ keys %opt ], 'AND uid =', \auth->uid);
+ tuwf->dbExeci('INSERT INTO threads_poll_votes', { uid => auth->uid, optid => $_ }) for $data->{options}->@*;
elm_Success
};
-my $REPLY = {
- tid => { id => 1 },
- old => { _when => 'out', anybool => 1 },
- msg => { _when => 'in', maxlength => 32768 }
+my $REPLY = form_compile any => {
+ tid => { vndbid => 't' },
+ old => { anybool => 1 },
+ msg => { maxlength => 32768 }
};
-my $REPLY_IN = form_compile in => $REPLY;
-my $REPLY_OUT = form_compile out => $REPLY;
-
-elm_api DiscussionsReply => $REPLY_OUT, $REPLY_IN, sub {
+js_api DiscussionReply => $REPLY, sub {
my($data) = @_;
- my $t = tuwf->dbRowi('SELECT id, locked, count FROM threads t WHERE id =', \$data->{tid}, 'AND', sql_visible_threads());
+ my $t = tuwf->dbRowi('SELECT id, locked FROM threads t WHERE id =', \$data->{tid}, 'AND', sql_visible_threads());
return tuwf->resNotFound if !$t->{id};
- return elm_Unauth if !can_edit t => $t;
+ return tuwf->resDenied if !can_edit t => $t;
- my $num = $t->{count}+1;
+ my $num = sql '(SELECT MAX(num)+1 FROM threads_posts WHERE tid =', \$data->{tid}, ')';
my $msg = bb_subst_links $data->{msg};
- tuwf->dbExeci('INSERT INTO threads_posts', { tid => $t->{id}, num => $num, uid => auth->uid, msg => $msg });
- tuwf->dbExeci('UPDATE threads SET count =', \$num, 'WHERE id =', \$t->{id});
- elm_Redirect post_url $t->{id}, $num, 'last';
+ $num = tuwf->dbVali('INSERT INTO threads_posts', { tid => $t->{id}, num => $num, uid => auth->uid, msg => $msg }, 'RETURNING num');
+ +{ _redir => "/$t->{id}.$num#last" };
};
sub metabox_ {
- my($t) = @_;
- div_ class => 'mainbox', sub {
- h1_ $t->{title};
+ my($t, $posts) = @_;
+ article_ sub {
+ h1_ sub { lit_ bb_format $t->{title}, idonly => 1 };
+ # UGLY hack: private threads from Multi (u1) are sometimes (ab)used for system notifications, treat that case differently.
+ if ($t->{private} && $posts->[0]{user_id} && $posts->[0]{user_id} eq 'u1') {
+ h2_ 'System notification';
+ return;
+ }
h2_ 'Hidden' if $t->{hidden};
h2_ 'Private' if $t->{private};
+ h2_ 'Locked' if $t->{locked};
h2_ 'Posted in';
ul_ sub {
li_ sub {
a_ href => "/t/$_->{btype}", $BOARD_TYPE{$_->{btype}}{txt};
if($_->{iid}) {
txt_ ' > ';
- a_ style => 'font-weight: bold', href => "/t/$_->{btype}$_->{iid}", "$_->{btype}$_->{iid}";
+ a_ style => 'font-weight: bold', href => "/t/$_->{iid}", $_->{iid};
txt_ ':';
if($_->{title}) {
- a_ href => "/$_->{btype}$_->{iid}", title => $_->{original}||$_->{title}, $_->{title};
+ a_ href => "/$_->{iid}", tattr $_;
} else {
- b_ '[deleted]';
+ strong_ '[deleted]';
}
}
} for $t->{boards}->@*;
@@ -93,17 +96,18 @@ sub metabox_ {
}
+# Also used by Reviews::Page for review comments.
sub posts_ {
my($t, $posts, $page) = @_;
- my sub url { "/t$t->{id}".($_?"/$_":'') }
+ my sub url { "/$t->{id}".($_?"/$_":'') }
paginate_ \&url, $page, [ $t->{count}, 25 ], 't';
- div_ class => 'mainbox thread', sub {
+ article_ class => 'thread', id => 'threadstart', sub {
table_ class => 'stripe', sub {
- tr_ mkclass(deleted => $_->{hidden}), id => $_->{num}, sub {
- td_ class => 'tc1', $t->{count} == $_->{num} ? (id => 'last') : (), sub {
- a_ href => "/t$t->{id}.$_->{num}", "#$_->{num}";
- if(!$_->{hidden} || auth->permBoard) {
+ tr_ mkclass(deleted => defined $_->{hidden}), id => "p$_->{num}", sub {
+ td_ class => 'tc1', $_ == $posts->[$#$posts] ? (id => 'last') : (), sub {
+ a_ href => "/$t->{id}.$_->{num}", "#$_->{num}";
+ if(!defined $_->{hidden} || auth->permBoard) {
txt_ ' by ';
user_ $_;
br_;
@@ -111,16 +115,23 @@ sub posts_ {
}
};
td_ class => 'tc2', sub {
- i_ class => 'edit', sub {
+ small_ class => 'edit', sub {
txt_ '< ';
- a_ href => "/t$t->{id}.$_->{num}/edit", 'edit';
+ if(can_edit t => $_) {
+ a_ href => "/$t->{id}.$_->{num}/edit", 'edit';
+ txt_ ' - ';
+ }
+ a_ href => "/report/$t->{id}.$_->{num}", 'report';
txt_ ' >';
- } if can_edit t => $_;
- if($_->{hidden}) {
- i_ class => 'deleted', 'Post deleted.';
+ } if !defined $_->{hidden} || can_edit t => $_;
+ if(defined $_->{hidden}) {
+ small_ sub {
+ txt_ 'Post deleted';
+ lit_ length $_->{hidden} ? ': '.bb_format $_->{hidden}, inline => 1 : '.';
+ };
} else {
- lit_ bb2html $_->{msg};
- i_ class => 'lastmod', 'Last modified on '.fmtdate($_->{edited}, 'full') if $_->{edited};
+ lit_ bb_format $_->{msg};
+ small_ class => 'lastmod', 'Last modified on '.fmtdate($_->{edited}, 'full') if $_->{edited};
}
};
} for @$posts;
@@ -134,9 +145,9 @@ sub reply_ {
my($t, $posts, $page) = @_;
return if $t->{count} > $page*25;
if(can_edit t => $t) {
- elm_ 'Discussions.Reply' => $REPLY_OUT, { tid => $t->{id}, old => $posts->[$#$posts]{date} < time-182*24*3600 };
+ div_ widget(DiscussionReply => $REPLY, { tid => $t->{id}, old => $posts->[$#$posts]{date} < time-182*24*3600 }), '';
} else {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Reply';
p_ class => 'center',
!auth ? 'You must be logged in to reply to this thread.' :
@@ -146,12 +157,13 @@ sub reply_ {
}
-TUWF::get qr{/$RE{tid}(?:/$RE{num})?}, sub {
- my($id, $page) = (tuwf->capture('id'), tuwf->capture('num')||1);
+TUWF::get qr{/$RE{tid}(?:(?<sep>[\./])$RE{num})?}, sub {
+ my($id, $sep, $num) = (tuwf->capture('id'), tuwf->capture('sep')||'', tuwf->capture('num'));
my $t = tuwf->dbRowi(
- 'SELECT id, title, count, hidden, locked, private
+ 'SELECT id, title, hidden, locked, private
, poll_question, poll_max_options
+ , (SELECT COUNT(*) FROM threads_posts WHERE tid = id) AS count
FROM threads t
WHERE', sql_visible_threads(), 'AND id =', \$id
);
@@ -159,16 +171,21 @@ TUWF::get qr{/$RE{tid}(?:/$RE{num})?}, sub {
enrich_boards '', $t;
+ my $page = $sep eq '/' ? $num||1 : $sep ne '.' ? 1
+ : ceil((tuwf->dbVali('SELECT COUNT(*) FROM threads_posts WHERE num <=', \$num, 'AND tid =', \$id)||9999)/25);
+ $num = 0 if $sep ne '.';
+
my $posts = tuwf->dbPagei({ results => 25, page => $page },
'SELECT tp.tid as id, tp.num, tp.hidden, tp.msg',
',', sql_user(),
',', sql_totime('tp.date'), ' as date',
',', sql_totime('tp.edited'), ' as edited
FROM threads_posts tp
- JOIN users u ON tp.uid = u.id
+ LEFT JOIN users u ON tp.uid = u.id
WHERE tp.tid =', \$id, '
ORDER BY tp.num'
);
+ return tuwf->resNotFound if !@$posts || ($num && !grep $_->{num} == $num, @$posts);
my $poll_options = $t->{poll_question} && tuwf->dbAlli(
'SELECT tpo.id, tpo.option, count(u.id) as votes, tpm.optid IS NOT NULL as my
@@ -177,15 +194,23 @@ TUWF::get qr{/$RE{tid}(?:/$RE{num})?}, sub {
LEFT JOIN users u ON tpv.uid = u.id AND NOT u.ign_votes
LEFT JOIN threads_poll_votes tpm ON tpm.optid = tpo.id AND tpm.uid =', \auth->uid, '
WHERE tpo.tid =', \$id, '
- GROUP BY tpo.id, tpo.option, tpm.optid'
+ GROUP BY tpo.id, tpo.option, tpm.optid
+ ORDER BY tpo.id'
);
- framework_ title => $t->{title}, sub {
- metabox_ $t;
+ auth->notiRead($id, [ map $_->{num}, $posts->@* ]) if @$posts;
+
+ framework_ title => $t->{title}, dbobj => $t, $num ? (js => 1, pagevars => {sethash=>"p$num"}) : (), sub {
+ metabox_ $t, $posts;
elm_ 'Discussions.Poll' => $POLL_OUT, {
question => $t->{poll_question},
max_options => $t->{poll_max_options},
- num_votes => tuwf->dbVali('SELECT COUNT(DISTINCT tpv.uid) FROM threads_poll_votes tpv JOIN users u ON tpv.uid = u.id WHERE NOT u.ign_votes AND tid =', \$id),
+ num_votes => tuwf->dbVali(
+ 'SELECT COUNT(DISTINCT tpv.uid)
+ FROM threads_poll_votes tpv
+ JOIN threads_poll_options tpo ON tpo.id = tpv.optid
+ JOIN users u ON tpv.uid = u.id
+ WHERE NOT u.ign_votes AND tpo.tid =', \$id),
preview => !!tuwf->reqGet('pollview'), # Old non-Elm way to preview poll results
can_vote => !!auth,
tid => $id,
@@ -196,10 +221,4 @@ TUWF::get qr{/$RE{tid}(?:/$RE{num})?}, sub {
}
};
-
-TUWF::get qr{/$RE{postid}}, sub {
- my($id, $num) = (tuwf->capture('id'), tuwf->capture('num'));
- tuwf->resRedirect(post_url($id, $num, $num), 'perm')
-};
-
1;
diff --git a/lib/VNWeb/Discussions/UPosts.pm b/lib/VNWeb/Discussions/UPosts.pm
index 45be3f0b..aaa75c1e 100644
--- a/lib/VNWeb/Discussions/UPosts.pm
+++ b/lib/VNWeb/Discussions/UPosts.pm
@@ -9,7 +9,7 @@ sub listing_ {
my sub url { '?'.query_encode @_ }
paginate_ \&url, $page, [ $count, 50 ], 't';
- div_ class => 'mainbox browse uposts', sub {
+ article_ class => 'browse uposts', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { debug_ $list };
@@ -18,13 +18,13 @@ sub listing_ {
td_ class => 'tc4', 'Title';
}};
tr_ sub {
- my $url = "/t$_->{tid}.$_->{num}";
- td_ class => 'tc1', sub { a_ href => $url, 't'.$_->{tid} };
- td_ class => 'tc2', sub { a_ href => $url, '.'.$_->{num} };
+ my $url = "/$_->{id}.$_->{num}";
+ td_ class => 'tc1', sub { a_ href => $url, $_->{hidden} ? (class => 'grayedout') : (), $_->{id} };
+ td_ class => 'tc2', sub { a_ href => $url, $_->{hidden} ? (class => 'grayedout') : (), '.'.$_->{num} };
td_ class => 'tc3', fmtdate $_->{date};
td_ class => 'tc4', sub {
a_ href => $url, $_->{title};
- b_ class => 'grayedout', sub { lit_ bb2html $_->{msg}, 150 };
+ small_ sub { lit_ bb_format $_->{msg}, maxlength => 150, inline => 1 };
};
} for @$list;
}
@@ -36,28 +36,34 @@ sub listing_ {
TUWF::get qr{/$RE{uid}/posts}, sub {
my $u = tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \tuwf->capture('id'));
- return tuwf->resNotFound if !$u->{id};
+ return tuwf->resNotFound if !$u->{id} || (!$u->{user_name} && !auth->isMod);
my $page = tuwf->validate(get => p => { upage => 1 })->data;
- my $from_and_where = sql
- 'FROM threads_posts tp
- JOIN threads t ON t.id = tp.tid
- WHERE NOT t.private AND NOT t.hidden AND NOT tp.hidden AND tp.uid =', \$u->{id};
+ my $sql = sql '(
+ SELECT tp.tid, tp.num, tp.msg, t.title, tp.date, t.hidden OR tp.hidden IS NOT NULL
+ FROM threads_posts tp
+ JOIN threads t ON t.id = tp.tid
+ WHERE tp.uid =', \$u->{id}, 'AND NOT t.private', auth->permBoardmod ? () : 'AND NOT t.hidden AND tp.hidden IS NULL', '
+ UNION ALL
+ SELECT rp.id, rp.num, rp.msg, v.title[1+1], rp.date, rp.hidden IS NOT NULL
+ FROM reviews_posts rp
+ JOIN reviews r ON r.id = rp.id
+ JOIN', vnt, 'v ON v.id = r.vid
+ WHERE rp.uid =', \$u->{id}, auth->permBoardmod ? () : 'AND rp.hidden IS NULL', '
+ ) p(id,num,msg,title,date,hidden)';
- my $count = tuwf->dbVali('SELECT count(*)', $from_and_where);
- my $list = $count && tuwf->dbPagei(
- { results => 50, page => $page },
- 'SELECT tp.tid, tp.num, substring(tp.msg from 1 for 1000) as msg, t.title
- , ', sql_totime('tp.date'), 'as date',
- $from_and_where, 'ORDER BY tp.date DESC'
+ my $count = tuwf->dbVali('SELECT count(*) FROM', $sql);
+ my $list = $count && tuwf->dbPagei({ results => 50, page => $page },
+ 'SELECT id, num, substring(msg from 1 for 1000) as msg, title, ', sql_totime('date'), 'as date, hidden
+ FROM ', $sql, 'ORDER BY date DESC'
);
- my $own = auth && $u->{id} == auth->uid;
+ my $own = auth && $u->{id} eq auth->uid;
my $title = $own ? 'My posts' : 'Posts by '.user_displayname $u;
- framework_ title => $title, type => 'u', dbobj => $u, tab => 'posts',
+ framework_ title => $title, dbobj => $u, tab => 'posts',
sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
if(!$count) {
p_ +($own ? 'You have' : user_displayname($u).' has').' not posted anything on the forums yet.';
diff --git a/lib/VNWeb/Docs/Edit.pm b/lib/VNWeb/Docs/Edit.pm
index 82d9506f..2e33432a 100644
--- a/lib/VNWeb/Docs/Edit.pm
+++ b/lib/VNWeb/Docs/Edit.pm
@@ -5,9 +5,9 @@ use VNWeb::Docs::Lib;
my $FORM = {
- id => { id => 1 },
- title => { maxlength => 200 },
- content => { required => 0, default => '' },
+ id => { vndbid => 'd' },
+ title => { sl => 1, maxlength => 200 },
+ content => { default => '' },
hidden => { anybool => 1 },
locked => { anybool => 1 },
@@ -20,35 +20,36 @@ my $FORM_CMP = form_compile cmp => $FORM;
TUWF::get qr{/$RE{drev}/edit} => sub {
- my $d = db_entry d => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ my $d = db_entry tuwf->captures('id', 'rev') or return tuwf->resNotFound;
return tuwf->resDenied if !can_edit d => $d;
- $d->{editsum} = $d->{chrev} == $d->{maxrev} ? '' : "Reverted to revision d$d->{id}.$d->{chrev}";
+ $d->{editsum} = $d->{chrev} == $d->{maxrev} ? '' : "Reverted to revision $d->{id}.$d->{chrev}";
- framework_ title => "Edit $d->{title}", type => 'd', dbobj => $d, tab => 'edit',
+ framework_ title => "Edit $d->{title}", dbobj => $d, tab => 'edit',
sub {
- elm_ DocEdit => $FORM_OUT, $d;
+ div_ widget(DocEdit => $FORM_OUT, $d), '';
};
};
-elm_api DocEdit => $FORM_OUT, $FORM_IN, sub {
+js_api DocEdit => $FORM_IN, sub {
my $data = shift;
- my $doc = db_entry d => $data->{id} or return tuwf->resNotFound;
+ my $doc = db_entry $data->{id} or return tuwf->resNotFound;
- return elm_Unauth if !can_edit d => $doc;
- return elm_Unchanged if !form_changed $FORM_CMP, $data, $doc;
+ return tuwf->resDenied if !can_edit d => $doc;
+ return +{ _err => 'No changes' } if !form_changed $FORM_CMP, $data, $doc;
- my($id,undef,$rev) = db_edit d => $doc->{id}, $data;
- elm_Redirect "/d$id.$rev";
+ $data->{html} = md2html $data->{content};
+ my $c = db_edit d => $doc->{id}, $data;
+ +{ _redir => "/$c->{nitemid}.$c->{nrev}" };
};
-elm_api Markdown => undef, {
- content => { required => 0, default => '' }
+js_api Markdown => {
+ content => { default => '' }
}, sub {
- return elm_Unauth if !auth->permDbmod;
- elm_Content md2html shift->{content};
+ return tuwf->resDenied if !auth->permDbmod;
+ +{ html => enrich_html md2html shift->{content} };
};
diff --git a/lib/VNWeb/Docs/Lib.pm b/lib/VNWeb/Docs/Lib.pm
index eed1afc0..9a0cb6f9 100644
--- a/lib/VNWeb/Docs/Lib.pm
+++ b/lib/VNWeb/Docs/Lib.pm
@@ -1,32 +1,24 @@
package VNWeb::Docs::Lib;
use VNWeb::Prelude;
-use Text::MultiMarkdown 'markdown';
+use VNDB::Skins;
-our @EXPORT = qw/md2html/;
+our @EXPORT = qw/enrich_html/;
-# Lets you call TUWF::XML functions and returns a string, doesn't affect any existing TUWF::XML outputs.
-# Nice idea for a TUWF::XML feature.
-sub lexicalxml(&) {
- my $f = shift;
- my $buf = '';
- local $TUWF::XML::OBJ = TUWF::XML->new(write => sub { $buf .= shift });
- $f->();
- $buf
-}
-
+my @special_perms = qw/boardmod dbmod usermod tagmod/;
sub _moderators {
- my $l = tuwf->dbAlli('SELECT id, username, perm FROM users WHERE (perm & ', \(auth->allPerms &~ auth->defaultPerms), ') > 0 ORDER BY id LIMIT 100');
- my @modperms = grep 0 == (auth->listPerms->{$_} & auth->defaultPerms), keys auth->listPerms->%*;
+ my $cols = sql_comma map "perm_$_", @special_perms;
+ my $where = sql_or map "perm_$_", @special_perms;
+ state $l //= tuwf->dbAlli("SELECT u.id, username, $cols FROM users u JOIN users_shadow us ON us.id = u.id WHERE $where ORDER BY u.id LIMIT 100");
- lexicalxml {
+ xml_string sub {
dl_ sub {
for my $u (@$l) {
- dt_ sub { a_ href => "/u$u->{id}", $u->{username} };
- dd_ auth->allPerms == ($u->{perm} & auth->allPerms) ? 'admin'
- : join ', ', sort grep $u->{perm} & auth->listPerms->{$_}, @modperms;
+ dt_ sub { a_ href => "/$u->{id}", $u->{username} };
+ dd_ @special_perms == grep($u->{"perm_$_"}, @special_perms) ? 'admin'
+ : join ', ', grep $u->{"perm_$_"}, @special_perms;
}
}
}
@@ -35,15 +27,15 @@ sub _moderators {
sub _skincontrib {
my %users;
- push $users{ tuwf->{skins}{$_}[1] }->@*, [ $_, tuwf->{skins}{$_}[0] ]
- for sort { tuwf->{skins}{$a}[0] cmp tuwf->{skins}{$b}[0] } keys tuwf->{skins}->%*;
+ push $users{ skins->{$_}{userid} }->@*, [ $_, skins->{$_}{name} ]
+ for sort { skins->{$a}{name} cmp skins->{$b}{name} } keys skins->%*;
- my $u = tuwf->dbAlli('SELECT id, username FROM users WHERE id IN', [keys %users]);
+ my $u = tuwf->dbAlli('SELECT id, username FROM users WHERE id IN', [keys %users], 'ORDER BY id');
- lexicalxml {
+ xml_string sub {
dl_ sub {
for my $u (@$u) {
- dt_ sub { a_ href => "/u$u->{id}", $u->{username} };
+ dt_ sub { a_ href => "/$u->{id}", $u->{username} };
dd_ sub {
join_ ', ', sub { a_ href => "?skin=$_->[0]", $_->[1] }, $users{$u->{id}}->@*
}
@@ -53,36 +45,11 @@ sub _skincontrib {
}
-sub md2html {
- my $content = shift;
-
- $content =~ s{^:MODERATORS:$}{_moderators}me;
- $content =~ s{^:SKINCONTRIB:$}{_skincontrib}me;
-
- my $html = markdown $content, {
- strip_metadata => 1,
- img_ids => 0,
- disable_footnotes => 1,
- disable_bibliography => 1,
- };
-
- # Number sections and turn them into links
- my($sec, $subsec) = (0,0);
- $html =~ s{<h([1-2])[^>]+>(.*?)</h\1>}{
- if($1 == 1) {
- $sec++;
- $subsec = 0;
- qq{<h3><a href="#$sec" name="$sec">$sec. $2</a></h3>}
- } elsif($1 == 2) {
- $subsec++;
- qq|<h4><a href="#$sec.$subsec" name="$sec.$subsec">$sec.$subsec. $2</a></h4>\n|
- }
- }ge;
+sub enrich_html {
+ my $html = shift;
- # Text::MultiMarkdown doesn't handle fenced code blocks properly. The
- # following solution breaks inline code blocks, but I don't use those anyway.
- $html =~ s/<code>/<pre>/g;
- $html =~ s#</code>#</pre>#g;
+ $html =~ s{^:MODERATORS:}{_moderators}me;
+ $html =~ s{^:SKINCONTRIB:}{_skincontrib}me;
$html
}
diff --git a/lib/VNWeb/Docs/Page.pm b/lib/VNWeb/Docs/Page.pm
index 2225890a..e9949ab3 100644
--- a/lib/VNWeb/Docs/Page.pm
+++ b/lib/VNWeb/Docs/Page.pm
@@ -6,7 +6,7 @@ use VNWeb::Docs::Lib;
sub _index_ {
ul_ class => 'index', sub {
- li_ sub { b_ 'Guidelines' };
+ li_ sub { strong_ 'Guidelines' };
li_ sub { a_ href => '/d5', 'Editing Guidelines' };
li_ sub { a_ href => '/d2', 'Visual Novels' };
li_ sub { a_ href => '/d15', 'Special Games' };
@@ -15,15 +15,15 @@ sub _index_ {
li_ sub { a_ href => '/d16', 'Staff' };
li_ sub { a_ href => '/d12', 'Characters' };
li_ sub { a_ href => '/d10', 'Tags & Traits' };
+ li_ sub { a_ href => '/d19', 'Image Flagging' };
li_ sub { a_ href => '/d13', 'Capturing Screenshots' };
- li_ sub { b_ 'About VNDB' };
+ li_ sub { strong_ 'About VNDB' };
li_ sub { a_ href => '/d9', 'Discussion Board' };
li_ sub { a_ href => '/d6', 'FAQ' };
li_ sub { a_ href => '/d7', 'About Us' };
li_ sub { a_ href => '/d17', 'Privacy Policy & Licensing' };
li_ sub { a_ href => '/d11', 'Database API' };
li_ sub { a_ href => '/d14', 'Database Dumps' };
- li_ sub { a_ href => '/d18', 'Database Querying' };
li_ sub { a_ href => '/d8', 'Development' };
}
}
@@ -31,33 +31,25 @@ sub _index_ {
sub _rev_ {
my $d = shift;
- revision_ d => $d, sub {},
+ revision_ $d, sub {},
[ title => 'Title' ],
[ content => 'Contents' ];
}
-# A little in-memory cache of the rendered HTML for the latest revision of each
-# doc page. md2html() performance is "acceptable" for regular page loads but
-# can still feel a little sluggish.
-my %cache; # chid => html
-
-
TUWF::get qr{/$RE{drev}} => sub {
- my $d = db_entry d => tuwf->capture('id'), tuwf->capture('rev');
+ my $d = db_entry tuwf->captures('id', 'rev');
return tuwf->resNotFound if !$d;
- my $html = $cache{$d->{chid}} || md2html $d->{content};
- $cache{$d->{chid}} ||= $html if $d->{chrev} == $d->{maxrev};
-
- framework_ title => $d->{title}, index => 1, type => 'd', dbobj => $d, hiddenmsg => 1,
+ framework_ title => $d->{title}, index => !tuwf->capture('rev'), dbobj => $d, hiddenmsg => 1,
sub {
_rev_ $d if tuwf->capture('rev');
- div_ class => 'mainbox', sub {
+ article_ sub {
+ itemmsg_ $d;
h1_ $d->{title};
div_ class => 'docs', sub {
_index_;
- lit_ $html;
+ lit_ enrich_html($d->{html} || md2html $d->{content});
clearfloat_;
};
};
diff --git a/lib/VNWeb/Elm.pm b/lib/VNWeb/Elm.pm
index 0c8b42eb..ad4f80a3 100644
--- a/lib/VNWeb/Elm.pm
+++ b/lib/VNWeb/Elm.pm
@@ -1,7 +1,8 @@
-# This module is responsible for generating elm/Gen/*.
+# This module is responsible for generating elm/Gen/*;
#
-# It exports an `elm_form` function to generate type definitions, a JSON
-# encoder and HTML5 validation attributes to simplify and synchronize forms.
+# It exports an `elm_api` function to create an API endpoint, type definitions,
+# a JSON encoder and HTML5 validation attributes to simplify and synchronize
+# forms.
#
# It also exports an `elm_Response` function for each possible API response
# (see %apis below).
@@ -16,10 +17,13 @@ use List::Util 'max';
use VNDB::Config;
use VNDB::Types;
use VNDB::Func 'fmtrating';
+use VNDB::ExtLinks ();
+use VNDB::Skins;
+use VNWeb::Validation;
use VNWeb::Auth;
our @EXPORT = qw/
- elm_api
+ elm_api elm_empty
/;
@@ -29,50 +33,167 @@ our @EXPORT = qw/
# elm_Changed $id, $revision;
#
# These API responses are available in Elm in the `Gen.Api.Response` union type.
-my %apis = (
+our %apis = (
Unauth => [], # Not authorized
Unchanged => [], # No changes
Success => [],
Redirect => [{}], # Redirect to the given URL
- CSRF => [], # Invalid CSRF token
Invalid => [], # POST data did not validate the schema
+ Editsum => [], # Invalid edit summary
Content => [{}], # Rendered HTML content (for markdown/bbcode APIs)
- BadLogin => [], # Invalid user or pass
- LoginThrottle => [], # Too many failed login attempts
- InsecurePass => [], # Password is in a dictionary or breach database
- BadEmail => [], # Unknown email address in password reset form
- Bot => [], # User didn't pass bot verification
- Taken => [], # Username already taken
- DoubleEmail => [], # Account with same email already exists
- DoubleIP => [], # Account with same IP already exists
- BadCurPass => [], # Current password is incorrect when changing password
- MailChange => [], # A confirmation mail has been sent to change a user's email address
- Releases => [ { aoh => { # Response to /r/get.json
- id => { id => 1 },
+ ImgFormat => [], # Unrecognized image format
+ LabelId => [{uint => 1}], # Label created
+ DupNames => [ { aoh => { # Duplicate names/aliases (for tags & traits)
+ id => { vndbid => ['i','g'] },
+ name => {},
+ } } ],
+ Releases => [ { aoh => { # Response to 'Release'
+ id => { vndbid => 'r' },
title => {},
- original => { required => 0, default => '' },
+ alttitle => { default => '' },
released => { uint => 1 },
rtype => {},
+ reso_x => { uint => 1 },
+ reso_y => { uint => 1 },
lang => { type => 'array', values => {} },
platforms=> { type => 'array', values => {} },
} } ],
- BoardResult => [ { aoh => { # Response to /t/boards.json
- btype => {},
- iid => { required => 0, default => 0, id => 1 },
- title => { required => 0 },
+ Resolutions => [ { aoh => { # Response to 'Resolutions'
+ resolution => {},
+ count => { uint => 1 },
+ } } ],
+ Engines => [ { aoh => { # Response to 'Engines'
+ engine => {},
+ count => { uint => 1 },
+ } } ],
+ DRM => [ { aoh => { # Response to 'DRM'
+ name => {},
+ count => { uint => 1 },
+ } } ],
+ BoardResult => [ { aoh => { # Response to 'Boards'
+ btype => { enum => \%BOARD_TYPE },
+ iid => { default => undef, vndbid => ['p','v','u'] },
+ title => { default => undef },
+ } } ],
+ TagResult => [ { aoh => { # Response to 'Tags'
+ id => { vndbid => 'g' },
+ name => {},
+ searchable => { anybool => 1 },
+ applicable => { anybool => 1 },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+ } } ],
+ TraitResult => [ { aoh => { # Response to 'Traits'
+ id => { vndbid => 'i' },
+ name => {},
+ searchable => { anybool => 1 },
+ applicable => { anybool => 1 },
+ defaultspoil => { uint => 1 },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+ group_id => { default => undef, vndbid => 'i' },
+ group_name => { default => undef },
+ } } ],
+ VNResult => [ { aoh => { # Response to 'VN'
+ id => { vndbid => 'v' },
+ title => {},
+ hidden => { anybool => 1 },
+ } } ],
+ ProducerResult => [ { aoh => { # Response to 'Producers'
+ id => { vndbid => 'p' },
+ name => {},
+ altname => { default => undef },
+ } } ],
+ StaffResult => [ { aoh => { # Response to 'Staff'
+ id => { vndbid => 's' },
+ lang => {},
+ aid => { id => 1 },
+ title => {},
+ alttitle => {},
+ } } ],
+ CharResult => [ { aoh => { # Response to 'Chars'
+ id => { vndbid => 'c' },
+ title => {},
+ alttitle => {},
+ main => { default => undef, type => 'hash', keys => {
+ id => { vndbid => 'c' },
+ title => {},
+ alttitle => {},
+ } }
+ } } ],
+ AnimeResult => [ { aoh => { # Response to 'Anime'
+ id => { id => 1 },
+ title => {},
+ original => { default => '' },
+ } } ],
+ ImageResult => [ { aoh => { # Response to 'Images'
+ id => { vndbid => ['ch','cv','sf'] },
+ token => { default => undef },
+ width => { uint => 1 },
+ height => { uint => 1 },
+ votecount => { uint => 1 },
+ sexual_avg => { num => 1, default => undef },
+ sexual_stddev => { num => 1, default => undef },
+ violence_avg => { num => 1, default => undef },
+ violence_stddev => { num => 1, default => undef },
+ my_sexual => { uint => 1, default => undef },
+ my_violence => { uint => 1, default => undef },
+ my_overrule => { anybool => 1 },
+ entry => { default => undef, type => 'hash', keys => {
+ id => {},
+ title => {},
+ } },
+ votes => { unique => 0, aoh => {
+ user => {},
+ uid => { vndbid => 'u', default => undef },
+ sexual => { uint => 1 },
+ violence => { uint => 1 },
+ ignore => { anybool => 1 },
+ } },
} } ],
);
-
-
-# Generate the elm_Response() functions
+# (These references to other API results cause redundant Elm code - can be deduplicated)
+$apis{AdvSearchQuery} = [ { type => 'hash', keys => { # Response to 'AdvSearchLoad'
+ qtype => {},
+ query => { type => 'any' },
+ producers => $apis{ProducerResult}[0],
+ staff => $apis{StaffResult}[0],
+ tags => $apis{TagResult}[0],
+ traits => $apis{TraitResult}[0],
+ anime => $apis{AnimeResult}[0],
+} } ];
+$apis{UListWidget} = [ { type => 'hash', keys => { # Initialization for UList.Widget and response to UListWidget
+ uid => { vndbid => 'u' },
+ vid => { vndbid => 'v' },
+ # Only includes selected labels, null if the VN is not on the list at all.
+ labels => { default => undef, aoh => { id => { int => 1 }, label => {default => ''} } },
+ # Can be set to null to lazily load the extra data as needed
+ full => { default => undef, type => 'hash', keys => {
+ title => {},
+ labels => { aoh => { id => { int => 1 }, label => {}, private => { anybool => 1 } } },
+ canvote => { anybool => 1 },
+ canreview => { anybool => 1 },
+ vote => { vnvote => 1 },
+ review => { default => undef, vndbid => 'w' },
+ notes => { default => '' },
+ started => { default => '' },
+ finished => { default => '' },
+ releases => $apis{Releases}[0],
+ rlist => { aoh => { id => { vndbid => 'r' }, status => { uint => 1 } } },
+ } },
+} } ];
+
+
+# Compile %apis into a %schema and generate the elm_Response() functions
+my %schemas;
for my $name (keys %apis) {
no strict 'refs';
- $apis{$name} = [ map tuwf->compile($_), $apis{$name}->@* ];
+ $schemas{$name} = [ map tuwf->compile($_), $apis{$name}->@* ];
*{'elm_'.$name} = sub {
my @args = map {
- $apis{$name}[$_]->validate($_[$_])->data if tuwf->debug;
- $apis{$name}[$_]->analyze->coerce_for_json($_[$_], unknown => 'reject')
- } 0..$#{$apis{$name}};
+ $schemas{$name}[$_]->validate($_[$_])->data if tuwf->debug;
+ $schemas{$name}[$_]->analyze->coerce_for_json($_[$_], unknown => 'reject')
+ } 0..$#{$schemas{$name}};
tuwf->resJSON({$name, \@args})
};
push @EXPORT, 'elm_'.$name;
@@ -99,14 +220,16 @@ sub def_type {
my $data = '';
my @keys = $obj->{keys} ? grep $obj->{keys}{$_}{keys}||($obj->{keys}{$_}{values}&&$obj->{keys}{$_}{values}{keys}), sort keys $obj->{keys}->%* : ();
- $data .= def_type($name . to_camel($_), $obj->{keys}{$_}{values} || $obj->{keys}{$_}) for @keys;
+ $data .= def_type($name . to_camel($_), $obj->{keys}{$_}{values} || bless { $obj->{keys}{$_}->%*, required => 1 }, ref $obj->{keys}{$_} ) for @keys;
$data .= sprintf "\ntype alias %s = %s\n\n", $name, $obj->elm_type(
+ any => 'JE.Value',
keys => +{ map {
my $t = $obj->{keys}{$_};
my $n = $name . to_camel($_);
$n = "List $n" if $t->{values};
$n = "Maybe ($n)" if $t->{values} && !$t->{required} && !defined $t->{default};
+ $n = "Maybe $n" if $t->{keys} && !$t->{required} && !defined $t->{default};
($_, $n)
} @keys }
);
@@ -126,12 +249,12 @@ sub def_validation {
my %v = $obj->html5_validation();
$data .= def $name, 'List (Html.Attribute msg)', '[ '.join(', ',
- $v{required} ? 'A.required True' : (),
- $v{minlength} ? "A.minlength $v{minlength}" : (),
- $v{maxlength} ? "A.maxlength $v{maxlength}" : (),
- $v{min} ? 'A.min '.string($v{min}) : (),
- $v{max} ? 'A.max '.string($v{max}) : (),
- $v{pattern} ? 'A.pattern '.string($v{pattern}) : ()
+ $v{required} ? 'A.required True' : (),
+ defined $v{minlength} ? "A.minlength $v{minlength}" : (),
+ defined $v{maxlength} ? "A.maxlength $v{maxlength}" : (),
+ defined $v{min} ? 'A.min '.string($v{min}) : (),
+ defined $v{max} ? 'A.max '.string($v{max}) : (),
+ $v{pattern} ? 'A.pattern '.string($v{pattern}) : ()
).']' if !$obj->{keys};
$data;
}
@@ -140,7 +263,7 @@ sub def_validation {
# Generate an Elm JSON encoder taking a corresponding def_type() as input
sub encoder {
my($name, $type, $obj) = @_;
- def $name, "$type -> JE.Value", $obj->elm_encoder(json_encode => 'JE.');
+ def $name, "$type -> JE.Value", $obj->elm_encoder(any => ' ', json_encode => 'JE.');
}
@@ -148,13 +271,14 @@ sub encoder {
sub write_module {
my($module, $contents) = @_;
- my $fn = sprintf '%s/elm/Gen/%s.elm', config->{root}, $module;
+ my $fn = sprintf '%s/elm/Gen/%s.elm', config->{gen_path}, $module;
# The imports aren't necessary in all the files, but might as well add them.
$contents = <<~"EOF";
-- This file is automatically generated from lib/VNWeb/Elm.pm.
-- Do not edit, your changes will be lost.
module Gen.$module exposing (..)
+ import Dict
import Http
import Html
import Html.Attributes as A
@@ -183,7 +307,7 @@ sub write_module {
# elm_api FormName => $OUT_SCHEMA, $IN_SCHEMA, sub {
# my($data) = @_;
# elm_Success # Or any other elm_Response() function
-# };
+# }, %extra_schemas;
#
# That will create an endpoint at `POST /elm/FormName.json` that accepts JSON
# data that must validate $IN_SCHEMA. The subroutine is given the validated
@@ -201,19 +325,19 @@ sub write_module {
# -- Command to send an API request to the endpoint and receive a response
# send : Send -> (Gen.Api.Response -> msg) -> Cmd msg
#
+# Extra type aliases can be added using %extra_schemas.
sub elm_api {
- my($name, $out, $in, $sub) = @_;
+ my($name, $out, $in, $sub, %extra) = @_;
- $in = ref $in eq 'HASH' ? tuwf->compile({ type => 'hash', keys => $in }) : $in;
- $out = ref $out eq 'HASH' ? tuwf->compile({ type => 'hash', keys => $out }) : $out;
+ my sub comp { ref $_[0] eq 'HASH' ? tuwf->compile({ type => 'hash', keys => $_[0] }) : $_[0] }
+ $in = comp $in;
TUWF::post qr{/elm/\Q$name\E\.json} => sub {
- if(!auth->csrfcheck(tuwf->reqHeader('X-CSRF-Token')||'')) {
- warn "Invalid CSRF token in request\n";
- return elm_CSRF();
- }
-
my $data = tuwf->validate(json => $in);
+ # Handle failure of the 'editsum' validation as a special case and return elm_Editsum().
+ if(!$data && $data->err->{errors} && grep $_->{validation} eq 'editsum' || ($_->{validation} eq 'required' && $_->{key} eq 'editsum'), $data->err->{errors}->@*) {
+ return elm_Editsum();
+ }
if(!$data) {
warn "JSON validation failed\ninput: " . JSON::XS->new->allow_nonref->pretty->canonical->encode(tuwf->reqJSON) . "\nerror: " . JSON::XS->new->encode($data->err) . "\n";
return elm_Invalid();
@@ -226,8 +350,9 @@ sub elm_api {
if(tuwf->{elmgen}) {
my $data = "import Gen.Api as GApi\n";
$data .= "import Lib.Api as Api\n";
- $data .= def_type Recv => $out->analyze if $out;
+ $data .= def_type Recv => comp($out)->analyze if $out;
$data .= def_type Send => $in->analyze;
+ $data .= def_type $_ => comp($extra{$_})->analyze for sort keys %extra;
$data .= def_validation val => $in->analyze;
$data .= encoder encode => 'Send', $in->analyze;
$data .= "send : Send -> (GApi.Response -> msg) -> Cmd msg\n";
@@ -237,6 +362,27 @@ sub elm_api {
}
+# Return a new, empty value that conforms to the given schema and can be parsed
+# by the generated Elm/json decoder for the same schema. It may not actually
+# validate according to the schema (e.g. required fields may be left empty).
+# Values are initialized as follows:
+# - If a 'default' has been set in the schema, that will be used.
+# - Nullable fields are initialized to undef
+# - Integers are initialized to 0
+# - Strings are initialized to ""
+# - Arrays are initialized to []
+sub elm_empty {
+ my($schema) = @_;
+ $schema = $schema->analyze if ref $schema eq 'TUWF::Validate';
+ return $schema->{default} if exists $schema->{default};
+ return undef if !$schema->{required};
+ return [] if $schema->{type} eq 'array';
+ return '' if $schema->{type} eq 'bool' || $schema->{type} eq 'scalar';
+ return 0 if $schema->{type} eq 'num' || $schema->{type} eq 'int';
+ return +{ map +($_, elm_empty($schema->{keys}{$_})), $schema->{keys} ? keys $schema->{keys}->%* : () } if $schema->{type} eq 'hash';
+ die "Unable to initialize required value of type '$schema->{type}' without a default";
+}
+
# Generate the Gen.Api module with the Response type and decoder.
sub write_api {
@@ -246,9 +392,9 @@ sub write_api {
# of the Elm code, similar to def_type().
my(@union, @decode);
my $data = '';
- my $len = max map length, keys %apis;
- for (sort keys %apis) {
- my($name, $schema) = ($_, $apis{$_});
+ my $len = max map length, keys %schemas;
+ for (sort keys %schemas) {
+ my($name, $schema) = ($_, $schemas{$_});
my $def = $name;
my $dec = sprintf 'JD.field "%s"%s <| %s', $name,
' 'x($len-(length $name)),
@@ -282,28 +428,66 @@ sub write_api {
sub write_types {
my $data = '';
- $data .= def urlStatic => String => string config->{url_static};
- $data .= def adminEMail => String => string config->{admin_email};
- $data .= def userPerms => 'List (Int, String)' => list map tuple(VNWeb::Auth::listPerms->{$_}, string $_), sort keys VNWeb::Auth::listPerms->%*;
- $data .= def skins => 'List (String, String)' =>
- list map tuple(string $_, string tuwf->{skins}{$_}[0]),
- sort { tuwf->{skins}{$a}[0] cmp tuwf->{skins}{$b}[0] } keys tuwf->{skins}->%*;
- $data .= def languages => 'List (String, String)' => list map tuple(string $_, string $LANGUAGE{$_}), sort { $LANGUAGE{$a} cmp $LANGUAGE{$b} } keys %LANGUAGE;
+ $data .= def languages => 'List (String, String)' => list map tuple(string $_, string $LANGUAGE{$_}{txt}), sort { $LANGUAGE{$a}{txt} cmp $LANGUAGE{$b}{txt} } keys %LANGUAGE;
$data .= def platforms => 'List (String, String)' => list map tuple(string $_, string $PLATFORM{$_}), keys %PLATFORM;
$data .= def releaseTypes => 'List (String, String)' => list map tuple(string $_, string $RELEASE_TYPE{$_}), keys %RELEASE_TYPE;
- $data .= def rlistStatus => 'List (Int, String)' => list map tuple($_, string $RLIST_STATUS{$_}), keys %RLIST_STATUS;
+ $data .= def media => 'List (String, String, Bool)' => list map tuple(string $_, string $MEDIUM{$_}{txt}, $MEDIUM{$_}{qty}?'True':'False'), keys %MEDIUM;
+ $data .= def rlistStatus=> 'List (Int, String)' => list map tuple($_, string $RLIST_STATUS{$_}), keys %RLIST_STATUS;
$data .= def boardTypes => 'List (String, String)' => list map tuple(string $_, string $BOARD_TYPE{$_}{txt}), keys %BOARD_TYPE;
- $data .= def ratings => 'List String' => list map string(fmtrating $_), 1..10;
+ $data .= def ratings => 'List String' => list map string(fmtrating $_), 1..10;
+ $data .= def ageRatings => 'List (Int, String)' => list map tuple($_, string $AGE_RATING{$_}{txt}.($AGE_RATING{$_}{ex}?" ($AGE_RATING{$_}{ex})":'')), keys %AGE_RATING;
+ $data .= def devStatus => 'List (Int, String)' => list map tuple($_, string $DEVSTATUS{$_}), keys %DEVSTATUS;
+ $data .= def voiced => 'List (Int, String)' => list map tuple($_, string $VOICED{$_}{txt}), keys %VOICED;
+ $data .= def animated => 'List (Int, String)' => list map tuple($_, string $ANIMATED{$_}{txt}), keys %ANIMATED;
+ $data .= def genders => 'List (String, String)' => list map tuple(string $_, string $GENDER{$_}), keys %GENDER;
+ $data .= def cupSizes => 'List (String, String)' => list map tuple(string $_, string $CUP_SIZE{$_}), keys %CUP_SIZE;
+ $data .= def bloodTypes => 'List (String, String)' => list map tuple(string $_, string $BLOOD_TYPE{$_}), keys %BLOOD_TYPE;
+ $data .= def charRoles => 'List (String, String)' => list map tuple(string $_, string $CHAR_ROLE{$_}{txt}), keys %CHAR_ROLE;
+ $data .= def vnLengths => 'List (Int, String)' => list map tuple($_, string $VN_LENGTH{$_}{txt}.($VN_LENGTH{$_}{time}?" ($VN_LENGTH{$_}{time})":'')), keys %VN_LENGTH;
+ $data .= def vnRelations=> 'List (String, String)' => list map tuple(string $_, string $VN_RELATION{$_}{txt}), keys %VN_RELATION;
+ $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;
}
+sub write_extlinks {
+ my $data =<<~'_';
+ import Regex
+
+ type alias Site =
+ { name : String
+ , advid : String
+ }
+ _
+
+ my sub links {
+ my($name, @links) = @_;
+ $data .= def $name.'Sites' => "List (Site)" => list map {
+ my $l = $_;
+ my $addval = $l->{int} ? 'toint v' : 'v';
+ '{ '.join("\n , ",
+ 'name = '.string($l->{name}),
+ 'advid = '.string($l->{id} =~ s/^l_//r),
+ )."\n }";
+ } @links;
+ }
+ links release => VNDB::ExtLinks::extlinks_sites('r');
+ links staff => VNDB::ExtLinks::extlinks_sites('s');
+
+ write_module ExtLinks => $data;
+}
+
+
if(tuwf->{elmgen}) {
- mkdir config->{root}.'/elm/Gen';
write_api;
write_types;
- open my $F, '>', config->{root}.'/elm/Gen/.generated';
+ write_extlinks;
+ open my $F, '>', config->{gen_path}.'/elm/Gen/.generated';
print $F scalar gmtime;
}
diff --git a/lib/VNWeb/Filters.pm b/lib/VNWeb/Filters.pm
new file mode 100644
index 00000000..b422ad8c
--- /dev/null
+++ b/lib/VNWeb/Filters.pm
@@ -0,0 +1,246 @@
+package VNWeb::Filters;
+
+# This module implements validating old search filters and converting them to
+# the new AdvSearch system. It only exists for compatibility with old URLs.
+
+use v5.26;
+use TUWF;
+use VNDB::Types;
+use VNWeb::Auth;
+use VNWeb::Validation;
+use Exporter 'import';
+
+our @EXPORT = qw/filter_parse filter_vn_adv filter_release_adv filter_char_adv filter_staff_adv/;
+
+
+my $VN = form_compile any => {
+ date_before => { default => undef, uint => 1, range => [0, 99999999] }, # don't use 'rdate' validation here, the search form allows invalid dates
+ date_after => { default => undef, uint => 1, range => [0, 99999999] }, # ^
+ released => { undefbool => 1 },
+ length => { undefarray => { enum => \%VN_LENGTH } },
+ hasani => { undefbool => 1 },
+ hasshot => { undefbool => 1 },
+ tag_inc => { undefarray => { id => 1 } },
+ tag_exc => { undefarray => { id => 1 } },
+ taginc => { undefarray => {} }, # [old] Tag search by name
+ tagexc => { undefarray => {} }, # [old] Tag search by name
+ tagspoil => { default => 0, uint => 1, range => [0,2] },
+ lang => { undefarray => { enum => \%LANGUAGE } },
+ olang => { undefarray => { enum => \%LANGUAGE } },
+ plat => { undefarray => { enum => \%PLATFORM } },
+ staff_inc => { undefarray => { id => 1 } },
+ staff_exc => { undefarray => { id => 1 } },
+ ul_notblack => { undefbool => 1 },
+ ul_onwish => { undefbool => 1 },
+ ul_voted => { undefbool => 1 },
+ ul_onlist => { undefbool => 1 },
+};
+
+my $RELEASE = form_compile any => {
+ type => { default => undef, enum => \%RELEASE_TYPE },
+ patch => { undefbool => 1 },
+ freeware => { undefbool => 1 },
+ doujin => { undefbool => 1 },
+ uncensored => { undefbool => 1 },
+ date_before => { default => undef, range => [0, 99999999] }, # don't use 'rdate' validation here, the search form allows invalid dates
+ date_after => { default => undef, range => [0, 99999999] }, # ^
+ released => { undefbool => 1 },
+ minage => { undefarray => { enum => [-1, keys %AGE_RATING] } },
+ lang => { undefarray => { enum => \%LANGUAGE } },
+ olang => { undefarray => { enum => \%LANGUAGE } },
+ resolution => { undefarray => {} },
+ plat => { undefarray => { enum => [ 'unk', keys %PLATFORM ] } },
+ prod_inc => { undefarray => { id => 1 } },
+ prod_exc => { undefarray => { id => 1 } },
+ med => { undefarray => { enum => [ 'unk', keys %MEDIUM ] } },
+ voiced => { undefarray => { enum => \%VOICED } },
+ ani_story => { undefarray => { enum => \%ANIMATED } },
+ ani_ero => { undefarray => { enum => \%ANIMATED } },
+ engine => { default => undef },
+};
+
+my $CHAR = form_compile any => {
+ gender => { undefarray => { enum => \%GENDER } },
+ bloodt => { undefarray => { enum => \%BLOOD_TYPE } },
+ bust_min => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ bust_max => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ waist_min => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ waist_max => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ hip_min => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ hip_max => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ height_min => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ height_max => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ weight_min => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ weight_max => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ cup_min => { default => undef, enum => \%CUP_SIZE },
+ cup_max => { default => undef, enum => \%CUP_SIZE },
+ va_inc => { undefarray => { id => 1 } },
+ va_exc => { undefarray => { id => 1 } },
+ trait_inc => { undefarray => { id => 1 } },
+ trait_exc => { undefarray => { id => 1 } },
+ tagspoil => { default => 0, uint => 1, range => [0,2] },
+ role => { undefarray => { enum => \%CHAR_ROLE } },
+};
+
+my $STAFF = form_compile any => {
+ gender => { undefarray => { enum => [qw[unknown m f]] } },
+ role => { undefarray => { enum => [ 'seiyuu', keys %CREDIT_TYPE ] } },
+ truename => { undefbool => 1 },
+ lang => { undefarray => { enum => \%LANGUAGE } },
+};
+
+
+
+# Compatibility with old VN filters. Modifies the filter in-place and returns the number of changes made.
+sub filter_vn_compat {
+ my($fil) = @_; #XXX: This function is called from old VNDB:: code and the filter data may not have been normalized as per the schema.
+ my $mod = 0;
+
+ # older tag specification (by name rather than ID)
+ for ('taginc', 'tagexc') {
+ my $l = delete $fil->{$_};
+ next if !$l;
+ $l = [ map lc($_), ref $l ? @$l : $l ];
+ $fil->{ s/^tag/tag_/rg } ||= [ map $_->{id}, tuwf->dbAlli(
+ 'SELECT DISTINCT id FROM tags WHERE searchable AND lower(name) IN', $l
+ )->@* ];
+ $mod++;
+ }
+
+ $mod;
+}
+
+
+# Resolutions were passed as integers into an array index before 6bd0b0cd1f3892253d881f71533940f0cf07c13d.
+# New resolutions have been added to this array in the past, so some older filters may reference the wrong resolution.
+my @OLDRES = (qw/unknown nonstandard 640x480 800x600 1024x768 1280x960 1600x1200 640x400 960x600 1024x576 1024x600 1024x640 1280x720 1280x800 1366x768 1600x900 1920x1080/);
+
+sub filter_release_compat {
+ my($fil) = @_;
+ my $mod = 0;
+ $fil->{resolution} &&= [ map /^(?:0|[1-9][0-9]*)$/ && $_ <= $#OLDRES ? do { $mod++; $OLDRES[$_] } : $_, $fil->{resolution}->@* ];
+ $mod;
+}
+
+
+
+my @fil_escape = split //, '_ !"#$%&\'()*+,-./:;<=>?@[\]^`{}~';
+
+sub _fil_parse {
+ my $str = shift;
+ my %r;
+ for (split /\./, $str) {
+ next if !/^([a-z0-9_]+)-([a-zA-Z0-9_~\x81-\x{ffffff}]+)$/;
+ my($f, $v) = ($1, $2);
+ my @v = split /~/, $v;
+ s/_([0-9]{2})/$1 > $#fil_escape ? '' : $fil_escape[$1]/eg for(@v);
+ $r{$f} = @v > 1 ? \@v : $v[0]
+ }
+ return \%r;
+}
+
+
+# Throws error on failure.
+sub filter_parse {
+ my($type, $str) = @_;
+ return {} if !$str;
+ my $s = {v => $VN, r => $RELEASE, c => $CHAR, s => $STAFF}->{$type};
+ my $data = ref $str ? $str : $str =~ /^{/ ? JSON::XS->new->decode($str) : _fil_parse $str;
+ die "Invalid filter data: $str\n" if !$data;
+ my $f = $s->validate($data)->data;
+ filter_vn_compat $f if $type eq 'v';
+ filter_release_compat $f if $type eq 'r';
+ $f
+}
+
+
+sub filter_vn_adv {
+ my($fil) = @_;
+ [ 'and',
+ defined $fil->{date_before} ? [ 'released', '<=', $fil->{date_before} ] : (),
+ defined $fil->{date_after} ? [ 'released', '>=', $fil->{date_after} ] : (),
+ defined $fil->{released} ? [ 'released', $fil->{released} ? '<=' : '>', 1 ] : (),
+ defined $fil->{length} ? [ 'or', map [ 'length', '=', $_ ], $fil->{length}->@* ] : (),
+ defined $fil->{hasani} ? [ 'has_anime', $fil->{hasani} ? '=' : '!=', 1 ] : (),
+ defined $fil->{hasshot} ? [ 'has_screenshot', $fil->{hasshot} ? '=' : '!=', 1 ] : (),
+ defined $fil->{tag_inc} ? [ 'and', map [ 'tag', '=', [ $_, $fil->{tagspoil}, 0 ] ], $fil->{tag_inc}->@* ] : (),
+ defined $fil->{tag_exc} ? [ 'and', map [ 'tag', '!=', [ $_, 2, 0 ] ], $fil->{tag_exc}->@* ] : (),
+ defined $fil->{lang} ? [ 'or', map [ 'lang', '=', $_ ], $fil->{lang}->@* ] : (),
+ defined $fil->{olang} ? [ 'or', map [ 'olang', '=', $_ ], $fil->{olang}->@* ] : (),
+ defined $fil->{plat} ? [ 'or', map [ 'platform', '=', $_ ], $fil->{plat}->@* ] : (),
+ defined $fil->{staff_inc} ? [ 'staff', '=', [ 'or', map [ 'id', '=', $_ ], $fil->{staff_inc}->@* ] ] : (),
+ defined $fil->{staff_exc} ? [ 'staff', '!=', [ 'or', map [ 'id', '=', $_ ], $fil->{staff_exc}->@* ] ] : (),
+ auth ? (
+ defined $fil->{ul_notblack} ? [ 'label', '!=', [ auth->uid, 6 ] ] : (),
+ defined $fil->{ul_onwish} ? [ 'label', $fil->{ul_onwish} ? '=' : '!=', [ auth->uid, 5 ] ] : (),
+ defined $fil->{ul_voted} ? [ 'label', $fil->{ul_voted} ? '=' : '!=', [ auth->uid, 7 ] ] : (),
+ defined $fil->{ul_onlist} ? [ 'on-list', $fil->{ul_onlist} ? '=' : '!=', 1 ] : (),
+ ) : ()
+ ]
+}
+
+
+sub filter_release_adv {
+ my($fil) = @_;
+ [ 'and',
+ defined $fil->{type} ? [ 'rtype', '=', $fil->{type} ] : (),
+ defined $fil->{patch} ? [ 'patch', $fil->{patch} ? '=' : '!=', 1 ] : (),
+ defined $fil->{freeware} ? [ 'freeware', $fil->{freeware} ? '=' : '!=', 1 ] : (),
+ defined $fil->{doujin} ? [ 'doujin', $fil->{doujin} ? '=' : '!=', 1 ] : (),
+ defined $fil->{uncensored} ? [ 'uncensored', $fil->{uncensored} ? '=' : '!=', 1 ] : (),
+ defined $fil->{date_before} ? [ 'released', '<=', $fil->{date_before} ] : (),
+ defined $fil->{date_after} ? [ 'released', '>=', $fil->{date_after} ] : (),
+ defined $fil->{released} ? [ 'released', $fil->{released} ? '<=' : '>', 1 ] : (),
+ defined $fil->{minage} ? [ 'or', map [ 'minage', '=', $_ == -1 ? undef : $_ ], $fil->{minage}->@* ] : (),
+ defined $fil->{lang} ? [ 'or', map [ 'lang', '=', $_ ], $fil->{lang}->@* ] : (),
+ defined $fil->{olang} ? [ 'vn', '=', [ 'or', map [ 'olang', '=', $_ ], $fil->{olang}->@* ] ] : (),
+ defined $fil->{resolution} ? [ 'or', map [ 'resolution', '=', $_ eq 'unknown' ? [0,0] : $_ eq 'nonstandard' ? [0,1] : [split /x/] ], $fil->{resolution}->@* ] : (),
+ defined $fil->{plat} ? [ 'or', map [ 'platform', '=', $_ eq 'unk' ? '' : $_ ], $fil->{plat}->@* ] : (),
+ defined $fil->{prod_inc} ? [ 'or', map [ 'producer-id', '=', $_ ], $fil->{prod_inc}->@* ] : (),
+ defined $fil->{prod_exc} ? [ 'and', map [ 'producer-id', '!=', $_ ], $fil->{prod_exc}->@* ] : (),
+ defined $fil->{med} ? [ 'or', map [ 'medium', '=', $_ eq 'unk' ? '' : $_ ], $fil->{med}->@* ] : (),
+ defined $fil->{voiced} ? [ 'or', map [ 'voiced', '=', $_ ], $fil->{voiced}->@* ] : (),
+ defined $fil->{ani_story} ? [ 'or', map [ 'animation-story', '=', $_ ], $fil->{ani_story}->@* ] : (),
+ defined $fil->{ani_ero} ? [ 'or', map [ 'animation-ero', '=', $_ ], $fil->{ani_ero}->@* ] : (),
+ defined $fil->{engine} ? [ 'engine', '=', $fil->{engine} ] : (),
+ ]
+}
+
+
+sub filter_char_adv {
+ my($fil) = @_;
+ [ 'and',
+ defined $fil->{gender} ? [ 'or', map [ 'sex', '=', $_ ], $fil->{gender}->@* ] : (),
+ defined $fil->{bloodt} ? [ 'or', map [ 'blood_type', '=', $_ ], $fil->{bloodt}->@* ] : (),
+ defined $fil->{bust_min} ? [ 'bust', '>=', $fil->{bust_min} ] : (),
+ defined $fil->{bust_max} ? [ 'bust', '<=', $fil->{bust_max} ] : (),
+ defined $fil->{waist_min} ? [ 'waist', '>=', $fil->{waist_min} ] : (),
+ defined $fil->{waist_max} ? [ 'waist', '<=', $fil->{waist_max} ] : (),
+ defined $fil->{hip_min} ? [ 'hips', '>=', $fil->{hip_min} ] : (),
+ defined $fil->{hip_max} ? [ 'hips', '<=', $fil->{hip_max} ] : (),
+ defined $fil->{height_min} ? [ 'height', '>=', $fil->{height_min} ] : (),
+ defined $fil->{height_max} ? [ 'height', '<=', $fil->{height_max} ] : (),
+ defined $fil->{weight_min} ? [ 'weight', '>=', $fil->{weight_min} ] : (),
+ defined $fil->{weight_max} ? [ 'weight', '<=', $fil->{weight_max} ] : (),
+ defined $fil->{cup_min} ? [ 'cup', '>=', $fil->{cup_min} ] : (),
+ defined $fil->{cup_max} ? [ 'cup', '<=', $fil->{cup_max} ] : (),
+ defined $fil->{va_inc} ? [ 'seiyuu', '=', [ 'or', map [ 'id', '=', $_ ], $fil->{va_inc}->@* ] ] : (),
+ defined $fil->{va_exc} ? [ 'seiyuu', '!=', [ 'or', map [ 'id', '=', $_ ], $fil->{va_exc}->@* ] ] : (),
+ defined $fil->{trait_inc} ? [ 'and', map [ 'trait', '=', [ $_, $fil->{tagspoil} ] ], $fil->{trait_inc}->@* ] : (),
+ defined $fil->{trait_exc} ? [ 'and', map [ 'trait', '!=', [ $_, 2 ] ], $fil->{trait_exc}->@* ] : (),
+ defined $fil->{role} ? [ 'or', map [ 'role', '=', $_ ], $fil->{role}->@* ] : (),
+ ]
+}
+
+
+# 'truename' filter is ignored, not part of the AdvSearch interface
+sub filter_staff_adv {
+ my($fil) = @_;
+ [ 'and',
+ defined $fil->{gender} ? [ 'or', map [ 'gender', '=', $_ ], $fil->{gender}->@* ] : (),
+ defined $fil->{role} ? [ 'or', map [ 'role', '=', $_ ], $fil->{role}->@* ] : (),
+ defined $fil->{lang} ? [ 'or', map [ 'lang', '=', $_ ], $fil->{lang}->@* ] : (),
+ ]
+}
+
+1;
diff --git a/lib/VNWeb/Graph.pm b/lib/VNWeb/Graph.pm
new file mode 100644
index 00000000..8505923c
--- /dev/null
+++ b/lib/VNWeb/Graph.pm
@@ -0,0 +1,119 @@
+package VNWeb::Graph;
+
+# Utility functions for VNWeb::Producers::Graph anv VNWeb::VN::Graph.
+
+use v5.26;
+use AnyEvent::Util;
+use TUWF::XML 'xml_escape';
+use Exporter 'import';
+use List::Util 'max';
+use VNDB::Config;
+use VNDB::Func 'idcmp';
+
+our @EXPORT = qw/gen_nodes dot2svg val_escape node_more gen_dot/;
+
+
+# Given a starting ID, an array of {id0,id1} relation hashes and a number of
+# nodes to be included, returns a hash of (id=>{id, distance, rels}) nodes.
+#
+# This is basically a breath-first search that prioritizes nodes with fewer
+# relations. Direct relations with the starting node are always included,
+# regardless of $num.
+sub gen_nodes {
+ my($id, $rel, $num) = @_;
+
+ my %rels;
+ push $rels{$_->{id0}}->@*, $_->{id1} for @$rel;
+
+ my %nodes;
+ my @q = ({ id => $id, distance => 0 });
+ while(my $n = shift @q) {
+ next if $nodes{$n->{id}};
+ last if $num <= 0 && $n->{distance} > 1;
+ $num--;
+ $n->{rels} = $rels{$n->{id}};
+ $nodes{$n->{id}} = $n;
+ push @q, map +{ id => $_, distance => $n->{distance}+1 }, sort { $rels{$a}->@* <=> $rels{$b}->@* } grep !$nodes{$_}, $n->{rels}->@*;
+ }
+
+ \%nodes;
+}
+
+
+sub dot2svg {
+ my($dot) = @_;
+
+ utf8::encode $dot;
+ my $e = run_cmd([config->{graphviz_path},'-Tsvg'], '<', \$dot, '>', \my $out, '2>', \my $err)->recv;
+ warn "graphviz STDERR: $err\n" if chomp $err;
+ $e and die "Failed to run graphviz";
+
+ # - Remove <?xml> declaration and <!DOCTYPE> (not compatible with embedding in HTML5)
+ # - Remove comments (unused)
+ # - Remove <title> elements (unused)
+ # - Remove first <polygon> element (emulates a background color)
+ # - Replace stroke and fill attributes with classes (so that coloring is done in CSS)
+ # (I used to have an implementation based on XML::Parser, but regexes are so much faster...)
+ utf8::decode $out or die;
+ $out=~ s/<\?xml.+?\?>//r
+ =~ s/<!DOCTYPE[^>]*>//r
+ =~ s/<!--.*?-->//srg
+ =~ s/<title>.+?<\/title>//gr
+ =~ s/<polygon.+?\/>//r
+ =~ s/ font-size="9[^"]+"/ class="title"/gr
+ =~ s/ font-size="[^"]+"//gr
+ =~ s/ font-family="[^"]+"//gr
+ =~ s/ (?:stroke|fill)="([^"]+)"/$1 eq '#111111' ? ' class="border"' : $1 eq '#222222' ? ' class="nodebg"' : ''/egr;
+}
+
+
+sub val_escape { $_[0] =~ s/&/&amp;/rg =~ s/\\/\\\\/rg =~ s/"/&quot;/rg =~ s/</&lt;/rg =~ s/>/&gt;/rg }
+
+
+sub node_more {
+ my($id, $url, $number) = @_;
+ return () if !$number;
+ (
+ qq|\tns$id [ URL = "$url", label="$number more..." ]|,
+ qq|\tn$id -- ns$id [ dir = "forward", style = "dashed" ]|
+ )
+}
+
+
+sub gen_dot {
+ my($lines, $nodes, $rel, $rel_types) = @_;
+
+ # Attempt to figure out a good 'rankdir' to minimize the width of the
+ # graph. Ideally we'd just generate two graphs and pick the least wide one,
+ # but that's way too slow. Graphviz tends to put adjacent nodes next to
+ # each other, so going for the LR (left-right) rank order tends to work
+ # better with large fan-out, while TB (top-bottom) often results in less
+ # wide graphs for large depths.
+ #my $max_distance = max map $_->{distance}, values %$nodes;
+ my $max_fanout = max map scalar grep($nodes->{$_}, $_->{rels}->@*), values %$nodes;
+ my $rankdir = $max_fanout > 6 ? 'LR' : 'TB';
+
+ for (@$rel) {
+ next if idcmp($_->{id0}, $_->{id1}) < 0;
+ my $r1 = $rel_types->{$_->{relation}};
+ my $r2 = $rel_types->{ $r1->{reverse} };
+ my $style = exists $_->{official} && !$_->{official} ? 'style="dotted", ' : '';
+ push @$lines,
+ qq|n$_->{id0} -- n$_->{id1} [$style|.(
+ $r1 == $r2 ? qq|label="$r1->{txt}"| :
+ $r1->{pref} ? qq|headlabel="$r1->{txt}", dir = "forward"| :
+ $r2->{pref} ? qq|taillabel="$r2->{txt}", dir = "back"| :
+ qq|headlabel="$r1->{txt}", taillabel="$r2->{txt}"|
+ ).']';
+ }
+
+ qq|graph rgraph {\n|.
+ qq|\trankdir = "$rankdir"\n|.
+ qq|\tnode [ fontname = "Arial", shape = "plaintext", fontsize = 8, color = "#111111" ]\n|.
+ qq|\tedge [ labeldistance = 2.5, labelangle = -20, labeljust = 1, minlen = 2, dir = "both",|.
+ qq| fontname = "Arial", fontsize = 7, arrowsize = 0.7, color = "#111111" ]\n|.
+ join("\n", @$lines).
+ qq|\n}\n|;
+}
+
+1;
diff --git a/lib/VNWeb/HTML.pm b/lib/VNWeb/HTML.pm
index 066cbde9..13df2256 100644
--- a/lib/VNWeb/HTML.pm
+++ b/lib/VNWeb/HTML.pm
@@ -4,29 +4,34 @@ use v5.26;
use warnings;
use utf8;
use Algorithm::Diff::XS 'sdiff', 'compact_diff';
-use Encode 'encode_utf8', 'decode_utf8';
use JSON::XS;
use TUWF ':html5_', 'uri_escape', 'html_escape', 'mkclass';
use Exporter 'import';
-use POSIX 'ceil', 'strftime';
+use POSIX 'ceil', 'floor', 'strftime';
use Carp 'croak';
+use Digest::SHA;
use JSON::XS;
use VNDB::Config;
use VNDB::BBCode;
+use VNDB::Skins;
+use VNDB::Types;
use VNWeb::Auth;
use VNWeb::Validation;
use VNWeb::DB;
-use VNDB::Func 'fmtdate';
+use VNDB::Func 'fmtdate', 'rdate', 'tattr';
our @EXPORT = qw/
clearfloat_
+ platform_
debug_
join_
- user_ user_displayname
+ user_maybebanned_ user_ user_displayname
rdate_
- elm_
+ vnlength_
+ spoil_
+ elm_ widget
framework_
- revision_
+ revision_patrolled_ revision_
paginate_
sortable_
searchbox_
@@ -35,14 +40,16 @@ our @EXPORT = qw/
/;
-# Encoded as JSON and appended to the end of the page, to be read by pagevars.js.
-our %pagevars;
-
-
# Ugly hack to move rendering down below the float object.
sub clearfloat_ { div_ class => 'clearfloat', '' }
+# Platform icon
+sub platform_ {
+ abbr_ class => "icon-plat-$_[0]", title => $PLATFORM{$_[0]}, '';
+}
+
+
# Throw any data structure on the page for inspection.
sub debug_ {
return if !tuwf->debug;
@@ -65,6 +72,17 @@ sub join_($&@) {
}
+sub user_maybebanned_ {
+ my($obj) = shift;
+ my($prefix) = shift||'user_';
+ my sub f($) { $obj->{"${prefix}$_[0]"} }
+ span_ title => join("\n",
+ !f 'perm_board' ? "Banned from posting" : (),
+ !f 'perm_edit' ? "Banned from editing" : (),
+ ), '🚫' if defined f 'perm_board' && (!f 'perm_board' || !f 'perm_edit');
+}
+
+
# Display a user link, the given object must have the columns as fetched using DB::sql_user().
# Args: $object, $prefix, $capital
sub user_ {
@@ -73,13 +91,16 @@ sub user_ {
my $capital = shift;
my sub f($) { $obj->{"${prefix}$_[0]"} }
- return lit_ '[deleted]' if !f 'id';
+ my $softdel = !defined f 'name';
+ return small_ 'anonymous' if ($softdel && !auth->isMod) || !f 'id';
my $fancy = !(auth->pref('nodistract_can') && auth->pref('nodistract_nofancy'));
my $uniname = f 'uniname_can' && f 'uniname';
- a_ href => '/u'.f('id'),
+ a_ href => '/'.f('id'),
+ $softdel ? (class => 'grayedout') : (),
$fancy && $uniname ? (title => f('name'), $uniname) :
- (!$fancy && $uniname ? (title => $uniname) : (), $capital ? ucfirst f 'name' : f 'name');
+ (!$fancy && $uniname ? (title => $uniname) : (), ($capital ? f 'name' : f 'name') // f 'id');
txt_ '⭐' if $fancy && f 'support_can' && f 'support_enabled';
+ user_maybebanned_ $obj, $prefix;
}
@@ -89,54 +110,70 @@ sub user_displayname {
my $prefix = shift||'user_';
my sub f($) { $obj->{"${prefix}$_[0]"} }
- return '[deleted]' if !f 'id';
+ return 'anonymous' if !f 'id';
my $fancy = !(auth->pref('nodistract_can') && auth->pref('nodistract_nofancy'));
- $fancy && f 'uniname_can' && f 'uniname' ? f 'uniname' : f 'name'
+ $fancy && f 'uniname_can' && f 'uniname' ? f 'uniname' : f('name') // f 'id'
}
-
# Display a release date.
sub rdate_ {
- my $date = sprintf '%08d', shift||0;
- my $future = $date > strftime '%Y%m%d', gmtime;
- my($y, $m, $d) = ($1, $2, $3) if $date =~ /^([0-9]{4})([0-9]{2})([0-9]{2})$/;
+ my $str = rdate $_[0];
+ $_[0] > strftime('%Y%m%d', gmtime) ? b_ class => 'future', $str : txt_ $str;
+}
+
+
+sub vnlength_ {
+ my($l) = @_;
+ my $h = floor($l/60);
+ my $m = $l % 60;
+ txt_ "${h}h" if $h;
+ span_ class => 'small', "${m}m" if $h && $m;
+ txt_ "${m}m" if !$h && $m;
+}
- my $str = $y == 0 ? 'unknown' :
- $y == 9999 ? 'TBA' :
- $m == 99 ? sprintf('%04d', $y) :
- $d == 99 ? sprintf('%04d-%02d', $y, $m) :
- sprintf('%04d-%02d-%02d', $y, $m, $d);
- $future ? b_ class => 'future', $str : txt_ $str
+# Spoiler indication supscript (used for tags & traits)
+sub spoil_ {
+ sup_ title => 'Minor spoiler', 'S' if $_[0] == 1;
+ sup_ title => 'Major spoiler', class => 'standout', 'S' if $_[0] == 2;
}
-# Instantiate an Elm module
+# Instantiate an Elm module.
+# $schema can be set to the string 'raw' to encode the JSON directly, without a normalizing through a schema.
sub elm_ {
my($mod, $schema, $data, $placeholder) = @_;
- $pagevars{elm} ||= [];
- push $pagevars{elm}->@*, [ $mod, $data ? ($schema ? $schema->analyze->coerce_for_json($data, unknown => 'remove') : $data) : () ];
- div_ id => "elm$#{$pagevars{elm}}", $placeholder//'';
+ die "Elm data without a schema" if defined $data && !defined $schema;
+ tuwf->req->{js}{elm} = 1;
+ push tuwf->req->{pagevars}{elm}->@*, [ $mod, $data ? ($schema eq 'raw' ? $data : $schema->analyze->coerce_for_json($data, unknown => 'remove')) : () ];
+ my @arg = (id => sprintf 'elm%d', $#{ tuwf->req->{pagevars}{elm} });
+ $placeholder ? $placeholder->(@arg) : div_ @arg, '';
}
+# Instantiate a JS widget.
+# Used as attribute to a html tag, which will then be used as parent node for the widget.
+# $schema is optional, if present it is used to normalize the data.
+sub widget {
+ my($name, $schema, $data) = @_;
+ $data = $data ? $schema->analyze->coerce_for_json($data, unknown => 'remove') : $schema;
+ tuwf->req->{widget_id} //= 0;
+ tuwf->req->{js}{ VNWeb::JS::widgets()->{$name} // die "No bundle found for widget '$name'" } = 1;
+ my $id = ++tuwf->req->{widget_id};
+ push tuwf->req->{pagevars}{widget}{$name}->@*, [ $id, $data ];
+ (id => sprintf 'widget%d', $id)
+}
+
-sub _sanitize_css {
- # This function is attempting to do the impossible: Sanitize user provided
- # CSS against various attacks. I'm not expecting this to be bullet-proof.
- # This function doesn't bother with HTML injection as the output will go
- # through xml_escape(). Fortunately, we also have CSP in place to mitigate
- # some problems if they arise, but I'd rather not rely on it.
- # I'd *love* to disable support for external url()'s, but unfortunately
- # many people use that to load images. I'm afraid the only way to work
- # around that is to fetch and cache those URLs on the server.
- local $_ = $_[0];
- s/\\//g; # Get rid of backslashes, could be used to bypass the other regexes.
- s/@(import|charset|font-face)[^\n\;]*.//ig;
- s/javascript\s*://ig; # Not sure 'javascript:' URLs do anything, but just in case.
- s/expression\s*\(//ig; # An old IE thing I guess.
- s/binding\s*://ig; # Definitely don't want bindings.
- $_;
+# Generate a url to a file in gen/static/ and append a checksum.
+sub _staticurl {
+ my($file) = @_;
+ state %urls;
+ $urls{$file} //= do {
+ my $c = Digest::SHA->new('sha1');
+ $c->addfile(config->{gen_path}.'/static/'.$file);
+ sprintf '%s/%s?%s', config->{url_static}, $file, substr $c->hexdigest(), 0, 8;
+ };
}
@@ -144,33 +181,34 @@ sub _head_ {
my $o = shift;
my $fancy = !(auth->pref('nodistract_can') && auth->pref('nodistract_nofancy'));
- my $pubskin = $fancy && $o->{type} && $o->{type} eq 'u' && $o->{dbobj} ? tuwf->dbRowi(
- 'SELECT customcss, skin FROM users WHERE pubskin_can AND pubskin_enabled AND id =', \$o->{dbobj}{id}
+ my $pubskin = $fancy && $o->{dbobj} && $o->{dbobj}{id} =~ /^u/ ? tuwf->dbRowi(
+ 'SELECT u.id, customcss_csum, skin FROM users u JOIN users_prefs up ON up.id = u.id WHERE pubskin_can AND pubskin_enabled AND u.id =', \$o->{dbobj}{id}
) : {};
my $skin = tuwf->reqGet('skin') || $pubskin->{skin} || auth->pref('skin') || '';
- $skin = config->{skin_default} if !tuwf->{skins}{$skin};
- my $customcss = $pubskin->{customcss} || auth->pref('customcss');
+ $skin = config->{skin_default} if !skins->{$skin};
+ my $customcss = $pubskin->{customcss_csum} ? [ $pubskin->{id}, $pubskin->{customcss_csum} ] :
+ auth->pref('customcss_csum') ? [ auth->uid, auth->pref('customcss_csum') ] : undef;
meta_ charset => 'utf-8';
title_ $o->{title}.' | vndb';
base_ href => tuwf->reqURI();
link_ rel => 'shortcut icon', href => '/favicon.ico', type => 'image/x-icon';
- link_ rel => 'stylesheet', href => config->{url_static}.'/s/'.$skin.'/style.css?'.config->{version}, type => 'text/css', media => 'all';
- link_ rel => 'search', type => 'application/opensearchdescription+xml', title => 'VNDB VN Search', href => tuwf->reqBaseURI().'/opensearch.xml';
- style_ type => 'text/css', _sanitize_css($customcss) if $customcss;
+ link_ rel => 'stylesheet', href => _staticurl("$skin.css"), type => 'text/css', media => 'all';
+ link_ rel => 'search', type => 'application/opensearchdescription+xml', title => 'VNDB Visual Novel Search', href => tuwf->reqBaseURI().'/opensearch.xml';
+ link_ rel => 'stylesheet', href => sprintf '/%s.css?%x', $customcss->[0], $customcss->[1] if $customcss;
+ meta_ name => 'viewport', content => 'width=device-width, initial-scale=1.0, user-scalable=yes' if tuwf->reqGet('mobile-test');
if($o->{feeds}) {
link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/announcements.atom", title => 'Site Announcements';
link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/changes.atom", title => 'Recent Changes';
link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/posts.atom", title => 'Recent Posts';
}
- meta_ name => 'csrf-token', content => auth->csrftoken;
- meta_ name => 'robots', content => 'noindex' if !$o->{index};
+ meta_ name => 'robots', content => 'noindex' if !$o->{index} || tuwf->reqGet('view');
# Opengraph metadata
if($o->{og}) {
$o->{og}{site_name} ||= 'The Visual Novel Database';
$o->{og}{type} ||= 'object';
- $o->{og}{image} ||= 'https://s.vndb.org/s/angel/bg.jpg'; # TODO: Something better
+ $o->{og}{image} ||= config->{placeholder_img};
$o->{og}{url} ||= tuwf->reqURI;
$o->{og}{title} ||= $o->{title};
meta_ property => "og:$_", content => ($o->{og}{$_} =~ s/\n/ /gr) for sort keys $o->{og}->%*;
@@ -182,25 +220,24 @@ sub _menu_ {
my $o = shift;
div_ id => 'support', sub {
- a_ href => 'https://www.patreon.com/vndb', id => 'patreon', sub {
- img_ src => config->{url_static}.'/f/patreon.png', alt => 'Support VNDB on Patreon', width => 160, height => 38;
- };
- a_ href => 'https://www.subscribestar.com/vndb', id => 'subscribestar', sub {
- img_ src => config->{url_static}.'/f/subscribestar.png', alt => 'Support VNDB on SubscribeStar', width => 160, height => 38;
- };
+ strong_ 'Support VNDB';
+ p_ sub {
+ a_ href => 'https://www.patreon.com/vndb', 'Patreon';
+ a_ href => 'https://www.subscribestar.com/vndb', 'SubscribeStar';
+ }
} if !(auth->pref('nodistract_can') && auth->pref('nodistract_noads'));
- div_ class => 'menubox', sub {
+ article_ sub {
h2_ 'Menu';
div_ sub {
a_ href => '/', 'Home'; br_;
- a_ href => '/v/all', 'Visual novels'; br_;
- b_ class => 'grayedout', '> '; a_ href => '/g', 'Tags'; br_;
+ a_ href => '/v', 'Visual novels'; br_;
+ small_ '> '; a_ href => '/g', 'Tags'; br_;
a_ href => '/r', 'Releases'; br_;
- a_ href => '/p/all', 'Producers'; br_;
- a_ href => '/s/all', 'Staff'; br_;
- a_ href => '/c/all', 'Characters'; br_;
- b_ class => 'grayedout', '> '; a_ href => '/i', 'Traits'; br_;
+ a_ href => '/p', 'Producers'; br_;
+ a_ href => '/s', 'Staff'; br_;
+ a_ href => '/c', 'Characters'; br_;
+ small_ '> '; a_ href => '/i', 'Traits'; br_;
a_ href => '/u/all', 'Users'; br_;
a_ href => '/hist', 'Recent changes'; br_;
a_ href => '/t', 'Discussion board'; br_;
@@ -208,36 +245,48 @@ sub _menu_ {
a_ href => '/v/rand','Random visual novel'; br_;
a_ href => '/d11', 'API'; lit_ ' - ';
a_ href => '/d14', 'Dumps'; lit_ ' - ';
- a_ href => '/d18', 'Query';
+ a_ href => 'https://query.vndb.org/about', 'Query';
};
- form_ action => '/v/all', method => 'get', id => 'search', sub {
+ form_ action => '/v', method => 'get', sub {
fieldset_ sub {
- legend_ 'Search';
input_ type => 'text', class => 'text', id => 'sq', name => 'sq', value => $o->{search}||'', placeholder => 'search';
- input_ type => 'submit', class => 'submit', value => 'Search';
+ input_ type => 'submit', class => 'hidden', value => 'Search';
}
}
};
- div_ class => 'menubox', sub {
- my $uid = sprintf '/u%d', auth->uid;
- my $nc = auth && tuwf->dbVali('SELECT count(*) FROM notifications WHERE uid =', \auth->uid, 'AND read IS NULL');
- my $support_opt = auth->pref('nodistract_can') || auth->pref('support_can') || auth->pref('uniname_can') || auth->pref('pubskin_can');
+ article_ sub {
+ my $uid = '/'.auth->uid;
h2_ sub { user_ auth->user, 'user_', 1 };
div_ sub {
- a_ href => "$uid/edit", 'My Profile'; txt_ '⭐' if $support_opt && !auth->pref('nodistract_nofancy'); br_;
+ a_ href => "$uid/edit", 'My Profile'; txt_ '⭐' if auth->pref('nodistract_can') && !auth->pref('nodistract_nofancy'); br_;
a_ href => "$uid/ulist?vnlist=1", 'My Visual Novel List'; br_;
a_ href => "$uid/ulist?votes=1",'My Votes'; br_;
a_ href => "$uid/ulist?wishlist=1", 'My Wishlist'; br_;
- a_ href => "$uid/notifies", $nc ? (class => 'notifyget') : (), 'My Notifications'.($nc?" ($nc)":''); br_;
+ a_ href => "$uid/notifies", $o->{unread_noti} ? (class => 'notifyget') : (), 'My Notifications'.($o->{unread_noti}?" ($o->{unread_noti})":''); br_;
a_ href => "$uid/hist", 'My Recent Changes'; br_;
a_ href => '/g/links?u='.auth->uid, 'My Tags'; br_;
br_;
- if(auth->permEdit) {
+ if(VNWeb::Images::Vote::can_vote()) {
+ a_ href => '/img/vote', 'Image Flagging'; br_;
+ }
+ if(can_edit v => {}) {
a_ href => '/v/add', 'Add Visual Novel'; br_;
a_ href => '/p/add', 'Add Producer'; br_;
a_ href => '/s/new', 'Add Staff'; br_;
- a_ href => '/c/new', 'Add Character'; br_;
+ }
+ if(auth->isMod) {
+ my $stats = tuwf->dbRowi("SELECT
+ (SELECT count(*) FROM reports WHERE status = 'new') as new,
+ (SELECT count(*) FROM reports WHERE status = 'new' AND date > (SELECT last_reports FROM users_prefs WHERE id =", \auth->uid, ")) AS unseen,
+ (SELECT count(*) FROM reports WHERE lastmod > (SELECT last_reports FROM users_prefs WHERE id =", \auth->uid, ")) AS upd
+ ");
+ a_ $stats->{unseen} ? (class => 'standout') : (), href => '/report/list?status=new', sprintf 'Reports %d/%d', $stats->{unseen}, $stats->{new};
+ small_ ' | ';
+ a_ href => '/report/list?s=lastmod', sprintf '%d upd', $stats->{upd};
+ br_;
+ a_ global_settings->{lockdown_edit} || global_settings->{lockdown_board} || global_settings->{lockdown_registration} ? (class => 'standout') : (), href => '/lockdown', 'Lockdown';
+ br_;
}
br_;
form_ action => "$uid/logout", method => 'post', sub {
@@ -247,29 +296,29 @@ sub _menu_ {
}
} if auth;
- div_ class => 'menubox', sub {
+ article_ sub {
h2_ 'User menu';
div_ sub {
- my $ref = uri_escape tuwf->reqPath().tuwf->reqQuery();
+ my $ref = uri_escape(tuwf->reqGet('ref') || tuwf->reqPath().tuwf->reqQuery());
a_ href => "/u/login?ref=$ref", 'Login'; br_;
- a_ href => '/u/newpass', 'Password reset'; br_;
a_ href => '/u/register', 'Register'; br_;
}
- } if !auth;
+ } if !auth && !config->{read_only};
- div_ class => 'menubox', sub {
+ article_ sub {
h2_ 'Database Statistics';
div_ sub {
dl_ sub {
- dt_ 'Visual Novels'; dd_ tuwf->{stats}{vn};
- dt_ sub { b_ class => 'grayedout', '> '; lit_ 'Tags' };
- dd_ tuwf->{stats}{tags};
- dt_ 'Releases'; dd_ tuwf->{stats}{releases};
- dt_ 'Producers'; dd_ tuwf->{stats}{producers};
- dt_ 'Staff'; dd_ tuwf->{stats}{staff};
- dt_ 'Characters'; dd_ tuwf->{stats}{chars};
- dt_ sub { b_ class => 'grayedout', '> '; lit_ 'Traits' };
- dd_ tuwf->{stats}{traits};
+ my %stats = map +($_->{section}, $_->{count}), tuwf->dbAll('SELECT * FROM stats_cache')->@*;
+ dt_ 'Visual Novels'; dd_ $stats{vn};
+ dt_ sub { small_ '> '; lit_ 'Tags' };
+ dd_ $stats{tags};
+ dt_ 'Releases'; dd_ $stats{releases};
+ dt_ 'Producers'; dd_ $stats{producers};
+ dt_ 'Staff'; dd_ $stats{staff};
+ dt_ 'Characters'; dd_ $stats{chars};
+ dt_ sub { small_ '> '; lit_ 'Traits' };
+ dd_ $stats{traits};
};
clearfloat_;
}
@@ -278,50 +327,91 @@ sub _menu_ {
sub _footer_ {
- my $q = tuwf->dbRow('SELECT vid, quote FROM quotes ORDER BY RANDOM() LIMIT 1');
- if($q && $q->{vid}) {
+ my($o) = @_;
+ my $q = tuwf->dbRow('SELECT vid, quote FROM quotes WHERE rand <= (SELECT random()) ORDER BY rand DESC LIMIT 1');
+ span_ sub {
lit_ '"';
- a_ href => "/v$q->{vid}", style => 'text-decoration: none', $q->{quote};
- txt_ '"';
+ a_ href => "/$q->{vid}", $q->{quote};
+ txt_ '" ';
br_;
- }
+ } if $q && $q->{vid};
a_ href => config->{source_url}, config->{version};
txt_ ' | ';
+ a_ href => '/d17', 'privacy & content policy';
+ txt_ ' | ';
a_ href => '/d7', 'about us';
lit_ ' | ';
- a_ href => 'irc://irc.synirc.net/vndb', '#vndb';
+ a_ href => '/.env', 'security';
+ lit_ ' | ';
+ a_ href => '/ads.txt', 'advertising';
lit_ ' | ';
a_ href => sprintf('mailto:%s', config->{admin_email}), config->{admin_email};
if(tuwf->debug) {
lit_ ' | ';
+ debug_ tuwf->req->{pagevars};
+ br_;
tuwf->dbCommit; # Hack to measure the commit time
- my $sql = uri_escape join "\n", map {
+ my(@sql_r, @sql_i) = ();
+ for (tuwf->{_TUWF}{DB}{queries}->@*) {
my($sql, $params, $time) = @$_;
- sprintf " [%6.2fms] %s | %s", $time*1000, $sql,
- join ', ', map "$_:".DBI::neat($params->{$_}),
- sort { $a =~ /^[0-9]+$/ && $b =~ /^[0-9]+$/ ? $a <=> $b : $a cmp $b }
- keys %$params;
- } tuwf->{_TUWF}{DB}{queries}->@*;
- a_ href => 'data:text/plain,'.$sql, 'SQL';
- lit_ ' | ';
-
- my $modules = uri_escape join "\n", sort keys %INC;
- a_ href => 'data:text/plain,'.$modules, 'Modules';
- lit_ ' | ';
- debug_ \%pagevars;
+ my @params = sort { $a =~ /^[0-9]+$/ && $b =~ /^[0-9]+$/ ? $a <=> $b : $a cmp $b } keys %$params;
+ my $prefix = sprintf " [%6.2fms] ", $time*1000;
+ push @sql_r, sprintf "%s%s | %s", $prefix, $sql, join ', ', map "$_:".DBI::neat($params->{$_}), @params;
+ my $i=1;
+ push @sql_i, $prefix.($sql =~ s/\?/tuwf->dbh->quote($params->{$i++})/egr);
+ }
+ my $sql_r = join "\n", @sql_r;
+ my $sql_i = join "\n", @sql_i;
+ my $modules = join "\n", sort keys %INC;
+ details_ sub {
+ summary_ 'debug info';
+ pre_ style => 'text-align: left; color: black; background: white',
+ "SQL (with placeholders):\n$sql_r\n\nSQL (interpolated, possibly buggy):\n$sql_i\n\nMODULES:\n$modules";
+ };
}
}
+sub _maintabs_subscribe_ {
+ my($o, $id) = @_;
+ return if !auth || $id !~ /^[twvrpcsdig]/;
+
+ 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 =~ /^[vrpcsdgi]/ && auth->pref('notify_dbedit') && tuwf->dbVali('
+ SELECT 1 FROM changes WHERE itemid =', \$id, 'AND requester =', \auth->uid);
+
+ my $sub = tuwf->dbRowi('SELECT subnum, subreview, subapply FROM notification_subs WHERE uid =', \auth->uid, 'AND iid =', \$id);
+
+ li_ widget(Subscribe => $VNWeb::User::Notifications::SUB, {
+ id => $id,
+ noti => $noti||0,
+ subnum => $sub->{subnum},
+ subreview => $sub->{subreview}||0,
+ subapply => $sub->{subapply}||0,
+ }), class => 'maintabs-dd subscribe', 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/};
- return if !$t || !$o;
- return if $t eq 'g' && !auth->permTagmod;
+ my($o, $sel) = @{$opt}{qw/dbobj tab/};
- my $id = $t.$o->{id};
+ my $id = $o ? $o->{id} : '';
+ my($t) = $o ? $id =~ /^(.)/ : '';
my sub t {
my($tabname, $url, $text) = @_;
@@ -330,48 +420,53 @@ sub _maintabs_ {
};
};
- div_ class => 'maintabs right', sub {
- ul_ sub {
- t '' => "/$id", $id;
+ nav_ sub {
+ label_ for => 'mainmenu', sub {
+ lit_ 'Menu';
+ b_ " ($opt->{unread_noti})" if $opt->{unread_noti};
+ };
+ menu_ sub {
+ t '' => "/$id", $id if $o && $t ne 't';
t rg => "/$id/rg", 'relations'
- if $t =~ /[vp]/ && (exists $o->{rgraph} ? $o->{rgraph}
- : tuwf->dbVali('SELECT rgraph FROM', $t eq 'v' ? 'vn' : 'producers', 'WHERE id =', \$o->{id}));
+ 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 $o && $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};
do {
+ t admin => "/$id/admin", 'admin' if auth->isMod;
t list => "/$id/ulist?vnlist=1", 'list';
t votes => "/$id/ulist?votes=1", 'votes';
t wish => "/$id/ulist?wishlist=1", 'wishlist';
+ t reviews => "/w?u=$o->{id}", 'reviews';
+ t posts => "/$id/posts", 'posts';
} if $t eq 'u';
- t posts => "/$id/posts", 'posts' if $t eq 'u';
-
if($t =~ /[uvp]/) {
my $cnt = tuwf->dbVali(q{
SELECT COUNT(*)
FROM threads_boards tb
JOIN threads t ON t.id = tb.tid
- WHERE tb.type =}, \$t, 'AND tb.iid =', \$o->{id}, 'AND', VNWeb::Discussions::Lib::sql_visible_threads());
+ WHERE tb.type =}, \$t, 'AND tb.iid =', \$o->{id}, ' AND', VNWeb::Discussions::Lib::sql_visible_threads());
t disc => "/t/$id", "discussions ($cnt)";
};
- t hist => "/$id/hist", 'history' if $t =~ /[uvrpcsd]/;
+ t hist => "/$id/hist", 'history' if $t =~ /[uvrpcsdgi]/;
+ _maintabs_subscribe_ $o, $id;
}
}
}
-# Attempt to figure out the board id from a database entry ($type, $dbobj) combination
+# Attempt to figure out the board id from a database entry
sub _board_id {
- my($type, $obj) = @_;
- $type =~ /[vp]/ ? $type.$obj->{id} :
- $type eq 'r' && $obj->{vn}->@* ? 'v'.$obj->{vn}[0]{vid} :
- $type eq 'c' && $obj->{vns}->@* ? 'v'.$obj->{vns}[0]{vid} : 'db';
+ my($obj) = @_;
+ $obj->{id} =~ /^[vp]/ ? $obj->{id} :
+ $obj->{id} =~ /^r/ && $obj->{vn} && $obj->{vn}->@* ? $obj->{vn}[0]{vid} :
+ $obj->{id} =~ /^c/ && $obj->{vns} && $obj->{vns}->@* ? $obj->{vns}[0]{vid} : 'db';
}
@@ -379,39 +474,53 @@ sub _board_id {
sub _hidden_msg_ {
my $o = shift;
- die "Can't use hiddenmsg on an object that is missing 'entry_hidden'" if !exists $o->{dbobj}{entry_hidden};
+ die "Can't use hiddenmsg on an object that is missing 'entry_hidden' or 'entry_locked'"
+ if !exists $o->{dbobj}{entry_hidden} || !exists $o->{dbobj}{entry_locked};
+
return 0 if !$o->{dbobj}{entry_hidden};
- my $msg = tuwf->dbVali(
- 'SELECT comments
+ # Awaiting moderation
+ if(!$o->{dbobj}{entry_locked}) {
+ article_ sub {
+ h1_ $o->{title};
+ div_ class => 'notice', sub {
+ h2_ 'Waiting for approval';
+ p_ 'This entry is waiting for a moderator to approve it.';
+ }
+ };
+ return 0;
+ }
+
+ # Deleted.
+ my $msg = tuwf->dbRowi(
+ 'SELECT comments, rev
FROM changes
- WHERE', { type => $o->{type}, itemid => $o->{dbobj}{id} },
+ WHERE itemid =', \$o->{dbobj}{id},
'ORDER BY id DESC LIMIT 1'
);
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $o->{title};
div_ class => 'warning', sub {
h2_ 'Item deleted';
p_ sub {
+ if($o->{dbobj}{id} =~ /^r/ && $o->{dbobj}{vn}) {
+ txt_ 'This was a release entry for ';
+ join_ ',', sub { a_ href => "/$_->{vid}", tattr $_ }, $o->{dbobj}{vn}->@*;
+ txt_ '.';
+ br_;
+ }
txt_ 'This item has been deleted from the database. You may file a request on the ';
- a_ href => '/t/'._board_id($o->{type}, $o->{dbobj}), "discussion board";
+ a_ href => '/t/'._board_id($o->{dbobj}), "discussion board";
txt_ ' if you believe that this entry should be restored.';
- br_;
- br_;
- lit_ bb2html $msg;
+ if($msg->{rev} > 1) {
+ br_;
+ br_;
+ lit_ bb_format $msg->{comments};
+ }
}
}
};
- !auth->permDbmod # dbmods can still see the page
-}
-
-
-sub v2rwjs_ { # Also used by VNDB::Util::LayoutHTML.
- 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(\%pagevars) =~ s{</}{<\\/}rg =~ s/<!--/<\\u0021--/rg);
- } if keys %pagevars;
- script_ type => 'application/javascript', src => config->{url_static}.'/f/v2rw.js?'.config->{version}, '';
+ $o->{dbobj}{id} !~ /^[gi]/ && !auth->permDbmod # tags/traits are still visible, dbmods can still see all pages
}
@@ -419,49 +528,90 @@ sub v2rwjs_ { # Also used by VNDB::Util::LayoutHTML.
# title => $title
# index => 1/0, default 0
# feeds => 1/0
+# js => 1/0, set to 1 to ensure 'basic.js' is included on the page even if no elm_() modules or JS widgets are loaded.
# search => $query
# og => { opengraph metadata }
-# type => Database entry type (used for the main tabs & hidden message)
# dbobj => Database entry object (used for the main tabs & hidden message)
# Recognized object fields: id, entry_hidden, entry_locked
# tab => Current tab, or empty for the main tab
# hiddenmsg => 1/0, if true and dbobj is 'hidden', a message will be displayed
-# and the content function will not be called.
+# and the content function may not be called.
# sub { content }
sub framework_ {
my $cont = pop;
my %o = @_;
- %pagevars = $o{pagevars} ? $o{pagevars}->%* : ();
-
+ tuwf->req->{pagevars} = { tuwf->req->{pagevars} ? tuwf->req->{pagevars}->%* : (), $o{pagevars}->%* } if $o{pagevars};
+ $o{unread_noti} = auth && tuwf->dbVali('SELECT count(*) FROM notifications WHERE uid =', \auth->uid, 'AND read IS NULL');
+
+ lit_ "<!--\n"
+ ." This HTML is an unreadable auto-generated mess, sorry for that.\n"
+ ." The full source code of this site can be found at ".config->{source_url}."\n"
+ .(tuwf->req->{trace_loc}[0] ?
+ " This particular page was generated by ".config->{source_url}."/src/branch/master/lib/".(tuwf->req->{trace_loc}[0] =~ s/::/\//rg).".pm\n" : '')
+ ."-->\n";
html_ lang => 'en', sub {
head_ sub { _head_ \%o };
body_ sub {
- div_ id => 'bgright', ' ';
- div_ id => 'header', sub { h1_ sub { a_ href => '/', 'the visual novel database' } };
- div_ id => 'menulist', sub { _menu_ \%o };
- div_ id => 'maincontent', sub {
+ input_ type => 'checkbox', class => 'hidden', id => 'mainmenu', name => 'mainmenu';
+ header_ sub {
+ div_ id => 'bgright', ' ';
+ div_ id => 'readonlymode', config->{read_only} eq 1 ? 'The site is in read-only mode, account functionality is currently disabled.' : config->{read_only} if config->{read_only};
+ h1_ sub { a_ href => '/', 'the visual novel database' };
_maintabs_ \%o;
+ };
+ nav_ sub { _menu_ \%o };
+ main_ sub {
$cont->() unless $o{hiddenmsg} && _hidden_msg_ \%o;
- div_ id => 'footer', \&_footer_;
+ footer_ sub { _footer_ \%o };
};
- v2rwjs_;
+
+ # 'basic' bundle is always included if there's any JS at all
+ tuwf->req->{js}{basic} = 1 if tuwf->req->{js}{elm} || tuwf->req->{pagevars}{widget} || $o{js};
+ # 'dbmod' value is used by various widgets
+ tuwf->req->{pagevars}{dbmod} = 1 if tuwf->req->{pagevars}{widget} && auth->permDbmod;
+
+ 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_ defer => 'defer', src => _staticurl("$_.js"), '' for grep tuwf->req->{js}{$_}, qw/elm basic user contrib graph/;
}
}
}
+sub revision_patrolled_ {
+ my($r) = @_;
+ return span_ class => 'done', title =>
+ "Patrolled by ".join(', ', map user_displayname($_), $r->{rev_patrolled}->@*), '✓'
+ if $r->{rev_patrolled}->@*;
+ return lit_ '✓' if $r->{rev_dbmod};
+ small_ '#';
+}
+
sub _revision_header_ {
- my($type, $obj) = @_;
- b_ "Revision $obj->{chrev}";
+ my($obj) = @_;
+ strong_ "Revision $obj->{chrev}";
debug_ $obj;
if(auth) {
lit_ ' (';
- a_ href => "/$type$obj->{id}.$obj->{chrev}/edit", $obj->{chrev} == $obj->{maxrev} ? 'edit' : 'revert to';
+ a_ href => "/$obj->{id}.$obj->{chrev}/edit", $obj->{chrev} == $obj->{maxrev} ? 'edit' : 'revert to';
if($obj->{rev_user_id}) {
lit_ ' / ';
- a_ href => "/t/u$obj->{rev_user_id}/new?title=Regarding%20$type$obj->{id}.$obj->{chrev}", 'msg user';
+ a_ href => "/t/$obj->{rev_user_id}/new?title=Regarding%20$obj->{id}.$obj->{chrev}", 'msg user';
+ }
+ if(auth->permDbmod) {
+ lit_ ' / ';
+ revision_patrolled_ $obj;
+ if($obj->{rev_user_id} && $obj->{rev_user_id} eq auth->uid) {}
+ elsif(grep $_->{user_id} eq auth->uid, $obj->{rev_patrolled}->@*) {
+ a_ href => "?unpatrolled=$obj->{chid}", 'unmark';
+ } else {
+ a_ href => "?patrolled=$obj->{chid}", 'mark patrolled';
+ }
}
lit_ ')';
}
@@ -474,8 +624,8 @@ sub _revision_header_ {
sub _revision_fmtval_ {
- my($opt, $val) = @_;
- return i_ '[empty]' if !defined $val || !length $val || (defined $opt->{empty} && $val eq $opt->{empty});
+ my($opt, $val, $obj) = @_;
+ return em_ '[empty]' if !defined $val || !length $val || (defined $opt->{empty} && $val eq $opt->{empty});
return lit_ html_escape $val if !$opt->{fmt};
if(ref $opt->{fmt} eq 'HASH') {
my $h = $opt->{fmt}{$val};
@@ -483,17 +633,18 @@ sub _revision_fmtval_ {
}
return txt_ $val ? 'True' : 'False' if $opt->{fmt} eq 'bool';
local $_ = $val;
- $opt->{fmt}->();
+ $opt->{fmt}->($obj);
}
sub _revision_fmtcol_ {
- my($opt, $i, $l) = @_;
+ my($opt, $i, $l, $obj) = @_;
my $ctx = 100; # Number of characters of context in textual diffs
- my sub sep_ { b_ class => 'standout', '<...>' }; # Context separator
+ my sub sep_ { b_ '<...>' }; # Context separator
td_ class => 'tcval', sub {
+ em_ '[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];
@@ -501,12 +652,12 @@ sub _revision_fmtcol_ {
if($diff) {
my $lastchunk = int (($#$diff-2)/2);
for my $n (0..$lastchunk) {
- my $a = decode_utf8 join '', @{$old}[ $diff->[$n*2] .. $diff->[$n*2+2]-1 ];
- my $b = decode_utf8 join '', @{$new}[ $diff->[$n*2+1] .. $diff->[$n*2+3]-1 ];
+ utf8::decode(my $a = join '', @{$old}[ $diff->[$n*2] .. $diff->[$n*2+2]-1 ]);
+ utf8::decode(my $b = join '', @{$new}[ $diff->[$n*2+1] .. $diff->[$n*2+3]-1 ]);
# Difference, highlight and display in full
if($n % 2) {
- b_ class => $i == 1 ? 'diff_del' : 'diff_add', sub { lit_ html_escape $i == 1 ? $a : $b };
+ span_ class => $i == 1 ? 'diff_del' : 'diff_add', sub { lit_ html_escape $i == 1 ? $a : $b };
# Short context, display in full
} elsif(length $a < $ctx*3) {
lit_ html_escape $a;
@@ -523,11 +674,11 @@ sub _revision_fmtcol_ {
}
} elsif(@$l > 1 && $i == 2 && ($ch eq '+' || $ch eq 'c')) {
- b_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val }
+ span_ 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 }
- } elsif($ch eq 'c' || $ch eq 'u') {
- _revision_fmtval_ $opt, $val;
+ span_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val, $obj };
+ } elsif($ch eq 'u' || @$l == 1) {
+ _revision_fmtval_ $opt, $val, $obj;
}
}, @$l;
};
@@ -548,13 +699,16 @@ sub _stringify_scalars_rec {
}
sub _revision_diff_ {
- my($type, $old, $new, $field, $name, %opt) = @_;
+ my($old, $new, $field, $name, %opt) = @_;
# First do a diff on the raw field elements.
# (if the field is a scalar, it's considered a single element and the diff just tests equality)
my @old = ref $old->{$field} eq 'ARRAY' ? $old->{$field}->@* : ($old->{$field});
my @new = ref $new->{$field} eq 'ARRAY' ? $new->{$field}->@* : ($new->{$field});
+ @old = map $opt{txt}->(), @old if $opt{txt};
+ @new = map $opt{txt}->(), @new if $opt{txt};
+
my $JS = JSON::XS->new->utf8->canonical->allow_nonref;
my $l = sdiff \@old, \@new, sub { _stringify_scalars_rec($_[0]); $JS->encode($_[0]) };
return if !grep $_->[0] ne 'u', @$l;
@@ -568,42 +722,44 @@ sub _revision_diff_ {
# Do a word-based diff if this is a large chunk of text, otherwise character-based.
my $split = length $item->[1] > 1024 ? qr/([ ,\n]+)/ : qr//;
- $item->[1] = [map encode_utf8($_), split $split, $item->[1]];
- $item->[2] = [map encode_utf8($_), split $split, $item->[2]];
+ $item->[1] = [map { utf8::encode($_); $_ } split $split, $item->[1]];
+ $item->[2] = [map { utf8::encode($_); $_ } split $split, $item->[2]];
$item->[3] = compact_diff $item->[1], $item->[2];
}
tr_ sub {
td_ $name;
- _revision_fmtcol_ \%opt, 1, $l;
- _revision_fmtcol_ \%opt, 2, $l;
+ _revision_fmtcol_ \%opt, 1, $l, $old;
+ _revision_fmtcol_ \%opt, 2, $l, $new;
}
}
sub _revision_cmp_ {
- my($type, $old, $new, @fields) = @_;
+ my($old, $new, @fields) = @_;
+
+ local $old->{_entry_state} = ($old->{hidden}?2:0) + ($old->{locked}?1:0);
+ local $new->{_entry_state} = ($new->{hidden}?2:0) + ($new->{locked}?1:0);
table_ class => 'stripe', sub {
thead_ sub {
tr_ sub {
td_ ' ';
- td_ sub { _revision_header_ $type, $old };
- td_ sub { _revision_header_ $type, $new };
+ td_ sub { _revision_header_ $old };
+ td_ sub { _revision_header_ $new };
};
tr_ sub {
td_ ' ';
td_ colspan => 2, sub {
- b_ "Edit summary for revision $new->{chrev}";
+ strong_ "Edit summary for revision $new->{chrev}";
br_;
br_;
- lit_ bb2html $new->{rev_comments}||'-';
+ lit_ bb_format $new->{rev_comments}||'-';
};
};
};
- _revision_diff_ $type, $old, $new, @$_ for(
- [ hidden => 'Hidden', fmt => 'bool' ],
- [ locked => 'Locked', fmt => 'bool' ],
+ _revision_diff_ $old, $new, @$_ for(
+ [ _entry_state => 'State', fmt => {0 => 'Normal', 1 => 'Locked', 2 => 'Awaiting approval', 3 => 'Deleted'} ],
@fields,
);
};
@@ -612,7 +768,7 @@ sub _revision_cmp_ {
# Revision info box.
#
-# Arguments: $type, $object, \&enrich_for_diff, @fields
+# Arguments: $object, \&enrich_for_diff, @fields
#
# The given $object is assumed to originate from VNWeb::DB::db_entry() and
# should have the 'id', 'hidden', 'locked', 'chrev' and 'maxrev' fields in
@@ -633,37 +789,53 @@ sub _revision_cmp_ {
# If not given, the field is rendered as plain text and changes are highlighted with a diff.
# \%HASH -> Look the field up in the hash table (values should be string or {txt=>string}.
# sub($value) {$_} -> Custom formatting function, should output TUWF::XML data HTML.
+# txt => sub{$_} - Text formatting function for individual values.
+# Alternative to 'fmt' above; the returned value is treated as a text field with diffing support.
# join => sub{} - HTML to join multi-value fields, defaults to \&br_.
# empty => str - What value should be considered "empty", e.g. (empty => 0) for integer fields.
# undef or empty string are always considered empty values.
sub revision_ {
- my($type, $new, $enrich, @fields) = @_;
+ my($new, $enrich, @fields) = @_;
- my $old = $new->{chrev} == 1 ? undef : db_entry $type, $new->{id}, $new->{chrev} - 1;
+ my $old = $new->{chrev} == 1 ? undef : db_entry $new->{id}, $new->{chrev} - 1;
$enrich->($old) if $old;
+ if(auth->permDbmod) {
+ my $f = tuwf->validate(get =>
+ patrolled => { default => 0, uint => 1 },
+ unpatrolled => { default => 0, uint => 1 },
+ )->data;
+ tuwf->dbExeci('INSERT INTO changes_patrolled', {id => $f->{patrolled}, uid => auth->uid}, 'ON CONFLICT (id,uid) DO NOTHING') if $f->{patrolled};
+ tuwf->dbExeci('DELETE FROM changes_patrolled WHERE', {id => $f->{unpatrolled}, uid => auth->uid}) if $f->{unpatrolled};
+ }
+
enrich_merge chid => sql(
- 'SELECT c.id AS chid, c.comments as rev_comments,', sql_totime('c.added'), 'as rev_added, ', sql_user('u', 'rev_user_'), '
+ 'SELECT c.id AS chid, c.comments as rev_comments,', sql_totime('c.added'), 'as rev_added, ', sql_user('u', 'rev_user_'), ', u.perm_dbmod AS rev_dbmod
FROM changes c LEFT JOIN users u ON u.id = c.requester
WHERE c.id IN'),
$new, $old||();
- div_ class => 'mainbox revision', sub {
+ enrich rev_patrolled => chid => id =>
+ sql('SELECT c.id,', sql_user(), 'FROM changes_patrolled c JOIN users u ON u.id = c.uid WHERE c.id IN'),
+ $new, $old||()
+ if auth->permDbmod;
+
+ article_ class => 'revision', sub {
h1_ "Revision $new->{chrev}";
- a_ class => 'prev', href => sprintf('/%s%d.%d', $type, $new->{id}, $new->{chrev}-1), '<- earlier revision' if $new->{chrev} > 1;
- a_ class => 'next', href => sprintf('/%s%d.%d', $type, $new->{id}, $new->{chrev}+1), 'later revision ->' if $new->{chrev} < $new->{maxrev};
- p_ class => 'center', sub { a_ href => "/$type$new->{id}", $type.$new->{id} };
+ a_ class => 'prev', href => sprintf('/%s.%d', $new->{id}, $new->{chrev}-1), '<- earlier revision' if $new->{chrev} > 1;
+ a_ class => 'next', href => sprintf('/%s.%d', $new->{id}, $new->{chrev}+1), 'later revision ->' if $new->{chrev} < $new->{maxrev};
+ p_ class => 'center', sub { a_ href => "/$new->{id}", $new->{id} };
div_ class => 'rev', sub {
- _revision_header_ $type, $new;
+ _revision_header_ $new;
br_;
- b_ 'Edit summary';
+ strong_ 'Edit summary';
br_; br_;
- lit_ bb2html $new->{rev_comments}||'-';
+ lit_ bb_format $new->{rev_comments}||'-';
} if !$old;
- _revision_cmp_ $type, $old, $new, @fields if $old;
+ _revision_cmp_ $old, $new, @fields if $old;
};
}
@@ -674,150 +846,190 @@ sub revision_ {
# current page number (1..n),
# nextpage (0/1 or, if the full count is known: [$total, $perpage]),
# alignment (t/b)
-# func
+# tableopts obj
sub paginate_ {
- my($url, $p, $np, $al, $fun) = @_;
+ my($url, $p, $np, $al, $tbl) = @_;
my($cnt, $pp) = ref($np) ? @$np : ($p+$np, 1);
- return if !$fun && $p == 1 && $cnt <= $pp;
+ return if !$tbl && $p == 1 && $cnt <= $pp;
my sub tab_ {
my($page, $label) = @_;
li_ sub {
local $_ = $page;
my $u = $url->(p => $page);
- a_ href => $u, $label;
+ a_ href => $u,
+ class => $page == $p ? 'highlightselected' : undef,
+ rel => $label && $label =~ /next/ ? 'next' : $label && $label =~ /prev/ ? 'prev' : undef,
+ $label//$page;
}
}
my sub ell_ {
- my($left) = @_;
- li_ mkclass(ellipsis => 1, left => $left), sub { b_ '⋯' };
+ li_ mkclass(ellipsis => 1), '⋯';
}
- my $nc = 5; # max. number of buttons on each side
-
- div_ class => 'maintabs browsetabs '.($al eq 't' ? '' : 'bottom'), sub {
- ul_ sub {
- $p > 2 and ref $np and tab_ 1, '« first';
- $p > $nc+1 and ref $np and ell_;
- $p > $_ and ref $np and tab_ $p-$_, $p-$_ for (reverse 2..($nc>$p-2?$p-2:$nc-1));
- $p > 1 and tab_ $p-1, '‹ previous';
- };
-
- $fun->() if $fun;
- ul_ sub {
- my $l = ceil($cnt/$pp)-$p+1;
- $l > 1 and tab_ $p+1, 'next ›';
- $l > $_ and tab_ $p+$_, $p+$_ for (2..($nc>$l-2?$l-2:$nc-1));
- $l > $nc+1 and ell_;
- $l > 2 and tab_ $l+$p-1, 'last »';
+ nav_ class => $al eq 't' ? undef : 'bottom', sub {
+ my $n = ceil($cnt/$pp);
+ my $l = $n-$p+1;
+ menu_ class => 'browsetabs', sub {
+ $p > 1 and tab_ $p-1, '‹ previous';
+ if(ref $np) {
+ $p > 3 and tab_ 1;
+ $p > 4 and ell_;
+ $_ > 0 and $_ <= $n and tab_ $_ for ($p-2..$p+2);
+ $l > 4 and ell_;
+ $l > 3 and tab_ $n;
+ }
+ $l > 1 and tab_ $p+1, 'next ›';
};
+
+ $tbl->widget_($url) if $tbl;
}
}
# Generate sort buttons for a table header. This function assumes that sorting
-# options are given as query parameters: 's' for the $column_name to sort on
-# and 'o' for order ('a'sc/'d'esc).
+# options are given either as a TableOpts parameter in 's' or as two query
+# parameters: 's' for the $column_name to sort on and 'o' for order ('a'/'d').
# Options: $column_title, $column_name, $opt, $url
# Where $url is a function that is given ('p', undef, 's', $column_name, 'o', $order) and returns a URL.
sub sortable_ {
- my($name, $opt, $url) = @_;
- $opt->{s} eq $name && $opt->{o} eq 'a' ? txt_ ' ▴' : a_ href => $url->(p => undef, s => $name, o => 'a'), ' ▴';
- $opt->{s} eq $name && $opt->{o} eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $name, o => 'd'), '▾';
+ my($name, $opt, $url, $space) = @_;
+ txt_ ' ' if $space || !defined $space;
+ if(ref $opt->{s}) {
+ my $o = $opt->{s}->sorted($name);
+ $o eq 'a' ? txt_ '▴' : a_ href => $url->(p => undef, s => $opt->{s}->sort_param($name, 'a')), '▴';
+ $o eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $opt->{s}->sort_param($name, 'd')), '▾';
+ } else {
+ $opt->{s} eq $name && $opt->{o} eq 'a' ? txt_ '▴' : a_ href => $url->(p => undef, s => $name, o => 'a'), '▴';
+ $opt->{s} eq $name && $opt->{o} eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $name, o => 'd'), '▾';
+ }
}
sub searchbox_ {
- my($sel, $value) = @_;
+ my($sel, $q) = @_;
+ tuwf->req->{js}{basic} = 1;
+
+ # Only fetch counts for queries that can use the trigram index
+ # (This length requirement is not ideal for Kanji, but pg_trgm doesn't
+ # discriminate between scripts)
+ my %counts = $q && (grep length($_)>=3, $q->words->@*) ?
+ map +($_->{type}, $_->{cnt}), tuwf->dbAlli('
+ SELECT vndbid_type(id) AS type, count(*) AS cnt
+ FROM (
+ SELECT DISTINCT id
+ FROM search_cache sc
+ WHERE', sql_and($q->where()), "
+ AND NOT (id BETWEEN '${sel}1' AND vndbid_max('$sel'))
+ ) x
+ GROUP BY vndbid_type(id)
+ ")->@* : ();
+
+ my sub lnk_ {
+ my($type, $label) = @_;
+ a_ href => "/$type", $sel eq $type ? (class => 'sel') : (), sub {
+ txt_ $label;
+ sup_ class => 'standout', $counts{$type} if $counts{$type};
+ };
+ }
+
fieldset_ class => 'search', sub {
p_ id => 'searchtabs', sub {
- a_ href => '/v/all', $sel eq 'v' ? (class => 'sel') : (), 'Visual novels';
- a_ href => '/r', $sel eq 'r' ? (class => 'sel') : (), 'Releases';
- a_ href => '/p/all', $sel eq 'p' ? (class => 'sel') : (), 'Producers';
- a_ href => '/s/all', $sel eq 's' ? (class => 'sel') : (), 'Staff';
- a_ href => '/c/all', $sel eq 'c' ? (class => 'sel') : (), 'Characters';
- a_ href => '/g', $sel eq 'g' ? (class => 'sel') : (), 'Tags';
- a_ href => '/i', $sel eq 'i' ? (class => 'sel') : (), 'Traits';
- a_ href => '/u/all', $sel eq 'u' ? (class => 'sel') : (), 'Users';
+ lnk_ v => 'Visual novels';
+ lnk_ r => 'Releases';
+ lnk_ p => 'Producers';
+ lnk_ s => 'Staff';
+ lnk_ c => 'Characters';
+ lnk_ g => 'Tags';
+ lnk_ i => 'Traits';
};
- input_ type => 'text', name => 'q', id => 'q', class => 'text', value => $value;
- input_ type => 'submit', class => 'submit', value => 'Search!';
+ input_ type => 'text', name => 'q', id => 'q', class => 'text', value => "$q";
+ input_ type => 'submit', class => 'submit', name => 'sb', value => 'Search!';
};
}
-# Generate a message to display on an entry page when the entry has been locked or the user can't edit it.
+# Generate a message to display on an entry page to report the entry and to indicate it has been locked or the user can't edit it.
sub itemmsg_ {
- my($type, $obj) = @_;
- if($obj->{entry_locked}) {
- p_ class => 'locked', 'Locked for editing';
- } elsif(auth && !can_edit $type => $obj) {
- p_ class => 'locked', 'You can not edit this page';
- }
+ my($obj) = @_;
+ p_ class => 'itemmsg', sub {
+ if($obj->{id} !~ /^[dwu]/) {
+ if($obj->{entry_locked} && !$obj->{entry_hidden}) {
+ txt_ 'Locked for editing. ';
+ } elsif(auth && !can_edit(($obj->{id} =~ /^(.)/), $obj)) {
+ txt_ 'You can not edit this page. ';
+ }
+ }
+ a_ href => "/report/$obj->{id}", $obj->{id} =~ /^u/ ? 'report user' : 'Report an issue on this page.';
+ } if !config->{read_only};
}
-# Generate the initial mainbox when adding or editing a database entry, with a
+# Generate the initial box when adding or editing a database entry, with a
# friendly message pointing to the guidelines and stuff.
# Args: $type ('v','r', etc), $obj (from db_entry(), or undef for new page), $page_title, $is_this_a_copy?
sub editmsg_ {
- my($type, $obj, $title, $copy) = @_;
- my $typename = {v => 'visual novel', r => 'release', p => 'producer', c => 'character', s => 'person'}->{$type};
- my $guidelines = {v => 2, r => 3, p => 4, c => 12, s => 16 }->{$type};
- croak "Unknown type: $type" if !$typename;
-
- div_ class => 'mainbox', sub {
- h1_ sub {
- txt_ $title;
- debug_ $obj if $obj;
- };
- if($copy) {
- div_ class => 'warning', sub {
- h2_ "You're not editing an entry!";
- p_ sub {;
- txt_ "You're about to insert a new entry into the database with information based on ";
- a_ href => "/$type$obj->{id}", "$type$obj->{id}";
- txt_ '.';
- br_;
- txt_ "Hit the 'edit' tab on the right-top if you intended to edit the entry instead of creating a new one.";
- }
- }
- }
- # 'lastrev' is for compatibility with VNDB::*
- if($obj && ($obj->{maxrev} ? $obj->{maxrev} != $obj->{chrev} : !$obj->{lastrev})) {
- div_ class => 'warning', sub {
- h2_ 'Reverting';
- p_ "You are editing an old revision of this $typename. If you save it, all changes made after this revision will be reverted!";
- }
- }
- div_ class => 'notice', sub {
- h2_ 'Before editing:';
- ul_ sub {
- li_ sub {
- txt_ 'Read the ';
- a_ href=> "/d$guidelines", 'guidelines';
- txt_ '!';
- };
- if($obj) {
- li_ sub {
- txt_ 'Check for any existing discussions on the ';
- a_ href => '/t/'._board_id($type, $obj), 'discussion board';
- };
- # TODO: Include a list of the most recent edits in this page.
- li_ sub {
- txt_ 'Browse the ';
- a_ href => "/$type$obj->{id}/hist", 'edit history';
- txt_ ' for any recent changes related to what you want to change.';
- };
- } elsif($type ne 'r') {
- li_ sub {
- a_ href => "/$type/all", 'Search the database';
- txt_ " to see if we already have information about this $typename.";
- }
- }
- }
- };
- }
+ my($type, $obj, $title, $copy) = @_;
+ my $typename = {v => 'visual novel', r => 'release', p => 'producer', c => 'character', s => 'person'}->{$type};
+ my $guidelines = {v => 2, r => 3, p => 4, c => 12, s => 16 }->{$type};
+ croak "Unknown type: $type" if !$typename;
+
+ article_ sub {
+ h1_ sub {
+ txt_ $title;
+ debug_ $obj if $obj;
+ };
+ if($obj && config->{data_requests}{$obj->{id}}) {
+ div_ class => 'warning', sub {
+ h2_ '## DATA REMOVAL/CHANGE REQUEST ##';
+ br_;
+ p_ sub { lit_ config->{data_requests}{$obj->{id}} };
+ br_;
+ h2_ '## DATA REMOVAL/CHANGE REQUEST ##';
+ };
+ }
+ if($copy) {
+ div_ class => 'warning', sub {
+ h2_ "You're not editing an entry!";
+ p_ sub {;
+ txt_ "You're about to insert a new entry into the database with information based on ";
+ a_ href => "/$obj->{id}", $obj->{id};
+ txt_ '.';
+ br_;
+ txt_ "Hit the 'edit' tab on the right-top if you intended to edit the entry instead of creating a new one.";
+ }
+ }
+ }
+ if($obj && $obj->{maxrev} != $obj->{chrev}) {
+ div_ class => 'warning', sub {
+ h2_ 'Reverting';
+ p_ "You are editing an old revision of this $typename. If you save it, all changes made after this revision will be reverted!";
+ }
+ }
+ div_ class => 'notice', sub {
+ h2_ 'Before editing:';
+ ul_ sub {
+ li_ sub {
+ txt_ 'Read the ';
+ a_ href=> "/d$guidelines", 'guidelines';
+ txt_ '!';
+ };
+ if($obj) {
+ li_ sub {
+ txt_ 'Check for any existing discussions on the ';
+ a_ href => '/t/'._board_id($obj), 'discussion board';
+ };
+ } elsif($type ne 'r') {
+ li_ sub {
+ a_ href => "/$type/all", 'Search the database';
+ txt_ " to see if we already have information about this $typename.";
+ }
+ }
+ li_ 'Fields marked with (*) may cause other fields to become (un)available depending on the selection.' if $type eq 'r';
+ }
+ };
+ };
+ VNWeb::Misc::History::tablebox_($obj->{id}, {p=>1}, results => 10, nopage => 1) if $obj && !$copy;
}
1;
diff --git a/lib/VNWeb/Images/Lib.pm b/lib/VNWeb/Images/Lib.pm
new file mode 100644
index 00000000..0170d37e
--- /dev/null
+++ b/lib/VNWeb/Images/Lib.pm
@@ -0,0 +1,166 @@
+package VNWeb::Images::Lib;
+
+use VNWeb::Prelude;
+use Exporter 'import';
+
+our @EXPORT = qw/enrich_image validate_token image_flagging_display image_hidden image_ enrich_image_obj/;
+
+
+my @SEX = qw/Safe Suggestive Explicit/;
+my @VIO = qw/Tame Violent Brutal /;
+
+# Enrich images so that they match the format expected by the 'ImageResult' Elm
+# API response.
+#
+# Also adds signed tokens to the image list - indicating that the current user
+# is permitted to vote on these images. These tokens ensure that non-moderators
+# can only vote on images that they have been randomly assigned, thus
+# preventing possible abuse when a single person uses multiple accounts to
+# influence the rating of a single image.
+sub enrich_image {
+ my($canvote, $l) = @_;
+ enrich_merge id => sub { sql q{
+ SELECT i.id, i.width, i.height, i.c_votecount AS votecount
+ , i.c_sexual_avg::real/100 AS sexual_avg, i.c_sexual_stddev::real/100 AS sexual_stddev
+ , i.c_violence_avg::real/100 AS violence_avg, i.c_violence_stddev::real/100 AS violence_stddev
+ , iv.sexual AS my_sexual, iv.violence AS my_violence
+ , COALESCE(EXISTS(SELECT 1 FROM image_votes iv0 WHERE iv0.id = i.id AND iv0.ignore) AND NOT iv.ignore, FALSE) AS my_overrule
+ , COALESCE(v.id, c.id, vsv.id) AS entry_id
+ , COALESCE(v.title[1+1], c.title[1+1], vsv.title[1+1]) AS entry_title
+ FROM images i
+ LEFT JOIN image_votes iv ON iv.id = i.id AND iv.uid =}, \auth->uid, q{
+ LEFT JOIN}, vnt, q{v ON i.id BETWEEN 'cv1' AND vndbid_max('cv') AND v.image = i.id
+ LEFT JOIN}, charst, q{c ON i.id BETWEEN 'ch1' AND vndbid_max('ch') AND c.image = i.id
+ LEFT JOIN vn_screenshots vs ON i.id BETWEEN 'sf1' AND vndbid_max('sf') AND vs.scr = i.id
+ LEFT JOIN}, vnt, q{vsv ON i.id BETWEEN 'sf1' AND vndbid_max('sf') AND vsv.id = vs.id
+ WHERE i.id IN}, $_
+ }, $l;
+
+ enrich votes => id => id => sub { sql '
+ SELECT iv.id, iv.uid, iv.sexual, iv.violence, iv.ignore OR (u.id IS NOT NULL AND NOT u.perm_imgvote) AS ignore, ', sql_user(), '
+ FROM image_votes iv
+ LEFT JOIN users u ON u.id = iv.uid
+ WHERE iv.id IN', $_,
+ auth ? ('AND (iv.uid IS NULL OR iv.uid <> ', \auth->uid, ')') : (), '
+ ORDER BY u.username'
+ }, $l;
+
+ for(grep defined $_->{width}, @$l) {
+ $_->{entry} = $_->{entry_id} ? { id => $_->{entry_id}, title => $_->{entry_title} } : undef;
+ delete $_->{entry_id};
+ delete $_->{entry_title};
+ for my $v ($_->{votes}->@*) {
+ $v->{user} = xml_string sub { user_ $v }; # Easier than duplicating user_() in Elm
+ delete $v->{$_} for grep /^user_/, keys %$v;
+ }
+ $_->{token} = ($_->{votecount} == 0 && auth->permImgvote) || (ref $canvote eq 'CODE' ? $canvote->($_) : $canvote) ? auth->csrftoken(0, "imgvote-$_->{id}") : undef;
+ }
+}
+
+# Validates the token generated by enrich_image;
+sub validate_token {
+ my($l) = @_;
+ my $ok = 1;
+ $ok &&= $_->{token} && auth->csrfcheck($_->{token}, "imgvote-$_->{id}") for @$l;
+ $ok;
+}
+
+
+# Returns a string like 'Not flagged' or 'Safe / Tame (5)'
+sub image_flagging_display {
+ my($img, $small) = @_;
+ !$img->{votecount} ? 'Not flagged' :
+ $small ? sprintf '%s / %s', $SEX[$img->{sexual}], $VIO[$img->{violence}]
+ : sprintf '%s / %s (%d)', $SEX[$img->{sexual}], $VIO[$img->{violence}], $img->{votecount}
+}
+
+
+# Returns whether the image is hidden according to the user's preferences.
+# Return values:
+# 0 -> visible
+# 4 -> hidden for some reason
+# 5 -> hidden because of sexual flag
+# 6 -> hidden because of violence flag
+# 7 -> hidden because both
+sub image_hidden {
+ my($img) = @_;
+ my($sex,$vio) = $img->@{'sexual', 'violence'};
+ my $sexp = auth->pref('max_sexual')||0;
+ my $viop = auth->pref('max_violence')||0;
+ my $sexh = $sex > $sexp && $sexp >= 0 if $img->{votecount};
+ my $vioh = $vio > $viop if $img->{votecount};
+ my $hidden = $sexp < 0 || $sexh || $vioh || (!$img->{votecount} && ($sexp < 2 || $viop < 2));
+ $hidden ? 4 + ($sexh?1:0)+($vioh?2:0) : 0;
+}
+
+
+# Display (or not) an image with preference toggle and hover-information.
+# Given $img is assumed to be an object generated by enrich_image_obj().
+# %opt:
+# alt -> alt text
+# width -> if different from original image
+# height -> if different from original image
+# url -> link the image to a page (if not hidden by settings)
+# overlay -> CODE ref, html to replace the overlay with.
+# XXX: Not all of these options are used, could clean up a few.
+sub image_ {
+ my($img, %opt) = @_;
+ return p_ 'No image' if !$img;
+
+ my($sex,$vio) = $img->@{'sexual', 'violence'};
+ my($w,$h) = $opt{width} ? @opt{'width','height'} : @{$img}{'width', 'height'};
+ my $hidden = image_hidden $img;
+ my $hide_on_click = $opt{url} ? $hidden : $sex || $vio || !$img->{votecount} || (auth->pref('max_sexual')||0) < 0;
+ my $small = $w*$h < 20000;
+
+ label_ class => 'imghover', style => "width: ${w}px; height: ${h}px", sub {
+ input_ type => 'checkbox', class => 'hidden', $hidden ? () : (checked => 'checked') if $hide_on_click;
+ div_ class => 'imghover--visible', sub {
+ a_ href => $opt{url} if $opt{url};
+ img_ src => imgurl($img->{id}), width => $w, height => $h, $opt{alt} ? (alt => $opt{alt}) : ();
+ end_ if $opt{url};
+ if(!exists $opt{overlay}) {
+ a_ class => 'imghover--overlay', href => "/$img->{id}?view=".viewset(show_nsfw=>1), image_flagging_display $img, $small if auth;
+ span_ class => 'imghover--overlay', image_flagging_display $img, $small if !auth;
+ } elsif(ref $opt{overlay} eq 'CODE') {
+ $opt{overlay}->();
+ }
+ };
+ div_ class => 'imghover--warning', sub {
+ if($img->{votecount}) {
+ if(!$small) {
+ txt_ 'This image has been flagged as:';
+ br_; br_;
+ }
+ txt_ 'Sexual: '; $hidden & 1 ? b_ $SEX[$sex] : txt_ $SEX[$sex];
+ br_;
+ txt_ 'Violence: '; $hidden & 2 ? b_ $VIO[$vio] : txt_ $VIO[$vio];
+ } else {
+ txt_ 'This image has not yet been flagged';
+ }
+ if(!$small) {
+ br_; br_;
+ span_ class => 'fake_link', 'Show me anyway';
+ br_; br_;
+ small_ 'This warning can be disabled in your account';
+ }
+ } if $hide_on_click;
+ }
+}
+
+
+sub enrich_image_obj {
+ my $field = shift;
+ enrich_obj $field => id => 'SELECT id, width, height, c_votecount AS votecount, c_sexual_avg::real/100 AS sexual_avg, c_violence_avg::real/100 AS violence_avg FROM images WHERE id IN', @_;
+
+ # Also add our final verdict. Still no clue why I chose these thresholds, but they seem to work.
+ for (map +(ref $_ eq 'ARRAY' ? @$_ : $_), @_) {
+ local $_ = $_->{$field};
+ if(ref $_) {
+ $_->{sexual} = !$_->{votecount} ? 2 : $_->{sexual_avg} > 1.3 ? 2 : $_->{sexual_avg} > 0.4 ? 1 : 0;
+ $_->{violence} = !$_->{votecount} ? 2 : $_->{violence_avg} > 1.3 ? 2 : $_->{violence_avg} > 0.4 ? 1 : 0;
+ }
+ }
+}
+
+1;
diff --git a/lib/VNWeb/Images/List.pm b/lib/VNWeb/Images/List.pm
new file mode 100644
index 00000000..28713316
--- /dev/null
+++ b/lib/VNWeb/Images/List.pm
@@ -0,0 +1,209 @@
+package VNWeb::Images::List;
+
+use VNWeb::Prelude;
+
+
+sub graph_ {
+ my($i, $opt) = @_;
+ my($gw, $go) = (150, 40); # histogram width, x offset
+
+ sub clamp { $_[0] > $_[2] ? $_[0] : $_[1] < $_[2] ? $_[1] : $_[2] }
+
+ my $y;
+ my sub line_ {
+ my($lbl, $left, $mid, $right) = @_;
+ tag_ 'text', x => 0, y => $y+9, $lbl;
+ tag_ 'line', class => 'errorbar', x1 => $go+clamp(0, $gw, $left*$gw/2), y1 => $y+5, x2 => $go+clamp(0, $gw, $right*$gw/2), y2 => $y+5, undef;
+ tag_ 'rect', width => 5, height => 10, x => $go+clamp(0, $gw-5, $mid*$gw/2-2), y => $y, undef;
+ $y += 12;
+ }
+
+ my sub subgraph_ {
+ my($left, $right, $avg, $stddev, $my, $user) = @_;
+ tag_ 'text', x => $go-2, y => 10, $left;
+ tag_ 'text', x => $go+$gw, y => 10, 'text-anchor' => 'end', $right;
+ tag_ 'line', class => 'ruler', x1 => $go, y1 => 12, x2 => $go, y2 => 46, undef;
+ tag_ 'line', class => 'ruler', x1 => $go+$gw/2, y1 => 12, x2 => $go+$gw/2, y2 => 46, undef;
+ tag_ 'line', class => 'ruler', x1 => $go+$gw-2, y1 => 12, x2 => $go+$gw-2, y2 => 46, undef;
+
+ $y = 13;
+ line_ 'Avg', $avg-$stddev, $avg, $avg+$stddev if defined $avg;
+ line_ 'User', $user, $user, $avg if defined $user;
+ line_ 'My', $my, $my, $avg if defined $my && $opt->{u} ne $opt->{u2};
+ }
+
+ tag_ 'svg', width => '190px', height => '100px', viewBox => '0 0 190 100', sub {
+ tag_ 'g', sub {
+ subgraph_ 'Safe', 'Explicit', $i->{sexual_avg}, $i->{sexual_stddev}, $i->{my_sexual}, $i->{user_sexual}
+ };
+ tag_ 'g', transform => 'translate(0,51)', sub {
+ subgraph_ 'Tame', 'Brutal', $i->{violence_avg}, $i->{violence_stddev}, $i->{my_violence}, $i->{user_violence}
+ };
+ };
+}
+
+
+sub listing_ {
+ my($lst, $np, $opt, $url) = @_;
+
+ my $view = viewset(show_nsfw => 1);
+ paginate_ $url, $opt->{p}, $np, 't';
+ article_ class => 'imagebrowse', sub {
+ div_ class => 'imagecard', sub {
+ a_ href => "/$_->{id}?view=$view", style => 'background-image: url('.imgurl($_->{id}, $_->{id} =~ /^sf/ ? 't' : '').')', '';
+ div_ sub {
+ a_ href => "/$_->{id}?view=$view", $_->{id};
+ txt_ sprintf ' / %d', $_->{c_votecount},;
+ small_ sprintf ' / w%d', $_->{c_weight};
+ br_;
+ graph_ $_, $opt;
+ };
+ } for @$lst;
+ };
+ paginate_ $url, $opt->{p}, $np, 'b';
+}
+
+
+sub opts_ {
+ my($opt, $u) = @_;
+
+ my sub opt_ {
+ my($type, $key, $val, $label, $checked) = @_;
+ input_ type => $type, name => $key, id => "form_${key}{$val}", value => $val,
+ $checked // $opt->{$key} eq $val ? (checked => 'checked') : ();
+ label_ for => "form_${key}{$val}", $label;
+ };
+
+ form_ sub {
+ input_ type => 'hidden', class => 'hidden', name => 'u', value => $opt->{u} if $opt->{u};
+ input_ type => 'hidden', class => 'hidden', name => 'u2', value => $opt->{u2} if $opt->{u2} ne (auth->uid||'');
+ input_ type => 'hidden', class => 'hidden', name => 'view', value => viewset(show_nsfw => viewget('show_nsfw'));
+ table_ style => 'margin: auto', sub {
+ tr_ sub {
+ td_ 'User:';
+ td_ sub { user_ $u };
+ } if $u;
+ tr_ sub {
+ td_ 'Image types:';
+ td_ class => 'linkradio', sub {
+ opt_ checkbox => t => 'ch', 'Character images', $opt->{t}->@* == 0 || in ch => $opt->{t}; em_ ' / ';
+ opt_ checkbox => t => 'cv', 'VN images', $opt->{t}->@* == 0 || in cv => $opt->{t}; em_ ' / ';
+ opt_ checkbox => t => 'sf', 'Screenshots', $opt->{t}->@* == 0 || in sf => $opt->{t};
+ };
+ };
+ tr_ sub {
+ td_ 'Minimum votes:';
+ td_ class => 'linkradio', sub { join_ sub { em_ ' / ' }, sub { opt_ radio => m => $_, $_ }, 0..10 };
+ };
+ tr_ sub {
+ td_ '';
+ td_ class => 'linkradio', sub { opt_ checkbox => my => 1, 'Only images I voted on' };
+ } if auth && $opt->{u} ne $opt->{u2};
+ tr_ sub {
+ td_ '';
+ td_ class => 'linkradio', sub { opt_ checkbox => up => 1, 'Only images uploaded by this user' };
+ } if $opt->{u};
+ tr_ sub {
+ td_ 'Time filter';
+ td_ class => 'linkradio', sub {
+ opt_ radio => d => 1, 'Last 24h'; em_ ' / ';
+ opt_ radio => d => 7, 'Last 7d'; em_ ' / ';
+ opt_ radio => d => 30, 'Last 30d'; em_ ' / ';
+ opt_ radio => d => 0, 'Any time';
+ }
+ } if $opt->{u};
+ tr_ sub {
+ td_ 'Order by:';
+ td_ class => 'linkradio', sub {
+ if($u) {
+ opt_ radio => s => 'date', 'Recent'; em_ ' / ';
+ opt_ radio => s => 'diff', 'Vote difference'; em_ ' / ';
+ }
+ opt_ radio => s => 'weight', 'Weight'; em_ ' / ';
+ opt_ radio => s => 'sdev', 'Sexual stddev'; em_ ' / ';
+ opt_ radio => s => 'vdev', 'Violence stddev';
+ }
+ };
+ tr_ sub {
+ td_ '';
+ td_ sub { input_ type => 'submit', class => 'submit', value => 'Update' };
+ }
+ }
+ }
+}
+
+
+TUWF::get qr{/img/list}, sub {
+ # TODO filters: sexual / violence?
+ my $opt = tuwf->validate(get =>
+ s => { onerror => 'date', enum => [qw/ weight sdev vdev date diff/] },
+ t => { onerror => [], scalar => 1, type => 'array', values => { enum => [qw/ ch cv sf /] } },
+ m => { onerror => 0, range => [0,10] },
+ d => { onerror => 0, range => [0,10000] },
+ u => { onerror => '', vndbid => 'u' },
+ u2 => { onerror => '', vndbid => 'u' }, # Hidden option, allows comparing two users by overriding the 'My' user.
+ my => { anybool => 1 },
+ up => { anybool => 1 },
+ p => { page => 1 },
+ )->data;
+
+ $opt->{u2} ||= auth->uid || '';
+ $opt->{s} = 'weight' if !$opt->{u} && ($opt->{s} eq 'date' || $opt->{s} eq 'diff');
+ $opt->{t} = [ List::Util::uniq sort $opt->{t}->@* ];
+ $opt->{t} = [] if $opt->{t}->@* == 3;
+ $opt->{d} = 0 if !$opt->{u};
+
+ my $u = $opt->{u} && tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \$opt->{u});
+ return tuwf->resNotFound if $opt->{u} && (!$u->{id} || (!defined $u->{user_name} && !auth->isMod));
+
+ my $where = sql_and
+ $opt->{t}->@* ? sql_or(map sql('i.id BETWEEN vndbid(',\"$_",',1) AND vndbid_max(',\"$_",')'), $opt->{t}->@*) : (),
+ $opt->{m} ? sql('i.c_votecount >=', \$opt->{m}) : (),
+ $opt->{d} ? sql('iu.date > NOW()-', \"$opt->{d} days", '::interval') : (),
+ $opt->{up} && $opt->{u} ? sql('i.uploader =', \$opt->{u}) : ();
+
+ my($lst, $np) = tuwf->dbPagei({ results => 100, page => $opt->{p} }, '
+ SELECT i.id, i.width, i.height, i.c_votecount, i.c_weight
+ , i.c_sexual_avg::real/100 AS sexual_avg, i.c_sexual_stddev::real/100 AS sexual_stddev
+ , i.c_violence_avg::real/100 AS violence_avg, i.c_violence_stddev::real/100 AS violence_stddev
+ , iv.sexual as my_sexual, iv.violence as my_violence',
+ $opt->{u} ? ', iu.sexual as user_sexual, iu.violence as user_violence' : (), '
+ FROM images i',
+ $opt->{u} ? ('JOIN image_votes iu ON iu.uid =', \$opt->{u}, ' AND iu.id = i.id') : (),
+ $opt->{my} ? () : 'LEFT', 'JOIN image_votes iv ON iv.uid =', \($opt->{u2}||undef), ' AND iv.id = i.id
+ WHERE', $where, '
+ ORDER BY', {
+ weight => 'i.c_weight DESC',
+ sdev => 'i.c_sexual_stddev DESC NULLS LAST',
+ vdev => 'i.c_violence_stddev DESC NULLS LAST',
+ date => 'iu.date DESC',
+ diff => 'abs(iu.sexual*100-i.c_sexual_avg) + abs(iu.violence*100-i.c_violence_avg) DESC',
+ }->{$opt->{s}}, ', i.id'
+ );
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ my $title = $u ? 'Images flagged by '.user_displayname($u) : 'Image browser';
+
+ framework_ title => $title, sub {
+ article_ sub {
+ h1_ $title;
+ opts_ $opt, $u;
+ };
+ my $nsfw = viewget->{show_nsfw};
+ listing_ $lst, $np, $opt, \&url if $nsfw && @$lst;
+ article_ sub {
+ div_ class => 'warning', sub {
+ h2_ 'NSFW Warning';
+ p_ sub {
+ txt_ 'This listing contains images that may contain sexual content or violence. ';
+ a_ href => url(view => viewset show_nsfw => 1), 'I understand, show me.';
+ br_;
+ txt_ '(This warning can be disabled in your profile)';
+ };
+ };
+ } if !$nsfw && @$lst;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Images/Upload.pm b/lib/VNWeb/Images/Upload.pm
new file mode 100644
index 00000000..113ef9c8
--- /dev/null
+++ b/lib/VNWeb/Images/Upload.pm
@@ -0,0 +1,86 @@
+package VNWeb::Misc::ImageUpload;
+
+use VNWeb::Prelude;
+use VNWeb::Images::Lib;
+use AnyEvent::Util;
+
+
+TUWF::post qr{/elm/ImageUpload.json}, sub {
+ # Have to require the samesite cookie here as CSRF protection, because this API can be triggered as a regular HTML form post.
+ return elm_Unauth if !samesite || !(auth->permDbmod || (auth->permEdit && !global_settings->{lockdown_edit}));
+
+ my $type = tuwf->validate(post => type => { enum => [qw/cv ch sf/] })->data;
+ my $imgdata = tuwf->reqUploadRaw('img');
+ my $fmt =
+ $imgdata =~ /^\xff\xd8/ ? 'jpg' :
+ $imgdata =~ /^\x89\x50/ ? 'png' :
+ $imgdata =~ /^RIFF....WEBP/s ? 'webp' :
+ $imgdata =~ /^....ftyp/s ? 'avif' : # Considers every heif file to be AVIF, not entirely correct but works fine.
+ $imgdata =~ /^\xff\x0a/ ? 'jxl' :
+ $imgdata =~ /^\x00\x00\x00\x00\x0CJXL / ? 'jxl' : undef;
+ return elm_ImgFormat if !$fmt;
+
+ my $seq = {qw/sf screenshots_seq cv covers_seq ch charimg_seq/}->{$type}||die;
+ my $id = tuwf->dbVali('INSERT INTO images', {
+ id => sql_func(vndbid => \$type, sql(sql_func(nextval => \$seq), '::int')),
+ uploader => \auth->uid,
+ width => 0,
+ height => 0
+ }, 'RETURNING id');
+
+ my $fno = imgpath($id, 'orig', $fmt);
+ my $fn0 = imgpath($id);
+ my $fn1 = imgpath($id, 't');
+
+ {
+ open my $F, '>', $fno or die $!;
+ print $F $imgdata;
+ }
+
+ my $rc = run_cmd(
+ [
+ config->{imgproc_path},
+ $type eq 'ch' ? (fit => config->{ch_size}->@*, size => jpeg => 1) :
+ $type eq 'cv' ? (fit => config->{cv_size}->@*, size => jpeg => 1) :
+ $type eq 'sf' ? (size => jpeg => 1 => fit => config->{scr_size}->@*, jpeg => 3) : die
+ ],
+ '<', \$imgdata,
+ '>', $fn0,
+ '2>', \my $err,
+ $type eq 'sf' ? ('3>', $fn1) : (),
+ close_all => 1,
+ on_prepare => sub { %ENV = () },
+ )->recv;
+ chomp($err);
+
+ if($rc || !-s $fn0 || $err !~ /^([0-9]+)x([0-9]+)$/) {
+ warn "imgproc: $err\n" if $err;
+ warn "Failed to run imgproc for $id\n";
+ # keep original for troubleshooting
+ rename $fno, config->{var_path}."/tmp/error-${id}.${fmt}";
+ unlink $fn0;
+ unlink $fn1;
+ tuwf->dbRollBack;
+ return elm_ImgFormat;
+ }
+ my($w,$h) = ($1,$2);
+ tuwf->dbExeci('UPDATE images SET', { width => $w, height => $h }, 'WHERE id =', \$id);
+
+ chmod 0666, $fno;
+ chmod 0666, $fn0;
+ chmod 0666, $fn1;
+
+ my $l = [{id => $id}];
+ enrich_image 1, $l;
+ elm_ImageResult $l;
+};
+
+
+elm_api Image => undef, { id => { vndbid => [qw/ch cv sf/] } }, sub {
+ my($data) = @_;
+ my $l = tuwf->dbAlli('SELECT id FROM images WHERE id =', \$data->{id});
+ enrich_image 0, $l;
+ elm_ImageResult $l;
+};
+
+1;
diff --git a/lib/VNWeb/Images/Vote.pm b/lib/VNWeb/Images/Vote.pm
new file mode 100644
index 00000000..48c1fffb
--- /dev/null
+++ b/lib/VNWeb/Images/Vote.pm
@@ -0,0 +1,138 @@
+package VNWeb::Images::Vote;
+
+use VNWeb::Prelude;
+use VNWeb::Images::Lib;
+
+
+my $SEND = form_compile any => {
+ images => $VNWeb::Elm::apis{ImageResult}[0],
+ single => { anybool => 1 },
+ warn => { anybool => 1 },
+ mod => { anybool => 1 },
+ my_votes => { uint => 1 },
+ pWidth => { uint => 1 }, # Set by JS
+ pHeight => { uint => 1 }, # ^
+ nsfw_token => {},
+};
+
+
+sub can_vote { auth->permDbmod || (auth->permImgvote && !global_settings->{lockdown_edit}) }
+
+
+# Fetch a list of images for the user to vote on.
+elm_api Images => $SEND, { excl_voted => { anybool => 1 } }, sub {
+ my($data) = @_;
+ return elm_Unauth if !can_vote;
+
+ state $stats = tuwf->dbRowi('SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE c_weight > 1) AS referenced FROM images');
+
+ # 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
+ # is adjusted so that we get approximately 5000 rows to work with. This is
+ # 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%.
+ #
+ # 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 TABLESAMPLE SYSTEM (', \$tablesample, ')
+ WHERE c_weight > 1',
+ $data->{excl_voted} ? ('AND NOT (c_uids && ARRAY[', \auth->uid, '::vndbid])') : (), '
+ ORDER BY random() ^ (1.0/c_weight) DESC
+ LIMIT', \30
+ );
+ warn sprintf 'Weighted random image sampling query returned %d < 30 rows for %s with a sample fraction of %f', scalar @$l, auth->uid(), $tablesample if @$l < 30;
+ enrich_image 1, $l;
+ elm_ImageResult $l;
+};
+
+
+elm_api ImageVote => undef, {
+ votes => { sort_keys => 'id', aoh => {
+ id => { vndbid => [qw/ch cv sf/] },
+ token => {},
+ sexual => { uint => 1, range => [0,2] },
+ violence => { uint => 1, range => [0,2] },
+ overrule => { anybool => 1 },
+ } },
+}, sub {
+ my($data) = @_;
+ return elm_Unauth if !can_vote;
+ return elm_Unauth if !validate_token $data->{votes};
+
+ # Lock the users table early to prevent deadlock with a concurrent DB edit that attempts to update c_changes.
+ tuwf->dbExeci('SELECT c_imgvotes FROM users WHERE id =', \auth->uid, 'FOR UPDATE');
+
+ # Find out if any of these images are being overruled
+ enrich_merge id => sub { sql 'SELECT id, bool_or(ignore) AS overruled FROM image_votes WHERE id IN', $_, 'GROUP BY id' }, $data->{votes};
+ enrich_merge id => sql('SELECT id, NOT ignore AS my_overrule FROM image_votes WHERE uid =', \auth->uid, 'AND id IN'),
+ grep $_->{overruled}, $data->{votes}->@* if auth->permDbmod;
+
+ for($data->{votes}->@*) {
+ $_->{overrule} = 0 if !auth->permDbmod;
+ my $d = {
+ id => $_->{id},
+ uid => auth->uid(),
+ sexual => $_->{sexual},
+ violence => $_->{violence},
+ ignore => !$_->{overrule} && !$_->{my_overrule} && $_->{overruled} ? 1 : 0,
+ };
+ tuwf->dbExeci('INSERT INTO image_votes', $d, 'ON CONFLICT (id, uid) DO UPDATE SET', $d, ', date = now()');
+ tuwf->dbExeci('UPDATE image_votes SET ignore =', \($_->{overrule}?1:0), 'WHERE uid IS DISTINCT FROM', \auth->uid, 'AND id =', \$_->{id})
+ if !$_->{overrule} != !$_->{my_overrule};
+ }
+ elm_Success
+};
+
+
+sub my_votes {
+ auth ? tuwf->dbVali('SELECT c_imgvotes FROM users WHERE id =', \auth->uid) : 0
+}
+
+
+sub imgflag_ {
+ elm_ 'ImageFlagging', $SEND, {
+ my_votes => my_votes(),
+ nsfw_token => viewset(show_nsfw => 1),
+ mod => auth->permDbmod()||0,
+ @_
+ };
+}
+
+
+TUWF::get qr{/img/vote}, sub {
+ return tuwf->resDenied if !can_vote;
+
+ my $recent = tuwf->dbAlli('SELECT id FROM image_votes WHERE uid =', \auth->uid, 'ORDER BY date DESC LIMIT', \30);
+ enrich_image 1, $recent;
+
+ framework_ title => 'Image flagging', sub {
+ imgflag_ images => [ reverse @$recent ], single => 0, warn => 1;
+ };
+};
+
+
+TUWF::get qr{/$RE{imgid}}, sub {
+ my $id = tuwf->capture('id');
+
+ my $l = [{ id => $id }];
+ enrich_image auth->permDbmod() || sub { defined $_[0]{my_sexual} }, $l;
+ return tuwf->resNotFound if !defined $l->[0]{width};
+
+ framework_ title => "Image flagging for $id", sub {
+ imgflag_ images => $l, single => 1, warn => !viewget->{show_nsfw};
+ };
+};
+
+1;
diff --git a/lib/VNWeb/JS.pm b/lib/VNWeb/JS.pm
new file mode 100644
index 00000000..6a81c757
--- /dev/null
+++ b/lib/VNWeb/JS.pm
@@ -0,0 +1,73 @@
+package VNWeb::JS;
+
+use v5.26;
+use TUWF;
+use VNDB::Config;
+use VNWeb::Validation ();
+use Exporter 'import';
+
+our @EXPORT = qw/js_api/;
+
+
+# Provide a '/js/<endpoint>.json' API for the JS front-end.
+# The $fun callback is given the validated json request object as argument.
+# It should return a string on error or a hash on success.
+sub js_api {
+ my($endpoint, $schema, $fun) = @_;
+ $schema = tuwf->compile({ type => 'hash', keys => $schema }) if ref $schema eq 'HASH';
+
+ TUWF::post qr{/js/\Q$endpoint\E\.json} => sub {
+ my $data = tuwf->validate(json => $schema);
+ if(!$data) {
+ my $err = $data->err;
+ warn "JSON validation failed\ninput: " . JSON::XS->new->allow_nonref->pretty->canonical->encode(tuwf->reqJSON) . "\nerror: " . JSON::XS->new->encode($err) . "\n";
+ $err = $err->{errors}[0]//{};
+ return tuwf->resJSON({_err => 'Form validation failed'.($err->{key} ? " ($err->{key})." : '.')});
+ }
+ my $res = $fun->($data->data);
+ tuwf->resJSON(ref $res ? $res : {_err => $res});
+ };
+}
+
+
+# Log errors from JS.
+TUWF::post qr{/js-error}, sub {
+ my($ev, $source, $lineno, $colno, $stack) = map tuwf->reqPost($_)//'-', qw/ev source lineno colno stack/;
+ return if $source =~ /elm\.js/ && $ev =~ /InvalidStateError/;
+ my $msg = sprintf
+ "\nMessage: %s"
+ ."\nSource: %s %s:%s\n", $ev, $source, $lineno, $colno;
+ $msg .= "Referer: ".tuwf->reqHeader('referer')."\n" if tuwf->reqHeader('referer');
+ $msg .= "Browser: ".tuwf->reqHeader('user-agent')."\n" if tuwf->reqHeader('user-agent');
+ $msg .= ($stack =~ s/[\r\n]+$//r)."\n" if $stack ne '-' && $stack ne 'undefined' && $stack ne 'null';
+ warn $msg;
+};
+
+
+# Returns a hashref with widget_name => bundle_name.
+sub widgets {
+ state $w ||= do {
+ my %w;
+ my sub grab {
+ $w{$1} = $_[0] if $_[1] =~ /(?:^|\W)widget\s*\(\s*['"]([^'"]+)['"]/;
+ }
+ for my $index (glob config->{root}."/js/*/index.js") {
+ my $bundle = $index =~ s#.+/([^/]+)/index\.js$#$1#r;
+ my @f;
+ {
+ open my $F, '<', $index or die $!;
+ while (local $_ = <$F>) {
+ grab($bundle, $_);
+ push @f, $1 if /^\@include (.+)/ && !/ \.gen\//;
+ }
+ };
+ for (@f) {
+ open my $F, '<', config->{root}."/js/$bundle/$_" or die $!;
+ grab($bundle, $_) while (<$F>);
+ }
+ }
+ \%w;
+ };
+}
+
+1;
diff --git a/lib/VNWeb/Misc/AdvSearch.pm b/lib/VNWeb/Misc/AdvSearch.pm
new file mode 100644
index 00000000..ea101ff9
--- /dev/null
+++ b/lib/VNWeb/Misc/AdvSearch.pm
@@ -0,0 +1,31 @@
+package VNWeb::Misc::AdvSearch;
+
+use VNWeb::Prelude;
+use VNWeb::AdvSearch;
+
+
+elm_api 'AdvSearchSave' => undef, {
+ name => { default => '', length => [1,50] },
+ qtype => { enum => \%VNWeb::AdvSearch::FIELDS },
+ query => {},
+}, sub {
+ my($d) = @_;
+ my $q = tuwf->compile({ advsearch => $d->{qtype} })->validate($d->{query})->data->query_encode;
+ tuwf->dbExeci(
+ 'INSERT INTO saved_queries', { uid => auth->uid, qtype => $d->{qtype}, name => $d->{name}, query => $q },
+ 'ON CONFLICT (uid, qtype, name) DO UPDATE SET query =', \$q
+ );
+ elm_Success
+};
+
+
+elm_api 'AdvSearchDel' => undef, {
+ name => { type => 'array', minlength => 1, values => { default => '', length => [1,50] } },
+ qtype => { enum => \%VNWeb::AdvSearch::FIELDS },
+}, sub {
+ my($d) = @_;
+ tuwf->dbExeci('DELETE FROM saved_queries WHERE uid =', \auth->uid, 'AND qtype =', \$d->{qtype}, 'AND name IN', $d->{name});
+ elm_Success
+};
+
+1;
diff --git a/lib/VNWeb/Misc/BBCode.pm b/lib/VNWeb/Misc/BBCode.pm
index 5d6f2e0b..ddc744b2 100644
--- a/lib/VNWeb/Misc/BBCode.pm
+++ b/lib/VNWeb/Misc/BBCode.pm
@@ -3,9 +3,15 @@ package VNWeb::Misc::BBCode;
use VNWeb::Prelude;
elm_api BBCode => undef, {
- content => { required => 0, default => '' }
+ content => { default => '' }
}, sub {
- elm_Content bb2html bb_subst_links shift->{content};
+ elm_Content bb_format bb_subst_links shift->{content};
+};
+
+js_api BBCode => {
+ content => { default => '' }
+}, sub {
+ +{ html => bb_format bb_subst_links shift->{content} };
};
1;
diff --git a/lib/VNWeb/Misc/ElmAnime.pm b/lib/VNWeb/Misc/ElmAnime.pm
new file mode 100644
index 00000000..7910e18e
--- /dev/null
+++ b/lib/VNWeb/Misc/ElmAnime.pm
@@ -0,0 +1,25 @@
+package VNWeb::Misc::ElmAnime;
+
+use VNWeb::Prelude;
+
+elm_api Anime => undef, { search => {}, ref => { anybool => 1 } }, sub {
+ my($d) = @_;
+ my $q = $d->{search};
+ my $qs = sql_like $q;
+
+ elm_AnimeResult tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT a.id, a.title_romaji AS title, coalesce(a.title_kanji, \'\') AS original
+ FROM (',
+ sql_join('UNION ALL',
+ $q =~ /^a([0-9]+)$/ ? sql('SELECT 1, id FROM anime WHERE id =', \"$1") : (),
+ sql('SELECT 1+substr_score(lower(title_romaji),', \$qs, '), id FROM anime WHERE title_romaji ILIKE', \"%$qs%"),
+ sql('SELECT 10+substr_score(lower(title_kanji),', \$qs, '), id FROM anime WHERE title_kanji ILIKE', \"%$qs%"),
+ ), ') x(prio, id)
+ JOIN anime a ON a.id = x.id',
+ $d->{ref} ? 'WHERE EXISTS(SELECT 1 FROM vn_anime va WHERE va.aid = a.id)' : (), '
+ GROUP BY a.id, a.title_romaji, a.title_kanji
+ ORDER BY MIN(x.prio), a.title_romaji
+ ');
+};
+
+1;
diff --git a/lib/VNWeb/Misc/Feeds.pm b/lib/VNWeb/Misc/Feeds.pm
new file mode 100644
index 00000000..f24144d5
--- /dev/null
+++ b/lib/VNWeb/Misc/Feeds.pm
@@ -0,0 +1,80 @@
+package VNWeb::Misc::Feeds;
+
+use VNWeb::Prelude;
+use TUWF::XML ':xml';
+
+
+sub datetime { strftime '%Y-%m-%dT%H:%M:%SZ', gmtime shift }
+
+
+sub feed {
+ my($path, $title, $data) = @_;
+ my $base = tuwf->reqBaseURI();
+
+ tuwf->resHeader('Content-Type', 'application/atom+xml; charset=UTF-8');
+ xml;
+ tag feed => xmlns => 'http://www.w3.org/2005/Atom', 'xml:lang' => 'en', 'xml:base' => "$base/", sub {
+ tag title => $title;
+ tag updated => datetime max grep $_, map +($_->{published}, $_->{updated}), @$data;
+ tag id => $base.$path;
+ tag link => rel => 'self', type => 'application/atom+xml', href => $base.tuwf->reqPath(), undef;
+ tag link => rel => 'alternate', type => 'text/html', href => $base.$path, undef;
+
+ tag entry => sub {
+ tag id => "$base/$_->{id}";
+ tag title => $_->{title};
+ tag updated => datetime($_->{updated} || $_->{published});
+ tag published => datetime $_->{published} if $_->{published};
+ tag author => sub {
+ tag name => $_->{user_name};
+ tag uri => "$base/$_->{user_id}";
+ } if $_->{user_id};
+ tag link => rel => 'alternate', type => 'text/html', href => "$base/$_->{id}", undef;
+ tag summary => type => 'html', bb_format $_->{summary}, maxlength => 300 if $_->{summary};
+ } for @$data;
+ }
+}
+
+
+TUWF::get qr{/feeds/announcements.atom}, sub {
+ feed '/t/an', 'VNDB Site Announcements', tuwf->dbAlli('
+ SELECT t.id, t.title, tp.msg AS summary
+ , ', sql_totime('tp.date'), 'AS published,', sql_totime('tp.edited'), 'AS updated,', sql_user(), '
+ FROM threads t
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num = 1
+ JOIN threads_boards tb ON tb.tid = t.id AND tb.type = \'an\'
+ LEFT JOIN users u ON u.id = tp.uid
+ WHERE NOT t.hidden AND NOT t.private
+ ORDER BY tb.tid DESC
+ LIMIT 10'
+ );
+};
+
+
+TUWF::get qr{/feeds/changes.atom}, sub {
+ my($lst) = VNWeb::Misc::History::fetch(undef, {m=>1,h=>1,p=>1}, {results=>25});
+ for (@$lst) {
+ $_->{id} = "$_->{itemid}.$_->{rev}";
+ $_->{title} = $_->{title}[1];
+ $_->{summary} = $_->{comments};
+ $_->{updated} = $_->{added};
+ }
+ feed '/hist', 'VNDB Recent Changes', $lst;
+};
+
+
+TUWF::get qr{/feeds/posts.atom}, sub {
+ feed '/t', 'VNDB Recent Posts', tuwf->dbAlli('
+ SELECT t.id||\'.\'||tp.num AS id, t.title||\' (#\'||tp.num||\')\' AS title, tp.msg AS summary
+ , ', sql_totime('tp.date'), 'AS published,', sql_totime('tp.edited'), 'AS updated,', sql_user(), '
+ FROM threads_posts tp
+ JOIN threads t ON t.id = tp.tid
+ LEFT JOIN users u ON u.id = tp.uid
+ WHERE tp.hidden IS NULL AND NOT t.hidden AND NOT t.private
+ ORDER BY tp.date DESC
+ LIMIT ', \25
+ );
+};
+
+
+1;
diff --git a/lib/VNWeb/Misc/History.pm b/lib/VNWeb/Misc/History.pm
index 26ef5f48..9664363b 100644
--- a/lib/VNWeb/Misc/History.pm
+++ b/lib/VNWeb/Misc/History.pm
@@ -3,84 +3,82 @@ package VNWeb::Misc::History;
use VNWeb::Prelude;
+# Also used by Misc::HomePage and Misc::Feeds
sub fetch {
- my($type, $id, $filt, $opt) = @_;
+ my($id, $filt, $opt) = @_;
+ my $num = $opt->{results}||50;
my $where = sql_and
- !$type ? ()
- : $type eq 'u' ? sql 'c.requester =', \$id
- : sql_or(
- sql('c.type =', \$type, ' AND c.itemid =', \$id),
+ !$id ? ()
+ : $id =~ /^u/ ? sql 'c.requester =', \$id
+ : $id =~ /^v/ && $filt->{r} ? sql 'c.itemid =', \$id, 'OR c.id IN(SELECT chid FROM releases_vn_hist WHERE vid =', \$id, ')' # This may need an index on releases_vn_hist.vid
+ : sql('c.itemid =', \$id),
- # This may need an index on releases_vn_hist.vid
- $type eq 'v' && $filt->{r} ?
- sql 'c.id IN(SELECT chid FROM releases_vn_hist WHERE vid =', \$id, ')' : ()
- ),
-
- $filt->{t} && $filt->{t}->@* ? sql 'c.type IN', \$filt->{t} : (),
- $filt->{m} ? sql 'c.requester <> 1' : (),
+ $filt->{t} && $filt->{t}->@* ? sql_or map sql('c.itemid BETWEEN vndbid(', \"$_", ',1) AND vndbid_max(', \"$_", ')'), $filt->{t}->@* : (),
+ $filt->{m} ? sql 'c.requester IS DISTINCT FROM \'u1\'' : (),
$filt->{e} && $filt->{e} == 1 ? sql 'c.rev <> 1' : (),
$filt->{e} && $filt->{e} ==-1 ? sql 'c.rev = 1' : (),
- $filt->{h} ? sql $filt->{h} == 1 ? 'NOT' : '',
+ # -2 = awaiting mod, -1 = deleted, 0 = all, 1 = approved
+ $filt->{h} ? sql
'EXISTS(SELECT 1 FROM changes c_i
- WHERE c_i.type = c.type AND c_i.itemid = c.itemid AND c_i.ihid
- AND c_i.rev = (SELECT MAX(c_ii.rev) FROM changes c_ii WHERE c_ii.type = c.type AND c_ii.itemid = c.itemid))' : ();
-
- my($lst, $np) = tuwf->dbPagei({ page => $filt->{p}, results => $opt->{results}||50 }, q{
- SELECT c.id, c.type, c.itemid, c.comments, c.rev,}, sql_totime('c.added'), q{ AS added, }, sql_user(), q{
- FROM changes c
- JOIN users u ON c.requester = u.id
- WHERE}, $where, q{
- ORDER BY c.id DESC
- });
-
- # Fetching the titles in a separate query is faster, for some reason.
- enrich_merge id => sql(q{
- SELECT id, title, original FROM (
- SELECT chid, title, original FROM vn_hist
- UNION ALL SELECT chid, title, original FROM releases_hist
- UNION ALL SELECT chid, name, original FROM producers_hist
- UNION ALL SELECT chid, name, original FROM chars_hist
- UNION ALL SELECT chid, title, '' AS original FROM docs_hist
- UNION ALL SELECT sh.chid, name, original FROM staff_hist sh JOIN staff_alias_hist sah ON sah.chid = sh.chid AND sah.aid = sh.aid
- ) t(id, title, original)
- WHERE id IN}), $lst;
+ WHERE c_i.itemid = c.itemid AND',
+ $filt->{h} == -2 ? 'c_i.ihid AND NOT c_i.ilock' :
+ $filt->{h} == -1 ? 'c_i.ihid AND c_i.ilock' : 'NOT c_i.ihid', '
+ AND c_i.rev = (SELECT MAX(c_ii.rev) FROM changes c_ii WHERE c_ii.itemid = c.itemid))' : ();
+
+ my $lst = tuwf->dbAlli('
+ SELECT c.id, c.itemid, c.comments, c.rev,', sql_totime('c.added'), 'AS added,', sql_user(), ', x.title, u.perm_dbmod AS rev_dbmod
+ FROM (SELECT * FROM changes c WHERE', $where, ' ORDER BY c.id DESC LIMIT', \($num+1), 'OFFSET', \($num*($filt->{p}-1)), ') c
+ JOIN item_info(NULL, c.itemid, c.rev) x ON true
+ LEFT JOIN users u ON c.requester = u.id
+ ORDER BY c.id DESC'
+ );
+ enrich rev_patrolled => id => id =>
+ sql('SELECT c.id,', sql_user(), 'FROM changes_patrolled c JOIN users u ON u.id = c.uid WHERE c.id IN'), $lst
+ if auth->permDbmod;
+ my $np = @$lst > $num ? pop(@$lst)&&1 : 0;
($lst, $np)
}
-# Also used by User::Page.
-# %opt: nopage => 1/0, results => $num
+# Also used by User::Page and VNWeb::HTML.
+# %opt: nopage => 1/0, nouser => 1/0, results => $num
sub tablebox_ {
- my($type, $id, $filt, %opt) = @_;
+ my($id, $filt, %opt) = @_;
- my($lst, $np) = fetch $type, $id, $filt, \%opt;
+ my($lst, $np) = fetch $id, $filt, \%opt;
my sub url { '?'.query_encode %$filt, p => $_ }
paginate_ \&url, $filt->{p}, $np, 't' unless $opt{nopage};
- div_ class => 'mainbox browse history mainbox-overflow-hack', sub {
+ article_ class => 'browse history overflow-hack', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
+ td_ class => 'tc1_0', '' if auth->permDbmod;
td_ class => 'tc1_1', 'Rev.';
td_ class => 'tc1_2', '';
td_ class => 'tc2', 'Date';
- td_ class => 'tc3', 'User';
+ td_ class => 'tc3', 'User' unless $opt{nouser};
td_ class => 'tc4', sub { txt_ 'Page'; debug_ $lst; };
}};
tr_ sub {
my $i = $_;
- my $revurl = "/$i->{type}$i->{itemid}.$i->{rev}";
-
- td_ class => 'tc1_1', sub { a_ href => $revurl, "$i->{type}$i->{itemid}" };
+ my $revurl = "/$i->{itemid}.$i->{rev}";
+
+ td_ class => 'tc1_0', sub {
+ a_ href => "$revurl?patrolled=$i->{id}", sub {
+ revision_patrolled_ $i;
+ }
+ } if auth->permDbmod;
+ td_ class => 'tc1_1', sub { a_ href => $revurl, $i->{itemid} };
td_ class => 'tc1_2', sub { a_ href => $revurl, ".$i->{rev}" };
td_ class => 'tc2', fmtdate $i->{added}, 'full';
- td_ class => 'tc3', sub { user_ $i };
+ td_ class => 'tc3', sub { user_ $i } unless $opt{nouser};
td_ class => 'tc4', sub {
- a_ href => $revurl, title => $i->{original}, shorten $i->{title}, 80;
- b_ class => 'grayedout', sub { lit_ bb2html $i->{comments}, 150 };
+ a_ href => $revurl, tattr $i;
+ small_ sub { lit_ bb_format $i->{comments}, maxlength => 150, inline => 1 };
};
} for @$lst;
};
@@ -94,18 +92,20 @@ sub filters_ {
my @types = (
[ v => 'Visual novels' ],
+ [ g => 'Tags' ],
[ r => 'Releases' ],
[ p => 'Producers' ],
[ s => 'Staff' ],
[ c => 'Characters' ],
- [ d => 'Docs' ]
+ [ i => 'Traits' ],
+ [ d => 'Docs' ],
);
state $schema = tuwf->compile({ type => 'hash', keys => {
# Types
t => { type => 'array', scalar => 1, onerror => [map $_->[0], @types], values => { enum => [(map $_->[0], @types), 'a'] } },
m => { onerror => undef, enum => [ 0, 1 ] }, # Automated edits
- h => { onerror => 0, enum => [ -1..1 ] }, # Hidden items
+ h => { onerror => 0, enum => [ -2..1 ] }, # Item status (the numbers dont make sense)
e => { onerror => 0, enum => [ -1..1 ] }, # Existing/new items
r => { onerror => 0, enum => [ 0, 1 ] }, # Include releases
p => { page => 1 },
@@ -131,16 +131,14 @@ sub filters_ {
};
form_ method => 'get', action => tuwf->reqPath(), sub {
- table_ style => 'margin: 0 auto', sub { tr_ sub {
- td_ style => 'padding: 10px', sub {
- p_ class => 'linkradio', sub {
- join_ \&br_, sub {
- opt_ checkbox => t => $_->[0], $_->[1], $t{$_->[0]}||0;
- }, @types;
+ table_ class => 'histoptions', sub { tr_ sub {
+ td_ sub {
+ select_ multiple => 1, size => scalar @types, name => 't', sub {
+ option_ $t{$_->[0]} ? (selected => 1) : (), value => $_->[0], $_->[1] for @types;
}
} if exists $filt->{t};
- td_ style => 'padding: 10px', sub {
+ td_ sub {
p_ class => 'linkradio', sub {
opt_ radio => e => 0, 'All'; em_ ' | ';
opt_ radio => e => 1, 'Only changes to existing items'; em_ ' | ';
@@ -148,8 +146,9 @@ sub filters_ {
} if exists $filt->{e};
p_ class => 'linkradio', sub {
opt_ radio => h => 0, 'All'; em_ ' | ';
- opt_ radio => h => 1, 'Only non-deleted items'; em_ ' | ';
- opt_ radio => h =>-1, 'Only deleted';
+ opt_ radio => h => 1, 'Only public items'; em_ ' | ';
+ opt_ radio => h =>-1, 'Only deleted'; em_ ' | ';
+ opt_ radio => h =>-2, 'Only unapproved';
} if exists $filt->{h};
p_ class => 'linkradio', sub {
opt_ checkbox => m => 0, 'Show automated edits' if !$type;
@@ -167,35 +166,22 @@ sub filters_ {
}
-TUWF::get qr{/(?:([upvrcsd])([1-9]\d*)/)?hist} => sub {
- my($type, $id) = (tuwf->capture(1)||'', tuwf->capture(2));
-
- my sub dbitem {
- my($table, $title) = @_;
- tuwf->dbRowi('SELECT id,', $title, ' AS title, hidden AS entry_hidden, locked AS entry_locked FROM', $table, 'WHERE id =', \$id);
- };
-
- my $obj = !$type ? undef :
- $type eq 'u' ? tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \$id) :
- $type eq 'p' ? dbitem producers => 'name' :
- $type eq 'v' ? dbitem vn => 'title' :
- $type eq 'r' ? dbitem releases => 'title' :
- $type eq 'c' ? dbitem chars => 'name' :
- $type eq 's' ? dbitem staff => '(SELECT name FROM staff_alias WHERE aid = staff.aid)' :
- $type eq 'd' ? dbitem docs => 'title' : die;
+TUWF::get qr{/(?:([upvrcsdgi][1-9][0-9]{0,6})/)?hist} => sub {
+ my $id = tuwf->capture(1)||'';
+ my $obj = dbobj $id;
- return tuwf->resNotFound if $type && !$obj->{id};
- $obj->{title} = user_displayname $obj if $type eq 'u';
+ return tuwf->resNotFound if $id && !$obj->{id};
+ return tuwf->resNotFound if $id =~ /^u/ && $obj->{entry_hidden} && !auth->isMod;
- my $title = $type ? "Edit history of $obj->{title}" : 'Recent changes';
- framework_ title => $title, type => $type, dbobj => $obj, tab => 'hist',
+ my $title = $id ? "Edit history of $obj->{title}[1]" : 'Recent changes';
+ framework_ title => $title, dbobj => $obj, tab => 'hist',
sub {
my $filt;
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
- $filt = filters_ $type;
+ $filt = filters_($id =~ /^(.)/ ? $1 : '');
};
- tablebox_ $type, $id, $filt;
+ tablebox_ $id, $filt, nouser => scalar $id =~ /^u/;
};
};
diff --git a/lib/VNWeb/Misc/HomePage.pm b/lib/VNWeb/Misc/HomePage.pm
new file mode 100644
index 00000000..86254fcd
--- /dev/null
+++ b/lib/VNWeb/Misc/HomePage.pm
@@ -0,0 +1,286 @@
+package VNWeb::Misc::HomePage;
+
+use VNWeb::Prelude;
+use VNWeb::AdvSearch;
+use VNWeb::Discussions::Lib 'enrich_boards';
+
+
+sub screens {
+ state $where ||= sql 'i.c_weight > 0 and vndbid_type(i.id) =', \'sf', 'and i.c_sexual_avg <', \40, 'and i.c_violence_avg <', \40;
+ state $stats ||= tuwf->dbRowi('SELECT count(*) as total, count(*) filter(where', $where, ') as subset from images i');
+ state $sample ||= 100*min 1, (200 / (1+$stats->{subset})) * ($stats->{total} / (1+$stats->{subset}));
+
+ my $filt = advsearch_default 'v';
+ my $start = time;
+ my $lst = $filt->{query} ? tuwf->dbAlli(
+ # Assumption: If we randomly select 30 matching VNs, there'll be at least 4 VNs with qualified screenshots
+ # (As of Sep 2020, over half of the VNs in the database have screenshots, so that assumption usually works)
+ 'SELECT * FROM (
+ SELECT DISTINCT ON (v.id) i.id, i.width, i,height, v.id AS vid, v.title
+ FROM (SELECT id, title FROM', vnt, 'v WHERE NOT v.hidden AND ', $filt->sql_where(), ' ORDER BY random() LIMIT', \30, ') v
+ JOIN vn_screenshots vs ON v.id = vs.id
+ JOIN images i ON i.id = vs.scr
+ WHERE ', $where, '
+ ORDER BY v.id
+ ) x ORDER BY random() LIMIT', \4
+ ) : tuwf->dbAlli('
+ SELECT i.id, i.width, i.height, v.id AS vid, v.title
+ FROM (SELECT id, width, height FROM images i TABLESAMPLE SYSTEM (', \$sample, ') WHERE', $where, ' ORDER BY random() LIMIT', \4, ') i(id)
+ JOIN vn_screenshots vs ON vs.scr = i.id
+ JOIN', vnt, 'v ON v.id = vs.id
+ WHERE NOT v.hidden
+ ORDER BY random()
+ LIMIT', \4
+ );
+ ($lst, $filt->{query} && time - $start > 0.3)
+}
+
+
+sub recent_changes_ {
+ my($lst) = VNWeb::Misc::History::fetch(undef, {m=>1,h=>1,p=>1}, {results=>10});
+ h1_ sub {
+ a_ href => '/hist', 'Recent Changes'; txt_ ' ';
+ a_ href => '/feeds/changes.atom', sub {
+ abbr_ class => 'icon-rss', title => 'Atom feed', '';
+ }
+ };
+ ul_ sub {
+ li_ sub {
+ span_ sub {
+ txt_ "$1:" if $_->{itemid} =~ /^(.)/;
+ a_ href => "/$_->{itemid}.$_->{rev}", tattr $_;
+ };
+ span_ sub {
+ lit_ " by ";
+ user_ $_;
+ }
+ } for @$lst;
+ };
+}
+
+
+sub recent_db_posts_ {
+ my $an = tuwf->dbAlli('
+ SELECT t.id, t.title,', sql_totime('tp.date'), 'AS date
+ FROM threads t
+ JOIN threads_boards tb ON tb.tid = t.id AND tb.type = \'an\'
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num = 1
+ WHERE NOT t.hidden AND NOT t.private AND tp.date >', sql_fromtime(time-30*24*3600), '
+ ORDER BY tb.tid DESC
+ LIMIT 1+1'
+ );
+ my $lst = tuwf->dbAlli('
+ SELECT t.id, t.title, tp.num,', sql_totime('tp.date'), 'AS date, ', sql_user(), '
+ FROM threads t
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num = t.c_lastnum
+ LEFT JOIN users u ON tp.uid = u.id
+ WHERE EXISTS(SELECT 1 FROM threads_boards tb WHERE tb.tid = t.id AND tb.type IN(\'db\',\'an\'))
+ AND NOT t.hidden AND NOT t.private
+ ORDER BY tp.date DESC
+ LIMIT', \(10-@$an)
+ );
+ enrich_boards undef, $lst;
+ p_ class => 'mainopts', sub {
+ a_ href => '/t/an', 'Announcements';
+ small_ '&';
+ a_ href => '/t/db', 'VNDB';
+ };
+ h1_ sub {
+ txt_ 'DB Discussions';
+ };
+ ul_ sub {
+ li_ class => 'announcement', sub {
+ a_ href => "/$_->{id}", $_->{title};
+ } for @$an;
+ li_ sub {
+ my $boards = join ', ', map $BOARD_TYPE{$_->{btype}}{txt}.($_->{iid}?' > '.$_->{title}[1]:''), $_->{boards}->@*;
+ span_ sub {
+ txt_ fmtage($_->{date}).' ';
+ a_ href => "/$_->{id}.$_->{num}#last", title => "Posted in $boards", $_->{title};
+ };
+ span_ sub {
+ lit_ ' by ';
+ user_ $_;
+ }
+ } for @$lst;
+ };
+}
+
+
+sub recent_vn_posts_ {
+ my $lst = tuwf->dbAlli('
+ WITH tposts (id,title,num,date,uid) AS (
+ SELECT t.id, ARRAY[NULL, t.title], tp.num, tp.date, tp.uid
+ FROM threads t
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num = t.c_lastnum
+ WHERE NOT EXISTS(SELECT 1 FROM threads_boards tb WHERE tb.tid = t.id AND tb.type IN(\'an\',\'db\',\'u\'))
+ AND NOT t.hidden AND NOT t.private
+ ORDER BY tp.date DESC LIMIT 10
+ ), wposts (id,title,num,date,uid) AS (
+ SELECT w.id, v.title, wp.num, wp.date, wp.uid
+ FROM reviews w
+ JOIN reviews_posts wp ON wp.id = w.id AND wp.num = w.c_lastnum
+ JOIN', vnt, 'v ON v.id = w.vid
+ LEFT JOIN users u ON wp.uid = u.id
+ WHERE NOT w.c_flagged AND wp.hidden IS NULL
+ ORDER BY wp.date DESC LIMIT 10
+ ) SELECT x.id, x.num, x.title,', sql_totime('x.date'), 'AS date, ', sql_user(), '
+ FROM (SELECT * FROM tposts UNION ALL SELECT * FROM wposts) x
+ LEFT JOIN users u ON u.id = x.uid
+ ORDER BY date DESC
+ LIMIT 10'
+ );
+ enrich_boards undef, $lst;
+ p_ class => 'mainopts', sub {
+ a_ href => '/t/all', 'Forums';
+ small_ '&';
+ a_ href => '/w?o=d&s=lastpost', 'Reviews';
+ };
+ h1_ sub {
+ a_ href => '/t/all', 'VN Discussions';
+ };
+ ul_ sub {
+ li_ sub {
+ span_ sub {
+ my $boards = join ', ', map $BOARD_TYPE{$_->{btype}}{txt}.($_->{iid}?' > '.$_->{title}[1]:''), $_->{boards}->@*;
+ txt_ fmtage($_->{date}).' ';
+ a_ href => "/$_->{id}.$_->{num}#last", title => $boards ? "Posted in $boards" : 'Review', tlang(@{$_->{title}}[0,1]), $_->{title}[1];
+ };
+ span_ sub {
+ lit_ ' by ';
+ user_ $_;
+ }
+ } for @$lst;
+ };
+}
+
+
+
+sub releases {
+ my($released) = @_;
+
+ my $filt = advsearch_default 'r';
+
+ # Drop any top-level date filters
+ $filt->{query} = [ grep !(ref $_ eq 'ARRAY' && $_->[0] eq 'released'), $filt->{query}->@* ] if $filt->{query};
+ delete $filt->{query} if $filt->{query} && ($filt->{query}[0] eq 'released' || $filt->{query}->@* < 2);
+ my $has_saved = !!$filt->{query};
+
+ # Add the release date as filter, we need to construct a filter for the header link anyway
+ $filt->{query} = [ 'and', [ released => $released ? '<=' : '>', 1 ], $filt->{query} || () ];
+
+ my $start = time;
+ my $lst = tuwf->dbAlli('
+ SELECT id, title, released
+ FROM', releasest, 'r
+ WHERE NOT hidden AND ', $filt->sql_where(), '
+ AND NOT EXISTS(SELECT 1 FROM releases_titles rt WHERE rt.id = r.id AND rt.mtl)
+ ORDER BY released', $released ? 'DESC' : '', ', id LIMIT 10'
+ );
+ my $end = time;
+ enrich_flatten plat => id => id => 'SELECT id, platform FROM releases_platforms WHERE id IN', $lst;
+ enrich_flatten lang => id => id => 'SELECT id, lang FROM releases_titles WHERE id IN', $lst;
+ ($lst, $filt, $has_saved && $end-$start > 0.3)
+}
+
+
+sub releases_ {
+ my($lst, $filt, $released) = @_;
+
+ h1_ sub {
+ a_ href => '/r?f='.$filt->query_encode().';o=a;s=released', 'Upcoming Releases' if !$released;
+ a_ href => '/r?f='.$filt->query_encode().';o=d;s=released', 'Just Released' if $released;
+ };
+ ul_ sub {
+ li_ sub {
+ span_ sub {
+ rdate_ $_->{released};
+ txt_ ' ';
+ platform_ $_ for $_->{plat}->@*;
+ abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' for $_->{lang}->@*;
+ txt_ ' ';
+ a_ href => "/$_->{id}", tattr $_;
+ }
+ } for @$lst;
+ };
+}
+
+
+sub reviews_ {
+ my $lst = tuwf->dbAlli('
+ SELECT w.id, v.title, w.isfull, ', sql_user(), ',', sql_totime('w.date'), 'AS date
+ FROM reviews w
+ JOIN', vnt, 'v ON v.id = w.vid
+ LEFT JOIN users u ON u.id = w.uid
+ WHERE NOT w.c_flagged
+ ORDER BY w.id DESC LIMIT 10'
+ );
+ h1_ sub {
+ a_ href => '/w', 'Latest Reviews';
+ };
+ ul_ sub {
+ li_ sub {
+ span_ sub {
+ txt_ fmtage($_->{date}).' ';
+ small_ $_->{isfull} ? ' Full ' : ' Mini ';
+ a_ href => "/$_->{id}", tattr $_;
+ };
+ span_ sub {
+ lit_ 'by ';
+ user_ $_;
+ }
+ } for @$lst;
+ }
+}
+
+
+TUWF::get qr{/}, sub {
+ my %meta = (
+ 'type' => 'website',
+ 'title' => 'The Visual Novel Database',
+ 'description' => 'VNDB.org strives to be a comprehensive database for information about visual novels.',
+ );
+
+ my($screens, $slowscreens) = screens;
+ my($rel0, $filt0, $slowrel0) = releases 0;
+ my($rel1, $filt1, $slowrel1) = releases 1;
+ my $slowrel = $slowrel0 || $slowrel1;
+
+ framework_ title => $meta{title}, feeds => 1, og => \%meta, index => 1, sub {
+ article_ sub {
+ h1_ $meta{title};
+ p_ class => 'description', sub {
+ txt_ $meta{description};
+ br_;
+ txt_ q{
+ This website is built as a wiki, meaning that anyone can freely add
+ and contribute information to the database, allowing us to create the
+ largest, most accurate and most up-to-date visual novel database on the web.
+ };
+ };
+ p_ class => 'screenshots', sub {
+ a_ href => "/$_->{vid}", title => $_->{title}[1], sub {
+ my($w, $h) = imgsize $_->{width}, $_->{height}, config->{scr_size}->@*;
+ img_ src => imgurl($_->{id}, 't'), alt => $_->{title}[1], width => $w, height => $h;
+ } for @$screens;
+ };
+ p_ class => 'center standout', sub {
+ txt_ 'If VNDB appears to load a little slow for you, try clearing or adjusting your ';
+ a_ href => '/v', 'saved visual novel filters' if $slowscreens;
+ txt_ ' or ' if $slowscreens && $slowrel;
+ a_ href => '/r', 'saved release filters' if $slowrel;
+ txt_ '.';
+ } if $slowscreens || $slowrel;
+ };
+ div_ class => 'homepage', sub {
+ article_ \&recent_changes_;
+ article_ \&recent_db_posts_;
+ article_ \&recent_vn_posts_;
+ article_ sub { reviews_ };
+ article_ sub { releases_ $rel0, $filt0, 0 };
+ article_ sub { releases_ $rel1, $filt1, 1 };
+ };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Misc/Lockdown.pm b/lib/VNWeb/Misc/Lockdown.pm
new file mode 100644
index 00000000..ad0d4bb2
--- /dev/null
+++ b/lib/VNWeb/Misc/Lockdown.pm
@@ -0,0 +1,54 @@
+package VNWeb::Misc::Lockdown;
+
+use VNWeb::Prelude;
+
+TUWF::get '/lockdown', sub {
+ return tuwf->resDenied if !auth->isMod;
+
+ sub chk_ {
+ my($name, $lbl) = @_;
+ label_ sub {
+ input_ type => 'checkbox', name => $name, global_settings->{$name} ? (checked => 'checked') : ();
+ txt_ $lbl;
+ };
+ br_;
+ }
+
+ framework_ title => 'Database lockdown', sub {
+ article_ sub {
+ h1_ 'Database lockdown';
+
+ p_ sub {
+ txt_ 'This form provides a sledghehammer approach to dealing with
+ targeted vandalism or spam attacks on the site. The goal of
+ these options is to put the website in a temporary lockdown
+ while waiting for Yorhel to wake up or while a better solution
+ is being worked on.';
+ br_;
+ txt_ 'Moderators can keep using the site as usual regardless of these settings.';
+ };
+
+ form_ action => '/lockdown', method => 'post', style => 'margin: 20px', sub {
+ chk_ lockdown_registration => ' Disable account creation.';
+ chk_ lockdown_edit => ' Disable database editing globally. Also disables image and tag voting.';
+ chk_ lockdown_board => ' Disable forum and review posting globally.';
+ input_ type => 'submit', name => 'submit', class => 'submit', value => 'Submit';
+ };
+ };
+ };
+};
+
+
+TUWF::post '/lockdown', sub {
+ return auth->resDenied if !auth->isMod || !samesite;
+ my $frm = tuwf->validate(post =>
+ lockdown_registration => { anybool => 1 },
+ lockdown_edit => { anybool => 1 },
+ lockdown_board => { anybool => 1 },
+ )->data;
+ tuwf->dbExeci('UPDATE global_settings SET', $frm);
+ auth->audit(0, 'lockdown', JSON::XS->new->encode($frm));
+ tuwf->resRedirect('/lockdown', 'post');
+};
+
+1;
diff --git a/lib/VNWeb/Misc/OpenSearch.pm b/lib/VNWeb/Misc/OpenSearch.pm
new file mode 100644
index 00000000..1f74496b
--- /dev/null
+++ b/lib/VNWeb/Misc/OpenSearch.pm
@@ -0,0 +1,22 @@
+package VNWeb::Misc::OpenSearch;
+
+use VNWeb::Prelude;
+use TUWF::XML 'xml', 'tag';
+
+TUWF::get qr{/opensearch\.xml}, sub {
+ my $h = tuwf->reqBaseURI;
+ tuwf->resHeader('Content-Type' => 'application/opensearchdescription+xml');
+ xml;
+ tag 'OpenSearchDescription', xmlns => 'http://a9.com/-/spec/opensearch/1.1/', 'xmlns:moz' => 'http://www.mozilla.org/2006/browser/search/', sub {
+ tag 'ShortName', 'VNDB';
+ tag 'LongName', 'VNDB.org Visual Vovel Search';
+ tag 'Description', 'Search visual novels on VNDB.org';
+ tag 'Image', width => 16, height => 16, type => 'image/x-icon', "$h/favicon.ico";
+ tag 'Url', type => 'text/html', method => 'get', template => "$h/v?q={searchTerms}", undef;
+ tag 'Url', type => 'application/opensearchdescription+xml', rel => 'self', template => "$h/opensearch.xml", undef;
+ tag 'Query', role => 'example', searchTerms => 'Tsukihime', undef;
+ tag 'moz:SearchForm', "$h/v";
+ }
+};
+
+1;
diff --git a/lib/VNWeb/Misc/Redirects.pm b/lib/VNWeb/Misc/Redirects.pm
new file mode 100644
index 00000000..e16cf495
--- /dev/null
+++ b/lib/VNWeb/Misc/Redirects.pm
@@ -0,0 +1,46 @@
+package VNWeb::Misc::Redirects;
+
+use VNWeb::Prelude;
+use VNWeb::AdvSearch;
+
+
+# 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{/v/search}, sub { tuwf->resRedirect('/v'.tuwf->reqQuery(), 'perm') };
+
+TUWF::get qr{/experimental/v}, sub { tuwf->resRedirect('/v'.tuwf->reqQuery(), 'perm') };
+TUWF::get qr{/experimental/r}, sub { tuwf->resRedirect('/r'.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 '/%s#staff', tuwf->capture('id')) };
+TUWF::get qr{/$RE{vid}/stats}, sub { tuwf->resRedirect(sprintf '/%s#stats', tuwf->capture('id')) };
+TUWF::get qr{/$RE{vid}/scr}, sub { tuwf->resRedirect(sprintf '/%s#screenshots', tuwf->capture('id')) };
+TUWF::get qr{/img/$RE{imgid}}, sub { tuwf->resRedirect('/'.tuwf->capture(1).tuwf->reqQuery(), 'perm') };
+
+TUWF::get qr{/u/tokens}, sub { tuwf->resRedirect(auth ? '/'.auth->uid.'/edit#api' : '/u/login?ref=/u/tokens', 'temp') };
+
+
+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, (1000 / $stats->{subset}) * ($stats->{total} / $stats->{subset});
+
+ my $filt = advsearch_default 'v';
+ my $vn = tuwf->dbVali('
+ SELECT id
+ FROM vn v', $filt->{query} ? '' : ('TABLESAMPLE SYSTEM (', \$sample, ')'), '
+ WHERE NOT hidden AND', $filt->sql_where(), '
+ ORDER BY random() LIMIT 1'
+ );
+ return tuwf->resNotFound if !$vn;
+ tuwf->resRedirect("/$vn", 'temp');
+};
+
+1;
diff --git a/lib/VNWeb/Misc/Reports.pm b/lib/VNWeb/Misc/Reports.pm
new file mode 100644
index 00000000..5c5dcac6
--- /dev/null
+++ b/lib/VNWeb/Misc/Reports.pm
@@ -0,0 +1,271 @@
+package VNWeb::Misc::Reports;
+
+use VNWeb::Prelude;
+
+my $reportsperday = 5;
+
+my @STATUS = qw/new busy done dismissed/;
+my $STATUSRE = '(?:'.join('|', @STATUS).')';
+
+
+# Returns the object associated with the vndbid.num; Returns false if the object can't be reported.
+sub obj {
+ my($id, $num) = @_;
+ my $o = tuwf->dbRowi('SELECT x.*, ', sql_user(), 'FROM', item_info(\$id, \$num), 'x LEFT JOIN users u ON u.id = x.uid');
+ $o->{object} = $id;
+ $o->{objectnum} = $num;
+ $o->{title} //= [undef,$o->{object},undef,$o->{object}];
+ my $can = !defined $o->{title} ? 0
+ : $id =~ /^[vrpcsdu]/ ? !$num
+ : $id =~ /^w/ ? 1
+ : $id =~ /^t/ ? $num && !$o->{hidden} : 0;
+ $can && $o
+}
+
+
+sub obj_ {
+ my($o) = @_;
+ my $lnk = $o->{object} . ($o->{objectnum} ? ".$o->{objectnum}" : '');
+ if($o->{object} =~ /^(?:$RE{wid}|$RE{tid})$/ && $o->{objectnum}) {
+ txt_ 'Comment ';
+ a_ href => "/$lnk", "#$o->{objectnum}";
+ txt_ ' on ';
+ a_ href => "/$lnk", $o->{title} ? tattr $o : '<deleted>';
+ txt_ ' by ';
+ user_ $o;
+
+ } else {
+ txt_ {qw/v VN r Release p Producer c Character s Staff d Doc w Review t Thread u User/}->{substr $o->{object}, 0, 1};
+ txt_ ': ';
+ a_ href => "/$lnk", tattr $o;
+ if($o->{user_name}) {
+ txt_ ' by ';
+ user_ $o;
+ }
+ }
+}
+
+
+sub is_throttled {
+ tuwf->dbVali('SELECT COUNT(*) FROM reports WHERE date > NOW()-\'1 day\'::interval AND', auth ? ('uid =', \auth->uid) : ('(ip).ip =', \tuwf->reqIP)) >= $reportsperday
+}
+
+
+my $FORM = form_compile any => {
+ object => {},
+ objectnum=> { default => undef, uint => 1 },
+ title => {},
+ reason => { maxlength => 50 },
+ message => { default => '', maxlength => 50000 },
+ loggedin => { anybool => 1 },
+};
+
+js_api Report => $FORM, sub {
+ return tuwf->resDenied if is_throttled;
+ my($data) = @_;
+ my $obj = obj $data->{object}, $data->{objectnum};
+ return 'Invalid object' if !$data;
+
+ tuwf->dbExeci('INSERT INTO reports', {
+ uid => auth->uid,
+ ip => auth ? undef : ipinfo(),
+ object => $data->{object},
+ objectnum=> $data->{objectnum},
+ reason => $data->{reason},
+ message => $data->{message},
+ });
+ +{}
+};
+
+
+TUWF::get qr{/report/(?<object>[vrpcsdtwu]$RE{num})(?:\.(?<subid>$RE{num}))?}, sub {
+ my $obj = obj tuwf->captures('object', 'subid');
+ return tuwf->resNotFound if !$obj || config->{read_only};
+
+ framework_ title => 'Submit report', sub {
+ if(is_throttled) {
+ article_ sub {
+ h1_ 'Submit report';
+ p_ "Sorry, you can only submit $reportsperday reports per day. If you wish to report more, you can do so by sending an email to ".config->{admin_email}
+ }
+ } else {
+ div_ widget(Report => $FORM, { elm_empty($FORM)->%*, %$obj, loggedin => !!auth, title => xml_string sub { obj_ $obj } }), '';
+ }
+ };
+};
+
+
+sub report_ {
+ my($r, $url) = @_;
+ my $objid = $r->{object}.(defined $r->{objectnum} ? ".$r->{objectnum}" : '');
+ td_ style => 'padding: 3px 5px 5px 20px', sub {
+ a_ href => "?id=$r->{id}", "#$r->{id}";
+ small_ ' '.fmtdate $r->{date}, 'full';
+ txt_ ' by ';
+ if($r->{uid}) {
+ a_ href => "/$r->{uid}", $r->{username};
+ txt_ ' (';
+ a_ href => "/t/$r->{uid}/new?title=Regarding your report on $objid&priv=1", 'pm';
+ txt_ ')';
+ } else {
+ txt_ $r->{ip}||'[anonymous]';
+ }
+ br_;
+ obj_ $r;
+ br_;
+ if($r->{message} && $r->{reason} =~ /spoilers/i) {
+ details_ sub {
+ summary_ $r->{reason};
+ div_ class => 'quote', sub { lit_ bb_format $r->{message} };
+ };
+ } else {
+ txt_ $r->{reason};
+ div_ class => 'quote', sub { lit_ bb_format $r->{message} } if $r->{message};
+ }
+ };
+ td_ style => 'width: 300px', sub {
+ form_ method => 'post', action => '/report/edit', sub {
+ input_ type => 'hidden', name => 'id', value => $r->{id};
+ input_ type => 'hidden', name => 'url', value => $url;
+ textarea_ name => 'comment', rows => 2, cols => 25, style => 'width: 290px', placeholder => 'Mod comment... (optional)', '';
+ br_;
+ input_ type => 'submit', class => 'submit', value => 'Post';
+ txt_ ' & ';
+ input_ type => 'submit', class => 'submit', name => 'status', value => $_, $_ eq $r->{status} ? (style => 'font-weight: bold') : () for @STATUS;
+ };
+ };
+ td_ sub {
+ lit_ bb_format $r->{log};
+ my $status = $r->{log} =~ /$STATUSRE -> ($STATUSRE).*$/ ? $1 : 'new';
+ for ($r->{elog}->@*) {
+ txt_ fmtdate $_->{date}, 'full';
+ small_ ' <';
+ user_ $_;
+ small_ '> ';
+ em_ "$status -> $_->{status}. " if $status ne $_->{status};
+ $status = $_->{status};
+ lit_ bb_format $_->{message};
+ br_;
+ }
+ };
+}
+
+
+TUWF::get qr{/report/list}, sub {
+ return tuwf->resDenied if !auth->isMod;
+
+ my $opt = tuwf->validate(get =>
+ p => { upage => 1 },
+ s => { enum => ['id','lastmod'], default => 'id' },
+ status => { enum => \@STATUS, default => undef },
+ id => { id => 1, default => undef },
+ )->data;
+
+ my $where = sql_and
+ $opt->{id} ? sql 'r.id =', \$opt->{id} : (),
+ $opt->{status} ? sql 'r.status =', \$opt->{status} : (),
+ $opt->{s} eq 'lastmod' ? 'r.lastmod IS NOT NULL' : ();
+
+ my $cnt = tuwf->dbVali('SELECT count(*) FROM reports r WHERE', $where);
+ my $lst = tuwf->dbPagei({results => 25, page => $opt->{p}},
+ 'SELECT r.id,', sql_totime('r.date'), 'as date, r.uid, ur.username, fmtip(r.ip) as ip, r.reason, r.status, r.message, r.log
+ , r.object, r.objectnum, x.title, x.uid as by_uid,', sql_user('uo'), '
+ FROM reports r
+ LEFT JOIN', item_info('r.object', 'r.objectnum'), 'x ON true
+ LEFT JOIN users ur ON ur.id = r.uid
+ LEFT JOIN users uo ON uo.id = x.uid
+ WHERE', $where, '
+ ORDER BY', {id => 'r.id DESC', lastmod => 'r.lastmod DESC'}->{$opt->{s}}
+ );
+ enrich elog => id => id => sub { sql '
+ SELECT l.id, l.status, l.message, ', sql_totime('l.date'), 'date,', sql_user(), '
+ FROM reports_log l
+ LEFT JOIN users u ON u.id = l.uid
+ WHERE l.id IN', $_[0], '
+ ORDER BY l.date'
+ }, $lst;
+
+ tuwf->dbExeci(
+ 'UPDATE users_prefs SET last_reports = NOW()
+ WHERE (last_reports IS NULL OR EXISTS(SELECT 1 FROM reports WHERE lastmod > last_reports OR date > last_reports))
+ AND id =', \auth->uid
+ );
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ framework_ title => 'Reports', sub {
+ article_ sub {
+ h1_ 'Reports';
+ p_ 'Welcome to the super advanced reports handling interface. Reports can have the following statuses:';
+ ul_ sub {
+ li_ 'New: Default status for newly submitted reports';
+ li_ 'Busy: You can use this state to indicate that you\'re working on it.';
+ li_ 'Done: Report handled.';
+ li_ 'Dismissed: Report ignored.';
+ };
+ p_ q{
+ There's no flowchart you have to follow, if you can quickly handle a report you can go directly from 'New' to 'Done' or 'Dismissed'.
+ If you want to bring an older report to other's attention you can go back from any existing state to 'New'.
+ };
+ p_ q{
+ Feel free to skip over reports that you can't or don't want to handle, someone else will eventually pick it up.
+ };
+ p_ q{
+ Changing the status and/or adding a comment will add an entry to the log, so other mods can see what is going on. Everything on this page is only visible to moderators.
+ };
+ p_ q{
+ BUG: Deleting the last post from a thread (not "hiding", but actually deleting it) will cause the report
+ to refer to an innocent post when someone adds a new post to that thread, as the reply will get the same number as the deleted post.
+ Not a huge problem, but something to be aware of when browsing through handled reports.
+ };
+ br_;
+ br_;
+ p_ class => 'browseopts', sub {
+ a_ href => url(p => undef, status => undef), !$opt->{status} ? (class => 'optselected') : (), 'All';
+ a_ href => url(p => undef, status => $_), $opt->{status} && $opt->{status} eq $_ ? (class => 'optselected') : (), ucfirst $_ for @STATUS;
+ };
+ p_ class => 'browseopts', sub {
+ txt_ 'Sort by ';
+ a_ href => url(p => undef, s => 'id'), $opt->{s} eq 'id' ? (class => 'optselected') : (), 'newest';
+ a_ href => url(p => undef, s => 'lastmod'), $opt->{s} eq 'lastmod' ? (class => 'optselected') : (), 'last updated';
+ };
+ };
+
+ paginate_ \&url, $opt->{p}, [$cnt, 25], 't';
+ article_ class => 'thread', sub {
+ table_ class => 'stripe', sub {
+ my $url = '/report/list'.url;
+ tr_ sub { report_ $_, $url } for @$lst;
+ tr_ sub { td_ style => 'text-align: center', 'Nothing to report! (heh)' } if !@$lst;
+ };
+ };
+ paginate_ \&url, $opt->{p}, [$cnt, 25], 'b';
+ };
+};
+
+
+TUWF::post qr{/report/edit}, sub {
+ return tuwf->resDenied if !auth->isMod;
+ my $frm = tuwf->validate(post =>
+ id => { id => 1 },
+ url => { regex => qr{^/report/list} },
+ status => { enum => \@STATUS, default => undef },
+ comment => { default => '' },
+ )->data;
+ my $r = tuwf->dbRowi('SELECT id, status FROM reports WHERE id =', \$frm->{id});
+ return tuwf->resNotFound if !$r->{id};
+
+ if(($frm->{status} && $r->{status} ne $frm->{status}) || length $frm->{comment}) {
+ tuwf->dbExeci('UPDATE reports SET', {
+ lastmod => sql('NOW()'),
+ $frm->{status} ? (status => $frm->{status}) : (),
+ }, 'WHERE id =', \$r->{id});
+ tuwf->dbExeci('INSERT INTO reports_log', {
+ id => $r->{id}, uid => auth->uid,
+ status => $frm->{status}//$r->{status}, message => $frm->{comment}
+ });
+ }
+ tuwf->resRedirect($frm->{url}, 'post');
+};
+
+1;
diff --git a/lib/VNWeb/Prelude.pm b/lib/VNWeb/Prelude.pm
index 3f5d5f67..f422aa50 100644
--- a/lib/VNWeb/Prelude.pm
+++ b/lib/VNWeb/Prelude.pm
@@ -4,25 +4,27 @@
# use warnings;
# use utf8;
#
-# use TUWF ':html5_', 'mkclass';
+# use TUWF ':html5_', 'mkclass', 'xml_string', 'xml_escape';
# use Exporter 'import';
# use Time::HiRes 'time';
# use List::Util 'min', 'max', 'sum';
-# use POSIX 'ceil', 'floor';
+# use POSIX 'ceil', 'floor', 'strftime';
#
-# use VNDBUtil;
# use VNDB::BBCode;
# use VNDB::Types;
# use VNDB::Config;
-# use VNDB::Func 'fmtdate', 'fmtage', 'fmtvote', 'fmtspoil', 'query_encode';
+# use VNDB::Func;
# use VNDB::ExtLinks;
# use VNWeb::Auth;
# use VNWeb::HTML;
# use VNWeb::DB;
# use VNWeb::Validation;
+# use VNWeb::JS;
# use VNWeb::Elm;
+# use VNWeb::TableOpts;
+# use VNWeb::TitlePrefs;
#
-# + A few other handy tools.
+# + A handy dbobj() function.
#
# WARNING: This should not be used from the above modules.
package VNWeb::Prelude;
@@ -33,8 +35,8 @@ use feature ':5.26';
use utf8;
use VNWeb::Elm;
use VNWeb::Auth;
+use VNWeb::DB;
use TUWF;
-use JSON::XS;
sub import {
@@ -48,67 +50,46 @@ sub import {
die $@ if !eval <<" EOM;";
package $c;
- use TUWF ':html5_', 'mkclass';
+ use TUWF ':html5_', 'mkclass', 'xml_string', 'xml_escape';
use Exporter 'import';
use Time::HiRes 'time';
use List::Util 'min', 'max', 'sum';
- use POSIX 'ceil', 'floor';
+ use POSIX 'ceil', 'floor', 'strftime';
- use VNDBUtil;
use VNDB::BBCode;
use VNDB::Types;
use VNDB::Config;
- use VNDB::Func 'fmtdate', 'fmtage', 'fmtvote', 'fmtspoil', 'query_encode';
+ use VNDB::Func;
use VNDB::ExtLinks;
use VNWeb::Auth;
use VNWeb::HTML;
use VNWeb::DB;
use VNWeb::Validation;
+ use VNWeb::JS;
use VNWeb::Elm;
+ use VNWeb::TableOpts;
+ use VNWeb::TitlePrefs;
1;
EOM;
no strict 'refs';
- *{$c.'::RE'} = *RE;
- *{$c.'::in'} = \&in;
+ *{$c.'::dbobj'} = \&dbobj;
}
-# Regular expressions for use in path registration
-my $num = qr{[1-9][0-9]{0,8}};
-my $id = qr{(?<id>$num)};
-my $rev = qr{(?:\.(?<rev>$num))};
-our %RE = (
- num => qr{(?<num>$num)},
- uid => qr{u$id},
- vid => qr{v$id},
- rid => qr{r$id},
- sid => qr{s$id},
- cid => qr{c$id},
- pid => qr{p$id},
- iid => qr{i$id},
- did => qr{d$id},
- tid => qr{t$id},
- vrev => qr{v$id$rev?},
- rrev => qr{r$id$rev?},
- prev => qr{p$id$rev?},
- srev => qr{s$id$rev?},
- crev => qr{c$id$rev?},
- drev => qr{d$id$rev?},
- postid => qr{t$id\.(?<num>$num)},
-);
+# Returns very generic information on a DB entry object.
+# Suitable for passing to HTML::framework_'s dbobj argument.
+sub dbobj {
+ my($id) = @_;
+ return undef if !$id;
+ if($id =~ /^u/) {
+ my $o = tuwf->dbRowi('SELECT id, username IS NULL AS entry_hidden,', sql_user(), 'FROM users u WHERE id =', \$id);
+ $o->{title} = [(undef, VNWeb::HTML::user_displayname $o)x2];
+ return $o;
+ }
-# Simple "is this element in the array?" function, using 'eq' to test equality.
-# Supports both an @array and \@array.
-# Usage:
-#
-# my $contains_hi = in 'hi', qw/ a b hi c /; # true
-#
-sub in {
- my($q, @a) = @_;
- $_ eq $q && return 1 for map ref $_ eq 'ARRAY' ? @$_ : ($_), @a;
- 0
+ tuwf->dbRowi('SELECT', \$id, 'AS id, title, hidden AS entry_hidden, locked AS entry_locked FROM', VNWeb::TitlePrefs::item_info(\$id, 'NULL'), ' x');
}
1;
diff --git a/lib/VNWeb/Producers/Edit.pm b/lib/VNWeb/Producers/Edit.pm
new file mode 100644
index 00000000..56df8aa3
--- /dev/null
+++ b/lib/VNWeb/Producers/Edit.pm
@@ -0,0 +1,114 @@
+package VNWeb::Producers::Edit;
+
+use VNWeb::Prelude;
+
+
+my $FORM = {
+ id => { default => undef, vndbid => 'p' },
+ type => { default => 'co', enum => \%PRODUCER_TYPE },
+ name => { sl => 1, maxlength => 200 },
+ latin => { default => undef, sl => 1, maxlength => 200 },
+ alias => { default => '', maxlength => 500 },
+ lang => { enum => \%LANGUAGE },
+ website => { default => '', weburl => 1 },
+ l_wikidata => { default => undef, uint => 1, max => (1<<31)-1 },
+ description => { default => '', maxlength => 5000 },
+ relations => { sort_keys => 'pid', aoh => {
+ pid => { vndbid => 'p' },
+ relation => { enum => \%PRODUCER_RELATION },
+ name => { _when => 'out' },
+ } },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_CMP = form_compile cmp => $FORM;
+
+
+TUWF::get qr{/$RE{prev}/edit} => sub {
+ my $e = db_entry tuwf->captures('id', 'rev') or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit p => $e;
+
+ $e->{editsum} = $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
+
+ enrich_merge pid => sql('SELECT id AS pid, title[1+1] AS name FROM', producerst, 'p WHERE id IN'), $e->{relations};
+
+ my $title = titleprefs_swap @{$e}{qw/ lang name latin /};
+ framework_ title => "Edit $title->[1]", dbobj => $e, tab => 'edit',
+ sub {
+ editmsg_ p => $e, "Edit $title->[1]";
+ div_ widget(ProducerEdit => $FORM_OUT, $e), '';
+ };
+};
+
+
+TUWF::get qr{/p/add}, sub {
+ return tuwf->resDenied if !can_edit p => undef;
+
+ framework_ title => 'Add producer',
+ sub {
+ editmsg_ p => undef, 'Add producer';
+ div_ widget(ProducerEdit => $FORM_OUT, elm_empty $FORM_OUT), '';
+ };
+};
+
+
+js_api ProducerEdit => $FORM_IN, sub {
+ my $data = shift;
+ my $new = !$data->{id};
+ my $e = $new ? { id => 0 } : db_entry $data->{id} or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit p => $e;
+
+ if(!auth->permDbmod) {
+ $data->{hidden} = $e->{hidden}||0;
+ $data->{locked} = $e->{locked}||0;
+ }
+ $data->{description} = bb_subst_links $data->{description};
+ $data->{alias} =~ s/\n\n+/\n/;
+
+ $data->{relations} = [] if $data->{hidden};
+ validate_dbid 'SELECT id FROM producers WHERE id IN', map $_->{pid}, $data->{relations}->@*;
+ die "Relation with self" if grep $_->{pid} eq $e->{id}, $data->{relations}->@*;
+
+ return +{ _err => 'No changes.' } if !$new && !form_changed $FORM_CMP, $data, $e;
+ my $ch = db_edit p => $e->{id}, $data;
+ update_reverse($ch->{nitemid}, $ch->{nrev}, $e, $data);
+ +{ _redir => "/$ch->{nitemid}.$ch->{nrev}" };
+};
+
+
+sub update_reverse {
+ my($id, $rev, $old, $new) = @_;
+
+ my %old = map +($_->{pid}, $_), $old->{relations} ? $old->{relations}->@* : ();
+ my %new = map +($_->{pid}, $_), $new->{relations}->@*;
+
+ # Updates to be performed, pid => { pid => x, relation => y } or undef if the relation should be removed.
+ my %upd;
+
+ for my $i (keys %old, keys %new) {
+ if($old{$i} && !$new{$i}) {
+ $upd{$i} = undef;
+ } elsif(!$old{$i} || $old{$i}{relation} ne $new{$i}{relation}) {
+ $upd{$i} = {
+ pid => $id,
+ relation => $PRODUCER_RELATION{ $new{$i}{relation} }{reverse},
+ };
+ }
+ }
+
+ for my $i (keys %upd) {
+ my $e = db_entry $i;
+ $e->{relations} = [
+ $upd{$i} ? $upd{$i} : (),
+ grep $_->{pid} ne $id, $e->{relations}->@*
+ ];
+ $e->{editsum} = "Reverse relation update caused by revision $id.$rev";
+ db_edit p => $i, $e, 'u1';
+ }
+}
+
+1;
diff --git a/lib/VNWeb/Producers/Elm.pm b/lib/VNWeb/Producers/Elm.pm
new file mode 100644
index 00000000..cde3bd39
--- /dev/null
+++ b/lib/VNWeb/Producers/Elm.pm
@@ -0,0 +1,34 @@
+package VNWeb::Producers::Elm;
+
+use VNWeb::Prelude;
+
+elm_api Producers => undef, {
+ search => { type => 'array', values => { searchquery => 1 } },
+}, sub {
+ my($data) = @_;
+ my @q = grep $_, $data->{search}->@*;
+
+ elm_ProducerResult @q ? tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT p.id, p.title[1+1] AS name, p.title[1+1+1+1] AS altname
+ FROM', producerst, 'p', VNWeb::Validate::SearchQuery::sql_joina(\@q, 'p', 'p.id'), '
+ WHERE NOT p.hidden
+ ORDER BY sc.score DESC, p.sorttitle
+ ') : [];
+};
+
+js_api Producers => {
+ search => { type => 'array', values => { searchquery => 1 } },
+}, sub {
+ my($data) = @_;
+ my @q = grep $_, $data->{search}->@*;
+
+ +{ results => @q ? tuwf->dbAlli(
+ 'SELECT p.id, p.title[1+1] AS name, p.title[1+1+1+1] AS altname
+ FROM', producerst, 'p', VNWeb::Validate::SearchQuery::sql_joina(\@q, 'p', 'p.id'), '
+ WHERE NOT p.hidden
+ ORDER BY sc.score DESC, p.sorttitle
+ LIMIT', \30
+ ) : [] };
+};
+
+1;
diff --git a/lib/VNWeb/Producers/Graph.pm b/lib/VNWeb/Producers/Graph.pm
new file mode 100644
index 00000000..4ac14c62
--- /dev/null
+++ b/lib/VNWeb/Producers/Graph.pm
@@ -0,0 +1,72 @@
+package VNWeb::Producers::Graph;
+
+use VNWeb::Prelude;
+use VNWeb::Graph;
+
+
+TUWF::get qr{/$RE{pid}/rg}, sub {
+ my $num = tuwf->validate(get => num => { uint => 1, onerror => 15 })->data;
+ my $p = dbobj tuwf->capture(1);
+
+ # Big list of { id0, id1, relation } hashes.
+ # Each relation is included twice, with id0 and id1 reversed.
+ my $rel = tuwf->dbAlli(q{
+ WITH RECURSIVE rel(id0, id1, relation) AS (
+ SELECT id, pid, relation FROM producers_relations WHERE id =}, \$p->{id}, q{
+ UNION
+ SELECT id, pid, pr.relation FROM producers_relations pr JOIN rel r ON pr.id = r.id1
+ ) SELECT * FROM rel ORDER BY id0
+ });
+ return tuwf->resNotFound if !@$rel;
+
+ # Fetch the nodes
+ my $nodes = gen_nodes $p->{id}, $rel, $num;
+ enrich_merge id => sql('SELECT id, title[1+1] AS name, lang, type FROM', producerst, 'p WHERE id IN'), values %$nodes;
+
+ my $total_nodes = keys { map +($_->{id0},1), @$rel }->%*;
+ my $visible_nodes = keys %$nodes;
+
+ my @lines;
+ my $params = $num == 15 ? '' : "?num=$num";
+ for my $n (sort { idcmp $a->{id}, $b->{id} } values %$nodes) {
+ my $name = val_escape shorten $n->{name}, 27;
+ my $tooltip = val_escape $n->{name};
+ my $nodeid = $n->{distance} == 0 ? 'id = "graph_current", ' : '';
+ push @lines,
+ qq|n$n->{id} [ $nodeid URL = "/$n->{id}", tooltip = "$tooltip", label=<|.
+ qq|<TABLE CELLSPACING="0" CELLPADDING="2" BORDER="0" CELLBORDER="1" BGCOLOR="#222222">|.
+ qq|<TR><TD COLSPAN="2" ALIGN="CENTER" CELLPADDING="3"><FONT POINT-SIZE="9"> $name </FONT></TD></TR>|.
+ qq|<TR><TD ALIGN="CENTER"> $LANGUAGE{$n->{lang}}{txt} </TD><TD ALIGN="CENTER"> $PRODUCER_TYPE{$n->{type}} </TD></TR>|.
+ qq|</TABLE>> ]|;
+
+ push @lines, node_more $n->{id}, "/$n->{id}/rg$params", scalar grep !$nodes->{$_}, $n->{rels}->@*;
+ }
+
+ $rel = [ grep $nodes->{$_->{id0}} && $nodes->{$_->{id1}}, @$rel ];
+ my $dot = gen_dot \@lines, $nodes, $rel, \%PRODUCER_RELATION;
+
+ framework_ title => "Relations for $p->{title}[1]", dbobj => $p, tab => 'rg',
+ sub {
+ article_ class => 'relgraph', sub {
+ h1_ "Relations for $p->{title}[1]";
+ p_ sub {
+ txt_ sprintf "Displaying %d out of %d related producers.", $visible_nodes, $total_nodes;
+ debug_ +{ nodes => $nodes, rel => $rel };
+ br_;
+ txt_ "Adjust graph size: ";
+ join_ ', ', sub {
+ if($_ == min $num, $total_nodes) {
+ txt_ $_ ;
+ } else {
+ a_ href => "/$p->{id}/rg?num=$_", $_;
+ }
+ }, grep($_ < $total_nodes, 10, 15, 25, 50, 75, 100, 150, 250, 500, 750, 1000), $total_nodes;
+ txt_ '.';
+ } if $total_nodes > 10;
+ p_ class => 'center', sub { lit_ dot2svg $dot };
+ };
+ clearfloat_;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Producers/List.pm b/lib/VNWeb/Producers/List.pm
new file mode 100644
index 00000000..4b8112f0
--- /dev/null
+++ b/lib/VNWeb/Producers/List.pm
@@ -0,0 +1,75 @@
+package VNWeb::Producers::List;
+
+use VNWeb::Prelude;
+use VNWeb::AdvSearch;
+
+
+sub listing_ {
+ my($opt, $list, $count) = @_;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ paginate_ \&url, $opt->{p}, [$count, 150], 't';
+ article_ class => 'producerbrowse', sub {
+ h1_ $opt->{q} ? 'Search results' : 'Browse producers';
+ ul_ sub {
+ li_ sub {
+ abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, '';
+ a_ href => "/$_->{id}", tattr $_;
+ } 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 => { searchquery => 1 },
+ f => { advsearch_err => 'p' },
+ ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
+ )->data;
+ $opt->{ch} = $opt->{ch}[0];
+
+ # compat with old URLs
+ my $oldch = tuwf->capture('char');
+ $opt->{ch} //= $oldch if defined $oldch && $oldch ne 'all';
+
+ $opt->{f} = advsearch_default 'p' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
+
+ my $where = sql_and 'NOT p.hidden', $opt->{f}->sql_where(),
+ defined($opt->{ch}) ? sql 'match_firstchar(p.sorttitle, ', \$opt->{ch}, ')' : ();
+
+ my $time = time;
+ my($count, $list);
+ db_maytimeout {
+ $count = tuwf->dbVali('SELECT COUNT(*) FROM', producerst, 'p WHERE', sql_and $where, $opt->{q}->sql_where('p', 'p.id'));
+ $list = $count ? tuwf->dbPagei({ results => 150, page => $opt->{p} },
+ 'SELECT p.id, p.title, p.lang
+ FROM', producerst, 'p', $opt->{q}->sql_join('p', 'p.id'), '
+ WHERE', $where, '
+ ORDER BY', $opt->{q} ? 'sc.score DESC, ' : (), 'p.sorttitle'
+ ) : [];
+ } || (($count, $list) = (undef, []));
+ $time = time - $time;
+
+ framework_ title => 'Browse producers', sub {
+ article_ sub {
+ h1_ 'Browse producers';
+ form_ action => '/p', method => 'get', sub {
+ searchbox_ p => $opt->{q};
+ p_ class => 'browseopts', sub {
+ button_ type => 'submit', name => 'ch', value => ($_//''), ($_//'') eq ($opt->{ch}//'') ? (class => 'optselected') : (), !defined $_ ? 'ALL' : $_ ? uc $_ : '#'
+ for (undef, 'a'..'z', 0);
+ };
+ input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
+ $opt->{f}->elm_($count, $time);
+ };
+ };
+ listing_ $opt, $list, $count if $count;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Producers/Page.pm b/lib/VNWeb/Producers/Page.pm
new file mode 100644
index 00000000..5453d777
--- /dev/null
+++ b/lib/VNWeb/Producers/Page.pm
@@ -0,0 +1,183 @@
+package VNWeb::Producers::Page;
+
+use VNWeb::Prelude;
+use VNWeb::Releases::Lib;
+use VNWeb::ULists::Lib;
+
+
+sub enrich_item {
+ my($p) = @_;
+ enrich_extlinks p => 0, $p;
+ enrich_merge pid => sql('SELECT id AS pid, title, sorttitle FROM', producerst, 'p WHERE id IN'), $p->{relations};
+ $p->{relations} = [ sort { $a->{sorttitle} cmp $b->{sorttitle} || idcmp($a->{pid}, $b->{pid}) } $p->{relations}->@* ];
+}
+
+
+sub rev_ {
+ my($p) = @_;
+ revision_ $p, \&enrich_item,
+ [ name => 'Name' ],
+ [ latin => 'Name (latin)' ],
+ [ alias => 'Aliases' ],
+ [ description=> 'Description' ],
+ [ type => 'Type', fmt => \%PRODUCER_TYPE ],
+ [ lang => 'Language', fmt => \%LANGUAGE ],
+ [ relations => 'Relations', fmt => sub {
+ txt_ $PRODUCER_RELATION{$_->{relation}}{txt}.': ';
+ a_ href => "/$_->{pid}", tattr $_;
+ } ],
+ revision_extlinks 'p'
+}
+
+
+sub info_ {
+ my($p) = @_;
+
+ p_ class => 'center', sub {
+ txt_ $PRODUCER_TYPE{$p->{type}};
+ br_;
+ txt_ "Primary language: $LANGUAGE{$p->{lang}}{txt}";
+ if(length $p->{alias}) {
+ br_;
+ txt_ 'a.k.a. ';
+ txt_ $p->{alias} =~ s/\n/, /gr;
+ }
+ br_ if $p->{extlinks}->@*;
+ join_ ' - ', sub { a_ href => $_->{url2}, $_->{label} }, $p->{extlinks}->@*;
+ };
+
+ p_ class => 'center', sub {
+ my %rel;
+ push $rel{$_->{relation}}->@*, $_ for $p->{relations}->@*;
+ br_;
+ join_ \&br_, sub {
+ txt_ $PRODUCER_RELATION{$_}{txt}.': ';
+ join_ ', ', sub { a_ href => "/$_->{pid}", tattr $_ }, $rel{$_}->@*;
+ }, grep $rel{$_}, keys %PRODUCER_RELATION;
+ } if $p->{relations}->@*;
+
+ div_ class => 'description', sub { lit_ bb_format $p->{description} } if length $p->{description};
+}
+
+
+sub rel_ {
+ my($p) = @_;
+
+ my $r = tuwf->dbAlli('
+ SELECT r.id, r.patch, r.released, r.gtin, rp.publisher, rp.developer, ', sql_extlinks(r => 'r.'), '
+ FROM releases r
+ JOIN releases_producers rp ON rp.id = r.id
+ WHERE rp.pid =', \$p->{id}, ' AND NOT r.hidden
+ ORDER BY r.released
+ ');
+ $_->{rtype} = 1 for @$r; # prevent enrich_release() from fetching rtypes
+ enrich_extlinks r => 0, $r;
+ enrich_release $r;
+ enrich vn => id => rid => sub { sql '
+ SELECT rv.id as rid, rv.rtype, v.id, v.title
+ FROM', vnt, 'v
+ JOIN releases_vn rv ON rv.vid = v.id
+ WHERE NOT v.hidden AND rv.id IN', $_, '
+ ORDER BY v.title
+ '}, $r;
+
+ my(%vn, @vn);
+ for my $rel (@$r) {
+ for ($rel->{vn}->@*) {
+ push @vn, $_ if !$vn{$_->{id}};
+ push $vn{$_->{id}}->@*, [ $_->{rtype}, $rel ];
+ }
+ }
+ enrich_ulists_widget \@vn;
+
+ h1_ 'Releases';
+ debug_ $r;
+ table_ class => 'releases', sub {
+ for my $v (@vn) {
+ tr_ class => 'vn', sub {
+ td_ colspan => 8, sub {
+ ulists_widget_ $v;
+ a_ href => "/$v->{id}", tattr $v;
+ };
+ my $ropt = { id => $v->{id}, prod => 1 };
+ release_row_ $_, $ropt for sort_releases(
+ [ map { $_->[1]{rtype} = $_->[0]; $_->[1] } $vn{$v->{id}}->@* ]
+ )->@*;
+ };
+ }
+ } if @$r;
+ p_ 'This producer has no releases in the database.' if !@$r;
+}
+
+
+sub vns_ {
+ my($p) = @_;
+ my $v = tuwf->dbAlli(q{
+ SELECT v.id, v.title, rels.developer, rels.publisher, rels.released
+ FROM}, vnt, q{v
+ JOIN (
+ SELECT rv.vid, bool_or(rp.developer), bool_or(rp.publisher)
+ , COALESCE(MIN(r.released) FILTER(WHERE rv.rtype <> 'trial'), MIN(r.released))
+ FROM releases_vn rv
+ JOIN releases r ON r.id = rv.id
+ JOIN releases_producers rp ON rp.id = rv.id
+ WHERE NOT r.hidden AND rp.pid =}, \$p->{id}, '
+ GROUP BY rv.vid
+ ) rels(vid, developer, publisher, released) ON rels.vid = v.id
+ WHERE NOT v.hidden
+ ORDER BY rels.released, v.sorttitle
+ ');
+
+ h1_ 'Visual Novels';
+ debug_ $v;
+ enrich_ulists_widget $v;
+ # TODO: Perhaps something more table-like, also showing languages, platforms & VN list status
+ ul_ class => 'prodvns', sub {
+ li_ sub {
+ span_ sub { rdate_ $_->{released} };
+ ulists_widget_ $_;
+ a_ href => "/$_->{id}", tattr $_;
+ span_ join ' & ',
+ $_->{publisher} ? 'Publisher' : (),
+ $_->{developer} ? 'Developer' : ();
+ } for @$v;
+ };
+ p_ 'This producer has no releases in the database.' if !@$v;
+}
+
+
+TUWF::get qr{/$RE{prev}(?:/(?<tab>vn|rel))?}, sub {
+ my $p = db_entry tuwf->captures('id', 'rev');
+ return tuwf->resNotFound if !$p;
+ enrich_item $p;
+
+ my $tab = tuwf->capture('tab')
+ || (auth && (tuwf->dbVali('SELECT prodrelexpand FROM users_prefs WHERE id=', \auth->uid) ? 'rel' : 'vn'))
+ || 'rel';
+
+ my $title = titleprefs_swap @{$p}{qw/ lang name latin /};
+ framework_ title => $title->[1], index => !tuwf->capture('rev'), dbobj => $p, hiddenmsg => 1,
+ og => {
+ title => $title->[1],
+ description => bb_format($p->{description}, text => 1),
+ },
+ sub {
+ rev_ $p if tuwf->capture('rev');
+ article_ sub {
+ itemmsg_ $p;
+ h1_ tlang(@{$title}[0,1]), $title->[1];
+ h2_ class => 'alttitle', tlang(@{$title}[2,3]), $title->[3] if $title->[3] && $title->[3] ne $title->[1];
+ info_ $p;
+ };
+ nav_ class => 'right', sub {
+ menu_ sub {
+ li_ mkclass(tabselected => $tab eq 'vn'), sub { a_ href => "/$p->{id}/vn", 'Visual Novels' };
+ li_ mkclass(tabselected => $tab eq 'rel'), sub { a_ href => "/$p->{id}/rel", 'Releases' };
+ };
+ };
+ article_ sub { rel_ $p } if $tab eq 'rel';
+ article_ sub { vns_ $p } if $tab eq 'vn';
+ }
+};
+
+1;
diff --git a/lib/VNWeb/Releases/DRM.pm b/lib/VNWeb/Releases/DRM.pm
new file mode 100644
index 00000000..7ac7add3
--- /dev/null
+++ b/lib/VNWeb/Releases/DRM.pm
@@ -0,0 +1,120 @@
+package VNWeb::Releases::DRM;
+
+use VNWeb::Prelude;
+use TUWF 'uri_escape';
+
+TUWF::get '/r/drm', sub {
+ my $opt = tuwf->validate(get =>
+ n => { onerror => '' },
+ s => { onerror => '' },
+ t => { onerror => undef, enum => [0,1,2] },
+ u => { anybool => 1 },
+ )->data;
+ my $where = sql_and
+ $opt->{s} ? sql 'name ILIKE', \('%'.sql_like($opt->{s}).'%') : (),
+ defined $opt->{t} ? sql 'state =', \$opt->{t} : ();
+
+ my $lst = tuwf->dbAlli('
+ SELECT id, state, name, description, c_ref, ', sql_comma(keys %DRM_PROPERTY), '
+ FROM drm
+ WHERE', $where, $opt->{u} ? () : 'AND c_ref > 0',
+ 'ORDER BY c_ref DESC
+ ');
+ my $missing = $opt->{u} ? 0 : tuwf->dbVali('SELECT COUNT(*) FROM drm WHERE', $where, 'AND c_ref = 0');
+
+ framework_ title => 'List of DRM implementations', sub {
+ article_ sub {
+ h1_ 'List of DRM implementations';
+ form_ action => '/r/drm', method => 'get', sub {
+ fieldset_ class => 'search', sub {
+ input_ type => 'text', name => 's', id => 's', class => 'text', value => $opt->{s};
+ input_ type => 'submit', class => 'submit', value => 'Search!';
+ }
+ };
+ my sub opt_ {
+ my($k,$v,$lbl) = @_;
+ a_ href => '?'.query_encode(%$opt,$k=>$v), defined $opt->{$k} eq defined $v && (!defined $v || $opt->{$k} == $v) ? (class => 'optselected') : (), $lbl;
+ }
+ p_ class => 'browseopts', sub {
+ a_ href => '?'.query_encode(%$opt,t=>undef), !defined $opt->{t} ? (class => 'optselected') : (), 'All';
+ a_ href => '?'.query_encode(%$opt,t=>0), defined $opt->{t} && $opt->{t} == 0 ? (class => 'optselected') : (), 'New';
+ a_ href => '?'.query_encode(%$opt,t=>1), defined $opt->{t} && $opt->{t} == 1 ? (class => 'optselected') : (), 'Approved';
+ a_ href => '?'.query_encode(%$opt,t=>2), defined $opt->{t} && $opt->{t} == 2 ? (class => 'optselected') : (), 'Deleted';
+ };
+ my $unused = 0;
+ section_ class => 'drmlist', sub {
+ my $d = $_;
+ h2_ !$d->{c_ref} && !$unused++ ? (id => 'unused') : (), sub {
+ span_ class => 'strikethrough', $d->{name} if $d->{state} == 2;
+ txt_ $d->{name} if $d->{state} != 2;
+ a_ href => '/r?f='.tuwf->compile({advsearch => 'r'})->validate(['drm','=',$d->{name}])->data->query_encode, " ($d->{c_ref})";
+ b_ ' (new)' if $d->{state} == 0;
+ a_ href => "/r/drm/edit/$d->{id}?ref=".uri_escape(query_encode(%$opt)), ' edit' if auth->permDbmod;
+ };
+ my @prop = grep $d->{$_}, keys %DRM_PROPERTY;
+ p_ sub {
+ join_ ' ', sub {
+ abbr_ class => "icon-drm-$_", title => $DRM_PROPERTY{$_}, '';
+ txt_ $DRM_PROPERTY{$_};
+ }, @prop;
+ if (!@prop) {
+ abbr_ class => 'icon-drm-free', title => 'DRM-free', '';
+ txt_ 'DRM-free';
+ }
+ };
+ div_ sub { lit_ bb_format $d->{description} if $d->{description} };
+ } for @$lst;
+ p_ class => 'center', sub {
+ txt_ "$missing unused DRM type(s) not shown. ";
+ a_ href => '?'.query_encode(%$opt,u=>1).'#unused', 'Show all';
+ } if $missing;
+ };
+ };
+};
+
+
+my $FORM = form_compile any => {
+ id => { uint => 1 },
+ state => { uint => 1, range => [0,2] },
+ name => { sl => 1, maxlength => 128 },
+ description => { default => '', maxlength => 10240 },
+ ref => { default => '' },
+ map +($_,{anybool=>1}), keys %DRM_PROPERTY
+};
+
+
+sub info_ {
+ tuwf->dbRowi('
+ SELECT id, state, name, description,', sql_comma(keys %DRM_PROPERTY), '
+ FROM drm WHERE id =', \shift
+ );
+}
+
+TUWF::get qr{/r/drm/edit/(0|$RE{num})}, sub {
+ return tuwf->resDenied if !auth->permDbmod;
+ my $d = info_ tuwf->capture(1);
+ return tuwf->resNotFound if !defined $d->{id};
+ $d->{ref} = tuwf->reqGet('ref');
+ framework_ title => "Edit DRM: $d->{name}", sub {
+ div_ widget(DRMEdit => $FORM, $d), '';
+ };
+};
+
+js_api DRMEdit => $FORM, sub {
+ my $data = shift;
+ return tuwf->resDenied if !auth->permDbmod;
+ my $d = info_ delete $data->{id};
+ return tuwf->resNotFound if !defined $d->{id};
+ my $ref = delete $data->{ref};
+
+ return +{ _er => 'Duplicate DRM name' }
+ if tuwf->dbVali('SELECT 1 FROM drm WHERE id <>', \$d->{id}, 'AND name =', \$d->{name});
+
+ tuwf->dbExeci('UPDATE drm SET', $data, 'WHERE id =', \$d->{id});
+
+ my @diff = grep $d->{$_} ne $data->{$_}, qw/state name description/, keys %DRM_PROPERTY;
+ auth->audit(undef, 'drm edit', join '; ', map "$_: $d->{$_} -> $data->{$_}", @diff) if @diff;
+ +{ _redir => "/r/drm?$ref" };
+};
+
+1;
diff --git a/lib/VNWeb/Releases/Edit.pm b/lib/VNWeb/Releases/Edit.pm
new file mode 100644
index 00000000..b004b7e1
--- /dev/null
+++ b/lib/VNWeb/Releases/Edit.pm
@@ -0,0 +1,220 @@
+package VNWeb::Releases::Edit;
+
+use VNWeb::Prelude;
+
+
+my $FORM = {
+ id => { default => undef, vndbid => 'r' },
+ official => { anybool => 1 },
+ patch => { anybool => 1 },
+ freeware => { anybool => 1 },
+ doujin => { anybool => 1 },
+ has_ero => { anybool => 1 },
+ titles => { minlength => 1, sort_keys => 'lang', aoh => {
+ lang => { enum => \%LANGUAGE },
+ mtl => { anybool => 1 },
+ title => { default => undef, sl => 1, maxlength => 300 },
+ latin => { default => undef, sl => 1, maxlength => 300 },
+ } },
+ # Titles fetched from the VN entry, for auto-filling
+ vntitles => { _when => 'out', aoh => {
+ lang => {},
+ title => {},
+ latin => { default => undef },
+ } },
+ olang => { enum => \%LANGUAGE, default => 'ja' },
+ platforms => { aoh => { platform => { enum => \%PLATFORM } } },
+ media => { aoh => {
+ medium => { enum => \%MEDIUM },
+ qty => { uint => 1, range => [0,40] },
+ } },
+ drm => { sort_keys => 'name', aoh => {
+ name => { sl => 1, maxlength => 128 },
+ notes => { default => '' },
+ description => { default => '', maxlength => 10240 },
+ map +($_,{anybool=>1}), keys %DRM_PROPERTY
+ } },
+ gtin => { gtin => 1 },
+ catalog => { default => '', sl => 1, maxlength => 50 },
+ released => { default => 99999999, min => 1, rdate => 1 },
+ minage => { default => undef, int => 1, enum => \%AGE_RATING },
+ uncensored => { undefbool => 1 },
+ reso_x => { uint => 1, range => [0,32767] },
+ reso_y => { uint => 1, range => [0,32767] },
+ voiced => { uint => 1, enum => \%VOICED },
+ ani_story => { uint => 1, enum => \%ANIMATED },
+ ani_ero => { uint => 1, enum => \%ANIMATED },
+ ani_story_sp => { default => undef, uint => 1, range => [0,32767] },
+ ani_story_cg => { default => undef, uint => 1, range => [0,32767] },
+ ani_cutscene => { default => undef, uint => 1, range => [0,32767] },
+ ani_ero_sp => { default => undef, uint => 1, range => [0,32767] },
+ ani_ero_cg => { default => undef, uint => 1, range => [0,32767] },
+ ani_face => { undefbool => 1 },
+ ani_bg => { undefbool => 1 },
+ website => { default => '', weburl => 1 },
+ engine => { default => '', sl => 1, maxlength => 50 },
+ notes => { default => '', maxlength => 10240 },
+ vn => { sort_keys => 'vid', aoh => {
+ vid => { vndbid => 'v' },
+ title => { _when => 'out' },
+ rtype => { default => 'complete', enum => \%RELEASE_TYPE },
+ } },
+ producers => { sort_keys => 'pid', aoh => {
+ pid => { vndbid => 'p' },
+ developer => { anybool => 1 },
+ publisher => { anybool => 1 },
+ name => { _when => 'out' },
+ } },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+ validate_extlinks 'r'
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_CMP = form_compile cmp => $FORM;
+
+
+TUWF::get qr{/$RE{rrev}/(?<action>edit|copy)} => sub {
+ my $e = db_entry tuwf->captures('id', 'rev') or return tuwf->resNotFound;
+ my $copy = tuwf->capture('action') eq 'copy';
+ return tuwf->resDenied if !can_edit r => $copy ? {} : $e;
+
+ $e->{editsum} = $copy ? "Copied from $e->{id}.$e->{chrev}" : $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
+
+ $e->{titles} = [ sort { $a->{lang} cmp $b->{lang} } $e->{titles}->@* ];
+
+ $e->{vntitles} = $e->{vn}->@* == 1 ? tuwf->dbAlli('SELECT lang, title, latin FROM vn_titles WHERE id =', \$e->{vn}[0]{vid}) : [];
+
+ enrich_merge vid => sql('SELECT id AS vid, title[1+1] FROM', vnt, 'v WHERE id IN'), $e->{vn};
+ enrich_merge pid => sql('SELECT id AS pid, title[1+1] AS name FROM', producerst, 'p WHERE id IN'), $e->{producers};
+ enrich_merge drm => sql('SELECT id AS drm, name FROM drm WHERE id IN'), $e->{drm};
+
+ my @empty_fields = ('gtin', 'catalog', grep /^l_/, keys %$e);
+ $e->@{@empty_fields} = elm_empty($FORM_OUT)->@{@empty_fields} if $copy;
+
+ my $title = ($copy ? 'Copy ' : 'Edit ').titleprefs_obj($e->{olang}, $e->{titles})->[1];
+ framework_ title => $title, dbobj => $e, tab => tuwf->capture('action'),
+ sub {
+ editmsg_ r => $e, $title, $copy;
+ div_ widget(ReleaseEdit => $FORM_OUT, $copy ? {%$e, id=>undef} : $e), '';
+ };
+};
+
+
+TUWF::get qr{/$RE{vid}/add}, sub {
+ return tuwf->resDenied if !can_edit r => undef;
+ my $v = tuwf->dbRowi('SELECT id, title FROM', vnt, 'v WHERE NOT hidden AND v.id =', \tuwf->capture('id'));
+ return tuwf->resNotFound if !$v->{id};
+
+ my $delrel = tuwf->dbAlli('SELECT r.id, r.title FROM', releasest, 'r JOIN releases_vn rv ON rv.id = r.id WHERE r.hidden AND rv.vid =', \$v->{id}, 'ORDER BY id');
+ enrich_flatten languages => id => id => 'SELECT id, lang FROM releases_titles WHERE id IN', $delrel;
+
+ my $e = {
+ elm_empty($FORM_OUT)->%*,
+ vn => [{vid => $v->{id}, title => $v->{title}[1], rtype => 'complete'}],
+ vntitles => tuwf->dbAlli('SELECT lang, title, latin FROM vn_titles WHERE id =', \$v->{id}),
+ official => 1,
+ };
+
+ framework_ title => "Add release to $v->{title}[1]",
+ sub {
+ editmsg_ r => undef, "Add release to $v->{title}[1]";
+
+ article_ sub {
+ h1_ 'Deleted releases';
+ div_ class => 'warning', sub {
+ p_ q{This visual novel has releases that have been deleted
+ before. Please review this list to make sure you're not
+ adding a release that has already been deleted.};
+ br_;
+ ul_ sub {
+ li_ sub {
+ txt_ '['.join(',', $_->{languages}->@*)."] $_->{id}:";
+ a_ href => "/$_->{id}", tattr $_;
+ } for @$delrel;
+ }
+ }
+ } if @$delrel;
+
+ div_ widget(ReleaseEdit => $FORM_OUT, $e), '';
+ };
+};
+
+
+js_api ReleaseEdit => $FORM_IN, sub {
+ my $data = shift;
+ my $new = !$data->{id};
+ my $e = $new ? { id => 0 } : db_entry $data->{id} or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit r => $e;
+
+ if(!auth->permDbmod) {
+ $data->{hidden} = $e->{hidden}||0;
+ $data->{locked} = $e->{locked}||0;
+ }
+
+ if($data->{patch}) {
+ $data->{doujin} = $data->{voiced} = $data->{ani_story} = $data->{ani_ero} = 0;
+ $data->{reso_x} = $data->{reso_y} = 0;
+ $data->{ani_story_sp} = $data->{ani_story_cg} = $data->{ani_cutscene} = $data->{ani_ero_sp} = $data->{ani_ero_cg} = $data->{ani_face} = $data->{ani_bg} = undef;
+ $data->{engine} = '';
+ }
+ if(!$data->{has_ero}) {
+ $data->{uncensored} = undef;
+ $data->{ani_ero} = 0;
+ $data->{ani_ero_sp} = $data->{ani_ero_cg} = undef;
+ }
+ ani_compat($data, $e);
+
+ die "No title in main language" if !length [grep $_->{lang} eq $data->{olang}, $data->{titles}->@*]->[0]{title};
+
+ $_->{qty} = $MEDIUM{$_->{medium}}{qty} ? $_->{qty}||1 : 0 for $data->{media}->@*;
+ $data->{notes} = bb_subst_links $data->{notes};
+ die "No VNs selected" if !$data->{vn}->@*;
+ die "Invalid resolution: ($data->{reso_x},$data->{reso_y})" if (!$data->{reso_x} && $data->{reso_y} > 1) || ($data->{reso_x} && !$data->{reso_y});
+
+ # We need the DRM names for form_changed()
+ enrich_merge drm => sql('SELECT id AS drm, name FROM drm WHERE id IN'), $e->{drm};
+ # And the DRM identifiers to actually save the new form.
+ enrich_merge name => sql('SELECT name, id AS drm FROM drm WHERE name IN'), $data->{drm};
+ for my $d ($data->{drm}->@*) {
+ $d->{notes} = bb_subst_links $d->{notes};
+ $d->{drm} = tuwf->dbVali('INSERT INTO drm', {map +($_,$d->{$_}), 'name', 'description', keys %DRM_PROPERTY}, 'RETURNING id')
+ if !defined $d->{drm};
+ }
+
+ return 'No changes' if !$new && !form_changed $FORM_CMP, $data, $e;
+
+ my $ch = db_edit r => $e->{id}, $data;
+ +{ _redir => "/$ch->{nitemid}.$ch->{nrev}" };
+};
+
+
+# Set the old ani_story and ani_ero fields to some sort of value based on the
+# new ani_* fields, if they've been changed.
+sub ani_compat {
+ my($r, $old) = @_;
+ return if !grep +($r->{$_}//'_undef_') ne ($old->{$_}//'_undef_'),
+ qw{ ani_story_sp ani_story_cg ani_cutscene ani_ero_sp ani_ero_cg ani_face ani_bg };
+
+ my sub known($) { defined $r->{"ani_$_[0]"} }
+ my sub hasani($) { $r->{"ani_$_[0]"} && $r->{"ani_$_[0]"} > 1 }
+ my sub someani($) { hasani $_[0] && ($r->{"ani_$_[0]"} & 512) == 0 }
+ my sub fullani($) { defined $r->{"ani_$_[0]"} && ($r->{"ani_$_[0]"} & 512) > 0 }
+
+ $r->{ani_story} =
+ !known 'story_sp' && !known 'story_cg' && !known 'cutscene' ? 0 :
+ !hasani 'story_sp' && !hasani 'story_cg' && !hasani 'cutscene' ? 1 :
+ (fullani 'story_sp' || fullani 'story_cg') && !(someani 'story_sp' || someani 'story_cg') ? 4 : 3;
+
+ $r->{ani_ero} =
+ !known 'ero_sp' && !known 'ero_cg' ? 0 :
+ !hasani 'ero_sp' && !hasani 'ero_cg' ? 1 :
+ (fullani 'ero_sp' || fullani 'ero_cg') && !(someani 'ero_sp' || someani 'ero_cg') ? 4 : 3;
+
+ $r->{ani_story} = 2 if $r->{ani_story} < 2 && ($r->{ani_face} || $r->{ani_bg});
+}
+
+
+1;
diff --git a/lib/VNWeb/Releases/Elm.pm b/lib/VNWeb/Releases/Elm.pm
index 32dd89ca..4abe0b12 100644
--- a/lib/VNWeb/Releases/Elm.pm
+++ b/lib/VNWeb/Releases/Elm.pm
@@ -1,22 +1,57 @@
package VNWeb::Releases::Elm;
use VNWeb::Prelude;
+use VNWeb::Releases::Lib;
-# Used by UList.Opt to fetch releases from a VN id.
-elm_api Release => undef, { vid => { id => 1 } }, sub {
+# Used by UList.Opt and CharEdit to fetch releases from a VN id.
+elm_api Release => undef, { vid => { vndbid => 'v' } }, sub {
my($data) = @_;
- my $l = tuwf->dbAlli(
- 'SELECT r.id, r.title, r.original, r.type AS rtype, r.released
- FROM releases r
- JOIN releases_vn rv ON rv.id = r.id
- WHERE NOT r.hidden
- AND rv.vid =', \$data->{vid},
- 'ORDER BY r.released, r.title, r.id'
- );
- enrich_flatten lang => id => id => sub { sql('SELECT id, lang FROM releases_lang WHERE id IN', $_, 'ORDER BY lang') }, $l;
- enrich_flatten platforms => id => id => sub { sql('SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY platform') }, $l;
- elm_Releases $l;
+ elm_Releases releases_by_vn $data->{vid};
+};
+
+
+elm_api Resolutions => undef, {}, sub {
+ elm_Resolutions [ map +{ resolution => resolution($_), count => $_->{count} }, tuwf->dbAlli(q{
+ SELECT reso_x, reso_y, count(*) AS count FROM releases WHERE NOT hidden AND NOT (reso_x = 0 AND reso_y = 0)
+ GROUP BY reso_x, reso_y ORDER BY count(*) DESC
+ })->@* ];
+};
+
+
+elm_api Engines => undef, {}, sub {
+ elm_Engines tuwf->dbAlli(q{
+ SELECT engine, count(*) AS count FROM releases WHERE NOT hidden AND engine <> ''
+ GROUP BY engine ORDER BY count(*) DESC, engine
+ });
+};
+
+
+elm_api DRM => undef, {}, sub {
+ elm_DRM tuwf->dbAlli(q{
+ SELECT name, c_ref AS count FROM drm WHERE c_ref > 0 ORDER BY state = 1+1, c_ref DESC, name
+ });
+};
+
+
+js_api Resolutions => {}, sub {
+ +{ results => [ map +{ id => resolution($_), count => $_->{count} }, tuwf->dbAlli(q{
+ SELECT reso_x, reso_y, count(*) AS count FROM releases WHERE NOT hidden AND NOT (reso_x = 0 AND reso_y = 0)
+ GROUP BY reso_x, reso_y ORDER BY count(*) DESC
+ })->@* ] };
+};
+
+
+js_api Engines => {}, sub {
+ +{ results => tuwf->dbAlli(q{
+ SELECT engine AS id, count(*) AS count FROM releases WHERE NOT hidden AND engine <> ''
+ GROUP BY engine ORDER BY count(*) DESC, engine
+ }) };
+};
+
+
+js_api DRM => {}, sub {
+ +{ results => tuwf->dbAlli('SELECT name AS id, c_ref AS count, state FROM drm ORDER BY state = 1+1, c_ref DESC, name') };
};
1;
diff --git a/lib/VNWeb/Releases/Engines.pm b/lib/VNWeb/Releases/Engines.pm
new file mode 100644
index 00000000..f5e7e812
--- /dev/null
+++ b/lib/VNWeb/Releases/Engines.pm
@@ -0,0 +1,43 @@
+package VNWeb::Releases::Engines;
+
+use VNWeb::Prelude;
+use VNWeb::AdvSearch;
+
+
+TUWF::get qr{/r/engines}, sub {
+ my $list = tuwf->dbAlli('
+ SELECT engine, count(*) AS cnt
+ FROM releases
+ WHERE NOT hidden AND engine <> \'\'
+ GROUP BY engine
+ ORDER BY count(*) DESC'
+ );
+
+ framework_ title => 'Engine list', sub {
+ article_ sub {
+ h1_ 'Engine list';
+ p_ sub {
+ lit_ q{
+ This is a list of all engines currently associated with releases. This
+ list can be used as reference when filling out the engine field for a
+ release and to find inconsistencies in the engine names. See the <a
+ href="/d3#3">releases guidelines</a> for more information.
+ };
+ };
+ };
+ article_ class => 'browse', sub {
+ table_ class => 'stripe', sub {
+ my $c = tuwf->compile({advsearch => 'r'});
+ tr_ sub {
+ td_ class => 'tc1', style => 'text-align: right; width: 80px', $_->{cnt};
+ td_ class => 'tc2', sub {
+ a_ href => '/r?f='.$c->validate([engine => '=', $_->{engine}])->data->query_encode(), $_->{engine};
+ }
+ } for @$list;
+ };
+ };
+ };
+};
+
+
+1;
diff --git a/lib/VNWeb/Releases/Lib.pm b/lib/VNWeb/Releases/Lib.pm
new file mode 100644
index 00000000..708ed95b
--- /dev/null
+++ b/lib/VNWeb/Releases/Lib.pm
@@ -0,0 +1,185 @@
+package VNWeb::Releases::Lib;
+
+use VNWeb::Prelude;
+use Exporter 'import';
+
+our @EXPORT = qw/enrich_release_elm releases_by_vn enrich_release sort_releases release_row_/;
+
+
+# Enrich a list of releases so that it's suitable as 'Releases' Elm response.
+# Given objects must have 'id' and 'rtype' fields (appropriate for the VN in context).
+sub enrich_release_elm {
+ enrich_merge id => sql('SELECT id, title[1+1] AS title, title[1+1+1+1] AS alttitle, released, reso_x, reso_y FROM', releasest, 'r WHERE id IN'), @_;
+ enrich_flatten lang => id => id => sub { sql('SELECT id, lang FROM releases_titles WHERE id IN', $_, 'ORDER BY lang') }, @_;
+ enrich_flatten platforms => id => id => sub { sql('SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY platform') }, @_;
+}
+
+# Return the list of releases associated with a VN in the format suitable as 'Releases' Elm response.
+sub releases_by_vn {
+ my($id) = @_;
+ my $l = tuwf->dbAlli('SELECT r.id, rv.rtype FROM', releasest, 'r JOIN releases_vn rv ON rv.id = r.id WHERE NOT r.hidden AND rv.vid =', \$id, 'ORDER BY r.released, r.sorttitle, r.id');
+ enrich_release_elm $l;
+ $l
+}
+
+
+# Enrich a list of releases so that it's suitable for release_row_().
+# Assumption: Each release already has id, patch, released, gtin and enrich_extlinks().
+sub enrich_release {
+ my($r) = @_;
+ enrich_merge id => sql(
+ 'SELECT id, title, olang, notes, minage, official, freeware, has_ero, reso_x, reso_y, voiced, uncensored
+ , ani_story, ani_ero, ani_story_sp, ani_story_cg, ani_cutscene, ani_ero_sp, ani_ero_cg, ani_face, ani_bg
+ FROM', releasest, 'r WHERE id IN'), $r;
+ enrich_merge id => sub { sql 'SELECT id, MAX(rtype) AS rtype FROM releases_vn WHERE id IN', $_, 'GROUP BY id' }, grep !$_->{rtype}, ref $r ? @$r : $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 platforms => id => id => sub { sql 'SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY id, platform' }, $r;
+ enrich titles => id => id => sub { 'SELECT id, lang, mtl, title, latin FROM releases_titles WHERE id IN', $_, 'ORDER BY id, mtl, lang' }, $r;
+ enrich media => id => id => sub { 'SELECT id, medium, qty FROM releases_media WHERE id IN', $_, 'ORDER BY id, medium' }, $r;
+ enrich drm => id => id => sub { 'SELECT r.id, r.drm, r.notes, d.name,', sql_comma(keys %DRM_PROPERTY), 'FROM releases_drm r JOIN drm d ON d.id = r.drm WHERE r.id IN', $_, 'ORDER BY r.id, r.drm' }, $r;
+}
+
+
+# Sort an array of releases, assumes the objects come from enrich_release()
+# (Not always possible with an SQL ORDER BY due to rtype being context-dependent and platforms coming from other tables)
+sub sort_releases {
+ return [ sort {
+ $a->{released} <=> $b->{released} ||
+ $b->{rtype} cmp $a->{rtype} ||
+ $b->{official} cmp $a->{official} ||
+ $a->{patch} cmp $b->{patch} ||
+ ($a->{platforms}[0]||'') cmp ($b->{platforms}[0]||'') ||
+ $a->{title}[1] cmp $b->{title}[1] ||
+ idcmp($a->{id}, $b->{id})
+ } $_[0]->@* ];
+}
+
+
+sub release_extlinks_ {
+ my($r, $id) = @_;
+ return if !$r->{extlinks}->@*;
+
+ if($r->{extlinks}->@* == 1 && $r->{website}) {
+ a_ href => $r->{extlinks}[0]{url2}, sub {
+ abbr_ class => 'icon-external', title => 'Official website', '';
+ };
+ return
+ }
+
+ div_ class => 'elm_dd_noarrow elm_dd_hover elm_dd_left elm_dd_relextlink', sub {
+ div_ class => 'elm_dd', sub {
+ a_ href => $r->{website}||'#', sub {
+ txt_ scalar $r->{extlinks}->@*;
+ abbr_ class => 'icon-external', title => 'External link', '';
+ };
+ div_ sub {
+ div_ sub {
+ ul_ sub {
+ li_ sub {
+ a_ href => $_->{url2}, sub {
+ span_ $_->{price} if length $_->{price};
+ txt_ $_->{label};
+ }
+ } for $r->{extlinks}->@*;
+ }
+ }
+ }
+ }
+ }
+}
+
+
+# Options
+# id: unique identifier if the same release may be listed on a page twice.
+# lang: $lang, whether to display language icons and which language to use for the title and MTL flag.
+# prod: 0/1 whether to display Pub/Dev indication
+sub release_row_ {
+ my($r, $opt) = @_;
+
+ my $lang = $opt->{lang} && (grep $_->{lang} eq $opt->{lang}, $r->{titles}->@*)[0];
+ my $mtl = $lang ? $lang->{mtl} : (grep $_->{mtl}, $r->{titles}->@*) == $r->{titles}->@*;
+
+ my $storyani = join "\n", map "$_.",
+ $r->{ani_story} == 1 ? 'Not animated' :
+ defined $r->{ani_story_sp} || defined $r->{ani_story_cg} || defined $r->{ani_cutscene} || defined $r->{ani_bg} || defined $r->{ani_face} ? (
+ defined $r->{ani_story_sp} ? fmtanimation $r->{ani_story_sp}, 'sprites' : (),
+ defined $r->{ani_story_cg} ? fmtanimation $r->{ani_story_cg}, 'CGs' : (),
+ defined $r->{ani_cutscene} ? fmtanimation $r->{ani_cutscene}, 'cutscenes' : (),
+ defined $r->{ani_bg} ? ($r->{ani_bg} ? 'Animated background effects' : 'No background effects') : (),
+ defined $r->{ani_face} ? ($r->{ani_face} ? 'Lip and/or eye movement' : 'No facial animations') : (),
+ ) : $ANIMATED{$r->{ani_story}}{txt};
+
+ my $eroani = join "\n", map "$_.",
+ $r->{ani_ero} == 1 ? 'Not animated' :
+ defined $r->{ani_ero_sp} || defined $r->{ani_ero_cg} ? (
+ defined $r->{ani_ero_sp} ? fmtanimation $r->{ani_ero_sp}, 'sprites' : (),
+ defined $r->{ani_ero_cg} ? fmtanimation $r->{ani_ero_cg}, 'CGs' : (),
+ ) : $ANIMATED{$r->{ani_ero}}{txt};
+
+ my sub icon_ {
+ my($img, $label, $class) = @_;
+ $class = $class ? " icon-rel-$class" : '';
+ abbr_ class => "icon-rel-$img$class", title => $label, '';
+ }
+
+ my sub icons_ {
+ my($r) = @_;
+ icon_ 'notes', bb_format $r->{notes}, text => 1 if $r->{notes};
+ icon_ $MEDIUM{ $r->{media}[0]{medium} }{icon}, join ', ', map fmtmedia($_->{medium}, $_->{qty}), $r->{media}->@* if $r->{media}->@*;
+ if($r->{reso_y}) {
+ my $ratio = $r->{reso_x} / $r->{reso_y};
+ my $type = $ratio == 4/3 ? '43' : $ratio == 16/9 ? '169' : 'custom';
+ # Ugly workaround: PC-98 has non-square pixels, thus not widescreen
+ $type = '43' if $ratio > 4/3 && grep $_ eq 'p98', $r->{platforms}->@*;
+ icon_ "reso-$type", resolution $r;
+ }
+ icon_ 'free', 'Freeware' if $r->{freeware};
+ icon_ 'nonfree', 'Non-free' if !$r->{freeware};
+ icon_ 'ani-ero', "Erotic scene animation:\n$eroani", "a$r->{ani_ero}" if $r->{ani_ero};
+ icon_ 'ani-story', "Story scene animation:\n$storyani", "a$r->{ani_story}" if $r->{ani_story};
+ icon_ 'voiced', $VOICED{$r->{voiced}}{txt}, "v$r->{voiced}" if $r->{voiced};
+ }
+
+ tr_ $mtl ? (class => 'mtl') : (), sub {
+ td_ class => 'tc1', sub { rdate_ $r->{released} };
+ td_ class => 'tc2', sub {
+ span_ class => 'releaseero releaseero_'.(!$r->{has_ero} ? 'no' : $r->{uncensored} ? 'unc' : defined $r->{uncensored} ? 'cen' : 'yes'),
+ title => !$r->{has_ero} ? 'No erotic scenes' :
+ $r->{uncensored} ? 'Contains uncensored erotic scenes'
+ : defined $r->{uncensored} ? 'Contains erotic scenes with optical censoring' : 'Contains erotic scenes', '♥';
+ txt_ !$r->{minage} ? 'All' : minage $r->{minage} if defined $r->{minage};
+ };
+ td_ class => 'tc3', sub {
+ platform_ $_ for $r->{platforms}->@*;
+ if(!$opt->{lang}) {
+ abbr_ class => "icon-lang-$_->{lang}".($_->{mtl}?' mtl':''), title => $LANGUAGE{$_->{lang}}{txt}, '' for $r->{titles}->@*;
+ }
+ abbr_ class => "icon-rt$r->{rtype}", title => $r->{rtype}, '';
+ };
+ td_ class => 'tc4', sub {
+ my $title =
+ $lang && defined $lang->{title} ? titleprefs_obj $lang->{lang}, [$lang] :
+ $lang ? titleprefs_obj $r->{olang}, [grep $_->{lang} eq $r->{olang}, $r->{titles}->@*]
+ : $r->{title};
+ a_ href => "/$r->{id}", tattr $title;
+ my $note = join ' ', $r->{official} ? () : 'unofficial', $mtl ? 'machine translation' : (), $r->{patch} ? 'patch' : ();
+ small_ " ($note)" if $note;
+ if ($r->{drm}->@*) {
+ my($free,$drm);
+ for my $d ($r->{drm}->@*) {
+ ${ (grep $d->{$_}, keys %DRM_PROPERTY)[0] ? \$drm : \$free } = 1
+ }
+ my $nfo = join "\n", map $_->{name}.($_->{notes} ? ' ('.bb_format($_->{notes}, text => 1).')' : ''), $r->{drm}->@*;
+ ($free && $drm ? \&span_ : $drm ? \&b_ : \&small_)->(title => $nfo, $free && !$drm ? ' (drm-free)' : ' (drm)');
+ }
+ };
+ td_ class => 'tc_icons', sub { icons_ $r };
+ td_ class => 'tc_prod', join ' & ', $r->{publisher} ? 'Pub' : (), $r->{developer} ? 'Dev' : () if $opt->{prod};
+ td_ class => 'tc5 elm_dd_left', sub {
+ elm_ 'UList.ReleaseEdit', $VNWeb::ULists::Elm::RLIST_STATUS, { rid => $r->{id}, uid => auth->uid, status => $r->{rlist_status}, empty => '--' } if auth;
+ };
+ td_ class => 'tc6', sub { release_extlinks_ $r, "$opt->{id}_$r->{id}" };
+ }
+}
+
+1;
diff --git a/lib/VNWeb/Releases/List.pm b/lib/VNWeb/Releases/List.pm
new file mode 100644
index 00000000..a6618dd1
--- /dev/null
+++ b/lib/VNWeb/Releases/List.pm
@@ -0,0 +1,92 @@
+package VNWeb::Releases::List;
+
+use VNDB::Func 'gtintype';
+use VNWeb::Prelude;
+use VNWeb::AdvSearch;
+use VNWeb::Filters;
+use VNWeb::Releases::Lib;
+
+
+sub listing_ {
+ my($opt, $list, $count) = @_;
+ my sub url { '?'.query_encode %$opt, @_ }
+ paginate_ \&url, $opt->{p}, [$count, 50], 't';
+ article_ class => 'browse', sub {
+ table_ class => 'stripe releases', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'released',$opt, \&url; debug_ $list; };
+ td_ class => 'tc2', sub { txt_ 'Rating'; sortable_ 'minage', $opt, \&url };
+ td_ class => 'tc3', '';
+ td_ class => 'tc4', sub { txt_ 'Title'; sortable_ 'title', $opt, \&url };
+ td_ class => 'tc_icons', '';
+ td_ class => 'tc5', '';
+ td_ class => 'tc6', '';
+ } };
+ my $ropt = { id => '' };
+ release_row_ $_, $ropt for @$list;
+ }
+ };
+ paginate_ \&url, $opt->{p}, [$count, 50], 'b';
+}
+
+
+TUWF::get qr{/r}, sub {
+ my $opt = tuwf->validate(get =>
+ q => { searchquery => 1 },
+ p => { upage => 1 },
+ f => { advsearch_err => 'r' },
+ s => { onerror => 'qscore', enum => [qw/qscore released minage title/] },
+ o => { onerror => 'a', enum => ['a','d'] },
+ fil => { onerror => '' },
+ )->data;
+ $opt->{s} = 'qscore' if $opt->{q} && tuwf->reqGet('sb');
+ $opt->{s} = 'title' if $opt->{s} eq 'qscore' && !$opt->{q};
+
+ # URL compatibility with old filters
+ if(!$opt->{f}->{query} && $opt->{fil}) {
+ my $q = eval {
+ tuwf->compile({ advsearch => 'r' })->validate(filter_release_adv filter_parse r => $opt->{fil})->data;
+ };
+ return tuwf->resRedirect(tuwf->reqPath().'?'.query_encode(%$opt, fil => undef, f => $q), 'perm') if $q;
+ }
+
+ $opt->{f} = advsearch_default 'r' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
+
+ my $where = sql_and 'NOT r.hidden', $opt->{f}->sql_where();
+
+ my $time = time;
+ my($count, $list);
+ db_maytimeout {
+ $count = tuwf->dbVali('SELECT count(*) FROM releases r WHERE', sql_and $where, $opt->{q}->sql_where('r', 'r.id'));
+ $list = $count ? tuwf->dbPagei({results => 50, page => $opt->{p}}, '
+ SELECT r.id, r.patch, r.released, r.gtin, ', sql_extlinks(r => 'r.'), '
+ FROM', releasest, 'r', $opt->{q}->sql_join('r', 'r.id'), '
+ WHERE', $where, '
+ ORDER BY', sprintf {
+ qscore => '10 - sc.score %s, r.sorttitle %1$s',
+ title => 'r.sorttitle %s, r.released %1$s',
+ minage => 'r.minage %s, r.sorttitle %1$s, r.released %1$s',
+ released => 'r.released %s, r.sorttitle %1$s, r.id %1$s',
+ }->{$opt->{s}}, $opt->{o} eq 'a' ? 'ASC' : 'DESC'
+ ) : [];
+ } || (($count, $list) = (undef, []));
+
+ enrich_extlinks r => 0, $list;
+ enrich_release $list;
+ $time = time - $time;
+
+ framework_ title => 'Browse releases', sub {
+ article_ sub {
+ h1_ 'Browse releases';
+ form_ action => '/r', method => 'get', sub {
+ searchbox_ r => $opt->{q}//'';
+ input_ type => 'hidden', name => 'o', value => $opt->{o};
+ input_ type => 'hidden', name => 's', value => $opt->{s};
+ $opt->{f}->elm_($count, $time);
+ };
+ };
+ listing_ $opt, $list, $count if $count;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Releases/Page.pm b/lib/VNWeb/Releases/Page.pm
new file mode 100644
index 00000000..17befb1f
--- /dev/null
+++ b/lib/VNWeb/Releases/Page.pm
@@ -0,0 +1,312 @@
+package VNWeb::Releases::Page;
+
+use VNWeb::Prelude;
+use TUWF 'uri_escape';
+use VNWeb::Releases::Lib;
+
+
+sub enrich_item {
+ my($r) = @_;
+
+ enrich_merge pid => sql('SELECT id AS pid, title, sorttitle FROM', producerst, 'p WHERE id IN'), $r->{producers};
+ enrich_merge vid => sql('SELECT id AS vid, title, sorttitle FROM', vnt, 'v WHERE id IN'), $r->{vn};
+ enrich_merge drm => sql('SELECT id AS drm, name,', sql_join(',', keys %DRM_PROPERTY), 'FROM drm WHERE id IN'), $r->{drm};
+
+ $r->{titles} = [ sort { ($b->{lang} eq $r->{olang}) cmp ($a->{lang} eq $r->{olang}) || ($a->{mtl}?1:0) <=> ($b->{mtl}?1:0) || $a->{lang} cmp $b->{lang} } $r->{titles}->@* ];
+ $r->{platforms} = [ sort map $_->{platform}, $r->{platforms}->@* ];
+ $r->{vn} = [ sort { $a->{sorttitle} cmp $b->{sorttitle} || idcmp($a->{vid}, $b->{vid}) } $r->{vn}->@* ];
+ $r->{producers} = [ sort { $a->{sorttitle} cmp $b->{sorttitle} || idcmp($a->{pid}, $b->{pid}) } $r->{producers}->@* ];
+ $r->{media} = [ sort { $a->{medium} cmp $b->{medium} || $a->{qty} <=> $b->{qty} } $r->{media}->@* ];
+ $r->{drm} = [ sort { !$a->{drm} || !$b->{drm} ? $b->{drm} <=> $a->{drm} : $a->{name} cmp $b->{name} } $r->{drm}->@* ];
+
+ $r->{resolution} = resolution $r;
+}
+
+
+sub _rev_ {
+ my($r) = @_;
+ # The old ani_* fields are automatically inferred from the new ani_* fields
+ # for edits made after the fields were introduced. Hide the old fields for
+ # such revisions to remove some clutter.
+ my $newani = $r->{chid} > 1110896;
+ revision_ $r, \&enrich_item,
+ [ vn => 'Relations', fmt => sub {
+ abbr_ class => "icon-rt$_->{rtype}", title => $_->{rtype}, ' ';
+ a_ href => "/$_->{vid}", tattr $_;
+ txt_ " ($_->{rtype})" if $_->{rtype} ne 'complete';
+ } ],
+ [ official => 'Official', fmt => 'bool' ],
+ [ patch => 'Patch', fmt => 'bool' ],
+ [ freeware => 'Freeware', fmt => 'bool' ],
+ [ has_ero => 'Has ero', fmt => 'bool' ],
+ [ doujin => 'Doujin', fmt => 'bool' ],
+ [ uncensored => 'Uncensored', fmt => 'bool' ],
+ [ gtin => 'JAN/EAN/UPC/ISBN',empty => 0 ],
+ [ catalog => 'Catalog number' ],
+ [ titles => 'Languages', txt => sub {
+ '['.$_->{lang}.($_->{mtl} ? ' machine translation' : '').'] '.($_->{title}//'').(length $_->{latin} ? " / $_->{latin}" : '')
+ }],
+ [ olang => 'Main title', fmt => \%LANGUAGE ],
+ [ released => 'Release date', fmt => sub { rdate_ $_ } ],
+ [ minage => 'Age rating', fmt => sub { txt_ minage $_ } ],
+ [ notes => 'Notes' ],
+ [ platforms => 'Platforms', fmt => \%PLATFORM ],
+ [ media => 'Media', fmt => sub { txt_ fmtmedia $_->{medium}, $_->{qty}; } ],
+ [ resolution => 'Resolution' ],
+ [ voiced => 'Voiced', fmt => \%VOICED ],
+ $newani ? () :
+ [ ani_story => 'Story animation', fmt => \%ANIMATED ],
+ [ ani_story_sp => 'Story animation/sprites',fmt => sub { txt_ fmtanimation $_, 'sprites' } ],
+ [ ani_story_cg => 'Story animation/cg', fmt => sub { txt_ fmtanimation $_, 'CGs' } ],
+ [ ani_cutscene => 'Cutscene animation', fmt => sub { txt_ fmtanimation $_, 'cutscenes' } ],
+ $newani ? () :
+ [ ani_ero => 'Ero animation', fmt => \%ANIMATED ],
+ [ ani_ero_sp => 'Ero animation/sprites',fmt=> sub { txt_ fmtanimation $_, 'sprites' } ],
+ [ ani_ero_cg => 'Ero animation/cg', fmt => sub { txt_ fmtanimation $_, 'CGs' } ],
+ [ ani_face => 'Lip/eye animation', fmt => 'bool' ],
+ [ ani_bg => 'Background effects', fmt => 'bool' ],
+ [ engine => 'Engine' ],
+ [ producers => 'Producers', fmt => sub {
+ a_ href => "/$_->{pid}", tattr $_;
+ txt_ ' (';
+ txt_ join ', ', $_->{developer} ? 'developer' : (), $_->{publisher} ? 'publisher' : ();
+ txt_ ')';
+ } ],
+ [ drm => 'DRM', fmt => sub {
+ a_ href => '/r/drm?s='.uri_escape($_->{name}), $_->{name};
+ txt_ " ($_->{notes})" if length $_->{notes};
+ } ],
+ revision_extlinks 'r'
+}
+
+
+sub _infotable_animation_ {
+ my($r) = @_;
+ state @fields = qw|ani_story_sp ani_story_cg ani_cutscene ani_ero_sp ani_ero_cg ani_bg ani_face|;
+
+ return if !$r->{ani_story} && !$r->{ani_ero};
+
+ my sub txtc {
+ my($bool, $txt) = @_;
+ +(sub { $bool ? txt_ $txt : small_ $txt })
+ }
+
+ my sub sect {
+ my($val, $lbl) = @_;
+ defined $val ? txtc $val > 2, fmtanimation $val, $lbl : ();
+ }
+
+ my @story = !$r->{ani_story} ? () :
+ defined $r->{ani_story_sp} || defined $r->{ani_story_cg} || defined $r->{ani_cutscene} || defined $r->{ani_bg} || defined $r->{ani_face} ? (
+ defined $r->{ani_story_sp} ? sect $r->{ani_story_sp}, 'sprites' : (),
+ defined $r->{ani_story_cg} ? sect $r->{ani_story_cg}, 'CGs' : (),
+ defined $r->{ani_cutscene} ? sect $r->{ani_cutscene}, 'cutscenes' : (),
+ ) : txtc $r->{ani_story} > 1, $ANIMATED{$r->{ani_story}}{txt};
+
+ my @ero = !$r->{ani_ero} ? () :
+ defined $r->{ani_ero_sp} || defined $r->{ani_ero_cg} ? (
+ defined $r->{ani_ero_sp} ? sect $r->{ani_ero_sp}, 'sprites' : (),
+ defined $r->{ani_ero_cg} ? sect $r->{ani_ero_cg}, 'CGs' : (),
+ ) : txtc $r->{ani_ero} > 1, $ANIMATED{$r->{ani_ero}}{txt};
+
+ tr_ sub {
+ td_ 'Animation';
+ td_ sub {
+ dl_ sub {
+ if(@story) {
+ dt_ 'Story scenes';
+ dd_ sub { join_ \&br_, sub { $_->() }, @story };
+ }
+ if(@ero) {
+ dt_ 'Erotic scenes';
+ dd_ sub { join_ \&br_, sub { $_->() }, @ero };
+ }
+ } if @story || @ero;
+ join_ \&br_, sub { $_->() },
+ defined $r->{ani_bg} ? (txtc $r->{ani_bg}, $r->{ani_bg} ? 'Animated background effects' : 'No background effects') : (),
+ defined $r->{ani_face} ? (txtc $r->{ani_face}, $r->{ani_face} ? 'Lip and/or eye movement' : 'No facial animations') : ();
+ };
+ };
+}
+
+
+sub _infotable_ {
+ my($r) = @_;
+
+ table_ class => 'stripe', sub {
+ tr_ sub {
+ td_ class => 'key', 'Relation';
+ td_ sub {
+ join_ \&br_, sub {
+ abbr_ class => "icon-rt$_->{rtype}", title => $_->{rtype}, ' ';
+ a_ href => "/$_->{vid}", tattr $_;
+ txt_ " ($_->{rtype})" if $_->{rtype} ne 'complete';
+ }, $r->{vn}->@*
+ }
+ };
+
+ tr_ class => 'titles', sub {
+ td_ $r->{titles}->@* == 1 ? 'Title' : 'Titles';
+ td_ sub {
+ table_ sub {
+ my($olang) = grep $_->{lang} eq $r->{olang}, $r->{titles}->@*;
+ tr_ class => 'nostripe title', sub {
+ td_ style => 'white-space: nowrap', sub {
+ abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, '';
+ };
+ td_ sub {
+ my $title = $_->{title}//$olang->{title};
+ span_ tlang($_->{lang}, $title), $title;
+ small_ ' (machine translation)' if $_->{mtl};
+ my $latin = defined $_->{title} ? $_->{latin} : $olang->{latin};
+ if(defined $latin) {
+ br_;
+ txt_ $latin;
+ }
+ }
+ } for $r->{titles}->@*;
+ };
+ };
+ };
+
+ tr_ sub {
+ td_ 'Type';
+ td_ !$r->{official} && $r->{patch} ? 'Unofficial patch' :
+ !$r->{official} ? 'Unofficial' : 'Patch';
+ } if !$r->{official} || $r->{patch};
+
+ tr_ sub {
+ td_ 'Publication';
+ td_ $r->{freeware} ? 'Freeware' : 'Non-free';
+ };
+
+ tr_ sub {
+ td_ 'Platform'.($r->{platforms}->@* == 1 ? '' : 's');
+ td_ sub {
+ join_ \&br_, sub {
+ platform_ $_;
+ txt_ ' '.$PLATFORM{$_};
+ }, $r->{platforms}->@*;
+ }
+ } if $r->{platforms}->@*;
+
+ tr_ sub {
+ td_ $r->{media}->@* == 1 ? 'Medium' : 'Media';
+ td_ sub {
+ join_ \&br_, sub { txt_ fmtmedia $_->{medium}, $_->{qty} }, $r->{media}->@*;
+ }
+ } if $r->{media}->@*;
+
+ tr_ sub {
+ td_ 'Resolution';
+ td_ resolution $r;
+ } if $r->{reso_y};
+
+ tr_ sub {
+ td_ 'Voiced';
+ td_ $VOICED{$r->{voiced}}{txt};
+ } if $r->{voiced};
+
+ _infotable_animation_ $r;
+
+ tr_ sub {
+ td_ 'Engine';
+ td_ sub {
+ a_ href => '/r?f='.tuwf->compile({advsearch => 'r'})->validate(['engine', '=', $r->{engine}])->data->query_encode, $r->{engine};
+ }
+ } if length $r->{engine};
+
+ tr_ sub {
+ td_ 'DRM';
+ td_ sub { join_ \&br_, sub {
+ my $d = $_;
+ my @prop = grep $d->{$_}, keys %DRM_PROPERTY;
+ abbr_ class => "icon-drm-$_", title => $DRM_PROPERTY{$_}, '' for @prop;
+ abbr_ class => 'icon-drm-free', title => 'DRM-free', '' if !@prop;
+ a_ href => '/r/drm?s='.uri_escape($d->{name}), $d->{name};
+ lit_ ' ('.bb_format($d->{notes}, inline => 1).')' if length $d->{notes};
+ }, $r->{drm}->@* };
+ } if $r->{drm}->@*;
+
+ tr_ sub {
+ td_ 'Released';
+ td_ sub { rdate_ $r->{released} };
+ };
+
+ tr_ sub {
+ td_ 'Age rating';
+ td_ minage $r->{minage};
+ } if defined $r->{minage};
+
+ tr_ sub {
+ td_ 'Erotic content';
+ td_ $r->{uncensored} ? 'Contains uncensored erotic scenes' : defined $r->{uncensored} ? 'Contains erotic scenes with optical censoring' : 'Contains erotic scenes',
+ } if $r->{has_ero};
+
+ for my $t (qw|developer publisher|) {
+ my @prod = grep $_->{$t}, @{$r->{producers}};
+ tr_ sub {
+ td_ ucfirst($t).(@prod == 1 ? '' : 's');
+ td_ sub {
+ join_ \&br_, sub {
+ a_ href => "/$_->{pid}", tattr $_;
+ }, @prod
+ }
+ } if @prod;
+ }
+
+ tr_ sub {
+ td_ gtintype($r->{gtin}) || 'GTIN';
+ td_ $r->{gtin};
+ } if $r->{gtin};
+
+ tr_ sub {
+ td_ 'Catalog no.';
+ td_ $r->{catalog};
+ } if $r->{catalog};
+
+ tr_ sub {
+ td_ 'Links';
+ td_ sub {
+ join_ ', ', sub { a_ href => $_->{url2}, $_->{label} }, $r->{extlinks}->@*;
+ }
+ } if $r->{extlinks}->@*;
+
+ tr_ sub {
+ td_ 'User options';
+ td_ sub {
+ div_ class => 'elm_dd_input', style => 'width: 150px', sub {
+ my $d = tuwf->dbVali('SELECT status FROM rlists WHERE', { rid => $r->{id}, uid => auth->uid });
+ elm_ 'UList.ReleaseEdit', $VNWeb::ULists::Elm::RLIST_STATUS, { rid => $r->{id}, uid => auth->uid, status => $d, empty => 'not on your list' };
+ }
+ };
+ } if auth;
+ }
+}
+
+
+TUWF::get qr{/$RE{rrev}} => sub {
+ my $r = db_entry tuwf->captures('id','rev');
+ return tuwf->resNotFound if !$r;
+
+ $r->{title} = titleprefs_obj $r->{olang}, $r->{titles};
+ enrich_item $r;
+ enrich_extlinks r => 0, $r;
+
+ framework_ title => $r->{title}[1], index => !tuwf->capture('rev'), dbobj => $r, hiddenmsg => 1,
+ og => {
+ description => bb_format $r->{notes}, text => 1
+ },
+ sub {
+ _rev_ $r if tuwf->capture('rev');
+ article_ class => 'release', sub {
+ itemmsg_ $r;
+ h1_ tlang($r->{title}[0], $r->{title}[1]), $r->{title}[1];
+ h2_ class => 'alttitle', tlang(@{$r->{title}}[2,3]), $r->{title}[3] if $r->{title}[3] && $r->{title}[3] ne $r->{title}[1];
+ _infotable_ $r;
+ div_ class => 'description', sub { lit_ bb_format $r->{notes} } if $r->{notes};
+ };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Releases/VNTab.pm b/lib/VNWeb/Releases/VNTab.pm
new file mode 100644
index 00000000..33df7207
--- /dev/null
+++ b/lib/VNWeb/Releases/VNTab.pm
@@ -0,0 +1,263 @@
+# TODO: This code is kind of obsolete. It's not been updated with recently
+# added release fields and all fields are already displayed more concisely in
+# the releases box on the main VN page. The filtering and display options on
+# this page can still be useful, though, so need to figure out what to do with
+# this in the future.
+# Maybe update/modernize this page with the latest fields and icons and
+# shorten/simplify the long list of releases on the main VN page? Or expand the
+# list on VN pages with filters and display options?
+
+package VNWeb::Releases::VNTab;
+
+use VNWeb::Prelude;
+use VNWeb::Releases::Lib 'enrich_release';
+
+
+# Description of each column, field:
+# id: Identifier used in URLs
+# sort_field: Name of the field when sorting
+# sort_sql: ORDER BY clause when sorting
+# column_string: String to use as column header
+# column_width: Maximum width (in pixels) of the column in 'restricted width' mode
+# button_string: String to use for the hide/unhide button
+# na_for_patch: When the field is N/A for patch releases
+# default: Set when it's visible by default
+# has_data: Subroutine called with a release object, should return true if the release has data for the column
+# draw: Subroutine called with a release object, should draw its column contents
+my @rel_cols = (
+ { # Title
+ id => 'tit',
+ sort_field => 'title',
+ sort_sql => 'r.sorttitle %s, r.released %1$s',
+ column_string => 'Title',
+ draw => sub { a_ href => "/$_[0]{id}", tattr $_[0] },
+ }, { # Type
+ id => 'typ',
+ sort_field => 'type',
+ sort_sql => 'r.patch %s, rv.rtype %1$s, r.released %1$s, r.sorttitle %1$s',
+ button_string => 'Type',
+ default => 1,
+ draw => sub { abbr_ class => "icon-rt$_[0]{rtype}", title => $_[0]{rtype}, ''; txt_ '(patch)' if $_[0]{patch} },
+ }, { # Languages
+ id => 'lan',
+ button_string => 'Language',
+ default => 1,
+ draw => sub { join_ \&br_, sub { abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, ''; }, $_[0]{titles}->@* },
+ }, { # Publication
+ id => 'pub',
+ sort_field => 'publication',
+ sort_sql => 'r.freeware %1$s, r.patch %1$s, r.released %1$s, r.sorttitle %1$s',
+ column_string => 'Publication',
+ column_width => 70,
+ button_string => 'Publication',
+ default => 1,
+ draw => sub { txt_ $_[0]{freeware} ? 'Freeware' : 'Non-free' },
+ }, { # Platforms
+ id => 'pla',
+ button_string => 'Platforms',
+ default => 1,
+ has_data => sub { !!@{$_[0]{platforms}} },
+ draw => sub {
+ join_ \&br_, sub { platform_ $_ }, $_[0]{platforms}->@*;
+ txt_ 'Unknown' if !$_[0]{platforms}->@*;
+ },
+ }, { # Media
+ id => 'med',
+ column_string => 'Media',
+ button_string => 'Media',
+ has_data => sub { !!@{$_[0]{media}} },
+ draw => sub {
+ join_ \&br_, sub { txt_ fmtmedia $_->{medium}, $_->{qty} }, $_[0]{media}->@*;
+ txt_ 'Unknown' if !$_[0]{media}->@*;
+ },
+ }, { # Resolution
+ id => 'res',
+ sort_field => 'resolution',
+ sort_sql => 'r.reso_x %s, r.reso_y %1$s, r.patch %1$s, r.released %1$s, r.sorttitle %1$s',
+ column_string => 'Resolution',
+ button_string => 'Resolution',
+ na_for_patch => 1,
+ default => 1,
+ has_data => sub { !!$_[0]{reso_y} },
+ draw => sub { txt_ resolution($_[0]) || 'Unknown' },
+ }, { # Voiced
+ id => 'voi',
+ sort_field => 'voiced',
+ sort_sql => 'r.voiced %s, r.patch %1$s, r.released %1$s, r.sorttitle %1$s',
+ column_string => 'Voiced',
+ column_width => 70,
+ button_string => 'Voiced',
+ na_for_patch => 1,
+ default => 1,
+ has_data => sub { !!$_[0]{voiced} },
+ draw => sub { txt_ $VOICED{$_[0]{voiced}}{txt} },
+ }, { # Animation
+ id => 'ani',
+ sort_field => 'ani_ero',
+ sort_sql => 'r.ani_story %s, r.ani_ero %1$s, r.patch %1$s, r.released %1$s, r.sorttitle %1$s',
+ column_string => 'Animation',
+ column_width => 110,
+ button_string => 'Animation',
+ na_for_patch => '1',
+ has_data => sub { !!($_[0]{ani_story} || $_[0]{ani_ero}) },
+ draw => sub {
+ txt_ join ', ',
+ $_[0]{ani_story} ? "Story: $ANIMATED{$_[0]{ani_story}}{txt}" :(),
+ $_[0]{ani_ero} ? "Ero scenes: $ANIMATED{$_[0]{ani_ero}}{txt}":();
+ txt_ 'Unknown' if !$_[0]{ani_story} && !$_[0]{ani_ero};
+ },
+ }, { # Released
+ id => 'rel',
+ sort_field => 'released',
+ sort_sql => 'r.released %s, r.id %1$s',
+ column_string => 'Released',
+ button_string => 'Released',
+ default => 1,
+ draw => sub { rdate_ $_[0]{released} },
+ }, { # Age rating
+ id => 'min',
+ sort_field => 'minage',
+ sort_sql => 'r.minage %s, r.released %1$s, r.sorttitle %1$s',
+ button_string => 'Age rating',
+ default => 1,
+ has_data => sub { defined $_[0]{minage} },
+ draw => sub { txt_ minage $_[0]{minage} },
+ }, { # Notes
+ id => 'not',
+ sort_field => 'notes',
+ sort_sql => 'r.notes %s, r.released %1$s, r.sorttitle %1$s',
+ column_string => 'Notes',
+ column_width => 400,
+ button_string => 'Notes',
+ default => 1,
+ has_data => sub { !!$_[0]{notes} },
+ draw => sub { lit_ bb_format $_[0]{notes} },
+ }
+);
+
+
+
+sub buttons_ {
+ my($opt, $url, $r) = @_;
+
+ # Column visibility
+ p_ class => 'browseopts', sub {
+ a_ href => $url->($_->{id}, $opt->{$_->{id}} ? 0 : 1), $opt->{$_->{id}} ? (class => 'optselected') : (), $_->{button_string}
+ for grep $_->{button_string}, @rel_cols;
+ };
+
+ # Misc options
+ my $all_selected = !grep $_->{button_string} && !$opt->{$_->{id}}, @rel_cols;
+ my $all_unselected = !grep $_->{button_string} && $opt->{$_->{id}}, @rel_cols;
+ my $all_url = sub { $url->(map +($_->{id},$_[0]), grep $_->{button_string}, @rel_cols); };
+ p_ class => 'browseopts', sub {
+ a_ href => $all_url->(1), $all_selected ? (class => 'optselected') : (), 'All on';
+ a_ href => $all_url->(0), $all_unselected ? (class => 'optselected') : (), 'All off';
+ a_ href => $url->('cw', $opt->{cw} ? 0 : 1), $opt->{cw} ? (class => 'optselected') : (), 'Restrict column width';
+ };
+
+ my sub pl {
+ my($option, $icon, @lst) = @_;
+ my %opts = map +($_,1), @lst;
+ return if !keys %opts;
+ p_ class => 'browseopts', sub {
+ a_ href => $url->($option, $_), $_ eq $opt->{$option} ? (class => 'optselected') : (), sub {
+ $_ eq 'all' ? txt_ 'All' : $icon->($_);
+ } for ('all', sort keys %opts);
+ }
+ };
+ pl 'os', \&platform_, map $_->{platforms}->@*, @$r if $opt->{pla};
+ pl 'lang', sub { abbr_ class => "icon-lang-$_[0]", title => $LANGUAGE{$_[0]}{txt}, '' }, map $_->{lang}, map $_->{titles}->@*, @$r if $opt->{lan};
+}
+
+
+sub listing_ {
+ my($opt, $url, $r) = @_;
+
+ # Apply language and platform filters
+ my @r = grep +
+ ($opt->{os} eq 'all' || ($_->{platforms} && grep $_ eq $opt->{os}, $_->{platforms}->@*)) &&
+ ($opt->{lang} eq 'all' || ($_->{titles} && grep $_ eq $opt->{lang}, map $_->{lang}, $_->{titles}->@*)), @$r;
+
+ # Figure out which columns to display
+ my @col;
+ for my $c (@rel_cols) {
+ next if $c->{button_string} && !$opt->{$c->{id}}; # Hidden by settings
+ push @col, $c if !@r || !$c->{has_data} || grep $c->{has_data}->($_), @r; # Must have relevant data
+ }
+
+ article_ class => 'releases_compare', sub {
+ table_ sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'key', sub {
+ txt_ $_->{column_string} if $_->{column_string};
+ sortable_ $_->{sort_field}, $opt, $url if $_->{sort_field};
+ } for @col;
+ } };
+ tr_ sub {
+ my $r = $_;
+ # Combine "N/A for patches" columns
+ my $cspan = 1;
+ for my $c (0..$#col) {
+ if($r->{patch} && $col[$c]{na_for_patch} && $c < $#col && $col[$c+1]{na_for_patch}) {
+ $cspan++;
+ next;
+ }
+ td_ $cspan > 1 ? (colspan => $cspan) : (),
+ $col[$c]{column_width} && $opt->{cw} ? (style => "max-width: $col[$c]{column_width}px") : ();
+ if($r->{patch} && $col[$c]{na_for_patch}) {
+ txt_ 'NA for patches';
+ } else {
+ $col[$c]{draw}->($r);
+ }
+ end_;
+ $cspan = 1;
+ }
+ } for @r;
+ }
+ }
+}
+
+
+TUWF::get qr{/$RE{vid}/releases} => sub {
+ my $v = dbobj tuwf->capture('id');
+ return tuwf->resNotFound if !$v->{id};
+
+ my $opt = tuwf->validate(get =>
+ cw => { anybool => 1 },
+ o => { onerror => 'a', enum => [0,1,'d','a'] },
+ s => { onerror => 'released', enum => [ map $_->{sort_field}, grep $_->{sort_field}, @rel_cols ]},
+ os => { onerror => 'all', enum => [ 'all', keys %PLATFORM ] },
+ lang => { onerror => 'all', enum => [ 'all', keys %LANGUAGE ] },
+ map +($_->{id}, { anybool => 1, default => $_->{default} }), grep $_->{button_string}, @rel_cols
+ )->data;
+ # Compat with old URLs
+ $opt->{o} = 'a' if $opt->{o} eq 0;
+ $opt->{o} = 'd' if $opt->{o} eq 1;
+
+ my $r = tuwf->dbAlli('
+ SELECT r.id, rv.rtype, r.patch, r.released, r.gtin
+ FROM', releasest, 'r
+ JOIN releases_vn rv ON rv.id = r.id
+ WHERE NOT hidden AND rv.vid =', \$v->{id}, '
+ ORDER BY', sprintf(+(grep $opt->{s} eq ($_->{sort_field}//''), @rel_cols)[0]{sort_sql}, $opt->{o} eq 'a' ? 'ASC' : 'DESC')
+ );
+ enrich_release $r;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ framework_ title => "Releases for $v->{title}[1]", dbobj => $v, tab => 'releases', sub {
+ article_ class => 'releases_compare', sub {
+ h1_ "Releases for $v->{title}[1]";
+ if(!@$r) {
+ p_ 'We don\'t have any information about releases of this visual novel yet...';
+ } else {
+ buttons_($opt, \&url, $r);
+ }
+ };
+ listing_ $opt, \&url, $r if @$r;
+ };
+};
+
+
+1;
diff --git a/lib/VNWeb/Reviews/Edit.pm b/lib/VNWeb/Reviews/Edit.pm
new file mode 100644
index 00000000..925206d2
--- /dev/null
+++ b/lib/VNWeb/Reviews/Edit.pm
@@ -0,0 +1,122 @@
+package VNWeb::Reviews::Edit;
+
+use VNWeb::Prelude;
+use VNWeb::Releases::Lib;
+
+
+my $FORM = {
+ id => { vndbid => 'w', default => undef },
+ vid => { vndbid => 'v' },
+ vntitle => { _when => 'out' },
+ rid => { vndbid => 'r', default => undef },
+ spoiler => { anybool => 1 },
+ isfull => { anybool => 1 },
+ modnote => { maxlength => 1024, default => '' },
+ text => { maxlength => 100_000, default => '' },
+ locked => { anybool => 1 },
+
+ mod => { _when => 'out', anybool => 1 },
+ releases => { _when => 'out', $VNWeb::Elm::apis{Releases}[0]->%* },
+};
+
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_OUT = form_compile out => $FORM;
+
+
+sub throttled { tuwf->dbVali('SELECT COUNT(*) FROM reviews WHERE uid =', \auth->uid, 'AND date > date_trunc(\'day\', NOW())') >= 5 }
+
+sub releases {
+ my($vid) = @_;
+ my $today = strftime '%Y%m%d', gmtime;
+ [ grep $_->{released} <= $today, releases_by_vn($vid)->@* ]
+}
+
+
+TUWF::get qr{/$RE{vid}/addreview}, sub {
+ my $v = tuwf->dbRowi('SELECT id, title[1+1] FROM', vnt, 'v WHERE NOT hidden AND id =', \tuwf->capture('id'));
+ return tuwf->resNotFound if !$v->{id};
+
+ my $id = tuwf->dbVali('SELECT id FROM reviews WHERE vid =', \$v->{id}, 'AND uid =', \auth->uid);
+ return tuwf->resRedirect("/$id/edit") if $id;
+ return tuwf->resDenied if !can_edit w => {};
+
+ framework_ title => "Write review for $v->{title}", sub {
+ if(throttled) {
+ article_ sub {
+ h1_ 'Throttled';
+ p_ 'You can only submit 5 reviews per day. Check back later!';
+ };
+ } else {
+ elm_ 'Reviews.Edit' => $FORM_OUT, { elm_empty($FORM_OUT)->%*,
+ vid => $v->{id}, vntitle => $v->{title}, releases => releases($v->{id}), mod => auth->permBoardmod()
+ };
+ }
+ };
+};
+
+
+TUWF::get qr{/$RE{wid}/edit}, sub {
+ my $e = tuwf->dbRowi(
+ 'SELECT r.id, r.uid AS user_id, r.vid, r.rid, r.isfull, r.modnote, r.text, r.spoiler, r.locked, v.title[1+1] AS vntitle
+ FROM reviews r JOIN', vnt, 'v ON v.id = r.vid WHERE r.id =', \tuwf->capture('id')
+ );
+ return tuwf->resNotFound if !$e->{id};
+ return tuwf->resDenied if !can_edit w => $e;
+
+ $e->{releases} = releases $e->{vid};
+ $e->{mod} = auth->permBoardmod;
+ framework_ title => "Edit review for $e->{vntitle}", dbobj => $e, tab => 'edit', sub {
+ elm_ 'Reviews.Edit' => $FORM_OUT, $e;
+ };
+};
+
+
+
+elm_api ReviewsEdit => $FORM_OUT, $FORM_IN, sub {
+ my($data) = @_;
+ my $id = delete $data->{id};
+
+ my $review = $id ? tuwf->dbRowi('SELECT id, locked, modnote, text, uid AS user_id FROM reviews WHERE id =', \$id) : {};
+ return tuwf->resNotFound if $id && !$review->{id};
+ return elm_Unauth if !can_edit w => $review;
+
+ if(!auth->permBoardmod) {
+ $data->{locked} = $review->{locked}||0;
+ $data->{modnote} = $review->{modnote}||'';
+ }
+
+ validate_dbid 'SELECT id FROM vn WHERE id IN', $data->{vid};
+ validate_dbid 'SELECT id FROM releases WHERE id IN', $data->{rid} if defined $data->{rid};
+
+ die "Review too long" if !$data->{isfull} && length $data->{text} > 800;
+ $data->{text} = bb_subst_links $data->{text} if $data->{isfull};
+
+ if($id) {
+ $data->{lastmod} = sql 'NOW()' if $review->{text} ne $data->{text};
+ tuwf->dbExeci('UPDATE reviews SET', $data, 'WHERE id =', \$id) if $id;
+ auth->audit($review->{user_id}, 'review edit', "edited $review->{id}") if auth->uid ne $review->{user_id};
+
+ } else {
+ return elm_Unauth if tuwf->dbVali('SELECT 1 FROM reviews WHERE vid =', \$data->{vid}, 'AND uid =', \auth->uid);
+ return elm_Unauth if throttled;
+ $data->{uid} = auth->uid;
+ $id = tuwf->dbVali('INSERT INTO reviews', $data, 'RETURNING id');
+ }
+
+ elm_Redirect "/$id".($data->{uid}?'?submit=1':'')
+};
+
+
+elm_api ReviewsDelete => undef, { id => { vndbid => 'w' } }, sub {
+ my($data) = @_;
+ my $review = tuwf->dbRowi('SELECT id, uid AS user_id FROM reviews WHERE id =', \$data->{id});
+ return tuwf->resNotFound if !$review->{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
+};
+
+
+1;
diff --git a/lib/VNWeb/Reviews/JS.pm b/lib/VNWeb/Reviews/JS.pm
new file mode 100644
index 00000000..32489a33
--- /dev/null
+++ b/lib/VNWeb/Reviews/JS.pm
@@ -0,0 +1,24 @@
+package VNWeb::Reviews::JS;
+
+use VNWeb::Prelude;
+
+our $VOTE = form_compile any => {
+ id => { vndbid => 'w' },
+ my => { undefbool => 1 },
+ overrule => { anybool => 1 },
+ mod => { anybool => 1 },
+};
+
+js_api ReviewsVote => $VOTE, sub {
+ my($data) = @_;
+ my %id = (auth ? (uid => auth->uid) : (ip => norm_ip tuwf->reqIP), id => $data->{id});
+ my %val = (vote => $data->{my}, overrule => auth->permBoardmod ? $data->{overrule} : 0, date => sql 'NOW()');
+ tuwf->dbExeci(
+ defined $data->{my}
+ ? sql 'INSERT INTO reviews_votes', {%id,%val}, 'ON CONFLICT (id,', auth ? 'uid' : 'ip', ') DO UPDATE SET', \%val
+ : sql 'DELETE FROM reviews_votes WHERE', \%id
+ );
+ +{}
+};
+
+1;
diff --git a/lib/VNWeb/Reviews/Lib.pm b/lib/VNWeb/Reviews/Lib.pm
new file mode 100644
index 00000000..8ea54a09
--- /dev/null
+++ b/lib/VNWeb/Reviews/Lib.pm
@@ -0,0 +1,30 @@
+package VNWeb::Reviews::Lib;
+
+use VNWeb::Prelude;
+use Exporter 'import';
+our @EXPORT = qw/reviews_helpfulness reviews_vote_ reviews_format/;
+
+sub reviews_helpfulness {
+ my($w) = @_;
+ my ($uup, $aup, $udown, $adown) = (floor($w->{c_up}/100), $w->{c_up}%100, floor($w->{c_down}/100), $w->{c_down}%100);
+ return sprintf '%.0f', max 0, ($uup + 0.3*$aup) - ($udown + 0.3*$adown);
+}
+
+sub reviews_vote_ {
+ my($w) = @_;
+ span_ sub {
+ span_ widget(ReviewsVote => $VNWeb::Reviews::JS::VOTE, {%$w, mod => auth->permBoardmod||0}), ''
+ if !config->{read_only} && ($w->{can} || auth->permBoardmod);
+ my $p = reviews_helpfulness $w;
+ small_ sprintf ' %d point%s', $p, $p == 1 ? '' : 's';
+ small_ sprintf ' %.2f/%.2f', $w->{c_up}/100, $w->{c_down}/100 if auth->permBoardmod;
+ }
+}
+
+# Mini-reviews don't expand vndbids on submission, so they need an extra bb_subst_links() pass.
+sub reviews_format {
+ my($w, @opt) = @_;
+ bb_format($w->{isfull} ? $w->{text} : bb_subst_links($w->{text}), @opt);
+}
+
+1;
diff --git a/lib/VNWeb/Reviews/List.pm b/lib/VNWeb/Reviews/List.pm
new file mode 100644
index 00000000..84985de0
--- /dev/null
+++ b/lib/VNWeb/Reviews/List.pm
@@ -0,0 +1,87 @@
+package VNWeb::Reviews::List;
+
+use VNWeb::Prelude;
+
+
+sub tablebox_ {
+ my($opt, $lst, $count) = @_;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ paginate_ \&url, $opt->{p}, [$count, 50], 't';
+ article_ class => 'browse reviewlist', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'id', $opt, \&url; debug_ $lst };
+ td_ class => 'tc2', 'By';
+ td_ class => 'tc3', 'Vote';
+ td_ class => 'tc4', 'Type';
+ td_ class => 'tc5', 'Review';
+ td_ class => 'tc6', sub { txt_ 'Score*'; sortable_ 'rating', $opt, \&url } if auth->isMod;
+ td_ class => 'tc7', 'C#';
+ td_ class => 'tc8', sub { txt_ 'Last comment'; sortable_ 'lastpost', $opt, \&url };
+ } };
+ tr_ sub {
+ td_ class => 'tc1', fmtdate $_->{date}, 'compact';
+ td_ class => 'tc2', sub { user_ $_ };
+ td_ class => 'tc3', fmtvote $_->{vote};
+ td_ class => 'tc4', $_->{isfull} ? 'Full' : 'Mini';
+ td_ class => 'tc5', sub { a_ href => "/$_->{id}", tattr $_; small_ ' (flagged)' if $_->{c_flagged} };
+ 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_';
+ txt_ ' @ ';
+ a_ href => "/$_->{id}.$_->{c_lastnum}#last", fmtdate $_->{ldate}, 'full';
+ } : '';
+ } for @$lst;
+ };
+ };
+ paginate_ \&url, $opt->{p}, [$count, 50], 'b';
+}
+
+
+TUWF::get qr{/w}, sub {
+ my $opt = tuwf->validate(get =>
+ p => { page => 1 },
+ s => { onerror => 'id', enum => [qw[id lastpost rating]] },
+ o => { onerror => 'd', enum => [qw[a d]] },
+ u => { onerror => 0, vndbid => 'u' },
+ )->data;
+ $opt->{s} = 'id' if $opt->{s} eq 'rating' && !auth->isMod;
+
+ my $u = $opt->{u} && tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \$opt->{u});
+ return tuwf->resNotFound if $u && (!$u->{id} || (!$u->{user_name} && !auth->isMod));
+
+ my $where = sql_and
+ $u ? sql 'w.uid =', \$u->{id} : (),
+ auth->isMod ? () : 'NOT w.c_flagged';
+ my $count = tuwf->dbVali('SELECT COUNT(*) FROM reviews w WHERE', $where);
+ my $lst = tuwf->dbPagei({results => 50, page => $opt->{p}}, '
+ SELECT w.id, w.vid, w.isfull, w.c_up, w.c_down, w.c_flagged, w.c_count, w.c_lastnum, v.title, uv.vote
+ , ', sql_user(), ',', sql_totime('w.date'), 'as date
+ , ', sql_user('wpu','lu_'), ',', sql_totime('wp.date'), 'as ldate
+ FROM reviews w
+ JOIN', vnt, 'v ON v.id = w.vid
+ LEFT JOIN users u ON u.id = w.uid
+ LEFT JOIN reviews_posts wp ON w.id = wp.id AND w.c_lastnum = wp.num
+ LEFT JOIN users wpu ON wpu.id = wp.uid
+ LEFT JOIN ulist_vns uv ON uv.uid = w.uid AND uv.vid = w.vid
+ WHERE', $where, '
+ ORDER BY', {id => 'w.id', lastpost => 'wp.date', rating => 'w.c_up-w.c_down'}->{$opt->{s}}, {a=>'ASC',d=>'DESC'}->{$opt->{o}}, 'NULLS LAST'
+ );
+
+ my $title = $u ? 'Reviews by '.user_displayname($u) : 'Browse reviews';
+ framework_ title => $title, $u ? (dbobj => $u, tab => 'reviews') : (), sub {
+ article_ sub {
+ h1_ $title;
+ if($u && !$count) {
+ p_ +(auth && $u->{id} eq auth->uid ? 'You have' : user_displayname($u).' has').' not submitted any reviews yet.';
+ }
+ p_ 'Note: The score column is only visible to moderators.' if $count && auth->isMod;
+ };
+ tablebox_ $opt, $lst, $count if $count;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Reviews/Page.pm b/lib/VNWeb/Reviews/Page.pm
new file mode 100644
index 00000000..3f58905b
--- /dev/null
+++ b/lib/VNWeb/Reviews/Page.pm
@@ -0,0 +1,166 @@
+package VNWeb::Reviews::Page;
+
+use VNWeb::Prelude;
+use VNWeb::Releases::Lib;
+use VNWeb::Reviews::Lib;
+
+
+my $COMMENT = form_compile any => {
+ id => { vndbid => 'w' },
+ msg => { maxlength => 32768 }
+};
+
+js_api ReviewComment => $COMMENT, sub {
+ my($data) = @_;
+ my $w = tuwf->dbRowi('SELECT id, locked FROM reviews WHERE id =', \$data->{id});
+ return tuwf->resNotFound if !$w->{id};
+ return tuwf->resDenied if !can_edit t => $w;
+
+ my $num = sql 'COALESCE((SELECT MAX(num)+1 FROM reviews_posts WHERE id =', \$data->{id}, '),1)';
+ my $msg = bb_subst_links $data->{msg};
+ $num = tuwf->dbVali('INSERT INTO reviews_posts', { id => $w->{id}, num => $num, uid => auth->uid, msg => $msg }, 'RETURNING num');
+ +{ _redir => "/$w->{id}.$num#last" };
+};
+
+
+
+sub review_ {
+ my($w) = @_;
+
+ input_ type => 'checkbox', class => 'hidden', id => 'reviewspoil', (auth->pref('spoilers')||0) == 2 ? ('checked', 'checked') : (), undef;
+ my @spoil = $w->{spoiler} ? (class => 'reviewspoil') : ();
+ table_ class => 'fullreview', sub {
+ tr_ sub {
+ td_ 'Subject';
+ td_ sub {
+ a_ href => "/$w->{vid}", tattr $w;
+ if($w->{rid}) {
+ br_;
+ platform_ $_ for $w->{platforms}->@*;
+ abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' for $w->{lang}->@*;
+ abbr_ class => "icon-rt$w->{rtype}", title => $w->{rtype}, '' if $w->{rtype};
+ a_ href => "/$w->{rid}", tattr $w->{rtitle};
+ b_ ' (different visual novel)' if !$w->{rtype};
+ }
+ };
+ };
+ tr_ sub {
+ td_ 'By';
+ td_ sub {
+ span_ style => 'float: right; padding-left: 25px; text-align: right', sub {
+ txt_ 'Helpfulness: '.reviews_helpfulness($w);
+ br_;
+ strong_ 'Vote: '.fmtvote($w->{vote}) if $w->{vote};
+ };
+ user_ $w;
+ my($date, $lastmod) = map $_&&fmtdate($_,'compact'), $w->@{'date', 'lastmod'};
+ txt_ " on $date";
+ small_ " last updated on $lastmod" if $lastmod && $date ne $lastmod;
+ br_ if $w->{c_flagged} || $w->{locked} || ($w->{spoiler} && (auth->pref('spoilers')||0) == 2);
+ if($w->{c_flagged}) {
+ br_;
+ small_ 'Flagged: this review is below the voting threshold and not visible on the VN page.';
+ }
+ if($w->{locked}) {
+ br_;
+ small_ 'Locked: commenting on this review has been disabled.';
+ }
+ if($w->{spoiler} && (auth->pref('spoilers')||0) == 2) {
+ br_;
+ strong_ 'This review contains spoilers.';
+ }
+ }
+ };
+ tr_ sub {
+ td_ 'Moderator note';
+ td_ sub { lit_ bb_format $w->{modnote} };
+ } if $w->{modnote};
+ tr_ class => 'reviewnotspoil', sub {
+ td_ '';
+ td_ sub {
+ label_ class => 'fake_link', for => 'reviewspoil', 'This review contains spoilers, click to view.';
+ };
+ } if $w->{spoiler};
+ tr_ @spoil, sub {
+ td_ 'Review';
+ td_ sub { lit_ reviews_format $w }
+ };
+ tr_ @spoil, sub {
+ td_ '';
+ td_ style => 'text-align: right', sub {
+ reviews_vote_ $w;
+ };
+ };
+ }
+}
+
+
+TUWF::get qr{/$RE{wid}(?:(?<sep>[\./])$RE{num})?}, sub {
+ my($id, $sep, $num) = (tuwf->capture('id'), tuwf->capture('sep')||'', tuwf->capture('num'));
+ my $w = tuwf->dbRowi(
+ 'SELECT r.id, r.vid, r.rid, r.isfull, r.modnote, r.text, r.spoiler, r.locked, COALESCE(c.count,0) AS count, r.c_flagged, r.c_up, r.c_down, uv.vote, rm.id IS NULL AS can
+ , v.title, rel.title AS rtitle, relv.rtype, rv.vote AS my, COALESCE(rv.overrule,false) AS overrule
+ , ', sql_user(), ',', sql_totime('r.date'), 'AS date,', sql_totime('r.lastmod'), 'AS lastmod
+ FROM reviews r
+ JOIN', vnt, 'v ON v.id = r.vid
+ LEFT JOIN', releasest, 'rel ON rel.id = r.rid
+ LEFT JOIN releases_vn relv ON relv.id = r.rid AND relv.vid = r.vid
+ 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', 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
+ );
+ return tuwf->resNotFound if !$w->{id};
+
+ enrich_flatten lang => rid => id => sub { sql 'SELECT id, lang FROM releases_titles WHERE id IN', $_, 'ORDER BY id, lang' }, $w;
+ enrich_flatten platforms => rid => id => sub { sql 'SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY id, platform' }, $w;
+
+ my $page = $sep eq '/' ? $num||1 : $sep ne '.' ? 1
+ : ceil((tuwf->dbVali('SELECT COUNT(*) FROM reviews_posts WHERE num <=', \$num, 'AND id =', \$id)||9999)/25);
+ $num = 0 if $sep ne '.';
+
+ my $posts = tuwf->dbPagei({ results => 25, page => $page },
+ 'SELECT rp.id, rp.num, rp.hidden, rp.msg',
+ ',', sql_user(),
+ ',', sql_totime('rp.date'), ' as date',
+ ',', sql_totime('rp.edited'), ' as edited
+ FROM reviews_posts rp
+ LEFT JOIN users u ON rp.uid = u.id
+ WHERE rp.id =', \$id, '
+ ORDER BY rp.num'
+ );
+ return tuwf->resNotFound if $num && !grep $_->{num} == $num, @$posts;
+
+ auth->notiRead($id, undef);
+ auth->notiRead($id, [ map $_->{num}, $posts->@* ]) if @$posts;
+
+ my $newreview = auth && $w->{user_id} && auth->uid eq $w->{user_id} && tuwf->reqGet('submit');
+
+ my $title = "Review of $w->{title}[1]";
+ framework_ title => $title, index => 1, dbobj => $w,
+ $num||$page>1 ? (pagevars => {sethash=>$num?"p$num":'threadstart'}) : (),
+ sub {
+ article_ sub {
+ itemmsg_ $w;
+ h1_ $title;
+ div_ class => 'notice', sub {
+ h2_ 'Review has been successfully submitted! ';
+ a_ href => "/$w->{id}", "dismiss";
+ } if $newreview;
+ review_ $w;
+ };
+ if(grep !defined $_->{hidden}, @$posts) {
+ nav_ sub {
+ h1_ 'Comments';
+ };
+ VNWeb::Discussions::Thread::posts_($w, $posts, $page);
+ } else {
+ div_ id => 'threadstart', '';
+ }
+ div_ widget(ReviewComment => $COMMENT, { id => $w->{id}, msg => '' }), '' if !$newreview && $w->{count} <= $page*25 && can_edit t => $w;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Reviews/VNTab.pm b/lib/VNWeb/Reviews/VNTab.pm
new file mode 100644
index 00000000..c0e6cbbb
--- /dev/null
+++ b/lib/VNWeb/Reviews/VNTab.pm
@@ -0,0 +1,93 @@
+package VNWeb::Reviews::VNTab;
+
+use VNWeb::Prelude;
+use VNWeb::Reviews::Lib;
+
+
+sub reviews_ {
+ my($v, $mini) = @_;
+
+ # TODO: Better order, pagination, option to show flagged reviews
+ my $lst = tuwf->dbAlli(
+ 'SELECT r.id, r.rid, r.modnote, r.text, r.spoiler, r.c_count, r.c_up, r.c_down, uv.vote, rv.vote AS my
+ , COALESCE(rv.overrule,false) AS overrule, NOT r.isfull AND rm.id IS NULL AS can
+ , ', sql_totime('r.date'), 'AS date, ', sql_user(), '
+ 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.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'
+ );
+ return if !@$lst;
+
+ article_ sub {
+ h1_ $mini ? 'Mini reviews' : 'Full reviews';
+ debug_ $lst;
+ };
+ div_ class => 'reviews', sub {
+ article_ sub {
+ my $r = $_;
+ div_ sub {
+ span_ sub {
+ txt_ 'By '; user_ $r; txt_ ' on '.fmtdate $r->{date}, 'compact';
+ small_ ' contains spoilers' if $r->{spoiler} && (auth->pref('spoilers')||0) == 2;
+ };
+ a_ href => "/$r->{rid}", $r->{rid} if $r->{rid};
+ span_ "Vote: ".fmtvote($r->{vote}) if $r->{vote};
+ };
+ div_ sub {
+ p_ sub { lit_ bb_format $r->{modnote} } if $r->{modnote};
+ };
+ div_ sub {
+ span_ sub {
+ txt_ '<';
+ if(can_edit w => $r) {
+ a_ href => "/$r->{id}/edit", 'edit';
+ txt_ ' - ';
+ }
+ a_ href => "/report/$r->{id}", 'report';
+ txt_ '>';
+ };
+ my $html = reviews_format $r, maxlength => $mini ? undef : 700;
+ $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 => 'hidden', (auth->pref('spoilers')||0) == 2 ? ('checked', 'checked') : (), undef;
+ div_ sub { lit_ $html };
+ span_ class => 'fake_link', 'This review contains spoilers, click to view.';
+ }
+ } else {
+ lit_ $html;
+ }
+ };
+ div_ sub {
+ a_ href => "/$r->{id}#threadstart", $r->{c_count} == 1 ? '1 comment' : "$r->{c_count} comments";
+ reviews_vote_ $r;
+ };
+ } for @$lst;
+ };
+}
+
+
+TUWF::get qr{/$RE{vid}/(?<mini>mini|full)?reviews}, sub {
+ my $mini = !tuwf->capture('mini') ? undef : tuwf->capture('mini') eq 'mini' ? 1 : 0;
+ my $v = db_entry tuwf->capture('id');
+ return tuwf->resNotFound if !$v;
+ VNWeb::VN::Page::enrich_vn($v);
+
+ framework_ title => ($mini?'Mini reviews':'Reviews')." for $v->{title}[1]", index => 1, dbobj => $v, hiddenmsg => 1,
+ sub {
+ VNWeb::VN::Page::infobox_($v);
+ VNWeb::VN::Page::tabs_($v, !defined $mini ? 'reviews' : $mini ? 'minireviews' : 'fullreviews');
+ if(defined $mini) {
+ reviews_ $v, $mini;
+ } else {
+ reviews_ $v, 1;
+ reviews_ $v, 0;
+ }
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Staff/Edit.pm b/lib/VNWeb/Staff/Edit.pm
index deb5e7fc..42ef2a3d 100644
--- a/lib/VNWeb/Staff/Edit.pm
+++ b/lib/VNWeb/Staff/Edit.pm
@@ -4,27 +4,23 @@ use VNWeb::Prelude;
my $FORM = {
- id => { required => 0, id => 1 },
- aid => { int => 1, range => [ -1000, 1<<40 ] }, # X
+ id => { default => undef, vndbid => 's' },
+ main => { int => 1, range => [ -1000, 1<<40 ] }, # X
alias => { maxlength => 100, sort_keys => 'aid', aoh => {
aid => { int => 1, range => [ -1000, 1<<40 ] }, # X, negative IDs are for new aliases
- name => { maxlength => 200 },
- original => { maxlength => 200, required => 0, default => '' },
+ name => { sl => 1, maxlength => 200 },
+ latin => { sl => 1, maxlength => 200, default => undef },
inuse => { anybool => 1, _when => 'out' },
+ wantdel => { anybool => 1, _when => 'out' },
} },
- desc => { required => 0, default => '', maxlength => 5000 },
- gender => { required => 0, default => 'unknown', enum => [qw[unknown m f]] },
+ description=> { default => '', maxlength => 5000 },
+ gender => { default => 'unknown', enum => [qw[unknown m f]] },
lang => { language => 1 },
- l_site => { required => 0, default => '', weburl => 1 },
- l_wikidata => { required => 0, id => 1 },
- l_twitter => { required => 0, default => '', regex => qr/^\S+$/, maxlength => 16 },
- l_anidb => { required => 0, id => 1, default => undef },
- l_pixiv => { required => 0, id => 1, default => 0 },
+ l_site => { default => '', weburl => 1 },
hidden => { anybool => 1 },
locked => { anybool => 1 },
-
- authmod => { _when => 'out', anybool => 1 },
editsum => { _when => 'in out', editsum => 1 },
+ validate_extlinks 's'
};
my $FORM_OUT = form_compile out => $FORM;
@@ -33,22 +29,28 @@ my $FORM_CMP = form_compile cmp => $FORM;
TUWF::get qr{/$RE{srev}/edit} => sub {
- my $e = db_entry s => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ my $e = db_entry tuwf->captures('id', 'rev') or return tuwf->resNotFound;
return tuwf->resDenied if !can_edit s => $e;
- $e->{authmod} = auth->permDbmod;
- $e->{editsum} = $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision s$e->{id}.$e->{chrev}";
+ $e->{editsum} = $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
+
+ my $alias_inuse = 'EXISTS(SELECT 1 FROM vn_staff WHERE aid = sa.aid UNION ALL SELECT 1 FROM vn_seiyuu WHERE aid = sa.aid)';
+ enrich_merge aid => sub { "SELECT aid, $alias_inuse AS inuse, false AS wantdel FROM unnest(", sql_array(@$_), '::int[]) AS sa(aid)' }, $e->{alias};
+
+ # If we're reverting to an older revision, we have to make sure all the
+ # still referenced aliases are included.
+ push $e->{alias}->@*, tuwf->dbAlli(
+ "SELECT aid, name, latin, true AS inuse, true AS wantdel
+ FROM staff_alias sa WHERE $alias_inuse AND sa.id =", \$e->{id}, 'AND sa.aid NOT IN', [ map $_->{aid}, $e->{alias}->@* ]
+ )->@* if $e->{chrev} != $e->{maxrev};
- enrich_merge aid => sub {
- 'SELECT aid, EXISTS(SELECT 1 FROM vn_staff WHERE aid = x.aid UNION ALL SELECT 1 FROM vn_seiyuu WHERE aid = x.aid) AS inuse
- FROM unnest(', sql_array(@$_), '::int[]) AS x(aid)'
- }, $e->{alias};
+ $e->{alias} = [ sort { ($a->{latin}//$a->{name}) cmp ($b->{latin}//$b->{name}) } $e->{alias}->@* ];
- my $name = (grep $_->{aid} == $e->{aid}, @{$e->{alias}})[0]{name};
- framework_ title => "Edit $name", type => 's', dbobj => $e, tab => 'edit',
+ my $name = titleprefs_swap($e->{lang}, @{ (grep $_->{aid} == $e->{main}, @{$e->{alias}})[0] }{qw/ name latin /})->[1];
+ framework_ title => "Edit $name", dbobj => $e, tab => 'edit',
sub {
editmsg_ s => $e, "Edit $name";
- elm_ 'StaffEdit.Main' => $FORM_OUT, $e;
+ div_ widget(StaffEdit => $FORM_OUT, $e), '';
};
};
@@ -58,44 +60,50 @@ TUWF::get qr{/s/new}, sub {
framework_ title => 'Add staff member',
sub {
editmsg_ s => undef, 'Add staff member';
- elm_ 'StaffEdit.New';
+ div_ widget(StaffEdit => $FORM_OUT, {
+ elm_empty($FORM_OUT)->%*,
+ alias => [ { aid => -1, name => '', latin => undef, inuse => 0, wantdel => 0 } ],
+ main => -1
+ }), '';
};
};
-elm_api StaffEdit => $FORM_OUT, $FORM_IN, sub {
+js_api StaffEdit => $FORM_IN, sub {
my $data = shift;
my $new = !$data->{id};
- my $e = $new ? { id => 0 } : db_entry s => $data->{id} or return tuwf->resNotFound;
- return elm_Unauth if !can_edit s => $e;
+ my $e = $new ? { id => 0 } : db_entry $data->{id} or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit s => $e;
if(!auth->permDbmod) {
$data->{hidden} = $e->{hidden}||0;
$data->{locked} = $e->{locked}||0;
}
$data->{l_wp} = $e->{l_wp}||'';
- $data->{desc} = bb_subst_links $data->{desc};
+ $data->{description} = bb_subst_links $data->{description};
- # The form validation only checks for duplicate aid's, but the name+original should also be unique.
+ # The form validation only checks for duplicate aid's, but the name+latin should also be unique.
my %names;
- die "Duplicate aliases" if grep $names{"$_->{name}\x00$_->{original}"}++, $data->{alias}->@*;
+ die "Duplicate aliases" if grep $names{"$_->{name}\x00".($_->{latin}//'')}++, $data->{alias}->@*;
+ die "Latin = name" if grep $_->{latin} && $_->{name} eq $_->{latin}, $data->{alias}->@*;
- # For positive alias IDs: Make sure they exist and are owned by this entry.
+ # For positive alias IDs: Make sure they exist and are (or were) owned by this entry.
validate_dbid
- sql('SELECT aid FROM staff_alias WHERE id =', \$e->{id}, 'AND aid IN'),
+ sql('SELECT aid FROM staff_alias_hist WHERE chid IN(SELECT id FROM changes WHERE itemid =', \$e->{id}, ') AND aid IN'),
grep $_>=0, map $_->{aid}, $data->{alias}->@*;
# For negative alias IDs: Assign a new ID.
for my $alias (grep $_->{aid} < 0, $data->{alias}->@*) {
my $new = tuwf->dbVali(select => sql_func nextval => \'staff_alias_aid_seq');
- $data->{aid} = $new if $alias->{aid} == $data->{aid};
+ $data->{main} = $new if $alias->{aid} == $data->{main};
$alias->{aid} = $new;
}
# We rely on Postgres to throw an error if we attempt to delete an alias that is still being referenced.
- return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
- my($id,undef,$rev) = db_edit s => $e->{id}, $data;
- elm_Redirect "/s$id.$rev";
+ return +{ _err => 'No changes.' } if !$new && !form_changed $FORM_CMP, $data, $e;
+
+ my $ch = db_edit s => $e->{id}, $data;
+ +{ _redir => "/$ch->{nitemid}.$ch->{nrev}" };
};
1;
diff --git a/lib/VNWeb/Staff/Elm.pm b/lib/VNWeb/Staff/Elm.pm
new file mode 100644
index 00000000..43cff16a
--- /dev/null
+++ b/lib/VNWeb/Staff/Elm.pm
@@ -0,0 +1,34 @@
+package VNWeb::Staff::Elm;
+
+use VNWeb::Prelude;
+
+elm_api Staff => undef, {
+ search => { type => 'array', values => { searchquery => 1 } },
+}, sub {
+ my($data) = @_;
+ my @q = grep $_, $data->{search}->@*;
+
+ elm_StaffResult @q ? tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT s.id, s.lang, s.aid, s.title[1+1], s.title[1+1+1+1] as alttitle
+ FROM', staff_aliast, 's', VNWeb::Validate::SearchQuery::sql_joina(\@q, 's', 's.id', 's.aid'), '
+ WHERE NOT s.hidden
+ ORDER BY sc.score DESC, s.sorttitle
+ ') : [];
+};
+
+js_api Staff => {
+ search => { type => 'array', values => { searchquery => 1 } },
+}, sub {
+ my($data) = @_;
+ my @q = grep $_, $data->{search}->@*;
+
+ +{ results => @q ? tuwf->dbAlli(
+ 'SELECT s.id, s.lang, s.aid, s.title[1+1], s.title[1+1+1+1] as alttitle
+ FROM', staff_aliast, 's', VNWeb::Validate::SearchQuery::sql_joina(\@q, 's', 's.id', 's.aid'), '
+ WHERE NOT s.hidden
+ ORDER BY sc.score DESC, s.sorttitle
+ LIMIT', \30
+ ) : [] };
+};
+
+1;
diff --git a/lib/VNWeb/Staff/List.pm b/lib/VNWeb/Staff/List.pm
new file mode 100644
index 00000000..fb92db52
--- /dev/null
+++ b/lib/VNWeb/Staff/List.pm
@@ -0,0 +1,94 @@
+package VNWeb::Staff::List;
+
+use VNWeb::Prelude;
+use VNWeb::AdvSearch;
+use VNWeb::Filters;
+
+
+sub listing_ {
+ my($opt, $list, $count) = @_;
+ my sub url { '?'.query_encode %$opt, @_ }
+ paginate_ \&url, $opt->{p}, [$count, 150], 't';
+ article_ class => 'staffbrowse', sub {
+ h1_ 'Staff list';
+ ul_ sub {
+ li_ sub {
+ abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, '';
+ a_ href => "/$_->{id}", tattr $_;
+ } for @$list;
+ };
+ };
+ paginate_ \&url, $opt->{p}, [$count, 150], 'b';
+}
+
+
+TUWF::get qr{/s(?:/(?<char>all|[a-z0]))?}, sub {
+ my $opt = tuwf->validate(get =>
+ q => { searchquery => 1 },
+ p => { upage => 1 },
+ f => { advsearch_err => 's' },
+ n => { onerror => [], type => 'array', scalar => 1, values => { anybool => 1 } },
+ ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
+ fil => { onerror => '' },
+ )->data;
+ $opt->{ch} = $opt->{ch}[0];
+ $opt->{n} = $opt->{n}[0];
+
+ # compat with old URLs
+ my $oldch = tuwf->capture('char');
+ $opt->{ch} //= $oldch if defined $oldch && $oldch ne 'all';
+
+ # URL compatibility with old filters
+ if(!$opt->{f}->{query} && $opt->{fil}) {
+ my $q = eval {
+ my $f = filter_parse s => $opt->{fil};
+ $opt->{n} = $f->{truename} if defined $f->{truename};
+ $f = filter_staff_adv $f;
+ tuwf->compile({ advsearch => 's' })->validate(@$f > 1 ? $f : undef)->data;
+ };
+ return tuwf->resRedirect(tuwf->reqPath().'?'.query_encode(%$opt, fil => undef, f => $q), 'perm') if $q;
+ }
+
+ $opt->{f} = advsearch_default 's' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
+
+ my $where = sql_and
+ $opt->{n} ? 's.main = s.aid' : (),
+ 'NOT s.hidden', $opt->{f}->sql_where(),
+ defined($opt->{ch}) ? sql 'match_firstchar(s.sorttitle, ', \$opt->{ch}, ')' : ();
+
+ my $time = time;
+ my($count, $list);
+ db_maytimeout {
+ $count = tuwf->dbVali('SELECT count(*) FROM', staff_aliast, 's WHERE', sql_and $where, $opt->{q}->sql_where('s', 's.id', 's.aid'));
+ $list = $count ? tuwf->dbPagei({results => 150, page => $opt->{p}}, '
+ SELECT s.id, s.title, s.lang
+ FROM', staff_aliast, 's', $opt->{q}->sql_join('s', 's.id', 's.aid'), '
+ WHERE', $where,
+ 'ORDER BY', $opt->{q} ? 'sc.score DESC, ' : (), 's.sorttitle, s.aid'
+ ) : [];
+ } || (($count, $list) = (undef, []));
+ $time = time - $time;
+
+ framework_ title => 'Browse staff', sub {
+ article_ sub {
+ h1_ 'Browse staff';
+ form_ action => '/s', method => 'get', sub {
+ searchbox_ s => $opt->{q}//'';
+ p_ class => 'browseopts', sub {
+ button_ type => 'submit', name => 'ch', value => ($_//''), ($_//'') eq ($opt->{ch}//'') ? (class => 'optselected') : (), !defined $_ ? 'ALL' : $_ ? uc $_ : '#'
+ for (undef, 'a'..'z', 0);
+ };
+ p_ class => 'browseopts', sub {
+ button_ type => 'submit', name => 'n', value => 0, !$opt->{n} ? (class => 'optselected') : (), 'Display aliases';
+ button_ type => 'submit', name => 'n', value => 1, $opt->{n} ? (class => 'optselected') : (), 'Hide aliases';
+ };
+ input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
+ input_ type => 'hidden', name => 'n', value => $opt->{n}//0;
+ $opt->{f}->elm_($count, $time);
+ };
+ };
+ listing_ $opt, $list, $count if $count;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Staff/Page.pm b/lib/VNWeb/Staff/Page.pm
index fe5a5696..0dc1a856 100644
--- a/lib/VNWeb/Staff/Page.pm
+++ b/lib/VNWeb/Staff/Page.pm
@@ -1,31 +1,37 @@
package VNWeb::Staff::Page;
use VNWeb::Prelude;
-use VNWeb::Docs::Lib;
+use VNWeb::ULists::Lib;
sub enrich_item {
my($s) = @_;
- # Add a 'main' flag to each alias
- $_->{main} = $s->{aid} == $_->{aid} for $s->{alias}->@*;
+ # Add a 'main' flag and title field to each alias
+ for ($s->{alias}->@*) {
+ $_->{main} = $s->{main} == $_->{aid};
+ $_->{title} = titleprefs_swap $s->{lang}, $_->{name}, $_->{latin};
+ }
- # Sort aliases by name
- $s->{alias} = [ sort { $a->{name} cmp $b->{name} || ($a->{original}||'') cmp ($b->{original}||'') } $s->{alias}->@* ];
+ # Sort aliases by aid for more readable comparison at revisions.
+ $s->{alias} = [ sort { $a->{aid} <=> $b->{aid} } $s->{alias}->@* ];
}
sub _rev_ {
my($s) = @_;
- revision_ s => $s, \&enrich_item,
+ my %aid;
+ revision_ $s, \&enrich_item,
[ alias => 'Names', fmt => sub {
+ my $num = ($aid{$_->{aid}} ||= keys %aid);
+ strong_ "$num: ";
txt_ $_->{name};
- txt_ " ($_->{original})" if $_->{original};
- b_ class => 'grayedout', ' (primary)' if $_->{main};
+ txt_ " ($_->{latin})" if $_->{latin};
+ small_ ' (primary)' if $_->{main};
} ],
[ gender => 'Gender', fmt => \%GENDER ],
[ lang => 'Language', fmt => \%LANGUAGE ],
- [ desc => 'Description' ],
+ [ description => 'Description' ],
revision_extlinks 's'
}
@@ -35,25 +41,25 @@ sub _infotable_ {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ colspan => 2, sub {
- b_ style => 'margin-right: 10px', $main->{name};
- b_ class => 'grayedout', style => 'margin-right: 10px', lang => $s->{lang}, $main->{original} if $main->{original};
- abbr_ class => "icons gen $s->{gender}", title => $GENDER{$s->{gender}}, '' if $s->{gender} ne 'unknown';
+ span_ style => 'margin-right: 10px', tlang($main->{title}[0], $main->{title}[1]), $main->{title}[1];
+ small_ style => 'margin-right: 10px', tlang($main->{title}[2], $main->{title}[3]), $main->{title}[3] if $main->{title}[1] ne $main->{title}[3];
+ abbr_ class => "icon-gen-$s->{gender}", title => $GENDER{$s->{gender}}, '' if $s->{gender} ne 'unknown';
}
} };
tr_ sub {
td_ class => 'key', 'Language';
- td_ $LANGUAGE{$s->{lang}};
+ td_ $LANGUAGE{$s->{lang}}{txt};
};
- my @alias = grep !$_->{main}, $s->{alias}->@*;
+ my @alias = sort { ($a->{latin}//$a->{name}) cmp ($b->{latin}//$b->{name}) } grep !$_->{main}, $s->{alias}->@*;
tr_ sub {
td_ @alias == 1 ? 'Alias' : 'Aliases';
td_ sub {
table_ class => 'aliases', sub {
tr_ class => 'nostripe', sub {
- td_ class => 'key', $_->{original} ? () : (colspan => 2), $_->{name};
- td_ lang => $s->{lang}, $_->{original} if $_->{original};
+ td_ class => 'key', $_->{latin} ? () : (colspan => 2), tlang($s->{lang}, $_->{name}), $_->{name};
+ td_ tlang($s->{lang}, $_->{latin}), $_->{latin} if $_->{latin};
} for @alias;
};
};
@@ -62,7 +68,7 @@ sub _infotable_ {
tr_ sub {
td_ class => 'key', 'Links';
td_ sub {
- join_ \&br_, sub { a_ href => $_->[1], $_->[0] }, $s->{extlinks}->@*;
+ join_ \&br_, sub { a_ href => $_->{url2}, $_->{label} }, $s->{extlinks}->@*;
};
} if $s->{extlinks}->@*;
};
@@ -73,34 +79,45 @@ sub _roles_ {
my($s) = @_;
my %alias = map +($_->{aid}, $_), $s->{alias}->@*;
- my $roles = tuwf->dbAlli(q{
- SELECT v.id, vs.aid, vs.role, vs.note, v.c_released, v.title, v.original
+ my $roles = tuwf->dbAlli('
+ SELECT v.id, vs.aid, vs.role, vs.note, ve.name, ve.official, ve.lang, v.c_released, v.title
FROM vn_staff vs
- JOIN vn v ON v.id = vs.id
- WHERE vs.aid IN}, [ keys %alias ], q{
+ JOIN', vnt, 'v ON v.id = vs.id
+ LEFT JOIN vn_editions ve ON ve.id = vs.id AND ve.eid = vs.eid
+ WHERE vs.aid IN', [ keys %alias ], '
AND NOT v.hidden
- ORDER BY v.c_released ASC, v.title ASC, vs.role ASC
- });
+ ORDER BY v.c_released ASC, v.sorttitle ASC, ve.lang NULLS FIRST, ve.name NULLS FIRST, vs.role ASC
+ ');
return if !@$roles;
+ enrich_ulists_widget $roles;
- h1_ class => 'boxtitle', sprintf 'Credits (%d)', scalar @$roles;
- div_ class => 'mainbox browse staffroles', sub {
+ nav_ sub {
+ h1_ sprintf 'Credits (%d)', scalar @$roles;
+ };
+ article_ class => 'browse staffroles', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
+ td_ class => 'tc_ulist', '' if auth;
td_ class => 'tc1', 'Title';
td_ class => 'tc2', 'Released';
td_ class => 'tc3', 'Role';
td_ class => 'tc4', 'As';
td_ class => 'tc5', 'Note';
}};
+ my %vns;
tr_ sub {
my($v, $a) = ($_, $alias{$_->{aid}});
+ td_ class => 'tc_ulist', sub { ulists_widget_ $v if !$vns{$v->{id}}++ } if auth;
td_ class => 'tc1', sub {
- a_ href => "/v$v->{id}", title => $v->{original}||$v->{title}, shorten $v->{title}, 60;
+ a_ href => "/$v->{id}", tattr $v;
+ lit_ ' ' if $v->{name};
+ abbr_ class => "icon-lang-$v->{lang}", title => $LANGUAGE{$v->{lang}}{txt}, '' if $v->{lang};
+ txt_ $v->{name} if $v->{name} && $v->{official};
+ small_ $v->{name} if $v->{name} && !$v->{official};
};
td_ class => 'tc2', sub { rdate_ $v->{c_released} };
td_ class => 'tc3', $CREDIT_TYPE{$v->{role}};
- td_ class => 'tc4', title => $a->{original}||$a->{name}, $a->{name};
+ td_ class => 'tc4', tattr $a;
td_ class => 'tc5', $v->{note};
} for @$roles;
};
@@ -112,75 +129,81 @@ sub _cast_ {
my($s) = @_;
my %alias = map +($_->{aid}, $_), $s->{alias}->@*;
- my $cast = tuwf->dbAlli(q{
- SELECT vs.aid, v.id, v.c_released, v.title, v.original, c.id AS cid, c.name AS c_name, c.original AS c_original, vs.note,
+ my $cast = [ grep defined $_->{spoil}, tuwf->dbAlli('
+ SELECT vs.aid, v.id, v.c_released, v.title, c.id AS cid, c.title AS c_title, vs.note,
(SELECT MIN(cv.spoil) FROM chars_vns cv WHERE cv.id = c.id AND cv.vid = v.id) AS spoil
FROM vn_seiyuu vs
- JOIN vn v ON v.id = vs.id
- JOIN chars c ON c.id = vs.cid
- WHERE vs.aid IN}, [ keys %alias ], q{
+ JOIN', vnt, 'v ON v.id = vs.id
+ JOIN', charst, 'c ON c.id = vs.cid
+ WHERE vs.aid IN', [ keys %alias ], '
AND NOT v.hidden
AND NOT c.hidden
- ORDER BY v.c_released ASC, v.title ASC
- });
+ ORDER BY v.c_released ASC, v.sorttitle ASC
+ ')->@* ];
return if !@$cast;
+ enrich_ulists_widget $cast;
- my $spoillvl = tuwf->authPref('spoilers')||0;
+ my $spoilers = viewget->{spoilers};
+ my $max_spoil = max(map $_->{spoil}, @$cast);
- div_ class => 'maintabs', sub {
+ nav_ sub {
h1_ sprintf 'Voiced characters (%d)', scalar @$cast;
- ul_ sub {
- li_ mkclass(tabselected => $spoillvl == 0), sub { a_ href => '#', class => 'spoilset-0', 'data-target' => 'voicedchars', 'hide spoilers' };
- li_ mkclass(tabselected => $spoillvl == 1), sub { a_ href => '#', class => 'spoilset-1', 'data-target' => 'voicedchars', 'minor spoilers' };
- li_ mkclass(tabselected => $spoillvl == 2), sub { a_ href => '#', class => 'spoilset-2', 'data-target' => 'voicedchars', 'spoil me!' };
- };
+ menu_ sub {
+ li_ mkclass(tabselected => $spoilers == 0), sub { a_ href => '?view='.viewset(spoilers => 0), 'hide spoilers' };
+ li_ mkclass(tabselected => $spoilers == 1), sub { a_ href => '?view='.viewset(spoilers => 1), 'minor spoilers' };
+ li_ mkclass(tabselected => $spoilers == 2), sub { a_ href => '?view='.viewset(spoilers => 2), 'spoil me!' } if $max_spoil == 2;
+ } if $max_spoil;
};
- div_ class => "mainbox browse staffroles voicedchars spoillvl-$spoillvl", sub {
+ article_ class => "browse staffroles", sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
+ td_ class => 'tc_ulist', '' if auth;
td_ class => 'tc1', sub { txt_ 'Title'; debug_ $cast };
td_ class => 'tc2', 'Released';
td_ class => 'tc3', 'Cast';
td_ class => 'tc4', 'As';
td_ class => 'tc5', 'Note';
}};
- tr_ class => "spoil-$_->{spoil}", sub {
+ my %vns;
+ tr_ sub {
my($v, $a) = ($_, $alias{$_->{aid}});
+ td_ class => 'tc_ulist', sub { ulists_widget_ $v if !$vns{$v->{id}}++ } if auth;
td_ class => 'tc1', sub {
- a_ href => "/v$v->{id}", title => $v->{original}||$v->{title}, shorten $v->{title}, 60;
+ a_ href => "/$v->{id}", tattr $v;
};
td_ class => 'tc2', sub { rdate_ $v->{c_released} };
td_ class => 'tc3', sub {
- a_ href => "/c$v->{cid}", title => $v->{c_original}||$v->{c_name}, $v->{c_name};
+ a_ href => "/$v->{cid}", tattr $v->{c_title};
+ spoil_ $_->{spoil};
};
- td_ class => 'tc4', title => $a->{original}||$a->{name}, $a->{name};
+ td_ class => 'tc4', tattr $a;
td_ class => 'tc5', $v->{note};
- } for @$cast;
+ } for grep $_->{spoil} <= $spoilers, @$cast;
};
};
}
TUWF::get qr{/$RE{srev}} => sub {
- my $s = db_entry s => tuwf->capture('id'), tuwf->capture('rev');
+ my $s = db_entry tuwf->captures('id', 'rev');
return tuwf->resNotFound if !$s;
enrich_item $s;
- enrich_extlinks s => $s;
- my($main) = grep $_->{aid} == $s->{aid}, $s->{alias}->@*;
+ enrich_extlinks s => 0, $s;
+ my($main) = grep $_->{aid} == $s->{main}, $s->{alias}->@*;
- framework_ title => $main->{name}, index => 1, type => 's', dbobj => $s, hiddenmsg => 1,
+ framework_ title => $main->{title}[1], index => !tuwf->capture('rev'), dbobj => $s, hiddenmsg => 1,
og => {
- description => bb2text $s->{desc}
+ description => bb_format $s->{description}, text => 1
},
sub {
_rev_ $s if tuwf->capture('rev');
- div_ class => 'mainbox staffpage', sub {
- itemmsg_ s => $s;
- h1_ sub { txt_ $main->{name}; debug_ $s };
- h2_ class => 'alttitle', lang => $s->{lang}, $main->{original} if $main->{original};
+ article_ class => 'staffpage', sub {
+ itemmsg_ $s;
+ h1_ tlang(@{$main->{title}}[0,1]), $main->{title}[1];
+ h2_ class => 'alttitle', tlang(@{$main->{title}}[2,3]), $main->{title}[3] if $main->{title}[3] && $main->{title}[3] ne $main->{title}[1];
_infotable_ $main, $s;
- p_ class => 'description', sub { lit_ bb2html $s->{desc} };
+ div_ class => 'description', sub { lit_ bb_format $s->{description} };
};
_roles_ $s;
diff --git a/lib/VNWeb/TT/Elm.pm b/lib/VNWeb/TT/Elm.pm
new file mode 100644
index 00000000..b30aeff1
--- /dev/null
+++ b/lib/VNWeb/TT/Elm.pm
@@ -0,0 +1,56 @@
+package VNWeb::TT::Elm;
+
+use VNWeb::Prelude;
+
+elm_api Tags => undef, { search => { searchquery => 1 } }, sub {
+ my $q = shift->{search};
+
+ elm_TagResult $q ? tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT t.id, t.name, t.searchable, t.applicable, t.hidden, t.locked
+ FROM tags t', $q->sql_join('g', 't.id'), '
+ WHERE NOT (t.hidden AND t.locked)
+ ORDER BY sc.score DESC, t.name
+ ') : [];
+};
+
+
+js_api Tags => { search => { searchquery => 1 } }, sub {
+ my $q = shift->{search};
+
+ +{ results => $q ? tuwf->dbAlli(
+ 'SELECT t.id, t.name, t.searchable, t.applicable, t.hidden, t.locked
+ FROM tags t', $q->sql_join('g', 't.id'), '
+ WHERE NOT (t.hidden AND t.locked)
+ ORDER BY sc.score DESC, t.name
+ LIMIT', \30
+ ) : [] }
+};
+
+
+elm_api Traits => undef, { search => { searchquery => 1 } }, sub {
+ my $q = shift->{search};
+
+ elm_TraitResult $q ? tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT t.id, t.name, t.searchable, t.applicable, t.defaultspoil, t.hidden, t.locked, g.id AS group_id, g.name AS group_name
+ FROM traits t', $q->sql_join('i', 't.id'), '
+ LEFT JOIN traits g ON g.id = t.gid
+ WHERE NOT (t.hidden AND t.locked)
+ ORDER BY sc.score DESC, t.name
+ ') : [];
+};
+
+
+js_api Traits => { search => { searchquery => 1 } }, sub {
+ my $q = shift->{search};
+
+ +{ results => $q ? tuwf->dbAlli(
+ 'SELECT t.id, t.name, t.searchable, t.applicable, t.defaultspoil, t.hidden, t.locked, g.id AS group_id, g.name AS group_name
+ FROM traits t', $q->sql_join('i', 't.id'), '
+ LEFT JOIN traits g ON g.id = t.gid
+ WHERE NOT (t.hidden AND t.locked)
+ ORDER BY sc.score DESC, t.name
+ LIMIT', \30
+ ) : [] };
+};
+
+1;
diff --git a/lib/VNWeb/TT/Index.pm b/lib/VNWeb/TT/Index.pm
new file mode 100644
index 00000000..7a8ac10b
--- /dev/null
+++ b/lib/VNWeb/TT/Index.pm
@@ -0,0 +1,88 @@
+package VNWeb::TT::Index;
+
+use VNWeb::Prelude;
+use VNWeb::TT::Lib 'enrich_group', 'tree_';
+
+
+sub recent_ {
+ my($type) = @_;
+ my $lst = tuwf->dbAlli('SELECT id, name, ', sql_totime('added'), 'AS added FROM', $type eq 'g' ? 'tags' : 'traits', 'WHERE NOT hidden ORDER BY id 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_ ' ';
+ small_ "$_->{group} / " if $_->{group};
+ a_ href => "/$_->{id}", $_->{name};
+ } for @$lst;
+ };
+}
+
+
+sub popular_ {
+ my($type) = @_;
+ my $lst = tuwf->dbAlli('SELECT id, name, c_items FROM', $type eq 'g' ? 'tags' : 'traits', 'WHERE NOT hidden 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 {
+ small_ "$_->{group} / " if $_->{group};
+ a_ href => "/$_->{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 hidden AND NOT locked 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_ ' ';
+ small_ "$_->{group} / " if $_->{group};
+ a_ href => "/$_->{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 {
+ article_ 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;
+ div_ class => 'threelayout', sub {
+ article_ sub { recent_ $type };
+ article_ sub { popular_ $type };
+ article_ sub { moderation_ $type };
+ };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/TT/Lib.pm b/lib/VNWeb/TT/Lib.pm
new file mode 100644
index 00000000..5ac3e08d
--- /dev/null
+++ b/lib/VNWeb/TT/Lib.pm
@@ -0,0 +1,102 @@
+package VNWeb::TT::Lib;
+
+use VNWeb::Prelude;
+use Exporter 'import';
+
+our @EXPORT = qw/ tagscore_ enrich_group tree_ parents_ /;
+
+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.gid WHERE t.id IN', @lst if $type eq 'i';
+}
+
+
+sub tree_ {
+ my($type, $id) = @_;
+ my $table = $type eq 'g' ? 'tags' : 'traits';
+ my $top = tuwf->dbAlli(
+ "SELECT id, name, c_items FROM $table t
+ WHERE NOT hidden
+ AND", $id ? sql "id IN(SELECT id FROM ${table}_parents WHERE parent = ", \$id, ')'
+ : "NOT EXISTS(SELECT 1 FROM ${table}_parents tp WHERE tp.id = t.id)", "
+ ORDER BY ", $type eq 'g' || $id ? 'name' : 'gorder'
+ );
+ return if !@$top;
+
+ enrich childs => id => parent => sub { sql
+ "SELECT tp.parent, t.id, t.name, t.c_items FROM $table t JOIN ${table}_parents tp ON tp.id = t.id WHERE NOT hidden AND tp.parent IN", $_, 'ORDER BY name'
+ }, $top;
+ $top = [ sort { $b->{childs}->@* <=> $a->{childs}->@* } @$top ] if $type eq 'g' || $id;
+
+ my sub lnk_ {
+ a_ href => "/$_[0]{id}", $_[0]{name};
+ small_ " ($_[0]{c_items})" if $_[0]{c_items};
+ }
+ article_ sub {
+ h1_ $id ? ($type eq 'g' ? 'Child tags' : 'Child traits') : $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 => "/$_->{id}", style => 'font-style: italic', sprintf '%d more %s%s', $num, $type eq 'g' ? 'tag' : 'trait', $num == 1 ? '' : 's';
+ } if @$sub > 6;
+ } if @$sub;
+ } for @$top;
+ };
+ clearfloat_;
+ br_;
+ };
+}
+
+
+# Breadcrumbs-style listing of parent tags/traits
+sub parents_ {
+ my($type, $t) = @_;
+
+ my %t;
+ my $table = $type eq 'g' ? 'tags' : 'traits';
+ push $t{$_->{child}}->@*, $_ for tuwf->dbAlli("
+ WITH RECURSIVE p(id,child,name,main) AS (
+ SELECT t.id, tp.id, t.name, tp.main FROM ${table}_parents tp JOIN $table t ON t.id = tp.parent WHERE tp.id =", \$t->{id}, "
+ UNION
+ SELECT t.id, p.id, t.name, tp.main FROM p JOIN ${table}_parents tp ON tp.id = p.id JOIN $table t ON t.id = tp.parent
+ ) SELECT * FROM p ORDER BY main DESC, name
+ ")->@*;
+
+ my sub rec {
+ $t{$_[0]} ? map { my $e=$_; map [ @$_, $e ], __SUB__->($e->{id}) } $t{$_[0]}->@* : []
+ }
+
+ p_ sub {
+ join_ \&br_, sub {
+ a_ href => "/$type", $type eq 'g' ? 'Tags' : 'Traits';
+ for (@$_) {
+ txt_ ' > ';
+ a_ href => "/$_->{id}", $_->{name};
+ }
+ txt_ ' > ';
+ txt_ $t->{name};
+ }, rec($t->{id});
+ };
+}
+
+
+1;
diff --git a/lib/VNWeb/TT/List.pm b/lib/VNWeb/TT/List.pm
new file mode 100644
index 00000000..537c6d3d
--- /dev/null
+++ b/lib/VNWeb/TT/List.pm
@@ -0,0 +1,102 @@
+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';
+ article_ class => '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 {
+ small_ "$_->{group} / " if $_->{group};
+ a_ href => "/$_->{id}", $_->{name};
+ join_ ',', sub { small_ ' '.$_ },
+ !$_->{hidden} ? () : $_->{locked} ? 'deleted' : 'awaiting moderation',
+ !$_->{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 => 'qscore', enum => ['qscore', '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 => { searchquery => 1 },
+ )->data;
+ $opt->{s} = 'items' if $opt->{s} eq 'vns';
+ $opt->{s} = 'name' if $opt->{s} eq 'qscore' && !$opt->{q};
+ $opt->{t} = undef if $opt->{t} && $opt->{t} == -1; # for legacy URLs
+
+ my $where = sql_and
+ !defined $opt->{t} ? () :
+ $opt->{t} == 0 ? 'hidden AND NOT locked' :
+ $opt->{t} == 1 ? 'hidden AND locked' : 'NOT hidden',
+ defined $opt->{a} ? sql 'applicable =', \$opt->{a} : (),
+ defined $opt->{b} ? sql 'searchable =', \$opt->{b} : ();
+
+ my $table = $type eq 'g' ? 'tags' : 'traits';
+ my $count = tuwf->dbVali("SELECT COUNT(*) FROM $table t WHERE", sql_and $where, $opt->{q}->sql_where($type, 't.id'));
+ my $list = tuwf->dbPagei({ results => 50, page => $opt->{p} },'
+ SELECT t.id, name, hidden, locked, searchable, applicable, c_items,', sql_totime('added'), "as added
+ FROM $table t", $opt->{q}->sql_join($type, 't.id'), '
+ WHERE ', $where, '
+ ORDER BY', {qscore => '10 - sc.score', qw|added t.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 {
+ article_ 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..115a24bf
--- /dev/null
+++ b/lib/VNWeb/TT/TagEdit.pm
@@ -0,0 +1,154 @@
+package VNWeb::TT::TagEdit;
+
+use VNWeb::Prelude;
+
+# TODO: Let users edit their own tag while it's still waiting for approval?
+
+my $FORM = {
+ id => { default => undef, vndbid => 'g' },
+ name => { maxlength => 250, regex => qr/^[^,\r\n\t]+$/ },
+ alias => { maxlength => 1024, regex => qr/^[^,]+$/, default => '' },
+ 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 => {
+ parent => { vndbid => 'g' },
+ main => { anybool => 1 },
+ name => { _when => 'out' },
+ } },
+ wipevotes => { _when => 'in', anybool => 1 },
+ merge => { _when => 'in out', aoh => {
+ id => { vndbid => 'g' },
+ name => { _when => 'out' },
+ } },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+
+ authmod => { _when => 'out', anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_CMP = form_compile cmp => $FORM;
+
+
+TUWF::get qr{/$RE{grev}/edit}, sub {
+ my $g = db_entry tuwf->captures('id','rev');
+ return tuwf->resNotFound if !$g->{id};
+ return tuwf->resDenied if !can_edit g => $g;
+
+ enrich_merge parent => 'SELECT id AS parent, name FROM tags WHERE id IN', $g->{parents};
+
+ $g->{authmod} = auth->permTagmod;
+ $g->{editsum} = $g->{chrev} == $g->{maxrev} ? '' : "Reverted to revision $g->{id}.$g->{chrev}";
+ $g->{merge} = [];
+
+ framework_ title => "Edit $g->{name}", 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 NOT hidden AND id =', \$id);
+ return tuwf->resDenied if !can_edit g => {};
+ return tuwf->resNotFound if $id && !$g->{id};
+
+ my $e = elm_empty($FORM_OUT);
+ $e->{authmod} = auth->permTagmod;
+ if($id) {
+ $e->{parents} = [{ parent => $g->{id}, main => 1, name => $g->{name} }];
+ $e->{cat} = $g->{cat};
+ }
+
+ framework_ title => 'Submit a new tag', sub {
+ article_ 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 $new = !$data->{id};
+ my $e = $new ? {} : db_entry $data->{id} or return tuwf->resNotFound;
+ return tuwf->resNotFound if !$new && !$e->{id};
+ return elm_Unauth if !can_edit g => $e;
+
+ if(!auth->permTagmod) {
+ $data->{hidden} = $e->{hidden}//1;
+ $data->{locked} = $e->{locked}//0;
+ }
+
+ my $re = '[\t\s]*\n[\t\s]*';
+ my $dups = tuwf->dbAlli('
+ SELECT id, name
+ FROM (SELECT id, name FROM tags UNION SELECT id, s FROM tags, regexp_split_to_table(alias, ', \$re, ') a(s) WHERE s <> \'\') n(id,name)
+ WHERE ', sql_and(
+ $new ? () : sql('id <>', \$data->{id}),
+ sql 'lower(name) IN', [ map lc($_), $data->{name}, grep length($_), split /$re/, $data->{alias} ]
+ )
+ );
+ 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
+ $new ? () : sql('id NOT IN(WITH RECURSIVE t(id) AS (SELECT', \$data->{id}, '::vndbid UNION SELECT tp.id FROM tags_parents tp JOIN t ON t.id = tp.parent) SELECT id FROM t)'),
+ sql 'id IN', $_[0]
+ }, map $_->{parent}, $data->{parents}->@*;
+ die "No or multiple primary parents" if $data->{parents}->@* && 1 != grep $_->{main}, $data->{parents}->@*;
+
+ $data->{description} = bb_subst_links($data->{description});
+
+ my $changed = 0;
+ if(!$new && auth->permTagmod && $data->{wipevotes}) {
+ my $num = tuwf->dbExeci('DELETE FROM tags_vn WHERE tag =', \$e->{id});
+ auth->audit(undef, 'tag wipe', "Wiped $num votes on $e->{id}");
+ $changed++;
+ }
+
+ if(!$new && 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 ', \$e->{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 ',', @merge;
+ auth->audit(undef, 'tag merge', "Moved $mov/$del votes from $lst to $e->{id}");
+ $changed++;
+ }
+
+ if($new || form_changed $FORM_CMP, $data, $e) {
+ my $ch = db_edit g => $e->{id}, $data;
+ elm_Redirect "/$ch->{nitemid}.$ch->{nrev}";
+ } elsif($changed) {
+ elm_Redirect "/$e->{id}";
+ } else {
+ elm_Unchanged;
+ }
+};
+
+1;
diff --git a/lib/VNWeb/Tags/Links.pm b/lib/VNWeb/TT/TagLinks.pm
index 31fb0ae5..7b178d58 100644
--- a/lib/VNWeb/Tags/Links.pm
+++ b/lib/VNWeb/TT/TagLinks.pm
@@ -1,14 +1,14 @@
-package VNWeb::Tags::Links;
+package VNWeb::TT::TagLinks;
use VNWeb::Prelude;
-use VNWeb::Tags::Lib;
+use VNWeb::TT::Lib;
sub listing_ {
my($opt, $lst, $np, $url) = @_;
paginate_ $url, $opt->{p}, $np, 't';
- div_ class => 'mainbox browse taglinks', sub {
+ article_ class => 'browse taglinks', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'date', $opt, $url; debug_ $lst; };
@@ -16,29 +16,37 @@ sub listing_ {
td_ class => 'tc3', 'Rating';
td_ class => 'tc4', sub { txt_ 'Tag'; sortable_ 'tag', $opt, $url };
td_ class => 'tc5', 'Spoiler';
- td_ class => 'tc6', 'Visual novel';
+ td_ class => 'tc6', 'Lie';
+ td_ class => 'tc7', 'Visual novel';
+ td_ class => 'tc8', 'Note';
}};
tr_ sub {
my $i = $_;
td_ class => 'tc1', fmtdate $i->{date};
td_ class => 'tc2', sub {
- a_ href => $url->(u => $i->{uid}, p=>undef), class => 'setfil', '> ' if !defined $opt->{u};
+ a_ href => $url->(u => $i->{uid}, p=>undef), class => 'setfil', '> ' if $i->{uid} && !defined $opt->{u} && (defined $i->{user_name} || auth->isMod);
user_ $i;
};
td_ class => 'tc3', sub { tagscore_ $i->{vote}, $i->{ignore} };
td_ class => 'tc4', sub {
a_ href => $url->(t => $i->{tag}, p=>undef), class => 'setfil', '> ' if !defined $opt->{t};
- a_ href => "/g$i->{tag}", $i->{name};
+ a_ href => "/$i->{tag}", $i->{name};
};
td_ class => 'tc5', sub {
my $s = !defined $i->{spoiler} ? '' : fmtspoil $i->{spoiler};
- b_ class => 'grayedout', $s if $i->{ignore};
+ small_ $s if $i->{ignore};
txt_ $s if !$i->{ignore};
};
td_ class => 'tc6', sub {
+ my $s = !defined $i->{lie} ? '' : $i->{lie} ? '+' : '-';
+ small_ $s if $i->{ignore};
+ txt_ $s if !$i->{ignore};
+ };
+ td_ class => 'tc7', sub {
a_ href => $url->(v => $i->{vid}, p=>undef), class => 'setfil', '> ' if !defined $opt->{v};
- a_ href => "/v$i->{vid}", shorten $i->{title}, 50;
+ a_ href => "/$i->{vid}", tattr $i;
};
+ td_ class => 'tc8', sub { lit_ bb_format $i->{notes}, inline => 1 };
} for @$lst;
};
};
@@ -51,11 +59,14 @@ TUWF::get qr{/g/links}, sub {
p => { page => 1 },
o => { onerror => 'd', enum => ['a', 'd'] },
s => { onerror => 'date', enum => [qw|date tag|] },
- v => { onerror => undef, id => 1 },
- u => { onerror => undef, id => 1 },
- t => { onerror => undef, id => 1 },
+ v => { onerror => undef, vndbid => 'v' },
+ u => { onerror => undef, vndbid => 'u' },
+ t => { onerror => undef, vndbid => 'g' },
)->data;
+ my $u = $opt->{u} && tuwf->dbRowi('SELECT id,', sql_user(), 'FROM users u WHERE id =', \$opt->{u});
+ return tuwf->resNotFound if $opt->{u} && (!$u->{id} || (!defined $u->{user_name} && !auth->isMod));
+
my $where = sql_and
defined $opt->{v} ? sql('tv.vid =', \$opt->{v}) : (),
defined $opt->{u} ? sql('tv.uid =', \$opt->{u}) : (),
@@ -65,10 +76,11 @@ TUWF::get qr{/g/links}, sub {
my $count = $filt && tuwf->dbVali('SELECT COUNT(*) FROM tags_vn tv WHERE', $where);
my($lst, $np) = tuwf->dbPagei({ page => $opt->{p}, results => 50 }, '
- SELECT tv.vid, tv.uid, tv.tag, tv.vote, tv.spoiler,', sql_totime('tv.date'), 'as date, tv.ignore, v.title,', sql_user(), ', t.name
+ SELECT tv.vid, tv.uid, tv.tag, tv.vote, tv.spoiler, tv.lie,', sql_totime('tv.date'), 'as date
+ , tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) AS ignore, tv.notes, v.title, ', sql_user(), ', t.name
FROM tags_vn tv
- JOIN vn v ON v.id = tv.vid
- JOIN users u ON u.id = tv.uid
+ JOIN', vnt, 'v ON v.id = tv.vid
+ LEFT JOIN users u ON u.id = tv.uid
JOIN tags t ON t.id = tv.tag
WHERE', $where, '
ORDER BY', { date => 'tv.date', tag => 't.name' }->{$opt->{s}}, { a => 'ASC', d => 'DESC' }->{$opt->{o}}
@@ -78,7 +90,7 @@ TUWF::get qr{/g/links}, sub {
my sub url { '?'.query_encode %$opt, @_ }
framework_ title => 'Tag link browser', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Tag link browser';
if($filt) {
p_ 'Active filters:';
@@ -86,17 +98,18 @@ TUWF::get qr{/g/links}, sub {
li_ sub {
txt_ '['; a_ href => url(u=>undef, p=>undef), 'remove'; txt_ '] ';
txt_ 'User: ';
- user_ tuwf->dbRowi('SELECT', sql_user(), 'FROM users u WHERE id=', \$opt->{u});
+ user_ $u;
} if defined $opt->{u};
li_ sub {
txt_ '['; a_ href => url(t=>undef, p=>undef), 'remove'; txt_ '] ';
txt_ 'Tag:'; txt_ ' ';
- a_ href => "/g$opt->{t}", tuwf->dbVali('SELECT name FROM tags WHERE id=', \$opt->{t})||'Unknown tag';
+ a_ href => "/$opt->{t}", tuwf->dbVali('SELECT name FROM tags WHERE id=', \$opt->{t})||'Unknown tag';
} if defined $opt->{t};
li_ sub {
txt_ '['; a_ href => url(v=>undef, p=>undef), 'remove'; txt_ '] ';
txt_ 'Visual novel'; txt_ ' ';
- a_ href => "/v$opt->{v}", tuwf->dbVali('SELECT title FROM vn WHERE id=', \$opt->{v})||'Unknown VN';
+ my $v = tuwf->dbRowi('SELECT title FROM', vnt, 'v WHERE id=', \$opt->{v});
+ a_ href => "/$opt->{v}", $v->{title} ? tattr $v : ('Unknown VN');
} if defined $opt->{v};
}
}
diff --git a/lib/VNWeb/TT/TagPage.pm b/lib/VNWeb/TT/TagPage.pm
new file mode 100644
index 00000000..c23a7cbe
--- /dev/null
+++ b/lib/VNWeb/TT/TagPage.pm
@@ -0,0 +1,161 @@
+package VNWeb::TT::TagPage;
+
+use VNWeb::Prelude;
+use VNWeb::Filters;
+use VNWeb::AdvSearch;
+use VNWeb::VN::List;
+use VNWeb::TT::Lib 'tree_', 'parents_';
+
+
+sub rev_ {
+ my($t) = @_;
+ sub enrich_item {
+ enrich_merge parent => 'SELECT id AS parent, name FROM tags WHERE id IN', $_[0]{parents};
+ $_[0]{parents} = [ sort { $a->{name} cmp $b->{name} || $a->{parent} <=> $b->{parent} } $_[0]{parents}->@* ];
+ }
+ enrich_item $t;
+ revision_ $t, \&enrich_item,
+ [ name => 'Name' ],
+ [ alias => 'Aliases' ],
+ [ cat => 'Category', fmt => \%TAG_CATEGORY ],
+ [ description => 'Description' ],
+ [ searchable => 'Searchable', fmt => 'bool' ],
+ [ applicable => 'Applicable', fmt => 'bool' ],
+ [ defaultspoil => 'Default spoiler level' ],
+ [ parents => 'Parent tags', fmt => sub { a_ href => "/$_->{parent}", $_->{name}; txt_ ' (primary)' if $_->{main} } ];
+}
+
+
+sub infobox_ {
+ my($t) = @_;
+
+ p_ class => 'mainopts', sub {
+ a_ href => "/$t->{id}/add", 'Create child tag';
+ } if !$t->{hidden} && can_edit g => {};
+ h1_ "Tag: $t->{name}";
+ debug_ $t;
+
+ parents_ g => $t;
+
+ div_ class => 'description', sub {
+ lit_ bb_format $t->{description};
+ } if $t->{description};
+
+ my @prop = (
+ $t->{searchable} ? () : 'Not searchable.',
+ $t->{applicable} ? () : 'Can not be directly applied to visual novels.'
+ );
+ p_ class => 'center', sub {
+ strong_ 'Properties';
+ br_;
+ join_ \&br_, sub { txt_ $_ }, @prop;
+ } if @prop;
+
+ p_ class => 'center', sub {
+ strong_ 'Category';
+ br_;
+ txt_ $TAG_CATEGORY{$t->{cat}};
+ };
+
+ p_ class => 'center', sub {
+ strong_ 'Aliases';
+ br_;
+ join_ \&br_, sub { txt_ $_ }, split /\n/, $t->{alias};
+ } if $t->{alias};
+}
+
+
+my $TABLEOPTS = VNWeb::VN::List::TABLEOPTS('tags');
+
+
+sub vns_ {
+ my($t) = @_;
+
+ my $opt = tuwf->validate(get =>
+ p => { upage => 1 },
+ f => { advsearch_err => 'v' },
+ s => { tableopts => $TABLEOPTS },
+ m => { onerror => [auth->pref('spoilers')||0], type => 'array', scalar => 1, minlength => 1, values => { enum => [0..2] } },
+ l => { onerror => [''], type => 'array', scalar => 1, minlength => 1, values => { anybool => 1 } },
+ fil => { onerror => '' },
+ )->data;
+ $opt->{m} = $opt->{m}[0];
+ $opt->{l} = $opt->{l}[0];
+
+ # URL compatibility with old filters
+ if(!$opt->{f}->{query} && $opt->{fil}) {
+ my $q = eval {
+ my $f = filter_parse v => $opt->{fil};
+ # Old URLs often had the tag ID as part of the filter, let's remove that.
+ $f->{tag_inc} = [ grep "g$_" ne $t->{id}, $f->{tag_inc}->@* ] if $f->{tag_inc};
+ delete $f->{tag_inc} if $f->{tag_inc} && !$f->{tag_inc}->@*;
+ $f = filter_vn_adv $f;
+ tuwf->compile({ advsearch => 'v' })->validate(@$f > 1 ? $f : undef)->data;
+ };
+ return tuwf->resRedirect(tuwf->reqPath().'?'.query_encode(%$opt, fil => undef, f => $q), 'perm') if $q;
+ }
+
+ $opt->{f} = advsearch_default 'v' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
+
+ my $where = sql_and
+ 'NOT v.hidden',
+ $opt->{l} ? 'NOT tvi.lie' : (),
+ sql('tvi.tag =', \$t->{id}),
+ sql('tvi.spoiler <=', \$opt->{m}),
+ $opt->{f}->sql_where();
+
+ my $time = time;
+ my($count, $list);
+ db_maytimeout {
+ $count = tuwf->dbVali('SELECT count(*) FROM vn v JOIN tags_vn_inherit tvi ON tvi.vid = v.id WHERE', $where);
+ $list = $count ? tuwf->dbPagei({results => $opt->{s}->results(), page => $opt->{p}}, '
+ SELECT tvi.rating AS tagscore, v.id, v.title, v.c_released, v.c_votecount, v.c_rating, v.c_average
+ , v.image, v.c_platforms::text[] AS platforms, v.c_languages::text[] AS lang',
+ $opt->{s}->vis('length') ? ', v.length, v.c_length, v.c_lengthnum' : (), '
+ FROM', vnt, 'v
+ JOIN tags_vn_inherit tvi ON tvi.vid = v.id
+ WHERE', $where, '
+ ORDER BY', $opt->{s}->sql_order(),
+ ) : [];
+ } || (($count, $list) = (undef, []));
+
+ VNWeb::VN::List::enrich_listing 1, $opt, $list;
+ $time = time - $time;
+
+ form_ action => "/$t->{id}", method => 'get', sub {
+ article_ sub {
+ p_ class => 'mainopts', sub {
+ a_ href => "/g/links?t=$t->{id}", 'Recently tagged';
+ };
+ h1_ 'Visual novels';
+ p_ class => 'browseopts', sub {
+ button_ type => 'submit', name => 'm', value => 0, $opt->{m} == 0 ? (class => 'optselected') : (), 'Hide spoilers';
+ button_ type => 'submit', name => 'm', value => 1, $opt->{m} == 1 ? (class => 'optselected') : (), 'Show minor spoilers';
+ button_ type => 'submit', name => 'm', value => 2, $opt->{m} == 2 ? (class => 'optselected') : (), 'Spoil me!';
+ };
+ p_ class => 'browseopts', sub {
+ button_ type => 'submit', name => 'l', value => 0, !$opt->{l} ? (class => 'optselected') : (), 'Include lies';
+ button_ type => 'submit', name => 'l', value => 1, $opt->{l} ? (class => 'optselected') : (), 'Exclude lies';
+ };
+ input_ type => 'hidden', name => 'm', value => $opt->{m};
+ input_ type => 'hidden', name => 'l', value => $opt->{l};
+ $opt->{f}->elm_($count, $time);
+ };
+ VNWeb::VN::List::listing_ $opt, $list, $count, 1 if $count;
+ };
+}
+
+
+TUWF::get qr{/$RE{grev}}, sub {
+ my $t = db_entry tuwf->captures('id', 'rev');
+ return tuwf->resNotFound if !$t->{id};
+
+ framework_ index => !tuwf->capture('rev'), title => "Tag: $t->{name}", dbobj => $t, hiddenmsg => 1, sub {
+ rev_ $t if tuwf->capture('rev');
+ article_ sub { infobox_ $t; };
+ tree_ g => $t->{id};
+ vns_ $t if $t->{searchable} && !$t->{hidden};
+ };
+};
+
+1;
diff --git a/lib/VNWeb/TT/TraitEdit.pm b/lib/VNWeb/TT/TraitEdit.pm
new file mode 100644
index 00000000..f92efd58
--- /dev/null
+++ b/lib/VNWeb/TT/TraitEdit.pm
@@ -0,0 +1,134 @@
+package VNWeb::TT::TraitEdit;
+
+use VNWeb::Prelude;
+
+my $FORM = {
+ id => { default => undef, vndbid => 'i' },
+ name => { maxlength => 250, regex => qr/^[^,\r\n\t]+$/ },
+ alias => { maxlength => 1024, regex => qr/^[^,]+$/, default => '' },
+ sexual => { anybool => 1 },
+ description => { maxlength => 10240 },
+ searchable => { anybool => 1, default => 1 },
+ applicable => { anybool => 1, default => 1 },
+ defaultspoil => { uint => 1, range => [0,2] },
+ parents => { aoh => {
+ parent => { vndbid => 'i' },
+ main => { anybool => 1 },
+ name => { _when => 'out' },
+ group => { _when => 'out', default => undef },
+ } },
+ gorder => { uint => 1 },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+
+ authmod => { _when => 'out', anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_CMP = form_compile cmp => $FORM;
+
+
+TUWF::get qr{/$RE{irev}/edit}, sub {
+ my $e = db_entry tuwf->captures('id','rev');
+ return tuwf->resNotFound if !$e->{id};
+ return tuwf->resDenied if !can_edit i => $e;
+
+ enrich_merge parent => '
+ SELECT i.id AS parent, i.name, g.name AS group
+ FROM traits i LEFT JOIN traits g ON g.id = i.gid WHERE i.id IN', $e->{parents};
+
+ $e->{authmod} = auth->permTagmod;
+ $e->{editsum} = $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
+
+ framework_ title => "Edit $e->{name}", 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 AS parent, i.name, g.name AS "group", i.sexual FROM traits i LEFT JOIN traits g ON g.id = i.gid WHERE i.id =', \$id);
+ return tuwf->resDenied if !can_edit i => {};
+ return tuwf->resNotFound if $id && !$i->{parent};
+
+ my $e = elm_empty($FORM_OUT);
+ $e->{authmod} = auth->permTagmod;
+ if($id) {
+ $i->{main} = 1;
+ $e->{parents} = [$i];
+ $e->{sexual} = $i->{sexual};
+ }
+
+ framework_ title => 'Submit a new trait', sub {
+ article_ 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 $new = !$data->{id};
+ my $e = $new ? {} : db_entry $data->{id} or return tuwf->resNotFound;
+ return tuwf->resNotFound if !$new && !$e->{id};
+ return elm_Unauth if !can_edit i => $e;
+
+ if(!auth->permTagmod) {
+ $data->{hidden} = $e->{hidden}//1;
+ $data->{locked} = $e->{locked}//0;
+ }
+ $data->{gorder} = 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 $_->{parent}, $data->{parents}->@*;
+ validate_dbid sub {
+ 'SELECT id FROM traits WHERE', sql_and
+ $new ? () : sql('id NOT IN(WITH RECURSIVE t(id) AS (SELECT', \$e->{id}, '::vndbid UNION SELECT tp.id FROM traits_parents tp JOIN t ON t.id = tp.parent) SELECT id FROM t)'),
+ sql 'id IN', $_[0]
+ }, @parents;
+ die "No or multiple primary parents" if $data->{parents}->@* && 1 != grep $_->{main}, $data->{parents}->@*;
+
+ my $group = tuwf->dbVali('SELECT coalesce(gid,id) FROM traits WHERE id =', \[grep $_->{main}, $data->{parents}->@*]->[0]{parent});
+
+ $data->{description} = bb_subst_links($data->{description});
+
+ # (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(
+ $new ? () : sql('n.id <>', \$e->{id}),
+ sql('t.gid IS NOT DISTINCT FROM', \$group),
+ sql 'lower(n.name) IN', [ map lc($_), $data->{name}, grep length($_), split /$re/, $data->{alias} ]
+ )
+ );
+ return elm_DupNames $dups if @$dups;
+
+ return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
+ my $ch = db_edit i => $e->{id}, $data;
+ tuwf->dbExeci('UPDATE traits SET gid = null WHERE id =', \$ch->{nitemid}) if !$group;
+ tuwf->dbExeci('
+ WITH RECURSIVE childs (id) AS (
+ SELECT ', \$ch->{nitemid}, '::vndbid UNION ALL SELECT tp.id FROM childs JOIN traits_parents tp ON tp.parent = childs.id AND tp.main
+ ) UPDATE traits SET gid =', \$group, 'WHERE id IN(SELECT id FROM childs) AND gid IS DISTINCT FROM', \$group
+ ) if $group;
+ elm_Redirect "/$ch->{nitemid}.$ch->{nrev}";
+};
+
+1;
diff --git a/lib/VNWeb/TT/TraitPage.pm b/lib/VNWeb/TT/TraitPage.pm
new file mode 100644
index 00000000..c120d645
--- /dev/null
+++ b/lib/VNWeb/TT/TraitPage.pm
@@ -0,0 +1,149 @@
+package VNWeb::TT::TraitPage;
+
+use VNWeb::Prelude;
+use VNWeb::Filters;
+use VNWeb::AdvSearch;
+use VNWeb::Images::Lib;
+use VNWeb::TT::Lib 'tree_', 'parents_';
+
+
+sub rev_ {
+ my($t) = @_;
+ sub enrich_item {
+ enrich_merge parent => 'SELECT id AS parent, name FROM traits WHERE id IN', $_[0]{parents};
+ $_[0]{parents} = [ sort { $a->{name} cmp $b->{name} || $a->{parent} <=> $b->{parent} } $_[0]{parents}->@* ];
+ }
+ enrich_item $t;
+ revision_ $t, \&enrich_item,
+ [ name => 'Name' ],
+ [ alias => 'Aliases' ],
+ [ description => 'Description' ],
+ [ sexual => 'Sexual content',fmt => 'bool' ],
+ [ searchable => 'Searchable', fmt => 'bool' ],
+ [ applicable => 'Applicable', fmt => 'bool' ],
+ [ defaultspoil => 'Default spoiler level' ],
+ [ gorder => 'Sort order' ],
+ [ parents => 'Parent traits', fmt => sub { a_ href => "/$_->{parent}", $_->{name}; txt_ ' (primary)' if $_->{main} } ];
+}
+
+
+sub infobox_ {
+ my($t) = @_;
+
+ p_ class => 'mainopts', sub {
+ a_ href => "/$t->{id}/add", 'Create child trait';
+ } if !$t->{hidden} && can_edit i => {};
+ h1_ "Trait: $t->{name}";
+ debug_ $t;
+
+ parents_ i => $t;
+
+ div_ class => 'description', sub {
+ lit_ bb_format $t->{description};
+ } if $t->{description};
+
+ my @prop = (
+ !$t->{sexual} ? () : 'Indicates sexual content.',
+ $t->{searchable} ? () : 'Not searchable.',
+ $t->{applicable} ? () : 'Can not be directly applied to characters.',
+ );
+ p_ class => 'center', sub {
+ strong_ 'Properties';
+ br_;
+ join_ \&br_, sub { txt_ $_ }, @prop;
+ } if @prop;
+
+ p_ class => 'center', sub {
+ strong_ 'Aliases';
+ br_;
+ join_ \&br_, sub { txt_ $_ }, split /\n/, $t->{alias};
+ } if $t->{alias};
+}
+
+
+sub chars_ {
+ my($t) = @_;
+
+ my $opt = tuwf->validate(get =>
+ p => { upage => 1 },
+ f => { advsearch_err => 'c' },
+ m => { onerror => [auth->pref('spoilers')||0], type => 'array', scalar => 1, minlength => 1, values => { enum => [0..2] } },
+ l => { onerror => [''], type => 'array', scalar => 1, minlength => 1, values => { anybool => 1 } },
+ fil => { onerror => '' },
+ s => { tableopts => $VNWeb::Chars::List::TABLEOPTS },
+ )->data;
+ $opt->{m} = $opt->{m}[0];
+ $opt->{l} = $opt->{l}[0];
+
+ # URL compatibility with old filters
+ if(!$opt->{f}->{query} && $opt->{fil}) {
+ my $q = eval {
+ my $f = filter_parse c => $opt->{fil};
+ # Old URLs often had the trait ID as part of the filter, let's remove that.
+ $f->{trait_inc} = [ grep "i$_" ne $t->{id}, $f->{trait_inc}->@* ] if $f->{trait_inc};
+ delete $f->{trait_inc} if $f->{trait_inc} && !$f->{trait_inc}->@*;
+ $f = filter_char_adv $f;
+ tuwf->compile({ advsearch => 'c' })->validate(@$f > 1 ? $f : undef)->data;
+ };
+ return tuwf->resRedirect(tuwf->reqPath().'?'.query_encode(%$opt, fil => undef, f => $q), 'perm') if $q;
+ }
+
+ $opt->{f} = advsearch_default 'c' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
+
+ my $where = sql_and
+ 'NOT c.hidden',
+ $opt->{l} ? 'NOT tc.lie' : (),
+ sql('tc.tid =', \$t->{id}),
+ sql('tc.spoil <=', \$opt->{m}),
+ $opt->{f}->sql_where();
+
+ my $time = time;
+ my($count, $list);
+ db_maytimeout {
+ $count = tuwf->dbVali('SELECT count(*) FROM chars c JOIN traits_chars tc ON tc.cid = c.id WHERE', $where);
+ $list = $count ? tuwf->dbPagei({results => $opt->{s}->results(), page => $opt->{p}}, '
+ SELECT c.id, c.title, c.gender, c.image
+ FROM', charst, 'c
+ JOIN traits_chars tc ON tc.cid = c.id
+ WHERE', $where, '
+ ORDER BY c.sorttitle, c.id'
+ ) : [];
+ } || (($count, $list) = (undef, []));
+
+ VNWeb::Chars::List::enrich_listing $list;
+ enrich_image_obj image => $list if !$opt->{s}->rows;
+ $time = time - $time;
+
+ form_ action => "/$t->{id}", method => 'get', sub {
+ article_ sub {
+ h1_ 'Characters';
+ p_ class => 'browseopts', sub {
+ button_ type => 'submit', name => 'm', value => 0, $opt->{m} == 0 ? (class => 'optselected') : (), 'Hide spoilers';
+ button_ type => 'submit', name => 'm', value => 1, $opt->{m} == 1 ? (class => 'optselected') : (), 'Show minor spoilers';
+ button_ type => 'submit', name => 'm', value => 2, $opt->{m} == 2 ? (class => 'optselected') : (), 'Spoil me!';
+ };
+ p_ class => 'browseopts', sub {
+ button_ type => 'submit', name => 'l', value => 0, !$opt->{l} ? (class => 'optselected') : (), 'Include lies';
+ button_ type => 'submit', name => 'l', value => 1, $opt->{l} ? (class => 'optselected') : (), 'Exclude lies';
+ };
+ input_ type => 'hidden', name => 'm', value => $opt->{m};
+ $opt->{f}->elm_($count, $time);
+ };
+ VNWeb::Chars::List::listing_ $opt, $list, $count, 1 if $count;
+ };
+}
+
+
+TUWF::get qr{/$RE{irev}}, sub {
+ my $t = db_entry tuwf->captures('id', 'rev');
+ return tuwf->resNotFound if !$t->{id};
+
+ framework_ index => !$t->{hidden}, title => "Trait: $t->{name}", dbobj => $t, hiddenmsg => 1, sub {
+ rev_ $t if tuwf->capture('rev');
+ article_ sub { infobox_ $t; };
+ tree_ i => $t->{id};
+ chars_ $t if $t->{searchable} && !$t->{hidden};
+ };
+};
+
+1;
diff --git a/lib/VNWeb/TableOpts.pm b/lib/VNWeb/TableOpts.pm
new file mode 100644
index 00000000..42885fa1
--- /dev/null
+++ b/lib/VNWeb/TableOpts.pm
@@ -0,0 +1,297 @@
+package VNWeb::TableOpts;
+
+# This is a helper module to handle passing around various table display
+# options in a single compact query parameter.
+#
+# Supported options:
+#
+# Sort column & order
+# Number of results per page
+# View: rows, cards or grid
+# Which columns are visible
+#
+# Out of scope: pagination & filtering.
+#
+# Usage:
+#
+# my $config = tableopts
+# # Which views are supported (default: all)
+# _views => [ 'rows', 'cards', 'grid' ],
+#
+# # SQL column in the users table to store the saved default
+# _pref => 'tableopts_something',
+#
+# # Column config.
+# # The key names are only used internally.
+# title => {
+# name => 'Title', # Column name, used in the configuration box.
+# compat => 'title', # Name of this column for compatibility with old URLs that referred to the column by name.
+# sort_id => 0, # This column can be sorted on, option indicates numeric identifier (must be stable)
+# sort_sql => 'v.title', # SQL to generate when sorting on this column,
+# # may include '?o' placeholder that will be replaced with selected ASC/DESC,
+# # or '!o' as placeholder for the opposite.
+# # If no placeholders are present, the ASC/DESC will be added automatically.
+# sort_num => 0/1, # Whether this is a numeric field, used in the UI to display "1→9" instead of "A→Z".
+# sort_default => 'asc', # Set to 'asc' or 'desc' if this column should be sorted on by default.
+# },
+# popularity => {
+# name => 'Popularity',
+# sort_id => 1,
+# sort_sql => 'v.c_popularity ?o, v.title',
+# vis_id => 0, # This column can be hidden/visible, option indicates numeric identifier
+# vis_default => 1, # If this column should be visible by default
+# };
+#
+# my $opts = tuwf->validate(get => s => { tableopts => $config })->data;
+#
+# my $sql = sql('.... ORDER BY', $opts->sql_order);
+#
+# $opts->view; # Current view, 'rows', 'cards' or 'grid'
+# $opts->results; # How many results to display
+# $opts->vis('popularity'); # is the column visible?
+#
+#
+#
+# Table options are encoded in a base64-encoded 31 bits integer (can be
+# extended, but bitwise operations in JS are quirky beyond 31 bits).
+# The bit layout is as follows, 0 being the least significant bit:
+#
+# 0 - 1: view 0: rows, 1: cards, 2: grid (3: unused)
+# 2 - 4: results 0: 50, 1: 10, 2: 25, 3: 100, 4: 200 (5-7: unused)
+# 5: order 0: ascending, 1: descending
+# 6 - 11: sort column, identifier used in the configuration
+# 12 - 31: column visibility, identifier in the configuration is used as bit index (12+$vis_id)
+#
+# This supports 64 column identifiers for sorting, 19 identifiers for visibility.
+
+use v5.26;
+use Carp 'croak';
+use Exporter 'import';
+use TUWF ':html5_';
+use VNWeb::Auth;
+use VNWeb::HTML ();
+use VNWeb::Validation;
+use VNWeb::JS;
+
+our @EXPORT = ('tableopts');
+
+my @alpha = (0..9, 'a'..'z', 'A'..'Z', '_', '-');
+my %alpha = map +($alpha[$_],$_), 0..$#alpha;
+sub _enc { ($_[0] >= @alpha ? _enc(int $_[0]/@alpha) : '').$alpha[$_[0]%@alpha] }
+sub _dec { return if length $_[0] > 6; my $n = 0; $n = $n*@alpha + ($alpha{$_}//return) for split //, $_[0]; $n }
+
+my @views = qw|rows cards grid|;
+my %views = map +($views[$_], $_), 0..$#views;
+
+my @results = (50, 10, 25, 100, 200);
+my %results = map +($results[$_], $_), 0..$#results;
+
+
+# Turn config options into something more efficient to work with
+sub tableopts {
+ my %o = (
+ sort_ids => [], # identifier => column config hash
+ col_order => [], # column config hashes in the order listed in the config
+ columns => {}, # column name => config hash
+ views => [], # supported views, as numbers
+ default => 0, # default settings, integer form
+ );
+ my @vis;
+ while(@_) {
+ my($k,$v) = (shift,shift);
+ if($k eq '_views') {
+ $o{views} = [ map $views{$_}//croak("unknown view: $_"), ref $v ? @$v : $v ];
+ next;
+ }
+ if($k eq '_pref') {
+ $o{pref} = $v;
+ next;
+ }
+ $o{columns}{$k} = $v;
+ $v->{id} = $k;
+ push $o{col_order}->@*, $v;
+ if(defined $v->{sort_id}) {
+ die "Duplicate sort_id $v->{sort_id}\n" if $o{sort_ids}[$v->{sort_id}];
+ $o{sort_ids}[$v->{sort_id}] = $v;
+ }
+ die "Duplicate vis_id $v->{vis_id}\n" if defined $v->{vis_id} && $vis[$v->{vis_id}]++;
+ $o{default} |= ($v->{sort_id} << 6) | ({qw|asc 0 desc 32|}->{$v->{sort_default}}//croak("unknown sort_default: $v->{sort_default}")) if $v->{sort_default};
+ $o{default} |= 1 << ($v->{vis_id} + 12) if $v->{vis_default};
+ }
+ $o{views} ||= [0];
+ $o{default} |= $o{views}[0];
+ #warn "=== ".($o{pref}||'undef')."\n"; dump_ids(\%o);
+ \%o
+}
+
+
+# COMPAT: For old URLs, we assume that this validation is used on the 's'
+# parameter, so we can accept two formats:
+# - "s=$compat_sort_column/$order"
+# - "s=$compat_sort_column&o=$order"
+# In the latter case, the validation will use reqGet() to get the 'o'
+# parameter.
+TUWF::set('custom_validations')->{tableopts} = sub {
+ my($t) = @_;
+ +{ onerror => sub {
+ my $d = $t->{pref} && auth->pref($t->{pref});
+ my $o = bless([$d // $t->{default},$t], __PACKAGE__);
+ $o->fixup;
+ }, func => sub {
+ my $obj = bless [undef, $t], __PACKAGE__;
+ my($val,$ord) = $_[0] =~ m{^([^/]+)/([ad])$} ? ($1,$2) : ($_[0],undef);
+ my $col = [grep $_->{compat} && $_->{compat} eq $val, values $t->{columns}->%*]->[0];
+ if($col && defined $col->{sort_id}) {
+ $obj->[0] = $t->{default};
+ $obj->set_sort_col_id($col->{sort_id});
+ $ord //= tuwf->reqGet('o');
+ $obj->set_order($ord && $ord eq 'd' ? 1 : 0);
+ } else {
+ $obj->[0] = _dec($_[0]) // return 0;
+ }
+ $_[0] = $obj->fixup;
+ # We could do strict validation on the individual fields, but the methods below can handle incorrect data.
+ 1;
+ } }
+};
+
+sub fixup {
+ my($obj) = @_;
+ # Reset sort_col and order to their default if the current sort_col id does not exist.
+ if(!$obj->[1]{sort_ids}[ $obj->sort_col_id ]) {
+ $obj->set_sort_col_id(sort_col_id([$obj->[1]{default}]));
+ $obj->set_order(order([$obj->[1]{default}]));
+ }
+ $obj
+}
+
+sub query_encode { _enc $_[0][0] }
+
+sub view { $views[$_[0][0] & 3] || $views[$_[0][1]{views}[0]] }
+sub rows { shift->view eq 'rows' }
+sub cards { shift->view eq 'cards' }
+sub grid { shift->view eq 'grid' }
+
+sub results { $results[($_[0][0] >> 2) & 7] || $results[0] }
+
+sub order { $_[0][0] & 32 }
+sub set_order { if($_[1]) { $_[0][0] |= 32 } else { $_[0][0] &= ~32 } }
+
+sub sort_col_id { ($_[0][0] >> 6) & 63 }
+sub set_sort_col_id { $_[0][0] = ($_[0][0] & (~0 - 0b111111000000)) | ($_[1] << 6) }
+
+# Given a view id, return a new object with that view selected.
+sub view_param {
+ my($self, $view) = @_;
+ my $n = bless [@$self], __PACKAGE__;
+ $n->[0] = ($n->[0] & ~3) | $view;
+ $n
+}
+
+
+# Given the key of a column, returns whether it is currently sorted on ('' / 'a' / 'd')
+sub sorted {
+ my($self, $key) = @_;
+ $self->[1]{columns}{$key}{sort_id} != $self->sort_col_id ? '' : $self->order ? 'd' : 'a';
+}
+
+# Given the key of a column and the desired order ('a'/'d'), returns a new object with that sorting applied.
+sub sort_param {
+ my($self, $key, $o) = @_;
+ my $n = bless [@$self], __PACKAGE__;
+ $n->set_order($o eq 'a' ? 0 : 1);
+ $n->set_sort_col_id($self->[1]{columns}{$key}{sort_id});
+ $n
+}
+
+# Returns an SQL expression suitable for use in an ORDER BY clause.
+sub sql_order {
+ my($self) = @_;
+ my($v,$o) = $self->@*;
+ my $col = $o->{sort_ids}[ $self->sort_col_id ];
+ die "No column to sort on" if !$col;
+ my $order = $self->order ? 'DESC' : 'ASC';
+ my $opposite_order = $self->order ? 'ASC' : 'DESC';
+ my $sql = $col->{sort_sql};
+ $sql =~ /[?!]o/ ? ($sql =~ s/\?o/$order/rg =~ s/!o/$opposite_order/rg) : "$sql $order";
+}
+
+
+# Returns whether the given column key is visible.
+sub vis { my $c = $_[0][1]{columns}{$_[1]}; $c && defined $c->{vis_id} && ($_[0][0] & (1 << (12+$c->{vis_id}))) }
+
+# Given a list of column names, return a new object with only these columns visible
+sub vis_param {
+ my($self, @cols) = @_;
+ my $n = bless [@$self], __PACKAGE__;
+ $n->[0] = $n->[0] & 0b1111_1111_1111;
+ $n->[0] |= 1 << (12+$self->[1]{columns}{$_}{vis_id}) for @cols;
+ $n;
+}
+
+
+my $FORM_OUT = form_compile any => {
+ save => { default => undef },
+ views => { type => 'array', values => { uint => 1 } },
+ value => { uint => 1 },
+ default => { uint => 1 },
+ usaved => { uint => 1, default => undef },
+ sorts => { aoh => { id => { uint => 1 }, name => {}, num => { anybool => 1 } } },
+ vis => { aoh => { id => { uint => 1 }, name => {} } },
+};
+
+js_api TableOptsSave => {
+ save => { enum => ['tableopts_c', 'tableopts_v', 'tableopts_vt'] },
+ value => { default => undef, uint => 1 }
+}, sub {
+ my($f) = @_;
+ return tuwf->resDenied if !auth;
+ tuwf->dbExeci('UPDATE users_prefs SET', { $f->{save} => $f->{value} }, 'WHERE id =', \auth->uid);
+ {}
+};
+
+
+sub widget_ {
+ my($self,$url) = @_;
+ my($v,$o) = $self->@*;
+ menu_ class => 'tableopts', VNWeb::HTML::widget(TableOpts => $FORM_OUT, {
+ save => auth ? $o->{pref} : undef,
+ views => $o->{views},
+ value => $v,
+ default => $o->{default},
+ usaved => $o->{pref} && auth->pref($o->{pref}),
+ sorts => [ map +{ id => $_->{sort_id}, name => $_->{name}, num => $_->{sort_num}||0 }, grep defined $_->{sort_id}, values $o->{col_order}->@* ],
+ vis => [ map +{ id => $_->{vis_id}, name => $_->{name} }, grep defined $_->{vis_id}, values $o->{col_order}->@* ],
+ }), sub {
+ li_ class => 'hidden', sub {
+ input_ type => 'hidden', name => 's', value => $self->query_encode;
+ };
+ li_ sub {
+ a_ href => $url->(s => $self->view_param($_)),
+ class => $_ == ($self->[0] & 3) ? 'highlightselected' : undef,
+ title => ['List view', 'Card view', 'Grid view']->[$_], sub {
+ # SVG icons from https://lucide.dev/, MIT
+ lit_ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'.
+ [ '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>'
+ , '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><line x1="3" x2="21" y1="12" y2="12"/>'
+ , '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>'
+ ]->[$_].'</g></svg>';
+ };
+ } for $o->{views}->@*;
+ };
+}
+
+
+# Helpful debugging function, dumps a quick overview of assigned numeric
+# identifiers for the given opts.
+sub dump_ids {
+ my($o) = @_;
+ warn sprintf "sort %2d %s %s\n", $_->{sort_id}, $_->{id}, $_->{name}
+ for sort { $a->{sort_id} <=> $b->{sort_id} }
+ grep defined $_->{sort_id}, values $o->{col_order}->@*;
+ warn sprintf "vis %2d %s %s\n", $_->{vis_id}, $_->{id}, $_->{name}
+ for sort { $a->{vis_id} <=> $b->{vis_id} }
+ grep defined $_->{vis_id}, values $o->{col_order}->@*;
+}
+
+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/TimeZone.pm b/lib/VNWeb/TimeZone.pm
new file mode 100644
index 00000000..6b14f4f0
--- /dev/null
+++ b/lib/VNWeb/TimeZone.pm
@@ -0,0 +1,512 @@
+package VNWeb::TimeZone;
+
+use v5.28;
+use warnings;
+use TUWF;
+use VNWeb::Auth;
+use VNWeb::Validation 'is_api';
+use Exporter 'import';
+
+
+our @EXPORT = ('@ZONES', '%ZONES');
+
+# All cities, including aliases for other timezones but excluding "country"
+# aliases to keep the list sane.
+# find /usr/share/zoneinfo -type f -printf '%P\n' | grep '/' | grep -vE '^(Etc|Brazil|Chile|Mexico|US|Canada)' | sort
+our @ZONES = qw{
+ UTC
+ Africa/Abidjan
+ Africa/Accra
+ Africa/Addis_Ababa
+ Africa/Algiers
+ Africa/Asmara
+ Africa/Asmera
+ Africa/Bamako
+ Africa/Bangui
+ Africa/Banjul
+ Africa/Bissau
+ Africa/Blantyre
+ Africa/Brazzaville
+ Africa/Bujumbura
+ Africa/Cairo
+ Africa/Casablanca
+ Africa/Ceuta
+ Africa/Conakry
+ Africa/Dakar
+ Africa/Dar_es_Salaam
+ Africa/Djibouti
+ Africa/Douala
+ Africa/El_Aaiun
+ Africa/Freetown
+ Africa/Gaborone
+ Africa/Harare
+ Africa/Johannesburg
+ Africa/Juba
+ Africa/Kampala
+ Africa/Khartoum
+ Africa/Kigali
+ Africa/Kinshasa
+ Africa/Lagos
+ Africa/Libreville
+ Africa/Lome
+ Africa/Luanda
+ Africa/Lubumbashi
+ Africa/Lusaka
+ Africa/Malabo
+ Africa/Maputo
+ Africa/Maseru
+ Africa/Mbabane
+ Africa/Mogadishu
+ Africa/Monrovia
+ Africa/Nairobi
+ Africa/Ndjamena
+ Africa/Niamey
+ Africa/Nouakchott
+ Africa/Ouagadougou
+ Africa/Porto-Novo
+ Africa/Sao_Tome
+ Africa/Timbuktu
+ Africa/Tripoli
+ Africa/Tunis
+ Africa/Windhoek
+ America/Adak
+ America/Anchorage
+ America/Anguilla
+ America/Antigua
+ America/Araguaina
+ America/Argentina/Buenos_Aires
+ America/Argentina/Catamarca
+ America/Argentina/ComodRivadavia
+ America/Argentina/Cordoba
+ America/Argentina/Jujuy
+ America/Argentina/La_Rioja
+ America/Argentina/Mendoza
+ America/Argentina/Rio_Gallegos
+ America/Argentina/Salta
+ America/Argentina/San_Juan
+ America/Argentina/San_Luis
+ America/Argentina/Tucuman
+ America/Argentina/Ushuaia
+ America/Aruba
+ America/Asuncion
+ America/Atikokan
+ America/Atka
+ America/Bahia
+ America/Bahia_Banderas
+ America/Barbados
+ America/Belem
+ America/Belize
+ America/Blanc-Sablon
+ America/Boa_Vista
+ America/Bogota
+ America/Boise
+ America/Buenos_Aires
+ America/Cambridge_Bay
+ America/Campo_Grande
+ America/Cancun
+ America/Caracas
+ America/Catamarca
+ America/Cayenne
+ America/Cayman
+ America/Chicago
+ America/Chihuahua
+ America/Coral_Harbour
+ America/Cordoba
+ America/Costa_Rica
+ America/Creston
+ America/Cuiaba
+ America/Curacao
+ America/Danmarkshavn
+ America/Dawson
+ America/Dawson_Creek
+ America/Denver
+ America/Detroit
+ America/Dominica
+ America/Edmonton
+ America/Eirunepe
+ America/El_Salvador
+ America/Ensenada
+ America/Fort_Nelson
+ America/Fort_Wayne
+ America/Fortaleza
+ America/Glace_Bay
+ America/Godthab
+ America/Goose_Bay
+ America/Grand_Turk
+ America/Grenada
+ America/Guadeloupe
+ America/Guatemala
+ America/Guayaquil
+ America/Guyana
+ America/Halifax
+ America/Havana
+ America/Hermosillo
+ America/Indiana/Indianapolis
+ America/Indiana/Knox
+ America/Indiana/Marengo
+ America/Indiana/Petersburg
+ America/Indiana/Tell_City
+ America/Indiana/Vevay
+ America/Indiana/Vincennes
+ America/Indiana/Winamac
+ America/Indianapolis
+ America/Inuvik
+ America/Iqaluit
+ America/Jamaica
+ America/Jujuy
+ America/Juneau
+ America/Kentucky/Louisville
+ America/Kentucky/Monticello
+ America/Knox_IN
+ America/Kralendijk
+ America/La_Paz
+ America/Lima
+ America/Los_Angeles
+ America/Louisville
+ America/Lower_Princes
+ America/Maceio
+ America/Managua
+ America/Manaus
+ America/Marigot
+ America/Martinique
+ America/Matamoros
+ America/Mazatlan
+ America/Mendoza
+ America/Menominee
+ America/Merida
+ America/Metlakatla
+ America/Mexico_City
+ America/Miquelon
+ America/Moncton
+ America/Monterrey
+ America/Montevideo
+ America/Montreal
+ America/Montserrat
+ America/Nassau
+ America/New_York
+ America/Nipigon
+ America/Nome
+ America/Noronha
+ America/North_Dakota/Beulah
+ America/North_Dakota/Center
+ America/North_Dakota/New_Salem
+ America/Nuuk
+ America/Ojinaga
+ America/Panama
+ America/Pangnirtung
+ America/Paramaribo
+ America/Phoenix
+ America/Port-au-Prince
+ America/Port_of_Spain
+ America/Porto_Acre
+ America/Porto_Velho
+ America/Puerto_Rico
+ America/Punta_Arenas
+ America/Rainy_River
+ America/Rankin_Inlet
+ America/Recife
+ America/Regina
+ America/Resolute
+ America/Rio_Branco
+ America/Rosario
+ America/Santa_Isabel
+ America/Santarem
+ America/Santiago
+ America/Santo_Domingo
+ America/Sao_Paulo
+ America/Scoresbysund
+ America/Shiprock
+ America/Sitka
+ America/St_Barthelemy
+ America/St_Johns
+ America/St_Kitts
+ America/St_Lucia
+ America/St_Thomas
+ America/St_Vincent
+ America/Swift_Current
+ America/Tegucigalpa
+ America/Thule
+ America/Thunder_Bay
+ America/Tijuana
+ America/Toronto
+ America/Tortola
+ America/Vancouver
+ America/Virgin
+ America/Whitehorse
+ America/Winnipeg
+ America/Yakutat
+ America/Yellowknife
+ Antarctica/Casey
+ Antarctica/Davis
+ Antarctica/DumontDUrville
+ Antarctica/Macquarie
+ Antarctica/Mawson
+ Antarctica/McMurdo
+ Antarctica/Palmer
+ Antarctica/Rothera
+ Antarctica/South_Pole
+ Antarctica/Syowa
+ Antarctica/Troll
+ Antarctica/Vostok
+ Arctic/Longyearbyen
+ Asia/Aden
+ Asia/Almaty
+ Asia/Amman
+ Asia/Anadyr
+ Asia/Aqtau
+ Asia/Aqtobe
+ Asia/Ashgabat
+ Asia/Ashkhabad
+ Asia/Atyrau
+ Asia/Baghdad
+ Asia/Bahrain
+ Asia/Baku
+ Asia/Bangkok
+ Asia/Barnaul
+ Asia/Beirut
+ Asia/Bishkek
+ Asia/Brunei
+ Asia/Calcutta
+ Asia/Chita
+ Asia/Choibalsan
+ Asia/Chongqing
+ Asia/Chungking
+ Asia/Colombo
+ Asia/Dacca
+ Asia/Damascus
+ Asia/Dhaka
+ Asia/Dili
+ Asia/Dubai
+ Asia/Dushanbe
+ Asia/Famagusta
+ Asia/Gaza
+ Asia/Harbin
+ Asia/Hebron
+ Asia/Ho_Chi_Minh
+ Asia/Hong_Kong
+ Asia/Hovd
+ Asia/Irkutsk
+ Asia/Istanbul
+ Asia/Jakarta
+ Asia/Jayapura
+ Asia/Jerusalem
+ Asia/Kabul
+ Asia/Kamchatka
+ Asia/Karachi
+ Asia/Kashgar
+ Asia/Kathmandu
+ Asia/Katmandu
+ Asia/Khandyga
+ Asia/Kolkata
+ Asia/Krasnoyarsk
+ Asia/Kuala_Lumpur
+ Asia/Kuching
+ Asia/Kuwait
+ Asia/Macao
+ Asia/Macau
+ Asia/Magadan
+ Asia/Makassar
+ Asia/Manila
+ Asia/Muscat
+ Asia/Nicosia
+ Asia/Novokuznetsk
+ Asia/Novosibirsk
+ Asia/Omsk
+ Asia/Oral
+ Asia/Phnom_Penh
+ Asia/Pontianak
+ Asia/Pyongyang
+ Asia/Qatar
+ Asia/Qostanay
+ Asia/Qyzylorda
+ Asia/Rangoon
+ Asia/Riyadh
+ Asia/Saigon
+ Asia/Sakhalin
+ Asia/Samarkand
+ Asia/Seoul
+ Asia/Shanghai
+ Asia/Singapore
+ Asia/Srednekolymsk
+ Asia/Taipei
+ Asia/Tashkent
+ Asia/Tbilisi
+ Asia/Tehran
+ Asia/Tel_Aviv
+ Asia/Thimbu
+ Asia/Thimphu
+ Asia/Tokyo
+ Asia/Tomsk
+ Asia/Ujung_Pandang
+ Asia/Ulaanbaatar
+ Asia/Ulan_Bator
+ Asia/Urumqi
+ Asia/Ust-Nera
+ Asia/Vientiane
+ Asia/Vladivostok
+ Asia/Yakutsk
+ Asia/Yangon
+ Asia/Yekaterinburg
+ Asia/Yerevan
+ Atlantic/Azores
+ Atlantic/Bermuda
+ Atlantic/Canary
+ Atlantic/Cape_Verde
+ Atlantic/Faeroe
+ Atlantic/Faroe
+ Atlantic/Jan_Mayen
+ Atlantic/Madeira
+ Atlantic/Reykjavik
+ Atlantic/South_Georgia
+ Atlantic/St_Helena
+ Atlantic/Stanley
+ Australia/ACT
+ Australia/Adelaide
+ Australia/Brisbane
+ Australia/Broken_Hill
+ Australia/Canberra
+ Australia/Currie
+ Australia/Darwin
+ Australia/Eucla
+ Australia/Hobart
+ Australia/LHI
+ Australia/Lindeman
+ Australia/Lord_Howe
+ Australia/Melbourne
+ Australia/NSW
+ Australia/North
+ Australia/Perth
+ Australia/Queensland
+ Australia/South
+ Australia/Sydney
+ Australia/Tasmania
+ Australia/Victoria
+ Australia/West
+ Australia/Yancowinna
+ Europe/Amsterdam
+ Europe/Andorra
+ Europe/Astrakhan
+ Europe/Athens
+ Europe/Belfast
+ Europe/Belgrade
+ Europe/Berlin
+ Europe/Bratislava
+ Europe/Brussels
+ Europe/Bucharest
+ Europe/Budapest
+ Europe/Busingen
+ Europe/Chisinau
+ Europe/Copenhagen
+ Europe/Dublin
+ Europe/Gibraltar
+ Europe/Guernsey
+ Europe/Helsinki
+ Europe/Isle_of_Man
+ Europe/Istanbul
+ Europe/Jersey
+ Europe/Kaliningrad
+ Europe/Kiev
+ Europe/Kirov
+ Europe/Kyiv
+ Europe/Lisbon
+ Europe/Ljubljana
+ Europe/London
+ Europe/Luxembourg
+ Europe/Madrid
+ Europe/Malta
+ Europe/Mariehamn
+ Europe/Minsk
+ Europe/Monaco
+ Europe/Moscow
+ Europe/Nicosia
+ Europe/Oslo
+ Europe/Paris
+ Europe/Podgorica
+ Europe/Prague
+ Europe/Riga
+ Europe/Rome
+ Europe/Samara
+ Europe/San_Marino
+ Europe/Sarajevo
+ Europe/Saratov
+ Europe/Simferopol
+ Europe/Skopje
+ Europe/Sofia
+ Europe/Stockholm
+ Europe/Tallinn
+ Europe/Tirane
+ Europe/Tiraspol
+ Europe/Ulyanovsk
+ Europe/Uzhgorod
+ Europe/Vaduz
+ Europe/Vatican
+ Europe/Vienna
+ Europe/Vilnius
+ Europe/Volgograd
+ Europe/Warsaw
+ Europe/Zagreb
+ Europe/Zaporozhye
+ Europe/Zurich
+ Indian/Antananarivo
+ Indian/Chagos
+ Indian/Christmas
+ Indian/Cocos
+ Indian/Comoro
+ Indian/Kerguelen
+ Indian/Mahe
+ Indian/Maldives
+ Indian/Mauritius
+ Indian/Mayotte
+ Indian/Reunion
+ Pacific/Apia
+ Pacific/Auckland
+ Pacific/Bougainville
+ Pacific/Chatham
+ Pacific/Chuuk
+ Pacific/Easter
+ Pacific/Efate
+ Pacific/Enderbury
+ Pacific/Fakaofo
+ Pacific/Fiji
+ Pacific/Funafuti
+ Pacific/Galapagos
+ Pacific/Gambier
+ Pacific/Guadalcanal
+ Pacific/Guam
+ Pacific/Honolulu
+ Pacific/Johnston
+ Pacific/Kanton
+ Pacific/Kiritimati
+ Pacific/Kosrae
+ Pacific/Kwajalein
+ Pacific/Majuro
+ Pacific/Marquesas
+ Pacific/Midway
+ Pacific/Nauru
+ Pacific/Niue
+ Pacific/Norfolk
+ Pacific/Noumea
+ Pacific/Pago_Pago
+ Pacific/Palau
+ Pacific/Pitcairn
+ Pacific/Pohnpei
+ Pacific/Ponape
+ Pacific/Port_Moresby
+ Pacific/Rarotonga
+ Pacific/Saipan
+ Pacific/Samoa
+ Pacific/Tahiti
+ Pacific/Tarawa
+ Pacific/Tongatapu
+ Pacific/Truk
+ Pacific/Wake
+ Pacific/Wallis
+ Pacific/Yap
+};
+our %ZONES = map +($_,1), @ZONES;
+
+TUWF::hook before => sub {
+ $ENV{TZ} = !is_api() && auth->pref('timezone') || 'UTC';
+} if !$main::ONLYAPI;
+
+1;
diff --git a/lib/VNWeb/TitlePrefs.pm b/lib/VNWeb/TitlePrefs.pm
new file mode 100644
index 00000000..4405d176
--- /dev/null
+++ b/lib/VNWeb/TitlePrefs.pm
@@ -0,0 +1,217 @@
+package VNWeb::TitlePrefs;
+
+use v5.26;
+use TUWF;
+use VNDB::Types;
+use VNWeb::Auth;
+use VNWeb::DB;
+use VNWeb::Validation;
+use Exporter 'import';
+
+our @EXPORT = qw/
+ titleprefs_obj
+ titleprefs_swap
+ vnt
+ releasest
+ producerst
+ charst
+ staff_aliast
+ item_info
+/;
+
+our @EXPORT_OK = qw/
+ titleprefs_parse
+ titleprefs_fmt
+ $DEFAULT_TITLE_PREFS
+/;
+
+
+# Parse a string representation of the 'titleprefs' SQL type for use in Perl & Elm.
+# (Could also use Postgres row_to_json() to simplify this a bit, but it wouldn't save much)
+sub titleprefs_parse {
+ return undef if !defined $_[0];
+ state $L = qr/([^,]*)/;
+ state $B = qr/([tf])/;
+ state $O = qr/([tf]?)/;
+ state $RE = qr/^\(
+ $L,$L,$L,$L, # 1.. 4 -> t1_lang .. t4_lang
+ $L,$L,$L,$L, # 5.. 8 -> a1_lang .. a4_lang
+ $B,$B,$B,$B,$B, # 9..13 -> t1_latin .. to_latin
+ $B,$B,$B,$B,$B, # 14..18 -> a1_latin .. ao_latin
+ $O,$O,$O,$O, # 19..22 -> t1_official .. t4_official
+ $O,$O,$O,$O # 23..26 -> a1_official .. a4_official
+ \)$/x;
+ die $_[0] if $_[0] !~ $RE;
+ sub b($) { !$_[0] ? undef : $_[0] eq 't' }
+ sub l($) { !$_[0] ? undef : $_[0] }
+ [
+ [ $1 ? { lang => l $1, latin => b $9, official => b $19 } : ()
+ , $2 ? { lang => l $2, latin => b $10, official => b $20 } : ()
+ , $3 ? { lang => l $3, latin => b $11, official => b $21 } : ()
+ , $4 ? { lang => l $4, latin => b $12, official => b $22 } : ()
+ , { lang => undef,latin => b $13, official => undef } ],
+ [ $5 ? { lang => l $5, latin => b $14, official => b $23 } : ()
+ , $6 ? { lang => l $6, latin => b $15, official => b $24 } : ()
+ , $7 ? { lang => l $7, latin => b $16, official => b $25 } : ()
+ , $8 ? { lang => l $8, latin => b $17, official => b $26 } : ()
+ , { lang => undef,latin => b $18, official => undef } ],
+ ]
+}
+
+
+sub titleprefs_fmt {
+ my($p) = @_;
+ return undef if !defined $p;
+ my sub val { my $v = $p->[$_[0]][$_[1]]; $v && $v->{lang} ? $v->{$_[2]} : undef }
+ my sub l($$) { val @_, 'lang' }
+ my sub b($$) { my $v = val @_, 'latin'; $v ? 't' : 'f' }
+ my sub o($$) { my $v = val @_, 'official'; !defined $v ? '' : $v ? 't' : 'f' }
+ '('.join(',',
+ l(0,0), l(0,1), l(0,2), l(0,3),
+ l(1,0), l(1,1), l(1,2), l(1,3),
+ b(0,0), b(0,1), b(0,2), b(0,3), $p->[0][$#{$p->[0]}]{latin} ? 't' : 'f',
+ b(1,0), b(1,1), b(1,2), b(1,3), $p->[1][$#{$p->[1]}]{latin} ? 't' : 'f',
+ o(0,0), o(0,1), o(0,2), o(0,3),
+ o(1,0), o(1,1), o(1,2), o(1,3)
+ ).')'
+}
+
+
+# This validation only covers half of the titleprefs, i.e. just the main or alternative title.
+TUWF::set('custom_validations')->{titleprefs} = {
+ type => 'array',
+ maxlength => 5,
+ values => { type => 'hash', keys => {
+ lang => { default => undef, enum => \%LANGUAGE }, # undef referring to the original title language
+ latin => { anybool => 1 },
+ official => { undefbool => 1 },
+ }},
+ func => sub {
+ # Last one must be olang if n==5.
+ return 0 if $_[0]->@* == 5 && $_[0][4]{lang};
+ # undef lang is only allowed as sentinel
+ return 0 if $_[0]->@* >= 2 && grep !$_[0][$_]{lang}, 0..($_[0]->@*-2);
+ # ensure we have an undef lang
+ push $_[0]->@*, { lang => undef, latin => '', official => undef } if !grep !$_->{lang}, $_[0]->@*;
+
+ # Remove duplicate languages that will never be matched.
+ my %l;
+ $_[0] = [ grep {
+ my $prio = !defined $_->{official} ? 3 : $_->{official} ? 2 : 1;
+ my $dupe = $l{$_->{lang}} && $l{$_->{lang}} <= $prio;
+ $l{$_->{lang}} = $prio if !$dupe;
+ !$dupe
+ } $_[0]->@* ];
+
+ # (XXX: we can also merge adjacent duplicates at this stage)
+
+ # Expand 'Chinese' to the scripts if we have enough free slots.
+ # (this is a hack and should ideally be handled in the title selection
+ # algorithm, but that selection code has multiple implementations and
+ # is already subject to potential performance issues, so I'd rather
+ # keep it simple)
+ $_[0] = [ map $_->{lang} eq 'zh' ? ($_, {%$_,lang=>'zh-Hant'}, {%$_,lang=>'zh-Hans'}) : ($_), $_[0]->@* ]
+ if $_[0]->@* <= 3 && !grep $_->{lang} && $_->{lang} =~ /^zh-/, $_[0]->@*;
+ 1;
+ },
+};
+
+
+our $DEFAULT_TITLE_PREFS = [
+ [ { lang => undef, latin => 1, official => undef } ],
+ [ { lang => undef, latin => '', official => undef } ],
+];
+
+sub pref { tuwf->req->{titleprefs} //= !is_api() && titleprefs_parse(auth->pref('titles')) }
+
+
+# Returns the preferred title array given an array of (vn|releases)_titles-like
+# objects. Same functionality as the SQL view, except implemented in perl.
+sub titleprefs_obj {
+ my($olang, $titles) = @_;
+ my $p = pref || $DEFAULT_TITLE_PREFS;
+ my %l = map +($_->{lang},$_), $titles->@*;
+
+ my @title = ('','','','');
+ for my $t (0,1) {
+ for ($p->[$t]->@*) {
+ my $o = $l{$_->{lang} // $olang} or next;
+ next if !defined $_->{official} && $o->{lang} ne $olang;
+ next if $_->{official} && defined $o->{official} && !$o->{official};
+ next if !defined $o->{title};
+ $title[$t*2] = $o->{lang};
+ $title[$t*2+1] = $_->{latin} && length $o->{latin} ? $o->{latin} : $o->{title};
+ last;
+ }
+ }
+ \@title;
+}
+
+
+# Returns the preferred title array given a language, latin title and original title.
+# For DB entries that only have (title, latin) fields.
+sub titleprefs_swap {
+ my($olang, $title, $latin) = @_;
+ my $p = pref || $DEFAULT_TITLE_PREFS;
+
+ my @title = ($olang,'',$olang,'');
+ for my $t (0,1) {
+ for ($p->[$t]->@*) {
+ next if $_->{lang} && $_->{lang} ne $olang;
+ $title[$t*2+1] = $_->{latin} ? $latin//$title : $title;
+ last;
+ }
+ }
+ \@title;
+}
+
+
+sub gen_sql {
+ my($has_official, $tbl_main, $tbl_titles, $join_col) = @_;
+ my $p = pref || $DEFAULT_TITLE_PREFS;
+
+ sub id { (!defined $_[0]{official}?'r':$_[0]{official}?'o':'u').($_[0]{lang}//'') }
+
+ my %joins = map +(id($_),1), $p->[0]->@*, $p->[1]->@*;
+ my $var = 'a';
+ $joins{$_} = 'x_'.$var++ for sort keys %joins;
+ my @joins = map sql(
+ "LEFT JOIN $tbl_titles $joins{$_} ON", sql_and
+ "$joins{$_}.$join_col = x.$join_col",
+ $_ =~ /^r/ ? "$joins{$_}.lang = x.olang" : (),
+ length($_) > 1 ? sql("$joins{$_}.lang =", \(''.substr($_,1))) : (),
+ $has_official && $_ =~ /^o./ ? "$joins{$_}.official" : (),
+ ), sort keys %joins;
+
+ my sub titlearray {
+ my($o) = @_;
+ 'ARRAY['.($o->{lang}?"'$o->{lang}'":'null').', COALESCE('.($o->{latin} ? $joins{ id($o) }.'.latin, ' : '').$joins{ id($o) }.'.title)]';
+ }
+ my sub titlesel {
+ my $orig = pop;
+ return titlearray($orig) if !@_;
+ 'CASE '.join(' ', map 'WHEN '.$joins{ id($_) }.'.title IS NOT NULL THEN '.titlearray($_), @_).' ELSE '.titlearray($orig).' END';
+ }
+ my $title = titlesel($p->[0]->@*).'||'.titlesel($p->[1]->@*);
+ my $sorttitle = 'COALESCE('.join(',',
+ map +($joins{ id($_) }.'.latin', $joins{ id($_) }.'.title'), $p->[0]->@*
+ ).')';
+
+ sql "(SELECT x.*, $title AS title, $sorttitle AS sorttitle FROM $tbl_main x", @joins, ')';
+}
+
+
+sub vnt() { tuwf->req->{titleprefs_v} //= pref ? gen_sql 1, 'vn', 'vn_titles', 'id' : 'vnt' }
+sub releasest() { tuwf->req->{titleprefs_r} //= pref ? gen_sql 0, 'releases', 'releases_titles', 'id' : 'releasest' }
+sub producerst() { tuwf->req->{titleprefs_p} //= pref ? sql 'producerst(', \tuwf->req->{auth}{user}{titles}, ')' : 'producerst' }
+sub charst() { tuwf->req->{titleprefs_c} //= pref ? sql 'charst(', \tuwf->req->{auth}{user}{titles}, ')' : 'charst' }
+sub staff_aliast() { tuwf->req->{titleprefs_s} //= pref ? sql 'staff_aliast(', \tuwf->req->{auth}{user}{titles}, ')' : 'staff_aliast' }
+
+# (Not currently used)
+#sub vnt_hist { gen_sql 1, 'vn_hist', 'vn_titles_hist', 'chid' }
+#sub releasest_hist { gen_sql 0, 'releases_hist', 'releases_titles_hist', 'chid' }
+
+# Wrapper around SQL's item_info() with the user's preference applied.
+sub item_info($$) { sql 'item_info(', \((tuwf->req->{auth} && tuwf->req->{auth}{user}{titles}) || undef), ',', $_[0], ',', $_[1], ')' }
+
+1;
diff --git a/lib/VNWeb/ULists/Elm.pm b/lib/VNWeb/ULists/Elm.pm
new file mode 100644
index 00000000..bcc22de1
--- /dev/null
+++ b/lib/VNWeb/ULists/Elm.pm
@@ -0,0 +1,297 @@
+package VNWeb::ULists::Elm;
+
+use VNWeb::Prelude;
+use VNWeb::ULists::Lib;
+
+
+# Should be called after any label/vote/private change to the ulist_vns table.
+# (Normally I'd do this with triggers, but that seemed like a more complex and less efficient solution in this case)
+sub updcache {
+ my($uid,$vid) = @_;
+ tuwf->dbExeci(SELECT => sql_func update_users_ulist_private => \$uid, \$vid) if @_ == 2;
+ tuwf->dbExeci(SELECT => sql_func update_users_ulist_stats => \$uid);
+}
+
+
+sub sql_labelid {
+ my($uid) = @_;
+ sql '(SELECT min(x.n)
+ FROM generate_series(10,
+ greatest((SELECT max(id)+1 from ulist_labels ul WHERE ul.uid =', \$uid, '), 10)
+ ) x(n)
+ WHERE NOT EXISTS(SELECT 1 FROM ulist_labels ul WHERE ul.uid =', \$uid, 'AND ul.id = x.n))';
+}
+
+
+our $LABELS = form_compile any => {
+ uid => { vndbid => 'u' },
+ labels => { maxlength => 1500, aoh => {
+ id => { int => 1 },
+ label => { sl => 1, maxlength => 50 },
+ private => { anybool => 1 },
+ count => { uint => 1 },
+ delete => { default => undef, uint => 1, range => [1, 3] }, # 1=keep vns, 2=delete when no other label, 3=delete all
+ } }
+};
+
+elm_api UListManageLabels => undef, $LABELS, sub {
+ my($uid, $labels) = ($_[0]{uid}, $_[0]{labels});
+ return elm_Unauth if !ulists_own $uid;
+
+ # Insert new labels
+ my @new = grep $_->{id} < 0 && !$_->{delete}, @$labels;
+ tuwf->dbExeci('INSERT INTO ulist_labels', { id => sql_labelid($uid), uid => $uid, label => $_->{label}, private => $_->{private} }) for @new;
+
+ # Update private flag
+ my $changed = 0;
+ $changed += tuwf->dbExeci(
+ 'UPDATE ulist_labels SET private =', \$_->{private},
+ 'WHERE uid =', \$uid, 'AND id =', \$_->{id}, 'AND private <>', \$_->{private}
+ ) for grep $_->{id} > 0 && !$_->{delete}, @$labels;
+
+ # Update label
+ tuwf->dbExeci(
+ 'UPDATE ulist_labels SET label =', \$_->{label},
+ 'WHERE uid =', \$uid, 'AND id =', \$_->{id}, 'AND label <>', \$_->{label}
+ ) for grep $_->{id} >= 10 && !$_->{delete}, @$labels;
+
+ # Delete labels
+ my @delete = grep $_->{id} >= 10 && $_->{delete}, @$labels;
+ my @delete_lblonly = map $_->{id}, grep $_->{delete} == 1, @delete;
+ my @delete_empty = map $_->{id}, grep $_->{delete} == 2, @delete;
+ 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 = (
+ @delete_all ? sql('labels &&', sql_array(@delete_all), '::smallint[]') : (),
+ @delete_empty ? sql(
+ 'labels &&', sql_array(@delete_empty), '::smallint[]
+ AND labels <@', sql_array(@delete_lblonly, @delete_empty), '::smallint[]'
+ ) : ()
+ );
+ tuwf->dbExeci('DELETE FROM ulist_vns uv WHERE uid =', \$uid, 'AND (', sql_or(@where), ')') if @where;
+
+ $changed += tuwf->dbExeci(
+ 'UPDATE ulist_vns
+ SET labels = array_remove(labels,', \$_->{id}, ')
+ WHERE uid =', \$uid, 'AND labels && ARRAY[', \$_->{id}, '::smallint]'
+ ) for @delete;
+
+ tuwf->dbExeci('DELETE FROM ulist_labels WHERE uid =', \$uid, 'AND id IN', [ map $_->{id}, @delete ]) if @delete;
+
+ updcache $uid, $changed ? undef : ();
+ elm_Success
+};
+
+
+# Create a new label and add it to a VN
+elm_api UListLabelAdd => undef, {
+ uid => { vndbid => 'u' },
+ vid => { vndbid => 'v' },
+ label => { sl => 1, maxlength => 50 },
+}, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+
+ my $id = tuwf->dbVali('
+ WITH x(id) AS (SELECT id FROM ulist_labels WHERE', { uid => $data->{uid}, label => $data->{label} }, '),
+ y(id) AS (INSERT INTO ulist_labels (id, uid, label, private) SELECT', sql_join(',',
+ sql_labelid($data->{uid}), \$data->{uid}, \$data->{label},
+ # Let's copy the private flag from the Voted label, seems like a sane default
+ sql('(SELECT private FROM ulist_labels WHERE', {uid => $data->{uid}, id => 7}, ')')
+ ), 'WHERE NOT EXISTS(SELECT 1 FROM x) RETURNING id)
+ SELECT id FROM x UNION SELECT id FROM y'
+ );
+ die "Attempt to set vote label" if $id == 7;
+
+ tuwf->dbExeci(
+ 'INSERT INTO ulist_vns', {uid => $data->{uid}, vid => $data->{vid}, labels => "{$id}"},
+ 'ON CONFLICT (uid, vid) DO UPDATE SET labels = array_set(ulist_vns.labels,', \$id, ')'
+ );
+ updcache $data->{uid}, $data->{vid};
+ elm_LabelId $id
+};
+
+
+
+our $VNVOTE = form_compile any => {
+ uid => { vndbid => 'u' },
+ vid => { vndbid => 'v' },
+ vote => { vnvote => 1 },
+};
+
+elm_api UListVoteEdit => undef, $VNVOTE, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ tuwf->dbExeci(
+ 'INSERT INTO ulist_vns', { %$data, vote_date => sql $data->{vote} ? 'NOW()' : 'NULL' },
+ 'ON CONFLICT (uid, vid) DO UPDATE
+ SET', { %$data,
+ lastmod => sql('NOW()'),
+ vote_date => sql $data->{vote} ? 'CASE WHEN ulist_vns.vote IS NULL THEN NOW() ELSE ulist_vns.vote_date END' : 'NULL'
+ }
+ );
+ updcache $data->{uid}, $data->{vid};
+ elm_Success
+};
+
+
+
+
+my $VNLABELS = {
+ uid => { vndbid => 'u' },
+ vid => { vndbid => 'v' },
+ label => { _when => 'in', id => 1 },
+ applied => { _when => 'in', anybool => 1 },
+ labels => { _when => 'out', aoh => { id => { int => 1 }, label => {}, private => { anybool => 1 } } },
+ selected => { _when => 'out', type => 'array', values => { id => 1 } },
+};
+
+our $VNLABELS_OUT = form_compile out => $VNLABELS;
+my $VNLABELS_IN = form_compile in => $VNLABELS;
+
+elm_api UListLabelEdit => $VNLABELS_OUT, $VNLABELS_IN, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ die "Attempt to set vote label" if $data->{label} == 7;
+ die "Attempt to set invalid label" if $data->{applied}
+ && !tuwf->dbVali('SELECT 1 FROM ulist_labels WHERE uid =', \$data->{uid}, 'AND id =', \$data->{label});
+
+ tuwf->dbExeci(
+ 'INSERT INTO ulist_vns', {
+ uid => $data->{uid},
+ vid => $data->{vid},
+ labels => $data->{applied}?"{$data->{label}}":'{}'
+ }, 'ON CONFLICT (uid, vid) DO UPDATE SET lastmod = NOW(),
+ labels =', sql_func $data->{applied} ? 'array_set' : 'array_remove', 'ulist_vns.labels', \$data->{label}
+ );
+ updcache $data->{uid}, $data->{vid};
+ elm_Success
+};
+
+
+
+
+our $VNDATE = form_compile any => {
+ uid => { vndbid => 'u' },
+ vid => { vndbid => 'v' },
+ date => { default => '', caldate => 1 },
+ start => { anybool => 1 }, # Field selection, started/finished
+};
+
+elm_api UListDateEdit => undef, $VNDATE, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ tuwf->dbExeci(
+ 'UPDATE ulist_vns SET lastmod = NOW(), ', $data->{start} ? 'started' : 'finished', '=', \($data->{date}||undef),
+ 'WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid}
+ );
+ # Doesn't need `updcache()`
+ elm_Success
+};
+
+
+
+
+our $VNOPT = form_compile any => {
+ own => { anybool => 1 },
+ uid => { vndbid => 'u' },
+ vid => { vndbid => 'v' },
+ notes => {},
+ rels => $VNWeb::Elm::apis{Releases}[0],
+ relstatus => { type => 'array', values => { uint => 1 } }, # List of release statuses, same order as rels
+};
+
+
+# UListVNNotes module is abused for the UList.Opts flag definition
+elm_api UListVNNotes => $VNOPT, {
+ uid => { vndbid => 'u' },
+ vid => { vndbid => 'v' },
+ notes => { default => '', maxlength => 2000 },
+}, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ tuwf->dbExeci(
+ 'INSERT INTO ulist_vns', \%$data, 'ON CONFLICT (uid, vid) DO UPDATE SET', { %$data, lastmod => sql('NOW()') }
+ );
+ # Doesn't need `updcache()`
+ elm_Success
+};
+
+
+
+
+elm_api UListDel => undef, {
+ uid => { vndbid => 'u' },
+ vid => { vndbid => 'v' },
+}, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ tuwf->dbExeci('DELETE FROM ulist_vns WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid});
+ updcache $data->{uid};
+ elm_Success
+};
+
+
+
+
+# Adds the release when not in the list.
+# $RLIST_STATUS is also referenced from VNWeb::Releases::Page.
+our $RLIST_STATUS = form_compile any => {
+ uid => { vndbid => 'u' },
+ rid => { vndbid => 'r' },
+ status => { default => undef, uint => 1, enum => \%RLIST_STATUS }, # undef meaning delete
+ empty => { default => '' }, # An 'out' field
+};
+elm_api UListRStatus => undef, $RLIST_STATUS, sub {
+ my($data) = @_;
+ delete $data->{empty};
+ return elm_Unauth if !ulists_own $data->{uid};
+ if(!defined $data->{status}) {
+ tuwf->dbExeci('DELETE FROM rlists WHERE uid =', \$data->{uid}, 'AND rid =', \$data->{rid})
+ } else {
+ tuwf->dbExeci('INSERT INTO rlists', $data, 'ON CONFLICT (uid, rid) DO UPDATE SET status =', \$data->{status})
+ }
+ # Doesn't need `updcache()`
+ elm_Success
+};
+
+
+
+our $WIDGET = form_compile out => $VNWeb::Elm::apis{UListWidget}[0]{keys};
+
+elm_api UListWidget => $WIDGET, { uid => { vndbid => 'u' }, vid => { vndbid => 'v' } }, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ my $v = tuwf->dbRowi('SELECT id, title, c_released FROM', vnt, 'v WHERE id =', \$data->{vid});
+ return elm_Invalid if !defined $v->{title};
+ elm_UListWidget ulists_widget_full_data $v, $data->{uid};
+};
+
+
+
+
+our %SAVED_OPTS = (
+ l => { onerror => [], type => 'array', scalar => 1, values => { int => 1, range => [-1,1600] } },
+ mul => { anybool => 1 },
+ s => { onerror => '' }, # TableOpts query string
+ f => { onerror => '' }, # AdvSearch
+);
+
+my $SAVED_OPTS = {
+ uid => { vndbid => 'u' },
+ opts => { type => 'hash', keys => \%SAVED_OPTS },
+ field => { _when => 'in', enum => [qw/ vnlist votes wish /] },
+};
+
+my $SAVED_OPTS_IN = form_compile in => $SAVED_OPTS;
+our $SAVED_OPTS_OUT = form_compile out => $SAVED_OPTS;
+
+elm_api UListSaveDefault => $SAVED_OPTS_OUT, $SAVED_OPTS_IN, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ tuwf->dbExeci('UPDATE users_prefs SET ulist_'.$data->{field}, '=', \JSON::XS->new->encode($data->{opts}), 'WHERE id =', \$data->{uid});
+ elm_Success
+};
+
+1;
diff --git a/lib/VNWeb/ULists/Export.pm b/lib/VNWeb/ULists/Export.pm
new file mode 100644
index 00000000..c9dc6875
--- /dev/null
+++ b/lib/VNWeb/ULists/Export.pm
@@ -0,0 +1,127 @@
+package VNWeb::ULists::Export;
+
+use TUWF::XML ':xml';
+use VNWeb::Prelude;
+use VNWeb::ULists::Lib;
+
+# XXX: Reading someone's entire list into memory (multiple times even) is not
+# the most efficient way to implement an export function. Might want to switch
+# to an async background process for this to reduce the footprint of web
+# workers.
+
+sub data {
+ my($uid) = @_;
+
+ # We'd like ISO7601/RFC3339 timestamps in UTC with accuracy to the second.
+ my sub tz { sql 'to_char(', $_[0], ' at time zone \'utc\',', \'YYYY-MM-DD"T"HH24:MM:SS"Z"', ') as', $_[1] }
+
+ # XXX: This keeps the old "title"/"original" fields for compatibility, but
+ # should the export take user title preferences into account instead? Or
+ # export all known titles?
+ my $d = {
+ 'export-date' => tuwf->dbVali(select => tz('NOW()', 'now')),
+ user => tuwf->dbRowi('SELECT id, username as name FROM users WHERE id =', \$uid),
+ labels => tuwf->dbAlli('SELECT id, label, private FROM ulist_labels WHERE uid =', \$uid, 'ORDER BY id'),
+ vns => tuwf->dbAlli('
+ SELECT v.id, v.title, uv.vote, uv.started, uv.finished, uv.notes, uv.c_private, uv.labels,',
+ sql_comma(tz('uv.added', 'added'), tz('uv.lastmod', 'lastmod'), tz('uv.vote_date', 'vote_date')), '
+ FROM ulist_vns uv
+ JOIN vnt v ON v.id = uv.vid
+ WHERE uv.uid =', \$uid, '
+ ORDER BY v.sorttitle'),
+ 'length-votes' => tuwf->dbAlli('
+ SELECT v.id, v.title, l.length, l.speed, l.private, l.notes, l.rid::text[] AS releases, ', tz('l.date', 'date'), '
+ FROM vn_length_votes l
+ JOIN vnt v ON v.id = l.vid
+ WHERE l.uid =', \$uid, '
+ ORDER BY v.sorttitle'),
+ };
+ enrich releases => id => vid => sub { sql '
+ SELECT rv.vid, r.id, r.title, r.released, rl.status, ', tz('rl.added', 'added'), '
+ FROM rlists rl
+ JOIN releasest r ON r.id = rl.rid
+ JOIN releases_vn rv ON rv.id = rl.rid
+ WHERE rl.uid =', \$uid, '
+ ORDER BY r.released, r.id'
+ }, $d->{vns};
+ enrich_merge id => sub { sql '
+ SELECT id, title, released FROM releasest WHERE id IN', $_, 'ORDER BY released, id'
+ }, map +($_->{releases} = [map +{id=>$_}, $_->{releases}->@*]), $d->{'length-votes'}->@*;
+ $d
+}
+
+
+sub filename {
+ my($d, $ext) = @_;
+ my $date = $d->{'export-date'} =~ s/[-TZ:]//rg;
+ "vndb-list-export-$d->{user}{name}-$date.$ext"
+}
+
+
+sub title {
+ my(@t) = $_[0]->@*;
+ return (length($t[3]) && $t[3] ne $t[1] ? (original => $t[3]) : (), $t[1]);
+}
+
+
+TUWF::get qr{/$RE{uid}/list-export/xml}, sub {
+ my $uid = tuwf->capture('id');
+ return tuwf->resDenied if !ulists_own $uid;
+ my $d = data $uid;
+ return tuwf->resNotFound if !$d->{user}{id};
+
+ tuwf->resHeader('Content-Disposition', sprintf 'attachment; filename="%s"', filename $d, 'xml');
+ tuwf->resHeader('Content-Type', 'application/xml; charset=UTF-8');
+
+ my %labels = map +($_->{id}, $_), $d->{labels}->@*;
+
+ my $fd = tuwf->resFd;
+ TUWF::XML->new(
+ write => sub { print $fd $_ for @_ },
+ pretty => 2,
+ default => 1,
+ );
+ xml;
+ tag 'vndb-export' => version => '1.0', date => $d->{'export-date'}, sub {
+ tag user => sub {
+ tag name => $d->{user}{name};
+ tag url => config->{url}.'/'.$d->{user}{id};
+ };
+ tag labels => sub {
+ tag label => id => $_->{id}, label => $_->{label}, private => $_->{private}?'true':'false', undef for $d->{labels}->@*;
+ };
+ tag vns => sub {
+ tag vn => id => $_->{id}, private => $_->{c_private}?'true':'false', sub {
+ tag title => title($_->{title});
+ tag label => id => $_, label => $labels{$_}{label}, undef for sort { $a <=> $b } $_->{labels}->@*;
+ tag added => $_->{added};
+ tag modified => $_->{lastmod} if $_->{added} ne $_->{lastmod};
+ tag vote => timestamp => $_->{vote_date}, fmtvote $_->{vote} if $_->{vote};
+ tag started => $_->{started} if $_->{started};
+ tag finished => $_->{finished} if $_->{finished};
+ tag notes => $_->{notes} if length $_->{notes};
+ tag release => id => $_->{id}, sub {
+ tag title => title($_->{title});
+ tag 'release-date' => rdate $_->{released};
+ tag status => $RLIST_STATUS{$_->{status}};
+ tag added => $_->{added};
+ } for $_->{releases}->@*;
+ } for $d->{vns}->@*;
+ };
+ tag 'length-votes', sub {
+ tag vn => id => $_->{id}, private => $_->{private}?'true':'false', sub {
+ tag title => title($_->{title});
+ tag date => $_->{date};
+ tag minutes => $_->{length};
+ tag speed => [qw/slow normal fast/]->[$_->{speed}] if defined $_->{speed};
+ tag notes => $_->{notes} if length $_->{notes};
+ tag release => id => $_->{id}, sub {
+ tag title => title($_->{title});
+ tag 'release-date' => rdate $_->{released};
+ } for $_->{releases}->@*;
+ } for $d->{'length-votes'}->@*;
+ };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/ULists/Lib.pm b/lib/VNWeb/ULists/Lib.pm
new file mode 100644
index 00000000..0e264b3b
--- /dev/null
+++ b/lib/VNWeb/ULists/Lib.pm
@@ -0,0 +1,96 @@
+package VNWeb::ULists::Lib;
+
+use VNWeb::Prelude;
+use VNWeb::Releases::Lib 'releases_by_vn';
+use Exporter 'import';
+
+our @EXPORT = qw/ulists_own ulist_filtlabels enrich_ulists_widget ulists_widget_ ulists_widget_full_data/;
+
+# Do we have "ownership" access to this users' list (i.e. can we edit and see private stuff)?
+sub ulists_own {
+ auth->permUsermod || auth->api2Listread(shift)
+}
+
+
+sub ulist_filtlabels {
+ my($uid, $count) = @_;
+ my $own = ulists_own $uid;
+
+ my $l = tuwf->dbAlli(
+ 'SELECT l.id, l.label, l.private', $count ? ', coalesce(x.count, 0) as count' : (),
+ 'FROM ulist_labels l',
+ $count ? ('LEFT JOIN (
+ SELECT x.id, COUNT(*)
+ FROM ulist_vns uv, unnest(uv.labels) x(id)
+ WHERE uid =', \$uid, $own ? () : 'AND NOT uv.c_private', '
+ GROUP BY x.id
+ ) x(id, count) ON x.id = l.id') : (), '
+ WHERE l.uid =', \$uid, $own ? () : 'AND (NOT l.private OR l.id = 10-1-1-1)', # XXX: 'Voted' (7) is always visibible
+ 'ORDER BY CASE WHEN l.id < 10 THEN l.id ELSE 10 END, l.label'
+ );
+
+ # Virtual 'No label' label, only ever has private VNs.
+ push @$l, {
+ id => 0, label => 'No label', private => 1,
+ $count ? (count => tuwf->dbVali("SELECT count(*) FROM ulist_vns WHERE labels IN('{}','{7}') AND uid =", \$uid)) : (),
+ } if $own;
+
+ $l
+}
+
+
+# Enrich a list of VNs with data necessary for ulist_widget_.
+sub enrich_ulists_widget {
+ enrich_merge id => sql('SELECT vid AS id, true AS on_vnlist FROM ulist_vns WHERE uid =', \auth->uid, 'AND vid IN'), @_ if auth;
+
+ enrich vnlist_labels => id => vid => sub { sql '
+ SELECT uv.vid, ul.id, ul.label
+ FROM ulist_vns uv, unnest(uv.labels) l(id), ulist_labels ul
+ WHERE ul.uid =', \auth->uid, 'AND uv.uid =', \auth->uid, 'AND ul.id = l.id AND uv.vid IN', $_[0], '
+ ORDER BY CASE WHEN ul.id < 10 THEN ul.id ELSE 10 END, ul.label'
+ }, @_ if auth;
+}
+
+sub ulists_widget_ {
+ my($v) = @_;
+ elm_ 'UList.Widget', $VNWeb::ULists::Elm::WIDGET, {
+ uid => auth->uid,
+ vid => $v->{id},
+ labels => $v->{on_vnlist} ? $v->{vnlist_labels} : undef,
+ full => undef,
+ }, sub {
+ my $img = !$v->{on_vnlist} ? 'add' :
+ (reverse sort map "l$_->{id}", grep $_->{id} >= 1 && $_->{id} <= 6, $v->{vnlist_labels}->@*)[0] || 'unknown';
+ abbr_ @_, class => "icon-list-$img ulist-widget-icon", '';
+ } if auth && exists $v->{vnlist_labels};
+}
+
+
+# Returns the data structure for the elm_UListWidget API response for the given VN.
+sub ulists_widget_full_data {
+ my($v, $uid, $vnpage, $canvote) = @_;
+ my $lst = tuwf->dbRowi('SELECT vid, vote, notes, started, finished, labels FROM ulist_vns WHERE uid =', \$uid, 'AND vid =', \$v->{id});
+ my $review = tuwf->dbVali('SELECT id FROM reviews WHERE uid =', \$uid, 'AND vid =', \$v->{id});
+ $canvote //= sprintf('%08d', $v->{c_released}||99999999) <= strftime '%Y%m%d', gmtime;
+ +{
+ uid => $uid,
+ vid => $v->{id},
+ labels => $lst->{vid} ? [ map +{ id => $_, label => '' }, $lst->{labels}->@* ] : undef,
+ full => {
+ title => $vnpage ? '' : $v->{title}[1],
+ labels => tuwf->dbAlli('SELECT id, label, private FROM ulist_labels WHERE uid =', \$uid, 'ORDER BY CASE WHEN id < 10 THEN id ELSE 10 END, label'),
+ canvote => $lst->{vote} || $canvote || 0,
+ canreview => $review || ($canvote && can_edit(w => {})) || 0,
+ vote => fmtvote($lst->{vote}),
+ review => $review,
+ notes => $lst->{notes}||'',
+ started => $lst->{started}||'',
+ finished => $lst->{finished}||'',
+ releases => $vnpage ? [] : releases_by_vn($v->{id}),
+ rlist => $vnpage ? [] : tuwf->dbAlli('SELECT rid AS id, status FROM rlists WHERE uid =', \$uid, 'AND rid IN(SELECT id FROM releases_vn WHERE vid =', \$v->{id}, ')'),
+ },
+ };
+
+}
+
+1;
diff --git a/lib/VNWeb/ULists/List.pm b/lib/VNWeb/ULists/List.pm
new file mode 100644
index 00000000..04ca3e16
--- /dev/null
+++ b/lib/VNWeb/ULists/List.pm
@@ -0,0 +1,348 @@
+package VNWeb::ULists::Main;
+
+use VNWeb::Prelude;
+use VNWeb::ULists::Lib;
+use VNWeb::Releases::Lib;
+
+
+my $TABLEOPTS = VNWeb::VN::List::TABLEOPTS('ulist');
+
+
+sub opt {
+ my($u, $labels) = @_;
+
+ # Note that saved defaults may still use the old query format, which is
+ # { s => $sort_column, o => $order, c => [$visible_columns] }
+ my sub load { my $o = $u->{"ulist_$_[0]"}; ($o && eval { JSON::XS->new->decode($o) } or {})->%* };
+
+ state $s_default = tuwf->compile({ tableopts => $TABLEOPTS })->validate(undef)->data;
+ state $s_vnlist = $s_default->sort_param(title => 'a')->vis_param(qw/label vote added started finished/)->query_encode;
+ state $s_votes = $s_default->sort_param(voted => 'd')->vis_param(qw/vote voted/)->query_encode;
+ state $s_wishlist = $s_default->sort_param(title => 'a')->vis_param(qw/label added/)->query_encode;
+ state @all = (mul => 0, p => 1, f => '', q => tuwf->compile({ searchquery => 1 })->validate(undef)->data);
+
+ my $opt =
+ # Presets
+ tuwf->reqGet('vnlist') ? { @all, l => [1,2,3,4,7,0], s => $s_vnlist, load 'vnlist' } :
+ tuwf->reqGet('votes') ? { @all, l => [7], s => $s_votes, load 'votes' } :
+ tuwf->reqGet('wishlist') ? { @all, l => [5], s => $s_wishlist, load 'wish' } :
+ # Full options
+ tuwf->validate(get =>
+ p => { upage => 1 },
+ ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
+ q => { searchquery => 1 },
+ %VNWeb::ULists::Elm::SAVED_OPTS,
+ # Compat for old URLs
+ o => { onerror => undef, enum => ['a', 'd'] },
+ c => { onerror => undef, type => 'array', scalar => 1, values => { enum => [qw[ label vote voted added modified started finished rel rating ]] } },
+ )->data;
+ $opt->{ch} = $opt->{ch}[0];
+
+ $opt->{s} .= "/$opt->{o}" if $opt->{o};
+ $opt->{s} = tuwf->compile({ tableopts => $TABLEOPTS })->validate($opt->{s})->data;
+ $opt->{s} = $opt->{s}->vis_param($opt->{c}->@*) if $opt->{c};
+ delete $opt->{o};
+ delete $opt->{c};
+
+ $opt->{f} = tuwf->compile({ advsearch_err => 'v' })->validate($opt->{f})->data;
+
+ # $labels only includes labels we are allowed to see, getting rid of any
+ # labels in 'l' that aren't in $labels ensures we only filter on visible
+ # labels.
+ # Also, '-1' used to refer to the virtual "No label" label, now it's '0' instead.
+ my %accessible_labels = map +($_->{id}, 1), @$labels;
+ my %opt_l = map +($_, 1), grep $accessible_labels{$_}, map $_ == -1 ? 0 : $_, $opt->{l}->@*;
+ %opt_l = %accessible_labels if !keys %opt_l;
+ $opt->{l} = keys %opt_l == keys %accessible_labels ? [] : [ sort keys %opt_l ];
+
+ ($opt, \%opt_l)
+}
+
+
+sub filters_ {
+ my($own, $labels, $opt, $opt_labels, $url) = @_;
+
+ my sub lblfilt_ {
+ input_ type => 'checkbox', name => 'l', value => $_->{id}, id => "form_l$_->{id}", tabindex => 10, $opt_labels->{$_->{id}} ? (checked => 'checked') : ();
+ label_ for => "form_l$_->{id}", "$_->{label} ";
+ txt_ " ($_->{count})";
+ }
+
+ div_ class => 'labelfilters', sub {
+ # Implicit behavior alert: pressing enter in this input will activate
+ # the *first* submit button in the form, which happens to be the "ALL"
+ # character selector. Let's just pretend that is intended behavior.
+ input_ type => 'text', class => 'text', name => 'q', value => $opt->{q}||'', style => 'width: 500px', placeholder => 'Search', tabindex => 10;
+ br_;
+ span_ class => 'browseopts', sub {
+ button_ type => 'submit', name => 'ch', value => ($_//''), ($_//'') eq ($opt->{ch}//'') ? (class => 'optselected') : (), !defined $_ ? 'ALL' : $_ ? uc $_ : '#'
+ for (undef, 'a'..'z', 0);
+ };
+ input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
+ $opt->{f}->elm_;
+ p_ class => 'linkradio', sub {
+ join_ sub { em_ ' / ' }, \&lblfilt_, grep $_->{id} < 10, @$labels;
+ span_ class => 'hidden', sub {
+ em_ ' || ';
+ input_ type => 'checkbox', name => 'mul', value => 1, id => 'form_l_multi', tabindex => 10, $opt->{mul} ? (checked => 'checked') : ();
+ label_ for => 'form_l_multi', 'Multi-select';
+ };
+ debug_ $labels;
+ my @cust = grep $_->{id} >= 10, @$labels;
+ if(@cust) {
+ br_;
+ join_ sub { em_ ' / ' }, \&lblfilt_, @cust;
+ }
+ };
+ input_ type => 'submit', class => 'submit', tabindex => 10, value => 'Update filters';
+ input_ type => 'button', class => 'submit', tabindex => 10, id => 'managelabels', value => 'Manage labels' if $own;
+ input_ type => 'button', class => 'submit', tabindex => 10, id => 'savedefault', value => 'Save as default' if $own;
+ input_ type => 'button', class => 'submit', tabindex => 10, id => 'exportlist', value => 'Export' if $own;
+ };
+}
+
+
+sub vn_ {
+ my($uid, $own, $opt, $n, $v, $labels) = @_;
+ tr_ mkclass(odd => $n % 2 == 0), id => "ulist_tr_$v->{id}", sub {
+ my %labels = map +($_,1), $v->{labels}->@*;
+
+ td_ class => 'tc1', sub {
+ input_ type => 'checkbox', class => 'checkhidden', 'x-checkall' => 'collapse_vid', id => 'collapse_vid'.$v->{id}, value => 'collapsed_vid'.$v->{id};
+ label_ for => 'collapse_vid'.$v->{id}, sub {
+ my $obtained = grep $_->{status} == 2, $v->{rels}->@*;
+ my $total = $v->{rels}->@*;
+ span_ id => 'ulist_relsum_'.$v->{id},
+ mkclass(done => $total && $obtained == $total, todo => $obtained < $total),
+ sprintf '%d/%d', $obtained, $total;
+ if($own) {
+ my $public = List::Util::any { $labels{$_->{id}} && !$_->{private} } @$labels;
+ my $publicLabel = List::Util::any { $_->{id} != 7 && $labels{$_->{id}} && !$_->{private} } @$labels;
+ span_ mkclass(invisible => !$public),
+ id => 'ulist_public_'.$v->{id},
+ 'data-publabel' => !!$publicLabel,
+ 'data-voted' => !!$labels{7},
+ title => 'This item is public', ' 👁';
+ }
+ };
+ };
+
+ td_ class => 'tc_voted', $v->{vote_date} ? fmtdate $v->{vote_date}, 'compact' : '-' if $opt->{s}->vis('voted');
+
+ td_ mkclass(tc_vote => 1, compact => $own, stealth => $own), sub {
+ txt_ fmtvote $v->{vote} if !$own;
+ elm_ 'UList.VoteEdit' => $VNWeb::ULists::Elm::VNVOTE, { uid => $uid, vid => $v->{id}, vote => fmtvote($v->{vote}) }, sub {
+ div_ @_, fmtvote $v->{vote}
+ } if $own && ($v->{vote} || sprintf('%08d', $v->{c_released}||0) < strftime '%Y%m%d', gmtime);
+ } if $opt->{s}->vis('vote');
+
+ td_ class => 'tc_rating', sub {
+ txt_ sprintf '%.2f', ($v->{c_rating}||0)/100;
+ small_ sprintf ' (%d)', $v->{c_votecount};
+ } if $opt->{s}->vis('rating');
+ td_ class => 'tc_average',sub {
+ txt_ sprintf '%.2f', ($v->{c_average}||0)/100;
+ small_ sprintf ' (%d)', $v->{c_votecount} if !$opt->{s}->vis('rating');
+ } if $opt->{s}->vis('average');
+
+ td_ class => 'tc_labels', sub {
+ my @l = grep $labels{$_->{id}} && $_->{id} != 7, @$labels;
+ my $txt = @l ? join ', ', map $_->{label}, @l : '-';
+ if($own) {
+ elm_ 'UList.LabelEdit' => $VNWeb::ULists::Elm::VNLABELS_OUT, { vid => $v->{id}, selected => [ grep $_ != 7, $v->{labels}->@* ] }, sub {
+ div_ @_, $txt;
+ };
+ } else {
+ txt_ $txt;
+ }
+ } if $opt->{s}->vis('label');
+
+ td_ class => 'tc_title', sub {
+ a_ href => "/$v->{id}", tattr $v;
+ small_ id => 'ulist_notes_'.$v->{id}, $v->{notes} if $v->{notes} || $own;
+ };
+ td_ class => 'tc_dev', sub {
+ join_ ' & ', sub {
+ a_ href => "/$_->{id}", tattr $_;
+ }, $v->{developers}->@*;
+ } if $opt->{s}->vis('developer');
+
+ td_ class => 'tc_added', fmtdate $v->{added}, 'compact' if $opt->{s}->vis('added');
+ td_ class => 'tc_modified', fmtdate $v->{lastmod}, 'compact' if $opt->{s}->vis('modified');
+
+ td_ class => 'tc_started', sub {
+ txt_ $v->{started}||'' if !$own;
+ elm_ 'UList.DateEdit' => $VNWeb::ULists::Elm::VNDATE, { uid => $uid, vid => $v->{id}, date => $v->{started}||'', start => 1 }, sub {
+ div_ @_, $v->{started}||''
+ } if $own;
+ } if $opt->{s}->vis('started');
+
+ td_ class => 'tc_finished', sub {
+ txt_ $v->{finished}||'' if !$own;
+ elm_ 'UList.DateEdit' => $VNWeb::ULists::Elm::VNDATE, { uid => $uid, vid => $v->{id}, date => $v->{finished}||'', start => 0 }, sub {
+ div_ @_, $v->{finished}||''
+ } if $own;
+ } if $opt->{s}->vis('finished');
+
+ td_ class => 'tc_rel', sub { rdate_ $v->{c_released} } if $opt->{s}->vis('released');
+ td_ class => 'tc_length',sub { VNWeb::VN::List::len_($v) } if $opt->{s}->vis('length');
+ };
+
+ tr_ mkclass(hidden => 1, 'collapsed_vid'.$v->{id} => 1, odd => $n % 2 == 0), sub {
+ td_ colspan => 7, class => 'tc_opt', sub {
+ my $relstatus = [ map $_->{status}, $v->{rels}->@* ];
+ elm_ 'UList.Opt' => $VNWeb::ULists::Elm::VNOPT, { own => $own?1:0, uid => $uid, vid => $v->{id}, notes => $v->{notes}, rels => $v->{rels}, relstatus => $relstatus };
+ };
+ };
+}
+
+
+sub listing_ {
+ my($uid, $own, $opt, $labels, $url) = @_;
+
+ my @l = grep $_ > 0 && $_ != 7, $opt->{l}->@*;
+ my $unlabeled = grep $_ == 0, $opt->{l}->@*;
+ my $voted = grep $_ == 7, $opt->{l}->@*;
+
+ my @where_vns = (
+ @l ? sql('uv.labels &&', sql_array(@l), '::smallint[]') : (),
+ $unlabeled ? sql("uv.labels IN('{}','{7}')") : (),
+ $voted ? sql('uv.vote IS NOT NULL') : ()
+ );
+
+ my $where = sql_and
+ sql('uv.uid =', \$uid),
+ $opt->{f}->sql_where(),
+ $opt->{q}->sql_where('v', 'v.id'),
+ $own ? () : 'NOT uv.c_private AND NOT v.hidden',
+ @where_vns ? sql_or(@where_vns) : (),
+ defined($opt->{ch}) ? sql 'match_firstchar(v.sorttitle, ', \$opt->{ch}, ')' : ();
+
+ my $count = tuwf->dbVali('SELECT count(*) FROM ulist_vns uv JOIN', vnt, 'v ON v.id = uv.vid WHERE', $where);
+
+ my $lst = tuwf->dbPagei({ page => $opt->{p}, results => $opt->{s}->results },
+ 'SELECT v.id, v.title, uv.vote, uv.notes, uv.labels, uv.started, uv.finished
+ , v.c_released, v.c_average, v.c_rating, v.c_votecount, v.c_released
+ , v.image, v.c_platforms::text[] AS platforms, v.c_languages::text[] AS lang
+ ,', sql_totime('uv.added'), ' as added
+ ,', sql_totime('uv.lastmod'), ' as lastmod
+ ,', sql_totime('uv.vote_date'), ' as vote_date',
+ $opt->{s}->vis('length') ? ', v.length, v.c_length, v.c_lengthnum' : (), '
+ FROM ulist_vns uv
+ JOIN', vnt, 'v ON v.id = uv.vid
+ WHERE', $where, '
+ ORDER BY', $opt->{s}->sql_order(), 'NULLS LAST, v.sorttitle'
+ );
+
+ enrich rels => id => vid => sub { sql '
+ SELECT rv.vid, r.id, rl.status, rv.rtype
+ FROM rlists rl
+ JOIN', releasest, 'r ON rl.rid = r.id
+ JOIN releases_vn rv ON rv.id = r.id
+ WHERE rl.uid =', \$uid, '
+ AND rv.vid IN', $_, '
+ ORDER BY r.released, r.sorttitle, r.id'
+ }, $lst;
+ enrich_release_elm map $_->{rels}, @$lst;
+ VNWeb::VN::List::enrich_listing(auth && auth->uid eq $uid && !$opt->{s}->rows(), $opt, $lst);
+
+ return VNWeb::VN::List::listing_($opt, $lst, $count, 0, $labels) if !$opt->{s}->rows;
+
+ # TODO: Consolidate the 'rows' listing with VN::List as well
+ paginate_ $url, $opt->{p}, [$count, $opt->{s}->results], 't', $opt->{s};
+ article_ class => 'browse ulist', sub {
+ table_ sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', sub {
+ input_ type => 'checkbox', class => 'checkall', 'x-checkall' => 'collapse_vid', id => 'collapse_vid';
+ label_ for => 'collapse_vid', sub { txt_ 'Opt' };
+ };
+ td_ class => 'tc_voted', sub { txt_ 'Vote date'; sortable_ 'voted', $opt, $url } if $opt->{s}->vis('voted');
+ td_ class => 'tc_vote', sub { txt_ 'Vote'; sortable_ 'vote', $opt, $url } if $opt->{s}->vis('vote');
+ td_ class => 'tc_pop', sub { txt_ 'Popularity'; sortable_ 'popularity', $opt, $url } if $opt->{s}->vis('popularity');
+ td_ class => 'tc_rating', sub { txt_ 'Rating'; sortable_ 'rating', $opt, $url } if $opt->{s}->vis('rating');
+ td_ class => 'tc_average', sub { txt_ 'Average'; sortable_ 'average', $opt, $url } if $opt->{s}->vis('average');
+ td_ class => 'tc_labels', sub { txt_ 'Labels'; sortable_ 'label', $opt, $url } if $opt->{s}->vis('label');
+ td_ class => 'tc_title', sub { txt_ 'Title'; sortable_ 'title', $opt, $url; debug_ $lst };
+ td_ class => 'tc_dev', 'Developer' if $opt->{s}->vis('developer');
+ td_ class => 'tc_added', sub { txt_ 'Added'; sortable_ 'added', $opt, $url } if $opt->{s}->vis('added');
+ td_ class => 'tc_modified', sub { txt_ 'Modified'; sortable_ 'modified', $opt, $url } if $opt->{s}->vis('modified');
+ td_ class => 'tc_started', sub { txt_ 'Start date'; sortable_ 'started', $opt, $url } if $opt->{s}->vis('started');
+ td_ class => 'tc_finished', sub { txt_ 'Finish date'; sortable_ 'finished', $opt, $url } if $opt->{s}->vis('finished');
+ td_ class => 'tc_rel', sub { txt_ 'Release date';sortable_ 'released', $opt, $url } if $opt->{s}->vis('released');
+ td_ class => 'tc_length', 'Length' if $opt->{s}->vis('length');
+ }};
+ vn_ $uid, $own, $opt, $_, $lst->[$_], $labels for (0..$#$lst);
+ };
+ };
+ paginate_ $url, $opt->{p}, [$count, $opt->{s}->results], 'b';
+}
+
+
+TUWF::get qr{/$RE{uid}/ulist}, sub {
+ my $u = tuwf->dbRowi('
+ SELECT u.id,', sql_user(), ', ulist_votes, ulist_vnlist, ulist_wish
+ FROM users u JOIN users_prefs up ON up.id = u.id
+ WHERE u.id =', \tuwf->capture('id'));
+ return tuwf->resNotFound if !$u->{id};
+
+ my $own = ulists_own $u->{id};
+ my $labels = ulist_filtlabels $u->{id}, 1;
+ $_->{delete} = undef for @$labels;
+
+ my($opt, $opt_labels) = opt $u, $labels;
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ # This page has 3 user tabs: list, wish and votes; Select the appropriate active tab based on label filters.
+ my $num_core_labels = grep $_ < 10, keys %$opt_labels;
+ my $tab = $num_core_labels == 1 && $opt_labels->{7} ? 'votes'
+ : $num_core_labels == 1 && $opt_labels->{5} ? 'wish' : 'list';
+
+ my $title = $own ? 'My list' : user_displayname($u)."'s list";
+ framework_ title => $title, dbobj => $u, tab => $tab, js => 1,
+ $own ? ( pagevars => {
+ uid => $u->{id},
+ labels => $VNWeb::ULists::Elm::LABELS->analyze->{keys}{labels}->coerce_for_json($labels),
+ voteprivate => (map \($_->{private}?1:0), grep $_->{id} == 7, @$labels),
+ } ) : (),
+ sub {
+ my $empty = !grep $_->{count}, @$labels;
+ form_ method => 'get', sub {
+ article_ sub {
+ h1_ $title;
+ if($empty) {
+ p_ $own
+ ? 'Your list is empty! You can add visual novels to your list from the visual novel pages.'
+ : user_displayname($u).' does not have any visible visual novels in their list.';
+ } else {
+ filters_ $own, $labels, $opt, $opt_labels, \&url;
+ elm_ 'UList.ManageLabels' if $own;
+ elm_ 'UList.SaveDefault', $VNWeb::ULists::Elm::SAVED_OPTS_OUT, {
+ uid => $u->{id},
+ opts => { l => $opt->{l}, mul => $opt->{mul}, s => $opt->{s}->query_encode(), f => $opt->{f}->query_encode() },
+ } if $own;
+ div_ class => 'hidden exportlist', sub {
+ strong_ 'Export your list';
+ br_;
+ txt_ 'This function will export all visual novels and releases in your list, even those marked as private ';
+ txt_ '(there is currently no import function, more export options may be added later).';
+ br_;
+ br_;
+ a_ href => "/$u->{id}/list-export/xml", "Download XML export.";
+ } if $own;
+ }
+ };
+ listing_ $u->{id}, $own, $opt, $labels, \&url if !$empty;
+ }
+ };
+};
+
+
+
+# Redirects for old URLs
+TUWF::get qr{/$RE{uid}/votes}, sub { tuwf->resRedirect("/".tuwf->capture('id').'/ulist?votes=1', 'perm') };
+TUWF::get qr{/$RE{uid}/list}, sub { tuwf->resRedirect("/".tuwf->capture('id').'/ulist?vnlist=1', 'perm') };
+TUWF::get qr{/$RE{uid}/wish}, sub { tuwf->resRedirect("/".tuwf->capture('id').'/ulist?wishlist=1', 'perm') };
+
+
+1;
diff --git a/lib/VNWeb/User/Admin.pm b/lib/VNWeb/User/Admin.pm
new file mode 100644
index 00000000..36dd4da2
--- /dev/null
+++ b/lib/VNWeb/User/Admin.pm
@@ -0,0 +1,74 @@
+package VNWeb::User::Admin;
+
+use VNWeb::Prelude;
+
+my $FORM = {
+ id => { vndbid => 'u' },
+ username => { default => '' },
+
+ # Permissions of the user editing this account
+ editor_dbmod => { _when => 'out', anybool => 1 },
+ editor_usermod => { _when => 'out', anybool => 1 },
+ editor_tagmod => { _when => 'out', anybool => 1 },
+ editor_boardmod => { _when => 'out', anybool => 1 },
+
+ ign_votes => { anybool => 1 },
+ map +("perm_$_" => { anybool => 1 }), VNWeb::Auth::listPerms
+};
+
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_OUT = form_compile out => $FORM;
+
+sub _userinfo {
+ if(!auth->isMod) { tuwf->resDenied; tuwf->done; }
+ my $u = tuwf->dbRowi('
+ SELECT u.id, username, ign_votes, ', sql_comma(map "perm_$_", auth->listPerms), '
+ FROM users u
+ LEFT JOIN users_shadow us ON us.id = u.id
+ WHERE u.id =', \$_[0]
+ );
+ if(!$u->{id}) { tuwf->resNotFound; tuwf->done; }
+ $u
+}
+
+
+TUWF::get qr{/$RE{uid}/admin}, sub {
+ my $u = _userinfo tuwf->capture('id');
+
+ $u->{editor_dbmod} = auth->permDbmod;
+ $u->{editor_usermod} = auth->permUsermod;
+ $u->{editor_tagmod} = auth->permTagmod;
+ $u->{editor_boardmod} = auth->permBoardmod;
+
+ framework_ title => "Admin settings for ".($u->{username}//$u->{id}), dbobj => $u, tab => 'admin',
+ sub {
+ div_ widget(UserAdmin => $FORM_OUT, $u), '';
+ };
+};
+
+
+js_api UserAdmin => $FORM_IN, sub {
+ my($data) = @_;
+ my $u = _userinfo $data->{id};
+
+ tuwf->dbExeci(select => sql_func user_setperm_usermod => \$u->{id}, \auth->uid, sql_fromhex(auth->token), \$data->{perm_usermod})
+ if auth->permUsermod;
+
+ my @set = (
+ auth->permUsermod
+ ? ('ign_votes', map "perm_$_", grep $_ ne 'usermod', auth->listPerms)
+ : (
+ auth->permBoardmod ? qw/perm_board perm_review/ : (),
+ auth->permDbmod ? qw/perm_edit perm_imgvote perm_lengthvote/ : (),
+ auth->permTagmod ? qw/perm_tag/ : (),
+ ),
+ );
+ tuwf->dbExeci('UPDATE users SET', { map +($_, $data->{$_}), @set }, 'WHERE id =', \$u->{id});
+
+ my $new = _userinfo $u->{id};
+ my @diff = grep $u->{$_} ne $new->{$_}, @set;
+ auth->audit($data->{id}, 'user admin', join '; ', map "$_: $u->{$_} -> $new->{$_}", @diff) if @diff;
+ +{ ok => 1 }
+};
+
+1;
diff --git a/lib/VNWeb/User/Css.pm b/lib/VNWeb/User/Css.pm
new file mode 100644
index 00000000..10d21097
--- /dev/null
+++ b/lib/VNWeb/User/Css.pm
@@ -0,0 +1,37 @@
+package VNWeb::User::Css;
+
+use VNWeb::Prelude;
+
+
+sub _sanitize_css {
+ # This function is attempting to do the impossible: Sanitize user provided
+ # CSS against various attacks. I'm not expecting this to be bullet-proof.
+ # Fortunately, we also have CSP in place to mitigate some problems if they
+ # arise, but I'd rather not rely on it. I'd *love* to disable support for
+ # external url()'s, but unfortunately many people use that to load images.
+ # I'm afraid the only way to work around that is to fetch and cache those
+ # URLs on the server.
+ local $_ = $_[0];
+ s/\\//g; # Get rid of backslashes, could be used to bypass the other regexes.
+ s/@(import|charset|font-face)[^\n\;]*.//ig;
+ s/javascript\s*://ig; # Not sure 'javascript:' URLs do anything, but just in case.
+ s/expression\s*\(//ig; # An old IE thing I guess.
+ s/binding\s*://ig; # Definitely don't want bindings.
+ $_;
+}
+
+
+TUWF::get qr{/$RE{uid}\.css}, sub {
+ my $u = tuwf->dbRowi('
+ SELECT u.id, pubskin_can, pubskin_enabled, customcss
+ FROM users u
+ JOIN users_prefs up ON up.id = u.id
+ WHERE u.id =', \tuwf->capture('id'));
+ return tuwf->resNotFound if !$u->{id};
+ return tuwf->resDenied if !($u->{pubskin_can} && $u->{pubskin_enabled}) && !(auth && auth->uid eq $u->{id});
+ tuwf->resHeader('Content-type', 'text/css; charset=UTF8');
+ tuwf->resHeader('Cache-Control', 'max-age=31536000'); # invalidation is done by adding a checksum to the URL.
+ lit_ _sanitize_css $u->{customcss};
+};
+
+1;
diff --git a/lib/VNWeb/User/Delete.pm b/lib/VNWeb/User/Delete.pm
new file mode 100644
index 00000000..6e7827d4
--- /dev/null
+++ b/lib/VNWeb/User/Delete.pm
@@ -0,0 +1,214 @@
+package VNWeb::User::Delete;
+
+use VNWeb::Prelude;
+
+
+sub _getmail {
+ tuwf->dbVali(select => sql_func user_getmail => \auth->uid, \auth->uid, sql_fromhex auth->token);
+}
+
+sub set_delete {
+ return 0 if tuwf->reqMethod ne 'POST';
+ my $pwd = tuwf->validate(post => password => { password => 1, onerror => undef })->data // return 1;
+ return 1 if !VNWeb::Auth->new->login(auth->uid, $pwd, 1);
+
+ tuwf->dbExeci(select => sql_func user_setdelete => \auth->uid, sql_fromhex(auth->token), \1);
+ auth->audit(auth->uid, 'mark for deletion');
+
+ my $path = '/'.auth->uid.'/del/'.auth->token;
+ my $body = sprintf
+ "Hello %s,"
+ ."\n"
+ ."\nAs per your request, your account is scheduled for deletion in approximately 7 days."
+ ."\nTo view the status of your request or to cancel the deletion, visit the link below before the timer expires:"
+ ."\n"
+ ."\n%s"
+ ."\n"
+ ."\nvndb.org",
+ auth->user->{user_name}, tuwf->reqBaseURI().$path;
+
+ tuwf->mail($body,
+ To => _getmail(),
+ From => 'VNDB <noreply@vndb.org>',
+ Subject => 'Account deletion for '.auth->user->{user_name},
+ );
+ tuwf->resRedirect($path, 'post');
+ tuwf->done;
+}
+
+
+TUWF::any ['get','post'], qr{/$RE{uid}/del}, sub {
+ my $uid = auth->uid;
+ return tuwf->resNotFound if !auth || tuwf->capture('id') ne auth->uid;
+
+ my $invalid = set_delete;
+
+ framework_ title => 'Account deletion', sub {
+ article_ sub {
+ h1_ 'Account deletion';
+ div_ class => 'warning', 'Account deletion is permanent and your data cannot be restored. Proceed with care!';
+
+ h2_ 'E-mail opt-out';
+ p_ sub {
+ txt_ 'You can NOT register a new account in the future with the email address associated with this account: ';
+ strong_ _getmail;
+ txt_ '.';
+ };
+
+ my $vns = tuwf->dbVali('SELECT COUNT(*) FROM ulist_vns WHERE uid =', \$uid);
+ if ($vns) {
+ h2_ 'Visual novel list';
+ p_ sub {
+ a_ href => "/$uid/ulist", 'Your visual novel list';
+ txt_ ' will be deleted with your account.';
+ };
+ p_ sub {
+ txt_ 'Your list currently holds ';
+ strong_ $vns;
+ txt_ ' visual novels, consider making a local backup through the "Export" button before proceeding with the deletion.';
+ };
+ }
+
+ my $posts = tuwf->dbVali('SELECT
+ (SELECT COUNT(*)
+ FROM threads_posts tp
+ WHERE hidden IS NULL AND uid =', \$uid, '
+ AND EXISTS(SELECT 1 FROM threads t WHERE t.id = tp.tid AND NOT t.hidden)
+ ) +
+ (SELECT COUNT(*) FROM reviews_posts WHERE hidden IS NULL AND uid =', \$uid, ')');
+ if ($posts) {
+ h2_ 'Forum posts';
+ p_ sub {
+ a_ href => "/$uid/posts", sub {
+ txt_ 'Your ';
+ strong_ $posts;
+ txt_ ' forum posts';
+ };
+ txt_ ' will remain after your account has been deleted.';
+ };
+ p_ 'Please send an email to '.config->{admin_email}.' if these contain sensitive information that you wish to have deleted.';
+ }
+
+ my $edits = tuwf->dbVali('SELECT COUNT(*) FROM changes WHERE requester =', \$uid);
+ if ($edits) {
+ h2_ 'Database edits';
+ p_ sub {
+ a_ href => "/$uid/hist", sub {
+ txt_ 'Your ';
+ strong_ $edits;
+ txt_ ' database edits';
+ };
+ txt_ ' will remain after your account has been deleted.';
+ };
+ p_ 'Please send an email to '.config->{admin_email}.' if these contain sensitive information that you wish to have deleted.';
+ }
+
+ my $reviews = tuwf->dbVali('SELECT COUNT(*) FROM reviews WHERE uid =', \$uid);
+ if ($reviews) {
+ h2_ 'Reviews';
+ p_ sub {
+ a_ href => "/w?u=$uid", sub {
+ txt_ 'Your ';
+ strong_ $reviews;
+ txt_ ' reviews';
+ };
+ txt_ ' will remain after your account has been deleted.';
+ };
+ p_ "If you don't want this, make sure to delete the reviews by going through the edit form.";
+ }
+
+ my $lengthvotes = tuwf->dbVali('SELECT COUNT(*) FROM vn_length_votes WHERE NOT private AND uid =', \$uid);
+ my $imgvotes = tuwf->dbVali('SELECT COUNT(*) FROM image_votes WHERE uid =', \$uid);
+ my $tags = tuwf->dbVali('SELECT COUNT(*) FROM tags_vn WHERE uid =', \$uid);
+ my $quotes => tuwf->dbVali('SELECT COUNT(*) FROM quotes WHERE addedby =', \$uid);
+ if ($lengthvotes || $imgvotes || $tags || $quotes) {
+ h2_ 'Misc. database contributions';
+ p_ 'Your database contributions will remain after your account has been deleted, these include:';
+ ul_ sub {
+ li_ sub { strong_ $lengthvotes; txt_ ' visual novel play times.'; } if $lengthvotes;
+ li_ sub { strong_ $imgvotes; txt_ ' image flagging votes.'; } if $imgvotes;
+ li_ sub { strong_ $tags; txt_ ' visual novel tags.'; } if $tags;
+ li_ sub { strong_ $quotes; txt_ ' visual novel quotes.'; } if $quotes;
+ };
+ }
+
+ br_;
+ h2_ 'Confirm account deletion';
+ form_ method => 'POST', class => 'invalid-form', sub {
+ fieldset_ class => 'form', sub {
+ fieldset_ sub {
+ label_ for => 'password', 'Password';
+ input_ type => 'password', id => 'password', name => 'password', required => 1, class => 'mw';
+ p_ class => 'invalid', 'Invalid password.' if $invalid;
+ };
+ fieldset_ sub {
+ input_ type => 'submit', value => 'Delete my account';
+ p_ 'Your account will be deleted approximately 7 days after confirmation. You can cancel the deletion before that time.';
+ };
+ };
+ };
+ };
+ };
+};
+
+
+TUWF::any ['post','get'], qr{/$RE{uid}/del/([a-fA-F0-9]{40})}, sub {
+ my($uid, $token) = tuwf->captures(1,2);
+ return tuwf->resRedirect('/', 'temp') if auth && auth->uid ne $uid;
+
+ my $u = tuwf->dbRowi('
+ SELECT ', sql_totime('us.delete_at'), 'delete_at, ', sql_user(), '
+ , ', sql_func(user_validate_session => 'u.id', sql_fromhex($token), \'web'), 'IS DISTINCT FROM NULL AS valid
+ FROM users u
+ JOIN users_shadow us ON us.id = u.id
+ WHERE u.id =', \$uid
+ );
+
+ my $cancelled;
+ if (tuwf->reqMethod eq 'POST' && $u->{valid} && $u->{delete_at}) {
+ # TODO: Ideally this should just auto-login and redirect, but doing so
+ # with the current session token is a bad idea and I'm too lazy to code
+ # a session token renewal thing.
+ # TODO: This should really invalidate all existing session tokens,
+ # given that we could also have reached this page with a fresh token on
+ # login.
+ tuwf->dbExeci(select => sql_func user_setdelete => \$uid, sql_fromhex($token), \0);
+ tuwf->dbExeci(select => sql_func user_logout => \$uid, sql_fromhex $token);
+ auth->audit($uid, 'cancel deletion');
+ $cancelled = 1;
+ }
+
+ framework_ title => 'Account deletion', sub {
+ article_ $cancelled ? sub {
+ h1_ 'Account deletion cancelled';
+ p_ sub {
+ txt_ 'Your account is no longer scheduled for deletion. You can now ';
+ a_ href => '/u/login', 'login to your account again';
+ txt_ '.';
+ };
+ } : !defined $u->{user_name} ? sub {
+ h1_ 'No such user';
+ p_ 'No user found with that ID, perhaps the account has been deleted already.';
+ } : !$u->{valid} ? sub {
+ h1_ 'Invalid token';
+ } : !$u->{delete_at} ? sub {
+ h1_ 'No account deletion pending';
+ p_ 'Your account is not scheduled to be deleted.';
+ } : sub {
+ h1_ 'Account deletion pending';
+ p_ sub {
+ my $days = sprintf '%.0f', ($u->{delete_at}-time())/(24*3600);
+ txt_ 'Your account is scheduled to be deleted ';
+ txt_ $days < 1 ? 'in less than 24 hours.' :
+ $days < 2 ? 'tomorrow.' : "in approximately $days days.";
+ };
+ form_ method => 'POST', sub {
+ p_ sub {
+ input_ type => 'submit', value => 'Cancel account deletion';
+ };
+ };
+ };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/User/Edit.pm b/lib/VNWeb/User/Edit.pm
index bfd2e5f8..a4e42ad8 100644
--- a/lib/VNWeb/User/Edit.pm
+++ b/lib/VNWeb/User/Edit.pm
@@ -1,44 +1,94 @@
package VNWeb::User::Edit;
use VNWeb::Prelude;
+use VNDB::Skins;
+use VNWeb::TitlePrefs '/./';
+use VNWeb::TimeZone;
+use Digest::SHA 'sha1';
-my $FORM = form_compile in => {
- username => { username => 1 },
- email => { email => 1 },
- perm => { uint => 1, func => sub { ($_[0] & ~auth->allPerms) == 0 } },
- ign_votes => { anybool => 1 },
- show_nsfw => { anybool => 1 },
- traits_sexual => { anybool => 1 },
- tags_all => { anybool => 1 },
- tags_cont => { anybool => 1 },
- tags_ero => { anybool => 1 },
- tags_tech => { anybool => 1 },
- spoilers => { uint => 1, range => [ 0, 2 ] },
- skin => { enum => tuwf->{skins} },
- customcss => { required => 0, default => '', maxlength => 2000 },
-
- nodistract_can => { anybool => 1 },
+
+my $FORM = {
+ id => { vndbid => 'u' },
+ username => { username => 1 },
+ username_throttled => { _when => 'out', anybool => 1 },
+ email => { email => 1 },
+ password => { default => undef, type => 'hash', keys => {
+ old => { password => 1 },
+ new => { password => 1 }
+ } },
+
+ # Supporter options available to this user
+ editor_usermod => { anybool => 1 },
+ nodistract_can => { _when => 'out', anybool => 1 },
+ support_can => { _when => 'out', anybool => 1 },
+ uniname_can => { _when => 'out', anybool => 1 },
+ pubskin_can => { _when => 'out', anybool => 1 },
+ # Supporter options
nodistract_noads => { anybool => 1 },
nodistract_nofancy => { anybool => 1 },
- support_can => { anybool => 1 },
support_enabled => { anybool => 1 },
- uniname_can => { anybool => 1 },
- uniname => { required => 0, default => '', regex => qr/^.{2,15}$/ }, # Use regex to check length, HTML5 `maxlength` attribute counts UTF-16 code units...
- pubskin_can => { anybool => 1 },
+ uniname => { default => '', sl => 1, length => [2,15] },
pubskin_enabled => { anybool => 1 },
- password => { _when => 'in', required => 0, type => 'hash', keys => {
- old => { password => 1 },
- new => { password => 1 }
+ traits => { sort_keys => 'tid', maxlength => 100, aoh => {
+ tid => { vndbid => 'i' },
+ name => { _when => 'out' },
+ group => { _when => 'out', default => undef },
+ } },
+
+ timezone => { default => '', enum => \%ZONES },
+ max_sexual => { int => 1, range => [-1, 2 ] },
+ max_violence => { uint => 1, range => [ 0, 2 ] },
+ spoilers => { uint => 1, range => [ 0, 2 ] },
+ titles => { titleprefs => 1 },
+ alttitles => { titleprefs => 1 },
+ tags_all => { anybool => 1 },
+ tags_cont => { anybool => 1 },
+ tags_ero => { anybool => 1 },
+ tags_tech => { anybool => 1 },
+ vnrel_langs => { default => undef, type => 'array', values => { enum => \%LANGUAGE }, sort => 'str', unique => 1 },
+ vnrel_olang => { anybool => 1 },
+ vnrel_mtl => { anybool => 1 },
+ staffed_langs => { default => undef, type => 'array', values => { enum => \%LANGUAGE }, sort => 'str', unique => 1 },
+ staffed_olang => { anybool => 1 },
+ staffed_unoff => { anybool => 1 },
+ traits_sexual => { anybool => 1 },
+ prodrelexpand => { anybool => 1 },
+ skin => { enum => skins },
+ customcss => { default => '', maxlength => 256*1024 },
+ customcss_csum => { anybool => 1 },
+
+ tagprefs => { sort_keys => 'tid', maxlength => 500, aoh => {
+ tid => { vndbid => 'g' },
+ spoil => { default => undef, int => 1, range => [ 0, 3 ] },
+ color => { default => undef, regex => qr/^(standout|grayedout|#[a-fA-F0-9]{6})$/ },
+ childs => { anybool => 1 },
+ name => {},
+ } },
+
+ traitprefs => { sort_keys => 'tid', maxlength => 500, aoh => {
+ tid => { vndbid => 'i' },
+ spoil => { default => undef, int => 1, range => [ 0, 3 ] },
+ color => { default => undef, regex => qr/^(standout|grayedout|#[a-fA-F0-9]{6})$/ },
+ childs => { anybool => 1 },
+ name => {},
+ group => { default => undef },
} },
- id => { uint => 1 },
- # This is technically only used for Perl->Elm data, but also received from
- # Elm in order to make the Send and Recv types equivalent.
- authmod => { anybool => 1 },
+ api2 => { maxlength => 64, aoh => {
+ token => {},
+ added => {},
+ lastused => { default => '' },
+ notes => { default => '', sl => 1, maxlength => 200 },
+ listread => { anybool => 1 },
+ listwrite => { anybool => 1 },
+ delete => { anybool => 1 },
+ } },
};
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_OUT = form_compile out => $FORM;
sub _getmail {
@@ -46,74 +96,105 @@ sub _getmail {
tuwf->dbVali(select => sql_func user_getmail => \$uid, \auth->uid, sql_fromhex auth->token);
}
+sub _namethrottled {
+ my($uid) = @_;
+ !auth->permUsermod && tuwf->dbVali('SELECT 1 FROM users_username_hist WHERE id =', \$uid, 'AND date > NOW()-\'1 day\'::interval')
+}
+
TUWF::get qr{/$RE{uid}/edit}, sub {
- my $u = tuwf->dbRowi(q{
- SELECT id, username, perm, ign_votes, show_nsfw, traits_sexual
- , tags_all, tags_cont, tags_ero, tags_tech, spoilers, skin, customcss
- , nodistract_can, nodistract_noads, nodistract_nofancy, support_can, support_enabled, uniname_can, uniname, pubskin_can, pubskin_enabled
- FROM users WHERE id =}, \tuwf->capture('id')
+ my $u = tuwf->dbRowi(
+ 'SELECT u.id, username, max_sexual, max_violence, traits_sexual, tags_all, tags_cont, tags_ero, tags_tech, prodrelexpand
+ , vnrel_langs::text[], vnrel_olang, vnrel_mtl, staffed_langs::text[], staffed_olang, staffed_unoff
+ , spoilers, skin, customcss, customcss_csum, timezone, titles
+ , nodistract_can, support_can, uniname_can, pubskin_can
+ , nodistract_noads, nodistract_nofancy, support_enabled, uniname, pubskin_enabled
+ FROM users u JOIN users_prefs up ON up.id = u.id WHERE u.id =', \tuwf->capture('id')
);
-
return tuwf->resNotFound if !$u->{id} || !can_edit u => $u;
- $u->{email} = _getmail $u->{id};
- $u->{authmod} = auth->permUsermod;
- $u->{password} = undef;
+ $u->{editor_usermod} = auth->permUsermod;
+ $u->{username_throttled} = _namethrottled $u->{id};
+ $u->{email} = _getmail $u->{id};
+ $u->{password} = undef;
+
+ $u->{traits} = tuwf->dbAlli('SELECT u.tid, t.name, g.name AS "group" FROM users_traits u JOIN traits t ON t.id = u.tid LEFT JOIN traits g ON g.id = t.gid WHERE u.id =', \$u->{id}, 'ORDER BY g.gorder, t.name');
+ $u->{timezone} ||= 'UTC';
+ @{$u}{'titles','alttitles'} = @{ titleprefs_parse($u->{titles}) // $DEFAULT_TITLE_PREFS };
$u->{skin} ||= config->{skin_default};
- # Let's not disclose this (though it's not hard to find out through other means)
- if(!auth->permUsermod) {
- $u->{ign_votes} = 0;
- $u->{perm} = auth->defaultPerms;
- }
+ $u->{tagprefs} = tuwf->dbAlli('SELECT u.tid, u.spoil, u.color, u.childs, t.name FROM users_prefs_tags u JOIN tags t ON t.id = u.tid WHERE u.id =', \$u->{id}, 'ORDER BY t.name');
+ $u->{traitprefs} = tuwf->dbAlli('SELECT u.tid, u.spoil, u.color, u.childs, t.name, g.name as "group" FROM users_prefs_traits u JOIN traits t ON t.id = u.tid LEFT JOIN traits g ON g.id = t.gid WHERE u.id =', \$u->{id}, 'ORDER BY g.gorder, t.name');
+
+ $u->{api2} = auth->api2_tokens($u->{id});
- my $title = $u->{id} == auth->uid ? 'My Account' : "Edit $u->{username}";
- framework_ title => $title, type => 'u', dbobj => $u, tab => 'edit',
+ my $title = $u->{id} eq auth->uid ? 'My Account' : "Edit $u->{username}";
+ framework_ title => $title, dbobj => $u, tab => 'edit',
sub {
- elm_ 'User.Edit', $FORM, $u;
+ article_ sub {
+ h1_ $title;
+ };
+ div_ widget(UserEdit => $FORM_OUT, $u), '';
};
};
-elm_api UserEdit => undef, $FORM, sub {
+js_api UserEdit => $FORM_IN, sub {
my $data = shift;
- my $username = tuwf->dbVali('SELECT username FROM users WHERE id =', \$data->{id});
- return tuwf->resNotFound if !$username;
- return elm_Unauth if !can_edit u => $data;
+ my $u = tuwf->dbRowi('SELECT id, username FROM users WHERE id =', \$data->{id});
+ return tuwf->resNotFound if !$u->{id};
+ return tuwf->resDenied if !can_edit u => $u;
+
+ my(%set, %setp);
+
+ $data->{uniname} = '' if $data->{uniname} eq $u->{username};
+ return +{ code => 'uniname', _err => 'Display name already taken.' }
+ if $data->{uniname} && tuwf->dbVali('SELECT 1 FROM users WHERE id <>', \$data->{id}, 'AND lower(username) =', \lc($data->{uniname}));
+
+ $data->{skin} = '' if $data->{skin} eq config->{skin_default};
+ $data->{timezone} = '' if $data->{timezone} eq 'UTC';
+ $data->{titles} = titleprefs_fmt [ $data->{titles}, delete $data->{alttitles} ];
+ $data->{titles} = undef if $data->{titles} eq titleprefs_fmt $DEFAULT_TITLE_PREFS;
+
+ $data->{vnrel_langs} = !$data->{vnrel_langs} || $data->{vnrel_langs}->@* == keys %LANGUAGE ? undef : '{'.join(',',$data->{vnrel_langs}->@*).'}';
+ $data->{staffed_langs} = !$data->{staffed_langs} || $data->{staffed_langs}->@* == keys %LANGUAGE ? undef : '{'.join(',',$data->{staffed_langs}->@*).'}';
+
+ $set{$_} = $data->{$_} for qw/nodistract_noads nodistract_nofancy support_enabled uniname pubskin_enabled/;
+ $setp{$_} = $data->{$_} for qw/
+ tags_all tags_cont tags_ero tags_tech
+ vnrel_langs vnrel_olang vnrel_mtl staffed_langs staffed_olang staffed_unoff
+ skin customcss timezone max_sexual max_violence spoilers traits_sexual prodrelexpand titles
+ /;
+ $setp{customcss_csum} = $data->{customcss_csum} && length $data->{customcss} ? unpack 'q', sha1 do { utf8::encode(local $_=$data->{customcss}); $_ } : 0;
- return elm_Taken if $data->{uniname}
- && tuwf->dbVali('SELECT 1 FROM users WHERE id <>', \$data->{id}, 'AND username =', \lc($data->{uniname}));
+ $set{email_confirmed} = 1 if auth->permUsermod;
- if(auth->permUsermod) {
- tuwf->dbExeci(update => users => set => {
- username => $data->{username},
- ign_votes => $data->{ign_votes},
- email_confirmed => 1,
- }, where => { id => $data->{id} });
- tuwf->dbExeci(select => sql_func user_setperm => \$data->{id}, \auth->uid, sql_fromhex(auth->token), \$data->{perm});
+ if($data->{username} ne $u->{username}) {
+ return +{ _err => 'You can only change your username once a day.' } if _namethrottled $data->{id};
+ return +{ code => 'username_taken', _err => 'Username already taken.' } if !is_unique_username $data->{username}, $data->{id};
+ $set{username} = $data->{username};
+ auth->audit($data->{id}, 'username change', "old=$u->{username}; new=$data->{username}");
+ tuwf->dbExeci('INSERT INTO users_username_hist', { id => $data->{id}, old => $u->{username}, new => $data->{username} });
}
if($data->{password}) {
- return elm_InsecurePass if is_insecurepass $data->{password}{new};
-
- if(auth->uid == $data->{id}) {
- return elm_BadCurPass if !auth->setpass($data->{id}, undef, $data->{password}{old}, $data->{password}{new});
- } else {
- tuwf->dbExeci(select => sql_func user_admin_setpass => \$data->{id}, \auth->uid,
- sql_fromhex(auth->token), sql_fromhex auth->_preparepass($data->{password}{new})
- );
- }
+ return +{ code => 'npass', _err => 'Your new password is in a public database of leaked passwords, please choose a different password.' }
+ if is_insecurepass $data->{password}{new};
+ my $ok = auth->setpass($data->{id}, undef, $data->{password}{old}, $data->{password}{new});
+ auth->audit($data->{id}, $ok ? 'password change' : 'bad password', 'at user edit form');
+ return +{ code => 'opass', _err => 'Incorrect password' } if !$ok;
}
- my $ret = \&elm_Success;
+ my $ret = {ok=>1};
my $oldmail = _getmail $data->{id};
- if($data->{email} ne $oldmail) {
+ if ($oldmail ne $data->{email}) {
+ return +{ code => 'email_taken', _err => 'E-Mail address already in use by another account' }
+ if tuwf->dbVali('SELECT 1 FROM user_emailtoid(', \$data->{email}, ') x(id) WHERE id <>', \$data->{id});
+ auth->audit($data->{id}, 'email change', "old=$oldmail; new=$data->{email}");
if(auth->permUsermod) {
tuwf->dbExeci(select => sql_func user_admin_setmail => \$data->{id}, \auth->uid, sql_fromhex(auth->token), \$data->{email});
} else {
- return elm_DoubleEmail if tuwf->dbVali(select => sql_func user_emailexists => \$data->{email}, \$data->{id});
my $token = auth->setmail_token($data->{email});
my $body = sprintf
"Hello %s,"
@@ -123,27 +204,51 @@ elm_api UserEdit => undef, $FORM, sub {
."%s"
."\n\n"
."vndb.org",
- $username, $oldmail, $data->{email}, tuwf->reqBaseURI()."/u$data->{id}/setmail/$token";
+ $u->{username}, $oldmail, $data->{email}, tuwf->reqBaseURI()."/$data->{id}/setmail/$token";
tuwf->mail($body,
To => $data->{email},
From => 'VNDB <noreply@vndb.org>',
- Subject => "Confirm e-mail change for $username",
+ Subject => "Confirm e-mail change for $u->{username}",
);
- $ret = \&elm_MailChange;
+ $ret = {email=>1};
}
}
- $data->{skin} = '' if $data->{skin} eq config->{skin_default};
- $data->{uniname} = '' if $data->{uniname} eq $data->{username};
- tuwf->dbExeci('UPDATE users SET', { %{$data}{qw/
- show_nsfw traits_sexual tags_all tags_cont tags_ero tags_tech spoilers skin customcss
- nodistract_noads nodistract_nofancy support_enabled uniname pubskin_enabled
- /} },
- 'WHERE id =', \$data->{id}
- );
+ tuwf->dbExeci('DELETE FROM users_traits WHERE id =', \$data->{id});
+ tuwf->dbExeci('INSERT INTO users_traits', { id => $data->{id}, tid => $_->{tid} }) for $data->{traits}->@*;
+
+ tuwf->dbExeci('DELETE FROM users_prefs_tags WHERE id =', \$data->{id});
+ tuwf->dbExeci('INSERT INTO users_prefs_tags', { id => $data->{id}, %{$_}{qw|tid spoil color childs|} }) for $data->{tagprefs}->@*;
- $ret->();
+ tuwf->dbExeci('DELETE FROM users_prefs_traits WHERE id =', \$data->{id});
+ tuwf->dbExeci('INSERT INTO users_prefs_traits', { id => $data->{id}, %{$_}{qw|tid spoil color childs|} }) for $data->{traitprefs}->@*;
+
+ my %tokens = map +($_->{token},$_), $data->{api2}->@*;
+ for (auth->api2_tokens($data->{id})->@*) {
+ my $t = $tokens{$_->{token}} // next;
+ $t->{listwrite} = 0 if !$t->{listread};
+ if($t->{delete}) {
+ auth->api2_del_token($data->{id}, $t->{token});
+ } elsif($t->{notes} ne $_->{notes}
+ || !$t->{listread} ne !$_->{listread}
+ || !$t->{listwrite} ne !$_->{listwrite}) {
+ auth->api2_set_token($data->{id}, %$t);
+ }
+ }
+
+ my $old = tuwf->dbRowi('SELECT', sql_comma(keys %set, keys %setp), 'FROM users u JOIN users_prefs up ON up.id = u.id WHERE u.id =', \$data->{id});
+ tuwf->dbExeci('UPDATE users SET', \%set, 'WHERE id =', \$data->{id}) if keys %set;
+ tuwf->dbExeci('UPDATE users_prefs SET', \%setp, 'WHERE id =', \$data->{id}) if keys %setp;
+ my $new = tuwf->dbRowi('SELECT', sql_comma(keys %set, keys %setp), 'FROM users u JOIN users_prefs up ON up.id = u.id WHERE u.id =', \$data->{id});
+
+ if (auth->uid ne $data->{id}) {
+ $_ = JSON::XS->new->allow_nonref->encode($_) for values %$old, %$new;
+ my @diff = grep $old->{$_} ne $new->{$_}, keys %set, keys %setp;
+ auth->audit($data->{id}, 'user edit', join '; ', map "$_: $old->{$_} -> $new->{$_}", @diff) if @diff;
+ }
+
+ return $ret;
};
@@ -151,7 +256,7 @@ TUWF::get qr{/$RE{uid}/setmail/(?<token>[a-f0-9]{40})}, sub {
my $success = auth->setmail_confirm(tuwf->capture('id'), tuwf->capture('token'));
my $title = $success ? 'E-mail confirmed' : 'Error confirming email';
framework_ title => $title, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
div_ class => $success ? 'notice' : 'warning', sub {
p_ "Your e-mail address has been updated!" if $success;
@@ -161,4 +266,9 @@ TUWF::get qr{/$RE{uid}/setmail/(?<token>[a-f0-9]{40})}, sub {
};
};
+
+js_api UserApi2New => { id => { vndbid => 'u' }}, sub {
+ +{ token => auth->api2_set_token($_[0]{id}), added => strftime '%Y-%m-%d', localtime }
+};
+
1;
diff --git a/lib/VNWeb/User/List.pm b/lib/VNWeb/User/List.pm
index 7d5311a2..7fe5cb43 100644
--- a/lib/VNWeb/User/List.pm
+++ b/lib/VNWeb/User/List.pm
@@ -9,7 +9,7 @@ sub listing_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 50], 't';
- div_ class => 'mainbox browse', sub {
+ article_ class => 'browse userlist', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Username'; sortable_ 'username', $opt, \&url };
@@ -19,6 +19,7 @@ sub listing_ {
td_ class => 'tc5', sub { txt_ 'Wishlist'; sortable_ 'wish', $opt, \&url };
td_ class => 'tc6', sub { txt_ 'Edits'; sortable_ 'changes', $opt, \&url };
td_ class => 'tc7', sub { txt_ 'Tags'; sortable_ 'tags', $opt, \&url };
+ td_ class => 'tc8', sub { txt_ 'Images'; sortable_ 'images', $opt, \&url };
} };
tr_ sub {
my $l = $_;
@@ -26,24 +27,28 @@ sub listing_ {
td_ class => 'tc2', fmtdate $l->{registered};
td_ class => 'tc3', sub {
txt_ '0' if !$l->{c_vns};
- a_ href => "/u$l->{user_id}/ulist?vnlist=1", $l->{c_vns} if $l->{c_vns};
+ a_ href => "/$l->{user_id}/ulist?vnlist=1", $l->{c_vns} if $l->{c_vns};
};
td_ class => 'tc4', sub {
txt_ '0' if !$l->{c_votes};
- a_ href => "/u$l->{user_id}/ulist?votes=1", $l->{c_votes} if $l->{c_votes};
+ a_ href => "/$l->{user_id}/ulist?votes=1", $l->{c_votes} if $l->{c_votes};
};
td_ class => 'tc5', sub {
txt_ '0' if !$l->{c_wish};
- a_ href => "/u$l->{user_id}/ulist?wishlist=1", $l->{c_wish} if $l->{c_wish};
+ a_ href => "/$l->{user_id}/ulist?wishlist=1", $l->{c_wish} if $l->{c_wish};
};
td_ class => 'tc6', sub {
txt_ '-' if !$l->{c_changes};
- a_ href => "/u$l->{user_id}/hist", $l->{c_changes} if $l->{c_changes};
+ a_ href => "/$l->{user_id}/hist", $l->{c_changes} if $l->{c_changes};
};
td_ class => 'tc7', sub {
txt_ '-' if !$l->{c_tags};
a_ href => "/g/links?u=$l->{user_id}", $l->{c_tags} if $l->{c_tags};
};
+ td_ class => 'tc8', sub {
+ txt_ '-' if !$l->{c_imgvotes};
+ a_ href => "/img/list?u=$l->{user_id}", $l->{c_imgvotes} if $l->{c_imgvotes};
+ };
} for @$list;
};
};
@@ -56,45 +61,55 @@ TUWF::get qr{/u/(?<char>[0a-z]|all)}, sub {
my $opt = tuwf->validate(get =>
p => { upage => 1 },
- s => { onerror => 'registered', enum => [qw[username registered vns votes wish changes tags]] },
+ s => { onerror => 'registered', enum => [qw[username registered vns votes wish changes tags images]] },
o => { onerror => 'd', enum => [qw[a d]] },
q => { onerror => '' },
)->data;
my @where = (
- $char eq 'all' ? () : $char eq '0' ? "ascii(username) not between ascii('a') and ascii('z')" : "username like '$char%'",
+ 'username IS NOT NULL',
+ auth->permUsermod ? () : 'email_confirmed',
+ $char eq 'all' ? () : sql('match_firstchar(username, ', \$char, ')'),
$opt->{q} ? sql_or(
- $opt->{q} =~ /^u?([0-9]+)$/ ? sql 'id =', \"$1" : (),
- sql 'position(', \$opt->{q}, 'in username) > 0'
+ auth->permUsermod && $opt->{q} =~ /@/ ? sql('id IN(SELECT uid FROM user_emailtoid(', \$opt->{q}, '))') : (),
+ $opt->{q} =~ /^u?$RE{num}$/ ? sql 'id =', \"u$1" : (),
+ $opt->{q} =~ /@/ ? () : sql('username ILIKE', \('%'.sql_like($opt->{q}).'%')),
) : ()
);
my $list = tuwf->dbPagei({ results => 50, page => $opt->{p} },
- 'SELECT', sql_user(), ',', sql_totime('registered'), 'as registered, c_vns, c_votes, c_wish, c_changes, c_tags
+ 'SELECT', sql_user(), ',', sql_totime('registered'), 'as registered, c_vns, c_votes, c_wish, c_changes, c_tags, c_imgvotes
FROM users u
- WHERE', sql_and('id > 0', @where),
+ WHERE', sql_and(@where),
'ORDER BY', {
- username => 'username',
+ username => 'lower(username)',
registered => 'id',
vns => 'c_vns',
votes => 'c_votes',
wish => 'c_wish',
changes => 'c_changes',
- tags => 'c_tags'
+ tags => 'c_tags',
+ images => 'c_imgvotes',
}->{$opt->{s}}, $opt->{o} eq 'd' ? 'DESC' : 'ASC'
);
- my $count = @where ? tuwf->dbVali('SELECT count(*) FROM users WHERE', sql_and @where) : tuwf->{stats}{users};
+ state $totalusers = tuwf->dbVal('SELECT count(*) FROM users');
+ my $count = @where ? tuwf->dbVali('SELECT count(*) FROM users WHERE', sql_and @where) : $totalusers;
framework_ title => 'Browse users', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Browse users';
form_ action => '/u/all', method => 'get', sub {
- searchbox_ u => $opt->{q};
+ fieldset_ class => 'search', sub {
+ input_ type => 'text', name => 'q', id => 'q', class => 'text', value => $opt->{q}//'';
+ input_ type => 'submit', class => 'submit', value => 'Search!';
+ }
};
p_ class => 'browseopts', sub {
a_ href => "/u/$_", $_ eq $char ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#'
for ('all', 'a'..'z', 0);
};
+ b_ 'The given email address is on the opt-out list.'
+ if auth->permUsermod && $opt->{q} && $opt->{q} =~ /@/ && tuwf->dbVali('SELECT email_optout_check(', \$opt->{q}, ')');
};
listing_ $opt, $list, $count if $count;
};
diff --git a/lib/VNWeb/User/Lists.pm b/lib/VNWeb/User/Lists.pm
deleted file mode 100644
index cdebab6b..00000000
--- a/lib/VNWeb/User/Lists.pm
+++ /dev/null
@@ -1,588 +0,0 @@
-package VNWeb::User::Lists;
-
-use VNWeb::Prelude;
-use POSIX 'strftime';
-
-
-# Do we have "ownership" access to this users' list (i.e. can we edit and see private stuff)?
-sub own {
- auth->permUsermod || (auth && auth->uid == shift)
-}
-
-
-# Should be called after any change to the ulist_* tables.
-# (Normally I'd do this with triggers, but that seemed like a more complex and less efficient solution in this case)
-sub updcache {
- tuwf->dbExeci(SELECT => sql_func update_users_ulist_stats => \shift);
-}
-
-
-my $LABELS = form_compile any => {
- uid => { id => 1 },
- labels => { aoh => {
- id => { int => 1 },
- label => { maxlength => 50 },
- private => { anybool => 1 },
- count => { uint => 1 },
- delete => { required => 0, default => undef, uint => 1, range => [1, 3] }, # 1=keep vns, 2=delete when no other label, 3=delete all
- } }
-};
-
-elm_api UListManageLabels => undef, $LABELS, sub {
- my($uid, $labels) = ($_[0]{uid}, $_[0]{labels});
- return elm_Unauth if !own $uid;
-
- # Insert new labels
- my @new = grep $_->{id} < 0 && !$_->{delete}, @$labels;
- # Subquery to get the lowest unused id
- my $newid = sql '(
- SELECT min(x.n)
- FROM generate_series(10,
- greatest((SELECT max(id)+1 from ulist_labels ul WHERE ul.uid =', \$uid, '), 10)
- ) x(n)
- WHERE NOT EXISTS(SELECT 1 FROM ulist_labels ul WHERE ul.uid =', \$uid, 'AND ul.id = x.n)
- )';
- tuwf->dbExeci('INSERT INTO ulist_labels', { id => $newid, uid => $uid, label => $_->{label}, private => $_->{private} }) for @new;
-
- # Update private flag
- tuwf->dbExeci(
- 'UPDATE ulist_labels SET private =', \$_->{private},
- 'WHERE uid =', \$uid, 'AND id =', \$_->{id}, 'AND private <>', \$_->{private}
- ) for grep $_->{id} > 0 && !$_->{delete}, @$labels;
-
- # Update label
- tuwf->dbExeci(
- 'UPDATE ulist_labels SET label =', \$_->{label},
- 'WHERE uid =', \$uid, 'AND id =', \$_->{id}, 'AND label <>', \$_->{label}
- ) for grep $_->{id} >= 10 && !$_->{delete}, @$labels;
-
- # Delete labels
- my @delete = grep $_->{id} >= 10 && $_->{delete}, @$labels;
- my @delete_lblonly = map $_->{id}, grep $_->{delete} == 1, @delete;
- my @delete_empty = map $_->{id}, grep $_->{delete} == 2, @delete;
- 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 =
- @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;
-
- # (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;
-
- updcache $uid;
- elm_Success
-};
-
-
-
-
-my $VNVOTE = form_compile any => {
- uid => { id => 1 },
- vid => { id => 1 },
- vote => { vnvote => 1 },
-};
-
-elm_api UListVoteEdit => undef, $VNVOTE, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- tuwf->dbExeci(
- 'INSERT INTO ulist_vns', { %$data, vote_date => sql $data->{vote} ? 'NOW()' : 'NULL' },
- 'ON CONFLICT (uid, vid) DO UPDATE
- SET', { %$data,
- lastmod => sql('NOW()'),
- vote_date => sql $data->{vote} ? 'CASE WHEN ulist_vns.vote IS NULL THEN NOW() ELSE ulist_vns.vote_date END' : 'NULL'
- }
- );
- updcache $data->{uid};
- elm_Success
-};
-
-
-
-
-my $VNLABELS = {
- uid => { id => 1 },
- vid => { id => 1 },
- label => { _when => 'in', id => 1 },
- applied => { _when => 'in', anybool => 1 },
- labels => { _when => 'out', aoh => { id => { int => 1 }, label => {}, private => { anybool => 1 } } },
- selected => { _when => 'out', type => 'array', values => { id => 1 } },
-};
-
-my $VNLABELS_OUT = form_compile out => $VNLABELS;
-my $VNLABELS_IN = form_compile in => $VNLABELS;
-
-elm_api UListLabelEdit => $VNLABELS_OUT, $VNLABELS_IN, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- die "Attempt to set vote label" if $data->{label} == 7;
-
- tuwf->dbExeci('INSERT INTO ulist_vns', {uid => $data->{uid}, vid => $data->{vid}}, 'ON CONFLICT (uid, vid) DO NOTHING');
- tuwf->dbExeci(
- 'DELETE FROM ulist_vns_labels
- WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid}, 'AND lbl =', \$data->{label}
- ) if !$data->{applied};
- tuwf->dbExeci(
- 'INSERT INTO ulist_vns_labels', { uid => $data->{uid}, vid => $data->{vid}, lbl => $data->{label} },
- 'ON CONFLICT (uid, vid, lbl) DO NOTHING'
- ) if $data->{applied};
- tuwf->dbExeci('UPDATE ulist_vns SET lastmod = NOW() WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid});
-
- updcache $data->{uid};
- elm_Success
-};
-
-
-
-
-my $VNDATE = form_compile any => {
- uid => { id => 1 },
- vid => { id => 1 },
- date => { required => 0, default => '', regex => qr/^(?:19[7-9][0-9]|20[0-9][0-9])-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/ }, # 1970 - 2099 for sanity
- start => { anybool => 1 }, # Field selection, started/finished
-};
-
-elm_api UListDateEdit => undef, $VNDATE, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- tuwf->dbExeci(
- 'UPDATE ulist_vns SET lastmod = NOW(), ', $data->{start} ? 'started' : 'finished', '=', \($data->{date}||undef),
- 'WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid}
- );
- updcache $data->{uid};
- elm_Success
-};
-
-
-
-
-my $VNOPT = form_compile any => {
- own => { anybool => 1 },
- uid => { id => 1 },
- vid => { id => 1 },
- notes => {},
- rels => { aoh => { # Same structure as 'elm_Releases' response
- id => { id => 1 },
- title => {},
- original => {},
- released => { uint => 1 },
- rtype => {},
- lang => { type => 'array', values => {} },
- platforms=> { type => 'array', values => {} },
- } },
- relstatus => { type => 'array', values => { uint => 1 } }, # List of release statuses, same order as rels
-};
-
-
-
-# UListVNNotes module is abused for the UList.Opts flag definition
-elm_api UListVNNotes => $VNOPT, {
- uid => { id => 1 },
- vid => { id => 1 },
- notes => { required => 0, default => '', maxlength => 2000 },
-}, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- tuwf->dbExeci(
- 'UPDATE ulist_vns SET lastmod = NOW(), notes = ', \$data->{notes},
- 'WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid}
- );
- # Doesn't need `updcache()`
- elm_Success
-};
-
-
-
-
-elm_api UListDel => undef, {
- uid => { id => 1 },
- vid => { id => 1 },
-}, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- tuwf->dbExeci('DELETE FROM ulist_vns WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid});
- updcache $data->{uid};
- elm_Success
-};
-
-
-
-
-# Adds the release when not in the list.
-elm_api UListRStatus => undef, {
- uid => { id => 1 },
- rid => { id => 1 },
- status => { int => 1, enum => [ -1, keys %RLIST_STATUS ] }, # -1 meaning delete
-}, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- if($data->{status} == -1) {
- tuwf->dbExeci('DELETE FROM rlists WHERE uid =', \$data->{uid}, 'AND rid =', \$data->{rid})
- } else {
- tuwf->dbExeci('INSERT INTO rlists', $data, 'ON CONFLICT (uid, rid) DO UPDATE SET status =', \$data->{status})
- }
- # Doesn't need `updcache()`
- elm_Success
-};
-
-
-
-
-my %SAVED_OPTS = (
- # Labels
- l => { onerror => [], type => 'array', scalar => 1, values => { int => 1 } },
- mul => { anybool => 1 },
- # Sort column & order
- s => { onerror => 'title', enum => [qw[ title label vote voted added modified started finished rel rating ]] },
- o => { onerror => 'a', enum => ['a', 'd'] },
- # Visible columns
- c => { onerror => [], type => 'array', scalar => 1, values => { enum => [qw[ label vote voted added modified started finished rel rating ]] } },
-);
-
-my $SAVED_OPTS = {
- uid => { id => 1 },
- opts => { type => 'hash', keys => \%SAVED_OPTS },
- field => { _when => 'in', enum => [qw/ vnlist votes wish /] },
-};
-
-my $SAVED_OPTS_IN = form_compile in => $SAVED_OPTS;
-my $SAVED_OPTS_OUT = form_compile out => $SAVED_OPTS;
-
-elm_api UListSaveDefault => $SAVED_OPTS_OUT, $SAVED_OPTS_IN, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- tuwf->dbExeci('UPDATE users SET ulist_'.$data->{field}, '=', \JSON::XS->new->encode($data->{opts}), 'WHERE id =', \$data->{uid});
- elm_Success
-};
-
-
-
-
-sub opt {
- my($u, $filtlabels) = @_;
-
- my sub load { my $o = $u->{"ulist_$_[0]"}; ($o && eval { JSON::XS->new->decode($o) } or {})->%* };
-
- my $opt =
- # Presets
- tuwf->reqGet('vnlist') ? { mul => 0, p => 1, l => [1,2,3,4,7,-1,0], s => 'title', o => 'a', c => [qw/label vote added started finished/], load 'vnlist' } :
- tuwf->reqGet('votes') ? { mul => 0, p => 1, l => [7], s => 'voted', o => 'd', c => [qw/vote voted/], load 'votes' } :
- tuwf->reqGet('wishlist') ? { mul => 0, p => 1, l => [5], s => 'title', o => 'a', c => [qw/label added/], load 'wish' } :
- # Full options
- tuwf->validate(get =>
- p => { upage => 1 },
- ch=> { onerror => undef, enum => [ 'a'..'z', 0 ] },
- q => { required => 0 },
- %SAVED_OPTS
- )->data;
-
- # $labels only includes labels we are allowed to see, getting rid of any labels in 'l' that aren't in $labels ensures we only filter on visible labels
- my %accessible_labels = map +($_->{id}, 1), @$filtlabels;
- my %opt_l = map +($_, 1), grep $accessible_labels{$_}, $opt->{l}->@*;
- %opt_l = %accessible_labels if !keys %opt_l;
- $opt->{l} = keys %opt_l == keys %accessible_labels ? [] : [ sort keys %opt_l ];
-
- ($opt, \%opt_l)
-}
-
-
-sub filters_ {
- my($own, $filtlabels, $opt, $opt_labels, $url) = @_;
-
- my sub lblfilt_ {
- input_ type => 'checkbox', name => 'l', value => $_->{id}, id => "form_l$_->{id}", tabindex => 10, $opt_labels->{$_->{id}} ? (checked => 'checked') : ();
- label_ for => "form_l$_->{id}", "$_->{label} ";
- txt_ " ($_->{count})";
- }
-
- form_ method => 'get', sub {
- input_ type => 'hidden', name => 's', value => $opt->{s};
- input_ type => 'hidden', name => 'o', value => $opt->{o};
- input_ type => 'hidden', name => 'ch', value => $opt->{ch} if defined $opt->{ch};
- input_ type => 'hidden', name => 'c', value => $_ for $opt->{c}->@*;
- p_ class => 'labelfilters', sub {
- input_ type => 'text', class => 'text', name => 'q', value => $opt->{q}||'', style => 'width: 500px', placeholder => 'Search', tabindex => 10;
- br_;
- # XXX: Rather silly that everything in this form is a form element except for the alphabet filter. Meh, behavior seems intuitive enough.
- span_ class => 'browseopts', sub {
- a_ href => $url->(ch => $_, p => undef), ($_//'') eq ($opt->{ch}//'') ? (class => 'optselected') : (), !defined($_) ? 'ALL' : $_ ? uc $_ : '#'
- for (undef, 'a'..'z', 0);
- };
- br_;
- span_ class => 'linkradio', sub {
- join_ sub { em_ ' / ' }, \&lblfilt_, grep $_->{id} < 10, @$filtlabels;
-
- span_ class => 'hidden', sub {
- em_ ' || ';
- input_ type => 'checkbox', name => 'mul', value => 1, id => 'form_l_multi', tabindex => 10, $opt->{mul} ? (checked => 'checked') : ();
- label_ for => 'form_l_multi', 'Multi-select';
- };
- debug_ $filtlabels;
- };
- my @cust = grep $_->{id} >= 10, @$filtlabels;
- if(@cust) {
- br_;
- span_ class => 'linkradio', sub {
- join_ sub { em_ ' / ' }, \&lblfilt_, @cust;
- }
- }
- br_;
- input_ type => 'submit', class => 'submit', tabindex => 10, value => 'Update filters';
- input_ type => 'button', class => 'submit', tabindex => 10, id => 'managelabels', value => 'Manage labels' if $own;
- input_ type => 'button', class => 'submit', tabindex => 10, id => 'savedefault', value => 'Save as default' if $own;
- };
- };
-}
-
-
-sub vn_ {
- my($uid, $own, $opt, $n, $v, $labels) = @_;
- tr_ mkclass(odd => $n % 2 == 0), id => "ulist_tr_$v->{id}", sub {
- my %labels = map +($_,1), $v->{labels}->@*;
-
- td_ class => 'tc1', sub {
- input_ type => 'checkbox', class => 'checkhidden', name => 'collapse_vid', id => 'collapse_vid'.$v->{id}, value => 'collapsed_vid'.$v->{id};
- label_ for => 'collapse_vid'.$v->{id}, sub {
- my $obtained = grep $_->{status} == 2, $v->{rels}->@*;
- my $total = $v->{rels}->@*;
- b_ id => 'ulist_relsum_'.$v->{id},
- mkclass(done => $total && $obtained == $total, todo => $obtained < $total, neutral => 1),
- sprintf '%d/%d', $obtained, $total;
- if($own) {
- my $public = List::Util::any { $labels{$_->{id}} && !$_->{private} } @$labels;
- my $publicLabel = List::Util::any { $_->{id} != 7 && $labels{$_->{id}} && !$_->{private} } @$labels;
- span_ mkclass(invisible => !$public),
- id => 'ulist_public_'.$v->{id},
- 'data-publabel' => !!$publicLabel,
- 'data-voted' => !!$labels{7},
- title => 'This item is public', ' 👁';
- }
- };
- };
-
- td_ class => 'tc_voted', $v->{vote_date} ? fmtdate $v->{vote_date}, 'compact' : '-' if in voted => $opt->{c};
-
- td_ mkclass(tc_vote => 1, compact => $own, stealth => $own), sub {
- txt_ fmtvote $v->{vote} if !$own;
- elm_ 'UList.VoteEdit' => $VNVOTE, { uid => $uid, vid => $v->{id}, vote => fmtvote($v->{vote}) }, fmtvote $v->{vote}
- if $own && ($v->{vote} || sprintf('%08d', $v->{c_released}||0) < strftime '%Y%m%d', gmtime);
- } if in vote => $opt->{c};
-
- td_ class => 'tc_rating', sub {
- txt_ sprintf '%.2f', ($v->{c_rating}||0)/10;
- b_ class => 'grayedout', sprintf ' (%d)', $v->{c_votecount};
- } if in rating => $opt->{c};
-
- td_ class => 'tc_labels', sub {
- my @l = grep $labels{$_->{id}} && $_->{id} != 7, @$labels;
- my $txt = @l ? join ', ', map $_->{label}, @l : '-';
- if($own) {
- elm_ 'UList.LabelEdit' => $VNLABELS_OUT, { vid => $v->{id}, selected => [ grep $_ != 7, $v->{labels}->@* ] }, $txt;
- } else {
- txt_ $txt;
- }
- } if in label => $opt->{c};
-
- td_ class => 'tc_title', sub {
- a_ href => "/v$v->{id}", title => $v->{original}||$v->{title}, shorten $v->{title}, 70;
- b_ class => 'grayedout', id => 'ulist_notes_'.$v->{id}, $v->{notes} if $v->{notes} || $own;
- };
-
- td_ class => 'tc_added', fmtdate $v->{added}, 'compact' if in added => $opt->{c};
- td_ class => 'tc_modified', fmtdate $v->{lastmod}, 'compact' if in modified => $opt->{c};
-
- td_ class => 'tc_started', sub {
- txt_ $v->{started}||'' if !$own;
- elm_ 'UList.DateEdit' => $VNDATE, { uid => $uid, vid => $v->{id}, date => $v->{started}||'', start => 1 }, $v->{started}||'' if $own;
- } if in started => $opt->{c};
-
- td_ class => 'tc_finished', sub {
- txt_ $v->{finished}||'' if !$own;
- elm_ 'UList.DateEdit' => $VNDATE, { uid => $uid, vid => $v->{id}, date => $v->{finished}||'', start => 0 }, $v->{finished}||'' if $own;
- } if in finished => $opt->{c};
-
- td_ class => 'tc_rel', sub { rdate_ $v->{c_released} } if in rel => $opt->{c};
- };
-
- tr_ mkclass(hidden => 1, 'collapsed_vid'.$v->{id} => 1, odd => $n % 2 == 0), sub {
- td_ colspan => 7, class => 'tc_opt', sub {
- my $relstatus = [ map $_->{status}, $v->{rels}->@* ];
- elm_ 'UList.Opt' => $VNOPT, { own => $own, uid => $uid, vid => $v->{id}, notes => $v->{notes}, rels => $v->{rels}, relstatus => $relstatus };
- };
- };
-}
-
-
-sub listing_ {
- my($uid, $own, $opt, $labels, $url) = @_;
-
- my @l = grep $_ > 0, $opt->{l}->@*;
- my($unlabeled) = grep $_ == -1, $opt->{l}->@*;
-
- my @where_vns = (
- @l ? sql('uv.vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN', \@l, ')') :
- !$own ? sql('uv.vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN(SELECT id FROM ulist_labels WHERE uid =', \$uid, 'AND NOT private))') : (),
- $unlabeled ? sql('NOT EXISTS(SELECT 1 FROM ulist_vns_labels WHERE uid =', \$uid, 'AND vid = uv.vid AND lbl <> ', \7, ')') : ()
- );
-
- my $where = sql_and
- sql('uv.uid =', \$uid),
- @where_vns ? sql_or(@where_vns) : (),
- $opt->{q} ? map sql('v.c_search like', \"%$_%"), normalize_query $opt->{q} : (),
- defined($opt->{ch}) && $opt->{ch} ? sql('LOWER(SUBSTR(v.title, 1, 1)) =', \$opt->{ch}) : (),
- defined($opt->{ch}) && !$opt->{ch} ? sql('(ASCII(v.title) <', \97, 'OR ASCII(v.title) >', \122, ') AND (ASCII(v.title) <', \65, 'OR ASCII(v.title) >', \90, ')') : ();
-
- my $count = tuwf->dbVali('SELECT count(*) FROM ulist_vns uv JOIN vn v ON v.id = uv.vid WHERE', $where);
-
- my $lst = tuwf->dbPagei({ page => $opt->{p}, results => 50 },
- 'SELECT v.id, v.title, v.original, uv.vote, uv.notes, uv.started, uv.finished, v.c_rating, v.c_votecount, v.c_released
- ,', sql_totime('uv.added'), ' as added
- ,', sql_totime('uv.lastmod'), ' as lastmod
- ,', sql_totime('uv.vote_date'), ' as vote_date
- FROM ulist_vns uv
- JOIN vn v ON v.id = uv.vid
- WHERE', $where, '
- ORDER BY', {
- title => 'v.title',
- label => sql('ARRAY(SELECT ul.label FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND uvl.lbl <> ', \7, ')'),
- vote => 'uv.vote',
- voted => 'uv.vote_date',
- added => 'uv.added',
- modified => 'uv.lastmod',
- started => 'uv.started',
- finished => 'uv.finished',
- rel => 'v.c_released',
- rating => 'v.c_rating',
- }->{$opt->{s}}, $opt->{o} eq 'd' ? 'DESC' : 'ASC', 'NULLS LAST, v.title'
- );
-
- enrich_flatten labels => id => vid => sql('SELECT vid, lbl FROM ulist_vns_labels WHERE uid =', \$uid, 'AND vid IN'), $lst;
-
- enrich rels => id => vid => sub { sql '
- SELECT rv.vid, r.id, r.title, r.original, r.released, r.type as rtype, rl.status
- FROM rlists rl
- JOIN releases r ON rl.rid = r.id
- JOIN releases_vn rv ON rv.id = r.id
- WHERE rl.uid =', \$uid, '
- AND rv.vid IN', $_, '
- ORDER BY r.released ASC'
- }, $lst;
-
- enrich_flatten lang => id => id => sub { sql('SELECT id, lang FROM releases_lang WHERE id IN', $_, 'ORDER BY lang') }, map $_->{rels}, @$lst;
- enrich_flatten platforms => id => id => sub { sql('SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY platform') }, map $_->{rels}, @$lst;
-
- # TODO: Thumbnail view?
- paginate_ $url, $opt->{p}, [ $count, 50 ], 't', sub {
- elm_ ColSelect => undef, [ $url->(), [
- [ voted => 'Vote date' ],
- [ vote => 'Vote' ],
- [ rating => 'Rating' ],
- [ label => 'Labels' ],
- [ added => 'Added' ],
- [ modified => 'Modified' ],
- [ started => 'Start date' ],
- [ finished => 'Finish date' ],
- [ rel => 'Release date' ],
- ] ];
- };
- div_ class => 'mainbox browse ulist', sub {
- table_ sub {
- thead_ sub { tr_ sub {
- td_ class => 'tc1', sub {
- input_ type => 'checkbox', class => 'checkall', name => 'collapse_vid', id => 'collapse_vid';
- label_ for => 'collapse_vid', sub { txt_ 'Opt' };
- };
- td_ class => 'tc_voted', sub { txt_ 'Vote date'; sortable_ 'voted', $opt, $url } if in voted => $opt->{c};
- td_ class => 'tc_vote', sub { txt_ 'Vote'; sortable_ 'vote', $opt, $url } if in vote => $opt->{c};
- td_ class => 'tc_rating', sub { txt_ 'Rating'; sortable_ 'rating', $opt, $url } if in rating => $opt->{c};
- td_ class => 'tc_labels', sub { txt_ 'Labels'; sortable_ 'label', $opt, $url } if in label => $opt->{c};
- td_ class => 'tc_title', sub { txt_ 'Title'; sortable_ 'title', $opt, $url; debug_ $lst };
- td_ class => 'tc_added', sub { txt_ 'Added'; sortable_ 'added', $opt, $url } if in added => $opt->{c};
- td_ class => 'tc_modified', sub { txt_ 'Modified'; sortable_ 'modified', $opt, $url } if in modified => $opt->{c};
- td_ class => 'tc_started', sub { txt_ 'Start date'; sortable_ 'started', $opt, $url } if in started => $opt->{c};
- td_ class => 'tc_finished', sub { txt_ 'Finish date'; sortable_ 'finished', $opt, $url } if in finished => $opt->{c};
- td_ class => 'tc_rel', sub { txt_ 'Release date';sortable_ 'rel', $opt, $url } if in rel => $opt->{c};
- }};
- vn_ $uid, $own, $opt, $_, $lst->[$_], $labels for (0..$#$lst);
- };
- };
- paginate_ $url, $opt->{p}, [ $count, 50 ], 'b';
-}
-
-
-# TODO: Ability to add VNs from this page
-TUWF::get qr{/$RE{uid}/ulist}, sub {
- my $u = tuwf->dbRowi('SELECT id,', sql_user(), ', ulist_votes, ulist_vnlist, ulist_wish FROM users u WHERE id =', \tuwf->capture('id'));
- return tuwf->resNotFound if !$u->{id};
-
- my $own = own $u->{id};
-
- # Visible and selectable labels
- my $labels = tuwf->dbAlli(
- 'SELECT l.id, l.label, l.private, count(vl.vid) as count, null as delete
- FROM ulist_labels l LEFT JOIN ulist_vns_labels vl ON vl.uid = l.uid AND vl.lbl = l.id
- WHERE', { 'l.uid' => $u->{id}, $own ? () : ('l.private' => 0) },
- 'GROUP BY l.id, l.label, l.private
- ORDER BY CASE WHEN l.id < 10 THEN l.id ELSE 10 END, l.label'
- );
-
- # All visible labels that can be filtered on, including "virtual" labels like 'No label'
- my $filtlabels = [
- @$labels,
- $own ? {
- id => -1, label => 'No label', count => tuwf->dbVali(
- 'SELECT count(*)
- FROM ulist_vns uv
- WHERE NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND uvl.lbl <>', \7, ')
- AND uid =', \$u->{id}
- )
- } : (),
- ];
-
- my($opt, $opt_labels) = opt $u, $filtlabels;
- my sub url { '?'.query_encode %$opt, @_ }
-
- # This page has 3 user tabs: list, wish and votes; Select the appropriate active tab based on label filters.
- my $num_core_labels = grep $_ < 10, keys %$opt_labels;
- my $tab = $num_core_labels == 1 && $opt_labels->{7} ? 'votes'
- : $num_core_labels == 1 && $opt_labels->{5} ? 'wish' : 'list';
-
- my $title = $own ? 'My list' : user_displayname($u)."'s list";
- framework_ title => $title, type => 'u', dbobj => $u, tab => $tab,
- $own ? ( pagevars => {
- uid => $u->{id}*1,
- labels => $LABELS->analyze->{keys}{labels}->coerce_for_json($labels),
- voteprivate => (map \($_->{private}?1:0), grep $_->{id} == 7, @$labels),
- } ) : (),
- sub {
- my $empty = !grep $_->{count}, @$filtlabels;
- div_ class => 'mainbox', sub {
- h1_ $title;
- if($empty) {
- p_ $own
- ? 'Your list is empty! You can add visual novels to your list from the visual novel pages.'
- : user_displayname($u).' does not have any visible visual novels in their list.';
- } else {
- filters_ $own, $filtlabels, $opt, $opt_labels, \&url;
- elm_ 'UList.ManageLabels' if $own;
- elm_ 'UList.SaveDefault', $SAVED_OPTS_OUT, { uid => $u->{id}, opts => $opt } if $own;
- }
- };
- listing_ $u->{id}, $own, $opt, $labels, \&url if !$empty;
- };
-};
-
-
-
-# Redirects for old URLs
-TUWF::get qr{/$RE{uid}/votes}, sub { tuwf->resRedirect("/u".tuwf->capture('id').'/ulist?votes=1', 'perm') };
-TUWF::get qr{/$RE{uid}/list}, sub { tuwf->resRedirect("/u".tuwf->capture('id').'/ulist?vnlist=1', 'perm') };
-TUWF::get qr{/$RE{uid}/wish}, sub { tuwf->resRedirect("/u".tuwf->capture('id').'/ulist?wishlist=1', 'perm') };
-
-
-1;
diff --git a/lib/VNWeb/User/Login.pm b/lib/VNWeb/User/Login.pm
index 95295e05..b4ac76da 100644
--- a/lib/VNWeb/User/Login.pm
+++ b/lib/VNWeb/User/Login.pm
@@ -4,19 +4,19 @@ use VNWeb::Prelude;
TUWF::get '/u/login' => sub {
- return tuwf->resRedirect('/', 'temp') if auth;
+ return tuwf->resRedirect('/', 'temp') if auth || config->{read_only};
my $ref = tuwf->reqGet('ref');
$ref = '/' if !$ref || $ref !~ /^\//;
framework_ title => 'Login', sub {
- elm_ 'User.Login' => tuwf->compile({}), $ref;
+ div_ widget(UserLogin => {ref => $ref}), '';
};
};
-elm_api UserLogin => undef, {
- username => { username => 1 },
+js_api UserLogin => {
+ username => {},
password => { password => 1 }
}, sub {
my $data = shift;
@@ -25,38 +25,61 @@ elm_api UserLogin => undef, {
my $tm = tuwf->dbVali(
'SELECT', sql_totime('greatest(timeout, now())'), 'FROM login_throttle WHERE ip =', \$ip
) || time;
- return elm_LoginThrottle if $tm-time() > config->{login_throttle}[1];
+ return +{ _err => 'Too many failed login attempts, please use the password reset form or try again later.' }
+ if $tm-time() > config->{login_throttle}[1];
+
+ my $ismail = $data->{username} =~ /@/;
+ my $mailmsg = 'Invalid username or password.';
+
+ my $u = tuwf->dbRowi('SELECT id, user_getscryptargs(id) x FROM users WHERE',
+ $ismail ? sql('id IN(SELECT uid FROM user_emailtoid(', \$data->{username}, '))')
+ : sql('lower(username) = lower(', \$data->{username}, ')')
+ );
+ # When logging in with an email, make sure we don't disclose whether or not an account with that email exists.
+ if ($ismail && !$u->{id}) {
+ auth->wasteTime; # make timing attacks a bit harder (not 100% perfect, DB lookups & different scrypt args can still influence timing)
+ return +{ _err => $mailmsg };
+ }
+ return +{ _err => 'No user with that name.' } if !$u->{id};
+ return +{ _err => 'Account disabled, please use the password reset form to re-activate your account.' } if !$u->{x};
my $insecure = is_insecurepass $data->{password};
- return $insecure ? elm_InsecurePass : elm_Success
- if auth->login($data->{username}, $data->{password}, $insecure);
+ my $ret = auth->login($u->{id}, $data->{password}, $insecure);
+ if($ret && $insecure) {
+ return +{ insecurepass => 1, uid => $u->{id} };
+ } elsif (40 == length $ret) {
+ return +{ _redir => "/$u->{id}/del/$ret" };
+ } else {
+ auth->audit(auth->uid, 'login');
+ return +{ ok => 1 };
+ }
- # Failed login, update throttle.
+ # Failed login, log and update throttle.
+ auth->audit($u->{id}, 'bad password', 'failed login attempt');
my $upd = {
ip => \$ip,
timeout => sql_fromtime $tm + config->{login_throttle}[0]
};
tuwf->dbExeci('INSERT INTO login_throttle', $upd, 'ON CONFLICT (ip) DO UPDATE SET', $upd);
- elm_BadLogin
+ +{ _err => $ismail ? $mailmsg : 'Incorrect password.' }
};
-elm_api UserChangePass => undef, {
- username => { username => 1 },
+js_api UserChangePass => {
+ uid => { vndbid => 'u' },
oldpass => { password => 1 },
newpass => { password => 1 },
}, sub {
my $data = shift;
- my $uid = tuwf->dbVali('SELECT id FROM users WHERE username =', \$data->{username});
- die if !$uid;
- return elm_InsecurePass if is_insecurepass $data->{newpass};
- die if !auth->setpass($uid, undef, $data->{oldpass}, $data->{newpass}); # oldpass should already have been verified.
- elm_Success
+ return +{ _err => 'Your new password has also been leaked.' } if is_insecurepass $data->{newpass};
+ die if !auth->setpass($data->{uid}, undef, $data->{oldpass}, $data->{newpass}); # oldpass should already have been verified.
+ auth->audit($data->{uid}, 'password change', 'after login with an insecure password');
+ {}
};
TUWF::post qr{/$RE{uid}/logout}, sub {
- return tuwf->resNotFound if !auth || auth->uid != tuwf->capture('id') || (tuwf->reqPost('csrf')||'') ne auth->csrftoken;
+ return tuwf->resNotFound if !auth || auth->uid ne tuwf->capture('id') || (tuwf->reqPost('csrf')||'') ne auth->csrftoken;
auth->logout;
tuwf->resRedirect('/', 'post');
};
diff --git a/lib/VNWeb/User/Notifications.pm b/lib/VNWeb/User/Notifications.pm
index c74cc1a8..513cec23 100644
--- a/lib/VNWeb/User/Notifications.pm
+++ b/lib/VNWeb/User/Notifications.pm
@@ -3,28 +3,46 @@ package VNWeb::User::Notifications;
use VNWeb::Prelude;
my %ntypes = (
- pm => 'Private Message',
- dbdel => 'Entry you contributed to has been deleted',
- listdel => 'VN in your (wish)list has been deleted',
- dbedit => 'Entry you contributed to has been edited',
- announce => 'Site announcement',
+ 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',
);
sub settings_ {
my $id = shift;
+ my $u = tuwf->dbRowi('SELECT notify_dbedit, notify_post, notify_comment, notify_announce FROM users WHERE id =', \$id);
+
h1_ 'Settings';
- form_ action => "/u$id/notify_options", method => 'POST', sub {
+ form_ action => "/$id/notify_options", method => 'POST', sub {
input_ type => 'hidden', class => 'hidden', name => 'csrf', value => auth->csrftoken;
p_ sub {
label_ sub {
- input_ type => 'checkbox', name => 'dbedit', auth->pref('notify_dbedit') ? (checked => 'checked') : ();
+ input_ type => 'checkbox', name => 'dbedit', $u->{notify_dbedit} ? (checked => 'checked') : ();
txt_ ' Notify me about edits of database entries I contributed to.';
};
br_;
label_ sub {
- input_ type => 'checkbox', name => 'announce', auth->pref('notify_announce') ? (checked => 'checked') : ();
+ input_ type => 'checkbox', name => 'post', $u->{notify_post} ? (checked => 'checked') : ();
+ txt_ ' Notify me about replies to threads I posted in.';
+ };
+ br_;
+ label_ sub {
+ input_ type => 'checkbox', name => 'comment', $u->{notify_comment} ? (checked => 'checked') : ();
+ txt_ ' Notify me about comments to my reviews.';
+ };
+ br_;
+ label_ sub {
+ input_ type => 'checkbox', name => 'announce', $u->{notify_announce} ? (checked => 'checked') : ();
txt_ ' Notify me about site announcements.';
};
br_;
@@ -37,7 +55,7 @@ sub settings_ {
sub listing_ {
my($id, $opt, $count, $list) = @_;
- my sub url { "/u$id/notifies?r=$opt->{r}&p=$_" }
+ my sub url { "/$id/notifies?r=$opt->{r}&p=$_" }
my sub tbl_ {
thead_ sub { tr_ sub {
@@ -53,32 +71,40 @@ sub listing_ {
txt_ ' ';
input_ type => 'submit', class => 'submit', name => 'markread', value => 'mark selected read';
input_ type => 'submit', class => 'submit', name => 'remove', value => 'remove selected';
- b_ class => 'grayedout', ' (Read notifications are automatically removed after one month)';
+ small_ ' (Read notifications are automatically removed after one month)';
}
}};
tr_ $_->{read} ? () : (class => 'unread'), sub {
my $l = $_;
- my $lid = $l->{ltype}.$l->{iid}.($l->{subid}?'.'.$l->{subid}:'');
- my $url = "/u$id/notify/$l->{id}/$lid";
+ my $lid = $l->{iid}.($l->{num}?'.'.$l->{num}:'');
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 {
- txt_ $l->{ltype} eq 't' ? 'Edit of ' : $l->{subid} == 1 ? 'New thread ' : 'Reply to ';
- i_ $l->{c_title};
+ 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 ';
+ span_ tattr $l;
txt_ ' by ';
- i_ user_displayname $l;
+ span_ user_displayname $l;
};
};
} for @$list;
}
- form_ action => "/u$id/notify_update", method => 'POST', sub {
+ form_ action => "/$id/notify_update", method => 'POST', sub {
input_ type => 'hidden', class => 'hidden', name => 'url', value => do { local $_ = $opt->{p}; url };
paginate_ \&url, $opt->{p}, [$count, 25], 't';
- div_ class => 'mainbox browse notifies', sub {
+ article_ class => 'browse notifies', sub {
table_ class => 'stripe', \&tbl_;
};
paginate_ \&url, $opt->{p}, [$count, 25], 'b';
@@ -86,9 +112,13 @@ 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('/'.auth->uid.'/notifies', 'temp') : tuwf->resNotFound };
+
+
TUWF::get qr{/$RE{uid}/notifies}, sub {
my $id = tuwf->capture('id');
- return tuwf->resNotFound if !auth || $id != auth->uid;
+ return tuwf->resNotFound if !auth || $id ne auth->uid;
my $opt = tuwf->validate(get =>
p => { page => 1 },
@@ -96,24 +126,23 @@ 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.ltype, n.iid, n.subid, 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'
);
- framework_ title => 'My notifications',
+ framework_ title => 'My notifications', js => 1,
sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'My notifications';
p_ class => 'browseopts', sub {
a_ !$opt->{r} ? (class => 'optselected') : (), href => '?r=0', 'Unread notifications';
@@ -122,35 +151,41 @@ TUWF::get qr{/$RE{uid}/notifies}, sub {
p_ 'No notifications!' if !$count;
};
listing_ $id, $opt, $count, $list;
- div_ class => 'mainbox', sub { settings_ $id };
+ article_ sub { settings_ $id };
};
};
TUWF::post qr{/$RE{uid}/notify_options}, sub {
my $id = tuwf->capture('id');
- return tuwf->resNotFound if !auth || $id != auth->uid;
+ return tuwf->resNotFound if !auth || $id ne auth->uid;
my $frm = tuwf->validate(post =>
csrf => {},
dbedit => { anybool => 1 },
announce => { anybool => 1 },
+ post => { anybool => 1 },
+ comment => { anybool => 1 },
)->data;
return tuwf->resNotFound if !auth->csrfcheck($frm->{csrf});
- auth->prefSet(notify_dbedit => $frm->{dbedit});
- auth->prefSet(notify_announce => $frm->{announce});
- tuwf->resRedirect("/u$id/notifies", 'post');
+ tuwf->dbExeci('UPDATE users SET', {
+ notify_dbedit => $frm->{dbedit},
+ notify_announce => $frm->{announce},
+ notify_post => $frm->{post},
+ notify_comment => $frm->{comment},
+ }, 'WHERE id =', \$id);
+ tuwf->resRedirect("/$id/notifies", 'post');
};
TUWF::post qr{/$RE{uid}/notify_update}, sub {
my $id = tuwf->capture('id');
- return tuwf->resNotFound if !auth || $id != auth->uid;
+ return tuwf->resNotFound if !auth || $id ne auth->uid;
my $frm = tuwf->validate(post =>
- url => { regex => qr{^/u$id/notifies} },
- notifysel => { required => 0, default => [], type => 'array', scalar => 1, values => { id => 1 } },
+ url => { regex => qr{^/$id/notifies} },
+ notifysel => { default => [], type => 'array', scalar => 1, values => { id => 1 } },
markread => { anybool => 1 },
remove => { anybool => 1 },
)->data;
@@ -164,11 +199,45 @@ 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;
- tuwf->dbExeci('UPDATE notifications SET read = NOW() WHERE uid =', \$id, ' AND id =', \tuwf->capture('num'));
+ return tuwf->resNotFound if !auth || $id ne auth->uid;
+ tuwf->dbExeci('UPDATE notifications SET read = NOW() WHERE read IS NULL AND uid =', \$id, ' AND id =', \tuwf->capture('num'));
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>[vrpcsdgi]$RE{num})\.(?<rev>$RE{num})$};
+};
+
+
+
+
+our $SUB = form_compile any => {
+ id => { vndbid => [qw|t w v r p c s d i g|] },
+ subnum => { undefbool => 1 },
+ subreview => { anybool => 1 },
+ subapply => { anybool => 1 },
+ noti => { uint => 1, default => undef }, # used by the widget, ignored in the backend
+};
+
+js_api Subscribe => $SUB, sub {
+ my($data) = @_;
+ $data->{subreview} = 0 if $data->{id} !~ /^v/;
+ delete $data->{noti};
+
+ 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);
+ }
+ {};
+};
+
1;
diff --git a/lib/VNWeb/User/Page.pm b/lib/VNWeb/User/Page.pm
index a1d86c58..db4f7a36 100644
--- a/lib/VNWeb/User/Page.pm
+++ b/lib/VNWeb/User/Page.pm
@@ -8,7 +8,7 @@ sub _info_table_ {
my($u, $own) = @_;
my sub sup {
- b_ ' ⭐supporter⭐' if $u->{user_support_can} && $u->{user_support_enabled};
+ strong_ ' ⭐supporter⭐' if $u->{user_support_can} && $u->{user_support_enabled};
}
tr_ sub {
@@ -19,13 +19,22 @@ sub _info_table_ {
};
} if $u->{user_uniname_can} && $u->{user_uniname};
tr_ sub {
+ my $old = tuwf->dbAlli('SELECT date::date, old FROM users_username_hist WHERE id =', \$u->{id},
+ auth->permUsermod ? () : 'AND date > NOW()-\'1 month\'::interval', 'ORDER BY date DESC');
td_ class => 'key', 'Username';
td_ sub {
- txt_ ucfirst $u->{user_name};
- txt_ ' ('; a_ href => "/u$u->{id}", "u$u->{id}";
+ txt_ $u->{user_name} if defined $u->{user_name};
+ b_ 'Account deleted' if !defined $u->{user_name};
+ user_maybebanned_ $u;
+ txt_ ' ('; a_ href => "/$u->{id}", $u->{id};
txt_ ')';
+ b_ ' Scheduled for deletion' if auth->isMod && tuwf->dbVali('SELECT delete_at FROM users_shadow WHERE id =', \$u->{id});
debug_ $u;
sup if !($u->{user_uniname_can} && $u->{user_uniname});
+ for(@$old) {
+ br_;
+ small_ "Changed from '$_->{old}' on $_->{date}.";
+ }
};
};
tr_ sub {
@@ -35,7 +44,7 @@ sub _info_table_ {
tr_ sub {
td_ 'Edits';
td_ !$u->{c_changes} ? '-' : sub {
- a_ href => "/u$u->{id}/hist", $u->{c_changes}
+ a_ href => "/$u->{id}/hist", $u->{c_changes}
};
};
tr_ sub {
@@ -44,17 +53,25 @@ sub _info_table_ {
td_ 'Votes';
td_ !$num ? '-' : sub {
txt_ sprintf '%d vote%s, %.2f average. ', $num, $num == 1 ? '' : 's', $sum/$num/10;
- a_ href => "/u$u->{id}/ulist?votes=1", 'Browse votes »';
+ a_ href => "/$u->{id}/ulist?votes=1", 'Browse votes »';
}
};
+ my $lengthvotes = tuwf->dbRowi('SELECT count(*) AS count, sum(length) AS sum, bool_or(not private) as haspub FROM vn_length_votes WHERE uid =', \$u->{id});
+ tr_ sub {
+ td_ 'Play times';
+ td_ sub {
+ vnlength_ $lengthvotes->{sum};
+ txt_ sprintf ' from %d submitted play times. ', $lengthvotes->{count};
+ a_ href => "/$u->{id}/lengthvotes", 'Browse votes »' if $own || $lengthvotes->{haspub};
+ };
+ } if $lengthvotes->{count};
tr_ sub {
my $vns = tuwf->dbVali(
- 'SELECT COUNT(DISTINCT uvl.vid) FROM ulist_vns_labels uvl',
- $own ? () : ('JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl AND NOT ul.private'),
- 'WHERE uvl.lbl NOT IN(', \5, ',', \6, ') AND uvl.uid =', \$u->{id}
+ 'SELECT COUNT(vid) FROM ulist_vns
+ WHERE NOT (labels && ARRAY[', \5, ',', \6, ']::smallint[]) AND uid =', \$u->{id}, $own ? () : 'AND NOT c_private'
)||0;
my $privrel = $own ? '1=1' : 'EXISTS(
- SELECT 1 FROM releases_vn rv JOIN ulist_vns_labels uvl ON uvl.vid = rv.vid JOIN ulist_labels ul ON ul.id = uvl.lbl AND ul.uid = uvl.uid WHERE rv.id = r.rid AND uvl.uid = r.uid AND NOT ul.private
+ SELECT 1 FROM releases_vn rv JOIN ulist_vns uv ON uv.vid = rv.vid WHERE uv.uid = r.uid AND rv.id = r.rid AND NOT uv.c_private
)';
my $rel = tuwf->dbVali('SELECT COUNT(*) FROM rlists r WHERE', $privrel, 'AND r.uid =', \$u->{id})||0;
td_ 'List stats';
@@ -62,7 +79,15 @@ sub _info_table_ {
txt_ sprintf '%d release%s of %d visual novel%s. ',
$rel, $rel == 1 ? '' : 's',
$vns, $vns == 1 ? '' : 's';
- a_ href => "/u$u->{id}/ulist?vnlist=1", 'Browse list »';
+ a_ href => "/$u->{id}/ulist?vnlist=1", 'Browse list »';
+ };
+ };
+ tr_ sub {
+ my $cnt = tuwf->dbVali('SELECT COUNT(*) FROM reviews WHERE uid =', \$u->{id});
+ td_ 'Reviews';
+ td_ !$cnt ? '-' : sub {
+ txt_ sprintf '%d review%s. ', $cnt, $cnt == 1 ? '' : 's';
+ a_ href => "/w?u=$u->{id}", 'Browse reviews »';
};
};
tr_ sub {
@@ -77,15 +102,46 @@ sub _info_table_ {
};
};
tr_ sub {
- my $stats = tuwf->dbRowi('SELECT COUNT(*) AS posts, COUNT(*) FILTER (WHERE num = 1) AS threads FROM threads_posts WHERE uid =', \$u->{id});
+ td_ 'Images';
+ td_ sub {
+ txt_ sprintf '%d images flagged. ', $u->{c_imgvotes};
+ a_ href => "/img/list?u=$u->{id}", 'Browse image votes »';
+ };
+ } if $u->{c_imgvotes};
+ tr_ sub {
+ my $stats = tuwf->dbRowi('
+ SELECT COUNT(*) AS posts, COUNT(*) FILTER (WHERE num = 1) AS threads
+ FROM threads_posts tp
+ WHERE hidden IS NULL AND uid =', \$u->{id}, '
+ AND EXISTS(SELECT 1 FROM threads t WHERE t.id = tp.tid AND NOT t.hidden AND NOT t.private)');
+ $stats->{posts} += tuwf->dbVali('SELECT COUNT(*) FROM reviews_posts WHERE hidden IS NULL AND uid =', \$u->{id});
td_ 'Forum stats';
td_ !$stats->{posts} ? '-' : sub {
txt_ sprintf '%d post%s, %d new thread%s. ',
$stats->{posts}, $stats->{posts} == 1 ? '' : 's',
$stats->{threads}, $stats->{threads} == 1 ? '' : 's';
- a_ href => "/u$u->{id}/posts", 'Browse posts »';
+ a_ href => "/$u->{id}/posts", 'Browse posts »';
};
};
+ my $quotes = tuwf->dbVali('SELECT COUNT(*) FROM quotes WHERE addedby =', \$u->{id}, auth->permDbmod ? () : 'AND NOT hidden');
+ tr_ sub {
+ td_ 'Quotes';
+ td_ sub {
+ txt_ sprintf '%d quote%s submitted. ', $quotes, $quotes == 1 ? '' : 's';
+ a_ href => "/v/quotes?u=$u->{id}", 'Browse quotes »' if auth;
+ };
+ } if $quotes;
+
+ my $traits = tuwf->dbAlli('SELECT u.tid, t.name, g.id as "group", g.name AS groupname FROM users_traits u JOIN traits t ON t.id = u.tid LEFT JOIN traits g ON g.id = t.gid WHERE u.id =', \$u->{id}, 'ORDER BY g.gorder, t.name');
+ my @groups;
+ for (@$traits) {
+ push @groups, $_ if !@groups || $groups[$#groups]{group} ne $_->{group};
+ push $groups[$#groups]{traits}->@*, $_;
+ }
+ tr_ sub {
+ td_ class => 'key', sub { a_ href => "/$_->{group}", $_->{groupname} };
+ td_ sub { join_ ', ', sub { a_ href => "/$_->{tid}", $_->{name} }, $_->{traits}->@* };
+ } for @groups;
}
@@ -111,24 +167,21 @@ sub _votestats_ {
};
my $recent = tuwf->dbAlli('
- SELECT vn.id, vn.title, vn.original, uv.vote,', sql_totime('uv.vote_date'), 'AS date
+ SELECT v.id, v.title, uv.vote,', sql_totime('uv.vote_date'), 'AS date
FROM ulist_vns uv
- JOIN vn ON vn.id = uv.vid
- WHERE uv.vote IS NOT NULL AND uv.uid =', \$u->{id},
- $own ? () : (
- 'AND EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private)'
- ), '
+ JOIN', vnt, 'v ON v.id = uv.vid
+ WHERE uv.vote IS NOT NULL AND uv.uid =', \$u->{id}, $own ? () : ('AND NOT uv.c_private AND NOT v.hidden'), '
ORDER BY uv.vote_date DESC LIMIT', \8
);
table_ class => 'recentvotes stripe', sub {
thead_ sub { tr_ sub { td_ colspan => 3, sub {
txt_ 'Recent votes';
- b_ sub { txt_ ' ('; a_ href => "/u$u->{id}/ulist?votes=1", 'show all'; txt_ ')' };
+ span_ sub { txt_ '('; a_ href => "/$u->{id}/ulist?votes=1", 'show all'; txt_ ')' };
} } };
tr_ sub {
my $v = $_;
- td_ sub { a_ href => "/v$v->{id}", title => $v->{original}||$v->{title}, shorten $v->{title}, 30 };
+ td_ sub { a_ href => "/$v->{id}", tattr $v; };
td_ fmtvote $v->{vote};
td_ fmtdate $v->{date};
} for @$recent;
@@ -140,42 +193,41 @@ sub _votestats_ {
TUWF::get qr{/$RE{uid}}, sub {
my $u = tuwf->dbRowi(q{
- SELECT id, c_changes, c_votes, c_tags
+ SELECT id, c_changes, c_votes, c_tags, c_imgvotes
,}, sql_totime('registered'), q{ AS registered
,}, sql_user(), q{
FROM users u
WHERE id =}, \tuwf->capture('id')
);
- return tuwf->resNotFound if !$u->{id};
+ return tuwf->resNotFound if !$u->{id} || (!$u->{user_name} && !auth->isMod);
- my $own = (auth && auth->uid == $u->{id}) || auth->permUsermod;
+ my $own = (auth && auth->uid eq $u->{id}) || auth->permUsermod;
$u->{votes} = tuwf->dbAlli('
SELECT (uv.vote::numeric/10)::int AS idx, COUNT(uv.vote) as votes, SUM(uv.vote) AS total
FROM ulist_vns uv
- WHERE uv.vote IS NOT NULL AND uv.uid =', \$u->{id},
- $own ? () : (
- 'AND EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private)'
- ), '
+ WHERE uv.vote IS NOT NULL AND uv.uid =', \$u->{id}, $own ? () : 'AND NOT uv.c_private', '
GROUP BY (uv.vote::numeric/10)::int
');
my $title = user_displayname($u)."'s profile";
- framework_ title => $title, type => 'u', dbobj => $u,
- sub {
- div_ class => 'mainbox userpage', sub {
+ framework_ title => $title, dbobj => $u, sub {
+ article_ class => 'userpage', sub {
+ itemmsg_ $u;
h1_ $title;
table_ class => 'stripe', sub { _info_table_ $u, $own };
};
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Vote statistics';
div_ class => 'votestats', sub { _votestats_ $u, $own };
} if grep $_->{votes} > 0, $u->{votes}->@*;
if($u->{c_changes}) {
- h1_ class => 'boxtitle', sub { a_ href => "/u$u->{id}/hist", 'Recent changes' };
- VNWeb::Misc::History::tablebox_ u => $u->{id}, {p=>1}, nopage => 1, results => 10;
+ nav_ sub {
+ h1_ sub { a_ href => "/$u->{id}/hist", 'Recent changes' };
+ };
+ VNWeb::Misc::History::tablebox_ $u->{id}, {p=>1}, nopage => 1, nouser => 1, results => 10;
}
};
};
diff --git a/lib/VNWeb/User/PassReset.pm b/lib/VNWeb/User/PassReset.pm
index 39f1d6ea..45109f80 100644
--- a/lib/VNWeb/User/PassReset.pm
+++ b/lib/VNWeb/User/PassReset.pm
@@ -3,40 +3,56 @@ package VNWeb::User::PassReset;
use VNWeb::Prelude;
TUWF::get '/u/newpass' => sub {
- return tuwf->resRedirect('/', 'temp') if auth;
+ return tuwf->resRedirect('/', 'temp') if auth || config->{read_only};
framework_ title => 'Password reset', sub {
- elm_ 'User.PassReset';
+ div_ widget(UserPassReset => {}), '';
};
};
-elm_api UserPassReset => undef, {
+js_api UserPassReset => {
email => { email => 1 },
}, sub {
my $data = shift;
- my($id, $token) = auth->resetpass($data->{email});
- return elm_BadEmail if !$id;
+ # Throttle exists to prevent email sending abuse
+ my $ip = norm_ip tuwf->reqIP;
+ my $tm = tuwf->dbVali(
+ 'SELECT', sql_totime('greatest(timeout, now())'), 'FROM reset_throttle WHERE ip =', \$ip
+ ) || time;
+ return 'Too many password reset attempts, try again later.' if $tm-time() > config->{reset_throttle}[1];
- my $name = tuwf->dbVali('SELECT username FROM users WHERE id =', \$id);
- my $body = sprintf
+ my $upd = {ip => $ip, timeout => sql_fromtime $tm + config->{reset_throttle}[0]};
+ tuwf->dbExeci('INSERT INTO reset_throttle', $upd, 'ON CONFLICT (ip) DO UPDATE SET', $upd);
+
+ my($id, $mail, $token) = auth->resetpass($data->{email});
+ my $name = $id ? tuwf->dbVali('SELECT username FROM users WHERE id =', \$id) : $data->{email};
+ my $body = $id ? sprintf
"Hello %s,"
- ."\n\n"
- ."Your VNDB.org login has been disabled, you can now set a new password by following the link below:"
- ."\n\n"
- ."%s"
- ."\n\n"
- ."Now don't forget your password again! :-)"
- ."\n\n"
- ."vndb.org",
- $name, tuwf->reqBaseURI()."/u$id/setpass/$token";
+ ."\n"
+ ."\nYou can set a new password for your VNDB.org account by following the link below:"
+ ."\n"
+ ."\n%s"
+ ."\n"
+ ."\nNow don't forget your password again! :-)"
+ ."\n"
+ ."\nvndb.org",
+ $name, tuwf->reqBaseURI()."/$id/setpass/$token"
+ : "Hello,"
+ ."\n"
+ ."\nSomeone has requested a password reset for the VNDB account associated with this email address."
+ ."\nIf this was not done by you, feel free to ignore this email."
+ ."\n"
+ ."\nThere is no VNDB account associated with this email address, perhaps you used another address to sign up?"
+ ."\n"
+ ."\nvndb.org";
tuwf->mail($body,
- To => $data->{email},
+ To => $mail // $data->{email},
From => 'VNDB <noreply@vndb.org>',
Subject => "Password reset for $name",
);
- elm_Success
+ +{}
};
1;
diff --git a/lib/VNWeb/User/PassSet.pm b/lib/VNWeb/User/PassSet.pm
index cbb6c31f..13d6ba2f 100644
--- a/lib/VNWeb/User/PassSet.pm
+++ b/lib/VNWeb/User/PassSet.pm
@@ -2,18 +2,8 @@ package VNWeb::User::PassSet;
use VNWeb::Prelude;
-my $FORM = {
- uid => { id => 1 },
- token => { regex => qr/[a-f0-9]{40}/ },
- password => { _when => 'in', password => 1 },
-};
-
-my $FORM_IN = form_compile in => $FORM;
-my $FORM_OUT = form_compile out => $FORM;
-
-
TUWF::get qr{/$RE{uid}/setpass/(?<token>[a-f0-9]{40})}, sub {
- return tuwf->resRedirect('/', 'temp') if auth;
+ return tuwf->resRedirect('/', 'temp') if auth || config->{read_only};
my $id = tuwf->capture('id');
my $token = tuwf->capture('token');
@@ -22,21 +12,25 @@ TUWF::get qr{/$RE{uid}/setpass/(?<token>[a-f0-9]{40})}, sub {
return tuwf->resNotFound if !$name || !auth->isvalidtoken($id, $token);
framework_ title => 'Set password', sub {
- elm_ 'User.PassSet', $FORM_OUT, { uid => $id, token => $token };
+ div_ widget(UserPassSet => { uid => $id, token => $token }), '';
};
};
-elm_api UserPassSet => $FORM_OUT, $FORM_IN, sub {
+js_api UserPassSet => {
+ uid => { vndbid => 'u' },
+ token => { regex => qr/^[a-f0-9]{40}$/ },
+ password => { password => 1 },
+}, sub {
my($data) = @_;
- return elm_InsecurePass if is_insecurepass($data->{password});
- # "CSRF" is kind of wrong here, but the message advices to reload the page,
- # which will give a 404, which should be a good enough indication that the
- # token has expired. This case won't happen often.
- return elm_CSRF if !auth->setpass($data->{uid}, $data->{token}, undef, $data->{password});
+ return +{ insecure => 1, _err => 'Your new password is in a public database of leaked passwords, please choose a different password.' }
+ if is_insecurepass($data->{password});
+ return +{ _err => 'Invalid token.' }
+ if !auth->setpass($data->{uid}, $data->{token}, undef, $data->{password});
tuwf->dbExeci('UPDATE users SET email_confirmed = true WHERE id =', \$data->{uid});
- elm_Success
+ auth->audit($data->{uid}, 'password change', 'with email token');
+ +{ _redir => '/' }
};
1;
diff --git a/lib/VNWeb/User/Register.pm b/lib/VNWeb/User/Register.pm
index 2dd41e4e..85de3599 100644
--- a/lib/VNWeb/User/Register.pm
+++ b/lib/VNWeb/User/Register.pm
@@ -6,35 +6,64 @@ use VNWeb::Prelude;
TUWF::get '/u/register', sub {
return tuwf->resRedirect('/', 'temp') if auth;
framework_ title => 'Register', sub {
- elm_ 'User.Register';
+ if(global_settings->{lockdown_registration} || config->{read_only}) {
+ article_ sub {
+ h1_ 'Create an account';
+ p_ 'Account registration is temporarily disabled. Try again later.';
+ }
+ } else {
+ div_ widget('UserRegister'), '';
+ }
};
};
-elm_api UserRegister => undef, {
+js_api UserRegister => {
username => { username => 1 },
email => { email => 1 },
- vns => { int => 1 },
}, sub {
my $data = shift;
+ return 'Registration disabled.' if global_settings->{lockdown_registration};
- my $num = tuwf->dbVali("SELECT count FROM stats_cache WHERE section = 'vn'");
- return elm_Bot if $data->{vns} < $num*0.995 || $data->{vns} > $num*1.005;
- return elm_Taken if tuwf->dbVali('SELECT 1 FROM users WHERE username =', \$data->{username});
- return elm_DoubleEmail if tuwf->dbVali(select => sql_func user_emailexists => \$data->{email}, \undef);
+ return +{ err => 'username' } if !is_unique_username $data->{username};
+ # Throttle before checking for duplicate email, wouldn't want to be sending too many emails.
my $ip = tuwf->reqIP;
- return elm_DoubleIP if tuwf->dbVali(
- q{SELECT 1 FROM users WHERE registered >= NOW()-'1 day'::interval AND ip <<},
- $ip =~ /:/ ? \"$ip/48" : \"$ip/30"
- );
+ return 'You can only register one account from the same IP within 24 hours.'
+ if tuwf->dbVali('SELECT 1 FROM registration_throttle WHERE timeout > NOW() AND ip =', \norm_ip($ip));
+ my %throttle = (timeout => sql("NOW()+'1 day'::interval"), ip => norm_ip($ip));
+ tuwf->dbExeci('INSERT INTO registration_throttle', \%throttle, 'ON CONFLICT (ip) DO UPDATE SET', \%throttle);
+
+ # Check for opt-out. Returning 'ok' here sucks balls, but otherwise we'd be vulnerable to email enumeration.
+ return +{ ok => 1 } if tuwf->dbVali('SELECT email_optout_check(', \$data->{email}, ')');
+
+ # Check for duplicate email
+ my $dupe = tuwf->dbVali('SELECT u.username FROM users u, user_emailtoid(', \$data->{email}, ') x(id) WHERE x.id = u.id');
+ if (defined $dupe) {
+ tuwf->mail(
+ "Hello $data->{username},"
+ ."\n"
+ ."\nSomeone has attempted to register an account on VNDB.org with your email address,"
+ ."\nbut you already have an account on VNDB with the username '$dupe'."
+ ."\n"
+ ."\nIf you forgot your password, you can recover access to your account through the following link:"
+ ."\n".tuwf->reqBaseURI()."/u/newpass"
+ ."\n"
+ ."\nIf you don't remember creating an account on VNDB.org recently, please ignore this e-mail."
+ ."\n"
+ ."\nvndb.org",
+ To => $data->{email},
+ From => 'VNDB <noreply@vndb.org>',
+ Subject => "Duplicate registration for $data->{username}",
+ );
+ return +{ ok => 1 };
+ }
+
+ my $id = tuwf->dbVali('INSERT INTO users', {username => $data->{username}}, 'RETURNING id');
+ tuwf->dbExeci('INSERT INTO users_prefs', {id => $id});
+ tuwf->dbExeci('INSERT INTO users_shadow', {id => $id, ip => ipinfo(), mail => $data->{email}});
- my $id = tuwf->dbVali('INSERT INTO users', {
- username => $data->{username},
- mail => $data->{email},
- ip => $ip,
- }, 'RETURNING id');
- my(undef, $token) = auth->resetpass($data->{email});
+ my(undef, undef, $token) = auth->resetpass($data->{email});
my $body = sprintf
"Hello %s,"
@@ -46,14 +75,14 @@ elm_api UserRegister => undef, {
."If you don't remember creating an account on VNDB.org recently, please ignore this e-mail."
."\n\n"
."vndb.org",
- $data->{username}, tuwf->reqBaseURI()."/u$id/setpass/$token";
+ $data->{username}, tuwf->reqBaseURI()."/$id/setpass/$token";
tuwf->mail($body,
To => $data->{email},
From => 'VNDB <noreply@vndb.org>',
Subject => "Confirm registration for $data->{username}",
);
- elm_Success
+ +{ ok => 1 }
};
1;
diff --git a/lib/VNWeb/VN/Edit.pm b/lib/VNWeb/VN/Edit.pm
new file mode 100644
index 00000000..6c8a5f16
--- /dev/null
+++ b/lib/VNWeb/VN/Edit.pm
@@ -0,0 +1,239 @@
+package VNWeb::VN::Edit;
+
+use VNWeb::Prelude;
+use VNWeb::Images::Lib 'enrich_image';
+use VNWeb::Releases::Lib;
+
+
+my $FORM = {
+ id => { default => undef, vndbid => 'v' },
+ titles => { minlength => 1, sort_keys => 'lang', aoh => {
+ lang => { enum => \%LANGUAGE },
+ title => { sl => 1, maxlength => 250 },
+ latin => { default => undef, sl => 1, maxlength => 250 },
+ official => { anybool => 1 },
+ } },
+ alias => { default => '', maxlength => 500 },
+ description=> { default => '', maxlength => 10240 },
+ devstatus => { uint => 1, enum => \%DEVSTATUS },
+ olang => { default => 'ja', enum => \%LANGUAGE },
+ length => { uint => 1, enum => \%VN_LENGTH },
+ l_wikidata => { default => undef, uint => 1, max => (1<<31)-1 },
+ l_renai => { default => '', sl => 1, maxlength => 100 },
+ relations => { sort_keys => 'vid', aoh => {
+ vid => { vndbid => 'v' },
+ relation => { enum => \%VN_RELATION },
+ official => { anybool => 1 },
+ title => { _when => 'out' },
+ } },
+ anime => { sort_keys => 'aid', aoh => {
+ aid => { id => 1 },
+ title => { _when => 'out' },
+ original => { _when => 'out', default => '' },
+ } },
+ image => { default => undef, vndbid => 'cv' },
+ image_info => { _when => 'out', default => undef, type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
+ editions => { sort_keys => 'eid', aoh => {
+ eid => { uint => 1, max => 500 },
+ lang => { default => undef, language => 1 },
+ name => { sl => 1 },
+ official => { anybool => 1 },
+ } },
+ staff => { sort_keys => ['aid','eid','role'], aoh => {
+ aid => { id => 1 },
+ eid => { default => undef, uint => 1 },
+ role => { enum => \%CREDIT_TYPE },
+ note => { default => '', sl => 1, maxlength => 250 },
+ id => { _when => 'out', vndbid => 's' },
+ title => { _when => 'out' },
+ alttitle => { _when => 'out' },
+ } },
+ seiyuu => { sort_keys => ['aid','cid'], aoh => {
+ aid => { id => 1 },
+ cid => { vndbid => 'c' },
+ note => { default => '', sl => 1, maxlength => 250 },
+ # Staff info
+ id => { _when => 'out', vndbid => 's' },
+ title => { _when => 'out' },
+ alttitle => { _when => 'out' },
+ } },
+ screenshots=> { sort_keys => 'scr', aoh => {
+ scr => { vndbid => 'sf' },
+ rid => { default => undef, vndbid => 'r' },
+ info => { _when => 'out', type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
+ } },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+
+ authmod => { _when => 'out', anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+ releases => { _when => 'out', $VNWeb::Elm::apis{Releases}[0]->%* },
+ reltitles => { _when => 'out', aoh => { id => { vndbid => 'r' }, title => {} } },
+ chars => { _when => 'out', aoh => {
+ id => { vndbid => 'c' },
+ title => {},
+ alttitle => {},
+ } },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_CMP = form_compile cmp => $FORM;
+
+
+TUWF::get qr{/$RE{vrev}/edit} => sub {
+ my $e = db_entry tuwf->captures('id', 'rev') or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit v => $e;
+
+ $e->{authmod} = auth->permDbmod;
+ $e->{editsum} = $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
+
+ $e->{titles} = [ sort { $a->{lang} cmp $b->{lang} } $e->{titles}->@* ];
+ if($e->{image}) {
+ $e->{image_info} = { id => $e->{image} };
+ enrich_image 0, [$e->{image_info}];
+ } else {
+ $e->{image_info} = undef;
+ }
+ $_->{info} = {id=>$_->{scr}} for $e->{screenshots}->@*;
+ enrich_image 0, [map $_->{info}, $e->{screenshots}->@*];
+
+ enrich_merge vid => sql('SELECT id AS vid, title[1+1] AS title FROM', vnt, 'v WHERE id IN'), $e->{relations};
+ enrich_merge aid => 'SELECT id AS aid, title_romaji AS title, COALESCE(title_kanji, \'\') AS original FROM anime WHERE id IN', $e->{anime};
+
+ enrich_merge aid => sql('SELECT id, aid, title[1+1], title[1+1+1+1] AS alttitle, sorttitle FROM', staff_aliast, 's WHERE aid IN'), $e->{staff}, $e->{seiyuu};
+
+ # It's possible for older revisions to link to aliases that have been removed.
+ # Let's exclude those to make sure the form will at least load.
+ $e->{staff} = [ grep $_->{id}, $e->{staff}->@* ];
+ $e->{seiyuu} = [ grep $_->{id}, $e->{seiyuu}->@* ];
+
+ my %CRED;
+ $CRED{$_} = keys %CRED for keys %CREDIT_TYPE;
+ $e->{staff} = [ sort { $CRED{$a->{role}} <=> $CRED{$b->{role}} || $a->{sorttitle} cmp $b->{sorttitle} || $a->{aid} <=> $b->{aid} } $e->{staff}->@* ];
+ $e->{editions} = [ sort { ($a->{lang}||'') cmp ($b->{lang}||'') || $b->{official} cmp $a->{official} || $a->{name} cmp $b->{name} } $e->{editions}->@* ];
+
+ $e->{releases} = releases_by_vn $e->{id};
+ $e->{reltitles} = tuwf->dbAlli('
+ SELECT DISTINCT r.id, i.title
+ FROM releases r
+ JOIN releases_vn rv ON rv.id = r.id
+ JOIN releases_titles rt ON rt.id = r.id
+ JOIN unnest(ARRAY[rt.title,rt.latin]) i(title) ON i.title IS NOT NULL
+ WHERE NOT r.hidden AND rv.vid =', \$e->{id}
+ );
+
+ $e->{chars} = tuwf->dbAlli('
+ SELECT id, title[1+1], title[1+1+1+1] AS alttitle FROM', charst, '
+ WHERE NOT hidden AND id IN(SELECT id FROM chars_vns WHERE vid =', \$e->{id},')
+ ORDER BY sorttitle, id'
+ );
+
+ my $title = titleprefs_obj $e->{olang}, $e->{titles};
+ framework_ title => "Edit $title->[1]", dbobj => $e, tab => 'edit',
+ sub {
+ editmsg_ v => $e, "Edit $title->[1]";
+ elm_ VNEdit => $FORM_OUT, $e;
+ };
+};
+
+
+TUWF::get qr{/v/add}, sub {
+ return tuwf->resDenied if !can_edit v => undef;
+
+ framework_ title => 'Add visual novel',
+ sub {
+ editmsg_ v => undef, 'Add visual novel';
+ elm_ VNEdit => $FORM_OUT, elm_empty($FORM_OUT);
+ };
+};
+
+
+elm_api VNEdit => $FORM_OUT, $FORM_IN, sub {
+ my $data = shift;
+ my $new = !$data->{id};
+ my $e = $new ? { id => 0 } : db_entry $data->{id} or return tuwf->resNotFound;
+ return elm_Unauth if !can_edit v => $e;
+
+ if(!auth->permDbmod) {
+ $data->{hidden} = $e->{hidden}||0;
+ $data->{locked} = $e->{locked}||0;
+ }
+ $data->{description} = bb_subst_links $data->{description};
+ $data->{alias} =~ s/\n\n+/\n/;
+ die "No title in original language" if !length [grep $_->{lang} eq $data->{olang}, $data->{titles}->@*]->[0]{title};
+
+ validate_dbid 'SELECT id FROM anime WHERE id IN', map $_->{aid}, $data->{anime}->@*;
+ validate_dbid 'SELECT id FROM images WHERE id IN', $data->{image} if $data->{image};
+ validate_dbid 'SELECT id FROM images WHERE id IN', map $_->{scr}, $data->{screenshots}->@*;
+ validate_dbid 'SELECT aid FROM staff_alias WHERE aid IN', map $_->{aid}, $data->{staff}->@*;
+ validate_dbid 'SELECT aid FROM staff_alias WHERE aid IN', map $_->{aid}, $data->{seiyuu}->@*;
+
+ # Drop unused staff editions
+ my %editions = map defined $_->{eid} ? +($_->{eid},1) : (), $data->{staff}->@*;
+ $data->{editions} = [ grep $editions{$_->{eid}}, $data->{editions}->@* ];
+
+ $data->{relations} = [] if $data->{hidden};
+ validate_dbid 'SELECT id FROM vn WHERE id IN', map $_->{vid}, $data->{relations}->@*;
+ die "Relation with self" if grep $_->{vid} eq $e->{id}, $data->{relations}->@*;
+
+ die "Screenshot without releases assigned" if grep !$_->{rid}, $data->{screenshots}->@*; # This is only the case for *very* old revisions, form disallows this now.
+ # Allow linking to deleted or moved releases only if the previous revision also had that.
+ # (The form really should encourage the user to fix that, but disallowing the edit seems a bit overkill)
+ validate_dbid sub { '
+ SELECT r.id FROM releases r JOIN releases_vn rv ON r.id = rv.id WHERE NOT r.hidden AND rv.vid =', \$e->{id}, ' AND r.id IN', $_, '
+ UNION
+ SELECT rid FROM vn_screenshots WHERE id =', \$e->{id}, 'AND rid IN', $_
+ }, map $_->{rid}, $data->{screenshots}->@*;
+
+ # Likewise, allow linking to deleted or moved characters.
+ validate_dbid sub { '
+ SELECT c.id FROM chars c JOIN chars_vns cv ON c.id = cv.id WHERE NOT c.hidden AND cv.vid =', \$e->{id}, ' AND c.id IN', $_, '
+ UNION
+ SELECT cid FROM vn_seiyuu WHERE id =', \$e->{id}, 'AND cid IN', $_
+ }, map $_->{cid}, $data->{seiyuu}->@*;
+
+ $data->{image_nsfw} = $e->{image_nsfw}||0;
+ my %oldscr = map +($_->{scr}, $_->{nsfw}), @{ $e->{screenshots}||[] };
+ $_->{nsfw} = $oldscr{$_->{scr}}||0 for $data->{screenshots}->@*;
+
+ return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
+ my $ch = db_edit v => $e->{id}, $data;
+ update_reverse($ch->{nitemid}, $ch->{nrev}, $e, $data);
+ elm_Redirect "/$ch->{nitemid}.$ch->{nrev}";
+};
+
+
+sub update_reverse {
+ my($id, $rev, $old, $new) = @_;
+
+ my %old = map +($_->{vid}, $_), $old->{relations} ? $old->{relations}->@* : ();
+ my %new = map +($_->{vid}, $_), $new->{relations}->@*;
+
+ # Updates to be performed, vid => { vid => x, relation => y, official => z } or undef if the relation should be removed.
+ my %upd;
+
+ for my $i (keys %old, keys %new) {
+ if($old{$i} && !$new{$i}) {
+ $upd{$i} = undef;
+ } elsif(!$old{$i} || $old{$i}{relation} ne $new{$i}{relation} || !$old{$i}{official} != !$new{$i}{official}) {
+ $upd{$i} = {
+ vid => $id,
+ relation => $VN_RELATION{ $new{$i}{relation} }{reverse},
+ official => $new{$i}{official}
+ };
+ }
+ }
+
+ for my $i (keys %upd) {
+ my $v = db_entry $i;
+ $v->{relations} = [
+ $upd{$i} ? $upd{$i} : (),
+ grep $_->{vid} ne $id, $v->{relations}->@*
+ ];
+ $v->{editsum} = "Reverse relation update caused by revision $id.$rev";
+ db_edit v => $i, $v, 'u1';
+ }
+}
+
+1;
diff --git a/lib/VNWeb/VN/Elm.pm b/lib/VNWeb/VN/Elm.pm
new file mode 100644
index 00000000..e3486049
--- /dev/null
+++ b/lib/VNWeb/VN/Elm.pm
@@ -0,0 +1,37 @@
+package VNWeb::VN::Elm;
+
+use VNWeb::Prelude;
+
+elm_api VN => undef, {
+ search => { type => 'array', values => { searchquery => 1 } },
+ hidden => { anybool => 1 },
+}, sub {
+ my($data) = @_;
+ my @q = grep $_, $data->{search}->@*;
+
+ elm_VNResult @q ? tuwf->dbPagei({ results => $data->{hidden}?50:15, page => 1 },
+ 'SELECT v.id, v.title[1+1] AS title, v.hidden
+ FROM', vnt, 'v', VNWeb::Validate::SearchQuery::sql_joina(\@q, 'v', 'v.id'),
+ $data->{hidden} ? () : 'WHERE NOT v.hidden', '
+ ORDER BY sc.score DESC, v.sorttitle
+ ') : [];
+};
+
+
+js_api VN => {
+ search => { type => 'array', values => { searchquery => 1 } },
+ hidden => { anybool => 1 },
+}, sub {
+ my($data) = @_;
+ my @q = grep $_, $data->{search}->@*;
+
+ +{ results => @q ? tuwf->dbAlli(
+ 'SELECT v.id, v.title[1+1] AS title, v.hidden
+ FROM', vnt, 'v', VNWeb::Validate::SearchQuery::sql_joina(\@q, 'v', 'v.id'),
+ $data->{hidden} ? () : 'WHERE NOT v.hidden', '
+ ORDER BY sc.score DESC, v.sorttitle
+ LIMIT', \50
+ ) : [] };
+};
+
+1;
diff --git a/lib/VNWeb/VN/Graph.pm b/lib/VNWeb/VN/Graph.pm
new file mode 100644
index 00000000..e1cabbe9
--- /dev/null
+++ b/lib/VNWeb/VN/Graph.pm
@@ -0,0 +1,143 @@
+package VNWeb::VN::Graph;
+
+use VNWeb::Prelude;
+use VNWeb::Graph;
+use VNWeb::Images::Lib 'enrich_image_obj';
+
+
+TUWF::get qr{/$RE{vid}/rg}, sub {
+ my $id = tuwf->capture(1);
+ my $num = tuwf->validate(get => num => { uint => 1, onerror => 15 })->data;
+ my $unoff = tuwf->validate(get => unoff => { default => 1, anybool => 1 })->data;
+ my $v = dbobj $id;
+
+ my $has = tuwf->dbRowi('SELECT bool_or(official) AS official, bool_or(not official) AS unofficial FROM vn_relations WHERE id =', \$id, 'GROUP BY id');
+ $unoff = 1 if !$has->{official};
+
+ # Big list of { id0, id1, relation } hashes.
+ # Each relation is included twice, with id0 and id1 reversed.
+ my $where = $unoff ? '1=1' : 'vr.official';
+ my $rel = tuwf->dbAlli(q{
+ WITH RECURSIVE rel(id0, id1, relation, official) AS (
+ SELECT id, vid, relation, official FROM vn_relations vr WHERE id =}, \$id, 'AND', $where, q{
+ UNION
+ SELECT id, vid, vr.relation, vr.official FROM vn_relations vr JOIN rel r ON vr.id = r.id1 WHERE}, $where, q{
+ ) SELECT * FROM rel ORDER BY id0
+ });
+ return tuwf->resNotFound if !@$rel;
+
+ # Fetch the nodes
+ my $nodes = gen_nodes $id, $rel, $num;
+ enrich_merge id => sql("SELECT id, title[1+1] AS title, c_released, array_to_string(c_languages, '/') AS lang FROM", vnt, "v WHERE id IN"), values %$nodes;
+
+ my $total_nodes = keys { map +($_->{id0},1), @$rel }->%*;
+ my $visible_nodes = keys %$nodes;
+
+ my @lines;
+ my $params = "?num=$num&unoff=$unoff";
+ for my $n (sort { idcmp $a->{id}, $b->{id} } values %$nodes) {
+ my $title = val_escape shorten $n->{title}, 27;
+ my $tooltip = val_escape $n->{title};
+ my $date = rdate $n->{c_released};
+ my $lang = $n->{lang}||'N/A';
+ my $nodeid = $n->{distance} == 0 ? 'id = "graph_current", ' : '';
+ push @lines,
+ qq|n$n->{id} [ $nodeid URL = "/$n->{id}", tooltip = "$tooltip", label=<|.
+ qq|<TABLE CELLSPACING="0" CELLPADDING="2" BORDER="0" CELLBORDER="1" BGCOLOR="#222222">|.
+ qq|<TR><TD COLSPAN="2" ALIGN="CENTER" CELLPADDING="3"><FONT POINT-SIZE="9"> $title </FONT></TD></TR>|.
+ qq|<TR><TD> $date </TD><TD> $lang </TD></TR>|.
+ qq|</TABLE>> ]|;
+
+ push @lines, node_more $n->{id}, "/$n->{id}/rg$params", scalar grep !$nodes->{$_}, $n->{rels}->@*;
+ }
+
+ $rel = [ grep $nodes->{$_->{id0}} && $nodes->{$_->{id1}}, @$rel ];
+ my $dot = gen_dot \@lines, $nodes, $rel, \%VN_RELATION;
+
+ framework_ title => "Relations for $v->{title}[1]", dbobj => $v, tab => 'rg',
+ sub {
+ article_ class => 'relgraph', sub {
+ h1_ "Relations for $v->{title}[1]";
+ a_ href => "/$v->{id}/rgi", 'Interactive graph »';
+ p_ sub {
+ txt_ sprintf "Displaying %d out of %d related visual novels.", $visible_nodes, $total_nodes;
+ debug_ +{ nodes => $nodes, rel => $rel };
+ br_;
+ if($has->{official}) {
+ if($unoff) {
+ txt_ 'Show / ';
+ a_ href => "?num=$num&unoff=0", 'Hide';
+ } else {
+ a_ href => "?num=$num&unoff=1", 'Show';
+ txt_ ' / Hide';
+ }
+ txt_ ' unofficial relations. ';
+ br_;
+ }
+ if($total_nodes > 10) {
+ txt_ 'Adjust graph size: ';
+ join_ ', ', sub {
+ if($_ == min $num, $total_nodes) {
+ txt_ $_ ;
+ } else {
+ a_ href => "/$id/rg?num=$_", $_;
+ }
+ }, grep($_ < $total_nodes, 10, 15, 25, 50, 75, 100, 150, 250, 500, 750, 1000), $total_nodes;
+ }
+ txt_ '.';
+ } if $total_nodes > 10 || $has->{unofficial};
+ p_ class => 'center', sub { lit_ dot2svg $dot };
+ };
+ clearfloat_;
+ };
+};
+
+
+TUWF::get qr{/$RE{vid}/rgi}, sub {
+ my $v = dbobj tuwf->capture(1);
+
+ # Big list of { id0, id1, relation, official } hashes.
+ # Each relation is included twice, with id0 and id1 reversed.
+ my $rel = tuwf->dbAlli(q{
+ WITH RECURSIVE rel(id0, id1, relation, official) AS (
+ SELECT id, vid, relation, official FROM vn_relations vr WHERE id =}, \$v->{id}, q{
+ UNION
+ SELECT id, vid, vr.relation, vr.official FROM vn_relations vr JOIN rel r ON vr.id = r.id1
+ ) SELECT * FROM rel ORDER BY id0
+ });
+ return tuwf->resNotFound if !@$rel;
+
+ # Get rid of duplicate relations and convert to a more efficient array-based format.
+ # For directional relations, keep the one that is preferred ("pref"), for unidirectional relations, keep the one with the lowest id0.
+ $rel = [
+ map [ @{$_}{qw/ id0 id1 relation official /} ],
+ grep $VN_RELATION{$_->{relation}}{pref} || ($VN_RELATION{$_->{relation}}{reverse} eq $_->{relation} && idcmp($_->{id0}, $_->{id1}) < 0), @$rel
+ ];
+
+ # Fetch the nodes
+ my %nodes = map +($_, {id => $_}), map @{$_}[0,1], @$rel;
+ enrich_merge id => sql("
+ SELECT id, title[1+1] AS title, title[1+1+1+1] AS alttitle, c_released AS released, image, c_languages::text[] AS languages
+ FROM", vnt, "v WHERE id IN"
+ ), values %nodes;
+ enrich_image_obj image => values %nodes;
+
+ # compress image info a bit
+ $_->{image} = $_->{image} && [imgurl($_->{image}{id}), $_->{image}{sexual}, $_->{image}{violence}] for values %nodes;
+
+ framework_ title => "Relations for $v->{title}[1]", dbobj => $v, tab => 'rg',
+ sub {
+ article_ sub {
+ h1_ "Relations for $v->{title}[1]";
+ div_ widget(VNGraph => {
+ sexual => 0+(auth->pref('max_sexual')||0),
+ violence => 0+(auth->pref('max_violence')||0),
+ main => $v->{id},
+ nodes => [values %nodes],
+ rels => $rel,
+ }), ''
+ }
+ };
+};
+
+1;
diff --git a/lib/VNWeb/VN/Length.pm b/lib/VNWeb/VN/Length.pm
new file mode 100644
index 00000000..eb291665
--- /dev/null
+++ b/lib/VNWeb/VN/Length.pm
@@ -0,0 +1,213 @@
+package VNWeb::VN::Length;
+
+use VNWeb::Prelude;
+
+# Also used from VN::Page
+sub can_vote { auth->permDbmod || (auth->permLengthvote && !global_settings->{lockdown_edit}) }
+
+sub opts {
+ my($mode) = @_;
+ tableopts
+ date => { name => 'Date', sort_id => 0, sort_sql => 'l.date', sort_default => 'desc' },
+ length => { name => 'Time', sort_id => 1, sort_sql => 'l.length' },
+ speed => { name => 'Speed', sort_id => 2, sort_sql => 'l.speed ?o NULLS LAST, l.length' },
+ $mode ne 'u' ? (
+ username => { name => 'User', sort_id => 3, sort_sql => 'u.username' } ) : (),
+ $mode ne 'v' ? (
+ title => { name => 'Title', sort_id => 4, sort_sql => 'v.sorttitle' } ) : ()
+}
+my %TABLEOPTS = map +($_, opts $_), '', 'v', 'u';
+
+
+sub listing_ {
+ my($opt, $url, $count, $list, $mode) = @_;
+
+ if(auth->permDbmod) {
+ form_ method => 'post', action => '/lengthvotes-edit';
+ input_ type => 'hidden', class => 'hidden', name => 'url', value => tuwf->reqPath.tuwf->reqQuery, undef;
+ }
+
+ paginate_ $url, $opt->{p}, [$count, $opt->{s}->results], 't';
+ article_ class => 'browse lengthlist', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'date', $opt, $url };
+ td_ class => 'tc2', sub { txt_ 'User'; sortable_ 'username', $opt, $url } if $mode ne 'u';
+ td_ class => 'tc2', sub { txt_ 'Title'; sortable_ 'title', $opt, $url } if $mode ne 'v';
+ td_ class => 'tc3', sub { txt_ 'Time'; sortable_ 'length', $opt, $url };
+ td_ class => 'tc4', sub { txt_ 'Speed'; sortable_ 'speed', $opt, $url };
+ td_ class => 'tc5', 'Rel';
+ td_ class => 'tc6', 'Notes';
+ td_ class => 'tc7', sub {
+ input_ type => 'submit', class => 'submit', value => 'Update', undef;
+ } if auth->permDbmod;
+ } };
+ tr_ sub {
+ td_ class => 'tc1', fmtdate $_->{date};
+ td_ class => 'tc2', sub { user_ $_ } if $mode ne 'u';
+ td_ class => 'tc2', sub {
+ a_ href => "/$_->{vid}", tattr $_;
+ } if $mode ne 'v';
+ td_ class => 'tc3'.($_->{ignore}?' grayedout':''), sub { vnlength_ $_->{length} };
+ td_ class => 'tc4'.($_->{ignore}?' grayedout':''), ['Slow','Normal','Fast','-']->[$_->{speed}//3];
+ td_ class => 'tc5', sub {
+ my %l = map +($_,1), map $_->{lang}->@*, $_->{rel}->@*;
+ abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' for sort keys %l;
+ join_ ',', sub { a_ href => "/$_->{id}", $_->{id} }, sort { idcmp $a->{id}, $b->{id} } $_->{rel}->@*;
+ };
+ td_ class => 'tc6'.($_->{ignore}?' grayedout':''), sub {
+ small_ '(private) ' if $_->{private};
+ lit_ bb_format $_->{notes}, inline => 1;
+ };
+ td_ class => 'tc7', sub {
+ select_ name => "lv$_->{id}", sub {
+ option_ value => '', '--';
+ option_ value => 's0', 'slow';
+ option_ value => 's1', 'normal';
+ option_ value => 's2', 'fast';
+ option_ value => 'sn', 'uncounted';
+ };
+ } if auth->permDbmod;
+ } for @$list;
+ };
+ };
+ paginate_ $url, $opt->{p}, [$count, $opt->{s}->results], 'b';
+
+ end_ 'form' if auth->permDbmod;
+}
+
+
+sub stats_ {
+ my($o) = @_;
+ my $stats = tuwf->dbAlli('
+ SELECT speed, count(*) as count, avg(l.length) as avg
+ , stddev_pop(l.length::real)::int as stddev
+ , percentile_cont(', \0.5, ') WITHIN GROUP (ORDER BY l.length) AS median
+ FROM vn_length_votes l
+ LEFT JOIN users u ON u.id = l.uid
+ WHERE u.perm_lengthvote IS DISTINCT FROM false AND l.speed IS NOT NULL AND NOT l.private AND l.vid =', \$o->{id}, '
+ GROUP BY GROUPING SETS ((speed),()) ORDER BY speed'
+ );
+ return if !$stats->[0]{count};
+
+ table_ style => 'margin: 0 auto', sub {
+ thead_ sub { tr_ sub {
+ td_ 'Speed';
+ td_ 'Median';
+ td_ 'Average';
+ td_ 'Stddev';
+ td_ '# Votes';
+ } };
+ tr_ sub {
+ td_ ['Slow', 'Normal', 'Fast', 'Total']->[$_->{speed}//3];
+ td_ sub { vnlength_ $_->{median} };
+ td_ sub { vnlength_ $_->{avg} };
+ td_ sub { vnlength_ $_->{stddev} if $_->{stddev} };
+ td_ $_->{count};
+ } for @$stats;
+ };
+}
+
+
+TUWF::get qr{/(?:(?<thing>$RE{vid}|$RE{uid})/)?lengthvotes}, sub {
+ my $thing = tuwf->capture('thing');
+ my $o = $thing && dbobj $thing;
+ return tuwf->resNotFound if $thing && (!$o->{id} || ($o->{entry_hidden} && !auth->isMod));
+ my $mode = !$thing ? '' : $o->{id} =~ /^v/ ? 'v' : 'u';
+
+ my $opt = tuwf->validate(get =>
+ ign => { default => undef, enum => [0,1] },
+ p => { page => 1 },
+ s => { tableopts => $TABLEOPTS{$mode} },
+ )->data;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ my $where = sql_and
+ $mode ? sql($mode eq 'v' ? 'l.vid =' : 'l.uid =', \$o->{id}) : (),
+ $mode eq 'u' && auth && $o->{id} eq auth->uid ? () : 'NOT l.private',
+ defined $opt->{ign} ? sql('l.speed IS', $opt->{ign} ? 'NULL' : 'NOT NULL') : ();
+ my $count = tuwf->dbVali('SELECT COUNT(*) FROM vn_length_votes l WHERE', $where);
+
+ my $lst = tuwf->dbPagei({results => $opt->{s}->results, page => $opt->{p}},
+ 'SELECT l.id, l.uid, l.vid, l.length, l.speed, l.notes, l.private, l.rid::text[] AS rel, '
+ , sql_totime('l.date'), 'AS date, u.perm_lengthvote IS NOT DISTINCT FROM false AS ignore',
+ $mode ne 'u' ? (', ', sql_user()) : (),
+ $mode ne 'v' ? ', v.title' : (), '
+ FROM vn_length_votes l
+ LEFT JOIN users u ON u.id = l.uid',
+ $mode ne 'v' ? ('JOIN', vnt, 'v ON v.id = l.vid') : (),
+ 'WHERE', $where,
+ 'ORDER BY', $opt->{s}->sql_order(),
+ );
+ $_->{rel} = [ map +{ id => $_ }, $_->{rel}->@* ] for @$lst;
+ enrich_flatten lang => id => id => 'SELECT id, lang FROM releases_titles WHERE id IN', map $_->{rel}, @$lst;
+
+ my $title = 'Length votes'.($mode ? ($mode eq 'v' ? ' for ' : ' by ').$o->{title}[1] : '');
+ framework_ title => $title, dbobj => $o, sub {
+ article_ sub {
+ h1_ $title;
+ p_ 'Nothing to list. :(' if !@$lst;
+ stats_ $o if $mode eq 'v' && @$lst;
+ p_ class => 'browseopts', sub {
+ a_ href => url(p => undef, ign => undef), class => defined $opt->{ign} ? undef : 'optselected', 'All';
+ a_ href => url(p => undef, ign => 0), class => defined $opt->{ign} && !$opt->{ign} ? 'optselected' : undef, 'Active';
+ a_ href => url(p => undef, ign => 1), class => defined $opt->{ign} && $opt->{ign} ? 'optselected' : undef, 'Ignored';
+ } if auth->permDbmod;
+ };
+ listing_ $opt, \&url, $count, $lst, $mode if @$lst;
+ };
+};
+
+
+TUWF::post '/lengthvotes-edit', sub {
+ return tuwf->resDenied if !auth->permDbmod || !samesite;
+
+ my @actions;
+ for my $k (tuwf->reqPosts) {
+ next if $k !~ /^lv$RE{num}$/;
+ my $id = $+{num};
+ my $act = tuwf->reqPost($k);
+ next if !$act;
+ my $r = tuwf->dbRowi('
+ UPDATE vn_length_votes SET',
+ $act eq 'sn' ? 'speed = NULL' :
+ $act eq 's0' ? 'speed = 0' :
+ $act eq 's1' ? 'speed = 1' :
+ $act eq 's2' ? ('speed =', \2) : die,
+ 'WHERE id =', \$id, 'RETURNING vid, uid'
+ );
+ push @actions, "$r->{vid}-".($r->{uid}//'anon')."-$act";
+ }
+ auth->audit(undef, 'lengthvote edit', join ', ', sort @actions) if @actions;
+ tuwf->resRedirect(tuwf->reqPost('url'), 'post');
+};
+
+
+our $LENGTHVOTE = form_compile any => {
+ uid => { vndbid => 'u' },
+ vid => { vndbid => 'v' },
+ maycount => { anybool => 1 },
+ vote => { type => 'hash', default => undef, keys => {
+ rid => { type => 'array', minlength => 1, values => { vndbid => 'r' } },
+ length => { uint => 1, range => [1,26159] }, # 435h59m, largest round-ish number where the 'fast' speed adjustment doesn't overflow a smallint
+ speed => { default => undef, uint => 1, enum => [0,1,2] },
+ private => { anybool => 1 },
+ notes => { default => '' },
+ } },
+};
+
+elm_api VNLengthVote => undef, $LENGTHVOTE, sub {
+ my($data) = @_;
+ return elm_Unauth if !can_vote() || $data->{uid} ne auth->uid;
+ my %where = ( uid => $data->{uid}, vid => $data->{vid} );
+ tuwf->dbExeci('DELETE FROM vn_length_votes WHERE', \%where) if !$data->{vote};
+ $data->{vote}{rid} = sql sql_array($data->{vote}{rid}->@*), '::vndbid[]' if $data->{vote};
+ tuwf->dbExeci(
+ 'INSERT INTO vn_length_votes', { %where, $data->{vote}->%* },
+ 'ON CONFLICT (uid, vid) DO UPDATE SET', $data->{vote}
+ ) if $data->{vote};
+ return elm_Success;
+};
+
+1;
diff --git a/lib/VNWeb/VN/List.pm b/lib/VNWeb/VN/List.pm
new file mode 100644
index 00000000..42891f81
--- /dev/null
+++ b/lib/VNWeb/VN/List.pm
@@ -0,0 +1,450 @@
+package VNWeb::VN::List;
+
+use VNWeb::Prelude;
+use VNWeb::AdvSearch;
+use VNWeb::Filters;
+use VNWeb::Images::Lib;
+use VNWeb::ULists::Lib;
+use VNWeb::TT::Lib 'tagscore_';
+
+# Returns the tableopts config for:
+# - this VN list ('vn')
+# - this VN list with a search query ('vns')
+# - the VN listing on tags ('tags')
+# - a user's VN list ('ulist')
+# The latter has different numeric identifiers, a sad historical artifact. :(
+sub TABLEOPTS {
+ my $tags = $_[0] eq 'tags';
+ my $vns = $_[0] eq 'vns';
+ my $vn = $vns || $_[0] eq 'vn';
+ my $ulist = $_[0] eq 'ulist';
+ die if !$tags && !$vn && !$ulist;
+
+ # Old popularity column:
+ # sort_id => $ulist ? 14 : 3,
+ # vis_id => $ulist ? 11 : 0,
+ tableopts
+ _pref => $tags ? 'tableopts_vt' : $vn ? 'tableopts_v' : undef,
+ _views => ['rows', 'cards', 'grid'],
+ $tags ? (tagscore => {
+ name => 'Tag score',
+ compat => 'tagscore',
+ sort_id => 0,
+ sort_sql => 'tvi.rating ?o, v.sorttitle',
+ sort_default => 'desc',
+ sort_num => 1,
+ }) : (),
+ $vns ? (qscore => {
+ name => 'Relevance',
+ sort_id => 0,
+ sort_sql => 'sc.score !o, v.sorttitle',
+ sort_default => 'asc',
+ sort_num => 1,
+ }) : (),
+ title => {
+ name => 'Title',
+ compat => 'title',
+ sort_id => $ulist ? 0 : 1,
+ sort_sql => 'v.sorttitle',
+ },
+ $ulist ? (
+ voted => {
+ name => 'Vote date',
+ sort_sql => 'uv.vote_date',
+ sort_id => 1,
+ sort_num => 1,
+ vis_id => 0,
+ compat => 'voted'
+ },
+ vote => {
+ name => 'Vote',
+ sort_sql => 'uv.vote',
+ sort_id => 2,
+ sort_num => 1,
+ vis_id => 1,
+ compat => 'vote'
+ },
+ label => {
+ name => 'Labels',
+ sort_sql => sql('ARRAY(SELECT ul.label FROM unnest(uv.labels) l(id) JOIN ulist_labels ul ON ul.id = l.id WHERE ul.uid = uv.uid AND l.id <> ', \7, ')'),
+ sort_id => 4,
+ vis_id => 3,
+ compat => 'label'
+ },
+ added => {
+ name => 'Added',
+ sort_sql => 'uv.added',
+ sort_id => 5,
+ sort_num => 1,
+ vis_id => 4,
+ compat => 'added'
+ },
+ modified => {
+ name => 'Modified',
+ sort_sql => 'uv.lastmod',
+ sort_id => 6,
+ sort_num => 1,
+ vis_id => 5,
+ compat => 'modified'
+ },
+ started => {
+ name => 'Start date',
+ sort_sql => 'uv.started',
+ sort_id => 7,
+ sort_num => 1,
+ vis_id => 6,
+ compat => 'started'
+ },
+ finished => {
+ name => 'Finish date',
+ sort_sql => 'uv.finished',
+ sort_id => 8,
+ sort_num => 1,
+ vis_id => 7,
+ compat => 'finished'
+ },
+ ) : (),
+ released => {
+ name => 'Release date',
+ compat => 'rel',
+ sort_id => $ulist ? 9 : 2,
+ sort_sql => 'v.c_released ?o, v.title',
+ sort_num => 1,
+ vis_id => $ulist ? 8 : undef,
+ },
+ length => {
+ name => 'Length',
+ vis_id => $ulist ? 9 : 4,
+ },
+ developer => {
+ name => 'Developer',
+ vis_id => $ulist ? 10 : 2,
+ },
+ rating => {
+ name => 'Bayesian rating',
+ compat => 'rating',
+ sort_id => $ulist ? 11 : 4,
+ sort_sql => 'v.c_rat_rank !o NULLS LAST, v.c_votecount ?o, v.sorttitle',
+ sort_num => 1,
+ vis_id => $ulist ? 12 : 1,
+ vis_default => 1,
+ },
+ average => {
+ name => 'Vote average',
+ sort_id => $ulist ? 12 : 5,
+ sort_sql => 'v.c_average ?o NULLS LAST, v.c_votecount ?o, v.sorttitle',
+ sort_num => 1,
+ vis_id => $ulist ? 13 : 3,
+ },
+ votes => {
+ name => 'Number of votes',
+ sort_id => $ulist ? 13 : 6,
+ sort_sql => 'v.c_votecount ?o, v.sorttitle',
+ sort_num => 1,
+ sort_default => $tags || $vns ? undef : 'desc',
+ },
+ id => {
+ name => $ulist ? 'VN entry added' : 'Date added',
+ sort_id => 10,
+ sort_sql => 'v.id',
+ sort_num => 1,
+ };
+}
+
+my $TABLEOPTS = TABLEOPTS 'vn';
+my $TABLEOPTS_Q = TABLEOPTS 'vns';
+
+sub len_ {
+ my($v) = @_;
+ if ($v->{c_lengthnum}) {
+ vnlength_ $v->{c_length};
+ small_ " ($v->{c_lengthnum})";
+ } elsif($v->{length}) {
+ txt_ $VN_LENGTH{$v->{length}}{txt};
+ }
+}
+
+# Also used by VNWeb::TT::TagPage
+sub listing_ {
+ my($opt, $list, $count, $tagscore, $labels) = @_;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ paginate_ \&url, $opt->{p}, [$count, $opt->{s}->results], 't', $opt->{s};
+
+ my sub votesort {
+ txt_ ' (';
+ sortable_ 'votes', $opt, \&url, 0;
+ txt_ ')'
+ }
+ article_ class => 'browse vnbrowse', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc_score', sub { txt_ 'Score'; sortable_ 'tagscore', $opt, \&url } if $tagscore;
+ td_ class => 'tc_ulist', '' if auth;
+ td_ class => 'tc_title', sub { txt_ 'Title'; sortable_ 'title', $opt, \&url };
+ td_ class => 'tc_dev', 'Developer' if $opt->{s}->vis('developer');
+ td_ class => 'tc_plat', '';
+ td_ class => 'tc_lang', '';
+ td_ class => 'tc_rel', sub { txt_ 'Released'; sortable_ 'released', $opt, \&url };
+ td_ class => 'tc_length',sub { txt_ 'Length'; } if $opt->{s}->vis('length');
+ td_ class => 'tc_rating', sub {
+ txt_ 'Rating'; sortable_ 'rating', $opt, \&url;
+ votesort();
+ } if $opt->{s}->vis('rating');
+ td_ class => $opt->{s}->vis('rating') ? 'tc_average' : 'tc_rating', sub {
+ txt_ 'Average'; sortable_ 'average', $opt, \&url;
+ votesort() if !$opt->{s}->vis('rating');
+ } if $opt->{s}->vis('average');
+ } };
+ tr_ sub {
+ td_ class => 'tc_score', sub { tagscore_ $_->{tagscore} } if $tagscore;
+ td_ class => 'tc_ulist', sub { ulists_widget_ $_ } if auth;
+ td_ class => 'tc_title', sub { a_ href => "/$_->{id}", tattr $_ };
+ td_ class => 'tc_dev', sub {
+ join_ ' & ', sub {
+ a_ href => "/$_->{id}", tattr $_;
+ }, $_->{developers}->@*;
+ } if $opt->{s}->vis('developer');
+ td_ class => 'tc_plat', sub { join_ '', sub { platform_ $_ if $_ ne 'unk' }, sort $_->{platforms}->@* };
+ td_ class => 'tc_lang', sub { join_ '', sub { abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' }, reverse sort $_->{lang}->@* };
+ td_ class => 'tc_rel', sub { rdate_ $_->{c_released} };
+ td_ class => 'tc_length',sub { len_ $_ } if $opt->{s}->vis('length');
+ td_ class => 'tc_rating',sub {
+ txt_ $_->{c_rating} ? sprintf '%.2f', $_->{c_rating}/100 : '-';
+ small_ sprintf ' (%d)', $_->{c_votecount};
+ } if $opt->{s}->vis('rating');
+ td_ class => 'tc_average',sub {
+ txt_ $_->{c_average} ? sprintf '%.2f', $_->{c_average}/100 : '-';
+ small_ sprintf ' (%d)', $_->{c_votecount} if !$opt->{s}->vis('rating');
+ } if $opt->{s}->vis('average');
+ } for @$list;
+ }
+ } if $opt->{s}->rows;
+
+ # Contents of the grid & card modes are the same
+ my sub infoblock_ {
+ my($canlink) = @_; # grid contains an outer <a>, so may not contain links itself.
+ my sub lnk_ {
+ my($url, @attr) = @_;
+ a_ href => $url, @attr if $canlink;
+ span_ @attr if !$canlink;
+ }
+ lnk_ "/$_->{id}", tattr $_;
+ if(!$labels || $opt->{s}->vis('released')) {
+ br_;
+ join_ '', sub { platform_ $_ if $_ ne 'unk' }, sort $_->{platforms}->@*;
+ join_ '', sub { abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' }, reverse sort $_->{lang}->@*;
+ rdate_ $_->{c_released};
+ }
+ if($opt->{s}->vis('developer')) {
+ br_;
+ join_ ' & ', sub {
+ lnk_ "/$_->{id}", tattr $_;
+ }, $_->{developers}->@*;
+ }
+ table_ sub {
+ tr_ sub {
+ td_ 'Tag score:';
+ td_ sub { tagscore_ $_->{tagscore} };
+ } if $tagscore;
+ tr_ sub {
+ td_ 'Length';
+ td_ sub { len_ $_ };
+ } if $opt->{s}->vis('length');
+ tr_ sub {
+ td_ $opt->{s}->vis('vote') ? 'Vote:' : 'Voted:';
+ td_ sub {
+ txt_ fmtvote $_->{vote} if $opt->{s}->vis('vote');
+ txt_ ' on '.($_->{vote_date} ? fmtdate $_->{vote_date}, 'compact' : '-') if $opt->{s}->vis('voted');
+ }
+ } if $opt->{s}->vis('vote') || $opt->{s}->vis('voted');
+ tr_ sub {
+ td_ 'Labels:';
+ td_ sub {
+ my %labels = map +($_,1), $_->{labels}->@*;
+ my @l = grep $labels{$_->{id}} && $_->{id} != 7, @$labels;
+ txt_ @l ? join ', ', map $_->{label}, @l : '-';
+ };
+ } if $opt->{s}->vis('label');
+ tr_ sub {
+ td_ 'Added on:';
+ td_ fmtdate $_->{added}, 'compact';
+ } if $opt->{s}->vis('added');
+ tr_ sub {
+ td_ 'Modified on:';
+ td_ fmtdate $_->{lastmod}, 'compact';
+ } if $opt->{s}->vis('modified');
+ tr_ sub {
+ td_ 'Started:';
+ td_ $_->{started}||'-';
+ } if $opt->{s}->vis('started');
+ tr_ sub {
+ td_ 'Finished:';
+ td_ $_->{finished}||'-';
+ } if $opt->{s}->vis('finished');
+ tr_ sub {
+ td_ 'Rating:';
+ td_ sub {
+ txt_ $_->{c_rating} ? sprintf '%.2f', $_->{c_rating}/100 : '-';
+ small_ sprintf ' (%d)', $_->{c_votecount};
+ };
+ } if $opt->{s}->vis('rating');
+ tr_ sub {
+ td_ 'Average:';
+ td_ sub {
+ txt_ $_->{c_average} ? sprintf '%.2f', $_->{c_average}/100 : '';
+ small_ sprintf ' (%d)', $_->{c_votecount} if !$opt->{s}->vis('rating');
+ };
+ } if $opt->{s}->vis('average');
+ }
+ }
+
+ article_ class => 'vncards', sub {
+ my($w,$h) = (90,120);
+ div_ sub {
+ div_ sub {
+ if($_->{image}) {
+ my($iw,$ih) = imgsize $_->{image}{width}*100, $_->{image}{height}*100, $w, $h;
+ image_ $_->{image}, width => $iw, height => $ih, url => "/$_->{id}", overlay => undef;
+ } else {
+ txt_ 'no image';
+ }
+ };
+ div_ sub {
+ ulists_widget_ $_;
+ infoblock_ 1;
+ };
+ } for @$list;
+ } if $opt->{s}->cards;
+
+ article_ class => 'vngrid', sub {
+ div_ !$_->{image} || image_hidden($_->{image}) ? (class => 'noimage') : (style => 'background-image: url("'.imgurl($_->{image}{id}).'")'), sub {
+ ulists_widget_ $_;
+ a_ href => "/$_->{id}", title => $_->{title}[3], sub { infoblock_ 0 };
+ } for @$list;
+ } if $opt->{s}->grid;
+
+ paginate_ \&url, $opt->{p}, [$count, $opt->{s}->results], 'b';
+}
+
+
+# Enrich some extra fields fields needed for listing_()
+# Also used by TT::TagPage and UList::List
+sub enrich_listing {
+ my($widget, $opt, @lst) = @_;
+
+ enrich developers => id => vid => sub { sql
+ 'SELECT v.id AS vid, p.id, p.title
+ FROM vn v, unnest(v.c_developers) vp(id),', producerst, 'p
+ WHERE p.id = vp.id AND v.id IN', $_[0], 'ORDER BY p.sorttitle, p.id'
+ }, @lst if $opt->{s}->vis('developer');
+
+ enrich_image_obj image => @lst if !$opt->{s}->rows;
+ enrich_ulists_widget @lst if $widget;
+}
+
+
+TUWF::get qr{/v(?:/(?<char>all|[a-z0]))?}, sub {
+ my $opt = tuwf->validate(get =>
+ q => { searchquery => 1 },
+ sq=> { searchquery => 1 },
+ p => { upage => 1 },
+ f => { advsearch_err => 'v' },
+ ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
+ fil => { onerror => '' },
+ rfil => { onerror => '' },
+ cfil => { onerror => '' },
+ )->data;
+ $opt->{q} = $opt->{sq} if !$opt->{q};
+ $opt->{s} = tuwf->validate(get => s => { tableopts => $opt->{q} ? $TABLEOPTS_Q : $TABLEOPTS })->data;
+ $opt->{s} = $opt->{s}->sort_param(qscore => 'a') if $opt->{q} && tuwf->reqGet('sb');
+ $opt->{ch} = $opt->{ch}[0];
+
+ # compat with old URLs
+ my $oldch = tuwf->capture('char');
+ $opt->{ch} //= $oldch if defined $oldch && $oldch ne 'all';
+
+ # URL compatibility with old filters
+ if(!$opt->{f}->{query} && ($opt->{fil} || $opt->{rfil} || $opt->{cfil})) {
+ my $q = eval {
+ my $fil = filter_vn_adv filter_parse v => $opt->{fil};
+ my $rfil = filter_release_adv filter_parse r => $opt->{rfil};
+ my $cfil = filter_char_adv filter_parse c => $opt->{cfil};
+ my @q = (
+ $fil && @$fil > 1 ? $fil : (),
+ $rfil && @$rfil > 1 ? [ 'release', '=', $rfil ] : (),
+ $cfil && @$cfil > 1 ? [ 'character', '=', $cfil ] : (),
+ );
+ tuwf->compile({ advsearch => 'v' })->validate(@q > 1 ? ['and',@q] : @q)->data;
+ };
+ return tuwf->resRedirect(tuwf->reqPath().'?'.query_encode(%$opt, fil => undef, rfil => undef, cfil => undef, f => $q), 'perm') if $q;
+ }
+
+ $opt->{f} = advsearch_default 'v' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
+
+ my $where = sql_and
+ 'NOT v.hidden', $opt->{f}->sql_where(),
+ defined($opt->{ch}) ? sql 'match_firstchar(v.sorttitle, ', \$opt->{ch}, ')' : ();
+
+ my $time = time;
+ my($count, $list);
+ db_maytimeout {
+ $count = tuwf->dbVali('SELECT count(*) FROM', vnt, 'v WHERE', sql_and $where, $opt->{q}->sql_where('v', 'v.id'));
+ $list = $count ? tuwf->dbPagei({results => $opt->{s}->results(), page => $opt->{p}}, '
+ SELECT v.id, v.title, v.c_released, v.c_votecount, v.c_rating, v.c_average
+ , v.image, v.c_platforms::text[] AS platforms, v.c_languages::text[] AS lang',
+ $opt->{s}->vis('length') ? ', v.length, v.c_length, v.c_lengthnum' : (), '
+ FROM', vnt, 'v', $opt->{q}->sql_join('v', 'v.id'), '
+ WHERE', $where, '
+ ORDER BY', $opt->{s}->sql_order(),
+ ) : [];
+ } || (($count, $list) = (undef, []));
+
+ my $fullq = join '', $opt->{q}->words->@*;
+ my $other = length $fullq && $opt->{s}->sorted('qscore') && $opt->{p} == 1 ? tuwf->dbAlli("
+ SELECT x.id, i.title
+ FROM (
+ SELECT DISTINCT id
+ FROM search_cache
+ WHERE NOT (id BETWEEN 'v1' AND vndbid_max('v'))
+ AND NOT (id BETWEEN 'r1' AND vndbid_max('r'))
+ AND label =", \$fullq, ') x,
+ ', item_info('id', 'null'), 'i
+ WHERE NOT i.hidden
+ ORDER BY vndbid_type(x.id) DESC, i.title[1+1]
+ ') : [];
+
+ return tuwf->resRedirect("/$list->[0]{id}", 'temp') if $count && $count == 1 && $opt->{p} == 1 && $opt->{q} && !defined $opt->{ch} && !@$other;
+
+ enrich_listing(1, $opt, $list);
+ $time = time - $time;
+
+ framework_ title => 'Browse visual novels', sub {
+ form_ action => '/v', method => 'get', sub {
+ article_ sub {
+ h1_ 'Browse visual novels';
+ searchbox_ v => $opt->{q};
+ p_ class => 'browseopts', sub {
+ button_ type => 'submit', name => 'ch', value => ($_//''), ($_//'') eq ($opt->{ch}//'') ? (class => 'optselected') : (), !defined $_ ? 'ALL' : $_ ? uc $_ : '#'
+ for (undef, 'a'..'z', 0);
+ };
+ input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
+ $opt->{f}->elm_($count, $time);
+ };
+ article_ sub {
+ h1_ 'Did you mean to search for...';
+ ul_ style => 'column-width: 250px', sub {
+ li_ sub {
+ strong_ {qw/r Release p Producer c Character s Staff g Tag i Trait/}->{substr $_->{id}, 0, 1};
+ txt_ ': ';
+ a_ href => "/$_->{id}", tattr $_;
+ } for @$other;
+ };
+ } if @$other;
+ listing_ $opt, $list, $count if $count;
+ };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/VN/Page.pm b/lib/VNWeb/VN/Page.pm
new file mode 100644
index 00000000..6262fcc1
--- /dev/null
+++ b/lib/VNWeb/VN/Page.pm
@@ -0,0 +1,1036 @@
+package VNWeb::VN::Page;
+
+use VNWeb::Prelude;
+use VNWeb::Releases::Lib;
+use VNWeb::Images::Lib qw/image_flagging_display image_ enrich_image_obj/;
+use VNWeb::ULists::Lib 'ulists_widget_full_data';
+use VNDB::Func 'fmtrating';
+
+
+# Enrich everything necessary to at least render infobox_() and tabs_().
+# Also used by Chars::VNTab, Reviews::VNTab and VN::Quotes
+sub enrich_vn {
+ my($v, $revonly) = @_;
+ $v->{title} = titleprefs_obj $v->{olang}, $v->{titles};
+ enrich_merge id => 'SELECT id, c_votecount, c_length, c_lengthnum FROM vn WHERE id IN', $v;
+ enrich_merge vid => sql('SELECT id AS vid, title, sorttitle, c_released FROM', vnt, 'v 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};
+ enrich_extlinks v => 0, $v;
+ 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('
+ SELECT r.id, rv.rtype, r.patch, r.released, r.gtin,', sql_extlinks(r => 'r.'), '
+ , (SELECT COUNT(*) FROM releases_vn rv WHERE rv.id = r.id) AS num_vns
+ FROM releases r
+ JOIN releases_vn rv ON rv.id = r.id
+ WHERE NOT r.hidden AND rv.vid =', \$v->{id}
+ );
+ enrich_extlinks r => 0, $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}
+ );
+ $v->{tags} = !prefs()->{has_tagprefs} ? tuwf->dbAlli('
+ SELECT t.id, t.name, t.cat, tv.rating, tv.count, tv.spoiler, tv.lie
+ FROM tags t
+ JOIN tags_vn_direct tv ON t.id = tv.tag
+ WHERE tv.vid =', \$v->{id}, '
+ ORDER BY rating DESC, t.name'
+ ) : tuwf->dbAlli(
+ # Monster of a query, but tag overrides are a bit complicated:
+ # - We need to find the shortest path from a tag applied to the VN to a
+ # parent in users_prefs_tags, and use those preferences. That's what
+ # tag_direct does.
+ # - If the user has a tag marked as "Always show" but hasn't checked
+ # "also apply to child tags", then we need to look for any child tags
+ # and inject their parent if said parent hasn't been directly applied.
+ # That's what tag_indirect does.
+ 'WITH RECURSIVE tag_overrides (tid, spoil, color, childs, lvl) AS (
+ SELECT tid, spoil, color, childs, 0 FROM users_prefs_tags WHERE id =', \auth->uid, '
+ UNION ALL
+ SELECT tp.id, x.spoil, x.color, true, lvl+1
+ FROM tag_overrides x
+ JOIN tags_parents tp ON tp.parent = x.tid
+ WHERE x.childs
+ ), tag_overrides_grouped (tid, spoil, color) AS (
+ SELECT DISTINCT ON(tid) tid, spoil, color FROM tag_overrides ORDER BY tid, lvl
+ ), tag_direct (tid, rating, count, spoiler, lie, override, color) AS (
+ SELECT t.tag, t.rating, t.count, t.spoiler, t.lie, x.spoil, x.color
+ FROM tags_vn_direct t
+ LEFT JOIN tag_overrides_grouped x ON x.tid = t.tag
+ WHERE t.vid =', \$v->{id}, 'AND x.spoil IS DISTINCT FROM 1+1+1
+ ), tag_indirect (tid, rating, count, spoiler, lie, override, color) AS (
+ SELECT t.tag, t.rating, 0::smallint, t.spoiler, t.lie, x.spoil, x.color
+ FROM tags_vn_inherit t
+ JOIN users_prefs_tags x ON x.tid = t.tag
+ WHERE t.vid =', \$v->{id}, 'AND x.id =', \auth->uid, 'AND NOT x.childs AND x.spoil = 0
+ AND NOT EXISTS(SELECT 1 FROM tag_direct d WHERE d.tid = t.tag)
+ ) SELECT t.id, t.name, t.cat, d.rating, d.count, d.spoiler, d.lie, d.override, d.color
+ FROM tags t
+ JOIN (SELECT * FROM tag_direct UNION ALL SELECT * FROM tag_indirect) d ON d.tid = t.id
+ ORDER BY d.rating DESC, t.name'
+ );
+}
+
+
+# Enrich everything necessary for rev_() (includes enrich_vn())
+sub enrich_item {
+ my($v, $full) = @_;
+ enrich_vn $v, !$full;
+ enrich_merge aid => sql('SELECT id AS sid, aid, title FROM', staff_aliast, 's WHERE aid IN'), $v->{staff}, $v->{seiyuu};
+ enrich_merge cid => sql('SELECT id AS cid, title AS char_title FROM', charst, 'c WHERE id IN'), $v->{seiyuu};
+
+ $v->{relations} = [ sort { idcmp($a->{vid}, $b->{vid}) } $v->{relations}->@* ];
+ $v->{anime} = [ sort { $a->{aid} <=> $b->{aid} } $v->{anime}->@* ];
+ $v->{editions} = [ sort { ($a->{lang}||'') cmp ($b->{lang}||'') || $b->{official} cmp $a->{official} || $a->{name} cmp $b->{name} } $v->{editions}->@* ];
+ $v->{staff} = [ sort { ($a->{eid}//-1) <=> ($b->{eid}//-1) || $a->{aid} <=> $b->{aid} || $a->{role} cmp $b->{role} } $v->{staff}->@* ];
+ $v->{seiyuu} = [ sort { $a->{aid} <=> $b->{aid} || idcmp($a->{cid}, $b->{cid}) || $a->{note} cmp $b->{note} } $v->{seiyuu}->@* ];
+ $v->{screenshots} = [ sort { idcmp($a->{scr}{id}, $b->{scr}{id}) } $v->{screenshots}->@* ];
+}
+
+
+sub og {
+ my($v) = @_;
+ +{
+ description => bb_format($v->{description}, text => 1),
+ image => $v->{image} && !$v->{image}{sexual} && !$v->{image}{violence} ? imgurl($v->{image}{id}) :
+ [map $_->{scr}{sexual}||$_->{scr}{violence}?():(imgurl($_->{scr}{id})), $v->{screenshots}->@*]->[0]
+ }
+}
+
+
+sub prefs {
+ state $default = {
+ vnrel_langs => \%LANGUAGE, vnrel_olang => 1, vnrel_mtl => 0,
+ staffed_langs => \%LANGUAGE, staffed_olang => 1, staffed_unoff => 0,
+ has_tagprefs => 0,
+ };
+ tuwf->req->{vnpage_prefs} //= auth ? do {
+ my $v = tuwf->dbRowi('
+ SELECT vnrel_langs::text[], vnrel_olang, vnrel_mtl
+ , staffed_langs::text[], staffed_olang, staffed_unoff
+ , EXISTS(SELECT 1 FROM users_prefs_tags WHERE id =', \auth->uid, ') AS has_tagprefs
+ FROM users_prefs
+ WHERE id =', \auth->uid
+ );
+ $v->{vnrel_langs} = $v->{vnrel_langs} ? { map +($_,1), $v->{vnrel_langs}->@* } : \%LANGUAGE;
+ $v->{staffed_langs} = $v->{staffed_langs} ? { map +($_,1), $v->{staffed_langs}->@* } : \%LANGUAGE;
+ $v
+ } : $default;
+}
+
+
+# The voting and review options are hidden if nothing has been released yet.
+sub canvote {
+ my($v) = @_;
+ $v->{_canvote} //= do {
+ my $minreleased = min grep $_, map $_->{released}, $v->{releases}->@*;
+ $minreleased && $minreleased <= strftime('%Y%m%d', gmtime)
+ };
+}
+
+
+sub rev_ {
+ my($v) = @_;
+ revision_ $v, \&enrich_item,
+ [ titles => 'Title(s)', txt => sub {
+ "[$_->{lang}] $_->{title}".($_->{latin} ? " / $_->{latin}" : '').($_->{official} ? '' : ' (unofficial)')
+ }],
+ [ alias => 'Alias' ],
+ [ olang => 'Original language', fmt => \%LANGUAGE ],
+ [ description => 'Description' ],
+ [ devstatus => 'Development status',fmt => \%DEVSTATUS ],
+ [ length => 'Length', fmt => \%VN_LENGTH ],
+ [ editions => 'Editions', fmt => sub {
+ abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, '' if $_->{lang};
+ txt_ $_->{name};
+ small_ ' (unofficial)' if !$_->{official};
+ }],
+ [ staff => 'Credits', fmt => sub {
+ my $eid = $_->{eid};
+ my $e = defined $eid && (grep $eid == $_->{eid}, $_[0]{editions}->@*)[0];
+ txt_ "[$e->{name}] " if $e;
+ a_ href => "/$_->{sid}", tattr $_ if $_->{sid};
+ small_ '[removed alias]' if !$_->{sid};
+ txt_ " [$CREDIT_TYPE{$_->{role}}]";
+ txt_ " [$_->{note}]" if $_->{note};
+ }],
+ [ seiyuu => 'Seiyuu', fmt => sub {
+ a_ href => "/$_->{sid}", tattr $_ if $_->{sid};
+ small_ '[removed alias]' if !$_->{sid};
+ txt_ ' as ';
+ a_ href => "/$_->{cid}", tattr $_->{char_title};
+ txt_ " [$_->{note}]" if $_->{note};
+ }],
+ [ relations => 'Relations', fmt => sub {
+ txt_ sprintf '[%s] %s: ', $_->{official} ? 'official' : 'unofficial', $VN_RELATION{$_->{relation}}{txt};
+ a_ href => "/$_->{vid}", tattr $_;
+ }],
+ [ anime => 'Anime', fmt => sub { a_ href => "https://anidb.net/anime/$_->{aid}", "a$_->{aid}" }],
+ [ screenshots => 'Screenshots', fmt => sub {
+ my $rev = $_[0]{chid} == $v->{chid} ? 'new' : 'old';
+ txt_ '[';
+ a_ href => "/$_->{rid}", $_->{rid} if $_->{rid};
+ txt_ 'no release' if !$_->{rid};
+ txt_ '] ';
+ a_ href => imgurl($_->{scr}{id}), 'data-iv' => "$_->{scr}{width}x$_->{scr}{height}:$rev:$_->{scr}{sexual}$_->{scr}{violence}$_->{scr}{votecount}", $_->{scr}{id};
+ txt_ " [$_->{scr}{width}x$_->{scr}{height}; ";
+ a_ href => "/$_->{scr}{id}", image_flagging_display $_->{scr} if auth;
+ span_ image_flagging_display $_->{scr} if !auth;
+ txt_ '] ';
+ # The old NSFW flag has been removed around 2020-07-14, so not relevant for edits made later on.
+ small_ 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' } ],
+ revision_extlinks 'v'
+}
+
+
+sub infobox_relations_ {
+ my($v) = @_;
+ return if !$v->{relations}->@*;
+
+ my %rel;
+ push $rel{$_->{relation}}->@*, $_ for sort { $b->{official} <=> $a->{official} || $a->{c_released} <=> $b->{c_released} || $a->{sorttitle} cmp $b->{sorttitle} } $v->{relations}->@*;
+ my $unoffcount = grep !$_->{official}, $v->{relations}->@*;
+
+ tr_ sub {
+ td_ 'Relations';
+ td_ class => 'relations linkradio', sub {
+ if($unoffcount >= 3) {
+ input_ type => 'checkbox', id => 'unoffrelations', class => 'hidden';
+ label_ for => 'unoffrelations', "unofficial ($unoffcount)";
+ }
+ dl_ sub {
+ for(sort keys %rel) {
+ my @allunoff = (!grep $_->{official}, $rel{$_}->@*) ? (class => 'unofficial') : ();
+ dt_ @allunoff, $VN_RELATION{$_}{txt};
+ dd_ @allunoff, sub {
+ p_ class => $_->{official} ? undef : 'unofficial', sub {
+ small_ '[unofficial] ' if !$_->{official};
+ a_ href => "/$_->{vid}", tattr $_;
+ } for $rel{$_}->@*;
+ }
+ }
+ }
+ }
+ }
+}
+
+
+sub infobox_length_ {
+ my($v) = @_;
+
+ tr_ sub {
+ td_ 'Play time';
+ td_ sub {
+ # Cached number, which means this VN has counted votes
+ if($v->{c_lengthnum}) {
+ my $m = $v->{c_length};
+ txt_ +(grep $m >= $_->{low} && $m < $_->{high}, values %VN_LENGTH)[0]{txt}.' (';
+ vnlength_ $m;
+ txt_ ' from ';
+ a_ href => "/$v->{id}/lengthvotes", sprintf '%d vote%s', $v->{c_lengthnum}, $v->{c_length}==1?'':'s';
+ txt_ ')';
+ # No cached number so no counted votes; fall back to old 'length' field and display number of uncounted votes
+ } else {
+ my $uncounted = tuwf->dbVali('SELECT count(*) FROM vn_length_votes WHERE vid =', \$v->{id}, 'AND NOT private');
+ txt_ $VN_LENGTH{$v->{length}}{txt};
+ if ($v->{length} || $uncounted) {
+ lit_ ' (';
+ txt_ $VN_LENGTH{$v->{length}}{time} if $v->{length};
+ lit_ ', ' if $v->{length} && $uncounted;
+ a_ href => "/$v->{id}/lengthvotes", sprintf '%d uncounted vote%s', $uncounted, $uncounted == 1 ? '' : 's' if $uncounted;
+ lit_ ')';
+ }
+ }
+ if (VNWeb::VN::Length::can_vote()) {
+ my $my = tuwf->dbRowi('SELECT rid::text[] AS rid, length, speed, private, notes FROM vn_length_votes WHERE vid =', \$v->{id}, 'AND uid =', \auth->uid);
+ elm_ VNLengthVote => $VNWeb::VN::Length::LENGTHVOTE, {
+ uid => auth->uid, vid => $v->{id},
+ vote => $my->{rid}?$my:undef,
+ maycount => $v->{devstatus} != 1,
+ }, sub { span_ @_, ''};
+ }
+ };
+ };
+}
+
+
+sub infobox_producers_ {
+ my($v) = @_;
+
+ my $p = tuwf->dbAlli('
+ SELECT p.id, p.title, p.sorttitle, rl.lang, bool_or(rp.developer) as developer, bool_or(rp.publisher) as publisher, min(rv.rtype) as rtype, bool_or(r.official) as official
+ FROM releases_vn rv
+ JOIN releases r ON r.id = rv.id
+ JOIN releases_titles rl ON rl.id = rv.id
+ JOIN releases_producers rp ON rp.id = rv.id
+ JOIN', producerst, 'p ON p.id = rp.pid
+ WHERE NOT r.hidden AND (r.official OR NOT rl.mtl) AND rv.vid =', \$v->{id}, '
+ GROUP BY p.id, p.title, p.sorttitle, rl.lang
+ ORDER BY NOT bool_or(r.official), MIN(r.released), p.sorttitle
+ ');
+ return if !@$p;
+
+ my $hasfull = grep $_->{rtype} eq 'complete', @$p;
+ my %dev;
+ my @dev = grep $_->{developer} && (!$hasfull || $_->{rtype} ne 'trial') && !$dev{$_->{id}}++, @$p;
+
+ tr_ sub {
+ td_ 'Developer';
+ td_ sub {
+ join_ ' & ', sub { a_ href => "/$_->{id}", tattr $_ }, @dev;
+ };
+ } if @dev;
+
+ my(%lang, @lang, $lang);
+ for(grep $_->{publisher} && (!$hasfull || $_->{rtype} ne 'trial'), @$p) {
+ push @lang, $_->{lang} if !$lang{$_->{lang}};
+ push $lang{$_->{lang}}->@*, $_;
+ }
+ return if !keys %lang;
+
+ use sort 'stable';
+ @lang = sort { ($b eq $v->{olang}) cmp ($a eq $v->{olang}) } @lang;
+
+ # Merge multiple languages into one group if the publishers are the same.
+ my @nlang = (shift @lang);
+ my $last = join ';', sort map $_->{id}, $lang{$nlang[0]}->@*;
+ for (@lang) {
+ my $cids = join ';', sort map $_->{id}, $lang{$_}->@*;
+ if($last eq $cids) {
+ $nlang[$#nlang] .= ";$_";
+ } else {
+ push @nlang, $_;
+ }
+ $last = $cids;
+ }
+
+ tr_ sub {
+ td_ 'Publishers';
+ td_ sub {
+ join_ \&br_, sub {
+ my @l = split /;/;
+ abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' for @l;
+ join_ ' & ', sub { a_ href => "/$_->{id}", $_->{official} ? () : (class => 'grayedout'), tattr $_ }, $lang{$l[0]}->@*;
+ }, @nlang;
+ }
+ };
+}
+
+
+sub infobox_affiliates_ {
+ my($v) = @_;
+
+ # If the same shop link has been added to multiple releases, use the 'first' matching type in this list.
+ my @type = ('bundle', '', 'partial', 'trial', 'patch');
+
+ # url => [$title, $url, $price, $type]
+ my %links;
+ for my $rel ($v->{releases}->@*) {
+ my $type = $rel->{patch} ? 4 :
+ $rel->{rtype} eq 'trial' ? 3 :
+ $rel->{rtype} eq 'partial' ? 2 :
+ $rel->{num_vns} > 1 ? 0 : 1;
+
+ $links{$_->{url2}} = [ @{$_}{qw/label url2 price/}, min $type, $links{$_->{url2}}[3]||9 ] for grep $_->{price}, $rel->{extlinks}->@*;
+ }
+ return if !keys %links;
+
+ tr_ id => 'buynow', sub {
+ td_ 'Shops';
+ td_ sub {
+ small_ class => 'ad', 'sponsored links';
+ join_ \&br_, sub {
+ b_ '» ';
+ a_ href => $_->[1], sub {
+ txt_ $_->[2];
+ small_ ' @ ';
+ txt_ $_->[0];
+ small_ " ($type[$_->[3]])" if $_->[3] != 1;
+ };
+ }, sort { $a->[0] cmp $b->[0] || $a->[2] cmp $b->[2] } values %links;
+ }
+ }
+}
+
+
+sub infobox_anime_ {
+ my($v) = @_;
+ return if !$v->{anime}->@*;
+ tr_ sub {
+ td_ 'Related anime';
+ td_ class => 'anime', sub { join_ \&br_, sub {
+ if(!$_->{lastfetch} || !$_->{year} || !$_->{title_romaji}) {
+ span_ sub {
+ txt_ '[no information available at this time: ';
+ a_ href => 'https://anidb.net/anime/'.$_->{aid}, "a$_->{aid}";
+ txt_ ']';
+ };
+ } else {
+ span_ sub {
+ txt_ '[';
+ a_ href => "https://anidb.net/anime/$_->{aid}", title => 'AniDB', 'DB';
+ if($_->{ann_id}) {
+ txt_ '-';
+ a_ href => "http://www.animenewsnetwork.com/encyclopedia/anime.php?id=$_->{ann_id}", title => 'Anime News Network', 'ANN';
+ }
+ txt_ '] ';
+ };
+ abbr_ title => $_->{title_kanji}||$_->{title_romaji}, shorten $_->{title_romaji}, 50;
+ span_ ' ('.(defined $_->{type} ? $ANIME_TYPE{$_->{type}}{txt}.', ' : '').$_->{year}.')';
+ }
+ }, sort { ($a->{year}||9999) <=> ($b->{year}||9999) } $v->{anime}->@* }
+ }
+}
+
+
+sub infobox_tags_ {
+ my($v) = @_;
+ div_ id => 'tagops', sub {
+ debug_ $v->{tags};
+ my @ero = grep($_->{cat} eq 'ero', $v->{tags}->@*) ? ('ero') : ();
+ for ('cont', @ero, 'tech') {
+ input_ id => "cat_$_", type => 'checkbox', class => 'hidden',
+ (auth ? auth->pref("tags_$_") : $_ ne 'ero') ? (checked => 'checked') : ();
+ label_ for => "cat_$_", lc $TAG_CATEGORY{$_};
+ }
+ my $spoiler = auth->pref('spoilers') || 0;
+ input_ id => 'tag_spoil_none', type => 'radio', class => 'hidden', name => 'tag_spoiler', $spoiler == 0 ? (checked => 'checked') : ();
+ label_ for => 'tag_spoil_none', class => 'sec', 'hide spoilers';
+ input_ id => 'tag_spoil_some', type => 'radio', class => 'hidden', name => 'tag_spoiler', $spoiler == 1 ? (checked => 'checked') : ();
+ label_ for => 'tag_spoil_some', 'show minor spoilers';
+ input_ id => 'tag_spoil_all', type => 'radio', class => 'hidden', name => 'tag_spoiler', $spoiler == 2 ? (checked => 'checked') : ();
+ label_ for => 'tag_spoil_all', 'spoil me!';
+
+ input_ id => 'tag_toggle_summary', type => 'radio', class => 'hidden', name => 'tag_all', auth->pref('tags_all') ? () : (checked => 'checked');
+ label_ for => 'tag_toggle_summary', class => 'sec', 'summary';
+ input_ id => 'tag_toggle_all', type => 'radio', class => 'hidden', name => 'tag_all', auth->pref('tags_all') ? (checked => 'checked') : ();
+ label_ for => 'tag_toggle_all', class => 'lst', 'all';
+ div_ id => 'vntags', sub {
+ my %counts = map +($_,[0,0,0]), keys %TAG_CATEGORY;
+ join_ ' ', sub {
+ my $spoil = $_->{override}//$_->{spoiler};
+ my $cnt = $counts{$_->{cat}};
+ $cnt->[2]++;
+ $cnt->[1]++ if $spoil < 2;
+ $cnt->[0]++ if $spoil < 1;
+ my $cut = defined $_->{override} ? '' : $cnt->[0] > 15 ? ' cut cut2 cut1 cut0' : $cnt->[1] > 15 ? ' cut cut2 cut1' : $cnt->[2] > 15 ? ' cut cut2' : '';
+ span_ class => "tagspl$spoil cat_$_->{cat} $cut", sub {
+ a_ href => "/$_->{id}",
+ mkclass(defined $_->{override} ? 'lieo' : 'lie', $_->{lie},
+ $_->{color} ? ($_->{color}, $_->{color} =~ /standout|grayedout/ ? 1 : 0) : ()),
+ style => sprintf('font-size: %dpx', $_->{rating}*3.5+6)
+ .(($_->{color}//'') =~ /^#/ ? "; color: $_->{color}" : ''),
+ $_->{name};
+ spoil_ $_->{spoiler};
+ small_ sprintf ' %.1f', $_->{rating};
+ }
+ }, $v->{tags}->@*;
+ }
+ }
+}
+
+
+# Also used by Chars::VNTab & Reviews::VNTab
+sub infobox_ {
+ my($v, $notags) = @_;
+
+ sub tlang_ {
+ my($t) = @_;
+ tr_ mkclass(title => 1, grayedout => !$t->{official}), sub {
+ td_ sub {
+ abbr_ class => "icon-lang-$t->{lang}", title => $LANGUAGE{$t->{lang}}{txt}, '';
+ };
+ td_ sub {
+ span_ tlang($t->{lang}, $t->{title}), $t->{title};
+ if($t->{latin}) {
+ br_;
+ txt_ $t->{latin};
+ }
+ }
+ }
+ }
+
+ article_ sub {
+ itemmsg_ $v;
+ h1_ tlang($v->{title}[0], $v->{title}[1]), $v->{title}[1];
+ h2_ class => 'alttitle', tlang(@{$v->{title}}[2,3]), $v->{title}[3] if $v->{title}[3] && $v->{title}[3] ne $v->{title}[1];
+
+ div_ class => 'warning', sub {
+ h2_ 'No releases';
+ p_ sub {
+ txt_ 'This entry does not have any releases associated with it yet. Please ';
+ a_ href => "/$v->{id}/add", 'add a release entry';
+ txt_ ' if you have information about this visual novel.';
+ br_;
+ txt_ '(A release entry should be present even if nothing has been
+ released yet, in that case it can just be a placeholder for a
+ future release)';
+ };
+ } if !$v->{hidden} && auth->permEdit && !$v->{releases}->@*;
+
+ p_ class => 'center standout', sub { lit_ config->{special_games}{$v->{id}}; br_; br_ } if config->{special_games}{$v->{id}};
+
+ div_ class => 'vndetails', sub {
+ div_ class => 'vnimg', sub { image_ $v->{image}, alt => $v->{title}[1]; };
+
+ table_ class => 'stripe', sub {
+ tr_ sub {
+ td_ 'Title';
+ td_ sub {
+ table_ sub { tlang_ $v->{titles}[0] };
+ };
+ } if $v->{titles}->@* == 1;
+ tr_ sub {
+ td_ class => 'titles', colspan => 2, sub {
+ details_ sub {
+ summary_ sub {
+ div_ 'Titles';
+ table_ sub { tlang_ grep $_->{lang} eq $v->{olang}, $v->{titles}->@* };
+ };
+ table_ sub {
+ tlang_ $_ for grep $_->{lang} ne $v->{olang}, sort { $b->{official} cmp $a->{official} || $a->{lang} cmp $b->{lang} } $v->{titles}->@*;
+ };
+ };
+ };
+ } if $v->{titles}->@* > 1;
+
+ tr_ sub {
+ td_ 'Aliases';
+ td_ $v->{alias} =~ s/\n/, /gr;
+ } if $v->{alias};
+
+ tr_ sub {
+ td_ 'Status';
+ td_ sub {
+ txt_ 'In development' if $v->{devstatus} == 1;
+ txt_ 'Unfinished, no ongoing development' if $v->{devstatus} == 2;
+ };
+ } if $v->{devstatus};
+
+ infobox_length_ $v;
+ infobox_producers_ $v;
+ infobox_relations_ $v;
+
+ tr_ sub {
+ td_ 'Links';
+ td_ sub { join_ ', ', sub { a_ href => $_->{url2}, $_->{label} }, $v->{extlinks}->@* };
+ } if $v->{extlinks}->@*;
+
+ infobox_affiliates_ $v;
+ infobox_anime_ $v;
+
+ tr_ class => 'nostripe', sub {
+ td_ colspan => 2, sub {
+ elm_ 'UList.VNPage', $VNWeb::ULists::Elm::WIDGET,
+ ulists_widget_full_data $v, auth->uid, 1, canvote $v;
+ }
+ } if auth;
+
+ tr_ class => 'nostripe', sub {
+ td_ class => 'vndesc', colspan => 2, sub {
+ h2_ 'Description';
+ p_ sub { lit_ $v->{description} ? bb_format $v->{description} : '-' };
+ debug_ $v;
+ }
+ }
+ }
+ };
+ div_ class => 'clearfloat', style => 'height: 5px', ''; # otherwise the tabs below aren't positioned correctly
+ infobox_tags_ $v if $v->{tags}->@* && !$notags;
+ }
+}
+
+
+# Also used by Chars::VNTab, Reviews::VNTab and VN::Quotes
+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 $quotes = tuwf->dbVali('SELECT COUNT(*) FROM quotes WHERE NOT hidden AND vid =', \$v->{id});
+
+ $tab ||= '';
+ nav_ sub {
+ menu_ sub {
+ li_ class => ($tab eq '' ? ' tabselected' : ''), sub { a_ href => "/$v->{id}#main", name => 'main', 'main' };
+ li_ class => ($tab eq 'tags' ? ' tabselected' : ''), sub { a_ href => "/$v->{id}/tags#tags", name => 'tags', 'tags' };
+ li_ class => ($tab eq 'chars' ? ' tabselected' : ''), sub { a_ href => "/$v->{id}/chars#chars", name => 'chars', "characters ($chars)" } if $chars;
+ if($v->{reviews}{mini} > 4 || $tab eq 'minireviews' || $tab eq 'fullreviews') {
+ li_ class => ($tab eq 'minireviews'?' tabselected' : ''), sub { a_ href => "/$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->{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->{id}/reviews#review", name => 'review', sprintf 'reviews (%d)', $v->{reviews}{total} };
+ }
+ li_ class => ($tab eq 'quotes' ? ' tabselected' : ''), sub { a_ href => "/$v->{id}/quotes#quotes", name => 'quotes', "quotes ($quotes)" };
+ };
+ menu_ sub {
+ if(auth && canvote $v) {
+ my $id = tuwf->dbVali('SELECT id FROM reviews WHERE vid =', \$v->{id}, 'AND uid =', \auth->uid);
+ li_ sub { a_ href => "/$v->{id}/addreview", 'add review' } if !$id && can_edit w => {};
+ li_ sub { a_ href => "/$id/edit", 'edit review' } if $id;
+ }
+ if(auth->permEdit) {
+ li_ sub { a_ href => "/$v->{id}/add", 'add release' };
+ li_ sub { a_ href => "/$v->{id}/addchar", 'add character' };
+ }
+ };
+ }
+}
+
+
+sub releases_ {
+ my($v) = @_;
+
+ enrich_release $v->{releases};
+ $v->{releases} = sort_releases $v->{releases};
+
+ my(%lang, %langrel, %langmtl);
+ for my $r ($v->{releases}->@*) {
+ for ($r->{titles}->@*) {
+ push $lang{$_->{lang}}->@*, $r;
+ $langmtl{$_->{lang}} = ($langmtl{$_->{lang}}//1) && $_->{mtl};
+ }
+ }
+ $langrel{$_} = min map $_->{released}, $lang{$_}->@* for keys %lang;
+ my @lang = sort { $langrel{$a} <=> $langrel{$b} || ($b eq $v->{olang}) cmp ($a eq $v->{olang}) || $a cmp $b } keys %lang;
+ my $pref = prefs;
+
+ my sub lang_ {
+ my($lang) = @_;
+ my $ropt = { id => $lang, lang => $lang };
+ my $mtl = $langmtl{$lang};
+ my $open = ($pref->{vnrel_olang} && $lang eq $v->{olang} && !$mtl) || ($pref->{vnrel_langs}{$lang} && (!$mtl || $pref->{vnrel_mtl}));
+ details_ open => $open?'open':undef, sub {
+ summary_ $mtl ? (class => 'mtl') : (), sub {
+ abbr_ class => "icon-lang-$lang".($mtl?' mtl':''), title => $LANGUAGE{$lang}{txt}, '';
+ txt_ $LANGUAGE{$lang}{txt};
+ small_ sprintf ' (%d)', scalar $lang{$lang}->@*;
+ };
+ table_ class => 'releases', sub {
+ release_row_ $_, $ropt for $lang{$lang}->@*;
+ };
+ };
+ }
+
+ article_ class => 'vnreleases', sub {
+ h1_ 'Releases';
+ if(!$v->{releases}->@*) {
+ p_ 'We don\'t have any information about releases of this visual novel yet...';
+ } else {
+ lang_ $_ for @lang;
+ }
+ }
+}
+
+
+sub staff_cols_ {
+ my($lst) = @_;
+
+ # XXX: The staff listing is included in the page 3 times, for 3 different
+ # layouts. A better approach to get the same layout is to add the boxes to
+ # the HTML once with classes indicating the box position (e.g.
+ # "4col-col1-row1 3col-col2-row1" etc) and then using CSS to position the
+ # box appropriately. My attempts to do this have failed, however. The
+ # layouting can also be done in JS, but that's not my preferred option.
+
+ # Step 1: Get a list of 'boxes'; Each 'box' represents a role with a list of staff entries.
+ # @boxes = [ $height, $roleimp, $html ]
+ my %roles;
+ push $roles{$_->{role}}->@*, $_ for grep $_->{sid}, @$lst;
+ my $i=0;
+ my @boxes =
+ sort { $b->[0] <=> $a->[0] || $a->[1] <=> $b->[1] }
+ map [ 2+$roles{$_}->@*, $i++,
+ xml_string sub {
+ li_ class => 'vnstaff_head', $CREDIT_TYPE{$_};
+ li_ sub {
+ a_ href => "/$_->{sid}", tattr $_;
+ small_ $_->{note} if $_->{note};
+ } for sort { $a->{title}[1] cmp $b->{title}[1] } $roles{$_}->@*;
+ }
+ ], grep $roles{$_}, keys %CREDIT_TYPE;
+
+ # Step 2. Assign boxes to columns for 2 to 4 column layouts,
+ # efficiently packing the boxes to use the least vertical space,
+ # sorting the columns and boxes within columns by role importance.
+ # (There is no 1-column layout, that's just the 2-column layout stacked with css)
+ my @cols = map [map [0,99,[]], 1..$_], 2..4; # [ $height, $min_roleimp, $boxes ] for each column in each layout
+ for my $c (@cols) {
+ for (@boxes) {
+ my $smallest = $c->[0];
+ $c->[$_][0] < $smallest->[0] && ($smallest = $c->[$_]) for 1..$#$c;
+ $smallest->[0] += $_->[0];
+ $smallest->[1] = $_->[1] if $_->[1] < $smallest->[1];
+ push $smallest->[2]->@*, $_;
+ }
+ $_->[2] = [ sort { $a->[1] <=> $b->[1] } $_->[2]->@* ] for @$c;
+ @$c = sort { $a->[1] <=> $b->[1] } @$c;
+ }
+
+ div_ class => sprintf('vnstaff-%d', scalar @$_), sub {
+ ul_ sub {
+ lit_ $_->[2] for $_->[2]->@*;
+ } for @$_
+ } for @cols;
+}
+
+
+sub staff_ {
+ my($v) = @_;
+ return if !$v->{staff}->@*;
+
+ my %staff;
+ push $staff{ $_->{eid} // '' }->@*, $_ for $v->{staff}->@*;
+ my $pref = prefs;
+
+ article_ class => 'vnstaff', id => 'staff', sub {
+ h1_ 'Staff';
+ if (!$v->{editions}->@*) {
+ staff_cols_ $v->{staff};
+ return;
+ }
+ for my $e (undef, $v->{editions}->@*) {
+ my $lst = $staff{ $e ? $e->{eid} : '' };
+ next if !$lst;
+ my $lang = ($e && $e->{lang}) || $v->{olang};
+ my $unoff = $e && !$e->{official};
+ my $open = ($pref->{staffed_olang} && !$e) || ($pref->{staffed_langs}{$lang} && (!$unoff || $pref->{staffed_unoff}));
+ details_ open => $open?'open':undef, sub {
+ summary_ sub {
+ abbr_ class => "icon-lang-$e->{lang}", title => $LANGUAGE{$e->{lang}}{txt}, '' if $e && $e->{lang};
+ txt_ 'Original edition' if !$e;
+ txt_ $e->{name} if $e;
+ small_ ' (unofficial)' if $unoff;
+ };
+ staff_cols_ $lst;
+ };
+ }
+ };
+}
+
+
+sub charsum_ {
+ my($v) = @_;
+
+ my $spoil = viewget->{spoilers};
+ my $c = tuwf->dbAlli('
+ SELECT c.id, c.title, c.gender, v.role
+ FROM', charst, 'c
+ JOIN (SELECT id, MIN(role) FROM chars_vns WHERE role <> \'appears\' AND spoil <=', \$spoil, 'AND vid =', \$v->{id}, 'GROUP BY id) v(id,role) ON c.id = v.id
+ WHERE NOT c.hidden
+ ORDER BY v.role, c.name, c.id'
+ );
+ return if !@$c;
+ enrich seiyuu => id => cid => sub { sql('
+ SELECT vs.cid, sa.id, sa.title, vs.note
+ FROM vn_seiyuu vs
+ JOIN', staff_aliast, 'sa ON sa.aid = vs.aid
+ WHERE vs.id =', \$v->{id}, 'AND vs.cid IN', $_, '
+ ORDER BY sa.sorttitle'
+ ) }, $c;
+
+ article_ 'data-mainbox-summarize' => 210, sub {
+ p_ class => 'mainopts', sub {
+ a_ href => "/$v->{id}/chars#chars", 'Full character list';
+ };
+ h1_ 'Character summary';
+ div_ class => 'charsum_list', sub {
+ div_ class => 'charsum_bubble', sub {
+ div_ class => 'name', sub {
+ span_ sub {
+ abbr_ class => "icon-gen-$_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
+ a_ href => "/$_->{id}", tattr $_;
+ };
+ em_ $CHAR_ROLE{$_->{role}}{txt};
+ };
+ div_ class => 'actor', sub {
+ txt_ 'Voiced by';
+ $_->{seiyuu}->@* > 1 ? br_ : txt_ ' ';
+ join_ \&br_, sub {
+ a_ href => "/$_->{id}", tattr $_;
+ small_ $_->{note} if $_->{note};
+ }, $_->{seiyuu}->@*;
+ } if $_->{seiyuu}->@*;
+ } for @$c;
+ };
+ };
+}
+
+
+sub stats_ {
+ my($v) = @_;
+
+ my $stats = tuwf->dbAlli('
+ SELECT (uv.vote::numeric/10)::int AS idx, COUNT(uv.vote) as votes, SUM(uv.vote) AS total
+ FROM ulist_vns uv
+ WHERE uv.vote IS NOT NULL
+ AND NOT EXISTS(SELECT 1 FROM users u WHERE u.id = uv.uid AND u.ign_votes)
+ AND uv.vid =', \$v->{id}, '
+ GROUP BY (uv.vote::numeric/10)::int'
+ );
+ my $sum = sum map $_->{total}, @$stats;
+ my $max = max map $_->{votes}, @$stats;
+ my $num = sum map $_->{votes}, @$stats;
+
+ my $recent = @$stats && tuwf->dbAlli('
+ SELECT uv.vote, uv.c_private,', sql_totime('uv.vote_date'), 'as date, ', sql_user(), '
+ FROM ulist_vns uv
+ JOIN users u ON u.id = uv.uid
+ 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', \($v->{reviews}{total} ? 7 : 8)
+ );
+
+ my $rank = $v->{c_votecount} && tuwf->dbRowi('SELECT c_average, c_rating, c_pop_rank, c_rat_rank FROM vn v WHERE id =', \$v->{id});
+
+ my sub votestats_ {
+ table_ class => 'votegraph', sub {
+ thead_ sub { tr_ sub { td_ colspan => 2, 'Vote stats' } };
+ tfoot_ sub { tr_ sub { td_ colspan => 2, sub {
+ txt_ sprintf '%d vote%s%s', $num, $num == 1 ? '' : 's', $rank && $rank->{c_pop_rank} ? sprintf ' (rank %d)', $rank->{c_pop_rank} : '';
+ br_;
+ txt_ sprintf '%.02f average (%s%s)', $sum/$num/10,
+ $rank && $rank->{c_rating} && $rank->{c_rating} != $rank->{c_average} ? sprintf '%.02f weighted, ', $rank->{c_rating}/100 : '',
+ $rank && $rank->{c_rat_rank} ? sprintf('rank %d', $rank->{c_rat_rank}) : 'unranked';
+ } } };
+ tr_ sub {
+ my $num = $_;
+ my $votes = [grep $num == $_->{idx}, @$stats]->[0]{votes} || 0;
+ td_ class => 'number', $num;
+ td_ class => 'graph', sub {
+ div_ style => sprintf('width: %dpx', ($votes||0)/$max*250), ' ';
+ txt_ $votes||0;
+ };
+ } for (reverse 1..10);
+ };
+
+ table_ class => 'recentvotes stripe', sub {
+ thead_ sub { tr_ sub { td_ colspan => 3, sub {
+ txt_ 'Recent votes';
+ span_ sub {
+ txt_ '(';
+ a_ href => "/$v->{id}/votes", 'show all';
+ txt_ ')';
+ }
+ } } };
+ tfoot_ sub { tr_ sub { td_ colspan => 3, sub {
+ a_ href => "/$v->{id}/reviews#review", sprintf'%d review%s »', $v->{reviews}{total}, $v->{reviews}{total}==1?'':'s';
+ } } } if $v->{reviews}{total};
+ tr_ sub {
+ td_ sub {
+ small_ 'hidden' if $_->{c_private};
+ user_ $_ if !$_->{c_private};
+ };
+ td_ fmtvote $_->{vote};
+ td_ fmtdate $_->{date};
+ } for @$recent;
+ } if $recent && @$recent;
+ clearfloat_;
+ }
+
+ article_ id => 'stats', sub {
+ h1_ 'User stats';
+ if(!@$stats) {
+ p_ 'Nobody has voted on this visual novel yet...';
+ } else {
+ div_ class => 'votestats', \&votestats_;
+ }
+ }
+}
+
+
+sub screenshots_ {
+ my($v) = @_;
+ my $s = $v->{screenshots};
+ return if !@$s;
+
+ my $sexp = auth->pref('max_sexual')||0;
+ my $viop = auth->pref('max_violence')||0;
+ $viop = 0 if $sexp < 0;
+ my $sexs = min($sexp, max map $_->{scr}{sexual}, @$s);
+ my $vios = min($viop, max map $_->{scr}{violence}, @$s);
+
+ my @sex = (0,0,0);
+ my @vio = (0,0,0);
+ for (@$s) { $sex[$_->{scr}{sexual}]++; $vio[$_->{scr}{violence}]++ }
+
+ my %rel;
+ push $rel{$_->{rid}}->@*, $_ for grep $_->{rid}, @$s;
+
+ input_ name => 'scrhide_s', id => "scrhide_s$_", type => 'radio', class => 'hidden', $sexs == $_ ? (checked => 'checked') : () for 0..2;
+ input_ name => 'scrhide_v', id => "scrhide_v$_", type => 'radio', class => 'hidden', $vios == $_ ? (checked => 'checked') : () for 0..2;
+ article_ id => 'screenshots', sub {
+
+ p_ class => 'mainopts', sub {
+ if($sexp < 0 || $sex[1] || $sex[2]) {
+ label_ for => 'scrhide_s0', class => 'fake_link', "Safe ($sex[0])";
+ label_ for => 'scrhide_s1', class => 'fake_link', "Suggestive ($sex[1])" if $sex[1];
+ label_ for => 'scrhide_s2', class => 'fake_link', "Explicit ($sex[2])" if $sex[2];
+ }
+ small_ ' | ' if ($sexp < 0 || $sex[1] || $sex[2]) && ($vio[1] || $vio[2]);
+ if($vio[1] || $vio[2]) {
+ label_ for => 'scrhide_v0', class => 'fake_link', "Tame ($vio[0])";
+ label_ for => 'scrhide_v1', class => 'fake_link', "Violent ($vio[1])" if $vio[1];
+ label_ for => 'scrhide_v2', class => 'fake_link', "Brutal ($vio[2])" if $vio[2];
+ }
+ } if $sexp < 0 || $sex[1] || $sex[2] || $vio[1] || $vio[2];
+
+ h1_ 'Screenshots';
+
+ for my $r (grep $rel{$_->{id}}, $v->{releases}->@*) {
+ p_ class => 'rel', sub {
+ abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, '' for $r->{titles}->@*;
+ platform_ $_ for $r->{platforms}->@*;
+ a_ href => "/$r->{id}", tattr $r;
+ };
+ div_ class => 'scr', sub {
+ a_ href => imgurl($_->{scr}{id}),
+ 'data-iv' => "$_->{scr}{width}x$_->{scr}{height}:scr:$_->{scr}{sexual}$_->{scr}{violence}$_->{scr}{votecount}",
+ mkclass(
+ scrlnk => 1,
+ scrlnk_s0 => $_->{scr}{sexual} <= 0,
+ scrlnk_s1 => $_->{scr}{sexual} <= 1,
+ scrlnk_v0 => $_->{scr}{violence} >= 1,
+ scrlnk_v1 => $_->{scr}{violence} >= 2,
+ nsfw => $_->{scr}{sexual} || $_->{scr}{violence},
+ ),
+ sub {
+ my($w, $h) = imgsize $_->{scr}{width}, $_->{scr}{height}, config->{scr_size}->@*;
+ img_ src => imgurl($_->{scr}{id}, 't'), width => $w, height => $h, alt => "Screenshot $_->{scr}{id}";
+ } for $rel{$r->{id}}->@*;
+ }
+ }
+ }
+}
+
+
+sub tags_ {
+ my($v) = @_;
+ if(!$v->{tags}->@*) {
+ article_ 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::vndbid, NULL::vndbid FROM (VALUES", sql_join(',', map sql('(',\$_,')'), keys %tags), ") AS x(tag)
+ UNION
+ SELECT tp.parent, tp.id FROM tags_parents tp, parents a WHERE a.tag = tp.id AND tp.main
+ ) 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->{override} //= min map $tags{$_}{override}//$tags{$_}{spoiler}, $t->{childs}->@* if grep defined($tags{$_}{override}), $t->{childs}->@*;
+ $t->{rating} //= sum(map $tags{$_}{rating}, $t->{childs}->@*) / $t->{childs}->@*;
+ }
+ scores $_ for @roots;
+
+ my $view = viewget;
+ my sub rec {
+ my($lvl, $t) = @_;
+ return if ($t->{override}//$t->{spoiler}) > $view->{spoilers};
+ li_ class => "tagvnlist-top", sub {
+ h3_ sub { a_ href => "/$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});
+ small_ '━━'x($lvl-1).' ' if $lvl > 1;
+ a_ href => "/$t->{id}", mkclass(
+ $t->{color} ? ($t->{color}, $t->{color} =~ /standout|grayedout/ ? 1 : 0) : (),
+ lie => $t->{lie} && ($view->{spoilers} > 1 || defined $t->{override}),
+ parent => !$t->{rating}
+ ), ($t->{color}//'') =~ /^#/ ? (style => "color: $t->{color}") : (),
+ $t->{name};
+ spoil_ $t->{spoiler};
+ a_ href => "/g/links?v=$v->{id}&t=$t->{id}", class => 'grayedout', " ($t->{count})" if $t->{count};
+ } if $lvl;
+
+ if($t->{childs}) {
+ __SUB__->($lvl+1, $_) for sort { $a->{name} cmp $b->{name} } map $tags{$_}, $t->{childs}->@*;
+ }
+ }
+
+ article_ sub {
+ my $max_spoil = max map $_->{lie}?2:$_->{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 tuwf->captures('id', 'rev');
+ return tuwf->resNotFound if !$v;
+
+ enrich_item $v, 1;
+
+ framework_ title => $v->{title}[1], index => !tuwf->capture('rev'), dbobj => $v, hiddenmsg => 1, js => 1, og => og($v),
+ sub {
+ rev_ $v if tuwf->capture('rev');
+ infobox_ $v;
+ tabs_ $v, 0;
+ releases_ $v;
+ staff_ $v;
+ charsum_ $v;
+ stats_ $v;
+ screenshots_ $v;
+ };
+};
+
+
+TUWF::get qr{/$RE{vid}/tags}, sub {
+ my $v = db_entry tuwf->capture('id');
+ return tuwf->resNotFound if !$v;
+
+ enrich_vn $v;
+
+ framework_ title => $v->{title}[1], index => 1, dbobj => $v, hiddenmsg => 1,
+ sub {
+ infobox_ $v, 1;
+ tabs_ $v, 'tags';
+ tags_ $v;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/VN/Quotes.pm b/lib/VNWeb/VN/Quotes.pm
new file mode 100644
index 00000000..4edd1aaa
--- /dev/null
+++ b/lib/VNWeb/VN/Quotes.pm
@@ -0,0 +1,399 @@
+package VNWeb::VN::Quotes;
+
+use VNWeb::Prelude;
+
+sub deletable {
+ my($q) = @_;
+ !$q->{hidden} && $q->{addedby} && auth && $q->{addedby} eq auth->uid && auth->permEdit && $q->{added} > time()-5*24*3600;
+}
+
+sub editable {
+ auth->permDbmod || deletable @_;
+}
+
+sub submittable {
+ my($vid) = @_;
+ auth->permDbmod || (auth->permEdit && tuwf->dbVali(q{SELECT COUNT(*) FROM quotes WHERE added > NOW() - '1 day'::interval AND addedby =}, \auth->uid) < 5);
+}
+
+# Also used by Chars::Page
+sub votething_ {
+ my($q) = @_;
+ if (auth) {
+ $q->{id} *= 1;
+ span_ class => 'quote-score', widget(QuoteVote => [@{$q}{qw/id score vote/}, $_->{hidden} ? \1 : \0, editable($q) ? \1 : \0]), '';
+ } else {
+ span_ $q->{score};
+ }
+}
+
+TUWF::get qr{/$RE{vid}/quotes}, sub {
+ my $v = db_entry tuwf->capture('id');
+ return tuwf->resNotFound if !$v->{id} || $v->{entry_hidden};
+ VNWeb::VN::Page::enrich_vn($v);
+
+ my $lst = tuwf->dbAlli('
+ SELECT q.id, q.score, q.quote,', sql_totime('q.added'), 'AS added, q.addedby, q.cid, c.title, v.spoil
+ FROM quotes q
+ LEFT JOIN', charst, 'c ON c.id = q.cid
+ LEFT JOIN (SELECT id, MIN(spoil) FROM chars_vns WHERE vid =', \$v->{id}, 'GROUP BY id) v(id,spoil) ON c.id = v.id
+ WHERE NOT q.hidden
+ AND vid =', \$v->{id}, '
+ ORDER BY q.score DESC, q.quote
+ ');
+ enrich_merge id => sql('SELECT id, vote FROM quotes_votes WHERE uid =', \auth->uid, 'AND id IN'), $lst if auth;
+
+ my $view = viewget;
+ my $max_spoil = max 0, grep $_, map $_->{spoil}, @$lst;
+
+ framework_ title => "Quotes for $v->{title}[1]", dbobj => $v, hiddenmsg => 1, sub {
+ VNWeb::VN::Page::infobox_($v);
+ VNWeb::VN::Page::tabs_($v, 'quotes');
+ article_ sub {
+ h1_ "Quotes";
+ p_ submittable($v->{id}) ? sub {
+ txt_ 'No quotes yet, maybe ';
+ a_ href => "/$v->{id}/addquote", 'submit a quote yourself';
+ txt_ '?';
+ } : sub {
+ txt_ 'No quotes yet.';
+ };
+ } if !@$lst;
+ article_ sub {
+ p_ class => 'mainopts', sub {
+ if ($max_spoil) {
+ a_ mkclass(checked => $view->{spoilers} == 0), href => '?view='.viewset(spoilers=>0).'#quotes', 'Hide spoilers';
+ a_ mkclass(checked => $view->{spoilers} == 1), href => '?view='.viewset(spoilers=>1).'#quotes', 'Show minor spoilers';
+ a_ mkclass(standout =>$view->{spoilers} == 2), href => '?view='.viewset(spoilers=>2).'#quotes', 'Spoil me!' if $max_spoil == 2;
+ small_ ' | ';
+ }
+ if (auth->permDbmod) {
+ a_ href => "/v/quotes?v=$v->{id}", 'details';
+ small_ ' | ';
+ }
+ a_ href => "/$v->{id}/addquote", 'submit a quote';
+ } if submittable($v->{id});
+ h1_ "Quotes";
+ table_ sub {
+ tr_ sub {
+ td_ sub { votething_ $_ };
+ td_ sub {
+ if ($_->{cid} && ($_->{spoil}||0) <= $view->{spoilers}) {
+ small_ '[';
+ a_ href => "/$_->{cid}", tattr $_;
+ small_ '] ';
+ }
+ txt_ $_->{quote};
+ };
+ } for @$lst;
+ };
+ p_ sub {
+ small_ 'Vote to like/dislike a quote, typos and other errors should be reported on the forums.';
+ } if auth;
+ } if @$lst;
+ };
+};
+
+
+sub listing_ {
+ my($lst, $count, $opt, $url) = @_;
+ paginate_ $url, $opt->{p}, [$count, 50], 't';
+ article_ class => 'browse quotes', sub {
+ table_ class => 'stripe', sub {
+ tr_ sub {
+ td_ class => 'tc1', sub { votething_ $_ };
+ td_ class => 'tc2', sub { txt_ fmtdate $_->{added}, 'full' };
+ td_ class => 'tc3', sub {
+ a_ href => $url->(u => $_->{addedby}, p=>undef), class => 'setfil', '> ' if $_->{addedby} && !defined $opt->{u};
+ user_ $_;
+ };
+ td_ sub {
+ a_ href => $url->(v => $_->{vid}, p=>undef), class => 'setfil', '> ' if !defined $opt->{v};
+ a_ href => "/$_->{vid}/quotes#quotes", tattr $_;
+ br_;
+ if ($_->{cid}) {
+ small_ '[';
+ a_ href => "/$_->{cid}", tattr $_->{char};
+ small_ '] ';
+ }
+ txt_ $_->{quote};
+ };
+ } for @$lst;
+ };
+ };
+ paginate_ $url, $opt->{p}, [$count, 50], 'b';
+}
+
+sub opts_ {
+ my($opt) = @_;
+
+ my sub obj_ {
+ my($key, $label) = @_;
+ my $v = $opt->{$key} // return;
+ my $o = dbobj $v;
+ tr_ sub {
+ td_ "$label:";
+ td_ sub {
+ input_ type => 'checkbox', name => $key, value => $v, checked => 'checked';
+ lit_ ' ';
+ a_ href => "/$v", $o && $o->{id} && $o->{title}[1] ? tattr $o : $v;
+ };
+ };
+ }
+
+ my sub opt_ {
+ my($key, $val, $label) = @_;
+ label_ sub {
+ lit_ ' ';
+ input_ type => 'radio', name => $key, value => $val//'',
+ checked => ($opt->{$key}//'undef') eq ($val//'undef') ? 'checked' : undef;
+ lit_ ' ';
+ txt_ $label;
+ };
+ };
+
+ form_ sub {
+ table_ style => 'margin: auto', sub {
+ obj_ v => 'VN';
+ obj_ u => 'User';
+ tr_ sub {
+ td_ 'State:';
+ td_ sub {
+ opt_ h => undef, 'any';
+ opt_ h => 0 => 'Visible';
+ opt_ h => 1 => 'Deleted';
+ };
+ } if auth->permDbmod;
+ tr_ sub {
+ td_ 'Has char:';
+ td_ sub {
+ opt_ c => undef, 'any';
+ opt_ c => 0, 'no';
+ opt_ c => 1, 'yes';
+ };
+ };
+ tr_ sub {
+ td_ 'Order by:';
+ td_ sub {
+ opt_ s => added => 'date added';
+ opt_ s => lastmod => 'last modified';
+ opt_ s => top => 'highest score';
+ opt_ s => bottom => 'lowest score';
+ };
+ };
+ tr_ sub {
+ td_ '';
+ td_ sub { input_ type => 'submit', class => 'submit', value => 'Update' };
+ }
+ };
+ };
+}
+
+TUWF::get '/v/quotes', sub {
+ return tuwf->resDenied if !auth;
+ my $opt = tuwf->validate(get =>
+ v => { default => undef, vndbid => 'v' },
+ u => { default => undef, vndbid => 'u' },
+ h => { undefbool => 1 },
+ c => { undefbool => 1 },
+ s => { default => 'added', enum => [qw/added lastmod top bottom/] },
+ p => { upage => 1 },
+ )->data;
+ $opt->{h} = 0 if !auth->permDbmod;
+
+ my $u = $opt->{u} && tuwf->dbRowi('SELECT id,', sql_user(), 'FROM users u WHERE id =', \$opt->{u});
+ return tuwf->resNotFound if $opt->{u} && (!$u->{id} || (!defined $u->{user_name} && !auth->isMod));
+
+ my $where = sql_and
+ $opt->{v} ? sql('q.vid =', \$opt->{v}) : (),
+ $opt->{u} ? sql('q.addedby =', \$opt->{u}) : (),
+ defined $opt->{h} ? sql($opt->{h} ? '' : 'NOT', 'q.hidden') : (),
+ defined $opt->{c} ? sql('q.cid', $opt->{c} ? 'IS NOT NULL' : 'IS NULL') : ();
+
+ my $count = tuwf->dbVali('SELECT COUNT(*) FROM quotes q WHERE', $where);
+ my $lst = !$count ? [] : tuwf->dbPagei({ results => 50, page => $opt->{p} }, '
+ SELECT q.id, q.hidden, q.score, q.quote, q.addedby, q.vid, q.cid
+ , v.title, c.title AS char,', sql_user(), '
+ , ', sql_totime('q.added'), 'added
+ FROM quotes q
+ JOIN', vnt, 'v ON v.id = q.vid
+ LEFT JOIN', charst, 'c ON c.id = q.cid
+ LEFT JOIN users u ON u.id = q.addedby
+ ', $opt->{s} eq 'lastmod' ? 'LEFT JOIN (
+ SELECT id, MAX(date) FROM quotes_log GROUP BY id
+ ) l (id, latest) ON l.id = q.id' : (), '
+ WHERE', $where, '
+ ORDER BY ', {
+ added => 'q.id DESC',
+ lastmod => 'l.latest DESC, q.id DESC',
+ top => 'q.score DESC, q.id',
+ bottom => 'q.score, q.id',
+ }->{$opt->{s}}
+ );
+ enrich_merge id => sql('SELECT id, vote FROM quotes_votes WHERE uid =', \auth->uid, 'AND id IN'), $lst if auth;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ framework_ title => 'Quotes browser', sub {
+ article_ sub {
+ h1_ 'Quotes browser';
+ opts_ $opt;
+ };
+ listing_ $lst, $count, $opt, \&url if @$lst;
+ };
+};
+
+
+my $FORM = {
+ id => { uint => 1, default => undef },
+ vid => { vndbid => 'v' },
+ hidden => { anybool => 1 },
+ quote => { sl => 1, maxlength => 170 },
+ cid => { vndbid => 'c', default => undef },
+ title => { _when => 'out' },
+ alttitle => { _when => 'out' },
+ chars => { _when => 'out', aoh => {
+ id => { vndbid => 'c' },
+ title => {},
+ alttitle => {},
+ } },
+ delete => { anybool => 1 },
+};
+
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_OUT = form_compile out => $FORM;
+
+TUWF::get qr{/(?:$RE{vid}/addquote|editquote/$RE{num})}, sub {
+ my($vid, $qid) = tuwf->captures('id', 'num');
+
+ my $q = $qid && tuwf->dbRowi('
+ SELECT q.id, q.vid, q.hidden, q.quote,', sql_totime('q.added'), 'added, q.addedby, q.cid, c.title
+ FROM quotes q
+ LEFT JOIN', charst, 'c ON c.id = q.cid
+ WHERE q.id = ', \$qid
+ );
+ return tuwf->resNotFound if $qid && !$q->{id};
+ $vid ||= $q->{vid};
+
+ my $v = $vid && dbobj $vid;
+ return tuwf->resNotFound if $vid && (!$v->{id} || $v->{entry_hidden});
+ return tuwf->resDenied if $qid ? !editable $q : !submittable $vid;
+
+ my $log = $qid && tuwf->dbAlli('
+ SELECT ', sql_totime('q.date'), 'date, q.action,', sql_user(), '
+ FROM quotes_log q
+ LEFT JOIN users u ON u.id = q.uid
+ WHERE q.id = ', \$qid, '
+ ORDER BY q.date DESC
+ ');
+
+ my $chars = tuwf->dbAlli('
+ SELECT id, title[1+1] AS title, title[1+1+1+1] AS alttitle
+ FROM ', charst, '
+ WHERE NOT hidden AND id IN(SELECT id FROM chars_vns WHERE vid =', \$v->{id}, ')
+ ORDER BY sorttitle, id
+ ');
+
+ my $title = ($qid ? 'Edit' : 'Add')." quote for $v->{title}[1]";
+ framework_ title => $title, dbobj => $v, sub {
+ article_ sub {
+ h1_ $title;
+ h2_ 'Some rules:';
+ ul_ sub {
+ li_ 'Quotes must be in English. You may use your own translation.';
+ li_ 'Quotes should be interesting, funny and/or insightful out of context.';
+ li_ 'Quotes must come from an actual release of the visual novel.';
+ li_ 'Quotes may not contain spoilers.';
+ li_ 'At most 170 characters per quote, but shorter quotes are preferred.';
+ li_ 'You may submit at most 5 quotes per day.';
+ li_ "This quotes feature is more of a silly gimmick than a proper database feature, keep your expectations low.";
+ };
+ br_;
+ div_ widget(QuoteEdit => $FORM_OUT, { $qid ? (
+ id => $q->{id}, hidden => $q->{hidden}, quote => $q->{quote},
+ cid => $q->{cid}, title => $q->{title}[1], alttitle => $q->{title}[3],
+ ) : elm_empty($FORM_OUT)->%*, chars => $chars, vid => $vid, delete => deletable($q) }), '';
+ };
+ if ($log && @$log) {
+ nav_ sub {
+ h1_ 'Log';
+ };
+ article_ class => 'browse', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', 'Date';
+ td_ 'User';
+ td_ 'Action';
+ } };
+ tr_ sub {
+ td_ class => 'tc1', fmtdate $_->{date}, 'full';
+ td_ sub { user_ $_; };
+ td_ sub {
+ lit_ bb_format $_->{action}, inline => 1;
+ };
+ } for @$log;
+ };
+ };
+ }
+ };
+};
+
+js_api QuoteEdit => $FORM_IN, sub {
+ my($data) = @_;
+
+ my $v = dbobj $data->{vid};
+ return tuwf->resNotFound if !$v->{id} || $v->{entry_hidden};
+
+ my $q = $data->{id} && tuwf->dbRowi('SELECT id, hidden, quote,', sql_totime('added'), 'added, addedby, cid FROM quotes WHERE id = ', \$data->{id});
+ return tuwf->resDenied if $data->{id} && (!$q->{id} || !editable $q);
+
+ if ($data->{id}) {
+ my %set = (
+ !$data->{hidden} ne !$q->{hidden} ? (hidden => $data->{hidden}) : (),
+ $data->{quote} ne $q->{quote} ? (quote => $data->{quote}) : (),
+ ($data->{cid}//'') ne ($q->{cid}//'') ? (cid => $data->{cid}) : (),
+ );
+ tuwf->dbExeci('UPDATE quotes SET', \%set, 'WHERE id =', \$data->{id}) if keys %set;
+ tuwf->dbExeci('INSERT INTO quotes_log', {
+ id => $data->{id}, uid => auth->uid,
+ action => join '; ',
+ exists $set{hidden} ? "State: ".($q->{hidden}?"Deleted":"New")." -> ".($data->{hidden}?"Deleted":"New") : (),
+ exists $set{cid} ? "Character: ".($q->{cid}||'empty')." -> ".($data->{cid}||'empty') : (),
+ exists $set{quote} ? "Quote: \"[i][raw]$q->{quote} [/raw][/i]\" -> \"[i][raw]$data->{quote} [/raw][/i]\"" : (),
+ }) if keys %set;
+
+ } else {
+ return 'You have already submitted 5 quotes today, try again tomorrow.' if !submittable($data->{vid});
+ my sub norm { sql 'lower(regexp_replace(', $_[0], q{, '[\s",.]+', '', 'g'))} }
+ return 'This quote has already been submitted.'
+ if tuwf->dbVali('SELECT 1 FROM quotes WHERE vid =', \$data->{vid}, 'AND', norm(\$data->{quote}), '=', norm('quote'));
+
+ my $id = tuwf->dbVali('INSERT INTO quotes', {
+ vid => $v->{id},
+ cid => $data->{cid},
+ addedby => auth->uid,
+ quote => $data->{quote},
+ auth->permDbmod ? (hidden => $data->{hidden}) : (),
+ }, 'RETURNING id');
+ tuwf->dbExeci('INSERT INTO quotes_votes', {id => $id, uid => auth->uid, vote => 1});
+ tuwf->dbExeci('INSERT INTO quotes_log', {id => $id, uid => auth->uid, action => 'Submitted'});
+ }
+ +{}
+};
+
+js_api QuoteDel => { id => { uint => 1 } }, sub {
+ my $q = tuwf->dbRowi('SELECT id, hidden,', sql_totime('added'), 'added, addedby FROM quotes WHERE id = ', \$_[0]{id});
+ return tuwf->resDenied if !$q->{id} || !deletable $q;
+ tuwf->dbExeci('DELETE FROM quotes WHERE id =', \$q->{id});
+ +{}
+};
+
+js_api QuoteVote => { id => { uint => 1 }, vote => { default => undef, enum => [-1,1] } }, sub {
+ my($data) = @_;
+ tuwf->dbExeci('DELETE FROM quotes_votes WHERE', { uid => auth->uid, id => $data->{id} }) if !$data->{vote};
+ $data->{uid} = auth->uid;
+ tuwf->dbExeci('INSERT INTO quotes_votes', $data, 'ON CONFLICT (id, uid) DO UPDATE SET vote =', \$data->{vote}) if $data->{vote};
+ +{}
+};
+
+1;
diff --git a/lib/VNWeb/VN/Tagmod.pm b/lib/VNWeb/VN/Tagmod.pm
new file mode 100644
index 00000000..367d95f0
--- /dev/null
+++ b/lib/VNWeb/VN/Tagmod.pm
@@ -0,0 +1,121 @@
+package VNWeb::VN::Tagmod;
+
+use VNWeb::Prelude;
+
+
+my $FORM = {
+ id => { vndbid => 'v' },
+ title => { _when => 'out' },
+ tags => { sort_keys => 'id', aoh => {
+ id => { vndbid => 'g' },
+ vote => { int => 1, enum => [ -3..3 ] },
+ spoil => { default => undef, uint => 1, enum => [ 0..2 ] },
+ lie => { undefbool => 1 },
+ overrule => { anybool => 1 },
+ notes => { default => '', sl => 1, maxlength => 1000 },
+ cat => { _when => 'out' },
+ name => { _when => 'out' },
+ rating => { _when => 'out', num => 1 },
+ count => { _when => 'out', uint => 1 },
+ spoiler => { _when => 'out', num => 1 },
+ islie => { _when => 'out', anybool => 1 },
+ overruled => { _when => 'out', anybool => 1 },
+ othnotes => { _when => 'out' },
+ hidden => { _when => 'out', anybool => 1 },
+ locked => { _when => 'out', anybool => 1 },
+ applicable => { _when => 'out', anybool => 1 },
+ } },
+ mod => { _when => 'out', anybool => 1 },
+};
+
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_OUT = form_compile out => $FORM;
+
+
+sub can_tag { auth->permTagmod || (auth->permTag && !global_settings->{lockdown_edit}) }
+
+
+elm_api Tagmod => $FORM_OUT, $FORM_IN, sub {
+ my($id, $tags) = $_[0]->@{'id', 'tags'};
+ return elm_Unauth if !can_tag;
+
+ $tags = [ grep $_->{vote}, @$tags ];
+ $_->{overrule} = 0 for auth->permTagmod ? () : @$tags;
+
+ # Weed out invalid/deleted/non-applicable tags.
+ # Voting on non-applicable tags is still allowed if there are existing votes for this tag on this VN.
+ enrich_merge id => sql('
+ SELECT tag AS id, 1 as exists FROM tags_vn WHERE vid =', \$id, '
+ UNION
+ SELECT id, 1 as exists FROM tags WHERE NOT (hidden AND locked) AND applicable AND id IN'
+ ), $tags;
+ $tags = [ grep $_->{exists}, @$tags ];
+
+ # Find out if any of these tags are being overruled
+ enrich_merge id => sub { sql 'SELECT tag AS id, bool_or(ignore) as overruled FROM tags_vn WHERE vid =', \$id, 'AND tag IN', $_, 'GROUP BY tag' }, $tags;
+
+ # Delete tag votes not in $tags
+ tuwf->dbExeci('DELETE FROM tags_vn WHERE uid =', \auth->uid, 'AND vid =', \$id, @$tags ? ('AND tag NOT IN', [ map $_->{id}, @$tags ]) : ());
+
+ # Add & update tags
+ for(@$tags) {
+ my $row = { uid => auth->uid, vid => $id, tag => $_->{id}, vote => $_->{vote}, notes => $_->{notes}
+ , spoiler => $_->{spoil}, lie => $_->{lie}, ignore => ($_->{overruled} && !$_->{overrule})?1:0
+ };
+ tuwf->dbExeci('INSERT INTO tags_vn', $row, 'ON CONFLICT (uid, tag, vid) DO UPDATE SET', $row);
+ tuwf->dbExeci('UPDATE tags_vn SET ignore = TRUE WHERE uid IS DISTINCT FROM (', \auth->uid, ') AND vid =', \$id, 'AND tag =', \$_->{id}) if $_->{overrule};
+ }
+
+ # Make sure to reset the ignore flag when a moderator removes an overruled vote.
+ # (i.e. look for tags where *all* votes are on ignore)
+ tuwf->dbExeci('UPDATE tags_vn tv SET ignore = FALSE WHERE NOT EXISTS(SELECT 1 FROM tags_vn tvi WHERE tvi.tag = tv.tag AND tvi.vid = tv.vid AND NOT tvi.ignore) AND vid =', \$id) if auth->permTagmod;
+
+ tuwf->dbExeci(select => sql_func tag_vn_calc => \$id);
+ elm_Success
+};
+
+
+TUWF::get qr{/$RE{vid}/tagmod}, sub {
+ my $v = dbobj tuwf->capture('id');
+ return tuwf->resNotFound if !$v->{id} || (!auth->permDbmod && $v->{entry_hidden});
+ return tuwf->resDenied if !can_tag;
+
+ my $tags = tuwf->dbAlli('
+ SELECT t.id, t.name, t.cat, t.hidden, t.locked, t.applicable
+ , tv.count, tv.overruled
+ , coalesce(td.rating, 0) AS rating, coalesce(td.spoiler, t.defaultspoil) AS spoiler, coalesce(td.islie, false) AS islie
+ FROM (SELECT tag, count(*) AS count, bool_or(ignore) as overruled FROM tags_vn WHERE vid =', \$v->{id}, ' GROUP BY tag) tv
+ JOIN tags t ON t.id = tv.tag
+ LEFT JOIN (
+ SELECT tv.tag
+ , COALESCE(AVG(tv.vote) filter (where tv.vote > 0), 1+1+1) * SUM(sign(tv.vote)) / COUNT(tv.vote) AS rating
+ , AVG(tv.spoiler) AS spoiler
+ , count(lie) filter(where lie) > 0 AND count(lie) filter (where lie) >= count(lie) filter(where not lie) AS islie
+ FROM tags_vn tv
+ JOIN tags t ON t.id = tv.tag
+ LEFT JOIN users u ON u.id = tv.uid
+ WHERE NOT tv.ignore AND (u.id IS NULL OR u.perm_tag) AND tv.vid =', \$v->{id}, '
+ GROUP BY tv.tag
+ ) td ON td.tag = tv.tag
+ ORDER BY t.name'
+ );
+ enrich_merge id => sub { sql 'SELECT tag AS id, vote, spoiler AS spoil, lie, ignore, notes FROM tags_vn WHERE', { uid => auth->uid, vid => $v->{id} } }, $tags;
+ enrich othnotes => id => tag => sub {
+ sql('SELECT tv.tag, ', sql_user(), ', tv.notes FROM tags_vn tv JOIN users u ON u.id = tv.uid WHERE tv.notes <> \'\' AND uid IS DISTINCT FROM (', \auth->uid, ') AND vid=', \$v->{id})
+ }, $tags;
+
+ for(@$tags) {
+ $_->{vote} //= 0;
+ $_->{spoil} //= undef;
+ $_->{lie} //= undef;
+ $_->{notes} //= '';
+ $_->{overrule} = $_->{vote} && !$_->{ignore} && $_->{overruled};
+ $_->{othnotes} = join "\n", map user_displayname($_).': '.$_->{notes}, $_->{othnotes}->@*;
+ }
+
+ framework_ title => "Edit tags for $v->{title}[1]", dbobj => $v, tab => 'tagmod', sub {
+ elm_ 'Tagmod' => $FORM_OUT, { id => $v->{id}, title => $v->{title}[1], tags => $tags, mod => auth->permTagmod };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/VN/Votes.pm b/lib/VNWeb/VN/Votes.pm
index 00ea04b6..08813671 100644
--- a/lib/VNWeb/VN/Votes.pm
+++ b/lib/VNWeb/VN/Votes.pm
@@ -8,7 +8,7 @@ sub listing_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [ $count, 50 ], 't';
- div_ class => 'mainbox browse votelist', sub {
+ article_ class => 'browse votelist', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'date', $opt, \&url; debug_ $lst };
@@ -19,8 +19,8 @@ sub listing_ {
td_ class => 'tc1', fmtdate $_->{date};
td_ class => 'tc2', fmtvote $_->{vote};
td_ class => 'tc3', sub {
- b_ class => 'grayedout', 'hidden' if $_->{hide_list};
- user_ $_ if !$_->{hide_list};
+ small_ 'hidden' if $_->{c_private};
+ user_ $_ if !$_->{c_private};
};
} for @$lst;
};
@@ -30,9 +30,8 @@ sub listing_ {
TUWF::get qr{/$RE{vid}/votes}, sub {
- my $id = tuwf->capture('id');
- my $v = tuwf->dbRowi('SELECT id, title, hidden AS entry_hidden, locked AS entry_locked FROM vn WHERE id =', \$id);
- return tuwf->resNotFound if !$v->{id} || $v->{hidden};
+ my $v = dbobj tuwf->capture('id');
+ return tuwf->resNotFound if !$v->{id} || $v->{entry_hidden};
my $opt = tuwf->validate(get =>
p => { page => 1 },
@@ -49,16 +48,15 @@ TUWF::get qr{/$RE{vid}/votes}, sub {
my $count = tuwf->dbVali('SELECT COUNT(*)', $fromwhere);
my $lst = tuwf->dbPagei({results => 50, page => $opt->{p}},
- 'SELECT uv.vote,', sql_totime('uv.vote_date'), 'as date, ', sql_user(), '
- , NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private) AS hide_list
- ', $fromwhere, 'ORDER BY', sprintf
- { date => 'uv.vote_date %s', vote => 'uv.vote %s', title => '(CASE WHEN hide_list THEN NULL ELSE u.username END) %s, uv.vote_date' }->{$opt->{s}},
+ 'SELECT uv.vote, uv.c_private, ', sql_totime('uv.vote_date'), 'as date, ', sql_user(),
+ $fromwhere, 'ORDER BY', sprintf
+ { date => 'uv.vote_date %s, uv.vote', vote => 'uv.vote %s, uv.vote_date', title => "(CASE WHEN uv.c_private THEN NULL ELSE u.username END) %s, uv.vote_date" }->{$opt->{s}},
{ a => 'ASC', d => 'DESC' }->{$opt->{o}}
);
- framework_ title => "Votes for $v->{title}", type => 'v', dbobj => $v, sub {
- div_ class => 'mainbox', sub {
- h1_ "Votes for $v->{title}";
+ framework_ title => "Votes for $v->{title}[1]", dbobj => $v, sub {
+ article_ sub {
+ h1_ "Votes for $v->{title}[1]";
p_ 'No votes to list. :(' if !@$lst;
};
listing_ $opt, $count, $lst if @$lst;
diff --git a/lib/VNWeb/Validation.pm b/lib/VNWeb/Validation.pm
index c506903a..87c5e171 100644
--- a/lib/VNWeb/Validation.pm
+++ b/lib/VNWeb/Validation.pm
@@ -1,34 +1,87 @@
package VNWeb::Validation;
use v5.26;
-use TUWF;
-use PWLookup;
+use TUWF 'uri_escape';
use VNDB::Types;
use VNDB::Config;
use VNWeb::Auth;
use VNWeb::DB;
+use VNDB::Func 'gtintype';
+use Time::Local 'timegm';
use Carp 'croak';
use Exporter 'import';
our @EXPORT = qw/
- is_insecurepass
+ %RE
+ samesite
+ is_api
+ is_unique_username
+ ipinfo
form_compile
form_changed
validate_dbid
can_edit
+ viewget viewset
/;
+# Regular expressions for use in path registration
+my $num = qr{[1-9][0-9]{0,6}}; # Allow up to 10 mil, SQL vndbid type can't handle more than 2^26-1 (~ 67 mil).
+my $rev = qr{(?:\.(?<rev>$num))};
+our %RE = (
+ num => qr{(?<num>$num)},
+ uid => qr{(?<id>u$num)},
+ vid => qr{(?<id>v$num)},
+ rid => qr{(?<id>r$num)},
+ sid => qr{(?<id>s$num)},
+ cid => qr{(?<id>c$num)},
+ pid => qr{(?<id>p$num)},
+ iid => qr{(?<id>i$num)},
+ did => qr{(?<id>d$num)},
+ tid => qr{(?<id>t$num)},
+ gid => qr{(?<id>g$num)},
+ wid => qr{(?<id>w$num)},
+ imgid=> qr{(?<id>(?:ch|cv|sf)$num)},
+ vrev => qr{(?<id>v$num)$rev?},
+ rrev => qr{(?<id>r$num)$rev?},
+ prev => qr{(?<id>p$num)$rev?},
+ srev => qr{(?<id>s$num)$rev?},
+ crev => qr{(?<id>c$num)$rev?},
+ drev => qr{(?<id>d$num)$rev?},
+ grev => qr{(?<id>g$num)$rev?},
+ irev => qr{(?<id>i$num)$rev?},
+ postid => qr{(?<id>t$num)\.(?<num>$num)},
+);
+
+
TUWF::set custom_validations => {
- id => { uint => 1, max => 1<<40 },
- editsum => { required => 1, length => [ 2, 5000 ] },
- page => { uint => 1, min => 1, max => 1000, required => 0, default => 1, onerror => 1 },
- upage => { uint => 1, min => 1, required => 0, default => 1, onerror => 1 }, # pagination without a maximum
- username => { regex => qr/^(?!-*[a-z][0-9]+-*$)[a-z0-9-]*$/, minlength => 2, maxlength => 15 },
+ 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 $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 } }
+ },
+ sl => { regex => qr/^[^\t\r\n]+$/ }, # "Single line", also excludes tabs because they're weird.
+ editsum => { length => [ 2, 5000 ] },
+ page => { uint => 1, min => 1, max => 1000, default => 1, onerror => 1 },
+ upage => { uint => 1, min => 1, default => 1, onerror => 1 }, # pagination without a maximum
+ username => { regex => qr/^(?!-*[a-zA-Z][0-9]+-*$)[a-zA-Z0-9-]*$/, minlength => 2, maxlength => 15 },
password => { length => [ 4, 500 ] },
language => { enum => \%LANGUAGE },
+ gtin => { default => 0, func => sub { $_[0] = 0 if !length $_[0]; $_[0] eq 0 || gtintype($_[0]) } },
+ rdate => { uint => 1, func => \&_validate_rdate },
+ fuzzyrdate => { default => 0, func => \&_validate_fuzzyrdate },
+ searchquery => { onerror => bless([],'VNWeb::Validate::SearchQuery'), func => sub { $_[0] = bless([$_[0]], 'VNWeb::Validate::SearchQuery'); 1 } },
+ # Calendar date, limited to 1970 - 2099 for sanity.
+ # TODO: Should also validate whether the day exists, currently "2022-11-31" is accepted, but that's a bug.
+ caldate => { regex => qr/^(?:19[7-9][0-9]|20[0-9][0-9])-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/ },
+ # An array that may be either missing (returns undef), a single scalar (returns single-element array) or a proper array
+ undefarray => sub { +{ default => undef, type => 'array', scalar => 1, values => $_[0] } },
# Accepts a user-entered vote string (or '-' or empty) and converts that into a DB vote number (or undef) - opposite of fmtvote()
- vnvote => { required => 0, default => undef, regex => qr/^(?:|-|[1-9]|10|[1-9]\.[0-9]|10\.0)$/, func => sub { $_[0] = $_[0] eq '-' ? undef : 10*$_[0]; 1 } },
+ vnvote => { default => undef, regex => qr/^(?:|-|[1-9]|10|[1-9]\.[0-9]|10\.0)$/, func => sub { $_[0] = $_[0] eq '-' ? undef : 10*$_[0]; 1 } },
# Sort an array by the listed hash keys, using string comparison on each key
sort_keys => sub {
my @keys = ref $_[0] eq 'ARRAY' ? @{$_[0]} : $_[0];
@@ -42,11 +95,85 @@ TUWF::set custom_validations => {
},
# Sorted and unique array-of-hashes (default order is sort_keys on the sorted keys...)
aoh => sub { +{ type => 'array', unique => 1, sort_keys => [sort keys %{$_[0]}], values => { type => 'hash', keys => $_[0] } } },
+ # Fields query parameter for the API, supports multiple values or comma-delimited list, returns a hash.
+ fields => sub {
+ my %keys = map +($_,1), ref $_[0] eq 'ARRAY' ? @{$_[0]} : $_[0];
+ +{ default => {}, type => 'array', values => {}, scalar => 1, func => sub {
+ my @l = map split(/\s*,\s*/,$_), @{$_[0]};
+ return 0 if grep !$keys{$_}, @l;
+ $_[0] = { map +($_,1), @l };
+ 1;
+ } }
+ },
};
+sub _validate_rdate {
+ return 0 if $_[0] ne 0 && $_[0] !~ /^([0-9]{4})([0-9]{2})([0-9]{2})$/;
+ my($y, $m, $d) = $_[0] eq 0 ? (0,0,0) : ($1, $2, $3);
+
+ # Re-normalize
+ ($m, $d) = (0, 0) if $y == 0;
+ $m = 99 if $y == 9999;
+ $d = 99 if $m == 99;
+ $_[0] = $y*10000 + $m*100 + $d;
-sub is_insecurepass {
- config->{password_db} && PWLookup::lookup(config->{password_db}, shift)
+ return 0 if $y && $y != 9999 && ($y < 1980 || $y > 2100);
+ return 0 if $y && $m != 99 && (!$m || $m > 12);
+ return 0 if $y && $d != 99 && !eval { timegm(0, 0, 0, $d, $m-1, $y) };
+ return 1;
+}
+
+
+sub _validate_fuzzyrdate {
+ $_[0] = 0 if $_[0] =~ /^unknown$/i;
+ $_[0] = 1 if $_[0] =~ /^today$/i;
+ $_[0] = 99999999 if $_[0] =~ /^tba$/i;
+ $_[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]);
+}
+
+
+# returns true if this request originated from the same site, i.e. not an external referer.
+sub samesite { !!tuwf->reqCookie('samesite') }
+
+# returns true if this request is for an /api/ URL.
+sub is_api { !$main::NOAPI && ($main::ONLYAPI || tuwf->reqPath =~ /^\/api\//) }
+
+# Test uniqueness of a username in the database. Usernames with similar
+# homographs are considered duplicate.
+# (Would be much faster and safer to do this normalization in the DB and put a
+# unique constraint on the normalized name, but we have a bunch of existing
+# username clashes that I can't just change)
+sub is_unique_username {
+ my($name, $excludeid) = @_;
+ my sub norm {
+ # lowercase, normalize 'i1l' and '0o'
+ sql "regexp_replace(regexp_replace(lower(", $_[0], "), '[1l]', 'i', 'g'), '0', 'o', 'g')";
+ };
+ !tuwf->dbVali('SELECT 1 FROM users WHERE', norm('username'), '=', norm(\$name),
+ $excludeid ? ('AND id <>', \$excludeid) : ());
+}
+
+
+# Lookup IP and return an 'ipinfo' DB string.
+sub ipinfo {
+ my $ip = shift || tuwf->reqIP;
+ state $db = config->{location_db} && do {
+ require Location;
+ Location::init(config->{location_db});
+ };
+ sub esc { ($_[0]//'') =~ s/([,()\\'"])/\\$1/rg }
+ return sprintf "(%s,,,,,,,)", esc $ip if !$db;
+
+ my sub f { Location::lookup_network_has_flag($db, $ip, "LOC_NETWORK_FLAG_$_[0]") ? 't' : 'f' }
+ my $asn = Location::lookup_asn($db, $ip);
+ sprintf "(%s,%s,%d,%s,%s,%s,%s,%s)", esc($ip),
+ esc(Location::lookup_country_code($db,$ip)),
+ $asn, esc(Location::get_as_name($db,$asn)),
+ f('ANONYMOUS_PROXY'), f('SATELLITE_PROVIDER'), f('ANYCAST'), f('DROP');
}
@@ -99,12 +226,17 @@ sub _eq_deep {
# ($b), using the normalization defined in $schema. The $schema must validate.
sub form_changed {
my($schema, $a, $b) = @_;
- my $na = $schema->validate($a)->data;
- my $nb = $schema->validate($b)->data;
-
- #warn "a=".JSON::XS->new->pretty->canonical->encode($na);
- #warn "b=".JSON::XS->new->pretty->canonical->encode($nb);
- !_eq_deep $na, $nb;
+ my sub norm {
+ my $v = $schema->validate($_[0]);
+ if($v->err) {
+ require Data::Dumper;
+ my $e = Data::Dumper->new([$v->err])->Terse(1)->Pair(':')->Indent(0)->Sortkeys(1)->Dump;
+ my $j = JSON::XS->new->pretty->encode($_[0]);
+ warn "form_changed() input did not validate according to the schema.\nError: $e\nInput: $j";
+ }
+ $v->unsafe_data;
+ }
+ !_eq_deep norm($a), norm($b);
}
@@ -142,6 +274,15 @@ sub validate_dbid {
# Otherwise, checks if the user can edit the post.
# Requires the 'user_id', 'date' and 'hidden' fields.
#
+# w:
+# If no 'id' field, checks if the user can submit a new review.
+# 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.
@@ -149,12 +290,12 @@ sub validate_dbid {
sub can_edit {
my($type, $entry) = @_;
- return auth->permUsermod || (auth && $entry->{id} == auth->uid) if $type eq 'u';
+ return auth->permUsermod || (auth && $entry->{id} eq auth->uid) if $type eq 'u';
return auth->permDbmod if $type eq 'd';
if($type eq 't') {
- return 0 if !auth->permBoard;
return 1 if auth->permBoardmod;
+ return 0 if !auth->permBoard || (global_settings->{lockdown_board} && !auth->isMod);
if(!$entry->{id}) {
# Allow at most 5 new threads per day per user.
return auth && tuwf->dbVali('SELECT count(*) < ', \5, 'FROM threads_posts WHERE num = 1 AND date > NOW()-\'1 day\'::interval AND uid =', \auth->uid);
@@ -164,14 +305,156 @@ sub can_edit {
} else {
die "Can't do authorization test when hidden/date/user_id fields aren't present"
if !exists $entry->{hidden} || !exists $entry->{date} || !exists $entry->{user_id};
- return auth && $entry->{user_id} == auth->uid && !$entry->{hidden} && $entry->{date} > time-config->{board_edit_time};
+ # beware: for threads the 'hidden' field is a non-undef boolean flag, for posts it is a possibly-undef text field.
+ my $hidden = $entry->{id} =~ /^t/ && $entry->{num} == 1 ? $entry->{hidden} : defined $entry->{hidden};
+ return auth && $entry->{user_id} eq auth->uid && !$hidden && $entry->{date} > time-config->{board_edit_time};
}
}
+ if($type eq 'w') {
+ return 1 if auth->permBoardmod;
+ return auth->permReview && (!global_settings->{lockdown_board} || auth->isMod) if !$entry->{id};
+ return auth && auth->uid eq $entry->{user_id};
+ }
+
+ if($type eq 'g' || $type eq 'i') {
+ return auth->permEdit && (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});
- auth->permDbmod || (auth->permEdit && !($entry->{entry_hidden} || $entry->{entry_locked}));
+ auth->permDbmod || (auth->permEdit && !global_settings->{lockdown_edit} && !($entry->{entry_hidden} || $entry->{entry_locked}));
+}
+
+
+# Some user preferences can be overruled with a ?view= query parameter,
+# viewget() can be used to fetch these parameters, viewset() to generate a
+# query parameter with certain preferences overruled.
+#
+# The query parameter has the following format:
+# view=1 -> spoilers=1, traits_sexual=<default>
+# view=2s -> spoilers=2, traits_sexual=1
+# view=2S -> spoilers=2, traits_sexual=0
+# view=S -> spoilers=<default>, traits_sexual=0
+# i.e. a list of single-character flags:
+# 0-2 -> spoilers
+# s/S -> 1/0 traits_sexual
+# n/N -> 1/0 show_nsfw
+# Missing flags will use default.
+#
+# The parameter also contains a CSRF token to prevent direct links to pages
+# with sensitive content. The token is domain-separated from the form CSRF
+# tokens, but is otherwise generic for all pages and options, so if someone's
+# token leaks, it's possible to generate links to any sensitive page for that
+# particular user for several hours.
+sub viewget {
+ tuwf->req->{view} ||= do {
+ my($view, $token) = tuwf->reqGet('view') =~ /^([^-]*)-(.+)$/;
+
+ # Abort this request and redirect if the token is invalid.
+ if(length($view) && (!samesite || !length($token) || !auth->csrfcheck($token, 'view'))) {
+ my $qs = join '&', map { my $k=$_; my @l=tuwf->reqGets($k); map uri_escape($k).'='.uri_escape($_), @l } grep $_ ne 'view', tuwf->reqGets();
+ tuwf->resInit;
+ tuwf->resRedirect(tuwf->reqPath().($qs?"?$qs":''), 'temp');
+ tuwf->done;
+ }
+
+ my($sp, $ts, $ns) = $view =~ /^([0-2])?([sS]?)([nN]?)$/;
+ {
+ spoilers => $sp // auth->pref('spoilers') || 0,
+ traits_sexual => !$ts ? auth->pref('traits_sexual') : $ts eq 's',
+ show_nsfw => !$ns ? (auth->pref('max_sexual')||0)==2 && (auth->pref('max_violence')||0)>0 : $ns eq 'n',
+ }
+ };
+ tuwf->req->{view}
}
+
+# Creates a new 'view=' string with the given parameters. All other fields remain at their default.
+sub viewset {
+ my %s = @_;
+ join '',
+ $s{spoilers}//'',
+ !defined $s{traits_sexual} ? '' : $s{traits_sexual} ? 's' : 'S',
+ !defined $s{show_nsfw} ? '' : $s{show_nsfw} ? 'n' : 'N',
+ '-'.auth->csrftoken(0, 'view');
+}
+
+
+# Object returned by the 'searchquery' validation, has some handy methods for generating SQL.
+package VNWeb::Validate::SearchQuery {
+ use TUWF;
+ use VNWeb::DB;
+
+ sub query_encode { $_[0][0] }
+ sub TO_JSON { $_[0][0] }
+
+ sub words {
+ $_[0][1] //= length $_[0][0]
+ ? [ map s/%//rg, tuwf->dbVali('SELECT search_query(', \$_[0][0], ')')->@* ]
+ : []
+ }
+
+ use overload bool => sub { $_[0]->words->@* > 0 };
+ use overload '""' => sub { $_[0][0]//'' };
+
+ sub _isvndbid { my $l = $_[0]->words; @$l == 1 && $l->[0] =~ /^[vrpcsgi]$num$/ }
+
+ sub where {
+ my($self, $type) = @_;
+ my $lst = $self->words;
+ my @keywords = map sql('sc.label LIKE', \('%'.sql_like($_).'%')), @$lst;
+ +(
+ $type ? "sc.id BETWEEN '${type}1' AND vndbid_max('$type')" : (),
+ $self->_isvndbid()
+ ? (sql 'sc.id =', \$lst->[0], 'OR', sql_and(@keywords))
+ : @keywords
+ )
+ }
+
+ sub sql_where {
+ my($self, $type, $id, $subid) = @_;
+ return '1=1' if !$self;
+ sql 'EXISTS(SELECT 1 FROM search_cache sc WHERE', sql_and(
+ sql('sc.id =', $id), $subid ? sql('sc.subid =', $subid) : (),
+ $self->where($type),
+ ), ')';
+ }
+
+ # Returns a subquery that can be joined to get the search score.
+ # Columns (id, subid, score)
+ sub sql_score {
+ my($self, $type) = @_;
+ my $lst = $self->words;
+ my $q = join '', @$lst;
+ sql '(SELECT id, subid, max(sc.prio * (', VNWeb::DB::sql_join('+',
+ $self->_isvndbid() ? sql('CASE WHEN sc.id =', \$q, 'THEN 1+1 ELSE 0 END') : (),
+ sql('CASE WHEN sc.label LIKE', \(sql_like($q).'%'), 'THEN 1::float/(1+1) ELSE 0 END'),
+ sql('similarity(sc.label,', \$q, ')'),
+ ), ')) AS score
+ FROM search_cache sc
+ WHERE', sql_and($self->where($type)), '
+ GROUP BY id, subid
+ )';
+ }
+
+ # Optionally returns a JOIN clause for sql_score, aliassed 'sc'
+ sub sql_join {
+ my($self, $type, $id, $subid) = @_;
+ return '' if !$self;
+ sql 'JOIN', $self->sql_score($type), 'sc ON sc.id =', $id, $subid ? ('AND sc.subid =', $subid) : ();
+ }
+
+ # Same as sql_join(), but accepts an array of SearchQuery objects that are OR'ed together.
+ sub sql_joina {
+ my($lst, $type, $id, $subid) = @_;
+ sql 'JOIN (
+ SELECT id, subid, max(score) AS score
+ FROM (', VNWeb::DB::sql_join('UNION ALL', map sql('SELECT * FROM', $_->sql_score($type), 'x'), @$lst), ') x
+ GROUP BY id, subid
+ ) sc ON sc.id =', $id, $subid ? ('AND sc.subid =', $subid) : ();
+ }
+};
+
1;
diff --git a/sql/all.sql b/sql/all.sql
new file mode 100644
index 00000000..15ae372d
--- /dev/null
+++ b/sql/all.sql
@@ -0,0 +1,12 @@
+-- NOTE: Make sure you're cd'ed in the vndb root directory before running this script
+
+\set ON_ERROR_STOP 1
+\i sql/util.sql
+\i sql/schema.sql
+\i sql/data.sql
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/tableattrs.sql
+\i sql/triggers.sql
+\set ON_ERROR_STOP 0
+\i sql/perms.sql
diff --git a/sql/c/Makefile b/sql/c/Makefile
new file mode 100644
index 00000000..6127292a
--- /dev/null
+++ b/sql/c/Makefile
@@ -0,0 +1,4 @@
+MODULES = vndbfuncs
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
diff --git a/sql/c/test.sql b/sql/c/test.sql
new file mode 100644
index 00000000..dffca0b3
--- /dev/null
+++ b/sql/c/test.sql
@@ -0,0 +1,53 @@
+-- should all fail
+select 's+10'::vndbid;
+select 's 10'::vndbid;
+select ' s10'::vndbid;
+select 's10 '::vndbid;
+select 's01'::vndbid;
+select 'x01'::vndbid;
+select 'x1'::vndbid;
+select 'v0'::vndbid;
+select 'v'::vndbid;
+select ''::vndbid;
+select 'cx1'::vndbid;
+select 'chx1'::vndbid;
+select 'v67108864'::vndbid;
+
+-- Should all return their input
+select 'c123456'::vndbid;
+select 'p789000'::vndbid;
+select 'v67108863'::vndbid;
+select 'r10'::vndbid;
+select 'i10'::vndbid;
+select 'g10'::vndbid;
+select 's10'::vndbid;
+select 'ch10'::vndbid;
+select 'cv10'::vndbid;
+select 'sf10'::vndbid;
+
+select 's11'::vndbid = 's11'::vndbid; -- t
+select 's11'::vndbid = 'v11'::vndbid; -- f
+select 's11'::vndbid <> 's11'::vndbid; -- f
+select 's11'::vndbid <> 'v11'::vndbid; -- t
+select 's11'::vndbid > 's11'::vndbid; -- f
+select 's11'::vndbid > 's10'::vndbid; -- t
+select 's11'::vndbid >= 's11'::vndbid; -- t
+select 's11'::vndbid >= 's10'::vndbid; -- t
+select 's11'::vndbid >= 's12'::vndbid; -- f
+
+select vndbid_type('sf1'); -- 'sf'
+select vndbid_type('v1'); -- 'v'
+
+select vndbid_num('sf1'); -- 1
+select vndbid_num('v5'); -- 5
+select vndbid_num('v67108863'); -- large
+
+select vndbid('s', 1); -- 's1'
+select vndbid('sf', 500); -- 'sf500'
+select vndbid('s', 0); -- fail
+select vndbid('x', 1); -- fail
+select vndbid('s', 67108864); -- fail
+
+-- The functions probably aren't even called, so not sure if this is a good test.
+select vndbid_le(NULL, 'sf1'); -- NULL
+select vndbid(NULL, 1); -- NULL
diff --git a/sql/c/vndbfuncs.c b/sql/c/vndbfuncs.c
new file mode 100644
index 00000000..a327838b
--- /dev/null
+++ b/sql/c/vndbfuncs.c
@@ -0,0 +1,224 @@
+/* This file contains C support functions for the 'vndbid' type,
+ * see sql/vndbid.sql for more information.
+ */
+
+#include "postgres.h"
+#include "fmgr.h"
+#include "libpq/pqformat.h"
+#include "utils/sortsupport.h"
+
+PG_MODULE_MAGIC;
+
+
+/* Internal representation of the vndbid is an int32,
+ * 6 most significant bits are used for the type,
+ * 26 least significant bits for the numeric identifier.
+ *
+ * Apart from the different formatting and type system considerations, these
+ * identifiers are treated (compared, sorted, etc) exactly as if they were
+ * regular integers.
+ *
+ * The order of different entry types is, uh, implementation-defined. It
+ * doesn't have to make sense, it just has to have a stable order.
+ */
+
+/* List of recognized types: encoded type_id (must be stable!), string, first character, second character.
+ * ASSUMPTION: 0 <= type_id <= 31, so that (vndbid-vndbid) can't overflow.
+ */
+#define VNDBID_TYPES\
+ X( 1, "c" , 'c', 0)\
+ X( 2, "d", 'd', 0)\
+ X( 3, "g" , 'g', 0)\
+ X( 4, "i" , 'i', 0)\
+ X( 5, "p" , 'p', 0)\
+ X( 6, "r" , 'r', 0)\
+ X( 7, "s" , 's', 0)\
+ X( 8, "v" , 'v', 0)\
+ X( 9, "ch", 'c', 'h')\
+ X(10, "cv", 'c', 'v')\
+ X(11, "sf", 's', 'f')\
+ X(12, "w", 'w', 0)\
+ X(13, "u", 'u', 0)\
+ X(14, "t", 't', 0)
+
+#define VNDBID_TYPE(_x) ((_x) >> 26)
+#define VNDBID_NUM(_x) ((_x) & 0x03FFFFFF)
+#define VNDBID_MAXID ((1<<26)-1)
+#define VNDBID_CREATE(_x, _y) (((_x) << 26) | (_y))
+
+
+static char *vndbid_type2str(int t) {
+ switch(t) {
+#define X(num, str, _a, _b) case num: return str;
+ VNDBID_TYPES
+#undef X
+ }
+ return "";
+}
+
+
+static int vndbid_str2type(char a, char b) {
+#define CHAR2(_x, _y) (((int)(_x)<<8) | (int)(_y))
+ switch(CHAR2(a, b)) {
+#define X(num, _a, first, second) case CHAR2(first, second): return num;
+ VNDBID_TYPES
+#undef X
+ }
+ return -1;
+#undef CHAR2
+}
+
+
+PG_FUNCTION_INFO_V1(vndbid_in);
+
+Datum vndbid_in(PG_FUNCTION_ARGS) {
+ char *ostr = PG_GETARG_CSTRING(0);
+ char *str = ostr, a = 0, b = 0;
+ int type, num = 0;
+ if(*str >= 'a' && *str <= 'z') a = *(str++);
+ if(*str >= 'a' && *str <= 'z') b = *(str++);
+ type = vndbid_str2type(a, b);
+
+ if(type < 0 || *str == 0 || *str == '0')
+ ereport(ERROR, (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION), errmsg("invalid input syntax for type %s: \"%s\"", "vndbid", ostr)));
+
+ /* Custom string-to-int function, we don't allow leading zeros or signs */
+ while(*str >= '0' && *str <= '9' && num <= VNDBID_MAXID)
+ num = num*10 + (*(str++)-'0');
+
+ if(num > VNDBID_MAXID || *str != 0)
+ ereport(ERROR, (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION), errmsg("invalid input syntax for type %s: \"%s\"", "vndbid", ostr)));
+
+ PG_RETURN_INT32(VNDBID_CREATE(type, num));
+}
+
+
+PG_FUNCTION_INFO_V1(vndbid_out);
+
+Datum vndbid_out(PG_FUNCTION_ARGS) {
+ int32 arg = PG_GETARG_INT32(0);
+ PG_RETURN_CSTRING(psprintf("%s%d", vndbid_type2str(VNDBID_TYPE(arg)), (int)VNDBID_NUM(arg)));
+}
+
+
+PG_FUNCTION_INFO_V1(vndbid_recv);
+
+Datum vndbid_recv(PG_FUNCTION_ARGS) {
+ StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
+ int32 val = pq_getmsgint(buf, sizeof(int32));
+ if(!*vndbid_type2str(VNDBID_TYPE(val)))
+ ereport(ERROR, (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION), errmsg("invalid data for type vndbid")));
+ PG_RETURN_INT32(val);
+}
+
+
+PG_FUNCTION_INFO_V1(vndbid_send);
+
+Datum vndbid_send(PG_FUNCTION_ARGS) {
+ int32 arg1 = PG_GETARG_INT32(0);
+ StringInfoData buf;
+
+ pq_begintypsend(&buf);
+ pq_sendint32(&buf, arg1);
+ PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
+}
+
+
+PG_FUNCTION_INFO_V1(vndbid_cmp);
+PG_FUNCTION_INFO_V1(vndbid_lt);
+PG_FUNCTION_INFO_V1(vndbid_le);
+PG_FUNCTION_INFO_V1(vndbid_eq);
+PG_FUNCTION_INFO_V1(vndbid_ge);
+PG_FUNCTION_INFO_V1(vndbid_gt);
+PG_FUNCTION_INFO_V1(vndbid_ne);
+Datum vndbid_cmp(PG_FUNCTION_ARGS){ PG_RETURN_INT32(PG_GETARG_INT32(0) - PG_GETARG_INT32(1)); }
+Datum vndbid_lt(PG_FUNCTION_ARGS) { PG_RETURN_BOOL(PG_GETARG_INT32(0) < PG_GETARG_INT32(1)); }
+Datum vndbid_le(PG_FUNCTION_ARGS) { PG_RETURN_BOOL(PG_GETARG_INT32(0) <= PG_GETARG_INT32(1)); }
+Datum vndbid_eq(PG_FUNCTION_ARGS) { PG_RETURN_BOOL(PG_GETARG_INT32(0) == PG_GETARG_INT32(1)); }
+Datum vndbid_ge(PG_FUNCTION_ARGS) { PG_RETURN_BOOL(PG_GETARG_INT32(0) >= PG_GETARG_INT32(1)); }
+Datum vndbid_gt(PG_FUNCTION_ARGS) { PG_RETURN_BOOL(PG_GETARG_INT32(0) > PG_GETARG_INT32(1)); }
+Datum vndbid_ne(PG_FUNCTION_ARGS) { PG_RETURN_BOOL(PG_GETARG_INT32(0) != PG_GETARG_INT32(1)); }
+
+
+static int vndbid_fastcmp(Datum x, Datum y, SortSupport ssup) {
+ int32 a = DatumGetInt32(x);
+ int32 b = DatumGetInt32(y);
+ return a-b;
+}
+
+PG_FUNCTION_INFO_V1(vndbid_sortsupport);
+
+Datum vndbid_sortsupport(PG_FUNCTION_ARGS) {
+ SortSupport ssup = (SortSupport) PG_GETARG_POINTER(0);
+ ssup->comparator = vndbid_fastcmp;
+ PG_RETURN_VOID();
+}
+
+
+PG_FUNCTION_INFO_V1(vndbid_hash);
+
+Datum vndbid_hash(PG_FUNCTION_ARGS) {
+ uint32 v = PG_GETARG_INT32(0);
+ /* Found in khashl.h, no clue which hash function this is, but it's short and seems to make a good attempt at mixing bits.
+ * PostgresSQL's internal hash functions are not exported. */
+ v += ~(v << 15);
+ v ^= (v >> 10);
+ v += (v << 3);
+ v ^= (v >> 6);
+ v += ~(v << 11);
+ v ^= (v >> 16);
+ PG_RETURN_INT32(v);
+}
+
+
+PG_FUNCTION_INFO_V1(vndbid);
+
+Datum vndbid(PG_FUNCTION_ARGS) {
+ text *type = PG_GETARG_TEXT_PP(0);
+ int32 v = PG_GETARG_INT32(1);
+
+ int itype =
+ VARSIZE(type) == VARHDRSZ + 1 ? vndbid_str2type(*((char *)VARDATA(type)), 0) :
+ VARSIZE(type) == VARHDRSZ + 2 ? vndbid_str2type(*((char *)VARDATA(type)), ((char *)VARDATA(type))[1]) : -1;
+
+ if(itype < 0 || v <= 0 || v > VNDBID_MAXID)
+ ereport(ERROR, (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION), errmsg("invalid input for type vndbid")));
+
+ PG_RETURN_INT32(VNDBID_CREATE(itype, v));
+}
+
+
+PG_FUNCTION_INFO_V1(vndbid_type);
+
+Datum vndbid_type(PG_FUNCTION_ARGS) {
+ uint32 v = PG_GETARG_INT32(0);
+ char *str = vndbid_type2str(VNDBID_TYPE(v));
+ size_t len = strlen(str);
+ text *ret = (text *) palloc(len + VARHDRSZ);
+ SET_VARSIZE(ret, len + VARHDRSZ);
+ memcpy(VARDATA(ret), str, len);
+ PG_RETURN_TEXT_P(ret);
+}
+
+
+PG_FUNCTION_INFO_V1(vndbid_num);
+
+Datum vndbid_num(PG_FUNCTION_ARGS) {
+ PG_RETURN_INT32(VNDBID_NUM(PG_GETARG_INT32(0)));
+}
+
+
+PG_FUNCTION_INFO_V1(vndbid_max);
+
+Datum vndbid_max(PG_FUNCTION_ARGS) {
+ text *type = PG_GETARG_TEXT_PP(0);
+
+ int itype =
+ VARSIZE(type) == VARHDRSZ + 1 ? vndbid_str2type(*((char *)VARDATA(type)), 0) :
+ VARSIZE(type) == VARHDRSZ + 2 ? vndbid_str2type(*((char *)VARDATA(type)), ((char *)VARDATA(type))[1]) : -1;
+
+ if(itype < 0)
+ ereport(ERROR, (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION), errmsg("invalid input for type vndbid")));
+
+ PG_RETURN_INT32(VNDBID_CREATE(itype, VNDBID_MAXID));
+}
diff --git a/sql/data.sql b/sql/data.sql
new file mode 100644
index 00000000..1fd960f1
--- /dev/null
+++ b/sql/data.sql
@@ -0,0 +1,13 @@
+INSERT INTO global_settings (id) VALUES (TRUE);
+
+INSERT INTO users (id, username, notify_dbedit) VALUES ('u1', 'multi', FALSE);
+SELECT setval('users_id_seq', 2);
+
+INSERT INTO stats_cache (section, count) VALUES
+ ('vn', 0),
+ ('producers', 0),
+ ('releases', 0),
+ ('chars', 0),
+ ('staff', 0),
+ ('tags', 0),
+ ('traits', 0);
diff --git a/sql/editfunc.sql b/sql/editfunc.sql
new file mode 100644
index 00000000..64016c16
--- /dev/null
+++ b/sql/editfunc.sql
@@ -0,0 +1,4 @@
+-- This file is an alias for $VNDB_GEN/editfunc.sql
+\set genpath gen
+\getenv genpath VNDB_GEN
+\i :genpath/editfunc.sql
diff --git a/sql/func.sql b/sql/func.sql
new file mode 100644
index 00000000..de0a45c3
--- /dev/null
+++ b/sql/func.sql
@@ -0,0 +1,1197 @@
+-- A small note on the function naming scheme:
+-- edit_* -> revision insertion abstraction functions
+-- *_notify -> functions issuing a PgSQL NOTIFY statement
+-- notify_* -> functions creating entries in the notifications table
+-- user_* -> functions to manage users and sessions
+-- update_* -> functions to update a cache
+-- *_calc ^ (same, should prolly rename to the update_* scheme for consistency)
+-- I like to keep the nouns in functions singular, in contrast to the table
+-- naming scheme where nouns are always plural. But I'm not very consistent
+-- with that, either.
+
+
+-- Handy function to format an ipinfo type for human consumption.
+CREATE OR REPLACE FUNCTION fmtip(n ipinfo) RETURNS text AS $$
+ SELECT COALESCE(COALESCE((n).country, 'X')||':'||(n).asn||COALESCE(':'||(n).as_name,'')||'/', (n).country||'/', '')
+ || abbrev((n).ip)
+ || CASE WHEN (n).anonymous_proxy THEN ' ANON' ELSE '' END
+ || CASE WHEN (n).sattelite_provider THEN ' SAT' ELSE '' END
+ || CASE WHEN (n).anycast THEN ' ANY' ELSE '' END
+ || CASE WHEN (n).drop THEN ' DROP' ELSE '' END
+$$ LANGUAGE SQL IMMUTABLE;
+
+
+
+-- Helper function for `update_search()`
+CREATE OR REPLACE FUNCTION update_search_terms(objid vndbid) RETURNS SETOF record AS $$
+DECLARE
+ e int; -- because I'm too lazy to write out 'NULL::int' every time.
+BEGIN
+ CASE vndbid_type(objid)
+ WHEN 'v' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(title) FROM vn_titles WHERE id = objid
+ UNION ALL SELECT e, 3, search_norm_term(latin) FROM vn_titles WHERE id = objid
+ UNION ALL SELECT e, 2, search_norm_term(a) FROM vn, regexp_split_to_table(alias, E'\n') a(a) WHERE objid = id
+ -- Remove the various editions/version strings from release titles,
+ -- this reduces the index size and makes VN search more relevant.
+ -- People looking for editions should be using the release search.
+ UNION ALL SELECT e, 1, regexp_replace(search_norm_term(t), '(?:
+ 体験|ダウンロド|初回限定|初回|限定|通常|廉価|豪華|追加|コレクション
+ |パッケージ|ダウンロード|ベスト|復刻|新装|7対応|版|生産|リメイク
+ |first|press|limited|regular|standard|full|remake
+ |pack|package|boxed|download|complete|popular|premium|deluxe|collectors?|collection
+ |lowprice|price|free|best|thebest|cheap|budget|reprint|bundle|set|renewal|extended
+ |special|trial|demo|allages|voiced?|uncensored|web|patch|port|r18|18|earlyaccess
+ |cd|cdr|cdrom|dvdrom|dvd|dvdpg|disk|disc|steam|for
+ |(?:win|windows)(?:7|10|95)?|vista|pc9821|support(?:ed)?
+ |(?:parts?|vol|volumes?|chapters?|v|ver|versions?)(?:[0-9]+)
+ |editions?|version|production|thebest|append|scenario|dlc)+$', '', 'xg')
+ FROM (
+ SELECT title FROM releases r JOIN releases_vn rv ON rv.id = r.id JOIN releases_titles rt ON rt.id = r.id WHERE NOT r.hidden AND rv.vid = objid
+ UNION ALL
+ SELECT latin FROM releases r JOIN releases_vn rv ON rv.id = r.id JOIN releases_titles rt ON rt.id = r.id WHERE NOT r.hidden AND rv.vid = objid
+ ) r(t);
+
+ WHEN 'r' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(title) FROM releases_titles WHERE id = objid
+ UNION ALL SELECT e, 3, search_norm_term(latin) FROM releases_titles WHERE id = objid
+ UNION ALL SELECT e, 1, gtin::text FROM releases WHERE id = objid AND gtin <> 0
+ UNION ALL SELECT e, 1, search_norm_term(catalog) FROM releases WHERE id = objid AND catalog <> '';
+
+ WHEN 'c' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(name) FROM chars WHERE id = objid
+ UNION ALL SELECT e, 3, search_norm_term(latin) FROM chars WHERE id = objid
+ UNION ALL SELECT e, 2, search_norm_term(a) FROM chars, regexp_split_to_table(alias, E'\n') a(a) WHERE id = objid;
+
+ WHEN 'p' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(name) FROM producers WHERE id = objid
+ UNION ALL SELECT e, 3, search_norm_term(latin) FROM producers WHERE id = objid
+ UNION ALL SELECT e, 2, search_norm_term(a) FROM producers, regexp_split_to_table(alias, E'\n') a(a) WHERE id = objid;
+
+ WHEN 's' THEN RETURN QUERY
+ SELECT aid, 3, search_norm_term(name) FROM staff_alias WHERE id = objid
+ UNION ALL SELECT aid, 3, search_norm_term(latin) FROM staff_alias WHERE id = objid;
+
+ WHEN 'g' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(name) FROM tags WHERE id = objid
+ UNION ALL SELECT e, 2, search_norm_term(a) FROM tags, regexp_split_to_table(alias, E'\n') a(a) WHERE objid = id;
+
+ WHEN 'i' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(name) FROM traits WHERE id = objid
+ UNION ALL SELECT e, 2, search_norm_term(a) FROM traits, regexp_split_to_table(alias, E'\n') a(a) WHERE objid = id;
+
+ ELSE RAISE 'unknown objid type';
+ END CASE;
+END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OR REPLACE FUNCTION update_search(objid vndbid) RETURNS void AS $$
+ WITH excluded(excluded) AS (
+ -- VN, tag & trait search needs to support finding 'hidden' items, but for
+ -- other entry types we can safely exclude those from the search cache.
+ SELECT 1
+ WHERE (vndbid_type(objid) = 'r' AND EXISTS(SELECT 1 FROM releases WHERE hidden AND id = objid))
+ OR (vndbid_type(objid) = 'c' AND EXISTS(SELECT 1 FROM chars WHERE hidden AND id = objid))
+ OR (vndbid_type(objid) = 'p' AND EXISTS(SELECT 1 FROM producers WHERE hidden AND id = objid))
+ OR (vndbid_type(objid) = 's' AND EXISTS(SELECT 1 FROM staff WHERE hidden AND id = objid))
+ ), uniq(subid, prio, label) AS (
+ SELECT subid, MAX(prio)::smallint, label
+ FROM update_search_terms(objid) x (subid int, prio int, label text)
+ WHERE label IS NOT NULL AND label <> '' AND NOT EXISTS(SELECT 1 FROM excluded)
+ GROUP BY subid, label
+ ), terms(subid, prio, label) AS (
+ -- It's possible for some entries to have no searchable terms at all, e.g.
+ -- when their titles only consists of characters that are normalized away.
+ -- In that case we still need to have at least one row in the search_cache
+ -- table for the id-based search to work. (Would be nicer to support
+ -- non-normalized search in those cases, but these cases aren't too common)
+ SELECT * FROM uniq
+ UNION ALL
+ SELECT NULL::int, 1, '' WHERE NOT EXISTS(SELECT 1 FROM excluded) AND NOT EXISTS(SELECT 1 FROM uniq)
+ ), n(subid, prio, label) AS (
+ SELECT COALESCE(t.subid, o.subid), t.prio, COALESCE(t.label, o.label)
+ FROM terms t
+ FULL OUTER JOIN (SELECT subid, label FROM search_cache WHERE id = objid) o ON o.subid IS NOT DISTINCT FROM t.subid AND o.label = t.label
+ ) MERGE INTO search_cache o USING n ON o.id = objid AND (o.subid, o.label) IS NOT DISTINCT FROM (n.subid, n.label)
+ WHEN NOT MATCHED THEN INSERT (id, subid, prio, label) VALUES (objid, subid, n.prio, n.label)
+ WHEN MATCHED AND n.prio IS NULL THEN DELETE
+ WHEN MATCHED AND n.prio <> o.prio THEN UPDATE SET prio = n.prio;
+$$ LANGUAGE SQL;
+
+
+
+-- Helper function for the titleprefs functions below.
+CREATE OR REPLACE FUNCTION titleprefs_swap(p titleprefs, lang language, title text, latin text) RETURNS text[] AS $$
+ SELECT ARRAY[lang::text, CASE WHEN (
+ CASE WHEN p.t1_lang = lang THEN p.t1_latin
+ WHEN p.t2_lang = lang THEN p.t2_latin
+ WHEN p.t3_lang = lang THEN p.t3_latin
+ WHEN p.t4_lang = lang THEN p.t4_latin ELSE p IS NULL OR p.to_latin END
+ ) THEN COALESCE(latin, title) ELSE title END, lang::text, CASE WHEN (
+ CASE WHEN p.a1_lang = lang THEN p.a1_latin
+ WHEN p.a2_lang = lang THEN p.a2_latin
+ WHEN p.a3_lang = lang THEN p.a3_latin
+ WHEN p.a4_lang = lang THEN p.a4_latin ELSE p.ao_latin END
+ ) THEN COALESCE(latin, title) ELSE title END]
+$$ LANGUAGE SQL STABLE;
+
+
+-- This is a pure-SQL implementation of the title preference selection
+-- algorithm in VNWeb::TitlePrefs. Given a preferences object, this function
+-- returns a copy of the 'vn' table with two additional columns:
+-- * title - Array of: main title language, main title, secondary title language, secondary title
+-- * sorttitle - title to be used in ORDER BY clause
+--
+-- The 'title' array format is (supposed to be) used pervasively through the
+-- back-end code to order to easily pass around titles as a single object and
+-- to support proper rendering of both the main & secondary title of each
+-- entry.
+--
+-- This function looks slow and messy, but it's been specifically written to be
+-- transparent to the query planner and so that unused joins can be fully
+-- optimized out during query execution. Even with that, it's better to avoid
+-- this function in complex queries when possible because you may run into
+-- bad query plans by hitting join_collapse_limit or from_collapse_limit.
+-- (More info at https://dev.yorhel.nl/doc/vndbtitles)
+CREATE OR REPLACE FUNCTION vnt(p titleprefs) RETURNS SETOF vnt AS $$
+ -- The language selection logic below is specially written so that the planner can remove references to joined tables corresponding to NULL languages.
+ SELECT v.*, (CASE
+ WHEN p.t1_lang = t1.lang AND (NOT p.t1_official OR t1.official) AND (p.t1_official IS NOT NULL OR p.t1_lang = v.olang) THEN ARRAY[t1.lang::text, COALESCE(CASE WHEN p.t1_latin THEN t1.latin ELSE NULL END, t1.title)]
+ WHEN p.t2_lang = t2.lang AND (NOT p.t2_official OR t2.official) AND (p.t2_official IS NOT NULL OR p.t2_lang = v.olang) THEN ARRAY[t2.lang::text, COALESCE(CASE WHEN p.t2_latin THEN t2.latin ELSE NULL END, t2.title)]
+ WHEN p.t3_lang = t3.lang AND (NOT p.t3_official OR t3.official) AND (p.t3_official IS NOT NULL OR p.t3_lang = v.olang) THEN ARRAY[t3.lang::text, COALESCE(CASE WHEN p.t3_latin THEN t3.latin ELSE NULL END, t3.title)]
+ WHEN p.t4_lang = t4.lang AND (NOT p.t4_official OR t4.official) AND (p.t4_official IS NOT NULL OR p.t4_lang = v.olang) THEN ARRAY[t4.lang::text, COALESCE(CASE WHEN p.t4_latin THEN t4.latin ELSE NULL END, t4.title)]
+ ELSE ARRAY[v.olang::text, COALESCE(CASE WHEN p IS NULL OR p.to_latin THEN ol.latin ELSE NULL END, ol.title)] END
+ ) || (CASE
+ WHEN p.a1_lang = a1.lang AND (NOT p.a1_official OR a1.official) AND (p.a1_official IS NOT NULL OR p.a1_lang = v.olang) THEN ARRAY[a1.lang::text, COALESCE(CASE WHEN p.a1_latin THEN a1.latin ELSE NULL END, a1.title)]
+ WHEN p.a2_lang = a2.lang AND (NOT p.a2_official OR a2.official) AND (p.a2_official IS NOT NULL OR p.a2_lang = v.olang) THEN ARRAY[a2.lang::text, COALESCE(CASE WHEN p.a2_latin THEN a2.latin ELSE NULL END, a2.title)]
+ WHEN p.a3_lang = a3.lang AND (NOT p.a3_official OR a3.official) AND (p.a3_official IS NOT NULL OR p.a3_lang = v.olang) THEN ARRAY[a3.lang::text, COALESCE(CASE WHEN p.a3_latin THEN a3.latin ELSE NULL END, a3.title)]
+ WHEN p.a4_lang = a4.lang AND (NOT p.a4_official OR a4.official) AND (p.a4_official IS NOT NULL OR p.a4_lang = v.olang) THEN ARRAY[a4.lang::text, COALESCE(CASE WHEN p.a4_latin THEN a4.latin ELSE NULL END, a4.title)]
+ ELSE ARRAY[v.olang::text, COALESCE(CASE WHEN p.ao_latin THEN ol.latin ELSE NULL END, ol.title)] END)
+ , CASE
+ WHEN p.t1_lang = t1.lang AND (NOT p.t1_official OR t1.official) AND (p.t1_official IS NOT NULL OR p.t1_lang = v.olang) THEN COALESCE(t1.latin, t1.title)
+ WHEN p.t2_lang = t2.lang AND (NOT p.t2_official OR t2.official) AND (p.t2_official IS NOT NULL OR p.t2_lang = v.olang) THEN COALESCE(t2.latin, t2.title)
+ WHEN p.t3_lang = t3.lang AND (NOT p.t3_official OR t3.official) AND (p.t3_official IS NOT NULL OR p.t3_lang = v.olang) THEN COALESCE(t3.latin, t3.title)
+ WHEN p.t4_lang = t4.lang AND (NOT p.t4_official OR t4.official) AND (p.t4_official IS NOT NULL OR p.t4_lang = v.olang) THEN COALESCE(t4.latin, t4.title)
+ ELSE COALESCE(ol.latin, ol.title) END
+ FROM vn v
+ JOIN vn_titles ol ON ol.id = v.id AND ol.lang = v.olang
+ -- The COALESCE() below is kind of meaningless, but apparently the query planner can't optimize out JOINs with NULL conditions.
+ LEFT JOIN vn_titles t1 ON t1.id = v.id AND t1.lang = COALESCE(p.t1_lang, 'en')
+ LEFT JOIN vn_titles t2 ON t2.id = v.id AND t2.lang = COALESCE(p.t2_lang, 'en')
+ LEFT JOIN vn_titles t3 ON t3.id = v.id AND t3.lang = COALESCE(p.t3_lang, 'en')
+ LEFT JOIN vn_titles t4 ON t4.id = v.id AND t4.lang = COALESCE(p.t4_lang, 'en')
+ LEFT JOIN vn_titles a1 ON a1.id = v.id AND a1.lang = COALESCE(p.a1_lang, 'en')
+ LEFT JOIN vn_titles a2 ON a2.id = v.id AND a2.lang = COALESCE(p.a2_lang, 'en')
+ LEFT JOIN vn_titles a3 ON a3.id = v.id AND a3.lang = COALESCE(p.a3_lang, 'en')
+ LEFT JOIN vn_titles a4 ON a4.id = v.id AND a4.lang = COALESCE(p.a4_lang, 'en')
+$$ LANGUAGE SQL STABLE;
+
+
+
+-- Same thing as vnt()
+CREATE OR REPLACE FUNCTION releasest(p titleprefs) RETURNS SETOF releasest AS $$
+ SELECT r.*, (CASE
+ WHEN p.t1_lang = t1.lang AND (p.t1_official IS NOT NULL OR p.t1_lang = r.olang) THEN ARRAY[t1.lang::text, COALESCE(CASE WHEN p.t1_latin THEN t1.latin ELSE NULL END, t1.title)]
+ WHEN p.t2_lang = t2.lang AND (p.t2_official IS NOT NULL OR p.t2_lang = r.olang) THEN ARRAY[t2.lang::text, COALESCE(CASE WHEN p.t2_latin THEN t2.latin ELSE NULL END, t2.title)]
+ WHEN p.t3_lang = t3.lang AND (p.t3_official IS NOT NULL OR p.t3_lang = r.olang) THEN ARRAY[t3.lang::text, COALESCE(CASE WHEN p.t3_latin THEN t3.latin ELSE NULL END, t3.title)]
+ WHEN p.t4_lang = t4.lang AND (p.t4_official IS NOT NULL OR p.t4_lang = r.olang) THEN ARRAY[t4.lang::text, COALESCE(CASE WHEN p.t4_latin THEN t4.latin ELSE NULL END, t4.title)]
+ ELSE ARRAY[r.olang::text, COALESCE(CASE WHEN p IS NULL OR p.to_latin THEN ol.latin ELSE NULL END, ol.title)] END
+ ) || (CASE
+ WHEN p.a1_lang = a1.lang AND (p.a1_official IS NOT NULL OR p.a1_lang = r.olang) THEN ARRAY[a1.lang::text, COALESCE(CASE WHEN p.a1_latin THEN a1.latin ELSE NULL END, a1.title)]
+ WHEN p.a2_lang = a2.lang AND (p.a2_official IS NOT NULL OR p.a2_lang = r.olang) THEN ARRAY[a2.lang::text, COALESCE(CASE WHEN p.a2_latin THEN a2.latin ELSE NULL END, a2.title)]
+ WHEN p.a3_lang = a3.lang AND (p.a3_official IS NOT NULL OR p.a3_lang = r.olang) THEN ARRAY[a3.lang::text, COALESCE(CASE WHEN p.a3_latin THEN a3.latin ELSE NULL END, a3.title)]
+ WHEN p.a4_lang = a4.lang AND (p.a4_official IS NOT NULL OR p.a4_lang = r.olang) THEN ARRAY[a4.lang::text, COALESCE(CASE WHEN p.a4_latin THEN a4.latin ELSE NULL END, a4.title)]
+ ELSE ARRAY[r.olang::text, COALESCE(CASE WHEN p.ao_latin THEN ol.latin ELSE NULL END, ol.title)] END)
+ , CASE
+ WHEN p.t1_lang = t1.lang AND (p.t1_official IS NOT NULL OR p.t1_lang = r.olang) THEN COALESCE(t1.latin, t1.title)
+ WHEN p.t2_lang = t2.lang AND (p.t2_official IS NOT NULL OR p.t2_lang = r.olang) THEN COALESCE(t2.latin, t2.title)
+ WHEN p.t3_lang = t3.lang AND (p.t3_official IS NOT NULL OR p.t3_lang = r.olang) THEN COALESCE(t3.latin, t3.title)
+ WHEN p.t4_lang = t4.lang AND (p.t4_official IS NOT NULL OR p.t4_lang = r.olang) THEN COALESCE(t4.latin, t4.title)
+ ELSE COALESCE(ol.latin, ol.title) END
+ FROM releases r
+ JOIN releases_titles ol ON ol.id = r.id AND ol.lang = r.olang
+ LEFT JOIN releases_titles t1 ON t1.id = r.id AND t1.lang = COALESCE(p.t1_lang, 'en') AND t1.title IS NOT NULL
+ LEFT JOIN releases_titles t2 ON t2.id = r.id AND t2.lang = COALESCE(p.t2_lang, 'en') AND t2.title IS NOT NULL
+ LEFT JOIN releases_titles t3 ON t3.id = r.id AND t3.lang = COALESCE(p.t3_lang, 'en') AND t3.title IS NOT NULL
+ LEFT JOIN releases_titles t4 ON t4.id = r.id AND t4.lang = COALESCE(p.t4_lang, 'en') AND t4.title IS NOT NULL
+ LEFT JOIN releases_titles a1 ON a1.id = r.id AND a1.lang = COALESCE(p.a1_lang, 'en') AND a1.title IS NOT NULL
+ LEFT JOIN releases_titles a2 ON a2.id = r.id AND a2.lang = COALESCE(p.a2_lang, 'en') AND a2.title IS NOT NULL
+ LEFT JOIN releases_titles a3 ON a3.id = r.id AND a3.lang = COALESCE(p.a3_lang, 'en') AND a3.title IS NOT NULL
+ LEFT JOIN releases_titles a4 ON a4.id = r.id AND a4.lang = COALESCE(p.a4_lang, 'en') AND a4.title IS NOT NULL
+$$ LANGUAGE SQL STABLE;
+
+
+
+-- This one just flips the name/original columns around depending on
+-- preferences, so is fast enough to use directly.
+CREATE OR REPLACE FUNCTION producerst(p titleprefs) RETURNS SETOF producerst AS $$
+ SELECT *, titleprefs_swap(p, lang, name, latin), COALESCE(latin, name) FROM producers
+$$ LANGUAGE SQL STABLE;
+
+
+
+-- Same for charst
+CREATE OR REPLACE FUNCTION charst(p titleprefs) RETURNS SETOF charst AS $$
+ SELECT *, titleprefs_swap(p, c_lang, name, latin), COALESCE(latin, name) FROM chars
+$$ LANGUAGE SQL STABLE;
+
+
+
+-- Same for staff_aliast
+CREATE OR REPLACE FUNCTION staff_aliast(p titleprefs) RETURNS SETOF staff_aliast AS $$
+ SELECT s.*, sa.aid, sa.name, sa.latin
+ , titleprefs_swap(p, s.lang, sa.name, sa.latin), COALESCE(sa.latin, sa.name)
+ FROM staff s
+ JOIN staff_alias sa ON sa.id = s.id
+$$ LANGUAGE SQL STABLE;
+
+
+
+-- update_vncache(id) - updates some c_* columns in the vn table
+CREATE OR REPLACE FUNCTION update_vncache(vndbid) RETURNS void AS $$
+ UPDATE vn SET
+ c_released = COALESCE((
+ SELECT MIN(r.released)
+ FROM releases r
+ JOIN releases_vn rv ON r.id = rv.id
+ WHERE rv.vid = $1
+ AND rv.rtype <> 'trial'
+ AND r.hidden = FALSE
+ AND r.released <> 0
+ AND r.official
+ GROUP BY rv.vid
+ ), 0),
+ c_languages = ARRAY(
+ SELECT rl.lang
+ FROM releases_titles rl
+ JOIN releases r ON r.id = rl.id
+ JOIN releases_vn rv ON r.id = rv.id
+ WHERE rv.vid = $1
+ AND rv.rtype <> 'trial'
+ AND NOT rl.mtl
+ AND r.released <= TO_CHAR('today'::timestamp, 'YYYYMMDD')::integer
+ AND r.hidden = FALSE
+ GROUP BY rl.lang
+ ORDER BY rl.lang
+ ),
+ c_platforms = ARRAY(
+ SELECT rp.platform
+ FROM releases_platforms rp
+ JOIN releases r ON rp.id = r.id
+ JOIN releases_vn rv ON rp.id = rv.id
+ WHERE rv.vid = $1
+ AND rv.rtype <> 'trial'
+ AND r.released <= TO_CHAR('today'::timestamp, 'YYYYMMDD')::integer
+ AND r.hidden = FALSE
+ GROUP BY rp.platform
+ ORDER BY rp.platform
+ ),
+ c_developers = ARRAY(
+ SELECT rp.pid
+ FROM releases_producers rp
+ JOIN releases r ON rp.id = r.id
+ JOIN releases_vn rv ON rv.id = r.id
+ WHERE rv.vid = $1
+ AND r.official AND rp.developer
+ AND r.hidden = FALSE
+ GROUP BY rp.pid
+ ORDER BY rp.pid
+ )
+ WHERE id = $1;
+$$ LANGUAGE sql;
+
+
+-- Update c_rating, c_votecount, c_pop_rank, c_rat_rank and c_average
+CREATE OR REPLACE FUNCTION update_vnvotestats() RETURNS void AS $$
+ WITH votes(vid, uid, vote) AS ( -- List of all non-ignored VN votes
+ SELECT vid, uid, vote FROM ulist_vns WHERE vote IS NOT NULL AND uid NOT IN(SELECT id FROM users WHERE ign_votes)
+ ), avgavg(avgavg) AS ( -- Average vote average
+ SELECT AVG(a)::real FROM (SELECT AVG(vote) FROM votes GROUP BY vid) x(a)
+ ), ratings(vid, count, average, rating) AS ( -- Ratings and vote counts
+ SELECT vid, COUNT(uid), (AVG(vote)*10)::smallint,
+ -- Bayesian average B(a,p,votes) = (p * a + sum(votes)) / (p + count(votes))
+ -- p = (1 - min(1, count(votes)/100)) * 7 i.e. linear interpolation from 7 to 0 for vote counts from 0 to 100.
+ -- a = Average vote average
+ ( (1 - LEAST(1, COUNT(uid)::real/100))*7 * (SELECT avgavg FROM avgavg) + SUM(vote) ) /
+ ( (1 - LEAST(1, COUNT(uid)::real/100))*7 + COUNT(uid) )
+ FROM votes
+ GROUP BY vid
+ ), capped(vid, count, average, rating) AS ( -- Ratings, but capped
+ SELECT vid, count, average, CASE
+ WHEN count < 5 THEN NULL
+ WHEN count < 50 THEN LEAST(rating, (SELECT rating FROM ratings WHERE count >= 50 ORDER BY rating DESC LIMIT 1 OFFSET 101))
+ WHEN count < 100 THEN LEAST(rating, (SELECT rating FROM ratings WHERE count >= 100 ORDER BY rating DESC LIMIT 1 OFFSET 51))
+ ELSE rating END
+ FROM ratings
+ ), stats(vid, count, average, rating, rat_rank, pop_rank) AS ( -- Combined stats
+ SELECT v.id, COALESCE(r.count, 0), r.average, (r.rating*10)::smallint
+ , CASE WHEN r.rating IS NULL THEN NULL ELSE rank() OVER(ORDER BY hidden, r.rating DESC NULLS LAST) END
+ , rank() OVER(ORDER BY hidden, r.count DESC NULLS LAST)
+ FROM vn v
+ LEFT JOIN capped r ON r.vid = v.id
+ )
+ UPDATE vn SET c_rating = rating, c_votecount = count, c_pop_rank = pop_rank, c_rat_rank = rat_rank, c_average = average
+ FROM stats
+ WHERE id = vid AND (c_rating, c_votecount, c_pop_rank, c_rat_rank, c_average) IS DISTINCT FROM (rating, count, pop_rank, rat_rank, average);
+$$ LANGUAGE SQL;
+
+
+
+-- Updates vn.c_length and vn.c_lengthnum
+CREATE OR REPLACE FUNCTION update_vn_length_cache(vndbid) RETURNS void AS $$
+ WITH s (vid, cnt, len) AS (
+ SELECT v.id, count(l.vid) FILTER (WHERE u.id IS NOT NULL AND l.vid IS NOT NULL AND v.devstatus <> 1)
+ , percentile_cont(0.5) WITHIN GROUP (ORDER BY l.length + (l.length/4 * (l.speed-1))) FILTER (WHERE u.id IS NOT NULL AND l.vid IS NOT NULL AND v.devstatus <> 1)
+ FROM vn v
+ LEFT JOIN vn_length_votes l ON l.vid = v.id AND l.speed IS NOT NULL AND NOT l.private
+ LEFT JOIN users u ON u.id = l.uid AND u.perm_lengthvote
+ WHERE ($1 IS NULL OR v.id = $1)
+ GROUP BY v.id
+ ) UPDATE vn SET c_lengthnum = cnt, c_length = len
+ FROM s
+ WHERE s.vid = id AND (c_lengthnum, c_length) IS DISTINCT FROM (cnt, len)
+$$ LANGUAGE SQL;
+
+
+
+-- c_weight = if not_referenced then 0 else lower(c_votecount) -> higher(c_weight) && higher(*_stddev) -> higher(c_weight)
+--
+-- Current algorithm:
+--
+-- votes_weight = 2 ^ max(0, 14 - c_votecount) -> exponential weight between 1 and 2^13 (~16k)
+-- (sexual|violence)_weight = (stddev/max_stddev)^2 * 100
+-- weight = votes_weight + sexual_weight + violence_weight
+--
+-- This isn't very grounded in theory, I've no clue how statistics work. I
+-- suspect confidence intervals/levels are more appropriate for this use case.
+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_uids = uids
+ FROM (
+ SELECT s.id, s.votecount, s.uids
+ , COALESCE(s.sexual_avg *100, 200) AS sexual_avg, COALESCE(s.sexual_stddev *100, 0) AS sexual_stddev
+ , COALESCE(s.violence_avg*100, 200) AS violence_avg, COALESCE(s.violence_stddev*100, 0) AS violence_stddev
+ , CASE WHEN s.votecount >= 15 THEN 1 -- Lock the weight at 1 at 15 votes, collecting more votes is just inefficient
+ WHEN EXISTS(
+ SELECT 1 FROM vn v WHERE s.id BETWEEN 'cv1' AND vndbid_max('cv') AND NOT v.hidden AND v.image = s.id
+ UNION ALL SELECT 1 FROM vn_screenshots vs JOIN vn v ON v.id = vs.id WHERE s.id BETWEEN 'sf1' AND vndbid_max('sf') AND NOT v.hidden AND vs.scr = s.id
+ UNION ALL SELECT 1 FROM chars c WHERE s.id BETWEEN 'ch1' AND vndbid_max('ch') AND NOT c.hidden AND c.image = s.id
+ )
+ THEN ceil(pow(2, greatest(0, 14 - s.votecount)) + coalesce(pow(s.sexual_stddev, 2), 0)*100 + coalesce(pow(s.violence_stddev, 2), 0)*100)
+ ELSE 0 END AS weight
+ FROM (
+ SELECT i.id, count(iv.id) AS votecount
+ , round(avg(sexual) FILTER(WHERE NOT iv.ignore), 2) AS sexual_avg
+ , round(avg(violence) FILTER(WHERE NOT iv.ignore), 2) AS violence_avg
+ , round(stddev_pop(sexual) FILTER(WHERE NOT iv.ignore), 2) AS sexual_stddev
+ , round(stddev_pop(violence) FILTER(WHERE NOT iv.ignore), 2) 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
+ WHERE ($1 IS NULL OR i.id = $1)
+ AND (u.id IS NULL OR u.perm_imgvote)
+ GROUP BY i.id
+ ) s
+ ) weights
+ WHERE weights.id = images.id AND (c_votecount, c_sexual_avg, c_sexual_stddev, c_violence_avg, c_violence_stddev, c_weight, c_uids)
+ IS DISTINCT FROM (votecount, sexual_avg, sexual_stddev, violence_avg, violence_stddev, weight, uids);
+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) AS (
+ SELECT r.id
+ , 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 (rv.overrule OR 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 (rv.overrule OR 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
+ LEFT JOIN reviews r2 ON r2.vid = r.vid AND r2.uid = rv.uid
+ WHERE $1 IS NULL OR r.id = $1
+ GROUP BY r.id
+ )
+ 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;
+
+
+
+-- Update users.c_vns, c_votes and c_wish for one user (when given an id) or all users (when given NULL)
+CREATE OR REPLACE FUNCTION update_users_ulist_stats(vndbid) RETURNS void AS $$
+BEGIN
+ WITH cnt(uid, votes, vns, wish) AS (
+ SELECT u.id
+ , COUNT(uv.vid) FILTER (WHERE NOT uv.c_private AND uv.vote IS NOT NULL) -- Voted
+ , COUNT(uv.vid) FILTER (WHERE NOT uv.c_private AND NOT (uv.labels <@ ARRAY[5,6]::smallint[])) -- Labelled, but not wishlish/blacklist
+ , COUNT(uv.vid) FILTER (WHERE uwish.private IS NOT DISTINCT FROM false AND uv.labels && ARRAY[5::smallint]) -- Wishlist
+ FROM users u
+ LEFT JOIN ulist_vns uv ON uv.uid = u.id
+ LEFT JOIN ulist_labels uwish ON uwish.uid = u.id AND uwish.id = 5
+ WHERE $1 IS NULL OR u.id = $1
+ GROUP BY u.id
+ ) UPDATE users SET c_votes = votes, c_vns = vns, c_wish = wish
+ FROM cnt WHERE id = uid AND (c_votes, c_vns, c_wish) IS DISTINCT FROM (votes, vns, wish);
+END;
+$$ LANGUAGE plpgsql; -- Don't use "LANGUAGE SQL" here; Make sure to generate a new query plan at invocation time.
+
+
+
+-- Update ulist_vns.c_private for a particular (user, vid). vid can be null to
+-- update the cache for the all VNs in the user's list, user can also be null
+-- to update the cache for everyone.
+CREATE OR REPLACE FUNCTION update_users_ulist_private(vndbid, vndbid) RETURNS void AS $$
+BEGIN
+ WITH p(uid,vid,private) AS (
+ SELECT uv.uid, uv.vid, COALESCE(bool_and(l.private), true)
+ FROM ulist_vns uv
+ LEFT JOIN unnest(uv.labels) x(id) ON true
+ LEFT JOIN ulist_labels l ON l.id = x.id AND l.uid = uv.uid
+ WHERE ($1 IS NULL OR uv.uid = $1)
+ AND ($2 IS NULL OR uv.vid = $2)
+ GROUP BY uv.uid, uv.vid
+ ) UPDATE ulist_vns SET c_private = p.private FROM p
+ WHERE ulist_vns.uid = p.uid AND ulist_vns.vid = p.vid AND ulist_vns.c_private <> p.private;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+-- Update tags_vn_direct & tags_vn_inherit.
+-- When a vid is given, only the tags for that vid will be updated. These
+-- incremental updates do not affect tags.c_items, so that may still get
+-- out-of-sync.
+CREATE OR REPLACE FUNCTION tag_vn_calc(uvid vndbid) RETURNS void AS $$
+BEGIN
+ -- tags_vn_direct
+ WITH new (tag, vid, rating, count, spoiler, lie) AS (
+ -- Rows that we want
+ SELECT tv.tag, tv.vid
+ -- https://vndb.org/t13470.28 -> (z || 3) * ((x-y) / (x+y))
+ -- No exception is made for the x==y case, a score of 0 seems better to me.
+ , (COALESCE(AVG(tv.vote) filter (where tv.vote > 0), 3) * SUM(sign(tv.vote)) / COUNT(tv.vote))::real
+ , LEAST( COUNT(tv.vote) filter (where tv.vote > 0), 32000 )::smallint
+ , CASE WHEN COUNT(spoiler) = 0 THEN MIN(t.defaultspoil) WHEN AVG(spoiler) > 1.3 THEN 2 WHEN AVG(spoiler) > 0.4 THEN 1 ELSE 0 END
+ , count(lie) filter(where lie) > 0 AND count(lie) filter (where lie) >= count(lie) filter(where not lie)
+ FROM tags_vn tv
+ JOIN tags t ON t.id = tv.tag
+ LEFT JOIN users u ON u.id = tv.uid
+ WHERE NOT t.hidden
+ AND NOT tv.ignore AND (u.id IS NULL OR u.perm_tag)
+ AND vid NOT IN(SELECT id FROM vn WHERE hidden)
+ AND (uvid IS NULL OR vid = uvid)
+ GROUP BY tv.tag, tv.vid
+ HAVING SUM(sign(tv.vote)) > 0
+ ), n AS (
+ -- Add existing rows from tags_vn_direct as NULLs, so we can delete them during merge
+ SELECT coalesce(a.tag, b.tag) AS tag, coalesce(a.vid, b.vid) AS vid, a.rating, a.count, a.spoiler, a.lie
+ FROM new a
+ FULL OUTER JOIN (SELECT tag, vid FROM tags_vn_direct WHERE uvid IS NULL OR vid = uvid) b on (a.tag, a.vid) = (b.tag, b.vid)
+ -- Now merge
+ ) MERGE INTO tags_vn_direct o USING n ON (n.tag, n.vid) = (o.tag, o.vid)
+ WHEN NOT MATCHED THEN INSERT (tag, vid, rating, count, spoiler, lie) VALUES (n.tag, n.vid, n.rating, (n)."count", n.spoiler, n.lie)
+ WHEN MATCHED AND n.rating IS NULL THEN DELETE
+ WHEN MATCHED AND (o.rating, o.count, o.spoiler, o.lie) IS DISTINCT FROM (n.rating, n.count, n.spoiler, n.lie) THEN
+ UPDATE SET rating = n.rating, count = n.count, spoiler = n.spoiler, lie = n.lie;
+
+ -- tags_vn_inherit, based on the data from tags_vn_direct
+ WITH new (tag, vid, rating, spoiler, lie) AS (
+ -- Add parent tags to tags_vn_direct
+ WITH RECURSIVE t_all(lvl, tag, vid, vote, spoiler, lie) AS (
+ SELECT 15, tag, vid, rating, spoiler, lie
+ FROM tags_vn_direct
+ WHERE (uvid IS NULL OR vid = uvid)
+ UNION ALL
+ SELECT ta.lvl-1, tp.parent, ta.vid, ta.vote, ta.spoiler, ta.lie
+ FROM t_all ta
+ JOIN tags_parents tp ON tp.id = ta.tag
+ WHERE ta.lvl > 0
+ -- Merge duplicates
+ ) SELECT tag, vid, AVG(vote)::real, MIN(spoiler), bool_and(lie)
+ FROM t_all
+ WHERE tag IN(SELECT id FROM tags WHERE searchable)
+ GROUP BY tag, vid
+ ), n AS (
+ -- Add existing rows from tags_vn_inherit as NULLs, so we can delete them during merge
+ SELECT coalesce(a.tag, b.tag) AS tag, coalesce(a.vid, b.vid) AS vid, a.rating, a.spoiler, a.lie
+ FROM new a
+ FULL OUTER JOIN (SELECT tag, vid FROM tags_vn_inherit WHERE uvid IS NULL OR vid = uvid) b on (a.tag, a.vid) = (b.tag, b.vid)
+ -- Now merge
+ ) MERGE INTO tags_vn_inherit o USING n ON (n.tag, n.vid) = (o.tag, o.vid)
+ WHEN NOT MATCHED THEN INSERT (tag, vid, rating, spoiler, lie) VALUES (n.tag, n.vid, n.rating, n.spoiler, n.lie)
+ WHEN MATCHED AND n.rating IS NULL THEN DELETE
+ WHEN MATCHED AND (o.rating, o.spoiler, o.lie) IS DISTINCT FROM (n.rating, n.spoiler, n.lie) THEN
+ UPDATE SET rating = n.rating, spoiler = n.spoiler, lie = n.lie;
+
+ IF uvid IS NULL THEN
+ UPDATE tags SET c_items = (SELECT COUNT(*) FROM tags_vn_inherit WHERE tag = id);
+ END IF;
+ RETURN;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+-- Recalculate traits_chars. Pretty much same thing as tag_vn_calc().
+CREATE OR REPLACE FUNCTION traits_chars_calc(ucid vndbid) RETURNS void AS $$
+BEGIN
+ WITH new (tid, cid, spoil, lie) AS (
+ -- all char<->trait links of the latest revisions, including chars inherited from child traits.
+ -- (also includes non-searchable traits, because they could have a searchable trait as parent)
+ WITH RECURSIVE t_all(lvl, tid, cid, spoiler, lie) AS (
+ SELECT 15, tid, ct.id, spoil, lie
+ FROM chars_traits ct
+ WHERE id NOT IN(SELECT id from chars WHERE hidden)
+ AND (ucid IS NULL OR ct.id = ucid)
+ AND NOT EXISTS (SELECT 1 FROM traits t WHERE t.id = ct.tid AND t.hidden)
+ UNION ALL
+ SELECT lvl-1, tp.parent, tc.cid, tc.spoiler, tc.lie
+ FROM t_all tc
+ JOIN traits_parents tp ON tp.id = tc.tid
+ JOIN traits t ON t.id = tp.parent
+ WHERE NOT t.hidden
+ AND tc.lvl > 0
+ )
+ -- now grouped by (tid, cid), with non-searchable traits filtered out
+ SELECT tid, cid
+ , (CASE WHEN MIN(spoiler) > 1.3 THEN 2 WHEN MIN(spoiler) > 0.7 THEN 1 ELSE 0 END)::smallint
+ , bool_and(lie)
+ FROM t_all
+ WHERE tid IN(SELECT id FROM traits WHERE searchable)
+ GROUP BY tid, cid
+ ), n AS (
+ -- Add existing rows from traits_chars as NULLs, so we can delete them during merge
+ SELECT coalesce(a.tid, b.tid) AS tid, coalesce(a.cid, b.cid) AS cid, a.spoil, a.lie
+ FROM new a
+ FULL OUTER JOIN (SELECT tid, cid FROM traits_chars WHERE ucid IS NULL OR cid = ucid) b on (a.tid, a.cid) = (b.tid, b.cid)
+ -- Now merge
+ ) MERGE INTO traits_chars o USING n ON (n.tid, n.cid) = (o.tid, o.cid)
+ WHEN NOT MATCHED THEN INSERT (tid, cid, spoil, lie) VALUES (n.tid, n.cid, n.spoil, n.lie)
+ WHEN MATCHED AND n.spoil IS NULL THEN DELETE
+ WHEN MATCHED AND (o.spoil, o.lie) IS DISTINCT FROM (n.spoil, n.lie) THEN
+ UPDATE SET spoil = n.spoil, lie = n.lie;
+
+ IF ucid IS NULL THEN
+ UPDATE traits SET c_items = (SELECT COUNT(*) FROM traits_chars WHERE tid = id);
+ END IF;
+ RETURN;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+CREATE OR REPLACE FUNCTION quotes_rand_calc() RETURNS void AS $$
+ WITH q(id, vid, score) AS (
+ SELECT id, vid, score FROM quotes q WHERE score > 0 AND NOT hidden AND EXISTS(SELECT 1 FROM vn v WHERE v.id = q.vid AND NOT v.hidden)
+ ), r(id,rand) AS (
+ SELECT id, -- 'rand' is chosen such that each VN has an equal probability to be selected, regardless of how many quotes it has.
+ ( ((dense_rank() OVER (ORDER BY vid)) - 1)::real -- [0..n-1] cumulative count of distinct VNs
+ + ((sum(score) OVER (PARTITION BY vid ORDER BY id) - score)::float / (sum(score) OVER (PARTITION BY vid))) -- [0,1) cumulative normalized score of this quote
+ ) / (SELECT count(DISTINCT vid) FROM q)
+ FROM q
+ ), u AS (
+ UPDATE quotes SET rand = NULL WHERE rand IS NOT NULL AND NOT EXISTS(SELECT 1 FROM r WHERE quotes.id = r.id)
+ ) UPDATE quotes SET rand = r.rand FROM r WHERE quotes.rand IS DISTINCT FROM r.rand AND r.id = quotes.id;
+$$ LANGUAGE SQL;
+
+
+
+-- Fully recalculate all rows in stats_cache
+CREATE OR REPLACE FUNCTION update_stats_cache_full() RETURNS void AS $$
+BEGIN
+ UPDATE stats_cache SET count = (SELECT COUNT(*) FROM vn WHERE hidden = FALSE) WHERE section = 'vn';
+ UPDATE stats_cache SET count = (SELECT COUNT(*) FROM releases WHERE hidden = FALSE) WHERE section = 'releases';
+ UPDATE stats_cache SET count = (SELECT COUNT(*) FROM producers WHERE hidden = FALSE) WHERE section = 'producers';
+ UPDATE stats_cache SET count = (SELECT COUNT(*) FROM chars WHERE hidden = FALSE) WHERE section = 'chars';
+ UPDATE stats_cache SET count = (SELECT COUNT(*) FROM staff WHERE hidden = FALSE) WHERE section = 'staff';
+ UPDATE stats_cache SET count = (SELECT COUNT(*) FROM tags WHERE hidden = FALSE) WHERE section = 'tags';
+ UPDATE stats_cache SET count = (SELECT COUNT(*) FROM traits WHERE hidden = FALSE) WHERE section = 'traits';
+END;
+$$ LANGUAGE plpgsql;
+
+
+-- Create ulist labels for new users.
+CREATE OR REPLACE FUNCTION ulist_labels_create(vndbid) RETURNS void AS $$
+ INSERT INTO ulist_labels (uid, id, label, private)
+ VALUES ($1, 1, 'Playing', false),
+ ($1, 2, 'Finished', false),
+ ($1, 3, 'Stalled', false),
+ ($1, 4, 'Dropped', false),
+ ($1, 5, 'Wishlist', false),
+ ($1, 6, 'Blacklist', false),
+ ($1, 7, 'Voted', false)
+ ON CONFLICT (uid, id) DO NOTHING;
+$$ LANGUAGE SQL;
+
+
+-- Returns generic information for almost every supported vndbid + num.
+-- Not currently supported: ch#, cv#, sf#
+-- Some oddities:
+-- * The given user title preferences are not used for explicit revisions.
+-- * Trait names are prefixed with their group name ("Group > Trait"), but only for non-revisions.
+--
+-- Returned fields:
+-- * title - Titles array, same format as returned by vnt().
+-- For users this is their username, not displayname.
+-- * uid - User who created/initiated this entry. Used in notification listings and reports
+-- * hidden - Whether this entry is 'hidden' or private. Used for the reporting function & framework_ object.
+-- For edits this info comes from the revision itself, not the final entry.
+-- Interpretation of this field is dependent on the entry type, For most database entries,
+-- 'hidden' means "partially visible if you know the ID, but not shown in regular listings".
+-- For threads it means "totally invisible, does not exist".
+-- * locked - Whether this entry is 'locked'. Used for the framework_ object.
+CREATE OR REPLACE FUNCTION item_info(titleprefs, vndbid, int, out ret item_info_type) AS $$
+BEGIN
+ -- x#
+ IF $3 IS NULL THEN CASE vndbid_type($2)
+ WHEN 'v' THEN SELECT v.title, NULL::vndbid, v.hidden, v.locked INTO ret FROM vnt($1) v WHERE v.id = $2;
+ WHEN 'r' THEN SELECT r.title, NULL::vndbid, r.hidden, r.locked INTO ret FROM releasest($1) r WHERE r.id = $2;
+ WHEN 'p' THEN SELECT p.title, NULL::vndbid, p.hidden, p.locked INTO ret FROM producerst($1) p WHERE p.id = $2;
+ WHEN 'c' THEN SELECT c.title, NULL::vndbid, c.hidden, c.locked INTO ret FROM charst($1) c WHERE c.id = $2;
+ WHEN 'd' THEN SELECT ARRAY[NULL, d.title, NULL, d.title], NULL::vndbid, d.hidden, d.locked INTO ret FROM docs d WHERE d.id = $2;
+ WHEN 'g' THEN SELECT ARRAY[NULL, g.name, NULL, g.name], NULL::vndbid, g.hidden, g.locked INTO ret FROM tags g WHERE g.id = $2;
+ WHEN 'i' THEN SELECT ARRAY[NULL, COALESCE(g.name||' > ', '')||i.name, NULL, COALESCE(g.name||' > ', '')||i.name], NULL::vndbid, i.hidden, i.locked INTO ret FROM traits i LEFT JOIN traits g ON g.id = i.gid WHERE i.id = $2;
+ WHEN 's' THEN SELECT s.title, NULL::vndbid, s.hidden, s.locked INTO ret FROM staff_aliast($1) s WHERE s.id = $2 AND s.aid = s.main;
+ WHEN 't' THEN SELECT ARRAY[NULL, t.title, NULL, t.title], NULL::vndbid, t.hidden OR t.private, t.locked INTO ret FROM threads t WHERE t.id = $2;
+ WHEN 'w' THEN SELECT v.title, w.uid, w.c_flagged, w.locked INTO ret FROM reviews w JOIN vnt v ON v.id = w.vid WHERE w.id = $2;
+ WHEN 'u' THEN SELECT ARRAY[NULL, COALESCE(u.username, u.id::text), NULL, COALESCE(u.username, u.id::text)], NULL::vndbid, u.username IS NULL, FALSE INTO ret FROM users u WHERE u.id = $2;
+ ELSE NULL;
+ END CASE;
+ -- x#.#
+ ELSE CASE vndbid_type($2)
+ WHEN 'v' THEN SELECT ARRAY[v.olang::text, COALESCE(vo.latin, vo.title), v.olang::text, CASE WHEN vo.latin IS NULL THEN '' ELSE vo.title END], h.requester, h.ihid, h.ilock INTO ret
+ FROM changes h JOIN vn_hist v ON h.id = v.chid JOIN vn_titles_hist vo ON h.id = vo.chid AND vo.lang = v.olang WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'r' THEN SELECT ARRAY[r.olang::text, COALESCE(ro.latin, ro.title), r.olang::text, CASE WHEN ro.latin IS NULL THEN '' ELSE ro.title END], h.requester, h.ihid, h.ilock INTO ret
+ FROM changes h JOIN releases_hist r ON h.id = r.chid JOIN releases_titles_hist ro ON h.id = ro.chid AND ro.lang = r.olang WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'p' THEN SELECT ARRAY[p.lang::text, COALESCE(p.latin, p.name), p.lang::text, p.name], h.requester, h.ihid, h.ilock INTO ret FROM changes h JOIN producers_hist p ON h.id = p.chid WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'c' THEN SELECT ARRAY[cm.c_lang::text, COALESCE(c.latin, c.name), cm.c_lang::text, c.name], h.requester, h.ihid, h.ilock INTO ret FROM changes h JOIN chars cm ON cm.id = h.itemid JOIN chars_hist c ON h.id = c.chid WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'd' THEN SELECT ARRAY[NULL, d.title, NULL, d.title ], h.requester, h.ihid, h.ilock INTO ret FROM changes h JOIN docs_hist d ON h.id = d.chid WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'g' THEN SELECT ARRAY[NULL, g.name, NULL, g.name ], h.requester, h.ihid, h.ilock INTO ret FROM changes h JOIN tags_hist g ON h.id = g.chid WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'i' THEN SELECT ARRAY[NULL, i.name, NULL, i.name ], h.requester, h.ihid, h.ilock INTO ret FROM changes h JOIN traits_hist i ON h.id = i.chid WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 's' THEN SELECT ARRAY[s.lang::text, COALESCE(sa.latin, sa.name), s.lang::text, sa.name], h.requester, h.ihid, h.ilock INTO ret 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.main WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 't' THEN SELECT ARRAY[NULL, t.title, NULL, t.title], tp.uid, t.hidden OR t.private OR tp.hidden IS NOT NULL, t.locked INTO ret FROM threads t JOIN threads_posts tp ON tp.tid = t.id WHERE t.id = $2 AND tp.num = $3;
+ WHEN 'w' THEN SELECT v.title, wp.uid, w.c_flagged OR wp.hidden IS NOT NULL, w.locked INTO ret FROM reviews w JOIN vnt($1) v ON v.id = w.vid JOIN reviews_posts wp ON wp.id = w.id WHERE w.id = $2 AND wp.num = $3;
+ ELSE NULL;
+ END CASE;
+ END IF;
+END;
+$$ LANGUAGE plpgsql STABLE;
+
+
+
+----------------------------------------------------------
+-- revision insertion abstraction --
+----------------------------------------------------------
+
+-- The two functions below are utility functions used by the item-specific functions in editfunc.sql
+
+-- create temporary table for generic revision info, and returns the chid of the revision being edited (or NULL).
+CREATE OR REPLACE FUNCTION edit_revtable(xitemid vndbid, xrev integer) RETURNS integer AS $$
+DECLARE
+ x record;
+BEGIN
+ BEGIN
+ CREATE TEMPORARY TABLE edit_revision (
+ itemid vndbid,
+ requester vndbid,
+ comments text,
+ ihid boolean,
+ ilock boolean
+ );
+ EXCEPTION WHEN duplicate_table THEN
+ TRUNCATE edit_revision;
+ END;
+ SELECT INTO x id, ihid, ilock FROM changes c WHERE itemid = xitemid AND rev = xrev;
+ INSERT INTO edit_revision (itemid, ihid, ilock) VALUES (xitemid, COALESCE(x.ihid, FALSE), COALESCE(x.ilock, FALSE));
+ RETURN x.id;
+END;
+$$ LANGUAGE plpgsql;
+
+
+-- Check for stuff to be done when an item has been changed
+CREATE OR REPLACE FUNCTION edit_committed(nchid integer, nitemid vndbid, nrev integer) RETURNS void AS $$
+DECLARE
+ xoldchid integer;
+BEGIN
+ SELECT id INTO xoldchid FROM changes WHERE itemid = nitemid AND rev = nrev-1;
+
+ -- Update search_cache
+ IF vndbid_type(nitemid) IN('v','r','c','p','s','g','i') THEN
+ PERFORM update_search(nitemid);
+ END IF;
+
+ -- Update search_cache for related VNs when
+ -- 1. A new release is created
+ -- 2. A release has been hidden or unhidden
+ -- 3. The releases_titles have changed
+ -- 4. The releases_vn table differs from a previous revision
+ IF vndbid_type(nitemid) = 'r' THEN
+ IF -- 1.
+ xoldchid IS NULL OR
+ -- 2.
+ EXISTS(SELECT 1 FROM changes c1, changes c2 WHERE c1.ihid IS DISTINCT FROM c2.ihid AND c1.id = nchid AND c2.id = xoldchid) OR
+ -- 3.
+ EXISTS(SELECT title, latin FROM releases_titles_hist WHERE chid = xoldchid EXCEPT SELECT title, latin FROM releases_titles_hist WHERE chid = nchid) OR
+ EXISTS(SELECT title, latin FROM releases_titles_hist WHERE chid = nchid EXCEPT SELECT title, latin FROM releases_titles_hist WHERE chid = xoldchid) OR
+ -- 4.
+ EXISTS(SELECT vid FROM releases_vn_hist WHERE chid = xoldchid EXCEPT SELECT vid FROM releases_vn_hist WHERE chid = nchid) OR
+ EXISTS(SELECT vid FROM releases_vn_hist WHERE chid = nchid EXCEPT SELECT vid FROM releases_vn_hist WHERE chid = xoldchid)
+ THEN
+ PERFORM update_search(vid) FROM releases_vn_hist WHERE chid IN(nchid, xoldchid);
+ END IF;
+ END IF;
+
+ -- Update drm.c_ref
+ IF vndbid_type(nitemid) = 'r' THEN
+ WITH
+ old (id) AS (SELECT r.drm FROM releases_drm_hist r, changes c WHERE r.chid = xoldchid AND c.id = xoldchid AND NOT c.ihid),
+ new (id) AS (SELECT r.drm FROM releases_drm_hist r, changes c WHERE r.chid = nchid AND c.id = nchid AND NOT c.ihid),
+ ins AS (UPDATE drm SET c_ref = c_ref + 1 WHERE id IN(SELECT id FROM new EXCEPT SELECT id FROM old))
+ UPDATE drm SET c_ref = c_ref - 1 WHERE id IN(SELECT id FROM old EXCEPT SELECT id FROM new);
+ END IF;
+
+ -- Update tags_vn_* when the VN's hidden flag is changed
+ IF vndbid_type(nitemid) = 'v' AND EXISTS(SELECT 1 FROM changes c1, changes c2 WHERE c1.ihid IS DISTINCT FROM c2.ihid AND c1.id = nchid AND c2.id = xoldchid) THEN
+ PERFORM tag_vn_calc(nitemid);
+ END IF;
+
+ -- Ensure chars.c_lang is updated when the related VN or char has been edited
+ -- (the cache also depends on vn.c_released but isn't run when that is updated;
+ -- not an issue, the c_released is only there as rare fallback)
+ IF vndbid_type(nitemid) IN('c','v') THEN
+ WITH x(id,lang) AS (
+ SELECT DISTINCT ON (cv.id) cv.id, v.olang
+ FROM chars_vns cv
+ JOIN vn v ON v.id = cv.vid
+ WHERE cv.vid = nitemid OR cv.id = nitemid
+ ORDER BY cv.id, v.hidden, v.c_released
+ ) UPDATE chars c SET c_lang = x.lang FROM x WHERE c.id = x.id AND c.c_lang <> x.lang;
+ END IF;
+
+ -- Call update_vncache() for related VNs when a release has been created or edited
+ -- (This could be made more specific, but update_vncache() is fast enough that it's not worth the complexity)
+ IF vndbid_type(nitemid) = 'r' THEN
+ PERFORM update_vncache(vid) FROM (
+ SELECT DISTINCT vid FROM releases_vn_hist WHERE chid IN(nchid, xoldchid)
+ ) AS v(vid);
+ END IF;
+
+ -- Call traits_chars_calc() for characters to update the traits cache
+ IF vndbid_type(nitemid) = 'c' THEN
+ PERFORM traits_chars_calc(nitemid);
+ 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(nitemid, c.rev, c.requester) n WHERE c.id = nchid;
+
+ -- 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
+ -- update_vnlist_rlist().
+ IF vndbid_type(nitemid) = 'r' AND xoldchid IS NOT NULL
+ THEN
+ INSERT INTO ulist_vns (uid, vid)
+ SELECT rl.uid, rv.vid FROM rlists rl JOIN releases_vn rv ON rv.id = rl.rid WHERE rl.rid = nitemid
+ ON CONFLICT (uid, vid) DO NOTHING;
+ END IF;
+
+ -- Call update_images_cache() where appropriate
+ IF vndbid_type(nitemid) = 'c'
+ THEN
+ PERFORM update_images_cache(image) FROM chars_hist WHERE chid IN(xoldchid,nchid) AND image IS NOT NULL;
+ END IF;
+ IF vndbid_type(nitemid) = 'v'
+ THEN
+ PERFORM update_images_cache(image) FROM vn_hist WHERE chid IN(xoldchid,nchid) AND image IS NOT NULL;
+ PERFORM update_images_cache(scr) FROM vn_screenshots_hist WHERE chid IN(xoldchid,nchid);
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+
+----------------------------------------------------------
+-- notification functions --
+----------------------------------------------------------
+
+
+-- 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 vndbid) RETURNS TABLE (uid vndbid, ntype notification_ntype[], iid vndbid, num int) AS $$
+ SELECT uid, array_agg(ntype), $1, $2
+ FROM (
+
+ -- pm
+ SELECT 'pm'::notification_ntype, u.id
+ FROM threads_boards tb
+ JOIN users u ON u.id = tb.iid
+ 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.itemid = $1 AND c_cur.rev = $2 -- Current edit
+ AND c_pre.itemid = $1 AND c_pre.rev = $2-1 -- Previous edit, to check if .ihid changed
+ AND c_all.itemid = $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', 'g', 'i')
+
+ -- 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 = $1 -- TODO: Could use an index on ulist_vns.vid
+ UNION ALL
+ SELECT uid FROM rlists WHERE vndbid_type($1) = 'r' AND rid = $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.itemid = $1 AND c_cur.rev = $2 -- Current edit
+ AND c_pre.itemid = $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.itemid = $1
+ AND $2 > 1 AND vndbid_type($1) IN('v', 'r', 'p', 'c', 's', 'd', 'g', 'i')
+ AND $3 <> 'u1' -- 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', 'g', 'i')
+ AND $3 <> 'u1' -- 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 = 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 tid FROM chars_traits_hist WHERE chid = (SELECT id FROM changes WHERE itemid = $1 AND rev = $2)),
+ old(tid) AS (SELECT tid FROM chars_traits_hist WHERE chid = (SELECT id FROM changes WHERE itemid = $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 <> 'u1' -- No announcements for Multi
+ GROUP BY uid;
+$$ LANGUAGE SQL;
+
+
+
+
+----------------------------------------------------------
+-- user management --
+----------------------------------------------------------
+-- XXX: These functions run with the permissions of the 'vndb' user.
+
+
+-- Returns the raw scrypt parameters (N, r, p and salt) for this user, in order
+-- to create an encrypted pass. Returns NULL if this user does not have a valid
+-- password.
+CREATE OR REPLACE FUNCTION user_getscryptargs(vndbid) RETURNS bytea AS $$
+ SELECT
+ CASE WHEN length(passwd) = 46 THEN substring(passwd from 1 for 14) ELSE NULL END
+ FROM users_shadow WHERE id = $1
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+-- Create a new session for this user (uid, type, scryptpass, token)
+CREATE OR REPLACE FUNCTION user_login(vndbid, session_type, bytea, bytea) RETURNS boolean AS $$
+ INSERT INTO sessions (uid, token, expires, type) SELECT $1, $4, NOW() + '1 month', $2 FROM users_shadow
+ WHERE length($3) = 46 AND length($4) = 20
+ AND id = $1 AND passwd = $3 AND $2 IN('web', 'api')
+ RETURNING true
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+CREATE OR REPLACE FUNCTION user_logout(vndbid, bytea) RETURNS void AS $$
+ DELETE FROM sessions WHERE uid = $1 AND token = $2 AND type IN('web','api')
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+-- BIG WARNING: Do not use "IS NOT NULL" on the return value, it'll always
+-- evaluate to false. Use 'IS DISTINCT FROM NULL' instead.
+CREATE OR REPLACE FUNCTION user_validate_session(vndbid, bytea, session_type) RETURNS sessions AS $$
+ -- Extends the expiration time of web and api sessions.
+ UPDATE sessions SET expires = NOW() + '1 month'
+ WHERE uid = $1 AND token = $2 AND type = $3 AND $3 IN('web', 'api')
+ AND expires < NOW() + '1 month'::interval - '6 hours'::interval;
+ -- Update last use date for api2 sessions
+ UPDATE sessions SET expires = NOW()
+ WHERE uid = $1 AND token = $2 AND type = $3 AND $3 = 'api2'
+ AND (expires = added OR expires::date < 'today'::date);
+ SELECT * FROM sessions WHERE uid = $1 AND token = $2 AND type = $3
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+-- Used for duplicate email checks and user-by-email lookup for usermods.
+CREATE OR REPLACE FUNCTION user_emailtoid(text) RETURNS TABLE (uid vndbid, mail text) AS $$
+ SELECT id, mail FROM users_shadow WHERE hash_email(mail) = hash_email($1)
+$$ LANGUAGE SQL SECURITY DEFINER ROWS 1;
+
+
+-- Store a password reset token. args: email, token. Returns: user id, actual email.
+-- Doesn't work for usermods, otherwise an attacker could use this function to
+-- gain access to all user's emails by obtaining a reset token of a usermod.
+-- Ideally Postgres itself would send the user an email so that the application
+-- calling this function doesn't even get the token, and thus can't get access
+-- to someone's account. But alas, that'd require a separate process.
+CREATE OR REPLACE FUNCTION user_resetpass(text, bytea, OUT vndbid, OUT text) AS $$
+ INSERT INTO sessions (uid, token, expires, type)
+ SELECT id, $2, NOW()+'1 week', 'pass' FROM users_shadow
+ WHERE hash_email(mail) = hash_email($1) AND length($2) = 20 AND NOT perm_usermod
+ RETURNING uid, mail
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+-- Changes the user's password and invalidates all existing sessions. args: uid, old_pass_or_reset_token, new_pass
+CREATE OR REPLACE FUNCTION user_setpass(vndbid, bytea, bytea) RETURNS boolean AS $$
+ WITH upd(id) AS (
+ UPDATE users_shadow SET passwd = $3
+ WHERE id = $1
+ AND length($3) = 46
+ AND ( (passwd = $2 AND length($2) = 46)
+ OR EXISTS(SELECT 1 FROM sessions WHERE uid = $1 AND token = $2 AND type = 'pass' AND expires > NOW())
+ )
+ RETURNING id
+ ), del AS( -- Not referenced, but still guaranteed to run
+ DELETE FROM sessions WHERE uid IN(SELECT id FROM upd) AND type <> 'api2'
+ )
+ SELECT true FROM upd
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+-- Internal function, used to verify whether user ($2 with session $3) is
+-- allowed to access sensitive data from user $1.
+CREATE OR REPLACE FUNCTION user_isauth(vndbid, vndbid, bytea) RETURNS boolean AS $$
+ SELECT true FROM users_shadow
+ WHERE id = $2
+ AND EXISTS(SELECT 1 FROM sessions WHERE uid = $2 AND token = $3 AND type = 'web')
+ AND ($2 IS NOT DISTINCT FROM $1 OR perm_usermod)
+$$ LANGUAGE SQL;
+
+
+-- uid of user email to get, uid currently logged in, session token of currently logged in.
+-- Ensures that only the user itself or a useradmin can get someone's email address.
+CREATE OR REPLACE FUNCTION user_getmail(vndbid, vndbid, bytea) RETURNS text AS $$
+ SELECT mail FROM users_shadow WHERE id = $1 AND user_isauth($1, $2, $3)
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+-- Set or unset delete_at for this user.
+-- Args: uid, web-token, delete?.
+CREATE OR REPLACE FUNCTION user_setdelete(vndbid, bytea, boolean) RETURNS void AS $$
+ UPDATE users_shadow
+ SET delete_at = CASE WHEN $3 THEN NOW() + '1 week'::interval ELSE NULL END
+ WHERE id = $1 AND user_isauth($1, $1, $2)
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+-- Set a token to change a user's email address.
+-- Args: uid, web-token, new-email-token, email
+CREATE OR REPLACE FUNCTION user_setmail_token(vndbid, bytea, bytea, text) RETURNS void AS $$
+ INSERT INTO sessions (uid, token, expires, type, mail)
+ SELECT id, $3, NOW()+'1 week', 'mail', $4 FROM users
+ WHERE id = $1 AND user_isauth($1, $1, $2) AND length($3) = 20
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+-- Actually change a user's email address, given a valid token.
+CREATE OR REPLACE FUNCTION user_setmail_confirm(vndbid, bytea) RETURNS boolean AS $$
+ WITH u(mail) AS (
+ DELETE FROM sessions WHERE uid = $1 AND token = $2 AND type = 'mail' AND expires > NOW() RETURNING mail
+ )
+ UPDATE users_shadow SET mail = (SELECT mail FROM u) WHERE id = $1 AND EXISTS(SELECT 1 FROM u) RETURNING true;
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+CREATE OR REPLACE FUNCTION user_setperm_usermod(vndbid, vndbid, bytea, boolean) RETURNS void AS $$
+ UPDATE users_shadow SET perm_usermod = $4 WHERE id = $1 AND user_isauth(NULL, $2, $3)
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+CREATE OR REPLACE FUNCTION user_admin_setpass(vndbid, vndbid, bytea, bytea) RETURNS void AS $$
+ WITH upd(id) AS (
+ UPDATE users_shadow SET passwd = $4 WHERE id = $1 AND user_isauth(NULL, $2, $3) AND length($4) = 46 RETURNING id
+ )
+ DELETE FROM sessions WHERE uid IN(SELECT id FROM upd)
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+CREATE OR REPLACE FUNCTION user_admin_setmail(vndbid, vndbid, bytea, text) RETURNS void AS $$
+ UPDATE users_shadow SET mail = $4 WHERE id = $1 AND user_isauth(NULL, $2, $3)
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+CREATE OR REPLACE FUNCTION user_api2_tokens(vndbid, vndbid, bytea) RETURNS SETOF sessions AS $$
+ SELECT * FROM sessions WHERE uid = $1 AND type = 'api2' AND user_isauth($1, $2, $3)
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+CREATE OR REPLACE FUNCTION user_api2_set_token(vndbid, vndbid, bytea, bytea, text, boolean, boolean) RETURNS void AS $$
+ INSERT INTO sessions (uid, type, expires, token, notes, listread, listwrite)
+ SELECT $1, 'api2', NOW(), $4, $5, $6, $7
+ WHERE user_isauth($1, $2, $3) AND length($4) = 20
+ ON CONFLICT (uid, token) DO UPDATE SET notes = $5, listread = $6, listwrite = $7
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+CREATE OR REPLACE FUNCTION user_api2_del_token(vndbid, vndbid, bytea, bytea) RETURNS void AS $$
+ DELETE FROM sessions WHERE uid = $1 AND token = $4 AND user_isauth($1, $2, $3)
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+CREATE OR REPLACE FUNCTION email_optout_check(text) RETURNS boolean AS $$
+ SELECT EXISTS(SELECT 1 FROM email_optout WHERE mail = hash_email($1))
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+-- Delete a user account.
+-- A 'hard' delete means that the row in the 'users' table is also deleted and
+-- any database contributions referring to this user will refer to NULL
+-- instead.
+-- A non-'hard' delete still deletes all account information but keeps the row
+-- in the users table, so that we are still able to audit their database
+-- contributions.
+-- 'hard' can be set to NULL to do a hard delete when the user has not made any
+-- relevant contributions and a soft delete otherwise.
+CREATE OR REPLACE FUNCTION user_delete(userid vndbid, hard boolean) RETURNS void AS $$
+BEGIN
+ -- References can be audited with: grep 'REFERENCES users' sql/tableattrs.sql
+ IF hard IS NULL THEN
+ SELECT INTO hard NOT (
+ EXISTS(SELECT 1 FROM changes WHERE userid = requester)
+ OR EXISTS(SELECT 1 FROM changes_patrolled WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM images WHERE userid = uploader)
+ OR EXISTS(SELECT 1 FROM image_votes WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM quotes WHERE userid = addedby)
+ OR EXISTS(SELECT 1 FROM reports_log WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM reviews WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM reviews_posts WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM tags_vn WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM threads_posts WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM vn_length_votes WHERE userid = uid));
+ END IF;
+ INSERT INTO email_optout (mail)
+ SELECT hash_email(mail) FROM users_shadow WHERE id = userid
+ ON CONFLICT (mail) DO NOTHING;
+ -- Account-related data.
+ -- (This is unnecessary for a hard delete due to the ON DELETE CASCADE
+ -- constraint actions, but we need this code anyway for the soft deletes)
+ DELETE FROM notification_subs WHERE uid = userid;
+ DELETE FROM notifications WHERE uid = userid;
+ DELETE FROM rlists WHERE uid = userid;
+ DELETE FROM saved_queries WHERE uid = userid;
+ DELETE FROM sessions WHERE uid = userid;
+ DELETE FROM ulist_labels WHERE uid = userid;
+ DELETE FROM ulist_vns WHERE uid = userid;
+ DELETE FROM users_prefs WHERE id = userid;
+ DELETE FROM users_prefs_tags WHERE id = userid;
+ DELETE FROM users_prefs_traits WHERE id = userid;
+ DELETE FROM users_shadow WHERE id = userid;
+ DELETE FROM users_traits WHERE id = userid;
+ DELETE FROM users_username_hist WHERE id = userid;
+ DELETE FROM vn_length_votes WHERE private AND uid = userid;
+ IF hard THEN
+ -- Delete votes that have been invalidated by a moderator, otherwise they will suddenly start counting again
+ DELETE FROM reviews_votes WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND ign_votes);
+ DELETE FROM threads_poll_votes WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND ign_votes);
+ DELETE FROM quotes_votes WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND ign_votes);
+ DELETE FROM tags_vn WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND perm_tag);
+ DELETE FROM vn_length_votes WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND perm_lengthvote);
+ DELETE FROM image_votes WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND perm_imgvote);
+ DELETE FROM users WHERE id = userid;
+ INSERT INTO audit_log (affected_uid, action) VALUES (userid, 'hard delete');
+ ELSE
+ UPDATE users SET
+ notify_dbedit = DEFAULT,
+ notify_announce = DEFAULT,
+ notify_post = DEFAULT,
+ notify_comment = DEFAULT,
+ nodistract_noads = DEFAULT,
+ nodistract_nofancy = DEFAULT,
+ support_enabled = DEFAULT,
+ pubskin_enabled = DEFAULT,
+ username = DEFAULT,
+ uniname = DEFAULT
+ WHERE id = userid;
+ INSERT INTO audit_log (affected_uid, action) VALUES (userid, 'soft delete');
+ END IF;
+END
+$$ LANGUAGE plpgsql;
+
+
+-- Should be called from a cron, deletes user accounts with a delete_at in the past.
+CREATE OR REPLACE FUNCTION user_delete() RETURNS int AS $$
+ SELECT COUNT(*) FROM (SELECT user_delete(id, null) FROM users_shadow WHERE delete_at < NOW()) x
+$$ LANGUAGE SQL SECURITY DEFINER;
diff --git a/util/sql/perms.sql b/sql/perms.sql
index b4833a60..a67442be 100644
--- a/util/sql/perms.sql
+++ b/sql/perms.sql
@@ -1,13 +1,23 @@
+-- vndb
+-- these are used by util/dbdump.pl
+
+GRANT EXECUTE ON FUNCTION pg_wal_replay_pause TO vndb;
+GRANT EXECUTE ON FUNCTION pg_wal_replay_resume TO vndb;
+
-- vndb_site
DROP OWNED BY vndb_site;
GRANT CONNECT, TEMP ON DATABASE :DBNAME TO vndb_site;
+GRANT USAGE ON SCHEMA public TO vndb_site;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO vndb_site;
-GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO vndb_site;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vndb_site;
GRANT SELECT, INSERT ON anime TO vndb_site;
+GRANT INSERT ON audit_log TO vndb_site;
GRANT SELECT, INSERT ON changes TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON changes_patrolled TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON chars TO vndb_site;
+GRANT SELECT ON charst TO vndb_site;
GRANT SELECT, INSERT ON chars_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON chars_traits TO vndb_site;
GRANT SELECT, INSERT ON chars_traits_hist TO vndb_site;
@@ -15,17 +25,29 @@ GRANT SELECT, INSERT, DELETE ON chars_vns TO vndb_site;
GRANT SELECT, INSERT ON chars_vns_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON docs TO vndb_site;
GRANT SELECT, INSERT ON docs_hist TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON drm TO vndb_site;
+GRANT SELECT , UPDATE ON global_settings 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 ON producerst TO vndb_site;
GRANT SELECT, INSERT ON producers_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON producers_relations TO vndb_site;
GRANT SELECT, INSERT ON producers_relations_hist TO vndb_site;
-GRANT SELECT ON quotes TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON quotes TO vndb_site;
+GRANT SELECT, INSERT ON quotes_log TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON quotes_votes TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON registration_throttle TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON releases TO vndb_site;
+GRANT SELECT ON releasest TO vndb_site;
GRANT SELECT, INSERT ON releases_hist TO vndb_site;
-GRANT SELECT, INSERT, DELETE ON releases_lang TO vndb_site;
-GRANT SELECT, INSERT ON releases_lang_hist TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON releases_drm TO vndb_site;
+GRANT SELECT, INSERT ON releases_drm_hist TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON releases_titles TO vndb_site;
+GRANT SELECT, INSERT ON releases_titles_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON releases_media TO vndb_site;
GRANT SELECT, INSERT ON releases_media_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON releases_platforms TO vndb_site;
@@ -34,47 +56,62 @@ GRANT SELECT, INSERT, DELETE ON releases_producers TO vndb_site;
GRANT SELECT, INSERT ON releases_producers_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON releases_vn TO vndb_site;
GRANT SELECT, INSERT ON releases_vn_hist TO vndb_site;
-GRANT SELECT ON relgraphs TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON reports TO vndb_site;
+GRANT SELECT, INSERT ON reports_log TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON reset_throttle TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON reviews TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON reviews_posts TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON reviews_votes TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON rlists TO vndb_site;
-GRANT SELECT, INSERT, UPDATE ON screenshots TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON saved_queries TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON search_cache TO vndb_site;
-- No access to the 'sessions' table, managed by the user_* functions.
GRANT SELECT ON shop_denpa TO vndb_site;
GRANT SELECT ON shop_dlsite TO vndb_site;
+GRANT SELECT ON shop_jastusa TO vndb_site;
GRANT SELECT ON shop_jlist TO vndb_site;
GRANT SELECT ON shop_mg TO vndb_site;
GRANT SELECT ON shop_playasia TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON staff TO vndb_site;
GRANT SELECT, INSERT, DELETE ON staff_alias TO vndb_site;
GRANT SELECT, INSERT ON staff_alias_hist TO vndb_site;
+GRANT SELECT ON staff_aliast TO vndb_site;
GRANT SELECT, INSERT ON staff_hist TO vndb_site;
GRANT SELECT, UPDATE ON stats_cache TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON tags TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON tags_aliases TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON tags_parents TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON tags TO vndb_site;
+GRANT SELECT, INSERT ON tags_hist TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON tags_parents TO vndb_site;
+GRANT SELECT, INSERT ON tags_parents_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn TO vndb_site;
-GRANT SELECT ON tags_vn_inherit TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_direct TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_inherit TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads_boards TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads_poll_options TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads_poll_votes TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads_posts TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON traits TO vndb_site;
+GRANT INSERT ON trace_log TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON traits TO vndb_site;
+GRANT SELECT, INSERT ON traits_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON traits_chars TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON traits_parents TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON traits_parents TO vndb_site;
+GRANT SELECT, INSERT ON traits_parents_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_labels TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns_labels TO vndb_site;
-
--- users table is special; The 'perm', 'passwd' and 'mail' columns are
--- protected and can only be accessed through the user_* functions.
-GRANT SELECT (id, username, registered, perm, c_votes, c_changes, ip, c_tags, ign_votes, email_confirmed, skin, customcss, filter_vn, filter_release, show_nsfw, hide_list, notify_dbedit, notify_announce, vn_list_own, vn_list_wish, tags_all, tags_cont, tags_ero, tags_tech, spoilers, traits_sexual, nodistract_can, nodistract_noads, nodistract_nofancy, support_can, support_enabled, uniname_can, uniname, pubskin_can, pubskin_enabled, c_vns, c_wish, ulist_votes, ulist_vnlist, ulist_wish),
- INSERT (id, username, mail, registered, c_votes, c_changes, ip, c_tags, ign_votes, email_confirmed, skin, customcss, filter_vn, filter_release, show_nsfw, hide_list, notify_dbedit, notify_announce, vn_list_own, vn_list_wish, tags_all, tags_cont, tags_ero, tags_tech, spoilers, traits_sexual, nodistract_can, nodistract_noads, nodistract_nofancy, support_can, support_enabled, uniname_can, uniname, pubskin_can, pubskin_enabled, c_vns, c_wish, ulist_votes, ulist_vnlist, ulist_wish),
- UPDATE ( username, registered, c_votes, c_changes, ip, c_tags, ign_votes, email_confirmed, skin, customcss, filter_vn, filter_release, show_nsfw, hide_list, notify_dbedit, notify_announce, vn_list_own, vn_list_wish, tags_all, tags_cont, tags_ero, tags_tech, spoilers, traits_sexual, nodistract_can, nodistract_noads, nodistract_nofancy, support_can, support_enabled, uniname_can, uniname, pubskin_can, pubskin_enabled, c_vns, c_wish, ulist_votes, ulist_vnlist, ulist_wish) ON users TO vndb_site;
-
+GRANT SELECT, INSERT, UPDATE ON users TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON users_prefs TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON users_prefs_tags TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON users_prefs_traits TO vndb_site;
+GRANT SELECT (id, perm_usermod, delete_at), INSERT (id, mail, ip) ON users_shadow TO vndb_site;
+GRANT SELECT, INSERT ON users_username_hist TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON users_traits TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON vn TO vndb_site;
GRANT SELECT, INSERT, DELETE ON vn_anime TO vndb_site;
GRANT SELECT, INSERT ON vn_anime_hist TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON vn_editions TO vndb_site;
+GRANT SELECT, INSERT ON vn_editions_hist TO vndb_site;
GRANT SELECT, INSERT ON vn_hist TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON vn_length_votes TO vndb_site;
GRANT SELECT, INSERT, DELETE ON vn_relations TO vndb_site;
GRANT SELECT, INSERT ON vn_relations_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON vn_screenshots TO vndb_site;
@@ -83,6 +120,9 @@ GRANT SELECT, INSERT, DELETE ON vn_seiyuu TO vndb_site;
GRANT SELECT, INSERT ON vn_seiyuu_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON vn_staff TO vndb_site;
GRANT SELECT, INSERT ON vn_staff_hist TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON vn_titles TO vndb_site;
+GRANT SELECT, INSERT ON vn_titles_hist TO vndb_site;
+GRANT SELECT ON vnt TO vndb_site;
GRANT SELECT, INSERT ON wikidata TO vndb_site;
@@ -93,37 +133,49 @@ GRANT SELECT, INSERT ON wikidata TO vndb_site;
DROP OWNED BY vndb_multi;
GRANT CONNECT, TEMP ON DATABASE :DBNAME TO vndb_multi;
+GRANT USAGE ON SCHEMA public TO vndb_multi;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO vndb_multi;
-GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO vndb_multi;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vndb_multi;
-GRANT SELECT, UPDATE ON anime TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE ON anime TO vndb_multi;
GRANT SELECT ON changes TO vndb_multi;
GRANT SELECT ON chars TO vndb_multi;
+GRANT SELECT ON charst TO vndb_multi;
GRANT SELECT ON chars_hist TO vndb_multi;
GRANT SELECT ON chars_traits TO vndb_multi;
GRANT SELECT ON chars_vns TO vndb_multi;
GRANT SELECT ON docs TO vndb_multi;
GRANT SELECT ON docs_hist TO vndb_multi;
+GRANT SELECT, UPDATE ON images TO vndb_multi;
+GRANT SELECT ON image_votes TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON login_throttle TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON notifications TO vndb_multi;
GRANT SELECT, UPDATE ON producers TO vndb_multi;
+GRANT SELECT ON producerst TO vndb_multi;
GRANT SELECT ON producers_hist TO vndb_multi;
GRANT SELECT ON producers_relations TO vndb_multi;
-GRANT SELECT ON quotes TO vndb_multi;
+GRANT SELECT, UPDATE ON quotes TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON registration_throttle TO vndb_multi;
GRANT SELECT ON releases TO vndb_multi;
+GRANT SELECT ON releasest TO vndb_multi;
GRANT SELECT ON releases_hist TO vndb_multi;
-GRANT SELECT ON releases_lang TO vndb_multi;
+GRANT SELECT ON releases_titles TO vndb_multi;
+GRANT SELECT ON releases_titles_hist TO vndb_multi;
GRANT SELECT ON releases_media TO vndb_multi;
GRANT SELECT ON releases_platforms TO vndb_multi;
GRANT SELECT ON releases_producers TO vndb_multi;
GRANT SELECT ON releases_vn TO vndb_multi;
-GRANT SELECT, INSERT, UPDATE, DELETE ON relgraphs TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON reset_throttle TO vndb_multi;
+GRANT SELECT, UPDATE ON reviews TO vndb_multi;
+GRANT SELECT ON reviews_posts TO vndb_multi;
+GRANT SELECT ON reviews_votes TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON rlists TO vndb_multi;
-GRANT SELECT ON screenshots TO vndb_multi;
-GRANT SELECT (expires) ON sessions TO vndb_multi;
+GRANT SELECT ON search_cache TO vndb_multi;
+GRANT SELECT (expires, type) ON sessions TO vndb_multi;
GRANT DELETE ON sessions TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_denpa TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_dlsite TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON shop_jastusa TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_jlist TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_mg TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_playasia TO vndb_multi;
@@ -131,34 +183,41 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON shop_playasia_gtin TO vndb_multi;
GRANT SELECT ON staff TO vndb_multi;
GRANT SELECT ON staff_alias TO vndb_multi;
GRANT SELECT ON staff_alias_hist TO vndb_multi;
+GRANT SELECT ON staff_aliast TO vndb_multi;
GRANT SELECT ON staff_hist TO vndb_multi;
GRANT SELECT, UPDATE ON stats_cache TO vndb_multi;
-GRANT SELECT ON tags TO vndb_multi;
-GRANT SELECT ON tags_aliases TO vndb_multi;
+GRANT SELECT, UPDATE ON tags TO vndb_multi;
+GRANT SELECT ON tags_hist TO vndb_multi;
GRANT SELECT ON tags_parents TO vndb_multi;
-GRANT SELECT ON tags_vn TO vndb_multi;
-GRANT SELECT ON tags_vn_inherit TO vndb_multi; -- tag_vn_calc() is SECURITY DEFINER due to index drop/create, so no extra perms needed here
+GRANT SELECT ON tags_parents_hist TO vndb_multi;
+GRANT SELECT, DELETE ON tags_vn TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_direct TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_inherit TO vndb_multi;
GRANT SELECT ON threads TO vndb_multi;
GRANT SELECT ON threads_boards TO vndb_multi;
GRANT SELECT ON threads_posts TO vndb_multi;
GRANT SELECT, UPDATE ON traits TO vndb_multi;
-GRANT SELECT ON traits_chars TO vndb_multi; -- traits_chars_calc() is SECURITY DEFINER
+GRANT SELECT ON traits_hist TO vndb_multi;
+GRANT SELECT ON traits_chars TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON traits_chars TO vndb_multi;
GRANT SELECT ON traits_parents TO vndb_multi;
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, c_votes, c_changes, c_tags, c_vns, c_wish, ign_votes, email_confirmed, hide_list, notify_dbedit, notify_announce),
- UPDATE ( c_votes, c_changes, c_tags, c_vns, c_wish) ON users TO vndb_multi;
-GRANT DELETE ON users TO vndb_multi;
-
+GRANT SELECT, UPDATE, DELETE ON users TO vndb_multi;
+GRANT SELECT, UPDATE, DELETE ON users_prefs TO vndb_multi;
+GRANT SELECT (id, delete_at), DELETE ON users_shadow TO vndb_multi;
+GRANT SELECT, DELETE ON users_username_hist TO vndb_multi;
GRANT SELECT, UPDATE ON vn TO vndb_multi;
GRANT SELECT ON vn_anime TO vndb_multi;
GRANT SELECT ON vn_hist TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON vn_length_votes TO vndb_multi;
GRANT SELECT ON vn_relations TO vndb_multi;
GRANT SELECT ON vn_screenshots TO vndb_multi;
GRANT SELECT ON vn_screenshots_hist TO vndb_multi;
GRANT SELECT ON vn_seiyuu TO vndb_multi;
GRANT SELECT ON vn_staff TO vndb_multi;
GRANT SELECT ON vn_staff_hist TO vndb_multi;
+GRANT SELECT ON vn_titles TO vndb_multi;
+GRANT SELECT ON vn_titles_hist TO vndb_multi;
+GRANT SELECT ON vnt TO vndb_multi;
GRANT SELECT, INSERT, UPDATE ON wikidata TO vndb_multi;
diff --git a/sql/rebuild-search-cache.sql b/sql/rebuild-search-cache.sql
new file mode 100644
index 00000000..9ab8f49d
--- /dev/null
+++ b/sql/rebuild-search-cache.sql
@@ -0,0 +1,60 @@
+-- This is a maintenance script to update all rows in search_cache.
+-- It should be run whenever the search normalization functions in func.sql are updated.
+
+-- This script is intentionally slow and performs the updates in smaller
+-- batches in order to avoid long-held locks, which may otherwise cause the
+-- site to become unresponsive. It also tries to avoid table bloat by only
+-- updating rows that need to be updated.
+
+DO $$
+DECLARE
+ rows_per_transaction CONSTANT integer := 1000;
+ sleep_seconds CONSTANT float := 1;
+ i integer;
+BEGIN
+ -- chars
+ FOR i IN SELECT n FROM generate_series(0, (SELECT MAX(vndbid_num(id)) FROM chars), rows_per_transaction) x(n)
+ LOOP
+ PERFORM update_search(vndbid('c', x)) FROM generate_series(i+1, i+rows_per_transaction) x(x);
+ COMMIT;
+ PERFORM pg_sleep(sleep_seconds);
+ END LOOP;
+
+ -- producers
+ FOR i IN SELECT n FROM generate_series(0, (SELECT MAX(vndbid_num(id)) FROM producers), rows_per_transaction) x(n)
+ LOOP
+ PERFORM update_search(vndbid('p', x)) FROM generate_series(i+1, i+rows_per_transaction) x(x);
+ COMMIT;
+ PERFORM pg_sleep(sleep_seconds);
+ END LOOP;
+
+ -- vn
+ FOR i IN SELECT n FROM generate_series(0, (SELECT MAX(vndbid_num(id)) FROM vn), rows_per_transaction) x(n)
+ LOOP
+ PERFORM update_search(vndbid('v', x)) FROM generate_series(i+1, i+rows_per_transaction) x(x);
+ COMMIT;
+ PERFORM pg_sleep(sleep_seconds);
+ END LOOP;
+
+ -- releases
+ FOR i IN SELECT n FROM generate_series(0, (SELECT MAX(vndbid_num(id)) FROM releases), rows_per_transaction) x(n)
+ LOOP
+ PERFORM update_search(vndbid('r', x)) FROM generate_series(i+1, i+rows_per_transaction) x(x);
+ COMMIT;
+ PERFORM pg_sleep(sleep_seconds);
+ END LOOP;
+
+ -- staff
+ FOR i IN SELECT n FROM generate_series(0, (SELECT MAX(vndbid_num(id)) FROM staff), rows_per_transaction) x(n)
+ LOOP
+ PERFORM update_search(vndbid('s', x)) FROM generate_series(i+1, i+rows_per_transaction) x(x);
+ COMMIT;
+ PERFORM pg_sleep(sleep_seconds);
+ END LOOP;
+END$$;
+
+-- These tables are small enough
+SELECT count(*) FROM (SELECT update_search(id) FROM tags) x;
+SELECT count(*) FROM (SELECT update_search(id) FROM traits) x;
+
+ANALYZE search_cache;
diff --git a/sql/schema.sql b/sql/schema.sql
new file mode 100644
index 00000000..beb0b3dd
--- /dev/null
+++ b/sql/schema.sql
@@ -0,0 +1,1641 @@
+-- Convention for database items with version control:
+--
+-- CREATE TABLE items ( -- dbentry_type=x
+-- id vndbid NOT NULL PRIMARY KEY ..,
+-- locked boolean NOT NULL DEFAULT FALSE,
+-- hidden boolean NOT NULL DEFAULT FALSE,
+-- -- item-specific columns here
+-- );
+-- CREATE TABLE items_hist ( -- History of the 'items' table
+-- chid integer NOT NULL, -- references changes.id
+-- -- item-specific columns here
+-- );
+--
+-- The '-- dbentry_type=x' comment is required, and is used by sqleditfunc.pl
+-- to generate the correct editing functions. It's possible for 'items' to have
+-- more item-specific columns than 'items_hist'. Some columns are caches or
+-- otherwise autogenerated, and do not need to be versioned.
+--
+-- The (hidden,locked) columns indicate the item's state:
+-- !hidden && !locked -> Normal
+-- !hidden && locked -> Locked
+-- hidden && !locked -> Awaiting approval (tags/traits only)
+-- hidden && locked -> Deleted
+-- The history of these flags is recorded as (ihid,ilock) in the changes table.
+-- (Yes, the state is better represented as an ENUM, but this way it's easier
+-- to filter out 'hidden' items in listings)
+--
+-- item-related tables work roughly the same:
+--
+-- CREATE TABLE items_field (
+-- id vndbid, -- references items.id
+-- -- field-specific columns here
+-- );
+-- CREATE TABLE items_field_hist ( -- History of the 'items_field' table
+-- chid integer, -- references changes.id
+-- -- field-specific columns here
+-- );
+--
+-- The changes and *_hist tables contain all the data. In a sense, the other
+-- tables related to the item are just a cache/view into the latest versions.
+-- All modifications to the item tables has to go through the edit_* functions
+-- in editfunc.sql, these are also responsible for keeping things synchronized.
+--
+-- Columns marked with a '[pub]' comment on the same line are included in the
+-- public database dump. Be aware that not all properties of the to-be-dumped
+-- data are annotated in this file. Which tables and which rows are exported is
+-- defined in util/dbdump.pl.
+--
+-- Comments on CREATE TABLE and column lines for '[pub]' items are included in
+-- the public database dump import.sql in the form of "COMMENT ON" commands.
+--
+-- Columns in tables are generally ordered for efficient storage: larger
+-- fixed-sized columns go before smaller fixed-sized columns, variable-length
+-- columns go at the end. When a new column is added to a table, it should
+-- always be added at the end because that's the only thing Postgres supports.
+-- Once in a while I re-order all newly added columns for efficiency and that
+-- requires a full dump and re-import of the database using
+-- util/dbdump.pl export-data
+-- to take effect. That's why I typically plan these at the same time that I'm
+-- upgrading to a new major Postgres version, after all, a dump-and-import is a
+-- good upgrade strategy.
+-- Code should not depend on column order!
+--
+-- Note: Every CREATE TABLE clause and each column should be on a separate
+-- line. This file is parsed by lib/VNDB/Schema.pm and it doesn't implement a
+-- full SQL query parser.
+
+
+-- data types
+
+CREATE TYPE anime_type AS ENUM ('tv', 'ova', 'mov', 'oth', 'web', 'spe', 'mv');
+CREATE TYPE blood_type AS ENUM ('unknown', 'a', 'b', 'ab', 'o');
+CREATE TYPE board_type AS ENUM ('an', 'db', 'ge', 'v', 'p', 'u');
+CREATE TYPE char_role AS ENUM ('main', 'primary', 'side', 'appears');
+CREATE TYPE credit_type AS ENUM ('scenario', 'chardesign', 'art', 'music', 'songs', 'director', 'translator', 'editor', 'qa', 'staff');
+CREATE TYPE cup_size AS ENUM ('', 'AAA', 'AA', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z');
+CREATE TYPE dbentry_type AS ENUM ('v', 'r', 'p', 'c', 's', 'd');
+CREATE TYPE gender AS ENUM ('unknown', 'm', 'f', 'b');
+CREATE TYPE language AS ENUM ('ar', 'be', 'bg', 'ca', 'cs', 'ck', 'da', 'de', 'el', 'en', 'eo', 'es', 'eu', 'fa', 'fi', 'fr', 'ga', 'gd', 'he', 'hi', 'hr', 'hu', 'id', 'it', 'iu', 'ja', 'ko', 'mk', 'ms', 'la', 'lt', 'lv', 'nl', 'no', 'pl', 'pt-pt', 'pt-br', 'ro', 'ru', 'sk', 'sl', 'sr', 'sv', 'ta', 'th', 'tr', 'uk', 'ur', 'vi', 'zh', 'zh-Hans', 'zh-Hant');
+CREATE TYPE medium AS ENUM ('cd', 'dvd', 'gdr', 'blr', 'flp', 'cas', 'mrt', 'mem', 'umd', 'nod', 'in', 'otc');
+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', 'fm7', 'fm8', 'fmt', 'gba', 'gbc', 'msx', 'nds', 'nes', 'p88', 'p98', 'pce', 'pcf', 'psp', 'ps1', 'ps2', 'ps3', 'ps4', 'ps5', 'psv', 'drc', 'smd', 'scd', 'sat', 'sfc', 'swi', 'wii', 'wiu', 'n3d', 'vnd', 'x1s', 'x68', 'xb1', 'xb3', 'xbo', 'xxs', 'web', 'tdo', 'mob', 'oth');
+CREATE TYPE producer_type AS ENUM ('co', 'in', 'ng');
+CREATE TYPE producer_relation AS ENUM ('old', 'new', 'sub', 'par', 'imp', 'ipa', 'spa', 'ori');
+CREATE TYPE release_type AS ENUM ('complete', 'partial', 'trial');
+CREATE TYPE report_status AS ENUM ('new', 'busy', 'done', 'dismissed');
+CREATE TYPE tag_category AS ENUM('cont', 'ero', 'tech');
+CREATE TYPE vn_relation AS ENUM ('seq', 'preq', 'set', 'alt', 'char', 'side', 'par', 'ser', 'fan', 'orig');
+CREATE TYPE session_type AS ENUM ('web', 'pass', 'mail', 'api', 'api2');
+
+CREATE TYPE ipinfo AS (
+ ip inet,
+ country text,
+ asn integer,
+ as_name text,
+ anonymous_proxy boolean,
+ sattelite_provider boolean,
+ anycast boolean,
+ drop boolean
+);
+
+
+CREATE TYPE item_info_type AS (title text[], uid vndbid, hidden boolean, locked boolean);
+
+CREATE TYPE titleprefs AS (
+ -- NULL langs means unused slot
+ t1_lang language,
+ t2_lang language,
+ t3_lang language,
+ t4_lang language,
+ a1_lang language,
+ a2_lang language,
+ a3_lang language,
+ a4_lang language,
+ -- These should never be NULL
+ t1_latin boolean,
+ t2_latin boolean,
+ t3_latin boolean,
+ t4_latin boolean,
+ to_latin boolean, -- Original language fallback
+ a1_latin boolean,
+ a2_latin boolean,
+ a3_latin boolean,
+ a4_latin boolean,
+ ao_latin boolean,
+ -- These have three possible options:
+ -- * NULL: Only if lang == original, i.e. skip this slot if it's not the original language
+ -- * true: Only if official
+ -- * false: Use this language regardless of official/original status
+ t1_official boolean,
+ t2_official boolean,
+ t3_official boolean,
+ t4_official boolean,
+ a1_official boolean,
+ a2_official boolean,
+ a3_official boolean,
+ a4_official boolean
+);
+
+
+-- Animation types & frequency encoded as bitflags in a smallint.
+-- Bitflags suck balls, but the alternatives suck too.
+-- Special values:
+-- NULL Animation information not known
+-- 0 No animation
+-- 1 Animation type does not apply (e.g. VN has no sprites)
+-- Otherwise, bit flags:
+-- 4 type = handrawn
+-- 8 type = vectorial
+-- 16 type = 3d
+-- 32 type = live
+-- 256 frequency = some scenes
+-- 512 frequency = all scenes
+-- At least one of the 'type' flags must be set.
+-- If none of the frequency flags are set -> frequency = unknown.
+CREATE DOMAIN animation AS smallint CHECK(value IS NULL OR value IN(0,1) OR ((value & (4+8+16+32)) > 0 AND (value & (256+512)) <> (256+512)));
+
+
+-- Sequences used for ID generation
+CREATE SEQUENCE charimg_seq;
+CREATE SEQUENCE chars_id_seq;
+CREATE SEQUENCE covers_seq;
+CREATE SEQUENCE docs_id_seq;
+CREATE SEQUENCE producers_id_seq;
+CREATE SEQUENCE releases_id_seq;
+CREATE SEQUENCE reviews_seq;
+CREATE SEQUENCE screenshots_seq;
+CREATE SEQUENCE staff_id_seq;
+CREATE SEQUENCE tags_id_seq;
+CREATE SEQUENCE traits_id_seq;
+CREATE SEQUENCE threads_id_seq;
+CREATE SEQUENCE vn_id_seq;
+CREATE SEQUENCE users_id_seq;
+
+
+-- anime
+CREATE TABLE anime ( -- Anime information fetched from AniDB, only used for linking with visual novels.
+ id integer NOT NULL PRIMARY KEY, -- [pub] AniDB identifier
+ ann_id integer, -- [pub] Anime News Network identifier
+ lastfetch timestamptz,
+ type anime_type, -- [pub]
+ year smallint, -- [pub]
+ nfo_id varchar(200), -- [pub] AnimeNFO identifier (unused, site is long dead)
+ title_romaji varchar(250), -- [pub]
+ title_kanji varchar(250) -- [pub]
+);
+
+-- audit_log
+CREATE TABLE audit_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ by_uid vndbid,
+ affected_uid vndbid,
+ by_ip ipinfo,
+ by_name text,
+ affected_name text,
+ action text NOT NULL,
+ detail text
+);
+
+-- changes
+CREATE TABLE changes (
+ id SERIAL PRIMARY KEY,
+ requester vndbid,
+ added timestamptz NOT NULL DEFAULT NOW(),
+ itemid vndbid NOT NULL,
+ rev integer NOT NULL DEFAULT 1,
+ ihid boolean NOT NULL DEFAULT FALSE,
+ ilock boolean NOT NULL DEFAULT FALSE,
+ comments text NOT NULL DEFAULT ''
+);
+
+-- changes_patrolled
+CREATE TABLE changes_patrolled (
+ id integer NOT NULL,
+ uid vndbid NOT NULL,
+ PRIMARY KEY(id,uid)
+);
+
+-- chars
+CREATE TABLE chars ( -- dbentry_type=c
+ id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('c', nextval('chars_id_seq')::int) CONSTRAINT chars_id_check CHECK(vndbid_type(id) = 'c'), -- [pub]
+ image vndbid CONSTRAINT chars_image_check CHECK(vndbid_type(image) = 'ch'), -- [pub]
+ gender gender NOT NULL DEFAULT 'unknown', -- [pub] Character's sex, not gender
+ spoil_gender gender, -- [pub] Character's actual sex, in case it's a spoiler
+ bloodt blood_type NOT NULL DEFAULT 'unknown', -- [pub] Blood type
+ cup_size cup_size NOT NULL DEFAULT '', -- [pub]
+ main vndbid, -- [pub] When this character is an instance of another character
+ s_bust smallint NOT NULL DEFAULT 0, -- [pub] cm
+ s_waist smallint NOT NULL DEFAULT 0, -- [pub] cm
+ s_hip smallint NOT NULL DEFAULT 0, -- [pub] cm
+ b_month smallint NOT NULL DEFAULT 0, -- [pub] Birthday month, 1-12
+ b_day smallint NOT NULL DEFAULT 0, -- [pub] Birthday day, 1-32
+ height smallint NOT NULL DEFAULT 0, -- [pub] cm
+ weight smallint, -- [pub] kg
+ main_spoil smallint NOT NULL DEFAULT 0, -- [pub]
+ age smallint, -- [pub] years
+ locked boolean NOT NULL DEFAULT FALSE,
+ hidden boolean NOT NULL DEFAULT FALSE,
+ name varchar(250) NOT NULL DEFAULT '', -- [pub]
+ latin varchar(250), -- [pub]
+ alias varchar(500) NOT NULL DEFAULT '', -- [pub]
+ description text NOT NULL DEFAULT '', -- [pub]
+ c_lang language NOT NULL DEFAULT 'ja'
+);
+
+-- chars_hist
+CREATE TABLE chars_hist (
+ chid integer NOT NULL PRIMARY KEY,
+ image vndbid CONSTRAINT chars_hist_image_check CHECK(vndbid_type(image) = 'ch'),
+ gender gender NOT NULL DEFAULT 'unknown',
+ spoil_gender gender,
+ bloodt blood_type NOT NULL DEFAULT 'unknown',
+ cup_size cup_size NOT NULL DEFAULT '',
+ main vndbid, -- chars.id
+ s_bust smallint NOT NULL DEFAULT 0,
+ s_waist smallint NOT NULL DEFAULT 0,
+ s_hip smallint NOT NULL DEFAULT 0,
+ b_month smallint NOT NULL DEFAULT 0,
+ b_day smallint NOT NULL DEFAULT 0,
+ height smallint NOT NULL DEFAULT 0,
+ weight smallint,
+ main_spoil smallint NOT NULL DEFAULT 0,
+ age smallint,
+ name varchar(250) NOT NULL DEFAULT '',
+ latin varchar(250),
+ alias varchar(500) NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT ''
+);
+
+-- chars_traits
+CREATE TABLE chars_traits (
+ id vndbid NOT NULL, -- [pub]
+ tid vndbid NOT NULL, -- [pub]
+ spoil smallint NOT NULL DEFAULT 0, -- [pub]
+ lie boolean NOT NULL DEFAULT false, -- [pub]
+ PRIMARY KEY(id, tid)
+);
+
+-- chars_traits_hist
+CREATE TABLE chars_traits_hist (
+ chid integer NOT NULL,
+ tid vndbid NOT NULL, -- traits.id
+ spoil smallint NOT NULL DEFAULT 0,
+ lie boolean NOT NULL DEFAULT false,
+ PRIMARY KEY(chid, tid)
+);
+
+-- chars_vns
+CREATE TABLE chars_vns (
+ id vndbid NOT NULL, -- [pub]
+ vid vndbid NOT NULL, -- [pub]
+ rid vndbid NULL, -- [pub]
+ role char_role NOT NULL DEFAULT 'main', -- [pub]
+ spoil smallint NOT NULL DEFAULT 0 -- [pub]
+);
+
+-- chars_vns_hist
+CREATE TABLE chars_vns_hist (
+ chid integer NOT NULL,
+ vid vndbid NOT NULL, -- vn.id
+ rid vndbid NULL, -- releases.id
+ role char_role NOT NULL DEFAULT 'main',
+ spoil smallint NOT NULL DEFAULT 0
+);
+
+-- docs
+CREATE TABLE docs ( -- dbentry_type=d
+ id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('d', nextval('docs_id_seq')::int) CONSTRAINT docs_id_check CHECK(vndbid_type(id) = 'd') , -- [pub]
+ locked boolean NOT NULL DEFAULT FALSE,
+ hidden boolean NOT NULL DEFAULT FALSE,
+ title varchar(200) NOT NULL DEFAULT '', -- [pub]
+ content text NOT NULL DEFAULT '', -- [pub] In MultiMarkdown format
+ html text -- cache, can be manually updated with util/update-docs-html-cache.pl
+);
+
+-- docs_hist
+CREATE TABLE docs_hist (
+ chid integer NOT NULL PRIMARY KEY,
+ title varchar(200) NOT NULL DEFAULT '',
+ content text NOT NULL DEFAULT '',
+ html text -- cache
+);
+
+-- drm
+CREATE TABLE drm ( -- DRM types, for use with release info
+ id serial PRIMARY KEY, -- [pub]
+ c_ref integer NOT NULL DEFAULT 0, -- [pub] How many release entries use this DRM type
+ state smallint NOT NULL DEFAULT 0, -- 0 = new, 1 = approved, 2 = deleted
+ disc boolean NOT NULL, -- [pub]
+ cdkey boolean NOT NULL, -- [pub]
+ activate boolean NOT NULL, -- [pub]
+ alimit boolean NOT NULL, -- [pub]
+ account boolean NOT NULL, -- [pub]
+ online boolean NOT NULL, -- [pub]
+ cloud boolean NOT NULL, -- [pub]
+ physical boolean NOT NULL, -- [pub]
+ name text NOT NULL, -- [pub]
+ description text NOT NULL -- [pub]
+);
+
+-- email_optout
+CREATE TABLE email_optout (
+ mail uuid, -- hash_email()
+ date timestamptz NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (mail)
+);
+
+-- global_settings
+CREATE TABLE global_settings (
+ -- Only permit a single row in this table
+ id boolean NOT NULL PRIMARY KEY DEFAULT FALSE CONSTRAINT global_settings_single_row CHECK(id),
+ -- locks down any DB edits, including image voting and tagging
+ lockdown_edit boolean NOT NULL DEFAULT FALSE,
+ -- locks down any forum & review posting
+ lockdown_board boolean NOT NULL DEFAULT FALSE,
+ lockdown_registration boolean NOT NULL DEFAULT FALSE
+);
+
+-- images
+CREATE TABLE images (
+ id vndbid NOT NULL PRIMARY KEY CONSTRAINT images_id_check CHECK(vndbid_type(id) IN('ch', 'cv', 'sf')), -- [pub]
+ width smallint NOT NULL, -- [pub] px
+ height smallint NOT NULL, -- [pub] px
+ -- cached columns are marked [pub] for easy querying
+ c_votecount smallint NOT NULL DEFAULT 0, -- [pub]
+ c_sexual_avg smallint NOT NULL DEFAULT 200, -- [pub] 0 - 200, so average vote * 100
+ c_sexual_stddev smallint NOT NULL DEFAULT 0, -- [pub]
+ c_violence_avg smallint NOT NULL DEFAULT 200, -- [pub] 0 - 200
+ c_violence_stddev smallint NOT NULL DEFAULT 0, -- [pub]
+ c_weight smallint NOT NULL DEFAULT 0, -- [pub] Random selection weight for the image flagging UI
+ c_uids vndbid[] NOT NULL DEFAULT '{}',
+ uploader vndbid
+ -- (technically, c_votecount is redundant as it can be easily derived from
+ -- c_uids, but otherwise we'd lose the space to padding anyway)
+);
+
+-- image_votes
+CREATE TABLE image_votes (
+ id vndbid NOT NULL, -- [pub]
+ uid vndbid, -- [pub]
+ date timestamptz NOT NULL DEFAULT NOW(),-- [pub]
+ sexual smallint NOT NULL CHECK(sexual >= 0 AND sexual <= 2), -- [pub] 0 = safe, 1 = suggestive, 2 = explicit
+ violence smallint NOT NULL CHECK(violence >= 0 AND violence <= 2), -- [pub] 0 = tame, 1 = violent, 2 = brutal
+ ignore boolean NOT NULL DEFAULT false -- [pub] Set when overruled by a moderator
+);
+
+-- login_throttle
+CREATE TABLE login_throttle (
+ ip inet NOT NULL PRIMARY KEY,
+ timeout timestamptz NOT NULL
+);
+
+-- notification_subs
+CREATE TABLE notification_subs (
+ uid vndbid 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 vndbid NOT NULL,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ read timestamptz,
+ iid vndbid NOT NULL,
+ num integer,
+ ntype notification_ntype[] NOT NULL
+);
+
+-- producers
+CREATE TABLE producers ( -- dbentry_type=p
+ id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('p', nextval('producers_id_seq')::int) CONSTRAINT producers_id_check CHECK(vndbid_type(id) = 'p'), -- [pub]
+ type producer_type NOT NULL DEFAULT 'co', -- [pub]
+ lang language NOT NULL DEFAULT 'ja', -- [pub]
+ l_wikidata integer, -- [pub]
+ locked boolean NOT NULL DEFAULT FALSE,
+ hidden boolean NOT NULL DEFAULT FALSE,
+ name varchar(200) NOT NULL DEFAULT '', -- [pub]
+ latin varchar(200), -- [pub]
+ alias varchar(500) NOT NULL DEFAULT '', -- [pub]
+ website varchar(1024) NOT NULL DEFAULT '', -- [pub]
+ description text NOT NULL DEFAULT '', -- [pub]
+ l_wp varchar(150) -- (deprecated)
+);
+
+-- producers_hist
+CREATE TABLE producers_hist (
+ chid integer NOT NULL PRIMARY KEY,
+ type producer_type NOT NULL DEFAULT 'co',
+ lang language NOT NULL DEFAULT 'ja',
+ l_wikidata integer,
+ name varchar(200) NOT NULL DEFAULT '',
+ latin varchar(200),
+ alias varchar(500) NOT NULL DEFAULT '',
+ website varchar(1024) NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT '',
+ l_wp varchar(150)
+);
+
+-- producers_relations
+CREATE TABLE producers_relations (
+ id vndbid NOT NULL, -- [pub]
+ pid vndbid NOT NULL, -- [pub]
+ relation producer_relation NOT NULL, -- [pub]
+ PRIMARY KEY(id, pid)
+);
+
+-- producers_relations_hist
+CREATE TABLE producers_relations_hist (
+ chid integer NOT NULL,
+ pid vndbid NOT NULL, -- producers.id
+ relation producer_relation NOT NULL,
+ PRIMARY KEY(chid, pid)
+);
+
+-- quotes
+CREATE TABLE quotes (
+ id serial PRIMARY KEY, -- [pub]
+ vid vndbid NOT NULL, -- [pub]
+ cid vndbid, -- [pub]
+ addedby vndbid,
+ rand real,
+ score smallint NOT NULL DEFAULT 0, -- [pub]
+ quote text NOT NULL, -- [pub]
+ hidden boolean NOT NULL DEFAULT FALSE,
+ added timestamptz NOT NULL DEFAULT NOW()
+);
+
+-- quotes_log
+CREATE TABLE quotes_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ uid vndbid,
+ action text NOT NULL
+);
+
+-- quotes_votes
+CREATE TABLE quotes_votes (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ uid vndbid NOT NULL,
+ vote smallint NOT NULL,
+ PRIMARY KEY(id, uid)
+);
+
+-- registration_throttle
+CREATE TABLE registration_throttle (
+ ip inet NOT NULL PRIMARY KEY,
+ timeout timestamptz NOT NULL
+);
+
+-- releases
+CREATE TABLE releases ( -- dbentry_type=r
+ id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('r', nextval('releases_id_seq')::int) CONSTRAINT releases_id_check CHECK(vndbid_type(id) = 'r'), -- [pub]
+ olang language NOT NULL DEFAULT 'ja', -- [pub] Refers to the main title to use for display purposes, not necessarily the original language.
+ gtin bigint NOT NULL DEFAULT 0, -- [pub] JAN/UPC/EAN/ISBN
+ l_toranoana bigint NOT NULL DEFAULT 0, -- [pub]
+ l_appstore bigint NOT NULL DEFAULT 0, -- [pub]
+ l_nintendo_jp bigint NOT NULL DEFAULT 0, -- [pub]
+ l_nintendo_hk bigint NOT NULL DEFAULT 0, -- [pub]
+ released integer NOT NULL DEFAULT 0, -- [pub]
+ l_steam integer NOT NULL DEFAULT 0, -- [pub]
+ l_digiket integer NOT NULL DEFAULT 0, -- [pub]
+ l_melon integer NOT NULL DEFAULT 0, -- [pub]
+ l_mg integer NOT NULL DEFAULT 0, -- [pub]
+ l_getchu integer NOT NULL DEFAULT 0, -- [pub]
+ l_getchudl integer NOT NULL DEFAULT 0, -- [pub]
+ l_egs integer NOT NULL DEFAULT 0, -- [pub]
+ l_erotrail integer NOT NULL DEFAULT 0, -- [pub] (deprecated, site hasn't been reachable for a while)
+ l_melonjp integer NOT NULL DEFAULT 0, -- [pub]
+ l_gamejolt integer NOT NULL DEFAULT 0, -- [pub]
+ l_animateg integer NOT NULL DEFAULT 0, -- [pub]
+ l_freem integer NOT NULL DEFAULT 0, -- [pub]
+ l_novelgam integer NOT NULL DEFAULT 0, -- [pub]
+ voiced smallint NOT NULL DEFAULT 0, -- [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]
+ minage smallint, -- [pub] Age rating, 0 - 18
+ ani_story smallint NOT NULL DEFAULT 0, -- [pub] (old, superseded by the newer ani_* columns)
+ ani_ero smallint NOT NULL DEFAULT 0, -- [pub] (^ but the newer columns haven't been filled out much)
+ -- These replace the old ani_story and ani_ero columns.
+ ani_story_sp animation, -- [pub] Story sprite animation
+ ani_story_cg animation, -- [pub] Story CG animation
+ -- "Not animated" and frequency options are irrelevant for ani_cutscene.
+ ani_cutscene animation CONSTRAINT releases_cutscene_check CHECK(ani_cutscene <> 0 AND (ani_cutscene & (256+512)) = 0), -- [pub] Cutscene animation
+ ani_ero_sp animation, -- [pub] Ero scene sprite animation
+ ani_ero_cg animation, -- [pub] Ero scene CG animation
+ ani_bg boolean, -- [pub] Background effects
+ ani_face boolean, -- [pub] Eye blink / lip sync
+ has_ero boolean NOT NULL DEFAULT FALSE, -- [pub]
+ patch boolean NOT NULL DEFAULT FALSE, -- [pub]
+ freeware boolean NOT NULL DEFAULT FALSE, -- [pub]
+ doujin boolean NOT NULL DEFAULT FALSE,
+ uncensored boolean, -- [pub]
+ official boolean NOT NULL DEFAULT TRUE, -- [pub]
+ locked boolean NOT NULL DEFAULT FALSE,
+ hidden boolean NOT NULL DEFAULT FALSE,
+ website varchar(1024) NOT NULL DEFAULT '', -- [pub]
+ catalog varchar(50) NOT NULL DEFAULT '', -- [pub]
+ engine varchar(50) NOT NULL DEFAULT '', -- [pub]
+ notes text NOT NULL DEFAULT '', -- [pub]
+ l_dlsite text NOT NULL DEFAULT '', -- [pub]
+ l_dlsiteen text NOT NULL DEFAULT '', -- (deprecated, DLsite doesn't have a separate English shop anymore)
+ l_gog text NOT NULL DEFAULT '', -- [pub]
+ l_denpa text NOT NULL DEFAULT '', -- [pub]
+ l_jlist text NOT NULL DEFAULT '', -- [pub]
+ l_jastusa text NOT NULL DEFAULT '', -- [pub]
+ l_itch text NOT NULL DEFAULT '', -- [pub]
+ l_nutaku text NOT NULL DEFAULT '', -- [pub]
+ l_googplay text NOT NULL DEFAULT '', -- [pub]
+ l_fakku text NOT NULL DEFAULT '', -- [pub]
+ l_freegame text NOT NULL DEFAULT '', -- [pub]
+ l_playstation_jp text NOT NULL DEFAULT '', -- [pub]
+ l_playstation_na text NOT NULL DEFAULT '', -- [pub]
+ l_playstation_eu text NOT NULL DEFAULT '', -- [pub]
+ l_playstation_hk text NOT NULL DEFAULT '', -- [pub]
+ l_nintendo text NOT NULL DEFAULT '', -- [pub]
+ l_gyutto integer[] NOT NULL DEFAULT '{}', -- [pub]
+ l_dmm text[] NOT NULL DEFAULT '{}', -- [pub]
+ l_booth integer NOT NULL DEFAULT 0, -- [pub]
+ l_patreonp integer NOT NULL DEFAULT 0, -- [pub]
+ l_patreon text NOT NULL DEFAULT '', -- [pub]
+ l_substar text NOT NULL DEFAULT '' -- [pub]
+);
+
+-- releases_hist
+CREATE TABLE releases_hist (
+ chid integer NOT NULL PRIMARY KEY,
+ olang language NOT NULL DEFAULT 'ja',
+ gtin bigint NOT NULL DEFAULT 0,
+ l_toranoana bigint NOT NULL DEFAULT 0,
+ l_appstore bigint NOT NULL DEFAULT 0,
+ l_nintendo_jp bigint NOT NULL DEFAULT 0,
+ l_nintendo_hk bigint NOT NULL DEFAULT 0,
+ released integer NOT NULL DEFAULT 0,
+ l_steam integer NOT NULL DEFAULT 0,
+ l_digiket integer NOT NULL DEFAULT 0,
+ l_melon integer NOT NULL DEFAULT 0,
+ l_mg integer NOT NULL DEFAULT 0,
+ l_getchu integer NOT NULL DEFAULT 0,
+ l_getchudl integer NOT NULL DEFAULT 0,
+ l_egs integer NOT NULL DEFAULT 0,
+ l_erotrail integer NOT NULL DEFAULT 0,
+ l_melonjp integer NOT NULL DEFAULT 0,
+ l_gamejolt integer NOT NULL DEFAULT 0,
+ l_animateg integer NOT NULL DEFAULT 0,
+ l_freem integer NOT NULL DEFAULT 0,
+ l_novelgam integer NOT NULL DEFAULT 0,
+ voiced smallint NOT NULL DEFAULT 0,
+ reso_x smallint NOT NULL DEFAULT 0,
+ reso_y smallint NOT NULL DEFAULT 0,
+ minage smallint,
+ ani_story smallint NOT NULL DEFAULT 0,
+ ani_ero smallint NOT NULL DEFAULT 0,
+ ani_story_sp animation,
+ ani_story_cg animation,
+ ani_cutscene animation,
+ ani_ero_sp animation,
+ ani_ero_cg animation,
+ ani_bg boolean,
+ ani_face boolean,
+ has_ero boolean NOT NULL DEFAULT FALSE,
+ patch boolean NOT NULL DEFAULT FALSE,
+ freeware boolean NOT NULL DEFAULT FALSE,
+ doujin boolean NOT NULL DEFAULT FALSE,
+ uncensored boolean,
+ official boolean NOT NULL DEFAULT TRUE,
+ website varchar(1024) NOT NULL DEFAULT '',
+ catalog varchar(50) NOT NULL DEFAULT '',
+ engine varchar(50) NOT NULL DEFAULT '',
+ notes text NOT NULL DEFAULT '',
+ l_dlsite text NOT NULL DEFAULT '',
+ l_dlsiteen text NOT NULL DEFAULT '',
+ l_gog text NOT NULL DEFAULT '',
+ l_denpa text NOT NULL DEFAULT '',
+ l_jlist text NOT NULL DEFAULT '',
+ l_jastusa text NOT NULL DEFAULT '',
+ l_itch text NOT NULL DEFAULT '',
+ l_nutaku text NOT NULL DEFAULT '',
+ l_googplay text NOT NULL DEFAULT '',
+ l_fakku text NOT NULL DEFAULT '',
+ l_freegame text NOT NULL DEFAULT '',
+ l_playstation_jp text NOT NULL DEFAULT '',
+ l_playstation_na text NOT NULL DEFAULT '',
+ l_playstation_eu text NOT NULL DEFAULT '',
+ l_playstation_hk text NOT NULL DEFAULT '',
+ l_nintendo text NOT NULL DEFAULT '',
+ l_gyutto integer[] NOT NULL DEFAULT '{}',
+ l_dmm text[] NOT NULL DEFAULT '{}',
+ l_booth integer NOT NULL DEFAULT 0,
+ l_patreonp integer NOT NULL DEFAULT 0,
+ l_patreon text NOT NULL DEFAULT '',
+ l_substar text NOT NULL DEFAULT ''
+);
+
+-- releases_drm
+CREATE TABLE releases_drm (
+ id vndbid NOT NULL, -- [pub]
+ drm integer NOT NULL, -- [pub]
+ notes text NOT NULL DEFAULT '', -- [pub]
+ PRIMARY KEY(id, drm)
+);
+
+-- releases_drm_hist
+CREATE TABLE releases_drm_hist (
+ chid integer NOT NULL,
+ drm integer NOT NULL,
+ notes text NOT NULL DEFAULT '',
+ PRIMARY KEY(chid, drm)
+);
+
+-- releases_media
+CREATE TABLE releases_media (
+ id vndbid NOT NULL, -- [pub]
+ medium medium NOT NULL, -- [pub]
+ qty smallint NOT NULL DEFAULT 1, -- [pub]
+ PRIMARY KEY(id, medium, qty)
+);
+
+-- releases_media_hist
+CREATE TABLE releases_media_hist (
+ chid integer NOT NULL,
+ medium medium NOT NULL,
+ qty smallint NOT NULL DEFAULT 1,
+ PRIMARY KEY(chid, medium, qty)
+);
+
+-- releases_platforms
+CREATE TABLE releases_platforms (
+ id vndbid NOT NULL, -- [pub]
+ platform platform NOT NULL, -- [pub]
+ PRIMARY KEY(id, platform)
+);
+
+-- releases_platforms_hist
+CREATE TABLE releases_platforms_hist (
+ chid integer NOT NULL,
+ platform platform NOT NULL,
+ PRIMARY KEY(chid, platform)
+);
+
+-- releases_producers
+CREATE TABLE releases_producers (
+ id vndbid NOT NULL, -- [pub]
+ pid vndbid NOT NULL, -- [pub]
+ developer boolean NOT NULL DEFAULT FALSE, -- [pub]
+ publisher boolean NOT NULL DEFAULT TRUE, -- [pub]
+ CONSTRAINT releases_producers_check1 CHECK(developer OR publisher),
+ PRIMARY KEY(id, pid)
+);
+
+-- releases_producers_hist
+CREATE TABLE releases_producers_hist (
+ chid integer NOT NULL,
+ pid vndbid NOT NULL, -- producers.id
+ developer boolean NOT NULL DEFAULT FALSE,
+ publisher boolean NOT NULL DEFAULT TRUE,
+ CHECK(developer OR publisher),
+ PRIMARY KEY(chid, pid)
+);
+
+-- releases_titles (also: languages this release is available in)
+CREATE TABLE releases_titles (
+ id vndbid NOT NULL, -- [pub]
+ lang language NOT NULL, -- [pub]
+ mtl boolean NOT NULL DEFAULT false, -- [pub]
+ title text, -- [pub]
+ latin text, -- [pub]
+ PRIMARY KEY(id, lang)
+);
+
+-- releases_titles_hist
+CREATE TABLE releases_titles_hist (
+ chid integer NOT NULL,
+ lang language NOT NULL,
+ mtl boolean NOT NULL DEFAULT false,
+ title text,
+ latin text,
+ PRIMARY KEY(chid, lang)
+);
+
+-- releases_vn
+CREATE TABLE releases_vn (
+ id vndbid NOT NULL, -- [pub]
+ vid vndbid NOT NULL, -- [pub]
+ rtype release_type NOT NULL, -- [pub]
+ PRIMARY KEY(id, vid)
+);
+
+-- releases_vn_hist
+CREATE TABLE releases_vn_hist (
+ chid integer NOT NULL,
+ vid vndbid NOT NULL, -- vn.id
+ rtype release_type NOT NULL,
+ PRIMARY KEY(chid, vid)
+);
+
+-- reports
+CREATE TABLE reports (
+ id SERIAL PRIMARY KEY,
+ status report_status NOT NULL DEFAULT 'new',
+ date timestamptz NOT NULL DEFAULT NOW(),
+ lastmod timestamptz,
+ uid vndbid, -- user who created the report, if logged in
+ object vndbid NOT NULL, -- The id of the thing being reported
+ objectnum integer, -- The sub-id of the thing to be reported
+ ip ipinfo, -- IP address of the visitor, if not logged in
+ reason text NOT NULL,
+ message text NOT NULL,
+ log text NOT NULL DEFAULT '' -- replaced by reports_log for new reports
+);
+
+-- reports_log
+CREATE TABLE reports_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ status report_status NOT NULL,
+ uid vndbid,
+ message text NOT NULL
+);
+
+-- reset_throttle
+CREATE TABLE reset_throttle (
+ ip inet NOT NULL PRIMARY KEY,
+ timeout timestamptz NOT NULL
+);
+
+-- reviews
+CREATE TABLE reviews (
+ id vndbid PRIMARY KEY DEFAULT vndbid('w', nextval('reviews_seq')::int) CONSTRAINT reviews_id_check CHECK(vndbid_type(id) = 'w'),
+ vid vndbid NOT NULL,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ lastmod timestamptz,
+ uid vndbid,
+ rid vndbid,
+ c_up integer NOT NULL DEFAULT 0,
+ c_down integer NOT NULL DEFAULT 0,
+ c_count smallint NOT NULL DEFAULT 0,
+ c_lastnum smallint,
+ spoiler boolean NOT NULL,
+ isfull boolean NOT NULL,
+ locked boolean NOT NULL DEFAULT false,
+ c_flagged boolean NOT NULL DEFAULT false,
+ text text NOT NULL,
+ modnote text NOT NULL DEFAULT ''
+);
+
+-- reviews_posts
+CREATE TABLE reviews_posts (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ edited timestamptz,
+ id vndbid NOT NULL,
+ uid vndbid,
+ num smallint NOT NULL,
+ hidden text,
+ msg text NOT NULL DEFAULT '',
+ PRIMARY KEY(id, num)
+);
+
+-- reviews_votes
+CREATE TABLE reviews_votes (
+ date timestamptz NOT NULL,
+ id vndbid NOT NULL,
+ uid vndbid,
+ vote boolean NOT NULL, -- true = upvote, false = downvote
+ overrule boolean NOT NULL DEFAULT false,
+ ip inet -- Only for anonymous votes
+);
+
+-- rlists
+CREATE TABLE rlists ( -- User's releases list
+ uid vndbid NOT NULL, -- [pub]
+ rid vndbid NOT NULL, -- [pub]
+ added timestamptz NOT NULL DEFAULT NOW(), -- [pub]
+ status smallint NOT NULL DEFAULT 0, -- [pub] 0 = Unknown, 1 = Pending, 2 = Obtained, 3 = On loan, 4 = Deleted
+ PRIMARY KEY(uid, rid)
+);
+
+-- saved_queries
+CREATE TABLE saved_queries (
+ uid vndbid NOT NULL,
+ qtype dbentry_type NOT NULL,
+ name text NOT NULL, -- Empty string is the users' default filter for the given qtype
+ query text NOT NULL, -- compact encoded form
+ PRIMARY KEY(uid, qtype, name)
+);
+
+-- search_cache
+CREATE TABLE search_cache (
+ id vndbid NOT NULL,
+ subid integer, -- only for staff_alias.id at the moment
+ prio smallint NOT NULL, -- 1 for indirect titles, 2 for aliases, 3 for main titles
+ label text NOT NULL COLLATE "C"
+) PARTITION BY RANGE(id);
+
+CREATE TABLE search_cache_v PARTITION OF search_cache FOR VALUES FROM ('v1') TO (vndbid_max('v'));
+CREATE TABLE search_cache_r PARTITION OF search_cache FOR VALUES FROM ('r1') TO (vndbid_max('r'));
+CREATE TABLE search_cache_c PARTITION OF search_cache FOR VALUES FROM ('c1') TO (vndbid_max('c'));
+CREATE TABLE search_cache_p PARTITION OF search_cache FOR VALUES FROM ('p1') TO (vndbid_max('p'));
+CREATE TABLE search_cache_s PARTITION OF search_cache FOR VALUES FROM ('s1') TO (vndbid_max('s'));
+CREATE TABLE search_cache_g PARTITION OF search_cache FOR VALUES FROM ('g1') TO (vndbid_max('g'));
+CREATE TABLE search_cache_i PARTITION OF search_cache FOR VALUES FROM ('i1') TO (vndbid_max('i'));
+
+-- sessions
+CREATE TABLE sessions (
+ uid vndbid NOT NULL,
+ type session_type NOT NULL,
+ added timestamptz NOT NULL DEFAULT NOW(),
+ expires timestamptz NOT NULL, -- 'api2' tokens don't expire, this column is used for last-use tracking
+ token bytea NOT NULL,
+ mail text,
+ notes text,
+ listread boolean NOT NULL DEFAULT false,
+ listwrite boolean NOT NULL DEFAULT false,
+ PRIMARY KEY (uid, token)
+);
+
+-- shop_denpa
+CREATE TABLE shop_denpa (
+ lastfetch timestamptz,
+ deadsince timestamptz,
+ id text NOT NULL PRIMARY KEY,
+ sku text NOT NULL DEFAULT '',
+ price text NOT NULL DEFAULT ''
+);
+
+-- shop_dlsite
+CREATE TABLE shop_dlsite (
+ lastfetch timestamptz,
+ deadsince timestamptz,
+ id text NOT NULL PRIMARY KEY,
+ shop text NOT NULL DEFAULT '',
+ price text NOT NULL DEFAULT ''
+);
+
+-- shop_jastusa
+CREATE TABLE shop_jastusa (
+ lastfetch timestamptz,
+ deadsince timestamptz,
+ id text NOT NULL PRIMARY KEY,
+ price text NOT NULL DEFAULT '',
+ slug text NOT NULL DEFAULT ''
+);
+
+-- shop_jlist
+CREATE TABLE shop_jlist (
+ lastfetch timestamptz,
+ deadsince timestamptz,
+ id text NOT NULL PRIMARY KEY,
+ price text NOT NULL DEFAULT '' -- empty when unknown or not in stock
+);
+
+-- shop_mg
+CREATE TABLE shop_mg (
+ lastfetch timestamptz,
+ deadsince timestamptz,
+ id integer NOT NULL PRIMARY KEY,
+ r18 boolean NOT NULL DEFAULT true,
+ price text NOT NULL DEFAULT ''
+);
+
+-- shop_playasia
+CREATE TABLE shop_playasia (
+ gtin bigint NOT NULL,
+ lastfetch timestamptz,
+ pax text NOT NULL PRIMARY KEY,
+ url text NOT NULL DEFAULT '',
+ price text NOT NULL DEFAULT ''
+);
+
+-- shop_playasia_gtin
+CREATE TABLE shop_playasia_gtin (
+ gtin bigint NOT NULL PRIMARY KEY,
+ lastfetch timestamptz
+);
+
+-- staff
+CREATE TABLE staff ( -- dbentry_type=s
+ id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('s', nextval('staff_id_seq')::int) CONSTRAINT staff_id_check CHECK(vndbid_type(id) = 's'), -- [pub]
+ gender gender NOT NULL DEFAULT 'unknown', -- [pub]
+ lang language NOT NULL DEFAULT 'ja', -- [pub]
+ main integer NOT NULL DEFAULT 0, -- [pub] Primary name for the staff entry
+ l_anidb integer, -- [pub]
+ l_wikidata integer, -- [pub]
+ l_pixiv integer NOT NULL DEFAULT 0, -- [pub]
+ locked boolean NOT NULL DEFAULT FALSE,
+ hidden boolean NOT NULL DEFAULT FALSE,
+ description text NOT NULL DEFAULT '', -- [pub]
+ l_wp varchar(150) NOT NULL DEFAULT '', -- (deprecated)
+ l_site varchar(250) NOT NULL DEFAULT '', -- [pub]
+ l_twitter varchar(16) NOT NULL DEFAULT '', -- [pub]
+ l_vgmdb integer NOT NULL DEFAULT 0, -- [pub]
+ l_discogs integer NOT NULL DEFAULT 0, -- [pub]
+ l_mobygames integer NOT NULL DEFAULT 0, -- [pub]
+ l_bgmtv integer NOT NULL DEFAULT 0, -- [pub]
+ l_imdb integer NOT NULL DEFAULT 0, -- [pub]
+ l_vndb vndbid, -- [pub]
+ l_mbrainz uuid, -- [pub]
+ l_scloud text NOT NULL DEFAULT '' -- [pub]
+);
+
+-- staff_hist
+CREATE TABLE staff_hist (
+ chid integer NOT NULL PRIMARY KEY,
+ gender gender NOT NULL DEFAULT 'unknown',
+ lang language NOT NULL DEFAULT 'ja',
+ main integer NOT NULL DEFAULT 0, -- Can't refer to staff_alias.id, because the alias might have been deleted
+ l_anidb integer,
+ l_wikidata integer,
+ l_pixiv integer NOT NULL DEFAULT 0,
+ description text NOT NULL DEFAULT '',
+ l_wp varchar(150) NOT NULL DEFAULT '',
+ l_site varchar(250) NOT NULL DEFAULT '',
+ l_twitter varchar(16) NOT NULL DEFAULT '',
+ l_vgmdb integer NOT NULL DEFAULT 0,
+ l_discogs integer NOT NULL DEFAULT 0,
+ l_mobygames integer NOT NULL DEFAULT 0,
+ l_bgmtv integer NOT NULL DEFAULT 0,
+ l_imdb integer NOT NULL DEFAULT 0,
+ l_vndb vndbid,
+ l_mbrainz uuid,
+ l_scloud text NOT NULL DEFAULT ''
+);
+
+-- staff_alias
+CREATE TABLE staff_alias (
+ id vndbid NOT NULL, -- [pub]
+ aid SERIAL PRIMARY KEY, -- [pub] Globally unique ID of this alias
+ name varchar(200) NOT NULL DEFAULT '', -- [pub]
+ latin varchar(200) -- [pub]
+);
+
+-- staff_alias_hist
+CREATE TABLE staff_alias_hist (
+ chid integer NOT NULL,
+ aid integer NOT NULL, -- staff_alias.aid, but can't reference it because the alias may have been deleted
+ name varchar(200) NOT NULL DEFAULT '',
+ latin varchar(200),
+ PRIMARY KEY(chid, aid)
+);
+
+-- stats_cache
+CREATE TABLE stats_cache (
+ section varchar(25) NOT NULL PRIMARY KEY,
+ count integer NOT NULL DEFAULT 0
+);
+
+-- tags
+CREATE TABLE tags ( -- dbentry_type=g
+ id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('g', nextval('tags_id_seq')::int) CONSTRAINT tags_id_check CHECK(vndbid_type(id) = 'g'), -- [pub]
+ cat tag_category NOT NULL DEFAULT 'cont', -- [pub]
+ added timestamptz NOT NULL DEFAULT NOW(), -- Tag creation time. Relic of a long forgotten past where changes to tag entries weren't logged.
+ c_items integer NOT NULL DEFAULT 0,
+ defaultspoil smallint NOT NULL DEFAULT 0, -- [pub]
+ locked boolean NOT NULL DEFAULT FALSE,
+ hidden boolean NOT NULL DEFAULT TRUE,
+ searchable boolean NOT NULL DEFAULT TRUE, -- [pub]
+ applicable boolean NOT NULL DEFAULT TRUE, -- [pub]
+ name varchar(250) NOT NULL DEFAULT '' UNIQUE, -- [pub]
+ alias varchar(500) NOT NULL DEFAULT '', -- [pub]
+ description text NOT NULL DEFAULT '' -- [pub]
+);
+
+-- tags_hist
+CREATE TABLE tags_hist (
+ chid integer NOT NULL PRIMARY KEY,
+ cat tag_category NOT NULL DEFAULT 'cont',
+ defaultspoil smallint NOT NULL DEFAULT 0,
+ searchable boolean NOT NULL DEFAULT TRUE,
+ applicable boolean NOT NULL DEFAULT TRUE,
+ name varchar(250) NOT NULL DEFAULT '',
+ alias varchar(500) NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT ''
+);
+
+-- tags_parents
+CREATE TABLE tags_parents (
+ id vndbid NOT NULL, -- [pub]
+ parent vndbid NOT NULL, -- [pub]
+ main boolean NOT NULL DEFAULT false, -- [pub]
+ PRIMARY KEY(id, parent)
+);
+
+-- tags_parents_hist
+CREATE TABLE tags_parents_hist (
+ chid integer NOT NULL,
+ parent vndbid NOT NULL,
+ main boolean NOT NULL DEFAULT false,
+ PRIMARY KEY(chid, parent)
+);
+
+-- tags_vn
+CREATE TABLE tags_vn (
+ date timestamptz NOT NULL DEFAULT NOW(), -- [pub]
+ tag vndbid NOT NULL, -- [pub]
+ vid vndbid NOT NULL, -- [pub]
+ uid vndbid, -- [pub]
+ vote smallint NOT NULL DEFAULT 3 CHECK (vote >= -3 AND vote <= 3 AND vote <> 0), -- [pub] negative for downvote, 1-3 otherwise
+ spoiler smallint CHECK(spoiler >= 0 AND spoiler <= 2), -- [pub]
+ ignore boolean NOT NULL DEFAULT false, -- [pub]
+ lie boolean, -- [pub] implies spoiler=0
+ notes text NOT NULL DEFAULT '' -- [pub]
+);
+
+-- tags_vn_direct
+CREATE TABLE tags_vn_direct (
+ tag vndbid NOT NULL,
+ vid vndbid NOT NULL,
+ rating real NOT NULL,
+ spoiler smallint NOT NULL,
+ lie boolean NOT NULL,
+ count smallint NOT NULL,
+ PRIMARY KEY(tag, vid)
+);
+
+-- tags_vn_inherit
+CREATE TABLE tags_vn_inherit (
+ tag vndbid NOT NULL,
+ vid vndbid NOT NULL,
+ rating real NOT NULL,
+ spoiler smallint NOT NULL,
+ lie boolean NOT NULL,
+ PRIMARY KEY(tag, vid)
+);
+
+-- threads
+CREATE TABLE threads (
+ id vndbid PRIMARY KEY DEFAULT vndbid('t', nextval('threads_id_seq')::int) CONSTRAINT threads_id_check CHECK(vndbid_type(id) = 't'),
+ poll_max_options smallint NOT NULL DEFAULT 1,
+ c_count smallint NOT NULL DEFAULT 0, -- Number of non-hidden posts
+ c_lastnum smallint NOT NULL DEFAULT 1, -- 'num' of the most recent non-hidden post
+ locked boolean NOT NULL DEFAULT FALSE,
+ hidden boolean NOT NULL DEFAULT FALSE,
+ private boolean NOT NULL DEFAULT FALSE,
+ boards_locked boolean NOT NULL DEFAULT FALSE,
+ title varchar(50) NOT NULL DEFAULT '',
+ poll_question varchar(100)
+);
+
+-- threads_poll_options
+CREATE TABLE threads_poll_options (
+ id SERIAL PRIMARY KEY,
+ tid vndbid NOT NULL,
+ option varchar(100) NOT NULL
+);
+
+-- threads_poll_votes
+CREATE TABLE threads_poll_votes (
+ uid vndbid NOT NULL,
+ optid integer NOT NULL,
+ date timestamptz DEFAULT NOW(),
+ PRIMARY KEY (optid, uid)
+);
+
+-- threads_posts
+CREATE TABLE threads_posts (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ edited timestamptz,
+ tid vndbid NOT NULL,
+ uid vndbid,
+ num smallint NOT NULL,
+ hidden text,
+ msg text NOT NULL DEFAULT '',
+ PRIMARY KEY(tid, num),
+ CONSTRAINT threads_posts_first_nonhidden CHECK(num > 1 OR hidden IS NULL)
+);
+
+-- threads_boards
+CREATE TABLE threads_boards (
+ tid vndbid NOT NULL,
+ type board_type NOT NULL,
+ iid vndbid
+);
+
+-- trace_log
+CREATE TABLE trace_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ line integer,
+ sql_num integer,
+ sql_time real,
+ perl_time real,
+ has_txn boolean,
+ loggedin boolean,
+ method text NOT NULL,
+ path text NOT NULL,
+ query text NOT NULL DEFAULT '',
+ module text,
+ js text[]
+);
+
+-- traits
+CREATE TABLE traits ( -- dbentry_type=i
+ id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('i', nextval('traits_id_seq')::int) CONSTRAINT traits_id_check CHECK(vndbid_type(id) = 'i'), -- [pub]
+ c_items integer NOT NULL DEFAULT 0,
+ added timestamptz NOT NULL DEFAULT NOW(),
+ gid vndbid, -- [pub] Trait group (technically a cached column, main parent's root trait)
+ gorder smallint NOT NULL DEFAULT 0, -- [pub] Group order, only used when gid IS NULL
+ defaultspoil smallint NOT NULL DEFAULT 0, -- [pub]
+ hidden boolean NOT NULL DEFAULT TRUE,
+ locked boolean NOT NULL DEFAULT FALSE,
+ sexual boolean NOT NULL DEFAULT false, -- [pub]
+ searchable boolean NOT NULL DEFAULT true, -- [pub]
+ applicable boolean NOT NULL DEFAULT true, -- [pub]
+ name varchar(250) NOT NULL DEFAULT '', -- [pub]
+ alias varchar(500) NOT NULL DEFAULT '', -- [pub]
+ description text NOT NULL DEFAULT '' -- [pub]
+);
+
+-- traits_hist
+CREATE TABLE traits_hist (
+ chid integer NOT NULL,
+ gorder smallint NOT NULL DEFAULT 0,
+ defaultspoil smallint NOT NULL DEFAULT 0,
+ sexual boolean NOT NULL DEFAULT false,
+ searchable boolean NOT NULL DEFAULT true,
+ applicable boolean NOT NULL DEFAULT true,
+ name varchar(250) NOT NULL DEFAULT '',
+ alias varchar(500) NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT ''
+);
+
+-- traits_chars
+-- This table is a cache for the data in chars_traits and includes child traits
+-- into parent traits. In order to improve performance, there are no foreign
+-- key constraints on this table.
+CREATE TABLE traits_chars (
+ cid vndbid NOT NULL, -- chars (id)
+ tid vndbid NOT NULL, -- traits (id)
+ spoil smallint NOT NULL DEFAULT 0,
+ lie boolean NOT NULL DEFAULT false,
+ PRIMARY KEY (tid, cid)
+);
+
+-- traits_parents
+CREATE TABLE traits_parents (
+ id vndbid NOT NULL, -- [pub]
+ parent vndbid NOT NULL, -- [pub]
+ main boolean NOT NULL DEFAULT false, -- [pub]
+ PRIMARY KEY(id, parent)
+);
+
+-- traits_parents_hist
+CREATE TABLE traits_parents_hist (
+ chid integer NOT NULL,
+ parent vndbid NOT NULL,
+ main boolean NOT NULL DEFAULT false,
+ PRIMARY KEY(chid, parent)
+);
+
+-- ulist_labels
+CREATE TABLE ulist_labels ( -- User labels assigned to visual novels
+ uid vndbid NOT NULL, -- [pub]
+ id smallint NOT NULL, -- [pub] 0 < builtin < 10 <= custom, ids are reused
+ private boolean NOT NULL,
+ label text NOT NULL, -- [pub]
+ PRIMARY KEY(uid, id)
+);
+
+-- ulist_vns
+-- XXX: dbdump.pl has a custom query for this table, make sure to sync that when adding/removing [pub] columns.
+CREATE TABLE ulist_vns ( -- User's VN lists
+ uid vndbid NOT NULL, -- [pub]
+ vid vndbid NOT NULL, -- [pub]
+ added timestamptz NOT NULL DEFAULT NOW(), -- [pub]
+ lastmod timestamptz NOT NULL DEFAULT NOW(), -- [pub] updated when any column in this row has changed
+ vote_date timestamptz, -- [pub] Not updated when the vote is changed
+ started date, -- [pub]
+ finished date, -- [pub]
+ vote smallint CHECK(vote IS NULL OR vote BETWEEN 10 AND 100), -- [pub] 0 - 100
+ -- Cache, equivalent to 'coalesce(bool_and(private), true)' on the labels.
+ -- Updated by update_users_ulist_private(), which MUST be called any time:
+ -- * when a label's private flag has been changed, or
+ -- * when the 'vote' or 'labels' column has been changed
+ -- There's no triggers for this (yet).
+ c_private boolean NOT NULL DEFAULT true,
+ notes text NOT NULL DEFAULT '', -- [pub]
+ -- The 'Voted' label (id 7) is special: it is included in this array, but
+ -- actually redundant with the 'vote' column. The 'ulist_voted_label' trigger
+ -- ensures that the label is added/removed automatically when the 'vote'
+ -- column is changed.
+ -- In public database dumps, the voted label is not included if the label is
+ -- flagged as private, even if a 'vote' is set.
+ -- This array is sorted, for no real reason.
+ labels smallint[] NOT NULL DEFAULT '{}', -- [pub]
+ PRIMARY KEY(uid, vid)
+);
+
+-- users
+CREATE TABLE users (
+ registered timestamptz NOT NULL DEFAULT NOW(),
+ id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('u', nextval('users_id_seq')::int) CONSTRAINT users_id_check CHECK(vndbid_type(id) = 'u'), -- [pub]
+ c_votes integer NOT NULL DEFAULT 0,
+ c_changes integer NOT NULL DEFAULT 0,
+ c_tags integer NOT NULL DEFAULT 0,
+ c_vns integer NOT NULL DEFAULT 0,
+ c_wish integer NOT NULL DEFAULT 0,
+ c_imgvotes integer NOT NULL DEFAULT 0,
+ ign_votes boolean NOT NULL DEFAULT false, -- [pub] Set when user's votes are ignored
+ email_confirmed boolean NOT NULL DEFAULT false,
+ notify_dbedit boolean NOT NULL DEFAULT true,
+ notify_announce boolean NOT NULL DEFAULT false,
+ notify_post boolean NOT NULL DEFAULT true,
+ notify_comment boolean NOT NULL DEFAULT true,
+ nodistract_can boolean NOT NULL DEFAULT false,
+ nodistract_noads boolean NOT NULL DEFAULT false,
+ nodistract_nofancy boolean NOT NULL DEFAULT false,
+ support_can boolean NOT NULL DEFAULT false,
+ support_enabled boolean NOT NULL DEFAULT false,
+ uniname_can boolean NOT NULL DEFAULT false,
+ pubskin_can boolean NOT NULL DEFAULT false,
+ pubskin_enabled boolean NOT NULL DEFAULT false,
+ perm_board boolean NOT NULL DEFAULT true,
+ perm_boardmod boolean NOT NULL DEFAULT false,
+ perm_dbmod boolean NOT NULL DEFAULT false,
+ perm_edit boolean NOT NULL DEFAULT true,
+ perm_imgvote boolean NOT NULL DEFAULT true, -- [pub] User's image votes don't count when false
+ perm_tag boolean NOT NULL DEFAULT true, -- [pub] User's tag votes don't count when false
+ perm_tagmod boolean NOT NULL DEFAULT false,
+ perm_review boolean NOT NULL DEFAULT true,
+ perm_lengthvote boolean NOT NULL DEFAULT true, -- [pub] User's length votes don't count when false
+ username varchar(20), -- [pub]
+ uniname text NOT NULL DEFAULT ''
+);
+
+-- Additional, less frequently accessed fields for the 'users' table.
+-- (Separated to debloat the main users table, which is often used in JOINs)
+CREATE TABLE users_prefs (
+ customcss_csum bigint NOT NULL DEFAULT 0, -- hash of 'customcss'
+ id vndbid NOT NULL PRIMARY KEY,
+ 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
+ tableopts_c integer,
+ tableopts_v integer,
+ tableopts_vt integer, -- VN listing on tag pages
+ spoilers smallint NOT NULL DEFAULT 0,
+ tags_all boolean NOT NULL DEFAULT false,
+ tags_cont boolean NOT NULL DEFAULT true,
+ tags_ero boolean NOT NULL DEFAULT false,
+ tags_tech boolean NOT NULL DEFAULT true,
+ traits_sexual boolean NOT NULL DEFAULT false,
+ prodrelexpand boolean NOT NULL DEFAULT true,
+ vnrel_olang boolean NOT NULL DEFAULT true,
+ vnrel_mtl boolean NOT NULL DEFAULT false,
+ staffed_olang boolean NOT NULL DEFAULT true,
+ staffed_unoff boolean NOT NULL DEFAULT false,
+ skin text NOT NULL DEFAULT '',
+ customcss text NOT NULL DEFAULT '',
+ timezone text NOT NULL DEFAULT '',
+ ulist_votes jsonb,
+ ulist_vnlist jsonb,
+ ulist_wish jsonb,
+ vnlang jsonb, -- Deprecated, replaced by vnrel_x. '$lang(-mtl)?' => true/false, which languages to expand/collapse on VN pages
+ title_langs jsonb, -- Deprecated, replaced by 'titles'
+ alttitle_langs jsonb, -- Deprecated, replaced by 'titles'
+ vnrel_langs language[], -- NULL meaning "show all languages"
+ staffed_langs language[],
+ titles titleprefs
+);
+
+-- users_prefs_tags
+CREATE TABLE users_prefs_tags (
+ id vndbid NOT NULL,
+ tid vndbid NOT NULL,
+ spoil smallint, -- 0 = always show, 3 = always hide
+ childs boolean NOT NULL,
+ color text, -- NULL / 'standout' / 'grayedout' / '#customcolor'
+ PRIMARY KEY(id, tid)
+);
+
+-- users_prefs_traits
+CREATE TABLE users_prefs_traits (
+ id vndbid NOT NULL,
+ tid vndbid NOT NULL,
+ spoil smallint,
+ childs boolean NOT NULL,
+ color text,
+ PRIMARY KEY(id, tid)
+);
+
+-- Additional fields for the 'users' table, but with some protected columns.
+-- (Separated from the users table to simplify permission management)
+CREATE TABLE users_shadow (
+ id vndbid NOT NULL PRIMARY KEY,
+ -- Usermods can see other users' mail and edit their passwords, so this
+ -- permission is separated in this table to prevent unauthorized writes.
+ perm_usermod boolean NOT NULL DEFAULT false,
+ mail varchar(100) NOT NULL,
+ -- A valid passwd column is 46 bytes:
+ -- 4 bytes: N (big endian)
+ -- 1 byte: r
+ -- 1 byte: p
+ -- 8 bytes: salt
+ -- 32 bytes: scrypt(passwd, global_salt + salt, N, r, p, 32)
+ -- Anything else is invalid, account disabled.
+ passwd bytea NOT NULL DEFAULT '',
+ ip ipinfo,
+ delete_at timestamptz
+);
+
+-- users_traits
+CREATE TABLE users_traits (
+ id vndbid NOT NULL,
+ tid vndbid NOT NULL,
+ PRIMARY KEY(id, tid)
+);
+
+-- users_username_hist
+CREATE TABLE users_username_hist (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id vndbid NOT NULL,
+ old text NOT NULL,
+ new text NOT NULL,
+ PRIMARY KEY(id, date)
+);
+
+-- vn
+CREATE TABLE vn ( -- dbentry_type=v
+ id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('v', nextval('vn_id_seq')::int) CONSTRAINT vn_id_check CHECK(vndbid_type(id) = 'v'), -- [pub]
+ olang language NOT NULL DEFAULT 'ja', -- [pub] Original language
+ image vndbid CONSTRAINT vn_image_check CHECK(vndbid_type(image) = 'cv'), -- [pub]
+ l_wikidata integer, -- [pub]
+ c_votecount integer NOT NULL DEFAULT 0, -- [pub]
+ c_pop_rank integer NOT NULL DEFAULT 10000000,
+ c_rat_rank integer,
+ c_released integer NOT NULL DEFAULT 0,
+ c_rating smallint, -- [pub] decimal vote*100, i.e. 100 - 1000
+ c_average smallint, -- [pub] decimal vote*100, i.e. 100 - 1000
+ c_length smallint,
+ c_lengthnum smallint NOT NULL DEFAULT 0,
+ length smallint NOT NULL DEFAULT 0, -- [pub] Old length field, 0 = unknown, 1 = very short [..] 5 = very long
+ devstatus smallint NOT NULL DEFAULT 0, -- [pub] 0 = finished, 1 = ongoing, 2 = cancelled
+ img_nsfw boolean NOT NULL DEFAULT FALSE, -- (deprecated)
+ locked boolean NOT NULL DEFAULT FALSE,
+ hidden boolean NOT NULL DEFAULT FALSE,
+ alias varchar(500) NOT NULL DEFAULT '', -- [pub]
+ l_wp varchar(150) NOT NULL DEFAULT '', -- (deprecated)
+ l_encubed varchar(100) NOT NULL DEFAULT '', -- (deprecated)
+ l_renai varchar(100) NOT NULL DEFAULT '', -- [pub] Renai.us identifier
+ description text NOT NULL DEFAULT '', -- [pub]
+ c_languages language[] NOT NULL DEFAULT '{}',
+ c_platforms platform[] NOT NULL DEFAULT '{}',
+ c_developers vndbid[] NOT NULL DEFAULT '{}'
+);
+
+-- vn_hist
+CREATE TABLE vn_hist (
+ chid integer NOT NULL PRIMARY KEY,
+ olang language NOT NULL DEFAULT 'ja',
+ image vndbid CONSTRAINT vn_hist_image_check CHECK(vndbid_type(image) = 'cv'),
+ l_wikidata integer,
+ length smallint NOT NULL DEFAULT 0,
+ devstatus smallint NOT NULL DEFAULT 0,
+ img_nsfw boolean NOT NULL DEFAULT FALSE,
+ alias varchar(500) NOT NULL DEFAULT '',
+ l_wp varchar(150) NOT NULL DEFAULT '',
+ l_encubed varchar(100) NOT NULL DEFAULT '',
+ l_renai varchar(100) NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT ''
+);
+
+-- vn_anime
+CREATE TABLE vn_anime (
+ id vndbid NOT NULL, -- [pub]
+ aid integer NOT NULL, -- [pub]
+ PRIMARY KEY(id, aid)
+);
+
+-- vn_anime_hist
+CREATE TABLE vn_anime_hist (
+ chid integer NOT NULL,
+ aid integer NOT NULL, -- anime.id
+ PRIMARY KEY(chid, aid)
+);
+
+-- vn_editions
+CREATE TABLE vn_editions (
+ id vndbid NOT NULL, -- [pub]
+ lang language, -- [pub]
+ eid smallint NOT NULL, -- [pub] Edition identifier, local to the VN, not stable across revisions
+ official boolean NOT NULL DEFAULT TRUE, -- [pub]
+ name text NOT NULL, -- [pub]
+ PRIMARY KEY(id, eid)
+);
+
+-- vn_editions_hist
+CREATE TABLE vn_editions_hist (
+ chid integer NOT NULL,
+ lang language,
+ eid smallint NOT NULL,
+ official boolean NOT NULL DEFAULT TRUE,
+ name text NOT NULL,
+ PRIMARY KEY(chid, eid)
+);
+
+-- vn_relations
+CREATE TABLE vn_relations (
+ id vndbid NOT NULL, -- [pub]
+ vid vndbid NOT NULL, -- [pub]
+ relation vn_relation NOT NULL, -- [pub]
+ official boolean NOT NULL DEFAULT TRUE, -- [pub]
+ PRIMARY KEY(id, vid)
+);
+
+-- vn_relations_hist
+CREATE TABLE vn_relations_hist (
+ chid integer NOT NULL,
+ vid vndbid NOT NULL, -- vn.id
+ relation vn_relation NOT NULL,
+ official boolean NOT NULL DEFAULT TRUE,
+ PRIMARY KEY(chid, vid)
+);
+
+-- vn_screenshots
+CREATE TABLE vn_screenshots (
+ id vndbid NOT NULL, -- [pub]
+ scr vndbid NOT NULL CONSTRAINT vn_screenshots_scr_check CHECK(vndbid_type(scr) = 'sf'), -- [pub]
+ rid vndbid, -- [pub]
+ nsfw boolean NOT NULL DEFAULT FALSE, -- (deprecated)
+ PRIMARY KEY(id, scr)
+);
+
+-- vn_screenshots_hist
+CREATE TABLE vn_screenshots_hist (
+ chid integer NOT NULL,
+ scr vndbid NOT NULL CONSTRAINT vn_screenshots_hist_scr_check CHECK(vndbid_type(scr) = 'sf'),
+ rid vndbid,
+ nsfw boolean NOT NULL DEFAULT FALSE,
+ PRIMARY KEY(chid, scr)
+);
+
+-- vn_seiyuu
+CREATE TABLE vn_seiyuu (
+ id vndbid NOT NULL, -- [pub]
+ aid integer NOT NULL, -- [pub]
+ cid vndbid NOT NULL, -- [pub]
+ note varchar(250) NOT NULL DEFAULT '', -- [pub]
+ PRIMARY KEY (id, aid, cid)
+);
+
+-- vn_seiyuu_hist
+CREATE TABLE vn_seiyuu_hist (
+ chid integer NOT NULL,
+ aid integer NOT NULL, -- staff_alias.aid, but can't reference it because the alias may have been deleted
+ cid vndbid NOT NULL,
+ note varchar(250) NOT NULL DEFAULT '',
+ PRIMARY KEY (chid, aid, cid)
+);
+
+-- vn_staff
+CREATE TABLE vn_staff (
+ id vndbid NOT NULL, -- [pub]
+ aid integer NOT NULL, -- [pub]
+ role credit_type NOT NULL DEFAULT 'staff', -- [pub]
+ eid smallint, -- [pub]
+ note varchar(250) NOT NULL DEFAULT '' -- [pub]
+);
+
+-- vn_staff_hist
+CREATE TABLE vn_staff_hist (
+ chid integer NOT NULL,
+ aid integer NOT NULL, -- See note at vn_seiyuu_hist.aid
+ role credit_type NOT NULL DEFAULT 'staff',
+ eid smallint,
+ note varchar(250) NOT NULL DEFAULT ''
+);
+
+-- vn_titles
+CREATE TABLE vn_titles (
+ id vndbid NOT NULL, -- [pub]
+ lang language NOT NULL, -- [pub]
+ official boolean NOT NULL, -- [pub]
+ title text NOT NULL, -- [pub]
+ latin text, -- [pub]
+ PRIMARY KEY(id, lang)
+);
+
+-- vn_titles_hist
+CREATE TABLE vn_titles_hist (
+ chid integer NOT NULL,
+ lang language NOT NULL,
+ official boolean NOT NULL,
+ title text NOT NULL,
+ latin text,
+ PRIMARY KEY(chid, lang)
+);
+
+-- vn_length_votes
+CREATE TABLE vn_length_votes (
+ id serial PRIMARY KEY,
+ vid vndbid NOT NULL, -- [pub]
+ date timestamptz NOT NULL DEFAULT NOW(), -- [pub]
+ length smallint NOT NULL, -- [pub] minutes
+ speed smallint, -- [pub] NULL=uncounted/ignored, 0=slow, 1=normal, 2=fast
+ private boolean NOT NULL,
+ uid vndbid, -- [pub]
+ rid vndbid[] NOT NULL, -- [pub]
+ notes text NOT NULL DEFAULT '' -- [pub]
+);
+
+-- wikidata
+CREATE TABLE wikidata ( -- Information fetched from Wikidata
+ lastfetch timestamptz,
+ id integer NOT NULL PRIMARY KEY, -- [pub] Q-number
+ enwiki text, -- [pub]
+ jawiki text, -- [pub]
+ website text[], -- [pub] P856
+ vndb text[], -- [pub] P3180
+ mobygames text[], -- [pub] P1933
+ mobygames_company text[], -- [pub] P4773
+ gamefaqs_game integer[], -- [pub] P4769
+ gamefaqs_company integer[], -- [pub] P6182
+ anidb_anime integer[], -- [pub] P5646
+ anidb_person integer[], -- [pub] P5649
+ ann_anime integer[], -- [pub] P1985
+ ann_manga integer[], -- [pub] P1984
+ musicbrainz_artist uuid[], -- [pub] P434
+ twitter text[], -- [pub] P2002
+ vgmdb_product integer[], -- [pub] P5659
+ vgmdb_artist integer[], -- [pub] P3435
+ discogs_artist integer[], -- [pub] P1953
+ acdb_char integer[], -- [pub] P7013
+ acdb_source integer[], -- [pub] P7017
+ indiedb_game text[], -- [pub] P6717
+ howlongtobeat integer[], -- [pub] P2816
+ crunchyroll text[], -- [pub] P4110
+ igdb_game text[], -- [pub] P5794
+ giantbomb text[], -- [pub] P5247
+ pcgamingwiki text[], -- [pub] P6337
+ steam integer[], -- [pub] P1733
+ gog text[], -- [pub] P2725
+ pixiv_user integer[], -- [pub] P5435
+ doujinshi_author integer[], -- [pub] P7511
+ soundcloud text[], -- [pub] P3040
+ humblestore text[], -- [pub] P4477
+ itchio text[], -- [pub] P7294
+ playstation_jp text[], -- [pub] P5999
+ playstation_na text[], -- [pub] P5944
+ playstation_eu text[], -- [pub] P5971
+ lutris text[], -- [pub] P7597
+ wine integer[] -- [pub] P600
+);
+
+
+-- This view is equivalent to vnt(NULL), see func.sql for a more detailed explanation.
+-- This view serves two purposes:
+-- * It's easier for the Postgres query planner to optimize this than vnt(NULL).
+-- * This view creates a type that can be used as return value for vnt().
+--
+-- This view and the vnt() function must be recreated anytime a column has been
+-- added/removed/changed in the vn table.
+CREATE VIEW vnt AS
+ SELECT v.*
+ , ARRAY[ v.olang::text, COALESCE(vo.latin, vo.title)
+ , v.olang::text, vo.title ] AS title
+ , COALESCE(vo.latin, vo.title) AS sorttitle
+ FROM vn v
+ JOIN vn_titles vo ON vo.id = v.id AND vo.lang = v.olang;
+
+-- Same for releases
+CREATE VIEW releasest AS
+ SELECT r.*
+ , ARRAY[ r.olang::text, COALESCE(ro.latin, ro.title)
+ , r.olang::text, ro.title ] AS title
+ , COALESCE(ro.latin, ro.title) AS sorttitle
+ FROM releases r
+ JOIN releases_titles ro ON ro.id = r.id AND ro.lang = r.olang;
+
+-- And producers
+CREATE VIEW producerst AS
+ SELECT *
+ , ARRAY [ lang::text, COALESCE(latin, name)
+ , lang::text, name ] AS title
+ , COALESCE(latin, name) AS sorttitle
+ FROM producers;
+
+-- And chars
+CREATE VIEW charst AS
+ SELECT *
+ , ARRAY [ c_lang::text, COALESCE(latin, name)
+ , c_lang::text, name ] AS title
+ , COALESCE(latin, name) AS sorttitle
+ FROM chars;
+
+-- This joins staff & staff_alias and adds the title + sorttitle fields.
+CREATE VIEW staff_aliast AS
+ SELECT s.*, sa.aid, sa.name, sa.latin
+ , ARRAY [ s.lang::text, COALESCE(sa.latin, sa.name)
+ , s.lang::text, sa.name ] AS title
+ , COALESCE(sa.latin, sa.name) AS sorttitle
+ FROM staff s
+ JOIN staff_alias sa ON sa.id = s.id;
diff --git a/util/sql/superuser_init.sql b/sql/superuser_init.sql
index 6e94167c..c756584d 100644
--- a/util/sql/superuser_init.sql
+++ b/sql/superuser_init.sql
@@ -11,5 +11,7 @@ CREATE DATABASE vndb OWNER vndb;
-- The website
CREATE ROLE vndb_site;
+ALTER ROLE vndb_site SET client_min_messages TO WARNING;
+ALTER ROLE vndb_site SET statement_timeout TO 10000;
-- Multi
CREATE ROLE vndb_multi;
diff --git a/util/sql/tableattrs.sql b/sql/tableattrs.sql
index 781e6aad..a707bf50 100644
--- a/util/sql/tableattrs.sql
+++ b/sql/tableattrs.sql
@@ -1,22 +1,88 @@
+-- Indices
+
+CREATE INDEX chars_main ON chars (main) WHERE main IS NOT NULL AND NOT hidden; -- Only used on /c+
+CREATE INDEX chars_vns_vid ON chars_vns (vid);
+CREATE INDEX chars_image ON chars (image);
+CREATE INDEX chars_traits_tid ON chars_traits (tid);
+CREATE UNIQUE INDEX drm_name ON drm (name);
+CREATE UNIQUE INDEX image_votes_pkey ON image_votes (uid, id);
+CREATE INDEX image_votes_id ON image_votes (id);
+CREATE INDEX notifications_uid_iid ON notifications (uid,iid);
+CREATE INDEX quotes_rand ON quotes (rand) WHERE rand IS NOT NULL;
+CREATE INDEX quotes_vid ON quotes (vid);
+CREATE INDEX quotes_addedby ON quotes (addedby);
+CREATE INDEX quotes_log_id ON quotes_log (id);
+CREATE INDEX releases_released ON releases (released) WHERE NOT hidden; -- Mainly for the homepage
+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';
+CREATE INDEX reports_lastmod ON reports (lastmod);
+CREATE INDEX reports_log_id ON reports_log (id);
+CREATE UNIQUE INDEX reviews_vid_uid ON reviews (vid,uid);
+CREATE INDEX reviews_uid ON reviews (uid);
+CREATE INDEX reviews_ts ON reviews USING gin(bb_tsvector(text));
+CREATE INDEX reviews_posts_uid ON reviews_posts (uid);
+CREATE INDEX reviews_posts_ts ON reviews_posts USING gin(bb_tsvector(msg));
+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 UNIQUE INDEX threads_boards_pkey ON threads_boards (tid,type,iid) NULLS NOT DISTINCT;
+CREATE INDEX tags_vn_date ON tags_vn (date);
+CREATE INDEX tags_vn_direct_vid ON tags_vn_direct (vid);
+CREATE INDEX tags_vn_uid ON tags_vn (uid) WHERE uid IS NOT NULL;
+CREATE INDEX tags_vn_vid ON tags_vn (vid);
+CREATE INDEX search_cache_id ON search_cache (id);
+CREATE INDEX search_cache_label ON search_cache USING GIN (label gin_trgm_ops);
+CREATE INDEX shop_playasia__gtin ON shop_playasia (gtin);
+CREATE INDEX threads_posts_date ON threads_posts (date);
+CREATE INDEX threads_posts_ts ON threads_posts USING gin(bb_tsvector(msg));
+CREATE INDEX threads_posts_uid ON threads_posts (uid); -- Only really used on /u+ pages to get stats
+CREATE INDEX traits_chars_cid ON traits_chars (cid);
+CREATE INDEX vn_image ON vn (image);
+CREATE INDEX vn_screenshots_scr ON vn_screenshots (scr);
+CREATE INDEX vn_seiyuu_aid ON vn_seiyuu (aid); -- Only used on /s+?
+CREATE INDEX vn_seiyuu_cid ON vn_seiyuu (cid); -- Only used on /c+?
+CREATE UNIQUE INDEX vn_staff_pkey ON vn_staff (id, eid, aid, role) NULLS NOT DISTINCT;
+CREATE UNIQUE INDEX vn_staff_hist_pkey ON vn_staff_hist (chid, eid, aid, role) NULLS NOT DISTINCT;
+CREATE INDEX vn_staff_aid ON vn_staff (aid);
+CREATE UNIQUE INDEX vn_length_votes_vid_uid ON vn_length_votes (vid, uid);
+CREATE INDEX vn_length_votes_uid ON vn_length_votes (uid);
+CREATE UNIQUE INDEX changes_itemrev ON changes (itemid, rev);
+CREATE UNIQUE INDEX chars_vns_pkey ON chars_vns (id, vid, rid) NULLS NOT DISTINCT;
+CREATE UNIQUE INDEX chars_vns_hist_pkey ON chars_vns_hist (chid, vid, rid) NULLS NOT DISTINCT;
+CREATE INDEX ulist_vns_voted ON ulist_vns (vid, vote_date) WHERE vote IS NOT NULL; -- For VN recent votes & vote graph. INCLUDE(vote) speeds up vote graph even more
+CREATE UNIQUE INDEX users_username_key ON users (lower(username));
+CREATE INDEX users_ign_votes ON users (id) WHERE ign_votes;
+CREATE INDEX users_shadow_mail ON users_shadow (hash_email(mail)); -- Should be UNIQUE, but there was no duplicate check in earlier code
+
+
+
-- Constraints
ALTER TABLE changes ADD CONSTRAINT changes_requester_fkey FOREIGN KEY (requester) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE changes_patrolled ADD CONSTRAINT changes_patrolled_id_fkey FOREIGN KEY (id) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE changes_patrolled ADD CONSTRAINT changes_patrolled_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE chars ADD CONSTRAINT chars_main_fkey FOREIGN KEY (main) REFERENCES chars (id);
+ALTER TABLE chars ADD CONSTRAINT chars_image_fkey FOREIGN KEY (image) REFERENCES images (id);
ALTER TABLE chars_hist ADD CONSTRAINT chars_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE chars_hist ADD CONSTRAINT chars_hist_main_fkey FOREIGN KEY (main) REFERENCES chars (id);
+ALTER TABLE chars_hist ADD CONSTRAINT chars_hist_image_fkey FOREIGN KEY (image) REFERENCES images (id);
ALTER TABLE chars_traits ADD CONSTRAINT chars_traits_id_fkey FOREIGN KEY (id) REFERENCES chars (id);
ALTER TABLE chars_traits ADD CONSTRAINT chars_traits_tid_fkey FOREIGN KEY (tid) REFERENCES traits (id);
ALTER TABLE chars_traits_hist ADD CONSTRAINT chars_traits_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE chars_traits_hist ADD CONSTRAINT chars_traits_hist_tid_fkey FOREIGN KEY (tid) REFERENCES traits (id);
ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_id_fkey FOREIGN KEY (id) REFERENCES chars (id);
-ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id) DEFERRABLE;
-ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) DEFERRABLE;
+ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id) DEFERRABLE;
-ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) DEFERRABLE;
+ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
+ALTER TABLE images ADD CONSTRAINT images_uploader_fkey FOREIGN KEY (uploader) REFERENCES users (id) ON DELETE SET DEFAULT;
+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_rgraph_fkey FOREIGN KEY (rgraph) REFERENCES relgraphs (id);
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);
@@ -24,9 +90,21 @@ ALTER TABLE producers_relations ADD CONSTRAINT producers_relations_pid_fkey
ALTER TABLE producers_relations_hist ADD CONSTRAINT producers_relations_hist_id_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE producers_relations_hist ADD CONSTRAINT producers_relations_hist_pid_fkey FOREIGN KEY (pid) REFERENCES producers (id);
ALTER TABLE quotes ADD CONSTRAINT quotes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_cid_fkey FOREIGN KEY (cid) REFERENCES chars (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_addedby_fkey FOREIGN KEY (addedby) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE releases ADD CONSTRAINT releases_olang_fkey FOREIGN KEY (id,olang) REFERENCES releases_titles(id,lang) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE releases_hist ADD CONSTRAINT releases_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-ALTER TABLE releases_lang ADD CONSTRAINT releases_lang_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
-ALTER TABLE releases_lang_hist ADD CONSTRAINT releases_lang_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE releases_hist ADD CONSTRAINT releases_hist_olang_fkey FOREIGN KEY (chid,olang)REFERENCES releases_titles_hist(chid,lang) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE releases_drm ADD CONSTRAINT releases_drm_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
+ALTER TABLE releases_drm ADD CONSTRAINT releases_drm_drm_fkey FOREIGN KEY (drm) REFERENCES drm (id);
+ALTER TABLE releases_drm_hist ADD CONSTRAINT releases_drm_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE releases_drm_hist ADD CONSTRAINT releases_drm_hist_drm_fkey FOREIGN KEY (drm) REFERENCES drm (id);
+ALTER TABLE releases_titles ADD CONSTRAINT releases_titles_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
+ALTER TABLE releases_titles_hist ADD CONSTRAINT releases_titles_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE releases_media ADD CONSTRAINT releases_media_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
ALTER TABLE releases_media_hist ADD CONSTRAINT releases_media_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE releases_platforms ADD CONSTRAINT releases_platforms_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
@@ -39,45 +117,64 @@ ALTER TABLE releases_vn ADD CONSTRAINT releases_vn_id_fkey
ALTER TABLE releases_vn ADD CONSTRAINT releases_vn_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
ALTER TABLE releases_vn_hist ADD CONSTRAINT releases_vn_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE releases_vn_hist ADD CONSTRAINT releases_vn_hist_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_id_fkey FOREIGN KEY (id) REFERENCES reports (id);
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews ADD CONSTRAINT reviews_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id) ON DELETE CASCADE;
+ALTER TABLE reviews ADD CONSTRAINT reviews_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews ADD CONSTRAINT reviews_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews_posts ADD CONSTRAINT reviews_posts_id_fkey FOREIGN KEY (id) REFERENCES reviews (id) ON DELETE CASCADE;
+ALTER TABLE reviews_posts ADD CONSTRAINT reviews_posts_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews_votes ADD CONSTRAINT reviews_votes_id_fkey FOREIGN KEY (id) REFERENCES reviews (id) ON DELETE CASCADE;
+ALTER TABLE reviews_votes ADD CONSTRAINT reviews_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE rlists ADD CONSTRAINT rlists_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE rlists ADD CONSTRAINT rlists_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
+ALTER TABLE saved_queries ADD CONSTRAINT saved_queries_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE sessions ADD CONSTRAINT sessions_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE staff ADD CONSTRAINT staff_aid_fkey FOREIGN KEY (aid) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE staff ADD CONSTRAINT staff_main_fkey FOREIGN KEY (main) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE staff ADD CONSTRAINT staff_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
ALTER TABLE staff_hist ADD CONSTRAINT staff_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE staff_hist ADD CONSTRAINT staff_hist_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
ALTER TABLE staff_alias ADD CONSTRAINT staff_alias_id_fkey FOREIGN KEY (id) REFERENCES staff (id);
ALTER TABLE staff_alias_hist ADD CONSTRAINT staff_alias_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-ALTER TABLE tags ADD CONSTRAINT tags_addedby_fkey FOREIGN KEY (addedby) REFERENCES users (id) ON DELETE SET DEFAULT;
-ALTER TABLE tags_aliases ADD CONSTRAINT tags_aliases_tag_fkey FOREIGN KEY (tag) REFERENCES tags (id);
-ALTER TABLE tags_parents ADD CONSTRAINT tags_parents_tag_fkey FOREIGN KEY (tag) REFERENCES tags (id);
+ALTER TABLE tags_hist ADD CONSTRAINT tags_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id);
+ALTER TABLE tags_parents ADD CONSTRAINT tags_parents_id_fkey FOREIGN KEY (id) REFERENCES tags (id);
ALTER TABLE tags_parents ADD CONSTRAINT tags_parents_parent_fkey FOREIGN KEY (parent) REFERENCES tags (id);
+ALTER TABLE tags_parents_hist ADD CONSTRAINT tags_parents_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id);
+ALTER TABLE tags_parents_hist ADD CONSTRAINT tags_parents_hist_parent_fkey FOREIGN KEY (parent) REFERENCES tags (id);
ALTER TABLE tags_vn ADD CONSTRAINT tags_vn_tag_fkey FOREIGN KEY (tag) REFERENCES tags (id);
ALTER TABLE tags_vn ADD CONSTRAINT tags_vn_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE tags_vn ADD CONSTRAINT tags_vn_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE threads ADD CONSTRAINT threads_id_fkey FOREIGN KEY (id, count) REFERENCES threads_posts (tid, num) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE tags_vn ADD CONSTRAINT tags_vn_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
ALTER TABLE threads_poll_options ADD CONSTRAINT threads_poll_options_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
-ALTER TABLE threads_poll_votes ADD CONSTRAINT threads_poll_votes_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
ALTER TABLE threads_poll_votes ADD CONSTRAINT threads_poll_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE threads_poll_votes ADD CONSTRAINT threads_poll_votes_optid_fkey FOREIGN KEY (optid) REFERENCES threads_poll_options (id) ON DELETE CASCADE;
-ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id);
+ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
-ALTER TABLE threads_boards ADD CONSTRAINT threads_boards_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id);
-ALTER TABLE traits ADD CONSTRAINT traits_addedby_fkey FOREIGN KEY (addedby) REFERENCES users (id) ON DELETE SET DEFAULT;
-ALTER TABLE traits ADD CONSTRAINT traits_group_fkey FOREIGN KEY ("group") REFERENCES traits (id);
-ALTER TABLE traits_parents ADD CONSTRAINT traits_parents_trait_fkey FOREIGN KEY (trait) REFERENCES traits (id);
+ALTER TABLE threads_boards ADD CONSTRAINT threads_boards_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
+ALTER TABLE traits ADD CONSTRAINT traits_gid_fkey FOREIGN KEY (gid) REFERENCES traits (id);
+ALTER TABLE traits_hist ADD CONSTRAINT traits_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id);
+ALTER TABLE traits_parents ADD CONSTRAINT traits_parents_id_fkey FOREIGN KEY (id) REFERENCES traits (id);
ALTER TABLE traits_parents ADD CONSTRAINT traits_parents_parent_fkey FOREIGN KEY (parent) REFERENCES traits (id);
+ALTER TABLE traits_parents_hist ADD CONSTRAINT traits_parents_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id);
+ALTER TABLE traits_parents_hist ADD CONSTRAINT traits_parents_hist_parent_fkey FOREIGN KEY (parent) REFERENCES traits (id);
ALTER TABLE ulist_labels ADD CONSTRAINT ulist_labels_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE ulist_vns ADD CONSTRAINT ulist_vns_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE ulist_vns ADD CONSTRAINT ulist_vns_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_uid_lbl_fkey FOREIGN KEY (uid,lbl) REFERENCES ulist_labels (uid,id) ON DELETE CASCADE;
-ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_uid_vid_fkey FOREIGN KEY (uid,vid) REFERENCES ulist_vns (uid,vid) ON DELETE CASCADE;
-ALTER TABLE vn ADD CONSTRAINT vn_rgraph_fkey FOREIGN KEY (rgraph) REFERENCES relgraphs (id);
+ALTER TABLE users_prefs ADD CONSTRAINT users_prefs_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE users_prefs_tags ADD CONSTRAINT users_prefs_tags_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE users_prefs_tags ADD CONSTRAINT users_prefs_tags_tid_fkey FOREIGN KEY (tid) REFERENCES tags (id) ON DELETE CASCADE;
+ALTER TABLE users_prefs_traits ADD CONSTRAINT users_prefs_traits_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE users_prefs_traits ADD CONSTRAINT users_prefs_traits_tid_fkey FOREIGN KEY (tid) REFERENCES traits (id) ON DELETE CASCADE;
+ALTER TABLE users_shadow ADD CONSTRAINT users_shadow_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE users_traits ADD CONSTRAINT users_traits_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE users_traits ADD CONSTRAINT users_traits_tid_fkey FOREIGN KEY (tid) REFERENCES traits (id);
+ALTER TABLE users_username_hist ADD CONSTRAINT users_username_hist_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE vn ADD CONSTRAINT vn_image_fkey FOREIGN KEY (image) REFERENCES images (id);
ALTER TABLE vn ADD CONSTRAINT vn_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
+ALTER TABLE vn ADD CONSTRAINT vn_olang_fkey FOREIGN KEY (id,olang) REFERENCES vn_titles (id,lang) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_image_fkey FOREIGN KEY (image) REFERENCES images (id);
ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
+ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_olang_fkey FOREIGN KEY (chid,olang)REFERENCES vn_titles_hist(chid,lang) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE vn_anime ADD CONSTRAINT vn_anime_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
ALTER TABLE vn_anime ADD CONSTRAINT vn_anime_aid_fkey FOREIGN KEY (aid) REFERENCES anime (id);
ALTER TABLE vn_anime_hist ADD CONSTRAINT vn_anime_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
@@ -87,44 +184,20 @@ ALTER TABLE vn_relations ADD CONSTRAINT vn_relations_vid_fkey
ALTER TABLE vn_relations_hist ADD CONSTRAINT vn_relations_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE vn_relations_hist ADD CONSTRAINT vn_relations_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
-ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_scr_fkey FOREIGN KEY (scr) REFERENCES screenshots (id);
-ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) DEFERRABLE;
+ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_scr_fkey FOREIGN KEY (scr) REFERENCES images (id);
+ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_scr_fkey FOREIGN KEY (scr) REFERENCES screenshots (id);
-ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) DEFERRABLE;
+ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_scr_fkey FOREIGN KEY (scr) REFERENCES images (id);
+ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
ALTER TABLE vn_seiyuu ADD CONSTRAINT vn_seiyuu_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
ALTER TABLE vn_seiyuu ADD CONSTRAINT vn_seiyuu_aid_fkey FOREIGN KEY (aid) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE vn_seiyuu ADD CONSTRAINT vn_seiyuu_cid_fkey FOREIGN KEY (cid) REFERENCES chars (id);
ALTER TABLE vn_seiyuu_hist ADD CONSTRAINT vn_seiyuu_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE vn_seiyuu_hist ADD CONSTRAINT vn_seiyuu_hist_cid_fkey FOREIGN KEY (cid) REFERENCES chars (id);
-ALTER TABLE vn_staff ADD CONSTRAINT vn_staff_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
+ALTER TABLE vn_staff ADD CONSTRAINT vn_staff_id_eid_fkey FOREIGN KEY (id,eid) REFERENCES vn_editions (id,eid) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE vn_staff ADD CONSTRAINT vn_staff_aid_fkey FOREIGN KEY (aid) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vn_staff_hist ADD CONSTRAINT vn_staff_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-
-
-
--- Indices
-
-CREATE INDEX chars_main ON chars (main) WHERE main IS NOT NULL AND NOT hidden; -- Only used on /c+
-CREATE INDEX chars_vns_vid ON chars_vns (vid);
-CREATE INDEX notifications_uid ON notifications (uid);
-CREATE INDEX releases_producers_pid ON releases_producers (pid);
-CREATE INDEX releases_vn_vid ON releases_vn (vid);
-CREATE INDEX staff_alias_id ON staff_alias (id);
-CREATE INDEX tags_vn_date ON tags_vn (date);
-CREATE INDEX tags_vn_inherit_tag_vid ON tags_vn_inherit (tag, vid);
-CREATE INDEX tags_vn_uid ON tags_vn (uid);
-CREATE INDEX shop_playasia__gtin ON shop_playasia (gtin);
-CREATE INDEX tags_vn_vid ON tags_vn (vid);
-CREATE INDEX threads_posts_date ON threads_posts (date);
-CREATE INDEX threads_posts_ts ON threads_posts USING gin(bb_tsvector(msg));
-CREATE INDEX threads_posts_uid ON threads_posts (uid); -- Only really used on /u+ pages to get stats
-CREATE INDEX traits_chars_tid ON traits_chars (tid);
-CREATE INDEX vn_seiyuu_aid ON vn_seiyuu (aid); -- Only used on /s+?
-CREATE INDEX vn_seiyuu_cid ON vn_seiyuu (cid); -- Only used on /c+?
-CREATE INDEX vn_staff_aid ON vn_staff (aid);
-CREATE UNIQUE INDEX changes_itemrev ON changes (type, itemid, rev);
-CREATE UNIQUE INDEX chars_vns_pkey ON chars_vns (id, vid, COALESCE(rid, 0));
-CREATE UNIQUE INDEX chars_vns_hist_pkey ON chars_vns_hist (chid, vid, COALESCE(rid, 0));
-CREATE INDEX ulist_vns_voted ON ulist_vns (vid, vote_date) WHERE vote IS NOT NULL; -- For VN recent votes & vote graph. INCLUDE(vote) speeds up vote graph even more
-CREATE INDEX users_ign_votes ON users (id) WHERE ign_votes;
+ALTER TABLE vn_staff_hist ADD CONSTRAINT vn_staff_hist_chid_eid_fkey FOREIGN KEY (chid,eid) REFERENCES vn_editions_hist (chid,eid) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE vn_titles ADD CONSTRAINT vn_titles_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
+ALTER TABLE vn_titles_hist ADD CONSTRAINT vn_titles_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
diff --git a/util/sql/triggers.sql b/sql/triggers.sql
index a8ef3bbc..dc03feb5 100644
--- a/util/sql/triggers.sql
+++ b/sql/triggers.sql
@@ -1,4 +1,5 @@
--- keep the c_tags and c_changes columns in the users table up to date
+-- keep the c_tags, c_changes and c_imgvotes columns in the users table up to date
+-- Assumption: The column referencing the user is never modified.
CREATE OR REPLACE FUNCTION update_users_cache() RETURNS trigger AS $$
BEGIN
@@ -14,13 +15,20 @@ BEGIN
ELSE
UPDATE users SET c_tags = c_tags - 1 WHERE id = OLD.uid;
END IF;
+ ELSIF TG_TABLE_NAME = 'image_votes' THEN
+ IF TG_OP = 'INSERT' THEN
+ UPDATE users SET c_imgvotes = c_imgvotes + 1 WHERE id = NEW.uid;
+ ELSE
+ UPDATE users SET c_imgvotes = c_imgvotes - 1 WHERE id = OLD.uid;
+ END IF;
END IF;
RETURN NULL;
END;
$$ LANGUAGE 'plpgsql';
-CREATE TRIGGER users_changes_update AFTER INSERT OR DELETE ON changes FOR EACH ROW EXECUTE PROCEDURE update_users_cache();
-CREATE TRIGGER users_tags_update AFTER INSERT OR DELETE ON tags_vn FOR EACH ROW EXECUTE PROCEDURE update_users_cache();
+CREATE TRIGGER users_changes_update AFTER INSERT OR DELETE ON changes FOR EACH ROW EXECUTE PROCEDURE update_users_cache();
+CREATE TRIGGER users_tags_update AFTER INSERT OR DELETE ON tags_vn FOR EACH ROW EXECUTE PROCEDURE update_users_cache();
+CREATE TRIGGER users_imgvotes_update AFTER INSERT OR DELETE ON image_votes FOR EACH ROW EXECUTE PROCEDURE update_users_cache();
@@ -28,45 +36,16 @@ CREATE TRIGGER users_tags_update AFTER INSERT OR DELETE ON tags_vn FOR EACH R
-- the stats_cache table
CREATE OR REPLACE FUNCTION update_stats_cache() RETURNS TRIGGER AS $$
-DECLARE
- unhidden boolean;
- hidden boolean;
BEGIN
IF TG_OP = 'INSERT' THEN
- IF TG_TABLE_NAME = 'users' THEN
- UPDATE stats_cache SET count = count+1 WHERE section = TG_TABLE_NAME;
- ELSE
- IF TG_TABLE_NAME = 'threads_posts' THEN
- IF EXISTS(SELECT 1 FROM threads WHERE id = NEW.tid AND threads.hidden = FALSE) THEN
- UPDATE stats_cache SET count = count+1 WHERE section = TG_TABLE_NAME;
- END IF;
- ELSE
- UPDATE stats_cache SET count = count+1 WHERE section = TG_TABLE_NAME;
- END IF;
- END IF;
+ UPDATE stats_cache SET count = count+1 WHERE section = TG_TABLE_NAME;
ELSIF TG_OP = 'UPDATE' THEN
- IF TG_TABLE_NAME IN('tags', 'traits') THEN
- unhidden := OLD.state <> 2 AND NEW.state = 2;
- hidden := OLD.state = 2 AND NEW.state <> 2;
- ELSE
- unhidden := OLD.hidden AND NOT NEW.hidden;
- hidden := NOT unhidden;
- END IF;
- IF unhidden THEN
- IF TG_TABLE_NAME = 'threads' THEN
- UPDATE stats_cache SET count = count+NEW.count WHERE section = 'threads_posts';
- END IF;
+ IF OLD.hidden AND NOT NEW.hidden THEN
UPDATE stats_cache SET count = count+1 WHERE section = TG_TABLE_NAME;
- ELSIF hidden THEN
- IF TG_TABLE_NAME = 'threads' THEN
- UPDATE stats_cache SET count = count-NEW.count WHERE section = 'threads_posts';
- END IF;
+ ELSIF NEW.hidden AND NOT OLD.hidden THEN
UPDATE stats_cache SET count = count-1 WHERE section = TG_TABLE_NAME;
END IF;
-
- ELSIF TG_OP = 'DELETE' AND TG_TABLE_NAME = 'users' THEN
- UPDATE stats_cache SET count = count-1 WHERE section = TG_TABLE_NAME;
END IF;
RETURN NULL;
END;
@@ -82,15 +61,10 @@ CREATE TRIGGER stats_cache_new AFTER INSERT ON chars FOR EAC
CREATE TRIGGER stats_cache_edit AFTER UPDATE ON chars FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
CREATE TRIGGER stats_cache_new AFTER INSERT ON staff FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
CREATE TRIGGER stats_cache_edit AFTER UPDATE ON staff FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_new AFTER INSERT ON tags FOR EACH ROW WHEN (NEW.state = 2) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON tags FOR EACH ROW WHEN (OLD.state IS DISTINCT FROM NEW.state) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_new AFTER INSERT ON traits FOR EACH ROW WHEN (NEW.state = 2) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON traits FOR EACH ROW WHEN (OLD.state IS DISTINCT FROM NEW.state) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_new AFTER INSERT ON threads FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON threads FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_new AFTER INSERT ON threads_posts FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON threads_posts FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache AFTER INSERT OR DELETE ON users FOR EACH ROW EXECUTE PROCEDURE update_stats_cache();
+CREATE TRIGGER stats_cache_new AFTER INSERT ON tags FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
+CREATE TRIGGER stats_cache_edit AFTER UPDATE ON tags FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
+CREATE TRIGGER stats_cache_new AFTER INSERT ON traits FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
+CREATE TRIGGER stats_cache_edit AFTER UPDATE ON traits FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
@@ -193,139 +167,167 @@ CREATE TRIGGER ulist_labels_create AFTER INSERT ON users FOR EACH ROW EXECUTE PR
CREATE OR REPLACE FUNCTION ulist_voted_label() RETURNS trigger AS $$
BEGIN
- IF NEW.vote IS NULL THEN
- DELETE FROM ulist_vns_labels WHERE uid = NEW.uid AND vid = NEW.vid AND lbl = 7;
- ELSE
- INSERT INTO ulist_vns_labels (uid, vid, lbl) VALUES (NEW.uid, NEW.vid, 7) ON CONFLICT (uid, vid, lbl) DO NOTHING;
- END IF;
- RETURN NULL;
+ NEW.labels := CASE WHEN NEW.vote IS NULL THEN array_remove(NEW.labels, 7) ELSE array_set(NEW.labels, 7) END;
+ RETURN NEW;
END
$$ LANGUAGE plpgsql;
-CREATE TRIGGER ulist_voted_label AFTER INSERT OR UPDATE ON ulist_vns FOR EACH ROW EXECUTE PROCEDURE ulist_voted_label();
+CREATE TRIGGER ulist_voted_label_ins BEFORE INSERT ON ulist_vns FOR EACH ROW EXECUTE PROCEDURE ulist_voted_label();
+CREATE TRIGGER ulist_voted_label_upd BEFORE UPDATE ON ulist_vns FOR EACH ROW WHEN ((OLD.vote IS NULL) <> (NEW.vote IS NULL)) EXECUTE PROCEDURE ulist_voted_label();
--- 1. Send a notify when vn.rgraph is set to NULL, and there are related entries in vn_relations
--- 2. Set rgraph to NULL when c_languages or c_released has changed
+-- NOTIFY on insert into changes/posts/reviews
-CREATE OR REPLACE FUNCTION vn_relgraph_notify() RETURNS trigger AS $$
+CREATE OR REPLACE FUNCTION insert_notify() RETURNS trigger AS $$
BEGIN
- IF EXISTS(SELECT 1 FROM vn_relations WHERE id = NEW.id) THEN
- -- 1.
- IF NEW.rgraph IS NULL THEN
- NOTIFY relgraph;
- -- 2.
- ELSE
- UPDATE vn SET rgraph = NULL WHERE id = NEW.id;
- END IF;
+ IF TG_TABLE_NAME = 'changes' THEN
+ NOTIFY newrevision;
+ ELSIF TG_TABLE_NAME = 'threads_posts' THEN
+ NOTIFY newpost;
+ ELSIF TG_TABLE_NAME = 'reviews' THEN
+ NOTIFY newreview;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-CREATE TRIGGER vn_relgraph_notify AFTER UPDATE ON vn FOR EACH ROW
- WHEN ((OLD.rgraph IS NOT NULL AND NEW.rgraph IS NULL)
- OR (NEW.rgraph IS NOT NULL AND (OLD.c_released IS DISTINCT FROM NEW.c_released OR OLD.c_languages IS DISTINCT FROM NEW.c_languages))
- ) EXECUTE PROCEDURE vn_relgraph_notify();
+CREATE TRIGGER insert_notify AFTER INSERT ON changes FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
+CREATE TRIGGER insert_notify AFTER INSERT ON threads_posts FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
+CREATE TRIGGER insert_notify AFTER INSERT ON reviews FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
--- Send a notify when producers.rgraph is set to NULL and there are related entries in producers_relations
+-- Create notifications for new posts.
-CREATE OR REPLACE FUNCTION producer_relgraph_notify() RETURNS trigger AS $$
+CREATE OR REPLACE FUNCTION notify_post() RETURNS trigger AS $$
BEGIN
- IF EXISTS(SELECT 1 FROM producers_relations WHERE id = NEW.id) THEN
- NOTIFY relgraph;
- END IF;
+ 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 producer_relgraph_notify AFTER UPDATE ON producers FOR EACH ROW WHEN (OLD.rgraph IS NOT NULL AND NEW.rgraph IS NULL) EXECUTE PROCEDURE producer_relgraph_notify();
+CREATE TRIGGER notify_post AFTER INSERT ON threads_posts FOR EACH ROW EXECUTE PROCEDURE notify_post();
--- NOTIFY on insert into changes/posts/tags/trait
+-- Create notifications for new review comments.
-CREATE OR REPLACE FUNCTION insert_notify() RETURNS trigger AS $$
+CREATE OR REPLACE FUNCTION notify_comment() RETURNS trigger AS $$
BEGIN
- IF TG_TABLE_NAME = 'changes' THEN
- NOTIFY newrevision;
- ELSIF TG_TABLE_NAME = 'threads_posts' THEN
- NOTIFY newpost;
- ELSIF TG_TABLE_NAME = 'tags' THEN
- NOTIFY newtag;
- ELSIF TG_TABLE_NAME = 'traits' THEN
- NOTIFY newtrait;
- END IF;
+ 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 insert_notify AFTER INSERT ON changes FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
-CREATE TRIGGER insert_notify AFTER INSERT ON threads_posts FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
-CREATE TRIGGER insert_notify AFTER INSERT ON tags FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
-CREATE TRIGGER insert_notify AFTER INSERT ON traits FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
+CREATE TRIGGER notify_comment AFTER INSERT ON reviews_posts FOR EACH ROW EXECUTE PROCEDURE notify_comment();
--- Send a vnsearch notification when the c_search column is set to NULL.
+-- Create notifications for new reviews.
-CREATE OR REPLACE FUNCTION vn_vnsearch_notify() RETURNS trigger AS 'BEGIN NOTIFY vnsearch; RETURN NULL; END;' LANGUAGE plpgsql;
+CREATE OR REPLACE FUNCTION notify_review() RETURNS trigger AS $$
+BEGIN
+ 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 vn_vnsearch_notify AFTER UPDATE ON vn FOR EACH ROW WHEN (OLD.c_search IS NOT NULL AND NEW.c_search IS NULL) EXECUTE PROCEDURE vn_vnsearch_notify();
+CREATE TRIGGER notify_review AFTER INSERT ON reviews FOR EACH ROW EXECUTE PROCEDURE notify_review();
--- Add a notification when someone posts in someone's board.
+-- Update threads.c_count and c_lastnum
-CREATE OR REPLACE FUNCTION notify_pm() RETURNS trigger AS $$
+CREATE OR REPLACE FUNCTION update_threads_cache() RETURNS trigger AS $$
BEGIN
- INSERT INTO notifications (ntype, ltype, uid, iid, subid, c_title, c_byuser)
- SELECT 'pm', 't', 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.ntype = 'pm'
- AND n.iid = t.id
- AND n.read IS NULL
- );
+ UPDATE threads
+ SET c_count = (SELECT COUNT(*) FROM threads_posts WHERE hidden IS NULL AND tid = threads.id)
+ , c_lastnum = (SELECT MAX(num) FROM threads_posts WHERE hidden IS NULL AND tid = threads.id)
+ WHERE id IN(OLD.tid,NEW.tid);
RETURN NULL;
-END;
+END
$$ LANGUAGE plpgsql;
-CREATE TRIGGER notify_pm AFTER INSERT ON threads_posts FOR EACH ROW EXECUTE PROCEDURE notify_pm();
+CREATE TRIGGER update_threads_cache AFTER INSERT OR UPDATE OR DELETE ON threads_posts FOR EACH ROW EXECUTE PROCEDURE update_threads_cache();
--- Add a notification when a thread is created in /t/an
+-- Update reviews.c_count and c_lastnum
-CREATE OR REPLACE FUNCTION notify_announce() RETURNS trigger AS $$
+CREATE OR REPLACE FUNCTION update_reviews_cache() RETURNS trigger AS $$
BEGIN
- INSERT INTO notifications (ntype, ltype, uid, iid, subid, c_title, c_byuser)
- SELECT 'announce', 't', 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;
+ UPDATE reviews
+ SET c_count = COALESCE((SELECT COUNT(*) FROM reviews_posts WHERE hidden IS NULL AND id = reviews.id), 0)
+ , c_lastnum = (SELECT MAX(num) FROM reviews_posts WHERE hidden IS NULL AND id = reviews.id)
+ WHERE id IN(OLD.id,NEW.id);
RETURN NULL;
-END;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER update_reviews_cache AFTER INSERT OR UPDATE OR DELETE ON reviews_posts FOR EACH ROW EXECUTE PROCEDURE update_reviews_cache();
+
+
+
+
+-- Call update_vn_length_cache() for every change on vn_length_votes
+
+CREATE OR REPLACE FUNCTION update_vn_length_cache() RETURNS trigger AS $$
+BEGIN
+ PERFORM update_vn_length_cache(id) FROM (SELECT OLD.vid UNION SELECT NEW.vid) AS x(id) WHERE id IS NOT NULL;
+ RETURN NULL;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER vn_length_cache AFTER INSERT OR UPDATE OR DELETE ON vn_length_votes FOR EACH ROW EXECUTE PROCEDURE update_vn_length_cache();
+
+
+
+
+-- Call update_images_cache() for every change on image_votes
+
+CREATE OR REPLACE FUNCTION update_images_cache() RETURNS trigger AS $$
+BEGIN
+ PERFORM update_images_cache(id) FROM (SELECT OLD.id UNION SELECT NEW.id) AS x(id) WHERE id IS NOT NULL;
+ RETURN NULL;
+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 <> NEW.id OR (OLD.sexual, OLD.violence, OLD.ignore) IS DISTINCT FROM (NEW.sexual, NEW.violence, NEW.ignore)) EXECUTE PROCEDURE update_images_cache();
+
+
+
+
+-- Call update_reviews_votes_cache() for every change on reviews_votes
+
+CREATE OR REPLACE FUNCTION update_reviews_votes_cache() RETURNS trigger AS $$
+BEGIN
+ PERFORM update_reviews_votes_cache(id) FROM (SELECT OLD.id UNION SELECT NEW.id) AS x(id) WHERE id IS NOT NULL;
+ RETURN NULL;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER reviews_votes_cache AFTER INSERT OR UPDATE OR DELETE ON reviews_votes FOR EACH ROW EXECUTE PROCEDURE update_reviews_votes_cache();
+
+
+
+
+-- Update quotes.score on every change to quotes_votes
+
+CREATE OR REPLACE FUNCTION update_quotes_votes_cache() RETURNS trigger AS $$
+BEGIN
+ UPDATE quotes
+ SET score = COALESCE((SELECT SUM(vote) FROM quotes_votes WHERE quotes_votes.id = quotes.id), 0)
+ WHERE id IN(OLD.id, NEW.id);
+ 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 quotes_votes_cache AFTER INSERT OR UPDATE OR DELETE ON quotes_votes FOR EACH ROW EXECUTE PROCEDURE update_quotes_votes_cache();
diff --git a/sql/util.sql b/sql/util.sql
new file mode 100644
index 00000000..0483c9a2
--- /dev/null
+++ b/sql/util.sql
@@ -0,0 +1,157 @@
+-- This file is for generic utility functions that do not depend on the data schema.
+-- It should be loaded before schema.sql.
+
+
+-- Add an element in the correct position to an already sorted array.
+-- The array is not modified if the element already exists.
+-- This function is probably quite slow, don't use in contexts where performance matters.
+CREATE OR REPLACE FUNCTION array_set(arr anycompatiblearray, elem anycompatible) RETURNS anycompatiblearray AS $$
+DECLARE
+ ret arr%TYPE;
+ e elem%TYPE;
+ added boolean := false;
+BEGIN
+ FOREACH e IN ARRAY arr LOOP
+ IF e = elem THEN RETURN arr;
+ ELSIF added or e < elem THEN ret := ret || e;
+ ELSE
+ ret := ret || elem || e;
+ added := true;
+ END IF;
+ END LOOP;
+ RETURN CASE WHEN added THEN ret ELSE ret || elem END;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+-- Some tests.
+--SELECT array_set(ARRAY[1,2,3,8], 9) = ARRAY[1,2,3,8,9]
+-- , array_set(ARRAY[1,2,3,8], 0) = ARRAY[0,1,2,3,8]
+-- , array_set(ARRAY[1,2,3,8], 2) = ARRAY[1,2,3,8]
+-- , array_set(ARRAY[1,2,3,8], 8) = ARRAY[1,2,3,8]
+-- , array_set(ARRAY[1,2,3,8], 5) = ARRAY[1,2,3,5,8]
+-- , array_set(ARRAY[8,3,2,1], 3) = ARRAY[8,3,2,1] -- Also works on unsorted arrays
+-- , array_set(ARRAY[8,3,2,1], 5) = ARRAY[5,8,3,2,1]; -- But then the output is also unsorted
+
+
+
+-- strip_bb_tags(text) - simple utility function to aid full-text searching
+CREATE OR REPLACE FUNCTION strip_bb_tags(t text) RETURNS text AS $$
+ SELECT regexp_replace(t, '\[(?:url=[^\]]+|/?(?:spoiler|quote|raw|code|url))\]', ' ', 'gi');
+$$ LANGUAGE sql IMMUTABLE;
+
+-- Wrapper around to_tsvector() and strip_bb_tags(), implemented in plpgsql and
+-- with an associated cost function to make it opaque to the query planner and
+-- ensure the query planner realizes that this function is _slow_.
+CREATE OR REPLACE FUNCTION bb_tsvector(t text) RETURNS tsvector AS $$
+BEGIN
+ RETURN to_tsvector('english', public.strip_bb_tags(t));
+END;
+$$ LANGUAGE plpgsql IMMUTABLE COST 500;
+
+-- BUG: Since this isn't a full bbcode parser, [spoiler] tags inside [raw] or [code] are still considered spoilers.
+CREATE OR REPLACE FUNCTION strip_spoilers(t text) RETURNS text AS $$
+ -- The website doesn't require the [spoiler] tag to be closed, the outer replace catches that case.
+ SELECT regexp_replace(regexp_replace(t, '\[spoiler\].*?\[/spoiler\]', ' ', 'ig'), '\[spoiler\].*', ' ', 'i');
+$$ LANGUAGE sql IMMUTABLE;
+
+
+-- Assigns a score to the relevance of a substring match, intended for use in
+-- an ORDER BY clause. Exact matches are ordered first, prefix matches after
+-- that, and finally a normal substring match. Not particularly fast, but
+-- that's to be expected of naive substring searches.
+-- Pattern must be escaped for use as a LIKE pattern.
+CREATE OR REPLACE FUNCTION substr_score(str text, pattern text) RETURNS integer AS $$
+SELECT CASE
+ WHEN str ILIKE pattern THEN 0
+ WHEN str ILIKE pattern||'%' THEN 1
+ WHEN str ILIKE '%'||pattern||'%' THEN 2
+ ELSE 3
+END;
+$$ LANGUAGE SQL;
+
+
+-- Convenient function to match the first character of a string. Second argument must be lowercase 'a'-'z' or '0'.
+-- Postgres can inline and partially evaluate this function into the query plan, so it's fairly efficient.
+CREATE OR REPLACE FUNCTION match_firstchar(str text, chr text) RETURNS boolean AS $$
+ SELECT CASE WHEN chr = '0'
+ THEN (ascii(str) < 97 OR ascii(str) > 122) AND (ascii(str) < 65 OR ascii(str) > 90)
+ ELSE ascii(str) IN(ascii(chr),ascii(upper(chr)))
+ END;
+$$ LANGUAGE SQL IMMUTABLE;
+
+
+-- Helper function for search normalization
+CREATE OR REPLACE FUNCTION search_norm_term(str text) RETURNS text AS $$
+ SELECT regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(
+ translate(lower(public.unaccent(normalize(str, NFKC))), $s$@,_-‐.~~〜∼ー῀:[]()%+!?#$`♥★☆♪†「」『』【】・<>'$s$, 'a'), -- '
+ '\s+', '', 'g'),
+ '&', 'and', 'g'),
+ 'disc', 'disk', 'g'),
+ 'gray', 'grey', 'g'),
+ 'colour', 'color', 'g'),
+ 'senpai', 'sempai', 'g');
+$$ LANGUAGE SQL IMMUTABLE;
+
+
+-- Split a search query into LIKE patterns.
+-- Supports double quoting for adjacent terms.
+-- e.g. 'SEARCH que.ry "word here"' -> '{%search%,%query%,%wordhere%}'
+--
+-- Can be efficiently used as follows: label LIKE ALL (search_query('query here'))
+CREATE OR REPLACE FUNCTION search_query(q text) RETURNS text[] AS $$
+DECLARE
+ tmp text;
+ ret text[];
+BEGIN
+ ret := ARRAY[]::text[];
+ LOOP
+ q := regexp_replace(q, '^\s+', '');
+ IF q = '' THEN EXIT;
+ ELSIF q ~ '^"[^"]+"' THEN
+ tmp := regexp_replace(q, '^"([^"]+)".*$', '\1', '');
+ q := regexp_replace(q, '^"[^"]+"', '', '');
+ ELSE
+ tmp := regexp_replace(q, '^([^\s]+).*$', '\1', '');
+ q := regexp_replace(q, '^[^\s]+', '', '');
+ END IF;
+
+ tmp := '%'||search_norm_term(tmp)||'%';
+ IF length(tmp) > 2 AND NOT (ARRAY[tmp] <@ ret) THEN
+ ret := array_append(ret, tmp);
+ END IF;
+ END LOOP;
+ RETURN ret;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+
+-- E-mail normalization, used for account lookup and to provide a strong account opt-out.
+-- Totally imperfect, of course, but it catches common cases.
+-- Based on https://dev.maxmind.com/minfraud/normalizing-email-addresses-for-minfraud
+-- except this function assumes the address has already been validated.
+CREATE OR REPLACE FUNCTION norm_email(email text) RETURNS text AS $$
+ WITH n1 (u,d) AS (
+ SELECT lower(regexp_replace(email, '^(.+)@.+$', '\1')),
+ lower(regexp_replace(email, '^.+@(.+)$', '\1'))
+ ), n2 (u,d) AS (
+ SELECT u, CASE WHEN d = 'googlemail.com' THEN 'gmail.com'
+ WHEN d IN('pm.me', 'proton.me') THEN 'protonmail.com'
+ WHEN d IN('yandex.by', 'yandex.com', 'yandex.kz', 'yandex.ua', 'ya.ru') THEN 'yandex.ru'
+ ELSE d END FROM n1
+ ), n3 (u,d) AS (
+ SELECT CASE WHEN d IN('myyahoo.com', 'ymail.com', 'y7mail.com') OR d ~ '^yahoo.(ca|cl|cn|co|co\.id|co\.il|co\.in|co\.jp|co\.kr|com\.ar|com\.au|com\.br|com\.cn|com\.hk|com\.mx|com\.my|com\.ph|com\.sg|com\.tr|com\.tw|com\.vn|co\.nz|co\.th|co\.uk|co\.za|de|dk|es|fr|gr|hu|ie|in|it|ne\.jp|nl|no|pl|ro|se)$'
+ THEN regexp_replace(u, '-.*$', '')
+ ELSE regexp_replace(u, '\+.*$', '')
+ END, d FROM n2
+ ), n4 (u,d) AS (
+ SELECT CASE WHEN d = 'gmail.com' THEN regexp_replace(u, '\.', '', 'g') ELSE u END, d FROM n3
+ ) SELECT regexp_replace(u || '@' || d, -- https://www.fastmail.com/about/ourdomains/
+ '^.+@(.+)\.(123mail\.org|150mail\.com|150ml\.com|16mail\.com|2-mail\.com|4email\.net|50mail\.com|airpost\.net|allmail\.net|cluemail\.com|elitemail\.org|emailcorner\.net|emailengine\.net|emailengine\.org|emailgroups\.net|emailplus\.org|emailuser\.net|eml\.cc|f-m\.fm|fast-email\.com|fast-mail\.org|fastem\.com|fastemailer\.com|fastest\.cc|fastimap\.com|fastmail\.cn|fastmail\.co\.uk|fastmail\.com|fastmail\.com\.au|fastmail\.de|fastmail\.es|fastmail\.fm|fastmail\.fr|fastmail\.im|fastmail\.in|fastmail\.jp|fastmail\.mx|fastmail\.net|fastmail\.nl|fastmail\.org|fastmail\.se|fastmail\.to|fastmail\.tw|fastmail\.uk|fastmailbox\.net|fastmessaging\.com|fea\.st|fmail\.co\.uk|fmailbox\.com|fmgirl\.com|fmguy\.com|ftml\.net|hailmail\.net|imap-mail\.com|imap\.cc|imapmail\.org|inoutbox\.com|internet-e-mail\.com|internet-mail\.org|internetemails\.net|internetmailing\.net|jetemail\.net|justemail\.net|letterboxes\.org|mail-central\.com|mail-page\.com|mailas\.com|mailbolt\.com|mailc\.net|mailcan\.com|mailforce\.net|mailhaven\.com|mailingaddress\.org|mailite\.com|mailmight\.com|mailnew\.com|mailsent\.net|mailservice\.ms|mailup\.net|mailworks\.org|ml1\.net|mm\.st|myfastmail\.com|mymacmail\.com|nospammail\.net|ownmail\.net|petml\.com|postinbox\.com|postpro\.net|proinbox\.com|promessage\.com|realemail\.net|reallyfast\.biz|reallyfast\.info|rushpost\.com|sent\.as|sent\.at|sent\.com|speedpost\.net|speedymail\.org|ssl-mail\.com|swift-mail\.com|the-fastest\.net|the-quickest\.com|theinternetemail\.com|veryfast\.biz|veryspeedy\.net|warpmail\.net|xsmail\.com|yepmail\.net|your-mail\.com)$', '\1@\2')
+ FROM n4
+$$ LANGUAGE SQL IMMUTABLE;
+
+--SELECT norm_email('T.E.S.T+alias+2@GoogleMail.com') = 'test@gmail.com'
+-- , norm_email('hello-alias-2@yahoo.co.jp') = 'hello@yahoo.co.jp'
+-- , norm_email('somename@hello.4email.net') = 'hello@4email.net';
+
+CREATE OR REPLACE FUNCTION hash_email(email text) RETURNS uuid LANGUAGE SQL IMMUTABLE RETURN md5(norm_email(email))::uuid;
diff --git a/sql/vndbid.sql b/sql/vndbid.sql
new file mode 100644
index 00000000..385435c6
--- /dev/null
+++ b/sql/vndbid.sql
@@ -0,0 +1,88 @@
+-- This file defines a custom 'vndbid' base type and a bunch of utility functions.
+-- This file must be loaded into the 'vndb' database as a superuser, e.g.:
+--
+-- psql -U postgres vndb -f sql/vndbid.sql
+--
+-- A 'vndbid' represents an identifier used on the site and is essentially a
+-- (type,number) tuple, e.g. 'v17', 'r102', 'sf500'. It is not strictly limited
+-- to database entries with an edit history, any type-prefixed integer could be
+-- added here.
+--
+-- Main advantage of this type is convenience and domain separation. Comparing
+-- vndbids of different types will always return false, so it's less prone to
+-- errors. Values are interally represented as a 32bit integer, so they're
+-- pretty efficient as well.
+--
+-- Constructing an ID:
+--
+-- 'v1'::vndbid
+-- vndbid('v', 1)
+--
+-- Extracting info:
+--
+-- vndbid_type('v1') -- 'v'
+-- vndbid_num('v1') -- 1
+--
+-- Efficient filtering on the type:
+--
+-- id BETWEEN 'v1' AND vndbid_max('v')
+--
+-- Is equivalent to, but faster than:
+--
+-- vndbid_type(id) = 'v'
+--
+CREATE TYPE vndbid;
+
+CREATE FUNCTION vndbid_in(cstring) RETURNS vndbid AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_out(vndbid) RETURNS cstring AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_recv(internal) RETURNS vndbid AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_send(vndbid) RETURNS bytea AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_cmp(vndbid, vndbid) RETURNS int AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_lt(vndbid, vndbid) RETURNS boolean AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_le(vndbid, vndbid) RETURNS boolean AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_eq(vndbid, vndbid) RETURNS boolean AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_ge(vndbid, vndbid) RETURNS boolean AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_gt(vndbid, vndbid) RETURNS boolean AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_ne(vndbid, vndbid) RETURNS boolean AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_sortsupport(internal) RETURNS void AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_hash(vndbid) RETURNS int AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid(text, int) RETURNS vndbid AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_type(vndbid) RETURNS text AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_num(vndbid) RETURNS int AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+CREATE FUNCTION vndbid_max(text) RETURNS vndbid AS 'vndbfuncs' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
+
+CREATE TYPE vndbid (
+ internallength = 4,
+ input = vndbid_in,
+ output = vndbid_out,
+ receive = vndbid_recv,
+ send = vndbid_send,
+ alignment = int4,
+ passedbyvalue
+);
+
+CREATE OPERATOR < (leftarg = vndbid, rightarg = vndbid, procedure = vndbid_lt, commutator = > , negator = >=, restrict = scalarltsel, join = scalarltjoinsel);
+CREATE OPERATOR <= (leftarg = vndbid, rightarg = vndbid, procedure = vndbid_le, commutator = >=, negator = > , restrict = scalarlesel, join = scalarlejoinsel);
+CREATE OPERATOR = (leftarg = vndbid, rightarg = vndbid, procedure = vndbid_eq, commutator = = , negator = <>, restrict = eqsel, join = eqjoinsel, HASHES, MERGES);
+CREATE OPERATOR <> (leftarg = vndbid, rightarg = vndbid, procedure = vndbid_ne, commutator = <>, negator = =, restrict = neqsel, join = neqjoinsel);
+CREATE OPERATOR >= (leftarg = vndbid, rightarg = vndbid, procedure = vndbid_ge, commutator = <=, negator = < , restrict = scalargesel, join = scalargejoinsel);
+CREATE OPERATOR > (leftarg = vndbid, rightarg = vndbid, procedure = vndbid_gt, commutator = < , negator = <=, restrict = scalargtsel, join = scalargtjoinsel);
+
+CREATE OPERATOR CLASS vndbid_btree_ops DEFAULT FOR TYPE vndbid USING btree AS
+ OPERATOR 1 <,
+ OPERATOR 2 <=,
+ OPERATOR 3 =,
+ OPERATOR 4 >=,
+ OPERATOR 5 >,
+ FUNCTION 1 vndbid_cmp(vndbid, vndbid),
+ FUNCTION 2 vndbid_sortsupport(internal),
+ FUNCTION 4 btequalimage(oid);
+
+CREATE OPERATOR CLASS vndbid_hash_ops DEFAULT FOR TYPE vndbid USING hash AS
+ OPERATOR 1 =,
+ FUNCTION 1 vndbid_hash(vndbid);
+
+
+-- Unrelated to the vndbid type, but put here because this file is, ultimately, where all extensions are loaded.
+CREATE EXTENSION unaccent;
+CREATE EXTENSION pg_trgm;
diff --git a/static/f/16-9.svg b/static/f/16-9.svg
deleted file mode 100644
index 50f8935f..00000000
--- a/static/f/16-9.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="512pt" height="402pt" viewBox="0 0 512 402" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 20.20 0.00 L 491.80 0.00 C 501.17 1.97 509.71 8.82 512.00 18.33 L 512.00 383.77 C 508.98 392.91 501.65 400.88 491.70 402.00 L 20.20 402.00 C 10.83 400.03 2.29 393.18 0.00 383.67 L 0.00 18.33 C 2.29 8.82 10.83 1.97 20.20 0.00 M 149.15 144.17 C 135.56 169.04 135.84 198.61 137.78 226.05 C 139.54 245.53 144.72 266.51 160.25 279.74 C 174.59 293.46 196.42 295.97 214.97 290.93 C 231.62 286.26 244.76 272.34 250.17 256.12 C 254.52 241.20 253.98 224.78 248.91 210.09 C 241.03 189.94 219.16 173.91 196.99 177.85 C 186.90 178.38 178.74 185.05 171.31 191.21 C 172.61 178.97 173.42 166.21 179.01 155.00 C 183.24 146.13 193.89 140.77 203.57 143.44 C 214.10 145.07 215.99 160.37 227.01 160.87 C 237.29 162.09 246.48 152.14 244.14 141.97 C 241.44 132.79 233.42 126.26 225.73 121.26 C 199.55 107.74 163.29 117.93 149.15 144.17 M 393.60 122.55 C 383.36 128.52 375.58 138.15 370.91 148.95 C 366.72 159.36 366.61 170.92 367.12 181.97 C 367.96 205.15 386.07 226.96 409.12 230.84 C 423.32 233.46 437.71 227.63 448.22 218.16 C 446.58 231.83 446.56 247.18 437.35 258.35 C 430.57 266.78 416.13 269.14 408.57 260.52 C 403.42 254.34 396.77 245.35 387.44 248.45 C 381.26 249.13 377.28 254.92 375.30 260.31 C 373.81 269.32 379.96 277.12 386.32 282.66 C 398.98 293.49 417.14 295.31 432.96 292.01 C 451.18 288.38 466.70 274.93 473.91 257.95 C 482.84 237.64 483.45 214.86 482.81 193.04 C 481.59 172.55 478.53 150.47 464.72 134.27 C 447.85 113.86 416.17 109.46 393.60 122.55 M 76.13 120.12 C 68.72 127.74 63.82 137.68 54.99 143.99 C 46.95 151.32 36.28 154.93 28.30 162.28 C 20.72 171.92 31.40 187.70 43.32 183.37 C 53.03 179.63 60.98 172.41 68.98 165.95 C 69.52 203.82 68.06 241.75 69.58 279.58 C 71.83 291.73 89.02 296.19 97.27 287.27 C 104.03 281.49 101.43 271.71 102.11 264.01 C 101.70 220.68 102.40 177.34 101.82 134.02 C 103.81 120.92 85.99 111.52 76.13 120.12 M 308.43 138.48 C 296.91 141.11 287.39 152.91 289.05 164.95 C 288.87 179.66 303.86 192.31 318.39 189.48 C 332.94 188.01 344.23 172.24 340.06 157.97 C 337.45 144.16 321.98 134.79 308.43 138.48 M 309.44 219.52 C 302.83 220.98 296.49 225.08 293.14 231.10 C 284.06 243.32 290.01 263.28 304.22 268.76 C 319.43 276.54 339.52 264.61 340.66 247.82 C 343.38 230.85 325.87 215.57 309.44 219.52 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 416.45 143.52 C 428.79 140.52 440.73 150.78 443.43 162.44 C 446.49 175.07 447.18 190.57 437.85 200.84 C 430.47 207.73 418.31 209.34 410.13 202.88 C 398.92 194.40 398.94 178.66 399.84 165.94 C 400.26 156.26 406.20 145.26 416.45 143.52 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 192.43 202.44 C 205.77 199.13 218.60 210.52 219.42 223.59 C 220.47 236.02 222.13 251.59 211.86 260.85 C 204.89 267.64 193.23 267.21 185.99 261.02 C 175.27 253.04 173.80 238.61 174.32 226.31 C 175.07 215.87 181.54 204.67 192.43 202.44 Z" />
-</g>
-</svg>
diff --git a/static/f/4-3.svg b/static/f/4-3.svg
deleted file mode 100644
index 7950572e..00000000
--- a/static/f/4-3.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="512pt" height="476pt" viewBox="0 0 512 476" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 17.30 0.00 L 494.70 0.00 C 503.19 2.31 509.02 9.35 512.00 17.32 L 512.00 458.69 C 510.18 462.48 508.55 466.54 505.63 469.64 C 502.38 472.36 498.59 474.39 494.68 476.00 L 17.30 476.00 C 8.80 473.68 2.98 466.65 0.00 458.68 L 0.00 17.32 C 2.98 9.35 8.80 2.31 17.30 0.00 M 378.44 126.53 C 358.73 128.78 338.73 139.50 330.08 158.06 C 324.53 166.42 327.30 178.91 336.02 183.95 C 342.51 188.56 351.79 187.52 358.01 183.01 C 364.13 177.11 367.45 168.44 374.98 163.98 C 385.94 157.11 402.74 160.09 409.37 171.65 C 414.81 183.25 412.53 199.81 400.92 206.87 C 394.34 211.70 385.67 212.24 379.10 217.07 C 373.74 223.66 374.33 233.59 380.68 239.32 C 387.98 246.73 400.09 242.66 408.00 248.98 C 417.77 255.85 421.72 268.49 421.02 280.01 C 421.53 292.82 414.90 306.41 402.98 311.94 C 390.61 318.21 373.32 314.15 366.37 301.77 C 362.16 295.33 357.79 287.26 349.55 285.53 C 337.28 282.07 324.15 292.61 324.47 305.19 C 325.06 314.99 331.62 323.07 338.21 329.79 C 363.06 354.02 404.77 355.99 433.40 337.41 C 446.59 328.40 457.08 315.06 461.64 299.65 C 466.50 281.57 465.08 260.32 452.39 245.64 C 445.86 235.92 434.47 232.06 424.04 228.19 C 431.10 222.23 439.53 217.67 444.91 209.93 C 451.04 202.04 454.86 192.07 454.14 181.98 C 454.49 159.50 438.31 139.29 417.96 131.09 C 405.69 125.33 391.66 125.20 378.44 126.53 M 124.82 142.86 C 100.11 178.83 75.94 215.18 50.94 250.96 C 45.72 259.10 38.60 267.93 40.53 278.33 C 41.19 292.00 53.74 303.11 67.12 303.76 C 88.04 304.54 109.01 303.72 129.95 304.00 C 131.06 315.96 126.66 330.03 134.68 340.32 C 141.42 349.15 155.70 350.19 163.83 342.82 C 174.24 332.84 170.04 317.13 171.17 304.39 C 179.00 303.25 188.63 304.04 193.74 296.77 C 200.20 289.42 199.19 276.64 190.89 271.13 C 185.58 265.96 177.69 267.10 171.02 266.60 C 170.55 227.54 171.82 188.43 170.48 149.39 C 170.23 137.14 158.47 125.90 146.03 128.07 C 136.29 127.50 129.74 135.65 124.82 142.86 M 248.44 145.54 C 236.90 147.91 226.75 156.91 223.22 168.20 C 218.65 181.35 222.80 197.43 233.94 206.07 C 245.42 215.95 263.51 216.53 275.83 207.84 C 289.86 198.68 295.14 178.71 287.08 163.95 C 280.78 149.50 263.58 142.02 248.44 145.54 M 255.36 261.41 C 243.32 261.72 231.85 268.38 225.93 278.93 C 218.84 290.86 220.19 307.24 229.03 317.93 C 238.61 330.87 258.02 334.35 272.06 327.07 C 286.26 320.15 294.02 302.56 289.59 287.40 C 286.10 272.17 270.91 260.79 255.36 261.41 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 79.71 266.84 C 96.14 241.63 113.35 216.93 129.81 191.74 C 130.19 216.82 129.95 241.90 129.99 266.98 C 113.23 267.02 96.47 267.10 79.71 266.84 Z" />
-</g>
-</svg>
diff --git a/static/f/cartridge.svg b/static/f/cartridge.svg
deleted file mode 100644
index 3c2ea4b7..00000000
--- a/static/f/cartridge.svg
+++ /dev/null
@@ -1,22 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="76pt" height="88pt" viewBox="0 0 76 88" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#706f6fff">
-<path fill="#706f6f" opacity="1.00" d=" M 0.00 0.00 L 76.00 0.00 L 76.00 71.94 C 75.25 71.97 73.76 72.03 73.01 72.06 C 72.99 77.37 72.99 82.68 73.00 88.00 L 3.00 88.00 C 3.01 82.68 3.01 77.37 2.99 72.06 C 2.24 72.03 0.75 71.97 0.00 71.94 L 0.00 0.00 M 3.00 4.00 C 3.00 25.67 3.00 47.33 3.00 69.00 C 4.33 69.00 5.67 69.00 7.00 69.00 C 7.00 72.75 7.00 80.25 7.00 84.00 C 27.67 84.00 48.33 84.00 69.00 84.00 C 69.00 80.25 69.00 72.75 69.00 69.00 C 70.33 69.00 71.67 69.00 73.00 69.00 C 73.00 47.33 73.00 25.67 73.00 4.00 C 71.50 4.00 68.50 4.00 67.00 4.00 C 67.00 23.00 67.00 42.00 67.00 61.00 C 54.67 61.00 42.33 61.00 30.00 61.00 C 30.00 42.00 30.00 23.00 30.00 4.00 C 21.00 4.00 12.00 4.00 3.00 4.00 M 32.00 4.00 C 32.00 22.33 32.00 40.67 32.00 59.00 C 43.00 59.00 54.00 59.00 65.00 59.00 C 65.00 40.67 65.00 22.33 65.00 4.00 C 54.00 4.00 43.00 4.00 32.00 4.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 11.00 C 15.75 11.00 23.25 11.00 27.00 11.00 C 27.00 11.75 27.00 13.25 27.00 14.00 C 23.25 14.00 15.75 14.00 12.00 14.00 C 12.00 13.25 12.00 11.75 12.00 11.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 19.00 C 15.75 19.00 23.25 19.00 27.00 19.00 C 27.00 19.75 27.00 21.25 27.00 22.00 C 23.25 22.00 15.75 22.00 12.00 22.00 C 12.00 21.25 12.00 19.75 12.00 19.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 27.00 C 15.75 27.00 23.25 27.00 27.00 27.00 C 27.00 27.75 27.00 29.25 27.00 30.00 C 23.25 30.00 15.75 30.00 12.00 30.00 C 12.00 29.25 12.00 27.75 12.00 27.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 35.00 C 15.75 35.00 23.25 35.00 27.00 35.00 C 27.00 35.75 27.00 37.25 27.00 38.00 C 23.25 38.00 15.75 38.00 12.00 38.00 C 12.00 37.25 12.00 35.75 12.00 35.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 43.00 C 15.75 43.00 23.25 43.00 27.00 43.00 C 27.00 43.75 27.00 45.25 27.00 46.00 C 23.25 46.00 15.75 46.00 12.00 46.00 C 12.00 45.25 12.00 43.75 12.00 43.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 51.00 C 15.75 51.00 23.25 51.00 27.00 51.00 C 27.00 51.75 27.00 53.25 27.00 54.00 C 23.25 54.00 15.75 54.00 12.00 54.00 C 12.00 53.25 12.00 51.75 12.00 51.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 59.00 C 15.75 59.00 23.25 59.00 27.00 59.00 C 27.00 59.75 27.00 61.25 27.00 62.00 C 23.25 62.00 15.75 62.00 12.00 62.00 C 12.00 61.25 12.00 59.75 12.00 59.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 67.00 C 15.75 67.00 23.25 67.00 27.00 67.00 C 27.00 67.75 27.00 69.25 27.00 70.00 C 23.25 70.00 15.75 70.00 12.00 70.00 C 12.00 69.25 12.00 67.75 12.00 67.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 75.00 C 15.75 75.00 23.25 75.00 27.00 75.00 C 27.00 75.75 27.00 77.25 27.00 78.00 C 23.25 78.00 15.75 78.00 12.00 78.00 C 12.00 77.25 12.00 75.75 12.00 75.00 Z" />
-</g>
-<g id="#eaeaeaff">
-<path fill="#eaeaea" opacity="1.00" d=" M 3.00 4.00 C 12.00 4.00 21.00 4.00 30.00 4.00 C 30.00 23.00 30.00 42.00 30.00 61.00 C 42.33 61.00 54.67 61.00 67.00 61.00 C 67.00 42.00 67.00 23.00 67.00 4.00 C 68.50 4.00 71.50 4.00 73.00 4.00 C 73.00 25.67 73.00 47.33 73.00 69.00 C 71.67 69.00 70.33 69.00 69.00 69.00 C 69.00 72.75 69.00 80.25 69.00 84.00 C 48.33 84.00 27.67 84.00 7.00 84.00 C 7.00 80.25 7.00 72.75 7.00 69.00 C 5.67 69.00 4.33 69.00 3.00 69.00 C 3.00 47.33 3.00 25.67 3.00 4.00 M 12.00 11.00 C 12.00 11.75 12.00 13.25 12.00 14.00 C 15.75 14.00 23.25 14.00 27.00 14.00 C 27.00 13.25 27.00 11.75 27.00 11.00 C 23.25 11.00 15.75 11.00 12.00 11.00 M 12.00 19.00 C 12.00 19.75 12.00 21.25 12.00 22.00 C 15.75 22.00 23.25 22.00 27.00 22.00 C 27.00 21.25 27.00 19.75 27.00 19.00 C 23.25 19.00 15.75 19.00 12.00 19.00 M 12.00 27.00 C 12.00 27.75 12.00 29.25 12.00 30.00 C 15.75 30.00 23.25 30.00 27.00 30.00 C 27.00 29.25 27.00 27.75 27.00 27.00 C 23.25 27.00 15.75 27.00 12.00 27.00 M 12.00 35.00 C 12.00 35.75 12.00 37.25 12.00 38.00 C 15.75 38.00 23.25 38.00 27.00 38.00 C 27.00 37.25 27.00 35.75 27.00 35.00 C 23.25 35.00 15.75 35.00 12.00 35.00 M 12.00 43.00 C 12.00 43.75 12.00 45.25 12.00 46.00 C 15.75 46.00 23.25 46.00 27.00 46.00 C 27.00 45.25 27.00 43.75 27.00 43.00 C 23.25 43.00 15.75 43.00 12.00 43.00 M 12.00 51.00 C 12.00 51.75 12.00 53.25 12.00 54.00 C 15.75 54.00 23.25 54.00 27.00 54.00 C 27.00 53.25 27.00 51.75 27.00 51.00 C 23.25 51.00 15.75 51.00 12.00 51.00 M 12.00 59.00 C 12.00 59.75 12.00 61.25 12.00 62.00 C 15.75 62.00 23.25 62.00 27.00 62.00 C 27.00 61.25 27.00 59.75 27.00 59.00 C 23.25 59.00 15.75 59.00 12.00 59.00 M 12.00 67.00 C 12.00 67.75 12.00 69.25 12.00 70.00 C 15.75 70.00 23.25 70.00 27.00 70.00 C 27.00 69.25 27.00 67.75 27.00 67.00 C 23.25 67.00 15.75 67.00 12.00 67.00 M 12.00 75.00 C 12.00 75.75 12.00 77.25 12.00 78.00 C 15.75 78.00 23.25 78.00 27.00 78.00 C 27.00 77.25 27.00 75.75 27.00 75.00 C 23.25 75.00 15.75 75.00 12.00 75.00 Z" />
-</g>
-<g id="#939292ff">
-<path fill="#939292" opacity="1.00" d=" M 32.00 4.00 C 43.00 4.00 54.00 4.00 65.00 4.00 C 65.00 22.33 65.00 40.67 65.00 59.00 C 54.00 59.00 43.00 59.00 32.00 59.00 C 32.00 40.67 32.00 22.33 32.00 4.00 Z" />
-</g>
-</svg>
diff --git a/static/f/commercial.svg b/static/f/commercial.svg
deleted file mode 100644
index d8d6df49..00000000
--- a/static/f/commercial.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="464pt" height="438pt" viewBox="0 0 464 438" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 118.53 72.51 C 156.58 49.01 193.86 24.29 231.84 0.68 C 232.20 146.11 231.92 291.55 231.98 436.98 C 154.65 437.03 77.33 436.98 0.00 437.00 L 0.00 292.58 C 2.55 290.85 5.25 289.35 7.91 287.79 C 8.10 261.27 7.89 234.75 8.11 208.24 C 34.06 196.78 60.00 185.31 85.92 173.81 C 86.10 201.18 85.93 228.55 86.03 255.91 C 89.58 254.64 93.11 253.36 96.67 252.13 C 97.35 279.51 96.77 306.90 97.12 334.28 C 101.94 330.03 110.34 328.23 112.81 322.00 C 113.61 241.71 112.32 161.34 113.30 81.05 C 112.02 76.76 115.74 74.50 118.53 72.51 M 175.14 60.30 C 174.98 71.99 174.97 83.67 175.14 95.36 C 189.48 86.62 203.56 77.44 217.96 68.81 C 218.02 57.03 218.02 45.26 217.80 33.48 C 203.23 41.87 189.56 51.68 175.14 60.30 M 130.99 89.00 C 129.68 90.20 127.27 90.94 127.16 93.05 C 126.40 103.79 127.06 114.58 127.16 125.34 C 139.37 117.56 152.49 110.96 163.62 101.68 C 164.92 90.70 163.77 79.53 163.80 68.49 C 152.70 75.06 141.75 81.89 130.99 89.00 M 174.99 118.01 C 174.98 129.48 174.99 140.94 175.17 152.41 C 189.61 143.86 203.71 134.74 217.96 125.88 C 218.02 114.11 218.02 102.34 217.83 90.58 C 203.19 99.15 189.23 108.80 174.99 118.01 M 127.00 149.03 C 126.98 160.17 126.99 171.30 127.18 182.45 C 139.64 175.24 151.53 167.09 163.97 159.84 C 164.02 148.42 164.01 137.00 163.83 125.58 C 151.25 132.91 139.21 141.11 127.00 149.03 M 179.90 171.88 C 178.42 173.40 175.00 174.28 175.09 176.99 C 174.45 187.79 175.05 198.64 175.20 209.45 C 189.80 201.18 203.82 191.92 218.03 182.99 C 218.02 171.19 218.02 159.40 217.82 147.61 C 205.06 155.52 192.45 163.65 179.90 171.88 M 131.91 202.90 C 130.43 204.41 127.00 205.28 127.10 207.99 C 126.48 218.64 127.02 229.32 127.01 239.98 C 137.60 233.19 148.47 226.85 159.04 220.03 C 160.47 218.48 164.03 217.75 163.91 215.01 C 164.54 204.21 163.95 193.37 163.80 182.57 C 153.08 189.22 142.44 195.97 131.91 202.90 M 48.13 208.40 C 47.99 218.91 47.98 229.42 47.94 239.93 C 56.58 236.14 65.16 232.21 73.89 228.65 C 74.02 218.01 74.00 207.38 73.92 196.76 C 65.28 200.53 56.77 204.61 48.13 208.40 M 175.06 232.13 C 174.98 243.79 174.98 255.46 175.04 267.12 C 189.46 258.24 203.92 249.38 218.03 240.00 C 218.02 228.25 217.99 216.49 217.88 204.74 C 203.48 213.68 189.44 223.17 175.06 232.13 M 40.95 212.08 C 32.28 216.01 23.63 220.00 14.98 223.98 C 14.98 234.38 15.00 244.78 15.09 255.19 C 23.79 251.59 32.29 247.54 40.93 243.79 C 41.02 233.22 41.00 222.65 40.95 212.08 M 127.53 263.47 C 125.75 274.52 127.40 285.95 127.06 297.11 C 139.44 289.51 151.79 281.86 164.01 274.00 C 164.00 262.58 164.00 251.16 163.89 239.75 C 151.90 247.82 138.82 254.48 127.53 263.47 M 75.95 248.07 C 67.25 251.94 58.65 256.01 50.01 260.01 C 49.97 270.40 50.00 280.79 50.08 291.18 C 58.77 287.56 67.35 283.70 75.93 279.83 C 76.02 269.24 75.99 258.65 75.95 248.07 M 175.15 289.29 C 174.98 300.91 174.98 312.53 175.04 324.16 C 189.47 315.59 203.49 306.36 217.88 297.72 C 218.02 285.98 218.02 274.23 217.82 262.49 C 203.14 270.68 189.49 280.54 175.15 289.29 M 17.07 275.18 C 16.98 285.60 17.01 296.03 17.13 306.46 C 25.88 302.91 34.45 298.96 43.02 295.03 C 43.03 284.58 42.99 274.14 42.89 263.70 C 34.21 267.35 25.67 271.33 17.07 275.18 M 53.07 306.18 C 52.98 316.60 53.01 327.03 53.13 337.46 C 61.88 333.91 70.45 329.96 79.02 326.03 C 79.03 315.58 78.99 305.14 78.89 294.70 C 70.21 298.35 61.67 302.33 53.07 306.18 M 127.09 320.28 C 126.96 331.59 127.00 342.90 127.11 354.22 C 139.63 346.84 151.85 338.96 164.01 330.99 C 164.01 319.82 164.00 308.65 163.81 297.49 C 151.13 304.35 139.47 312.91 127.09 320.28 M 18.08 321.19 C 17.98 331.77 17.99 342.35 17.98 352.94 C 26.67 348.98 35.36 345.03 44.01 341.00 C 44.03 330.58 44.00 320.16 43.90 309.74 C 35.25 313.46 26.73 317.47 18.08 321.19 M 52.99 354.96 C 52.98 365.28 52.98 375.62 53.00 385.95 C 61.63 382.21 70.22 378.33 78.91 374.72 C 79.01 364.07 78.99 353.43 78.93 342.79 C 70.25 346.78 61.57 350.76 52.99 354.96 M 17.99 369.96 C 17.98 380.32 17.99 390.68 18.01 401.05 C 26.70 397.39 35.23 393.38 43.91 389.70 C 44.01 379.06 43.99 368.42 43.93 357.79 C 35.25 361.78 26.57 365.76 17.99 369.96 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 242.99 2.05 C 274.63 22.74 306.31 43.38 337.96 64.06 C 338.05 188.37 337.99 312.68 337.99 437.00 C 306.33 437.01 274.67 437.02 243.02 436.99 C 242.97 292.01 243.03 147.03 242.99 2.05 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 346.04 270.92 C 385.05 283.17 423.95 295.76 462.92 308.13 C 463.09 350.42 462.97 392.70 462.99 434.99 C 423.99 435.01 385.00 435.01 346.01 434.99 C 346.01 380.30 345.95 325.61 346.04 270.92 M 360.12 304.64 C 359.99 314.80 359.96 324.96 359.96 335.13 C 371.51 338.88 383.20 342.21 394.96 345.22 C 395.03 335.89 395.00 326.57 394.90 317.24 C 383.30 313.04 371.86 308.41 360.12 304.64 M 404.10 317.53 C 403.98 327.64 403.98 337.76 404.06 347.88 C 415.94 351.61 427.89 355.21 439.95 358.34 C 440.03 348.97 440.00 339.61 439.90 330.25 C 427.96 326.02 416.32 320.90 404.10 317.53 M 360.08 358.68 C 359.99 368.97 359.99 379.27 360.09 389.58 C 371.78 392.56 383.25 396.38 394.95 399.35 C 395.02 389.98 395.00 380.61 394.90 371.24 C 383.29 367.06 371.86 362.35 360.08 358.68 M 404.12 371.51 C 403.99 381.69 403.97 391.88 403.97 402.06 C 415.94 405.56 427.86 409.26 439.95 412.35 C 440.02 402.97 439.99 393.59 439.91 384.22 C 427.92 380.15 416.25 375.16 404.12 371.51 Z" />
-</g>
-</svg>
diff --git a/static/f/disk.svg b/static/f/disk.svg
deleted file mode 100644
index 459ad518..00000000
--- a/static/f/disk.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="470pt" height="470pt" viewBox="0 0 470 470" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#bdc3c7ff">
-<path fill="#bdc3c7" opacity="1.00" d=" M 210.44 1.64 C 247.88 -2.17 286.28 2.79 321.29 16.70 C 357.01 30.73 389.13 53.77 413.94 83.04 C 431.90 104.12 446.03 128.42 455.50 154.44 C 473.39 203.58 474.46 258.84 457.65 308.43 C 445.73 344.39 424.84 377.29 397.56 403.58 C 371.66 428.64 339.98 447.75 305.60 458.65 C 254.81 475.01 198.38 472.96 148.86 453.09 C 92.18 430.63 45.10 385.24 20.50 329.46 C 6.20 297.40 -0.80 262.11 0.55 227.01 C 1.95 177.67 19.64 128.97 50.21 90.21 C 75.74 57.55 110.11 31.85 148.71 16.70 C 168.45 8.84 189.32 3.92 210.44 1.64 M 70.51 97.54 C 44.93 128.62 28.05 167.00 23.37 207.04 C 27.56 207.89 31.80 208.46 36.05 208.88 C 68.37 212.91 100.68 216.99 133.00 221.01 C 138.87 221.71 144.70 222.77 150.62 222.93 C 153.62 200.22 166.62 179.48 184.86 165.82 C 180.52 159.34 175.53 153.33 170.95 147.02 C 151.69 121.31 132.24 95.73 113.06 69.96 C 111.27 67.37 109.26 64.92 107.05 62.68 C 93.29 72.46 81.21 84.51 70.51 97.54 M 227.41 171.45 C 207.76 173.65 189.41 185.43 179.63 202.67 C 171.90 216.05 169.13 232.33 172.52 247.46 C 175.94 264.65 187.04 279.84 201.67 289.27 C 217.19 299.08 237.02 301.04 254.40 295.46 C 271.64 290.11 286.16 276.89 293.42 260.40 C 298.64 249.13 300.17 236.25 297.99 224.04 C 295.20 206.11 284.11 189.71 268.47 180.49 C 256.38 172.84 241.58 169.77 227.41 171.45 M 320.11 243.00 C 318.15 258.75 312.56 274.18 302.73 286.75 C 298.35 292.92 292.52 297.76 287.06 302.92 C 309.30 332.37 331.42 361.91 353.61 391.41 C 356.66 395.36 359.32 399.63 362.87 403.17 C 400.13 375.39 427.62 334.91 440.25 290.22 C 442.97 279.77 445.93 269.16 446.08 258.30 C 408.65 253.83 371.28 248.95 333.88 244.24 C 329.32 243.55 324.73 242.97 320.11 243.00 Z" />
-</g>
-<g id="#ecf0f1ff">
-<path fill="#ecf0f1" opacity="1.00" d=" M 70.51 97.54 C 81.21 84.51 93.29 72.46 107.05 62.68 C 109.26 64.92 111.27 67.37 113.06 69.96 C 132.24 95.73 151.69 121.31 170.95 147.02 C 175.53 153.33 180.52 159.34 184.86 165.82 C 166.62 179.48 153.62 200.22 150.62 222.93 C 144.70 222.77 138.87 221.71 133.00 221.01 C 100.68 216.99 68.37 212.91 36.05 208.88 C 31.80 208.46 27.56 207.89 23.37 207.04 C 28.05 167.00 44.93 128.62 70.51 97.54 Z" />
-<path fill="#ecf0f1" opacity="1.00" d=" M 227.41 171.45 C 241.58 169.77 256.38 172.84 268.47 180.49 C 284.11 189.71 295.20 206.11 297.99 224.04 C 300.17 236.25 298.64 249.13 293.42 260.40 C 286.16 276.89 271.64 290.11 254.40 295.46 C 237.02 301.04 217.19 299.08 201.67 289.27 C 187.04 279.84 175.94 264.65 172.52 247.46 C 169.13 232.33 171.90 216.05 179.63 202.67 C 189.41 185.43 207.76 173.65 227.41 171.45 M 226.42 192.62 C 204.36 196.92 188.30 220.76 193.38 242.80 C 196.68 257.58 208.02 271.09 223.01 274.79 C 234.22 277.75 246.55 276.33 256.71 270.73 C 259.94 269.02 261.52 265.54 264.13 263.13 C 269.91 257.51 274.52 250.50 276.37 242.59 C 280.90 224.83 271.26 205.34 255.65 196.40 C 246.75 191.79 236.20 190.51 226.42 192.62 Z" />
-<path fill="#ecf0f1" opacity="1.00" d=" M 320.11 243.00 C 324.73 242.97 329.32 243.55 333.88 244.24 C 371.28 248.95 408.65 253.83 446.08 258.30 C 445.93 269.16 442.97 279.77 440.25 290.22 C 427.62 334.91 400.13 375.39 362.87 403.17 C 359.32 399.63 356.66 395.36 353.61 391.41 C 331.42 361.91 309.30 332.37 287.06 302.92 C 292.52 297.76 298.35 292.92 302.73 286.75 C 312.56 274.18 318.15 258.75 320.11 243.00 Z" />
-</g>
-</svg>
diff --git a/static/f/doujin.svg b/static/f/doujin.svg
deleted file mode 100644
index 7ce8414c..00000000
--- a/static/f/doujin.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="470pt" height="460pt" viewBox="0 0 470 460" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#0e0f0fff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 343.38 0.00 L 354.63 0.00 C 361.95 1.92 369.32 4.18 375.82 8.17 C 390.48 17.75 399.79 35.17 397.81 52.91 C 397.34 78.72 372.66 100.58 347.08 98.84 C 321.00 98.48 298.33 74.42 299.37 48.35 C 300.49 36.30 304.58 24.01 313.28 15.27 C 321.36 7.17 332.15 1.98 343.38 0.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 50.74 72.28 C 56.20 70.87 61.65 69.41 67.09 67.90 C 77.71 106.60 88.46 145.25 99.07 183.95 C 130.38 184.10 161.69 183.92 193.01 184.01 C 192.99 190.00 192.99 196.00 193.00 201.99 C 157.29 202.02 121.57 202.00 85.86 202.02 C 74.19 158.76 61.92 115.66 50.74 72.28 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 325.68 131.65 C 335.39 108.99 363.13 95.13 386.98 103.03 C 405.49 109.15 418.19 127.47 420.54 146.43 C 426.41 187.41 422.46 229.57 410.03 269.01 C 406.65 280.83 400.99 291.83 397.59 303.64 C 395.01 312.97 388.56 322.06 378.91 324.84 C 370.79 327.16 362.27 326.93 353.95 327.92 C 332.81 329.97 311.82 337.67 295.55 351.53 C 274.05 369.26 262.39 396.34 257.22 423.14 C 254.95 432.39 253.63 443.37 245.40 449.43 C 228.65 460.84 203.27 451.22 196.01 432.94 C 193.29 426.09 192.92 418.52 194.34 411.33 C 200.59 376.55 215.06 342.47 239.74 316.74 C 255.65 299.16 275.94 285.72 297.93 276.97 C 299.92 276.06 302.06 275.31 303.76 273.88 C 312.28 251.54 319.69 228.64 322.71 204.83 C 290.32 218.72 253.71 222.72 219.14 215.75 C 210.96 214.16 203.15 208.92 200.03 200.99 C 193.20 183.80 205.78 161.19 224.61 159.66 C 231.45 158.67 238.23 160.30 245.02 160.88 C 272.02 164.34 299.34 154.76 321.02 138.97 C 323.43 137.20 324.38 134.22 325.68 131.65 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 441.79 173.66 C 442.75 166.80 450.56 162.34 457.02 164.93 C 463.94 165.58 467.67 172.30 470.00 178.08 L 470.00 180.46 C 461.77 222.43 461.81 266.59 444.23 306.27 C 431.42 335.61 406.98 360.53 376.19 370.49 C 375.86 390.79 375.72 411.09 376.41 431.38 C 387.17 436.60 398.16 441.45 408.43 447.61 C 411.29 449.11 413.10 452.94 410.76 455.69 C 408.92 459.41 404.42 457.53 401.30 456.64 C 390.61 452.09 380.87 445.54 370.11 441.13 C 367.64 440.21 365.16 441.97 362.94 442.75 C 350.43 448.48 338.48 455.68 324.99 458.97 C 324.16 455.97 323.33 452.90 322.58 449.90 C 335.91 442.34 349.96 436.14 363.89 429.79 C 364.04 411.17 364.13 392.56 363.94 373.95 C 348.87 377.23 331.86 377.09 319.38 387.41 C 311.44 394.56 307.44 404.84 304.98 415.00 C 303.74 419.24 302.89 424.23 298.88 426.91 C 292.34 432.59 282.16 428.53 277.54 422.43 C 274.06 416.35 276.23 409.17 277.80 402.91 C 282.77 384.10 292.97 365.07 311.05 356.09 C 329.72 346.36 351.68 349.58 371.04 342.12 C 394.13 333.53 411.02 313.37 419.85 290.84 C 434.98 253.61 435.02 212.75 441.79 173.66 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 0.99 216.02 C 67.99 215.97 134.99 215.99 202.00 216.01 C 202.00 230.64 202.02 245.27 202.03 259.90 C 194.36 259.97 186.68 260.04 179.02 260.11 C 178.96 326.74 179.02 393.37 179.00 460.00 L 24.00 460.00 C 23.98 393.36 24.03 326.73 23.99 260.10 C 16.33 260.02 8.67 259.95 1.00 259.89 C 1.00 245.27 0.99 230.64 0.99 216.02 Z" />
-</g>
-</svg>
diff --git a/static/f/download.svg b/static/f/download.svg
deleted file mode 100644
index f5f5d96a..00000000
--- a/static/f/download.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="286pt" height="296pt" viewBox="0 0 286 296" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 89.89 13.94 C 92.81 7.38 99.11 2.52 106.17 1.27 C 110.07 0.60 114.05 0.72 117.99 0.71 C 143.75 0.86 169.51 0.55 195.26 0.86 C 195.50 31.93 195.40 63.01 195.31 94.08 C 206.54 94.62 217.78 94.13 229.01 94.30 C 231.71 94.49 235.35 94.52 236.62 97.43 C 237.03 100.18 235.14 102.56 233.72 104.72 C 206.44 140.11 179.12 175.48 151.91 210.93 C 149.53 213.95 145.99 217.37 141.78 216.25 C 137.00 214.82 134.47 210.09 131.48 206.48 C 105.42 172.91 79.34 139.35 53.16 105.87 C 51.45 103.37 48.97 100.69 49.40 97.44 C 50.59 94.44 54.29 94.48 56.99 94.30 C 67.24 94.16 77.50 94.55 87.74 94.13 C 87.61 72.09 87.76 50.05 87.65 28.01 C 87.67 23.25 87.80 18.31 89.89 13.94 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 11.65 203.71 C 18.69 202.00 26.03 202.92 33.20 202.88 C 33.47 222.57 33.30 242.26 33.27 261.95 C 70.51 262.41 107.76 262.01 145.00 262.14 C 180.91 262.02 216.82 262.40 252.73 261.95 C 252.70 242.26 252.53 222.57 252.80 202.88 C 259.97 202.91 267.30 202.00 274.35 203.71 C 280.35 205.31 284.00 210.94 286.00 216.45 L 286.00 288.76 C 284.28 292.74 280.55 295.62 276.07 295.23 C 191.38 295.31 106.69 295.22 22.00 295.28 C 16.74 295.20 11.41 295.74 6.22 294.75 C 3.14 294.17 1.46 291.24 0.00 288.77 L 0.00 216.47 C 1.99 210.95 5.65 205.31 11.65 203.71 Z" />
-</g>
-</svg>
diff --git a/static/f/ero_animated.svg b/static/f/ero_animated.svg
deleted file mode 100644
index c6c1d08d..00000000
--- a/static/f/ero_animated.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="480pt" height="448pt" viewBox="0 0 480 448" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#914040" opacity="1.00" d=" M 27.63 0.00 L 452.37 0.00 C 459.41 1.97 466.61 4.83 471.58 10.42 C 475.92 15.25 478.26 21.47 480.00 27.63 L 480.00 420.37 C 478.03 427.41 475.17 434.61 469.58 439.58 C 464.75 443.92 458.53 446.26 452.37 448.00 L 27.63 448.00 C 20.59 446.02 13.39 443.17 8.42 437.58 C 4.07 432.75 1.74 426.53 0.00 420.37 L 0.00 27.63 C 1.74 21.47 4.08 15.25 8.42 10.42 C 13.39 4.82 20.59 1.97 27.63 0.00 M 64.02 32.02 C 63.98 42.67 63.98 53.33 64.02 63.98 C 85.34 64.02 106.66 64.01 127.98 63.98 C 128.01 53.33 128.02 42.67 127.98 32.02 C 106.66 31.99 85.34 31.99 64.02 32.02 M 160.02 32.02 C 159.98 42.67 159.98 53.33 160.02 63.98 C 181.34 64.02 202.66 64.01 223.98 63.98 C 224.02 53.33 224.02 42.67 223.98 32.02 C 202.66 31.99 181.34 31.99 160.02 32.02 M 256.02 32.02 C 255.98 42.67 255.98 53.32 256.02 63.98 C 277.34 64.02 298.66 64.01 319.98 63.98 C 320.02 53.33 320.02 42.67 319.98 32.02 C 298.66 31.99 277.34 31.98 256.02 32.02 M 352.02 32.02 C 351.97 42.67 351.98 53.33 352.02 63.98 C 373.34 64.02 394.66 64.02 415.98 63.98 C 416.02 53.33 416.02 42.67 415.98 32.02 C 394.66 31.99 373.34 31.98 352.02 32.02 M 64.02 96.02 C 63.98 181.34 63.99 266.66 64.02 351.98 C 181.34 352.02 298.66 352.01 415.98 351.98 C 416.02 266.66 416.01 181.34 415.98 96.02 C 298.66 95.99 181.34 95.98 64.02 96.02 M 64.02 384.02 C 63.99 394.67 63.98 405.33 64.02 415.98 C 85.34 416.01 106.66 416.02 127.98 415.98 C 128.02 405.33 128.02 394.67 127.98 384.02 C 106.66 383.98 85.34 383.99 64.02 384.02 M 160.02 384.02 C 159.98 394.67 159.98 405.33 160.02 415.98 C 181.34 416.01 202.66 416.01 223.98 415.98 C 224.02 405.33 224.02 394.67 223.98 384.02 C 202.66 383.98 181.34 383.99 160.02 384.02 M 256.02 384.02 C 255.98 394.67 255.98 405.32 256.02 415.98 C 277.34 416.02 298.66 416.01 319.98 415.98 C 320.02 405.33 320.02 394.67 319.98 384.02 C 298.66 383.99 277.34 383.98 256.02 384.02 M 352.02 384.02 C 351.98 394.67 351.98 405.33 352.02 415.98 C 373.34 416.02 394.66 416.01 415.98 415.98 C 416.02 405.33 416.02 394.67 415.98 384.02 C 394.66 383.99 373.34 383.99 352.02 384.02 Z" />
-<path fill="#914040" opacity="1.00" d=" M 100.46 142.47 C 110.06 133.83 122.18 127.84 135.04 126.21 C 147.29 124.77 160.43 126.97 170.42 134.58 C 182.89 143.04 190.30 156.56 197.82 169.20 C 198.98 170.66 199.73 173.31 202.03 173.06 C 205.87 172.08 208.72 169.04 211.99 166.95 C 224.62 158.12 239.11 150.17 254.98 150.52 C 276.20 151.26 295.58 165.74 304.16 184.87 C 297.69 182.49 291.96 178.39 285.22 176.70 C 274.09 173.48 261.93 173.81 251.13 178.13 C 235.37 183.96 222.76 197.10 216.76 212.71 C 208.83 232.66 212.33 256.58 225.46 273.54 C 230.28 280.27 236.71 285.51 242.73 291.09 C 236.15 295.70 228.56 298.56 221.67 302.67 C 207.67 309.43 193.56 316.17 178.65 320.70 C 174.40 321.94 169.42 323.08 165.39 320.57 C 158.85 316.68 153.75 310.90 148.41 305.58 C 143.03 299.62 137.57 293.71 132.67 287.35 C 116.78 267.47 101.76 246.76 89.68 224.32 C 83.84 212.13 80.04 198.58 80.92 184.97 C 81.76 169.00 88.83 153.46 100.46 142.47 Z" />
-<path fill="#914040" opacity="1.00" d=" M 342.12 173.13 C 351.03 169.84 360.80 171.61 369.54 174.52 C 382.20 179.49 392.43 190.42 396.67 203.34 C 401.52 216.58 399.08 231.57 392.87 243.95 C 386.94 255.84 379.56 266.96 371.63 277.60 C 362.12 290.29 352.31 302.88 340.62 313.67 C 337.86 316.25 334.20 318.38 330.28 317.58 C 321.02 316.03 312.55 311.68 303.97 308.09 C 287.52 300.40 271.57 291.57 256.38 281.61 C 246.18 274.27 236.36 265.31 231.60 253.38 C 227.87 244.78 227.22 235.08 228.91 225.93 C 231.82 211.11 242.56 198.10 256.64 192.60 C 262.91 190.23 269.83 189.02 276.46 190.50 C 284.78 191.95 292.26 196.16 299.23 200.74 C 302.53 203.09 305.87 205.42 309.58 207.08 C 313.93 201.14 317.20 194.49 321.69 188.65 C 327.13 181.98 333.59 175.51 342.12 173.13 Z" />
-</g>
-</svg>
diff --git a/static/f/free.svg b/static/f/free.svg
deleted file mode 100644
index 0b95ed8f..00000000
--- a/static/f/free.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="282pt" height="294pt" viewBox="0 0 282 294" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 64.26 0.00 L 69.48 0.00 C 89.53 2.99 108.58 12.00 124.22 24.80 C 129.71 29.85 136.44 35.81 136.00 44.01 C 136.99 49.25 132.99 53.32 129.45 56.44 C 116.18 64.42 100.34 67.38 85.00 67.07 C 71.68 67.16 57.15 66.12 46.01 57.97 C 40.39 53.41 35.20 47.23 34.26 39.81 C 33.77 32.03 34.40 23.84 37.64 16.65 C 42.72 6.74 53.42 1.11 64.26 0.00 M 64.33 22.33 C 56.26 22.48 55.52 32.02 55.39 38.02 C 71.18 48.92 91.98 45.60 109.55 41.61 C 96.86 31.12 81.11 22.42 64.33 22.33 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 213.39 0.00 L 218.58 0.00 C 227.86 1.62 238.42 4.86 242.81 14.11 C 250.49 25.70 250.87 42.64 241.34 53.33 C 230.80 64.28 214.68 67.62 200.00 67.07 C 184.59 66.84 168.08 65.66 154.60 57.38 C 150.48 54.51 146.21 50.40 145.98 45.06 C 145.35 36.51 152.06 30.09 157.96 24.95 C 173.98 12.32 192.84 2.24 213.39 0.00 M 172.64 41.67 C 187.74 45.73 204.09 46.93 219.19 42.30 C 223.32 41.05 227.45 37.25 226.57 32.57 C 226.78 26.70 221.83 21.78 216.02 22.29 C 199.84 23.40 184.99 31.56 172.64 41.67 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 9.39 78.46 C 20.74 76.04 32.48 77.13 44.00 76.89 C 72.25 77.45 100.56 75.89 128.76 78.14 C 129.18 101.08 129.23 124.04 128.70 146.99 C 91.18 148.93 53.56 147.96 16.02 147.81 C 8.42 149.08 2.88 143.17 0.00 136.92 L 0.00 87.68 C 2.97 84.52 4.98 79.82 9.39 78.46 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 153.30 78.03 C 181.48 75.89 209.77 77.47 238.00 76.89 C 249.52 77.13 261.26 76.04 272.61 78.46 C 277.02 79.82 279.02 84.52 282.00 87.68 L 282.00 136.91 C 279.73 141.59 276.16 146.85 270.57 147.44 C 259.08 148.50 247.52 148.08 236.00 148.07 C 208.41 147.63 180.78 148.97 153.24 147.00 C 152.80 124.01 152.80 101.02 153.30 78.03 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 16.87 171.72 C 19.46 164.07 28.92 162.37 35.97 162.89 C 66.90 163.29 97.89 162.03 128.78 164.00 C 128.72 207.33 130.16 250.72 127.91 294.00 L 27.17 294.00 C 22.19 291.82 16.44 287.97 16.06 282.07 C 15.41 258.72 16.35 235.36 15.94 212.00 C 16.07 198.58 15.14 185.06 16.87 171.72 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 153.27 163.90 C 170.46 162.25 187.77 163.13 205.00 162.95 C 221.51 163.20 238.07 162.17 254.54 163.64 C 260.27 164.21 265.71 168.99 265.88 174.96 C 266.63 198.29 265.65 221.65 266.05 245.00 C 265.92 258.43 266.87 271.96 265.20 285.32 C 263.35 289.55 258.77 291.87 254.88 294.00 L 153.83 294.00 C 152.07 250.67 153.16 207.25 153.27 163.90 Z" />
-</g>
-</svg>
diff --git a/static/f/imgvote-keybindings.svg b/static/f/imgvote-keybindings.svg
new file mode 100644
index 00000000..f466b69c
--- /dev/null
+++ b/static/f/imgvote-keybindings.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="170.9mm" height="279.49mm" version="1.1" viewBox="0 0 170.9 279.49" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><marker id="b" overflow="visible" orient="auto"><path transform="matrix(.4 0 0 .4 4 0)" d="m0 0 5-5-17.5 5 17.5 5z" fill-rule="evenodd" stroke="#000" stroke-width="1pt"/></marker><marker id="c" overflow="visible" orient="auto"><path transform="matrix(.4 0 0 .4 4 0)" d="m0 0 5-5-17.5 5 17.5 5z" fill-rule="evenodd" stroke="#000" stroke-width="1pt"/></marker><marker id="d" overflow="visible" orient="auto"><path transform="matrix(.4 0 0 .4 4 0)" d="m0 0 5-5-17.5 5 17.5 5z" fill-rule="evenodd" stroke="#000" stroke-width="1pt"/></marker><marker id="e" overflow="visible" orient="auto"><path transform="matrix(.4 0 0 .4 4 0)" d="m0 0 5-5-17.5 5 17.5 5z" fill-rule="evenodd" stroke="#000" stroke-width="1pt"/></marker><marker id="f" overflow="visible" orient="auto"><path transform="matrix(.4 0 0 .4 4 0)" d="m0 0 5-5-17.5 5 17.5 5z" fill-rule="evenodd" stroke="#000" stroke-width="1pt"/></marker><marker id="g" overflow="visible" orient="auto"><path transform="matrix(.4 0 0 .4 4 0)" d="m0 0 5-5-17.5 5 17.5 5z" fill-rule="evenodd" stroke="#000" stroke-width="1pt"/></marker><marker id="a" overflow="visible" orient="auto"><path transform="matrix(.4 0 0 .4 4 0)" d="m0 0 5-5-17.5 5 17.5 5z" fill-rule="evenodd" stroke="#000" stroke-width="1pt"/></marker></defs><g transform="translate(6.0372 7.7235)"><g fill="#000000" font-family="Sans" letter-spacing="0px" stroke-width=".26458px" word-spacing="0px"><text x="3.4088731" y="62.201187" font-size="5.6444px" style="line-height:125%" xml:space="preserve"><tspan x="3.4088731" y="62.201187" font-size="5.6444px" stroke-width=".26458px">Double-keypress voting</tspan></text><text x="3.4088731" y="6.5649157" font-size="5.6444px" style="line-height:125%" xml:space="preserve"><tspan x="3.4088731" y="6.5649157" font-size="5.6444px" stroke-width=".26458px">Navigation</tspan></text><g font-size="4.2333px"><text x="71.644356" y="21.343708" style="line-height:125%" xml:space="preserve"><tspan x="71.644356" y="21.343708" font-size="4.2333px" stroke-width=".26458px">Next</tspan></text><text x="17.629141" y="20.320484" style="line-height:125%" xml:space="preserve"><tspan x="17.629141" y="20.320484" font-size="4.2333px" stroke-width=".26458px">Previous</tspan></text><text x="32.415588" y="72.919655" style="line-height:125%" xml:space="preserve"><tspan x="32.415588" y="72.919655" font-size="4.2333px" stroke-width=".26458px">Safe</tspan></text><text x="46.429398" y="77.841766" style="line-height:125%" xml:space="preserve"><tspan x="46.429398" y="77.841766" font-size="4.2333px" stroke-width=".26458px">Suggestive</tspan></text><text x="59.079708" y="84.701698" style="line-height:125%" xml:space="preserve"><tspan x="59.079708" y="84.701698" font-size="4.2333px" stroke-width=".26458px">Explicit</tspan></text></g></g><g fill="none" stroke="#000" stroke-width=".26458px"><path d="m28.419 87.894v-16.655h3.4254"/><path d="m43.137 88.012v-11.41h2.9057"/><path d="m57.145 88.036v-4.9373h1.63"/><path d="m39.664 29.572v-10.442h-2.9766"/><path d="m68.414 29.431v-9.6384h2.3624"/></g><g fill="#000000" font-family="Sans" font-size="4.2333px" letter-spacing="0px" stroke-width=".26458px" word-spacing="0px"><text x="103.16604" y="72.854546" style="line-height:125%" xml:space="preserve"><tspan x="103.16604" y="72.854546" font-size="4.2333px" stroke-width=".26458px">Tame</tspan></text><text x="117.59858" y="78.252075" style="line-height:125%" xml:space="preserve"><tspan x="117.59858" y="78.252075" font-size="4.2333px" stroke-width=".26458px">Violent</tspan></text><text x="130.56116" y="85.112007" style="line-height:125%" xml:space="preserve"><tspan x="130.56116" y="85.112007" font-size="4.2333px" stroke-width=".26458px">Brutal</tspan></text></g><g fill="none" stroke="#000" stroke-width=".26458px"><path d="m99.613 87.987v-16.655h3.4254"/><path d="m114.33 88.105v-11.41h2.9057"/><path d="m128.34 88.129v-4.9373h1.63"/></g><g fill="#000000" font-family="Sans" letter-spacing="0px" stroke-width=".26458px" word-spacing="0px"><text x="3.5907741" y="120.37173" font-size="5.6444px" style="line-height:125%" xml:space="preserve"><tspan x="3.5907741" y="120.37173" font-size="5.6444px" stroke-width=".26458px">Single-keypress voting (numpad)</tspan></text><text x="3.5907741" y="207.49449" font-size="5.6444px" style="line-height:125%" xml:space="preserve"><tspan x="3.5907741" y="207.49449" font-size="5.6444px" stroke-width=".26458px">Single-keypress voting (number keys)</tspan></text><g font-size="4.2333px"><text x="58.447845" y="192.16911" style="line-height:125%" xml:space="preserve"><tspan x="58.447845" y="192.16911" font-size="4.2333px" stroke-width=".26458px">Safe</tspan></text><text x="71.312592" y="188.0325" style="line-height:125%" xml:space="preserve"><tspan x="71.312592" y="188.0325" font-size="4.2333px" stroke-width=".26458px">Suggestive</tspan></text><text x="85.480835" y="181.6974" style="line-height:125%" xml:space="preserve"><tspan x="85.480835" y="181.6974" font-size="4.2333px" stroke-width=".26458px">Explicit</tspan></text><text x="99.153763" y="167.49852" style="line-height:125%" xml:space="preserve"><tspan x="99.153763" y="167.49852" font-size="4.2333px" stroke-width=".26458px">Tame</tspan></text><text x="99.108292" y="152.56093" style="line-height:125%" xml:space="preserve"><tspan x="99.108292" y="152.56093" font-size="4.2333px" stroke-width=".26458px">Violent</tspan></text><text x="98.725883" y="137.72504" style="line-height:125%" xml:space="preserve"><tspan x="98.725883" y="137.72504" font-size="4.2333px" stroke-width=".26458px">Brutal</tspan></text></g></g><g fill="none" stroke="#000"><g stroke-width=".265"><path d="m53.084 174.78v15.727h4.8443" marker-start="url(#b)"/><path d="m67.416 174.78v12.152h3.107" marker-start="url(#c)"/><path d="m81.882 174.78v5.1697h2.4722" marker-start="url(#d)"/><path d="m90.892 136.54h7.3499" marker-start="url(#e)"/><path d="m90.892 151.48h7.3499" marker-start="url(#f)"/><path d="m90.892 165.98h7.3499" marker-start="url(#g)"/></g><g stroke-width=".26458px"><path d="m16.353 227.98v-5.9791h39.239v6.0804"/><path d="m59.158 227.98v-5.9791h39.239v6.0804"/><path d="m101.85 227.98v-5.9791h39.239v6.0804"/></g></g><g fill="#000000" font-family="Sans" font-size="4.2333px" letter-spacing="0px" stroke-width=".26458px" word-spacing="0px"><text x="29.492479" y="220.94774" style="line-height:125%" xml:space="preserve"><tspan x="29.492479" y="220.94774" font-size="4.2333px" stroke-width=".26458px">Tame</tspan></text><text x="71.213379" y="220.94774" style="line-height:125%" xml:space="preserve"><tspan x="71.213379" y="220.94774" font-size="4.2333px" stroke-width=".26458px">Violent</tspan></text><text x="114.10925" y="220.94774" style="line-height:125%" xml:space="preserve"><tspan x="114.10925" y="220.94774" font-size="4.2333px" stroke-width=".26458px">Brutal</tspan></text><text x="112.54527" y="261.7034" style="line-height:125%" xml:space="preserve"><tspan x="112.54527" y="261.7034" font-size="4.2333px" stroke-width=".26458px">Safe</tspan></text><text x="125.41002" y="255.4501" style="line-height:125%" xml:space="preserve"><tspan x="125.41002" y="255.4501" font-size="4.2333px" stroke-width=".26458px">Suggestive</tspan></text><text x="139.57826" y="249.11504" style="line-height:125%" xml:space="preserve"><tspan x="139.57826" y="249.11504" font-size="4.2333px" stroke-width=".26458px">Explicit</tspan></text></g><g fill="none" stroke="#000" stroke-width=".265"><path d="m107.18 242.2v18.065h4.8443"/><path d="m121.51 242.2v12.152h3.107" stroke-dasharray="0.79499996, 0.79499996"/><g stroke-dasharray="0.26499999, 0.52999997"><path d="m135.98 242.2v5.1697h2.4722"/><path d="m93.551 242.2v5.1697h42.429"/><path d="m50.453 242.2v5.1697h43.097"/></g><path d="m78.918 242.2v12.152h42.596" stroke-dasharray="0.79499996, 0.79499996"/><path d="m36.322 242.2v12.152h42.596" stroke-dasharray="0.79499996, 0.79499996"/><g><path d="m64.318 242.2v18.065h42.863"/><path d="m21.455 242.2v18.065h42.863"/><path d="m21.344 145.94v3.4509" marker-start="url(#a)"/></g></g><text x="13.417314" y="153.64476" fill="#000000" font-family="Sans" font-size="4.2333px" letter-spacing="0px" stroke-width=".26458px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><tspan x="13.417314" y="153.64476" font-size="4.2333px" stroke-width=".26458px">Enabled</tspan></text><image x="32.846" y="14.989" width="42.862" height="28.575" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKIAAABsCAMAAADezpntAAAAgVBMVEUAAAD///+vr69AQEAAAAC/ v79gYGAfHx+Wlpa2trbMzMx/f3/Dw8M/Pz+ZmZnBwcG6urq3t7fCwsLIyMjz8/P8/Py5ubmPj4+Q kJBcXFz4+PiYmJhPT08gICCXl5f29vb7+/vs7OyCgoLd3d2jo6MICAh2dnakpKSEhITc3Nzu7u7M DXksAAAAAXRSTlMAQObYZgAAAXpJREFUeAHt2mVyI0EUA2A3mJmZ2fe/327yTjBPVZFJ+j2q/hKz rRKWEGLKrqRyCCVmQqVaqzccqdeqTS4xVlsNZ1rVMpXY7tix3V6/QHoDu7jTphJzy4TD0bhARkMz 1jOXaP+XiQkLGHt2+TOI/XHB9EV8f+J09urE4Xw+fPX/Yrn8av9FEUXU86KIIooooogiiiiiiCKK KKKIIooo4mTx8l/HD1w/avCJZvT8NEQnAhFRxL/5mdKdOpfYXvqJnRWVGNf+n8zXZSoxNNcb3/Bg s24GLjGU29mVdjkE6laE2QK3IswWuBVhtsCtCLMFbkWYLXArwmyBb/GZra8gbrcvTyyXRXw34m7P Je53buLhyCUeD17i6Wy5eA6ztRNAvJwtV+9/8XYHDptO4f/i/ea/Lz64T92PnV5dRBRRRBFFFFFE EUUUUUQRRRTxK4nAVoTZArcizBa4FWG2wAkBsyXi1xChrQizBW5FmC1wK8JsgVsRZgvcijBb/wHQ F9u524090QAAAABJRU5ErkJggg== "/><image x="8.2881" y="87.802" width="128.59" height="14.288" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAeYAAAA2CAAAAAA3sWWIAAAC2klEQVR4Ae3cA4wcQBTG8Qlrt+e7 RW3btm27jdMLa9u2bevs2rZtvmSa2a3ifZPL5Cb5Xnj4ZV7mf8YKKXfVEqypFSEllF1KyF6OhQdP MebgQscoCWWXErscMaeYE+OIgLJLiZpLTqmJOnrIhzkafUrNkppQdikhYjwq8SP5MB8TPe6gbQpK nFJzRClf3NFTamxTUF52iHycQ15mmYL6n1Uuzj8ssX6ZYhX2MNW39A5XcMsEtgoICgqqwVT0c1YJ v5AmSdyz7hHR7Lbcqx8R5IH8s9YWfsxXWpkvVKody87siiGKyPmUv+Knlf7RbKXzVh9e9vTPzytL vDWSmUgv8wn3dTKUOXz2kqHsFbOcJKLPWitOqmMi85OsF/jKdOZz7iQylPl7gedv3NxgNC1X5xUP 9Fa8lPGLgcy7i1Cazxxf4AiZynygC1Gf7dzMdGd579DhP3VWfJ3+pcbn5h7MDdfUJnrt55dlNvOs PH5+ftkMZS7sWG8sc+e8BQrka63z9d77Evt1Vryc9aeB9+YDBUnNkNlp+L35QHLAOUOZXzm+Ef1w M7+YenlaPbfRUp0Vx7YiA5lfZ01Uzx5sKPPn9C91gi0s8tpM5kWej4b95vIOO+0fRbQ/52X+ij82 BJwxkZkmFI3/+XlNYISBzIOH0+Zieh9+u7X5aSRz9c3q2bsrMy9xb/UC+Wse5n/f7A5rlERGMtPK CkGODqfJQObbdULLJuhlfld8gs5PEU4wMuOnRZYrZIZCZmTGJSIzFDJDITMUMkMhMxQyQyEzFDIj My4RmaGQGQqZoZAZCpmhkBkKmVNDITMUMkMhMxQyQyHztt9j04bIDGUg85Fv/Ac6gbJIeVk06wGI TkHZphRTjvNwYqegLFOK8QfKFmVjZihkhkJmZI7hq4NQtqmai/lsSUsou5TY5eQ/DLczAsouJeQo 5yLeg+ovco6SUHYpIWVETcGamhFSQtmlfgGALnyYyouPPAAAAABJRU5ErkJggg== "/><image x="14.411" y="130.66" width="14.288" height="14.288" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADYAAAA2CAAAAACpLjUBAAABuElEQVR4Ae3WA4wlMRwG8H94tm3b tm3bNuKzLzzbZnQ21rZt21/Sne1ba9q3xjfGbzxtibH3Y0kqY38xRmxtm2sfjSXy8Vqbo4zet/ln LJl/bX7RmJt89s/XTwL5+pfvfHMM0T+u9KMgkCh97j4SEfdfBBR3X/nuaewTBPMpO4uvuh/A+wWy rF5XW21Y7aezOdPphZTBdPCxfgOM53TZpcKqY/TnTMyizk8sGRgeWs9Ljen0S8jEmgNHtwPdjdUY Vl/NxDoDJw4DvQxUmVvHhwug3wP4IsVwbOACeNbzT9ogxyLaLQAOdxh9ubsoE0jJs/iq7sgek25F zV4NHTHJBng3qPeKSIXFTbogwlybuePBSHi18U5adV5hO7ZAhN2dC8RUDXm4QHORN6bEC7Gz6wHU c7i4nrP6jdZC7GxzgKiqYQ9nc9bctcc7Iebe1A13JsCruSt2HFPu7W8bPxXWtHnz5j/weuiIaU7A mwG9F4QoDAeXaPmVlHtWySrZl3itqvy/Ug0MzjROpjnDmVTKFvsnrz4Sjbkhz27Oofdt5ZuhbX8R O9r2ulyj93rbo4wY+zWGpDLmF2PJWy4WktFXI7IAAAAASUVORK5CYII= "/><image x="46.237" y="129.98" width="42.862" height="42.862" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKIAAACiCAAAAADnmUnHAAAFw0lEQVR4Ae3cA5Bb+xcH8Ixv7Xad bfpvUK9t/J+zz7Zto+ZDbds2UzOobSObYp18Z/J27p03dZNzt/1V5xS7wXfOZ2KcORqvd3q6hlTp Fq9XZErjfUvbb56VUPP6aVt7RaY007WrrMRapbWITGnSBsq/rlg0349atFI+88A0kSmNZpWcWl8A P6pgvZybJzalka0LlZTv3CL57GJTSmw+/Kz5Skxk6nJsnK68pLPUZuPiU3K2k4mTWuqezqcS5Zpt pjY7HHACIzKoqQMNduLjT9QQSyN3UpstbwVsDqWmRpqBnUFqiIPfA7XZJcOSS7+8TybmAYclpwpi hINMxMjAloZt1NS++vvQWjpOJ65uBjJxrckJy/9KiSkMN7bqWLmATmz7M53Y/Q0A1Q5RiQDszUAn PjGeTlxqyMfykDJi6mSGs+zFLiqIMUvpRPwVmZC6gpzqamz0aanP1D307MJEJjKRiUxkIhOZyMRS 6TCAHs+KIJZKWp02ZiGUwmnpAoAR/7+3iIeBJfVPqCZOik/K2QF7bJuIKKvZ8DkwJzH58UM3I3bv oZKIuBlo3zRnkP4ycWPEL7EtLL6JBwMPY0QyttRYhhejL7hqHTsetA/9nroJcf1rr61XSWw1f0vw GffzpstEW5XpmND0FsR69evXr/EshuYBRVL+liCg9SdAE+vIPOBC5ZIbE2GxQBWxaKTWNeAFYMKV xLoelEinfV6Knd8BUGvPFj3Q7geg+Ya/AmLK68RtJWp1BrMDnT4AVplwVnIBGPI4bAYAtXb7JA41 AwXS+cvE0WYlcnsvRQDo+zIw0QR3TTuAHz+CrT5QIp31STwccAhDsnCZeCJoDyzmO0Fcr3V5XjEB n5idsASsha3SPEyNgE8iJscnPbrvCiLmxMenbLoTRPxgeqxvFFDwTXhQ3CzA1uSHpIiV99Szy6WF Jfj7vcuHbaZ77gmw7MNmCU8c9Y+4sJT+wbqwlBJbSfqiwSoupRCVHOXrGqvQlBKjltgUEx8W4ip6 ap7YVNoAemygWWRKMz2c/jV2uEVkSuNtHd6fNgzQP7y1V2RK4/Va0jSkSrN4vSJTPGHCEyY8YcIT Jnfvw7tF0g5qs0JJp9N9QCYezGkYtVYFsSCuEZl4LETVpZg9BhM+UUH8uXsEmbi9iRriPpO6K9qe WEYnrg5NN6Rvpaam574ZkWAjE92pa0EnHux5Fj1bUlMjqm/G2JZkYq/PQSfK5a5+hHopxgOe6qep xE8iIiKqN11EJRYCJZXPEFMOPeCu6qISATWX4uQIJ7qlUlOInoShKRBD9LQ2GJ84QCY6EoxZu/mr ISYykYlMZCITmchEJjKRiQ8q0eMUS3R6yMR2B8QSD7SjEkdLcv0ggviD0msMkYgO+8Veivs70G+L LrFEl+eBukfzhAlPmCjFsxFM5AkTnjDhCROeMOEJE54w4QkTnjBxPCedpjcb10KfuYOcGty0cfYu OvG1BZXpxD0Bu9E5l5qyNjyKdrl0IqCC6NoK2BpRU87dwFq9GKJcf72qInXmre/FEZfpD9JTf1TP OCmMOM24RUUKnoHN3YKI85sdADm11wa4Kx8XQ3TptoFOnGU6hclaD5V4TqeTwnWnqMt7qurK6xwx hc5GfdIq/qybiUxkIhOZyEQmMvH+2mESEFReK3G5bCYRO0zkgw2Dzev8XLMCUInFxRXcYaIcLBzW YKW/RHtsxxj9AqBTs6yO/hAtlgrvMFEOds6E0tsncUuNKRiRju3B5/A+iah+h4lycHvlYqX3LYgh YWFhcdhSF3A0xoDngIUkovodJgoxXzqn9PZ9Kerkf53eBdaTiOp3mCitd1T3KL39JfZ/DphDIqrf YaIcbJcHGtEedMbzxpVE2j4dwg4ThegeG2D3SVQeF//572ytDYnd9b6J6N69ojtM5Os97JENuEy8 d3eYAPfoDpP7csKEJ0x4woSJPGHCEyY8YcITJv8CRhJEvIEgUSUAAAAASUVORK5CYII= "/><image x="14.765" y="227.98" width="128.59" height="14.288" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAeYAAAA2CAAAAAA3sWWIAAAE4UlEQVR4Ae3cA2wtaRjG8Ymvbd+p 3WvbyrVt2zzXtm3btm27tt3zJLPN6SJOn0k6u5N936D8Db5/jU/RtFN1FWrq3tI0cylRitan9MaL z4m5uLG0RRNlLqWcKn3vOTn3St8ylxKl1Nlie/TO1UsZmKt3ba+8pY65lChFuWdTj+OQgYl7bHMX zaZEKbbeVzKgbO6q7dXNpkSls0vI4FxKZ/+WaqJLQdQ/rMwd/mRYMuZ0M14hrrO9w25WWad453Xf waqgVp0WAmj1mFLwd+2sZ+m3N6i/g1bxzXVlnjdVT2bHh/zJsH76tQ68wsa2qS/ms+pIo6Qmv4r+ ItWKjagchxNDOYVhp1s/4O/LqibG27NqxtRKPS7z53paIUVPZtdnfGbsmne/F68wcR54tWwEmiCV VRNPocnv2KohnPpUFy8agc9cFrfq0GqO60uAVu33QU9mr9fGfV4ZNx+8eldkXn0rrdasQ6WEmdtI 1eUK0OMMvxotOrT4xarwnj+GAqyKzxemK3PFD4Zlnqs6NepOK7wblU3dyKrQVm2Xf64f3bbjuMy/ L8ytUAFoFGPEGr5VoStzta8Gvjcv1PmV9iP1Jq/Q+skBCxr9yvT7Ot0oud32z3UNWcO73voy1/5p XObeO8Gr95FogpGr+Cs8OQzLNqPXg0y/r6mr4GPf9DD+y+/NgGGZfd2CYR1whlR9xiQ3CfS6TSog rlooDlrQ8Hem39fu1ikYlO2zIWsYlydUV+ZiDwzLPCK/qpZqEUuqiH6OuT238Fdo2Q7Ete8yNfPv K2V01Voj9rh2N2IN0XovoeSnYGZVj8olmy4zr0TNnSKZRZk8syjJLEoyS2ZZDsksiyiZRUlmUZJZ lGQWJZlFSWZRklkyv+qQJZg/2UFPx/ofaLXNzaHhJ1oBV7PQ54rPoqrqIFbhZ6OyFR6y6qCaNllC 6TWsWqvRe7DqqJfaNoLP3ONyNj7zlyKfsagxq56X9cVcWgFxVezozH4ldL2vNNyPw8NoBeBca1b9 LhKA3fVY9aPwRwwdxmcG+MyIfAu8sGNV+GfgoSOrgKmrytGZ37vqyfzNRY8CkFz+I6tuewOvS7Jq T2vgYzFjMttmeXfwKqTPRFq9rJ7CZ75fsq5T3besOtW4d7lqL1gFYNsAsCrW6XrstIF05jbA7yzh hmW+6fiTV7Ny1QtkVWrth+Az/1wTijVerNqd6zUO0ApAuVeg1Z6iXk7vWPWt0DdYsvgblfmk8xvw CtYtHqmkWjsSfGbbpObyIdWpqoA1VzB9rvvuAEj10CUct+yTSYVdzt4LssUZlPmS+w/Q6usLIDWb P6mGlStXLpfbVVIhHkjKFkKqV45Aao5IOvOcqfxqrOoFIOcv+lzAS3ejvgRT3wG0OusShGOlraQC 9Lw3HysXjqW1WYWKR7GjFq3Q8hDAqhtOEbhdIoVUgfXCUzovpjOHqWqWMmoQ+4VADjVtwkiFRc6O Ne7BkMxWi5Nzyx+swqtqzg0+0wqVbgC0Wl6+Wu07tFribDc8WX4K9v9UklkyX0nmNzoRZSKVzu5S GxA9F2U2lcZsjtlO7Lkokykbo0eUuZRkNpeSzKIks2S+x6uLosym6mzm2ZbWosyllFNl+G24y9wS ZS6laJYym7hN9TeVsWiizKUUTbtVR6Gmzi1NE2Uu9Qe0PRGkp7gDBgAAAABJRU5ErkJggg== "/><image x="100.52" y="29.277" width="14.288" height="14.288" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADYAAAA2CAAAAACpLjUBAAAA8klEQVR4Ae3WgUZEQRTG8fMKCcAd UCCIBGbRLkGPENJb3CAFggB7iRK4bzCMa0fQgMUGgkL0BCcsZH1oHYLAfIFi/mBwfgDmE9V+JFSj oCp63EzjgihOm1alb/KCLDdBfGfPxzQUlLIdd14km5ovUdBybtdRRMzPCpS5ZOffbEBhw092cIN1 txOS3Y2x7vCeZLrxBrxvfpAMJ1fA9SlYlnaB/QearbaenrdXNMP52eUFePays/cKnmE8Ac+syiqr rLLK/gCbff7qy8/UwDBmZWbOGOP7NyzzKooNQ7buSHrHz1AXRFvHjl7XqqgGL1Q+qH4BVYyAWvH8 sQUAAAAASUVORK5CYII= "/><text x="110.50974" y="21.473932" fill="#000000" font-family="Sans" font-size="4.2333px" letter-spacing="0px" stroke-width=".26458px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><tspan x="110.50974" y="21.473932" font-size="4.2333px" stroke-width=".26458px">Toggle fullscreen</tspan></text><path d="m107.37 29.547v-9.6384h2.3624" fill="none" stroke="#000" stroke-width=".26458px"/></g></svg>
diff --git a/static/f/nonfree.svg b/static/f/nonfree.svg
deleted file mode 100644
index 07bdb646..00000000
--- a/static/f/nonfree.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="417pt" height="569pt" viewBox="0 0 417 569" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#231f20ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 187.05 9.04 C 215.43 4.50 244.34 10.44 271.15 19.75 C 281.61 23.76 292.33 27.44 302.01 33.15 C 297.99 44.75 293.08 56.06 288.51 67.46 C 281.64 85.22 274.18 102.74 267.44 120.55 C 263.36 121.55 259.13 120.75 254.99 120.78 C 229.81 120.33 204.63 119.82 179.45 119.46 C 173.37 119.10 167.25 119.59 161.20 118.80 C 151.94 94.16 143.89 69.00 133.14 44.94 C 134.05 41.44 136.85 38.81 138.98 35.99 C 150.85 21.32 168.55 12.04 187.05 9.04 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 66.51 72.43 C 67.69 72.01 68.64 72.77 69.52 73.45 C 90.10 89.89 110.74 106.25 131.33 122.68 C 134.08 124.90 137.05 126.88 139.51 129.43 C 140.16 134.03 139.19 138.69 139.81 143.31 C 145.38 140.26 148.98 134.67 154.25 131.19 C 157.71 130.18 161.43 131.06 165.00 130.95 C 196.69 131.42 228.37 132.18 260.06 132.70 C 263.86 132.54 266.34 135.82 269.13 137.87 C 272.66 141.02 277.00 143.28 280.00 146.97 C 277.00 151.54 272.98 155.32 269.78 159.75 C 268.67 160.90 267.81 162.89 265.95 162.81 C 237.63 162.70 209.33 161.68 181.01 161.53 C 171.44 161.07 161.84 161.39 152.29 160.77 C 147.08 155.61 143.45 148.95 137.81 144.23 C 110.65 128.16 83.86 111.45 56.79 95.23 C 55.88 94.54 54.59 94.10 54.17 92.95 C 57.94 85.92 62.39 79.25 66.51 72.43 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 58.23 149.12 C 60.47 148.64 62.77 148.93 65.03 149.04 C 83.70 150.30 102.40 151.15 121.07 152.31 C 125.21 152.59 129.45 152.32 133.47 153.45 C 135.59 158.31 137.80 163.13 139.79 168.04 C 133.34 169.37 126.69 169.18 120.19 170.19 C 97.27 172.22 74.39 174.77 51.48 176.89 C 53.84 167.66 55.66 158.29 58.23 149.12 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 156.34 167.29 C 157.72 166.49 159.42 166.80 160.94 166.73 C 193.30 168.19 225.68 169.17 258.04 170.60 C 260.55 170.46 262.54 172.14 264.38 173.62 C 294.54 198.22 322.37 225.72 347.13 255.76 C 364.12 277.19 379.76 299.91 391.21 324.82 C 398.56 340.54 404.16 357.17 406.75 374.37 C 410.52 396.52 407.62 419.25 401.94 440.81 C 393.60 473.89 374.52 504.51 347.11 525.09 C 339.91 530.50 332.18 535.20 324.06 539.12 C 315.06 543.81 305.17 546.33 295.60 549.59 C 232.31 568.05 163.25 565.88 101.09 543.98 C 85.32 538.71 70.12 531.29 57.11 520.85 C 40.32 507.72 27.77 489.52 20.53 469.53 C 9.42 437.57 9.81 402.45 17.63 369.79 C 18.52 364.27 18.80 358.65 20.25 353.22 C 25.80 330.50 36.36 309.34 48.48 289.46 C 76.09 245.09 112.44 206.94 150.85 171.82 C 152.61 170.23 154.29 168.51 156.34 167.29 M 194.23 216.22 C 192.75 222.15 194.13 228.34 193.41 234.37 C 172.70 236.70 151.62 240.85 133.33 251.33 C 118.36 259.90 105.43 273.00 99.31 289.34 C 94.00 304.29 93.39 321.47 99.88 336.16 C 107.00 351.25 120.48 362.51 135.27 369.74 C 153.33 379.07 173.57 383.00 193.59 385.34 C 193.55 408.48 193.62 431.63 193.55 454.77 C 191.68 455.53 189.61 454.69 187.70 454.48 C 172.53 451.51 156.93 447.16 144.67 437.32 C 139.32 433.31 136.87 426.31 130.72 423.24 C 120.35 417.78 106.07 420.53 98.74 429.78 C 94.04 435.75 93.97 444.52 98.22 450.75 C 103.99 459.47 111.72 466.82 120.37 472.65 C 139.28 485.53 161.61 492.49 184.10 495.73 C 187.26 496.17 190.46 496.40 193.53 497.28 C 193.86 502.19 193.30 507.11 193.64 512.02 C 193.85 516.18 196.63 519.68 199.68 522.28 C 207.33 528.22 219.94 526.63 225.25 518.26 C 229.88 512.21 226.08 504.15 228.32 497.46 C 250.80 495.20 273.70 490.80 293.55 479.49 C 308.26 470.99 320.89 458.09 327.17 442.14 C 333.30 424.98 333.02 405.00 324.23 388.79 C 316.03 374.14 301.86 363.78 286.57 357.45 C 268.01 349.77 247.83 347.26 227.95 345.75 C 227.18 339.53 227.83 333.25 227.63 327.01 C 227.78 309.95 227.34 292.87 227.83 275.82 C 237.98 275.86 248.03 278.26 257.68 281.26 C 267.00 284.44 276.32 288.60 283.49 295.52 C 287.30 299.08 289.55 304.09 293.87 307.12 C 303.29 313.74 317.33 312.63 325.57 304.57 C 331.06 299.41 332.40 290.60 329.34 283.83 C 325.68 276.54 320.06 270.39 313.94 265.08 C 291.90 246.62 263.21 237.82 234.94 235.22 C 232.60 234.77 229.80 235.32 227.79 233.89 C 226.76 226.76 229.69 218.47 224.64 212.38 C 217.34 201.95 198.61 204.11 194.23 216.22 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 149.17 290.18 C 161.48 280.27 177.69 277.24 193.00 275.62 C 193.89 276.88 193.50 278.53 193.64 279.97 C 193.46 301.54 193.70 323.12 193.52 344.69 C 188.66 344.50 183.89 343.46 179.10 342.69 C 169.06 340.87 158.77 338.58 150.21 332.73 C 145.85 329.79 141.82 325.88 140.18 320.76 C 136.87 310.01 140.15 297.20 149.17 290.18 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 227.83 387.02 C 240.38 387.76 253.13 388.95 265.12 392.96 C 273.70 395.95 282.35 402.01 284.48 411.36 C 286.45 420.54 284.86 431.18 278.17 438.16 C 271.39 445.79 261.30 449.06 251.81 451.84 C 243.93 453.73 235.97 455.64 227.86 456.17 C 227.30 441.13 227.80 426.05 227.63 411.00 C 227.79 403.01 227.31 395.00 227.83 387.02 Z" />
-</g>
-</svg>
diff --git a/static/f/notes.svg b/static/f/notes.svg
deleted file mode 100644
index 8c1f1bb9..00000000
--- a/static/f/notes.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="384pt" height="384pt" viewBox="0 0 384 384" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 0.00 0.00 L 384.00 0.00 L 384.00 384.00 L 0.00 384.00 L 0.00 0.00 M 32.01 32.01 C 32.00 138.67 32.00 245.33 32.01 351.99 C 138.67 352.00 245.33 352.00 351.99 351.99 C 352.00 245.33 352.00 138.67 351.99 32.01 C 245.33 32.00 138.67 32.00 32.01 32.01 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 64.00 128.00 C 149.33 128.00 234.67 128.00 320.00 128.00 C 319.99 138.67 319.99 149.33 320.00 160.00 C 234.67 160.00 149.33 160.00 64.00 160.00 C 64.01 149.33 64.00 138.67 64.00 128.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 64.00 192.00 C 149.33 192.00 234.67 192.00 320.00 192.00 C 319.99 202.67 319.99 213.33 320.00 224.00 C 234.67 224.00 149.33 224.00 64.00 224.00 C 64.01 213.33 64.00 202.67 64.00 192.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 64.00 256.00 C 128.00 255.99 192.00 256.00 256.00 256.00 C 256.00 266.67 256.00 277.33 256.00 288.00 C 192.00 288.00 128.00 288.00 64.00 288.00 C 64.00 277.33 64.00 266.67 64.00 256.00 Z" />
-</g>
-</svg>
diff --git a/static/f/patreon.png b/static/f/patreon.png
deleted file mode 100644
index ae2938b7..00000000
--- a/static/f/patreon.png
+++ /dev/null
Binary files differ
diff --git a/static/f/resolution_16-9.svg b/static/f/resolution_16-9.svg
deleted file mode 100644
index 50f8935f..00000000
--- a/static/f/resolution_16-9.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="512pt" height="402pt" viewBox="0 0 512 402" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 20.20 0.00 L 491.80 0.00 C 501.17 1.97 509.71 8.82 512.00 18.33 L 512.00 383.77 C 508.98 392.91 501.65 400.88 491.70 402.00 L 20.20 402.00 C 10.83 400.03 2.29 393.18 0.00 383.67 L 0.00 18.33 C 2.29 8.82 10.83 1.97 20.20 0.00 M 149.15 144.17 C 135.56 169.04 135.84 198.61 137.78 226.05 C 139.54 245.53 144.72 266.51 160.25 279.74 C 174.59 293.46 196.42 295.97 214.97 290.93 C 231.62 286.26 244.76 272.34 250.17 256.12 C 254.52 241.20 253.98 224.78 248.91 210.09 C 241.03 189.94 219.16 173.91 196.99 177.85 C 186.90 178.38 178.74 185.05 171.31 191.21 C 172.61 178.97 173.42 166.21 179.01 155.00 C 183.24 146.13 193.89 140.77 203.57 143.44 C 214.10 145.07 215.99 160.37 227.01 160.87 C 237.29 162.09 246.48 152.14 244.14 141.97 C 241.44 132.79 233.42 126.26 225.73 121.26 C 199.55 107.74 163.29 117.93 149.15 144.17 M 393.60 122.55 C 383.36 128.52 375.58 138.15 370.91 148.95 C 366.72 159.36 366.61 170.92 367.12 181.97 C 367.96 205.15 386.07 226.96 409.12 230.84 C 423.32 233.46 437.71 227.63 448.22 218.16 C 446.58 231.83 446.56 247.18 437.35 258.35 C 430.57 266.78 416.13 269.14 408.57 260.52 C 403.42 254.34 396.77 245.35 387.44 248.45 C 381.26 249.13 377.28 254.92 375.30 260.31 C 373.81 269.32 379.96 277.12 386.32 282.66 C 398.98 293.49 417.14 295.31 432.96 292.01 C 451.18 288.38 466.70 274.93 473.91 257.95 C 482.84 237.64 483.45 214.86 482.81 193.04 C 481.59 172.55 478.53 150.47 464.72 134.27 C 447.85 113.86 416.17 109.46 393.60 122.55 M 76.13 120.12 C 68.72 127.74 63.82 137.68 54.99 143.99 C 46.95 151.32 36.28 154.93 28.30 162.28 C 20.72 171.92 31.40 187.70 43.32 183.37 C 53.03 179.63 60.98 172.41 68.98 165.95 C 69.52 203.82 68.06 241.75 69.58 279.58 C 71.83 291.73 89.02 296.19 97.27 287.27 C 104.03 281.49 101.43 271.71 102.11 264.01 C 101.70 220.68 102.40 177.34 101.82 134.02 C 103.81 120.92 85.99 111.52 76.13 120.12 M 308.43 138.48 C 296.91 141.11 287.39 152.91 289.05 164.95 C 288.87 179.66 303.86 192.31 318.39 189.48 C 332.94 188.01 344.23 172.24 340.06 157.97 C 337.45 144.16 321.98 134.79 308.43 138.48 M 309.44 219.52 C 302.83 220.98 296.49 225.08 293.14 231.10 C 284.06 243.32 290.01 263.28 304.22 268.76 C 319.43 276.54 339.52 264.61 340.66 247.82 C 343.38 230.85 325.87 215.57 309.44 219.52 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 416.45 143.52 C 428.79 140.52 440.73 150.78 443.43 162.44 C 446.49 175.07 447.18 190.57 437.85 200.84 C 430.47 207.73 418.31 209.34 410.13 202.88 C 398.92 194.40 398.94 178.66 399.84 165.94 C 400.26 156.26 406.20 145.26 416.45 143.52 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 192.43 202.44 C 205.77 199.13 218.60 210.52 219.42 223.59 C 220.47 236.02 222.13 251.59 211.86 260.85 C 204.89 267.64 193.23 267.21 185.99 261.02 C 175.27 253.04 173.80 238.61 174.32 226.31 C 175.07 215.87 181.54 204.67 192.43 202.44 Z" />
-</g>
-</svg>
diff --git a/static/f/resolution_4-3.svg b/static/f/resolution_4-3.svg
deleted file mode 100644
index 7950572e..00000000
--- a/static/f/resolution_4-3.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="512pt" height="476pt" viewBox="0 0 512 476" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 17.30 0.00 L 494.70 0.00 C 503.19 2.31 509.02 9.35 512.00 17.32 L 512.00 458.69 C 510.18 462.48 508.55 466.54 505.63 469.64 C 502.38 472.36 498.59 474.39 494.68 476.00 L 17.30 476.00 C 8.80 473.68 2.98 466.65 0.00 458.68 L 0.00 17.32 C 2.98 9.35 8.80 2.31 17.30 0.00 M 378.44 126.53 C 358.73 128.78 338.73 139.50 330.08 158.06 C 324.53 166.42 327.30 178.91 336.02 183.95 C 342.51 188.56 351.79 187.52 358.01 183.01 C 364.13 177.11 367.45 168.44 374.98 163.98 C 385.94 157.11 402.74 160.09 409.37 171.65 C 414.81 183.25 412.53 199.81 400.92 206.87 C 394.34 211.70 385.67 212.24 379.10 217.07 C 373.74 223.66 374.33 233.59 380.68 239.32 C 387.98 246.73 400.09 242.66 408.00 248.98 C 417.77 255.85 421.72 268.49 421.02 280.01 C 421.53 292.82 414.90 306.41 402.98 311.94 C 390.61 318.21 373.32 314.15 366.37 301.77 C 362.16 295.33 357.79 287.26 349.55 285.53 C 337.28 282.07 324.15 292.61 324.47 305.19 C 325.06 314.99 331.62 323.07 338.21 329.79 C 363.06 354.02 404.77 355.99 433.40 337.41 C 446.59 328.40 457.08 315.06 461.64 299.65 C 466.50 281.57 465.08 260.32 452.39 245.64 C 445.86 235.92 434.47 232.06 424.04 228.19 C 431.10 222.23 439.53 217.67 444.91 209.93 C 451.04 202.04 454.86 192.07 454.14 181.98 C 454.49 159.50 438.31 139.29 417.96 131.09 C 405.69 125.33 391.66 125.20 378.44 126.53 M 124.82 142.86 C 100.11 178.83 75.94 215.18 50.94 250.96 C 45.72 259.10 38.60 267.93 40.53 278.33 C 41.19 292.00 53.74 303.11 67.12 303.76 C 88.04 304.54 109.01 303.72 129.95 304.00 C 131.06 315.96 126.66 330.03 134.68 340.32 C 141.42 349.15 155.70 350.19 163.83 342.82 C 174.24 332.84 170.04 317.13 171.17 304.39 C 179.00 303.25 188.63 304.04 193.74 296.77 C 200.20 289.42 199.19 276.64 190.89 271.13 C 185.58 265.96 177.69 267.10 171.02 266.60 C 170.55 227.54 171.82 188.43 170.48 149.39 C 170.23 137.14 158.47 125.90 146.03 128.07 C 136.29 127.50 129.74 135.65 124.82 142.86 M 248.44 145.54 C 236.90 147.91 226.75 156.91 223.22 168.20 C 218.65 181.35 222.80 197.43 233.94 206.07 C 245.42 215.95 263.51 216.53 275.83 207.84 C 289.86 198.68 295.14 178.71 287.08 163.95 C 280.78 149.50 263.58 142.02 248.44 145.54 M 255.36 261.41 C 243.32 261.72 231.85 268.38 225.93 278.93 C 218.84 290.86 220.19 307.24 229.03 317.93 C 238.61 330.87 258.02 334.35 272.06 327.07 C 286.26 320.15 294.02 302.56 289.59 287.40 C 286.10 272.17 270.91 260.79 255.36 261.41 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 79.71 266.84 C 96.14 241.63 113.35 216.93 129.81 191.74 C 130.19 216.82 129.95 241.90 129.99 266.98 C 113.23 267.02 96.47 267.10 79.71 266.84 Z" />
-</g>
-</svg>
diff --git a/static/f/resolution_custom.svg b/static/f/resolution_custom.svg
deleted file mode 100644
index 4cdc7d25..00000000
--- a/static/f/resolution_custom.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="442pt" height="412pt" viewBox="0 0 442 412" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 14.00 1.00 C 102.34 1.00 190.67 1.00 279.00 1.00 C 278.84 36.22 279.36 71.45 278.69 106.67 C 272.12 106.85 265.56 106.96 259.00 107.05 C 259.00 78.04 258.99 49.02 259.00 20.00 C 183.67 20.00 108.34 20.00 33.00 20.00 C 33.00 62.00 33.00 104.00 33.00 146.00 C 77.00 146.00 121.00 146.00 165.01 146.00 C 164.95 160.89 165.17 175.79 164.80 190.68 C 131.68 191.24 98.55 191.11 65.43 190.79 C 65.25 187.85 65.08 184.94 64.91 182.01 C 83.81 181.92 102.71 182.21 121.60 181.74 C 121.72 179.06 121.97 173.70 122.09 171.02 C 86.06 170.98 50.03 171.02 14.00 171.00 C 14.00 114.33 14.00 57.67 14.00 1.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 177.00 118.00 C 265.34 118.00 353.67 118.00 442.00 118.00 L 442.00 287.00 C 405.00 287.00 368.00 287.00 331.00 287.00 C 331.00 289.75 331.00 295.24 331.00 297.99 C 349.57 297.99 368.15 297.89 386.72 298.28 C 386.89 301.21 386.90 304.03 387.10 306.99 C 350.51 306.92 313.92 307.21 277.33 306.79 C 278.82 300.77 283.74 295.07 283.13 288.90 C 274.57 282.68 279.23 270.99 278.00 262.20 C 326.00 261.73 374.00 262.13 422.00 261.99 C 422.00 220.33 422.00 178.67 422.00 137.00 C 346.67 137.00 271.34 137.00 196.01 137.00 C 195.94 160.67 196.06 184.33 195.98 208.00 C 189.66 208.00 183.33 208.00 177.01 208.00 C 176.99 178.00 177.00 148.00 177.00 118.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 0.00 221.36 C 88.32 220.51 176.66 221.24 265.00 221.00 C 265.00 277.67 265.00 334.33 265.00 391.00 C 228.33 391.00 191.67 391.00 155.00 391.00 C 155.00 393.75 155.00 399.25 155.00 402.00 C 173.33 402.00 191.67 401.99 210.00 402.00 C 210.01 405.02 209.96 407.93 210.00 411.00 C 157.33 411.00 104.67 411.00 52.00 410.99 C 52.00 408.02 51.99 404.94 52.00 402.00 C 71.00 401.97 90.00 402.03 109.00 401.99 C 109.00 399.25 109.00 393.75 109.00 391.00 C 72.67 391.00 36.33 390.99 0.00 391.00 L 0.00 221.36 M 20.00 241.00 C 19.99 282.67 20.00 324.33 20.00 366.00 C 95.33 366.00 170.66 366.00 246.00 366.00 C 246.00 324.33 246.00 282.67 246.00 241.00 C 170.67 241.00 95.34 241.00 20.00 241.00 Z" />
-</g>
-</svg>
diff --git a/static/f/story_animated.svg b/static/f/story_animated.svg
deleted file mode 100644
index 30f4fd53..00000000
--- a/static/f/story_animated.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="480pt" height="448pt" viewBox="0 0 480 448" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#914040" opacity="1.00" d=" M 25.43 0.00 L 453.57 0.00 C 466.56 3.01 477.06 13.39 480.00 26.43 L 480.00 421.57 C 476.99 434.56 466.61 445.06 453.57 448.00 L 26.43 448.00 C 13.44 444.99 2.93 434.61 0.00 421.57 L 0.00 26.43 C 2.86 13.65 13.00 3.67 25.43 0.00 M 63.99 32.02 C 64.02 42.68 64.01 53.33 63.98 63.98 C 85.32 64.03 106.66 64.05 128.01 63.98 C 127.98 53.32 127.99 42.67 128.02 32.02 C 106.68 31.97 85.34 31.95 63.99 32.02 M 159.99 32.02 C 160.02 42.68 160.01 53.33 159.98 63.98 C 181.32 64.03 202.66 64.05 224.01 63.98 C 223.98 53.32 223.99 42.67 224.02 32.02 C 202.68 31.97 181.34 31.95 159.99 32.02 M 255.99 32.02 C 256.02 42.68 256.01 53.33 255.98 63.98 C 277.32 64.03 298.66 64.05 320.01 63.98 C 319.98 53.32 319.99 42.67 320.02 32.02 C 298.68 31.97 277.34 31.95 255.99 32.02 M 351.99 32.02 C 352.02 42.68 352.01 53.33 351.98 63.98 C 373.32 64.03 394.66 64.05 416.01 63.98 C 415.98 53.32 415.99 42.67 416.02 32.02 C 394.68 31.97 373.33 31.95 351.99 32.02 M 64.02 96.02 C 63.98 181.34 63.99 266.66 64.01 351.98 C 181.33 352.01 298.66 352.01 415.98 351.98 C 416.02 266.66 416.00 181.34 415.99 96.02 C 298.66 95.99 181.34 95.99 64.02 96.02 M 63.99 384.02 C 64.02 394.67 64.01 405.33 63.98 415.98 C 85.32 416.03 106.66 416.05 128.01 415.98 C 127.98 405.32 127.99 394.67 128.02 384.02 C 106.68 383.97 85.34 383.95 63.99 384.02 M 159.99 384.02 C 160.02 394.67 160.01 405.33 159.98 415.98 C 181.32 416.03 202.66 416.05 224.01 415.98 C 223.98 405.32 223.99 394.67 224.02 384.02 C 202.68 383.97 181.34 383.95 159.99 384.02 M 255.99 384.02 C 256.02 394.67 256.01 405.33 255.98 415.98 C 277.32 416.03 298.66 416.05 320.01 415.98 C 319.98 405.32 319.99 394.67 320.02 384.02 C 298.68 383.97 277.34 383.95 255.99 384.02 M 351.99 384.02 C 352.02 394.67 352.01 405.33 351.98 415.98 C 373.32 416.03 394.66 416.05 416.01 415.98 C 415.98 405.32 415.99 394.67 416.02 384.02 C 394.68 383.97 373.33 383.95 351.99 384.02 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 128.01 C 215.35 155.64 270.65 183.37 326.01 210.98 C 334.53 215.31 343.33 219.18 351.47 224.22 C 287.69 256.22 223.81 288.04 160.01 320.01 C 159.98 256.01 160.01 192.01 160.00 128.01 Z" />
-</g>
-</svg>
diff --git a/static/f/subscribestar.png b/static/f/subscribestar.png
deleted file mode 100644
index 516b7c78..00000000
--- a/static/f/subscribestar.png
+++ /dev/null
Binary files differ
diff --git a/static/f/uncensor.svg b/static/f/uncensor.svg
deleted file mode 100644
index 40ce113a..00000000
--- a/static/f/uncensor.svg
+++ /dev/null
@@ -1,2 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="818.31" height="818.31" enable-background="new 0 0 1174.96 855.746" overflow="visible" version="1.1" viewBox="0 0 818.31 818.31" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-299.99 -.0010071)"><ellipse cx="708.06" cy="424.7" rx="364.48" ry="383.98" fill="#fff"/></g><g transform="translate(-299.99 -.0010071)" display="none"><g display="inline"><rect x="808.32" y="124.1" width="183.31" height="183.31" fill="#0a1623"/><rect x="991.65" y="124.1" width="183.31" height="183.31" fill="#fff"/><rect x="441.7" y="124.1" width="183.31" height="183.31" fill="#0a1623"/><rect x="625.03" y="124.1" width="183.31" height="183.31" fill="#fff"/><rect x="624.81" y="306.42" width="183.31" height="183.31" fill="#0a1623"/><rect x="808.14" y="306.42" width="183.31" height="183.31" fill="#fff"/><rect x="258.39" y="306.62" width="183.31" height="183.31" fill="#0a1623"/><rect x="441.73" y="306.62" width="183.31" height="183.31" fill="#fff"/><rect x="808.32" y="489.92" width="183.31" height="183.31" fill="#0a1623"/><rect x="991.65" y="489.92" width="183.31" height="183.31" fill="#fff"/><rect x="441.7" y="489.92" width="183.31" height="183.31" fill="#0a1623"/><rect x="625.03" y="489.92" width="183.31" height="183.31" fill="#fff"/><rect x="624.81" y="672.24" width="183.31" height="183.31" fill="#0a1623"/><rect x="808.14" y="672.24" width="183.31" height="183.31" fill="#fff"/><rect x="258.39" y="672.44" width="183.31" height="183.31" fill="#0a1623"/><rect x="441.73" y="672.44" width="183.31" height="183.31" fill="#fff"/></g></g><g transform="translate(-299.99 -.0010071)" fill="#0a1623"><rect x="431.19" y="368.64" width="122.22" height="122.22"/><rect x="553.87" y="245.98" width="122.22" height="122.22"/><rect x="677.66" y="122.21" width="122.23" height="122.22"/><rect x="456.96" y="148.65" width="97.328" height="97.327"/><rect transform="rotate(180.02 927.8 381.36)" x="866.69" y="320.25" width="122.22" height="122.22"/><rect transform="rotate(180.02 805.08 503.98)" x="743.97" y="442.87" width="122.22" height="122.22"/><rect transform="rotate(180.02 681.26 627.71)" x="620.14" y="566.6" width="122.23" height="122.22"/><rect transform="rotate(180.02 914.4 613.8)" x="865.74" y="565.13" width="97.328" height="97.327"/></g><g transform="translate(-299.99 -.0010071)"><circle cx="709.06" cy="410.7" r="301.48" fill="none" stroke="#0c1623" stroke-width="50"/></g><g transform="translate(-299.99 -.0010071)"><rect transform="matrix(.7068 .7074 -.7074 .7068 490.1 -396.09)" x="647.45" y="22.881" width="150.9" height="740.68" fill="#0c1623"/></g><g transform="translate(-299.99 -.0010071)" display="none"><g display="inline"><rect y="87.618" width="115" height="115" fill="#0a1623"/><rect x="115.02" y="87.618" width="115" height="115" fill="#fff"/></g></g><g transform="translate(-299.99 -.0010071)"><circle cx="709.14" cy="409.16" r="359.16" fill="none" stroke="#b5b5b5" stroke-width="100"/></g><g transform="translate(-299.99 -.0010071)"><line x1="429.08" x2="991.55" y1="688.69" y2="124.72" fill="none" stroke="#b5b5b5" stroke-width="100"/></g></svg>
diff --git a/static/f/voiced.svg b/static/f/voiced.svg
deleted file mode 100644
index 73e77a23..00000000
--- a/static/f/voiced.svg
+++ /dev/null
@@ -1,33 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="352pt" height="512pt" viewBox="0 0 352 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#546e7aff">
-<path fill="#914040" opacity="1.00" d=" M 169.61 0.00 L 182.39 0.00 C 210.72 1.91 238.32 14.44 257.64 35.39 C 277.03 55.81 288.11 83.84 287.99 112.00 C 288.00 165.33 288.01 218.67 287.99 272.00 C 288.06 281.84 286.63 291.65 284.10 301.14 C 276.40 330.44 256.05 356.08 229.39 370.42 C 205.91 383.27 177.70 387.24 151.59 381.29 C 127.94 376.13 106.23 362.89 90.60 344.42 C 73.52 324.46 63.89 298.29 64.00 272.00 C 64.00 218.34 63.98 164.67 64.02 111.00 C 64.10 81.02 77.01 51.36 98.86 30.85 C 117.78 12.51 143.43 1.83 169.61 0.00 M 160.43 33.42 L 159.96 34.08 C 155.30 34.22 150.91 35.98 146.61 37.61 C 126.01 45.63 109.14 62.67 101.32 83.36 C 99.81 87.42 98.18 91.57 98.10 95.98 L 97.40 96.39 C 95.57 106.79 95.85 117.48 96.13 128.00 C 95.89 138.65 95.88 149.35 96.14 160.00 C 95.88 170.65 95.88 181.35 96.14 192.00 C 95.89 202.65 95.89 213.35 96.14 224.00 C 95.88 234.65 95.88 245.35 96.14 256.00 C 96.08 266.67 95.08 277.67 97.90 288.09 C 99.80 299.63 104.96 310.75 112.19 319.91 C 116.47 326.08 121.92 331.52 128.09 335.81 C 131.86 339.27 136.55 341.53 141.03 343.92 C 147.05 346.68 153.28 349.32 159.91 350.09 C 164.75 351.72 169.94 351.85 175.00 351.98 C 180.72 351.92 186.58 351.84 192.09 350.10 C 203.63 348.18 214.76 343.04 223.91 335.80 C 230.07 331.51 235.51 326.07 239.80 319.91 C 243.27 316.14 245.54 311.46 247.90 306.96 C 250.63 300.93 253.33 294.72 254.10 288.09 C 256.91 277.67 255.92 266.67 255.86 256.00 C 256.12 245.35 256.12 234.65 255.86 224.00 C 256.12 213.35 256.11 202.65 255.86 192.00 C 256.11 181.35 256.13 170.65 255.86 160.00 C 256.11 149.35 256.11 138.65 255.87 128.00 C 256.13 117.49 256.46 106.81 254.57 96.43 L 253.92 95.96 C 253.67 90.25 251.28 84.96 249.19 79.73 C 240.62 60.22 223.93 44.39 203.90 37.05 C 200.08 35.62 196.16 34.18 192.03 34.09 L 191.61 33.40 C 181.34 31.57 170.69 31.52 160.43 33.42 Z" />
-<path fill="#914040" opacity="1.00" d=" M 8.72 257.72 C 15.58 254.12 24.85 256.36 29.16 262.84 C 32.53 267.55 31.95 273.60 32.22 279.07 C 33.27 302.97 40.59 326.55 53.10 346.93 C 68.46 372.14 91.74 392.43 118.84 404.15 C 148.78 417.19 183.25 419.63 214.67 410.69 C 244.14 402.54 270.73 384.59 289.50 360.48 C 307.21 337.86 318.04 309.81 319.68 281.10 C 320.15 274.99 319.10 268.16 322.84 262.84 C 327.15 256.35 336.42 254.13 343.28 257.72 C 347.64 259.78 350.22 264.12 352.00 268.40 L 352.00 280.38 C 351.11 289.42 350.23 298.49 348.36 307.39 C 342.16 338.38 327.11 367.48 305.77 390.77 C 282.80 415.85 252.43 434.15 219.42 442.44 C 210.57 444.97 201.37 445.69 192.37 447.36 C 191.54 452.53 192.08 457.79 192.01 463.00 C 192.13 468.59 191.59 474.20 192.23 479.76 C 196.47 480.29 200.74 480.00 205.00 480.01 C 226.34 479.96 247.69 480.07 269.03 479.94 C 273.44 479.93 278.18 480.38 281.74 483.26 C 287.79 487.72 289.74 496.63 286.27 503.27 C 284.21 507.63 279.87 510.22 275.58 512.00 L 76.43 512.00 C 72.13 510.23 67.79 507.64 65.72 503.28 C 62.26 496.64 64.19 487.72 70.26 483.26 C 73.81 480.37 78.56 479.93 82.97 479.94 C 104.31 480.07 125.65 479.96 147.00 480.01 C 151.25 480.00 155.53 480.30 159.77 479.77 C 160.41 474.20 159.87 468.59 159.99 463.00 C 159.91 457.79 160.46 452.53 159.63 447.36 C 155.34 446.28 150.89 446.13 146.54 445.38 C 116.89 440.49 88.65 427.61 65.31 408.71 C 38.39 387.00 18.08 357.17 7.89 324.12 C 3.38 310.28 1.38 295.80 0.00 281.37 L 0.00 268.41 C 1.76 264.11 4.36 259.77 8.72 257.72 Z" />
-</g>
-<g id="#914040ff">
-<path fill="#914040" opacity="1.00" d=" M 160.43 33.42 C 170.69 31.52 181.34 31.57 191.61 33.40 L 192.03 34.09 C 192.00 44.06 191.99 54.03 192.00 64.00 C 181.33 64.00 170.67 64.00 160.00 64.00 C 160.00 54.03 160.02 44.05 159.96 34.08 L 160.43 33.42 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.21 64.21 C 138.78 63.62 149.40 64.20 160.00 64.00 C 160.00 74.67 160.00 85.33 160.00 96.00 C 149.33 96.00 138.67 96.00 128.00 96.00 C 128.20 85.41 127.63 74.79 128.21 64.21 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 64.00 C 202.59 64.20 213.21 63.63 223.79 64.21 C 224.37 74.79 223.80 85.40 224.00 96.00 C 213.33 96.00 202.67 96.00 192.00 96.00 C 192.00 85.33 192.00 74.67 192.00 64.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 97.40 96.39 L 98.10 95.98 C 108.07 96.00 118.03 96.01 128.00 96.00 C 128.00 106.67 128.00 117.33 128.00 128.00 C 117.38 128.00 106.76 128.00 96.13 128.00 C 95.85 117.48 95.57 106.79 97.40 96.39 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 96.00 C 170.67 96.00 181.33 96.00 192.00 96.00 C 192.00 106.67 192.00 117.33 192.00 128.00 C 181.33 128.00 170.67 128.00 160.00 128.00 C 160.00 117.33 160.00 106.67 160.00 96.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 96.00 C 233.97 96.00 243.94 96.02 253.92 95.96 L 254.57 96.43 C 256.46 106.81 256.13 117.49 255.87 128.00 C 245.24 128.00 234.62 128.00 224.00 128.00 C 224.00 117.33 224.00 106.67 224.00 96.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.00 128.00 C 138.67 128.00 149.33 128.00 160.00 128.00 C 160.00 138.67 160.00 149.33 160.00 160.00 C 149.33 160.00 138.67 160.00 128.00 160.00 C 128.00 149.33 128.00 138.67 128.00 128.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 128.00 C 202.67 128.00 213.33 128.00 224.00 128.00 C 224.00 138.67 224.00 149.33 224.00 160.00 C 213.33 160.00 202.67 160.00 192.00 160.00 C 192.00 149.33 192.00 138.67 192.00 128.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 96.14 160.00 C 106.76 160.00 117.38 160.00 128.00 160.00 C 128.00 170.67 128.00 181.33 128.00 192.00 C 117.38 192.00 106.76 192.00 96.14 192.00 C 95.88 181.35 95.88 170.65 96.14 160.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 160.00 C 170.67 160.00 181.33 160.00 192.00 160.00 C 192.00 170.67 192.00 181.33 192.00 192.00 C 181.33 192.00 170.67 192.00 160.00 192.00 C 160.00 181.33 160.00 170.67 160.00 160.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 160.00 C 234.62 160.00 245.24 160.00 255.86 160.00 C 256.13 170.65 256.11 181.35 255.86 192.00 C 245.24 192.00 234.62 192.00 224.00 192.00 C 224.00 181.33 224.00 170.67 224.00 160.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.00 192.00 C 138.67 192.00 149.33 192.00 160.00 192.00 C 160.00 202.67 160.00 213.33 160.00 224.00 C 149.33 224.00 138.67 224.00 128.00 224.00 C 128.00 213.33 128.00 202.67 128.00 192.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 192.00 C 202.67 192.00 213.33 192.00 224.00 192.00 C 224.00 202.67 224.00 213.33 224.00 224.00 C 213.33 224.00 202.67 224.00 192.00 224.00 C 192.00 213.33 192.00 202.67 192.00 192.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 96.14 224.00 C 106.76 224.00 117.38 224.00 128.00 224.00 C 128.00 234.67 128.00 245.33 128.00 256.00 C 117.38 256.00 106.76 256.00 96.14 256.00 C 95.88 245.35 95.88 234.65 96.14 224.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 224.00 C 170.67 224.00 181.33 224.00 192.00 224.00 C 192.00 234.67 192.00 245.33 192.00 256.00 C 181.33 256.00 170.67 256.00 160.00 256.00 C 160.00 245.33 160.00 234.67 160.00 224.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 224.00 C 234.62 224.00 245.24 224.00 255.86 224.00 C 256.12 234.65 256.12 245.35 255.86 256.00 C 245.24 256.00 234.62 256.00 224.00 256.00 C 224.00 245.33 224.00 234.67 224.00 224.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.00 256.00 C 138.67 256.00 149.33 256.00 160.00 256.00 C 160.00 266.67 160.00 277.33 160.00 288.00 C 149.33 288.00 138.67 288.00 128.00 288.00 C 128.00 277.33 128.00 266.67 128.00 256.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 256.00 C 202.67 256.00 213.33 256.00 224.00 256.00 C 224.00 266.67 224.00 277.33 224.00 288.00 C 213.33 288.00 202.67 288.00 192.00 288.00 C 192.00 277.33 192.00 266.67 192.00 256.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 97.90 288.09 C 107.93 287.91 117.97 288.03 128.00 288.00 C 128.00 298.67 127.99 309.33 128.01 320.00 C 122.73 319.98 117.46 320.11 112.19 319.91 C 104.96 310.75 99.80 299.63 97.90 288.09 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 288.00 C 170.67 288.00 181.33 288.00 192.00 288.00 C 192.00 298.67 192.00 309.33 192.00 320.00 C 181.33 320.00 170.67 320.00 160.00 320.00 C 160.00 309.33 160.00 298.67 160.00 288.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 288.00 C 234.03 288.03 244.06 287.91 254.10 288.09 C 253.33 294.72 250.63 300.93 247.90 306.96 C 245.54 311.46 243.27 316.14 239.80 319.91 C 234.53 320.11 229.26 319.98 223.99 319.99 C 224.01 309.33 224.00 298.66 224.00 288.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.01 320.00 C 138.67 320.01 149.33 320.00 160.00 320.00 C 159.97 330.03 160.08 340.06 159.91 350.09 C 153.28 349.32 147.05 346.68 141.03 343.92 C 136.55 341.53 131.86 339.27 128.09 335.81 C 127.89 330.54 128.01 325.27 128.01 320.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 320.00 C 202.66 320.00 213.33 320.01 223.99 319.99 C 223.98 325.26 224.11 330.53 223.91 335.80 C 214.76 343.04 203.63 348.18 192.09 350.10 C 191.91 340.06 192.03 330.03 192.00 320.00 Z" />
-</g>
-</svg>
diff --git a/static/s/air/bg.jpg b/static/s/air-bg.jpg
index 3957d3ba..3957d3ba 100644
--- a/static/s/air/bg.jpg
+++ b/static/s/air-bg.jpg
Binary files differ
diff --git a/static/s/air/bgright.png b/static/s/air-right.png
index 5cee5462..5cee5462 100644
--- a/static/s/air/bgright.png
+++ b/static/s/air-right.png
Binary files differ
diff --git a/static/s/air/conf b/static/s/air/conf
deleted file mode 100644
index 209d1c2b..00000000
--- a/static/s/air/conf
+++ /dev/null
@@ -1,32 +0,0 @@
-////////////////////////////////////////////////////////////////
-// 'AIR' skin for VNDB.org //
-// Created by Yirba <yirba AT spiderlilytranslations DOT com> //
-// Some portions (c)2000 Key/VisualArt's //
-////////////////////////////////////////////////////////////////
-
-name AIR (sky blue)
-userid 13885
-maintext #222222
-grayedout #77a9dd
-standout #bb1511
-link #226588
-statok #33ff38
-statnok #ff3833
-footer #226588
-maintitle #226588
-boxtitle #77a9dd
-alttitle #000000
-bodybg #ffffff
-tabbg #ddeeff
-secbg #bed8f2
-secborder #5677cc
-border #77a9dd
-boxbg #ccddeebc
-imglefttop bg.jpg
-imgrighttop bgright.png
-diffadd #aaccbc
-diffdel #ccaabb
-warnbg #ccaabb
-warnborder #ff3833
-noticebg #aaccbc
-noticeborder #33ff38
diff --git a/static/s/angel/bg-xmas.jpg b/static/s/angel-bg-xmas.jpg
index 66604f8a..66604f8a 100644
--- a/static/s/angel/bg-xmas.jpg
+++ b/static/s/angel-bg-xmas.jpg
Binary files differ
diff --git a/static/s/angel/bg.jpg b/static/s/angel-bg.jpg
index f91e414e..f91e414e 100644
--- a/static/s/angel/bg.jpg
+++ b/static/s/angel-bg.jpg
Binary files differ
diff --git a/static/s/angel/bgright.jpg b/static/s/angel-right.jpg
index e1dff0cd..e1dff0cd 100644
--- a/static/s/angel/bgright.jpg
+++ b/static/s/angel-right.jpg
Binary files differ
diff --git a/static/s/angel/conf b/static/s/angel/conf
deleted file mode 100644
index 4fe674ac..00000000
--- a/static/s/angel/conf
+++ /dev/null
@@ -1,37 +0,0 @@
-name Angelic Serenade (dark blue)
-userid 2
-
-// text
-maintext #ddd // primary text color (also used for the menu links)
-grayedout #258 // color used for grayed-out/non-important things
-standout #e44 // color of 'stand-out' text
-link #7bd // primary link color (not used for the menu links)
-statok #0c0 // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #c00 // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #247 // text color of the footer
-
-// titles
-maintitle #135 // text color of the site title, set to '0' to hide the main title
-boxtitle #258 // box titles
-alttitle #fff // alternative title
-
-// bg colors
-bodybg #000 // main background color
-tabbg #012 // background color of inactive tabs
-secbg #0d2741 // secondary background color (used on input fields and table headers)
-secborder #35A // secondary border color (used on input fields)
-border #258 // primary border color
-boxbg #071c30bc // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop bg.jpg
-imgrighttop bgright.jpg
-
-// misc colors
-diffadd #354 // background color of changes in the diff viewer
-diffdel #534
-warnbg #534 // background color of a warning box
-warnborder #c00 // ..border
-noticebg #354 // notice box
-noticeborder #0c0 // ...and border
-
diff --git a/static/s/aselia_01/bgright.jpg b/static/s/aselia_01-right.jpg
index 8040e813..8040e813 100644
--- a/static/s/aselia_01/bgright.jpg
+++ b/static/s/aselia_01-right.jpg
Binary files differ
diff --git a/static/s/aselia_01/conf b/static/s/aselia_01/conf
deleted file mode 100644
index b07657c7..00000000
--- a/static/s/aselia_01/conf
+++ /dev/null
@@ -1,39 +0,0 @@
-name Eien no Aselia (falu red)
-userid 51
-
-// Eien no Aselia skin made using Minitokyo.Eien.no.Aselia.Scans_373967
-// created: 09/27/2009 by echomateria
-
-// text
-maintext #ffffff // primary text color (also used for the menu links)
-grayedout #eee388 // color used for grayed-out/non-important things
-standout #e96e73 // color of 'stand-out' text
-link #4a2b33 // primary link color (not used for the menu links)
-statok #ffcbee // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #e96e73 // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #f4f1e6 // text color of the footer
-
-// titles
-maintitle #fce5e5 // text color of the site title
-boxtitle #f4e1b5 // box titles
-alttitle #ed9f92 // alternative title
-
-// bg colors
-bodybg #8a3c35 // main background color
-tabbg #ac7595 // background color of inactive tabs
-secbg #8a3c35 // secondary background color (used on input fields and table headers)
-secborder #e86a76 // secondary border color (used on input fields)
-border #f1c0b2 // primary border color
-boxbg #ac759595 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop 0
-imgrighttop bgright.jpg
-
-// misc colors
-diffadd #833d71 // background color of changes in the diff viewer
-diffdel #6c3a55
-warnbg #6b3b51 // background color of a warning box
-warnborder #e37192 // ..border
-noticebg #cc8487 // notice box
-noticeborder #975352 // ...and border
diff --git a/static/s/carnevale/bgright.jpg b/static/s/carnevale-right.jpg
index 570e882e..570e882e 100644
--- a/static/s/carnevale/bgright.jpg
+++ b/static/s/carnevale-right.jpg
Binary files differ
diff --git a/static/s/carnevale/conf b/static/s/carnevale/conf
deleted file mode 100644
index 84ba8940..00000000
--- a/static/s/carnevale/conf
+++ /dev/null
@@ -1,40 +0,0 @@
-name Gekkou no Carnevale (black)
-userid 51
-
-// Gekkou no Carnevale skin made using a wallpaper comes with the game
-// created: 22/01/2009 by echomateria
-
-// text
-maintext #b9c7ae // primary text color (also used for the menu links)
-grayedout #cfdbc7 // color used for grayed-out/non-important things
-standout #eff5f5 // color of 'stand-out' text
-link #ffffff // primary link color (not used for the menu links)
-statok #dab0fc //# // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #768b78 //# // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #b9c7ae // text color of the footer
-
-// titles
-maintitle #b9c7ae // text color of the site title
-boxtitle #c7d3bf // box titles
-alttitle #6f8578 //# // alternative title
-
-// bg colors
-bodybg #030708 // main background color
-tabbg #1f272a // background color of inactive tabs
-secbg #121622 // secondary background color (used on input fields and table headers)
-secborder #ffffff // secondary border color (used on input fields)
-border #b9c7ae // primary border color
-boxbg #12162290 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop 0
-imgrighttop bgright.jpg
-
-// misc colors
-diffadd #76a3b8 // background color of changes in the diff viewer
-diffdel #354554
-warnbg #5d182b // background color of a warning box
-warnborder #829076 // ..border
-noticebg #263a45 // notice box
-noticeborder #21343a // ...and border
-
diff --git a/static/s/eiel/bg.jpg b/static/s/eiel-bg.jpg
index 03126d8a..03126d8a 100644
--- a/static/s/eiel/bg.jpg
+++ b/static/s/eiel-bg.jpg
Binary files differ
diff --git a/static/s/eiel/conf b/static/s/eiel/conf
deleted file mode 100644
index fd00320c..00000000
--- a/static/s/eiel/conf
+++ /dev/null
@@ -1,40 +0,0 @@
-name Jingai Makyo (peach-orange)
-userid 51
-
-// A skin made using an image I had for a long time without knowing it's source,
-// thankfully this skin finally brought out the answer that it was from Jingai Makyo.
-// created: 24/01/2009 by echomateria
-
-// text
-maintext #f8cb8a // primary text color (also used for the menu links)
-grayedout #f26a7e // color used for grayed-out/non-important things
-standout #ff435f // color of 'stand-out' text
-link #ffffff // primary link color (not used for the menu links)
-statok #ffcbee //# // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #ff435f //# // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #362142 // text color of the footer
-
-// titles
-maintitle #676082 // text color of the site title
-boxtitle #ffcbee // box titles
-alttitle #f26a7e //# // alternative title
-
-// bg colors
-bodybg #fdd298 // main background color
-tabbg #5a3a49 // background color of inactive tabs
-secbg #584563 // secondary background color (used on input fields and table headers)
-secborder #c42b5a // secondary border color (used on input fields)
-border #362142 // primary border color
-boxbg #36214299 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop bg.jpg
-
-// misc colors
-diffadd #2bc88b // background color of changes in the diff viewer
-diffdel #ca4a4d
-warnbg #c42b5a // background color of a warning box
-warnborder #f8cb8a // ..border
-noticebg #b48ab2 // notice box
-noticeborder #f8cb8a // ...and border
-
diff --git a/static/s/ever17_01/bgright.jpg b/static/s/ever17_01-right.jpg
index 4ee43231..4ee43231 100644
--- a/static/s/ever17_01/bgright.jpg
+++ b/static/s/ever17_01-right.jpg
Binary files differ
diff --git a/static/s/ever17_01/conf b/static/s/ever17_01/conf
deleted file mode 100644
index e7c7ff1c..00000000
--- a/static/s/ever17_01/conf
+++ /dev/null
@@ -1,39 +0,0 @@
-name Ever17 (bondi blue)
-userid 51
-
-// Ever 17 skin made using the images from the extras section of the game
-// created: 01/01/2009 by echomateria
-
-// text
-maintext #3c363f // primary text color (also used for the menu links)
-grayedout #785a5b // color used for grayed-out/non-important things
-standout #966932 // color of 'stand-out' text
-link #013f7a // primary link color (not used for the menu links)
-statok #9c4b00 //#d87417 // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #eb0e4c // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #e2cfa6 // text color of the footer
-
-// titles
-maintitle #f0a260 // text color of the site title
-boxtitle #f0a260 // box titles
-alttitle #ff9013 // alternative title
-
-// bg colors
-bodybg #00879b // main background color
-tabbg #81d5ea // background color of inactive tabs
-secbg #bcd1e4 // secondary background color (used on input fields and table headers)
-secborder #00627e // secondary border color (used on input fields)
-border #016c80 // primary border color
-boxbg #d1ebee77 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop 0
-imgrighttop bgright.jpg
-
-// misc colors
-diffadd #f1a361 // background color of changes in the diff viewer
-diffdel #9a7071
-warnbg #c43f5c // background color of a warning box
-warnborder #951924 // ...border
-noticebg #fefbf6 // notice box
-noticeborder #f1a360 // ...and border
diff --git a/static/s/fate_01/bg.jpg b/static/s/fate_01-bg.jpg
index 275adea3..275adea3 100644
--- a/static/s/fate_01/bg.jpg
+++ b/static/s/fate_01-bg.jpg
Binary files differ
diff --git a/static/s/fate_01/conf b/static/s/fate_01/conf
deleted file mode 100644
index f749ed0e..00000000
--- a/static/s/fate_01/conf
+++ /dev/null
@@ -1,39 +0,0 @@
-name Fate/stay night (seal brown)
-userid 51
-
-// FSN skin skin made using a popular fanart
-// created: 12/31/2008 by echomateria
-
-// text
-maintext #ab928d // primary text color (also used for the menu links)
-grayedout #916858 //#65483d // color used for grayed-out/non-important things
-standout #72322b // color of 'stand-out' text
-link #eee0da // primary link color (not used for the menu links)
-statok #efdab9 // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #b07a6b // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #642924 // text color of the footer
-
-// titles
-maintitle #200b0c // text color of the site title
-boxtitle #d56243 // box titles
-alttitle #d7926e // alternative title
-
-// bg colors
-bodybg #200b0c // main background color
-tabbg #130504 // background color of inactive tabs
-secbg #7c362f // secondary background color (used on input fields and table headers)
-secborder #33261d // secondary border color (used on input fields)
-border #9e4a47 // primary border color
-boxbg #4d3c3abc // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop bg.jpg
-
-// misc colors
-diffadd #4d2c25 // background color of changes in the diff viewer
-diffdel #6f5347
-warnbg #882f27 // background color of a warning box
-warnborder #fbf1e0 // ..border
-noticebg #882f27 // notice box
-noticeborder #fbf1e0 // ...and border
-
diff --git a/static/s/fate_02/bg.jpg b/static/s/fate_02-bg.jpg
index 83e30554..83e30554 100644
--- a/static/s/fate_02/bg.jpg
+++ b/static/s/fate_02-bg.jpg
Binary files differ
diff --git a/static/s/fate_02/conf b/static/s/fate_02/conf
deleted file mode 100644
index 0c0ddc96..00000000
--- a/static/s/fate_02/conf
+++ /dev/null
@@ -1,39 +0,0 @@
-name Fate/stay night (pale carmine)
-userid 51
-
-// FSN skin made using a popular fanart
-// created: 01/01/2009 by echomateria
-
-// text
-maintext #fcfbfb // primary text color (also used for the menu links)
-grayedout #ff9d82 // color used for grayed-out/non-important things
-standout #ffc98f // color of 'stand-out' text
-link #48b2c1 // primary link color (not used for the menu links)
-statok #ff7682 //#db6570 // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #9f72ff //#7330ff // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #fffed5 // text color of the footer
-
-// titles
-maintitle #ffffff // text color of the site title
-boxtitle #ffffff // box titles
-alttitle #c4a9a7 //#7b6a69 // alternative title
-
-// bg colors
-bodybg #ac4b47 // main background color
-tabbg #723033 // background color of inactive tabs
-secbg #792447 // secondary background color (used on input fields and table headers)
-secborder #a68483 // secondary border color (used on input fields)
-border #452d2c // primary border color
-boxbg #c6093366 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop bg.jpg
-
-// misc colors
-diffadd #3d3231 // background color of changes in the diff viewer
-diffdel #01023f
-warnbg #772446 // background color of a warning box
-warnborder #35152d // ..border
-noticebg #5c151f // notice box
-noticeborder #460015 // ...and border
-
diff --git a/static/s/grey/bg.jpg b/static/s/grey-bg.jpg
index 8d61ac55..8d61ac55 100644
--- a/static/s/grey/bg.jpg
+++ b/static/s/grey-bg.jpg
Binary files differ
diff --git a/static/s/grey/bgright.jpg b/static/s/grey-right.jpg
index c52499ea..c52499ea 100644
--- a/static/s/grey/bgright.jpg
+++ b/static/s/grey-right.jpg
Binary files differ
diff --git a/static/s/grey/conf b/static/s/grey/conf
deleted file mode 100644
index ef3ca505..00000000
--- a/static/s/grey/conf
+++ /dev/null
@@ -1,37 +0,0 @@
-name Touhou (grey)
-userid 2
-
-// text
-maintext #222
-grayedout #666
-standout #500
-link #005
-statok #050
-statnok #500
-footer #999
-
-// titles
-maintitle #ccc
-boxtitle #444
-alttitle #000
-
-// bg colors
-bodybg #fff
-tabbg #ddd
-secbg #ccc
-secborder #000
-border #999
-boxbg #ddddddcc
-
-// images (0 = no image)
-imglefttop bg.jpg
-imgrighttop bgright.jpg
-
-// misc colors
-diffadd #cfc
-diffdel #fcc
-warnbg #fcc
-warnborder #c00
-noticebg #cfc
-noticeborder #0c0
-
diff --git a/static/s/higanbana/bg.jpg b/static/s/higanbana-bg.jpg
index 1d02e802..1d02e802 100644
--- a/static/s/higanbana/bg.jpg
+++ b/static/s/higanbana-bg.jpg
Binary files differ
diff --git a/static/s/higanbana/bgright.png b/static/s/higanbana-right.png
index ee96f504..ee96f504 100644
--- a/static/s/higanbana/bgright.png
+++ b/static/s/higanbana-right.png
Binary files differ
diff --git a/static/s/higanbana/conf b/static/s/higanbana/conf
deleted file mode 100644
index d7ffb2f1..00000000
--- a/static/s/higanbana/conf
+++ /dev/null
@@ -1,32 +0,0 @@
-////////////////////////////////////////////////////////////////
-// 'Higanbana no Saku Yoru ni' skin for VNDB.org //
-// Created by Yirba <yirba AT spiderlilytranslations DOT com> //
-// Some portions (c)2011 Ryukishi07/07th Expansion //
-////////////////////////////////////////////////////////////////
-
-name Higanbana no Saku Yoru ni (maroon)
-userid 13885
-maintext #ddd
-grayedout #822
-standout #ec4
-link #d77
-statok #0c0
-statnok #c00
-footer #722
-maintitle #511
-boxtitle #822
-alttitle #fff
-bodybg #000
-tabbg #200
-secbg #410d0d
-secborder #a33
-border #822
-boxbg #331111bc
-imglefttop bg.jpg
-imgrighttop bgright.png
-diffadd #354
-diffdel #534
-warnbg #534
-warnborder #c00
-noticebg #354
-noticeborder #0c0
diff --git a/static/s/higu/bg.jpg b/static/s/higu-bg.jpg
index 9430acfb..9430acfb 100644
--- a/static/s/higu/bg.jpg
+++ b/static/s/higu-bg.jpg
Binary files differ
diff --git a/static/s/higu/conf b/static/s/higu/conf
deleted file mode 100644
index 9c860379..00000000
--- a/static/s/higu/conf
+++ /dev/null
@@ -1,39 +0,0 @@
-name Higurashi no Naku Koro ni (orange)
-userid 51
-
-// Higurashi no Naku Koro ni skin made using an image I found in MiniTokyo
-// created: 22/01/2009 by echomateria
-
-// text
-maintext #2c1a18 // primary text color (also used for the menu links)
-grayedout #8c5b3b // color used for grayed-out/non-important things
-standout #c24857 // color of 'stand-out' text
-link #3c549c // primary link color (not used for the menu links)
-statok #8c6290 //#735d8b // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #824e52 //#522e38 // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #2e2536 // text color of the footer
-
-// titles
-maintitle #e5d3e1 // text color of the site title
-boxtitle #1b1b51 // box titles
-alttitle #35346d //#423a73 // alternative title
-
-// bg colors
-bodybg #f89e7e // main background color
-tabbg #9b8587 // background color of inactive tabs
-secbg #f7c7bb // secondary background color (used on input fields and table headers)
-secborder #3c549c // secondary border color (used on input fields)
-border #2c1a18 // primary border color
-boxbg #f7c7bb80 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop bg.jpg
-
-// misc colors
-diffadd #c2bcc6 // background color of changes in the diff viewer
-diffdel #8c5b3b
-warnbg #ae6866 // background color of a warning box
-warnborder #612028 // ..border
-noticebg #9b8587 // notice box
-noticeborder #dc9b7f // ...and border
-
diff --git a/static/s/lb/bg.jpg b/static/s/lb-bg.jpg
index 7726f1b7..7726f1b7 100644
--- a/static/s/lb/bg.jpg
+++ b/static/s/lb-bg.jpg
Binary files differ
diff --git a/static/s/lb/conf b/static/s/lb/conf
deleted file mode 100644
index a1e317a0..00000000
--- a/static/s/lb/conf
+++ /dev/null
@@ -1,37 +0,0 @@
-name Little Busters! (pink)
-userid 93
-
-// text
-maintext #408
-grayedout #670159
-standout #e44
-link #a2d
-statok #0c0
-statnok #c00
-footer #f76ee2
-
-// titles
-maintitle #f78de7
-boxtitle #670159
-alttitle #5328a7
-
-// bg colors
-bodybg #fff
-tabbg #f78de7
-secbg #f78de7
-secborder #670159
-border #f76ee2
-boxbg #f7b6edcc
-
-// images (0 = no image)
-imglefttop bg.jpg
-imgrighttop 0
-
-// misc colors
-diffadd #cfc
-diffdel #fcc
-warnbg #fff
-warnborder #c00
-noticebg #f7b6ed
-noticeborder #670159
-
diff --git a/static/s/lb_02/bg.jpg b/static/s/lb_02-bg.jpg
index 4db226b7..4db226b7 100644
--- a/static/s/lb_02/bg.jpg
+++ b/static/s/lb_02-bg.jpg
Binary files differ
diff --git a/static/s/lb_02/conf b/static/s/lb_02/conf
deleted file mode 100644
index e0fd96c2..00000000
--- a/static/s/lb_02/conf
+++ /dev/null
@@ -1,38 +0,0 @@
-name Little Busters! (lemon chiffon)
-userid 51
-
-// Little Busters! skin made using the Minitokyo.Little.Busters.Scans_316439
-// created: 09/27/2009 by echomateria
-
-// text
-maintext #3c363f // primary text color (also used for the menu links)
-grayedout #785a5b // color used for grayed-out/non-important things
-standout #eb0e4c // color of 'stand-out' text
-link #04529b // primary link color (not used for the menu links)
-statok #d87417 // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #eb0e4c // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #eb0e4c // text color of the footer
-
-// titles
-maintitle #fffeff // text color of the site title
-boxtitle #ff9013 // box titles
-alttitle #f0a260 // alternative title
-
-// bg colors
-bodybg #fff4d4 // main background color
-tabbg #9aa4d7 // background color of inactive tabs
-secbg #bcd1e4 // secondary background color (used on input fields and table headers)
-secborder #966932 // secondary border color (used on input fields)
-border #016c80 // primary border color
-boxbg #d1ebee99 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop bg.jpg
-
-// misc colors
-diffadd #3689b5 // background color of changes in the diff viewer
-diffdel #b7adc6
-warnbg #e97a9a // background color of a warning box
-warnborder #f1a360 // ...border
-noticebg #fefbf6 // notice box
-noticeborder #951924 // ...and border
diff --git a/static/s/primitive/bgright.jpg b/static/s/primitive-right.jpg
index bc00f894..bc00f894 100644
--- a/static/s/primitive/bgright.jpg
+++ b/static/s/primitive-right.jpg
Binary files differ
diff --git a/static/s/primitive/conf b/static/s/primitive/conf
deleted file mode 100644
index 6f3b4b8f..00000000
--- a/static/s/primitive/conf
+++ /dev/null
@@ -1,40 +0,0 @@
-name Primitive Link (pale chestnut)
-userid 51
-
-// Primitive Link skin made using an image that I liked without knowing what it's based on for a long time
-// created: 23/01/2009 by echomateria
-
-// text
-maintext #f8eacd // primary text color (also used for the menu links)
-grayedout #ff9d8a // color used for grayed-out/non-important things
-standout #642a12 // color of 'stand-out' text
-link #ffda63 // primary link color (not used for the menu links)
-statok #edc176 // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #d63f21 // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #935a40 // text color of the footer
-
-// titles
-maintitle #f8eacd // text color of the site title
-boxtitle #f8eacd // box titles
-alttitle #f19d20 // alternative title
-
-// bg colors
-bodybg #ddac9b // main background color
-tabbg #935a40 // background color of inactive tabs
-secbg #be8f9f // secondary background color (used on input fields and table headers)
-secborder #642a12 // secondary border color (used on input fields)
-border #edc176 // primary border color
-boxbg #935a4099 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop 0
-imgrighttop bgright.jpg
-
-// misc colors
-diffadd #bd7f98 // background color of changes in the diff viewer
-diffdel #c6562d
-warnbg #7a3313 // background color of a warning box
-warnborder #ff392a // ..border
-noticebg #d4aab4 // notice box
-noticeborder #edc176 // ...and border
-
diff --git a/static/s/saya/bgright.jpg b/static/s/saya-right.jpg
index 92f40c5c..92f40c5c 100644
--- a/static/s/saya/bgright.jpg
+++ b/static/s/saya-right.jpg
Binary files differ
diff --git a/static/s/saya/conf b/static/s/saya/conf
deleted file mode 100644
index fd4408d7..00000000
--- a/static/s/saya/conf
+++ /dev/null
@@ -1,40 +0,0 @@
-name Saya no Uta (dark scarlet)
-userid 51
-
-// Saya no Uta skin made using a criminally cute fanart
-// created: 22/01/2009 by echomateria
-
-// text
-maintext #ffffff // primary text color (also used for the menu links)
-grayedout #ecbc93 // color used for grayed-out/non-important things
-standout #d75f25 // color of 'stand-out' text
-link #ffcb3a // primary link color (not used for the menu links)
-statok #a55a3d // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #281e14 // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #e07340 // text color of the footer
-
-// titles
-maintitle #ebb48b // text color of the site title
-boxtitle #de9670 // box titles
-alttitle #ebb48b // alternative title
-
-// bg colors
-bodybg #25010f // main background color
-tabbg #575c51 // background color of inactive tabs
-secbg #437f63 // secondary background color (used on input fields and table headers)
-secborder #ffcb3a // secondary border color (used on input fields)
-border #ebb48b // primary border color
-boxbg #437f6388 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop 0
-imgrighttop bgright.jpg
-
-// misc colors
-diffadd #f59731 // background color of changes in the diff viewer
-diffdel #f2c5a3
-warnbg #d45628 // background color of a warning box
-warnborder #fbab34 // ..border
-noticebg #c5af88 // notice box
-noticeborder #56714e // ...and border
-
diff --git a/static/s/seinarukana/bg.jpg b/static/s/seinarukana-bg.jpg
index d49ec9a1..d49ec9a1 100644
--- a/static/s/seinarukana/bg.jpg
+++ b/static/s/seinarukana-bg.jpg
Binary files differ
diff --git a/static/s/seinarukana/conf b/static/s/seinarukana/conf
deleted file mode 100644
index 1a2a833d..00000000
--- a/static/s/seinarukana/conf
+++ /dev/null
@@ -1,38 +0,0 @@
-name Seinarukana (white)
-userid 51
-
-// Seinarukana skin made using a callendar image
-// created: 12/31/2008 by echomateria
-
-// text
-maintext #131838 // primary text color (also used for the menu links)
-grayedout #fc8e77 //#fcdfd9 // color used for grayed-out/non-important things
-standout #e93d71 //#a5e2f2 // color of 'stand-out' text
-link #5a5fc7 //#9fa1c7 // primary link color (not used for the menu links)
-statok #424d81 // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #a43462 // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #324978 // text color of the footer
-
-// titles
-maintitle #99c9dd // text color of the site title
-boxtitle #e93d71 // box titles
-alttitle #983666 // alternative title
-
-// bg colors
-bodybg #ffffff // main background color
-tabbg #bfd2e3 // background color of inactive tabs
-secbg #bcd1e4 // secondary background color (used on input fields and table headers)
-secborder #7a88a5 // secondary border color (used on input fields)
-border #324978 // primary border color
-boxbg #fde9e688 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop bg.jpg
-
-// misc colors
-diffadd #3689b5 // background color of changes in the diff viewer
-diffdel #b7adc6
-warnbg #ee3970 // background color of a warning box
-warnborder #451f4b // ...border
-noticebg #fdf1e8 // notice box
-noticeborder #d4aba2 // ...and border
diff --git a/static/s/taka/bgright.jpg b/static/s/taka-right.jpg
index e88e1876..e88e1876 100644
--- a/static/s/taka/bgright.jpg
+++ b/static/s/taka-right.jpg
Binary files differ
diff --git a/static/s/taka/conf b/static/s/taka/conf
deleted file mode 100644
index 37f0993e..00000000
--- a/static/s/taka/conf
+++ /dev/null
@@ -1,40 +0,0 @@
-name Sora no Iro, Mizu no Iro (turquoise)
-userid 51
-
-// A Sora no Iro, Mizu no Iro skin based on a wallpaper named My Perfect Day
-// created: 23/01/2009 by echomateria
-
-// text
-maintext #fefefc // primary text color (also used for the menu links)
-grayedout #4b2427 // color used for grayed-out/non-important things
-standout #ffaa88 // color of 'stand-out' text
-link #f4d926 // primary link color (not used for the menu links)
-statok #f8a022 //# // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #4b2427 //# // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #f6ffff // text color of the footer
-
-// titles
-maintitle #f6ffff // text color of the site title
-boxtitle #943048 // box titles
-alttitle #a93f56 //# // alternative title
-
-// bg colors
-bodybg #4bb3ae // main background color
-tabbg #3c6d69 // background color of inactive tabs
-secbg #48878c // secondary background color (used on input fields and table headers)
-secborder #f4d926 // secondary border color (used on input fields)
-border #48878c // primary border color
-boxbg #48878c92 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop 0
-imgrighttop bgright.jpg
-
-// misc colors
-diffadd #b2f68f // background color of changes in the diff viewer
-diffdel #5ed8e5
-warnbg #008278 // background color of a warning box
-warnborder #fcfefd // ..border
-noticebg #5bdebe // notice box
-noticeborder #a9fdc1 // ...and border
-
diff --git a/static/s/term/conf b/static/s/term/conf
deleted file mode 100644
index d6fa1d52..00000000
--- a/static/s/term/conf
+++ /dev/null
@@ -1,37 +0,0 @@
-name Neon (black)
-userid 93
-
-// text
-maintext #0f0
-grayedout #aaa
-standout #f00
-link #ff0
-statok #0c0
-statnok #c00
-footer #fff
-
-// titles
-maintitle #0f0
-boxtitle #0f0
-alttitle #0f0
-
-// bg colors
-bodybg #000
-tabbg #000
-secbg #000
-secborder #0f0
-border #fff
-boxbg #000
-
-// images (0 = no image)
-imglefttop 0
-imgrighttop 0
-
-// misc colors
-diffadd #cfc
-diffdel #fcc
-warnbg #000
-warnborder #c00
-noticebg #000
-noticeborder #0f0
-
diff --git a/static/s/tsukihime/bg.jpg b/static/s/tsukihime-bg.jpg
index f1d62635..f1d62635 100644
--- a/static/s/tsukihime/bg.jpg
+++ b/static/s/tsukihime-bg.jpg
Binary files differ
diff --git a/static/s/tsukihime/bgright.jpg b/static/s/tsukihime-right.jpg
index db31821a..db31821a 100644
--- a/static/s/tsukihime/bgright.jpg
+++ b/static/s/tsukihime-right.jpg
Binary files differ
diff --git a/static/s/tsukihime/conf b/static/s/tsukihime/conf
deleted file mode 100644
index c44a063a..00000000
--- a/static/s/tsukihime/conf
+++ /dev/null
@@ -1,39 +0,0 @@
-name Tsukihime (midnight blue)
-userid 51
-
-// Tsukihime skin made using an image from the Tsukihime Plus+Disc
-// created: 02/01/2009 by echomateria
-
-// text
-maintext #ffffff // primary text color (also used for the menu links)
-grayedout #abcdff // color used for grayed-out/non-important things
-standout #ffffff // color of 'stand-out' text
-link #0be0e9 // primary link color (not used for the menu links)
-statok #55dfaa // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #e30b47 // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #0cacf3 // text color of the footer
-
-// titles
-maintitle #a9bbfb // text color of the site title
-boxtitle #e3ecff // box titles
-alttitle #c6d7ff // alternative title
-
-// bg colors
-bodybg #29345f // main background color
-tabbg #5a3a63 // background color of inactive tabs
-secbg #9b8494 // secondary background color (used on input fields and table headers)
-secborder #605567 // secondary border color (used on input fields)
-border #b791f3 // primary border color
-boxbg #6a4668bb // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop bg.jpg
-imgrighttop bgright.jpg
-
-// misc colors
-diffadd #87705c // background color of changes in the diff viewer
-diffdel #374d77
-warnbg #76a1cd // background color of a warning box
-warnborder #decdcd // ...border
-noticebg #b50439 // notice box
-noticeborder #decdcd // ...and border
diff --git a/static/s/tsukihime_02/bg.jpg b/static/s/tsukihime_02-bg.jpg
index 5e5329f6..5e5329f6 100644
--- a/static/s/tsukihime_02/bg.jpg
+++ b/static/s/tsukihime_02-bg.jpg
Binary files differ
diff --git a/static/s/tsukihime_02/conf b/static/s/tsukihime_02/conf
deleted file mode 100644
index e9866c8d..00000000
--- a/static/s/tsukihime_02/conf
+++ /dev/null
@@ -1,39 +0,0 @@
-name Tsukihime (black)
-userid 51
-
-// Tsukihime skin made with an awesome Akiha artwork from Tsukihime PLUS disc
-// created: 23/01/2009 by echomateria
-
-// text
-maintext #fa4347 // primary text color (also used for the menu links)
-grayedout #54459a // color used for grayed-out/non-important things
-standout #fd3fa9 // color of 'stand-out' text
-link #b768aa // primary link color (not used for the menu links)
-statok #d79a7e //#fc3f46 // generic 'ok' text color (used for vnlist statuses & category browser)
-statnok #412651 //#2e1340 // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-footer #fa4347 // text color of the footer
-
-// titles
-maintitle #fa4347 // text color of the site title
-boxtitle #d79a7e // box titles
-alttitle #c17e61 //#a45548 // alternative title
-
-// bg colors
-bodybg #000000 // main background color
-tabbg #2e0106 // background color of inactive tabs
-secbg #2e0106 // secondary background color (used on input fields and table headers)
-secborder #b768aa // secondary border color (used on input fields)
-border #000000 // primary border color
-boxbg #35020990 // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// images (0 = no image)
-imglefttop bg.jpg
-
-// misc colors
-diffadd #d79a7e // background color of changes in the diff viewer
-diffdel #412651
-warnbg #46285a // background color of a warning box
-warnborder #c0959f // ..border
-noticebg #4f3246 // notice box
-noticeborder #94769a // ...and border
-
diff --git a/util/README.md b/util/README.md
new file mode 100644
index 00000000..2c4d499e
--- /dev/null
+++ b/util/README.md
@@ -0,0 +1,65 @@
+# VNDB utility scripts
+
+(Only interesting scripts are documented here)
+
+dbdump.pl
+: Can generate various database dumps, refer to its help text for details.
+
+devdump.pl
+: Generates a tarball containing a [small subset of the
+ database](https://vndb.org/d8#3) for development purposes.
+
+hibp-dl.pl
+: Utility to fetch the [Pwned
+ Passwords](https://haveibeenpwned.com/Passwords) database and store it in
+ `$VNDB_VAR/hibp`. The web backend can use this to warn about compromised
+ passwords.
+
+multi.pl
+: Runs the background service for the old API and various maintenance tasks.
+ The actual code for the service lives in */lib/Multi/*.
+
+unusedimages.pl
+: Purges unreferenced images from the database and scans `$VNDB_VAR/static/`
+ for files to be deleted.
+
+vndb.pl
+: This is the main entry point of the web backend. This script does some
+ setup and loads all the code from */lib/VNWeb/*. Can be started from CGI or
+ FastCGI context. When run on the command line it will spawn a simple
+ single-threaded web server on port 3000.
+
+vndb-dev-server.pl
+: A handy wrapper around *vndb.pl* for development use. Spawns a web server
+ on port 3000 that will automatically run `make` and reload the backend code
+ on changes.
+
+
+## imgproc.c
+
+*imgproc.c* this is a tool that wraps [libvips](https://www.libvips.org/) image
+processing operations used by VNDB in a simple CLI. It can be built in two ways:
+
+The default *imgproc* links against your system-provided libvips and should be
+portable across various systems.
+
+`make gen/imgproc-custom` builds and links against a custom build of libvips
+with support for better JPEG compression through jpegli. It also enables fairly
+restrictive seccomp rules for secure sandboxing, to protect against potential
+vulnerabilities in the used image codecs. This version likely only works on
+x86\_64 Linux with glibc. To use this custom version, update `imgproc_path` in
+your conf.pl.
+
+Build requirements for *imgproc-custom*:
+
+- C & C++ build system
+- Linux x86\_64 with glibc
+- meson
+- cmake
+- glib
+- lcms
+- libexpat
+- libheif (with libaom for AVIF support)
+- libpng
+- libseccomp
+- libwebp
diff --git a/util/dbdump.pl b/util/dbdump.pl
index 640ff6fc..fef8c5da 100755
--- a/util/dbdump.pl
+++ b/util/dbdump.pl
@@ -6,12 +6,18 @@ util/dbdump.pl export-db output.tar.zst
Write a full database export as a .tar.zst
- The uncompressed directory is written to "output.tar.zst_dir"
-
util/dbdump.pl export-img output-dir
Create or update a directory with hardlinks to images.
+util/dbdump.pl export-data data.sql
+
+ Create an SQL script that is usable as replacement for 'sql/all.sql'.
+ (Similar to the dump created by devdump.pl, except this one includes *all* data)
+
+ This allows recreating the full database using the definitions in sql/*.
+ The script does not rely on column order, so can be used to re-order table columns.
+
util/dbdump.pl export-votes output.gz
util/dbdump.pl export-tags output.gz
util/dbdump.pl export-traits output.gz
@@ -28,6 +34,7 @@ use DBI;
use DBD::Pg;
use File::Copy 'cp';
use File::Find 'find';
+use File::Path 'rmtree';
use Time::HiRes 'time';
use Cwd 'abs_path';
@@ -36,6 +43,38 @@ BEGIN { ($ROOT = abs_path $0) =~ s{/util/dbdump\.pl$}{}; }
use lib "$ROOT/lib";
use VNDB::Schema;
+use VNDB::ExtLinks;
+
+$ENV{VNDB_VAR} //= 'var';
+
+# Ridiculous query to export 'ulist_vns' with private labels removed.
+# Since doing a lookup in ulist_labels for each row+label in ulist_vns is
+# rather slow, this query takes a shortcut: for users that do not have any
+# private labels at all (i.e. the common case), this query just dumps the rows
+# without any modification. Only for users that have at least one private label
+# are the labels filtered.
+my $sql_ulist_vns_cols = q{
+ uid, vid, date_trunc('day',added) AS added, date_trunc('day',lastmod) AS lastmod
+ , date_trunc('day',vote_date), started, finished, vote, notes
+};
+my $sql_ulist_vns = qq{
+ SELECT * FROM (
+ SELECT $sql_ulist_vns_cols, array_agg(lblid ORDER BY lblid) AS labels
+ FROM ulist_vns, unnest(labels) x(lblid)
+ WHERE NOT c_private
+ AND NOT EXISTS(SELECT 1 FROM ulist_labels WHERE uid = ulist_vns.uid AND id = lblid AND private)
+ AND uid IN(SELECT uid FROM ulist_labels WHERE private)
+ GROUP BY uid, vid
+ UNION ALL
+ SELECT $sql_ulist_vns_cols, labels
+ FROM ulist_vns
+ WHERE NOT c_private
+ AND uid NOT IN(SELECT uid FROM ulist_labels WHERE private)
+ ) z
+ WHERE vid IN(SELECT id FROM vn WHERE NOT hidden)
+ ORDER BY uid, vid
+};
+
# Tables and columns to export.
@@ -48,57 +87,61 @@ use VNDB::Schema;
# interesting references are excluded from the dumps. Keeping all references
# consistent with those omissions complicates the WHERE clauses somewhat.
my %tables = (
- anime => { where => 'id IN(SELECT va.aid FROM vn_anime va JOIN vn v ON v.id = va.id WHERE NOT v.hidden)' },
- chars => { where => 'NOT hidden' },
- chars_traits => { where => 'id IN(SELECT id FROM chars WHERE NOT hidden) AND tid IN(SELECT id FROM traits WHERE state = 2)' },
- chars_vns => { where => 'id IN(SELECT id FROM chars WHERE NOT hidden)'
- .' AND vid IN(SELECT id FROM vn WHERE NOT hidden)'
- .' AND (rid IS NULL OR rid IN(SELECT id FROM releases WHERE NOT hidden))'
- , order => 'id, vid, rid' },
- docs => { where => 'NOT hidden' },
- producers => { where => 'NOT hidden' },
- producers_relations => { where => 'id IN(SELECT id FROM producers WHERE NOT hidden)' },
- releases => { where => 'NOT hidden' },
- releases_lang => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden)' },
- releases_media => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden)' },
- releases_platforms => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden)' },
- releases_producers => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden) AND pid IN(SELECT id FROM producers WHERE NOT hidden)' },
- releases_vn => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden) AND vid IN(SELECT id FROM vn WHERE NOT hidden)' },
+ anime => { where => 'x.id IN(SELECT va.aid FROM vn_anime va JOIN vn v ON v.id = va.id WHERE NOT v.hidden)' },
+ chars => { where => 'NOT x.hidden' },
+ chars_traits => { where => 'x.id IN(SELECT id FROM chars WHERE NOT hidden) AND tid IN(SELECT id FROM traits WHERE NOT hidden)' },
+ chars_vns => { where => 'x.id IN(SELECT id FROM chars WHERE NOT hidden)'
+ .' AND x.vid IN(SELECT id FROM vn WHERE NOT hidden)'
+ .' AND (x.rid IS NULL OR x.rid IN(SELECT id FROM releases WHERE NOT hidden))'
+ , order => 'x.id, x.vid, x.rid' },
+ docs => { where => 'NOT x.hidden' },
+ images => { where => "x.c_weight > 0" }, # Only images with a positive weight are referenced.
+ image_votes => { where => "x.id IN(SELECT id FROM images WHERE c_weight > 0)", order => 'x.uid, x.id' },
+ producers => { where => 'NOT x.hidden' },
+ producers_relations => { where => 'x.id IN(SELECT id FROM producers WHERE NOT hidden)' },
+ quotes => { where => 'x.rand IS NOT NULL' },
+ releases => { where => 'NOT x.hidden' },
+ releases_media => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden)' },
+ releases_platforms => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden)' },
+ releases_producers => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden) AND pid IN(SELECT id FROM producers WHERE NOT hidden)' },
+ releases_titles => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden)' },
+ releases_vn => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden) AND vid IN(SELECT id FROM vn WHERE NOT hidden)' },
rlists => { where => 'EXISTS(SELECT 1 FROM releases r'
.' JOIN releases_vn rv ON rv.id = r.id'
.' JOIN vn v ON v.id = rv.vid'
- .' JOIN ulist_vns_labels uvl ON uvl.vid = rv.vid'
- .' JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl'
- .' WHERE r.id = rlists.rid AND uvl.uid = rlists.uid AND NOT r.hidden AND NOT v.hidden AND NOT ul.private)' },
- screenshots => { where => 'id IN(SELECT scr FROM vn_screenshots vs JOIN vn v ON v.id = vs.id WHERE NOT v.hidden)' },
- staff => { where => 'NOT hidden' },
- staff_alias => { where => 'id IN(SELECT id FROM staff WHERE NOT hidden)' },
- tags => { where => 'state = 2' },
- tags_aliases => { where => 'tag IN(SELECT id FROM tags WHERE state = 2)' },
- tags_parents => { where => 'tag IN(SELECT id FROM tags WHERE state = 2)' },
- tags_vn => { where => 'tag IN(SELECT id FROM tags WHERE state = 2) AND vid IN(SELECT id FROM vn WHERE NOT hidden)' },
- traits => { where => 'state = 2' },
- traits_parents => { where => 'trait IN(SELECT id FROM traits WHERE state = 2)' },
- ulist_labels => { where => 'NOT private AND EXISTS(SELECT 1 FROM ulist_vns_labels uvl WHERE uvl.lbl = id AND ulist_labels.uid = uvl.uid)' },
- ulist_vns => { where => 'vid IN(SELECT id FROM vn WHERE NOT hidden)'
- .' AND EXISTS(SELECT 1 FROM ulist_vns_labels uvl'
- .' JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl'
- .' WHERE ulist_vns.uid = uvl.uid AND ulist_vns.vid = uvl.vid AND NOT ul.private)' },
- ulist_vns_labels => { where => 'vid IN(SELECT id FROM vn WHERE NOT hidden)'
- .' AND EXISTS(SELECT 1 FROM ulist_labels ul WHERE ul.uid = ulist_vns_labels.uid AND id = lbl AND NOT ul.private)' },
- users => { where => 'id IN(SELECT DISTINCT uvl.uid FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE NOT ul.private)'
- .' OR id IN(SELECT DISTINCT uid FROM tags_vn)' },
- vn => { where => 'NOT hidden' },
- vn_anime => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)' },
- vn_relations => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)' },
- vn_screenshots => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)' },
- vn_seiyuu => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)'
- .' AND aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)'
- .' AND cid IN(SELECT id FROM chars WHERE NOT hidden)' },
- vn_staff => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden) AND aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)' },
- wikidata => { where => q{id IN(SELECT l_wikidata FROM producers WHERE NOT hidden
- UNION SELECT l_wikidata FROM staff WHERE NOT hidden
- UNION SELECT l_wikidata FROM vn WHERE NOT hidden)} },
+ .' JOIN ulist_vns uv ON uv.vid = rv.vid'
+ .' WHERE r.id = x.rid AND uv.uid = x.uid AND NOT r.hidden AND NOT v.hidden AND NOT uv.c_private)' },
+ staff => { where => 'NOT x.hidden' },
+ staff_alias => { where => 'x.id IN(SELECT id FROM staff WHERE NOT hidden)' },
+ tags => { where => 'NOT x.hidden' },
+ tags_parents => { where => 'x.id IN(SELECT id FROM tags WHERE NOT hidden)' },
+ tags_vn => { where => 'x.tag IN(SELECT id FROM tags WHERE NOT hidden) AND x.vid IN(SELECT id FROM vn WHERE NOT hidden)', order => 'x.tag, x.vid, x.uid, x.date' },
+ traits => { where => 'NOT x.hidden' },
+ traits_parents => { where => 'x.id IN(SELECT id FROM traits WHERE NOT hidden)' },
+ ulist_labels => { where => 'NOT x.private AND EXISTS(SELECT 1 FROM ulist_vns uv JOIN vn v ON v.id = uv.vid
+ WHERE NOT v.hidden AND uv.labels && ARRAY[x.id] AND x.uid = uv.uid)' },
+ ulist_vns => { sql => $sql_ulist_vns },
+ users => { where => 'x.username IS NOT NULL AND ('
+ .' x.id IN(SELECT DISTINCT uid FROM ulist_vns WHERE NOT c_private)'
+ .' OR x.id IN(SELECT DISTINCT uid FROM tags_vn)'
+ .' OR x.id IN(SELECT DISTINCT uid FROM image_votes)'
+ .' OR x.id IN(SELECT DISTINCT uid FROM vn_length_votes WHERE NOT private))' },
+ vn => { where => 'NOT x.hidden' },
+ vn_anime => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_editions => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_relations => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_screenshots => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_seiyuu => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)'
+ .' AND x.aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)'
+ .' AND x.cid IN(SELECT id FROM chars WHERE NOT hidden)' },
+ vn_staff => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden) AND x.aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)'
+ , order => 'x.id, x.eid, x.aid, x.role' },
+ vn_titles => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_length_votes => { where => 'x.vid IN(SELECT id FROM vn WHERE NOT hidden) AND NOT x.private'
+ , order => 'x.vid, x.uid' },
+ wikidata => { where => q{x.id IN(SELECT l_wikidata FROM producers WHERE NOT hidden
+ UNION SELECT l_wikidata FROM staff WHERE NOT hidden
+ UNION SELECT l_wikidata FROM vn WHERE NOT hidden)} },
);
my @tables = map +{ name => $_, %{$tables{$_}} }, sort keys %tables;
@@ -108,7 +151,29 @@ my $references = VNDB::Schema::references;
my $db = DBI->connect('dbi:Pg:dbname=vndb', 'vndb', undef, { RaiseError => 1, AutoCommit => 0 });
$db->do('SET TIME ZONE +0');
-$db->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
+
+
+sub consistent_snapshot {
+ my($func) = @_;
+ my($standby) = $db->selectrow_array('SELECT pg_is_in_recovery()');
+ if($standby) {
+ $db->do('SELECT pg_wal_replay_pause()');
+ } else {
+ $db->rollback;
+ $db->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
+ }
+ eval { $func->() };
+ warn $@ if length $@;
+ $db->do('SELECT pg_wal_replay_resume()') if $standby;
+}
+
+
+sub table_order {
+ my $s = $schema->{$_[0]};
+ my $c = $tables{$_[0]};
+ my $o = $s->{primary} ? join ', ', map "x.$_", $s->{primary}->@* : $c ? $c->{order} : '';
+ $o ? "ORDER BY $o" : '';
+}
sub export_timestamp {
@@ -127,14 +192,32 @@ sub export_table {
my $fn = "$dest/$table->{name}";
- # Truncate all timestamptz columns to a day, to avoid leaking privacy-sensitive info.
- my $cols = join ', ', map $_->{type} eq 'timestamptz' ? "date_trunc('day', \"$_->{name}\")" : qq{"$_->{name}"}, @cols;
- my $where = $table->{where} ? "WHERE $table->{where}" : '';
- my $order = $schema->{primary} ? join ', ', map "\"$_\"", @{$schema->{primary}} : $table->{order};
- die "Table '$table->{name}' is missing an ORDER BY clause\n" if !$order;
+ my $sql = $table->{sql} // do {
+ my %isuid =
+ map +($_->{from_cols}[0], 1),
+ grep $_->{to_table} eq 'users' && $_->{to_cols}[0] eq 'id' && $_->{from_table} eq $table->{name}, @$references;
+ my $join = '';
+
+ my $cols = join ', ', map {
+ # For uid columns, check against the users table and export NULL for deleted accounts
+ $isuid{$_->{name}} ? do {
+ my $t = "u_$_->{name}";
+ $join .= " LEFT JOIN users $t ON $t.id = x.$_->{name}";
+ "CASE WHEN $t.username IS NULL THEN NULL ELSE $t.id END"
+ }
+ # Truncate all timestamptz columns to a day, to avoid leaking privacy-sensitive info.
+ : $_->{type} eq 'timestamptz' ? "date_trunc('day', x.$_->{name})"
+ : qq{x.$_->{name}}
+ } @cols;
+
+ my $where = $table->{where} ? "WHERE $table->{where}" : '';
+ my $order = table_order $table->{name};
+ die "Table '$table->{name}' is missing an ORDER BY clause\n" if !$order;
+ qq{SELECT $cols FROM $table->{name} x $join $where $order}
+ };
my $start = time;
- $db->do(qq{COPY (SELECT $cols FROM "$table->{name}" $where ORDER BY $order) TO STDOUT});
+ $db->do(qq{COPY ($sql) TO STDOUT});
open my $F, '>:utf8', $fn;
my $v;
print $F $v while($db->pg_getcopydata($v) >= 0);
@@ -166,6 +249,12 @@ sub export_import_script {
-- Uncomment to import the schema and data into a separate namespace:
--CREATE SCHEMA vndb;
--SET search_path TO vndb;
+
+ -- 'vndbid' is a custom base type used in the VNDB codebase, but it's safe to treat
+ -- it as just text. If you want to use the proper type, load sql/vndbid.sql from
+ -- the VNDB source code into your database and comment out the following line.
+ -- (or ignore the error message about 'vndbid' already existing)
+ CREATE DOMAIN vndbid AS text;
_
print $F "\n\n";
@@ -174,10 +263,11 @@ sub export_import_script {
for my $table (@tables) {
my $schema = $schema->{$table->{name}};
+ my @primary = grep { my $n=$_; !!grep $_->{name} eq $n && $_->{pub}, $schema->{cols}->@* } ($schema->{primary}||[])->@*;
print $F "\n";
- print $F "CREATE TABLE \"$table->{name}\" (\n";
- print $F join ",\n", map " $_->{decl}" =~ s/" serial/" integer/ir, grep $_->{pub}, @{$schema->{cols}};
- print $F ",\n PRIMARY KEY(".join(', ', map "\"$_\"", @{$schema->{primary}}).")" if $schema->{primary};
+ print $F "CREATE TABLE $table->{name} (\n";
+ print $F join ",\n", map " $_->{decl}" =~ s/ serial/ integer/ir =~ s/ +(?:check|constraint|default) +.*//ir, grep $_->{pub}, @{$schema->{cols}};
+ print $F ",\n PRIMARY KEY(".join(', ', map "$_", @primary).")" if @primary;
print $F "\n);\n";
}
@@ -193,6 +283,19 @@ sub export_import_script {
next if grep !$pub{$_}, @{$ref->{from_cols}};
print $F "$ref->{decl}\n";
}
+
+ print $F "\n\n";
+ print $F "-- Sparse documentation, but it's something!\n";
+ my $L = \%VNDB::ExtLinks::LINKS;
+ for my $table (@tables) {
+ my $schema = $schema->{$table->{name}};
+ print $F "COMMENT ON TABLE $table->{name} IS ".$db->quote($schema->{comment}).";\n" if $schema->{comment};
+ my $l = ($schema->{dbentry_type} && $L->{$schema->{dbentry_type}}) || {};
+ for (grep $_->{pub}, $schema->{cols}->@*) {
+ $_->{comment} = "$l->{$_->{name}}{label}, $l->{$_->{name}}{fmt} $_->{comment}" if $l->{$_->{name}} && $l->{$_->{name}}{fmt};
+ print $F "COMMENT ON COLUMN $table->{name}.$_->{name} IS ".$db->quote($_->{comment}).";\n" if $_->{comment};
+ }
+ }
}
@@ -207,7 +310,7 @@ sub export_db {
README.txt
};
- # This will die if it already exists, which is good because we want to write to a new empty dir.
+ rmtree "${dest}_dir";
mkdir "${dest}_dir";
mkdir "${dest}_dir/db";
@@ -218,7 +321,8 @@ sub export_db {
export_import_script "${dest}_dir/import.sql";
#print "# Compressing\n";
- `tar -cf "$dest" -I 'zstd -7' --sort=name -C "${dest}_dir" @static import.sql TIMESTAMP db`
+ `tar -cf "$dest" -I 'zstd -7' --sort=name -C "${dest}_dir" @static import.sql TIMESTAMP db`;
+ rmtree "${dest}_dir";
}
@@ -234,55 +338,86 @@ sub cp_p {
sub export_img {
my $dest = shift;
- {
- no autodie;
- mkdir ${dest};
- mkdir sprintf '%s/%s', $dest, $_ for qw/ch cv sf st/;
- mkdir sprintf '%s/%s/%02d', $dest, $_->[0], $_->[1] for map +([ch=>$_], [cv=>$_], [sf=>$_], [st=>$_]), 0..99;
- }
+ no autodie;
+ mkdir ${dest};
+ mkdir sprintf '%s/%s', $dest, $_ for qw/ch cv sf sf.t/;
+ mkdir sprintf '%s/%s/%02d', $dest, $_->[0], $_->[1] for map +([ch=>$_], [cv=>$_], [sf=>$_], ['sf.t'=>$_]), 0..99;
cp_p "$ROOT/util/dump/LICENSE-ODBL.txt", "$dest/LICENSE-ODBL.txt";
cp_p "$ROOT/util/dump/README-img.txt", "$dest/README.txt";
export_timestamp "$dest/TIMESTAMP";
my %scr;
- my %dir = (ch => {}, cv => {}, sf => \%scr, st => \%scr);
- $dir{sf}{$_->[0]} = 1 for $db->selectall_array("SELECT id FROM screenshots WHERE $tables{screenshots}{where} ORDER BY id");
- $dir{cv}{$_->[0]} = 1 for $db->selectall_array("SELECT image FROM vn WHERE image <> 0 AND $tables{vn}{where} ORDER BY image");
- $dir{ch}{$_->[0]} = 1 for $db->selectall_array("SELECT image FROM chars WHERE image <> 0 AND $tables{chars}{where} ORDER BY image");
+ my %dir = (ch => {}, cv => {}, sf => \%scr, 'sf.t' => \%scr);
+ $dir{sf}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(scr) FROM vn_screenshots x WHERE $tables{vn_screenshots}{where}");
+ $dir{cv}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(image) FROM vn x WHERE image IS NOT NULL AND $tables{vn}{where}");
+ $dir{ch}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(image) FROM chars x WHERE image IS NOT NULL AND $tables{chars}{where}");
$db->rollback;
undef $db;
find {
no_chdir => 1,
wanted => sub {
- unlink $File::Find::name if $File::Find::name =~ m{(cv|ch|sf|st)/[0-9][0-9]/([0-9]+)\.jpg$} && !$dir{$1}{$2};
+ unlink $File::Find::name or warn "Unable to unlink $File::Find::name: $!\n"
+ if $File::Find::name =~ m{(cv|ch|sf|sf\.t)/[0-9][0-9]/([0-9]+)\.jpg$} && !$dir{$1}{$2};
}
}, $dest;
for my $d (keys %dir) {
for my $i (keys %{$dir{$d}}) {
my $f = sprintf('%s/%02d/%d.jpg', $d, $i % 100, $i);
- link "$ROOT/static/$f", "$dest/$f" if !-e "$dest/$f";
+ link "$ENV{VNDB_VAR}/static/$f", "$dest/$f" or warn "Unable to link $f: $!\n" if !-e "$dest/$f";
}
}
}
+sub export_data {
+ my $dest = shift;
+ my $F = *STDOUT;
+ open $F, '>', $dest if $dest ne '-';
+ binmode($F, ":utf8");
+ select $F;
+ print "\\set ON_ERROR_STOP 1\n";
+ print "\\i sql/util.sql\n";
+ print "\\i sql/schema.sql\n";
+ # Would be nice if VNDB::Schema could list sequences, too.
+ my @seq = sort @{ $db->selectcol_arrayref(
+ "SELECT oid::regclass::text FROM pg_class WHERE relkind = 'S' AND relnamespace = 'public'::regnamespace"
+ ) };
+ printf "SELECT setval('%s', %d);\n", $_, $db->selectrow_array("SELECT last_value FROM \"$_\"", {}) for @seq;
+ for my $t (sort { $a->{name} cmp $b->{name} } values %$schema) {
+ my $cols = join ',', map $_->{name}, grep $_->{decl} !~ /\sGENERATED\s/, $t->{cols}->@*;
+ my $order = table_order $t->{name};
+ print "\nCOPY $t->{name} ($cols) FROM STDIN;\n";
+ $db->do("COPY (SELECT $cols FROM $t->{name} x $order) TO STDOUT");
+ my $v;
+ print $v while($db->pg_getcopydata($v) >= 0);
+ print "\\.\n";
+ }
+ print "\\i sql/func.sql\n";
+ print "\\i sql/editfunc.sql\n";
+ print "\\i sql/tableattrs.sql\n";
+ print "\\i sql/triggers.sql\n";
+ print "\\set ON_ERROR_STOP 0\n";
+ print "\\i sql/perms.sql\n";
+}
+
+
sub export_votes {
my $dest = shift;
require PerlIO::gzip;
open my $F, '>:gzip:utf8', $dest;
$db->do(q{COPY (
- SELECT uv.vid||' '||uv.uid||' '||uv.vote||' '||to_char(uv.vote_date, 'YYYY-MM-DD')
+ SELECT vndbid_num(uv.vid)||' '||vndbid_num(uv.uid)||' '||uv.vote||' '||to_char(uv.vote_date, 'YYYY-MM-DD')
FROM ulist_vns uv
JOIN users u ON u.id = uv.uid
JOIN vn v ON v.id = uv.vid
WHERE NOT v.hidden
AND NOT u.ign_votes
AND uv.vote IS NOT NULL
- AND EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.id = uvl.lbl AND ul.uid = uvl.uid WHERE uv.uid = uvl.uid AND uv.vid = uvl.vid AND NOT ul.private)
+ AND NOT uv.c_private
ORDER BY uv.vid, uv.uid
) TO STDOUT
});
@@ -297,10 +432,9 @@ sub export_tags {
require PerlIO::gzip;
my $lst = $db->selectall_arrayref(q{
- SELECT id, name, description, searchable, applicable, c_items AS vns, cat,
- (SELECT string_agg(alias,'$$$-$$$') FROM tags_aliases where tag = id) AS aliases,
- (SELECT string_agg(parent::text, ',') FROM tags_parents WHERE tag = id) AS parents
- FROM tags WHERE state = 2 ORDER BY id
+ SELECT vndbid_num(id) AS id, name, description, searchable, applicable, c_items AS vns, cat, alias,
+ (SELECT string_agg(vndbid_num(parent)::text, ',' ORDER BY main desc, parent) FROM tags_parents tp WHERE tp.id = t.id) AS parents
+ FROM tags t WHERE NOT hidden ORDER BY id
}, { Slice => {} });
for(@$lst) {
$_->{id} *= 1;
@@ -308,7 +442,7 @@ sub export_tags {
$_->{searchable} = $_->{searchable} ? JSON::XS::true() : JSON::XS::false();
$_->{applicable} = $_->{applicable} ? JSON::XS::true() : JSON::XS::false();
$_->{vns} *= 1;
- $_->{aliases} = [ split /\$\$\$-\$\$\$/, ($_->{aliases}||'') ];
+ $_->{aliases} = [ split /\n/, delete $_->{alias} ];
$_->{parents} = [ map $_*1, split /,/, ($_->{parents}||'') ];
}
@@ -323,9 +457,9 @@ sub export_traits {
require PerlIO::gzip;
my $lst = $db->selectall_arrayref(q{
- SELECT id, name, alias AS aliases, description, searchable, applicable, c_items AS chars,
- (SELECT string_agg(parent::text, ',') FROM traits_parents WHERE trait = id) AS parents
- FROM traits WHERE state = 2 ORDER BY id
+ SELECT vndbid_num(id) AS id, name, alias AS aliases, description, searchable, applicable, c_items AS chars,
+ (SELECT string_agg(vndbid_num(parent)::text, ',' ORDER BY main desc, parent) FROM traits_parents tp WHERE tp.id = t.id) AS parents
+ FROM traits t WHERE NOT hidden ORDER BY id
}, { Slice => {} });
for(@$lst) {
$_->{id} *= 1;
@@ -343,9 +477,11 @@ sub export_traits {
if($ARGV[0] && $ARGV[0] eq 'export-db' && $ARGV[1]) {
- export_db $ARGV[1];
+ consistent_snapshot sub { export_db $ARGV[1] };
} elsif($ARGV[0] && $ARGV[0] eq 'export-img' && $ARGV[1]) {
export_img $ARGV[1];
+} elsif($ARGV[0] && $ARGV[0] eq 'export-data' && $ARGV[1]) {
+ export_data $ARGV[1];
} elsif($ARGV[0] && $ARGV[0] eq 'export-votes' && $ARGV[1]) {
export_votes $ARGV[1];
} elsif($ARGV[0] && $ARGV[0] eq 'export-tags' && $ARGV[1]) {
diff --git a/util/devdump.pl b/util/devdump.pl
index 9e724619..e0f0f80f 100755
--- a/util/devdump.pl
+++ b/util/devdump.pl
@@ -8,34 +8,55 @@ use warnings;
use autodie;
use DBI;
use DBD::Pg;
+use Cwd 'abs_path';
+
+my $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/devdump\.pl$}{}; }
+
+use lib $ROOT.'/lib';
my $db = DBI->connect('dbi:Pg:dbname=vndb', 'vndb', undef, { RaiseError => 1 });
+sub ids { join ',', map "'$_'", @{$_[0]} }
+sub idq { ids $db->selectcol_arrayref($_[0]) }
+
+chdir($ENV{VNDB_VAR}//'var');
# Figure out which DB entries to export
-my @vids = (3, 17, 97, 183, 264, 266, 384, 407, 1910, 2932, 5922, 6438, 9837);
-my $vids = join ',', @vids;
-my $staff = $db->selectcol_arrayref(
+my $large = ($ARGV[0]||'') eq 'large';
+
+my $vids = $large ? 'SELECT id FROM vn' : ids [qw/v3 v17 v97 v183 v264 v266 v384 v407 v1910 v2932 v5922 v6438 v9837/];
+my $staff = $large ? 'SELECT id FROM staff' : idq(
"SELECT c2.itemid FROM vn_staff_hist v JOIN changes c ON c.id = v.chid JOIN staff_alias_hist a ON a.aid = v.aid JOIN changes c2 ON c2.id = a.chid WHERE c.itemid IN($vids) "
."UNION "
."SELECT c2.itemid FROM vn_seiyuu_hist v JOIN changes c ON c.id = v.chid JOIN staff_alias_hist a ON a.aid = v.aid JOIN changes c2 ON c2.id = a.chid WHERE c.itemid IN($vids)"
);
-my $releases = $db->selectcol_arrayref("SELECT DISTINCT c.itemid FROM releases_vn_hist v JOIN changes c ON c.id = v.chid WHERE v.vid IN($vids)");
-my $producers = $db->selectcol_arrayref("SELECT pid FROM releases_producers_hist p JOIN changes c ON c.id = p.chid WHERE c.type = 'r' AND c.itemid IN(".join(',',@$releases).")");
-my $characters = $db->selectcol_arrayref(
+my $releases = $large ? 'SELECT id FROM releases' : idq(
+ "SELECT DISTINCT c.itemid FROM releases_vn_hist v JOIN changes c ON c.id = v.chid WHERE v.vid IN($vids)"
+);
+my $producers = $large ? 'SELECT id FROM producers' : idq(
+ "SELECT pid FROM releases_producers_hist p JOIN changes c ON c.id = p.chid WHERE c.itemid IN($releases)"
+);
+my $characters = $large ? 'SELECT id FROM chars' : idq(
"SELECT DISTINCT c.itemid FROM chars_vns_hist e JOIN changes c ON c.id = e.chid WHERE e.vid IN($vids) "
."UNION "
."SELECT DISTINCT h.main FROM chars_vns_hist e JOIN changes c ON c.id = e.chid JOIN chars_hist h ON h.chid = e.chid WHERE e.vid IN($vids) AND h.main IS NOT NULL"
);
-
+my $imageids = !$large && $db->selectcol_arrayref("
+ SELECT image FROM chars_hist ch JOIN changes c ON c.id = ch.chid WHERE c.itemid IN($characters) AND ch.image IS NOT NULL
+ UNION SELECT image FROM vn_hist vh JOIN changes c ON c.id = vh.chid WHERE c.itemid IN($vids) AND vh.image IS NOT NULL
+ UNION SELECT scr FROM vn_screenshots_hist vs JOIN changes c ON c.id = vs.chid WHERE c.itemid IN($vids)
+");
+my $images = $large ? 'SELECT id FROM images' : ids($imageids);
# Helper function to copy a table or SQL statement. Can do modifications on a
# few columns (the $specials).
sub copy {
my($dest, $sql, $specials) = @_;
+ warn "$dest...\n";
$sql ||= "SELECT * FROM $dest";
$specials ||= {};
@@ -46,15 +67,11 @@ sub copy {
grep !($specials->{$_} && $specials->{$_} eq 'del'), @{$s->{NAME}}
};
- printf "COPY %s (%s) FROM stdin;\n", $dest, join ', ', map "\"$_\"", @cols;
+ printf "COPY %s (%s) FROM stdin;\n", $dest, join ', ', @cols;
$sql = "SELECT " . join(',', map {
my $s = $specials->{$_} || '';
- if($s eq 'user') {
- qq{"$_" % 10 AS "$_"}
- } else {
- qq{"$_"}
- }
+ $s eq 'user' ? "CASE WHEN vndbid_num($_) % 10 = 0 THEN NULL ELSE vndbid('u', vndbid_num($_) % 10) END AS $_" : $_;
} @cols) . " FROM ($sql) AS x";
#warn $sql;
$db->do("COPY ($sql) TO STDOUT");
@@ -67,14 +84,13 @@ sub copy {
# Helper function to copy a full DB entry with history and all (doesn't handle references)
sub copy_entry {
- my($type, $tables, $ids) = @_;
- $ids = join ',', @$ids;
- copy changes => "SELECT * FROM changes WHERE type = '$type' AND itemid IN($ids)", {requester => 'user', ip => 'del'};
+ my($tables, $ids) = @_;
+ copy changes => "SELECT * FROM changes WHERE itemid IN($ids)", {requester => 'user', ip => 'del'};
for(@$tables) {
my $add = '';
$add = " AND vid IN($vids)" if /^releases_vn/ || /^vn_relations/ || /^chars_vns/;
copy $_ => "SELECT * FROM $_ WHERE id IN($ids) $add";
- copy "${_}_hist" => "SELECT x.* FROM ${_}_hist x JOIN changes c ON c.id = x.chid WHERE c.type = '$type' AND c.itemid IN($ids) $add";
+ copy "${_}_hist" => "SELECT x.* FROM ${_}_hist x JOIN changes c ON c.id = x.chid WHERE c.itemid IN($ids) $add";
}
}
@@ -83,12 +99,13 @@ sub copy_entry {
open my $OUT, '>:utf8', 'dump.sql';
select $OUT;
- print "-- This file replaces 'util/sql/all.sql'.\n";
+ print "-- This file replaces 'sql/all.sql'.\n";
print "\\set ON_ERROR_STOP 1\n";
- print "\\i util/sql/schema.sql\n";
- print "\\i util/sql/data.sql\n";
- print "\\i util/sql/func.sql\n";
- print "\\i util/sql/editfunc.sql\n";
+ print "\\i sql/util.sql\n";
+ print "\\i sql/schema.sql\n";
+ print "\\i sql/data.sql\n";
+ print "\\i sql/func.sql\n";
+ print "\\i sql/editfunc.sql\n";
# Copy over all sequence values
my @seq = sort @{ $db->selectcol_arrayref(
@@ -99,78 +116,85 @@ sub copy_entry {
# A few pre-defined users
# This password is 'hunter2' with the default salt
my $pass = '000100000801ec4185fed438752d6b3b968e2b2cd045f70005cb7e10cafdbb694a82246bd34a065b6e977e0c3dcc';
- printf "INSERT INTO users (id, username, mail, perm, passwd, email_confirmed) VALUES (%d, '%s', '%s', %d, decode('%s', 'hex'), true);\n", @$_, $pass for(
- [ 2, 'admin', 'admin@vndb.org', 503 ],
- [ 3, 'user1', 'user1@vndb.org', 21 ],
- [ 4, 'user2', 'user2@vndb.org', 21 ],
- [ 5, 'user3', 'user3@vndb.org', 21 ],
- [ 6, 'user4', 'user4@vndb.org', 21 ],
- [ 7, 'user5', 'user5@vndb.org', 21 ],
- [ 8, 'user6', 'user6@vndb.org', 21 ],
- [ 9, 'user7', 'user7@vndb.org', 21 ],
- );
+ for(
+ [ 'u2', 'admin', 'admin@vndb.org', 'true', 'true'],
+ [ 'u3', 'mod', 'mod@vndb.org', 'false', 'true'],
+ [ 'u4', 'user1', 'user1@vndb.org', 'false', 'false'],
+ [ 'u5', 'user2', 'user2@vndb.org', 'false', 'false'],
+ [ 'u6', 'user3', 'user3@vndb.org', 'false', 'false'],
+ [ 'u7', 'user4', 'user4@vndb.org', 'false', 'false'],
+ [ 'u8', 'user5', 'user5@vndb.org', 'false', 'false'],
+ [ 'u9', 'user6', 'user6@vndb.org', 'false', 'false'],
+ ) {
+ printf "INSERT INTO users (id, username, email_confirmed, perm_dbmod, perm_tagmod) VALUES ('%s', '%s', true, '%s', '%s');\n", @{$_}[0,1,4,4];
+ printf "INSERT INTO users_shadow (id, mail, perm_usermod, passwd) VALUES ('%s', '%s', %s, decode('%s', 'hex'));\n", @{$_}[0,2,3], $pass;
+ printf "INSERT INTO users_prefs (id) VALUES ('%s');\n", $_->[0];
+ }
print "SELECT ulist_labels_create(id) FROM users;\n";
# Tags & traits
- copy tags => undef, {addedby => 'user'};
- copy 'tags_aliases';
- copy 'tags_parents';
- copy traits => undef, {addedby => 'user'};
- copy 'traits_parents';
+ copy_entry [qw/tags tags_parents/], 'SELECT id FROM tags';
+ copy_entry [qw/traits traits_parents/], 'SELECT id FROM traits';
# Wikidata (TODO: This could be a lot more selective)
copy 'wikidata';
+ # Image metadata
+ copy images => "SELECT * FROM images WHERE id IN($images)", { uploader => 'user' };
+ copy image_votes => "SELECT DISTINCT ON (id,vndbid('u', vndbid_num(uid)%10+10)) * FROM image_votes WHERE id IN($images)", { uid => 'user' };
+
# Threads (announcements)
- my $threads = join ',', @{ $db->selectcol_arrayref("SELECT tid FROM threads_boards b WHERE b.type = 'an'") };
+ my $threads = idq("SELECT tid FROM threads_boards b WHERE b.type = 'an'");
copy threads => "SELECT * FROM threads WHERE id IN($threads)";
copy threads_boards => "SELECT * FROM threads_boards WHERE tid IN($threads)";
copy threads_posts => "SELECT * FROM threads_posts WHERE tid IN($threads)", { uid => 'user' };
# Doc pages
- copy_entry d => ['docs'], $db->selectcol_arrayref('SELECT id FROM docs');
+ copy_entry ['docs'], 'SELECT id FROM docs';
# Staff
- copy_entry s => [qw/staff staff_alias/], $staff;
+ copy_entry [qw/staff staff_alias/], $staff;
# Producers (TODO: Relations)
- copy 'relgraphs', "SELECT DISTINCT ON (r.id) r.* FROM relgraphs r JOIN producers p ON p.rgraph = r.id WHERE p.id IN(".join(',', @$producers).")", {};
- copy_entry p => [qw/producers/], $producers;
+ copy_entry [qw/producers/], $producers;
# Characters
- copy_entry c => [qw/chars chars_traits chars_vns/], $characters;
+ copy_entry [qw/chars chars_traits chars_vns/], $characters;
# Visual novels
- copy screenshots => "SELECT DISTINCT s.* FROM screenshots s JOIN vn_screenshots_hist v ON v.scr = s.id JOIN changes c ON c.id = v.chid WHERE c.type = 'v' AND c.itemid IN($vids)";
- copy anime => "SELECT DISTINCT a.* FROM anime a JOIN vn_anime_hist v ON v.aid = a.id JOIN changes c ON c.id = v.chid WHERE c.type = 'v' AND c.itemid IN($vids)";
- copy relgraphs => "SELECT DISTINCT ON (r.id) r.* FROM relgraphs r JOIN vn v ON v.rgraph = r.id WHERE v.id IN($vids)", {};
- copy_entry v => [qw/vn vn_anime vn_seiyuu vn_staff vn_relations vn_screenshots/], \@vids;
+ copy anime => "SELECT DISTINCT a.* FROM anime a JOIN vn_anime_hist v ON v.aid = a.id JOIN changes c ON c.id = v.chid WHERE c.itemid IN($vids)";
+ copy_entry [qw/vn vn_anime vn_editions vn_seiyuu vn_staff vn_relations vn_screenshots vn_titles/], $vids;
# VN-related niceties
- copy tags_vn => "SELECT DISTINCT ON (tag,vid,uid%10) * FROM tags_vn WHERE vid IN($vids)", {uid => 'user'};
- copy quotes => "SELECT * FROM quotes WHERE vid IN($vids)";
- my $votes = "SELECT vid, uid%8+2 AS uid, (percentile_cont((uid%8+1)::float/9) WITHIN GROUP (ORDER BY vote))::smallint AS vote, MIN(date) AS vote_date FROM votes WHERE vid IN($vids) GROUP BY vid, uid%8";
- copy ulist_vns => $votes, {uid => 'user'};
- copy ulist_vns_labels => "SELECT vid, uid, 7 AS lbl FROM ($votes) x", {uid => 'user'};
+ copy vn_length_votes => "SELECT DISTINCT ON (vid,vndbid_num(uid)%10) * FROM vn_length_votes WHERE NOT private AND vid IN($vids)", {uid => 'user'};
+ copy tags_vn => "SELECT DISTINCT ON (tag,vid,vndbid_num(uid)%10) * FROM tags_vn WHERE vid IN($vids)", {uid => 'user'};
+ copy quotes => "SELECT * FROM quotes WHERE rand IS NOT NULL AND vid IN($vids)", {addedby => 'user'};
+ copy ulist_vns => "SELECT vid, vndbid('u', vndbid_num(uid)%8+2) AS uid, MIN(vote_date) AS vote_date, '{7}' AS labels, false AS c_private
+ , (percentile_cont((vndbid_num(uid)%8+1)::float/9) WITHIN GROUP (ORDER BY vote))::smallint AS vote
+ FROM ulist_vns WHERE vid IN($vids) AND vote IS NOT NULL GROUP BY vid, vndbid_num(uid)%8", {uid => 'user'};
# Releases
- copy_entry r => [qw/releases releases_lang releases_media releases_platforms releases_producers releases_vn/], $releases;
+ copy 'drm';
+ copy_entry [qw/releases releases_drm releases_media releases_platforms releases_producers releases_titles releases_vn/], $releases;
- print "\\i util/sql/tableattrs.sql\n";
- print "\\i util/sql/triggers.sql\n";
+ print "\\i sql/tableattrs.sql\n";
+ print "\\i sql/triggers.sql\n";
# Update some caches
print "SELECT tag_vn_calc(NULL);\n";
print "SELECT traits_chars_calc(NULL);\n";
- print "SELECT update_vncache(id) FROM vn;\n";
+ print "SELECT count(*) FROM (SELECT update_vncache(id) FROM vn) x;\n";
print "SELECT update_stats_cache_full();\n";
print "SELECT update_vnvotestats();\n";
print "SELECT update_users_ulist_stats(NULL);\n";
+ print "SELECT update_images_cache(NULL);\n";
+ print "SELECT count(*) FROM (SELECT update_search(id) FROM $_) x;\n" for (qw/chars producers vn releases staff tags traits/);
print "UPDATE users u SET c_tags = (SELECT COUNT(*) FROM tags_vn v WHERE v.uid = u.id);\n";
print "UPDATE users u SET c_changes = (SELECT COUNT(*) FROM changes c WHERE c.requester = u.id);\n";
print "\\set ON_ERROR_STOP 0\n";
- print "\\i util/sql/perms.sql\n";
+ print "\\i sql/perms.sql\n";
+ print "VACUUM ANALYZE;\n";
select STDOUT;
close $OUT;
@@ -178,13 +202,11 @@ sub copy_entry {
-
# Now figure out which images we need, and throw everything in a tarball
-sub imgs { map sprintf('static/%s/%02d/%d.jpg', $_[0], $_%100, $_), @{$_[1]} }
-
-my $ch = $db->selectcol_arrayref("SELECT DISTINCT e.image FROM chars_hist e JOIN changes c ON c.id = e.chid WHERE c.type = 'c' AND e.image <> 0 AND c.itemid IN(".join(',', @$characters).")");
-my $cv = $db->selectcol_arrayref("SELECT DISTINCT e.image FROM vn_hist e JOIN changes c ON c.id = e.chid WHERE c.type = 'v' AND e.image <> 0 AND c.itemid IN($vids)");
-my $sf = $db->selectcol_arrayref("SELECT DISTINCT e.scr FROM vn_screenshots_hist e JOIN changes c ON c.id = e.chid WHERE c.type = 'v' AND c.itemid IN($vids)");
+if(!$large) {
+ sub img { sprintf 'static/%s/%02d/%d.jpg', $_[0], $_[1]%100, $_[1] }
+ my @imgpaths = sort map { my($t,$id) = /([a-z]+)([0-9]+)/; (img($t, $id), $t eq 'sf' ? img('sf.t', $id) : ()) } @$imageids;
-system("tar -czf devdump.tar.gz dump.sql ".join ' ', imgs(ch => $ch), imgs(cv => $cv), imgs(sf => $sf), imgs(st => $sf));
-unlink 'dump.sql';
+ system("tar -czf devdump.tar.gz dump.sql ".join ' ', @imgpaths);
+ unlink 'dump.sql';
+}
diff --git a/util/dl-cron.sh b/util/dl-cron.sh
new file mode 100755
index 00000000..8149ccbd
--- /dev/null
+++ b/util/dl-cron.sh
@@ -0,0 +1,44 @@
+#!/bin/sh
+
+[ -z "$VNDB_VAR" ] && VNDB_VAR=var
+
+mkdir -p "$VNDB_VAR/dl/dump" "$VNDB_VAR/dl/img" "$VNDB_VAR/tmp"
+
+# Keep only the last (non-symlink) files matching the given pattern, delete the rest.
+cleanup() {
+ (
+ cd "$VNDB_VAR/dl/dump"
+ for f in $(find . -type f -name "$1" | sort | head -n -1); do
+ rm "$f"
+ done
+ )
+ util/dl-gendir.pl
+}
+
+
+dumpfile() {
+ FN=$1
+ LATEST=$2
+ CMD=$3
+ test -f "$VNDB_VAR/dl/dump/$FN" && echo "$FN already exists" && return
+ util/dbdump.pl $CMD "$VNDB_VAR/tmp/$FN"
+ mv "$VNDB_VAR/tmp/$FN" "$VNDB_VAR/dl/dump/$FN"
+ ln -sf "$FN" "$VNDB_VAR/dl/dump/$LATEST"
+ util/dl-gendir.pl
+}
+
+cleanup "vndb-dev-*.tar.gz"
+
+cleanup "vndb-votes-*.gz"
+dumpfile "vndb-votes-`date +%F`.gz" "vndb-votes-latest.gz" export-votes
+
+cleanup "vndb-tags-*.json.gz"
+dumpfile "vndb-tags-`date +%F`.json.gz" "vndb-tags-latest.json.gz" export-tags
+
+cleanup "vndb-traits-*.json.gz"
+dumpfile "vndb-traits-`date +%F`.json.gz" "vndb-traits-latest.json.gz" export-traits
+
+cleanup "vndb-db-*.tar.zst"
+dumpfile "vndb-db-`date +%F`.tar.zst" "vndb-db-latest.tar.zst" export-db
+
+util/dbdump.pl export-img "$VNDB_VAR/dl/img"
diff --git a/util/dl-gendir.pl b/util/dl-gendir.pl
new file mode 100755
index 00000000..3ca9e1f3
--- /dev/null
+++ b/util/dl-gendir.pl
@@ -0,0 +1,51 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+use autodie;
+use POSIX 'strftime';
+
+chdir(($ENV{VNDB_VAR}//'var').'/dl/dump');
+
+my @pub = (glob('vndb-db-*'), glob('vndb-dev-*'), glob('vndb-votes-*'), glob('vndb-tags-*'), glob('vndb-traits-*'));
+
+open my $F, '>', 'index.html~';
+print $F q{<!DOCTYPE html>
+<html>
+ <head>
+ <title>VNDB Database Downloads</title>
+ <style type="text/css">
+ th { text-align: left }
+ td, th { padding: 1px 5px }
+ td:nth-child(3), th:nth-child(3) { text-align: right }
+ </style>
+ </head>
+ <body>
+ <h1>VNDB Database Downloads</h1>
+ <p>Refer to the <a href="https://vndb.org/d14">Database Dumps</a> page on VNDB.org for more information about these files.</p>
+ <h2>Latest versions</h2>
+ <table>
+ <thead>
+ <thead><tr><th>Name</th><th>Destination</th></tr></thead>
+ <tbody>
+};
+
+printf $F q{<tr><td><a href="%s">%s</a></td><td>%s</td></tr>},
+ $_, $_, readlink
+ for (grep -l, @pub);
+
+print $F q{
+ </tbody>
+ </table>
+ <h2>Files</h2>
+ <table>
+ <thead><tr><th>Name</th><th>Last modified</th><th>Size</th></tr></thead>
+ <tbody>
+};
+printf $F q{<tr><td><a href="%s">%s</a></td><td>%s</td><td>%d</td></tr>},
+ $_, $_, strftime('%F %T', gmtime((stat)[9])), -s
+ for (grep !-l, @pub);
+
+print $F q{</tbody></table></body>};
+close $F;
+rename 'index.html~', 'index.html';
diff --git a/util/docker-init.sh b/util/docker-init.sh
index 4e34f753..e3ae25b8 100755
--- a/util/docker-init.sh
+++ b/util/docker-init.sh
@@ -1,6 +1,6 @@
#!/bin/sh
-VER=`test -f /var/www/Dockerfile && grep VNDB_DOCKER_VERSION= /var/www/Dockerfile | sed -E s/^.+=//`
+VER=`test -f /vndb/Dockerfile && grep VNDB_DOCKER_VERSION= /vndb/Dockerfile | sed -E s/^.+=//`
if [ -z "$VER" -o -z "$VNDB_DOCKER_VERSION" -o "$VER" != "$VNDB_DOCKER_VERSION" ]; then
echo "The Docker image version ($VNDB_DOCKER_VERSION) does not match the version in the currently checked out source code ($VER)."
@@ -24,8 +24,8 @@ mkdevuser() {
# If the owner is root, we're probably running under Docker for Mac or
# similar and don't need to match UID/GID. See https://vndb.org/t9959 #38
# to #44.
- USER_UID=`stat -c '%u' /var/www`
- USER_GID=`stat -c '%g' /var/www`
+ USER_UID=`stat -c '%u' /vndb`
+ USER_GID=`stat -c '%g' /vndb`
if test $USER_UID -eq 0; then
addgroup devgroup
adduser -s /bin/sh devuser
@@ -37,16 +37,27 @@ mkdevuser() {
}
+# Should run as root
+installvndbid() {
+ mkdir -p /tmp/vndbid
+ cp /vndb/sql/c/vndbfuncs.c /vndb/sql/c/Makefile /tmp/vndbid
+ make -C /tmp/vndbid install || exit
+}
+
+
# Should run as devuser
pg_start() {
- if [ ! -d /var/www/data/docker-pg/12 ]; then
- mkdir -p /var/www/data/docker-pg/12
- initdb -D /var/www/data/docker-pg/12 --locale en_US.UTF-8 -A trust
+ cd /vndb
+ make -j4
+ util/setup-var.sh
+
+ if [ ! -d docker/pg15 ]; then
+ mkdir -p docker/pg15
+ initdb -D docker/pg15 --locale en_US.UTF-8 -A trust
fi
- pg_ctl -D /var/www/data/docker-pg/12 -l /var/www/data/docker-pg/12/logfile start
+ pg_ctl -D /vndb/docker/pg15 -l /vndb/docker/pg15/logfile start
- cd /var/www
- if test -f data/docker-pg/vndb-init-done; then
+ if test -f docker/pg15/vndb-init-done; then
echo
echo "Database initialization already done."
echo
@@ -59,14 +70,13 @@ pg_start() {
echo "If you want to have some data to play around with,"
echo "I can download and install a development database for you."
echo "For information, see https://vndb.org/d8#3"
- echo "(Warning: This will also write images to static/)"
echo
echo "Enter n to setup an empty database, y to download the dev database."
[ -f dump.sql ] && echo " Or e to import the existing dump.sql."
read -p "Choice: " opt
- make util/sql/editfunc.sql
- psql postgres -f util/sql/superuser_init.sql
+ psql postgres -f sql/superuser_init.sql
+ psql -U devuser vndb -f sql/vndbid.sql
echo "ALTER ROLE vndb LOGIN" | psql postgres
echo "ALTER ROLE vndb_site LOGIN" | psql postgres
echo "ALTER ROLE vndb_multi LOGIN" | psql postgres
@@ -76,14 +86,14 @@ pg_start() {
psql -U vndb -f dump.sql
elif [ $opt = y ]
then
- curl -L https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -xzf-
- psql -U vndb -f dump.sql
- rm dump.sql
+ curl -sL https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -C docker/var -xzf-
+ psql -U vndb -f docker/var/dump.sql
+ rm docker/var/dump.sql
else
- psql -U vndb -f util/sql/all.sql
+ psql -U vndb -f sql/all.sql
fi
- touch data/docker-pg/vndb-init-done
+ touch docker/pg15/vndb-init-done
echo
echo "Database initialization done!"
@@ -93,7 +103,7 @@ pg_start() {
# Should run as devuser
devshell() {
- cd /var/www
+ cd /vndb
util/vndb-dev-server.pl
sh
}
@@ -102,8 +112,9 @@ devshell() {
case "$1" in
'')
mkdevuser
- su devuser -c '/var/www/util/docker-init.sh pg_start'
- exec su devuser -c '/var/www/util/docker-init.sh devshell'
+ installvndbid
+ su devuser -c '/vndb/util/docker-init.sh pg_start'
+ exec su devuser -c '/vndb/util/docker-init.sh devshell'
;;
pg_start)
pg_start
diff --git a/util/hibp-dl.pl b/util/hibp-dl.pl
new file mode 100755
index 00000000..c3abd8c0
--- /dev/null
+++ b/util/hibp-dl.pl
@@ -0,0 +1,89 @@
+#!/usr/bin/perl
+
+# This script downloads a full copy of the Have I Been Pwned SHA1 database
+# using their range API.
+# -> https://haveibeenpwned.com/API/v3#PwnedPasswords
+#
+# Output database format:
+# var/hibp/#### -> file for hashes prefixed with those two bytes
+#
+# Each file is an ordered concatenation of raw hashes, excluding the first
+# two bytes (part of the filename) and the last 8 bytes (truncated hashes),
+# so each hash is represented with 10 bytes.
+#
+# This means we actually store 96bit truncated SHA1 hashes, which should
+# still provide a very low probability of collision. A bloom filter may have
+# a lower collision probability for the same amount of space, but is also
+# more complex and expensive to manage.
+
+use v5.28;
+use warnings;
+use AE;
+use AnyEvent::HTTP;
+use Cwd 'abs_path';
+
+my $API = 'https://api.pwnedpasswords.com/range/';
+my $concurrency = 5;
+my $lastnum = 0;
+my $run = AE::cv;
+
+my $ROOT = abs_path($0) =~ s{/util/hibp-dl\.pl$}{}r;
+
+$ENV{VNDB_VAR} //= 'var';
+
+mkdir "$ENV{VNDB_VAR}/hibp";
+chdir "$ENV{VNDB_VAR}/hibp" or die $!;
+
+
+$AnyEvent::HTTP::MAX_PER_HOST = $concurrency;
+
+sub save {
+ my($file, $count, $data) = @_;
+ {
+ open my $OUT, '>', "$file~" or die $!;
+ print $OUT $data;
+ }
+ rename "$file~", $file or die $!;
+ say sprintf '%s -> %d hashes, %.0f KiB', $file, $count, length($data)/1024;
+}
+
+sub fetch_one {
+ my($file, $count, $data, $midnum) = @_;
+
+ my $mid = sprintf '%X', $midnum;
+ http_request GET => $API.$file.$mid, persistent => 1, sub {
+ my($body, $hdr) = @_;
+ if($hdr->{Status} =~ /^2/) {
+ for (split /\r?\n/, $body) {
+ # 40-5 -> 35 hex chars per hash; 16 of which we discard so 19 we grab.
+ warn "$file.$mid Unrecognized line: $_\n" if !/^([a-fA-F0-9]{19})[a-fA-F0-9]{16}:[0-9]+$/;
+ $count++;
+ $data .= pack 'H*', $mid.$1;
+ }
+ if($midnum == 15) {
+ save $file, $count, $data;
+ fetch_next();
+ } else {
+ fetch_one($file, $count, $data, $midnum+1);
+ }
+ } else {
+ warn "$file.$mid: $hdr->{Status}\n";
+ fetch_next();
+ }
+ };
+}
+
+sub fetch_next {
+ my $file;
+ do {
+ my $filenum = $lastnum++;
+ return $run->end if $filenum > 65535;
+ $file = sprintf '%04X', $filenum;
+ } while(-s $file);
+
+ fetch_one $file, 0, '', 0;
+}
+
+$run->begin for (1..$concurrency);
+fetch_next() for (1..$concurrency);
+$run->recv;
diff --git a/util/imgproc.c b/util/imgproc.c
new file mode 100644
index 00000000..bb5202af
--- /dev/null
+++ b/util/imgproc.c
@@ -0,0 +1,252 @@
+/*
+ * USAGE: imgproc [commands] <input.png
+ *
+ * Commands:
+ *
+ * size - Output image dimensions to standard error.
+ * jpeg n - Write a jpeg to fd n.
+ * fit x y - Resize the image to fit within the given dimensions. Does not upscale.
+ * composite - For util/pngsprite.pl, must be first and only command.
+ Combine multiple input images and write a png to stdout.
+ */
+#include <stdio.h>
+#include <stdlib.h>
+#include <time.h>
+#include <unistd.h>
+
+#ifndef DISABLE_SECCOMP
+#include <seccomp.h>
+#include <fcntl.h>
+#include <locale.h>
+#include <sys/mman.h>
+#include <sys/prctl.h>
+#include <sys/ioctl.h>
+#include <malloc.h>
+#endif
+
+#include <vips/vips.h>
+
+#define MAX_INPUT_SIZE (10*1024*1024)
+
+char input_buffer[MAX_INPUT_SIZE];
+size_t input_len;
+
+
+#ifndef DISABLE_SECCOMP
+
+static void setup_seccomp() {
+ scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL_PROCESS);
+ if (ctx == NULL) goto err;
+
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap), 2,
+ SCMP_A2_32(SCMP_CMP_EQ, PROT_READ|PROT_WRITE),
+ SCMP_A4_32(SCMP_CMP_EQ, -1)
+ )) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mremap), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(munmap), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(madvise), 1, SCMP_A2_32(SCMP_CMP_EQ, MADV_DONTNEED))) goto err;
+
+ /* Threading, very fiddly :(
+ * These are likely specific to a particular glibc version on x86_64.
+ * I made an attempt to patch libvips to not use threads, but that turned out to be far more challenging.
+ */
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(futex), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(clone3), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rseq), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(set_robust_list), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap), 3,
+ SCMP_A2_32(SCMP_CMP_EQ, PROT_NONE),
+ SCMP_A3_32(SCMP_CMP_MASKED_EQ, MAP_PRIVATE&MAP_ANONYMOUS, MAP_PRIVATE|MAP_ANONYMOUS),
+ SCMP_A4_32(SCMP_CMP_EQ, -1)
+ )) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mprotect), 1, SCMP_A2_32(SCMP_CMP_EQ, PROT_READ|PROT_WRITE))) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(prctl), 1, SCMP_A0_32(SCMP_CMP_EQ, PR_SET_NAME))) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(sched_getaffinity), 0)) goto err;
+
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rt_sigaction), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rt_sigprocmask), 0)) goto err;
+
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(close), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(dup), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 1, SCMP_A0(SCMP_CMP_EQ, 0))) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0)) goto err;
+
+ /* glib logging thing
+ (disabled, no need with our custom logging handler)
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(getpeername), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(getpid), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(ioctl), 1, SCMP_A1(SCMP_CMP_EQ, TCGETS))) goto err;*/
+
+ if (seccomp_load(ctx) < 0) goto err;
+ seccomp_release(ctx);
+ return;
+err:
+ perror("setting up seccomp");
+ exit(1);
+}
+
+#endif
+
+
+/* The default glib logging handler attempt to do charset conversion, color
+ * detection and other unnecessary crap that complicates parsing and sandboxing. */
+static void log_func(const gchar *log_domain, GLogLevelFlags log_level, const gchar *message, gpointer user_data) {
+ if (g_log_writer_default_would_drop(log_level, log_domain)) return;
+ /* https://github.com/libvips/libvips/discussions/2734 - fix not yet in a release */
+ if (strcmp(message, "heifload: ignoring nclx profile") == 0) return;
+ fprintf(stderr, "[%s#%d] %s\n", log_domain, (int)log_level, message);
+}
+
+
+static int composite(void) {
+ if (input_len < 8) return 1;
+
+ int offset = 0;
+#define RDINT ({ offset += 4; *((int *)(input_buffer+offset-4)); })
+
+ int width = RDINT;
+ int height = RDINT;
+ /*fprintf(stderr, "Output of %dx%d\n", width, height);*/
+ VipsImage *img;
+ vips_black(&img, width, height, "bands", 4, NULL);
+
+ while (input_len - offset > 12) {
+ int x = RDINT;
+ int y = RDINT;
+ int bytes = RDINT;
+ /*fprintf(stderr, "Image at %dx%d of %d bytes\n", x, y, bytes);*/
+ if (input_len - offset < bytes) return 1;
+ VipsImage *sub = vips_image_new_from_buffer(input_buffer+offset, bytes, "", NULL);
+ if (!img) vips_error_exit(NULL);
+ offset += bytes;
+
+ VipsImage *tmp;
+ if (!vips_image_hasalpha(sub)) {
+ if (vips_addalpha(sub, &tmp, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(sub);
+ sub = tmp;
+ }
+
+ if (vips_insert(img, sub, &tmp, x, y, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ VIPS_UNREF(sub);
+ img = tmp;
+ }
+
+ VipsTarget *target = vips_target_new_to_descriptor(1);
+ if (vips_pngsave_target(img, target, "strip", TRUE, NULL))
+ vips_error_exit(NULL);
+ VIPS_UNREF(target);
+ return 0;
+}
+
+
+int main(int argc, char **argv) {
+#ifndef DISABLE_SECCOMP
+ /* don't write to temporary files when working with large images,
+ unless we need more than 1g, then we'll just crash. */
+ putenv("VIPS_DISC_THRESHOLD=1g");
+
+ /* error messages go through gettext(), prevent that from loading translation files */
+ putenv("LANGUAGE=C");
+
+ /* Timezone initialization loads data from disk */
+ putenv("TZ=");
+ tzset();
+
+ /* glibc malloc() trim feature attempts to read from /proc */
+ mallopt(M_TRIM_THRESHOLD, -1);
+#endif
+
+ if (VIPS_INIT(argv[0])) vips_error_exit(NULL);
+ g_log_set_default_handler(log_func, NULL);
+
+#ifndef DISABLE_SECCOMP
+ /* vips error logging attempt to do charset stuff
+ (must be a UTF-8 locale otherwise it tries to load iconv modules, sigh) */
+ setlocale(LC_ALL, "C.utf8");
+ g_get_charset(NULL);
+
+ setup_seccomp();
+#endif
+
+ /* Reading into a buffer allows for more strict seccomp rules than using vips_source_new_from_descriptor() */
+ int r = 0;
+ while ((r = read(0, input_buffer + input_len, MAX_INPUT_SIZE - input_len)) > 0)
+ input_len += r;
+ if (r < 0) {
+ perror("reading input");
+ exit(1);
+ }
+
+ if (argc == 2 && strcmp(argv[1], "composite") == 0) return composite();
+
+ VipsImage *img = vips_image_new_from_buffer(input_buffer, input_len, "", NULL);
+ if (!img) vips_error_exit(NULL);
+
+ /* Remove alpha channel */
+ VipsImage *tmp;
+ if (vips_image_hasalpha(img)) {
+ /* "white" is 256 for 8-bit images and 65536 for 16-bit, the latter works for both.
+ (where is this documented!?) */
+ VipsArrayDouble *white = vips_array_double_newv(1, 65536.0);
+ if (vips_flatten(img, &tmp, "background", white, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ img = tmp;
+ }
+
+ /* This approach to processing CLI arguments is sloppy and unsafe, but the
+ * CLI is considered trusted input. */
+ while (*++argv) {
+ if (strcmp(*argv, "size") == 0)
+ fprintf(stderr, "%dx%d\n", vips_image_get_width(img), vips_image_get_height(img));
+
+ else if (strcmp(*argv, "jpeg") == 0) {
+ int fd = atoi(*++argv);
+
+ /* Always save as sRGB (suboptimal for greyscale images... do we have those?) */
+ if (vips_colourspace(img, &tmp, VIPS_INTERPRETATION_sRGB, NULL))
+ vips_error_exit(NULL);
+
+ /* Ignore DPI values from the original image, enforce a consistent 72 DPI */
+ vips_copy(tmp, &img, "xres", 2.83, "yres", 2.83, NULL);
+ VIPS_UNREF(tmp);
+
+ VipsTarget *target = vips_target_new_to_descriptor(fd);
+ if (vips_jpegsave_target(img, target, "Q", 90, "optimize_coding", TRUE, "strip", TRUE, NULL))
+ vips_error_exit(NULL);
+ VIPS_UNREF(target);
+
+ } else if (strcmp(*argv, "fit") == 0) {
+ int width = atoi(*++argv);
+ int height = atoi(*++argv);
+ if (width >= vips_image_get_width(img) && height >= vips_image_get_height(img))
+ continue;
+
+ /* The "linear" option is supposedly quite slow (haven't benchmarked, seems
+ fast enough) but it offers a very significant quality boost. */
+ if (vips_thumbnail_image(img, &tmp, width, "height", height, "linear", TRUE, NULL))
+ vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ img = tmp;
+
+ /* The lanczos3 kernel used by vips_thumbnail tends to be overly blurry for small images.
+ Ideally we should use a sharper downscaler instead, but I couldn't find any in VIPS,
+ so just use a sharpen post-processing filter for now. */
+ if (width * height < 400*400) {
+ if (vips_sharpen(img, &tmp, "m2", 2.0, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ img = tmp;
+ }
+
+ } else {
+ fprintf(stderr, "Unknown argument: %s\n", *argv);
+ return 1;
+ }
+ }
+
+ return 0;
+}
diff --git a/util/jsgen.pl b/util/jsgen.pl
index ff5868d5..3825e860 100755
--- a/util/jsgen.pl
+++ b/util/jsgen.pl
@@ -1,81 +1,69 @@
#!/usr/bin/perl
-use strict;
-use warnings;
-use Encode 'encode_utf8';
use Cwd 'abs_path';
-use JSON::XS;
-
-my $ROOT;
+our $ROOT;
BEGIN { ($ROOT = abs_path $0) =~ s{/util/jsgen\.pl$}{}; }
use lib "$ROOT/lib";
-use VNDB::Config;
+use TUWF;
+use TUWF::Validate::Interop;
+use JSON::XS;
+use VNWeb::Validation ();
+use VNWeb::TimeZone;
+use VNDB::ExtLinks ();
+use VNDB::Skins;
use VNDB::Types;
+my $js = JSON::XS->new->pretty->canonical;
-# screen resolution information, suitable for usage in filFSelect()
-sub resolutions {
- my $cat = '';
- my @r;
- my $push = \@r;
- for my $i (keys %RESOLUTION) {
- my $r = $RESOLUTION{$i};
- if($cat ne $r->{cat}) {
- push @r, [$r->{cat}];
- $cat = $r->{cat};
- $push = $r[$#r];
- }
- push @$push, [$i, $r->{txt}];
- }
- \@r
+sub validations {
+ print 'window.formVals = '.$js->encode({
+ map +($_, { tuwf->compile({ $_ => 1 })->analyze->html5_validation() }->{pattern}),
+ qw/ email weburl /
+ }).";\n";
}
-
-sub vars {
- my %vars = (
- rlist_status => [ map [ $_, $RLIST_STATUS{$_} ], keys %RLIST_STATUS ],
- cookie_prefix => config->{tuwf}{cookie_prefix},
- age_ratings => [ map [ $_, $AGE_RATING{$_}{txt}], keys %AGE_RATING ],
- languages => [ map [ $_, $LANGUAGE{$_} ], sort { $LANGUAGE{$a} cmp $LANGUAGE{$b} } keys %LANGUAGE ],
- platforms => [ map [ $_, $PLATFORM{$_} ], keys %PLATFORM ],
- char_roles => [ map [ $_, $CHAR_ROLE{$_}{txt} ], keys %CHAR_ROLE ],
- media => [ map [ $_, $MEDIUM{$_}{txt}, $MEDIUM{$_}{qty} ], keys %MEDIUM ],
- release_types => [ map [ $_, $RELEASE_TYPE{$_} ], keys %RELEASE_TYPE ],
- animated => [ map [ $_, $ANIMATED{$_}{txt} ], keys %ANIMATED ],
- voiced => [ map [ $_, $VOICED{$_}{txt} ], keys %VOICED ],
- vn_lengths => [ map [ $_, $VN_LENGTH{$_}{txt} ], keys %VN_LENGTH ],
- blood_types => [ map [ $_, $BLOOD_TYPE{$_} ], keys %BLOOD_TYPE ],
- genders => [ map [ $_, $GENDER{$_} ], keys %GENDER ],
- credit_type => [ map [ $_, $CREDIT_TYPE{$_} ], keys %CREDIT_TYPE ],
- cup_size => [ grep $_, keys %CUP_SIZE ],
- resolutions => scalar resolutions(),
- );
- JSON::XS->new->encode(\%vars);
+sub types {
+ print 'window.vndbTypes = '.$js->encode({
+ language => [ map [$_, $LANGUAGE{$_}{txt}, $LANGUAGE{$_}{latin}?\1:\0, $LANGUAGE{$_}{rank}], keys %LANGUAGE ],
+ platform => [ map [$_, $PLATFORM{$_} ], keys %PLATFORM ],
+ medium => [ map [$_, $MEDIUM{$_}{txt}, $MEDIUM{$_}{qty}?\1:\0 ], keys %MEDIUM ],
+ voiced => [ map [$VOICED{$_}{txt}], keys %VOICED ],
+ ageRating => [ map [1*$_, $AGE_RATING{$_}{txt}.($AGE_RATING{$_}{ex}?" ($AGE_RATING{$_}{ex})":'')], keys %AGE_RATING ],
+ releaseType => [ map [$_, $RELEASE_TYPE{$_}], keys %RELEASE_TYPE ],
+ drmProperty => [ map [$_, $DRM_PROPERTY{$_}], keys %DRM_PROPERTY ],
+ producerType => [ map [$_, $PRODUCER_TYPE{$_}], keys %PRODUCER_TYPE ],
+ producerRelation => [ map [$_, $PRODUCER_RELATION{$_}{txt}], keys %PRODUCER_RELATION ],
+ vnRelation => [ map [$_, $VN_RELATION{$_}{txt}, $VN_RELATION{$_}{reverse}, $VN_RELATION{$_}{pref}], keys %VN_RELATION ],
+ }).";\n";
}
-
-# Reads main.js and any included files.
-sub readjs {
- my $f = shift || 'main.js';
- open my $JS, '<:utf8', "$ROOT/data/js/$f" or die $!;
- local $/ = undef;
- local $_ = <$JS>;
- close $JS;
- s{^//include (.+)$}{'(function(){'.readjs($1).'})();'}meg;
- $_;
+sub zones {
+ print 'window.timeZones = '.$js->encode(\@ZONES).";\n";
}
-
-sub save {
- my($f, $body) = @_;
- open my $F, '>', "$f~" or die $!;
- print $F encode_utf8($body);
- close $F;
- rename "$f~", $f or die $!;
+sub vskins {
+ print 'window.vndbSkins = '.$js->encode([ map [$_, skins->{$_}{name}], sort { skins->{$a}{name} cmp skins->{$b}{name} } keys skins->%*]).";\n";
}
+sub extlinks {
+ sub t {
+ [ map +{
+ id => $_->{id},
+ name => $_->{name},
+ fmt => $_->{fmt},
+ default => $_->{default},
+ int => $_->{int},
+ regex => TUWF::Validate::Interop::_re_compat($_->{regex}),
+ patt => $_->{pattern},
+ }, VNDB::ExtLinks::extlinks_sites($_[0]) ]
+ }
+ print 'window.extLinks = '.$js->encode({
+ release => t('r'),
+ staff => t('s'),
+ }).";\n";
+}
-my $js = readjs;
-$js =~ s{/\*VARS\*/}{vars()}eg;
-save "$ROOT/static/f/vndb.js", $js;
+if ($ARGV[0] eq 'types') { validations; types; }
+if ($ARGV[0] eq 'user') { zones; vskins; }
+if ($ARGV[0] eq 'extlinks') { extlinks; }
diff --git a/util/multi.pl b/util/multi.pl
index 1ad92ef4..6dc3cf5c 100755
--- a/util/multi.pl
+++ b/util/multi.pl
@@ -10,4 +10,6 @@ BEGIN { ($ROOT = abs_path $0) =~ s{/util/multi\.pl$}{} }
use lib $ROOT.'/lib';
use Multi::Core;
-Multi::Core->run();
+my $quiet = grep '-q', @ARGV;
+
+Multi::Core::run $quiet;
diff --git a/util/pngsprite.pl b/util/pngsprite.pl
new file mode 100755
index 00000000..79fc2719
--- /dev/null
+++ b/util/pngsprite.pl
@@ -0,0 +1,122 @@
+#!/usr/bin/perl
+
+use v5.28;
+
+my $GEN = $ENV{VNDB_GEN} // 'gen';
+
+my $icons = "$GEN/static/icons.png";
+my $ticons = "$GEN/static/icons~.png";
+my $css = "$GEN/png.css";
+my $imgproc = "$GEN/imgproc";
+
+my @img = map {
+ local $/ = undef;
+ open my $F, '<', $_ or die $_;
+ my $data = <$F>;
+ # 8 byte PNG header, 4 byte IHDR chunk length, 4 bytes IHDR chunk identifier, 4 bytes width, 4 bytes height
+ my($w,$h) = unpack 'NN', substr $data, 16, 8;
+ {
+ f => /^icons\/(.+)\.png/ && $1,
+ w => $w,
+ h => $h,
+ d => $data,
+ }
+} glob("icons/*.png"), glob("icons/*/*.png");
+
+
+@img = sort { $b->{h} <=> $a->{h} || $b->{w} <=> $a->{w} } @img;
+
+
+# Simple strip packing algortihm, First-Fit Decreasing Height.
+sub genstrip {
+ my $w = shift;
+ my @l;
+ my $h = 0;
+ for my $i (@img) {
+ my $found = 0;
+ # @img is assumed to be sorted by height, so image always fits
+ # (height-wise) in any of the previously created levels.
+ for my $l (@l) {
+ next if $l->{left} + $i->{w} > $w;
+ # Image fits, add to level
+ $i->{x} = $l->{left};
+ $i->{y} = $l->{top};
+ $l->{left} += $i->{w};
+ $found = 1;
+ last;
+ }
+ next if $found;
+
+ # No level found, create a new one
+ push @l, { top => $h, left => $i->{w} };
+ $i->{x} = 0;
+ $i->{y} = $h;
+ $h += $i->{h};
+ }
+
+ # Recalculate the (actually used) width
+ $w = 0;
+ $w < $_->{x}+$_->{w} && ($w = $_->{x}+$_->{w}) for (@img);
+ ($w, $h);
+}
+
+
+# Tries to find the width of the strip for which the number of unused pixels is
+# the minimum. Simple and dumb linear search; it's fast enough.
+#
+# Note that minimum number of unused pixels does not imply minimum file size,
+# although there is some correlation. To further minimize the file size, it's
+# possible to attempt to group similar-looking images close together so that
+# the final png image might compress better. Finding a good (and fast)
+# algorithm for this is not a trivial task, however.
+sub minstrip {
+ my($minwidth, $maxwidth) = (0,0);
+ for(@img) {
+ $minwidth = $_->{w} if $_->{w} > $minwidth;
+ $maxwidth += $_->{w};
+ }
+
+ my($optsize, $w, $h, $optw, $opth) = (1e9, $maxwidth);
+ while($w >= $minwidth) {
+ ($w, $h) = genstrip($w);
+ my $size = $w*$h;
+ if($size < $optsize) {
+ $optw = $w;
+ $opth = $h;
+ $optsize = $size;
+ }
+ $w--;
+ }
+ genstrip($optw);
+}
+
+
+sub img {
+ my($w, $h) = @_;
+ open my $CMD, "|$imgproc composite >$ticons" or die $!;
+ print $CMD pack 'll', $w, $h;
+ print $CMD pack('lll', $_->{x}, $_->{y}, length $_->{d}).$_->{d} for @img;
+}
+
+
+sub css {
+ # The gender icons need special treatment, they're 3 icons in one image.
+ my $gender;
+
+ open my $F, '>', $css or die $!;
+ for my $i (@img) {
+ if($i->{f} eq 'gender') {
+ $gender = $i;
+ next;
+ }
+ printf $F ".icon-%s { background-position: %dpx %dpx; width: %dpx; height: %dpx }\n", $i->{f} =~ s#/#-#rg, -$i->{x}, -$i->{y}, $i->{w}, $i->{h};
+ }
+ printf $F ".icon-gen-f, .icon-gen-b { background-position: %dpx %dpx; width: 14px; height: 14px }\n", -$gender->{x}, -$gender->{y};
+ print $F ".icon-gen-b { width: 28px }\n";
+ printf $F ".icon-gen-m { background-position: %dpx %dpx; width: 14px; height: 14px }\n", -($gender->{x}+14), -$gender->{y};
+}
+
+
+img minstrip;
+css;
+rename $ticons, $icons or die $!;
diff --git a/util/revision-integrity.pl b/util/revision-integrity.pl
new file mode 100755
index 00000000..4bed133d
--- /dev/null
+++ b/util/revision-integrity.pl
@@ -0,0 +1,39 @@
+#!/usr/bin/perl
+
+# This script is used to verify the consistency of the item tables (e.g. 'vn')
+# with the latest revision in the corresponding history tables (e.g. 'vn_hist').
+#
+# The edit_* functions generated by sqleditfunc.pl should ensure this
+# consistency, but bugs can happen and migration scripts sometimes bypass these
+# functions.
+#
+# Outputs SQL statements that can be piped to 'psql'. The generated SELECT
+# statements should not return any rows.
+
+use v5.24;
+use warnings;
+use Cwd 'abs_path';
+
+my $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/revision-integrity\.pl$}{}; }
+use lib "$ROOT/lib";
+use VNDB::Schema;
+
+my $schema = VNDB::Schema::schema;
+
+for my $table (sort { $a->{name} cmp $b->{name} } values %$schema) {
+ next if $table->{name} !~ /^(.+)_hist$/ || $table->{name} eq 'users_username_hist';
+ my($main, $type) = ($1, $1);
+ $type =~ s/_[^_]+$// while !$schema->{$type}{dbentry_type};
+
+ my ($mainlock, $histlock) = $main eq $type ? ('locked, hidden, ', 'c.ilock, c.ihid, ') : ('','');
+
+ my $cols = join ', ', map "e.\"$_->{name}\"", grep $_->{name} ne 'chid', $table->{cols}->@*;
+ print "SELECT '$main' as table, id, $mainlock $cols
+ FROM $main e
+ EXCEPT
+ SELECT '$main', c.itemid, $histlock $cols
+ FROM $table->{name} e
+ JOIN changes c ON e.chid = c.id
+ WHERE NOT EXISTS(SELECT 1 FROM changes c2 WHERE c2.itemid = c.itemid AND c2.rev > c.rev);\n\n"
+}
diff --git a/util/setup-var.sh b/util/setup-var.sh
new file mode 100755
index 00000000..f153237e
--- /dev/null
+++ b/util/setup-var.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+
+[ -z "$VNDB_GEN" ] && VNDB_GEN=gen
+[ -z "$VNDB_VAR" ] && VNDB_VAR=var
+
+mkdir -p "$VNDB_VAR/static"
+
+[ -e "$VNDB_VAR/conf.pl" ] || cp conf_example.pl "$VNDB_VAR/conf.pl"
+
+# Symlink for compatibility with old URLs
+ln -sfT "$(realpath $VNDB_GEN/static)" "$VNDB_VAR/static/g"
+
+cd "$VNDB_VAR"
+mkdir -p tmp log
+
+for d in ch ch.orig cv cv.orig sf sf.orig sf.t; do
+ for i in `seq -w 0 1 99`; do
+ mkdir -p static/$d/$i
+ done
+done
+ln -sfT sf.t static/st
diff --git a/util/skingen.pl b/util/skingen.pl
deleted file mode 100755
index 8d9f3b4e..00000000
--- a/util/skingen.pl
+++ /dev/null
@@ -1,100 +0,0 @@
-#!/usr/bin/perl
-
-use v5.12;
-use warnings;
-use Cwd 'abs_path';
-
-our($ROOT, %S);
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/skingen\.pl$}{}; }
-
-use lib "$ROOT/lib";
-use SkinFile;
-
-
-my $iconcss = do {
- open my $F, '<', "$ROOT/data/icons/icons.css" or die $!;
- local $/=undef;
- <$F>;
-};
-
-
-sub imgsize {
- open my $IMG, '<', $_[0] or die $!;
- sysread $IMG, my $buf, 1024 or die $!;
- $buf =~ /\xFF\xC0...(....)/s ? unpack('nn', $1) : $buf =~ /IHDR(.{8})/s ? unpack('NN', $1) : die;
-}
-
-
-sub rdcolor {
- length $_[0] == 4 ? map hex($_)/15, $_[0] =~ /#(.)(.)(.)/ : #RGB
- length $_[0] == 7 ? map hex($_)/255, $_[0] =~ /#(..)(..)(..)/ : #RRGGBB
- length $_[0] == 9 ? map hex($_)/255, $_[0] =~ /#(..)(..)(..)(..)/ : #RRGGBBAA
- die;
-}
-
-
-sub blend {
- my($f, $b) = @_;
- my @f = rdcolor $f;
- my @b = rdcolor $b;
- $f[3] //= 1;
- sprintf '#%02x%02x%02x',
- ($f[0] * $f[3] + $b[0] * (1 - $f[3]))*255,
- ($f[1] * $f[3] + $b[1] * (1 - $f[3]))*255,
- ($f[2] * $f[3] + $b[2] * (1 - $f[3]))*255;
-}
-
-sub mtime($) { [stat("$ROOT/static$_[0]")]->[9] }
-
-
-sub writeskin { # $name
- my $name = shift;
- my $skin = SkinFile->new("$ROOT/static/s", $name);
- my %o = map +($_ => $skin->get($_)), $skin->get;
- $o{iconcss} = $iconcss;
-
- # get the right top image
- if($o{imgrighttop}) {
- my $path = "/s/$name/$o{imgrighttop}";
- my($h, $w) = imgsize "$ROOT/static$path";
- $o{_bgright} = sprintf 'background: url(%s?%s) no-repeat; width: %dpx; height: %dpx', $path, mtime $path, $w, $h;
- } else {
- $o{_bgright} = 'display: none';
- }
-
- # body background
- if($o{imglefttop}) {
- my $path = "/s/$name/$o{imglefttop}";
- $o{_bodybg} = sprintf 'background: %s url(%s?%s) no-repeat', $o{bodybg}, $path, mtime $path;
- } else {
- $o{_bodybg} = sprintf 'background-color: %s', $o{bodybg};
- }
-
- # boxbg blended with bodybg
- $o{_blendbg} = blend $o{boxbg}, $o{bodybg};
-
- # version
- $o{icons_version} = mtime '/f/icons.png';
-
- # write the CSS
- open my $CSS, '<', "$ROOT/data/style.css" or die $!;
- local $/=undef;
- my $css = <$CSS>;
- close $CSS;
-
- my $f = "$ROOT/static/s/$name/style.css";
- open my $SKIN, '>', "$f~" or die $!;
- print $SKIN $css =~ s{\$([a-z_]+)\$}{$o{$1} // die "Unknown variable $1"}egr;
- close $SKIN;
-
- rename "$f~", $f;
-}
-
-
-if(@ARGV) {
- writeskin($_) for (@ARGV);
-} else {
- writeskin($_) for (SkinFile->new("$ROOT/static/s")->list);
-}
-
-
diff --git a/util/spritegen.pl b/util/spritegen.pl
deleted file mode 100755
index 5b2b5982..00000000
--- a/util/spritegen.pl
+++ /dev/null
@@ -1,136 +0,0 @@
-#!/usr/bin/perl
-
-use strict;
-use warnings;
-use Image::Magick;
-use Cwd 'abs_path';
-
-our $ROOT;
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/spritegen\.pl$}{}; }
-
-my $path = "$ROOT/data/icons";
-my $icons = "$ROOT/static/f/icons.png";
-my $ticons = "$ROOT/static/f/icons~.png";
-my $css = "$ROOT/data/icons/icons.css";
-
-my @img = map {
- my $i = Image::Magick->new();
- $i->Read($_) and die $_;
- {
- f => /^\Q$path\E\/(.+)\.png/ && $1,
- i => $i,
- h => scalar $i->Get('height'),
- w => scalar $i->Get('width')
- }
-} glob("$path/*.png"), glob("$path/*/*.png");
-
-
-@img = sort { $b->{h} <=> $a->{h} || $b->{w} <=> $a->{w} } @img;
-
-my $minpixels = 0;
-$minpixels += $_->{w}*$_->{h} for @img;
-
-
-# Simple strip packing algortihm, First-Fit Decreasing Height.
-sub genstrip {
- my $w = shift;
- my @l;
- my $h = 0;
- for my $i (@img) {
- my $found = 0;
- # @img is assumed to be sorted by height, so image always fits
- # (height-wise) in any of the previously created levels.
- for my $l (@l) {
- next if $l->{left} + $i->{w} > $w;
- # Image fits, add to level
- $i->{x} = $l->{left};
- $i->{y} = $l->{top};
- $l->{left} += $i->{w};
- $found = 1;
- last;
- }
- next if $found;
-
- # No level found, create a new one
- push @l, { top => $h, left => $i->{w} };
- $i->{x} = 0;
- $i->{y} = $h;
- $h += $i->{h};
- }
-
- # Recalculate the (actually used) width
- $w = 0;
- $w < $_->{x}+$_->{w} && ($w = $_->{x}+$_->{w}) for (@img);
- ($w, $h);
-}
-
-
-# Tries to find the width of the strip for which the number of unused pixels is
-# the minimum. Simple and dumb linear search; it's fast enough.
-#
-# Note that minimum number of unused pixels does not imply minimum file size,
-# although there is some correlation. To further minimize the file size, it's
-# possible to attempt to group similar-looking images close together so that
-# the final png image might compress better. Finding a good (and fast)
-# algorithm for this is not a trivial task, however.
-sub minstrip {
- my($minwidth, $maxwidth) = (0,0);
- for(@img) {
- $minwidth = $_->{w} if $_->{w} > $minwidth;
- $maxwidth += $_->{w};
- }
-
- my($optsize, $w, $h, $optw, $opth) = (1e9, $maxwidth);
- while($w >= $minwidth) {
- ($w, $h) = genstrip($w);
- my $size = $w*$h;
- if($size < $optsize) {
- $optw = $w;
- $opth = $h;
- $optsize = $size;
- }
- $w--;
- }
- genstrip($optw);
-}
-
-
-sub img {
- my($w, $h) = @_;
- my $img = Image::Magick->new;
- print $img->Set(size => "${w}x$h");
- print $img->ReadImage('canvas:rgba(0,0,0,0)');
- my $pixels = $w*$h;
- for my $i (@img) {
- print $img->Composite(image => $i->{i}, x => $i->{x}, y => $i->{y});
- }
- print $img->Write("png32:$ticons");
- undef $img;
-
- my $size = -s $ticons;
- #printf "Dim: %dx%d, size: %d, pixels wasted: %d\n", $w, $h, $size, $w*$h-$minpixels;
- $size;
-}
-
-
-sub css {
- # The gender icons need special treatment, they're 3 icons in one image.
- my $gender;
-
- open my $F, '>', $css or die $!;
- for my $i (@img) {
- if($i->{f} eq 'gender') {
- $gender = $i;
- next;
- }
- $i->{f} =~ /([^\/]+)$/;
- printf $F ".icons.%s { background-position: %dpx %dpx }\n", $1, -$i->{x}, -$i->{y};
- }
- printf $F ".icons.gen.f, .icons.gen.b { background-position: %dpx %dpx }\n", -$gender->{x}, -$gender->{y};
- printf $F ".icons.gen.m { background-position: %dpx %dpx }\n", -($gender->{x}+14), -$gender->{y};
-}
-
-
-img minstrip;
-css;
-rename $ticons, $icons or die $!;
diff --git a/util/sql/all.sql b/util/sql/all.sql
deleted file mode 100644
index 1e01dd3f..00000000
--- a/util/sql/all.sql
+++ /dev/null
@@ -1,11 +0,0 @@
--- NOTE: Make sure you're cd'ed in the vndb root directory before running this script
-
-\set ON_ERROR_STOP 1
-\i util/sql/schema.sql
-\i util/sql/data.sql
-\i util/sql/func.sql
-\i util/sql/editfunc.sql
-\i util/sql/tableattrs.sql
-\i util/sql/triggers.sql
-\set ON_ERROR_STOP 0
-\i util/sql/perms.sql
diff --git a/util/sql/data.sql b/util/sql/data.sql
deleted file mode 100644
index 3283c035..00000000
--- a/util/sql/data.sql
+++ /dev/null
@@ -1,15 +0,0 @@
-INSERT INTO users (id, username, mail, perm, notify_dbedit) VALUES (0, 'deleted', 'del@vndb.org', 0, FALSE);
-INSERT INTO users (id, username, mail, perm, notify_dbedit) VALUES (1, 'multi', 'multi@vndb.org', 0, FALSE);
-SELECT setval('users_id_seq', 2);
-
-INSERT INTO stats_cache (section, count) VALUES
- ('users', 1),
- ('vn', 0),
- ('producers', 0),
- ('releases', 0),
- ('chars', 0),
- ('staff', 0),
- ('tags', 0),
- ('traits', 0),
- ('threads', 0),
- ('threads_posts', 0);
diff --git a/util/sql/func.sql b/util/sql/func.sql
deleted file mode 100644
index af510e51..00000000
--- a/util/sql/func.sql
+++ /dev/null
@@ -1,676 +0,0 @@
--- A small note on the function naming scheme:
--- edit_* -> revision insertion abstraction functions
--- *_notify -> functions issuing a PgSQL NOTIFY statement
--- notify_* -> functions creating entries in the notifications table
--- user_* -> functions to manage users and sessions
--- update_* -> functions to update a cache
--- *_update ^ (I should probably rename these to
--- *_calc ^ the update_* scheme for consistency)
--- I like to keep the nouns in functions singular, in contrast to the table
--- naming scheme where nouns are always plural. But I'm not very consistent
--- with that, either.
-
-
--- strip_bb_tags(text) - simple utility function to aid full-text searching
-CREATE OR REPLACE FUNCTION strip_bb_tags(t text) RETURNS text AS $$
- SELECT regexp_replace(t, '\[(?:url=[^\]]+|/?(?:spoiler|quote|raw|code|url))\]', ' ', 'gi');
-$$ LANGUAGE sql IMMUTABLE;
-
--- Wrapper around to_tsvector() and strip_bb_tags(), implemented in plpgsql and
--- with an associated cost function to make it opaque to the query planner and
--- ensure the query planner realizes that this function is _slow_.
-CREATE OR REPLACE FUNCTION bb_tsvector(t text) RETURNS tsvector AS $$
-BEGIN
- RETURN to_tsvector('english', public.strip_bb_tags(t));
-END;
-$$ LANGUAGE plpgsql IMMUTABLE COST 500;
-
--- BUG: Since this isn't a full bbcode parser, [spoiler] tags inside [raw] or [code] are still considered spoilers.
-CREATE OR REPLACE FUNCTION strip_spoilers(t text) RETURNS text AS $$
- -- The website doesn't require the [spoiler] tag to be closed, the outer replace catches that case.
- SELECT regexp_replace(regexp_replace(t, '\[spoiler\].*?\[/spoiler\]', ' ', 'ig'), '\[spoiler\].*', ' ', 'i');
-$$ LANGUAGE sql IMMUTABLE;
-
-
--- Assigns a score to the relevance of a substring match, intended for use in
--- an ORDER BY clause. Exact matches are ordered first, prefix matches after
--- that, and finally a normal substring match. Not particularly fast, but
--- that's to be expected of naive substring searches.
--- Pattern must be escaped for use as a LIKE pattern.
-CREATE OR REPLACE FUNCTION substr_score(str text, pattern text) RETURNS integer AS $$
-SELECT CASE
- WHEN str ILIKE pattern THEN 0
- WHEN str ILIKE pattern||'%' THEN 1
- WHEN str ILIKE '%'||pattern||'%' THEN 2
- ELSE 3
-END;
-$$ LANGUAGE SQL;
-
-
--- update_vncache(id) - updates some c_* columns in the vn table
-CREATE OR REPLACE FUNCTION update_vncache(integer) RETURNS void AS $$
- UPDATE vn SET
- c_released = COALESCE((
- SELECT MIN(r.released)
- FROM releases r
- JOIN releases_vn rv ON r.id = rv.id
- WHERE rv.vid = $1
- AND r.type <> 'trial'
- AND r.hidden = FALSE
- AND r.released <> 0
- 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
- )
- ),
- c_languages = ARRAY(
- 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 r.type <> 'trial'
- AND r.released <= TO_CHAR('today'::timestamp, 'YYYYMMDD')::integer
- AND r.hidden = FALSE
- GROUP BY rl.lang
- ORDER BY rl.lang
- ),
- c_platforms = ARRAY(
- SELECT rp.platform
- FROM releases_platforms rp
- JOIN releases r ON rp.id = r.id
- JOIN releases_vn rv ON rp.id = rv.id
- WHERE rv.vid = $1
- AND r.type <> 'trial'
- AND r.released <= TO_CHAR('today'::timestamp, 'YYYYMMDD')::integer
- AND r.hidden = FALSE
- GROUP BY rp.platform
- ORDER BY rp.platform
- )
- WHERE id = $1;
-$$ LANGUAGE sql;
-
-
--- Update vn.c_popularity, c_rating and c_votecount
-CREATE OR REPLACE FUNCTION update_vnvotestats() RETURNS void AS $$
- WITH votes(vid, uid, vote) AS ( -- List of all non-ignored VN votes
- SELECT vid, uid, vote FROM ulist_vns WHERE vote IS NOT NULL AND uid NOT IN(SELECT id FROM users WHERE ign_votes)
- ), avgcount(avgcount) AS ( -- Average number of votes per VN
- SELECT COUNT(vote)::real/COUNT(DISTINCT vid)::real FROM votes
- ), avgavg(avgavg) AS ( -- Average vote average
- SELECT AVG(a)::real FROM (SELECT AVG(vote) FROM votes GROUP BY vid) x(a)
- ), ratings(vid, count, rating) AS ( -- Ratings and vote counts
- SELECT vid, COALESCE(COUNT(uid), 0),
- COALESCE(
- ((SELECT avgcount FROM avgcount) * (SELECT avgavg FROM avgavg) + SUM(vote)::real) /
- ((SELECT avgcount FROM avgcount) + COUNT(uid)::real),
- 0)
- FROM votes
- GROUP BY vid
- ), popularities(vid, win) AS ( -- Popularity scores (before normalization)
- SELECT vid, SUM(rank)
- FROM (
- SELECT uid, vid, ((rank() OVER (PARTITION BY uid ORDER BY vote))::real - 1) ^ 0.36788 FROM votes
- ) x(uid, vid, rank)
- GROUP BY vid
- ), stats(vid, rating, count, popularity) AS ( -- Combined stats
- SELECT v.id, COALESCE(r.rating, 0), COALESCE(r.count, 0)
- , p.win/(SELECT MAX(win) FROM popularities)
- FROM vn v
- LEFT JOIN ratings r ON r.vid = v.id
- LEFT JOIN popularities p ON p.vid = v.id AND p.win > 0
- )
- UPDATE vn SET c_rating = rating, c_votecount = count, c_popularity = popularity FROM stats WHERE id = vid;
-$$ LANGUAGE SQL;
-
-
-
--- Update users.c_vns, c_votes and c_wish for one user (when given an id) or all users (when given NULL)
-CREATE OR REPLACE FUNCTION update_users_ulist_stats(integer) RETURNS void AS $$
-BEGIN
- WITH cnt(uid, votes, vns, wish) AS (
- SELECT u.id
- , COUNT(DISTINCT uvl.vid) FILTER (WHERE NOT ul.private AND uv.vote IS NOT NULL) -- Voted
- , COUNT(DISTINCT uvl.vid) FILTER (WHERE NOT ul.private AND ul.id NOT IN(5,6)) -- Labelled, but not wishlish/blacklist
- , COUNT(DISTINCT uvl.vid) FILTER (WHERE NOT ul.private AND ul.id = 5) -- Wishlist
- FROM users u
- LEFT JOIN ulist_vns_labels uvl ON uvl.uid = u.id
- LEFT JOIN ulist_labels ul ON ul.id = uvl.lbl AND ul.uid = u.id
- LEFT JOIN ulist_vns uv ON uv.uid = u.id AND uv.vid = uvl.vid
- WHERE $1 IS NULL OR u.id = $1
- GROUP BY u.id
- ) UPDATE users SET c_votes = votes, c_vns = vns, c_wish = wish FROM cnt WHERE id = uid;
-END;
-$$ LANGUAGE plpgsql; -- Don't use "LANGUAGE SQL" here; Make sure to generate a new query plan at invocation time.
-
-
-
--- Recalculate tags_vn_inherit.
--- When a vid is given, only the tags for that vid will be updated. These
--- incremental updates do not affect tags.c_items, so that may still get
--- out-of-sync.
-CREATE OR REPLACE FUNCTION tag_vn_calc(uvid integer) RETURNS void AS $$
-BEGIN
- IF uvid IS NULL THEN
- DROP INDEX IF EXISTS tags_vn_inherit_tag_vid;
- TRUNCATE tags_vn_inherit;
- ELSE
- DELETE FROM tags_vn_inherit WHERE vid = uvid;
- END IF;
-
- INSERT INTO tags_vn_inherit (tag, vid, rating, spoiler)
- -- Group votes to generate a list of directly-upvoted (vid, tag) pairs.
- -- This is essentually the same as the tag listing on VN pages.
- WITH RECURSIVE t_avg(tag, vid, vote, spoiler) AS (
- SELECT tv.tag, tv.vid, AVG(tv.vote)::real, CASE WHEN COUNT(tv.spoiler) = 0 THEN MIN(t.defaultspoil) ELSE AVG(tv.spoiler)::real END
- FROM tags_vn tv
- JOIN tags t ON t.id = tv.tag
- WHERE NOT tv.ignore AND t.state = 2
- AND vid NOT IN(SELECT id FROM vn WHERE hidden)
- AND (uvid IS NULL OR vid = uvid)
- GROUP BY tv.tag, tv.vid
- HAVING AVG(tv.vote) > 0
- -- Add parent tags
- ), t_all(lvl, tag, vid, vote, spoiler) AS (
- SELECT 15, * FROM t_avg
- UNION ALL
- SELECT ta.lvl-1, tp.parent, ta.vid, ta.vote, ta.spoiler
- FROM t_all ta
- JOIN tags_parents tp ON tp.tag = ta.tag
- WHERE ta.lvl > 0
- )
- -- Merge
- SELECT tag, vid, AVG(vote)
- , (CASE WHEN MIN(spoiler) > 1.3 THEN 2 WHEN MIN(spoiler) > 0.4 THEN 1 ELSE 0 END)::smallint
- FROM t_all
- WHERE tag IN(SELECT id FROM tags WHERE searchable)
- GROUP BY tag, vid;
-
- IF uvid IS NULL THEN
- CREATE INDEX tags_vn_inherit_tag_vid ON tags_vn_inherit (tag, vid);
- UPDATE tags SET c_items = (SELECT COUNT(*) FROM tags_vn_inherit WHERE tag = id);
- END IF;
-
- RETURN;
-END;
-$$ LANGUAGE plpgsql SECURITY DEFINER;
-
-
--- Recalculate traits_chars. Pretty much same thing as tag_vn_calc().
-CREATE OR REPLACE FUNCTION traits_chars_calc(ucid integer) RETURNS void AS $$
-BEGIN
- IF ucid IS NULL THEN
- DROP INDEX IF EXISTS traits_chars_tid;
- TRUNCATE traits_chars;
- ELSE
- DELETE FROM traits_chars WHERE cid = ucid;
- END IF;
-
- INSERT INTO traits_chars (tid, cid, spoil)
- -- all char<->trait links of the latest revisions, including chars inherited from child traits.
- -- (also includes non-searchable traits, because they could have a searchable trait as parent)
- WITH RECURSIVE traits_chars_all(lvl, tid, cid, spoiler) AS (
- SELECT 15, tid, ct.id, spoil
- FROM chars_traits ct
- WHERE id NOT IN(SELECT id from chars WHERE hidden)
- AND (ucid IS NULL OR ct.id = ucid)
- UNION ALL
- SELECT lvl-1, tp.parent, tc.cid, tc.spoiler
- FROM traits_chars_all tc
- JOIN traits_parents tp ON tp.trait = tc.tid
- JOIN traits t ON t.id = tp.parent
- WHERE t.state = 2
- AND tc.lvl > 0
- )
- -- now grouped by (tid, cid), with non-searchable traits filtered out
- SELECT tid, cid
- , (CASE WHEN MIN(spoiler) > 1.3 THEN 2 WHEN MIN(spoiler) > 0.7 THEN 1 ELSE 0 END)::smallint AS spoiler
- FROM traits_chars_all
- WHERE tid IN(SELECT id FROM traits WHERE searchable)
- GROUP BY tid, cid;
-
- IF ucid IS NULL THEN
- CREATE INDEX traits_chars_tid ON traits_chars (tid);
- UPDATE traits SET c_items = (SELECT COUNT(*) FROM traits_chars WHERE tid = id);
- END IF;
- RETURN;
-END;
-$$ LANGUAGE plpgsql SECURITY DEFINER;
-
-
--- Fully recalculate all rows in stats_cache
-CREATE OR REPLACE FUNCTION update_stats_cache_full() RETURNS void AS $$
-BEGIN
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM users)-1 WHERE section = 'users';
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM vn WHERE hidden = FALSE) WHERE section = 'vn';
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM releases WHERE hidden = FALSE) WHERE section = 'releases';
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM producers WHERE hidden = FALSE) WHERE section = 'producers';
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM chars WHERE hidden = FALSE) WHERE section = 'chars';
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM staff WHERE hidden = FALSE) WHERE section = 'staff';
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM tags WHERE state = 2) WHERE section = 'tags';
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM traits WHERE state = 2) WHERE section = 'traits';
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM threads WHERE hidden = FALSE) WHERE section = 'threads';
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM threads_posts WHERE hidden = FALSE
- AND EXISTS(SELECT 1 FROM threads WHERE threads.id = tid AND threads.hidden = FALSE)) WHERE section = 'threads_posts';
-END;
-$$ LANGUAGE plpgsql;
-
-
--- Create ulist labels for new users.
-CREATE OR REPLACE FUNCTION ulist_labels_create(integer) RETURNS void AS $$
- INSERT INTO ulist_labels (uid, id, label, private)
- VALUES ($1, 1, 'Playing', false),
- ($1, 2, 'Finished', false),
- ($1, 3, 'Stalled', false),
- ($1, 4, 'Dropped', false),
- ($1, 5, 'Wishlist', false),
- ($1, 6, 'Blacklist', false),
- ($1, 7, 'Voted', false)
- ON CONFLICT (uid, id) DO NOTHING;
-$$ LANGUAGE SQL;
-
-
-
-
-----------------------------------------------------------
--- revision insertion abstraction --
-----------------------------------------------------------
-
--- The two functions below are utility functions used by the item-specific functions in editfunc.sql
-
--- create temporary table for generic revision info, and returns the chid of the revision being edited (or NULL).
-CREATE OR REPLACE FUNCTION edit_revtable(xtype dbentry_type, xitemid integer, xrev integer) RETURNS integer AS $$
-DECLARE
- ret integer;
- x record;
-BEGIN
- BEGIN
- CREATE TEMPORARY TABLE edit_revision (
- type dbentry_type NOT NULL,
- itemid integer,
- requester integer,
- ip inet,
- comments text,
- ihid boolean,
- ilock boolean
- );
- EXCEPTION WHEN duplicate_table THEN
- TRUNCATE edit_revision;
- END;
- SELECT INTO x id, ihid, ilock FROM changes c WHERE type = xtype AND itemid = xitemid AND rev = xrev;
- INSERT INTO edit_revision (type, itemid, ihid, ilock) VALUES (xtype, xitemid, COALESCE(x.ihid, FALSE), COALESCE(x.ilock, FALSE));
- RETURN x.id;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
-CREATE OR REPLACE FUNCTION edit_commit() RETURNS edit_rettype AS $$
-DECLARE
- ret edit_rettype;
- xtype dbentry_type;
-BEGIN
- SELECT type INTO xtype FROM edit_revision;
- SELECT itemid INTO ret.itemid FROM edit_revision;
- -- figure out revision number
- SELECT MAX(rev)+1 INTO ret.rev FROM changes WHERE type = xtype AND itemid = ret.itemid;
- SELECT COALESCE(ret.rev, 1) INTO ret.rev;
- -- insert DB item
- IF ret.itemid IS NULL THEN
- CASE xtype
- WHEN 'v' THEN INSERT INTO vn DEFAULT VALUES RETURNING id INTO ret.itemid;
- WHEN 'r' THEN INSERT INTO releases DEFAULT VALUES RETURNING id INTO ret.itemid;
- WHEN 'p' THEN INSERT INTO producers DEFAULT VALUES RETURNING id INTO ret.itemid;
- WHEN 'c' THEN INSERT INTO chars DEFAULT VALUES RETURNING id INTO ret.itemid;
- WHEN 's' THEN INSERT INTO staff DEFAULT VALUES RETURNING id INTO ret.itemid;
- WHEN 'd' THEN INSERT INTO docs DEFAULT VALUES RETURNING id INTO ret.itemid;
- END CASE;
- END IF;
- -- insert change
- INSERT INTO changes (type, itemid, rev, requester, ip, comments, ihid, ilock)
- SELECT type, ret.itemid, ret.rev, requester, ip, comments, ihid, ilock FROM edit_revision RETURNING id INTO ret.chid;
- RETURN ret;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
--- Check for stuff to be done when an item has been changed
-CREATE OR REPLACE FUNCTION edit_committed(xtype dbentry_type, xedit edit_rettype) RETURNS void AS $$
-DECLARE
- xoldchid integer;
-BEGIN
- SELECT id INTO xoldchid FROM changes WHERE type = xtype AND itemid = xedit.itemid AND rev = xedit.rev-1;
-
- -- Set producers.rgraph to NULL and notify when:
- -- 1. There's a new producer entry with some relations
- -- 2. The producer name/type/language has changed
- -- 3. The producer relations have been changed
- IF xtype = 'p' THEN
- IF -- 1.
- (xoldchid IS NULL AND EXISTS(SELECT 1 FROM producers_relations_hist WHERE chid = xedit.chid))
- OR (xoldchid IS NOT NULL AND (
- -- 2.
- EXISTS(SELECT 1 FROM producers_hist p1, producers_hist p2 WHERE (p2.name <> p1.name OR p2.type <> p1.type OR p2.lang <> p1.lang) AND p1.chid = xoldchid AND p2.chid = xedit.chid)
- -- 3.
- OR EXISTS(SELECT pid, relation FROM producers_relations_hist WHERE chid = xoldchid EXCEPT SELECT pid, relation FROM producers_relations_hist WHERE chid = xedit.chid)
- OR EXISTS(SELECT pid, relation FROM producers_relations_hist WHERE chid = xedit.chid EXCEPT SELECT pid, relation FROM producers_relations_hist WHERE chid = xoldchid)
- ))
- THEN
- UPDATE producers SET rgraph = NULL WHERE id = xedit.itemid;
- NOTIFY relgraph; -- This notify is not done by the producer_relgraph_notify trigger for new entries or if rgraph was already NULL
- END IF;
- END IF;
-
- -- Set vn.rgraph to NULL and notify when:
- -- 1. There's a new vn entry with some relations
- -- 2. The vn title has changed
- -- 3. The vn relations have been changed
- IF xtype = 'v' THEN
- IF -- 1.
- (xoldchid IS NULL AND EXISTS(SELECT 1 FROM vn_relations_hist WHERE chid = xedit.chid))
- OR (xoldchid IS NOT NULL AND (
- -- 2.
- EXISTS(SELECT 1 FROM vn_hist v1, vn_hist v2 WHERE v2.title <> v1.title AND v1.chid = xoldchid AND v2.chid = xedit.chid)
- -- 3.
- OR EXISTS(SELECT vid, relation, official FROM vn_relations_hist WHERE chid = xoldchid EXCEPT SELECT vid, relation, official FROM vn_relations_hist WHERE chid = xedit.chid)
- OR EXISTS(SELECT vid, relation, official FROM vn_relations_hist WHERE chid = xedit.chid EXCEPT SELECT vid, relation, official FROM vn_relations_hist WHERE chid = xoldchid)
- ))
- THEN
- UPDATE vn SET rgraph = NULL WHERE id = xedit.itemid;
- NOTIFY relgraph;
- END IF;
- END IF;
-
- -- Set c_search to NULL and notify when
- -- 1. A new VN entry is created
- -- 2. The vn title/original/alias has changed
- IF xtype = 'v' THEN
- IF -- 1.
- xoldchid IS NULL OR
- -- 2.
- EXISTS(SELECT 1 FROM vn_hist v1, vn_hist v2 WHERE (v2.title <> v1.title OR v2.original <> v1.original OR v2.alias <> v1.alias) AND v1.chid = xoldchid AND v2.chid = xedit.chid)
- THEN
- UPDATE vn SET c_search = NULL WHERE id = xedit.itemid;
- NOTIFY vnsearch;
- END IF;
- END IF;
-
- -- Set related vn.c_search columns to NULL and notify when
- -- 1. A new release is created
- -- 2. A release has been hidden or unhidden
- -- 3. The release title/original has changed
- -- 4. The releases_vn table differs from a previous revision
- IF xtype = 'r' THEN
- IF -- 1.
- xoldchid IS NULL OR
- -- 2.
- EXISTS(SELECT 1 FROM changes c1, changes c2 WHERE c1.ihid IS DISTINCT FROM c2.ihid AND c1.id = xedit.chid AND c2.id = xoldchid) OR
- -- 3.
- EXISTS(SELECT 1 FROM releases_hist r1, releases_hist r2 WHERE (r2.title <> r1.title OR r2.original <> r1.original) AND r1.chid = xoldchid AND r2.chid = xedit.chid) OR
- -- 4.
- EXISTS(SELECT vid FROM releases_vn_hist WHERE chid = xoldchid EXCEPT SELECT vid FROM releases_vn_hist WHERE chid = xedit.chid) OR
- EXISTS(SELECT vid FROM releases_vn_hist WHERE chid = xedit.chid EXCEPT SELECT vid FROM releases_vn_hist WHERE chid = xoldchid)
- THEN
- UPDATE vn SET c_search = NULL WHERE id IN(SELECT vid FROM releases_vn_hist WHERE chid IN(xedit.chid, xoldchid));
- NOTIFY vnsearch;
- END IF;
- END IF;
-
- -- Call update_vncache() for related VNs when a release has been created or edited
- -- (This could be made more specific, but update_vncache() is fast enough that it's not worth the complexity)
- IF xtype = 'r' THEN
- PERFORM update_vncache(vid) FROM (
- SELECT DISTINCT vid FROM releases_vn_hist WHERE chid IN(xedit.chid, xoldchid)
- ) AS v(vid);
- END IF;
-
- -- Call traits_chars_calc() for characters to update the traits cache
- IF xtype = 'c' THEN
- 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;
-
- -- 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
- -- update_vnlist_rlist().
- IF xtype = 'r' AND xoldchid IS NOT NULL
- THEN
- INSERT INTO ulist_vns (uid, vid)
- SELECT rl.uid, rv.vid FROM rlists rl JOIN releases_vn rv ON rv.id = rl.rid WHERE rl.rid = xedit.itemid
- ON CONFLICT (uid, vid) DO NOTHING;
- END IF;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
-
-----------------------------------------------------------
--- notification functions --
-----------------------------------------------------------
-
-
--- 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, ltype, uid, iid, subid, c_title, c_byuser)
- SELECT DISTINCT 'dbdel'::notification_ntype, xtype::text::notification_ltype, h.requester, 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, ltype, uid, iid, subid, c_title, c_byuser)
- SELECT DISTINCT 'dbedit'::notification_ntype, xtype::text::notification_ltype, h.requester, 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 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, ltype, uid, iid, subid, c_title, c_byuser)
- SELECT DISTINCT 'listdel'::notification_ntype, xtype::text::notification_ltype, u.uid, 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;
-
-
-
-
-----------------------------------------------------------
--- user management --
-----------------------------------------------------------
--- XXX: These functions run with the permissions of the 'vndb' user.
-
-
--- Returns the raw scrypt parameters (N, r, p and salt) for this user, in order
--- to create an encrypted pass. Returns NULL if this user does not have a valid
--- password.
-CREATE OR REPLACE FUNCTION user_getscryptargs(integer) RETURNS bytea AS $$
- SELECT
- CASE WHEN length(passwd) = 46 THEN substring(passwd from 1 for 14) ELSE NULL END
- FROM users WHERE id = $1
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
--- Create a new web session for this user (uid, scryptpass, token)
-CREATE OR REPLACE FUNCTION user_login(integer, bytea, bytea) RETURNS boolean AS $$
- INSERT INTO sessions (uid, token, expires, type) SELECT $1, $3, NOW() + '1 month', 'web' FROM users
- WHERE length($2) = 46 AND length($3) = 20
- AND id = $1 AND passwd = $2
- RETURNING true
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
-CREATE OR REPLACE FUNCTION user_logout(integer, bytea) RETURNS void AS $$
- DELETE FROM sessions WHERE uid = $1 AND token = $2 AND type = 'web'
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
--- Returns true if the given session token is valid.
--- As a side effect, this also extends the expiration time of web sessions.
-CREATE OR REPLACE FUNCTION user_isvalidsession(integer, bytea, session_type) RETURNS bool AS $$
- UPDATE sessions SET expires = NOW() + '1 month'
- WHERE uid = $1 AND token = $2 AND type = $3 AND $3 = 'web'
- AND expires < NOW() + '1 month'::interval - '6 hours'::interval;
- SELECT true FROM sessions WHERE uid = $1 AND token = $2 AND type = $3 AND expires > NOW();
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
-CREATE OR REPLACE FUNCTION user_emailexists(text, integer) RETURNS boolean AS $$
- SELECT true FROM users WHERE lower(mail) = lower($1) AND ($2 IS NULL OR id <> $2) LIMIT 1
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
--- Create a password reset token. args: email, token. Returns: user id.
--- Doesn't work for usermods, otherwise an attacker could use this function to
--- gain access to all user's emails by obtaining a reset token of a usermod.
--- Ideally Postgres itself would send the user an email so that the application
--- calling this function doesn't even get the token, and thus can't get access
--- to someone's account. But alas, that'd require a separate process.
-CREATE OR REPLACE FUNCTION user_resetpass(text, bytea) RETURNS integer AS $$
- INSERT INTO sessions (uid, token, expires, type)
- SELECT id, $2, NOW()+'1 week', 'pass' FROM users
- WHERE lower(mail) = lower($1) AND length($2) = 20 AND perm & 128 = 0
- RETURNING uid
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
--- Changes the user's password and invalidates all existing sessions. args: uid, old_pass_or_reset_token, new_pass
-CREATE OR REPLACE FUNCTION user_setpass(integer, bytea, bytea) RETURNS boolean AS $$
- WITH upd(id) AS (
- UPDATE users SET passwd = $3
- WHERE id = $1
- AND length($3) = 46
- AND ( (passwd = $2 AND length($2) = 46)
- OR EXISTS(SELECT 1 FROM sessions WHERE uid = $1 AND token = $2 AND type = 'pass' AND expires > NOW())
- )
- RETURNING id
- ), del AS( -- Not referenced, but still guaranteed to run
- DELETE FROM sessions WHERE uid IN(SELECT id FROM upd)
- )
- SELECT true FROM upd
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
--- Internal function, used to verify whether user ($2 with session $3) is
--- allowed to access sensitive data from user $1.
-CREATE OR REPLACE FUNCTION user_isauth(integer, integer, bytea) RETURNS boolean AS $$
- SELECT true FROM users
- WHERE id = $2
- AND EXISTS(SELECT 1 FROM sessions WHERE uid = $2 AND token = $3 AND type = 'web')
- AND ($2 = $1 OR perm & 128 = 128)
-$$ LANGUAGE SQL;
-
-
--- uid of user email to get, uid currently logged in, session token of currently logged in.
--- Ensures that only the user itself or a useradmin can get someone's email address.
-CREATE OR REPLACE FUNCTION user_getmail(integer, integer, bytea) RETURNS text AS $$
- SELECT mail FROM users WHERE id = $1 AND user_isauth($1, $2, $3)
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
--- Set a token to change a user's email address.
--- Args: uid, web-token, new-email-token, email
-CREATE OR REPLACE FUNCTION user_setmail_token(integer, bytea, bytea, text) RETURNS void AS $$
- INSERT INTO sessions (uid, token, expires, type, mail)
- SELECT id, $3, NOW()+'1 week', 'mail', $4 FROM users
- WHERE id = $1 AND user_isauth($1, $1, $2) AND length($3) = 20
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
--- Actually change a user's email address, given a valid token.
-CREATE OR REPLACE FUNCTION user_setmail_confirm(integer, bytea) RETURNS boolean AS $$
- WITH u(mail) AS (
- DELETE FROM sessions WHERE uid = $1 AND token = $2 AND type = 'mail' AND expires > NOW() RETURNING mail
- )
- UPDATE users SET mail = (SELECT mail FROM u) WHERE id = $1 AND EXISTS(SELECT 1 FROM u) RETURNING true;
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
-CREATE OR REPLACE FUNCTION user_setperm(integer, integer, bytea, integer) RETURNS void AS $$
- UPDATE users SET perm = $4 WHERE id = $1 AND user_isauth(-1, $2, $3)
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
-CREATE OR REPLACE FUNCTION user_admin_setpass(integer, integer, bytea, bytea) RETURNS void AS $$
- WITH upd(id) AS (
- UPDATE users SET passwd = $4 WHERE id = $1 AND user_isauth(-1, $2, $3) AND length($4) = 46 RETURNING id
- )
- DELETE FROM sessions WHERE uid IN(SELECT id FROM upd)
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
-CREATE OR REPLACE FUNCTION user_admin_setmail(integer, integer, bytea, text) RETURNS void AS $$
- UPDATE users SET mail = $4 WHERE id = $1 AND user_isauth(-1, $2, $3)
-$$ LANGUAGE SQL SECURITY DEFINER;
diff --git a/util/sql/schema.sql b/util/sql/schema.sql
deleted file mode 100644
index f2cc611c..00000000
--- a/util/sql/schema.sql
+++ /dev/null
@@ -1,953 +0,0 @@
--- Convention for database items with version control:
---
--- CREATE TABLE items ( -- dbentry_type=x
--- id SERIAL PRIMARY KEY,
--- locked boolean NOT NULL DEFAULT FALSE,
--- hidden boolean NOT NULL DEFAULT FALSE,
--- -- item-specific columns here
--- );
--- CREATE TABLE items_hist ( -- History of the 'items' table
--- chid integer NOT NULL, -- references changes.id
--- -- item-specific columns here
--- );
---
--- The '-- dbentry_type=x' comment is required, and is used by
--- util/sqleditfunc.pl to generate the correct editing functions. The history
--- of the 'locked' and 'hidden' flags is recorded in the changes table. It's
--- possible for 'items' to have more item-specific columns than 'items_hist'.
--- Some columns are caches or otherwise autogenerated, and do not need to be
--- versioned.
---
--- item-related tables work roughly the same:
---
--- CREATE TABLE items_field (
--- id integer, -- references items.id
--- -- field-specific columns here
--- );
--- CREATE TABLE items_field_hist ( -- History of the 'items_field' table
--- chid integer, -- references changes.id
--- -- field-specific columns here
--- );
---
--- The changes and *_hist tables contain all the data. In a sense, the other
--- tables related to the item are just a cache/view into the latest versions.
--- All modifications to the item tables has to go through the edit_* functions
--- in editfunc.sql, these are also responsible for keeping things synchronized.
---
--- Columns marked with a '[pub]' comment on the same line are included in the
--- public database dump. Be aware that not all properties of the to-be-dumped
--- data is annotated in this file. Which tables and which rows are exported is
--- defined in util/dbdump.pl.
---
--- Note: Every CREATE TABLE clause and each column should be on a separate
--- line. This file is parsed by lib/VNDB/Schema.pm and it doesn't implement a
--- full SQL query parser.
-
-
--- data types
-
-CREATE TYPE anime_type AS ENUM ('tv', 'ova', 'mov', 'oth', 'web', 'spe', 'mv');
-CREATE TYPE blood_type AS ENUM ('unknown', 'a', 'b', 'ab', 'o');
-CREATE TYPE board_type AS ENUM ('an', 'db', 'ge', 'v', 'p', 'u');
-CREATE TYPE char_role AS ENUM ('main', 'primary', 'side', 'appears');
-CREATE TYPE credit_type AS ENUM ('scenario', 'chardesign', 'art', 'music', 'songs', 'director', 'staff');
-CREATE TYPE cup_size AS ENUM ('', 'AAA', 'AA', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z');
-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 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');
-CREATE TYPE notification_ltype AS ENUM ('v', 'r', 'p', 'c', 't', 's', 'd');
-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');
-CREATE TYPE release_type AS ENUM ('complete', 'partial', 'trial');
-CREATE TYPE tag_category AS ENUM('cont', 'ero', 'tech');
-CREATE TYPE vn_relation AS ENUM ('seq', 'preq', 'set', 'alt', 'char', 'side', 'par', 'ser', 'fan', 'orig');
-CREATE TYPE resolution AS ENUM ('unknown', 'nonstandard', '640x480', '800x600', '1024x768', '1280x960', '1600x1200', '640x400', '960x600', '960x640', '1024x576', '1024x600', '1024x640', '1280x720', '1280x800', '1366x768', '1600x900', '1920x1080');
-CREATE TYPE session_type AS ENUM ('web', 'pass', 'mail');
-
--- Sequences used for ID generation of items not in the DB
-CREATE SEQUENCE covers_seq;
-CREATE SEQUENCE charimg_seq;
-
-
-
--- anime
-CREATE TABLE anime (
- id integer NOT NULL PRIMARY KEY, -- [pub]
- year smallint, -- [pub]
- ann_id integer, -- [pub]
- nfo_id varchar(200), -- [pub]
- type anime_type, -- [pub]
- title_romaji varchar(250), -- [pub]
- title_kanji varchar(250), -- [pub]
- lastfetch timestamptz
-);
-
--- changes
-CREATE TABLE changes (
- id SERIAL PRIMARY KEY,
- type dbentry_type NOT NULL,
- itemid integer NOT NULL,
- rev integer NOT NULL DEFAULT 1,
- added timestamptz NOT NULL DEFAULT NOW(),
- requester integer NOT NULL DEFAULT 0,
- ip inet NOT NULL DEFAULT '0.0.0.0',
- comments text NOT NULL DEFAULT '',
- ihid boolean NOT NULL DEFAULT FALSE,
- ilock boolean NOT NULL DEFAULT FALSE
-);
-
--- chars
-CREATE TABLE chars ( -- dbentry_type=c
- id SERIAL PRIMARY KEY, -- [pub]
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- name varchar(250) NOT NULL DEFAULT '', -- [pub]
- original varchar(250) NOT NULL DEFAULT '', -- [pub]
- alias varchar(500) NOT NULL DEFAULT '', -- [pub]
- image integer NOT NULL DEFAULT 0, -- [pub]
- "desc" text NOT NULL DEFAULT '', -- [pub]
- gender gender NOT NULL DEFAULT 'unknown', -- [pub]
- s_bust smallint NOT NULL DEFAULT 0, -- [pub]
- s_waist smallint NOT NULL DEFAULT 0, -- [pub]
- s_hip smallint NOT NULL DEFAULT 0, -- [pub]
- b_month smallint NOT NULL DEFAULT 0, -- [pub]
- b_day smallint NOT NULL DEFAULT 0, -- [pub]
- height smallint NOT NULL DEFAULT 0, -- [pub]
- weight smallint, -- [pub]
- bloodt blood_type NOT NULL DEFAULT 'unknown', -- [pub]
- main integer, -- [pub] chars.id
- main_spoil smallint NOT NULL DEFAULT 0, -- [pub]
- cup_size cup_size NOT NULL DEFAULT '', -- [pub]
- age smallint -- [pub]
-);
-
--- chars_hist
-CREATE TABLE chars_hist (
- chid integer NOT NULL PRIMARY KEY,
- name varchar(250) NOT NULL DEFAULT '',
- original varchar(250) NOT NULL DEFAULT '',
- alias varchar(500) NOT NULL DEFAULT '',
- image integer NOT NULL DEFAULT 0,
- "desc" text NOT NULL DEFAULT '',
- gender gender NOT NULL DEFAULT 'unknown',
- s_bust smallint NOT NULL DEFAULT 0,
- s_waist smallint NOT NULL DEFAULT 0,
- s_hip smallint NOT NULL DEFAULT 0,
- b_month smallint NOT NULL DEFAULT 0,
- b_day smallint NOT NULL DEFAULT 0,
- height smallint NOT NULL DEFAULT 0,
- weight smallint,
- bloodt blood_type NOT NULL DEFAULT 'unknown',
- main integer, -- chars.id
- main_spoil smallint NOT NULL DEFAULT 0,
- cup_size cup_size NOT NULL DEFAULT '',
- age smallint
-);
-
--- chars_traits
-CREATE TABLE chars_traits (
- id integer NOT NULL, -- [pub]
- tid integer NOT NULL, -- [pub] traits.id
- spoil smallint NOT NULL DEFAULT 0, -- [pub]
- PRIMARY KEY(id, tid)
-);
-
--- chars_traits_hist
-CREATE TABLE chars_traits_hist (
- chid integer NOT NULL,
- tid integer NOT NULL, -- traits.id
- spoil smallint NOT NULL DEFAULT 0,
- PRIMARY KEY(chid, tid)
-);
-
--- chars_vns
-CREATE TABLE chars_vns (
- id integer NOT NULL, -- [pub]
- vid integer NOT NULL, -- [pub] vn.id
- rid integer NULL, -- [pub] releases.id
- spoil smallint NOT NULL DEFAULT 0, -- [pub]
- role char_role NOT NULL DEFAULT 'main' -- [pub]
-);
-
--- chars_vns_hist
-CREATE TABLE chars_vns_hist (
- chid integer NOT NULL,
- vid integer NOT NULL, -- vn.id
- rid integer NULL, -- releases.id
- spoil smallint NOT NULL DEFAULT 0,
- role char_role NOT NULL DEFAULT 'main'
-);
-
--- docs
-CREATE TABLE docs ( -- dbentry_type=d
- id SERIAL PRIMARY KEY, -- [pub]
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- title varchar(200) NOT NULL DEFAULT '', -- [pub]
- content text NOT NULL DEFAULT '' -- [pub]
-);
-
--- docs_hist
-CREATE TABLE docs_hist (
- chid integer NOT NULL PRIMARY KEY,
- title varchar(200) NOT NULL DEFAULT '',
- content text NOT NULL DEFAULT ''
-);
-
--- login_throttle
-CREATE TABLE login_throttle (
- ip inet NOT NULL PRIMARY KEY,
- timeout timestamptz NOT NULL
-);
-
--- 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,
- ltype notification_ltype NOT NULL,
- iid integer NOT NULL,
- subid integer,
- c_title text NOT NULL,
- c_byuser integer NOT NULL DEFAULT 0
-);
-
--- producers
-CREATE TABLE producers ( -- dbentry_type=p
- id SERIAL PRIMARY KEY, -- [pub]
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- type producer_type NOT NULL DEFAULT 'co', -- [pub]
- name varchar(200) NOT NULL DEFAULT '', -- [pub]
- original varchar(200) NOT NULL DEFAULT '', -- [pub]
- website varchar(250) NOT NULL DEFAULT '', -- [pub]
- lang language NOT NULL DEFAULT 'ja', -- [pub]
- "desc" text NOT NULL DEFAULT '', -- [pub]
- alias varchar(500) NOT NULL DEFAULT '', -- [pub]
- l_wp varchar(150), -- [pub] (deprecated)
- rgraph integer, -- relgraphs.id
- l_wikidata integer -- [pub]
-);
-
--- producers_hist
-CREATE TABLE producers_hist (
- chid integer NOT NULL PRIMARY KEY,
- type producer_type NOT NULL DEFAULT 'co',
- name varchar(200) NOT NULL DEFAULT '',
- original varchar(200) NOT NULL DEFAULT '',
- website varchar(250) NOT NULL DEFAULT '',
- lang language NOT NULL DEFAULT 'ja',
- "desc" text NOT NULL DEFAULT '',
- alias varchar(500) NOT NULL DEFAULT '',
- l_wp varchar(150),
- l_wikidata integer
-);
-
--- producers_relations
-CREATE TABLE producers_relations (
- id integer NOT NULL, -- [pub]
- pid integer NOT NULL, -- [pub] producers.id
- relation producer_relation NOT NULL, -- [pub]
- PRIMARY KEY(id, pid)
-);
-
--- producers_relations_hist
-CREATE TABLE producers_relations_hist (
- chid integer NOT NULL,
- pid integer NOT NULL, -- producers.id
- relation producer_relation NOT NULL,
- PRIMARY KEY(chid, pid)
-);
-
--- quotes
-CREATE TABLE quotes (
- vid integer NOT NULL,
- quote varchar(250) NOT NULL,
- PRIMARY KEY(vid, quote)
-);
-
--- releases
-CREATE TABLE releases ( -- dbentry_type=r
- id SERIAL PRIMARY KEY, -- [pub]
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- title varchar(250) NOT NULL DEFAULT '', -- [pub]
- original varchar(250) NOT NULL DEFAULT '', -- [pub]
- type release_type NOT NULL DEFAULT 'complete', -- [pub]
- website varchar(250) NOT NULL DEFAULT '', -- [pub]
- catalog varchar(50) NOT NULL DEFAULT '', -- [pub]
- gtin bigint NOT NULL DEFAULT 0, -- [pub]
- released integer NOT NULL DEFAULT 0, -- [pub]
- notes text NOT NULL DEFAULT '', -- [pub]
- minage smallint, -- [pub]
- patch boolean NOT NULL DEFAULT FALSE, -- [pub]
- freeware boolean NOT NULL DEFAULT FALSE, -- [pub]
- doujin boolean NOT NULL DEFAULT FALSE, -- [pub]
- resolution resolution NOT NULL DEFAULT 'unknown', -- [pub]
- voiced smallint NOT NULL DEFAULT 0, -- [pub]
- ani_story smallint NOT NULL DEFAULT 0, -- [pub]
- ani_ero smallint NOT NULL DEFAULT 0, -- [pub]
- uncensored boolean NOT NULL DEFAULT FALSE, -- [pub]
- engine varchar(50) NOT NULL DEFAULT '', -- [pub]
- l_steam integer NOT NULL DEFAULT 0, -- [pub]
- l_dlsite text NOT NULL DEFAULT '', -- [pub]
- l_dlsiteen text NOT NULL DEFAULT '', -- [pub]
- l_gog text NOT NULL DEFAULT '', -- [pub]
- l_denpa text NOT NULL DEFAULT '', -- [pub]
- l_jlist text NOT NULL DEFAULT '', -- [pub]
- l_gyutto integer[] NOT NULL DEFAULT '{}', -- [pub]
- l_digiket integer NOT NULL DEFAULT 0, -- [pub]
- l_melon integer NOT NULL DEFAULT 0, -- [pub]
- l_mg integer NOT NULL DEFAULT 0, -- [pub]
- l_getchu integer NOT NULL DEFAULT 0, -- [pub]
- l_getchudl integer NOT NULL DEFAULT 0, -- [pub]
- l_dmm text[] NOT NULL DEFAULT '{}', -- [pub]
- l_itch text NOT NULL DEFAULT '', -- [pub]
- l_jastusa text NOT NULL DEFAULT '', -- [pub]
- l_egs integer NOT NULL DEFAULT 0, -- [pub]
- l_erotrail integer NOT NULL DEFAULT 0 -- [pub]
-);
-
--- releases_hist
-CREATE TABLE releases_hist (
- chid integer NOT NULL PRIMARY KEY,
- title varchar(250) NOT NULL DEFAULT '',
- original varchar(250) NOT NULL DEFAULT '',
- type release_type NOT NULL DEFAULT 'complete',
- website varchar(250) NOT NULL DEFAULT '',
- catalog varchar(50) NOT NULL DEFAULT '',
- gtin bigint NOT NULL DEFAULT 0,
- released integer NOT NULL DEFAULT 0,
- notes text NOT NULL DEFAULT '',
- minage smallint,
- patch boolean NOT NULL DEFAULT FALSE,
- freeware boolean NOT NULL DEFAULT FALSE,
- doujin boolean NOT NULL DEFAULT FALSE,
- resolution resolution NOT NULL DEFAULT 'unknown',
- voiced smallint NOT NULL DEFAULT 0,
- ani_story smallint NOT NULL DEFAULT 0,
- ani_ero smallint NOT NULL DEFAULT 0,
- uncensored boolean NOT NULL DEFAULT FALSE,
- engine varchar(50) NOT NULL DEFAULT '',
- l_steam integer NOT NULL DEFAULT 0,
- l_dlsite text NOT NULL DEFAULT '',
- l_dlsiteen text NOT NULL DEFAULT '',
- l_gog text NOT NULL DEFAULT '',
- l_denpa text NOT NULL DEFAULT '',
- l_jlist text NOT NULL DEFAULT '',
- l_gyutto integer[] NOT NULL DEFAULT '{}',
- l_digiket integer NOT NULL DEFAULT 0,
- l_melon integer NOT NULL DEFAULT 0,
- l_mg integer NOT NULL DEFAULT 0,
- l_getchu integer NOT NULL DEFAULT 0,
- l_getchudl integer NOT NULL DEFAULT 0,
- l_dmm text[] NOT NULL DEFAULT '{}',
- l_itch text NOT NULL DEFAULT '',
- l_jastusa text NOT NULL DEFAULT '',
- l_egs integer NOT NULL DEFAULT 0,
- l_erotrail integer NOT NULL DEFAULT 0
-);
-
--- releases_lang
-CREATE TABLE releases_lang (
- id integer NOT NULL, -- [pub]
- lang language NOT NULL, -- [pub]
- PRIMARY KEY(id, lang)
-);
-
--- releases_lang_hist
-CREATE TABLE releases_lang_hist (
- chid integer NOT NULL,
- lang language NOT NULL,
- PRIMARY KEY(chid, lang)
-);
-
--- releases_media
-CREATE TABLE releases_media (
- id integer NOT NULL, -- [pub]
- medium medium NOT NULL, -- [pub]
- qty smallint NOT NULL DEFAULT 1, -- [pub]
- PRIMARY KEY(id, medium, qty)
-);
-
--- releases_media_hist
-CREATE TABLE releases_media_hist (
- chid integer NOT NULL,
- medium medium NOT NULL,
- qty smallint NOT NULL DEFAULT 1,
- PRIMARY KEY(chid, medium, qty)
-);
-
--- releases_platforms
-CREATE TABLE releases_platforms (
- id integer NOT NULL, -- [pub]
- platform platform NOT NULL, -- [pub]
- PRIMARY KEY(id, platform)
-);
-
--- releases_platforms_hist
-CREATE TABLE releases_platforms_hist (
- chid integer NOT NULL,
- platform platform NOT NULL,
- PRIMARY KEY(chid, platform)
-);
-
--- releases_producers
-CREATE TABLE releases_producers (
- id integer NOT NULL, -- [pub]
- pid integer NOT NULL, -- [pub] producers.id
- developer boolean NOT NULL DEFAULT FALSE, -- [pub]
- publisher boolean NOT NULL DEFAULT TRUE, -- [pub]
- CHECK(developer OR publisher),
- PRIMARY KEY(id, pid)
-);
-
--- releases_producers_hist
-CREATE TABLE releases_producers_hist (
- chid integer NOT NULL,
- pid integer NOT NULL, -- producers.id
- developer boolean NOT NULL DEFAULT FALSE,
- publisher boolean NOT NULL DEFAULT TRUE,
- CHECK(developer OR publisher),
- PRIMARY KEY(chid, pid)
-);
-
--- releases_vn
-CREATE TABLE releases_vn (
- id integer NOT NULL, -- [pub]
- vid integer NOT NULL, -- [pub] vn.id
- PRIMARY KEY(id, vid)
-);
-
--- releases_vn_hist
-CREATE TABLE releases_vn_hist (
- chid integer NOT NULL,
- vid integer NOT NULL, -- vn.id
- PRIMARY KEY(chid, vid)
-);
-
--- relgraphs
-CREATE TABLE relgraphs (
- id SERIAL PRIMARY KEY,
- svg xml NOT NULL
-);
-
--- rlists
-CREATE TABLE rlists (
- uid integer NOT NULL DEFAULT 0, -- [pub]
- rid integer NOT NULL DEFAULT 0, -- [pub]
- status smallint NOT NULL DEFAULT 0, -- [pub]
- added timestamptz NOT NULL DEFAULT NOW(), -- [pub]
- PRIMARY KEY(uid, rid)
-);
-
--- screenshots
-CREATE TABLE screenshots (
- id SERIAL NOT NULL PRIMARY KEY, -- [pub]
- width smallint NOT NULL DEFAULT 0, -- [pub]
- height smallint NOT NULL DEFAULT 0 -- [pub]
-);
-
--- sessions
-CREATE TABLE sessions (
- uid integer NOT NULL,
- token bytea NOT NULL,
- added timestamptz NOT NULL DEFAULT NOW(),
- expires timestamptz NOT NULL,
- type session_type NOT NULL,
- mail text,
- PRIMARY KEY (uid, token)
-);
-
--- shop_denpa
-CREATE TABLE shop_denpa (
- id text NOT NULL PRIMARY KEY,
- lastfetch timestamptz,
- deadsince timestamptz,
- sku text NOT NULL DEFAULT '',
- price text NOT NULL DEFAULT ''
-);
-
--- shop_dlsite
-CREATE TABLE shop_dlsite (
- id text NOT NULL PRIMARY KEY,
- lastfetch timestamptz,
- deadsince timestamptz,
- shop text NOT NULL DEFAULT '',
- price text NOT NULL DEFAULT ''
-);
-
--- shop_jlist
-CREATE TABLE shop_jlist (
- id text NOT NULL PRIMARY KEY,
- lastfetch timestamptz,
- deadsince timestamptz,
- jbox boolean NOT NULL DEFAULT false,
- price text NOT NULL DEFAULT '' -- empty when unknown or not in stock
-);
-
--- shop_mg
-CREATE TABLE shop_mg (
- id integer NOT NULL PRIMARY KEY,
- lastfetch timestamptz,
- deadsince timestamptz,
- r18 boolean NOT NULL DEFAULT true,
- price text NOT NULL DEFAULT ''
-);
-
--- shop_playasia
-CREATE TABLE shop_playasia (
- pax text NOT NULL PRIMARY KEY,
- gtin bigint NOT NULL,
- lastfetch timestamptz,
- url text NOT NULL DEFAULT '',
- price text NOT NULL DEFAULT ''
-);
-
--- shop_playasia_gtin
-CREATE TABLE shop_playasia_gtin (
- gtin bigint NOT NULL PRIMARY KEY,
- lastfetch timestamptz
-);
-
--- staff
-CREATE TABLE staff ( -- dbentry_type=s
- id SERIAL PRIMARY KEY, -- [pub]
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- aid integer NOT NULL DEFAULT 0, -- [pub] staff_alias.aid
- gender gender NOT NULL DEFAULT 'unknown', -- [pub]
- lang language NOT NULL DEFAULT 'ja', -- [pub]
- "desc" text NOT NULL DEFAULT '', -- [pub]
- l_wp varchar(150) NOT NULL DEFAULT '', -- [pub] (deprecated)
- l_site varchar(250) NOT NULL DEFAULT '', -- [pub]
- l_twitter varchar(16) NOT NULL DEFAULT '', -- [pub]
- l_anidb integer, -- [pub]
- l_wikidata integer, -- [pub]
- l_pixiv integer NOT NULL DEFAULT 0 -- [pub]
-);
-
--- staff_hist
-CREATE TABLE staff_hist (
- chid integer NOT NULL PRIMARY KEY,
- aid integer NOT NULL DEFAULT 0, -- Can't refer to staff_alias.id, because the alias might have been deleted
- gender gender NOT NULL DEFAULT 'unknown',
- lang language NOT NULL DEFAULT 'ja',
- "desc" text NOT NULL DEFAULT '',
- l_wp varchar(150) NOT NULL DEFAULT '',
- l_site varchar(250) NOT NULL DEFAULT '',
- l_twitter varchar(16) NOT NULL DEFAULT '',
- l_anidb integer,
- l_wikidata integer,
- l_pixiv integer NOT NULL DEFAULT 0
-);
-
--- staff_alias
-CREATE TABLE staff_alias (
- id integer NOT NULL, -- [pub]
- aid SERIAL PRIMARY KEY, -- [pub] Globally unique ID of this alias
- name varchar(200) NOT NULL DEFAULT '', -- [pub]
- original varchar(200) NOT NULL DEFAULT '' -- [pub]
-);
-
--- staff_alias_hist
-CREATE TABLE staff_alias_hist (
- chid integer NOT NULL,
- aid integer NOT NULL, -- staff_alias.aid, but can't reference it because the alias may have been deleted
- name varchar(200) NOT NULL DEFAULT '',
- original varchar(200) NOT NULL DEFAULT '',
- PRIMARY KEY(chid, aid)
-);
-
--- stats_cache
-CREATE TABLE stats_cache (
- section varchar(25) NOT NULL PRIMARY KEY,
- count integer NOT NULL DEFAULT 0
-);
-
--- tags
-CREATE TABLE tags (
- id SERIAL NOT NULL PRIMARY KEY, -- [pub]
- name varchar(250) NOT NULL UNIQUE, -- [pub]
- description text NOT NULL DEFAULT '', -- [pub]
- added timestamptz NOT NULL DEFAULT NOW(),
- state smallint NOT NULL DEFAULT 0, -- [pub]
- c_items integer NOT NULL DEFAULT 0,
- addedby integer NOT NULL DEFAULT 0,
- cat tag_category NOT NULL DEFAULT 'cont', -- [pub]
- defaultspoil smallint NOT NULL DEFAULT 0, -- [pub]
- searchable boolean NOT NULL DEFAULT TRUE, -- [pub]
- applicable boolean NOT NULL DEFAULT TRUE -- [pub]
-);
-
--- tags_aliases
-CREATE TABLE tags_aliases (
- alias varchar(250) NOT NULL PRIMARY KEY, -- [pub]
- tag integer NOT NULL -- [pub]
-);
-
--- tags_parents
-CREATE TABLE tags_parents (
- tag integer NOT NULL, -- [pub]
- parent integer NOT NULL, -- [pub]
- PRIMARY KEY(tag, parent)
-);
-
--- tags_vn
-CREATE TABLE tags_vn (
- tag integer NOT NULL, -- [pub]
- vid integer NOT NULL, -- [pub]
- uid integer NOT NULL, -- [pub]
- vote smallint NOT NULL DEFAULT 3 CHECK (vote >= -3 AND vote <= 3 AND vote <> 0), -- [pub]
- spoiler smallint CHECK(spoiler >= 0 AND spoiler <= 2), -- [pub]
- date timestamptz NOT NULL DEFAULT NOW(), -- [pub]
- ignore boolean NOT NULL DEFAULT false, -- [pub]
- PRIMARY KEY(tag, vid, uid)
-);
-
--- tags_vn_inherit
-CREATE TABLE tags_vn_inherit (
- tag integer NOT NULL,
- vid integer NOT NULL,
- rating real NOT NULL,
- spoiler smallint NOT NULL
-);
-
--- threads
-CREATE TABLE threads (
- id SERIAL NOT NULL PRIMARY KEY,
- title varchar(50) NOT NULL DEFAULT '',
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- count smallint NOT NULL DEFAULT 0,
- poll_question varchar(100),
- poll_max_options smallint NOT NULL DEFAULT 1,
- poll_preview boolean NOT NULL DEFAULT FALSE, -- deprecated
- poll_recast boolean NOT NULL DEFAULT FALSE, -- deprecated
- private boolean NOT NULL DEFAULT FALSE
-);
-
--- threads_poll_options
-CREATE TABLE threads_poll_options (
- id SERIAL PRIMARY KEY,
- tid integer NOT NULL,
- option varchar(100) NOT NULL
-);
-
--- threads_poll_votes
-CREATE TABLE threads_poll_votes (
- tid integer NOT NULL,
- uid integer NOT NULL,
- optid integer NOT NULL,
- date timestamptz DEFAULT NOW(),
- PRIMARY KEY (tid, uid, optid)
-);
-
--- threads_posts
-CREATE TABLE threads_posts (
- tid integer NOT NULL DEFAULT 0,
- num smallint NOT NULL DEFAULT 0,
- uid integer NOT NULL DEFAULT 0,
- date timestamptz NOT NULL DEFAULT NOW(),
- edited timestamptz,
- msg text NOT NULL DEFAULT '',
- hidden boolean NOT NULL DEFAULT FALSE,
- PRIMARY KEY(tid, num)
-);
-
--- threads_boards
-CREATE TABLE threads_boards (
- tid integer NOT NULL DEFAULT 0,
- type board_type NOT NULL,
- iid integer NOT NULL DEFAULT 0,
- PRIMARY KEY(tid, type, iid)
-);
-
--- traits
-CREATE TABLE traits (
- id SERIAL PRIMARY KEY, -- [pub]
- name varchar(250) NOT NULL, -- [pub]
- alias varchar(500) NOT NULL DEFAULT '', -- [pub]
- description text NOT NULL DEFAULT '', -- [pub]
- added timestamptz NOT NULL DEFAULT NOW(),
- state smallint NOT NULL DEFAULT 0, -- [pub]
- addedby integer NOT NULL DEFAULT 0,
- "group" integer, -- [pub]
- "order" smallint NOT NULL DEFAULT 0, -- [pub]
- sexual boolean NOT NULL DEFAULT false, -- [pub]
- c_items integer NOT NULL DEFAULT 0,
- defaultspoil smallint NOT NULL DEFAULT 0, -- [pub]
- searchable boolean NOT NULL DEFAULT true, -- [pub]
- applicable boolean NOT NULL DEFAULT true -- [pub]
-);
-
--- traits_chars
--- This table is a cache for the data in chars_traits and includes child traits
--- into parent traits. In order to improve performance, there are no foreign
--- key constraints on this table.
-CREATE TABLE traits_chars (
- cid integer NOT NULL, -- chars (id)
- tid integer NOT NULL, -- traits (id)
- spoil smallint NOT NULL DEFAULT 0
-);
-
--- traits_parents
-CREATE TABLE traits_parents (
- trait integer NOT NULL, -- [pub]
- parent integer NOT NULL, -- [pub]
- PRIMARY KEY(trait, parent)
-);
-
--- ulist_labels
-CREATE TABLE ulist_labels (
- uid integer NOT NULL, -- [pub] user.id
- id integer NOT NULL, -- [pub] 0 < builtin < 10 <= custom, ids are reused
- label text NOT NULL, -- [pub]
- private boolean NOT NULL,
- PRIMARY KEY(uid, id)
-);
-
--- ulist_vns
-CREATE TABLE ulist_vns (
- uid integer NOT NULL, -- [pub] users.id
- vid integer NOT NULL, -- [pub] vn.id
- added timestamptz NOT NULL DEFAULT NOW(), -- [pub]
- lastmod timestamptz NOT NULL DEFAULT NOW(), -- [pub] updated when anything in this row has changed?
- vote_date timestamptz, -- [pub] Used for "recent votes" - also updated when vote has changed?
- vote smallint CHECK(vote IS NULL OR vote BETWEEN 10 AND 100), -- [pub]
- started date, -- [pub]
- finished date, -- [pub]
- notes text NOT NULL DEFAULT '', -- [pub]
- PRIMARY KEY(uid, vid)
-);
-
--- ulist_vns_labels
-CREATE TABLE ulist_vns_labels (
- uid integer NOT NULL, -- [pub] user.id
- lbl integer NOT NULL, -- [pub]
- vid integer NOT NULL, -- [pub] vn.id
- PRIMARY KEY(uid, lbl, vid)
-);
-
--- users
-CREATE TABLE users (
- id SERIAL NOT NULL PRIMARY KEY, -- [pub]
- username varchar(20) NOT NULL UNIQUE, -- [pub]
- mail varchar(100) NOT NULL,
- perm smallint NOT NULL DEFAULT 1+4+16,
- -- A valid passwd column is 46 bytes:
- -- 4 bytes: N (big endian)
- -- 1 byte: r
- -- 1 byte: p
- -- 8 bytes: salt
- -- 32 bytes: scrypt(passwd, global_salt + salt, N, r, p, 32)
- -- Anything else is invalid, account disabled.
- passwd bytea NOT NULL DEFAULT '',
- registered timestamptz NOT NULL DEFAULT NOW(),
- c_votes integer NOT NULL DEFAULT 0,
- c_changes integer NOT NULL DEFAULT 0,
- ip inet NOT NULL DEFAULT '0.0.0.0',
- c_tags integer NOT NULL DEFAULT 0,
- ign_votes boolean NOT NULL DEFAULT FALSE, -- [pub]
- email_confirmed boolean NOT NULL DEFAULT FALSE,
- skin text NOT NULL DEFAULT '',
- customcss text NOT NULL DEFAULT '',
- filter_vn text NOT NULL DEFAULT '',
- filter_release text NOT NULL DEFAULT '',
- show_nsfw boolean NOT NULL DEFAULT FALSE,
- hide_list boolean NOT NULL DEFAULT FALSE, -- deprecated, replaced with ulist_labels.private
- notify_dbedit boolean NOT NULL DEFAULT TRUE,
- notify_announce boolean NOT NULL DEFAULT FALSE,
- vn_list_own boolean NOT NULL DEFAULT FALSE,
- vn_list_wish boolean NOT NULL DEFAULT FALSE, -- Not used anymore, wishlist column in the VN list view has been removed
- tags_all boolean NOT NULL DEFAULT FALSE,
- tags_cont boolean NOT NULL DEFAULT TRUE,
- tags_ero boolean NOT NULL DEFAULT FALSE,
- tags_tech boolean NOT NULL DEFAULT TRUE,
- spoilers smallint NOT NULL DEFAULT 0,
- traits_sexual boolean NOT NULL DEFAULT FALSE,
- nodistract_can boolean NOT NULL DEFAULT FALSE,
- nodistract_noads boolean NOT NULL DEFAULT FALSE,
- nodistract_nofancy boolean NOT NULL DEFAULT FALSE,
- support_can boolean NOT NULL DEFAULT FALSE,
- support_enabled boolean NOT NULL DEFAULT FALSE,
- uniname_can boolean NOT NULL DEFAULT FALSE,
- uniname text NOT NULL DEFAULT '',
- pubskin_can boolean NOT NULL DEFAULT FALSE,
- pubskin_enabled boolean NOT NULL DEFAULT FALSE,
- c_vns integer NOT NULL DEFAULT 0,
- c_wish integer NOT NULL DEFAULT 0,
- ulist_votes jsonb,
- ulist_vnlist jsonb,
- ulist_wish jsonb
-);
-
--- vn
-CREATE TABLE vn ( -- dbentry_type=v
- id SERIAL PRIMARY KEY, -- [pub]
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- title varchar(250) NOT NULL DEFAULT '', -- [pub]
- original varchar(250) NOT NULL DEFAULT '', -- [pub]
- alias varchar(500) NOT NULL DEFAULT '', -- [pub]
- length smallint NOT NULL DEFAULT 0, -- [pub]
- img_nsfw boolean NOT NULL DEFAULT FALSE, -- [pub]
- image integer NOT NULL DEFAULT 0, -- [pub]
- "desc" text NOT NULL DEFAULT '', -- [pub]
- l_wp varchar(150) NOT NULL DEFAULT '', -- [pub] (deprecated)
- l_encubed varchar(100) NOT NULL DEFAULT '', -- [pub] (deprecated)
- l_renai varchar(100) NOT NULL DEFAULT '', -- [pub]
- rgraph integer, -- relgraphs.id
- c_released integer NOT NULL DEFAULT 0,
- c_languages language[] NOT NULL DEFAULT '{}',
- c_olang language[] NOT NULL DEFAULT '{}',
- c_platforms platform[] NOT NULL DEFAULT '{}',
- c_popularity real,
- c_rating real,
- c_votecount integer NOT NULL DEFAULT 0,
- c_search text,
- l_wikidata integer -- [pub]
-);
-
--- vn_hist
-CREATE TABLE vn_hist (
- chid integer NOT NULL PRIMARY KEY,
- title varchar(250) NOT NULL DEFAULT '',
- original varchar(250) NOT NULL DEFAULT '',
- alias varchar(500) NOT NULL DEFAULT '',
- length smallint NOT NULL DEFAULT 0,
- img_nsfw boolean NOT NULL DEFAULT FALSE,
- image integer NOT NULL DEFAULT 0,
- "desc" text NOT NULL DEFAULT '',
- l_wp varchar(150) NOT NULL DEFAULT '',
- l_encubed varchar(100) NOT NULL DEFAULT '',
- l_renai varchar(100) NOT NULL DEFAULT '',
- l_wikidata integer
-);
-
--- vn_anime
-CREATE TABLE vn_anime (
- id integer NOT NULL, -- [pub]
- aid integer NOT NULL, -- [pub] anime.id
- PRIMARY KEY(id, aid)
-);
-
--- vn_anime_hist
-CREATE TABLE vn_anime_hist (
- chid integer NOT NULL,
- aid integer NOT NULL, -- anime.id
- PRIMARY KEY(chid, aid)
-);
-
--- vn_relations
-CREATE TABLE vn_relations (
- id integer NOT NULL, -- [pub]
- vid integer NOT NULL, -- [pub] vn.id
- relation vn_relation NOT NULL, -- [pub]
- official boolean NOT NULL DEFAULT TRUE, -- [pub]
- PRIMARY KEY(id, vid)
-);
-
--- vn_relations_hist
-CREATE TABLE vn_relations_hist (
- chid integer NOT NULL,
- vid integer NOT NULL, -- vn.id
- relation vn_relation NOT NULL,
- official boolean NOT NULL DEFAULT TRUE,
- PRIMARY KEY(chid, vid)
-);
-
--- vn_screenshots
-CREATE TABLE vn_screenshots (
- id integer NOT NULL, -- [pub]
- scr integer NOT NULL, -- [pub] screenshots.id
- rid integer, -- [pub] releases.id (only NULL for old revisions, nowadays not allowed anymore)
- nsfw boolean NOT NULL DEFAULT FALSE, -- [pub]
- PRIMARY KEY(id, scr)
-);
-
--- vn_screenshots_hist
-CREATE TABLE vn_screenshots_hist (
- chid integer NOT NULL,
- scr integer NOT NULL,
- rid integer,
- nsfw boolean NOT NULL DEFAULT FALSE,
- PRIMARY KEY(chid, scr)
-);
-
--- vn_seiyuu
-CREATE TABLE vn_seiyuu (
- id integer NOT NULL, -- [pub]
- aid integer NOT NULL, -- [pub] staff_alias.aid
- cid integer NOT NULL, -- [pub] chars.id
- note varchar(250) NOT NULL DEFAULT '', -- [pub]
- PRIMARY KEY (id, aid, cid)
-);
-
--- vn_seiyuu_hist
-CREATE TABLE vn_seiyuu_hist (
- chid integer NOT NULL,
- aid integer NOT NULL, -- staff_alias.aid, but can't reference it because the alias may have been deleted
- cid integer NOT NULL, -- chars.id
- note varchar(250) NOT NULL DEFAULT '',
- PRIMARY KEY (chid, aid, cid)
-);
-
--- vn_staff
-CREATE TABLE vn_staff (
- id integer NOT NULL, -- [pub]
- aid integer NOT NULL, -- [pub] staff_alias.aid
- role credit_type NOT NULL DEFAULT 'staff', -- [pub]
- note varchar(250) NOT NULL DEFAULT '', -- [pub]
- PRIMARY KEY (id, aid, role)
-);
-
--- vn_staff_hist
-CREATE TABLE vn_staff_hist (
- chid integer NOT NULL,
- aid integer NOT NULL, -- See note at vn_seiyuu_hist.aid
- role credit_type NOT NULL DEFAULT 'staff',
- note varchar(250) NOT NULL DEFAULT '',
- PRIMARY KEY (chid, aid, role)
-);
-
--- wikidata
-CREATE TABLE wikidata (
- id integer NOT NULL PRIMARY KEY, -- [pub]
- lastfetch timestamptz,
- enwiki text, -- [pub]
- jawiki text, -- [pub]
- website text[], -- [pub] P856
- vndb text[], -- [pub] P3180
- mobygames text[], -- [pub] P1933
- mobygames_company text[], -- [pub] P4773
- gamefaqs_game integer[], -- [pub] P4769
- gamefaqs_company integer[], -- [pub] P6182
- anidb_anime integer[], -- [pub] P5646
- anidb_person integer[], -- [pub] P5649
- ann_anime integer[], -- [pub] P1985
- ann_manga integer[], -- [pub] P1984
- musicbrainz_artist uuid[], -- [pub] P434
- twitter text[], -- [pub] P2002
- vgmdb_product integer[], -- [pub] P5659
- vgmdb_artist integer[], -- [pub] P3435
- discogs_artist integer[], -- [pub] P1953
- acdb_char integer[], -- [pub] P7013
- acdb_source integer[], -- [pub] P7017
- indiedb_game text[], -- [pub] P6717
- howlongtobeat integer[], -- [pub] P2816
- crunchyroll text[], -- [pub] P4110
- igdb_game text[], -- [pub] P5794
- giantbomb text[], -- [pub] P5247
- pcgamingwiki text[], -- [pub] P6337
- steam integer[], -- [pub] P1733
- gog text[], -- [pub] P2725
- pixiv_user integer[], -- [pub] P5435
- doujinshi_author integer[] -- [pub] P7511
-);
diff --git a/util/sqleditfunc.pl b/util/sqleditfunc.pl
index 3375136a..59558822 100755
--- a/util/sqleditfunc.pl
+++ b/util/sqleditfunc.pl
@@ -1,9 +1,7 @@
#!/usr/bin/perl
-use strict;
-use warnings;
+use v5.28;
use List::Util 'any';
-
use Cwd 'abs_path';
our $ROOT;
BEGIN { ($ROOT = abs_path $0) =~ s{/util/sqleditfunc\.pl$}{}; }
@@ -19,13 +17,13 @@ sub gensql {
# table_name_without_hist => [ column_names_without_chid ]
my %ts = map
- +($_, [ map "\"$_->{name}\"", grep $_->{name} !~ /^chid$/, @{$schema->{"${_}_hist"}{cols}} ]),
+ +($_, [ map $_->{name}, grep $_->{name} !~ /^chid$/, @{$schema->{"${_}_hist"}{cols}} ]),
map /^${item}_/ && /^(.+)_hist$/ ? $1 : (), keys %$schema;
my %replace = ( item => $item, itemtype => $schema->{$item}{dbentry_type} );
$replace{createtemptables} = join "\n", map sprintf(
- " CREATE TEMPORARY TABLE edit_%s (LIKE %s INCLUDING DEFAULTS INCLUDING CONSTRAINTS);\n".
+ " CREATE TEMPORARY TABLE edit_%s (LIKE %s INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING GENERATED);\n".
" ALTER TABLE edit_%1\$s DROP COLUMN %s;",
$_, $_ eq 'staff_alias' ? ($_, 'id') : ("${_}_hist", 'chid') # staff_alias copies from the non-_hist table, because it needs the sequence
), sort keys %ts;
@@ -37,29 +35,28 @@ sub gensql {
$_, join ', ', @{$ts{$_}}), sort keys %ts;
$replace{copyfromtemp} = join "\n", map sprintf(
- " DELETE FROM %1\$s WHERE id = r.itemid;\n".
- " INSERT INTO %1\$s (id, %2\$s) SELECT r.itemid, %2\$s FROM edit_%1\$s;\n".
- " INSERT INTO %1\$s_hist (chid, %2\$s) SELECT r.chid, %2\$s FROM edit_%1\$s;",
+ " DELETE FROM %1\$s WHERE id = nitemid;\n".
+ " INSERT INTO %1\$s (id, %2\$s) SELECT nitemid, %2\$s FROM edit_%1\$s;\n".
+ " INSERT INTO %1\$s_hist (chid, %2\$s) SELECT nchid, %2\$s FROM edit_%1\$s;",
$_, join ', ', @{$ts{$_}}), grep $_ ne $item, sort keys %ts;
$replace{copymainfromtemp} = sprintf
- " INSERT INTO %1\$s_hist (chid, %2\$s) SELECT r.chid, %2\$s FROM edit_%1\$s;\n".
+ " INSERT INTO %1\$s_hist (chid, %2\$s) SELECT nchid, %2\$s FROM edit_%1\$s;\n".
" UPDATE %1\$s SET locked = (SELECT ilock FROM edit_revision), hidden = (SELECT ihid FROM edit_revision),\n".
- " %3\$s FROM edit_%1\$s x WHERE id = r.itemid;",
+ " %3\$s FROM edit_%1\$s x WHERE id = nitemid;",
$item, join(', ', @{$ts{$item}}), join(', ', map "$_ = x.$_", @{$ts{$item}});
$template =~ s/{([a-z]+)}/$replace{$1}/gr;
}
-open my $F, '>', "$ROOT/util/sql/editfunc.sql" or die $!;
-print $F "-- Automatically generated by util/sqleditfunc.pl. DO NOT EDIT.\n";
-print $F gensql $_ for sort grep $schema->{$_}{dbentry_type}, keys %$schema;
+print "-- Automatically generated by util/sqleditfunc.pl. DO NOT EDIT.\n";
+print gensql $_ for sort grep $schema->{$_}{dbentry_type}, keys %$schema;
__DATA__
-CREATE OR REPLACE FUNCTION edit_{itemtype}_init(xid integer, xrev integer) RETURNS void AS $$
+CREATE OR REPLACE FUNCTION edit_{itemtype}_init(xid vndbid, xrev integer) RETURNS void AS $$
DECLARE
xchid integer;
BEGIN
@@ -70,7 +67,7 @@ BEGIN
TRUNCATE {temptablenames};
END;
-- Create edit_revision table and get relevant change ID.
- SELECT edit_revtable('{itemtype}', xid, xrev) INTO xchid;
+ SELECT edit_revtable(xid, xrev) INTO xchid;
-- new entry, load defaults
IF xchid IS NULL THEN
INSERT INTO edit_{item} DEFAULT VALUES;
@@ -82,18 +79,26 @@ END;
$$ LANGUAGE plpgsql;
-CREATE OR REPLACE FUNCTION edit_{itemtype}_commit() RETURNS edit_rettype AS $$
-DECLARE
- r edit_rettype;
+CREATE OR REPLACE FUNCTION edit_{itemtype}_commit(out nchid integer, out nitemid vndbid, out nrev integer) AS $$
BEGIN
IF (SELECT COUNT(*) FROM edit_{item}) <> 1 THEN
RAISE 'edit_{item} must have exactly one row!';
END IF;
- SELECT INTO r * FROM edit_commit();
+ SELECT itemid INTO nitemid FROM edit_revision;
+ -- figure out revision number
+ SELECT MAX(rev)+1 INTO nrev FROM changes WHERE itemid = nitemid;
+ SELECT COALESCE(nrev, 1) INTO nrev;
+ -- insert DB item
+ IF nitemid IS NULL THEN
+ INSERT INTO {item} DEFAULT VALUES RETURNING id INTO nitemid;
+ END IF;
+ -- insert change
+ INSERT INTO changes (itemid, rev, requester, comments, ihid, ilock)
+ SELECT nitemid, nrev, requester, comments, ihid, ilock FROM edit_revision RETURNING id INTO nchid;
+ -- insert data
{copyfromtemp}
{copymainfromtemp}
- PERFORM edit_committed('{itemtype}', r);
- RETURN r;
+ PERFORM edit_committed(nchid, nitemid, nrev);
END;
$$ LANGUAGE plpgsql;
diff --git a/util/svgsprite.pl b/util/svgsprite.pl
new file mode 100755
index 00000000..910e0454
--- /dev/null
+++ b/util/svgsprite.pl
@@ -0,0 +1,54 @@
+#!/usr/bin/perl
+
+# I had planned to use fragment identifiers as described in
+# https://css-tricks.com/svg-fragment-identifiers-work/
+# But it turns out Firefox doesn't cache/reuse the SVG when referenced with
+# different fragments. :facepalm:
+
+use v5.26;
+use strict;
+use autodie;
+
+my $GEN = $ENV{VNDB_GEN} // 'gen';
+
+my %icons = map +((m{^icons/(.+)\.svg$})[0] =~ s#/#-#rg, $_), glob('icons/*.svg'), glob('icons/*/*.svg');
+my $idnum = 'a';
+my($width, $height) = (-10,0);
+my($defs, $group, $css) = ('','','');
+
+for my $id (sort keys %icons) {
+ my $data = do { local $/=undef; open my $F, '<', $icons{$id}; <$F> };
+ $data =~ s{<\?xml[^>]*>}{};
+ $data =~ s{</svg>}{}g;
+ $data =~ s/\n//g;
+ $data =~ s{<svg [^>]*viewBox="0 0 ([^ ]+) ([^ ]+)"[^>]*>}{};
+ my($w,$h) = ($1,$2);
+ my $viewbox = $w // die "No suitable viewBox property found in $icons{$id}\n";
+
+ # Identifiers must be globally unique, so need to renumber.
+ my %idmap;
+ $data =~ s{(id="|href="#|url\(#)([^"\)]+)}{ $idmap{$2}||=$idnum++; $1.$idmap{$2} }eg;
+
+ # Take out the <defs> and put them in global scope, otherwise some(?) renderers can't find the definitions.
+ $defs .= $1 if $data =~ s{<defs>(.+)</defs>}{};
+
+ $width += 10;
+ $group .= qq{<g transform="translate($width)">$data</g>};
+ $css .= sprintf ".icon-%s { background-position: %dpx 0; width: %dpx; height: %dpx }\n", $id, -$width, $w, $h;
+
+ $width += $w;
+ $height = $h if $height < $h;
+}
+
+{
+ open my $F, '>', "$GEN/svg.css";
+ print $F $css;
+}
+
+{
+ open my $F, '>', "$GEN/static/icons.svg";
+ print $F qq{<svg xmlns="http://www.w3.org/2000/svg" width="$width" height="$height" viewBox="0 0 $width $height">};
+ print $F qq{<defs>$defs</defs>} if $defs;
+ print $F $group;
+ print $F '</svg>';
+}
diff --git a/util/test/basn4a08.png b/util/test/basn4a08.png
new file mode 100644
index 00000000..3e130522
--- /dev/null
+++ b/util/test/basn4a08.png
Binary files differ
diff --git a/util/test/basn6a16.png b/util/test/basn6a16.png
new file mode 100644
index 00000000..984a9952
--- /dev/null
+++ b/util/test/basn6a16.png
Binary files differ
diff --git a/util/bbcode-test.pl b/util/test/bbcode.pl
index 0ad0f3ea..94128684 100755
--- a/util/bbcode-test.pl
+++ b/util/test/bbcode.pl
@@ -5,14 +5,11 @@
use strict;
use warnings;
-use Cwd 'abs_path';
use Test::More;
use Benchmark 'timethese';
-our($ROOT, %S);
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/bbcode-test\.pl$}{}; }
-use lib "$ROOT/lib";
-use VNDB::BBCode qw/bb2html bb2text/;
+use lib 'lib';
+use VNDB::BBCode;
my @tests = (
@@ -30,27 +27,43 @@ my @tests = (
'[quote]some quote[/quote]',
'<div class="quote">some quote</div>',
- 'some quote',
+ '"some quote"',
"[code]some code\n\nalso newlines;[/code]",
'<pre>some code<br><br>also newlines;</pre>',
- "some code\n\nalso newlines;",
+ "`some code\n\nalso newlines;`",
'[spoiler]some spoiler[/spoiler]',
- '<b class="spoiler">some spoiler</b>',
+ '<span class="spoiler">some spoiler</span>',
'',
+ '[b][i][u][s]Formatting![/s][/u][/i][/b]',
+ '<strong><em><span class="underline"><s>Formatting!</s></span></em></strong>',
+ '*/_-Formatting!-_/*',
+
"[raw][quote]not parsed\n[url=https://vndb.org/]valid url[/url]\n[url=asdf]invalid url[/url][/quote][/raw]",
"[quote]not parsed<br>[url=https://vndb.org/]valid url[/url]<br>[url=asdf]invalid url[/url][/quote]",
"[quote]not parsed\n[url=https://vndb.org/]valid url[/url]\n[url=asdf]invalid url[/url][/quote]",
'[quote]basic [spoiler]single[/spoiler]-line [spoiler][url=/g]tag[/url] nesting [raw](without [url=/v3333]special[/url] cases)[/raw][/spoiler][/quote]',
- '<div class="quote">basic <b class="spoiler">single</b>-line <b class="spoiler"><a href="/g" rel="nofollow">tag</a> nesting (without [url=/v3333]special[/url] cases)</b></div>',
- 'basic -line ',
+ '<div class="quote">basic <span class="spoiler">single</span>-line <span class="spoiler"><a href="/g" rel="nofollow">tag</a> nesting (without [url=/v3333]special[/url] cases)</span></div>',
+ '"basic -line "',
+
+ '[quote][b]more [spoiler]nesting [code]mkay?',
+ '<div class="quote"><strong>more <span class="spoiler">nesting [code]mkay?</span></strong></div>',
+ '"*more *"',
+
+ '[url=/v][b]does not work here[/b][/url]',
+ '<a href="/v" rel="nofollow">[b]does not work here[/b]</a>',
+ '[b]does not work here[/b]',
+
+ '[s] v5 [url=/p1]x[/url] [/s]',
+ '<s> <a href="/v5">v5</a> <a href="/p1" rel="nofollow">x</a> </s>',
+ '- v5 x -',
"[quote]rmnewline after closing tag[/quote]\n",
'<div class="quote">rmnewline after closing tag</div>',
- "rmnewline after closing tag\n",
+ "\"rmnewline after closing tag\"",
'[url=/v19]some vndb url[/url]',
'<a href="/v19" rel="nofollow">some vndb url</a>',
@@ -58,20 +71,20 @@ my @tests = (
"quite\n\n\n\n\n\n\na\n\n\n\n\n lot of\n\n\n\nunneeded whitespace",
'quite<br><br>a<br><br> lot of<br><br><br><br>unneeded whitespace',
- "quite\n\n\n\n\n\n\na\n\n\n\n\n lot of\n\n\n\nunneeded whitespace",
+ "quite\n\na\n\n lot of\n\n\n\nunneeded whitespace",
"[quote]\nsimple\nrmnewline\ntest\n[/quote]",
'<div class="quote">simple<br>rmnewline<br>test<br></div>',
- "\nsimple\nrmnewline\ntest\n",
+ "\"simple\nrmnewline\ntest\n\"",
# the new implementation doesn't special-case [code], as the first newline shouldn't matter either way
"[quote]\n\nhello, rmnewline test[code]\n#!/bin/sh\n\nfunction random_username() {\n </dev/urandom tr -cd 'a-zA-Z0-9' | dd bs=1 count=16 2>/dev/null\n}\n[/code]\nsome text after the code tag\n[/quote]\n\n[spoiler]\nsome newlined spoiler\n[/spoiler]",
- '<div class="quote"><br>hello, rmnewline test<pre>#!/bin/sh<br><br>function random_username() {<br> &lt;/dev/urandom tr -cd \'a-zA-Z0-9\' | dd bs=1 count=16 2&gt;/dev/null<br>}<br></pre>some text after the code tag<br></div><br><b class="spoiler"><br>some newlined spoiler<br></b>',
- "\n\nhello, rmnewline test\n#!/bin/sh\n\nfunction random_username() {\n </dev/urandom tr -cd 'a-zA-Z0-9' | dd bs=1 count=16 2>/dev/null\n}\n\nsome text after the code tag\n\n\n",
+ '<div class="quote"><br>hello, rmnewline test<pre>#!/bin/sh<br><br>function random_username() {<br> &lt;/dev/urandom tr -cd \'a-zA-Z0-9\' | dd bs=1 count=16 2&gt;/dev/null<br>}<br></pre>some text after the code tag<br></div><br><span class="spoiler"><br>some newlined spoiler<br></span>',
+ "\"\nhello, rmnewline test`#!/bin/sh\n\nfunction random_username() {\n </dev/urandom tr -cd 'a-zA-Z0-9' | dd bs=1 count=16 2>/dev/null\n}\n`some text after the code tag\n\"\n",
"[quote]\n[raw]\nrmnewline test with made-up elements\n[/raw]\nwelp\n[dumbtag]\nnone\n[/dumbtag]\n[/quote]",
'<div class="quote"><br>rmnewline test with made-up elements<br><br>welp<br>[dumbtag]<br>none<br>[/dumbtag]<br></div>',
- "\n\nrmnewline test with made-up elements\n\nwelp\n[dumbtag]\nnone\n[/dumbtag]\n",
+ "\"\nrmnewline test with made-up elements\n\nwelp\n[dumbtag]\nnone\n[/dumbtag]\n\"",
'[url=http://example.com/]markup in [raw][url][/raw][/url]',
'<a href="http://example.com/" rel="nofollow">markup in [url]</a>',
@@ -94,24 +107,24 @@ my @tests = (
'http://192.168.1.1:8080/some/path (literal ipv4 address, port included)',
'[Quote]non-lowercase tags [SpOILER]here[/sPOilER][/qUOTe]',
- '<div class="quote">non-lowercase tags <b class="spoiler">here</b></div>',
- 'non-lowercase tags ',
+ '<div class="quote">non-lowercase tags <span class="spoiler">here</span></div>',
+ '"non-lowercase tags "',
'some text [spoiler]with (v17) tags[/spoiler] and internal ids such as s1',
- 'some text <b class="spoiler">with (<a href="/v17">v17</a>) tags</b> and internal ids such as <a href="/s1">s1</a>',
+ 'some text <span class="spoiler">with (<a href="/v17">v17</a>) tags</span> and internal ids such as <a href="/s1">s1</a>',
'some text and internal ids such as s1',
- 'r12.1 v6.3 s1.2',
- '<a href="/r12.1">r12.1</a> <a href="/v6.3">v6.3</a> <a href="/s1.2">s1.2</a>',
- 'r12.1 v6.3 s1.2',
+ 'r12.1 v6.3 s1.2 w5.3',
+ '<a href="/r12.1">r12.1</a> <a href="/v6.3">v6.3</a> <a href="/s1.2">s1.2</a> <a href="/w5.3">w5.3</a>',
+ 'r12.1 v6.3 s1.2 w5.3',
'd3 d1.3 d2#4 d5#6.7',
'<a href="/d3">d3</a> <a href="/d1.3">d1.3</a> <a href="/d2#4">d2#4</a> <a href="/d5#6.7">d5#6.7</a>',
'd3 d1.3 d2#4 d5#6.7',
- 'v17 text dds16v21 more text1 v9 _d5_ d3-',
- '<a href="/v17">v17</a> text dds16v21 more text1 <a href="/v9">v9</a> _d5_ d3-',
- 'v17 text dds16v21 more text1 v9 _d5_ d3-',
+ 'v17 text dds16v21 more text1 v9 _d5_ d3- m10',
+ '<a href="/v17">v17</a> text dds16v21 more text1 <a href="/v9">v9</a> _d5_ d3- m10',
+ 'v17 text dds16v21 more text1 v9 _d5_ d3- m10',
# https://vndb.org/t2520.233
'[From[url=http://densetsu.com/display.php?id=468&style=alphabetical] Anime Densetsu[/url]]',
@@ -130,17 +143,17 @@ my @tests = (
'<tag>html escapes (&)</tag>',
'[spoiler]stray open tag',
- '<b class="spoiler">stray open tag</b>',
+ '<span class="spoiler">stray open tag</span>',
'',
# TODO: This isn't ideal
'[quote][spoiler]stray open tag (nested)[/quote]',
- '<div class="quote"><b class="spoiler">stray open tag (nested)[/quote]</b></div>',
- '',
+ '<div class="quote"><span class="spoiler">stray open tag (nested)[/quote]</span></div>',
+ '""',
'[quote][spoiler]two stray open tags',
- '<div class="quote"><b class="spoiler">two stray open tags</b></div>',
- '',
+ '<div class="quote"><span class="spoiler">two stray open tags</span></div>',
+ '""',
"[url=https://cat.xyz/]that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination[/url]",
'<a href="https://cat.xyz/" rel="nofollow">that\'s [spoiler]some [quote]uncommon[/quote][/spoiler] combination</a>',
@@ -154,13 +167,21 @@ my @tests = (
#'<a href="http://[fedc:ba98:7654:3210:fedc:ba98:7654:3210]/some/path" rel="nofollow">link</a> (literal ipv6 address)',
# test shortening
- [ "[url=https://cat.xyz/]that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination[/url]", 10 ],
+ [ "[url=https://cat.xyz/]that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination[/url]", maxlength => 10 ],
'<a href="https://cat.xyz/" rel="nofollow">that\'s </a>',
- "that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination",
+ "that's ",
- [ "A https://blicky.net/ only takes 4 characters", 8 ],
+ [ "A https://blicky.net/ only takes 4 characters", maxlength => 8 ],
'A <a href="https://blicky.net/" rel="nofollow">link</a>',
- "A https://blicky.net/ only takes 4 characters",
+ "A https", # Yeah, uh... word boundary
+
+ [ 'vndbids only [url=/v9]nothing[/url] [b] [spoiler]p5', idonly => 1 ],
+ 'vndbids only [url=/v9]nothing[/url] [b] [spoiler]<a href="/p5">p5</a>',
+ 'vndbids only [url=/v9]nothing[/url] [b] [spoiler]p5',
+
+ [ 'this [spoiler]spoiler will be[/spoiler] kept', keepspoil => 1 ],
+ 'this spoiler will be kept',
+ 'this spoiler will be kept',
);
@@ -196,8 +217,8 @@ sub test {
my @arg = ref $input ? @$input : ($input);
(my $msg = $arg[0]) =~ s/\n/\\n/g;
is identity($arg[0]), $arg[0], "id: $msg";
- is bb2html(@arg), $html, "html: $msg";
- is bb2text($arg[0]), $plain, "plain: $msg";
+ is bb_format(@arg), $html, "html: $msg";
+ is bb_format(@arg, text => 1), $plain, "plain: $msg";
}
}
@@ -208,9 +229,9 @@ sub bench {
my $short = "Nobody ev3r v10 uses v5 so s1 many [url=https://blicky.net/]x[raw]y[/raw][/url] tags. ";
my $heavy = $short x100;
timethese(0, {
- short => sub { bb2html($short) },
- plain => sub { bb2html($plain) },
- heavy => sub { bb2html($heavy) },
+ short => sub { bb_format($short) },
+ plain => sub { bb_format($plain) },
+ heavy => sub { bb_format($heavy) },
});
# old:
# heavy: 3 wallclock secs ( 3.15 usr + 0.00 sys = 3.15 CPU) @ 357.46/s (n=1126)
diff --git a/util/test/imgproc-custom.pl b/util/test/imgproc-custom.pl
new file mode 100755
index 00000000..a71d83dd
--- /dev/null
+++ b/util/test/imgproc-custom.pl
@@ -0,0 +1,76 @@
+#!/usr/bin/perl
+
+# This script requires an imagemagick compiled with all image formats supported by imgproc-custom.
+
+use v5.28;
+use warnings;
+use Cwd 'abs_path';
+
+my $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/test/imgproc-custom\.pl$}{}; }
+
+use lib $ROOT.'/lib';
+use VNDB::Func;
+
+my $bin = ($ENV{VNDB_GEN} // 'gen').'/imgproc-custom';
+
+sub cmphash {
+ my($fn, $out, $hash) = @_;
+ my $outd = `$bin size fit 500 500 jpeg 1 <$fn 2>&1 >tst.jpg`;
+ chomp($outd);
+ my($hashd) = split / /, `sha1sum tst.jpg`;
+ die "Hash mismatch for $fn, got $hashd see tst.jpg\n" if $hash ne $hashd;
+ unlink 'tst.jpg';
+ die "Output mismatch for $fn, got $outd" if $out ne $outd;
+}
+
+sub cmpmagick {
+ my($fn, $arg, $size, $hash) = @_;
+ `convert -size $size $arg $fn`;
+ cmphash $fn, $size, $hash;
+ unlink $fn;
+}
+
+# Test pngs from http://www.schaik.com/pngsuite/
+
+# These hashes are likely to change with libvips / libjpeg versions, output
+# should be manually verified and the hashes updated in that case.
+cmphash 'util/test/basn4a08.png', '32x32', '62c4f502c6e8f13fe72cd511267616ea75724503';
+cmphash 'util/test/basn6a16.png', '32x32', 'f85f1bb196ad6f8c284370bcb74d5cd8b19fc432';
+
+# Triggers g_warning() output
+die if `$bin size <util/test/xd9n2c08.png 2>&1` !~ /Invalid IHDR data/;
+# Triggers vips_error_exit() output
+die if `$bin jpeg 5 <util/test/basn4a08.png 2>&1` !~ /write error/;
+
+# Large images are tested to see if extra memory or thread pool use triggers more unique system calls.
+# (it does, and yes it varies per input format)
+cmpmagick 'large.png', '"canvas:rgb(100,50,30)"', '5000x5000', 'c5f1d23d43f3ec42ce04a31ba67334c2b5f68ee2';
+
+cmpmagick 'large-lossless.webp', '"canvas:rgb(100,50,30)" -define webp:lossless=true', '5000x5000', 'c5f1d23d43f3ec42ce04a31ba67334c2b5f68ee2';
+cmpmagick 'large-lossy.webp', '"canvas:rgb(100,50,30)" -define webp:lossless=false', '5000x5000', 'e043021ad032a8dbfbb21bef373ea9e2851baf51';
+cmpmagick 'gray.webp', 'pattern:GRAY50 -colorspace GRAY -define webp:lossless=true', '32x32', '8de7aebd2d86572f9dc320886a3bc4cf59bb53ca';
+
+cmpmagick 'large.jpg', '"canvas:rgb(100,50,30)"', '5000x5000', '7a54b06bdf1b742c5a97f2a105de48da81f3b284';
+cmpmagick 'gray.jpg', 'pattern:GRAY50 -colorspace GRAY', '32x32', '13980f3168cdddbe193b445552dab40fa9afa0a1';
+cmpmagick 'cmyk.jpg', 'LOGO: -colorspace CMYK', '640x480', '3ff8566e661a0faef5a90d11195819983b595876';
+
+cmpmagick 'large.avif', '"canvas:rgb(100,50,30)"', '5000x5000', 'b42788bf491a9a73d30d58c3a3a843e219f36f91';
+
+cmpmagick 'large.jxl', '"canvas:rgb(100,50,30)"', '5000x5000', 'c5f1d23d43f3ec42ce04a31ba67334c2b5f68ee2';
+
+# TODO: Test metadata stripping?
+
+# Slow, dumb and somewhat comprehensive thumbnail size checks, it's important
+# that the dimensions match with imgsize().
+exit; # don't need to test this often
+for my $w (10, 50, 256, 400) {
+ for my $h (300..1000) {
+ `convert -size ${w}x$h 'canvas:rgb(0,0,0)' tst.png`;
+ my $dim = `$bin fit 256 300 size <tst.png 2>&1`;
+ unlink 'tst.png';
+ chomp($dim);
+ my $size = join 'x', imgsize $w, $h, 256, 300;
+ die "$dim != $size\n" if $dim ne $size;
+ }
+}
diff --git a/util/test/xd9n2c08.png b/util/test/xd9n2c08.png
new file mode 100644
index 00000000..2c3b91aa
--- /dev/null
+++ b/util/test/xd9n2c08.png
Binary files differ
diff --git a/util/unusedimages.pl b/util/unusedimages.pl
index b5eb3989..01678f77 100755
--- a/util/unusedimages.pl
+++ b/util/unusedimages.pl
@@ -14,93 +14,108 @@ use Cwd 'abs_path';
my $ROOT;
BEGIN { ($ROOT = abs_path $0) =~ s{/util/unusedimages\.pl$}{}; }
+$ENV{VNDB_VAR} //= 'var';
+
my $db = DBI->connect('dbi:Pg:dbname=vndb', 'vndb', undef, { RaiseError => 1 });
-my(%scr, %cv, %ch);
my $count = 0;
+my $dirmatch = '/(cv|ch|sf|st)(?:\.orig|\.t)?/';
+my $fnmatch = $dirmatch.'[0-9][0-9]/([1-9][0-9]{0,6})\.(?:jpg|webp|png|avif|jxl)?';
-my $fnmatch = qr{/(cv|ch|sf|st)/[0-9][0-9]/([0-9]+)\.jpg};
+my(%scr, %cv, %ch);
my %dir = (cv => \%cv, ch => \%ch, sf => \%scr, st => \%scr);
+
sub cleandb {
- my $cnt = $db->do(q{
- DELETE FROM screenshots s
- WHERE NOT EXISTS(SELECT 1 FROM vn_screenshots_hist WHERE scr = s.id)
- AND NOT EXISTS(SELECT 1 FROM vn_screenshots WHERE scr = s.id)
- });
- print "# Deleted unreferenced screenshots: $cnt\n";
+ # Delete all images from the `images` table that are not referenced from
+ # *anywhere* in the database, including old revisions and links found in
+ # comments, descriptions and docs.
+ # The 30 (100, in the case of screenshots) most recently uploaded images of
+ # each type are also kept because there's a good chance they will get
+ # referenced from somewhere, soon.
+ my $cnt = $db->do(q{
+ DELETE FROM images WHERE id IN(
+ SELECT id FROM images
+ WHERE id NOT IN(SELECT id FROM images WHERE id BETWEEN vndbid('ch',1) AND vndbid_max('ch') ORDER BY id DESC LIMIT 30)
+ AND id NOT IN(SELECT id FROM images WHERE id BETWEEN vndbid('cv',1) AND vndbid_max('cv') ORDER BY id DESC LIMIT 30)
+ AND id NOT IN(SELECT id FROM images WHERE id BETWEEN vndbid('sf',1) AND vndbid_max('sf') ORDER BY id DESC LIMIT 100)
+ EXCEPT
+ SELECT * FROM (
+ SELECT scr FROM vn_screenshots
+ UNION SELECT scr FROM vn_screenshots_hist
+ UNION SELECT image FROM vn WHERE image IS NOT NULL
+ UNION SELECT image FROM vn_hist WHERE image IS NOT NULL
+ UNION SELECT image FROM chars WHERE image IS NOT NULL
+ UNION SELECT image FROM chars_hist WHERE image IS NOT NULL
+ UNION (
+ SELECT vndbid(case when img[1] = 'st' then 'sf' else img[1] end, img[2]::int)
+ FROM ( SELECT content FROM docs
+ UNION ALL SELECT content FROM docs_hist
+ UNION ALL SELECT description FROM vn
+ UNION ALL SELECT description FROM vn_hist
+ UNION ALL SELECT description FROM chars
+ UNION ALL SELECT description FROM chars_hist
+ UNION ALL SELECT description FROM producers
+ UNION ALL SELECT description FROM producers_hist
+ UNION ALL SELECT notes FROM releases
+ UNION ALL SELECT notes FROM releases_hist
+ UNION ALL SELECT description FROM staff
+ UNION ALL SELECT description FROM staff_hist
+ UNION ALL SELECT description FROM tags
+ UNION ALL SELECT description FROM tags_hist
+ UNION ALL SELECT description FROM traits
+ UNION ALL SELECT description FROM traits_hist
+ UNION ALL SELECT comments FROM changes
+ UNION ALL SELECT msg FROM threads_posts
+ UNION ALL SELECT msg FROM reviews_posts
+ UNION ALL SELECT text FROM reviews
+ ) x(text), regexp_matches(text, '}.$fnmatch.q{', 'g') as y(img)
+ )
+ ) x
+ )
+ });
+ print "# Deleted unreferenced images: $cnt\n";
}
-sub addtxt {
- my $t = shift;
- while($t =~ m{$fnmatch}g) {
- $dir{$1}{$2} = 1;
- $count++;
- }
-}
-sub addtxtsql {
- my($name, $query) = @_;
- $count = 0;
- my $st = $db->prepare($query);
- $st->execute();
- while((my $txt = $st->fetch())) {
- addtxt $txt->[0];
- }
- print "# References in $name... $count\n";
-}
+sub addimagessql {
+ my $st = $db->prepare('SELECT vndbid_type(id), vndbid_num(id) FROM images');
+ $st->execute();
+ $count = 0;
+ while((my $num = $st->fetch())) {
+ $dir{$num->[0]}{$num->[1]} = 1;
+ $count++;
+ }
+ print "# Items in `images'... $count\n";
+};
-sub addnumsql {
- my($name, $tbl, $query) = @_;
- $count = 0;
- my $st = $db->prepare($query);
- $st->execute();
- while((my $num = $st->fetch())) {
- $tbl->{$num->[0]} = 1;
- $count++;
- }
- print "# Items in $name... $count\n";
-}
sub findunused {
- my $size = 0;
- $count = 0;
- my $left = 0;
- find {
- no_chdir => 1,
- follow => 1,
- wanted => sub {
- return if -d "$File::Find::name";
- if($File::Find::name !~ /($fnmatch)$/) {
- print "# Unknown file: $File::Find::name\n";
- return;
- }
- if(!$dir{$2}{$3}) {
- my $s = (-s $File::Find::name) / 1024;
- $size += $s;
- $count++;
- printf "rm '%s' # %d KiB, https://s.vndb.org%s\n", $File::Find::name, $s, $1
- } else {
- $left++;
- }
- }
- }, "$ROOT/static/cv", "$ROOT/static/ch", "$ROOT/static/sf", "$ROOT/static/st";
- printf "# Deleted %d files, left %d files, saved %d KiB\n", $count, $left, $size;
+ my $size = 0;
+ $count = 0;
+ my $left = 0;
+ find {
+ no_chdir => 1,
+ wanted => sub {
+ return if -d "$File::Find::name";
+ if($File::Find::name !~ /($fnmatch)$/) {
+ print "# Unknown file: $File::Find::name\n" if $File::Find::name =~ /$dirmatch/;
+ return;
+ }
+ if(!$dir{$2}{$3}) {
+ my $s = (-s $File::Find::name) / 1024;
+ $size += $s;
+ $count++;
+ printf "rm '%s' # %d KiB, https://s.vndb.org%s\n", $File::Find::name, $s, $1
+ } else {
+ $left++;
+ }
+ }
+ }, "$ENV{VNDB_VAR}/static";
+ printf "# Deleted %d files, left %d files, saved %d KiB\n", $count, $left, $size;
}
cleandb;
-addtxtsql 'Docs', 'SELECT content FROM docs UNION ALL SELECT content FROM docs_hist';
-addtxtsql 'VN descriptions', 'SELECT "desc" FROM vn UNION ALL SELECT "desc" FROM vn_hist';
-addtxtsql 'Character descriptions', 'SELECT "desc" FROM chars UNION ALL SELECT "desc" FROM chars_hist';
-addtxtsql 'Producer descriptions', 'SELECT "desc" FROM producers UNION ALL SELECT "desc" FROM producers_hist';
-addtxtsql 'Release descriptions', 'SELECT notes FROM releases UNION ALL SELECT notes FROM releases_hist';
-addtxtsql 'Staff descriptions', 'SELECT "desc" FROM staff UNION ALL SELECT "desc" FROM staff_hist';
-addtxtsql 'Tag descriptions', 'SELECT description FROM tags';
-addtxtsql 'Trait descriptions', 'SELECT description FROM traits';
-addtxtsql 'Change summaries', 'SELECT comments FROM changes';
-addtxtsql 'Posts', 'SELECT msg FROM threads_posts';
-addnumsql 'Screenshots', \%scr, 'SELECT id FROM screenshots';
-addnumsql 'VN images', \%cv, 'SELECT image FROM vn UNION ALL SELECT image from vn_hist';
-addnumsql 'Character images', \%ch, 'SELECT image FROM chars UNION ALL SELECT image from chars_hist';
+addimagessql;
findunused;
diff --git a/util/update-docs-html-cache.pl b/util/update-docs-html-cache.pl
new file mode 100755
index 00000000..8ef0696e
--- /dev/null
+++ b/util/update-docs-html-cache.pl
@@ -0,0 +1,16 @@
+#!/usr/bin/perl
+
+use v5.24;
+use warnings;
+use DBI;
+use Cwd 'abs_path';
+
+my $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/update-docs-html-cache\.pl$}{}; }
+use lib "$ROOT/lib";
+use VNDB::Func 'md2html';
+
+my $db = DBI->connect('dbi:Pg:dbname=vndb', 'vndb', undef, { RaiseError => 1, AutoCommit => 0 });
+$db->do('UPDATE docs_hist SET html = ? WHERE chid = ?', undef, md2html($_->[1]), $_->[0]) for $db->selectall_array('SELECT chid, content FROM docs_hist');
+$db->do('UPDATE docs SET html = ? WHERE id = ?', undef, md2html($_->[1]), $_->[0]) for $db->selectall_array('SELECT id, content FROM docs' );
+$db->commit;
diff --git a/util/updates/2020-02-06-docs-html-cache.sql b/util/updates/2020-02-06-docs-html-cache.sql
new file mode 100644
index 00000000..e7b1540e
--- /dev/null
+++ b/util/updates/2020-02-06-docs-html-cache.sql
@@ -0,0 +1,5 @@
+-- Run 'make' before this script
+-- Run 'util/update-docs-html-cache.pl' after this script
+ALTER TABLE docs ADD COLUMN html text;
+ALTER TABLE docs_hist ADD COLUMN html text;
+\i util/sql/editfunc.sql
diff --git a/util/updates/2020-02-09-tags-vn-notes.sql b/util/updates/2020-02-09-tags-vn-notes.sql
new file mode 100644
index 00000000..6e4e357b
--- /dev/null
+++ b/util/updates/2020-02-09-tags-vn-notes.sql
@@ -0,0 +1 @@
+ALTER TABLE tags_vn ADD COLUMN notes text NOT NULL DEFAULT '';
diff --git a/util/updates/2020-02-21-tags-vn-null-users.sql b/util/updates/2020-02-21-tags-vn-null-users.sql
new file mode 100644
index 00000000..4a645071
--- /dev/null
+++ b/util/updates/2020-02-21-tags-vn-null-users.sql
@@ -0,0 +1,8 @@
+ALTER TABLE tags_vn DROP CONSTRAINT tags_vn_pkey;
+ALTER TABLE tags_vn DROP CONSTRAINT tags_vn_uid_fkey;
+ALTER TABLE tags_vn ALTER COLUMN uid DROP NOT NULL;
+CREATE UNIQUE INDEX tags_vn_pkey ON tags_vn (tag,vid,uid);
+DROP INDEX tags_vn_uid;
+CREATE INDEX tags_vn_uid ON tags_vn (uid) WHERE uid IS NOT NULL;
+UPDATE tags_vn SET uid = 0 WHERE uid IN(0,1);
+ALTER TABLE tags_vn ADD CONSTRAINT tags_vn_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
diff --git a/util/updates/2020-03-06-images-table.sql b/util/updates/2020-03-06-images-table.sql
new file mode 100644
index 00000000..f3db7975
--- /dev/null
+++ b/util/updates/2020-03-06-images-table.sql
@@ -0,0 +1,58 @@
+CREATE TYPE image_type AS ENUM ('ch', 'cv', 'sf');
+CREATE TYPE image_id AS (itype image_type, id int);
+
+CREATE TABLE images (
+ id image_id NOT NULL PRIMARY KEY CHECK((id).id IS NOT NULL AND (id).itype IS NOT NULL),
+ width smallint, -- dimensions are only set for the 'sf' type (for now)
+ height smallint
+);
+
+BEGIN;
+SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
+
+INSERT INTO images (id, width, height)
+ SELECT ROW('sf', id)::image_id, width, height FROM screenshots
+UNION ALL
+ SELECT ROW('cv', image)::image_id, null, null FROM vn_hist WHERE image <> 0 GROUP BY image
+UNION ALL
+ SELECT ROW('ch', image)::image_id, null, null FROM chars_hist WHERE image <> 0 GROUP BY image;
+
+
+ALTER TABLE vn ALTER COLUMN image DROP NOT NULL;
+ALTER TABLE vn ALTER COLUMN image DROP DEFAULT;
+ALTER TABLE vn ALTER COLUMN image TYPE image_id USING CASE WHEN image = 0 THEN NULL ELSE ROW('cv', image)::image_id END;
+ALTER TABLE vn ADD CONSTRAINT vn_image_fkey FOREIGN KEY (image) REFERENCES images (id);
+ALTER TABLE vn ADD CONSTRAINT vn_image_check CHECK((image).itype = 'cv');
+ALTER TABLE vn_hist ALTER COLUMN image DROP NOT NULL;
+ALTER TABLE vn_hist ALTER COLUMN image DROP DEFAULT;
+ALTER TABLE vn_hist ALTER COLUMN image TYPE image_id USING CASE WHEN image = 0 THEN NULL ELSE ROW('cv', image)::image_id END;
+ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_image_fkey FOREIGN KEY (image) REFERENCES images (id);
+ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_image_check CHECK((image).itype = 'cv');
+
+ALTER TABLE vn_screenshots DROP CONSTRAINT vn_screenshots_scr_fkey;
+ALTER TABLE vn_screenshots ALTER COLUMN scr TYPE image_id USING CASE WHEN scr = 0 THEN NULL ELSE ROW('sf', scr)::image_id END;
+ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_scr_fkey FOREIGN KEY (scr) REFERENCES images (id);
+ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_scr_check CHECK((scr).itype = 'sf');
+ALTER TABLE vn_screenshots_hist DROP CONSTRAINT vn_screenshots_hist_scr_fkey;
+ALTER TABLE vn_screenshots_hist ALTER COLUMN scr TYPE image_id USING CASE WHEN scr = 0 THEN NULL ELSE ROW('sf', scr)::image_id END;
+ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_scr_fkey FOREIGN KEY (scr) REFERENCES images (id);
+ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_scr_check CHECK((scr).itype = 'sf');
+
+ALTER TABLE chars ALTER COLUMN image DROP NOT NULL;
+ALTER TABLE chars ALTER COLUMN image DROP DEFAULT;
+ALTER TABLE chars ALTER COLUMN image TYPE image_id USING CASE WHEN image = 0 THEN NULL ELSE ROW('ch', image)::image_id END;
+ALTER TABLE chars ADD CONSTRAINT chars_image_fkey FOREIGN KEY (image) REFERENCES images (id);
+ALTER TABLE chars ADD CONSTRAINT chars_image_check CHECK((image).itype = 'ch');
+ALTER TABLE chars_hist ALTER COLUMN image DROP NOT NULL;
+ALTER TABLE chars_hist ALTER COLUMN image DROP DEFAULT;
+ALTER TABLE chars_hist ALTER COLUMN image TYPE image_id USING CASE WHEN image = 0 THEN NULL ELSE ROW('ch', image)::image_id END;
+ALTER TABLE chars_hist ADD CONSTRAINT chars_hist_image_fkey FOREIGN KEY (image) REFERENCES images (id);
+ALTER TABLE chars_hist ADD CONSTRAINT chars_hist_image_check CHECK((image).itype = 'ch');
+
+COMMIT;
+
+CREATE SEQUENCE screenshots_seq;
+SELECT setval('screenshots_seq', nextval('screenshots_id_seq'));
+DROP TABLE screenshots;
+
+\i util/sql/perms.sql
diff --git a/util/updates/2020-03-12-image-sizes.pl b/util/updates/2020-03-12-image-sizes.pl
new file mode 100755
index 00000000..2855c581
--- /dev/null
+++ b/util/updates/2020-03-12-image-sizes.pl
@@ -0,0 +1,26 @@
+#!/usr/bin/perl
+
+use v5.26;
+use warnings;
+use DBI;
+use Image::Magick;
+
+my $db = DBI->connect('dbi:Pg:dbname=vndb', 'vndb', undef, { RaiseError => 1, AutoCommit => 0 });
+
+my $upd = $db->prepare('UPDATE images SET width = ?, height = ? WHERE id = ?::image_id');
+
+for my $id ($db->selectcol_arrayref('SELECT id FROM images WHERE width IS NULL')->@*) {
+ my($t,$n) = $id =~ /\(([a-z]+),([0-9]+)\)/;
+ my $f = sprintf 'static/%s/%02d/%d.jpg', $t, $n%100, $n;
+ my $im = Image::Magick->new;
+ my $e = $im->Read($f);
+ warn "$f: $e\n" if $e;
+ $upd->execute($im->Get('width'), $im->Get('height'), $id) if !$e;
+}
+
+# A few images have been permanently deleted, that's alright, not being used anyway.
+$db->do('UPDATE images SET width = 0, height = 0 WHERE width IS NULL');
+
+$db->do('ALTER TABLE images ALTER COLUMN width SET NOT NULL');
+$db->do('ALTER TABLE images ALTER COLUMN height SET NOT NULL');
+$db->commit;
diff --git a/util/updates/2020-03-13-image-flagging.sql b/util/updates/2020-03-13-image-flagging.sql
new file mode 100644
index 00000000..d106af1c
--- /dev/null
+++ b/util/updates/2020-03-13-image-flagging.sql
@@ -0,0 +1,30 @@
+ALTER TABLE images ADD COLUMN c_votecount integer NOT NULL DEFAULT 0;
+ALTER TABLE images ADD COLUMN c_sexual_avg float;
+ALTER TABLE images ADD COLUMN c_sexual_stddev float;
+ALTER TABLE images ADD COLUMN c_violence_avg float;
+ALTER TABLE images ADD COLUMN c_violence_stddev float;
+ALTER TABLE images ADD COLUMN c_weight float NOT NULL DEFAULT 0;
+
+CREATE TABLE image_votes (
+ id image_id NOT NULL,
+ uid integer,
+ sexual smallint NOT NULL CHECK(sexual >= 0 AND sexual <= 2),
+ violence smallint NOT NULL CHECK(violence >= 0 AND violence <= 2),
+ date timestamptz NOT NULL DEFAULT NOW()
+);
+
+CREATE UNIQUE INDEX image_votes_pkey ON image_votes (uid, id);
+CREATE INDEX image_votes_id ON image_votes (id);
+ALTER TABLE image_votes ADD CONSTRAINT image_votes_id_fkey FOREIGN KEY (id) REFERENCES images (id);
+
+-- These significantly speed up the update_image_cache() and reverse image search on the flagging UI
+CREATE INDEX vn_image ON vn (image);
+CREATE INDEX vn_screenshots_scr ON vn_screenshots (scr);
+CREATE INDEX chars_image ON chars (image);
+
+\i util/sql/func.sql
+\i util/sql/triggers.sql
+\i util/sql/perms.sql
+
+\timing
+select update_images_cache(NULL);
diff --git a/util/updates/2020-03-17-imgvote-permission.sql b/util/updates/2020-03-17-imgvote-permission.sql
new file mode 100644
index 00000000..6749cab8
--- /dev/null
+++ b/util/updates/2020-03-17-imgvote-permission.sql
@@ -0,0 +1,3 @@
+ALTER TABLE users ALTER COLUMN perm SET DEFAULT 1 + 4 + 8 + 16;
+-- Give the 'imgvote' permission to everyone who has the 'edit' permission.
+UPDATE users SET perm = perm | (CASE WHEN perm & 4 = 4 THEN 8 ELSE 0 END);
diff --git a/util/updates/2020-03-23-release-extlinks.sql b/util/updates/2020-03-23-release-extlinks.sql
new file mode 100644
index 00000000..54d1e89e
--- /dev/null
+++ b/util/updates/2020-03-23-release-extlinks.sql
@@ -0,0 +1,9 @@
+ALTER TABLE releases ADD COLUMN l_toranoana bigint NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_toranoana bigint NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_melonjp integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_melonjp integer NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_gamejolt integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_gamejolt integer NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_nutaku text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_nutaku text NOT NULL DEFAULT '';
+\i util/sql/editfunc.sql
diff --git a/util/updates/2020-03-26-users-imgvotes.sql b/util/updates/2020-03-26-users-imgvotes.sql
new file mode 100644
index 00000000..0c5f1399
--- /dev/null
+++ b/util/updates/2020-03-26-users-imgvotes.sql
@@ -0,0 +1,6 @@
+ALTER TABLE users ADD COLUMN c_imgvotes integer NOT NULL DEFAULT 0;
+
+UPDATE users SET c_imgvotes = (SELECT COUNT(*) FROM image_votes WHERE uid = users.id);
+
+\i util/sql/triggers.sql
+\i util/sql/perms.sql
diff --git a/util/updates/2020-04-02-releases-original-title-size.sql b/util/updates/2020-04-02-releases-original-title-size.sql
new file mode 100644
index 00000000..e59dbbe2
--- /dev/null
+++ b/util/updates/2020-04-02-releases-original-title-size.sql
@@ -0,0 +1,2 @@
+alter table releases alter column title type varchar(300);
+alter table releases_hist alter column title type varchar(300);
diff --git a/util/updates/2020-04-05-vndbid-for-images.sql b/util/updates/2020-04-05-vndbid-for-images.sql
new file mode 100644
index 00000000..df85313b
--- /dev/null
+++ b/util/updates/2020-04-05-vndbid-for-images.sql
@@ -0,0 +1,51 @@
+-- Make sure to import sql/vndbid.sql before running this script.
+
+ALTER TABLE chars DROP CONSTRAINT chars_image_fkey;
+ALTER TABLE chars_hist DROP CONSTRAINT chars_hist_image_fkey;
+ALTER TABLE image_votes DROP CONSTRAINT image_votes_id_fkey;
+ALTER TABLE vn DROP CONSTRAINT vn_image_fkey;
+ALTER TABLE vn_hist DROP CONSTRAINT vn_hist_image_fkey;
+ALTER TABLE vn_screenshots DROP CONSTRAINT vn_screenshots_scr_fkey;
+ALTER TABLE vn_screenshots_hist DROP CONSTRAINT vn_screenshots_hist_scr_fkey;
+
+ALTER TABLE chars DROP CONSTRAINT chars_image_check;
+ALTER TABLE chars_hist DROP CONSTRAINT chars_hist_image_check;
+ALTER TABLE images DROP CONSTRAINT images_id_check;
+ALTER TABLE vn DROP CONSTRAINT vn_image_check;
+ALTER TABLE vn_hist DROP CONSTRAINT vn_hist_image_check;
+ALTER TABLE vn_screenshots DROP CONSTRAINT vn_screenshots_scr_check;
+ALTER TABLE vn_screenshots_hist DROP CONSTRAINT vn_screenshots_hist_scr_check;
+
+ALTER TABLE chars ALTER COLUMN image TYPE vndbid USING vndbid((image).itype::text, (image).id);
+ALTER TABLE chars_hist ALTER COLUMN image TYPE vndbid USING vndbid((image).itype::text, (image).id);
+ALTER TABLE images ALTER COLUMN id TYPE vndbid USING vndbid((id).itype::text, (id).id);
+ALTER TABLE image_votes ALTER COLUMN id TYPE vndbid USING vndbid((id).itype::text, (id).id);
+ALTER TABLE vn ALTER COLUMN image TYPE vndbid USING vndbid((image).itype::text, (image).id);
+ALTER TABLE vn_hist ALTER COLUMN image TYPE vndbid USING vndbid((image).itype::text, (image).id);
+ALTER TABLE vn_screenshots ALTER COLUMN scr TYPE vndbid USING vndbid((scr).itype::text, (scr).id);
+ALTER TABLE vn_screenshots_hist ALTER COLUMN scr TYPE vndbid USING vndbid((scr).itype::text, (scr).id);
+
+ALTER TABLE chars ADD CONSTRAINT chars_image_check CHECK(vndbid_type(image) = 'ch');
+ALTER TABLE chars_hist ADD CONSTRAINT chars_hist_image_check CHECK(vndbid_type(image) = 'ch');
+ALTER TABLE images ADD CONSTRAINT images_id_check CHECK(vndbid_type(id) IN('ch', 'cv', 'sf'));
+ALTER TABLE vn ADD CONSTRAINT vn_image_check CHECK(vndbid_type(image) = 'cv');
+ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_image_check CHECK(vndbid_type(image) = 'cv');
+ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_scr_check CHECK(vndbid_type(scr) = 'sf');
+ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_scr_check CHECK(vndbid_type(scr) = 'sf');
+
+ALTER TABLE chars ADD CONSTRAINT chars_image_fkey FOREIGN KEY (image) REFERENCES images (id);
+ALTER TABLE chars_hist ADD CONSTRAINT chars_hist_image_fkey FOREIGN KEY (image) REFERENCES images (id);
+ALTER TABLE image_votes ADD CONSTRAINT image_votes_id_fkey FOREIGN KEY (id) REFERENCES images (id) ON DELETE CASCADE;
+ALTER TABLE vn ADD CONSTRAINT vn_image_fkey FOREIGN KEY (image) REFERENCES images (id);
+ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_image_fkey FOREIGN KEY (image) REFERENCES images (id);
+ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_scr_fkey FOREIGN KEY (scr) REFERENCES images (id);
+ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_scr_fkey FOREIGN KEY (scr) REFERENCES images (id);
+
+DROP FUNCTION update_images_cache(image_id);
+
+\i sql/func.sql
+
+DROP TYPE image_id;
+DROP TYPE image_type;
+
+ANALYZE images, image_votes;
diff --git a/util/updates/2020-04-06-drop-relgraphs.sql b/util/updates/2020-04-06-drop-relgraphs.sql
new file mode 100644
index 00000000..b9edcf6d
--- /dev/null
+++ b/util/updates/2020-04-06-drop-relgraphs.sql
@@ -0,0 +1,9 @@
+DROP TRIGGER vn_relgraph_notify ON vn;
+DROP FUNCTION vn_relgraph_notify();
+
+DROP TRIGGER producer_relgraph_notify ON producers;
+DROP FUNCTION producer_relgraph_notify();
+
+ALTER TABLE vn DROP COLUMN rgraph;
+ALTER TABLE producers DROP COLUMN rgraph;
+DROP TABLE relgraphs;
diff --git a/util/updates/2020-04-09-stats-cleanup.sql b/util/updates/2020-04-09-stats-cleanup.sql
new file mode 100644
index 00000000..ccd87569
--- /dev/null
+++ b/util/updates/2020-04-09-stats-cleanup.sql
@@ -0,0 +1,10 @@
+DROP TRIGGER stats_cache_new ON threads;
+DROP TRIGGER stats_cache_edit ON threads;
+DROP TRIGGER stats_cache_new ON threads_posts;
+DROP TRIGGER stats_cache_edit ON threads_posts;
+DROP TRIGGER stats_cache ON users;
+
+DELETE FROM stats_cache WHERE section IN('users', 'threads', 'threads_posts');
+
+\i sql/triggers.sql
+\i sql/func.sql
diff --git a/util/updates/2020-04-15-users-permflags.sql b/util/updates/2020-04-15-users-permflags.sql
new file mode 100644
index 00000000..f33daf25
--- /dev/null
+++ b/util/updates/2020-04-15-users-permflags.sql
@@ -0,0 +1,26 @@
+ALTER TABLE users ADD COLUMN perm_board boolean NOT NULL DEFAULT true;
+ALTER TABLE users ADD COLUMN perm_boardmod boolean NOT NULL DEFAULT false;
+ALTER TABLE users ADD COLUMN perm_dbmod boolean NOT NULL DEFAULT false;
+ALTER TABLE users ADD COLUMN perm_edit boolean NOT NULL DEFAULT true;
+ALTER TABLE users ADD COLUMN perm_imgvote boolean NOT NULL DEFAULT true;
+ALTER TABLE users ADD COLUMN perm_tag boolean NOT NULL DEFAULT true;
+ALTER TABLE users ADD COLUMN perm_tagmod boolean NOT NULL DEFAULT false;
+ALTER TABLE users ADD COLUMN perm_usermod boolean NOT NULL DEFAULT false;
+
+UPDATE users SET
+ perm_board = (perm & 1) > 0,
+ perm_boardmod = (perm & 2) > 0,
+ perm_dbmod = (perm & 32) > 0,
+ perm_edit = (perm & 4) > 0,
+ perm_imgvote = (perm & 8) > 0,
+ perm_tag = (perm & 16) > 0,
+ perm_tagmod = (perm & 64) > 0,
+ perm_usermod = (perm & 128) > 0;
+
+ALTER TABLE users DROP COLUMN perm;
+ALTER TABLE users DROP COLUMN hide_list;
+
+DROP FUNCTION user_setperm(integer, integer, bytea, integer);
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2020-04-16-imgflag-user-deletion.sql b/util/updates/2020-04-16-imgflag-user-deletion.sql
new file mode 100644
index 00000000..f8f21ac1
--- /dev/null
+++ b/util/updates/2020-04-16-imgflag-user-deletion.sql
@@ -0,0 +1,4 @@
+ALTER TABLE image_votes ADD CONSTRAINT image_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+
+DROP TRIGGER image_votes_cache ON image_votes;
+\i sql/triggers.sql
diff --git a/util/updates/2020-04-25-imgflag-overrule.sql b/util/updates/2020-04-25-imgflag-overrule.sql
new file mode 100644
index 00000000..19f8d8a5
--- /dev/null
+++ b/util/updates/2020-04-25-imgflag-overrule.sql
@@ -0,0 +1,4 @@
+ALTER TABLE image_votes ADD COLUMN ignore boolean NOT NULL DEFAULT false;
+DROP TRIGGER image_votes_cache2 ON image_votes;
+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();
+\i sql/func.sql
diff --git a/util/updates/2020-04-26-perm-imgmod.sql b/util/updates/2020-04-26-perm-imgmod.sql
new file mode 100644
index 00000000..dabe3dfd
--- /dev/null
+++ b/util/updates/2020-04-26-perm-imgmod.sql
@@ -0,0 +1,3 @@
+ALTER TABLE users ADD COLUMN perm_imgmod boolean NOT NULL DEFAULT false;
+UPDATE users SET perm_imgmod = perm_dbmod;
+\i sql/perms.sql
diff --git a/util/updates/2020-04-27-audit-logging.sql b/util/updates/2020-04-27-audit-logging.sql
new file mode 100644
index 00000000..47a99ce9
--- /dev/null
+++ b/util/updates/2020-04-27-audit-logging.sql
@@ -0,0 +1,11 @@
+CREATE TABLE audit_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ by_uid integer,
+ by_name text,
+ by_ip inet NOT NULL,
+ affected_uid integer,
+ affected_name text,
+ action text NOT NULL,
+ detail text
+);
+\i sql/perms.sql
diff --git a/util/updates/2020-05-11-imgflag-preferences.sql b/util/updates/2020-05-11-imgflag-preferences.sql
new file mode 100644
index 00000000..98fbc1c5
--- /dev/null
+++ b/util/updates/2020-05-11-imgflag-preferences.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users ADD COLUMN max_sexual smallint NOT NULL DEFAULT 0;
+ALTER TABLE users ADD COLUMN max_violence smallint NOT NULL DEFAULT 0;
+UPDATE users SET max_sexual = 2, max_violence = 2 WHERE show_nsfw;
+\i sql/perms.sql
diff --git a/util/updates/2020-06-04-vn-ranking-cache.sql b/util/updates/2020-06-04-vn-ranking-cache.sql
new file mode 100644
index 00000000..3eb02392
--- /dev/null
+++ b/util/updates/2020-06-04-vn-ranking-cache.sql
@@ -0,0 +1,4 @@
+ALTER TABLE vn ADD COLUMN c_pop_rank integer;
+ALTER TABLE vn ADD COLUMN c_rat_rank integer;
+\i sql/func.sql
+select update_vnvotestats();
diff --git a/util/updates/2020-06-13-spoil-gender.sql b/util/updates/2020-06-13-spoil-gender.sql
new file mode 100644
index 00000000..d09cd4d9
--- /dev/null
+++ b/util/updates/2020-06-13-spoil-gender.sql
@@ -0,0 +1,4 @@
+-- Run 'make' first to update editfunc.sql
+ALTER TABLE chars ADD COLUMN spoil_gender gender;
+ALTER TABLE chars_hist ADD COLUMN spoil_gender gender;
+\i sql/editfunc.sql
diff --git a/util/updates/2020-06-15-custom-resolutions.sql b/util/updates/2020-06-15-custom-resolutions.sql
new file mode 100644
index 00000000..f8b3ed0d
--- /dev/null
+++ b/util/updates/2020-06-15-custom-resolutions.sql
@@ -0,0 +1,39 @@
+ALTER TABLE releases ADD COLUMN reso_x smallint NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN reso_y smallint NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN reso_x smallint NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN reso_y smallint NOT NULL DEFAULT 0;
+
+CREATE FUNCTION tmp_convert_resolution(resolution) RETURNS TABLE (x smallint, y smallint) AS $$
+ SELECT a[1], a[2] FROM (SELECT CASE
+ WHEN $1 = 'nonstandard' THEN '{0,1}'::smallint[]
+ WHEN $1 = '640x480' THEN '{640,480}'
+ WHEN $1 = '800x600' THEN '{800,600}'
+ WHEN $1 = '1024x768' THEN '{1024,768}'
+ WHEN $1 = '1280x960' THEN '{1280,960}'
+ WHEN $1 = '1600x1200' THEN '{1600,1200}'
+ WHEN $1 = '640x400' THEN '{640,400}'
+ WHEN $1 = '960x600' THEN '{960,600}'
+ WHEN $1 = '960x640' THEN '{960,640}'
+ WHEN $1 = '1024x576' THEN '{1024,576}'
+ WHEN $1 = '1024x600' THEN '{1024,600}'
+ WHEN $1 = '1024x640' THEN '{1024,640}'
+ WHEN $1 = '1280x720' THEN '{1280,720}'
+ WHEN $1 = '1280x800' THEN '{1280,800}'
+ WHEN $1 = '1366x768' THEN '{1366,768}'
+ WHEN $1 = '1600x900' THEN '{1600,900}'
+ WHEN $1 = '1920x1080' THEN '{1920,1080}'
+ ELSE '{0,0}' END
+ ) a(a)
+$$ LANGUAGE SQL;
+
+UPDATE releases SET (reso_x, reso_y) = (SELECT * FROM tmp_convert_resolution(resolution));
+UPDATE releases_hist SET (reso_x, reso_y) = (SELECT * FROM tmp_convert_resolution(resolution));
+
+DROP FUNCTION tmp_convert_resolution(resolution);
+
+ALTER TABLE releases DROP COLUMN resolution;
+ALTER TABLE releases_hist DROP COLUMN resolution;
+
+\i sql/editfunc.sql
+
+DROP TYPE resolution;
diff --git a/util/updates/2020-07-23-reports.sql b/util/updates/2020-07-23-reports.sql
new file mode 100644
index 00000000..1738fd72
--- /dev/null
+++ b/util/updates/2020-07-23-reports.sql
@@ -0,0 +1,19 @@
+CREATE TYPE report_status AS ENUM ('new', 'busy', 'done', 'dismissed');
+CREATE TYPE report_type AS ENUM ('t');
+
+CREATE TABLE reports (
+ id SERIAL PRIMARY KEY,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ lastmod timestamptz,
+ uid integer, -- user who created the report, if logged in
+ ip inet, -- IP address of the visitor, if not logged in
+ reason text NOT NULL,
+ rtype report_type NOT NULL,
+ status report_status NOT NULL DEFAULT 'new',
+ object text NOT NULL, -- The id of the thing being reported
+ message text NOT NULL,
+ log text NOT NULL DEFAULT ''
+);
+CREATE INDEX reports_status ON reports (status,id);
+
+\i sql/perms.sql
diff --git a/util/updates/2020-07-25-report-db.sql b/util/updates/2020-07-25-report-db.sql
new file mode 100644
index 00000000..54dbd03d
--- /dev/null
+++ b/util/updates/2020-07-25-report-db.sql
@@ -0,0 +1,2 @@
+ALTER TYPE report_type ADD VALUE 'db' AFTER 't';
+
diff --git a/util/updates/2020-07-29-reports-last-seen.sql b/util/updates/2020-07-29-reports-last-seen.sql
new file mode 100644
index 00000000..e38c5f54
--- /dev/null
+++ b/util/updates/2020-07-29-reports-last-seen.sql
@@ -0,0 +1,5 @@
+ALTER TABLE users ADD COLUMN last_reports timestamptz;
+DROP INDEX reports_status;
+CREATE INDEX reports_new ON reports (date) WHERE status = 'new';
+CREATE INDEX reports_lastmod ON reports (lastmod);
+\i sql/perms.sql
diff --git a/util/updates/2020-08-07-schema-sync.sql b/util/updates/2020-08-07-schema-sync.sql
new file mode 100644
index 00000000..9e6229da
--- /dev/null
+++ b/util/updates/2020-08-07-schema-sync.sql
@@ -0,0 +1,14 @@
+-- The credit_type definition used in production was... wrong.
+-- It had more values than in the schema and values were ordered incorrectly.
+-- Redefine it with the proper definition.
+ALTER TYPE credit_type RENAME TO old_credit_type;
+CREATE TYPE credit_type AS ENUM ('scenario', 'chardesign', 'art', 'music', 'songs', 'director', 'staff');
+
+ALTER TABLE vn_staff ALTER COLUMN role DROP DEFAULT;
+ALTER TABLE vn_staff ALTER COLUMN role TYPE credit_type USING role::text::credit_type;
+ALTER TABLE vn_staff ALTER COLUMN role SET DEFAULT 'staff';
+ALTER TABLE vn_staff_hist ALTER COLUMN role DROP DEFAULT;
+ALTER TABLE vn_staff_hist ALTER COLUMN role TYPE credit_type USING role::text::credit_type;
+ALTER TABLE vn_staff_hist ALTER COLUMN role SET DEFAULT 'staff';
+
+DROP TYPE old_credit_type;
diff --git a/util/updates/2020-08-07-threads.sql b/util/updates/2020-08-07-threads.sql
new file mode 100644
index 00000000..ede9621b
--- /dev/null
+++ b/util/updates/2020-08-07-threads.sql
@@ -0,0 +1,46 @@
+-- * Convert thread identifiers to vndbids
+-- * Remove threads_poll_votes.tid
+-- * Add two ON DELETE CASCADE's
+-- * Replace threads.count with threads.c_(count,lastnum)
+
+ALTER TABLE threads_poll_votes DROP COLUMN tid;
+ALTER TABLE threads_poll_votes ADD PRIMARY KEY (optid,uid);
+
+ALTER TABLE threads_poll_options DROP CONSTRAINT threads_poll_options_tid_fkey;
+ALTER TABLE threads_poll_options ALTER COLUMN tid TYPE vndbid USING vndbid('t', tid);
+
+ALTER TABLE threads_boards DROP CONSTRAINT threads_boards_tid_fkey;
+ALTER TABLE threads_boards ALTER COLUMN tid DROP DEFAULT;
+ALTER TABLE threads_boards ALTER COLUMN tid TYPE vndbid USING vndbid('t', tid);
+
+ALTER TABLE threads DROP CONSTRAINT threads_id_fkey;
+ALTER TABLE threads_posts DROP CONSTRAINT threads_posts_tid_fkey;
+ALTER TABLE threads_posts ALTER COLUMN tid DROP DEFAULT;
+ALTER TABLE threads_posts ALTER COLUMN tid TYPE vndbid USING vndbid('t', tid);
+
+ALTER TABLE threads ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE threads ALTER COLUMN id TYPE vndbid USING vndbid('t', id);
+ALTER TABLE threads ALTER COLUMN id SET DEFAULT vndbid('t', nextval('threads_id_seq')::int);
+ALTER TABLE threads ADD CONSTRAINT threads_id_check CHECK(vndbid_type(id) = 't');
+
+ALTER TABLE threads ADD CONSTRAINT threads_id_fkey FOREIGN KEY (id, count) REFERENCES threads_posts (tid, num) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE threads_poll_options ADD CONSTRAINT threads_poll_options_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
+ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
+ALTER TABLE threads_boards ADD CONSTRAINT threads_boards_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
+
+ALTER TABLE threads DROP COLUMN count;
+ALTER TABLE threads ADD COLUMN c_count smallint NOT NULL DEFAULT 0; -- Number of non-hidden posts
+ALTER TABLE threads ADD COLUMN c_lastnum smallint NOT NULL DEFAULT 1; -- 'num' of the most recent non-hidden post
+
+ALTER TABLE threads_posts ALTER COLUMN num DROP DEFAULT;
+ALTER TABLE threads_posts ALTER COLUMN uid DROP DEFAULT;
+ALTER TABLE threads_posts ALTER COLUMN uid DROP NOT NULL;
+ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_first_nonhidden CHECK(num > 1 OR NOT hidden);
+
+UPDATE threads
+ SET c_count = (SELECT COUNT(*) FROM threads_posts WHERE NOT hidden AND tid = threads.id)
+ , c_lastnum = (SELECT MAX(num) FROM threads_posts WHERE NOT hidden AND tid = threads.id);
+
+UPDATE threads_posts SET uid = NULL WHERE uid = 0;
+
+\i sql/triggers.sql
diff --git a/util/updates/2020-08-17-reviews.sql b/util/updates/2020-08-17-reviews.sql
new file mode 100644
index 00000000..87ded565
--- /dev/null
+++ b/util/updates/2020-08-17-reviews.sql
@@ -0,0 +1,71 @@
+ALTER TABLE reports ADD COLUMN objectnum integer;
+UPDATE reports SET objectnum = regexp_replace(object, '^.+\.([0-9]+)$', '\1')::integer WHERE object LIKE '%.%';
+ALTER TABLE reports ALTER COLUMN object TYPE vndbid USING regexp_replace(object, '\.[0-9]+$','')::vndbid;
+ALTER TABLE reports DROP COLUMN rtype;
+DROP TYPE report_type;
+
+
+
+CREATE SEQUENCE reviews_seq;
+
+CREATE TABLE reviews (
+ id vndbid PRIMARY KEY DEFAULT vndbid('w', nextval('reviews_seq')::int) CONSTRAINT reviews_id_check CHECK(vndbid_type(id) = 'w'),
+ vid int NOT NULL,
+ uid int,
+ rid int,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ lastmod timestamptz,
+ summary text NOT NULL,
+ text text,
+ spoiler boolean NOT NULL
+);
+
+CREATE TABLE reviews_posts (
+ id vndbid NOT NULL,
+ num smallint NOT NULL,
+ uid integer,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ edited timestamptz,
+ hidden boolean NOT NULL DEFAULT FALSE,
+ msg text NOT NULL DEFAULT '',
+ PRIMARY KEY(id, num)
+);
+
+CREATE TABLE reviews_votes (
+ id vndbid NOT NULL,
+ uid int,
+ date timestamptz NOT NULL,
+ vote boolean NOT NULL -- true = upvote, false = downvote
+);
+
+CREATE UNIQUE INDEX reviews_vid_uid ON reviews (vid,uid);
+CREATE INDEX reviews_uid ON reviews (uid);
+CREATE UNIQUE INDEX reviews_votes_id_uid ON reviews_votes (id,uid);
+
+ALTER TABLE reviews ADD CONSTRAINT reviews_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id) ON DELETE CASCADE;
+ALTER TABLE reviews ADD CONSTRAINT reviews_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews ADD CONSTRAINT reviews_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews_posts ADD CONSTRAINT reviews_posts_id_fkey FOREIGN KEY (id) REFERENCES reviews (id) ON DELETE CASCADE;
+ALTER TABLE reviews_posts ADD CONSTRAINT reviews_posts_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews_votes ADD CONSTRAINT reviews_votes_id_fkey FOREIGN KEY (id) REFERENCES reviews (id) ON DELETE CASCADE;
+ALTER TABLE reviews_votes ADD CONSTRAINT reviews_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+
+ALTER TABLE users ADD COLUMN perm_review boolean NOT NULL DEFAULT false;
+UPDATE users SET perm_review = false WHERE not perm_dbmod;
+
+\i sql/perms.sql
+
+--c_votes int NOT NULL DEFAULT 0,
+--c_avg float
+--
+--CREATE OR REPLACE FUNCTION update_reviews_vote_cache() RETURNS trigger AS $$
+--BEGIN
+-- WITH stats(id,cnt,avg) AS (
+-- SELECT id, COUNT(*), AVG(vote::int) FROM reviews_votes WHERE id IN(OLD.id,NEW.id) GROUP BY id
+-- ) UPDATE reviews SET c_votes = cnt, c_avg = avg FROM stats WHERE reviews.id = stats.id;
+-- RETURN NULL;
+--END
+--$$ LANGUAGE plpgsql;
+--
+--CREATE TRIGGER reviews_votes_cache1 AFTER INSERT OR DELETE ON reviews_votes FOR EACH ROW EXECUTE PROCEDURE update_reviews_vote_cache();
+--CREATE TRIGGER reviews_votes_cache2 AFTER UPDATE ON reviews_votes FOR EACH ROW WHEN ((OLD.id, OLD.vote) IS DISTINCT FROM (NEW.id, NEW.vote)) EXECUTE PROCEDURE update_reviews_vote_cache();
diff --git a/util/updates/2020-08-19-reviews-caches.sql b/util/updates/2020-08-19-reviews-caches.sql
new file mode 100644
index 00000000..9c37808d
--- /dev/null
+++ b/util/updates/2020-08-19-reviews-caches.sql
@@ -0,0 +1,14 @@
+CREATE UNIQUE INDEX reviews_posts_uid ON reviews_posts (uid);
+
+ALTER TABLE reviews ADD COLUMN c_up int NOT NULL DEFAULT 0;
+ALTER TABLE reviews ADD COLUMN c_down int NOT NULL DEFAULT 0;
+ALTER TABLE reviews ADD COLUMN c_count smallint NOT NULL DEFAULT 0;
+ALTER TABLE reviews ADD COLUMN c_lastnum smallint;
+
+\i sql/func.sql
+\i sql/triggers.sql
+
+SELECT update_reviews_votes_cache(NULL);
+UPDATE reviews
+ SET c_count = COALESCE((SELECT COUNT(*) FROM reviews_posts WHERE NOT hidden AND id = reviews.id), 0)
+ , c_lastnum = (SELECT MAX(num) FROM reviews_posts WHERE NOT hidden AND id = reviews.id);
diff --git a/util/updates/2020-08-24-reviews-nosummary.sql b/util/updates/2020-08-24-reviews-nosummary.sql
new file mode 100644
index 00000000..7333a2fc
--- /dev/null
+++ b/util/updates/2020-08-24-reviews-nosummary.sql
@@ -0,0 +1,6 @@
+ALTER TABLE reviews ADD COLUMN isfull boolean NOT NULL DEFAULT false;
+UPDATE reviews SET isfull = text <> '';
+UPDATE reviews SET text = summary WHERE NOT isfull;
+UPDATE reviews SET text = summary || text WHERE isfull;
+ALTER TABLE reviews ALTER COLUMN isfull DROP DEFAULT;
+ALTER TABLE reviews DROP COLUMN summary;
diff --git a/util/updates/2020-08-25-reviews-fixups.sql b/util/updates/2020-08-25-reviews-fixups.sql
new file mode 100644
index 00000000..86f77b2a
--- /dev/null
+++ b/util/updates/2020-08-25-reviews-fixups.sql
@@ -0,0 +1,5 @@
+DROP INDEX reviews_posts_uid;
+CREATE INDEX reviews_posts_uid ON reviews_posts (uid);
+
+\i sql/func.sql
+\i sql/triggers.sql
diff --git a/util/updates/2020-09-03-reviews-flagging.sql b/util/updates/2020-09-03-reviews-flagging.sql
new file mode 100644
index 00000000..3cf8ee2c
--- /dev/null
+++ b/util/updates/2020-09-03-reviews-flagging.sql
@@ -0,0 +1,5 @@
+ALTER TABLE reviews ADD COLUMN c_flagged boolean NOT NULL DEFAULT false;
+ALTER TABLE reviews_votes ADD COLUMN overrule boolean NOT NULL DEFAULT false;
+
+\i sql/func.sql
+select update_reviews_votes_cache(null);
diff --git a/util/updates/2020-09-05-notifications.sql b/util/updates/2020-09-05-notifications.sql
new file mode 100644
index 00000000..1c3e6bd6
--- /dev/null
+++ b/util/updates/2020-09-05-notifications.sql
@@ -0,0 +1,16 @@
+ALTER TABLE notifications ALTER COLUMN iid TYPE vndbid USING vndbid(ltype::text, iid);
+ALTER TABLE notifications RENAME COLUMN subid TO num;
+ALTER TABLE notifications DROP COLUMN ltype;
+ALTER TABLE notifications ALTER COLUMN c_byuser DROP DEFAULT;
+ALTER TABLE notifications ALTER COLUMN c_byuser DROP NOT NULL;
+DROP TYPE notification_ltype;
+UPDATE notifications SET c_byuser = NULL WHERE c_byuser = 0;
+
+ALTER TABLE users ADD COLUMN notify_post boolean NOT NULL DEFAULT true;
+ALTER TABLE users ADD COLUMN notify_comment boolean NOT NULL DEFAULT true;
+ALTER TYPE notification_ntype ADD VALUE 'post' AFTER 'announce';
+ALTER TYPE notification_ntype ADD VALUE 'comment' AFTER 'post';
+
+\i sql/func.sql
+\i sql/triggers.sql
+\i sql/perms.sql
diff --git a/util/updates/2020-09-20-reviews-locked.sql b/util/updates/2020-09-20-reviews-locked.sql
new file mode 100644
index 00000000..ee66cf71
--- /dev/null
+++ b/util/updates/2020-09-20-reviews-locked.sql
@@ -0,0 +1 @@
+ALTER TABLE reviews ADD COLUMN locked boolean NOT NULL DEFAULT false;
diff --git a/util/updates/2020-10-08-extra-notifications.sql b/util/updates/2020-10-08-extra-notifications.sql
new file mode 100644
index 00000000..ef0f574b
--- /dev/null
+++ b/util/updates/2020-10-08-extra-notifications.sql
@@ -0,0 +1,45 @@
+-- Simplified triggers, all the logic is consolidated in notify().
+DROP TRIGGER notify_pm ON threads_posts;
+DROP TRIGGER notify_announce ON threads_posts;
+DROP FUNCTION notify_pm();
+DROP FUNCTION notify_announce();
+
+DROP FUNCTION notify_dbdel(dbentry_type, edit_rettype);
+DROP FUNCTION notify_dbedit(dbentry_type, edit_rettype);
+DROP FUNCTION notify_listdel(dbentry_type, edit_rettype);
+
+-- Table changes
+ALTER TABLE notifications ALTER COLUMN ntype TYPE notification_ntype[] USING ARRAY[ntype];
+ALTER TABLE notifications DROP COLUMN c_title;
+ALTER TABLE notifications DROP COLUMN c_byuser;
+
+DROP INDEX notifications_uid;
+CREATE INDEX notifications_uid_iid ON notifications (uid,iid);
+
+-- Merge duplicate notifications (dbdel & listdel could cause duplicates)
+UPDATE notifications n SET ntype = ntype || ARRAY['dbdel'::notification_ntype]
+ WHERE ntype = ARRAY['listdel'::notification_ntype]
+ AND EXISTS(SELECT 1 FROM notifications m WHERE m.id <> n.id AND m.uid = n.uid AND m.iid = n.iid AND m.num IS NOT DISTINCT FROM n.num AND m.ntype = ARRAY['dbdel'::notification_ntype]);
+DELETE FROM notifications n
+ WHERE ntype = ARRAY['dbdel'::notification_ntype]
+ AND EXISTS(SELECT 1 FROM notifications m WHERE m.id <> n.id AND m.uid = n.uid AND m.iid = n.iid AND m.num IS NOT DISTINCT FROM n.num AND m.ntype = ARRAY['listdel'::notification_ntype,'dbdel']);
+-- For some reason a few notifications from 2014 were duplicated, let's just get rid of those.
+DELETE FROM notifications n WHERE EXISTS(SELECT 1 FROM notifications m WHERE m.id <> n.id AND m.uid = n.uid AND m.iid = n.iid AND m.num IS NOT DISTINCT FROM n.num AND m.id > n.id);
+
+-- Subscriptions
+ALTER TYPE notification_ntype ADD VALUE 'subpost' AFTER 'comment';
+ALTER TYPE notification_ntype ADD VALUE 'subedit' AFTER 'subpost';
+ALTER TYPE notification_ntype ADD VALUE 'subreview' AFTER 'subedit';
+
+CREATE TABLE notification_subs (
+ uid integer NOT NULL,
+ iid vndbid NOT NULL,
+ subnum boolean,
+ subreview boolean NOT NULL DEFAULT false,
+ PRIMARY KEY(iid,uid)
+);
+ALTER TABLE notification_subs ADD CONSTRAINT notification_subs_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+
+\i sql/func.sql
+\i sql/triggers.sql
+\i sql/perms.sql
diff --git a/util/updates/2020-10-13-notifications-subapply.sql b/util/updates/2020-10-13-notifications-subapply.sql
new file mode 100644
index 00000000..e20ad2a6
--- /dev/null
+++ b/util/updates/2020-10-13-notifications-subapply.sql
@@ -0,0 +1,3 @@
+ALTER TYPE notification_ntype ADD VALUE 'subapply' AFTER 'subreview';
+ALTER TABLE notification_subs ADD COLUMN subapply boolean NOT NULL DEFAULT false;
+\i sql/func.sql
diff --git a/util/updates/2020-10-15-reviews-anonymous-votes.sql b/util/updates/2020-10-15-reviews-anonymous-votes.sql
new file mode 100644
index 00000000..721543f6
--- /dev/null
+++ b/util/updates/2020-10-15-reviews-anonymous-votes.sql
@@ -0,0 +1,4 @@
+ALTER TABLE reviews_votes ADD COLUMN ip inet;
+CREATE UNIQUE INDEX reviews_votes_id_ip ON reviews_votes (id,ip);
+\i sql/func.sql
+SELECT update_reviews_votes_cache(id) FROM reviews;
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);
diff --git a/util/updates/2020-12-14-release-extlinks.sql b/util/updates/2020-12-14-release-extlinks.sql
new file mode 100644
index 00000000..83da4083
--- /dev/null
+++ b/util/updates/2020-12-14-release-extlinks.sql
@@ -0,0 +1,46 @@
+ALTER TABLE releases ADD COLUMN l_animateg integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_animateg integer NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_freem integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_freem integer NOT NULL DEFAULT 0;
+-- I don't think I've actually seen app store IDs that didn't fit in an int, but they can get pretty close.
+ALTER TABLE releases ADD COLUMN l_appstore bigint NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_appstore bigint NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_googplay text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_googplay text NOT NULL DEFAULT '';
+\i sql/editfunc.sql
+
+
+CREATE OR REPLACE FUNCTION migrate_website_to_freem(rid integer) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid AND type = 'r'));
+ UPDATE edit_releases SET l_freem = regexp_replace(website, '^https?://(?:www\.)?freem\.ne\.jp/win/game/([0-9]+)$', '\1')::int, website = '';
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic conversion of website to Freem link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_website_to_freem(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:www\.)?freem\.ne\.jp/win/game/([0-9]+)$';
+DROP FUNCTION migrate_website_to_freem(integer);
+
+
+CREATE OR REPLACE FUNCTION migrate_website_to_googplay(rid integer) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid AND type = 'r'));
+ UPDATE edit_releases SET l_googplay = regexp_replace(website, '^https?://play\.google\.com/store/apps/details\?id=([^/&\?]+)(?:&.*)?$', '\1'), website = '';
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic conversion of website to Google Play store link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_website_to_googplay(id) FROM releases WHERE NOT hidden AND website ~ '^https?://play\.google\.com/store/apps/details\?id=([^/&\?]+)(?:&.*)?$';
+DROP FUNCTION migrate_website_to_googplay(integer);
+
+
+CREATE OR REPLACE FUNCTION migrate_website_to_appstore(rid integer) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid AND type = 'r'));
+ UPDATE edit_releases SET l_appstore = regexp_replace(website, '^https?://(?:itunes|apps)\.apple\.com/(?:[^/]+/)?app/(?:[^/]+/)?id([0-9]+)([\?/].*)?$', '\1')::bigint, website = '';
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic conversion of website to Apple App Store link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_website_to_appstore(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:itunes|apps)\.apple\.com/(?:[^/]+/)?app/(?:[^/]+/)?id([0-9]+)([\?/].*)?$';
+DROP FUNCTION migrate_website_to_appstore(integer);
diff --git a/util/updates/2020-12-15-release-extlinks.sql b/util/updates/2020-12-15-release-extlinks.sql
new file mode 100644
index 00000000..d554aa43
--- /dev/null
+++ b/util/updates/2020-12-15-release-extlinks.sql
@@ -0,0 +1,16 @@
+ALTER TABLE releases ADD COLUMN l_fakku text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_fakku text NOT NULL DEFAULT '';
+ALTER TABLE releases ADD COLUMN l_novelgam integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_novelgam integer NOT NULL DEFAULT 0;
+\i sql/editfunc.sql
+
+CREATE OR REPLACE FUNCTION migrate_website_to_novelgam(rid integer) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid AND type = 'r'));
+ UPDATE edit_releases SET l_novelgam = regexp_replace(website, '^https?://(?:www\.)?novelgame\.jp/games/show/([0-9]+)$', '\1')::int, website = '';
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic conversion of website to NovelGame link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_website_to_novelgam(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:www\.)?novelgame\.jp/games/show/([0-9]+)$';
+DROP FUNCTION migrate_website_to_novelgam(integer);
diff --git a/util/updates/2021-01-03-advsearch-saved-queries.sql b/util/updates/2021-01-03-advsearch-saved-queries.sql
new file mode 100644
index 00000000..89a6f844
--- /dev/null
+++ b/util/updates/2021-01-03-advsearch-saved-queries.sql
@@ -0,0 +1,10 @@
+CREATE TABLE saved_queries (
+ uid integer NOT NULL,
+ name text NOT NULL,
+ qtype dbentry_type NOT NULL,
+ query text NOT NULL, -- compact encoded form
+ PRIMARY KEY(uid, qtype, name)
+);
+
+ALTER TABLE saved_queries ADD CONSTRAINT saved_queries_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+GRANT SELECT, INSERT, UPDATE, DELETE ON saved_queries TO vndb_site;
diff --git a/util/updates/2021-01-10-advsearch-convert-saved-filters.pl b/util/updates/2021-01-10-advsearch-convert-saved-filters.pl
new file mode 100755
index 00000000..16e569b0
--- /dev/null
+++ b/util/updates/2021-01-10-advsearch-convert-saved-filters.pl
@@ -0,0 +1,46 @@
+#!/usr/bin/perl
+
+use v5.24;
+use warnings;
+use TUWF;
+use Cwd 'abs_path';
+
+my $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/updates/[^/]+.pl$}{}; }
+
+use lib $ROOT.'/lib';
+use VNDB::Config;
+
+BEGIN { TUWF::set %{ config->{tuwf} } };
+
+use VNWeb::AdvSearch;
+use VNWeb::Filters;
+
+for my $r (tuwf->dbAlli('SELECT id, filter_vn AS fil FROM users WHERE filter_vn <> \'\' AND NOT EXISTS(SELECT 1 FROM saved_queries WHERE uid = id AND name = \'\' AND qtype = \'v\') ORDER BY id')->@*) {
+ next if $r->{fil} =~ /^tagspoil-\d+$/;
+
+ # HACK: trick VNWeb code into thinking we're logged in as the user owning the filter.
+ tuwf->{_TUWF}{request_data}{auth} = bless { user => { user_id => $r->{id} } }, 'VNWeb::Auth';
+
+ my $q = eval { tuwf->compile({advsearch => 'v'})->validate(filter_vn_adv filter_parse v => $r->{fil})->data };
+ if(!$q) {
+ warn "Unable to convert VN filter for u$r->{id}, \"$r->{fil}\": $@";
+ next;
+ }
+ my $qs = $q->query_encode;
+ tuwf->dbExeci('INSERT INTO saved_queries', { uid => $r->{id}, qtype => 'v', name => '', query => $qs }) if $qs;
+}
+
+for my $r (tuwf->dbAlli('SELECT id, filter_release AS fil FROM users WHERE filter_release <> \'\' AND NOT EXISTS(SELECT 1 FROM saved_queries WHERE uid = id AND name = \'\' AND qtype = \'r\') ORDER BY id')->@*) {
+ tuwf->{_TUWF}{request_data}{auth} = bless { user => { user_id => $r->{id} } }, 'VNWeb::Auth';
+
+ my $q = eval { tuwf->compile({advsearch => 'r'})->validate(filter_release_adv filter_parse r => $r->{fil})->data };
+ if(!$q) {
+ warn "Unable to convert release filter for u$r->{id}, \"$r->{fil}\": $@";
+ next;
+ }
+ my $qs = $q->query_encode;
+ tuwf->dbExeci('INSERT INTO saved_queries', { uid => $r->{id}, qtype => 'r', name => '', query => $qs }) if $qs;
+}
+
+tuwf->dbCommit;
diff --git a/util/updates/2021-01-17-irish-language.sql b/util/updates/2021-01-17-irish-language.sql
new file mode 100644
index 00000000..4b35d174
--- /dev/null
+++ b/util/updates/2021-01-17-irish-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'ga' AFTER 'fr';
diff --git a/util/updates/2021-01-21-update-saved-queries.pl b/util/updates/2021-01-21-update-saved-queries.pl
new file mode 100755
index 00000000..f93d4643
--- /dev/null
+++ b/util/updates/2021-01-21-update-saved-queries.pl
@@ -0,0 +1,66 @@
+#!/usr/bin/perl
+
+# This script checks and updates all queries in the saved_queries table.
+
+use v5.24;
+use warnings;
+use Cwd 'abs_path';
+use TUWF;
+
+my $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/saved-queries\.pl$}{}; }
+
+use lib $ROOT.'/lib';
+use VNDB::Config;
+
+TUWF::set %{ config->{tuwf} };
+
+require VNWeb::AdvSearch;
+
+
+my($total, $updated, $err) = (0,0,0);
+
+for my $r (tuwf->dbAlli('SELECT uid, qtype, name, query FROM saved_queries')->@*) {
+ $total++;
+ my $q = eval { tuwf->compile({advsearch => $r->{qtype}})->validate($r->{query})->data };
+ if(!$q) {
+ $err++;
+ warn "Invalid query: $r->{uid}, $r->{qtype}, \"$r->{name}\": $r->{query}\n";
+ next;
+ }
+
+ # The old filter->advsearch conversion had a bug that caused length filters to get AND'ed together, which doesn't make sense.
+ if($r->{qtype} eq 'v' && !$r->{name} && $q->{query}[0] eq 'and') {
+ my @lengths = grep ref $_ && $_->[0] eq 'length', $q->{query}->@*;
+ $q->{query} = [ grep(!ref $_ || $_->[0] ne 'length', $q->{query}->@*), [ 'or', @lengths ] ] if @lengths > 1;
+ warn "Converted 'AND length' to 'OR length' for $r->{uid}\n" if @lengths > 1;
+ }
+
+ # "Unlabeled && !Unlabeled" used to mean "on my list" and was what the old filter conversions used.
+ # That meaning has changed and we now have a better on-list filter.
+ if($r->{qtype} eq 'v' && $q->{query}[0] eq 'and') {
+ my sub isonlist {
+ my $q = $_;
+ ref $q && $q->[0] eq 'or' && @$q == 3
+ && $q->[1][0] eq 'label' && $q->[1][1] eq '=' && ref $q->[1][2] && $q->[1][2][0] eq $r->{uid} && $q->[1][2][1] eq 0
+ && $q->[2][0] eq 'label' && $q->[2][1] eq '!=' && ref $q->[2][2] && $q->[2][2][0] eq $r->{uid} && $q->[2][2][1] eq 0
+ }
+ my $e=0;
+ $q->{query} = [ map isonlist($_) ? do { $e=1; [ 'on-list', '=', 1 ] } : $_, $q->{query}->@* ];
+ warn "Converted Unlabaled hack to on-list for $r->{uid}\n" if $e;
+ }
+
+ my $qs = $q->query_encode;
+ if(!$qs) {
+ warn "Empty query: $r->{uid}, $r->{qtype}, \"$r->{name}\": $r->{query}\n";
+ next;
+ }
+ if($qs ne $r->{query}) {
+ $updated++;
+ tuwf->dbExeci('UPDATE saved_queries SET query =', \$qs, 'WHERE', { uid => $r->{uid}, qtype => $r->{qtype}, name => $r->{name} });
+ }
+}
+
+tuwf->dbCommit;
+
+printf "Updated %d/%d saved queries, %d errors.\n", $updated, $total, $err;
diff --git a/util/updates/2021-01-30-vn-olang.sql b/util/updates/2021-01-30-vn-olang.sql
new file mode 100644
index 00000000..4f12c179
--- /dev/null
+++ b/util/updates/2021-01-30-vn-olang.sql
@@ -0,0 +1,35 @@
+ALTER TABLE vn ADD COLUMN olang language NOT NULL DEFAULT 'ja';
+ALTER TABLE vn_hist ADD COLUMN olang language NOT NULL DEFAULT 'ja';
+
+
+-- Initial original language: Use c_olang if it only has a single language,
+-- fall back to developer's language if there are multiple languages.
+-- (Based on the idea from https://vndb.org/t12800.23)
+-- There are still ~50 games for which that fails due to the lack of a
+-- developer entry, and ~20 games for which we have no releases at all.
+-- These will have to be updated manually.
+WITH dl(id, lang) AS (
+ SELECT rv.vid, MIN(p.lang)
+ FROM releases_vn rv
+ JOIN releases r ON r.id = rv.id
+ JOIN releases_producers rp ON rp.id = rv.id
+ JOIN producers p ON p.id = rp.pid
+ WHERE NOT p.hidden AND NOT r.hidden AND rp.developer
+ GROUP BY rv.vid
+), vl(id, hidden, lang) AS (
+ SELECT vn.id, vn.hidden, CASE WHEN array_length(vn.c_olang, 1) = 1 THEN vn.c_olang[1] ELSE dl.lang END
+ FROM vn
+ LEFT JOIN dl ON dl.id = vn.id
+) UPDATE vn SET olang = vl.lang FROM vl WHERE vn.id = vl.id AND vl.lang IS NOT NULL;
+--) SELECT 'https://vndb.org/v'||id FROM vl WHERE NOT hidden AND lang IS NULL ORDER BY id;
+
+-- Make sure vn_hist is consistent with vn.
+WITH ch(id, lang) AS (
+ SELECT c.id, v.olang
+ FROM changes c
+ JOIN vn v ON v.id = c.itemid
+ WHERE c.type = 'v'
+) UPDATE vn_hist SET olang = ch.lang FROM ch WHERE vn_hist.chid = ch.id;
+
+\i sql/editfunc.sql
+\i sql/func.sql
diff --git a/util/updates/2021-02-02-cleanup.sql b/util/updates/2021-02-02-cleanup.sql
new file mode 100644
index 00000000..9165e1df
--- /dev/null
+++ b/util/updates/2021-02-02-cleanup.sql
@@ -0,0 +1,8 @@
+ALTER TABLE threads DROP COLUMN poll_preview;
+ALTER TABLE threads DROP COLUMN poll_recast;
+ALTER TABLE users DROP COLUMN filter_vn;
+ALTER TABLE users DROP COLUMN filter_release;
+ALTER TABLE users DROP COLUMN show_nsfw;
+ALTER TABLE users DROP COLUMN vn_list_own;
+ALTER TABLE users DROP COLUMN vn_list_wish;
+ALTER TABLE vn DROP COLUMN c_olang;
diff --git a/util/updates/2021-02-08-user-lookup-by-mail.sql b/util/updates/2021-02-08-user-lookup-by-mail.sql
new file mode 100644
index 00000000..02d18a88
--- /dev/null
+++ b/util/updates/2021-02-08-user-lookup-by-mail.sql
@@ -0,0 +1,2 @@
+DROP FUNCTION user_emailexists(text, integer);
+\i sql/func.sql
diff --git a/util/updates/2021-02-13-uid-0.sql b/util/updates/2021-02-13-uid-0.sql
new file mode 100644
index 00000000..c3ccb328
--- /dev/null
+++ b/util/updates/2021-02-13-uid-0.sql
@@ -0,0 +1,11 @@
+-- columns that could still refer to uid=0
+ALTER TABLE changes ALTER COLUMN requester DROP DEFAULT;
+ALTER TABLE changes ALTER COLUMN requester DROP NOT NULL;
+UPDATE changes SET requester = NULL WHERE requester = 0;
+ALTER TABLE tags ALTER COLUMN addedby DROP DEFAULT;
+ALTER TABLE tags ALTER COLUMN addedby DROP NOT NULL;
+UPDATE tags SET addedby = NULL WHERE addedby = 0;
+ALTER TABLE traits ALTER COLUMN addedby DROP DEFAULT;
+ALTER TABLE traits ALTER COLUMN addedby DROP NOT NULL;
+UPDATE traits SET addedby = NULL WHERE addedby = 0;
+DELETE FROM users WHERE id = 0;
diff --git a/util/updates/2021-02-22-tableopts-char.sql b/util/updates/2021-02-22-tableopts-char.sql
new file mode 100644
index 00000000..af5f5168
--- /dev/null
+++ b/util/updates/2021-02-22-tableopts-char.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users ADD COLUMN tableopts_c int;
+\i sql/perms.sql
diff --git a/util/updates/2021-03-01-entries-to-vndbid.sql b/util/updates/2021-03-01-entries-to-vndbid.sql
new file mode 100644
index 00000000..29669bea
--- /dev/null
+++ b/util/updates/2021-03-01-entries-to-vndbid.sql
@@ -0,0 +1,245 @@
+-- Public dump breakage:
+-- SELECT .. FROM vn WHERE id = 10;
+-- SELECT .. FROM vn WHERE id IN(1,2,3);
+-- SELECT 'https://vndb.org/v'||id FROM vn;
+
+BEGIN;
+
+ALTER TABLE changes DROP CONSTRAINT changes_requester_fkey;
+ALTER TABLE chars DROP CONSTRAINT chars_main_fkey;
+ALTER TABLE chars_hist DROP CONSTRAINT chars_hist_main_fkey;
+ALTER TABLE chars_traits DROP CONSTRAINT chars_traits_id_fkey;
+ALTER TABLE chars_vns DROP CONSTRAINT chars_vns_id_fkey;
+ALTER TABLE chars_vns DROP CONSTRAINT chars_vns_rid_fkey;
+ALTER TABLE chars_vns DROP CONSTRAINT chars_vns_vid_fkey;
+ALTER TABLE chars_vns_hist DROP CONSTRAINT chars_vns_hist_rid_fkey;
+ALTER TABLE chars_vns_hist DROP CONSTRAINT chars_vns_hist_vid_fkey;
+ALTER TABLE image_votes DROP CONSTRAINT image_votes_uid_fkey;
+ALTER TABLE notification_subs DROP CONSTRAINT notification_subs_uid_fkey;
+ALTER TABLE notifications DROP CONSTRAINT notifications_uid_fkey;
+ALTER TABLE producers_relations DROP CONSTRAINT producers_relations_pid_fkey;
+ALTER TABLE producers_relations_hist DROP CONSTRAINT producers_relations_hist_pid_fkey;
+ALTER TABLE quotes DROP CONSTRAINT quotes_vid_fkey;
+ALTER TABLE releases_lang DROP CONSTRAINT releases_lang_id_fkey;
+ALTER TABLE releases_media DROP CONSTRAINT releases_media_id_fkey;
+ALTER TABLE releases_platforms DROP CONSTRAINT releases_platforms_id_fkey;
+ALTER TABLE releases_producers DROP CONSTRAINT releases_producers_id_fkey;
+ALTER TABLE releases_producers DROP CONSTRAINT releases_producers_pid_fkey;
+ALTER TABLE releases_producers_hist DROP CONSTRAINT releases_producers_hist_pid_fkey;
+ALTER TABLE releases_vn DROP CONSTRAINT releases_vn_id_fkey;
+ALTER TABLE releases_vn DROP CONSTRAINT releases_vn_vid_fkey;
+ALTER TABLE releases_vn_hist DROP CONSTRAINT releases_vn_hist_vid_fkey;
+ALTER TABLE reviews DROP CONSTRAINT reviews_rid_fkey;
+ALTER TABLE reviews DROP CONSTRAINT reviews_uid_fkey;
+ALTER TABLE reviews DROP CONSTRAINT reviews_vid_fkey;
+ALTER TABLE reviews_posts DROP CONSTRAINT reviews_posts_uid_fkey;
+ALTER TABLE reviews_votes DROP CONSTRAINT reviews_votes_uid_fkey;
+ALTER TABLE rlists DROP CONSTRAINT rlists_rid_fkey;
+ALTER TABLE rlists DROP CONSTRAINT rlists_uid_fkey;
+ALTER TABLE saved_queries DROP CONSTRAINT saved_queries_uid_fkey;
+ALTER TABLE sessions DROP CONSTRAINT sessions_uid_fkey;
+ALTER TABLE staff_alias DROP CONSTRAINT staff_alias_id_fkey;
+ALTER TABLE tags DROP CONSTRAINT tags_addedby_fkey;
+ALTER TABLE tags_vn DROP CONSTRAINT tags_vn_uid_fkey;
+ALTER TABLE tags_vn DROP CONSTRAINT tags_vn_vid_fkey;
+ALTER TABLE threads_poll_votes DROP CONSTRAINT threads_poll_votes_uid_fkey;
+ALTER TABLE threads_posts DROP CONSTRAINT threads_posts_uid_fkey;
+ALTER TABLE traits DROP CONSTRAINT traits_addedby_fkey;
+ALTER TABLE ulist_labels DROP CONSTRAINT ulist_labels_uid_fkey;
+ALTER TABLE ulist_vns DROP CONSTRAINT ulist_vns_uid_fkey;
+ALTER TABLE ulist_vns DROP CONSTRAINT ulist_vns_vid_fkey;
+ALTER TABLE ulist_vns_labels DROP CONSTRAINT ulist_vns_labels_uid_fkey;
+ALTER TABLE ulist_vns_labels DROP CONSTRAINT ulist_vns_labels_uid_lbl_fkey;
+ALTER TABLE ulist_vns_labels DROP CONSTRAINT ulist_vns_labels_uid_vid_fkey;
+ALTER TABLE ulist_vns_labels DROP CONSTRAINT ulist_vns_labels_vid_fkey;
+ALTER TABLE vn_anime DROP CONSTRAINT vn_anime_id_fkey;
+ALTER TABLE vn_relations DROP CONSTRAINT vn_relations_id_fkey;
+ALTER TABLE vn_relations DROP CONSTRAINT vn_relations_vid_fkey;
+ALTER TABLE vn_relations_hist DROP CONSTRAINT vn_relations_vid_fkey;
+ALTER TABLE vn_screenshots DROP CONSTRAINT vn_screenshots_id_fkey;
+ALTER TABLE vn_screenshots DROP CONSTRAINT vn_screenshots_rid_fkey;
+ALTER TABLE vn_screenshots_hist DROP CONSTRAINT vn_screenshots_hist_rid_fkey;
+ALTER TABLE vn_seiyuu DROP CONSTRAINT vn_seiyuu_cid_fkey;
+ALTER TABLE vn_seiyuu DROP CONSTRAINT vn_seiyuu_id_fkey;
+ALTER TABLE vn_seiyuu_hist DROP CONSTRAINT vn_seiyuu_hist_cid_fkey;
+ALTER TABLE vn_staff DROP CONSTRAINT vn_staff_id_fkey;
+
+DROP INDEX chars_vns_pkey;
+DROP INDEX chars_vns_hist_pkey;
+
+ALTER TABLE rlists ALTER COLUMN uid DROP DEFAULT;
+ALTER TABLE rlists ALTER COLUMN rid DROP DEFAULT;
+
+
+DROP INDEX changes_itemrev;
+ALTER TABLE changes ALTER COLUMN itemid TYPE vndbid USING vndbid(type::text, itemid);
+ALTER TABLE changes DROP COLUMN type;
+
+ALTER TABLE threads_boards DROP CONSTRAINT threads_boards_pkey;
+ALTER TABLE threads_boards ALTER COLUMN iid DROP DEFAULT;
+ALTER TABLE threads_boards ALTER COLUMN iid DROP NOT NULL;
+ALTER TABLE threads_boards ALTER COLUMN iid TYPE vndbid USING CASE WHEN iid = 0 THEN NULL ELSE vndbid(type::text, iid) END;
+
+ALTER TABLE audit_log ALTER COLUMN by_uid TYPE vndbid USING vndbid('u', by_uid);
+ALTER TABLE audit_log ALTER COLUMN affected_uid TYPE vndbid USING vndbid('u', affected_uid);
+ALTER TABLE reports ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+
+
+ALTER TABLE chars ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE chars ALTER COLUMN id TYPE vndbid USING vndbid('c', id);
+ALTER TABLE chars ALTER COLUMN id SET DEFAULT vndbid('c', nextval('chars_id_seq')::int);
+ALTER TABLE chars ADD CONSTRAINT chars_id_check CHECK(vndbid_type(id) = 'c');
+
+ALTER TABLE chars ALTER COLUMN main TYPE vndbid USING vndbid('c', main);
+ALTER TABLE chars_hist ALTER COLUMN main TYPE vndbid USING vndbid('c', main);
+ALTER TABLE chars_traits ALTER COLUMN id TYPE vndbid USING vndbid('c', id);
+ALTER TABLE chars_vns ALTER COLUMN id TYPE vndbid USING vndbid('c', id);
+ALTER TABLE traits_chars ALTER COLUMN cid TYPE vndbid USING vndbid('c', cid);
+ALTER TABLE vn_seiyuu ALTER COLUMN cid TYPE vndbid USING vndbid('c', cid);
+ALTER TABLE vn_seiyuu_hist ALTER COLUMN cid TYPE vndbid USING vndbid('c', cid);
+
+
+ALTER TABLE docs ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE docs ALTER COLUMN id TYPE vndbid USING vndbid('d', id);
+ALTER TABLE docs ALTER COLUMN id SET DEFAULT vndbid('d', nextval('docs_id_seq')::int);
+ALTER TABLE docs ADD CONSTRAINT docs_id_check CHECK(vndbid_type(id) = 'd');
+
+
+ALTER TABLE producers ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE producers ALTER COLUMN id TYPE vndbid USING vndbid('p', id);
+ALTER TABLE producers ALTER COLUMN id SET DEFAULT vndbid('p', nextval('producers_id_seq')::int);
+ALTER TABLE producers ADD CONSTRAINT producers_id_check CHECK(vndbid_type(id) = 'p');
+
+ALTER TABLE producers_relations ALTER COLUMN id TYPE vndbid USING vndbid('p', id);
+ALTER TABLE producers_relations ALTER COLUMN pid TYPE vndbid USING vndbid('p', pid);
+ALTER TABLE producers_relations_hist ALTER COLUMN pid TYPE vndbid USING vndbid('p', pid);
+ALTER TABLE releases_producers ALTER COLUMN pid TYPE vndbid USING vndbid('p', pid);
+ALTER TABLE releases_producers_hist ALTER COLUMN pid TYPE vndbid USING vndbid('p', pid);
+
+
+ALTER TABLE releases ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE releases ALTER COLUMN id TYPE vndbid USING vndbid('r', id);
+ALTER TABLE releases ALTER COLUMN id SET DEFAULT vndbid('r', nextval('releases_id_seq')::int);
+ALTER TABLE releases ADD CONSTRAINT releases_id_check CHECK(vndbid_type(id) = 'r');
+
+ALTER TABLE chars_vns ALTER COLUMN rid TYPE vndbid USING vndbid('r', rid);
+ALTER TABLE chars_vns_hist ALTER COLUMN rid TYPE vndbid USING vndbid('r', rid);
+ALTER TABLE releases_lang ALTER COLUMN id TYPE vndbid USING vndbid('r', id);
+ALTER TABLE releases_media ALTER COLUMN id TYPE vndbid USING vndbid('r', id);
+ALTER TABLE releases_platforms ALTER COLUMN id TYPE vndbid USING vndbid('r', id);
+ALTER TABLE releases_producers ALTER COLUMN id TYPE vndbid USING vndbid('r', id);
+ALTER TABLE releases_vn ALTER COLUMN id TYPE vndbid USING vndbid('r', id);
+ALTER TABLE reviews ALTER COLUMN rid TYPE vndbid USING vndbid('r', rid);
+ALTER TABLE rlists ALTER COLUMN rid TYPE vndbid USING vndbid('r', rid);
+ALTER TABLE vn_screenshots ALTER COLUMN rid TYPE vndbid USING vndbid('r', rid);
+ALTER TABLE vn_screenshots_hist ALTER COLUMN rid TYPE vndbid USING vndbid('r', rid);
+
+
+ALTER TABLE staff ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE staff ALTER COLUMN id TYPE vndbid USING vndbid('s', id);
+ALTER TABLE staff ALTER COLUMN id SET DEFAULT vndbid('s', nextval('staff_id_seq')::int);
+ALTER TABLE staff ADD CONSTRAINT staff_id_check CHECK(vndbid_type(id) = 's');
+
+ALTER TABLE staff_alias ALTER COLUMN id TYPE vndbid USING vndbid('s', id);
+
+
+ALTER TABLE vn ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE vn ALTER COLUMN id TYPE vndbid USING vndbid('v', id);
+ALTER TABLE vn ALTER COLUMN id SET DEFAULT vndbid('v', nextval('vn_id_seq')::int);
+ALTER TABLE vn ADD CONSTRAINT vn_id_check CHECK(vndbid_type(id) = 'v');
+
+ALTER TABLE chars_vns ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE chars_vns_hist ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE quotes ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE releases_vn ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE releases_vn_hist ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE reviews ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE tags_vn ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE tags_vn_inherit ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE ulist_vns ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE ulist_vns_labels ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE vn_anime ALTER COLUMN id TYPE vndbid USING vndbid('v', id);
+ALTER TABLE vn_relations ALTER COLUMN id TYPE vndbid USING vndbid('v', id);
+ALTER TABLE vn_relations ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE vn_relations_hist ALTER COLUMN vid TYPE vndbid USING vndbid('v', vid);
+ALTER TABLE vn_screenshots ALTER COLUMN id TYPE vndbid USING vndbid('v', id);
+ALTER TABLE vn_seiyuu ALTER COLUMN id TYPE vndbid USING vndbid('v', id);
+ALTER TABLE vn_staff ALTER COLUMN id TYPE vndbid USING vndbid('v', id);
+
+
+ALTER TABLE users ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE users ALTER COLUMN id TYPE vndbid USING vndbid('u', id);
+ALTER TABLE users ALTER COLUMN id SET DEFAULT vndbid('u', nextval('users_id_seq')::int);
+ALTER TABLE users ADD CONSTRAINT users_id_check CHECK(vndbid_type(id) = 'u');
+
+ALTER TABLE changes ALTER COLUMN requester TYPE vndbid USING vndbid('u', requester);
+ALTER TABLE image_votes ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE notification_subs ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE notifications ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE reviews ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE reviews_posts ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE reviews_votes ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE rlists ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE saved_queries ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE sessions ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE tags ALTER COLUMN addedby TYPE vndbid USING vndbid('u', addedby);
+ALTER TABLE tags_vn ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE threads_poll_votes ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE threads_posts ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE traits ALTER COLUMN addedby TYPE vndbid USING vndbid('u', addedby);
+ALTER TABLE ulist_labels ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE ulist_vns ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+ALTER TABLE ulist_vns_labels ALTER COLUMN uid TYPE vndbid USING vndbid('u', uid);
+
+ALTER TABLE images ALTER COLUMN c_uids DROP DEFAULT;
+ALTER TABLE images ALTER COLUMN c_uids TYPE vndbid[] USING '{}';
+ALTER TABLE images ALTER COLUMN c_uids SET DEFAULT '{}';
+
+DROP FUNCTION edit_revtable(dbentry_type, integer, integer);
+DROP FUNCTION edit_commit();
+DROP FUNCTION edit_committed(dbentry_type, edit_rettype);
+DROP FUNCTION edit_c_init(integer, integer);
+DROP FUNCTION edit_d_init(integer, integer);
+DROP FUNCTION edit_p_init(integer, integer);
+DROP FUNCTION edit_r_init(integer, integer);
+DROP FUNCTION edit_s_init(integer, integer);
+DROP FUNCTION edit_v_init(integer, integer);
+DROP FUNCTION edit_c_commit();
+DROP FUNCTION edit_d_commit();
+DROP FUNCTION edit_p_commit();
+DROP FUNCTION edit_r_commit();
+DROP FUNCTION edit_s_commit();
+DROP FUNCTION edit_v_commit();
+
+DROP FUNCTION update_vncache(integer);
+DROP FUNCTION tag_vn_calc(integer);
+DROP FUNCTION traits_chars_calc(integer);
+DROP FUNCTION ulist_labels_create(integer);
+DROP FUNCTION item_info(id vndbid, num int);
+DROP FUNCTION notify(iid vndbid, num integer, uid integer);
+DROP FUNCTION update_users_ulist_stats(integer);
+DROP FUNCTION user_getscryptargs(integer);
+DROP FUNCTION user_login(integer, bytea, bytea);
+DROP FUNCTION user_logout(integer, bytea);
+DROP FUNCTION user_isvalidsession(integer, bytea, session_type);
+DROP FUNCTION user_emailtoid(text);
+DROP FUNCTION user_resetpass(text, bytea);
+DROP FUNCTION user_setpass(integer, bytea, bytea);
+DROP FUNCTION user_isauth(integer, integer, bytea);
+DROP FUNCTION user_getmail(integer, integer, bytea);
+DROP FUNCTION user_setmail_token(integer, bytea, bytea, text);
+DROP FUNCTION user_setmail_confirm(integer, bytea);
+DROP FUNCTION user_setperm_usermod(integer, integer, bytea, boolean);
+DROP FUNCTION user_admin_setpass(integer, integer, bytea, bytea);
+DROP FUNCTION user_admin_setmail(integer, integer, bytea, text);
+\i sql/func.sql
+\i sql/editfunc.sql
+DROP TYPE edit_rettype;
+
+COMMIT;
+
+-- Need to do this analyze to ensure adding the foreign key constraints will use proper query plans.
+ANALYZE;
+\i sql/tableattrs.sql
+\i sql/perms.sql
+SELECT update_images_cache(NULL);
diff --git a/util/updates/2021-03-02-reviews-modnote.sql b/util/updates/2021-03-02-reviews-modnote.sql
new file mode 100644
index 00000000..f6313303
--- /dev/null
+++ b/util/updates/2021-03-02-reviews-modnote.sql
@@ -0,0 +1,4 @@
+ALTER TABLE reviews ADD COLUMN modnote text NOT NULL DEFAULT '';
+
+-- Not sure why NULL was allowed for the text column, let's fix that while we're here.
+ALTER TABLE reviews ALTER COLUMN text SET NOT NULL;
diff --git a/util/updates/2021-03-04-releases-minage.sql b/util/updates/2021-03-04-releases-minage.sql
new file mode 100644
index 00000000..c4eb0bb4
--- /dev/null
+++ b/util/updates/2021-03-04-releases-minage.sql
@@ -0,0 +1,2 @@
+UPDATE releases SET minage = NULL WHERE minage = -1;
+UPDATE releases_hist SET minage = NULL WHERE minage = -1;
diff --git a/util/updates/2021-03-06-medium-cassette-tape.sql b/util/updates/2021-03-06-medium-cassette-tape.sql
new file mode 100644
index 00000000..370a293d
--- /dev/null
+++ b/util/updates/2021-03-06-medium-cassette-tape.sql
@@ -0,0 +1 @@
+ALTER TYPE medium ADD VALUE 'cas' AFTER 'flp';
diff --git a/util/updates/2021-03-07-platforms.sql b/util/updates/2021-03-07-platforms.sql
new file mode 100644
index 00000000..a0d19533
--- /dev/null
+++ b/util/updates/2021-03-07-platforms.sql
@@ -0,0 +1,9 @@
+ALTER TYPE platform ADD VALUE 'tdo' BEFORE 'oth';
+ALTER TYPE platform ADD VALUE 'fm7' BEFORE 'fmt';
+ALTER TYPE platform ADD VALUE 'fm8' BEFORE 'fmt';
+ALTER TYPE platform ADD VALUE 'ps5' BEFORE 'psv';
+ALTER TYPE platform ADD VALUE 'smd' BEFORE 'sat';
+ALTER TYPE platform ADD VALUE 'scd' BEFORE 'sat';
+ALTER TYPE platform ADD VALUE 'x1s' BEFORE 'x68';
+ALTER TYPE platform ADD VALUE 'vnd' AFTER 'n3d';
+ALTER TYPE platform ADD VALUE 'xxs' AFTER 'xbo';
diff --git a/util/updates/2021-03-11-platform-mobile.sql b/util/updates/2021-03-11-platform-mobile.sql
new file mode 100644
index 00000000..cf062b4a
--- /dev/null
+++ b/util/updates/2021-03-11-platform-mobile.sql
@@ -0,0 +1 @@
+ALTER TYPE platform ADD VALUE 'mob' BEFORE 'oth';
diff --git a/util/updates/2021-03-11-tag-history.sql b/util/updates/2021-03-11-tag-history.sql
new file mode 100644
index 00000000..ddbdd674
--- /dev/null
+++ b/util/updates/2021-03-11-tag-history.sql
@@ -0,0 +1,89 @@
+BEGIN;
+
+-- 'deleted' state is now represented as (hidden && locked)
+-- (hidden && !locked) now means 'awaiting moderation'
+UPDATE vn SET locked = true WHERE hidden AND NOT locked;
+UPDATE producers SET locked = true WHERE hidden AND NOT locked;
+UPDATE staff SET locked = true WHERE hidden AND NOT locked;
+UPDATE chars SET locked = true WHERE hidden AND NOT locked;
+UPDATE releases SET locked = true WHERE hidden AND NOT locked;
+UPDATE docs SET locked = true WHERE hidden AND NOT locked;
+UPDATE changes SET ilock = true WHERE ihid AND NOT ilock;
+
+ALTER TABLE tags_aliases DROP CONSTRAINT tags_aliases_tag_fkey;
+ALTER TABLE tags_parents DROP CONSTRAINT tags_parents_tag_fkey;
+ALTER TABLE tags_parents DROP CONSTRAINT tags_parents_parent_fkey;
+ALTER TABLE tags_vn DROP CONSTRAINT tags_vn_tag_fkey;
+
+DROP TRIGGER insert_notify ON tags;
+DROP TRIGGER stats_cache_new ON tags;
+DROP TRIGGER stats_cache_edit ON tags;
+
+-- Move tags_alias into tags as 'alias' column, to be consistent with how aliases are stored for traits.
+-- No real need to enforce uniqueness on aliasses as they're just search helpers.
+ALTER TABLE tags ADD COLUMN alias varchar(500) NOT NULL DEFAULT '';
+UPDATE tags SET alias = COALESCE((SELECT string_agg(alias, E'\n') FROM tags_aliases WHERE tag = tags.id), '');
+DROP TABLE tags_aliases;
+
+ALTER TABLE tags ALTER COLUMN name SET DEFAULT '';
+
+-- State -> hidden,locked
+ALTER TABLE tags ADD COLUMN hidden boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE tags ADD COLUMN locked boolean NOT NULL DEFAULT TRUE;
+UPDATE tags SET hidden = (state <> 2), locked = (state = 1);
+ALTER TABLE tags DROP COLUMN state;
+
+-- id -> vndbid
+ALTER TABLE tags ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE tags ALTER COLUMN id TYPE vndbid USING vndbid('g', id);
+ALTER TABLE tags ALTER COLUMN id SET DEFAULT vndbid('g', nextval('tags_id_seq')::int);
+ALTER TABLE tags ADD CONSTRAINT tags_id_check CHECK(vndbid_type(id) = 'g');
+
+ALTER TABLE tags_parents RENAME COLUMN tag TO id;
+ALTER TABLE tags_parents ALTER COLUMN id TYPE vndbid USING vndbid('g', id);
+ALTER TABLE tags_parents ALTER COLUMN parent TYPE vndbid USING vndbid('g', parent);
+
+
+CREATE TABLE tags_hist (
+ chid integer NOT NULL PRIMARY KEY,
+ cat tag_category NOT NULL DEFAULT 'cont',
+ defaultspoil smallint NOT NULL DEFAULT 0,
+ searchable boolean NOT NULL DEFAULT TRUE,
+ applicable boolean NOT NULL DEFAULT TRUE,
+ name varchar(250) NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT '',
+ alias varchar(500) NOT NULL DEFAULT ''
+);
+
+CREATE TABLE tags_parents_hist (
+ chid integer NOT NULL,
+ parent vndbid NOT NULL,
+ PRIMARY KEY(chid, parent)
+);
+
+ALTER TABLE tags_vn ALTER COLUMN tag TYPE vndbid USING vndbid('g', tag);
+ALTER TABLE tags_vn_inherit ALTER COLUMN tag TYPE vndbid USING vndbid('g', tag);
+
+INSERT INTO changes (requester,itemid,rev,ihid,ilock,comments)
+ SELECT 'u1', id, 1, hidden, locked,
+'Automated import from when the tag database did not keep track of change histories.
+This tag was initially submitted by '||coalesce(nullif(addedby::text, 'u1'), 'an anonymous user')||' on '||added::date||', but has no doubt been updated over time by moderators.'
+ FROM tags;
+
+INSERT INTO tags_hist (chid, cat, defaultspoil, searchable, applicable, name, description, alias)
+ SELECT c.id, t.cat, t.defaultspoil, t.searchable, t.applicable, t.name, t.description, t.alias
+ FROM tags t JOIN changes c ON c.itemid = t.id;
+
+INSERT INTO tags_parents_hist (chid, parent) SELECT c.id, t.parent FROM tags_parents t JOIN changes c ON c.itemid = t.id;
+
+ALTER TABLE tags DROP COLUMN addedby;
+
+
+\i sql/func.sql
+\i sql/editfunc.sql
+
+COMMIT;
+
+\i sql/tableattrs.sql
+\i sql/triggers.sql
+\i sql/perms.sql
diff --git a/util/updates/2021-03-16-release-dlsiteen.sql b/util/updates/2021-03-16-release-dlsiteen.sql
new file mode 100644
index 00000000..a3a65a50
--- /dev/null
+++ b/util/updates/2021-03-16-release-dlsiteen.sql
@@ -0,0 +1,16 @@
+-- Create a temporary copy of the DLsite English shop status information in case we want to revert.
+CREATE TABLE shop_dlsiteen_old AS SELECT * FROM shop_dlsite WHERE id LIKE 'RE%';
+DELETE FROM shop_dlsite WHERE id LIKE 'RE%';
+
+CREATE OR REPLACE FUNCTION migrate_dlsiteen_to_dlsite(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_dlsite = regexp_replace(l_dlsiteen, '^RE', 'RJ');
+ UPDATE edit_revision SET requester = 'u1', ip = '0.0.0.0', comments = 'DLsite English has been merged into the main DLsite, automatically migrating shop link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_dlsiteen_to_dlsite(id) FROM releases
+ WHERE NOT hidden AND l_dlsite = '' AND l_dlsiteen <> ''
+ AND NOT EXISTS(SELECT 1 FROM shop_dlsite WHERE id = l_dlsiteen AND deadsince < NOW()-'7 days'::interval);
+DROP FUNCTION migrate_dlsiteen_to_dlsite(vndbid);
diff --git a/util/updates/2021-03-23-trait-history.sql b/util/updates/2021-03-23-trait-history.sql
new file mode 100644
index 00000000..a940799f
--- /dev/null
+++ b/util/updates/2021-03-23-trait-history.sql
@@ -0,0 +1,74 @@
+BEGIN;
+
+ALTER TABLE chars_traits DROP CONSTRAINT chars_traits_tid_fkey;
+ALTER TABLE chars_traits_hist DROP CONSTRAINT chars_traits_hist_tid_fkey;
+ALTER TABLE traits DROP CONSTRAINT traits_group_fkey;
+ALTER TABLE traits_parents DROP CONSTRAINT traits_parents_trait_fkey;
+ALTER TABLE traits_parents DROP CONSTRAINT traits_parents_parent_fkey;
+
+DROP TRIGGER insert_notify ON traits;
+DROP TRIGGER stats_cache_new ON traits;
+DROP TRIGGER stats_cache_edit ON traits;
+
+ALTER TABLE traits ADD COLUMN hidden boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE traits ADD COLUMN locked boolean NOT NULL DEFAULT TRUE;
+UPDATE traits SET hidden = (state <> 2), locked = (state = 1);
+ALTER TABLE traits DROP COLUMN state;
+
+ALTER TABLE traits ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE traits ALTER COLUMN id TYPE vndbid USING vndbid('i', id);
+ALTER TABLE traits ALTER COLUMN id SET DEFAULT vndbid('i', nextval('traits_id_seq')::int);
+ALTER TABLE traits ADD CONSTRAINT traits_id_check CHECK(vndbid_type(id) = 'i');
+
+ALTER TABLE traits ALTER COLUMN "group" TYPE vndbid USING vndbid('i', "group");
+ALTER TABLE traits ALTER COLUMN name SET DEFAULT '';
+
+ALTER TABLE traits_parents RENAME COLUMN trait TO id;
+ALTER TABLE traits_parents ALTER COLUMN id TYPE vndbid USING vndbid('i', id);
+ALTER TABLE traits_parents ALTER COLUMN parent TYPE vndbid USING vndbid('i', parent);
+
+ALTER TABLE traits_chars ALTER COLUMN tid TYPE vndbid USING vndbid('i', tid);
+ALTER TABLE chars_traits ALTER COLUMN tid TYPE vndbid USING vndbid('i', tid);
+ALTER TABLE chars_traits_hist ALTER COLUMN tid TYPE vndbid USING vndbid('i', tid);
+
+CREATE TABLE traits_hist (
+ chid integer NOT NULL,
+ "order" smallint NOT NULL DEFAULT 0,
+ defaultspoil smallint NOT NULL DEFAULT 0,
+ sexual boolean NOT NULL DEFAULT false,
+ searchable boolean NOT NULL DEFAULT true,
+ applicable boolean NOT NULL DEFAULT true,
+ name varchar(250) NOT NULL DEFAULT '',
+ alias varchar(500) NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT ''
+);
+
+CREATE TABLE traits_parents_hist (
+ chid integer NOT NULL,
+ parent vndbid NOT NULL,
+ PRIMARY KEY(chid, parent)
+);
+
+
+INSERT INTO changes (requester,itemid,rev,ihid,ilock,comments)
+ SELECT 'u1', id, 1, hidden, locked,
+'Automated import from when the trait database did not keep track of change histories.
+This trait was initially submitted by '||coalesce(nullif(addedby::text, 'u1'), 'an anonymous user')||' on '||added::date||', but has no doubt been updated over time by moderators.'
+ FROM traits;
+
+INSERT INTO traits_hist (chid, "order", defaultspoil, sexual, searchable, applicable, name, description, alias)
+ SELECT c.id, t."order", t.defaultspoil, t.sexual, t.searchable, t.applicable, t.name, t.description, t.alias
+ FROM traits t JOIN changes c ON c.itemid = t.id;
+
+INSERT INTO traits_parents_hist (chid, parent) SELECT c.id, t.parent FROM traits_parents t JOIN changes c ON c.itemid = t.id;
+
+ALTER TABLE traits DROP COLUMN addedby;
+
+\i sql/func.sql
+\i sql/editfunc.sql
+
+COMMIT;
+
+\i sql/tableattrs.sql
+\i sql/triggers.sql
+\i sql/perms.sql
diff --git a/util/updates/2021-04-09-item-info.sql b/util/updates/2021-04-09-item-info.sql
new file mode 100644
index 00000000..22728127
--- /dev/null
+++ b/util/updates/2021-04-09-item-info.sql
@@ -0,0 +1,2 @@
+DROP FUNCTION item_info(vndbid,int);
+\i sql/func.sql
diff --git a/util/updates/2021-05-05-latin-language.sql b/util/updates/2021-05-05-latin-language.sql
new file mode 100644
index 00000000..7612430e
--- /dev/null
+++ b/util/updates/2021-05-05-latin-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'la' AFTER 'ms';
diff --git a/util/updates/2021-05-14-releases-lang-mtl.sql b/util/updates/2021-05-14-releases-lang-mtl.sql
new file mode 100644
index 00000000..43723117
--- /dev/null
+++ b/util/updates/2021-05-14-releases-lang-mtl.sql
@@ -0,0 +1,4 @@
+ALTER TABLE releases_lang ADD COLUMN mtl boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE releases_lang_hist ADD COLUMN mtl boolean NOT NULL DEFAULT FALSE;
+\i sql/editfunc.sql
+\i sql/func.sql
diff --git a/util/updates/2021-05-21-tt-primary-parent.sql b/util/updates/2021-05-21-tt-primary-parent.sql
new file mode 100644
index 00000000..00f513a4
--- /dev/null
+++ b/util/updates/2021-05-21-tt-primary-parent.sql
@@ -0,0 +1,17 @@
+ALTER TABLE tags_parents ADD COLUMN main boolean NOT NULL DEFAULT false;
+ALTER TABLE tags_parents_hist ADD COLUMN main boolean NOT NULL DEFAULT false;
+ALTER TABLE traits_parents ADD COLUMN main boolean NOT NULL DEFAULT false;
+ALTER TABLE traits_parents_hist ADD COLUMN main boolean NOT NULL DEFAULT false;
+\i sql/editfunc.sql
+
+UPDATE tags_parents tp SET main = true WHERE NOT EXISTS(SELECT 1 FROM tags_parents tp2 WHERE tp2.id = tp.id AND tp2.parent < tp.parent);
+UPDATE tags_parents_hist tp SET main = true WHERE NOT EXISTS(SELECT 1 FROM tags_parents_hist tp2 WHERE tp2.chid = tp.chid AND tp2.parent < tp.parent);
+UPDATE traits_parents tp SET main = true WHERE NOT EXISTS(SELECT 1 FROM traits_parents tp2 WHERE tp2.id = tp.id AND tp2.parent < tp.parent);
+UPDATE traits_parents_hist tp SET main = true WHERE NOT EXISTS(SELECT 1 FROM traits_parents_hist tp2 WHERE tp2.chid = tp.chid AND tp2.parent < tp.parent);
+
+-- Update the traits.group cache for consistency with the above selected 'main' flags.
+WITH RECURSIVE childs (id, grp) AS (
+ SELECT id, id FROM traits t WHERE NOT EXISTS(SELECT 1 FROM traits_parents tp WHERE tp.id = t.id)
+ UNION ALL
+ SELECT tp.id, childs.grp FROM childs JOIN traits_parents tp ON tp.parent = childs.id AND tp.main
+) UPDATE traits SET "group" = grp FROM childs WHERE childs.id = traits.id AND "group" IS DISTINCT FROM grp AND grp <> childs.id;
diff --git a/util/updates/2021-05-25-users-shadow.sql b/util/updates/2021-05-25-users-shadow.sql
new file mode 100644
index 00000000..bf48d0af
--- /dev/null
+++ b/util/updates/2021-05-25-users-shadow.sql
@@ -0,0 +1,19 @@
+CREATE TABLE users_shadow (
+ id vndbid NOT NULL PRIMARY KEY,
+ perm_usermod boolean NOT NULL DEFAULT false,
+ mail varchar(100) NOT NULL,
+ passwd bytea NOT NULL DEFAULT ''
+);
+
+BEGIN;
+INSERT INTO users_shadow SELECT id, perm_usermod, mail, passwd FROM users;
+
+ALTER TABLE users_shadow ADD CONSTRAINT users_shadow_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+
+ALTER TABLE users DROP COLUMN perm_usermod;
+ALTER TABLE users DROP COLUMN mail;
+ALTER TABLE users DROP COLUMN passwd;
+COMMIT;
+
+\i sql/perms.sql
+\i sql/func.sql
diff --git a/util/updates/2021-05-25-users-vnlang.sql b/util/updates/2021-05-25-users-vnlang.sql
new file mode 100644
index 00000000..d480c60a
--- /dev/null
+++ b/util/updates/2021-05-25-users-vnlang.sql
@@ -0,0 +1 @@
+ALTER TABLE users ADD COLUMN vnlang jsonb;
diff --git a/util/updates/2021-06-04-vn-developers-and-average-cache.sql b/util/updates/2021-06-04-vn-developers-and-average-cache.sql
new file mode 100644
index 00000000..4fc6a510
--- /dev/null
+++ b/util/updates/2021-06-04-vn-developers-and-average-cache.sql
@@ -0,0 +1,11 @@
+ALTER TABLE users ADD COLUMN tableopts_v integer;
+ALTER TABLE users ADD COLUMN tableopts_vt integer;
+
+ALTER TABLE vn ADD COLUMN c_developers vndbid[] NOT NULL DEFAULT '{}';
+ALTER TABLE vn ADD COLUMN c_average smallint;
+ALTER TABLE vn ALTER COLUMN c_popularity TYPE smallint USING c_popularity*10000;
+ALTER TABLE vn ALTER COLUMN c_rating TYPE smallint USING c_rating*10;
+\i sql/func.sql
+\timing
+SELECT count(*) FROM (SELECT update_vncache(id) FROM vn) x;
+SELECT update_vnvotestats();
diff --git a/util/updates/2021-06-22-indi-urdu-languages.sql b/util/updates/2021-06-22-indi-urdu-languages.sql
new file mode 100644
index 00000000..9de77172
--- /dev/null
+++ b/util/updates/2021-06-22-indi-urdu-languages.sql
@@ -0,0 +1,2 @@
+ALTER TYPE language ADD VALUE 'hi' AFTER 'he';
+ALTER TYPE language ADD VALUE 'ur' AFTER 'uk';
diff --git a/util/updates/2021-06-28-lockdown-mode.sql b/util/updates/2021-06-28-lockdown-mode.sql
new file mode 100644
index 00000000..d0b51cbe
--- /dev/null
+++ b/util/updates/2021-06-28-lockdown-mode.sql
@@ -0,0 +1,13 @@
+CREATE TABLE global_settings (
+ -- Only permit a single row in this table
+ id boolean NOT NULL PRIMARY KEY DEFAULT FALSE CONSTRAINT global_settings_single_row CHECK(id),
+ -- locks down any DB edits, including image voting and tagging
+ lockdown_edit boolean NOT NULL DEFAULT FALSE,
+ -- locks down any forum & review posting
+ lockdown_board boolean NOT NULL DEFAULT FALSE,
+ lockdown_registration boolean NOT NULL DEFAULT FALSE
+);
+
+INSERT INTO global_settings (id) VALUES (TRUE);
+
+\i sql/perms.sql
diff --git a/util/updates/2021-07-24-more-wikidata-ids.sql b/util/updates/2021-07-24-more-wikidata-ids.sql
new file mode 100644
index 00000000..e5e80359
--- /dev/null
+++ b/util/updates/2021-07-24-more-wikidata-ids.sql
@@ -0,0 +1,3 @@
+ALTER TABLE wikidata ADD COLUMN soundcloud text[];
+ALTER TABLE wikidata ADD COLUMN humblestore text[];
+ALTER TABLE wikidata ADD COLUMN itchio text[];
diff --git a/util/updates/2021-07-28-merge-imgmod.sql b/util/updates/2021-07-28-merge-imgmod.sql
new file mode 100644
index 00000000..eab67c41
--- /dev/null
+++ b/util/updates/2021-07-28-merge-imgmod.sql
@@ -0,0 +1,2 @@
+-- imgmod permissions merged into dbmod, no need to separate these.
+ALTER TABLE users DROP COLUMN perm_imgmod;
diff --git a/util/updates/2021-07-30-vn-length-voting.sql b/util/updates/2021-07-30-vn-length-voting.sql
new file mode 100644
index 00000000..48dedb52
--- /dev/null
+++ b/util/updates/2021-07-30-vn-length-voting.sql
@@ -0,0 +1,17 @@
+CREATE TABLE vn_length_votes (
+ vid vndbid NOT NULL,
+ rid vndbid NOT NULL,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ uid vndbid,
+ length smallint NOT NULL, -- minutes
+ notes text NOT NULL DEFAULT ''
+);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+CREATE UNIQUE INDEX vn_length_votes_pkey ON vn_length_votes (vid, uid);
+
+-- DEFAULT false while it's in development.
+ALTER TABLE users ADD COLUMN perm_lengthvote boolean NOT NULL DEFAULT false;
+
+\i sql/perms.sql
diff --git a/util/updates/2021-08-03-vnlength-speed.sql b/util/updates/2021-08-03-vnlength-speed.sql
new file mode 100644
index 00000000..f2809a59
--- /dev/null
+++ b/util/updates/2021-08-03-vnlength-speed.sql
@@ -0,0 +1,6 @@
+ALTER TABLE vn_length_votes ADD COLUMN speed smallint NOT NULL DEFAULT 0;
+ALTER TABLE vn_length_votes ALTER COLUMN speed DROP DEFAULT;
+ALTER TABLE vn_length_votes ADD COLUMN notes2 text NOT NULL DEFAULT '';
+UPDATE vn_length_votes SET notes2 = notes;
+ALTER TABLE vn_length_votes DROP COLUMN notes;
+ALTER TABLE vn_length_votes RENAME COLUMN notes2 TO notes;
diff --git a/util/updates/2021-08-04-vnlength-index.sql b/util/updates/2021-08-04-vnlength-index.sql
new file mode 100644
index 00000000..f9e93d01
--- /dev/null
+++ b/util/updates/2021-08-04-vnlength-index.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users ALTER COLUMN perm_lengthvote SET DEFAULT true;
+CREATE INDEX vn_length_votes_uid ON vn_length_votes (uid);
diff --git a/util/updates/2021-08-08-lengthvote-ignore.sql b/util/updates/2021-08-08-lengthvote-ignore.sql
new file mode 100644
index 00000000..594961d7
--- /dev/null
+++ b/util/updates/2021-08-08-lengthvote-ignore.sql
@@ -0,0 +1 @@
+ALTER TABLE vn_length_votes ADD COLUMN ignore boolean NOT NULL DEFAULT false;
diff --git a/util/updates/2021-08-09-vnlength-multirelease.sql b/util/updates/2021-08-09-vnlength-multirelease.sql
new file mode 100644
index 00000000..e5917f34
--- /dev/null
+++ b/util/updates/2021-08-09-vnlength-multirelease.sql
@@ -0,0 +1,4 @@
+ALTER TABLE vn_length_votes ADD COLUMN rid2 vndbid[] NOT NULL DEFAULT '{}';
+UPDATE vn_length_votes SET rid2 = ARRAY[rid];
+ALTER TABLE vn_length_votes DROP COLUMN rid;
+ALTER TABLE vn_length_votes RENAME COLUMN rid2 TO rid;
diff --git a/util/updates/2021-08-09b-vnlength-primarykey.sql b/util/updates/2021-08-09b-vnlength-primarykey.sql
new file mode 100644
index 00000000..5bb1df32
--- /dev/null
+++ b/util/updates/2021-08-09b-vnlength-primarykey.sql
@@ -0,0 +1,28 @@
+-- Recreate the vn_length_votes table to cleanly add a primary key and for more efficient storage.
+-- The table layout had gotten messy with all the recent edits.
+BEGIN;
+DROP INDEX vn_length_votes_pkey;
+DROP INDEX vn_length_votes_uid;
+ALTER TABLE vn_length_votes RENAME TO vn_length_votes_tmp;
+
+CREATE TABLE vn_length_votes (
+ id SERIAL PRIMARY KEY,
+ vid vndbid NOT NULL, -- [pub]
+ date timestamptz NOT NULL DEFAULT NOW(), -- [pub]
+ length smallint NOT NULL, -- [pub] minutes
+ speed smallint NOT NULL, -- [pub] 0=slow, 1=normal, 2=fast
+ uid vndbid, -- [pub]
+ ignore boolean NOT NULL DEFAULT false, -- [pub]
+ rid vndbid[] NOT NULL, -- [pub]
+ notes text NOT NULL DEFAULT '' -- [pub]
+);
+
+INSERT INTO vn_length_votes (vid,date,uid,length,speed,ignore,rid,notes)
+ SELECT vid,date,uid,length,speed,ignore,rid,notes FROM vn_length_votes_tmp;
+
+CREATE UNIQUE INDEX vn_length_votes_vid_uid ON vn_length_votes (vid, uid);
+CREATE INDEX vn_length_votes_uid ON vn_length_votes (uid);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+COMMIT;
+\i sql/perms.sql
diff --git a/util/updates/2021-09-02-some-foreign-key-stuff.sql b/util/updates/2021-09-02-some-foreign-key-stuff.sql
new file mode 100644
index 00000000..09abff70
--- /dev/null
+++ b/util/updates/2021-09-02-some-foreign-key-stuff.sql
@@ -0,0 +1,5 @@
+-- Add an ON UPDATE CASCADE clause to these contraints to simplify moving lists across users or VNs.
+ALTER TABLE ulist_vns_labels DROP CONSTRAINT ulist_vns_labels_uid_lbl_fkey;
+ALTER TABLE ulist_vns_labels DROP CONSTRAINT ulist_vns_labels_uid_vid_fkey;
+ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_uid_lbl_fkey FOREIGN KEY (uid,lbl) REFERENCES ulist_labels (uid,id) ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_uid_vid_fkey FOREIGN KEY (uid,vid) REFERENCES ulist_vns (uid,vid) ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/util/updates/2021-09-26-vn-length-cache.sql b/util/updates/2021-09-26-vn-length-cache.sql
new file mode 100644
index 00000000..40dfa0a0
--- /dev/null
+++ b/util/updates/2021-09-26-vn-length-cache.sql
@@ -0,0 +1,6 @@
+ALTER TABLE vn ADD COLUMN c_length smallint;
+ALTER TABLE vn ADD COLUMN c_lengthnum smallint NOT NULL DEFAULT 0;
+
+\i sql/func.sql
+\i sql/triggers.sql
+select update_vn_length_cache(null);
diff --git a/util/updates/2021-10-27-freegame-mugen.sql b/util/updates/2021-10-27-freegame-mugen.sql
new file mode 100644
index 00000000..cc3f487b
--- /dev/null
+++ b/util/updates/2021-10-27-freegame-mugen.sql
@@ -0,0 +1,3 @@
+ALTER TABLE releases ADD COLUMN l_freegame text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_freegame text NOT NULL DEFAULT '';
+\i sql/editfunc.sql
diff --git a/util/updates/2021-10-28-username-casefold.sql b/util/updates/2021-10-28-username-casefold.sql
new file mode 100644
index 00000000..88bc1238
--- /dev/null
+++ b/util/updates/2021-10-28-username-casefold.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users DROP CONSTRAINT users_username_key;
+CREATE UNIQUE INDEX users_username_key ON users (lower(username));
diff --git a/util/updates/2021-10-28-username-history.sql b/util/updates/2021-10-28-username-history.sql
new file mode 100644
index 00000000..ac703fc8
--- /dev/null
+++ b/util/updates/2021-10-28-username-history.sql
@@ -0,0 +1,16 @@
+CREATE TABLE users_username_hist (
+ id vndbid NOT NULL,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ old text NOT NULL,
+ new text NOT NULL,
+ PRIMARY KEY(id, date)
+);
+ALTER TABLE users_username_hist ADD CONSTRAINT users_username_hist_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+\i sql/perms.sql
+
+INSERT INTO users_username_hist (id, date, old, new)
+ SELECT affected_uid, date
+ , regexp_replace(detail, 'username: "([^"]+)" -> "([^"]+)"', '\1', '') AS old
+ , regexp_replace(detail, 'username: "([^"]+)" -> "([^"]+)"', '\2', '') AS new
+ FROM audit_log
+ WHERE detail ~ 'username: "([^"]+)" -> "([^"]+)"' AND EXISTS(SELECT 1 FROM users WHERE id = affected_uid);
diff --git a/util/updates/2021-10-28-website-length.sql b/util/updates/2021-10-28-website-length.sql
new file mode 100644
index 00000000..a666e05f
--- /dev/null
+++ b/util/updates/2021-10-28-website-length.sql
@@ -0,0 +1,4 @@
+ALTER TABLE producers ALTER COLUMN website TYPE varchar(1024);
+ALTER TABLE producers_hist ALTER COLUMN website TYPE varchar(1024);
+ALTER TABLE releases ALTER COLUMN website TYPE varchar(1024);
+ALTER TABLE releases_hist ALTER COLUMN website TYPE varchar(1024);
diff --git a/util/updates/2021-10-29-fix-thumbnail-resolution.pl b/util/updates/2021-10-29-fix-thumbnail-resolution.pl
new file mode 100755
index 00000000..8da530f7
--- /dev/null
+++ b/util/updates/2021-10-29-fix-thumbnail-resolution.pl
@@ -0,0 +1,50 @@
+#!/usr/bin/perl
+
+use v5.26;
+use warnings;
+use Cwd 'abs_path';
+use lib ((abs_path $0) =~ s{/\Q$0\E$}{}r).'/lib';
+
+use VNDB::Func 'imgsize', 'imgpath';
+use VNDB::Config;
+use VNWeb::DB;
+use TUWF;
+
+TUWF::set %{ config->{tuwf} };
+
+sub jpgsize {
+ my($f) = @_;
+ my $id = config->{identify_path};
+ return split 'x', `$id -format "%wx%h" "$f"`;
+
+ use bytes;
+ open my $F, '<', $f or die "$f: $!";
+ die "$f: $!" if 1 > read $F, my $buf, 16*1024;
+ die "$f: Not a JPEG\n" if $buf !~ /\xFF[\xC0\xC2]...(....)/s;
+ my($h,$w) = unpack 'nn', $1;
+ return ($w,$h);
+}
+
+for (tuwf->dbAlli('SELECT id, width, height FROM images WHERE id BETWEEN \'sf1\' AND vndbid_max(\'sf\')')->@*) {
+ my $fullpath = imgpath $_->{id};
+ my $thumbpath = imgpath $_->{id}, 1;
+ next if !$_->{width} || !-s $fullpath;
+ my ($thumbw, $thumbh) = imgsize $_->{width}, $_->{height}, config->{scr_size}->@*;
+ my ($filew, $fileh) = jpgsize $thumbpath;
+ if($filew != $thumbw || $fileh != $thumbh) {
+ warn "$thumbpath: dimensions don't match, recreating; file=${filew}x$fileh expected=${thumbw}x$thumbh\n";
+ my $conv = config->{convert_path};
+ my $resize = config->{scr_size}[0].'x'.config->{scr_size}[1].'>';
+ unlink 'tmpimg.jpg';
+ my ($neww, $newh) = split /x/, `$conv "$fullpath" -strip -quality 90 -resize "$resize" -unsharp 0x0.75+0.75+0.008 -print %wx%h tmpimg.jpg`;
+ if(!$neww || !$newh) {
+ warn "$thumbpath: unable to write new image\n";
+ next;
+ }
+ if($neww != $thumbw || $newh != $thumbh) {
+ warn "$thumbpath: new thumbnail doesn't match expected dimensions, got ${neww}x$newh instead.\n";
+ next;
+ }
+ rename 'tmpimg.jpg', $thumbpath;
+ }
+}
diff --git a/util/updates/2021-11-07-posts-hidden-msg.sql b/util/updates/2021-11-07-posts-hidden-msg.sql
new file mode 100644
index 00000000..878ae0ab
--- /dev/null
+++ b/util/updates/2021-11-07-posts-hidden-msg.sql
@@ -0,0 +1,17 @@
+BEGIN;
+ALTER TABLE threads_posts
+ DROP CONSTRAINT threads_posts_first_nonhidden,
+ ALTER COLUMN hidden DROP NOT NULL,
+ ALTER COLUMN hidden DROP DEFAULT,
+ ALTER COLUMN hidden TYPE text USING case when hidden then '' else null end,
+ ADD CONSTRAINT threads_posts_first_nonhidden CHECK(num > 1 OR hidden IS NULL);
+
+ALTER TABLE reviews_posts
+ ALTER COLUMN hidden DROP NOT NULL,
+ ALTER COLUMN hidden DROP DEFAULT,
+ ALTER COLUMN hidden TYPE text USING case when hidden then '' else null end;
+
+\i sql/func.sql
+COMMIT;
+
+\i sql/triggers.sql
diff --git a/util/updates/2021-11-07-threads-board-lock.sql b/util/updates/2021-11-07-threads-board-lock.sql
new file mode 100644
index 00000000..6b25a187
--- /dev/null
+++ b/util/updates/2021-11-07-threads-board-lock.sql
@@ -0,0 +1 @@
+ALTER TABLE threads ADD COLUMN boards_locked boolean NOT NULL DEFAULT FALSE;
diff --git a/util/updates/2021-11-15-release-vn-type.sql b/util/updates/2021-11-15-release-vn-type.sql
new file mode 100644
index 00000000..54916086
--- /dev/null
+++ b/util/updates/2021-11-15-release-vn-type.sql
@@ -0,0 +1,12 @@
+BEGIN;
+ALTER TABLE releases_vn ADD COLUMN rtype release_type NOT NULL DEFAULT 'complete';
+ALTER TABLE releases_vn_hist ADD COLUMN rtype release_type NOT NULL DEFAULT 'complete';
+ALTER TABLE releases_vn ALTER COLUMN rtype DROP DEFAULT;
+ALTER TABLE releases_vn_hist ALTER COLUMN rtype DROP DEFAULT;
+UPDATE releases_vn SET rtype = type FROM releases r WHERE r.id = releases_vn.id;
+UPDATE releases_vn_hist SET rtype = type FROM releases_hist r WHERE r.chid = releases_vn_hist.chid;
+ALTER TABLE releases DROP COLUMN type;
+ALTER TABLE releases_hist DROP COLUMN type;
+\i sql/editfunc.sql
+\i sql/func.sql
+COMMIT;
diff --git a/util/updates/2021-11-15-reviews-fulltext-search.sql b/util/updates/2021-11-15-reviews-fulltext-search.sql
new file mode 100644
index 00000000..c6f60211
--- /dev/null
+++ b/util/updates/2021-11-15-reviews-fulltext-search.sql
@@ -0,0 +1,2 @@
+CREATE INDEX reviews_ts ON reviews USING gin(bb_tsvector(text));
+CREATE INDEX reviews_posts_ts ON reviews_posts USING gin(bb_tsvector(msg));
diff --git a/util/updates/2021-11-18-release-search.sql b/util/updates/2021-11-18-release-search.sql
new file mode 100644
index 00000000..9627a188
--- /dev/null
+++ b/util/updates/2021-11-18-release-search.sql
@@ -0,0 +1,3 @@
+CREATE EXTENSION unaccent;
+\i sql/func.sql
+ALTER TABLE releases ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(hidden, ARRAY[title, original])) STORED;
diff --git a/util/updates/2021-11-19-more-search.sql b/util/updates/2021-11-19-more-search.sql
new file mode 100644
index 00000000..5b6a99b0
--- /dev/null
+++ b/util/updates/2021-11-19-more-search.sql
@@ -0,0 +1,9 @@
+BEGIN;
+\i sql/func.sql
+ALTER TABLE releases DROP COLUMN c_search;
+DROP FUNCTION search_gen(boolean,text[]);
+ALTER TABLE releases ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[title, original])) STORED;
+ALTER TABLE producers ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name, original]::text[]||string_to_array(alias,E'\n'))) STORED;
+ALTER TABLE chars ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name, original]::text[]||string_to_array(alias,E'\n'))) STORED;
+ALTER TABLE staff_alias ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name, original])) STORED;
+COMMIT;
diff --git a/util/updates/2021-11-19-vn-search.sql b/util/updates/2021-11-19-vn-search.sql
new file mode 100644
index 00000000..56ce6661
--- /dev/null
+++ b/util/updates/2021-11-19-vn-search.sql
@@ -0,0 +1,7 @@
+DROP TRIGGER vn_vnsearch_notify ON vn;
+DROP FUNCTION vn_vnsearch_notify();
+\i sql/func.sql
+
+-- Warning: slow
+\timing
+UPDATE vn SET c_search = search_gen_vn(id);
diff --git a/util/updates/2021-11-24-tagtrait-search.sql b/util/updates/2021-11-24-tagtrait-search.sql
new file mode 100644
index 00000000..7e4aaf50
--- /dev/null
+++ b/util/updates/2021-11-24-tagtrait-search.sql
@@ -0,0 +1,2 @@
+ALTER TABLE tags ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name]::text[]||string_to_array(alias,E'\n'))) STORED;
+ALTER TABLE traits ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name]::text[]||string_to_array(alias,E'\n'))) STORED;
diff --git a/util/updates/2021-11-29-release-unknown-uncensored.sql b/util/updates/2021-11-29-release-unknown-uncensored.sql
new file mode 100644
index 00000000..a3db3873
--- /dev/null
+++ b/util/updates/2021-11-29-release-unknown-uncensored.sql
@@ -0,0 +1,5 @@
+ALTER TABLE releases ALTER COLUMN uncensored DROP NOT NULL, ALTER COLUMN uncensored DROP DEFAULT;
+ALTER TABLE releases_hist ALTER COLUMN uncensored DROP NOT NULL, ALTER COLUMN uncensored DROP DEFAULT;
+\i sql/editfunc.sql
+UPDATE releases SET uncensored = NULL WHERE minage <> 18;
+UPDATE releases_hist SET uncensored = NULL WHERE minage <> 18;
diff --git a/util/updates/2021-12-06-extlinks-playstation-stores.sql b/util/updates/2021-12-06-extlinks-playstation-stores.sql
new file mode 100644
index 00000000..648fb74d
--- /dev/null
+++ b/util/updates/2021-12-06-extlinks-playstation-stores.sql
@@ -0,0 +1,13 @@
+ALTER TABLE releases
+ ADD COLUMN l_playstation_jp text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_na text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_eu text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist
+ ADD COLUMN l_playstation_jp text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_na text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_eu text NOT NULL DEFAULT '';
+ALTER TABLE wikidata
+ ADD COLUMN playstation_jp text[],
+ ADD COLUMN playstation_na text[],
+ ADD COLUMN playstation_eu text[];
+\i sql/editfunc.sql
diff --git a/util/updates/2021-12-15-api-sessions.sql b/util/updates/2021-12-15-api-sessions.sql
new file mode 100644
index 00000000..005fdb52
--- /dev/null
+++ b/util/updates/2021-12-15-api-sessions.sql
@@ -0,0 +1,3 @@
+ALTER TYPE session_type ADD VALUE 'api';
+DROP FUNCTION user_login(vndbid, bytea, bytea);
+\i sql/func.sql
diff --git a/util/updates/2022-02-05-popularity-non-null.sql b/util/updates/2022-02-05-popularity-non-null.sql
new file mode 100644
index 00000000..238d7867
--- /dev/null
+++ b/util/updates/2022-02-05-popularity-non-null.sql
@@ -0,0 +1,7 @@
+\i sql/func.sql
+SELECT update_vnvotestats();
+ALTER TABLE vn
+ ALTER COLUMN c_popularity SET NOT NULL,
+ ALTER COLUMN c_pop_rank SET NOT NULL,
+ ALTER COLUMN c_popularity SET DEFAULT 0,
+ ALTER COLUMN c_pop_rank SET DEFAULT 0;
diff --git a/util/updates/2022-02-11-vn-titles.sql b/util/updates/2022-02-11-vn-titles.sql
new file mode 100644
index 00000000..9332c2c4
--- /dev/null
+++ b/util/updates/2022-02-11-vn-titles.sql
@@ -0,0 +1,41 @@
+BEGIN;
+
+CREATE TABLE vn_titles (
+ id vndbid NOT NULL,
+ lang language NOT NULL,
+ title text NOT NULL,
+ latin text,
+ official boolean NOT NULL,
+ PRIMARY KEY(id, lang)
+);
+
+CREATE TABLE vn_titles_hist (
+ chid integer NOT NULL,
+ lang language NOT NULL,
+ title text NOT NULL,
+ latin text,
+ official boolean NOT NULL,
+ PRIMARY KEY(chid, lang)
+);
+
+INSERT INTO vn_titles SELECT id, olang, CASE WHEN original = '' THEN title ELSE original END, CASE WHEN original = '' THEN NULL ELSE title END, true FROM vn;
+INSERT INTO vn_titles_hist SELECT chid, olang, CASE WHEN original = '' THEN title ELSE original END, CASE WHEN original = '' THEN NULL ELSE title END, true FROM vn_hist;
+
+ALTER TABLE vn_titles ADD CONSTRAINT vn_titles_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
+ALTER TABLE vn_titles_hist ADD CONSTRAINT vn_titles_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE vn ADD CONSTRAINT vn_olang_fkey FOREIGN KEY (id,olang) REFERENCES vn_titles (id,lang) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_olang_fkey FOREIGN KEY (chid,olang)REFERENCES vn_titles_hist(chid,lang) DEFERRABLE INITIALLY DEFERRED;
+
+ALTER TABLE vn DROP COLUMN original
+ALTER TABLE vn DROP COLUMN title;
+ALTER TABLE vn_hist DROP COLUMN original
+ALTER TABLE vn_hist DROP COLUMN title;
+
+CREATE VIEW vnt AS SELECT v.*, COALESCE(vo.latin, vo.title) AS title, CASE WHEN vo.latin IS NULL THEN '' ELSE vo.title END AS alttitle FROM vn v JOIN vn_titles vo ON vo.id = v.id AND vo.lang = v.olang;
+
+ALTER TABLE users ADD COLUMN title_langs jsonb, ADD COLUMN alttitle_langs jsonb;
+
+COMMIT;
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-02-12-chinese-languages.sql b/util/updates/2022-02-12-chinese-languages.sql
new file mode 100644
index 00000000..330d9224
--- /dev/null
+++ b/util/updates/2022-02-12-chinese-languages.sql
@@ -0,0 +1,30 @@
+ALTER TYPE language ADD VALUE 'zh-Hans' AFTER 'zh';
+ALTER TYPE language ADD VALUE 'zh-Hant' AFTER 'zh-Hans';
+
+
+CREATE OR REPLACE FUNCTION migrate_notes_to_lang(rid vndbid, rlang language) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases_lang SET lang = rlang WHERE lang = 'zh';
+ UPDATE edit_releases SET notes = regexp_replace(notes, '\s*(Simplified|Traditional) Chinese\.?\s*', '', 'i');
+ UPDATE edit_revision SET requester = 'u1', ip = '0.0.0.0', comments = 'Automatic extraction of Chinese language from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+
+SELECT COUNT(*) FROM (SELECT migrate_notes_to_lang(id, 'zh-Hans')
+--SELECT 'http://whatever.blicky.net/'||r.id, regexp_replace(r.notes, '\s*Simplified Chinese\.?\s*', '', 'i')
+ FROM releases r WHERE NOT hidden
+ AND EXISTS(SELECT 1 FROM releases_lang rl WHERE rl.id = r.id AND rl.lang = 'zh')
+ AND NOT EXISTS(SELECT 1 FROM releases_lang rl WHERE rl.id = r.id AND rl.lang IN('zh-Hans', 'zh-Hant'))
+ AND notes ~* '(^|\n)Simplified Chinese(\.|\n|$)'
+) x;
+
+SELECT COUNT(*) FROM (SELECT migrate_notes_to_lang(id, 'zh-Hant')
+ FROM releases r WHERE NOT hidden
+ AND EXISTS(SELECT 1 FROM releases_lang rl WHERE rl.id = r.id AND rl.lang = 'zh')
+ AND NOT EXISTS(SELECT 1 FROM releases_lang rl WHERE rl.id = r.id AND rl.lang IN('zh-Hans', 'zh-Hant'))
+ AND notes ~* '(^|\n)Traditional Chinese(\.|\n|$)'
+) x;
+
+DROP FUNCTION migrate_notes_to_lang(vndbid, language);
diff --git a/util/updates/2022-02-19-vnt-sorttitle.sql b/util/updates/2022-02-19-vnt-sorttitle.sql
new file mode 100644
index 00000000..189b19fe
--- /dev/null
+++ b/util/updates/2022-02-19-vnt-sorttitle.sql
@@ -0,0 +1,3 @@
+DROP VIEW vnt;
+CREATE VIEW vnt AS SELECT v.*, COALESCE(vo.latin, vo.title) AS title, COALESCE(vo.latin, vo.title) AS sorttitle, CASE WHEN vo.latin IS NULL THEN '' ELSE vo.title END AS alttitle FROM vn v JOIN vn_titles vo ON vo.id = v.id AND vo.lang = v.olang;
+\i sql/perms.sql
diff --git a/util/updates/2022-03-23-vn-length-votes-uncounted.sql b/util/updates/2022-03-23-vn-length-votes-uncounted.sql
new file mode 100644
index 00000000..fa24d44c
--- /dev/null
+++ b/util/updates/2022-03-23-vn-length-votes-uncounted.sql
@@ -0,0 +1,6 @@
+BEGIN;
+ALTER TABLE vn_length_votes ALTER COLUMN speed DROP NOT NULL;
+UPDATE vn_length_votes SET speed = NULL WHERE ignore;
+ALTER TABLE vn_length_votes DROP COLUMN ignore;
+COMMIT;
+\i sql/func.sql
diff --git a/util/updates/2022-03-29-lengthvotes-private.sql b/util/updates/2022-03-29-lengthvotes-private.sql
new file mode 100644
index 00000000..5c721818
--- /dev/null
+++ b/util/updates/2022-03-29-lengthvotes-private.sql
@@ -0,0 +1,3 @@
+ALTER TABLE vn_length_votes ADD COLUMN private boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE vn_length_votes ALTER COLUMN private DROP DEFAULT;
+\i sql/func.sql
diff --git a/util/updates/2022-03-29-release-animation.sql b/util/updates/2022-03-29-release-animation.sql
new file mode 100644
index 00000000..cc6a5a20
--- /dev/null
+++ b/util/updates/2022-03-29-release-animation.sql
@@ -0,0 +1,29 @@
+BEGIN;
+
+CREATE DOMAIN animation AS smallint CHECK(value IS NULL OR value IN(0,1) OR ((value & (4+8+16+32)) > 0 AND (value & (256+512)) <> (256+512)));
+
+ALTER TABLE releases ADD COLUMN ani_story_sp animation;
+ALTER TABLE releases ADD COLUMN ani_story_cg animation;
+ALTER TABLE releases ADD COLUMN ani_cutscene animation;
+ALTER TABLE releases ADD COLUMN ani_ero_sp animation;
+ALTER TABLE releases ADD COLUMN ani_ero_cg animation;
+ALTER TABLE releases ADD COLUMN ani_bg boolean;
+ALTER TABLE releases ADD COLUMN ani_face boolean;
+
+ALTER TABLE releases_hist ADD COLUMN ani_story_sp animation;
+ALTER TABLE releases_hist ADD COLUMN ani_story_cg animation;
+ALTER TABLE releases_hist ADD COLUMN ani_cutscene animation;
+ALTER TABLE releases_hist ADD COLUMN ani_ero_sp animation;
+ALTER TABLE releases_hist ADD COLUMN ani_ero_cg animation;
+ALTER TABLE releases_hist ADD COLUMN ani_bg boolean;
+ALTER TABLE releases_hist ADD COLUMN ani_face boolean;
+
+UPDATE releases SET ani_story_sp = 0, ani_story_cg = 0, ani_face = false, ani_bg = false WHERE ani_story = 1;
+UPDATE releases_hist SET ani_story_sp = 0, ani_story_cg = 0, ani_face = false, ani_bg = false WHERE ani_story = 1;
+UPDATE releases SET ani_ero_sp = 0, ani_ero_cg = 0 WHERE ani_ero = 1;
+UPDATE releases_hist SET ani_ero_sp = 0, ani_ero_cg = 0 WHERE ani_ero = 1;
+
+ALTER TABLE releases ADD CONSTRAINT releases_cutscene_check CHECK(ani_cutscene <> 0 AND (ani_cutscene & (256+512)) = 0);
+
+\i sql/editfunc.sql
+COMMIT;
diff --git a/util/updates/2022-04-01-user-traits.sql b/util/updates/2022-04-01-user-traits.sql
new file mode 100644
index 00000000..a99b3d3a
--- /dev/null
+++ b/util/updates/2022-04-01-user-traits.sql
@@ -0,0 +1,8 @@
+CREATE TABLE users_traits (
+ id vndbid NOT NULL,
+ tid vndbid NOT NULL,
+ PRIMARY KEY(id, tid)
+);
+ALTER TABLE users_traits ADD CONSTRAINT users_traits_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE users_traits ADD CONSTRAINT users_traits_tid_fkey FOREIGN KEY (tid) REFERENCES traits (id);
+GRANT SELECT, INSERT, UPDATE, DELETE ON users_traits TO vndb_site;
diff --git a/util/updates/2022-04-05-releases-has-ero.sql b/util/updates/2022-04-05-releases-has-ero.sql
new file mode 100644
index 00000000..f31d9f04
--- /dev/null
+++ b/util/updates/2022-04-05-releases-has-ero.sql
@@ -0,0 +1,5 @@
+ALTER TABLE releases ADD COLUMN has_ero boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE releases_hist ADD COLUMN has_ero boolean NOT NULL DEFAULT FALSE;
+UPDATE releases SET has_ero = TRUE WHERE minage = 18;
+UPDATE releases_hist SET has_ero = TRUE WHERE minage = 18;
+\i sql/editfunc.sql
diff --git a/util/updates/2022-04-19-vn-default-poprank.sql b/util/updates/2022-04-19-vn-default-poprank.sql
new file mode 100644
index 00000000..080269e2
--- /dev/null
+++ b/util/updates/2022-04-19-vn-default-poprank.sql
@@ -0,0 +1 @@
+ALTER TABLE vn ALTER COLUMN c_pop_rank SET DEFAULT 10000000;
diff --git a/util/updates/2022-04-23-inuktitut-language.sql b/util/updates/2022-04-23-inuktitut-language.sql
new file mode 100644
index 00000000..ae9507c6
--- /dev/null
+++ b/util/updates/2022-04-23-inuktitut-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'iu' AFTER 'it';
diff --git a/util/updates/2022-06-16-users-debloat.sql b/util/updates/2022-06-16-users-debloat.sql
new file mode 100644
index 00000000..aa2ced78
--- /dev/null
+++ b/util/updates/2022-06-16-users-debloat.sql
@@ -0,0 +1,90 @@
+CREATE TABLE users_prefs (
+ id vndbid NOT NULL PRIMARY KEY,
+ 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
+ tableopts_c integer,
+ tableopts_v integer,
+ tableopts_vt integer, -- VN listing on tag pages
+ spoilers smallint NOT NULL DEFAULT 0,
+ tags_all boolean NOT NULL DEFAULT false,
+ tags_cont boolean NOT NULL DEFAULT true,
+ tags_ero boolean NOT NULL DEFAULT false,
+ tags_tech boolean NOT NULL DEFAULT true,
+ traits_sexual boolean NOT NULL DEFAULT false,
+ skin text NOT NULL DEFAULT '',
+ customcss text NOT NULL DEFAULT '',
+ ulist_votes jsonb,
+ ulist_vnlist jsonb,
+ ulist_wish jsonb,
+ vnlang jsonb, -- '$lang(-mtl)?' => true/false, which languages to expand/collapse on VN pages
+ title_langs jsonb,
+ alttitle_langs jsonb
+);
+
+INSERT INTO users_prefs SELECT id
+ , max_sexual
+ , max_violence
+ , last_reports
+ , tableopts_c
+ , tableopts_v
+ , tableopts_vt
+ , spoilers
+ , tags_all
+ , tags_cont
+ , tags_ero
+ , tags_tech
+ , traits_sexual
+ , skin
+ , customcss
+ , ulist_votes
+ , ulist_vnlist
+ , ulist_wish
+ , vnlang
+ , title_langs
+ , alttitle_langs
+ FROM users;
+
+ALTER TABLE users_prefs ADD CONSTRAINT users_prefs_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+
+ALTER TABLE users DROP COLUMN max_sexual ;
+ALTER TABLE users DROP COLUMN max_violence ;
+ALTER TABLE users DROP COLUMN last_reports ;
+ALTER TABLE users DROP COLUMN tableopts_c ;
+ALTER TABLE users DROP COLUMN tableopts_v ;
+ALTER TABLE users DROP COLUMN tableopts_vt ;
+ALTER TABLE users DROP COLUMN spoilers ;
+ALTER TABLE users DROP COLUMN tags_all ;
+ALTER TABLE users DROP COLUMN tags_cont ;
+ALTER TABLE users DROP COLUMN tags_ero ;
+ALTER TABLE users DROP COLUMN tags_tech ;
+ALTER TABLE users DROP COLUMN traits_sexual ;
+ALTER TABLE users DROP COLUMN skin ;
+ALTER TABLE users DROP COLUMN customcss ;
+ALTER TABLE users DROP COLUMN ulist_votes ;
+ALTER TABLE users DROP COLUMN ulist_vnlist ;
+ALTER TABLE users DROP COLUMN ulist_wish ;
+ALTER TABLE users DROP COLUMN vnlang ;
+ALTER TABLE users DROP COLUMN title_langs ;
+ALTER TABLE users DROP COLUMN alttitle_langs;
+
+ALTER TABLE users_shadow ADD COLUMN ip inet NOT NULL DEFAULT '0.0.0.0';
+UPDATE users_shadow SET ip = users.ip FROM users WHERE users.id = users_shadow.id;
+ALTER TABLE users DROP COLUMN ip;
+
+-- Rewrite the table to properly remove the columns.
+CLUSTER users USING users_pkey;
+
+-- users.ip is not accessible anymore, so we need a separate table to throttle
+-- registrations per IP.
+CREATE TABLE registration_throttle (
+ ip inet NOT NULL PRIMARY KEY,
+ timeout timestamptz NOT NULL
+);
+
+-- While I'm at it, let's remove changes.ip too. I've not used it in the past decade.
+ALTER TABLE changes DROP COLUMN ip;
+
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-06-18-user-prefs-prodrelexpand.sql b/util/updates/2022-06-18-user-prefs-prodrelexpand.sql
new file mode 100644
index 00000000..96fe5fe5
--- /dev/null
+++ b/util/updates/2022-06-18-user-prefs-prodrelexpand.sql
@@ -0,0 +1 @@
+ALTER TABLE users_prefs ADD COLUMN prodrelexpand boolean NOT NULL DEFAULT true;
diff --git a/util/updates/2022-06-19-user-prefs-vnrel.sql b/util/updates/2022-06-19-user-prefs-vnrel.sql
new file mode 100644
index 00000000..f9321b93
--- /dev/null
+++ b/util/updates/2022-06-19-user-prefs-vnrel.sql
@@ -0,0 +1,31 @@
+ALTER TABLE users_prefs ADD COLUMN vnrel_langs language[],
+ ADD COLUMN vnrel_olang boolean NOT NULL DEFAULT true,
+ ADD COLUMN vnrel_mtl boolean NOT NULL DEFAULT false;
+
+-- Attempt to infer vnrel_langs and vnrel_mtl from the old 'vnlang' column.
+BEGIN;
+
+CREATE OR REPLACE FUNCTION vnlang_to_langs(vnlang jsonb) RETURNS language[] AS $$
+DECLARE
+ ret language[];
+ del language;
+BEGIN
+ ret := enum_range(null::language);
+ FOR del IN SELECT key::language FROM jsonb_each(vnlang) x WHERE key NOT LIKE '%-mtl' AND value = 'false'
+ LOOP
+ ret := array_remove(ret, del);
+ END LOOP;
+ RETURN CASE WHEN array_length(ret,1) = array_length(enum_range(null::language),1) THEN NULL ELSE RET END;
+END$$ LANGUAGE plpgsql;
+
+WITH p(id,langs,mtl) AS (
+ SELECT id, vnlang_to_langs(vnlang), vnlang->'en-mtl' is not distinct from 'true'
+ FROM users_prefs WHERE vnlang IS NOT NULL
+) UPDATE users_prefs
+ SET vnrel_langs = langs, vnrel_mtl = mtl
+ FROM p
+ WHERE p.id = users_prefs.id AND (langs IS NOT NULL OR mtl);
+
+DROP FUNCTION vnlang_to_langs(jsonb);
+
+COMMIT;
diff --git a/util/updates/2022-06-20-changes-patrolling.sql b/util/updates/2022-06-20-changes-patrolling.sql
new file mode 100644
index 00000000..32ad9929
--- /dev/null
+++ b/util/updates/2022-06-20-changes-patrolling.sql
@@ -0,0 +1,8 @@
+CREATE TABLE changes_patrolled (
+ id integer NOT NULL,
+ uid vndbid NOT NULL,
+ PRIMARY KEY(id,uid)
+);
+ALTER TABLE changes_patrolled ADD CONSTRAINT changes_patrolled_id_fkey FOREIGN KEY (id) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE changes_patrolled ADD CONSTRAINT changes_patrolled_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+\i sql/perms.sql
diff --git a/util/updates/2022-06-21-tags-vn-lie.sql b/util/updates/2022-06-21-tags-vn-lie.sql
new file mode 100644
index 00000000..b4aafad9
--- /dev/null
+++ b/util/updates/2022-06-21-tags-vn-lie.sql
@@ -0,0 +1 @@
+ALTER TABLE tags_vn ADD COLUMN lie boolean;
diff --git a/util/updates/2022-07-31-vn-devstatus.sql b/util/updates/2022-07-31-vn-devstatus.sql
new file mode 100644
index 00000000..7bc709a0
--- /dev/null
+++ b/util/updates/2022-07-31-vn-devstatus.sql
@@ -0,0 +1,24 @@
+ALTER TABLE vn ADD COLUMN devstatus smallint NOT NULL DEFAULT 0;
+ALTER TABLE vn_hist ADD COLUMN devstatus smallint NOT NULL DEFAULT 0;
+\i sql/editfunc.sql
+
+UPDATE vn SET devstatus = 0 WHERE devstatus <> 0;
+
+-- Heuristic: VN is considered cancelled if it meets all of the following criteria:
+-- * doesn't have a complete release
+-- * doesn't have any release after 2020
+-- * doesn't have multiple partial releases
+-- * doesn't have both a trial and partial release (weird heuristic, but there's many matching in-dev games)
+UPDATE vn SET devstatus = 2 WHERE
+ id NOT IN(SELECT vid FROM releases_vn rv JOIN releases r ON r.id = rv.id WHERE NOT r.hidden AND rtype = 'complete' OR released > 20200000)
+ AND id NOT IN(SELECT vid FROM releases_vn rv JOIN releases r ON r.id = rv.id WHERE NOT r.hidden AND rtype = 'partial' GROUP BY vid HAVING COUNT(r.id) > 1)
+ AND id NOT IN(SELECT vid FROM releases_vn rv JOIN releases r ON r.id = rv.id WHERE NOT r.hidden AND rtype IN('partial','trial') GROUP BY vid HAVING COUNT(DISTINCT rtype) = 2);
+
+-- Heuristic: VN is considerd in development if it's not cancelled and meets one of the following:
+-- * Has a future release date
+-- * Has no complete releases and only a single partial release
+UPDATE vn SET devstatus = 1 WHERE devstatus = 0 AND (c_released > 22020731 OR (
+ id NOT IN(SELECT vid FROM releases_vn rv JOIN releases r ON r.id = rv.id WHERE NOT r.hidden AND rtype = 'complete')
+ AND id IN(SELECT vid FROM releases_vn rv JOIN releases r ON r.id = rv.id WHERE NOT r.hidden AND rtype = 'partial' GROUP BY vid HAVING COUNT(r.id) = 1)));
+
+UPDATE vn_hist SET devstatus = v.devstatus FROM changes c JOIN vn v ON c.itemid = v.id WHERE vn_hist.chid = c.id AND v.devstatus <> vn_hist.devstatus;
diff --git a/util/updates/2022-08-03-tags_vn_direct.sql b/util/updates/2022-08-03-tags_vn_direct.sql
new file mode 100644
index 00000000..e8a2445c
--- /dev/null
+++ b/util/updates/2022-08-03-tags_vn_direct.sql
@@ -0,0 +1,10 @@
+CREATE TABLE tags_vn_direct (
+ tag vndbid NOT NULL,
+ vid vndbid NOT NULL,
+ rating real NOT NULL,
+ spoiler smallint NOT NULL,
+ lie boolean NOT NULL
+);
+\i sql/func.sql
+\i sql/perms.sql
+SELECT tag_vn_calc(NULL);
diff --git a/util/updates/2022-08-24-ipinfo.sql b/util/updates/2022-08-24-ipinfo.sql
new file mode 100644
index 00000000..ffa00708
--- /dev/null
+++ b/util/updates/2022-08-24-ipinfo.sql
@@ -0,0 +1,17 @@
+CREATE TYPE ipinfo AS (
+ ip inet,
+ country text,
+ asn integer,
+ as_name text,
+ anonymous_proxy boolean,
+ sattelite_provider boolean,
+ anycast boolean,
+ drop boolean
+);
+
+ALTER TABLE audit_log ALTER COLUMN by_ip TYPE ipinfo USING ROW(by_ip,null,null,null,null,null,null,null);
+ALTER TABLE reports ALTER COLUMN ip TYPE ipinfo USING CASE WHEN ip IS NULL THEN NULL ELSE ROW(ip,null,null,null,null,null,null,null)::ipinfo END;
+
+ALTER TABLE users_shadow ALTER COLUMN ip DROP DEFAULT;
+ALTER TABLE users_shadow ALTER COLUMN ip DROP NOT NULL;
+ALTER TABLE users_shadow ALTER COLUMN ip TYPE ipinfo USING CASE WHEN ip = '0.0.0.0' THEN NULL ELSE ROW(ip,null,null,null,null,null,null,null)::ipinfo END;
diff --git a/util/updates/2022-08-25-customcss-csum.sql b/util/updates/2022-08-25-customcss-csum.sql
new file mode 100644
index 00000000..8a2a8938
--- /dev/null
+++ b/util/updates/2022-08-25-customcss-csum.sql
@@ -0,0 +1,3 @@
+ALTER TABLE users_prefs ADD COLUMN customcss_csum bigint NOT NULL DEFAULT 0;
+-- '1' is not exactly a checksum, but it'll do fine for the first version.
+UPDATE users_prefs SET customcss_csum = 1 WHERE customcss <> '';
diff --git a/util/updates/2022-08-25-staff-editions.sql b/util/updates/2022-08-25-staff-editions.sql
new file mode 100644
index 00000000..d5a731e5
--- /dev/null
+++ b/util/updates/2022-08-25-staff-editions.sql
@@ -0,0 +1,43 @@
+ALTER TYPE credit_type ADD VALUE 'translator' AFTER 'director';
+ALTER TYPE credit_type ADD VALUE 'editor' AFTER 'translator';
+ALTER TYPE credit_type ADD VALUE 'qa' AFTER 'editor';
+
+CREATE TABLE vn_editions (
+ id vndbid NOT NULL, -- [pub]
+ lang language, -- [pub]
+ eid smallint NOT NULL, -- [pub] (not stable across entry revisions)
+ official boolean NOT NULL DEFAULT TRUE, -- [pub]
+ name text NOT NULL, -- [pub]
+ PRIMARY KEY(id, eid)
+);
+
+CREATE TABLE vn_editions_hist (
+ chid integer NOT NULL,
+ lang language,
+ eid smallint NOT NULL,
+ official boolean NOT NULL DEFAULT TRUE,
+ name text NOT NULL,
+ PRIMARY KEY(chid, eid)
+);
+
+ALTER TABLE vn_staff ADD COLUMN eid smallint;
+ALTER TABLE vn_staff DROP CONSTRAINT vn_staff_pkey;
+CREATE UNIQUE INDEX vn_staff_pkey ON vn_staff (id, COALESCE(eid,-1::smallint), aid, role);
+
+ALTER TABLE vn_staff_hist ADD COLUMN eid smallint;
+ALTER TABLE vn_staff_hist DROP CONSTRAINT vn_staff_hist_pkey;
+CREATE UNIQUE INDEX vn_staff_hist_pkey ON vn_staff_hist (chid, COALESCE(eid,-1::smallint), aid, role);
+
+ALTER TABLE vn_staff DROP CONSTRAINT vn_staff_id_fkey;
+ALTER TABLE vn_staff_hist DROP CONSTRAINT vn_staff_hist_chid_fkey;
+
+ALTER TABLE vn_staff ADD CONSTRAINT vn_staff_id_eid_fkey FOREIGN KEY (id,eid) REFERENCES vn_editions (id,eid) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE vn_staff_hist ADD CONSTRAINT vn_staff_hist_chid_eid_fkey FOREIGN KEY (chid,eid) REFERENCES vn_editions_hist (chid,eid) DEFERRABLE INITIALLY DEFERRED;
+
+ALTER TABLE users_prefs
+ ADD COLUMN staffed_langs language[],
+ ADD COLUMN staffed_olang boolean NOT NULL DEFAULT true,
+ ADD COLUMN staffed_unoff boolean NOT NULL DEFAULT false;
+
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-08-28-basque-language.sql b/util/updates/2022-08-28-basque-language.sql
new file mode 100644
index 00000000..a0bd3899
--- /dev/null
+++ b/util/updates/2022-08-28-basque-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'eu' AFTER 'es';
diff --git a/util/updates/2022-08-30-tag-trait-prefs.sql b/util/updates/2022-08-30-tag-trait-prefs.sql
new file mode 100644
index 00000000..db2bec02
--- /dev/null
+++ b/util/updates/2022-08-30-tag-trait-prefs.sql
@@ -0,0 +1,23 @@
+CREATE TABLE users_prefs_tags (
+ id vndbid NOT NULL,
+ tid vndbid NOT NULL,
+ spoil smallint NOT NULL,
+ childs boolean NOT NULL,
+ PRIMARY KEY(id, tid)
+);
+
+ALTER TABLE users_prefs_tags ADD CONSTRAINT users_prefs_tags_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE users_prefs_tags ADD CONSTRAINT users_prefs_tags_tid_fkey FOREIGN KEY (tid) REFERENCES tags (id) ON DELETE CASCADE;
+
+CREATE TABLE users_prefs_traits (
+ id vndbid NOT NULL,
+ tid vndbid NOT NULL,
+ spoil smallint NOT NULL,
+ childs boolean NOT NULL,
+ PRIMARY KEY(id, tid)
+);
+
+ALTER TABLE users_prefs_traits ADD CONSTRAINT users_prefs_traits_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE users_prefs_traits ADD CONSTRAINT users_prefs_traits_tid_fkey FOREIGN KEY (tid) REFERENCES traits (id) ON DELETE CASCADE;
+
+\i sql/perms.sql
diff --git a/util/updates/2022-09-28-release-titles.sql b/util/updates/2022-09-28-release-titles.sql
new file mode 100644
index 00000000..4e875a14
--- /dev/null
+++ b/util/updates/2022-09-28-release-titles.sql
@@ -0,0 +1,81 @@
+BEGIN;
+
+CREATE TABLE releases_titles (
+ id vndbid NOT NULL,
+ lang language NOT NULL,
+ mtl boolean NOT NULL DEFAULT false,
+ title text,
+ latin text,
+ PRIMARY KEY(id, lang)
+);
+
+CREATE TABLE releases_titles_hist (
+ chid integer NOT NULL,
+ lang language NOT NULL,
+ mtl boolean NOT NULL DEFAULT false,
+ title text,
+ latin text,
+ PRIMARY KEY(chid, lang)
+);
+
+-- Fixup some old (deleted) entries that are missing a language field
+INSERT INTO releases_lang SELECT rv.id, v.olang, false FROM releases_vn rv JOIN vn v ON v.id = rv.vid WHERE NOT EXISTS(SELECT 1 FROM releases_lang rl WHERE rl.id = rv.id);
+INSERT INTO releases_lang_hist SELECT rv.chid, v.olang, false FROM releases_vn_hist rv JOIN vn v ON v.id = rv.vid WHERE NOT EXISTS(SELECT 1 FROM releases_lang_hist rl WHERE rl.chid = rv.chid);
+
+ALTER TABLE releases ADD COLUMN olang language NOT NULL DEFAULT 'ja';
+ALTER TABLE releases_hist ADD COLUMN olang language NOT NULL DEFAULT 'ja';
+
+-- 'releases' table needs an olang field now in order to select the proper
+-- default title to display. Inherit these from the related (lowest-id) VN
+-- entry if the release language matches, otherwise select an arbitrary one
+-- (preferring English).
+WITH rl (id, ol) AS (
+ SELECT DISTINCT ON(rv.id) rv.id, COALESCE(rl.lang, re.lang, rf.lang, v.olang)
+ FROM releases_vn rv
+ JOIN vn v ON v.id = rv.vid
+ LEFT JOIN releases_lang rl ON rl.id = rv.id AND rl.lang = v.olang
+ LEFT JOIN releases_lang re ON re.id = rv.id AND re.lang = 'en'
+ LEFT JOIN releases_lang rf ON rf.id = rv.id AND (rf.lang <> v.olang AND rf.lang <> 'en')
+ ORDER BY rv.id, rl.id NULLS LAST, rv.vid, rl.lang
+) UPDATE releases SET olang = ol FROM rl WHERE releases.id = rl.id AND ol <> 'ja';
+
+WITH rl (id, ol) AS (
+ SELECT DISTINCT ON(rv.chid) rv.chid, COALESCE(rl.lang, re.lang, rf.lang, v.olang)
+ FROM releases_vn_hist rv
+ JOIN vn v ON v.id = rv.vid
+ LEFT JOIN releases_lang_hist rl ON rl.chid = rv.chid AND rl.lang = v.olang
+ LEFT JOIN releases_lang_hist re ON re.chid = rv.chid AND re.lang = 'en'
+ LEFT JOIN releases_lang_hist rf ON rf.chid = rv.chid AND (rf.lang <> v.olang AND rf.lang <> 'en')
+ ORDER BY rv.chid, rl.chid NULLS LAST, rv.vid, rl.lang
+) UPDATE releases_hist SET olang = ol FROM rl WHERE chid = id AND ol <> 'ja';
+
+-- Copy all languages and set the title only for the "main" language as determined above.
+INSERT INTO releases_titles
+ SELECT rl.id, rl.lang, rl.mtl
+ , CASE WHEN rl.lang <> r.olang THEN NULL WHEN r.original = '' THEN r.title ELSE r.original END
+ , CASE WHEN rl.lang <> r.olang THEN NULL WHEN r.original = '' THEN NULL ELSE r.title END
+ FROM releases_lang rl
+ JOIN releases r ON r.id = rl.id;
+
+INSERT INTO releases_titles_hist
+ SELECT rl.chid, rl.lang, rl.mtl
+ , CASE WHEN rl.lang <> r.olang THEN NULL WHEN r.original = '' THEN r.title ELSE r.original END
+ , CASE WHEN rl.lang <> r.olang THEN NULL WHEN r.original = '' THEN NULL ELSE r.title END
+ FROM releases_lang_hist rl
+ JOIN releases_hist r ON r.chid = rl.chid;
+
+ALTER TABLE releases ALTER COLUMN c_search DROP NOT NULL, ALTER COLUMN c_search DROP EXPRESSION;
+
+ALTER TABLE releases DROP COLUMN title, DROP COLUMN original;
+ALTER TABLE releases_hist DROP COLUMN title, DROP COLUMN original;
+
+CREATE VIEW releasest AS SELECT r.*, COALESCE(ro.latin, ro.title) AS title, COALESCE(ro.latin, ro.title) AS sorttitle, CASE WHEN ro.latin IS NULL THEN '' ELSE ro.title END AS alttitle FROM releases r JOIN releases_titles ro ON ro.id = r.id AND ro.lang = r.olang;
+
+DROP TABLE releases_lang, releases_lang_hist;
+
+COMMIT;
+
+\i sql/tableattrs.sql
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-10-08-images-smallints.sql b/util/updates/2022-10-08-images-smallints.sql
new file mode 100644
index 00000000..316bf0c8
--- /dev/null
+++ b/util/updates/2022-10-08-images-smallints.sql
@@ -0,0 +1,19 @@
+ALTER TABLE images
+ ALTER c_votecount TYPE smallint,
+ ALTER c_weight TYPE smallint,
+ ALTER c_sexual_avg TYPE smallint USING COALESCE(c_sexual_avg *100, 200),
+ ALTER c_sexual_stddev TYPE smallint USING COALESCE(c_sexual_stddev *100, 0),
+ ALTER c_violence_avg TYPE smallint USING COALESCE(c_violence_avg *100, 200),
+ ALTER c_violence_stddev TYPE smallint USING COALESCE(c_violence_stddev*100, 0),
+ ALTER c_sexual_avg SET DEFAULT 200,
+ ALTER c_sexual_stddev SET DEFAULT 0,
+ ALTER c_violence_avg SET DEFAULT 200,
+ ALTER c_violence_stddev SET DEFAULT 0,
+ ALTER c_sexual_avg SET NOT NULL,
+ ALTER c_sexual_stddev SET NOT NULL,
+ ALTER c_violence_avg SET NOT NULL,
+ ALTER c_violence_stddev SET NOT NULL;
+
+\i sql/func.sql
+
+SELECT update_images_cache(NULL);
diff --git a/util/updates/2022-10-16-release-shop-links.sql b/util/updates/2022-10-16-release-shop-links.sql
new file mode 100644
index 00000000..6be52706
--- /dev/null
+++ b/util/updates/2022-10-16-release-shop-links.sql
@@ -0,0 +1,11 @@
+ALTER TABLE releases
+ ADD COLUMN l_nintendo_jp bigint NOT NULL DEFAULT 0,
+ ADD COLUMN l_nintendo_hk bigint NOT NULL DEFAULT 0,
+ ADD COLUMN l_nintendo text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_hk text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist
+ ADD COLUMN l_nintendo_jp bigint NOT NULL DEFAULT 0,
+ ADD COLUMN l_nintendo_hk bigint NOT NULL DEFAULT 0,
+ ADD COLUMN l_nintendo text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_hk text NOT NULL DEFAULT '';
+\i sql/editfunc.sql
diff --git a/util/updates/2022-10-22-tags_vn_inherit-lie.sql b/util/updates/2022-10-22-tags_vn_inherit-lie.sql
new file mode 100644
index 00000000..9ab1e329
--- /dev/null
+++ b/util/updates/2022-10-22-tags_vn_inherit-lie.sql
@@ -0,0 +1,4 @@
+ALTER TABLE tags_vn_inherit ADD COLUMN lie boolean;
+\i sql/func.sql
+SELECT tag_vn_calc(null);
+ALTER TABLE tags_vn_inherit ALTER COLUMN lie DROP NOT NULL;
diff --git a/util/updates/2022-10-27-trait-lies.sql b/util/updates/2022-10-27-trait-lies.sql
new file mode 100644
index 00000000..fff91ca8
--- /dev/null
+++ b/util/updates/2022-10-27-trait-lies.sql
@@ -0,0 +1,5 @@
+ALTER TABLE chars_traits ADD COLUMN lie boolean NOT NULL DEFAULT false;
+ALTER TABLE chars_traits_hist ADD COLUMN lie boolean NOT NULL DEFAULT false;
+ALTER TABLE traits_chars ADD COLUMN lie boolean NOT NULL DEFAULT false;
+\i sql/editfunc.sql
+\i sql/func.sql
diff --git a/util/updates/2022-10-31-ulist-vns-labels.sql b/util/updates/2022-10-31-ulist-vns-labels.sql
new file mode 100644
index 00000000..04973343
--- /dev/null
+++ b/util/updates/2022-10-31-ulist-vns-labels.sql
@@ -0,0 +1,137 @@
+-- This migration script is written so that it can be run while keeping VNDB
+-- online in read-only mode. Any writes to the database while this script is
+-- active will likely result in a deadlock or a bit of data loss.
+
+-- (An older version of this script attempted to do an in-place UPDATE on
+-- ulist_vns, but postgres didn't properly optimize that query in production
+-- and ended up taking the site down for 30 minutes. This version is both
+-- faster and doesn't require the site to go fully down)
+
+CREATE TABLE ulist_vns_tmp (
+ uid vndbid NOT NULL,
+ vid vndbid NOT NULL,
+ added timestamptz NOT NULL DEFAULT NOW(),
+ lastmod timestamptz NOT NULL DEFAULT NOW(),
+ vote_date timestamptz,
+ started date,
+ finished date,
+ vote smallint,
+ c_private boolean NOT NULL DEFAULT true,
+ labels smallint[] NOT NULL DEFAULT '{}',
+ notes text NOT NULL DEFAULT ''
+);
+
+INSERT INTO ulist_vns_tmp
+ SELECT uv.uid, uv.vid, uv.added, uv.lastmod, uv.vote_date, uv.started, uv.finished, uv.vote, coalesce(l.private, true), coalesce(l.labels, '{}'), uv.notes
+ FROM ulist_vns uv
+ LEFT JOIN (
+ SELECT uvl.uid, uvl.vid, bool_and(ul.private), array_agg(uvl.lbl::smallint ORDER BY uvl.lbl)
+ FROM ulist_vns_labels uvl
+ JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl
+ GROUP BY uvl.uid, uvl.vid
+ ) l(uid, vid, private, labels) ON l.uid = uv.uid AND l.vid = uv.vid
+ ORDER BY uv.uid, uv.vid;
+
+-- Attempt a perfect reconstruction of 'ulist_vns', so that constraint & index
+-- names match those of a newly created table with the correct name.
+ALTER INDEX ulist_vns_pkey RENAME TO ulist_vns_old_pkey;
+ALTER INDEX ulist_vns_voted RENAME TO ulist_vns_old_voted;
+
+\timing
+ALTER TABLE ulist_vns_tmp ADD CONSTRAINT ulist_vns_pkey PRIMARY KEY (uid, vid);
+ALTER TABLE ulist_vns_tmp ADD CONSTRAINT ulist_vns_vote_check CHECK(vote IS NULL OR vote BETWEEN 10 AND 100);
+CREATE INDEX ulist_vns_voted ON ulist_vns_tmp (vid, vote_date) WHERE vote IS NOT NULL;
+ALTER TABLE ulist_vns_tmp ADD CONSTRAINT ulist_vns_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE ulist_vns_tmp ADD CONSTRAINT ulist_vns_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+
+ANALYZE ulist_vns_tmp;
+GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns_tmp TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns_tmp TO vndb_multi;
+
+BEGIN;
+ALTER TABLE ulist_vns RENAME TO ulist_vns_old;
+ALTER TABLE ulist_vns_tmp RENAME TO ulist_vns;
+COMMIT;
+
+
+-- Let's not \i SQL files here, since we're running this script on an older commit.
+
+-- From util.sql
+
+CREATE OR REPLACE FUNCTION array_set(arr anycompatiblearray, elem anycompatible) RETURNS anycompatiblearray AS $$
+DECLARE
+ ret arr%TYPE;
+ e elem%TYPE;
+ added boolean := false;
+BEGIN
+ FOREACH e IN ARRAY arr LOOP
+ IF e = elem THEN RETURN arr;
+ ELSIF added or e < elem THEN ret := ret || e;
+ ELSE
+ ret := ret || elem || e;
+ added := true;
+ END IF;
+ END LOOP;
+ RETURN CASE WHEN added THEN ret ELSE ret || elem END;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+
+
+-- From func.sql
+
+CREATE OR REPLACE FUNCTION update_users_ulist_stats(vndbid) RETURNS void AS $$
+BEGIN
+ WITH cnt(uid, votes, vns, wish) AS (
+ SELECT u.id
+ , COUNT(uv.vid) FILTER (WHERE NOT uv.c_private AND uv.vote IS NOT NULL) -- Voted
+ , COUNT(uv.vid) FILTER (WHERE NOT uv.c_private AND NOT (uv.labels <@ ARRAY[5,6]::smallint[])) -- Labelled, but not wishlish/blacklist
+ , COUNT(uv.vid) FILTER (WHERE uwish.private IS NOT DISTINCT FROM false AND uv.labels && ARRAY[5::smallint]) -- Wishlist
+ FROM users u
+ LEFT JOIN ulist_vns uv ON uv.uid = u.id
+ LEFT JOIN ulist_labels uwish ON uwish.uid = u.id AND uwish.id = 5
+ WHERE $1 IS NULL OR u.id = $1
+ GROUP BY u.id
+ ) UPDATE users SET c_votes = votes, c_vns = vns, c_wish = wish
+ FROM cnt WHERE id = uid AND (c_votes, c_vns, c_wish) IS DISTINCT FROM (votes, vns, wish);
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION update_users_ulist_private(vndbid, vndbid) RETURNS void AS $$
+BEGIN
+ WITH p(uid,vid,private) AS (
+ SELECT uv.uid, uv.vid, COALESCE(bool_and(l.private), true)
+ FROM ulist_vns uv
+ LEFT JOIN unnest(uv.labels) x(id) ON true
+ LEFT JOIN ulist_labels l ON l.id = x.id AND l.uid = uv.uid
+ WHERE ($1 IS NULL OR uv.uid = $1)
+ AND ($2 IS NULL OR uv.vid = $2)
+ GROUP BY uv.uid, uv.vid
+ ) UPDATE ulist_vns SET c_private = p.private FROM p
+ WHERE ulist_vns.uid = p.uid AND ulist_vns.vid = p.vid AND ulist_vns.c_private <> p.private;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+-- From triggers.sql
+
+CREATE OR REPLACE FUNCTION ulist_voted_label() RETURNS trigger AS $$
+BEGIN
+ NEW.labels := CASE WHEN NEW.vote IS NULL THEN array_remove(NEW.labels, 7) ELSE array_set(NEW.labels, 7) END;
+ RETURN NEW;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER ulist_voted_label_ins BEFORE INSERT ON ulist_vns FOR EACH ROW EXECUTE PROCEDURE ulist_voted_label();
+CREATE TRIGGER ulist_voted_label_upd BEFORE UPDATE ON ulist_vns FOR EACH ROW WHEN ((OLD.vote IS NULL) <> (NEW.vote IS NULL)) EXECUTE PROCEDURE ulist_voted_label();
+
+
+
+
+ALTER TABLE ulist_labels ALTER COLUMN id TYPE smallint;
+
+
+-- These should be run after restarting vndb.pl with the new codebase.
+DROP TABLE ulist_vns_labels;
+DROP TABLE ulist_vns_old;
diff --git a/util/updates/2022-11-11-serbian-language.sql b/util/updates/2022-11-11-serbian-language.sql
new file mode 100644
index 00000000..48cda88e
--- /dev/null
+++ b/util/updates/2022-11-11-serbian-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'sr' AFTER 'sl';
diff --git a/util/updates/2022-11-29-api2-tokens.sql b/util/updates/2022-11-29-api2-tokens.sql
new file mode 100644
index 00000000..f88e9754
--- /dev/null
+++ b/util/updates/2022-11-29-api2-tokens.sql
@@ -0,0 +1,9 @@
+ALTER TYPE session_type ADD VALUE 'api2' AFTER 'api';
+
+ALTER TABLE sessions
+ ADD COLUMN notes text,
+ ADD COLUMN listread boolean NOT NULL DEFAULT false;
+
+\i sql/func.sql
+
+DROP FUNCTION user_isvalidsession(vndbid, bytea, session_type);
diff --git a/util/updates/2022-12-13-users-prefs-timezone.sql b/util/updates/2022-12-13-users-prefs-timezone.sql
new file mode 100644
index 00000000..1e90d967
--- /dev/null
+++ b/util/updates/2022-12-13-users-prefs-timezone.sql
@@ -0,0 +1 @@
+ALTER TABLE users_prefs ADD COLUMN timezone text NOT NULL DEFAULT '';
diff --git a/util/updates/2022-12-18-sql-tags-cache-merge.sql b/util/updates/2022-12-18-sql-tags-cache-merge.sql
new file mode 100644
index 00000000..83730e56
--- /dev/null
+++ b/util/updates/2022-12-18-sql-tags-cache-merge.sql
@@ -0,0 +1,8 @@
+DROP INDEX IF EXISTS tags_vn_direct_tag_vid;
+ALTER TABLE tags_vn_direct ADD PRIMARY KEY (tag, vid);
+
+DROP INDEX IF EXISTS tags_vn_inherit_tag_vid;
+ALTER TABLE tags_vn_inherit ADD PRIMARY KEY (tag, vid);
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-12-19-sql-traits-chars-cache-merge.sql b/util/updates/2022-12-19-sql-traits-chars-cache-merge.sql
new file mode 100644
index 00000000..e6196d68
--- /dev/null
+++ b/util/updates/2022-12-19-sql-traits-chars-cache-merge.sql
@@ -0,0 +1,5 @@
+DROP INDEX traits_chars_tid;
+ALTER TABLE traits_chars ADD PRIMARY KEY (tid, cid);
+CREATE INDEX traits_chars_cid ON traits_chars (cid);
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-12-19-sql-unique-null-not-distinct.sql b/util/updates/2022-12-19-sql-unique-null-not-distinct.sql
new file mode 100644
index 00000000..02c00628
--- /dev/null
+++ b/util/updates/2022-12-19-sql-unique-null-not-distinct.sql
@@ -0,0 +1,12 @@
+DROP INDEX threads_boards_pkey;
+CREATE UNIQUE INDEX threads_boards_pkey ON threads_boards (tid,type,iid) NULLS NOT DISTINCT;
+
+DROP INDEX vn_staff_pkey;
+CREATE UNIQUE INDEX vn_staff_pkey ON vn_staff (id, eid, aid, role) NULLS NOT DISTINCT;
+DROP INDEX vn_staff_hist_pkey;
+CREATE UNIQUE INDEX vn_staff_hist_pkey ON vn_staff_hist (chid, eid, aid, role) NULLS NOT DISTINCT;
+
+DROP INDEX chars_vns_pkey;
+CREATE UNIQUE INDEX chars_vns_pkey ON chars_vns (id, vid, rid) NULLS NOT DISTINCT;
+DROP INDEX chars_vns_hist_pkey;
+CREATE UNIQUE INDEX chars_vns_hist_pkey ON chars_vns_hist (chid, vid, rid) NULLS NOT DISTINCT;
diff --git a/util/updates/2023-01-08-cherokee-language.sql b/util/updates/2023-01-08-cherokee-language.sql
new file mode 100644
index 00000000..f48c5694
--- /dev/null
+++ b/util/updates/2023-01-08-cherokee-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'ck' AFTER 'cs';
diff --git a/util/updates/2023-01-17-api2-listwrite.sql b/util/updates/2023-01-17-api2-listwrite.sql
new file mode 100644
index 00000000..75279206
--- /dev/null
+++ b/util/updates/2023-01-17-api2-listwrite.sql
@@ -0,0 +1,3 @@
+ALTER TABLE sessions ADD COLUMN listwrite boolean NOT NULL DEFAULT false;
+DROP FUNCTION user_api2_set_token(vndbid, vndbid, bytea, bytea, text, boolean);
+\i sql/func.sql
diff --git a/util/updates/2023-01-19-delete-admin-setpass.sql b/util/updates/2023-01-19-delete-admin-setpass.sql
new file mode 100644
index 00000000..3f9b158d
--- /dev/null
+++ b/util/updates/2023-01-19-delete-admin-setpass.sql
@@ -0,0 +1 @@
+DROP FUNCTION user_admin_setpass(vndbid, vndbid, bytea, bytea);
diff --git a/util/updates/2023-02-01-sql-titleprefs.sql b/util/updates/2023-02-01-sql-titleprefs.sql
new file mode 100644
index 00000000..4f49427d
--- /dev/null
+++ b/util/updates/2023-02-01-sql-titleprefs.sql
@@ -0,0 +1,67 @@
+\i sql/schema.sql
+
+-- The old JSON structure is messy; the same language may be listed multiple
+-- times and original language isn't always present or the last option. This
+-- function attempts a clean conversion, where the preference is the same but
+-- without the weirdness.
+CREATE OR REPLACE FUNCTION json2titleprefs(title_langs jsonb, alttitle_langs jsonb) RETURNS titleprefs AS $$
+ WITH t_parsed (rank, lang, latin, prio, official) AS (
+ -- Parse, add rank & prio
+ SELECT row_number() OVER(ROWS CURRENT ROW), lang, COALESCE(latin, false)
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN 3 WHEN official IS NOT DISTINCT FROM true THEN 2 ELSE 1 END
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN NULL ELSE COALESCE(official, false) END
+ FROM jsonb_to_recordset(COALESCE(title_langs, '[{"latin":true}]'))
+ AS x(lang language, latin bool, official bool, original bool)
+ ), t (rank, lang, latin, official) AS (
+ -- Filter, remove duplicates and re-rank
+ SELECT CASE WHEN lang IS NULL THEN NULL ELSE row_number() OVER(ORDER BY rank) END, lang, latin, official
+ FROM t_parsed x
+ WHERE rank <= COALESCE((SELECT MIN(rank) FROM t_parsed WHERE lang IS NULL), 10)
+ AND NOT EXISTS(SELECT 1 FROM t_parsed y WHERE x.lang = y.lang AND y.rank < x.rank AND y.prio <= x.prio)
+
+ -- Same, for alttitle
+ ), a_parsed (rank, lang, latin, prio, official) AS (
+ SELECT row_number() OVER(ROWS CURRENT ROW), lang, COALESCE(latin, false)
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN 3 WHEN official IS NOT DISTINCT FROM true THEN 2 ELSE 1 END
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN NULL ELSE COALESCE(official, false) END
+ FROM jsonb_to_recordset(alttitle_langs)
+ AS x(lang language, latin bool, official bool, original bool)
+ ), a (rank, lang, latin, official) AS (
+ SELECT CASE WHEN lang IS NULL THEN NULL ELSE row_number() OVER(ORDER BY rank) END, lang, latin, official
+ FROM a_parsed x
+ WHERE rank <= COALESCE((SELECT MIN(rank) FROM a_parsed WHERE lang IS NULL), 10)
+ AND NOT EXISTS(SELECT 1 FROM a_parsed y WHERE x.lang = y.lang AND y.rank < x.rank AND y.prio <= x.prio)
+
+ ) SELECT ROW(
+ (SELECT lang FROM t WHERE rank = 1)
+ , (SELECT lang FROM t WHERE rank = 2)
+ , (SELECT lang FROM t WHERE rank = 3)
+ , (SELECT lang FROM t WHERE rank = 4)
+ , (SELECT lang FROM a WHERE rank = 1)
+ , (SELECT lang FROM a WHERE rank = 2)
+ , (SELECT lang FROM a WHERE rank = 3)
+ , (SELECT lang FROM a WHERE rank = 4)
+ , COALESCE((SELECT latin FROM t WHERE rank = 1), false)
+ , COALESCE((SELECT latin FROM t WHERE rank = 2), false)
+ , COALESCE((SELECT latin FROM t WHERE rank = 3), false)
+ , COALESCE((SELECT latin FROM t WHERE rank = 4), false)
+ , COALESCE((SELECT latin FROM t WHERE lang IS NULL), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 1), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 2), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 3), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 4), false)
+ , COALESCE((SELECT latin FROM a WHERE lang IS NULL), false)
+ , (SELECT official FROM t WHERE rank = 1)
+ , (SELECT official FROM t WHERE rank = 2)
+ , (SELECT official FROM t WHERE rank = 3)
+ , (SELECT official FROM t WHERE rank = 4)
+ , (SELECT official FROM a WHERE rank = 1)
+ , (SELECT official FROM a WHERE rank = 2)
+ , (SELECT official FROM a WHERE rank = 3)
+ , (SELECT official FROM a WHERE rank = 4)
+ )::titleprefs
+$$ LANGUAGE SQL IMMUTABLE;
+
+
+ALTER TABLE users_prefs ADD COLUMN titles titleprefs;
+UPDATE users_prefs SET titles = json2titleprefs(title_langs, alttitle_langs) WHERE title_langs IS NOT NULL OR alttitle_langs IS NOT NULL;
diff --git a/util/updates/2023-02-02-sql-titleprefs.sql b/util/updates/2023-02-02-sql-titleprefs.sql
new file mode 100644
index 00000000..73f7c6de
--- /dev/null
+++ b/util/updates/2023-02-02-sql-titleprefs.sql
@@ -0,0 +1,5 @@
+CREATE TYPE item_info_type AS (title text, alttitle text, uid vndbid, hidden boolean, locked boolean);
+\i sql/func.sql
+
+-- Can be dropped after reloading all code.
+--DROP FUNCTION item_info(vndbid, int);
diff --git a/util/updates/2023-02-04-producerst.sql b/util/updates/2023-02-04-producerst.sql
new file mode 100644
index 00000000..ee5804a9
--- /dev/null
+++ b/util/updates/2023-02-04-producerst.sql
@@ -0,0 +1,15 @@
+ALTER TABLE producers ALTER COLUMN original DROP NOT NULL;
+ALTER TABLE producers ALTER COLUMN original DROP DEFAULT;
+ALTER TABLE producers_hist ALTER COLUMN original DROP NOT NULL;
+ALTER TABLE producers_hist ALTER COLUMN original DROP DEFAULT;
+UPDATE producers SET original = NULL WHERE original = '';
+UPDATE producers_hist SET original = NULL WHERE original = '';
+
+CREATE VIEW producerst AS
+ SELECT id, type, lang, l_wikidata, locked, hidden, alias, website, "desc", l_wp, c_search
+ , name, original AS altname, name AS sortname
+ FROM producers;
+
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-02-19-title-langs.sql b/util/updates/2023-02-19-title-langs.sql
new file mode 100644
index 00000000..62510e2b
--- /dev/null
+++ b/util/updates/2023-02-19-title-langs.sql
@@ -0,0 +1,5 @@
+DROP TYPE item_info_type CASCADE;
+DROP VIEW vnt, releasest, producerst CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-02-20-titleprefs-staff.sql b/util/updates/2023-02-20-titleprefs-staff.sql
new file mode 100644
index 00000000..b7e3047e
--- /dev/null
+++ b/util/updates/2023-02-20-titleprefs-staff.sql
@@ -0,0 +1,18 @@
+ALTER TABLE staff_alias ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+ALTER TABLE staff_alias_hist ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+UPDATE staff_alias SET original = null WHERE original = '';
+UPDATE staff_alias_hist SET original = null WHERE original = '';
+
+CREATE VIEW staff_aliast AS
+ -- Everything from 'staff', except 'aid' is renamed to 'main'
+ SELECT s.id, s.gender, s.lang, s.l_anidb, s.l_wikidata, s.l_pixiv, s.locked, s.hidden, s."desc", s.l_wp, s.l_site, s.l_twitter, s.aid AS main
+ , sa.aid, sa.name, sa.original
+ , ARRAY [ s.lang::text, sa.name
+ , s.lang::text, COALESCE(sa.original, sa.name) ] AS title
+ , sa.name AS sorttitle
+ FROM staff s
+ JOIN staff_alias sa ON sa.id = s.id;
+
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-02-21-tt-prefs.sql b/util/updates/2023-02-21-tt-prefs.sql
new file mode 100644
index 00000000..24d527e0
--- /dev/null
+++ b/util/updates/2023-02-21-tt-prefs.sql
@@ -0,0 +1,7 @@
+ALTER TABLE users_prefs_tags ALTER COLUMN spoil DROP NOT NULL;
+ALTER TABLE users_prefs_tags ADD COLUMN color text;
+ALTER TABLE users_prefs_traits ALTER COLUMN spoil DROP NOT NULL;
+ALTER TABLE users_prefs_traits ADD COLUMN color text;
+
+UPDATE users_prefs_tags SET spoil = 0, color = 'standout' WHERE spoil = -1;
+UPDATE users_prefs_traits SET spoil = 0, color = 'standout' WHERE spoil = -1;
diff --git a/util/updates/2023-03-09-chars-lang.sql b/util/updates/2023-03-09-chars-lang.sql
new file mode 100644
index 00000000..dcfffa0d
--- /dev/null
+++ b/util/updates/2023-03-09-chars-lang.sql
@@ -0,0 +1,10 @@
+ALTER TABLE chars ADD COLUMN c_lang language NOT NULL DEFAULT 'ja';
+
+WITH x(id,lang) AS (
+ SELECT DISTINCT ON (cv.id) cv.id, v.olang
+ FROM chars_vns cv
+ JOIN vn v ON v.id = cv.vid
+ ORDER BY cv.id, v.hidden, v.c_released
+) UPDATE chars c SET c_lang = x.lang FROM x WHERE c.id = x.id AND c.c_lang <> x.lang;
+
+\i sql/func.sql
diff --git a/util/updates/2023-03-09b-chars-titleprefs.sql b/util/updates/2023-03-09b-chars-titleprefs.sql
new file mode 100644
index 00000000..c78a62d6
--- /dev/null
+++ b/util/updates/2023-03-09b-chars-titleprefs.sql
@@ -0,0 +1,14 @@
+ALTER TABLE chars ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+ALTER TABLE chars_hist ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+UPDATE chars SET original = NULL WHERE original = '';
+UPDATE chars_hist SET original = NULL WHERE original = '';
+
+CREATE VIEW charst AS
+ SELECT *
+ , ARRAY [ c_lang::text, name
+ , c_lang::text, COALESCE(original, name) ] AS title
+ , name AS sorttitle
+ FROM chars;
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-03-20-producer-name-swap.sql b/util/updates/2023-03-20-producer-name-swap.sql
new file mode 100644
index 00000000..bf04fb12
--- /dev/null
+++ b/util/updates/2023-03-20-producer-name-swap.sql
@@ -0,0 +1,13 @@
+ALTER TABLE producers RENAME COLUMN original TO latin;
+ALTER TABLE producers_hist RENAME COLUMN original TO latin;
+
+UPDATE producers SET name = latin, latin = name WHERE latin IS NOT NULL;
+UPDATE producers_hist SET name = latin, latin = name WHERE latin IS NOT NULL;
+
+DROP FUNCTION titleprefs_swap(titleprefs, language, text, text);
+DROP VIEW producerst CASCADE;
+
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-03-20b-chars-name-swap.sql b/util/updates/2023-03-20b-chars-name-swap.sql
new file mode 100644
index 00000000..952aba80
--- /dev/null
+++ b/util/updates/2023-03-20b-chars-name-swap.sql
@@ -0,0 +1,12 @@
+ALTER TABLE chars RENAME COLUMN original TO latin;
+ALTER TABLE chars_hist RENAME COLUMN original TO latin;
+
+UPDATE chars SET name = latin, latin = name WHERE latin IS NOT NULL;
+UPDATE chars_hist SET name = latin, latin = name WHERE latin IS NOT NULL;
+
+DROP VIEW charst CASCADE;
+
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-03-20c-staff-name-swap.sql b/util/updates/2023-03-20c-staff-name-swap.sql
new file mode 100644
index 00000000..c2474d2f
--- /dev/null
+++ b/util/updates/2023-03-20c-staff-name-swap.sql
@@ -0,0 +1,14 @@
+ALTER TABLE staff_alias RENAME COLUMN original TO latin;
+ALTER TABLE staff_alias_hist RENAME COLUMN original TO latin;
+
+UPDATE staff_alias SET name = latin, latin = name WHERE latin IS NOT NULL;
+UPDATE staff_alias_hist SET name = latin, latin = name WHERE latin IS NOT NULL;
+
+DROP VIEW staff_aliast CASCADE;
+
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+DROP FUNCTION titleprefs_swapold(titleprefs, language, text, text);
diff --git a/util/updates/2023-03-24-search-cache.sql b/util/updates/2023-03-24-search-cache.sql
new file mode 100644
index 00000000..f72034cf
--- /dev/null
+++ b/util/updates/2023-03-24-search-cache.sql
@@ -0,0 +1,44 @@
+-- Part one, can be done while the site is running old code
+
+CREATE EXTENSION pg_trgm;
+
+CREATE TABLE search_cache (
+ id vndbid NOT NULL,
+ subid integer, -- only for staff_alias.id at the moment
+ prio smallint NOT NULL, -- 1 for indirect titles, 2 for aliases, 3 for main titles
+ label text NOT NULL COLLATE "C"
+) PARTITION BY RANGE(id);
+
+CREATE TABLE search_cache_v PARTITION OF search_cache FOR VALUES FROM ('v1') TO (vndbid_max('v'));
+CREATE TABLE search_cache_r PARTITION OF search_cache FOR VALUES FROM ('r1') TO (vndbid_max('r'));
+CREATE TABLE search_cache_c PARTITION OF search_cache FOR VALUES FROM ('c1') TO (vndbid_max('c'));
+CREATE TABLE search_cache_p PARTITION OF search_cache FOR VALUES FROM ('p1') TO (vndbid_max('p'));
+CREATE TABLE search_cache_s PARTITION OF search_cache FOR VALUES FROM ('s1') TO (vndbid_max('s'));
+CREATE TABLE search_cache_g PARTITION OF search_cache FOR VALUES FROM ('g1') TO (vndbid_max('g'));
+CREATE TABLE search_cache_i PARTITION OF search_cache FOR VALUES FROM ('i1') TO (vndbid_max('i'));
+
+CREATE INDEX search_cache_id ON search_cache (id);
+CREATE INDEX search_cache_label ON search_cache USING GIN (label gin_trgm_ops);
+
+\i sql/perms.sql
+\i sql/func.sql
+\i sql/rebuild-search-cache.sql
+
+
+-- Part two, can be done after the site has been reloaded with the new code
+
+ALTER TABLE chars DROP COLUMN c_search CASCADE;
+ALTER TABLE producers DROP COLUMN c_search CASCADE;
+ALTER TABLE releases DROP COLUMN c_search CASCADE;
+ALTER TABLE staff_alias DROP COLUMN c_search CASCADE;
+ALTER TABLE tags DROP COLUMN c_search CASCADE;
+ALTER TABLE traits DROP COLUMN c_search CASCADE;
+ALTER TABLE vn DROP COLUMN c_search CASCADE;
+
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+DROP FUNCTION search_gen_vn(vndbid);
+DROP FUNCTION search_gen_release(vndbid);
+DROP FUNCTION search_gen(text[]);
diff --git a/util/updates/2023-04-03-extlinks-booth.sql b/util/updates/2023-04-03-extlinks-booth.sql
new file mode 100644
index 00000000..7185b289
--- /dev/null
+++ b/util/updates/2023-04-03-extlinks-booth.sql
@@ -0,0 +1,57 @@
+ALTER TABLE releases ADD COLUMN l_booth integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_booth integer NOT NULL DEFAULT 0;
+\i sql/editfunc.sql
+
+DROP VIEW releasest CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+
+-- Extract from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_booth(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_booth = regexp_replace(website, '^https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+).*', '\1')::int, website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to BOOTH link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_website_to_booth(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)';
+DROP FUNCTION migrate_website_to_booth(vndbid);
+
+
+
+-- Extract from notes in "Available at .." format
+CREATE OR REPLACE FUNCTION migrate_notes_to_booth(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET
+ l_booth = regexp_replace(notes, '^.*\s*(?:Also available|Available) (?:on|at|from) \[url=https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*).*$', '\1', 'i')::int,
+ notes = regexp_replace(notes, '\s*(?:Also available|Available) (?:on|at|from) \[url=https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)', '', 'i');
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of BOOTH link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_notes_to_booth(id) FROM releases WHERE NOT hidden AND l_booth = 0
+ AND notes ~* '\s*(?:Also available|Available) (?:on|at|from) \[url=https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)'
+ AND id <> 'r104675';
+DROP FUNCTION migrate_notes_to_booth(vndbid);
+
+
+
+-- Extract from notes when it's the only thing in the note
+CREATE OR REPLACE FUNCTION migrate_notes_to_booth2(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_booth = regexp_replace(notes, '^(?:booth|available on)?:?\s*(?:\[url=)?https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)(?:\][^\[]*\[/url\])?\.?$', '\1', 'i')::int, notes = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of BOOTH link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_notes_to_booth2(id) FROM releases WHERE NOT hidden AND l_booth = 0
+ AND notes ~* '^(?:booth|available on)?:?\s*(?:\[url=)?https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)(?:\][^\[]*\[/url\])?\.?$';
+DROP FUNCTION migrate_notes_to_booth2(vndbid);
+
+
+-- select 'https://vndb.org/'||id, title[2] from releasest where not hidden and notes like '%booth.pm%' order by id;
diff --git a/util/updates/2023-04-05-extlinks-patreon-substar.sql b/util/updates/2023-04-05-extlinks-patreon-substar.sql
new file mode 100644
index 00000000..1699c924
--- /dev/null
+++ b/util/updates/2023-04-05-extlinks-patreon-substar.sql
@@ -0,0 +1,102 @@
+ALTER TABLE releases
+ ADD COLUMN l_patreonp integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_patreon text NOT NULL DEFAULT '',
+ ADD COLUMN l_substar text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist
+ ADD COLUMN l_patreonp integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_patreon text NOT NULL DEFAULT '',
+ ADD COLUMN l_substar text NOT NULL DEFAULT '';
+\i sql/editfunc.sql
+
+DROP VIEW releasest CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+
+
+-- patreonp from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_patreonp(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_patreonp = regexp_replace(website, '^https?://(?:www\.)?patreon\.com/posts/(?:[^/?]+-)?([0-9]+).*$', '\1')::int, website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to Patreon link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_website_to_patreonp(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:www\.)?patreon\.com/posts/(?:[^/?]+-)?([0-9]+)') x;
+DROP FUNCTION migrate_website_to_patreonp(vndbid);
+
+
+
+-- patreon from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_patreon(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_patreon = regexp_replace(website, '^https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+).*$', '\1'), website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to Patreon link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_website_to_patreon(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)') x;
+DROP FUNCTION migrate_website_to_patreon(vndbid);
+
+
+
+
+-- patreon from notes field
+CREATE OR REPLACE FUNCTION migrate_notes_to_patreon(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET
+ l_patreon = regexp_replace(notes, '^.*\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*).*$', '\1', 'i'),
+ notes = regexp_replace(notes, '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)', '', 'i');
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of Patreon link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_notes_to_patreon(id) FROM releases WHERE NOT hidden AND l_patreon = ''
+ AND notes ~* '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)'
+ AND id NOT IN('r55516', 'r54903', 'r50178')
+) x;
+DROP FUNCTION migrate_notes_to_patreon(vndbid);
+
+
+
+
+-- substar from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_substar(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_substar = regexp_replace(website, '^https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+).*$', '\1'), website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to SubscribeStar link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_website_to_substar(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)') x;
+DROP FUNCTION migrate_website_to_substar(vndbid);
+
+
+
+
+-- substar from notes field
+CREATE OR REPLACE FUNCTION migrate_notes_to_substar(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET
+ l_substar = regexp_replace(notes, '^.*\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*).*$', '\1', 'i'),
+ notes = regexp_replace(notes, '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)', '', 'i');
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of SubscribeStar link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_notes_to_substar(id) FROM releases WHERE NOT hidden AND l_substar = ''
+ AND notes ~* '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)'
+) x;
+DROP FUNCTION migrate_notes_to_substar(vndbid);
+
+
+
+--select 'https://vndb.org/'||id, title[2], website from releasest where not hidden and website like 'https://www.patreon.com%' order by id;
+--select 'https://vndb.org/'||id, title[2] from releasest where not hidden and notes like '%https://www.patreon.com%' order by id;
+--select 'https://vndb.org/'||id, title[2] from releasest where not hidden and notes like '%subscribestar%' order by id;
diff --git a/util/updates/2023-04-19-images-uploader.sql b/util/updates/2023-04-19-images-uploader.sql
new file mode 100644
index 00000000..c6255775
--- /dev/null
+++ b/util/updates/2023-04-19-images-uploader.sql
@@ -0,0 +1,29 @@
+ALTER TABLE images ADD COLUMN uploader vndbid;
+ALTER TABLE images ADD CONSTRAINT images_uploader_fkey FOREIGN KEY (uploader) REFERENCES users (id) ON DELETE SET DEFAULT;
+
+
+-- Attempt to find the original uploader of an image by finding the first
+-- change that references it.
+WITH cv (id, uid) AS (
+ SELECT DISTINCT ON (v.image) v.image, c.requester
+ FROM vn_hist v
+ JOIN changes c ON c.id = v.chid
+ WHERE v.image IS NOT NULL AND c.requester IS NOT NULL AND c.requester <> 'u1'
+ ORDER BY v.image, v.chid
+) UPDATE images SET uploader = uid FROM cv WHERE uploader IS NULL AND cv.id = images.id;
+
+WITH sf (id, uid) AS (
+ SELECT DISTINCT ON (v.scr) v.scr, c.requester
+ FROM vn_screenshots_hist v
+ JOIN changes c ON c.id = v.chid
+ WHERE c.requester IS NOT NULL AND c.requester <> 'u1'
+ ORDER BY v.scr, v.chid
+) UPDATE images SET uploader = uid FROM sf WHERE uploader IS NULL AND sf.id = images.id;
+
+WITH ch (id, uid) AS (
+ SELECT DISTINCT ON (v.image) v.image, c.requester
+ FROM chars_hist v
+ JOIN changes c ON c.id = v.chid
+ WHERE v.image IS NOT NULL AND c.requester IS NOT NULL AND c.requester <> 'u1'
+ ORDER BY v.image, v.chid
+) UPDATE images SET uploader = uid FROM ch WHERE uploader IS NULL AND ch.id = images.id;
diff --git a/util/updates/2023-04-19-jastusa-shoplinks.sql b/util/updates/2023-04-19-jastusa-shoplinks.sql
new file mode 100644
index 00000000..7d93fe9e
--- /dev/null
+++ b/util/updates/2023-04-19-jastusa-shoplinks.sql
@@ -0,0 +1,8 @@
+CREATE TABLE shop_jastusa (
+ lastfetch timestamptz,
+ deadsince timestamptz,
+ id text NOT NULL PRIMARY KEY,
+ price text NOT NULL DEFAULT '',
+ slug text NOT NULL DEFAULT ''
+);
+\i sql/perms.sql
diff --git a/util/updates/2023-05-03-sql-noquote.sql b/util/updates/2023-05-03-sql-noquote.sql
new file mode 100644
index 00000000..9a8168a0
--- /dev/null
+++ b/util/updates/2023-05-03-sql-noquote.sql
@@ -0,0 +1,23 @@
+ALTER TABLE chars RENAME COLUMN "desc" TO description;
+ALTER TABLE chars_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE producers RENAME COLUMN "desc" TO description;
+ALTER TABLE producers_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE staff RENAME COLUMN "desc" TO description;
+ALTER TABLE staff_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE vn RENAME COLUMN "desc" TO description;
+ALTER TABLE vn_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE traits RENAME COLUMN "group" TO gid;
+ALTER TABLE traits RENAME COLUMN "order" TO gorder;
+ALTER TABLE traits_hist RENAME COLUMN "order" TO gorder;
+
+ALTER TABLE traits DROP CONSTRAINT traits_group_fkey;
+ALTER TABLE traits ADD CONSTRAINT traits_gid_fkey FOREIGN KEY (gid) REFERENCES traits (id);
+
+DROP VIEW charst CASCADE;
+DROP VIEW producerst CASCADE;
+DROP VIEW staff_aliast CASCADE;
+DROP VIEW vnt CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-06-19-tags-vn-direct-count.sql b/util/updates/2023-06-19-tags-vn-direct-count.sql
new file mode 100644
index 00000000..d784725c
--- /dev/null
+++ b/util/updates/2023-06-19-tags-vn-direct-count.sql
@@ -0,0 +1,4 @@
+ALTER TABLE tags_vn_direct ADD COLUMN count smallint NOT NULL DEFAULT 0;
+\i sql/func.sql
+SELECT tag_vn_calc(NULL);
+ALTER TABLE tags_vn_direct ALTER COLUMN count DROP DEFAULT;
diff --git a/util/updates/2023-07-11-vn-rating.sql b/util/updates/2023-07-11-vn-rating.sql
new file mode 100644
index 00000000..9996df88
--- /dev/null
+++ b/util/updates/2023-07-11-vn-rating.sql
@@ -0,0 +1,8 @@
+DROP VIEW vnt CASCADE;
+ALTER TABLE vn DROP COLUMN c_popularity;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+-- Twice, to stabilize the "top50" variable.
+SELECT update_vnvotestats();
+SELECT update_vnvotestats();
diff --git a/util/updates/2023-09-15-quotes-rand.sql b/util/updates/2023-09-15-quotes-rand.sql
new file mode 100644
index 00000000..001f0770
--- /dev/null
+++ b/util/updates/2023-09-15-quotes-rand.sql
@@ -0,0 +1,37 @@
+BEGIN;
+ALTER TABLE quotes
+ DROP CONSTRAINT quotes_pkey,
+ DROP CONSTRAINT quotes_vid_fkey;
+ALTER TABLE quotes RENAME TO quotes_old;
+
+CREATE TABLE quotes (
+ vid vndbid NOT NULL,
+ rand real,
+ approved boolean NOT NULL DEFAULT FALSE,
+ quote text NOT NULL,
+ PRIMARY KEY(vid, quote)
+);
+
+INSERT INTO quotes SELECT vid, NULL, TRUE, quote FROM quotes_old;
+
+ALTER TABLE quotes ADD CONSTRAINT quotes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+CREATE INDEX quotes_rand ON quotes (rand) WHERE rand IS NOT NULL;
+
+CREATE OR REPLACE FUNCTION quotes_rand_calc() RETURNS void AS $$
+ WITH q(vid,quote) AS (
+ SELECT vid, quote FROM quotes q WHERE approved AND EXISTS(SELECT 1 FROM vn v WHERE v.id = q.vid AND NOT v.hidden)
+ ), r(vid,quote,rand) AS (
+ SELECT vid, quote,
+ -- 'rand' is chosen such that each VN has an equal probability to be selected, regardless of how many quotes it has.
+ ((dense_rank() OVER (ORDER BY vid)) - 1)::real / (SELECT COUNT(DISTINCT vid) FROM q) +
+ (percent_rank() OVER (PARTITION BY vid ORDER BY quote)) / (SELECT COUNT(DISTINCT vid)+1 FROM q)
+ FROM q
+ ), u AS (
+ UPDATE quotes SET rand = NULL WHERE NOT EXISTS(SELECT 1 FROM r WHERE quotes.vid = r.vid AND quotes.quote = r.quote)
+ ) UPDATE quotes SET rand = r.rand FROM r WHERE r.vid = quotes.vid AND r.quote = quotes.quote
+$$ LANGUAGE SQL;
+
+SELECT quotes_rand_calc();
+COMMIT;
+
+\i sql/perms.sql
diff --git a/util/updates/2023-09-17-wikidata-props.sql b/util/updates/2023-09-17-wikidata-props.sql
new file mode 100644
index 00000000..1e58f42a
--- /dev/null
+++ b/util/updates/2023-09-17-wikidata-props.sql
@@ -0,0 +1,3 @@
+ALTER TABLE wikidata
+ ADD COLUMN lutris text[],
+ ADD COLUMN wine integer[];
diff --git a/util/updates/2023-09-21-reset-throttle.sql b/util/updates/2023-09-21-reset-throttle.sql
new file mode 100644
index 00000000..3557f66e
--- /dev/null
+++ b/util/updates/2023-09-21-reset-throttle.sql
@@ -0,0 +1,5 @@
+CREATE TABLE reset_throttle (
+ ip inet NOT NULL PRIMARY KEY,
+ timeout timestamptz NOT NULL
+);
+\i sql/perms.sql
diff --git a/util/updates/2023-10-14-drm.sql b/util/updates/2023-10-14-drm.sql
new file mode 100644
index 00000000..d7c55982
--- /dev/null
+++ b/util/updates/2023-10-14-drm.sql
@@ -0,0 +1,7 @@
+\i sql/schema.sql
+\i sql/tableattrs.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+INSERT INTO drm VALUES (0, 0, 0, false, false, false, false, false, false, false, false, 'DRM-free', 'This release is available without DRM.');
diff --git a/util/updates/2023-12-03-staff-aid.sql b/util/updates/2023-12-03-staff-aid.sql
new file mode 100644
index 00000000..f6deb3e6
--- /dev/null
+++ b/util/updates/2023-12-03-staff-aid.sql
@@ -0,0 +1,11 @@
+ALTER TABLE staff RENAME COLUMN aid TO main;
+ALTER TABLE staff_hist RENAME COLUMN aid TO main;
+
+ALTER TABLE staff DROP CONSTRAINT staff_aid_fkey;
+ALTER TABLE staff ADD CONSTRAINT staff_main_fkey FOREIGN KEY (main) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
+
+DROP VIEW staff_aliast CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-12-03-staff-extlinks.sql b/util/updates/2023-12-03-staff-extlinks.sql
new file mode 100644
index 00000000..96ef0501
--- /dev/null
+++ b/util/updates/2023-12-03-staff-extlinks.sql
@@ -0,0 +1,24 @@
+ALTER TABLE staff
+ ADD COLUMN l_vgmdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_discogs integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_mobygames integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_bgmtv integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_imdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_vndb vndbid,
+ ADD COLUMN l_mbrainz uuid,
+ ADD COLUMN l_scloud text NOT NULL DEFAULT '';
+ALTER TABLE staff_hist
+ ADD COLUMN l_vgmdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_discogs integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_mobygames integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_bgmtv integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_imdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_vndb vndbid,
+ ADD COLUMN l_mbrainz uuid,
+ ADD COLUMN l_scloud text NOT NULL DEFAULT '';
+
+DROP VIEW staff_aliast CASCADE;
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2024-02-23-quotes.sql b/util/updates/2024-02-23-quotes.sql
new file mode 100644
index 00000000..810a2bec
--- /dev/null
+++ b/util/updates/2024-02-23-quotes.sql
@@ -0,0 +1,89 @@
+BEGIN;
+
+CREATE TABLE quotes_tmp (
+ id serial PRIMARY KEY,
+ vid vndbid NOT NULL,
+ cid vndbid,
+ addedby vndbid,
+ rand real,
+ score smallint NOT NULL DEFAULT 0,
+ state smallint NOT NULL DEFAULT 0,
+ quote text NOT NULL
+);
+
+CREATE TABLE quotes_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ uid vndbid,
+ action text NOT NULL
+);
+
+CREATE TABLE quotes_votes (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ uid vndbid NOT NULL,
+ vote smallint NOT NULL,
+ PRIMARY KEY(id, uid)
+);
+
+WITH s (date, uid, vid, quote) AS (
+ SELECT DISTINCT ON (detail) date, by_uid, regexp_replace(detail, '^([^ ]+): .+$', '\1', '')::vndbid, regexp_replace(detail, '^[^ ]+: (.+)$', '\1', '')
+ FROM audit_log a
+ WHERE action = 'submit quote'
+ AND EXISTS(SELECT 1 FROM users WHERE id = by_uid)
+ ORDER BY detail, date
+), q AS (
+ INSERT INTO quotes_tmp (vid, rand, addedby, state, quote, score)
+SELECT q.vid, q.rand, s.uid, CASE WHEN q.approved THEN 1 ELSE 0 END, q.quote, 1
+ FROM quotes q
+ LEFT JOIN s ON s.vid = q.vid AND s.quote = q.quote
+ ORDER BY s.date NULLS FIRST
+ RETURNING id, vid, quote
+), l AS (
+ INSERT INTO quotes_log
+ SELECT COALESCE(s.date, '2023-09-15 12:00 UTC'), q.id, s.uid, CASE WHEN s.uid IS NULL THEN 'Added to the database before the submission form existed' ELSE 'Submitted' END
+ FROM q LEFT JOIN s ON s.vid = q.vid AND s.quote = q.quote
+ RETURNING date, id, uid
+) INSERT INTO quotes_votes
+ SELECT date, id, COALESCE(uid, 'u1'), 1 FROM l;
+
+
+DROP TABLE quotes;
+ALTER TABLE quotes_tmp RENAME TO quotes;
+ALTER INDEX quotes_tmp_pkey RENAME TO quotes_pkey;
+ALTER SEQUENCE quotes_tmp_id_seq RENAME TO quotes_id_seq;
+
+
+CREATE INDEX quotes_rand ON quotes (rand) WHERE rand IS NOT NULL;
+CREATE INDEX quotes_vid ON quotes (vid);
+CREATE INDEX quotes_log_id ON quotes_log (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_cid_fkey FOREIGN KEY (cid) REFERENCES chars (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_addedby_fkey FOREIGN KEY (addedby) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+
+
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON quotes TO vndb_site;
+GRANT SELECT, INSERT ON quotes_log TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON quotes_votes TO vndb_site;
+GRANT SELECT, UPDATE ON quotes TO vndb_multi;
+
+
+CREATE OR REPLACE FUNCTION update_quotes_votes_cache() RETURNS trigger AS $$
+BEGIN
+ UPDATE quotes
+ SET score = (SELECT SUM(vote) FROM quotes_votes WHERE quotes_votes.id = quotes.id)
+ WHERE id IN(OLD.id, NEW.id);
+ RETURN NULL;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER quotes_votes_cache AFTER INSERT OR UPDATE OR DELETE ON quotes_votes FOR EACH ROW EXECUTE PROCEDURE update_quotes_votes_cache();
+
+COMMIT;
+
+\i sql/func.sql
diff --git a/util/updates/2024-02-26-quotes-adjustments.sql b/util/updates/2024-02-26-quotes-adjustments.sql
new file mode 100644
index 00000000..bbb09801
--- /dev/null
+++ b/util/updates/2024-02-26-quotes-adjustments.sql
@@ -0,0 +1,12 @@
+BEGIN;
+ALTER TABLE quotes
+ ADD COLUMN hidden boolean NOT NULL DEFAULT FALSE,
+ ADD COLUMN added timestamptz NOT NULL DEFAULT NOW();
+UPDATE quotes SET hidden = true WHERE state = 2;
+ALTER TABLE quotes DROP COLUMN state;
+
+CREATE INDEX quotes_addedby ON quotes (addedby);
+
+COMMIT;
+
+\i sql/func.sql
diff --git a/util/updates/2024-03-01-reports-log.sql b/util/updates/2024-03-01-reports-log.sql
new file mode 100644
index 00000000..51c7d6f2
--- /dev/null
+++ b/util/updates/2024-03-01-reports-log.sql
@@ -0,0 +1,14 @@
+CREATE TABLE reports_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ status report_status NOT NULL,
+ uid vndbid,
+ message text NOT NULL
+);
+
+CREATE INDEX reports_log_id ON reports_log (id);
+
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_id_fkey FOREIGN KEY (id) REFERENCES reports (id);
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+
+GRANT SELECT, INSERT ON reports_log TO vndb_site;
diff --git a/util/updates/2024-03-08-belarusian-language.sql b/util/updates/2024-03-08-belarusian-language.sql
new file mode 100644
index 00000000..23520558
--- /dev/null
+++ b/util/updates/2024-03-08-belarusian-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'be' AFTER 'ar';
diff --git a/util/updates/2024-03-14-sql-email-normalization.sql b/util/updates/2024-03-14-sql-email-normalization.sql
new file mode 100644
index 00000000..3e9eb93f
--- /dev/null
+++ b/util/updates/2024-03-14-sql-email-normalization.sql
@@ -0,0 +1,9 @@
+\i sql/util.sql
+
+DROP INDEX users_shadow_mail;
+CREATE INDEX users_shadow_mail ON users_shadow (hash_email(mail));
+
+DROP FUNCTION user_emailtoid(text);
+DROP FUNCTION user_resetpass(text, bytea);
+
+\i sql/func.sql
diff --git a/util/updates/2024-03-20-account-softdelete.sql b/util/updates/2024-03-20-account-softdelete.sql
new file mode 100644
index 00000000..1f1cb055
--- /dev/null
+++ b/util/updates/2024-03-20-account-softdelete.sql
@@ -0,0 +1,11 @@
+CREATE TABLE email_optout (
+ mail uuid, -- hash_email()
+ date timestamptz NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (mail)
+);
+
+ALTER TABLE users ALTER COLUMN username DROP NOT NULL;
+ALTER TABLE audit_log ALTER COLUMN by_ip DROP NOT NULL;
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2024-03-22-delayed-account-deletion.sql b/util/updates/2024-03-22-delayed-account-deletion.sql
new file mode 100644
index 00000000..8d55fc13
--- /dev/null
+++ b/util/updates/2024-03-22-delayed-account-deletion.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users_shadow ADD COLUMN delete_at timestamptz;
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/README.md b/util/updates/README.md
new file mode 100644
index 00000000..a6032c4b
--- /dev/null
+++ b/util/updates/README.md
@@ -0,0 +1,51 @@
+# SQL Update Scripts
+
+This directory contains scripts to keep the live database schema synchronized
+with the code in the git repo, in particular with the definitions in the `sql/`
+directory.
+
+## Naming scheme
+
+```sh
+`date +%F`-description.sql
+```
+
+The date is the date on which the script is applied to the production database.
+For work-in-progress updates where that date is not yet known, use a `wip-`
+prefix instead.
+
+(The older `update_{date}.sql` naming scheme is deprecated)
+
+## Applying the updates
+
+Do not blindly apply these scripts in order and expect them to work. Since the
+scripts were written for the sole purpose of updating the live production
+database - which only needs to happen once per update - I often take some
+shortcuts:
+
+- The scripts often directly import other scripts from `sql/`. Later changes to
+ files in `sql/` may break the update scripts, so generally the safest way to
+ apply a particular script is to find the latest commit where the script has
+ been edited, then do a checkout of that commit and run the script in that
+ context.
+- Always run `make` before running a script, it may rely on `sql/editfunc.sql`.
+- Not all changes get an update script. Sometimes just running `sql/func.sql`
+ is sufficient to apply a change. In rare cases an update requires a full dump
+ & reload using `util/dbdump.pl export-data`, such as changes to column order
+ (which I sometimes do around a PostgreSQL version upgrade since those can
+ benefit from a dump & reload anyway) or changes to the definition of an
+ important data type (`vndbid` in particular, but such changes should be very
+ rare).
+
+## Downtime
+
+I'm not consistent with respect to whether these scripts can be run without
+downtime. Most scripts work just fine while the site is up and running, others
+may require that the site is taken down for a few minutes.
+
+Likewise, some scripts will leave the database in a state that an already
+running process can't deal with. That may result in some 500 errors until the
+process is restarted with the new code.
+
+Scripts often contain comments regarding the above. They're worth reading
+before applying, in any case.
diff --git a/util/vndb-dev-server.pl b/util/vndb-dev-server.pl
index a7571e10..214b0ee0 100755
--- a/util/vndb-dev-server.pl
+++ b/util/vndb-dev-server.pl
@@ -15,6 +15,8 @@ use Cwd 'abs_path';
my $listen_port = $ENV{TUWF_HTTP_SERVER_PORT} || 3000;
$ENV{TUWF_HTTP_SERVER_PORT} = $listen_port+1;
+$ENV{VNDB_VAR} //= 'var';
+
my($pid, $prog, $killed);
sub prog_start {
@@ -58,7 +60,7 @@ sub make_run {
print "\n" if !$newline++;
print $d;
};
- my $cb = run_cmd "cd $ROOT && make", '>', $out, '2>', $out;
+ my $cb = run_cmd "cd $ROOT && make -j4", '>', $out, '2>', $out;
$cb->recv;
print "\n" if $newline;
}
@@ -100,10 +102,8 @@ sub checkmod {
}, "$ROOT/lib";
chdir $ROOT;
- $check->($_) for (qw{
- util/vndb.pl
- data/conf.pl
- });
+ $check->('util/vndb.pl');
+ $check->("$ENV{VNDB_VAR}/conf.pl");
my $ismod = $newlastmod > $lastmod;
$lastmod = $newlastmod;
diff --git a/util/vndb.pl b/util/vndb.pl
index 15db575a..6690f4c9 100755
--- a/util/vndb.pl
+++ b/util/vndb.pl
@@ -1,36 +1,48 @@
#!/usr/bin/perl
+# Usage:
+# vndb.pl # Run from the CLI to get a webserver, or spawn from CGI/FastCGI
+# vndb.pl noapi # Same, but disable /api/ calls
+# vndb.pl onlyapi # Same, but disable everything but /api/ calls
+#
+# vndb.pl elmgen # Generate Elm files and quit
+
use v5.24;
use warnings;
use Cwd 'abs_path';
-use TUWF ':html_';
+use JSON::XS;
+use TUWF ':html5_';
+use Time::HiRes 'time';
$|=1; # Disable buffering on STDOUT, otherwise vndb-dev-server.pl won't pick up our readyness notification.
-my $ROOT;
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/vndb\.pl$}{}; }
+# Force the pure-perl AnyEvent backend; More lightweight and we don't need the
+# performance of EV. Fixes an issue with subprocess spawning under TUWF's
+# built-in web server that I haven't been able to track down.
+BEGIN { $ENV{PERL_ANYEVENT_MODEL} = 'Perl'; }
+
+
+our($ROOT, $NOAPI, $ONLYAPI);
+BEGIN {
+ ($ROOT = abs_path $0) =~ s{/util/vndb\.pl$}{};
+ ($NOAPI) = grep $_ eq 'noapi', @ARGV;
+ ($ONLYAPI) = grep $_ eq 'onlyapi', @ARGV;
+}
use lib $ROOT.'/lib';
-use SkinFile;
use VNDB::Config;
use VNWeb::Auth;
use VNWeb::HTML ();
use VNWeb::Validation ();
+use VNWeb::TitlePrefs ();
+use VNWeb::TimeZone ();
-
-# load the skins
-my $skin = SkinFile->new("$ROOT/static/s");
-tuwf->{skins} = { map +($_ => [ $skin->get($_, 'name'), $skin->get($_, 'userid') ]), $skin->list };
-
-# Some global variables
-tuwf->{scr_size} = [ 136, 102 ]; # w*h of screenshot thumbnails
-tuwf->{ch_size} = [ 256, 300 ]; # max. w*h of char images
-tuwf->{cv_size} = [ 256, 400 ]; # max. w*h of cover images
-tuwf->{permissions} = {qw| board 1 boardmod 2 edit 4 tag 16 dbmod 32 tagmod 64 usermod 128 |};
-tuwf->{default_perm} = 1+4+16; # Keep synchronised with the default value of users.perm
-tuwf->{$_} = config->{$_} for keys %{ config() };
-
+$ENV{TZ} = 'UTC';
TUWF::set %{ config->{tuwf} };
+TUWF::set import_modules => 0;
+TUWF::set db_login => sub {
+ DBI->connect(config->{tuwf}{db_login}->@*, { PrintError => 0, RaiseError => 1, AutoCommit => 0, pg_enable_utf8 => 1, ReadOnly => 1 })
+} if config->{read_only};
# Signal to VNWeb::Elm whether it should generate the Elm files.
# Should be done before loading any more modules.
@@ -38,23 +50,49 @@ tuwf->{elmgen} = $ARGV[0] && $ARGV[0] eq 'elmgen';
TUWF::hook before => sub {
- # If we're running standalone, serve www/ and static/ too.
- if(tuwf->{_TUWF}{http}) {
- if(tuwf->resFile("$ROOT/www", tuwf->reqPath) || tuwf->resFile("$ROOT/static", tuwf->reqPath)) {
- tuwf->resHeader('Cache-Control' => 'max-age=31536000');
- tuwf->done;
- }
+ return if VNWeb::Validation::is_api;
+
+ # Serve static files from www/
+ if(tuwf->resFile(config->{var_path}.'/www', tuwf->reqPath)) {
+ tuwf->resHeader('Cache-Control' => 'max-age=86400');
+ tuwf->done;
}
- # load some stats (used for about all pageviews, anyway)
- tuwf->{stats} = tuwf->dbStats;
+ # If we're running standalone, serve static/ too.
+ if(tuwf->{_TUWF}{http} && (
+ tuwf->resFile(config->{var_path}.'/static', tuwf->reqPath) ||
+ tuwf->resFile(config->{gen_path}.'/static', tuwf->reqPath) ||
+ tuwf->resFile("$ROOT/static", tuwf->reqPath)
+ )) {
+ tuwf->resHeader('Cache-Control' => 'max-age=31536000');
+ tuwf->done;
+ }
+
+ # Use a 'SameSite=Strict' cookie to determine whether this page was loaded from internal or external.
+ # Ought to be more reliable than checking the Referer header, but it's unfortunately a bit uglier.
+ tuwf->resCookie(samesite => 1, httponly => 1, samesite => 'Strict') if !VNWeb::Validation::samesite;
+
+ tuwf->req->{trace_start} = time if config->{trace_log};
+} if !$ONLYAPI;
+
+
+# Provide a default /robots.txt
+TUWF::get '/robots.txt', sub {
+ tuwf->resHeader('Content-Type' => 'text/plain');
+ lit_ "User-agent: *\nDisallow: /\n";
};
+TUWF::set error_400_handler => sub {
+ return eval { VNWeb::API::err(400, 'Invalid request (most likely: invalid JSON or non-UTF8 data).') } if VNWeb::Validation::is_api;
+ TUWF::_error_400();
+};
+
TUWF::set error_404_handler => sub {
+ return eval { VNWeb::API::err(404, 'Not found.') } if VNWeb::Validation::is_api;
tuwf->resStatus(404);
VNWeb::HTML::framework_ title => 'Page Not Found', noindex => 1, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Page not found';
div_ class => 'warning', sub {
h2_ 'Oops!';
@@ -67,11 +105,16 @@ TUWF::set error_404_handler => sub {
}
};
+TUWF::set error_500_handler => sub {
+ return eval { VNWeb::API::err(500, 'Internal server error. Can be temporary, but usually points to a server bug.') } if VNWeb::Validation::is_api;
+ TUWF::_error_500();
+};
+
sub TUWF::Object::resDenied {
tuwf->resStatus(403);
VNWeb::HTML::framework_ title => 'Access Denied', noindex => 1, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Access Denied';
div_ class => 'warning', sub {
if(!auth) {
@@ -83,6 +126,8 @@ sub TUWF::Object::resDenied {
a_ href => '/u/register', 'create an account';
txt_ " if you don't have one yet.";
}
+ } elsif(VNWeb::DB::global_settings()->{lockdown_edit} || VNWeb::DB::global_settings()->{lockdown_board}) {
+ h2_ 'The database is in temporary lockdown.';
} else {
h2_ 'You are not allowed to perform this action.';
p_ 'You do not have the proper rights to perform the action you wanted to perform.';
@@ -93,8 +138,50 @@ sub TUWF::Object::resDenied {
}
-TUWF::load_recursive('VNDB::Util', 'VNDB::DB', 'VNDB::Handler');
-TUWF::set import_modules => 0;
-TUWF::load_recursive('VNWeb');
+# Intercept TUWF::any() to figure out which module is processing the request.
+# Used by VNWeb::HTML::framework_ and trace logging.
+{
+ no warnings 'redefine';
+ my $f = \&TUWF::any;
+ *TUWF::any = sub {
+ my($meth, $path, $sub) = @_;
+ my $i = 0;
+ my $loc = ['',0];
+ while(my($pack, undef, $line, undef, undef, undef, undef, $is_require) = caller($i++)) {
+ last if $is_require;
+ $loc = [$pack,$line];
+ }
+ $f->($meth, $path, sub { tuwf->req->{trace_loc} = $loc; $sub->(@_) });
+ };
+}
+
+if($ONLYAPI) {
+ require VNWeb::API;
+} else {
+ TUWF::load_recursive('VNWeb');
+}
+
+TUWF::hook after => sub {
+ return if rand() > config->{trace_log} || !tuwf->req->{trace_start};
+ my $sqlt = List::Util::sum(map $_->[2], tuwf->{_TUWF}{DB}{queries}->@*);
+ my %js = (
+ (map +("$_.js",1), keys tuwf->req->{js}->%*),
+ (map +($_,1), keys tuwf->req->{pagevars}{widget}->%*),
+ (map +($_->[0], 1), tuwf->req->{pagevars}{elm}->@*)
+ );
+ tuwf->dbExeci('INSERT INTO trace_log', {
+ method => tuwf->reqMethod(),
+ path => tuwf->reqPath(),
+ query => tuwf->reqQuery(),
+ module => tuwf->req->{trace_loc}[0],
+ line => tuwf->req->{trace_loc}[1],
+ sql_num => scalar grep($_->[0] ne 'ping/rollback' && $_->[0] ne 'commit', tuwf->{_TUWF}{DB}{queries}->@*),
+ sql_time => $sqlt,
+ perl_time => time() - tuwf->req->{trace_start},
+ has_txn => VNWeb::DB::sql('txid_current_if_assigned() IS NOT NULL'),
+ loggedin => auth?1:0,
+ js => '{'.join(',', sort keys %js).'}'
+ });
+} if config->{trace_log};
TUWF::run if !tuwf->{elmgen};