summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
l---------.dockerignore1
-rw-r--r--.gitignore31
-rw-r--r--ChangeLog773
-rw-r--r--Dockerfile77
-rw-r--r--Makefile330
-rw-r--r--README.md262
-rw-r--r--api-kana.md1704
-rw-r--r--api-nyan.md975
-rw-r--r--conf_example.pl41
-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/config_example.pl55
-rw-r--r--data/global.pl317
-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/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.js700
-rw-r--r--data/js/iv.js138
-rw-r--r--data/js/lib.js179
-rw-r--r--data/js/main.js53
-rw-r--r--data/js/misc.js325
-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/staffalias.js80
-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/notes/atom-feeds26
-rw-r--r--data/notes/chardb578
-rw-r--r--data/notes/mylist-revamp86
-rw-r--r--data/notes/notifications20
-rw-r--r--data/notes/permanent-filters88
-rw-r--r--data/notes/preferences117
-rw-r--r--data/notes/sponsored-links106
-rw-r--r--data/notes/tagmod-overrule55
-rw-r--r--data/style.css1002
-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/Discussions/Edit.elm251
-rw-r--r--elm/Discussions/Poll.elm139
-rw-r--r--elm/Discussions/PostEdit.elm112
-rw-r--r--elm/ImageFlagging.elm353
-rw-r--r--elm/Lib/Api.elm96
-rw-r--r--elm/Lib/Autocomplete.elm407
-rw-r--r--elm/Lib/DropDown.elm68
-rw-r--r--elm/Lib/Editsum.elm65
-rw-r--r--elm/Lib/Ffi.elm35
-rw-r--r--elm/Lib/Html.elm221
-rw-r--r--elm/Lib/Image.elm184
-rw-r--r--elm/Lib/RDate.elm121
-rw-r--r--elm/Lib/TextPreview.elm83
-rw-r--r--elm/Lib/Util.elm129
-rw-r--r--elm/Reviews/Edit.elm199
-rw-r--r--elm/TagEdit.elm237
-rw-r--r--elm/Tagmod.elm303
-rw-r--r--elm/TraitEdit.elm205
-rw-r--r--elm/UList/DateEdit.elm85
-rw-r--r--elm/UList/LabelEdit.elm134
-rw-r--r--elm/UList/ManageLabels.elm127
-rw-r--r--elm/UList/Opt.elm205
-rw-r--r--elm/UList/ReleaseEdit.elm70
-rw-r--r--elm/UList/SaveDefault.elm76
-rw-r--r--elm/UList/VNPage.elm70
-rw-r--r--elm/UList/VoteEdit.elm117
-rw-r--r--elm/UList/Widget.elm316
-rw-r--r--elm/VNEdit.elm788
-rw-r--r--elm/VNLengthVote.elm216
-rw-r--r--elm/elm.json30
-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.pngbin0 -> 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.pngbin0 -> 100 bytes
-rw-r--r--icons/lang/lv.pngbin0 -> 88 bytes
-rw-r--r--icons/lang/mk.pngbin0 -> 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.pngbin0 -> 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.pm760
-rw-r--r--lib/Multi/APIDump.pm128
-rw-r--r--lib/Multi/Anime.pm120
-rw-r--r--lib/Multi/Core.pm75
-rw-r--r--lib/Multi/DLsite.pm84
-rw-r--r--lib/Multi/Denpa.pm77
-rw-r--r--lib/Multi/Feed.pm154
-rw-r--r--lib/Multi/IRC.pm203
-rw-r--r--lib/Multi/JASTUSA.pm87
-rw-r--r--lib/Multi/JList.pm79
-rw-r--r--lib/Multi/MG.pm80
-rw-r--r--lib/Multi/Maintenance.pm183
-rw-r--r--lib/Multi/PlayAsia.pm127
-rw-r--r--lib/Multi/RG.pm346
-rw-r--r--lib/Multi/Wikidata.pm116
-rw-r--r--lib/PWLookup.pm155
-rw-r--r--lib/SkinFile.pm74
-rw-r--r--lib/VNDB/BBCode.pm190
-rw-r--r--lib/VNDB/Config.pm77
-rw-r--r--lib/VNDB/DB/Affiliates.pm73
-rw-r--r--lib/VNDB/DB/Chars.pm199
-rw-r--r--lib/VNDB/DB/Discussions.pm354
-rw-r--r--lib/VNDB/DB/Docs.pm53
-rw-r--r--lib/VNDB/DB/Misc.pm127
-rw-r--r--lib/VNDB/DB/Producers.pm131
-rw-r--r--lib/VNDB/DB/Releases.pm260
-rw-r--r--lib/VNDB/DB/Staff.pm196
-rw-r--r--lib/VNDB/DB/Tags.pm285
-rw-r--r--lib/VNDB/DB/Traits.pm113
-rw-r--r--lib/VNDB/DB/ULists.pm354
-rw-r--r--lib/VNDB/DB/Users.pm283
-rw-r--r--lib/VNDB/DB/VN.pm365
-rw-r--r--lib/VNDB/ExtLinks.pm517
-rw-r--r--lib/VNDB/Func.pm457
-rw-r--r--lib/VNDB/Handler/Affiliates.pm152
-rw-r--r--lib/VNDB/Handler/Chars.pm604
-rw-r--r--lib/VNDB/Handler/Discussions.pm718
-rw-r--r--lib/VNDB/Handler/Docs.pm179
-rw-r--r--lib/VNDB/Handler/Misc.pm350
-rw-r--r--lib/VNDB/Handler/Producers.pm502
-rw-r--r--lib/VNDB/Handler/Releases.pm711
-rw-r--r--lib/VNDB/Handler/Staff.pm398
-rw-r--r--lib/VNDB/Handler/Tags.pm795
-rw-r--r--lib/VNDB/Handler/Traits.pm455
-rw-r--r--lib/VNDB/Handler/ULists.pm530
-rw-r--r--lib/VNDB/Handler/Users.pm865
-rw-r--r--lib/VNDB/Handler/VNBrowse.pm147
-rw-r--r--lib/VNDB/Handler/VNEdit.pm536
-rw-r--r--lib/VNDB/Handler/VNPage.pm1076
-rw-r--r--lib/VNDB/Schema.pm129
-rw-r--r--lib/VNDB/Skins.pm27
-rw-r--r--lib/VNDB/Types.pm358
-rw-r--r--lib/VNDB/Util/Auth.pm228
-rw-r--r--lib/VNDB/Util/BrowseHTML.pm223
-rw-r--r--lib/VNDB/Util/CommonHTML.pm491
-rw-r--r--lib/VNDB/Util/FormHTML.pm279
-rw-r--r--lib/VNDB/Util/LayoutHTML.pm204
-rw-r--r--lib/VNDB/Util/Misc.pm164
-rw-r--r--lib/VNDB/Util/ValidateTemplates.pm103
-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.pm404
-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.pm373
-rw-r--r--lib/VNWeb/Discussions/Board.pm50
-rw-r--r--lib/VNWeb/Discussions/Edit.pm167
-rw-r--r--lib/VNWeb/Discussions/Elm.pm33
-rw-r--r--lib/VNWeb/Discussions/Index.pm35
-rw-r--r--lib/VNWeb/Discussions/Lib.pm134
-rw-r--r--lib/VNWeb/Discussions/PostEdit.pm89
-rw-r--r--lib/VNWeb/Discussions/Search.pm178
-rw-r--r--lib/VNWeb/Discussions/Thread.pm224
-rw-r--r--lib/VNWeb/Discussions/UPosts.pm78
-rw-r--r--lib/VNWeb/Docs/Edit.pm56
-rw-r--r--lib/VNWeb/Docs/Lib.pm57
-rw-r--r--lib/VNWeb/Docs/Page.pm59
-rw-r--r--lib/VNWeb/Elm.pm495
-rw-r--r--lib/VNWeb/Filters.pm246
-rw-r--r--lib/VNWeb/Graph.pm119
-rw-r--r--lib/VNWeb/HTML.pm1035
-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.pm17
-rw-r--r--lib/VNWeb/Misc/ElmAnime.pm25
-rw-r--r--lib/VNWeb/Misc/Feeds.pm80
-rw-r--r--lib/VNWeb/Misc/History.pm188
-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.pm95
-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.pm57
-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.pm109
-rw-r--r--lib/VNWeb/Staff/Elm.pm34
-rw-r--r--lib/VNWeb/Staff/List.pm94
-rw-r--r--lib/VNWeb/Staff/Page.pm214
-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.pm130
-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/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.pm118
-rw-r--r--lib/VNWeb/User/Login.pm87
-rw-r--r--lib/VNWeb/User/Notifications.pm243
-rw-r--r--lib/VNWeb/User/Page.pm235
-rw-r--r--lib/VNWeb/User/PassReset.pm58
-rw-r--r--lib/VNWeb/User/PassSet.pm36
-rw-r--r--lib/VNWeb/User/Register.pm88
-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.pm67
-rw-r--r--lib/VNWeb/Validation.pm460
-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.sql223
-rw-r--r--sql/rebuild-search-cache.sql60
-rw-r--r--sql/schema.sql1641
-rw-r--r--sql/superuser_init.sql17
-rw-r--r--sql/tableattrs.sql203
-rw-r--r--sql/triggers.sql333
-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/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/uncensor.svg2
-rw-r--r--static/f/voiced.svg33
-rw-r--r--static/f/wikidata.pngbin0 -> 35543 bytes
-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.jpgbin0 -> 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/bbcode-test.pl227
-rwxr-xr-xutil/dbdump.pl493
-rwxr-xr-xutil/devdump.pl195
-rwxr-xr-xutil/dl-cron.sh44
-rwxr-xr-xutil/dl-gendir.pl51
-rwxr-xr-xutil/docker-init.sh130
-rw-r--r--util/dump/LICENSE-CC-BY-NC-SA.txt362
-rw-r--r--util/dump/LICENSE-CC0.txt121
-rw-r--r--util/dump/LICENSE-DBCL.txt52
-rw-r--r--util/dump/LICENSE-ODBL.txt540
-rw-r--r--util/dump/README-img.txt13
-rw-r--r--util/dump/README.txt53
-rwxr-xr-xutil/hibp-dl.pl89
-rw-r--r--util/imgproc.c252
-rwxr-xr-xutil/jsgen.pl146
-rwxr-xr-xutil/multi.pl19
-rwxr-xr-xutil/pngsprite.pl122
-rwxr-xr-xutil/revision-integrity.pl39
-rwxr-xr-xutil/setup-var.sh21
-rwxr-xr-xutil/skingen.pl96
-rwxr-xr-xutil/spritegen.pl145
-rw-r--r--util/sql/all.sql60
-rw-r--r--util/sql/data.sql17
-rw-r--r--util/sql/func.sql808
-rw-r--r--util/sql/perms.sql154
-rw-r--r--util/sql/schema.sql776
-rw-r--r--util/sql/superuser_init.sql15
-rw-r--r--util/sql/tableattrs.sql120
-rw-r--r--util/sql/triggers.sql48
-rwxr-xr-xutil/sqleditfunc.pl88
-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.pl248
-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-01-01-ulists.sql135
-rw-r--r--util/updates/2020-01-04-ulist-saved-views.sql4
-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/updates/update_1.1.pl18
-rw-r--r--util/updates/update_1.1.sql13
-rw-r--r--util/updates/update_1.10.sql92
-rw-r--r--util/updates/update_1.11.sql4
-rw-r--r--util/updates/update_1.12.sql34
-rw-r--r--util/updates/update_1.13.sql229
-rw-r--r--util/updates/update_1.14.pl59
-rw-r--r--util/updates/update_1.14.sql84
-rw-r--r--util/updates/update_1.15.sql26
-rw-r--r--util/updates/update_1.16.sql74
-rwxr-xr-xutil/updates/update_1.17.pl82
-rw-r--r--util/updates/update_1.17.sql17
-rw-r--r--util/updates/update_1.18.sql31
-rw-r--r--util/updates/update_1.19.sql35
-rw-r--r--util/updates/update_1.2.sql9
-rw-r--r--util/updates/update_1.20.sql36
-rwxr-xr-xutil/updates/update_1.21.pl15
-rw-r--r--util/updates/update_1.21.sql113
-rw-r--r--util/updates/update_1.22.sh16
-rw-r--r--util/updates/update_1.22.sql34
-rw-r--r--util/updates/update_1.23.sql3
-rw-r--r--util/updates/update_1.4.sql37
-rw-r--r--util/updates/update_1.5.sql10
-rw-r--r--util/updates/update_1.6.sql21
-rw-r--r--util/updates/update_1.7.sql23
-rw-r--r--util/updates/update_1.8.sql27
-rw-r--r--util/updates/update_1.9.sql375
-rw-r--r--util/updates/update_2.0.sql140
-rw-r--r--util/updates/update_2.1.sql4
-rw-r--r--util/updates/update_2.10.sql63
-rw-r--r--util/updates/update_2.11.sql120
-rw-r--r--util/updates/update_2.12.sql20
-rw-r--r--util/updates/update_2.13.sql6
-rw-r--r--util/updates/update_2.14.sql145
-rw-r--r--util/updates/update_2.15.sql11
-rw-r--r--util/updates/update_2.16.sql86
-rw-r--r--util/updates/update_2.17.sql8
-rw-r--r--util/updates/update_2.18.sql8
-rw-r--r--util/updates/update_2.19.sql206
-rw-r--r--util/updates/update_2.2.sql36
-rw-r--r--util/updates/update_2.20.sql54
-rw-r--r--util/updates/update_2.21.sql17
-rw-r--r--util/updates/update_2.22.sql11
-rw-r--r--util/updates/update_2.23.sql100
-rw-r--r--util/updates/update_2.24-staff.sql67
-rw-r--r--util/updates/update_2.24.sql14
-rw-r--r--util/updates/update_2.25-sqlsplit.sql258
-rw-r--r--util/updates/update_2.25.sql80
-rw-r--r--util/updates/update_2.26.sql60
-rw-r--r--util/updates/update_2.27.sql10
-rw-r--r--util/updates/update_2.3.sql205
-rw-r--r--util/updates/update_2.4.sql99
-rw-r--r--util/updates/update_2.5.sql67
-rw-r--r--util/updates/update_2.6.sql357
-rw-r--r--util/updates/update_2.7.sql108
-rw-r--r--util/updates/update_2.8.sql213
-rw-r--r--util/updates/update_2.9.sql76
-rw-r--r--util/updates/update_20180207.sql3
-rw-r--r--util/updates/update_20180208.sql57
-rw-r--r--util/updates/update_20180525.sql9
-rw-r--r--util/updates/update_20180812.sql3
-rw-r--r--util/updates/update_20180929.sql2
-rw-r--r--util/updates/update_20181002.sql32
-rw-r--r--util/updates/update_20181006.sql1
-rw-r--r--util/updates/update_20190809.sql60
-rw-r--r--util/updates/update_20190814.sql19
-rw-r--r--util/updates/update_20190816.sql142
-rw-r--r--util/updates/update_20190821.sql35
-rw-r--r--util/updates/update_20190822.sql4
-rw-r--r--util/updates/update_20190824.sql71
-rw-r--r--util/updates/update_20190831.sql4
-rw-r--r--util/updates/update_20190901.sql6
-rw-r--r--util/updates/update_20190902.sql12
-rw-r--r--util/updates/update_20190903.sql31
-rw-r--r--util/updates/update_20190914.sql19
-rw-r--r--util/updates/update_20190923.sql6
-rw-r--r--util/updates/update_20191003.sql40
-rw-r--r--util/updates/update_20191003b.sql23
-rw-r--r--util/updates/update_20191007.sql10
-rw-r--r--util/updates/update_20191010.sql10
-rw-r--r--util/updates/update_20191102.sql72
-rw-r--r--util/updates/update_20191108.sql1
-rw-r--r--util/updates/update_20191220.sql2
-rwxr-xr-xutil/vndb-dev-server.pl11
-rwxr-xr-xutil/vndb.pl246
893 files changed, 52034 insertions, 29399 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 120000
index 00000000..3e4e48b0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1 @@
+.gitignore \ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 5309a6ce..ca825c86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,22 +1,9 @@
-/data/config.pl
-/data/docs/8
-/data/icons/icons.css
-/data/log/
-/data/multi.pid
-/data/passwords.dat
-/static/f/js/
-/static/f/icons.png
-/static/f/vndb.js
-/static/f/vndb.js.gz
-/static/feeds/
-/static/s/*/style.css
-/static/s/*/style.css.gz
-/static/s/*/boxbg.png
-/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/ChangeLog b/ChangeLog
deleted file mode 100644
index 25448863..00000000
--- a/ChangeLog
+++ /dev/null
@@ -1,773 +0,0 @@
-This file is not updated anymore. Check the git log for changes.
-
-2.22 - 2011-12-31
- - Added character filters
- - Added duplicate checking form before creating a new VN entry
- - Combined "remove" and "add" labels in a single lang.txt entry
- - Added secondary order to VN browser when sorting on release date
- - doc updates for the characters and traits
-
-2.21 - 2011-08-23
- - New resolution: 1280x960
- - New platforms: Android, Web and DB-PG
- - Added email confirmation to registration process
- - Re-structured password reset feature
- - Multi::Feed: Show full summary and refresh more often
- - Switched back to Algorithm::Diff::XS
- - Added secondary ordering on title on releases listing on VN page
- - Added i+/c+ ID recognition to VN search
- - JS: Don't consider 256x384 a "bad" screenshot resolution
- - Changed location of 'add character/release' links on VN page
- - Use generic imgurl() and imgpath() functions to generate image URLs/paths
- - Don't allow regular users to create more than 10 threads a day
- - Bugfix: Properly make i+ IDs linkable in bb2html()
- - Bugfix: Make sure the user dropdown boxes work on /v+/chars
- - Bugfix: dbTraitGet() filtering would not always work correctly
- - Bugfix: Don't allow duplicate trait names/aliasses within the same group
- - Bugfix: Don't throw error when adding character to VN without releases
- - Bugfix: Keep image id on failed (vn|char)add + validate image id
- - Bugfix: Don't display "group number" field on trait creation for non-mods
-
-2.20 - 2011-05-01
- - Added support for sponsored links on VN pages
- - Order the VNs listed on char browser by release date
- - Order the traits groups on /i by their 'order' column
- - Use same browsing-table on trait pages and char browser
- - Added spoiler warning to character revision pages
- - Generate dbedit/dbdel notifications on character edits
- - CSS: Hide links in [spoiler] tags
- - Added 'select' all to wishlist and moved 'select all' down on notifies
- - Added char/tag/trait stats to database statistics box
- - Update traits_chars cache daily using Multi::Maintenance
- - Toggle [spoiler] tag visibility with global setting rather than mouse-over
- - Added "Add character" link to VN pages
- - Added "Image ID" field to VN image uploader
- - Added "All except characters" filter to history browser
- - Cleaned up CSS code
- - Cleaned up permissions
- - Replaced user ranks with a permission system
- - Bugfix: don't accidentally remove char traits when editing
- - Bugfix: fixed possible SQL table name clash on history browser
- - Bugfix: properly announce chars and traits in Multi::IRC
- - Bugfix: display more than 10 characters on VN page
- - Bugfix: removed Perl warning in Handler::Chars
- - Bugfix: use the 'vnlists' table to calculate stats on user page
- - Bugfix: allow adding/copying a char with instance field set
- - Bugfix: copy over search string when switching to trait search
- - Bugfix: Use the translatable role names on char<->vn link form
- - Bugfix: Prevent the spiol dd to hide the del link on char<->trait form
- - Bugfix: Hide pointless groups and commas on spoiler-hidden trait display
- - Bugfix: Properly format future dates on my vn list
- - Bugfix: Properly position the sub-tabs on VN page without tags
- - Bugfix: Allow unhiding of posts by mods
- - Bugfix: Forgot to make two JS strings translatable
- - Bugfix: Don't allow unicode numbers as 'int' in formValidate
-
-2.19 - 2011-03-30
- - Character database:
- - New DB item (versioned): character, page: /c+
- - New DB item (not versioned): traits (like tags), page: /i+
- - New pages: trait listing/browser: /i/*
- - New pages: character browser: /c/*
- - VN pages updated with a characters tab
- - Changed text color of inactive tabs
- - Don't allow users to edit their post when it was deleted
- - Auto-set category when creating new child tag
- - Increased allowed size of VN cover image upload to 5MB
-
-2.18 - 2011-02-08
- - Added category field to tags (content/ero/technical)
- - Group tags on /v+/tagmod by their category
- - Added tag visibility options by category on /v+
- - Added filter selector to tag pages (excl. tags tab)
- - Added new VN filters: wish/blacklist, voted, on VN list
- - Added tooltip to the overruled-exclamation-mark
- - Bugfix: don't generate listdel notify for the user who deleted
-
-2.17 - 2011-02-04
- - Allow moderators to overrule VN tag votes
- - Added 'released' release filter
- - Changed order of the filter action buttons
- - Slightly re-organized lang.txt
- - Fixed perl warning on /u+/votes batchedit with nothing selected
- - Don't update the 'date' when changing a VN vote
- - Switched to TUWF
- - Order "all notifications" with new notifications first
- - Added /t/all - a listing of all recently replied to threads
- - Added error message when selected meta tags on VN filters
- - Display warning for non-standard resolutions of uploaded screenshots
- - Don't save uploaded image to a temporary location before checks
- - Bugfix: Correctly randomize screenshots on homepage with filters
- - Bugfix: Don't show NSFW screenshots on homepage with filters
- - Bugfix-API: Use ~ for the get vn search filter instead of =
- - Bugfix: Secondary order by title or username on vote listings
- - Bugfix: Don't allow empty edit on releases with multiple producers
-
-2.16 - 2011-01-02
- - VNDBUtil::bb2html(): Fixed bug when the string starts with a VNDBID
- - VNDBUtil::bb2html(): Fixed bug with lowercasing all [url=..] URLs
- - Fixed perl warning on /v/search redirect without search query
- - Bugfix: Don't allow others to open /u+/votes when show_list is false
- - Don't allow NULL for rr.minage and use -1 for unknown
- - Check for editsum = description and give an easier to understand error
- - RFC-01: Added vnlists feature and removed rlists.vstat option
- - ULists::votelist: Don't give a 404 on /u+/votes when no votes found
- - Added tab and link for /u+/votes to user tabs & main menu
- - Added ability to batch-edit votes to /u+/votes
- - Update the votes.date column when changing a vote
- - ULists::votelist: Added first character selection
- - Added advanced page-browsing tabs to threads
- - Added notes field to the user VN list
- - Added vnlists.status filter to /u+/list
- - Pass VN tag filters by ID rather than name
- - Improved VN tag filter selection with a dynamic HTML list of selected tags
- - Don't send 'tagspoil' filter when 'tag_inc' isn't active
- - Don't allow page > 100 or sorting on username or title on tag link browser
- - Added users_prefs table and removed the following columns from users:
- skin, customcss, show_nsfw, show_list, notify_announce, notify_dbedit
- - Store l10n preference in the database for logged-in users
- - Bugfix: check for validness of form arguments on /[uv]+/votes
- - Bugfix: translate screen resolutions on release revision pages
- - Bugfix: properly escape search query in links query string
- - Bugfix: allow a VN to be available for more than 7 platforms
- - Implemented permanent release/vn filters
-
-2.15 - 2010-12-15
- - Removed expand/collapse from history browser and /u+/posts and switched to
- a combined view
- - Added a "general discussions" board
- - Added vote listings for VNs and users (/[uv]+/votes)
- - Keep track of last modification date for tag<->vn links
- - Added advanced tag link browser
- - Removed specific tags-by-user listing
- - Disable "Don't update last modified field" by default for mods
- - Make Multi not report posts
- - Consider "senpai" and "sempai" the same in the VN search
- - Extracted screen resolution strings from the code
- - API: Allow extra whitespace after "get .." command
- - API: Allow non-numbers as "clientver" for the login command
- - API: Added "image_nsfw" member to "get vn"
- - API: Added "results" option to the "get .. {}"
- - API: Increased the maximum number of results for the "get" command to 25
- - API: Added "orig_lang" member and filter to the "get vn .." command
- - API: Throttle the commands and sqltime per IP instead of per user
- - API: Removed the limit on the number of open sessions per user
- - API: Allow the API to be used without logging in with a username/password
- - d11: Various documentation fixes and improvements
-
-2.14 - 2010-11-28
- - Improved filter selection interface for the release and VN browser
- - New release filters: voiced, animation and original language
- - New VN filters: length, "has anime" and original language
- - Apply search query and filters when changing first char
- - Added Atom feeds for the recent announcements, changes and posts
- (located in /www/feeds and updated every 15 min. by Multi::Feed)
- - Re-added producer role to collapsed view on producer pages
- - JS: Reverted to the old selection box date selector
- - JS: Split script.js into a separate file for each language
- - Improved performance of update_vnpopularity() on PostgreSQL 9.0
- - Faster and improved bb2html()
- - Added WHEN clause to all SQL TRIGGERs for which it was useful
- (this *requires* PostgreSQL 9.0 or up!)
- - Added ON DELETE clause to all foreign keys referencing users (id)
- - Use word-level (instead of character-level) diff for large fields
- - Extended IE6 warning message to show up for IE7 as well
-
-2.13 - 2010-11-11
- - Added 'formcode' parameter to all modification requests to fix all
- cross-site request forgery vulnerabilities
- - URL change: /u/logout => /u$id/logout
- - Added human confirmation question to the register page
- - Added "official" flag to vn<->vn relations
- - Display releases grouped by VNs on producer pages
- - Optimized SQL queries:
- - dbScreenshotRandom()
- - dbVNGet() with random ordering
- - dbRevisionGet() (in most cases)
- - Removed (p###) from release resolution information
- - Replaced Algorithm::Diff::XS with Algorithm::Diff::Fast
- - Bugfix: delete/update all references when deleting a user
- - Bugfix: reverting a VN image now works
-
-2.12 - 2010-11-03
- - !scr command for Multi::IRC
- - API: Added 'image' field to get vn
- - API: Slightly improved error messages
- - Re-added /g/debug (for moderators)
- - Improved search
- - Display friendly message in the VN edit scr tab when no release is known
- - Added 1024x576 and 1280x800 screen resolutions
- - Added more comparison VNs for the length field
- - Automatically remove read notifications after a month
- - Added Apple iProduct platform
- - Removed XML sitemap
- - Added image dimensions to screenshot thumbail <img> tags
- - Prefix all cookies with a configurable cookie_prefix
- - Automatically read L10N keys from script.js
- - Compressed the thread listing into one row per thread (instead of two)
- - Use newlines to separate aliases (except when displayed on VN pages)
- - Automatically remove duplicate aliases on /v+/edit
- - Increased maxlength of tag descriptions
- - Bugfix: only redirect VN search to VN page if page=1
- - Bugfix: remove duplicate votes when merging tags (fixes a 500)
- - Bugfix: Multi::Anime: don't crash when anidb returns an invalid or empty year
- - Bugfix: properly order the relations listed on producer pages
- - Bugfix: Various gtintype() related issues
- - Bugfix: background image issue in Opera 10.50
-
-2.11 - 2010-02-06
- - Added Slovak to the language list
- - Centered the thumbnails on the screenshots viewer
- - Improved date selector
- - Made the release date a required field
- - Versioned the deleting and locking of database entries
- - Multi's announcements are colored in blue
- - Abstracted parsing skin config files into a SkinFile module
- - Automatically generate the skin credits on d7, by reading the skin files
- - Only tagmods can create top-level tags
- - Notification system for
- - PMs
- - Notifying users of a deletion of an entry they contributed to
- - Notifying users of a deletion they have in their (wish)list
- - Notifying users of an edit of an entry they contributed to
- - Notifying users of site announcements
- - Removed the ?l10n= paremeter
- - Remove sessions that haven't been used for more than a month
- - Properly copy over search string on switching with the searchtabs
- - Converted language columns in SQL to an ENUM type
- - Differentiate between pt-PT and pt-BR
- - Added Dutch translation of the user interface
-
-2.10 - 2010-01-10
- - VN score on tag pages use plain averages instead of bayesian rating
- - Display VN ratings on tag pages as well
- - Split browse functions from CommonHTML.pm into BrowseHTML.pm
- - Abstracted all ORDER BY clauses in the DB abstraction layer
- - Show language flags on release lists on the homepage
- - Allow hiding of NSFW cover even if NSFW warning is disabled
- - Removed /g/debug
- - Replaced recursive stored procedures with WITH .. SELECT queries
- - Merged db[VN|Producer|Release][Edit|Add] into dbItemEdit and dbItemAdd
- - Removed the use of CONSTRAINT TRIGGERs
- - Added maxlength check on the website fields for releases and producers
- - Removed changes.causedby
- - Fixed minor JS dropdown issue when trigger objects are close to each other
- - Allow earlier selecting of release on screenshot upload
- - Fixed bug with zero strings ("0") in the diff viewer
- - Rewrote POE::Filter::VNDBAPI to be more generic
- - Highlight opened VN/producer in relation graphs
- - Added revision insertion abstraction functions in SQL
- - Determine interface language from Accept-Language header
-
-2.9 - 2009-11-16
- - Fixed another bug with the calculation of tags_vn_bayesian.spoiler
- - Implemented proper daemonizing and error handling for Multi
- - Added basic Makefile
- - Added public database API
- - Added [code] tag to bb2html()
- - Tweaked Multi's idlequote timings
- - Added :SUBSUB: macro to the doc pages
- - Allow NULL values for releases_rev.minage
- - Made age ratings and external VN link titles translatable
- - Added wikipedia link for producers
- - Added bayesian rating for VNs
- - Improved popularity sorting on VN list
-
-2.8 - 2009-10-24
- - Converted relation graphs to use inline SVG
- - Relation graphs now use the color scheme of selected skin
- - VN relations are translatable in both the interface and the graphs
- - Full date is displayed in graphs instead of only month/year
- - Converted to ENUM data type:
- - vn_relations.relation
- - anime.type
- - changes.type
- - releases_rev.type
- - releases_media.medium
- - New language: Hungarian
- - Complete rewrite of the Javascript code:
- - Intended to be less error prone, more maintainable, and easier to make
- 'XHTML compliant' in the future (currently still has some issues here).
- - Improved spoiler selection on /v+/tagmod
- - Everything merged into one file.
- - Optionally minified (using JavaScript::Minifier::XS)
- - Language strings are translatable
- - Information is automatically synchronised with data/global.pl
- - Changed language selector into a Javascript dropdown
- - Added producer role (developer/publisher) to releases
- - Display number of unread posts in "My messages" (instead of total threads)
- - Optimized dbUserGet (mostly for the user list)
- - All languages are listed on /r and /v/all instead of only those in use
- - Copy over search query when switching search type (htmlSearchBox)
- - Fixed obscure sorting bug on user VN list
- - Fixed calculation of tags_vn_bayesian.spoiler
- - Fixed bug with unhiding a producer entry
- - Set 'no spoilers' as default spoiler level for tags
- - Added Czech and Hungarian interface translation
- - Producer relations
- - Increased tag dropdown search results to 15
-
-2.7 - 2009-09-24
- - Improved styling of the threeboxes layout
- - Blacklist a users' votes from the VN vote statistics
- - usermods can browse a users' votes and list even when they are hidden
- - More sensible placing of the submit button on /v+/tagmod
- - Improved VN relations:
- - Removed: summary, full story
- - Added: same series, fandisc, original game
- - Renamed: same characters to shares characters
- - Merged: alternative setting into alternative version, and other into same series
- - Allow empty VN descriptions
- - New platforms: DOS, PC-98, Sega Saturn
- - Box titles on homepage are click-able
- - Russian translation of the interface
- - Random VN link in menu
- - Ignore some release fields when the patch status is checked
- - Batch edit downloadable trial releases to add freeware status
- - Remind the user to type English in several form fields
- - Full reply button in Quick reply box + larger textarea in post form
- - Removed visual-novels.net link from the interface
- - Fixed bug with excluding AVG(vote) < 0 VNs from tag pages
- - Allow media quantity up to 20 instead of 10
-
-2.6 - 2009-08-09
- - New screen resolutions: 1024x600 and 1600x1200
- - Rewritten authentication system
- - New language: Vietnamese
- - Complete rewrite of Multi
- - Asynchronous communication with PostgreSQL
- - Got rid of the shared memory
- - No more $self->multiCmd in the VNDB code
- - Extended IRC bot functionality
- - Tag cache regenerated daily rather than hourly
- - Added OpenSearch plugin + autodetection
- - Converted font size units to px in the css
- - Added double-post prevention
- - Converted old categories to tags and removed last traces of the category system
- - Converted all date/time columns to timestamptz
-
-2.5 - 2009-07-09
- - Hide NSFW images in diff viewer (unless NSFW warnings are disabled)
- - Display related boards in recent posts tooltip op homepage
- - Added search box on user list
- - Proper support for multilingual releases
- - Copy-add release feature
- - Automatically fill out title & original title when adding a release
- - Separated VN search filters from search box
- - Tag filers on VN search
- - Posts browser on user pages
- - Keep track of the user who created a tag
-
-2.4 - 2009-06-07
- - Release search + browser + filters
- - Javascript date input
- - More release information:
- - Screen resolution
- - Voiced
- - Freeware/doujin
- - Animated
- - Show comparable CERO ratings on /r+/edit input field
- - Allow search queries with only one character
- - Removed category filter from /v/all
- - Added expand/collapse feature to the history browser
- - Added tabs on v/r/p/g search fields
-
-2.3 - 2009-04-01
- - No page reload needed when changing rlist status from vn page
- - Random VN quotes to the footer of every page
- - Fixed case-sensitivity for BBCode
- - Homepage shows platform icons for releases
- - Don't show deleted items on /u+ recent changes
- - Catalog number field to release entries
- - Aliases field to producers
- - Various small improvements to the BBcode
- - Various bugfixes
- - Experimental tagging system
- - Renamed thread tags to boards
- - Tiny skin fixes
- - Tagging system
-
-2.2 - 2009-01-16
- - Additional custom CSS field to user profile
- - Search dropdown calls the return function automatically on select
- - Revised the media list
- - Added a checkbox to releases to indicate a patch
- - VN popularity ranking
- - Limit account creation to one account in 24 hours per IP address
- - Fixed error message when uploading VN images larger than 500kB
- - Fixed 3 grammar mistakes related to singular/plural
- - Don't show hidden release relations on producer pages
- - Hide the vote dropdown on v+ pages when the VN is already on the wishlist
- - Don't search for the ADV category when searching for the Game Boy Advance
- - Keep VN relations on r+ pages ordered by title
- - VN search doesn't match on titles of older release revisions anymore
- - Don't forget to update the vn.c_* columns when hiding/unhiding a release
- - Fixed month display on VNBrowse
- - VN search also matches on original title field of the vn entry
-
-2.1 - 2008-12-29
- - Skin support
- - 'show all items' tab to large forms
- - Allow items to be selected using the mouse on the dropdown search
- - [spoiler] tag produces mouseover-style spoilers instead of ROT13
- - Fixed tiny timezone-related bug
- - Re-added release list dropdown on VN pages
- - Added [quote] tag to bb2html
- - fixed URL parser in bb2html
-
-2.0 - 2008-12-20
- - New layout
- - Massive code rewrite:
- - Switched to YAWF
- - Removed template system
- - Split DB functions in several files
- - Converted absolute paths to be relative to the root directory
- - Database changes:
- - Added caching of edit and vote counts in users.c_votes and .c_changes
- - Split users.flags into users.show_nsfw and .show_list (boolean type)
- - Global statistics are cached in stats_cache
- - URL changes:
- - /p and /v don't work anymore, use /[pv]/all
- - /u/list/* -> /u/* and /u/list -> /u/all
- - Revert URL changed from /x99/edit?rev=1 to /x99.1/edit
- - /v+/stats and /v+/scr moved into /v+
- - Functionality changes:
- - Ability to sort the userlist on vote and change counts
- - Added threads and posts counts to the global statistics
- - Improved diff calculation
- - Whitespace around input fields are removed
- - Automated edits filter to history browser
- - Number of threads is shown in the discussion tab for each item
- - Boardmods can edit threads without updating the last edited field
- - No more RSS feeds for changes (will be replaced with a notification
- system in the future)
- - Improved formsub interface
- - Improved VN relation editor interface
- - Voted/non-voted filter to user's VNLists
- - VNList status can only be changed from release pages
- - More stats + recent changes on user pages
-
-1.23 - 2008-10-22 (r117)
- - Removed redirects for old revision URLs (the code wasn't very secure...)
- - Fixed bug when using unicode in the AJAX vn/producers/release search box
- - Added original title field to VN entries
- - Fixed incorrect quoting in producer select form
- - Improved display of original titles
-
-1.22 - 2008-08-29 (r106)
- - Inverted vote graph
- - Relation graph image maps are now stored in the DB
- - Properly fixed the command synchronisation issues with Multi
- - Fixed display of wrong ID in the screenshot diff
- - Fixed bug with the infinite thumbnail generation message when the server
- doesn't respond within one second.
- - Rewrote VNDB::Util::DB::sqlprint to use server-side prepared statements
- - Added two new foreign key constraints:
- changes (causedby) -> changes (id)
- threads (id, count) -> threads_posts (tid, num)
- - Converted relation graphs to PNG
- - Added link between screenshots and releases
-
-1.21 - 2008-08-16 (r90)
- - Added !vn and !uptime commands to Multi::IRC
- - Added realtime IRC notifications for actions on the site
- - Added screenshots to VNs
- - Rewrote Multi::Image
- - Renamed the 'anime check' command to 'anime' for consistency
- - Moved to PostgreSQL's boolean data type to store boolean data
-
-1.20 - 2008-08-06 (r79)
- - Admins can change someone's username
- - Fixed the automatic relogin after changing password
- - Added lock indicator when browsing threads on a tag
- - Re-added the vote stats to VN pages
- - Searching for 'Chinese' doesn't select 'NES'
- - Place/time category order on VN pages is now consistent
- - Admins can delete users from the DB
- - Added small NSFW indication for users who have disabled the warning
- - Added noindex tag to iid-ttag browser
- - Replaced last poster with age of last post on home page
- - Added release list feature and removed the old VNList
- - Merged user vote list into the new release list (and removed 'hide my
- votes' option)
- - Merged Votes.pm into VNLists.pm
- - Auto-expand edit summary form when adding a release
- - Added wishlist
-
-1.19 - 2008-07-08 (r62)
- - Integrated discussion board
- - Colored diff for alies field
- - 'ttabs' for user entries
- - Removed the rating system
-
-1.18 - 2008-07-02 (r51)
- - Releases, producers and visual novel items can't be fully deleted anymore
- - Hidden vote and vnlist items from the 'recent' lists on VN stat pages for
- users who don't want that to be seen
- - Added warnings for empty edit summary and extreme votes
- - Changed earliest release date to 1980
- - Added NES and MSX platforms
- - All revision numbers are now local to their item ID
- - Rewrote Multi's VNDBID matching
-
-1.17 - 2008-06-21 (r33)
- - Added PS3 and Xbox 360 to platforms
- - Relation graph generation improvements: Unicode, anti-aliassing, async
- - Removed all passwords from the main code, and created a seperate config
- file to override all options - not available on SVN
- - Dynamic loading, several bugfixes, and code cleanup for Multi
- - Added 'School Life' and 'Protagonist' categories
- - Time and Place categories are now boolean
- - Added GTIN field to releases
- - Added links to encubed and renai.us
-
-1.16 - 2008-05-22
- - Release dates in the current year or month without a specified day will
- be considered as not yet released
- - Added platform and language icons to the VN browser
- - Grouped producers, user stats and general information on the VN page
- - Added language icons to producer browser
- - A little CSS cleanup
- - Grouped category browser and search into one advanced search
- - Updated sitemap generator
- - Pattern matching bug fixes in Multi::IRC, and better handling of commands
- - Added .xml extention to all RSS URLs
-
-1.15 - 2008-05-04
- - Relation graph now also updated on VN title change
- - Anime relations
- - [js] Position of the dropdown box is now relative to the link element
- - Used inline-block for icon image sprites (to avoid stupid float hacks)
- - Used icons to indicate the release status type on VN pages
- - Give a 'not logged in' dropdown edit box when the user isn't logged in
- - Fixed the redirects for old URLs
- - Designed a better system to handle documentation
- - Created a centralised system for site errors within the same layout
- - Wrote some more documentation
- - Multi::IRC now also handles d[0-9] IDs
- - Multi::Maintenance automatically rotates Multi's logs
-
-1.14 - 2008-04-26
- - Removed the ID gap prevention method
- - Moved static content to static.vndb.org (and rely on lighty for js/css
- compression)
- - relation graphs and cover images now get an ID instead of MD5-sum
- - Added Nintendo Wii to platforms
- - Added 'hidden' flag, which should now be used instead of the delete option
- - Fixed the ordering of nodes in the relation graphs
- - Used global.pl as the central location of the PgSQL login info
- - Wrote a daemon which handles several tasks:
- - Generation of relation graphs
- - Generation of the sitemap.xml.gz
- - The IRC bot
- - scaling/compressing of cover images
- - General maintenance
- - Fixed bug with categories not being re-selected after an error submitting
- a new VN
- - Relation graphs are now automatically updated when a change in a related
- release causes information in the graph to be outdated
-
-1.13 - 2008-04-04
- - Fixed update_prev
- - Split revision insert queries into a seperate function for code reuse
- - Fixed wiki links
- - Fixed search for VN's without releases
- - Fixed bug with accepting zero-padded VNDB ID's
- - Fixed bug with V-N.net link getting lost after reverse relation update
- - Added .xml extension to AJAX requests
- - Switched to ';' seperator instead of '&' for some URL's (=cleaner)
- - Added language filter to category browser
- - Stored release dates as integers and added NOT NULL constraint
- - Used a newline to seperate multiple relations on a VN page
- - Multi will get credits for a reverse relation edit
- - Going to an edit-page without logging in will redirect
- - Added rankings to the categories
- - Fixed automated relation graph updates
- - Added /nospam page
- - Changed vote treshold to 3
-
-1.12 - 2008-03-09
- - Color coded diffs
- - Added noindex on ?ref= pages
- - Added TBA to release dates
- - Possibility to change vote without revoking first
- - Added VN/ADV categories
- - Replaced the Release summary with Producers on VN pages
- - Added foreign key constrains
-
-1.11 - 2008-02-29
- - [bug] Home page layout got screwed up when line wrapping occurs
- - [bug] Multiple revisions got counted at the category browser
- - Added GBA platform
- - Added Gameplay and Plot categories
- - Added link to V-N.net review
- - Added vote count to the global statistics in the main menu
- - [hidden] Added language filter to category browser
- - Created user pages
- - Redirect to VN page if someone visits an rX page from google/yahoo
- - Added link to latest revision in the diff-browser
- - Renamed "comments" to "Personal note" at VN List
-
-1.10 - 2008-02-09
- - [bug] Long revision summaries incorrectly chopped
- - Added GD-ROM and Blu-ray disk to media
- - Platform icons will be kept in a consistent order
- - ?rev= pages now show information about the change + diffs + links to
- previous/next revisions
- - Removed diff and revert links on history pages
- - Added rel="nofollow" to edit links
- - Changed lowest selectable year at releases to 1990
- - Use Bayesian ratings and added extra char to c_votes
- - A few small internal DB changes
- - Allowed [url]-tag in edit summary, and used same function to parse vn/p/r
- descriptions
- - Added line wrapping on long words at diff-viewer
- - VN search matches on release titles again
- - Added producer search
- - [bug] Releases in the future don't count as new language
- - Release dates in the future are now red
- - multiple vns for releases
- - Redirect to specific revision after editing
- - Redirect to the page you were at after logging in
- - Added "Other" status and "comments" field to VN lists
-
-1.9 - 2008-02-01
- - Redirect to VN when changing VN List status
- - [bug] All ages was not automatically selected
- - [bug] Description field ignored when adding or requesting edit of producer
- - Rewrote diff calculation
- - Added wildcard support to URI-mappings
- - Changed some URI's:
- /vn/* -> /v/*
- /u/_* -> /u/*
- /u/[username] -> /u[uid]
- - id-gaps for producers and releases are now also filled automatically
- - Switched producers name and romaji
- - Added visitor as rank for non-logged in visitors, and losers for banned
- users
- - Added history pages & feeds
- - Removed everything related to "pending changes"
- - Producers are lockable
- - Combined DBGetVN and DBGetVNs
- - Moved code for releases from VN.pm to Releases.pm
- - Denormalized vn_categories
- - Added "tabs" to visual novels, releases & producers
- - Made several changes to the visual novel page layout
- - Added mass-change/delete option to vnlists
- - Renamed vnr* to releases*
- - Fixed relation graphs generator to work with the new DB structure, and to
- delete graphs for VN's where the relation was deleted
- - Removed option to hide a user from the userlist
- - ResDenied will show the regiser-new-account-page
- - Usernames linkified at history and vn-stats pages
- - Added noindex tag on pages that include usernames
- - Swapped title <-> romaji for releases
- - Removed relation field and added type field for releases
- - Also allow [url]-bbcode tag for the notes field for releases and producers
- - [bug] Self-refering vn relations are not possible anymore
- - Wrote update_vncache as a plpgsql function
- - Updated homepage layout: added a few lists
- - Added filters to recent changes pages
- - Added platform icons to releases
- - Added user menu to vn pages
- - De-JS'ed the platform select form, used checkboxes instead
- - Updated FAQ
-
-1.8 - 2007-12-05
- - Added [url]-tag to vn description field
- - Changed category input to checkboxes
- - Used image sprites for category browser icons
- - Fixed bug with media-select-form
- - Fixed bug with pending producer changes showing up in the producer search
- - Added hack to exclude trial versions in the release dates
- - Removed audience category and added age rating field to releases
- - Fixed typo: "game hes either" -> "game has either"
- - Added Wikipedia & CISVisual link
- - Added small vertical padding between releases
- - Added length of visual novel
- - Renamed continues back to Sequel/Prequel
-
-1.7 - 2007-11-25
- - Bugfix: The visual novel itself is now also listed at the Pending Changes
- under the releases
- - Bugfix: Comments and Moderation subforms cannot be automatically hidden
- - Made release and vn-links in the edit-dropdown clickable, to edit all
- - Added "show all pending changes" option for moderators
- - Removed official (japanese) titles from producer list
- - Added description field for producers
- - Added a red asterisk for fields that are required
- - Combined 4 flag-columns in the users table to one
- - Added cronjob to delete unused relation graphs
-
-1.6 - 2007-11-11
- - vnr.released accepts NULL
- - vn.c_years renamed to vn.c_released, and only stores year+month of first
- release
- - Removed vn_releases.lastmod
- - Fixed CSS bug in releases layout
- - Renamed Sequel/Prequel to Continuation/continues...
- - Added relation graphs (/vX/rg)
-
-1.5 - 2007-11-04
- - Automatically hiding form parts is now done server-side
- - Release id's are hidden for not logged in visitors
- - Added cron job to compress images and remove Exif information
- - Possibility to add planned releases to 5 years in the future
- - Bugfix: When editing a VN that's waiting for moderation, the 'added'
- column won't be updated
- - Added NSFW-option to VN-images
- - Added small edit-dropdown when clicked on release-id
- - Pending changes tab for VN removed and contents moved to relations tab
- - Added Visual Novel Relations
-
-1.4 - 2007-10-28
- - 'Mina' category renamed to 'All Ages'
- - Added 'Clear selection' button to the category browser
- - New visual novels will get unused/lower ID's
- - Added notes-field to releases
- - Subforms can be dynamically hidden/shown
- - Bugfix: user stats will always stay under the votes at /vX/stats
- - Bugfix: syntax error in dyna.js in Opera
- - Combined all the add/edit/del-buttons into one menu
- - Changed VN page layout: description moved to relations page and categories
- have their own sub-item
-
-1.3 - 2007-10-21
- - Bugfix: checkbox at producer-search now works
- - VN ratings don't count of only one user has voted
- - Added VN list size and number of votes to user list
- - Added categories 'Drama' & 'Mystery'
- - Added exclude filters to the category browser
- - Added a few statistics to the right bottom of the page
-
-1.2 - 2007-10-14
- - Bugfix: vnr_producers rows weren't deleted when deleting a release
- - Added number of pending changes at "Pending changes" menu item
- - Long items (>30 chars) at the top 5's (right bottom) will be shortened
- - Added visual novel descriptions to the RSS feed
- - Bugfix: fixed msg when browsing votes of someone who hasn't voted yet
- - Bugfix: Voting now also works when viewing the vote stats of a VN
- - Added user VN lists
- - Added profile option to hide VN list
- - Changed 'votes' tab on VN page to 'stats' and added user stats.
-
-1.1 - 2007-10-07
- - Bugfix: you can now empty columns of the vn table
- - Japanese is automatically selected when adding a release or producer
- - User list has been made public
- - Possible to browse other people's votes
- - Added two options to "my account" to hide in user list and votes
- - Bugfix: username is now shown when accepting a producer
- - Bugfix: variable typo in tpl->pedit
- - Bugfix: c_*-update-function wasn't called correctly when changing/deleting
- releases
- - Bugfix: 'added' column in releases, vn and vnr is now updated at accepting
- - Added "Most Popular" vns to every page, and added "More..."-links.
- - Added RSS feed for recent additions
- - Changes visual novel page layout
- - Added vote graph + latest votes to the visual novel pages
- - Added compression on javascript files
- - Replaced relation-selection-box with an input field
-
-1.0 - 2007-09-30
- - First release
diff --git a/Dockerfile b/Dockerfile
index c4b73396..9738ccc5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,41 +1,40 @@
-FROM ubuntu:bionic
-MAINTAINER Yoran Heling <contact@vndb.org>
+FROM alpine:3.17
+MAINTAINER Yorhel <contact@vndb.org>
-RUN apt-get update
+ENV VNDB_DOCKER_VERSION=14
+ENV VNDB_GEN=/vndb/docker/gen
+ENV VNDB_VAR=/vndb/docker/var
+CMD /vndb/util/docker-init.sh
-RUN apt-get install -y locales && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
-ENV LANG en_US.utf8
-
-RUN apt-get install -y tzdata && apt-get install -y --no-install-recommends \
- build-essential \
- cpanminus \
- curl \
- git \
- graphviz \
- imagemagick \
- libalgorithm-diff-xs-perl \
- libanyevent-irc-perl \
- libanyevent-perl \
- libcrypt-urandom-perl \
- libdbd-pg-perl \
- libfcgi-perl \
- libhttp-server-simple-perl \
- libimage-magick-perl \
- libjson-xs-perl \
- libperlio-gzip-perl \
- libpq-dev \
- libtext-multimarkdown-perl \
- libtie-ixhash-perl \
- libxml-parser-perl \
- postgresql
-
-# These modules aren't packaged
-RUN cpanm -vn \
- Crypt::ScryptKDF \
- AnyEvent::Pg
-
-# Get TUWF from Git; I tend to experiment with VNDB before releasing new versions to CPAN.
-RUN cd /root && git clone git://g.blicky.net/tuwf.git && cd tuwf && perl Build.PL && ./Build install
-
-RUN touch /var/vndb-docker-image
-CMD /var/www/util/docker-init.sh
+RUN apk add --no-cache \
+ build-base \
+ curl \
+ git \
+ graphviz \
+ 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 \
+ postgresql \
+ postgresql-contrib \
+ postgresql-dev \
+ sassc \
+ wget \
+ zlib-dev \
+ && cpanm -nq \
+ AnyEvent::HTTP \
+ AnyEvent::IRC \
+ AnyEvent::Pg \
+ Crypt::ScryptKDF \
+ Crypt::URandom \
+ PerlIO::gzip \
+ SQL::Interp \
+ Text::MultiMarkdown \
+ git://g.blicky.net/tuwf.git \
+ && curl -sL https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz | zcat >/usr/bin/elm \
+ && chmod 755 /usr/bin/elm
diff --git a/Makefile b/Makefile
index 1ad640ba..6184d3e9 100644
--- a/Makefile
+++ b/Makefile
@@ -1,109 +1,279 @@
# all (default)
-# Same as `make dirs js icons skins robots`
+# Create all the necessary directories, javascript, css, etc.
#
-# dirs
-# Creates the required directories not present in git
+# prod
+# Create static assets for production. Requires the following additional dependencies:
+# - uglifyjs
+# - zopfli
+# - zopflipng
+# - brotli
+# - pandoc
#
-# js
-# Generates the Javascript code
-#
-# icons
-# Generates the CSS icon sprites
-#
-# skins
-# Generates the CSS code
-#
-# robots
-# Ensures that www/robots.txt and static/robots.txt exist. Can be modified to
-# suit your needs.
-#
-# 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.
-#
-# chmod-autoupdate
-# As chmod, but also chmods all files that may need to be updated from a
-# normal 'make' run. Should be used when the regen_static option is enabled
-# and the http process is run from a different user.
-#
-# 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.
+# test
+# Run the few unit tests that we do have.
+.PHONY: all prod clean test multi-stop multi-start multi-restart
+.DELETE_ON_ERROR:
-.PHONY: all dirs js icons skins robots chmod chmod-autoupdate multi-stop multi-start multi-restart
+VNDB_GEN ?= gen
+export VNDB_GEN
+GEN=${VNDB_GEN}
-all: dirs js skins robots data/config.pl util/sql/editfunc.sql
+CFLAGS ?= -O3 -Wall
-dirs: static/ch static/f static/cv static/sf static/st data/log www www/feeds www/api
+ifdef V
+Q=
+T=@\#
+E=@\#
+else
+Q=@
+E=@echo
+T=@printf "%s $@\n"
+endif
-js: static/f/vndb.js
+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))
-icons: data/icons/icons.css
+all: \
+ ${GEN}/editfunc.sql \
+ ${GEN}/static/icons.svg \
+ ${GEN}/static/icons.png \
+ ${GEN}/static/elm.js \
+ ${GEN}/imgproc \
+ ${JS_OUT} \
+ ${CSS_OUT}
-skins: $(shell ls static/s | sed -e 's/\(.\+\)/static\/s\/\1\/style.css/g')
+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}
-robots: dirs www/robots.txt static/robots.txt
+clean:
+ rm -rf "${GEN}"
-util/sql/editfunc.sql: util/sqleditfunc.pl util/sql/schema.sql
- util/sqleditfunc.pl
+%.gz: %
+ zopfli $<
-static/ch static/cv static/sf static/st:
- mkdir -p $@;
- for i in $$(seq -w 0 1 99); do mkdir -p "$@/$$i"; done
+%.br: %
+ brotli -f $<
+ @touch $@
-data/log www www/feeds www/api static/f:
+${GEN} ${GEN}/static ${GEN}/js ${GEN}/elm/Gen:
mkdir -p $@
-data/config.pl:
- cp -n data/config_example.pl data/config.pl
+${GEN}/editfunc.sql: util/sqleditfunc.pl sql/schema.sql | ${GEN}
+ util/sqleditfunc.pl >$@
-static/f/vndb.js: data/js/*.js util/jsgen.pl data/config.pl data/global.pl | static/f
- util/jsgen.pl
+${GEN}/api-%.html: api-%.md | ${GEN}
+ $T DOC
+ $Q pandoc "$<" -st html5 --toc -o "$@"
-data/icons/icons.css: data/icons/*.png data/icons/*/*.png util/spritegen.pl | static/f
- util/spritegen.pl
+test: all
+ prove util/test/bbcode.pl
+ if [ -e ${GEN}/imgproc-custom ]; then util/test/imgproc-custom.pl; fi
-static/s/%/style.css: static/s/%/conf util/skingen.pl data/style.css data/icons/icons.css
- util/skingen.pl $*
-%/robots.txt:
- echo 'User-agent: *' > $@
- echo 'Disallow: /' >> $@
-chmod: all
- chmod -R a-x+rwX static/{ch,cv,sf,st}
-chmod-autoupdate: chmod
- chmod a+xrw static/f data/icons
- chmod -f a-x+rw static/s/*/{style.css,boxbg.png} static/f/icons.png
+###### 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
+ $<
-# 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
+${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 $@
-define multi-start
- util/multi.pl
+
+
+
+###### 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:
+# - Add @license and @source comments
+# - Redirect calls from Lib.Ffi.* to window.elmFfi_*
+# - Patch the virtualdom diffing algorithm to always apply the 'selected' attribute
+# - 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 $@; \
+ 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" \
+ | sed -E "s/var flags = 'g'/var flags = 'gu'/g" >$@~
+ $Q mv $@~ $@
endef
-multi-stop:
- $(multi-stop)
+${ELM_CPFILES}: ${GEN}/%: %
+ $Q mkdir -p $(dir $@)
+ $Q cp $< $@
+
+${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
+
+${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 $@~
+ $Q mv $@~ $@
+
+
+
+
+###### Javascript #####
+
+${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 >$@
+
+${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 9c8c7268..49b6fdb1 100644
--- a/README.md
+++ b/README.md
@@ -1,48 +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 volume create --name vndb-data
+docker build --progress=plain -t vndb .
```
Run (will run on the foreground):
```
- docker run -ti --name vndb -p 3000:3000 -v vndb-data:/var/lib/postgresql -v "`pwd`":/var/www --rm vndb
+docker run -ti --name vndb -p 3000:3000 -v "`pwd`":/vndb --rm vndb
```
-While running, if you need another terminal into the container:
+If you need another terminal into the container while it's running:
```
- docker exec -ti vndb bash # root shell
- docker exec -ti vndb su -l devuser # development shell
- docker exec -ti vndb su postgres -c psql # postgres superuser shell
- docker exec -ti vndb su devuser -c 'psql -U vndb' # postgres vndb shell
+docker exec -ti vndb su -l devuser # development shell (files are at /vndb)
+docker exec -ti vndb psql -U vndb # postgres shell
```
-To run Multi, the optional application server:
+To start Multi, the optional application server:
```
- docker exec -ti vndb su -l devuser
- cd /var/www
- make multi-restart
+docker exec -ti vndb su -l devuser -c /vndb/util/multi.pl
```
-## Development database
-
-There is a development database available for download at
-[https://vndb.org/d8#3](https://vndb.org/d8#3).
-When you first run the docker image, you will be asked whether you want to
-download and import this database. If you do not use docker, you can import
-this database manually as follows:
-
-- Follow the steps below to setup PostgreSQL and initialze the database
-- Download and extract the development database
-- psql -U vndb -f dump.sql
+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)
@@ -50,77 +128,157 @@ this database manually as follows:
Global requirements:
- Linux, or an OS that resembles Linux. Chances are VNDB won't run on Windows.
-- PostgreSQL 10 (older versions may work)
-- perl 5.24 recommended, 5.10+ may also work
+- 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
-- Tie::IxHash
util/vndb.pl (the web backend):
- Algorithm::Diff::XS
+- SQL::Interp
- Text::MultiMarkdown
- TUWF
- HTTP::Server::Simple
util/multi.pl (application server, optional):
-- AnyEvent
-- AnyEvent::Pg
+- AnyEvent::HTTP
- AnyEvent::IRC
-- XML::Parser
-- graphviz (/usr/bin/dot is used by default)
+- AnyEvent::Pg
-## Setup
+## Manual setup
-- Make sure all the required dependencies (see above) are installed
-- Create a suitable data/config.pl, using data/config_example.pl as base.
+- Make sure all the required dependencies (see above) are installed. Hint: See
+ the Docker file for Alpine Linux commands, other distributions will be similar.
+ For non-root setup, check out cpanminus & local::lib.
- Run the build system:
```
- make
+make -j8
+```
+
+- Initialize your *var/* directory:
+
+```
+util/setup-var.sh
```
- Setup a PostgreSQL server and make sure you can login with some admin user
-- Initialize the VNDB database (assuming 'postgres' is a superuser):
+- Build the *vndbfuncs* PostgreSQL library:
```
- # Create the database & roles
- psql -U postgres -f util/sql/superuser_init.sql
+make -C sql/c
+```
- # 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
+- 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):
- # Now import the rest
- psql -U vndb -f util/sql/all.sql
+```
+# 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 the vndb_site password in data/config.pl to whatever you set it in
- the previous step.
-- (Optional) Import the "Development database" as explained above.
-- (Optional) Do the same for vndb_multi if Multi is needed.
+- 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
+```
+
+
+## Production Deployment
+
+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):
+
+```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
+# 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/conf_example.pl b/conf_example.pl
new file mode 100644
index 00000000..c4788971
--- /dev/null
+++ b/conf_example.pl
@@ -0,0 +1,41 @@
+{
+ # Canonical URL of this site
+ url => 'http://localhost:3000',
+ # And of the static files (leave unset to use `url`)
+ #url_static => 'http://localhost:3000',
+
+ # Salt used to generate the CSRF tokens
+ form_salt => '<some unique string>',
+ # 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' ],
+ xml_pretty => 0,
+ log_queries => 0,
+ debug => 1,
+ cookie_defaults => { domain => 'localhost', path => '/' },
+ mail_sendmail => 'log',
+ #fastcgi_max_requests => 1000 + int(rand(1000)),
+ },
+
+ # Options for Multi, the background server.
+ Multi => {
+ # Each module in lib/Multi/ can be enabled and configured here.
+ Core => {
+ db_login => { dbname => 'vndb', user => 'vndb_multi', password => 'vndb_multi' },
+ },
+ #API => {},
+ #IRC => {
+ # nick => 'MyVNDBBot',
+ # server => 'irc.synirc.net',
+ # channels => [ '#vndb' ],
+ # pass => '<nickserv-password>',
+ # masters => [ 'yorhel!~Ayo@your.hell' ],
+ #},
+ },
+}
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/config_example.pl b/data/config_example.pl
deleted file mode 100644
index dd2fb9db..00000000
--- a/data/config_example.pl
+++ /dev/null
@@ -1,55 +0,0 @@
-package VNDB;
-
-# This file is used to override config options in global.pl.
-# You can override anything you want.
-
-%O = (
- %O,
- db_login => [ 'dbi:Pg:dbname=vndb', 'vndb_site', 'vndb_site' ],
- logfile => $ROOT.'/err.log',
- xml_pretty => 0,
- log_queries => 0,
- debug => 1,
- cookie_defaults => { domain => 'localhost', path => '/' },
- mail_sendmail => 'log',
-);
-
-%S = (
- %S,
- url => 'http://localhost:3000',
- url_static => 'http://localhost:3000',
- form_salt => '<some unique string>',
- scrypt_salt => '<another unique string>',
- # 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 => $ROOT.'/data/passwords.dat',
-);
-
-$M{db_login} = { dbname => 'vndb', user => 'vndb_multi', password => 'vndb_multi' };
-
-# Uncomment to enable certain features of Multi
-
-#$M{modules}{API} = {};
-#$M{modules}{APIDump} = {};
-
-#$M{modules}{IRC} = {
-# nick => 'MyVNDBBot',
-# server => 'irc.synirc.net',
-# channels => [ '#vndb' ],
-# pass => '<nickserv-password>',
-# masters => [ 'yorhel!~Ayo@your.hell' ],
-#};
-
-
-# Uncomment the compression method to use for the generated Javascript (or just leave as-is to disable compression)
-#$JSGEN{compress} = 'JavaScript::Minifier::XS';
-#$JSGEN{compress} = "|/usr/bin/uglifyjs --compress --mangle";
-
-# Uncomment to create pre-compressed css and js files using zopfli
-#$JSGEN{gzip} = $SKINGEN{gzip} = "/usr/bin/zopfli";
-
-# Uncomment to generate an extra small icons.png
-# (note: using zopflipng or pngcrush with the slow option is *really* slow, but compresses awesomely)
-#$SPRITEGEN{crush} = '/usr/bin/pngcrush -q';
-#$SPRITEGEN{crush} = '/usr/bin/zopflipng -m --lossy_transparent';
-#$SPRITEGEN{slow} = 1;
diff --git a/data/global.pl b/data/global.pl
deleted file mode 100644
index 878bdba4..00000000
--- a/data/global.pl
+++ /dev/null
@@ -1,317 +0,0 @@
-
-package VNDB;
-
-use utf8;
-use strict;
-use warnings;
-use Tie::IxHash;
-
-our $ROOT;
-
-# Convenient wrapper to create an ordered hash
-sub ordhash { my %x; tie %x, 'Tie::IxHash', @_; \%x }
-
-
-# options for TUWF
-our %O = (
- db_login => [ 'dbi:Pg:dbname=vndb', 'vndb_site', 'passwd' ],
- debug => 1,
- logfile => $ROOT.'/data/log/vndb.log',
- cookie_prefix => 'vndb_',
- cookie_defaults => {
- domain => '.vndb.org',
- path => '/',
- },
-);
-
-
-# VNDB-specific options (object_data)
-our %S;
-%S = (%S,
- version => `cd $ROOT; git describe` =~ /^(.+)$/ && $1,
- url => 'http://vndb.org', # Only used by Multi, web pages infer their own address
- url_static => 'http://s.vndb.org',
- skin_default => 'angel',
- placeholder_img => 'http://s.vndb.org/s/angel/bg.jpg', # Used in the og:image meta tag
- form_salt => 'a-private-string-here',
- scrypt_args => [ 65536, 8, 1 ], # N, r, p
- scrypt_salt => 'another-random-string',
- regen_static => 0,
- source_url => 'http://git.blicky.net/vndb.git/?h=master',
- admin_email => 'contact@vndb.org',
- login_throttle => [ 24*3600/10, 24*3600 ], # interval between attempts, max burst (10 a day)
- 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
- # bit flags (Flag 8 was used for staffedit, now free to re-use)
- # The 'usermod' flag is hardcoded in sql/func.sql for user_* functions.
- permissions => {qw| board 1 boardmod 2 edit 4 tag 16 dbmod 32 tagmod 64 usermod 128 affiliate 256 |},
- default_perm => 1+4+16, # Keep synchronised with the default value of users.perm
- default_tags_cat=> 'cont,tech',
- languages => ordhash(grep !/^ *$/, split /[\s\r\n]*([^ ]+) +(.+)/, q{
- ar Arabic
- bg Bulgarian
- ca Catalan
- cs Czech
- da Danish
- de German
- el Greek
- en English
- eo Esperanto
- es Spanish
- fi Finnish
- fr French
- he Hebrew
- hr Croatian
- hu Hungarian
- id Indonesian
- it Italian
- ja Japanese
- ko Korean
- nl Dutch
- no Norwegian
- pl Polish
- pt-br Portuguese (Brazil)
- pt-pt Portuguese (Portugal)
- ro Romanian
- ru Russian
- sk Slovak
- sv Swedish
- ta Tagalog
- th Thai
- tr Turkish
- uk Ukrainian
- vi Vietnamese
- zh Chinese
- }),
- producer_types => ordhash(
- co => 'Company',
- in => 'Individual',
- ng => 'Amateur group',
- ),
- # Some discussion board properties are hardcoded, e.g.:
- # - number of rows to show on /t
- # - whether it needs mod access
- # - whether it needs to be linked to a DB item.
- discussion_boards => ordhash(
- an => 'Announcements',
- db => 'VNDB discussions',
- ge => 'General discussions',
- v => 'Visual novels',
- p => 'Producers',
- u => 'Users',
- ),
- vn_lengths => [
- # name time examples
- [ 'Unknown', '', '' ],
- [ 'Very short', '< 2 hours', 'OMGWTFOTL, Jouka no Monshou, The world to reverse' ],
- [ 'Short', '2 - 10 hours', 'Narcissu, Saya no Uta, Planetarian' ],
- [ 'Medium', '10 - 30 hours', 'Yume Miru Kusuri, Cross†Channel, Crescendo' ],
- [ 'Long', '30 - 50 hours', 'Tsukihime, Ever17, Demonbane' ],
- [ 'Very long', '> 50 hours', 'Clannad, Umineko, Fate/Stay Night' ],
- ],
- anime_types => {
- tv => 'TV Series',
- ova => 'OVA',
- mov => 'Movie',
- oth => 'Other',
- web => 'Web',
- spe => 'TV Special',
- mv => 'Music Video',
- },
- board_edit_time => 7*24*3600,
- vn_relations => ordhash(
- # id => [ reverse, txt ]
- seq => [ 'preq', 'Sequel' ],
- preq => [ 'seq', 'Prequel' ],
- set => [ 'set', 'Same setting' ],
- alt => [ 'alt', 'Alternative version' ],
- char => [ 'char', 'Shares characters' ],
- side => [ 'par', 'Side story' ],
- par => [ 'side', 'Parent story' ],
- ser => [ 'ser', 'Same series' ],
- fan => [ 'orig', 'Fandisc' ],
- orig => [ 'fan', 'Original game' ],
- ),
- prod_relations => ordhash(
- 'old' => [ 'new', 'Formerly' ],
- 'new' => [ 'old', 'Succeeded by' ],
- 'spa' => [ 'ori', 'Spawned' ],
- 'ori' => [ 'spa', 'Originated from' ],
- 'sub' => [ 'par', 'Subsidiary' ],
- 'par' => [ 'sub', 'Parent producer' ],
- 'imp' => [ 'ipa', 'Imprint' ],
- 'ipa' => [ 'imp', 'Parent brand ' ],
- ),
- age_ratings => [-1, 0, 6..18],
- release_types => [qw|complete partial trial|],
- # The 'unk' platform and medium are reserved for "unknown".
- platforms => ordhash(grep !/^ *$/, split /[\s\r\n]*([^ ]+) +(.+)/, q{
- win Windows
- dos DOS
- lin Linux
- mac Mac OS
- ios Apple iProduct
- and Android
- dvd DVD Player
- bdp Blu-ray Player
- fmt FM Towns
- gba Game Boy Advance
- gbc Game Boy Color
- msx MSX
- nds Nintendo DS
- nes Famicom
- p88 PC-88
- p98 PC-98
- pce PC Engine
- pcf PC-FX
- psp PlayStation Portable
- ps1 PlayStation 1
- ps2 PlayStation 2
- ps3 PlayStation 3
- ps4 PlayStation 4
- psv PlayStation Vita
- drc Dreamcast
- sat Sega Saturn
- sfc Super Nintendo
- swi Nintendo Switch
- wii Nintendo Wii
- wiu Nintendo Wii U
- n3d Nintendo 3DS
- x68 X68000
- xb1 Xbox
- xb3 Xbox 360
- xbo Xbox One
- web Website
- oth Other
- }),
- media => ordhash(
- #DB qty txt plural (if qty) icon
- cd => [ 1, 'CD', 'CDs', 'disk' ],
- dvd => [ 1, 'DVD', 'DVDs', 'disk' ],
- gdr => [ 1, 'GD-ROM', 'GD-ROMs', 'disk' ],
- blr => [ 1, 'Blu-ray disc', 'Blu-ray discs', 'disk' ],
- flp => [ 1, 'Floppy', 'Floppies', 'cartridge'],
- mrt => [ 1, 'Cartridge', 'Cartridges', 'cartridge'],
- mem => [ 1, 'Memory card', 'Memory cards', 'cartridge'],
- umd => [ 1, 'UMD', 'UMDs', 'disk' ],
- nod => [ 1, 'Nintendo Optical Disc', 'Nintendo Optical Discs', 'disk' ],
- in => [ 0, 'Internet download', '', 'download' ],
- otc => [ 0, 'Other', '', 'cartridge'],
- ),
- resolutions => ordhash(
- unknown => [ 'Unknown / console / handheld', '' ], # hardcoded in many places
- nonstandard => [ 'Non-standard', '' ], # hardcoded in VNPage.pm
- '640x480' => [ '640x480', '4:3' ],
- '800x600' => [ '800x600', '4:3' ],
- '1024x768' => [ '1024x768', '4:3' ],
- '1280x960' => [ '1280x960', '4:3' ],
- '1600x1200' => [ '1600x1200', '4:3' ],
- '640x400' => [ '640x400', 'widescreen' ],
- '960x600' => [ '960x600', 'widescreen' ],
- '960x640' => [ '960x640', 'widescreen' ],
- '1024x576' => [ '1024x576', 'widescreen' ],
- '1024x600' => [ '1024x600', 'widescreen' ],
- '1024x640' => [ '1024x640', 'widescreen' ],
- '1280x720' => [ '1280x720', 'widescreen' ],
- '1280x800' => [ '1280x800', 'widescreen' ],
- '1366x768' => [ '1366x768', 'widescreen' ],
- '1600x900' => [ '1600x900', 'widescreen' ],
- '1920x1080' => [ '1920x1080', 'widescreen' ],
- ),
- tag_categories => ordhash(
- cont => 'Content',
- ero => 'Sexual content',
- tech => 'Technical',
- ),
- animated => [ 'Unknown', 'No animations', 'Simple animations', 'Some fully animated scenes', 'All scenes fully animated' ],
- icons_story_animated => [ 'unknown', 'story_not_animated', 'story_simple_animated', 'story_some_fully_animated', 'story_all_fully_animated' ],
- icons_ero_animated => [ 'unknown', 'ero_not_animated', 'ero_simple_animated', 'ero_some_fully_animated', 'ero_all_fully_animated' ],
- voiced => [ 'Unknown', 'Not voiced', 'Only ero scenes voiced', 'Partially voiced', 'Fully voiced' ],
- icons_voiced => [ 'unknown', 'not_voiced', 'ero_voiced', 'partially_voiced', 'fully_voiced' ],
- wishlist_status => [ 'high', 'medium', 'low', 'blacklist' ],
- rlist_status => [ 'Unknown', 'Pending', 'Obtained', 'On loan', 'Deleted' ], # 0 = hardcoded "unknown", 2 = hardcoded 'OK'
- vnlist_status => [ 'Unknown', 'Playing', 'Finished', 'Stalled', 'Dropped' ],
- blood_types => ordhash(qw{unknown Unknown o O a A b B ab AB}),
- genders => ordhash(unknown => 'Unknown or N/A', qw{m Male f Female b Both}),
- char_roles => ordhash(
- main => [ 'Protagonist', 'Protagonists' ],
- primary => [ 'Main character', 'Main characters' ],
- side => [ 'Side character', 'Side characters' ],
- appears => [ 'Makes an appearance', 'Make an appearance' ],
- ),
- atom_feeds => { # num_entries, title, id
- announcements => [ 10, 'VNDB Site Announcements', '/t/an' ],
- changes => [ 25, 'VNDB Recent Changes', '/hist' ],
- posts => [ 25, 'VNDB Recent Posts', '/t' ],
- },
- staff_roles => ordhash(
- scenario => 'Scenario',
- chardesign => 'Character design',
- art => 'Artist',
- music => 'Composer',
- songs => 'Vocals',
- director => 'Director',
- staff => 'Staff',
- ),
- poll_options => 20, # max number of options in discussion board polls
- engines => [ grep $_, split /\s*\n\s*/, q{
- BGI/Ethornell
- CatSystem2
- codeX RScript
- EntisGLS
- Ikura GDL
- KiriKiri
- Majiro
- NScripter
- QLIE
- RPG Maker
- RealLive
- Ren'Py
- Shiina Rio
- Unity
- YU-RIS
- }],
-);
-
-
-# Multi-specific options (Multi also uses some options in %S and %O)
-our %M = (
- log_dir => $ROOT.'/data/log',
- log_level => 'trace',
- modules => {
- #API => {}, # disabled by default, not really needed
- #APIDump => {},
- Feed => {},
- RG => {},
- #Anime => {}, # disabled by default, requires AniDB username/pass
- Maintenance => {},
- #IRC => {}, # disabled by default, no need to run an IRC bot when debugging
- },
-);
-
-
-# Options for jsgen.pl
-our %JSGEN = (
- compress => undef,
- gzip => undef,
-);
-
-
-# Options for spritegen.pl
-our %SPRITEGEN = (
- slow => 0,
- crush => undef,
-);
-
-# Options for skingen.pl
-our %SKINGEN = (
- gzip => undef,
-);
-
-
-# allow the settings to be overwritten in config.pl
-require $ROOT.'/data/config.pl' if -f $ROOT.'/data/config.pl';
-
-1;
-
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/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 1258afc6..00000000
--- a/data/js/filter.js
+++ /dev/null
@@ -1,700 +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;
- }
-
- if(!values['tag_inc'] && !values['trait_inc'])
- delete values['tagspoil'];
-
- 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) {
- 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 = v[0] == '' ? def : parseInt(v[0]);
- x = (x-min)*w/(max-min);
- } else {
- s = curSlider;
- if(!e) e = window.event;
- x = (!e) ? (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 = 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 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;
-
- 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'),
- ],
- 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),
- filFInput('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.staff_roles;
- 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 8d9eef30..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.body.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.body.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 09d62d12..00000000
--- a/data/js/main.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/* 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
-
-// Staff editing (/s+/edit)
-//include staffalias.js
diff --git a/data/js/misc.js b/data/js/misc.js
deleted file mode 100644
index c0c67ef8..00000000
--- a/data/js/misc.js
+++ /dev/null
@@ -1,325 +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);
- };
-
-// Notification list onclick
-(function(){
- var d = byId('notifies');
- if(!d)
- return;
- var l = byClass(d, 'td', 'clickable');
- for(var i=0; i<l.length; i++)
- l[i].onclick = function() {
- var baseurl = location.href.replace(/\/u([0-9]+)\/notifies.*$/, '/u$1/notify/');
- location.href = baseurl + this.id.replace(/notify_/, '');
- };
-})();
-
-
-// BBCode spoiler tags
-(function(){
- var l = byClass('b', 'spoiler');
- for(var i=0; i<l.length; i++) {
- l[i].onmouseover = function() { setClass(this, 'spoiler', false); setClass(this, 'spoiler_shown', true) };
- l[i].onmouseout = function() { setClass(this, 'spoiler', true); setClass(this, 'spoiler_shown', false) };
- }
-})();
-
-
-// vndb.org domain check
-if(location.hostname != 'vndb.org') {
- addBody(tag('div', {id:'debug'},
- tag('h2', 'This is not VNDB!'),
- 'The real VNDB is ',
- tag('a', {href:'http://vndb.org/'}, 'here'),
- '.'
- ));
-}
-
-
-// '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();
-})();
-
-
-// mouse-over price information / disclaimer
-(function(){
- if(byId('buynow')) {
- var l = byClass(byId('buynow'), 'abbr', 'pricenote');
- for(var i=0; i<l.length; i++) {
- l[i].buynow_last = l[i].title;
- l[i].title = null;
- ddInit(l[i], 'bottom', function(acr) {
- return tag('p', {onmouseover:ddHide, style:'padding: 3px'},
- acr.buynow_last, tag('br', null),
- '* The displayed price only serves as an indication and',
- tag('br', null), 'usually excludes shipping. Actual price may differ.'
- );
- });
- }
- }
-})();
-
-
-// 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();
-})();
-
-
-// "check all" checkbox
-(function(){
- function set() {
- var l = byName('input');
- for(var i=0; i<l.length; i++)
- if(l[i].type == this.type && l[i].name == this.name && !hasClass(l[i], 'hidden'))
- l[i].checked = this.checked;
- }
- var l = byClass('input', 'checkall');
- for(var i=0; i<l.length; i++)
- if(l[i].type == 'checkbox')
- l[i].onclick = set;
-})();
-
-
-// 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/staffalias.js b/data/js/staffalias.js
deleted file mode 100644
index 7e6abe0c..00000000
--- a/data/js/staffalias.js
+++ /dev/null
@@ -1,80 +0,0 @@
-function salLoad () {
- byId('alias_tbl').appendChild(tag('tr', {id:'alias_new'},
- tag('td', null),
- tag('td', {colspan:3}, tag('a', {href:'#', onclick:salFormAdd}, 'Add alias'))));
-
- salAdd(byId('primary').value||0, byId('name').value, byId('original').value);
- var aliases = jsonParse(byId('aliases').value) || [];
- for(var i = 0; i < aliases.length; i++) {
- salAdd(aliases[i].aid, aliases[i].name, aliases[i].orig);
- }
-
- byName(byId('maincontent'), 'form')[0].onsubmit = salSerialize;
-}
-
-function salAdd(aid, name, original) {
- var tbl = byId('alias_tbl');
- var first = tbl.rows.length <= 1;
- tbl.insertBefore(tag('tr', first ? {id:'primary_name'} : null,
- tag('td', {'class':'tc_id' },
- tag('input', {type:'radio', name:'primary_id', value:aid, checked:first, onchange:salPrimary})),
- tag('td', {'class':'tc_name' }, tag('input', {type:'text', 'class':'text', value:name})),
- tag('td', {'class':'tc_original' }, tag('input', {type:'text', 'class':'text', value:original})),
- tag('td', {'class':'tc_add' }, !first ?
- tag('a', {href:'#', onclick:salDel}, 'remove') : null)
- ), byId('alias_new'));
-}
-
-function salPrimary() {
- var prev = byId('primary_name')
- prev.removeAttribute('id');
- byClass(prev, 'td', 'tc_add')[0].appendChild(tag('a', {href:'#', onclick:salDel}, 'remove'));
- var tr = this;
- while (tr && tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- tr.setAttribute('id', 'primary_name');
- var td = byClass(tr, 'td', 'tc_add')[0];
- while (td.firstChild)
- td.removeChild(td.firstChild);
-
- return salSerialize();
-}
-
-function salSerialize() {
- var tbl = byName(byId('alias_tbl'), 'tr');
- var a = [];
- for (var i = 0; i < tbl.length; ++i) {
- if(tbl[i].id == 'alias_new')
- continue;
- var id = byName(byClass(tbl[i], 'td', 'tc_id')[0], 'input')[0].value;
- var name = byName(byClass(tbl[i], 'td', 'tc_name')[0], 'input')[0].value;
- var orig = byName(byClass(tbl[i], 'td', 'tc_original')[0], 'input')[0].value;
- if(tbl[i].id == 'primary_name') {
- byId('name').value = name;
- byId('original').value = orig;
- byId('primary').value = id;
- } else
- a.push({ aid:Number(id), name:name, orig:orig });
- }
- byId('aliases').value = JSON.stringify(a);
- return true;
-}
-
-function salDel() {
- var tr = this;
- while (tr && tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- var tbl = byId('alias_tbl');
- tbl.removeChild(tr);
- salSerialize();
- return false;
-}
-
-function salFormAdd() {
- salAdd(0, '', '');
- byName(byClass(byId('alias_new').previousSibling, 'td', 'tc_name')[0], 'input')[0].focus();
- return false;
-}
-
-if(byId('jt_box_staffe_geninfo'))
- salLoad();
diff --git a/data/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 3b8cdf04..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
- ? tag('i', val)
- : tag('a', {href:'#', rl_rid:relid, rl_act:i, onclick:change}, val)));
- }
- 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]);
- });
- 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 60a30b80..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.staff_roles;
- 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/notes/atom-feeds b/data/notes/atom-feeds
deleted file mode 100644
index f1be17e8..00000000
--- a/data/notes/atom-feeds
+++ /dev/null
@@ -1,26 +0,0 @@
-Atom Feeds
-
-Last modified: 2010-11-13
-Status: Implemented
-
-
-New module: Multi::Feed
-Automatically generates and updates the following feeds:
- www/feeds/
- announcements.atom
- Updated?: LISTEN 'newpost'; post.num = 1 and board = 'an'
- (what about an edit of the annoucement title/content?)
- changes.atom
- Updated?: LISTEN 'changes'
- posts.atom
- Updated?: LISTEN 'newpost'
- (what about edits of posts? title/contents can change...)
- released.atom (not implemented)
- Updated?: daily + LISTEN 'changes'; c.type = 'r'
- (more restrictions can be added if the generation time of this feed is long)
-
-All feeds are updated once every 15 minutes; this is easier and less
-error-prone than the above notify solutions that differ for each feed.
-Assuming all feeds can be generated in one second, this takes
-(1/(15*60))*100 = ~0.1% of server CPU time on average.
-
diff --git a/data/notes/chardb b/data/notes/chardb
deleted file mode 100644
index 37dfa80d..00000000
--- a/data/notes/chardb
+++ /dev/null
@@ -1,578 +0,0 @@
-Character Database
-
-Last modified: 2011-02-14
-Status: Draft / incomplete
-
-
-*GLOBAL* data layout (ignoring any UI stuff or implementation details):
-
- Format: (in case it's not obvious)
- - field with a single value
- - field with multiple values
- - subfield - field for each value in the above field
-
- New DB item: Trait (not versioned, moderated; similar to tags)
- - name
- - aliases
- - description
- - parents (multiple parents; similar to tags)
- - state (new/approved/deleted; similar to tags)
- - added by (similar to tags)
-
- New DB item: Character (versioned) (without instances)
- - name
- - original name
- - aliases / nicknames
- - image
- - description
- - 3 sizes
- - height and weight
- - birthday (day and month only; year rarely known and hardly practical)
- - list of traits
- - trait ID
- - spoiler flag
- - list of VNs
- - VN id
- - release id
- - spoiler flag ("the fact that this character appears in this game is a spoiler")
- - role
- - link to "main" character + spoiler indication
-
- (UNUSED) New DB item: Character (versioned) (instance idea)
- - aliases / nicknames (misc. not names of instances)
- - default instance
- - instances (at least one for each character)
- - name
- - original name
- - image
- - description
- - spoiler flag
- - 3 sizes
- - age? birthday?
- - list of traits
- - trait ID
- - spoiler level
- - list of VNs
- - VN id
- - role (protagonist, primary character, side character, appears in)
- - list of releases (none implies all)
-
-
- "Same character" spoiler problems:
- Case 1: (relatively common)
- The characters themselves are not spoilers, but the fact that they are the same is.
- Examples (ROT13):
- Symphonic Rain: Cubeav & Nevrggn
- Fate/stay night: Nepure & Fuvebh
- How to handle:
- With instances: no solution found yet, other than not using instances
- Without instances: character relation with spoiler flag set
-
- Case 2: (not very uncommon)
- The entire existence of a character is a spoiler.
- Examples (ROT13):
- Ever17: Oyvpx Jvaxry
- Aoishiro: Arxngn? Lnfhuvzr?
- How to handle:
- With instances: make the instance a spoiler (would take care of everything)
- Without instances: the relation with the VN should be marked a spoiler
-
- Case 3: (pretty uncommon)
- What appears to be a single character turns out to be multiple characters in the end.
- (this one sucks...)
- Examples:
- Ever17: Xvq (Ubxhgb & Elbtb), Lbh ('Nxv naq 'Uneh)
- How to handle:
- With instances: (ugly, but does the trick)
- make one character entry with two or three instances: one with what
- the game wants you to believe and use other instances for the actual
- characters (set to spoiler).
- Without instances:
- make a separate entry for each character that the game wants you to
- believe is a single character, and separate entries for the actual
- characters. link them together and to VNs with spoiler markins.
-
-
- Traits vs. fields:
- - Preferably, we'd put as much data in traits, since these are flexible
- - However, we do want to have some basic information (e.g. gender, apparent
- age) to be specified early on (e.g. with a dropdown selection). And we also
- want them to be nicely ordered on the charpage (e.g. Gender: male).
- - It would be nice if it were also possible to limit the selection of some
- tags to only one for a specific category. E.g. A character can only have
- one "Gender" trait. But this isn't all that important, since I doubt users
- are *that* stupid and traits are part of the characters' revisioning
- system, which means everything can be moderated quite easily.
- - "Displaying everything nice on a characters' page"-solutions:
- 1. Table-layout with a "Parent: trait1, trait2" listing, where parent is
- the top trait (i.e. without parents). Traits with multiple parents will
- have to be listed multiple times.
- 2. Same as above, but add a boolean flag "category" to the traits. The
- traits with this flag set will be used in the table instead of the
- top-traits. This allows more flexibility of the trait tree, but is more
- complex to implement.
-
-
- Linking "same character"s together (without instances):
- - It is possible to handle this with a regular char<->char "is the same as"
- relation. This can become annoying when there are many entries that are
- the same. For example, if there are four characters (A-D), then there are
- many different ways to link these together with that relation:
- "Linked list"-style: A=B; B=A,C; C=B,D; D=C
- "Binary Tree"-style: A=B,C; B=A,D; C=A; D=B
- "Everything"-style: A=B,C,D; B=A,C,D; C=A,B,D; D=A,B,C
- That is quite annoying, both to the user and in the code. :-(
- On the upside, since every relation has a "spoiler" option, this does allow
- some flexibility: A=B and C=D may be spoilers, but B=C does not have to be.
- - An alternative approach: "parent"->"child" relations (let's call them
- main->guise relations (thank you AniDB), since the CS terminology fails
- here).
- To explain: each "guise" character can be linked to a single "main"
- character (with spoiler flag). As a restriction, this "main" character can
- not itself be linked to an other character as "guise" again. This gives us
- an easy structure to work with. With the above example, using "A" as "main"
- character (and "->" is "links to"): A; B->A; C->A, D->A.
- This approach is actually extremely similar to the idea of using instances:
- the data structure created with these links is equivalent to the structure
- with instances. The main differences are the implementation and the idea
- that the "instances" themselves are centric rather than their "main
- character". This idea also prevents the issue of "same character spoiling:
- case 1".
-
-
- Misc. questions:
- - How to handle cases where in an original release a character only played a
- side role, while in a later release this character would get a route?
- This isn't entirely uncommon... Possible solutions:
- 1. Allow a character to be linked to the same VN more than once with
- different role and different releases. This solves the problem, but would
- make it hard to generate a nice overview of all characters in a VN
- (covering all its releases).
- 2. Move the "role" field as a subfield of the release links. This is
- probably a better idea...
- But I'm going with option 1 anyway, since is easier and more efficient.
- - Similar to the above, are there cases where in an original release the
- existance of a character is a spoiler, while in a later release it is not?
- Can't think if any...
- - "Has route" indication?
- This obviously doesn't work for all VNs, since routes are not always linked
- to characters. In the case that it does work, it should probably be a
- subfield of the release links (see the "role" thing above).
- Better yet, we should have a "route database". For the future. >_>
- - "Age" field? Bad idea?
- - May differ per release even when nothing else changed (hello JAST USA).
- Can be handled by adding a new character and linking and stuff, but isn't
- worth the trouble)
- - Conflicting information. For example: When heroines are 1st grade high
- school but it is stated in the beginning that "all characters are at
- least 18 years old"
- - Just a bad idea in general, since there are quite a few fuckable
- 10-years-olds, and explicitely stating that is not a very nice thing.
- - External links?
- - Wikipedia (en)?
- - AniDB
- - Animecharacterdatabase.com (crappy site, but people seem to use it?)
- - MLA?
-
-
- Traits vs. VN tags (not very important for now):
- - Ideally, some character traits would imply VN tags.
- - For example: a VN that has a character linked to it with the "vampire"
- trait, the VN should have the "Vampire" tag.
- - Correctly implementing this would be hard, but it is possible to get Multi
- to add auto-votes with some rules. This would require traits to have an
- "implies" relation with tags.
- - "Level" setting can be partly determined from the importance of the role of
- the character. Though this will just be a wild guess.
- - "Spoiler" setting is inherited, though unknown which level it should have.
- The following might work:
- tag spoiler = 0
- tag spoiler +1 if the character-VN link is a spoiler
- tag spoiler +1 if the tag-character link is a spoiler
-
-
- Ever17 example (with instances, SPOILERS):
- These examples are not technically correct, since the two "You"s are two
- separate characters, and there is no such thing as "Kid". But the following
- example would do the trick in a way that is neither spoilerous, nor
- completely wrong.
-
- You:
- alias: You, Nakkyu
- Instance #1:
- | name: Yubiseiharukana Tanaka
- | VNs: v17, all releases, primary character
- | spoiler: no
- Instance #2:
- | name: Yubiseiakikana Tanaka
- | VNs: v17, all releases, side character
- | spoiler: yes
-
- Kid:
- alias: Kid
- Instance #1:
- | name: Shounen
- | description: Doesn't even remember his own name, dumbass! Protagonist in the 2034 routes.
- | VNs: v17, all releases, protagonist
- | spoiler: no
- Instance #2:
- | name: Ryogo Kaburaki
- | description: "kid" in 2017, "Takeshi" in 2034
- | VNs: v17, all releases, primary character
- | spoiler: yes
- Instance #3:
- | name: Hokuto
- | description: "kid" in 2034, son of Tsugumi and Takeshi
- | VNs: v17, all releases, protagonist
- | spoiler: yes
-
- Without instances: make a separate character entry for each of the above
- instances and set the appropriate spoiler flags.
-
-
-
-
-The term "instance":
- To make things extra confusing, the term "instance" has two meanings:
- 1. In the above part (global data layout) and in Maou's original draft, it
- refers to a special database entry of type "instance", separate from the type
- "character".
- 2. In the rest of this document, I'm using it to mean a character entry which
- has the "main character" field set. In the global data layout I used the term
- "guise", but I believe "instance" is better.
-
-
-
-
-User interface considerations:
-
- Pages to consider:
- - trait page
- - trait edit
- - trait listing / overview
- - character page
- - character edit
- - character listing / search
- - VN page (list of characters)
-
- Trait page:
- URI: /i+
- Similar to tag page: basic description + listing of characters.
- The listing of characters includes all characters linked to child traits.
- (same as with tags)
-
- Trait edit:
- URI: /i+/edit, /i/new
- The regular add/edit form.
- - What to do with the linked characters when a trait is marked as deleted
- or meta? Batch-edit all character entries to remove the trait? Sounds
- painful...
-
- Trait listing / overview:
- URI: /i
- Just be creative with this, can be similar to the tag overview.
-
- Character page:
- URI: /c+
- If the character is not an instance (i.e. it has no "main character"):
- Display the information of the requested character, followed by that of
- all instances linked to it. (spoilerous instances are hidden by default).
- If the character is an instance:
- - Display the information of the requested character, followed by a link
- to its main character? (if it's not a spoiler of course).
- - Or make no distinction between "Main character" and "instance", and
- simply display all information or the main character and its other
- instances on the same page? (similar to the main character page). This
- is sligtly counter-intuitive when the relation between the instance and
- its main character is a spoiler: in that case all the related entries
- would be hidden, rather than only those of which their relation is a
- spoiler. (See global data layout -> same character linking above for a
- discussion).
- Information display for a single character:
- Name
- (orig name)
- [image] [table]
- [description]
- table:
- | Name: <name>
- | Original name: <orig>
- | ...
- | Visual novels: Role - VN title
- | VN title 2
- | > Role - release title
- | > Role - other releases
- | Trait group #1: Trait1, trait2, ..
- | Trait group #2: ..
-
- Character edit:
- URI: /c+/edit
- This requires some thinking...
- - Batch-edit-with-instances:
- Instances more often than not share quite a bit of information with each
- other. When editing a character that is an instance or has instances, the
- edit page should preferably contain the char-edit-form for all related
- characters, and automatically link fields that are the same for all
- instances together. E.g. each field could have a checkbox indicating
- "same as main character", in which case editing the field in the main
- character would update that of the instances as well.
- This is slightly more annoying with traits, since this should be done on
- a per-trait basis.
- - VN-linking:
- Just mirror the structure of the chars_traits table:
- <VN title> <release dropdown> <spoiler checkbox> <role dropdown>
- A VN can be added more than once to select other releases. This isn't all
- that intuitive, but is simple to implement and does the job.
- - Trait linking interface? How will it work together with the
- batch-edit-with-instances and copying over traits from the main
- character?
-
- Character listing / search:
- URI: /c
- Nothing special.
-
- VN page (list of characters):
- URI: /v+ (stays the same, obviously)
- Add tabs above the "Releases" box with two items: "Main" and "Characters".
- "Main": Displays the usual "Releases" / "User stats" / "Screenshots" boxes
- "Characters": Displays a list of characters linked to that VN,
- getchu-style. Spoilerous characters are hidden by default.
- JS-tabs vs. new URI:
- JS tabs are nicer, but require all character information to be sent with
- each pageview on the VN page. This is heavy on the server and slows down
- page loading. This can be avoided by loading the character data using
- AJAX when the tab is opened, but I'm not very fond of using AJAX in this
- way. So in that sense a separate URI may be a better idea. E.g. /v+/chars
- Listing:
- Order by role: protag -> main chars -> side -> etc
- Display image + quite a bit of information for protag + main chars,
- followed by a plain and simple (table) listing of "other characters".
-
- Misc. stuff:
- - Do we want to be able to search for VNs that have a character with a
- certain trait? For example, to get a listing of all VNs that have a
- "vampire" character. This will be very heavy on the server if it were
- implemented without some form of caching, and may not be very useful if
- you can't set other constraints as well (e.g. it must be a main character
- in the VN).
- People will definitely complain if they can't search on their "Genius
- protagonist" tag anymore. >_>
-
-
-
-
-The SQL schema:
- (outdated, see /util/updates/update_2.19.sql instead)
-
- CREATE TABLE traits (
- id SERIAL PRIMARY KEY,
- name varchar(250) NOT NULL UNIQUE,
- description text NOT NULL DEFAULT '',
- meta boolean NOT NULL DEFAULT false,
- added timestamptz NOT NULL DEFAULT NOW(),
- state smallint NOT NULL DEFAULT 0,
- addedby integer NOT NULL DEFAULT 0 REFERENCES users (id)
- );
-
- CREATE TABLE traits_aliases (
- alias varchar(250) NOT NULL PRIMARY KEY,
- trait integer NOT NULL REFERENCES traits (id)
- );
-
- CREATE TABLE traits_parents (
- trait integer NOT NULL REFERENCES traits (id),
- parent integer NOT NULL REFERENCES traits (id),
- PRIMARY KEY(trait, parent)
- );
-
- CREATE TABLE chars (
- id SERIAL PRIMARY KEY,
- latest integer NOT NULL DEFAULT 0 REFERENCES chars_rev (id),
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE
- );
-
- CREATE TABLE chars_rev (
- id integer NOT NULL PRIMARY KEY REFERENCES changes (id),
- cid integer NOT NULL REFERENCES chars (id),
- 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 '',
- s_bust smallint NOT NULL DEFAULT 0, -- in cm
- s_waist smallint NOT NULL DEFAULT 0,
- s_hip smallint NOT NULL DEFAULT 0,
- b_month smallint NOT NULL DEFAULT 0, -- birthday
- b_day smallint NOT NULL DEFAULT 0,
- height smallint NOT NULL DEFAULT 0, -- in cm
- weight smallint NOT NULL DEFAULT 0, -- in kg
- main integer REFERENCES chars (id),
- main_spoil boolean NOT NULL DEFAULT false
- );
-
- CREATE TABLE chars_traits (
- cid integer NOT NULL REFERENCES chars_rev (id),
- tid integer NOT NULL REFERENCES traits (id),
- spoil boolean NOT NULL DEFAULT false, -- boolean or smallint?
- PRIMARY KEY(cid, tid)
- );
-
- CREATE TABLE chars_vns (
- cid integer NOT NULL REFERENCES chars_rev (id),
- vid integer NOT NULL REFERENCES vn (id),
- rid integer REFERENCES releases (id), -- NULL = "all releases"
- spoil boolean NOT NULL DEFAULT false,
- role char_role NOT NULL DEFAULT 'main',
- PRIMARY KEY(cid, vid, rid)
- );
-
- -- this one is probably required to speed up character-by-trait search.
- -- Similar to chars_traits, but has two differences:
- -- 1. all parent tags are included here
- -- 2. unversioned. i.e. it links to the chars table instead of chars_rev.
- CREATE TABLE chars_traits_inherit (
- cid integer NOT NULL REFERENCES chars (id),
- tid integer NOT NULL REFERENCES traits (id),
- spoil boolean NOT NULL DEFAULT false, -- boolean or smallint?
- PRIMARY KEY(cid, tid)
- );
-
-
-
-
-
-
-The original Maou draft (which I use as a sort of guideline / inspiration):
-
- Each game has a list of characters.
-
- Each "character" has:
- 0. an ID (cXXX)
- 1. a name (mandatory)
- 2. original (kanji/kana)
- 3. alias, nicknames
- 4. a portray/pic (if so desired, move to instance)
- 5. a list of instances (mandatory)
-
- Instances:
- 0. an ID (iXXX)
- 1. a vn (mandatory)
- 2. a list of releases
- 3. a description - what it says on the label
- 4. a traitlist
- 5. a commentlist
-
- Traits(have spoilerratings and inheritance, but are not votable):
- 1st Trait (mandatory when creating): Role - protagonist, Heroine, Side Character, Antagonist
- 2nd Trait ( " " " ): Sex - male, female, both?
- following traits should describe the character, our current character tags could prolly be converted for that
-
- commentlist: People can post (short) opinions about a character quickly...
- Users can edit/delete their own entries, mods can edit/delete everything.
-
- Q: Why instances?
- A: Characters often reappear in other games by the same company - works by
- age are just one example. More often that not, they change between their
- appearances - most obviously, their role changes, but their other
- characteristics may also change to do artistic license, different PoV or
- (gasp) character development. The alternative to this would be to add each
- version as a new character and then link them together, but the end-result
- would look rather silly for longer series IMHO.
-
- Q: How would adding a character work? How would adding new instances work?
- A: When adding a character you'd create the first instance together with the
- character. When you decide to add another instance, you start with the
- currently selected instance as base - so you'd just have to make the
- necessary adjustments.
-
- Q: How would characters be displayed?
- A: VN Characterlist (part of/accessible from the VN page): All characters of
- the VN together, ordered Protagonist > Heroine > Sidekick > Antagonist, with
- the applicable instance only.
- Characterpage: Just a single character (think release page), toggle/dropdown
- menu/whatever to switch between instances. Hide traits above a set
- spoilerlevel (as we're doing with tags already).
-
- Q: How would searching work?
- A: Enter a combination of traits you're looking for (with exclusions - find
- all swordwielding heroines that DO NOT have the "has rapescene" trait), get a
- list of games that have the characters (instances) in question. If the trait
- in question is a spoiler, the charactername shouldn't be displayed, else it
- should be presented together with the VN.
-
- Q: What about Seiyuu?
- A: Just link to the corresponding entry in the StaffDB... <_<
-
-
- Release-sensitive Instances:
- Normally, the list contains all releases of the VN in question. (If a new
- release is added it will be added to that list per default (if there are
- multiple instances for that VN, it should be selectable from a dropdown
- menu). Adding new instances for releases would work identical to adding a
- new instance for another VN (with the difference that when you add an
- instance to a release, all other instance remove said VN from their list).
- If a character has multiple instances for a single VN, the applicable
- releases would be displayed in the character list and the user would be
- able to switch between the instances (similar to the character page).
- The advantage of this is that it covers everything. The drawback is that it
- is more work intensive and complex.
- NOTE: Add only new instances for full versions, NOT for trials
-
- List of Relations:
- 5 possibilities:
- Instance <-> Instance, display on a simple map
- if they're multiple instances, they're all displayed together with (<->
- same character) relations between them
- Instance <-> Instance, display on dynamic map
- display each character once, but allow to switch between the various
- instances while viewing the map, with the relations being redrawn based on
- the relations the new instance (and instances being replaced/added based
- where necessary)
- Instance <-> Instance. display release-centric
- first, display the relations of the instances that belong to the release
- and connect them. Ignore any that don't belong to the release. Then draw
- relations to instances which aren't part of the current VN. Allow to switch
- between the various releases with different relation map easily)
- Instance <-> Instance, display instances as a single character,
- colourcode relations depending on what instances it applies to (with legend
- for which colour belongs to what release/instances)
- Character <-> Character, what it says
- simplistic, not suitable for longer series and more complex universes
-
- Examples:
- c1
- NAME: SAKURAI KEI
- ORIGINAL: 櫻井螢
- ALIAS:
- PORTRAY: (prolly taken from the getchu page)
- INSTANCES: i1, i2
-
- i1
- VN: v548
- RELEASES: r1132, r1133
- DESCRIPTION: 5th Seat, Leonhard August.
- TRAITS: Antagonist Heroine, Female, Long Hair, Black Hair, Coodere, Lacks
- Ending, Has Optional Sex eroscene, Swordwielding, Immortal (Spoiler 1),
- Maou's Harem
- COMMENTS: Maou(One of my favourite heroines, sadly she's lacking a route.)
-
- i2
- VN: v548
- RELEASES: r3228
- DESCRIPTION: 5th Seat, Leonhard August.
- TRAITS: Antagonist Heroine, Female, Long Hair, Black Hair, Coodere, Has
- Ending, Has Sex Scenes, Swordwielding, Immortal (Spoiler 1), Maou's Harem
- COMMENTS: Maou(Perfect), RandomPerson(Interesting character)
-
- really short traittree:
- Traits(Role(Protagonist, Heroine (Antagonist Heroine - also child of
- Antagonist)), Sidekick, Antagonist), Gender (Male, Female, Both, Other),
- Appearance(Hair(Long Hair, Black Hair)), Personality(Deretypes(classic
- Tsundere, Tsundere, Deredere, Coodere)), Significance(Routes(Has End(Has
- True End), Lacks End), Has Sex Scenes(Has Insignificant/Optional
- eroscene, has rapescene (has unavoidable rapescene))), Relation(Sister,
- Senpai, Osananajimi), Vocation(Fighting (Swordwielding, Knight), Hacker),
- Other(Idiot Friend, Immortal, Maou's Harem)
-
- Ok, just kidding about the Harem thing <_<
-
diff --git a/data/notes/mylist-revamp b/data/notes/mylist-revamp
deleted file mode 100644
index 4335b7fd..00000000
--- a/data/notes/mylist-revamp
+++ /dev/null
@@ -1,86 +0,0 @@
-RFC-01: Mylist revamp
-
-Last modified: 2010-12-19
-Status: Implemented
-
-
-CREATE TABLE vnlists (
- uid integer NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- vid integer NOT NULL REFERENCES vn (id),
- status smallint NOT NULL DEFAULT 0,
- added TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- likely not used, but whatever
- PRIMARY KEY(uid, vid)
-);
-
--- after converting:
-ALTER TABLE rlists DROP COLUMN vstat;
-ALTER TABLE rlists ALTER COLUMN rstat RENAME TO status;
-
-vnlist.status: Unknown / Playing / Finished / Stalled / Dropped
-
-
-Converting from old rlists:
- vstat = X for all releases -> status = X
- vstat = (X\{unknown}) for all releases with vstat != unknown -> status = X
- vstat = (stalled, dropped) for all releases with vstat != unknown -> status = stalled
- vstat = (finished, stalled, dropped) for all releases with vstat != unknown -> status = finished
- vstat = (playing, ..) for all releases with vstat != unknown -> status = playing
-Rephrased in easier terms:
- status = first_present([playing, finished, stalled, dropped, unknown], @vstat)
- Where first_present(<order>, <list>) returns the first item in <list> when using the order of <order>
- Since the statusses are coincidentally defined as an integer with a mapping
- in that order (with playing being the lowest number), we can simply say:
- status = min(@vstat without unknown) || unknown
-
-
-Constraint:
- For each row in rlists, there should be at least one corresponding row in
- vnlists for at least one of the VNs linked to that release.
- This will significantly simplify the the "show my VN list" query, and gives
- the user the option to not add *all* VNs linked to the release to his list.
-
- Example: the "Infinity Plus" release can be in your rlist, even when only
- E17 is in your vnlist. As long as at least one of the infinity series is
- in your vnlist.
-
- How to enforce:
- - When a row is deleted from vnlists, also remove all rows from rlists that
- would otherwise not have a corresponding row in vnlists
- - When a row is inserted to rlists and there is not yet a corresponding row
- in vnlists, add a row in vnlists (with status=unknown) for each vn linked
- to the release.
- Alternatively it's possible to add only one of the linked vns, but since
- we can't decide for the user which one he wants, let's just add all of
- them.
- - Deleting a row from rlists or inserting a row to vnlists will never cause
- the constraint to be violated.
- - Strictly, updating rlists.rid or vnlists.vid should also trigger a check,
- but since those columns are never updated we can ignore that.
-
- How to implement:
- - Unfortunately it's not possible to use a real SQL CONSTRAINT for this,
- due to the complexity of the references.
- - SQL triggers would work. This is the easiest way to ensure the constraint
- is enforced even when rows are inserted/deleted in rlists or vnlists from
- within other triggers or constraints. (e.g. auto-delete vnlist entry when
- VN is hidden or something - bad idea but whatever :P)
- The triggers should probably be defined as CONSTRAINT TRIGGERs and be
- DEFFERABLE. CONSTRAINT TRIGGERs because otherwise the "ON DELETE CASCADE"
- on users.id might do too much work when a user is deleted. DEFFERABLE
- because otherwise one would have to be careful when adding rlists rows
- before vnlists rows. (Doesn't happen with the current code, but oh well)
-
-
-"My VN List" table layout:
- H: | | | Title <sort> | Status | Releases* | Vote <sort> |
- V: | check | expand | title | status | releases | vote |
- R: | | check | date | icons | title | <pad> status | | |
- F: | <all> | <all> | <select> <select> <send> | <expl> |
- C: | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
-
-
-Misc. things to keep in mind:
-- Update 'listdel' notification to also check the vnlists table
-- Allow users to remove rows from vnlists and rlists even when the
- corresponding vn/release entry is hidden.
-
diff --git a/data/notes/notifications b/data/notes/notifications
deleted file mode 100644
index 4743f6ee..00000000
--- a/data/notes/notifications
+++ /dev/null
@@ -1,20 +0,0 @@
-Notifications
-
-Last modified: 2010-02-06
-Status: Implemented
-
-
-+ = implemented
-- = planned
-
-Always:
-+ pm notify for a new post or thread in my discussion board
-+ dbdel notify for the deletion of an entry I made or edited
-+ listdel notify for the deletion of an entry I voted on / have in my release list / wishlist
-
-Option "Notify me about database entries I contributed to" (enabled by default)
-+ dbedit notify for each edit of an entry I made or edited
-
-Option "Notify me for site announcements" (disabled by default - too many notifications otherwise)
-+ announce notify for each new thread in the 'an' board
-
diff --git a/data/notes/permanent-filters b/data/notes/permanent-filters
deleted file mode 100644
index 768a8a71..00000000
--- a/data/notes/permanent-filters
+++ /dev/null
@@ -1,88 +0,0 @@
-Permanent VN/release filters
-
-Last modified: 2011-01-01
-Status: Implemented
-
-
-Storage:
-- format: the usual filter string (as used in fil=X query string)
-- location: users_prefs, key = filter_(vn|release)
-
-
-How to fetch entries within Perl with the filters applied:
- Special wrapper function for db(VN|Release)Get(), which does the following:
-
- # compatibility checking/converting
- function check_compat(fil, save):
- if filters_contain_old_stuff then
- fil = convert_old_stuff(filters)
- if save then
- save_preference(filter_vn, serialize_filter(filters))
- end if
- end if
- return fil
-
- function filVNGet(fil_overwrite, opts):
- if (not logged_in or not filter_preference) and not fil_overwrite then
- return dbFunc(opts)
- end if
-
- filters = check_compat(parse_filter(fil_overwrite || filter_preference), fil_overwrite?dontsave:save)
-
- # incorrect filters can trigger an error, catch such an error and remove
- # the preference if that was what caused the error
- if(fil_overwrite) # preferences can't cause the error
- return dbFunc(filters + opts);
- else
- try
- create_sql_savepoint()
- return dbFunc(filters + opts)
- error
- rollback_to_sql_savepoint()
- results = dbFunc(opts)
- # if the previous call also fails, the next command won't be executed
- delete_filters_preference()
- return results
-
- A filReleaseGet() would do something similar. In fact, it might make sense
- to combine it into a single function filFetchDB(type, fil, opts)
- Filters can be disabled by adding a '<filter_name> => undef' to opts.
-
-
-All cases where the current code calls dbVNGet() should be checked and
-considered for replacing with the above fetching function. Some cases are:
-VN:
-- Random visual novels on homepage
-- "Random visual novel" menu link
-- VN browser
- In this case the query string should overwrite preferences? Since
- the preference is loaded in the filter selector as a default anyway
-- Tag page VN listing
- The tag_inc and tag_exc filters should be disabled here?
-- Preferably also the random screenshots on the homepage. But this requires
- some more code changes.
-Release:
-- "Upcoming releases" and "Just released" on homepage
-- Release browser
- Same note as VN browser above
-
-
-Some cases that shouldn't be affected by the filter preferences:
-- Edit histories
-- User lists (votes, vnlist, wishlist)
-- Tag link browser
-- VN page release listing
-- VN page relations listing
-- Producer page VN/release listing
-- Release page VN listing
-- Database Statistics
- (Even if they should, I wouldn't do it. Too heavy on server resources)
-
-
-User interface considerations:
-- An extra button "Save as default" will be added to the filter selector if
- the visitor is logged in
-- Ideally, there should be some indication that filters were applied to all
- places where they are used, with the possibility of changing them.
- (this is going to to be a pain to implement :-/)
-
diff --git a/data/notes/preferences b/data/notes/preferences
deleted file mode 100644
index 0629e51f..00000000
--- a/data/notes/preferences
+++ /dev/null
@@ -1,117 +0,0 @@
-User preference storage
-
-Last modified: 2011-02-06
-Status: Long-term plans / partially implemented
-
-
-up = SQL: users_prefs
-Preference old storage method Current storage method Can be changed at
-- Interface language Browser or cookie: l10n Browser/up/cookie Perl: Link in main menu (explicit)
-- Main skin SQL: users.skin up: skin Perl: Users' profile (explicit)
-- Additional CSS SQL: users.customcss up: customcss Perl: Users' profile (explicit)
-- NSFW toggle SQL: users.show_nsfw up: show_nsfw Perl: Users' profile (explicit)
-- List is private SQL: users.show_list up: hide_list Perl: Users' profile (explicit)
-- Notify on announce SQL: users.notify_announce up: notify_announce Perl: Users' notifications page (explicit)
-- Notify on DB edit SQL: users.notify_dbedit up: notify_nodbedit Perl: Users' notifications page (explicit)
-- Tag spoil level Cookie: tagspoil Cookie: tagspoil JS: VN pages, Tag pages, VN filter settings (all implicit)
-- Tag VN page cat - Cookie: tagcat JS: VN pages (implicit)
-- Producer page view Cookie: prodrelexpand Cookie: prodrelexpand JS: Producer pages (implicit)
-- VN filters - up: filter_vn JS: VN filter settings (explicit)
-- Release filters - up: filter_release JS: Release filter settings (explicit)
-
-
-What do we want?
-- Ideally, all preferences are saved explicitly. That is, the user can
- indicate whether the change of a preference is temporary or should be saved
- as the new default.
-- Ideally, all preferences are stored on the server. This makes it easy to
- convert the preference data on VNDB updates, without having to provide
- backwards compatibility with old data. It also scales better than cookies.
-- Preferably, you don't have to have an account to set or change preferences.
- In the case of the interface language it's quite important that users don't
- have to be logged in. For other preferences it's not very important, but I
- don't really like the idea of forcing people to create an account.
-- Preferably, the user can change each preference at the place where it makes
- most sense:
- - Default NSFW flag should be set when encountering an NSFW image
- - Skin and custom CSS settings should be somewhere in the global page
- layout (like the language setting currently is)
- - The "my list is private" setting should be set when viewing your
- wish/vote/VN list.
- Although... this one might be okay on the profile page.
- - Most other preferences already are at sensible locations
- In particular, I don't like the idea of grouping all preferences on a
- single "settings" or "profile" page. This is likely to become a mess (see
- AniDB for a nice example), and users might not know something is available
- as a preference (like how most users don't know VNDB has skins).
-- Don't store everything in separate columns of the users table. Most users
- don't actually change their preferences from the defaults, so only saving
- the non-default settings will save a significant amount of space. Bloating
- the users table with information that is only ever accessed by the user
- itself is also a bad idea - this table is used in a lot of joins and can be
- browsed on with the user list.
-
-
-Concrete ideas:
-- (done)
- User preferences can be stored in a separate table:
- -- incomplete list of preference keys
- CREATE prefs_key AS ENUM ('l10n', 'skin', 'customcss', 'show_nsfw',
- 'hide_list', 'notify_nodbedit', 'notify_announce');
- CREATE TABLE users_prefs (
- uid integer NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- key prefs_key NOT NULL,
- value varchar NOT NULL,
- PRIMARY KEY(uid, key)
- );
- This doesn't store the data in a properly normalized fashion, but is likely
- easier to work with anyway.
-- (done)
- Accessing the prefs table from Perl:
- - authCheck() loads all of the users' preferences in a hash
- - authPref($key) returns the value of the preference (from the hash)
- - authPref($key, $val) sets the preference (in hash and DB)
-- (done)
- Keep the interface language setting as-is for anonymous visitors.
- For logged-in users:
- - Store the users' preferred language in the database instead of cookie.
- - Contrary to the cookie: do not automatically remove the db preference
- even if it's the same as what the browser requests. This is to ensure
- that a user gets the same language even when switching PCs.
- - When a user logs in and the l10n cookie is set, copy its value into the
- DB and remove the cookie.
- - Similar with logging out: copy l10n setting to cookie (but keep the DB)
- "What language to use" checking order: database, cookie, browser
-- (done - except some JS'ed preferences)
- All other preferences can be moved to the users_prefs table. It is a lot of
- work to correctly save and handle all preferences for anonymous visitors,
- so let's stick with logged-in users for now to keep things simple.
-- (done - at least the abstraction)
- Some preferences need to be read and modified in Javascript.
- Reading:
- Add global JS variable using inline <script> to the bottom of the page,
- before loading the global JS file, and store the required preferences in
- there for the JS code to read.
- Since some preferences are specific to some pages, add an option to
- htmlFooter() to indicate which preferences need to be added.
- Writing:
- AJAX call to some .xml page. This will kind-of force the input method to
- be explicit, since with AJAX you need some kind UI interaction to
- indicate when the save is successful. Implicit saving is an especially
- bad idea with this approach since that might make a lot of AJAX calls.
-- Make implicit preference saving explicit:
- - On producer pages, add a link 'Save as default' to the left of the
- expand/collapse link when the user is logged in AND the current view is
- different from the default.
- - On VN pages: same for the spoiler level
- - On Tag pages: same for spoiler level
- - On VN filter settings: same for spoiler level
- I'm not sure I like this idea... unless I can figure out a good abstraction
- to nicely add those links with a single line of code.
-- Remove "Don't hide NSFW" checkbox from profile page and add similar "Save
- as default" links to the VN page. Close to the "show/hide NSFW" at the
- screenshots and "Flagged as NSFW" note at the VN image.
-- Add a "settings" icon to the user menu title box thing, and have it show a
- CSS'ed window when clicked with settings for the skin and custom CSS.
- Optionally with Javascripted previewing of the settings.
-
diff --git a/data/notes/sponsored-links b/data/notes/sponsored-links
deleted file mode 100644
index 222553aa..00000000
--- a/data/notes/sponsored-links
+++ /dev/null
@@ -1,106 +0,0 @@
-Advertisements
-
-Last modified: 2011-04-10
-Status: Implemented / Implementation may differ from these notes
-
-
-Idea: (semi-)large "Buy now" / "Download now" button on VN pages, linking
-either to the product on a webshop or displaying a dropdown list with
-available releases with links to webshops.
-
-A link to a webshop only appears if it has at least one release of the VN on
-their site, and the link always points directly to the product page, not to
-the search function or the homepage.
-
-A webshop link is internally linked to a release in the database, so we have
-all kinds of information including whether it's a download or package, and in
-what language it is.
-
-Preferably, the link also indicates the price and whether it is in stock.
-
-
-Possible parties interested in advertising:
-- J-List
- Has an affiliate system that includes direct links
- doesn't store JAN/UPC/catalog numbers
-- Play-asia
- Has an affiliate system that includes direct links
- stores JAN, UPC, and catalog numbers
-- DLSite English
- Has an affiliate system that includes direct links
- Most releases don't even have a JAN code or catalog number
-- MangaGamer
- Rather specific "shop", but could count as one.
- Has no affiliate system, but is planning to add one, as announced in
- http://mangagamer.wordpress.com/2010/12/31/holidays-passing/
- Releases don't have catalog numbers or EAN codes
-- PaletWeb
- Has no affiliate system
- Does have JAN codes for a few titles, but inconsistent
- Rather messy website... finding/updating links will be a chore
-- CDJapan
- Doesn't have that many VNs from what I've browsed, but still several
- Has an affiliate system (seems to include direct links)
- Has catalog numbers for most (all?) releases
-- Hendane!
- Does not seem to have many VNs (3 or 4?)
- Has no affiliate system
- Does not have JAN or catalog numbers
-- Himeyashop / Erogeshop (out of business?)
- Has no affiliate system but has shown interest in link exchanges in the past
- Does not store JAN/UPC/catalog numbers
- "Temporarily" closed, so probably not a good time to ask for ads?
-- Eroge-Europe.com (out of business?)
- Seems to have an affiliate system, haven't really looked at it yet
- Does not store JAN/UPC/catalog numbers
-
-
-So who is going to update all those links?
-Three possibilities:
-
-1. Automatically
- By matching JAN/EAN/UPC or catalog numbers from our database with the
- information on the webshop, and fetching the information necessary for the
- links.
- Since Play-asia is the only one storing that kind of information, this
- will be rather specific. We can't really expect all other parties to
- update their system, and for DLSite and MangaGamer it would involve
- creating (official) catalog numbers for each entry - which would be easy
- for MG, but certainly not for DLSite.
- Even if a shop stores it, we'd need fast and up-to-date access to it. We
- have several thousand JAN codes in the database. If we want to make sure
- our information is accurate and up-to-date we'd have to check for the
- availability of each release each day. Doing this will most likely require
- the other party to update their site with an API providing this
- information. I somehow doubt they would...
-
-2. Let the advertiser add and update the info
- Add an admin interface to the site allowing advertisers to add links to
- their shop to release entries - also allowing them to indicate the price
- and stock availability.
- Since advertisers benefit from these links, we can assume that, if they
- agree to do this, they will keep the info up-to-date.
- However, for some reason I don't think many advertisers would want to
- invest that much time in advertising on a single site.
- Instead of the advertiser itself, it would also be possible to look for a
- dedicated user to do this for them. Though somehow I doubt we'd find
- someone like that, and I don't feel like doing that myself.
-
-3. Let our users add and update the info
- Add webshop links to release entries. Since the price and stock
- availability tend to change over time and our dear users are either pretty
- slow on the uptake or too lazy to update VNDB, we can forget about any
- other information besides the links. :-(
- It might, however, be possible to automatically fetch the price and stock
- information anyway since we have the URLs, but in that case the webshop
- should either allow us to crawl quite a lot or provide an alternative
- method.
- Since the list of webshops we link to is not a static one - shops can be
- added or removed after a while - we can expect these links to be edited
- quite often, which could make a mess with the edit histories.
- Alternatively, we could do it VGMdb-like: allow users to simply manage
- links where the release is sold, regardless of whether they are
- advertising on VNDB or not. This would still make it possible to
- special-case advertisers and give them special treatment or fetch
- additional information.
-
diff --git a/data/notes/tagmod-overrule b/data/notes/tagmod-overrule
deleted file mode 100644
index 6209b79f..00000000
--- a/data/notes/tagmod-overrule
+++ /dev/null
@@ -1,55 +0,0 @@
-Allow moderators to overrule a VN tag score
-
-Last modified: 2011-01-03
-Status: Implemented
-
-
-SQL implementation #1:
- Extra column to tags_vn:
- ALTER TABLE tags_vn ADD COLUMN overrule boolean NOT NULL DEFAULT false;
- There can only be one row in tags_vn with the same (tag, vid) combination
- when one is set with overrule = true; this row then automatically indicates
- the final score and spoiler setting.
- - Pro: This way none of the final score calculating functions will have to be
- modified, and this won't incur an extra performance penalty.
- - Con: the votes of all other users for that tag and VN will have to be
- removed. This makes overruling a VN a non-reversible operation.
- - Determining whether a score was forced by a mod: bool_or(tv.overwrite)
- - Regular voting on an overruled tag is simply not allowed
- - An other mod should be able to remove the overruled vote and replace it
-
-SQL implementation #2:
- Extra column to tags_vn:
- ALTER TABLE tags_vn ADD COLUMN ignore boolean NOT NULL DEFAULT false;
- Any tag vote with the ignore flag set is ignored in the score calculation.
- When a moderator "overrules" a score, all votes with that (tag, vid) will
- have ignore=true, except the mods own vote.
- - Pro: Far more flexible than #1, can be used to ignore individual votes.
- However, using it for anything other than overruling will make it very
- hard or even impossible to reliably implement the overruling feature, so
- we'll have avoid making use of this flexibility.
- - Pro: Votes of other users don't have to be removed
- - Pro: Users can still add votes to the tag (although it will be ignored)
- - Con: Requires special coding to automatically set new votes on ignore
- - Con: Requires modifying score calculation functions, possibly slower
- - Determining whether a score was forced by a mod: bool_or(tv.ignore)
- (Assumes we don't use the added flexibility)
-
-Let's go with #2. Will be slightly more work; but at least it's less prone to
-irriversible moderation mistakes and more "friendly" to taggers.
-
-
-UI changes:
- Add extra 'overrule' checkbox to the 'you' column for moderators.
- - Checking this will take over the mods' tagvote and spoiler level and
- ignore the votes of all others.
- - Unchecking it will de-overrule the score
- - When an overruled vote is removed by the mod (setting '-' as vote), the
- tag is de-overruled again.
-
- Add "overruled" indication to "others" column
- - A red "!" next to the score column would work
- - Simply indicates whether the score has been overruled by a mod
-
- Add "ignored" / "not counted" indication to tag link browser
-
diff --git a/data/style.css b/data/style.css
deleted file mode 100644
index e03113fb..00000000
--- a/data/style.css
+++ /dev/null
@@ -1,1002 +0,0 @@
-* { margin: 0; padding: 0; }
-body, td { font: 12px "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: url($_boxbg$) repeat; }
-
-#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;
- $_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; }
-
-.visuallyhidden {
- position: absolute !important;
- left: 0;
- height: 1px; width: 1px;
- border: 0; padding: 0;
- overflow: hidden;
- clip: rect(1px 1px 1px 1px);
-}
-
-/* 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: 12px; 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-right: 5px }
-#dd_box li a:hover { background: url($_boxbg$) repeat }
-
-/* 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: url($_boxbg$) repeat; }
-#ds_box table { width: 100%; }
-
-
-
-/* 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: 12px "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$ }
-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 }
-.linethrough { text-decoration: line-through }
-b.spoiler, b.spoiler a { color: #000!important; background-color: #000; font-weight: normal; }
-b.spoiler_shown { font-weight: normal }
-b.spoiler_shown a { color: $link$!important }
-
-#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: url($_boxbg$) repeat;
- overflow-x: auto;
-}
-
-
-
-
-/***** general form markup *****/
-
-input.text, input.submit, select, textarea {
- background-color: $secbg$;
- color: $maintext$;
- border: 1px solid $secborder$;
- font: 13px "Tahoma", "Arial", sans-serif;
- margin: 1px;
-}
-form, fieldset { border: 0; display: block; }
-legend { display: none; }
-optgroup option { padding-left: 10px; font-style: normal; }
-input.submit { background: url($_boxbg$) repeat; padding: 1px; }
-input.text, select { width: 200px; }
-fieldset.submit { width: 100%; text-align: center; margin: 5px; }
-fieldset.submit input { width: 150px; }
-fieldset.submit h2 { font-size: 12px!important; }
-fieldset.submit textarea { margin: 0 20px 5px 20px; }
-td.label, td.label label { width: 110px; }
-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; }
-
-
-
-
-/***** menu *****/
-
-
-#menulist a { color: $maintext$; text-decoration: none; }
-#menulist a:hover { border-bottom: 1px dotted $maintext$; }
-#menulist { position: absolute; left: 30px; top: 190px; width: 150px; }
-#menulist div.menubox { margin: 0 0 10px 0; border: 1px solid $border$; background: url($_boxbg$) repeat; }
-#menulist div.menubox div { padding: 2px 7px; }
-#menulist h2 { border-bottom: 1px solid $border$; background: url($_boxbg$) repeat; padding: 1px 3px; }
-#menulist h2, #menulist h2 a { font-size: 12px; color: $maintext$; }
-#menulist h2 #lang_select { float: right; padding-top: 1px; }
-#menulist dt { display: block; float: left; width: 93px; font-style: italic; }
-#menulist dd { width: 40px; float: left; text-align: right; }
-#menulist p { text-align: center; }
-#menulist input.text { width: 100px; margin-left: 15px; }
-#menulist input.submit { width: 90px; margin-left: 20px; }
-#menulist #search input.text { width: 133px; 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: 125px; padding: 4px; background: $warnbg$; border: 1px solid $warnborder$; }
-
-
-
-
-/***** main content *****/
-
-#maincontent {
- position: absolute;
- top: 169px;
- left: 190px;
- 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: url($_boxbg$) repeat;
-}
-.mainbox h1 { color: $boxtitle$; font-size: 21px; 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: 16px; 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 *****/
-
-ul.maintabs { display: inline; margin: 0; }
-ul.maintabs.notfirst { display: block; height: 20px }
-ul.maintabs li { display: inline; list-style-type: none }
-ul.maintabs li a, ul.maintabs li b {
- float: right;
- display: block;
- height: 14px;
- padding: 1px 7px 5px 7px;
- margin: 0 0 0 10px;
-}
-ul.maintabs li a {
- border: 1px solid $border$;
- border-bottom: none;
- background-color: $tabbg$;
- color: $grayedout$;
-}
-ul.maintabs.notfirst li a,
-ul.maintabs.notfirst li b { margin-top: 20px }
-ul.maintabs.bottom li a,
-ul.maintabs.bottom li b { margin-top: 10px; padding: 4px 7px 2px 7px }
-ul.maintabs.bottom li a { border-bottom: 1px solid $border$; border-top: none }
-ul.maintabs li.left a,
-ul.maintabs li.left b { float: left; margin-left: 0; margin-right: 10px }
-ul.maintabs li b { margin-left: -2px; margin-right: -7px }
-ul.maintabs li.left b { margin-left: -7px; margin-right: -2px }
-ul.maintabs li.tabselected a,
-ul.maintabs li a:hover { background-color: $_blendbg$; color: $maintext$; padding-bottom: 6px }
-ul.maintabs.bottom li.tabselected a,
-ul.maintabs.bottom li a:hover { padding-bottom: 2px; padding-top: 5px; margin-top: 9px }
-ul.maintabs.browsetabs li a { margin-left: 5px; color: $maintext$ }
-ul.maintabs.browsetabs li.left a { margin-left: 0; margin-right: 5px }
-
-
-
-
-
-/***** 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 ******/
-
-p.browseopts a {
- padding: 1px 3px;
- color: $maintext$;
- border: 1px solid $border$;
- margin: 0 2px;
- white-space: nowrap;
-}
-p.browseopts { text-align: center; padding: 2px; }
-p.browseopts a.optselected,
-p.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: 12px; 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: $secbg$ url($_boxbg$) repeat;
-}
-#q { width: 600px }
-#bq { width: 300px }
-
-
-
-/* history browser */
-
-div.history table { table-layout: fixed }
-div.history td { white-space: nowrap }
-div.history td.tc1_1 { width: 70px; padding-left: 0; padding-right: 0; text-align: right }
-div.history td.tc1_2 { width: 30px; padding-left: 0 }
-div.history td.tc2 { width: 130px }
-div.history td.tc3 { width: 100px }
-div.history td.tc4 { overflow: hidden }
-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 td { border-bottom: 1px solid $border$; }
-div.thread td.tc1 { width: 150px; 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 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: 50px; }
-div.discussions td.tc3 { width: 100px; }
-div.discussions td.tc4 { width: 210px; }
-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; }
-h1.boxtitle, h1.boxtitle a {
- font-family: "Futura", "Century New Gothic", "Arial", Serif;
- font-weight: bold;
- font-style: italic;
- color: $grayedout$;
- font-size: 17px;
- margin: 20px 0 -20px 0;
-}
-
-
-
-
-/***** 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; }
-.vndesc 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; }
-
-tr#buynow .pricenote { border: 0 }
-
-div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $border$; padding: 1px 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: url($_boxbg$) repeat; font-weight: bold; }
-.releases td.tc1 { padding-left: 30px; width: 80px; }
-.releases td.tc2 { text-align: center; width: 50px; }
-.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; }
-
-#screenshots p.rel {
- background: url($_boxbg$) repeat;
- 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: url($_boxbg$) repeat;
- 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: 14px; 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: 220px }
-.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: url($_boxbg$) repeat; }
-.releases_compare td.multi { vertical-align:middle; }
-.releases_compare .key { background: url($_boxbg$) repeat; }
-
-/****** VN browse ********/
-
-.vnbrowse thead .tc_s { padding-left: 30px }
-.vnbrowse .tc_s { width: 65px }
-.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 }
-.vnbrowse .tc8 { width: 8px }
-
-
-
-/***** Producer page/list *******/
-
-#prodrel { width: 100%; }
-#prodrel tr.vn td { background: url($_boxbg$) repeat; 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; }
-#prodrel td.tc2 { width: 50px; text-align: center; }
-#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; }
-#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 { padding-top: 5px; margin-top: 5px; border-top: 1px solid $border$ }
-
-.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: 400px; 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: url($_boxbg$) repeat;
- 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,
-#jt_box_staffe_geninfo table#names td { padding: 1px 2px; vertical-align: middle; }
-#jt_box_staffe_geninfo table#names tr#alias_new td { padding-top: 8px }
-#jt_box_vn_cast td.tc_role,
-#jt_box_vn_cast td.tc_role select,
-#jt_box_vn_staff td.tc_role,
-#jt_box_vn_staff td.tc_role select { width: 120px }
-#jt_box_vn_cast td.tc_staff,
-#jt_box_vn_staff td.tc_staff,
-#jt_box_staffe_geninfo td.tc_name,
-#jt_box_staffe_geninfo td.tc_original { width: 200px }
-#jt_box_vn_cast td.tc_staff input,
-#jt_box_vn_staff td.tc_staff input,
-#jt_box_staffe_geninfo td.tc_name input,
-#jt_box_staffe_geninfo td.tc_original input { width: 200px }
-#jt_box_vn_cast td.tc_note,
-#jt_box_vn_cast td.tc_note input,
-#jt_box_vn_staff td.tc_note,
-#jt_box_vn_staff td.tc_note input { width: 250px }
-#jt_box_vn_cast td.tc_add,
-#jt_box_vn_staff td.tc_add,
-#jt_box_staffe_geninfo td.tc_add { width: 40px; text-align: left }
-
-/***** 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: 150px; padding: 2px; margin: 0 0 10px 5px; background: url($_boxbg$) repeat; 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 }
-
-
-
-/***** 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: 90px }
-.browse.notifies td.tc4 { width: 60px }
-.browse.notifies tbody td.tc5 { color: $grayedout$; cursor: pointer }
-.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: 400px; margin: 0 auto; }
-.userpage .key { width: 70px; }
-
-
-/***** 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: 25px; padding-left: 0 }
-div.uposts td.tc3 { width: 65px; }
-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: 200px; 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: 80px }
-
-
-/***** Tag links *****/
-
-.browse.taglinks .tc1 { width: 70px }
-.browse.taglinks .tc3 { width: 90px }
-.browse.taglinks .tc3 { width: 90px }
-.browse.taglinks .ignored .taglvl.taglvlsel { background-color: #222 }
-.browse.taglinks .ignored .taglvl.taglvl0 { color: $grayedout$!important }
-.browse.taglinks .setfil { font-size: 10px; padding-right: 3px }
-
-
-/***** 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: 12px; 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: url($_boxbg$) repeat;
- 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: 12px }
-.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?$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/Discussions/Edit.elm b/elm/Discussions/Edit.elm
new file mode 100644
index 00000000..f4899e95
--- /dev/null
+++ b/elm/Discussions/Edit.elm
@@ -0,0 +1,251 @@
+module Discussions.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.Autocomplete as A
+import Gen.Api as GApi
+import Gen.Types exposing (boardTypes)
+import Gen.DiscussionsEdit as GDE
+
+
+main : Program GDE.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 : 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
+ , 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
+ }
+
+
+searchConfig : A.Config Msg GApi.ApiBoardResult
+searchConfig = { wrap = BoardSearch, id = "boardadd", source = A.boardSource }
+
+
+encode : Model -> GDE.Send
+encode m =
+ { 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
+ }
+
+
+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, 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)
+
+
+type Msg
+ = Locked Bool
+ | 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 (Maybe Int)
+ | PollOpt Int String
+ | PollRem Int
+ | PollAdd
+ | Submit
+ | Submitted GApi.Response
+
+
+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)
+ 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 = 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)
+
+ Submit -> ({ model | state = Api.Loading }, GDE.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 =
+ let
+ board n bd =
+ li [] <|
+ [ 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.iid, bd.title) of
+ (_, Just iid, Just title) ->
+ [ small [] [ text " > " ]
+ , a [ href <| "/" ++ iid ] [ text title ]
+ ]
+ ("u", Just iid, _) -> [ small [] [ text " > " ], text <| iid ++ " (deleted)" ]
+ _ -> []
+
+ boards () =
+ [ 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)
+ , 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 [] [ text "Please add at least one board." ] ]
+ else if dupBoards model
+ then [ b [] [ text "List contains duplicates." ] ]
+ else []
+
+ pollOpt n p =
+ li []
+ [ inputText "" p (PollOpt n) (style "width" "400px" :: placeholder ("Option #" ++ String.fromInt (n+1)) :: GDE.valPollOptions)
+ , if numPollOptions model > 2
+ then a [ href "#", onClickD (PollRem n), tabindex 10 ] [ text "remove" ]
+ else text ""
+ ]
+
+ 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 [] [ 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"
+ [ ul [ style "list-style-type" "none", style "margin" "0px" ] <| List.indexedMap pollOpt p.options
+ , if numPollOptions model < 20
+ then a [ href "#", onClickD PollAdd, tabindex 10 ] [ text "Add option" ]
+ else text ""
+ ]
+ , formField ""
+ [ 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."
+ ]
+ ]
+ (_, _) -> []
+
+
+ in
+ form_ "" Submit (model.state == Api.Loading)
+ [ article []
+ [ h1 [] [ text <| if model.tid == Nothing then "Create new thread" else "Edit thread" ]
+ , table [ class "formtable" ] <|
+ [ 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 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 ""
+ , 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 [] [ text " (English please!) " ]
+ , a [ href "/d9#4" ] [ text "Formatting" ]
+ ]
+ ]
+ ]
+ ++ 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!" ]
+ ])
+ ]
+ , article [ class "submit" ] [ submitButton "Submit" model.state (isValid model) ]
+ ]
diff --git a/elm/Discussions/Poll.elm b/elm/Discussions/Poll.elm
new file mode 100644
index 00000000..6764bfbd
--- /dev/null
+++ b/elm/Discussions/Poll.elm
@@ -0,0 +1,139 @@
+module Discussions.Poll exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Lib.Html exposing (..)
+import Lib.TextPreview as TP
+import Lib.Api as Api
+import Gen.Api as GApi
+import Gen.DiscussionsPoll as GDP
+
+
+main : Program GDP.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
+ , data : GDP.Recv
+ , voted : Bool
+ }
+
+
+init : GDP.Recv -> Model
+init d =
+ { state = Api.Normal
+ -- Remove own vote from the count, so we can dynamically adjust the counter
+ , data = { d | options = List.map (\o -> { o | votes = if o.my then o.votes - 1 else o.votes }) d.options }
+ , voted = List.any (\o -> o.my) d.options
+ }
+
+type Msg
+ = Preview
+ | Vote Int Bool
+ | Submit
+ | Submitted GApi.Response
+
+
+toomany : Model -> Bool
+toomany model = List.length (List.filter (\o -> o.my) model.data.options) > model.data.max_options
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Preview ->
+ let d = model.data
+ nd = { d | preview = True }
+ in ({ model | data = nd }, Cmd.none)
+
+ Vote n b ->
+ let d = model.data
+ nd = { d | options = List.map (\o -> { o | my = if n == o.id then b else o.my && d.max_options > 1 }) d.options }
+ in ({ model | data = nd }, Cmd.none)
+
+ Submit ->
+ if toomany model then (model, Cmd.none)
+ else
+ ( { model | state = Api.Loading }
+ , GDP.send { tid = model.data.tid, options = List.filterMap (\o -> if o.my then Just o.id else Nothing) model.data.options } Submitted
+ )
+
+ Submitted (GApi.Success) ->
+ let d = model.data
+ v = List.any (\o -> o.my) model.data.options
+ nd = { d | num_votes = model.data.num_votes +
+ case (model.voted, v) of
+ (True, False) -> -1
+ (False, True) -> 1
+ _ -> 0 }
+ in ({ model | state = Api.Normal, voted = v, data = nd }, Cmd.none)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ let
+ cvotes = model.data.num_votes + (if not model.voted && List.any (\o -> o.my) model.data.options then 1 else 0)
+ nvotes o = if o.my then o.votes + 1 else o.votes
+ max = toFloat <| Maybe.withDefault 1 <| List.maximum <| List.map nvotes model.data.options
+
+ opt o =
+ tr [ classList [("odd", o.my)] ]
+ [ td [ class "tc1" ]
+ [ label []
+ [ if not model.data.can_vote
+ then text ""
+ else if model.data.max_options == 1
+ then inputRadio "vote" o.my (Vote o.id)
+ else inputCheck "" o.my (Vote o.id)
+ , span [ class "option", classList [("own", o.my)] ] [ text o.option ]
+ ]
+ ]
+ , if model.data.preview || model.voted
+ then td [ class "tc2" ]
+ [ div [ class "graph", style "width" (String.fromFloat (toFloat (nvotes o) / max * 200) ++ "px") ] [ text " " ]
+ , div [ class "number" ] [ text <| String.fromInt (nvotes o) ]
+ ]
+ else td [ class "tc2", colspan 2 ] []
+ , if model.data.preview || model.voted
+ then td [ class "tc3" ]
+ [ let pc = toFloat (nvotes o) / toFloat cvotes * 100
+ in text <| String.fromInt (truncate pc) ++ "%" ]
+ else text ""
+ ]
+ in
+ 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
+ then thead [] [ tr [] [ td [ colspan 3 ] [ i [] [ text <| "You may choose up to " ++ String.fromInt model.data.max_options ++ " options" ] ] ] ]
+ else text ""
+ , tfoot [] [ tr []
+ [ td [ class "tc1" ]
+ [ if model.data.can_vote
+ then submitButton "Vote" model.state True
+ else b [] [ text "You must be logged in to be able to vote." ]
+ , if toomany model
+ then b [] [ text "Too many options selected." ]
+ else text ""
+ ]
+ , td [ class "tc2" ]
+ [ if model.data.num_votes == 0
+ then i [] [ text "Nobody voted yet" ]
+ else if model.data.preview || model.voted
+ then text <| (String.fromInt model.data.num_votes) ++ (if model.data.num_votes == 1 then " vote total" else " votes total")
+ else a [ href "#", onClickD Preview ] [ text "View results" ]
+ ]
+ ] ]
+ , tbody [] <| List.map opt model.data.options
+ ]
+ ]
+ ]
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/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
new file mode 100644
index 00000000..5b1bf583
--- /dev/null
+++ b/elm/Lib/Api.elm
@@ -0,0 +1,96 @@
+module Lib.Api exposing (..)
+
+import Json.Encode as JE
+import File exposing (File)
+import Http
+
+import Gen.Api exposing (..)
+
+
+-- Handy state enum for forms
+type State
+ = Normal
+ | Loading
+ | Error Response
+
+
+-- User-friendly error message if the response isn't what the code expected.
+-- (Technically a good chunk of this function could also be automatically
+-- generated by Elm.pm, but that wouldn't really have all that much value).
+showResponse : Response -> String
+showResponse res =
+ let unexp = "Unexpected response, please report a bug."
+ 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 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
+ 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
+ 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
+expectResponse msg =
+ let
+ res r = msg <| case r of
+ Err e -> HTTPError e
+ Ok v -> v
+ in Http.expectJson res decode
+
+
+-- Send a POST request to a Perl `elm_api` endpoint
+-- Should not be used directly, use the `send` function in the appropriate Gen.FormName module instead.
+post : String -> JE.Value -> (Response -> msg) -> Cmd msg
+post name body msg =
+ Http.post
+ { url = "/elm/" ++ name ++ ".json"
+ , 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
new file mode 100644
index 00000000..4c465d7c
--- /dev/null
+++ b/elm/Lib/Autocomplete.elm
@@ -0,0 +1,407 @@
+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
+ )
+
+import Html exposing (..)
+import Html.Events exposing (..)
+import Html.Attributes exposing (..)
+import Html.Keyed as Keyed
+import Json.Encode as JE
+import Json.Decode as JD
+import Task
+import Process
+import Browser.Dom as Dom
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+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 =
+ -- How to wrap a Msg from this model into a Msg of the using model
+ { wrap : Msg a -> m
+ -- A unique 'id' of the input box (necessary for the blur/focus events)
+ , id : String
+ -- The source defines where to get autocomplete results from and how to display them
+ , source : SourceConfig 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 =
+ -- 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).
+ -- This is used to remember selection across data refreshes and to optimize
+ -- HTML generation.
+ , key : a -> String
+ }
+
+
+boardSource : SourceConfig m GApi.ApiBoardResult
+boardSource =
+ { 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 -> [ small [] [ text " > " ], text title ]
+ _ -> []
+ )
+ , 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
+ }
+
+
+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 : String -> Model a
+init s =
+ { visible = False
+ , value = s
+ , results = []
+ , all = Nothing
+ , sel = ""
+ , default = s
+ , loading = False
+ , wait = 0
+ }
+
+
+clear : Model a -> String -> Model a
+clear m v = { m
+ | value = v
+ , results = []
+ , sel = ""
+ , default = v
+ , loading = False
+ }
+
+
+type Msg a
+ = Noop
+ | Focus
+ | Blur
+ | Input String
+ | Search Int
+ | Key String
+ | Sel String
+ | Enter a
+ | Results String GApi.Response
+
+
+select : Config m a -> Int -> Model a -> Model a
+select cfg offset model =
+ let
+ get n = List.drop n model.results |> List.head
+ count = List.length model.results
+ find (n,i) = if cfg.source.key i == model.sel then Just n else Nothing
+ curidx = List.indexedMap (\a b -> (a,b)) model.results |> List.filterMap find |> List.head
+ nextidx = (Maybe.withDefault -1 curidx) + offset
+ nextsel = if nextidx < 0 then 0 else if nextidx >= count then count-1 else nextidx
+ in
+ { 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)
+ 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 cfg, Just r)
+
+ 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)
+ Key "ArrowUp" -> mod <| select cfg -1 model
+ Key "ArrowDown" -> mod <| select cfg 1 model
+ Key _ -> mod model
+
+ Input s ->
+ 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 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 <|
+ 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
+view cfg model attrs =
+ let
+ input =
+ inputText cfg.id model.value (cfg.wrap << Input) <|
+ [ autocomplete False
+ , onFocus <| cfg.wrap Focus
+ , onBlur <| cfg.wrap Blur
+ , style "width" "270px"
+ , custom "keydown" <| JD.map (\c ->
+ if c == "Enter" || c == "ArrowUp" || c == "ArrowDown"
+ then { preventDefault = True, stopPropagation = True, message = cfg.wrap (Key c) }
+ else { preventDefault = False, stopPropagation = False, message = cfg.wrap (Key c) }
+ ) <| JD.field "key" JD.string
+ ] ++ attrs
+
+ visible = model.visible && model.value /= model.default && not (model.loading && List.isEmpty model.results)
+
+ msg = [("",
+ if List.isEmpty model.results
+ then li [ class "msg" ] [ text "No results" ]
+ else text ""
+ )]
+
+ item i =
+ ( cfg.source.key i
+ , li []
+ [ a
+ [ href "#"
+ , classList [("active", cfg.source.key i == model.sel)]
+ , onMouseOver <| cfg.wrap <| Sel <| cfg.source.key i
+ , onMouseDown <| cfg.wrap <| Enter i
+ ] <| cfg.source.view i
+ ]
+ )
+
+ in div [ class "elm_dd", class "search", style "width" "300px" ]
+ [ 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
new file mode 100644
index 00000000..050dcfac
--- /dev/null
+++ b/elm/Lib/DropDown.elm
@@ -0,0 +1,68 @@
+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 (..)
+
+
+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 or any of its parents has the given id
+onClickOutsideParse : String -> JD.Decoder Bool
+onClickOutsideParse id =
+ JD.oneOf
+ [ JD.field "id" JD.string |> JD.andThen (\s -> if id == s then JD.succeed True else JD.fail "")
+ , JD.field "parentNode" <| JD.lazy <| \_ -> onClickOutsideParse id
+ , JD.succeed False
+ ]
+
+-- onClick subscription that only fires when the click was outside of the element with the given id
+onClickOutside : String -> msg -> Sub msg
+onClickOutside id msg =
+ E.onClick (JD.field "target" (onClickOutsideParse id) |> JD.andThen (\b -> if b then JD.fail "" else JD.succeed msg))
+
+
+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 && not conf.hover then onClickOutside conf.id (conf.toggle False) else Sub.none
+
+
+toggle : Config msg -> Bool -> Config msg
+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
+ ] ++ 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 [] [ span [ class "arrow" ] [ text "▾" ] ] ]
+ Api.Loading -> [ lbl, span [] [ span [ class "spinner" ] [] ] ]
+ Api.Error e -> [ b [] [ text "error" ], span [] [ span [ class "arrow" ] [ text "▾" ] ] ]
+ , div [ classList [("hidden", not conf.opened)] ]
+ [ if conf.opened then div [] (cont ()) else text "" ]
+ ]
diff --git a/elm/Lib/Editsum.elm b/elm/Lib/Editsum.elm
new file mode 100644
index 00000000..7320d66a
--- /dev/null
+++ b/elm/Lib/Editsum.elm
@@ -0,0 +1,65 @@
+-- This module provides an the 'Edit summary' box, including the entry state
+-- option for moderators.
+
+module Lib.Editsum exposing (Model, Msg, new, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Lib.Html exposing (..)
+import Lib.TextPreview as TP
+
+
+type alias Model =
+ { authmod : Bool
+ , hasawait : Bool
+ , locked : Bool
+ , hidden : Bool
+ , editsum : TP.Model
+ }
+
+
+type Msg
+ = State Bool Bool Bool
+ | Editsum TP.Msg
+
+
+new : Model
+new =
+ { authmod = False
+ , hasawait = False
+ , locked = False
+ , hidden = False
+ , editsum = TP.bbcode ""
+ }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ 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)
+
+
+view : Model -> Html Msg
+view model =
+ let
+ lockhid =
+ [ 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]
+ [ 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
new file mode 100644
index 00000000..af8c963a
--- /dev/null
+++ b/elm/Lib/Ffi.elm
@@ -0,0 +1,35 @@
+-- Elm 0.19: "We've removed all Native modules and plugged all XSS vectors,
+-- it's now impossible to talk with Javascript other than with ports!"
+-- Me: "Oh yeah? I'll just run sed over the generated Javascript!"
+
+-- 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 elm-support.js.
+--
+-- Use sparingly, all of this will likely break in future Elm versions.
+module Lib.Ffi exposing (..)
+
+import Html
+import Html.Attributes
+import Browser.Dom
+import Task
+
+-- Set the innerHTML attribute of a node
+innerHtml : String -> Html.Attribute msg
+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/Html.elm b/elm/Lib/Html.elm
new file mode 100644
index 00000000..7ec8dacc
--- /dev/null
+++ b/elm/Lib/Html.elm
@@ -0,0 +1,221 @@
+module Lib.Html exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+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
+
+
+-- onClick with stopPropagation & preventDefault
+onClickN : m -> Attribute m
+onClickN action = custom "click" (JD.succeed { message = action, stopPropagation = True, preventDefault = True})
+
+-- onClick with preventDefault
+onClickD : m -> Attribute m
+onClickD action = custom "click" (JD.succeed { message = action, stopPropagation = False, preventDefault = True})
+
+-- onInput that also tells us whether the input is valid
+onInputValidation : (String -> Bool -> msg) -> Attribute msg
+onInputValidation msg = custom "input" <|
+ JD.map2 (\value valid -> { preventDefault = False, stopPropagation = True, message = msg value valid })
+ 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
+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_ 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 ]
+
+
+inputButton : String -> m -> List (Attribute m) -> Html m
+inputButton val onch attrs =
+ input ([ type_ "button", class "submit", tabindex 10, value val, onClick onch] ++ attrs) []
+
+
+-- Submit button with loading indicator and error message display
+submitButton : String -> Api.State -> Bool -> Html m
+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 [] [ text <| Api.showResponse r ] ]
+ _ -> if valid
+ then text ""
+ else p [] [ b [] [ text "The form contains errors, please fix these before submitting. " ] ]
+ , if state == Api.Loading
+ then div [ class "spinner" ] []
+ else text ""
+ ]
+
+
+inputSelect : String -> a -> (a -> m) -> List (Attribute m) -> List (a, String) -> Html m
+inputSelect nam sel onch attrs lst =
+ let
+ opt n (id, name) = option [ value (String.fromInt n), selected (id == sel) ] [ text name ]
+ call first n =
+ case List.drop (Maybe.withDefault 0 <| String.toInt n) lst |> List.head of
+ Just (id, name) -> onch id
+ Nothing -> onch first
+ ev =
+ case List.head lst of
+ Just first -> [ onInput <| call <| Tuple.first first ]
+ Nothing -> []
+ in select (
+ [ tabindex 10 ]
+ ++ ev
+ ++ attrs
+ ++ (if nam == "" then [] else [ id nam, name nam ])
+ ) <| List.indexedMap opt lst
+
+
+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 <| Maybe.withDefault "" <| Maybe.map String.fromInt val
+ , onInput (\s -> onch <| String.toInt s)
+ ]
+ ++ attrs
+ ++ (if nam == "" then [] else [ id nam, name nam ])
+ ) []
+
+
+inputText : String -> String -> (String -> m) -> List (Attribute m) -> Html m
+inputText nam val onch attrs = input (
+ [ type_ "text"
+ , class "text"
+ , tabindex 10
+ , value val
+ , onInput onch
+ ]
+ ++ attrs
+ ++ (if nam == "" then [] else [ id nam, name nam ])
+ ) []
+
+
+inputPassword : String -> String -> (String -> m) -> List (Attribute m) -> Html m
+inputPassword nam val onch attrs = input (
+ [ type_ "password"
+ , class "text"
+ , tabindex 10
+ , value val
+ , onInput onch
+ ]
+ ++ attrs
+ ++ (if nam == "" then [] else [ id nam, name nam ])
+ ) []
+
+
+inputTextArea : String -> String -> (String -> m) -> List (Attribute m) -> Html m
+inputTextArea nam val onch attrs = textarea (
+ [ tabindex 10
+ , onInput onch
+ , rows 4
+ , cols 50
+ , value val
+ ]
+ ++ attrs
+ ++ (if nam == "" then [] else [ id nam, name nam ])
+ ) []
+
+
+inputCheck : String -> Bool -> (Bool -> m) -> Html m
+inputCheck nam val onch = input (
+ [ type_ "checkbox"
+ , tabindex 10
+ , onCheck onch
+ , checked val
+ ]
+ ++ (if nam == "" then [] else [ id nam, name nam ])
+ ) []
+
+
+inputRadio : String -> Bool -> (Bool -> m) -> Html m
+inputRadio nam val onch = input (
+ [ type_ "radio"
+ , tabindex 10
+ , onCheck onch
+ , checked val
+ ]
+ ++ (if nam == "" then [] else [ name nam ])
+ ) []
+
+
+-- Same as an inputText, but formats/parses an integer as Q###
+inputWikidata : String -> 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}$" :: attr)
+
+
+-- Similar to inputCheck and inputRadio with a label, except this is just a link.
+linkRadio : Bool -> (Bool -> m) -> List (Html m) -> Html m
+linkRadio val onch content =
+ a [ href "#", onClickD (onch (not val)), class "linkradio", classList [("checked", val)] ] content
+
+
+-- Generate a form field (table row) with a label. The `label` string can be:
+--
+-- "none" -> To generate a full-width field (colspan=2)
+-- "" -> Empty label
+-- "Some string" -> Text label
+-- "Some string#eng" -> Text label with (English please!) message
+-- "input::String" -> Label that refers to the named input (also supports #eng)
+--
+-- (Yeah, stringly typed arguments; I wish Elm had typeclasses)
+formField : String -> List (Html m) -> Html m
+formField lbl cont =
+ tr [ class "newfield" ]
+ [ if lbl == "none"
+ then text ""
+ 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 [] [ text "English please!" ] ] else []
+ in
+ td [ class "label" ] <|
+ case String.split "::" nlbl of
+ [name, txt] -> [ label [ for name ] (genlbl txt) ]
+ txt -> genlbl (String.concat txt)
+ , td (class "field" :: if lbl == "none" then [ colspan 2 ] else []) cont
+ ]
+
+
+
+langIcon : String -> Html m
+langIcon l = abbr [ class ("icon-lang-"++l), title (Maybe.withDefault "" <| lookup l T.languages) ] [ text " " ]
+
+platformIcon : String -> Html m
+platformIcon l = abbr [ class ("icon-plat-"++l), title (Maybe.withDefault "" <| lookup l T.platforms) ] [ text " " ]
+
+releaseTypeIcon : String -> Html m
+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
new file mode 100644
index 00000000..3eca4cfa
--- /dev/null
+++ b/elm/Lib/RDate.elm
@@ -0,0 +1,121 @@
+-- Utility module and UI widget for handling release dates.
+--
+-- 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
+module Lib.RDate exposing (..)
+
+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
+
+type alias RDateComp =
+ { y : Int
+ , m : Int
+ , d : Int
+ }
+
+
+expand : RDate -> RDateComp
+expand r =
+ { y = r // 10000
+ , m = modBy 100 (r // 100)
+ , d = modBy 100 r
+ }
+
+
+compact : RDateComp -> RDate
+compact r = r.y * 10000 + r.m * 100 + r.d
+
+
+fromDate : Date.Date -> RDateComp
+fromDate d =
+ { y = Date.year d
+ , m = Date.monthNumber 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 = clamp 0 1 r.d }
+ else if r.y == 9999 then { y = 9999, 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
+ ( y, m, 99) -> String.fromInt y ++ "-" ++ (String.padLeft 2 '0' <| String.fromInt m)
+ ( y, m, d) -> String.fromInt y ++ "-" ++ (String.padLeft 2 '0' <| String.fromInt m) ++ "-" ++ (String.padLeft 2 '0' <| String.fromInt d)
+
+
+display : Date.Date -> RDate -> Html msg
+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
new file mode 100644
index 00000000..dc876048
--- /dev/null
+++ b/elm/Lib/TextPreview.elm
@@ -0,0 +1,83 @@
+module Lib.TextPreview exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Lib.Html exposing (..)
+import Lib.Ffi as Ffi
+import Lib.Api as Api
+import Gen.Api as GApi
+import Gen.BBCode as GB
+
+
+type alias Model =
+ { state : Api.State
+ , data : String -- contents of the textarea
+ , preview : String -- Rendered HTML, "" if not in sync with data
+ , display : Bool -- False = textarea is displayed, True = preview is displayed
+ , endpoint : { content : String } -> (GApi.Response -> Msg) -> Cmd Msg
+ , class : String
+ }
+
+
+bbcode : String -> Model
+bbcode data =
+ { state = Api.Normal
+ , data = data
+ , preview = ""
+ , display = False
+ , endpoint = GB.send
+ , class = "preview bbcode"
+ }
+
+
+
+type Msg
+ = Edit String
+ | TextArea
+ | Preview
+ | HandlePreview GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Edit s -> ({ model | preview = "", data = s, display = False, state = Api.Normal }, Cmd.none)
+ TextArea -> ({ model | display = False }, Cmd.none)
+
+ Preview ->
+ if model.preview /= ""
+ then ( { model | display = True }, Cmd.none)
+ else ( { model | display = True, state = Api.Loading }
+ , model.endpoint { content = model.data } HandlePreview
+ )
+
+ HandlePreview (GApi.Content s) -> ({ model | state = Api.Normal, preview = s }, Cmd.none)
+ HandlePreview r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : String -> Model -> (Msg -> m) -> Int -> List (Attribute m) -> List (Html m) -> Html m
+view name model cmdmap width attr header =
+ let
+ display = model.display && model.preview /= ""
+ in
+ div [ class "textpreview", style "width" (String.fromInt width ++ "px") ]
+ [ div []
+ [ div [] header
+ , div [ classList [("invisible", model.data == "")] ]
+ [ case model.state of
+ Api.Loading -> span [ class "spinner" ] []
+ Api.Error _ -> small [] [ text "Error loading preview. " ]
+ Api.Normal -> text ""
+ , if display
+ then a [ onClickN (cmdmap TextArea) ] [ text "Edit" ]
+ else span [] [text "Edit"]
+ , if display
+ then span [] [text "Preview"]
+ else a [ onClickN (cmdmap Preview) ] [ text "Preview" ]
+ ]
+ ]
+ , inputTextArea name model.data (cmdmap << Edit) (class (if display then "hidden" else "") :: attr)
+ , if not display then text ""
+ else div [ class model.class, Ffi.innerHtml model.preview ] []
+ ]
diff --git a/elm/Lib/Util.elm b/elm/Lib/Util.elm
new file mode 100644
index 00000000..edde2e37
--- /dev/null
+++ b/elm/Lib/Util.elm
@@ -0,0 +1,129 @@
+module Lib.Util exposing (..)
+
+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
+delidx n l = List.take n l ++ List.drop (n+1) l
+
+
+-- Modify an element in a List
+modidx : Int -> (a -> a) -> List a -> List a
+modidx n f = List.indexedMap (\i e -> if i == n then f e else e)
+
+
+isJust : Maybe a -> Bool
+isJust m = case m of
+ Just _ -> True
+ _ -> False
+
+
+-- Returns true if the list contains duplicates
+hasDuplicates : List comparable -> Bool
+hasDuplicates l =
+ let
+ step e acc =
+ case acc of
+ Nothing -> Nothing
+ Just m -> if Set.member e m then Nothing else Just (Set.insert e m)
+ in
+ 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) (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/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
new file mode 100644
index 00000000..72f1b87d
--- /dev/null
+++ b/elm/UList/DateEdit.elm
@@ -0,0 +1,85 @@
+module UList.DateEdit exposing (main,init,view,update,Model,Msg)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Task
+import Process
+import Browser
+import Regex
+import Lib.Html exposing (..)
+import Lib.Api as Api
+import Gen.Api as GApi
+import Gen.UListDateEdit as GDE
+
+
+main : Program GDE.Send Model Msg
+main = Browser.element
+ { init = \f -> (init f, Cmd.none)
+ , subscriptions = always Sub.none
+ , view = view
+ , update = update
+ }
+
+type alias Model =
+ { state : Api.State
+ , flags : GDE.Send
+ , val : String
+ , valid : Bool
+ , debnum : Int -- Debounce for submit
+ , visible : Bool
+ }
+
+init : GDE.Send -> Model
+init f =
+ { state = Api.Normal
+ , flags = f
+ , val = f.date
+ , valid = True
+ , debnum = 0
+ , visible = False
+ }
+
+type Msg
+ = Show
+ | Val String Bool
+ | Save Int
+ | Saved GApi.Response
+
+isDate : String -> Bool
+isDate s
+ = Regex.fromString "^(?:19[7-9][0-9]|20[0-9][0-9])-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$"
+ |> Maybe.map (\r -> Regex.contains r s) |> Maybe.withDefault True
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Show -> ({ model | visible = True }, Cmd.none)
+ Val s b ->
+ ({ model | val = s, debnum = model.debnum + 1, valid = b && (s == "" || isDate s) }
+ , Task.perform (\_ -> Save (model.debnum+1)) <| Process.sleep 300)
+
+ Save n ->
+ if n /= model.debnum || model.val == model.flags.date || not model.valid
+ then (model, Cmd.none)
+ else ( { model | state = Api.Loading, debnum = model.debnum+1 }
+ , GDE.send { uid = model.flags.uid, vid = model.flags.vid, start = model.flags.start, date = model.val } Saved )
+
+ Saved GApi.Success ->
+ let f = model.flags
+ nf = { f | date = model.val }
+ in ({ model | state = Api.Normal, flags = nf }, Cmd.none)
+ Saved e -> ({ model | state = Api.Error e }, Cmd.none)
+
+
+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 [] [ 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) []
+ else text ""
+ , span [] [ text model.val ]
+ ]
diff --git a/elm/UList/LabelEdit.elm b/elm/UList/LabelEdit.elm
new file mode 100644
index 00000000..153fad8c
--- /dev/null
+++ b/elm/UList/LabelEdit.elm
@@ -0,0 +1,134 @@
+port module UList.LabelEdit exposing (main, init, update, view, isPublic, Model, Msg)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Task
+import Set exposing (Set)
+import Dict exposing (Dict)
+import Lib.Util exposing (..)
+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
+
+
+main : Program GLE.Recv Model Msg
+main = Browser.element
+ { init = \f -> (init f, Cmd.none)
+ , subscriptions = \model -> DD.sub model.dd
+ , view = \m -> view m "-"
+ , update = update
+ }
+
+port ulistLabelChanged : Bool -> Cmd msg
+
+type alias Model =
+ { 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 = 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" ++ 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
+
+
+isPublic : Model -> Bool
+isPublic model = List.any (\lb -> lb.id /= 7 && not lb.private && Set.member lb.id model.sel) model.labels
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Open b -> ({ model | dd = DD.toggle model.dd b }, Cmd.none)
+
+ Toggle l cascade b ->
+ ( { model
+ | tsel = if b then Set.insert l model.tsel else Set.remove l model.tsel
+ , state = Dict.insert l Api.Loading model.state
+ }
+ , Cmd.batch <|
+ 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 >= 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))
+ Saved l b e -> ({ model | state = Dict.insert l (Api.Error e) model.state }, Cmd.none)
+
+
+view : Model -> String -> Html Msg
+view model txt =
+ let
+ 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 " "
+ , case Dict.get l.id model.state of
+ 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)
+ (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/ManageLabels.elm b/elm/UList/ManageLabels.elm
new file mode 100644
index 00000000..8a5533d7
--- /dev/null
+++ b/elm/UList/ManageLabels.elm
@@ -0,0 +1,127 @@
+module UList.ManageLabels exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Task
+import Browser.Navigation exposing (reload)
+import Json.Encode as JE
+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.UListManageLabels as GML
+
+
+main : Program GML.Send Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+type alias Model =
+ { uid : String
+ , state : Api.State
+ , labels : List GML.SendLabels
+ , editing : Maybe Int
+ }
+
+init : GML.Send -> Model
+init d =
+ { uid = d.uid
+ , state = Api.Normal
+ , labels = List.filter (\l -> l.id > 0) d.labels
+ , editing = Nothing
+ }
+
+type Msg
+ = Noop
+ | Private Int Bool
+ | Label Int String
+ | Delete Int (Maybe Int)
+ | Add
+ | Submit
+ | Submitted GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Noop -> (model, Cmd.none)
+ Private n b -> ({ model | labels = modidx n (\l -> { l | private = b }) model.labels }, Cmd.none)
+ Label n s -> ({ model | labels = modidx n (\l -> { l | label = s }) model.labels }, Cmd.none)
+ Delete n o -> ({ model | labels = List.filter (\l -> l.id > 0 || l.delete == Nothing) <| modidx n (\l -> { l | delete = o }) model.labels }, Cmd.none)
+ Add ->
+ ( { model | labels = model.labels ++ [{ id = -1, label = "New label", private = List.all (\il -> il.private) model.labels, count = 0, delete = Nothing }] }
+ , Task.attempt (always Noop) <| Ffi.elemCall "select" <| "label_txt_" ++ String.fromInt (List.length model.labels) )
+
+ Submit -> ({ model | state = Api.Loading }, GML.send { uid = model.uid, labels = model.labels } Submitted)
+ Submitted GApi.Success -> (model, reload)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ let
+ item n l =
+ tr [ class "compact" ]
+ [ td [] [ text <| if l.count == 0 then "" else String.fromInt l.count ]
+ , td [ class "stealth" ]
+ [ if l.id > 0 && l.id < 10 then text l.label
+ else inputText ("label_txt_"++String.fromInt n) l.label (Label n) GML.valLabelsLabel
+ ]
+ , td [ ] [ linkRadio l.private (Private n) [ text "private" ] ]
+ , td [ class "stealth" ]
+ [ 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")
+ , (Just 1, "Delete label but keep VNs in my list")
+ , (Just 2, "Delete label and VNs with only this label")
+ , (Just 3, "Delete label and all VNs with this label")
+ ]
+ ]
+ ]
+
+ hasDup = hasDuplicates <| List.map (\l -> l.label) model.labels
+ in
+ Html.form [ onSubmit Submit, class "managelabels hidden" ]
+ [ div [ ]
+ [ 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" ]
+ , li [] [ text "Private labels will not be visible to other users" ]
+ , li [] [ text "Your vote and notes will be public when at least one non-private label has been assigned to the visual novel" ]
+ ]
+ ]
+ , table [ class "stripe" ] <|
+ [ thead [] [ tr []
+ [ td [] [ text "VNs" ]
+ , td [] [ text "Label" ]
+ , td [] [ text "Private" ]
+ , td [] [ ]
+ ] ]
+ , 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 [] [ 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 ]
+ [ if List.length model.labels < 500 then inputButton "New label" Add [] else text ""
+ , submitButton "Save changes" model.state (not hasDup)
+ ]
+ ]
+ ]
+ , tbody [] <| List.indexedMap item model.labels
+ ]
+ ]
diff --git a/elm/UList/Opt.elm b/elm/UList/Opt.elm
new file mode 100644
index 00000000..e909f2d8
--- /dev/null
+++ b/elm/UList/Opt.elm
@@ -0,0 +1,205 @@
+port module UList.Opt exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Task
+import Process
+import Browser
+import Date
+import Dict exposing (Dict)
+import Lib.Util exposing (..)
+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.Release as GR
+
+main : Program GVN.Recv Model Msg
+main = Browser.element
+ { init = \f -> (init f, Date.today |> Task.perform Today)
+ , subscriptions = \model -> List.map (\r -> Sub.map (Rel r.rid) (DD.sub r.dd)) model.rels |> Sub.batch
+ , view = view
+ , update = update
+ }
+
+port ulistVNDeleted : Bool -> Cmd msg
+port ulistNotesChanged : String -> Cmd msg
+port ulistRelChanged : (Int, Int) -> Cmd msg
+
+type alias Model =
+ { flags : GVN.Recv
+ , today : Date.Date
+ , del : Bool
+ , delState : Api.State
+ , notes : String
+ , notesRev : Int
+ , notesState : Api.State
+ , rels : List RE.Model
+ , relNfo : Dict String GApi.ApiReleases
+ , relOptions : Maybe (List (String, String))
+ , relState : Api.State
+ }
+
+init : GVN.Recv -> Model
+init f =
+ { flags = f
+ , today = Date.fromOrdinalDate 2100 1
+ , del = False
+ , delState = Api.Normal
+ , notes = f.notes
+ , notesRev = 0
+ , notesState = Api.Normal
+ , 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
+ }
+
+type Msg
+ = Today Date.Date
+ | Del Bool
+ | Delete
+ | Deleted GApi.Response
+ | Notes String
+ | NotesSave Int
+ | NotesSaved Int GApi.Response
+ | Rel String RE.Msg
+ | RelLoad
+ | RelLoaded GApi.Response
+ | RelAdd String
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Today d -> ({ model | today = d }, Cmd.none)
+
+ Del b -> ({ model | del = b }, Cmd.none)
+ Delete ->
+ ( { model | delState = Api.Loading }
+ , GDE.send { uid = model.flags.uid, vid = model.flags.vid } Deleted)
+ Deleted GApi.Success -> (model, ulistVNDeleted True)
+ Deleted e -> ({ model | delState = Api.Error e }, Cmd.none)
+
+ 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.flags.notes
+ then (model, Cmd.none)
+ else ( { model | notesState = Api.Loading }
+ , GVN.send { uid = model.flags.uid, vid = model.flags.vid, notes = model.notes } (NotesSaved rev))
+ NotesSaved rev GApi.Success ->
+ let f = model.flags
+ nf = { f | notes = model.notes }
+ in if model.notesRev /= rev
+ then (model, Cmd.none)
+ else ({model | flags = nf, notesState = Api.Normal }, ulistNotesChanged model.notes)
+ NotesSaved _ e -> ({ model | notesState = 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
+ 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 }
+ , GR.send { vid = model.flags.vid } RelLoaded )
+ RelLoaded (GApi.Releases rels) ->
+ ( { 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, RDate.showrel r)) rels
+ }, Cmd.none)
+ RelLoaded e -> ({ model | relState = Api.Error e }, Cmd.none)
+ RelAdd rid ->
+ ( { 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
+view model =
+ let
+ opt =
+ [ tr []
+ [ td [ colspan 5 ]
+ [ textarea (
+ [ placeholder "Notes", rows 2, cols 80
+ , onInput Notes, onBlur (NotesSave model.notesRev)
+ ] ++ GVN.valNotes
+ ) [ text model.notes ]
+ , div [ ] <|
+ [ div [ class "spinner", classList [("hidden", model.notesState /= Api.Loading)] ] []
+ , a [ href "#", onClickD (Del True) ] [ text "Remove VN" ]
+ ] ++ (
+ if model.relOptions == Nothing
+ then [ text " | ", a [ href "#", onClickD RelLoad ] [ text "Add release" ] ]
+ else []
+ ) ++ (
+ case model.notesState of
+ Api.Error e -> [ br [] [], b [] [ text <| Api.showResponse e ] ]
+ _ -> []
+ )
+ ]
+ ]
+ , if model.relOptions == Nothing && model.relState == Api.Normal
+ then text ""
+ else tfoot []
+ [ tr []
+ [ td [ colspan 5 ] <|
+ -- 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 "" "" 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 [] [ text <| Api.showResponse e ], text ". ", a [ href "#", onClickD RelLoad ] [ text "Try again" ] ]
+ ]
+ ]
+ ]
+
+ 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 ] ]
+ ]
+
+ confirm =
+ div []
+ [ text "Are you sure you want to remove this visual novel from your list? "
+ , a [ onClickD Delete ] [ text "Yes" ]
+ , text " | "
+ , a [ onClickD (Del False) ] [ text "Cancel" ]
+ ]
+
+ in case (model.del, model.delState) of
+ (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 [] [ text <| "Error removing item: " ++ Api.showResponse e ]
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
new file mode 100644
index 00000000..cf7ab13b
--- /dev/null
+++ b/elm/UList/SaveDefault.elm
@@ -0,0 +1,76 @@
+module UList.SaveDefault exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Lib.Api as Api
+import Gen.Api as GApi
+import Gen.UListSaveDefault as GUSD
+
+
+main : Program GUSD.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
+ , uid : String
+ , opts : GUSD.SendOpts
+ , field : String -- Ewwww stringly typed enum
+ , hid : Bool
+ }
+
+init : GUSD.Recv -> Model
+init d =
+ { state = Api.Normal
+ , uid = d.uid
+ , opts = d.opts
+ , field = "vnlist"
+ , hid = True
+ }
+
+type Msg
+ = SetField String
+ | Submit
+ | Submitted GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ SetField s -> ({ model | field = s, hid = False }, Cmd.none)
+
+ Submit ->
+ ( { model | state = Api.Loading, hid = False }
+ , GUSD.send { uid = model.uid, opts = model.opts, field = model.field } Submitted)
+ Submitted GApi.Success -> ({ model | state = Api.Normal, hid = True }, Cmd.none)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ form_ "" Submit (model.state == Api.Loading)
+ [ div [ classList [("savedefault", True), ("hidden", model.hid)] ]
+ [ 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."
+ , br [] []
+ , text "(If you just changed the label filters, make sure to hit \"Update filters\" before saving)"
+ , br [] []
+ , label [] [ inputRadio "savedefault_page" (model.field == "votes") (always (SetField "votes") ), text " My Votes" ]
+ , br [] []
+ , label [] [ inputRadio "savedefault_page" (model.field == "vnlist") (always (SetField "vnlist")), text " My Visual Novel List" ]
+ , br [] []
+ , label [] [ inputRadio "savedefault_page" (model.field == "wish") (always (SetField "wish") ), text " My Wishlist" ]
+ , br [] []
+ , submitButton "Save" model.state True
+ ]
+ ]
diff --git a/elm/UList/VNPage.elm b/elm/UList/VNPage.elm
new file mode 100644
index 00000000..63a1136d
--- /dev/null
+++ b/elm/UList/VNPage.elm
@@ -0,0 +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 Task
+import Date
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Lib.Api as Api
+import Lib.DropDown as DD
+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
+
+main : Program GUW.Recv UW.Model UW.Msg
+main = Browser.element
+ { 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 = UW.update
+ }
+
+
+view : UW.Model -> Html UW.Msg
+view model =
+ 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 ""
+ ]
+ 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 (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.canvote
+ then tr [ class "nostripe compact" ]
+ [ td [] [ text "My vote" ]
+ , 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
new file mode 100644
index 00000000..2f57dca8
--- /dev/null
+++ b/elm/UList/VoteEdit.elm
@@ -0,0 +1,117 @@
+port module UList.VoteEdit exposing (main, init, update, view, Model, Msg)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Task
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Lib.Api as Api
+import Lib.Ffi as Ffi
+import Lib.DropDown as DD
+import Gen.Types exposing (ratings)
+import Gen.Api as GApi
+import Gen.UListVoteEdit as GVE
+
+
+main : Program GVE.Send Model Msg
+main = Browser.element
+ { init = \f -> (init f, Cmd.none)
+ , subscriptions = \model -> DD.sub model.dd
+ , view = \m -> view m "-"
+ , update = update
+ }
+
+port ulistVoteChanged : Bool -> Cmd msg
+
+type alias Model =
+ { state : Api.State
+ , flags : GVE.Send
+ , dd : DD.Config Msg
+ , text : String
+ , vote : Maybe String
+ , ovote : Maybe String
+ , isvalid : Bool
+ , fieldId : String
+ }
+
+init : GVE.Send -> Model
+init f =
+ let v = if f.vote == Just "-" || f.vote == Just "" then Nothing else f.vote
+ in
+ { state = Api.Normal
+ , flags = f
+ , 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_" ++ f.vid
+ }
+
+type Msg
+ = Input String Bool
+ | Open Bool
+ | Set (Maybe String) Bool
+ | Noop
+ | Focus
+ | Save
+ | Saved GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Input s b -> let t = String.replace "," "." s in ({ model | text = t, isvalid = b, vote = if not b then model.vote else if t == "" || t == "-" then Nothing else Just t }, Cmd.none)
+ Open b -> ({ model | dd = DD.toggle model.dd b }, if b then selfCmd Focus else Cmd.none)
+ Set s b -> ({ model | text = "", vote = s, isvalid = True, dd = DD.toggle model.dd False }, selfCmd Save)
+ Noop -> (model, Cmd.none)
+ Focus -> (model, Task.attempt (always Noop) <| Ffi.elemCall "select" model.fieldId)
+
+ Save ->
+ case (model.vote == model.ovote, model.isvalid) of
+ (True, _) -> (model, Cmd.none)
+ (_, False) -> (model, Task.attempt (always Noop) <| Ffi.elemCall "reportValidity" model.fieldId)
+ (_, _) -> ( { model | state = Api.Loading, ovote = model.vote, dd = DD.toggle model.dd False }
+ , GVE.send { uid = model.flags.uid, vid = model.flags.vid, vote = model.vote } Saved)
+
+ Saved GApi.Success -> ({ model | state = Api.Normal }, ulistVoteChanged (isJust (model.vote)))
+ Saved e -> ({ model | state = Api.Error e }, Cmd.none)
+
+
+view : Model -> String -> Html Msg
+view model txt =
+ div [ class "elm_votedd" ]
+ [ DD.view model.dd model.state
+ (text <| Maybe.withDefault txt model.ovote)
+ <| \_ ->
+ [ ul [] <|
+ List.indexedMap (\n s ->
+ let sn = String.fromInt (10-n)
+ in li [] [ linkRadio (Just sn == model.ovote) (Set (Just sn)) [ text <| sn ++ " (" ++ s ++ ")" ] ]
+ ) (List.reverse ratings)
+ ++
+ [ li [] [ Html.form [ onSubmit Save ] [ p []
+ [ text "custom: "
+ , input (
+ [ type_ "text"
+ , class "text"
+ , id model.fieldId
+ , value model.text
+ , onInputValidation Input
+ , onBlur Save
+ , onFocus Focus
+ , placeholder "7.5"
+ , style "width" "55px"
+ ] ++ GVE.valVote
+ ) []
+ ] ]
+ ] ]
+ ++
+ ( if isJust (model.ovote)
+ then [ li [] [ a [ href "#", onClickD (Set Nothing True) ] [ text "remove vote" ] ] ]
+ else []
+ )
+ ]
+ ]
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/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/elm.json b/elm/elm.json
new file mode 100644
index 00000000..6c052936
--- /dev/null
+++ b/elm/elm.json
@@ -0,0 +1,30 @@
+{
+ "type": "application",
+ "source-directories": [
+ "."
+ ],
+ "elm-version": "0.19.1",
+ "dependencies": {
+ "direct": {
+ "elm/browser": "1.0.1",
+ "elm/core": "1.0.2",
+ "elm/file": "1.0.1",
+ "elm/html": "1.0.0",
+ "elm/http": "2.0.0",
+ "elm/json": "1.1.2",
+ "elm/regex": "1.0.0",
+ "elm/url": "1.0.0",
+ "justinmimbs/date": "3.1.2"
+ },
+ "indirect": {
+ "elm/bytes": "1.0.3",
+ "elm/parser": "1.1.0",
+ "elm/time": "1.0.0",
+ "elm/virtual-dom": "1.0.2"
+ }
+ },
+ "test-dependencies": {
+ "direct": {},
+ "indirect": {}
+ }
+}
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/icons/lang/gd.png b/icons/lang/gd.png
new file mode 100644
index 00000000..d0fb86c3
--- /dev/null
+++ 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/icons/lang/lt.png b/icons/lang/lt.png
new file mode 100644
index 00000000..eb50db98
--- /dev/null
+++ b/icons/lang/lt.png
Binary files differ
diff --git a/icons/lang/lv.png b/icons/lang/lv.png
new file mode 100644
index 00000000..e5d45b33
--- /dev/null
+++ b/icons/lang/lv.png
Binary files differ
diff --git a/icons/lang/mk.png b/icons/lang/mk.png
new file mode 100644
index 00000000..e3fd792d
--- /dev/null
+++ 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/icons/lang/sl.png b/icons/lang/sl.png
new file mode 100644
index 00000000..0f096cee
--- /dev/null
+++ 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 aa767fb2..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,9 +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 }
@@ -31,7 +34,7 @@ sub FALSE () { JSON::XS::false }
my %O = (
port => 19534,
tls_port => 19535, # Only used when tls_options is set
- logfile => "$VNDB::M{log_dir}/api.log",
+ logfile => config->{Multi}{Core}{log_dir}.'/api.log',
conn_per_ip => 10,
max_results => 25, # For get vn/release/producer/character
max_results_lists => 100, # For get votelist/vnlist/wishlist
@@ -144,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); }
}
@@ -227,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;
@@ -258,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 $VNDB::S{password_db} && PWLookup::lookup($VNDB::S{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() > $VNDB::S{login_throttle}[1];
+ 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]);
};
};
@@ -305,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}), $VNDB::S{scrypt_salt} . $salt, $N, $r, $p, 32);
+ 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 + $VNDB::S{login_throttle}[0], norm_ip($c->{ip}) );
+ 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'";
}
};
}
@@ -335,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';
};
@@ -359,12 +400,34 @@ 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/;
return [ split /,/, $s ];
}
+# 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.
@@ -386,62 +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',
+ 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
+ renai => delete($_[0]{l_renai}) ||undef,
+ wikidata => formatwd(delete $_[0]{l_wikidata}),
};
- $_[0]{image} = $_[0]{image} ? sprintf '%s/cv/%02d/%d.jpg', $VNDB::S{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) {
@@ -454,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};
@@ -477,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', $VNDB::S{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};
}
@@ -522,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|= = != <>|} ],
@@ -553,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 : $VNDB::S{resolutions}{ $_[0]{resolution} }[0];
+ $_[0]{resolution} = resolution $_[0];
$_[0]{voiced} = $_[0]{voiced} ? $_[0]{voiced}*1 : undef;
$_[0]{animation} = [
$_[0]{ani_story} ? $_[0]{ani_story}*1 : undef,
@@ -613,35 +704,52 @@ 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};
- $_->{qty} = $VNDB::S{media}{$_->{medium}}[0] ? $_->{qty}*1 : undef;
+ $_->{qty} = $MEDIUM{$_->{medium}}{qty} ? $_->{qty}*1 : undef;
}
} ],
]
},
+ 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};
}
@@ -649,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|= = != <>|} ],
@@ -695,8 +818,8 @@ my %GET_RELEASE = (
freeware => [ [ bool => 'r.freeware = :value:', {'=',1} ] ],
doujin => [ [ bool => 'r.doujin = :value:', {'=',1} ] ],
type => [
- [ str => 'r.type :op: :value:', {qw|= = != <>|},
- process => sub { !grep($_ eq $_[0], @{$VNDB::S{release_types}}) ? \'No such release type' : $_[0] } ],
+ [ 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 => [
[ 'int' => 'r.gtin :op: :value:', {qw|= = != <>|}, process => sub { length($_[0]) > 14 ? \'Too long GTIN code' : $_[0] } ],
@@ -705,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' ],
@@ -716,44 +839,45 @@ 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.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;
$_[0]{links} = {
homepage => delete($_[0]{website})||undef,
wikipedia => delete $_[0]{l_wp},
+ wikidata => formatwd(delete $_[0]{l_wikidata}),
};
},
},
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};
}
},
@@ -762,72 +886,77 @@ 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|= = != <>|},
- process => sub { !$VNDB::S{producer_types}{$_[0]} ? \'No such producer type' : $_[0] } ],
+ process => sub { !$PRODUCER_TYPE{$_[0]} ? \'No such producer type' : $_[0] } ],
],
language => [
[ str => 'p.lang :op: :value:', {qw|= = != <>|}, process => \'lang' ],
[ 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', $VNDB::S{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 ];
}
},
]],
@@ -836,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 ];
}
},
]],
@@ -847,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};
}
@@ -860,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};
}
@@ -878,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 => {
@@ -917,21 +1046,23 @@ 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',
+ 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} = {
wikipedia => delete($_[0]{l_wp}) ||undef,
homepage => delete($_[0]{l_site}) ||undef,
twitter => delete($_[0]{l_twitter})||undef,
- anidb => (delete($_[0]{l_anidb})||0)*1||undef
+ anidb => (delete($_[0]{l_anidb})||0)*1||undef,
+ wikidata => formatwd(delete $_[0]{l_wikidata}),
+ pixiv => delete($_[0]{l_pixiv})*1||undef,
};
},
},
@@ -940,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 ];
}
},
]],
@@ -954,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};
@@ -971,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};
}
@@ -986,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|= = != <>|} ],
@@ -1027,23 +1176,23 @@ my %GET_USER = (
# the uid filter for votelist/vnlist/wishlist
-my $UID_FILTER = [ 'int' => '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' => 'vid :op: :value:', {qw|= = != <> > > < < <= <= >= >=|}, range => [1,1e6] ],
- [ inta => '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 %GET_VOTELIST = (
islist => 1,
- sql => "SELECT %s FROM votes v WHERE (%s) AND NOT EXISTS(SELECT 1 FROM users_prefs WHERE uid = v.uid AND key = 'hide_list') %s",
- sqluser => q{SELECT %1$s FROM votes v WHERE (%2$s) AND (uid = %4$d OR NOT EXISTS(SELECT 1 FROM users_prefs WHERE uid = v.uid AND key = 'hide_list')) %3$s},
- select => "uid, vid as vn, vote, extract('epoch' from 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};
},
@@ -1053,15 +1202,18 @@ my %GET_VOTELIST = (
filters => { uid => [ $UID_FILTER ], vn => $VN_FILTER }
);
+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 FROM vnlists v WHERE (%s) AND NOT EXISTS(SELECT 1 FROM users_prefs WHERE uid = v.uid AND key = 'hide_list') %s",
- sqluser => q{SELECT %1$s FROM vnlists v WHERE (%2$s) AND (uid = %4$d OR NOT EXISTS(SELECT 1 FROM users_prefs WHERE uid = v.uid AND key = 'hide_list')) %3$s},
- select => "uid, vid as vn, status, extract('epoch' from added) AS added, 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}*=1;
+ $_[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;
},
@@ -1073,13 +1225,13 @@ my %GET_VNLIST = (
my %GET_WISHLIST = (
islist => 1,
- sql => "SELECT %s FROM wlists w WHERE (%s) AND NOT EXISTS(SELECT 1 FROM users_prefs WHERE uid = w.uid AND key = 'hide_list') %s",
- sqluser => q{SELECT %1$s FROM wlists w WHERE (%2$s) AND (uid = %4$d OR NOT EXISTS(SELECT 1 FROM users_prefs WHERE uid = w.uid AND key = 'hide_list')) %3$s},
- select => "uid, vid AS vn, wstat AS priority, extract('epoch' from 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}*=1;
+ $_[0]{uid} = idnum $_[0]{uid};
+ $_[0]{vn} = idnum $_[0]{vn};
+ $_[0]{priority} *= 1;
$_[0]{added} = int $_[0]{added};
},
sortdef => 'vn',
@@ -1088,6 +1240,75 @@ my %GET_WISHLIST = (
filters => { uid => [ $UID_FILTER ], vn => $VN_FILTER }
);
+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$s OR NOT uv.private) %3$s',
+ select => 'uid AS uid, id, label, private',
+ proc => sub {
+ $_[0]{uid} = idnum $_[0]{uid};
+ $_[0]{id} = idnum $_[0]{id};
+ $_[0]{private} = $_[0]{private} =~ /^t/ ? TRUE : FALSE;
+ },
+ sortdef => 'id',
+ sorts => { id => 'id %s', label => 'label %s' },
+ flags => { basic => {} },
+ filters => { uid => [ $UID_FILTER ] },
+);
+
+my %GET_ULIST = (
+ islist => 1,
+ 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} = 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};
+ $_[0]{vote}*=1 if $_[0]{vote};
+ $_[0]{notes} ||= '';
+ },
+ sortdef => 'vn',
+ sorts => {
+ uid => 'uid %s',
+ vn => 'vid %s',
+ added => 'added %s',
+ lastmod => 'lastmod %s',
+ voted => 'vote_date %s',
+ vote => 'vote %s',
+ },
+ flags => {
+ basic => {},
+ labels => {
+ 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} eq $_->{uid} && $i->{vn} eq $_->{vid}, @$r ];
+ }
+ for (@$r) {
+ $_->{id} = idnum $_->{id};
+ delete $_->{uid};
+ delete $_->{vid};
+ }
+ },
+ ]]
+ }
+ },
+ filters => {
+ uid => [ $UID_FILTER ],
+ vn => $VN_FILTER,
+ label => [
+ [ '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] ],
+ ],
+ },
+);
+
my %GET = (
vn => \%GET_VN,
@@ -1095,10 +1316,13 @@ my %GET = (
producer => \%GET_PRODUCER,
character => \%GET_CHARACTER,
staff => \%GET_STAFF,
+ quote => \%GET_QUOTE,
user => \%GET_USER,
votelist => \%GET_VOTELIST,
vnlist => \%GET_VNLIST,
wishlist => \%GET_WISHLIST,
+ 'ulist-labels' => \%GET_ULIST_LABELS,
+ ulist => \%GET_ULIST,
);
@@ -1193,9 +1417,12 @@ sub get_filters {
y/%//;
$v = "%$v%";
} elsif(${$o{process}} eq 'lang') {
- return cerr $c, filter => 'Invalid language code', %e if !$VNDB::S{languages}{$v};
+ 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 !$VNDB::S{platforms}{$v};
+ 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;
}
}
@@ -1255,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;
@@ -1275,9 +1502,12 @@ sub get_fetch {
my %need = map +($_, $need[$_]), 0..$#need;
for my $n (keys %need) {
- my @ids = map $_->{ $need{$n}[0] }, @{$get->{list}};
- my $ids = join ',', map '$'.$_, 1..@ids;
- cpg $c, sprintf($need{$n}[1], $ids), \@ids, sub {
+ my $field = $need{$n}[0];
+ my $ref = 1;
+ 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} ? "'$c->{uid}'" : 'NULL'), \@ids, sub {
$get->{fetched}{$n} = [ $need{$n}[2], [$_[0]->rowsAsHashes] ];
delete $need{$n};
get_final($c, $type, $get) if !keys %need;
@@ -1316,6 +1546,7 @@ sub set {
votelist => \&set_votelist,
vnlist => \&set_vnlist,
wishlist => \&set_wishlist,
+ ulist => \&set_ulist,
);
return cerr $c, parse => 'Invalid arguments to set command' if @arg < 2 || @arg > 3 || ref($arg[0])
@@ -1343,28 +1574,37 @@ sub setpg {
};
}
+sub set_ulist_ret {
+ my($c, $obj) = @_;
+ 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 setpg $obj, 'DELETE FROM votes WHERE uid = $1 AND vid = $2',
- [ $c->{uid}, $obj->{id} ] if !$obj->{opt};
+ 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};
my($ev, $vv) = (exists($obj->{opt}{vote}), $obj->{opt}{vote});
return cerr $c, missing => 'No vote given', field => 'vote' if !$ev;
return cerr $c, badarg => 'Invalid vote', field => 'vote' if ref($vv) || !defined($vv) || $vv !~ /^\d+$/ || $vv < 10 || $vv > 100;
- setpg $obj, 'WITH upsert AS (UPDATE votes SET vote = $1 WHERE uid = $2 AND vid = $3 RETURNING vid)
- INSERT INTO votes (vote, uid, vid) SELECT $1, $2, $3 WHERE EXISTS(SELECT 1 FROM vn v WHERE v.id = $3) AND NOT EXISTS(SELECT 1 FROM upsert)',
- [ $vv, $c->{uid}, $obj->{id} ];
+ 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}, 'v'.$obj->{id}, $vv ], sub { set_ulist_ret $c, $obj; }
}
sub set_vnlist {
my($c, $obj) = @_;
- return setpg $obj, 'DELETE FROM vnlists WHERE uid = $1 AND vid = $2',
- [ $c->{uid}, $obj->{id} ] if !$obj->{opt};
+ # Bug: Also removes from wishlist and votelist.
+ 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($es, $en, $vs, $vn) = (exists($obj->{opt}{status}), exists($obj->{opt}{notes}), $obj->{opt}{status}, $obj->{opt}{notes});
return cerr $c, missing => 'No status or notes given', field => 'status,notes' if !$es && !$en;
@@ -1374,26 +1614,92 @@ sub set_vnlist {
$vs ||= 0;
$vn ||= '';
- my $set = join ', ', $es ? 'status = $3' : (), $en ? 'notes = $4' : ();
- setpg $obj, 'WITH upsert AS (UPDATE vnlists SET '.$set.' WHERE uid = $1 AND vid = $2 RETURNING vid)
- INSERT INTO vnlists (uid, vid, status, notes) SELECT $1, $2, $3, $4 WHERE EXISTS(SELECT 1 FROM vn v WHERE v.id = $2) AND NOT EXISTS(SELECT 1 FROM upsert)',
- [ $c->{uid}, $obj->{id}, $vs, $vn ];
+ 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 $l = 'array_remove(array_remove(ulist_vns.labels,5),6)';
- return setpg $obj, 'DELETE FROM wlists WHERE uid = $1 AND vid = $2',
- [ $c->{uid}, $obj->{id} ] if !$obj->{opt};
+ # Bug: This will make it appear in the vnlist
+ 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};
my($ep, $vp) = (exists($obj->{opt}{priority}), $obj->{opt}{priority});
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]$/;
- setpg $obj, 'WITH upsert AS (UPDATE wlists SET wstat = $1 WHERE uid = $2 AND vid = $3 RETURNING vid)
- INSERT INTO wlists (wstat, uid, vid) SELECT $1, $2, $3 WHERE EXISTS(SELECT 1 FROM vn v WHERE v.id = $3) AND NOT EXISTS(SELECT 1 FROM upsert)',
- [ $vp, $c->{uid}, $obj->{id} ];
+ 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}, 'v'.$obj->{id} ], sub {
+ set_ulist_ret $c, $obj;
+ } if !$obj->{opt};
+
+ my $opt = $obj->{opt};
+ my @set;
+ 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);
+ if($opt->{vote}) {
+ push @bind, $opt->{vote};
+ push @set, 'vote_date = NOW()', 'vote = $'.@bind;
+ } else {
+ push @set, 'vote_date = NULL', 'vote = NULL';
+ }
+ }
+
+ if(exists $opt->{notes}) {
+ return cerr $c, badarg => 'Invalid notes', field => 'notes' if ref $opt->{notes};
+ push @bind, $opt->{notes} // '';
+ push @set, 'notes = $'.@bind;
+ }
+
+ for my $f ('started', 'finished') {
+ if(exists $opt->{$f}) {
+ return cerr $c, badarg => "Invalid $f date", field => $f if defined $opt->{$f} && (ref $opt->{$f} || $opt->{$f} !~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/);
+ push @bind, $opt->{$f};
+ push @set, "$f = \$".@bind;
+ }
+ }
+
+ if(exists $opt->{labels}) {
+ 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: 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;
+ 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}, '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;
+ }
+ };
}
1;
diff --git a/lib/Multi/APIDump.pm b/lib/Multi/APIDump.pm
deleted file mode 100644
index 79458f8d..00000000
--- a/lib/Multi/APIDump.pm
+++ /dev/null
@@ -1,128 +0,0 @@
-
-#
-# Multi::APIDump - Regular dumps of the database for public API stuff
-#
-
-package Multi::APIDump;
-
-use strict;
-use warnings;
-use Multi::Core;
-use JSON::XS;
-use PerlIO::gzip;
-
-
-sub run {
- push_watcher schedule 0, 24*3600, \&generate;
-}
-
-
-sub tags_gen {
- # The subqueries are kinda ugly, but it's convenient to have everything in a single query.
- pg_cmd 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
- |, undef, sub {
- my($res, $time) = @_;
- return if pg_expect $res, 1;
- my $ws = AE::time;
- my @res = $res->rowsAsHashes;
- for(@res) {
- $_->{id} *= 1;
- $_->{meta} = $_->{searchable} ne 't' ? JSON::XS::true : JSON::XS::false; # For backwards compat
- $_->{searchable} = $_->{searchable} eq 't' ? JSON::XS::true : JSON::XS::false;
- $_->{applicable} = $_->{applicable} eq 't' ? JSON::XS::true : JSON::XS::false;
- $_->{vns} *= 1;
- $_->{aliases} = [ split /\$\$\$-\$\$\$/, ($_->{aliases}||'') ];
- $_->{parents} = [ map $_*1, split /,/, ($_->{parents}||'') ];
- }
- writejson(\@res, "$VNDB::ROOT/www/api/tags.json.gz", $time, $ws);
- };
-}
-
-
-sub traits_gen {
- pg_cmd 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
- |, undef, sub {
- my($res, $time) = @_;
- return if pg_expect $res, 1;
- my $ws = AE::time;
- my @res = $res->rowsAsHashes;
- for(@res) {
- $_->{id} *= 1;
- $_->{meta} = $_->{searchable} ne 't' ? JSON::XS::true : JSON::XS::false; # For backwards compat
- $_->{searchable} = $_->{searchable} eq 't' ? JSON::XS::true : JSON::XS::false;
- $_->{applicable} = $_->{applicable} eq 't' ? JSON::XS::true : JSON::XS::false;
- $_->{chars} *= 1;
- $_->{aliases} = [ split /\r?\n/, ($_->{aliases}||'') ];
- $_->{parents} = [ map $_*1, split /,/, ($_->{parents}||'') ];
- }
- writejson(\@res, "$VNDB::ROOT/www/api/traits.json.gz", $time, $ws);
- };
-}
-
-
-sub writejson {
- my($data, $file, $sqltime, $procstart) = @_;
-
- open my $f, '>:gzip:utf8', "$file~" or die "Writing $file: $!";
- print $f JSON::XS->new->encode($data);
- close $f;
- rename "$file~", $file or die "Renaming $file: $!";
-
- my $wt = AE::time-$procstart;
- AE::log info => sprintf 'Wrote %s in %.2fs query + %.2fs write, size: %.1fkB, items: %d.',
- $file, $sqltime, $wt, (-s $file)/1024, scalar @$data;
-}
-
-
-sub votes_gen {
- pg_cmd q{
- SELECT vv.vid||' '||vv.uid||' '||vv.vote as l, to_char(vv.date, 'YYYY-MM-DD') as d
- FROM votes vv
- JOIN users u ON u.id = vv.uid
- JOIN vn v ON v.id = vv.vid
- WHERE NOT v.hidden
- AND NOT u.ign_votes
- AND NOT EXISTS(SELECT 1 FROM users_prefs up WHERE up.uid = u.id AND key = 'hide_list')
- }, undef, sub {
- my($res, $time) = @_;
- return if pg_expect $res, 1;
- my $ws = AE::time;
-
- # legacy votes v1 file, without date
- my $file = "$VNDB::ROOT/www/api/votes.gz";
- open my $f, '>:gzip:utf8', "$file~" or die "Writing $file: $!";
- printf $f "%s\n", $res->value($_,0) for (0 .. $res->rows-1);
- close $f;
- rename "$file~", $file or die "Renaming $file: $!";
-
- # v2 file with date
- $file = "$VNDB::ROOT/www/api/votes2.gz";
- open $f, '>:gzip:utf8', "$file~" or die "Writing $file: $!";
- printf $f "%s %s\n", $res->value($_,0), $res->value($_,1) for (0 .. $res->rows-1);
- close $f;
- rename "$file~", $file or die "Renaming $file: $!";
-
- my $wt = AE::time-$ws;
- AE::log info => sprintf 'Wrote %s in %.2fs query + %.2fs write, size: %.1fkB, items: %d.',
- $file, $time, $wt, (-s $file)/1024, scalar $res->rows;
- };
-}
-
-
-sub generate {
- # TODO: Running these functions in the main process adds ~11MB of RAM because
- # the full query results are kept in memory. It might be worthwile to
- # generate the dumps in a forked process.
- tags_gen;
- my $a; $a = AE::timer 5, 0, sub { traits_gen; undef $a; };
- my $b; $b = AE::timer 10, 0, sub { votes_gen; undef $b; };
-}
-
-1;
diff --git a/lib/Multi/Anime.pm b/lib/Multi/Anime.pm
index f189286e..b9db5003 100644
--- a/lib/Multi/Anime.pm
+++ b/lib/Multi/Anime.pm
@@ -10,7 +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 }
@@ -32,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
@@ -44,6 +48,7 @@ my %O = (
maxtimeoutdelay => 2*3600,
check_delay => 3600,
resolve_delay => 3*3600,
+ titles_delay => 48*3600,
cachetime => '3 months',
);
@@ -57,25 +62,16 @@ my %C = (
lm => 0, # timestamp of last outgoing message
aid => 0, # anime ID of the last sent ANIME command
tag => int(rand()*50000),
- # anime types as returned by AniDB (lowercased)
- anime_types => {
- 'unknown' => undef, # NULL
- 'tv series' => 'tv',
- 'ova' => 'ova',
- 'movie' => 'mov',
- 'other' => 'oth',
- 'web' => 'web',
- 'tv special' => 'spe',
- 'music video' => 'mv',
- },
);
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();
}
@@ -86,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: $!";
@@ -110,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);
@@ -135,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
@@ -236,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] = $O{anime_types}{ lc($col[4]) };
- $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 3656f5b7..ea1aeb97 100644
--- a/lib/Multi/Core.pm
+++ b/lib/Multi/Core.pm
@@ -12,16 +12,14 @@ 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;
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;
@@ -36,44 +34,9 @@ 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(
- $VNDB::M{db_login},
+ config->{Multi}{Core}{db_login},
timeout => 600, # Some maintenance queries can take a while to run...
on_error => sub { die "Lost connection to PostgreSQL\n"; },
on_connect_error => sub { die "Lost connection to PostgreSQL\n"; },
@@ -90,8 +53,9 @@ sub load_pg {
sub load_mods {
- for(keys %{$VNDB::M{modules}}) {
- my($mod, $args) = ($_, $VNDB::M{modules}{$_});
+ for(keys %{ config->{Multi} }) {
+ next if /^Core$/;
+ my($mod, $args) = ($_, config->{Multi}{$_});
next if !$args || ref($args) ne 'HASH';
require "Multi/$mod.pm";
# I'm surprised the strict pagma isn't complaining about this
@@ -104,8 +68,9 @@ sub unload {
AE::log info => 'Shutting down';
@watchers = ();
- for(keys %{$VNDB::M{modules}}) {
- my($mod, $args) = ($_, $VNDB::M{modules}{$_});
+ for(keys %{ config->{Multi} }) {
+ next if /^Core$/;
+ my($mod, $args) = ($_, config->{Multi}{$_});
next if !$args || ref($args) ne 'HASH';
no strict 'refs';
${"Multi::$mod\::"}{unload} && "Multi::$mod"->unload();
@@ -114,25 +79,27 @@ sub unload {
sub run {
- my $p = shift;
- $pidfile = "$VNDB::ROOT/data/multi.pid";
- die "PID file already exists\n" if -e $pidfile;
+ my($quiet) = @_;
- $stopcv = AE::cv;
- AnyEvent::Log::ctx('Multi')->attach(AnyEvent::Log::Ctx->new(level => $VNDB::M{log_level}, # log_to_file => $VNDB::M{log_dir}.'/multi.log'));
+ 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', $VNDB::M{log_dir}.'/multi.log');
+ 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;
- AE::log info => "Starting Multi $VNDB::S{version}";
+ 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));
$stopcv->recv;
@@ -145,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
new file mode 100644
index 00000000..a09f0325
--- /dev/null
+++ b/lib/Multi/DLsite.pm
@@ -0,0 +1,84 @@
+package Multi::DLsite;
+
+use strict;
+use warnings;
+use utf8;
+use Encode 'decode_utf8';
+use Multi::Core;
+use AnyEvent::HTTP;
+use VNDB::Config;
+
+
+my %C = (
+ url => 'https://www.dlsite.com/%s/work/=/product_id/%s.html',
+ clean_timeout => 48*3600,
+ check_timeout => 1*60,
+);
+
+
+sub run {
+ shift;
+ $C{ua} = sprintf 'VNDB.org Affiliate Crawler (Multi v%s; contact@vndb.org)', config->{version};
+ %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)};
+ };
+ push_watcher schedule 0, $C{check_timeout}, sub {
+ pg_cmd q{
+ INSERT INTO shop_dlsite (id)
+ SELECT DISTINCT l_dlsite
+ FROM releases
+ WHERE NOT hidden AND l_dlsite <> ''
+ AND NOT EXISTS(SELECT 1 FROM shop_dlsite WHERE id = l_dlsite)
+ }, [], \&sync
+ }
+}
+
+
+sub data {
+ my($shop, $time, $id, $body, $hdr) = @_;
+ my $prefix = sprintf '[%.1fs] %s', $time, $id;
+ #use Data::Dumper 'Dumper'; AE::log warn => Dumper $hdr, $body; exit;
+ return AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}" if $hdr->{Status} !~ /^2/ && $hdr->{Status} ne '404';
+
+ $body = decode_utf8($body);
+ my $found = $hdr->{Status} ne '404' && $body =~ /"id":"\Q$id\E",/;
+
+ my $price =
+ $body =~ m{<div class="work_buy_content"><span class="price">([0-9,]+)<i>円</i></span></div>} ? sprintf('JP¥ %d', $1 =~ s/,//gr) :
+ $body =~ m{<i class="work_jpy">([0-9,]+) JPY</i></span>} ? sprintf('JP¥ %d', $1 =~ s/,//gr) : '';
+
+ $shop = $body =~ /,"category":"([^"]+)"/ ? $1 : '';
+
+ return AE::log warn => "$prefix Product found, but no price ($price) or shop ($shop)" if $found && (!$price || !$shop);
+
+ # We have a price? Update database.
+ if($price && $shop) {
+ pg_cmd q{UPDATE shop_dlsite SET deadsince = NULL, shop = $2, price = $3, lastfetch = NOW() WHERE id = $1}, [ $id, $shop, $price ];
+ AE::log debug => "$prefix for $price at /$shop/";
+
+ # Nothing? Update DB
+ } else {
+ pg_cmd q{UPDATE shop_dlsite SET deadsince = COALESCE(deadsince, NOW()), lastfetch = NOW() WHERE id = $1}, [ $id ];
+ AE::log info => "$prefix not found.";
+ }
+}
+
+
+sub fetch {
+ my($shop, $id) = @_;
+ my $ts = AE::now;
+ my $url = sprintf $C{url}, $shop, $id;
+ http_get $url, headers => {'User-Agent' => $C{ua} }, timeout => 60,
+ sub { data($shop, AE::now-$ts, $id, @_) };
+}
+
+
+sub sync {
+ pg_cmd 'SELECT id, shop FROM shop_dlsite ORDER BY lastfetch ASC NULLS FIRST LIMIT 1', [], sub {
+ my($res, $time) = @_;
+ return if pg_expect $res, 1 or !$res->nRows;
+ fetch 'home', $res->value(0,0);
+ };
+}
diff --git a/lib/Multi/Denpa.pm b/lib/Multi/Denpa.pm
new file mode 100644
index 00000000..99c60231
--- /dev/null
+++ b/lib/Multi/Denpa.pm
@@ -0,0 +1,77 @@
+package Multi::Denpa;
+
+use strict;
+use warnings;
+use Multi::Core;
+use AnyEvent::HTTP;
+use VNDB::Config;
+use VNDB::ExtLinks ();
+
+
+my %C = (
+ clean_timeout => 48*3600,
+ check_timeout => 10*60,
+);
+
+
+sub run {
+ shift;
+ $C{ua} = sprintf 'VNDB.org Affiliate Crawler (Multi v%s; contact@vndb.org)', config->{version};
+ %C = (%C, @_);
+
+ push_watcher schedule 0, $C{clean_timeout}, sub {
+ pg_cmd 'DELETE FROM shop_denpa WHERE id NOT IN(SELECT l_denpa FROM releases WHERE NOT hidden)';
+ };
+ push_watcher schedule 0, $C{check_timeout}, sub {
+ pg_cmd q{
+ INSERT INTO shop_denpa (id)
+ SELECT DISTINCT l_denpa
+ FROM releases
+ WHERE NOT hidden AND l_denpa <> ''
+ AND NOT EXISTS(SELECT 1 FROM shop_denpa WHERE id = l_denpa)
+ }, [], \&sync
+ }
+}
+
+
+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|404)/;
+
+ 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 : '';
+
+ # 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($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 or not in stock.";
+
+ } else {
+ 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',
+ [ $id, $sku, $price ];
+ AE::log debug => "$prefix for $price at $sku";
+ }
+}
+
+
+sub sync {
+ pg_cmd 'SELECT id FROM shop_denpa ORDER BY lastfetch ASC NULLS FIRST LIMIT 1', [], sub {
+ my($res, $time) = @_;
+ return if pg_expect $res, 1 or !$res->nRows;
+
+ my $id = $res->value(0,0);
+ my $ts = AE::now;
+ http_get sprintf($VNDB::ExtLinks::LINKS{r}{l_denpa}{fmt}, $id),
+ headers => {'User-Agent' => $C{ua}},
+ timeout => 60,
+ sub { data(AE::now-$ts, $id, @_) };
+ };
+}
+
+1;
diff --git a/lib/Multi/Feed.pm b/lib/Multi/Feed.pm
deleted file mode 100644
index 76a7e459..00000000
--- a/lib/Multi/Feed.pm
+++ /dev/null
@@ -1,154 +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;
-
-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},
- [$VNDB::S{atom_feeds}{announcements}[0]],
- sub { write_atom(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},
- [$VNDB::S{atom_feeds}{changes}[0]],
- sub { write_atom(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},
- [$VNDB::S{atom_feeds}{posts}[0]],
- sub { write_atom(posts => @_); };
-}
-
-
-sub write_atom {
- my($feed, $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' => $VNDB::S{url}.'/');
- $x->tag(title => $VNDB::S{atom_feeds}{$feed}[1]);
- $x->tag(updated => datetime($updated));
- $x->tag(id => $VNDB::S{url}.$VNDB::S{atom_feeds}{$feed}[2]);
- $x->tag(link => rel => 'self', type => 'application/atom+xml', href => "$VNDB::S{url}/feeds/$feed.atom", undef);
- $x->tag(link => rel => 'alternate', type => 'text/html', href => $VNDB::S{url}.$VNDB::S{atom_feeds}{$feed}[2], undef);
-
- for(@r) {
- $x->tag('entry');
- $x->tag(id => $VNDB::S{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 => $VNDB::S{url}.'/u'.$_->{uid}) if $_->{uid};
- $x->end;
- }
- $x->tag(link => rel => 'alternate', type => 'text/html', href => $VNDB::S{url}.$_->{id}, undef);
- $x->tag('summary', type => 'html', bb2html $_->{summary}) if $_->{summary};
- $x->end('entry');
- }
-
- $x->end('feed');
-
- open my $f, '>:utf8', "$VNDB::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 "$VNDB::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 b9b8a035..df055b93 100644
--- a/lib/Multi/IRC.pm
+++ b/lib/Multi/IRC.pm
@@ -10,7 +10,7 @@ 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';
use Encode 'decode_utf8', 'encode_utf8';
@@ -18,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
@@ -36,7 +36,6 @@ my $LIGHT_GREY = "\x0315";
my $irc;
my $connecttimer;
-my @quotew;
my %lastnotify;
@@ -61,7 +60,6 @@ sub run {
set_cbs();
set_logger();
- set_quotew($_) for (0..$#{$O{channels}});
set_notify();
ircconnect();
@@ -85,7 +83,6 @@ sub run {
sub unload {
- @quotew = ();
# TODO: Wait until we've nicely disconnected?
$irc->disconnect('Closing...');
undef $connecttimer;
@@ -106,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];
@@ -142,12 +121,7 @@ sub set_cbs {
reconnect();
});
- #$irc->reg_cb(read => sub {
- # require Data::Dumper;
- # AE::log trace => "Received: ".Data::Dumper::Dumper($_[1]);
- #});
-
- $irc->ctcp_auto_reply(VERSION => ['VERSION', "$O{ircname}:$VNDB::S{version}:AnyEvent"]);
+ $irc->ctcp_auto_reply(VERSION => ['VERSION', join ':', $O{ircname}, config->{version}, 'AnyEvent']);
$irc->ctcp_auto_reply(USERINFO => ['USERINFO', ":$O{ircname}"]);
$irc->reg_cb(publicmsg => sub { my @a = (prefix_nick($_[2]->{prefix}), $_[1], $_[2]->{params}[1]); command(@a) || vndbid(@a); });
@@ -162,7 +136,7 @@ sub set_logger {
my $l = sub {
my($chan, $msg, @arg) = @_;
return if !grep $chan eq $_, @{$O{channels}};
- open my $F, '>>', "$VNDB::M{log_dir}/$chan.log" or die $!;
+ open my $F, '>>', config->{Multi}{Core}{log_dir}."/$chan.log" or die $!;
print $F strftime('%Y-%m-%d %H:%M:%S', localtime).' '.sprintf($msg, @arg)."\n";
};
@@ -203,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
@@ -238,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};
@@ -259,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};
@@ -268,7 +244,7 @@ sub formatid {
) if defined $_->{comments};
# (always) @ URL
- push @msg, $c."@ $NORMAL$LIGHT_GREY$VNDB::S{url}/$id$NORMAL";
+ push @msg, $c."@ $NORMAL$LIGHT_GREY".config->{url}."/$id$NORMAL";
# now post it
$irc->send_msg(PRIVMSG => $dest, encode_utf8 join ' ', @msg);
@@ -277,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;
@@ -291,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]/;
}
@@ -325,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;
}
@@ -336,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 {
@@ -392,7 +345,7 @@ my %cmds = (
info => [ 0, 0, sub {
$irc->send_msg(PRIVMSG => $_[1],
- 'Hi! I am HMX-12 Multi '.$VNDB::S{version}.', the IRC bot of '.$VNDB::S{url}.'/, written by the great master Yorhel!');
+ 'Hi! I am HMX-12 Multi '.config->{version}.', the IRC bot of '.config->{url}.'/, written by the great master Yorhel!');
}],
list => [ 0, 0, sub {
@@ -400,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', $VNDB::S{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);
};
}],
@@ -431,38 +385,17 @@ 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;
return $irc->send_msg(PRIVMSG => $chan,
- sprintf 'Too many results found, see %s/p/all?q=%s', $VNDB::S{url}, uri_escape($q)) if $res->nRows > 5;
- formatid([$res->rowsAsHashes()], $chan, 0);
- };
-}],
-
-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;
+ sprintf 'Too many results found, see %s/p/all?q=%s', config->{url}, uri_escape($q)) if $res->nRows > 5;
formatid([$res->rowsAsHashes()], $chan, 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
new file mode 100644
index 00000000..60ce2c1e
--- /dev/null
+++ b/lib/Multi/JList.pm
@@ -0,0 +1,79 @@
+package Multi::JList;
+
+use strict;
+use warnings;
+use Multi::Core;
+use AnyEvent::HTTP;
+use VNDB::Config;
+use VNDB::ExtLinks;
+
+
+my %C = (
+ url => 'https://jlist.com/shop/product/%s',
+ clean_timeout => 48*3600,
+ check_timeout => 10*60, # Minimum time between fetches.
+);
+
+
+sub run {
+ shift;
+ $C{ua} = sprintf 'VNDB.org Affiliate Crawler (Multi v%s; contact@vndb.org)', config->{version};
+ %C = (%C, @_);
+
+ push_watcher schedule 0, $C{clean_timeout}, sub {
+ pg_cmd 'DELETE FROM shop_jlist WHERE id NOT IN(SELECT l_jlist FROM releases WHERE NOT hidden)';
+ };
+ push_watcher schedule 0, $C{check_timeout}, sub {
+ pg_cmd q{
+ INSERT INTO shop_jlist (id)
+ SELECT DISTINCT l_jlist
+ FROM releases
+ WHERE NOT hidden AND l_jlist <> ''
+ AND NOT EXISTS(SELECT 1 FROM shop_jlist WHERE id = l_jlist)
+ }, [], \&sync
+ }
+}
+
+
+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/ && $hdr->{Status} ne '404';
+
+ # 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, 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, price = $2, lastfetch = NOW() WHERE id = $1}, [ $id, $price ];
+ AE::log debug => "$prefix for $price";
+
+ # Not found? Update database.
+ } else {
+ pg_cmd q{UPDATE shop_jlist SET deadsince = NOW() WHERE deadsince IS NULL AND id = $1}, [ $id ];
+ AE::log info => "$prefix not found.";
+ }
+}
+
+
+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;
+ 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/MG.pm b/lib/Multi/MG.pm
new file mode 100644
index 00000000..3ffdee8c
--- /dev/null
+++ b/lib/Multi/MG.pm
@@ -0,0 +1,80 @@
+package Multi::MG;
+
+use strict;
+use warnings;
+use Multi::Core;
+use AnyEvent::HTTP;
+use VNDB::Config;
+
+
+my %C = (
+ r18 => 'https://www.mangagamer.com/r18/detail.php?product_code=',
+ main => 'https://www.mangagamer.com/detail.php?product_code=',
+ clean_timeout => 48*3600,
+ check_timeout => 10*60, # Minimum time between fetches.
+);
+
+
+sub run {
+ shift;
+ $C{ua} = sprintf 'VNDB.org Affiliate Crawler (Multi v%s; contact@vndb.org)', config->{version};
+ %C = (%C, @_);
+
+ push_watcher schedule 0, $C{clean_timeout}, sub {
+ pg_cmd 'DELETE FROM shop_mg WHERE id NOT IN(SELECT l_mg FROM releases WHERE NOT hidden)';
+ };
+ push_watcher schedule 0, $C{check_timeout}, sub {
+ pg_cmd q{
+ INSERT INTO shop_mg (id)
+ SELECT DISTINCT l_mg
+ FROM releases
+ WHERE NOT hidden AND l_mg <> 0
+ AND NOT EXISTS(SELECT 1 FROM shop_mg WHERE id = l_mg)
+ }, [], \&sync
+ }
+}
+
+
+sub trysite {
+ my($r18, $id) = @_;
+ my $ts = AE::now;
+ my $url = ($r18 eq 't' ? $C{r18} : $C{main}).$id;
+ http_get $url, headers => {'User-Agent' => $C{ua} }, timeout => 60,
+ sub { data($r18, AE::now-$ts, $id, @_) };
+}
+
+
+sub data {
+ my($r18, $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';
+
+ my $found = $hdr->{Status} ne '404' && $body =~ /title_information\.png/;
+ my $price = $body =~ /<b>\$(\d+\.\d+)<\/b>.+<b>MG point:/ ? sprintf('US$ %.2f', $1) : '';
+
+ return AE::log warn => "$prefix Product found, but no price" if !$price && $found;
+
+ # We have a price? Update database.
+ if($price) {
+ pg_cmd q{UPDATE shop_mg SET deadsince = NULL, r18 = $2, price = $3, lastfetch = NOW() WHERE id = $1}, [ $id, $r18, $price ];
+ AE::log debug => "$prefix for $price on r18=$r18";
+
+ # Try /r18/
+ } elsif($r18 eq 'f') {
+ trysite 't', $id;
+
+ # Nothing? Update DB
+ } else {
+ pg_cmd q{UPDATE shop_mg SET deadsince = COALESCE(deadsince, NOW()), lastfetch = NOW() WHERE id = $1}, [ $id ];
+ AE::log info => "$prefix not found.";
+ }
+}
+
+
+sub sync {
+ pg_cmd 'SELECT id FROM shop_mg ORDER BY lastfetch ASC NULLS FIRST LIMIT 1', [], sub {
+ my($res, $time) = @_;
+ return if pg_expect $res, 1 or !$res->nRows;
+ trysite 'f', $res->value(0,0);
+ };
+}
diff --git a/lib/Multi/Maintenance.pm b/lib/Multi/Maintenance.pm
index 738909a9..728bcd20 100644
--- a/lib/Multi/Maintenance.pm
+++ b/lib/Multi/Maintenance.pm
@@ -8,18 +8,18 @@ package Multi::Maintenance;
use strict;
use warnings;
use Multi::Core;
-use PerlIO::gzip;
-use VNDBUtil 'normalize_titles';
+use POSIX 'strftime';
+use VNDB::Config;
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();
}
@@ -47,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)
@@ -64,37 +97,30 @@ my %dailies = (
AND r.released <= TO_CHAR(NOW(), 'YYYYMMDD')::integer
) AS r(id)|,
- # takes about 15 seconds max, still OK
- tagcache => 'SELECT tag_vn_calc()',
+ # takes about 6 seconds, OK
+ tagcache => 'SELECT tag_vn_calc(NULL)',
- # takes about 25 seconds, OK
- traitcache => 'SELECT traits_chars_calc()',
+ # takes about 11 seconds, OK
+ traitcache => 'SELECT traits_chars_calc(NULL)',
- # takes about 140 seconds, not really OK
- vnpopularity => 'SELECT update_vnpopularity()',
+ lengthcache => 'SELECT update_vn_length_cache(NULL)',
- # takes about 3 seconds, can be performed in ranges as well when necessary
- vnrating => q|
- UPDATE vn SET
- c_rating = (SELECT (
- ((SELECT COUNT(vote)::real/COUNT(DISTINCT vid)::real FROM votes)*(SELECT AVG(a)::real FROM (SELECT AVG(vote) FROM votes GROUP BY vid) AS v(a)) + SUM(vote)::real) /
- ((SELECT COUNT(vote)::real/COUNT(DISTINCT vid)::real FROM votes) + COUNT(uid)::real)
- ) FROM votes WHERE vid = id AND uid NOT IN(SELECT id FROM users WHERE ign_votes)
- ),
- c_votecount = COALESCE((SELECT count(*) FROM votes WHERE vid = id AND uid NOT IN(SELECT id FROM users WHERE ign_votes)), 0)|,
+ # takes about 10 seconds, OK
+ imagecache => 'SELECT update_images_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)|,
+ reviewcache => 'SELECT update_reviews_votes_cache(NULL)',
- cleansessions => q|DELETE FROM sessions WHERE lastused < NOW()-'1 month'::interval|,
+ 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()|,
);
@@ -113,6 +139,7 @@ sub daily {
run_daily shift(@l), $s if @l;
};
$s->();
+ logrotate;
}
@@ -127,44 +154,12 @@ my %monthlies = (
# This only takes about 3 seconds to complete
vncache_full => 'SELECT update_vncache(id) FROM vn',
- # These shouldn't really be necessary, the triggers in PgSQL should keep
- # these up-to-date nicely. But these all take less a second to complete,
- # anyway.
- stats_users => q|UPDATE stats_cache SET count = (SELECT COUNT(*) FROM users)-1 WHERE section = 'users'|,
- stats_vn => q|UPDATE stats_cache SET count = (SELECT COUNT(*) FROM vn WHERE hidden = FALSE) WHERE section = 'vn'|,
- stats_rel => q|UPDATE stats_cache SET count = (SELECT COUNT(*) FROM releases WHERE hidden = FALSE) WHERE section = 'releases'|,
- stats_prod => q|UPDATE stats_cache SET count = (SELECT COUNT(*) FROM producers WHERE hidden = FALSE) WHERE section = 'producers'|,
- stats_chars => q|UPDATE stats_cache SET count = (SELECT COUNT(*) FROM chars WHERE hidden = FALSE) WHERE section = 'chars'|,
- stats_chars => q|UPDATE stats_cache SET count = (SELECT COUNT(*) FROM staff WHERE hidden = FALSE) WHERE section = 'staff'|,
- stats_tags => q|UPDATE stats_cache SET count = (SELECT COUNT(*) FROM tags WHERE state = 2) WHERE section = 'tags'|,
- stats_trait => q|UPDATE stats_cache SET count = (SELECT COUNT(*) FROM traits WHERE state = 2) WHERE section = 'traits'|,
- stats_thread=> q|UPDATE stats_cache SET count = (SELECT COUNT(*) FROM threads WHERE hidden = FALSE) WHERE section = 'threads'|,
- stats_posts => q|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'|,
+ # This shouldn't really be necessary, the triggers in PgSQL should keep
+ # these up-to-date nicely. But it takes less than a second, anyway.
+ stats_cache => 'SELECT update_stats_cache_full()',
);
-sub logrotate {
- my $dir = sprintf '%s/old', $VNDB::M{log_dir};
- mkdir $dir if !-d $dir;
-
- for (glob sprintf '%s/*', $VNDB::M{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', $VNDB::M{log_dir}, $f;
- open my $O, '>:gzip', $n;
- print $O $_ while <$I>;
- close $O;
- close $I;
- open $I, '>', sprintf '%s/%s', $VNDB::M{log_dir}, $f;
- close $I;
- }
- AE::log info => 'Logs rotated.';
-}
-
-
sub run_monthly {
my($d, $sub) = @_;
pg_cmd $monthlies{$d}, undef, sub {
@@ -180,74 +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;
-
-__END__
-
-# Shouldn't really be necessary, except c_changes could be slightly off when
-# hiding/unhiding DB items.
-# This query takes almost two hours to complete and tends to bring the entire
-# site down with it, so it's been disabled for now. Can be performed in
-# ranges though.
-UPDATE users SET
- c_votes = COALESCE(
- (SELECT COUNT(vid)
- FROM votes
- WHERE uid = users.id
- GROUP BY uid
- ), 0),
- c_changes = COALESCE(
- (SELECT COUNT(id)
- FROM changes
- WHERE requester = users.id
- GROUP BY requester
- ), 0),
- c_tags = COALESCE(
- (SELECT COUNT(tag)
- FROM tags_vn
- WHERE uid = users.id
- GROUP BY uid
- ), 0)
diff --git a/lib/Multi/PlayAsia.pm b/lib/Multi/PlayAsia.pm
new file mode 100644
index 00000000..8a3249b8
--- /dev/null
+++ b/lib/Multi/PlayAsia.pm
@@ -0,0 +1,127 @@
+package Multi::PlayAsia;
+
+use strict;
+use warnings;
+use Multi::Core;
+use AnyEvent::HTTP;
+use VNDB::Config;
+
+my %C = (
+ api => '',
+ gtin_timeout => 1*60,
+ info_timeout => 3*60,
+ sync_gtin_timeout => 24*3600,
+);
+
+
+sub run {
+ shift;
+ $C{ua} = sprintf 'VNDB.org Affiliate Crawler (Multi v%s; contact@vndb.org)', config->{version};
+ %C = (%C, @_);
+
+ push_watcher schedule 0, $C{sync_gtin_timeout}, \&sync_gtin;
+ push_watcher schedule 0, $C{gtin_timeout}, \&syncpax;
+ push_watcher schedule 0, $C{info_timeout}, \&syncinfo;
+}
+
+
+sub sync_gtin {
+ pg_cmd q{
+ INSERT INTO shop_playasia_gtin (gtin)
+ SELECT DISTINCT r.gtin
+ FROM releases r
+ WHERE r.gtin <> 0
+ AND NOT r.hidden
+ AND NOT EXISTS(SELECT 1 FROM shop_playasia_gtin spg WHERE spg.gtin = r.gtin)};
+ pg_cmd q{
+ DELETE FROM shop_playasia_gtin spg WHERE NOT EXISTS(
+ SELECT 1 FROM releases r WHERE r.gtin = spg.gtin AND NOT r.hidden)};
+}
+
+
+sub pa_expect {
+ my($body, $hdr, $prefix) = @_;
+
+ if($hdr->{Status} !~ /^2/) {
+ AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}";
+ return 1;
+ }
+
+ my $errorstr = $body =~ s/<errorstring>\s*([^<]+)\s*<\/errorstring>// ? $1 : undef;
+ if($errorstr && !($body =~ /paxfrombarcode/ && $errorstr =~ /Unknown error/)) {
+ AE::log warn => "$prefix ERROR: $errorstr";
+ return 1;
+ }
+
+ return 0;
+}
+
+
+sub getpax {
+ my $bc = shift;
+ my $ts = AE::now;
+ http_get "$C{api}&query=paxfrombarcode&bc=$bc", headers => {'User-Agent' => $C{ua} }, timeout => 60,
+ sub {
+ my($body, $hdr) = @_;
+ my $time = AE::now-$ts;
+ my $prefix = sprintf '[%.1fs] paxfrombarcode[%s]', $time, $bc;
+ return if pa_expect $body, $hdr, $prefix;
+
+ my @pax;
+ push @pax, $1 while ($body =~ s/<pax>\s*([^<]+)\s*<\/pax>//);
+ AE::log debug => "$prefix Got new paxes: @pax";
+
+ pg_cmd 'UPDATE shop_playasia_gtin SET lastfetch = NOW() WHERE gtin = $1', [ $bc ];
+ pg_cmd 'INSERT INTO shop_playasia (pax, gtin) VALUES ($1, $2) ON CONFLICT DO NOTHING', [ $_, $bc ] for (@pax);
+ pg_cmd 'DELETE FROM shop_playasia WHERE gtin = $1', [ $bc ] if !@pax;
+ my $lst = join ',', map "\$$_", 2..(@pax+1);
+ pg_cmd "DELETE FROM shop_playasia WHERE gtin = \$1 AND pax NOT IN($lst)", [ $bc, @pax ] if @pax;
+ };
+}
+
+
+sub syncpax {
+ pg_cmd 'SELECT gtin FROM shop_playasia_gtin ORDER BY lastfetch ASC NULLS FIRST LIMIT 1', [],
+ sub {
+ my($res) = @_;
+ return if pg_expect $res, 1 or !$res->nRows;
+ getpax $res->value(0,0);
+ }
+}
+
+
+
+sub getinfo {
+ my $pax = shift;
+ my $ts = AE::now;
+ http_get "$C{api}&query=info&pax=$pax&mask=aps", headers => {'User-Agent' => $C{ua} }, timeout => 60,
+ sub {
+ my($body, $hdr) = @_;
+ my $time = AE::now-$ts;
+ my $prefix = sprintf '[%.1fs] info[%s]', $time, $pax;
+ return if pa_expect $body, $hdr, $prefix;
+
+ my $url = $body =~ /<affiliate_url>\s*([^<]+)\s*<\/affiliate_url>/ ? $1 : '';
+ my $onsale = $body =~ /<on_sale>\s*yes/ ? 't' : 'f';
+ my $price = $url && $onsale eq 't'
+ && $body =~ /<price>\s*(\d+(?:\.\d+)?)\s*<\/price>/ && $1 ? sprintf('US$ %.2f', $1) : '';
+
+ AE::log debug => "$prefix got price='$price' onsale=$onsale url=$url";
+ pg_cmd
+ q{UPDATE shop_playasia SET url = $2, price = $3, lastfetch = NOW() WHERE pax = $1},
+ [ $pax, $url, $price ];
+ };
+}
+
+
+sub syncinfo {
+ pg_cmd 'SELECT pax FROM shop_playasia ORDER BY lastfetch ASC NULLS FIRST LIMIT 1', [],
+ sub {
+ my $res = shift;
+ return if pg_expect $res, 1 or !$res->nRows;
+ getinfo $res->value(0,0);
+ };
+}
+
+
+1;
diff --git a/lib/Multi/RG.pm b/lib/Multi/RG.pm
deleted file mode 100644
index 4234d1c2..00000000
--- a/lib/Multi/RG.pm
+++ /dev/null
@@ -1,346 +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;
-
-
-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} = [ $VNDB::S{ $C{type} eq 'v' ? 'vn_relations' : 'prod_relations' }{$xrel}[0], $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] = $VNDB::S{vn_relations}{$_->[2]}[0];
- }
- my $rel = $VNDB::S{vn_relations}{$_->[2]}[1];
- my $rev = $VNDB::S{vn_relations}{ $VNDB::S{vn_relations}{$_->[2]}[0] }[1];
- 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),
- $VNDB::S{languages}{$n->{lang}}, $VNDB::S{producer_types}{$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 = $VNDB::S{prod_relations}{$rels->{$_}[0]}[1];
- my $rev = $VNDB::S{prod_relations}{ $VNDB::S{prod_relations}{$rels->{$_}[0]}[0] }[1];
- 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
new file mode 100644
index 00000000..44f49a43
--- /dev/null
+++ b/lib/Multi/Wikidata.pm
@@ -0,0 +1,116 @@
+
+#
+# Multi::Wikidata - Fetches information from wikidata
+#
+
+package Multi::Wikidata;
+
+use strict;
+use warnings;
+use Multi::Core;
+use JSON::XS 'decode_json';
+use AnyEvent::HTTP;
+use VNDB::Config;
+use VNDB::ExtLinks;
+
+
+my %C = (
+ check_timeout => 30, # Check & fetch for entries to update every 30 seconds
+ fetch_number => 50, # Number of entries to fetch in a single API call
+ fetch_interval => 24*3600, # Minimum delay between updates of a single entry
+ api_endpoint => 'https://www.wikidata.org/w/api.php',
+);
+
+
+sub run {
+ shift;
+ $C{ua} = sprintf 'VNDB.org Crawler (Multi v%s; contact@vndb.org)', config->{version};
+ %C = (%C, @_);
+
+ push_watcher schedule 0, $C{check_timeout}, \&fetch;
+}
+
+
+sub fetch {
+ pg_cmd q{
+ SELECT id
+ FROM wikidata
+ WHERE id IN(
+ SELECT l_wikidata FROM producers WHERE l_wikidata IS NOT NULL AND NOT hidden
+ UNION SELECT l_wikidata FROM staff WHERE l_wikidata IS NOT NULL AND NOT hidden
+ UNION SELECT l_wikidata FROM vn WHERE l_wikidata IS NOT NULL AND NOT hidden)
+ AND (lastfetch IS NULL OR lastfetch < date_trunc('hour', now()-($1 * '1 second'::interval)))
+ ORDER BY lastfetch NULLS FIRST
+ LIMIT $2
+ }, [ $C{fetch_interval}, $C{fetch_number} ], sub {
+ my($res) = @_;
+ return if pg_expect $res, 1 or !$res->nRows;
+ my @ids = map $res->value($_,0), 0..($res->nRows-1);
+
+ my $ids_q = join '|', map "Q$_", @ids;
+ my $ts = AE::now;
+ http_get "$C{api_endpoint}?action=wbgetentities&format=json&props=sitelinks|claims&sitefilter=enwiki|jawiki&ids=$ids_q",
+ 'User-Agent' => $C{ua},
+ timeout => 60,
+ sub { process(\@ids, $ids_q, $ts, @_) }
+ }
+}
+
+
+# property_id -> [ column name, sql element type ]
+my %props =
+ map +($VNDB::ExtLinks::WIKIDATA{$_}{property}, [ $_, $VNDB::ExtLinks::WIKIDATA{$_}{type} =~ s/\[\]$//r ]),
+ grep $VNDB::ExtLinks::WIKIDATA{$_}{property}, keys %VNDB::ExtLinks::WIKIDATA;
+
+
+sub process {
+ my($ids, $ids_q, $ts, $body, $hdr) = @_;
+
+ # Just update lastfetch even if we have some kind of error further on. This
+ # makes sure we at least don't get into an error loop on the same entry.
+ my $n = 1;
+ my $ids_where = join ',', map sprintf('$%d', $n++), @$ids;
+ pg_cmd "UPDATE wikidata SET lastfetch = NOW() WHERE id IN($ids_where)", $ids;
+
+ return AE::log warn => "$ids_q Http error: $hdr->{Status} $hdr->{Reason}"
+ if $hdr->{Status} !~ /^2/;
+
+ my $data = eval { decode_json $body };
+ return AE::log warn => "$ids_q Error decoding JSON: $@" if !$data;
+
+ save($_, $ts, $data->{entities}{"Q$_"}) for @$ids;
+}
+
+
+sub save {
+ my($id, $ts, $data) = @_;
+
+ my @set = ( 'enwiki = $2', 'jawiki = $3');
+ my @val = ($id, $data->{sitelinks}{enwiki}{title}, $data->{sitelinks}{jawiki}{title});
+
+ for my $p (sort keys %props) {
+ my @v;
+ for (@{$data->{claims}{$p}}) {
+ my $v = $_->{mainsnak}{datavalue}{value};
+ if(ref $v) {
+ AE::log warn => "Q$id has a non-scalar value for '$p'";
+ } 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;
+ push @v, sprintf '$%d::%s', scalar @val, $props{$p}[1];
+ }
+ }
+
+ push @set, @v
+ ? sprintf '%s = ARRAY[%s]', $props{$p}[0], join ',', @v
+ : "$props{$p}[0] = NULL";
+ }
+
+ my $set = join ', ', @set;
+
+ pg_cmd "UPDATE wikidata SET $set WHERE id = \$1", \@val;
+ AE::log info => sprintf "Q%d in %.1fs with %d vals", $id, AE::now()-$ts, -1+scalar grep defined($_), @val;
+}
+
+1;
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 3f623405..950dcb8b 100644
--- a/lib/VNDB/BBCode.pm
+++ b/lib/VNDB/BBCode.pm
@@ -1,13 +1,17 @@
package VNDB::BBCode;
-use strict;
+use v5.26;
use warnings;
use Exporter 'import';
use TUWF::XML 'xml_escape';
-our @EXPORT = qw/bb2html bb2text/;
+our @EXPORT = qw/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/;
# 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/;
# 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 { () } }
@@ -56,7 +69,7 @@ sub _link {
return ('link') if $match =~ /^[hf]t/;
# Now we're left with various forms of IDs, just need to make sure it's not surrounded by word characters
- return ('dblink') if $char_pre !~ /\w/ && $char_post !~ /\w/;
+ return ('dblink') if $char_pre !~ /[\w-]/ && $char_post !~ /[\w-]/;
();
}
@@ -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,105 +173,149 @@ 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;
+ !$opt{maxlength} || $length < $opt{maxlength};
};
$ret;
}
-# Convert bbcode into plain text, stripping all tags and spoilers. [url] tags
-# only display the title.
-sub bb2text {
- my $input = shift;
+# Turn (most) 'dblink's into [url=..] links. This function relies on TUWF to do
+# the database querying, so can't be used from Multi.
+# Doesn't handle:
+# - d+, t+, r+ and u+ links
+# - item revisions
+sub bb_subst_links {
+ my $msg = shift;
- 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)$/;
- }
+ # Parse a message and create an index of links to resolve
+ my %lookup;
+ parse $msg, sub {
+ my($code, $tag) = @_;
+ $lookup{$1} = 1 if $tag eq 'dblink' && $code =~ /^([vcpgis]\d+)$/;
1;
};
- $ret;
+ return $msg unless %lookup;
+
+ 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
+ my $result = '';
+ parse $msg, sub {
+ my($code, $tag) = @_;
+ $result .= $tag eq 'dblink' && $links{$code}
+ ? sprintf '[url=/%s]%s[/url]', $code, $links{$code}
+ : $code;
+ 1;
+ };
+ return $result;
}
diff --git a/lib/VNDB/Config.pm b/lib/VNDB/Config.pm
new file mode 100644
index 00000000..050a0124
--- /dev/null
+++ b/lib/VNDB/Config.pm
@@ -0,0 +1,77 @@
+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 = {
+ gen_path => $GEN,
+ var_path => $VAR,
+
+ url => 'http://localhost:3000',
+
+ tuwf => {
+ db_login => [ 'dbi:Pg:dbname=vndb', 'vndb_site', undef ],
+ cookie_prefix => 'vndb_',
+ },
+
+ skin_default => 'angel',
+ 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
+ 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 => {},
+ Maintenance => {},
+ },
+};
+
+
+my $config_file = -e "$VAR/conf.pl" ? do("$VAR/conf.pl") || die $! : {};
+my $config_merged;
+
+sub config {
+ $config_merged ||= do {
+ my $c = $config;
+ $c->{$_} = $config_file->{$_} for grep !/^(Multi|tuwf)$/, keys %$config_file;
+ $c->{Multi}{$_} = $config_file->{Multi}{$_} for keys %{ $config_file->{Multi} || {} };
+ $c->{tuwf}{$_} = $config_file->{tuwf}{$_} for keys %{ $config_file->{tuwf} || {} };
+
+ $c->{url_static} ||= $c->{url};
+ $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} ||= $VAR.'/log';
+ $c
+ };
+ $config_merged
+}
+
+1;
+
diff --git a/lib/VNDB/DB/Affiliates.pm b/lib/VNDB/DB/Affiliates.pm
deleted file mode 100644
index 94dfd198..00000000
--- a/lib/VNDB/DB/Affiliates.pm
+++ /dev/null
@@ -1,73 +0,0 @@
-
-package VNDB::DB::Affiliates;
-
-use strict;
-use warnings;
-use POSIX 'strftime';
-use Exporter 'import';
-
-our @EXPORT = qw|dbAffiliateGet dbAffiliateEdit dbAffiliateDel dbAffiliateAdd|;
-
-
-# options: id rids affiliate hidden sort reverse
-# what: release
-sub dbAffiliateGet {
- my($self, %o) = @_;
- $o{sort} ||= 'id';
- $o{reverse} //= 0;
-
- my %where = (
- $o{id} ? ('id = ?' => $o{id}) : (),
- $o{rids} ? ('rid IN(!l)' => [$o{rids}]) : (),
- defined($o{affiliate}) ? ('affiliate = ?' => $o{affiliate}) : (),
- defined($o{hidden}) ? ('!s af.hidden' => $o{hidden} ? '' : 'NOT') : (),
- );
-
- my $join = $o{what} ? 'JOIN releases r ON r.id = af.rid' : '';
- my $select = $o{what} ? ', r.title' : '';
-
- my $order = sprintf {
- id => 'af.id %s',
- rel => 'r.title %s',
- prio => 'af.priority %s',
- url => 'af.url %s',
- lastfetch => 'af.lastfetch %s',
- }->{$o{sort}}, $o{reverse} ? 'DESC' : 'ASC';
-
- return $self->dbAll(qq|
- SELECT af.id, af.rid, af.hidden, af.priority, af.affiliate, af.url, af.version,
- extract('epoch' from af.lastfetch) as lastfetch, af.price, af.data$select
- FROM affiliate_links af
- $join
- !W
- ORDER BY !s|, \%where, $order);
-}
-
-
-sub dbAffiliateDel {
- my($self, $id) = @_;
- $self->dbExec('DELETE FROM affiliate_links WHERE id = ?', $id);
-}
-
-
-sub dbAffiliateEdit {
- my($self, $id, %ops) = @_;
- my %set;
- exists($ops{$_}) && ($set{"$_ = ?"} = $ops{$_}) for(qw|rid priority hidden affiliate url version price data|);
- $set{"lastfetch = TIMESTAMP WITH TIME ZONE 'epoch' + ? * INTERVAL '1 second'"} = $ops{lastfetch} || $ops{lastfetch} eq '0' ? $ops{lastfetch} : undef if exists $ops{lastfetch};
- return if !keys %set;
- $self->dbExec('UPDATE affiliate_links !H WHERE id = ?', \%set, $id);
-}
-
-
-sub dbAffiliateAdd {
- my($self, %ops) = @_;
- $self->dbExec(q|INSERT INTO affiliate_links (rid, priority, hidden, affiliate, url, version, price, data, lastfetch)
- VALUES(!l, TIMESTAMP WITH TIME ZONE 'epoch' + ? * INTERVAL '1 second')|,
- [@ops{qw| rid priority hidden affiliate url version price data|}],
- $ops{lastfetch} || $ops{lastfetch} eq '0' ? $ops{lastfetch} : undef);
-}
-
-
-1;
-
diff --git a/lib/VNDB/DB/Chars.pm b/lib/VNDB/DB/Chars.pm
deleted file mode 100644
index 23953028..00000000
--- a/lib/VNDB/DB/Chars.pm
+++ /dev/null
@@ -1,199 +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} ) : (),
- $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.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.requester, c.comments, u.username, c.rev, c.ihid, c.ilock';
- $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.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 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 0e7f0623..00000000
--- a/lib/VNDB/DB/Discussions.pm
+++ /dev/null
@@ -1,354 +0,0 @@
-
-package VNDB::DB::Discussions;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|dbThreadGet dbThreadEdit dbThreadAdd dbPostGet dbPostEdit dbPostAdd dbThreadCount dbPollStats dbPollVote|;
-
-
-# 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/ ? ('tpl.uid AS luid', q|EXTRACT('epoch' from tpl.date) AS ldate|, 'ul.username AS lusername') : (),
- $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 $x = $r->[$r{$_->{tid}}]; $x->{fuid} = $_->{uid}; $x->{fdate} = $_->{date}; $x->{fusername} = $_->{username} } for (@{$self->dbAll(q|
- SELECT tpf.tid, tpf.uid, EXTRACT('epoch' from tpf.date) AS date, uf.username
- FROM threads_posts tpf
- JOIN users uf ON tpf.uid = uf.id
- WHERE tpf.num = 1 AND tpf.tid IN(!l)|,
- [ 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;
-}
-
-
-# id, %options->( title locked hidden private boards poll_question poll_max_options poll_preview poll_recast poll_options }
-# The poll_{question,options,max_options} fields should not be set when there
-# are no changes to the poll info. Either all or none of these fields should be
-# set.
-sub dbThreadEdit {
- my($self, $id, %o) = @_;
-
- my %set = (
- 'title = ?' => $o{title},
- 'locked = ?' => $o{locked}?1:0,
- 'hidden = ?' => $o{hidden}?1:0,
- 'private = ?' => $o{private}?1:0,
- 'poll_preview = ?' => $o{poll_preview}?1:0,
- 'poll_recast = ?' => $o{poll_recast}?1:0,
- exists $o{poll_question} ? (
- 'poll_question = ?' => $o{poll_question}||undef,
- 'poll_max_options = ?' => $o{poll_max_options}||1,
- ) : (),
- );
-
- $self->dbExec(q|
- UPDATE threads
- !H
- WHERE id = ?|,
- \%set, $id);
-
- if($o{boards}) {
- $self->dbExec('DELETE FROM threads_boards WHERE tid = ?', $id);
- $self->dbExec(q|
- INSERT INTO threads_boards (tid, type, iid)
- VALUES (?, ?, ?)|,
- $id, $_->[0], $_->[1]||0
- ) for (@{$o{boards}});
- }
-
- if(exists $o{poll_question}) {
- $self->dbExec('DELETE FROM threads_poll_options WHERE tid = ?', $id);
- $self->dbExec(q|
- INSERT INTO threads_poll_options (tid, option)
- VALUES (?, ?)|,
- $id, $_
- ) for (@{$o{poll_options}});
- }
-}
-
-
-# %options->{ title hidden locked private boards poll_stuff }
-sub dbThreadAdd {
- my($self, %o) = @_;
-
- my $id = $self->dbRow(q|
- INSERT INTO threads (title, hidden, locked, private, poll_question, poll_max_options, poll_preview, poll_recast)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- RETURNING id|,
- $o{title}, $o{hidden}?1:0, $o{locked}?1:0, $o{private}?1:0, $o{poll_question}||undef, $o{poll_max_options}||1, $o{poll_preview}?1:0, $o{poll_recast}?1:0
- )->{id};
-
- $self->dbExec(q|
- INSERT INTO threads_boards (tid, type, iid)
- VALUES (?, ?, ?)|,
- $id, $_->[0], $_->[1]||0
- ) for (@{$o{boards}});
-
- $self->dbExec(q|
- INSERT INTO threads_poll_options (tid, option)
- VALUES (?, ?)|,
- $id, $_
- ) for ($o{poll_question} ? @{$o{poll_options}} : ());
-
- return $id;
-}
-
-
-# Returns thread count of a specific item board
-# Arguments: type, iid
-sub dbThreadCount {
- my($self, $type, $iid) = @_;
- return $self->dbRow(q|
- SELECT COUNT(*) AS cnt
- FROM threads_boards tb
- JOIN threads t ON t.id = tb.tid
- WHERE tb.type = ? AND tb.iid = ?
- AND t.hidden = FALSE|,
- $type, $iid)->{cnt};
-}
-
-
-# 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{search} ? (
- 'bb_tsvector(msg) @@ to_tsquery(?)' => $o{search}) : (),
- $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/ ? qw|tp.uid u.username| : (),
- $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
- );
-
- # Get headlines in a separate query
- if($o{search} && @$r) {
- my %r = map {
- ($r->[$_]{tid}.'.'.$r->[$_]{num}, $_)
- } 0..$#$r;
- my $where = join ' or ', ('(tid = ? and num = ?)')x@$r;
- my @where = map +($_->{tid},$_->{num}), @$r;
- my $h = join ',', map "$_=$o{headline}{$_}", $o{headline} ? keys %{$o{headline}} : ();
-
- $r->[$r{$_->{tid}.'.'.$_->{num}}]{headline} = $_->{headline} for (@{$self->dbAll(qq|
- SELECT tid, num, ts_headline('english', strip_bb_tags(strip_spoilers(msg)), to_tsquery(?), ?) as headline
- FROM threads_posts
- WHERE $where|,
- $o{search}, $h, @where
- )});
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# tid, num, %options->{ num msg hidden lastmod }
-sub dbPostEdit {
- my($self, $tid, $num, %o) = @_;
-
- my %set = (
- 'msg = ?' => $o{msg},
- 'edited = to_timestamp(?)' => $o{lastmod},
- 'hidden = ?' => $o{hidden}?1:0,
- );
-
- $self->dbExec(q|
- UPDATE threads_posts
- !H
- WHERE tid = ?
- AND num = ?|,
- \%set, $tid, $num
- );
-}
-
-
-# tid, %options->{ uid msg }
-sub dbPostAdd {
- my($self, $tid, %o) = @_;
-
- my $num = $self->dbRow('SELECT num FROM threads_posts WHERE tid = ? ORDER BY num DESC LIMIT 1', $tid)->{num};
- $num = $num ? $num+1 : 1;
- $o{uid} ||= $self->authInfo->{id};
-
- $self->dbExec(q|
- INSERT INTO threads_posts (tid, num, uid, msg)
- VALUES(?, ?, ?, ?)|,
- $tid, $num, @o{qw| uid msg |}
- );
- $self->dbExec(q|
- UPDATE threads
- SET count = count+1
- WHERE id = ?|,
- $tid);
-
- return $num;
-}
-
-
-# Args: tid
-# Returns: num_users, poll_stats, user_voted_options
-sub dbPollStats {
- my($self, $tid) = @_;
- my $uid = $self->authInfo->{id};
-
- my $num_users = $self->dbRow('SELECT COUNT(DISTINCT uid) AS votes FROM threads_poll_votes WHERE tid = ?', $tid)->{votes} || 0;
-
- my $stats = !$num_users ? {} : { map +($_->{optid}, $_->{votes}), @{$self->dbAll(
- 'SELECT optid, COUNT(optid) AS votes FROM threads_poll_votes WHERE tid = ? GROUP BY optid', $tid
- )} };
-
- my $user = !$num_users || !$uid ? [] : [
- map $_->{optid}, @{$self->dbAll('SELECT optid FROM threads_poll_votes WHERE tid = ? AND uid = ?', $tid, $uid)}
- ];
-
- return $num_users, $stats, $user;
-}
-
-
-sub dbPollVote {
- my($self, $tid, $uid, @opts) = @_;
-
- $self->dbExec('DELETE FROM threads_poll_votes WHERE tid = ? AND uid = ?', $tid, $uid);
- $self->dbExec('INSERT INTO threads_poll_votes (tid, uid, optid) VALUES (?, ?, ?)',
- $tid, $uid, $_) for @opts;
-}
-
-1;
diff --git a/lib/VNDB/DB/Docs.pm b/lib/VNDB/DB/Docs.pm
deleted file mode 100644
index 27cabf6e..00000000
--- a/lib/VNDB/DB/Docs.pm
+++ /dev/null
@@ -1,53 +0,0 @@
-
-package VNDB::DB::Docs;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|dbDocGet dbDocGetRev dbDocRevisionInsert|;
-
-
-# Can only fetch a single document.
-# $doc = $self->dbDocGet(id => $id);
-sub dbDocGet {
- my $self = shift;
- my %o = @_;
-
- my $r = $self->dbAll('SELECT id, title, content FROM docs WHERE id = ?', $o{id});
- return wantarray ? ($r, 0) : $r;
-}
-
-
-# options: id, rev
-sub dbDocGetRev {
- my $self = shift;
- my %o = @_;
-
- $o{rev} ||= $self->dbRow('SELECT MAX(rev) AS rev FROM changes WHERE type = \'d\' AND itemid = ?', $o{id})->{rev};
-
- my $r = $self->dbAll(q|
- SELECT de.id, d.title, d.content, de.hidden, de.locked,
- extract('epoch' from c.added) as added, c.requester, c.comments, u.username, c.rev, c.ihid, c.ilock, 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
- FROM changes c
- JOIN docs de ON de.id = c.itemid
- JOIN docs_hist d ON d.chid = c.id
- JOIN users u ON u.id = c.requester
- WHERE c.type = 'd' AND c.itemid = ? AND c.rev = ?|,
- $o{id}, $o{rev}
- );
- return wantarray ? ($r, 0) : $r;
-}
-
-
-# Updates the edit_* tables, used from dbItemEdit()
-# Arguments: { title content },
-sub dbDocRevisionInsert {
- my($self, $o) = @_;
- my %set = map exists($o->{$_}) ? (qq|"$_" = ?|, $o->{$_}) : (), qw|title content|;
- $self->dbExec('UPDATE edit_docs !H', \%set) if keys %set;
-}
-
-
-1;
diff --git a/lib/VNDB/DB/Misc.pm b/lib/VNDB/DB/Misc.pm
deleted file mode 100644
index 61bb71a2..00000000
--- a/lib/VNDB/DB/Misc.pm
+++ /dev/null
@@ -1,127 +0,0 @@
-
-package VNDB::DB::Misc;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|
- dbStats dbItemEdit dbRevisionGet dbRandomQuote
-|;
-
-
-# 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';
- $self->dbStaffRevisionInsert( \%o) if $type eq 's';
- $self->dbDocRevisionInsert( \%o) if $type eq 'd';
-
- 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.requester, c.comments, c.rev, extract('epoch' from c.added) as added, u.username
- FROM changes c
- JOIN users u ON c.requester = u.id
- !W
- ORDER BY c.id DESC|, \%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 random quote (hashref with keys = vid, quote)
-sub dbRandomQuote {
- return $_[0]->dbRow(q|
- SELECT vid, quote
- FROM quotes
- ORDER BY RANDOM()
- LIMIT 1|);
-}
-
-
-
-
-1;
-
diff --git a/lib/VNDB/DB/Producers.pm b/lib/VNDB/DB/Producers.pm
deleted file mode 100644
index a6a301e5..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.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.requester, c.comments, u.username, c.rev, c.ihid, c.ilock';
- $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, 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 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 e5c63a41..00000000
--- a/lib/VNDB/DB/Releases.pm
+++ /dev/null
@@ -1,260 +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 affiliates
-# 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') : (),
- );
-
- 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 affiliates
-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.requester, c.comments, u.username, c.rev, c.ihid, c.ilock';
- $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';
-
- 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
- patch resolution voiced freeware doujin uncensored ani_story ani_ero engine|;
- $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 bf2ae325..00000000
--- a/lib/VNDB/DB/Staff.pm
+++ /dev/null
@@ -1,196 +0,0 @@
-
-package VNDB::DB::Staff;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|dbStaffGet dbStaffGetRev dbStaffRevisionInsert dbStaffAliasIds|;
-
-# options: results, page, id, aid, search, exact, truename, role, gender
-# what: extended changes roles aliases
-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';
- $select .= ', s.desc, s.l_wp, s.l_site, s.l_twitter, s.l_anidb, s.hidden, s.locked' if $o{what} =~ /extended/;
-
- 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 _enrich($self, $r, $np, 0, $o{what});
-}
-
-
-sub dbStaffGetRev {
- my $self = shift;
- my %o = (what => '', @_);
-
- $o{rev} ||= $self->dbRow('SELECT MAX(rev) AS rev FROM changes WHERE type = \'s\' AND itemid = ?', $o{id})->{rev};
-
- my $select = 'c.itemid AS id, sa.aid, sa.name, sa.original, s.gender, s.lang';
- $select .= ', extract(\'epoch\' from c.added) as added, c.requester, c.comments, u.username, c.rev, c.ihid, c.ilock';
- $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 .= ', s.desc, s.l_wp, s.l_site, s.l_twitter, s.l_anidb, so.hidden, so.locked' if $o{what} =~ /extended/;
-
- my $r = $self->dbAll(q|
- SELECT !s
- FROM changes c
- JOIN staff so ON so.id = c.itemid
- JOIN staff_hist s ON s.chid = c.id
- JOIN staff_alias_hist sa ON sa.chid = c.id AND s.aid = sa.aid
- JOIN users u ON u.id = c.requester
- WHERE c.type = 's' 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) = @_;
-
- # Role info is linked to VN revisions, so is independent of the selected staff revision
- if(@$r && $what =~ /roles/) {
- my %r = map {
- $_->{roles} = [];
- $_->{cast} = [];
- ($_->{id}, $_);
- } @$r;
-
- push @{$r{ delete $_->{id} }{roles}}, $_ for (@{$self->dbAll(q|
- SELECT sa.id, sa.aid, v.id AS vid, sa.name, sa.original, v.c_released, v.title, v.original AS t_original, vs.role, vs.note
- FROM vn_staff vs
- JOIN vn v ON v.id = vs.id
- JOIN staff_alias sa ON vs.aid = sa.aid
- WHERE sa.id IN(!l) AND NOT v.hidden
- ORDER BY v.c_released ASC, v.title ASC, vs.role ASC|, [ keys %r ]
- )});
- push @{$r{ delete $_->{id} }{cast}}, $_ for (@{$self->dbAll(q|
- SELECT sa.id, sa.aid, v.id AS vid, sa.name, sa.original, v.c_released, v.title, v.original AS t_original, c.id AS cid, c.name AS c_name, c.original AS c_original, vs.note
- FROM vn_seiyuu vs
- JOIN vn v ON v.id = vs.id
- JOIN chars c ON c.id = vs.cid
- JOIN staff_alias sa ON vs.aid = sa.aid
- WHERE sa.id IN(!l) AND NOT v.hidden
- ORDER BY v.c_released ASC, v.title ASC|, [ keys %r ]
- )});
- }
-
- if(@$r && $what =~ /aliases/) {
- my ($col, $hist, $colname) = $rev ? ('cid', '_hist', 'chid') : ('id', '', 'id');
- my %r = map {
- $_->{aliases} = [];
- ($_->{$col}, $_);
- } @$r;
-
- push @{$r{ delete $_->{xid} }{aliases}}, $_ for (@{$self->dbAll("
- SELECT s.$colname AS xid, sa.aid, sa.name, sa.original
- FROM staff_alias$hist sa
- JOIN staff$hist s ON s.$colname = sa.$colname
- WHERE s.$colname IN(!l) AND s.aid <> sa.aid
- ORDER BY sa.name ASC", [ keys %r ]
- )});
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Updates the edit_* tables, used from dbItemEdit()
-# Arguments: { columns in staff_rev and staff_alias},
-sub dbStaffRevisionInsert {
- my($self, $o) = @_;
-
- $self->dbExec('DELETE FROM edit_staff_alias');
- if($o->{aid}) {
- $self->dbExec(q|
- INSERT INTO edit_staff_alias (aid, name, original) VALUES (?, ?, ?)|,
- $o->{aid}, $o->{name}, $o->{original});
- } else {
- $o->{aid} = $self->dbRow(q|
- INSERT INTO edit_staff_alias (name, original) VALUES (?, ?) RETURNING aid|,
- $o->{name}, $o->{original})->{aid};
- }
-
- my %staff = map exists($o->{$_}) ? (qq|"$_" = ?|, $o->{$_}) : (),
- qw|aid gender lang desc l_wp l_site l_twitter l_anidb|;
- $self->dbExec('UPDATE edit_staff !H', \%staff) if %staff;
- for my $a (@{$o->{aliases}}) {
- if($a->{aid}) {
- $self->dbExec('INSERT INTO edit_staff_alias (aid, name, original) VALUES (!l)', [ @{$a}{qw|aid name orig|} ]);
- } else {
- $self->dbExec('INSERT INTO edit_staff_alias (name, original) VALUES (?, ?)', $a->{name}, $a->{orig});
- }
- }
-}
-
-
-# returns alias IDs that are and were related to the given staff ID
-sub dbStaffAliasIds {
- my($self, $sid) = @_;
- return $self->dbAll(q|
- SELECT DISTINCT sa.aid
- FROM changes c
- JOIN staff_alias_hist sa ON sa.chid = c.id
- WHERE c.type = 's' AND c.itemid = ?|, $sid);
-}
-
-1;
diff --git a/lib/VNDB/DB/Tags.pm b/lib/VNDB/DB/Tags.pm
deleted file mode 100644
index 97852724..00000000
--- a/lib/VNDB/DB/Tags.pm
+++ /dev/null
@@ -1,285 +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/ ? ('t.addedby', 'u.username') : (),
- );
- 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 u.username t.name|) : (),
- );
-
- 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);
-}
-
-
-# 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 f1c55b24..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/ ? ('t.addedby', 'u.username') : (),
- );
- 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 9c892cf3..00000000
--- a/lib/VNDB/DB/ULists.pm
+++ /dev/null
@@ -1,354 +0,0 @@
-
-package VNDB::DB::ULists;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-
-our @EXPORT = qw|
- dbRListGet dbVNListGet dbVNListList dbVNListAdd dbVNListDel dbRListAdd dbRListDel
- dbVoteGet dbVoteStats dbVoteAdd dbVoteDel
- dbWishListGet dbWishListAdd dbWishListDel
-|;
-
-
-# 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
- );
-}
-
-# Options: uid vid
-sub dbVNListGet {
- my($self, %o) = @_;
-
- my %where = (
- 'uid = ?' => $o{uid},
- $o{vid} ? ('vid IN(!l)' => [ ref $o{vid} ? $o{vid} : [$o{vid}] ]) : (),
- );
-
- return $self->dbAll(q|
- SELECT uid, vid, status
- FROM vnlists
- !W|,
- \%where
- );
-}
-
-
-# Options: uid char voted page results sort reverse
-# sort: title vote
-sub dbVNListList {
- my($self, %o) = @_;
- $o{results} ||= 50;
- $o{page} ||= 1;
-
- my %where = (
- 'vl.uid = ?' => $o{uid},
- defined($o{voted}) ? ('vo.vote !s NULL' => $o{voted} ? 'IS NOT' : 'IS') : (),
- defined($o{status})? ('vl.status = ?' => $o{status}) : (),
- $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 ) : (),
- );
-
- my $order = sprintf {
- title => 'v.title %s',
- vote => 'vo.vote %s NULLS LAST, v.title ASC',
- }->{ $o{sort}||'title' }, $o{reverse} ? 'DESC' : 'ASC';
-
- # execute query
- my($r, $np) = $self->dbPage(\%o, qq|
- SELECT vl.vid, v.title, v.original, vl.status, vl.notes, COALESCE(vo.vote, 0) AS vote
- FROM vnlists vl
- JOIN vn v ON v.id = vl.vid
- LEFT JOIN votes vo ON vo.vid = vl.vid AND vo.uid = vl.uid
- !W
- ORDER BY !s|,
- \%where, $order
- );
-
- # fetch releases and link to VNs
- if(@$r) {
- my %vns = map {
- $_->{rels}=[];
- $_->{vid}, $_->{rels}
- } @$r;
-
- my $rel = $self->dbAll(q|
- SELECT rv.vid, rl.rid, r.title, r.original, r.released, r.type, 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 = ?
- AND rv.vid IN(!l)
- ORDER BY r.released ASC|,
- $o{uid}, [ keys %vns ]
- );
-
- if(@$rel) {
- my %rel = map { $_->{rid} => [] } @$rel;
- push(@{$rel{$_->{id}}}, $_->{lang}) for (@{$self->dbAll(q|
- SELECT id, lang
- FROM releases_lang
- WHERE id IN(!l)|,
- [ keys %rel ]
- )});
- for(@$rel) {
- $_->{languages} = $rel{$_->{rid}};
- push @{$vns{$_->{vid}}}, $_;
- }
- }
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Arguments: uid vid status notes
-# vid can be an arrayref only when the rows are already present, in which case an update is done
-# status and notes can be undef when an update is done, in which case these fields aren't updated
-sub dbVNListAdd {
- my($self, $uid, $vid, $stat, $notes) = @_;
- $self->dbExec(
- 'UPDATE vnlists !H WHERE uid = ? AND vid IN(!l)',
- {defined($stat) ? ('status = ?' => $stat ):(),
- defined($notes)? ('notes = ?' => $notes):()},
- $uid, ref($vid) ? $vid : [ $vid ]
- )
- ||
- $self->dbExec(
- 'INSERT INTO vnlists (uid, vid, status, notes) VALUES(?, ?, ?, ?)',
- $uid, $vid, $stat||0, $notes||''
- );
-}
-
-
-# Arguments: uid, vid
-sub dbVNListDel {
- my($self, $uid, $vid) = @_;
- $self->dbExec(
- 'DELETE FROM vnlists WHERE uid = ? AND vid IN(!l)',
- $uid, ref($vid) ? $vid : [ $vid ]
- );
-}
-
-
-# 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 ]
- );
-}
-
-
-# Options: uid vid hide_ign results page what sort reverse
-# what: user, vn, hide_list
-sub dbVoteGet {
- my($self, %o) = @_;
- $o{results} ||= 50;
- $o{page} ||= 1;
- $o{what} ||= '';
- $o{sort} ||= 'date';
- $o{reverse} //= 1;
-
- my %where = (
- $o{uid} ? ( 'n.uid = ?' => $o{uid} ) : (),
- $o{vid} ? ( 'n.vid = ?' => $o{vid} ) : (),
- $o{hide_ign} ? ( '(NOT u.ign_votes OR u.id = ?)' => $self->authInfo->{id}||0 ) : (),
- $o{vn_char} ? ( 'LOWER(SUBSTR(v.title, 1, 1)) = ?' => $o{vn_char} ) : (),
- defined $o{vn_char} && !$o{vn_char} ? (
- '(ASCII(v.title) < 97 OR ASCII(v.title) > 122) AND (ASCII(v.title) < 65 OR ASCII(v.title) > 90)' => 1 ) : (),
- );
-
- my @select = (
- qw|n.vid n.vote n.uid|, q|extract('epoch' from n.date) as date|,
- $o{what} =~ /user/ ? ('u.username') : (),
- $o{what} =~ /vn/ ? (qw|v.title v.original|) : (),
- $o{what} =~ /hide_list/ ? ('up.uid as hide_list') : (),
- );
-
- my @join = (
- $o{what} =~ /vn/ ? (
- 'JOIN vn v ON v.id = n.vid',
- ) : (),
- $o{what} =~ /user/ || $o{hide} ? (
- 'JOIN users u ON u.id = n.uid'
- ) : (),
- $o{what} =~ /hide_list/ ? (
- 'LEFT JOIN users_prefs up ON up.uid = n.uid AND key = \'hide_list\''
- ) : (),
- );
-
- my $order = sprintf {
- date => 'n.date %s',
- # Hidden users should not be sorted among the rest. as that would still give them away
- username => $o{what} =~ /hide_list/ ? '(CASE WHEN up.uid IS NULL THEN u.username ELSE NULL END) %s, n.date' : 'u.username %s',
- title => 'v.title %s',
- vote => 'n.vote %s'.($o{what} =~ /vn/ ? ', v.title ASC' : $o{what} =~ /user/ ? ', u.username ASC' : ''),
- }->{$o{sort}}, $o{reverse} ? 'DESC' : 'ASC';
-
- my($r, $np) = $self->dbPage(\%o, q|
- SELECT !s
- FROM votes n
- !s
- !W
- ORDER BY !s|,
- join(',', @select), join(' ', @join), \%where, $order
- );
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Arguments: (uid|vid), id, use_ignore_list
-# 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 $u = $self->authInfo->{id};
- 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 votes
- !s
- !W
- GROUP BY (vote::numeric/10)::int|,
- $ign ? 'JOIN users ON id = uid AND (NOT ign_votes'.($u?sprintf(' OR id = %d',$u):'').')' : '',
- $col ? { '!s = ?' => [ $col, $id ] } : {},
- )});
- return $r;
-}
-
-
-# Adds a new vote or updates an existing one
-# Arguments: vid, uid, vote
-# vid can be an arrayref only when the rows are already present, in which case an update is done
-sub dbVoteAdd {
- my($self, $vid, $uid, $vote) = @_;
- $self->dbExec(q|
- UPDATE votes
- SET vote = ?
- WHERE vid IN(!l)
- AND uid = ?|,
- $vote, ref($vid) ? $vid : [$vid], $uid
- ) || $self->dbExec(q|
- INSERT INTO votes
- (vid, uid, vote)
- VALUES (!l)|,
- [ $vid, $uid, $vote ]
- );
-}
-
-
-# Arguments: uid, vid
-# vid can be an arrayref
-sub dbVoteDel {
- my($self, $uid, $vid) = @_;
- $self->dbExec('DELETE FROM votes !W',
- { 'vid IN(!l)' => [ref($vid)?$vid:[$vid]], 'uid = ?' => $uid }
- );
-}
-
-
-# %options->{ uid vid wstat what page results sort reverse }
-# what: vn
-# sort: title added wstat
-sub dbWishListGet {
- my($self, %o) = @_;
-
- $o{page} ||= 1;
- $o{results} ||= 50;
- $o{what} ||= '';
-
- my %where = (
- 'wl.uid = ?' => $o{uid},
- $o{vid} ? ( 'wl.vid = ?' => $o{vid} ) : (),
- defined $o{wstat} ? ( 'wl.wstat = ?' => $o{wstat} ) : (),
- );
-
- my $select = q|wl.vid, wl.wstat, extract('epoch' from wl.added) AS added|;
- my @join;
- if($o{what} =~ /vn/) {
- $select .= ', v.title, v.original';
- push @join, 'JOIN vn v ON v.id = wl.vid';
- }
-
- no if $] >= 5.022, warnings => 'redundant';
- my $order = sprintf {
- title => 'v.title %s',
- added => 'wl.added %s',
- wstat => 'wl.wstat %2$s, v.title ASC',
- }->{ $o{sort}||'added' }, $o{reverse} ? 'DESC' : 'ASC', $o{reverse} ? 'ASC' : 'DESC';
-
- my($r, $np) = $self->dbPage(\%o, q|
- SELECT !s
- FROM wlists wl
- !s
- !W
- ORDER BY !s|,
- $select, join(' ', @join), \%where, $order,
- );
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Updates or adds a whishlist item
-# Arguments: vid, uid, wstat
-sub dbWishListAdd {
- my($self, $vid, $uid, $wstat) = @_;
- $self->dbExec(
- 'UPDATE wlists SET wstat = ? WHERE uid = ? AND vid IN(!l)',
- $wstat, $uid, ref($vid) eq 'ARRAY' ? $vid : [ $vid ]
- )
- ||
- $self->dbExec(
- 'INSERT INTO wlists (uid, vid, wstat) VALUES(!l)',
- [ $uid, $vid, $wstat ]
- );
-}
-
-
-# Arguments: uid, vids
-sub dbWishListDel {
- my($self, $uid, $vid) = @_;
- $self->dbExec(
- 'DELETE FROM wlists WHERE uid = ? AND vid IN(!l)',
- $uid, ref($vid) eq 'ARRAY' ? $vid : [ $vid ]
- );
-}
-
-
-1;
-
diff --git a/lib/VNDB/DB/Users.pm b/lib/VNDB/DB/Users.pm
deleted file mode 100644
index 84ff10f2..00000000
--- a/lib/VNDB/DB/Users.pm
+++ /dev/null
@@ -1,283 +0,0 @@
-
-package VNDB::DB::Users;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|
- dbUserGet dbUserEdit dbUserAdd dbUserDel dbUserPrefSet dbUserLogin dbUserLogout
- dbUserUpdateLastUsed dbUserEmailExists dbUserGetMail dbUserSetMail dbUserSetPerm dbUserAdminSetPass
- dbUserResetPass dbUserIsValidToken dbUserSetPass
- dbNotifyGet dbNotifyMarkRead dbNotifyRemove
- dbThrottleGet dbThrottleSet
-|;
-
-
-# %options->{ username session uid ip registered search results page what sort reverse notperm }
-# what: notifycount stats scryptargs extended prefs hide_list
-# sort: username registered votes changes tags
-sub dbUserGet {
- my $s = shift;
- my %o = (
- page => 1,
- results => 10,
- what => '',
- sort => '',
- @_
- );
-
- my $token = unpack 'H*', $o{session}||'';
- $o{search} =~ s/%// if $o{search};
- my %where = (
- $o{username} ? (
- 'username = ?' => $o{username} ) : (),
- $o{firstchar} ? (
- 'SUBSTRING(username from 1 for 1) = ?' => $o{firstchar} ) : (),
- !$o{firstchar} && defined $o{firstchar} ? (
- 'ASCII(username) < 97 OR ASCII(username) > 122' => 1 ) : (),
- $o{uid} && !ref($o{uid}) ? (
- 'id = ?' => $o{uid} ) : (),
- $o{uid} && ref($o{uid}) ? (
- 'id IN(!l)' => [ $o{uid} ]) : (),
- !$o{uid} && !$o{username} ? (
- 'id > 0' => 1 ) : (),
- $o{ip} ? (
- 'ip !s ?' => [ $o{ip} =~ /\// ? '<<' : '=', $o{ip} ] ) : (),
- $o{registered} ? (
- 'registered > to_timestamp(?)' => $o{registered} ) : (),
- $o{search} ? (
- 'username ILIKE ?' => "%$o{search}%") : (),
- $token ? (
- q|user_isloggedin(id, decode(?, 'hex')) IS NOT NULL| => $token ) : (),
- $o{notperm} ? (
- 'perm & ~(?::smallint) > 0' => $o{notperm} ) : (),
- );
-
- my @select = (
- qw|id username c_votes c_changes c_tags|,
- q|extract('epoch' from registered) as registered|,
- $o{what} =~ /extended/ ? qw|perm ign_votes| : (), # mail
- $o{what} =~ /hide_list/ ? 'up.value AS hide_list' : (),
- $o{what} =~ /scryptargs/ ? 'user_getscryptargs(id) AS scryptargs' : (),
- $o{what} =~ /notifycount/ ?
- '(SELECT COUNT(*) FROM notifications WHERE uid = u.id AND read IS NULL) AS notifycount' : (),
- $o{what} =~ /stats/ ? (
- '(SELECT COUNT(*) FROM rlists WHERE uid = u.id) AS releasecount',
- '(SELECT COUNT(*) FROM vnlists WHERE uid = u.id) AS vncount',
- '(SELECT COUNT(*) FROM threads_posts WHERE uid = u.id) AS postcount',
- '(SELECT COUNT(*) FROM threads_posts WHERE uid = u.id AND num = 1) AS threadcount',
- '(SELECT COUNT(DISTINCT tag) FROM tags_vn WHERE uid = u.id) AS tagcount',
- '(SELECT COUNT(DISTINCT vid) FROM tags_vn WHERE uid = u.id) AS tagvncount',
- ) : (),
- $token ? qq|extract('epoch' from user_isloggedin(id, decode('$token', 'hex'))) as session_lastused| : (),
- );
-
- my @join = (
- $o{what} =~ /hide_list/ || $o{sort} eq 'votes' ?
- "LEFT JOIN users_prefs up ON up.uid = u.id AND up.key = 'hide_list'" : (),
- );
-
- my $order = sprintf {
- id => 'u.id %s',
- username => 'u.username %s',
- registered => 'u.registered %s',
- votes => 'up.value NULLS FIRST, u.c_votes %s',
- changes => 'u.c_changes %s',
- tags => 'u.c_tags %s',
- }->{ $o{sort}||'username' }, $o{reverse} ? 'DESC' : 'ASC';
-
- my($r, $np) = $s->dbPage(\%o, q|
- SELECT !s
- FROM users u
- !s
- !W
- ORDER BY !s|,
- join(', ', @select), join(' ', @join), \%where, $order
- );
-
- if(@$r && $o{what} =~ /prefs/) {
- my %r = map {
- $r->[$_]{prefs} = {};
- ($r->[$_]{id}, $r->[$_])
- } 0..$#$r;
-
- $r{$_->{uid}}{prefs}{$_->{key}} = $_->{value} for (@{$s->dbAll(q|
- SELECT uid, key, value
- FROM users_prefs
- WHERE uid IN(!l)|,
- [ keys %r ]
- )});
- }
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# uid, %options->{ columns in users table }
-sub dbUserEdit {
- my($s, $uid, %o) = @_;
-
- my %h;
- defined $o{$_} && ($h{$_.' = ?'} = $o{$_})
- for (qw| username ign_votes email_confirmed |);
-
- return if scalar keys %h <= 0;
- return $s->dbExec(q|
- UPDATE users
- !H
- WHERE id = ?|,
- \%h, $uid);
-}
-
-
-# username, mail, [ip]
-sub dbUserAdd {
- $_[0]->dbRow(q|INSERT INTO users (username, mail, ip) VALUES(?, ?, ?) RETURNING id|, $_[1], $_[2], $_[3]||$_[0]->reqIP)->{id};
-}
-
-
-# uid
-sub dbUserDel {
- $_[0]->dbExec(q|DELETE FROM users WHERE id = ?|, $_[1]);
-}
-
-
-# uid, key, val
-sub dbUserPrefSet {
- my($s, $uid, $key, $val) = @_;
- !$val ? $s->dbExec('DELETE FROM users_prefs WHERE uid = ? AND key = ?', $uid, $key)
- : $s->dbExec('UPDATE users_prefs SET value = ? WHERE uid = ? AND key = ?', $val, $uid, $key)
- || $s->dbExec('INSERT INTO users_prefs (uid, key, value) VALUES (?, ?, ?)', $uid, $key, $val);
-}
-
-
-# uid, encpass, token
-sub dbUserLogin {
- $_[0]->dbRow(
- q|SELECT user_login(?, decode(?, 'hex'), decode(?, 'hex')) AS r|,
- $_[1], unpack('H*', $_[2]), unpack('H*', $_[3])
- )->{r}||0;
-}
-
-
-# uid, token
-sub dbUserLogout {
- $_[0]->dbExec(q|SELECT user_logout(?, decode(?, 'hex'))|, $_[1], unpack 'H*', $_[2]);
-}
-
-
-# uid, token
-sub dbUserUpdateLastUsed {
- $_[0]->dbExec(q|SELECT user_update_lastused(?, decode(?, 'hex'))|, $_[1], unpack 'H*', $_[2]);
-}
-
-
-sub dbUserEmailExists {
- $_[0]->dbRow(q|SELECT user_emailexists(?) AS r|, $_[1])->{r};
-}
-
-
-sub dbUserIsValidToken {
- $_[0]->dbRow(q|SELECT user_isvalidtoken(?, decode(?, 'hex')) AS r|, $_[1], unpack 'H*', $_[2])->{r};
-}
-
-
-sub dbUserResetPass {
- $_[0]->dbRow(q|SELECT user_resetpass(?, decode(?, 'hex')) AS r|, $_[1], unpack 'H*', $_[2])->{r};
-}
-
-
-sub dbUserSetPass {
- $_[0]->dbRow(q|SELECT user_setpass(?, decode(?, 'hex'), decode(?, 'hex')) AS r|, $_[1], unpack('H*', $_[2]), unpack('H*', $_[3]))->{r};
-}
-
-
-sub dbUserGetMail {
- $_[0]->dbRow(q|SELECT user_getmail(?, ?, decode(?, 'hex')) AS r|, $_[1], $_[2], unpack 'H*', $_[3])->{r};
-}
-
-
-sub dbUserSetMail {
- $_[0]->dbExec(q|SELECT user_setmail(?, ?, decode(?, 'hex'), ?)|, $_[1], $_[2], unpack('H*', $_[3]), $_[4]);
-}
-
-
-sub dbUserSetPerm {
- $_[0]->dbExec(q|SELECT user_setperm(?, ?, decode(?, 'hex'), ?)|, $_[1], $_[2], unpack('H*', $_[3]), $_[4]);
-}
-
-
-sub dbUserAdminSetPass {
- $_[0]->dbExec(q|SELECT user_admin_setpass(?, ?, decode(?, 'hex'), decode(?, 'hex'))|, $_[1], $_[2], unpack('H*', $_[3]), unpack('H*', $_[4]));
-}
-
-
-# %options->{ uid id what results page reverse }
-# what: titles
-sub dbNotifyGet {
- my($s, %o) = @_;
- $o{what} ||= '';
- $o{results} ||= 10;
- $o{page} ||= 1;
-
- my %where = (
- 'n.uid = ?' => $o{uid},
- $o{id} ? (
- 'n.id = ?' => $o{id} ) : (),
- defined($o{read}) ? (
- 'n.read !s' => $o{read} ? 'IS NOT NULL' : 'IS NULL' ) : (),
- );
-
- my @join = (
- $o{what} =~ /titles/ ? 'LEFT JOIN users u ON n.c_byuser = u.id' : (),
- );
-
- my @select = (
- qw|n.id n.ntype n.ltype n.iid n.subid|,
- q|extract('epoch' from n.date) as date|,
- q|extract('epoch' from n.read) as read|,
- $o{what} =~ /titles/ ? qw|u.username n.c_title| : (),
- );
-
- my($r, $np) = $s->dbPage(\%o, q|
- SELECT !s
- FROM notifications n
- !s
- !W
- ORDER BY n.id !s
- |, join(', ', @select), join(' ', @join), \%where, $o{reverse} ? 'DESC' : 'ASC');
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# ids
-sub dbNotifyMarkRead {
- my $s = shift;
- $s->dbExec('UPDATE notifications SET read = NOW() WHERE id IN(!l)', \@_);
-}
-
-
-# ids
-sub dbNotifyRemove {
- my $s = shift;
- $s->dbExec('DELETE FROM notifications WHERE id IN(!l)', \@_);
-}
-
-
-# ip
-sub dbThrottleGet {
- my $s = shift;
- my $t = $s->dbRow("SELECT extract('epoch' from timeout) as timeout FROM login_throttle WHERE ip = ?", shift)->{timeout};
- return $t && $t >= time ? $t : time;
-}
-
-# ip, timeout
-sub dbThrottleSet {
- my($s, $ip, $timeout) = @_;
- !$timeout ? $s->dbExec('DELETE FROM login_throttle WHERE ip = ?', $ip)
- : $s->dbExec('UPDATE login_throttle SET timeout = to_timestamp(?) WHERE ip = ?', $timeout, $ip)
- || $s->dbExec('INSERT INTO login_throttle (ip, timeout) VALUES (?, to_timestamp(?))', $ip, $timeout);
-}
-
-1;
-
diff --git a/lib/VNDB/DB/VN.pm b/lib/VNDB/DB/VN.pm
deleted file mode 100644
index 75cf0a0d..00000000
--- a/lib/VNDB/DB/VN.pm
+++ /dev/null
@@ -1,365 +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 wishlist vnlist
-# Note: wishlist and vnlist are 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 wlists WHERE uid = ? AND wstat = 3)' => $uid ) : (),
- $uid && defined $o{ul_onwish} ? (
- 'v.id !s IN(SELECT vid FROM wlists WHERE uid = ?)' => [ $o{ul_onwish} ? '' : 'NOT', $uid ] ) : (),
- $uid && defined $o{ul_voted} ? (
- 'v.id !s IN(SELECT vid FROM votes WHERE uid = ?)' => [ $o{ul_voted} ? '' : 'NOT', $uid ] ) : (),
- $uid && defined $o{ul_onlist} ? (
- 'v.id !s IN(SELECT vid FROM vnlists 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} =~ /wishlist/ ?
- 'LEFT JOIN wlists wl ON wl.vid = v.id AND wl.uid = ' . $uid : (),
- $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_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| ) : (),
- $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} =~ /wishlist/ ? 'wl.wstat' : (),
- $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_platforms::text[], v.title, v.original, vo.rgraph';
- $select .= ', extract(\'epoch\' from c.added) as added, c.requester, c.comments, u.username, c.rev, c.ihid, c.ilock';
- $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, 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 s.hidden = FALSE AND 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 s.hidden = FALSE AND 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 ]
- )});
- }
- }
-
- 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|;
- $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
new file mode 100644
index 00000000..7d22ec32
--- /dev/null
+++ b/lib/VNDB/ExtLinks.pm
@@ -0,0 +1,517 @@
+package VNDB::ExtLinks;
+
+use v5.26;
+use warnings;
+use VNDB::Config;
+use VNDB::Schema;
+use Exporter 'import';
+
+our @EXPORT = qw/
+ sql_extlinks
+ enrich_extlinks
+ revision_extlinks
+ validate_extlinks
+/;
+
+
+# column name in wikidata table => \%info
+# info keys:
+# type SQL type, used by Multi to generate the proper SQL
+# property Wikidata Property ID, used by Multi
+# label How the link is displayed on the website
+# fmt How to generate the url (printf-style string or subroutine returning the full URL)
+our %WIKIDATA = (
+ enwiki => { type => 'text', property => undef, label => 'Wikipedia (en)', fmt => sub { sprintf 'https://en.wikipedia.org/wiki/%s', (shift =~ s/ /_/rg) =~ s/\?/%3f/rg } },
+ jawiki => { type => 'text', property => undef, label => 'Wikipedia (ja)', fmt => sub { sprintf 'https://ja.wikipedia.org/wiki/%s', (shift =~ s/ /_/rg) =~ s/\?/%3f/rg } },
+ website => { type => 'text[]', property => 'P856', label => undef, fmt => undef },
+ vndb => { type => 'text[]', property => 'P3180', label => undef, fmt => undef },
+ mobygames => { type => 'text[]', property => 'P1933', label => 'MobyGames', fmt => 'https://www.mobygames.com/game/%s' },
+ mobygames_company => { type => 'text[]', property => 'P4773', label => 'MobyGames', fmt => 'https://www.mobygames.com/company/%s' },
+ gamefaqs_game => { type => 'integer[]', property => 'P4769', label => 'GameFAQs', fmt => 'https://gamefaqs.gamespot.com/-/%s-' },
+ gamefaqs_company => { type => 'integer[]', property => 'P6182', label => 'GameFAQs', fmt => 'https://gamefaqs.gamespot.com/company/%s-' },
+ anidb_anime => { type => 'integer[]', property => 'P5646', label => undef, fmt => undef },
+ anidb_person => { type => 'integer[]', property => 'P5649', label => 'AniDB', fmt => 'https://anidb.net/cr%s' },
+ ann_anime => { type => 'integer[]', property => 'P1985', label => undef, fmt => undef },
+ ann_manga => { type => 'integer[]', property => 'P1984', label => undef, fmt => undef },
+ musicbrainz_artist => { type => 'uuid[]', property => 'P434', label => 'MusicBrainz', fmt => 'https://musicbrainz.org/artist/%s' },
+ twitter => { type => 'text[]', property => 'P2002', label => 'Twitter', fmt => 'https://twitter.com/%s' },
+ vgmdb_product => { type => 'integer[]', property => 'P5659', label => 'VGMdb', fmt => 'https://vgmdb.net/product/%s' },
+ vgmdb_artist => { type => 'integer[]', property => 'P3435', label => 'VGMdb', fmt => 'https://vgmdb.net/artist/%s' },
+ discogs_artist => { type => 'integer[]', property => 'P1953', label => 'Discogs', fmt => 'https://www.discogs.com/artist/%s' },
+ acdb_char => { type => 'integer[]', property => 'P7013', label => undef, fmt => undef },
+ acdb_source => { type => 'integer[]', property => 'P7017', label => 'ACDB', fmt => 'https://www.animecharactersdatabase.com/source.php?id=%s' },
+ indiedb_game => { type => 'text[]', property => 'P6717', label => 'IndieDB', fmt => 'https://www.indiedb.com/games/%s' },
+ howlongtobeat => { type => 'integer[]', property => 'P2816', label => 'HowLongToBeat', fmt => 'http://howlongtobeat.com/game.php?id=%s' },
+ 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 => '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' },
+ l_wikidata => { label => 'Wikidata', fmt => 'https://www.wikidata.org/wiki/Q%d' },
+ # deprecated
+ l_wp => { label => 'Wikipedia', fmt => 'https://en.wikipedia.org/wiki/%s' },
+ l_encubed => { label => 'Novelnews', fmt => 'http://novelnews.net/tag/%s/' },
+ },
+ r => {
+ website => { label => 'Official website', fmt => '%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'
+ , 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' },
+ },
+ p => {
+ website => { label => 'Official website', fmt => '%s' },
+ l_wikidata => { label => 'Wikidata', fmt => 'https://www.wikidata.org/wiki/Q%d' },
+ # deprecated
+ l_wp => { label => 'Wikipedia', fmt => 'https://en.wikipedia.org/wiki/%s' },
+ },
+);
+
+
+# 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 => [
+# { name, label, id, url, url2, price }, # depending on which fields are $enabled
+# ..
+# ]
+#
+# Assumes the columns returned by sql_extlinks() are already available.
+sub enrich_extlinks {
+ 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";
+
+ my @w_ids = grep $_, map $_->{l_wikidata}, @obj;
+ 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
+ , 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
+ 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_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_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) {
+ my @links;
+ my sub w {
+ return if !$obj->{l_wikidata};
+ my($v, $fmt, $label) = ($w->{$obj->{l_wikidata}}{$_[0]}, @{$WIKIDATA{$_[0]}}{'fmt', 'label'});
+ 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} ? @{$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';
+ l 'website';
+ w 'enwiki';
+ w 'jawiki';
+ l 'l_wikidata';
+
+ # VN links
+ if($type eq 'v') {
+ w 'mobygames';
+ w 'gamefaqs_game';
+ w 'vgmdb_product';
+ w 'acdb_source';
+ w 'indiedb_game';
+ w 'howlongtobeat';
+ w 'igdb_game';
+ w 'pcgamingwiki';
+ w 'lutris';
+ w 'wine';
+ l 'l_renai';
+ 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_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_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', $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';
+ 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
+ if($type eq 's') {
+ 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};
+ 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
+ if($type eq 'p') {
+ w 'twitter';
+ w 'mobygames_company';
+ w 'gamefaqs_company';
+ #w 'doujinshi_author';
+ w 'soundcloud';
+ c 'vnstat', 'VNStat', 'https://vnstat.net/developer/%d', $obj->{id} =~ s/^.//r;
+ }
+
+ $obj->{extlinks} = \@links;
+ delete @{$obj}{ @cleanup };
+ }
+}
+
+
+# Returns a list of @fields for use in VNWeb::HTML::revision_()
+sub revision_extlinks {
+ my($type) = @_;
+ map {
+ my($f, $p) = ($_, $LINKS{$type}{$_});
+ [ $f, $p->{label}, fmt => sub { TUWF::XML::a_(href => sprintf($p->{fmt}, $_), $_); }, empty => 0 ]
+ } sort keys $LINKS{$type}->%*
+}
+
+
+# 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 9fe9c56c..8c448ad8 100644
--- a/lib/VNDB/Func.pm
+++ b/lib/VNDB/Func.pm
@@ -1,205 +1,160 @@
-
package VNDB::Func;
use strict;
use warnings;
-use TUWF ':html', 'kv_validate', 'xml_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 tagscore mt minage fil_parse fil_serialize parenttags
- childtags charspoil imgpath imgurl
- fmtvote fmtmedia fmtvnlen fmtage fmtdatestr fmtdate fmtuser fmtrating fmtspoil
- 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
}
-# Tag score in html tags, argument: score, users
-sub tagscore {
- my $s = shift;
- div class => 'taglvl', style => sprintf('width: %.0fpx', ($s-floor($s))*10), ' ' if $s < 0 && $s-floor($s) > 0;
- for(-3..3) {
- div(class => "taglvl taglvl0", sprintf '%.1f', $s), next if !$_;
- if($_ < 0) {
- if($s > 0 || floor($s) > $_) {
- div class => "taglvl taglvl$_", ' ';
- } elsif(floor($s) != $_) {
- div class => "taglvl taglvl$_ taglvlsel", ' ';
- } else {
- div class => "taglvl taglvl$_ taglvlsel", style => sprintf('width: %.0fpx', 10-($s-$_)*10), ' ';
- }
- } else {
- if($s < 0 || ceil($s) < $_) {
- div class => "taglvl taglvl$_", ' ';
- } elsif(ceil($s) != $_) {
- div class => "taglvl taglvl$_ taglvlsel", ' ';
- } else {
- div class => "taglvl taglvl$_ taglvlsel", style => sprintf('width: %.0fpx', 10-($_-$s)*10), ' ';
- }
- }
- }
- div class => 'taglvl', style => sprintf('width: %.0fpx', (ceil($s)-$s)*10), ' ' if $s > 0 && ceil($s)-$s > 0;
+sub shorten {
+ my($str, $len) = @_;
+ return length($str) > $len ? substr($str, 0, $len-3).'...' : $str;
}
-# short wrapper around maketext()
-sub mt {
- return $TUWF::OBJ->{l10n}->maketext(@_);
+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 minage {
- my($a, $ex) = @_;
- my $str = $a == -1 ? 'Unknown' : !$a ? 'All ages' : sprintf '%d+', $a;
- $ex = !defined($a) ? '' : {
- 0 => 'CERO A',
- 12 => 'CERO B',
- 15 => 'CERO C',
- 17 => 'CERO D',
- 18 => 'CERO Z',
- }->{$a} if $ex;
- return $str if !$ex;
- return "$str (e.g. $ex)";
+# 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 :)
}
-# 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]
+# 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));
}
-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;
-}
-
-
-# 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($_);
+# 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;
}
- push @r, [$t] if !@{$t->{'sub'}};
- }
- return @r;
-}
-
-# 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';
- }
- 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', $VNDB::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.
@@ -210,19 +165,10 @@ sub fmtvote {
# Formats a media string ("1 CD", "2 CDs", "Internet download", etc)
sub fmtmedia {
my($med, $qty) = @_;
- $med = $TUWF::OBJ->{media}{$med};
+ $med = $MEDIUM{$med};
join ' ',
- ($med->[0] ? ($qty) : ()),
- $med->[ $med->[0] && $qty > 1 ? 2 : 1 ];
-}
-
-# Formats a VN length (xtra = 1 for time indication, 2 for examples)
-sub fmtvnlen {
- my($len, $xtra) = @_;
- $len = $TUWF::OBJ->{vn_lengths}[$len];
- $len->[0].
- ($xtra && $xtra == 1 && $len->[1] ? " ($len->[1])" : '').
- ($xtra && $xtra == 2 && $len->[2] ? " ($len->[2])" : '');
+ ($med->{qty} ? ($qty) : ()),
+ $med->{ $med->{qty} && $qty > 1 ? 'plural' : 'txt' };
}
# Formats a UNIX timestamp as a '<number> <unit> ago' string
@@ -240,38 +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;
-}
-
-# Arguments: (uid, username), or a hashref containing that info
-sub fmtuser {
- my($id,$n) = ref($_[0]) eq 'HASH' ? ($_[0]{uid}||$_[0]{requester}, $_[0]{username}) : @_;
- return !$id ? '[deleted]' : sprintf '<a href="/u%d">%s</a>', $id, xml_escape $n;
+ 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
@@ -297,47 +217,118 @@ sub fmtspoil {
}
+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(@_);
+
+# 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'))
+ : ();
}
-# 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 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])
}
-# 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 || ref $o ne ref $n;
- if(!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;
+# 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;
+}
+
+
+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;
}
- }
- 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/Affiliates.pm b/lib/VNDB/Handler/Affiliates.pm
deleted file mode 100644
index efba6b18..00000000
--- a/lib/VNDB/Handler/Affiliates.pm
+++ /dev/null
@@ -1,152 +0,0 @@
-
-package VNDB::Handler::Affiliates;
-
-use strict;
-use warnings;
-use TUWF ':html';
-use VNDB::Func;
-
-
-TUWF::register(
- qr{affiliates} => \&list,
- qr{affiliates/del/([1-9]\d*)} => \&linkdel,
- qr{affiliates/edit/([1-9]\d*)} => \&edit,
- qr{affiliates/new} => \&edit,
-);
-
-
-sub list {
- my $self = shift;
-
- return $self->htmlDenied if !$self->authCan('affiliate');
- my $f = $self->formValidate(
- { get => 'a', required => 0, enum => [ 0..$#{$self->{affiliates}} ] },
- { get => 'h', required => 0, default => 0, enum => [ -1..1 ] },
- { get => 'o', required => 0, default => 'a', enum => ['a', 'd'] },
- { get => 's', required => 0, default => 'rel', enum => [qw|rel prio url lastfetch|] },
- );
- return $self->resNotFound if $f->{_err};
-
- $self->htmlHeader(title => 'Affiliate administration interface');
- div class => 'mainbox';
- h1 'Affiliate administration interface';
- p class => 'browseopts';
- a defined($f->{a}) && $f->{a} == $_ ? (class => 'optselected') : (), href => "/affiliates?a=$_", $self->{affiliates}[$_]{name}
- for (grep $self->{affiliates}[$_], 0..$#{$self->{affiliates}});
- end;
- if(defined $f->{a}) {
- p class => 'browseopts';
- a $f->{h} == -1 ? (class => 'optselected') : (), href => "/affiliates?a=$f->{a};h=-1",'all';
- a $f->{h} == 1 ? (class => 'optselected') : (), href => "/affiliates?a=$f->{a};h=1", 'hidden';
- a $f->{h} == 0 ? (class => 'optselected') : (), href => "/affiliates?a=$f->{a};h=0", 'non-hidden';
- end;
- }
- end;
-
- if(defined $f->{a}) {
- my $list = $self->dbAffiliateGet(
- affiliate => $f->{a}, hidden => $f->{h}==-1?undef:$f->{h},
- what => 'release',
- sort => $f->{s}, reverse => $f->{o} eq 'd'
- );
- $self->htmlBrowse(
- items => $list,
- nextpage => 0,
- options => {p=>0, %$f},
- pageurl => '',
- sorturl => "/affiliates?a=$f->{a};h=$f->{h}",
- header => [
- ['Release', 'rel'],
- ['Version'],
- ['Hid'],
- ['Prio', 'prio'],
- ['Price / Lastfetch', 'lastfetch'],
- ['', 'url' ]
- ],
- row => sub {
- my($s, $n, $l) = @_;
- Tr;
- td class => 'tc1'; a href => "/r$l->{rid}", shorten $l->{title}, 50; end;
- td class => 'tc2', $l->{version} || '<default>';
- td class => 'tc3', $l->{hidden} ? 'YES' : 'no';
- td class => 'tc4', $l->{priority};
- td class => 'tc5', sprintf '%s / %s', $l->{price}, $l->{lastfetch} ? fmtage($l->{lastfetch}) : '-';
- td class => 'tc6';
- a href => $l->{url}, 'link';
- txt ' | ';
- a href => "/affiliates/edit/$l->{id}", 'edit';
- txt ' | ';
- a href => "/affiliates/del/$l->{id}?formcode=".$self->authGetCode("/affiliates/del/$l->{id}"), 'del';
- end;
- end;
- },
- );
- }
- $self->htmlFooter;
-}
-
-
-sub linkdel {
- my($self, $id) = @_;
- return $self->htmlDenied if !$self->authCan('affiliate');
- return if !$self->authCheckCode;
- my $l = $self->dbAffiliateGet(id => $id)->[0];
- return $self->resNotFound if !$l;
- $self->dbAffiliateDel($id);
- $self->resRedirect("/affiliates?a=$l->{affiliate}");
-}
-
-
-sub edit {
- my($self, $id) = @_;
- return $self->htmlDenied if !$self->authCan('affiliate');
-
- my $r = $id && $self->dbAffiliateGet(id => $id)->[0];
- return $self->resNotFound if $id && !$r;
-
- my $frm;
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'rid', required => 1, template => 'id' },
- { post => 'priority', required => 0, default => 0, template => 'int' },
- { post => 'hidden', required => 0, default => 0, enum => [0,1] },
- { post => 'affiliate',required => 1, enum => [0..$#{$self->{affiliates}}] },
- { post => 'url', required => 1 },
- { post => 'version', required => 0, default => '' },
- { post => 'price', required => 0, default => '' },
- { post => 'lastfetch',required => 0, template => 'uint' },
- { post => 'data', required => 0, default => '' },
- );
- if(!$frm->{_err}) {
- $self->dbAffiliateEdit($id, %$frm) if $id;
- $self->dbAffiliateAdd(%$frm) if !$id;
- return $self->resRedirect("/affiliates?a=$frm->{affiliate}", 'post');
- }
- }
-
- if($id) {
- $frm->{$_} = $r->{$_} for(qw|rid priority hidden affiliate url version price lastfetch data|);
- } else {
- $frm->{rid} = $self->reqGet('rid');
- }
-
- $self->htmlHeader(title => 'Edit affiliate link');
- $self->htmlForm({ frm => $frm, action => $id ? "/affiliates/edit/$id" : '/affiliates/new' }, 'blah' => [ 'Edit affiliate link',
- [ input => short => 'rid', name => 'Release ID', width => 100 ],
- [ input => short => 'priority', name => 'Priority', width => 50 ],
- [ check => short => 'hidden', name => 'Hidden' ],
- [ select => short => 'affiliate', name => 'Affiliate', options => [ map
- [ $_, $self->{affiliates}[$_]{name} ], grep $self->{affiliates}[$_], 0..$#{$self->{affiliates}} ] ],
- [ input => short => 'url', name => 'URL', width => 400 ],
- [ input => short => 'version', name => 'Version', width => 400 ],
- [ input => short => 'price', name => 'Price' ],
- [ input => short => 'lastfetch', name => 'Lastfetch', post => ' UNIX timestamp' ],
- [ input => short => 'data', name => 'Data', width => 400 ],
- ]);
- $self->htmlFooter;
-}
-
-
-1;
-
diff --git a/lib/VNDB/Handler/Chars.pm b/lib/VNDB/Handler/Chars.pm
deleted file mode 100644
index 814fce7b..00000000
--- a/lib/VNDB/Handler/Chars.pm
+++ /dev/null
@@ -1,604 +0,0 @@
-
-package VNDB::Handler::Chars;
-
-use strict;
-use warnings;
-use TUWF ':html', 'uri_escape';
-use Exporter 'import';
-use VNDB::Func;
-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 { $self->{genders}{$_[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 { $self->{blood_types}{$_[0]} } ],
- [ 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}):'',
- $self->{char_roles}{$_->{role}}[0], 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}", $self->{genders}{$r->{gender}} if $r->{gender} ne 'unknown';
- span $self->{blood_types}{$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}) {
- 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}||'??' : ();
- 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;
- }
-
- # 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) {
- br if $first++;
- my @r = @{$vns{$g}};
- # special case: all releases, no exceptions
- if(!$vn && @r == 1 && !$r[0]{rid}) {
- span class => charspoil $r[0]{spoil};
- txt $self->{char_roles}{$r[0]{role}}[0].' - ';
- a href => "/v$r[0]{vid}/chars", $r[0]{vntitle};
- 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 $self->{char_roles}{$_->{role}}[0].' - ';
- if($_->{rid}) {
- b class => 'grayedout', "r$_->{rid}:";
- a href => "/r$_->{rid}", $_->{rtitle};
- } else {
- txt 'All other releases';
- }
- end;
- }
- 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 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 %{$self->{genders}} ] },
- { 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 %{$self->{blood_types}} ] },
- { 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 },
- );
-
- # 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 [ $_, $self->{genders}{$_} ], keys %{$self->{genders}} ] ],
- [ input => name => 'Birthday', short => 'bday', width => 100,post => ' MM-DD (e.g. "01-26" for the 26th of January)' ],
- [ 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 [ $_, $self->{blood_types}{$_} ], keys %{$self->{blood_types}} ] ],
- [ 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}", $self->{genders}{$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/Discussions.pm b/lib/VNDB/Handler/Discussions.pm
deleted file mode 100644
index 48676bfc..00000000
--- a/lib/VNDB/Handler/Discussions.pm
+++ /dev/null
@@ -1,718 +0,0 @@
-
-package VNDB::Handler::Discussions;
-
-use strict;
-use warnings;
-use TUWF ':html', 'xml_escape', 'uri_escape';
-use POSIX 'ceil';
-use VNDB::Func;
-use List::Util qw(first max);
-
-
-TUWF::register(
- qr{t([1-9]\d*)(?:/([1-9]\d*))?} => \&thread,
- qr{t([1-9]\d*)(/[1-9]\d*)?/vote} => \&vote,
- qr{t([1-9]\d*)\.([1-9]\d*)} => \&redirect,
- qr{t/(all|db|an|ge|[vpu])([1-9]\d*)?} => \&board,
- qr{t([1-9]\d*)/reply} => \&edit,
- qr{t([1-9]\d*)\.([1-9]\d*)/edit} => \&edit,
- qr{t/(db|an|ge|[vpu])([1-9]\d*)?/new} => \&edit,
- qr{t/search} => \&search,
- qr{t} => \&index,
-);
-
-
-sub caneditpost {
- my($self, $post) = @_;
- return $self->authCan('boardmod') ||
- ($self->authInfo->{id} && $post->{uid} == $self->authInfo->{id} && !$post->{hidden} && time()-$post->{date} < $self->{board_edit_time})
-}
-
-
-sub thread {
- my($self, $tid, $page) = @_;
- $page ||= 1;
-
- my $t = $self->dbThreadGet(id => $tid, what => 'boardtitles poll')->[0];
- return $self->resNotFound if !$t->{id} || $t->{hidden} && !$self->authCan('boardmod');
-
- my $onuserboard = grep $_->{type} eq 'u' && $_->{iid} == ($self->authInfo->{id}||-1), @{$t->{boards}};
- return $self->resNotFound if $t->{private} && !($self->authCan('boardmod') || $onuserboard);
-
- my $p = $self->dbPostGet(tid => $tid, results => 25, page => $page, what => 'user');
- return $self->resNotFound if !$p->[0];
-
- $self->htmlHeader(title => $t->{title}, noindex => 1);
- div class => 'mainbox';
- h1 $t->{title};
- h2 'Hidden' if $t->{hidden};
- h2 'Private' if $t->{private};
- h2 'Posted in';
- ul;
- for (sort { $a->{type}.$a->{iid} cmp $b->{type}.$b->{iid} } @{$t->{boards}}) {
- li;
- a href => "/t/$_->{type}", $self->{discussion_boards}{$_->{type}};
- if($_->{iid}) {
- txt ' > ';
- a style => 'font-weight: bold', href => "/t/$_->{type}$_->{iid}", "$_->{type}$_->{iid}";
- txt ':';
- a href => "/$_->{type}$_->{iid}", title => $_->{original}, $_->{title};
- }
- end;
- }
- end;
- end 'div';
-
- _poll($self, $t, "/t$tid".($page > 1 ? "/$page" : '')) if $t->{haspoll};
-
- $self->htmlBrowseNavigate("/t$tid/", $page, [ $t->{count}, 25 ], 't', 1);
- div class => 'mainbox thread';
- table class => 'stripe';
- for my $i (0..$#$p) {
- local $_ = $p->[$i];
- Tr $_->{deleted} ? (class => 'deleted') : ();
- td class => 'tc1';
- a href => "/t$tid.$_->{num}", name => $_->{num}, "#$_->{num}";
- if(!$_->{hidden}) {
- lit ' by '.fmtuser($_);
- br;
- txt fmtdate $_->{date}, 'full';
- }
- end;
- td class => 'tc2';
- if(caneditpost($self, $_)) {
- i class => 'edit';
- txt '< ';
- a href => "/t$tid.$_->{num}/edit", 'edit';
- txt ' >';
- end;
- }
- if($_->{hidden}) {
- i class => 'deleted', 'Post deleted.';
- } else {
- lit bb2html $_->{msg};
- i class => 'lastmod', 'Last modified on '.fmtdate($_->{edited}, 'full') if $_->{edited};
- }
- end;
- end;
- }
- end;
- end 'div';
- $self->htmlBrowseNavigate("/t$tid/", $page, [ $t->{count}, 25 ], 'b', 1);
-
- if($t->{locked}) {
- div class => 'mainbox';
- h1 'Reply';
- p class => 'center', 'This thread has been locked, you can\'t reply to it anymore';
- end;
- } elsif($t->{count} <= $page*25 && $self->authCan('board')) {
- form action => "/t$tid/reply", method => 'post', 'accept-charset' => 'UTF-8';
- div class => 'mainbox';
- fieldset class => 'submit';
- input type => 'hidden', class => 'hidden', name => 'formcode', value => $self->authGetCode("/t$tid/reply");
- h2;
- txt 'Quick reply';
- b class => 'standout', ' (English please!)';
- end;
- textarea name => 'msg', id => 'msg', rows => 4, cols => 50, '';
- br;
- input type => 'submit', value => 'Reply', class => 'submit';
- input type => 'submit', value => 'Go advanced...', class => 'submit', name => 'fullreply';
- end;
- end;
- end 'form';
- } elsif(!$self->authCan('board')) {
- div class => 'mainbox';
- h1 'Reply';
- p class => 'center', 'You must be logged in to reply to this thread.';
- end;
- }
-
- $self->htmlFooter;
-}
-
-
-sub redirect {
- my($self, $tid, $num) = @_;
- $self->resRedirect("/t$tid".($num > 25 ? '/'.ceil($num/25) : '').'#'.$num, 'perm');
-}
-
-
-# Arguments, action
-# tid reply
-# tid, 1 edit thread
-# tid, num edit post
-# type, (iid) start new thread
-sub edit {
- my($self, $tid, $num) = @_;
- $num ||= 0;
-
- # in case we start a new thread, parse boards
- my $board = '';
- if($tid !~ /^\d+$/) {
- return $self->resNotFound if $tid =~ /(db|an|ge)/ && $num || $tid =~ /[vpu]/ && !$num;
- $board = $tid.($num||'');
- $tid = 0;
- $num = 0;
- }
-
- # get thread and post, if any
- my $t = $tid && $self->dbThreadGet(id => $tid, what => 'boards poll')->[0];
- return $self->resNotFound if $tid && !$t->{id};
-
- my $p = $num && $self->dbPostGet(tid => $tid, num => $num, what => 'user')->[0];
- return $self->resNotFound if $num && !$p->{num};
-
- # are we allowed to perform this action?
- return $self->htmlDenied if !$self->authCan('board')
- || ($tid && ($t->{locked} || $t->{hidden}) && !$self->authCan('boardmod'))
- || ($num && !caneditpost($self, $p));
-
- # check form etc...
- my $frm;
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- my $haspoll = $self->reqPost('poll') && 1;
- $frm = $self->formValidate(
- !$tid || $num == 1 ? (
- { post => 'title', maxlength => 50 },
- { post => 'boards', maxlength => 200 },
- $haspoll ? (
- { post => 'poll', required => 0 },
- { post => 'poll_question', required => 1, maxlength => 100 },
- { post => 'poll_options', required => 1, maxlength => 100*$self->{poll_options} },
- { post => 'poll_max_options', required => 1, default => 1, template => 'uint', min => 1, max => $self->{poll_options} },
- { post => 'poll_preview', required => 0 },
- { post => 'poll_recast', required => 0 },
- ) : (),
- ) : (),
- $self->authCan('boardmod') ? (
- { post => 'locked', required => 0 },
- { post => 'hidden', required => 0 },
- { post => 'nolastmod', required => 0 },
- ) : (),
- $self->authCan('boardmod') || $self->authCan('dbmod') || $self->authCan('tagmod') ? (
- { post => 'private', required => 0 },
- ) : (),
- { post => 'msg', maxlength => 32768 },
- { post => 'fullreply', required => 0 },
- );
-
- $frm->{_err} = 1 if $frm->{fullreply};
-
- # check for double-posting
- push @{$frm->{_err}}, 'Please wait 30 seconds before making another post' if !$num && !$frm->{_err} && $self->dbPostGet(
- uid => $self->authInfo->{id}, tid => $tid, mindate => time - 30, results => 1, $tid ? () : (num => 1))->[0]{num};
-
- # Don't allow regular users to create more than 5 threads a day
- push @{$frm->{_err}}, 'You can only create 5 threads every 24 hours' if
- !$tid && !$self->authCan('boardmod') &&
- @{$self->dbPostGet(uid => $self->authInfo->{id}, mindate => time - 24*3600, num => 1)} >= 5;
-
- # parse and validate the boards
- my @boards;
- if(!$frm->{_err} && $frm->{boards}) {
- for (split /[ ,]/, $frm->{boards}) {
- my($ty, $id) = ($1, $2) if /^([a-z]{1,2})([0-9]*)$/;
- push @boards, [ $ty, $id ] if !grep $_->[0].$_->[1] eq $ty.$id, @boards;
- push @{$frm->{_err}}, "Wrong board: $_" if
- !$ty || !$self->{discussion_boards}{$ty}
- || $ty eq 'an' && ($id || !$self->authCan('boardmod'))
- || $ty eq 'db' && $id
- || $ty eq 'ge' && $id
- || $ty eq 'v' && (!$id || !$self->dbVNGet(id => $id)->[0]{id})
- || $ty eq 'p' && (!$id || !$self->dbProducerGet(id => $id)->[0]{id})
- || $ty eq 'u' && (!$id || !$self->dbUserGet(uid => $id)->[0]{id});
- }
- }
-
- # validate poll options
- my @poll_options;
- if(!$frm->{_err} && $haspoll) {
- @poll_options = split /\s*\n\s*/, $frm->{poll_options};
- push @{$frm->{_err}}, [ 'poll_options', 'mincount', 2 ] if @poll_options < 2;
- push @{$frm->{_err}}, [ 'poll_options', 'maxcount', $frm->{poll_max_options} ] if @poll_options > $self->{poll_options};
- push @{$frm->{_err}}, [ 'poll_max_options', 'template', 'uint' ] if @poll_options > 1 && @poll_options < $frm->{poll_max_options};
- }
-
- if(!$frm->{_err}) {
- my($ntid, $nnum) = ($tid, $num);
-
- # create/edit thread
- if(!$tid || $num == 1) {
- my $pollchange = $haspoll && (!$t
- || ($t->{poll_question}||'') ne $frm->{poll_question}
- || $t->{poll_max_options} != $frm->{poll_max_options}
- || join("\n", map $_->[1], @{$t->{poll_options}}) ne join("\n", @poll_options)
- );
- my %thread = (
- title => $frm->{title},
- boards => \@boards,
- hidden => $frm->{hidden},
- locked => $frm->{locked},
- private => $frm->{private},
- poll_preview => $frm->{poll_preview}||0,
- poll_recast => $frm->{poll_recast}||0,
- !$haspoll ? (
- poll_question => undef # Make sure any existing poll gets deleted
- ) : $pollchange ? (
- poll_question => $frm->{poll_question},
- poll_max_options => $frm->{poll_max_options},
- poll_options => \@poll_options
- ) : (),
- );
- $self->dbThreadEdit($tid, %thread) if $tid;
- $ntid = $self->dbThreadAdd(%thread) if !$tid;
- }
-
- # create/edit post
- my %post = (
- msg => $self->bbSubstLinks($frm->{msg}),
- hidden => $num != 1 && $frm->{hidden},
- lastmod => !$num || $frm->{nolastmod} ? 0 : time,
- );
- $self->dbPostEdit($tid, $num, %post) if $num;
- $nnum = $self->dbPostAdd($ntid, %post) if !$num;
-
- return $self->resRedirect("/t$ntid".($nnum > 25 ? '/'.ceil($nnum/25) : '').'#'.$nnum, 'post');
- }
- }
-
- # fill out form if we have some data
- if($p) {
- $frm->{msg} ||= $p->{msg};
- $frm->{hidden} = $p->{hidden} if $num != 1 && !exists $frm->{hidden};
- if($num == 1) {
- $frm->{boards} ||= join ' ', sort map $_->[1]?$_->[0].$_->[1]:$_->[0], @{$t->{boards}};
- $frm->{title} ||= $t->{title};
- $frm->{locked} //= $t->{locked};
- $frm->{hidden} //= $t->{hidden};
- $frm->{private} //= $t->{private};
- if($t->{haspoll}) {
- $frm->{poll} //= 1;
- $frm->{poll_question} ||= $t->{poll_question};
- $frm->{poll_max_options} ||= $t->{poll_max_options};
- $frm->{poll_preview} //= $t->{poll_preview};
- $frm->{poll_recast} //= $t->{poll_recast};
- $frm->{poll_options} ||= join "\n", map $_->[1], @{$t->{poll_options}};
- }
- }
- }
- delete $frm->{_err} unless ref $frm->{_err};
- $frm->{boards} ||= $board;
- $frm->{poll_preview} //= 1;
- $frm->{poll_max_options} ||= 1;
-
- # generate html
- my $url = !$tid ? "/t/$board/new" : !$num ? "/t$tid/reply" : "/t$tid.$num/edit";
- my $title = !$tid ? 'Start new thread' :
- !$num ? "Reply to $t->{title}" :
- 'Edit post';
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlForm({ frm => $frm, action => $url }, 'postedit' => [$title,
- [ static => label => 'Username', content => fmtuser($p ? ($p->{uid}, $p->{username}) : ($self->authInfo->{id}, $self->authInfo->{username})) ],
- !$tid || $num == 1 ? (
- [ input => short => 'title', name => 'Thread title' ],
- [ input => short => 'boards', name => 'Board(s)' ],
- [ static => content => 'Read <a href="/d9#2">d9#2</a> for information about how to specify boards.' ],
- $self->authCan('boardmod') ? (
- [ check => name => 'Locked', short => 'locked' ],
- ) : (),
- $self->authCan('boardmod') || $self->authCan('dbmod') || $self->authCan('tagmod') ? (
- [ check => name => 'Private (only visible to users mentioned in the boards)', short => 'private' ],
- ) : (),
- ) : (
- [ static => label => 'Topic', content => qq|<a href="/t$tid">|.xml_escape($t->{title}).'</a>' ],
- ),
- $self->authCan('boardmod') ? (
- [ check => name => 'Hidden', short => 'hidden' ],
- $num ? (
- [ check => name => 'Don\'t update last modified field', short => 'nolastmod' ],
- ) : (),
- ) : (),
- [ text => name => 'Message<br /><b class="standout">English please!</b>', short => 'msg', rows => 25, cols => 75 ],
- [ static => content => 'See <a href="/d9#3">d9#3</a> for the allowed formatting codes' ],
- (!$tid || $num == 1) ? (
- [ static => content => '<br />' ],
- [ check => short => 'poll', name => 'Add poll' ],
- $num && $frm->{poll_question} ? (
- [ static => content => '<b class="standout">All votes will be reset if any changes to the poll fields are made!</b>' ]
- ) : (),
- [ input => short => 'poll_question', name => 'Poll question', width => 250 ],
- [ text => short => 'poll_options', name => "Poll options<br /><i>one per line,<br />$self->{poll_options} max</i>", rows => 8, cols => 35 ],
- [ input => short => 'poll_max_options',width => 16, post => ' Number of options voter is allowed to choose' ],
- [ check => short => 'poll_preview', name => 'Allow users to view poll results before voting' ],
- [ check => short => 'poll_recast', name => 'Allow users to change their vote' ],
- ) : (),
- ]);
- $self->htmlFooter;
-}
-
-
-sub vote {
- my($self, $tid, $page) = @_;
- return $self->htmlDenied if !$self->authCan('board');
- return if !$self->authCheckCode;
-
- my $url = '/t'.$tid.($page ? "/$page" : '');
- my $t = $self->dbThreadGet(id => $tid, what => 'poll')->[0];
- return $self->resNotFound if !$t;
-
- # user has already voted and poll doesn't allow to change a vote.
- my $voted = ($self->dbPollStats($tid))[2][0];
- return $self->resRedirect($url, 'post') if $voted && !$t->{poll_recast};
-
- my $f = $self->formValidate(
- { post => 'option', multi => 1, mincount => 1, maxcount => $t->{poll_max_options}, enum => [ map $_->[0], @{$t->{poll_options}} ] }
- );
- if($f->{_err}) {
- $self->htmlHeader(title => 'Poll error');
- $self->htmlFormError($f, 1);
- $self->htmlFooter;
- return;
- }
-
- $self->dbPollVote($t->{id}, $self->authInfo->{id}, @{$f->{option}});
- $self->resRedirect($url, 'post');
-}
-
-
-sub board {
- my($self, $type, $iid) = @_;
- $iid ||= '';
- return $self->resNotFound if $type =~ /(db|an|ge|all)/ && $iid;
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- );
- return $self->resNotFound if $f->{_err};
-
- my $obj = !$iid ? undef :
- $type eq 'u' ? $self->dbUserGet(uid => $iid, what => 'hide_list')->[0] :
- $type eq 'p' ? $self->dbProducerGet(id => $iid)->[0] :
- $self->dbVNGet(id => $iid)->[0];
- return $self->resNotFound if $iid && !$obj;
- my $ititle = $obj && ($obj->{title}||$obj->{name}||$obj->{username});
- my $title = !$obj ? $self->{discussion_boards}{$type} || 'All boards' : "Related discussions for $ititle";
-
- my($list, $np) = $self->dbThreadGet(
- $type ne 'all' ? (type => $type) : (),
- $iid ? (iid => $iid) : (),
- results => 50,
- page => $f->{p},
- what => 'firstpost lastpost boardtitles',
- sort => $type eq 'an' ? 'id' : 'lastpost', reverse => 1,
- asuser => $self->authInfo()->{id},
- );
-
- $self->htmlHeader(title => $title, noindex => 1, feeds => [ $type eq 'an' ? 'announcements' : 'posts' ]);
-
- $self->htmlMainTabs($type, $obj, 'disc') if $iid;
- form action => '/t/search', method => 'get';
- div class => 'mainbox';
- h1 $title;
- p;
- a href => '/t', 'Discussion board';
- txt ' > ';
- a href => "/t/$type", $self->{discussion_boards}{$type}||'All boards';
- if($iid) {
- txt ' > ';
- a style => 'font-weight: bold', href => "/t/$type$iid", "$type$iid";
- txt ':';
- a href => "/$type$iid", $ititle;
- }
- end;
- if(!$iid) {
- fieldset class => 'search';
- input type => 'text', name => 'bq', id => 'bq', class => 'text';
- input type => 'hidden', name => 'b', value => $type if $type ne 'all';
- input type => 'submit', class => 'submit', value => 'Search!';
- end 'fieldset';
- }
- p class => 'center';
- if(!@$list) {
- b 'No related threads found';
- br; br;
- a href => "/t/$type$iid/new", 'Why not create one yourself?';
- } else {
- a href => '/t/'.($iid ? $type.$iid : $type ne 'ge' ? 'db' : $type).'/new', 'Start a new thread' if $type ne 'all';
- }
- end;
- end 'div';
- end 'form';
-
- _threadlist($self, $list, $f, $np, "/t/$type$iid", $type.$iid) if @$list;
-
- $self->htmlFooter;
-}
-
-
-sub index {
- my $self = shift;
-
- $self->htmlHeader(title => 'Discussion board index', noindex => 1, feeds => [ 'posts', 'announcements' ]);
- form action => '/t/search', method => 'get';
- div class => 'mainbox';
- h1 'Discussion board index';
- fieldset class => 'search';
- input type => 'text', name => 'bq', id => 'bq', class => 'text';
- input type => 'submit', class => 'submit', value => 'Search!';
- end 'fieldset';
- p class => 'browseopts';
- a href => '/t/all', 'All boards';
- a href => '/t/'.$_, $self->{discussion_boards}{$_}
- for (keys %{$self->{discussion_boards}});
- end;
- end;
- end;
-
- for (keys %{$self->{discussion_boards}}) {
- my $list = $self->dbThreadGet(
- type => $_,
- results => /^(db|v|ge)$/ ? 10 : 5,
- page => 1,
- what => 'firstpost lastpost boardtitles',
- sort => 'lastpost', reverse => 1,
- asuser => $self->authInfo()->{id},
- );
- h1 class => 'boxtitle';
- a href => "/t/$_", $self->{discussion_boards}{$_};
- end;
- _threadlist($self, $list, {p=>1}, 0, "/t", $_);
- }
-
- $self->htmlFooter;
-}
-
-
-sub search {
- my $self = shift;
-
- my $frm = $self->formValidate(
- { get => 'bq', required => 0, maxlength => 100 },
- { get => 'b', required => 0, multi => 1, enum => [ keys %{$self->{discussion_boards}} ] },
- { get => 't', required => 0 },
- { get => 'p', required => 0, default => 1, template => 'page' },
- );
- return $self->resNotFound if $frm->{_err};
-
- $self->htmlHeader(title => 'Search the discussion board', noindex => 1);
- $self->htmlForm({ frm => $frm, action => '/t/search', method => 'get', nosubmit => 1, noformcode => 1 }, 'boardsearch' => ['Search the discussion board',
- [ input => short => 'bq', name => 'Query' ],
- [ check => short => 't', name => 'Only search thread titles' ],
- [ select => short => 'b', name => 'Boards', multi => 1, size => scalar keys %{$self->{discussion_boards}},
- options => [ map [$_,$self->{discussion_boards}{$_}], keys %{$self->{discussion_boards}} ] ],
- [ static => content => sub {
- input type => 'submit', class => 'submit', tabindex => 10, value => 'Search!';
- } ],
- ]);
- return $self->htmlFooter if !$frm->{bq};
-
- my %boards = map +($_,1), @{$frm->{b}};
- %boards = () if keys %boards == keys %{$self->{discussion_boards}};
-
- my($l, $np);
- if($frm->{t}) {
- ($l, $np) = $self->dbThreadGet(
- keys %boards ? ( type => [keys %boards] ) : (),
- search => $frm->{bq},
- results => 50,
- page => $frm->{p},
- what => 'firstpost lastpost boardtitles',
- sort => 'lastpost', reverse => 1,
- );
- } else {
- # TODO: Allow or-matching too. But what syntax?
- (my $ts = $frm->{bq}) =~ y{+|&:*()="';!?$%^\\[]{}<>~` }{ }s;
- $ts =~ s/ +/ /;
- $ts =~ s/^ //;
- $ts =~ s/ $//;
- $ts =~ s/ / & /g;
- $ts =~ s/(?:^| )-([^ ]+)/ !$1 /;
- ($l, $np) = $self->dbPostGet(
- keys %boards ? ( type => [keys %boards] ) : (),
- search => $ts,
- results => 20,
- page => $frm->{p},
- hide => 1,
- what => 'thread user',
- sort => 'date', reverse => 1,
- headline => {
- # 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 means we can re-use them for highlighting without
- # worrying that they conflict with the message contents.
- MaxFragments => 2, MinWords => 15, MaxWords => 40, StartSel => '[raw]', StopSel => '[/raw]', FragmentDelimiter => '[code]',
- },
- );
- }
-
- my $url = '/t/search?'.join ';', 'bq='.uri_escape($frm->{bq}), $frm->{t} ? 't=1' : (), map "b=$_", keys %boards;
- if(!@$l) {
- div class => 'mainbox';
- h1 'No results';
- p 'No threads or messages found matching your criteria.';
- end;
- } elsif($frm->{t}) {
- _threadlist($self, $l, $frm, $np, $url, 'all');
- } else {
- $self->htmlBrowse(
- items => $l,
- options => $frm,
- nextpage => $np,
- pageurl => $url,
- class => 'postsearch',
- header => [
- sub { td class => 'tc1_1', ''; td class => 'tc1_2', ''; },
- [ 'Date' ],
- [ 'User' ],
- [ 'Message' ],
- ],
- row => sub {
- my($s, $n, $l) = @_;
- my $link = "/t$l->{tid}.$l->{num}";
- Tr;
- td class => 'tc1_1'; a href => $link, 't'.$l->{tid}; end;
- td class => 'tc1_2'; a href => $link, '.'.$l->{num}; end;
- td class => 'tc2', fmtdate $l->{date};
- td class => 'tc3'; lit fmtuser $l->{uid}, $l->{username}; end;
- td class => 'tc4';
- div class => 'title';
- a href => $link, $l->{title};
- end;
- my $h = xml_escape $l->{headline};
- $h =~ s/\[raw\]/<b class="standout">/g;
- $h =~ s/\[\/raw\]/<\/b>/g;
- $h =~ s/\[code\]/<b class="grayedout">...<\/b><br \/>/g;
- div class => 'thread';
- lit $h;
- end;
- end;
- end;
- }
- );
- }
- $self->htmlFooter;
-}
-
-
-sub _threadlist {
- my($self, $list, $f, $np, $url, $board) = @_;
- $self->htmlBrowse(
- items => $list,
- options => $f,
- nextpage => $np,
- pageurl => $url,
- class => 'discussions',
- header => [
- [ 'Topic' ],
- [ 'Replies' ],
- [ 'Starter' ],
- [ 'Last post' ],
- ],
- row => sub {
- my($self, $n, $o) = @_;
- Tr;
- td class => 'tc1';
- a $o->{locked} ? ( class => 'locked' ) : (), href => "/t$o->{id}";
- span class => 'pollflag', '[poll]' if $o->{haspoll};
- txt shorten $o->{title}, 50;
- end;
- b class => 'boards';
- my $i = 1;
- my @boards = sort { $a->{type}.$a->{iid} cmp $b->{type}.$b->{iid} } grep $_->{type}.($_->{iid}||'') ne $board, @{$o->{boards}};
- for(@boards) {
- last if $i++ > 4;
- txt ', ' if $i > 2;
- a href => "/t/$_->{type}".($_->{iid}||''),
- title => $_->{original}||$self->{discussion_boards}{$_->{type}},
- shorten $_->{title}||$self->{discussion_boards}{$_->{type}}, 30;
- }
- txt ', ...' if @boards > 4;
- end;
- end;
- td class => 'tc2', $o->{count}-1;
- td class => 'tc3';
- lit fmtuser $o->{fuid}, $o->{fusername};
- end;
- td class => 'tc4';
- lit fmtuser $o->{luid}, $o->{lusername};
- lit ' @ ';
- a href => "/t$o->{id}.$o->{count}", fmtdate $o->{ldate}, 'full';
- end;
- end 'tr';
- }
- );
-}
-
-
-sub _poll {
- my($self, $t, $url) = @_;
- my($num_votes, $stats, $own_votes) = $self->dbPollStats($t->{id});
- my %own_votes = map +($_ => 1), @$own_votes;
- my $preview = !@$own_votes && $self->reqGet('pollview') && $t->{poll_preview};
- my $allow_vote = $self->authCan('board') && (!@$own_votes || $t->{poll_recast});
-
- div class => 'mainbox poll';
- form action => $url.'/vote', method => 'post';
- h1 class => 'question', $t->{poll_question};
- input type => 'hidden', name => 'formcode', value => $self->authGetCode($url.'/vote') if $allow_vote;
- table class => 'votebooth';
- if($allow_vote && $t->{poll_max_options} > 1) {
- thead; Tr; td colspan => 3;
- i "You may choose up to $t->{poll_max_options} options";
- end; end; end;
- }
- tfoot; Tr;
- td class => 'tc1';
- input type => 'submit', class => 'submit', value => 'Vote' if $allow_vote;
- if(!$self->authCan('board')) {
- b class => 'standout', 'You must be logged in to be able to vote.';
- }
- end;
- td class => 'tc2', colspan => 2;
- if($t->{poll_preview} || @$own_votes) {
- if(!$num_votes) {
- i 'Nobody voted yet.';
- } elsif(!$preview && !@$own_votes) {
- a href => $url.'?pollview=1', id => 'pollpreview', 'View results';
- } else {
- txt sprintf '%d vote%s total', $num_votes, $num_votes == 1 ? '' : 's';
- }
- }
- end;
- end; end;
- tbody;
- my $max = max values %$stats;
- my $show_graph = $max && (@$own_votes || $preview);
- my $graph_width = 200;
- for my $opt (@{$t->{poll_options}}) {
- my $votes = $stats->{$opt->[0]};
- my $own = exists $own_votes{$opt->[0]} ? ' own' : '';
- Tr $own ? (class => 'odd') : ();
- td class => 'tc1';
- label;
- input type => $t->{poll_max_options} > 1 ? 'checkbox' : 'radio', name => 'option', class => 'option', value => $opt->[0], $own ? (checked => '') : () if $allow_vote;
- span class => 'option'.$own, $opt->[1];
- end;
- end;
- if($show_graph) {
- td class => 'tc2';
- div class => 'graph', style => sprintf('width: %dpx', ($votes||0)/$max*$graph_width), ' ';
- div class => 'number', $votes;
- end;
- td class => 'tc3', sprintf('%.3g%%', $votes ? $votes/$num_votes*100 : 0);
- } else {
- td class => 'tc2', colspan => 2, '';
- }
- end;
- }
- end;
- end 'table';
- end 'form';
- end 'div';
-}
-
-
-1;
-
diff --git a/lib/VNDB/Handler/Docs.pm b/lib/VNDB/Handler/Docs.pm
deleted file mode 100644
index 5848eb2b..00000000
--- a/lib/VNDB/Handler/Docs.pm
+++ /dev/null
@@ -1,179 +0,0 @@
-
-package VNDB::Handler::Docs;
-
-
-use strict;
-use warnings;
-use TUWF ':html';
-use VNDB::Func;
-use Text::MultiMarkdown 'markdown';
-
-
-TUWF::register(
- qr{d([1-9]\d*)(?:\.([1-9]\d*))?} => \&page,
- qr{d([1-9]\d*)(?:\.([1-9]\d*))?/edit} => \&edit,
-);
-
-
-sub _html {
- my $content = shift;
-
- $content =~ s{^:MODERATORS:$}{
- my $l = tuwf->dbUserGet(results => 100, sort => 'id', notperm => tuwf->{default_perm}, what => 'extended');
- my $admin = 0;
- $admin |= $_ for values %{ tuwf->{permissions} };
- '<dl>'.join('', map {
- my $u = $_;
- my $p = $u->{perm} >= $admin ? 'admin' : join ', ', sort map +($u->{perm} &~ tuwf->{default_perm}) & tuwf->{permissions}{$_} ? $_ : (), keys %{ tuwf->{permissions} };
- $p ? sprintf('<dt><a href="/u%d">%s</a></dt><dd>%s</dd>', $_->{id}, $_->{username}, $p) : ()
- } @$l).'</dl>';
- }me;
- $content =~ s{^:SKINCONTRIB:$}{
- my %users;
- push @{$users{ tuwf->{skins}{$_}[1] }}, [ $_, tuwf->{skins}{$_}[0] ]
- for sort { tuwf->{skins}{$a}[0] cmp tuwf->{skins}{$b}[0] } keys %{ tuwf->{skins} };
- my $u = tuwf->dbUserGet(uid => [ keys %users ]);
- '<dl>'.join('', map sprintf('<dt><a href="/u%d">%s</a></dt><dd>%s</dd>',
- $_->{id}, $_->{username}, join(', ', map sprintf('<a href="?skin=%s">%s</a>', $_->[0], $_->[1]), @{$users{$_->{id}}})
- ), @$u).'</dl>';
- }me;
-
- my $html = markdown $content, {
- strip_metadata => 1,
- img_ids => 0,
- disable_footnotes => 1,
- disable_bibliography => 1,
- };
-
- # Number sections and turn them into links
- my($sec, $subsec) = (0,0);
- $html =~ s{<h([1-2])[^>]+>(.*?)</h\1>}{
- if($1 == 1) {
- $sec++;
- $subsec = 0;
- qq{<h3><a href="#$sec" name="$sec">$sec. $2</a></h3>}
- } elsif($1 == 2) {
- $subsec++;
- qq|<h4><a href="#$sec.$subsec" name="$sec.$subsec">$sec.$subsec. $2</a></h4>\n|
- }
- }ge;
-
- # Text::MultiMarkdown doesn't handle fenced code blocks properly. The
- # following solution breaks inline code blocks, but I don't use those anyway.
- $html =~ s/<code>/<pre>/g;
- $html =~ s#</code>#</pre>#g;
-
- $html
-}
-
-
-sub page {
- my($self, $id, $rev) = @_;
-
- my $method = $rev ? 'dbDocGetRev' : 'dbDocGet';
- my $d = $self->$method(id => $id, $rev ? ( rev => $rev ) : ())->[0];
- return $self->resNotFound if !$d->{id};
-
- $self->htmlHeader(title => $d->{title}, noindex => $rev);
- $self->htmlMainTabs(d => $d);
- return if $self->htmlHiddenMessage('d', $d);
-
- if($rev) {
- my $prev = $rev && $rev > 1 && $self->dbDocGetRev(id => $id, rev => $rev-1)->[0];
- $self->htmlRevision('d', $prev, $d,
- [ title => 'Title', diff => 1 ],
- [ content => 'Content', diff => qr/\s+/, short_diff => 1 ],
- );
- }
-
- div class => 'mainbox';
- h1 $d->{title};
- div class => 'docs';
- ul class => 'index';
- li; b 'Guidelines'; end;
- li; a href => '/d5', 'Editing Guidelines'; end;
- li; a href => '/d2', 'Visual Novels'; end;
- li; a href => '/d15', 'Special Games'; end;
- li; a href => '/d3', 'Releases'; end;
- li; a href => '/d4', 'Producers'; end;
- li; a href => '/d16', 'Staff'; end;
- li; a href => '/d12', 'Characters'; end;
- li; a href => '/d10', 'Tags & Traits'; end;
- li; a href => '/d13', 'Capturing Screenshots'; end;
- li; b 'About VNDB'; end;
- li; a href => '/d9', 'Discussion Board'; end;
- li; a href => '/d6', 'FAQ'; end;
- li; a href => '/d7', 'About Us'; end;
- li; a href => '/d17', 'Privacy Policy'; end;
- li; a href => '/d11', 'Database API'; end;
- li; a href => '/d14', 'Database Dumps'; end;
- li; a href => '/d8', 'Development'; end;
- end;
- lit _html $d->{content};
- end;
- end;
- $self->htmlFooter;
-}
-
-
-sub edit {
- my($self, $id, $rev) = @_;
-
- my $d = $self->dbDocGetRev(id => $id, rev => $rev)->[0];
- return $self->resNotFound if !$d->{id};
- $rev = undef if $d->{lastrev};
-
- return $self->htmlDenied if !$self->authCan('dbmod');
-
- my %b4 = map { $_ => $d->{$_} } qw|title content ihid ilock|;
- my $frm;
-
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'title', maxlength => 200 },
- { post => 'content', },
- { post => 'editsum', template => 'editsum' },
- { post => 'ihid', required => 0 },
- { post => 'ilock', required => 0 },
- { post => 'preview', required => 0 },
- );
- if(!$frm->{_err} && !$frm->{preview}) {
- $frm->{ihid} = $frm->{ihid}?1:0;
- $frm->{ilock} = $frm->{ilock}?1:0;
-
- return $self->resRedirect("/d$id", 'post') if !form_compare(\%b4, $frm);
- my $nrev = $self->dbItemEdit(d => $id, $d->{rev}, %$frm);
- return $self->resRedirect("/d$nrev->{itemid}.$nrev->{rev}", 'post');
- }
- }
-
- !defined $frm->{$_} && ($frm->{$_} = $b4{$_}) for keys %b4;
- $frm->{editsum} = sprintf 'Reverted to revision d%d.%d', $id, $rev if $rev && !defined $frm->{editsum};
- delete $frm->{_err} if $frm->{preview};
-
- my $title = "Edit $d->{title}";
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('d', $d, 'edit');
-
- if($frm->{preview}) {
- div class => 'mainbox';
- h1 'Preview';
- div class => 'docs';
- lit _html $frm->{content};
- end;
- end;
- }
-
- $self->htmlForm({ frm => $frm, action => "/d$id/edit", editsum => 1, preview => 1 }, dedit => [ $title,
- [ input => name => 'Title', short => 'title', width => 300 ],
- [ static => nolabel => 1, content => q{
- <br>Contents (HTML and MultiMarkdown supported, which is
- <a href="https://daringfireball.net/projects/markdown/basics">Markdown</a>
- with some <a href="http://fletcher.github.io/MultiMarkdown-5/syntax.html">extensions</a>).} ],
- [ textarea => short => 'content', name => 'Content', rows => 50, cols => 90, nolabel => 1 ],
- ]);
- $self->htmlFooter;
-}
-
-1;
diff --git a/lib/VNDB/Handler/Misc.pm b/lib/VNDB/Handler/Misc.pm
deleted file mode 100644
index 771c9d83..00000000
--- a/lib/VNDB/Handler/Misc.pm
+++ /dev/null
@@ -1,350 +0,0 @@
-
-package VNDB::Handler::Misc;
-
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml', 'uri_escape';
-use VNDB::Func;
-
-
-TUWF::register(
- qr{}, \&homepage,
- qr{(?:([upvrcsd])([1-9]\d*)/)?hist},\&history,
- 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 => [ keys %{$self->{atom_feeds}} ], 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 ".fmtuser($_);
- 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 $self->{discussion_boards}{$_->{type}}.($_->{iid}?' > '.$_->{title}:''), @{$_->{boards}};
- li;
- txt fmtage($_->{ldate}).' ';
- a href => "/t$_->{id}.$_->{count}", title => "Posted in $boards", shorten $_->{title}, 25;
- lit ' by '.fmtuser($_->{luid}, $_->{lusername});
- 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 $_, $self->{platforms}{$_} for (@{$_->{platforms}});
- cssicon "lang $_", $self->{languages}{$_} 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 $_, $self->{platforms}{$_} for (@{$_->{platforms}});
- cssicon "lang $_", $self->{languages}{$_} for (@{$_->{languages}});
- txt ' ';
- a href => "/r$_->{id}", title => $_->{original}||$_->{title}, shorten $_->{title}, 30;
- end;
- }
- end;
- end 'td';
-
- end 'tr';
- end 'table';
-
- $self->htmlFooter;
-}
-
-
-sub history {
- my($self, $type, $id) = @_;
- $type ||= '';
- $id ||= 0;
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'm', required => 0, default => !$type, enum => [ 0, 1 ] },
- { get => 'h', required => 0, default => 0, enum => [ -1..1 ] },
- { get => 't', required => 0, default => '', enum => [qw|v r p c s d a|] },
- { get => 'e', required => 0, default => 0, enum => [ -1..1 ] },
- { get => 'r', required => 0, default => 0, enum => [ 0, 1 ] },
- );
- return $self->resNotFound if $f->{_err};
-
- # get item object and title
- my $obj = $type eq 'u' ? $self->dbUserGet(uid => $id, what => 'hide_list')->[0] :
- $type eq 'p' ? $self->dbProducerGet(id => $id)->[0] :
- $type eq 'r' ? $self->dbReleaseGet(id => $id)->[0] :
- $type eq 'c' ? $self->dbCharGet(id => $id)->[0] :
- $type eq 's' ? $self->dbStaffGet(id => $id)->[0] :
- $type eq 'd' ? $self->dbDocGet(id => $id)->[0] :
- $type eq 'v' ? $self->dbVNGet(id => $id)->[0] : undef;
- return $self->resNotFound if $type && !$obj->{id};
- my $title = $type ? 'Edit history of '.($obj->{title} || $obj->{name} || $obj->{username}) : 'Recent changes';
-
- # get the edit history
- my($list, $np) = $self->dbRevisionGet(
- $type && $type ne 'u' ? ( type => $type, itemid => $id ) : (),
- $type eq 'u' ? ( uid => $id ) : (),
- $f->{t} ? ( type => $f->{t} eq 'a' ? [qw|v r p s d|] : $f->{t} ) : (),
- page => $f->{p},
- results => 50,
- auto => $f->{m},
- hidden => $type && $type ne 'u' ? 0 : $f->{h},
- edit => $f->{e},
- releases => $f->{r},
- );
-
- $self->htmlHeader(title => $title, noindex => 1, feeds => [ 'changes' ]);
- $self->htmlMainTabs($type, $obj, 'hist') if $type;
-
- # url generator
- my $u = sub {
- my($n, $v) = @_;
- $n ||= '';
- local $_ = ($type ? "/$type$id" : '').'/hist';
- $_ .= '?m='.($n eq 'm' ? $v : $f->{m});
- $_ .= ';h='.($n eq 'h' ? $v : $f->{h});
- $_ .= ';t='.($n eq 't' ? $v : $f->{t});
- $_ .= ';e='.($n eq 'e' ? $v : $f->{e});
- $_ .= ';r='.($n eq 'r' ? $v : $f->{r});
- };
-
- # filters
- div class => 'mainbox';
- h1 $title;
- if($type ne 'u') {
- p class => 'browseopts';
- a !$f->{m} ? (class => 'optselected') : (), href => $u->(m => 0), 'Show automated edits';
- a $f->{m} ? (class => 'optselected') : (), href => $u->(m => 1), 'Hide automated edits';
- end;
- }
- if(!$type || $type eq 'u') {
- if($self->authCan('dbmod')) {
- p class => 'browseopts';
- a $f->{h} == 1 ? (class => 'optselected') : (), href => $u->(h => 1), 'Hide deleted items';
- a $f->{h} == -1 ? (class => 'optselected') : (), href => $u->(h => -1), 'Show deleted items';
- end;
- }
- p class => 'browseopts';
- a !$f->{t} ? (class => 'optselected') : (), href => $u->(t => ''), 'Show all items';
- a $f->{t} eq 'v' ? (class => 'optselected') : (), href => $u->(t => 'v'), 'Only visual novels';
- a $f->{t} eq 'r' ? (class => 'optselected') : (), href => $u->(t => 'r'), 'Only releases';
- a $f->{t} eq 'p' ? (class => 'optselected') : (), href => $u->(t => 'p'), 'Only producers';
- a $f->{t} eq 's' ? (class => 'optselected') : (), href => $u->(t => 's'), 'Only staff';
- a $f->{t} eq 'c' ? (class => 'optselected') : (), href => $u->(t => 'c'), 'Only characters';
- a $f->{t} eq 'd' ? (class => 'optselected') : (), href => $u->(t => 'd'), 'Only docs';
- a $f->{t} eq 'a' ? (class => 'optselected') : (), href => $u->(t => 'a'), 'All except characters';
- end;
- p class => 'browseopts';
- a !$f->{e} ? (class => 'optselected') : (), href => $u->(e => 0), 'Show all changes';
- a $f->{e} == 1 ? (class => 'optselected') : (), href => $u->(e => 1), 'Only edits';
- a $f->{e} == -1 ? (class => 'optselected') : (), href => $u->(e => -1), 'Only newly created pages';
- end;
- }
- if($type eq 'v') {
- p class => 'browseopts';
- a !$f->{r} ? (class => 'optselected') : (), href => $u->(r => 0), 'Exclude edits of releases';
- a $f->{r} ? (class => 'optselected') : (), href => $u->(r => 1), 'Include edits of releases';
- end;
- }
- end 'div';
-
- $self->htmlBrowseHist($list, $f, $np, $u->());
- $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"
- if -s "$VNDB::ROOT/www/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 f8bddf9b..00000000
--- a/lib/VNDB/Handler/Producers.pm
+++ /dev/null
@@ -1,502 +0,0 @@
-
-package VNDB::Handler::Producers;
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml', 'xml_escape', 'html_escape';
-use VNDB::Func;
-
-
-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};
-
- 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 { $self->{producer_types}{$_[0]} } ],
- [ name => 'Name (romaji)', diff => 1 ],
- [ original => 'Original name', diff => 1 ],
- [ alias => 'Aliases', diff => qr/[ ,\n\.]/ ],
- [ lang => 'Language', serialize => sub { "$_[0] ($self->{languages}{$_[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]'
- }],
- [ desc => 'Description', diff => qr/[ ,\n\.]/ ],
- [ relations => 'Relations', join => '<br />', split => sub {
- my @r = map sprintf('%s: <a href="/p%d" title="%s">%s</a>',
- $self->{prod_relations}{$_->{relation}}[1], $_->{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', $p->{original} if $p->{original};
- p class => 'center';
- txt "$self->{languages}{$p->{lang}} $self->{producer_types}{$p->{type}}";
- if($p->{alias}) {
- (my $alias = $p->{alias}) =~ s/\n/, /g;
- br;
- txt "a.k.a. $alias";
- }
-
- my @links = (
- $p->{website} ? [ 'Homepage', $p->{website} ] : (),
- $p->{l_wp} ? [ 'Wikipedia', "http://en.wikipedia.org/wiki/$p->{l_wp}" ] : (),
- );
- br if @links;
- for(@links) {
- a href => $_->[1], $_->[0];
- txt ' - ' if $_ ne $links[$#links];
- }
- 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 %{$self->{prod_relations}}) {
- next if !$rel{$r};
- txt $self->{prod_relations}{$r}[1].': ';
- 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');
- 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 $_, $self->{platforms}{$_};
- }
- cssicon "lang $_", $self->{languages}{$_} 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';
- if($rel->{website}) {
- a href => $rel->{website}, rel => 'nofollow';
- cssicon 'external', 'External link';
- end;
- } else {
- txt ' ';
- }
- 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 desc alias ihid ilock|),
- l_wp => $p->{l_wp} || '',
- 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 %{$self->{producer_types}} ] },
- { post => 'name', maxlength => 200 },
- { post => 'original', required => 0, maxlength => 200, default => '' },
- { post => 'alias', required => 0, maxlength => 500, default => '' },
- { post => 'lang', required => !$nosubmit, enum => [ keys %{$self->{languages}} ] },
- { post => 'website', required => 0, maxlength => 250, default => '', template => 'weburl' },
- { post => 'l_wp', required => 0, maxlength => 150, default => '' },
- { 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 },
- );
- 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;
- $frm->{l_wp} = undef if !$frm->{l_wp};
- 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 [ $_, $self->{producer_types}{$_} ], keys %{$self->{producer_types}} ] ],
- [ 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 [ $_, "$_ ($self->{languages}{$_})" ], keys %{$self->{languages}} ] ],
- [ input => name => 'Website', short => 'website' ],
- [ input => name => 'Wikipedia link', short => 'l_wp', pre => 'http://en.wikipedia.org/wiki/' ],
- [ 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 => $_, $self->{prod_relations}{$_}[1]
- for (keys %{$self->{prod_relations}});
- 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{$_} = $self->{prod_relations}{$$new{$_}}[0];
- }
- }
- 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}, $self->{languages}{$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 0c649cc4..00000000
--- a/lib/VNDB/Handler/Releases.pm
+++ /dev/null
@@ -1,711 +0,0 @@
-
-package VNDB::Handler::Releases;
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml', 'uri_escape';
-use VNDB::Func;
-
-
-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,
-);
-
-
-sub page {
- my($self, $rid, $rev) = @_;
-
- my $method = $rev ? 'dbReleaseGetRev' : 'dbReleaseGet';
- my $r = $self->$method(
- id => $rid,
- what => 'vn extended producers platforms media',
- $rev ? (rev => $rev) : (),
- )->[0];
- return $self->resNotFound if !$r->{id};
-
- 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 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 $self->{languages}{$_}, @{$_[0]} } ],
- [ website => 'Website' ],
- [ released => 'Release date', htmlize => \&fmtdatestr ],
- [ minage => 'Age rating', serialize => \&minage ],
- [ notes => 'Notes', diff => qr/[ ,\n\.]/ ],
- [ platforms => 'Platforms', join => ', ', split => sub { map $self->{platforms}{$_}, @{$_[0]} } ],
- [ media => 'Media', join => ', ', split => sub { map fmtmedia($_->{medium}, $_->{qty}), @{$_[0]} } ],
- [ resolution => 'Resolution', serialize => sub { $self->{resolutions}{$_[0]}[0]; } ],
- [ voiced => 'Voiced', serialize => sub { $self->{voiced}[$_[0]] } ],
- [ ani_story => 'Story animation', serialize => sub { $self->{animated}[$_[0]] } ],
- [ ani_ero => 'Ero animation', serialize => sub { $self->{animated}[$_[0]] } ],
- [ engine => 'Engine' ],
- [ producers => 'Producers', join => '<br />', split => sub {
- map sprintf('<a href="/p%d" title="%s">%s</a> (%s)', $_->{id}, $_->{original}||$_->{name}, shorten($_->{name}, 50),
- join(', ', $_->{developer} ? 'developer' :(), $_->{publisher} ? 'publisher' :())
- ), @{$_[0]};
- } ],
- );
- }
-
- div class => 'mainbox release';
- $self->htmlItemMessage('r', $r);
- h1 $r->{title};
- h2 class => 'alttitle', $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 $r->{original};
- end;
- }
-
- Tr;
- td 'Type';
- td;
- cssicon "rt$r->{type}", $r->{type};
- txt sprintf ' %s%s', ucfirst($r->{type}), $r->{patch} ? ', patch' : '';
- end;
- end;
-
- Tr;
- td 'Language';
- td;
- for (@{$r->{languages}}) {
- cssicon "lang $_", $self->{languages}{$_};
- txt ' '.$self->{languages}{$_};
- 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 $_, $self->{platforms}{$_};
- txt ' '.$self->{platforms}{$_};
- 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 $self->{resolutions}{$r->{resolution}}[0];
- end;
- }
-
- if($r->{voiced}) {
- Tr;
- td 'Voiced';
- td $self->{voiced}[$r->{voiced}];
- end;
- }
-
- if($r->{ani_story} || $r->{ani_ero}) {
- Tr;
- td 'Animation';
- td join ', ',
- $r->{ani_story} ? "Story: $self->{animated}[$r->{ani_story}]" : (),
- $r->{ani_ero} ? "Ero scenes: $self->{animated}[$r->{ani_ero}]" : ();
- 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->{website}) {
- Tr;
- td 'Links';
- td;
- a href => $r->{website}, rel => 'nofollow', 'Official website';
- 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: $self->{rlist_status}[$rl->{status}]";
- optgroup label => 'Set status';
- option value => $_, $self->{rlist_status}[$_]
- for (0..$#{$self->{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 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 gtin catalog languages website released minage
- notes platforms patch resolution voiced freeware doujin uncensored ani_story ani_ero engine ihid ilock|),
- 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;
- $frm = $self->formValidate(
- { post => 'type', enum => $self->{release_types} },
- { 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 %{$self->{languages}} ] },
- { post => 'website', required => 0, default => '', maxlength => 250, template => 'weburl' },
- { post => 'released', required => 0, default => 0, template => 'rdate' },
- { post => 'minage' , required => 0, default => -1, enum => $self->{age_ratings} },
- { post => 'notes', required => 0, default => '', maxlength => 10240 },
- { post => 'platforms', required => 0, default => '', multi => 1, enum => [ keys %{$self->{platforms}} ] },
- { post => 'media', required => 0, default => '' },
- { post => 'resolution',required => 0, default => 0, enum => [ keys %{$self->{resolutions}} ] },
- { post => 'voiced', required => 0, default => 0, enum => [ 0..$#{$self->{voiced}} ] },
- { post => 'ani_story', required => 0, default => 0, enum => [ 0..$#{$self->{animated}} ] },
- { post => 'ani_ero', required => 0, default => 0, enum => [ 0..$#{$self->{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};
-
- 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;
-
- 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
- notes platforms resolution editsum patch voiced freeware doujin uncensored ani_story ani_ero engine ihid ilock|),
- 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 [ $_, ucfirst $_ ], @{$self->{release_types}} ] ],
- [ 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,
- options => [ map [ $_, "$_ ($self->{languages}{$_})" ], keys %{$self->{languages}} ] ],
- [ 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 ], @{$self->{age_ratings}} ] ],
- [ check => short => 'uncensored',name => 'No mosaic or other optical censoring (only check if this release has erotic content)' ],
- [ 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 [ $_, @{$self->{resolutions}{$_}} ], keys %{$self->{resolutions}} ] ],
- [ 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 [ $_, $self->{voiced}[$_] ], 0..$#{$self->{voiced}} ] ],
- [ select => short => 'ani_story', name => 'Story animation', options => [
- map [ $_, $self->{animated}[$_] ], 0..$#{$self->{animated}} ] ],
- [ select => short => 'ani_ero', name => 'Ero animation', options => [
- map [ $_, $_ ? $self->{animated}[$_] : 'Unknown / no ero scenes' ], 0..$#{$self->{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 %{$self->{platforms}}) {
- span;
- input type => 'checkbox', name => 'platforms', value => $p, id => $p,
- $frm->{platforms} && grep($_ eq $p, @{$frm->{platforms}}) ? (checked => 'checked') : ();
- label for => $p;
- cssicon $p, $self->{platforms}{$p};
- txt ' '.$self->{platforms}{$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 $_, $self->{platforms}{$_} for (@{$l->{platforms}});
- cssicon "lang $_", $self->{languages}{$_} 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 %{$self->{languages}} ] },
- { get => 'pl', required => 0, multi => 1, default => '', enum => [ keys %{$self->{platforms}} ] },
- { get => 'me', required => 0, multi => 1, default => '', enum => [ keys %{$self->{media}} ] },
- { get => 'tp', required => 0, default => '', enum => [ '', @{$self->{release_types}} ] },
- { 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 => $self->{age_ratings} },
- { 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} <= $_), @{$self->{age_ratings}} ] 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;
-}
-
-
-1;
-
diff --git a/lib/VNDB/Handler/Staff.pm b/lib/VNDB/Handler/Staff.pm
deleted file mode 100644
index 25c30073..00000000
--- a/lib/VNDB/Handler/Staff.pm
+++ /dev/null
@@ -1,398 +0,0 @@
-
-package VNDB::Handler::Staff;
-
-use strict;
-use warnings;
-use TUWF qw(:html :xml uri_escape xml_escape);
-use VNDB::Func;
-use List::Util qw(first);
-
-TUWF::register(
- qr{s([1-9]\d*)(?:\.([1-9]\d*))?} => \&page,
- qr{s(?:([1-9]\d*)(?:\.([1-9]\d*))?/edit|/new)}
- => \&edit,
- qr{s/([a-z0]|all)} => \&list,
- qr{xml/staff\.xml} => \&staffxml,
-);
-
-
-sub page {
- my($self, $id, $rev) = @_;
-
- my $method = $rev ? 'dbStaffGetRev' : 'dbStaffGet';
- my $s = $self->$method(
- id => $id,
- what => 'extended aliases roles',
- $rev ? ( rev => $rev ) : ()
- )->[0];
- return $self->resNotFound if !$s->{id};
-
- my $metadata = {
- 'og:title' => $s->{name},
- 'og:description' => bb2text $s->{desc},
- };
-
- $self->htmlHeader(title => $s->{name}, noindex => $rev, metadata => $metadata);
- $self->htmlMainTabs('s', $s) if $id;
- return if $self->htmlHiddenMessage('s', $s);
-
- if($rev) {
- my $prev = $rev && $rev > 1 && $self->dbStaffGetRev(id => $id, rev => $rev-1, what => 'extended aliases')->[0];
- $self->htmlRevision('s', $prev, $s,
- [ name => 'Name (romaji)', diff => 1 ],
- [ original => 'Original name', diff => 1 ],
- [ gender => 'Gender', serialize => sub { $self->{genders}{$_[0]} } ],
- [ lang => 'Language', serialize => sub { "$_[0] ($self->{languages}{$_[0]})" } ],
- [ l_site => 'Official page', 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_twitter => 'Twitter account', diff => 1 ],
- [ l_anidb => 'AniDB creator ID', serialize => sub { $_[0] // '' } ],
- [ desc => 'Description', diff => qr/[ ,\n\.]/ ],
- [ aliases => 'Aliases', join => '<br />', split => sub {
- map xml_escape(sprintf('%s%s', $_->{name}, $_->{original} ? ' ('.$_->{original}.')' : '')), @{$_[0]};
- }],
- );
- }
-
- div class => 'mainbox staffpage';
- $self->htmlItemMessage('s', $s);
- h1 $s->{name};
- h2 class => 'alttitle', $s->{original} if $s->{original};
-
- # info table
- table class => 'stripe';
- thead;
- Tr;
- td colspan => 2;
- b style => 'margin-right: 10px', $s->{name};
- b class => 'grayedout', style => 'margin-right: 10px', $s->{original} if $s->{original};
- cssicon "gen $s->{gender}", $self->{genders}{$s->{gender}} if $s->{gender} ne 'unknown';
- end;
- end;
- end;
- Tr;
- td class => 'key', 'Language';
- td $self->{languages}{$s->{lang}};
- end;
- if(@{$s->{aliases}}) {
- Tr;
- td class => 'key', @{$s->{aliases}} == 1 ? 'Alias' : 'Aliases';
- td;
- table class => 'aliases';
- for my $alias (@{$s->{aliases}}) {
- Tr class => 'nostripe';
- td $alias->{original} ? () : (colspan => 2), class => 'key';
- txt $alias->{name};
- end;
- td $alias->{original} if $alias->{original};
- end;
- }
- end;
- end;
- end;
- }
- my @links = (
- $s->{l_site} ? [ 'Official page', $s->{l_site} ] : (),
- $s->{l_wp} ? [ 'Wikipedia', "http://en.wikipedia.org/wiki/$s->{l_wp}" ] : (),
- $s->{l_twitter} ? [ 'Twitter', "https://twitter.com/$s->{l_twitter}" ] : (),
- $s->{l_anidb} ? [ 'AniDB', "http://anidb.net/cr$s->{l_anidb}" ] : (),
- );
- if(@links) {
- Tr;
- td class => 'key', 'Links';
- td;
- for(@links) {
- a href => $_->[1], $_->[0];
- br if $_ != $links[$#links];
- }
- end;
- end;
- }
- end 'table';
-
- # description
- p class => 'description';
- lit bb2html $s->{desc}, 0, 1;
- end;
- end;
-
- _roles($self, $s);
- _cast($self, $s);
- $self->htmlFooter;
-}
-
-
-sub _roles {
- my($self, $s) = @_;
- return if !@{$s->{roles}};
-
- h1 class => 'boxtitle', 'Credits';
- $self->htmlBrowse(
- items => $s->{roles},
- class => 'staffroles',
- header => [
- [ 'Title' ],
- [ 'Released' ],
- [ 'Role' ],
- [ 'As' ],
- [ 'Note' ],
- ],
- row => sub {
- my($r, $n, $l) = @_;
- Tr;
- td class => 'tc1'; a href => "/v$l->{vid}", title => $l->{t_original}||$l->{title}, shorten $l->{title}, 60; end;
- td class => 'tc2'; lit fmtdatestr $l->{c_released}; end;
- td class => 'tc3', $self->{staff_roles}{$l->{role}};
- td class => 'tc4', title => $l->{original}||$l->{name}, $l->{name};
- td class => 'tc5', $l->{note};
- end;
- },
- );
-}
-
-
-sub _cast {
- my($self, $s) = @_;
- return if !@{$s->{cast}};
-
- h1 class => 'boxtitle', sprintf 'Voiced characters (%d)', scalar @{$s->{cast}};
- $self->htmlBrowse(
- items => $s->{cast},
- class => 'staffroles',
- header => [
- [ 'Title' ],
- [ 'Released' ],
- [ 'Cast' ],
- [ 'As' ],
- [ 'Note' ],
- ],
- row => sub {
- my($r, $n, $l) = @_;
- Tr;
- td class => 'tc1'; a href => "/v$l->{vid}", title => $l->{t_original}||$l->{title}, shorten $l->{title}, 60; end;
- td class => 'tc2'; lit fmtdatestr $l->{c_released}; end;
- td class => 'tc3'; a href => "/c$l->{cid}", title => $l->{c_original}, $l->{c_name}; end;
- td class => 'tc4', title => $l->{original}||$l->{name}, $l->{name};
- td class => 'tc5', $l->{note};
- end;
- },
- );
-}
-
-
-sub edit {
- my($self, $sid, $rev) = @_;
-
- my $s = $sid && $self->dbStaffGetRev(id => $sid, what => 'extended aliases roles', $rev ? (rev => $rev) : ())->[0];
- return $self->resNotFound if $sid && !$s->{id};
- $rev = undef if !$s || $s->{lastrev};
-
- return $self->htmlDenied if !$self->authCan('edit')
- || $sid && (($s->{locked} || $s->{hidden}) && !$self->authCan('dbmod'));
-
- my %b4 = !$sid ? () : (
- (map { $_ => $s->{$_} } qw|name original gender lang desc l_wp l_site l_twitter l_anidb ihid ilock|),
- primary => $s->{aid},
- aliases => [
- map +{ aid => $_->{aid}, name => $_->{name}, orig => $_->{original} },
- sort { $a->{name} cmp $b->{name} || $a->{original} cmp $b->{original} } @{$s->{aliases}}
- ],
- );
- my $frm;
-
- if ($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate (
- { post => 'name', maxlength => 200 },
- { post => 'original', required => 0, maxlength => 200, default => '' },
- { post => 'primary', required => 0, template => 'id', default => 0 },
- { post => 'desc', required => 0, maxlength => 5000, default => '' },
- { post => 'gender', required => 0, default => 'unknown', enum => [qw|unknown m f|] },
- { post => 'lang', enum => [ keys %{$self->{languages}} ] },
- { post => 'l_wp', required => 0, maxlength => 150, default => '' },
- { post => 'l_site', required => 0, template => 'weburl', maxlength => 250, default => '' },
- { post => 'l_twitter', required => 0, maxlength => 16, default => '', regex => [ qr/^\S+$/, 'Invalid twitter username' ] },
- { post => 'l_anidb', required => 0, template => 'id', default => undef },
- { post => 'aliases', template => 'json', json_sort => ['name','orig'], json_fields => [
- { field => 'name', required => 1, maxlength => 200 },
- { field => 'orig', required => 0, maxlength => 200, default => '' },
- { field => 'aid', required => 0, template => 'id', default => 0 },
- ]},
- { post => 'editsum', template => 'editsum' },
- { post => 'ihid', required => 0 },
- { post => 'ilock', required => 0 },
- );
-
- if(!$frm->{_err}) {
- my %old_aliases = $sid ? ( map +($_->{aid} => 1), @{$self->dbStaffAliasIds($sid)} ) : ();
- $frm->{primary} = 0 unless exists $old_aliases{$frm->{primary}};
-
- # reset aid to zero for newly added aliases.
- $_->{aid} *= $old_aliases{$_->{aid}} ? 1 : 0 for(@{$frm->{aliases}});
-
- # Make sure no aliases that have been linked to a VN are removed.
- my %new_aliases = map +($_, 1), grep $_, $frm->{primary}, map $_->{aid}, @{$frm->{aliases}};
- $frm->{_err} = [ "Can't remove an alias that is still linked to a VN." ]
- if grep !$new_aliases{$_->{aid}}, @{$s->{roles}}, @{$self->{cast}};
- }
-
- if(!$frm->{_err}) {
- $frm->{ihid} = $frm->{ihid} ?1:0;
- $frm->{ilock} = $frm->{ilock}?1:0;
- $frm->{aid} = $frm->{primary} if $sid;
- $frm->{desc} = $self->bbSubstLinks($frm->{desc});
- return $self->resRedirect("/s$sid", 'post') if $sid && !form_compare(\%b4, $frm);
-
- my $nrev = $self->dbItemEdit(s => $sid ? ($s->{id}, $s->{rev}) : (undef, undef), %$frm);
- return $self->resRedirect("/s$nrev->{itemid}.$nrev->{rev}", 'post');
- }
- }
-
- $frm->{$_} //= $b4{$_} for keys %b4;
- $frm->{editsum} //= sprintf 'Reverted to revision s%d.%d', $sid, $rev if $rev;
- $frm->{lang} = 'ja' if !$sid && !defined $frm->{lang};
-
- my $title = $s ? "Edit $s->{name}" : 'Add staff member';
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('s', $s, 'edit') if $s;
- $self->htmlEditMessage('s', $s, $title);
- $self->htmlForm({ frm => $frm, action => $s ? "/s$sid/edit" : '/s/new', editsum => 1 },
- staffe_geninfo => [ 'General info',
- [ hidden => short => 'name' ],
- [ hidden => short => 'original' ],
- [ hidden => short => 'primary' ],
- [ json => short => 'aliases' ],
- $sid && @{$s->{aliases}} ?
- [ static => content => 'You may choose a different primary name.' ] : (),
- [ static => label => 'Names', content => sub {
- table id => 'names';
- thead; Tr;
- td class => 'tc_id'; end;
- td class => 'tc_name', 'Name (romaji)';
- td class => 'tc_original', 'Original'; td; end;
- end; end;
- tbody id => 'alias_tbl';
- # filled with javascript
- end;
- end;
- }],
- [ static => content => '<br />' ],
- [ text => name => 'Staff note<br /><b class="standout">English please!</b>', short => 'desc', rows => 4 ],
- [ select => name => 'Gender',short => 'gender', options => [
- map [ $_, $self->{genders}{$_} ], qw(unknown m f) ] ],
- [ select => name => 'Primary language', short => 'lang',
- options => [ map [ $_, "$_ ($self->{languages}{$_})" ], keys %{$self->{languages}} ] ],
- [ input => name => 'Official page', short => 'l_site' ],
- [ input => name => 'Wikipedia link', short => 'l_wp', pre => 'http://en.wikipedia.org/wiki/' ],
- [ input => name => 'Twitter username', short => 'l_twitter' ],
- [ input => name => 'AniDB creator ID', short => 'l_anidb' ],
- [ static => content => '<br />' ],
- ]);
-
- $self->htmlFooter;
-}
-
-
-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;
- my $gender = $list->[$_]{gender};
- cssicon 'lang '.$list->[$_]{lang}, $self->{languages}{$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 69fc005b..00000000
--- a/lib/VNDB/Handler/Tags.pm
+++ /dev/null
@@ -1,795 +0,0 @@
-
-package VNDB::Handler::Tags;
-
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml', 'xml_escape';
-use VNDB::Func;
-
-
-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{g/links}, \&taglinks,
- 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 $self->{tag_categories}{$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;
- }
- p; br; txt 'The list below also includes all visual novels linked to child tags. This list is cached, it can take up to 24 hours after a visual novel has been tagged for it to show up on this page.'; 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 %{$self->{tag_categories}} ] },
- { 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 => fmtuser($t->{addedby}, $t->{username}) ] : (),
- [ 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 [$_, $self->{tag_categories}{$_}], keys %{$self->{tag_categories}} ] ],
- $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 taglinks {
- my $self = shift;
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'o', required => 0, default => 'd', enum => ['a', 'd'] },
- { get => 's', required => 0, default => 'date', enum => [qw|date tag|] },
- { get => 'v', required => 0, default => 0, template => 'id' },
- { get => 'u', required => 0, default => 0, template => 'id' },
- { get => 't', required => 0, default => 0, template => 'id' },
- );
- return $self->resNotFound if $f->{_err} || $f->{p} > 100;
-
- my($list, $np) = $self->dbTagLinks(
- what => 'details',
- results => 50,
- page => $f->{p},
- sort => $f->{s},
- reverse => $f->{o} eq 'd',
- $f->{v} ? (vid => $f->{v}) : (),
- $f->{u} ? (uid => $f->{u}) : (),
- $f->{t} ? (tag => $f->{t}) : (),
- );
-
- my $url = sub {
- my %f = ((map +($_,$f->{$_}), qw|s o v u t|), @_);
- my $qs = join ';', map $f{$_}?"$_=$f{$_}":(), keys %f;
- return '/g/links'.($qs?"?$qs":'')
- };
-
- $self->htmlHeader(noindex => 1, title => 'Tag link browser');
- div class => 'mainbox';
- h1 'Tag link browser';
-
- div class => 'warning';
- h2 'Spoiler warning';
- p 'This list displays the tag votes of individual users. Spoilery tags are not hidden, and may not even be correctly flagged as such.';
- end;
- br;
-
- if($f->{u} || $f->{t} || $f->{v}) {
- p 'Active filters:';
- ul;
- if($f->{u}) {
- my $o = $self->dbUserGet(uid => $f->{u})->[0];
- li;
- txt '['; a href => $url->(u=>0), 'remove'; txt '] ';
- txt 'User: ';
- a href => "/u$f->{u}", $o->{username}||'Unknown user';
- end;
- }
- if($f->{t}) {
- my $o = $self->dbTagGet(id => $f->{t})->[0];
- li;
- txt '['; a href => $url->(t=>0), 'remove'; txt '] ';
- txt 'Tag:'; txt ' ';
- a href => "/g$f->{t}", $o->{name}||'Unknown tag';
- end;
- }
- if($f->{v}) {
- my $o = $self->dbVNGet(id => $f->{v})->[0];
- li;
- txt '['; a href => $url->(v=>0), 'remove'; txt '] ';
- txt 'Visual novel:'; txt ' ';
- a href => "/v$f->{v}", $o->{title}||'Unknown VN';
- end;
- }
- end 'ul';
- }
- p 'Click the arrow beside a user, tag or VN to add it as a filter.' unless $f->{v} && $f->{u} && $f->{t};
- end 'div';
-
- $self->htmlBrowse(
- class => 'taglinks',
- options => $f,
- nextpage => $np,
- items => $list,
- pageurl => $url->(),
- sorturl => $url->(s=>0,o=>0),
- header => [
- [ 'Date', 'date' ],
- [ 'User' ],
- [ 'Rating' ],
- [ 'Tag', 'tag' ],
- [ 'Spoiler' ],
- [ 'Visual novel' ],
- ],
- row => sub {
- my($s, $n, $l) = @_;
- Tr;
- td class => 'tc1', fmtdate $l->{date};
- td class => 'tc2';
- if($l->{uid}) {
- a href => $url->(u=>$l->{uid}), class => 'setfil', '> ' if !$f->{u};
- a href => "/u$l->{uid}", $l->{username};
- } else {
- txt '[deleted]';
- }
- end;
- td class => 'tc3'.($l->{ignore}?' ignored':'');
- tagscore $l->{vote};
- end;
- td class => 'tc4';
- a href => $url->(t=>$l->{tag}), class => 'setfil', '> ' if !$f->{t};
- a href => "/g$l->{tag}", $l->{name};
- end;
- td class => 'tc5', !defined $l->{spoiler} ? ' ' : fmtspoil $l->{spoiler};
- td class => 'tc6';
- a href => $url->(v=>$l->{vid}), class => 'setfil', '> ' if !$f->{v};
- a href => "/v$l->{vid}", shorten $l->{title}, 50;
- end;
- end;
- },
- );
- $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.';
- li 'Some tag information on the site is cached, it can take up to an hour for your changes to be visible everywhere.';
- 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 %{$self->{tag_categories}}) {
- my @tags = grep $_->{cat} eq $cat, @$tags;
- next if !@tags;
- Tr class => 'tagmod_cat';
- td colspan => 7, $self->{tag_categories}{$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';
- 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 05321988..00000000
--- a/lib/VNDB/Handler/Traits.pm
+++ /dev/null
@@ -1,455 +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;
- }
- p; br; txt 'The list below also includes all characters linked to child traits. This list is cached, it can take up to 24 hours after a character has been edited for it to show up on this page.'; 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 => fmtuser($t->{addedby}, $t->{username}) ] : (),
- [ 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 7f4d1ed6..00000000
--- a/lib/VNDB/Handler/ULists.pm
+++ /dev/null
@@ -1,530 +0,0 @@
-
-package VNDB::Handler::ULists;
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml';
-use VNDB::Func;
-
-
-TUWF::register(
- qr{v([1-9]\d*)/vote}, \&vnvote,
- qr{v([1-9]\d*)/wish}, \&vnwish,
- qr{v([1-9]\d*)/list}, \&vnlist_e,
- qr{r([1-9]\d*)/list}, \&rlist_e,
- qr{xml/rlist.xml}, \&rlist_e,
- qr{([uv])([1-9]\d*)/votes}, \&votelist,
- qr{u([1-9]\d*)/wish}, \&wishlist,
- qr{u([1-9]\d*)/list}, \&vnlist,
-);
-
-
-sub vnvote {
- my($self, $id) = @_;
-
- my $uid = $self->authInfo->{id};
- return $self->htmlDenied() if !$uid;
-
- return if !$self->authCheckCode;
- my $f = $self->formValidate(
- { get => 'v', regex => qr/^(-1|([1-9]|10)(\.[0-9])?)$/ },
- { get => 'ref', required => 0, default => "/v$id" }
- );
- return $self->resNotFound if $f->{_err} || ($f->{v} != -1 && ($f->{v} > 10 || $f->{v} < 1));
-
- $self->dbVoteDel($uid, $id) if $f->{v} == -1;
- $self->dbVoteAdd($id, $uid, $f->{v}*10) if $f->{v} > 0;
-
- $self->resRedirect($f->{ref}, 'temp');
-}
-
-
-sub vnwish {
- my($self, $id) = @_;
-
- my $uid = $self->authInfo->{id};
- return $self->htmlDenied() if !$uid;
-
- return if !$self->authCheckCode;
- my $f = $self->formValidate(
- { get => 's', enum => [ -1..$#{$self->{wishlist_status}} ] },
- { get => 'ref', required => 0, default => "/v$id" }
- );
- return $self->resNotFound if $f->{_err};
-
- $self->dbWishListDel($uid, $id) if $f->{s} == -1;
- $self->dbWishListAdd($id, $uid, $f->{s}) if $f->{s} != -1;
-
- $self->resRedirect($f->{ref}, 'temp');
-}
-
-
-sub vnlist_e {
- my($self, $id) = @_;
-
- my $uid = $self->authInfo->{id};
- return $self->htmlDenied() if !$uid;
-
- return if !$self->authCheckCode;
- my $f = $self->formValidate(
- { get => 'e', enum => [ -1..$#{$self->{vnlist_status}} ] },
- { get => 'ref', required => 0, default => "/v$id" }
- );
- return $self->resNotFound if $f->{_err};
-
- $self->dbVNListDel($uid, $id) if $f->{e} == -1;
- $self->dbVNListAdd($uid, $id, $f->{e}) if $f->{e} != -1;
-
- $self->resRedirect($f->{ref}, 'temp');
-}
-
-
-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..$#{$self->{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', '';
- }
-}
-
-
-sub votelist {
- my($self, $type, $id) = @_;
-
- my $obj = $type eq 'v' ? $self->dbVNGet(id => $id)->[0] : $self->dbUserGet(uid => $id, what => 'hide_list')->[0];
- return $self->resNotFound if !$obj->{id};
-
- my $own = $type eq 'u' && $self->authInfo->{id} && $self->authInfo->{id} == $id;
- return $self->resNotFound if $type eq 'u' && !$own && !(!$obj->{hide_list} || $self->authCan('usermod'));
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'o', required => 0, default => 'd', enum => ['a', 'd'] },
- { get => 's', required => 0, default => 'date', enum => [qw|date title vote|] },
- { get => 'c', required => 0, default => 'all', enum => [ 'all', 'a'..'z', 0 ] },
- );
- return $self->resNotFound if $f->{_err};
-
- if($own && $self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- my $frm = $self->formValidate(
- { post => 'vid', required => 1, multi => 1, template => 'id' },
- { post => 'batchvotes', required => 1, regex => qr/^(-1|([1-9]|10)(\.[0-9])?)$/ },
- );
- my @vid = grep $_ && $_ > 0, @{$frm->{vid}};
- if(!$frm->{_err} && @vid && $frm->{batchvotes} > -2) {
- $self->dbVoteDel($id, \@vid) if $frm->{batchvotes} == -1;
- $self->dbVoteAdd(\@vid, $id, $frm->{batchvotes}*10) if $frm->{batchvotes} > 0;
- }
- }
-
- my($list, $np) = $self->dbVoteGet(
- $type.'id' => $id,
- what => $type eq 'v' ? 'user hide_list' : 'vn',
- hide_ign => $type eq 'v',
- sort => $f->{s} eq 'title' && $type eq 'v' ? 'username' : $f->{s},
- reverse => $f->{o} eq 'd',
- results => 50,
- page => $f->{p},
- $type eq 'u' && $f->{c} ne 'all' ? (vn_char => $f->{c}) : (),
- );
-
- my $title = $type eq 'v' ? "Votes for $obj->{title}" : "Votes by $obj->{username}";
- $self->htmlHeader(noindex => 1, title => $title);
- $self->htmlMainTabs($type => $obj, 'votes');
- div class => 'mainbox';
- h1 $title;
- if($type eq 'u') {
- p class => 'browseopts';
- for ('all', 'a'..'z', 0) {
- a href => "/$type$id/votes?c=$_", $_ eq $f->{c} ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#';
- }
- end;
- }
- p 'No votes to list. :-(' if !@$list;
- end;
-
- if($own) {
- my $code = $self->authGetCode("/u$id/votes");
- form action => "/u$id/votes?formcode=$code;c=$f->{c};s=$f->{s};p=$f->{p}", method => 'post';
- }
-
- @$list && $self->htmlBrowse(
- class => 'votelist',
- items => $list,
- options => $f,
- nextpage => $np,
- pageurl => "/$type$id/votes?c=$f->{c};o=$f->{o};s=$f->{s}",
- sorturl => "/$type$id/votes?c=$f->{c}",
- header => [
- [ 'Cast', 'date' ],
- [ 'Vote', 'vote' ],
- [ $type eq 'v' ? 'User' : 'Visual novel', 'title' ],
- ],
- row => sub {
- my($s, $n, $l) = @_;
- Tr;
- td class => 'tc1';
- input type => 'checkbox', name => 'vid', value => $l->{vid} if $own;
- txt ' '.fmtdate $l->{date};
- end;
- td class => 'tc2', fmtvote $l->{vote};
- td class => 'tc3';
- if($type eq 'u') {
- a href => "/v$l->{vid}", title => $l->{original}||$l->{title}, shorten $l->{title}, 100;
- } elsif($l->{hide_list}) {
- b class => 'grayedout', 'hidden';
- } else {
- a href => "/u$l->{uid}", $l->{username};
- }
- end;
- end;
- },
- $own ? (footer => sub {
- Tr;
- td colspan => 3, class => 'tc1';
- input type => 'checkbox', class => 'checkall', name => 'vid', value => 0;
- txt ' ';
- Select name => 'batchvotes', id => 'batchvotes';
- option value => -2, '-- with selected --';
- optgroup label => 'Change vote';
- option value => $_, sprintf '%d (%s)', $_, fmtrating $_ for (reverse 1..10);
- option value => -3, 'Other';
- end;
- option value => -1, 'revoke';
- end;
- end;
- end 'tr';
- }) : (),
- );
- end if $own;
- $self->htmlFooter;
-}
-
-
-sub wishlist {
- my($self, $uid) = @_;
-
- my $own = $self->authInfo->{id} && $self->authInfo->{id} == $uid;
- my $u = $self->dbUserGet(uid => $uid, what => 'hide_list')->[0];
- return $self->resNotFound if !$u || !$own && !(!$u->{hide_list} || $self->authCan('usermod'));
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'o', required => 0, default => 'd', enum => [ 'a', 'd' ] },
- { get => 's', required => 0, default => 'wstat', enum => [qw|title added wstat|] },
- { get => 'f', required => 0, default => -1, enum => [ -1..$#{$self->{wishlist_status}} ] },
- );
- return $self->resNotFound if $f->{_err};
-
- if($own && $self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- my $frm = $self->formValidate(
- { post => 'sel', required => 0, default => 0, multi => 1, template => 'id' },
- { post => 'batchedit', required => 1, enum => [ -1..$#{$self->{wishlist_status}} ] },
- );
- $frm->{sel} = [ grep $_, @{$frm->{sel}} ]; # weed out "select all" checkbox
- if(!$frm->{_err} && @{$frm->{sel}} && $frm->{sel}[0]) {
- $self->dbWishListDel($uid, $frm->{sel}) if $frm->{batchedit} == -1;
- $self->dbWishListAdd($frm->{sel}, $uid, $frm->{batchedit}) if $frm->{batchedit} >= 0;
- }
- }
-
- my($list, $np) = $self->dbWishListGet(
- uid => $uid,
- sort => $f->{s}, reverse => $f->{o} eq 'd',
- $f->{f} != -1 ? (wstat => $f->{f}) : (),
- what => 'vn',
- results => 50,
- page => $f->{p},
- );
-
- my $title = $own ? 'My wishlist' : "$u->{username}'s wishlist";
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('u', $u, 'wish');
- div class => 'mainbox';
- h1 $title;
- if(!@$list && $f->{f} == -1) {
- p 'Wishlist empty...';
- end;
- return $self->htmlFooter;
- }
- p class => 'browseopts';
- a $f->{f} == $_ ? (class => 'optselected') : (), href => "/u$uid/wish?f=$_",
- $_ == -1 ? 'All priorities' : $self->{wishlist_status}[$_]
- for (-1..$#{$self->{wishlist_status}});
- end;
- end 'div';
-
- if($own) {
- my $code = $self->authGetCode("/u$uid/wish");
- form action => "/u$uid/wish?formcode=$code;f=$f->{f};o=$f->{o};s=$f->{s};p=$f->{p}", method => 'post';
- }
-
- $self->htmlBrowse(
- class => 'wishlist',
- items => $list,
- nextpage => $np,
- options => $f,
- pageurl => "/u$uid/wish?f=$f->{f};o=$f->{o};s=$f->{s}",
- sorturl => "/u$uid/wish?f=$f->{f}",
- header => [
- [ 'Title' => 'title' ],
- [ 'Priority' => 'wstat' ],
- [ 'Added' => 'added' ],
- ],
- row => sub {
- my($s, $n, $i) = @_;
- Tr;
- td class => 'tc1';
- input type => 'checkbox', name => 'sel', value => $i->{vid}
- if $own;
- a href => "/v$i->{vid}", title => $i->{original}||$i->{title}, ' '.shorten $i->{title}, 70;
- end;
- td class => 'tc2', $self->{wishlist_status}[$i->{wstat}];
- td class => 'tc3', fmtdate $i->{added}, 'compact';
- end;
- },
- $own ? (footer => sub {
- Tr;
- td colspan => 3;
- input type => 'checkbox', class => 'checkall', name => 'sel', value => 0;
- txt ' ';
- Select name => 'batchedit', id => 'batchedit';
- option '-- with selected --';
- optgroup label => 'Change priority';
- option value => $_, $self->{wishlist_status}[$_]
- for (0..$#{$self->{wishlist_status}});
- end;
- option value => -1, 'remove from wishlist';
- end;
- end;
- end;
- }) : (),
- );
- end 'form' if $own;
- $self->htmlFooter;
-}
-
-
-sub vnlist {
- my($self, $uid) = @_;
-
- my $own = $self->authInfo->{id} && $self->authInfo->{id} == $uid;
- my $u = $self->dbUserGet(uid => $uid, what => 'hide_list')->[0];
- return $self->resNotFound if !$u || !$own && !(!$u->{hide_list} || $self->authCan('usermod'));
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'o', required => 0, default => 'a', enum => [ 'a', 'd' ] },
- { get => 's', required => 0, default => 'title', enum => [ 'title', 'vote' ] },
- { get => 'c', required => 0, default => 'all', enum => [ 'all', 'a'..'z', 0 ] },
- { get => 'v', required => 0, default => 0, enum => [ -1..1 ] },
- { get => 't', required => 0, default => -1, enum => [ -1..$#{$self->{vnlist_status}} ] },
- );
- return $self->resNotFound if $f->{_err};
-
- if($own && $self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- my $frm = $self->formValidate(
- { post => 'vid', required => 0, default => 0, multi => 1, template => 'id' },
- { post => 'rid', required => 0, default => 0, multi => 1, template => 'id' },
- { post => 'not', required => 0, default => '', maxlength => 2000 },
- { post => 'vns', required => 1, enum => [ -2..$#{$self->{vnlist_status}}, 999 ] },
- { post => 'rel', required => 1, enum => [ -2..$#{$self->{rlist_status}} ] },
- );
- my @vid = grep $_ > 0, @{$frm->{vid}};
- my @rid = grep $_ > 0, @{$frm->{rid}};
- if(!$frm->{_err} && @vid && $frm->{vns} > -2) {
- $self->dbVNListDel($uid, \@vid) if $frm->{vns} == -1;
- $self->dbVNListAdd($uid, \@vid, $frm->{vns}) if $frm->{vns} >= 0 && $frm->{vns} < 999;
- $self->dbVNListAdd($uid, \@vid, undef, $frm->{not}) if $frm->{vns} == 999;
- }
- if(!$frm->{_err} && @rid && $frm->{rel} > -2) {
- $self->dbRListDel($uid, \@rid) if $frm->{rel} == -1;
- $self->dbRListAdd($uid, \@rid, $frm->{rel}) if $frm->{rel} >= 0;
- }
- }
-
- my($list, $np) = $self->dbVNListList(
- uid => $uid,
- results => 50,
- page => $f->{p},
- sort => $f->{s}, reverse => $f->{o} eq 'd',
- voted => $f->{v} == 0 ? undef : $f->{v} < 0 ? 0 : $f->{v},
- $f->{c} ne 'all' ? (char => $f->{c}) : (),
- $f->{t} >= 0 ? (status => $f->{t}) : (),
- );
-
- my $title = $own ? 'My visual novel list' : "$u->{username}'s visual novel list";
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('u', $u, 'list');
-
- # url generator
- my $url = sub {
- my($n, $v) = @_;
- $n ||= '';
- local $_ = "/u$uid/list";
- $_ .= '?c='.($n eq 'c' ? $v : $f->{c});
- $_ .= ';v='.($n eq 'v' ? $v : $f->{v});
- $_ .= ';t='.($n eq 't' ? $v : $f->{t});
- if($n eq 'page') {
- $_ .= ';o='.($n eq 'o' ? $v : $f->{o});
- $_ .= ';s='.($n eq 's' ? $v : $f->{s});
- }
- return $_;
- };
-
- div class => 'mainbox';
- h1 $title;
- p class => 'browseopts';
- for ('all', 'a'..'z', 0) {
- a href => $url->(c => $_), $_ eq $f->{c} ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#';
- }
- end;
- p class => 'browseopts';
- a href => $url->(v => 0), 0 == $f->{v} ? (class => 'optselected') : (), 'All';
- a href => $url->(v => 1), 1 == $f->{v} ? (class => 'optselected') : (), 'Only voted';
- a href => $url->(v => -1), -1 == $f->{v} ? (class => 'optselected') : (), 'Hide voted';
- end;
- p class => 'browseopts';
- a href => $url->(t => -1), -1 == $f->{t} ? (class => 'optselected') : (), 'All';
- a href => $url->(t => $_), $_ == $f->{t} ? (class => 'optselected') : (), $self->{vnlist_status}[$_] for 0..$#{$self->{vnlist_status}};
- end;
- end 'div';
-
- _vnlist_browse($self, $own, $list, $np, $f, $url, $uid);
- $self->htmlFooter;
-}
-
-sub _vnlist_browse {
- my($self, $own, $list, $np, $f, $url, $uid) = @_;
-
- if($own) {
- form action => $url->(), method => 'post';
- input type => 'hidden', class => 'hidden', name => 'not', id => 'not', value => '';
- input type => 'hidden', class => 'hidden', name => 'formcode', id => 'formcode', value => $self->authGetCode("/u$uid/list");
- }
-
- $self->htmlBrowse(
- class => 'rlist',
- items => $list,
- nextpage => $np,
- options => $f,
- sorturl => $url->(),
- pageurl => $url->('page'),
- header => [
- [ '' ],
- sub { td class => 'tc2', id => 'expandall'; lit '&#9656;'; end; },
- [ 'Title' => 'title' ],
- [ '' ], [ '' ],
- [ 'Status' ],
- [ 'Releases*' ],
- [ 'Vote' => 'vote' ],
- ],
- row => sub {
- my($s, $n, $i) = @_;
- Tr class => 'nostripe'.($n%2 ? ' odd' : '');
- td class => 'tc1'; input type => 'checkbox', name => 'vid', value => $i->{vid} if $own; end;
- if(@{$i->{rels}}) {
- td class => 'tc2 collapse_but', id => "vid$i->{vid}"; lit '&#9656;'; end;
- } else {
- td class => 'tc2', '';
- }
- td class => 'tc3_5', colspan => 3;
- a href => "/v$i->{vid}", title => $i->{original}||$i->{title}, shorten $i->{title}, 70;
- b class => 'grayedout', $i->{notes} if $i->{notes};
- end;
- td class => 'tc6', $i->{status} ? $self->{vnlist_status}[$i->{status}] : '';
- td class => 'tc7';
- my $obtained = grep $_->{status}==2, @{$i->{rels}};
- my $total = scalar @{$i->{rels}};
- my $txt = sprintf '%d/%d', $obtained, $total;
- $txt = qq|<b class="done">$txt</b>| if $total && $obtained == $total;
- $txt = qq|<b class="todo">$txt</b>| if $obtained < $total;
- lit $txt;
- end;
- td class => 'tc8', fmtvote $i->{vote};
- end 'tr';
-
- for (@{$i->{rels}}) {
- Tr class => "nostripe collapse relhid collapse_vid$i->{vid}".($n%2 ? ' odd':'');
- td class => 'tc1', '';
- td class => 'tc2';
- input type => 'checkbox', name => 'rid', value => $_->{rid} if $own;
- end;
- td class => 'tc3';
- lit fmtdatestr $_->{released};
- end;
- td class => 'tc4';
- cssicon "lang $_", $self->{languages}{$_} for @{$_->{languages}};
- cssicon "rt$_->{type}", $_->{type};
- end;
- td class => 'tc5';
- a href => "/r$_->{rid}", title => $_->{original}||$_->{title}, shorten $_->{title}, 50;
- end;
- td class => 'tc6', $_->{status} ? $self->{rlist_status}[$_->{status}] : '';
- td class => 'tc7_8', colspan => 2, '';
- end 'tr';
- }
- },
-
- $own ? (footer => sub {
- Tr;
- td class => 'tc1'; input type => 'checkbox', name => 'vid', value => 0, class => 'checkall'; end;
- td class => 'tc2'; input type => 'checkbox', name => 'rid', value => 0, class => 'checkall'; end;
- td class => 'tc3_6', colspan => 4;
- Select id => 'vns', name => 'vns';
- option value => -2, '-- with selected VNs --';
- optgroup label => 'Change status';
- option value => $_, $self->{vnlist_status}[$_]
- for (0..$#{$self->{vnlist_status}});
- end;
- option value => 999, 'Set note';
- option value => -1, 'remove from list';
- end;
- Select id => 'rel', name => 'rel';
- option value => -2, '-- with selected releases --';
- optgroup label => 'Change status';
- option value => $_, $self->{rlist_status}[$_]
- for (0..$#{$self->{rlist_status}});
- end;
- option value => -1, 'remove from list';
- end;
- input type => 'submit', value => 'Update';
- end;
- td class => 'tc7_8', colspan => 2, '* Obtained/total';
- end 'tr';
- }) : (),
- );
-
- end 'form' if $own;
-}
-
-1;
-
diff --git a/lib/VNDB/Handler/Users.pm b/lib/VNDB/Handler/Users.pm
deleted file mode 100644
index 875623a7..00000000
--- a/lib/VNDB/Handler/Users.pm
+++ /dev/null
@@ -1,865 +0,0 @@
-
-package VNDB::Handler::Users;
-
-use strict;
-use warnings;
-use TUWF ':html', 'xml_escape';
-use VNDB::Func;
-use POSIX 'floor';
-use PWLookup;
-
-
-TUWF::register(
- qr{u([1-9]\d*)} => \&userpage,
- qr{u/login} => \&login,
- qr{u([1-9]\d*)/logout} => \&logout,
- qr{u/newpass} => \&newpass,
- qr{u/newpass/sent} => \&newpass_sent,
- qr{u([1-9]\d*)/setpass} => \&setpass,
- qr{u/register} => \&register,
- qr{u/register/done} => \&register_done,
- qr{u([1-9]\d*)/edit} => \&edit,
- qr{u([1-9]\d*)/posts} => \&posts,
- qr{u([1-9]\d*)/del(/[od])?} => \&delete,
- qr{u/(all|[0a-z])} => \&list,
- qr{u([1-9]\d*)/notifies} => \&notifies,
- qr{u([1-9]\d*)/notify/([1-9]\d*)} => \&readnotify,
-);
-
-
-sub userpage {
- my($self, $uid) = @_;
-
- my $u = $self->dbUserGet(uid => $uid, what => 'stats hide_list')->[0];
- return $self->resNotFound if !$u->{id};
-
- my $votes = $u->{c_votes} && $self->dbVoteStats(uid => $uid);
- my $list_visible = !$u->{hide_list} || ($self->authInfo->{id}||0) == $u->{id} || $self->authCan('usermod');
-
- my $title = "$u->{username}'s profile";
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('u', $u);
- div class => 'mainbox userpage';
- h1 $title;
-
- table class => 'stripe';
-
- Tr;
- td class => 'key', 'Username';
- td;
- txt ucfirst($u->{username}).' (';
- a href => "/u$uid", "u$uid";
- txt ')';
- end;
- end;
-
- Tr;
- td 'Registered';
- td fmtdate $u->{registered};
- end;
-
- Tr;
- td 'Edits';
- td;
- if($u->{c_changes}) {
- a href => "/u$uid/hist", $u->{c_changes};
- } else {
- txt '-';
- }
- end;
- end;
-
- Tr;
- td 'Votes';
- td;
- if(!$list_visible) {
- txt 'hidden';
- } elsif($votes) {
- my($total, $count) = (0, 0);
- for (1..@$votes) {
- $count += $votes->[$_-1][0];
- $total += $votes->[$_-1][1];
- }
- a href => "/u$uid/votes", $count;
- txt sprintf ' (%.2f average)', $total/$count/10;
- } else {
- txt '-';
- }
- end;
- end;
-
- Tr;
- td 'Tags';
- td;
- if(!$u->{c_tags}) {
- txt '-';
- } else {
- txt sprintf '%d vote%s on %d distinct tag%s and %d visual novel%s. ',
- $u->{c_tags}, $u->{c_tags} == 1 ? '' : 's',
- $u->{tagcount}, $u->{tagcount} == 1 ? '' : 's',
- $u->{tagvncount}, $u->{tagvncount} == 1 ? '' : 's';
- a href => "/g/links?u=$uid"; lit 'Browse tags &raquo;'; end;
- }
- end;
- end;
-
- Tr;
- td 'List stats';
- td !$list_visible ? 'hidden' :
- sprintf '%d release%s of %d visual novel%s.',
- $u->{releasecount}, $u->{releasecount} == 1 ? '' : 's',
- $u->{vncount}, $u->{vncount} == 1 ? '' : 's';
- end;
-
- Tr;
- td 'Forum stats';
- td;
- txt sprintf '%d post%s, %d new thread%s. ',
- $u->{postcount}, $u->{postcount} == 1 ? '' : 's',
- $u->{threadcount}, $u->{threadcount} == 1 ? '' : 's';
- if($u->{postcount}) {
- a href => "/u$uid/posts"; lit 'Browse posts &raquo;'; end;
- }
- end;
- end;
- end 'table';
- end 'div';
-
- if($votes && $list_visible) {
- div class => 'mainbox';
- h1 'Vote statistics';
- $self->htmlVoteStats(u => $u, $votes);
- end;
- }
-
- if($u->{c_changes}) {
- my $list = $self->dbRevisionGet(uid => $uid, results => 5);
- h1 class => 'boxtitle';
- a href => "/u$uid/hist", 'Recent changes';
- end;
- $self->htmlBrowseHist($list, { p => 1 }, 0, "/u$uid/hist");
- }
- $self->htmlFooter;
-}
-
-
-sub _check_throttle {
- my $self = shift;
- my $tm = $self->dbThrottleGet(norm_ip($self->reqIP));
- if($tm-time() > $self->{login_throttle}[1]) {
- $self->htmlHeader(title => 'Login');
- div class => 'mainbox';
- h1 'Login';
- div class => 'warning';
- h2 'Maximum failed login attempts reached.';
- p;
- txt 'Login has been temporarily disabled for your IP address. You can wait a few hours and try again,'
- .' or you can try from a different IP address. If you forgot your password, you can still use the ';
- a href => '/u/newpass', 'password reset';
- txt ' functionality. If you still have trouble logging in, send a mail to ';
- a href => 'mailto:contact@vndb.org', 'contact@vndb.org';
- txt '.';
- end;
- end;
- end 'div';
- $self->htmlFooter;
- return undef;
- }
- $tm
-}
-
-
-sub login {
- my $self = shift;
-
- return $self->resRedirect('/', 'temp') if $self->authInfo->{id};
-
- my $tm = _check_throttle($self);
- return if !defined $tm;
-
- my $ref = $self->formValidate({ param => 'ref', required => 0, default => '/'})->{ref};
-
- my $frm;
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'usrname', required => 1, minlength => 2, maxlength => 15 },
- { post => 'usrpass', required => 1, minlength => 4, maxlength => 500 },
- );
-
- if(!$frm->{_err}) {
- $frm->{usrname} = lc $frm->{usrname};
-
- my $ok = $self->authLogin($frm->{usrname}, $frm->{usrpass}, $ref);
-
- if($ok && $self->{password_db} && PWLookup::lookup($self->{password_db}, $frm->{usrpass})) {
- my $u = $self->dbUserGet(username => $frm->{usrname})->[0];
- $self->dbUserLogout($u->{id}, $ok); # Make sure to throw away the session we just created
- return $self->resRedirect("/u$u->{id}/setpass", 'post');
- }
- return if $ok;
-
- $frm->{_err} = [ 'Invalid username or password' ];
- $self->dbThrottleSet(norm_ip($self->reqIP), $tm+$self->{login_throttle}[0]);
- }
- }
-
- $self->htmlHeader(noindex => 1, title => 'Login');
- $self->htmlForm({ frm => $frm, action => '/u/login' }, login => [ 'Login',
- [ hidden => short => 'ref', value => $ref ],
- [ input => short => 'usrname', name => 'Username' ],
- [ static => content => '<a href="/u/register">No account yet?</a>' ],
- [ passwd => short => 'usrpass', name => 'Password' ],
- [ static => content => '<a href="/u/newpass">Forgot your password?</a>' ],
- ]);
- $self->htmlFooter;
-}
-
-
-sub logout {
- my $self = shift;
- my $uid = shift;
- return $self->resNotFound if !$self->authInfo->{id} || $self->authInfo->{id} != $uid;
- $self->authLogout;
-}
-
-
-sub newpass {
- my $self = shift;
-
- return $self->resRedirect('/', 'temp') if $self->authInfo->{id};
-
- my($frm, $uid, $token);
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate({ post => 'mail', template => 'email' });
- if(!$frm->{_err}) {
- ($uid, $token) = $self->authResetPass($frm->{mail});
- $frm->{_err} = [ 'No user found with that email address' ] if !$uid;
- }
- if(!$frm->{_err}) {
- my $u = $self->dbUserGet(uid => $uid)->[0];
- my $body = sprintf
- "Hello %s,\n\nYour VNDB.org login has been disabled, you can now set a new password by following the link below:\n\n"
- ."%s\n\nNow don't forget your password again! :-)\n\nvndb.org",
- $u->{username}, $self->reqBaseURI()."/u$u->{id}/setpass?t=$token";
- $self->mail($body,
- To => $frm->{mail},
- From => 'VNDB <noreply@vndb.org>',
- Subject => "Password reset for $u->{username}",
- );
- return $self->resRedirect('/u/newpass/sent', 'post');
- }
- }
-
- $self->htmlHeader(title => 'Forgot password', noindex => 1);
- div class => 'mainbox';
- h1 'Forgot password';
- 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!';
- end;
- $self->htmlForm({ frm => $frm, action => '/u/newpass' }, newpass => [ 'Reset password',
- [ input => short => 'mail', name => 'Email' ],
- ]);
- $self->htmlFooter;
-}
-
-
-sub newpass_sent {
- my $self = shift;
- return $self->resRedirect('/', 'temp') if $self->authInfo->{id};
- $self->htmlHeader(title => 'New password', noindex => 1);
- div class => 'mainbox';
- h1 'New password';
- div class => 'notice';
- p 'Your password has been reset and instructions to set a new one should reach your mailbox in a few minutes.';
- end;
- end;
- $self->htmlFooter;
-}
-
-
-# /u+/setpass has two modes: With a token (?t=xxx), to set the password after a
-# 'register' or 'newpass', or without a token, after the user tried to log in
-# with a weak password.
-sub setpass {
- my($self, $uid) = @_;
- return $self->resRedirect('/', 'temp') if $self->authInfo->{id};
-
- my $t = $self->formValidate({param => 't', required => 0, regex => qr/^[a-f0-9]{40}$/i });
- return $self->resNotFound if $t->{_err};
- $t = $t->{t};
-
- my $u = $self->dbUserGet(uid => $uid)->[0];
- return $self->resNotFound if !$u || ($t && !$self->authIsValidToken($u->{id}, $t));
-
- my $tm = !$t && _check_throttle($self);
- return if !$t && !defined $tm;
-
- my $frm;
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode("/u$u->{id}/setpass");
- $frm = $self->formValidate(
- $t ? () : (
- { post => 'curpass', minlength => 4, maxlength => 500 },
- ),
- { post => 'usrpass', minlength => 4, maxlength => 500 },
- { post => 'usrpass2', minlength => 4, maxlength => 500 },
- );
- push @{$frm->{_err}}, 'Passwords do not match' if $frm->{usrpass} ne $frm->{usrpass2};
- push @{$frm->{_err}}, 'Your chosen password is in a database of leaked passwords, please choose another one.'
- if $self->{password_db} && PWLookup::lookup($self->{password_db}, $frm->{usrpass});
-
- if(!$frm->{_err}) {
- $self->dbUserEdit($uid, email_confirmed => 1);
- return if $self->authSetPass($uid, $frm->{usrpass}, "/u$uid", $t ? (token => $t) : (pass => $frm->{curpass}));
- $self->dbThrottleSet(norm_ip($self->reqIP), $tm+$self->{login_throttle}[0]);
- push @{$frm->{_err}}, 'Invalid password';
- }
- }
-
- $self->htmlHeader(title => "Set password for $u->{username}", noindex => 1);
- $self->htmlForm({ frm => $frm, action => "/u$u->{id}/setpass" }, setpass => [ "Set password for $u->{username}",
- [ hidden => short => 't', value => $t||'' ],
- $t ? (
- [ static => nolabel => 1, content => 'Now you can set a password for your account.'
- .' You will be logged in automatically after your password has been saved.' ],
- ) : (
- [ static => nolabel => 1, content => "Your current password is in a database of leaked passwords, please change your password to continue.<br><br>" ],
- [ passwd => short => 'curpass', name => 'Current password' ],
- ),
- [ passwd => short => 'usrpass', name => 'Password' ],
- [ passwd => short => 'usrpass2', name => 'Confirm password' ],
- ]);
- $self->htmlFooter;
-}
-
-
-sub register {
- my $self = shift;
- return $self->resRedirect('/', 'temp') if $self->authInfo->{id};
-
- my $frm;
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'usrname', template => 'uname' },
- { post => 'mail', template => 'email' },
- { post => 'type', enum => [1..3] },
- { post => 'answer', template => 'uint' },
- );
- my $num = $self->{stats}{[qw|vn releases producers|]->[ $frm->{type} - 1 ]};
- push @{$frm->{_err}}, 'Question was not correctly answered. Are you sure you are a human?'
- if !$frm->{_err} && ($frm->{answer} > $num*1.005 || $frm->{answer} < $num*0.995);
- push @{$frm->{_err}}, 'Someone already has this username, please choose another name'
- if $frm->{usrname} eq 'anonymous' || !$frm->{_err} && $self->dbUserGet(username => $frm->{usrname})->[0]{id};
- push @{$frm->{_err}}, 'Someone already registered with that email address'
- if !$frm->{_err} && $self->dbUserEmailExists($frm->{mail});
-
- # Use /32 match for IPv4 and /48 for IPv6. The /48 is fairly broad, so some
- # users may have to wait a bit before they can register...
- my $ip = $self->reqIP;
- push @{$frm->{_err}}, 'You can only register one account from the same IP within 24 hours'
- if !$frm->{_err} && $self->dbUserGet(ip => $ip =~ /:/ ? "$ip/48" : $ip, registered => time-24*3600)->[0]{id};
-
- if(!$frm->{_err}) {
- my $uid = $self->dbUserAdd($frm->{usrname}, $frm->{mail});
- my(undef, $token) = $self->authResetPass($frm->{mail});
- my $body = sprintf "Hello %s,\n\n"
- ."Someone has registered an account on VNDB.org with your email address. To confirm your registration, follow the link below.\n\n"
- ."%s\n\n"
- ."If you don't remember creating an account on VNDB.org recently, please ignore this e-mail.\n\n"
- ."vndb.org",
- $frm->{usrname}, $self->reqBaseURI()."/u$uid/setpass?t=$token";
- $self->mail($body,
- To => $frm->{mail},
- From => 'VNDB <noreply@vndb.org>',
- Subject => "Confirm registration for $frm->{usrname}",
- );
- return $self->resRedirect('/u/register/done', 'post');
- }
- }
-
- $self->htmlHeader(title => 'Create an account', noindex => 1);
-
- my $type = $frm->{type} || floor(rand 3)+1;
- $self->htmlForm({ frm => $frm, action => '/u/register' }, register => [ 'Create an account',
- [ hidden => short => 'type', value => $type ],
- [ input => short => 'usrname', name => 'Username' ],
- [ static => content => 'Preferred username. Must be lowercase and can only consist of alphanumeric characters.' ],
- [ input => short => 'mail', name => 'Email' ],
- [ static => content => 'Your email address will only be 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.<br /><br />' ],
- [ static => content => sprintf '<br /><br />How many %s do we have in the database? (Hint: look to your left)',
- ['visual novels', 'releases', 'producers']->[$type-1] ],
- [ input => short => 'answer', name => 'Answer' ],
- ]);
- $self->htmlFooter;
-}
-
-
-sub register_done {
- my $self = shift;
- return $self->resRedirect('/', 'temp') if $self->authInfo->{id};
- $self->htmlHeader(title => 'Account created', noindex => 1);
- div class => 'mainbox';
- h1 'Account created';
- div class => 'notice';
- p 'Your account has been created! In a few minutes, you should receive an email with instructions to set your password.';
- end;
- end;
- $self->htmlFooter;
-}
-
-
-sub edit {
- my($self, $uid) = @_;
-
- # are we allowed to edit this user?
- return $self->htmlDenied if !$self->authInfo->{id} || $self->authInfo->{id} != $uid && !$self->authCan('usermod');
-
- # fetch user info (cached if uid == loggedin uid)
- my $u = $self->authInfo->{id} == $uid ? $self->authInfo : $self->dbUserGet(uid => $uid, what => 'extended prefs')->[0];
- return $self->resNotFound if !$u->{id};
-
- # check POST data
- my $frm;
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- $self->authCan('usermod') ? (
- { post => 'usrname', template => 'uname' },
- { post => 'perms', required => 0, multi => 1, enum => [ keys %{$self->{permissions}} ] },
- { post => 'ign_votes', required => 0, default => 0 },
- ) : (),
- { post => 'mail', template => 'email' },
- { post => 'curpass', required => 0, minlength => 4, maxlength => 500, default => '' },
- { post => 'usrpass', required => 0, minlength => 4, maxlength => 500 },
- { post => 'usrpass2', required => 0, minlength => 4, maxlength => 500 },
- { post => 'hide_list', required => 0, default => 0, enum => [0,1] },
- { post => 'show_nsfw', required => 0, default => 0, enum => [0,1] },
- { post => 'traits_sexual', required => 0, default => 0, enum => [0,1] },
- { post => 'tags_all', required => 0, default => 0, enum => [0,1] },
- { post => 'tags_cat', required => 0, multi => 1, enum => [qw|cont ero tech|] },
- { post => 'spoilers', required => 0, default => 0, enum => [0..2] },
- { post => 'skin', required => 0, default => $self->{skin_default}, enum => [ keys %{$self->{skins}} ] },
- { post => 'customcss', required => 0, maxlength => 2000, default => '' },
- );
- push @{$frm->{_err}}, 'Passwords do not match'
- if ($frm->{usrpass} || $frm->{usrpass2}) && (!$frm->{usrpass} || !$frm->{usrpass2} || $frm->{usrpass} ne $frm->{usrpass2});
- push @{$frm->{_err}}, 'Your chosen password is in a database of leaked passwords, please choose another one'
- if $self->{password_db} && PWLookup::lookup($self->{password_db}, $frm->{usrpass});
-
- if(!$frm->{_err}) {
- $frm->{skin} = '' if $frm->{skin} eq $self->{skin_default};
- $self->dbUserPrefSet($uid, $_ => $frm->{$_}) for (qw|skin customcss show_nsfw traits_sexual tags_all hide_list spoilers|);
-
- my $tags_cat = join(',', sort @{$frm->{tags_cat}}) || 'none';
- $self->dbUserPrefSet($uid, tags_cat => $tags_cat eq $self->{default_tags_cat} ? '' : $tags_cat);
-
- my %o;
- if($self->authCan('usermod')) {
- $o{username} = $frm->{usrname} if $frm->{usrname};
- $o{ign_votes} = $frm->{ign_votes} ? 1 : 0;
-
- my $perm = 0;
- $perm |= $self->{permissions}{$_} for(@{ delete $frm->{perms} });
- $self->dbUserSetPerm($u->{id}, $self->authInfo->{id}, $self->authInfo->{token}, $perm);
- }
- $self->dbUserSetMail($u->{id}, $self->authInfo->{id}, $self->authInfo->{token}, $frm->{mail});
- $self->dbUserEdit($uid, %o);
- $self->authAdminSetPass($u->{id}, $frm->{usrpass}) if $frm->{usrpass} && $self->authInfo->{id} != $u->{id};
-
- if($frm->{usrpass} && $self->authInfo->{id} == $u->{id}) {
- # Bit ugly: On incorrect password, all other changes are still saved.
- my $ok = $self->authSetPass($u->{id}, $frm->{usrpass}, "/u$uid/edit?d=1", pass => $frm->{curpass});
- return if $ok;
- push @{$frm->{_err}}, 'Invalid password';
- } else {
- return $self->resRedirect("/u$uid/edit?d=1", 'post');
- }
- }
- }
-
- # fill out default values
- $frm->{usrname} ||= $u->{username};
- $frm->{mail} ||= $self->dbUserGetMail($u->{id}, $self->authInfo->{id}, $self->authInfo->{token});
- $frm->{perms} ||= [ grep $u->{perm} & $self->{permissions}{$_}, keys %{$self->{permissions}} ];
- $frm->{$_} //= $u->{prefs}{$_} for(qw|skin customcss show_nsfw traits_sexual tags_all hide_list spoilers|);
- $frm->{tags_cat} ||= [ split /,/, $u->{prefs}{tags_cat}||$self->{default_tags_cat} ];
- $frm->{ign_votes} = $u->{ign_votes} if !defined $frm->{ign_votes};
- $frm->{skin} ||= $self->{skin_default};
- $frm->{usrpass} = $frm->{usrpass2} = $frm->{curpass} = '';
-
- # create the page
- $self->htmlHeader(title => 'My account', noindex => 1);
- $self->htmlMainTabs('u', $u, 'edit');
- if($self->reqGet('d')) {
- div class => 'mainbox';
- h1 'Settings saved';
- div class => 'notice';
- p 'Settings successfully saved.';
- end;
- end
- }
- $self->htmlForm({ frm => $frm, action => "/u$uid/edit" }, useredit => [ 'My account',
- [ part => title => 'General info' ],
- $self->authCan('usermod') ? (
- [ input => short => 'usrname', name => 'Username' ],
- [ select => short => 'perms', name => 'Permissions', multi => 1, size => (scalar keys %{$self->{permissions}}), options => [
- map [ $_, $_ ], sort keys %{$self->{permissions}} ] ],
- [ check => short => 'ign_votes', name => 'Ignore votes in VN statistics' ],
- ) : (
- [ static => label => 'Username', content => $frm->{usrname} ],
- ),
- [ input => short => 'mail', name => 'Email' ],
-
- [ part => title => 'Change password' ],
- [ static => content => 'Leave blank to keep your current password' ],
- [ passwd => short => 'curpass', name => 'Current Password' ],
- [ passwd => short => 'usrpass', name => 'New Password' ],
- [ passwd => short => 'usrpass2', name => 'Confirm password' ],
-
- [ part => title => 'Options' ],
- [ check => short => 'hide_list', name =>
- qq{Don't allow other people to see my <a href="/u$uid/list">visual novel list</a>,
- <a href="/u$uid/votes">votes</a> and <a href="/u$uid/wish">wishlist</a>,
- and exclude these lists from the <a href="/d14">database dumps</a> and <a href="/d11">API</a>.} ],
- [ check => short => 'show_nsfw', name => 'Disable warnings for images that are not safe for work.' ],
- [ check => short => 'traits_sexual', name => 'Show sexual traits by default on character pages.' ],
- [ check => short => 'tags_all', name => 'Show all tags by default on visual novel pages.' ],
- [ select => short => 'tags_cat', name => 'Tag categories', multi => 1, size => 3,
- options => [ map [ $_, $self->{tag_categories}{$_} ], keys %{$self->{tag_categories}} ] ],
- [ select => short => 'spoilers', name => 'Spoiler level', options => [
- [0, 'Hide spoilers'], [1, 'Show only minor spoilers'], [2, 'Show all spoilers'] ]],
- [ select => short => 'skin', name => 'Preferred skin', width => 300, options => [
- map [ $_, $self->{skins}{$_}[0].($self->debug?" [$_]":'') ], sort { $self->{skins}{$a}[0] cmp $self->{skins}{$b}[0] } keys %{$self->{skins}} ] ],
- [ textarea => short => 'customcss', name => 'Additional <a href="http://en.wikipedia.org/wiki/Cascading_Style_Sheets">CSS</a>' ],
- ]);
- $self->htmlFooter;
-}
-
-
-sub posts {
- my($self, $uid) = @_;
-
- # fetch user info (cached if uid == loggedin uid)
- my $u = $self->authInfo->{id} && $self->authInfo->{id} == $uid ? $self->authInfo : $self->dbUserGet(uid => $uid, what => 'hide_list')->[0];
- return $self->resNotFound if !$u->{id};
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' }
- );
- return $self->resNotFound if $f->{_err};
-
- my($posts, $np) = $self->dbPostGet(uid => $uid, hide => 1, what => 'thread', page => $f->{p}, sort => 'date', reverse => 1);
-
- my $title = "Posts made by $u->{username}";
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs(u => $u, 'posts');
- div class => 'mainbox';
- h1 $title;
- if(!@$posts) {
- p "$u->{username} hasn't made any posts yet.";
- }
- end;
-
- $self->htmlBrowse(
- items => $posts,
- class => 'uposts',
- options => $f,
- nextpage => $np,
- pageurl => "/u$uid/posts",
- header => [
- [ '' ],
- [ '' ],
- [ 'Date' ],
- [ 'Title' ],
- ],
- row => sub {
- my($s, $n, $l) = @_;
- Tr;
- td class => 'tc1'; a href => "/t$l->{tid}.$l->{num}", 't'.$l->{tid}; end;
- td class => 'tc2'; a href => "/t$l->{tid}.$l->{num}", '.'.$l->{num}; end;
- td class => 'tc3', fmtdate $l->{date};
- td class => 'tc4';
- a href => "/t$l->{tid}.$l->{num}", $l->{title};
- b class => 'grayedout'; lit bb2html $l->{msg}, 150; end;
- end;
- end;
- },
- ) if @$posts;
- $self->htmlFooter;
-}
-
-
-sub delete {
- my($self, $uid, $act) = @_;
- return $self->htmlDenied if !$self->authCan('usermod');
-
- # rarely used admin function, won't really need translating
-
- # confirm
- if(!$act) {
- my $code = $self->authGetCode("/u$uid/del/o");
- my $u = $self->dbUserGet(uid => $uid, what => 'hide_list')->[0];
- return $self->resNotFound if !$u->{id};
- $self->htmlHeader(title => 'Delete user', noindex => 1);
- $self->htmlMainTabs('u', $u, 'del');
- div class => 'mainbox';
- div class => 'warning';
- h2 'Delete user';
- p;
- lit qq|Are you sure you want to remove <a href="/u$uid">$u->{username}</a>'s account?<br /><br />|
- .qq|<a href="/u$uid/del/o?formcode=$code">Yes, I'm not kidding!</a>|;
- end;
- end;
- end;
- $self->htmlFooter;
- }
- # delete
- elsif($act eq '/o') {
- return if !$self->authCheckCode;
- $self->dbUserDel($uid);
- $self->resRedirect("/u$uid/del/d", 'post');
- }
- # done
- elsif($act eq '/d') {
- $self->htmlHeader(title => 'Delete user', noindex => 1);
- div class => 'mainbox';
- div class => 'notice';
- p 'User deleted.';
- end;
- end;
- $self->htmlFooter;
- }
-}
-
-
-sub list {
- my($self, $char) = @_;
-
- my $f = $self->formValidate(
- { get => 's', required => 0, default => 'username', enum => [ qw|username registered votes changes tags| ] },
- { get => 'o', required => 0, default => 'a', enum => [ 'a','d' ] },
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'q', required => 0, default => '', maxlength => 50 },
- );
- return $self->resNotFound if $f->{_err};
-
- $self->htmlHeader(noindex => 1, title => 'Browse users');
-
- div class => 'mainbox';
- h1 'Browse users';
- form action => '/u/all', 'accept-charset' => 'UTF-8', method => 'get';
- $self->htmlSearchBox('u', $f->{q});
- end;
- p class => 'browseopts';
- for ('all', 'a'..'z', 0) {
- a href => "/u/$_", $_ eq $char ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#';
- }
- end;
- end;
-
- my($list, $np) = $self->dbUserGet(
- sort => $f->{s}, reverse => $f->{o} eq 'd',
- what => 'hide_list',
- $char ne 'all' ? (
- firstchar => $char ) : (),
- results => 50,
- page => $f->{p},
- search => $f->{q},
- );
-
- $self->htmlBrowse(
- items => $list,
- options => $f,
- nextpage => $np,
- pageurl => "/u/$char?o=$f->{o};s=$f->{s};q=$f->{q}",
- sorturl => "/u/$char?q=$f->{q}",
- header => [
- [ 'Username', 'username' ],
- [ 'Registered', 'registered' ],
- [ 'Votes', 'votes' ],
- [ 'Edits', 'changes' ],
- [ 'Tags', 'tags' ],
- ],
- row => sub {
- my($s, $n, $l) = @_;
- Tr;
- td class => 'tc1';
- a href => '/u'.$l->{id}, $l->{username};
- end;
- td class => 'tc2', fmtdate $l->{registered};
- td class => 'tc3'.($l->{hide_list} && $self->authCan('usermod') ? ' linethrough' : '');
- lit $l->{hide_list} && !$self->authCan('usermod') ? '-' : !$l->{c_votes} ? 0 :
- qq|<a href="/u$l->{id}/votes">$l->{c_votes}</a>|;
- end;
- td class => 'tc4';
- lit !$l->{c_changes} ? 0 : qq|<a href="/u$l->{id}/hist">$l->{c_changes}</a>|;
- end;
- td class => 'tc5';
- lit !$l->{c_tags} ? 0 : qq|<a href="/g/links?u=$l->{id}">$l->{c_tags}</a>|;
- end;
- end 'tr';
- },
- );
- $self->htmlFooter;
-}
-
-
-sub notifies {
- my($self, $uid) = @_;
-
- my $u = $self->authInfo;
- return $self->htmlDenied if !$u->{id} || $uid != $u->{id};
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'r', required => 0, default => 0, enum => [0,1] },
- );
- return $self->resNotFound if $f->{_err};
-
- # changing the notification settings
- my $saved;
- if($self->reqMethod() eq 'POST' && $self->reqPost('set')) {
- return if !$self->authCheckCode;
- my $frm = $self->formValidate(
- { post => 'notify_nodbedit', required => 0, default => 1, enum => [0,1] },
- { post => 'notify_announce', required => 0, default => 0, enum => [0,1] }
- );
- return $self->resNotFound if $frm->{_err};
- $self->authPref($_, $frm->{$_}) for ('notify_nodbedit', 'notify_announce');
- $saved = 1;
-
- # updating notifications
- } elsif($self->reqMethod() eq 'POST') {
- return if !$self->authCheckCode;
- my $frm = $self->formValidate(
- { post => 'notifysel', multi => 1, required => 0, template => 'id' },
- { post => 'markread', required => 0 },
- { post => 'remove', required => 0 }
- );
- return $self->resNotFound if $frm->{_err};
- my @ids = grep $_, @{$frm->{notifysel}};
- $self->dbNotifyMarkRead(@ids) if @ids && $frm->{markread};
- $self->dbNotifyRemove(@ids) if @ids && $frm->{remove};
- $self->authInfo->{notifycount} = $self->dbUserGet(uid => $uid, what => 'notifycount')->[0]{notifycount};
- }
-
- my($list, $np) = $self->dbNotifyGet(
- uid => $uid,
- page => $f->{p},
- results => 25,
- what => 'titles',
- read => $f->{r} == 1 ? undef : 0,
- reverse => $f->{r} == 1,
- );
-
- $self->htmlHeader(title => 'My notifications', noindex => 1);
- $self->htmlMainTabs(u => $u);
- div class => 'mainbox';
- h1 'My notifications';
- p class => 'browseopts';
- a !$f->{r} ? (class => 'optselected') : (), href => "/u$uid/notifies?r=0", 'Unread notifications';
- a $f->{r} ? (class => 'optselected') : (), href => "/u$uid/notifies?r=1", 'All notifications';
- end;
- p 'No notifications!' if !@$list;
- end;
-
- my $code = $self->authGetCode("/u$uid/notifies");
-
- 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',
- );
-
- if(@$list) {
- form action => "/u$uid/notifies?r=$f->{r};formcode=$code", method => 'post', id => 'notifies';
- $self->htmlBrowse(
- items => $list,
- options => $f,
- nextpage => $np,
- class => 'notifies',
- pageurl => "/u$uid/notifies?r=$f->{r}",
- header => [
- [ '' ],
- [ 'Type' ],
- [ 'Age' ],
- [ 'ID' ],
- [ 'Action' ],
- ],
- row => sub {
- my($s, $n, $l) = @_;
- Tr $l->{read} ? () : (class => 'unread');
- td class => 'tc1';
- input type => 'checkbox', name => 'notifysel', value => "$l->{id}";
- end;
- td class => 'tc2', $ntypes{$l->{ntype}};
- td class => 'tc3', fmtage $l->{date};
- td class => 'tc4';
- a href => "/u$uid/notify/$l->{id}", "$l->{ltype}$l->{iid}".($l->{subid}?".$l->{subid}":'');
- end;
- td class => 'tc5 clickable', id => "notify_$l->{id}";
- lit sprintf
- $l->{ltype} ne 't' ? 'Edit of %s by %s' :
- $l->{subid} == 1 ? 'New thread %s by %s' : 'Reply to %s by %s',
- sprintf('<i>%s</i>', xml_escape $l->{c_title}),
- sprintf('<i>%s</i>', xml_escape $l->{username});
- end;
- end 'tr';
- },
- footer => sub {
- Tr;
- td colspan => 5;
- input type => 'checkbox', class => 'checkall', name => 'notifysel', value => 0;
- txt ' ';
- input type => 'submit', name => 'markread', value => 'mark selected read';
- input type => 'submit', name => 'remove', value => 'remove selected';
- b class => 'grayedout', ' (Read notifications are automatically removed after one month)';
- end;
- end;
- }
- );
- end;
- }
-
- form method => 'post', action => "/u$uid/notifies?formcode=$code";
- div class => 'mainbox';
- h1 'Settings';
- div class => 'notice', 'Settings successfully saved.' if $saved;
- p;
- for('nodbedit', 'announce') {
- my $def = $_ eq 'nodbedit' ? 0 : 1;
- input type => 'checkbox', name => "notify_$_", id => "notify_$_", value => $def,
- ($self->authPref("notify_$_")||0) == $def ? (checked => 'checked') : ();
- label for => "notify_$_", $_ eq 'nodbedit'
- ? ' Notify me about edits of database entries I contributed to.'
- : ' Notify me about site announcements.';
- br;
- }
- input type => 'submit', name => 'set', value => 'Save';
- end;
- end;
- end 'form';
- $self->htmlFooter;
-}
-
-
-sub readnotify {
- my($self, $uid, $nid) = @_;
- return $self->htmlDenied if !$self->authInfo->{id} || $uid != $self->authInfo->{id};
- my $n = $self->dbNotifyGet(uid => $uid, id => $nid)->[0];
- return $self->resNotFound if !$n->{iid};
- $self->dbNotifyMarkRead($n->{id}) if !$n->{read};
- # NOTE: for t+.+ IDs, this will create a double redirect, which is rather awkward...
- $self->resRedirect("/$n->{ltype}$n->{iid}".($n->{subid}?".$n->{subid}":''), 'perm');
-}
-
-
-1;
-
diff --git a/lib/VNDB/Handler/VNBrowse.pm b/lib/VNDB/Handler/VNBrowse.pm
deleted file mode 100644
index 554541a7..00000000
--- a/lib/VNDB/Handler/VNBrowse.pm
+++ /dev/null
@@ -1,147 +0,0 @@
-
-package VNDB::Handler::VNBrowse;
-
-use strict;
-use warnings;
-use TUWF ':html', 'uri_escape';
-use VNDB::Func;
-
-
-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
- { get => 'wish', 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');
- $f->{wish} = $read_write_pref->('wish', 'vn_list_wish');
-
- 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' : '').
- ($f->{wish} ? ' wishlist' : ''),
- $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';
- a href => $url->($char, 'wish' ), $f->{wish} ? (class => 'optselected') : (), 'Wishlist';
- 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 %{$self->{languages}} ], default => '' },
- { get => 'pl', required => 0, multi => 1, enum => [ keys %{$self->{platforms}} ], 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 cd0d9550..00000000
--- a/lib/VNDB/Handler/VNEdit.pm
+++ /dev/null
@@ -1,536 +0,0 @@
-
-package VNDB::Handler::VNEdit;
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml';
-use Image::Magick;
-use VNDB::Func;
-
-
-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_wp l_encubed l_renai 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 => [ 0..$#{$self->{vn_lengths}} ] },
- { post => 'l_wp', required => 0, default => '', maxlength => 150 },
- { post => 'l_encubed', required => 0, default => '', maxlength => 100 },
- { post => 'l_renai', required => 0, default => '', maxlength => 100 },
- { 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 %{$self->{staff_roles}} ] },
- { 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 },
- );
- # 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_wp l_encubed l_renai 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', width => 450, options =>
- [ map [ $_ => fmtvnlen $_, 2 ], 0..$#{$self->{vn_lengths}} ] ],
-
- [ input => short => 'l_wp', name => 'External links', pre => 'http://en.wikipedia.org/wiki/' ],
- [ input => short => 'l_encubed', pre => 'http://novelnews.net/tag/', post => '/' ],
- [ input => short => 'l_renai', 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 => $_, $self->{vn_relations}{$_}[1]
- for (keys %{$self->{vn_relations}});
- 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{$_} = [ $self->{vn_relations}{ $$new{$_}[0] }[0], $$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 a34a099d..00000000
--- a/lib/VNDB/Handler/VNPage.pm
+++ /dev/null
@@ -1,1076 +0,0 @@
-
-package VNDB::Handler::VNPage;
-
-use strict;
-use warnings;
-use TUWF ':html', 'xml_escape';
-use VNDB::Func;
-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 $_", $TUWF::OBJ->{languages}{$_};
- 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 $_, $TUWF::OBJ->{platforms}{$_};
- 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' : $TUWF::OBJ->{resolutions}{$_[0]{resolution}}[0];
- },
- }, { # 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 $TUWF::OBJ->{voiced}[$_[0]{voiced}] },
- }, { # 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: $TUWF::OBJ->{animated}[$_[0]{ani_story}]" :(),
- $_[0]{ani_ero} ? "Ero scenes: $TUWF::OBJ->{animated}[$_[0]{ani_ero}]":();
- 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 %{$self->{platforms}} ] },
- { get => 'lang', required => 0, default => 'all', enum => [ 'all', keys %{$self->{languages}} ] },
- );
- 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', $self->{platforms}, '') if $f->{pla};
- $plat_lang_draw->('languages', 'lang',$self->{languages}, '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 producers platforms media', results => 200);
-
- 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', $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 $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;
- }
- my @links = (
- $v->{l_wp} ? [ 'Wikipedia', 'http://en.wikipedia.org/wiki/%s', $v->{l_wp} ] : (),
- $v->{l_encubed} ? [ 'Encubed', 'http://novelnews.net/tag/%s/', $v->{l_encubed} ] : (),
- $v->{l_renai} ? [ 'Renai.us', 'http://renai.us/game/%s.shtml', $v->{l_renai} ] : (),
- );
- if(@links) {
- Tr;
- td 'Links';
- td;
- for(@links) {
- a href => sprintf($_->[1], $_->[2]), $_->[0];
- txt ', ' if $_ ne $links[$#links];
- }
- end;
- end;
- }
-
- _producers($self, $r);
- _relations($self, $v) if @{$v->{relations}};
- _anime($self, $v) if @{$v->{anime}};
- _useroptions($self, $v, $r) if $self->authInfo->{id};
- _affiliate_links($self, $r);
-
- 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';
- my $tags_cat = $self->authPref('tags_cat') || $self->{default_tags_cat};
- for (keys %{$self->{tag_categories}}) {
- input id => "cat_$_", type => 'checkbox', class => 'visuallyhidden', $tags_cat =~ /\Q$_/ ? (checked => 'checked') : ();
- label for => "cat_$_", lc $self->{tag_categories}{$_};
- }
- 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
- ul class => 'maintabs notfirst';
- if(@$chars) {
- li class => 'left '.(!$char ? ' tabselected' : ''); a href => "/v$v->{id}#main", name => 'main', 'main'; end;
- li class => 'left '.($char ? ' tabselected' : ''); a href => "/v$v->{id}/chars#chars", name => 'chars', 'characters'; end;
- }
- if($self->authCan('edit')) {
- li; a href => "/c/new?vid=$v->{id}", 'add character'; end;
- li; a href => "/v$v->{id}/add", 'add release'; 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;
-}
-
-
-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_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="http://renai.us/game/%s.shtml">%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($self->{staff_roles}{$_->{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 %s%s',
- $_->{id}, xml_escape($_->{original}||$_->{name}), xml_escape($_->{name}), 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', $self->{vn_relations}{$_->{relation}}[1],
- $_->{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", $self->{languages}{$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 $self->{vn_relations}{$_}[1];
- 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} ? $self->{anime_types}{$_->{type}}.', ' : '').$_->{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 $canvote = $minreleased && $minreleased < strftime '%Y%m%d', gmtime;
-
- my $vote = $self->dbVoteGet(uid => $self->authInfo->{id}, vid => $v->{id})->[0];
- my $list = $self->dbVNListGet(uid => $self->authInfo->{id}, vid => $v->{id})->[0];
- my $wish = $self->dbWishListGet(uid => $self->authInfo->{id}, vid => $v->{id})->[0];
-
- Tr;
- td 'User options';
- td;
- if($vote || ($canvote && !$wish)) {
- Select id => 'votesel', name => $self->authGetCode("/v$v->{id}/vote");
- option value => -3, $vote ? 'your vote: '.fmtvote($vote->{vote}) : 'not voted yet';
- optgroup label => $vote ? 'Change vote' : 'Vote';
- option value => $_, "$_ (".fmtrating($_).')' for (reverse 1..10);
- option value => -2, 'Other';
- end;
- option value => -1, 'revoke' if $vote;
- end;
- br;
- }
-
- Select id => 'listsel', name => $self->authGetCode("/v$v->{id}/list");
- option $list ? "VN list: $self->{vnlist_status}[$list->{status}]" : 'not on your VN list';
- optgroup label => $list ? 'Change status' : 'Add to VN list';
- option value => $_, $self->{vnlist_status}[$_] for (0..$#{$self->{vnlist_status}});
- end;
- option value => -1, 'remove from VN list' if $list;
- end;
- br;
-
- if(!$vote || $wish) {
- Select id => 'wishsel', name => $self->authGetCode("/v$v->{id}/wish");
- option $wish ? "wishlist: $self->{wishlist_status}[$wish->{wstat}]" : 'not on your wishlist';
- optgroup label => $wish ? 'Change status' : 'Add to wishlist';
- option value => $_, $self->{wishlist_status}[$_] for (0..$#{$self->{wishlist_status}});
- end;
- option value => -1, 'remove from wishlist' if $wish;
- end;
- }
- end;
- end 'tr';
-}
-
-
-sub _affiliate_links {
- my($self, $r) = @_;
- return if !keys @$r;
- my %r = map +($_->{id}, $_), @$r;
- my $links = $self->dbAffiliateGet(rids => [ keys %r ], hidden => 0);
- return if !@$links;
-
- $links = [ sort { $b->{priority}||$self->{affiliates}[$b->{affiliate}]{default_prio} <=> $a->{priority}||$self->{affiliates}[$a->{affiliate}]{default_prio} } @$links ];
-
- Tr id => 'buynow';
- td 'Available at';
- td;
- for my $link (@$links) {
- my $f = $self->{affiliates}[$link->{affiliate}];
- my $rel = $r{$link->{rid}};
- my $plat = join(' and ', map $self->{platforms}{$_}, @{$rel->{platforms}});
- my $version = join(' and ', map $self->{languages}{$_}, @{$rel->{languages}}).' '.$plat.' version';
-
- a rel => 'nofollow', href => $f->{link_format} ? $f->{link_format}->($link->{url}) : $link->{url};
- use utf8;
- txt $link->{version}
- || ($f->{default_version} && $f->{default_version}->($self, $link, $rel))
- || $version;
- txt " at $f->{name}";
- abbr class => 'pricenote', title =>
- $link->{lastfetch} ? sprintf('Last updated: %s.', fmtage($link->{lastfetch})) : '', " for $link->{price}"
- if $link->{price};
- txt ' »';
- 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", $self->{languages}{$l};
- txt $self->{languages}{$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 $_, $self->{platforms}{$_};
- }
- 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} ? $self->{rlist_status}[ $rel->{ulist}{status} ] : '--';
- } else {
- txt ' ';
- }
- end;
- td class => 'tc6';
- a href => "/affiliates/new?rid=$rel->{id}", 'a' if $self->authCan('affiliate');
- if($rel->{website}) {
- a href => $rel->{website}, rel => 'nofollow';
- cssicon 'external', 'External link';
- end;
- } else {
- txt ' ';
- }
- 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 $self->{icons_voiced}[$voice], $self->{voiced}[$voice], 'voiced' if $voice;
-
- # Animations columns
- my $story_anim = $rel->{ani_story};
- _release_icon $self->{icons_story_animated}[$story_anim], "Story: $self->{animated}[$story_anim]", 'story_animated' if $story_anim;
-
- my $ero_anim = $rel->{ani_ero};
- _release_icon $self->{icons_ero_animated}[$ero_anim], "Ero: $self->{animated}[$ero_anim]", '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' : $self->{resolutions}{$resolution}[1] 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", $self->{resolutions}{$resolution}[0], "resolution_$resolution_type";
- }
-
- # Media column
- if(@{$rel->{media}}) {
- my $icon = $self->{media}{ $rel->{media}[0]{medium} }[3];
- 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 $_", $self->{languages}{$_} for (@{$rel->{languages}});
- cssicon $_, $TUWF::OBJ->{platforms}{$_} 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 %{$self->{char_roles}}) {
- $rol{$r} = [ grep grep($_->{role} eq $r, @{$_->{vns}}) && !$done{$_->{id}}++, @$l ];
- }
- div class => 'charops', id => 'charops';
- $self->charOps(1, 'chars');
- for my $r (keys %{$self->{char_roles}}) {
- next if !@{$rol{$r}};
- div class => 'mainbox';
- h1 $self->{char_roles}{$r}[ @{$rol{$r}} > 1 ? 1 : 0 ];
- $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 %{$self->{char_roles}}) {
- 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 $self->{char_roles}{$c->{role}}[0];
- cssicon "gen $c->{gender}", $self->{genders}{$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 %{$self->{staff_roles}}) {
- my @s = grep $_->{role} eq $r, @{$v->{credits}};
- next if !@s;
- ul;
- li; b $self->{staff_roles}{$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
new file mode 100644
index 00000000..ffc80e77
--- /dev/null
+++ b/lib/VNDB/Schema.pm
@@ -0,0 +1,129 @@
+# 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
+# about the formatting of the .sql files.
+
+package VNDB::Schema;
+
+use v5.12;
+use warnings;
+
+my $ROOT = $INC{'VNDB/Schema.pm'} =~ s{/lib/VNDB/Schema\.pm}{}r;
+
+
+# Reads schema.sql and returns a hashref with the following structure:
+# {
+# vn => {
+# name => 'vn',
+# dbentry_type => 'v',
+# cols => [
+# {
+# name => 'id',
+# 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/sql/schema.sql" or die "schema.sql: $!";
+ while(<$F>) {
+ chomp;
+ next if /^\s*--/ || /^\s*$/;
+ 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}{comment} = /--\s*(.*)\s*/ ? $1 : '';
+ $schema{$table}{dbentry_type} = $1 if $schema{$table}{comment} =~ s/\s*dbentry_type=(.)\s*//;
+ $schema{$table}{cols} = [];
+
+ } elsif(/^\s*\)(?: PARTITION .+)?;/) {
+ $table = undef;
+
+ } 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+//) {
+ my $col = { name => $1 };
+ push @{$schema{$table}{cols}}, $col;
+
+ $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->{type} = lc s/^([^ ]+)\s.+/$1/r;
+ }
+ }
+
+ \%schema
+}
+
+
+# Parses types from schema.sql and returns a hashref with the following structure:
+# {
+# anime_type => {
+# decl => 'CREATE TYPE ..;'
+# }, ..
+# }
+sub types {
+ my %types;
+ open my $F, '<', "$ROOT/sql/schema.sql" or die "schema.sql: $!";
+ while(<$F>) {
+ chomp;
+ if(/^CREATE (?:TYPE|DOMAIN) ([^ ]+)/) {
+ $types{$1} = { decl => $_ };
+ }
+ }
+ \%types
+}
+
+
+# Parses foreign key references from tableattrs.sql and returns an arrayref:
+# [
+# {
+# decl => 'ALTER TABLE ..;',
+# from_table => 'vn_anime',
+# from_cols => ['id'],
+# to_table => 'vn',
+# to_cols => ['id'],
+# name => 'vn_anime_id_fkey'
+# }, ..
+# ]
+sub references {
+ my @ref;
+ 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*\(([^\)]+)\)/;
+ push @ref, {
+ decl => $_,
+ from_table => $1,
+ name => $2,
+ from_cols => [ split /\s*,\s*/, $3 ],
+ to_table => $4,
+ to_cols => [ split /\s*,\s*/, $5 ]
+ };
+ }
+ \@ref
+}
+
+1;
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
new file mode 100644
index 00000000..16f730c5
--- /dev/null
+++ b/lib/VNDB/Types.pm
@@ -0,0 +1,358 @@
+package VNDB::Types;
+
+use v5.24;
+no strict 'refs';
+use warnings;
+use Exporter 'import';
+
+our @EXPORT;
+sub hash {
+ my $name = shift;
+ tie $name->%*, 'VNDB::Types::Hash', @_;
+ push @EXPORT, "%$name";
+}
+
+
+
+# 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 => { 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' };
+
+
+
+# SQL: ENUM platform
+# The 'unk' platform is used to mean "Unknown" in various places (not in the DB).
+hash PLATFORM =>
+ win => 'Windows',
+ lin => 'Linux',
+ mac => 'Mac OS',
+ web => 'Website',
+ tdo => '3DO',
+ ios => 'Apple iProduct',
+ and => 'Android',
+ 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',
+ swi => 'Nintendo Switch',
+ wii => 'Nintendo Wii',
+ wiu => 'Nintendo Wii U',
+ n3d => 'Nintendo 3DS',
+ p88 => 'PC-88',
+ p98 => 'PC-98',
+ pce => 'PC Engine',
+ pcf => 'PC-FX',
+ psp => 'PlayStation Portable',
+ ps1 => 'PlayStation 1',
+ ps2 => 'PlayStation 2',
+ ps3 => 'PlayStation 3',
+ ps4 => 'PlayStation 4',
+ ps5 => 'PlayStation 5',
+ psv => 'PlayStation Vita',
+ smd => 'Sega Mega Drive',
+ scd => 'Sega Mega-CD',
+ sat => 'Sega Saturn',
+ vnd => 'VNDS',
+ x1s => 'Sharp X1',
+ x68 => 'Sharp X68000',
+ xb1 => 'Xbox',
+ xb3 => 'Xbox 360',
+ xbo => 'Xbox One',
+ xxs => 'Xbox X/S',
+ mob => 'Other (mobile)',
+ oth => 'Other';
+
+
+
+# SQL: ENUM vn_relation
+hash VN_RELATION =>
+ 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', 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' };
+
+
+
+# SQL: ENUM producer_type
+hash PRODUCER_TYPE =>
+ co => 'Company',
+ in => 'Individual',
+ ng => 'Amateur group';
+
+
+
+# SQL: ENUM credit_type
+hash CREDIT_TYPE =>
+ scenario => 'Scenario',
+ director => 'Director',
+ chardesign => 'Character design',
+ art => 'Artist',
+ music => 'Composer',
+ songs => 'Vocals',
+ translator => 'Translator',
+ editor => 'Editor',
+ qa => 'Quality assurance',
+ staff => 'Staff';
+
+
+
+hash VN_LENGTH =>
+ 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 };
+
+
+
+# SQL: ENUM anime_type
+hash ANIME_TYPE => # anidb = what the UDP API returns, lowercased
+ tv => { txt => 'TV Series', anidb => 'tv series' },
+ ova => { txt => 'OVA', anidb => 'ova' },
+ mov => { txt => 'Movie', anidb => 'movie' },
+ oth => { txt => 'Other', anidb => 'other' },
+ web => { txt => 'Web', anidb => 'web' },
+ spe => { txt => 'TV Special', anidb => 'tv special' },
+ mv => { txt => 'Music Video', anidb => 'music video' };
+
+
+
+# SQL: ENUM tag_category
+hash TAG_CATEGORY =>
+ cont => 'Content',
+ ero => 'Sexual content',
+ tech => 'Technical';
+
+
+
+hash 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' },
+ 1 => { txt => 'Not voiced' },
+ 2 => { txt => 'Only ero scenes voiced' },
+ 3 => { txt => 'Partially voiced' },
+ 4 => { txt => 'Fully voiced' };
+
+
+
+hash AGE_RATING =>
+ 0 => { txt => 'All ages', ex => 'CERO A' },
+ 3 => { txt => '3+', ex => '' },
+ 6 => { txt => '6+', ex => '' },
+ 7 => { txt => '7+', ex => '' },
+ 8 => { txt => '8+', ex => '' },
+ 9 => { txt => '9+', ex => '' },
+ 10 => { txt => '10+', ex => '' },
+ 11 => { txt => '11+', ex => '' },
+ 12 => { txt => '12+', ex => 'CERO B' },
+ 13 => { txt => '13+', ex => '' },
+ 14 => { txt => '14+', ex => '' },
+ 15 => { txt => '15+', ex => 'CERO C' },
+ 16 => { txt => '16+', ex => '' },
+ 17 => { txt => '17+', ex => 'CERO D' },
+ 18 => { txt => '18+', ex => 'CERO Z' };
+
+
+
+# SQL: ENUM medium
+# The 'unk' medium is used in release filters to mean "unknown".
+hash MEDIUM =>
+ cd => { qty => 1, txt => 'CD', plural => 'CDs', icon => 'disk' },
+ dvd => { qty => 1, txt => 'DVD', plural => 'DVDs', icon => 'disk' },
+ 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' },
+ nod => { qty => 1, txt => 'Nintendo Optical Disc', plural => 'Nintendo Optical Discs', icon => 'disk' },
+ in => { qty => 0, txt => 'Internet download', plural => '', icon => 'download' },
+ otc => { qty => 0, txt => 'Other', plural => '', icon => 'cartridge' };
+
+
+
+# SQL: ENUM release_type
+hash RELEASE_TYPE =>
+ complete => 'Complete',
+ partial => 'Partial',
+ trial => 'Trial';
+
+
+
+# 0 = hardcoded "unknown", 2 = hardcoded 'OK'
+hash RLIST_STATUS =>
+ 0 => 'Unknown',
+ 1 => 'Pending',
+ 2 => 'Obtained',
+ 3 => 'On loan',
+ 4 => 'Deleted';
+
+
+
+# SQL: ENUM board_type
+hash BOARD_TYPE =>
+ an => { txt => 'Announcements', post_perm => 'boardmod', index_rows => 5, dbitem => 0 },
+ db => { txt => 'VNDB discussions', post_perm => 'board', index_rows => 10, dbitem => 0 },
+ ge => { txt => 'General discussions', post_perm => 'board', index_rows => 10, dbitem => 0 },
+ v => { txt => 'Visual novels', post_perm => 'board', index_rows => 10, dbitem => 1 },
+ p => { txt => 'Producers', post_perm => 'board', index_rows => 5, dbitem => 1 },
+ u => { txt => 'Users', post_perm => 'board', index_rows => 5, dbitem => 1 };
+
+
+
+# SQL: ENUM blood_type
+hash BLOOD_TYPE =>
+ unknown => 'Unknown',
+ o => 'O',
+ a => 'A',
+ b => 'B',
+ ab => 'AB';
+
+
+
+# SQL: ENUM gender
+hash GENDER =>
+ unknown => 'Unknown or N/A',
+ m => 'Male',
+ f => 'Female',
+ b => 'Both';
+
+
+
+# SQL: ENUM cup_size
+hash CUP_SIZE =>
+ '' => 'Unknown or N/A',
+ AAA => 'AAA',
+ AA => 'AA',
+ map +($_,$_), 'A'..'Z';
+
+
+
+# SQL: ENUM char_role
+hash CHAR_ROLE =>
+ main => { txt => 'Protagonist', plural => 'Protagonists' },
+ primary => { txt => 'Main character', plural => 'Main characters' },
+ side => { txt => 'Side character', plural => 'Side characters' },
+ appears => { txt => 'Makes an appearance', plural => 'Make an appearance' };
+
+
+
+
+# Concise implementation of an immutable hash that remembers key order.
+package VNDB::Types::Hash;
+use v5.24;
+sub TIEHASH { shift; bless [ [ map $_[$_*2], 0..$#_/2 ], +{@_}, 0 ], __PACKAGE__ };
+sub FETCH { $_[0][1]{$_[1]} }
+sub EXISTS { exists $_[0][1]{$_[1]} }
+sub FIRSTKEY { $_[0][2] = 0; &NEXTKEY }
+sub NEXTKEY { $_[0][0][ $_[0][2]++ ] }
+sub SCALAR { scalar $_[0][0]->@* }
+1;
diff --git a/lib/VNDB/Util/Auth.pm b/lib/VNDB/Util/Auth.pm
deleted file mode 100644
index 0093bf2d..00000000
--- a/lib/VNDB/Util/Auth.pm
+++ /dev/null
@@ -1,228 +0,0 @@
-
-package VNDB::Util::Auth;
-
-
-use strict;
-use warnings;
-use Exporter 'import';
-use Digest::SHA qw|sha1 sha1_hex|;
-use Crypt::URandom 'urandom';
-use Crypt::ScryptKDF 'scrypt_raw';
-use Encode 'encode_utf8';
-use TUWF ':html';
-use VNDB::Func;
-
-
-our @EXPORT = qw|
- authInit authLogin authLogout authInfo authCan authSetPass authAdminSetPass
- authResetPass authIsValidToken authGetCode authCheckCode authPref
-|;
-
-
-sub randomascii {
- return join '', map chr($_%92+33), unpack 'C*', urandom shift;
-}
-
-
-# Fetches and parses the auth cookie.
-# Returns (uid, encrypted_token) on success, (0, '') on failure.
-sub parsecookie {
- # Earlier versions of the auth cookie didn't have the dot separator, so that's optional.
- return ($_[0]->reqCookie('auth')||'') =~ /^([a-fA-F0-9]{40})\.?(\d+)$/ ? ($2, sha1 pack 'H*', $1) : (0, '');
-}
-
-
-# initializes authentication information and checks the vndb_auth cookie
-sub authInit {
- my $self = shift;
-
- my($uid, $token_e) = parsecookie($self);
- $self->{_auth} = $uid && $self->dbUserGet(uid => $uid, session => $token_e, what => 'extended notifycount prefs')->[0];
- $self->{_auth}{token} = $token_e if $self->{_auth};
-
- # update the sessions.lastused column if lastused < now()-'6 hours'
- $self->dbUserUpdateLastUsed($uid, $token_e) if $self->{_auth} && $self->{_auth}{session_lastused} < time()-6*3600;
-
- # Drop the cookie if it's not valid
- $self->resCookie(auth => undef) if !$self->{_auth} && $self->reqCookie('auth');
-}
-
-
-# login, arguments: user, password, url-to-redirect-to-on-success
-# returns 1 on success (redirected), 0 otherwise (no reply sent)
-sub authLogin {
- my($self, $user, $pass, $to) = @_;
-
- return 0 if !$user || !$pass;
-
- my $d = $self->dbUserGet(username => $user, what => 'scryptargs extended prefs notifycount')->[0];
- return 0 if !$d->{id} || !$d->{scryptargs} || length($d->{scryptargs}) != 14;
-
- my($N, $r, $p, $salt) = unpack 'NCCa8', $d->{scryptargs};
- my $encpass = _preparepass($self, $pass, $salt, $N, $r, $p);
-
- return _createsession($self, $d->{id}, $encpass, $to);
-}
-
-
-# Prepares a plaintext password for database storage
-# Arguments: pass, optionally: salt, N, r, p
-# Returns: encrypted password (as a binary string)
-sub _preparepass {
- my($self, $pass, $salt, $N, $r, $p) = @_;
- ($N, $r, $p) = @{$self->{scrypt_args}} if !$N;
- $salt ||= urandom(8);
- return pack 'NCCa8a*', $N, $r, $p, $salt, scrypt_raw(encode_utf8($pass), $self->{scrypt_salt} . $salt, $N, $r, $p, 32);
-}
-
-
-# self, uid, encpass, url-to-redirect-to
-sub _createsession {
- my($self, $uid, $encpass, $url) = @_;
-
- my $token = urandom(20);
- my $token_e = sha1 $token;
- return 0 if !$self->dbUserLogin($uid, $encpass, $token_e);
-
- $self->resRedirect($url, 'post');
- $self->resCookie(auth => unpack('H*', $token).'.'.$uid, httponly => 1, expires => time + 31536000); # keep the cookie for 1 year
- return $token_e;
-}
-
-
-# clears authentication cookie and redirects to /
-sub authLogout {
- my $self = shift;
-
- my($uid, $token_e) = parsecookie($self);
- $self->dbUserLogout($uid, $token_e) if $uid;
-
- $self->resRedirect('/', 'temp');
- $self->resCookie(auth => undef);
-}
-
-
-# Replaces the user's password with a random token that can be used to reset the password.
-sub authResetPass {
- my $self = shift;
- my $mail = shift;
- my $token = unpack 'H*', urandom(20);
- my $id = $self->dbUserResetPass($mail, sha1(lc($token)));
- return $id ? ($id, $token) : ();
-}
-
-
-# uid, token
-sub authIsValidToken {
- $_[0]->dbUserIsValidToken($_[1], sha1(lc($_[2])))
-}
-
-
-# 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($self, $uid, $pass, $redir, $oldtype, $oldpass) = @_;
-
- if($oldtype eq 'token') {
- $oldpass = sha1(lc($oldpass));
-
- } elsif($oldtype eq 'pass') {
- my $u = $self->dbUserGet(uid => $uid, what => 'scryptargs')->[0];
- return 0 if !$u->{id} || !$u->{scryptargs} || length($u->{scryptargs}) != 14;
- my($N, $r, $p, $salt) = unpack 'NCCa8', $u->{scryptargs};
- $oldpass = _preparepass($self, $oldpass, $salt, $N, $r, $p);
- }
-
- $pass = _preparepass($self, $pass);
- return 0 if !$self->dbUserSetPass($uid, $oldpass, $pass);
- return _createsession($self, $uid, $pass, $redir);
-}
-
-
-sub authAdminSetPass {
- my($self, $uid, $pass) = @_;
- $pass = _preparepass($self, $pass);
- $self->dbUserAdminSetPass($uid, $self->authInfo->{id}, $self->authInfo->{token}, $pass);
-}
-
-
-# returns a hashref with information about the current loggedin user
-# the hash is identical to the hash returned by dbUserGet
-# returns empty hash if no user is logged in.
-sub authInfo {
- return shift->{_auth} || {};
-}
-
-
-# returns whether the currently loggedin or anonymous user can perform
-# a certain action. Argument is the action name as defined in global.pl
-sub authCan {
- my($self, $act) = @_;
- return $self->{_auth} ? $self->{_auth}{perm} & $self->{permissions}{$act} : 0;
-}
-
-
-# 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 (string, can be empty, but makes the validation stronger)
-# time (optional, time() to encode in the code)
-sub authGetCode {
- my $self = shift;
- my $id = shift;
- my $time = (shift || time)/3600; # accuracy of an hour
- my $uid = encode_utf8($self->{_auth} ? $self->{_auth}{id} : norm_ip($self->reqIP()));
- return lc substr sha1_hex($self->{form_salt} . $uid . encode_utf8($id||'') . pack('N', int $time)), 0, 16;
-}
-
-
-# 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 || $self->reqPath();
- my $code = shift || $self->reqParam('formcode');
- return _incorrectcode($self) if !$code || $code !~ qr/^[0-9a-f]{16}$/;
- my $time = time;
- return 1 if $self->authGetCode($id, $time) eq $code;
- return 1 if $self->authGetCode($id, $time-3600) eq $code;
- return 1 if $self->authGetCode($id, $time-2*3600) eq $code;
- return _incorrectcode($self);
-}
-
-
-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($self, $key, $val) = @_;
- my $nfo = $self->authInfo;
- return '' if !$nfo->{id};
- return $nfo->{prefs}{$key}||'' if @_ == 2;
- $nfo->{prefs}{$key} = $val;
- $self->dbUserPrefSet($nfo->{id}, $key, $val);
-}
-
-1;
-
diff --git a/lib/VNDB/Util/BrowseHTML.pm b/lib/VNDB/Util/BrowseHTML.pm
deleted file mode 100644
index c3115017..00000000
--- a/lib/VNDB/Util/BrowseHTML.pm
+++ /dev/null
@@ -1,223 +0,0 @@
-
-package VNDB::Util::BrowseHTML;
-
-use strict;
-use warnings;
-use TUWF ':html', 'xml_escape';
-use Exporter 'import';
-use VNDB::Func;
-use POSIX 'ceil';
-
-
-our @EXPORT = qw| htmlBrowse htmlBrowseNavigate htmlBrowseHist 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($left, $page, $label) = @_;
- li $left ? (class => 'left') : ();
- a href => $url.$page; lit $label; end;
- end;
- };
- my $ell = sub {
- use utf8;
- li class => 'ellipsis'.(shift() ? ' left' : '');
- b '⋯';
- end;
- };
- my $nc = 5; # max. number of buttons on each side
-
- ul class => 'maintabs browsetabs ' . ($al eq 't' ? 'notfirst' : 'bottom');
- $p > 2 and ref $np and $tab->(1, 1, '&laquo; first');
- $p > $nc+1 and ref $np and $ell->(1);
- $p > $_ and ref $np and $tab->(1, $p-$_, $p-$_) for (reverse 2..($nc>$p-2?$p-2:$nc-1));
- $p > 1 and $tab->(1, $p-1, '&lsaquo; previous');
-
- my $l = ceil($cnt/$pp)-$p+1;
- $l > 2 and $tab->(0, $l+$p-1, 'last &raquo;');
- $l > $nc+1 and $ell->(0);
- $l > $_ and $tab->(0, $p+$_, $p+$_) for (reverse 2..($nc>$l-2?$l-2:$nc-1));
- $l > 1 and $tab->(0, $p+1, 'next &rsaquo;');
- end 'ul';
-}
-
-
-sub htmlBrowseHist {
- my($self, $list, $f, $np, $url) = @_;
- $self->htmlBrowse(
- items => $list,
- options => $f,
- nextpage => $np,
- pageurl => $url,
- class => 'history',
- header => [
- sub { td class => 'tc1_1', 'Rev.'; td class => 'tc1_2', ''; },
- [ 'Date' ],
- [ 'User' ],
- [ 'Page' ],
- ],
- row => sub {
- my($s, $n, $i) = @_;
- my $revurl = "/$i->{type}$i->{itemid}.$i->{rev}";
-
- Tr;
- td class => 'tc1_1';
- a href => $revurl, "$i->{type}$i->{itemid}";
- end;
- td class => 'tc1_2';
- a href => $revurl, ".$i->{rev}";
- end;
- td class => 'tc2', fmtdate $i->{added}, 'full';
- td class => 'tc3';
- lit fmtuser $i;
- end;
- td class => 'tc4';
- a href => $revurl, title => $i->{ioriginal}, shorten $i->{ititle}, 80;
- b class => 'grayedout'; lit bb2html $i->{comments}, 150; end;
- end;
- end 'tr';
- },
- );
-}
-
-
-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';
- tagscore $l->{tagscore}, 0;
- 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};
- end 'td';
- }
- td class => 'tc8', defined($l->{wstat}) ? $self->{wishlist_status}[$l->{wstat}] : '' if $f->{wish};
- td class => 'tc2';
- $_ ne 'oth' && cssicon $_, $self->{platforms}{$_}
- for (sort @{$l->{c_platforms}});
- end;
- td class => 'tc3';
- cssicon "lang $_", $self->{languages}{$_}
- 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 1adb726b..00000000
--- a/lib/VNDB/Util/CommonHTML.pm
+++ /dev/null
@@ -1,491 +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) = @_;
- $sel ||= '';
- my $id = $type.$obj->{id};
-
- return if $type eq 'g' && !$self->authCan('tagmod');
-
- ul class => 'maintabs';
- if($type =~ /[uvrpcsd]/) {
- li $sel eq 'hist' ? (class => 'tabselected') : ();
- a href => "/$id/hist", 'history';
- end;
- }
-
- if($type =~ /[uvp]/) {
- my $cnt = $self->dbThreadCount($type, $obj->{id});
- li $sel eq 'disc' ? (class => 'tabselected') : ();
- a href => "/t/$id", "discussions ($cnt)";
- end;
- }
-
- if($type eq 'u') {
- li $sel eq 'posts' ? (class => 'tabselected') : ();
- a href => "/$id/posts", 'posts';
- end;
- }
-
- if($type eq 'u' && (!($obj->{hide_list} || $obj->{prefs}{hide_list}) || ($self->authInfo->{id} && $self->authInfo->{id} == $obj->{id}) || $self->authCan('usermod'))) {
- li $sel eq 'wish' ? (class => 'tabselected') : ();
- a href => "/$id/wish", 'wishlist';
- end;
-
- li $sel eq 'votes' ? (class => 'tabselected') : ();
- a href => "/$id/votes", 'votes';
- end;
-
- li $sel eq 'list' ? (class => 'tabselected') : ();
- a href => "/$id/list", 'list';
- end;
- }
-
- if($type eq 'v' && $self->authCan('tag') && !$obj->{hidden}) {
- li $sel eq 'tagmod' ? (class => 'tabselected') : ();
- a href => "/$id/tagmod", 'modify tags';
- end;
- }
-
- if(($type =~ /[rc]/ && $self->authCan('edit')) && $self->authInfo->{c_changes} > 0) {
- li $sel eq 'copy' ? (class => 'tabselected') : ();
- a href => "/$id/copy", 'copy';
- end;
- }
-
- if( $type eq 'u' && ($self->authInfo->{id} && $obj->{id} == $self->authInfo->{id} || $self->authCan('usermod'))
- || $type =~ /[vrpcs]/ && $self->authCan('edit') && ((!$obj->{locked} && !$obj->{hidden}) || $self->authCan('dbmod'))
- || $type =~ /[gi]/ && $self->authCan('tagmod')
- || $type eq 'd' && $self->authCan('dbmod')
- ) {
- li $sel eq 'edit' ? (class => 'tabselected') : ();
- a href => "/$id/edit", 'edit';
- end;
- }
-
- if($type eq 'u' && $self->authCan('usermod')) {
- li $sel eq 'del' ? (class => 'tabselected') : ();
- a href => "/$id/del", 'remove';
- end;
- }
-
- if($type eq 'v') {
- li $sel eq 'releases' ? (class => 'tabselected') : ();
- a href => "/$id/releases", 'releases';
- end;
- }
-
- if($type =~ /[vp]/ && $obj->{rgraph}) {
- li $sel eq 'rg' ? (class => 'tabselected') : ();
- a href => "/$id/rg", 'relations';
- end;
- }
-
- li !$sel ? (class => 'tabselected') : ();
- a href => "/$id", $id;
- end;
- end 'ul';
-}
-
-
-# generates a full error page, including header and footer
-sub htmlDenied {
- my $self = shift;
- $self->htmlHeader(title => 'Access Denied');
- div class => 'mainbox';
- h1 'Access Denied';
- div class => 'warning';
- if(!$self->authInfo->{id}) {
- h2 'You need to be logged in to perform this action.';
- p; lit 'Please <a href="/u/login">login</a>, or <a href="/u/register">create an account</a> if you don\'t have one yet.'; end;
- } else {
- h2 'You are not allowed to perform this action.';
- p 'It seems you don\'t have the proper rights to perform the action you wanted to perform...';
- }
- end;
- end 'div';
- $self->htmlFooter;
-}
-
-
-# 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}
- : $type eq 's' ? $self->dbStaffGetRev(id => $obj->{id})->[0]{comments}
- : $type eq 'd' ? $self->dbDocGetRev(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}";
-
- # character information may be rather spoilerous
- if($type eq 'c') {
- div class => 'warning';
- h2 'SPOILER WARNING!';
- lit 'This revision page may contain major spoilers. You may want to view the <a href="/c'.$new->{id}.'">final page</a> instead.';
- end;
- br;br;
- }
-
- # 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", 'edit';
- txt ')';
- br;
- txt 'By ';
- lit fmtuser $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
-sub htmlEditMessage {
- my($self, $type, $obj, $title, $copy) = @_;
- my $typename = {v => 'visual novel', r => 'release', p => 'producer', c => 'character', s => 'person'}->{$type};
- my $guidelines = {v => 2, r => 3, p => 4, c => 12, 's' => 16}->{$type};
-
- div class => 'mainbox';
- h1 $title;
- if($copy) {
- div class => 'warning';
- h2 'You\'re not editing an entry!';
- p;
- txt 'You\'re about to insert a new entry into the database with information based on ';
- a href => "/$type$obj->{id}", $obj->{title}||$obj->{name};
- txt '.';
- br;
- txt 'Hit the \'edit\' tab on the right-top if you intended to edit the entry instead of creating a new one.';
- end;
- end;
- }
- div class => 'notice';
- h2 'Before editing:';
- ul;
- li;
- txt "Read the ";
- a href=> "/d$guidelines", 'guidelines';
- txt '!';
- end;
- if($obj) {
- li;
- txt 'Check for any existing discussions on the ';
- a href => $type =~ /[cs]/ ? '/t/db' : $type eq 'r' ? "/t/v$obj->{vn}[0]{vid}" : "/t/$type$obj->{id}", 'discussion board';
- end;
- li;
- txt 'Browse the ';
- a href => "/$type$obj->{id}/hist", 'edit history';
- txt ' for any recent changes related to what you want to change.';
- end;
- } elsif($type ne 'r') {
- li;
- a href => "/$type/all", 'Search the database';
- txt " to see if we already have information about this $typename.";
- end;
- }
- end;
- end;
- if($obj && !$obj->{lastrev}) {
- div class => 'warning';
- h2 'Reverting';
- p "You are editing an old revision of this $typename. If you save it, all changes made after this revision will be reverted!";
- end;
- }
- end 'div';
-}
-
-
-# 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
-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->dbVoteGet(
- $type.'id' => $obj->{id},
- results => 8,
- what => $type eq 'v' ? 'user hide_list' : 'vn',
- hide_ign => $type eq 'v',
- );
- 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($type eq 'u') {
- a href => "/v$_->{vid}", title => $_->{original}||$_->{title}, shorten $_->{title}, 40;
- } elsif($_->{hide_list}) {
- b class => 'grayedout', 'hidden';
- } else {
- a href => "/u$_->{uid}", $_->{username};
- }
- 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 {
- my($self, $sel, $v) = @_;
-
- fieldset class => 'search';
- p id => 'searchtabs';
- 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';
- end;
- input type => 'text', name => 'q', id => 'q', class => 'text', value => $v;
- input type => 'submit', class => 'submit', value => 'Search!';
- end 'fieldset';
-}
-
-
-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 e047dbd9..00000000
--- a/lib/VNDB/Util/FormHTML.pm
+++ /dev/null
@@ -1,279 +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';
- 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, (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{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) {
- ul class => 'maintabs notfirst', 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';
- }
-
- # 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 5cb266f4..00000000
--- a/lib/VNDB/Util/LayoutHTML.pm
+++ /dev/null
@@ -1,204 +0,0 @@
-
-package VNDB::Util::LayoutHTML;
-
-use strict;
-use warnings;
-use TUWF ':html', 'uri_escape';
-use Exporter 'import';
-use Encode 'decode_utf8';
-use VNDB::Func;
-
-our @EXPORT = qw|htmlHeader htmlFooter|;
-
-
-sub htmlHeader { # %options->{ title, noindex, search, feeds, svg, metadata }
- my($self, %o) = @_;
- my $skin = $self->reqGet('skin') || $self->authPref('skin') || $self->{skin_default};
- $skin = $self->{skin_default} if !$self->{skins}{$skin} || !-d "$VNDB::ROOT/static/s/$skin";
-
- # heading
- lit '<!DOCTYPE HTML>';
- tag 'html', lang => 'en';
- head prefix => 'og: http://ogp.me/ns#';
- title $o{title};
- Link rel => 'shortcut icon', href => '/favicon.ico', type => 'image/x-icon';
- Link rel => 'stylesheet', href => $self->{url_static}.'/s/'.$skin.'/style.css?'.$self->{version}, type => 'text/css', media => 'all';
- Link rel => 'search', type => 'application/opensearchdescription+xml', title => 'VNDB VN Search', href => $self->reqBaseURI().'/opensearch.xml';
- if($self->authPref('customcss')) {
- (my $css = $self->authPref('customcss')) =~ s/\n/ /g;
- style type => 'text/css', $css;
- }
- Link rel => 'alternate', type => 'application/atom+xml', href => "/feeds/$_.atom", title => $self->{atom_feeds}{$_}[1]
- for ($o{feeds} ? @{$o{feeds}} : ());
-
- if(exists $o{metadata}) {
- # Required fields as per http://op.me/#metadata: og:title, og:type, og:image, og:url
- if(exists $o{metadata}{'og:title'}) {
- $o{metadata}{'og:site_name'} = 'The Visual Novel Database';
- $o{metadata}{'og:type'} ||= 'object';
- $o{metadata}{'og:image'} ||= $self->{placeholder_img};
- $o{metadata}{'og:url'} ||= $self->reqURI();
- }
-
- for my $k (keys %{$o{metadata}}) {
- next if !$o{metadata}{$k} and $o{metadata}{$k} ne '0';
- $o{metadata}{$k} =~ s/\R/ /g;
-
- meta property => "$k", content => $o{metadata}->{$k}, undef;
- }
- }
-
- meta name => 'robots', content => 'noindex, follow', undef if $o{noindex};
- end;
- body;
- div id => 'bgright', ' ';
- div id => 'header';
- h1;
- a href => '/', 'the visual novel database';
- end;
- end;
-
- _menu($self, %o);
-
- div id => 'maincontent';
-}
-
-
-sub _menu {
- my($self, %o) = @_;
-
- div id => 'menulist';
-
- div class => 'menubox';
- h2;
- txt 'Menu';
- end;
- div;
- a href => '/', 'Home'; br;
- a href => '/v/all', 'Visual novels'; br;
- b class => 'grayedout', '> '; 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 => '/u/all', 'Users'; br;
- a href => '/hist', 'Recent changes'; br;
- a href => '/t', 'Discussion board'; br;
- a href => '/d6', 'FAQ'; br;
- a href => '/v/rand','Random visual novel';
- end;
- form action => '/v/all', method => 'get', id => 'search';
- fieldset;
- legend 'Search';
- input type => 'text', class => 'text', id => 'sq', name => 'sq', value => $o{search}||'', placeholder => 'search';
- input type => 'submit', class => 'submit', value => 'Search';
- end;
- end;
- end 'div'; # /menubox
-
- div class => 'menubox';
- if($self->authInfo->{id}) {
- my $uid = sprintf '/u%d', $self->authInfo->{id};
- my $nc = $self->authInfo->{notifycount};
- h2;
- a href => $uid, ucfirst $self->authInfo->{username};
- end;
- div;
- a href => "$uid/edit", 'My Profile'; br;
- a href => "$uid/list", 'My Visual Novel List'; br;
- a href => "$uid/votes",'My Votes'; br;
- a href => "$uid/wish", 'My Wishlist'; br;
- a href => "$uid/notifies", $nc ? (class => 'notifyget') : (), 'My Notifications'.($nc?" ($nc)":''); br;
- a href => "$uid/hist", 'My Recent Changes'; br;
- a href => '/g/links?u='.$self->authInfo->{id}, 'My Tags'; br;
- br;
- if($self->authCan('edit')) {
- 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;
- }
- br;
- a href => "$uid/logout", 'Logout';
- end;
- } else {
- h2 'User menu';
- div;
- my $ref = uri_escape $self->reqPath().$self->reqQuery();
- a href => "/u/login?ref=$ref", 'Login'; br;
- a href => '/u/newpass', 'Password reset'; br;
- a href => '/u/register', 'Register'; br;
- end;
- }
- end 'div'; # /menubox
-
- div class => 'menubox';
- h2 'Database Statistics';
- div;
- dl;
- dt 'Visual Novels'; dd $self->{stats}{vn};
- dt 'Releases'; dd $self->{stats}{releases};
- dt 'Producers'; dd $self->{stats}{producers};
- dt 'Characters'; dd $self->{stats}{chars};
- dt 'Staff'; dd $self->{stats}{staff};
- dt 'VN Tags'; dd $self->{stats}{tags};
- dt 'Character Traits';dd $self->{stats}{traits};
- dt 'Users'; dd $self->{stats}{users};
- dt 'Threads'; dd $self->{stats}{threads};
- dt 'Posts'; dd $self->{stats}{posts};
- end;
- clearfloat;
- end;
- end;
- end 'div'; # /menulist
-}
-
-
-sub htmlFooter { # %options => { pref_code => 1 }
- my($self, %o) = @_;
- div id => 'footer';
-
- my $q = $self->dbRandomQuote;
- if($q && $q->{vid}) {
- lit '"';
- a href => "/v$q->{vid}", style => 'text-decoration: none', $q->{quote};
- txt '"';
- br;
- }
-
- txt "vndb $self->{version} | ";
- a href => '/d7', 'about us';
- txt ' | ';
- a href => 'irc://irc.synirc.net/vndb', '#vndb';
- txt ' | ';
- a href => "mailto:$self->{admin_email}", $self->{admin_email};
- txt ' | ';
- a href => $self->{source_url}, 'source';
- end;
- 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}, '';
- end 'body';
- end 'html';
-
- # write the SQL queries as a HTML comment when debugging is enabled
- if($self->debug) {
- lit "\n<!--\n SQL Queries:\n";
- for (@{$self->{_TUWF}{DB}{queries}}) {
- my($sql, $params, $time) = @$_;
- lit sprintf " [%6.2fms] %s | %s\n", $time*1000, $sql,
- join ', ',
- map "$_:".DBI::neat($params->{$_}),
- sort { $a =~ /^[0-9]+$/ && $b =~ /^[0-9]+$/ ? $a <=> $b : $a cmp $b }
- keys %$params
- }
- lit "-->\n";
- }
-}
-
-
-1;
diff --git a/lib/VNDB/Util/Misc.pm b/lib/VNDB/Util/Misc.pm
deleted file mode 100644
index d18d19ec..00000000
--- a/lib/VNDB/Util/Misc.pm
+++ /dev/null
@@ -1,164 +0,0 @@
-
-package VNDB::Util::Misc;
-
-use strict;
-use warnings;
-use Exporter 'import';
-use TUWF ':html';
-use VNDB::Func;
-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 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 %{$self->{resolutions}})[$_] || 'unknown'
- } else { $_ }
- } ref $fil->{resolution} ? @{$fil->{resolution}} : $fil->{resolution} ];
- }
-
- $mod;
-}
-
-
-
-sub bbSubstLinks {
- my ($self, $msg) = @_;
-
- # Parse a message and create an index of links to resolve
- my %lookup;
- VNDB::BBCode::parse $msg, sub {
- my($code, $tag) = @_;
- $lookup{$1}{$2} = 1 if $tag eq 'dblink' && $code =~ /^(.)(\d+)/;
- 1;
- };
- return $msg unless %lookup;
-
- # Now resolve the links
- my %links;
- my @opt = (results => 50);
-
- if ($lookup{v}) {
- $links{"v$_->{id}"} = $_->{title} for (@{$self->dbVNGet(id => [keys %{$lookup{v}}], @opt)});
- }
- if ($lookup{c}) {
- $links{"c$_->{id}"} = $_->{name} for (@{$self->dbCharGet(id => [keys %{$lookup{c}}], @opt)});
- }
- if ($lookup{p}) {
- $links{"p$_->{id}"} = $_->{name} for (@{$self->dbProducerGet(id => [keys %{$lookup{p}}], @opt)});
- }
- if ($lookup{g}) {
- $links{"g$_->{id}"} = $_->{name} for (@{$self->dbTagGet(id => [keys %{$lookup{g}}], @opt)});
- }
- if ($lookup{i}) {
- $links{"i$_->{id}"} = $_->{name} for (@{$self->dbTraitGet(id => [keys %{$lookup{i}}], @opt)});
- }
- if ($lookup{s}) {
- $links{"s$_->{id}"} = $_->{name} for (@{$self->dbStaffGet(id => [keys %{$lookup{s}}], @opt)});
- }
- return $msg unless %links;
-
- # Now substitute
- my $result = '';
- VNDB::BBCode::parse $msg, sub {
- my($code, $tag) = @_;
- $result .= $tag eq 'dblink' && $links{$code}
- ? sprintf '[url=/%s]%s[/url]', $code, $links{$code}
- : $code;
- 1;
- };
- return $result;
-}
-
-1;
-
diff --git a/lib/VNDB/Util/ValidateTemplates.pm b/lib/VNDB/Util/ValidateTemplates.pm
deleted file mode 100644
index e7ff3102..00000000
--- a/lib/VNDB/Util/ValidateTemplates.pm
+++ /dev/null
@@ -1,103 +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-]*$/, 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 },
- }
-);
-
-
-# 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
new file mode 100644
index 00000000..442d46f4
--- /dev/null
+++ b/lib/VNWeb/Auth.pm
@@ -0,0 +1,404 @@
+# This package provides an 'auth' function and a useful object for dealing with
+# VNDB sessions. Usage:
+#
+# use VNWeb::Auth;
+#
+# if(auth) {
+# ..user is logged in
+# }
+#
+# my $success = auth->login($uid, $pass);
+# auth->logout;
+#
+# my $uid = auth->uid;
+# my $wants_spoilers = auth->pref('spoilers');
+# ..etc
+#
+# die "You're not allowed to post!" if !auth->permBoard;
+#
+package VNWeb::Auth;
+
+use v5.24;
+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 MIME::Base64 'encode_base64url';
+use POSIX 'strftime';
+
+use VNDB::Func 'norm_ip';
+use VNDB::Config;
+use VNWeb::DB;
+
+our @EXPORT = ('auth');
+
+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 UTC] %s %s: %s\n", strftime('%Y-%m-%d %H:%M:%S', gmtime), $uri, tuwf->req && tuwf->req->{auth} ? auth->uid : '-', $msg;
+};
+
+
+
+use overload bool => sub { defined shift->{user}{user_id} };
+
+sub uid { shift->{user}{user_id} }
+sub user { shift->{user} }
+sub token { shift->{token} }
+sub isMod { auth->permUsermod || auth->permDbmod || auth->permBoardmod || auth->permTagmod }
+
+
+
+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 (@perms) {
+ no strict 'refs';
+ *{ '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;
+}
+
+
+# Prepares a plaintext password for database storage
+# Arguments: pass, optionally: salt, N, r, p
+# Returns: hashed password (hex coded)
+sub _preparepass {
+ my($self, $pass, $salt, $N, $r, $p) = @_;
+ ($N, $r, $p) = @{$self->{scrypt_args}} if !$N;
+ $salt ||= urandom(8);
+ utf8::encode(my $utf8pass = $pass);
+ unpack 'H*', pack 'NCCa8a*', $N, $r, $p, $salt, scrypt_raw($utf8pass, $self->{scrypt_salt} . $salt, $N, $r, $p, 32);
+}
+
+
+# Hash a password with the same scrypt parameters as the users' current password.
+sub _encpass {
+ my($self, $uid, $pass) = @_;
+
+ my $args = tuwf->dbVali('SELECT user_getscryptargs(id) FROM users WHERE id =', \$uid);
+ return undef if !$args || length($args) != 14;
+
+ my($N, $r, $p, $salt) = unpack 'NCCa8', $args;
+ $self->_preparepass($pass, $salt, $N, $r, $p);
+}
+
+
+# Arguments: self, uid, encpass
+# 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, \'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);
+ return $self->_load_session($uid, $token_db) ? 1 : $token_db;
+ }
+}
+
+
+sub _load_session {
+ my($self, $uid, $token_db) = @_;
+
+ my $user = $uid ? tuwf->dbRowi(
+ '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
+ tuwf->resCookie(auth => undef) if !$user->{user_id} && tuwf->reqCookie('auth');
+
+ $self->{user} = $user;
+ $self->{token} = $token_db;
+ $user->{user_id};
+}
+
+
+sub new {
+ bless {
+ 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 uid/pass combination is correct,
+# but doesn't actually create a session.
+sub login {
+ 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);
+}
+
+
+sub logout {
+ my $self = shift;
+ return if !$self->uid;
+ tuwf->dbExeci('SELECT', sql_func user_logout => \$self->uid, sql_fromhex $self->{token});
+ $self->_load_session();
+}
+
+
+sub wasteTime {
+ my $self = shift;
+ $self->_preparepass(urandom(20));
+}
+
+
+# Create a random token that can be used to reset the password.
+# 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 $u = tuwf->dbRowi(
+ 'SELECT uid, mail FROM', sql_func(user_resetpass => \$mail, sql_fromhex sha1_hex lc $token), 'x(uid, mail)'
+ );
+ 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_validate_session => \$uid, sql_fromhex(sha1_hex lc $token), \'pass'), 'IS DISTINCT FROM NULL');
+}
+
+
+# Change the users' password, drop all existing sessions and create a new session.
+# Requires either the current password or a reset token.
+# Returns 1 on success, 0 on failure.
+sub setpass {
+ my($self, $uid, $token, $oldpass, $newpass) = @_;
+
+ my $code = $token
+ ? sha1_hex lc $token
+ : $self->_encpass($uid, $oldpass);
+ return 0 if !$code;
+
+ my $encpass = $self->_preparepass($newpass);
+ return 0 if !tuwf->dbVali(
+ select => sql_func user_setpass => \$uid, sql_fromhex($code), sql_fromhex($encpass)
+ );
+ $self->_create_session($uid, $encpass);
+}
+
+
+sub setmail_token {
+ my($self, $mail) = @_;
+ my $token = unpack 'H*', urandom(20);
+ tuwf->dbExeci(select => sql_func user_setmail_token => \$self->uid, sql_fromhex($self->token), sql_fromhex(sha1_hex lc $token), \$mail);
+ $token;
+}
+
+
+sub setmail_confirm {
+ my(undef, $uid, $token) = @_;
+ tuwf->dbVali(select => sql_func user_setmail_confirm => \$uid, sql_fromhex sha1_hex lc $token);
+}
+
+
+# Generate an CSRF token for this user, also works for anonymous users (albeit
+# 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, $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
+ ), 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, $purpose) = @_;
+ $self->csrftoken($_, $purpose) eq $token && return 1 for reverse -11..0;
+ return 0;
+}
+
+
+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;
+}
+
+
+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 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
new file mode 100644
index 00000000..7eae6db8
--- /dev/null
+++ b/lib/VNWeb/DB.pm
@@ -0,0 +1,373 @@
+package VNWeb::DB;
+
+use v5.24;
+use warnings;
+use TUWF;
+use SQL::Interp ':all';
+use Carp 'carp';
+use Exporter 'import';
+use VNDB::Schema;
+
+our @EXPORT = qw/
+ sql
+ 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
+/;
+
+
+
+# Test for potential SQL injection and warn about it. This will cause some
+# false positives.
+# The heuristic is pretty simple: Just check if there's an integer in the SQL
+# statement. SQL injection through strings is likely to be caught much earlier,
+# since that will generate a syntax error if the string is not properly escaped
+# (and who'd put effort into escaping strings when placeholders are easier?).
+sub interp_warn {
+ my @r = sql_interp @_;
+ # 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;
+}
+
+
+# SQL::Interp wrappers around TUWF's db* methods. These do not work with
+# sql_type(). Proper integration should probably be added directly to TUWF.
+sub TUWF::Object::dbExeci { shift->dbExec(interp_warn @_) }
+sub TUWF::Object::dbVali { shift->dbVal (interp_warn @_) }
+sub TUWF::Object::dbRowi { shift->dbRow (interp_warn @_) }
+sub TUWF::Object::dbAlli { shift->dbAll (interp_warn @_) }
+sub TUWF::Object::dbPagei { shift->dbPage(shift, interp_warn @_) }
+
+# Ugly workaround to ensure that db* method failures are reported at the actual caller.
+$Carp::Internal{ (__PACKAGE__) }++;
+
+
+
+# sql_* are macros for SQL::Interp use
+
+# join(), but for sql objects.
+sub sql_join {
+ my $sep = shift;
+ my @args = map +($sep, $_), @_;
+ sql @args[1..$#args];
+}
+
+# Join multiple arguments together with a comma, for use in a SELECT or IN
+# clause or function arguments.
+sub sql_comma { sql_join ',', @_ }
+
+sub sql_and { @_ ? sql_join 'AND', map sql('(', $_, ')'), @_ : sql '1=1' }
+sub sql_or { @_ ? sql_join 'OR', map sql('(', $_, ')'), @_ : sql '1=0' }
+
+# Construct a PostgreSQL array type from the function arguments.
+sub sql_array { 'ARRAY[', sql_join(',', map \$_, @_), ']' }
+
+# Call an SQL function
+sub sql_func {
+ my($funcname, @args) = @_;
+ sql $funcname, '(', sql_comma(@args), ')';
+}
+
+# Convert a Perl hex value into Postgres bytea
+sub sql_fromhex($) {
+ sql_func decode => \$_[0], "'hex'";
+}
+
+# Convert a Postgres bytea into a Perl hex value
+sub sql_tohex($) {
+ sql_func encode => $_[0], "'hex'";
+}
+
+# Convert a Perl time value (UNIX timestamp) into a Postgres timestamp
+sub sql_fromtime($) {
+ sql_func to_timestamp => \$_[0];
+}
+
+# Convert a Postgres timestamp into a Perl time value
+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 = shift||'u';
+ my $prefix = shift||'user_';
+ join ', ',
+ "$tbl.id as ${prefix}id",
+ "$tbl.username as ${prefix}name",
+ "$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",
+ 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');
+}
+
+
+
+
+# The enrich*() functions are based on https://dev.yorhel.nl/doc/sqlobject
+# See that article for general usage information, the following is purely
+# reference documentation:
+#
+# enrich $name, $key, $merge_col, $sql, @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 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:
+#
+# $key is the field in @objects used in the IN clause of $sql,
+#
+# $merge_col is the column name returned by $sql and compared against the
+# values of the $key field.
+# (enrich_merge() requires that the column name is equivalent to $key)
+#
+# $sql is the query to be executed, can be either:
+# - A string or sql() object, in which case it should end with ' IN' so
+# that the list of identifiers can be appended to it.
+# - A subroutine, in which case the array of identifiers is given as first
+# argument. The sub should return an sql() object.
+#
+# @objects is a list or array of hashrefs to be enriched.
+
+
+# Helper function for the enrich functions below.
+sub _enrich {
+ my($merge, $key, $sql, @array) = @_;
+
+ # 'flatten' the given array, so that you can also give arrayrefs as argument
+ @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 defined($_->{$key}) ? ($_->{$key},1) : (), @array;
+ return if !keys %ids;
+
+ # Fetch the data
+ $sql = ref $sql eq 'CODE' ? do { local $_ = [keys %ids]; sql $sql->($_) } : sql $sql, [keys %ids];
+ my $data = tuwf->dbAlli($sql);
+
+ # And merge
+ $merge->($data, \@array);
+}
+
+
+sub enrich {
+ my($name, $key, $merge_col, $sql, @array) = @_;
+ _enrich sub {
+ my($data, $array) = @_;
+ my %ids = ();
+ push $ids{ delete $_->{$merge_col} }->@*, $_ for @$data;
+ $_->{$name} = $ids{ $_->{$key} }||[] for @$array;
+ }, $key, $sql, @array;
+}
+
+
+sub enrich_merge {
+ my($key, $sql, @array) = @_;
+ _enrich sub {
+ my($data, $array) = @_;
+ my %ids = map +(delete($_->{$key}), $_), @$data;
+ %$_ = (%$_, ($ids{ $_->{$key} }||{})->%*) for @$array;
+ }, $key, $sql, @array;
+}
+
+
+sub enrich_flatten {
+ my($name, $key, $merge_col, $sql, @array) = @_;
+ _enrich sub {
+ my($data, $array) = @_;
+ my %ids = ();
+ push $ids{ delete $_->{$merge_col} }->@*, values %$_ for @$data;
+ $_->{$name} = $ids{ $_->{$key} }||[] for @$array;
+ }, $key, $sql, @array;
+}
+
+
+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
+# updating entries, and should support easy diffing/comparison.
+# Not very convenient for general querying & searching, those still need custom
+# queries.
+
+
+# Hash table, something like:
+# {
+# v => {
+# prefix => 'vn',
+# base => { .. 'vn_hist' schema }
+# tables => {
+# anime => { .. 'vn_anime_hist' schema }
+# },
+# }, ..
+# }
+my $entry_types = do {
+ my $schema = VNDB::Schema::schema;
+ my %types = map +($_->{dbentry_type}, { prefix => $_->{name} }), grep $_->{dbentry_type}, values %$schema;
+ for my $t (values %$schema) {
+ my $n = $t->{name};
+ my($type) = grep $n =~ /^$_->{prefix}_/, values %types;
+ next if !$type || $n !~ s/^$type->{prefix}_?(.*)_hist$/$1/;
+ if($n eq '') { $type->{base} = $t }
+ else { $type->{tables}{$n} = $t }
+ }
+ \%types;
+};
+
+
+# Returns everything for a specific entry ID. The top-level hash also includes
+# the following keys:
+#
+# id, chid, chrev, maxrev, hidden, locked, entry_hidden, entry_locked
+#
+# (Ordering of arrays is unspecified)
+sub db_entry {
+ 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};
+
+ # 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}
+ }
+
+ %$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 $_->{name}, grep $_->{name} ne 'chid', $tbl->{cols}->@*),
+ FROM => data_table($tbl->{name}),
+ );
+ }
+ $entry
+}
+
+
+# Edit or create an entry, usage:
+# ($id, $chid, $rev) = db_edit $type, $id, $data, $uid;
+#
+# $id should be undef to create a new entry.
+# $uid should be undef to use the currently logged in user.
+# $data should have the same format as returned by db_entry(), but instead with
+# the following additional keys in the top-level hash:
+#
+# hidden, locked, editsum
+sub db_edit {
+ my($type, $id, $data, $uid) = @_;
+ $id ||= undef;
+ my $t = $entry_types->{$type}||die;
+
+ 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(),
+ 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($_->{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 @cols = grep $_->{name} ne 'chid', $tbl->{cols}->@*;
+ my @colnames = sql_comma(map $_->{name}, @cols);
+ my @rows = map {
+ my $d = $_;
+ sql '(', sql_comma(map val($d->{$_->{name}}, $_), @cols), ')'
+ } $data->{$name}->@*;
+
+ tuwf->dbExeci("DELETE FROM edit_${base}");
+ tuwf->dbExeci("INSERT INTO edit_${base} (", @colnames, ') VALUES ', sql_comma @rows) if @rows;
+ }
+
+ tuwf->dbRow("SELECT * FROM edit_${type}_commit()");
+}
+
+1;
diff --git a/lib/VNWeb/Discussions/Board.pm b/lib/VNWeb/Discussions/Board.pm
new file mode 100644
index 00000000..9fa9e304
--- /dev/null
+++ b/lib/VNWeb/Discussions/Board.pm
@@ -0,0 +1,50 @@
+package VNWeb::Discussions::Board;
+
+use VNWeb::Prelude;
+use VNWeb::Discussions::Lib;
+
+
+TUWF::get qr{/t/(all|$BOARD_RE)}, sub {
+ 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 ? dbobj $id : undef;
+ return tuwf->resNotFound if $id && !$obj->{id};
+ return tuwf->resNotFound if $id && $id =~ /^u/ && $obj->{entry_hidden} && !auth->isMod;
+
+ 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, dbobj => $obj, tab => 'disc',
+ sub {
+ article_ sub {
+ h1_ $title;
+ boardtypes_ $type;
+ boardsearch_ $type if !$id;
+ p_ class => 'center', sub {
+ a_ href => $createurl, 'Start a new thread';
+ } if can_edit t => {};
+ };
+
+ 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 IS NOT DISTINCT FROM', \$id, ')'),
+ results => 50,
+ sort => $type eq 'an' ? 't.id DESC' : undef,
+ page => $page,
+ paginate => sub { "?p=$_" }
+ or article_ sub {
+ h1_ 'An empty board';
+ p_ class => 'center', sub {
+ txt_ "Nobody's started a discussion on this board yet. Why not ";
+ a_ href => $createurl, 'create a new thread';
+ txt_ ' yourself?';
+ }
+ }
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Discussions/Edit.pm b/lib/VNWeb/Discussions/Edit.pm
new file mode 100644
index 00000000..06fb2397
--- /dev/null
+++ b/lib/VNWeb/Discussions/Edit.pm
@@ -0,0 +1,167 @@
+package VNWeb::Discussions::Edit;
+
+use VNWeb::Prelude;
+use VNWeb::Discussions::Lib;
+
+
+my $FORM = {
+ 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 => { 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
+ 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 },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+
+
+elm_api DiscussionsEdit => $FORM_OUT, $FORM_IN, sub {
+ my($data) = @_;
+ my $tid = $data->{tid};
+
+ my $t = !$tid ? {} : tuwf->dbRowi('
+ 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 = 1
+ WHERE t.id =', \$tid,
+ 'AND', sql_visible_threads());
+ return tuwf->resNotFound if $tid && !$t->{id};
+ return elm_Unauth if !can_edit t => $t;
+
+ tuwf->dbExeci(q{DELETE FROM notifications WHERE iid =}, \$tid) if $tid && auth->permBoardmod && ($data->{delete} || $data->{hidden});
+
+ if($tid && $data->{delete} && auth->permBoardmod) {
+ auth->audit($t->{user_id}, 'post delete', "deleted $tid.1");
+ tuwf->dbExeci('DELETE FROM threads WHERE id =', \$tid);
+ 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,
+ auth->permBoardmod ? (
+ hidden => $data->{hidden},
+ locked => $data->{locked},
+ boards_locked => $data->{boards_locked},
+ ) : (),
+ auth->isMod ? (
+ private => $data->{private}
+ ) : (),
+ };
+ tuwf->dbExeci('UPDATE threads SET', $thread, 'WHERE id =', \$tid) if $tid;
+ $tid = tuwf->dbVali('INSERT INTO threads', $thread, 'RETURNING id') if !$tid;
+
+ 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} }) for $data->{boards}->@*;
+ }
+
+ if($pollchanged) {
+ tuwf->dbExeci('DELETE FROM threads_poll_options WHERE tid =', \$tid);
+ tuwf->dbExeci('INSERT INTO threads_poll_options', { tid => $tid, option => $_ }) for $data->{poll}{options}->@*;
+ }
+
+ my $post = {
+ tid => $tid,
+ num => 1,
+ msg => bb_subst_links($data->{msg}),
+ $data->{tid} ? () : (uid => auth->uid),
+ !$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 => 1 }) if $data->{tid};
+
+ elm_Redirect "/$tid.1";
+};
+
+
+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, 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 = 1
+ WHERE t.id =', \$tid,
+ 'AND', sql_visible_threads());
+ return tuwf->resNotFound if $tid && !$t->{id};
+ return tuwf->resDenied if !can_edit t => $t;
+
+ $t->{poll}{options} = $t->{poll_question} && [ map $_->{option}, tuwf->dbAlli('SELECT option FROM threads_poll_options WHERE tid =', \$t->{id}, 'ORDER BY id')->@* ];
+ $t->{poll}{question} = delete $t->{poll_question};
+ $t->{poll}{max_options} = delete $t->{poll_max_options};
+ $t->{poll} = undef if !$t->{poll}{question};
+
+ if($tid) {
+ enrich_boards undef, $t;
+ } else {
+ $t->{boards} = [ {
+ btype => $board_type,
+ iid => $board_id ? $board_id->{id} : undef,
+ title => $board_id ? $board_id->{title} : undef,
+ } ];
+ 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->isMod;
+
+ $t->{hidden} //= 0;
+ $t->{msg} //= '';
+ $t->{title} //= tuwf->reqGet('title');
+ $t->{tid} //= undef;
+ $t->{private} //= auth->isMod && tuwf->reqGet('priv') ? 1 : 0;
+ $t->{locked} //= 0;
+ $t->{boards_locked} //= 0;
+ $t->{delete} = 0;
+
+ framework_ title => $tid ? 'Edit thread' : 'Create new thread', sub {
+ elm_ 'Discussions.Edit' => $FORM_OUT, $t;
+ };
+};
+
+
+1;
diff --git a/lib/VNWeb/Discussions/Elm.pm b/lib/VNWeb/Discussions/Elm.pm
new file mode 100644
index 00000000..500cc3b9
--- /dev/null
+++ b/lib/VNWeb/Discussions/Elm.pm
@@ -0,0 +1,33 @@
+package VNWeb::Discussions::Elm;
+
+use VNWeb::Prelude;
+
+# Autocompletion search results for boards
+elm_api Boards => undef, {
+ search => { searchquery => 1 },
+}, sub {
+ return elm_Unauth if !auth->permBoard;
+ my $q = shift->{search};
+ my $qs = sql_like "$q";
+
+ my $uscore = sql 'similarity(username, ', \$qs, ')';
+ $uscore = sql 'CASE WHEN id =', \$qs, 'THEN 1+1 ELSE', $uscore, 'END' if $qs =~ /^u$RE{num}$/;
+
+ elm_BoardResult tuwf->dbPagei({ results => 10, page => 1 },
+ 'SELECT btype, iid, title
+ FROM (',
+ sql_join('UNION ALL',
+ (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'
+ )
+};
+
+1;
diff --git a/lib/VNWeb/Discussions/Index.pm b/lib/VNWeb/Discussions/Index.pm
new file mode 100644
index 00000000..1e797d31
--- /dev/null
+++ b/lib/VNWeb/Discussions/Index.pm
@@ -0,0 +1,35 @@
+package VNWeb::Discussions::Index;
+
+use VNWeb::Prelude;
+use VNWeb::Discussions::Lib;
+
+
+TUWF::get qr{/t}, sub {
+ framework_ title => 'Discussion board index', sub {
+ form_ method => 'get', action => '/t/search', sub {
+ article_ sub {
+ h1_ 'Discussion board index';
+ boardtypes_ 'index';
+ boardsearch_;
+ p_ class => 'center', sub {
+ a_ href => '/t/ge/new', 'Start a new thread';
+ } if can_edit t => {};
+ }
+ };
+
+ for my $b (keys %BOARD_TYPE) {
+ 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 IS NULL)'),
+ results => $BOARD_TYPE{$b}{index_rows},
+ page => 1;
+ }
+ }
+};
+
+1;
diff --git a/lib/VNWeb/Discussions/Lib.pm b/lib/VNWeb/Discussions/Lib.pm
new file mode 100644
index 00000000..d4e8146a
--- /dev/null
+++ b/lib/VNWeb/Discussions/Lib.pm
@@ -0,0 +1,134 @@
+package VNWeb::Discussions::Lib;
+
+use VNWeb::Prelude;
+use Exporter 'import';
+
+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 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 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, ')');
+}
+
+
+# Adds a 'boards' array to threads.
+sub enrich_boards {
+ my($filt, @lst) = @_;
+ 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;
+}
+
+
+# Generate a thread list table, options:
+# where => SQL for the WHERE clause ('t' is available as alias for 'threads').
+# boards => SQL for the WHERE clause of the boards ('tb' as alias for 'threads_boards').
+# results => Number of threads to display.
+# page => Current page number.
+# paginate => sub {} reference that generates a url for paginate_(); pagination is disabled when not set.
+# sort => SQL (default: tl.date DESC)
+#
+# Returns 1 if something was displayed, 0 if no threads matched the where clause.
+sub threadlist_ {
+ my %opt = @_;
+
+ my $where = sql_and sql_visible_threads(), $opt{where}||();
+
+ my $count = $opt{paginate} && tuwf->dbVali('SELECT count(*) FROM threads t WHERE', $where);
+ return 0 if $opt{paginate} && !$count;
+
+ my $lst = tuwf->dbPagei(\%opt, q{
+ 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.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'
+ );
+ return 0 if !@$lst;
+
+ enrich_boards $opt{boards}, $lst;
+
+ paginate_ $opt{paginate}, $opt{page}, [ $count, $opt{results} ], 't' if $opt{paginate};
+ article_ class => 'browse discussions', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', sub { txt_ 'Topic'; debug_ $lst };
+ td_ class => 'tc2', 'Replies';
+ td_ class => 'tc3', 'Starter';
+ td_ class => 'tc4', 'Last post';
+ }};
+ tr_ sub {
+ my $l = $_;
+ td_ class => 'tc1', 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', $system ? '[system]' : '[private]' if $l->{private};
+ span_ class => 'pollflag', '[hidden]' if $l->{hidden};
+ txt_ shorten $l->{title}, 50;
+ };
+ span_ class => 'boards', sub {
+ join_ ', ', sub {
+ 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->{c_count}-1;
+ td_ class => 'tc3', sub { user_ $l, 'firstpost_' };
+ td_ class => 'tc4', sub {
+ user_ $l, 'lastpost_';
+ txt_ ' @ ';
+ a_ href => "/$l->{id}.$l->{c_lastnum}#last", fmtdate $l->{lastpost_date}, 'full';
+ };
+ } for @$lst;
+ }
+ };
+ paginate_ $opt{paginate}, $opt{page}, [ $count, $opt{results} ], 'b' if $opt{paginate};
+ 1;
+}
+
+
+sub boardsearch_ {
+ my($type) = @_;
+ form_ action => '/t/search', sub {
+ fieldset_ class => 'search', sub {
+ input_ type => 'text', name => 'bq', id => 'bq', class => 'text';
+ input_ type => 'hidden', name => 'b', value => $type if $type && $type ne 'all';
+ input_ type => 'submit', class => 'submit', value => 'Search!';
+ }
+ }
+}
+
+
+sub boardtypes_ {
+ my($type) = @_;
+ p_ class => 'browseopts', sub {
+ a_ href => $_->[0] eq 'index' ? '/t' : '/t/'.$_->[0], mkclass(optselected => $type && $type eq $_->[0]), $_->[1] for (
+ [ index => 'Index' ],
+ [ all => 'All boards' ],
+ map [ $_, $BOARD_TYPE{$_}{txt} ], keys %BOARD_TYPE
+ );
+ };
+}
+
+
+1;
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
new file mode 100644
index 00000000..79db2823
--- /dev/null
+++ b/lib/VNWeb/Discussions/Search.pm
@@ -0,0 +1,178 @@
+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 => { 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_ 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_ 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') : ();
+ label_ for => 't', 'Only search thread titles';
+ };
+
+ input_ type => 'submit', class => 'submit', value => 'Search';
+ debug_ $filt;
+ };
+ };
+ }
+ };
+ ($filt, $u)
+}
+
+
+sub noresults_ {
+ article_ sub {
+ h1_ 'No results';
+ p_ 'No threads or messages found matching your criteria.';
+ };
+}
+
+
+sub posts_ {
+ my($filt, $u) = @_;
+
+ # 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
+ # means we can re-use them for highlighting without worrying that they
+ # conflict with the message contents.
+
+ my($posts, $np) = tuwf->dbPagei({ results => 20, page => $filt->{p} }, q{
+ SELECT m.id, m.num, m.title
+ , }, sql_user(), q{
+ , }, 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]',
+ ') 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';
+ article_ class => 'browse postsearch', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1_1', 'Id';
+ td_ class => 'tc1_2', '';
+ td_ class => 'tc2', 'Date';
+ td_ class => 'tc3', 'User';
+ td_ class => 'tc4', sub { txt_ 'Message'; debug_ $posts; };
+ }};
+ tr_ sub {
+ my $l = $_;
+ 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_(
+ xml_escape($l->{headline})
+ =~ s/\[raw\]/<b>/gr
+ =~ s/\[\/raw\]/<\/b>/gr
+ =~ s/\[code\]/<small>...<\/small><br \/>/gr
+ )};
+ };
+ } for @$posts;
+ }
+ };
+ paginate_ \&url, $filt->{p}, $np, 'b';
+}
+
+
+sub threads_ {
+ my($filt, $u) = @_;
+
+ my @boards = grep $_ ne 'w', $filt->{b}->@*; # Can't search reviews by title
+ return noresults_ if !@boards;
+
+ my $where = sql_and
+ @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,
+ results => 50,
+ page => $filt->{p},
+ paginate => sub { '?'.query_encode %$filt, @_ };
+}
+
+
+TUWF::get qr{/t/search}, sub {
+ framework_ title => 'Search the discussion board',
+ sub {
+ my($filt, $u);
+ article_ sub {
+ h1_ 'Search the discussion board';
+ ($filt, $u) = filters_;
+ };
+ posts_ $filt, $u if $filt->{bq} && !$filt->{t};
+ threads_ $filt, $u if $filt->{bq} && $filt->{t};
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Discussions/Thread.pm b/lib/VNWeb/Discussions/Thread.pm
new file mode 100644
index 00000000..b3820dd7
--- /dev/null
+++ b/lib/VNWeb/Discussions/Thread.pm
@@ -0,0 +1,224 @@
+package VNWeb::Discussions::Thread;
+
+use VNWeb::Prelude;
+use VNWeb::Discussions::Lib;
+
+
+my $POLL_OUT = form_compile any => {
+ question => {},
+ max_options => { uint => 1 },
+ num_votes => { uint => 1 },
+ can_vote => { anybool => 1 },
+ preview => { anybool => 1 },
+ tid => { vndbid => 't' },
+ options => { aoh => {
+ id => { id => 1 },
+ option => {},
+ votes => { uint => 1 },
+ my => { anybool => 1 },
+ } },
+};
+
+my $POLL_IN = form_compile any => {
+ tid => { vndbid => 't' },
+ options => { type => 'array', values => { id => 1 } },
+};
+
+elm_api DiscussionsPoll => $POLL_OUT, $POLL_IN, sub {
+ my($data) = @_;
+ return elm_Unauth if !auth;
+
+ my $t = tuwf->dbRowi('SELECT poll_question, poll_max_options FROM threads t WHERE id =', \$data->{tid}, 'AND', sql_visible_threads());
+ return tuwf->resNotFound if !$t->{poll_question};
+
+ die 'Too many options' if $data->{options}->@* > $t->{poll_max_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 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 = form_compile any => {
+ tid => { vndbid => 't' },
+ old => { anybool => 1 },
+ msg => { maxlength => 32768 }
+};
+
+js_api DiscussionReply => $REPLY, sub {
+ my($data) = @_;
+ 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 tuwf->resDenied if !can_edit t => $t;
+
+ my $num = sql '(SELECT MAX(num)+1 FROM threads_posts WHERE tid =', \$data->{tid}, ')';
+ my $msg = bb_subst_links $data->{msg};
+ $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, $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/$_->{iid}", $_->{iid};
+ txt_ ':';
+ if($_->{title}) {
+ a_ href => "/$_->{iid}", tattr $_;
+ } else {
+ strong_ '[deleted]';
+ }
+ }
+ } for $t->{boards}->@*;
+ };
+ }
+}
+
+
+# Also used by Reviews::Page for review comments.
+sub posts_ {
+ my($t, $posts, $page) = @_;
+ my sub url { "/$t->{id}".($_?"/$_":'') }
+
+ paginate_ \&url, $page, [ $t->{count}, 25 ], 't';
+ article_ class => 'thread', id => 'threadstart', sub {
+ table_ class => 'stripe', sub {
+ 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_;
+ txt_ fmtdate $_->{date}, 'full';
+ }
+ };
+ td_ class => 'tc2', sub {
+ small_ class => 'edit', sub {
+ txt_ '< ';
+ if(can_edit t => $_) {
+ a_ href => "/$t->{id}.$_->{num}/edit", 'edit';
+ txt_ ' - ';
+ }
+ a_ href => "/report/$t->{id}.$_->{num}", 'report';
+ txt_ ' >';
+ } if !defined $_->{hidden} || can_edit t => $_;
+ if(defined $_->{hidden}) {
+ small_ sub {
+ txt_ 'Post deleted';
+ lit_ length $_->{hidden} ? ': '.bb_format $_->{hidden}, inline => 1 : '.';
+ };
+ } else {
+ lit_ bb_format $_->{msg};
+ small_ class => 'lastmod', 'Last modified on '.fmtdate($_->{edited}, 'full') if $_->{edited};
+ }
+ };
+ } for @$posts;
+ };
+ };
+ paginate_ \&url, $page, [ $t->{count}, 25 ], 'b';
+}
+
+
+sub reply_ {
+ my($t, $posts, $page) = @_;
+ return if $t->{count} > $page*25;
+ if(can_edit t => $t) {
+ div_ widget(DiscussionReply => $REPLY, { tid => $t->{id}, old => $posts->[$#$posts]{date} < time-182*24*3600 }), '';
+ } else {
+ article_ sub {
+ h1_ 'Reply';
+ p_ class => 'center',
+ !auth ? 'You must be logged in to reply to this thread.' :
+ $t->{locked} ? 'This thread has been locked, you can\'t reply to it anymore.' : 'You can not currently reply to this thread.';
+ }
+ }
+}
+
+
+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, 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
+ );
+ return tuwf->resNotFound if !$t->{id};
+
+ 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
+ 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
+ FROM threads_poll_options tpo
+ LEFT JOIN threads_poll_votes tpv ON tpv.optid = tpo.id
+ 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
+ ORDER BY tpo.id'
+ );
+
+ 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 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,
+ options => $poll_options
+ } if $t->{poll_question};
+ posts_ $t, $posts, $page;
+ reply_ $t, $posts, $page;
+ }
+};
+
+1;
diff --git a/lib/VNWeb/Discussions/UPosts.pm b/lib/VNWeb/Discussions/UPosts.pm
new file mode 100644
index 00000000..aaa75c1e
--- /dev/null
+++ b/lib/VNWeb/Discussions/UPosts.pm
@@ -0,0 +1,78 @@
+package VNWeb::Discussions::UPosts;
+
+use VNWeb::Prelude;
+
+
+sub listing_ {
+ my($count, $list, $page) = @_;
+
+ my sub url { '?'.query_encode @_ }
+
+ paginate_ \&url, $page, [ $count, 50 ], 't';
+ article_ class => 'browse uposts', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', sub { debug_ $list };
+ td_ class => 'tc2', '';
+ td_ class => 'tc3', 'Date';
+ td_ class => 'tc4', 'Title';
+ }};
+ tr_ sub {
+ 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};
+ small_ sub { lit_ bb_format $_->{msg}, maxlength => 150, inline => 1 };
+ };
+ } for @$list;
+ }
+ };
+
+ paginate_ \&url, $page, [ $count, 50 ], 'b';
+}
+
+
+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} || (!$u->{user_name} && !auth->isMod);
+
+ my $page = tuwf->validate(get => p => { upage => 1 })->data;
+
+ 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', $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} eq auth->uid;
+ my $title = $own ? 'My posts' : 'Posts by '.user_displayname $u;
+ framework_ title => $title, dbobj => $u, tab => 'posts',
+ sub {
+ article_ sub {
+ h1_ $title;
+ if(!$count) {
+ p_ +($own ? 'You have' : user_displayname($u).' has').' not posted anything on the forums yet.';
+ }
+ };
+
+ listing_ $count, $list, $page if $count;
+ };
+};
+
+
+1;
diff --git a/lib/VNWeb/Docs/Edit.pm b/lib/VNWeb/Docs/Edit.pm
new file mode 100644
index 00000000..2e33432a
--- /dev/null
+++ b/lib/VNWeb/Docs/Edit.pm
@@ -0,0 +1,56 @@
+package VNWeb::Docs::Edit;
+
+use VNWeb::Prelude;
+use VNWeb::Docs::Lib;
+
+
+my $FORM = {
+ id => { vndbid => 'd' },
+ title => { sl => 1, maxlength => 200 },
+ content => { default => '' },
+ 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{drev}/edit} => sub {
+ 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->{id}.$d->{chrev}";
+
+ framework_ title => "Edit $d->{title}", dbobj => $d, tab => 'edit',
+ sub {
+ div_ widget(DocEdit => $FORM_OUT, $d), '';
+ };
+};
+
+
+js_api DocEdit => $FORM_IN, sub {
+ my $data = shift;
+ my $doc = db_entry $data->{id} or return tuwf->resNotFound;
+
+ return tuwf->resDenied if !can_edit d => $doc;
+ return +{ _err => 'No changes' } if !form_changed $FORM_CMP, $data, $doc;
+
+ $data->{html} = md2html $data->{content};
+ my $c = db_edit d => $doc->{id}, $data;
+ +{ _redir => "/$c->{nitemid}.$c->{nrev}" };
+};
+
+
+js_api Markdown => {
+ content => { default => '' }
+}, sub {
+ return tuwf->resDenied if !auth->permDbmod;
+ +{ html => enrich_html md2html shift->{content} };
+};
+
+
+1;
diff --git a/lib/VNWeb/Docs/Lib.pm b/lib/VNWeb/Docs/Lib.pm
new file mode 100644
index 00000000..9a0cb6f9
--- /dev/null
+++ b/lib/VNWeb/Docs/Lib.pm
@@ -0,0 +1,57 @@
+package VNWeb::Docs::Lib;
+
+use VNWeb::Prelude;
+use VNDB::Skins;
+
+our @EXPORT = qw/enrich_html/;
+
+
+my @special_perms = qw/boardmod dbmod usermod tagmod/;
+
+sub _moderators {
+ 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");
+
+ xml_string sub {
+ dl_ sub {
+ for my $u (@$l) {
+ dt_ sub { a_ href => "/$u->{id}", $u->{username} };
+ dd_ @special_perms == grep($u->{"perm_$_"}, @special_perms) ? 'admin'
+ : join ', ', grep $u->{"perm_$_"}, @special_perms;
+ }
+ }
+ }
+}
+
+
+sub _skincontrib {
+ my %users;
+ 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], 'ORDER BY id');
+
+ xml_string sub {
+ dl_ sub {
+ for my $u (@$u) {
+ dt_ sub { a_ href => "/$u->{id}", $u->{username} };
+ dd_ sub {
+ join_ ', ', sub { a_ href => "?skin=$_->[0]", $_->[1] }, $users{$u->{id}}->@*
+ }
+ }
+ }
+ }
+}
+
+
+sub enrich_html {
+ my $html = shift;
+
+ $html =~ s{^:MODERATORS:}{_moderators}me;
+ $html =~ s{^:SKINCONTRIB:}{_skincontrib}me;
+
+ $html
+}
+
+1;
diff --git a/lib/VNWeb/Docs/Page.pm b/lib/VNWeb/Docs/Page.pm
new file mode 100644
index 00000000..e9949ab3
--- /dev/null
+++ b/lib/VNWeb/Docs/Page.pm
@@ -0,0 +1,59 @@
+package VNWeb::Docs::Page;
+
+use VNWeb::Prelude;
+use VNWeb::Docs::Lib;
+
+
+sub _index_ {
+ ul_ class => 'index', sub {
+ 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' };
+ li_ sub { a_ href => '/d3', 'Releases' };
+ li_ sub { a_ href => '/d4', 'Producers' };
+ li_ sub { a_ href => '/d16', 'Staff' };
+ li_ sub { a_ href => '/d12', 'Characters' };
+ li_ sub { a_ href => '/d10', 'Tags & Traits' };
+ li_ sub { a_ href => '/d19', 'Image Flagging' };
+ li_ sub { a_ href => '/d13', 'Capturing Screenshots' };
+ 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 => '/d8', 'Development' };
+ }
+}
+
+
+sub _rev_ {
+ my $d = shift;
+ revision_ $d, sub {},
+ [ title => 'Title' ],
+ [ content => 'Contents' ];
+}
+
+
+TUWF::get qr{/$RE{drev}} => sub {
+ my $d = db_entry tuwf->captures('id', 'rev');
+ return tuwf->resNotFound if !$d;
+
+ framework_ title => $d->{title}, index => !tuwf->capture('rev'), dbobj => $d, hiddenmsg => 1,
+ sub {
+ _rev_ $d if tuwf->capture('rev');
+ article_ sub {
+ itemmsg_ $d;
+ h1_ $d->{title};
+ div_ class => 'docs', sub {
+ _index_;
+ lit_ enrich_html($d->{html} || md2html $d->{content});
+ clearfloat_;
+ };
+ };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Elm.pm b/lib/VNWeb/Elm.pm
new file mode 100644
index 00000000..ad4f80a3
--- /dev/null
+++ b/lib/VNWeb/Elm.pm
@@ -0,0 +1,495 @@
+# This module is responsible for generating elm/Gen/*;
+#
+# 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).
+
+package VNWeb::Elm;
+
+use strict;
+use warnings;
+use TUWF;
+use Exporter 'import';
+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_empty
+/;
+
+
+# API response types and arguments. To generate an API response from Perl, call
+# elm_ResponseName(@args), e.g.:
+#
+# elm_Changed $id, $revision;
+#
+# These API responses are available in Elm in the `Gen.Api.Response` union type.
+our %apis = (
+ Unauth => [], # Not authorized
+ Unchanged => [], # No changes
+ Success => [],
+ Redirect => [{}], # Redirect to the given URL
+ Invalid => [], # POST data did not validate the schema
+ Editsum => [], # Invalid edit summary
+ Content => [{}], # Rendered HTML content (for markdown/bbcode APIs)
+ 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 => {},
+ alttitle => { default => '' },
+ released => { uint => 1 },
+ rtype => {},
+ reso_x => { uint => 1 },
+ reso_y => { uint => 1 },
+ lang => { type => 'array', values => {} },
+ platforms=> { type => 'array', values => {} },
+ } } ],
+ 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 },
+ } },
+ } } ],
+);
+# (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';
+ $schemas{$name} = [ map tuwf->compile($_), $apis{$name}->@* ];
+ *{'elm_'.$name} = sub {
+ my @args = map {
+ $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;
+}
+
+
+
+
+# Formatting functions
+sub indent($) { $_[0] =~ s/\n/\n /gr }
+sub list { indent "\n[ ".join("\n, ", @_)."\n]" }
+sub string($) { '"'.($_[0] =~ s/([\\"])/\\$1/gr).'"' }
+sub tuple { '('.join(', ', @_).')' }
+sub bool($) { $_[0] ? 'True' : 'False' }
+sub to_camel { (ucfirst $_[0]) =~ s/_([a-z])/'_'.uc $1/egr; }
+
+# Generate a variable definition: name, type, value
+sub def($$$) { sprintf "\n%s : %s\n%1\$s = %s\n", @_; }
+
+
+# Generate an Elm type definition corresponding to a TUWF::Validate schema
+sub def_type {
+ my($name, $obj) = @_;
+ 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} || 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 }
+ );
+ $data
+}
+
+
+# Generate HTML5 validation attribute lists corresponding to a TUWF::Validate schema
+# TODO: Deduplicate some regexes (weburl, email)
+# TODO: Throw these inside a struct for better namespacing?
+sub def_validation {
+ my($name, $obj) = @_;
+ $obj = $obj->{values} if $obj->{values};
+ my $data = '';
+
+ $data .= def_validation($name . to_camel($_), $obj->{keys}{$_}) for $obj->{keys} ? sort keys $obj->{keys}->%* : ();
+
+ my %v = $obj->html5_validation();
+ $data .= def $name, 'List (Html.Attribute msg)', '[ '.join(', ',
+ $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;
+}
+
+
+# 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(any => ' ', json_encode => 'JE.');
+}
+
+
+
+
+sub write_module {
+ my($module, $contents) = @_;
+ 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
+ import Json.Encode as JE
+ import Json.Decode as JD
+ $contents
+ EOF
+
+ # Don't write anything if the file hasn't changed.
+ my $oldcontents = do {
+ local $/=undef; my $F;
+ open($F, '<:utf8', $fn) ? <$F> : '';
+ };
+ return if $oldcontents eq $contents;
+
+ open my $F, '>:utf8', $fn or die "$fn: $!";
+ print $F $contents;
+}
+
+
+
+
+# Create an API endpoint that can be called from Elm.
+# Usage:
+#
+# 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
+# data as argument.
+#
+# It will also create an Elm module called `Gen.FormName` with the following definitions:
+#
+# -- Elm type corresponding to $OUT_SCHEMA
+# type alias Recv = { .. }
+# -- Elm type corresponding to $IN_SCHEMA
+# type alias Send = { .. }
+# -- HTML Validation attributes corresponding to fields in `Send`
+# valFieldName : List Html.Attribute
+#
+# -- 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, %extra) = @_;
+
+ 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 {
+ 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();
+ }
+
+ $sub->($data->data);
+ warn "Non-JSON response to a json_api request, is this intended?\n" if tuwf->resHeader('Content-Type') !~ /^application\/json/;
+ };
+
+ if(tuwf->{elmgen}) {
+ my $data = "import Gen.Api as GApi\n";
+ $data .= "import Lib.Api as Api\n";
+ $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";
+ $data .= "send v m = Api.post \"$name\" (encode v) m\n";
+ write_module $name, $data;
+ }
+}
+
+
+# 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 {
+
+ # Extract all { type => 'hash' } schemas and give them their own
+ # definition, so that it's easy to refer to those records in other places
+ # of the Elm code, similar to def_type().
+ my(@union, @decode);
+ my $data = '';
+ 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)),
+ @$schema == 0 ? "JD.succeed $name" :
+ @$schema == 1 ? "JD.map $name" : sprintf 'JD.map%d %s', scalar @$schema, $name;
+ my $tname = "Api$name";
+ for my $argn (0..$#$schema) {
+ my $arg = $schema->[$argn]->analyze();
+ my $jd = $arg->elm_decoder(json_decode => 'JD.', level => 3);
+ $dec .= " (JD.index $argn $jd)";
+ if($arg->{keys}) {
+ $data .= def_type $tname, $arg;
+ $def .= " $tname";
+ } elsif($arg->{values} && $arg->{values}{keys}) {
+ $data .= def_type $tname, $arg->{values};
+ $def .= " (List $tname)";
+ } else {
+ $def .= ' '.$arg->elm_type();
+ }
+ }
+ push @union, $def;
+ push @decode, $dec;
+ }
+ $data .= sprintf "\ntype Response\n = HTTPError Http.Error\n | %s\n", join "\n | ", @union;
+ $data .= sprintf "\ndecode : JD.Decoder Response\ndecode = JD.oneOf\n [ %s\n ]", join "\n , ", @decode;
+
+ write_module Api => $data;
+};
+
+
+sub write_types {
+ my $data = '';
+
+ $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 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 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}) {
+ write_api;
+ write_types;
+ write_extlinks;
+ open my $F, '>', config->{gen_path}.'/elm/Gen/.generated';
+ print $F scalar gmtime;
+}
+
+
+1;
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
new file mode 100644
index 00000000..13df2256
--- /dev/null
+++ b/lib/VNWeb/HTML.pm
@@ -0,0 +1,1035 @@
+package VNWeb::HTML;
+
+use v5.26;
+use warnings;
+use utf8;
+use Algorithm::Diff::XS 'sdiff', 'compact_diff';
+use JSON::XS;
+use TUWF ':html5_', 'uri_escape', 'html_escape', 'mkclass';
+use Exporter 'import';
+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', 'rdate', 'tattr';
+
+our @EXPORT = qw/
+ clearfloat_
+ platform_
+ debug_
+ join_
+ user_maybebanned_ user_ user_displayname
+ rdate_
+ vnlength_
+ spoil_
+ elm_ widget
+ framework_
+ revision_patrolled_ revision_
+ paginate_
+ sortable_
+ searchbox_
+ itemmsg_
+ editmsg_
+/;
+
+
+# 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;
+ # This provides a nice JSON browser in FF, not sure how other browsers render it.
+ my $data = uri_escape(JSON::XS->new->canonical->allow_nonref->encode($_[0]));
+ a_ style => 'margin: 0 5px', title => 'Debug', href => 'data:application/json,'.$data, ' ⚙ ';
+}
+
+
+# Similar to join($sep, map $f->(), @list), but works for HTML generation functions.
+# join_ ', ', sub { a_ href => '#', $_ }, @list;
+# join_ \&br_, \&txt_, @list;
+sub join_($&@) {
+ my($sep, $f, @list) = @_;
+ for my $i (0..$#list) {
+ ref $sep ? $sep->() : txt_ $sep if $i > 0;
+ local $_ = $list[$i];
+ $f->();
+ }
+}
+
+
+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_ {
+ my $obj = shift;
+ my $prefix = shift||'user_';
+ my $capital = shift;
+ my sub f($) { $obj->{"${prefix}$_[0]"} }
+
+ 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 => '/'.f('id'),
+ $softdel ? (class => 'grayedout') : (),
+ $fancy && $uniname ? (title => f('name'), $uniname) :
+ (!$fancy && $uniname ? (title => $uniname) : (), ($capital ? f 'name' : f 'name') // f 'id');
+ txt_ '⭐' if $fancy && f 'support_can' && f 'support_enabled';
+ user_maybebanned_ $obj, $prefix;
+}
+
+
+# Similar to user_(), but just returns a string. Mainly for use in titles.
+sub user_displayname {
+ my $obj = shift;
+ my $prefix = shift||'user_';
+ my sub f($) { $obj->{"${prefix}$_[0]"} }
+
+ 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') // f 'id'
+}
+
+# Display a release date.
+sub rdate_ {
+ 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;
+}
+
+
+# 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.
+# $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) = @_;
+ 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)
+}
+
+
+# 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;
+ };
+}
+
+
+sub _head_ {
+ my $o = shift;
+
+ my $fancy = !(auth->pref('nodistract_can') && auth->pref('nodistract_nofancy'));
+ 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 !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 => _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 => '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} ||= 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}->%*;
+ }
+}
+
+
+sub _menu_ {
+ my $o = shift;
+
+ div_ id => 'support', sub {
+ 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'));
+
+ article_ sub {
+ h2_ 'Menu';
+ div_ sub {
+ a_ href => '/', 'Home'; br_;
+ a_ href => '/v', 'Visual novels'; br_;
+ small_ '> '; a_ href => '/g', 'Tags'; br_;
+ a_ href => '/r', 'Releases'; 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_;
+ a_ href => '/d6', 'FAQ'; br_;
+ a_ href => '/v/rand','Random visual novel'; br_;
+ a_ href => '/d11', 'API'; lit_ ' - ';
+ a_ href => '/d14', 'Dumps'; lit_ ' - ';
+ a_ href => 'https://query.vndb.org/about', 'Query';
+ };
+ form_ action => '/v', method => 'get', sub {
+ fieldset_ sub {
+ input_ type => 'text', class => 'text', id => 'sq', name => 'sq', value => $o->{search}||'', placeholder => 'search';
+ input_ type => 'submit', class => 'hidden', value => 'Search';
+ }
+ }
+ };
+
+ article_ sub {
+ my $uid = '/'.auth->uid;
+ h2_ sub { user_ auth->user, 'user_', 1 };
+ div_ sub {
+ 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", $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(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_;
+ }
+ 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 {
+ input_ type => 'hidden', class => 'hidden', name => 'csrf', value => auth->csrftoken;
+ input_ type => 'submit', class => 'logout', value => 'Logout';
+ };
+ }
+ } if auth;
+
+ article_ sub {
+ h2_ 'User menu';
+ div_ sub {
+ my $ref = uri_escape(tuwf->reqGet('ref') || tuwf->reqPath().tuwf->reqQuery());
+ a_ href => "/u/login?ref=$ref", 'Login'; br_;
+ a_ href => '/u/register', 'Register'; br_;
+ }
+ } if !auth && !config->{read_only};
+
+ article_ sub {
+ h2_ 'Database Statistics';
+ div_ sub {
+ dl_ sub {
+ 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_;
+ }
+ };
+}
+
+
+sub _footer_ {
+ 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 => "/$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 => '/.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_r, @sql_i) = ();
+ for (tuwf->{_TUWF}{DB}{queries}->@*) {
+ my($sql, $params, $time) = @$_;
+ 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($o, $sel) = @{$opt}{qw/dbobj tab/};
+
+ my $id = $o ? $o->{id} : '';
+ my($t) = $o ? $id =~ /^(.)/ : '';
+
+ my sub t {
+ my($tabname, $url, $text) = @_;
+ li_ mkclass(tabselected => $tabname eq ($sel||'')), sub {
+ a_ href => $url, $text;
+ };
+ };
+
+ 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]/ && 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 $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';
+
+ 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());
+ t disc => "/t/$id", "discussions ($cnt)";
+ };
+
+ t hist => "/$id/hist", 'history' if $t =~ /[uvrpcsdgi]/;
+ _maintabs_subscribe_ $o, $id;
+ }
+ }
+}
+
+
+# Attempt to figure out the board id from a database entry
+sub _board_id {
+ 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';
+}
+
+
+# Returns 1 if the page contents should be hidden.
+sub _hidden_msg_ {
+ my $o = shift;
+
+ die "Can't use hiddenmsg on an object that is missing 'entry_hidden' or 'entry_locked'"
+ if !exists $o->{dbobj}{entry_hidden} || !exists $o->{dbobj}{entry_locked};
+
+ return 0 if !$o->{dbobj}{entry_hidden};
+
+ # 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 itemid =', \$o->{dbobj}{id},
+ 'ORDER BY id DESC LIMIT 1'
+ );
+ 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->{dbobj}), "discussion board";
+ txt_ ' if you believe that this entry should be restored.';
+ if($msg->{rev} > 1) {
+ br_;
+ br_;
+ lit_ bb_format $msg->{comments};
+ }
+ }
+ }
+ };
+ $o->{dbobj}{id} !~ /^[gi]/ && !auth->permDbmod # tags/traits are still visible, dbmods can still see all pages
+}
+
+
+# Options:
+# 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 }
+# 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 may not be called.
+# sub { content }
+sub framework_ {
+ my $cont = pop;
+ my %o = @_;
+ 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 {
+ 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;
+ footer_ sub { _footer_ \%o };
+ };
+
+ # '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($obj) = @_;
+ strong_ "Revision $obj->{chrev}";
+ debug_ $obj;
+ if(auth) {
+ lit_ ' (';
+ a_ href => "/$obj->{id}.$obj->{chrev}/edit", $obj->{chrev} == $obj->{maxrev} ? 'edit' : 'revert to';
+ if($obj->{rev_user_id}) {
+ lit_ ' / ';
+ 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_ ')';
+ }
+ br_;
+ lit_ 'By ';
+ user_ $obj, 'rev_user_';
+ lit_ ' on ';
+ txt_ fmtdate $obj->{rev_added}, 'full';
+}
+
+
+sub _revision_fmtval_ {
+ 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};
+ return txt_ ref $h eq 'HASH' ? $h->{txt} : $h || '[unknown]';
+ }
+ return txt_ $val ? 'True' : 'False' if $opt->{fmt} eq 'bool';
+ local $_ = $val;
+ $opt->{fmt}->($obj);
+}
+
+
+sub _revision_fmtcol_ {
+ my($opt, $i, $l, $obj) = @_;
+
+ my $ctx = 100; # Number of characters of context in textual diffs
+ 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];
+
+ if($diff) {
+ my $lastchunk = int (($#$diff-2)/2);
+ for my $n (0..$lastchunk) {
+ 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) {
+ 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;
+ # Longer context, abbreviate
+ } elsif($n == 0) {
+ sep_; br_; lit_ html_escape substr $a, -$ctx;
+ } elsif($n == $lastchunk) {
+ lit_ html_escape substr $a, 0, $ctx; br_; sep_;
+ } else {
+ lit_ html_escape substr $a, 0, $ctx;
+ br_; br_; sep_; br_; br_;
+ lit_ html_escape substr $a, -$ctx;
+ }
+ }
+
+ } elsif(@$l > 1 && $i == 2 && ($ch eq '+' || $ch eq 'c')) {
+ span_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val, $obj };
+ } elsif(@$l > 1 && $i == 1 && ($ch eq '-' || $ch eq 'c')) {
+ span_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val, $obj };
+ } elsif($ch eq 'u' || @$l == 1) {
+ _revision_fmtval_ $opt, $val, $obj;
+ }
+ }, @$l;
+ };
+}
+
+
+# Recursively stringify scalars. This is generally a no-op, except when
+# serializing the data structure to JSON this will cause all numbers to be
+# formatted as strings. Not very useful for data exchange, but this allows for
+# creating proper canonicalized JSON where equivalent data structures serialize
+# to the same string. (TODO: Might as well write a function that hashes
+# recursive data structures and use that for comparison - a little bit more
+# work but less magical)
+sub _stringify_scalars_rec {
+ defined($_[0]) && !ref $_[0] ? "$_[0]" :
+ ref $_[0] eq 'HASH' ? map _stringify_scalars_rec($_), values $_[0]->%* :
+ ref $_[0] eq 'ARRAY' ? map _stringify_scalars_rec($_), $_[0]->@* : undef;
+}
+
+sub _revision_diff_ {
+ 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;
+
+ # Now check if we should do a textual diff on the changed items.
+ for my $item (@$l) {
+ last if $opt{fmt};
+ next if $item->[0] ne 'c' || ref $item->[1] || ref $item->[2];
+ next if !defined $item->[1] || !defined $item->[2];
+ next if length $item->[1] < 10 || length $item->[2] < 10;
+
+ # Do a word-based diff if this is a large chunk of text, otherwise character-based.
+ my $split = length $item->[1] > 1024 ? qr/([ ,\n]+)/ : qr//;
+ $item->[1] = [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, $old;
+ _revision_fmtcol_ \%opt, 2, $l, $new;
+ }
+}
+
+
+sub _revision_cmp_ {
+ 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_ $old };
+ td_ sub { _revision_header_ $new };
+ };
+ tr_ sub {
+ td_ ' ';
+ td_ colspan => 2, sub {
+ strong_ "Edit summary for revision $new->{chrev}";
+ br_;
+ br_;
+ lit_ bb_format $new->{rev_comments}||'-';
+ };
+ };
+ };
+ _revision_diff_ $old, $new, @$_ for(
+ [ _entry_state => 'State', fmt => {0 => 'Normal', 1 => 'Locked', 2 => 'Awaiting approval', 3 => 'Deleted'} ],
+ @fields,
+ );
+ };
+}
+
+
+# Revision info box.
+#
+# 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
+# addition to those specified in @fields.
+#
+# \&enrich_for_diff is a subroutine that is given an earlier revision returned
+# by db_entry() and should enrich this object with information necessary for
+# diffing. $object is assumed to have already been enriched in this way (it is
+# assumed that a page will need to fetch and enrich such an $object for its own
+# display purposes anyway).
+#
+# @fields is a list of arrayrefs with the following form:
+#
+# [ field_name, display_name, %options ]
+#
+# Options:
+# fmt => 'bool'||\%HASH||sub {$_} - Formatting function for individual values.
+# 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($new, $enrich, @fields) = @_;
+
+ 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_'), ', u.perm_dbmod AS rev_dbmod
+ FROM changes c LEFT JOIN users u ON u.id = c.requester
+ WHERE c.id IN'),
+ $new, $old||();
+
+ 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', $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_ $new;
+ br_;
+ strong_ 'Edit summary';
+ br_; br_;
+ lit_ bb_format $new->{rev_comments}||'-';
+ } if !$old;
+
+ _revision_cmp_ $old, $new, @fields if $old;
+ };
+}
+
+
+# Creates next/previous buttons (tabs), if needed.
+# Arguments:
+# url generator (code reference that takes ('p', $pagenumber) as arguments with $_=$pagenumber and returns a url for that page).
+# current page number (1..n),
+# nextpage (0/1 or, if the full count is known: [$total, $perpage]),
+# alignment (t/b)
+# tableopts obj
+sub paginate_ {
+ my($url, $p, $np, $al, $tbl) = @_;
+ my($cnt, $pp) = ref($np) ? @$np : ($p+$np, 1);
+ 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,
+ class => $page == $p ? 'highlightselected' : undef,
+ rel => $label && $label =~ /next/ ? 'next' : $label && $label =~ /prev/ ? 'prev' : undef,
+ $label//$page;
+ }
+ }
+ my sub ell_ {
+ li_ mkclass(ellipsis => 1), '⋯';
+ }
+
+ 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 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, $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, $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 {
+ 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 => "$q";
+ input_ type => 'submit', class => 'submit', name => 'sb', value => 'Search!';
+ };
+}
+
+
+# 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($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 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;
+
+ 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
new file mode 100644
index 00000000..ddc744b2
--- /dev/null
+++ b/lib/VNWeb/Misc/BBCode.pm
@@ -0,0 +1,17 @@
+package VNWeb::Misc::BBCode;
+
+use VNWeb::Prelude;
+
+elm_api BBCode => undef, {
+ content => { default => '' }
+}, sub {
+ 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
new file mode 100644
index 00000000..9664363b
--- /dev/null
+++ b/lib/VNWeb/Misc/History.pm
@@ -0,0 +1,188 @@
+package VNWeb::Misc::History;
+
+use VNWeb::Prelude;
+
+
+# Also used by Misc::HomePage and Misc::Feeds
+sub fetch {
+ my($id, $filt, $opt) = @_;
+ my $num = $opt->{results}||50;
+
+ my $where = sql_and
+ !$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),
+
+ $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' : (),
+
+ # -2 = awaiting mod, -1 = deleted, 0 = all, 1 = approved
+ $filt->{h} ? sql
+ 'EXISTS(SELECT 1 FROM changes c_i
+ 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 and VNWeb::HTML.
+# %opt: nopage => 1/0, nouser => 1/0, results => $num
+sub tablebox_ {
+ my($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};
+ 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' unless $opt{nouser};
+ td_ class => 'tc4', sub { txt_ 'Page'; debug_ $lst; };
+ }};
+ tr_ sub {
+ my $i = $_;
+ 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 } unless $opt{nouser};
+ td_ class => 'tc4', sub {
+ a_ href => $revurl, tattr $i;
+ small_ sub { lit_ bb_format $i->{comments}, maxlength => 150, inline => 1 };
+ };
+ } for @$lst;
+ };
+ };
+ paginate_ \&url, $filt->{p}, $np, 'b' unless $opt{nopage};
+}
+
+
+sub filters_ {
+ my($type) = @_;
+
+ my @types = (
+ [ v => 'Visual novels' ],
+ [ g => 'Tags' ],
+ [ r => 'Releases' ],
+ [ p => 'Producers' ],
+ [ s => 'Staff' ],
+ [ c => 'Characters' ],
+ [ 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 => [ -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 },
+ }});
+ my $filt = tuwf->validate(get => $schema)->data;
+
+ $filt->{m} //= $type ? 0 : 1; # Exclude automated edits by default on the main 'recent changes' view.
+
+ # For compat with old URLs, 't=a' means "everything except characters". Let's also weed out duplicates
+ my %t = map +($_, 1), map $_ eq 'a' ? (qw|v r p s d|) : ($_), $filt->{t}->@*;
+ $filt->{t} = keys %t == @types ? [] : [ keys %t ];
+
+ # Not all filters apply everywhere
+ delete @{$filt}{qw/ t e h /} if $type && $type ne 'u';
+ delete $filt->{m} if $type eq 'u';
+ delete $filt->{r} if $type ne 'v';
+
+ my sub opt_ {
+ my($type, $key, $val, $label, $checked) = @_;
+ input_ type => $type, name => $key, id => "form_${key}{$val}", value => $val,
+ $checked // $filt->{$key} eq $val ? (checked => 'checked') : ();
+ label_ for => "form_${key}{$val}", $label;
+ };
+
+ form_ method => 'get', action => tuwf->reqPath(), sub {
+ 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_ sub {
+ p_ class => 'linkradio', sub {
+ opt_ radio => e => 0, 'All'; em_ ' | ';
+ opt_ radio => e => 1, 'Only changes to existing items'; em_ ' | ';
+ opt_ radio => e =>-1, 'Only newly created items';
+ } if exists $filt->{e};
+ p_ class => 'linkradio', sub {
+ opt_ radio => h => 0, 'All'; em_ ' | ';
+ 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;
+ opt_ checkbox => m => 1, 'Hide automated edits' if $type;
+ } if exists $filt->{m};
+ p_ class => 'linkradio', sub {
+ opt_ checkbox => r => 1, 'Include releases'
+ } if exists $filt->{r};
+ input_ type => 'submit', class => 'submit', value => 'Update';
+ debug_ $filt;
+ };
+ }};
+ };
+ $filt;
+}
+
+
+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 $id && !$obj->{id};
+ return tuwf->resNotFound if $id =~ /^u/ && $obj->{entry_hidden} && !auth->isMod;
+
+ my $title = $id ? "Edit history of $obj->{title}[1]" : 'Recent changes';
+ framework_ title => $title, dbobj => $obj, tab => 'hist',
+ sub {
+ my $filt;
+ article_ sub {
+ h1_ $title;
+ $filt = filters_($id =~ /^(.)/ ? $1 : '');
+ };
+ tablebox_ $id, $filt, nouser => scalar $id =~ /^u/;
+ };
+};
+
+1;
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
new file mode 100644
index 00000000..f422aa50
--- /dev/null
+++ b/lib/VNWeb/Prelude.pm
@@ -0,0 +1,95 @@
+# Importing this module is equivalent to:
+#
+# use v5.26;
+# use warnings;
+# use utf8;
+#
+# 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', 'strftime';
+#
+# use VNDB::BBCode;
+# use VNDB::Types;
+# use VNDB::Config;
+# 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 handy dbobj() function.
+#
+# WARNING: This should not be used from the above modules.
+package VNWeb::Prelude;
+
+use strict;
+use warnings;
+use feature ':5.26';
+use utf8;
+use VNWeb::Elm;
+use VNWeb::Auth;
+use VNWeb::DB;
+use TUWF;
+
+
+sub import {
+ my $c = caller;
+
+ strict->import;
+ warnings->import;
+ feature->import(':5.26');
+ utf8->import;
+
+ die $@ if !eval <<" EOM;";
+ package $c;
+
+ use TUWF ':html5_', 'mkclass', 'xml_string', 'xml_escape';
+ use Exporter 'import';
+ use Time::HiRes 'time';
+ use List::Util 'min', 'max', 'sum';
+ use POSIX 'ceil', 'floor', 'strftime';
+
+ use VNDB::BBCode;
+ use VNDB::Types;
+ use VNDB::Config;
+ 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.'::dbobj'} = \&dbobj;
+}
+
+
+# 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;
+ }
+
+ 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
new file mode 100644
index 00000000..4abe0b12
--- /dev/null
+++ b/lib/VNWeb/Releases/Elm.pm
@@ -0,0 +1,57 @@
+package VNWeb::Releases::Elm;
+
+use VNWeb::Prelude;
+use VNWeb::Releases::Lib;
+
+
+# Used by UList.Opt and CharEdit to fetch releases from a VN id.
+elm_api Release => undef, { vid => { vndbid => 'v' } }, sub {
+ my($data) = @_;
+ 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
new file mode 100644
index 00000000..42ef2a3d
--- /dev/null
+++ b/lib/VNWeb/Staff/Edit.pm
@@ -0,0 +1,109 @@
+package VNWeb::Staff::Edit;
+
+use VNWeb::Prelude;
+
+
+my $FORM = {
+ 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 => { sl => 1, maxlength => 200 },
+ latin => { sl => 1, maxlength => 200, default => undef },
+ inuse => { anybool => 1, _when => 'out' },
+ wantdel => { anybool => 1, _when => 'out' },
+ } },
+ description=> { default => '', maxlength => 5000 },
+ gender => { default => 'unknown', enum => [qw[unknown m f]] },
+ lang => { language => 1 },
+ l_site => { default => '', weburl => 1 },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+ validate_extlinks 's'
+};
+
+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{srev}/edit} => sub {
+ my $e = db_entry tuwf->captures('id', 'rev') or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit s => $e;
+
+ $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};
+
+ $e->{alias} = [ sort { ($a->{latin}//$a->{name}) cmp ($b->{latin}//$b->{name}) } $e->{alias}->@* ];
+
+ 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";
+ div_ widget(StaffEdit => $FORM_OUT, $e), '';
+ };
+};
+
+
+TUWF::get qr{/s/new}, sub {
+ return tuwf->resDenied if !can_edit s => undef;
+ framework_ title => 'Add staff member',
+ sub {
+ editmsg_ s => undef, 'Add staff member';
+ div_ widget(StaffEdit => $FORM_OUT, {
+ elm_empty($FORM_OUT)->%*,
+ alias => [ { aid => -1, name => '', latin => undef, inuse => 0, wantdel => 0 } ],
+ main => -1
+ }), '';
+ };
+};
+
+
+js_api StaffEdit => $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 s => $e;
+
+ if(!auth->permDbmod) {
+ $data->{hidden} = $e->{hidden}||0;
+ $data->{locked} = $e->{locked}||0;
+ }
+ $data->{l_wp} = $e->{l_wp}||'';
+ $data->{description} = bb_subst_links $data->{description};
+
+ # 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".($_->{latin}//'')}++, $data->{alias}->@*;
+ die "Latin = name" if grep $_->{latin} && $_->{name} eq $_->{latin}, $data->{alias}->@*;
+
+ # For positive alias IDs: Make sure they exist and are (or were) owned by this entry.
+ validate_dbid
+ 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->{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 +{ _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
new file mode 100644
index 00000000..0dc1a856
--- /dev/null
+++ b/lib/VNWeb/Staff/Page.pm
@@ -0,0 +1,214 @@
+package VNWeb::Staff::Page;
+
+use VNWeb::Prelude;
+use VNWeb::ULists::Lib;
+
+
+sub enrich_item {
+ my($s) = @_;
+
+ # 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 aid for more readable comparison at revisions.
+ $s->{alias} = [ sort { $a->{aid} <=> $b->{aid} } $s->{alias}->@* ];
+}
+
+
+sub _rev_ {
+ my($s) = @_;
+ my %aid;
+ revision_ $s, \&enrich_item,
+ [ alias => 'Names', fmt => sub {
+ my $num = ($aid{$_->{aid}} ||= keys %aid);
+ strong_ "$num: ";
+ txt_ $_->{name};
+ txt_ " ($_->{latin})" if $_->{latin};
+ small_ ' (primary)' if $_->{main};
+ } ],
+ [ gender => 'Gender', fmt => \%GENDER ],
+ [ lang => 'Language', fmt => \%LANGUAGE ],
+ [ description => 'Description' ],
+ revision_extlinks 's'
+}
+
+
+sub _infotable_ {
+ my($main, $s) = @_;
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ colspan => 2, sub {
+ 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}}{txt};
+ };
+
+ 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', $_->{latin} ? () : (colspan => 2), tlang($s->{lang}, $_->{name}), $_->{name};
+ td_ tlang($s->{lang}, $_->{latin}), $_->{latin} if $_->{latin};
+ } for @alias;
+ };
+ };
+ } if @alias;
+
+ tr_ sub {
+ td_ class => 'key', 'Links';
+ td_ sub {
+ join_ \&br_, sub { a_ href => $_->{url2}, $_->{label} }, $s->{extlinks}->@*;
+ };
+ } if $s->{extlinks}->@*;
+ };
+}
+
+
+sub _roles_ {
+ my($s) = @_;
+ my %alias = map +($_->{aid}, $_), $s->{alias}->@*;
+
+ 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', 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.sorttitle ASC, ve.lang NULLS FIRST, ve.name NULLS FIRST, vs.role ASC
+ ');
+ return if !@$roles;
+ enrich_ulists_widget $roles;
+
+ 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->{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', tattr $a;
+ td_ class => 'tc5', $v->{note};
+ } for @$roles;
+ };
+ };
+}
+
+
+sub _cast_ {
+ my($s) = @_;
+ my %alias = map +($_->{aid}, $_), $s->{alias}->@*;
+
+ 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', 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.sorttitle ASC
+ ')->@* ];
+ return if !@$cast;
+ enrich_ulists_widget $cast;
+
+ my $spoilers = viewget->{spoilers};
+ my $max_spoil = max(map $_->{spoil}, @$cast);
+
+ nav_ sub {
+ h1_ sprintf 'Voiced characters (%d)', scalar @$cast;
+ 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;
+ };
+ 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';
+ }};
+ 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->{id}", tattr $v;
+ };
+ td_ class => 'tc2', sub { rdate_ $v->{c_released} };
+ td_ class => 'tc3', sub {
+ a_ href => "/$v->{cid}", tattr $v->{c_title};
+ spoil_ $_->{spoil};
+ };
+ td_ class => 'tc4', tattr $a;
+ td_ class => 'tc5', $v->{note};
+ } for grep $_->{spoil} <= $spoilers, @$cast;
+ };
+ };
+}
+
+
+TUWF::get qr{/$RE{srev}} => sub {
+ my $s = db_entry tuwf->captures('id', 'rev');
+ return tuwf->resNotFound if !$s;
+
+ enrich_item $s;
+ enrich_extlinks s => 0, $s;
+ my($main) = grep $_->{aid} == $s->{main}, $s->{alias}->@*;
+
+ framework_ title => $main->{title}[1], index => !tuwf->capture('rev'), dbobj => $s, hiddenmsg => 1,
+ og => {
+ description => bb_format $s->{description}, text => 1
+ },
+ sub {
+ _rev_ $s if tuwf->capture('rev');
+ 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;
+ div_ class => 'description', sub { lit_ bb_format $s->{description} };
+ };
+
+ _roles_ $s;
+ _cast_ $s;
+ };
+};
+
+1;
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/TT/TagLinks.pm b/lib/VNWeb/TT/TagLinks.pm
new file mode 100644
index 00000000..7b178d58
--- /dev/null
+++ b/lib/VNWeb/TT/TagLinks.pm
@@ -0,0 +1,130 @@
+package VNWeb::TT::TagLinks;
+
+use VNWeb::Prelude;
+use VNWeb::TT::Lib;
+
+
+sub listing_ {
+ my($opt, $lst, $np, $url) = @_;
+
+ paginate_ $url, $opt->{p}, $np, 't';
+ 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; };
+ td_ class => 'tc2', 'User';
+ td_ class => 'tc3', 'Rating';
+ td_ class => 'tc4', sub { txt_ 'Tag'; sortable_ 'tag', $opt, $url };
+ td_ class => 'tc5', 'Spoiler';
+ 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 $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 => "/$i->{tag}", $i->{name};
+ };
+ td_ class => 'tc5', sub {
+ my $s = !defined $i->{spoiler} ? '' : fmtspoil $i->{spoiler};
+ 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 => "/$i->{vid}", tattr $i;
+ };
+ td_ class => 'tc8', sub { lit_ bb_format $i->{notes}, inline => 1 };
+ } for @$lst;
+ };
+ };
+ paginate_ $url, $opt->{p}, $np, 'b';
+}
+
+
+TUWF::get qr{/g/links}, sub {
+ my $opt = tuwf->validate(get =>
+ p => { page => 1 },
+ o => { onerror => 'd', enum => ['a', 'd'] },
+ s => { onerror => 'date', enum => [qw|date tag|] },
+ 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}) : (),
+ defined $opt->{t} ? sql('tv.tag =', \$opt->{t}) : ();
+
+ my $filt = defined $opt->{u} || defined $opt->{t} || defined $opt->{v};
+
+ 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, 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', 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}}
+ );
+ $np = [ $count, 50 ] if $count;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ framework_ title => 'Tag link browser', sub {
+ article_ sub {
+ h1_ 'Tag link browser';
+ if($filt) {
+ p_ 'Active filters:';
+ ul_ sub {
+ li_ sub {
+ txt_ '['; a_ href => url(u=>undef, p=>undef), 'remove'; txt_ '] ';
+ txt_ 'User: ';
+ user_ $u;
+ } if defined $opt->{u};
+ li_ sub {
+ txt_ '['; a_ href => url(t=>undef, p=>undef), 'remove'; txt_ '] ';
+ txt_ 'Tag:'; txt_ ' ';
+ 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_ ' ';
+ 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};
+ }
+ }
+ if($lst && @$lst) {
+ br_;
+ p_ 'Click the arrow before a user, tag or VN to add it as a filter.'
+ unless defined $opt->{u} && defined $opt->{t} && defined $opt->{v};
+ } else {
+ br_;
+ p_ 'No tag votes matching the requested filters.';
+ }
+ };
+
+ listing_ $opt, $lst, $np, \&url if $lst && @$lst;
+ };
+};
+
+1;
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/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
new file mode 100644
index 00000000..a4e42ad8
--- /dev/null
+++ b/lib/VNWeb/User/Edit.pm
@@ -0,0 +1,274 @@
+package VNWeb::User::Edit;
+
+use VNWeb::Prelude;
+use VNDB::Skins;
+use VNWeb::TitlePrefs '/./';
+use VNWeb::TimeZone;
+
+use Digest::SHA 'sha1';
+
+
+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_enabled => { anybool => 1 },
+ uniname => { default => '', sl => 1, length => [2,15] },
+ pubskin_enabled => { anybool => 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 },
+ } },
+
+ 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 {
+ my $uid = shift;
+ 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(
+ '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->{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};
+
+ $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} eq auth->uid ? 'My Account' : "Edit $u->{username}";
+ framework_ title => $title, dbobj => $u, tab => 'edit',
+ sub {
+ article_ sub {
+ h1_ $title;
+ };
+ div_ widget(UserEdit => $FORM_OUT, $u), '';
+ };
+};
+
+
+js_api UserEdit => $FORM_IN, sub {
+ my $data = shift;
+
+ 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;
+
+ $set{email_confirmed} = 1 if auth->permUsermod;
+
+ 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 +{ 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 = {ok=>1};
+
+ my $oldmail = _getmail $data->{id};
+ 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 {
+ my $token = auth->setmail_token($data->{email});
+ my $body = sprintf
+ "Hello %s,"
+ ."\n\n"
+ ."To confirm that you want to change the email address associated with your VNDB.org account from %s to %s, click the link below:"
+ ."\n\n"
+ ."%s"
+ ."\n\n"
+ ."vndb.org",
+ $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 $u->{username}",
+ );
+ $ret = {email=>1};
+ }
+ }
+
+ 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}->@*;
+
+ 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;
+};
+
+
+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 {
+ article_ sub {
+ h1_ $title;
+ div_ class => $success ? 'notice' : 'warning', sub {
+ p_ "Your e-mail address has been updated!" if $success;
+ p_ "Invalid or expired confirmation link." if !$success;
+ };
+ };
+ };
+};
+
+
+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
new file mode 100644
index 00000000..7fe5cb43
--- /dev/null
+++ b/lib/VNWeb/User/List.pm
@@ -0,0 +1,118 @@
+package VNWeb::User::List;
+
+use VNWeb::Prelude;
+
+
+sub listing_ {
+ my($opt, $list, $count) = @_;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ paginate_ \&url, $opt->{p}, [$count, 50], 't';
+ article_ class => 'browse userlist', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', sub { txt_ 'Username'; sortable_ 'username', $opt, \&url };
+ td_ class => 'tc2', sub { txt_ 'Registered'; sortable_ 'registered', $opt, \&url };
+ td_ class => 'tc3', sub { txt_ 'VNs'; sortable_ 'vns', $opt, \&url };
+ td_ class => 'tc4', sub { txt_ 'Votes'; sortable_ 'votes', $opt, \&url };
+ 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 = $_;
+ td_ class => 'tc1', sub { user_ $l };
+ td_ class => 'tc2', fmtdate $l->{registered};
+ td_ class => 'tc3', sub {
+ txt_ '0' 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 => "/$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 => "/$l->{user_id}/ulist?wishlist=1", $l->{c_wish} if $l->{c_wish};
+ };
+ td_ class => 'tc6', sub {
+ txt_ '-' 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;
+ };
+ };
+ paginate_ \&url, $opt->{p}, [$count, 50], 'b';
+}
+
+
+TUWF::get qr{/u/(?<char>[0a-z]|all)}, sub {
+ my $char = tuwf->capture('char');
+
+ my $opt = tuwf->validate(get =>
+ p => { upage => 1 },
+ 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 = (
+ 'username IS NOT NULL',
+ auth->permUsermod ? () : 'email_confirmed',
+ $char eq 'all' ? () : sql('match_firstchar(username, ', \$char, ')'),
+ $opt->{q} ? sql_or(
+ 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, c_imgvotes
+ FROM users u
+ WHERE', sql_and(@where),
+ 'ORDER BY', {
+ username => 'lower(username)',
+ registered => 'id',
+ vns => 'c_vns',
+ votes => 'c_votes',
+ wish => 'c_wish',
+ changes => 'c_changes',
+ tags => 'c_tags',
+ images => 'c_imgvotes',
+ }->{$opt->{s}}, $opt->{o} eq 'd' ? 'DESC' : 'ASC'
+ );
+ 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 {
+ article_ sub {
+ h1_ 'Browse users';
+ form_ action => '/u/all', method => 'get', sub {
+ 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;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/User/Login.pm b/lib/VNWeb/User/Login.pm
new file mode 100644
index 00000000..b4ac76da
--- /dev/null
+++ b/lib/VNWeb/User/Login.pm
@@ -0,0 +1,87 @@
+package VNWeb::User::Login;
+
+use VNWeb::Prelude;
+
+
+TUWF::get '/u/login' => sub {
+ return tuwf->resRedirect('/', 'temp') if auth || config->{read_only};
+
+ my $ref = tuwf->reqGet('ref');
+ $ref = '/' if !$ref || $ref !~ /^\//;
+
+ framework_ title => 'Login', sub {
+ div_ widget(UserLogin => {ref => $ref}), '';
+ };
+};
+
+
+js_api UserLogin => {
+ username => {},
+ password => { password => 1 }
+}, sub {
+ my $data = shift;
+
+ my $ip = norm_ip tuwf->reqIP;
+ my $tm = tuwf->dbVali(
+ 'SELECT', sql_totime('greatest(timeout, now())'), 'FROM login_throttle WHERE ip =', \$ip
+ ) || time;
+ 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};
+ 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, 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);
+ +{ _err => $ismail ? $mailmsg : 'Incorrect password.' }
+};
+
+
+js_api UserChangePass => {
+ uid => { vndbid => 'u' },
+ oldpass => { password => 1 },
+ newpass => { password => 1 },
+}, sub {
+ my $data = shift;
+ 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 ne tuwf->capture('id') || (tuwf->reqPost('csrf')||'') ne auth->csrftoken;
+ auth->logout;
+ tuwf->resRedirect('/', 'post');
+};
+
+1;
diff --git a/lib/VNWeb/User/Notifications.pm b/lib/VNWeb/User/Notifications.pm
new file mode 100644
index 00000000..513cec23
--- /dev/null
+++ b/lib/VNWeb/User/Notifications.pm
@@ -0,0 +1,243 @@
+package VNWeb::User::Notifications;
+
+use VNWeb::Prelude;
+
+my %ntypes = (
+ 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 => "/$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', $u->{notify_dbedit} ? (checked => 'checked') : ();
+ txt_ ' Notify me about edits of database entries I contributed to.';
+ };
+ br_;
+ label_ sub {
+ 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_;
+ input_ type => 'submit', class => 'submit', value => 'Save';
+ }
+ };
+}
+
+
+sub listing_ {
+ my($id, $opt, $count, $list) = @_;
+
+ my sub url { "/$id/notifies?r=$opt->{r}&p=$_" }
+
+ my sub tbl_ {
+ thead_ sub { tr_ sub {
+ td_ '';
+ td_ 'Type';
+ td_ 'Age';
+ td_ 'ID';
+ td_ 'Action';
+ }};
+ tfoot_ sub { tr_ sub {
+ td_ colspan => 5, sub {
+ input_ type => 'checkbox', class => 'checkall', name => 'notifysel', value => 0;
+ txt_ ' ';
+ input_ type => 'submit', class => 'submit', name => 'markread', value => 'mark selected read';
+ input_ type => 'submit', class => 'submit', name => 'remove', value => 'remove selected';
+ small_ ' (Read notifications are automatically removed after one month)';
+ }
+ }};
+ tr_ $_->{read} ? () : (class => 'unread'), sub {
+ my $l = $_;
+ my $lid = $l->{iid}.($l->{num}?'.'.$l->{num}:'');
+ td_ class => 'tc1', sub { input_ type => 'checkbox', name => 'notifysel', value => $l->{id}; };
+ 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 => "/$lid", $lid };
+ td_ class => 'tc5', sub {
+ a_ href => "/$lid", sub {
+ txt_ $l->{iid} =~ /^w/ ? ($l->{num} ? 'Comment on ' : 'Review of ') :
+ $l->{iid} =~ /^t/ ? ($l->{num} == 1 ? 'New thread ' : 'Reply to ') : 'Edit of ';
+ span_ tattr $l;
+ txt_ ' by ';
+ span_ user_displayname $l;
+ };
+ };
+ } for @$list;
+ }
+
+ 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';
+ article_ class => 'browse notifies', sub {
+ table_ class => 'stripe', \&tbl_;
+ };
+ paginate_ \&url, $opt->{p}, [$count, 25], 'b';
+ } if $count;
+}
+
+
+# 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 ne auth->uid;
+
+ my $opt = tuwf->validate(get =>
+ p => { page => 1 },
+ r => { anybool => 1 },
+ )->data;
+
+ my $where = sql_and(
+ sql('n.uid =', \$id),
+ $opt->{r} ? () : 'n.read IS NULL'
+ );
+ 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::text[] AS ntype, n.iid, n.num, t.title, ', sql_user(), '
+ , ', sql_totime('n.date'), ' as date
+ , ', sql_totime('n.read'), ' as read
+ 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', js => 1,
+ sub {
+ article_ sub {
+ h1_ 'My notifications';
+ p_ class => 'browseopts', sub {
+ a_ !$opt->{r} ? (class => 'optselected') : (), href => '?r=0', 'Unread notifications';
+ a_ $opt->{r} ? (class => 'optselected') : (), href => '?r=1', 'All notifications';
+ };
+ p_ 'No notifications!' if !$count;
+ };
+ listing_ $id, $opt, $count, $list;
+ article_ sub { settings_ $id };
+ };
+};
+
+
+TUWF::post qr{/$RE{uid}/notify_options}, sub {
+ my $id = tuwf->capture('id');
+ 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});
+
+ 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 ne auth->uid;
+
+ my $frm = tuwf->validate(post =>
+ url => { regex => qr{^/$id/notifies} },
+ notifysel => { default => [], type => 'array', scalar => 1, values => { id => 1 } },
+ markread => { anybool => 1 },
+ remove => { anybool => 1 },
+ )->data;
+
+ if($frm->{notifysel}->@*) {
+ my $where = sql 'uid =', \$id, ' AND id IN', $frm->{notifysel};
+ tuwf->dbExeci('DELETE FROM notifications WHERE', $where) if $frm->{remove};
+ tuwf->dbExeci('UPDATE notifications SET read = NOW() WHERE', $where) if $frm->{markread};
+ }
+ tuwf->resRedirect($frm->{url}, 'post');
+};
+
+
+# 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 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
new file mode 100644
index 00000000..db4f7a36
--- /dev/null
+++ b/lib/VNWeb/User/Page.pm
@@ -0,0 +1,235 @@
+package VNWeb::User::Page;
+
+use VNWeb::Prelude;
+use VNWeb::Misc::History;
+
+
+sub _info_table_ {
+ my($u, $own) = @_;
+
+ my sub sup {
+ strong_ ' ⭐supporter⭐' if $u->{user_support_can} && $u->{user_support_enabled};
+ }
+
+ tr_ sub {
+ td_ class => 'key', 'Display name';
+ td_ sub {
+ txt_ $u->{user_uniname};
+ sup;
+ };
+ } 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_ $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 {
+ td_ 'Registered';
+ td_ fmtdate $u->{registered};
+ };
+ tr_ sub {
+ td_ 'Edits';
+ td_ !$u->{c_changes} ? '-' : sub {
+ a_ href => "/$u->{id}/hist", $u->{c_changes}
+ };
+ };
+ tr_ sub {
+ my $num = sum map $_->{votes}, $u->{votes}->@*;
+ my $sum = sum map $_->{total}, $u->{votes}->@*;
+ td_ 'Votes';
+ td_ !$num ? '-' : sub {
+ txt_ sprintf '%d vote%s, %.2f average. ', $num, $num == 1 ? '' : 's', $sum/$num/10;
+ 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(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 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';
+ td_ !$vns && !$rel ? '-' : sub {
+ txt_ sprintf '%d release%s of %d visual novel%s. ',
+ $rel, $rel == 1 ? '' : 's',
+ $vns, $vns == 1 ? '' : 's';
+ 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 {
+ my $stats = tuwf->dbRowi('SELECT COUNT(DISTINCT tag) AS tags, COUNT(DISTINCT vid) AS vns FROM tags_vn WHERE uid =', \$u->{id});
+ td_ 'Tags';
+ td_ !$u->{c_tags} ? '-' : !$stats->{tags} ? '-' : sub {
+ txt_ sprintf '%d vote%s on %d distinct tag%s and %d visual novel%s. ',
+ $u->{c_tags}, $u->{c_tags} == 1 ? '' : 's',
+ $stats->{tags}, $stats->{tags} == 1 ? '' : 's',
+ $stats->{vns}, $stats->{vns} == 1 ? '' : 's';
+ a_ href => "/g/links?u=$u->{id}", 'Browse tags »';
+ };
+ };
+ tr_ sub {
+ 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->{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;
+}
+
+
+sub _votestats_ {
+ my($u, $own) = @_;
+
+ my $sum = sum map $_->{total}, $u->{votes}->@*;
+ my $max = max map $_->{votes}, $u->{votes}->@*;
+ my $num = sum map $_->{votes}, $u->{votes}->@*;
+
+ table_ class => 'votegraph', sub {
+ thead_ sub { tr_ sub { td_ colspan => 2, 'Vote stats' } };
+ tfoot_ sub { tr_ sub { td_ colspan => 2, sprintf '%d vote%s total, average %.2f', $num, $num == 1 ? '' : 's', $sum/$num/10 } };
+ tr_ sub {
+ my $num = $_;
+ my $votes = [grep $num == $_->{idx}, $u->{votes}->@*]->[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);
+ };
+
+ my $recent = tuwf->dbAlli('
+ SELECT v.id, v.title, uv.vote,', sql_totime('uv.vote_date'), 'AS date
+ FROM ulist_vns uv
+ 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';
+ span_ sub { txt_ '('; a_ href => "/$u->{id}/ulist?votes=1", 'show all'; txt_ ')' };
+ } } };
+ tr_ sub {
+ my $v = $_;
+ td_ sub { a_ href => "/$v->{id}", tattr $v; };
+ td_ fmtvote $v->{vote};
+ td_ fmtdate $v->{date};
+ } for @$recent;
+ };
+
+ clearfloat_;
+}
+
+
+TUWF::get qr{/$RE{uid}}, sub {
+ my $u = tuwf->dbRowi(q{
+ 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} || (!$u->{user_name} && !auth->isMod);
+
+ 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 NOT uv.c_private', '
+ GROUP BY (uv.vote::numeric/10)::int
+ ');
+
+ my $title = user_displayname($u)."'s profile";
+ framework_ title => $title, dbobj => $u, sub {
+ article_ class => 'userpage', sub {
+ itemmsg_ $u;
+ h1_ $title;
+ table_ class => 'stripe', sub { _info_table_ $u, $own };
+ };
+
+ article_ sub {
+ h1_ 'Vote statistics';
+ div_ class => 'votestats', sub { _votestats_ $u, $own };
+ } if grep $_->{votes} > 0, $u->{votes}->@*;
+
+ if($u->{c_changes}) {
+ 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;
+ }
+ };
+};
+
+1;
diff --git a/lib/VNWeb/User/PassReset.pm b/lib/VNWeb/User/PassReset.pm
new file mode 100644
index 00000000..45109f80
--- /dev/null
+++ b/lib/VNWeb/User/PassReset.pm
@@ -0,0 +1,58 @@
+package VNWeb::User::PassReset;
+
+use VNWeb::Prelude;
+
+TUWF::get '/u/newpass' => sub {
+ return tuwf->resRedirect('/', 'temp') if auth || config->{read_only};
+ framework_ title => 'Password reset', sub {
+ div_ widget(UserPassReset => {}), '';
+ };
+};
+
+
+js_api UserPassReset => {
+ email => { email => 1 },
+}, sub {
+ my $data = shift;
+
+ # 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 $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"
+ ."\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 => $mail // $data->{email},
+ From => 'VNDB <noreply@vndb.org>',
+ Subject => "Password reset for $name",
+ );
+ +{}
+};
+
+1;
diff --git a/lib/VNWeb/User/PassSet.pm b/lib/VNWeb/User/PassSet.pm
new file mode 100644
index 00000000..13d6ba2f
--- /dev/null
+++ b/lib/VNWeb/User/PassSet.pm
@@ -0,0 +1,36 @@
+package VNWeb::User::PassSet;
+
+use VNWeb::Prelude;
+
+TUWF::get qr{/$RE{uid}/setpass/(?<token>[a-f0-9]{40})}, sub {
+ return tuwf->resRedirect('/', 'temp') if auth || config->{read_only};
+
+ my $id = tuwf->capture('id');
+ my $token = tuwf->capture('token');
+ my $name = tuwf->dbVali('SELECT username FROM users WHERE id =', \$id);
+
+ return tuwf->resNotFound if !$name || !auth->isvalidtoken($id, $token);
+
+ framework_ title => 'Set password', sub {
+ div_ widget(UserPassSet => { uid => $id, token => $token }), '';
+ };
+};
+
+
+js_api UserPassSet => {
+ uid => { vndbid => 'u' },
+ token => { regex => qr/^[a-f0-9]{40}$/ },
+ password => { password => 1 },
+}, sub {
+ my($data) = @_;
+
+ 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});
+ 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
new file mode 100644
index 00000000..85de3599
--- /dev/null
+++ b/lib/VNWeb/User/Register.pm
@@ -0,0 +1,88 @@
+package VNWeb::User::Register;
+
+use VNWeb::Prelude;
+
+
+TUWF::get '/u/register', sub {
+ return tuwf->resRedirect('/', 'temp') if auth;
+ framework_ title => 'Register', sub {
+ 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'), '';
+ }
+ };
+};
+
+
+js_api UserRegister => {
+ username => { username => 1 },
+ email => { email => 1 },
+}, sub {
+ my $data = shift;
+ return 'Registration disabled.' if global_settings->{lockdown_registration};
+
+ 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 '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(undef, undef, $token) = auth->resetpass($data->{email});
+
+ my $body = sprintf
+ "Hello %s,"
+ ."\n\n"
+ ."Someone has registered an account on VNDB.org with your email address. To confirm your registration, follow the link below."
+ ."\n\n"
+ ."%s"
+ ."\n\n"
+ ."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()."/$id/setpass/$token";
+
+ tuwf->mail($body,
+ To => $data->{email},
+ From => 'VNDB <noreply@vndb.org>',
+ Subject => "Confirm registration for $data->{username}",
+ );
+ +{ 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
new file mode 100644
index 00000000..08813671
--- /dev/null
+++ b/lib/VNWeb/VN/Votes.pm
@@ -0,0 +1,67 @@
+package VNWeb::VN::Votes;
+
+use VNWeb::Prelude;
+
+
+sub listing_ {
+ my($opt, $count, $lst) = @_;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+ paginate_ \&url, $opt->{p}, [ $count, 50 ], 't';
+ 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 };
+ td_ class => 'tc2', sub { txt_ 'Vote'; sortable_ 'vote', $opt, \&url; };
+ td_ class => 'tc3', sub { txt_ 'User'; sortable_ 'title', $opt, \&url; };
+ } };
+ tr_ sub {
+ td_ class => 'tc1', fmtdate $_->{date};
+ td_ class => 'tc2', fmtvote $_->{vote};
+ td_ class => 'tc3', sub {
+ small_ 'hidden' if $_->{c_private};
+ user_ $_ if !$_->{c_private};
+ };
+ } for @$lst;
+ };
+ };
+ paginate_ \&url, $opt->{p}, [ $count, 50 ], 'b';
+}
+
+
+TUWF::get qr{/$RE{vid}/votes}, sub {
+ my $v = dbobj tuwf->capture('id');
+ return tuwf->resNotFound if !$v->{id} || $v->{entry_hidden};
+
+ my $opt = tuwf->validate(get =>
+ p => { page => 1 },
+ o => { onerror => 'd', enum => ['a','d'] },
+ s => { onerror => 'date', enum => ['date', 'title', 'vote' ] }
+ )->data;
+
+ my $fromwhere = sql
+ '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)';
+
+ my $count = tuwf->dbVali('SELECT COUNT(*)', $fromwhere);
+
+ my $lst = tuwf->dbPagei({results => 50, page => $opt->{p}},
+ '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}[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;
+ };
+};
+
+
+1;
diff --git a/lib/VNWeb/Validation.pm b/lib/VNWeb/Validation.pm
new file mode 100644
index 00000000..87c5e171
--- /dev/null
+++ b/lib/VNWeb/Validation.pm
@@ -0,0 +1,460 @@
+package VNWeb::Validation;
+
+use v5.26;
+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/
+ %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<<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 => { 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];
+ +{ type => 'array', sort => sub {
+ for(@keys) {
+ my $c = defined($_[0]{$_}) cmp defined($_[1]{$_}) || (defined($_[0]{$_}) && $_[0]{$_} cmp $_[1]{$_});
+ return $c if $c;
+ }
+ 0
+ } }
+ },
+ # Sorted and unique array-of-hashes (default order is sort_keys on the sorted keys...)
+ aoh => sub { +{ type => 'array', unique => 1, sort_keys => [sort keys %{$_[0]}], values => { type => 'hash', keys => $_[0] } } },
+ # 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;
+
+ 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');
+}
+
+
+# Recursively remove keys from hashes that have a '_when' key that doesn't
+# match $when. This is a quick and dirty way to create multiple validation
+# schemas from a single schema. For example:
+#
+# {
+# title => { _when => 'input' },
+# name => { },
+# }
+#
+# If $when is 'input', then this function returns:
+# { title => {}, name => {} }
+# Otherwise, it returns:
+# { name => {} }
+sub _stripwhen {
+ my($when, $o) = @_;
+ return $o if ref $o ne 'HASH';
+ +{ map $_ eq '_when' || (ref $o->{$_} eq 'HASH' && defined $o->{$_}{_when} && $o->{$_}{_when} !~ $when) ? () : ($_, _stripwhen($when, $o->{$_})), keys %$o }
+}
+
+
+# Short-hand to compile a validation schema for a form. Usage:
+#
+# form_compile $when, {
+# title => { _when => 'input' },
+# name => { },
+# ..
+# };
+sub form_compile {
+ tuwf->compile({ type => 'hash', keys => _stripwhen @_ });
+}
+
+
+sub _eq_deep {
+ my($a, $b) = @_;
+ return 0 if ref $a ne ref $b;
+ return 0 if defined $a != defined $b;
+ return 1 if !defined $a;
+ return 1 if !ref $a && $a eq $b;
+ return 1 if ref $a eq 'ARRAY' && (@$a == @$b && !grep !_eq_deep($a->[$_], $b->[$_]), 0..$#$a);
+ return 1 if ref $a eq 'HASH' && _eq_deep([sort keys %$a], [sort keys %$b]) && !grep !_eq_deep($a->{$_}, $b->{$_}), keys %$a;
+ 0
+}
+
+
+# Usage: form_changed $schema, $a, $b
+# Returns 1 if there is a difference between the data ($a) and the form input
+# ($b), using the normalization defined in $schema. The $schema must validate.
+sub form_changed {
+ my($schema, $a, $b) = @_;
+ 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);
+}
+
+
+# Validate identifiers against an SQL query. The query must end with a 'id IN'
+# clause, where the @ids array is appended. The query must return exactly 1
+# column, the id of each entry. This function throws an error if an id is
+# missing from the query. For example, to test for non-hidden VNs:
+#
+# validate_dbid 'SELECT id FROM vn WHERE NOT hidden AND id IN', 2,3,5,7,...;
+#
+# If any of those ids is hidden or not in the database, an error is thrown.
+sub validate_dbid {
+ my($sql, @ids) = @_;
+ return if !@ids;
+ $sql = ref $sql eq 'CODE' ? do { local $_ = \@ids; sql $sql->(\@ids) } : sql $sql, \@ids;
+ my %dbids = map +((values %$_)[0],1), @{ tuwf->dbAlli($sql) };
+ my @missing = grep !$dbids{$_}, @ids;
+ croak "Invalid database IDs: ".join(',', @missing) if @missing;
+}
+
+
+# Returns whether the current user can edit the given database entry.
+#
+# Supported types:
+#
+# u:
+# Requires 'id' field, can only test for editing.
+#
+# t:
+# If no 'id' field, checks if the user can create a new thread
+# (permission to post in specific boards is not handled here).
+# If no 'num' field, checks if the user can reply to the existing thread.
+# Requires the 'locked' field.
+# Assumes the user is permitted to see the thread in the first place, i.e. neither hidden nor private.
+# 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.
+#
+sub can_edit {
+ my($type, $entry) = @_;
+
+ 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 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);
+ } elsif(!$entry->{num}) {
+ die "Can't do authorization test when 'locked' field isn't present" if !exists $entry->{locked};
+ return !$entry->{locked};
+ } 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};
+ # 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 && !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/sql/perms.sql b/sql/perms.sql
new file mode 100644
index 00000000..a67442be
--- /dev/null
+++ b/sql/perms.sql
@@ -0,0 +1,223 @@
+-- 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, 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;
+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, 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_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;
+GRANT SELECT, INSERT ON releases_platforms_hist TO vndb_site;
+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, 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, 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 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, 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 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, 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 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;
+GRANT SELECT, INSERT ON vn_screenshots_hist TO vndb_site;
+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;
+
+
+
+
+-- vndb_multi
+-- (Assuming all modules are loaded)
+
+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, SELECT ON ALL SEQUENCES IN SCHEMA public 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, 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_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 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 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;
+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, 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_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_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, 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/sql/superuser_init.sql b/sql/superuser_init.sql
new file mode 100644
index 00000000..c756584d
--- /dev/null
+++ b/sql/superuser_init.sql
@@ -0,0 +1,17 @@
+-- This script should be run before all other scripts and as a PostgreSQL
+-- superuser. It will create the VNDB database and required users.
+-- All other SQL scripts should be run by the 'vndb' user.
+
+-- In order to "activate" a user, i.e. to allow login, you need to manually run
+-- the following for each user you want to activate:
+-- ALTER ROLE rolename LOGIN PASSWORD 'password';
+
+CREATE ROLE vndb;
+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/sql/tableattrs.sql b/sql/tableattrs.sql
new file mode 100644
index 00000000..a707bf50
--- /dev/null
+++ b/sql/tableattrs.sql
@@ -0,0 +1,203 @@
+-- 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);
+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);
+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 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);
+ALTER TABLE producers_relations ADD CONSTRAINT producers_relations_pid_fkey FOREIGN KEY (pid) REFERENCES producers (id);
+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_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);
+ALTER TABLE releases_platforms_hist ADD CONSTRAINT releases_platforms_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE releases_producers ADD CONSTRAINT releases_producers_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
+ALTER TABLE releases_producers ADD CONSTRAINT releases_producers_pid_fkey FOREIGN KEY (pid) REFERENCES producers (id);
+ALTER TABLE releases_producers_hist ADD CONSTRAINT releases_producers_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE releases_producers_hist ADD CONSTRAINT releases_producers_hist_pid_fkey FOREIGN KEY (pid) REFERENCES producers (id);
+ALTER TABLE releases_vn ADD CONSTRAINT releases_vn_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
+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_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_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 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_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) 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) 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 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;
+ALTER TABLE vn_anime_hist ADD CONSTRAINT vn_anime_hist_aid_fkey FOREIGN KEY (aid) REFERENCES anime (id);
+ALTER TABLE vn_relations ADD CONSTRAINT vn_relations_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
+ALTER TABLE vn_relations ADD CONSTRAINT vn_relations_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+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 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 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_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_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/sql/triggers.sql b/sql/triggers.sql
new file mode 100644
index 00000000..dc03feb5
--- /dev/null
+++ b/sql/triggers.sql
@@ -0,0 +1,333 @@
+-- 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
+ IF TG_TABLE_NAME = 'changes' THEN
+ IF TG_OP = 'INSERT' THEN
+ UPDATE users SET c_changes = c_changes + 1 WHERE id = NEW.requester;
+ ELSE
+ UPDATE users SET c_changes = c_changes - 1 WHERE id = OLD.requester;
+ END IF;
+ ELSIF TG_TABLE_NAME = 'tags_vn' THEN
+ IF TG_OP = 'INSERT' THEN
+ UPDATE users SET c_tags = c_tags + 1 WHERE id = NEW.uid;
+ 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_imgvotes_update AFTER INSERT OR DELETE ON image_votes FOR EACH ROW EXECUTE PROCEDURE update_users_cache();
+
+
+
+
+-- the stats_cache table
+
+CREATE OR REPLACE FUNCTION update_stats_cache() RETURNS TRIGGER AS $$
+BEGIN
+ IF TG_OP = 'INSERT' THEN
+ UPDATE stats_cache SET count = count+1 WHERE section = TG_TABLE_NAME;
+
+ ELSIF TG_OP = 'UPDATE' THEN
+ IF OLD.hidden AND NOT NEW.hidden THEN
+ UPDATE stats_cache SET count = count+1 WHERE section = TG_TABLE_NAME;
+ ELSIF NEW.hidden AND NOT OLD.hidden THEN
+ UPDATE stats_cache SET count = count-1 WHERE section = TG_TABLE_NAME;
+ END IF;
+ END IF;
+ RETURN NULL;
+END;
+$$ LANGUAGE 'plpgsql';
+
+CREATE TRIGGER stats_cache_new AFTER INSERT ON vn FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
+CREATE TRIGGER stats_cache_edit AFTER UPDATE ON vn FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
+CREATE TRIGGER stats_cache_new AFTER INSERT ON producers FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
+CREATE TRIGGER stats_cache_edit AFTER UPDATE ON producers FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
+CREATE TRIGGER stats_cache_new AFTER INSERT ON releases FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
+CREATE TRIGGER stats_cache_edit AFTER UPDATE ON releases FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
+CREATE TRIGGER stats_cache_new AFTER INSERT ON chars FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
+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.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();
+
+
+
+
+-- insert rows into anime for new vn_anime.aid items
+
+CREATE OR REPLACE FUNCTION vn_anime_aid() RETURNS trigger AS $$
+BEGIN
+ INSERT INTO anime (id) VALUES (NEW.aid) ON CONFLICT (id) DO NOTHING;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER vn_anime_aid_new BEFORE INSERT ON vn_anime FOR EACH ROW EXECUTE PROCEDURE vn_anime_aid();
+CREATE TRIGGER vn_anime_aid_edit BEFORE UPDATE ON vn_anime FOR EACH ROW WHEN (OLD.aid IS DISTINCT FROM NEW.aid) EXECUTE PROCEDURE vn_anime_aid();
+
+
+
+
+-- Send a notify whenever anime info should be fetched
+
+CREATE OR REPLACE FUNCTION anime_fetch_notify() RETURNS trigger AS $$
+ BEGIN NOTIFY anime; RETURN NULL; END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER anime_fetch_notify AFTER INSERT OR UPDATE ON anime FOR EACH ROW WHEN (NEW.lastfetch IS NULL) EXECUTE PROCEDURE anime_fetch_notify();
+
+
+
+
+-- insert rows into wikidata for new l_wikidata items
+
+CREATE OR REPLACE FUNCTION wikidata_insert() RETURNS trigger AS $$
+BEGIN
+ INSERT INTO wikidata (id) VALUES (NEW.l_wikidata) ON CONFLICT (id) DO NOTHING;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER producers_wikidata_new BEFORE INSERT ON producers FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER producers_wikidata_edit BEFORE UPDATE ON producers FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER producers_hist_wikidata_new BEFORE INSERT ON producers_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER producers_hist_wikidata_edit BEFORE UPDATE ON producers_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER staff_wikidata_new BEFORE INSERT ON staff FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER staff_wikidata_edit BEFORE UPDATE ON staff FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER staff_hist_wikidata_new BEFORE INSERT ON staff_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER staff_hist_wikidata_edit BEFORE UPDATE ON staff_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER vn_wikidata_new BEFORE INSERT ON vn FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER vn_wikidata_edit BEFORE UPDATE ON vn FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER vn_hist_wikidata_new BEFORE INSERT ON vn_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER vn_hist_wikidata_edit BEFORE UPDATE ON vn_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
+
+
+
+
+-- For each row in rlists, there should be at least one corresponding row in
+-- ulist_vns for each VN linked to that release.
+-- 1. When a row is deleted from ulist_vns, also remove all rows from rlists
+-- with that VN linked.
+-- 2. When a row is inserted to rlists and there is not yet a corresponding row
+-- in ulist_vns, add a row to ulist_vns for each vn linked to the release.
+-- 3. When a release is edited to add another VN, add those VNs to ulist_vns
+-- for everyone who has the release in rlists.
+-- This is done in edit_committed().
+-- #. When a release is edited to remove a VN, that VN kinda should also be
+-- removed from ulist_vns, but only if that ulist_vns entry was
+-- automatically added as part of the rlists entry and the user has not
+-- changed anything in the ulist_vns row. This isn't currently done.
+CREATE OR REPLACE FUNCTION update_vnlist_rlist() RETURNS trigger AS $$
+BEGIN
+ -- 1.
+ IF TG_TABLE_NAME = 'ulist_vns' THEN
+ DELETE FROM rlists WHERE uid = OLD.uid AND rid IN(SELECT id FROM releases_vn WHERE vid = OLD.vid);
+ -- 2.
+ ELSE
+ INSERT INTO ulist_vns (uid, vid)
+ SELECT NEW.uid, rv.vid FROM releases_vn rv WHERE rv.id = NEW.rid
+ ON CONFLICT (uid, vid) DO NOTHING;
+ END IF;
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE CONSTRAINT TRIGGER update_ulist_vns_rlist AFTER DELETE ON ulist_vns DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE update_vnlist_rlist();
+CREATE CONSTRAINT TRIGGER update_rlist_ulist_vns AFTER INSERT ON rlists DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE update_vnlist_rlist();
+
+
+
+
+-- Create ulist_label rows when a new user is added
+
+CREATE OR REPLACE FUNCTION ulist_labels_create() RETURNS trigger AS 'BEGIN PERFORM ulist_labels_create(NEW.id); RETURN NULL; END' LANGUAGE plpgsql;
+
+CREATE TRIGGER ulist_labels_create AFTER INSERT ON users FOR EACH ROW EXECUTE PROCEDURE ulist_labels_create();
+
+
+
+
+-- Set/unset the 'Voted' label when voting.
+
+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();
+
+
+
+
+-- NOTIFY on insert into changes/posts/reviews
+
+CREATE OR REPLACE FUNCTION insert_notify() 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 = 'reviews' THEN
+ NOTIFY newreview;
+ END IF;
+ 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 reviews FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
+
+
+
+
+-- Create notifications for new posts.
+
+CREATE OR REPLACE FUNCTION notify_post() RETURNS trigger AS $$
+BEGIN
+ INSERT INTO notifications (uid, ntype, iid, num) SELECT uid, ntype, iid, num FROM notify(NEW.tid, NEW.num, NEW.uid) n;
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER notify_post AFTER INSERT ON threads_posts FOR EACH ROW EXECUTE PROCEDURE notify_post();
+
+
+
+
+-- Create notifications for new review comments.
+
+CREATE OR REPLACE FUNCTION notify_comment() RETURNS trigger AS $$
+BEGIN
+ INSERT INTO notifications (uid, ntype, iid, num) SELECT uid, ntype, iid, num FROM notify(NEW.id, NEW.num, NEW.uid) n;
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER notify_comment AFTER INSERT ON reviews_posts FOR EACH ROW EXECUTE PROCEDURE notify_comment();
+
+
+
+
+-- Create notifications for new reviews.
+
+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 notify_review AFTER INSERT ON reviews FOR EACH ROW EXECUTE PROCEDURE notify_review();
+
+
+
+
+-- Update threads.c_count and c_lastnum
+
+CREATE OR REPLACE FUNCTION update_threads_cache() RETURNS trigger AS $$
+BEGIN
+ 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
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER update_threads_cache AFTER INSERT OR UPDATE OR DELETE ON threads_posts FOR EACH ROW EXECUTE PROCEDURE update_threads_cache();
+
+
+
+
+-- Update reviews.c_count and c_lastnum
+
+CREATE OR REPLACE FUNCTION update_reviews_cache() RETURNS trigger AS $$
+BEGIN
+ 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
+$$ 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 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/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/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/f/wikidata.png b/static/f/wikidata.png
new file mode 100644
index 00000000..e02e8acd
--- /dev/null
+++ b/static/f/wikidata.png
Binary files differ
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
new file mode 100644
index 00000000..66604f8a
--- /dev/null
+++ 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 eb858770..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 #112233BC // 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/bbcode-test.pl b/util/bbcode-test.pl
deleted file mode 100755
index 1100b34e..00000000
--- a/util/bbcode-test.pl
+++ /dev/null
@@ -1,227 +0,0 @@
-#!/usr/bin/perl
-
-# This is a test & benchmark script for VNDB::BBCode.
-# Call without arguments to run the test, with any argument to run the benchmark.
-
-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/;
-
-
-my @tests = (
- '',
- '',
- '',
-
- '[From [url=http://www.dlSITE.com/eng/]DLsite English[/url]]',
- '[From <a href="http://www.dlSITE.com/eng/" rel="nofollow">DLsite English</a>]',
- '[From DLsite English]',
-
- '[url=http://example.com/]some url[/url]',
- '<a href="http://example.com/" rel="nofollow">some url</a>',
- 'some url',
-
- '[quote]some quote[/quote]',
- '<div class="quote">some quote</div>',
- 'some quote',
-
- "[code]some code\n\nalso newlines;[/code]",
- '<pre>some code<br><br>also newlines;</pre>',
- "some code\n\nalso newlines;",
-
- '[spoiler]some spoiler[/spoiler]',
- '<b class="spoiler">some spoiler</b>',
- '',
-
- "[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 ',
-
- "[quote]rmnewline after closing tag[/quote]\n",
- '<div class="quote">rmnewline after closing tag</div>',
- "rmnewline after closing tag\n",
-
- '[url=/v19]some vndb url[/url]',
- '<a href="/v19" rel="nofollow">some vndb url</a>',
- 'some vndb url',
-
- "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",
-
- "[quote]\nsimple\nrmnewline\ntest\n[/quote]",
- '<div class="quote">simple<br>rmnewline<br>test<br></div>',
- "\nsimple\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",
-
- "[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",
-
- '[url=http://example.com/]markup in [raw][url][/raw][/url]',
- '<a href="http://example.com/" rel="nofollow">markup in [url]</a>',
- "markup in [url]",
-
- '[url=http://192.168.1.1/some/path]ipv4 address in [url][/url]',
- '<a href="http://192.168.1.1/some/path" rel="nofollow">ipv4 address in [url]</a>',
- 'ipv4 address in [url]',
-
- 'http://192.168.1.1/some/path (literal ipv4 address)',
- '<a href="http://192.168.1.1/some/path" rel="nofollow">link</a> (literal ipv4 address)',
- 'http://192.168.1.1/some/path (literal ipv4 address)',
-
- '[url=http://192.168.1.1:8080/some/path]ipv4 address (port included) in [url][/url]',
- '<a href="http://192.168.1.1:8080/some/path" rel="nofollow">ipv4 address (port included) in [url]</a>',
- 'ipv4 address (port included) in [url]',
-
- 'http://192.168.1.1:8080/some/path (literal ipv4 address, port included)',
- '<a href="http://192.168.1.1:8080/some/path" rel="nofollow">link</a> (literal ipv4 address, port included)',
- '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 ',
-
- '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 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',
-
- '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',
- '<a href="/v17">v17</a> text dds16v21 more text1 <a href="/v9">v9</a>',
- 'v17 text dds16v21 more text1 v9',
-
- # https://vndb.org/t2520.233
- '[From[url=http://densetsu.com/display.php?id=468&style=alphabetical] Anime Densetsu[/url]]',
- '[From<a href="http://densetsu.com/display.php?id=468&amp;style=alphabetical" rel="nofollow"> Anime Densetsu</a>]',
- '[From Anime Densetsu]',
-
- # Not sure what to do here
- #'http://some[raw].pointlessly[/raw].unusual.domain/',
- #'<a href="http://some.pointlessly.unusual.domain/" rel="nofollow">link</a>',
-
- #'[url=http://some[raw].pointlessly[/raw].unusual.domain/]hi[/url]',
- #'<a href="http://some[raw].pointlessly[/raw].unusual.domain/" rel="nofollow">hi</a>',
-
- '<tag>html escapes (&)</tag>',
- '&lt;tag&gt;html escapes (&amp;)&lt;/tag&gt;',
- '<tag>html escapes (&)</tag>',
-
- '[spoiler]stray open tag',
- '<b class="spoiler">stray open tag</b>',
- '',
-
- # 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>',
- '',
-
- '[quote][spoiler]two stray open tags',
- '<div class="quote"><b class="spoiler">two stray open tags</b></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>',
- "that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination",
-
- # > I don't see anyone using IPv6 URLs anytime soon, so I'm not worried too either way.
- #'[url=http://[fedc:ba98:7654:3210:fedc:ba98:7654:3210]/some/path]ipv6 address in [url][/url]',
- #'<a href="http://[fedc:ba98:7654:3210:fedc:ba98:7654:3210]/some/path" rel="nofollow">ipv6 address in [url]</a>',
-
- #'http://[fedc:ba98:7654:3210:fedc:ba98:7654:3210]/some/path (literal ipv6 address)',
- #'<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 ],
- '<a href="https://cat.xyz/" rel="nofollow">that\'s </a>',
- "that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination",
-
- [ "A https://blicky.net/ only takes 4 characters", 8 ],
- 'A <a href="https://blicky.net/" rel="nofollow">link</a>',
- "A https://blicky.net/ only takes 4 characters",
-);
-
-
-# output should be the same as the input
-my @invalid_syntax = (
- '[url="http://example.com/"]invalid argument to the "url" tag[/url]',
- '[url=nicetext]simpler invalid param[/url]',
- '[url]empty "url" tag[/url]',
- '[tag]custom tag[/tag]',
- # https://vndb.org/t2520.231
- 'pov1',
-);
-
-
-# Chaining all the parse() raw arguments should generate the same string as the input
-sub identity {
- my $ret = '';
- VNDB::BBCode::parse $_[0], sub {
- $ret .= $_[0];
- };
- $ret;
-}
-
-
-sub test {
- push @tests, map +($_,$_,$_), @invalid_syntax;
- plan tests => scalar @tests;
-
- while(@tests) {
- my $input = shift @tests;
- my $html = shift @tests;
- my $plain = shift @tests;
- 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";
- }
-}
-
-
-# Performance comparison with old implementation
-sub bench {
- my $plain = "This isn't a terribly interesting [string]. "x1000;
- 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) },
- });
- # old:
- # heavy: 3 wallclock secs ( 3.15 usr + 0.00 sys = 3.15 CPU) @ 357.46/s (n=1126)
- # plain: 3 wallclock secs ( 3.20 usr + 0.00 sys = 3.20 CPU) @ 130.00/s (n=416)
- # short: 3 wallclock secs ( 3.17 usr + 0.00 sys = 3.17 CPU) @ 31420.82/s (n=99604)
- # new:
- # heavy: 3 wallclock secs ( 3.23 usr + 0.00 sys = 3.23 CPU) @ 242.11/s (n=782)
- # plain: 3 wallclock secs ( 3.12 usr + 0.00 sys = 3.12 CPU) @ 124.04/s (n=387)
- # short: 3 wallclock secs ( 3.18 usr + 0.00 sys = 3.18 CPU) @ 21018.55/s (n=66839)
- # That's a bit of a performance hit, but should still be fast enough.
-}
-
-test if !@ARGV;
-bench if @ARGV;
diff --git a/util/dbdump.pl b/util/dbdump.pl
new file mode 100755
index 00000000..fef8c5da
--- /dev/null
+++ b/util/dbdump.pl
@@ -0,0 +1,493 @@
+#!/usr/bin/perl
+my $HELP=<<_;
+Usage:
+
+util/dbdump.pl export-db output.tar.zst
+
+ Write a full database export as a .tar.zst
+
+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
+_
+
+# TODO:
+# - Import
+# - Consolidate with devdump.pl?
+
+use strict;
+use warnings;
+use autodie;
+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';
+our $ROOT;
+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.
+#
+# Tables are exported with an explicit ORDER BY to make them more deterministic
+# and avoid potentially leaking information about internal state (such as when
+# a user last updated their account).
+#
+# Hidden DB entries, private user lists and various other rows with no
+# interesting references are excluded from the dumps. Keeping all references
+# consistent with those omissions complicates the WHERE clauses somewhat.
+my %tables = (
+ 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 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;
+my $schema = VNDB::Schema::schema;
+my $types = VNDB::Schema::types;
+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');
+
+
+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 {
+ my $dest = shift;
+ open my $F, '>', $dest;
+ printf $F "%s\n", $db->selectrow_array('SELECT date_trunc(\'second\', NOW())');
+}
+
+
+sub export_table {
+ my($dest, $table) = @_;
+
+ my $schema = $schema->{$table->{name}};
+ my @cols = grep $_->{pub}, @{$schema->{cols}};
+ die "No columns to export for table '$table->{name}'\n" if !@cols;;
+
+ my $fn = "$dest/$table->{name}";
+
+ 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 ($sql) TO STDOUT});
+ open my $F, '>:utf8', $fn;
+ my $v;
+ print $F $v while($db->pg_getcopydata($v) >= 0);
+ close $F;
+
+ #printf "# Dumped %s in %.3fs\n", $table->{name}, time-$start;
+
+ open $F, '>', "$fn.header";
+ print $F join "\t", map $_->{name}, @cols;
+ print $F "\n";
+ close $F;
+}
+
+
+sub export_import_script {
+ my $dest = shift;
+ open my $F, '>', $dest;
+ print $F <<' _' =~ s/^ //mgr;
+ -- This script will create the necessary tables and import all data into an
+ -- existing PostgreSQL database.
+ --
+ -- Usage:
+ -- Run a 'CREATE DATABASE $database' somewhere.
+ -- psql -U $user $database -f import.sql
+ --
+ -- The imported database does not include any indices, other than primary keys.
+ -- You may want to create some indices by hand to speed up complex queries.
+
+ -- 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";
+ my %types = map +($_->{type}, 1), grep $_->{pub}, map @{$schema->{$_->{name}}{cols}}, @tables;
+ print $F "$types->{$_}{decl}\n" for (sort grep $types->{$_}, keys %types);
+
+ 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 =~ s/ +(?:check|constraint|default) +.*//ir, grep $_->{pub}, @{$schema->{cols}};
+ print $F ",\n PRIMARY KEY(".join(', ', map "$_", @primary).")" if @primary;
+ print $F "\n);\n";
+ }
+
+ print $F "\n\n";
+ print $F "-- You can comment out tables you don't need, to speed up the import and save some disk space.\n";
+ print $F "\\copy $_->{name} from 'db/$_->{name}'\n" for @tables;
+
+ print $F "\n\n";
+ print $F "-- These are included to verify the internal consistency of the dump, you can safely comment out this part.\n";
+ for my $ref (@$references) {
+ next if !$tables{$ref->{from_table}} || !$tables{$ref->{to_table}};
+ my %pub = map +($_->{name}, 1), grep $_->{pub}, @{$schema->{$ref->{from_table}}{cols}};
+ 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};
+ }
+ }
+}
+
+
+sub export_db {
+ my $dest = shift;
+
+ my @static = qw{
+ LICENSE-CC0.txt
+ LICENSE-CC-BY-NC-SA.txt
+ LICENSE-DBCL.txt
+ LICENSE-ODBL.txt
+ README.txt
+ };
+
+ rmtree "${dest}_dir";
+ mkdir "${dest}_dir";
+ mkdir "${dest}_dir/db";
+
+ cp "$ROOT/util/dump/$_", "${dest}_dir/$_" for @static;
+
+ export_timestamp "${dest}_dir/TIMESTAMP";
+ export_table "${dest}_dir/db", $_ for @tables;
+ 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`;
+ rmtree "${dest}_dir";
+}
+
+
+# Copy file while retaining access/modification times
+sub cp_p {
+ my($from, $to) = @_;
+ cp $from, $to;
+ utime @{ [stat($from)] }[8,9], $to;
+}
+
+
+# XXX: This does not include images that are linked from descriptions; May want to borrow from util/unusedimages.pl to find those.
+sub export_img {
+ my $dest = shift;
+
+ 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, '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 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 "$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 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 NOT uv.c_private
+ ORDER BY uv.vid, uv.uid
+ ) TO STDOUT
+ });
+ my $v;
+ print $F $v while($db->pg_getcopydata($v) >= 0);
+}
+
+
+sub export_tags {
+ my $dest = shift;
+ require JSON::XS;
+ require PerlIO::gzip;
+
+ my $lst = $db->selectall_arrayref(q{
+ 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;
+ $_->{meta} = !$_->{searchable} ? JSON::XS::true() : JSON::XS::false(); # For backwards compat
+ $_->{searchable} = $_->{searchable} ? JSON::XS::true() : JSON::XS::false();
+ $_->{applicable} = $_->{applicable} ? JSON::XS::true() : JSON::XS::false();
+ $_->{vns} *= 1;
+ $_->{aliases} = [ split /\n/, delete $_->{alias} ];
+ $_->{parents} = [ map $_*1, split /,/, ($_->{parents}||'') ];
+ }
+
+ open my $F, '>:gzip:utf8', $dest;
+ print $F JSON::XS->new->canonical->encode($lst);
+}
+
+
+sub export_traits {
+ my $dest = shift;
+ require JSON::XS;
+ require PerlIO::gzip;
+
+ my $lst = $db->selectall_arrayref(q{
+ 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;
+ $_->{meta} = $_->{searchable} ? JSON::XS::true() : JSON::XS::false(); # For backwards compat
+ $_->{searchable} = $_->{searchable} ? JSON::XS::true() : JSON::XS::false();
+ $_->{applicable} = $_->{applicable} ? JSON::XS::true() : JSON::XS::false();
+ $_->{chars} *= 1;
+ $_->{aliases} = [ split /\r?\n/, ($_->{aliases}||'') ];
+ $_->{parents} = [ map $_*1, split /,/, ($_->{parents}||'') ];
+ }
+
+ open my $F, '>:gzip:utf8', $dest;
+ print $F JSON::XS->new->canonical->encode($lst);
+}
+
+
+if($ARGV[0] && $ARGV[0] eq '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]) {
+ export_tags $ARGV[1];
+} elsif($ARGV[0] && $ARGV[0] eq 'export-traits' && $ARGV[1]) {
+ export_traits $ARGV[1];
+} else {
+ print $HELP;
+}
diff --git a/util/devdump.pl b/util/devdump.pl
index 6adc361b..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,20 +99,13 @@ sub copy_entry {
open my $OUT, '>:utf8', 'dump.sql';
select $OUT;
- # Header
- my @tables = grep !/^multi_/, sort @{ $db->selectcol_arrayref(
- "SELECT oid::regclass::text FROM pg_class WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace"
- ) };
- print "\\set ON_ERROR_STOP 1\n";
- print "BEGIN;\n";
- printf "TRUNCATE TABLE %s CASCADE;\n", join ',', @tables;
- print "SET CONSTRAINTS ALL DEFERRED;\n";
- printf "ALTER TABLE %s DISABLE TRIGGER USER;\n", $_ for @tables;
-
- # Copy over some required defaults
- open my $F, '<', 'util/sql/data.sql';
- print while <$F>;
- close $F;
+ print "-- This file replaces 'sql/all.sql'.\n";
+ print "\\set ON_ERROR_STOP 1\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(
@@ -107,83 +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)";
- copy 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 date FROM votes WHERE vid IN($vids) GROUP BY vid, uid%8", {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;
-
- # Caches
- print "SELECT tag_vn_calc();\n";
- print "SELECT traits_chars_calc();\n";
- print "SELECT update_vncache(id) FROM vn;\n";
- print "UPDATE users u SET c_votes = (SELECT COUNT(*) FROM votes v WHERE v.uid = u.id);\n";
+ copy 'drm';
+ copy_entry [qw/releases releases_drm releases_media releases_platforms releases_producers releases_titles releases_vn/], $releases;
+
+ 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 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";
- # These were copied from Multi::Maintenance
- print "UPDATE stats_cache SET count = (SELECT COUNT(*) FROM users)-1 WHERE section = 'users';\n";
- print "UPDATE stats_cache SET count = (SELECT COUNT(*) FROM vn WHERE hidden = FALSE) WHERE section = 'vn';\n";
- print "UPDATE stats_cache SET count = (SELECT COUNT(*) FROM releases WHERE hidden = FALSE) WHERE section = 'releases';\n";
- print "UPDATE stats_cache SET count = (SELECT COUNT(*) FROM producers WHERE hidden = FALSE) WHERE section = 'producers';\n";
- print "UPDATE stats_cache SET count = (SELECT COUNT(*) FROM chars WHERE hidden = FALSE) WHERE section = 'chars';\n";
- print "UPDATE stats_cache SET count = (SELECT COUNT(*) FROM staff WHERE hidden = FALSE) WHERE section = 'staff';\n";
- print "UPDATE stats_cache SET count = (SELECT COUNT(*) FROM tags WHERE state = 2) WHERE section = 'tags';\n";
- print "UPDATE stats_cache SET count = (SELECT COUNT(*) FROM traits WHERE state = 2) WHERE section = 'traits';\n";
- print "UPDATE stats_cache SET count = (SELECT COUNT(*) FROM threads WHERE hidden = FALSE) WHERE section = 'threads';\n";
- print "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';\n";
- # TODO: Something with the 'stats_cache' table. The vn.c_* stats are also inconsistent
-
- # Footer
- # Apparently we can't do an ALTER TABLE while the (deferred) foreign key checks
- # haven't been executed, so do a commit first.
- print "COMMIT;\n";
- printf "ALTER TABLE %s ENABLE TRIGGER USER;\n", $_ for @tables;
+
+ print "\\set ON_ERROR_STOP 0\n";
+ print "\\i sql/perms.sql\n";
+ print "VACUUM ANALYZE;\n";
select STDOUT;
close $OUT;
@@ -191,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]} }
+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;
-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)");
-
-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 6beaf9f4..e3ae25b8 100755
--- a/util/docker-init.sh
+++ b/util/docker-init.sh
@@ -1,12 +1,22 @@
-#!/bin/bash
+#!/bin/sh
-if ! test -f /var/vndb-docker-image; then
- echo "This script should only be run from within the VNDB docker container."
- echo "Check the README for instructions."
+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)."
+ echo
+ echo "Please rebuild the Docker image and try again:"
+ echo
+ echo " docker rmi vndb"
+ echo " docker build -t vndb ."
+ echo
+ echo "Check README.md for instructions."
+ echo
exit 1
fi
+# Should run as root
mkdevuser() {
# Create a new user with the same UID and GID as the owner of the VNDB
# directory. This allows for convenient exchange of files without worrying
@@ -14,100 +24,100 @@ 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
- groupadd devgroup
- useradd -m -s /bin/bash devuser
+ addgroup devgroup
+ adduser -s /bin/sh devuser
else
- groupadd -g $USER_GID devgroup
- useradd -u $USER_UID -g $USER_GID -m -s /bin/bash devuser
+ addgroup -g $USER_GID devgroup
+ adduser -s /bin/sh -u $USER_UID -G devgroup -D devuser
fi
+ install -d -o devuser -g devgroup /run/postgresql
+}
- # So you can easily do a 'psql -U vndb'
- echo '*:*:*:vndb:vndb' >/home/devuser/.pgpass
- echo '*:*:*:vndb_site:vndb_site' >>/home/devuser/.pgpass
- echo '*:*:*:vndb_multi:vndb_multi' >>/home/devuser/.pgpass
- chown devuser /home/devuser/.pgpass
- chmod 600 /home/devuser/.pgpass
+
+# 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() {
- echo 'local all postgres peer' >/etc/postgresql/10/main/pg_hba.conf
- echo 'local all all md5' >>/etc/postgresql/10/main/pg_hba.conf
- # I'm glad Ubuntu 18.04 still has an init script for this
- /etc/init.d/postgresql start
-}
+ 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 /vndb/docker/pg15 -l /vndb/docker/pg15/logfile start
-pg_init() {
- if test -f /var/lib/postgresql/vndb-init-done; then
+ if test -f docker/pg15/vndb-init-done; then
echo
echo "Database initialization already done."
echo
return
fi
- su postgres -c '/var/www/util/docker-init.sh pg_load_superuser'
- su devuser -c '/var/www/util/docker-init.sh pg_load_vndb'
- touch /var/lib/postgresql/vndb-init-done
+ echo "============================================================="
echo
- echo "Database initialization done!"
- echo
-}
-
-# Should run as the postgres user
-pg_load_superuser() {
- psql -f /var/www/util/sql/superuser_init.sql
- echo "ALTER ROLE vndb LOGIN PASSWORD 'vndb'" | psql -U postgres
- echo "ALTER ROLE vndb_site LOGIN PASSWORD 'vndb_site'" | psql -U postgres
- echo "ALTER ROLE vndb_multi LOGIN PASSWORD 'vndb_multi'" | psql -U postgres
-}
-
-# Should run as devuser
-pg_load_vndb() {
- cd /var/www
- make util/sql/editfunc.sql
- psql -U vndb -f util/sql/all.sql
-
- echo
- echo "You now have a functional, but empty, database."
+ echo "Database has not been initialized yet, doing that now."
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 keep an empty database, y to download the dev database."
+ 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
- if [[ $opt =~ ^[Yy] ]]
+
+ 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
+
+ if [ $opt = e ]
then
- curl https://s.vndb.org/devdump.tar.gz | tar -xzf-
psql -U vndb -f dump.sql
+ elif [ $opt = y ]
+ then
+ 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 sql/all.sql
fi
+
+ touch docker/pg15/vndb-init-done
+
+ echo
+ echo "Database initialization done!"
+ echo
}
# Should run as devuser
devshell() {
- cd /var/www
+ cd /vndb
util/vndb-dev-server.pl
- bash
+ sh
}
case "$1" in
'')
mkdevuser
- pg_start
- pg_init
- exec su devuser -c '/var/www/util/docker-init.sh devshell'
- ;;
- pg_load_superuser)
- pg_load_superuser
+ installvndbid
+ su devuser -c '/vndb/util/docker-init.sh pg_start'
+ exec su devuser -c '/vndb/util/docker-init.sh devshell'
;;
- pg_load_vndb)
- pg_load_vndb
+ pg_start)
+ pg_start
;;
devshell)
devshell
diff --git a/util/dump/LICENSE-CC-BY-NC-SA.txt b/util/dump/LICENSE-CC-BY-NC-SA.txt
new file mode 100644
index 00000000..5797ceb3
--- /dev/null
+++ b/util/dump/LICENSE-CC-BY-NC-SA.txt
@@ -0,0 +1,362 @@
+Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Creative
+Commons Corporation ("Creative Commons") is not a law firm and does not provide
+legal services or legal advice. Distribution of Creative Commons public licenses
+does not create a lawyer-client or other relationship. Creative Commons makes
+its licenses and related information available on an "as-is" basis. Creative
+Commons gives no warranties regarding its licenses, any material licensed
+under their terms and conditions, or any related information. Creative Commons
+disclaims all liability for damages resulting from their use to the fullest
+extent possible.
+
+Using Creative Commons Public Licenses
+
+Creative Commons public licenses provide a standard set of terms and conditions
+that creators and other rights holders may use to share original works of
+authorship and other material subject to copyright and certain other rights
+specified in the public license below. The following considerations are for
+informational purposes only, are not exhaustive, and do not form part of our
+licenses.
+
+Considerations for licensors: Our public licenses are intended for use by
+those authorized to give the public permission to use material in ways otherwise
+restricted by copyright and certain other rights. Our licenses are irrevocable.
+Licensors should read and understand the terms and conditions of the license
+they choose before applying it. Licensors should also secure all rights necessary
+before applying our licenses so that the public can reuse the material as
+expected. Licensors should clearly mark any material not subject to the license.
+This includes other CC-licensed material, or material used under an exception
+or limitation to copyright. More considerations for licensors : wiki.creativecommons.org/Considerations_for_licensors
+
+Considerations for the public: By using one of our public licenses, a licensor
+grants the public permission to use the licensed material under specified
+terms and conditions. If the licensor's permission is not necessary for any
+reason–for example, because of any applicable exception or limitation to copyright–then
+that use is not regulated by the license. Our licenses grant only permissions
+under copyright and certain other rights that a licensor has authority to
+grant. Use of the licensed material may still be restricted for other reasons,
+including because others have copyright or other rights in the material. A
+licensor may make special requests, such as asking that all changes be marked
+or described. Although not required by our licenses, you are encouraged to
+respect those requests where reasonable. More considerations for the public
+: wiki.creativecommons.org/Considerations_for_licensees
+
+Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public
+License
+
+By exercising the Licensed Rights (defined below), You accept and agree to
+be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike
+4.0 International Public License ("Public License"). To the extent this Public
+License may be interpreted as a contract, You are granted the Licensed Rights
+in consideration of Your acceptance of these terms and conditions, and the
+Licensor grants You such rights in consideration of benefits the Licensor
+receives from making the Licensed Material available under these terms and
+conditions.
+
+Section 1 – Definitions.
+
+a. Adapted Material means material subject to Copyright and Similar Rights
+that is derived from or based upon the Licensed Material and in which the
+Licensed Material is translated, altered, arranged, transformed, or otherwise
+modified in a manner requiring permission under the Copyright and Similar
+Rights held by the Licensor. For purposes of this Public License, where the
+Licensed Material is a musical work, performance, or sound recording, Adapted
+Material is always produced where the Licensed Material is synched in timed
+relation with a moving image.
+
+b. Adapter's License means the license You apply to Your Copyright and Similar
+Rights in Your contributions to Adapted Material in accordance with the terms
+and conditions of this Public License.
+
+c. BY-NC-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses,
+approved by Creative Commons as essentially the equivalent of this Public
+License.
+
+d. Copyright and Similar Rights means copyright and/or similar rights closely
+related to copyright including, without limitation, performance, broadcast,
+sound recording, and Sui Generis Database Rights, without regard to how the
+rights are labeled or categorized. For purposes of this Public License, the
+rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
+
+e. Effective Technological Measures means those measures that, in the absence
+of proper authority, may not be circumvented under laws fulfilling obligations
+under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996,
+and/or similar international agreements.
+
+f. Exceptions and Limitations means fair use, fair dealing, and/or any other
+exception or limitation to Copyright and Similar Rights that applies to Your
+use of the Licensed Material.
+
+g. License Elements means the license attributes listed in the name of a Creative
+Commons Public License. The License Elements of this Public License are Attribution,
+NonCommercial, and ShareAlike.
+
+h. Licensed Material means the artistic or literary work, database, or other
+material to which the Licensor applied this Public License.
+
+i. Licensed Rights means the rights granted to You subject to the terms and
+conditions of this Public License, which are limited to all Copyright and
+Similar Rights that apply to Your use of the Licensed Material and that the
+Licensor has authority to license.
+
+j. Licensor means the individual(s) or entity(ies) granting rights under this
+Public License.
+
+k. NonCommercial means not primarily intended for or directed towards commercial
+advantage or monetary compensation. For purposes of this Public License, the
+exchange of the Licensed Material for other material subject to Copyright
+and Similar Rights by digital file-sharing or similar means is NonCommercial
+provided there is no payment of monetary compensation in connection with the
+exchange.
+
+l. Share means to provide material to the public by any means or process that
+requires permission under the Licensed Rights, such as reproduction, public
+display, public performance, distribution, dissemination, communication, or
+importation, and to make material available to the public including in ways
+that members of the public may access the material from a place and at a time
+individually chosen by them.
+
+m. Sui Generis Database Rights means rights other than copyright resulting
+from Directive 96/9/EC of the European Parliament and of the Council of 11
+March 1996 on the legal protection of databases, as amended and/or succeeded,
+as well as other essentially equivalent rights anywhere in the world.
+
+n. You means the individual or entity exercising the Licensed Rights under
+this Public License. Your has a corresponding meaning.
+
+Section 2 – Scope.
+
+ a. License grant.
+
+1. Subject to the terms and conditions of this Public License, the Licensor
+hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive,
+irrevocable license to exercise the Licensed Rights in the Licensed Material
+to:
+
+A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial
+purposes only; and
+
+B. produce, reproduce, and Share Adapted Material for NonCommercial purposes
+only.
+
+2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions
+and Limitations apply to Your use, this Public License does not apply, and
+You do not need to comply with its terms and conditions.
+
+ 3. Term. The term of this Public License is specified in Section 6(a).
+
+4. Media and formats; technical modifications allowed. The Licensor authorizes
+You to exercise the Licensed Rights in all media and formats whether now known
+or hereafter created, and to make technical modifications necessary to do
+so. The Licensor waives and/or agrees not to assert any right or authority
+to forbid You from making technical modifications necessary to exercise the
+Licensed Rights, including technical modifications necessary to circumvent
+Effective Technological Measures. For purposes of this Public License, simply
+making modifications authorized by this Section 2(a)(4) never produces Adapted
+Material.
+
+ 5. Downstream recipients.
+
+A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed
+Material automatically receives an offer from the Licensor to exercise the
+Licensed Rights under the terms and conditions of this Public License.
+
+B. Additional offer from the Licensor – Adapted Material. Every recipient
+of Adapted Material from You automatically receives an offer from the Licensor
+to exercise the Licensed Rights in the Adapted Material under the conditions
+of the Adapter's License You apply.
+
+C. No downstream restrictions. You may not offer or impose any additional
+or different terms or conditions on, or apply any Effective Technological
+Measures to, the Licensed Material if doing so restricts exercise of the Licensed
+Rights by any recipient of the Licensed Material.
+
+6. No endorsement. Nothing in this Public License constitutes or may be construed
+as permission to assert or imply that You are, or that Your use of the Licensed
+Material is, connected with, or sponsored, endorsed, or granted official status
+by, the Licensor or others designated to receive attribution as provided in
+Section 3(a)(1)(A)(i).
+
+ b. Other rights.
+
+1. Moral rights, such as the right of integrity, are not licensed under this
+Public License, nor are publicity, privacy, and/or other similar personality
+rights; however, to the extent possible, the Licensor waives and/or agrees
+not to assert any such rights held by the Licensor to the limited extent necessary
+to allow You to exercise the Licensed Rights, but not otherwise.
+
+2. Patent and trademark rights are not licensed under this Public License.
+
+3. To the extent possible, the Licensor waives any right to collect royalties
+from You for the exercise of the Licensed Rights, whether directly or through
+a collecting society under any voluntary or waivable statutory or compulsory
+licensing scheme. In all other cases the Licensor expressly reserves any right
+to collect such royalties, including when the Licensed Material is used other
+than for NonCommercial purposes.
+
+Section 3 – License Conditions.
+
+Your exercise of the Licensed Rights is expressly made subject to the following
+conditions.
+
+ a. Attribution.
+
+1. If You Share the Licensed Material (including in modified form), You must:
+
+A. retain the following if it is supplied by the Licensor with the Licensed
+Material:
+
+i. identification of the creator(s) of the Licensed Material and any others
+designated to receive attribution, in any reasonable manner requested by the
+Licensor (including by pseudonym if designated);
+
+ ii. a copyright notice;
+
+ iii. a notice that refers to this Public License;
+
+ iv. a notice that refers to the disclaimer of warranties;
+
+
+
+v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
+
+B. indicate if You modified the Licensed Material and retain an indication
+of any previous modifications; and
+
+C. indicate the Licensed Material is licensed under this Public License, and
+include the text of, or the URI or hyperlink to, this Public License.
+
+2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner
+based on the medium, means, and context in which You Share the Licensed Material.
+For example, it may be reasonable to satisfy the conditions by providing a
+URI or hyperlink to a resource that includes the required information.
+
+3. If requested by the Licensor, You must remove any of the information required
+by Section 3(a)(1)(A) to the extent reasonably practicable.
+
+b. ShareAlike.In addition to the conditions in Section 3(a), if You Share
+Adapted Material You produce, the following conditions also apply.
+
+1. The Adapter's License You apply must be a Creative Commons license with
+the same License Elements, this version or later, or a BY-NC-SA Compatible
+License.
+
+2. You must include the text of, or the URI or hyperlink to, the Adapter's
+License You apply. You may satisfy this condition in any reasonable manner
+based on the medium, means, and context in which You Share Adapted Material.
+
+3. You may not offer or impose any additional or different terms or conditions
+on, or apply any Effective Technological Measures to, Adapted Material that
+restrict exercise of the rights granted under the Adapter's License You apply.
+
+Section 4 – Sui Generis Database Rights.
+
+Where the Licensed Rights include Sui Generis Database Rights that apply to
+Your use of the Licensed Material:
+
+a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract,
+reuse, reproduce, and Share all or a substantial portion of the contents of
+the database for NonCommercial purposes only;
+
+b. if You include all or a substantial portion of the database contents in
+a database in which You have Sui Generis Database Rights, then the database
+in which You have Sui Generis Database Rights (but not its individual contents)
+is Adapted Material, including for purposes of Section 3(b); and
+
+c. You must comply with the conditions in Section 3(a) if You Share all or
+a substantial portion of the contents of the database.
+
+For the avoidance of doubt, this Section 4 supplements and does not replace
+Your obligations under this Public License where the Licensed Rights include
+other Copyright and Similar Rights.
+
+Section 5 – Disclaimer of Warranties and Limitation of Liability.
+
+a. Unless otherwise separately undertaken by the Licensor, to the extent possible,
+the Licensor offers the Licensed Material as-is and as-available, and makes
+no representations or warranties of any kind concerning the Licensed Material,
+whether express, implied, statutory, or other. This includes, without limitation,
+warranties of title, merchantability, fitness for a particular purpose, non-infringement,
+absence of latent or other defects, accuracy, or the presence or absence of
+errors, whether or not known or discoverable. Where disclaimers of warranties
+are not allowed in full or in part, this disclaimer may not apply to You.
+
+b. To the extent possible, in no event will the Licensor be liable to You
+on any legal theory (including, without limitation, negligence) or otherwise
+for any direct, special, indirect, incidental, consequential, punitive, exemplary,
+or other losses, costs, expenses, or damages arising out of this Public License
+or use of the Licensed Material, even if the Licensor has been advised of
+the possibility of such losses, costs, expenses, or damages. Where a limitation
+of liability is not allowed in full or in part, this limitation may not apply
+to You.
+
+c. The disclaimer of warranties and limitation of liability provided above
+shall be interpreted in a manner that, to the extent possible, most closely
+approximates an absolute disclaimer and waiver of all liability.
+
+Section 6 – Term and Termination.
+
+a. This Public License applies for the term of the Copyright and Similar Rights
+licensed here. However, if You fail to comply with this Public License, then
+Your rights under this Public License terminate automatically.
+
+b. Where Your right to use the Licensed Material has terminated under Section
+6(a), it reinstates:
+
+1. automatically as of the date the violation is cured, provided it is cured
+within 30 days of Your discovery of the violation; or
+
+ 2. upon express reinstatement by the Licensor.
+
+For the avoidance of doubt, this Section 6(b) does not affect any right the
+Licensor may have to seek remedies for Your violations of this Public License.
+
+c. For the avoidance of doubt, the Licensor may also offer the Licensed Material
+under separate terms or conditions or stop distributing the Licensed Material
+at any time; however, doing so will not terminate this Public License.
+
+ d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
+
+Section 7 – Other Terms and Conditions.
+
+a. The Licensor shall not be bound by any additional or different terms or
+conditions communicated by You unless expressly agreed.
+
+b. Any arrangements, understandings, or agreements regarding the Licensed
+Material not stated herein are separate from and independent of the terms
+and conditions of this Public License.
+
+Section 8 – Interpretation.
+
+a. For the avoidance of doubt, this Public License does not, and shall not
+be interpreted to, reduce, limit, restrict, or impose conditions on any use
+of the Licensed Material that could lawfully be made without permission under
+this Public License.
+
+b. To the extent possible, if any provision of this Public License is deemed
+unenforceable, it shall be automatically reformed to the minimum extent necessary
+to make it enforceable. If the provision cannot be reformed, it shall be severed
+from this Public License without affecting the enforceability of the remaining
+terms and conditions.
+
+c. No term or condition of this Public License will be waived and no failure
+to comply consented to unless expressly agreed to by the Licensor.
+
+d. Nothing in this Public License constitutes or may be interpreted as a limitation
+upon, or waiver of, any privileges and immunities that apply to the Licensor
+or You, including from the legal processes of any jurisdiction or authority.
+
+Creative Commons is not a party to its public licenses. Notwithstanding, Creative
+Commons may elect to apply one of its public licenses to material it publishes
+and in those instances will be considered the "Licensor." The text of the
+Creative Commons public licenses is dedicated to the public domain under the
+CC0 Public Domain Dedication. Except for the limited purpose of indicating
+that material is shared under a Creative Commons public license or as otherwise
+permitted by the Creative Commons policies published at creativecommons.org/policies,
+Creative Commons does not authorize the use of the trademark "Creative Commons"
+or any other trademark or logo of Creative Commons without its prior written
+consent including, without limitation, in connection with any unauthorized
+modifications to any of its public licenses or any other arrangements, understandings,
+or agreements concerning use of licensed material. For the avoidance of doubt,
+this paragraph does not form part of the public licenses.
+
+Creative Commons may be contacted at creativecommons.org.
diff --git a/util/dump/LICENSE-CC0.txt b/util/dump/LICENSE-CC0.txt
new file mode 100644
index 00000000..0e259d42
--- /dev/null
+++ b/util/dump/LICENSE-CC0.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/util/dump/LICENSE-DBCL.txt b/util/dump/LICENSE-DBCL.txt
new file mode 100644
index 00000000..f8239599
--- /dev/null
+++ b/util/dump/LICENSE-DBCL.txt
@@ -0,0 +1,52 @@
+## ODC Database Contents License
+
+The Licensor and You agree as follows:
+
+### 1.0 Definitions of Capitalised Words
+
+The definitions of the Open Database License (ODbL) 1.0 are incorporated
+by reference into the Database Contents License.
+
+### 2.0 Rights granted and Conditions of Use
+
+2.1 Rights granted. The Licensor grants to You a worldwide,
+royalty-free, non-exclusive, perpetual, irrevocable copyright license to
+do any act that is restricted by copyright over anything within the
+Contents, whether in the original medium or any other. These rights
+explicitly include commercial use, and do not exclude any field of
+endeavour. These rights include, without limitation, the right to
+sublicense the work.
+
+2.2 Conditions of Use. You must comply with the ODbL.
+
+2.3 Relationship to Databases and ODbL. This license does not cover any
+Database Rights, Database copyright, or contract over the Contents as
+part of the Database. Please see the ODbL covering the Database for more
+details about Your rights and obligations.
+
+2.4 Non-assertion of copyright over facts. The Licensor takes the
+position that factual information is not covered by copyright. The DbCL
+grants you permission for any information having copyright contained in
+the Contents.
+
+### 3.0 Warranties, disclaimer, and limitation of liability
+
+3.1 The Contents are licensed by the Licensor "as is" and without any
+warranty of any kind, either express or implied, whether of title, of
+accuracy, of the presence of absence of errors, of fitness for purpose,
+or otherwise. Some jurisdictions do not allow the exclusion of implied
+warranties, so this exclusion may not apply to You.
+
+3.2 Subject to any liability that may not be excluded or limited by law,
+the Licensor is not liable for, and expressly excludes, all liability
+for loss or damage however and whenever caused to anyone by any use
+under this License, whether by You or by anyone else, and whether caused
+by any fault on the part of the Licensor or not. This exclusion of
+liability includes, but is not limited to, any special, incidental,
+consequential, punitive, or exemplary damages. This exclusion applies
+even if the Licensor has been advised of the possibility of such
+damages.
+
+3.3 If liability may not be excluded by law, it is limited to actual and
+direct financial loss to the extent it is caused by proved negligence on
+the part of the Licensor.
diff --git a/util/dump/LICENSE-ODBL.txt b/util/dump/LICENSE-ODBL.txt
new file mode 100644
index 00000000..21528871
--- /dev/null
+++ b/util/dump/LICENSE-ODBL.txt
@@ -0,0 +1,540 @@
+## ODC Open Database License (ODbL)
+
+### Preamble
+
+The Open Database License (ODbL) is a license agreement intended to
+allow users to freely share, modify, and use this Database while
+maintaining this same freedom for others. Many databases are covered by
+copyright, and therefore this document licenses these rights. Some
+jurisdictions, mainly in the European Union, have specific rights that
+cover databases, and so the ODbL addresses these rights, too. Finally,
+the ODbL is also an agreement in contract for users of this Database to
+act in certain ways in return for accessing this Database.
+
+Databases can contain a wide variety of types of content (images,
+audiovisual material, and sounds all in the same database, for example),
+and so the ODbL only governs the rights over the Database, and not the
+contents of the Database individually. Licensors should use the ODbL
+together with another license for the contents, if the contents have a
+single set of rights that uniformly covers all of the contents. If the
+contents have multiple sets of different rights, Licensors should
+describe what rights govern what contents together in the individual
+record or in some other way that clarifies what rights apply.
+
+Sometimes the contents of a database, or the database itself, can be
+covered by other rights not addressed here (such as private contracts,
+trade mark over the name, or privacy rights / data protection rights
+over information in the contents), and so you are advised that you may
+have to consult other documents or clear other rights before doing
+activities not covered by this License.
+
+------
+
+The Licensor (as defined below)
+
+and
+
+You (as defined below)
+
+agree as follows:
+
+### 1.0 Definitions of Capitalised Words
+
+"Collective Database" – Means this Database in unmodified form as part
+of a collection of independent databases in themselves that together are
+assembled into a collective whole. A work that constitutes a Collective
+Database will not be considered a Derivative Database.
+
+"Convey" – As a verb, means Using the Database, a Derivative Database,
+or the Database as part of a Collective Database in any way that enables
+a Person to make or receive copies of the Database or a Derivative
+Database. Conveying does not include interaction with a user through a
+computer network, or creating and Using a Produced Work, where no
+transfer of a copy of the Database or a Derivative Database occurs.
+"Contents" – The contents of this Database, which includes the
+information, independent works, or other material collected into the
+Database. For example, the contents of the Database could be factual
+data or works such as images, audiovisual material, text, or sounds.
+
+"Database" – A collection of material (the Contents) arranged in a
+systematic or methodical way and individually accessible by electronic
+or other means offered under the terms of this License.
+
+"Database Directive" – Means Directive 96/9/EC of the European
+Parliament and of the Council of 11 March 1996 on the legal protection
+of databases, as amended or succeeded.
+
+"Database Right" – Means rights resulting from the Chapter III ("sui
+generis") rights in the Database Directive (as amended and as transposed
+by member states), which includes the Extraction and Re-utilisation of
+the whole or a Substantial part of the Contents, as well as any similar
+rights available in the relevant jurisdiction under Section 10.4.
+
+"Derivative Database" – Means a database based upon the Database, and
+includes any translation, adaptation, arrangement, modification, or any
+other alteration of the Database or of a Substantial part of the
+Contents. This includes, but is not limited to, Extracting or
+Re-utilising the whole or a Substantial part of the Contents in a new
+Database.
+
+"Extraction" – Means the permanent or temporary transfer of all or a
+Substantial part of the Contents to another medium by any means or in
+any form.
+
+"License" – Means this license agreement and is both a license of rights
+such as copyright and Database Rights and an agreement in contract.
+
+"Licensor" – Means the Person that offers the Database under the terms
+of this License.
+
+"Person" – Means a natural or legal person or a body of persons
+corporate or incorporate.
+
+"Produced Work" – a work (such as an image, audiovisual material, text,
+or sounds) resulting from using the whole or a Substantial part of the
+Contents (via a search or other query) from this Database, a Derivative
+Database, or this Database as part of a Collective Database.
+
+"Publicly" – means to Persons other than You or under Your control by
+either more than 50% ownership or by the power to direct their
+activities (such as contracting with an independent consultant).
+
+"Re-utilisation" – means any form of making available to the public all
+or a Substantial part of the Contents by the distribution of copies, by
+renting, by online or other forms of transmission.
+
+"Substantial" – Means substantial in terms of quantity or quality or a
+combination of both. The repeated and systematic Extraction or
+Re-utilisation of insubstantial parts of the Contents may amount to the
+Extraction or Re-utilisation of a Substantial part of the Contents.
+
+"Use" – As a verb, means doing any act that is restricted by copyright
+or Database Rights whether in the original medium or any other; and
+includes without limitation distributing, copying, publicly performing,
+publicly displaying, and preparing derivative works of the Database, as
+well as modifying the Database as may be technically necessary to use it
+in a different mode or format.
+
+"You" – Means a Person exercising rights under this License who has not
+previously violated the terms of this License with respect to the
+Database, or who has received express permission from the Licensor to
+exercise rights under this License despite a previous violation.
+
+Words in the singular include the plural and vice versa.
+
+### 2.0 What this License covers
+
+2.1. Legal effect of this document. This License is:
+
+ a. A license of applicable copyright and neighbouring rights;
+
+ b. A license of the Database Right; and
+
+ c. An agreement in contract between You and the Licensor.
+
+2.2 Legal rights covered. This License covers the legal rights in the
+Database, including:
+
+ a. Copyright. Any copyright or neighbouring rights in the Database.
+ The copyright licensed includes any individual elements of the
+ Database, but does not cover the copyright over the Contents
+ independent of this Database. See Section 2.4 for details. Copyright
+ law varies between jurisdictions, but is likely to cover: the Database
+ model or schema, which is the structure, arrangement, and organisation
+ of the Database, and can also include the Database tables and table
+ indexes; the data entry and output sheets; and the Field names of
+ Contents stored in the Database;
+
+ b. Database Rights. Database Rights only extend to the Extraction and
+ Re-utilisation of the whole or a Substantial part of the Contents.
+ Database Rights can apply even when there is no copyright over the
+ Database. Database Rights can also apply when the Contents are removed
+ from the Database and are selected and arranged in a way that would
+ not infringe any applicable copyright; and
+
+ c. Contract. This is an agreement between You and the Licensor for
+ access to the Database. In return you agree to certain conditions of
+ use on this access as outlined in this License.
+
+2.3 Rights not covered.
+
+ a. This License does not apply to computer programs used in the making
+ or operation of the Database;
+
+ b. This License does not cover any patents over the Contents or the
+ Database; and
+
+ c. This License does not cover any trademarks associated with the
+ Database.
+
+2.4 Relationship to Contents in the Database. The individual items of
+the Contents contained in this Database may be covered by other rights,
+including copyright, patent, data protection, privacy, or personality
+rights, and this License does not cover any rights (other than Database
+Rights or in contract) in individual Contents contained in the Database.
+For example, if used on a Database of images (the Contents), this
+License would not apply to copyright over individual images, which could
+have their own separate licenses, or one single license covering all of
+the rights over the images.
+
+### 3.0 Rights granted
+
+3.1 Subject to the terms and conditions of this License, the Licensor
+grants to You a worldwide, royalty-free, non-exclusive, terminable (but
+only under Section 9) license to Use the Database for the duration of
+any applicable copyright and Database Rights. These rights explicitly
+include commercial use, and do not exclude any field of endeavour. To
+the extent possible in the relevant jurisdiction, these rights may be
+exercised in all media and formats whether now known or created in the
+future.
+
+The rights granted cover, for example:
+
+ a. Extraction and Re-utilisation of the whole or a Substantial part of
+ the Contents;
+
+ b. Creation of Derivative Databases;
+
+ c. Creation of Collective Databases;
+
+ d. Creation of temporary or permanent reproductions by any means and
+ in any form, in whole or in part, including of any Derivative
+ Databases or as a part of Collective Databases; and
+
+ e. Distribution, communication, display, lending, making available, or
+ performance to the public by any means and in any form, in whole or in
+ part, including of any Derivative Database or as a part of Collective
+ Databases.
+
+3.2 Compulsory license schemes. For the avoidance of doubt:
+
+ a. Non-waivable compulsory license schemes. In those jurisdictions in
+ which the right to collect royalties through any statutory or
+ compulsory licensing scheme cannot be waived, the Licensor reserves
+ the exclusive right to collect such royalties for any exercise by You
+ of the rights granted under this License;
+
+ b. Waivable compulsory license schemes. In those jurisdictions in
+ which the right to collect royalties through any statutory or
+ compulsory licensing scheme can be waived, the Licensor waives the
+ exclusive right to collect such royalties for any exercise by You of
+ the rights granted under this License; and,
+
+ c. Voluntary license schemes. The Licensor waives the right to collect
+ royalties, whether individually or, in the event that the Licensor is
+ a member of a collecting society that administers voluntary licensing
+ schemes, via that society, from any exercise by You of the rights
+ granted under this License.
+
+3.3 The right to release the Database under different terms, or to stop
+distributing or making available the Database, is reserved. Note that
+this Database may be multiple-licensed, and so You may have the choice
+of using alternative licenses for this Database. Subject to Section
+10.4, all other rights not expressly granted by Licensor are reserved.
+
+### 4.0 Conditions of Use
+
+4.1 The rights granted in Section 3 above are expressly made subject to
+Your complying with the following conditions of use. These are important
+conditions of this License, and if You fail to follow them, You will be
+in material breach of its terms.
+
+4.2 Notices. If You Publicly Convey this Database, any Derivative
+Database, or the Database as part of a Collective Database, then You
+must:
+
+ a. Do so only under the terms of this License or another license
+ permitted under Section 4.4;
+
+ b. Include a copy of this License (or, as applicable, a license
+ permitted under Section 4.4) or its Uniform Resource Identifier (URI)
+ with the Database or Derivative Database, including both in the
+ Database or Derivative Database and in any relevant documentation; and
+
+ c. Keep intact any copyright or Database Right notices and notices
+ that refer to this License.
+
+ d. If it is not possible to put the required notices in a particular
+ file due to its structure, then You must include the notices in a
+ location (such as a relevant directory) where users would be likely to
+ look for it.
+
+4.3 Notice for using output (Contents). Creating and Using a Produced
+Work does not require the notice in Section 4.2. However, if you
+Publicly Use a Produced Work, You must include a notice associated with
+the Produced Work reasonably calculated to make any Person that uses,
+views, accesses, interacts with, or is otherwise exposed to the Produced
+Work aware that Content was obtained from the Database, Derivative
+Database, or the Database as part of a Collective Database, and that it
+is available under this License.
+
+ a. Example notice. The following text will satisfy notice under
+ Section 4.3:
+
+ Contains information from DATABASE NAME, which is made available
+ here under the Open Database License (ODbL).
+
+DATABASE NAME should be replaced with the name of the Database and a
+hyperlink to the URI of the Database. "Open Database License" should
+contain a hyperlink to the URI of the text of this License. If
+hyperlinks are not possible, You should include the plain text of the
+required URI's with the above notice.
+
+4.4 Share alike.
+
+ a. Any Derivative Database that You Publicly Use must be only under
+ the terms of:
+
+ i. This License;
+
+ ii. A later version of this License similar in spirit to this
+ License; or
+
+ iii. A compatible license.
+
+ If You license the Derivative Database under one of the licenses
+ mentioned in (iii), You must comply with the terms of that license.
+
+ b. For the avoidance of doubt, Extraction or Re-utilisation of the
+ whole or a Substantial part of the Contents into a new database is a
+ Derivative Database and must comply with Section 4.4.
+
+ c. Derivative Databases and Produced Works. A Derivative Database is
+ Publicly Used and so must comply with Section 4.4. if a Produced Work
+ created from the Derivative Database is Publicly Used.
+
+ d. Share Alike and additional Contents. For the avoidance of doubt,
+ You must not add Contents to Derivative Databases under Section 4.4 a
+ that are incompatible with the rights granted under this License.
+
+ e. Compatible licenses. Licensors may authorise a proxy to determine
+ compatible licenses under Section 4.4 a iii. If they do so, the
+ authorised proxy's public statement of acceptance of a compatible
+ license grants You permission to use the compatible license.
+
+
+4.5 Limits of Share Alike. The requirements of Section 4.4 do not apply
+in the following:
+
+ a. For the avoidance of doubt, You are not required to license
+ Collective Databases under this License if You incorporate this
+ Database or a Derivative Database in the collection, but this License
+ still applies to this Database or a Derivative Database as a part of
+ the Collective Database;
+
+ b. Using this Database, a Derivative Database, or this Database as
+ part of a Collective Database to create a Produced Work does not
+ create a Derivative Database for purposes of Section 4.4; and
+
+ c. Use of a Derivative Database internally within an organisation is
+ not to the public and therefore does not fall under the requirements
+ of Section 4.4.
+
+4.6 Access to Derivative Databases. If You Publicly Use a Derivative
+Database or a Produced Work from a Derivative Database, You must also
+offer to recipients of the Derivative Database or Produced Work a copy
+in a machine readable form of:
+
+ a. The entire Derivative Database; or
+
+ b. A file containing all of the alterations made to the Database or
+ the method of making the alterations to the Database (such as an
+ algorithm), including any additional Contents, that make up all the
+ differences between the Database and the Derivative Database.
+
+The Derivative Database (under a.) or alteration file (under b.) must be
+available at no more than a reasonable production cost for physical
+distributions and free of charge if distributed over the internet.
+
+4.7 Technological measures and additional terms
+
+ a. This License does not allow You to impose (except subject to
+ Section 4.7 b.) any terms or any technological measures on the
+ Database, a Derivative Database, or the whole or a Substantial part of
+ the Contents that alter or restrict the terms of this License, or any
+ rights granted under it, or have the effect or intent of restricting
+ the ability of any person to exercise those rights.
+
+ b. Parallel distribution. You may impose terms or technological
+ measures on the Database, a Derivative Database, or the whole or a
+ Substantial part of the Contents (a "Restricted Database") in
+ contravention of Section 4.74 a. only if You also make a copy of the
+ Database or a Derivative Database available to the recipient of the
+ Restricted Database:
+
+ i. That is available without additional fee;
+
+ ii. That is available in a medium that does not alter or restrict
+ the terms of this License, or any rights granted under it, or have
+ the effect or intent of restricting the ability of any person to
+ exercise those rights (an "Unrestricted Database"); and
+
+ iii. The Unrestricted Database is at least as accessible to the
+ recipient as a practical matter as the Restricted Database.
+
+ c. For the avoidance of doubt, You may place this Database or a
+ Derivative Database in an authenticated environment, behind a
+ password, or within a similar access control scheme provided that You
+ do not alter or restrict the terms of this License or any rights
+ granted under it or have the effect or intent of restricting the
+ ability of any person to exercise those rights.
+
+4.8 Licensing of others. You may not sublicense the Database. Each time
+You communicate the Database, the whole or Substantial part of the
+Contents, or any Derivative Database to anyone else in any way, the
+Licensor offers to the recipient a license to the Database on the same
+terms and conditions as this License. You are not responsible for
+enforcing compliance by third parties with this License, but You may
+enforce any rights that You have over a Derivative Database. You are
+solely responsible for any modifications of a Derivative Database made
+by You or another Person at Your direction. You may not impose any
+further restrictions on the exercise of the rights granted or affirmed
+under this License.
+
+### 5.0 Moral rights
+
+5.1 Moral rights. This section covers moral rights, including any rights
+to be identified as the author of the Database or to object to treatment
+that would otherwise prejudice the author's honour and reputation, or
+any other derogatory treatment:
+
+ a. For jurisdictions allowing waiver of moral rights, Licensor waives
+ all moral rights that Licensor may have in the Database to the fullest
+ extent possible by the law of the relevant jurisdiction under Section
+ 10.4;
+
+ b. If waiver of moral rights under Section 5.1 a in the relevant
+ jurisdiction is not possible, Licensor agrees not to assert any moral
+ rights over the Database and waives all claims in moral rights to the
+ fullest extent possible by the law of the relevant jurisdiction under
+ Section 10.4; and
+
+ c. For jurisdictions not allowing waiver or an agreement not to assert
+ moral rights under Section 5.1 a and b, the author may retain their
+ moral rights over certain aspects of the Database.
+
+Please note that some jurisdictions do not allow for the waiver of moral
+rights, and so moral rights may still subsist over the Database in some
+jurisdictions.
+
+### 6.0 Fair dealing, Database exceptions, and other rights not affected
+
+6.1 This License does not affect any rights that You or anyone else may
+independently have under any applicable law to make any use of this
+Database, including without limitation:
+
+ a. Exceptions to the Database Right including: Extraction of Contents
+ from non-electronic Databases for private purposes, Extraction for
+ purposes of illustration for teaching or scientific research, and
+ Extraction or Re-utilisation for public security or an administrative
+ or judicial procedure.
+
+ b. Fair dealing, fair use, or any other legally recognised limitation
+ or exception to infringement of copyright or other applicable laws.
+
+6.2 This License does not affect any rights of lawful users to Extract
+and Re-utilise insubstantial parts of the Contents, evaluated
+quantitatively or qualitatively, for any purposes whatsoever, including
+creating a Derivative Database (subject to other rights over the
+Contents, see Section 2.4). The repeated and systematic Extraction or
+Re-utilisation of insubstantial parts of the Contents may however amount
+to the Extraction or Re-utilisation of a Substantial part of the
+Contents.
+
+### 7.0 Warranties and Disclaimer
+
+7.1 The Database is licensed by the Licensor "as is" and without any
+warranty of any kind, either express, implied, or arising by statute,
+custom, course of dealing, or trade usage. Licensor specifically
+disclaims any and all implied warranties or conditions of title,
+non-infringement, accuracy or completeness, the presence or absence of
+errors, fitness for a particular purpose, merchantability, or otherwise.
+Some jurisdictions do not allow the exclusion of implied warranties, so
+this exclusion may not apply to You.
+
+### 8.0 Limitation of liability
+
+8.1 Subject to any liability that may not be excluded or limited by law,
+the Licensor is not liable for, and expressly excludes, all liability
+for loss or damage however and whenever caused to anyone by any use
+under this License, whether by You or by anyone else, and whether caused
+by any fault on the part of the Licensor or not. This exclusion of
+liability includes, but is not limited to, any special, incidental,
+consequential, punitive, or exemplary damages such as loss of revenue,
+data, anticipated profits, and lost business. This exclusion applies
+even if the Licensor has been advised of the possibility of such
+damages.
+
+8.2 If liability may not be excluded by law, it is limited to actual and
+direct financial loss to the extent it is caused by proved negligence on
+the part of the Licensor.
+
+### 9.0 Termination of Your rights under this License
+
+9.1 Any breach by You of the terms and conditions of this License
+automatically terminates this License with immediate effect and without
+notice to You. For the avoidance of doubt, Persons who have received the
+Database, the whole or a Substantial part of the Contents, Derivative
+Databases, or the Database as part of a Collective Database from You
+under this License will not have their licenses terminated provided
+their use is in full compliance with this License or a license granted
+under Section 4.8 of this License. Sections 1, 2, 7, 8, 9 and 10 will
+survive any termination of this License.
+
+9.2 If You are not in breach of the terms of this License, the Licensor
+will not terminate Your rights under it.
+
+9.3 Unless terminated under Section 9.1, this License is granted to You
+for the duration of applicable rights in the Database.
+
+9.4 Reinstatement of rights. If you cease any breach of the terms and
+conditions of this License, then your full rights under this License
+will be reinstated:
+
+ a. Provisionally and subject to permanent termination until the 60th
+ day after cessation of breach;
+
+ b. Permanently on the 60th day after cessation of breach unless
+ otherwise reasonably notified by the Licensor; or
+
+ c. Permanently if reasonably notified by the Licensor of the
+ violation, this is the first time You have received notice of
+ violation of this License from the Licensor, and You cure the
+ violation prior to 30 days after your receipt of the notice.
+
+Persons subject to permanent termination of rights are not eligible to
+be a recipient and receive a license under Section 4.8.
+
+9.5 Notwithstanding the above, Licensor reserves the right to release
+the Database under different license terms or to stop distributing or
+making available the Database. Releasing the Database under different
+license terms or stopping the distribution of the Database will not
+withdraw this License (or any other license that has been, or is
+required to be, granted under the terms of this License), and this
+License will continue in full force and effect unless terminated as
+stated above.
+
+### 10.0 General
+
+10.1 If any provision of this License is held to be invalid or
+unenforceable, that must not affect the validity or enforceability of
+the remainder of the terms and conditions of this License and each
+remaining provision of this License shall be valid and enforced to the
+fullest extent permitted by law.
+
+10.2 This License is the entire agreement between the parties with
+respect to the rights granted here over the Database. It replaces any
+earlier understandings, agreements or representations with respect to
+the Database.
+
+10.3 If You are in breach of the terms of this License, You will not be
+entitled to rely on the terms of this License or to complain of any
+breach by the Licensor.
+
+10.4 Choice of law. This License takes effect in and will be governed by
+the laws of the relevant jurisdiction in which the License terms are
+sought to be enforced. If the standard suite of rights granted under
+applicable copyright law and Database Rights in the relevant
+jurisdiction includes additional rights not granted under this License,
+these additional rights are granted in this License in order to meet the
+terms of this License.
diff --git a/util/dump/README-img.txt b/util/dump/README-img.txt
new file mode 100644
index 00000000..498c226d
--- /dev/null
+++ b/util/dump/README-img.txt
@@ -0,0 +1,13 @@
+This is an export of all images referenced from the VNDB.org database.
+See https://vndb.org/d14 for more information.
+
+
+License
+=======
+
+This database is made available under the Open Database License [ODbL].
+
+Images in this database are gathered from various online sources and may be
+subject to separate license conditions.
+
+[ODbL]: LICENSE-ODBL.txt; http://opendatacommons.org/licenses/odbl/1.0/
diff --git a/util/dump/README.txt b/util/dump/README.txt
new file mode 100644
index 00000000..4d392378
--- /dev/null
+++ b/util/dump/README.txt
@@ -0,0 +1,53 @@
+This is an export of the VNDB.org database.
+See https://vndb.org/d14 for more information.
+
+
+Usage
+=====
+
+Each table is exported as a separate file in PostgreSQL's COPY format. This
+format is pretty easy to parse - it's TSV with some escape sequences.
+
+Alternatively, a script is provided to load the data into a PostgreSQL
+database for easy querying. See import.sql for options and usage information.
+
+Note that the included database schema is *not* compatible with the VNDB
+source code, Importing the data into a compatible schema should be possible,
+but this will require some additional scripting.
+
+
+Privacy
+=======
+
+This database dump contains user-contributed information, including personal
+visual novel lists and votes. Users have the option to have their data
+excluded from this dump, but that option is obviously unable to retroactively
+delete data from older dumps. If you republish any data contained in this
+dump, please make sure to synchronise regularly and remove data that is not
+present anymore.
+
+
+License
+=======
+
+This database is made available under the Open Database License [ODbL].
+Any rights in individual contents of the database are licensed under the
+Database Contents License [DbCL].
+
+With the following exceptions:
+
+Wikidata information (db/wikidata) has been obtained from Wikidata and is
+made available under the Creative Commons CC0 License [CC0].
+
+Anime data (db/anime) is obtained from the AniDB.net UDP API and is licensed
+under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0
+[CC-BY-NC-SA].
+
+Visual novel descriptions (db/vn, 'desc' column) and character descriptions
+(db/chars, 'desc' column) are gathered from various online sources and may be
+subject to separate license conditions.
+
+[ODbL]: LICENSE-ODBL.txt; http://opendatacommons.org/licenses/odbl/1.0/
+[DbCL]: LICENSE-DBCL.txt; http://opendatacommons.org/licenses/dbcl/1.0/
+[CC0]: LICENSE-CC0.txt; https://creativecommons.org/publicdomain/zero/1.0/
+[CC-BY-NC-SA]: LICENSE-CC-BY-NC-SA.txt; https://creativecommons.org/licenses/by-nc-sa/4.0/
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 828d090f..3825e860 100755
--- a/util/jsgen.pl
+++ b/util/jsgen.pl
@@ -1,105 +1,69 @@
#!/usr/bin/perl
-package VNDB;
-
-use strict;
-use warnings;
-use Encode 'encode_utf8';
use Cwd 'abs_path';
-use JSON::XS;
-
-our($ROOT, %S, %O);
+our $ROOT;
BEGIN { ($ROOT = abs_path $0) =~ s{/util/jsgen\.pl$}{}; }
-require $ROOT.'/data/global.pl';
-
-# screen resolution information, suitable for usage in filFSelect()
-sub resolutions {
- my $cat = '';
- my @r;
- my $push = \@r;
- for my $i (keys %{$S{resolutions}}) {
- my $r = $S{resolutions}{$i};
- if($cat ne $r->[1]) {
- push @r, [$r->[1]];
- $cat = $r->[1];
- $push = $r[$#r];
- }
- push @$push, [$i, $r->[0]];
- }
- \@r
+use lib "$ROOT/lib";
+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;
+
+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 => $S{rlist_status},
- cookie_prefix => $O{cookie_prefix},
- age_ratings => [ map [ $_, $_ == -1 ? 'Unknown' : $_ == 0 ? 'All ages' : "$_+" ], @{$S{age_ratings}} ],
- languages => [ map [ $_, $S{languages}{$_} ], keys %{$S{languages}} ],
- platforms => [ map [ $_, $S{platforms}{$_} ], keys %{$S{platforms}} ],
- char_roles => [ map [ $_, $S{char_roles}{$_}[0] ], keys %{$S{char_roles}} ],
- media => [ map [ $_, $S{media}{$_}[1], $S{media}{$_}[0] ], keys %{$S{media}} ],
- release_types => [ map [ $_, ucfirst $_ ], @{$S{release_types}} ],
- animated => [ map [ $_, $S{animated}[$_] ], 0..$#{$S{animated}} ],
- voiced => [ map [ $_, $S{voiced}[$_] ], 0..$#{$S{voiced}} ],
- vn_lengths => [ map [ $_, $S{vn_lengths}[$_][0] ], 0..$#{$S{vn_lengths}} ],
- blood_types => [ map [ $_, $S{blood_types}{$_} ], keys %{$S{blood_types}} ],
- genders => [ map [ $_, $S{genders}{$_} ], keys %{$S{genders}} ],
- staff_roles => [ map [ $_, $S{staff_roles}{$_} ], keys %{$S{staff_roles}} ],
- 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) = @_;
- my $content = encode_utf8($body);
-
- unlink "$f~";
- if(!$VNDB::JSGEN{compress}) {
- open my $F, '>', "$f~" or die $!;
- print $F $content;
- close $F;
-
- } elsif($VNDB::JSGEN{compress} eq 'JavaScript::Minifier::XS') {
- require JavaScript::Minifier::XS;
- open my $F, '>', "$f~" or die $!;
- print $F JavaScript::Minifier::XS::minify($content);
- close $F;
-
- } elsif($VNDB::JSGEN{compress} =~ /^\|/) { # External command
- (my $cmd = $VNDB::JSGEN{compress}) =~ s/^\|//;
- open my $C, '|-', "$cmd >'$f~'" or die $!;
- print $C $content;
- close $C or die $!;
-
- } else {
- die "Unrecognized compression option: '$VNDB::JSGEN{compress}'\n";
- }
-
- rename "$f~", $f or die $!;
-
- if($VNDB::JSGEN{gzip}) {
- `$VNDB::JSGEN{gzip} -c '$f' >'$f.gz~'`;
- rename "$f.gz~", "$f.gz";
- }
+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 010951a2..6dc3cf5c 100755
--- a/util/multi.pl
+++ b/util/multi.pl
@@ -1,24 +1,15 @@
#!/usr/bin/perl
-
-#
-# Multi - core namespace for initialisation and global variables
-#
-
-package Multi;
-
use strict;
use warnings;
use Cwd 'abs_path';
+my $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/multi\.pl$}{} }
-our $ROOT;
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/multi\.pl$}{}; *VNDB::ROOT = \$ROOT }
-use lib $VNDB::ROOT.'/lib';
-
+use lib $ROOT.'/lib';
use Multi::Core;
-require $VNDB::ROOT.'/data/global.pl';
-
-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 6648d6b6..00000000
--- a/util/skingen.pl
+++ /dev/null
@@ -1,96 +0,0 @@
-#!/usr/bin/perl
-
-
-package VNDB;
-
-use strict;
-use warnings;
-use Cwd 'abs_path';
-use Image::Magick;
-eval { require CSS::Minifier::XS };
-
-our($ROOT, %S);
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/skingen\.pl$}{}; }
-
-use lib "$ROOT/lib";
-use SkinFile;
-
-require $ROOT.'/data/global.pl';
-
-
-my $iconcss = do {
- open my $F, '<', "$ROOT/data/icons/icons.css" or die $!;
- local $/=undef;
- <$F>;
-};
-
-
-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 $img = Image::Magick->new;
- $img->Read("$ROOT/static$path");
- $o{_bgright} = sprintf 'background: url(%s?%s) no-repeat; width: %dpx; height: %dpx',
- $path, $S{version}, $img->Get('width'), $img->Get('height');
- } else {
- $o{_bgright} = 'display: none';
- }
-
- # body background
- if($o{imglefttop}) {
- $o{_bodybg} = sprintf 'background: %s url(/s/%s/%s?%s) no-repeat', $o{bodybg}, $name, $o{imglefttop}, $S{version};
- } else {
- $o{_bodybg} = sprintf 'background-color: %s', $o{bodybg};
- }
-
- # main title
- $o{_maintitle} = $o{maintitle} ? "color: ".$o{maintitle} : 'display: none';
-
- # create boxbg.png
- my $img = Image::Magick->new(size => '1x1');
- $img->Read("xc:$o{boxbg}");
- $img->Write(filename => "$ROOT/static/s/$name/boxbg.png");
- $o{_boxbg} = "/s/$name/boxbg.png?$S{version}";
-
- # get the blend color
- $img = Image::Magick->new(size => '1x1');
- $img->Read("xc:$o{bodybg}", "xc:$o{boxbg}");
- $img = $img->Flatten();
- $o{_blendbg} = '#'.join '', map sprintf('%02x', $_*255), $img->GetPixel(x=>1,y=>1);
-
- # version
- $o{version} = $S{version};
-
- # write the CSS
- open my $CSS, '<', "$ROOT/data/style.css" or die $!;
- my $css = join '', <$CSS>;
- close $CSS;
- $css =~ s/\$$_\$/$o{$_}/g for (keys %o);
-
- my $f = "$ROOT/static/s/$name/style.css";
- open my $SKIN, '>', "$f~" or die $!;
- print $SKIN $CSS::Minifier::XS::VERSION ? CSS::Minifier::XS::minify($css) : $css;
- close $SKIN;
-
- rename "$f~", $f;
-
- if($VNDB::SKINGEN{gzip}) {
- `$VNDB::SKINGEN{gzip} -c '$f' >'$f.gz~'`;
- rename "$f.gz~", "$f.gz";
- }
-}
-
-
-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 e8b60017..00000000
--- a/util/spritegen.pl
+++ /dev/null
@@ -1,145 +0,0 @@
-#!/usr/bin/perl
-
-package VNDB;
-
-use strict;
-use warnings;
-use Image::Magick;
-use Cwd 'abs_path';
-
-our $ROOT;
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/spritegen\.pl$}{}; }
-require $ROOT.'/data/global.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);
- # Optimize for file size rather than pixel count if slow is set
- my $size = $VNDB::SPRITEGEN{slow} ? img($w, $h) : $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;
-
- if($VNDB::SPRITEGEN{crush}) {
- `$VNDB::SPRITEGEN{crush} "$ticons" "$ticons~"`;
- rename "$ticons~", $ticons or die $!;
- }
-
- 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 0e79379f..00000000
--- a/util/sql/all.sql
+++ /dev/null
@@ -1,60 +0,0 @@
--- NOTE: Make sure you're cd'ed in the vndb root directory before running this script
-
-
--- 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 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', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'nl', 'no', 'pl', 'pt-pt', 'pt-br', 'ro', 'ru', 'sk', '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 prefs_key AS ENUM ('l10n', 'skin', 'customcss', 'filter_vn', 'filter_release', 'show_nsfw', 'hide_list', 'notify_nodbedit', 'notify_announce', 'vn_list_own', 'vn_list_wish', 'tags_all', 'tags_cat', 'spoilers', 'traits_sexual');
-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');
-
-
--- schema
-
-\i util/sql/schema.sql
-
-
--- functions
-
-\i util/sql/func.sql
-
--- auto-generated editing functions
-
-\i util/sql/editfunc.sql
-
--- constraints & indices
-
-\i util/sql/tableattrs.sql
-
--- triggers
-
-\i util/sql/triggers.sql
-
--- Sequences used for ID generation of items not in the DB
-CREATE SEQUENCE covers_seq;
-CREATE SEQUENCE charimg_seq;
-
--- permissions
-
-\i util/sql/perms.sql
-
-
--- Rows that are assumed to be available
-
-\i util/sql/data.sql
diff --git a/util/sql/data.sql b/util/sql/data.sql
deleted file mode 100644
index b921899b..00000000
--- a/util/sql/data.sql
+++ /dev/null
@@ -1,17 +0,0 @@
-INSERT INTO users (id, username, mail, perm) VALUES (0, 'deleted', 'del@vndb.org', 0);
-INSERT INTO users (id, username, mail, perm) VALUES (1, 'multi', 'multi@vndb.org', 0);
-INSERT INTO users_prefs (uid, key, value) VALUES (0, 'notify_nodbedit', '1');
-INSERT INTO users_prefs (uid, key, value) VALUES (1, 'notify_nodbedit', '1');
-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 93025dfe..00000000
--- a/util/sql/func.sql
+++ /dev/null
@@ -1,808 +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;
-
-
-
--- recalculate vn.c_popularity
-CREATE OR REPLACE FUNCTION update_vnpopularity() RETURNS void AS $$
-BEGIN
- -- the following queries only update rows with popularity > 0, so make sure to reset all rows first
- UPDATE vn SET c_popularity = NULL;
- CREATE OR REPLACE TEMP VIEW tmp_pop1 (uid, vid, rank) AS
- SELECT v.uid, v.vid, count(*)::real ^ 0.36788
- FROM votes v
- JOIN votes v2 ON v.uid = v2.uid AND v2.vote < v.vote
- JOIN users u ON u.id = v.uid AND NOT ign_votes
- GROUP BY v.vid, v.uid;
- CREATE OR REPLACE TEMP VIEW tmp_pop2 (vid, win) AS
- SELECT vid, sum(rank) FROM tmp_pop1 GROUP BY vid;
- UPDATE vn SET c_popularity = s1.win/(SELECT MAX(win) FROM tmp_pop2) FROM tmp_pop2 s1 WHERE s1.vid = vn.id;
- RETURN;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
--- recalculate tags_vn_inherit
-CREATE OR REPLACE FUNCTION tag_vn_calc() RETURNS void AS $$
-BEGIN
- DROP INDEX IF EXISTS tags_vn_inherit_tag_vid;
- TRUNCATE tags_vn_inherit;
- -- populate tags_vn_inherit
- INSERT INTO tags_vn_inherit
- -- All votes for all tags, including votes inherited by child tags if the
- -- parent tag itself does not have any votes.
- -- (also includes non-searchable tags, because they could have a searchable tag as parent)
- WITH RECURSIVE tags_vn_all(lvl, tag, vid, uid, vote, spoiler, defaultspoil, searchable) AS (
- SELECT 15, tv.tag, tv.vid, tv.uid, tv.vote, tv.spoiler, t.defaultspoil, true
- FROM tags_vn tv
- JOIN tags t ON t.id = tv.tag
- WHERE NOT tv.ignore
- UNION ALL
- SELECT lvl-1, tp.parent, ta.vid, ta.uid, ta.vote, ta.spoiler, t.defaultspoil, t.searchable
- FROM tags_vn_all ta
- JOIN tags_parents tp ON tp.tag = ta.tag
- JOIN tags t ON t.id = tp.parent
- WHERE t.state = 2
- AND ta.lvl > 0
- AND NOT EXISTS(SELECT 1 FROM tags_vn tv WHERE tv.tag = tp.parent AND tv.vid = ta.vid)
- )
- -- grouped by (tag, vid)
- SELECT tag, vid, COUNT(uid) AS users, AVG(vote)::real AS rating,
- (CASE WHEN COUNT(spoiler) = 0 THEN defaultspoil
- WHEN AVG(spoiler) > 1.3 THEN 2
- WHEN AVG(spoiler) > 0.4 THEN 1 ELSE 0
- END)::smallint AS spoiler
- FROM (
- -- grouped by (tag, vid, uid), so only one user votes on one parent tag per VN entry (also removing unsearchable tags)
- SELECT tag, vid, uid, MAX(vote)::real, AVG(spoiler)::real, defaultspoil
- FROM tags_vn_all
- WHERE searchable
- GROUP BY tag, vid, uid, defaultspoil
- ) AS t(tag, vid, uid, vote, spoiler, defaultspoil)
- GROUP BY tag, vid, defaultspoil
- HAVING AVG(vote) > 0;
- -- recreate index
- CREATE INDEX tags_vn_inherit_tag_vid ON tags_vn_inherit (tag, vid);
- -- and update the VN count in the tags table
- UPDATE tags SET c_items = (SELECT COUNT(*) FROM tags_vn_inherit WHERE tag = id);
- RETURN;
-END;
-$$ LANGUAGE plpgsql SECURITY DEFINER;
-
-
--- recalculate traits_chars
-CREATE OR REPLACE FUNCTION traits_chars_calc() RETURNS void AS $$
-BEGIN
- TRUNCATE traits_chars;
- INSERT INTO traits_chars (tid, cid, spoil)
- -- all char<->trait links of the latest revisions, including chars
- -- inherited from child traits if the parent trait was not mentioned
- -- directly.
- -- (also includes non-searchable traits, because they could have a searchable trait as parent)
- WITH RECURSIVE traits_chars_all(lvl, tid, cid, spoiler, searchable) AS (
- SELECT 15, tid, ct.id, spoil, true
- FROM chars_traits ct
- JOIN chars c ON c.id = ct.id
- WHERE NOT c.hidden
- UNION ALL
- SELECT lvl-1, tp.parent, tc.cid, tc.spoiler, t.searchable
- 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
- AND NOT EXISTS(SELECT 1 FROM chars_traits cti WHERE cti.tid = tp.parent AND cti.id = tc.cid)
- )
- -- now grouped by (tid, cid) and with non-searchable traits filtered out
- SELECT tid, cid, (CASE WHEN AVG(spoiler) > 1.3 THEN 2 WHEN AVG(spoiler) > 0.7 THEN 1 ELSE 0 END)::smallint AS spoiler
- FROM traits_chars_all
- WHERE searchable
- GROUP BY tid, cid;
- -- and update the VN count in the tags table
- UPDATE traits SET c_items = (SELECT COUNT(*) FROM traits_chars WHERE tid = id);
- RETURN;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
-
-
-
-----------------------------------------------------------
--- 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 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;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
-----------------------------------------------------------
--- trigger functions --
-----------------------------------------------------------
-
-
--- keep the c_* columns in the users table up to date
-CREATE OR REPLACE FUNCTION update_users_cache() RETURNS TRIGGER AS $$
-BEGIN
- IF TG_TABLE_NAME = 'votes' THEN
- IF TG_OP = 'INSERT' THEN
- UPDATE users SET c_votes = c_votes + 1 WHERE id = NEW.uid;
- ELSE
- UPDATE users SET c_votes = c_votes - 1 WHERE id = OLD.uid;
- END IF;
- ELSIF TG_TABLE_NAME = 'changes' THEN
- IF TG_OP = 'INSERT' THEN
- UPDATE users SET c_changes = c_changes + 1 WHERE id = NEW.requester;
- ELSE
- UPDATE users SET c_changes = c_changes - 1 WHERE id = OLD.requester;
- END IF;
- ELSIF TG_TABLE_NAME = 'tags_vn' THEN
- IF TG_OP = 'INSERT' THEN
- UPDATE users SET c_tags = c_tags + 1 WHERE id = NEW.uid;
- ELSE
- UPDATE users SET c_tags = c_tags - 1 WHERE id = OLD.uid;
- END IF;
- END IF;
- RETURN NULL;
-END;
-$$ LANGUAGE 'plpgsql';
-
-
-
--- 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;
-
- 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;
- 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;
- 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;
-$$ LANGUAGE 'plpgsql';
-
-
-
--- insert rows into anime for new vn_anime.aid items
--- (this is a BEFORE trigger)
-CREATE OR REPLACE FUNCTION vn_anime_aid() RETURNS trigger AS $$
-BEGIN
- IF NOT EXISTS(SELECT 1 FROM anime WHERE id = NEW.aid) THEN
- INSERT INTO anime (id) VALUES (NEW.aid);
- END IF;
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
--- For each row in rlists, there should be at least one corresponding row in
--- vnlists for at least one of the VNs linked to that release.
--- 1. When a row is deleted from vnlists, also remove all rows from rlists that
--- would otherwise not have a corresponding row in vnlists
--- 2. When a row is inserted to rlists and there is not yet a corresponding row
--- in vnlists, add a row in vnlists (with status=unknown) for each vn linked
--- to the release.
-CREATE OR REPLACE FUNCTION update_vnlist_rlist() RETURNS trigger AS $$
-BEGIN
- -- 1.
- IF TG_TABLE_NAME = 'vnlists' THEN
- DELETE FROM rlists WHERE uid = OLD.uid AND rid IN(SELECT rv.id
- -- fetch all related rows in rlists
- FROM releases_vn rv
- JOIN rlists rl ON rl.rid = rv.id
- WHERE rv.vid = OLD.vid AND rl.uid = OLD.uid
- -- and test for a corresponding row in vnlists
- AND NOT EXISTS(
- SELECT 1
- FROM releases_vn rvi
- JOIN vnlists vl ON vl.vid = rvi.vid AND uid = OLD.uid
- WHERE rvi.id = rv.id
- ));
-
- -- 2.
- ELSE
- INSERT INTO vnlists (uid, vid) SELECT NEW.uid, rv.vid
- -- all VNs linked to the release
- FROM releases_vn rv
- WHERE rv.id = NEW.rid
- -- but only if there are no corresponding rows in vnlists yet
- AND NOT EXISTS(
- SELECT 1
- FROM releases_vn rvi
- JOIN vnlists vl ON vl.vid = rvi.vid
- WHERE rvi.id = NEW.rid AND vl.uid = NEW.uid
- );
- END IF;
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
--- Send a notify whenever anime info should be fetched
-CREATE OR REPLACE FUNCTION anime_fetch_notify() RETURNS trigger AS $$
- BEGIN NOTIFY anime; RETURN NULL; END;
-$$ LANGUAGE plpgsql;
-
-
-
--- 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
-CREATE OR REPLACE FUNCTION vn_relgraph_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;
- END IF;
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-
-
--- Send a notify when producers.rgraph is set to NULL, and there are related entries in producers_relations
-CREATE OR REPLACE FUNCTION producer_relgraph_notify() RETURNS trigger AS $$
-BEGIN
- IF EXISTS(SELECT 1 FROM producers_relations WHERE id = NEW.id) THEN
- NOTIFY relgraph;
- END IF;
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
--- NOTIFY on insert into changes/posts/tags/trait
-CREATE OR REPLACE FUNCTION insert_notify() 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;
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
--- Send a vnsearch notification when the c_search column is set to NULL.
-CREATE OR REPLACE FUNCTION vn_vnsearch_notify() RETURNS trigger AS $$
- BEGIN NOTIFY vnsearch; RETURN NULL; END;
-$$ LANGUAGE plpgsql;
-
-
-
-
-----------------------------------------------------------
--- notification functions --
-----------------------------------------------------------
-
-
--- called on INSERT INTO threads_posts
-CREATE OR REPLACE FUNCTION notify_pm() 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
- );
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
--- called when an entry has been deleted
-CREATE OR REPLACE FUNCTION notify_dbdel(xtype dbentry_type, xedit edit_rettype) RETURNS void AS $$
- INSERT INTO notifications (ntype, 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
- -- exclude users who don't want this notify
- AND NOT EXISTS(SELECT 1 FROM users_prefs up WHERE uid = h.requester AND key = 'notify_nodbedit');
-$$ 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 votes WHERE xtype = 'v' AND vid = xedit.itemid
- UNION SELECT uid FROM vnlists WHERE xtype = 'v' AND vid = xedit.itemid
- UNION SELECT uid FROM wlists 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;
-
-
--- called on INSERT INTO threads_posts when (NEW.num = 1)
-CREATE OR REPLACE FUNCTION notify_announce() RETURNS trigger AS $$
-BEGIN
- INSERT INTO notifications (ntype, ltype, uid, iid, subid, c_title, c_byuser)
- SELECT 'announce', 't', up.uid, 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_prefs up ON up.key = 'notify_announce'
- WHERE t.id = NEW.tid
- AND tb.type = 'an' -- announcement board
- AND NOT t.hidden;
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
-
-----------------------------------------------------------
--- 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 session for this user (uid, scryptpass, token)
-CREATE OR REPLACE FUNCTION user_login(integer, bytea, bytea) RETURNS boolean AS $$
- INSERT INTO sessions (uid, token) SELECT $1, $3 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
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
-CREATE OR REPLACE FUNCTION user_update_lastused(integer, bytea) RETURNS void AS $$
- UPDATE sessions SET lastused = NOW() WHERE uid = $1 AND token = $2
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
-CREATE OR REPLACE FUNCTION user_isloggedin(integer, bytea) RETURNS timestamptz AS $$
- SELECT lastused FROM sessions WHERE uid = $1 AND token = $2
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
-CREATE OR REPLACE FUNCTION user_emailexists(text) RETURNS boolean AS $$
- SELECT true FROM users WHERE lower(mail) = lower($1) LIMIT 1
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
-CREATE OR REPLACE FUNCTION user_isvalidtoken(integer, bytea) RETURNS boolean AS $$
- SELECT true FROM users WHERE id = $1 AND passwd = $2 AND length($2) = 20
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
--- Replace password with a 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 $$
- UPDATE users SET passwd = $2 WHERE lower(mail) = lower($1) AND length($2) = 20 AND perm & 128 = 0 RETURNING id;
-$$ LANGUAGE SQL SECURITY DEFINER;
-
-
--- Changes the user's password and invalidates all existing sessions. args: uid, old_pass_or_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 passwd = $2 AND length($2) IN(20,46) AND length($3) = 46 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 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 ($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;
-
-
-CREATE OR REPLACE FUNCTION user_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;
-
-
-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;
diff --git a/util/sql/perms.sql b/util/sql/perms.sql
deleted file mode 100644
index 5b643ed1..00000000
--- a/util/sql/perms.sql
+++ /dev/null
@@ -1,154 +0,0 @@
--- vndb_site
-
-DROP OWNED BY vndb_site;
-GRANT CONNECT, TEMP ON DATABASE :DBNAME 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 SELECT, INSERT, UPDATE, DELETE ON affiliate_links TO vndb_site;
-GRANT SELECT, INSERT ON anime TO vndb_site;
-GRANT SELECT, INSERT ON changes TO vndb_site;
-GRANT SELECT, INSERT, UPDATE ON chars 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;
-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, DELETE ON login_throttle TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON notifications TO vndb_site;
-GRANT SELECT, INSERT, UPDATE ON producers TO vndb_site;
-GRANT SELECT, INSERT ON producers_hist TO vndb_site;
-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 ON releases 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_media TO vndb_site;
-GRANT SELECT, INSERT ON releases_media_hist TO vndb_site;
-GRANT SELECT, INSERT, DELETE ON releases_platforms TO vndb_site;
-GRANT SELECT, INSERT ON releases_platforms_hist TO vndb_site;
-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, DELETE ON rlists TO vndb_site;
-GRANT SELECT, INSERT, UPDATE ON screenshots TO vndb_site;
--- No access to the 'sessions' table, managed by the user_* functions.
-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, 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, DELETE ON tags_vn TO vndb_site;
-GRANT SELECT 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 SELECT, INSERT, UPDATE, DELETE ON traits_chars TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON traits_parents 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),
- INSERT (id, username, mail, registered, c_votes, c_changes, ip, c_tags, ign_votes, email_confirmed),
- UPDATE ( username, registered, c_votes, c_changes, ip, c_tags, ign_votes, email_confirmed) ON users TO vndb_site;
-GRANT DELETE ON users TO vndb_site;
-
-GRANT SELECT, INSERT, UPDATE, DELETE ON users_prefs 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 ON vn_hist 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;
-GRANT SELECT, INSERT ON vn_screenshots_hist TO vndb_site;
-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, UPDATE, DELETE ON vnlists TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON votes TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON wlists TO vndb_site;
-
-
-
-
--- vndb_multi
--- (Assuming all modules are loaded)
-
-DROP OWNED BY vndb_multi;
-GRANT CONNECT, TEMP ON DATABASE :DBNAME 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 SELECT, INSERT, UPDATE ON affiliate_links TO vndb_multi;
-GRANT SELECT, UPDATE ON anime TO vndb_multi;
-GRANT SELECT ON changes TO vndb_multi;
-GRANT SELECT ON chars 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, 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 producers_hist TO vndb_multi;
-GRANT SELECT ON producers_relations TO vndb_multi;
-GRANT SELECT ON quotes TO vndb_multi;
-GRANT SELECT ON releases TO vndb_multi;
-GRANT SELECT ON releases_hist TO vndb_multi;
-GRANT SELECT ON releases_lang 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 rlists TO vndb_multi;
-GRANT SELECT ON screenshots TO vndb_multi;
-GRANT SELECT (lastused) ON sessions TO vndb_multi;
-GRANT DELETE ON sessions 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_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 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 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, INSERT, TRUNCATE ON traits_chars TO vndb_multi;
-GRANT SELECT ON traits_parents TO vndb_multi;
-GRANT SELECT (id, username, registered, c_votes, c_changes, c_tags, ign_votes, email_confirmed),
- UPDATE ( c_votes, c_changes, c_tags ) ON users TO vndb_multi;
-GRANT DELETE ON users TO vndb_multi;
-GRANT SELECT ON users_prefs 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 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, INSERT, UPDATE, DELETE ON vnlists TO vndb_multi;
-GRANT SELECT, INSERT, UPDATE, DELETE ON votes TO vndb_multi;
-GRANT SELECT, INSERT, UPDATE, DELETE ON wlists TO vndb_multi;
diff --git a/util/sql/schema.sql b/util/sql/schema.sql
deleted file mode 100644
index 6ef2cd3b..00000000
--- a/util/sql/schema.sql
+++ /dev/null
@@ -1,776 +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 func.sql, these are also responsible for keeping things synchronized.
---
--- Note: Every CREATE TABLE clause and each column should be on a separate
--- line. This file is parsed by util/sqleditfunc.pl, and it doesn't implement a
--- full SQL query parser.
-
-
--- affiliate_links
-CREATE TABLE affiliate_links (
- id SERIAL PRIMARY KEY,
- rid integer NOT NULL,
- hidden boolean NOT NULL DEFAULT false,
- priority smallint NOT NULL DEFAULT 0,
- affiliate smallint NOT NULL DEFAULT 0,
- url varchar NOT NULL,
- version varchar NOT NULL DEFAULT '',
- lastfetch timestamptz,
- price varchar NOT NULL DEFAULT '',
- data varchar NOT NULL DEFAULT ''
-);
-
--- anime
-CREATE TABLE anime (
- id integer NOT NULL PRIMARY KEY,
- year smallint,
- ann_id integer,
- nfo_id varchar(200),
- type anime_type,
- title_romaji varchar(250),
- title_kanji varchar(250),
- 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,
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- 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
-);
-
--- 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
-);
-
--- chars_traits
-CREATE TABLE chars_traits (
- id integer NOT NULL,
- tid integer NOT NULL, -- traits.id
- spoil smallint NOT NULL DEFAULT 0,
- 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,
- vid integer NOT NULL, -- vn.id
- rid integer NULL, -- releases.id
- spoil smallint NOT NULL DEFAULT 0,
- role char_role NOT NULL DEFAULT 'main'
-);
-
--- 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,
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- title varchar(200) NOT NULL DEFAULT '',
- content text NOT NULL DEFAULT ''
-);
-
--- 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,
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- 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),
- rgraph integer -- relgraphs.id
-);
-
--- 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)
-);
-
--- producers_relations
-CREATE TABLE producers_relations (
- id integer NOT NULL,
- pid integer NOT NULL, -- producers.id
- relation producer_relation NOT NULL,
- 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,
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- 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 ''
-);
-
--- 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 ''
-);
-
--- releases_lang
-CREATE TABLE releases_lang (
- id integer NOT NULL,
- lang language NOT NULL,
- 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,
- medium medium NOT NULL,
- qty smallint NOT NULL DEFAULT 1,
- 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,
- platform platform NOT NULL,
- 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,
- pid integer NOT NULL, -- producers.id
- developer boolean NOT NULL DEFAULT FALSE,
- publisher boolean NOT NULL DEFAULT TRUE,
- 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,
- vid integer NOT NULL, -- 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,
- rid integer NOT NULL DEFAULT 0,
- status smallint NOT NULL DEFAULT 0,
- added timestamptz NOT NULL DEFAULT NOW(),
- PRIMARY KEY(uid, rid)
-);
-
--- screenshots
-CREATE TABLE screenshots (
- id SERIAL NOT NULL PRIMARY KEY,
- width smallint NOT NULL DEFAULT 0,
- height smallint NOT NULL DEFAULT 0
-);
-
--- sessions
-CREATE TABLE sessions (
- uid integer NOT NULL,
- token bytea NOT NULL,
- added timestamptz NOT NULL DEFAULT NOW(),
- lastused timestamptz NOT NULL DEFAULT NOW(),
- PRIMARY KEY (uid, token)
-);
-
--- staff
-CREATE TABLE staff ( -- dbentry_type=s
- id SERIAL PRIMARY KEY,
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- aid integer NOT NULL DEFAULT 0, -- staff_alias.aid
- 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
-);
-
--- 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
-);
-
--- staff_alias
-CREATE TABLE staff_alias (
- id integer NOT NULL,
- aid SERIAL PRIMARY KEY, -- Globally unique ID of this alias
- name varchar(200) NOT NULL DEFAULT '',
- original varchar(200) NOT NULL DEFAULT ''
-);
-
--- 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,
- name varchar(250) NOT NULL UNIQUE,
- description text NOT NULL DEFAULT '',
- added timestamptz NOT NULL DEFAULT NOW(),
- state smallint NOT NULL DEFAULT 0,
- c_items integer NOT NULL DEFAULT 0,
- addedby integer NOT NULL DEFAULT 0,
- 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
-);
-
--- tags_aliases
-CREATE TABLE tags_aliases (
- alias varchar(250) NOT NULL PRIMARY KEY,
- tag integer NOT NULL
-);
-
--- tags_parents
-CREATE TABLE tags_parents (
- tag integer NOT NULL,
- parent integer NOT NULL,
- PRIMARY KEY(tag, parent)
-);
-
--- tags_vn
-CREATE TABLE tags_vn (
- tag integer NOT NULL,
- vid integer NOT NULL,
- uid integer NOT NULL,
- vote smallint NOT NULL DEFAULT 3 CHECK (vote >= -3 AND vote <= 3 AND vote <> 0),
- spoiler smallint CHECK(spoiler >= 0 AND spoiler <= 2),
- date timestamptz NOT NULL DEFAULT NOW(),
- ignore boolean NOT NULL DEFAULT false,
- PRIMARY KEY(tag, vid, uid)
-);
-
--- tags_vn_inherit
-CREATE TABLE tags_vn_inherit (
- tag integer NOT NULL,
- vid integer NOT NULL,
- users 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,
- poll_recast boolean NOT NULL DEFAULT FALSE,
- 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,
- 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,
- name varchar(250) NOT NULL,
- alias varchar(500) NOT NULL DEFAULT '',
- description text NOT NULL DEFAULT '',
- added timestamptz NOT NULL DEFAULT NOW(),
- state smallint NOT NULL DEFAULT 0,
- addedby integer NOT NULL DEFAULT 0,
- "group" integer,
- "order" smallint NOT NULL DEFAULT 0,
- sexual boolean NOT NULL DEFAULT false,
- c_items integer NOT NULL DEFAULT 0,
- defaultspoil smallint NOT NULL DEFAULT 0,
- searchable boolean NOT NULL DEFAULT true,
- applicable boolean NOT NULL DEFAULT true
-);
-
--- 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,
- PRIMARY KEY(cid, tid)
-);
-
--- traits_parents
-CREATE TABLE traits_parents (
- trait integer NOT NULL,
- parent integer NOT NULL,
- PRIMARY KEY(trait, parent)
-);
-
--- users
-CREATE TABLE users (
- id SERIAL NOT NULL PRIMARY KEY,
- username varchar(20) NOT NULL UNIQUE,
- mail varchar(100) NOT NULL,
- perm smallint NOT NULL DEFAULT 1+4+16,
- -- Interpretation of the passwd column depends on its length:
- -- * 20 bytes: Password reset token (sha1(lower_hex(20 bytes of random data)))
- -- * 46 bytes: scrypt password
- -- 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: 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,
- email_confirmed boolean NOT NULL DEFAULT FALSE
-);
-
--- users_prefs
-CREATE TABLE users_prefs (
- uid integer NOT NULL,
- key prefs_key NOT NULL,
- value varchar NOT NULL,
- PRIMARY KEY(uid, key)
-);
-
--- vn
-CREATE TABLE vn ( -- dbentry_type=v
- id SERIAL PRIMARY KEY,
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- 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 '',
- 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
-);
-
--- 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 ''
-);
-
--- vn_anime
-CREATE TABLE vn_anime (
- id integer NOT NULL,
- aid integer NOT NULL, -- 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,
- vid integer NOT NULL, -- vn.id
- relation vn_relation NOT NULL,
- official boolean NOT NULL DEFAULT TRUE,
- 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,
- scr integer NOT NULL, -- screenshots.id
- rid integer, -- releases.id (only NULL for old revisions, nowadays not allowed anymore)
- nsfw boolean NOT NULL DEFAULT FALSE,
- 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,
- aid integer NOT NULL, -- staff_alias.aid
- cid integer NOT NULL, -- chars.id
- note varchar(250) NOT NULL DEFAULT '',
- 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,
- aid integer NOT NULL, -- staff_alias.aid
- role credit_type NOT NULL DEFAULT 'staff',
- note varchar(250) NOT NULL DEFAULT '',
- 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)
-);
-
--- vnlists
-CREATE TABLE vnlists (
- uid integer NOT NULL,
- vid integer NOT NULL,
- status smallint NOT NULL DEFAULT 0,
- added TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- notes varchar NOT NULL DEFAULT '',
- PRIMARY KEY(uid, vid)
-);
-
--- votes
-CREATE TABLE votes (
- vid integer NOT NULL DEFAULT 0,
- uid integer NOT NULL DEFAULT 0,
- vote integer NOT NULL DEFAULT 0,
- date timestamptz NOT NULL DEFAULT NOW(),
- PRIMARY KEY(vid, uid)
-);
-
--- wlists
-CREATE TABLE wlists (
- uid integer NOT NULL DEFAULT 0,
- vid integer NOT NULL DEFAULT 0,
- wstat smallint NOT NULL DEFAULT 0,
- added timestamptz NOT NULL DEFAULT NOW(),
- PRIMARY KEY(uid, vid)
-);
diff --git a/util/sql/superuser_init.sql b/util/sql/superuser_init.sql
deleted file mode 100644
index 6e94167c..00000000
--- a/util/sql/superuser_init.sql
+++ /dev/null
@@ -1,15 +0,0 @@
--- This script should be run before all other scripts and as a PostgreSQL
--- superuser. It will create the VNDB database and required users.
--- All other SQL scripts should be run by the 'vndb' user.
-
--- In order to "activate" a user, i.e. to allow login, you need to manually run
--- the following for each user you want to activate:
--- ALTER ROLE rolename LOGIN PASSWORD 'password';
-
-CREATE ROLE vndb;
-CREATE DATABASE vndb OWNER vndb;
-
--- The website
-CREATE ROLE vndb_site;
--- Multi
-CREATE ROLE vndb_multi;
diff --git a/util/sql/tableattrs.sql b/util/sql/tableattrs.sql
deleted file mode 100644
index 62800c17..00000000
--- a/util/sql/tableattrs.sql
+++ /dev/null
@@ -1,120 +0,0 @@
-ALTER TABLE affiliate_links ADD CONSTRAINT affiliate_links_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
-ALTER TABLE changes ADD CONSTRAINT changes_requester_fkey FOREIGN KEY (requester) REFERENCES users (id) ON DELETE SET DEFAULT;
-ALTER TABLE chars ADD CONSTRAINT chars_main_fkey FOREIGN KEY (main) REFERENCES chars (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_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_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 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_hist ADD CONSTRAINT producers_chid_id_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-ALTER TABLE producers_relations ADD CONSTRAINT producers_relations_pid_fkey FOREIGN KEY (pid) REFERENCES producers (id);
-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 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_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);
-ALTER TABLE releases_platforms_hist ADD CONSTRAINT releases_platforms_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-ALTER TABLE releases_producers ADD CONSTRAINT releases_producers_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
-ALTER TABLE releases_producers ADD CONSTRAINT releases_producers_pid_fkey FOREIGN KEY (pid) REFERENCES producers (id);
-ALTER TABLE releases_producers_hist ADD CONSTRAINT releases_producers_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-ALTER TABLE releases_producers_hist ADD CONSTRAINT releases_producers_hist_pid_fkey FOREIGN KEY (pid) REFERENCES producers (id);
-ALTER TABLE releases_vn ADD CONSTRAINT releases_vn_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
-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 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 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_hist ADD CONSTRAINT staff_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-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_parents ADD CONSTRAINT tags_parents_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 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_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 traits_parents ADD CONSTRAINT traits_parents_parent_fkey FOREIGN KEY (parent) REFERENCES traits (id);
-ALTER TABLE users_prefs ADD CONSTRAINT users_prefs_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE vn ADD CONSTRAINT vn_rgraph_fkey FOREIGN KEY (rgraph) REFERENCES relgraphs (id);
-ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-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;
-ALTER TABLE vn_anime_hist ADD CONSTRAINT vn_anime_hist_aid_fkey FOREIGN KEY (aid) REFERENCES anime (id);
-ALTER TABLE vn_relations ADD CONSTRAINT vn_relations_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
-ALTER TABLE vn_relations ADD CONSTRAINT vn_relations_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
-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_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_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_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;
-ALTER TABLE vnlists ADD CONSTRAINT vnlists_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE vnlists ADD CONSTRAINT vnlists_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE votes ADD CONSTRAINT votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE votes ADD CONSTRAINT votes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE wlists ADD CONSTRAINT wlists_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE wlists ADD CONSTRAINT wlists_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
-
-
-CREATE INDEX affiliate_links_rid ON affiliate_links (rid) WHERE NOT hidden;
-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 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); -- Significantly speeds up traits_chars_calc() and possibly other stuff
-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 INDEX votes_date ON votes (date desc); -- Mainly used on /v+ pages, other pages don't really need it
-CREATE INDEX votes_uid ON votes (uid);
-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));
diff --git a/util/sql/triggers.sql b/util/sql/triggers.sql
deleted file mode 100644
index 962610a7..00000000
--- a/util/sql/triggers.sql
+++ /dev/null
@@ -1,48 +0,0 @@
-CREATE TRIGGER users_changes_update AFTER INSERT OR DELETE ON changes FOR EACH ROW EXECUTE PROCEDURE update_users_cache();
-CREATE TRIGGER users_votes_update AFTER INSERT OR DELETE ON votes 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 stats_cache_new AFTER INSERT ON vn FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON vn FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_new AFTER INSERT ON producers FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON producers FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_new AFTER INSERT ON releases FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON releases FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_new AFTER INSERT ON chars FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
-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 vn_anime_aid_new BEFORE INSERT ON vn_anime FOR EACH ROW EXECUTE PROCEDURE vn_anime_aid();
-CREATE TRIGGER vn_anime_aid_edit BEFORE UPDATE ON vn_anime FOR EACH ROW WHEN (OLD.aid IS DISTINCT FROM NEW.aid) EXECUTE PROCEDURE vn_anime_aid();
-
-CREATE TRIGGER anime_fetch_notify AFTER INSERT OR UPDATE ON anime FOR EACH ROW WHEN (NEW.lastfetch IS NULL) EXECUTE PROCEDURE anime_fetch_notify();
-
-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 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 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_pm AFTER INSERT ON threads_posts FOR EACH ROW EXECUTE PROCEDURE notify_pm();
-CREATE TRIGGER notify_announce AFTER INSERT ON threads_posts FOR EACH ROW WHEN (NEW.num = 1) EXECUTE PROCEDURE notify_announce();
-
-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 CONSTRAINT TRIGGER update_vnlist_rlist AFTER DELETE ON vnlists DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE update_vnlist_rlist();
-CREATE CONSTRAINT TRIGGER update_vnlist_rlist AFTER INSERT ON rlists DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE update_vnlist_rlist();
diff --git a/util/sqleditfunc.pl b/util/sqleditfunc.pl
index 773214f9..59558822 100755
--- a/util/sqleditfunc.pl
+++ b/util/sqleditfunc.pl
@@ -1,78 +1,62 @@
#!/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$}{}; }
-my %tabletypes; # table_name => dbentry_type
-my %tables; # table_name => [ column_names ]
-my %items; # item_name => { tables_without_hist => [ data_column_names ] }
-
-
-# Fills %tables
-sub readschema {
- open my $F, '<', "$ROOT/util/sql/schema.sql" or die $!;
- my $table = '';
- while(<$F>) {
- chomp;
- if(/^\s*CREATE\s+TABLE\s+([^ ]+)/) {
- $table = $1;
- $tables{$table} = [];
- $tabletypes{$table} = $1 if /--.*\s+dbentry_type=(.)/;
- } elsif($table && /^\s+("?[^\( ]+"?)\s/ && !/^\s+PRIMARY\s+KEY/) {
- push @{$tables{$table}}, $1;
- }
- }
-}
+use lib "$ROOT/lib";
+use VNDB::Schema;
+my $schema = VNDB::Schema::schema;
+my $template = join '', <DATA>;
sub gensql {
- my($template, $item) = @_;
+ my $item = shift;
# table_name_without_hist => [ column_names_without_chid ]
- my %ts = map +($_, [ grep !/^chid$/, @{$tables{"${_}_hist"}} ]), map /^${item}_/ && /^(.+)_hist$/ ? $1 : (), keys %tables;
+ my %ts = map
+ +($_, [ map $_->{name}, grep $_->{name} !~ /^chid$/, @{$schema->{"${_}_hist"}{cols}} ]),
+ map /^${item}_/ && /^(.+)_hist$/ ? $1 : (), keys %$schema;
+
+ my %replace = ( item => $item, itemtype => $schema->{$item}{dbentry_type} );
- my %replace = ( item => $item, itemtype => $tabletypes{$item} );
$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;
+
$replace{temptablenames} = join ', ', map "edit_$_", sort keys %ts;
+
$replace{loadtemptables} = join "\n", map sprintf(
" INSERT INTO edit_%s (%s) SELECT %2\$s FROM %1\$s_hist WHERE chid = xchid;",
$_, 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}/g;
- $template;
+ $template =~ s/{([a-z]+)}/$replace{$1}/gr;
}
-readschema;
-my $template = join '', <DATA>;
-
-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 $template, $_ for sort keys %tabletypes;
+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
@@ -83,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;
@@ -95,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/test/bbcode.pl b/util/test/bbcode.pl
new file mode 100755
index 00000000..94128684
--- /dev/null
+++ b/util/test/bbcode.pl
@@ -0,0 +1,248 @@
+#!/usr/bin/perl
+
+# This is a test & benchmark script for VNDB::BBCode.
+# Call without arguments to run the test, with any argument to run the benchmark.
+
+use strict;
+use warnings;
+use Test::More;
+use Benchmark 'timethese';
+
+use lib 'lib';
+use VNDB::BBCode;
+
+
+my @tests = (
+ '',
+ '',
+ '',
+
+ '[From [url=http://www.dlSITE.com/eng/]DLsite English[/url]]',
+ '[From <a href="http://www.dlSITE.com/eng/" rel="nofollow">DLsite English</a>]',
+ '[From DLsite English]',
+
+ '[url=http://example.com/]some url[/url]',
+ '<a href="http://example.com/" rel="nofollow">some url</a>',
+ 'some url',
+
+ '[quote]some quote[/quote]',
+ '<div class="quote">some quote</div>',
+ '"some quote"',
+
+ "[code]some code\n\nalso newlines;[/code]",
+ '<pre>some code<br><br>also newlines;</pre>',
+ "`some code\n\nalso newlines;`",
+
+ '[spoiler]some spoiler[/spoiler]',
+ '<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 <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\"",
+
+ '[url=/v19]some vndb url[/url]',
+ '<a href="/v19" rel="nofollow">some vndb url</a>',
+ 'some vndb url',
+
+ "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\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>',
+ "\"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><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>',
+ "\"\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>',
+ "markup in [url]",
+
+ '[url=http://192.168.1.1/some/path]ipv4 address in [url][/url]',
+ '<a href="http://192.168.1.1/some/path" rel="nofollow">ipv4 address in [url]</a>',
+ 'ipv4 address in [url]',
+
+ 'http://192.168.1.1/some/path (literal ipv4 address)',
+ '<a href="http://192.168.1.1/some/path" rel="nofollow">link</a> (literal ipv4 address)',
+ 'http://192.168.1.1/some/path (literal ipv4 address)',
+
+ '[url=http://192.168.1.1:8080/some/path]ipv4 address (port included) in [url][/url]',
+ '<a href="http://192.168.1.1:8080/some/path" rel="nofollow">ipv4 address (port included) in [url]</a>',
+ 'ipv4 address (port included) in [url]',
+
+ 'http://192.168.1.1:8080/some/path (literal ipv4 address, port included)',
+ '<a href="http://192.168.1.1:8080/some/path" rel="nofollow">link</a> (literal ipv4 address, port included)',
+ '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 <span class="spoiler">here</span></div>',
+ '"non-lowercase tags "',
+
+ 'some text [spoiler]with (v17) tags[/spoiler] and internal ids such as s1',
+ '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 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- 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]]',
+ '[From<a href="http://densetsu.com/display.php?id=468&amp;style=alphabetical" rel="nofollow"> Anime Densetsu</a>]',
+ '[From Anime Densetsu]',
+
+ # Not sure what to do here
+ #'http://some[raw].pointlessly[/raw].unusual.domain/',
+ #'<a href="http://some.pointlessly.unusual.domain/" rel="nofollow">link</a>',
+
+ #'[url=http://some[raw].pointlessly[/raw].unusual.domain/]hi[/url]',
+ #'<a href="http://some[raw].pointlessly[/raw].unusual.domain/" rel="nofollow">hi</a>',
+
+ '<tag>html escapes (&)</tag>',
+ '&lt;tag&gt;html escapes (&amp;)&lt;/tag&gt;',
+ '<tag>html escapes (&)</tag>',
+
+ '[spoiler]stray open tag',
+ '<span class="spoiler">stray open tag</span>',
+ '',
+
+ # TODO: This isn't ideal
+ '[quote][spoiler]stray open tag (nested)[/quote]',
+ '<div class="quote"><span class="spoiler">stray open tag (nested)[/quote]</span></div>',
+ '""',
+
+ '[quote][spoiler]two stray open tags',
+ '<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>',
+ "that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination",
+
+ # > I don't see anyone using IPv6 URLs anytime soon, so I'm not worried too either way.
+ #'[url=http://[fedc:ba98:7654:3210:fedc:ba98:7654:3210]/some/path]ipv6 address in [url][/url]',
+ #'<a href="http://[fedc:ba98:7654:3210:fedc:ba98:7654:3210]/some/path" rel="nofollow">ipv6 address in [url]</a>',
+
+ #'http://[fedc:ba98:7654:3210:fedc:ba98:7654:3210]/some/path (literal ipv6 address)',
+ #'<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]", maxlength => 10 ],
+ '<a href="https://cat.xyz/" rel="nofollow">that\'s </a>',
+ "that's ",
+
+ [ "A https://blicky.net/ only takes 4 characters", maxlength => 8 ],
+ 'A <a href="https://blicky.net/" rel="nofollow">link</a>',
+ "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',
+);
+
+
+# output should be the same as the input
+my @invalid_syntax = (
+ '[url="http://example.com/"]invalid argument to the "url" tag[/url]',
+ '[url=nicetext]simpler invalid param[/url]',
+ '[url]empty "url" tag[/url]',
+ '[tag]custom tag[/tag]',
+ # https://vndb.org/t2520.231
+ 'pov1',
+);
+
+
+# Chaining all the parse() raw arguments should generate the same string as the input
+sub identity {
+ my $ret = '';
+ VNDB::BBCode::parse $_[0], sub {
+ $ret .= $_[0];
+ };
+ $ret;
+}
+
+
+sub test {
+ push @tests, map +($_,$_,$_), @invalid_syntax;
+ plan tests => scalar @tests;
+
+ while(@tests) {
+ my $input = shift @tests;
+ my $html = shift @tests;
+ my $plain = shift @tests;
+ my @arg = ref $input ? @$input : ($input);
+ (my $msg = $arg[0]) =~ s/\n/\\n/g;
+ is identity($arg[0]), $arg[0], "id: $msg";
+ is bb_format(@arg), $html, "html: $msg";
+ is bb_format(@arg, text => 1), $plain, "plain: $msg";
+ }
+}
+
+
+# Performance comparison with old implementation
+sub bench {
+ my $plain = "This isn't a terribly interesting [string]. "x1000;
+ 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 { 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)
+ # plain: 3 wallclock secs ( 3.20 usr + 0.00 sys = 3.20 CPU) @ 130.00/s (n=416)
+ # short: 3 wallclock secs ( 3.17 usr + 0.00 sys = 3.17 CPU) @ 31420.82/s (n=99604)
+ # new:
+ # heavy: 3 wallclock secs ( 3.23 usr + 0.00 sys = 3.23 CPU) @ 242.11/s (n=782)
+ # plain: 3 wallclock secs ( 3.12 usr + 0.00 sys = 3.12 CPU) @ 124.04/s (n=387)
+ # short: 3 wallclock secs ( 3.18 usr + 0.00 sys = 3.18 CPU) @ 21018.55/s (n=66839)
+ # That's a bit of a performance hit, but should still be fast enough.
+}
+
+test if !@ARGV;
+bench if @ARGV;
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-01-01-ulists.sql b/util/updates/2020-01-01-ulists.sql
new file mode 100644
index 00000000..7d8c4b82
--- /dev/null
+++ b/util/updates/2020-01-01-ulists.sql
@@ -0,0 +1,135 @@
+-- This script may be run multiple times while in beta, so clean up after ourselves.
+-- (Or, uh, before ourselves, in this case...)
+DROP TABLE IF EXISTS ulist_vns, ulist_labels, ulist_vns_labels CASCADE;
+DROP TRIGGER IF EXISTS ulist_labels_create ON users;
+DROP FUNCTION IF EXISTS ulist_labels_create();
+DROP FUNCTION IF EXISTS ulist_voted_label();
+
+
+
+
+-- Replaces the current vnlists, votes and wlists tables
+CREATE TABLE ulist_vns (
+ uid integer NOT NULL, -- users.id
+ vid integer NOT NULL, -- vn.id
+ added timestamptz NOT NULL DEFAULT NOW(),
+ lastmod timestamptz NOT NULL DEFAULT NOW(), -- updated when anything in this row has changed?
+ vote_date timestamptz, -- Used for "recent votes" - also updated when vote has changed?
+ vote smallint CHECK(vote IS NULL OR vote BETWEEN 10 AND 100),
+ started date,
+ finished date,
+ notes text NOT NULL DEFAULT '',
+ PRIMARY KEY(uid, vid)
+);
+
+CREATE TABLE ulist_labels (
+ uid integer NOT NULL, -- user.id
+ id integer NOT NULL, -- 0 < builtin < 10 <= custom, ids are reused
+ label text NOT NULL,
+ private boolean NOT NULL,
+ PRIMARY KEY(uid, id)
+);
+
+CREATE TABLE ulist_vns_labels (
+ uid integer NOT NULL, -- user.id
+ lbl integer NOT NULL,
+ vid integer NOT NULL, -- vn.id
+ PRIMARY KEY(uid, lbl, vid)
+ -- (uid, lbl) REFERENCES ulist_labels (uid, id) ON DELETE CASCADE
+ -- (uid, vid) REFERENCES ulist (uid, vid) ON DELETE CASCADE
+ -- Do we want a 'when has this label been applied' timestamp?
+);
+
+-- When is a row in ulist 'public'? i.e. When it is visible in a VNs recent votes and in the user's VN list?
+--
+-- EXISTS(SELECT 1 FROM ulist_vn_label uvl JOIN ulist_labels ul ON ul.id = uvl.lbl AND ul.uid = uvl.uid WHERE uid = ulist.uid AND vid = ulist.vid AND NOT ul.private)
+--
+-- That is: It is public when it has been assigned at least one non-private label.
+--
+-- This means that, during the conversion of old lists to this new format, all
+-- vns with an 'unknown' status (= old 'unknown' status or voted but not in
+-- vnlist/wlist) from users who have not hidden their list should be assigned
+-- to a new non-private label.
+--
+-- The "Don't allow others to see my [..] list" profile option becomes obsolete
+-- with this label-based private flag.
+
+
+
+\timing
+
+-- The following queries need a consistent view of the database.
+BEGIN;
+SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
+
+INSERT INTO ulist_labels (uid, id, label, private)
+ SELECT id, 1, 'Playing', hide_list FROM users
+ UNION ALL SELECT id, 2, 'Finished', hide_list FROM users
+ UNION ALL SELECT id, 3, 'Stalled', hide_list FROM users
+ UNION ALL SELECT id, 4, 'Dropped', hide_list FROM users
+ UNION ALL SELECT id, 5, 'Wishlist', hide_list FROM users
+ UNION ALL SELECT id, 6, 'Blacklist', hide_list FROM users
+ UNION ALL SELECT id, 7, 'Voted', hide_list FROM users
+ UNION ALL SELECT id, 10, 'Wishlist-High', hide_list FROM users WHERE id IN(SELECT DISTINCT uid FROM wlists WHERE wstat = 0)
+ UNION ALL SELECT id, 11, 'Wishlist-Medium', hide_list FROM users WHERE id IN(SELECT DISTINCT uid FROM wlists WHERE wstat = 1)
+ UNION ALL SELECT id, 12, 'Wishlist-Low', hide_list FROM users WHERE id IN(SELECT DISTINCT uid FROM wlists WHERE wstat = 2);
+
+INSERT INTO ulist_vns (uid, vid, added, lastmod, vote_date, vote, notes)
+ SELECT COALESCE(wl.uid, vl.uid, vo.uid, ro.uid)
+ , COALESCE(wl.vid, vl.vid, vo.vid, ro.vid)
+ , LEAST(wl.added, vl.added, vo.date, ro.added)
+ , GREATEST(wl.added, vl.added, vo.date, ro.added)
+ , vo.date, vo.vote
+ , COALESCE(vl.notes, '')
+ FROM wlists wl
+ FULL JOIN vnlists vl ON vl.uid = wl.uid AND vl.vid = wl.vid
+ FULL JOIN votes vo ON vo.uid = COALESCE(wl.uid, vl.uid) AND vo.vid = COALESCE(wl.vid, vl.vid)
+ FULL JOIN ( -- It used to be possible to have items in rlists without a corresponding entry in vnlists, so also merge rows from there.
+ SELECT rl.uid, rv.vid, MAX(rl.added)
+ FROM rlists rl
+ JOIN releases_vn rv ON rv.id = rl.rid
+ GROUP BY rl.uid, rv.vid
+ ) ro (uid, vid, added) ON ro.uid = COALESCE(wl.uid, vl.uid, vo.uid) AND ro.vid = COALESCE(wl.vid, vl.vid, vo.vid);
+
+INSERT INTO ulist_vns_labels (uid, vid, lbl)
+ SELECT uid, vid, 5 FROM wlists WHERE wstat <> 3 -- All wishlisted items except the blacklist
+ UNION ALL SELECT uid, vid, 10 FROM wlists WHERE wstat = 0 -- Wishlist-High
+ UNION ALL SELECT uid, vid, 11 FROM wlists WHERE wstat = 1 -- Wishlist-Medium
+ UNION ALL SELECT uid, vid, 12 FROM wlists WHERE wstat = 2 -- Wishlist-Low
+ UNION ALL SELECT uid, vid, 6 FROM wlists WHERE wstat = 3 -- Blacklist
+ UNION ALL SELECT uid, vid, status FROM vnlists WHERE status <> 0 -- Playing/Finished/Stalled/Dropped
+ UNION ALL SELECT uid, vid, 7 FROM votes;
+
+
+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_labels ADD CONSTRAINT ulist_labels_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+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;
+
+COMMIT;
+
+\timing
+
+DROP FUNCTION update_vnpopularity();
+
+ALTER TABLE users ADD COLUMN c_vns integer NOT NULL DEFAULT 0;
+ALTER TABLE users ADD COLUMN c_wish integer NOT NULL DEFAULT 0;
+
+DROP TRIGGER users_votes_update ON votes;
+DROP TRIGGER update_vnlist_rlist ON rlists;
+
+\i util/sql/func.sql
+\i util/sql/triggers.sql
+\i util/sql/perms.sql
+
+\timing
+SELECT update_users_ulist_stats(NULL);
+CREATE INDEX ulist_vns_voted ON ulist_vns (vid, vote_date) WHERE vote IS NOT NULL;
+CREATE INDEX users_ign_votes ON users (id) WHERE ign_votes;
+
+
+-- Can be done later:
+-- DROP TABLE wlists, vnlists, votes;
diff --git a/util/updates/2020-01-04-ulist-saved-views.sql b/util/updates/2020-01-04-ulist-saved-views.sql
new file mode 100644
index 00000000..14926ba6
--- /dev/null
+++ b/util/updates/2020-01-04-ulist-saved-views.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users ADD COLUMN ulist_votes jsonb;
+ALTER TABLE users ADD COLUMN ulist_vnlist jsonb;
+ALTER TABLE users ADD COLUMN ulist_wish jsonb;
+\i util/sql/perms.sql
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/updates/update_1.1.pl b/util/updates/update_1.1.pl
deleted file mode 100755
index c01e6625..00000000
--- a/util/updates/update_1.1.pl
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/usr/bin/perl
-
-use strict;
-use warnings;
-use DBI;
-
-require '../lib/global.pl';
-
-my $sql = DBI->connect('dbi:Pg:dbname=vndb', 'vndb', 'passwd',
- { RaiseError => 1, PrintError => 0, AutoCommit => 1, pg_enable_utf8 => 1 });
-
-my $q = $sql->prepare('SELECT id, rel_old, language FROM vnr'); $q->execute;
-for (@{$q->fetchall_arrayref({})}) {
- my $rel = sprintf !$_->{rel_old} ? 'Original release' :
- $_->{rel_old} == 1 ? '%s translation' : '%s rerelease', $VNDB::LANG->{$_->{language}};
- $sql->do('UPDATE vnr SET relation = ? WHERE id = ?', undef, $rel, $_->{id});
-}
-$sql->do('ALTER TABLE vnr DROP COLUMN rel_old');
diff --git a/util/updates/update_1.1.sql b/util/updates/update_1.1.sql
deleted file mode 100644
index 0538e3d5..00000000
--- a/util/updates/update_1.1.sql
+++ /dev/null
@@ -1,13 +0,0 @@
-ALTER TABLE users ADD COLUMN pvotes smallint NOT NULL DEFAULT 1;
-ALTER TABLE users ADD COLUMN pfind smallint NOT NULL DEFAULT 1;
-
-UPDATE users
- SET registered = 1191004915
- WHERE registered = 0;
-UPDATE votes
- SET date = 1191004915
- WHERE date = 0;
-
-ALTER TABLE vnr RENAME COLUMN relation TO rel_old;
-ALTER TABLE vnr ADD COLUMN relation varchar(32) NOT NULL DEFAULT 'Original release';
-
diff --git a/util/updates/update_1.10.sql b/util/updates/update_1.10.sql
deleted file mode 100644
index d68b0456..00000000
--- a/util/updates/update_1.10.sql
+++ /dev/null
@@ -1,92 +0,0 @@
-
--- seperate releases_vn table
-CREATE TABLE releases_vn (
- rid integer DEFAULT 0 NOT NULL,
- vid integer DEFAULT 0 NOT NULL,
- PRIMARY KEY(rid, vid)
-) WITHOUT OIDS;
-
-INSERT INTO releases_vn
- SELECT rr.id AS rid, r.vid AS vid
- FROM releases_rev rr
- JOIN releases r ON rr.rid = r.id;
-
-ALTER TABLE releases DROP COLUMN vid;
-
-
-ALTER TABLE releases_rev ALTER COLUMN notes TYPE text;
-UPDATE producers_rev SET "desc" = '' WHERE "desc" = '0';
-
-
-
-
--- Update rating calculation
-ALTER TABLE vn ALTER COLUMN c_votes TYPE character(10);
-ALTER TABLE vn ALTER COLUMN c_votes SET DEFAULT '00.00|0000';
-
-CREATE OR REPLACE FUNCTION calculate_rating() RETURNS void AS $$
-DECLARE
- av RECORD;
-BEGIN
- SELECT INTO av
- COUNT(vote)::real / COUNT(DISTINCT vid)::real AS num_votes,
- AVG(vote)::real AS rating
- FROM votes;
-
- UPDATE vn
- SET c_votes = COALESCE((SELECT
- TO_CHAR(CASE WHEN COUNT(uid) < 2 THEN 0 ELSE
- ( (av.num_votes * av.rating) + SUM(vote)::real ) / (av.num_votes + COUNT(uid)::real ) END,
- 'FM00D00'
- )||'|'||TO_CHAR(
- COUNT(votes.vote), 'FM0000'
- )
- FROM votes
- WHERE votes.vid = vn.id
- GROUP BY votes.vid
- ), '00.00|0000');
-END
-$$ LANGUAGE plpgsql;
-SELECT calculate_rating();
-
-
--- fix update_vncache
-DROP FUNCTION update_vncache(integer, integer);
-CREATE OR REPLACE FUNCTION update_vncache(id integer) RETURNS void AS $$
-DECLARE
- w text := '';
-BEGIN
- IF id > 0 THEN
- w := ' WHERE id = '||id;
- END IF;
- EXECUTE 'UPDATE vn SET
- c_released = COALESCE((SELECT
- SUBSTRING(COALESCE(MIN(rr1.released), ''0000-00'') from 1 for 7)
- FROM releases_rev rr1
- JOIN releases r1 ON rr1.id = r1.latest
- JOIN releases_vn rv1 ON rr1.id = rv1.rid
- WHERE rv1.vid = vn.id
- AND rr1.type <> 2
- GROUP BY rv1.vid
- ), ''0000-00''),
- c_languages = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT language
- FROM releases_rev rr2
- JOIN releases r2 ON rr2.id = r2.latest
- JOIN releases_vn rv2 ON rr2.id = rv2.rid
- WHERE rv2.vid = vn.id
- AND rr2.type <> 2
- AND rr2.released <= ''today''::date
- GROUP BY rr2.language
- ORDER BY rr2.language
- ), ''/''), '''')
- '||w;
-END;
-$$ LANGUAGE plpgsql;
-SELECT update_vncache(0);
-
-
-
--- Add comments field to vnlists
-ALTER TABLE vnlists ADD COLUMN comments character varying(500) NOT NULL DEFAULT '';
-
diff --git a/util/updates/update_1.11.sql b/util/updates/update_1.11.sql
deleted file mode 100644
index 63a822a5..00000000
--- a/util/updates/update_1.11.sql
+++ /dev/null
@@ -1,4 +0,0 @@
-
-UPDATE vn_rev SET categories = categories << 6;
-
-ALTER TABLE vn_rev ADD COLUMN l_vnn integer NOT NULL DEFAULT 0;
diff --git a/util/updates/update_1.12.sql b/util/updates/update_1.12.sql
deleted file mode 100644
index 30238c6d..00000000
--- a/util/updates/update_1.12.sql
+++ /dev/null
@@ -1,34 +0,0 @@
-
-UPDATE vn_rev SET categories = categories << 2;
-
-DELETE FROM releases_vn rv WHERE NOT EXISTS(SELECT 1 FROM releases_rev rr WHERE rr.id = rv.rid);
-
-
--- FOREIGN KEY CHECKING!
-ALTER TABLE releases_rev ADD FOREIGN KEY (id) REFERENCES changes (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_rev ADD FOREIGN KEY (rid) REFERENCES releases (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
---ALTER TABLE releases_rev ADD FOREIGN KEY (id) REFERENCES releases_vn (rid) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases ADD FOREIGN KEY (latest) REFERENCES releases_rev (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_vn ADD FOREIGN KEY (rid) REFERENCES releases_rev (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_vn ADD FOREIGN KEY (vid) REFERENCES vn (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_platforms ADD FOREIGN KEY (rid) REFERENCES releases_rev (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_media ADD FOREIGN KEY (rid) REFERENCES releases_rev (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_producers ADD FOREIGN KEY (rid) REFERENCES releases_rev (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_producers ADD FOREIGN KEY (pid) REFERENCES producers (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-
-ALTER TABLE vn_rev ADD FOREIGN KEY (id) REFERENCES changes (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vn_rev ADD FOREIGN KEY (vid) REFERENCES vn (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vn ADD FOREIGN KEY (latest) REFERENCES vn_rev (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vn_relations ADD FOREIGN KEY (vid1) REFERENCES vn_rev (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vn_relations ADD FOREIGN KEY (vid2) REFERENCES vn (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-
-ALTER TABLE producers_rev ADD FOREIGN KEY (id) REFERENCES changes (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE producers_rev ADD FOREIGN KEY (pid) REFERENCES producers (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE producers ADD FOREIGN KEY (latest) REFERENCES producers_rev (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-
-ALTER TABLE changes ADD FOREIGN KEY (requester) REFERENCES users (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE votes ADD FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE votes ADD FOREIGN KEY (vid) REFERENCES vn (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vnlists ADD FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vnlists ADD FOREIGN KEY (vid) REFERENCES vn (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-
diff --git a/util/updates/update_1.13.sql b/util/updates/update_1.13.sql
deleted file mode 100644
index 48a347e2..00000000
--- a/util/updates/update_1.13.sql
+++ /dev/null
@@ -1,229 +0,0 @@
-
-
-
--- why did we still have this column?
-ALTER TABLE releases_rev DROP COLUMN relation;
-
-
-
-
--- fix update_prev
-CREATE OR REPLACE FUNCTION update_prev(tbl text, ids text) RETURNS void AS $$
-DECLARE
- r RECORD;
- r2 RECORD;
- i integer;
- t text;
- e text;
-BEGIN
- SELECT INTO t SUBSTRING(tbl, 1, 1);
- e := '';
- IF ids <> '' THEN
- e := ' WHERE id IN('||ids||')';
- END IF;
- FOR r IN EXECUTE 'SELECT id FROM '||tbl||e LOOP
- i := 0;
- FOR r2 IN EXECUTE 'SELECT id FROM '||tbl||'_rev WHERE '||t||'id = '||r.id||' ORDER BY id ASC' LOOP
- UPDATE changes SET prev = i WHERE id = r2.id;
- i := r2.id;
- END LOOP;
- END LOOP;
-END;
-$$ LANGUAGE plpgsql;
-SELECT update_prev('vn',''), update_prev('releases',''), update_prev('producers','');
-
-
-
-
--- change votes treshold to 3
-CREATE OR REPLACE FUNCTION calculate_rating() RETURNS void AS $$
-DECLARE
- av RECORD;
-BEGIN
- SELECT INTO av
- COUNT(vote)::real / COUNT(DISTINCT vid)::real AS num_votes,
- AVG(vote)::real AS rating
- FROM votes;
-
- UPDATE vn
- SET c_votes = COALESCE((SELECT
- TO_CHAR(CASE WHEN COUNT(uid) < 3 THEN 0 ELSE
- ( (av.num_votes * av.rating) + SUM(vote)::real ) / (av.num_votes + COUNT(uid)::real ) END,
- 'FM00D00'
- )||'|'||TO_CHAR(
- COUNT(votes.vote), 'FM0000'
- )
- FROM votes
- WHERE votes.vid = vn.id
- GROUP BY votes.vid
- ), '00.00|0000');
-END
-$$ LANGUAGE plpgsql;
-SELECT calculate_rating();
-
-
-
-
--- store release dates as integers
-ALTER TABLE releases_rev ALTER COLUMN released TYPE integer USING REPLACE(released, '-', '')::integer;
-UPDATE releases_rev SET released = 0 WHERE released IS NULL;
-ALTER TABLE releases_rev ALTER COLUMN released SET NOT NULL;
-
-ALTER TABLE vn ALTER COLUMN c_released SET DEFAULT 0;
-ALTER TABLE vn ALTER COLUMN c_released TYPE integer USING 0;
-CREATE OR REPLACE FUNCTION update_vncache(id integer) RETURNS void AS $$
-DECLARE
- w text := '';
-BEGIN
- IF id > 0 THEN
- w := ' WHERE id = '||id;
- END IF;
- EXECUTE 'UPDATE vn SET
- c_released = COALESCE((SELECT
- MIN(rr1.released)
- FROM releases_rev rr1
- JOIN releases r1 ON rr1.id = r1.latest
- JOIN releases_vn rv1 ON rr1.id = rv1.rid
- WHERE rv1.vid = vn.id
- AND rr1.type <> 2
- AND rr1.released <> 0
- GROUP BY rv1.vid
- ), 0),
- c_languages = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT language
- FROM releases_rev rr2
- JOIN releases r2 ON rr2.id = r2.latest
- JOIN releases_vn rv2 ON rr2.id = rv2.rid
- WHERE rv2.vid = vn.id
- AND rr2.type <> 2
- AND rr2.released <= TO_CHAR(''today''::timestamp, ''YYYYMMDD'')::integer
- GROUP BY rr2.language
- ORDER BY rr2.language
- ), ''/''), '''')
- '||w;
-END;
-$$ LANGUAGE plpgsql;
-SELECT update_vncache(0);
-
-
-
-
--- Rewrite category system
-CREATE TABLE vn_categories (
- vid integer NOT NULL DEFAULT 0,
- cat char(3) NOT NULL DEFAULT '',
- lvl smallint NOT NULL DEFAULT 3,
- PRIMARY KEY(vid, cat)
-) WITHOUT OIDS;
-
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'gaa', 1 FROM vn_rev WHERE (categories & (1<<0)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'gab', 1 FROM vn_rev WHERE (categories & (1<<1)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'gac', 3 FROM vn_rev WHERE (categories & (1<<2)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'grp', 3 FROM vn_rev WHERE (categories & (1<<3)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'gst', 3 FROM vn_rev WHERE (categories & (1<<4)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'gsi', 3 FROM vn_rev WHERE (categories & (1<<5)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'pli', 1 FROM vn_rev WHERE (categories & (1<<6)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'pbr', 1 FROM vn_rev WHERE (categories & (1<<7)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'eac', 3 FROM vn_rev WHERE (categories & (1<<8)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'eco', 3 FROM vn_rev WHERE (categories & (1<<9)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'edr', 3 FROM vn_rev WHERE (categories & (1<<10)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'efa', 3 FROM vn_rev WHERE (categories & (1<<11)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'eho', 3 FROM vn_rev WHERE (categories & (1<<12)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'emy', 3 FROM vn_rev WHERE (categories & (1<<13)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'ero', 3 FROM vn_rev WHERE (categories & (1<<14)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'esf', 3 FROM vn_rev WHERE (categories & (1<<15)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'esj', 3 FROM vn_rev WHERE (categories & (1<<16)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'esn', 3 FROM vn_rev WHERE (categories & (1<<17)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'tfu', 3 FROM vn_rev WHERE (categories & (1<<18)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'tpa', 3 FROM vn_rev WHERE (categories & (1<<19)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'tpr', 3 FROM vn_rev WHERE (categories & (1<<20)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'lea', 3 FROM vn_rev WHERE (categories & (1<<21)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'lfa', 3 FROM vn_rev WHERE (categories & (1<<22)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'lsp', 3 FROM vn_rev WHERE (categories & (1<<23)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'saa', 3 FROM vn_rev WHERE (categories & (1<<24)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'sbe', 3 FROM vn_rev WHERE (categories & (1<<25)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'sin', 3 FROM vn_rev WHERE (categories & (1<<26)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'slo', 3 FROM vn_rev WHERE (categories & (1<<27)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'ssh', 3 FROM vn_rev WHERE (categories & (1<<28)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'sya', 3 FROM vn_rev WHERE (categories & (1<<29)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'syu', 3 FROM vn_rev WHERE (categories & (1<<30)) > 0;
-INSERT INTO vn_categories (vid, cat, lvl) SELECT id, 'sra', 3 FROM vn_rev WHERE (categories & (1<<31)) < 0; -- MSB, mind you!
-ALTER TABLE vn_rev DROP COLUMN categories;
-
-
-
--- Remove all previously defined constraints
-ALTER TABLE releases_rev DROP CONSTRAINT releases_rev_id_fkey;
-ALTER TABLE releases_rev DROP CONSTRAINT releases_rev_rid_fkey;
-ALTER TABLE releases DROP CONSTRAINT releases_latest_fkey;
-ALTER TABLE releases_vn DROP CONSTRAINT releases_vn_rid_fkey;
-ALTER TABLE releases_vn DROP CONSTRAINT releases_vn_vid_fkey;
-ALTER TABLE releases_platforms DROP CONSTRAINT releases_platforms_rid_fkey;
-ALTER TABLE releases_media DROP CONSTRAINT releases_media_rid_fkey;
-ALTER TABLE releases_producers DROP CONSTRAINT releases_producers_rid_fkey;
-ALTER TABLE releases_producers DROP CONSTRAINT releases_producers_pid_fkey;
-
-ALTER TABLE vn_rev DROP CONSTRAINT vn_rev_id_fkey;
-ALTER TABLE vn_rev DROP CONSTRAINT vn_rev_vid_fkey;
-ALTER TABLE vn DROP CONSTRAINT vn_latest_fkey;
-ALTER TABLE vn_relations DROP CONSTRAINT vn_relations_vid1_fkey;
-ALTER TABLE vn_relations DROP CONSTRAINT vn_relations_vid2_fkey;
-
-ALTER TABLE changes DROP CONSTRAINT changes_requester_fkey;
-ALTER TABLE votes DROP CONSTRAINT votes_uid_fkey;
-ALTER TABLE votes DROP CONSTRAINT votes_vid_fkey;
-ALTER TABLE vnlists DROP CONSTRAINT vnlists_uid_fkey;
-ALTER TABLE vnlists DROP CONSTRAINT vnlists_vid_fkey;
-
-
--- And re-add them... LOLZ
-ALTER TABLE releases_rev ADD FOREIGN KEY (id) REFERENCES changes (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_rev ADD FOREIGN KEY (rid) REFERENCES releases (id) DEFERRABLE INITIALLY DEFERRED;
---ALTER TABLE releases_rev ADD FOREIGN KEY (id, NULL) REFERENCES releases_vn (rid, vid) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases ADD FOREIGN KEY (latest) REFERENCES releases_rev (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_vn ADD FOREIGN KEY (rid) REFERENCES releases_rev (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_vn ADD FOREIGN KEY (vid) REFERENCES vn (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_platforms ADD FOREIGN KEY (rid) REFERENCES releases_rev (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_media ADD FOREIGN KEY (rid) REFERENCES releases_rev (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_producers ADD FOREIGN KEY (rid) REFERENCES releases_rev (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE releases_producers ADD FOREIGN KEY (pid) REFERENCES producers (id) DEFERRABLE INITIALLY DEFERRED;
-
-ALTER TABLE vn_rev ADD FOREIGN KEY (id) REFERENCES changes (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vn_rev ADD FOREIGN KEY (vid) REFERENCES vn (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vn ADD FOREIGN KEY (latest) REFERENCES vn_rev (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vn_categories ADD FOREIGN KEY (vid) REFERENCES vn_rev (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vn_relations ADD FOREIGN KEY (vid1) REFERENCES vn_rev (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vn_relations ADD FOREIGN KEY (vid2) REFERENCES vn (id) DEFERRABLE INITIALLY DEFERRED;
-
-ALTER TABLE producers_rev ADD FOREIGN KEY (id) REFERENCES changes (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE producers_rev ADD FOREIGN KEY (pid) REFERENCES producers (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE producers ADD FOREIGN KEY (latest) REFERENCES producers_rev (id) DEFERRABLE INITIALLY DEFERRED;
-
-ALTER TABLE changes ADD FOREIGN KEY (requester) REFERENCES users (id) DEFERRABLE INITIALLY DEFERRED;-- ON DELETE SET DEFAULT
-ALTER TABLE votes ADD FOREIGN KEY (uid) REFERENCES users (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE votes ADD FOREIGN KEY (vid) REFERENCES vn (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vnlists ADD FOREIGN KEY (uid) REFERENCES users (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vnlists ADD FOREIGN KEY (vid) REFERENCES vn (id) DEFERRABLE INITIALLY DEFERRED;
-
-
---ALTER TABLE releases_rev ADD COLUMN ref_vid_hack integer NULL DEFAULT NULL;
---ALTER TABLE releases_rev ADD FOREIGN KEY (id, ref_vid_hack) REFERENCES releases_vn (rid, vid) ON DELETE CASCADE;
-
--- TODO:
--- - make sure that changes.id should always refer to a row in *_rev
--- - make sure that there is always at least one row in releases_vn for every releases_rev
-
--- deletion of items in *_rev should trigger deletion in changes
---CREATE OR REPLACE FUNCTION changes_reference_del() RETURNS trigger AS $$
---BEGIN
--- DELETE FROM changes WHERE id = OLD.id;
---END
---$$ LANGUAGE PLPGSQL;
-
---CREATE TRIGGER vn_rev_cdel AFTER DELETE ON vn_rev FOR EACH ROW EXECUTE PROCEDURE changes_reference_del();
---CREATE TRIGGER releases_rev_cdel AFTER DELETE ON releases_rev FOR EACH ROW EXECUTE PROCEDURE changes_reference_del();
---CREATE TRIGGER producers_rev_cdel AFTER DELETE ON producers_rev FOR EACH ROW EXECUTE PROCEDURE changes_reference_del();
-
-
-
-
diff --git a/util/updates/update_1.14.pl b/util/updates/update_1.14.pl
deleted file mode 100644
index cd45b0fa..00000000
--- a/util/updates/update_1.14.pl
+++ /dev/null
@@ -1,59 +0,0 @@
-#!/usr/bin/perl
-
-use strict;
-use warnings;
-no warnings 'once';
-use File::Path;
-use DBI;
-
-# script assumes:
-# /static has been created
-# /www/files has already been moved
-chdir '/www/vndb';
-require 'lib/global.pl';
-
-
-# run the usual SQL update script
-system('psql -U vndb < util/updates/update_1.14.sql');
-
-# fix directories
-rmtree('data/rg');
-rmtree('www/rg');
-
-mkdir 'data/rg';
-mkdir 'static/cv';
-mkdir 'static/rg';
-chmod 0755, qw|data/rg static/cv static/rg|;
-
-for (0..99) {
- $_ = sprintf "%02d",$_;
- mkdir "data/rg/$_";
- mkdir "static/rg/$_";
- mkdir "static/cv/$_";
- chmod 0777, "data/rg/$_", "static/rg/$_", "static/cv/$_";
-}
-
-
-# rename relation graphs
-system('util/multi.pl -c "relgraph all"');
-
-
-# rename cover images
-my $sql = DBI->connect(@VNDB::DBLOGIN,
- { RaiseError => 0, PrintError => 1, AutoCommit => 1, pg_enable_utf8 => 1 });
-$sql->do('CREATE SEQUENCE covers_seq');
-$sql->do('ALTER TABLE vn_rev ADD COLUMN image_id integer NOT NULL DEFAULT 0');
-my $q = $sql->prepare('SELECT DISTINCT ENCODE(image,\'hex\') FROM vn_rev WHERE image <> \'\'');
-$q->execute();
-for (@{$q->fetchall_arrayref([])}) {
- $q = $sql->prepare('SELECT nextval(\'covers_seq\')');
- $q->execute();
- my($id) = $q->fetchrow_array();
- rename
- sprintf('www/img/%s/%s.jpg', substr($_->[0],0,1), $_->[0]),
- sprintf('static/cv/%02d/%d.jpg', $id%100, $id);
- $sql->do('UPDATE vn_rev SET image_id = ? WHERE image = DECODE(\''.$_->[0].'\', \'hex\')', undef, $id);
-}
-$sql->do('ALTER TABLE vn_rev DROP COLUMN image');
-$sql->do('ALTER TABLE vn_rev RENAME COLUMN image_id TO image');
-
diff --git a/util/updates/update_1.14.sql b/util/updates/update_1.14.sql
deleted file mode 100644
index 77f9ee71..00000000
--- a/util/updates/update_1.14.sql
+++ /dev/null
@@ -1,84 +0,0 @@
-
-
--- drop get_new_id()
-CREATE SEQUENCE vn_id_seq OWNED BY vn.id;
-SELECT setval('vn_id_seq', get_new_id('vn')-1);
-ALTER TABLE vn ALTER COLUMN id SET DEFAULT nextval('vn_id_seq');
-
-CREATE SEQUENCE releases_id_seq OWNED BY releases.id;
-SELECT setval('releases_id_seq', get_new_id('releases')-1);
-ALTER TABLE releases ALTER COLUMN id SET DEFAULT nextval('releases_id_seq');
-
-CREATE SEQUENCE producers_id_seq OWNED BY producers.id;
-SELECT setval('producers_id_seq', get_new_id('producers')-1);
-ALTER TABLE producers ALTER COLUMN id SET DEFAULT nextval('producers_id_seq');
-
-DROP FUNCTION get_new_id(text);
-
-
-
--- remove users.p* columns (Why haven't I done so earlier?)
-ALTER TABLE users DROP COLUMN pvotes;
-ALTER TABLE users DROP COLUMN pfind;
-ALTER TABLE users DROP COLUMN plist;
-ALTER TABLE users DROP COLUMN pign_nsfw;
-
-
-
--- relation graphs get ID numbers
-CREATE SEQUENCE relgraph_seq;
-ALTER TABLE vn ALTER COLUMN rgraph DROP NOT NULL;
-ALTER TABLE vn ALTER COLUMN rgraph DROP DEFAULT;
-ALTER TABLE vn ALTER COLUMN rgraph TYPE integer USING 0;
-ALTER TABLE vn ALTER COLUMN rgraph SET DEFAULT 0;
-ALTER TABLE vn ALTER COLUMN rgraph SET NOT NULL;
-
-
--- cover images get ID numbers as well
--- (handled in update_1.14.pl)
-
-
-
--- 'hidden' flag to all items in the DB
-ALTER TABLE vn ADD COLUMN hidden smallint NOT NULL DEFAULT 0;
-ALTER TABLE producers ADD COLUMN hidden smallint NOT NULL DEFAULT 0;
-ALTER TABLE releases ADD COLUMN hidden smallint NOT NULL DEFAULT 0;
-
-
--- update update_vncache to handle the hidden flag
-CREATE OR REPLACE FUNCTION update_vncache(id integer) RETURNS void AS $$
-DECLARE
- w text := '';
-BEGIN
- IF id > 0 THEN
- w := ' WHERE id = '||id;
- END IF;
- EXECUTE 'UPDATE vn SET
- c_released = COALESCE((SELECT
- MIN(rr1.released)
- FROM releases_rev rr1
- JOIN releases r1 ON rr1.id = r1.latest
- JOIN releases_vn rv1 ON rr1.id = rv1.rid
- WHERE rv1.vid = vn.id
- AND rr1.type <> 2
- AND r1.hidden = 0
- AND rr1.released <> 0
- GROUP BY rv1.vid
- ), 0),
- c_languages = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT language
- FROM releases_rev rr2
- JOIN releases r2 ON rr2.id = r2.latest
- JOIN releases_vn rv2 ON rr2.id = rv2.rid
- WHERE rv2.vid = vn.id
- AND rr2.type <> 2
- AND rr2.released <= TO_CHAR(''today''::timestamp, ''YYYYMMDD'')::integer
- AND r2.hidden = 0
- GROUP BY rr2.language
- ORDER BY rr2.language
- ), ''/''), '''')
- '||w;
-END;
-$$ LANGUAGE plpgsql;
-SELECT update_vncache(0);
-
diff --git a/util/updates/update_1.15.sql b/util/updates/update_1.15.sql
deleted file mode 100644
index 2f2f814c..00000000
--- a/util/updates/update_1.15.sql
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
--- remove the old image hashes
-ALTER TABLE vn_rev DROP COLUMN image_old;
-
-
-
--- Add anime relations
-CREATE TABLE anime (
- id integer NOT NULL PRIMARY KEY, -- anidb id
- year smallint NOT NULL DEFAULT 0,
- ann_id integer NOT NULL DEFAULT 0,
- nfo_id varchar(200) NOT NULL DEFAULT '',
- type smallint NOT NULL DEFAULT 0, -- TV/OVA/etc (global.pl)
- title_romaji varchar(250) NOT NULL DEFAULT '',
- title_kanji varchar(250) NOT NULL DEFAULT '',
- lastfetch bigint NOT NULL DEFAULT 0 -- -1:not found, 0: not fetched, >0: timestamp
-) WITHOUT oids;
-
-CREATE TABLE vn_anime (
- vid integer NOT NULL REFERENCES vn_rev (id) DEFERRABLE INITIALLY DEFERRED,
- aid integer NOT NULL REFERENCES anime (id) DEFERRABLE INITIALLY DEFERRED,
- PRIMARY KEY(vid, aid)
-) WITHOUT oids;
-
-
diff --git a/util/updates/update_1.16.sql b/util/updates/update_1.16.sql
deleted file mode 100644
index c64e1d49..00000000
--- a/util/updates/update_1.16.sql
+++ /dev/null
@@ -1,74 +0,0 @@
-
--- empty nfo_id
-UPDATE anime SET nfo_id = '' WHERE nfo_id = '0,';
-
-
--- future release dates
-UPDATE releases_rev
- SET released = (SUBSTRING(released::text, 1, 4)||'9999')::integer
- WHERE SUBSTRING(released::text, 5, 4) = '0000';
-
-UPDATE releases_rev
- SET released = (SUBSTRING(released::text, 1, 6)||'99')::integer
- WHERE SUBSTRING(released::text, 7, 4) = '00';
-
-
-
--- all platforms are three-letters now
-UPDATE releases_platforms SET platform = 'ps1' WHERE platform = 'ps ';
-UPDATE releases_platforms SET platform = 'drc' WHERE platform = 'dc ';
-
-
-
--- cache platforms
-ALTER TABLE vn ADD COLUMN c_platforms varchar(32) NOT NULL DEFAULT '';
-
-CREATE OR REPLACE FUNCTION update_vncache(id integer) RETURNS void AS $$
-DECLARE
- w text := '';
-BEGIN
- IF id > 0 THEN
- w := ' WHERE id = '||id;
- END IF;
- EXECUTE 'UPDATE vn SET
- c_released = COALESCE((SELECT
- MIN(rr1.released)
- FROM releases_rev rr1
- JOIN releases r1 ON rr1.id = r1.latest
- JOIN releases_vn rv1 ON rr1.id = rv1.rid
- WHERE rv1.vid = vn.id
- AND rr1.type <> 2
- AND r1.hidden = 0
- AND rr1.released <> 0
- GROUP BY rv1.vid
- ), 0),
- c_languages = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT language
- FROM releases_rev rr2
- JOIN releases r2 ON rr2.id = r2.latest
- JOIN releases_vn rv2 ON rr2.id = rv2.rid
- WHERE rv2.vid = vn.id
- AND rr2.type <> 2
- AND rr2.released <= TO_CHAR(''today''::timestamp, ''YYYYMMDD'')::integer
- AND r2.hidden = 0
- GROUP BY rr2.language
- ORDER BY rr2.language
- ), ''/''), ''''),
- c_platforms = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT rp3.platform
- FROM releases_platforms rp3
- JOIN releases_rev rr3 ON rp3.rid = rr3.id
- JOIN releases r3 ON rp3.rid = r3.latest
- JOIN releases_vn rv3 ON rp3.rid = rv3.rid
- WHERE rv3.vid = vn.id
- AND rr3.type <> 2
- AND rr3.released <= TO_CHAR(''today''::timestamp, ''YYYYMMDD'')::integer
- AND r3.hidden = 0
- GROUP BY rp3.platform
- ORDER BY rp3.platform
- ), ''/''), '''')
- '||w;
-END;
-$$ LANGUAGE plpgsql;
-
-SELECT update_vncache(0);
diff --git a/util/updates/update_1.17.pl b/util/updates/update_1.17.pl
deleted file mode 100755
index e2a5f91e..00000000
--- a/util/updates/update_1.17.pl
+++ /dev/null
@@ -1,82 +0,0 @@
-#!/usr/bin/perl
-
-use strict;
-use warnings;
-
-# execute update_1.17.sql first
-`psql -U vndb < /www/vndb/util/updates/update_1.17.sql`;
-
-
-use lib '/www/vndb/lib';
-BEGIN { require 'global.pl'; }
-
-
-# modules in the VNDB:: namespace aren't made to be included in
-# frameworks other than VNDB.pm... so we'll just emulate
-# a few functions of the framework to get DB.pm working
-package VNDB;
-
-use VNDB::Util::Tools; # for GTINType
-use VNDB::Util::DB;
-
-sub AuthInfo { { id => 1 } } # multi
-sub ReqIP { '127.0.0.1' }
-
-
-package main;
-
-my $db = bless {
- _DB => VNDB::Util::DB->new(@VNDB::DBLOGIN),
-}, 'VNDB';
-
-my $rids = $db->DBAll(q|
- SELECT r.id, rr.notes
- FROM releases r
- JOIN releases_rev rr ON rr.id = r.latest
- WHERE r.hidden <> 1
- AND r.locked <> 1
- AND rr.notes ILIKE '%JAN%'
- AND rr.gtin = 0
- ORDER BY r.id
-|);
-
-
-my $edits=0;
-for my $r (@$rids) {
- my $codes=0;
- $codes++ while($r->{notes} =~ /[0-9]{12,13}/g);
- if($codes > 1) {
- print "$$r{id}: found more than one GTIN-like code...\n";
- next;
- }
-
- my $jan;
- if($r->{notes} =~ s/[\s\n(]*JAN(?:(?:\s+|-)code)?\s*[:\x{FF1A}]\s*([0-9-]+)[\s\n)]*//i) {
- ($jan = $1) =~ s/-//g;
- if(!VNDB::GTINType($jan)) {
- print "$$r{id}: invalid GTIN code ($jan), ignoring\n";
- next;
- }
- } else {
- print "$$r{id}: matches on 'JAN', but couldn't find the code...\n";
- next;
- }
-
- my $p = $db->DBGetRelease(id => $r->{id}, what => 'changes vn producers platforms media')->[0];
-
- $db->DBEditRelease($r->{id},
- (map { $_ => $p->{$_} } qw| title original language website minage type released platforms |),
- producers => [ map { $_->{id} } @{$p->{producers}} ],
- media => [ map { [ $_->{medium}, $_->{qty} ] } @{$p->{media}} ],
- vn => [ map { $_->{vid} } @{$p->{vn}} ],
- gtin => $jan,
- notes => $r->{notes},
- comm => "(automated edit caused by VNDB upgrade to 1.17)\nMoving JAN code from notes to GTIN field."
- );
- $edits++;
-}
-
-$db->DBCommit;
-
-print "Modified $edits releases...\n";
-
diff --git a/util/updates/update_1.17.sql b/util/updates/update_1.17.sql
deleted file mode 100644
index c38d2e65..00000000
--- a/util/updates/update_1.17.sql
+++ /dev/null
@@ -1,17 +0,0 @@
-
--- Add GTIN column
-ALTER TABLE releases_rev ADD COLUMN gtin bigint NOT NULL DEFAULT 0;
-
-
--- Permanently delete the CISV link and add links to encubed and renai.us
-ALTER TABLE vn_rev DROP COLUMN l_cisv;
-ALTER TABLE vn_rev ADD COLUMN l_encubed varchar(100) NOT NULL DEFAULT '';
-ALTER TABLE vn_rev ADD COLUMN l_renai varchar(100) NOT NULL DEFAULT '';
-
-
--- time and place categories have only one level now
-UPDATE vn_categories
- SET lvl = 1
- WHERE cat IN('tfu', 'tpa', 'tpr', 'lea', 'lfa', 'lsp');
--- AND vid IN(SELECT latest FROM vn);
-
diff --git a/util/updates/update_1.18.sql b/util/updates/update_1.18.sql
deleted file mode 100644
index b2d37d5a..00000000
--- a/util/updates/update_1.18.sql
+++ /dev/null
@@ -1,31 +0,0 @@
-
--- prev -> rev
-ALTER TABLE changes ADD COLUMN rev integer NOT NULL DEFAULT 1;
-ALTER TABLE changes DROP COLUMN prev;
-
-DROP FUNCTION update_prev(text, text);
-
-CREATE OR REPLACE FUNCTION update_rev(tbl text, ids text) RETURNS void AS $$
-DECLARE
- r RECORD;
- r2 RECORD;
- i integer;
- t text;
- e text;
-BEGIN
- SELECT INTO t SUBSTRING(tbl, 1, 1);
- e := '';
- IF ids <> '' THEN
- e := ' WHERE id IN('||ids||')';
- END IF;
- FOR r IN EXECUTE 'SELECT id FROM '||tbl||e LOOP
- i := 1;
- FOR r2 IN EXECUTE 'SELECT id FROM '||tbl||'_rev WHERE '||t||'id = '||r.id||' ORDER BY id ASC' LOOP
- UPDATE changes SET rev = i WHERE id = r2.id;
- i := i+1;
- END LOOP;
- END LOOP;
-END;
-$$ LANGUAGE plpgsql;
-SELECT update_rev('vn', ''), update_rev('releases', ''), update_rev('producers', '');
-
diff --git a/util/updates/update_1.19.sql b/util/updates/update_1.19.sql
deleted file mode 100644
index a28b525a..00000000
--- a/util/updates/update_1.19.sql
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
--- Messageboard
-CREATE TABLE threads (
- id SERIAL NOT NULL PRIMARY KEY,
- title varchar(50) NOT NULL DEFAULT '',
- count smallint NOT NULL DEFAULT 0,
- locked smallint NOT NULL DEFAULT 0,
- hidden smallint NOT NULL DEFAULT 0
-) WITHOUT OIDS;
-
-CREATE TABLE threads_tags (
- tid integer NOT NULL DEFAULT 0 REFERENCES threads (id) DEFERRABLE INITIALLY DEFERRED,
- type char(2) NOT NULL DEFAULT 0,
- iid integer NOT NULL DEFAULT 0, -- references to (vn|releases|producers|users).id
- PRIMARY KEY(tid, type, iid)
-) WITHOUT OIDS;
-
-CREATE TABLE threads_posts (
- tid integer NOT NULL DEFAULT 0 REFERENCES threads (id) DEFERRABLE INITIALLY DEFERRED,
- num integer NOT NULL DEFAULT 0,
- uid integer NOT NULL DEFAULT 0 REFERENCES users (id) DEFERRABLE INITIALLY DEFERRED,
- date bigint NOT NULL DEFAULT DATE_PART('epoch', NOW()),
- edited bigint NOT NULL DEFAULT 0,
- hidden smallint NOT NULL DEFAULT 0,
- msg text NOT NULL DEFAULT '',
- PRIMARY KEY(tid, num)
-) WITHOUT OIDS;
-
-
-
--- Remove the rating/ranking system
-ALTER TABLE vn DROP COLUMN c_votes;
-DROP FUNCTION calculate_rating();
-
diff --git a/util/updates/update_1.2.sql b/util/updates/update_1.2.sql
deleted file mode 100644
index c1b48b84..00000000
--- a/util/updates/update_1.2.sql
+++ /dev/null
@@ -1,9 +0,0 @@
-CREATE TABLE vnlists (
- uid integer NOT NULL DEFAULT 0,
- vid integer NOT NULL DEFAULT 0,
- status smallint NOT NULL DEFAULT 0,
- added bigint NOT NULL DEFAULT 0,
- PRIMARY KEY(uid, vid)
-) WITHOUT OIDS;
-
-ALTER TABlE users ADD COLUMN plist smallint NOT NULL DEFAULT 1;
diff --git a/util/updates/update_1.20.sql b/util/updates/update_1.20.sql
deleted file mode 100644
index ee155f48..00000000
--- a/util/updates/update_1.20.sql
+++ /dev/null
@@ -1,36 +0,0 @@
-
--- deleted user
-INSERT INTO users (id, username, mail, rank)
- VALUES (0, 'deleted', 'del@vndb.org', 0);
-
-
-
--- release lists
-CREATE TABLE rlists (
- uid integer NOT NULL DEFAULT 0 REFERENCES users (id) DEFERRABLE INITIALLY DEFERRED,
- rid integer NOT NULL DEFAULT 0 REFERENCES releases (id) DEFERRABLE INITIALLY DEFERRED,
- vstat smallint NOT NULL DEFAULT 0,
- rstat smallint NOT NULL DEFAULT 0,
- added bigint NOT NULL DEFAULT DATE_PART('epoch', NOW()),
- PRIMARY KEY(uid, rid)
-) WITHOUT OIDS;
-
-
--- wishlist
-CREATE TABLE wlists (
- uid integer NOT NULL DEFAULT 0 REFERENCES users (id) DEFERRABLE INITIALLY DEFERRED,
- vid integer NOT NULL DEFAULT 0 REFERENCES vn (id) DEFERRABLE INITIALLY DEFERRED,
- wstat smallint NOT NULL DEFAULT 0,
- added bigint NOT NULL DEFAULT DATE_PART('epoch', NOW()),
- PRIMARY KEY(uid, vid)
-) WITHOUT OIDS;
-
-
--- move 'Wishlist' and 'Blacklist' statuses of the old VNList to the new wishlist
-INSERT INTO wlists (uid, vid, wstat, added)
- (SELECT uid, vid, CASE WHEN status = 0 THEN 1 ELSE 3 END, date
- FROM vnlists
- WHERE status < 2);
-
-DELETE FROM vnlists WHERE status < 2 AND comments = '';
-
diff --git a/util/updates/update_1.21.pl b/util/updates/update_1.21.pl
deleted file mode 100755
index d59d3603..00000000
--- a/util/updates/update_1.21.pl
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/usr/bin/perl
-
-# create static/sf and static/st with subdirectories
-chdir '/www/vndb/static';
-
-sub mk {
- for (@_) {
- mkdir $_ or die "mkdir: $_: $!";
- chmod 0777, $_ or die "chmod: $_: $!";
- }
-}
-
-mk 'sf', 'st';
-mk sprintf('sf/%02d',$_), sprintf('st/%02d',$_) for (0..99);
-
diff --git a/util/updates/update_1.21.sql b/util/updates/update_1.21.sql
deleted file mode 100644
index 7ae77c2d..00000000
--- a/util/updates/update_1.21.sql
+++ /dev/null
@@ -1,113 +0,0 @@
-
--- screenshots
-CREATE TABLE screenshots (
- id SERIAL NOT NULL PRIMARY KEY,
- status smallint NOT NULL DEFAULT 0, -- 0:unprocessed, 1:processed, <0:error (unimplemented)
- width smallint NOT NULL DEFAULT 0,
- height smallint NOT NULL DEFAULT 0
-) WITHOUT OIDS;
-
-CREATE TABLE vn_screenshots (
- vid integer NOT NULL DEFAULT 0 REFERENCES vn_rev (id) DEFERRABLE INITIALLY DEFERRED,
- scr integer NOT NULL DEFAULT 0 REFERENCES screenshots (id) DEFERRABLE INITIALLY DEFERRED,
- nsfw smallint NOT NULL DEFAULT 0,
- PRIMARY KEY(vid, scr)
-) WITHOUT OIDS;
-
-
-
--- PostgreSQL has a boolean type since 8.1, let's convert our smallints...
--- psql -> perl:
--- No changes required, DBD::Pg automatically converts the boolean type to 1 or 0
--- perl -> psql:
--- psql doesn't accept the integers 1 and 0 as boolean,
--- so I added a !b conversion for VNDB::Util::DB::sqlprint()
-
-ALTER TABLE producers ALTER COLUMN locked DROP DEFAULT;
-ALTER TABLE producers ALTER COLUMN locked TYPE boolean USING locked::text::boolean;
-ALTER TABLE producers ALTER COLUMN locked SET DEFAULT FALSE;
-ALTER TABLE producers ALTER COLUMN hidden DROP DEFAULT;
-ALTER TABLE producers ALTER COLUMN hidden TYPE boolean USING hidden::text::boolean;
-ALTER TABLE producers ALTER COLUMN hidden SET DEFAULT FALSE;
-
-ALTER TABLE releases ALTER COLUMN locked DROP DEFAULT;
-ALTER TABLE releases ALTER COLUMN locked TYPE boolean USING locked::text::boolean;
-ALTER TABLE releases ALTER COLUMN locked SET DEFAULT FALSE;
-ALTER TABLE releases ALTER COLUMN hidden DROP DEFAULT;
-ALTER TABLE releases ALTER COLUMN hidden TYPE boolean USING hidden::text::boolean;
-ALTER TABLE releases ALTER COLUMN hidden SET DEFAULT FALSE;
-
-ALTER TABLE threads ALTER COLUMN locked DROP DEFAULT;
-ALTER TABLE threads ALTER COLUMN locked TYPE boolean USING locked::text::boolean;
-ALTER TABLE threads ALTER COLUMN locked SET DEFAULT FALSE;
-ALTER TABLE threads ALTER COLUMN hidden DROP DEFAULT;
-ALTER TABLE threads ALTER COLUMN hidden TYPE boolean USING hidden::text::boolean;
-ALTER TABLE threads ALTER COLUMN hidden SET DEFAULT FALSE;
-
-ALTER TABLE threads_posts ALTER COLUMN hidden DROP DEFAULT;
-ALTER TABLE threads_posts ALTER COLUMN hidden TYPE boolean USING hidden::text::boolean;
-ALTER TABLE threads_posts ALTER COLUMN hidden SET DEFAULT FALSE;
-
-ALTER TABLE vn ALTER COLUMN locked DROP DEFAULT;
-ALTER TABLE vn ALTER COLUMN locked TYPE boolean USING locked::text::boolean;
-ALTER TABLE vn ALTER COLUMN locked SET DEFAULT FALSE;
-ALTER TABLE vn ALTER COLUMN hidden DROP DEFAULT;
-ALTER TABLE vn ALTER COLUMN hidden TYPE boolean USING hidden::text::boolean;
-ALTER TABLE vn ALTER COLUMN hidden SET DEFAULT FALSE;
-
-ALTER TABLE vn_rev ALTER COLUMN img_nsfw DROP DEFAULT;
-ALTER TABLE vn_rev ALTER COLUMN img_nsfw TYPE boolean USING img_nsfw::text::boolean;
-ALTER TABLE vn_rev ALTER COLUMN img_nsfw SET DEFAULT FALSE;
-
-ALTER TABLE vn_screenshots ALTER COLUMN nsfw DROP DEFAULT;
-ALTER TABLE vn_screenshots ALTER COLUMN nsfw TYPE boolean USING nsfw::text::boolean;
-ALTER TABLE vn_screenshots ALTER COLUMN nsfw SET DEFAULT FALSE;
-
-
-CREATE OR REPLACE FUNCTION update_vncache(id integer) RETURNS void AS $$
-DECLARE
- w text := '';
-BEGIN
- IF id > 0 THEN
- w := ' WHERE id = '||id;
- END IF;
- EXECUTE 'UPDATE vn SET
- c_released = COALESCE((SELECT
- MIN(rr1.released)
- FROM releases_rev rr1
- JOIN releases r1 ON rr1.id = r1.latest
- JOIN releases_vn rv1 ON rr1.id = rv1.rid
- WHERE rv1.vid = vn.id
- AND rr1.type <> 2
- AND r1.hidden = FALSE
- AND rr1.released <> 0
- GROUP BY rv1.vid
- ), 0),
- c_languages = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT language
- FROM releases_rev rr2
- JOIN releases r2 ON rr2.id = r2.latest
- JOIN releases_vn rv2 ON rr2.id = rv2.rid
- WHERE rv2.vid = vn.id
- AND rr2.type <> 2
- AND rr2.released <= TO_CHAR(''today''::timestamp, ''YYYYMMDD'')::integer
- AND r2.hidden = FALSE
- GROUP BY rr2.language
- ORDER BY rr2.language
- ), ''/''), ''''),
- c_platforms = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT rp3.platform
- FROM releases_platforms rp3
- JOIN releases_rev rr3 ON rp3.rid = rr3.id
- JOIN releases r3 ON rp3.rid = r3.latest
- JOIN releases_vn rv3 ON rp3.rid = rv3.rid
- WHERE rv3.vid = vn.id
- AND rr3.type <> 2
- AND rr3.released <= TO_CHAR(''today''::timestamp, ''YYYYMMDD'')::integer
- AND r3.hidden = FALSE
- GROUP BY rp3.platform
- ORDER BY rp3.platform
- ), ''/''), '''')
- '||w;
-END;
-$$ LANGUAGE plpgsql;
diff --git a/util/updates/update_1.22.sh b/util/updates/update_1.22.sh
deleted file mode 100644
index 3fdbaea7..00000000
--- a/util/updates/update_1.22.sh
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/bin/sh
-
-# update_1.22.sql must be executed before this script
-
-cd /www/vndb
-
-# delete all relation graphs (just the files)
-find static/rg -name '*.gif' -delete
-
-# delete all relation graph image maps (entire directory)
-rm -rf data/rg
-
-# regenerate all relation graphs
-util/multi.pl -c 'relgraph all'
-
-
diff --git a/util/updates/update_1.22.sql b/util/updates/update_1.22.sql
deleted file mode 100644
index 897b0a2b..00000000
--- a/util/updates/update_1.22.sql
+++ /dev/null
@@ -1,34 +0,0 @@
-
--- store relation graph image maps in the database
-CREATE TABLE relgraph (
- id SERIAL NOT NULL PRIMARY KEY,
- cmap text NOT NULL DEFAULT ''
-) WITHOUT OIDS;
-
-DROP SEQUENCE relgraph_seq;
-ALTER TABLE vn ALTER COLUMN rgraph DROP NOT NULL;
-ALTER TABLE vn ALTER COLUMN rgraph SET DEFAULT NULL;
-UPDATE vn SET rgraph = NULL;
-ALTER TABLE vn ADD FOREIGN KEY (rgraph) REFERENCES relgraph (id) DEFERRABLE INITIALLY DEFERRED;
-
-
--- add foreign table constraint to changes.causedby
-ALTER TABLE changes ALTER COLUMN causedby DROP NOT NULL;
-ALTER TABLE changes ALTER COLUMN causedby SET DEFAULT NULL;
-UPDATE changes c SET causedby = NULL
- WHERE causedby = 0
- -- yup, there are some problems caused by deleted revisions in older versions of the site
- OR NOT EXISTS(SELECT 1 FROM changes WHERE c.causedby = id);
-ALTER TABLE changes ADD FOREIGN KEY (causedby) REFERENCES changes (id) DEFERRABLE INITIALLY DEFERRED;
-
-
--- another foreign key constraint: (threads.id, threads.count) -> (threads_posts.tid, threads_posts.num)
--- threads_posts converted to smallint as well
-ALTER TABLE threads_posts ALTER COLUMN num TYPE smallint;
-ALTER TABLE threads ADD FOREIGN KEY (id, count) REFERENCES threads_posts (tid, num) DEFERRABLE INITIALLY DEFERRED;
-
-
--- screenshots now have a relation with releases
-ALTER TABLE vn_screenshots ADD COLUMN rid integer DEFAULT NULL REFERENCES releases (id) DEFERRABLE INITIALLY DEFERRED;
-
-
diff --git a/util/updates/update_1.23.sql b/util/updates/update_1.23.sql
deleted file mode 100644
index 909cb229..00000000
--- a/util/updates/update_1.23.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-
-ALTER TABLE vn_rev ADD COLUMN original VARCHAR(250) NOT NULL DEFAULT '';
-
diff --git a/util/updates/update_1.4.sql b/util/updates/update_1.4.sql
deleted file mode 100644
index 783c0029..00000000
--- a/util/updates/update_1.4.sql
+++ /dev/null
@@ -1,37 +0,0 @@
-UPDATE vn_categories SET category = 'aaa' WHERE category = 'ami';
-
---CREATE TABLE changes (
--- id SERIAL NOT NULL PRIMARY KEY,
--- "type" smallint DEFAULT 0 NOT NULL,
--- rel integer DEFAULT 0 NOT NULL,
--- vrel integer DEFAULT 0 NOT NULL,
--- uid integer DEFAULT 0 NOT NULL,
--- status smallint DEFAULT 0 NOT NULL,
--- added bigint DEFAULT 0 NOT NULL,
--- lastmod bigint DEFAULT 0 NOT NULL,
--- changes bytea DEFAULT ''::bytea NOT NULL,
--- comments text DEFAULT '' NOT NULL
---);
-
-
-CREATE LANGUAGE plpgsql;
-CREATE OR REPLACE FUNCTION get_new_id() RETURNS integer AS $$
-DECLARE
- i integer := 1;
- r RECORD;
-BEGIN
- FOR r IN SELECT id FROM vn ORDER BY id ASC LOOP
- IF i <> r.id THEN
- EXIT;
- END IF;
- i := i+1;
- END LOOP;
- RETURN i;
-END;
-$$ LANGUAGE plpgsql;
-
-ALTER TABLE vn ALTER COLUMN id SET DEFAULT get_new_id();
-DROP SEQUENCE vn_id_seq;
-
-
-ALTER TABLE vnr ADD COLUMN notes varchar(250) DEFAULT '';
diff --git a/util/updates/update_1.5.sql b/util/updates/update_1.5.sql
deleted file mode 100644
index 970aa0e1..00000000
--- a/util/updates/update_1.5.sql
+++ /dev/null
@@ -1,10 +0,0 @@
-CREATE TABLE vn_relations (
- vid1 integer NOT NULL,
- vid2 integer NOT NULL,
- relation smallint NOT NULL,
- lastmod bigint NOT NULL,
- PRIMARY KEY(vid1, vid2)
-);
-
-ALTER TABLE vn ADD COLUMN img_nsfw smallint NOT NULL DEFAULT 0;
-ALTER TABLE users ADD COLUMN pign_nsfw smallint NOT NULL DEFAULT 0;
diff --git a/util/updates/update_1.6.sql b/util/updates/update_1.6.sql
deleted file mode 100644
index a1c8ea23..00000000
--- a/util/updates/update_1.6.sql
+++ /dev/null
@@ -1,21 +0,0 @@
-ALTER TABLE vnr DROP COLUMN rel_old;
-ALTER TABLE vnr ALTER COLUMN released DROP NOT NULL;
-ALTER TABLE vnr ALTER COLUMN released SET DEFAULT NULL;
-UPDATE vnr SET released = NULL WHERE released = '0000-00-00';
-
-ALTER TABLE vn RENAME COLUMN c_years TO c_released;
-UPDATE vn SET c_released = '0000-00';
-ALTER TABLE vn ALTER COLUMN c_released SET DEFAULT '0000-00';
-ALTER TABLE vn ALTER COLUMN c_released TYPE character(7);
-UPDATE vn SET
- c_released = COALESCE((SELECT
- SUBSTRING(COALESCE(MIN(released), '0000-00') from 1 for 7)
- FROM vnr r1
- WHERE r1.vid = vn.id
- AND r1.r_rel = 0
- GROUP BY r1.vid
- ), '0000-00');
-
-
-ALTER TABLE vn_relations DROP COLUMN lastmod;
-ALTER TABLE vn ADD COLUMN rgraph bytea NOT NULL DEFAULT '';
diff --git a/util/updates/update_1.7.sql b/util/updates/update_1.7.sql
deleted file mode 100644
index 3ee6f5a2..00000000
--- a/util/updates/update_1.7.sql
+++ /dev/null
@@ -1,23 +0,0 @@
-ALTER TABLE producers ADD COLUMN "desc" text NOT NULL DEFAULT '';
-
-
---ALTER TABLE users ADD COLUMN flags bit(4) NOT NULL DEFAULT B'1110';
---UPDATE users SET flags = pvotes::bit || pfind::bit || plist::bit || pign_nsfw::bit;
-ALTER TABLE users ADD COLUMN flags integer NOT NULL DEFAULT 7;
-UPDATE users SET flags = pvotes + pfind*2 + plist*4 + pign_nsfw*8;
-
---ALTER TABLE users DROP COLUMN pvotes;
---ALTER TABLE users DROP COLUMN pfind;
---ALTER TABLE users DROP COLUMN plist;
---ALTER TABLE users DROP COLUMN pign_nsfw;
-
-
---ALTER TABLE vn ADD COLUMN categories integer NOT NULL DEFAULT 0;
---UPDATE vn SET categories =
--- COALESCE((SELECT 1 FROM vn_categories WHERE vid = vn.id AND category = 'a18'), 0)
--- +COALESCE((SELECT 2 FROM vn_categories WHERE vid = vn.id AND category = 'aaa'), 0)
--- +COALESCE((SELECT 4 FROM vn_categories WHERE vid = vn.id AND category = 'ajo'), 0)
--- +COALESCE((SELECT 8 FROM vn_categories WHERE vid = vn.id AND category = 'ako'), 0)
--- +COALESCE((SELECT 16 FROM vn_categories WHERE vid = vn.id AND category = 'ase'), 0)
--- +COALESCE((SELECT 32 FROM vn_categories WHERE vid = vn.id AND category = 'asj'), 0)
--- +COALESCE((SELECT 64 FROM vn_categories WHERE vid = vn.id AND category = 'asn'), 0);
diff --git a/util/updates/update_1.8.sql b/util/updates/update_1.8.sql
deleted file mode 100644
index b9e58ae6..00000000
--- a/util/updates/update_1.8.sql
+++ /dev/null
@@ -1,27 +0,0 @@
-ALTER TABLE vn ADD COLUMN length smallint NOT NULL DEFAULT 0;
-
-DELETE FROM vn_categories WHERE SUBSTR(category, 1, 1) = 'a';
-ALTER TABLE vnr ADD COLUMN minage smallint NOT NULL DEFAULT -1;
-
-ALTER TABLE vn ADD COLUMN l_wp varchar(150) NOT NULL DEFAULT '';
-ALTER TABLE vn ADD COLUMN l_cisv integer NOT NULL DEFAULT 0;
-
-
-UPDATE vn SET
- c_released = COALESCE((
- SELECT SUBSTRING(COALESCE(MIN(released), '0000-00') from 1 for 7)
- FROM vnr r1
- WHERE r1.vid = vn.id
- AND r1.r_rel = 0
- AND r1.relation NOT ILIKE 'trial'
- GROUP BY r1.vid
- ), '0000-00'),
- c_languages = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT language
- FROM vnr r2
- WHERE r2.vid = vn.id
- AND r2.r_rel = 0
- AND r2.relation NOT ILIKE 'trial'
- GROUP BY language
- ORDER BY language
- ), '/'), '');
diff --git a/util/updates/update_1.9.sql b/util/updates/update_1.9.sql
deleted file mode 100644
index ad36ec2c..00000000
--- a/util/updates/update_1.9.sql
+++ /dev/null
@@ -1,375 +0,0 @@
-CREATE TABLE changes (
- id SERIAL NOT NULL PRIMARY KEY,
- "type" smallint NOT NULL DEFAULT 0,
- added bigint NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
- requester integer NOT NULL DEFAULT 0,
- ip inet NOT NULL DEFAULT '0.0.0.0',
- comments text NOT NULL DEFAULT '',
- prev integer NOT NULL DEFAULT 0,
- causedby integer NOT NULL DEFAULT 0
-) WITHOUT OIDS;
-
-INSERT INTO users (id, username, mail, rank, registered)
- VALUES (1, 'multi', 'multi@vndb.org', 0, EXTRACT(EPOCH FROM NOW()));
-
-CREATE OR REPLACE FUNCTION get_new_id(tbl text) RETURNS integer AS $$
-DECLARE
- i integer := 1;
- r RECORD;
-BEGIN
- FOR r IN EXECUTE 'SELECT id FROM '||tbl||' ORDER BY id ASC' LOOP
- IF i <> r.id THEN
- EXIT;
- END IF;
- i := i + 1;
- END LOOP;
- RETURN i;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
-
--- V i s u a l N o v e l s
-
-
-ALTER TABLE vn RENAME TO vn_old;
-ALTER TABLE vn_relations RENAME TO vn_relations_old;
-
-CREATE TABLE vn (
- id integer NOT NULL DEFAULT get_new_id('vn') PRIMARY KEY,
- latest integer NOT NULL DEFAULT 0,
- locked smallint NOT NULL DEFAULT 0,
- rgraph bytea NOT NULL DEFAULT '',
- c_released character(7) NOT NULL DEFAULT '0000-00',
- c_languages varchar(32) NOT NULL DEFAULT '',
- c_votes character(9) NOT NULL DEFAULT '00.0|0000'
-) WITHOUT OIDS;
-
-CREATE TABLE vn_rev (
- id integer NOT NULL PRIMARY KEY,
- vid integer NOT NULL DEFAULT 0,
- title varchar(250) NOT NULL DEFAULT '',
- alias varchar(500) NOT NULL DEFAULT '',
- image bytea NOT NULL DEFAULT '',
- img_nsfw smallint NOT NULL DEFAULT 0,
- length smallint NOT NULL DEFAULT 0,
- "desc" text NOT NULL DEFAULT '',
- categories integer NOT NULL DEFAULT 0,
- l_wp varchar(150) NOT NULL DEFAULT '',
- l_cisv integer NOT NULL DEFAULT 0
-) WITHOUT OIDS;
-
-CREATE TABLE vn_relations (
- vid1 integer NOT NULL DEFAULT 0,
- vid2 integer NOT NULL DEFAULT 0,
- relation integer NOT NULL DEFAULT 0,
- PRIMARY KEY(vid1, vid2)
-) WITHOUT OIDS;
-
-CREATE OR REPLACE FUNCTION fill_vn() RETURNS void AS $$
-DECLARE
- r RECORD;
- r2 RECORD;
- i integer;
- rel integer;
-BEGIN
- FOR r IN SELECT * FROM vn_old ORDER BY added LOOP
- INSERT INTO changes ("type", added, requester, comments)
- VALUES (0, r.added, 1, 'Automated import from VNDB 1.8');
-
- SELECT currval('changes_id_seq') INTO i;
-
- INSERT INTO vn_rev (id, vid, title, alias, image, img_nsfw, length, "desc", l_wp, l_cisv, categories)
- VALUES (i, r.id, r.title, r.alias, r.image, r.img_nsfw, r.length, r.desc, r.l_wp, r.l_cisv, (
- -- ZOMFG DENORMALIZATION LOL!
- COALESCE((SELECT 1 FROM vn_categories WHERE vid = r.id AND category = 'eac'), 0)
- +COALESCE((SELECT 2 FROM vn_categories WHERE vid = r.id AND category = 'eco'), 0)
- +COALESCE((SELECT 4 FROM vn_categories WHERE vid = r.id AND category = 'edr'), 0)
- +COALESCE((SELECT 8 FROM vn_categories WHERE vid = r.id AND category = 'efa'), 0)
- +COALESCE((SELECT 16 FROM vn_categories WHERE vid = r.id AND category = 'eho'), 0)
- +COALESCE((SELECT 32 FROM vn_categories WHERE vid = r.id AND category = 'emy'), 0)
- +COALESCE((SELECT 64 FROM vn_categories WHERE vid = r.id AND category = 'ero'), 0)
- +COALESCE((SELECT 128 FROM vn_categories WHERE vid = r.id AND category = 'esf'), 0)
- +COALESCE((SELECT 256 FROM vn_categories WHERE vid = r.id AND category = 'eja'), 0)
- +COALESCE((SELECT 512 FROM vn_categories WHERE vid = r.id AND category = 'ena'), 0)
- +COALESCE((SELECT 1024 FROM vn_categories WHERE vid = r.id AND category = 'tfu'), 0)
- +COALESCE((SELECT 2048 FROM vn_categories WHERE vid = r.id AND category = 'tpa'), 0)
- +COALESCE((SELECT 4096 FROM vn_categories WHERE vid = r.id AND category = 'tpr'), 0)
- +COALESCE((SELECT 8192 FROM vn_categories WHERE vid = r.id AND category = 'pea'), 0)
- +COALESCE((SELECT 16384 FROM vn_categories WHERE vid = r.id AND category = 'pfw'), 0)
- +COALESCE((SELECT 32768 FROM vn_categories WHERE vid = r.id AND category = 'psp'), 0)
- +COALESCE((SELECT 65536 FROM vn_categories WHERE vid = r.id AND category = 'spa'), 0)
- +COALESCE((SELECT 131072 FROM vn_categories WHERE vid = r.id AND category = 'sbe'), 0)
- +COALESCE((SELECT 262144 FROM vn_categories WHERE vid = r.id AND category = 'sin'), 0)
- +COALESCE((SELECT 524288 FROM vn_categories WHERE vid = r.id AND category = 'slo'), 0)
- +COALESCE((SELECT 1048576 FROM vn_categories WHERE vid = r.id AND category = 'scc'), 0)
- +COALESCE((SELECT 2097152 FROM vn_categories WHERE vid = r.id AND category = 'sya'), 0)
- +COALESCE((SELECT 4194304 FROM vn_categories WHERE vid = r.id AND category = 'syu'), 0)
- +COALESCE((SELECT 8388608 FROM vn_categories WHERE vid = r.id AND category = 'sra'), 0)
- ));
-
- INSERT INTO vn (id, latest, locked, rgraph, c_released, c_languages, c_votes)
- VALUES (r.id, i, r.locked, r.rgraph, r.c_released, r.c_languages, r.c_votes);
-
- FOR r2 IN SELECT * FROM vn_relations_old WHERE vid2 = r.id LOOP
- INSERT INTO vn_relations (vid1, vid2, relation)
- VALUES(i, r2.vid1, r2.relation);
- END LOOP;
- FOR r2 IN SELECT * FROM vn_relations_old WHERE vid1 = r.id LOOP
- rel := r2.relation;
- IF rel = 0 OR rel = 6 OR rel = 8 THEN
- rel := rel+1;
- END IF;
- INSERT INTO vn_relations (vid1, vid2, relation)
- VALUES(i, r2.vid2, rel);
- END LOOP;
- END LOOP;
-END;
-$$ LANGUAGE plpgsql;
-SELECT fill_vn();
-DROP FUNCTION fill_vn();
-
-
-
-
-
--- R e l e a s e s
-
-
-ALTER TABLE vnr RENAME TO vnr_old;
-
-CREATE TABLE releases (
- id integer NOT NULL DEFAULT get_new_id('releases') PRIMARY KEY,
- latest integer NOT NULL DEFAULT 0,
- vid integer NOT NULL DEFAULT 0,
- locked smallint NOT NULL DEFAULT 0
-) WITHOUT OIDS;
-
-CREATE TABLE releases_rev (
- id integer NOT NULL PRIMARY KEY,
- rid integer NOT NULL DEFAULT 0,
- title varchar(250) NOT NULL DEFAULT '',
- original varchar(250) NOT NULL DEFAULT '',
- "type" smallint NOT NULL DEFAULT 0,
- relation varchar(32) NOT NULL DEFAULT '', -- deprecated
- language varchar NOT NULL DEFAULT 'ja',
- website varchar(250) NOT NULL DEFAULT '',
- released varchar(10),
- notes varchar(250) NOT NULL DEFAULT '',
- minage smallint NOT NULL DEFAULT -1
-) WITHOUT OIDS;
-
-ALTER TABLE vnr_media RENAME TO releases_media;
-ALTER TABLE vnr_platforms RENAME TO releases_platforms;
-ALTER TABLE vnr_producers RENAME TO releases_producers;
-ALTER TABLE releases_media RENAME vnrid TO rid;
-ALTER TABLE releases_platforms RENAME vnrid TO rid;
-ALTER TABLE releases_producers RENAME vnrid TO rid;
-ALTER TABLE releases_media ADD COLUMN tmp_upd smallint DEFAULT 0;
-ALTER TABLE releases_platforms ADD COLUMN tmp_upd smallint DEFAULT 0;
-ALTER TABLE releases_producers ADD COLUMN tmp_upd smallint DEFAULT 0;
-ALTER TABLE releases_platforms DROP CONSTRAINT vnv_platforms_pkey;
-ALTER TABLE releases_producers DROP CONSTRAINT vnv_companies_pkey;
-
-
-CREATE OR REPLACE FUNCTION fill_releases() RETURNS void AS $$
-DECLARE
- r RECORD;
- i integer;
- t integer;
- ti text;
- tg text;
-BEGIN
- FOR r IN SELECT * FROM vnr_old ORDER BY added LOOP
- INSERT INTO changes ("type", added, requester, comments)
- VALUES (1, r.added, 1, 'Automated import from VNDB 1.8');
-
- SELECT currval('changes_id_seq') INTO i;
-
- -- swap titles
- ti := r.romaji;
- tg := r.title;
- IF ti = '' THEN
- ti := r.title;
- tg := '';
- END IF;
- -- determine type
- t := 0;
- IF r.relation ILIKE '%trial%' OR r.relation ILIKE '%demo%' THEN
- t := 2;
- END IF;
-
- INSERT INTO releases_rev (id, rid, title, original, relation, language, website, released, notes, minage, "type")
- VALUES (i, r.id, ti, tg, r.relation, r.language, r.website, r.released, r.notes, r.minage, t);
-
- INSERT INTO releases (id, latest, vid)
- VALUES (r.id, i, r.vid);
-
- UPDATE releases_media SET rid = i, tmp_upd = 1 WHERE rid = r.id AND tmp_upd = 0;
- UPDATE releases_producers SET rid = i, tmp_upd = 1 WHERE rid = r.id AND tmp_upd = 0;
- UPDATE releases_platforms SET rid = i, tmp_upd = 1 WHERE rid = r.id AND tmp_upd = 0;
- END LOOP;
-END;
-$$ LANGUAGE plpgsql;
-SELECT fill_releases();
-DROP FUNCTION fill_releases();
-
-ALTER TABLE releases_media DROP COLUMN tmp_upd;
-ALTER TABLE releases_producers DROP COLUMN tmp_upd;
-ALTER TABLE releases_platforms DROP COLUMN tmp_upd;
-ALTER TABLE releases_producers ADD CONSTRAINT releases_producers_pkey PRIMARY KEY (pid, rid);
-ALTER TABLE releases_media ADD CONSTRAINT releases_media_pkey PRIMARY KEY (rid, medium, qty);
-ALTER TABLE releases_platforms ADD CONSTRAINT releases_platforms_pkey PRIMARY KEY (rid, platform);
-
-
-
-
-
--- P r o d u c e r s
-
-
-ALTER TABLE producers RENAME TO producers_old;
-
-CREATE TABLE producers (
- id integer NOT NULL DEFAULT get_new_id('producers') PRIMARY KEY,
- latest integer NOT NULL DEFAULT 0,
- locked smallint NOT NULL DEFAULT 0
-) WITHOUT OIDS;
-
-CREATE TABLE producers_rev (
- id integer NOT NULL PRIMARY KEY,
- pid integer NOT NULL DEFAULT 0,
- "type" character(2) NOT NULL DEFAULT 'co',
- name varchar(200) NOT NULL DEFAULT '',
- original varchar(200) NOT NULL DEFAULT '',
- website varchar(250) NOT NULL DEFAULT '',
- lang varchar NOT NULL DEFAULT 'ja',
- "desc" text NOT NULL DEFAULT ''
-) WITHOUT OIDS;
-
-CREATE OR REPLACE FUNCTION fill_producers() RETURNS void AS $$
-DECLARE
- r RECORD;
- i integer;
-BEGIN
- FOR r IN SELECT * FROM producers_old ORDER BY added LOOP
- INSERT INTO changes ("type", added, requester, comments)
- VALUES (2, r.added, 1, 'Automated import from VNDB 1.8');
-
- SELECT currval('changes_id_seq') INTO i;
-
- INSERT INTO producers_rev (id, pid, "type", name, original, website, lang, "desc")
- VALUES (i, r.id, r.type, r.name, r.original, r.website, r.lang, r.desc);
-
- INSERT INTO producers (id, latest, locked)
- VALUES (r.id, i, 0);
- END LOOP;
-END;
-$$ LANGUAGE plpgsql;
-SELECT fill_producers();
-DROP FUNCTION fill_producers();
-
-
-
-
-
-
-
-DROP TABLE vn_old;
-DROP TABLE vn_relations_old;
-DROP TABLE vn_categories;
-DROP TABLE vnr_old;
-DROP TABLE producers_old;
-DROP FUNCTION get_new_id();
-
-
-UPDATE users SET rank = rank+1;
-ALTER TABLE users ALTER COLUMN rank SET DEFAULT 2;
-
-
-
-
-
--- F u n c t i o n s
-
-
--- ids = empty string or comma-seperated list of id's (as a string)
-CREATE OR REPLACE FUNCTION update_prev(tbl text, ids text) RETURNS void AS $$
-DECLARE
- r RECORD;
- r2 RECORD;
- i integer;
- t text;
- e text;
-BEGIN
- SELECT INTO t SUBSTRING(tbl, 0, 1);
- e := '';
- IF ids <> '' THEN
- e := ' WHERE id IN('||ids||')';
- END IF;
- FOR r IN EXECUTE 'SELECT id FROM '||tbl||e LOOP
- i := 0;
- FOR r2 IN EXECUTE 'SELECT id FROM '||tbl||'_rev WHERE '||t||'id = '||r.id||' ORDER BY id ASC' LOOP
- UPDATE changes SET prev = i WHERE id = r2.id;
- i := r2.id;
- END LOOP;
- END LOOP;
-END;
-$$ LANGUAGE plpgsql;
-
--- /what/ bitflags: released, languages, votes
--- Typical yorhel-code: ugly...
-CREATE OR REPLACE FUNCTION update_vncache(what integer, id integer) RETURNS void AS $$
-DECLARE
- s text := '';
- w text := '';
-BEGIN
- IF what < 1 OR what > 7 THEN
- RETURN;
- END IF;
- IF what & 1 = 1 THEN
- s := 'c_released = COALESCE((SELECT
- SUBSTRING(COALESCE(MIN(rr1.released), ''0000-00'') from 1 for 7)
- FROM releases r1
- JOIN releases_rev rr1 ON r1.latest = rr1.id
- WHERE r1.vid = vn.id
- AND rr1.type <> 2
- GROUP BY r1.vid
- ), ''0000-00'')';
- END IF;
- IF what & 2 = 2 THEN
- IF s <> '' THEN
- s := s||', ';
- END IF;
- s := s||'c_languages = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT language
- FROM releases r2
- JOIN releases_rev rr2 ON r2.latest = rr2.id
- WHERE r2.vid = vn.id
- AND rr2.type <> 2
- GROUP BY rr2.language
- ORDER BY rr2.language
- ), ''/''), '''')';
- END IF;
- IF what & 4 = 4 THEN
- IF s <> '' THEN
- s := s||', ';
- END IF;
- s := s||'c_votes = COALESCE((SELECT
- TO_CHAR(CASE WHEN COUNT(uid) < 2 THEN 0 ELSE AVG(vote) END, ''FM00D0'')||''|''||TO_CHAR(COUNT(uid), ''FM0000'')
- FROM votes
- WHERE vid = vn.id
- GROUP BY vid
- ), ''00.0|0000'')';
- END IF;
- IF id > 0 THEN
- w := ' WHERE id = '||id;
- END IF;
- EXECUTE 'UPDATE vn SET '||s||w;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
-
diff --git a/util/updates/update_2.0.sql b/util/updates/update_2.0.sql
deleted file mode 100644
index f371c4de..00000000
--- a/util/updates/update_2.0.sql
+++ /dev/null
@@ -1,140 +0,0 @@
-
-
--- cache users vote and edit count
-ALTER TABLE users ADD COLUMN c_votes integer NOT NULL DEFAULT 0;
-ALTER TABLE users ADD COLUMN c_changes integer NOT NULL DEFAULT 0;
-
-
--- may be an idea to run this query as a monthly cron or something
-UPDATE users SET
- c_votes = COALESCE(
- (SELECT COUNT(vid)
- FROM votes
- WHERE uid = users.id
- GROUP BY uid
- ), 0),
- c_changes = COALESCE(
- (SELECT COUNT(id)
- FROM changes
- WHERE requester = users.id
- GROUP BY requester
- ), 0);
-
-
--- one function to rule them all
-CREATE OR REPLACE FUNCTION update_users_cache() RETURNS TRIGGER AS $$
-BEGIN
- IF TG_TABLE_NAME = 'votes' THEN
- IF TG_OP = 'INSERT' THEN
- UPDATE users SET c_votes = c_votes + 1 WHERE id = NEW.uid;
- ELSE
- UPDATE users SET c_votes = c_votes - 1 WHERE id = OLD.uid;
- END IF;
- ELSE
- IF TG_OP = 'INSERT' THEN
- UPDATE users SET c_changes = c_changes + 1 WHERE id = NEW.requester;
- ELSE
- UPDATE users SET c_changes = c_changes - 1 WHERE id = OLD.requester;
- 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_votes_update AFTER INSERT OR DELETE ON votes FOR EACH ROW EXECUTE PROCEDURE update_users_cache();
-
-
-
-
--- users.flags -> users.(show_nsfw|show_list)
-ALTER TABLE users ADD COLUMN show_nsfw boolean NOT NULL DEFAULT FALSE;
-ALTER TABLE users ADD COLUMN show_list boolean NOT NULL DEFAULT TRUE;
-
-UPDATE users SET
- show_nsfw = (flags & 8 = 8),
- show_list = (flags & 4 = 4);
-
-ALTER TABLE users DROP COLUMN flags;
-
-
-
-
--- get rid of \r and leading and trailing whitespace
-UPDATE vn_rev
- SET "desc" = trim(both E'\n ' from translate("desc", E'\r', '')),
- alias = trim(both E'\n ' from translate(alias, E'\r', ''));
-UPDATE releases_rev
- SET notes = trim(both E'\n ' from translate(notes, E'\r', ''));
-UPDATE producers_rev
- SET "desc" = trim(both E'\n ' from translate("desc", E'\r', ''));
-UPDATE changes
- SET comments = trim(both E'\n ' from translate(comments, E'\r', ''));
-UPDATE threads_posts
- SET msg = trim(both E'\n ' from translate(msg, E'\r', ''));
-
-
-
-
--- cache some database statistics
-CREATE TABLE stats_cache (
- section varchar(25) NOT NULL PRIMARY KEY,
- count integer NOT NULL DEFAULT 0
-);
-INSERT INTO stats_cache (section, count) VALUES
- ('users', (SELECT COUNT(*) FROM users)-1),
- ('vn', (SELECT COUNT(*) FROM vn WHERE hidden = FALSE)),
- ('producers', (SELECT COUNT(*) FROM producers WHERE hidden = FALSE)),
- ('releases', (SELECT COUNT(*) FROM releases WHERE hidden = FALSE)),
- ('threads', (SELECT COUNT(*) FROM threads WHERE hidden = FALSE)),
- ('threads_posts', (SELECT COUNT(*) FROM threads_posts WHERE hidden = FALSE AND EXISTS(SELECT 1 FROM threads WHERE threads.id = tid AND threads.hidden = FALSE)));
-
-CREATE OR REPLACE FUNCTION update_stats_cache() RETURNS TRIGGER AS $$
-BEGIN
- IF TG_OP = 'INSERT' THEN
- IF TG_TABLE_NAME = 'users' THEN
- UPDATE stats_cache SET count = count+1 WHERE section = TG_TABLE_NAME;
- ELSIF NEW.hidden = FALSE THEN
- IF TG_TABLE_NAME = 'threads_posts' THEN
- IF EXISTS(SELECT 1 FROM threads WHERE id = NEW.tid AND 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;
-
- ELSIF TG_OP = 'UPDATE' AND TG_TABLE_NAME <> 'users' THEN
- IF OLD.hidden = TRUE AND NEW.hidden = FALSE THEN
- IF TG_TABLE_NAME = 'threads' THEN
- UPDATE stats_cache SET count = count+NEW.count WHERE section = 'threads_posts';
- END IF;
- UPDATE stats_cache SET count = count+1 WHERE section = TG_TABLE_NAME;
- ELSIF OLD.hidden = FALSE AND NEW.hidden = TRUE THEN
- IF TG_TABLE_NAME = 'threads' THEN
- UPDATE stats_cache SET count = count-NEW.count WHERE section = 'threads_posts';
- END IF;
- 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;
-$$ LANGUAGE 'plpgsql';
-
-CREATE TRIGGER vn_stats_update AFTER INSERT OR UPDATE ON vn FOR EACH ROW EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER producers_stats_update AFTER INSERT OR UPDATE ON producers FOR EACH ROW EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER releases_stats_update AFTER INSERT OR UPDATE ON releases FOR EACH ROW EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER threads_stats_update AFTER INSERT OR UPDATE ON threads FOR EACH ROW EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER threads_posts_stats_update AFTER INSERT OR UPDATE ON threads_posts FOR EACH ROW EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER users_stats_update AFTER INSERT OR DELETE ON users FOR EACH ROW EXECUTE PROCEDURE update_stats_cache();
-
-
-
--- extra user rank
-UPDATE users SET rank = rank+1;
-ALTER TABLE users ALTER COLUMN rank SET DEFAULT 3;
-
-
diff --git a/util/updates/update_2.1.sql b/util/updates/update_2.1.sql
deleted file mode 100644
index 7ae8fecf..00000000
--- a/util/updates/update_2.1.sql
+++ /dev/null
@@ -1,4 +0,0 @@
-
--- skin selector
-ALTER TABLE users ADD COLUMN skin varchar(128) NOT NULL DEFAULT '';
-
diff --git a/util/updates/update_2.10.sql b/util/updates/update_2.10.sql
deleted file mode 100644
index e54e028c..00000000
--- a/util/updates/update_2.10.sql
+++ /dev/null
@@ -1,63 +0,0 @@
-
--- no more bayesian rating for VN list on tag pages, just plain averages
-DROP TABLE tags_vn_bayesian;
-CREATE TABLE tags_vn_inherit (
- tag integer NOT NULL,
- vid integer NOT NULL,
- users integer NOT NULL,
- rating real NOT NULL,
- spoiler smallint NOT NULL
-);
-
-
--- remove unused functions
-DROP FUNCTION tag_vn_childs() CASCADE;
-DROP FUNCTION tag_tree(integer, integer, boolean);
-DROP TYPE tag_tree_item;
-
-
--- remove changes.causedby and give the affected changes to Multi
-UPDATE changes SET requester = 1 WHERE causedby IS NOT NULL;
-ALTER TABLE changes DROP COLUMN causedby;
-UPDATE users SET
- c_changes = COALESCE((
- SELECT COUNT(id)
- FROM changes
- WHERE requester = users.id
- GROUP BY requester
- ), 0);
-
-
--- set default on releases_rev.released, required for the revision insertion abstraction
-ALTER TABLE releases_rev ALTER COLUMN released SET DEFAULT 0;
-
-
--- type used for the revision inserting functions
-CREATE TYPE edit_rettype AS (iid integer, cid integer, rev integer);
-
-
--- import the new and updated functions
-\i util/sql/func.sql
-
-
--- call update_vncache() when a release is added, edited, hidden or unhidden
-CREATE TRIGGER release_vncache_update AFTER UPDATE ON releases FOR EACH ROW EXECUTE PROCEDURE release_vncache_update();
-
-
--- improved relgraph notify triggers
-DROP TRIGGER vn_relgraph_notify ON vn;
-CREATE TRIGGER vn_relgraph_notify AFTER UPDATE ON vn FOR EACH ROW EXECUTE PROCEDURE vn_relgraph_notify();
-DROP TRIGGER vn_relgraph_notify ON producers;
-CREATE TRIGGER producer_relgraph_notify AFTER UPDATE ON producers FOR EACH ROW EXECUTE PROCEDURE producer_relgraph_notify();
-
-
--- more efficient version of tag_vn_calc()
-SELECT tag_vn_calc();
-
-
--- regenerate the relation graphs so that they contain IDs for highlighting
-UPDATE vn SET rgraph = NULL;
-UPDATE producers SET rgraph = NULL;
-DELETE FROM relgraphs;
-
-
diff --git a/util/updates/update_2.11.sql b/util/updates/update_2.11.sql
deleted file mode 100644
index 65571fa9..00000000
--- a/util/updates/update_2.11.sql
+++ /dev/null
@@ -1,120 +0,0 @@
-
-
-CREATE TYPE notification_ntype AS ENUM ('pm', 'dbdel', 'listdel', 'dbedit', 'announce');
-CREATE TYPE notification_ltype AS ENUM ('v', 'r', 'p', 't');
-
-CREATE TABLE notifications (
- id serial PRIMARY KEY NOT NULL,
- uid integer NOT NULL REFERENCES users (id),
- 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 REFERENCES users (id)
-);
-
--- convert the "unread messages" count into notifications
-INSERT INTO notifications (uid, date, ntype, ltype, iid, subid, c_title, c_byuser)
- SELECT tb.iid, tp.date, 'pm', 't', t.id, tp.num, t.title, tp.uid
- FROM threads_boards tb
- JOIN threads t ON t.id = tb.tid
- JOIN threads_posts tp ON tp.tid = t.id AND tp.num = COALESCE(tb.lastread, 1)
- WHERE tb.type = 'u' AND NOT t.hidden AND (tb.lastread IS NULL OR t.count <> tb.lastread);
-
--- ...and drop the now unused lastread column
-ALTER TABLE threads_boards DROP COLUMN lastread;
-
-ALTER TABLE users ADD COLUMN notify_dbedit boolean NOT NULL DEFAULT true;
-ALTER TABLE users ADD COLUMN notify_announce boolean NOT NULL DEFAULT false;
-UPDATE users SET notify_dbedit = false WHERE id IN(0,1);
-
-
--- languages -> ENUM
-CREATE TYPE language AS ENUM('cs', 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja', 'ko', 'nl', 'no', 'pl', 'pt-pt', 'pt-br', 'ru', 'sk', 'sv', 'tr', 'vi', 'zh');
-ALTER TABLE producers_rev ALTER COLUMN lang DROP DEFAULT;
-ALTER TABLE producers_rev ALTER COLUMN lang TYPE language USING CASE lang WHEN 'pt' THEN 'pt-pt' ELSE lang::language END;
-ALTER TABLE producers_rev ALTER COLUMN lang SET DEFAULT 'ja';
-ALTER TABLE releases_lang ALTER COLUMN lang TYPE language USING CASE lang WHEN 'pt' THEN 'pt-pt' ELSE lang::language END;
--- c_languages is an now array of languages, rather than a serialized string
-ALTER TABLE vn ALTER COLUMN c_languages DROP DEFAULT;
-ALTER TABLE vn ALTER COLUMN c_languages TYPE language[] USING '{}';
-ALTER TABLE vn ALTER COLUMN c_languages SET DEFAULT '{}';
-
-
-
-ALTER TABLE changes ADD COLUMN ihid boolean NOT NULL DEFAULT FALSE;
-ALTER TABLE changes ADD COLUMN ilock boolean NOT NULL DEFAULT FALSE;
-
-\i util/sql/func.sql
-
-SELECT COUNT(*) FROM (SELECT update_vncache(id) FROM vn) x;
-
-CREATE TRIGGER hidlock_update BEFORE UPDATE ON vn FOR EACH ROW EXECUTE PROCEDURE update_hidlock();
-CREATE TRIGGER hidlock_update BEFORE UPDATE ON producers FOR EACH ROW EXECUTE PROCEDURE update_hidlock();
-CREATE TRIGGER hidlock_update BEFORE UPDATE ON releases FOR EACH ROW EXECUTE PROCEDURE update_hidlock();
-
-
-CREATE OR REPLACE FUNCTION tmp_edit_hidlock(t text, iid integer) RETURNS void AS $$
-BEGIN
- IF t = 'v' THEN
- PERFORM edit_vn_init(latest) FROM vn WHERE id = iid;
- IF EXISTS(SELECT 1 FROM vn WHERE id = iid AND hidden) THEN
- UPDATE edit_revision SET ihid = true, ip = '0.0.0.0', requester = 1,
- comments = 'This visual novel was deleted before the update to VNDB 2.11, no reason specified.';
- ELSE
- UPDATE edit_revision SET ilock = true, ip = '0.0.0.0', requester = 1,
- comments = 'This visual novel was locked before the update to VNDB 2.11, no reason specified.';
- END IF;
- PERFORM edit_vn_commit();
- ELSIF t = 'r' THEN
- PERFORM edit_release_init(latest) FROM releases WHERE id = iid;
- IF EXISTS(SELECT 1 FROM releases WHERE id = iid AND hidden) THEN
- UPDATE edit_revision SET ihid = true, ip = '0.0.0.0', requester = 1,
- comments = 'This release was deleted before the update to VNDB 2.11, no reason specified.';
- ELSE
- UPDATE edit_revision SET ilock = true, ip = '0.0.0.0', requester = 1,
- comments = 'This release was locked before the update to VNDB 2.11, no reason specified.';
- END IF;
- PERFORM edit_release_commit();
- ELSE
- PERFORM edit_producer_init(latest) FROM producers WHERE id = iid;
- IF EXISTS(SELECT 1 FROM producers WHERE id = iid AND hidden) THEN
- UPDATE edit_revision SET ihid = true, ip = '0.0.0.0', requester = 1,
- comments = 'This producer was deleted before the update to VNDB 2.11, no reason specified.';
- ELSE
- UPDATE edit_revision SET ilock = true, ip = '0.0.0.0', requester = 1,
- comments = 'This producer was locked before the update to VNDB 2.11, no reason specified.';
- END IF;
- PERFORM edit_producer_commit();
- END IF;
-END;
-$$ LANGUAGE plpgsql;
-
- SELECT 'v', COUNT(*) FROM (SELECT tmp_edit_hidlock('v', id) FROM vn WHERE (hidden OR locked)) x
-UNION SELECT 'r', COUNT(*) FROM (SELECT tmp_edit_hidlock('r', id) FROM releases WHERE hidden OR locked) x
-UNION SELECT 'p', COUNT(*) FROM (SELECT tmp_edit_hidlock('p', id) FROM producers WHERE hidden OR locked) x;
-DROP FUNCTION tmp_edit_hidlock(text, integer);
-
-
--- keep track of when a session is last used
-ALTER TABLE sessions ADD COLUMN lastused timestamptz NOT NULL DEFAULT NOW();
-ALTER TABLE sessions RENAME COLUMN expiration TO added;
-UPDATE sessions SET added = added - '1 year'::interval;
-ALTER TABLE sessions ALTER COLUMN added SET DEFAULT NOW();
-
-
-CREATE TRIGGER notify_pm AFTER INSERT ON threads_posts FOR EACH ROW EXECUTE PROCEDURE notify_pm();
--- make sure to add these triggers AFTER performing the batch edit above
-CREATE TRIGGER notify_dbdel AFTER UPDATE ON vn FOR EACH ROW EXECUTE PROCEDURE notify_dbdel();
-CREATE TRIGGER notify_dbdel AFTER UPDATE ON producers FOR EACH ROW EXECUTE PROCEDURE notify_dbdel();
-CREATE TRIGGER notify_dbdel AFTER UPDATE ON releases FOR EACH ROW EXECUTE PROCEDURE notify_dbdel();
-CREATE TRIGGER notify_listdel AFTER UPDATE ON vn FOR EACH ROW EXECUTE PROCEDURE notify_listdel();
-CREATE TRIGGER notify_listdel AFTER UPDATE ON releases FOR EACH ROW EXECUTE PROCEDURE notify_listdel();
-CREATE TRIGGER notify_dbedit AFTER UPDATE ON vn FOR EACH ROW EXECUTE PROCEDURE notify_dbedit();
-CREATE TRIGGER notify_dbedit AFTER UPDATE ON producers FOR EACH ROW EXECUTE PROCEDURE notify_dbedit();
-CREATE TRIGGER notify_dbedit AFTER UPDATE ON releases FOR EACH ROW EXECUTE PROCEDURE notify_dbedit();
-CREATE TRIGGER notify_announce AFTER INSERT ON threads_posts FOR EACH ROW EXECUTE PROCEDURE notify_announce();
-
diff --git a/util/updates/update_2.12.sql b/util/updates/update_2.12.sql
deleted file mode 100644
index eea0ac5c..00000000
--- a/util/updates/update_2.12.sql
+++ /dev/null
@@ -1,20 +0,0 @@
-
--- use newlines to separate aliases
--- (note: this will go wrong with titles that contain a comma. Those have to be fixed manually)
-UPDATE vn_rev SET alias = trim(both ' ' from regexp_replace(alias, ' *, *', E'\n', 'g'));
-
-
--- cache for search
-ALTER TABLE vn ADD COLUMN c_search text;
-
-\i util/sql/func.sql
-
-CREATE TRIGGER vn_vnsearch_notify AFTER UPDATE ON vn FOR EACH ROW EXECUTE PROCEDURE vn_vnsearch_notify();
-CREATE TRIGGER vn_vnsearch_notify AFTER UPDATE ON releases FOR EACH ROW EXECUTE PROCEDURE vn_vnsearch_notify();
-
-
--- two new resolutions have been added, array indexes have changed
-UPDATE releases_rev SET resolution = resolution + 1 WHERE resolution >= 7;
-UPDATE releases_rev SET resolution = resolution + 1 WHERE resolution >= 11;
-
-
diff --git a/util/updates/update_2.13.sql b/util/updates/update_2.13.sql
deleted file mode 100644
index 5171d9eb..00000000
--- a/util/updates/update_2.13.sql
+++ /dev/null
@@ -1,6 +0,0 @@
-
--- "unofficial" flag for vn<->vn relations
-ALTER TABLE vn_relations ADD COLUMN official boolean NOT NULL DEFAULT TRUE;
-
-\i util/sql/func.sql
-
diff --git a/util/updates/update_2.14.sql b/util/updates/update_2.14.sql
deleted file mode 100644
index e8930843..00000000
--- a/util/updates/update_2.14.sql
+++ /dev/null
@@ -1,145 +0,0 @@
-
--- add vn.c_olang
-ALTER TABLE vn ADD COLUMN c_olang language[] NOT NULL DEFAULT '{}';
-
-
--- reload functions
-\i util/sql/func.sql
-
-
--- redefine the triggers to use the new conditional triggers in PostgreSQL 9.0
-
-DROP TRIGGER hidlock_update ON vn;
-DROP TRIGGER hidlock_update ON producers;
-DROP TRIGGER hidlock_update ON releases;
-CREATE TRIGGER hidlock_update BEFORE UPDATE ON vn FOR EACH ROW WHEN (OLD.latest IS DISTINCT FROM NEW.latest) EXECUTE PROCEDURE update_hidlock();
-CREATE TRIGGER hidlock_update BEFORE UPDATE ON producers FOR EACH ROW WHEN (OLD.latest IS DISTINCT FROM NEW.latest) EXECUTE PROCEDURE update_hidlock();
-CREATE TRIGGER hidlock_update BEFORE UPDATE ON releases FOR EACH ROW WHEN (OLD.latest IS DISTINCT FROM NEW.latest) EXECUTE PROCEDURE update_hidlock();
-
-
-DROP TRIGGER vn_stats_update ON vn;
-DROP TRIGGER producers_stats_update ON producers;
-DROP TRIGGER releases_stats_update ON releases;
-DROP TRIGGER threads_stats_update ON threads;
-DROP TRIGGER threads_posts_stats_update ON threads_posts;
-DROP TRIGGER users_stats_update ON users;
-CREATE TRIGGER stats_cache_new AFTER INSERT ON vn FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON vn FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_new AFTER INSERT ON producers FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON producers FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_new AFTER INSERT ON releases FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON releases 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 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();
-
-DROP TRIGGER vn_anime_aid ON vn_anime;
-CREATE TRIGGER vn_anime_aid_new BEFORE INSERT ON vn_anime FOR EACH ROW EXECUTE PROCEDURE vn_anime_aid();
-CREATE TRIGGER vn_anime_aid_edit BEFORE UPDATE ON vn_anime FOR EACH ROW WHEN (OLD.aid IS DISTINCT FROM NEW.aid) EXECUTE PROCEDURE vn_anime_aid();
-
-DROP TRIGGER anime_fetch_notify ON anime;
-CREATE TRIGGER anime_fetch_notify AFTER INSERT OR UPDATE ON anime FOR EACH ROW WHEN (NEW.lastfetch IS NULL) EXECUTE PROCEDURE anime_fetch_notify();
-
-DROP TRIGGER vn_rev_image_notify ON vn_rev;
-CREATE TRIGGER vn_rev_image_notify AFTER INSERT OR UPDATE ON vn_rev FOR EACH ROW WHEN (NEW.image < 0) EXECUTE PROCEDURE vn_rev_image_notify();
-
-DROP TRIGGER screenshot_process_notify ON screenshots;
-CREATE TRIGGER screenshot_process_notify AFTER INSERT OR UPDATE ON screenshots FOR EACH ROW WHEN (NEW.processed = FALSE) EXECUTE PROCEDURE screenshot_process_notify();
-
-DROP TRIGGER vn_relgraph_notify ON vn;
-CREATE TRIGGER vn_relgraph_notify AFTER UPDATE ON vn FOR EACH ROW
- WHEN (OLD.rgraph IS DISTINCT FROM NEW.rgraph
- OR OLD.latest IS DISTINCT FROM NEW.latest
- OR OLD.c_released IS DISTINCT FROM NEW.c_released
- OR OLD.c_languages IS DISTINCT FROM NEW.c_languages
- ) EXECUTE PROCEDURE vn_relgraph_notify();
-
-DROP TRIGGER producer_relgraph_notify ON producers;
-CREATE TRIGGER producer_relgraph_notify AFTER UPDATE ON producers FOR EACH ROW
- WHEN (OLD.rgraph IS DISTINCT FROM NEW.rgraph
- OR OLD.latest IS DISTINCT FROM NEW.latest
- ) EXECUTE PROCEDURE producer_relgraph_notify();
-
-DROP TRIGGER release_vncache_update ON releases;
-CREATE TRIGGER release_vncache_update AFTER UPDATE ON releases FOR EACH ROW
- WHEN (OLD.latest IS DISTINCT FROM NEW.latest OR OLD.hidden IS DISTINCT FROM NEW.hidden)
- EXECUTE PROCEDURE release_vncache_update();
-
-DROP TRIGGER notify_dbdel ON vn;
-DROP TRIGGER notify_dbdel ON producers;
-DROP TRIGGER notify_dbdel ON releases;
-CREATE TRIGGER notify_dbdel AFTER UPDATE ON vn FOR EACH ROW WHEN (NOT OLD.hidden AND NEW.hidden) EXECUTE PROCEDURE notify_dbdel();
-CREATE TRIGGER notify_dbdel AFTER UPDATE ON producers FOR EACH ROW WHEN (NOT OLD.hidden AND NEW.hidden) EXECUTE PROCEDURE notify_dbdel();
-CREATE TRIGGER notify_dbdel AFTER UPDATE ON releases FOR EACH ROW WHEN (NOT OLD.hidden AND NEW.hidden) EXECUTE PROCEDURE notify_dbdel();
-
-DROP TRIGGER notify_listdel ON vn;
-DROP TRIGGER notify_listdel ON releases;
-CREATE TRIGGER notify_listdel AFTER UPDATE ON vn FOR EACH ROW WHEN (NOT OLD.hidden AND NEW.hidden) EXECUTE PROCEDURE notify_listdel();
-CREATE TRIGGER notify_listdel AFTER UPDATE ON releases FOR EACH ROW WHEN (NOT OLD.hidden AND NEW.hidden) EXECUTE PROCEDURE notify_listdel();
-
-DROP TRIGGER notify_dbedit ON vn;
-DROP TRIGGER notify_dbedit ON producers;
-DROP TRIGGER notify_dbedit ON releases;
-CREATE TRIGGER notify_dbedit AFTER UPDATE ON vn FOR EACH ROW WHEN (OLD.latest IS DISTINCT FROM NEW.latest AND NOT NEW.hidden) EXECUTE PROCEDURE notify_dbedit();
-CREATE TRIGGER notify_dbedit AFTER UPDATE ON producers FOR EACH ROW WHEN (OLD.latest IS DISTINCT FROM NEW.latest AND NOT NEW.hidden) EXECUTE PROCEDURE notify_dbedit();
-CREATE TRIGGER notify_dbedit AFTER UPDATE ON releases FOR EACH ROW WHEN (OLD.latest IS DISTINCT FROM NEW.latest AND NOT NEW.hidden) EXECUTE PROCEDURE notify_dbedit();
-
-DROP TRIGGER notify_announce ON threads_posts;
-CREATE TRIGGER notify_announce AFTER INSERT ON threads_posts FOR EACH ROW WHEN (NEW.num = 1) EXECUTE PROCEDURE notify_announce();
-
-DROP TRIGGER vn_vnsearch_notify ON vn;
-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 AND NOT NEW.hidden
- OR NEW.hidden IS DISTINCT FROM OLD.hidden
- OR NEW.latest IS DISTINCT FROM OLD.latest
- ) EXECUTE PROCEDURE vn_vnsearch_notify();
-
-DROP TRIGGER vn_vnsearch_notify ON releases;
-CREATE TRIGGER vn_vnsearch_notify AFTER UPDATE ON releases FOR EACH ROW
- WHEN (NEW.hidden IS DISTINCT FROM OLD.hidden OR NEW.latest IS DISTINCT FROM OLD.latest)
- EXECUTE PROCEDURE vn_vnsearch_notify();
-
-
-
--- add ON DELETE clause to all foreign keys referencing users (id)
--- and change some defaults/constraints to make sure it'll actually work
-
-ALTER TABLE changes DROP CONSTRAINT changes_requester_fkey;
-ALTER TABLE changes ADD FOREIGN KEY (requester) REFERENCES users (id) ON DELETE SET DEFAULT;
-
-UPDATE notifications SET c_byuser = 0 WHERE c_byuser IS NULL;
-ALTER TABLE notifications ALTER COLUMN c_byuser SET DEFAULT 0;
-ALTER TABLE notifications ALTER COLUMN c_byuser SET NOT NULL;
-ALTER TABLE notifications DROP CONSTRAINT notifications_uid_fkey;
-ALTER TABLE notifications DROP CONSTRAINT notifications_c_byuser_fkey;
-ALTER TABLE notifications ADD FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE notifications ADD FOREIGN KEY (c_byuser) REFERENCES users (id) ON DELETE SET DEFAULT;
-
-ALTER TABLE rlists DROP CONSTRAINT rlists_uid_fkey;
-ALTER TABLE rlists ADD FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-
-ALTER TABLE sessions DROP CONSTRAINT sessions_uid_fkey;
-ALTER TABLE sessions ADD FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-
-ALTER TABLE tags ALTER COLUMN addedby SET DEFAULT 0;
-ALTER TABLE tags DROP CONSTRAINT tags_addedby_fkey;
-ALTER TABLE tags ADD FOREIGN KEY (addedby) REFERENCES users (id) ON DELETE SET DEFAULT;
-
-ALTER TABLE tags_vn DROP CONSTRAINT tags_vn_uid_fkey;
-ALTER TABLE tags_vn ADD FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-
-ALTER TABLE threads_posts DROP CONSTRAINT threads_posts_uid_fkey;
-ALTER TABLE threads_posts ADD FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
-
-ALTER TABLE votes DROP CONSTRAINT votes_uid_fkey;
-ALTER TABLE votes ADD FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-
-ALTER TABLE wlists DROP CONSTRAINT wlists_uid_fkey;
-ALTER TABLE wlists ADD FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-
-
--- regenerate vn.c_* columns
-SELECT COUNT(*) FROM (SELECT update_vncache(id) FROM vn WHERE NOT hidden) s;
-
diff --git a/util/updates/update_2.15.sql b/util/updates/update_2.15.sql
deleted file mode 100644
index 720254d0..00000000
--- a/util/updates/update_2.15.sql
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-ALTER TABLE tags_vn ADD COLUMN date timestamptz NOT NULL DEFAULT NOW();
-
--- this index is essential, quite often sorted on
-CREATE INDEX tags_vn_date ON tags_vn (date);
-
-
--- VNDBUtil::normalize() has been modified, so update search cache
-UPDATE vn SET c_search = NULL;
-
diff --git a/util/updates/update_2.16.sql b/util/updates/update_2.16.sql
deleted file mode 100644
index 2d354f03..00000000
--- a/util/updates/update_2.16.sql
+++ /dev/null
@@ -1,86 +0,0 @@
-
--- remove the NOT NULL from rr.minage and use -1 when unknown
-UPDATE releases_rev SET minage = -1 WHERE minage IS NULL;
-ALTER TABLE releases_rev ALTER COLUMN minage SET DEFAULT -1;
-ALTER TABLE releases_rev ALTER COLUMN minage DROP NOT NULL;
-
-
--- speed up get-releases-by-vn queries
-CREATE INDEX releases_vn_vid ON releases_vn (vid);
-
-
--- add vnlists table
-CREATE TABLE vnlists (
- uid integer NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- vid integer NOT NULL REFERENCES vn (id),
- status smallint NOT NULL DEFAULT 0,
- added TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- notes varchar NOT NULL DEFAULT '',
- PRIMARY KEY(uid, vid)
-);
-
-
--- load new function(s)
-\i util/sql/func.sql
-
-
--- convert from rlists.vstat
-INSERT INTO vnlists (uid, vid, status, added) SELECT
- i.uid, i.vid, COALESCE(MIN(CASE WHEN rl.vstat = 0 THEN NULL ELSE rl.vstat END), 0), MIN(rl.added)
- FROM (
- SELECT DISTINCT rl.uid, rv.vid
- FROM rlists rl
- JOIN releases r ON r.id = rl.rid
- JOIN releases_vn rv ON rv.rid = r.latest
- ) AS i(uid,vid)
- JOIN rlists rl ON rl.uid = i.uid
- JOIN releases r ON r.id = rl.rid
- JOIN releases_vn rv ON rv.rid = r.latest AND rv.vid = i.vid
- GROUP BY i.uid, i.vid;
-
-
--- add constraints triggers
-CREATE CONSTRAINT TRIGGER update_vnlist_rlist AFTER DELETE ON vnlists DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE update_vnlist_rlist();
-CREATE CONSTRAINT TRIGGER update_vnlist_rlist AFTER INSERT ON rlists DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE update_vnlist_rlist();
-
--- remove rlists.vstat and rename rlists.rstat
-ALTER TABLE rlists DROP COLUMN vstat;
-ALTER TABLE rlists RENAME COLUMN rstat TO status;
-
-
-
--- add users_prefs table
-CREATE TYPE prefs_key AS ENUM ('l10n', 'skin', 'customcss', 'filter_vn', 'filter_release', 'show_nsfw', 'hide_list', 'notify_nodbedit', 'notify_announce');
-CREATE TABLE users_prefs (
- uid integer NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- key prefs_key NOT NULL,
- value varchar NOT NULL,
- PRIMARY KEY(uid, key)
-);
-
--- convert from users.* to users_prefs
-INSERT INTO users_prefs (uid, key, value)
- SELECT id, 'skin'::prefs_key, skin FROM users WHERE skin <> ''
- UNION ALL
- SELECT id, 'customcss', customcss FROM users WHERE customcss <> ''
- UNION ALL
- SELECT id, 'show_nsfw', '1' FROM users WHERE show_nsfw
- UNION ALL
- SELECT id, 'hide_list', '1' FROM users WHERE NOT show_list
- UNION ALL
- SELECT id, 'notify_nodbedit', '1' FROM users WHERE NOT notify_dbedit
- UNION ALL
- SELECT id, 'notify_announce', '1' FROM users WHERE notify_announce;
-
--- remove unused columns from the user table
-ALTER TABLE users DROP COLUMN skin;
-ALTER TABLE users DROP COLUMN customcss;
-ALTER TABLE users DROP COLUMN show_nsfw;
-ALTER TABLE users DROP COLUMN show_list;
-ALTER TABLE users DROP COLUMN notify_dbedit;
-ALTER TABLE users DROP COLUMN notify_announce;
-
-
--- remove size constraint on vn.c_platforms
-ALTER TABLE vn ALTER COLUMN c_platforms TYPE varchar;
-
diff --git a/util/updates/update_2.17.sql b/util/updates/update_2.17.sql
deleted file mode 100644
index 54487d0a..00000000
--- a/util/updates/update_2.17.sql
+++ /dev/null
@@ -1,8 +0,0 @@
-
--- tag overrule feature
-ALTER TABLE tags_vn ADD COLUMN ignore boolean NOT NULL DEFAULT false;
-
-
--- load new function(s)
-\i util/sql/func.sql
-
diff --git a/util/updates/update_2.18.sql b/util/updates/update_2.18.sql
deleted file mode 100644
index 90a5bcd3..00000000
--- a/util/updates/update_2.18.sql
+++ /dev/null
@@ -1,8 +0,0 @@
-
-CREATE TYPE tag_category AS ENUM('cont', 'ero', 'tech');
-
-ALTER TABLE tags ADD COLUMN cat tag_category NOT NULL DEFAULT 'cont';
-
--- load new function(s)
-\i util/sql/func.sql
-
diff --git a/util/updates/update_2.19.sql b/util/updates/update_2.19.sql
deleted file mode 100644
index 1d980f3d..00000000
--- a/util/updates/update_2.19.sql
+++ /dev/null
@@ -1,206 +0,0 @@
-
--- character database -> traits
-
-CREATE TABLE traits (
- id SERIAL PRIMARY KEY,
- name varchar(250) NOT NULL,
- alias varchar(500) NOT NULL DEFAULT '',
- description text NOT NULL DEFAULT '',
- meta boolean NOT NULL DEFAULT false,
- added timestamptz NOT NULL DEFAULT NOW(),
- state smallint NOT NULL DEFAULT 0,
- addedby integer NOT NULL DEFAULT 0 REFERENCES users (id),
- "group" integer,
- "order" smallint NOT NULL DEFAULT 0,
- sexual boolean NOT NULL DEFAULT false,
- c_items integer NOT NULL DEFAULT 0
-);
-ALTER TABLE traits ADD FOREIGN KEY ("group") REFERENCES traits (id);
-
-CREATE TABLE traits_parents (
- trait integer NOT NULL REFERENCES traits (id),
- parent integer NOT NULL REFERENCES traits (id),
- PRIMARY KEY(trait, parent)
-);
-
-CREATE TRIGGER insert_notify AFTER INSERT ON traits FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
-
-ALTER TABLE tags RENAME c_vns TO c_items;
-
-
--- character database -> chars
-
-CREATE TYPE char_role AS ENUM ('main', 'primary', 'side', 'appears');
-CREATE TYPE blood_type AS ENUM ('unknown', 'a', 'b', 'ab', 'o');
-CREATE TYPE gender AS ENUM ('unknown', 'm', 'f', 'b');
-
-CREATE TABLE chars (
- id SERIAL PRIMARY KEY,
- latest integer NOT NULL DEFAULT 0,
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE
-);
-
-CREATE TABLE chars_rev (
- id integer NOT NULL PRIMARY KEY REFERENCES changes (id),
- cid integer NOT NULL REFERENCES chars (id),
- 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 NOT NULL DEFAULT 0,
- bloodt blood_type NOT NULL DEFAULT 'unknown',
- main integer REFERENCES chars (id),
- main_spoil smallint NOT NULL DEFAULT 0
-);
-ALTER TABLE chars ADD FOREIGN KEY (latest) REFERENCES chars_rev (id) DEFERRABLE INITIALLY DEFERRED;
-
-CREATE TABLE chars_traits (
- cid integer NOT NULL REFERENCES chars_rev (id),
- tid integer NOT NULL REFERENCES traits (id),
- spoil smallint NOT NULL DEFAULT 0,
- PRIMARY KEY(cid, tid)
-);
-
-CREATE TABLE chars_vns (
- cid integer NOT NULL REFERENCES chars_rev (id),
- vid integer NOT NULL REFERENCES vn (id),
- rid integer NULL REFERENCES releases (id),
- spoil smallint NOT NULL DEFAULT 0,
- role char_role NOT NULL DEFAULT 'main'
-);
--- primary key won't work when one column allows NULL
-CREATE UNIQUE INDEX chars_vns_pkey ON chars_vns (cid, vid, COALESCE(rid, 0));
-
--- cache table
-CREATE TABLE traits_chars (
- cid integer NOT NULL REFERENCES chars (id),
- tid integer NOT NULL REFERENCES traits (id),
- spoil smallint NOT NULL DEFAULT 0,
- PRIMARY KEY(cid, tid)
-);
-
-CREATE SEQUENCE charimg_seq;
-
-
-
--- allow characters to be versioned using the changes table
-
-CREATE TYPE dbentry_type_tmp AS ENUM ('v', 'r', 'p', 'c');
-ALTER TABLE changes ALTER COLUMN "type" TYPE dbentry_type_tmp USING "type"::text::dbentry_type_tmp;
-DROP FUNCTION edit_revtable(dbentry_type, integer);
-DROP TYPE dbentry_type;
-ALTER TYPE dbentry_type_tmp RENAME TO dbentry_type;
-
-
--- load the updated functions
-
-\i util/sql/func.sql
-
-
-CREATE TRIGGER hidlock_update BEFORE UPDATE ON chars FOR EACH ROW WHEN (OLD.latest IS DISTINCT FROM NEW.latest) EXECUTE PROCEDURE update_hidlock();
-CREATE TRIGGER chars_rev_image_notify AFTER INSERT OR UPDATE ON chars_rev FOR EACH ROW WHEN (NEW.image < 0) EXECUTE PROCEDURE chars_rev_image_notify();
-
-
-/* Debugging data *-/
-
-
--- some traits, based on Echo's draft
-INSERT INTO traits (name, meta, state, addedby, "group") VALUES
- ('Hair', true, 2, 2, NULL),
- ('Hair Color', true, 2, 2, 1),
- ('Auburn', false, 2, 2, 1),
- ('Black', false, 2, 2, 1),
- ('Blond', false, 2, 2, 1), -- 5
- ('Brown', false, 2, 2, 1),
- ('Hairstyle', true, 2, 2, 1),
- ('Bun', false, 2, 2, 1),
- ('Odango', false, 2, 2, 1),
- ('Ponytail', false, 2, 2, 1), -- 10
- ('Twin Tails', false, 2, 2, 1),
- ('Short', false, 2, 2, 1),
- ('Straight', false, 2, 2, 1),
- ('Eyes', true, 2, 2, NULL),
- ('Color', true, 2, 2, 14), -- 15
- ('Amber', false, 2, 2, 14),
- ('Black', false, 2, 2, 14),
- ('Red', false, 2, 2, 14),
- ('Body', true, 2, 2, NULL),
- ('Apparent age', true, 2, 2, 19), --20
- ('Child', false, 2, 2, 19),
- ('Teen', false, 2, 2, 19),
- ('Young-Adult', false, 2, 2, 19),
- ('Adult', false, 2, 2, 19),
- ('Old', false, 2, 2, 19), -- 25
- ('Body Type', true, 2, 2, 19),
- ('Slim', false, 2, 2, 19),
- ('Muscular', false, 2, 2, 19),
- ('Overweight', false, 2, 2, 19),
- ('Huge', false, 2, 2, 19); -- 30
-INSERT INTO traits_parents (trait, parent) VALUES
- (2, 1),
- (3, 2),
- (4, 2),
- (5, 2),
- (6, 2),
- (7, 1),
- (8, 7),
- (9, 8),
- (9, 11),
- (10, 7),
- (11, 10),
- (12, 7),
- (13, 7),
- (15, 14),
- (16, 15),
- (17, 15),
- (18, 15),
- (20, 19),
- (21, 20),
- (22, 20),
- (23, 20),
- (24, 20),
- (25, 20),
- (26, 19),
- (27, 26),
- (28, 26),
- (29, 26),
- (30, 26);
-
-
--- phorni!
-SELECT edit_char_init(null);
-UPDATE edit_revision SET comments = 'New test entry', requester = 2, ip = '0.0.0.0';
-UPDATE edit_char SET name = 'Phorni', original = 'フォーニ', "desc" = 'Sprite of Music', height = 14, gender = 'f';
-INSERT INTO edit_char_vns VALUES (38, null, 0, 'primary'), (97, null, 2, 'appears');
-SELECT edit_char_commit();
-
--- saya (incorrect test data)
-SELECT edit_char_init(null);
-UPDATE edit_revision SET comments = '2nd test entry', requester = 2, ip = '0.0.0.0';
-UPDATE edit_char SET name = 'Saya', original = '沙耶', "desc" = 'There is more than meets the eye!', alias = 'Cute monster', height = 140, weight = 52, s_bust = 41, s_waist = 38, s_hip = 40, b_month = 3, b_day = 15, bloodt = 'a', gender = 'f', main = 1;
-INSERT INTO edit_char_traits VALUES (4, 0), (12, 2), (22, 0), (27, 0), (18, 1);
-INSERT INTO edit_char_vns VALUES (97, null, 0, 'primary');
-SELECT edit_char_commit();
-
--- lafiel (not even a VN character...)
-SELECT edit_char_init(null);
-UPDATE edit_revision SET comments = '3rd test entry', requester = 2, ip = '0.0.0.0';
-UPDATE edit_char SET name = 'Abriel Nei Debrusc Borl Paryun Lafiel', original = 'アブリアル・ネイ=ドゥブレスク・パリューニュ・ベール・パリュン・ラフィール',
- alias = E'Ablïarsec néïc Dubreuscr Bœrh Parhynr Lamhirh\nLafiel', gender = 'f', height = 163, weight = 53, main = 1, "desc" = 'Not scary at all!';
-INSERT INTO edit_char_traits VALUES (13, 0), (17, 0), (22, 0);
-INSERT INTO edit_char_vns VALUES (97, null, 0, 'primary'), (17, 2479, 1, 'side'), (17, 626, 2, 'primary'), (17, null, 0, 'appears');
-SELECT edit_char_commit();
-
-SELECT traits_chars_calc();
-
--- */
-
diff --git a/util/updates/update_2.2.sql b/util/updates/update_2.2.sql
deleted file mode 100644
index d16c4bf0..00000000
--- a/util/updates/update_2.2.sql
+++ /dev/null
@@ -1,36 +0,0 @@
-
--- custom CSS
-ALTER TABLE users ADD COLUMN customcss text NOT NULL DEFAULT '';
-
-
-
--- patch flag
-ALTER TABLE releases_rev ADD COLUMN patch BOOLEAN NOT NULL DEFAULT FALSE;
-UPDATE releases_rev SET patch = TRUE
- WHERE EXISTS(SELECT 1 FROM releases_media rm WHERE rm.rid = id AND rm.medium = 'pa ');
-DELETE FROM releases_media WHERE medium = 'pa ';
-
-
-
--- popularity calculation
-ALTER TABLE vn ADD COLUMN c_popularity real NOT NULL DEFAULT 0;
-
-CREATE OR REPLACE FUNCTION update_vnpopularity() RETURNS void AS $$
-BEGIN
- CREATE OR REPLACE TEMP VIEW tmp_pop1 (uid, vid, rank) AS
- SELECT v.uid, v.vid, sqrt(count(*))::real FROM votes v JOIN votes v2 ON v.uid = v2.uid AND v2.vote < v.vote GROUP BY v.vid, v.uid;
- CREATE OR REPLACE TEMP VIEW tmp_pop2 (vid, win) AS
- SELECT vid, sum(rank) FROM tmp_pop1 GROUP BY vid;
- UPDATE vn SET c_popularity = COALESCE((SELECT win/(SELECT MAX(win) FROM tmp_pop2) FROM tmp_pop2 WHERE vid = id), 0);
- RETURN;
-END;
-$$ LANGUAGE plpgsql;
-
-SELECT update_vnpopularity();
-
-
-
--- store the IP address used to register
-ALTER TABLE users ADD COLUMN ip inet NOT NULL DEFAULT '0.0.0.0';
-
-
diff --git a/util/updates/update_2.20.sql b/util/updates/update_2.20.sql
deleted file mode 100644
index 390e9a41..00000000
--- a/util/updates/update_2.20.sql
+++ /dev/null
@@ -1,54 +0,0 @@
-
-ALTER TYPE notification_ltype RENAME TO tmp;
-CREATE TYPE notification_ltype AS ENUM ('v', 'r', 'p', 'c', 't');
-ALTER TABLE notifications ALTER COLUMN ltype TYPE notification_ltype USING ltype::text::notification_ltype;
-DROP TYPE tmp;
-
-\i util/sql/func.sql
-
-CREATE TRIGGER notify_dbdel AFTER UPDATE ON chars FOR EACH ROW WHEN (NOT OLD.hidden AND NEW.hidden) EXECUTE PROCEDURE notify_dbdel();
-CREATE TRIGGER notify_dbedit AFTER UPDATE ON chars FOR EACH ROW WHEN (OLD.latest IS DISTINCT FROM NEW.latest AND NOT NEW.hidden) EXECUTE PROCEDURE notify_dbedit();
-
-
-INSERT INTO stats_cache VALUES
- ('chars', (SELECT COUNT(*) FROM chars WHERE NOT hidden)),
- ('tags', (SELECT COUNT(*) FROM tags WHERE state = 2)),
- ('traits', (SELECT COUNT(*) FROM traits WHERE state = 2));
-
-CREATE TRIGGER stats_cache_new AFTER INSERT ON chars FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
-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 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 TABLE affiliate_links (
- id SERIAL PRIMARY KEY,
- rid integer NOT NULL REFERENCES releases (id),
- hidden boolean NOT NULL DEFAULT false, -- to hide a link for some reason
- priority smallint NOT NULL DEFAULT 0, -- manual ordering when competing on a VN page, usually not necessary
- affiliate smallint NOT NULL DEFAULT 0, -- index to a semi-static array in data/config.pl
- url varchar NOT NULL,
- version varchar NOT NULL DEFAULT '', -- "x edition" or "x version", default used is "<language> version"
- lastfetch timestamptz, -- last update of price
- price varchar NOT NULL DEFAULT '', -- formatted, including currency, e.g. "$50" or "€34.95 / $50.46"
- data varchar NOT NULL DEFAULT '' -- to be used by a fetch bot, if any
-);
-
-CREATE INDEX affiliate_links_rid ON affiliate_links (rid) WHERE NOT hidden;
-
-
-
--- rank -> permissions
-
-ALTER TABLE users RENAME rank TO perm;
-ALTER TABLE users ALTER COLUMN perm SET DEFAULT 1+4+16;
-UPDATE users SET perm = CASE
- WHEN perm = 2 THEN 1
- WHEN perm = 3 THEN 1+4+16
- WHEN perm = 4 THEN 1+2+4+8+16+32+64
- WHEN perm = 5 THEN 1+2+4+8+16+32+64+128+256
- ELSE 0 END;
-
diff --git a/util/updates/update_2.21.sql b/util/updates/update_2.21.sql
deleted file mode 100644
index 30ddac60..00000000
--- a/util/updates/update_2.21.sql
+++ /dev/null
@@ -1,17 +0,0 @@
-
--- New resolution added on index 5
-UPDATE releases_rev SET resolution = resolution + 1 WHERE resolution >= 5;
-
-
--- Old MD5 passwords can't be used anymore, so delete them
-UPDATE users SET passwd = '' WHERE salt = '';
-
-
--- Email addresses now have to be confirmed upon registration
--- This boolean column won't really checked on login, it's just here for
--- administration purposes. The passwd/salt columns contain a
--- password-reset-token, so the user won't be able to login directly after
--- registration anyway.
-ALTER TABLE users ADD COLUMN email_confirmed boolean NOT NULL DEFAULT FALSE;
-UPDATE users SET email_confirmed = TRUE;
-
diff --git a/util/updates/update_2.22.sql b/util/updates/update_2.22.sql
deleted file mode 100644
index 3268481e..00000000
--- a/util/updates/update_2.22.sql
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-DROP TRIGGER vn_vnsearch_notify ON vn;
-
-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
- OR NEW.latest IS DISTINCT FROM OLD.latest
- ) EXECUTE PROCEDURE vn_vnsearch_notify();
-
-\i util/sql/func.sql
-
diff --git a/util/updates/update_2.23.sql b/util/updates/update_2.23.sql
deleted file mode 100644
index 28117f4b..00000000
--- a/util/updates/update_2.23.sql
+++ /dev/null
@@ -1,100 +0,0 @@
--- Two extra indices for performance
-
-CREATE INDEX releases_producers_rid ON releases_producers (rid);
-CREATE INDEX tags_vn_vid ON tags_vn (vid);
-
-
-
--- Extra language for Arabic, Hebrew, Ukrainian and Indonesian
-
-ALTER TYPE language RENAME TO language_old;
-CREATE TYPE language AS ENUM ('ar', 'cs', 'da', 'de', 'en', 'es', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'ko', 'nl', 'no', 'pl', 'pt-pt', 'pt-br', 'ru', 'sk', 'sv', 'tr', 'uk', 'vi', 'zh');
-ALTER TABLE producers_rev ALTER COLUMN lang DROP DEFAULT;
-ALTER TABLE producers_rev ALTER COLUMN lang TYPE language USING lang::text::language;
-ALTER TABLE producers_rev ALTER COLUMN lang SET DEFAULT 'ja';
-
-ALTER TABLE releases_lang ALTER COLUMN lang TYPE language USING lang::text::language;
-
-ALTER TABLE vn ALTER COLUMN c_languages DROP DEFAULT;
-DROP TRIGGER vn_relgraph_notify ON vn;
-ALTER TABLE vn ALTER COLUMN c_languages TYPE language[] USING c_languages::text[]::language[];
-CREATE TRIGGER vn_relgraph_notify AFTER UPDATE ON vn FOR EACH ROW
- WHEN (OLD.rgraph IS DISTINCT FROM NEW.rgraph
- OR OLD.latest IS DISTINCT FROM NEW.latest
- OR OLD.c_released IS DISTINCT FROM NEW.c_released
- OR OLD.c_languages IS DISTINCT FROM NEW.c_languages
- ) EXECUTE PROCEDURE vn_relgraph_notify();
-ALTER TABLE vn ALTER COLUMN c_languages SET DEFAULT '{}';
-
-ALTER TABLE vn ALTER COLUMN c_olang DROP DEFAULT;
-ALTER TABLE vn ALTER COLUMN c_olang TYPE language[] USING c_olang::text[]::language[];
-ALTER TABLE vn ALTER COLUMN c_olang SET DEFAULT '{}';
-
-DROP TYPE language_old;
-
-
-
--- VN votes * 10
--- (The WHERE prevents another *10 if this query has already been executed)
-
-UPDATE votes SET vote = vote * 10 WHERE NOT EXISTS(SELECT 1 FROM votes WHERE vote > 10);
-
--- recalculate c_rating
-UPDATE vn SET c_rating = (SELECT (
- ((SELECT COUNT(vote)::real/COUNT(DISTINCT vid)::real FROM votes)
- *(SELECT AVG(a)::real FROM (SELECT AVG(vote) FROM votes GROUP BY vid) AS v(a)) + SUM(vote)::real) /
- ((SELECT COUNT(vote)::real/COUNT(DISTINCT vid)::real FROM votes) + COUNT(uid)::real)
- ) FROM votes WHERE vid = id AND uid NOT IN(SELECT id FROM users WHERE ign_votes)
-);
-
-
--- New enum types for user list display in VN list
-
-ALTER TYPE prefs_key ADD VALUE 'vn_list_own' AFTER 'notify_announce';
-ALTER TYPE prefs_key ADD VALUE 'vn_list_wish' AFTER 'vn_list_own';
-
-
--- Image processing doesn't happen via Multi anymore, so no more notifications
-
-DROP TRIGGER vn_rev_image_notify ON vn_rev;
-DROP FUNCTION vn_rev_image_notify();
-
-DROP TRIGGER chars_rev_image_notify ON chars_rev;
-DROP FUNCTION chars_rev_image_notify();
-
-DROP TRIGGER screenshot_process_notify ON screenshots;
-DROP FUNCTION screenshot_process_notify();
-
-ALTER TABLE screenshots DROP COLUMN processed;
-
-
--- New resolution has been added at index 8
-UPDATE releases_rev SET resolution = resolution + 1 WHERE resolution >= 8 AND NOT EXISTS(SELECT 1 FROM releases_rev WHERE resolution >= 14);
-
--- New language: Romanian
-ALTER TYPE language ADD VALUE 'ro' AFTER 'pt-br';
-
--- Login attempt throttling
-CREATE TABLE login_throttle (
- ip inet NOT NULL PRIMARY KEY,
- timeout bigint NOT NULL
-);
-
--- timeout is a timestamp...
-ALTER TABLE login_throttle ALTER COLUMN timeout TYPE timestamptz USING to_timestamp(timeout);
-
-
--- platform from varchar to enum
-CREATE TYPE platform AS ENUM ('win', 'dos', 'lin', 'mac', 'ios', 'and', 'dvd', 'bdp', 'gba', 'gbc', 'msx', 'nds', 'nes', 'p88', 'p98', 'pcf', 'psp', 'ps1', 'ps2', 'ps3', 'ps4', 'psv', 'drc', 'sat', 'sfc', 'wii', 'n3d', 'xb1', 'xb3', 'xbo', 'web', 'oth');
-ALTER TABLE releases_platforms ALTER COLUMN platform DROP DEFAULT;
-ALTER TABLE releases_platforms ALTER COLUMN platform TYPE platform USING platform::platform;
-
-ALTER TABLE vn ALTER COLUMN c_platforms DROP DEFAULT;
-ALTER TABLE vn ALTER COLUMN c_platforms TYPE platform[] USING string_to_array(c_platforms, '/')::platform[];
-ALTER TABLE vn ALTER COLUMN c_platforms SET DEFAULT '{}';
-
-
--- Merging passwd and salt
---SELECT length(passwd), count(*) from users group by length(passwd);
-UPDATE users SET passwd = convert_to(salt, 'UTF-8') || passwd;
-ALTER TABLE users DROP COLUMN salt;
diff --git a/util/updates/update_2.24-staff.sql b/util/updates/update_2.24-staff.sql
deleted file mode 100644
index 0c91deb4..00000000
--- a/util/updates/update_2.24-staff.sql
+++ /dev/null
@@ -1,67 +0,0 @@
--- database schema for staff/seiyuu
-
-ALTER TYPE dbentry_type ADD VALUE 's';
-ALTER TYPE notification_ltype ADD VALUE 's';
-CREATE TYPE credit_type AS ENUM ('script', 'chardesign', 'music', 'director', 'art', 'songs', 'staff');
-
-CREATE TABLE staff (
- id SERIAL NOT NULL PRIMARY KEY,
- latest integer NOT NULL DEFAULT 0,
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE
-);
-
-CREATE TABLE staff_rev (
- id integer NOT NULL PRIMARY KEY,
- sid integer NOT NULL, -- references staff
- aid integer NOT NULL, -- true name, references staff_alias
- 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
-);
-
-CREATE TABLE staff_alias (
- id SERIAL NOT NULL,
- rid integer, -- references staff_rev
- name varchar(200) NOT NULL DEFAULT '',
- original varchar(200) NOT NULL DEFAULT '',
- PRIMARY KEY (id, rid)
-);
-
-CREATE TABLE vn_staff (
- vid integer NOT NULL, -- vn_rev reference
- aid integer NOT NULL, -- staff_alias reference
- role credit_type NOT NULL DEFAULT 'staff',
- note varchar(250) NOT NULL DEFAULT '',
- PRIMARY KEY (vid, aid, role)
-);
-
-CREATE TABLE vn_seiyuu (
- vid integer NOT NULL, -- vn_rev reference
- aid integer NOT NULL, -- staff_alias reference
- cid integer NOT NULL, -- chars reference
- note varchar(250) NOT NULL DEFAULT '',
- PRIMARY KEY (vid, aid, cid)
-);
-
-ALTER TABLE staff ADD FOREIGN KEY (latest) REFERENCES staff_rev (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE staff_alias ADD FOREIGN KEY (rid) REFERENCES staff_rev (id) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE staff_rev ADD FOREIGN KEY (id) REFERENCES changes (id);
-ALTER TABLE staff_rev ADD FOREIGN KEY (sid) REFERENCES staff (id);
-ALTER TABLE staff_rev ADD FOREIGN KEY (aid,id) REFERENCES staff_alias (id,rid);
-ALTER TABLE vn_staff ADD FOREIGN KEY (vid) REFERENCES vn_rev (id);
-ALTER TABLE vn_seiyuu ADD FOREIGN KEY (cid) REFERENCES chars (id);
-ALTER TABLE vn_seiyuu ADD FOREIGN KEY (vid) REFERENCES vn_rev (id);
-
-CREATE INDEX vn_staff_vid ON vn_staff (vid);
-CREATE INDEX vn_staff_aid ON vn_staff (aid);
---CREATE INDEX staff_alias_orig ON staff_alias (translate(original,' ',''));
-
-CREATE TRIGGER hidlock_update BEFORE UPDATE ON staff FOR EACH ROW WHEN (OLD.latest IS DISTINCT FROM NEW.latest) EXECUTE PROCEDURE update_hidlock();
-
-CREATE TRIGGER notify_dbdel AFTER UPDATE ON staff FOR EACH ROW WHEN (NOT OLD.hidden AND NEW.hidden) EXECUTE PROCEDURE notify_dbdel();
-CREATE TRIGGER notify_dbedit AFTER UPDATE ON staff FOR EACH ROW WHEN (OLD.latest IS DISTINCT FROM NEW.latest AND NOT NEW.hidden) EXECUTE PROCEDURE notify_dbedit();
diff --git a/util/updates/update_2.24.sql b/util/updates/update_2.24.sql
deleted file mode 100644
index 011588cf..00000000
--- a/util/updates/update_2.24.sql
+++ /dev/null
@@ -1,14 +0,0 @@
--- Session tokens are stored in the database as a SHA-1 on the actual token
--- now. Note that this query should be executed only once, otherwise any
--- existing sessions will be invalidated.
--- CREATE EXTENSION pgcrypto;
-UPDATE sessions SET token = digest(token, 'sha1');
--- DROP EXTENSION pgcrypto;
-
-
--- No more 'charedit' permission flag
-UPDATE users SET perm = (perm & ~8);
-
-
--- Completely remove l_vnn column
-ALTER TABLE vn_rev DROP COLUMN l_vnn;
diff --git a/util/updates/update_2.25-sqlsplit.sql b/util/updates/update_2.25-sqlsplit.sql
deleted file mode 100644
index b7ffa91f..00000000
--- a/util/updates/update_2.25-sqlsplit.sql
+++ /dev/null
@@ -1,258 +0,0 @@
--- Q: Why recreate all the tables rather than modify existing ones?
--- A: Because the production tables have been modified many times, and columns
--- weren't always in the same order as in scheme.sql. Recreating everything
--- also has the advantage of ensuring that all references and indices are
--- handled and documented here. In hindsight, it also seems like the easier
--- approach.
-
-ALTER TABLE changes RENAME TO changes_old;
-ALTER TABLE chars RENAME TO chars_old;
-ALTER TABLE chars_rev RENAME TO chars_rev_old;
-ALTER TABLE chars_traits RENAME TO chars_traits_old;
-ALTER TABLE chars_vns RENAME TO chars_vns_old;
-ALTER TABLE producers RENAME TO producers_old;
-ALTER TABLE producers_rev RENAME TO producers_rev_old;
-ALTER TABLE producers_relations RENAME TO producers_relations_old;
-ALTER TABLE releases RENAME TO releases_old;
-ALTER TABLE releases_rev RENAME TO releases_rev_old;
-ALTER TABLE releases_lang RENAME TO releases_lang_old;
-ALTER TABLE releases_media RENAME TO releases_media_old;
-ALTER TABLE releases_platforms RENAME TO releases_platforms_old;
-ALTER TABLE releases_producers RENAME TO releases_producers_old;
-ALTER TABLE releases_vn RENAME TO releases_vn_old;
-ALTER TABLE staff RENAME TO staff_old;
-ALTER TABLE staff_rev RENAME TO staff_rev_old;
-ALTER TABLE staff_alias RENAME TO staff_alias_old;
-ALTER TABLE vn RENAME TO vn_old;
-ALTER TABLE vn_rev RENAME TO vn_rev_old;
-ALTER TABLE vn_anime RENAME TO vn_anime_old;
-ALTER TABLE vn_relations RENAME TO vn_relations_old;
-ALTER TABLE vn_screenshots RENAME TO vn_screenshots_old;
-ALTER TABLE vn_seiyuu RENAME TO vn_seiyuu_old;
-ALTER TABLE vn_staff RENAME TO vn_staff_old;
-
--- XXX: The names of these sequences depend on how the corresponding tables
--- were generated. The names below are the ones in the production database.
-ALTER SEQUENCE changes_id_seq RENAME TO changes_id_seq_old;
-ALTER SEQUENCE chars_id_seq RENAME TO chars_id_seq_old;
-ALTER SEQUENCE producers_id_seq RENAME TO producers_id_seq_old;
-ALTER SEQUENCE releases_id_seq RENAME TO releases_id_seq_old;
-ALTER SEQUENCE staff_alias_id_seq RENAME TO staff_alias_id_seq_old;
-ALTER SEQUENCE staff_id_seq RENAME TO staff_id_seq_old;
-ALTER SEQUENCE vn_id_seq RENAME TO vn_id_seq_old;
-
-\i util/sql/schema.sql
-
-
--- XXX: This query uses a window function to generate changes.rev instead of
--- copying the value from the old table. This is done because, in the old
--- database schema, there was no uniqueness constraint on (type, itemid, rev),
--- and due to a race condition it was possible for duplicates to appear. This
--- is a pretty rare occurence, and easy to correct by renumbering the changes.
--- (Changes the URL of a few revision pages, but there's no way to avoid that)
-INSERT INTO changes SELECT c.id, c.type, COALESCE(vr.vid, pr.pid, rr.rid, cr.cid, sr.sid),
- row_number() OVER (PARTITION BY c.type, COALESCE(vr.vid, pr.pid, rr.rid, cr.cid, sr.sid) ORDER BY c.id ASC),
- c.added, c.requester, c.ip, c.comments, c.ihid, c.ilock
- FROM changes_old c
- LEFT JOIN vn_rev_old vr ON vr.id = c.id
- LEFT JOIN producers_rev_old pr ON pr.id = c.id
- LEFT JOIN releases_rev_old rr ON rr.id = c.id
- LEFT JOIN chars_rev_old cr ON cr.id = c.id
- LEFT JOIN staff_rev_old sr ON sr.id = c.id;
-
-INSERT INTO chars SELECT c.id, c.locked, c.hidden,
- cr.name, cr.original, cr.alias, cr.image, cr.desc, cr.gender, cr.s_bust, cr.s_waist, cr.s_hip,
- cr.b_month, cr.b_day, cr.height, cr.weight, cr.bloodt, cr.main, cr.main_spoil
- FROM chars_old c JOIN chars_rev_old cr ON cr.id = c.latest;
-
-INSERT INTO chars_hist SELECT cr.id,
- cr.name, cr.original, cr.alias, cr.image, cr.desc, cr.gender, cr.s_bust, cr.s_waist, cr.s_hip,
- cr.b_month, cr.b_day, cr.height, cr.weight, cr.bloodt, cr.main, cr.main_spoil
- FROM chars_rev_old cr;
-
-INSERT INTO chars_traits SELECT c.id, ct.tid, ct.spoil
- FROM chars_old c
- JOIN chars_traits_old ct ON ct.cid = c.latest;
-
-INSERT INTO chars_traits_hist SELECT cid, tid, spoil
- FROM chars_traits_old;
-
-INSERT INTO chars_vns SELECT c.id, cv.vid, cv.rid, cv.spoil, cv.role
- FROM chars_old c
- JOIN chars_vns_old cv ON cv.cid = c.latest;
-
-INSERT INTO chars_vns_hist SELECT cid, vid, rid, spoil, role
- FROM chars_vns_old;
-
-INSERT INTO producers SELECT p.id, p.locked, p.hidden,
- pr.type, pr.name, pr.original, pr.website, pr.lang, pr.desc, pr.alias, pr.l_wp, p.rgraph
- FROM producers_old p JOIN producers_rev_old pr ON pr.id = p.latest;
-
-INSERT INTO producers_hist SELECT id, type, name, original, website, lang, "desc", alias, l_wp
- FROM producers_rev_old;
-
-INSERT INTO producers_relations SELECT p.id, pr.pid2, pr.relation
- FROM producers_old p
- JOIN producers_relations_old pr ON p.latest = pr.pid1;
-
-INSERT INTO producers_relations_hist SELECT pid1, pid2, relation
- FROM producers_relations_old;
-
-INSERT INTO releases SELECT r.id, r.locked, r.hidden,
- rr.title, rr.original, rr.type, rr.website, rr.catalog, rr.gtin, rr.released, rr.notes, rr.minage, rr.patch,
- rr.freeware, rr.doujin, rr.resolution, rr.voiced, rr.ani_story, rr.ani_ero
- FROM releases_old r JOIN releases_rev_old rr ON rr.id = r.latest;
-
-INSERT INTO releases_hist SELECT rr.id,
- rr.title, rr.original, rr.type, rr.website, rr.catalog, rr.gtin, rr.released, rr.notes, rr.minage, rr.patch,
- rr.freeware, rr.doujin, rr.resolution, rr.voiced, rr.ani_story, rr.ani_ero
- FROM releases_rev_old rr;
-
-INSERT INTO releases_lang SELECT r.id, rl.lang
- FROM releases_old r JOIN releases_lang_old rl ON rl.rid = r.latest;
-
-INSERT INTO releases_lang_hist SELECT rl.rid, rl.lang
- FROM releases_lang_old rl;
-
-INSERT INTO releases_media SELECT r.id, rm.medium, rm.qty
- FROM releases_old r JOIN releases_media_old rm ON rm.rid = r.latest;
-
-INSERT INTO releases_media_hist SELECT rm.rid, rm.medium, rm.qty
- FROM releases_media_old rm;
-
-INSERT INTO releases_platforms SELECT r.id, rp.platform
- FROM releases_old r JOIN releases_platforms_old rp ON rp.rid = r.latest;
-
-INSERT INTO releases_platforms_hist SELECT rp.rid, rp.platform
- FROM releases_platforms_old rp;
-
-INSERT INTO releases_producers SELECT r.id, rp.pid, rp.developer, rp.publisher
- FROM releases_old r JOIN releases_producers_old rp ON rp.rid = r.latest;
-
-INSERT INTO releases_producers_hist SELECT rp.rid, rp.pid, rp.developer, rp.publisher
- FROM releases_producers_old rp;
-
-INSERT INTO releases_vn SELECT r.id, rv.vid
- FROM releases_old r JOIN releases_vn_old rv ON rv.rid = r.latest;
-
-INSERT INTO releases_vn_hist SELECT rv.rid, rv.vid
- FROM releases_vn_old rv;
-
-INSERT INTO staff SELECT s.id, s.locked, s.hidden,
- sr.aid, sr.gender, sr.lang, sr.desc, sr.l_wp, sr.l_site, sr.l_twitter, sr.l_anidb
- FROM staff_old s JOIN staff_rev_old sr ON sr.id = s.latest;
-
-INSERT INTO staff_hist SELECT sr.id,
- sr.aid, sr.gender, sr.lang, sr.desc, sr.l_wp, sr.l_site, sr.l_twitter, sr.l_anidb
- FROM staff_rev_old sr;
-
-INSERT INTO staff_alias SELECT s.id, sa.id, sa.name, sa.original
- FROM staff_old s JOIN staff_alias_old sa ON sa.rid = s.latest;
-
-INSERT INTO staff_alias_hist SELECT rid, id, name, original
- FROM staff_alias_old;
-
-INSERT INTO vn SELECT v.id, v.locked, v.hidden,
- vr.title, vr.original, vr.alias, vr.length, vr.img_nsfw, vr.image, vr.desc, vr.l_wp, vr.l_encubed, vr.l_renai,
- v.rgraph, v.c_released, v.c_languages, v.c_olang, v.c_platforms, v.c_popularity, v.c_rating, v.c_votecount, v.c_search
- FROM vn_old v JOIN vn_rev_old vr ON vr.id = v.latest;
-
-INSERT INTO vn_hist SELECT vr.id,
- vr.title, vr.original, vr.alias, vr.length, vr.img_nsfw, vr.image, vr.desc, vr.l_wp, vr.l_encubed, vr.l_renai
- FROM vn_rev_old vr;
-
-INSERT INTO vn_anime SELECT v.id, va.aid
- FROM vn_old v JOIN vn_anime_old va ON va.vid = v.latest;
-
-INSERT INTO vn_anime_hist SELECT vid, aid
- FROM vn_anime_old;
-
-INSERT INTO vn_relations SELECT v.id, vr.vid2, vr.relation, vr.official
- FROM vn_old v JOIN vn_relations_old vr ON vr.vid1 = v.latest;
-
-INSERT INTO vn_relations_hist SELECT vid1, vid2, relation, official
- FROM vn_relations_old;
-
-INSERT INTO vn_screenshots SELECT v.id, vs.scr, vs.rid, vs.nsfw
- FROM vn_old v JOIN vn_screenshots_old vs ON vs.vid = v.latest;
-
-INSERT INTO vn_screenshots_hist SELECT vid, scr, rid, nsfw
- FROM vn_screenshots_old;
-
-INSERT INTO vn_seiyuu SELECT v.id, vs.aid, vs.cid, vs.note
- FROM vn_old v JOIN vn_seiyuu_old vs ON vs.vid = v.latest;
-
-INSERT INTO vn_seiyuu_hist SELECT vid, aid, cid, note
- FROM vn_seiyuu_old;
-
-INSERT INTO vn_staff SELECT v.id, vs.aid, vs.role, vs.note
- FROM vn_old v JOIN vn_staff_old vs ON vs.vid = v.latest;
-
-INSERT INTO vn_staff_hist SELECT vid, aid, role, note
- FROM vn_staff_old;
-
-
-SELECT setval('changes_id_seq', nextval('changes_id_seq_old'));
-SELECT setval('chars_id_seq', nextval('chars_id_seq_old'));
-SELECT setval('producers_id_seq', nextval('producers_id_seq_old'));
-SELECT setval('releases_id_seq', nextval('releases_id_seq_old'));
-SELECT setval('staff_alias_aid_seq', nextval('staff_alias_id_seq_old')); -- note the change from id to aid
-SELECT setval('staff_id_seq', nextval('staff_id_seq_old'));
-SELECT setval('vn_id_seq', nextval('vn_id_seq_old'));
-
-
--- Dropping all tables with CASCADE causes all foreign key references to and
--- from the tables to be dropped as well. This is exactly what we want, so we
--- can re-add the constraints on the newly created tables.
-DROP TABLE changes_old CASCADE;
-DROP TABLE chars_old CASCADE;
-DROP TABLE chars_rev_old CASCADE;
-DROP TABLE chars_traits_old CASCADE;
-DROP TABLE chars_vns_old CASCADE;
-DROP TABLE producers_old CASCADE;
-DROP TABLE producers_rev_old CASCADE;
-DROP TABLE producers_relations_old CASCADE;
-DROP TABLE releases_old CASCADE;
-DROP TABLE releases_rev_old CASCADE;
-DROP TABLE releases_lang_old CASCADE;
-DROP TABLE releases_media_old CASCADE;
-DROP TABLE releases_platforms_old CASCADE;
-DROP TABLE releases_producers_old CASCADE;
-DROP TABLE releases_vn_old CASCADE;
-DROP TABLE staff_old CASCADE;
-DROP TABLE staff_rev_old CASCADE;
-DROP TABLE staff_alias_old CASCADE;
-DROP TABLE vn_old CASCADE;
-DROP TABLE vn_rev_old CASCADE;
-DROP TABLE vn_anime_old CASCADE;
-DROP TABLE vn_relations_old CASCADE;
-DROP TABLE vn_screenshots_old CASCADE;
-DROP TABLE vn_seiyuu_old CASCADE;
-DROP TABLE vn_staff_old CASCADE;
-
-DROP INDEX threads_posts_ts;
-
-DROP FUNCTION edit_revtable(dbentry_type, integer);
-DROP FUNCTION edit_vn_init(integer);
-DROP FUNCTION edit_vn_commit();
-DROP FUNCTION edit_release_init(integer);
-DROP FUNCTION edit_release_commit();
-DROP FUNCTION edit_producer_init(integer);
-DROP FUNCTION edit_producer_commit();
-DROP FUNCTION edit_char_init(integer);
-DROP FUNCTION edit_char_commit();
-DROP FUNCTION edit_staff_init(integer);
-DROP FUNCTION edit_staff_commit();
-DROP FUNCTION release_vncache_update();
-DROP FUNCTION notify_dbdel();
-DROP FUNCTION notify_dbedit();
-DROP FUNCTION notify_listdel();
-DROP FUNCTION update_hidlock();
-
-DROP TYPE edit_rettype CASCADE;
-CREATE TYPE edit_rettype AS (itemid integer, chid integer, rev integer);
-
-\i util/sql/func.sql
-\i util/sql/editfunc.sql
-\i util/sql/tableattrs.sql
-\i util/sql/triggers.sql
diff --git a/util/updates/update_2.25.sql b/util/updates/update_2.25.sql
deleted file mode 100644
index 55fdb537..00000000
--- a/util/updates/update_2.25.sql
+++ /dev/null
@@ -1,80 +0,0 @@
-ALTER TYPE credit_type ADD VALUE 'scenario' BEFORE 'script';
-
-
-BEGIN;
--- There are no entries in the database where a single aid has both a script
--- and a staff role, and where a note has been associated with the script role.
--- So this conversion does not attempt to merge notes when merging roles.
-UPDATE vn_staff vs SET role = 'staff', note = CASE WHEN note = '' THEN 'Scripting' ELSE note END
- WHERE role = 'script' AND NOT EXISTS(SELECT 1 FROM vn_staff v2 where v2.vid = vs.vid AND v2.aid = vs.aid AND role = 'staff');
-UPDATE vn_staff vs SET note = CASE WHEN note = '' THEN 'Scripting' ELSE note || ', Scripting' END
- WHERE role = 'staff' AND EXISTS(SELECT 1 FROM vn_staff v2 where v2.vid = vs.vid AND v2.aid = vs.aid AND role = 'script');
-DELETE FROM vn_staff WHERE role = 'script';
-COMMIT;
-
-
--- Some new (or, well, old) platforms
-ALTER TYPE platform ADD VALUE 'fmt' BEFORE 'gba';
-ALTER TYPE platform ADD VALUE 'pce' BEFORE 'pcf';
-ALTER TYPE platform ADD VALUE 'x68' BEFORE 'xb1';
-
--- New language
-ALTER TYPE language ADD VALUE 'ca' BEFORE 'cs';
-
-
--- Reorder credit_type (and remove 'script')
-ALTER TYPE credit_type RENAME TO credit_type2;
-CREATE TYPE credit_type AS ENUM ('scenario', 'chardesign', 'art', 'music', 'songs', 'director', 'staff');
-ALTER TABLE vn_staff ALTER role DROP DEFAULT;
-ALTER TABLE vn_staff ALTER role TYPE credit_type USING role::text::credit_type;
-ALTER TABLE vn_staff ALTER role SET DEFAULT 'staff';
-DROP TYPE credit_type2;
-
-
--- Staff stat
-INSERT INTO stats_cache (section, count) VALUES ('staff', 0);
-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();
-UPDATE stats_cache SET count = (SELECT COUNT(*) FROM staff WHERE hidden = FALSE) WHERE section = 'staff'
-
-
--- New preferences
-ALTER TYPE prefs_key ADD VALUE 'tags_all';
-ALTER TYPE prefs_key ADD VALUE 'tags_cat';
-ALTER TYPE prefs_key ADD VALUE 'spoilers';
-ALTER TYPE prefs_key ADD VALUE 'traits_sexual';
-
-
--- Convert threads_boards.type to enum
-CREATE TYPE board_type AS ENUM ('an', 'db', 'ge', 'v', 'p', 'u');
-ALTER TABLE threads_boards ALTER COLUMN type DROP DEFAULT;
-ALTER TABLE threads_boards ALTER COLUMN type TYPE board_type USING trim(type)::board_type;
-
-
--- Full-text board search
-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;
-
-CREATE INDEX threads_posts_ts ON threads_posts USING gin(to_tsvector('english', strip_bb_tags(msg)));
-
--- 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;
-
-
--- Changes to search normalization
-UPDATE vn SET c_search = NULL;
-
-
--- Convert producers_rev.type to enum
-CREATE TYPE producer_type AS ENUM ('co', 'in', 'ng');
-ALTER TABLE producers_rev ALTER COLUMN type DROP DEFAULT;
-ALTER TABLE producers_rev ALTER COLUMN type TYPE producer_type USING type::producer_type;
-ALTER TABLE producers_rev ALTER COLUMN type SET DEFAULT 'co';
-
-
--- Extra index
-CREATE INDEX notifications_uid ON notifications (uid);
diff --git a/util/updates/update_2.26.sql b/util/updates/update_2.26.sql
deleted file mode 100644
index 44d15383..00000000
--- a/util/updates/update_2.26.sql
+++ /dev/null
@@ -1,60 +0,0 @@
--- No more 'staffedit' permission flag
-UPDATE users SET perm = (perm & ~8);
-
--- Removed support for sha256-hashed passwords
-UPDATE users SET passwd = '' WHERE length(passwd) = 41;
-
--- Need to regenerate all relation graphs in the switch to HTML5
-UPDATE vn SET rgraph = NULL;
-UPDATE producers SET rgraph = NULL;
-
-
--- Polls
-ALTER TABLE threads ADD COLUMN poll_question varchar(100);
-ALTER TABLE threads ADD COLUMN poll_max_options smallint NOT NULL DEFAULT 1;
-ALTER TABLE threads ADD COLUMN poll_preview boolean NOT NULL DEFAULT FALSE;
-ALTER TABLE threads ADD COLUMN poll_recast boolean NOT NULL DEFAULT FALSE;
-CREATE TABLE threads_poll_options (
- id SERIAL PRIMARY KEY,
- tid integer NOT NULL,
- option varchar(100) NOT NULL
-);
-CREATE TABLE threads_poll_votes (
- tid integer NOT NULL,
- uid integer NOT NULL,
- optid integer NOT NULL,
- PRIMARY KEY (tid, uid, optid)
-);
-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;
-
-
--- Tagalog language
-ALTER TYPE language ADD VALUE 'ta' BEFORE 'tr';
-
-
--- Improved substring search relevance
-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;
-
-
--- Thai language
-ALTER TYPE language ADD VALUE 'th' BEFORE 'tr';
-
-
--- Improve performance for traits_chars calculation
-ALTER TABLE traits_chars DROP CONSTRAINT traits_chars_cid_fkey;
-ALTER TABLE traits_chars DROP CONSTRAINT traits_chars_tid_fkey;
-CREATE INDEX traits_chars_tid ON traits_chars (tid);
-
-
--- Croatian language
-ALTER TYPE language ADD VALUE 'hr' BEFORE 'hu';
diff --git a/util/updates/update_2.27.sql b/util/updates/update_2.27.sql
deleted file mode 100644
index 35e7b4af..00000000
--- a/util/updates/update_2.27.sql
+++ /dev/null
@@ -1,10 +0,0 @@
--- Added 1366x768 resolution
-UPDATE releases SET resolution = 15 WHERE resolution = 14 AND NOT EXISTS(SELECT 1 FROM releases WHERE resolution = 15);
-UPDATE releases_hist SET resolution = 15 WHERE resolution = 14 AND NOT EXISTS(SELECT 1 FROM releases_hist WHERE resolution = 15);
-
--- Nintendo Switch & Wii U
-ALTER TYPE platform ADD VALUE 'swi' BEFORE 'wii';
-ALTER TYPE platform ADD VALUE 'wiu' BEFORE 'n3d';
-
--- Bulgarian
-ALTER TYPE language ADD VALUE 'bg' BEFORE 'ca';
diff --git a/util/updates/update_2.3.sql b/util/updates/update_2.3.sql
deleted file mode 100644
index 1a8c9571..00000000
--- a/util/updates/update_2.3.sql
+++ /dev/null
@@ -1,205 +0,0 @@
-
--- some random VN quotes
-CREATE TABLE quotes (
- vid integer NOT NULL REFERENCES vn (id),
- quote varchar(250) NOT NULL,
- PRIMARY KEY(vid, quote)
-) WITHOUT OIDS;
-
-
--- catalog numbers for releases
-ALTER TABLE releases_rev ADD COLUMN catalog varchar(50) NOT NULL DEFAULT '';
-
-
--- aliases field for producers
-ALTER TABLE producers_rev ADD COLUMN alias varchar(500) NOT NULL DEFAULT '';
-
-
-
--- tagging system
-
-CREATE TABLE tags (
- id SERIAL NOT NULL PRIMARY KEY,
- name varchar(250) NOT NULL UNIQUE,
- description text NOT NULL DEFAULT '',
- meta boolean NOT NULL DEFAULT FALSE,
- added bigint NOT NULL DEFAULT DATE_PART('epoch'::text, NOW()),
- state smallint NOT NULL DEFAULT 0, -- 0: awaiting moderation, 1: deleted, 2: accepted
- c_vns integer NOT NULL DEFAULT 0
-) WITHOUT OIDS;
-
-CREATE TABLE tags_aliases (
- alias varchar(250) NOT NULL PRIMARY KEY,
- tag integer NOT NULL REFERENCES tags (id) DEFERRABLE INITIALLY DEFERRED
-) WITHOUT OIDS;
-
-CREATE TABLE tags_parents (
- tag integer NOT NULL REFERENCES tags (id) DEFERRABLE INITIALLY DEFERRED,
- parent integer NOT NULL REFERENCES tags (id) DEFERRABLE INITIALLY DEFERRED,
- PRIMARY KEY(tag, parent)
-) WITHOUT OIDS;
-
-CREATE TABLE tags_vn (
- tag integer NOT NULL REFERENCES tags (id) DEFERRABLE INITIALLY DEFERRED,
- vid integer NOT NULL REFERENCES vn (id) DEFERRABLE INITIALLY DEFERRED,
- uid integer NOT NULL REFERENCES users (id) DEFERRABLE INITIALLY DEFERRED,
- vote smallint NOT NULL DEFAULT 3 CHECK (vote >= -3 AND vote <= 3 AND vote <> 0),
- spoiler smallint CHECK(spoiler >= 0 AND spoiler <= 2),
- PRIMARY KEY(tag, vid, uid)
-) WITHOUT OIDS;
-
-CREATE TABLE tags_vn_bayesian (
- tag integer NOT NULL,
- vid integer NOT NULL,
- users integer NOT NULL,
- rating real NOT NULL,
- spoiler smallint NOT NULL
-) WITHOUT OIDS;
-
-
-CREATE TYPE tag_tree_item AS (lvl smallint, tag integer, name text, c_vns integer);
-
--- tag: tag to start with,
--- lvl: recursion level
--- dir: direction, true = parent->child, false = child->parent
-CREATE OR REPLACE FUNCTION tag_tree(tag integer, lvl integer, dir boolean) RETURNS SETOF tag_tree_item AS $$
-DECLARE
- r tag_tree_item%rowtype;
- r2 tag_tree_item%rowtype;
-BEGIN
- IF dir AND tag = 0 THEN
- FOR r IN
- SELECT lvl, t.id, t.name, t.c_vns
- FROM tags t
- WHERE state = 2 AND NOT EXISTS(SELECT 1 FROM tags_parents tp WHERE tp.tag = t.id)
- ORDER BY t.name
- LOOP
- RETURN NEXT r;
- IF lvl-1 <> 0 THEN
- FOR r2 IN SELECT * FROM tag_tree(r.tag, lvl-1, dir) LOOP
- RETURN NEXT r2;
- END LOOP;
- END IF;
- END LOOP;
- ELSIF dir THEN
- FOR r IN
- SELECT lvl, tp.tag, t.name, t.c_vns
- FROM tags_parents tp
- JOIN tags t ON t.id = tp.tag
- WHERE tp.parent = tag
- AND state = 2
- ORDER BY t.name
- LOOP
- RETURN NEXT r;
- IF lvl-1 <> 0 THEN
- FOR r2 IN SELECT * FROM tag_tree(r.tag, lvl-1, dir) LOOP
- RETURN NEXT r2;
- END LOOP;
- END IF;
- END LOOP;
- ELSE
- FOR r IN
- SELECT lvl, tp.parent, t.name, t.c_vns
- FROM tags_parents tp
- JOIN tags t ON t.id = tp.parent
- WHERE tp.tag = tag
- AND state = 2
- ORDER BY t.name
- LOOP
- RETURN NEXT r;
- IF lvl-1 <> 0 THEN
- FOR r2 IN SELECT * FROM tag_tree(r.tag, lvl-1, dir) LOOP
- RETURN NEXT r2;
- END LOOP;
- END IF;
- END LOOP;
- END IF;
-END;
-$$ LANGUAGE plpgsql;
-
--- returns all votes inherited by childs
--- UNION this with tags_vn and you have all votes for all tags
-CREATE OR REPLACE FUNCTION tag_vn_childs() RETURNS SETOF tags_vn AS $$
-DECLARE
- r tags_vn%rowtype;
- i RECORD;
- l RECORD;
-BEGIN
- FOR l IN SElECT id FROM tags WHERE meta = FALSE AND state = 2 AND EXISTS(SELECT 1 FROM tags_parents WHERE parent = id) LOOP
- FOR i IN SELECT tag FROM tag_tree(l.id, 0, true) LOOP
- FOR r IN SELECT l.id, vid, uid, vote, spoiler FROM tags_vn WHERE tag = i.tag LOOP
- RETURN NEXT r;
- END LOOP;
- END LOOP;
- END LOOP;
-END;
-$$ LANGUAGE plpgsql;
-
--- updates tags_vn_bayesian with rankings of tags
-CREATE OR REPLACE FUNCTION tag_vn_calc() RETURNS void AS $$
-BEGIN
- -- all votes for all tags
- CREATE OR REPLACE TEMPORARY VIEW tags_vn_all AS
- SELECT * FROM tags_vn UNION SELECT * FROM tag_vn_childs();
- -- grouped by (tag, vid, uid), so only one user votes on one parent tag per VN entry
- CREATE OR REPLACE TEMPORARY VIEW tags_vn_grouped AS
- SELECT tag, vid, uid, MAX(vote)::real AS vote, COALESCE(AVG(spoiler), 0)::real AS spoiler
- FROM tags_vn_all GROUP BY tag, vid, uid;
- -- grouped by (tag, vid) and serialized into a table
- DROP INDEX IF EXISTS tags_vn_bayesian_tag;
- TRUNCATE tags_vn_bayesian;
- INSERT INTO tags_vn_bayesian
- SELECT tag, vid, COUNT(uid) AS users, AVG(vote)::real AS rating,
- (CASE WHEN AVG(spoiler) < 0.7 THEN 0 WHEN AVG(spoiler) > 1.3 THEN 2 ELSE 1 END)::smallint AS spoiler
- FROM tags_vn_grouped
- GROUP BY tag, vid;
- CREATE INDEX tags_vn_bayesian_tag ON tags_vn_bayesian (tag);
- -- now perform the bayesian ranking calculation
- UPDATE tags_vn_bayesian tvs SET rating =
- ((SELECT AVG(users)::real * AVG(rating)::real FROM tags_vn_bayesian WHERE tag = tvs.tag) + users*rating)
- / ((SELECT AVG(users)::real FROM tags_vn_bayesian WHERE tag = tvs.tag) + users)::real;
- -- and update the VN count in the tags table as well
- UPDATE tags SET c_vns = (SELECT COUNT(*) FROM tags_vn_bayesian WHERE tag = id);
- RETURN;
-END;
-$$ LANGUAGE plpgsql;
-SELECT tag_vn_calc();
-
-
-
--- Cache users tag vote count
-ALTER TABLE users ADD COLUMN c_tags integer NOT NULL DEFAULT 0;
-UPDATE users SET c_tags = (SELECT COUNT(*) FROM tags_vn WHERE uid = id);
-
-CREATE OR REPLACE FUNCTION update_users_cache() RETURNS TRIGGER AS $$
-BEGIN
- IF TG_TABLE_NAME = 'votes' THEN
- IF TG_OP = 'INSERT' THEN
- UPDATE users SET c_votes = c_votes + 1 WHERE id = NEW.uid;
- ELSE
- UPDATE users SET c_votes = c_votes - 1 WHERE id = OLD.uid;
- END IF;
- ELSIF TG_TABLE_NAME = 'changes' THEN
- IF TG_OP = 'INSERT' THEN
- UPDATE users SET c_changes = c_changes + 1 WHERE id = NEW.requester;
- ELSE
- UPDATE users SET c_changes = c_changes - 1 WHERE id = OLD.requester;
- END IF;
- ELSIF TG_TABLE_NAME = 'tags_vn' THEN
- IF TG_OP = 'INSERT' THEN
- UPDATE users SET c_tags = c_tags + 1 WHERE id = NEW.uid;
- ELSE
- UPDATE users SET c_tags = c_tags - 1 WHERE id = OLD.uid;
- END IF;
- END IF;
- RETURN NULL;
-END;
-$$ LANGUAGE 'plpgsql';
-
-CREATE TRIGGER users_tags_update AFTER INSERT OR DELETE ON tags_vn FOR EACH ROW EXECUTE PROCEDURE update_users_cache();
-
-
-
--- rename threads tags to boards
-ALTER TABLE threads_tags RENAME TO threads_boards;
-
diff --git a/util/updates/update_2.4.sql b/util/updates/update_2.4.sql
deleted file mode 100644
index d55ce0ef..00000000
--- a/util/updates/update_2.4.sql
+++ /dev/null
@@ -1,99 +0,0 @@
-
-
--- don't consider vns with vote < 0 on tag pages
-
-CREATE OR REPLACE FUNCTION tag_vn_calc() RETURNS void AS $$
-BEGIN
- -- all votes for all tags
- CREATE OR REPLACE TEMPORARY VIEW tags_vn_all AS
- SELECT * FROM tags_vn UNION SELECT * FROM tag_vn_childs();
- -- grouped by (tag, vid, uid), so only one user votes on one parent tag per VN entry
- CREATE OR REPLACE TEMPORARY VIEW tags_vn_grouped AS
- SELECT tag, vid, uid, MAX(vote)::real AS vote, COALESCE(AVG(spoiler), 0)::real AS spoiler
- FROM tags_vn_all WHERE vote > 0 GROUP BY tag, vid, uid;
- -- grouped by (tag, vid) and serialized into a table
- DROP INDEX IF EXISTS tags_vn_bayesian_tag;
- TRUNCATE tags_vn_bayesian;
- INSERT INTO tags_vn_bayesian
- SELECT tag, vid, COUNT(uid) AS users, AVG(vote)::real AS rating,
- (CASE WHEN AVG(spoiler) < 0.7 THEN 0 WHEN AVG(spoiler) > 1.3 THEN 2 ELSE 1 END)::smallint AS spoiler
- FROM tags_vn_grouped
- GROUP BY tag, vid;
- CREATE INDEX tags_vn_bayesian_tag ON tags_vn_bayesian (tag);
- -- now perform the bayesian ranking calculation
- UPDATE tags_vn_bayesian tvs SET rating =
- ((SELECT AVG(users)::real * AVG(rating)::real FROM tags_vn_bayesian WHERE tag = tvs.tag) + users*rating)
- / ((SELECT AVG(users)::real FROM tags_vn_bayesian WHERE tag = tvs.tag) + users)::real;
- -- and update the VN count in the tags table as well
- UPDATE tags SET c_vns = (SELECT COUNT(*) FROM tags_vn_bayesian WHERE tag = id);
- RETURN;
-END;
-$$ LANGUAGE plpgsql;
-SELECT tag_vn_calc();
-
-
-
-
--- resolution field
-ALTER TABLE releases_rev ADD COLUMN resolution smallint NOT NULL DEFAULT 0;
--- voiced
-ALTER TABLE releases_rev ADD COLUMN voiced smallint NOT NULL DEFAULT 0;
--- freeware / doujin
-ALTER TABLE releases_rev ADD COLUMN freeware boolean NOT NULL DEFAULT FALSE;
-ALTER TABLE releases_rev ADD COLUMN doujin boolean NOT NULL DEFAULT FALSE;
--- animated
-ALTER TABLE releases_rev ADD COLUMN ani_story smallint NOT NULL DEFAULT 0;
-ALTER TABLE releases_rev ADD COLUMN ani_ero smallint NOT NULL DEFAULT 0;
-
-
-
-
--- set doujin flag for all non-patch releases which have an "amateur group" as producer
--- set freeware flag for all patches
--- (the revision system makes this slightly more complex than doing a simple UPDATE)
-
-CREATE FUNCTION tmp_edit_release(iid integer) RETURNS void AS $$
-DECLARE
- cid integer;
- oid integer;
- fw boolean;
- do boolean;
- comm text;
-BEGIN
- SELECT INTO oid latest FROM releases WHERE id = iid;
- SELECT INTO fw EXISTS(SELECT 1 FROM releases_rev WHERE id = oid AND patch);
- SELECT INTO do EXISTS(SELECT 1 FROM releases_producers rp JOIN releases_rev rr ON rp.rid = rr.id
- JOIN producers p ON p.id = rp.pid JOIN producers_rev pr ON pr.id = p.latest WHERE rp.rid = oid AND pr.type = 'ng' AND rr.patch = false);
- IF NOT do AND NOT fw THEN
- RETURN;
- END IF;
- comm := E'Automated edit with the update to VNDB 2.4.\n\n';
- IF fw THEN
- comm := comm || E'This release is a patch, freeware flag is assumed\n';
- END IF;
- IF do THEN
- comm := comm || E'This release has an \'amateur group\' as producer and as such is likely to be a doujin release.\n';
- END IF;
- comm := comm || E'Feel free to revert if this assumption happens to be incorrect for this entry.';
- INSERT INTO changes (type, requester, ip, comments, rev)
- VALUES (1, 1, '0.0.0.0', comm, (SELECT rev+1 FROM changes WHERE id = oid))
- RETURNING id INTO cid;
- INSERT INTO releases_media (rid, medium, qty) SELECT cid, medium, qty FROM releases_media WHERE rid = oid;
- INSERT INTO releases_platforms (rid, platform) SELECT cid, platform FROM releases_platforms WHERE rid = oid;
- INSERT INTO releases_producers (rid, pid) SELECT cid, pid FROM releases_producers WHERE rid = oid;
- INSERT INTO releases_rev (id, rid, title, original, type, language, website, released, notes,
- minage, gtin, patch, catalog, resolution, voiced, freeware, doujin, ani_story, ani_ero)
- SELECT cid, rid, title, original, type, language, website, released, notes,
- minage, gtin, patch, catalog, resolution, voiced, fw, do, ani_story, ani_ero
- FROM releases_rev WHERE id = oid;
- INSERT INTO releases_vn (rid, vid) SELECT cid, vid FROM releases_vn WHERE rid = oid;
- UPDATE releases SET latest = cid WHERE id = iid;
-END;
-$$ LANGUAGE plpgsql;
-
--- this can be done a lot more efficiently, but this method is just easier :-)
-SELECT tmp_edit_release(id) FROM releases WHERE hidden = FALSE;
-
-DROP FUNCTION tmp_edit_release(integer);
-
-
diff --git a/util/updates/update_2.5.sql b/util/updates/update_2.5.sql
deleted file mode 100644
index b45da7b0..00000000
--- a/util/updates/update_2.5.sql
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
--- multilingual releases
-
-CREATE TABLE releases_lang (
- rid integer NOT NULL REFERENCES releases_rev (id) DEFERRABLE INITIALLY DEFERRED,
- lang varchar NOT NULL,
- PRIMARY KEY(rid, lang)
-);
-INSERT INTO releases_lang (rid, lang) SELECT id, language FROM releases_rev;
-ALTER TABLE releases_rev DROP COLUMN language;
-
-CREATE OR REPLACE FUNCTION update_vncache(id integer) RETURNS void AS $$
-DECLARE
- w text := '';
-BEGIN
- IF id > 0 THEN
- w := ' WHERE id = '||id;
- END IF;
- EXECUTE 'UPDATE vn SET
- c_released = COALESCE((SELECT
- MIN(rr1.released)
- FROM releases_rev rr1
- JOIN releases r1 ON rr1.id = r1.latest
- JOIN releases_vn rv1 ON rr1.id = rv1.rid
- WHERE rv1.vid = vn.id
- AND rr1.type <> 2
- AND r1.hidden = FALSE
- AND rr1.released <> 0
- GROUP BY rv1.vid
- ), 0),
- c_languages = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT rl2.lang
- FROM releases_rev rr2
- JOIN releases_lang rl2 ON rl2.rid = rr2.id
- JOIN releases r2 ON rr2.id = r2.latest
- JOIN releases_vn rv2 ON rr2.id = rv2.rid
- WHERE rv2.vid = vn.id
- AND rr2.type <> 2
- AND rr2.released <= TO_CHAR(''today''::timestamp, ''YYYYMMDD'')::integer
- AND r2.hidden = FALSE
- GROUP BY rl2.lang
- ORDER BY rl2.lang
- ), ''/''), ''''),
- c_platforms = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT rp3.platform
- FROM releases_platforms rp3
- JOIN releases_rev rr3 ON rp3.rid = rr3.id
- JOIN releases r3 ON rp3.rid = r3.latest
- JOIN releases_vn rv3 ON rp3.rid = rv3.rid
- WHERE rv3.vid = vn.id
- AND rr3.type <> 2
- AND rr3.released <= TO_CHAR(''today''::timestamp, ''YYYYMMDD'')::integer
- AND r3.hidden = FALSE
- GROUP BY rp3.platform
- ORDER BY rp3.platform
- ), ''/''), '''')
- '||w;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
--- added by field for tags
-
-ALTER TABLE tags ADD COLUMN addedby integer NOT NULL DEFAULT 1 REFERENCES users (id) DEFERRABLE INITIALLY DEFERRED;
-
diff --git a/util/updates/update_2.6.sql b/util/updates/update_2.6.sql
deleted file mode 100644
index 26d363d7..00000000
--- a/util/updates/update_2.6.sql
+++ /dev/null
@@ -1,357 +0,0 @@
-
-
--- Create table for session data storage
-CREATE TABLE sessions (
- uid integer NOT NULL REFERENCES users(id),
- token bytea NOT NULL,
- expiration timestamptz NOT NULL DEFAULT (NOW() + '1 year'::interval),
- PRIMARY KEY (uid, token)
-);
-
--- Add column to users for salt storage
-ALTER TABLE users ADD COLUMN salt character(9) NOT NULL DEFAULT ''::bpchar;
-
-
-
--- The anime table:
--- - use timestamp data type for anime.lastfetch
--- - allow NULL for all columns except id
-ALTER TABLE anime ALTER COLUMN lastfetch DROP NOT NULL;
-ALTER TABLE anime ALTER COLUMN lastfetch DROP DEFAULT;
-UPDATE anime SET lastfetch = NULL WHERE lastfetch <= 0;
-ALTER TABLE anime ALTER COLUMN lastfetch TYPE timestamptz USING to_timestamp(lastfetch);
-
-ALTER TABLE anime ALTER COLUMN ann_id DROP NOT NULL;
-ALTER TABLE anime ALTER COLUMN ann_id DROP DEFAULT;
-UPDATE anime SET ann_id = NULL WHERE ann_id = 0;
-
-ALTER TABLE anime ALTER COLUMN nfo_id DROP NOT NULL;
-ALTER TABLE anime ALTER COLUMN nfo_id DROP DEFAULT;
-UPDATE anime SET nfo_id = NULL WHERE nfo_id = '';
-
-ALTER TABLE anime ALTER COLUMN title_kanji DROP NOT NULL;
-ALTER TABLE anime ALTER COLUMN title_kanji DROP DEFAULT;
-UPDATE anime SET title_kanji = NULL WHERE title_kanji = '';
-
-ALTER TABLE anime ALTER COLUMN title_romaji DROP NOT NULL;
-ALTER TABLE anime ALTER COLUMN title_romaji DROP DEFAULT;
-UPDATE anime SET title_romaji = NULL WHERE title_romaji = '';
-
-ALTER TABLE anime ALTER COLUMN type DROP NOT NULL;
-ALTER TABLE anime ALTER COLUMN type DROP DEFAULT;
-UPDATE anime SET type = NULL WHERE type = 0;
-UPDATE anime SET type = type-1;
-
-ALTER TABLE anime ALTER COLUMN year DROP NOT NULL;
-ALTER TABLE anime ALTER COLUMN year DROP DEFAULT;
-UPDATE anime SET year = NULL WHERE year = 0;
-
-
--- rlists.added -> timestamptz
-ALTER TABLE rlists ALTER COLUMN added DROP DEFAULT;
-ALTER TABLE rlists ALTER COLUMN added TYPE timestamptz USING to_timestamp(added);
-ALTER TABLE rlists ALTER COLUMN added SET DEFAULT NOW();
-
-
--- wlists.added -> timestamptz
-ALTER TABLE wlists ALTER COLUMN added DROP DEFAULT;
-ALTER TABLE wlists ALTER COLUMN added TYPE timestamptz USING to_timestamp(added);
-ALTER TABLE wlists ALTER COLUMN added SET DEFAULT NOW();
-
-
--- threads_posts.date -> timestamptz
-ALTER TABLE threads_posts ALTER COLUMN date DROP DEFAULT;
-ALTER TABLE threads_posts ALTER COLUMN date TYPE timestamptz USING to_timestamp(date);
-ALTER TABLE threads_posts ALTER COLUMN date SET DEFAULT NOW();
-
--- threads_posts.edited -> timestamptz + allow NULL
-ALTER TABLE threads_posts ALTER COLUMN edited DROP NOT NULL;
-ALTER TABLE threads_posts ALTER COLUMN edited DROP DEFAULT;
-ALTER TABLE threads_posts ALTER COLUMN edited TYPE timestamptz USING CASE WHEN edited = 0 THEN NULL ELSE to_timestamp(edited) END;
-
-
--- votes.date -> timestamptz
-ALTER TABLE votes ALTER COLUMN date DROP DEFAULT;
-ALTER TABLE votes ALTER COLUMN date TYPE timestamptz USING to_timestamp(date);
-ALTER TABLE votes ALTER COLUMN date SET DEFAULT NOW();
-
-
--- users.registered -> timestamptz
-ALTER TABLE users ALTER COLUMN registered DROP DEFAULT;
-ALTER TABLE users ALTER COLUMN registered TYPE timestamptz USING to_timestamp(registered);
-ALTER TABLE users ALTER COLUMN registered SET DEFAULT NOW();
-
-
--- tags.added -> timestamptz
-ALTER TABLE tags ALTER COLUMN added DROP DEFAULT;
-ALTER TABLE tags ALTER COLUMN added TYPE timestamptz USING to_timestamp(added);
-ALTER TABLE tags ALTER COLUMN added SET DEFAULT NOW();
-
-
--- changes.added -> timestamptz
-ALTER TABLE changes ALTER COLUMN added DROP DEFAULT;
-ALTER TABLE changes ALTER COLUMN added TYPE timestamptz USING to_timestamp(added);
-ALTER TABLE changes ALTER COLUMN added SET DEFAULT NOW();
-
-
--- screenshots.status (smallint) -> screenshots.processed (boolean)
-ALTER TABLE screenshots RENAME COLUMN status TO processed;
-ALTER TABLE screenshots ALTER COLUMN processed DROP DEFAULT;
-ALTER TABLE screenshots ALTER COLUMN processed TYPE boolean USING processed::int::boolean;
-ALTER TABLE screenshots ALTER COLUMN processed SET DEFAULT FALSE;
-
-
-
--- two new resolutions have been added, array indexes have changed
-UPDATE releases_rev SET resolution = resolution + 1 WHERE resolution >= 5;
-UPDATE releases_rev SET resolution = resolution + 1 WHERE resolution >= 7;
-
-
-
--- remove the DEFERRED attribute on all foreign key checks on which it isn't necessary
--- (note: these queries all assume the foreign keys have their default names, as given
--- by PostgreSQL. This shouldn't be a problem, provided if you haven't touched them.)
-ALTER TABLE changes DROP CONSTRAINT changes_requester_fkey;
-ALTER TABLE changes DROP CONSTRAINT changes_causedby_fkey;
-ALTER TABLE producers_rev DROP CONSTRAINT producers_rev_id_fkey;
-ALTER TABLE producers_rev DROP CONSTRAINT producers_rev_pid_fkey;
-ALTER TABLE quotes DROP CONSTRAINT quotes_vid_fkey;
-ALTER TABLE releases_lang DROP CONSTRAINT releases_lang_rid_fkey;
-ALTER TABLE releases_media DROP CONSTRAINT releases_media_rid_fkey;
-ALTER TABLE releases_platforms DROP CONSTRAINT releases_platforms_rid_fkey;
-ALTER TABLE releases_producers DROP CONSTRAINT releases_producers_rid_fkey;
-ALTER TABLE releases_producers DROP CONSTRAINT releases_producers_pid_fkey;
-ALTER TABLE releases_rev DROP CONSTRAINT releases_rev_id_fkey;
-ALTER TABLE releases_rev DROP CONSTRAINT releases_rev_rid_fkey;
-ALTER TABLE releases_vn DROP CONSTRAINT releases_vn_rid_fkey;
-ALTER TABLE releases_vn DROP CONSTRAINT releases_vn_vid_fkey;
-ALTER TABLE rlists DROP CONSTRAINT rlists_uid_fkey;
-ALTER TABLE rlists DROP CONSTRAINT rlists_rid_fkey;
-ALTER TABLE tags DROP CONSTRAINT tags_addedby_fkey;
-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;
-ALTER TABLE tags_vn DROP CONSTRAINT tags_vn_vid_fkey;
-ALTER TABLE tags_vn DROP CONSTRAINT tags_vn_uid_fkey;
-ALTER TABLE threads_posts DROP CONSTRAINT threads_posts_tid_fkey;
-ALTER TABLE threads_posts DROP CONSTRAINT threads_posts_uid_fkey;
-ALTER TABLE threads_boards DROP CONSTRAINT threads_tags_tid_fkey; -- threads_boards used to be called threads_tags
-ALTER TABLE vn DROP CONSTRAINT vn_rgraph_fkey;
-ALTER TABLE vn_anime DROP CONSTRAINT vn_anime_aid_fkey;
-ALTER TABLE vn_anime DROP CONSTRAINT vn_anime_vid_fkey;
-ALTER TABLE vn_relations DROP CONSTRAINT vn_relations_vid1_fkey;
-ALTER TABLE vn_relations DROP CONSTRAINT vn_relations_vid2_fkey;
-ALTER TABLE vn_rev DROP CONSTRAINT vn_rev_id_fkey;
-ALTER TABLE vn_rev DROP CONSTRAINT vn_rev_vid_fkey;
-ALTER TABLE vn_screenshots DROP CONSTRAINT vn_screenshots_vid_fkey;
-ALTER TABLE vn_screenshots DROP CONSTRAINT vn_screenshots_scr_fkey;
-ALTER TABLE vn_screenshots DROP CONSTRAINT vn_screenshots_rid_fkey;
-ALTER TABLE votes DROP CONSTRAINT votes_uid_fkey;
-ALTER TABLE votes DROP CONSTRAINT votes_vid_fkey;
-ALTER TABLE wlists DROP CONSTRAINT wlists_uid_fkey;
-ALTER TABLE wlists DROP CONSTRAINT wlists_vid_fkey;
-
-ALTER TABLE changes ADD FOREIGN KEY (requester) REFERENCES users (id);
-ALTER TABLE changes ADD FOREIGN KEY (causedby) REFERENCES changes (id);
-ALTER TABLE producers_rev ADD FOREIGN KEY (id) REFERENCES changes (id);
-ALTER TABLE producers_rev ADD FOREIGN KEY (pid) REFERENCES producers (id);
-ALTER TABLE quotes ADD FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE releases_lang ADD FOREIGN KEY (rid) REFERENCES releases_rev (id);
-ALTER TABLE releases_media ADD FOREIGN KEY (rid) REFERENCES releases_rev (id);
-ALTER TABLE releases_platforms ADD FOREIGN KEY (rid) REFERENCES releases_rev (id);
-ALTER TABLE releases_producers ADD FOREIGN KEY (rid) REFERENCES releases_rev (id);
-ALTER TABLE releases_producers ADD FOREIGN KEY (pid) REFERENCES producers (id);
-ALTER TABLE releases_rev ADD FOREIGN KEY (id) REFERENCES changes (id);
-ALTER TABLE releases_rev ADD FOREIGN KEY (rid) REFERENCES releases (id);
-ALTER TABLE releases_vn ADD FOREIGN KEY (rid) REFERENCES releases_rev (id);
-ALTER TABLE releases_vn ADD FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE rlists ADD FOREIGN KEY (uid) REFERENCES users (id);
-ALTER TABLE rlists ADD FOREIGN KEY (rid) REFERENCES releases (id);
-ALTER TABLE tags ADD FOREIGN KEY (addedby) REFERENCES users (id);
-ALTER TABLE tags_aliases ADD FOREIGN KEY (tag) REFERENCES tags (id);
-ALTER TABLE tags_parents ADD FOREIGN KEY (tag) REFERENCES tags (id);
-ALTER TABLE tags_parents ADD FOREIGN KEY (parent) REFERENCES tags (id);
-ALTER TABLE tags_vn ADD FOREIGN KEY (tag) REFERENCES tags (id);
-ALTER TABLE tags_vn ADD FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE tags_vn ADD FOREIGN KEY (uid) REFERENCES users (id);
-ALTER TABLE threads_posts ADD FOREIGN KEY (tid) REFERENCES threads (id);
-ALTER TABLE threads_posts ADD FOREIGN KEY (uid) REFERENCES users (id);
-ALTER TABLE threads_boards ADD FOREIGN KEY (tid) REFERENCES threads (id);
-ALTER TABLE vn ADD FOREIGN KEY (rgraph) REFERENCES relgraph (id);
-ALTER TABLE vn_anime ADD FOREIGN KEY (aid) REFERENCES anime (id);
-ALTER TABLE vn_anime ADD FOREIGN KEY (vid) REFERENCES vn_rev (id);
-ALTER TABLE vn_relations ADD FOREIGN KEY (vid1) REFERENCES vn_rev (id);
-ALTER TABLE vn_relations ADD FOREIGN KEY (vid2) REFERENCES vn (id);
-ALTER TABLE vn_rev ADD FOREIGN KEY (id) REFERENCES changes (id);
-ALTER TABLE vn_rev ADD FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE vn_screenshots ADD FOREIGN KEY (vid) REFERENCES vn_rev (id);
-ALTER TABLE vn_screenshots ADD FOREIGN KEY (scr) REFERENCES screenshots (id);
-ALTER TABLE vn_screenshots ADD FOREIGN KEY (rid) REFERENCES releases (id);
-ALTER TABLE votes ADD FOREIGN KEY (uid) REFERENCES users (id);
-ALTER TABLE votes ADD FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE wlists ADD FOREIGN KEY (uid) REFERENCES users (id);
-ALTER TABLE wlists ADD FOREIGN KEY (vid) REFERENCES vn (id);
-
-
-
--- automatically insert rows into the anime table for unknown aids
--- when inserted into vn_anime
-CREATE OR REPLACE FUNCTION vn_anime_aid() RETURNS trigger AS $$
-BEGIN
- IF NOT EXISTS(SELECT 1 FROM anime WHERE id = NEW.aid) THEN
- INSERT INTO anime (id) VALUES (NEW.aid);
- END IF;
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
-CREATE TRIGGER vn_anime_aid BEFORE INSERT OR UPDATE ON vn_anime FOR EACH ROW EXECUTE PROCEDURE vn_anime_aid();
-
-
--- Send a notify whenever anime info should be fetched
-CREATE OR REPLACE FUNCTION anime_fetch_notify() RETURNS trigger AS $$
-BEGIN
- IF NEW.lastfetch IS NULL THEN
- NOTIFY anime;
- END IF;
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-
-CREATE TRIGGER anime_fetch_notify AFTER INSERT OR UPDATE ON anime FOR EACH ROW EXECUTE PROCEDURE anime_fetch_notify();
-
-
--- Send a notify when a new cover image is uploaded
-CREATE OR REPLACE FUNCTION vn_rev_image_notify() RETURNS trigger AS $$
-BEGIN
- IF NEW.image < 0 THEN
- NOTIFY coverimage;
- END IF;
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-
-CREATE TRIGGER vn_rev_image_notify AFTER INSERT OR UPDATE ON vn_rev FOR EACH ROW EXECUTE PROCEDURE vn_rev_image_notify();
-
-
--- Send a notify when a screenshot needs to be processed
-CREATE OR REPLACE FUNCTION screenshot_process_notify() RETURNS trigger AS $$
-BEGIN
- IF NEW.processed = FALSE THEN
- NOTIFY screenshot;
- END IF;
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-
-CREATE TRIGGER screenshot_process_notify AFTER INSERT OR UPDATE ON screenshots FOR EACH ROW EXECUTE PROCEDURE screenshot_process_notify();
-
-
--- Update vn.rgraph column and send notify when a relation graph needs to be regenerated
--- 1. NOTIFY is sent on an UPDATE or INSERT on vn with rgraph = NULL and with entries in vn_relations (deferred)
--- vn.rgraph is set to NULL when:
--- 2. UPDATE on vn where c_released or c_languages has changed (deferred, but doesn't have to be)
--- 3. New VN revision of which the title differs from previous revision (deferred)
--- 4. New VN revision with items in vn_relations that differ from previous revision (deferred)
-CREATE OR REPLACE FUNCTION vn_relgraph_notify() RETURNS trigger AS $$
-BEGIN
- -- 1.
- IF TG_TABLE_NAME = 'vn' THEN
- IF NEW.rgraph IS NULL AND EXISTS(SELECT 1 FROM vn_relations WHERE vid1 = NEW.latest) THEN
- NOTIFY relgraph;
- END IF;
- END IF;
- IF TG_TABLE_NAME = 'vn' AND TG_OP = 'UPDATE' THEN
- IF NEW.rgraph IS NOT NULL AND OLD.latest > 0 THEN
- -- 2.
- IF OLD.c_released <> NEW.c_released OR OLD.c_languages <> NEW.c_languages THEN
- UPDATE vn SET rgraph = NULL WHERE id = NEW.id;
- END IF;
- -- 3 & 4
- IF OLD.latest <> NEW.latest AND (
- EXISTS(SELECT 1 FROM vn_rev v1, vn_rev v2 WHERE v2.title <> v1.title AND v1.id = OLD.latest AND v2.id = NEW.latest)
- OR EXISTS(SELECT v1.vid2, v1.relation FROM vn_relations v1 WHERE v1.vid1 = OLD.latest EXCEPT SELECT v2.vid2, v2.relation FROM vn_relations v2 WHERE v2.vid1 = NEW.latest)
- OR EXISTS(SELECT v1.vid2, v1.relation FROM vn_relations v1 WHERE v1.vid1 = NEW.latest EXCEPT SELECT v2.vid2, v2.relation FROM vn_relations v2 WHERE v2.vid1 = OLD.latest)
- ) THEN
- UPDATE vn SET rgraph = NULL WHERE id = NEW.id;
- END IF;
- END IF;
- END IF;
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-
-CREATE CONSTRAINT TRIGGER vn_relgraph_notify AFTER INSERT OR UPDATE ON vn DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE PROCEDURE vn_relgraph_notify();
-
-
--- NOTIFY on insert into changes/posts/tags
-CREATE OR REPLACE FUNCTION insert_notify() 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;
- END IF;
- 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();
-
-
-
--- convert the old categories to the related tags for VNs that didn't have tags already
-INSERT INTO tags_vn (uid, spoiler, vid, vote, tag)
- SELECT 1, 0, v.id,
- CASE
- WHEN vc.cat IN('gaa', 'gab', 'pli', 'pbr', 'tfu', 'tpa', 'tpr', 'lea', 'lfa', 'lsp', 'hfa', 'hfe') THEN 2
- ELSE vc.lvl
- END,
- CASE
- WHEN vc.cat = 'gaa' THEN 43 -- NVL
- WHEN vc.cat = 'gab' THEN 32 -- ADV
- WHEN vc.cat = 'gac' THEN 31 -- Action game
- WHEN vc.cat = 'grp' THEN 35 -- RPG
- WHEN vc.cat = 'gst' THEN 33 -- Strategy game
- WHEN vc.cat = 'gsi' THEN 34 -- Simulation game
- WHEN vc.cat = 'pli' THEN 145 -- Linear plot
- WHEN vc.cat = 'pbr' THEN 606 -- Branching plot
- WHEN vc.cat = 'eac' THEN 12 -- Action
- WHEN vc.cat = 'eco' THEN 104 -- Comedy
- WHEN vc.cat = 'edr' THEN 147 -- Drama
- WHEN vc.cat = 'efa' THEN 2 -- Fantasy
- WHEN vc.cat = 'eho' THEN 7 -- Horror
- WHEN vc.cat = 'emy' THEN 19 -- Mystery
- WHEN vc.cat = 'ero' THEN 96 -- Romance
- WHEN vc.cat = 'esc' THEN 47 -- School life
- WHEN vc.cat = 'esf' THEN 105 -- Sci-Fi
- WHEN vc.cat = 'esj' THEN 97 -- Shoujo ai
- WHEN vc.cat = 'esn' THEN 98 -- Shounen ai
- WHEN vc.cat = 'tfu' THEN 140 -- Future
- WHEN vc.cat = 'tpa' THEN 141 -- Past
- WHEN vc.cat = 'tpr' THEN 143 -- Present
- WHEN vc.cat = 'lea' THEN 52 -- Earth
- WHEN vc.cat = 'lfa' THEN 259 -- Fantasy world
- WHEN vc.cat = 'lsp' THEN 53 -- Space
- WHEN vc.cat = 'hfa' THEN 133 -- Male protag
- WHEN vc.cat = 'hfe' THEN 134 -- Female protag
- WHEN vc.cat = 'saa' THEN 23 -- Sexual content
- WHEN vc.cat = 'sbe' THEN 183 -- Bestiality
- WHEN vc.cat = 'sin' THEN 86 -- Insect
- WHEN vc.cat = 'slo' THEN 156 -- Lolicon
- WHEN vc.cat = 'ssh' THEN 184 -- Shotacon
- WHEN vc.cat = 'sya' THEN 83 -- Yaoi
- WHEN vc.cat = 'syu' THEN 82 -- Yuri
- WHEN vc.cat = 'sra' THEN 84 -- Rape
- ELSE 11 -- the deleted 'Awesome' tag, this shouldn't happen
- END
- FROM vn v
- JOIN vn_categories vc ON vc.vid = v.latest
- WHERE NOT EXISTS(SELECT 1 FROM tags_vn tv WHERE tv.vid = v.id);
-DROP TABLE vn_categories;
-
diff --git a/util/updates/update_2.7.sql b/util/updates/update_2.7.sql
deleted file mode 100644
index e9061106..00000000
--- a/util/updates/update_2.7.sql
+++ /dev/null
@@ -1,108 +0,0 @@
-
-
--- add a flag to users whose votes we want to ignore
-ALTER TABLE users ADD COLUMN ign_votes boolean NOT NULL DEFAULT FALSE;
-
-CREATE OR REPLACE FUNCTION update_vnpopularity() RETURNS void AS $$
-BEGIN
- CREATE OR REPLACE TEMP VIEW tmp_pop1 (uid, vid, rank) AS
- SELECT v.uid, v.vid, sqrt(count(*))::real
- FROM votes v
--- JOIN users u ON u.id = v.uid AND NOT u.ign_votes -- slow
- JOIN votes v2 ON v.uid = v2.uid AND v2.vote < v.vote
- WHERE v.uid NOT IN(SELECT id FROM users WHERE ign_votes) -- faster
- GROUP BY v.vid, v.uid;
- CREATE OR REPLACE TEMP VIEW tmp_pop2 (vid, win) AS
- SELECT vid, sum(rank) FROM tmp_pop1 GROUP BY vid;
- UPDATE vn SET c_popularity = COALESCE((SELECT win/(SELECT MAX(win) FROM tmp_pop2) FROM tmp_pop2 WHERE vid = id), 0);
- RETURN;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
--- VN relations cleanup
-
-UPDATE vn_relations SET relation = relation + 50 WHERE relation IN(8, 9, 10);
-UPDATE vn_relations SET relation = relation - 1 WHERE relation > 3 AND relation < 50;
-UPDATE vn_relations SET relation = 7 WHERE relation = 60;
-DELETE FROM vn_relations WHERE relation > 50;
-
--- Be sure to execute the following query after restarting Multi, to regenerate the relation graphs:
--- UPDATE vn SET rgraph = NULL;
-
-
-
-
-
--- set freeware flag for all trials with internet download as medium
-
-CREATE FUNCTION tmp_edit_release(iid integer) RETURNS void AS $$
-DECLARE
- cid integer;
- oid integer;
-BEGIN
- SELECT INTO oid latest FROM releases WHERE id = iid;
- INSERT INTO changes (type, requester, ip, comments, rev)
- VALUES (1, 1, '0.0.0.0',
- E'Automated edit with the update to VNDB 2.7.\n\nThis release is a downloadable trial, freeware flag is assumed.',
- (SELECT rev+1 FROM changes WHERE id = oid))
- RETURNING id INTO cid;
- INSERT INTO releases_rev (id, rid, title, original, type, website, released, notes,
- minage, gtin, patch, catalog, resolution, voiced, freeware, doujin, ani_story, ani_ero)
- SELECT cid, rid, title, original, type, website, released, notes,
- minage, gtin, patch, catalog, resolution, voiced, true, doujin, ani_story, ani_ero
- FROM releases_rev WHERE id = oid;
- INSERT INTO releases_media (rid, medium, qty) SELECT cid, medium, qty FROM releases_media WHERE rid = oid;
- INSERT INTO releases_platforms (rid, platform) SELECT cid, platform FROM releases_platforms WHERE rid = oid;
- INSERT INTO releases_producers (rid, pid) SELECT cid, pid FROM releases_producers WHERE rid = oid;
- INSERT INTO releases_lang (rid, lang) SELECT cid, lang FROM releases_lang WHERE rid = oid;
- INSERT INTO releases_vn (rid, vid) SELECT cid, vid FROM releases_vn WHERE rid = oid;
- UPDATE releases SET latest = cid WHERE id = iid;
-END;
-$$ LANGUAGE plpgsql;
-
-SELECT tmp_edit_release(r.id)
- FROM releases r
- JOIN releases_rev rr ON rr.id = r.latest
- WHERE r.hidden = FALSE
- AND rr.type = 2
- AND NOT rr.freeware
- AND EXISTS(SELECT 1 FROM releases_media rm WHERE rm.medium = 'in ' AND rm.rid = rr.id)
- ORDER BY r.id;
-
-DROP FUNCTION tmp_edit_release(integer);
-
-
-
--- Really don't consider VNs with AVG(vote) < 0 on tag pages
-CREATE OR REPLACE FUNCTION tag_vn_calc() RETURNS void AS $$
-BEGIN
- -- all votes for all tags
- CREATE OR REPLACE TEMPORARY VIEW tags_vn_all AS
- SELECT * FROM tags_vn UNION SELECT * FROM tag_vn_childs();
- -- grouped by (tag, vid, uid), so only one user votes on one parent tag per VN entry
- CREATE OR REPLACE TEMPORARY VIEW tags_vn_grouped AS
- SELECT tag, vid, uid, MAX(vote)::real AS vote, COALESCE(AVG(spoiler), 0)::real AS spoiler
- FROM tags_vn_all GROUP BY tag, vid, uid;
- -- grouped by (tag, vid) and serialized into a table
- DROP INDEX IF EXISTS tags_vn_bayesian_tag;
- TRUNCATE tags_vn_bayesian;
- INSERT INTO tags_vn_bayesian
- SELECT tag, vid, COUNT(uid) AS users, AVG(vote)::real AS rating,
- (CASE WHEN AVG(spoiler) < 0.7 THEN 0 WHEN AVG(spoiler) > 1.3 THEN 2 ELSE 1 END)::smallint AS spoiler
- FROM tags_vn_grouped
- GROUP BY tag, vid
- HAVING AVG(vote) > 0;
- CREATE INDEX tags_vn_bayesian_tag ON tags_vn_bayesian (tag);
- -- now perform the bayesian ranking calculation
- UPDATE tags_vn_bayesian tvs SET rating =
- ((SELECT AVG(users)::real * AVG(rating)::real FROM tags_vn_bayesian WHERE tag = tvs.tag) + users*rating)
- / ((SELECT AVG(users)::real FROM tags_vn_bayesian WHERE tag = tvs.tag) + users)::real;
- -- and update the VN count in the tags table as well
- UPDATE tags SET c_vns = (SELECT COUNT(*) FROM tags_vn_bayesian WHERE tag = id);
- RETURN;
-END;
-$$ LANGUAGE plpgsql;
-SELECT tag_vn_calc();
-
diff --git a/util/updates/update_2.8.sql b/util/updates/update_2.8.sql
deleted file mode 100644
index d8373210..00000000
--- a/util/updates/update_2.8.sql
+++ /dev/null
@@ -1,213 +0,0 @@
-
--- !BEFORE! running this SQL file, make sure to kill Multi,
--- After running this SQL file, also make sure to do a:
--- $ rm -r static/rg/
--- And start multi again
-
--- VN Relation graphs are stored in the database as SVG - no cmaps and .png anymore
-UPDATE vn SET rgraph = NULL;
-ALTER TABLE vn DROP CONSTRAINT vn_rgraph_fkey;
-DROP TABLE relgraph;
-CREATE TABLE relgraphs (
- id SERIAL PRIMARY KEY,
- svg xml NOT NULL
-);
-ALTER TABLE vn ADD FOREIGN KEY (rgraph) REFERENCES relgraphs (id);
-
-
--- VN relations stored as enum
-CREATE TYPE vn_relation AS ENUM ('seq', 'preq', 'set', 'alt', 'char', 'side', 'par', 'ser', 'fan', 'orig');
-ALTER TABLE vn_relations ALTER COLUMN relation DROP DEFAULT;
-ALTER TABLE vn_relations ALTER COLUMN relation TYPE vn_relation USING
- CASE
- WHEN relation = 0 THEN 'seq'::vn_relation
- WHEN relation = 1 THEN 'preq'
- WHEN relation = 2 THEN 'set'
- WHEN relation = 3 THEN 'alt'
- WHEN relation = 4 THEN 'char'
- WHEN relation = 5 THEN 'side'
- WHEN relation = 6 THEN 'par'
- WHEN relation = 7 THEN 'ser'
- WHEN relation = 8 THEN 'fan'
- ELSE 'orig'
- END;
-
-
--- producer relations
-CREATE TYPE producer_relation AS ENUM ('old', 'new', 'sub', 'par', 'imp', 'ipa', 'spa', 'ori');
-CREATE TABLE producers_relations (
- pid1 integer NOT NULL REFERENCES producers_rev (id),
- pid2 integer NOT NULL REFERENCES producers (id),
- relation producer_relation NOT NULL,
- PRIMARY KEY(pid1, pid2)
-);
-ALTER TABLE producers ADD COLUMN rgraph integer REFERENCES relgraphs (id);
-
-CREATE OR REPLACE FUNCTION producer_relgraph_notify() RETURNS trigger AS $$
-BEGIN
- -- 1.
- IF TG_TABLE_NAME = 'producers' THEN
- IF NEW.rgraph IS NULL AND EXISTS(SELECT 1 FROM producers_relations WHERE pid1 = NEW.latest) THEN
- NOTIFY relgraph;
- END IF;
- END IF;
- IF TG_TABLE_NAME = 'producers' AND TG_OP = 'UPDATE' THEN
- IF NEW.rgraph IS NOT NULL AND OLD.latest > 0 THEN
- -- 3 & 4
- IF OLD.latest <> NEW.latest AND (
- EXISTS(SELECT 1 FROM producers_rev p1, producers_rev p2 WHERE (p2.name <> p1.name OR p2.type <> p1.type OR p2.lang <> p1.lang) AND p1.id = OLD.latest AND p2.id = NEW.latest)
- OR EXISTS(SELECT p1.pid2, p1.relation FROM producers_relations p1 WHERE p1.pid1 = OLD.latest EXCEPT SELECT p2.pid2, p2.relation FROM producers_relations p2 WHERE p2.pid1 = NEW.latest)
- OR EXISTS(SELECT p1.pid2, p1.relation FROM producers_relations p1 WHERE p1.pid1 = NEW.latest EXCEPT SELECT p2.pid2, p2.relation FROM producers_relations p2 WHERE p2.pid1 = OLD.latest)
- ) THEN
- UPDATE producers SET rgraph = NULL WHERE id = NEW.id;
- END IF;
- END IF;
- END IF;
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-CREATE CONSTRAINT TRIGGER vn_relgraph_notify AFTER INSERT OR UPDATE ON producers DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE PROCEDURE producer_relgraph_notify();
-
-
--- Anime types stored as enum
-CREATE TYPE anime_type AS ENUM ('tv', 'ova', 'mov', 'oth', 'web', 'spe', 'mv');
-ALTER TABLE anime ALTER COLUMN type TYPE anime_type USING
- CASE
- WHEN type = 0 THEN 'tv'::anime_type
- WHEN type = 1 THEN 'ova'
- WHEN type = 2 THEN 'mov'
- WHEN type = 3 THEN 'oth'
- WHEN type = 4 THEN 'web'
- WHEN type = 5 THEN 'spe'
- WHEN type = 6 THEN 'mv'
- ELSE NULL
- END;
-
-
--- Release media stored as enum
-CREATE TYPE medium AS ENUM ('cd', 'dvd', 'gdr', 'blr', 'flp', 'mrt', 'mem', 'umd', 'nod', 'in', 'otc');
-ALTER TABLE releases_media ALTER COLUMN medium DROP DEFAULT;
-ALTER TABLE releases_media ALTER COLUMN medium TYPE medium USING TRIM(both ' ' from medium)::medium;
-
-
--- Differentiate between publishers and developers
-ALTER TABLE releases_producers ADD COLUMN developer boolean NOT NULL DEFAULT FALSE;
-ALTER TABLE releases_producers ADD COLUMN publisher boolean NOT NULL DEFAULT TRUE;
-ALTER TABLE releases_producers ADD CHECK(developer OR publisher);
-
-
--- Keep track of last read post for PMs
-ALTER TABLE threads_boards ADD COLUMN lastread smallint;
-
-
--- changes.type stored as enum
-CREATE TYPE dbentry_type AS ENUM ('v', 'r', 'p');
-ALTER TABLE changes ALTER COLUMN type DROP DEFAULT;
-ALTER TABLE changes ALTER COLUMN type TYPE dbentry_type USING
- CASE
- WHEN type = 0 THEN 'v'::dbentry_type
- WHEN type = 1 THEN 'r'
- WHEN type = 2 THEN 'p'
- ELSE NULL -- not allowed to happen, otherwise FIX YOUR DATABASE!
- END;
-
-
--- releases_rev.type stored as enum
-CREATE TYPE release_type AS ENUM ('complete', 'partial', 'trial');
-ALTER TABLE releases_rev ALTER COLUMN type DROP DEFAULT;
-ALTER TABLE releases_rev ALTER COLUMN type TYPE release_type USING
- CASE
- WHEN type = 0 THEN 'complete'::release_type
- WHEN type = 1 THEN 'partial'
- WHEN type = 2 THEN 'trial'
- ELSE NULL
- END;
-ALTER TABLE releases_rev ALTER COLUMN type SET DEFAULT 'complete';
-
-CREATE OR REPLACE FUNCTION update_vncache(id integer) RETURNS void AS $$
-DECLARE
- w text := '';
-BEGIN
- IF id > 0 THEN
- w := ' WHERE id = '||id;
- END IF;
- EXECUTE 'UPDATE vn SET
- c_released = COALESCE((SELECT
- MIN(rr1.released)
- FROM releases_rev rr1
- JOIN releases r1 ON rr1.id = r1.latest
- JOIN releases_vn rv1 ON rr1.id = rv1.rid
- WHERE rv1.vid = vn.id
- AND rr1.type <> ''trial''
- AND r1.hidden = FALSE
- AND rr1.released <> 0
- GROUP BY rv1.vid
- ), 0),
- c_languages = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT rl2.lang
- FROM releases_rev rr2
- JOIN releases_lang rl2 ON rl2.rid = rr2.id
- JOIN releases r2 ON rr2.id = r2.latest
- JOIN releases_vn rv2 ON rr2.id = rv2.rid
- WHERE rv2.vid = vn.id
- AND rr2.type <> ''trial''
- AND rr2.released <= TO_CHAR(''today''::timestamp, ''YYYYMMDD'')::integer
- AND r2.hidden = FALSE
- GROUP BY rl2.lang
- ORDER BY rl2.lang
- ), ''/''), ''''),
- c_platforms = COALESCE(ARRAY_TO_STRING(ARRAY(
- SELECT rp3.platform
- FROM releases_platforms rp3
- JOIN releases_rev rr3 ON rp3.rid = rr3.id
- JOIN releases r3 ON rp3.rid = r3.latest
- JOIN releases_vn rv3 ON rp3.rid = rv3.rid
- WHERE rv3.vid = vn.id
- AND rr3.type <> ''trial''
- AND rr3.released <= TO_CHAR(''today''::timestamp, ''YYYYMMDD'')::integer
- AND r3.hidden = FALSE
- GROUP BY rp3.platform
- ORDER BY rp3.platform
- ), ''/''), '''')
- '||w;
-END;
-$$ LANGUAGE plpgsql;
-
-
-
--- fix calculation of the tags_vn_bayesian.spoiler column
-
-CREATE OR REPLACE FUNCTION tag_vn_calc() RETURNS void AS $$
-BEGIN
- -- all votes for all tags
- CREATE OR REPLACE TEMPORARY VIEW tags_vn_all AS
- SELECT * FROM tags_vn UNION SELECT * FROM tag_vn_childs();
- -- grouped by (tag, vid, uid), so only one user votes on one parent tag per VN entry
- CREATE OR REPLACE TEMPORARY VIEW tags_vn_grouped AS
- SELECT tag, vid, uid, MAX(vote)::real AS vote, AVG(spoiler)::real AS spoiler
- FROM tags_vn_all GROUP BY tag, vid, uid;
- -- grouped by (tag, vid) and serialized into a table
- DROP INDEX IF EXISTS tags_vn_bayesian_tag;
- TRUNCATE tags_vn_bayesian;
- INSERT INTO tags_vn_bayesian
- SELECT tag, vid, COUNT(uid) AS users, AVG(vote)::real AS rating,
- (CASE WHEN AVG(spoiler) < 0.7 THEN 0 WHEN AVG(spoiler) > 1.3 THEN 2 ELSE 1 END)::smallint AS spoiler
- FROM tags_vn_grouped
- GROUP BY tag, vid
- HAVING AVG(vote) > 0;
- CREATE INDEX tags_vn_bayesian_tag ON tags_vn_bayesian (tag);
- -- now perform the bayesian ranking calculation
- UPDATE tags_vn_bayesian tvs SET rating =
- ((SELECT AVG(users)::real * AVG(rating)::real FROM tags_vn_bayesian WHERE tag = tvs.tag) + users*rating)
- / ((SELECT AVG(users)::real FROM tags_vn_bayesian WHERE tag = tvs.tag) + users)::real;
- -- and update the VN count in the tags table as well
- UPDATE tags SET c_vns = (SELECT COUNT(*) FROM tags_vn_bayesian WHERE tag = id);
- RETURN;
-END;
-$$ LANGUAGE plpgsql;
-SELECT tag_vn_calc();
-
-
--- remove update_rev()
-DROP FUNCTION update_rev(text, text);
-
diff --git a/util/updates/update_2.9.sql b/util/updates/update_2.9.sql
deleted file mode 100644
index 3b3e147d..00000000
--- a/util/updates/update_2.9.sql
+++ /dev/null
@@ -1,76 +0,0 @@
-
--- another fix in the calculation of the tags_vn_bayesian.spoiler column
-
-CREATE OR REPLACE FUNCTION tag_vn_calc() RETURNS void AS $$
-BEGIN
- -- all votes for all tags
- CREATE OR REPLACE TEMPORARY VIEW tags_vn_all AS
- SELECT * FROM tags_vn UNION SELECT * FROM tag_vn_childs();
- -- grouped by (tag, vid, uid), so only one user votes on one parent tag per VN entry
- CREATE OR REPLACE TEMPORARY VIEW tags_vn_grouped AS
- SELECT tag, vid, uid, MAX(vote)::real AS vote, COALESCE(AVG(spoiler), 0)::real AS spoiler
- FROM tags_vn_all GROUP BY tag, vid, uid;
- -- grouped by (tag, vid) and serialized into a table
- DROP INDEX IF EXISTS tags_vn_bayesian_tag;
- TRUNCATE tags_vn_bayesian;
- INSERT INTO tags_vn_bayesian
- SELECT tag, vid, COUNT(uid) AS users, AVG(vote)::real AS rating,
- (CASE WHEN AVG(spoiler) < 0.7 THEN 0 WHEN AVG(spoiler) > 1.3 THEN 2 ELSE 1 END)::smallint AS spoiler
- FROM tags_vn_grouped
- GROUP BY tag, vid
- HAVING AVG(vote) > 0;
- CREATE INDEX tags_vn_bayesian_tag ON tags_vn_bayesian (tag);
- -- now perform the bayesian ranking calculation
- UPDATE tags_vn_bayesian tvs SET rating =
- ((SELECT AVG(users)::real * AVG(rating)::real FROM tags_vn_bayesian WHERE tag = tvs.tag) + users*rating)
- / ((SELECT AVG(users)::real FROM tags_vn_bayesian WHERE tag = tvs.tag) + users)::real;
- -- and update the VN count in the tags table as well
- UPDATE tags SET c_vns = (SELECT COUNT(*) FROM tags_vn_bayesian WHERE tag = id);
- RETURN;
-END;
-$$ LANGUAGE plpgsql;
-SELECT tag_vn_calc();
-
-
-
--- releases_rev.minage should accept NULL
-ALTER TABLE releases_rev ALTER COLUMN minage DROP NOT NULL;
-ALTER TABLE releases_rev ALTER COLUMN minage DROP DEFAULT;
-UPDATE releases_rev SET minage = NULL WHERE minage < 0;
-
-
--- wikipedia link for producers
-ALTER TABLE producers_rev ADD COLUMN l_wp varchar(150);
-
-
--- bayesian rating
-ALTER TABLE vn ADD COLUMN c_rating real;
-ALTER TABLE vn ADD COLUMN c_votecount integer NOT NULL DEFAULT 0;
-UPDATE vn SET
- c_rating = (SELECT (
- ((SELECT COUNT(vote)::real/COUNT(DISTINCT vid)::real FROM votes)*(SELECT AVG(a)::real FROM (SELECT AVG(vote) FROM votes GROUP BY vid) AS v(a)) + SUM(vote)::real) /
- ((SELECT COUNT(vote)::real/COUNT(DISTINCT vid)::real FROM votes) + COUNT(uid)::real)
- ) FROM votes WHERE vid = id AND uid NOT IN(SELECT id FROM users WHERE ign_votes)
- ),
- c_votecount = COALESCE((SELECT count(*) FROM votes WHERE vid = id AND uid NOT IN(SELECT id FROM users WHERE ign_votes)), 0);
-
-
--- vn.c_popularity can be NULL
-ALTER TABLE vn ALTER COLUMN c_popularity DROP NOT NULL;
-ALTER TABLE vn ALTER COLUMN c_popularity DROP DEFAULT;
-CREATE OR REPLACE FUNCTION update_vnpopularity() RETURNS void AS $$
-BEGIN
- CREATE OR REPLACE TEMP VIEW tmp_pop1 (uid, vid, rank) AS
- SELECT v.uid, v.vid, sqrt(count(*))::real
- FROM votes v
- JOIN votes v2 ON v.uid = v2.uid AND v2.vote < v.vote
- JOIN users u ON u.id = v.uid AND NOT ign_votes
- GROUP BY v.vid, v.uid;
- CREATE OR REPLACE TEMP VIEW tmp_pop2 (vid, win) AS
- SELECT vid, sum(rank) FROM tmp_pop1 GROUP BY vid;
- UPDATE vn SET c_popularity = (SELECT win/(SELECT MAX(win) FROM tmp_pop2) FROM tmp_pop2 WHERE vid = id);
- RETURN;
-END;
-$$ LANGUAGE plpgsql;
-SELECT update_vnpopularity();
-
diff --git a/util/updates/update_20180207.sql b/util/updates/update_20180207.sql
deleted file mode 100644
index 99a8be59..00000000
--- a/util/updates/update_20180207.sql
+++ /dev/null
@@ -1,3 +0,0 @@
--- Producer aliases are now separated by newline
-UPDATE producers SET alias = regexp_replace(alias, '\s*,\s*', E'\n', 'g');
-UPDATE producers_hist SET alias = regexp_replace(alias, '\s*,\s*', E'\n', 'g');
diff --git a/util/updates/update_20180208.sql b/util/updates/update_20180208.sql
deleted file mode 100644
index d84ac0e1..00000000
--- a/util/updates/update_20180208.sql
+++ /dev/null
@@ -1,57 +0,0 @@
-CREATE TABLE docs (
- id SERIAL PRIMARY KEY,
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT FALSE,
- title varchar(200) NOT NULL DEFAULT '',
- content text NOT NULL DEFAULT ''
-);
-CREATE TABLE docs_hist (
- chid integer NOT NULL PRIMARY KEY,
- title varchar(200) NOT NULL DEFAULT '',
- content text NOT NULL DEFAULT ''
-);
-ALTER TYPE dbentry_type ADD VALUE 'd';
-ALTER TYPE notification_ltype ADD VALUE 'd';
-
-\i util/sql/func.sql
-\i util/sql/editfunc.sql
-\i util/sql/perms.sql
-
-
--- Insert empty pages
-CREATE OR REPLACE FUNCTION insert_doc(integer, text) RETURNS void AS $$
-BEGIN
- PERFORM setval('docs_id_seq', $1-1);
- PERFORM edit_d_init(NULL, NULL);
- UPDATE edit_revision SET requester = 1, comments = 'Empty page', ip = '0.0.0.0';
- UPDATE edit_docs SET title = $2;
- PERFORM edit_d_commit();
-END
-$$ LANGUAGE plpgsql;
-
-SELECT insert_doc( 2, 'Adding/Editing a Visual Novel');
-SELECT insert_doc( 3, 'Adding/Editing a Release');
-SELECT insert_doc( 4, 'Adding/Editing a Producer');
-SELECT insert_doc( 5, 'Editing guidelines');
-SELECT insert_doc( 6, 'Frequently Asked Questions');
-SELECT insert_doc( 7, 'About us');
-SELECT insert_doc( 9, 'Discussion board');
-SELECT insert_doc(10, 'Tags & traits');
-SELECT insert_doc(11, 'Public Database API');
-SELECT insert_doc(12, 'Adding/Editing Characters');
-SELECT insert_doc(13, 'How to Capture Screenshots');
-SELECT insert_doc(14, 'Database Dumps');
-SELECT insert_doc(15, 'Special Games');
-SELECT insert_doc(16, 'Adding/Editing Staff Members');
-
-DROP FUNCTION insert_doc(integer, text);
-
-
-
--- Update doc references
-CREATE OR REPLACE FUNCTION safedocreplace(text) RETURNS text AS $$
- SELECT regexp_replace($1, 'd(2|3|4|5|6|7|9|10|11|12|13|14|15|16)\.([1-8](?:\.[1-8])?)', 'd\1#\2', 'g')
-$$ LANGUAGE sql;
-UPDATE threads_posts SET msg = safedocreplace(msg) WHERE msg ~ 'd[1-9]';
-UPDATE changes SET comments = safedocreplace(comments) WHERE comments ~ 'd[1-9]';
-DROP FUNCTION safedocreplace(text);
diff --git a/util/updates/update_20180525.sql b/util/updates/update_20180525.sql
deleted file mode 100644
index f71b0d52..00000000
--- a/util/updates/update_20180525.sql
+++ /dev/null
@@ -1,9 +0,0 @@
-SELECT edit_d_init(NULL, NULL);
-UPDATE edit_revision SET requester = 1, comments = 'Empty page', ip = '0.0.0.0';
-UPDATE edit_docs SET title = 'Privacy Policy';
-SELECT edit_d_commit();
-
-
-ALTER TABLE releases ADD COLUMN uncensored boolean NOT NULL DEFAULT FALSE;
-ALTER TABLE releases_hist ADD COLUMN uncensored boolean NOT NULL DEFAULT FALSE;
-\i util/sql/editfunc.sql
diff --git a/util/updates/update_20180812.sql b/util/updates/update_20180812.sql
deleted file mode 100644
index 2e5bbe2c..00000000
--- a/util/updates/update_20180812.sql
+++ /dev/null
@@ -1,3 +0,0 @@
--- New resolution before 1920x1080
-UPDATE releases SET resolution = 16 WHERE resolution = 15;
-UPDATE releases_hist SET resolution = 16 WHERE resolution = 15;
diff --git a/util/updates/update_20180929.sql b/util/updates/update_20180929.sql
deleted file mode 100644
index bd854f59..00000000
--- a/util/updates/update_20180929.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-ALTER TABLE traits ADD COLUMN defaultspoil smallint NOT NULL DEFAULT 0;
-ALTER TABLE tags ADD COLUMN defaultspoil smallint NOT NULL DEFAULT 0;
diff --git a/util/updates/update_20181002.sql b/util/updates/update_20181002.sql
deleted file mode 100644
index d98ed764..00000000
--- a/util/updates/update_20181002.sql
+++ /dev/null
@@ -1,32 +0,0 @@
-CREATE TYPE resolution AS ENUM ('unknown', 'nonstandard', '640x480', '800x600', '1024x768', '1280x960', '1600x1200', '640x400', '960x600', '1024x576', '1024x600', '1024x640', '1280x720', '1280x800', '1366x768', '1600x900', '1920x1080');
-
-CREATE OR REPLACE FUNCTION conv_resolution(integer) RETURNS resolution AS $$
-SELECT CASE
- WHEN $1 = 0 THEN 'unknown'::resolution
- WHEN $1 = 1 THEN 'nonstandard'
- WHEN $1 = 2 THEN '640x480'
- WHEN $1 = 3 THEN '800x600'
- WHEN $1 = 4 THEN '1024x768'
- WHEN $1 = 5 THEN '1280x960'
- WHEN $1 = 6 THEN '1600x1200'
- WHEN $1 = 7 THEN '640x400'
- WHEN $1 = 8 THEN '960x600'
- WHEN $1 = 9 THEN '1024x576'
- WHEN $1 = 10 THEN '1024x600'
- WHEN $1 = 11 THEN '1024x640'
- WHEN $1 = 12 THEN '1280x720'
- WHEN $1 = 13 THEN '1280x800'
- WHEN $1 = 14 THEN '1366x768'
- WHEN $1 = 15 THEN '1600x900'
- WHEN $1 = 16 THEN '1920x1080'
-END $$ LANGUAGE SQL;
-
-ALTER TABLE releases ALTER COLUMN resolution DROP DEFAULT;
-ALTER TABLE releases ALTER COLUMN resolution TYPE resolution USING conv_resolution(resolution);
-ALTER TABLE releases ALTER COLUMN resolution SET DEFAULT 'unknown';
-
-ALTER TABLE releases_hist ALTER COLUMN resolution DROP DEFAULT;
-ALTER TABLE releases_hist ALTER COLUMN resolution TYPE resolution USING conv_resolution(resolution);
-ALTER TABLE releases_hist ALTER COLUMN resolution SET DEFAULT 'unknown';
-
-DROP FUNCTION conv_resolution(int);
diff --git a/util/updates/update_20181006.sql b/util/updates/update_20181006.sql
deleted file mode 100644
index 1aa3b756..00000000
--- a/util/updates/update_20181006.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TYPE resolution ADD VALUE '960x640' AFTER '960x600';
diff --git a/util/updates/update_20190809.sql b/util/updates/update_20190809.sql
new file mode 100644
index 00000000..cf5baefd
--- /dev/null
+++ b/util/updates/update_20190809.sql
@@ -0,0 +1,60 @@
+-- Update instructions:
+--
+-- make
+-- psql -U vndb -f util/updates/update_20190809.sql
+-- psql -U postgres -f util/sql/perms.sql
+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
+);
+
+ALTER TABLE producers ADD COLUMN l_wikidata integer;
+ALTER TABLE producers_hist ADD COLUMN l_wikidata integer;
+ALTER TABLE staff ADD COLUMN l_wikidata integer;
+ALTER TABLE staff_hist ADD COLUMN l_wikidata integer;
+ALTER TABLE vn ADD COLUMN l_wikidata integer;
+ALTER TABLE vn_hist ADD COLUMN l_wikidata integer;
+
+ALTER TABLE producers ADD CONSTRAINT producers_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
+ALTER TABLE producers_hist ADD CONSTRAINT producers_hist_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
+ALTER TABLE staff ADD CONSTRAINT staff_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
+ALTER TABLE staff_hist ADD CONSTRAINT staff_hist_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
+ALTER TABLE vn ADD CONSTRAINT vn_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
+ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
+
+\i util/sql/func.sql
+\i util/sql/editfunc.sql
+
+CREATE TRIGGER producers_wikidata_new BEFORE INSERT ON producers FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER producers_wikidata_edit BEFORE UPDATE ON producers FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER producers_hist_wikidata_new BEFORE INSERT ON producers_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER producers_hist_wikidata_edit BEFORE UPDATE ON producers_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER staff_wikidata_new BEFORE INSERT ON staff FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER staff_wikidata_edit BEFORE UPDATE ON staff FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER staff_hist_wikidata_new BEFORE INSERT ON staff_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER staff_hist_wikidata_edit BEFORE UPDATE ON staff_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER vn_wikidata_new BEFORE INSERT ON vn FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER vn_wikidata_edit BEFORE UPDATE ON vn FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER vn_hist_wikidata_new BEFORE INSERT ON vn_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL) EXECUTE PROCEDURE wikidata_insert();
+CREATE TRIGGER vn_hist_wikidata_edit BEFORE UPDATE ON vn_hist FOR EACH ROW WHEN (NEW.l_wikidata IS NOT NULL AND OLD.l_wikidata IS DISTINCT FROM NEW.l_wikidata) EXECUTE PROCEDURE wikidata_insert();
diff --git a/util/updates/update_20190814.sql b/util/updates/update_20190814.sql
new file mode 100644
index 00000000..ddb04563
--- /dev/null
+++ b/util/updates/update_20190814.sql
@@ -0,0 +1,19 @@
+ALTER TABLE wikidata ALTER COLUMN website TYPE text[] USING CASE WHEN website IS NULL THEN NULL ELSE ARRAY[website ] END;
+ALTER TABLE wikidata ALTER COLUMN vndb TYPE text[] USING CASE WHEN vndb IS NULL THEN NULL ELSE ARRAY[vndb ] END;
+ALTER TABLE wikidata ALTER COLUMN mobygames TYPE text[] USING CASE WHEN mobygames IS NULL THEN NULL ELSE ARRAY[mobygames ] END;
+ALTER TABLE wikidata ALTER COLUMN mobygames_company TYPE text[] USING CASE WHEN mobygames_company IS NULL THEN NULL ELSE ARRAY[mobygames_company ] END;
+ALTER TABLE wikidata ALTER COLUMN gamefaqs_game TYPE integer[] USING CASE WHEN gamefaqs_game IS NULL THEN NULL ELSE ARRAY[gamefaqs_game ] END;
+ALTER TABLE wikidata ALTER COLUMN gamefaqs_company TYPE integer[] USING CASE WHEN gamefaqs_company IS NULL THEN NULL ELSE ARRAY[gamefaqs_company ] END;
+ALTER TABLE wikidata ALTER COLUMN anidb_anime TYPE integer[] USING CASE WHEN anidb_anime IS NULL THEN NULL ELSE ARRAY[anidb_anime ] END;
+ALTER TABLE wikidata ALTER COLUMN anidb_person TYPE integer[] USING CASE WHEN anidb_person IS NULL THEN NULL ELSE ARRAY[anidb_person ] END;
+ALTER TABLE wikidata ALTER COLUMN ann_anime TYPE integer[] USING CASE WHEN ann_anime IS NULL THEN NULL ELSE ARRAY[ann_anime ] END;
+ALTER TABLE wikidata ALTER COLUMN ann_manga TYPE integer[] USING CASE WHEN ann_manga IS NULL THEN NULL ELSE ARRAY[ann_manga ] END;
+ALTER TABLE wikidata ALTER COLUMN musicbrainz_artist TYPE uuid[] USING CASE WHEN musicbrainz_artist IS NULL THEN NULL ELSE ARRAY[musicbrainz_artist] END;
+ALTER TABLE wikidata ALTER COLUMN twitter TYPE text[] USING CASE WHEN twitter IS NULL THEN NULL ELSE ARRAY[twitter ] END;
+ALTER TABLE wikidata ALTER COLUMN vgmdb_product TYPE integer[] USING CASE WHEN vgmdb_product IS NULL THEN NULL ELSE ARRAY[vgmdb_product ] END;
+ALTER TABLE wikidata ALTER COLUMN vgmdb_artist TYPE integer[] USING CASE WHEN vgmdb_artist IS NULL THEN NULL ELSE ARRAY[vgmdb_artist ] END;
+ALTER TABLE wikidata ALTER COLUMN discogs_artist TYPE integer[] USING CASE WHEN discogs_artist IS NULL THEN NULL ELSE ARRAY[discogs_artist ] END;
+ALTER TABLE wikidata ALTER COLUMN acdb_char TYPE integer[] USING CASE WHEN acdb_char IS NULL THEN NULL ELSE ARRAY[acdb_char ] END;
+ALTER TABLE wikidata ALTER COLUMN acdb_source TYPE integer[] USING CASE WHEN acdb_source IS NULL THEN NULL ELSE ARRAY[acdb_source ] END;
+ALTER TABLE wikidata ALTER COLUMN indiedb_game TYPE text[] USING CASE WHEN indiedb_game IS NULL THEN NULL ELSE ARRAY[indiedb_game ] END;
+ALTER TABLE wikidata ALTER COLUMN howlongtobeat TYPE integer[] USING CASE WHEN howlongtobeat IS NULL THEN NULL ELSE ARRAY[howlongtobeat ] END;
diff --git a/util/updates/update_20190816.sql b/util/updates/update_20190816.sql
new file mode 100644
index 00000000..76c2da1b
--- /dev/null
+++ b/util/updates/update_20190816.sql
@@ -0,0 +1,142 @@
+-- Run 'make' before importing this script
+
+ALTER TABLE releases ADD COLUMN l_steam integer NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_dlsite text NOT NULL DEFAULT '';
+ALTER TABLE releases ADD COLUMN l_dlsiteen text NOT NULL DEFAULT '';
+ALTER TABLE releases ADD COLUMN l_gog text NOT NULL DEFAULT '';
+ALTER TABLE releases ADD COLUMN l_denpa text NOT NULL DEFAULT '';
+ALTER TABLE releases ADD COLUMN l_jlist text NOT NULL DEFAULT '';
+ALTER TABLE releases ADD COLUMN l_gyutto integer NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_digiket integer NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_melon integer NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_mg integer NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_getchu integer NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_getchudl integer NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_dmm text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_steam integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_dlsite text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_dlsiteen text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_gog text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_denpa text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_jlist text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_gyutto integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_digiket integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_melon integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_mg integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_getchu integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_getchudl integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_dmm text NOT NULL DEFAULT '';
+
+\i util/sql/editfunc.sql
+
+
+-- Steam URL formats:
+-- https?://store.steampowered.com/app/729330/
+-- These two often don't link to the game directly, but rather info about community patches.
+-- Using these in the conversion will cause too many incorrect links.
+-- https?://steamcommunity.com/app/755970/
+-- https?://steamcommunity.com/games/323490/
+
+CREATE OR REPLACE FUNCTION migrate_website_to_steam(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_steam = regexp_replace(website, 'https?://store\.steampowered\.com/app/([0-9]+)(?:/.*)?', '\1')::integer, website = '';
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic conversion of website to Steam AppID.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_website_to_steam(id) FROM releases WHERE NOT hidden AND website ~ 'https?://store\.steampowered\.com/app/([0-9]+)';
+DROP FUNCTION migrate_website_to_steam(integer);
+
+
+CREATE OR REPLACE FUNCTION migrate_notes_to_steam(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_steam = regexp_replace(notes, '^.*(?:Also available|Available) on \[url=https?://store\.steampowered\.com/app/([0-9]+)[^\]]*\]\s*Steam\s*\.?\[/url\].*$', '\1')::integer,
+ notes = regexp_replace(notes, '\s*(?:Also available|Available) on \[url=https?://store\.steampowered\.com/app/([0-9]+)[^\]]*\]\s*Steam\s*\.?\[/url\](?:\,?$|\.\s*)', '');
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic extraction of Steam AppID from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_notes_to_steam(id) FROM releases WHERE NOT hidden AND l_steam = 0
+ AND notes ~ '\s*(?:Also available|Available) on \[url=https?://store\.steampowered\.com/app/([0-9]+)[^\]]*\]\s*Steam\s*\.?\[/url\](?:\,?$|\.\s*)';
+DROP FUNCTION migrate_notes_to_steam(integer);
+
+
+-- DLsite URL formats:
+-- https://www.dlsite.com/pro/work/=/product_id/VJ003580.html
+-- * The '/pro/' is the store section, can also be 'home', 'eng', 'echi-eng', 'girls', 'maniax', etc (sections are listed on dlsite.com).
+-- Will automatically redirect to the right URL when the section is wrong, but it can't be empty or bogus.
+-- https://pro.dlsite.com/work/=/product_id/VJ003580.html
+-- * Same as above, but redirects to /pro/work/..
+-- https://www.dlsite.com/pro/work/=/product_id/VJ003580
+-- * The .html is optional
+--
+-- VJ003580 is the actual ID
+-- * B = Books, V = Doujin?, R = Professional? -> Product type/format?
+-- * J = Japanese, E = English, T = Taiwan
+-- -> Not a property of the actual product?
+-- Changing between J/E often (but not always!) redirects to same product on different store page.
+-- T is only available on getchu.com.tw, no automatic redirect if you get it wrong.
+
+CREATE OR REPLACE FUNCTION migrate_notes_to_dlsite(rid integer, rnotes text) RETURNS void AS $$
+DECLARE
+ l text;
+BEGIN
+ l := regexp_replace(rnotes, '^.*(?:Also available|Available) (?:on|at|from) \[url=https?://[^\]]+/work/=/product_id/([RV][EJ][0-9]+)[^\]]*\]\s*DLsite\s*(?:english\s*)?\.?\[/url\].*$', '\1', 'i');
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid AND type = 'r'));
+ UPDATE edit_releases SET
+ l_dlsite = CASE WHEN l ~ 'J' THEN l ELSE l_dlsite END,
+ l_dlsiteen = CASE WHEN l ~ 'E' THEN l ELSE l_dlsiteen END,
+ notes = regexp_replace(notes, '\s*(?:Also available|Available) (?:on|at|from) \[url=https?://[^\]]+/work/=/product_id/([RV][EJ][0-9]+)[^\]]*\]\s*DLsite\s*(?:english\s*)?\.?\[/url\](?:\,?$|\.\s*)', '', 'i');
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic extraction of DLsite link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_notes_to_dlsite(id, notes) FROM releases WHERE NOT hidden
+ AND id <> 20242 -- odd special case
+ AND notes ~* '\s*(?:Also available|Available) (?:on|at|from) \[url=https?://[^\]]+/work/=/product_id/([RV][EJ][0-9]+)[^\]]*\]\s*DLsite\s*(?:english\s*)?\.?\[/url\](?:\,?$|\.\s*)';
+DROP FUNCTION migrate_notes_to_dlsite(integer, text);
+
+
+
+CREATE OR REPLACE FUNCTION migrate_affiliates_to_denpa(rid integer, url text) 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_denpa = regexp_replace(url, '^.+/([^\/]+)/?$', '\1');
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic conversion of affiliate link to Denpasoft link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_affiliates_to_denpa(rid, url) FROM affiliate_links a WHERE affiliate = 6 AND NOT hidden
+ AND NOT EXISTS(SELECT 1 FROM affiliate_links b WHERE b.id <> a.id AND a.rid = b.rid);
+DROP FUNCTION migrate_affiliates_to_denpa(integer, text);
+
+
+
+CREATE OR REPLACE FUNCTION migrate_affiliates_to_mg(rid integer, url text) 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_mg = regexp_replace(url, '^.+product_code=([0-9]+).*$', '\1')::integer;
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic conversion of affiliate link to MangaGamer link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_affiliates_to_mg(rid, url) FROM affiliate_links a WHERE affiliate = 5 AND NOT hidden
+ AND NOT EXISTS(SELECT 1 FROM affiliate_links b WHERE b.id <> a.id AND a.rid = b.rid);
+DROP FUNCTION migrate_affiliates_to_mg(integer, text);
+
+
+
+CREATE OR REPLACE FUNCTION migrate_affiliates_to_jlist(rid integer, url text) 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_jlist = regexp_replace(url, '^.+/([^\/]+)/?$', '\1');
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic conversion of affiliate link to J-List link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_affiliates_to_jlist(rid, url) FROM affiliate_links a WHERE affiliate = 2 AND NOT hidden
+ AND NOT EXISTS(SELECT 1 FROM affiliate_links b WHERE b.id <> a.id AND a.rid = b.rid);
+DROP FUNCTION migrate_affiliates_to_jlist(integer, text);
diff --git a/util/updates/update_20190821.sql b/util/updates/update_20190821.sql
new file mode 100644
index 00000000..0635bdb3
--- /dev/null
+++ b/util/updates/update_20190821.sql
@@ -0,0 +1,35 @@
+ALTER TABLE releases ADD COLUMN l_itch text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_itch text NOT NULL DEFAULT '';
+
+\i util/sql/editfunc.sql
+
+CREATE OR REPLACE FUNCTION migrate_website_to_itch(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_itch = regexp_replace(website, '^https?://([a-z0-9_-]+)\.itch\.io/([a-z0-9_-]+)(?:\?.+)?$', '\1.itch.io/\2'), website = '';
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic conversion of website to Itch.io.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_website_to_itch(id) FROM releases WHERE NOT hidden AND website ~ '^https?://([a-z0-9_-]+)\.itch\.io/([a-z0-9_-]+)(?:\?.+)?$';
+DROP FUNCTION migrate_website_to_itch(integer);
+
+
+CREATE OR REPLACE FUNCTION migrate_notes_to_itch(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_itch = regexp_replace(notes, '^.*\s*(?:Also available|Available) (?:on|at|from) \[url=https?://([a-z0-9_-]+)\.itch\.io/([a-z0-9_-]+)\]\s*Itch(?:\.io)?\s*\.?\[/url\].*$', '\1.itch.io/\2', 'i'),
+ notes = regexp_replace(notes, '\s*(?:Also available|Available) (?:on|at|from) \[url=https?://([a-z0-9_-]+)\.itch\.io/([a-z0-9_-]+)\]\s*Itch(?:\.io)?\s*\.?\[/url\](?:\,?$|\.\s*)', '', 'i');
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic extraction of Itch.io link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_notes_to_itch(id) FROM releases WHERE NOT hidden AND l_itch = ''
+ AND notes ~* '\s*(?:Also available|Available) (?:on|at|from) \[url=https?://([a-z0-9_-]+)\.itch\.io/([a-z0-9_-]+)\]\s*Itch(?:\.io)?\s*\.?\[/url\](?:\,?$|\.\s*)';
+ AND id NOT IN(59555, 65209, 60553);
+DROP FUNCTION migrate_notes_to_itch(integer);
+
+
+UPDATE releases SET l_dmm = regexp_replace(l_dmm, 'https?://', '') where l_dmm <> '';
+UPDATE releases_hist SET l_dmm = regexp_replace(l_dmm, 'https?://', '') where l_dmm <> '';
diff --git a/util/updates/update_20190822.sql b/util/updates/update_20190822.sql
new file mode 100644
index 00000000..5f64a48b
--- /dev/null
+++ b/util/updates/update_20190822.sql
@@ -0,0 +1,4 @@
+ALTER TABLE releases ADD COLUMN l_jastusa text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_jastusa text NOT NULL DEFAULT '';
+
+\i util/sql/editfunc.sql
diff --git a/util/updates/update_20190824.sql b/util/updates/update_20190824.sql
new file mode 100644
index 00000000..5aeb779c
--- /dev/null
+++ b/util/updates/update_20190824.sql
@@ -0,0 +1,71 @@
+CREATE TABLE shop_jlist (
+ id text NOT NULL PRIMARY KEY,
+ lastfetch timestamptz,
+ found boolean NOT NULL DEFAULT false,
+ jbox boolean NOT NULL DEFAULT false,
+ price text NOT NULL DEFAULT ''
+);
+
+CREATE TABLE shop_mg (
+ id integer NOT NULL PRIMARY KEY,
+ lastfetch timestamptz,
+ found boolean NOT NULL DEFAULT false,
+ r18 boolean NOT NULL DEFAULT true,
+ price text NOT NULL DEFAULT ''
+);
+
+CREATE TABLE shop_denpa (
+ id text NOT NULL PRIMARY KEY,
+ lastfetch timestamptz,
+ found boolean NOT NULL DEFAULT false,
+ sku text NOT NULL DEFAULT '',
+ price text NOT NULL DEFAULT ''
+);
+
+CREATE TABLE shop_dlsite (
+ id text NOT NULL PRIMARY KEY,
+ lastfetch timestamptz,
+ found boolean NOT NULL DEFAULT false,
+ shop text NOT NULL DEFAULT '',
+ price text NOT NULL DEFAULT ''
+);
+
+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 ''
+);
+
+CREATE TABLE shop_playasia_gtin (
+ gtin bigint NOT NULL PRIMARY KEY,
+ lastfetch timestamptz
+);
+
+GRANT SELECT ON shop_denpa TO vndb_site;
+GRANT SELECT ON shop_dlsite 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, 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_denpa TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON shop_dlsite TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON shop_playasia TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON shop_playasia_gtin TO vndb_multi;
+
+CREATE INDEX shop_playasia__gtin ON shop_playasia (gtin);
+
+INSERT INTO shop_playasia (pax, gtin, lastfetch, url, price)
+ SELECT data, MAX(gtin), MAX(lastfetch), MAX(url), MAX(price)
+ FROM affiliate_links
+ JOIN releases ON affiliate_links.rid = releases.id
+ WHERE affiliate = 0 AND NOT affiliate_links.hidden AND price <> 'US$ 0.00'
+ GROUP BY data;
+
+
+-- Whenever:
+-- DROP TABLE affiliate_links;
+-- DROP TABLE multi_affiliate_gtin;
diff --git a/util/updates/update_20190831.sql b/util/updates/update_20190831.sql
new file mode 100644
index 00000000..16741ebf
--- /dev/null
+++ b/util/updates/update_20190831.sql
@@ -0,0 +1,4 @@
+SELECT edit_d_init(NULL, NULL);
+UPDATE edit_revision SET requester = 1, comments = 'Empty page', ip = '0.0.0.0';
+UPDATE edit_docs SET title = 'Database Querying';
+SELECT edit_d_commit();
diff --git a/util/updates/update_20190901.sql b/util/updates/update_20190901.sql
new file mode 100644
index 00000000..d6b76e90
--- /dev/null
+++ b/util/updates/update_20190901.sql
@@ -0,0 +1,6 @@
+ALTER TABLE releases ADD COLUMN l_egs integer NOT NULL DEFAULT 0;
+ALTER TABLE releases ADD COLUMN l_erotrail integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_egs integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_erotrail integer NOT NULL DEFAULT 0;
+
+\i util/sql/editfunc.sql
diff --git a/util/updates/update_20190902.sql b/util/updates/update_20190902.sql
new file mode 100644
index 00000000..fbf24c44
--- /dev/null
+++ b/util/updates/update_20190902.sql
@@ -0,0 +1,12 @@
+ALTER TABLE releases ALTER COLUMN l_dmm DROP DEFAULT;
+ALTER TABLE releases ALTER COLUMN l_gyutto DROP DEFAULT;
+ALTER TABLE releases_hist ALTER COLUMN l_dmm DROP DEFAULT;
+ALTER TABLE releases_hist ALTER COLUMN l_gyutto DROP DEFAULT;
+ALTER TABLE releases ALTER COLUMN l_dmm TYPE text[] USING CASE WHEN l_dmm = '' THEN '{}' ELSE ARRAY[l_dmm ] END;
+ALTER TABLE releases ALTER COLUMN l_gyutto TYPE integer[] USING CASE WHEN l_gyutto = 0 THEN '{}' ELSE ARRAY[l_gyutto] END;
+ALTER TABLE releases_hist ALTER COLUMN l_dmm TYPE text[] USING CASE WHEN l_dmm = '' THEN '{}' ELSE ARRAY[l_dmm ] END;
+ALTER TABLE releases_hist ALTER COLUMN l_gyutto TYPE integer[] USING CASE WHEN l_gyutto = 0 THEN '{}' ELSE ARRAY[l_gyutto] END;
+ALTER TABLE releases ALTER COLUMN l_dmm SET DEFAULT '{}';
+ALTER TABLE releases ALTER COLUMN l_gyutto SET DEFAULT '{}';
+ALTER TABLE releases_hist ALTER COLUMN l_dmm SET DEFAULT '{}';
+ALTER TABLE releases_hist ALTER COLUMN l_gyutto SET DEFAULT '{}';
diff --git a/util/updates/update_20190903.sql b/util/updates/update_20190903.sql
new file mode 100644
index 00000000..813d26aa
--- /dev/null
+++ b/util/updates/update_20190903.sql
@@ -0,0 +1,31 @@
+ALTER TABLE wikidata ADD COLUMN crunchyroll text[];
+ALTER TABLE wikidata ADD COLUMN igdb_game text[];
+ALTER TABLE wikidata ADD COLUMN giantbomb text[];
+ALTER TABLE wikidata ADD COLUMN pcgamingwiki text[];
+ALTER TABLE wikidata ADD COLUMN steam integer[];
+ALTER TABLE wikidata ADD COLUMN gog text[];
+ALTER TABLE wikidata ADD COLUMN pixiv_user integer[];
+
+
+ALTER TABLE staff ADD COLUMN l_pixiv integer NOT NULL DEFAULT 0;
+ALTER TABLE staff_hist ADD COLUMN l_pixiv integer NOT NULL DEFAULT 0;
+
+\i util/sql/editfunc.sql
+
+
+-- C U R S E D --
+
+CREATE OR REPLACE FUNCTION migrate_desc_to_pixiv(sid integer) RETURNS void AS $$
+BEGIN
+ PERFORM edit_s_init(sid, (SELECT MAX(rev) FROM changes WHERE itemid = sid AND type = 's'));
+ UPDATE edit_staff SET
+ l_pixiv = regexp_replace("desc", '^.*www\.pixiv\.net/(?:member\.php\?id=|whitecube/user/)([0-9]+).*$', '\1', 'i')::integer,
+ "desc" = trim(both E' \t\r\n' from regexp_replace("desc", '(?<=^|\n)\s*(?:(?:His|her )?Pixiv(?: profile| account| link)?\s*:\s+|(?:His|Her) Pixiv (?:profile|account) (?:can be (?:viewed|visited|accessed|reached)|is) )?(?:\[URL=)?(?:https?://)?www\.pixiv\.net/(?:member\.php\?id=|whitecube/user/)([0-9]+)(?:\](?:here|pixiv|link|URL|pixiv link|pixiv profile|pixiv account)\.?\[/URL\])?(?:\n|\s*$|\.\s*)', '', 'i'));
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic extraction of Pixiv id from the notes.';
+ PERFORM edit_s_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_desc_to_pixiv(id) FROM staff WHERE NOT hidden
+--SELECT id, "desc" FROM staff WHERE NOT hidden AND "desc" ~ 'pixiv\.net'
+ AND "desc" ~* '(?<=^|\n)\s*(?:(?:His|her )?Pixiv(?: profile| account| link)?\s*:\s+|(?:His|Her) Pixiv (?:profile|account) (?:can be (?:viewed|visited|accessed|reached)|is) )?(?:\[URL=)?(?:https?://)?www\.pixiv\.net/(?:member\.php\?id=|whitecube/user/)([0-9]+)(?:\](?:here|pixiv|link|URL|pixiv link|pixiv profile|pixiv account)\.?\[/URL\])?(?:\n|\s*$|\.\s*)';
+DROP FUNCTION migrate_desc_to_pixiv(integer);
diff --git a/util/updates/update_20190914.sql b/util/updates/update_20190914.sql
new file mode 100644
index 00000000..6c3cf21e
--- /dev/null
+++ b/util/updates/update_20190914.sql
@@ -0,0 +1,19 @@
+ALTER TABLE shop_denpa ALTER COLUMN found DROP NOT NULL;
+ALTER TABLE shop_denpa ALTER COLUMN found DROP DEFAULT;
+ALTER TABLE shop_denpa ALTER COLUMN found TYPE timestamptz USING CASE WHEN found THEN NULL ELSE lastfetch END;
+ALTER TABLE shop_denpa RENAME COLUMN found TO deadsince;
+
+ALTER TABLE shop_dlsite ALTER COLUMN found DROP NOT NULL;
+ALTER TABLE shop_dlsite ALTER COLUMN found DROP DEFAULT;
+ALTER TABLE shop_dlsite ALTER COLUMN found TYPE timestamptz USING CASE WHEN found THEN NULL ELSE lastfetch END;
+ALTER TABLE shop_dlsite RENAME COLUMN found TO deadsince;
+
+ALTER TABLE shop_jlist ALTER COLUMN found DROP NOT NULL;
+ALTER TABLE shop_jlist ALTER COLUMN found DROP DEFAULT;
+ALTER TABLE shop_jlist ALTER COLUMN found TYPE timestamptz USING CASE WHEN found THEN NULL ELSE lastfetch END;
+ALTER TABLE shop_jlist RENAME COLUMN found TO deadsince;
+
+ALTER TABLE shop_mg ALTER COLUMN found DROP NOT NULL;
+ALTER TABLE shop_mg ALTER COLUMN found DROP DEFAULT;
+ALTER TABLE shop_mg ALTER COLUMN found TYPE timestamptz USING CASE WHEN found THEN NULL ELSE lastfetch END;
+ALTER TABLE shop_mg RENAME COLUMN found TO deadsince;
diff --git a/util/updates/update_20190923.sql b/util/updates/update_20190923.sql
new file mode 100644
index 00000000..1c127447
--- /dev/null
+++ b/util/updates/update_20190923.sql
@@ -0,0 +1,6 @@
+ALTER TYPE language ADD VALUE 'mk' AFTER 'ko';
+ALTER TYPE language ADD VALUE 'lt' AFTER 'mk';
+ALTER TYPE language ADD VALUE 'lv' AFTER 'lt';
+ALTER TYPE language ADD VALUE 'sl' AFTER 'sk';
+ALTER TYPE language ADD VALUE 'gd' AFTER 'fr';
+ALTER TYPE language ADD VALUE 'ms' AFTER 'mk';
diff --git a/util/updates/update_20191003.sql b/util/updates/update_20191003.sql
new file mode 100644
index 00000000..28e0ddf3
--- /dev/null
+++ b/util/updates/update_20191003.sql
@@ -0,0 +1,40 @@
+ALTER TABLE users ADD COLUMN skin text NOT NULL DEFAULT '';
+ALTER TABLE users ADD COLUMN customcss text NOT NULL DEFAULT '';
+ALTER TABLE users ADD COLUMN filter_vn text NOT NULL DEFAULT '';
+ALTER TABLE users ADD COLUMN filter_release text NOT NULL DEFAULT '';
+ALTER TABLE users ADD COLUMN show_nsfw boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN hide_list boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN notify_dbedit boolean NOT NULL DEFAULT TRUE;
+ALTER TABLE users ADD COLUMN notify_announce boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN vn_list_own boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN vn_list_wish boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN tags_all boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN tags_cont boolean NOT NULL DEFAULT TRUE;
+ALTER TABLE users ADD COLUMN tags_ero boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN tags_tech boolean NOT NULL DEFAULT TRUE;
+ALTER TABLE users ADD COLUMN spoilers smallint NOT NULL DEFAULT 0;
+ALTER TABLE users ADD COLUMN traits_sexual boolean NOT NULL DEFAULT FALSE;
+
+UPDATE users SET
+ skin = COALESCE((SELECT value FROM users_prefs WHERE uid = id AND key = 'skin' ), ''),
+ customcss = COALESCE((SELECT value FROM users_prefs WHERE uid = id AND key = 'customcss' ), ''),
+ filter_vn = COALESCE((SELECT value FROM users_prefs WHERE uid = id AND key = 'filter_vn' ), ''),
+ filter_release = COALESCE((SELECT value FROM users_prefs WHERE uid = id AND key = 'filter_release' ), ''),
+ show_nsfw = COALESCE((SELECT TRUE FROM users_prefs WHERE uid = id AND key = 'show_nsfw' ), FALSE),
+ hide_list = COALESCE((SELECT TRUE FROM users_prefs WHERE uid = id AND key = 'hide_list' ), FALSE),
+ notify_dbedit = COALESCE((SELECT FALSE FROM users_prefs WHERE uid = id AND key = 'notify_nodbedit'), TRUE), -- NOTE: Inverted
+ notify_announce = COALESCE((SELECT TRUE FROM users_prefs WHERE uid = id AND key = 'notify_announce'), FALSE),
+ vn_list_own = COALESCE((SELECT TRUE FROM users_prefs WHERE uid = id AND key = 'vn_list_own' ), FALSE),
+ vn_list_wish = COALESCE((SELECT TRUE FROM users_prefs WHERE uid = id AND key = 'vn_list_wish' ), FALSE),
+ tags_all = COALESCE((SELECT TRUE FROM users_prefs WHERE uid = id AND key = 'tags_all' ), FALSE),
+ spoilers = COALESCE((SELECT value::smallint FROM users_prefs WHERE uid = id AND key = 'spoilers'), 0),
+ traits_sexual = COALESCE((SELECT TRUE FROM users_prefs WHERE uid = id AND key = 'traits_sexual' ), FALSE),
+ tags_cont = COALESCE((SELECT value LIKE '%cont%' FROM users_prefs WHERE uid = id AND key = 'tags_cat'), TRUE),
+ tags_ero = COALESCE((SELECT value LIKE '%ero%' FROM users_prefs WHERE uid = id AND key = 'tags_cat'), FALSE),
+ tags_tech = COALESCE((SELECT value LIKE '%tech%' FROM users_prefs WHERE uid = id AND key = 'tags_cat'), TRUE);
+
+\i util/sql/func.sql
+\i util/sql/perms.sql
+
+DROP TABLE users_prefs;
+DROP TYPE prefs_key;
diff --git a/util/updates/update_20191003b.sql b/util/updates/update_20191003b.sql
new file mode 100644
index 00000000..d3c41005
--- /dev/null
+++ b/util/updates/update_20191003b.sql
@@ -0,0 +1,23 @@
+-- lastused -> expires
+ALTER TABLE sessions RENAME COLUMN lastused TO expires;
+UPDATE sessions SET expires = expires + '1 month'::interval;
+ALTER TABLE sessions ALTER COLUMN expires DROP DEFAULT;
+
+-- Support different session types
+CREATE TYPE session_type AS ENUM ('web', 'pass', 'mail');
+ALTER TABLE sessions ADD COLUMN type session_type NOT NULL DEFAULT 'web';
+ALTER TABLE sessions ALTER COLUMN type DROP DEFAULT;
+ALTER TABLE sessions ADD COLUMN mail text;
+
+DROP FUNCTION user_isloggedin(integer, bytea);
+DROP FUNCTION user_update_lastused(integer, bytea);
+DROP FUNCTION user_isvalidtoken(integer, bytea);
+DROP FUNCTION user_setmail(integer, integer, bytea, text);
+DROP FUNCTION user_emailexists(text);
+
+-- Convert old password reset tokens to the new session format
+INSERT INTO sessions (uid, token, expires, type)
+ SELECT id, passwd, NOW() + '1 week', 'pass' FROM users WHERE length(passwd) = 20;
+UPDATE users SET passwd = '' WHERE length(passwd) = 20;
+
+\i util/sql/func.sql
diff --git a/util/updates/update_20191007.sql b/util/updates/update_20191007.sql
new file mode 100644
index 00000000..d2047e9c
--- /dev/null
+++ b/util/updates/update_20191007.sql
@@ -0,0 +1,10 @@
+ALTER TABLE tags_vn_inherit DROP COLUMN users;
+
+ALTER TABLE traits_chars DROP CONSTRAINT traits_chars_pkey;
+
+DROP FUNCTION tag_vn_calc();
+DROP FUNCTION traits_chars_calc();
+
+\i util/sql/func.sql
+SELECT tag_vn_calc(NULL);
+SELECT traits_chars_calc(NULL);
diff --git a/util/updates/update_20191010.sql b/util/updates/update_20191010.sql
new file mode 100644
index 00000000..1cdf898f
--- /dev/null
+++ b/util/updates/update_20191010.sql
@@ -0,0 +1,10 @@
+ALTER TABLE users ADD COLUMN nodistract_can boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN nodistract_noads boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN nodistract_nofancy boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN support_can boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN support_enabled boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN uniname_can boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN uniname text NOT NULL DEFAULT '';
+ALTER TABLE users ADD COLUMN pubskin_can boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE users ADD COLUMN pubskin_enabled boolean NOT NULL DEFAULT FALSE;
+\i util/sql/perms.sql
diff --git a/util/updates/update_20191102.sql b/util/updates/update_20191102.sql
new file mode 100644
index 00000000..e3862591
--- /dev/null
+++ b/util/updates/update_20191102.sql
@@ -0,0 +1,72 @@
+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');
+
+ALTER TABLE chars ADD COLUMN cup_size cup_size NOT NULL DEFAULT '';
+ALTER TABLE chars_hist ADD COLUMN cup_size cup_size NOT NULL DEFAULT '';
+ALTER TABLE chars ADD COLUMN age smallint;
+ALTER TABLE chars_hist ADD COLUMN age smallint;
+
+\i util/sql/editfunc.sql
+
+
+
+CREATE OR REPLACE FUNCTION migrate_trait_to_cup(cid integer, cup cup_size) RETURNS void AS $$
+BEGIN
+ PERFORM edit_c_init(cid, (SELECT MAX(rev) FROM changes WHERE itemid = cid AND type = 'c'));
+ UPDATE edit_chars SET cup_size = cup;
+ DELETE FROM edit_chars_traits WHERE tid IN(722, 1182, 1183, 1178, 1184, 723, 2129, 2115);
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic conversion of breast size trait to cup size field.';
+ PERFORM edit_c_commit();
+END;
+$$ LANGUAGE plpgsql;
+
+UPDATE traits SET state = 1 WHERE id IN(722, 1182, 1183, 1178, 1184);
+
+-- This takes a while (slowness is likely due to traits_chars_calc(), can be temporarily disabled, but w/e)
+\timing
+SELECT count(*) FROM (SELECT migrate_trait_to_cup(c.id, 'AA') FROM chars c JOIN chars_traits ct ON ct.id = c.id WHERE NOT c.hidden AND c.cup_size = '' AND ct.tid = 722) x;
+SELECT count(*) FROM (SELECT migrate_trait_to_cup(c.id, 'A' ) FROM chars c JOIN chars_traits ct ON ct.id = c.id WHERE NOT c.hidden AND c.cup_size = '' AND ct.tid = 1182) x;
+SELECT count(*) FROM (SELECT migrate_trait_to_cup(c.id, 'B' ) FROM chars c JOIN chars_traits ct ON ct.id = c.id WHERE NOT c.hidden AND c.cup_size = '' AND ct.tid = 1183) x;
+SELECT count(*) FROM (SELECT migrate_trait_to_cup(c.id, 'C' ) FROM chars c JOIN chars_traits ct ON ct.id = c.id WHERE NOT c.hidden AND c.cup_size = '' AND ct.tid = 1178) x;
+SELECT count(*) FROM (SELECT migrate_trait_to_cup(c.id, 'D' ) FROM chars c JOIN chars_traits ct ON ct.id = c.id WHERE NOT c.hidden AND c.cup_size = '' AND ct.tid = 1184) x;
+\timing
+
+DROP FUNCTION migrate_trait_to_cup(integer, cup_size);
+
+
+-- Regex magic by skorpiondeath (with minor changes) - https://query.vndb.org/queries/kKFzwjqvAshONiaf
+CREATE OR REPLACE FUNCTION migrate_desc_to_cup(cid integer) RETURNS void AS $$
+BEGIN
+ PERFORM edit_c_init(cid, (SELECT MAX(rev) FROM changes WHERE itemid = cid AND type = 'c'));
+ UPDATE edit_chars
+ SET cup_size = substring( substring("desc" from '([c|C]up[\s]*(size|Size)?:[\s]*[A-Z][A]*)') from '[A]*.$')::cup_size
+ , "desc" = regexp_replace("desc", '[\s]*(-)?[\s]*?cup[\s]*(size)?:[\s]?[A-Z][A]*[.|\s-]?((cup)|([\s]*-->[\s]*[A-Z]))?[\n\r]*', '', 'gi');
+ DELETE FROM edit_chars_traits WHERE tid IN(722, 1182, 1183, 1178, 1184, 723, 2129, 2115);
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic extraction of cup size field from the description.';
+ PERFORM edit_c_commit();
+END;
+$$ LANGUAGE plpgsql;
+
+\timing
+SELECT count(*) FROM (SELECT migrate_desc_to_cup(id) FROM chars WHERE NOT hidden AND cup_size = '' AND "desc" ~* '.*(cup[\s]*(size)?:[\s]*[A-Z]).*') x;
+\timing
+
+DROP FUNCTION migrate_desc_to_cup(integer);
+
+
+
+CREATE OR REPLACE FUNCTION migrate_desc_to_age(cid integer) RETURNS void AS $$
+BEGIN
+ PERFORM edit_c_init(cid, (SELECT MAX(rev) FROM changes WHERE itemid = cid AND type = 'c'));
+ UPDATE edit_chars SET
+ age = regexp_replace("desc", '^.*age:\s*([0-9]+).*$', '\1', 'i')::smallint,
+ "desc" = trim(both E' \t\r\n' from regexp_replace("desc", '(?<=^|\n)\s*age:\s*([0-9]+)(?:\n|\s*$|\.\s*)', '', 'ig'));
+ UPDATE edit_revision SET requester = 1, ip = '0.0.0.0', comments = 'Automatic extraction of age from the description.';
+ PERFORM edit_c_commit();
+END;
+$$ LANGUAGE plpgsql;
+
+\timing
+SELECT count(*) FROM (SELECT migrate_desc_to_age(id) FROM chars WHERE NOT hidden AND age IS NULL AND "desc" ~* '(?<=^|\n)\s*age:\s*([0-9]+)(?:\n|\s*$|\.\s*)') x;
+\timing
+
+DROP FUNCTION migrate_desc_to_age(integer);
diff --git a/util/updates/update_20191108.sql b/util/updates/update_20191108.sql
new file mode 100644
index 00000000..d1b99e7b
--- /dev/null
+++ b/util/updates/update_20191108.sql
@@ -0,0 +1 @@
+ALTER TABLE wikidata ADD COLUMN doujinshi_author integer[];
diff --git a/util/updates/update_20191220.sql b/util/updates/update_20191220.sql
new file mode 100644
index 00000000..7da67f22
--- /dev/null
+++ b/util/updates/update_20191220.sql
@@ -0,0 +1,2 @@
+ALTER TABLE threads_poll_votes ADD COLUMN date timestamptz;
+ALTER TABLE threads_poll_votes ALTER COLUMN date SET DEFAULT NOW();
diff --git a/util/vndb-dev-server.pl b/util/vndb-dev-server.pl
index 763748b6..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,11 +102,8 @@ sub checkmod {
}, "$ROOT/lib";
chdir $ROOT;
- $check->($_) for (qw{
- util/vndb.pl
- data/config.pl
- data/global.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 c0af72e1..6690f4c9 100755
--- a/util/vndb.pl
+++ b/util/vndb.pl
@@ -1,97 +1,187 @@
#!/usr/bin/perl
-
-package VNDB;
-
-use strict;
+# 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';
-our $ROOT;
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/vndb\.pl$}{}; }
+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.
-use lib $ROOT.'/lib';
-
-
-use TUWF ':html';
-use SkinFile;
-
-
-our(%O, %S);
-
-
-# load the skins
-# NOTE: $S{skins} can be modified in data/config.pl, allowing deletion of skins or forcing only one skin
-my $skin = SkinFile->new("$ROOT/static/s");
-$S{skins} = { map +($_ => [ $skin->get($_, 'name'), $skin->get($_, 'userid') ]), $skin->list };
-
-
-# load settings from global.pl
-require $ROOT.'/data/global.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'; }
-# automatically regenerate the skins and script.js and whatever else should be done
-system "make -sC $ROOT" if $S{regen_static};
-
-
-$TUWF::OBJ->{$_} = $S{$_} for (keys %S);
-TUWF::set(
- %O,
- pre_request_handler => \&reqinit,
- error_404_handler => \&handle404,
- log_format => \&logformat,
-);
-TUWF::load_recursive('VNDB::Util', 'VNDB::DB', 'VNDB::Handler');
-TUWF::run();
-
-
-sub reqinit {
- my $self = shift;
+our($ROOT, $NOAPI, $ONLYAPI);
+BEGIN {
+ ($ROOT = abs_path $0) =~ s{/util/vndb\.pl$}{};
+ ($NOAPI) = grep $_ eq 'noapi', @ARGV;
+ ($ONLYAPI) = grep $_ eq 'onlyapi', @ARGV;
+}
- # If we're running standalone, serve www/ and static/ too.
- if($TUWF::OBJ->{_TUWF}{http}) {
- if($self->resFile("$ROOT/www", $self->reqPath) || $self->resFile("$ROOT/static", $self->reqPath)) {
- $self->resHeader('Cache-Control' => 'max-age=31536000');
- return 0;
+use lib $ROOT.'/lib';
+use VNDB::Config;
+use VNWeb::Auth;
+use VNWeb::HTML ();
+use VNWeb::Validation ();
+use VNWeb::TitlePrefs ();
+use VNWeb::TimeZone ();
+
+$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.
+tuwf->{elmgen} = $ARGV[0] && $ARGV[0] eq 'elmgen';
+
+
+TUWF::hook before => sub {
+ 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;
}
- }
- # check authentication cookies
- $self->authInit;
-
- # load some stats (used for about all pageviews, anyway)
- $self->{stats} = $self->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;
+ }
- return 1;
+ # 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 {
+ article_ sub {
+ h1_ 'Page not found';
+ div_ class => 'warning', sub {
+ h2_ 'Oops!';
+ p_;
+ txt_ 'It seems the page you were looking for does not exist,';
+ br_;
+ txt_ 'you may want to try using the menu on your left to find what you are looking for.';
+ }
+ }
+ }
+};
+
+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 {
+ article_ sub {
+ h1_ 'Access Denied';
+ div_ class => 'warning', sub {
+ if(!auth) {
+ h2_ 'You need to be logged in to perform this action.';
+ p_ sub {
+ txt_ 'Please ';
+ a_ href => '/u/login', 'login';
+ txt_ ' or ';
+ 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.';
+ }
+ }
+ }
+ }
}
-sub handle404 {
- my $self = shift;
- $self->resStatus(404);
- $self->htmlHeader(title => 'Page Not Found');
- div class => 'mainbox';
- h1 'Page not found';
- div class => 'warning';
- h2 'Oops!';
- p;
- txt 'It seems the page you were looking for does not exist,';
- br;
- txt 'you may want to try using the menu on your left to find what you are looking for.';
- end;
- end;
- end;
- $self->htmlFooter;
+# 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->(@_) });
+ };
}
-
-# log user IDs (necessary for determining performance issues, user preferences
-# have a lot of influence in this)
-sub logformat {
- my($self, $uri, $msg) = @_;
- sprintf "[%s] %s %s: %s\n", scalar localtime(), $uri,
- $self->authInfo->{id} ? 'u'.$self->authInfo->{id} : '-', $msg;
+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};