summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore21
-rw-r--r--Dockerfile13
-rw-r--r--Makefile367
-rw-r--r--README.md212
-rw-r--r--api-kana.md1704
-rw-r--r--api-nyan.md975
-rw-r--r--conf_example.pl (renamed from data/conf_example.pl)8
-rw-r--r--css/blendbg.css6
-rw-r--r--css/forms.css282
-rw-r--r--css/layout.css158
-rw-r--r--css/skins/air.sass51
-rw-r--r--css/skins/angel.sass75
-rw-r--r--css/skins/aselia_01.sass52
-rw-r--r--css/skins/carnevale.sass52
-rw-r--r--css/skins/eiel.sass55
-rw-r--r--css/skins/ever17_01.sass52
-rw-r--r--css/skins/fate_01.sass50
-rw-r--r--css/skins/fate_02.sass50
-rw-r--r--css/skins/grey.sass51
-rw-r--r--css/skins/higanbana.sass51
-rw-r--r--css/skins/higu.sass50
-rw-r--r--css/skins/lb.sass50
-rw-r--r--css/skins/lb_02.sass50
-rw-r--r--css/skins/primitive.sass52
-rw-r--r--css/skins/saya.sass52
-rw-r--r--css/skins/seinarukana.sass50
-rw-r--r--css/skins/taka.sass52
-rw-r--r--css/skins/teal.sass50
-rw-r--r--css/skins/term.sass50
-rw-r--r--css/skins/tsukihime.sass51
-rw-r--r--css/skins/tsukihime_02.sass50
-rw-r--r--css/staffedit.css7
-rw-r--r--css/v2.css1179
-rw-r--r--css/vngraph.css27
-rw-r--r--data/icons/feed.pngbin519 -> 0 bytes
-rw-r--r--data/icons/lang/ar.pngbin313 -> 0 bytes
-rw-r--r--data/icons/lang/bg.pngbin107 -> 0 bytes
-rw-r--r--data/icons/lang/ca.pngbin129 -> 0 bytes
-rw-r--r--data/icons/lang/cs.pngbin138 -> 0 bytes
-rw-r--r--data/icons/lang/da.pngbin150 -> 0 bytes
-rw-r--r--data/icons/lang/de.pngbin92 -> 0 bytes
-rw-r--r--data/icons/lang/el.pngbin120 -> 0 bytes
-rw-r--r--data/icons/lang/en.pngbin215 -> 0 bytes
-rw-r--r--data/icons/lang/es.pngbin93 -> 0 bytes
-rw-r--r--data/icons/lang/fi.pngbin148 -> 0 bytes
-rw-r--r--data/icons/lang/fr.pngbin91 -> 0 bytes
-rw-r--r--data/icons/lang/ga.pngbin95 -> 0 bytes
-rw-r--r--data/icons/lang/he.pngbin189 -> 0 bytes
-rw-r--r--data/icons/lang/hr.pngbin262 -> 0 bytes
-rw-r--r--data/icons/lang/hu.pngbin108 -> 0 bytes
-rw-r--r--data/icons/lang/id.pngbin96 -> 0 bytes
-rw-r--r--data/icons/lang/it.pngbin91 -> 0 bytes
-rw-r--r--data/icons/lang/ja.pngbin112 -> 0 bytes
-rw-r--r--data/icons/lang/ko.pngbin164 -> 0 bytes
-rw-r--r--data/icons/lang/ms.pngbin352 -> 0 bytes
-rw-r--r--data/icons/lang/nl.pngbin94 -> 0 bytes
-rw-r--r--data/icons/lang/no.pngbin125 -> 0 bytes
-rw-r--r--data/icons/lang/pl.pngbin89 -> 0 bytes
-rw-r--r--data/icons/lang/pt-br.pngbin338 -> 0 bytes
-rw-r--r--data/icons/lang/pt-pt.pngbin217 -> 0 bytes
-rw-r--r--data/icons/lang/ro.pngbin114 -> 0 bytes
-rw-r--r--data/icons/lang/ru.pngbin96 -> 0 bytes
-rw-r--r--data/icons/lang/sk.pngbin260 -> 0 bytes
-rw-r--r--data/icons/lang/sv.pngbin326 -> 0 bytes
-rw-r--r--data/icons/lang/ta.pngbin291 -> 0 bytes
-rw-r--r--data/icons/lang/th.pngbin132 -> 0 bytes
-rw-r--r--data/icons/lang/tr.pngbin160 -> 0 bytes
-rw-r--r--data/icons/lang/uk.pngbin105 -> 0 bytes
-rw-r--r--data/icons/lang/vi.pngbin215 -> 0 bytes
-rw-r--r--data/icons/lang/zh.pngbin121 -> 0 bytes
-rw-r--r--elm/AdvSearch/Anime.elm10
-rw-r--r--elm/AdvSearch/Birthday.elm67
-rw-r--r--elm/AdvSearch/DRM.elm78
-rw-r--r--elm/AdvSearch/Engine.elm13
-rw-r--r--elm/AdvSearch/Fields.elm170
-rw-r--r--elm/AdvSearch/Lib.elm9
-rw-r--r--elm/AdvSearch/Main.elm151
-rw-r--r--elm/AdvSearch/Producers.elm31
-rw-r--r--elm/AdvSearch/Range.elm6
-rw-r--r--elm/AdvSearch/Resolution.elm4
-rw-r--r--elm/AdvSearch/Set.elm160
-rw-r--r--elm/AdvSearch/Staff.elm18
-rw-r--r--elm/AdvSearch/Tags.elm46
-rw-r--r--elm/AdvSearch/Traits.elm50
-rw-r--r--elm/CharEdit.elm161
-rw-r--r--elm/ColSelect.elm80
-rw-r--r--elm/Discussions/Edit.elm124
-rw-r--r--elm/Discussions/Poll.elm6
-rw-r--r--elm/Discussions/PostEdit.elm26
-rw-r--r--elm/Discussions/Reply.elm82
-rw-r--r--elm/DocEdit.elm102
-rw-r--r--elm/ImageFlagging.elm44
-rw-r--r--elm/ImageFlagging.js16
-rw-r--r--elm/Lib/Api.elm19
-rw-r--r--elm/Lib/Autocomplete.elm68
-rw-r--r--elm/Lib/DropDown.elm4
-rw-r--r--elm/Lib/Editsum.elm35
-rw-r--r--elm/Lib/ExtLinks.elm130
-rw-r--r--elm/Lib/Ffi.elm2
-rw-r--r--elm/Lib/Ffi.js26
-rw-r--r--elm/Lib/Html.elm24
-rw-r--r--elm/Lib/Image.elm18
-rw-r--r--elm/Lib/RDate.elm40
-rw-r--r--elm/Lib/TextPreview.elm23
-rw-r--r--elm/Lib/Util.elm71
-rw-r--r--elm/ProducerEdit.elm226
-rw-r--r--elm/ReleaseEdit.elm410
-rw-r--r--elm/Report.elm189
-rw-r--r--elm/Reviews/Comment.elm52
-rw-r--r--elm/Reviews/Edit.elm36
-rw-r--r--elm/Reviews/Vote.elm70
-rw-r--r--elm/StaffEdit.elm206
-rw-r--r--elm/Subscribe.elm99
-rw-r--r--elm/TableOpts.elm118
-rw-r--r--elm/TagEdit.elm237
-rw-r--r--elm/Tagmod.elm126
-rw-r--r--elm/TraitEdit.elm94
-rw-r--r--elm/UList/DateEdit.elm4
-rw-r--r--elm/UList/LabelEdit.elm48
-rw-r--r--elm/UList/LabelEdit.js10
-rw-r--r--elm/UList/ManageLabels.elm12
-rw-r--r--elm/UList/ManageLabels.js4
-rw-r--r--elm/UList/Opt.elm14
-rw-r--r--elm/UList/Opt.js34
-rw-r--r--elm/UList/SaveDefault.elm2
-rw-r--r--elm/UList/VNPage.elm170
-rw-r--r--elm/UList/VoteEdit.js8
-rw-r--r--elm/UList/Widget.elm316
-rw-r--r--elm/UList/actiontabs.js17
-rw-r--r--elm/UList/labelfilters.js17
-rw-r--r--elm/User/Edit.elm290
-rw-r--r--elm/User/Login.elm145
-rw-r--r--elm/User/PassReset.elm87
-rw-r--r--elm/User/PassSet.elm85
-rw-r--r--elm/User/Register.elm107
-rw-r--r--elm/VNEdit.elm366
-rw-r--r--elm/VNEdit.js6
-rw-r--r--elm/VNLengthVote.elm216
-rw-r--r--elm/checkall.js16
-rw-r--r--elm/checkhidden.js17
-rw-r--r--elm/elm-init.js34
-rw-r--r--elm/elm.json1
-rw-r--r--elm/lib.js15
-rw-r--r--elm/polyfills.js33
-rw-r--r--elm/searchtabs.js11
-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.png (renamed from data/icons/lang/fa.png)bin312 -> 312 bytes
-rw-r--r--icons/lang/fi.pngbin0 -> 88 bytes
-rw-r--r--icons/lang/fr.pngbin0 -> 81 bytes
-rw-r--r--icons/lang/ga.pngbin0 -> 88 bytes
-rw-r--r--icons/lang/gd.png (renamed from data/icons/lang/gd.png)bin321 -> 321 bytes
-rw-r--r--icons/lang/he.pngbin0 -> 137 bytes
-rw-r--r--icons/lang/hi.pngbin0 -> 123 bytes
-rw-r--r--icons/lang/hr.pngbin0 -> 206 bytes
-rw-r--r--icons/lang/hu.pngbin0 -> 98 bytes
-rw-r--r--icons/lang/id.pngbin0 -> 85 bytes
-rw-r--r--icons/lang/it.pngbin0 -> 81 bytes
-rw-r--r--icons/lang/iu.pngbin0 -> 271 bytes
-rw-r--r--icons/lang/ja.pngbin0 -> 88 bytes
-rw-r--r--icons/lang/ko.pngbin0 -> 122 bytes
-rw-r--r--icons/lang/la.pngbin0 -> 314 bytes
-rw-r--r--icons/lang/lt.png (renamed from data/icons/lang/lt.png)bin100 -> 100 bytes
-rw-r--r--icons/lang/lv.png (renamed from data/icons/lang/lv.png)bin88 -> 88 bytes
-rw-r--r--icons/lang/mk.png (renamed from data/icons/lang/mk.png)bin313 -> 313 bytes
-rw-r--r--icons/lang/ms.pngbin0 -> 352 bytes
-rw-r--r--icons/lang/nl.pngbin0 -> 83 bytes
-rw-r--r--icons/lang/no.pngbin0 -> 92 bytes
-rw-r--r--icons/lang/pl.pngbin0 -> 77 bytes
-rw-r--r--icons/lang/pt-br.pngbin0 -> 198 bytes
-rw-r--r--icons/lang/pt-pt.pngbin0 -> 137 bytes
-rw-r--r--icons/lang/ro.pngbin0 -> 102 bytes
-rw-r--r--icons/lang/ru.pngbin0 -> 85 bytes
-rw-r--r--icons/lang/sk.pngbin0 -> 208 bytes
-rw-r--r--icons/lang/sl.png (renamed from data/icons/lang/sl.png)bin169 -> 169 bytes
-rw-r--r--icons/lang/sr.pngbin0 -> 262 bytes
-rw-r--r--icons/lang/sv.pngbin0 -> 281 bytes
-rw-r--r--icons/lang/ta.pngbin0 -> 260 bytes
-rw-r--r--icons/lang/th.pngbin0 -> 120 bytes
-rw-r--r--icons/lang/tr.pngbin0 -> 101 bytes
-rw-r--r--icons/lang/uk.pngbin0 -> 96 bytes
-rw-r--r--icons/lang/ur.pngbin0 -> 172 bytes
-rw-r--r--icons/lang/vi.pngbin0 -> 149 bytes
-rw-r--r--icons/lang/zh-Hans.pngbin0 -> 174 bytes
-rw-r--r--icons/lang/zh-Hant.pngbin0 -> 105 bytes
-rw-r--r--icons/lang/zh.pngbin0 -> 93 bytes
-rw-r--r--icons/list/add.svg6
-rw-r--r--icons/list/l1.svg6
-rw-r--r--icons/list/l2.svg6
-rw-r--r--icons/list/l3.svg6
-rw-r--r--icons/list/l4.svg6
-rw-r--r--icons/list/l5.svg6
-rw-r--r--icons/list/l6.svg6
-rw-r--r--icons/list/unknown.svg7
-rw-r--r--icons/plat/and.svg3
-rw-r--r--icons/plat/bdp.svg6
-rw-r--r--icons/plat/dos.svg6
-rw-r--r--icons/plat/drc.svg3
-rw-r--r--icons/plat/dvd.svg3
-rw-r--r--icons/plat/fm7.svg3
-rw-r--r--icons/plat/fm8.svg3
-rw-r--r--icons/plat/fmt.svg3
-rw-r--r--icons/plat/gba.svg4
-rw-r--r--icons/plat/gbc.svg5
-rw-r--r--icons/plat/ios.svg10
-rw-r--r--icons/plat/lin.svg7
-rw-r--r--icons/plat/mac.svg9
-rw-r--r--icons/plat/mob.svg22
-rw-r--r--icons/plat/msx.svg4
-rw-r--r--icons/plat/n3d.svg4
-rw-r--r--icons/plat/nds.svg3
-rw-r--r--icons/plat/nes.svg3
-rw-r--r--icons/plat/oth.svg3
-rw-r--r--icons/plat/p88.svg6
-rw-r--r--icons/plat/p98.svg5
-rw-r--r--icons/plat/pce.svg4
-rw-r--r--icons/plat/pcf.svg13
-rw-r--r--icons/plat/ps1.svg10
-rw-r--r--icons/plat/ps2.svg3
-rw-r--r--icons/plat/ps3.svg3
-rw-r--r--icons/plat/ps4.svg3
-rw-r--r--icons/plat/ps5.svg3
-rw-r--r--icons/plat/psp.svg3
-rw-r--r--icons/plat/psv.svg3
-rw-r--r--icons/plat/sat.svg18
-rw-r--r--icons/plat/scd.svg8
-rw-r--r--icons/plat/sfc.svg6
-rw-r--r--icons/plat/smd.svg6
-rw-r--r--icons/plat/swi.svg6
-rw-r--r--icons/plat/tdo.svg6
-rw-r--r--icons/plat/vnd.svg5
-rw-r--r--icons/plat/web.svg3
-rw-r--r--icons/plat/wii.svg3
-rw-r--r--icons/plat/win.svg6
-rw-r--r--icons/plat/wiu.svg6
-rw-r--r--icons/plat/x1s.svg3
-rw-r--r--icons/plat/x68.svg3
-rw-r--r--icons/plat/xb1.svg65
-rw-r--r--icons/plat/xb3.svg37
-rw-r--r--icons/plat/xbo.svg3
-rw-r--r--icons/plat/xxs.svg4
-rw-r--r--icons/rel/ani-ero.svg7
-rw-r--r--icons/rel/ani-story.svg6
-rw-r--r--icons/rel/cartridge.svg7
-rw-r--r--icons/rel/disk.svg4
-rw-r--r--icons/rel/download.svg6
-rw-r--r--icons/rel/free.svg3
-rw-r--r--icons/rel/nonfree.svg7
-rw-r--r--icons/rel/notes.svg6
-rw-r--r--icons/rel/reso-169.svg6
-rw-r--r--icons/rel/reso-43.svg6
-rw-r--r--icons/rel/reso-custom.svg7
-rw-r--r--icons/rel/voiced.svg7
-rw-r--r--icons/rss.svg8
-rw-r--r--icons/rtcomplete.png (renamed from data/icons/rtcomplete.png)bin89 -> 89 bytes
-rw-r--r--icons/rtpartial.png (renamed from data/icons/rtpartial.png)bin102 -> 102 bytes
-rw-r--r--icons/rttrial.png (renamed from data/icons/rttrial.png)bin114 -> 114 bytes
-rw-r--r--js/README.md70
-rw-r--r--js/basic/TableOpts.js132
-rw-r--r--js/basic/api.js73
-rw-r--r--js/basic/checkall.js16
-rw-r--r--js/basic/checkhidden.js11
-rw-r--r--js/basic/components.js433
-rw-r--r--js/basic/ds.js450
-rw-r--r--js/basic/elm-support.js112
-rw-r--r--js/basic/index.js55
-rw-r--r--js/basic/iv.js (renamed from elm/iv.js)9
-rw-r--r--js/basic/mainbox-summarize.js (renamed from elm/mainbox-summarize.js)24
-rw-r--r--js/basic/polyfills.js24
-rw-r--r--js/basic/searchtabs.js9
-rw-r--r--js/basic/sethash.js (renamed from elm/sethash.js)2
-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/TagEdit.js121
-rw-r--r--js/contrib/index.js137
-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.pm439
-rw-r--r--lib/Multi/Core.pm54
-rw-r--r--lib/Multi/DLsite.pm18
-rw-r--r--lib/Multi/IRC.pm123
-rw-r--r--lib/Multi/JASTUSA.pm87
-rw-r--r--lib/Multi/JList.pm48
-rw-r--r--lib/Multi/Maintenance.pm110
-rw-r--r--lib/Multi/Wikidata.pm2
-rw-r--r--lib/PWLookup.pm155
-rw-r--r--lib/VNDB/BBCode.pm35
-rw-r--r--lib/VNDB/Config.pm22
-rw-r--r--lib/VNDB/ExtLinks.pm280
-rw-r--r--lib/VNDB/Func.pm168
-rw-r--r--lib/VNDB/Schema.pm28
-rw-r--r--lib/VNDB/Types.pm148
-rw-r--r--lib/VNWeb/API.pm1085
-rw-r--r--lib/VNWeb/AdvSearch.pm393
-rw-r--r--lib/VNWeb/Auth.pm162
-rw-r--r--lib/VNWeb/Chars/Edit.pm66
-rw-r--r--lib/VNWeb/Chars/Elm.pm28
-rw-r--r--lib/VNWeb/Chars/List.pm76
-rw-r--r--lib/VNWeb/Chars/Page.pm181
-rw-r--r--lib/VNWeb/Chars/VNTab.pm40
-rw-r--r--lib/VNWeb/DB.pm79
-rw-r--r--lib/VNWeb/Discussions/Board.pm7
-rw-r--r--lib/VNWeb/Discussions/Edit.pm56
-rw-r--r--lib/VNWeb/Discussions/Elm.pm40
-rw-r--r--lib/VNWeb/Discussions/Index.pm8
-rw-r--r--lib/VNWeb/Discussions/Lib.pm38
-rw-r--r--lib/VNWeb/Discussions/PostEdit.pm4
-rw-r--r--lib/VNWeb/Discussions/Search.pm119
-rw-r--r--lib/VNWeb/Discussions/Thread.pm58
-rw-r--r--lib/VNWeb/Discussions/UPosts.pm28
-rw-r--r--lib/VNWeb/Docs/Edit.pm22
-rw-r--r--lib/VNWeb/Docs/Lib.pm4
-rw-r--r--lib/VNWeb/Docs/Page.pm7
-rw-r--r--lib/VNWeb/Elm.pm154
-rw-r--r--lib/VNWeb/Filters.pm48
-rw-r--r--lib/VNWeb/Graph.pm12
-rw-r--r--lib/VNWeb/HTML.pm574
-rw-r--r--lib/VNWeb/Images/Lib.pm57
-rw-r--r--lib/VNWeb/Images/List.pm31
-rw-r--r--lib/VNWeb/Images/Upload.pm80
-rw-r--r--lib/VNWeb/Images/Vote.pm30
-rw-r--r--lib/VNWeb/JS.pm73
-rw-r--r--lib/VNWeb/Misc/AdvSearch.pm13
-rw-r--r--lib/VNWeb/Misc/BBCode.pm8
-rw-r--r--lib/VNWeb/Misc/Feeds.pm3
-rw-r--r--lib/VNWeb/Misc/History.pm90
-rw-r--r--lib/VNWeb/Misc/HomePage.pm112
-rw-r--r--lib/VNWeb/Misc/Lockdown.pm54
-rw-r--r--lib/VNWeb/Misc/Redirects.pm6
-rw-r--r--lib/VNWeb/Misc/Reports.pm193
-rw-r--r--lib/VNWeb/Prelude.pm59
-rw-r--r--lib/VNWeb/Producers/Edit.pm54
-rw-r--r--lib/VNWeb/Producers/Elm.pm45
-rw-r--r--lib/VNWeb/Producers/Graph.pm19
-rw-r--r--lib/VNWeb/Producers/List.pm69
-rw-r--r--lib/VNWeb/Producers/Page.pm87
-rw-r--r--lib/VNWeb/Releases/DRM.pm120
-rw-r--r--lib/VNWeb/Releases/Edit.pm161
-rw-r--r--lib/VNWeb/Releases/Elm.pm28
-rw-r--r--lib/VNWeb/Releases/Engines.pm4
-rw-r--r--lib/VNWeb/Releases/Lib.pm132
-rw-r--r--lib/VNWeb/Releases/List.pm39
-rw-r--r--lib/VNWeb/Releases/Page.pm206
-rw-r--r--lib/VNWeb/Releases/VNTab.pm51
-rw-r--r--lib/VNWeb/Reviews/Edit.pm26
-rw-r--r--lib/VNWeb/Reviews/JS.pm (renamed from lib/VNWeb/Reviews/Elm.pm)17
-rw-r--r--lib/VNWeb/Reviews/Lib.pm15
-rw-r--r--lib/VNWeb/Reviews/List.pm14
-rw-r--r--lib/VNWeb/Reviews/Page.pm60
-rw-r--r--lib/VNWeb/Reviews/VNTab.pm82
-rw-r--r--lib/VNWeb/Staff/Edit.pm57
-rw-r--r--lib/VNWeb/Staff/Elm.pm41
-rw-r--r--lib/VNWeb/Staff/List.pm34
-rw-r--r--lib/VNWeb/Staff/Page.pm115
-rw-r--r--lib/VNWeb/TT/Elm.pm76
-rw-r--r--lib/VNWeb/TT/Index.pm26
-rw-r--r--lib/VNWeb/TT/Lib.pm40
-rw-r--r--lib/VNWeb/TT/List.pm41
-rw-r--r--lib/VNWeb/TT/TagEdit.pm125
-rw-r--r--lib/VNWeb/TT/TagLinks.pm41
-rw-r--r--lib/VNWeb/TT/TagPage.pm124
-rw-r--r--lib/VNWeb/TT/TraitEdit.pm106
-rw-r--r--lib/VNWeb/TT/TraitPage.pm85
-rw-r--r--lib/VNWeb/TableOpts.pm183
-rw-r--r--lib/VNWeb/TimeZone.pm512
-rw-r--r--lib/VNWeb/TitlePrefs.pm217
-rw-r--r--lib/VNWeb/ULists/Elm.pm153
-rw-r--r--lib/VNWeb/ULists/Export.pm60
-rw-r--r--lib/VNWeb/ULists/Lib.pm87
-rw-r--r--lib/VNWeb/ULists/List.pm357
-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.pm321
-rw-r--r--lib/VNWeb/User/List.pm23
-rw-r--r--lib/VNWeb/User/Login.pm55
-rw-r--r--lib/VNWeb/User/Notifications.pm34
-rw-r--r--lib/VNWeb/User/Page.pm90
-rw-r--r--lib/VNWeb/User/PassReset.pm52
-rw-r--r--lib/VNWeb/User/PassSet.pm31
-rw-r--r--lib/VNWeb/User/Register.pm65
-rw-r--r--lib/VNWeb/VN/Edit.pm92
-rw-r--r--lib/VNWeb/VN/Elm.pm46
-rw-r--r--lib/VNWeb/VN/Graph.pm60
-rw-r--r--lib/VNWeb/VN/Length.pm213
-rw-r--r--lib/VNWeb/VN/List.pm450
-rw-r--r--lib/VNWeb/VN/Page.pm637
-rw-r--r--lib/VNWeb/VN/Quotes.pm399
-rw-r--r--lib/VNWeb/VN/Tagmod.pm59
-rw-r--r--lib/VNWeb/VN/Votes.pm24
-rw-r--r--lib/VNWeb/Validation.pm205
-rw-r--r--sql/all.sql1
-rw-r--r--sql/data.sql4
-rw-r--r--sql/editfunc.sql4
-rw-r--r--sql/func.sql884
-rw-r--r--sql/perms.sql122
-rw-r--r--sql/rebuild-search-cache.sql60
-rw-r--r--sql/schema.sql975
-rw-r--r--sql/superuser_init.sql2
-rw-r--r--sql/tableattrs.sql164
-rw-r--r--sql/triggers.sql87
-rw-r--r--sql/util.sql157
-rw-r--r--sql/vndbid.sql5
-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/nonfree.svg12
-rw-r--r--static/f/notes.svg10
-rw-r--r--static/f/patreon.pngbin2481 -> 0 bytes
-rw-r--r--static/f/plat/and.svg12
-rw-r--r--static/f/plat/bdp.svg12
-rw-r--r--static/f/plat/dos.svg9
-rw-r--r--static/f/plat/drc.svg11
-rw-r--r--static/f/plat/dvd.svg7
-rw-r--r--static/f/plat/fmt.svg7
-rw-r--r--static/f/plat/gba.svg5
-rw-r--r--static/f/plat/gbc.svg6
-rw-r--r--static/f/plat/ios.svg16
-rw-r--r--static/f/plat/lin.svg18
-rw-r--r--static/f/plat/mac.svg12
-rw-r--r--static/f/plat/msx.svg12
-rw-r--r--static/f/plat/n3d.svg13
-rw-r--r--static/f/plat/nds.svg12
-rw-r--r--static/f/plat/nes.svg15
-rw-r--r--static/f/plat/oth.svg4
-rw-r--r--static/f/plat/p88.svg14
-rw-r--r--static/f/plat/p98.svg14
-rw-r--r--static/f/plat/pce.svg14
-rw-r--r--static/f/plat/pcf.svg20
-rw-r--r--static/f/plat/ps1.svg13
-rw-r--r--static/f/plat/ps2.svg8
-rw-r--r--static/f/plat/ps3.svg8
-rw-r--r--static/f/plat/ps4.svg8
-rw-r--r--static/f/plat/psp.svg8
-rw-r--r--static/f/plat/psv.svg9
-rw-r--r--static/f/plat/sat.svg44
-rw-r--r--static/f/plat/sfc.svg14
-rw-r--r--static/f/plat/swi.svg8
-rw-r--r--static/f/plat/web.svg4
-rw-r--r--static/f/plat/wii.svg11
-rw-r--r--static/f/plat/win.svg7
-rw-r--r--static/f/plat/wiu.svg12
-rw-r--r--static/f/plat/x68.svg16
-rw-r--r--static/f/plat/xb1.svg80
-rw-r--r--static/f/plat/xb3.svg99
-rw-r--r--static/f/plat/xbo.svg4
-rw-r--r--static/f/resolution_16-9.svg9
-rw-r--r--static/f/resolution_4-3.svg8
-rw-r--r--static/f/resolution_custom.svg9
-rw-r--r--static/f/story_animated.svg8
-rw-r--r--static/f/subscribestar.pngbin2900 -> 0 bytes
-rw-r--r--static/f/uncensor.svg2
-rw-r--r--static/f/voiced.svg33
-rw-r--r--util/README.md65
-rwxr-xr-xutil/dbdump.pl260
-rwxr-xr-xutil/devdump.pl111
-rwxr-xr-xutil/dl-cron.sh44
-rwxr-xr-xutil/dl-gendir.pl51
-rwxr-xr-xutil/docker-init.sh41
-rwxr-xr-xutil/hibp-dl.pl89
-rw-r--r--util/imgproc.c252
-rwxr-xr-xutil/jsgen.pl70
-rwxr-xr-xutil/multi.pl4
-rwxr-xr-xutil/pngsprite.pl (renamed from util/spritegen.pl)47
-rwxr-xr-xutil/revision-integrity.pl2
-rwxr-xr-xutil/setup-var.sh21
-rwxr-xr-xutil/sqleditfunc.pl17
-rwxr-xr-xutil/svgsprite.pl54
-rw-r--r--util/test/basn4a08.pngbin0 -> 126 bytes
-rw-r--r--util/test/basn6a16.pngbin0 -> 3435 bytes
-rwxr-xr-xutil/test/bbcode.pl (renamed from util/bbcode-test.pl)25
-rwxr-xr-xutil/test/imgproc-custom.pl76
-rw-r--r--util/test/xd9n2c08.pngbin0 -> 145 bytes
-rwxr-xr-xutil/unusedimages.pl28
-rwxr-xr-xutil/updates/2021-01-21-update-saved-queries.pl (renamed from util/saved-queries.pl)0
-rw-r--r--util/updates/2021-03-07-platforms.sql9
-rw-r--r--util/updates/2021-03-11-platform-mobile.sql1
-rw-r--r--util/updates/2021-03-11-tag-history.sql89
-rw-r--r--util/updates/2021-03-16-release-dlsiteen.sql16
-rw-r--r--util/updates/2021-03-23-trait-history.sql74
-rw-r--r--util/updates/2021-04-09-item-info.sql2
-rw-r--r--util/updates/2021-05-05-latin-language.sql1
-rw-r--r--util/updates/2021-05-14-releases-lang-mtl.sql4
-rw-r--r--util/updates/2021-05-21-tt-primary-parent.sql17
-rw-r--r--util/updates/2021-05-25-users-shadow.sql19
-rw-r--r--util/updates/2021-05-25-users-vnlang.sql1
-rw-r--r--util/updates/2021-06-04-vn-developers-and-average-cache.sql11
-rw-r--r--util/updates/2021-06-22-indi-urdu-languages.sql2
-rw-r--r--util/updates/2021-06-28-lockdown-mode.sql13
-rw-r--r--util/updates/2021-07-24-more-wikidata-ids.sql3
-rw-r--r--util/updates/2021-07-28-merge-imgmod.sql2
-rw-r--r--util/updates/2021-07-30-vn-length-voting.sql17
-rw-r--r--util/updates/2021-08-03-vnlength-speed.sql6
-rw-r--r--util/updates/2021-08-04-vnlength-index.sql2
-rw-r--r--util/updates/2021-08-08-lengthvote-ignore.sql1
-rw-r--r--util/updates/2021-08-09-vnlength-multirelease.sql4
-rw-r--r--util/updates/2021-08-09b-vnlength-primarykey.sql28
-rw-r--r--util/updates/2021-09-02-some-foreign-key-stuff.sql5
-rw-r--r--util/updates/2021-09-26-vn-length-cache.sql6
-rw-r--r--util/updates/2021-10-27-freegame-mugen.sql3
-rw-r--r--util/updates/2021-10-28-username-casefold.sql2
-rw-r--r--util/updates/2021-10-28-username-history.sql16
-rw-r--r--util/updates/2021-10-28-website-length.sql4
-rwxr-xr-xutil/updates/2021-10-29-fix-thumbnail-resolution.pl50
-rw-r--r--util/updates/2021-11-07-posts-hidden-msg.sql17
-rw-r--r--util/updates/2021-11-07-threads-board-lock.sql1
-rw-r--r--util/updates/2021-11-15-release-vn-type.sql12
-rw-r--r--util/updates/2021-11-15-reviews-fulltext-search.sql2
-rw-r--r--util/updates/2021-11-18-release-search.sql3
-rw-r--r--util/updates/2021-11-19-more-search.sql9
-rw-r--r--util/updates/2021-11-19-vn-search.sql7
-rw-r--r--util/updates/2021-11-24-tagtrait-search.sql2
-rw-r--r--util/updates/2021-11-29-release-unknown-uncensored.sql5
-rw-r--r--util/updates/2021-12-06-extlinks-playstation-stores.sql13
-rw-r--r--util/updates/2021-12-15-api-sessions.sql3
-rw-r--r--util/updates/2022-02-05-popularity-non-null.sql7
-rw-r--r--util/updates/2022-02-11-vn-titles.sql41
-rw-r--r--util/updates/2022-02-12-chinese-languages.sql30
-rw-r--r--util/updates/2022-02-19-vnt-sorttitle.sql3
-rw-r--r--util/updates/2022-03-23-vn-length-votes-uncounted.sql6
-rw-r--r--util/updates/2022-03-29-lengthvotes-private.sql3
-rw-r--r--util/updates/2022-03-29-release-animation.sql29
-rw-r--r--util/updates/2022-04-01-user-traits.sql8
-rw-r--r--util/updates/2022-04-05-releases-has-ero.sql5
-rw-r--r--util/updates/2022-04-19-vn-default-poprank.sql1
-rw-r--r--util/updates/2022-04-23-inuktitut-language.sql1
-rw-r--r--util/updates/2022-06-16-users-debloat.sql90
-rw-r--r--util/updates/2022-06-18-user-prefs-prodrelexpand.sql1
-rw-r--r--util/updates/2022-06-19-user-prefs-vnrel.sql31
-rw-r--r--util/updates/2022-06-20-changes-patrolling.sql8
-rw-r--r--util/updates/2022-06-21-tags-vn-lie.sql1
-rw-r--r--util/updates/2022-07-31-vn-devstatus.sql24
-rw-r--r--util/updates/2022-08-03-tags_vn_direct.sql10
-rw-r--r--util/updates/2022-08-24-ipinfo.sql17
-rw-r--r--util/updates/2022-08-25-customcss-csum.sql3
-rw-r--r--util/updates/2022-08-25-staff-editions.sql43
-rw-r--r--util/updates/2022-08-28-basque-language.sql1
-rw-r--r--util/updates/2022-08-30-tag-trait-prefs.sql23
-rw-r--r--util/updates/2022-09-28-release-titles.sql81
-rw-r--r--util/updates/2022-10-08-images-smallints.sql19
-rw-r--r--util/updates/2022-10-16-release-shop-links.sql11
-rw-r--r--util/updates/2022-10-22-tags_vn_inherit-lie.sql4
-rw-r--r--util/updates/2022-10-27-trait-lies.sql5
-rw-r--r--util/updates/2022-10-31-ulist-vns-labels.sql137
-rw-r--r--util/updates/2022-11-11-serbian-language.sql1
-rw-r--r--util/updates/2022-11-29-api2-tokens.sql9
-rw-r--r--util/updates/2022-12-13-users-prefs-timezone.sql1
-rw-r--r--util/updates/2022-12-18-sql-tags-cache-merge.sql8
-rw-r--r--util/updates/2022-12-19-sql-traits-chars-cache-merge.sql5
-rw-r--r--util/updates/2022-12-19-sql-unique-null-not-distinct.sql12
-rw-r--r--util/updates/2023-01-08-cherokee-language.sql1
-rw-r--r--util/updates/2023-01-17-api2-listwrite.sql3
-rw-r--r--util/updates/2023-01-19-delete-admin-setpass.sql1
-rw-r--r--util/updates/2023-02-01-sql-titleprefs.sql67
-rw-r--r--util/updates/2023-02-02-sql-titleprefs.sql5
-rw-r--r--util/updates/2023-02-04-producerst.sql15
-rw-r--r--util/updates/2023-02-19-title-langs.sql5
-rw-r--r--util/updates/2023-02-20-titleprefs-staff.sql18
-rw-r--r--util/updates/2023-02-21-tt-prefs.sql7
-rw-r--r--util/updates/2023-03-09-chars-lang.sql10
-rw-r--r--util/updates/2023-03-09b-chars-titleprefs.sql14
-rw-r--r--util/updates/2023-03-20-producer-name-swap.sql13
-rw-r--r--util/updates/2023-03-20b-chars-name-swap.sql12
-rw-r--r--util/updates/2023-03-20c-staff-name-swap.sql14
-rw-r--r--util/updates/2023-03-24-search-cache.sql44
-rw-r--r--util/updates/2023-04-03-extlinks-booth.sql57
-rw-r--r--util/updates/2023-04-05-extlinks-patreon-substar.sql102
-rw-r--r--util/updates/2023-04-19-images-uploader.sql29
-rw-r--r--util/updates/2023-04-19-jastusa-shoplinks.sql8
-rw-r--r--util/updates/2023-05-03-sql-noquote.sql23
-rw-r--r--util/updates/2023-06-19-tags-vn-direct-count.sql4
-rw-r--r--util/updates/2023-07-11-vn-rating.sql8
-rw-r--r--util/updates/2023-09-15-quotes-rand.sql37
-rw-r--r--util/updates/2023-09-17-wikidata-props.sql3
-rw-r--r--util/updates/2023-09-21-reset-throttle.sql5
-rw-r--r--util/updates/2023-10-14-drm.sql7
-rw-r--r--util/updates/2023-12-03-staff-aid.sql11
-rw-r--r--util/updates/2023-12-03-staff-extlinks.sql24
-rw-r--r--util/updates/2024-02-23-quotes.sql89
-rw-r--r--util/updates/2024-02-26-quotes-adjustments.sql12
-rw-r--r--util/updates/2024-03-01-reports-log.sql14
-rw-r--r--util/updates/2024-03-08-belarusian-language.sql1
-rw-r--r--util/updates/2024-03-14-sql-email-normalization.sql9
-rw-r--r--util/updates/2024-03-20-account-softdelete.sql11
-rw-r--r--util/updates/2024-03-22-delayed-account-deletion.sql4
-rw-r--r--util/updates/README.md51
-rwxr-xr-xutil/vndb-dev-server.pl10
-rwxr-xr-xutil/vndb.pl100
637 files changed, 24907 insertions, 10815 deletions
diff --git a/.gitignore b/.gitignore
index a858c2a1..ca825c86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,22 +1,9 @@
-/data/conf.pl
-/data/docker-pg
-/data/icons/icons.css
-/data/log/
-/data/multi.pid
-/data/passwords.dat
-/elm/elm-stuff/
-/elm/Gen/
-/static/g
-/static/ch
-/static/cv
-/static/sf
-/static/st
-/static/robots.txt
-/static/api
-/sql/editfunc.sql
-/www/
+/docker/
+/gen/
+/var/
*.swp
*.o
*.so
*.dll
*.bc
+*~
diff --git a/Dockerfile b/Dockerfile
index 881324f4..9738ccc5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,23 +1,27 @@
-FROM alpine:3.13
+FROM alpine:3.17
MAINTAINER Yorhel <contact@vndb.org>
-ENV VNDB_DOCKER_VERSION=7
-CMD /var/www/util/docker-init.sh
+ENV VNDB_DOCKER_VERSION=14
+ENV VNDB_GEN=/vndb/docker/gen
+ENV VNDB_VAR=/vndb/docker/var
+CMD /vndb/util/docker-init.sh
RUN apk add --no-cache \
build-base \
curl \
git \
graphviz \
- imagemagick \
+ 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 \
@@ -28,7 +32,6 @@ RUN apk add --no-cache \
AnyEvent::Pg \
Crypt::ScryptKDF \
Crypt::URandom \
- HTTP::Server::Simple \
PerlIO::gzip \
SQL::Interp \
Text::MultiMarkdown \
diff --git a/Makefile b/Makefile
index dbba4695..6184d3e9 100644
--- a/Makefile
+++ b/Makefile
@@ -5,128 +5,201 @@
# Create static assets for production. Requires the following additional dependencies:
# - uglifyjs
# - zopfli
+# - zopflipng
+# - brotli
+# - pandoc
#
-# chmod
-# For when the http process is run from a different user than the files are
-# chown'ed to. chmods all files and directories written to from vndb.pl.
-#
-# multi-start, multi-stop, multi-restart:
-# Start/stop/restart the Multi daemon. Provided for convenience, a proper initscript
-# probably makes more sense.
-#
-# NOTE: This Makefile has only been tested using a recent version of GNU make
-# in a relatively up-to-date Arch/Gentoo Linux environment, and may not work in
-# other environments. Patches to improve the portability are always welcome.
-
-
-.PHONY: all prod chmod multi-stop multi-start multi-restart
-
-ALL_KEEP=\
- static/ch static/cv static/sf static/st \
- data/log static/g www www/api \
- data/conf.pl \
- www/robots.txt static/robots.txt
-
-ALL_CLEAN=\
- static/g/plain.js \
- static/g/elm.js \
- data/icons/icons.css \
- sql/editfunc.sql \
- $(shell ls css/skins/*.sass | sed -e 's/css\/skins\/\(.\+\)\.sass/static\/g\/\1.css/g')
-
-PROD=\
- static/g/plain.min.js static/g/plain.min.js.gz \
- static/g/elm.min.js static/g/elm.min.js.gz \
- static/g/icons.opt.png \
- $(shell ls css/skins/*.sass | sed -e 's/css\/skins\/\(.\+\)\.sass/static\/g\/\1.css.gz/g')
-
-all: ${ALL_KEEP} ${ALL_CLEAN}
-prod: all ${PROD}
+# test
+# Run the few unit tests that we do have.
+
+.PHONY: all prod clean test multi-stop multi-start multi-restart
+.DELETE_ON_ERROR:
+
+VNDB_GEN ?= gen
+export VNDB_GEN
+GEN=${VNDB_GEN}
+
+CFLAGS ?= -O3 -Wall
+
+ifdef V
+Q=
+T=@\#
+E=@\#
+else
+Q=@
+E=@echo
+T=@printf "%s $@\n"
+endif
+
+JS_OUT=$(patsubst js/%/index.js,${GEN}/static/%.js,$(wildcard js/*/index.js))
+CSS_OUT=$(patsubst css/skins/%.sass,${GEN}/static/%.css,$(wildcard css/skins/*.sass))
+
+all: \
+ ${GEN}/editfunc.sql \
+ ${GEN}/static/icons.svg \
+ ${GEN}/static/icons.png \
+ ${GEN}/static/elm.js \
+ ${GEN}/imgproc \
+ ${JS_OUT} \
+ ${CSS_OUT}
+
+prod: all \
+ ${GEN}/api-nyan.html ${GEN}/api-kana.html \
+ ${GEN}/static/icons.svg.gz ${GEN}/static/icons.svg.br \
+ ${GEN}/static/icons.opt.png \
+ ${GEN}/static/elm.min.js ${GEN}/static/elm.min.js.gz ${GEN}/static/elm.min.js.br \
+ ${JS_OUT:js=min.js} ${JS_OUT:js=min.js} \
+ ${JS_OUT:js=min.js.gz} ${JS_OUT:js=min.js.gz} \
+ ${JS_OUT:js=min.js.br} ${JS_OUT:js=min.js.br} \
+ ${CSS_OUT:css=css.gz} ${CSS_OUT:css=css.br}
clean:
- rm -f ${ALL_CLEAN} ${PROD}
- rm -f static/g/icons.png
- rm -f static/f/{vndb,elm,plain}{,.min}.js{,.gz} static/f/icons{,.opt}.png static/s/*/style{,.min}.css{,.gz} static/s/*/boxbg.png
- rm -f static/g/vndb{,.min}.js{,.gz}
- rm -rf elm/Gen/
- rm -rf elm/elm-stuff/build-artifacts
- $(MAKE) -C sql/c clean
-
-cleaner: clean
- rm -rf elm/elm-stuff
-
-sql/editfunc.sql: util/sqleditfunc.pl sql/schema.sql
- util/sqleditfunc.pl
-
-static/ch static/cv static/sf static/st:
- mkdir -p $@;
- for i in $$(seq -w 0 1 99); do mkdir -p "$@/$$i"; done
-
-data/log www www/api static/g:
- mkdir -p $@
-
-data/conf.pl:
- cp -n data/conf_example.pl data/conf.pl
-
-%/robots.txt: | www
- echo 'User-agent: *' > $@
- echo 'Disallow: /' >> $@
+ rm -rf "${GEN}"
%.gz: %
zopfli $<
-chmod: all
- chmod -R a-x+rwX static/{ch,cv,sf,st}
-
-
-
-data/icons/icons.css: data/icons/*.png data/icons/*/*.png util/spritegen.pl | static/g
- util/spritegen.pl
-
-static/g/icons.png: data/icons/icons.css
-
-static/g/icons.opt.png: static/g/icons.png
- rm -f $@
- zopflipng -m --lossy_transparent $< $@
-
-static/g/%.css: css/skins/%.sass css/v2.css data/icons/icons.css | static/g
- ( echo '$$icons-version: "$(shell sha1sum static/g/icons.png | head -c8)";'; echo '@import "css/skins/$*"' ) | sassc --stdin -I. --style compressed >$@
-
-
-
-# Order of JS files matters, so we read an '//order:x' comment from the files and sort by that.
-# Files without that comment are assumed to have '//order:4'.
-# (This trick will not work if we ever add JS files generated by this Makefile)
-JS_FILES=$(shell find elm \! -path 'elm/elm-stuff/*' -name '*.js' -exec sh -c "echo \`grep -Eo '^// *order: *[0-9]+' \"{}\" || echo 4\` \"{}\"" \; | sed -E 's/\/\/ *order: *//' | sort | sed 's/..//')
-
-static/g/plain.js: ${JS_FILES} | static/g
- echo '// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only' > $@~
- echo '// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/elm' >>$@~
- echo '// SPDX-License-Identifier: AGPL-3.0-only' >>$@~
- for fn in ${JS_FILES}; do \
- echo; \
- echo "(function(){'use strict'; /* $$fn */"; \
- cat $$fn; \
- echo "})();"; \
- done >>$@~
- echo '// @license-end' >>$@~
- mv $@~ $@
-
-static/g/plain.min.js: static/g/plain.js
- uglifyjs $< --comments '/(@license|@source|SPDX-)/' --compress \
- 'pure_getters,keep_fargs=false,unsafe_comps,unsafe'\
- | uglifyjs --mangle --comments all -o $@
+%.br: %
+ brotli -f $<
+ @touch $@
+${GEN} ${GEN}/static ${GEN}/js ${GEN}/elm/Gen:
+ mkdir -p $@
-ELM_FILES=elm/*.elm elm/*/*.elm
+${GEN}/editfunc.sql: util/sqleditfunc.pl sql/schema.sql | ${GEN}
+ util/sqleditfunc.pl >$@
+
+${GEN}/api-%.html: api-%.md | ${GEN}
+ $T DOC
+ $Q pandoc "$<" -st html5 --toc -o "$@"
+
+test: all
+ prove util/test/bbcode.pl
+ if [ -e ${GEN}/imgproc-custom ]; then util/test/imgproc-custom.pl; fi
+
+
+
+
+###### Icons & CSS #####
+
+# Single rule for svg & png sprites. This uses a GNU multiple pattern rule in
+# order to have it parallelize correctly - splitting this up into two
+# individual rules is buggy.
+${GEN}/%.css ${GEN}/static/icons.%: util/%sprite.pl icons icons/* icons/*/* | ${GEN}/static
+ $<
+
+${GEN}/static/png.css ${GEN}/static/icons.png: ${GEN}/imgproc
+
+${GEN}/static/icons.opt.png: ${GEN}/static/icons.png
+ $T PNGOPT
+ $Q zopflipng -ym --lossy_transparent "$<" "$@" >/dev/null
+
+${GEN}/static/%.css: css/skins/%.sass css/*.css ${GEN}/png.css ${GEN}/svg.css
+ $T SASS
+ $Q ( echo '$$png-version: "$(shell sha1sum ${GEN}/static/icons.png | head -c8)";'; \
+ echo '$$svg-version: "$(shell sha1sum ${GEN}/static/icons.svg | head -c8)";'; \
+ echo '@import "css/skins/$*";'; \
+ echo '@import "${GEN}/png";'; \
+ echo '@import "${GEN}/svg";'; \
+ ) | sassc --stdin -I. --style compressed >$@
+
+
+
+
+###### imgproc #####
+
+${GEN}/imgproc: util/imgproc.c
+ $T CC
+ $Q ${CC} ${CFLAGS} $< -DDISABLE_SECCOMP `pkg-config --cflags --libs vips` -o $@
+
+VIPS_VER := 8.15.1
+# TODO: switch to a proper release when it includes this commit
+JXL_VER := 5e7560d9e431b40159cf688b9d9be6c0f2e229a1
+
+VIPS_DIR := ${GEN}/build/vips-${VIPS_VER}
+JXL_DIR := ${GEN}/build/libjxl-${JXL_VER}
+
+${GEN}/imgproc-custom: util/imgproc.c ${VIPS_DIR}/done Makefile
+ ${CC} ${CFLAGS} $< `pkg-config --cflags --libs libseccomp` `PKG_CONFIG_PATH="$(realpath ${GEN})/build/inst/lib64/pkgconfig" pkg-config --cflags --libs vips` -o $@
+ @# Make sure we're not accidentally linking against system libjpeg
+ ! ldd $@ | grep -q libjpeg
+
+# jpeg, jpgeg-xl and highway are provided by jxl
+${VIPS_DIR}/done: ${JXL_DIR}/done
+ mkdir -p ${VIPS_DIR}
+ curl -Ls https://github.com/libvips/libvips/releases/download/v${VIPS_VER}/vips-${VIPS_VER}.tar.xz | tar -C ${VIPS_DIR} --strip-components 1 -xJf-
+ PKG_CONFIG_PATH="$(realpath ${GEN})/build/inst/lib64/pkgconfig" meson \
+ setup --wipe --default-library=static --prefix="$(realpath ${GEN})/build/inst" \
+ -Dpng=enabled -Djpeg=enabled -Djpeg-xl=enabled -Dwebp=enabled -Dheif=enabled -Dlcms=enabled -Dhighway=enabled \
+ -Ddeprecated=false -Dexamples=false -Dcplusplus=false \
+ -Dmodules=disabled -Dintrospection=disabled -Dcfitsio=disabled -Dcgif=disabled \
+ -Dexif=disabled -Dfftw=disabled -Dfontconfig=disabled -Darchive=disabled \
+ -Dimagequant=disabled -Dmagick=disabled -Dmatio=disabled -Dnifti=disabled -Dopenjpeg=disabled \
+ -Dopenslide=disabled -Dorc=disabled -Dpangocairo=disabled \
+ -Dpdfium=disabled -Dpoppler=disabled -Dquantizr=disabled -Drsvg=disabled \
+ -Dspng=disabled -Dtiff=disabled -Dzlib=disabled \
+ -Dnsgif=false -Dppm=false -Danalyze=false -Dradiance=false \
+ ${VIPS_DIR}/build ${VIPS_DIR}
+ cd ${VIPS_DIR}/build && meson compile && meson install
+ touch $@
+
+${JXL_DIR}/done:
+ mkdir -p ${JXL_DIR}
+ @#curl -Ls https://github.com/libjxl/libjxl/archive/refs/tags/v${JXL_VER}.tar.gz | tar -C $@ --strip-components 1 -xzf-
+ curl -Ls https://github.com/libjxl/libjxl/tarball/${JXL_VER} | tar -C ${JXL_DIR} --strip-components 1 -xzf-
+ cd ${JXL_DIR} && ./deps.sh
+ @# there's no option to build a static jpegli, patch the cmake file instead
+ sed -i 's/add_library(jpeg SHARED/add_library(jpeg STATIC/' ${JXL_DIR}/lib/jpegli.cmake
+ cd ${JXL_DIR} && cmake -L \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DBUILD_TESTING=OFF \
+ -DBUILD_SHARED_LIBS=OFF \
+ -DCMAKE_INSTALL_PREFIX="$(realpath ${GEN})/build/inst" \
+ -DJPEGXL_ENABLE_BENCHMARK=OFF \
+ -DJPEGXL_ENABLE_DOXYGEN=OFF \
+ -DJPEGXL_ENABLE_EXAMPLES=OFF \
+ -DJPEGXL_ENABLE_FUZZERS=OFF \
+ -DJPEGXL_ENABLE_JNI=OFF \
+ -DJPEGXL_ENABLE_JPEGLI=ON \
+ -DJPEGXL_ENABLE_JPEGLI_LIBJPEG=ON \
+ -DJPEGXL_INSTALL_JPEGLI_LIBJPEG=OFF \
+ -DJPEGXL_ENABLE_MANPAGES=OFF \
+ -DJPEGXL_ENABLE_OPENEXR=OFF \
+ -DJPEGXL_ENABLE_PLUGINS=OFF \
+ -DJPEGXL_ENABLE_SJPEG=OFF \
+ -DJPEGXL_ENABLE_TOOLS=OFF .
+ cd ${JXL_DIR} && cmake --build . -- -j`nproc`
+ cd ${JXL_DIR} && cmake --install .
+ @# jxl doesn't install a libjpeg.pc
+ @# It doesn't even install a static libjpeg.a at all, so we'll just grab it from the build dir directly.
+ @( \
+ echo "Name: libjpeg"; \
+ echo "Description: Actually jpegli"; \
+ echo "Version: 1.0"; \
+ echo "Libs: -L$(realpath ${JXL_DIR})/lib -L$(realpath ${GEN})/build/inst/lib64 -ljpeg -ljpegli-static -lhwy -lm -lstdc++"; \
+ echo "Cflags: -I$(realpath ${JXL_DIR})/lib/include/jpegli" \
+ ) >${GEN}/build/inst/lib64/pkgconfig/libjpeg.pc
+ @# Additionally, pkg-config doesn't know we're linking these libs statically, so
+ @# make sure that libs in Requires.private are also included.
+ sed -i 's/Requires.private/Requires/' ${GEN}/build/inst/lib64/pkgconfig/*.pc
+ touch $@
+
+
+
+
+###### Elm #####
+
+ELM_FILES=elm/elm.json $(wildcard elm/*.elm elm/*/*.elm)
+ELM_CPFILES=${ELM_FILES:%=${GEN}/%}
ELM_MODULES=$(shell grep -l '^main =' ${ELM_FILES} | sed 's/^elm\///')
# Patch the Javascript generated by Elm:
# - 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
- ( echo '// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only'; \
+ $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'; \
@@ -135,50 +208,72 @@ define fix-elm
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" >$@~
- mv $@~ $@
+ | sed -E "s/([^ ]+) !== 'checked'/\\1 !== 'checked' \&\& \\1 !== 'selected'/g" \
+ | sed -E "s/var flags = 'g'/var flags = 'gu'/g" >$@~
+ $Q mv $@~ $@
endef
-elm/Gen/.generated: lib/VNWeb/*.pm lib/VNWeb/*/*.pm lib/VNDB/Types.pm lib/VNDB/ExtLinks.pm lib/VNDB/Config.pm data/conf.pl
+${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
-static/g/elm.js: ${ELM_FILES} elm/Gen/.generated | static/g
- cd elm && ELM_HOME=elm-stuff elm make ${ELM_MODULES} --output ../$@
+${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}
-static/g/elm.min.js: ${ELM_FILES} elm/Gen/.generated | static/g
- cd elm && ELM_HOME=elm-stuff elm make --optimize ${ELM_MODULES} --output ../$@
+${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}
- uglifyjs $@ --comments '/(@license|@source|SPDX-)/' --compress \
+ $T MINIFY
+ $Q uglifyjs $@ --comments '/(@license|@source|SPDX-)/' --compress \
'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe'\
| uglifyjs --mangle --comments all -o $@~
- mv $@~ $@
+ $Q mv $@~ $@
-# Multi
-# may wait indefinitely, ^C and kill -9 in that case
-define multi-stop
- if [ -s data/multi.pid ]; then\
- kill `cat data/multi.pid`;\
- while [ -s data/multi.pid ]; do\
- if kill -0 `cat data/multi.pid`; then sleep 1;\
- else rm -f data/multi.pid; fi\
- done;\
- fi
-endef
+###### Javascript #####
-define multi-start
- util/multi.pl
-endef
+${GEN}/jsdeps.mk: js/*/index.js | ${GEN}
+ $E JSDEP
+ $Q for f in $(patsubst js/%/index.js,%,$(wildcard js/*/index.js)); do \
+ deps=$$(grep '^@include ' js/$$f/index.js | sed -e "s/@include / js\\/$$f\\//" -e "s/js\\/$$f\\/\.gen/\$${GEN}/" | tr -d '\n'); \
+ echo "\$${GEN}/static/$$f.js: js/$$f/index.js$$deps";echo; \
+ done >$@
+
+include ${GEN}/jsdeps.mk
+
+${GEN}/mithril.js:
+ $T FETCH
+ $Q curl -s 'https://code.blicky.net/yorhel/mithril-vndb/raw/branch/next/mithril.js' -o $@
+
+# TODO: Custom bundle with only the stuff we use
+${GEN}/d3.js:
+ $T FETCH
+ $Q curl -s 'https://d3js.org/d3.v7.min.js' -o $@
+
+${GEN}/types.js: util/jsgen.pl lib/VNDB/Types.pm lib/VNWeb/Validation.pm
+ util/jsgen.pl types >$@
+
+${GEN}/user.js: util/jsgen.pl lib/VNWeb/TimeZone.pm
+ util/jsgen.pl user >$@
-multi-stop:
- $(multi-stop)
+${GEN}/extlinks.js: util/jsgen.pl lib/VNDB/ExtLinks.pm
+ util/jsgen.pl extlinks >$@
-multi-start:
- $(multi-start)
+${JS_OUT}: ${GEN}/static/%.js: | ${GEN}/static
+ $T JS
+ $Q perl -Mautodie -pe 'if(/^\@include (.+)/) { #\
+ $$n=$$1; open F, $$n =~ m#^\.gen/# ? $$n =~ s#^\.gen/#$$ENV{VNDB_GEN}/#r : "js/$*/$$n"; #\
+ local$$/=undef; $$_="/* start of $$n */\n(()=>{\n".<F>."})();\n/* end of $$1 */\n\n" #\
+ }' js/$*/index.js >$@
-multi-restart:
- $(multi-stop)
- $(multi-start)
+${JS_OUT:js=min.js}: %.min.js: %.js
+ $T MINIFY
+ $Q uglifyjs $< --comments '/(@license|@source|SPDX-)/' --compress 'pure_getters,keep_fargs=false,unsafe_comps,unsafe' | uglifyjs --mangle --comments all -o $@
diff --git a/README.md b/README.md
index f98ea8b9..49b6fdb1 100644
--- a/README.md
+++ b/README.md
@@ -19,44 +19,108 @@ these on the [discussion board](https://vndb.org/t/db) first so everyone can
chime in with ideas.
+## Directory layout
+
+css/
+: CSS files. The files in *css/skins/* are processed with *sassc* and bunbled
+ into a single minified CSS file for each skin.
+
+elm/
+: Front-end code written in [Elm](https://elm-lang.org/). These files are
+ compiled and bundled into a single minified *elm.js* file. Elm is on the
+ way out, though, and this code is slowly rewritten into plain Javascript.
+
+icons/
+: SVG & PNG icons that are merged into a *icons.svg* and *icons.png* sprite
+ file. See *icons/README.md* for more details.
+
+js/
+: Front-end code written in Javascript. See *js/README.md* for more details.
+
+lib/
+: This is where all the backend Perl code lives. Notable subdirectories:
+
+ Multi/
+ : Single-process event-based application that runs the old API and
+ various background services.
+
+ VNDB/
+ : General utility modules shared between *Multi*, *VNWeb* and some
+ tools in *util/*.
+
+ VNWeb/
+ : The VNDB website backend, this code makes heavy use of
+ [TUWF](https://dev.yorhel.nl/tuwf).
+
+sql/
+: PostgreSQL script files to initialize a fresh database schema with all
+ assorted tables, functions, indices and attributes. Most of these scripts
+ are idempotent and can also be used to load new features into an existing
+ database, but see the *util/updates/README.md* for more details.
+
+static/
+: Static assets. *static/s/* contains images used by CSS skins and
+ miscellaneous files go into *static/f/*.
+
+util/
+: Command-line utilities for various tasks. See *util/README.md* for details.
+
+With some exceptions, commands and scripts generally assume that they are run
+from this top-level source directory.
+
+Directories not in this source repository, but still very important:
+
+gen/ (or `$VNDB_GEN`)
+: This is where all build-time generated files go, such as optimized static
+ assets, compiled code and intermediate build artifacts. This is essentially
+ the output directory for everything created by the top-level `Makefile`.
+
+ This directory can be freely deleted at any time, it can be recreated with
+ `make`.
+
+ This directory can be changed by setting the `VNDB_GEN` environment
+ variable. Just be sure to have this variable set and pointed to the same
+ directory for every VNDB-related command you run. This variable and the
+ full path it points to must not contain any spaces since the Makefile can't
+ handle that.
+
+var/ (or `$VNDB_VAR`)
+: The directory for run-time managed files, such as configuration, logs and
+ uploaded images. This is also where you can store other site-specific
+ files. Additional public assets can be saved into *var/static/*.
+
+
## Quick and dirty setup using Docker
Setup:
```
- docker build -t vndb .
+docker build --progress=plain -t vndb .
```
Run (will run on the foreground):
```
- docker run -ti --name vndb -p 3000:3000 -v "`pwd`":/var/www --rm vndb
+docker run -ti --name vndb -p 3000:3000 -v "`pwd`":/vndb --rm vndb
```
If you need another terminal into the container while it's running:
```
- docker exec -ti vndb su -l devuser # development shell (files are at /var/www)
- docker exec -ti vndb psql -U vndb # postgres shell
+docker exec -ti vndb su -l devuser # development shell (files are at /vndb)
+docker exec -ti vndb psql -U vndb # postgres shell
```
To start Multi, the optional application server:
```
- docker exec -ti vndb su -l devuser -c 'make -C /var/www multi-restart'
+docker exec -ti vndb su -l devuser -c /vndb/util/multi.pl
```
-It will run in the background for as long as the container is alive. Logs are
-written to `data/log/multi.log`.
-
-The PostgreSQL database will be stored in `data/docker-pg/` and the uploaded
-files in `static/{ch,cv,sf,st}`. If you want to restart with a clean slate, you
-can stop the container and run:
-
-```
- # Might want to make a backup of these dirs first if you have any interesting data.
- rm -rf data/docker-pg static/{ch,cv,sf,st}
-```
+All data is stored in the *docker/* directory. The `$VNDB_GEN` and `$VNDB_VAR`
+environment variables inside the container point into this directory and the
+PostgreSQL data files are also in there. If you want to restart with a clean
+slate, you can stop the container and delete or rename that directory.
## Requirements (when not using Docker)
@@ -64,12 +128,12 @@ can stop the container and run:
Global requirements:
- Linux, or an OS that resembles Linux. Chances are VNDB won't run on Windows.
-- A standard C build system (make/gcc/etc)
-- PostgreSQL 13+ (including development files)
-- Perl 5.26+
+- A standard C build system (GNU make, gcc/clang, etc)
+- PostgreSQL 15+ (including development files)
+- Perl 5.28+
- Elm 0.19.1
- Graphviz
-- ImageMagick
+- libvips
- sassc
**Perl modules** (core modules are not listed):
@@ -104,55 +168,117 @@ util/multi.pl (application server, optional):
- 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
- Build the *vndbfuncs* PostgreSQL library:
```
- make -C sql/c
+make -C sql/c
```
-- Copy `sql/c/vndbfuncs.so` to the appropriate directory (either run
+- Copy *sql/c/vndbfuncs.so* to the appropriate directory (either run
`sudo make -C sql/c install` or see `pg_config --pkglibdir` or
`SHOW dynamic_library_path`)
- Initialize the VNDB database (assuming 'postgres' is a superuser):
```
- # Create the database & roles
- psql -U postgres -f sql/superuser_init.sql
- psql -U postgres vndb -f sql/vndbid.sql
-
- # Set a password for each database role:
- echo "ALTER ROLE vndb LOGIN PASSWORD 'pwd1'" | psql -U postgres
- echo "ALTER ROLE vndb_site LOGIN PASSWORD 'pwd2'" | psql -U postgres
- echo "ALTER ROLE vndb_multi LOGIN PASSWORD 'pwd3'" | psql -U postgres
-
- # OPTION 1: Create an empty database:
- psql -U vndb -f sql/all.sql
-
- # OPTION 2: Import the development database (https://vndb.org/d8#3):
- curl -L https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -xzf-
- psql -U vndb -f dump.sql
- rm dump.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 `data/conf.pl` with the proper credentials for *vndb_site* and
+- Update *var/conf.pl* with the proper credentials for *vndb_site* and
*vndb_multi*.
- Now simply run:
```
- util/vndb-dev-server.pl
+util/vndb-dev-server.pl
```
- (Optional) To start Multi, the application server:
```
- make multi-restart
+make multi-restart
```
+## 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
-GNU AGPL, see COPYING file for details.
+AGPL-3.0-only, see COPYING file for details.
diff --git a/api-kana.md b/api-kana.md
new file mode 100644
index 00000000..dc66cc05
--- /dev/null
+++ b/api-kana.md
@@ -0,0 +1,1704 @@
+---
+title: VNDB.org API v2 (Kana)
+header-includes: |
+ <style>
+ body { max-width: 900px }
+ td { vertical-align: top }
+ header, header h1 { margin: 0 }
+ @media (min-width: 1100px) {
+ body { margin: 0 0 0 270px }
+ nav { box-sizing: border-box; position: fixed; padding: 50px 20px 10px 10px; top: 0; left: 0; height: 100%; overflow: scroll }
+ }
+ </style>
+---
+
+# Introduction
+
+This document describes the HTTPS API to query information from the
+[VNDB](https://vndb.org/) database and manage user lists.
+
+This version of the API is intended to replace the [old TCP-based
+API](https://vndb.org/d11), although the old API will likely remain available
+for the forseeable future.
+
+**Status**: Stable, but still missing some functionality.
+
+**API endpoint**: `%endpoint%`
+
+A sandbox endpoint is available for testing and development at
+[https://beta.vndb.org/api/kana](https://beta.vndb.org/api/kana), for more
+information see [the sandbox](https://beta.vndb.org/about-sandbox).
+
+# Usage Terms
+
+This service is free for non-commercial use. The API is provided on a
+best-effort basis, no guarantees are made about the stability or applicability
+of this service.
+
+The data obtained through this API is subject to our [Data
+License](https://vndb.org/d17#4).
+
+API access is rate-limited in order to keep server resources in check. The
+server will allow up to 200 requests per 5 minutes and up to 1 second of
+execution time per minute. Requests taking longer than 3 seconds will be
+aborted. These limits should be more than enough for most applications, but if
+this is still too limiting for you, don't hesitate to get in touch.
+
+This API intentionally does not expose *all* functionality provided by VNDB.
+Some site features, such as forums, database editing or account creation will
+not be exposed through the API, other features may be missing simply because
+nobody has asked for it yet. If you need anything not yet provided by the API
+or if you have any other questions, feel free to post on [the
+forums](https://vndb.org/t/db), [the issue
+tracker](https://code.blicky.net/yorhel/vndb/issues) or mail
+[contact@vndb.org](mailto:contact@vndb.org).
+
+
+# Common Data Types
+
+vndbid
+: A 'vndbid' is an identifier for an entry in the database, typically
+ formatted as a number with a one or two character prefix, e.g. "v17" refers
+ to [this visual novel](https://vndb.org/v17) and "sf190" refers to [this
+ screenshot](https://vndb.org/img/sf190).
+: The API will return vndbids as a JSON string, but the filters also accept
+ bare integers if the prefix is unambiguous from the context.
+
+release date
+: Release dates are represented as JSON strings as either `"YYYY-MM-DD"`,
+ `"YYYY-MM"` or `"YYYY"` formats, depending on whether the day and month are
+ known. Unspecified future dates are returned as `"TBA"`. The values
+ `"unknown"` and `"today"` are also supported in filters.
+: Partial dates are ordered *after* complete dates for the same year/month,
+ i.e. `"2022"` is ordered after `"2022-12"`, which in turn is ordered after
+ `"2022-12-31"`. This can be unintuitive when writing filters: `["released",
+ "<", "2022-01"]` also matches all complete dates in Jan 2022. Likewise,
+ `["released", "=", "2022"]` only matches items for which the release date
+ is exactly `"2022"`, not any other date in that year.
+
+enumeration types
+: Several fields in the database are represented as an integer or string with
+ a limited number of possible values. These values are either documented for
+ the particular field or listed separately in the [schema JSON](#get-schema).
+
+
+# User Authentication
+
+The majority of the API endpoints below are usable without any form of
+authentication, but some user-related actions - in particular, list management
+- require the calls to be authenticated with the respective VNDB user account.
+
+The API understands cookies originating from the main `vndb.org` domain, so
+user scripts running from the site only have to ensure that
+[XMLHttpRequest.withCredentials](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials)
+or [the Fetch API "credentials"
+parameter](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#sending_a_request_with_credentials_included)
+is set.
+
+In all other cases, token authentication should to be used. Users can obtain a
+token by opening their "My Profile" form and going to the "Applications" tab.
+The URL `https://vndb.org/u/tokens` can also be used to redirect users to this
+form. Tokens look like `xxxx-xxxxx-xxxxx-xxxx-xxxxx-xxxxx-xxxx`, with each `x`
+representing a lowercase z-base-32 character. The dashes in between are
+optional.
+
+Tokens may be included in API requests using the `Authorization` header with
+the `Token` type, for example:
+
+```
+Authorization: Token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk
+```
+
+A HTTP 401 error is returned if the token is invalid. The [GET
+/authinfo](#get-authinfo) endpoint can be used validate and extract information
+from tokens.
+
+
+# Simple Requests
+
+## GET /schema
+
+Returns a [JSON object](%endpoint%/schema) with metadata about several API
+objects, including enumeration values, which fields are available for querying
+and a list of supported external links. The JSON structure is hopefully
+self-explanatory.
+
+This information does not change very often and can safely be used for code
+generation or dynamic API introspection.
+
+## GET /stats
+
+Returns a few overall database statistics.
+
+`curl %endpoint%/stats`
+
+```json
+{
+ "chars": 112347,
+ "producers": 14789,
+ "releases": 91490,
+ "staff": 27929,
+ "tags": 2783,
+ "traits": 3115,
+ "vn": 36880
+}
+```
+
+## GET /user
+
+Lookup users by id or username. Accepts two query parameters:
+
+q
+: User ID or username to look up, can be given multiple times to look up
+ multiple users.
+
+fields
+: List of fields to select. The 'id' and 'username' fields are always
+ selected and should not be specified here.
+
+The response object contains one key for each given `q` parameter, its value is
+either `null` if no such user was found or otherwise an object with the
+following fields:
+
+id
+: String in `"u123"` format.
+
+username
+: String.
+
+lengthvotes
+: Integer, number of play time votes this user has submitted.
+
+lengthvotes\_sum
+: Integer, sum of the user's play time votes, in minutes.
+
+Strings that look like user IDs are not valid usernames, so the lookup is
+unambiguous. Usernames matching is case-insensitive.
+
+`curl '%endpoint%/user?q=NoUserWithThisNameExists&q=AYO&q=u3'`
+
+```json
+{
+ "AYO": {
+ "id": "u3",
+ "username": "ayo"
+ },
+ "NoUserWithThisNameExists": null,
+ "u3": {
+ "id": "u3",
+ "username": "ayo"
+ }
+}
+```
+
+`curl '%endpoint%/user?q=yorhel&fields=lengthvotes,lengthvotes_sum'`
+
+```json
+{
+ "yorhel": {
+ "id": "u2",
+ "lengthvotes": 9,
+ "lengthvotes_sum": 9685,
+ "username": "Yorhel"
+ }
+}
+```
+
+## GET /authinfo
+
+Validates and returns information about the given [API
+token](#user-authentication). The JSON object has the following members:
+
+id
+: String, user ID.
+
+username
+: String, username.
+
+permissions
+: Array of strings, permissions granted to this token.
+
+The following permissions are currently implemented:
+
+listread
+: Allows read access to private labels and entries in the user's visual novel
+ list.
+
+listwrite
+: Allows write access to the user's visual novel list.
+
+```sh
+curl %endpoint%/authinfo\
+ --header 'Authorization: token cdhy-bqy1q-6zobu-8w9k-xobxh-wzz4o-84fn'
+```
+
+```json
+{
+ "id": "u3",
+ "username": "ayo",
+ "permissions": [
+ "listread"
+ ]
+}
+```
+
+
+# Database Querying
+
+## API Structure
+
+Searching for and fetching database entries is done through a custom query
+format^[Yes, sorry, I know every API having its own query system sucks, but I
+couldn't find an existing solution that works well for VNDB.]. Queries are sent
+as `POST` requests, but I expect to also support the `QUERY` HTTP method once
+that gains more software support.
+
+### Query format
+
+A query is a JSON object that looks like this:
+
+```json
+{
+ "filters": [],
+ "fields": "",
+ "sort": "id",
+ "reverse": false,
+ "results": 10,
+ "page": 1,
+ "user": null,
+ "count": false,
+ "compact_filters": false,
+ "normalized_filters": false
+}
+```
+
+All members are optional, defaults are shown above.
+
+filters
+: Filters are used to determine which database items to fetch, see the
+ section on [Filters](#filters) below.
+
+fields
+: String. Comma-separated list of fields to fetch for each database item. Dot
+ notation can be used to select nested JSON objects, e.g. `"image.url"` will
+ select the `url` field inside the `image` object. Multiple nested fields
+ can be selected with brackets, e.g. `"image{id,url,dims}"` is equivalent to
+ `"image.id, image.url, image.dims"`.
+: Every field of interest must be explicitely mentioned, there is no support
+ for wildcard matching. The same applies to nested objects, it is an error
+ to list `image` without sub-fields in the example above.
+: The top-level `id` field is always selected by default and does not have to
+ be mentioned in this list.
+
+sort
+: Field to sort on. Supported values depend on the type of data being queried
+ and are documented separately.
+
+reverse
+: Set to true to sort in descending order.
+
+results
+: Number of results per page, max 100. Can also be set to `0` if you're not
+ interested in the results at all, but just want to verify your query or get
+ the `count`, `compact_filters` or `normalized_filters`.
+
+page
+: Page number to request, starting from 1. See also the [note on
+ pagination](#pagination) below.
+
+user
+: User ID. This field is mainly used for `POST /ulist`, but it also
+ sets the default user ID to use for the visual novel "label" filter.
+ Defaults to the currently authenticated user.
+
+count
+: Whether the response should include the `count` field (see below). This
+ option should be avoided when the count is not needed since it has a
+ considerable performance impact.
+
+compact\_filters
+: Whether the response should include the `compact_filters` field (see below).
+
+normalized\_filters
+: Whether the response should include the `normalized_filters` field (see below).
+
+
+### Response format
+
+```json
+{
+ "results": [],
+ "more": false,
+ "count": 1,
+ "compact_filters": "",
+ "normalized_filters": [],
+}
+```
+
+results
+: Array of objects representing the query results.
+
+more
+: When `true`, repeating the query with an incremented `page` number will
+ yield more results. This is a cheaper form of pagination than using the
+ `count` field.
+
+count
+: Only present if the query contained `"count":true`. Indicates the total
+ number of entries that matched the given filters.
+
+compact\_filters
+: Only present if the query contained `"compact_filters":true`. This is a
+ compact string representation of the filters given in the query.
+
+normalized\_filters
+: Only present if the query contained `"normalized_filters":true`. This is
+ a normalized JSON representation of the filters given in the query.
+
+### Filters
+
+Simple predicates are represented as a three-element JSON array containing a
+filter name, operator and value, e.g. `[ "id", "=", "v17" ]`. All filters
+accept the (in)equality operators `=` and `!=`. Filters that support ordering
+also accept `>=`, `>`, `<=` and `<`. The full list of accepted filter names
+and values is documented below for each type of database item.
+
+Simple predicates can be combined into larger queries with and/or predicates.
+These are represented as JSON arrays where the first element is either `"and"`
+or `"or"`, followed by two or more other predicates.
+
+Full example of a more complex visual novel filter (which, as of writing,
+doesn't actually match anything in the database):
+
+```json
+[ "and"
+, [ "or"
+ , [ "lang", "=", "en" ]
+ , [ "lang", "=", "de" ]
+ , [ "lang", "=", "fr" ]
+ ]
+, [ "olang", "!=", "ja" ]
+, [ "release", "=", [ "and"
+ , [ "released", ">=", "2020-01-01" ]
+ , [ "producer", "=", [ "id", "=", "p30" ] ]
+ ]
+ ]
+]
+```
+
+Besides the above JSON format, filters can also be represented as a more
+compact string. This representation is used in the URLs for the advanced search
+web interface^[Fun fact: the web interface also accepts filters in JSON form,
+but that tends to result in long and ugly URLs.] and is also accepted as value
+to the `"filters"` field. Since actually working with the compact string
+representation is kind of annoying, this API can convert between the two
+representations, so you can freely copy filters from the website to the API and
+the other way around.^[There is also a third representation for filters, which
+the API also accepts, but I won't bother you with that. It's only useful as an
+intermediate representation when converting between the JSON and string format,
+which you shouldn't be doing manually.]
+
+The compact representation of the above example is
+`"03132gen2gde2gfr3hjaN180272_0c2vQN6830u"` and can be seen in action in [the web
+UI](https://vndb.org/v?f=03132gen2gde2gfr3hjaN180272_0c2vQN6830u). The following
+command will convert that string back into the above JSON:
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "filters": "03132gen2gde2gfr3hjaN180272_0c2vQN6830u",
+ "normalized_filters": true
+}'
+```
+
+Note that the advanced search editing UI on the site does not support all
+filter types, for unsupported filters you will see an "Unrecognized filter"
+block. These are pretty harmless, the filter still works.
+
+#### Filter flags
+
+These flags are used in the documentation below to describe a few common filter
+properties.
+
+------------------------------------------------------------------------
+ Flag Description
+----- -----------------------------------------------------------------
+ o Ordering operators (such as `>` and `<`) can be used with this filter.
+
+ n This filter accepts `null` as value.
+
+ m A single entry can match multiple values. For example, a visual novel
+ available in both English and Japanese matches both `["lang","=","en"]`
+ and `["lang","=","ja"]`.
+
+ i Inverting or negating this filter (e.g. by changing the operator from
+ '=' to '!=' or from '>' to '<=') is not always equivalent to inverting
+ the selection of matching entries. This often means that the filter
+ implies another requirement (e.g. that the information must be known in
+ the first place), but the exact details depend on the filter.
+------------------------------------------------------------------------
+
+Be careful with applying boolean algebra to filters with the 'm' or 'i' flags,
+the results may be unintuitive. For example, searching for releases matching
+`["or",["minage","=",0],["minage","!=",0]]` will **not** find all releases in
+the database, but only those for which the `minage` field is known. Exact
+semantics regarding unknown or missing information often depends on how the
+filter is implemented and may be subject to change.
+
+## POST /vn
+
+Query visual novel entries.
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "filters": ["id", "=", "v17"],
+ "fields": "title, image.url"
+}'
+```
+
+Accepted values for `"sort"`: `id`, `title`, `released`, `rating`, `votecount`, `searchrank`.
+
+### Filters {#vn-filters}
+
+-----------------------------------------------------------------------------
+Name [F] Description
+---------------- ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search, matches on the VN titles, aliases and release titles.
+ The search algorithm is the same as used on the site.
+
+`lang` m Language availability.
+
+`olang` Original language.
+
+`platform` m Platform availability.
+
+`length` o Play time estimate, integer between 1 (Very short) and 5 (Very long).
+ This filter uses the length votes average when available but
+ falls back to the entries' `length` field when there are no votes.
+
+`released` o,n Release date.
+
+`rating` o,i Bayesian rating, integer between 10 and 100.
+
+`votecount` o Integer, number of votes.
+
+`has_description` Only accepts a single value, integer `1`.
+ Can of course still be negated with the `!=` operator.
+
+`has_anime` See `has_description`.
+
+`has_screenshot` See `has_description`.
+
+`has_review` See `has_description`.
+
+`devstatus` Development status, integer. See `devstatus` field.
+
+`tag` m Tags applied to this VN, also matches parent tags. See below for more details.
+
+`dtag` m Tags applied directly to this VN, does not match parent tags. See below for details.
+
+`anime_id` Integer, AniDB anime identifier.
+
+`label` m User labels applied to this VN. Accepts a two-element
+ array containing a user ID and label ID. When
+ authenticated or if the `"user"` request parameter has
+ been set, then it also accepts just a label ID.
+
+`release` m Match visual novels that have at least one release
+ matching the given [release filters](#release-filters).
+
+`character` m Match visual novels that have at least one character
+ matching the given [character filters](#character-filters).
+
+`staff` m Match visual novels that have at least one staff member
+ matching the given [staff filters](#staff-filters).
+
+`developer` m Match visual novels developed by the given [producer filters](#producer-filters).
+------------------------------------------------------------------------------
+
+The `tag` and `dtag` filters accept either a plain tag ID or a three-element
+array containing the tag ID, maximum spoiler level (0, 1 or 2) and minimum tag
+level (number between 0 and 3, inclusive), for example
+`["tag","=",["g505",2,1.2]]` matches all visual novels that have a [Donkan
+Protagonist](https://vndb.org/g505) with a vote of at least 1.2 at any spoiler
+level. If only an ID is given, `0` is assumed for both the spoiler and tag
+levels. For example, `["tag","=","g505"]` is equivalent to
+`["tag","=",["g505",0,0]]`.
+
+### Fields {#vn-fields}
+
+id
+: vndbid.
+
+title
+: String, main title as displayed on the site, typically romanized from the
+ original script.
+
+alttitle
+: String, can be null. Alternative title, typically the same as `title` but
+ in the original script.
+
+titles
+: Array of objects, full list of titles associated with the VN, always
+ contains at least one title.
+
+titles.lang
+: String, language. Each language appears at most once in the titles list.
+
+titles.title
+: String, title in the original script.
+
+titles.latin
+: String, can be null, romanized version of `title`.
+
+titles.official
+: Boolean.
+
+titles.main
+: Boolean, whether this is the "main" title for the visual novel entry.
+ Exactly one title has this flag set in the `titles` array and it's always
+ the title whose `lang` matches the VN's `olang` field. This field is
+ included for convenience, you can of course also use the `olang` field to
+ grab the main title.
+
+aliases
+: Array of strings, list of aliases.
+
+olang
+: String, language the VN has originally been written in.
+
+devstatus
+: Integer, development status. 0 meaning 'Finished', 1 is 'In development'
+ and 2 for 'Cancelled'.
+
+released
+: Release date, possibly null.
+
+languages
+: Array of strings, list of languages this VN is available in. Does not
+ include machine translations.
+
+platforms
+: Array of strings, list of platforms for which this VN is available.
+
+image
+: Object, can be null.
+
+image.id
+: String, image identifier.
+
+image.url
+: String.
+
+image.dims
+: Pixel dimensions of the image, array with two integer elements indicating
+ the width and height.
+
+image.sexual
+: Number between 0 and 2 (inclusive), average image flagging vote for sexual
+ content.
+
+image.violence
+: Number between 0 and 2 (inclusive), average image flagging vote for violence.
+
+image.votecount
+: Integer, number of image flagging votes.
+
+length
+: Integer, possibly null, rough length estimate of the VN between 1 (very
+ short) and 5 (very long). This field is only used as a fallback for when
+ there are no length votes, so you'll probably want to fetch
+ `length_minutes` too.
+
+length\_minutes
+: Integer, possibly null, average of user-submitted play times in minutes.
+
+length\_votes
+: Integer, number of submitted play times.
+
+description
+: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+
+rating
+: Number between 10 and 100, null if nobody voted.
+
+votecount
+: Integer, number of votes.
+
+screenshots
+: Array of objects, possibly empty.
+
+screenshots.\*
+: The above `image.*` fields are also available for screenshots.
+
+screenshots.thumbnail
+: String, URL to the thumbnail.
+
+screenshots.thumbnail\_dims
+: Pixel dimensions of the thumbnail, array with two integer elements.
+
+screenshots.release.\*
+: Release object. All [release fields](#release-fields) can be selected. It
+ is very common for all screenshots of a VN to be assigned to the same
+ release, so the fields you select here are likely to get duplicated several
+ times in the response. If you want to fetch more than just a few fields, it
+ is more efficient to only select `release.id` here and then grab detailed
+ release info with a separate request.
+
+relations
+: Array of objects, list of VNs directly related to this entry.
+
+relations.relation
+: String, relation type.
+
+relations.relation\_official
+: Boolean, whether this VN relation is official.
+
+relations.\*
+: All [visual novel fields](#vn-fields) can be selected here.
+
+tags
+: Array of objects, possibly empty. Only directly applied tags are returned,
+ parent tags are not included.
+
+tags.rating
+: Number, tag rating between 0 (exclusive) and 3 (inclusive).
+
+tags.spoiler
+: Integer, 0, 1 or 2, spoiler level.
+
+tags.lie
+: Boolean.
+
+tags.\*
+: All [tag fields](#tag-fields) can be used here. If you're fetching tags for
+ more than a single visual novel, it's usually more efficient to only select
+ `tags.id` here and then fetch (and cache) further tag information as a
+ separate request. Otherwise the same tag info may get duplicated many times
+ in the response.
+
+developers
+: Array of objects. The developers of a VN are all producers with a
+ "developer" role on a release linked to the VN. You can get this same
+ information by fetching all relevant release entries, but if all you need
+ is the list of developers then querying this field is faster.
+
+developers.\*
+: All [producer fields](#producer-fields) can be used here.
+
+editions
+: Array of objects, possibly empty.
+
+editions.eid
+: Integer, edition identifier. This identifier is local to the
+ visual novel and not stable across edits of the VN entry, it's only used
+ for organizing the staff listing (see below) and has no meaning beyond
+ that. But this is subject to change in the future.
+
+editions.lang
+: String, possibly null, language.
+
+editions.name
+: String, English name / label identifying this edition.
+
+editions.official
+: Boolean.
+
+staff
+: Array of objects, possibly empty.
+
+staff.eid
+: Integer, edition identifier or *null* when the staff has worked on the
+ "original" version of the visual novel.
+
+staff.role
+: String, see `enums.staff_role` in the [schema JSON](#get-schema) for
+ possible values.
+
+staff.note
+: String, possibly null.
+
+staff.*
+: All [staff fields](#staff-fields) can be used here.
+
+*Currently missing from the old API: voice actors, anime relations and external
+links. Can add if there's interest.*
+
+
+## POST /release
+
+Accepted values for `"sort"`: `id`, `title`, `released`, `searchrank`.
+
+### Filters {#release-filters}
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search.
+
+`lang` m Match on available languages.
+
+`platform` m Match on available platforms.
+
+`released` o Release date.
+
+`resolution` o,i Match on the image resolution, in pixels. Value must
+ be a two-element integer array to which the width and
+ height, respectively, are compared. For example,
+ `["resolution","<=",[640,480]]` matches releases with a
+ resolution smaller than or equal to 640x480.
+
+`resolution_aspect` o,i Same as the `resolution` filter, but additionally
+ requires that the aspect ratio matches that of the
+ given resolution.
+
+`minage` o,n,i Integer (0-18), age rating.
+
+`medium` m,n String.
+
+`voiced` n Integer, see `voiced` field.
+
+`engine` n String.
+
+`rtype` m String, see `vns.rtype` field. If this filter is used
+ when nested inside a visual novel filter, then this
+ matches the `rtype` of the particular visual novel.
+ Otherwise, this matches the `rtype` of any linked
+ visual novel.
+
+`extlink` m Match on external links, see below for details.
+
+`patch` Integer, only accepts the value `1`.
+
+`freeware` See `patch`.
+
+`uncensored` i See `patch`.
+
+`official` See `patch`.
+
+`has_ero` See `patch`.
+
+`vn` m Match releases that are linked to at least one visual novel
+ matching the given [visual novel filters](#vn-filters).
+
+`producer` m Match releases that have at least one producer
+ matching the given [producer filters](#producer-filters).
+-----------------------------------------------------------------------------
+
+The `extlink` filter can be used with three types of values:
+
+- Just a site name, e.g. `["extlink","=","steam"]` matches all releases that
+ have a steam ID.
+- A two-element array indicating the site name and the remote identifier, e.g.
+ `["extlink","=",["steam",702050]]` to match the Saya no Uta release on Steam.
+ The second element can be either an int or a string, depending on the site,
+ but integer identifiers are also accepted when formatted as a string.
+- A URL, e.g. `["extlink","=","https://store.steampowered.com/app/702050/"]` is
+ equivalent to the above example.
+
+In all of the above forms, an error is returned if the site is not known in the
+database or if the URL format is not recognized. The list of supported sites
+and URL formats tends to change over time, see [GET /schema](#get-schema) for
+the current list of supported sites.
+
+*Undocumented: animation*
+
+### Fields {#release-fields}
+
+id
+: vndbid.
+
+title
+: String, main title as displayed on the site, typically romanized from the
+ original script.
+
+alttitle
+: String, can be null. Alternative title, typically the same as `title` but
+ in the original script.
+
+languages
+: Array of objects, languages this release is available in. There is always
+ exactly one language that is considered the "main" language of this
+ release, which is only used to select the titles for the `title` and
+ `alttitle` fields.
+
+languages.lang
+: String, language. Each language appears at most once.
+
+languages.title
+: String, title in the original script. Can be null, in which case the title
+ for this language is the same as the "main" language.
+
+languages.latin
+: String, can be null, romanized version of `title`.
+
+languages.mtl
+: Boolean, whether this is a machine translation.
+
+languages.main
+: Boolean, whether this language is used to determine the "main" title for
+ the release entry.
+
+platforms
+: Array of strings.
+
+media
+: Array of objects.
+
+media.medium
+: String.
+
+media.qty
+: Integer, quantity. This is `0` for media where a quantity does not make
+ sense, like "internet download".
+
+vns
+: Array of objects, the list of visual novels this release is linked to.
+
+vns.rtype
+: The release type for this visual novel, can be `"trial"`, `"partial"` or
+ `"complete"`.
+
+vns.\*
+: All [visual novel fields](#vn-fields) are available.
+
+producers
+: Array of objects.
+
+producers.developer
+: Boolean.
+
+producers.publisher
+: Boolean.
+
+producers.\*
+: All [producer fields](#producer-fields) are available.
+
+released
+: Release date.
+
+minage
+: Integer, possibly null, age rating.
+
+patch
+: Boolean.
+
+freeware
+: Boolean.
+
+uncensored
+: Boolean, can be null.
+
+official
+: Boolean.
+
+has\_ero
+: Boolean.
+
+resolution
+: Can either be null, the string `"non-standard"` or an array of two integers
+ indicating the width and height.
+
+engine
+: String, possibly null.
+
+voiced
+: Int, possibly null, 1 = not voiced, 2 = only ero scenes voiced, 3 =
+ partially voiced, 4 = fully voiced.
+
+notes
+: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+
+gtin
+: JAN/EAN/UPC code, formatted as a string, possibly null.
+
+catalog
+: String, possibly null, catalog number.
+
+extlinks
+: Array, links to external websites. This list is equivalent to the links
+ displayed on the release pages on the site, so it may include redundant
+ entries (e.g. if a Steam ID is known, links to both Steam and SteamDB are
+ included) and links that are automatically fetched from external resources
+ (e.g. PlayAsia, for which a GTIN lookup is performed). These extra sites
+ are not listed in the `extlinks` list of [the schema](#get-schema).
+
+extlinks.url
+: String, URL.
+
+extlinks.label
+: String, English human-readable label for this link.
+
+extlinks.name
+: Internal identifier of the site, intended for applications that want to
+ localize the label or to parse/format/extract remote identifiers. Keep in
+ mind that the list of supported sites, their internal names and their ID
+ types are subject to change, but I'll try to keep things stable.
+
+extlinks.id
+: Remote identifier for this link. Not all sites have a sensible identifier
+ as part of their URL format, in such cases this field is simply equivalent
+ to the URL.
+
+*Missing: animation.*
+
+
+
+## POST /producer
+
+Accepted values for `"sort"`: `id`, `name`, `searchrank`.
+
+### Filters {#producer-filters}
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search.
+
+`lang` Language.
+
+`type` Producer type, see the `type` field below.
+-----------------------------------------------------------------------------
+
+### Fields {#producer-fields}
+
+id
+: vndbid.
+
+name
+: String.
+
+original
+: String, possibly null, name in the original script.
+
+aliases
+: Array of strings.
+
+lang
+: String, primary language.
+
+type
+: String, producer type, `"co"` for company, `"in"` for individual and `"ng"`
+ for amateur group.
+
+description
+: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+
+*Missing: External links, relations.*
+
+
+## POST /character
+
+Accepted values for `"sort"`: `id`, `name`, `searchrank`.
+
+### Filters {#character-filters}
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search.
+
+`role` m String, see `vns.role` field. If this filter is used
+ when nested inside a visual novel filter, then this
+ matches the `role` of the particular visual novel.
+ Otherwise, this matches the `role` of any linked
+ visual novel.
+
+`blood_type` String.
+
+`sex` String.
+
+`height` o,n,i Integer, cm.
+
+`weight` o,n,i Integer, kg.
+
+`bust` o,n,i Integer, cm.
+
+`waist` o,n,i Integer, cm.
+
+`hips` o,n,i Integer, cm.
+
+`cup` o,n,i String, cup size.
+
+`age` o,n,i Integer.
+
+`trait` m Traits applied to this character, also matches parent
+ traits. See below for more details.
+
+`dtrait` m Traits applied directly to this character, does not
+ match parent traits. See below for details.
+
+`birthday` n Array of two integers, month and day. Day may be `0`
+ to find characters whose birthday is in a given month.
+
+`seiyuu` m Match characters that are voiced by the matching
+ [staff filters](#staff-filters). Voice actor
+ information is actually specific to visual novels,
+ but this filter does not (currently) correlate
+ against the parent entry when nested inside a visual
+ novel filter.
+
+`vn` m Match characters linked to visual novels described by
+ [visual novel filters](#vn-filters).
+-----------------------------------------------------------------------------
+
+The `trait` and `dtrait` filters accept either a plain trait ID or a
+two-element array containing the trait ID and maximum spoiler level. These work
+similar to the tag filters for [visual novels](#vn-filters), except that traits
+don't have a rating.
+
+### Fields
+
+id
+: vndbid.
+
+name
+: String.
+
+original
+: String, possibly null, name in the original script.
+
+aliases
+: Array of strings.
+
+description
+: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+
+image.\*
+: Object, possibly null, same sub-fields as the `image` [visual novel field](#vn-fields).
+
+blood\_type
+: String, possibly null, `"a"`, `"b"`, `"ab"` or `"o"`.
+
+height
+: Integer, possibly null, cm.
+
+weight
+: Integer, possibly null, kg.
+
+bust
+: Integer, possibly null, cm.
+
+waist
+: Integer, possibly null, cm.
+
+hips
+: Integer, possibly null, cm.
+
+cup
+: String, possibly null, `"AAA"`, `"AA"`, or any single letter in the alphabet.
+
+age
+: Integer, possibly null, years.
+
+birthday
+: Possibly null, otherwise an array of two integers: month and day,
+ respectively.
+
+sex
+: Possibly null, otherwise an array of two strings: the character's apparent
+ (non-spoiler) sex and the character's real (spoiler) sex. Possible values
+ are `null`, `"m"`, `"f"` or `"b"` (meaning "both").
+
+vns
+: Array of objects, visual novels this character appears in. The same visual
+ novel may be listed multiple times with a different release; the spoiler
+ level and role can be different per release.
+
+vns.spoiler
+: Integer.
+
+vns.role
+: String, `"main"` for protagonist, `"primary"` for main characters, `"side"`
+ or `"appears"`.
+
+vns.\*
+: All [visual novel fields](#vn-fields) are available here.
+
+vns.release.\*
+: Object, usually null, specific release that this character appears in. All
+ [release fields](#release-fields) are available here.
+
+traits
+: Array of objects, possibly empty.
+
+traits.spoiler
+: Integer, 0, 1 or 2, spoiler level.
+
+traits.lie
+: Boolean.
+
+traits.\*
+: All [trait fields](#trait-fields) are available here.
+
+*Missing: instances, voice actor*
+
+
+## POST /staff
+
+Unlike other database entries, staff have more than one unique identifier.
+There is the main 'staff ID', which uniquely identifies a person and is what
+a staff page on the site represents.
+
+Additionally, every staff alias also has its own unique identifier, which is
+referenced from other database entries to identify which alias was used. This
+identifier is generally hidden on the site and aliases do not have their own
+page, but the IDs are exposed in this API in order to facilitate linking
+VNs/characters to staff names.
+
+This particular API queries staff *names*, not just staff *entries*, which
+means that a staff entry with multiple names can be included multiple times in
+the API results, once for each name they are known as. When searching or
+listing staff entries, this is usually what you want. When fetching more
+detailed information about specific staff entries, this is very much not what
+you want. The `ismain` filter can be used to remove this duplication and ensure
+you get at most one result per staff entry, for example:
+
+```sh
+curl %endpoint%/staff --header 'Content-Type: application/json' --data '{
+ "filters": ["and", ["ismain", "=", 1], ["id", "=", "s81"] ],
+ "fields": "lang,aliases{name,latin,ismain},description,extlinks{url,label}"
+}'
+```
+
+Accepted values for `"sort"`: `id`, `name`, `searchrank`.
+
+### Filters {#staff-filters}
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`aid` integer, alias identifier
+
+`search` m String search.
+
+`lang` Language.
+
+`gender` Gender.
+
+`role` m String, can either be `"seiyuu"` or one of the values
+ from `enums.staff_role` in the [schema JSON](#get-schema).
+ If this filter is used when nested inside a visual
+ novel filter, then this matches the `role` of the
+ particular visual novel. Otherwise, this matches the
+ `role` of any linked visual novel.
+
+`extlink` m Match on external links, works similar to the `exlink`
+ filter for [releases](#release-filters).
+
+`ismain` Only accepts a single value, integer `1`.
+-----------------------------------------------------------------------------
+
+### Fields {#staff-fields}
+
+id
+: vndbid.
+
+aid
+: Integer, alias id.
+
+ismain
+: Boolean, whether the 'name' and 'original' fields represent the main name
+ for this staff entry.
+
+name
+: String, possibly romanized name.
+
+original
+: String, possibly null, name in original script.
+
+lang
+: String, staff's primary language.
+
+gender
+: String, possibly null, `"m"` or `"f"`.
+
+description
+: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+
+extlinks
+: Array, links to external websites. Works the same as the 'extlinks'
+ [release field](#release-fields).
+
+aliases
+: Array, list of names used by this person.
+
+aliases.aid
+: Integer, alias id.
+
+aliases.name
+: String, name in original script.
+
+aliases.latin
+: String, possibly null, romanized version of 'name'.
+
+aliases.ismain
+: Boolean, whether this alias is used as "main" name for the staff entry.
+
+
+## POST /tag
+
+Accepted values for `"sort"`: `id`, `name`, `vn_count`, `searchrank`.
+
+### Filters
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search.
+
+`category` String, see `category` field.
+-----------------------------------------------------------------------------
+
+### Fields {#tag-fields}
+
+id
+: vndbid.
+
+name
+: String.
+
+aliases
+: Array of strings.
+
+description
+: String, may contain [formatting codes](https://vndb.org/d9#4).
+
+category
+: String, `"cont"` for content, `"ero"` for sexual content and `"tech"` for technical tags.
+
+searchable
+: Bool.
+
+applicable
+: Bool.
+
+vn\_count
+: Integer, number of VNs this tag has been applied to, including any child tags.
+
+*Missing: some way to fetch parent/child tags. Not obvious how to do this
+efficiently because tags form a DAG rather than a tree.*
+
+
+## POST /trait
+
+Accepted values for `"sort"`: `id`, `name`, `char_count`, `searchrank`.
+
+### Filters
+
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`search` m String search.
+-----------------------------------------------------------------------------
+
+### Fields {#trait-fields}
+
+id
+: vndbid
+
+name
+: String. Trait names are not necessarily self-describing, so they should
+ always be displayed together with their "group" (see below), which is the
+ top-level parent that the trait belongs to.
+
+aliases
+: Array of strings.
+
+description
+: String, may contain [formatting codes](https://vndb.org/d9#4).
+
+searchable
+: Bool.
+
+applicable
+: Bool.
+
+group\_id
+: vndbid
+
+group\_name
+: String
+
+char\_count
+: Integer, number of characters this trait has been applied to, including
+ child traits.
+
+
+
+# List Management
+
+## POST /ulist
+
+Fetch a user's list. This API is very much like `POST /vn`, except it requires
+the `"user"` parameter to be set and it has a different response structure. All
+[visual novel filters](#vn-filters) can be used here.
+
+If the user has visual novel entires on their list that have been deleted from
+the database, these will not be returned through the API even though they do
+show up on the website.
+
+Accepted values for `"sort"`: `id`, `title`, `released`, `rating`, `votecount`,
+`voted`, `vote`, `added`, `lastmod`, `started`, `finished`, `searchrank`.
+
+Very important example on how to fetch Yorhel's top 10 voted visual novels:
+
+```sh
+curl %endpoint%/ulist --header 'Content-Type: application/json' --data '{
+ "user": "u2",
+ "fields": "id, vote, vn.title",
+ "filters": [ "label", "=", 7 ],
+ "sort": "vote",
+ "reverse": true,
+ "results": 10
+}'
+```
+
+### Fields {#ulist-fields}
+
+id
+: Visual novel ID.
+
+added
+: Integer, unix timestamp.
+
+voted
+: Integer, can be null, unix timestamp of when the user voted on this VN.
+
+lastmod
+: Integer, unix timestamp when the user last modified their list for this VN.
+
+vote
+: Integer, can be null, 10 - 100.
+
+started
+: String, start date, can be null, "YYYY-MM-DD" format.
+
+finished
+: String, finish date, can be null.
+
+notes
+: String, can be null.
+
+labels
+: Array of objects, user labels assigned to this VN. Private labels are only
+ listed when the user is authenticated.
+
+labels.id
+: Integer.
+
+labels.label
+: String.
+
+vn\.*
+: Visual novel info, all [visual novel fields](#vn-fields) can be selected
+ here.
+
+releases
+: Array of objects, releases of this VN that the user has added to their list.
+
+releases.list\_status
+: Integer, 0 for "Unknown", 1 for "Pending", 2 for "Obtained", 3 for "On
+ loan", 4 for "Deleted".
+
+releases.\*
+: All [release fields](#release-fields) can be selected here.
+
+
+## GET /ulist\_labels
+
+Fetch the list labels for a certain user. Accepts two query parameters:
+
+user
+: The user ID to fetch the labels for. If the parameter is missing, the
+ labels for the currently authenticated user are fetched instead.
+
+fields
+: List of fields to select. Currently only `count` may be specified, the
+ other fields are always selected.
+
+Returns a JSON object with a single key, `"labels"`, which is an array of
+objects with the following members:
+
+id
+: Integer identifier of the label.
+
+private
+: Boolean, whether this label is private. Private labels are only included
+ when authenticated with the `listread` permission. The 'Voted' label (id=7)
+ is always included even when private.
+
+label
+: String.
+
+count
+: Integer. The 'Voted' label may have different counts depending on whether
+ the user has authenticated.
+
+Labels with an id below 10 are the pre-defined labels and are the same for
+everyone, though even pre-defined labels are excluded if they are marked
+private.
+
+Example: [Multi](https://vndb.org/u1) has only the default labels.
+
+```sh
+curl '%endpoint%/ulist_labels?user=u1'
+```
+
+## PATCH /ulist/\<id\>
+
+Add or update a visual novel in the user's list. Requires the `listwrite`
+permission. The JSON body accepts the following members:
+
+vote
+: Integer between 10 and 100.
+
+notes
+: String.
+
+started
+: Date.
+
+finished
+: Date.
+
+labels
+: Array of integers, label ids. Setting this will overwrite any existing
+ labels assigned to the VN with the given array.
+
+labels\_set
+: Array of label ids to add to the VN, any already existing labels will
+ be unaffected.
+
+labels\_unset
+: Array of label ids to remove from the VN.
+
+All members are be optional, missing members are not modified. A `null`
+value can be used to unset a field (except for labels).
+
+The virtual labels with id 0 ("No label") and 7 ("Voted") can not be set. The
+"voted" label is automatically added/removed based on the `vote` field.
+
+Wonky behavior alert: this API does not verify label ids and lets you add
+non-existent labels. These are not displayed on the website and not returned by
+[POST /ulist](#post-ulist), but they're still stored in the database and may
+magically show up if a label with that id is created in the future. Don't rely
+on this behavior, it's a bug.
+
+More wonky behavior: the website automatically unsets the other
+Playing/Finished/Stalled/Dropped labels when you select one of those, but this
+is not enforced server-side and the API lets you set all labels at the same
+time. This is totally not a bug.
+
+Example to remove the "Playing" label, add the "Finished" label and vote a 6:
+
+```sh
+curl -XPATCH %endpoint%/ulist/v17 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk' \
+ --header 'Content-Type: application/json' \
+ --data '{"labels_unset":[1],"labels_set":[2],"vote":60}'
+```
+
+Or to remove an existing vote without affecting any of the other fields:
+
+```sh
+curl -XPATCH %endpoint%/ulist/v17 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk' \
+ --header 'Content-Type: application/json' \
+ --data '{"vote":null}'
+```
+
+Slightly unintuitive behavior alert: this API *always* adds the visual novel to
+the user's list if it's not already present, and that also applies to the above
+"removing a vote" example. Use [DELETE](#delete-ulistid) if you want to remove
+a VN from the list.
+
+## PATCH /rlist/\<id\>
+
+Add or update a release in the user's list. Requires the `listwrite`
+permission. All visual novels linked to the release are also added to the
+user's visual novel list, if they aren't in the list yet. The JSON body
+accepts the following members:
+
+status
+: Release status, integer. See `releases.list_status` in the [POST /ulist
+ fields](#ulist-fields) for the list of possible values. Defaults to 0.
+
+Example, to mark `r12` as obtained:
+
+```sh
+curl -XPATCH %endpoint%/rlist/r12 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk' \
+ --header 'Content-Type: application/json' \
+ --data '{"status":2}'
+```
+
+## DELETE /ulist/\<id\>
+
+Remove a visual novel from the user's list. Returns success even if the VN is
+not on the user's list. Removing a VN also removes any associated releases from
+the user's list.
+
+```sh
+curl -XDELETE %endpoint%/ulist/v17 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk'
+```
+
+## DELETE /rlist/\<id\>
+
+Remove a release from the user's list. Returns success even if the release is
+not on the user's list. Removing a release does not remove the associated
+visual novels from the user's visual novel list, that requires separate calls
+to [DELETE /ulist/\<id\>](#delete-ulistid).
+
+```sh
+curl -XDELETE %endpoint%/rlist/r12 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk'
+```
+
+
+# HTTP Response Codes
+
+Successful responses always return either `200 OK` with a JSON body or `204 No
+Content` in the case of DELETE/PATCH requests, but errors may happen. Error
+response codes are typically followed with a `text/plain` or `text/html` body.
+The following is a non-exhaustive list of error codes you can expect to see:
+
+ Code Reason
+------ -------
+ 400 Invalid request body or query, the included error message hopefully points at the problem.
+ 401 Invalid authentication token.
+ 404 Invalid API path or HTTP method
+ 429 Throttled
+ 500 Server error, usually points to a bug if this persists
+ 502 Server is down, should be temporary
+
+# Tips & Troubleshooting
+
+## "Too much data selected"
+
+The server calculates a rough estimate of the number of JSON keys it would
+generate in response to your query and throws an error if that estimation
+exceeds a certain threshold, i.e. if the response is expected to be rather
+large. This estimation is entirely based on the `"fields"` and `"results"`
+parameters, so you can work around this error by either selecting fewer fields
+or fewer results.
+
+## List of identifiers
+
+If you have a (potentially large) list of database identifiers you'd like to
+fetch, it is faster and more efficient to fetch 100 entries in a single API
+call than it is to make 100 separate API calls. Simply create a filter
+containing the identifiers, like in the following example:
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "fields": "title",
+ "filters": ["or"
+ , ["id","=","v1"]
+ , ["id","=","v2"]
+ , ["id","=","v3"]
+ , ["id","=","v4"]
+ , ["id","=","v5"] ],
+ "results": 100
+}'
+```
+
+Do not add more than 100 identifiers in a single query. You'll especially want
+to avoid sending the same list of identifiers multiple times but with higher
+`"page"` numbers, see also the next point.
+
+## Pagination
+
+While the API supports pagination through the `"page"` parameter, this is often
+not the most efficient way to retrieve a large list of entries. Results are
+sorted on `"id"` by default so you can also implement pagination by filtering
+on this field. For example, if the last item you've received had id `"v123"`,
+you can fetch the next page by filtering on `["id",">","v123"]`.
+
+This approach tends to not work as well when sorting on other fields, so
+`"page"`-based pagination is often still the better solution in those cases.
+
+## Random entry
+
+Fetching a random entry from a database is, in general, pretty challenging to
+do in a performant way. Here's one approach that can be used with the API:
+first grab the highest database identifier, then select a random number between
+`1` and the highest identifier (both inclusive) and then fetch the entry with
+that or the nearest increasing id, e.g.:
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "sort": "id",
+ "reverse": true,
+ "results": 1
+}'
+```
+
+Then, assuming you've randomly chosen id `v4567`:
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "filters": [ "id", ">=", "v4567" ],
+ "fields": "title",
+ "results": 1
+}'
+```
+
+The result of the first query can be cached. Additional filters can be added to
+both queries if you want to narrow down the selection. This method has a slight
+bias in its selection due to the presence of id gaps, but you most likely don't
+need perfect uniform random selection anyway.
+
+# Change Log
+
+**2024-03-13**
+
+- Add [POST /staff](#post-staff).
+- Add `editions` and `staff` fields to [POST /vn](#post-vn).
+- Add `enums.staff_role` and `extlinks./staff` members to [GET /schema](#get-schema).
+
+**2023-11-20**
+
+- Add `relations` field to [POST /vn](#post-vn).
+
+**2023-08-02**
+
+- Add `developers` field to [POST /vn](#post-vn).
+
+**2023-07-11**
+
+- Deprecated `popularity` sort options for [POST /ulist](#post-ulist) and [POST
+ /vn](#post-vn), it's now equivalent to sorting on the reverse of `votecount`.
+- Deprecated `popularity` filter and field for [POST /vn](#post-vn).
+
+**2023-04-05**
+
+- Add `searchrank` sort option to all endpoints that have a `search` filter.
+
+**2023-03-19**
+
+- Add `voiced`, `gtin` and `catalog` fields to [POST /release](#post-release).
+
+**2023-01-17**
+
+- Add `listwrite` permission to API tokens.
+- Add [PATCH /ulist/\<id>](#patch-ulistid).
+- Add [PATCH /rlist/\<id>](#patch-rlistid).
+- Add [DELETE /ulist/\<id>](#delete-ulistid).
+- Add [DELETE /rlist/\<id>](#delete-rlistid).
+
+[F]: #filter-flags
diff --git a/api-nyan.md b/api-nyan.md
new file mode 100644
index 00000000..b6775326
--- /dev/null
+++ b/api-nyan.md
@@ -0,0 +1,975 @@
+---
+title: VNDB.org API v1 (Nyan)
+header-includes: |
+ <style>
+ body { max-width: 900px }
+ td { vertical-align: top }
+ header, header h1 { margin: 0 }
+ @media (min-width: 1100px) {
+ body { margin: 0 0 0 270px }
+ nav { box-sizing: border-box; position: fixed; padding: 50px 20px 10px 10px; top: 0; left: 0; height: 100%; overflow: scroll }
+ }
+ </style>
+---
+
+# Introduction
+
+This document describes the legacy TCP API of VNDB. Usage of this TCP API in
+new applications in discouraged, refer to the [new HTTPS
+API](https://api.vndb.org/kana) for a more modern replacement.
+
+**Usage terms**
+
+This service is free for non-commercial use. The API is provided on a
+best-effort basis, no guarantees are made about the stability or applicability
+of this service.
+
+The data obtained through this API is subject to our [Data
+License](https://vndb.org/d17#4).
+
+**Design goals**
+
+- Simple in implementation of both client and server. "Simple" here means that
+ it shouldn't take much code to write a secure and full implementation and
+ that client applications shouldn't require huge dependency trees just to use
+ this API.
+- Powerful: Not as powerful as raw SQL, but not as rigid as commonly used REST
+ or RPC protocols.
+- High-level: common applications need to perform only few actions to get what
+ they want.
+- Fast: minimal bandwidth overhead and simple and customizable queries.
+
+**Design overview**
+
+- TCP-based, all communication between the client and the server is done using
+ one TCP connection. This connection stays alive until it is explicitely
+ closed by either the client or the server.
+- Request/response, client sends a request and server replies with a response.
+- Session-based: clients are required to login before issuing commands to the
+ server. A session is created by issuing the 'login' command, this session
+ stays valid for the lifetime of the TCP connection.
+- **Everything** sent between the client and the server is encoded in UTF-8.
+
+**Limits**
+
+The following limits are enforced by the server, in order to limit the server
+resources and prevent abuse of this service.
+
+- 10 connections per IP. All connections that are opened after reaching this
+ limit will be immediately closed.
+- 200 commands per 10 minutes per ip. Server will reply with a 'throttled'
+ error (type="cmd") when reaching this limit.
+- 1 second of SQL time per minute per ip. SQL time is the total time taken to
+ run the database queries for each command. This depends on both the command
+ (filters and get flags) and server load, and is thus not very predictable.
+ Server will reply with a 'throttled' error with type="sql" upon reaching this
+ limit.
+- Each command returns at most 25 results, with the exception of get
+ votelist/vnlist/wishlist/ulist, which returns at most 100 results.
+
+These limits may sound strict, but in practice you won't have to worry much
+about it. As long as your application properly waits when the server replies
+with a "throttle" error, everything will be handled automatically. In the event
+that your application does require more resources, don't hesitate to ask.
+
+**Connection info:**
+
+Host
+: api.vndb.org
+
+Port (plain tcp)
+: 19534 ('VN')
+
+Port (TLS)
+: 19535
+: For improved security, make sure to verify that the certificate is valid for
+'api.vndb.org' and is signed by a trusted root (in particular, by [Let's
+Encrypt](https://letsencrypt.org/certificates/)).
+
+
+
+# Request/response syntax
+
+The VNDB API uses the JSON format for data in various places, this document
+assumes you are familiar with it. See
+[JSON.org](https://www.json.org/json-en.html) for a quick overview and [RFC
+4627](https://www.ietf.org/rfc/rfc4627.txt?number=4627) for the glory details.
+
+The words _object_, _array_, _value_, _string_, _number_ and _integer_ refer to
+the JSON data types. In addition the following definitions are used in this
+document:
+
+_request_ or _command_
+: Message sent from the client to the server.
+
+_response_
+: Message sent from the server to the client.
+
+_whitespace_
+: Any sequence of the following characters: space, tab, line feed and carriage
+return. (hexadecimal: 20, 09, 0A, 0D, respectively). This is in line with the
+definition of whitespace in the JSON specification.
+
+_date_
+: A _string_ signifying a date (in particular: release date). The following
+formats are used: "yyyy" (when day and month are unknown), "yyyy-mm" (when day
+is unknown) "yyyy-mm-dd", and "tba" (To Be Announced). If the year is not known
+and the date is not "tba", the special value **null** is used.
+
+**Message format**
+
+A message is formatted as a command or response name, followed by any number of
+arguments, followed by the End Of Transmission character (04 in hexadecimal).
+Arguments are separated by one or more whitespace characters, and any sequence
+of whitespace characters is allowed before and after the message.
+
+The command or response name is an unescaped string containing only lowercase
+alphabetical ASCII characters, and indicates what kind of command or response
+this message contains.
+
+An argument can either be an unescaped string (not containing whitespace), any
+JSON value, or a filter string. The following two examples demonstrate a
+'login' command, with an object as argument. Both messages are equivalent, as
+the whitespace is ignored. '0x04' is used to indicate the End Of Transmission
+character.
+
+```
+login {"protocol":1,"username":"ayo"}0x04
+```
+```
+login {
+ "protocol" : 1,
+ "username" : "ayo"
+}
+0x04
+```
+
+The 0x04 byte will be ommitted in the other examples in this document. It is
+however still required.
+
+**Filter string syntax**
+
+Some commands accept a filter string as argument. This argument is formatted
+similar to boolean expressions in most programming languages. A filter consists
+of one or more _expressions_, separated by the boolean operators "and" or "or"
+(lowercase). Each filter expression can be surrounded by parentheses to
+indicate precedence, the filter argument itself must be surrounded by
+parentheses.
+
+An _expression_ consists of a _field name_, followed by an _operator_ and a
+_value_. The field name must consist entirely of lowercase alphanumeric
+characters and can also contain an underscore. The operator must be one of the
+following characters: =, !=, <, <=, >, >= or ~. The _value_ can be any valid
+JSON value. Whitespace characters are allowed, but not required, between all
+expressions, field names, operators and values.
+
+The following two filters are equivalent:
+
+```
+ (title~"osananajimi"or(id=2))
+```
+```
+ (
+ id = 2
+ or
+ title ~ "osananajimi"
+ )
+```
+
+More complex filters are also possible:
+
+```
+ ((platforms = ["win", "ps2"] or languages = "ja") and released > "2009-01-10")
+```
+
+See the individual commands for more details.
+
+
+# The 'login' command
+```
+ login {"protocol":1,"client":"test","clientver":0.1,"username":"ayo","password":"hi-mi-tsu!"}
+```
+
+Every client is required to login before issuing other commands. The login
+command accepts a JSON object as argument. This object has the following
+members:
+
+protocol
+: An integer that indicates which protocol version the client implements. Must be 1.
+
+client
+: A string identifying the client application. Between the 3 and 50 characters,
+must contain only alphanumeric ASCII characters, space, underscore and hyphens.
+When writing a client, think of a funny (unique) name and hardcode it into your
+application.
+
+clientver
+: A number or string indicating the software version of the client.
+
+username
+: (optional) String containing the username of the person using the client.
+When this field is provided, the client must also provide either a "password"
+or "sessiontoken".
+
+password
+: (optional) String, password of that user in plain text.
+
+sessiontoken
+: (optional) String, to log in with a session token instead of password.
+
+createsession
+: (optional) Boolean, only available when logging in with a password. This will
+create a new session token so that future logins can be done with the
+"sessiontoken" field instead of providing a password.
+
+The server can reply with one of the following responses:
+
+ok
+: No arguments, returned when the login command is successful and
+"createsession" was not specified.
+
+session
+: Returned when the login is successful and the "createsession" field was
+specified. The response has one argument: the session token encoded as a hex
+string. The token will automatically expire one month after its last use, when
+the 'logout' command is used (see below) or when the user changes their
+password.
+
+error
+: Login failed, see below for error codes.
+
+Note that logging in using a username is optional, but some commands are only
+available when logged in. It is strongly recommended to connect with TLS when
+logging into an account.
+
+Example login request and response without authentication:
+
+```
+ login {"protocol":1,"client":"Awesome Client","clientver":"1.0"}
+```
+```
+ ok
+```
+
+Example login to obtain a session token:
+
+```
+ login {"protocol":1,"client":"Awesome Client","clientver":"1.0","username":"ayo","password":"xyz","createsession":true}
+```
+```
+ session df0cc97e1f0c9f1d59ab67d2be3bb1d437892505
+```
+
+Later connections can use that token to log in:
+
+```
+ login {"protocol":1,"client":"Awesome Client","clientver":"1.0","username":"ayo","sessiontoken":"df0cc97e1f0c9f1d59ab67d2be3bb1d437892505"}
+```
+```
+ ok
+```
+
+## logout
+
+When logged in with a session (either by specifying "createsession" or
+"sessiontoken" in the login command), the client can invalidate the token
+associated with the session by sending the 'logout' command without arguments:
+
+```
+ logout
+```
+
+The server will respond with 'ok' and disconnect.
+
+
+# The 'dbstats' command
+
+This command gives the global database statistics that are visible in the main
+menu of the site. The command is simply:
+
+```
+ dbstats
+```
+
+And the response has the following format:
+
+```
+ dbstats stats
+```
+
+Where _stats_ is a JSON object with integer values. Example response:
+
+```
+ dbstats {"users":0,
+ "threads":0,
+ "tags":1627,
+ "releases":28071,
+ "producers":3456,
+ "chars":14046,
+ "posts":0,
+ "vn":13051,
+ "traits":1272}
+```
+
+The *users*, *threads* and *posts* stats are always '0' and only included for
+backwards compatibility.
+
+# The 'get' command
+
+This command is used to fetch data from the database. It accepts 4 arguments:
+the type of data to fetch (e.g. visual novels or producers), what part of that
+data to fetch (e.g. only the VN titles, or the descriptions and relations as
+well), a filter expression, and lastly some options.
+
+```
+ get type flags filters options
+```
+
+_type_ and _flags_ are unescaped strings. The accepted values for _type_ are
+documented below. _flags_ is a comma-separated list of flags indicating what
+info to fetch. The filters, available flags and their meaning are documented
+separately for each type. The last _options_ argument is optional, and
+influences the behaviour of the returned results. When present, _options_
+should be a JSON object with the following members (all are optional):
+
+page
+: integer, used for pagination. Page 1 (the default) returns the first 10
+results (1-10), page 2 returns the following 10 (11-20), etc. (The actual
+number of results per page can be set with the "results" option below).
+
+results
+: integer, maximum number of results to return. Also affects the "page" option
+above. For example: with "page" set to 2 and "results" set to 5, the second
+five results (that is, results 6-10) will be returned. Default: 10.
+
+sort
+: string, the field to order the results by. The accepted field names differ
+per type, the default sort field is the ID of the database entry.
+
+reverse
+: boolean, default false. Set to true to reverse the order of the results.
+
+The following example will fetch basic information and information about the
+related anime of the visual novel with id = 17:
+
+```
+ get vn basic,anime (id = 17)
+```
+
+The server will reply with a 'results' message, this message is followed by a
+JSON object describing the results. This object has three members: 'num', which
+is an integer indicating the number of results returned, 'more', which is true
+when there are more results available (i.e. increasing the _page_ option
+described above will give new results) and 'items', which contains the results
+as an array of objects. For example, the server could reply to the previous
+command with the following message:
+
+```
+ results {"num":1, "more":false, "items":[{
+ "id": 17, "title": "Ever17 -the out of infinity-", "original": null,
+ "released": "2002-08-29", "languages": ["en","ja","ru","zh"],
+ "platforms": ["drc","ps2","psp","win"],"anime": []
+ }]}
+```
+
+Note that the actual result from the server can (and likely will) be formatted
+differently and that the order of the members may not be the same. What each
+member means and what possible values they can have differs per type and is
+documented below.
+
+
+## get vn
+
+The following members are returned from a 'get vn' command:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-----------
+id | - | integer | no | Visual novel ID
+title | basic | string | no | Main title
+original | basic | string | yes | Original/official title.
+released | basic | date (string) | yes | Date of the first release.
+languages | basic | array of strings | no | Can be an empty array when nothing has been released yet.
+orig\_lang | basic | array of strings | no | Original language of the VN. Always contains a single language,
+platforms | basic | array of strings | no | Can be an empty array when unknown or nothing has been released yet.
+aliases | details | string | yes | Aliases, separated by newlines.
+length | details | integer | yes | Length of the game, 1-5, broad category between "very short" and "very long". This field is not displayed on the site if there are length votes available (see below)
+length\_minutes | details | integer | yes | Average play time from length votes
+length\_votes | details | integer | no | Number of length votes
+description | details | string | yes | Description of the VN. Can include formatting codes as described in [d9#3](https://vndb.org/d9#3).
+links | details | object | no | Contains the following members: <br>"wikipedia", string, name of the related article on the English Wikipedia (deprecated, use wikidata instead).<br>"encubed", string, the URL-encoded tag used on [encubed](http://novelnews.net/) (deprecated).<br>"renai", string, the name part of the url on [renai.us](http://renai.us/).<br>"wikidata", string, Wikidata identifier.<br>All members can be **null** when no links are available or known to us.
+image | details | string | yes | HTTP link to the VN image.
+image\_nsfw | details | boolean | no | (deprecated) Whether the VN image is flagged as NSFW or not.
+image\_flagging | details | object | yes | Image flagging summary of the main VN image, object with the following fields:<br>"votecount", integer, number of flagging votes.<br>"sexual\_avg", number, sexual score between 0 (safe) and 2 (explicit).<br>"violence\_avg", number, violence score between 0 (tame) and 2 (brutal).<br>The two averages may be **null** if no votes have been cast yet.
+image\_width | details | integer | yes |
+image\_height | details | integer | yes |
+titles | titles | array of objects | no | Full list of titles associated with this VN. Each language is included only once, the "main" title is the one indicated by the "orig\_lang" member. Each object has the following members:<br>"lang": string, language of this title.<br>"title", string, title in the original script<br>"latin", string, possibly null, romanized version of "title"<br>"official", boolean, whether this is an official title.
+anime | anime | array of objects | no | (Possibly empty) list of anime related to the VN, each object has the following members:<br>"id", integer, [AniDB](http://anidb.net/) ID<br>"ann\_id", integer, [AnimeNewsNetwork](http://animenewsnetwork.com/) ID<br>"nfo\_id", string, [AnimeNfo](http://animenfo.com/) ID<br>"title\_romaji", string<br>"title\_kanji", string<br>"year", integer, year in which the anime was aired<br>"type", string<br>All members except the "id" can be **null**. Note that this data is courtesy of AniDB, and may not reflect the latest state of their information due to caching.
+relations | relations | array of objects | no | (Possibly empty) list of related visual novels, each object has the following members:<br>"id", integer<br>"relation", string, relation to the VN<br>"title", string, (romaji) title<br>"original", string, original/official title, can be **null**<br>"official", boolean.
+tags | tags | array of arrays | no | (Possibly empty) list of tags linked to this VN. Each tag is represented as an array with three elements:<br> tag id (integer),<br>score (number between 0 and 3),<br>spoiler level (integer, 0=none, 1=minor, 2=major)<br>Only tags with a positive score are included. Note that this list may be relatively large - more than 50 tags for a VN is quite possible.<br>General information for each tag is available in the [tags dump](https://vndb.org/d14#2). Keep in mind that it is possible that a tag has only recently been added and is not available in the dump yet, though this doesn't happen often.
+rating | stats | number | no | Bayesian rating, between 1 and 10.
+votecount | stats | integer | no | Number of votes.
+screens | screens | array of objects | no | (Possibly empty) list of screenshots, each object has the following members:<br>"id", string, image ID<br>"image", string, URL of the full-size screenshot<br>"rid", integer, release ID<br>"nsfw", boolean (depecated)<br>"flagging", object, same format as "image\_flagging" field mentioned above<br>"height", integer, height of the full-size screenshot<br>"width", integer, width of the full-size screenshot<br>"thumbnail", string, URL to the thumbnail<br>"thumbnail\_width", integer<br>"thumbnail\_height", integer
+staff | staff | array of objects | no | (Possibly empty) list of staff related to the VN, each object has the following members:<br>"sid", integer, staff ID<br>"aid", integer, alias ID<br>"name", string<br>"original", string, possibly null<br>"role", string<br>"note", string, possibly null
+
+Sorting is possible on the following fields: id, title, released, rating, votecount.
+
+'get vn' accepts the following filter expressions:
+
+| Field | Value | Operators | Notes |
+|--|--|--|---------------
+id | integer<br>array of integers | = != > >= < <=<br>= != | When you need to fetch info about multiple VNs, it is recommended to do so in one command using an array of integers as value. e.g. (id = [7,11,17]).
+title | string | = != ~ |
+original | null<br>string | = !=<br>= != ~ |
+firstchar | null<br><string> | = !=<br>= != | Filter by the first character of the title, similar to the [VN browser interface](http://vndb.org/v/all). The character must either be a lowercase 'a' to 'z', or null to match all titles not starting with an alphabetic character.
+released | null<br>date (string) | = !=<br>= != > >= < <= | Note that matching on partial dates (released = "2009") doesn't do what you want, use ranges instead, e.g. (released > "2008" and released <= "2009").
+platforms | null<br>string<br>array of strings | <br>= != |
+languages | null<br>string<br>array of strings | <br>= != |
+orig\_lang | string<br>array of strings | = != |
+search | string | ~ | This is not an actual field, but performs a search on the titles of the visual novel and its releases. Note that the algorithm of this search may change and that it can use a different algorithm than the search function on the website.
+tags | int<br>array of ints | = != | Find VNs by tag. When providing an array of ints, the '=' filter will return VNs that are linked to any (not all) of the given tags, the '!=' filter will return VNs that are not linked to any of the given tags. You can combine multiple tags filters with 'and' and 'or' to get the exact behavior you need.<br> This filter may used cached data, it may take up to 24 hours before a VN will have its tag updated with respect to this filter.<br> VNs that are linked to childs of the given tag are also included.<br> Be warned that this filter ignores spoiler settings, fetch the tags associated with the returned VN to verify the spoiler level.
+
+
+## get release
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|---------------
+id | - | integer | no | Release ID
+title | basic | string | no | Release title (romaji)
+original | basic | string | yes | Original/official title of the release.
+released | basic | date (string) | yes | Release date
+type | basic | string | no | (deprecated) "complete", "partial" or "trial". For releases linked to multiple VNs, the most-complete type will be selected.
+patch | basic | boolean | no |
+freeware | basic | boolean | no |
+doujin | basic | boolean | no | Deprecated and meaningless, don't use.
+official | basic | boolean | no |
+languages | basic | array of strings | no |
+website | details | string | yes | Official website URL
+notes | details | string | yes | Random notes, can contain formatting codes as described in [d9#3](https://vndb.org/d9#3)
+minage | details | integer | yes | Age rating, 0 = all ages.
+gtin | details | string | yes | JAN/UPC/EAN code. This is actually an integer, but formatted as a string to avoid an overflow on 32bit platforms.
+catalog | details | string | yes | Catalog number.
+platforms | details | array of strings | no | Empty array when platform is unknown.
+media | details | array of objects | no | Objects have the following two members:<br> "medium", string<br> "qty", integer, the quantity. **null** when it is not applicable for the medium.<br> An empty array is returned when the media are unknown.
+resolution | details | string | yes |
+voiced | details | integer | yes | 1 = Not voiced, 2 = Only ero scenes voiced, 3 = Partially voiced, 4 = Fully voiced
+animation | details | array of integers | no | The array has two integer members, the first one indicating the story animations, the second the ero scene animations. Both members can be null if unknown or not applicable.<br> <br> When not null, the number indicates the following: 1 = No animations, 2 = Simple animations, 3 = Some fully animated scenes, 4 = All scenes fully animated.
+lang | lang | array of objects | no | List of languages with associated metadata. Each object has the following members:<br>"lang": string, language the release is available in<br>"title", string, possibly null, title in the original script<br>"latin", string, possibly null, romanized version of "title"<br>"mtl", boolean, whether this is a machine translation<br>"main", boolean, whether this title is used as main title for the release entry.<br>There is always exactly one object where "main" is true.
+vn | vn | array of objects | no | Array of visual novels linked to this release. Objects have the following members: id, rtype, title and original. The "rtype" field indicates whether the release is a "trial", "partial" or "complete" for the given VN. The other fields are the same as the members of the "get vn" command.
+producers | producers | array of objects | no | (Possibly empty) list of producers involved in this release. Objects have the following members:<br> "id", integer<br> "developer", boolean,<br> "publisher", boolean,<br> "name", string, romaji name<br> "original", string, official/original name, can be **null**<br> "type", string, producer type
+links | links | array of objects | no | List of external links, each represented as an object with string members "label" and "url". Multiple links with the same label may be present. The official website is also included in this list, if one is known.
+
+Sorting is possible on the 'id', 'title' and 'released' fields.
+
+Accepted filters:
+
+| Field | Value | Operators | Notes |
+|--|--|--|-------------
+id | integer<br>array of integers | = != > >= < <=<br>= != |
+vn | integer<br>array of integers | = != | Find releases linked to the given visual novel ID.
+producer | integer | = | Find releases linked to the given producer ID.
+title | string | = != ~ |
+original | null<br>string | = !=<br>= != ~ |
+released | null<br>date (string) | = !=<br>= != > >= < <= | Note about released filter for the vn type also applies here.
+patch | boolean | = |
+freeware | boolean | = |
+doujin | boolean | = |
+type | string | = != |
+gtin | int | = != | Value can also be escaped as a string (if you risk an integer overflow otherwise)
+catalog | string | = != |
+languages | string<br>array of strings | = != |
+platforms | string<br>array of strings | = != |
+
+
+## get producer
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|--------------
+id | - | integer | no | Producer ID
+name | basic | string | no | (romaji) producer name
+original | basic | string | yes | Original/official name
+type | basic | string | no | Producer type
+language | basic | string | no | Primary language
+links | details | object | no | External links, object has the following members:<br> "homepage", official homepage,<br>"wikipedia", string, name of the related article on the English Wikipedia (deprecated, use wikidata instead).<br>"wikidata", string, Wikidata identifier.<br>All members can be **null**.
+aliases | details | string | yes | List of alternative names, separated by a newline
+description | details | string | yes | Description/notes of the producer, can contain formatting codes as described in [d9#3](https://vndb.org/d9#3)
+relations | relations | array of objects | no | (possibly empty) list of related producers, each object has the following members:<br> "id", integer, producer ID,<br> "relation", string, relation to the current producer,<br> "name", string,<br> "original", string, can be **null**
+
+Sorting is possible on the 'id' and 'name' fields.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|-----------------
+id | integer<br>array of integers | = != > >= < <=<br>= != |
+name | string | = != ~ |
+original | null<br>string | = !=<br>= != ~ |
+type | string | = != |
+language | string<br>array of strings | = != |
+search | string | ~ | Not an actual field. Performs a search on the name, original and aliases fields.
+
+
+## get character
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-----------------
+id | - | integer | no | Character ID
+name | basic | string | no | (romaji) name
+original | basic | string | yes | Original (kana/kanji) name
+gender | basic | string | yes | Character's sex (not gender); "m" (male), "f" (female) or "b" (both)
+spoil\_gender | basic | string | yes | Actual sex, if this is a spoiler. Can also be "unknown" if their actual sex is not known but different from their apparent sex.
+bloodt | basic | string | yes | Blood type, "a", "b", "ab" or "o"
+birthday | basic | array | no | Array of two numbers: day of the month (1-31) and the month (1-12). Either can be null.
+aliases | details | string | yes | Alternative names, separated with a newline.
+description | details | string | yes | Description/notes, can contain formatting codes as described in [d9#3](https://vndb.org/d9#3). May also include [spoiler] tags!
+age | details | int | yes | years
+image | details | string | yes | HTTP link to the character image.
+image\_flagging | details | object | yes | Image flagging summary, see the similar "image\_flagging" field of "get vn".
+image\_width | details | integer | yes |
+image\_height | details | integer | yes |
+bust | meas | integer | yes | cm
+waist | meas | integer | yes | cm
+hip | meas | integer | yes | cm
+height | meas | integer | yes | cm
+weight | meas | integer | yes | kg
+cup\_size | meas | string | yes |
+traits | traits | array of arrays | no | (Possibly empty) list of traits linked to this character. Each trait is represented as an array of two elements: The trait id (integer) and the spoiler level (integer, 0-2). General information for each trait is available in the [traits dump](https://vndb.org/d14#3).
+vns | vns | array of arrays | no | List of VNs linked to this character. Each VN is an array of 4 elements: VN id, release ID (0 = "all releases"), spoiler level (0-2) and the role (string).<br> Available roles: "main", "primary", "side" and "appears".
+voiced | voiced | array of objects | no | List of voice actresses (staff) that voiced this character, per VN. Each staff/VN is represented as a object with the following members:<br> "id", integer, staff ID<br> "aid", integer, the staff alias ID being used<br> "vid", integer, VN id<br> "note", string<br> The same voice actor may be listed multiple times if this entry is character to multiple visual novels. Similarly, the same visual novel may be listed multiple times if this character has multiple voice actors in the same VN.
+instances | instances | array of objects | no | List of instances of this character (excluding the character entry itself). Each instance is represented as an object with the following members:<br> "id", integer, character ID<br> "spoiler", integer, 0=none, 1=minor, 2=major<br> "name", string, character name<br> "original", string, character's original name.
+
+Sorting is possible on the 'id' and 'name' fields.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|-----------------
+id | integer<br>array of integers | = != > >= < <=<br>= != |
+name | string | = != ~ |
+original | null<br>string | = !=<br>= != ~ |
+search | string | ~ | Not an actual field. Performs a search on the name, original and aliases fields.
+vn | integer<br>array of integers | = | Find characters linked to the given visual novel ID(s). Note that this may also include characters that are normally hidden by spoiler settings.
+traits | int<br>array of ints | = != | Find chars by traits. When providing an array of ints, the '=' filter will return chars that are linked to any (not all) of the given traits, the '!=' filter will return chars that are not linked to any of the given traits. You can combine multiple trait filters with 'and' and 'or' to get the exact behavior you need.<br> This filter may use cached data, it may take up to 24 hours before a char entry will have its traits updated with respect to this filter.<br> Chars that are linked to childs of the given trait are also included.<br> Be warned that this filter ignores spoiler settings, fetch the traits associated with the returned char to verify the spoiler level.
+
+
+## get staff
+
+Unlike other database entries, staff have more than one unique identifier.
+
+There is the main 'staff ID', which uniquely identifies a person and is what
+the staff pages on the site represent.
+
+Additionally, every staff name and alias also has its own unique identifier,
+which is referenced from other database entries to identify which alias was
+used. This identifier is generally hidden on the site and aliases do not have
+their own page, but the IDs are exposed in this API in order to facilitate
+linking between VNs/characters and staff.
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-------------------
+id | - | integer | no | Staff ID
+name | basic | string | no | Primary (romaji) staff name
+original | basic | string | yes | Primary original name
+gender | basic | string | yes |
+language | basic | string | no | Primary language
+links | details | object | no | External links, object has the following members:<br> "homepage", official homepage,<br>"wikipedia", string, name of the related article on the English Wikipedia (deprecated, use wikidata instead).<br> "twitter", name of the twitter account.<br> "anidb", [AniDB](http://anidb.net/) creator ID.<br> "pixiv", integer, id of the pixiv account.<br> "wikidata", string, Wikidata identifier.<br>All values can be **null**.
+description | details | string | yes | Description/notes of the staff, can contain formatting codes as described in [d9#3](https://vndb.org/d9#3)
+aliases | aliases | array of arrays | no | List of names and aliases. Each name is represented as an array with the following elements: Alias ID, name (romaji) and the original name.<br> This list also includes the "primary" name.
+main\_alias | aliases | integer | no | ID of the alias that is the "primary" name of the entry
+vns | vns | array of objects | no | List of visual novels that this staff entry has been credited in (excluding character voicing). Each vn is represented as an object with the following members:<br> "id", integer, visual novel id<br> "aid", integer, alias ID of this staff entry<br> "role", string<br> "note", string, may be null if unset<br> The same VN entry may appear multiple times if the staff has been credited for multiple roles.
+voiced | voiced | array of objects | no | List of characters that this staff entry has voiced. Each object has the following members:<br> "id", integer, visual novel id<br> "aid", integer, alias ID of this staff entry<br> "cid", integer, character ID<br> "note", string, may be null if unset<br> The same VN entry may appear multiple times if the staff has been credited for multiple characters. Similarly, the same character may appear multiple times if it has been linked to multiple VNs.
+
+Sorting is possible on the 'id' field.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|---------------
+id | integer<br>array of integers | = != > >= < <=<br>= != |
+aid | integer<br>array of integers | =<br>= |
+search | string | ~ | Searched through all aliases, both the romanized and original names.
+
+
+## get quote
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|----------------
+id | - | integer | no | VN ID
+title | basic | string | no | VN title
+quote | basic | string | no |
+
+Sorting is possible on the 'id' and the pseudo 'random' field (default).
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|---------------
+id | integer<br>array of integers | = != > >= < <=<br>= != |
+
+Note that a filter is required for all *get* commands, so to get a random quote, use:
+```
+get quote basic (id>=1) {"results":1}
+```
+
+## get user
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-----------------
+id | basic | integer | no | User ID
+username | basic | string | no
+
+The returned list is always sorted on the 'id' field.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|----------------
+id | integer<br>array of integers | = | The special value '0' is recognized as the currently logged in user.
+username | string<br>array of strings | = != ~<br>= |
+
+
+## get ulist-labels
+
+Fetch the labels for a user. Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-----------------
+uid | basic | integer | no | User ID
+id | basic | integer | no | Label ID
+label | basic | string | no |
+private | basic | boolean | no |
+
+The returned list is always sorted on the 'id' field.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|-------------------
+uid | integer | = | The special value '0' is recognized as the currently logged in user.
+
+Labels marked as private are only returned for the currently logged in user.
+
+Label ids are local to the user, id < 10 are built-in labels and are the same
+for every user, id >= 10 or above are custom labels created by the user or a
+migration script.
+
+
+## get ulist
+
+This command replaces the (obsolete and now undocumented) "get votelist", "get
+vnlist" and "get wishlist" commands.
+
+Returned members:
+
+| Member | Flag | Type | null? | Description
+|--|--|--|--|-----------------
+uid | basic | integer | no | User ID
+vn | basic | integer | no | Visual Novel ID
+added | basic | integer | no | Unix timestamp of when this item has been added.
+lastmod | basic | integer | no | Unix timestamp of when this item has been last modified.
+voted | basic | integer | yes | Unix timestamp when the vote has been cast.
+vote | basic | integer | yes | Vote between 10 and 100.
+notes | basic | string | yes |
+started | basic | string | yes | YYYY-MM-DD
+finished | basic | string | yes | YYYY-MM-DD
+labels | labels | array of objects | no | List of labels assigned to this VN entry, each object has the following fields:<br>"id", integer, label ID<br>"label", string, label name.
+
+Sorting is possible on the following fields: uid, vn, added, lastmod, voted, vote.
+
+The following filters are recognised:
+
+| Field | Value | Operators | Notes |
+|--|--|--|-------------------------
+uid | integer | = | The special value '0' is recognized as the currently logged in user.
+vn | integer<br>array of integers | = != > < >= <=<br>= != | Visual novel ID.
+label | integer | = | Label assigned to the VN. As a technical limitation, this filter does not return private labels even when the user is logged in.
+
+
+# The 'set' command
+
+The set command can be used to modify stuff in the database. It can only be
+used when logged in as a user. The command has the following syntax:
+
+```
+ set type id fields
+```
+
+Here, _type_ is similar to the type argument to the 'get' command, _id_ is the
+(integer) identifier of the database entry to change, and _fields_ is an object
+with the fields to set or modify. If the _fields_ object is not present, the
+set command works as a 'delete'. The interpretation of the _id_ and _fields_
+arguments depend on the _type_, and are documented in the sections below.
+
+But before that, let me present some examples to get a feel on what the
+previous paragraph meant. The following example adds a '10' vote on
+[v17](https://vndb.org/v17), or changes the vote to a 10 if a previous vote was
+already present:
+
+```
+ set ulist 17 {"vote":100}
+```
+
+And here's how to remove Ever17 from the list:
+
+```
+ set ulist 17
+```
+
+'set' replies with a simple 'ok' on success, or with an 'error' (see below) on
+failure. Note that, due to my laziness, no error is currently returned if the
+identifier does not exist. So voting on a VN that does not exist will return an
+'ok', but no vote is actually added. This behaviour may change in the future.
+Note that this API doesn't care whether the VN has been deleted or not, so you
+can manage votes and stuff for deleted VNs (Which isn't very helpful, because
+'get vn' won't return a thing for deleted VNs).
+
+
+## set ulist
+
+This command replaces the "set votelist", "set vnlist" and "set wishlist"
+commands.
+
+This command facilitates adding, removing and modifying your VN list. The
+_identifier_ argument is the visual novel ID, and the following fields are
+recognized:
+
+| Field | Type | Description
+|--|--|----------------
+notes | string | Same as the 'notes' member returned by 'get ulist'. An empty string is considered equivalent to 'null'.
+started | string | Same as the 'started' member returned by 'get ulist'.
+finished | string | Same as the 'started' member returned by 'get ulist'.
+vote | integer | Same as the 'vote' member returned by 'get ulist', in the range 10 to 100.
+labels | array of integers | List of label IDs to assign to this VN. This will overwrite any previously assigned labels. Label id 7 ("Voted") is automatically assigned based on whether the vote field is set, so it does not need to be included here. An attempt to assign it anyway will be ignored. Attempts to assign an unknown label ID will be silently ignored, but this is subject to change.
+
+When removing a ulist item, any releases associated with the VN will be removed
+from the users' list as well. The release list functionality is not currently
+exposed to the API, so is only visible when the web interface is used.
+
+
+
+# The 'error' response
+
+Every command to the server can receive an 'error' response, this response has
+one argument: a JSON object containing at least a member named "id", which
+identifies the error, and a "msg", which contains a human readable message
+explaining what went wrong. Other members are also possible, depending on the
+value of "id". Example error message:
+
+```
+ error {"id":"parse", "msg":"Invalid command or argument"}
+```
+
+Note that the value of "msg" is not directly linked to the error identifier:
+the message explains what went wrong in more detail, there are several
+different messages for the same id. The following error identifiers are
+currently defined:
+
+parse
+: Syntax error, unknown command or invalid argument type.
+
+missing
+: A JSON object argument is missing a required member. The name of which is
+given in the additional "field" member.
+
+badarg
+: A JSON value is of the wrong type or in the wrong format. The name of the
+incorrect field is given in a "field" member.
+
+needlogin
+: Need to be logged in to issue this command.
+
+throttled
+: You have used too many server resources within a short time, and need to wait
+a bit before sending the next command. The type of throttle is given in the
+"type" member, and the "minwait" and "fullwait" members tell you how long you
+need to wait before sending the next command and when you can start bursting
+again (this is the recommended waiting time), respectively. Both values are in
+seconds, with a precision of 0.1 seconds.
+
+auth
+: (login) Incorrect username/password combination.
+
+loggedin
+: (login) Already logged in. Only one successful login command can be issues on
+one connection.
+
+gettype
+: (get) Unknown type argument to the 'get' command.
+
+getinfo
+: (get) Unknown info flag to the 'get' command. The name of the unrecognised
+flag is given in an additional "flag" member.
+
+filter
+: (get) Unknown filter field or invalid combination of field/operator/argument
+type. Includes three additional members: "field", "op" and "value" of the
+incorrect expression.
+
+settype
+: (set) Unknown type argument to the 'set' command.
+
+
+
+# Change Log
+
+This section lists the changes made in each version of the VNDB code. Check out
+the [announcements board](https://vndb.org/t/an) for more information about
+updates.
+
+**2023-07-11**
+
+- Deprecated "popularity" member of "get vn stats"
+- Deprecated "popularity" sort option of "get vn"
+
+**2022-10-04**
+
+- Add "official" member to "get release basic"
+- Add "id" and "thumbnail(|\_width|height)" members to "get vn screens"
+- Add "image\_(width|height)" members to "get vn details"
+- Add "image\_(width|height)" members to "get character details"
+
+**2022-10-02**
+
+- Add "get vn titles"
+- Add "length\_minutes" and "length\_votes" members to "get vn basic"
+- Add "get release lang"
+- Add "get release links"
+
+**2021-12-15**
+
+- Add support for creating and logging in with session tokens in the "login" command.
+
+**2021-11-15**
+
+- The "vn" object returned by "get release" now includes an "rtype" field.
+- The "type" field returned by "get release" has been deprecated in favor of the above.
+
+**2021-01-30**
+
+- The "orig\_lang" field in "get vn" now always returns exactly one language.
+
+**2020-12-29**
+
+- Add "get quote" command.
+
+**2020-11-13**
+
+- New fields for "get character": age, cup\_size and spoil\_gender.
+
+**2020-07-09**
+
+- Deprecated the "image\_nsfw" and "nsfw" flags given by "get vn details,screens"
+- Added "image\_flagging" fields to "get vn details" and "get character details"
+- Added "flagging" field to "get vn screens"
+
+**2020-04-09**
+
+- The "dbstats" command no longer returns stats for *users*, *threads* and *posts*.
+
+**2020-01-01**
+
+- Deprecated the get/set votelist/wishlist/vnlists commands
+- The "get ulist-labels", "get ulist" and "set ulist" commands should now be
+ used in new code
+- See [t13365](https://vndb.org/t13365) for more details
+
+**2019-12-05**
+
+- Early API support for the [new lists feature](https://vndb.org/t13136). The
+ votelist/wishlist/vnlist commands will be updated when it goes out of beta.
+- Add "get ulist-labels"
+- Add "get ulist"
+- Add "set ulist"
+
+**2019-10-07**
+
+- Add wikidata links to "get vn/producer/staff"
+- Add pixiv links to "get staff"
+
+**2018-06-13**
+
+- Add "get character instances"
+
+**2018-02-07**
+
+- The 'aliases' member for "get producer" is now uses newline as separation rather than a comma
+
+**2017-08-14**
+
+- Add 'uid' field to "get votelist/vnlist/wishlist" commands
+- Add 'vn' filter to the same commands
+- The 'uid' filter for these commands is now optional, making it possible to find all list entries for a particular VN
+
+**2017-06-21**
+
+- Add "resolution", "voiced", "animation" members to "get release" command
+- Add "platforms" filter to "get release" command
+- Accept arrays for the "vn" filter to the "get release" command
+- Add "search" filter to "get staff"
+
+**2017-05-22**
+
+- Add "vns" and "voiced" flags to "get staff" command
+- Add "voiced" flag to "get character" command
+
+**2017-04-28**
+
+- Add "get staff" command
+- Add "staff" flag to "get vn" command
+
+**2.27**
+
+- Add "username" filter to "get user"
+- Add "traits" filter to "get character"
+
+**2.25**
+
+- Add "tags" filter to "get vn"
+- Increased connection limit per IP from 5 to 10
+- Increased command limit from 100 to 200 commands per 10 minutes
+- Added support for TLS
+- Added "screens" flag and member to "get vn"
+- Added "vns" flag and member to "get character"
+- Allow sorting "get vn" on popularity, rating and votecount
+- Added basic "get user" command
+- Added "official" field to "get vn relations"
+
+**2.23**
+
+- Added new 'dbstats' command
+- Added new 'get' types: character, votelist, vnlist and wishlist
+- Added 'set' command, with types: votelist, vnlist and wishlist
+- New error id: 'settype'
+- Added "tags" flag and member to "get vn"
+- Added "stats" flag to "get vn"
+- Added "firstchar" filter to "get vn"
+- Added "vn" filter to "get character"
+
+**2.15**
+
+- Fixed a bug with the server not allowing extra whitespace after a "get .. " command
+- Allow non-numbers as "clientver" for the login command
+- Added "image\_nsfw" member to "get vn"
+- Added "results" option to the "get .. {<options>}"
+- Increased the maximum number of results for the "get .." command to 25
+- Added "orig\_lang" member and filter to the "get vn .." command
+- Throttle the commands and sqltime per IP instead of per user
+- Removed the limit on the number of open sessions per user
+- Allow the API to be used without logging in with a username/password
+
+**2.12**
+
+- Added "image" member to "get vn"
+- A few minor fixes in some error messages
+- Switched to a different (and faster) search algorithm for "get vn .. (search ~ ..)"
diff --git a/data/conf_example.pl b/conf_example.pl
index 5885e8e9..c4788971 100644
--- a/data/conf_example.pl
+++ b/conf_example.pl
@@ -9,6 +9,9 @@
# Global salt used to hash user passwords (used in addition to a user-specific salt)
scrypt_salt => '<another unique string>',
+ # Use the more secure imgproc
+ #imgproc_path => "$main::ROOT/imgproc/imgproc-custom",
+
# TUWF configuration options, see the TUWF::set() documentation for options.
tuwf => {
db_login => [ 'dbi:Pg:dbname=vndb', 'vndb_site', 'vndb_site' ],
@@ -17,12 +20,9 @@
debug => 1,
cookie_defaults => { domain => 'localhost', path => '/' },
mail_sendmail => 'log',
+ #fastcgi_max_requests => 1000 + int(rand(1000)),
},
- # Uncomment if you want to test password strength against a dictionary. See
- # lib/PWLookup.pm for instructions on how to create the database file.
- #password_db => 'data/passwords.dat',
-
# Options for Multi, the background server.
Multi => {
# Each module in lib/Multi/ can be enabled and configured here.
diff --git a/css/blendbg.css b/css/blendbg.css
new file mode 100644
index 00000000..ab4ba6e8
--- /dev/null
+++ b/css/blendbg.css
@@ -0,0 +1,6 @@
+$blendbg: rgb(
+ (red($boxbg) * alpha($boxbg) + red($bodybg) * (1 - alpha($boxbg))),
+ (green($boxbg) * alpha($boxbg) + green($bodybg) * (1 - alpha($boxbg))),
+ (blue($boxbg) * alpha($boxbg) + blue($bodybg) * (1 - alpha($boxbg)))
+);
+html { --blendbg: #{$blendbg} }
diff --git a/css/forms.css b/css/forms.css
new file mode 100644
index 00000000..ca4defbc
--- /dev/null
+++ b/css/forms.css
@@ -0,0 +1,282 @@
+/***** general form markup *****/
+
+/* TODO: The .text/.submit classes should be removed */
+
+input.text, input.submit, input[type=text], input[type=password], input[type=email], input[type=button], input[type=submit], select, textarea, button {
+ background-color: var(--boxbg);
+ color: var(--maintext);
+ border: 1px solid var(--secborder);
+ font: 14px "Tahoma", "Arial", sans-serif;
+ padding: 1px;
+ margin: 1px;
+ &:hover, &:focus { background-color: var(--secbg) }
+}
+input.submit, input[type=submit], input[type=button], button { padding: 1px 5px; cursor: pointer }
+form, fieldset { border: 0; display: block }
+select[multiple] option {
+ & { background: inherit; padding-left: 12px; position: relative }
+ &:checked:before { position: absolute; left: 0; top: 0; content: '✓' }
+}
+optgroup option { padding-left: 10px }
+button { text-align: left }
+button svg { height: 14px; width: 14px; margin-top: 1px; margin-bottom: -1px }
+button.ds svg { float: right; margin-top: 3px; margin-right: -2px; margin-left: -5px }
+button span { white-space: nowrap }
+input.obscured { color: transparent; text-shadow: 0 0 8px var(--maintext); }
+input[type=number] { -moz-appearance:textfield }
+input[type=number]::-webkit-outer-spin-button, input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0 }
+input[type=color] { width: 30px; height: 21px }
+input[type=checkbox], input[type=radio] {
+ appearance: none; width: 15px; height: 15px; position: relative; top: 2px; margin-bottom: -1px; border: 2px solid var(--border);
+ &:focus { outline: 1px dotted #fff }
+ &:checked::before {
+ content: '';
+ display: block; width: 80%; height: 80%;
+ position: relative; top: 10%; left: 10%;
+ background-color: var(--maintext);
+ /* Path from https://moderncss.dev/pure-css-custom-checkbox-style/ */
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
+ }
+}
+input[type=radio] { border-radius: 50% }
+
+/* Only used on js/graph/vn.js for now.
+ * Based on https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ */
+input[type=range] {
+ -webkit-appearance: none;
+ width: 200px;
+ height: 14px;
+ margin: 1px;
+ background: transparent;
+ &::-webkit-slider-thumb, &::-moz-range-thumb {
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 7px;
+ background: var(--maintext);
+ cursor: pointer;
+ }
+ &::-webkit-slider-runnable-track, &::-moz-range-track {
+ width: 100%;
+ height: 4px;
+ background: var(--grayedout);
+ cursor: pointer;
+ }
+}
+
+
+/* Old table-based form layout, used in Elm */
+
+td.label, td.label label { width: 130px; }
+td.label label { display: block; }
+td.field label { margin: 0 5px 0 5px; }
+table.formtable { margin: 0 20px 20px 20px; }
+table.formtable td { padding: 0; }
+table.formtable tr.newfield > td { padding-top: 5px; }
+table.formtable tr.newpart td { padding-top: 20px; font-weight: bold; }
+table.formtable td table td { padding: 1px 15px 1px 0px }
+table.formtable td table { margin-bottom: 5px }
+table.formtable input.text, table.formtable select { width: 200px; }
+
+table.formimage > tr > td:nth-child(1) { width: 300px; height: 300px; text-align: center }
+table.formimage > tr > td:nth-child(1) img { max-width: 290px; max-height: 500px }
+table.formimage h2 { margin: 0 }
+
+
+
+/* New fielset-based form layout, used in JS */
+
+fieldset.form {
+ margin-left: 140px; margin-bottom: 20px;
+ > fieldset { margin-bottom: 5px }
+ > fieldset:disabled > label { color: var(--grayedout) }
+ > legend { margin-left: -130px; margin-bottom: 5px; font-weight: bold }
+ > label:not(.check), > fieldset > label:not(.check) { display: block; float: left; margin-left: -130px; width: 130px }
+ table.full { margin-left: -130px }
+ .xw { width: 100%; max-width: 500px } /* for long inputs */
+ .lw { width: 100%; max-width: 300px } /* for slightly longer inputs */
+ .mw { width: 100%; max-width: 200px } /* for most inputs */
+ .sw { width: 50px } /* for shorter inputs, like numbers */
+ td { padding: 1px 15px 1px 0px; line-height: 1.8 }
+ table { margin-bottom: 5px }
+ p + table { margin-top: 5px }
+ a.help {
+ border: none; margin-left: 3px; color: var(--helpbutton, var(--link));
+ svg { width: 16px; height: 16px; margin-bottom: -2px }
+ }
+ section.help {
+ margin: -5px 0 5px -130px;
+ max-width: 700px;
+ background-color: var(--noticebg); border: 1px solid var(--noticeborder);
+ position: relative; padding: 5px;
+ > a:first-child {
+ float: right; margin-top: -4px; margin-right: -4px;
+ text-decoration: none; color: var(--maintext);
+ svg { width: 18px; height: 18px; border: none; }
+ &:hover { border: none }
+ }
+ p + p { padding-top: 10px }
+ dl { margin: 10px 0; display: grid; grid-template-columns: auto 1fr; grid-gap: 3px 10px }
+ dt { font-weight: bold }
+ }
+ .textpreview { max-width: 600px }
+ .textpreview.full { max-width: 730px; margin-left: -130px }
+ .release-animation td { width: 180px; line-height: normal }
+}
+
+p.invalid { display: none }
+.formerror { display: none }
+
+/* 'invalid-form' class is set on the parent <form> element after attempted
+ * submission, so that error states and messages don't distract when working on
+ * an empty form */
+form.invalid-form {
+ p.invalid { display: block; color: var(--standout) }
+ input.invalid, textarea.invalid { border-color: var(--standout) }
+ label.invalid { color: var(--standout) }
+ .invalid-tab a { color: var(--standout) }
+ .formerror { display: block; color: var(--standout) }
+}
+
+/* TODO: responsive form layout for mobile */
+
+
+/* Form submit box, with optional edit summmary */
+article.submit {
+ text-align: center;
+ input[type=submit] { width: 150px }
+ .textpreview, textarea { text-align: left; margin: 0 auto; width: 600px }
+}
+
+
+
+/* Format checkboxes and radio buttons as if they were normal links with unicode icons.
+ * Usage:
+ *
+ * <container class="linkradio">
+ * <input type="checkbox|radio" id="xyz">
+ * <label for="xyz">Text</label>
+ * <em>(optional option separator)</em>
+ * </container>
+ *
+ * TODO: Get rid of these and just use checkboxes/radio inputs.
+ */
+p.linkradio { padding: 2px }
+.linkradio label { color: var(--link); cursor: pointer }
+.linkradio label:before { content: '✗' }
+.linkradio input { display: none }
+.linkradio input:checked + label { color: var(--maintext) }
+.linkradio input:checked + label:before { content: '✓' }
+.linkradio input:focus + label { outline: 1px dotted var(--link) }
+.linkradio input:focus:checked + label { outline: 1px dotted var(--maintext) }
+.linkradio em { font-weight: normal; font-style: normal; color: var(--grayedout) }
+
+/* Same styling, but for regular links.
+ * Usage:
+ *
+ * <a href="#" class="linkradio">Unchecked option</a>
+ * <a href="#" class="linkradio checked">Checked option</a>
+ */
+a.linkradio:before { content: '✗' }
+a.linkradio.checked:before { content: '✓' }
+a.checked { color: var(--maintext) }
+
+
+/* Spinner, <div class="spinner"></div> for a large one, <span> for a smaller inline-text version */
+.spinner { content: ''; border: 3px solid #9eaebd; border-bottom-color: transparent; border-radius: 100%; animation: spin 1s infinite linear; width: 16px; height: 16px; display: inline-block; margin: auto }
+span.spinner { width: 1em; height: 1em }
+@keyframes spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
+
+
+.textpreview {
+ > div:first-child {
+ display: flex; justify-content: space-between; width: 100%; align-items: flex-end;
+ > div:last-child > * { margin-left: 10px }
+ }
+ textarea { width: 100%; }
+ .preview { width: 100%; border: 1px solid var(--secborder); margin: 1px; padding: 5px }
+}
+
+
+/* .compact input elements are smaller and can be embedded in tables/inline text
+ * .stealth input elements pretend to be just regular text, but turn into visibile input elements on mouse-over */
+.compact input.text, .compact select, .compact textarea { margin: -2px -1px; padding: 1px 0 }
+.compact input.submit { margin: -2px -1px; padding: 1px 3px }
+.stealth input, .stealth select { font: inherit; background: none; border: 1px solid transparent; -moz-appearance: none; -webkit-appearance: none; appearance: none }
+.stealth input:hover, .stealth input:focus,
+.stealth select:hover, .stealth select:focus { border: 1px solid var(--secborder); background: var(--secbg) }
+
+
+
+
+/* Elm dropdowns (also in perl: VNWeb::Releases::Lib) */
+
+.elm_dd > a { color: var(--maintext); display: block; border: none; padding-right: 15px; position: relative }
+.elm_dd > a > span:last-child { position: absolute; right: 5px; top: 0; width: 16px; text-align: right; display: block }
+.elm_dd > a > span:last-child .arrow { visibility: hidden }
+.elm_dd > a .nowrap { display: block; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.elm_dd > a:hover > span:last-child > .arrow,
+.elm_dd > a:focus > span:last-child > .arrow { visibility: visible }
+.elm_dd > div { position: relative; float: right; width: 0; height: 0 }
+.elm_dd > div > div { position: absolute; right: -10px; top: 0; border: 1px solid var(--border); background-color: var(--secbg); z-index: 1000; margin: 0; padding: 0; max-width: 400px }
+.elm_dd.search > div { float: left }
+.elm_dd.search > div > div { right: auto; left: 0; top: 23px }
+.elm_dd ul { width: 100%; list-style-type: none; margin: 0; padding: 0 }
+.elm_dd ul li { white-space: nowrap }
+.elm_dd ul li a { display: block; border: 0; padding: 3px 5px 3px 3px }
+.elm_dd ul li a.active,
+.elm_dd ul li a:hover { background: var(--boxbg) }
+.elm_dd ul li p { white-space: normal; padding: 3px 5px 3px 3px }
+.elm_dd ul li.separator { margin-bottom: 22px }
+
+.elm_votedd .elm_dd ul li { text-align: left }
+.elm_dd_input .elm_dd > a,
+.elm_dd_button .elm_dd > a { background-color: var(--boxbg); color: var(--maintext); border: 1px solid var(--secborder); font: 14px "Tahoma", "Arial", sans-serif; padding: 1px 15px 1px 5px; margin: -1px }
+.elm_dd_input .elm_dd > a { padding: 1px 15px 1px 2px }
+.elm_dd_input .elm_dd > a:hover,
+.elm_dd_button .elm_dd > a:hover { background-color: var(--secbg) }
+.elm_dd_input.elm_dd_noarrow .elm_dd > a { padding-right: 2px }
+.elm_dd_noarrow .elm_dd > a { padding-right: 0 }
+.elm_dd_noarrow .elm_dd > a > span:last-child { display: none }
+.elm_dd_hover .elm_dd > div { display: none }
+.elm_dd_hover .elm_dd:hover > div { display: block }
+.elm_dd_left .elm_dd > div { float: left }
+.elm_dd_left .elm_dd > div > div { right: 0; top: -20px }
+.elm_dd_rightish .elm_dd > div > div { right: auto; left: -30px }
+.elm_dd_relextlink .elm_dd > a { padding-left: 4px; color: var(--link) }
+.elm_dd_relextlink ul a { text-align: right }
+.elm_dd_relextlink ul span { color: var(--maintext); padding-right: 10px }
+
+
+/***** JS dropdown (ds.js) *****/
+
+#ds {
+ position: absolute; z-index: 2; background: var(--secbg); border: 1px solid var(--secborder);
+ > div:first-child {
+ display: flex;
+ > div:first-child {
+ flex: 1; position: relative;
+ input { width: 100%; outline: none; border-top: none; border-right: none; border-left: none; margin: 0; padding: 2px }
+ span { position: absolute; top: 4px; right: 5px }
+ svg { width: 14px; height: 14px }
+ }
+ }
+ > div:last-child {
+ overflow-x: hidden; overflow-y: scroll; width: 100%; position: relative;
+ > ul {
+ margin: 0; list-style-type: none; width: 100%; height: 100%;
+ > li {
+ white-space: nowrap; cursor: pointer; padding: 3px 3px 3px 3px;
+ > span:first-child { display: inline-block; width: 1em; visibility: hidden }
+ &.active {
+ background: var(--boxbg);
+ > span:first-child { visibility: visible }
+ }
+ &.unselectable {
+ color: var(--grayedout);
+ > span:first-child { visibility: visible }
+ }
+ }
+ }
+ }
+}
diff --git a/css/layout.css b/css/layout.css
new file mode 100644
index 00000000..7ab8046c
--- /dev/null
+++ b/css/layout.css
@@ -0,0 +1,158 @@
+/* We reserve some tags for layouting:
+ * - <article> always represents a box
+ * - <nav> is reserved for either the main menu (top-level tag) or tabs (otherwise)
+ * - <main> - there can only be one in a valid document anyway
+ *
+ * Any other tags should still be re-usable (and unstyled) inside <article>.
+ */
+body {
+ display: grid;
+ grid-template-columns: 170px minmax(0, 1fr);
+ grid-template-rows: 189px auto;
+ padding: 0 30px 30px 30px;
+}
+
+article { border: 1px solid var(--border); background: var(--boxbg) }
+
+
+/* Header */
+
+body > header {
+ grid-area: 1/1/2/3;
+ display: flex; flex-direction: column;
+ h1 {
+ flex: 1; display: flex; align-items: center; justify-content: flex-end;
+ padding-right: unquote('max(30px, calc(100% - 700px))'); padding-top: 30px;
+ font: bold italic 24px "Futura", "Century New Gothic", "Arial", Serif; color: var(--maintitle);
+ a { color: inherit; border-bottom: none }
+ a:hover { border-bottom: none }
+ }
+ nav > label { display: block; border: 1px solid var(--border); background: var(--boxbg); cursor: pointer; padding: 3px 15px; margin: 0 10px 10px 0; visibility: hidden }
+}
+#bgright { position: absolute; top: 0px; right: 0px; z-index: -10 }
+#readonlymode {
+ position: absolute; top: 0px; left: 0px; width: 100%;
+ text-align: center; padding: 3px;
+ background: var(--warnbg); border-bottom: 1px solid var(--warnborder);
+}
+
+
+
+
+/* Main navigation */
+
+body > nav {
+ grid-area: 2/1/3/2;
+ margin-right: 10px;
+ article { margin: 0 0 10px 0 }
+ article div { padding: 2px 7px }
+ fieldset { padding: 0 7px 3px 6px }
+ a { color: var(--maintext) }
+ a:hover { border-bottom: 1px dotted var(--maintext); }
+ h2 { border-bottom: 1px solid var(--border); background: var(--boxbg); padding: 1px 3px; }
+ input.text { width: 100% }
+ .notifyget { display: inline-block; width: 95%; padding: 4px; background: var(--warnbg); border: 1px solid var(--warnborder); }
+ .logout { border: 0; border-bottom: 1px solid transparent; background: none; cursor: pointer; font: inherit; padding: 0; margin: 0 }
+ .logout:hover { border-bottom: 1px dotted var(--maintext) }
+
+ dl { display: flex; flex-wrap: wrap }
+ dt { width: 60%; font-style: italic }
+ dd { width: 40%; text-align: right }
+}
+
+#support { background: var(--boxbg); font-size: 92%; padding: 4px; margin-bottom: 5px; text-align: center }
+#support p { display: flex; justify-content: space-between }
+
+
+
+/* Main content boxes */
+
+main {
+ grid-area: 2 / 2 / 3 / 3;
+ padding: 0 0 50px 0;
+ h1, h2 { font-family: "Futura", "Century New Gothic", "Arial", serif; }
+ h1 { color: var(--boxtitle); font-size: 24px; font-weight: normal; margin: -5px 0 15px 0 }
+ h2.alttitle { color: var(--alttitle); font-size: 120%; font-weight: normal; margin: -17px 0 15px 15px }
+
+ /* Box */
+ article {
+ margin-bottom: 10px; padding: 5px;
+ &.browse {
+ padding: 0;
+ > table { width: 100% }
+ }
+ &.overflow-hack { overflow: hidden; width: 100% }
+ &.relgraph { float: left; min-width: 100% }
+ }
+}
+
+/* Tabs */
+body > * nav {
+ display: flex; justify-content: space-between; align-items: flex-end;
+ &.right { justify-content: flex-end; }
+
+ > menu > li {
+ display: inline-block;
+ &:not(:first-child) { margin: 0 0 0 10px }
+ > a { display: inline-block; height: 21px; padding: 1px 7px 0 7px; border: 1px solid var(--border); border-bottom: none; background-color: var(--tabbg); color: var(--grayedout); position: relative }
+ > a:hover { border-bottom: none }
+ &.tabselected > a, > a:hover { background: var(--blendbg); color: var(--maintext); height: 22px; margin-bottom: -1px; }
+ > a.highlightselected { background: var(--secbg); color: var(--maintext); height: 22px; margin-bottom: -1px }
+ > a > svg { margin-top: 2px; margin-bottom: -2px }
+ }
+ .browsetabs > li {
+ > a { color: inherit }
+ &:not(:first-child) { margin-left: 5px }
+ }
+ &.bottom {
+ margin: -10px 0 10px 0; align-items: flex-start;
+ > menu {
+ > li {
+ > a { padding: 4px 7px 2px 7px; border-bottom: 1px solid var(--border); border-top: none }
+ &.tabselected > a, > a:hover { padding-top: 5px; height: 22px; margin-top: -1px }
+ }
+ }
+ }
+ .ellipsis { font-weight: bold; height: 19px }
+
+ > h1 {
+ font-size: 17px; color: var(--grayedout); margin: 0;
+ a { color: inherit }
+ }
+}
+
+.threelayout {
+ display: flex; column-gap: 10px;
+ article { flex: 1 1 0; padding: 0 2px 10px 2px; min-width: 30% }
+ h1 { margin: 0; font-size: 18px; font-weight: bold; color: var(--boxtitle) }
+ a.right { float: right; }
+ ul { list-style-type: none; margin-left: 10px; }
+ h1 a { color: inherit }
+}
+@media (max-width: 900px) {
+ .threelayout { flex-wrap: wrap }
+ .threelayout article { min-width: 90% }
+}
+
+.summarize_more { height: 20px; margin: -11px 0 11px 0; border: 1px solid var(--border); border-top: none; background: var(--boxbg); text-align: center }
+
+
+
+/* Mobile view */
+
+@media (max-width: 800px) {
+ #mainmenu:not(:checked) ~ nav { display: none }
+ #mainmenu:not(:checked) ~ main { grid-area: 2/1/3/3; }
+ body { padding: 0 10px; }
+ #bgright { display: none /* XXX: This is theme-specific */ }
+ body > header nav > label { visibility: visible }
+}
+
+
+
+
+main > footer {
+ margin: -7px auto 0 auto; text-align: center; color: var(--footer);
+ a { color: inherit; text-decoration: underline }
+ span a { text-decoration: none }
+}
diff --git a/css/skins/air.sass b/css/skins/air.sass
index 09f2d376..9371b1a0 100644
--- a/css/skins/air.sass
+++ b/css/skins/air.sass
@@ -6,35 +6,40 @@
// Some portions (c)2000 Key/VisualArt's //
////////////////////////////////////////////////////////////////
-$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
-$diffadd: #aaccbc
-$diffdel: #ccaabb
-$warnbg: #ccaabb
-$warnborder: #ff3833
-$noticebg: #aaccbc
-$noticeborder: #33ff38
-@mixin bodybg
- background: $bodybg url(/s/air-bg.jpg) no-repeat;
+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
-@mixin bgright
+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
index 07246106..1a40ba00 100644
--- a/css/skins/angel.sass
+++ b/css/skins/angel.sass
@@ -1,43 +1,48 @@
// userid: u2 name: Angelic Serenade (dark blue)
// ^ Must be the first line of skin files, read by VNDB::Skins.
-// 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: #000 // main background color
-$tabbg: #012 // background color of inactive tabs
-$secbg: #0d2741 // secondary background color (used on input fields and table headers)
-$secborder: #35A // secondary border color (used on input fields)
-$border: #258 // primary border color
-$boxbg: #071c30bc // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// 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
-
-// Page backgrounds
-@mixin bodybg
- background: $bodybg url(/s/angel-bg.jpg) no-repeat
-
-@mixin bgright
+$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
index ee2f242b..1ea6e2eb 100644
--- a/css/skins/aselia_01.sass
+++ b/css/skins/aselia_01.sass
@@ -3,35 +3,35 @@
// Eien no Aselia skin made using Minitokyo.Eien.no.Aselia.Scans_373967
// created: 09/27/2009 by echomateria
-$maintext: #ffffff
-$grayedout: #eee388
-$standout: #e96e73
-$link: #4a2b33
-$statok: #ffcbee
-$statnok: #e96e73
-$footer: #f4f1e6
-$maintitle: #fce5e5
-$boxtitle: #f4e1b5
-$alttitle: #ed9f92
$bodybg: #8a3c35
-$tabbg: #ac7595
-$secbg: #8a3c35
-$secborder: #e86a76
-$border: #f1c0b2
$boxbg: #ac759595
-$diffadd: #833d71
-$diffdel: #6c3a55
-$warnbg: #6b3b51
-$warnborder: #e37192
-$noticebg: #cc8487
-$noticeborder: #975352
-@mixin bodybg
- background-color: $bodybg
+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
-@mixin bgright
- background: url(/s/aselia_01-right.jpg) no-repeat
- width: 1200px
- height: 719px
+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
index bbee23a8..e2401954 100644
--- a/css/skins/carnevale.sass
+++ b/css/skins/carnevale.sass
@@ -3,35 +3,35 @@
// Gekkou no Carnevale skin made using a wallpaper comes with the game
// created: 22/01/2009 by echomateria
-$maintext: #b9c7ae
-$grayedout: #cfdbc7
-$standout: #eff5f5
-$link: #ffffff
-$statok: #dab0fc
-$statnok: #768b78
-$footer: #b9c7ae
-$maintitle: #b9c7ae
-$boxtitle: #c7d3bf
-$alttitle: #6f8578
$bodybg: #030708
-$tabbg: #1f272a
-$secbg: #121622
-$secborder: #ffffff
-$border: #b9c7ae
$boxbg: #12162290
-$diffadd: #76a3b8
-$diffdel: #354554
-$warnbg: #5d182b
-$warnborder: #829076
-$noticebg: #263a45
-$noticeborder: #21343a
-@mixin bodybg
- background-color: $bodybg
+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
-@mixin bgright
- background: url(/s/carnevale-right.jpg) no-repeat
- width: 1280px
- height: 768px
+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
index b9d18030..0df7a1b2 100644
--- a/css/skins/eiel.sass
+++ b/css/skins/eiel.sass
@@ -1,36 +1,41 @@
-// userid: u51 name: Jingai Makyo (peach-orange)
+// 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
-$maintext: #f8cb8a
-$grayedout: #f26a7e
-$standout: #ff435f
-$link: #ffffff
-$statok: #ffcbee
-$statnok: #ff435f
-$footer: #362142
-$maintitle: #676082
-$boxtitle: #ffcbee
-$alttitle: #f26a7e
$bodybg: #fdd298
-$tabbg: #5a3a49
-$secbg: #584563
-$secborder: #c42b5a
-$border: #362142
$boxbg: #36214299
-$diffadd: #2bc88b
-$diffdel: #ca4a4d
-$warnbg: #c42b5a
-$warnborder: #f8cb8a
-$noticebg: #b48ab2
-$noticeborder: #f8cb8a
-@mixin bodybg
- background: $bodybg url(/s/eiel-bg.jpg) no-repeat
+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
-@mixin bgright
- display: none
+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
index 278e9383..63c51499 100644
--- a/css/skins/ever17_01.sass
+++ b/css/skins/ever17_01.sass
@@ -3,35 +3,35 @@
// Ever 17 skin made using the images from the extras section of the game
// created: 01/01/2009 by echomateria
-$maintext: #3c363f
-$grayedout: #785a5b
-$standout: #966932
-$link: #013f7a
-$statok: #9c4b00
-$statnok: #eb0e4c
-$footer: #e2cfa6
-$maintitle: #f0a260
-$boxtitle: #f0a260
-$alttitle: #ff9013
$bodybg: #00879b
-$tabbg: #81d5ea
-$secbg: #bcd1e4
-$secborder: #00627e
-$border: #016c80
$boxbg: #d1ebee77
-$diffadd: #f1a361
-$diffdel: #9a7071
-$warnbg: #c43f5c
-$warnborder: #951924
-$noticebg: #fefbf6
-$noticeborder: #f1a360
-@mixin bodybg
- background-color: $bodybg
+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
-@mixin bgright
- background: url(/s/ever17_01-right.jpg) no-repeat
- width: 800px
- height: 800px
+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
index ca5551bc..01b9e17a 100644
--- a/css/skins/fate_01.sass
+++ b/css/skins/fate_01.sass
@@ -3,33 +3,35 @@
// FSN skin skin made using a popular fanart
// created: 12/31/2008 by echomateria
-$maintext: #ab928d
-$grayedout: #916858
-$standout: #72322b
-$link: #eee0da
-$statok: #efdab9
-$statnok: #b07a6b
-$footer: #642924
-$maintitle: #200b0c
-$boxtitle: #d56243
-$alttitle: #d7926e
$bodybg: #200b0c
-$tabbg: #130504
-$secbg: #7c362f
-$secborder: #33261d
-$border: #9e4a47
$boxbg: #4d3c3abc
-$diffadd: #4d2c25
-$diffdel: #6f5347
-$warnbg: #882f27
-$warnborder: #fbf1e0
-$noticebg: #882f27
-$noticeborder: #fbf1e0
-@mixin bodybg
- background: $bodybg url(/s/fate_01-bg.jpg) no-repeat;
+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
-@mixin bgright
- display: none
+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
index 872baec5..17993c9a 100644
--- a/css/skins/fate_02.sass
+++ b/css/skins/fate_02.sass
@@ -3,33 +3,35 @@
// FSN skin made using a popular fanart
// created: 01/01/2009 by echomateria
-$maintext: #fcfbfb
-$grayedout: #ff9d82
-$standout: #ffc98f
-$link: #48b2c1
-$statok: #ff7682
-$statnok: #9f72ff
-$footer: #fffed5
-$maintitle: #ffffff
-$boxtitle: #ffffff
-$alttitle: #c4a9a7
$bodybg: #ac4b47
-$tabbg: #723033
-$secbg: #792447
-$secborder: #a68483
-$border: #452d2c
$boxbg: #c6093366
-$diffadd: #3d3231
-$diffdel: #01023f
-$warnbg: #772446
-$warnborder: #35152d
-$noticebg: #5c151f
-$noticeborder: #460015
-@mixin bodybg
- background: $bodybg url(/s/fate_02-bg.jpg) no-repeat;
+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
-@mixin bgright
- display: none
+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
index 3655dec3..514894bd 100644
--- a/css/skins/grey.sass
+++ b/css/skins/grey.sass
@@ -1,34 +1,39 @@
// userid: u2 name: Touhou (grey)
-$maintext: #222
-$grayedout: #666
-$standout: #500
-$link: #005
-$statok: #050
-$statnok: #500
-$footer: #999
-$maintitle: #ccc
-$boxtitle: #444
-$alttitle: #000
$bodybg: #fff
-$tabbg: #ddd
-$secbg: #ccc
-$secborder: #000
-$border: #999
$boxbg: #ddddddcc
-$diffadd: #cfc
-$diffdel: #fcc
-$warnbg: #fcc
-$warnborder: #c00
-$noticebg: #cfc
-$noticeborder: #0c0
-@mixin bodybg
- background: $bodybg url(/s/grey-bg.jpg) no-repeat;
+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
-@mixin bgright
+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
index 3074e2f7..ddfd1fc1 100644
--- a/css/skins/higanbana.sass
+++ b/css/skins/higanbana.sass
@@ -6,35 +6,40 @@
// Some portions (c)2011 Ryukishi07/07th Expansion //
////////////////////////////////////////////////////////////////
-$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
-$diffadd: #354
-$diffdel: #534
-$warnbg: #534
-$warnborder: #c00
-$noticebg: #354
-$noticeborder: #0c0
-@mixin bodybg
- background: $bodybg url(/s/higanbana-bg.jpg) no-repeat
+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
-@mixin bgright
+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
index dd04a2fb..151afc02 100644
--- a/css/skins/higu.sass
+++ b/css/skins/higu.sass
@@ -3,33 +3,35 @@
// Higurashi no Naku Koro ni skin made using an image I found in MiniTokyo
// created: 22/01/2009 by echomateria
-$maintext: #2c1a18
-$grayedout: #8c5b3b
-$standout: #c24857
-$link: #3c549c
-$statok: #8c6290
-$statnok: #824e52
-$footer: #2e2536
-$maintitle: #e5d3e1
-$boxtitle: #1b1b51
-$alttitle: #35346d
$bodybg: #f89e7e
-$tabbg: #9b8587
-$secbg: #f7c7bb
-$secborder: #3c549c
-$border: #2c1a18
$boxbg: #f7c7bb80
-$diffadd: #c2bcc6
-$diffdel: #8c5b3b
-$warnbg: #ae6866
-$warnborder: #612028
-$noticebg: #9b8587
-$noticeborder: #dc9b7f
-@mixin bodybg
- background: $bodybg url(/s/higu-bg.jpg) no-repeat
+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
-@mixin bgright
- display: none
+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
index 3dcd86a4..f74b2e91 100644
--- a/css/skins/lb.sass
+++ b/css/skins/lb.sass
@@ -1,32 +1,34 @@
// userid: u93 name: Little Busters! (pink)
-$maintext: #408
-$grayedout: #670159
-$standout: #e44
-$link: #a2d
-$statok: #0c0
-$statnok: #c00
-$footer: #f76ee2
-$maintitle: #f78de7
-$boxtitle: #670159
-$alttitle: #5328a7
$bodybg: #fff
-$tabbg: #f78de7
-$secbg: #f78de7
-$secborder: #670159
-$border: #f76ee2
$boxbg: #f7b6edcc
-$diffadd: #cfc
-$diffdel: #fcc
-$warnbg: #fff
-$warnborder: #c00
-$noticebg: #f7b6ed
-$noticeborder: #670159
-@mixin bodybg
- background: $bodybg url(/s/lb-bg.jpg) no-repeat
+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
-@mixin bgright
- display: none
+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
index acd5b5de..3ce73d1b 100644
--- a/css/skins/lb_02.sass
+++ b/css/skins/lb_02.sass
@@ -3,33 +3,35 @@
// Little Busters! skin made using the Minitokyo.Little.Busters.Scans_316439
// created: 09/27/2009 by echomateria
-$maintext: #3c363f
-$grayedout: #785a5b
-$standout: #eb0e4c
-$link: #04529b
-$statok: #d87417
-$statnok: #eb0e4c
-$footer: #eb0e4c
-$maintitle: #fffeff
-$boxtitle: #ff9013
-$alttitle: #f0a260
$bodybg: #fff4d4
-$tabbg: #9aa4d7
-$secbg: #bcd1e4
-$secborder: #966932
-$border: #016c80
$boxbg: #d1ebee99
-$diffadd: #3689b5
-$diffdel: #b7adc6
-$warnbg: #e97a9a
-$warnborder: #f1a360
-$noticebg: #fefbf6
-$noticeborder: #951924
-@mixin bodybg
- background: $bodybg url(/s/lb_02-bg.jpg) no-repeat
+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
-@mixin bgright
- display: none
+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
index 53e4b842..598194a6 100644
--- a/css/skins/primitive.sass
+++ b/css/skins/primitive.sass
@@ -3,35 +3,35 @@
// 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
-$maintext: #f8eacd
-$grayedout: #ff9d8a
-$standout: #642a12
-$link: #ffda63
-$statok: #edc176
-$statnok: #d63f21
-$footer: #935a40
-$maintitle: #f8eacd
-$boxtitle: #f8eacd
-$alttitle: #f19d20
$bodybg: #ddac9b
-$tabbg: #935a40
-$secbg: #be8f9f
-$secborder: #642a12
-$border: #edc176
$boxbg: #935a4099
-$diffadd: #bd7f98
-$diffdel: #c6562d
-$warnbg: #7a3313
-$warnborder: #ff392a
-$noticebg: #d4aab4
-$noticeborder: #edc176
-@mixin bodybg
- background-color: $bodybg
+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
-@mixin bgright
- background: url(/s/primitive-right.jpg) no-repeat
- width: 1024px
- height: 768px
+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
index 61be9661..9ca9d699 100644
--- a/css/skins/saya.sass
+++ b/css/skins/saya.sass
@@ -3,35 +3,35 @@
// Saya no Uta skin made using a criminally cute fanart
// created: 22/01/2009 by echomateria
-$maintext: #ffffff
-$grayedout: #ecbc93
-$standout: #d75f25
-$link: #ffcb3a
-$statok: #a55a3d
-$statnok: #281e14
-$footer: #e07340
-$maintitle: #ebb48b
-$boxtitle: #de9670
-$alttitle: #ebb48b
$bodybg: #25010f
-$tabbg: #575c51
-$secbg: #437f63
-$secborder: #ffcb3a
-$border: #ebb48b
$boxbg: #437f6388
-$diffadd: #f59731
-$diffdel: #f2c5a3
-$warnbg: #d45628
-$warnborder: #fbab34
-$noticebg: #c5af88
-$noticeborder: #56714e
-@mixin bodybg
- background-color: $bodybg
+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
-@mixin bgright
- background: url(/s/saya-right.jpg) no-repeat
- width: 900px
- height: 537px
+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
index 6c5a270b..41660091 100644
--- a/css/skins/seinarukana.sass
+++ b/css/skins/seinarukana.sass
@@ -3,33 +3,35 @@
// Seinarukana skin made using a callendar image
// created: 12/31/2008 by echomateria
-$maintext: #131838
-$grayedout: #fc8e77
-$standout: #e93d71
-$link: #5a5fc7
-$statok: #424d81
-$statnok: #a43462
-$footer: #324978
-$maintitle: #99c9dd
-$boxtitle: #e93d71
-$alttitle: #983666
$bodybg: #ffffff
-$tabbg: #bfd2e3
-$secbg: #bcd1e4
-$secborder: #7a88a5
-$border: #324978
$boxbg: #fde9e688
-$diffadd: #3689b5
-$diffdel: #b7adc6
-$warnbg: #ee3970
-$warnborder: #451f4b
-$noticebg: #fdf1e8
-$noticeborder: #d4aba2
-@mixin bodybg
- background: $bodybg url(/s/seinarukana-bg.jpg) no-repeat
+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
-@mixin bgright
- display: none
+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
index 07cb00f7..7e046fd9 100644
--- a/css/skins/taka.sass
+++ b/css/skins/taka.sass
@@ -3,35 +3,35 @@
// A Sora no Iro, Mizu no Iro skin based on a wallpaper named My Perfect Day
// created: 23/01/2009 by echomateria
-$maintext: #fefefc
-$grayedout: #4b2427
-$standout: #ffaa88
-$link: #f4d926
-$statok: #f8a022
-$statnok: #4b2427
-$footer: #f6ffff
-$maintitle: #f6ffff
-$boxtitle: #943048
-$alttitle: #a93f56
$bodybg: #4bb3ae
-$tabbg: #3c6d69
-$secbg: #48878c
-$secborder: #f4d926
-$border: #48878c
$boxbg: #48878c92
-$diffadd: #b2f68f
-$diffdel: #5ed8e5
-$warnbg: #008278
-$warnborder: #fcfefd
-$noticebg: #5bdebe
-$noticeborder: #a9fdc1
-@mixin bodybg
- background-color: $bodybg
+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
-@mixin bgright
- background: url(/s/taka-right.jpg) no-repeat
- width: 1300px
- height: 975px
+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
index af2fd02b..b843d227 100644
--- a/css/skins/teal.sass
+++ b/css/skins/teal.sass
@@ -1,33 +1,35 @@
// userid: u163596 name: Teal (teal)
// by sw1tchbl4d3
-$maintext: #ddd
-$grayedout: #007070
-$standout: #e44
-$link: #00c9c9
-$statok: #0c0
-$statnok: #c00
-$footer: #004040
-$maintitle: #003535
-$boxtitle: #007070
-$alttitle: #fff
$bodybg: #000
-$tabbg: #001010
-$secbg: #003434
-$secborder: #008080
-$border: #007070
$boxbg: #002525bc
-$diffadd: #354
-$diffdel: #534
-$warnbg: #534
-$warnborder: #c00
-$noticebg: #354
-$noticeborder: #0c0
-@mixin bodybg
- background-color: $bodybg
+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
-@mixin bgright
- display: none
+body
+ background-color: var(--bodybg)
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/term.sass b/css/skins/term.sass
index 49f795c2..a8036e7e 100644
--- a/css/skins/term.sass
+++ b/css/skins/term.sass
@@ -1,32 +1,34 @@
// userid: u93 name: Neon (black)
-$maintext: #0f0
-$grayedout: #aaa
-$standout: #f00
-$link: #ff0
-$statok: #0c0
-$statnok: #c00
-$footer: #fff
-$maintitle: #0f0
-$boxtitle: #0f0
-$alttitle: #0f0
$bodybg: #000
-$tabbg: #000
-$secbg: #000
-$secborder: #0f0
-$border: #fff
$boxbg: #000
-$diffadd: #cfc
-$diffdel: #fcc
-$warnbg: #000
-$warnborder: #c00
-$noticebg: #000
-$noticeborder: #0f0
-@mixin bodybg
- background-color: $bodybg
+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
-@mixin bgright
- display: none
+body
+ background-color: var(--bodybg)
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/tsukihime.sass b/css/skins/tsukihime.sass
index 64b45591..cf67c5ed 100644
--- a/css/skins/tsukihime.sass
+++ b/css/skins/tsukihime.sass
@@ -3,35 +3,40 @@
// Tsukihime skin made using an image from the Tsukihime Plus+Disc
// created: 02/01/2009 by echomateria
-$maintext: #ffffff
-$grayedout: #abcdff
-$standout: #ffffff
-$link: #0be0e9
-$statok: #55dfaa
-$statnok: #e30b47
-$footer: #0cacf3
-$maintitle: #a9bbfb
-$boxtitle: #e3ecff
-$alttitle: #c6d7ff
$bodybg: #29345f
-$tabbg: #5a3a63
-$secbg: #9b8494
-$secborder: #605567
-$border: #b791f3
$boxbg: #6a4668bb
-$diffadd: #87705c
-$diffdel: #374d77
-$warnbg: #76a1cd
-$warnborder: #decdcd
-$noticebg: #b50439
-$noticeborder: #decdcd
-@mixin bodybg
- background: $bodybg url(/s/tsukihime-bg.jpg) no-repeat
+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
-@mixin bgright
+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
index e895055a..b9f8a2d1 100644
--- a/css/skins/tsukihime_02.sass
+++ b/css/skins/tsukihime_02.sass
@@ -3,33 +3,35 @@
// Tsukihime skin made with an awesome Akiha artwork from Tsukihime PLUS disc
// created: 23/01/2009 by echomateria
-$maintext: #fa4347
-$grayedout: #54459a
-$standout: #fd3fa9
-$link: #b768aa
-$statok: #d79a7e
-$statnok: #412651
-$footer: #fa4347
-$maintitle: #fa4347
-$boxtitle: #d79a7e
-$alttitle: #c17e61
$bodybg: #000000
-$tabbg: #2e0106
-$secbg: #2e0106
-$secborder: #b768aa
-$border: #000000
$boxbg: #35020990
-$diffadd: #d79a7e
-$diffdel: #412651
-$warnbg: #46285a
-$warnborder: #c0959f
-$noticebg: #4f3246
-$noticeborder: #94769a
-@mixin bodybg
- background: $bodybg url(/s/tsukihime_02-bg.jpg) no-repeat
+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
-@mixin bgright
- display: none
+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
index c4b974ae..aacd3282 100644
--- a/css/v2.css
+++ b/css/v2.css
@@ -1,349 +1,90 @@
-$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)))
-);
-
-* { margin: 0; padding: 0; }
-body, td { font: 13px "Tahoma", "Arial", sans-serif; }
-body { @include bodybg; color: $maintext }
+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; }
+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: $link; text-decoration: none; cursor:pointer; border-bottom: 1px solid transparent }
+.fake_link { color: var(--link); text-decoration: none; cursor:pointer; border-bottom: 1px solid transparent }
a:hover,
-.fake_link:hover { border-bottom: 1px dotted $maintext; }
+.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: $boxbg; }
-
-#bgright { position: absolute; top: 0px; right: 0px; @include bgright }
-#header { position: absolute; top: 80px; left: 400px; }
-#header h1, #header h1 a {
- font-family: "Futura", "Century New Gothic", "Arial", Serif;
- font-size: 24px;
- font-style: italic;
- border: none!important;
- color: $maintitle;
-}
-#footer { margin: 15px auto 0 auto; text-align: center; color: $footer; }
-#footer a { color: $footer; text-decoration: underline; }
+.docs table tbody tr:nth-child(odd) { background: var(--boxbg); }
-/* https://snook.ca/archives/html_and_css/hiding-content-for-accessibility */
-.visuallyhidden, .linkradio input {
- position: absolute !important;
- left: 0;
- height: 1px; width: 1px;
- border: 0; padding: 0;
- overflow: hidden;
- clip: rect(1px 1px 1px 1px);
-}
/* 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, 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: 13px; font-weight: bold; margin: 0; }
-
-/* dropdown box */
-#dd_box { position: absolute; left: 0px; border: 1px solid $border; background-color: $secbg; z-index: 2 }
-#dd_box ul { list-style-type: none; margin: 0; padding: 0 }
-#dd_box li b { display: block; font-weight: normal; padding-left: 5px; }
-#dd_box li i { display: block; font-style: normal; padding-left: 10px; padding-right: 5px }
-#dd_box li a { display: block; padding-left: 10px; border: 0; padding: 3px 5px 3px 3px }
-#dd_box li a:hover { background: $boxbg }
-
-/* dropdown search */
-#ds_box {
- position: absolute;
- top: 0;
- border: 1px solid $border;
- border-top: none;
- background-color: $secbg;
- cursor: pointer;
- z-index: 2
-}
-#ds_box b { padding: 2px 0 0 10px; }
-#ds_box tr.selected { background: $boxbg; }
-#ds_box table { width: 100%; }
-
-/* Elm dropdowns (also in perl: VNWeb::Releases::Lib) */
-.elm_dd > a { color: $maintext; display: block; border: none; padding-right: 15px; position: relative }
-.elm_dd > a > span:last-child { position: absolute; right: 5px; top: 0; width: 16px; text-align: right; display: block }
-.elm_dd > a > span:last-child i { visibility: hidden; font-style: normal }
-.elm_dd > a .nowrap { display: block; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.elm_dd > a:hover > span:last-child > i,
-.elm_dd > a:focus > span:last-child > i { visibility: visible }
-.elm_dd > div { position: relative; float: right; width: 0; height: 0 }
-.elm_dd > div > div { position: absolute; right: -10px; top: 0; border: 1px solid $border; background-color: $secbg; z-index: 1000; margin: 0; padding: 0; max-width: 400px }
-.elm_dd.search > div { float: left }
-.elm_dd.search > div > div { right: auto; left: 0; top: 23px }
-.elm_dd ul { width: 100%; list-style-type: none; margin: 0; padding: 0 }
-.elm_dd ul li { white-space: nowrap }
-.elm_dd ul li a { display: block; border: 0; padding: 3px 5px 3px 3px }
-.elm_dd ul li a.active,
-.elm_dd ul li a:hover { background: $boxbg }
-.elm_dd ul li p { white-space: normal; padding: 3px 5px 3px 3px }
-.elm_dd ul li.separator { margin-bottom: 22px }
-
-.maintabs .elm_dd > a { box-sizing: border-box; height: 21px; padding: 1px 15px 0 7px; border: 1px solid $border; border-bottom: none; background-color: $tabbg; color: $maintext }
-.elm_votedd .elm_dd ul li { text-align: left }
-.elm_dd_input .elm_dd > a { background-color: $secbg; color: $maintext; border: 1px solid $secborder; font: 14px "Tahoma", "Arial", sans-serif; padding: 1px 15px 1px 2px; margin: -1px }
-.elm_dd_button .elm_dd > a { background-color: $boxbg; color: $maintext; border: 1px solid $secborder; font: 14px "Tahoma", "Arial", sans-serif; padding: 1px 15px 1px 5px; margin: -1px }
-.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_relextlink .elm_dd > a { padding-left: 4px; color: $link }
-.elm_dd_relextlink ul a { text-align: right }
-.elm_dd_relextlink ul span { color: $maintext; padding-right: 10px }
+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: $standout; font-style: italic; margin: 0!important }
-.grayedout { color: $grayedout }
-b.grayedout { font-weight: normal }
-i.grayedout { font-style: normal }
+p.itemmsg { float: right; color: var(--standout); font-style: italic; margin: 0!important }
+small, .grayedout { color: var(--grayedout) }
.underline { text-decoration: underline }
-#maincontent h2 b { font: 13px "Tahoma", "Arial", sans-serif; font-weight: normal; }
-p.description, div.description { margin: 10px 100px!important; }
-b.done { font-weight: normal; color: $statok }
-b.todo { font-weight: normal; color: $statnok }
-b.neutral { font-weight: normal }
+.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; }
-.standout { color: $standout!important }
-b.future,
-b.standout { font-weight: normal; color: $standout; }
+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 }
-b.spoiler, b.spoiler a { color: #000!important; background-color: #000; font-weight: normal; }
-b.spoiler:hover, b.spoiler:focus { color: $maintext!important; background-color: transparent }
-b.spoiler:hover a, b.spoiler:focus a { color: $link!important; background-color: transparent }
-
-#maincontent div.quote {
+.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: $grayedout;
+ color: var(--grayedout);
border: none;
- border-left: 1px dotted $border;
+ border-left: 1px dotted var(--border);
text-align: left;
}
pre {
padding:1px 5px;
margin: 5px 15px;
- border: 1px dotted $border;
+ border: 1px dotted var(--border);
border-right: none;
- border-left: 1px solid $border;
- background: $boxbg;
+ border-left: 1px solid var(--border);
+ background: var(--boxbg);
overflow-x: auto;
}
-/***** general form markup *****/
-
-input.text, input.submit, select, textarea, button {
- background-color: $secbg;
- color: $maintext;
- border: 1px solid $secborder;
- font: 14px "Tahoma", "Arial", sans-serif;
- padding: 0 1px 1px 1px;
- margin: 1px;
-}
-form, fieldset { border: 0; display: block }
-legend { display: none; }
-optgroup option { padding-left: 10px; font-style: normal; }
-input.submit, button { background: $boxbg; padding: 1px 5px; cursor: pointer }
-input.text, select { width: 200px; }
-input[type=number] { -moz-appearance:textfield }
-input[type=number]::-webkit-outer-spin-button, input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0 }
-fieldset.submit { width: 100%; text-align: center; margin: 5px; }
-fieldset.submit input[type=submit] { width: 150px; }
-fieldset.submit input[type=checkbox] { margin: 0 5px 0 15px; }
-fieldset.submit h2 { font-size: 13px!important; }
-td.label, td.label label { width: 130px; }
-td.label label { display: block; }
-td.field label { margin: 0 5px 0 5px; }
-table.formtable { margin: 0 20px 20px 20px; }
-table.formtable td { padding: 0; }
-table.formtable tr.newfield > td { padding-top: 5px; }
-table.formtable tr.newpart td { padding-top: 20px; font-weight: bold; }
-table.formtable td table td { padding: 1px 15px 1px 0px }
-table.formtable td table { margin-bottom: 5px }
-
-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 }
-
-/* Format checkboxes and radio buttons as if they were normal links with unicode icons.
- * Usage:
- *
- * <container class="linkradio">
- * <input type="checkbox|radio" id="xyz">
- * <label for="xyz">Text</label>
- * <em>(optional option separator)</em>
- * </container>
- */
-p.linkradio { padding: 2px }
-.linkradio label { color: $link; cursor: pointer }
-.linkradio label:before { content: '✗' }
-.linkradio input:checked + label { color: $maintext }
-.linkradio input:checked + label:before { content: '✓' }
-.linkradio input:focus + label { outline: 1px dotted $link }
-.linkradio input:focus:checked + label { outline: 1px dotted $maintext }
-.linkradio em { font-weight: normal; font-style: normal; color: $grayedout }
-
-/* Same styling, but for regular links.
- * Usage:
- *
- * <a href="#" class="linkradio">Unchecked option</a>
- * <a href="#" class="linkradio checked">Checked option</a>
- */
-a.linkradio:before { content: '✗' }
-a.linkradio.checked:before { content: '✓' }
-a.checked { color: $maintext }
-
-/* Spinner, <div class="spinner"></div> for a large one, <span> for a smaller inline-text version */
-.spinner { content: ''; box-sizing: border-box; border: 3px solid #9eaebd; border-bottom-color: transparent; border-radius: 100%; animation: spin 1s infinite linear; width: 16px; height: 16px; display: inline-block; margin: auto }
-span.spinner { width: 1em; height: 1em }
-@keyframes spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
-
-.textpreview > span { display: flex; justify-content: space-between; width: 100% }
-.textpreview > span > p { align-self: flex-end; text-align: left }
-.textpreview > span > p.right > * { margin-left: 10px; font-style: normal }
-.textpreview textarea { width: 100%; box-sizing: border-box }
-.textpreview .preview { width: 100%; box-sizing: border-box; border: 1px solid $secborder; margin: 1px; padding: 5px; text-align: left }
-fieldset.submit .textpreview { margin: 0 auto }
-
-/* .compact input elements are smaller and can be embedded in tables/inline text
- * .stealth input elements pretend to be just regular text, but turn into visibile input elements on mouse-over */
-.compact input.text, .compact select, .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 $secborder; background: $secbg }
-
-
-/***** menu *****/
-
-
-#menulist a { color: $maintext; text-decoration: none; }
-#menulist a:hover { border-bottom: 1px dotted $maintext; }
-#menulist { position: absolute; left: 30px; top: 190px; width: 160px; }
-#menulist div.menubox { margin: 0 0 10px 0; border: 1px solid $border; background: $boxbg; }
-#menulist div.menubox div { padding: 2px 7px; }
-#menulist h2 { border-bottom: 1px solid $border; background: $boxbg; padding: 1px 3px; }
-#menulist h2, #menulist h2 a { font-size: 13px; color: $maintext; }
-#menulist h2 #lang_select { float: right; padding-top: 1px; }
-#menulist dt { display: block; float: left; width: 97px; font-style: italic; }
-#menulist dd { width: 45px; float: left; text-align: right; }
-#menulist p { text-align: center; }
-#menulist #search input.text { width: 141px; margin: 0 0 3px 7px }
-#menulist #search input.submit { display: none; }
-#dd_box abbr { margin: 2px 5px 2px 0!important; }
-#menulist .notifyget { display: inline-block; width: 135px; padding: 4px; background: $warnbg; border: 1px solid $warnborder; }
-#menulist .logout { border: 0; background: none; color: $maintext; cursor: pointer }
-#menulist .logout:hover { text-decoration: underline }
-
-#support { line-height: 0; height: 41px }
-#support img { height: 19px; object-fit: none }
-#support a { opacity: 0.7; border-bottom: none!important; display: inline-block }
-#support a:hover { opacity: 1 }
-#support a:hover img { position: absolute; left: 0; top: 0; height: 38px; z-index: 99 }
-
-
-
-
-/***** main content *****/
-
-#maincontent {
- position: absolute;
- top: 169px;
- left: 200px;
- right: 30px;
- margin: 0;
- padding-bottom: 50px!important;
-}
-.mainbox h1, .mainbox h2 {
- font-family: "Futura", "Century New Gothic", "Arial", Serif;
- font-weight: normal;
- font-size: 14px;
-}
-div.mainbox, div.threelayout > div {
- border: 1px solid $border;
- margin: 21px 0 -10px 0;
- padding: 5px;
- background: $boxbg;
-}
-div.mainbox-overflow-hack { overflow: hidden; width: 100%; box-sizing: border-box }
-.mainbox h1 { color: $boxtitle; font-size: 24px; margin: -5px 0 15px 0; }
-.mainbox h2.alttitle { color: $alttitle; margin: -17px 0 15px 15px; font-weight: normal; }
-.mainbox p { margin: 3px 20px; }
-.mainbox div p,
-.mainbox table p { margin: 0; }
-.mainbox h2 { font-weight: bold; font-size: 16px; margin: 10px 0 0 5px; }
-a.addnew, p.addnew { float: right; margin: 0 }
-a.mainopts, p.mainopts { float: right; margin: 0 }
-p.mainopts a, p.mainopts label { margin: 0 5px }
-
-div.threelayout { display: flex }
-div.threelayout > div { flex: 1; padding: 0 2px 10px 2px; margin-left: 5px; margin-right: 5px }
-div.threelayout > div:first-child { margin-left: 0 }
-div.threelayout > div:last-child { margin-right: 0 }
-.threelayout h1 { margin: 0; font-size: 18px; font-weight: bold; color: $boxtitle }
-.threelayout h2 { font-size: 14px; margin-top: 3px; }
-.threelayout a.right { float: right; }
-.threelayout ul { list-style-type: none; margin-left: 10px; }
-.threelayout h1 a { color: $boxtitle; }
-
-
-
-
-/***** main tabs *****/
-
-div.maintabs { display: flex; justify-content: space-between; position: relative; width: 100%; height: 22px; margin: 20px 0 -22px 0; padding: 0 }
-#maincontent > div:nth-child(1).maintabs { margin-top: 0 }
-div.maintabs.right { justify-content: flex-end }
-div.maintabs.left { justify-content: flex-start }
-div.maintabs > ul { margin: 0; padding: 0; list-style-type: none }
-div.maintabs > ul > li { display: inline-block; margin: 0 0 0 10px }
-div.maintabs > ul > li:nth-child(1) { margin-left: 0!important }
-div.maintabs > ul > li > a,
-div.maintabs > ul > li > div > a { display: inline-block; box-sizing: border-box; height: 21px; padding: 1px 7px 0 7px; border: 1px solid $border; border-bottom: none; background-color: $tabbg; color: $grayedout; }
-div.maintabs > ul > li.tabselected > a,
-div.maintabs > ul > li.tabselected > div > a,
-div.maintabs > ul > li > div > a:hover,
-div.maintabs > ul > li > a:hover { background: $blendbg; color: $maintext; height: 22px }
-div.maintabs.browsetabs > ul li a { color: $maintext }
-div.maintabs.browsetabs > ul li { margin-left: 5px }
-div.maintabs.bottom { margin-top: 10px; /* WHY!? */ margin-bottom: -10px }
-div.maintabs.bottom > ul li a { padding: 4px 7px 2px 7px; border-bottom: 1px solid $border; border-top: none }
-div.maintabs.bottom > ul li.tabselected a,
-div.maintabs.bottom > ul li a:hover { padding-top: 5px; height: 22px; margin-top: -1px }
-
-h1.boxtitle, h1.boxtitle a, div.maintabs h1 {
- font-family: "Futura", "Century New Gothic", "Arial", Serif;
- font-weight: bold;
- font-style: italic;
- color: $grayedout;
- font-size: 17px;
-}
-h1.boxtitle, h1.boxtitle a { margin: 20px 0 -20px 0 }
-
+@import "css/layout";
+@import "css/forms";
+@import "css/vngraph";
+@import "css/staffedit";
@@ -352,9 +93,16 @@ h1.boxtitle, h1.boxtitle a { margin: 20px 0 -20px 0 }
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: $maintext }
-.homepage h1 { margin-bottom: 5px }
-.homepage > div { overflow: hidden }
+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 }
@@ -366,8 +114,8 @@ li.announcement a { font-weight: bold; font-size: 15px; color: $maintext }
.browseopts a, .browseopts button {
padding: 1px 3px;
- color: $maintext;
- border: 1px solid $border;
+ color: inherit;
+ border: 1px solid var(--border);
margin: 0 2px;
white-space: nowrap;
}
@@ -376,20 +124,25 @@ span.browseopts { text-align: center; padding: 10px; display: in
.browseopts .optselected,
.browseopts a:hover,
.browseopts button: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; }
+.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; }
-p#searchtabs { height: 13px; padding-right: 70px; }
-p#searchtabs a { padding: 2px 6px 2px 6px; margin: 0 2px; color: $maintext; }
-p#searchtabs a:hover, p#searchtabs a.sel {
- border: 1px solid $secborder;
- border-bottom: none;
- padding: 1px 5px 2px 5px;
- background: $boxbg;
+#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 }
@@ -398,12 +151,18 @@ p#searchtabs a:hover, p#searchtabs a.sel {
/* history browser */
-div.history td { white-space: nowrap; padding-left: 15px }
-div.history td.tc1_1 { width: 70px; padding-right: 0; text-align: right }
-div.history td.tc1_2 { width: 30px; padding-left: 0 }
-div.history td.tc2 { width: 140px }
-div.history td.tc4 { width: 100% }
-div.history td.tc4 b { margin-left: 10px }
+.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 }
@@ -411,47 +170,63 @@ div.history td.tc4 b { margin-left: 10px }
/***** Discussions ******/
/* threads page */
-#maincontent div.thread { padding: 0; }
-div.thread table { width: 100%; table-layout: fixed }
-div.thread tr:not(:last-child) td { border-bottom: 1px solid $border; }
-div.thread td.tc1 { width: 170px; padding: 5px 10px; border-right: 1px solid $border; }
-div.thread td.tc2 { padding: 10px 20px 10px 10px; }
-div.thread tr.deleted td { padding: 1px 10px; }
-div.thread tr:target, div.thread tr.target { outline: 1px dotted $standout }
-div.thread i.deleted { font-style: normal; color: $grayedout; }
-div.thread i.lastmod { float: right; font-size: 11px; color: $grayedout; margin: 0 -10px -5px 0; }
-div.thread i.edit { float: right; color: $grayedout; font-style: normal; margin: -10px -10px 0 0; visibility: hidden }
-div.thread td:hover i.edit,
-div.thread td:active i.edit { visibility: visible }
+.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 */
-div.mainbox.discussions td.tc4 { text-align: right; }
-div.mainbox.discussions a.locked { text-decoration: line-through; }
-div.mainbox.discussions b.boards { padding-left: 10px; font-weight: normal; }
-div.mainbox.discussions b.boards a { color: $grayedout; }
-div.discussions td.tc2 { width: 60px; text-align: right }
-div.discussions td.tc3 { width: 110px; }
-div.discussions td.tc4 { width: 250px; }
-div.discussions .pollflag { color: $grayedout; padding-right: 6px; }
-div.postsearch td.tc1_1 { width: 60px; padding-left: 0; padding-right: 0; text-align: right }
-div.postsearch td.tc1_2 { width: 25px; padding-left: 0 }
-div.postsearch td.tc2 { width: 65px; }
-div.postsearch td.tc3 { width: 90px; }
+.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.lang td,
-.releases tr.vn td { background: $boxbg; font-weight: bold; }
-.releases td.tc1 { padding-left: 30px; width: 80px; white-space: nowrap }
-.releases td.tc2 { text-align: center; width: 50px; white-space: nowrap }
+.releases 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 { padding: 0 4px }
-.releases td.tc_prod { color: $grayedout; white-space: nowrap; width: 50px }
-.releases td.tc5 { width: 70px; text-align: right }
-.releases td.tc6 { text-align: right; width: 25px; padding: 0; white-space: nowrap }
-
+.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 *******/
@@ -460,28 +235,46 @@ 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: 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.title abbr { float: right }
+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.anime b { font-size: 10px; font-weight: normal; padding-right: 4px; }
+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 > b { font-size: 14px }
+.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 $border; padding: 3px 5% 0 5%; text-align: center; }
+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 b { color: $grayedout; font-weight: normal; font-size: 10px }
+#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: $link; cursor: pointer; }
-#tagops label.sec { border-left: 1px solid $border; padding-left: 10px }
+#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: $maintext; }
+#tagops input:checked + label { color: var(--maintext); }
/* tag filter machinery; the order of declarations is important */
@@ -499,30 +292,32 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
#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: $boxbg; font-weight: bold; }
+#screenshots tr.rel td { background: var(--boxbg); font-weight: bold; }
#screenshots p.rel {
- background: $boxbg;
+ 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-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 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: $maintext }
+#scrhide_v2:checked ~ #screenshots label[for=scrhide_v2] { color: var(--maintext) }
#screenshots .scrlnk { display: none }
#scrhide_s0:checked ~ #screenshots .scrlnk_s0 { display: inline }
@@ -531,28 +326,40 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
#scrhide_v0:checked ~ #screenshots .scrlnk_v0 { display: none }
#scrhide_v1:checked ~ #screenshots .scrlnk_v1 { display: none }
-.summarize_more {
- margin-top: 9px; margin-bottom: -10px; padding: 0; height: 15px;
- border: 1px solid $border; border-top: none;
- background: $boxbg;
- text-align: center
+.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 }
+ }
}
-.reviews { display: flex; justify-content: center; flex-wrap: wrap }
-.reviewbox { margin: 10px 12px 30px 12px; flex: 1 1; flex-basis: 450px }
-.reviewbox > div:nth-child(3) > span:first-child { float: right; color: $grayedout; font-style: normal; margin: -5px 0 0 0; visibility: hidden }
-.reviewbox > div:nth-child(3):hover > span:first-child,
-.reviewbox > div:nth-child(3):active > span:first-child { visibility: visible }
-.reviewbox .review_spoil input:checked ~ span { display: none }
-.reviewbox .review_spoil input:not(:checked) ~ div { display: none }
-.reviewbox > div:first-child { display: flex; justify-content: space-between; background: $secbg; font-weight: bold }
-.reviewbox > div:first-child > span:first-child { font-weight: bold }
-.reviewbox > div:nth-child(2) { background: $secbg }
-.reviewbox > div:nth-child(2) p { padding: 2px; text-align: center }
-.reviewbox > div:nth-child(3) { box-sizing: border-box; padding: 10px; background: $boxbg }
-.reviewbox > div:last-child { display: flex; justify-content: space-between; border-top: 1px solid $border }
-.reviewbox .myvote { font-weight: bold; text-decoration: underline }
+.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) *******/
@@ -560,10 +367,11 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
.vntaglist { list-style-type: none; column-width: 400px }
.vntaglist li.tagvnlist-top:not(:first-child) { margin-top: 30px }
.vntaglist li.tagvnlist-parent { margin: 5px 0 3px 0 }
-.vntaglist li.tagvnlist-parent a { color: $maintext; font-weight: bold }
-.vntaglist li.tagvnlist-inherited a { color: $grayedout }
-.vntaglist li:not(.tagvnlist-inherited) b.grayedout { color: $link }
-.vntaglist h3 a { color: $maintext }
+.vntaglist li.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 }
@@ -571,17 +379,17 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/***** Vote stats ****/
-.votestats { width: 630px; margin: 0 auto; }
+.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: $border; margin-right: 2px; padding: 0; }
+.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 b { font-weight: normal; padding-left: 5px }
+.recentvotes thead tr td span { font-weight: normal; padding-left: 5px }
@@ -594,7 +402,7 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
.votebooth td.tc1 { padding-right: 20px }
.votebooth td.tc2 { min-width: 240px }
.votebooth td.tc2 div { margin: 2px; }
-.votebooth td.tc2 div.graph { float: left; height: 14px; background-color: $border; padding: 0; }
+.votebooth td.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 }
@@ -605,8 +413,8 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/***** VN edit *****/
.vnedit_scr { width: 95%; margin: auto }
-.vnedit_scr > tr:nth-child(odd) > td { background: $boxbg }
-.vnedit_scr > tr > td { border-bottom: 1px solid $border }
+.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 }
@@ -614,28 +422,49 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/***** VN Release tab *****/
.releases_compare table { margin: 0 auto; }
-.releases_compare td { margin: 0 auto; border: 1px solid $border; }
-.releases_compare td.bg { background: $boxbg; }
+.releases_compare td { 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: $boxbg; }
+.releases_compare .key { background: var(--boxbg); }
/****** VN browse ********/
-.vnbrowse .tc_s { padding-left: 30px; width: 70px }
-.vnbrowse .tc2 { text-align: right; padding: 0; }
-.vnbrowse .tc3 { padding: 0; }
-.vnbrowse .tc5 { text-align: right; padding-right: 10px }
-.vnbrowse .tc6 { width: 80px }
-.vnbrowse .tc7 { text-align: right; width: 8px; white-space: nowrap }
-.vnbrowse .tc7 abbr { display: inline-block; width: 20px; }
+.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: 80px; text-align: right; padding-right: 15px }
-.prodvns li span:last-child { color: $grayedout; padding-left: 15px }
+.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 ******/
@@ -649,9 +478,13 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/***** Release page *****/
-.release table { width: 400px; margin: 0 auto; }
-.release .key { width: 90px; }
-
+.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 *****/
@@ -667,13 +500,13 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/***** Review browser *****/
-.reviewlist td.tc1 { width: 90px }
+.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: 250px; text-align: right }
+.reviewlist td.tc8 { width: 260px; text-align: right }
/***** Release browser *****/
@@ -683,6 +516,9 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
.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_) ****/
@@ -692,8 +528,8 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
.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: $secbg; border: 0 }
-.imghover div.imghover--warning { border: 1px solid $border; background: $secbg; box-sizing: border-box; padding: 10px 5px }
+.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 }
@@ -703,13 +539,15 @@ 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: 250px; margin: 0 10px; text-align: center }
+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: 530px; }
-div.chardetails table td.key { width: 100px; }
+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; }
@@ -717,43 +555,47 @@ div.chardetails.charsep { margin-top: 30px }
table.chare_traits .buts { padding: 0 20px }
-table.chare_traits .buts a { box-sizing: border-box; display: block; width: 15px; height: 14px; border: 1px solid $border; margin: 0; float: left }
+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 *****/
-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 }
+.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 }
-div.charbcard { padding: 0; display: flex; flex-wrap: wrap }
-div.charbcard > div { padding: 2px 2px 10px 2px; display: flex; flex: 1; min-width: 280px }
-div.charbcard > div:hover { background-color: $secbg }
-div.charbcard > div > div:first-child { flex-shrink: 0; width: 90px; height: 120px; text-align: center }
-div.charbcard > div > div:nth-child(2) { height: 120px; padding-left: 5px; overflow-y: hidden }
-div.charbcard b a { color: $grayedout!important }
+.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 }
-div.charbgrid { padding: 10px; display: flex; flex-wrap: wrap; justify-content: space-evenly }
-div.charbgrid > div { padding: 5px 5px 15px 5px; display: flex; flex-direction: column; width: 210px; text-align: center }
-div.charbgrid > div > a { font-size: 15px; font-weight: bold }
-div.charbgrid > div:hover { background-color: $secbg }
+.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 *****/
-div.staffbrowse { padding-bottom: 10px }
+.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: 70px; }
-.staffroles td.tc2 { white-space: nowrap; width: 80px }
+.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; }
@@ -762,14 +604,17 @@ table.aliases td.key { padding: 0 5px 0 0; width: auto }
/***** Staff display on VN pages *****/
-.vnstaff { width: 97%; margin: -15px auto 5px auto; justify-content: space-between }
+.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 { white-space: nowrap; overflow-x: hidden; text-overflow: ellipsis; padding-bottom: 1px }
-.vnstaff li.vnstaff_head { font-weight: bold; margin-top: 15px }
-.vnstaff li b.grayedout { margin-left: 10px }
+.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} }
@@ -779,37 +624,27 @@ 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 $border; padding-top: 3px }
-div.charsum_list .actor b.grayedout { margin-left: 10px }
+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: $boxbg;
+ background: var(--boxbg);
display: inline-block;
text-align: left;
vertical-align: top;
- width: 320px;
+ width: 100%;
+ max-width: 340px;
margin: 3px;
padding: 3px 10px;
}
-/***** Staff edit *****/
-
-.staffedit td.tc_name,
-.staffedit td.tc_original { width: 200px }
-.staffedit td.tc_name input,
-.staffedit td.tc_original input { width: 200px }
-.staffedit td.tc_add { width: 40px; text-align: left; white-space: nowrap }
-.staffedit table.names td { padding: 1px 2px; vertical-align: middle; }
-.staffedit table.names tr.alias_new td { padding-top: 8px }
-
-
/***** Documentation pages *****/
.docs { padding: 0 15% 20px 15%; line-height: 1.4 }
.docs h3 { margin: 30px 0 5px; font-size: 16px }
.docs h4 { margin-top: 15px; font-size: 14px }
.docs h3 a:target,
-.docs h4 a:target { color: $standout }
+.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 }
@@ -821,7 +656,7 @@ div.charsum_list .charsum_bubble {
.docs td[colspan]+td,
.docs td[colspan]+td+td { white-space: normal }
.docs p + p { padding-top: 10px }
-.docs ul.index { display: block; float: right; width: 190px; padding: 2px; margin: 0 0 10px 5px; background: $boxbg; border: 1px solid $border; }
+.docs ul.index { 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 }
@@ -830,20 +665,28 @@ div.charsum_list .charsum_bubble {
/* 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 }
+.votelist td.tc1 { width: 100px; padding-top: 0; padding-bottom: 0 }
+.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 }
+/* 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 }
+}
-/***** New User's VN list *****/
+/***** User's VN list *****/
.labelfilters { text-align: center }
-.labelfilters input.submit { margin-top: 5px }
+.labelfilters .xsearch { text-align: left }
+.labelfilters .linkradio { padding: 5px }
.managelabels > div { width: 600px; margin: 10px auto }
.managelabels table { margin: 0 auto }
@@ -855,12 +698,12 @@ div.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px }
.savedefault { width: 600px; margin: 10px auto }
.exportlist { width: 600px; margin: 10px auto }
-.ulist .tc1 { white-space: nowrap; width: 70px }
+.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 b { margin-left: 10px }
+.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,
@@ -879,26 +722,37 @@ div.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px }
.ulist .tc_opt { padding: 0 0 5px 70px }
.ulist .tc_opt textarea { width: 500px; height: 18px; border: none }
-.ulist .tc_opt textarea:focus { height: 50px; border: 1px solid $secborder }
+.ulist .tc_opt textarea: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 }
-/***** User VN list browser ******/
+/***** 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 }
+}
-#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 }
+/* 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 *****/
@@ -906,21 +760,32 @@ div.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px }
.browse.notifies td.tc1 { width: 14px }
.browse.notifies td.tc3 { width: 100px }
.browse.notifies td.tc4 { width: 75px }
-.browse.notifies tbody td.tc5 a { color: $grayedout }
-.browse.notifies td.tc5 i { font-style: normal; color: $maintext }
+.browse.notifies 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 }
-/***** Subscription tab thiny (HTML::_maintabs_subscribe_() + elm/Subscribe) ****/
-#subscribe .inactive { color: transparent; text-shadow: 0 0 $grayedout }
-#subscribe .active { color: transparent; text-shadow: 0 0 $maintext }
+/***** Maintabs dropdown thingy used by js Subscribe & TableOpts ****/
-#subscribe > div > a { height: 21px!important /* override :hover change */ }
-#subscribe > div > div { position: absolute; width: 1px }
-#subscribe > div > div > div { box-sizing: border-box; padding: 10px; width: 500px; border: 1px solid $border; background: $secbg; position: relative; bottom: 0; left: -470px; z-index: 100 }
-#subscribe p, #subscribe h4, #subscribe label { display: block; margin-bottom: 3px }
+.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,
@@ -934,17 +799,17 @@ div.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px }
/***** Userpage *****/
.userpage table { width: 600px; margin: 0 auto; }
-.userpage .key { width: 100px; }
+.userpage .key { width: 106px; }
/***** User posts browser ****/
-div.uposts table { table-layout: fixed }
-div.uposts td { white-space: nowrap }
-div.uposts td.tc1 { width: 60px; padding-left: 0!important; padding-right: 0; text-align: right }
-div.uposts td.tc2 { width: 40px; padding-left: 0 }
-div.uposts td.tc3 { width: 80px; }
-div.uposts td.tc4 { overflow: hidden }
-div.uposts td.tc4 b { margin-left: 10px }
+.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 }
@@ -971,7 +836,7 @@ div.uposts td.tc4 b { margin-left: 10px }
/***** Tag links *****/
-.browse.taglinks .tc1 { width: 70px }
+.browse.taglinks .tc1 { width: 100px }
.browse.taglinks .tc3 { width: 80px }
.browse.taglinks .setfil { font-size: 10px; padding-right: 3px }
@@ -979,9 +844,9 @@ div.uposts td.tc4 b { margin-left: 10px }
.tagscore > span { display: inline-block; width: 25px; text-align: right; padding-right: 3px; font-size: 11px }
.tagscore > div { display: inline-block; height: 13px; background: linear-gradient(90deg, #cf0 0px, #0f0 30px) }
.tagscore.negative > div { background: #f00 }
-.tagscore.negative > span { color: $standout }
+.tagscore.negative > span { color: var(--standout) }
.tagscore.ignored > div { background: #222 }
-.tagscore.ignored > span { color: $grayedout }
+.tagscore.ignored > span { color: var(--grayedout) }
/***** VN tagmod *****/
@@ -989,28 +854,35 @@ div.uposts td.tc4 b { margin-left: 10px }
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 $border; border-left: 1px solid $border; text-align: center }
-table.tgl .tc_others { border-left: 1px solid $border; text-align: center }
-table.tgl .tc_tagname { min-width: 200px; border-right: 1px solid $border }
+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 30px; min-width: 100px }
+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: 400px; padding: 0 5px 5px 5px; background: $blendbg }
-table.tgl .buts a { box-sizing: border-box; display: block; width: 15px; height: 14px; border: 1px solid $border; margin: 0; float: left }
-table.tgl .buts a.l0 { border: none; background-color: $border }
+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 5px }
-table.tgl .tc_myspoil { padding: 0 30px; min-width: 60px }
-table.tgl .buts a.sn { border: none; background-color: $border }
+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 .tc_allvote { border-left: 1px solid $border; padding: 1px 0 0 30px; }
+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: 15px }
+table.tgl .tc_allspoil { text-align: right; padding-right: 5px }
table.tgl .tagmod_cat td { font-weight: bold; padding-top: 10px }
@@ -1018,23 +890,23 @@ table.tgl .tagmod_cat td { font-weight: bold; padding-top: 10px }
/****** Revision information ******/
-div.revision div.rev, div.revision table {
- border: 1px solid $border;
+.revision div.rev, .revision table {
+ border: 1px solid var(--border);
margin: 0 auto;
width: 90%;
- background-color: $secbg;
+ background-color: var(--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; }
+.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; }
@@ -1044,8 +916,8 @@ div#iv_view {
position: absolute;
top: 0px;
left: 0px;
- background: $boxbg;
- border: 1px solid $border;
+ background: var(--boxbg);
+ border: 1px solid var(--border);
padding: 5px;
text-align: center;
}
@@ -1077,7 +949,7 @@ div#iv_view {
* 3 a -> next
* 4 a -> flagging
*/
-.ivview { position: fixed; background: $boxbg; border: 2px solid $border; padding: 5px; text-align: center }
+.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 }
@@ -1115,84 +987,40 @@ div#iv_view {
.ivview > div:nth-child(3) > a:nth-child(4) { text-align: right; font-size: 11px; font-weight: normal }
-/****** filter selector *****/
-
-.fil_div {
- position: absolute;
- top: 0px;
- left: 0px;
- background: $tabbg;
- border: 1px solid $border;
- padding: 5px;
- width: 600px;
- text-align: center;
-}
-.fil_div a.close { float: right; border: 0; font-weight: bold }
-.fil_div p.browseopts { padding: 2px 20px; line-height: 23px }
-.fil_div .browseopts a { outline: none; color: $maintext }
-.fil_div .browseopts a.active { font-weight: bold }
-.fil_div b.ruler { display: block; margin: auto; width: 93%; height: 1px; border-bottom: 1px solid $border; margin-bottom: 5px }
-.fil_div h3 { width: 100%; text-align: center; font-size: 13px }
-.fil_div table { width: 93%; text-align: left; margin: 0 auto 5px auto }
-.fil_div table td.label label { width: 120px }
-.fil_div table td.label b { display: block; font-weight: normal; padding: 10px 5px 0 0 }
-.fil_div table td.check { width: 15px }
-.fil_div label.active { font-weight: bold }
-.fil_div .opts a { border: 0; outline: none }
-.fil_div .opts b { margin: 0 7px; font-weight: normal }
-.fil_div .opts a.tsel { color: $maintext; }
-.fil_div table ul { margin: 0 0 0 15px }
-.fil_div .slider p { margin: 1px; }
-.fil_div .slider div { margin: 1px; border: 1px solid $secborder; float: left; height: 12px; }
-.fil_div .slider div div { border-top: none; border-bottom: none; cursor: default; position: relative; height: 10px; margin: 1px; }
-.fil_div .slider span { margin-left: 5px }
-
-p.filselect {
- text-align: center;
- display: block;
- margin: 10px auto 3px auto;
- border: none;
- outline: none;
-}
-p.filselect a { margin: 0 5px }
-p.filselect i { font-style: normal }
+/****** 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 }
-/****** Advanced Search *******/
+/* 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 }
-.advsearch { max-width: 800px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center }
-.advsearch .advrow > tr > td { padding: 0 2px 0 0 }
-.advsearch .advrow > tr > td:first-child { text-align: right; white-space: nowrap; }
-.advsearch .advrow > tr > td:first-child > div { width: auto }
-.advsearch .advnest > tr > td { padding: 0 2px 0 0 }
-.advsearch .advnest > tr > td:first-child { text-align: right; white-space: nowrap; }
-.advsearch .advnest > tr > td:first-child > div { width: auto }
-.advsearch .advnest > tr > td:first-child > b { display: block; margin: 6px 3px 0 0 }
+.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 }
-/* Line drawing. This is awful */
-.advsearch .advnest > tr > td:nth-child(2) { position: relative; width: 15px; padding: 0 }
-.advsearch .advnest > tr > td:nth-child(2) div { border-left: 1px solid $border; width: 15px; position: absolute; left: 5px; top: 0; bottom: 0 }
-.advsearch .advnest > tr > td:nth-child(2).start { top: 13px }
-.advsearch .advnest > tr > td:nth-child(2).start div { border-top: 1px solid $border }
-.advsearch .advnest > tr > td:nth-child(2).start span { display: block; position: absolute; left: -5px; top: 0; width: 15px; border-top: 1px solid $border; height: 1px }
-.advsearch .advnest > tr > td:nth-child(2).end div { bottom: 13px; border-bottom: 1px solid $border }
-.advsearch .advnest > tr > td:nth-child(2).mid span { display: block; position: absolute; left: 5px; top: 13px; width: 15px; border-top: 1px solid $border; height: 1px }
-
-.advsearch .elm_dd_input { display: inline-block; margin: 5px 4px; width: 150px; vertical-align: middle }
-.advsearch .elm_dd_input.short { width: auto }
-.advsearch .advbut { width: 100%; background-color: $blendbg; text-align: right; white-space: nowrap }
-.advsearch .advbut > * { display: inline-block; box-sizing: border-box; height: 20px; padding: 3px 5px 0 2px; cursor: pointer; border-bottom: none; font-size: 16px }
-.advsearch .advbut > b { color: $grayedout; font-style: normal }
-.advsearch .advheader { box-sizing: border-box; background-color: $blendbg; padding: 3px; width: 100%; margin-bottom: 2px }
-.advsearch .advheader > h3 { text-align: center; font-weight: bold; font-size: inherit; margin-bottom: 3px }
-.advsearch .advheader .opts { display: flex; justify-content: space-between; align-items: flex-end; min-width: 170px }
-.advsearch .advheader .opts > * { margin: 0; white-space: nowrap }
-.advsearch .advheader .opselect > * { display: inline-block; font-size: 18px; padding: 0 5px }
-
-.advsearch .optbuttons { margin-top: 5px }
-.advsearch .optbuttons > .elm_dd_button { margin-top: 5px; margin-right: 10px; width: 120px; display: inline-block; }
+.xsearch svg { width: 15px; height: 15px; margin: 2px 0 -2px 0 }
@@ -1208,115 +1036,68 @@ p.filselect i { font-style: normal }
.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 $border; padding: 5px; display: flex; justify-content: center; align-items: center; background: #000 }
+.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 $secborder; padding: 7px; width: 100px; white-space: nowrap; margin: 2px 0; cursor: pointer }
+.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: $secbg }
+.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: $grayedout }
+.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) ******/
-div.imagebrowse { padding: 0; display: flex; flex-wrap: wrap }
+.imagebrowse { padding: 0; display: flex; flex-wrap: wrap }
.imagebrowse .imagecard { padding: 2px; display: flex; flex: 1 }
-.imagebrowse .imagecard:hover { background-color: $secbg }
+.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: $standout; stroke-width: 2 }
-.imagebrowse .imagecard > div svg .ruler { stroke: $border; stroke-width: 1; stroke-dasharray: 3 }
-.imagebrowse .imagecard > div svg rect { fill: $maintext }
+.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 *******/
-.icons {
- background: url(/g/icons.png?#{$icons-version}) no-repeat;
- width: 16px;
- height: 14px;
- margin: 0 2px 0 0;
- margin-top: 0px!important;
- overflow: hidden;
- display:-moz-inline-stack;
- display: inline-block;
- padding: 0;
- border: 0;
- text-decoration: none;
-}
-.icons.lang { width: 13px; height: 11px }
-.icons.feed { width: 12px; height: 12px }
-.icons.gen { width: 14px; height: 14px }
-.icons.gen.b { width: 28px }
-.icons.rtcomplete, .icons.rtpartial, .icons.rttrial { width: 11px; }
-abbr.icons, abbr.uicons { cursor: default; }
-a .icons { cursor: pointer }
-@import 'data/icons/icons';
-
-.icons.plat { height: 16px; margin: -1px 2px -1px 0!important; background-size: contain; background-repeat: no-repeat; background-position: center }
-/* This list of platform icons can be avoided by linking to the icons directly with <img> tags,
- * but I like having the option to have different icons for different skins. */
-.icons.win { background-image: url(/f/plat/win.svg) }
-.icons.lin { background-image: url(/f/plat/lin.svg) }
-.icons.mac { background-image: url(/f/plat/mac.svg) }
-.icons.web { background-image: url(/f/plat/web.svg) }
-.icons.ios { background-image: url(/f/plat/ios.svg) }
-.icons.and { background-image: url(/f/plat/and.svg) }
-.icons.bdp { background-image: url(/f/plat/bdp.svg) }
-.icons.dos { background-image: url(/f/plat/dos.svg) }
-.icons.dvd { background-image: url(/f/plat/dvd.svg) }
-.icons.drc { background-image: url(/f/plat/drc.svg) }
-.icons.nes { background-image: url(/f/plat/nes.svg) }
-.icons.fmt { background-image: url(/f/plat/fmt.svg) }
-.icons.gba { background-image: url(/f/plat/gba.svg) }
-.icons.gbc { background-image: url(/f/plat/gbc.svg) }
-.icons.msx { background-image: url(/f/plat/msx.svg) }
-.icons.nds { background-image: url(/f/plat/nds.svg) }
-.icons.swi { background-image: url(/f/plat/swi.svg) }
-.icons.wii { background-image: url(/f/plat/wii.svg) }
-.icons.wiu { background-image: url(/f/plat/wiu.svg) }
-.icons.n3d { background-image: url(/f/plat/n3d.svg) }
-.icons.p88 { background-image: url(/f/plat/p88.svg) }
-.icons.p98 { background-image: url(/f/plat/p98.svg) }
-.icons.pce { background-image: url(/f/plat/pce.svg) }
-.icons.pcf { background-image: url(/f/plat/pcf.svg) }
-.icons.psp { background-image: url(/f/plat/psp.svg) }
-.icons.ps1 { background-image: url(/f/plat/ps1.svg) }
-.icons.ps2 { background-image: url(/f/plat/ps2.svg) }
-.icons.ps3 { background-image: url(/f/plat/ps3.svg) }
-.icons.ps4 { background-image: url(/f/plat/ps4.svg) }
-.icons.psv { background-image: url(/f/plat/psv.svg) }
-.icons.sat { background-image: url(/f/plat/sat.svg) }
-.icons.sfc { background-image: url(/f/plat/sfc.svg) }
-.icons.x68 { background-image: url(/f/plat/x68.svg) }
-.icons.xb1 { background-image: url(/f/plat/xb1.svg) }
-.icons.xb3 { background-image: url(/f/plat/xb3.svg) }
-.icons.xbo { background-image: url(/f/plat/xbo.svg) }
-.icons.oth { background-image: url(/f/plat/oth.svg) }
-
-
-.release_icons { width: 16px; height: 16px; float: right; margin-left: 4px; }
-.release_icon_voiced2, .release_icon_anim2 { filter: hue-rotate(30deg); }
-.release_icon_voiced3, .release_icon_anim3 { filter: invert(100%) hue-rotate(240deg); }
-.release_icon_voiced4, .release_icon_anim4 { filter: hue-rotate(80deg); }
-
-/* 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 }
+/* XXX: Icon elements MUST have their 'icon-*' class as the first in the list */
+[class^=icon-] { cursor: inherit; margin: 0 2px 0 0; display: inline-block; text-decoration: none; margin: 0 2px 0 0 }
+[class^=icon-lang-],
+[class^=icon-gen-],
+.icon-external,
+.icon-rtcomplete, .icon-rtpartial, .icon-rttrial { background-image: url(/icons.png?#{$png-version}) }
+[class^=icon-plat-],
+[class^=icon-drm-],
+[class^=icon-rel-],
+[class^=icon-list-],
+.icon-rss { background-image: url(/icons.svg?#{$svg-version}) }
+
+[class^=icon-lang-] { opacity: 0.5 }
+[class^=icon-lang-].mtl { filter: grayscale(1); opacity: 0.4 }
+[class^=icon-plat-] { margin: -1px 2px -1px 0 }
+[class^=icon-list-] { margin: -1px 0 }
+
+.icon-rel-v2, .icon-rel-a2 { filter: hue-rotate(30deg); }
+.icon-rel-v3, .icon-rel-a3 { filter: invert(100%) hue-rotate(240deg); }
+.icon-rel-v4, .icon-rel-a4 { filter: hue-rotate(80deg); }
+
+
+/* Relation graph */
+svg .border { fill: none; stroke: var(--border) }
+svg .edge polygon.border { fill: var(--border) }
+svg .nodebg { fill: var(--tabbg); stroke: var(--tabbg) }
+svg text { fill: var(--maintext); font: 8px "Tahoma", "Arial", sans-serif }
+svg .title { font-size: 9px }
+#graph_current .border { stroke: var(--warnborder) }
+#graph_current .nodebg { stroke: var(--warnborder); fill: var(--warnbg) }
diff --git a/css/vngraph.css b/css/vngraph.css
new file mode 100644
index 00000000..05a370fb
--- /dev/null
+++ b/css/vngraph.css
@@ -0,0 +1,27 @@
+/* Styles js/graph/vn.js */
+#vn-graph {
+ > div { /* options div */
+ width: 100%;
+ padding-top: 2px;
+ display: flex;
+ justify-content: space-between;
+ > small { font-size: 80% }
+ }
+ > svg { /* the graph */
+ width: 100%;
+ .edges line {
+ stroke-width: 5;
+ stroke: var(--grayedout);
+ }
+ .main circle { fill: var(--maintext) }
+ pattern circle { fill: var(--maintext) } /* No image */
+ }
+}
+.vn-rel-icon svg { width: 14px; height: 14px; margin-right: 5px }
+#vn-graph-arrow { stroke: var(--grayedout); fill: none; stroke-width: 2 }
+#vn-graph-sel {
+ width: 400px; height: 80px; padding-top: 50px;
+ display: flex; justify-content: center; align-items: flex-end;
+ > div { padding: 5px; background: var(--secbg) }
+ a { font-size: 18px; }
+}
diff --git a/data/icons/feed.png b/data/icons/feed.png
deleted file mode 100644
index 22b1e844..00000000
--- a/data/icons/feed.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ar.png b/data/icons/lang/ar.png
deleted file mode 100644
index da06bd16..00000000
--- a/data/icons/lang/ar.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/bg.png b/data/icons/lang/bg.png
deleted file mode 100644
index c115806a..00000000
--- a/data/icons/lang/bg.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ca.png b/data/icons/lang/ca.png
deleted file mode 100644
index 97612ceb..00000000
--- a/data/icons/lang/ca.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/cs.png b/data/icons/lang/cs.png
deleted file mode 100644
index a4a2f6cd..00000000
--- a/data/icons/lang/cs.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/da.png b/data/icons/lang/da.png
deleted file mode 100644
index 7b7070ea..00000000
--- a/data/icons/lang/da.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/de.png b/data/icons/lang/de.png
deleted file mode 100644
index a9155cfc..00000000
--- a/data/icons/lang/de.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/el.png b/data/icons/lang/el.png
deleted file mode 100644
index a8402131..00000000
--- a/data/icons/lang/el.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/en.png b/data/icons/lang/en.png
deleted file mode 100644
index b1cf3674..00000000
--- a/data/icons/lang/en.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/es.png b/data/icons/lang/es.png
deleted file mode 100644
index f461c138..00000000
--- a/data/icons/lang/es.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/fi.png b/data/icons/lang/fi.png
deleted file mode 100644
index 5f5d8fed..00000000
--- a/data/icons/lang/fi.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/fr.png b/data/icons/lang/fr.png
deleted file mode 100644
index 5f0589c8..00000000
--- a/data/icons/lang/fr.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ga.png b/data/icons/lang/ga.png
deleted file mode 100644
index 2f249cfc..00000000
--- a/data/icons/lang/ga.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/he.png b/data/icons/lang/he.png
deleted file mode 100644
index 01a62f56..00000000
--- a/data/icons/lang/he.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/hr.png b/data/icons/lang/hr.png
deleted file mode 100644
index c20821b4..00000000
--- a/data/icons/lang/hr.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/hu.png b/data/icons/lang/hu.png
deleted file mode 100644
index 68df6f90..00000000
--- a/data/icons/lang/hu.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/id.png b/data/icons/lang/id.png
deleted file mode 100644
index 3ab93ee7..00000000
--- a/data/icons/lang/id.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/it.png b/data/icons/lang/it.png
deleted file mode 100644
index 91f98e2b..00000000
--- a/data/icons/lang/it.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ja.png b/data/icons/lang/ja.png
deleted file mode 100644
index ef982e2c..00000000
--- a/data/icons/lang/ja.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ko.png b/data/icons/lang/ko.png
deleted file mode 100644
index de15c590..00000000
--- a/data/icons/lang/ko.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ms.png b/data/icons/lang/ms.png
deleted file mode 100644
index 6e033649..00000000
--- a/data/icons/lang/ms.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/nl.png b/data/icons/lang/nl.png
deleted file mode 100644
index 046cf74d..00000000
--- a/data/icons/lang/nl.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/no.png b/data/icons/lang/no.png
deleted file mode 100644
index ccad6bfe..00000000
--- a/data/icons/lang/no.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/pl.png b/data/icons/lang/pl.png
deleted file mode 100644
index bba98646..00000000
--- a/data/icons/lang/pl.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/pt-br.png b/data/icons/lang/pt-br.png
deleted file mode 100644
index f4094097..00000000
--- a/data/icons/lang/pt-br.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/pt-pt.png b/data/icons/lang/pt-pt.png
deleted file mode 100644
index ae293629..00000000
--- a/data/icons/lang/pt-pt.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ro.png b/data/icons/lang/ro.png
deleted file mode 100644
index bf52726f..00000000
--- a/data/icons/lang/ro.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ru.png b/data/icons/lang/ru.png
deleted file mode 100644
index 8926f08d..00000000
--- a/data/icons/lang/ru.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/sk.png b/data/icons/lang/sk.png
deleted file mode 100644
index 1511820d..00000000
--- a/data/icons/lang/sk.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/sv.png b/data/icons/lang/sv.png
deleted file mode 100644
index 512e100f..00000000
--- a/data/icons/lang/sv.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/ta.png b/data/icons/lang/ta.png
deleted file mode 100644
index ab44ca0a..00000000
--- a/data/icons/lang/ta.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/th.png b/data/icons/lang/th.png
deleted file mode 100644
index bef862a9..00000000
--- a/data/icons/lang/th.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/tr.png b/data/icons/lang/tr.png
deleted file mode 100644
index 004b9c83..00000000
--- a/data/icons/lang/tr.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/uk.png b/data/icons/lang/uk.png
deleted file mode 100644
index 5645f271..00000000
--- a/data/icons/lang/uk.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/vi.png b/data/icons/lang/vi.png
deleted file mode 100644
index 65c59ea5..00000000
--- a/data/icons/lang/vi.png
+++ /dev/null
Binary files differ
diff --git a/data/icons/lang/zh.png b/data/icons/lang/zh.png
deleted file mode 100644
index 8dafa06d..00000000
--- a/data/icons/lang/zh.png
+++ /dev/null
Binary files differ
diff --git a/elm/AdvSearch/Anime.elm b/elm/AdvSearch/Anime.elm
index 1e713676..8d0882dc 100644
--- a/elm/AdvSearch/Anime.elm
+++ b/elm/AdvSearch/Anime.elm
@@ -29,7 +29,7 @@ init dat =
let (ndat, sel) = S.init dat
in ( { ndat | objid = ndat.objid + 1 }
, { sel = { sel | single = False }
- , conf = { wrap = Search, id = "advsearch_anime" ++ String.fromInt ndat.objid, source = A.animeSource True }
+ , conf = { wrap = Search, id = "xsearch_anime" ++ String.fromInt ndat.objid, source = A.animeSource True }
, search = A.init ""
}
)
@@ -59,7 +59,7 @@ fromQuery dat qf = S.fromQuery (\q ->
|> Maybe.map (\(ndat,sel) ->
( { ndat | objid = ndat.objid+1 }
, { sel = { sel | single = False }
- , conf = { wrap = Search, id = "advsearch_anime" ++ String.fromInt ndat.objid, source = A.animeSource True }
+ , conf = { wrap = Search, id = "xsearch_anime" ++ String.fromInt ndat.objid, source = A.animeSource True }
, search = A.init ""
}
))
@@ -69,10 +69,10 @@ fromQuery dat qf = S.fromQuery (\q ->
view : Data -> Model -> (Html Msg, () -> List (Html Msg))
view dat model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text "Anime" ]
+ [] -> small [] [ text "Anime" ]
[s] -> span [ class "nowrap" ]
[ S.lblPrefix model.sel
- , b [ class "grayedout" ] [ text <| "a" ++ String.fromInt s ++ ":" ]
+ , 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) ++ ")" ]
@@ -84,7 +84,7 @@ view dat model =
, ul [] <| List.map (\s ->
li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ]
[ inputButton "X" (Sel (S.Sel s False)) []
- , b [ class "grayedout" ] [ text <| " a" ++ String.fromInt s ++ ": " ]
+ , small [] [ text <| " a" ++ String.fromInt s ++ ": " ]
, Dict.get s dat.anime |> Maybe.map (\e -> e.title) |> Maybe.withDefault "" |> text
]
) (Set.toList model.sel.sel)
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
index 2d0df65f..8214cae2 100644
--- a/elm/AdvSearch/Engine.elm
+++ b/elm/AdvSearch/Engine.elm
@@ -1,7 +1,5 @@
module AdvSearch.Engine exposing (..)
--- TODO: Add "unknown" option? (= empty string)
-
import Html exposing (..)
import Html.Attributes exposing (..)
import Set
@@ -30,7 +28,7 @@ init dat =
let (ndat, sel) = S.init dat
in ( { ndat | objid = ndat.objid + 1 }
, { sel = { sel | single = False }
- , conf = { wrap = Search, id = "advsearch_eng" ++ String.fromInt ndat.objid, source = A.engineSource }
+ , conf = { wrap = Search, id = "xsearch_eng" ++ String.fromInt ndat.objid, source = A.engineSource }
, search = A.init ""
}
)
@@ -56,7 +54,7 @@ fromQuery dat q =
in S.fromQuery f dat q |> Maybe.map (\(ndat,sel) ->
( { ndat | objid = ndat.objid+1 }
, { sel = { sel | single = False }
- , conf = { wrap = Search, id = "advsearch_eng" ++ String.fromInt ndat.objid, source = A.engineSource }
+ , conf = { wrap = Search, id = "xsearch_eng" ++ String.fromInt ndat.objid, source = A.engineSource }
, search = A.init ""
}
))
@@ -64,8 +62,8 @@ fromQuery dat q =
view : Model -> (Html Msg, () -> List (Html Msg))
view model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text "Engine" ]
- [s] -> span [ class "nowrap" ] [ S.lblPrefix model.sel, text s ]
+ [] -> 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" ]
@@ -74,7 +72,8 @@ view model =
]
, ul [] <| List.map (\s ->
li [] [ inputButton "X" (Sel (S.Sel s False)) [], text " ", text s ]
- ) (Set.toList model.sel.sel)
+ ) <| 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
index 1e80b45b..2ec6e205 100644
--- a/elm/AdvSearch/Fields.elm
+++ b/elm/AdvSearch/Fields.elm
@@ -19,7 +19,10 @@ 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.
@@ -54,8 +57,8 @@ nestInit and ptype qtype list dat =
, qtype = qtype
, fields = list
, and = and
- , andDd = DD.init ("advsearch_field"++String.fromInt (dat.objid+0)) (FSNest << NAndToggle)
- , addDd = DD.init ("advsearch_field"++String.fromInt (dat.objid+1)) (FSNest << NAddToggle)
+ , 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
}
@@ -79,7 +82,12 @@ nestUpdate dat msg model =
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)
- in (ndat2, { model | addDd = DD.toggle model.addDd False, addtype = [model.qtype], fields = model.fields ++ [f2] }, Cmd.none)
+ 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 ->
@@ -109,9 +117,11 @@ nestToQuery dat model =
(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
@@ -134,9 +144,11 @@ nestFromQuery ptype qtype dat q =
(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
@@ -176,10 +188,12 @@ nestView dat dd model =
(_,C) -> "Character"
(C,S) -> "VA"
(_,S) -> "Staff"
+ (V,P) -> "Developer"
+ (_,P) -> "Producer"
breads pre par l =
case l of
[] -> []
- [x] -> [ b [] [ text (showT par x) ] ]
+ [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" ]
@@ -212,7 +226,9 @@ nestView dat dd model =
(_, 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 ] ]
@@ -225,6 +241,8 @@ nestView dat dd model =
(_, R) -> "Rel"
(_, V) -> "VN"
(V, S) -> "Staff"
+ (V, P) -> "Developer"
+ (R, P) -> "Producer"
(C, S) -> "VA"
_ -> ""
@@ -249,7 +267,7 @@ nestView dat dd model =
]
]
else table [ class "advrow" ] [ tr []
- [ td [] (initialdd ++ [b [ class "grayedout" ] [ text " → " ]])
+ [ td [] (initialdd ++ [small [] [ text " → " ]])
, td [] (filters ++ [add]) ] ]
@@ -275,11 +293,11 @@ type FieldModel
= FMCustom Query -- A read-only placeholder for Query values that failed to parse into a Field
| FMNest NestModel
| FMList ListModel
- | FMLang (AS.Model String)
- | FMOLang (AS.Model String)
+ | 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)
@@ -292,6 +310,9 @@ type FieldModel
| 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)
@@ -303,25 +324,28 @@ type FieldModel
| FMRating (AR.Model Int)
| FMVotecount (AR.Model Int)
| FMMinAge (AR.Model Int)
- | FMDeveloper AP.Model
+ | 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)
- | FSOLang (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
@@ -334,6 +358,9 @@ type FieldMsg
| 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
@@ -345,15 +372,18 @@ type FieldMsg
| FSRating AR.Msg
| FSVotecount AR.Msg
| FSMinAge AR.Msg
- | FSDeveloper AP.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
@@ -403,19 +433,25 @@ fields =
, n V R "Release »"
, n V S "Staff »"
, n V C "Character »"
- , f V "Language" 1 FMLang AS.init AS.langFromQuery
- , f V "Original language" 2 FMOLang AS.init AS.olangFromQuery
+ , 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)
- , f V "" -4 FMTag AG.init (AG.fromQuery 0)
- , f V "" -4 FMTag AG.init (AG.fromQuery 1)
- , f V "" -4 FMTag AG.init (AG.fromQuery 2)
+ , 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 "Developer" 0 FMDeveloper AP.init (AP.fromQuery False)
+ , f V "Development status" 0 FMDevStatus AS.init AS.devStatusFromQuery
, f V "Release date" 0 FMRDate AD.init AD.fromQuery
- , f V "Popularity" 0 FMPopularity AR.popularityInit AR.popularityFromQuery
+ , 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
@@ -423,19 +459,20 @@ fields =
, 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 »"
- , f R "Language" 1 FMLang AS.init AS.langFromQuery
+ , 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 "Self-published" 0 [(QInt 63 Eq 1, "Self-published"), (QInt 63 Ne 1, "Commercially published")]
+ , 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 "Developer" 0 FMDeveloper AP.init (AP.fromQuery False)
- , f R "Producer" 0 FMProducer AP.init (AP.fromQuery True)
, 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
@@ -444,19 +481,31 @@ fields =
, 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)
- , f C "" 0 FMTrait AI.init (AI.fromQuery 0)
- , f C "" 0 FMTrait AI.init (AI.fromQuery 1)
- , f C "" 0 FMTrait AI.init (AI.fromQuery 2)
+ , 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
@@ -467,9 +516,15 @@ fields =
, n S S "And/Or"
, f S "Name" 0 FMStaff AT.init AT.fromQuery
- , f S "Language" 1 FMLang AS.init AS.langFromQuery
+ , 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
]
@@ -485,12 +540,14 @@ fieldUpdate dat msg_ (num, dd, model) =
case model of
FMTag m -> Cmd.map FSTag (A.refocus m.conf)
FMTrait m -> Cmd.map FSTrait (A.refocus m.conf)
- FMDeveloper m -> Cmd.map FSDeveloper (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.
@@ -509,15 +566,22 @@ fieldUpdate dat msg_ (num, dd, model) =
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.update msg m)
- (FSOLang msg, FMOLang m) -> maps FMOLang (AS.update msg m)
+ (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)
@@ -530,6 +594,9 @@ fieldUpdate dat msg_ (num, dd, model) =
(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)
@@ -541,15 +608,18 @@ fieldUpdate dat msg_ (num, dd, model) =
(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)
- (FSDeveloper msg,FMDeveloper m)-> mapf FMDeveloper FSDeveloper (AP.update dat 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
@@ -560,14 +630,12 @@ fieldViewDd dat dd lbl cont =
[ DD.view dd Api.Normal lbl <| \() ->
div [ class "advbut" ]
[ if dat.level == 0
- then b [ title "Can't delete the top-level filter" ] [ text "⊗" ]
+ 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 b [ title "Can't move this filter to parent branch" ] [ text "↰" ]
+ 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 "↰" ]
- , if dat.level == 0
- then b [ title "Can't move this filter into a subbranch" ] [ text "↳" ]
- else a [ href "#", onClickD FMoveSub, title "Create new branch for this filter" ] [ text "↳" ]
+ , a [ href "#", onClickD FMoveSub, title "Create new branch for this filter" ] [ text "↳" ]
] :: cont ()
]
@@ -580,11 +648,11 @@ fieldView dat (_, dd, model) =
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 False m)
- FMOLang m -> f FSOLang (AS.langView True 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)
@@ -597,6 +665,9 @@ fieldView dat (_, dd, model) =
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)
@@ -608,15 +679,18 @@ fieldView dat (_, dd, model) =
FMRating m -> f FSRating (AR.ratingView m)
FMVotecount m -> f FSVotecount (AR.votecountView m)
FMMinAge m -> f FSMinAge (AR.minageView m)
- FMDeveloper m -> f FSDeveloper (AP.view False dat m)
- FMProducer m -> f FSProducer (AP.view True dat 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
@@ -626,11 +700,11 @@ fieldToQuery dat (_, _, model) =
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.toQuery (QStr 2) m
- FMOLang m -> AS.toQuery (QStr 3) 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
@@ -643,6 +717,9 @@ fieldToQuery dat (_, _, model) =
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
@@ -654,21 +731,24 @@ fieldToQuery dat (_, _, model) =
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
- FMDeveloper m-> AP.toQuery False m
- FMProducer m -> AP.toQuery True 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 ("advsearch_field" ++ String.fromInt dat.objid) FToggle, fm)
+ , (fid, DD.init ("xsearch_field" ++ String.fromInt dat.objid) FToggle, fm)
)
diff --git a/elm/AdvSearch/Lib.elm b/elm/AdvSearch/Lib.elm
index 46f84e0b..2841acce 100644
--- a/elm/AdvSearch/Lib.elm
+++ b/elm/AdvSearch/Lib.elm
@@ -12,7 +12,7 @@ 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
+type QType = V | R | C | S | P
type Op = Eq | Ne | Ge | Gt | Le | Lt
type Query
= QAnd (List Query)
@@ -148,6 +148,7 @@ showQType q =
R -> "r"
C -> "c"
S -> "s"
+ P -> "p"
showOp : Op -> String
showOp op =
@@ -164,7 +165,7 @@ 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.b [] [ Html.text (showOp op) ] else Html.a [ Html.Attributes.href "#", Lib.Html.onClickD (msg op) ] [ Html.text (showOp 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]
@@ -178,7 +179,7 @@ type alias Data =
, defaultSpoil : Int
, producers : Dict.Dict String GApi.ApiProducerResult
, staff : Dict.Dict String GApi.ApiStaffResult
- , tags : Dict.Dict Int GApi.ApiTagResult
- , traits : Dict.Dict Int GApi.ApiTraitResult
+ , 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
index 06f60e4d..31331692 100644
--- a/elm/AdvSearch/Main.elm
+++ b/elm/AdvSearch/Main.elm
@@ -14,9 +14,9 @@ import Json.Decode as JD
import Gen.Api as GApi
import Gen.AdvSearchSave as GASS
import Gen.AdvSearchDel as GASD
-import Gen.AdvSearchLoad as GASL
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 (..)
@@ -41,6 +41,8 @@ type alias Recv =
, query : GApi.ApiAdvSearchQuery
}
+type SaveAct = Save | Load | Delete | Default
+
type alias Model =
{ query : Field
, qtype : QType
@@ -49,21 +51,21 @@ type alias Model =
, saved : List SQuery
, saveState : Api.State
, saveDd : DD.Config Msg
- , saveAct : Int
+ , saveAct : SaveAct
, saveName : String
, saveDel : Set.Set String
+ , loadQuery : Maybe String
}
type Msg
= Noop
| Field FieldMsg
| SaveToggle Bool
- | SaveAct Int
+ | SaveAct SaveAct
| SaveName String
| SaveSave String
| SaveSaved SQuery GApi.Response
| SaveLoad String
- | SaveLoaded GApi.Response
| SaveDelSel String
| SaveDel (Set.Set String)
| SaveDeleted (Set.Set String) GApi.Response
@@ -109,6 +111,7 @@ loadQuery odat arg =
"v" -> V
"c" -> C
"s" -> S
+ "p" -> P
_ -> R
(dat2, query) = JD.decodeValue decodeQuery arg.query |> Result.toMaybe |> Maybe.withDefault (QAnd []) |> fieldFromQuery qtype dat
@@ -145,10 +148,11 @@ init arg =
, error = arg.error
, saved = arg.saved
, saveState = Api.Normal
- , saveDd = DD.init "advsearch_save" SaveToggle
- , saveAct = 0
+ , saveDd = DD.init "xsearch_save" SaveToggle
+ , saveAct = Save
, saveName = ""
, saveDel = Set.empty
+ , loadQuery = Nothing
}
@@ -160,9 +164,9 @@ update msg model =
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 == 0 && not (List.isEmpty model.saved) && fieldToQuery model.data model.query == Nothing then 1 else model.saveAct
+ 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 == 0 then Task.attempt (always Noop) (Dom.focus "advsearch_saveinput") else Cmd.none)
+ , 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 ->
@@ -178,87 +182,86 @@ update msg model =
[] -> 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 }, GASL.send { qtype = showQType model.qtype, query = q } SaveLoaded)
- SaveLoaded (GApi.AdvSearchQuery q) ->
- let (_, query, dat) = loadQuery model.data q
- in ({ model | saveState = Api.Normal, query = query, data = dat }, Cmd.none)
- SaveLoaded 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 "advsearch" ] <|
+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 encQ ] []
- , Html.map Field (fieldView model.data model.query)
- , div [ class "optbuttons" ]
- [ if model.data.uid == Nothing then text "" else div [ class "elm_dd_button" ]
- [ DD.view model.saveDd model.saveState (text "Save/Load") <| \() ->
- [ div [ class "advheader", style "min-width" "300px" ]
- [ div [ class "opts", style "margin-bottom" "5px" ]
- [ if model.saveAct == 0 then b [] [ text "Save" ] else a [ href "#", onClickD (SaveAct 0) ] [ text "Save" ]
- , if model.saveAct == 1 then b [] [ text "Load" ] else a [ href "#", onClickD (SaveAct 1) ] [ text "Load" ]
- , if model.saveAct == 2 then b [] [ text "Delete" ] else a [ href "#", onClickD (SaveAct 2) ] [ text "Delete" ]
- , if model.saveAct == 3 then b [] [ text "Default"] else a [ href "#", onClickD (SaveAct 3) ] [ text "Default" ]
- ]
- , h3 [] [ text <| if model.saveAct == 0 then "Save current filter" else if model.saveAct == 1 then "Load filter" else "Delete saved filter" ]
+ [ 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" ]
]
- , case (List.filter (\e -> e.name /= "") model.saved, model.saveAct) of
- (_, 0) ->
- if encQ == "" then text "Nothing to save." else
- form_ "" (SaveSave model.saveName) False
- [ inputText "advsearch_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
- ]
- (_, 3) ->
- 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, 1) ->
- 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, _) ->
- 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) ]
- ]
+ , 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) ]
+ ]
]
- , input [ type_ "submit", class "submit", value "Search" ] []
]
+ , Html.map Field (fieldView model.data model.query)
, if model.error
- then b [ class "standout" ] [ text "Error parsing search query. The URL was probably corrupted in some way. "
+ 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
index 0beeab46..5d34aeb0 100644
--- a/elm/AdvSearch/Producers.elm
+++ b/elm/AdvSearch/Producers.elm
@@ -29,7 +29,7 @@ init dat =
let (ndat, sel) = S.init dat
in ( { ndat | objid = ndat.objid + 1 }
, { sel = { sel | single = False }
- , conf = { wrap = Search, id = "advsearch_prod" ++ String.fromInt ndat.objid, source = A.producerSource }
+ , conf = { wrap = Search, id = "xsearch_prod" ++ String.fromInt ndat.objid, source = A.producerSource }
, search = A.init ""
}
)
@@ -50,44 +50,41 @@ update dat msg model =
, c )
-toQuery prod m = S.toQuery (QInt (if prod then 17 else 6)) m.sel
+toQuery n m = S.toQuery (QInt n) m.sel
-fromQuery prod dat qf = S.fromQuery (\q ->
- case (prod, q) of
- (False, QInt 6 op v) -> Just (op, v)
- (True, QInt 17 op v) -> Just (op, v)
+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 = "advsearch_prod" ++ String.fromInt ndat.objid, source = A.producerSource }
+ , conf = { wrap = Search, id = "xsearch_prod" ++ String.fromInt ndat.objid, source = A.producerSource }
, search = A.init ""
}
))
-view : Bool -> Data -> Model -> (Html Msg, () -> List (Html Msg))
-view prod dat model =
- let lbl = if prod then "Producer" else "Developer"
- in
+view : String -> Data -> Model -> (Html Msg, () -> List (Html Msg))
+view lbl dat model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text lbl ]
+ [] -> small [] [ text lbl ]
[s] -> span [ class "nowrap" ]
[ S.lblPrefix model.sel
- , b [ class "grayedout" ] [ text <| "p" ++ String.fromInt s ++ ":" ]
+ , 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 ++ " (" ++ String.fromInt (List.length l) ++ ")" ]
+ l -> span [] [ S.lblPrefix model.sel, text <| lbl ++ "s (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
[ div [ class "advheader" ]
- [ h3 [] [ text lbl ]
- , Html.map Sel (S.opts model.sel True False)
+ [ 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)) []
- , b [ class "grayedout" ] [ text <| " p" ++ String.fromInt s ++ ": " ]
+ , 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)
diff --git a/elm/AdvSearch/Range.elm b/elm/AdvSearch/Range.elm
index 7bdcbfdf..89ab3a16 100644
--- a/elm/AdvSearch/Range.elm
+++ b/elm/AdvSearch/Range.elm
@@ -58,9 +58,9 @@ view canUnk lbl fmt model =
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" ]
- [ b [ class "grayedout" ] [ text (val 0) ]
- , b [] [ text (val model.val) ]
- , b [ class "grayedout" ] [ text (val (Array.length model.lst - 1)) ]
+ [ small [] [ text (val 0) ]
+ , strong [] [ text (val model.val) ]
+ , small [] [ text (val (Array.length model.lst - 1)) ]
]
, if model.unk then text "" else
input
diff --git a/elm/AdvSearch/Resolution.elm b/elm/AdvSearch/Resolution.elm
index 323d8081..7617d02c 100644
--- a/elm/AdvSearch/Resolution.elm
+++ b/elm/AdvSearch/Resolution.elm
@@ -46,7 +46,7 @@ init dat =
( { dat | objid = dat.objid+1 }
, { op = Ge
, reso = Nothing
- , conf = { wrap = Search, id = "advsearch_reso" ++ String.fromInt dat.objid, source = A.resolutionSource }
+ , conf = { wrap = Search, id = "xsearch_reso" ++ String.fromInt dat.objid, source = A.resolutionSource }
, search = A.init ""
, aspect = False
}
@@ -69,7 +69,7 @@ fromQuery dat q =
view : Model -> (Html Msg, () -> List (Html Msg))
view model =
( case model.reso of
- Nothing -> b [ class "grayedout" ] [ text "Resolution" ]
+ 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" ]
diff --git a/elm/AdvSearch/Set.elm b/elm/AdvSearch/Set.elm
index 6a1b6003..f5f2897c 100644
--- a/elm/AdvSearch/Set.elm
+++ b/elm/AdvSearch/Set.elm
@@ -6,6 +6,7 @@ import Set
import Lib.Html exposing (..)
import Lib.Util exposing (..)
import Gen.Types as GT
+import Gen.ExtLinks as GEL
import AdvSearch.Lib exposing (..)
@@ -101,32 +102,62 @@ opts m canAnd canSingle = div [ class "opts" ]
-- Language
-langView orig model =
- let tprefix = if orig then "O " else "L "
+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
- [] -> b [ class "grayedout" ] [ text <| if orig then "Orig language" else "Language" ]
+ [] -> 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 <| if orig then "Language the visual novel has been originally written in." else "Language(s) in which the visual novel is available." ]
- , opts model (not orig) True
+ [ 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 ] ]) GT.languages
+ , ul [ style "columns" "2"] <| List.map (\(l,t) -> li [] [ linkRadio (Set.member l model.sel) (Sel l) [ langIcon l, text t ] ]) lst
]
)
-langFromQuery = fromQuery (\q ->
- case q of
- QStr 2 op v -> Just (op, v)
- _ -> Nothing)
-
-olangFromQuery = fromQuery (\q ->
- case q of
- QStr 3 op v -> Just (op, v)
- _ -> Nothing)
+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
@@ -137,9 +168,9 @@ platformView unk model =
fmt p t = [ if p == "" then text "" else platformIcon p, text t ]
in
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Platform" ]
+ [] -> 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 langIcon l)
+ 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." ]
@@ -163,7 +194,7 @@ platformFromQuery = fromQuery (\q ->
lengthView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Length" ]
+ [] -> 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) ++ ")" ]
, \() ->
@@ -182,11 +213,34 @@ lengthFromQuery = fromQuery (\q ->
+-- 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
- [] -> b [ class "grayedout" ] [ text "Role" ]
+ [] -> 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) ++ ")" ]
, \() ->
@@ -209,7 +263,7 @@ roleFromQuery = fromQuery (\q ->
bloodView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Blood type" ]
+ [] -> 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) ++ ")" ]
, \() ->
@@ -249,7 +303,7 @@ sexUpdate msg (spoil,model) =
sexView (spoil,model) =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Sex" ]
+ [] -> 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) ++ ")" ]
, \() ->
@@ -272,7 +326,7 @@ sexView (spoil,model) =
genderView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Gender" ]
+ [] -> 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) ++ ")" ]
, \() ->
@@ -295,7 +349,7 @@ genderFromQuery = fromQuery (\q ->
mediumView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Medium" ]
+ [] -> small [] [ text "Medium" ]
[v] -> span [ class "nowrap" ]
[ lblPrefix model
, text <| if v == "" then "Medium: Unknown" else
@@ -324,7 +378,7 @@ mediumFromQuery = fromQuery (\q ->
voicedView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Voiced" ]
+ [] -> 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) ++ ")" ]
, \() ->
@@ -349,7 +403,7 @@ animatedView story model =
let lbl = (if story then "Story" else "Ero") ++ " animation"
in
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text lbl ]
+ [] -> 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) ++ ")" ]
, \() ->
@@ -373,7 +427,7 @@ animatedFromQuery story = fromQuery (\q ->
rtypeView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Type" ]
+ [] -> 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) ++ ")" ]
, \() ->
@@ -397,7 +451,7 @@ rtypeFromQuery = fromQuery (\q ->
labelView dat model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Labels" ]
+ [] -> 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) ++ ")" ]
, \() ->
@@ -423,7 +477,7 @@ sroleView model =
let lst = ("seiyuu","Voice actor") :: GT.creditTypes
in
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Role" ]
+ [] -> 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) ++ ")" ]
, \() ->
@@ -446,7 +500,7 @@ sroleFromQuery = fromQuery (\q ->
rlistView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "List status" ]
+ [] -> 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) ++ ")" ]
, \() ->
@@ -461,3 +515,51 @@ 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
index d2060f8d..7365419e 100644
--- a/elm/AdvSearch/Staff.elm
+++ b/elm/AdvSearch/Staff.elm
@@ -29,7 +29,7 @@ init dat =
let (ndat, sel) = S.init dat
in ( { ndat | objid = ndat.objid + 1 }
, { sel = { sel | single = False }
- , conf = { wrap = Search, id = "advsearch_staff" ++ String.fromInt ndat.objid, source = A.staffSource }
+ , conf = { wrap = Search, id = "xsearch_staff" ++ String.fromInt ndat.objid, source = A.staffSource }
, search = A.init ""
}
)
@@ -59,7 +59,7 @@ fromQuery dat qf = S.fromQuery (\q ->
|> Maybe.map (\(ndat,sel) ->
( { ndat | objid = ndat.objid+1 }
, { sel = { sel | single = False }
- , conf = { wrap = Search, id = "advsearch_staff" ++ String.fromInt ndat.objid, source = A.staffSource }
+ , conf = { wrap = Search, id = "xsearch_staff" ++ String.fromInt ndat.objid, source = A.staffSource }
, search = A.init ""
}
))
@@ -69,26 +69,26 @@ fromQuery dat qf = S.fromQuery (\q ->
view : Data -> Model -> (Html Msg, () -> List (Html Msg))
view dat model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text "Name" ]
+ [] -> small [] [ text "Name" ]
[s] -> span [ class "nowrap" ]
[ S.lblPrefix model.sel
- , b [ class "grayedout" ] [ text <| "s" ++ String.fromInt s ++ ":" ]
- , Dict.get (vndbid 's' s) dat.staff |> Maybe.map (\e -> e.name) |> Maybe.withDefault "" |> text
+ , 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 True False)
+ , 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)) []
- , b [ class "grayedout" ] [ 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.name ]) |> Maybe.withDefault (text "")
+ , 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..." ]
- , b [ class "grayedout" ] [ text "All aliases of the selected staff entries are searched, not just the names you specified." ]
+ , 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
index 2ffe1785..001890ee 100644
--- a/elm/AdvSearch/Tags.elm
+++ b/elm/AdvSearch/Tags.elm
@@ -18,12 +18,16 @@ type alias Model =
, 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)
@@ -32,9 +36,11 @@ init dat =
let (ndat, sel) = S.init dat
in ( { ndat | objid = ndat.objid + 1 }
, { sel = { sel | single = False, and = True }
- , conf = { wrap = Search, id = "advsearch_tag" ++ String.fromInt ndat.objid, source = A.tagSource }
+ , conf = { wrap = Search, id = "xsearch_tag" ++ String.fromInt ndat.objid, source = A.tagSource }
, search = A.init ""
, spoiler = dat.defaultSpoil
+ , inherit = True
+ , exclie = False
}
)
@@ -44,31 +50,38 @@ update dat msg model =
case msg of
Sel m -> (dat, { model | sel = S.update m model.sel }, Cmd.none)
Level (t,ol) nl -> (dat, { model | sel = S.update (S.Sel (t,ol) False) model.sel |> S.update (S.Sel (t,nl) True) }, Cmd.none)
- Spoiler -> (dat, { model | spoiler = if model.spoiler < 2 then model.spoiler + 1 else 0 }, Cmd.none)
+ 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 (t.id,0) True) model.sel }
+ , { 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) -> if m.spoiler == 0 && l == 0 then QInt 8 o t else QTuple 8 o t (l*3+m.spoiler)) m.sel
+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 dat q =
- let f qr = case qr of
- QInt 8 op t -> if spoil == 0 then Just (op, (t,0)) else Nothing
- QTuple 8 op t v -> if modBy 3 v == spoil then Just (op, (t,v//3)) else Nothing
+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 = "advsearch_tag" ++ String.fromInt ndat.objid, source = A.tagSource }
+ , conf = { wrap = Search, id = "xsearch_tag" ++ String.fromInt ndat.objid, source = A.tagSource }
, search = A.init ""
, spoiler = spoil
+ , inherit = inherit
+ , exclie = exclie
}
))
@@ -76,11 +89,11 @@ fromQuery spoil dat q =
view : Data -> Model -> (Html Msg, () -> List (Html Msg))
view dat model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text "Tags" ]
+ [] -> small [] [ text "Tags" ]
[(s,_)] -> span [ class "nowrap" ]
[ S.lblPrefix model.sel
- , b [ class "grayedout" ] [ text <| "g" ++ String.fromInt s ++ ":" ]
- , Dict.get s dat.tags |> Maybe.map (\t -> t.name) |> Maybe.withDefault "" |> text
+ , 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) ++ ")" ]
, \() ->
@@ -92,6 +105,11 @@ view dat model =
[ 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" ]
@@ -100,8 +118,8 @@ view dat model =
(0, "any")
:: List.map (\i -> (i, String.fromInt (i//5) ++ "." ++ String.fromInt (2*(modBy 5 i)) ++ "+")) (List.range 1 14)
++ [(15, "3.0")]
- , b [ class "grayedout" ] [ text <| " g" ++ String.fromInt t ++ ": " ]
- , Dict.get t dat.tags |> Maybe.map (\e -> a [ href ("/g" ++ String.fromInt t), target "_blank", style "display" "inline" ] [ text e.name ]) |> Maybe.withDefault (text "")
+ , 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
index 712acdff..db9b5f84 100644
--- a/elm/AdvSearch/Traits.elm
+++ b/elm/AdvSearch/Traits.elm
@@ -18,11 +18,15 @@ type alias Model =
, 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)
@@ -31,9 +35,11 @@ init dat =
let (ndat, sel) = S.init dat
in ( { ndat | objid = ndat.objid + 1 }
, { sel = { sel | single = False, and = True }
- , conf = { wrap = Search, id = "advsearch_trait" ++ String.fromInt ndat.objid, source = A.traitSource }
+ , conf = { wrap = Search, id = "xsearch_trait" ++ String.fromInt ndat.objid, source = A.traitSource }
, search = A.init ""
, spoiler = dat.defaultSpoil
+ , inherit = True
+ , exclie = False
}
)
@@ -42,31 +48,38 @@ 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 }, 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 t.id True) model.sel }
+ , { model | search = A.clear nm "", sel = S.update (S.Sel (vndbidNum t.id) True) model.sel }
, c )
-toQuery m = S.toQuery (\o t -> if m.spoiler == 0 then QInt 13 o t else QTuple 13 o t m.spoiler) m.sel
+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 dat q =
- let f qr = case qr of
- QInt 13 op t -> if spoil == 0 then Just (op, t) else Nothing
- QTuple 13 op t v -> if v == spoil then Just (op, t) else Nothing
+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 = "advsearch_trait" ++ String.fromInt ndat.objid, source = A.traitSource }
+ , conf = { wrap = Search, id = "xsearch_trait" ++ String.fromInt ndat.objid, source = A.traitSource }
, search = A.init ""
, spoiler = spoil
+ , inherit = inherit
+ , exclie = exclie
}
))
@@ -74,11 +87,11 @@ fromQuery spoil dat q =
view : Data -> Model -> (Html Msg, () -> List (Html Msg))
view dat model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text "Traits" ]
+ [] -> small [] [ text "Traits" ]
[s] -> span [ class "nowrap" ]
[ S.lblPrefix model.sel
- , b [ class "grayedout" ] [ text <| "i" ++ String.fromInt s ++ ":" ]
- , Dict.get s dat.traits |> Maybe.map (\t -> t.name) |> Maybe.withDefault "" |> text
+ , 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) ++ ")" ]
, \() ->
@@ -90,14 +103,19 @@ view dat model =
[ 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)) []
- , b [ class "grayedout" ] [ text <| " i" ++ String.fromInt t ++ ": " ]
- , Dict.get t dat.traits |> Maybe.map (\e -> span []
- [ Maybe.withDefault (text "") <| Maybe.map (\g -> b [ class "grayedout" ] [ text (g ++ " / ") ]) e.group_name
- , a [ href ("/i" ++ String.fromInt t), target "_blank", style "display" "inline" ] [ text e.name ] ]) |> Maybe.withDefault (text "")
+ , 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
index c2de581e..e8b8d420 100644
--- a/elm/CharEdit.elm
+++ b/elm/CharEdit.elm
@@ -43,15 +43,17 @@ type Tab
| VNs
| All
+type SelOpt = Spoil Int | Lie
+
type alias Model =
{ state : Api.State
, tab : Tab
, invalidDis : Bool
, editsum : Editsum.Model
, name : String
- , original : String
+ , latin : Maybe String
, alias : String
- , desc : TP.Model
+ , description : TP.Model
, gender : String
, spoilGender : Maybe String
, bMonth : Int
@@ -73,8 +75,7 @@ type alias Model =
, image : Img.Image
, traits : List GCE.RecvTraits
, traitSearch : A.Model GApi.ApiTraitResult
- , traitSelId : Int
- , traitSelSpl : Int
+ , traitSel : (String, SelOpt)
, vns : List GCE.RecvVns
, vnSearch : A.Model GApi.ApiVNResult
, releases : Dict.Dict String (List GCE.RecvReleasesRels) -- vid -> list of releases
@@ -87,11 +88,11 @@ init d =
{ state = Api.Normal
, tab = General
, invalidDis = False
- , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden }
+ , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = False }
, name = d.name
- , original = d.original
+ , latin = d.latin
, alias = d.alias
- , desc = TP.bbcode d.desc
+ , description = TP.bbcode d.description
, gender = d.gender
, spoilGender = d.spoil_gender
, bMonth = d.b_month
@@ -113,8 +114,7 @@ init d =
, image = Img.info d.image_info
, traits = d.traits
, traitSearch = A.init ""
- , traitSelId = 0
- , traitSelSpl = 0
+ , traitSel = ("", Spoil 0)
, vns = d.vns
, vnSearch = A.init ""
, releases = Dict.fromList <| List.map (\v -> (v.id, v.rels)) d.releases
@@ -129,9 +129,9 @@ encode model =
, hidden = model.editsum.hidden
, locked = model.editsum.locked
, name = model.name
- , original = model.original
+ , latin = model.latin
, alias = model.alias
- , desc = model.desc.data
+ , description = model.description.data
, gender = model.gender
, spoil_gender= model.spoilGender
, b_month = model.bMonth
@@ -147,7 +147,7 @@ encode model =
, 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 }) model.traits
+ , 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
}
@@ -168,7 +168,7 @@ type Msg
| Submit
| Submitted GApi.Response
| Name String
- | Original String
+ | Latin String
| Alias String
| Desc TP.Msg
| Gender String
@@ -191,8 +191,9 @@ type Msg
| ImageSelected File
| ImageMsg Img.Msg
| TraitDel Int
- | TraitSel Int Int
+ | TraitSel String SelOpt
| TraitSpoil Int Int
+ | TraitLie Int Bool
| TraitSearch (A.Msg GApi.ApiTraitResult)
| VnRel Int (Maybe String)
| VnRole Int String
@@ -212,9 +213,9 @@ update msg model =
({ model | tab = t, invalidDis = True }, Task.attempt (always InvalidEnable) (Ffi.elemCall "reportValidity" "mainform" |> Task.andThen (\_ -> Process.sleep 100)))
InvalidEnable -> ({ model | invalidDis = False }, Cmd.none)
Name s -> ({ model | name = s }, Cmd.none)
- Original s -> ({ model | original = s }, Cmd.none)
+ 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.desc in ({ model | desc = nm }, Cmd.map Desc nc)
+ 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)
@@ -235,26 +236,31 @@ update msg model =
Nothing -> ({ model | mainSearch = nm }, c)
Just m1 ->
case m1.main of
- Just m2 -> ({ model | mainSearch = A.clear nm "", main = Just m2.id, mainName = m2.name }, c)
- Nothing -> ({ model | mainSearch = A.clear nm "", main = Just m1.id, mainName = m1.name }, c)
+ 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/jpg"] ImageSelected)
+ 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 spl -> ({ model | traitSelId = id, traitSelSpl = spl }, 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 ->
- if not t.applicable || t.state /= 2 || 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 ++ [{ tid = t.id, spoil = t.defaultspoil, name = t.name, group = t.group_name, state = t.state, applicable = t.applicable, new = True }] }, c)
+ 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)
@@ -282,7 +288,7 @@ update msg model =
isValid : Model -> Bool
isValid model = not
- ( (model.name /= "" && model.name == model.original)
+ ( (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)
@@ -300,36 +306,24 @@ view : Model -> Html Msg
view model =
let
geninfo =
- [ formField "name::Name (romaji)" [ inputText "name" model.name Name (onInvalid (Invalid General) :: GCE.valName) ]
- , formField "original::Original name"
- [ inputText "original" model.original Original (onInvalid (Invalid General) :: GCE.valOriginal)
- , if model.name /= "" && model.name == model.original
- then b [ class "standout" ] [ br [] [], text "Should not be the same as the Name (romaji). Leave blank is the original name is already in the latin alphabet" ]
- else text ""
+ [ formField "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.desc Desc 600 (style "height" "150px" :: onInvalid (Invalid General) :: GCE.valDesc)
- [ b [ class "standout" ] [ text "English please!" ] ] ]
+ , 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")
- , ( 1, "January")
- , ( 2, "February")
- , ( 3, "March")
- , ( 4, "April")
- , ( 5, "May")
- , ( 6, "June")
- , ( 7, "July")
- , ( 8, "August")
- , ( 9, "September")
- , (10, "October")
- , (11, "November")
- , (12, "December")
- ]
+ [ 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
]
@@ -369,9 +363,9 @@ view model =
, br_ 2
, Maybe.withDefault (text "No character selected") <| Maybe.map (\m -> span []
[ text "Selected character: "
- , b [ class "grayedout" ] [ text <| m ++ ": " ]
+ , small [] [ text <| m ++ ": " ]
, a [ href <| "/" ++ m ] [ text model.mainName ]
- , if Just m == model.id then b [ class "standout" ] [ br [] [], text "A character can't be an instance of itself. Please select another character or disable the above checkbox to remove the instance." ] else text ""
+ , 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..."]
@@ -390,7 +384,9 @@ view model =
, h2 [] [ text "Upload new image" ]
, inputButton "Browse image" ImageSelect []
, br [] []
- , text "Image must be in JPEG or PNG format and at most 10 MiB. Images larger than 256x300 will automatically be resized."
+ , 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 ->
@@ -406,25 +402,34 @@ view model =
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 = if t.tid == model.traitSelId then model.traitSelSpl else t.spoil
- trait (i,t) = (String.fromInt t.tid,
+ 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 && t.state == 2 then "none" else "line-through") ]
- [ Maybe.withDefault (text "") <| Maybe.map (\g -> b [ class "grayedout" ] [ text <| g ++ " / " ]) t.group
- , a [ href <| "/i" ++ String.fromInt t.tid ] [ text t.name ]
- , if t.state == 0 then b [ class "standout" ] [ text " (awaiting moderation)" ]
- else if t.state == 1 then b [ class "standout" ] [ text " (deleted)" ]
- else if not t.applicable then b [ class "standout" ] [ text " (not applicable)" ]
+ [ 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 0), onMouseOut (TraitSel 0 0), onClickD (TraitSpoil i 0), classList [("s0", spoil t == 0 )], title "Not a spoiler" ] []
- , a [ href "#", onMouseOver (TraitSel t.tid 1), onMouseOut (TraitSel 0 0), onClickD (TraitSpoil i 1), classList [("s1", spoil t == 1 )], title "Minor spoiler" ] []
- , a [ href "#", onMouseOver (TraitSel t.tid 2), onMouseOut (TraitSel 0 0), onClickD (TraitSpoil i 2), classList [("s2", spoil t == 2 )], title "Major spoiler" ] []
+ [ 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 []
- [ case (t.tid == model.traitSelId, lookup model.traitSelSpl spoilOpts) of
- (True, Just s) -> text s
+ , 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" ]
]
])
@@ -446,13 +451,12 @@ view model =
case lst of
(x::xs) -> if Set.member x set then uniq xs set else x :: uniq xs (Set.insert x set)
[] -> []
- showrel r = "[" ++ (RDate.format (RDate.expand r.released)) ++ " " ++ (String.join "," r.lang) ++ "] " ++ r.title ++ " (" ++ r.id ++ ")"
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" ]
- [ b [ class "grayedout" ] [ text <| vid ++ ":" ]
+ [ small [] [ text <| vid ++ ":" ]
, a [ href <| "/" ++ vid ] [ text title ]
]]
)
@@ -461,7 +465,7 @@ view model =
, 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, showrel r)) rels
+ :: 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 []
]
@@ -473,13 +477,13 @@ view model =
) 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 [ class "standout" ] [ text "List contains duplicate releases." ] ] ]
+ , 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 [ class "standout" ] [ text "Note: " ]
+ [ 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\"." ]
@@ -500,8 +504,8 @@ view model =
in
form_ "mainform" Submit (model.state == Api.Loading)
- [ div [ class "maintabs left" ]
- [ ul []
+ [ 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" ] ]
@@ -509,13 +513,12 @@ view model =
, li [ classList [("tabselected", model.tab == All )] ] [ a [ href "#", onClickD (Tab All ) ] [ text "All items" ] ]
]
]
- , div [ class "mainbox", classList [("hidden", model.tab /= General && model.tab /= All)] ] [ h1 [] [ text "General info" ], table [ class "formtable" ] geninfo ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Image && model.tab /= All)] ] [ h1 [] [ text "Image" ], image ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Traits && model.tab /= All)] ] [ h1 [] [ text "Traits" ], traits ]
- , div [ class "mainbox", classList [("hidden", model.tab /= VNs && model.tab /= All)] ] [ h1 [] [ text "Visual Novels" ], vns ]
- , div [ class "mainbox" ] [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
+ , article [ classList [("hidden", model.tab /= General && model.tab /= All)] ] [ h1 [] [ text "General info" ], table [ class "formtable" ] geninfo ]
+ , article [ classList [("hidden", model.tab /= Image && model.tab /= All)] ] [ h1 [] [ text "Image" ], image ]
+ , article [ classList [("hidden", model.tab /= Traits && model.tab /= All)] ] [ h1 [] [ text "Traits" ], traits ]
+ , article [ classList [("hidden", model.tab /= VNs && model.tab /= All)] ] [ h1 [] [ text "Visual Novels" ], vns ]
+ , article [ class "submit" ]
+ [ Html.map Editsum (Editsum.view model.editsum)
+ , submitButton "Submit" model.state (isValid model)
]
]
diff --git a/elm/ColSelect.elm b/elm/ColSelect.elm
deleted file mode 100644
index d78d0995..00000000
--- a/elm/ColSelect.elm
+++ /dev/null
@@ -1,80 +0,0 @@
--- Column selection dropdown for tables. Assumes that the currently selected
--- columns are in the query string as the 'c' parameter, e.g.:
---
--- ?c=column_id&c=modified&...
---
--- Accepts a [ $current_url, [ list of columns ] ] from Perl, e.g.:
---
--- [ '?c=column_id', [
--- [ 'column_id', 'Column Label' ],
--- [ 'modified', 'Date modified' ],
--- ...
--- ] ]
---
--- TODO: Convert all uses of this module to the more flexible TableOpts.
-module ColSelect exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Set
-import Erl -- elm/url can't extract a full list of query parameters and hence can't be used to modify a parameter without removing all others.
-import Lib.DropDown as DD
-import Lib.Api as Api
-import Lib.Html exposing (..)
-
-
-main : Program (String, Columns) Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = \model -> DD.sub model.dd
- }
-
-
-type alias Columns = List (String, String)
-
-type alias Model =
- { cols : Columns
- , url : Erl.Url -- Without the "c" parameter
- , sel : Set.Set String
- , dd : DD.Config Msg
- }
-
-
-init : (String, Columns) -> Model
-init (u, c) =
- { cols = c
- , url = Erl.removeQuery "c" <| Erl.parse u
- , sel = Set.fromList <| Erl.getQueryValuesForKey "c" <| Erl.parse u
- , dd = DD.init "colselect" Open
- }
-
-
-type Msg
- = Open Bool
- | Toggle String Bool
- | Update
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Open b -> ({ model | dd = DD.toggle model.dd b }, Cmd.none)
- Toggle s b -> ({ model | sel = if b then Set.insert s model.sel else Set.remove s model.sel }, Cmd.none)
- Update -> (model, load <| Erl.toString <| List.foldl (\s u -> Erl.addQuery "c" s u) model.url <| Set.toList model.sel)
-
-
-view : Model -> Html Msg
-view model =
- let item (cid, cname) = li [ ] [ linkRadio (Set.member cid model.sel) (Toggle cid) [ text cname ] ]
- in
- DD.view model.dd Api.Normal
- (text "Select columns")
- (\_ -> [ ul []
- <| List.map item model.cols
- ++ [ li [ ] [ input [ type_ "button", class "submit", value "update", onClick Update ] [] ] ]
- ])
diff --git a/elm/Discussions/Edit.elm b/elm/Discussions/Edit.elm
index d478f52b..f4899e95 100644
--- a/elm/Discussions/Edit.elm
+++ b/elm/Discussions/Edit.elm
@@ -24,43 +24,45 @@ main = Browser.element
type alias Model =
- { state : Api.State
- , tid : Maybe 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
- , msg : TP.Model
- , poll : Maybe GDE.SendPoll
- , pollEnabled : Bool
- , pollEdit : Bool
+ { state : Api.State
+ , tid : Maybe String
+ , can_mod : Bool
+ , can_private : Bool
+ , locked : Bool
+ , hidden : Bool
+ , private : Bool
+ , nolastmod : Bool
+ , delete : Bool
+ , title : Maybe String
+ , boards : Maybe (List GDE.SendBoards)
+ , boardAdd : A.Model GApi.ApiBoardResult
+ , boardsLocked : Bool
+ , msg : TP.Model
+ , poll : Maybe GDE.SendPoll
+ , pollEnabled : Bool
+ , pollEdit : Bool
}
init : GDE.Recv -> Model
init d =
- { state = Api.Normal
- , can_mod = d.can_mod
- , can_private = d.can_private
- , tid = d.tid
- , locked = d.locked
- , hidden = d.hidden
- , private = d.private
- , nolastmod = False
- , delete = False
- , title = d.title
- , boards = d.boards
- , boardAdd = A.init ""
- , msg = TP.bbcode d.msg
- , poll = d.poll
- , pollEnabled = isJust d.poll
- , pollEdit = isJust d.poll
+ { state = Api.Normal
+ , can_mod = d.can_mod
+ , can_private = d.can_private
+ , tid = d.tid
+ , locked = d.locked
+ , hidden = d.hidden
+ , private = d.private
+ , nolastmod = False
+ , delete = False
+ , title = d.title
+ , boards = d.boards
+ , boardAdd = A.init ""
+ , boardsLocked = d.boards_locked
+ , msg = TP.bbcode d.msg
+ , poll = d.poll
+ , pollEnabled = isJust d.poll
+ , pollEdit = isJust d.poll
}
@@ -70,16 +72,17 @@ searchConfig = { wrap = BoardSearch, id = "boardadd", source = A.boardSource }
encode : Model -> GDE.Send
encode m =
- { tid = m.tid
- , locked = m.locked
- , hidden = m.hidden
- , private = m.private
- , nolastmod = m.nolastmod
- , delete = m.delete
- , boards = m.boards
- , poll = if m.pollEnabled then m.poll else Nothing
- , title = m.title
- , msg = m.msg.data
+ { tid = m.tid
+ , locked = m.locked
+ , hidden = m.hidden
+ , private = m.private
+ , nolastmod = m.nolastmod
+ , delete = m.delete
+ , boards = m.boards
+ , boards_locked = m.boardsLocked
+ , poll = if m.pollEnabled then m.poll else Nothing
+ , title = m.title
+ , msg = m.msg.data
}
@@ -101,6 +104,7 @@ type Msg
| Delete Bool
| Content TP.Msg
| Title String
+ | BoardsLocked Bool
| BoardDel Int
| BoardSearch (A.Msg GApi.ApiBoardResult)
| PollEnabled Bool
@@ -130,6 +134,7 @@ update msg model =
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
@@ -147,28 +152,34 @@ view model =
let
board n bd =
li [] <|
- [ text "["
- , a [ href "#", onClickD (BoardDel n), tabindex 10 ] [ text "remove" ]
- , text "] "
+ [ if model.boardsLocked then text "" else span []
+ [ text "["
+ , a [ href "#", onClickD (BoardDel n), tabindex 10 ] [ text "remove" ]
+ , text "] "
+ ]
, text (Maybe.withDefault "" (lookup bd.btype boardTypes))
] ++ case (bd.btype, bd.iid, bd.title) of
(_, Just iid, Just title) ->
- [ b [ class "grayedout" ] [ text " > " ]
+ [ small [] [ text " > " ]
, a [ href <| "/" ++ iid ] [ text title ]
]
- ("u", Just iid, _) -> [ b [ class "grayedout" ] [ text " > " ], text <| iid ++ " (deleted)" ]
+ ("u", Just iid, _) -> [ small [] [ text " > " ], text <| iid ++ " (deleted)" ]
_ -> []
boards () =
- [ text "You can link this thread to multiple boards. Every visual novel, producer and user in the database has its own board,"
+ [ if not model.can_mod then text ""
+ else label [] [ inputCheck "" model.boardsLocked BoardsLocked, text " Lock boards.", br [] [] ]
+ , text "You can link this thread to multiple boards. Every visual novel, producer and user in the database has its own board,"
, text " but you can also use the \"General Discussions\" and \"VNDB Discussions\" boards for threads that do not fit at a particular database entry."
, ul [ style "list-style-type" "none", style "margin" "10px" ] <| List.indexedMap board (Maybe.withDefault [] model.boards)
- , A.view searchConfig model.boardAdd [placeholder "Add boards..."]
+ , if model.boardsLocked
+ then text "Boards are locked, only a moderator can move this thread."
+ else A.view searchConfig model.boardAdd [placeholder "Add boards..."]
] ++
if model.boards == Just []
- then [ b [ class "standout" ] [ text "Please add at least one board." ] ]
+ then [ b [] [ text "Please add at least one board." ] ]
else if dupBoards model
- then [ b [ class "standout" ] [ text "List contains duplicates." ] ]
+ then [ b [] [ text "List contains duplicates." ] ]
else []
pollOpt n p =
@@ -186,7 +197,7 @@ view model =
case (model.pollEnabled, model.poll) of
(True, Just p) ->
[ if model.pollEdit
- then formField "" [ b [ class "standout" ] [ text "Votes will be reset if any changes are made to these options!" ] ]
+ then formField "" [ b [] [ text "Votes will be reset if any changes are made to these options!" ] ]
else text ""
, formField "pollq::Poll question" [ inputText "pollq" p.question PollQ (style "width" "400px" :: GDE.valPollQuestion) ]
, formField "Options"
@@ -205,7 +216,7 @@ view model =
in
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ 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) ]
@@ -225,8 +236,8 @@ view model =
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "msg::Message"
[ TP.view "msg" model.msg Content 700 ([rows 12, cols 50] ++ GDE.valMsg)
- [ b [ class "standout" ] [ text " (English please!) " ]
- , a [ href "/d9#3" ] [ text "Formatting" ]
+ [ b [] [ text " (English please!) " ]
+ , a [ href "/d9#4" ] [ text "Formatting" ]
]
]
]
@@ -236,6 +247,5 @@ view model =
, formField "" [ inputCheck "" model.delete Delete, text " Permanently delete this thread and all replies. This action can not be reverted, only do this with obvious spam!" ]
])
]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state (isValid model) ] ]
+ , article [ class "submit" ] [ submitButton "Submit" model.state (isValid model) ]
]
diff --git a/elm/Discussions/Poll.elm b/elm/Discussions/Poll.elm
index ec30fb06..6764bfbd 100644
--- a/elm/Discussions/Poll.elm
+++ b/elm/Discussions/Poll.elm
@@ -110,7 +110,7 @@ view model =
]
in
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ article []
[ h1 [] [ text model.data.question ]
, table [ class "votebooth" ]
[ if model.data.can_vote && model.data.max_options > 1
@@ -120,9 +120,9 @@ view model =
[ td [ class "tc1" ]
[ if model.data.can_vote
then submitButton "Vote" model.state True
- else b [ class "standout" ] [ text "You must be logged in to be able to vote." ]
+ else b [] [ text "You must be logged in to be able to vote." ]
, if toomany model
- then b [ class "standout" ] [ text "Too many options selected." ]
+ then b [] [ text "Too many options selected." ]
else text ""
]
, td [ class "tc2" ]
diff --git a/elm/Discussions/PostEdit.elm b/elm/Discussions/PostEdit.elm
index a46638a4..00b833ba 100644
--- a/elm/Discussions/PostEdit.elm
+++ b/elm/Discussions/PostEdit.elm
@@ -25,7 +25,7 @@ type alias Model =
, id : String
, num : Int
, can_mod : Bool
- , hidden : Bool
+ , hidden : Maybe String
, nolastmod : Bool
, delete : Bool
, msg : TP.Model
@@ -56,7 +56,7 @@ encode m =
type Msg
- = Hidden Bool
+ = Hidden (Maybe String)
| Nolastmod Bool
| Delete Bool
| Content TP.Msg
@@ -67,9 +67,9 @@ type Msg
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
- Hidden b -> ({ model | hidden = b }, Cmd.none)
- Nolastmod b -> ({ model | nolastmod=b }, Cmd.none)
- Delete b -> ({ model | delete = b }, Cmd.none)
+ 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)
@@ -80,12 +80,17 @@ update msg model =
view : Model -> Html Msg
view model =
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ 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 Hidden, text " Hidden" ] ]
+ 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" ] ]
@@ -93,8 +98,8 @@ view model =
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "msg::Message"
[ TP.view "msg" model.msg Content 700 ([rows 12, cols 50] ++ GPE.valMsg)
- [ b [ class "standout" ] [ text " (English please!) " ]
- , a [ href "/d9#3" ] [ text "Formatting" ]
+ [ b [] [ text " (English please!) " ]
+ , a [ href "/d9#4" ] [ text "Formatting" ]
]
]
]
@@ -103,6 +108,5 @@ view model =
, formField "" [ inputCheck "" model.delete Delete, text " Permanently delete this post. This action can not be reverted, only do this with obvious spam!" ]
])
]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ] ]
+ , article [ class "submit" ] [ submitButton "Submit" model.state True ]
]
diff --git a/elm/Discussions/Reply.elm b/elm/Discussions/Reply.elm
deleted file mode 100644
index 1769b06c..00000000
--- a/elm/Discussions/Reply.elm
+++ /dev/null
@@ -1,82 +0,0 @@
-module Discussions.Reply exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load,reload)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.DiscussionsReply as GDR
-
-
-main : Program GDR.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-
-type alias Model =
- { state : Api.State
- , tid : String
- , old : Bool
- , msg : TP.Model
- }
-
-
-init : GDR.Recv -> Model
-init e =
- { state = Api.Normal
- , tid = e.tid
- , old = e.old
- , msg = TP.bbcode ""
- }
-
-
-type Msg
- = NotOldAnymore
- | Content TP.Msg
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- NotOldAnymore -> ({ model | old = False }, Cmd.none)
- Content m -> let (nm,nc) = TP.update m model.msg in ({ model | msg = nm }, Cmd.map Content nc)
-
- Submit -> ({ model | state = Api.Loading }, GDR.send { msg = model.msg.data, tid = model.tid } Submitted)
- -- Reload is necessary because s may be the same as the current URL (with a location.hash)
- Submitted (GApi.Redirect s) -> (model, Cmd.batch [ load s, reload ])
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ] <| [
- if model.old
- then
- p [ class "center" ]
- [ text "This thread has not seen any activity for more than 6 months, but you may still "
- , a [ href "#", onClickD NotOldAnymore ] [ text "reply" ]
- , text " if you have something relevant to add."
- , text " If your message is not directly relevant to this thread, perhaps it's better to "
- , a [ href "/t/ge/new" ] [ text "create a new thread" ]
- , text " instead."
- ]
- else
- fieldset [ class "submit" ]
- [ TP.view "msg" model.msg Content 600 ([rows 4, cols 50] ++ GDR.valMsg)
- [ b [] [ text "Quick reply" ]
- , b [ class "standout" ] [ text " (English please!) " ]
- , a [ href "/d9#3" ] [ text "Formatting" ]
- ]
- , submitButton "Submit" model.state True
- ]
- ] ]
diff --git a/elm/DocEdit.elm b/elm/DocEdit.elm
deleted file mode 100644
index b5b52c2d..00000000
--- a/elm/DocEdit.elm
+++ /dev/null
@@ -1,102 +0,0 @@
-module DocEdit exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Lib.Ffi as Ffi
-import Lib.Editsum as Editsum
-import Gen.Api as GApi
-import Gen.DocEdit as GD
-
-
-main : Program GD.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-
-type alias Model =
- { state : Api.State
- , editsum : Editsum.Model
- , title : String
- , content : TP.Model
- , id : String
- }
-
-
-init : GD.Recv -> Model
-init d =
- { state = Api.Normal
- , editsum = { authmod = True, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden }
- , title = d.title
- , content = TP.markdown d.content
- , id = d.id
- }
-
-
-encode : Model -> GD.Send
-encode model =
- { id = model.id
- , editsum = model.editsum.editsum.data
- , hidden = model.editsum.hidden
- , locked = model.editsum.locked
- , title = model.title
- , content = model.content.data
- }
-
-
-type Msg
- = Editsum Editsum.Msg
- | Submit
- | Submitted GApi.Response
- | Title String
- | Content TP.Msg
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
- Title s -> ({ model | title = s }, Cmd.none)
- Content m -> let (nm,nc) = TP.update m model.content in ({ model | content = nm }, Cmd.map Content nc)
-
- Submit -> ({ model | state = Api.Loading }, GD.send (encode model) Submitted)
- Submitted (GApi.Redirect s) -> (model, load s)
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text <| "Edit " ++ model.id ]
- , table [ class "formtable" ]
- [ formField "title::Title" [ inputText "title" model.title Title (style "width" "300px" :: GD.valTitle) ]
- , formField "none"
- [ br_ 1
- , b [] [ text "Contents" ]
- , TP.view "content" model.content Content 850 ([rows 50, cols 90] ++ GD.valContent)
- [ text "HTML and MultiMarkdown supported, which is "
- , a [ href "https://daringfireball.net/projects/markdown/basics", target "_blank" ] [ text "Markdown" ]
- , text " with some "
- , a [ href "http://fletcher.github.io/MultiMarkdown-5/syntax.html", target "_blank" ][ text "extensions" ]
- , text "."
- ]
- ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state True
- ]
- ]
- ]
diff --git a/elm/ImageFlagging.elm b/elm/ImageFlagging.elm
index b2f73352..7f829f95 100644
--- a/elm/ImageFlagging.elm
+++ b/elm/ImageFlagging.elm
@@ -149,7 +149,7 @@ update msg model =
-- 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) ])
+ Just i -> (m, Cmd.batch [ c, preload (imageUrl "" i.id) ])
Nothing -> (m, c)
in
case msg of
@@ -223,8 +223,8 @@ view model =
else
[ p [ class "center" ]
[ text num
- , b [ class "grayedout" ] [ text " / " ], text <| "sexual: " ++ stat i.sexual_avg i.sexual_stddev
- , b [ class "grayedout" ] [ text " / " ], text <| "violence: " ++ stat i.violence_avg i.violence_stddev
+ , 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 ->
@@ -244,76 +244,76 @@ view model =
case i.entry of
Nothing -> []
Just e ->
- [ b [ class "grayedout" ] [ text (e.id ++ ":") ]
+ [ small [] [ text (e.id ++ ":") ]
, a [ href ("/" ++ e.id) ] [ text e.title ]
]
, inputButton "»»" Next [ classList [("invisible", model.single)] ]
]
- , div [ style "width" (px boxwidth), style "height" (px boxheight) ] <|
+ , 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++")")
+ [ 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 [ class "standout" ] [ text <| "Save failed: " ++ Api.showResponse e ] ]
+ Api.Error e -> [ b [] [ text <| "Save failed: " ++ Api.showResponse e ] ]
_ ->
[ span [ class "spinner", classList [("invisible", model.saveState == Api.Normal)] ] []
- , b [ class "grayedout" ] [ text <|
+ , 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 ]
- , b [ class "grayedout" ] [ text " / " ]
- , a [ href (imageUrl i.id) ] [ text <| String.fromInt i.width ++ "x" ++ String.fromInt i.height ]
+ , 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 -> [ b [] [ text "Safe" ], br [] []
+ 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 -> [ b [] [ text "Suggestive" ], br [] []
+ 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 -> [ b [] [ text "Explicit" ], br [] []
+ 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 [] [ b [] [ text "Sexual" ] ]
+ [ 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 [] [ b [] [ text "Violence" ] ]
+ [ 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 -> [ b [] [ text "Tame" ], br [] []
+ 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 -> [ b [] [ text "Violent" ], br [] []
+ Just 1 -> [ strong [] [ text "Violent" ], br [] []
, text "- Visible blood", br [] []
, text "- Non-comedic fight scenes", br [] []
, text "- Physically harmful activities" ]
- Just 2 -> [ b [] [ text "Brutal" ], br [] []
+ Just 2 -> [ strong [] [ text "Brutal" ], br [] []
, text "- Excessive amounts of blood", br [] []
, text "- Cut off limbs", br [] []
, text "- Sliced-open bodies", br [] []
@@ -327,17 +327,17 @@ view model =
]
, votestats i
, if model.fullscreen -- really lazy fullscreen mode
- then div [ class "fullscreen", style "background-image" ("url("++imageUrl i.id++")"), onClick (Fullscreen False) ] [ text "" ]
+ then div [ class "fullscreen", style "background-image" ("url("++imageUrl "" i.id++")"), onClick (Fullscreen False) ] [ text "" ]
else text ""
]
- in div [ class "mainbox" ]
+ 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 [ class "standout" ] [ text "WARNING: " ], text "Images shown may include spoilers, be highly offensive and/or contain very explicit depictions of sexual acts." ]
+ , 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
@@ -348,6 +348,6 @@ view model =
else case (Array.get model.index model.images, model.loadState) of
(Just i, _) -> imgView i
(_, Api.Loading) -> [ span [ class "spinner" ] [] ]
- (_, Api.Error e) -> [ b [ class "standout" ] [ text <| Api.showResponse e ] ]
+ (_, Api.Error e) -> [ b [] [ text <| Api.showResponse e ] ]
(_, Api.Normal) -> [ text "No more images to vote on!" ]
]
diff --git a/elm/ImageFlagging.js b/elm/ImageFlagging.js
deleted file mode 100644
index d460bd10..00000000
--- a/elm/ImageFlagging.js
+++ /dev/null
@@ -1,16 +0,0 @@
-wrap_elm_init('ImageFlagging', function(init, opt) {
- opt.flags.pWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
- opt.flags.pHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
- var app = init(opt);
- var preload = {};
- var curid = '';
-
- app.ports.preload.subscribe(function(url) {
- if(Object.keys(preload).length > 100)
- preload = {};
- if(!preload[url]) {
- preload[url] = new Image();
- preload[url].src = url;
- }
- });
-});
diff --git a/elm/Lib/Api.elm b/elm/Lib/Api.elm
index 514ac6d2..5b1bf583 100644
--- a/elm/Lib/Api.elm
+++ b/elm/Lib/Api.elm
@@ -23,32 +23,26 @@ showResponse res =
in case res of
HTTPError (Http.Timeout) -> "Network timeout, please try again later."
HTTPError (Http.NetworkError) -> "Network error, please try again later."
+ HTTPError (Http.BadStatus 403) -> "Permission denied. Your session may have expired, try reloading the page."
+ HTTPError (Http.BadStatus 413) -> "File upload too large."
+ HTTPError (Http.BadStatus 429) -> "Action throttled, please try again later."
HTTPError (Http.BadStatus r) -> "Server error " ++ String.fromInt r ++ ", please try again later or report an issue if this persists."
HTTPError (Http.BadBody r) -> "Invalid response from the server, please report a bug (debug info: " ++ r ++")."
HTTPError (Http.BadUrl _) -> unexp
Success -> unexp
Redirect _ -> unexp
- CSRF -> "Invalid CSRF token, please refresh the page and try again."
Invalid -> "Invalid form data, please report a bug."
Editsum -> "Invalid edit summary."
Unauth -> "You do not have the permission to perform this action."
Unchanged -> "No changes"
Content _ -> unexp
- BadLogin -> "Invalid username or password."
- LoginThrottle -> "Action throttled, too many failed login attempts."
- InsecurePass -> "Your chosen password is in a database of leaked passwords, please choose another one."
- BadEmail -> "Unknown email address."
- Bot -> "Invalid answer to the anti-bot question."
- Taken -> "Username already taken, please choose a different name."
- DoubleEmail -> "Email address already used for another account."
- DoubleIP -> "You can only register one account from the same IP within 24 hours."
- BadCurPass -> "Current password is invalid."
- MailChange -> unexp
- ImgFormat -> "Unrecognized image format, only JPEG and PNG are accepted."
+ 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
@@ -58,6 +52,7 @@ showResponse res =
CharResult _ -> unexp
AnimeResult _ -> unexp
ImageResult _ -> unexp
+ UListWidget _ -> unexp
AdvSearchQuery _ -> unexp
diff --git a/elm/Lib/Autocomplete.elm b/elm/Lib/Autocomplete.elm
index 61f13caf..4c465d7c 100644
--- a/elm/Lib/Autocomplete.elm
+++ b/elm/Lib/Autocomplete.elm
@@ -14,6 +14,7 @@ module Lib.Autocomplete exposing
, animeSource
, resolutionSource
, engineSource
+ , drmSource
, init
, clear
, refocus
@@ -45,6 +46,7 @@ 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 =
@@ -87,20 +89,20 @@ boardSource =
, view = (\i ->
[ text <| Maybe.withDefault "" (lookup i.btype boardTypes)
] ++ case i.title of
- Just title -> [ b [ class "grayedout" ] [ text " > " ], text title ]
+ Just title -> [ small [] [ text " > " ], text title ]
_ -> []
)
, key = \i -> Maybe.withDefault i.btype i.iid
}
-tagtraitStatus i =
- case (i.searchable, i.applicable, i.state) of
- (_, _, 0) -> b [ class "grayedout" ] [ text " (awaiting approval)" ]
- (_, _, 1) -> b [ class "grayedout" ] [ text " (deleted)" ] -- (not returned by the API for now)
- (False, False, _) -> b [ class "grayedout" ] [ text " (meta)" ]
- (True, False, _) -> b [ class "grayedout" ] [ text " (not applicable)" ]
- (False, True, _) -> b [ class "grayedout" ] [ text " (not searchable)" ]
+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 ""
@@ -110,8 +112,8 @@ tagSource =
<| \x -> case x of
GApi.TagResult e -> Just e
_ -> Nothing
- , view = \i -> [ text i.name, tagtraitStatus i ]
- , key = \i -> String.fromInt i.id
+ , view = \i -> [ text i.name, ttStatus i ]
+ , key = \i -> i.id
}
@@ -124,11 +126,11 @@ traitSource =
, view = \i ->
[ case i.group_name of
Nothing -> text ""
- Just g -> b [ class "grayedout" ] [ text <| g ++ " / " ]
+ Just g -> small [] [ text <| g ++ " / " ]
, text i.name
- , tagtraitStatus i
+ , ttStatus i
]
- , key = \i -> String.fromInt i.id
+ , key = \i -> i.id
}
@@ -139,7 +141,7 @@ vnSource =
GApi.VNResult e -> Just e
_ -> Nothing
, view = \i ->
- [ b [ class "grayedout" ] [ text <| i.id ++ ": " ]
+ [ small [] [ text <| i.id ++ ": " ]
, text i.title ]
, key = \i -> i.id
}
@@ -147,12 +149,12 @@ vnSource =
producerSource : SourceConfig m GApi.ApiProducerResult
producerSource =
- { source = Endpoint (\s -> GP.send { search = [s], hidden = False })
+ { source = Endpoint (\s -> GP.send { search = [s] })
<| \x -> case x of
GApi.ProducerResult e -> Just e
_ -> Nothing
, view = \i ->
- [ b [ class "grayedout" ] [ text <| i.id ++ ": " ]
+ [ small [] [ text <| i.id ++ ": " ]
, text i.name ]
, key = \i -> i.id
}
@@ -160,13 +162,16 @@ producerSource =
staffSource : SourceConfig m GApi.ApiStaffResult
staffSource =
- { source = Endpoint (\s -> GS.send { search = s })
+ { source = Endpoint (\s -> GS.send { search = [s] })
<| \x -> case x of
GApi.StaffResult e -> Just e
_ -> Nothing
, view = \i ->
- [ b [ class "grayedout" ] [ text <| i.id ++ ": " ]
- , text i.name ]
+ [ 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
}
@@ -178,10 +183,10 @@ charSource =
GApi.CharResult e -> Just e
_ -> Nothing
, view = \i ->
- [ b [ class "grayedout" ] [ text <| i.id ++ ": " ]
- , text i.name
+ [ small [] [ text <| i.id ++ ": " ]
+ , text i.title
, Maybe.withDefault (text "") <| Maybe.map (\m ->
- b [ class "grayedout" ] [ text <| " (instance of " ++ m.id ++ ": " ++ m.name ]
+ small [] [ text <| " (instance of " ++ m.id ++ ": " ++ m.title ]
) i.main
]
, key = \i -> i.id
@@ -195,7 +200,7 @@ animeSource ref =
GApi.AnimeResult e -> Just e
_ -> Nothing
, view = \i ->
- [ b [ class "grayedout" ] [ text <| "a" ++ String.fromInt i.id ++ ": " ]
+ [ small [] [ text <| "a" ++ String.fromInt i.id ++ ": " ]
, text i.title ]
, key = \i -> String.fromInt i.id
}
@@ -209,7 +214,7 @@ resolutionSource =
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, b [ class "grayedout" ] [ text <| " (" ++ String.fromInt i.count ++ ")" ] ]
+ , view = \i -> [ text i.resolution, small [] [ text <| " (" ++ String.fromInt i.count ++ ")" ] ]
, key = \i -> i.resolution
}
@@ -222,11 +227,24 @@ engineSource =
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, b [ class "grayedout" ] [ text <| " (" ++ String.fromInt i.count ++ ")" ] ]
+ , 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
diff --git a/elm/Lib/DropDown.elm b/elm/Lib/DropDown.elm
index 3de02f11..050dcfac 100644
--- a/elm/Lib/DropDown.elm
+++ b/elm/Lib/DropDown.elm
@@ -60,9 +60,9 @@ view conf status lbl cont =
] ++ if conf.hover then [ onMouseEnter (conf.toggle True) ] else []
) <|
case status of
- Api.Normal -> [ lbl, span [] [ i [] [ text "▾" ] ] ]
+ Api.Normal -> [ lbl, span [] [ span [ class "arrow" ] [ text "▾" ] ] ]
Api.Loading -> [ lbl, span [] [ span [ class "spinner" ] [] ] ]
- Api.Error e -> [ b [ class "standout" ] [ text "error" ], span [] [ i [] [ text "▾" ] ] ]
+ Api.Error e -> [ b [] [ text "error" ], span [] [ span [ class "arrow" ] [ text "▾" ] ] ]
, div [ classList [("hidden", not conf.opened)] ]
[ if conf.opened then div [] (cont ()) else text "" ]
]
diff --git a/elm/Lib/Editsum.elm b/elm/Lib/Editsum.elm
index 20a51872..7320d66a 100644
--- a/elm/Lib/Editsum.elm
+++ b/elm/Lib/Editsum.elm
@@ -1,5 +1,5 @@
--- This module provides an the 'Edit summary' box, including the 'hidden' and
--- 'locked' moderation checkboxes.
+-- This module provides an the 'Edit summary' box, including the entry state
+-- option for moderators.
module Lib.Editsum exposing (Model, Msg, new, update, view)
@@ -11,6 +11,7 @@ import Lib.TextPreview as TP
type alias Model =
{ authmod : Bool
+ , hasawait : Bool
, locked : Bool
, hidden : Bool
, editsum : TP.Model
@@ -18,25 +19,24 @@ type alias Model =
type Msg
- = Locked Bool
- | Hidden Bool
+ = State Bool Bool Bool
| Editsum TP.Msg
new : Model
new =
- { authmod = False
- , locked = False
- , hidden = False
- , editsum = TP.bbcode ""
+ { authmod = False
+ , hasawait = False
+ , locked = False
+ , hidden = False
+ , editsum = TP.bbcode ""
}
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
- Locked b -> ({ model | locked = b }, Cmd.none)
- Hidden b -> ({ model | hidden = b }, Cmd.none)
+ State hid lock _ -> ({ model | hidden = hid, locked = lock }, Cmd.none)
Editsum m -> let (nm,nc) = TP.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
@@ -44,14 +44,13 @@ view : Model -> Html Msg
view model =
let
lockhid =
- [ label []
- [ inputCheck "" model.hidden Hidden
- , text " Deleted" ]
- , label []
- [ inputCheck "" model.locked Locked
- , text " Locked" ]
+ [ 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
+ , 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 ""
]
@@ -59,7 +58,7 @@ view model =
(if model.authmod then lockhid else [])
++
[ TP.view "" model.editsum Editsum 600 [rows 4, cols 50, minlength 2, maxlength 5000, required True]
- [ b [class "title"] [ text "Edit summary", b [class "standout"] [ text " (English please!)" ] ]
+ [ strong [] [ text "Edit summary", b [] [ text " (English please!)" ] ]
, br [] []
, text "Summarize the changes you have made, including links to source(s)."
]
diff --git a/elm/Lib/ExtLinks.elm b/elm/Lib/ExtLinks.elm
deleted file mode 100644
index b37dbb6e..00000000
--- a/elm/Lib/ExtLinks.elm
+++ /dev/null
@@ -1,130 +0,0 @@
-module Lib.ExtLinks exposing (..)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Regex
-import Lib.Html exposing (..)
-import Gen.ReleaseEdit as GRE
-import Gen.ExtLinks as GEL
-
-
--- Takes a printf-style string with a single %s or %d formatting code and a parameter to format.
--- Supports 0-padding with '%0<num>d' formatting codes, where <num> <= 99.
--- Returns (prefix, formatted_param, suffix)
--- (This is super ugly and probably better written with elm/parser, but it gets the job done)
-splitPrintf : String -> String -> (String, String, String)
-splitPrintf s p =
- case String.split "%" s of
- [ pre, suf ] ->
- case String.uncons suf of
- Just ('s', suf1) -> (pre, p, suf1)
- Just ('d', suf1) -> (pre, p, suf1)
- Just ('0', suf1) ->
- case String.uncons suf1 of
- Just (c2, suf2) ->
- case String.uncons suf2 of
- Just ('d', suf3) -> (pre, String.padLeft (Char.toCode c2 - 48) '0' p, suf3)
- Just (c3, suf3) ->
- case String.uncons suf3 of
- Just ('d', suf4) -> (pre, String.padLeft (10*(Char.toCode c2 - 48) + Char.toCode c3 - 48) '0' p, suf4)
- _ -> (pre, "%", suf)
- _ -> (pre, "%", suf)
- _ -> (pre, "%", suf)
- _ -> (pre, "%", suf)
- _ -> (s, "", "")
-
-
-type Rec a
- = Unrecognized
- | Duplicate
- | Add (GEL.Site a, String) -- Site, value
-
-
-type alias Model a =
- { links : a
- , sites : List (GEL.Site a)
- , input : String
- , rec : Rec a
- , lst : Bool
- }
-
-
-type Msg a
- = Del (Int -> a -> a) Int
- | Input String
- | Enter
- | Expand
-
-
-new : a -> List (GEL.Site a) -> Model a
-new l s =
- { links = l
- , sites = s
- , input = ""
- , rec = Unrecognized
- , lst = False
- }
-
-
-update : Msg a -> Model a -> Model a
-update msg model =
- let
- match s m = (s, List.map (Maybe.withDefault "") m.submatches |> List.filter (\a -> a /= "") |> List.head |> Maybe.withDefault "")
- fmtval s v = let (_, val, _) = splitPrintf s.fmt v in val
- dup s val = List.filter (\l -> fmtval s l == fmtval s val) (s.links model.links) |> List.isEmpty |> not
- find i =
- case List.concatMap (\s -> List.map (match s) (Regex.find s.regex i)) model.sites |> List.head of
- Nothing -> Unrecognized
- Just (s, val) -> if dup s val then Duplicate else Add (s, val)
- add s val = { model | input = "", rec = Unrecognized, links = s.add val model.links }
-
- in case msg of
- Del f i -> { model | links = f i model.links }
- Input i ->
- case find (String.trim i) of
- Add (s, val) ->
- if s.multi || List.isEmpty (s.links model.links)
- then add s val
- else { model | input = i, rec = Add (s, val) }
- x -> { model | input = i, rec = x }
- Enter ->
- case model.rec of
- Add (s, val) -> add s val
- _ -> model
- Expand -> { model | lst = not model.lst }
-
-
-view : Model a -> Html (Msg a)
-view model =
- let msg st s = span [] [ br [] [], b [ class "grayedout" ] [ text ">>> " ], if st then b [ class "standout" ] [ text s ] else text s ]
- in
- Html.form [ onSubmit Enter ]
- [ table [] <| List.concatMap (\s ->
- List.indexedMap (\i l ->
- let (pre, val, suf) = splitPrintf s.fmt l
- in tr []
- [ td [] [ a [ href <| pre ++ val ++ suf, target "_blank" ] [ text s.name ] ]
- , td [] [ b [ class "grayedout" ] [ text pre ], text val, b [ class "grayedout" ] [ text suf ] ]
- , td [] [ inputButton "remove" (Del s.del i) [] ]
- ]
- ) (s.links model.links)
- ) model.sites
- , inputText "" model.input Input [style "width" "500px", placeholder "Add URL..."]
- , case (model.input, model.rec) of
- ("", _) -> text ""
- (_, Unrecognized) -> msg True "Invalid or unrecognized URL."
- (_, Duplicate) -> msg True "URL is already listed."
- (_, Add (s, _)) -> span [] [ inputButton "Edit" Enter [], msg False <| "URL recognized as: " ++ s.name ]
- , div [ style "margin-top" "5px" ]
- [ span [ onClickD Expand, style "cursor" "pointer" ] [ text <| if model.lst then "▾ " else "▸ ", text "Recognized sites: " ]
- , if model.lst
- then table [] <| List.map (\s ->
- tr []
- [ td [] [ text s.name ]
- , td [] <| List.indexedMap (\i l -> if modBy 2 i == 0 then b [ class "grayedout" ] [ text l ] else text l) s.patt
- ]
- ) model.sites
- else text <| String.join ", " (List.map (\s -> s.name) model.sites) ++ "."
- ]
- ]
diff --git a/elm/Lib/Ffi.elm b/elm/Lib/Ffi.elm
index b5601a9b..af8c963a 100644
--- a/elm/Lib/Ffi.elm
+++ b/elm/Lib/Ffi.elm
@@ -5,7 +5,7 @@
-- This module is a hack to work around the lack of an FFI (Foreign Function
-- Interface) in Elm. The functions in this module are stubs, their
-- implementations are replaced by the Makefile with calls to
--- window.elmFfi_<name> and the actual implementations are in Ffi.js.
+-- window.elmFfi_<name> and the actual implementations are in elm-support.js.
--
-- Use sparingly, all of this will likely break in future Elm versions.
module Lib.Ffi exposing (..)
diff --git a/elm/Lib/Ffi.js b/elm/Lib/Ffi.js
deleted file mode 100644
index 78d6083a..00000000
--- a/elm/Lib/Ffi.js
+++ /dev/null
@@ -1,26 +0,0 @@
-window.elmFfi_innerHtml = function(wrap,call) { // \s -> _VirtualDom_property('innerHTML', _Json_wrap(s))
- return function(s) {
- return {
- $: 'a2',
- n: 'innerHTML',
- o: wrap(s)
- }
- }
-};
-
-window.elmFfi_elemCall = function(wrap,call) { // _Browser_call
- return call
-};
-
-window.elmFfi_fmtFloat = function(wrap,call) {
- return function(val) {
- return function(prec) {
- return val.toLocaleString('en-US', { minimumFractionDigits: prec, maximumFractionDigits: prec });
- }
- }
-};
-
-var urlStatic = document.querySelector('link[rel=stylesheet]').href.replace(/^(https?:\/\/[^/]+)\/.*$/, '$1');
-window.elmFfi_urlStatic = function(wrap,call) {
- return urlStatic
-};
diff --git a/elm/Lib/Html.elm b/elm/Lib/Html.elm
index e63bd3d9..7ec8dacc 100644
--- a/elm/Lib/Html.elm
+++ b/elm/Lib/Html.elm
@@ -7,6 +7,7 @@ import Json.Decode as JD
import List
import Lib.Api as Api
import Lib.Util exposing (..)
+import Lib.Ffi as Ffi
import Gen.Types as T
@@ -48,13 +49,13 @@ inputButton val onch attrs =
-- Submit button with loading indicator and error message display
submitButton : String -> Api.State -> Bool -> Html m
-submitButton val state valid = div []
+submitButton val state valid = span []
[ input [ type_ "submit", class "submit", tabindex 10, value val, disabled (state == Api.Loading || not valid) ] []
, case state of
- Api.Error r -> p [] [ b [class "standout" ] [ text <| Api.showResponse r ] ]
+ Api.Error r -> p [] [ b [] [ text <| Api.showResponse r ] ]
_ -> if valid
then text ""
- else p [] [ b [class "standout" ] [ text "The form contains errors, please fix these before submitting. " ] ]
+ else p [] [ b [] [ text "The form contains errors, please fix these before submitting. " ] ]
, if state == Api.Loading
then div [ class "spinner" ] []
else text ""
@@ -190,7 +191,7 @@ formField lbl cont =
else
let
(nlbl, eng) = if String.endsWith "#eng" lbl then (String.dropRight 4 lbl, True) else (lbl, False)
- genlbl str = text str :: if eng then [ br [] [], b [ class "standout" ] [ text "English please!" ] ] else []
+ genlbl str = text str :: if eng then [ br [] [], b [] [ text "English please!" ] ] else []
in
td [ class "label" ] <|
case String.split "::" nlbl of
@@ -202,10 +203,19 @@ formField lbl cont =
langIcon : String -> Html m
-langIcon l = abbr [ class "icons lang", class l, title (Maybe.withDefault "" <| lookup l T.languages) ] [ text " " ]
+langIcon l = abbr [ class ("icon-lang-"++l), title (Maybe.withDefault "" <| lookup l T.languages) ] [ text " " ]
platformIcon : String -> Html m
-platformIcon l = abbr [ class "icons plat", class l, title (Maybe.withDefault "" <| lookup l T.platforms) ] [ text " " ]
+platformIcon l = abbr [ class ("icon-plat-"++l), title (Maybe.withDefault "" <| lookup l T.platforms) ] [ text " " ]
releaseTypeIcon : String -> Html m
-releaseTypeIcon t = abbr [ class ("icons rt"++t), title (Maybe.withDefault "" <| lookup t T.releaseTypes) ] [ text " " ]
+releaseTypeIcon t = abbr [ class ("icon-rt"++t), title (Maybe.withDefault "" <| lookup t T.releaseTypes) ] [ text " " ]
+
+-- Special values: -1 = "add to list", not 1-6 = unknown
+-- (Because why use the type system to encode special values?)
+ulistIcon : Int -> String -> Html m
+ulistIcon n lbl =
+ let fn = if n == -1 then "add"
+ else if n >= 1 && n <= 6 then "l" ++ String.fromInt n
+ else "unknown"
+ in abbr [ class ("icon-list-"++fn), title lbl ] []
diff --git a/elm/Lib/Image.elm b/elm/Lib/Image.elm
index 37cc26b4..14eca441 100644
--- a/elm/Lib/Image.elm
+++ b/elm/Lib/Image.elm
@@ -108,9 +108,9 @@ viewImg : Image -> Html m
viewImg image =
case (image.imgState, image.img) of
(Loading, _) -> div [ class "spinner" ] []
- (NotFound, _) -> b [ class "standout" ] [ text "Image not found." ]
- (Invalid, _) -> b [ class "standout" ] [ text "Invalid image ID." ]
- (Error e, _) -> b [ class "standout" ] [ text <| Api.showResponse e ]
+ (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
@@ -126,16 +126,16 @@ viewImg image =
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 <| String.replace "sf" "st" i.id ] [] ]
- else img [ src <| imageUrl 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 "Tame"
+ [ 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 "Safe"
+ , text <| if vio > 1.3 then "Brutal" else if vio > 0.4 then "Violent" else "Tame"
, text <| " (" ++ String.fromInt i.votecount ++ ")"
]
_ -> [ text "Not flagged" ]
@@ -162,7 +162,7 @@ viewVote model wrap msg =
] ]
, tfoot [] <|
case model.saveState of
- Api.Error e -> [ tr [] [ td [ colspan 2 ] [ b [ class "standout" ] [ text (Api.showResponse e) ] ] ] ]
+ Api.Error e -> [ tr [] [ td [ colspan 2 ] [ b [] [ text (Api.showResponse e) ] ] ] ]
_ -> []
, tr []
[ td [ style "white-space" "nowrap" ]
diff --git a/elm/Lib/RDate.elm b/elm/Lib/RDate.elm
index f86ecea4..3eca4cfa 100644
--- a/elm/Lib/RDate.elm
+++ b/elm/Lib/RDate.elm
@@ -15,6 +15,7 @@ import Html.Events exposing (..)
import Date
import Lib.Html exposing (..)
import Gen.Types as GT
+import Gen.Api as GApi
type alias RDate = Int
@@ -76,22 +77,25 @@ display today d =
in if future then b [ class "future" ] [ text fmt ] else text fmt
-monthList : List (Int, String)
+monthList : List String
monthList =
- [ ( 1, "Jan")
- , ( 2, "Feb")
- , ( 3, "Mar")
- , ( 4, "Apr")
- , ( 5, "May")
- , ( 6, "Jun")
- , ( 7, "Jul")
- , ( 8, "Aug")
- , ( 9, "Sep")
- , (10, "Oct")
- , (11, "Nov")
- , (12, "Dec")
+ [ "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 =
@@ -101,11 +105,17 @@ view ro permitUnknown permitToday msg =
++ (if permitUnknown then [(0, "Unknown")] else [])
++ [(99999999, "TBA")]
++ List.reverse (range 1980 (GT.curYear + 5) (\n -> {r|y=n}))
- mf (m,s) = (compact (normalize {r|m=m}), String.fromInt m ++ " (" ++ s ++ ")")
- ml = ({r|m=99} |> normalize |> compact, "- month -") :: List.map mf monthList
+ ml = ({r|m=99} |> normalize |> compact, "- month -") :: List.map (\(m,s) -> (compact (normalize {r|m=m}), s)) monthSelect
dl = ({r|d=99} |> normalize |> compact, "- day -") :: range 1 (maxDayInMonth r.y r.m) (\n -> {r|d=n})
in div []
[ inputSelect "" ro msg [ style "width" "100px" ] yl
, if r.y == 0 || r.y == 9999 then text "" else inputSelect "" ro msg [ style "width" "90px" ] ml
, if r.m == 0 || r.m == 99 then text "" else inputSelect "" ro msg [ style "width" "90px" ] dl
]
+
+
+-- Handy function for formatting release info as a string
+-- (Typically used in selection boxes)
+-- (Why is that in this module, you ask? Well, where else do I put it?)
+showrel : GApi.ApiReleases -> String
+showrel r = "[" ++ (format (expand r.released)) ++ " " ++ (String.join "," r.lang) ++ "] " ++ r.title ++ " (" ++ r.id ++ ")"
diff --git a/elm/Lib/TextPreview.elm b/elm/Lib/TextPreview.elm
index 9431848a..dc876048 100644
--- a/elm/Lib/TextPreview.elm
+++ b/elm/Lib/TextPreview.elm
@@ -7,7 +7,6 @@ import Lib.Html exposing (..)
import Lib.Ffi as Ffi
import Lib.Api as Api
import Gen.Api as GApi
-import Gen.Markdown as GM
import Gen.BBCode as GB
@@ -32,16 +31,6 @@ bbcode data =
}
-markdown : String -> Model
-markdown data =
- { state = Api.Normal
- , data = data
- , preview = ""
- , display = False
- , endpoint = GM.send
- , class = "preview docs"
- }
-
type Msg
= Edit String
@@ -73,18 +62,18 @@ view name model cmdmap width attr header =
display = model.display && model.preview /= ""
in
div [ class "textpreview", style "width" (String.fromInt width ++ "px") ]
- [ span []
- [ p [] header
- , p [ class "right", classList [("invisible", model.data == "")] ]
+ [ div []
+ [ div [] header
+ , div [ classList [("invisible", model.data == "")] ]
[ case model.state of
Api.Loading -> span [ class "spinner" ] []
- Api.Error _ -> b [ class "grayedout" ] [ text "Error loading preview. " ]
+ Api.Error _ -> small [] [ text "Error loading preview. " ]
Api.Normal -> text ""
, if display
then a [ onClickN (cmdmap TextArea) ] [ text "Edit" ]
- else i [] [text "Edit"]
+ else span [] [text "Edit"]
, if display
- then i [] [text "Preview"]
+ then span [] [text "Preview"]
else a [ onClickN (cmdmap Preview) ] [ text "Preview" ]
]
]
diff --git a/elm/Lib/Util.elm b/elm/Lib/Util.elm
index 2ab174f9..edde2e37 100644
--- a/elm/Lib/Util.elm
+++ b/elm/Lib/Util.elm
@@ -1,9 +1,12 @@
module Lib.Util exposing (..)
-import Dict
+import Set
import Task
+import Process
import Regex
import Lib.Ffi as Ffi
+import Gen.Api as GApi
+import Gen.Types as GT
-- Delete an element from a List
delidx : Int -> List a -> List a
@@ -28,44 +31,36 @@ hasDuplicates l =
step e acc =
case acc of
Nothing -> Nothing
- Just m -> if Dict.member e m then Nothing else Just (Dict.insert e True m)
+ Just m -> if Set.member e m then Nothing else Just (Set.insert e m)
in
- case List.foldr step (Just Dict.empty) l of
+ case List.foldr step (Just Set.empty) l of
Nothing -> True
Just _ -> False
+-- Returns true if list a contains elements also in list b
+contains : List comparable -> List comparable -> Bool
+contains a b =
+ let d = Set.fromList b
+ in List.any (\e -> Set.member e d) a
+
+
-- Haskell's 'lookup' - find an entry in an association list
lookup : a -> List (a,b) -> Maybe b
lookup n l = List.filter (\(a,_) -> a == n) l |> List.head |> Maybe.map Tuple.second
+-- Have to use Process.sleep instead of Task.succeed here, otherwise any
+-- subscriptions are not updated.
selfCmd : msg -> Cmd msg
-selfCmd m = Task.perform (always m) (Task.succeed True)
-
-
--- Based on VNDBUtil::gtintype()
-validateGtin : String -> Bool
-validateGtin =
- let check = String.fromInt
- >> String.reverse
- >> String.toList
- >> List.indexedMap (\i c -> (Char.toCode c - Char.toCode '0') * if modBy 2 i == 0 then 1 else 3)
- >> List.sum
- inval n =
- n < 1000000000
- || (n >= 200000000000 && n < 600000000000)
- || (n >= 2000000000000 && n < 3000000000000)
- || n >= 9770000000000
- || modBy 10 (check n) /= 0
- in String.filter Char.isDigit >> String.toInt >> Maybe.map (not << inval) >> Maybe.withDefault False
-
-
--- Convert an image ID (e.g. "sf500") into a URL.
-imageUrl : String -> String
-imageUrl id =
+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 ++ "/" ++ String.fromInt (modBy 10 (num // 10)) ++ String.fromInt (modBy 10 num) ++ "/" ++ String.fromInt num ++ ".jpg"
+ 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
@@ -81,7 +76,7 @@ jap_ = Maybe.withDefault Regex.never (Regex.fromString "[\\u3000-\\u9fff\\uff00-
-- Not even close to comprehensive, just excludes a few scripts commonly found on VNDB.
nonlatin_ : Regex.Regex
-nonlatin_ = Maybe.withDefault Regex.never (Regex.fromString "[\\u3000-\\u9fff\\uff00-\\uff9f\\u0400-\\u04ff\\u1100-\\u11ff\\uac00-\\ud7af]")
+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
@@ -91,13 +86,19 @@ containsNonLatin : String -> Bool
containsNonLatin = Regex.contains nonlatin_
--- Given an email address, returns the name of the provider if it has a good chance of blocking mails from our server.
-shittyMailProvider : String -> Maybe String
-shittyMailProvider s =
- case String.split "@" s |> List.drop 1 |> List.head |> Maybe.withDefault "" |> String.toLower of
- "sbcglobal.net" -> Just "AT&T"
- "att.net" -> Just "AT&T"
- _ -> Nothing
+-- 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"
diff --git a/elm/ProducerEdit.elm b/elm/ProducerEdit.elm
deleted file mode 100644
index b7b8a10e..00000000
--- a/elm/ProducerEdit.elm
+++ /dev/null
@@ -1,226 +0,0 @@
-module ProducerEdit exposing (main)
-
-import Html exposing (..)
-import Html.Events exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Util exposing (..)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Autocomplete as A
-import Lib.Api as Api
-import Lib.Editsum as Editsum
-import Gen.Producers as GP
-import Gen.ProducerEdit as GPE
-import Gen.Types as GT
-import Gen.Api as GApi
-
-
-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
- , editsum : Editsum.Model
- , ptype : String
- , name : String
- , original : String
- , alias : String
- , lang : String
- , website : String
- , lWikidata : Maybe Int
- , desc : TP.Model
- , rel : List GPE.RecvRelations
- , relSearch : A.Model GApi.ApiProducerResult
- , id : Maybe String
- , dupCheck : Bool
- , dupProds : List GApi.ApiProducerResult
- }
-
-
-init : GPE.Recv -> Model
-init d =
- { state = Api.Normal
- , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden }
- , ptype = d.ptype
- , name = d.name
- , original = d.original
- , alias = d.alias
- , lang = d.lang
- , website = d.website
- , lWikidata = d.l_wikidata
- , desc = TP.bbcode d.desc
- , rel = d.relations
- , relSearch = A.init ""
- , id = d.id
- , dupCheck = False
- , dupProds = []
- }
-
-
-encode : Model -> GPE.Send
-encode model =
- { id = model.id
- , editsum = model.editsum.editsum.data
- , hidden = model.editsum.hidden
- , locked = model.editsum.locked
- , ptype = model.ptype
- , name = model.name
- , original = model.original
- , alias = model.alias
- , lang = model.lang
- , website = model.website
- , l_wikidata = model.lWikidata
- , desc = model.desc.data
- , relations = List.map (\p -> { pid = p.pid, relation = p.relation }) model.rel
- }
-
-prodConfig : A.Config Msg GApi.ApiProducerResult
-prodConfig = { wrap = RelSearch, id = "relationadd", source = A.producerSource }
-
-type Msg
- = Editsum Editsum.Msg
- | Submit
- | Submitted GApi.Response
- | PType String
- | Name String
- | Original String
- | Alias String
- | Lang String
- | Website String
- | LWikidata (Maybe Int)
- | Desc TP.Msg
- | RelDel Int
- | RelRel Int String
- | RelSearch (A.Msg GApi.ApiProducerResult)
- | DupSubmit
- | DupResults 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)
- PType s -> ({ model | ptype = s }, Cmd.none)
- Name s -> ({ model | name = s, dupProds = [] }, Cmd.none)
- Original s -> ({ model | original = s, dupProds = [] }, Cmd.none)
- Alias s -> ({ model | alias = s, dupProds = [] }, Cmd.none)
- Lang s -> ({ model | lang = s }, Cmd.none)
- Website s -> ({ model | website = s }, Cmd.none)
- LWikidata n-> ({ model | lWikidata = n }, Cmd.none)
- Desc m -> let (nm,nc) = TP.update m model.desc in ({ model | desc = nm }, Cmd.map Desc nc)
-
- RelDel idx -> ({ model | rel = delidx idx model.rel }, Cmd.none)
- RelRel idx rel -> ({ model | rel = modidx idx (\p -> { p | relation = rel }) model.rel }, Cmd.none)
- RelSearch m ->
- let (nm, c, res) = A.update prodConfig m model.relSearch
- in case res of
- Nothing -> ({ model | relSearch = nm }, c)
- Just p ->
- if List.any (\l -> l.pid == p.id) model.rel
- then ({ model | relSearch = A.clear nm "" }, c)
- else ({ model | relSearch = A.clear nm "", rel = model.rel ++ [{ pid = p.id, name = p.name, original = p.original, relation = "old" }] }, c)
-
- DupSubmit ->
- if List.isEmpty model.dupProds
- then ({ model | state = Api.Loading }, GP.send { hidden = True, search = model.name :: model.original :: String.lines model.alias } DupResults)
- else ({ model | dupCheck = True, dupProds = [] }, Cmd.none)
- DupResults (GApi.ProducerResult prods) ->
- if List.isEmpty prods
- then ({ model | state = Api.Normal, dupCheck = True, dupProds = [] }, Cmd.none)
- else ({ model | state = Api.Normal, dupProds = prods }, Cmd.none)
- DupResults r -> ({ model | state = Api.Error r }, Cmd.none)
-
- 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)
-
-
-isValid : Model -> Bool
-isValid model = not
- ( (model.name /= "" && model.name == model.original)
- || hasDuplicates (List.map (\p -> p.pid) model.rel)
- )
-
-
-view : Model -> Html Msg
-view model =
- let
- titles =
- [ formField "name::Name (romaji)" [ inputText "name" model.name Name (style "width" "500px" :: GPE.valName) ]
- , formField "original::Original name"
- [ inputText "original" model.original Original (style "width" "500px" :: GPE.valOriginal)
- , if model.name /= "" && model.name == model.original
- then b [ class "standout" ] [ br [] [], text "Should not be the same as the Name (romaji). Leave blank is the original name is already in the latin alphabet" ]
- else if model.original /= "" && String.toLower model.name /= String.toLower model.original && not (containsNonLatin model.original)
- then b [ class "standout" ] [ br [] [], text "Original name does not seem to contain any non-latin characters. Leave this field empty if the name is already in the latin alphabet" ]
- else text ""
- ]
- , formField "alias::Aliases"
- [ inputTextArea "alias" model.alias Alias (rows 3 :: GPE.valAlias)
- , br [] []
- , if hasDuplicates <| String.lines <| String.toLower model.alias
- then b [ class "standout" ] [ text "List contains duplicate aliases.", br [] [] ]
- else text ""
- , text "(Un)official aliases, separated by a newline."
- ]
- ]
-
- geninfo =
- [ formField "ptype::Type" [ inputSelect "ptype" model.ptype PType [] GT.producerTypes ] ]
- ++ titles ++
- [ formField "lang::Primary language" [ inputSelect "lang" model.lang Lang [] GT.languages ]
- , formField "website::Website" [ inputText "website" model.website Website GPE.valWebsite ]
- , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.lWikidata LWikidata [] ]
- , formField "desc::Description"
- [ TP.view "desc" model.desc Desc 600 (style "height" "180px" :: GPE.valDesc) [ b [ class "standout" ] [ text "English please!" ] ] ]
-
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Database relations" ] ]
- , formField "Related producers"
- [ if List.isEmpty model.rel then text ""
- else table [] <| List.indexedMap (\i p -> tr []
- [ td [] [ inputSelect "" p.relation (RelRel i) [] GT.producerRelations ]
- , td [] [ b [ class "grayedout" ] [ text <| p.pid ++ ": " ], a [ href <| "/" ++ p.pid ] [ text p.name ] ]
- , td [] [ inputButton "remove" (RelDel i) [] ]
- ]
- ) model.rel
- , A.view prodConfig model.relSearch [placeholder "Add Producer..."]
- ]
- ]
-
- newform () =
- form_ "" DupSubmit (model.state == Api.Loading)
- [ div [ class "mainbox" ] [ h1 [] [ text "Add a new producer" ], table [ class "formtable" ] titles ]
- , div [ class "mainbox" ]
- [ if List.isEmpty model.dupProds then text "" else
- div []
- [ h1 [] [ text "Possible duplicates" ]
- , text "The following is a list of producers that match the name(s) you gave. "
- , text "Please check this list to avoid creating a duplicate producer 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 (\p -> li []
- [ a [ href <| "/" ++ p.id ] [ text p.name ]
- , if p.hidden then b [ class "standout" ] [ text " (deleted)" ] else text ""
- ]
- ) model.dupProds
- ]
- , fieldset [ class "submit" ] [ submitButton (if List.isEmpty model.dupProds then "Continue" else "Continue anyway") model.state (isValid model) ]
- ]
- ]
-
- fullform () =
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ] [ h1 [] [ text "Edit producer" ], table [ class "formtable" ] geninfo ]
- , div [ class "mainbox" ] [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
- ]
- ]
- in if model.id == Nothing && not model.dupCheck then newform () else fullform ()
diff --git a/elm/ReleaseEdit.elm b/elm/ReleaseEdit.elm
deleted file mode 100644
index 803341cd..00000000
--- a/elm/ReleaseEdit.elm
+++ /dev/null
@@ -1,410 +0,0 @@
-module ReleaseEdit exposing (main)
-
-import Html exposing (..)
-import Html.Events exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Set
-import Lib.Util exposing (..)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Lib.DropDown as DD
-import Lib.Editsum as Editsum
-import Lib.RDate as D
-import Lib.Autocomplete as A
-import Lib.ExtLinks as EL
-import Gen.ReleaseEdit as GRE
-import Gen.Types as GT
-import Gen.Api as GApi
-import Gen.ExtLinks as GEL
-
-
-main : Program GRE.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = sub
- }
-
-
-type alias Model =
- { state : Api.State
- , title : String
- , original : String
- , rtype : String
- , official : Bool
- , patch : Bool
- , freeware : Bool
- , doujin : Bool
- , lang : Set.Set String
- , langDd : DD.Config Msg
- , plat : Set.Set String
- , platDd : DD.Config Msg
- , media : List GRE.RecvMedia
- , gtin : String
- , gtinValid : Bool
- , catalog : String
- , released : D.RDate
- , minage : Maybe Int
- , uncensored : Bool
- , resoX : Int
- , resoY : Int
- , reso : A.Model GApi.ApiResolutions
- , voiced : Int
- , ani_story : Int
- , ani_ero : Int
- , website : String
- , engine : A.Model GApi.ApiEngines
- , extlinks : EL.Model GRE.RecvExtlinks
- , vn : List GRE.RecvVn
- , vnAdd : A.Model GApi.ApiVNResult
- , prod : List GRE.RecvProducers
- , prodAdd : A.Model GApi.ApiProducerResult
- , notes : TP.Model
- , editsum : Editsum.Model
- , id : Maybe String
- }
-
-
-init : GRE.Recv -> Model
-init d =
- { state = Api.Normal
- , title = d.title
- , original = d.original
- , rtype = d.rtype
- , official = d.official
- , patch = d.patch
- , freeware = d.freeware
- , doujin = d.doujin
- , lang = Set.fromList <| List.map (\e -> e.lang) d.lang
- , langDd = DD.init "lang" LangOpen
- , plat = Set.fromList <| List.map (\e -> e.platform) d.platforms
- , platDd = DD.init "platforms" PlatOpen
- , media = List.map (\m -> { m | qty = if m.qty == 0 then 1 else m.qty }) d.media
- , gtin = if d.gtin == "0" then "" else String.padLeft 12 '0' d.gtin
- , gtinValid = True
- , catalog = d.catalog
- , released = d.released
- , minage = d.minage
- , uncensored = d.uncensored
- , resoX = d.reso_x
- , resoY = d.reso_y
- , reso = A.init (resoFmt True d.reso_x d.reso_y)
- , voiced = d.voiced
- , ani_story = d.ani_story
- , ani_ero = d.ani_ero
- , website = d.website
- , engine = A.init d.engine
- , extlinks = EL.new d.extlinks GEL.releaseSites
- , vn = d.vn
- , vnAdd = A.init ""
- , prod = d.producers
- , prodAdd = A.init ""
- , notes = TP.bbcode d.notes
- , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden }
- , id = d.id
- }
-
-
-encode : Model -> GRE.Send
-encode model =
- { id = model.id
- , editsum = model.editsum.editsum.data
- , hidden = model.editsum.hidden
- , locked = model.editsum.locked
- , title = model.title
- , original = model.original
- , rtype = model.rtype
- , official = model.official
- , patch = model.patch
- , freeware = model.freeware
- , doujin = model.doujin
- , lang = List.map (\l -> {lang=l }) <| Set.toList model.lang
- , platforms = List.map (\l -> {platform=l}) <| Set.toList model.plat
- , media = model.media
- , gtin = model.gtin
- , catalog = model.catalog
- , released = model.released
- , minage = model.minage
- , uncensored = model.uncensored
- , reso_x = model.resoX
- , reso_y = model.resoY
- , voiced = model.voiced
- , ani_story = model.ani_story
- , ani_ero = model.ani_ero
- , website = model.website
- , engine = model.engine.value
- , extlinks = model.extlinks.links
- , vn = List.map (\l -> {vid=l.vid}) model.vn
- , producers = List.map (\l -> {pid=l.pid, developer=l.developer, publisher=l.publisher}) model.prod
- , notes = model.notes.data
- }
-
-vnConfig : A.Config Msg GApi.ApiVNResult
-vnConfig = { wrap = VNSearch, id = "vnadd", source = A.vnSource }
-
-producerConfig : A.Config Msg GApi.ApiProducerResult
-producerConfig = { wrap = ProdSearch, id = "prodadd", source = A.producerSource }
-
-resoConfig : A.Config Msg GApi.ApiResolutions
-resoConfig = { wrap = Resolution, id = "resolution", source = A.resolutionSource }
-
-engineConfig : A.Config Msg GApi.ApiEngines
-engineConfig = { wrap = Engine, id = "engine", source = A.engineSource }
-
-sub : Model -> Sub Msg
-sub m = Sub.batch [ DD.sub m.langDd, DD.sub m.platDd ]
-
-type Msg
- = Title String
- | Original String
- | RType String
- | Official Bool
- | Patch Bool
- | Freeware Bool
- | Doujin Bool
- | Lang String Bool
- | LangOpen Bool
- | Plat String Bool
- | PlatOpen Bool
- | MediaType Int String
- | MediaQty Int Int
- | MediaDel Int
- | Gtin String
- | Catalog String
- | Released D.RDate
- | Minage (Maybe Int)
- | Uncensored Bool
- | Resolution (A.Msg GApi.ApiResolutions)
- | Voiced Int
- | AniStory Int
- | AniEro Int
- | Website String
- | Engine (A.Msg GApi.ApiEngines)
- | ExtLinks (EL.Msg GRE.RecvExtlinks)
- | VNDel Int
- | VNSearch (A.Msg GApi.ApiVNResult)
- | ProdDel Int
- | ProdRole Int (Bool, Bool)
- | ProdSearch (A.Msg GApi.ApiProducerResult)
- | Notes (TP.Msg)
- | Editsum Editsum.Msg
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Title s -> ({ model | title = s }, Cmd.none)
- Original s -> ({ model | original = s }, Cmd.none)
- RType s -> ({ model | rtype = s }, Cmd.none)
- Official b -> ({ model | official = b }, Cmd.none)
- Patch b -> ({ model | patch = b }, Cmd.none)
- Freeware b -> ({ model | freeware = b }, Cmd.none)
- Doujin b -> ({ model | doujin = b }, Cmd.none)
- Lang s b -> ({ model | lang = if b then Set.insert s model.lang else Set.remove s model.lang }, Cmd.none)
- LangOpen b -> ({ model | langDd = DD.toggle model.langDd b }, Cmd.none)
- Plat s b -> ({ model | plat = if b then Set.insert s model.plat else Set.remove s model.plat }, Cmd.none)
- PlatOpen b -> ({ model | platDd = DD.toggle model.platDd b }, Cmd.none)
- MediaType n s -> ({ model | media = if s /= "unk" && n == List.length model.media then model.media ++ [{medium = s, qty = 1}] else modidx n (\m -> { m | medium = s }) model.media }, Cmd.none)
- MediaQty n i -> ({ model | media = modidx n (\m -> { m | qty = i }) model.media }, Cmd.none)
- MediaDel i -> ({ model | media = delidx i model.media }, Cmd.none)
- Gtin s -> ({ model | gtin = s, gtinValid = s == "" || validateGtin s }, Cmd.none)
- Catalog s -> ({ model | catalog = s }, Cmd.none)
- Released d -> ({ model | released = d }, Cmd.none)
- Minage i -> ({ model | minage = i }, Cmd.none)
- Uncensored b->({ model | uncensored = b }, Cmd.none)
- Resolution m->
- let (nm, c, en) = A.update resoConfig m model.reso
- nmod = { model | reso = Maybe.withDefault nm <| Maybe.map (\e -> A.clear nm e.resolution) en }
- n2mod = case resoParse True nmod.reso.value of
- Just (x,y) -> { nmod | resoX = x, resoY = y }
- Nothing -> nmod
- in (n2mod, c)
- Voiced i -> ({ model | voiced = i }, Cmd.none)
- AniStory i -> ({ model | ani_story = i }, Cmd.none)
- AniEro i -> ({ model | ani_ero = i }, Cmd.none)
- Website s -> ({ model | website = s }, Cmd.none)
- Engine m ->
- let (nm, c, en) = A.update engineConfig m model.engine
- nmod = case en of
- Just e -> A.clear nm e.engine
- Nothing -> nm
- in ({ model | engine = nmod }, c)
- ExtLinks m -> ({ model | extlinks = EL.update m model.extlinks }, Cmd.none)
-
- VNDel i -> ({ model | vn = delidx i model.vn }, Cmd.none)
- VNSearch m ->
- let (nm, c, res) = A.update vnConfig m model.vnAdd
- in case res of
- Nothing -> ({ model | vnAdd = nm }, c)
- Just v ->
- if List.any (\vn -> vn.vid == v.id) model.vn
- then ({ model | vnAdd = nm }, c)
- else ({ model | vnAdd = A.clear nm "", vn = model.vn ++ [{ vid = v.id, title = v.title}] }, c)
-
- ProdDel i -> ({ model | prod = delidx i model.prod }, Cmd.none)
- ProdRole i (d,p) -> ({ model | prod = modidx i (\e -> { e | developer = d, publisher = p }) model.prod }, Cmd.none)
- ProdSearch m ->
- let (nm, c, res) = A.update producerConfig m model.prodAdd
- in case res of
- Nothing -> ({ model | prodAdd = nm }, c)
- Just p ->
- if List.any (\e -> e.pid == p.id) model.prod
- then ({ model | prodAdd = nm }, c)
- else ({ model | prodAdd = A.clear nm "", prod = model.prod ++ [{ pid = p.id, name = p.name, developer = True, publisher = True}] }, c)
-
- Notes m -> let (nm, nc) = TP.update m model.notes in ({ model | notes = nm }, Cmd.map Notes nc)
- Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum 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)
-
-
-isValid : Model -> Bool
-isValid model = not
- ( model.title == model.original
- || Set.isEmpty model.lang
- || hasDuplicates (List.map (\m -> (m.medium, m.qty)) model.media)
- || not model.gtinValid
- || List.isEmpty model.vn
- || resoParse True model.reso.value == Nothing
- )
-
-
-viewGen : Model -> Html Msg
-viewGen model =
- table [ class "formtable" ]
- [ formField "title::Title (romaji)"
- [ inputText "title" model.title Title (style "width" "500px" :: GRE.valTitle)
- , if containsNonLatin model.title
- then b [ class "standout" ] [ br [] [], text "This title field should only contain latin-alphabet characters, please put the \"actual\" title in the field below and the romanization above." ]
- else text ""
- ]
- , formField "original::Original title"
- [ inputText "original" model.original Original (style "width" "500px" :: GRE.valOriginal)
- , if model.title /= "" && model.title == model.original
- then b [ class "standout" ] [ br [] [], text "Should not be the same as the Title (romaji). Leave blank is the original title is already in the latin alphabet" ]
- else if model.original /= "" && not (containsNonLatin model.original)
- then b [ class "standout" ] [ br [] [], text "Original title does not seem to contain any non-latin characters. Leave this field empty if the title is already in the latin alphabet" ]
- else if containsJapanese model.original && not (Set.isEmpty model.lang) && not (Set.member "ja" model.lang) && not (Set.member "zh" model.lang)
- then b [ class "standout" ] [ br [] [], text "Non-Japanese releases should (probably) not have a Japanese original title." ]
- else text ""
- ]
-
- , tr [ class "newpart" ] [ td [] [] ]
- , formField "rtype::Type" [ inputSelect "rtype" model.rtype RType [] GT.releaseTypes ]
- , formField "minage::Age rating" [ inputSelect "minage" model.minage Minage [] ((Nothing, "Unknown") :: List.map (Tuple.mapFirst Just) GT.ageRatings), text " (*)" ]
- , formField "" [ label [] [ inputCheck "" model.official Official, text " Official (i.e. sanctioned by the original developer of the visual novel)" ] ]
- , formField "" [ label [] [ inputCheck "" model.patch Patch , text " This release is a patch to another release.", text " (*)" ] ]
- , formField "" [ label [] [ inputCheck "" model.freeware Freeware, text " Freeware (i.e. available at no cost)" ] ]
- , if model.patch then text "" else
- formField "" [ label [] [ inputCheck "" model.doujin Doujin , text " Doujin (self-published, not by a company)" ] ]
- , formField "Release date" [ D.view model.released False False Released, text " Leave month or day blank if they are unknown." ]
-
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Format" ] ]
- , formField "Language(s)"
- [ div [ class "elm_dd_input", style "width" "500px" ] [ DD.view model.langDd Api.Normal
- (if Set.isEmpty model.lang
- then b [ class "standout" ] [ text "No language selected" ]
- else span [] <| List.intersperse (text ", ") <| List.map (\(l,t) -> span [ style "white-space" "nowrap" ] [ langIcon l, text t ]) <| List.filter (\(l,_) -> Set.member l model.lang) GT.languages)
- <| \() -> [ ul [ style "columns" "2"] <| List.map (\(l,t) -> li [] [ linkRadio (Set.member l model.lang) (Lang l) [ langIcon l, text t ] ]) GT.languages ]
- ] ]
- , formField "Platform(s)"
- [ div [ class "elm_dd_input", style "width" "500px" ] [ DD.view model.platDd Api.Normal
- (if Set.isEmpty model.plat
- then text "No platform selected"
- else span [] <| List.intersperse (text ", ") <| List.map (\(p,t) -> span [ style "white-space" "nowrap" ] [ platformIcon p, text t ]) <| List.filter (\(p,_) -> Set.member p model.plat) GT.platforms)
- <| \() -> [ ul [ style "columns" "2"] <| List.map (\(p,t) -> li [ classList [("separator", p == "web")] ] [ linkRadio (Set.member p model.plat) (Plat p) [ platformIcon p, text t ] ]) GT.platforms ]
- ] ]
- , formField "Media"
- [ table [] <| List.indexedMap (\i m ->
- let q = List.filter (\(s,_,_) -> m.medium == s) GT.media |> List.head |> Maybe.map (\(_,_,x) -> x) |> Maybe.withDefault False
- in tr []
- [ td [] [ inputSelect "" m.medium (MediaType i) [] <| (if m.medium == "unk" then [("unk", "- Add medium -")] else []) ++ List.map (\(a,b,_) -> (a,b)) GT.media ]
- , td [] [ if q then inputSelect "" m.qty (MediaQty i) [ style "width" "100px" ] <| List.map (\a -> (a,String.fromInt a)) <| List.range 1 40 else text "" ]
- , td [] [ if m.medium == "unk" then text "" else inputButton "remove" (MediaDel i) [] ]
- ]
- ) <| model.media ++ [{medium = "unk", qty = 0}]
- , if hasDuplicates (List.map (\m -> (m.medium, m.qty)) model.media)
- then b [ class "standout" ] [ text "List contains duplicates", br [] [] ]
- else text ""
- ]
-
- , if model.patch then text "" else
- formField "engine::Engine" [ A.view engineConfig model.engine [] ]
- , if model.patch then text "" else
- formField "resolution::Resolution"
- [ A.view resoConfig model.reso []
- , if resoParse True model.reso.value == Nothing then b [ class "standout" ] [ text " Invalid resolution" ] else text ""
- ]
- , if model.patch then text "" else
- formField "voiced::Voiced" [ inputSelect "voiced" model.voiced Voiced [] GT.voiced ]
- , if model.patch then text "" else
- formField "ani_story::Animations"
- [ inputSelect "ani_story" model.ani_story AniStory [] GT.animated
- , if model.minage == Just 18 then text " <= story | ero scenes => " else text ""
- , if model.minage == Just 18 then inputSelect "" model.ani_ero AniEro [] GT.animated else text ""
- ]
- , if model.minage /= Just 18 then text "" else
- formField "" [ label [] [ inputCheck "" model.uncensored Uncensored, text " Uncensored (No mosaic or other optical censoring, only check if this release has erotic content)" ] ]
-
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "External identifiers & links" ] ]
- , formField "gtin::JAN/UPC/EAN"
- [ inputText "gtin" model.gtin Gtin [pattern "[0-9]+"]
- , if not model.gtinValid then b [ class "standout" ] [ text "Invalid GTIN code" ] else text ""
- ]
- , formField "catalog::Catalog number" [ inputText "catalog" model.catalog Catalog GRE.valCatalog ]
- , formField "website::Website" [ inputText "website" model.website Website (style "width" "500px" :: GRE.valWebsite) ]
- , tr [ class "newpart" ] [ td [ colspan 2 ] [] ]
- , formField "External Links" [ Html.map ExtLinks (EL.view model.extlinks) ]
-
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Database relations" ] ]
- , formField "Visual novels"
- [ if List.isEmpty model.vn then b [ class "standout" ] [ text "No visual novels selected.", br [] [] ]
- else table [] <| List.indexedMap (\i v -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| v.vid ++ ":" ] ]
- , td [] [ a [ href <| "/" ++ v.vid ] [ text v.title ] ]
- , td [] [ inputButton "remove" (VNDel i) [] ]
- ]
- ) model.vn
- , A.view vnConfig model.vnAdd [placeholder "Add visual novel..."]
- ]
- , tr [ class "newpart" ] [ td [ colspan 2 ] [] ]
- , formField "Producers"
- [ table [ class "compact" ] <| List.indexedMap (\i p -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| p.pid ++ ":" ] ]
- , td [] [ a [ href <| "/" ++ p.pid ] [ text p.name ] ]
- , td [] [ inputSelect "" (p.developer, p.publisher) (ProdRole i) [style "width" "100px"] [((True,False), "Developer"), ((False,True), "Publisher"), ((True,True), "Both")] ]
- , td [] [ inputButton "remove" (ProdDel i) [] ]
- ]
- ) model.prod
- , A.view producerConfig model.prodAdd [placeholder "Add producer..."]
- ]
-
- , tr [ class "newpart" ] [ td [ colspan 2 ] [] ]
- , formField "notes::Notes"
- [ TP.view "notes" model.notes Notes 700 [] [ b [ class "standout" ] [ text " (English please!) " ] ]
- , text "Miscellaneous notes/comments, information that does not fit in the above fields. E.g.: Types of censoring or for which releases this patch applies."
- ]
- ]
-
-view : Model -> Html Msg
-view model =
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "General info" ]
- , viewGen model
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
- ]
- ]
diff --git a/elm/Report.elm b/elm/Report.elm
deleted file mode 100644
index f9fb1cd3..00000000
--- a/elm/Report.elm
+++ /dev/null
@@ -1,189 +0,0 @@
-module Report exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Html exposing (..)
-import Lib.Api as Api
-import Lib.Ffi as Ffi
-import Gen.Api as GApi
-import Gen.Report as GR
-
-
-main : Program GR.Send Model Msg
-main = Browser.element
- { init = \e -> ((Api.Normal, e), Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-type alias Model = (Api.State,GR.Send)
-
-type Msg
- = Reason String
- | Message String
- | Submit
- | Submitted GApi.Response
-
-
-type alias ReasonLabel =
- { label : String
- , vis : String -> Bool -- Given an objectid, returns whether it should be listed
- , submit : Bool -- Whether it allows submission of the form
- , msg : String -> List (Html Msg) -- Message to display
- }
-
-
-vis _ = True
-nomsg _ = []
-objtype s o = String.any (\c -> String.startsWith (String.fromChar c) o) s
-editable = objtype "vrpcs"
-initial = { label = "-- Select --" , vis = vis, submit = False , msg = nomsg }
-
-reasons : List ReasonLabel
-reasons =
- [ initial
- , { label = "Spam"
- , vis = vis
- , submit = True
- , msg = nomsg
- }
- , { label = "Links to piracy or illegal content"
- , vis = vis
- , submit = True
- , msg = nomsg
- }
- , { label = "Off-topic"
- , vis = objtype "tw"
- , submit = True
- , msg = nomsg
- }
- , { label = "Unwelcome behavior"
- , vis = objtype "tw"
- , submit = True
- , msg = nomsg
- }
- , { label = "Unmarked spoilers"
- , vis = vis
- , submit = True
- , msg = \o -> if not (editable o) then [] else
- [ text "VNDB is an open wiki, it is often easier if you removed the spoilers yourself by "
- , a [ href ("/" ++ o ++ "/edit") ] [ text " editing the entry" ]
- , text ". You likely know more about this entry than our moderators, after all. "
- , br [] []
- , text "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 "
- , a [ href "/t/db" ] [ text "discussion board" ]
- , text " so that others may be able to help you."
- ]
- }
- , { label = "Unmarked or improperly flagged NSFW image"
- , vis = objtype "vc"
- , submit = True
- , msg = nomsg
- }
- , { label = "Incorrect information"
- , vis = editable
- , submit = False
- , msg = \o ->
- [ text "VNDB is an open wiki, you can correct the information in this database yourself by "
- , a [ href ("/" ++ o ++ "/edit") ] [ text " editing the entry" ]
- , text ". You likely know more about this entry than our moderators, after all. "
- , br [] []
- , text "If you need help with editing, you can also report this issue on the "
- , a [ href "/t/db" ] [ text "discussion board" ]
- , text " so that others may be able to help you."
- ]
- }
- , { label = "Missing information"
- , vis = editable
- , submit = False
- , msg = \o ->
- [ text "VNDB is an open wiki, you can add any missing information to this database yourself. "
- , text "You likely know more about this entry than our moderators, after all. "
- , br [] []
- , text "If you need help with contributing information, feel free to ask around on the "
- , a [ href "/t/db" ] [ text "discussion board" ]
- , text "."
- ]
- }
- , { label = "Not a visual novel"
- , vis = objtype "v"
- , submit = False
- , msg = \_ ->
- [ text "If you suspect that this entry does not adhere to our "
- , a [ href "/d2#1" ] [ text "inclusion criteria" ]
- , text ", please report it in "
- , a [ href "/t2108" ] [ text "this thread" ]
- , text ", so that other users have a chance to provide feedback before a moderator makes their final decision."
- ]
- }
- , { label = "Does not belong here"
- , vis = \o -> editable o && not (objtype "v" o)
- , submit = True
- , msg = nomsg
- }
- , { label = "Duplicate entry"
- , vis = editable
- , submit = True
- , msg = \_ -> [ text "Please include a link to the entry that this is a duplicate of." ]
- }
- , { label = "Other"
- , vis = vis
- , submit = True
- , msg = \o ->
- if editable o
- then [ text "Keep in mind that VNDB is an open wiki, you can edit most of the information in this database."
- , br [] []
- , text "Reports for issues that do not require a moderator to get involved will most likely be ignored."
- , br [] []
- , text "If you need help with contributing to the database, feel free to ask around on the "
- , a [ href "/t/db" ] [ text "discussion board" ]
- , text "."
- ]
- else []
- }
- ]
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg (state,model) =
- case msg of
- Reason s -> ((state, { model | reason = s }), Cmd.none)
- Message s -> ((state, { model | message = s }), Cmd.none)
- Submit -> ((Api.Loading, model), GR.send model Submitted)
- Submitted r -> ((Api.Error r, model), Cmd.none)
-
-
-view : Model -> Html Msg
-view (state,model) =
- let
- lst = List.filter (\l -> l.vis model.object) reasons
- cur = List.filter (\l -> l.label == model.reason) lst |> List.head |> Maybe.withDefault initial
- in
- form_ "" Submit (state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "Submit report" ]
- , if state == Api.Error GApi.Success
- then p [] [ text "Your report has been submitted, a moderator will look at it as soon as possible." ]
- else table [ class "formtable" ] <|
- [ formField "Subject" [ span [ Ffi.innerHtml model.title ] [] ]
- , formField ""
- [ text "Your report will be forwarded to a moderator."
- , br [] []
- , text "Keep in mind that not every report will be acted upon, we may decide that the problem you reported is still within acceptable limits."
- , br [] []
- , if model.loggedin
- then text "We generally do not provide feedback on reports, but a moderator may decide to contact you for clarification."
- else text "We generally do not provide feedback on reports, but you may leave your email address in the message if you wish to be available for clarification."
- ]
- , formField "reason::Reason" [ inputSelect "reason" model.reason Reason [style "width" "300px"] <| List.map (\l->(l.label,l.label)) lst ]
- , formField "" (cur.msg model.object)
- ] ++ if not cur.submit then [] else
- [ formField "message::Message" [ inputTextArea "message" model.message Message [] ]
- , formField "" [ submitButton "Submit" state True ]
- ]
- ]
- ]
diff --git a/elm/Reviews/Comment.elm b/elm/Reviews/Comment.elm
deleted file mode 100644
index 8b2399dd..00000000
--- a/elm/Reviews/Comment.elm
+++ /dev/null
@@ -1,52 +0,0 @@
-module Reviews.Comment 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.ReviewsComment as GRC
-
-
-main : Program GRC.Send Model Msg
-main = Browser.element
- { init = \e -> ((Api.Normal, e.id, TP.bbcode ""), Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-type alias Model = (Api.State, String, TP.Model)
-
-type Msg
- = Content TP.Msg
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg (state,id,content) =
- case msg of
- Content m -> let (nm,nc) = TP.update m content in ((state,id,nm), Cmd.map Content nc)
- Submit -> ((Api.Loading,id,content), GRC.send { msg = content.data, id = id } Submitted)
- Submitted (GApi.Redirect s) -> ((state,id,content), load s)
- Submitted r -> ((Api.Error r,id,content), Cmd.none)
-
-
-view : Model -> Html Msg
-view (state,_,content) =
- form_ "" Submit (state == Api.Loading)
- [ div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ TP.view "msg" content Content 600 ([rows 4, cols 50] ++ GRC.valMsg)
- [ b [] [ text "Comment" ]
- , b [ class "standout" ] [ text " (English please!) " ]
- , a [ href "/d9#3" ] [ text "Formatting" ]
- ]
- , submitButton "Submit" state True
- ]
- ]
- ]
diff --git a/elm/Reviews/Edit.elm b/elm/Reviews/Edit.elm
index 35e8b1d7..b122d1ba 100644
--- a/elm/Reviews/Edit.elm
+++ b/elm/Reviews/Edit.elm
@@ -107,8 +107,6 @@ update msg model =
Deleted r -> ({ model | delState = Api.Error r }, Cmd.none)
-showrel r = "[" ++ (RDate.format (RDate.expand r.released)) ++ " " ++ (String.join "," r.lang) ++ "] " ++ r.title ++ " (" ++ r.id ++ ")"
-
view : Model -> Html Msg
view model =
let minChars = if model.isfull then 1000 else 200
@@ -116,9 +114,9 @@ view model =
len = String.length model.text.data
in
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ article []
[ h1 [] [ text <| if model.id == Nothing then "Submit a review" else "Edit review" ]
- , p [] [ b [] [ text "Rules" ] ]
+ , p [] [ strong [] [ text "Rules" ] ]
, ul []
[ li [] [ text "Submit only reviews you have written yourself!" ]
, li [] [ text "Reviews must be in English." ]
@@ -128,32 +126,32 @@ view model =
]
, br [] []
]
- , div [ class "mainbox" ]
+ , 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, showrel r)) model.releases
+ :: 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), b [] [ text " Mini review" ]
+ [ 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 ), b [] [ text " Full review" ]
+ , label [] [ inputRadio "type" (model.isfull == True ) (\_ -> Full True ), strong [] [ text " Full review" ]
, text " - Longer, more detailed." ]
, br [] []
- , b [ class "grayedout" ] [ text "You can always switch between review types later." ]
+ , 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 [] []
- , b [ class "grayedout" ] [ text "You do not have to check this option if all spoilers in your review are marked with [spoiler] tags." ]
+ , 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." ] ]
@@ -165,39 +163,35 @@ view model =
, 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#3" ] [ text "BBCode formatting supported" ] ]
+ [ a [ href "/d9#4" ] [ text "BBCode formatting supported" ] ]
, div [ style "width" "700px", style "text-align" "right" ] <|
- let num c s = if c then b [ class " standout" ] [ text s ] else text s
+ let num c s = if c then b [] [ text s ] else text s
in
[ num (len < minChars) (String.fromInt minChars)
, text " / "
- , b [] [ text (String.fromInt len) ]
+ , strong [] [ text (String.fromInt len) ]
, text " / "
, num (len > maxChars) (if model.isfull then "∞" else String.fromInt maxChars)
]
]
]
]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ submitButton "Submit" model.state (len <= maxChars && len >= minChars)
- ]
- ]
+ , article [ class "submit" ] [ submitButton "Submit" model.state (len <= maxChars && len >= minChars) ]
, if model.id == Nothing then text "" else
- div [ class "mainbox" ]
+ 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 [ class "standout" ] [ text "WARNING:" ]
+ , 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 [ class "standout" ] [ text <| Api.showResponse e ]
+ Api.Error e -> b [] [ text <| Api.showResponse e ]
Api.Normal -> text ""
]
] ]
diff --git a/elm/Reviews/Vote.elm b/elm/Reviews/Vote.elm
deleted file mode 100644
index 490a2b78..00000000
--- a/elm/Reviews/Vote.elm
+++ /dev/null
@@ -1,70 +0,0 @@
-module Reviews.Vote exposing (main)
-
-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 Gen.Api as GApi
-import Gen.ReviewsVote as GRV
-
-
-main : Program GRV.Recv Model Msg
-main = Browser.element
- { init = \d -> (init d, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-type alias Model =
- { state : Api.State
- , id : String
- , my : Maybe Bool
- , overrule : Bool
- , mod : Bool
- }
-
-init : GRV.Recv -> Model
-init d =
- { state = Api.Normal
- , id = d.id
- , my = d.my
- , overrule = d.overrule
- , mod = d.mod
- }
-
-type Msg
- = Vote Bool
- | Overrule Bool
- | Saved GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- let save m = ({ m | state = Api.Loading }, GRV.send { id = m.id, my = m.my, overrule = m.overrule } Saved)
- in
- case msg of
- Vote b -> save { model | my = if model.my == Just b then Nothing else Just b }
- Overrule b -> let nm = { model | overrule = b } in if isJust model.my then save nm else (nm, Cmd.none)
-
- Saved GApi.Success -> ({ model | state = Api.Normal }, Cmd.none)
- Saved e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- let but opt lbl = a [ href "#", onClickD (Vote opt), classList [("votebut", True), ("myvote", model.my == Just opt)] ] [ text lbl ]
- in
- span []
- [ case model.state of
- Api.Loading -> span [ class "spinner" ] []
- Api.Error e -> b [ class "standout" ] [ text (Api.showResponse e) ]
- Api.Normal -> text "Was this review helpful? "
- , but True "yes"
- , text " / "
- , but False "no"
- , if not model.mod then text "" else label [] [ text " / ", inputCheck "" model.overrule Overrule, text " O" ]
- ]
diff --git a/elm/StaffEdit.elm b/elm/StaffEdit.elm
deleted file mode 100644
index 0256e69a..00000000
--- a/elm/StaffEdit.elm
+++ /dev/null
@@ -1,206 +0,0 @@
-module StaffEdit exposing (main)
-
-import Html exposing (..)
-import Html.Events exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Util exposing (..)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Lib.Editsum as Editsum
-import Gen.StaffEdit as GSE
-import Gen.Types as GT
-import Gen.Api as GApi
-
-
-main : Program GSE.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-
-type alias Model =
- { state : Api.State
- , editsum : Editsum.Model
- , alias : List GSE.RecvAlias
- , aliasDup : Bool
- , aid : Int
- , desc : TP.Model
- , gender : String
- , lang : String
- , l_site : String
- , l_wikidata : Maybe Int
- , l_twitter : String
- , l_anidb : Maybe Int
- , l_pixiv : Int
- , id : Maybe String
- }
-
-
-init : GSE.Recv -> Model
-init d =
- { state = Api.Normal
- , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden }
- , alias = d.alias
- , aliasDup = False
- , aid = d.aid
- , desc = TP.bbcode d.desc
- , gender = d.gender
- , lang = d.lang
- , l_site = d.l_site
- , l_wikidata = d.l_wikidata
- , l_twitter = d.l_twitter
- , l_anidb = d.l_anidb
- , l_pixiv = d.l_pixiv
- , id = d.id
- }
-
-
-encode : Model -> GSE.Send
-encode model =
- { id = model.id
- , editsum = model.editsum.editsum.data
- , hidden = model.editsum.hidden
- , locked = model.editsum.locked
- , aid = model.aid
- , alias = List.map (\e -> { aid = e.aid, name = e.name, original = e.original }) model.alias
- , desc = model.desc.data
- , gender = model.gender
- , lang = model.lang
- , l_site = model.l_site
- , l_wikidata = model.l_wikidata
- , l_twitter = model.l_twitter
- , l_anidb = model.l_anidb
- , l_pixiv = model.l_pixiv
- }
-
-
-newAid : Model -> Int
-newAid model =
- let id = Maybe.withDefault 0 <| List.minimum <| List.map .aid model.alias
- in if id >= 0 then -1 else id - 1
-
-
-type Msg
- = Editsum Editsum.Msg
- | Submit
- | Submitted GApi.Response
- | Lang String
- | Gender String
- | Website String
- | LWikidata (Maybe Int)
- | LTwitter String
- | LAnidb String
- | LPixiv String
- | Desc TP.Msg
- | AliasDel Int
- | AliasName Int String
- | AliasOrig Int String
- | AliasMain Int Bool
- | AliasAdd
-
-
-validate : Model -> Model
-validate model = { model | aliasDup = hasDuplicates <| List.map (\e -> (e.name, e.original)) model.alias }
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
- Lang s -> ({ model | lang = s }, Cmd.none)
- Gender s -> ({ model | gender = s }, Cmd.none)
- Website s -> ({ model | l_site = s }, Cmd.none)
- LWikidata n-> ({ model | l_wikidata= n }, Cmd.none)
- LTwitter s -> ({ model | l_twitter = s }, Cmd.none)
- LAnidb s -> ({ model | l_anidb = if s == "" then Nothing else String.toInt s }, Cmd.none)
- LPixiv s -> ({ model | l_pixiv = Maybe.withDefault model.l_pixiv (String.toInt s) }, Cmd.none)
- Desc m -> let (nm,nc) = TP.update m model.desc in ({ model | desc = nm }, Cmd.map Desc nc)
-
- AliasDel i -> (validate { model | alias = delidx i model.alias }, Cmd.none)
- AliasName i s -> (validate { model | alias = modidx i (\e -> { e | name = s }) model.alias }, Cmd.none)
- AliasOrig i s -> (validate { model | alias = modidx i (\e -> { e | original = s }) model.alias }, Cmd.none)
- AliasMain n _ -> ({ model | aid = n }, Cmd.none)
- AliasAdd -> ({ model | alias = model.alias ++ [{ aid = newAid model, name = "", original = "", inuse = False, wantdel = False }] }, Cmd.none)
-
- Submit -> ({ model | state = Api.Loading }, GSE.send (encode model) Submitted)
- Submitted (GApi.Redirect s) -> (model, load s)
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-isValid : Model -> Bool
-isValid model = not (model.aliasDup || List.any (\l -> l.name == l.original) model.alias)
-
-
-view : Model -> Html Msg
-view model =
- let
- nameEntry n e =
- tr []
- [ td [ class "tc_id" ] [ inputRadio "main" (e.aid == model.aid) (AliasMain e.aid) ]
- , td [ class "tc_name" ] [ inputText "" e.name (AliasName n) GSE.valAliasName ]
- , td [ class "tc_original" ]
- [ inputText "" e.original (AliasOrig n) GSE.valAliasOriginal
- , if e.name /= "" && e.name == e.original then b [ class "standout" ] [ text "May not be the same as Name (romaji)" ] else text ""
- ]
- , td [ class "tc_add" ]
- [ if model.aid == e.aid then b [ class "grayedout" ] [ text " primary" ]
- else if e.wantdel then b [ class "standout" ] [ text " still referenced" ]
- else if e.inuse then b [ class "grayedout" ] [ text " referenced" ]
- else inputButton "remove" (AliasDel n) []
- ]
- ]
-
- names =
- table [ class "names" ] <|
- [ thead []
- [ tr []
- [ td [ class "tc_id" ] []
- , td [ class "tc_name" ] [ text "Name (romaji)" ]
- , td [ class "tc_original" ] [ text "Original" ]
- , td [] []
- ]
- ]
- ] ++ List.indexedMap nameEntry model.alias ++
- [ tr [ class "alias_new" ]
- [ td [] []
- , td [ colspan 3 ]
- [ if not model.aliasDup then text ""
- else b [ class "standout" ] [ text "The list contains duplicate aliases.", br_ 1 ]
- , a [ onClick AliasAdd ] [ text "Add alias" ]
- ]
- ]
- ]
-
- in
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox staffedit" ]
- [ h1 [] [ text "General info" ]
- , table [ class "formtable" ]
- [ formField "Names" [ names, br_ 1 ]
- , formField "desc::Biography" [ TP.view "desc" model.desc Desc 500 GSE.valDesc [ b [ class "standout" ] [ text "English please!" ] ] ]
- , formField "gender::Gender" [ inputSelect "gender" model.gender Gender []
- [ ("unknown", "Unknown or N/A")
- , ("f", "Female")
- , ("m", "Male")
- ] ]
- , formField "lang::Primary Language" [ inputSelect "lang" model.lang Lang [] GT.languages ]
- , formField "l_site::Official page" [ inputText "l_site" model.l_site Website (style "width" "400px" :: GSE.valL_Site) ]
- , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.l_wikidata LWikidata [] ]
- , formField "l_twitter::Twitter username" [ inputText "l_twitter" model.l_twitter LTwitter GSE.valL_Twitter ]
- , formField "l_anidb::AniDB Creator ID" [ inputText "l_anidb" (Maybe.withDefault "" (Maybe.map String.fromInt model.l_anidb)) LAnidb GSE.valL_Anidb ]
- , formField "l_pixiv::Pixiv ID" [ inputText "l_pixiv" (if model.l_pixiv == 0 then "" else String.fromInt model.l_pixiv) LPixiv GSE.valL_Pixiv ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
- ]
- ]
diff --git a/elm/Subscribe.elm b/elm/Subscribe.elm
deleted file mode 100644
index ca70a675..00000000
--- a/elm/Subscribe.elm
+++ /dev/null
@@ -1,99 +0,0 @@
-module Subscribe exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Lib.Html exposing (..)
-import Lib.Api as Api
-import Lib.DropDown exposing (onClickOutside)
-import Gen.Api as GApi
-import Gen.Subscribe as GS
-
-
-main : Program GS.Send Model Msg
-main = Browser.element
- { init = \e -> ({ state = Api.Normal, opened = False, data = e}, Cmd.none)
- , view = view
- , update = update
- , subscriptions = \m -> if m.opened then onClickOutside "subscribe" (Opened False) else Sub.none
- }
-
-type alias Model =
- { state : Api.State
- , opened : Bool
- , data : GS.Send
- }
-
-type Msg
- = Opened Bool
- | SubNum Bool Bool
- | SubReview Bool
- | SubApply Bool
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- let dat = model.data
- save nd = ({ model | data = nd, state = Api.Loading }, GS.send nd Submitted)
- in
- case msg of
- Opened b -> ({ model | opened = b }, Cmd.none)
- SubNum v b -> save { dat | subnum = if b then Just v else Nothing }
- SubReview b -> save { dat | subreview = b }
- SubApply b -> save { dat | subapply = b }
- Submitted e -> ({ model | state = if e == GApi.Success then Api.Normal else Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- let
- dat = model.data
- t = String.left 1 dat.id
- msg txt = p [] [ text txt, text " These can be disabled globally in your ", a [ href "/u/notifies" ] [ text "notification settings" ], text "." ]
- in
- div []
- [ a [ href "#", onClickD (Opened (not model.opened))
- , class (if (dat.noti > 0 && dat.subnum /= Just False) || dat.subnum == Just True || dat.subreview || dat.subapply then "active" else "inactive")
- ] [ text "🔔" ]
- , if not model.opened then text ""
- else div [] [ div []
- [ h4 []
- [ if model.state == Api.Loading then span [ class "spinner", style "float" "right" ] [] else text ""
- , text "Manage Notifications"
- ]
- , case (t, dat.noti) of
- ("t", 1) -> msg "You receive notifications for replies because you have posted in this thread."
- ("t", 2) -> msg "You receive notifications for replies because this thread is linked to your personal board."
- ("t", 3) -> msg "You receive notifications for replies because you have posted in this thread and it is linked to your personal board."
- ("w", 1) -> msg "You receive notifications for new comments because you have commented on this review."
- ("w", 2) -> msg "You receive notifications for new comments because this is your review."
- ("w", 3) -> msg "You receive notifications for new comments because this is your review and you have commented it."
- (_, 1) -> msg "You receive edit notifications for this entry because you have contributed to it."
- _ -> text ""
- , if dat.noti == 0 then text "" else
- label []
- [ inputCheck "" (dat.subnum == Just False) (SubNum False)
- , case t of
- "t" -> text " Disable notifications only for this thread."
- "w" -> text " Disable notifications only for this review."
- _ -> text " Disable edit notifications only for this entry."
- ]
- , if t == "i" then text "" else label []
- [ inputCheck "" (dat.subnum == Just True) (SubNum True)
- , case t of
- "t" -> text " Enable notifications for new replies"
- "w" -> text " Enable notifications for new comments"
- _ -> text " Enable notifications for new edits"
- , if dat.noti == 0 then text "." else text ", regardless of the global setting."
- ]
- , if t /= "v" then text "" else
- label [] [ inputCheck "" dat.subreview SubReview, text " Enable notifications for new reviews." ]
- , if t /= "i" then text "" else
- label [] [ inputCheck "" dat.subapply SubApply, text " Enable notifications when this trait is applied or removed from a character." ]
- , case model.state of
- Api.Error e -> b [ class "standout" ] [ br [] [], text (Api.showResponse e) ]
- _ -> text ""
- ] ]
- ]
diff --git a/elm/TableOpts.elm b/elm/TableOpts.elm
deleted file mode 100644
index 7dcfec2a..00000000
--- a/elm/TableOpts.elm
+++ /dev/null
@@ -1,118 +0,0 @@
-module TableOpts exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Bitwise as B
-import Lib.DropDown as DD
-import Lib.Api as Api
-import Lib.Html exposing (..)
-import Gen.Api as GApi
-import Gen.TableOptsSave as GTO
-
-
-main : Program GTO.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = \model -> DD.sub model.dd
- }
-
-type alias Model =
- { opts : GTO.Recv
- , state : Api.State
- , saved : Bool
- , dd : DD.Config Msg
- , view : Int
- , results : Int
- , asc : Bool
- , sort : Int
- , cols : Int
- }
-
-init : GTO.Recv -> Model
-init opts =
- { opts = opts
- , state = Api.Normal
- , saved = False
- , dd = DD.init "tableopts" Open
- , view = B.and 3 opts.value
- , results = B.and 7 (B.shiftRightBy 2 opts.value)
- , asc = B.and 32 opts.value == 0
- , sort = B.and 63 (B.shiftRightBy 6 opts.value)
- , cols = B.shiftRightBy 12 opts.value
- }
-
-
-type Msg
- = Open Bool
- | View Int Bool
- | Results Int Bool
- | Save
- | Saved GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Open b -> ({ model | saved = False, dd = DD.toggle model.dd b }, Cmd.none)
- View n _ -> ({ model | saved = False, view = n }, Cmd.none)
- Results n _ -> ({ model | saved = False, results = n }, Cmd.none)
- Save -> ( { model | saved = False, state = Api.Loading }
- , GTO.send { save = Maybe.withDefault "" model.opts.save
- , value = if encInt model == model.opts.default then Nothing else Just (encInt model)
- } Saved)
- Saved GApi.Success -> ({ model | saved = True, state = Api.Normal }, Cmd.none)
- Saved e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-encBase64Alpha : Int -> String
-encBase64Alpha n = String.slice n (n+1) "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-"
-
-encBase64 : Int -> String
-encBase64 n = (if n >= 64 then encBase64 (n//64) else "") ++ encBase64Alpha (modBy 64 n)
-
-encInt : Model -> Int
-encInt m =
- B.xor m.view
- <| B.xor (B.shiftLeftBy 2 m.results)
- <| B.xor (if m.asc then 0 else 32)
- <| B.xor (B.shiftLeftBy 6 m.sort)
- <| B.shiftLeftBy 12 m.cols
-
-view : Model -> Html Msg
-view model = div []
- [ if model.opts.save == Nothing && encInt model == model.opts.default
- then text ""
- else input [ type_ "hidden", name "s", value (encBase64 (encInt model)) ] []
- , DD.view model.dd Api.Normal
- (text "display options")
- (\_ -> [ table [ style "min-width" "300px" ]
- [ tr [] [ td [] [ text "Format" ], td [] -- TODO: Icons, or some sort of preview?
- [ linkRadio (model.view == 0) (View 0) [ text "Rows" ], text " / "
- , linkRadio (model.view == 1) (View 1) [ text "Cards" ], text " / "
- , linkRadio (model.view == 2) (View 2) [ text "Grid" ]
- ] ]
- , tr [] [ td [] [ text "Results" ], td []
- [ linkRadio (model.results == 1) (Results 1) [ text "10" ], text " / "
- , linkRadio (model.results == 2) (Results 2) [ text "25" ], text " / "
- , linkRadio (model.results == 0) (Results 0) [ text "50" ], text " / "
- , linkRadio (model.results == 3) (Results 3) [ text "100" ], text " / "
- , linkRadio (model.results == 4) (Results 4) [ text "200" ]
- ] ]
- , tr [] [ td [] [], td []
- [ input [ type_ "submit", class "submit", value "Update" ] []
- , case (model.opts.save, model.saved) of
- (_, True) -> text "Saved!"
- (Just _, _) -> inputButton "Save as default" Save []
- _ -> text ""
- , case model.state of
- Api.Normal -> text ""
- Api.Loading -> span [ class "spinner" ] []
- Api.Error e -> b [ class "standout" ] [ text <| Api.showResponse e ]
- ] ]
- ]
- ])
- ]
diff --git a/elm/TagEdit.elm b/elm/TagEdit.elm
deleted file mode 100644
index 2501f7b1..00000000
--- a/elm/TagEdit.elm
+++ /dev/null
@@ -1,237 +0,0 @@
-module TagEdit exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Lib.Util exposing (..)
-import Lib.Autocomplete as A
-import Lib.Ffi as Ffi
-import Gen.Api as GApi
-import Gen.Types exposing (tagCategories)
-import Gen.TagEdit as GTE
-
-
-main : Program GTE.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-
-type alias Model =
- { formstate : Api.State
- , id : Maybe Int
- , name : String
- , aliases : String
- , state : Int
- , cat : String
- , description : TP.Model
- , searchable : Bool
- , applicable : Bool
- , defaultspoil : Int
- , parents : List GTE.RecvParents
- , parentAdd : A.Model GApi.ApiTagResult
- , addedby : String
- , wipevotes : Bool
- , merge : List GTE.RecvParents
- , mergeAdd : A.Model GApi.ApiTagResult
- , canMod : Bool
- , dupNames : List GApi.ApiDupNames
- }
-
-
-init : GTE.Recv -> Model
-init d =
- { formstate = Api.Normal
- , id = d.id
- , name = d.name
- , aliases = String.join "\n" d.aliases
- , state = d.state
- , cat = d.cat
- , description = TP.bbcode d.description
- , searchable = d.searchable
- , applicable = d.applicable
- , defaultspoil = d.defaultspoil
- , parents = d.parents
- , parentAdd = A.init ""
- , addedby = d.addedby
- , wipevotes = False
- , merge = []
- , mergeAdd = A.init ""
- , canMod = d.can_mod
- , dupNames = []
- }
-
-
-splitAliases : String -> List String
-splitAliases l = String.lines l |> List.map String.trim |> List.filter (\s -> s /= "")
-
-findDup : Model -> String -> List GApi.ApiDupNames
-findDup model a = List.filter (\t -> String.toLower t.name == String.toLower a) model.dupNames
-
-isValid : Model -> Bool
-isValid model = not (List.any (findDup model >> List.isEmpty >> not) (model.name :: splitAliases model.aliases))
-
-parentConfig : A.Config Msg GApi.ApiTagResult
-parentConfig = { wrap = ParentSearch, id = "parentadd", source = A.tagSource }
-
-mergeConfig : A.Config Msg GApi.ApiTagResult
-mergeConfig = { wrap = MergeSearch, id = "mergeadd", source = A.tagSource }
-
-
-encode : Model -> GTE.Send
-encode m =
- { id = m.id
- , name = m.name
- , aliases = splitAliases m.aliases
- , state = m.state
- , cat = m.cat
- , description = m.description.data
- , searchable = m.searchable
- , applicable = m.applicable
- , defaultspoil = m.defaultspoil
- , parents = List.map (\l -> {id=l.id}) m.parents
- , wipevotes = m.wipevotes
- , merge = List.map (\l -> {id=l.id}) m.merge
- }
-
-
-type Msg
- = Name String
- | Aliases String
- | State Int
- | Searchable Bool
- | Applicable Bool
- | Cat String
- | DefaultSpoil Int
- | Description TP.Msg
- | ParentDel Int
- | ParentSearch (A.Msg GApi.ApiTagResult)
- | WipeVotes Bool
- | MergeDel Int
- | MergeSearch (A.Msg GApi.ApiTagResult)
- | Submit
- | Submitted (GApi.Response)
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Name s -> ({ model | name = s }, Cmd.none)
- Aliases s -> ({ model | aliases = String.replace "," "\n" s }, Cmd.none)
- State n -> ({ model | state = n }, Cmd.none)
- Searchable b -> ({ model | searchable = b }, Cmd.none)
- Applicable b -> ({ model | applicable = b }, Cmd.none)
- Cat s -> ({ model | cat = s }, Cmd.none)
- DefaultSpoil n-> ({ model | defaultspoil = n }, Cmd.none)
- WipeVotes b -> ({ model | wipevotes = b }, Cmd.none)
- Description m -> let (nm,nc) = TP.update m model.description in ({ model | description = nm }, Cmd.map Description nc)
-
- ParentDel i -> ({ model | parents = delidx i model.parents }, Cmd.none)
- ParentSearch m ->
- let (nm, c, res) = A.update parentConfig m model.parentAdd
- in case res of
- Nothing -> ({ model | parentAdd = nm }, c)
- Just p ->
- if List.any (\e -> e.id == p.id) model.parents
- then ({ model | parentAdd = nm }, c)
- else ({ model | parentAdd = A.clear nm "", parents = model.parents ++ [{ id = p.id, name = p.name}] }, c)
-
- MergeDel i -> ({ model | merge = delidx i model.merge }, Cmd.none)
- MergeSearch m ->
- let (nm, c, res) = A.update mergeConfig m model.mergeAdd
- in case res of
- Nothing -> ({ model | mergeAdd = nm }, c)
- Just p -> ({ model | mergeAdd = A.clear nm "", merge = model.merge ++ [{ id = p.id, name = p.name}] }, c)
-
- Submit -> ({ model | formstate = Api.Loading }, GTE.send (encode model) Submitted)
- Submitted (GApi.DupNames l) -> ({ model | dupNames = l, formstate = Api.Normal }, Cmd.none)
- Submitted (GApi.Redirect s) -> (model, load s)
- Submitted r -> ({ model | formstate = Api.Error r }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- form_ "" Submit (model.formstate == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text <| if model.id == Nothing then "Submit new tag" else "Edit tag" ]
- , table [ class "formtable" ] <|
- [ if model.id == Nothing then text "" else
- formField "Added by" [ span [ Ffi.innerHtml model.addedby ] [], br_ 2 ]
- , formField "name::Primary name" [ inputText "name" model.name Name GTE.valName ]
- , formField "aliases::Aliases"
- -- BUG: Textarea doesn't validate the maxlength and patterns for aliases, we don't have a client-side fallback check either.
- [ inputTextArea "aliases" model.aliases Aliases []
- , let dups = List.concatMap (findDup model) (model.name :: splitAliases model.aliases)
- in if List.isEmpty dups
- then span [] [ br [] [], text "Tag name and aliases must be unique and self-describing." ]
- else div []
- [ b [ class "standout" ] [ text "The following tag names are already present in the database:" ]
- , ul [] <| List.map (\t ->
- li [] [ a [ href ("/g"++String.fromInt t.id) ] [ text t.name ] ]
- ) dups
- ]
- ]
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
- , if not model.canMod then text "" else
- formField "state::State" [ inputSelect "state" model.state State GTE.valState
- [ (0, "Awaiting Moderation")
- , (1, "Deleted/hidden")
- , (2, "Approved")
- ]
- ]
- , if not model.canMod then text "" else
- formField "" [ label [] [ inputCheck "" model.searchable Searchable, text " Searchable (people can use this tag to find VNs)" ] ]
- , if not model.canMod then text "" else
- formField "" [ label [] [ inputCheck "" model.applicable Applicable, text " Applicable (people can apply this tag to VNs)" ] ]
- , formField "cat::Category" [ inputSelect "cat" model.cat Cat GTE.valCat tagCategories ]
- , formField "defaultspoil::Default spoiler level" [ inputSelect "defaultspoil" model.defaultspoil DefaultSpoil GTE.valDefaultspoil
- [ (0, "No spoiler")
- , (1, "Minor spoiler")
- , (2, "Major spoiler")
- ] ]
- , text "" -- aliases
- , formField "description::Description"
- [ TP.view "description" model.description Description 700 ([rows 12, cols 50] ++ GTE.valDescription) []
- , text "What should the tag be used for? Having a good description helps users choose which tags to link to a VN."
- ]
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
- , formField "Parent tags"
- [ table [ class "compact" ] <| List.indexedMap (\i p -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "g" ++ String.fromInt p.id ++ ":" ] ]
- , td [] [ a [ href <| "/g" ++ String.fromInt p.id ] [ text p.name ] ]
- , td [] [ inputButton "remove" (ParentDel i) [] ]
- ]
- ) model.parents
- , A.view parentConfig model.parentAdd [placeholder "Add parent tag..."]
- ]
- ]
- ++ if not model.canMod || model.id == Nothing then [] else
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "DANGER ZONE" ] ]
- , formField ""
- [ inputCheck "" model.wipevotes WipeVotes
- , text " Delete all direct votes on this tag. WARNING: cannot be undone!", br [] []
- , b [ class "grayedout" ] [ text "Does not affect votes on child tags. Old votes may still show up for 24 hours due to database caching." ]
- ]
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
- , formField "Merge votes"
- [ text "All direct votes on the listed tags will be moved to this tag. WARNING: cannot be undone!", br [] []
- , table [ class "compact" ] <| List.indexedMap (\i p -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "g" ++ String.fromInt p.id ++ ":" ] ]
- , td [] [ a [ href <| "/g" ++ String.fromInt p.id ] [ text p.name ] ]
- , td [] [ inputButton "remove" (MergeDel i) [] ]
- ]
- ) model.merge
- , A.view mergeConfig model.mergeAdd [placeholder "Add tag to merge..."]
- ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.formstate (isValid model) ] ]
- ]
diff --git a/elm/Tagmod.elm b/elm/Tagmod.elm
index c660a9aa..de82f77f 100644
--- a/elm/Tagmod.elm
+++ b/elm/Tagmod.elm
@@ -29,7 +29,9 @@ type alias Tag = GT.RecvTags
type Sel
= NoSel
| Vote Int
+ | Over
| Spoil (Maybe Int)
+ | Lie (Maybe Bool)
| Note
| NoteSet
@@ -41,7 +43,7 @@ type alias Model =
, tags : List Tag
, saved : List Tag
, changed : Bool
- , selId : Int
+ , selId : String
, selType : Sel
, negCount : Int
, negShow : Bool
@@ -59,7 +61,7 @@ init f =
, tags = f.tags
, saved = f.tags
, changed = False
- , selId = 0
+ , selId = ""
, selType = NoSel
, negCount = List.length <| List.filter (\t -> t.rating <= 0) f.tags
, negShow = False
@@ -73,11 +75,12 @@ searchConfig = { wrap = TagSearch, id = "tagadd", source = A.tagSource }
type Msg
= Noop
- | SetSel Int Sel
- | SetVote Int Int
- | SetOver Int Bool
- | SetSpoil Int (Maybe Int)
- | SetNote Int String
+ | 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
@@ -99,6 +102,7 @@ update msg model =
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)
@@ -108,16 +112,16 @@ update msg model =
Nothing -> ({ model | add = nm }, c)
Just t ->
let (nl, ms) =
- if t.state == 1 then ([], "Can't add deleted tags")
+ 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 = 2, spoil = Nothing, overrule = False, notes = "", cat = "new", name = t.name
- , rating = 0, count = 0, spoiler = 0, overruled = False, othnotes = "", state = t.state, applicable = t.applicable }], "")
+ 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, overrule = t.overrule, notes = t.notes }) model.tags } Submitted)
+ , 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)
@@ -128,80 +132,98 @@ viewTag t sel vid mod =
let
-- Similar to VNWeb::Tags::Lib::tagscore_
tagscore s =
- div [ class "tagscore", classList [("negative", s < 0)] ]
+ 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 <| "/g"++String.fromInt t.id, style "text-decoration" (if t.applicable && t.state /= 1 then "none" else "line-through") ] [ text t.name ]
- , case (t.state, t.applicable) of
- (0, _) -> b [ class "grayedout" ] [ text " (awaiting approval)" ]
- (1, _) -> b [ class "grayedout" ] [ text " (deleted)" ]
- (_, False) -> b [ class "grayedout" ] [ text " (not applicable)" ]
+ [ 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 0 NoSel), onClickD (SetVote t.id -3), classList [("ld", vote < 0)], title "Downvote" ] []
- , a [ href "#", onMouseOver (SetSel t.id (Vote 0)), onMouseOut (SetSel 0 NoSel), onClickD (SetVote t.id 0), classList [("l0", vote == 0)], title "Remove vote" ] []
- , a [ href "#", onMouseOver (SetSel t.id (Vote 1)), onMouseOut (SetSel 0 NoSel), onClickD (SetVote t.id 1), classList [("l1", vote >= 1)], title "+1" ] []
- , a [ href "#", onMouseOver (SetSel t.id (Vote 2)), onMouseOut (SetSel 0 NoSel), onClickD (SetVote t.id 2), classList [("l2", vote >= 2)], title "+2" ] []
- , a [ href "#", onMouseOver (SetSel t.id (Vote 3)), onMouseOut (SetSel 0 NoSel), onClickD (SetVote t.id 3), classList [("l3", vote == 3)], title "+3" ] []
+ [ 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" ] []
]
- , td [ class "tc_myover" ] [ if mod && t.vote /= 0 then inputCheck "" t.overrule (SetOver t.id) else text "" ]
+ ] ++ (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 0 NoSel), onClickD (SetSpoil t.id Nothing), classList [("sn", spoil == Nothing)], title "Unknown" ] []
- , a [ href "#", onMouseOver (SetSel t.id (Spoil (Just 0))), onMouseOut (SetSel 0 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 0 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 0 NoSel), onClickD (SetSpoil t.id (Just 2)), classList [("s2", spoil == Just 2 )], title "Major spoiler" ] []
+ [ 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 0 NoSel)
+ , 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 -> [ td [ colspan 3 ] [ text "Remove vote" ] ]
- Vote 1 -> [ td [ colspan 3 ] [ text "Vote +1" ] ]
- Vote 2 -> [ td [ colspan 3 ] [ text "Vote +2" ] ]
- Vote 3 -> [ td [ colspan 3 ] [ text "Vote +3" ] ]
- Vote _ -> [ td [ colspan 3 ] [ text "Downvote (-3)" ] ]
- Spoil Nothing -> [ td [ colspan 3 ] [ text "Spoiler status not known" ] ]
- Spoil (Just 0) -> [ td [ colspan 3 ] [ text "This is not spoiler" ] ]
- Spoil (Just 1) -> [ td [ colspan 3 ] [ text "This is a minor spoiler" ] ]
- Spoil (Just 2) -> [ td [ colspan 3 ] [ text "This is a major spoiler" ] ]
- Note -> [ td [ colspan 3 ] [ if t.notes == "" then text "Set note" else div [ class "noteview" ] [ text t.notes ] ] ]
+ 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 3, class "compact" ]
+ [ 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 3 ] [] ]
+ 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 b [ class "standout", style "font-weight" "bold", title "Tag overruled. All votes other than that of the moderator who overruled it will be ignored." ] [ 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="++String.fromInt t.id ] [ text "Who?" ]
+ , a [ href <| "/g/links?v="++vid++"&t="++t.id ] [ text "Who?" ]
]
]
@@ -212,30 +234,32 @@ viewHead mod negCount negShow =
[ 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 4, class "tc_you" ] [ text "You" ]
- , td [ colspan 3, class "tc_others" ] [ text "Others" ]
+ , 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 8 ]
+ 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 [ class "standout" ] [ text addMsg ]
+ then b [] [ text addMsg ]
else if changed
- then b [ class "standout" ] [ text "You have unsaved changes" ]
+ then b [] [ text "You have unsaved changes" ]
else text ""
, submitButton "Save changes" state True
]
@@ -249,7 +273,7 @@ viewFoot state changed add addMsg =
view : Model -> Html Msg
view model =
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ 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."
@@ -267,7 +291,7 @@ view model =
in
if List.length lst == 0
then []
- else tr [class "tagmod_cat"] [ td [] [text nam], td [ class "tc_you", colspan 4 ] [], td [ class "tc_others", colspan 3 ] [] ]
+ 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")
diff --git a/elm/TraitEdit.elm b/elm/TraitEdit.elm
index 6b257ce1..14b9d263 100644
--- a/elm/TraitEdit.elm
+++ b/elm/TraitEdit.elm
@@ -10,6 +10,7 @@ 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
@@ -24,11 +25,11 @@ main = Browser.element
type alias Model =
- { formstate : Api.State
- , id : Maybe Int
+ { state : Api.State
+ , editsum : Editsum.Model
+ , id : Maybe String
, name : String
, alias : String
- , state : Int
, sexual : Bool
, description : TP.Model
, searchable : Bool
@@ -36,20 +37,18 @@ type alias Model =
, defaultspoil : Int
, parents : List GTE.RecvParents
, parentAdd : A.Model GApi.ApiTraitResult
- , order : Int
- , addedby : String
- , canMod : Bool
+ , gorder : Int
, dupNames : List GApi.ApiDupNames
}
init : GTE.Recv -> Model
init d =
- { formstate = Api.Normal
+ { 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
- , state = d.state
, sexual = d.sexual
, description = TP.bbcode d.description
, searchable = d.searchable
@@ -57,9 +56,7 @@ init d =
, defaultspoil = d.defaultspoil
, parents = d.parents
, parentAdd = A.init ""
- , order = d.order
- , addedby = d.addedby
- , canMod = d.can_mod
+ , gorder = d.gorder
, dupNames = []
}
@@ -80,28 +77,31 @@ 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
- , state = m.state
, sexual = m.sexual
, description = m.description.data
, searchable = m.searchable
, applicable = m.applicable
, defaultspoil = m.defaultspoil
- , parents = List.map (\l -> {id=l.id}) m.parents
- , order = m.order
+ , parents = List.map (\l -> {parent=l.parent, main=l.main}) m.parents
+ , gorder = m.gorder
}
type Msg
= Name String
| Alias String
- | State Int
| 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
@@ -114,39 +114,41 @@ update msg model =
case msg of
Name s -> ({ model | name = s }, Cmd.none)
Alias s -> ({ model | alias = String.replace "," "\n" s }, Cmd.none)
- State n -> ({ model | state = n }, Cmd.none)
Searchable b -> ({ model | searchable = b }, Cmd.none)
Applicable b -> ({ model | applicable = b }, Cmd.none)
Sexual b -> ({ model | sexual = b }, Cmd.none)
DefaultSpoil n-> ({ model | defaultspoil = n }, Cmd.none)
- Order s -> ({ model | order = Maybe.withDefault 0 (String.toInt s) }, Cmd.none)
+ 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)
- ParentDel i -> ({ model | parents = delidx i model.parents }, Cmd.none)
+ 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.id == p.id) model.parents
+ if List.any (\e -> e.parent == p.id) model.parents
then ({ model | parentAdd = nm }, c)
- else ({ model | parentAdd = A.clear nm "", parents = model.parents ++ [{ id = p.id, name = p.name, group = p.group_name }] }, c)
+ 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 | formstate = Api.Loading }, GTE.send (encode model) Submitted)
- Submitted (GApi.DupNames l) -> ({ model | dupNames = l, formstate = Api.Normal }, Cmd.none)
+ 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 | formstate = Api.Error r }, Cmd.none)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
view : Model -> Html Msg
view model =
- form_ "" Submit (model.formstate == Api.Loading)
- [ div [ class "mainbox" ]
+ form_ "" Submit (model.state == Api.Loading)
+ [ article []
[ h1 [] [ text <| if model.id == Nothing then "Submit new trait" else "Edit trait" ]
, table [ class "formtable" ]
- [ if model.id == Nothing then text "" else
- formField "Added by" [ span [ Ffi.innerHtml model.addedby ] [], br_ 2 ]
- , formField "name::Primary name" [ inputText "name" model.name Name GTE.valName ]
+ [ formField "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 []
@@ -154,26 +156,17 @@ view model =
in if List.isEmpty dups
then span [] [ br [] [], text "Trait name and aliases must be self-describing and unique within the same group." ]
else div []
- [ b [ class "standout" ] [ text "The following trait names are already present in the same group:" ]
+ [ b [] [ text "The following trait names are already present in the same group:" ]
, ul [] <| List.map (\t ->
- li [] [ a [ href ("/i"++String.fromInt t.id) ] [ text t.name ] ]
+ li [] [ a [ href ("/"++t.id) ] [ text t.name ] ]
) dups
]
]
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
- , if not model.canMod then text "" else
- formField "state::State" [ inputSelect "state" model.state State GTE.valState
- [ (0, "Awaiting Moderation")
- , (1, "Deleted/hidden")
- , (2, "Approved")
- ]
- ]
- , if not model.canMod then text "" else
- formField "" [ label [] [ inputCheck "" model.searchable Searchable, text " Searchable (people can use this trait to find characters)" ] ]
- , if not model.canMod then text "" else
- formField "" [ label [] [ inputCheck "" model.applicable Applicable, text " Applicable (people can apply this trait to characters)" ] ]
+ , formField "" [ label [] [ inputCheck "" model.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
+ , formField "defaultspoil::Default spoiler level" [ inputSelect "defaultspoil" model.defaultspoil DefaultSpoil GTE.valDefaultspoil
[ (0, "No spoiler")
, (1, "Minor spoiler")
, (2, "Major spoiler")
@@ -186,11 +179,12 @@ view model =
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "Parent traits"
[ table [ class "compact" ] <| List.indexedMap (\i p -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "i" ++ String.fromInt p.id ++ ":" ] ]
+ [ td [ style "text-align" "right" ] [ small [] [ text <| p.parent ++ ":" ] ]
, td []
- [ Maybe.withDefault (text "") <| Maybe.map (\g -> b [ class "grayedout" ] [ text (g ++ " / ") ]) p.group
- , a [ href <| "/i" ++ String.fromInt p.id ] [ text p.name ]
+ [ 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
@@ -198,12 +192,14 @@ view model =
]
, if not (List.isEmpty model.parents) then text "" else
formField "order::Group order"
- [ inputText "order" (String.fromInt model.order) Order (style "width" "50px" :: GTE.valOrder)
- , text " Only meaningful if this trait is as a \"group\", i.e. a trait without any parents."
+ [ 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."
]
]
]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.formstate (isValid model) ] ]
+ , article [ class "submit" ]
+ [ Html.map Editsum (Editsum.view model.editsum)
+ , submitButton "Submit" model.state (isValid model)
+ ]
]
diff --git a/elm/UList/DateEdit.elm b/elm/UList/DateEdit.elm
index d20dbba7..72f1b87d 100644
--- a/elm/UList/DateEdit.elm
+++ b/elm/UList/DateEdit.elm
@@ -1,4 +1,4 @@
-module UList.DateEdit exposing (main)
+module UList.DateEdit exposing (main,init,view,update,Model,Msg)
import Html exposing (..)
import Html.Attributes exposing (..)
@@ -76,7 +76,7 @@ view : Model -> Html Msg
view model = div (class "compact" :: if model.visible then [] else [onMouseOver Show]) <|
case model.state of
Api.Loading -> [ span [ class "spinner" ] [] ]
- Api.Error _ -> [ b [ class "standout" ] [ text "error" ] ] -- Argh
+ Api.Error _ -> [ b [] [ text "error" ] ] -- Argh
Api.Normal ->
[ if model.visible
then input ([ type_ "date", class "text", value model.val, onInputValidation Val, onBlur (Save model.debnum), placeholder "yyyy-mm-dd" ] ++ GDE.valDate) []
diff --git a/elm/UList/LabelEdit.elm b/elm/UList/LabelEdit.elm
index d2877cf0..153fad8c 100644
--- a/elm/UList/LabelEdit.elm
+++ b/elm/UList/LabelEdit.elm
@@ -12,6 +12,7 @@ import Lib.Html exposing (..)
import Lib.Api as Api
import Lib.DropDown as DD
import Gen.Api as GApi
+import Gen.UListLabelAdd as GLA
import Gen.UListLabelEdit as GLE
@@ -33,22 +34,29 @@ type alias Model =
, tsel : Set Int -- Set of label IDs applied on the client
, state : Dict Int Api.State -- Only for labels that are being changed
, dd : DD.Config Msg
+ , custom : String
+ , customSt : Api.State
}
init : GLE.Recv -> Model
init f =
{ uid = f.uid
, vid = f.vid
- , labels = f.labels
+ , labels = List.filter (\l -> l.id > 0) f.labels
, sel = Set.fromList f.selected
, tsel = Set.fromList f.selected
, state = Dict.empty
, dd = DD.init ("ulist_labeledit_dd" ++ f.vid) Open
+ , custom = ""
+ , customSt = Api.Normal
}
type Msg
= Open Bool
| Toggle Int Bool Bool
+ | Custom String
+ | CustomSubmit
+ | CustomSaved GApi.Response
| Saved Int Bool GApi.Response
@@ -69,10 +77,21 @@ update msg model =
GLE.send { uid = model.uid, vid = model.vid, label = l, applied = b } (Saved l b)
-- Unselect other progress labels (1..4) when setting a progress label
:: if cascade
- then (List.map (\i -> selfCmd (Toggle i False False)) <| List.filter (\i -> l >= 0 && l <= 4 && i >= 0 && i <= 4 && i /= l) <| Set.toList model.tsel)
+ then (List.map (\i -> selfCmd (Toggle i False False)) <| List.filter (\i -> l >= 1 && l <= 4 && i >= 1 && i <= 4 && i /= l) <| Set.toList model.tsel)
else []
)
+ Custom t -> ({ model | custom = t }, Cmd.none)
+ CustomSubmit -> ({ model | customSt = Api.Loading }, GLA.send { uid = model.uid, vid = model.vid, label = model.custom } CustomSaved)
+ CustomSaved (GApi.LabelId id) ->
+ let new = List.filter (\l -> l.id == id) model.labels |> List.isEmpty
+ in ({ model | labels = if new then model.labels ++ [{ id = id, label = model.custom, private = True }] else model.labels
+ , customSt = Api.Normal, custom = ""
+ , sel = Set.insert id model.sel
+ , tsel = Set.insert id model.tsel
+ }, Cmd.none)
+ CustomSaved e -> ({ model | customSt = Api.Error e }, Cmd.none)
+
Saved l b (GApi.Success) ->
let nmodel = { model | sel = if b then Set.insert l model.sel else Set.remove l model.sel, state = Dict.remove l model.state }
in (nmodel, ulistLabelChanged (isPublic nmodel))
@@ -82,21 +101,34 @@ update msg model =
view : Model -> String -> Html Msg
view model txt =
let
- str = String.join ", " <| List.filterMap (\l -> if l.id /= 7 && Set.member l.id model.sel then Just l.label else Nothing) model.labels
+ lbl = List.intersperse (text ", ") <| List.filterMap (\l ->
+ if l.id /= 7 && Set.member l.id model.sel
+ then Just <| span []
+ [ if l.id <= 6 && txt /= "-" then ulistIcon l.id l.label else text ""
+ , text (" " ++ l.label) ]
+ else Nothing) model.labels
item l =
li [ ]
[ linkRadio (Set.member l.id model.tsel) (Toggle l.id True)
[ text l.label
, text " "
- , span [ class "spinner", classList [("invisible", Dict.get l.id model.state /= Just Api.Loading)] ] []
, case Dict.get l.id model.state of
- Just (Api.Error _) -> b [ class "standout" ] [ text "error" ] -- Need something better
- _ -> text ""
+ Just Api.Loading -> span [ class "spinner" ] []
+ Just (Api.Error _) -> b [] [ text "error" ] -- Need something better
+ _ -> if l.id <= 6 then ulistIcon l.id l.label else text ""
]
]
+
+ custom =
+ li [] [
+ case model.customSt of
+ Api.Normal -> Html.form [ onSubmit CustomSubmit ]
+ [ inputText "" model.custom Custom ([placeholder "new label", style "width" "150px"] ++ GLA.valLabel) ]
+ Api.Loading -> span [ class "spinner" ] []
+ Api.Error _ -> b [] [ text "error" ] ]
in
DD.view model.dd
(if List.any (\s -> s == Api.Loading) <| Dict.values model.state then Api.Loading else Api.Normal)
- (text <| if str == "" then txt else str)
- (\_ -> [ ul [] <| List.map item <| List.filter (\l -> l.id /= 7) model.labels ])
+ (if List.isEmpty lbl then text txt else span [] lbl)
+ (\_ -> [ ul [] <| List.map item (List.filter (\l -> l.id /= 7) model.labels) ++ [ custom ] ])
diff --git a/elm/UList/LabelEdit.js b/elm/UList/LabelEdit.js
deleted file mode 100644
index 156ae08f..00000000
--- a/elm/UList/LabelEdit.js
+++ /dev/null
@@ -1,10 +0,0 @@
-wrap_elm_init('UList.LabelEdit', function(init, opt) {
- opt.flags.uid = pageVars.uid;
- opt.flags.labels = pageVars.labels;
- var app = init(opt);
- app.ports.ulistLabelChanged.subscribe(function(pub) {
- var l = document.getElementById('ulist_public_'+opt.flags.vid);
- l.setAttribute('data-publabel', pub?1:'');
- l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
- });
-});
diff --git a/elm/UList/ManageLabels.elm b/elm/UList/ManageLabels.elm
index 2db1b68d..8a5533d7 100644
--- a/elm/UList/ManageLabels.elm
+++ b/elm/UList/ManageLabels.elm
@@ -34,7 +34,7 @@ init : GML.Send -> Model
init d =
{ uid = d.uid
, state = Api.Normal
- , labels = d.labels
+ , labels = List.filter (\l -> l.id > 0) d.labels
, editing = Nothing
}
@@ -76,8 +76,8 @@ view model =
]
, td [ ] [ linkRadio l.private (Private n) [ text "private" ] ]
, td [ class "stealth" ]
- [ if l.id == 7 then b [ class "grayedout" ] [ text "applied when you vote" ]
- else if l.id > 0 && l.id < 10 then b [ class "grayedout" ] [ text "built-in" ]
+ [ if l.id == 7 then small [] [ text "applied when you vote" ]
+ else if l.id > 0 && l.id < 10 then small [] [ text "built-in" ]
else if l.delete == Nothing then a [ onClick (Delete n (Just 1)) ] [ text "remove" ]
else inputSelect "" l.delete (Delete n) []
[ (Nothing, "Keep label")
@@ -92,7 +92,7 @@ view model =
in
Html.form [ onSubmit Submit, class "managelabels hidden" ]
[ div [ ]
- [ b [] [ text "How to use labels" ]
+ [ strong [] [ text "How to use labels" ]
, ul []
[ li [] [ text "You can assign multiple labels to a visual novel" ]
, li [] [ text "You can create custom labels or just use the built-in labels" ]
@@ -110,14 +110,14 @@ view model =
, tfoot []
[ if List.any (\l -> l.id == 7 && l.private) model.labels && List.any (\l -> not l.private) model.labels
then tr [] [ td [ colspan 4 ]
- [ b [ class "standout" ] [ text "WARNING: " ]
+ [ b [] [ text "WARNING: " ]
, text "Your vote is still public if you assign a non-private label to the visual novel."
] ]
else text ""
, tr []
[ td [] []
, td [ colspan 3 ]
- [ a [ onClick Add ] [ text "New label" ]
+ [ if List.length model.labels < 500 then inputButton "New label" Add [] else text ""
, submitButton "Save changes" model.state (not hasDup)
]
]
diff --git a/elm/UList/ManageLabels.js b/elm/UList/ManageLabels.js
deleted file mode 100644
index 3ff2db61..00000000
--- a/elm/UList/ManageLabels.js
+++ /dev/null
@@ -1,4 +0,0 @@
-wrap_elm_init('UList.ManageLabels', function(init, opt) {
- opt.flags = { uid: pageVars.uid, labels: pageVars.labels };
- init(opt);
-});
diff --git a/elm/UList/Opt.elm b/elm/UList/Opt.elm
index 9812a725..e909f2d8 100644
--- a/elm/UList/Opt.elm
+++ b/elm/UList/Opt.elm
@@ -75,10 +75,6 @@ type Msg
| RelAdd String
-showrel : GApi.ApiReleases -> String
-showrel r = "[" ++ (RDate.format (RDate.expand r.released)) ++ " " ++ (String.join "," r.lang) ++ "] " ++ r.title ++ " (" ++ r.id ++ ")"
-
-
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
@@ -128,7 +124,7 @@ update msg model =
( { model
| relState = Api.Normal
, relNfo = Dict.union (Dict.fromList <| List.map (\r -> (r.id, r)) rels) model.relNfo
- , relOptions = Just <| List.map (\r -> (r.id, showrel r)) rels
+ , relOptions = Just <| List.map (\r -> (r.id, RDate.showrel r)) rels
}, Cmd.none)
RelLoaded e -> ({ model | relState = Api.Error e }, Cmd.none)
RelAdd rid ->
@@ -156,7 +152,7 @@ view model =
else []
) ++ (
case model.notesState of
- Api.Error e -> [ br [] [], b [ class "standout" ] [ text <| Api.showResponse e ] ]
+ Api.Error e -> [ br [] [], b [] [ text <| Api.showResponse e ] ]
_ -> []
)
]
@@ -173,7 +169,7 @@ view model =
<| ("", "-- add release --") :: List.filter (\(rid,_) -> not <| List.any (\r -> r.rid == rid) model.rels) opts ]
(_, Api.Normal) -> []
(_, Api.Loading) -> [ span [ class "spinner" ] [], text "Loading releases..." ]
- (_, Api.Error e) -> [ b [ class "standout" ] [ text <| Api.showResponse e ], text ". ", a [ href "#", onClickD RelLoad ] [ text "Try again" ] ]
+ (_, Api.Error e) -> [ b [] [ text <| Api.showResponse e ], text ". ", a [ href "#", onClickD RelLoad ] [ text "Try again" ] ]
]
]
]
@@ -191,7 +187,7 @@ view model =
<| List.map platformIcon nfo.platforms
++ List.map langIcon nfo.lang
++ [ releaseTypeIcon nfo.rtype ]
- , td [ class "tco4" ] [ a [ href ("/"++nfo.id), title nfo.original ] [ text nfo.title ] ]
+ , td [ class "tco4" ] [ a [ href ("/"++nfo.id), title nfo.alttitle ] [ text nfo.title ] ]
]
confirm =
@@ -206,4 +202,4 @@ view model =
(False, _) -> table [] <| (if model.flags.own then opt else []) ++ List.map rel model.rels
(_, Api.Normal) -> confirm
(_, Api.Loading) -> div [ class "spinner" ] []
- (_, Api.Error e) -> b [ class "standout" ] [ text <| "Error removing item: " ++ Api.showResponse e ]
+ (_, Api.Error e) -> b [] [ text <| "Error removing item: " ++ Api.showResponse e ]
diff --git a/elm/UList/Opt.js b/elm/UList/Opt.js
deleted file mode 100644
index 7a80884a..00000000
--- a/elm/UList/Opt.js
+++ /dev/null
@@ -1,34 +0,0 @@
-var actualInit = function(init, opt) {
- var app = init(opt);
-
- app.ports.ulistVNDeleted.subscribe(function(b) {
- var e = document.getElementById('ulist_tr_'+opt.flags.vid);
- e.parentNode.removeChild(e.nextElementSibling);
- e.parentNode.removeChild(e);
-
- // Have to restripe after deletion :(
- var rows = document.querySelectorAll('.ulist > table > tbody > tr');
- for(var i=0; i<rows.length; i++)
- rows[i].classList.toggle('odd', Math.floor(i/2) % 2 == 0);
- });
-
- app.ports.ulistNotesChanged.subscribe(function(n) {
- document.getElementById('ulist_notes_'+opt.flags.vid).innerText = n;
- });
-
- app.ports.ulistRelChanged.subscribe(function(rels) {
- var e = document.getElementById('ulist_relsum_'+opt.flags.vid);
- e.classList.toggle('todo', rels[0] != rels[1]);
- e.classList.toggle('done', rels[1] > 0 && rels[0] == rels[1]);
- e.innerText = rels[0] + '/' + rels[1];
- });
-};
-
-// This module is typically hidden, lazily load it only when the module is visible to speed up page load time.
-wrap_elm_init('UList.Opt', function(init, opt) {
- var e = document.getElementById('collapse_vid'+opt.flags.vid);
- if(e.checked)
- actualInit(init, opt);
- else
- e.addEventListener('click', function() { actualInit(init, opt) }, { once: true });
-});
diff --git a/elm/UList/SaveDefault.elm b/elm/UList/SaveDefault.elm
index 7eed76e0..cf7ab13b 100644
--- a/elm/UList/SaveDefault.elm
+++ b/elm/UList/SaveDefault.elm
@@ -58,7 +58,7 @@ view : Model -> Html Msg
view model =
form_ "" Submit (model.state == Api.Loading)
[ div [ classList [("savedefault", True), ("hidden", model.hid)] ]
- [ b [] [ text "Save as default" ]
+ [ strong [] [ text "Save as default" ]
, br [] []
, text "This will change the default label selection, visible columns and table sorting options for the selected page to the currently applied settings."
, text " The saved view will also apply to users visiting your lists."
diff --git a/elm/UList/VNPage.elm b/elm/UList/VNPage.elm
index 53921388..63a1136d 100644
--- a/elm/UList/VNPage.elm
+++ b/elm/UList/VNPage.elm
@@ -1,172 +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 Browser.Dom exposing (focus)
import Task
-import Process
-import Set
+import Date
import Lib.Html exposing (..)
import Lib.Util exposing (..)
import Lib.Api as Api
import Lib.DropDown as DD
-import Gen.Api as GApi
+import Gen.UListWidget as GUW
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.Widget as UW
-main : Program GVN.VNPage Model Msg
+main : Program GUW.Recv UW.Model UW.Msg
main = Browser.element
- { init = \f -> (init f, Cmd.none)
- , subscriptions = \model -> Sub.batch [ Sub.map Labels (DD.sub model.labels.dd), Sub.map Vote (DD.sub model.vote.dd) ]
+ { init = \f -> (UW.init f, Date.today |> Task.perform UW.Today)
+ , subscriptions = \m -> Sub.batch
+ [ Sub.map UW.Label (DD.sub m.labels.dd)
+ , Sub.map UW.Vote (DD.sub m.vote.dd) ]
, view = view
- , update = update
+ , update = UW.update
}
-type alias Model =
- { flags : GVN.VNPage
- , onlist : Bool
- , del : Bool
- , state : Api.State -- For adding/deleting; Vote and label edit widgets have their own state
- , labels : LE.Model
- , vote : VE.Model
- , notes : String
- , notesRev : Int
- , notesState : Api.State
- , notesVis : Bool
- }
-
-init : GVN.VNPage -> Model
-init f =
- { flags = f
- , onlist = f.onlist
- , del = False
- , state = Api.Normal
- , labels = LE.init { uid = f.uid, vid = f.vid, labels = f.labels, selected = f.selected }
- , vote = VE.init { uid = f.uid, vid = f.vid, vote = f.vote }
- , notes = f.notes
- , notesRev = 0
- , notesState = Api.Normal
- , notesVis = f.notes /= ""
- }
-
-type Msg
- = Noop
- | Labels LE.Msg
- | Vote VE.Msg
- | NotesToggle
- | Notes String
- | NotesSave Int
- | NotesSaved Int GApi.Response
- | Del Bool
- | Delete
- | Deleted GApi.Response
-
-
-setOnList : Model -> Model
-setOnList model = { model | onlist = model.onlist || model.vote.ovote /= Nothing || not (Set.isEmpty model.labels.sel) || model.notes /= "" }
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Noop -> (model, Cmd.none)
- Labels m -> let (nm, cmd) = LE.update m model.labels in (setOnList { model | labels = nm}, Cmd.map Labels cmd)
- Vote m -> let (nm, cmd) = VE.update m model.vote in (setOnList { model | vote = nm}, Cmd.map Vote cmd)
-
- NotesToggle ->
- ( { model | notesVis = not model.notesVis }
- , if model.notesVis then Cmd.none else Task.attempt (always Noop) (focus "uvn_notes"))
- Notes s ->
- if s == model.notes then (model, Cmd.none)
- else ( { 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 (setOnList {model | flags = nf, notesState = Api.Normal }, Cmd.none)
- NotesSaved _ e -> ({ model | notesState = Api.Error e }, Cmd.none)
-
- Del b -> ({ model | del = b }, Cmd.none)
- Delete -> ({ model | state = Api.Loading }, GDE.send { uid = model.flags.uid, vid = model.flags.vid } Deleted)
- Deleted GApi.Success ->
- ( { model
- | state = Api.Normal, onlist = False, del = False
- , labels = LE.init { uid = model.flags.uid, vid = model.flags.vid, labels = model.flags.labels, selected = [] }
- , vote = VE.init { uid = model.flags.uid, vid = model.flags.vid, vote = Nothing }
- , notes = "", notesVis = False
- }
- , Cmd.none)
- Deleted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-isPublic : Model -> Bool
-isPublic model =
- LE.isPublic model.labels
- || (isJust model.vote.vote && List.any (\l -> l.id == 7 && not l.private) model.labels.labels)
-
-
-view : Model -> Html Msg
+view : UW.Model -> Html UW.Msg
view model =
- let canVote = model.flags.canvote || (Maybe.withDefault "-" model.flags.vote /= "-")
- notesBut =
- [ a [ href "#", onClickD NotesToggle ] [ text "💬" ]
+ 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 [ class "standout" ] [ text <| Api.showResponse e ]
+ Api.Error e -> b [] [ text <| Api.showResponse e ]
_ -> text ""
]
in
div [ class "ulistvn elm_dd_input" ]
- [ span [] <|
- case (model.state, model.del, model.onlist) of
- (Api.Loading, _, _) -> [ span [ class "spinner" ] [] ]
- (Api.Error e, _, _) -> [ b [ class "standout" ] [ text <| Api.showResponse e ] ]
- (Api.Normal, _, False) -> [ b [ class "grayedout" ] [ text "not on your list" ] ]
- (Api.Normal, True, _) ->
- [ a [ onClickD Delete ] [ text "Yes, delete" ]
- , text " | "
- , a [ onClickD (Del False) ] [ text "Cancel" ]
- ]
- (Api.Normal, False, True) ->
- [ span [ classList [("hidden", not (isPublic model))], title "This visual novel is on your public list" ] [ text "👁 " ]
- , text "On your list | "
- , a [ onClickD (Del True) ] [ text "Remove from list" ]
- ]
- , b [] [ text "User options" ]
- , table [ style "margin" "4px 0 0 0" ]
+ [ 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 canVote then 2 else 1) ] [ Html.map Labels (LE.view model.labels "- select label -") ]
- , if canVote then text "" else td [] notesBut
+ , 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 canVote
+ , if model.canvote
then tr [ class "nostripe compact" ]
[ td [] [ text "My vote" ]
- , td [ style "width" "80px" ] [ Html.map Vote (VE.view model.vote "- vote -") ]
- , td [] <| notesBut ++
- [ case (model.vote.vote /= Nothing && model.flags.canreview, model.flags.review) of
- (False, _) -> text ""
- (True, Nothing) -> a [ href ("/" ++ model.flags.vid ++ "/addreview") ] [ text " write a review »" ]
- (True, Just w) -> a [ href ("/" ++ w ++ "/edit") ] [ text " edit review »" ]
- ]
- ]
- else text ""
- , if model.notesVis
- then tr [ class "nostripe compact" ]
- [ td [] [ text "Notes" ]
- , td [ colspan 2 ]
- [ textarea ([ id "uvn_notes", placeholder "Notes", rows 2, cols 30, onInput Notes, onBlur (NotesSave model.notesRev)] ++ GVN.valNotes) [ text model.notes ] ]
+ , 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.js b/elm/UList/VoteEdit.js
deleted file mode 100644
index a7ebfb74..00000000
--- a/elm/UList/VoteEdit.js
+++ /dev/null
@@ -1,8 +0,0 @@
-wrap_elm_init('UList.VoteEdit', function(init, opt) {
- var app = init(opt);
- app.ports.ulistVoteChanged.subscribe(function(voted) {
- var l = document.getElementById('ulist_public_'+opt.flags.vid);
- l.setAttribute('data-voted', voted?1:'');
- l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
- });
-});
diff --git a/elm/UList/Widget.elm b/elm/UList/Widget.elm
new file mode 100644
index 00000000..ac5e0d70
--- /dev/null
+++ b/elm/UList/Widget.elm
@@ -0,0 +1,316 @@
+-- This module provides a ulist management widget. By default it shows as a
+-- small icon indicating the list status, which can be clicked on to open a
+-- full management modal for the VN.
+--
+-- It is also used by UList.VNPage to provide a different view for essentially
+-- the same functionality.
+module UList.Widget exposing (Model, Msg(..), main, init, update, viewStatus, viewReviewLink)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Browser.Dom exposing (focus)
+import Task
+import Process
+import Set
+import Date
+import Dict exposing (Dict)
+import Lib.Util exposing (..)
+import Lib.Html exposing (..)
+import Lib.Ffi as Ffi
+import Lib.Api as Api
+import Lib.RDate as RDate
+import Lib.DropDown as DD
+import Gen.Api as GApi
+import Gen.UListWidget as UW
+import Gen.UListVNNotes as GVN
+import Gen.UListDel as GDE
+import UList.LabelEdit as LE
+import UList.VoteEdit as VE
+import UList.DateEdit as DE
+import UList.ReleaseEdit as RE
+
+
+main : Program UW.Recv Model Msg
+main = Browser.element
+ { init = \f -> (init f, Date.today |> Task.perform Today)
+ , subscriptions = \m -> if not m.open then Sub.none else Sub.batch <|
+ [ DD.onClickOutside "ulist-widget-box" (Open False)
+ , Sub.map Label (DD.sub m.labels.dd)
+ , Sub.map Vote (DD.sub m.vote.dd)
+ ] ++ List.map (\r -> Sub.map (Rel r.rid) (DD.sub r.dd)) m.rels
+ , view = view
+ , update = update
+ }
+
+type alias Model =
+ { uid : String
+ , vid : String
+ , loadState : Api.State
+ , today : Date.Date
+ , title : Maybe String -- Nothing is used here to indicate that we haven't loaded the full data yet.
+ , open : Bool
+ , onlist : Bool
+ , del : Bool
+ , labels : LE.Model
+ , vote : VE.Model
+ , canvote : Bool
+ , canreview : Bool
+ , review : Maybe String
+ , notes : String
+ , notesRev : Int
+ , notesSaved : String
+ , notesState : Api.State
+ , notesVis : Bool -- For UList.VNPage
+ , started : DE.Model
+ , finished : DE.Model
+ , rels : List RE.Model
+ , relNfo : Dict String GApi.ApiReleases
+ , relOptions : List (String, String)
+ }
+
+init : UW.Recv -> Model
+init f =
+ { uid = f.uid
+ , vid = f.vid
+ , loadState = Api.Normal
+ , today = Date.fromOrdinalDate 2100 1
+ , title = Maybe.map (\full -> full.title) f.full
+ , open = False
+ , onlist = f.labels /= Nothing
+ , del = False
+ -- TODO: LabelEdit and VoteEdit create an internal vid-based ID, so this widget can't be used on VN pages or UList listings. Need to fix that.
+ , labels = LE.init
+ { uid = f.uid
+ , vid = f.vid
+ , selected = List.map (\l -> l.id) (Maybe.withDefault [] f.labels)
+ , labels = Maybe.withDefault
+ (List.map (\l -> {id = l.id, label = l.label, private = True}) (Maybe.withDefault [] f.labels))
+ (Maybe.map (\full -> full.labels) f.full)
+ }
+ , vote = VE.init { uid = f.uid, vid = f.vid, vote = Maybe.andThen (\full -> full.vote) f.full }
+ , canvote = Maybe.map (\full -> full.canvote ) f.full |> Maybe.withDefault False
+ , canreview = Maybe.map (\full -> full.canreview ) f.full |> Maybe.withDefault False
+ , review = Maybe.andThen (\full -> full.review) f.full
+ , notes = Maybe.map (\full -> full.notes ) f.full |> Maybe.withDefault ""
+ , notesRev = 0
+ , notesSaved = Maybe.map (\full -> full.notes ) f.full |> Maybe.withDefault ""
+ , notesState = Api.Normal
+ , notesVis = Maybe.map (\full -> full.notes /= "") f.full == Just True
+ , started = let m = DE.init { uid = f.uid, vid = f.vid, date = Maybe.map (\full -> full.started ) f.full |> Maybe.withDefault "", start = True } in { m | visible = True }
+ , finished = let m = DE.init { uid = f.uid, vid = f.vid, date = Maybe.map (\full -> full.finished) f.full |> Maybe.withDefault "", start = False } in { m | visible = True }
+ , rels = List.map (\st -> RE.init ("widget-" ++ f.vid) { uid = f.uid, rid = st.id, status = Just st.status, empty = "" }) <| Maybe.withDefault [] <| Maybe.map (\full -> full.rlist) f.full
+ , relNfo = Dict.fromList <| List.map (\r -> (r.id, r)) <| Maybe.withDefault [] <| Maybe.map (\full -> full.releases) f.full
+ , relOptions = Maybe.withDefault [] <| Maybe.map (\full -> List.map (\r -> (r.id, RDate.showrel r)) full.releases) f.full
+ }
+
+reset : Model -> Model
+reset m = init
+ { uid = m.uid
+ , vid = m.vid
+ , labels = Nothing
+ , full = Maybe.map (\t ->
+ { title = t
+ , labels = m.labels.labels
+ , canvote = m.canvote
+ , canreview = m.canreview
+ , vote = Nothing
+ , review = m.review
+ , notes = ""
+ , started = ""
+ , finished = ""
+ , releases = Dict.values m.relNfo
+ , rlist = []
+ }) m.title
+ }
+
+
+type Msg
+ = Noop
+ | Today Date.Date
+ | Open Bool
+ | Loaded GApi.Response
+ | Label LE.Msg
+ | Vote VE.Msg
+ | Notes String
+ | NotesSave Int
+ | NotesSaved Int GApi.Response
+ | NotesToggle
+ | Started DE.Msg
+ | Finished DE.Msg
+ | Del Bool
+ | Delete
+ | Deleted GApi.Response
+ | Rel String RE.Msg
+ | RelAdd String
+
+
+setOnList : Model -> Model
+setOnList model =
+ { model | onlist = model.onlist
+ || model.vote.ovote /= Nothing
+ || not (Set.isEmpty model.labels.sel)
+ || model.notes /= ""
+ || model.started.val /= ""
+ || model.finished.val /= ""
+ || not (List.isEmpty model.rels)
+ }
+
+
+isPublic : Model -> Bool
+isPublic model =
+ LE.isPublic model.labels
+ || (isJust model.vote.vote && List.any (\l -> l.id == 7 && not l.private) model.labels.labels)
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Noop -> (model, Cmd.none)
+ Today d -> ({ model | today = d }, Cmd.none)
+ Open b ->
+ if b && model.title == Nothing
+ then ({ model | open = b, loadState = Api.Loading }, UW.send { uid = model.uid, vid = model.vid } Loaded)
+ else ({ model | open = b }, Cmd.none)
+
+ Loaded (GApi.UListWidget w) -> let m = init w in ({ m | open = True }, Cmd.none)
+ Loaded e -> ({ model | loadState = Api.Error e }, Cmd.none)
+
+ Label m -> let (nm, nc) = LE.update m model.labels in (setOnList { model | labels = nm }, Cmd.map Label nc)
+ Vote m -> let (nm, nc) = VE.update m model.vote in (setOnList { model | vote = nm }, Cmd.map Vote nc)
+ Started m -> let (nm, nc) = DE.update m model.started in (setOnList { model | started = nm }, Cmd.map Started nc)
+ Finished m -> let (nm, nc) = DE.update m model.finished in (setOnList { model | finished = nm }, Cmd.map Finished nc)
+
+ Notes s ->
+ ( { model | notes = s, notesRev = model.notesRev + 1 }
+ , Task.perform (\_ -> NotesSave (model.notesRev+1)) <| Process.sleep 1000)
+ NotesSave rev ->
+ if rev /= model.notesRev || model.notes == model.notesSaved
+ then (model, Cmd.none)
+ else ( { model | notesState = Api.Loading }
+ , GVN.send { uid = model.uid, vid = model.vid, notes = model.notes } (NotesSaved rev))
+ NotesSaved rev GApi.Success ->
+ if model.notesRev /= rev
+ then (model, Cmd.none)
+ else (setOnList {model | notesSaved = model.notes, notesState = Api.Normal }, Cmd.none)
+ NotesSaved _ e -> ({ model | notesState = Api.Error e }, Cmd.none)
+ NotesToggle ->
+ ( { model | notesVis = not model.notesVis }
+ , if model.notesVis then Cmd.none else Task.attempt (always Noop) (focus "widget-notes"))
+
+ Del b -> ({ model | del = b }, Cmd.none)
+ Delete -> ({ model | loadState = Api.Loading }, GDE.send { uid = model.uid, vid = model.vid } Deleted)
+ Deleted GApi.Success -> (reset model, Cmd.none)
+ Deleted e -> ({ model | loadState = Api.Error e }, Cmd.none)
+
+ Rel rid m ->
+ case List.filterMap (\r -> if r.rid == rid then Just (RE.update m r) else Nothing) model.rels |> List.head of
+ Nothing -> (model, Cmd.none)
+ Just (rm, rc) ->
+ let
+ nr = if rm.state == Api.Normal && rm.status == Nothing
+ then List.filter (\r -> r.rid /= rid) model.rels
+ else List.map (\r -> if r.rid == rid then rm else r) model.rels
+ in ({ model | rels = nr }, Cmd.map (Rel rid) rc)
+ RelAdd rid ->
+ ( setOnList { model | rels = model.rels ++ (if rid == "" then [] else [RE.init model.vid { rid = rid, uid = model.uid, status = Just 2, empty = "" }]) }
+ , Task.perform (always <| Rel rid <| RE.Set (Just 2) True) <| Task.succeed True)
+
+
+viewStatus : Model -> List (Html Msg)
+viewStatus model =
+ case (model.loadState, model.del, model.onlist) of
+ (Api.Loading, _, _) -> [ span [ class "spinner" ] [] ]
+ (Api.Error e, _, _) -> [ b [] [ text <| Api.showResponse e ] ]
+ (_, _, False) -> [ small [] [ text "not on your list" ] ]
+ (_, True, _) ->
+ [ a [ onClickD Delete ] [ text "Yes, delete" ]
+ , text " | "
+ , a [ onClickD (Del False) ] [ text "Cancel" ]
+ ]
+ (_, False, True) ->
+ [ span [ classList [("hidden", not (isPublic model))], title "This visual novel is on your public list" ] [ text "👁 " ]
+ , text "On your list | "
+ , a [ onClickD (Del True) ] [ text "Remove from list" ]
+ ]
+
+viewReviewLink : Model -> Html Msg
+viewReviewLink model =
+ case (model.vote.vote /= Nothing && model.canreview, model.review) of
+ (False, _) -> text ""
+ (True, Nothing) -> a [ href ("/" ++ model.vid ++ "/addreview") ] [ text " write a review »" ]
+ (True, Just w) -> a [ href ("/" ++ w ++ "/edit") ] [ text " edit review »" ]
+
+
+
+view : Model -> Html Msg
+view model =
+ let
+ icon () =
+ let fn = if not model.onlist then -1
+ else List.range 1 6
+ |> List.filter (\n -> Set.member n model.labels.tsel)
+ |> List.maximum
+ |> Maybe.withDefault 0
+ lbl = if not model.onlist then "Add to list"
+ else String.join ", " <| List.filterMap (\l -> if Set.member l.id model.labels.tsel && l.id /= 7 then Just l.label else Nothing) model.labels.labels
+ in span [ onClickN (Open True), class "ulist-widget-icon" ] [ ulistIcon fn lbl ]
+
+ rel r =
+ case Dict.get r.rid model.relNfo of
+ Nothing -> text ""
+ Just nfo -> relnfo r nfo
+
+ relnfo r nfo =
+ tr []
+ [ td [ class "tco1" ] [ Html.map (Rel r.rid) (RE.view r) ]
+ , td [ class "tco2" ] [ RDate.display model.today nfo.released ]
+ , td [ class "tco3" ]
+ <| List.map platformIcon nfo.platforms
+ ++ List.map langIcon nfo.lang
+ ++ [ releaseTypeIcon nfo.rtype ]
+ , td [ class "tco4" ] [ a [ href ("/"++nfo.id), title nfo.alttitle ] [ text nfo.title ] ]
+ ]
+
+ box () =
+ [ h2 [] [ text (Maybe.withDefault "" model.title) ]
+ , div [ style "text-align" "right", style "margin" "3px 0" ] (viewStatus model)
+ , table [] <|
+ [ tr [] [ td [] [ text "Labels" ], td [] [ Html.map Label (LE.view model.labels "- select label -") ] ]
+ , if not model.canvote then text "" else
+ tr []
+ [ td [] [ text "Vote" ]
+ , td []
+ [ div [ style "width" "80px", style "display" "inline-block" ] [ Html.map Vote (VE.view model.vote "- vote -") ]
+ , viewReviewLink model ]
+ ]
+ , tr [] [ td [] [ text "Start date" ], td [ class "date" ] [ Html.map Started (DE.view model.started ) ] ]
+ , tr [] [ td [] [ text "Finish date" ], td [ class "date" ] [ Html.map Finished (DE.view model.finished) ] ]
+ , tr []
+ [ td [] [ text "Notes ", span [ class "spinner", classList [("hidden", model.notesState /= Api.Loading)] ] [] ]
+ , td [] <|
+ [ textarea ([ rows 2, cols 40, onInput Notes, onBlur (NotesSave model.notesRev)] ++ GVN.valNotes) [ text model.notes ]
+ ] ++ case model.notesState of
+ Api.Error e -> [ br [] [], b [] [ text <| Api.showResponse e ] ]
+ _ -> []
+ ]
+ ]
+ , if List.isEmpty model.relOptions then text "" else h2 [] [ text "Releases" ]
+ , table [] <|
+ (if List.isEmpty model.relOptions then text "" else tfoot [] [ tr []
+ [ td [] []
+ , td [ colspan 3 ]
+ [ inputSelect "" "" RelAdd [] <| ("", "-- add release --") :: List.filter (\(rid,_) -> not <| List.any (\r -> r.rid == rid) model.rels) model.relOptions ]
+ ] ]
+ ) :: List.map rel model.rels
+ ]
+ in
+ if model.open
+ then div [ class "ulist-widget elm_dd_input" ]
+ [ div [ id "ulist-widget-box" ] <|
+ case model.loadState of
+ Api.Loading -> [ div [ class "spinner" ] [] ]
+ Api.Error e -> [ b [] [ text <| Api.showResponse e ] ]
+ Api.Normal -> box () ]
+ else icon ()
diff --git a/elm/UList/actiontabs.js b/elm/UList/actiontabs.js
deleted file mode 100644
index 0ae2b7f9..00000000
--- a/elm/UList/actiontabs.js
+++ /dev/null
@@ -1,17 +0,0 @@
-var buttons = ['managelabels', 'savedefault', 'exportlist'];
-
-buttons.forEach(function(but) {
- document.querySelectorAll('#'+but).forEach(function(b) {
- b.onclick = function() {
- buttons.forEach(function(but2) {
- document.querySelectorAll('.'+but2).forEach(function(e) {
- if(but == but2)
- e.classList.toggle('hidden');
- else
- e.classList.add('hidden')
- })
- })
- return false;
- }
- })
-})
diff --git a/elm/UList/labelfilters.js b/elm/UList/labelfilters.js
deleted file mode 100644
index dfec97c6..00000000
--- a/elm/UList/labelfilters.js
+++ /dev/null
@@ -1,17 +0,0 @@
-var p = document.querySelectorAll('.labelfilters')[0];
-if(p) {
- var multi = document.getElementById('form_l_multi');
- multi.parentNode.classList.remove('hidden');
- var l = document.querySelectorAll('.labelfilters input[name=l]');
- l.forEach(function(el) {
- el.addEventListener('click', function() {
- if(multi.checked)
- return true;
- l.forEach(function(el2) { el2.checked = el2 == el });
- var n=el;
- while(n && n.nodeName.toLowerCase() != 'form')
- n=n.parentNode;
- n.submit();
- });
- });
-}
diff --git a/elm/User/Edit.elm b/elm/User/Edit.elm
deleted file mode 100644
index 25c5d49d..00000000
--- a/elm/User/Edit.elm
+++ /dev/null
@@ -1,290 +0,0 @@
-module User.Edit exposing (main)
-
-import Bitwise exposing (..)
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Html.Keyed as K
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Html exposing (..)
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.Types as GT
-import Gen.UserEdit as GUE
-
-
-main : Program GUE.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-
-type alias PassData =
- { cpass : Bool
- , pass1 : String
- , pass2 : String
- , opass : String
- }
-
-type alias Model =
- { state : Api.State
- , id : String
- , title : String
- , username : String
- , opts : GUE.RecvOpts
- , admin : Maybe GUE.SendAdmin
- , prefs : Maybe GUE.SendPrefs
- , pass : Maybe PassData
- , passNeq : Bool
- , mailConfirm : Bool
- }
-
-
-init : GUE.Recv -> Model
-init d =
- { state = Api.Normal
- , id = d.id
- , title = d.title
- , username = d.username
- , opts = d.opts
- , admin = d.admin
- , prefs = d.prefs
- , pass = Maybe.map (always { cpass = False, pass1 = "", pass2 = "", opass = "" }) d.prefs
- , passNeq = False
- , mailConfirm = False
- }
-
-
-type AdminMsg
- = PermBoard Bool
- | PermReview Bool
- | PermBoardmod Bool
- | PermEdit Bool
- | PermImgvote Bool
- | PermImgmod Bool
- | PermTag Bool
- | PermDbmod Bool
- | PermTagmod Bool
- | PermUsermod Bool
- | IgnVotes Bool
-
-type PrefMsg
- = EMail String
- | MaxSexual Int
- | MaxViolence Int
- | TraitsSexual Bool
- | Spoilers Int
- | TagsAll Bool
- | TagsCont Bool
- | TagsEro Bool
- | TagsTech Bool
- | Skin String
- | Css String
- | NoAds Bool
- | NoFancy Bool
- | Support Bool
- | PubSkin Bool
- | Uniname String
-
-type PassMsg
- = CPass Bool
- | OPass String
- | Pass1 String
- | Pass2 String
-
-type Msg
- = Username String
- | Admin AdminMsg
- | Prefs PrefMsg
- | Pass PassMsg
- | Submit
- | Submitted GApi.Response
-
-
-updateAdmin : AdminMsg -> GUE.SendAdmin -> GUE.SendAdmin
-updateAdmin msg model =
- case msg of
- PermBoard b -> { model | perm_board = b }
- PermReview b -> { model | perm_review = b }
- PermBoardmod b -> { model | perm_boardmod = b }
- PermEdit b -> { model | perm_edit = b }
- PermImgvote b -> { model | perm_imgvote = b }
- PermImgmod b -> { model | perm_imgmod = b }
- PermTag b -> { model | perm_tag = b }
- PermDbmod b -> { model | perm_dbmod = b }
- PermTagmod b -> { model | perm_tagmod = b }
- PermUsermod b -> { model | perm_usermod = b }
- IgnVotes b -> { model | ign_votes = b }
-
-updatePrefs : PrefMsg -> GUE.SendPrefs -> GUE.SendPrefs
-updatePrefs msg model =
- case msg of
- EMail n -> { model | email = n }
- MaxSexual n-> { model | max_sexual = n }
- MaxViolence n -> { model | max_violence = n }
- TraitsSexual b -> { model | traits_sexual = b }
- Spoilers n -> { model | spoilers = n }
- TagsAll b -> { model | tags_all = b }
- TagsCont b -> { model | tags_cont = b }
- TagsEro b -> { model | tags_ero = b }
- TagsTech b -> { model | tags_tech = b }
- Skin n -> { model | skin = n }
- Css n -> { model | customcss = n }
- NoAds b -> { model | nodistract_noads = b }
- NoFancy b -> { model | nodistract_nofancy = b }
- Support b -> { model | support_enabled = b }
- PubSkin b -> { model | pubskin_enabled = b }
- Uniname n -> { model | uniname = n }
-
-updatePass : PassMsg -> PassData -> PassData
-updatePass msg model =
- case msg of
- CPass b -> { model | cpass = b }
- OPass n -> { model | opass = n }
- Pass1 n -> { model | pass1 = n }
- Pass2 n -> { model | pass2 = n }
-
-
-encode : Model -> GUE.Send
-encode model =
- { id = model.id
- , username = model.username
- , admin = model.admin
- , prefs = model.prefs
- , password = Maybe.andThen (\p -> if p.cpass && p.pass1 == p.pass2 then Just { old = p.opass, new = p.pass1 } else Nothing) model.pass
- }
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Admin m -> ({ model | admin = Maybe.map (updateAdmin m) model.admin }, Cmd.none)
- Prefs m -> ({ model | prefs = Maybe.map (updatePrefs m) model.prefs }, Cmd.none)
- Pass m -> ({ model | pass = Maybe.map (updatePass m) model.pass, passNeq = False }, Cmd.none)
- Username s -> ({ model | username = s }, Cmd.none)
-
- Submit ->
- if Maybe.withDefault False (Maybe.map (\p -> p.cpass && p.pass1 /= p.pass2) model.pass)
- then ({ model | passNeq = True }, Cmd.none )
- else ({ model | state = Api.Loading }, GUE.send (encode model) Submitted)
-
- -- TODO: This reload is only necessary for the skin and customcss options to apply, but it's nicer to do that directly from JS.
- Submitted GApi.Success -> (model, load <| "/" ++ model.id ++ "/edit")
- Submitted GApi.MailChange -> ({ model | mailConfirm = True, state = Api.Normal }, Cmd.none)
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-
-view : Model -> Html Msg
-view model =
- let
- opts = model.opts
- perm b f = if opts.perm_usermod || b then f else text ""
-
- adminform m =
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Admin options" ] ]
- , perm False <| formField "username::Username" [ inputText "username" model.username Username GUE.valUsername ]
- , formField "Permissions"
- [ text "Fields marked with * indicate permissions assigned to new users by default", br_ 1
- , perm opts.perm_boardmod <| label [] [ inputCheck "" m.perm_board (Admin << PermBoard), text " board*", br_ 1 ]
- , perm opts.perm_boardmod <| label [] [ inputCheck "" m.perm_review (Admin << PermReview), text " review*", br_ 1 ]
- , perm False <| label [] [ inputCheck "" m.perm_boardmod (Admin << PermBoardmod), text " boardmod", br_ 1 ]
- , perm opts.perm_dbmod <| label [] [ inputCheck "" m.perm_edit (Admin << PermEdit), text " edit*", br_ 1 ]
- , perm opts.perm_imgmod <| label [] [ inputCheck "" m.perm_imgvote (Admin << PermImgvote), text " imgvote* (existing votes will stop counting when unset)", br_ 1 ]
- , perm False <| label [] [ inputCheck "" m.perm_imgmod (Admin << PermImgmod), text " imgmod", br_ 1 ]
- , perm opts.perm_tagmod <| label [] [ inputCheck "" m.perm_tag (Admin << PermTag), text " tag* (existing tag votes will stop counting when unset)", br_ 1 ]
- , perm False <| label [] [ inputCheck "" m.perm_dbmod (Admin << PermDbmod), text " dbmod", br_ 1 ]
- , perm False <| label [] [ inputCheck "" m.perm_tagmod (Admin << PermTagmod), text " tagmod", br_ 1 ]
- , perm False <| label [] [ inputCheck "" m.perm_usermod (Admin << PermUsermod), text " usermod", br_ 1 ]
- ]
- , perm False <| formField "Other" [ label [] [ inputCheck "" m.ign_votes (Admin << IgnVotes), text " Ignore votes in VN statistics" ] ]
- ]
-
- passform m =
- [ formField "" [ label [] [ inputCheck "" m.cpass (Pass << CPass), text " Change password" ] ]
- ] ++ if not m.cpass then [] else
- [ tr [] [ K.node "td" [colspan 2] [("pass_change", table []
- [ formField "opass::Old password" [ inputPassword "opass" m.opass (Pass << OPass) GUE.valPasswordOld ]
- , formField "pass1::New password" [ inputPassword "pass1" m.pass1 (Pass << Pass1) GUE.valPasswordNew ]
- , formField "pass2::Repeat"
- [ inputPassword "pass2" m.pass2 (Pass << Pass2) GUE.valPasswordNew
- , br_ 1
- , if model.passNeq
- then b [ class "standout" ] [ text "Passwords do not match" ]
- else text ""
- ]
- ])]]
- ]
-
- supportform m =
- if not (opts.perm_usermod || opts.nodistract_can || opts.support_can || opts.uniname_can || opts.pubskin_can) then [] else
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Supporter options⭐" ] ]
- , perm opts.nodistract_can <| formField "" [ label [] [ inputCheck "" m.nodistract_noads (Prefs << NoAds), text " Disable advertising and other distractions (only hides the support icons for the moment)" ] ]
- , perm opts.nodistract_can <| formField "" [ label [] [ inputCheck "" m.nodistract_nofancy (Prefs << NoFancy), text " Disable supporters badges, custom display names and profile skins" ] ]
- , perm opts.support_can <| formField "" [ label [] [ inputCheck "" m.support_enabled (Prefs << Support), text " Display my supporters badge" ] ]
- , perm opts.pubskin_can <| formField "" [ label [] [ inputCheck "" m.pubskin_enabled (Prefs << PubSkin), text " Apply my skin and custom CSS when others visit my profile" ] ]
- , perm opts.uniname_can <| formField "uniname::Display name" [ inputText "uniname" (if m.uniname == "" then model.username else m.uniname) (Prefs << Uniname) GUE.valPrefsUniname ]
- ]
-
- prefsform m =
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Preferences" ] ]
- , formField "NSFW"
- [ inputSelect "" m.max_sexual (Prefs << MaxSexual) [style "width" "400px"]
- [ (-1,"Hide all images")
- , (0, "Hide sexually suggestive or explicit images")
- , (1, "Hide only sexually explicit images")
- , (2, "Don't hide suggestive or explicit images")
- ]
- , br [] []
- , if m.max_sexual == -1 then text "" else
- inputSelect "" m.max_violence (Prefs << MaxViolence) [style "width" "400px"]
- [ (0, "Hide violent or brutal images")
- , (1, "Hide only brutal images")
- , (2, "Don't hide violent or brutal images")
- ]
- ]
- , formField "" [ label [] [ inputCheck "" m.traits_sexual (Prefs << TraitsSexual), text " Show sexual traits by default on character pages" ], br_ 2 ]
- , formField "Tags" [ label [] [ inputCheck "" m.tags_all (Prefs << TagsAll), text " Show all tags by default on visual novel pages (don't summarize)" ] ]
- , formField ""
- [ text "Default tag categories on visual novel pages:", br_ 1
- , label [] [ inputCheck "" m.tags_cont (Prefs << TagsCont), text " Content" ], br_ 1
- , label [] [ inputCheck "" m.tags_ero (Prefs << TagsEro ), text " Sexual content" ], br_ 1
- , label [] [ inputCheck "" m.tags_tech (Prefs << TagsTech), text " Technical" ]
- ]
- , formField "spoil::Spoiler level"
- [ inputSelect "spoil" m.spoilers (Prefs << Spoilers) []
- [ (0, "Hide spoilers")
- , (1, "Show only minor spoilers")
- , (2, "Show all spoilers")
- ]
- ]
- , formField "skin::Skin" [ inputSelect "skin" m.skin (Prefs << Skin) [ style "width" "300px" ] GT.skins ]
- , formField "css::Custom CSS" [ inputTextArea "css" m.customcss (Prefs << Css) ([ rows 5, cols 60 ] ++ GUE.valPrefsCustomcss) ]
- ]
-
- in form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text model.title ]
- , table [ class "formtable" ] <|
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Account settings" ] ]
- , formField "Username" [ text model.username ]
- , Maybe.withDefault (text "") <| Maybe.map (\m ->
- formField "email::E-Mail" [ inputText "email" m.email (Prefs << EMail) GUE.valPrefsEmail ]
- ) model.prefs
- ]
- ++ (Maybe.withDefault [] (Maybe.map passform model.pass))
- ++ (Maybe.withDefault [] (Maybe.map adminform model.admin))
- ++ (Maybe.withDefault [] (Maybe.map supportform model.prefs))
- ++ (Maybe.withDefault [] (Maybe.map prefsform model.prefs))
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state (not model.passNeq) ]
- , if not model.mailConfirm then text "" else
- div [ class "notice" ]
- [ text "A confirmation email has been sent to your new address. Your address will be updated after following the instructions in that mail." ]
- ]
- ]
diff --git a/elm/User/Login.elm b/elm/User/Login.elm
deleted file mode 100644
index c1c55dfe..00000000
--- a/elm/User/Login.elm
+++ /dev/null
@@ -1,145 +0,0 @@
-module User.Login exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserLogin as GUL
-import Gen.UserChangePass as GUCP
-import Gen.Types exposing (adminEMail)
-import Lib.Html exposing (..)
-
-
-main : Program String Model Msg
-main = Browser.element
- { init = \ref -> (init ref, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { ref : String
- , username : String
- , password : String
- , newpass1 : String
- , newpass2 : String
- , state : Api.State
- , insecure : Bool
- , noteq : Bool
- -- Extra Elm-side input validation, because apparently some login managers
- -- bypass HTML5 validation or proper onChange messages fail to get invoked.
- , invalid : Bool
- }
-
-
-init : String -> Model
-init ref =
- { ref = ref
- , username = ""
- , password = ""
- , newpass1 = ""
- , newpass2 = ""
- , state = Api.Normal
- , insecure = False
- , noteq = False
- , invalid = False
- }
-
-
-type Msg
- = Username String
- | Password String
- | Newpass1 String
- | Newpass2 String
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Username n -> ({ model | invalid = False, username = String.toLower n }, Cmd.none)
- Password n -> ({ model | invalid = False, password = n }, Cmd.none)
- Newpass1 n -> ({ model | newpass1 = n, noteq = False }, Cmd.none)
- Newpass2 n -> ({ model | newpass2 = n, noteq = False }, Cmd.none)
-
- Submit ->
- if model.username == "" || model.password == ""
- then ( { model | invalid = True }, Cmd.none)
- else if not model.insecure
- then ( { model | state = Api.Loading }
- , GUL.send { username = model.username, password = model.password } Submitted )
- else if model.newpass1 /= model.newpass2
- then ( { model | noteq = True }, Cmd.none )
- else ( { model | state = Api.Loading }
- , GUCP.send { username = model.username, oldpass = model.password, newpass = model.newpass1 } Submitted )
-
- Submitted GApi.Success -> (model, load model.ref)
- Submitted GApi.InsecurePass -> ({ model | insecure = True, state = if model.insecure then Api.Error GApi.InsecurePass else Api.Normal }, Cmd.none)
- Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- let
- loginBox =
- div [ class "mainbox" ]
- [ h1 [] [ text "Login" ]
- , table [ class "formtable" ]
- [ formField "username::Username"
- [ inputText "username" model.username Username GUL.valUsername
- , br_ 1
- , a [ href "/u/register" ] [ text "No account yet?" ]
- ]
- , formField "password::Password"
- [ inputPassword "password" model.password Password GUL.valPassword
- , br_ 1
- , a [ href "/u/newpass" ] [ text "Forgot your password?" ]
- ]
- ]
- , if model.state == Api.Normal || model.state == Api.Loading
- then text ""
- else div [ class "notice" ]
- [ h2 [] [ text "Trouble logging in?" ]
- , text "If you have not used this login form since October 2014, your account has likely been disabled. You can "
- , a [ href "/u/newpass" ] [ text "reset your password" ]
- , text " to regain access."
- , br_ 2
- , text "Still having trouble? Send a mail to "
- , a [ href <| "mailto:" ++ adminEMail ] [ text adminEMail ]
- , text ". But keep in mind that I can only help you if the email address associated with your account is correct"
- , text " and you still have access to it. Without that, there is no way to prove that the account is yours."
- ]
- ]
-
- changeBox =
- div [ class "mainbox" ]
- [ h1 [] [ text "Change your password" ]
- , div [ class "warning" ]
- [ h2 [] [ text "Your current password is not secure" ]
- , text "Your current password is in a public database of leaked passwords. You need to change it before you can continue."
- ]
- , table [ class "formtable" ]
- [ formField "newpass1::New password" [ inputPassword "newpass1" model.newpass1 Newpass1 GUCP.valNewpass ]
- , formField "newpass2::Repeat"
- [ inputPassword "newpass2" model.newpass2 Newpass2 GUCP.valNewpass
- , br_ 1
- , if model.noteq then b [ class "standout" ] [ text "Passwords do not match" ] else text ""
- ]
- ]
- ]
-
- in form_ "" Submit (model.state == Api.Loading)
- [ if model.insecure then changeBox else loginBox
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ if model.invalid then b [ class "standout" ] [ text "Username or password is empty." ] else text ""
- , submitButton "Submit" model.state (not model.invalid)
- ]
- ]
- ]
diff --git a/elm/User/PassReset.elm b/elm/User/PassReset.elm
deleted file mode 100644
index 5eb78313..00000000
--- a/elm/User/PassReset.elm
+++ /dev/null
@@ -1,87 +0,0 @@
-module User.PassReset exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserPassReset as GUPR
-import Lib.Html exposing (..)
-import Lib.Util exposing (..)
-
-
-main : Program () Model Msg
-main = Browser.element
- { init = always (init, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { email : String
- , state : Api.State
- , success : Bool
- }
-
-
-init : Model
-init =
- { email = ""
- , state = Api.Normal
- , success = False
- }
-
-
-type Msg
- = EMail String
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- EMail n -> ({ model | email = n }, Cmd.none)
- Submit -> ({ model | state = Api.Loading }, GUPR.send { email = model.email } Submitted)
- Submitted GApi.Success -> ({ model | success = True }, Cmd.none)
- Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- if model.success
- then
- div [ class "mainbox" ]
- [ h1 [] [ text "New password" ]
- , div [ class "notice" ]
- [ p [] [ text "Your password has been reset and instructions to set a new one should reach your mailbox in a few minutes." ] ]
- ]
- else
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "Forgot Password" ]
- , p []
- [ text "Forgot your password and can't login to VNDB anymore? "
- , text "Don't worry! Just give us the email address you used to register on VNDB "
- , text " and we'll send you instructions to set a new password within a few minutes!"
- ]
- , table [ class "formtable" ]
- [ formField "email::E-Mail"
- [ inputText "email" model.email EMail GUPR.valEmail
- , case shittyMailProvider model.email of
- Nothing -> text ""
- Just n -> span []
- [ br [] []
- , b [ class "standout" ] [ text "WARNING: " ]
- , text (n ++ " is known to silently drop emails from VNDB. If your password reset email does not arrive in a few hours, please send a mail to contact@vndb.org.")
- ]
- ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ]
- ]
- ]
diff --git a/elm/User/PassSet.elm b/elm/User/PassSet.elm
deleted file mode 100644
index 31e902ea..00000000
--- a/elm/User/PassSet.elm
+++ /dev/null
@@ -1,85 +0,0 @@
-module User.PassSet exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserPassSet as GUPS
-import Lib.Html exposing (..)
-
-
-main : Program GUPS.Recv Model Msg
-main = Browser.element
- { init = \f -> (init f, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { token : String
- , uid : String
- , newpass1 : String
- , newpass2 : String
- , state : Api.State
- , noteq : Bool
- }
-
-
-init : GUPS.Recv -> Model
-init f =
- { token = f.token
- , uid = f.uid
- , newpass1 = ""
- , newpass2 = ""
- , state = Api.Normal
- , noteq = False
- }
-
-
-type Msg
- = Newpass1 String
- | Newpass2 String
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Newpass1 n -> ({ model | newpass1 = n, noteq = False }, Cmd.none)
- Newpass2 n -> ({ model | newpass2 = n, noteq = False }, Cmd.none)
-
- Submit ->
- if model.newpass1 /= model.newpass2
- then ( { model | noteq = True }, Cmd.none)
- else ( { model | state = Api.Loading }
- , GUPS.send { token = model.token, uid = model.uid, password = model.newpass1 } Submitted )
-
- Submitted GApi.Success -> (model, load "/")
- Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "Set your password" ]
- , p [] [ text "Now you can set a password for your account. You will be logged in automatically after your password has been saved." ]
- , table [ class "formtable" ]
- [ formField "newpass1::New password" [ inputPassword "newpass1" model.newpass1 Newpass1 GUPS.valPassword ]
- , formField "newpass2::Repeat"
- [ inputPassword "newpass2" model.newpass2 Newpass2 GUPS.valPassword
- , br_ 1
- , if model.noteq then b [ class "standout" ] [ text "Passwords do not match" ] else text ""
- ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ]
- ]
- ]
diff --git a/elm/User/Register.elm b/elm/User/Register.elm
deleted file mode 100644
index e9d8cab5..00000000
--- a/elm/User/Register.elm
+++ /dev/null
@@ -1,107 +0,0 @@
-module User.Register exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserRegister as GUR
-import Lib.Html exposing (..)
-import Lib.Util exposing (..)
-
-
-main : Program () Model Msg
-main = Browser.element
- { init = always (init, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { username : String
- , email : String
- , vns : Int
- , state : Api.State
- , success : Bool
- }
-
-
-init : Model
-init =
- { username = ""
- , email = ""
- , vns = 0
- , state = Api.Normal
- , success = False
- }
-
-
-type Msg
- = Username String
- | EMail String
- | VNs String
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Username n -> ({ model | username = String.toLower n }, Cmd.none)
- EMail n -> ({ model | email = n }, Cmd.none)
- VNs n -> ({ model | vns = Maybe.withDefault model.vns (String.toInt n) }, Cmd.none)
-
- Submit -> ( { model | state = Api.Loading }
- , GUR.send { username = model.username, email = model.email, vns = model.vns } Submitted )
-
- Submitted GApi.Success -> ({ model | success = True }, Cmd.none)
- Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- if model.success
- then
- div [ class "mainbox" ]
- [ h1 [] [ text "Account created" ]
- , div [ class "notice" ]
- [ p [] [ text "Your account has been created! In a few minutes, you should receive an email with instructions to set your password." ] ]
- ]
- else
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "Create an account" ]
- , table [ class "formtable" ]
- [ formField "username::Username"
- [ inputText "username" model.username Username GUR.valUsername
- , br_ 1
- , text "Preferred username. Must be lowercase, between 2 and 15 characters long and consist entirely of alphanumeric characters or a dash."
- , text " Names that look like database identifiers (i.e. a single letter followed by several numbers) are also disallowed."
- ]
- , formField "email::E-Mail"
- [ inputText "email" model.email EMail GUR.valEmail
- , case shittyMailProvider model.email of
- Nothing -> text ""
- Just n -> span []
- [ br [] []
- , b [ class "standout" ] [ text "WARNING: " ]
- , text (n ++ " is known to silently drop emails from VNDB. If you have an address at another provider, you may want to use that."
- ++ " If you want to keep using this provider, please kindly tell them to stop blocking VNDB. Thanks.")
- , br [] []
- ]
- , br_ 1
- , text "Your email address will only be used in case you lose your password. "
- , text "We will never send spam or newsletters unless you explicitly ask us for it or we get hacked."
- , br_ 3
- , text "Anti-bot question: How many visual novels do we have in the database? (Hint: look to your left)"
- ]
- , formField "vns::Answer" [ inputText "vns" (if model.vns == 0 then "" else String.fromInt model.vns) VNs [] ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ]
- ]
- ]
diff --git a/elm/VNEdit.elm b/elm/VNEdit.elm
index aab55302..751cab61 100644
--- a/elm/VNEdit.elm
+++ b/elm/VNEdit.elm
@@ -6,9 +6,11 @@ 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
@@ -29,7 +31,7 @@ import Gen.Api as GApi
main : Program GVE.Recv Model Msg
main = Browser.element
- { init = \e -> (init e, Cmd.none)
+ { init = \e -> (init e, Date.today |> Task.perform Today)
, view = view
, update = update
, subscriptions = always Sub.none
@@ -49,12 +51,13 @@ type Tab
type alias Model =
{ state : Api.State
, tab : Tab
+ , today : Int
, invalidDis : Bool
, editsum : Editsum.Model
- , title : String
- , original : String
+ , titles : List GVE.RecvTitles
, alias : String
- , desc : TP.Model
+ , description : TP.Model
+ , devStatus : Int
, olang : String
, length : Int
, lWikidata : Maybe Int
@@ -64,8 +67,10 @@ type alias Model =
, anime : List GVE.RecvAnime
, animeSearch : A.Model GApi.ApiAnimeResult
, image : Img.Image
+ , editions : List GVE.RecvEditions
, staff : List GVE.RecvStaff
- , staffSearch : A.Model GApi.ApiStaffResult
+ -- 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
@@ -75,6 +80,7 @@ type alias Model =
, 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
@@ -86,12 +92,13 @@ 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 }
- , title = d.title
- , original = d.original
+ , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = False }
+ , titles = d.titles
, alias = d.alias
- , desc = TP.bbcode d.desc
+ , description = TP.bbcode d.description
+ , devStatus = d.devstatus
, olang = d.olang
, length = d.length
, lWikidata = d.l_wikidata
@@ -101,8 +108,9 @@ init d =
, anime = d.anime
, animeSearch = A.init ""
, image = Img.info d.image_info
+ , editions = d.editions
, staff = d.staff
- , staffSearch = A.init ""
+ , 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
@@ -112,6 +120,7 @@ init d =
, scrUplNum = Nothing
, scrId = 100
, releases = d.releases
+ , reltitles = d.reltitles
, chars = d.chars
, id = d.id
, dupCheck = False
@@ -125,10 +134,10 @@ encode model =
, editsum = model.editsum.editsum.data
, hidden = model.editsum.hidden
, locked = model.editsum.locked
- , title = model.title
- , original = model.original
+ , titles = model.titles
, alias = model.alias
- , desc = model.desc.data
+ , devstatus = model.devStatus
+ , description = model.description.data
, olang = model.olang
, length = model.length
, l_wikidata = model.lWikidata
@@ -136,8 +145,9 @@ encode model =
, 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
- , staff = List.map (\s -> { aid = s.aid, role = s.role, note = s.note }) model.staff
- , seiyuu = List.map (\s -> { aid = s.aid, cid = s.cid, note = s.note }) model.seiyuu
+ , 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
}
@@ -147,27 +157,38 @@ vnConfig = { wrap = VNSearch, id = "relationadd", source = A.vnSource }
animeConfig : A.Config Msg GApi.ApiAnimeResult
animeConfig = { wrap = AnimeSearch, id = "animeadd", source = A.animeSource False }
-staffConfig : A.Config Msg GApi.ApiStaffResult
-staffConfig = { wrap = StaffSearch, id = "staffadd", source = A.staffSource }
+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
- = Editsum Editsum.Msg
+ = Noop
+ | Today Date.Date
+ | Editsum Editsum.Msg
| Tab Tab
| Invalid Tab
| InvalidEnable
| Submit
| Submitted GApi.Response
- | Title String
- | Original String
| Alias String
| Desc TP.Msg
- | OLang String
+ | 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
@@ -178,10 +199,15 @@ type Msg
| 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 (A.Msg GApi.ApiStaffResult)
+ | StaffSearch (Maybe Int) (A.Msg GApi.ApiStaffResult)
| SeiyuuDef String
| SeiyuuDel Int
| SeiyuuChar Int String
@@ -213,20 +239,30 @@ scrProcessQueue (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)
- Title s -> ({ model | title = s, dupVNs = [] }, Cmd.none)
- Original s -> ({ model | original = s, dupVNs = [] }, Cmd.none)
Alias s -> ({ model | alias = s, dupVNs = [] }, Cmd.none)
- Desc m -> let (nm,nc) = TP.update m model.desc in ({ model | desc = nm }, Cmd.map Desc nc)
- OLang s -> ({ model | olang = s }, 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)
@@ -237,7 +273,7 @@ update msg model =
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, original = v.original, relation = "seq", official = True }] }, 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 ->
@@ -250,18 +286,47 @@ update msg model =
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/jpg"] ImageSelected)
+ 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 m ->
- let (nm, c, res) = A.update staffConfig m model.staffSearch
- in case res of
- Nothing -> ({ model | staffSearch = nm }, c)
- Just s -> ({ model | staffSearch = A.clear nm "", staff = model.staff ++ [{ id = s.id, aid = s.aid, name = s.name, original = s.original, role = "staff", note = "" }] }, c)
+ 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)
@@ -271,10 +336,10 @@ update msg model =
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, name = s.name, original = s.original, cid = model.seiyuuDef, note = "" }] }, 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/jpg"] ScrUpl)
+ 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)
@@ -290,7 +355,7 @@ update msg model =
DupSubmit ->
if List.isEmpty model.dupVNs
- then ({ model | state = Api.Loading }, GV.send { hidden = True, search = model.title :: model.original :: String.lines model.alias } DupResults)
+ 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
@@ -304,20 +369,22 @@ update msg model =
-- TODO: Fuzzier matching? Exclude stuff like 'x Edition', etc.
-relAlias : Model -> Maybe GVE.RecvReleases
+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 || Set.member (String.toLower r.original) a) model.releases |> List.head
+ in List.filter (\r -> Set.member (String.toLower r.title) a) model.reltitles |> List.head
isValid : Model -> Bool
isValid model = not
- ( (model.title /= "" && model.title == model.original)
+ ( 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 (\s -> (s.aid, s.role)) model.staff)
+ || 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)
)
@@ -325,37 +392,57 @@ isValid model = not
view : Model -> Html Msg
view model =
let
- titles =
- [ formField "title::Title (romaji)"
- [ inputText "title" model.title Title (style "width" "500px" :: onInvalid (Invalid General) :: GVE.valTitle)
- , if containsNonLatin model.title
- then b [ class "standout" ] [ br [] [], text "This title field should only contain latin-alphabet characters, please put the \"actual\" title in the field below and the romanization above." ]
- else text ""
+ 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
]
- , formField "original::Original title"
- [ inputText "original" model.original Original (style "width" "500px" :: onInvalid (Invalid General) :: GVE.valOriginal)
- , if model.title /= "" && model.title == model.original
- then b [ class "standout" ] [ br [] [], text "Should not be the same as the Title (romaji). Leave blank is the original title is already in the latin alphabet" ]
- else if model.original /= "" && String.toLower model.title /= String.toLower model.original && not (containsNonLatin model.original)
- then b [ class "standout" ] [ br [] [], text "Original title does not seem to contain any non-latin characters. Leave this field empty if the title is already in the latin alphabet" ]
- else text ""
+ ]
+
+ 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 <| String.lines <| String.toLower model.alias
- then b [ class "standout" ] [ text "List contains duplicate aliases.", 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 [ class "standout" ] [ text "Release titles should not be added as alias." ]
+ [ 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 alternative titles or abbreviations. One line for each alias. Can include both official (japanese/english) titles and unofficial titles used around net."
+ , 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!"
]
@@ -363,11 +450,34 @@ view model =
geninfo = titles ++
[ formField "desc::Description"
- [ TP.view "desc" model.desc Desc 600 (style "height" "180px" :: onInvalid (Invalid General) :: GVE.valDesc) [ b [ class "standout" ] [ text "English please!" ] ]
+ [ 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 "olang::Original language" [ inputSelect "olang" model.olang OLang [] GT.languages ]
- , formField "length::Length" [ inputSelect "length" model.length Length [] GT.vnLengths ]
+ , 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" ]
@@ -375,7 +485,7 @@ view model =
, formField "Related VNs"
[ if List.isEmpty model.vns then text ""
else table [] <| List.indexedMap (\i v -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| v.vid ++ ":" ] ]
+ [ 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 "
@@ -392,7 +502,7 @@ view model =
, formField "Related anime"
[ if List.isEmpty model.anime then text ""
else table [] <| List.indexedMap (\i e -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "a" ++ String.fromInt e.aid ++ ":" ] ]
+ [ 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) [] ]
]
@@ -413,7 +523,11 @@ view model =
, h2 [] [ text "Upload new image" ]
, inputButton "Browse image" ImageSelect []
, br [] []
- , text "Preferably the cover of the CD/DVD/package. Image must be in JPEG or PNG format and at most 10 MiB. Images larger than 256x400 will automatically be resized."
+ , 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 ->
@@ -427,45 +541,69 @@ view model =
staff =
let
- head =
- if List.isEmpty model.staff then [] else [
+ head lst =
+ if List.isEmpty lst then text "" else
thead [] [ tr []
[ td [] []
, td [] [ text "Staff" ]
, td [] [ text "Role" ]
, td [] [ text "Note" ]
, td [] []
- ] ] ]
- foot =
+ ] ]
+ foot searchn lst (sconfig, smodel) =
tfoot [] [ tr [] [ td [] [], td [ colspan 4 ]
- [ br [] []
- , if hasDuplicates (List.map (\s -> (s.aid, s.role)) model.staff)
- then b [ class "standout" ] [ text "List contains duplicate staff roles.", br [] [] ]
+ [ text ""
+ , if hasDuplicates (List.map (\(_,s) -> (s.aid, s.role)) lst)
+ then b [] [ text "List contains duplicate staff roles.", br [] [] ]
else text ""
- , A.view staffConfig model.staffSearch [placeholder "Add staff..."]
- , 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_ 2
- , text "Some guidelines:"
- , ul []
- [ li [] [ text "Please add major staff only, i.e. people who had a significant and noticable impact on the work." ]
- , li [] [ text "If one person performed several roles, you can add multiple entries with different major roles." ]
+ , 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" ] [ b [ class "grayedout" ] [ text <| s.id ++ ":" ] ]
- , td [] [ a [ href <| "/" ++ s.id ] [ text s.name ] ]
+ 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) [] ]
]
- in table [] <| head ++ [ foot ] ++ List.indexedMap item model.staff
+ 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.name ++ " (" ++ c.id ++ ")")) model.chars
+ chars = List.map (\c -> (c.id, c.title ++ " (" ++ c.id ++ ")")) model.chars
head =
if List.isEmpty model.seiyuu then [] else [
thead [] [ tr []
@@ -477,10 +615,10 @@ view model =
foot =
tfoot [] [ tr [] [ td [ colspan 4 ]
[ br [] []
- , b [] [ text "Add cast" ]
+ , strong [] [ text "Add cast" ]
, br [] []
, if hasDuplicates (List.map (\s -> (s.aid, s.cid)) model.seiyuu)
- then b [ class "standout" ] [ text "List contains duplicate cast roles.", br [] [] ]
+ then b [] [ text "List contains duplicate cast roles.", br [] [] ]
else text ""
, inputSelect "" model.seiyuuDef SeiyuuDef [] chars
, text " voiced by "
@@ -495,8 +633,8 @@ view model =
[ 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 []
- [ b [ class "grayedout" ] [ text <| s.id ++ ":" ]
- , a [ href <| "/" ++ s.id ] [ text s.name ] ]
+ [ 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) [] ]
]
@@ -514,8 +652,7 @@ view model =
screenshots =
let
- showrel r = "[" ++ (RDate.format (RDate.expand r.released)) ++ " " ++ (String.join "," r.lang) ++ "] " ++ r.title ++ " (" ++ r.id ++ ")"
- rellist = List.map (\r -> (Just r.id, showrel r)) model.releases
+ 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
@@ -526,7 +663,7 @@ view model =
[ td [] [ Img.viewImg i ]
, td [] [ Img.viewVote i (ScrMsg id) (Invalid Screenshots) |> Maybe.withDefault (text "") ]
, td []
- [ b [] [ text <| "Screenshot #" ++ String.fromInt (n+1) ]
+ [ strong [] [ text <| "Screenshot #" ++ String.fromInt (n+1) ]
, text " (", a [ href "#", onClickD (ScrDel id) ] [ text "remove" ], text ")"
, br [] []
, text <| "Image resolution: " ++ dimstr imgdim
@@ -537,10 +674,10 @@ view model =
else if reldim /= Nothing
then [ text " ❌"
, br [] []
- , b [ class "standout" ] [ text "WARNING: Resolutions do not match, please take screenshots with the correct resolution and make sure to crop them correctly!" ]
+ , 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 [ class "standout" ] [ text "WARNING: Inconsistent image resolutions for the same release, please take screenshots with the correct resolution and make sure to crop them correctly!" ]
+ 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 [] [] ]
@@ -557,18 +694,18 @@ view model =
let free = 10 - List.length model.screenshots
in
if not (List.isEmpty model.scrQueue)
- then [ b [] [ text "Uploading screenshots" ]
+ then [ strong [] [ text "Uploading screenshots" ]
, br [] []
, text <| (String.fromInt (List.length model.scrQueue)) ++ " remaining... "
, span [ class "spinner" ] []
]
else if free <= 0
- then [ b [] [ text "Enough screenshots" ]
+ 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
- [ b [] [ text "Add screenshots" ]
+ [ strong [] [ text "Add screenshots" ]
, br [] []
, text <| String.fromInt free ++ " more screenshot" ++ (if free == 1 then "" else "s") ++ " can be added."
, br [] []
@@ -582,7 +719,7 @@ view model =
, br [] []
]
, br [] []
- , b [] [ text "Important reminder" ]
+ , 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" ]
@@ -608,28 +745,28 @@ view model =
newform () =
form_ "" DupSubmit (model.state == Api.Loading)
- [ div [ class "mainbox" ] [ h1 [] [ text "Add a new visual novel" ], table [ class "formtable" ] titles ]
- , div [ class "mainbox" ]
- [ if List.isEmpty model.dupVNs then text "" else
- div []
+ [ 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 [ class "standout" ] [ text " (deleted)" ] else text ""
+ , if v.hidden then b [] [ text " (deleted)" ] else text ""
]
) model.dupVNs
]
- , fieldset [ class "submit" ] [ submitButton (if List.isEmpty model.dupVNs then "Continue" else "Continue anyway") model.state (isValid model) ]
]
+ , 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)
- [ div [ class "maintabs left" ]
- [ ul []
+ [ 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" ] ]
@@ -638,15 +775,14 @@ view model =
, li [ classList [("tabselected", model.tab == All )] ] [ a [ href "#", onClickD (Tab All ) ] [ text "All items" ] ]
]
]
- , div [ class "mainbox", classList [("hidden", model.tab /= General && model.tab /= All)] ] [ h1 [] [ text "General info" ], table [ class "formtable" ] geninfo ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Image && model.tab /= All)] ] [ h1 [] [ text "Image" ], image ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Staff && model.tab /= All)] ] [ h1 [] [ text "Staff" ], staff ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Cast && model.tab /= All)] ] [ h1 [] [ text "Cast" ], cast ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Screenshots && model.tab /= All)] ] [ h1 [] [ text "Screenshots" ], screenshots ]
- , div [ class "mainbox" ] [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
+ , 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/VNEdit.js b/elm/VNEdit.js
deleted file mode 100644
index 9d07036a..00000000
--- a/elm/VNEdit.js
+++ /dev/null
@@ -1,6 +0,0 @@
-wrap_elm_init('VNEdit', function(init, opt) {
- var app = init(opt);
- app.ports.ivRefresh.subscribe(function() {
- setTimeout(ivInit, 10);
- });
-});
diff --git a/elm/VNLengthVote.elm b/elm/VNLengthVote.elm
new file mode 100644
index 00000000..ceafe05a
--- /dev/null
+++ b/elm/VNLengthVote.elm
@@ -0,0 +1,216 @@
+module VNLengthVote exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Browser.Dom exposing (focus)
+import Task
+import Date
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Lib.Api as Api
+import Lib.RDate as RDate
+import Gen.Api as GApi
+import Gen.VNLengthVote as GV
+import Gen.Release as GR
+
+
+main : Program GV.Send Model Msg
+main = Browser.element
+ { init = \e -> (init e, Date.today |> Task.perform Today)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+type alias Model =
+ { state : Api.State
+ , open : Bool
+ , today : Int
+ , uid : String
+ , vid : String
+ , rid : List String
+ , maycount: Bool
+ , defrid : String
+ , hours : Maybe Int
+ , minutes : Maybe Int
+ , speed : Maybe Int
+ , length : Int -- last saved length
+ , notes : String
+ , rels : Maybe (List (String, String))
+ }
+
+init : GV.Send -> Model
+init f =
+ { state = Api.Normal
+ , today = 0
+ , open = False
+ , uid = f.uid
+ , vid = f.vid
+ , rid = Maybe.map (\v -> v.rid) f.vote |> Maybe.withDefault []
+ , maycount= f.maycount
+ , defrid = ""
+ , hours = Maybe.map (\v -> v.length // 60 ) f.vote
+ , minutes = Maybe.andThen (\v -> let n = modBy 60 v.length in if n == 0 then Nothing else Just n) f.vote
+ , speed = Maybe.map (\v -> if v.private then Just 8 else v.speed) f.vote |> Maybe.withDefault (Just 9)
+ , length = Maybe.map (\v -> v.length) f.vote |> Maybe.withDefault 0
+ , notes = Maybe.map (\v -> v.notes) f.vote |> Maybe.withDefault ""
+ , rels = Nothing
+ }
+
+enclen : Model -> Int
+enclen m = (Maybe.withDefault 0 m.hours) * 60 + Maybe.withDefault 0 m.minutes
+
+encode : Model -> GV.Send
+encode m =
+ { uid = m.uid
+ , vid = m.vid
+ , maycount = m.maycount
+ , vote = if enclen m == 0 then Nothing else Just
+ { rid = m.rid
+ , notes = m.notes
+ , speed = if m.speed == Just 8 then Nothing else m.speed
+ , length = enclen m
+ , private = m.speed == Just 8
+ }
+ }
+
+type Msg
+ = Noop
+ | Open Bool
+ | Today Date.Date
+ | Hours (Maybe Int)
+ | Minutes (Maybe Int)
+ | Speed (Maybe Int)
+ | Release Int String
+ | ReleaseAdd
+ | ReleaseDel Int
+ | Notes String
+ | RelLoaded GApi.Response
+ | Delete
+ | Submit
+ | Submitted GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Noop -> (model, Cmd.none)
+ Open b ->
+ if b && model.rels == Nothing
+ then ({ model | open = b, state = Api.Loading }, GR.send { vid = model.vid } RelLoaded)
+ else ({ model | open = b }, Cmd.none)
+ Today d -> ({ model | today = RDate.fromDate d |> RDate.compact }, Cmd.none)
+ Hours n -> ({ model | hours = n }, Cmd.none)
+ Minutes n -> ({ model | minutes = n }, Cmd.none)
+ Speed n -> ({ model | speed = n }, Cmd.none)
+ Release n s -> ({ model | rid = modidx n (always s) model.rid }, Cmd.none)
+ ReleaseAdd -> ({ model | rid = model.rid ++ [""] }, Cmd.none)
+ ReleaseDel n -> ({ model | rid = delidx n model.rid }, Cmd.none)
+ Notes s -> ({ model | notes = s }, Cmd.none)
+ RelLoaded (GApi.Releases rels) ->
+ let rel r = if r.rtype /= "trial" && r.released <= model.today then Just (r.id, RDate.showrel r) else Nothing
+ frels = List.filterMap rel rels
+ def = case frels of
+ [(r,_)] -> r
+ _ -> ""
+ in ({ model | state = Api.Normal
+ , rels = Just frels
+ , defrid = def
+ , rid = if not (List.isEmpty model.rid) then model.rid else [def]
+ }, if model.hours == Nothing then Task.attempt (always Noop) (focus "vnlengthhours") else Cmd.none)
+ RelLoaded e -> ({ model | state = Api.Error e }, Cmd.none)
+ Delete -> let m = { model | hours = Nothing, minutes = Nothing, rid = [model.defrid], notes = "", state = Api.Loading } in (m, GV.send (encode m) Submitted)
+ Submit -> ({ model | state = Api.Loading }, GV.send (encode model) Submitted)
+ Submitted (GApi.Success) -> ({ model | open = False, state = Api.Normal, length = enclen model }, Cmd.none)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model = div [class "lengthvotefrm"] <|
+ let
+ selcounted =
+ [ (Just 9, "-- how do you estimate your read/play speed? --")
+ , (Just 0, "Slow (e.g. low language proficiency or extra time spent on gameplay)")
+ , (Just 1, "Normal (no content skipped, all voices listened to end)")
+ , (Just 2, "Fast (e.g. fast reader or skipping through voices and gameplay)")
+ , (Nothing, "Don't count my play time (public)")
+ , (Just 8, "Don't count my play time (private)")
+ ]
+ seluncounted =
+ [ (Just 9, "-- visibility --")
+ , (Nothing, "Public (everyone can see your vote)")
+ , (Just 8, "Private (for your own administration)")
+ ]
+ cansubmit = enclen model > 0 && model.speed /= Just 9
+ && not (List.isEmpty model.rid)
+ && not (List.any (\r -> r == "") model.rid)
+ rels = Maybe.withDefault [] model.rels
+ frm = [ form_ "" (if cansubmit then Submit else Noop) False
+ [ br [] []
+ , if model.maycount then text "" else span []
+ [ b [] [ text "This visual novel is still in development." ]
+ , br [] []
+ , text "Which means your vote will not count towards the VN's length statistics."
+ , br_ 2
+ ]
+ , text "How long did you take to finish this VN?"
+ , br [] []
+ , text "Play time: "
+ , inputNumber "vnlengthhours" model.hours Hours [ Html.Attributes.min "0", Html.Attributes.max "435" ]
+ , text " hours "
+ , inputNumber "" model.minutes Minutes [ Html.Attributes.min "0", Html.Attributes.max "59" ]
+ , text " minutes"
+ , br [] []
+ , if model.defrid /= "" then text "" else table [] <| List.indexedMap (\n rid -> tr []
+ [ td [] [
+ inputSelect "" rid (Release n) []
+ <| ("", "-- select release --") :: rels
+ ++ if rid == "" || List.any (\(r,_) -> r == rid) rels then [] else [(rid, "[deleted/moved release: " ++ rid ++ "]")]
+ ]
+ , td []
+ [ if n == 0
+ then inputButton "+" ReleaseAdd [title "Add release"]
+ else inputButton "-" (ReleaseDel n) [title "Remove release"]
+ ]
+ ]) model.rid
+ , inputSelect "" model.speed Speed [] (if model.maycount then selcounted else seluncounted)
+ , case model.speed of
+ Just 9 -> span [] []
+ Just 8 -> span []
+ [ text "Your play time is not counted towards the VN's average and is not visible in the listings."
+ , text " It is only saved for your own administration and counted towards the personal play time displayed on your profile."
+ , br [] []
+ ]
+ Nothing -> span []
+ [ text "Your play time is not counted towards the VN's average, but is still visible in the listings and saved for your own administration."
+ , br [] []
+ ]
+ _ -> span []
+ [ text "- Only vote if you've completed all normal/true endings."
+ , br [] []
+ , text "- Exact measurements preferred, but rough estimates are accepted too."
+ , br [] []
+ ]
+ , inputTextArea "" model.notes Notes
+ [rows 2, cols 30, style "width" "100%", placeholder "(Optional) comments that may be helpful. For example, did you complete all the bad endings, how did you measure? etc." ]
+ , if model.length == 0 then text "" else inputButton "Delete my vote" Delete [style "float" "right"]
+ , if cansubmit then submitButton "Save" model.state True else text ""
+ , inputButton "Cancel" (Open False) []
+ , br_ 2
+ ] ]
+ in
+ [ text " "
+ , a [ onClickD (Open (not model.open)), href "#" ]
+ [ text <| if model.length == 0 then "Vote »"
+ else "My vote: " ++ String.fromInt (model.length // 60) ++ "h"
+ ++ if modBy 60 model.length /= 0 then String.fromInt (modBy 60 model.length) ++ "m" else "" ]
+ ] ++ case (model.open, model.state) of
+ (False, _) -> []
+ (_, Api.Normal) ->
+ if model.length == 0 && List.isEmpty (Maybe.withDefault [] model.rels)
+ then [ br_ 2, b [] [ text "There are no releases eligible for voting." ] ]
+ else frm
+ (_, Api.Error e) -> [ br_ 2, b [] [ text ("Error: " ++ Api.showResponse e) ] ]
+ (_, Api.Loading) -> [ span [ style "float" "right", class "spinner" ] [] ]
diff --git a/elm/checkall.js b/elm/checkall.js
deleted file mode 100644
index bc87bad4..00000000
--- a/elm/checkall.js
+++ /dev/null
@@ -1,16 +0,0 @@
-//order:9 - After Elm initialization
-
-/* "checkall" checkbox, usage:
- *
- * <input type="checkbox" class="checkall" name="$somename">
- *
- * Checking that will synchronize all other checkboxes with name="$somename".
- */
-document.querySelectorAll('input[type=checkbox].checkall').forEach(function(el) {
- el.addEventListener('click', function() {
- document.querySelectorAll('input[type=checkbox][name="'+el.name+'"]').forEach(function(el2) {
- if(el2.checked != el.checked)
- el2.click();
- });
- });
-});
diff --git a/elm/checkhidden.js b/elm/checkhidden.js
deleted file mode 100644
index 486b3c1d..00000000
--- a/elm/checkhidden.js
+++ /dev/null
@@ -1,17 +0,0 @@
-//order:9 - After Elm initialization
-
-/* "checkhidden" checkbox, usage:
- *
- * <input type="checkbox" class="checkhidden" value="$somename">
- *
- * Checking that will toggle the 'hidden' class of all elements with the "$somename" class.
- */
-document.querySelectorAll('input[type=checkbox].checkhidden').forEach(function(el) {
- var f = function() {
- document.querySelectorAll('.'+el.value).forEach(function(el2) {
- el2.classList.toggle('hidden', !el.checked);
- });
- };
- f();
- el.addEventListener('click', f);
-});
diff --git a/elm/elm-init.js b/elm/elm-init.js
deleted file mode 100644
index d9978111..00000000
--- a/elm/elm-init.js
+++ /dev/null
@@ -1,34 +0,0 @@
-//order:8 - After all regular JS, as other files may modify pageVars or modules in the Elm.* namespace.
-
-/* Add the X-CSRF-Token header to every POST request. Based on:
- * https://stackoverflow.com/questions/24196140/adding-x-csrf-token-header-globally-to-all-instances-of-xmlhttprequest/24196317#24196317
- */
-(function() {
- var open = XMLHttpRequest.prototype.open,
- token = document.querySelector('meta[name=csrf-token]').content;
-
- XMLHttpRequest.prototype.open = function(method, url) {
- var ret = open.apply(this, arguments);
- this.dataUrl = url;
- if(method.toLowerCase() == 'post' && /^\//.test(url))
- this.setRequestHeader('X-CSRF-Token', token);
- return ret;
- };
-})();
-
-
-/* Load all Elm modules listed in the pageVars.elm array */
-if(pageVars.elm) {
- //var t0 = performance.now();
- for(var i=0; i<pageVars.elm.length; i++) {
- var e = pageVars.elm[i];
- //if(e[0] != 'UList.DateEdit') continue;
- var mod = e[0].split('.').reduce(function(p, c) { return p[c] }, window.Elm);
- var node = document.getElementById('elm'+i);
- if(e.length > 1)
- mod.init({ node: node, flags: e[1] });
- else
- mod.init({ node: node });
- }
- //console.log("Elm modules initialized in " + (performance.now() - t0) + " milliseconds.");
-}
diff --git a/elm/elm.json b/elm/elm.json
index 3db9993a..6c052936 100644
--- a/elm/elm.json
+++ b/elm/elm.json
@@ -6,7 +6,6 @@
"elm-version": "0.19.1",
"dependencies": {
"direct": {
- "RomanErnst/erl": "2.1.1",
"elm/browser": "1.0.1",
"elm/core": "1.0.2",
"elm/file": "1.0.1",
diff --git a/elm/lib.js b/elm/lib.js
deleted file mode 100644
index 859cfc22..00000000
--- a/elm/lib.js
+++ /dev/null
@@ -1,15 +0,0 @@
-//order:0 - Before anything else that may use these functions.
-
-/* Load global page-wide variables from <script id="pagevars">...</script> and store them into window.pageVars */
-var e = document.getElementById('pagevars');
-window.pageVars = e ? JSON.parse(e.innerHTML) : {};
-
-
-// Utlity function to wrap the init() function of an Elm module.
-window.wrap_elm_init = function(mod, newinit) {
- mod = mod.split('.').reduce(function(p, c) { return p ? p[c] : null }, window.Elm);
- if(mod) {
- var oldinit = mod.init;
- mod.init = function(opt) { newinit(oldinit, opt) };
- }
-};
diff --git a/elm/polyfills.js b/elm/polyfills.js
deleted file mode 100644
index 4bb85105..00000000
--- a/elm/polyfills.js
+++ /dev/null
@@ -1,33 +0,0 @@
-//order:0 - Must be loaded before anything else.
-
-/* classList.toggle() */
-(function() {
- var historic = DOMTokenList.prototype.toggle;
- DOMTokenList.prototype.toggle = function(token, force) {
- if(arguments.length > 0 && this.contains(token) === force) {
- return force;
- }
- return historic.call(this, token);
- };
-})();
-
-
-/* Element.matches() and Element.closest() */
-if(!Element.prototype.matches)
- Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
-if(!Element.prototype.closest)
- Element.prototype.closest = function(s) {
- var el = this;
- if(!document.documentElement.contains(el)) return null;
- do {
- if(el.matches(s)) return el;
- el = el.parentElement || el.parentNode;
- } while(el !== null && el.nodeType === 1);
- return null;
- };
-
-
-/* NodeList.forEach */
-if(window.NodeList && !NodeList.prototype.forEach) {
- NodeList.prototype.forEach = Array.prototype.forEach;
-}
diff --git a/elm/searchtabs.js b/elm/searchtabs.js
deleted file mode 100644
index eed07ffc..00000000
--- a/elm/searchtabs.js
+++ /dev/null
@@ -1,11 +0,0 @@
-document.querySelectorAll('#searchtabs a').forEach(function(l) {
- l.onclick = function() {
- var str = document.getElementById('q').value;
- if(str.length > 1) {
- if(this.href.indexOf('/g') >= 0 || this.href.indexOf('/i') >= 0)
- this.href += '/list';
- this.href += '?q=' + encodeURIComponent(str);
- }
- return true;
- };
-});
diff --git a/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/data/icons/lang/fa.png b/icons/lang/fa.png
index 32aa1c44..32aa1c44 100644
--- a/data/icons/lang/fa.png
+++ b/icons/lang/fa.png
Binary files differ
diff --git a/icons/lang/fi.png b/icons/lang/fi.png
new file mode 100644
index 00000000..7ac075cd
--- /dev/null
+++ b/icons/lang/fi.png
Binary files differ
diff --git a/icons/lang/fr.png b/icons/lang/fr.png
new file mode 100644
index 00000000..2f551dc7
--- /dev/null
+++ b/icons/lang/fr.png
Binary files differ
diff --git a/icons/lang/ga.png b/icons/lang/ga.png
new file mode 100644
index 00000000..9885f597
--- /dev/null
+++ b/icons/lang/ga.png
Binary files differ
diff --git a/data/icons/lang/gd.png b/icons/lang/gd.png
index d0fb86c3..d0fb86c3 100644
--- a/data/icons/lang/gd.png
+++ b/icons/lang/gd.png
Binary files differ
diff --git a/icons/lang/he.png b/icons/lang/he.png
new file mode 100644
index 00000000..78362695
--- /dev/null
+++ b/icons/lang/he.png
Binary files differ
diff --git a/icons/lang/hi.png b/icons/lang/hi.png
new file mode 100644
index 00000000..3ee25fad
--- /dev/null
+++ b/icons/lang/hi.png
Binary files differ
diff --git a/icons/lang/hr.png b/icons/lang/hr.png
new file mode 100644
index 00000000..f13e48a7
--- /dev/null
+++ b/icons/lang/hr.png
Binary files differ
diff --git a/icons/lang/hu.png b/icons/lang/hu.png
new file mode 100644
index 00000000..ae3bef6c
--- /dev/null
+++ b/icons/lang/hu.png
Binary files differ
diff --git a/icons/lang/id.png b/icons/lang/id.png
new file mode 100644
index 00000000..4aa86adf
--- /dev/null
+++ b/icons/lang/id.png
Binary files differ
diff --git a/icons/lang/it.png b/icons/lang/it.png
new file mode 100644
index 00000000..0557e8ed
--- /dev/null
+++ b/icons/lang/it.png
Binary files differ
diff --git a/icons/lang/iu.png b/icons/lang/iu.png
new file mode 100644
index 00000000..60dca43e
--- /dev/null
+++ b/icons/lang/iu.png
Binary files differ
diff --git a/icons/lang/ja.png b/icons/lang/ja.png
new file mode 100644
index 00000000..f84d065c
--- /dev/null
+++ b/icons/lang/ja.png
Binary files differ
diff --git a/icons/lang/ko.png b/icons/lang/ko.png
new file mode 100644
index 00000000..eb0945b5
--- /dev/null
+++ b/icons/lang/ko.png
Binary files differ
diff --git a/icons/lang/la.png b/icons/lang/la.png
new file mode 100644
index 00000000..0082d99f
--- /dev/null
+++ b/icons/lang/la.png
Binary files differ
diff --git a/data/icons/lang/lt.png b/icons/lang/lt.png
index eb50db98..eb50db98 100644
--- a/data/icons/lang/lt.png
+++ b/icons/lang/lt.png
Binary files differ
diff --git a/data/icons/lang/lv.png b/icons/lang/lv.png
index e5d45b33..e5d45b33 100644
--- a/data/icons/lang/lv.png
+++ b/icons/lang/lv.png
Binary files differ
diff --git a/data/icons/lang/mk.png b/icons/lang/mk.png
index e3fd792d..e3fd792d 100644
--- a/data/icons/lang/mk.png
+++ b/icons/lang/mk.png
Binary files differ
diff --git a/icons/lang/ms.png b/icons/lang/ms.png
new file mode 100644
index 00000000..89d12c22
--- /dev/null
+++ b/icons/lang/ms.png
Binary files differ
diff --git a/icons/lang/nl.png b/icons/lang/nl.png
new file mode 100644
index 00000000..5b9ee268
--- /dev/null
+++ b/icons/lang/nl.png
Binary files differ
diff --git a/icons/lang/no.png b/icons/lang/no.png
new file mode 100644
index 00000000..f6f50ecc
--- /dev/null
+++ b/icons/lang/no.png
Binary files differ
diff --git a/icons/lang/pl.png b/icons/lang/pl.png
new file mode 100644
index 00000000..c567328a
--- /dev/null
+++ b/icons/lang/pl.png
Binary files differ
diff --git a/icons/lang/pt-br.png b/icons/lang/pt-br.png
new file mode 100644
index 00000000..2e7da252
--- /dev/null
+++ b/icons/lang/pt-br.png
Binary files differ
diff --git a/icons/lang/pt-pt.png b/icons/lang/pt-pt.png
new file mode 100644
index 00000000..b83ff833
--- /dev/null
+++ b/icons/lang/pt-pt.png
Binary files differ
diff --git a/icons/lang/ro.png b/icons/lang/ro.png
new file mode 100644
index 00000000..9caab41d
--- /dev/null
+++ b/icons/lang/ro.png
Binary files differ
diff --git a/icons/lang/ru.png b/icons/lang/ru.png
new file mode 100644
index 00000000..de447035
--- /dev/null
+++ b/icons/lang/ru.png
Binary files differ
diff --git a/icons/lang/sk.png b/icons/lang/sk.png
new file mode 100644
index 00000000..18cd9ed0
--- /dev/null
+++ b/icons/lang/sk.png
Binary files differ
diff --git a/data/icons/lang/sl.png b/icons/lang/sl.png
index 0f096cee..0f096cee 100644
--- a/data/icons/lang/sl.png
+++ b/icons/lang/sl.png
Binary files differ
diff --git a/icons/lang/sr.png b/icons/lang/sr.png
new file mode 100644
index 00000000..1d44d8f7
--- /dev/null
+++ b/icons/lang/sr.png
Binary files differ
diff --git a/icons/lang/sv.png b/icons/lang/sv.png
new file mode 100644
index 00000000..fb00fe65
--- /dev/null
+++ b/icons/lang/sv.png
Binary files differ
diff --git a/icons/lang/ta.png b/icons/lang/ta.png
new file mode 100644
index 00000000..c95b6b23
--- /dev/null
+++ b/icons/lang/ta.png
Binary files differ
diff --git a/icons/lang/th.png b/icons/lang/th.png
new file mode 100644
index 00000000..993113f8
--- /dev/null
+++ b/icons/lang/th.png
Binary files differ
diff --git a/icons/lang/tr.png b/icons/lang/tr.png
new file mode 100644
index 00000000..e2553714
--- /dev/null
+++ b/icons/lang/tr.png
Binary files differ
diff --git a/icons/lang/uk.png b/icons/lang/uk.png
new file mode 100644
index 00000000..5229c989
--- /dev/null
+++ b/icons/lang/uk.png
Binary files differ
diff --git a/icons/lang/ur.png b/icons/lang/ur.png
new file mode 100644
index 00000000..1ff90dbb
--- /dev/null
+++ b/icons/lang/ur.png
Binary files differ
diff --git a/icons/lang/vi.png b/icons/lang/vi.png
new file mode 100644
index 00000000..81fd0110
--- /dev/null
+++ b/icons/lang/vi.png
Binary files differ
diff --git a/icons/lang/zh-Hans.png b/icons/lang/zh-Hans.png
new file mode 100644
index 00000000..138a8397
--- /dev/null
+++ b/icons/lang/zh-Hans.png
Binary files differ
diff --git a/icons/lang/zh-Hant.png b/icons/lang/zh-Hant.png
new file mode 100644
index 00000000..31b90ef5
--- /dev/null
+++ b/icons/lang/zh-Hant.png
Binary files differ
diff --git a/icons/lang/zh.png b/icons/lang/zh.png
new file mode 100644
index 00000000..d06effec
--- /dev/null
+++ b/icons/lang/zh.png
Binary files differ
diff --git a/icons/list/add.svg b/icons/list/add.svg
new file mode 100644
index 00000000..d6f8f285
--- /dev/null
+++ b/icons/list/add.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g opacity="0.4" fill="#fff">
+<path d="M8.25 6.75v-3h-1.5v3h-3v1.5h3v3h1.5v-3h3v-1.5Z"/>
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+</g>
+</svg>
diff --git a/icons/list/l1.svg b/icons/list/l1.svg
new file mode 100644
index 00000000..7ccedc5c
--- /dev/null
+++ b/icons/list/l1.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g opacity="0.9" fill="#00b9f9">
+<path d="M7.5 0C3.37 0 0 3.37 0 7.5S3.37 15 7.5 15 15 11.62 15 7.5 11.62 0 7.5 0zm0 13.65c-3.38 0-6.15-2.77-6.15-6.15S4.12 1.35 7.5 1.35s6.15 2.78 6.15 6.15-2.78 6.15-6.15 6.15z"/>
+<path d="M5.25 3.07v8.85l6.6-4.42-6.6-4.43zm1.5 2.63 2.7 1.8-2.7 1.72V5.7z"/>
+</g>
+</svg>
diff --git a/icons/list/l2.svg b/icons/list/l2.svg
new file mode 100644
index 00000000..4ae634cd
--- /dev/null
+++ b/icons/list/l2.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#00cf00">
+<path d="m10.31 4.75-3.63 3.7-1.86-1.87-1 1 2.94 2.92 4.7-4.57z"/>
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+</g>
+</svg>
diff --git a/icons/list/l3.svg b/icons/list/l3.svg
new file mode 100644
index 00000000..df8185cd
--- /dev/null
+++ b/icons/list/l3.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#ff7819" opacity=".9">
+<path d="M7.5 0C3.37 0 0 3.37 0 7.5S3.37 15 7.5 15 15 11.62 15 7.5 11.62 0 7.5 0zm0 13.65c-3.38 0-6.15-2.77-6.15-6.15S4.12 1.35 7.5 1.35s6.15 2.78 6.15 6.15-2.78 6.15-6.15 6.15z"/>
+<path d="M5.25 4.5h1.5v6h-1.5zm3 0h1.5v6h-1.5z"/>
+</g>
+</svg>
diff --git a/icons/list/l4.svg b/icons/list/l4.svg
new file mode 100644
index 00000000..4c35bb5a
--- /dev/null
+++ b/icons/list/l4.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#f80000" opacity=".9">
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+<path d="M9 4.5H4.5v6h6v-6H9zM9 9H6V6h3v3z"/>
+</g>
+</svg>
diff --git a/icons/list/l5.svg b/icons/list/l5.svg
new file mode 100644
index 00000000..694fc174
--- /dev/null
+++ b/icons/list/l5.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#ffe700" opacity=".9">
+<path d="M8.74 8.47A1.25 1.25 0 0 1 7.5 9.72a1.25 1.25 0 0 1-1.25-1.25A1.25 1.25 0 0 1 7.5 7.22a1.25 1.25 0 0 1 1.25 1.25z"/>
+<path d="m15 5.83-5-1.02L7.5.35 5 4.81 0 5.82l3.46 3.75-.6 5.08 4.63-1.96 4.64 1.96-.59-5.07L15 5.83zm-7.5 5.7-3.38 1.43.44-3.72-2.51-2.75 3.63-.73L7.5 2.53l1.82 3.24 3.63.72-2.51 2.72.44 3.77z"/>
+</g>
+</svg>
diff --git a/icons/list/l6.svg b/icons/list/l6.svg
new file mode 100644
index 00000000..e3a04a4f
--- /dev/null
+++ b/icons/list/l6.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#b60700">
+<path d="M11.57 1.8a3.4 3.4 0 0 0-2.9 1.67 3.4 3.4 0 0 0-2.9-1.66c-.43 0-.86.08-1.26.25l.85.87a3 3 0 0 1 .42-.05c.81.01 1.56.45 1.98 1.15l.91 1.51.92-1.51a2.32 2.32 0 0 1 1.98-1.15 2.42 2.42 0 0 1 2.35 2.48c0 1.04-.88 2.53-2.05 4.07l.76.77C13.9 8.57 15 6.76 15 5.36a3.5 3.5 0 0 0-3.43-3.55zm-.71 8.87a35.6 35.6 0 0 1-2.19 2.32C6.4 10.78 3.42 7.26 3.42 5.36c0-.6.22-1.2.62-1.65a1.9 1.9 0 0 0-.88-.64 3.6 3.6 0 0 0-.81 2.29c0 3.4 6.32 9.1 6.32 9.1s1.19-1.07 2.53-2.56c.21-.23-.27-1.32-.34-1.23z"/>
+<path d="m0 .99.9-.9 13.9 13.92-.9.9z"/>
+</g>
+</svg>
diff --git a/icons/list/unknown.svg b/icons/list/unknown.svg
new file mode 100644
index 00000000..309ed63f
--- /dev/null
+++ b/icons/list/unknown.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#fff" opacity=".9">
+<path d="M6.75 10.5h1.5V12h-1.5z"/>
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+<path d="M10.42 5.29A3 3 0 0 0 4.5 6H6a1.5 1.5 0 0 1 3 .25A1.56 1.56 0 0 1 7.42 7.5c-.37 0-.67.3-.67.67v1.58h1.5v-.86a3 3 0 0 0 2.17-3.6z"/>
+</g>
+</svg>
diff --git a/icons/plat/and.svg b/icons/plat/and.svg
new file mode 100644
index 00000000..d4fe2f30
--- /dev/null
+++ b/icons/plat/and.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m11.44 3.32 1.08-1.99c.07-.13.04-.23-.08-.3-.13-.06-.23-.03-.3.1l-1.09 2a7.43 7.43 0 0 0-3.04-.64 7.3 7.3 0 0 0-3.04.63l-1.1-2C3.8 1 3.7.97 3.58 1.03c-.12.07-.14.17-.07.3l1.07 1.99a6.64 6.64 0 0 0-2.6 2.33C1.32 6.65 1 7.73 1 8.92h14c0-1.19-.32-2.27-.97-3.27-.64-1-1.5-1.77-2.59-2.33zM5.23 6.21a.57.57 0 0 1-.42.17.54.54 0 0 1-.4-.17.58.58 0 0 1-.17-.42c0-.16.05-.3.16-.41a.54.54 0 0 1 .41-.18c.16 0 .3.06.42.18.12.11.17.25.17.41 0 .16-.05.3-.17.42zm6.38 0a.54.54 0 0 1-.4.17.57.57 0 0 1-.43-.17.57.57 0 0 1-.17-.42c0-.16.06-.3.17-.41a.57.57 0 0 1 .42-.18c.16 0 .3.06.4.18.12.11.18.25.18.41a.6.6 0 0 1-.17.42zM1 15h14V9.46H1z" fill="#839e2e"/>
+</svg>
diff --git a/icons/plat/bdp.svg b/icons/plat/bdp.svg
new file mode 100644
index 00000000..cb84b1a8
--- /dev/null
+++ b/icons/plat/bdp.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#00b2ff">
+<path d="m1.68 4.59-.02.01A22.1 22.1 0 0 0 .08 7.13l-.04.09-.02.03a.23.23 0 0 0 .03.25c.26.42 1.73.9 5 .9 2.46 0 5.05-.4 5.05-1.14 0-.72-2.56-1.14-5.04-1.14-.82 0-1.64.11-1.87.15a61.54 61.54 0 0 1 1.16-1.66l-.02-.02zm.96 2.67c0-.15.92-.37 2.42-.37s2.42.22 2.42.37-.92.37-2.42.37-2.42-.22-2.42-.37z"/>
+<path d="M3.54 10.99s12.2.5 12.46-3.82c.2-3.5-8.45-3.16-8.46-3.16-.01 0-.07 0-.07.05s.03.06.06.06c2.4 0 6.31.96 6.19 3.06-.1 1.7-3.16 3.7-10.17 3.7-.05 0-.07.02-.07.05 0 .03.01.05.06.06z"/>
+</g>
+</svg>
diff --git a/icons/plat/dos.svg b/icons/plat/dos.svg
new file mode 100644
index 00000000..fb36f899
--- /dev/null
+++ b/icons/plat/dos.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m0 3.15 3.29-.08c.64-.02 1.23.05 1.83.26v.12c-.52.45-.89.98-1.23 1.58l-2 .02.02 5.79 1.18.08 1.2-.01c.73-.01 1.4-1.19 1.46-1.83l.18-2.01C6 6.18 6.5 5.5 7.28 5.14c.49.83.7 1.76.7 2.73 0 3.07-1.86 5-4.8 4.9L0 12.64z" fill="#c00"/>
+<path d="M9.71 3.04a4.3 4.3 0 0 0-1.35 1.58l-.33-.01c-1.16 0-2.22 1.04-2.36 2.2l-.3 2.54c-.04.36-.34.72-.57.98-.2.21-.42.25-.7.25h-.03a6.66 6.66 0 0 1-.55-2.63c0-2.88 1.58-5.17 4.52-5.17.58 0 1.13.11 1.67.26zm-3.59 9.54.95-.92.44-.63.72.01c.34.76.9 1.28 1.59 1.73-.5.17-1 .26-1.52.26-.76 0-1.47-.18-2.18-.45z" fill="#f0f"/>
+<path d="m8.3 9.65 1.84.01c.2 1.2.97 1.53 2.12 1.53.73 0 1.8-.17 1.8-1.11 0-.5-.37-.75-.76-.96l.1-1.88c1.46.4 2.6.97 2.6 2.73 0 2.07-1.94 2.95-3.72 2.95-1.28 0-2.93-.38-3.61-1.61a2.88 2.88 0 0 1-.42-1.32l.02-.1zM15.68 6H13.8c-.15-.94-.92-1.4-1.83-1.4-.63 0-1.65.28-1.68 1.08-.01.04 0 .07.01.1l.37 1.1c.08.22.1.47.1.7 0 .28-.04.55-.08.83-1.32-.38-2.24-1.03-2.24-2.55 0-2.04 1.61-3 3.45-3 2.05 0 3.65.98 3.78 3.13z" fill="#cca300"/>
+<path d="M12.28 4.97c.46.89.7 1.86.7 2.89 0 1.07-.26 2.08-.71 3.02l-.35.02c-.38 0-1.28-.27-1.28-.77 0-.09.01-.17.04-.25l.34-1.24c.07-.25.04-.54.04-.79 0-.96-.4-1.56-.43-2.22-.02-.47.79-.68 1.07-.68.2 0 .38 0 .58.02z" fill="#f0f"/>
+</svg>
diff --git a/icons/plat/drc.svg b/icons/plat/drc.svg
new file mode 100644
index 00000000..fcd9a78d
--- /dev/null
+++ b/icons/plat/drc.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.74.98c.6.09 1.2.3 1.52.4.64.19 1.67.8 2.04 1.08.17.14.54.5.68.68l.56.8c.27.39.58.97.85 1.5.3.6.32.77.4 1.26l.2.88c.02.15.04.71-.3.79a.72.72 0 0 1-.76-.3c-.2-.3-.17-.73-.4-1.23-.17-.4-.24-.98-.52-1.4l-.22-.37c-.38-.59-.79-1.06-1.03-1.37-.2-.25-.6-.6-.97-.85a6.65 6.65 0 0 0-2.33-.82c-.46-.06-.76-.13-1.2-.16-.55-.04-.8-.09-1.51 0-.27.07-.7.1-1.07.24a9.4 9.4 0 0 0-2.74 1.76c-.46.49-.69.75-1 1.37-.14.3-.41.78-.52 1.12-.08.34-.22.97-.2 1.28.02.55-.03.66 0 1.11.01.4.14.69.2 1.02.05.23.27 1.02.4 1.2A4.36 4.36 0 0 0 4.2 13.3c.26.14 1.07.35 1.3.42.41.07.94.12 1.45.13.54 0 1.28-.06 1.73-.23.48-.18 1.04-.43 1.04-.42.22-.13.57-.33.67-.42.21-.13.33-.19.46-.31.17-.16.28-.25.42-.44l.3-.42c.13-.2.2-.42.32-.68.15-.4.12-.38.15-.8.03-.11 0-.52-.02-.59-.07-.37-.05-.7-.13-1.07-.12-.49-.21-.86-.36-1.33a3.73 3.73 0 0 0-.86-1.37 4.51 4.51 0 0 0-1.6-1.06c-.22-.08-.6-.16-.84-.2-.5-.06-.6-.03-1.08 0-.17.04-.56.12-.7.17-.42.15-.58.24-1 .5-.2.12-.36.2-.56.4-.16.15-.28.4-.41.56-.18.24-.38.57-.52.93-.14.35-.2.84-.17 1.16 0 .25.07.7.1.79l.1.46c.01.06.17.38.2.42.08.2.25.46.54.74.2.23.62.46.92.55.27.15.74.17 1 .17.12 0 .44-.03.6-.07.35-.08.4-.08.76-.23.31-.15.53-.2.74-.36 0 0 .3-.24.44-.4.07-.1.2-.53.18-.8-.02-.37.04-.58-.04-.88A3.2 3.2 0 0 0 9 7.75a2.02 2.02 0 0 0-.78-.6c-.2-.1-.65-.18-.85-.2-.36-.05-.67.12-.86.3-.16.2-.48.58-.35.98.11.35.25.74.8.71.36.08.79-.04.93.03.21.12.3.39.22.57-.06.36-.4.4-.76.43-.33.05-1.16-.1-1.34-.24-.12-.08-.3-.25-.44-.37-.1-.08-.2-.27-.28-.4a1.49 1.49 0 0 1-.15-.4c-.03-.14-.09-.38-.07-.63.02-.16.06-.23.1-.48.1-.27.2-.48.38-.72.23-.23.52-.51.8-.64.26-.13.7-.18 1.1-.16.32.03.7.07.95.16.43.15.56.18.91.4.3.2.6.53.77.81.36.74.56 1.55.41 2.65 0 .18-.07.36-.18.6-.1.23-.3.51-.58.72-.1.1-.29.23-.43.3-.2.13-.35.2-.56.3-.2.1-.41.2-.7.27-.3.13-.55.13-.94.19-.38.1-1.44.03-2.06-.25-.43-.18-.91-.5-1.28-.88-.2-.22-.4-.6-.46-.68-.29-.56-.34-1.08-.39-1.2-.06-.14-.1-.48-.14-.7a5.38 5.38 0 0 1 .25-2.04c.14-.28.23-.52.33-.65.18-.26.26-.42.41-.6.23-.27.4-.5.62-.66.57-.46 1.04-.7 1.69-1 .8-.33 1.03-.27 1.79-.25.34 0 .83.1 1.07.15.45.06.71.17 1.02.25.32.1.51.24.78.4.22.14.48.34.65.5.44.32.65.66.92 1.07.05.07.37.75.42.94l.14.52.15.76c.06.3.16.85.2 1.37.08.44-.04 1.35-.1 1.63a4.63 4.63 0 0 1-1.72 2.46c-.21.18-.4.28-.73.48-.44.27-.79.44-1.41.69-.26.08-.58.19-.93.26a9.8 9.8 0 0 1-2.12.03 11.2 11.2 0 0 1-1.96-.44c-.25-.1-.73-.3-.93-.4a6.8 6.8 0 0 1-2.04-1.84c-.1-.18-.44-.73-.51-.97-.2-.4-.34-.89-.42-1.1C.25 9.7.11 9.38.05 8.9c0-.11-.02-.38-.05-.53.04-.34-.01-.65.05-1.02.06-.3.14-.43.16-.71.02-.12.17-.48.2-.64.13-.26.16-.47.32-.77.24-.45.48-.98.85-1.44.1-.14.41-.48.51-.6.25-.24.39-.28.67-.52.02-.02.2-.23.4-.3.31-.24.59-.33.89-.56.6-.3.82-.4 1.42-.65.35-.12.83-.19 1-.26C6.8.87 7.14.77 7.5.8c.39 0 .78.02 1.17.06.27.01.61.03 1.08.13z" fill="#cf3311"/>
+</svg>
diff --git a/icons/plat/dvd.svg b/icons/plat/dvd.svg
new file mode 100644
index 00000000..aeb5f8ad
--- /dev/null
+++ b/icons/plat/dvd.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path fill="#ddd" d="M9.11 5.46S8.01 6.81 8.07 6.9c.08-.1-.4-1.46-.4-1.46l-.4-1.23h-5.9l-.2.89h1.91c1 0 1.6.4 1.43 1.13-.18.8-1.05 1.13-1.97 1.13h-.35l.45-1.93H1.09L.44 8.26h2.19c1.65 0 3.21-.89 3.5-2.03.04-.2.04-.73-.09-1.04l-.01-.04c-.01-.01-.02-.06.01-.07l.04.03.03.06 1.4 4.04 3.55-4.1h1.86c1 0 1.61.4 1.44 1.13-.18.8-1.05 1.13-1.98 1.13h-.35l.45-1.93h-1.54l-.66 2.83h2.2c1.64 0 3.22-.89 3.48-2.03.27-1.13-.89-2.03-2.55-2.03h-3.27a52.65 52.65 0 0 0-1.03 1.25zM7.55 9.21c-4.17 0-7.55.5-7.55 1.1 0 .6 3.38 1.1 7.55 1.1 4.18 0 7.57-.5 7.57-1.1 0-.6-3.39-1.1-7.57-1.1zm-.25 1.5c-.96 0-1.73-.17-1.73-.37 0-.2.77-.37 1.73-.37.95 0 1.72.17 1.72.37 0 .2-.77.37-1.72.37z"/>
+</svg>
diff --git a/icons/plat/fm7.svg b/icons/plat/fm7.svg
new file mode 100644
index 00000000..e818b620
--- /dev/null
+++ b/icons/plat/fm7.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12.76h2.1v-1h-.55v-1.62l1.21 2.62h.73l1-2.54v1.54h-.37v1h2.5v-1h-.57V9.01h.57v-.9H4.36l-1 2.36-1.1-2.36H0v.9h.53v2.75H0Zm0-9.52v.97h.52v2.72H0v.94h2.71v-.94h-.55v-.7h1.58v-.96H2.18V4.22h1.95v.9h1.3V3.24Zm7.37.01L7.35 6.5h1.52v-.36a1 1 0 0 1 .05-.23.5.5 0 0 1 .09-.15.7.7 0 0 1 .15-.15.7.7 0 0 1 .2-.09c.07-.02.22-.04.82-.04l2.99.01a10.9 10.9 0 0 0-.83.52c-.13.1-.25.2-.37.34a14.48 14.48 0 0 0-.75 1.19l-.22.48a13.07 13.07 0 0 0-.78 1.93c-.12.37-.23.77-.3 1.24-.06.47-.07 1.02-.09 1.57h2.96v-1a6.6 6.6 0 0 1 .14-1.26l.22-.93.27-.97.15-.56a5.94 5.94 0 0 1 .32-.8 3.57 3.57 0 0 1 .55-.75l.41-.45c.14-.14.27-.24.4-.32.11-.09.22-.15.34-.19.13-.04.26-.05.4-.06l.02-2.25-8.64.02z" fill="#85c20a"/>
+</svg>
diff --git a/icons/plat/fm8.svg b/icons/plat/fm8.svg
new file mode 100644
index 00000000..7d9f6c09
--- /dev/null
+++ b/icons/plat/fm8.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12.76h2.1v-1h-.55v-1.62l1.21 2.62h.73l1-2.54v1.55h-.37v.99h2.5v-1h-.57V9.03h.57v-.9H4.36l-1 2.36-1.1-2.37H0v.9h.53v2.76H0Zm0-9.52v.98h.52v2.7H0v.96h2.71v-.95h-.55v-.7h1.58v-.96H2.18V4.22h1.95v.9h1.3V3.24Zm11.52.01-1.04.05c-.29.02-.42.04-.56.06a25.02 25.02 0 0 0-.73.12 5.7 5.7 0 0 0-.57.17l-.17.07-.18.08-.15.09-.11.08-.1.07-.1.12a2.72 2.72 0 0 0-.27.43 2.02 2.02 0 0 0-.11.52 10.35 10.35 0 0 0-.04 1.36l.06.3a1.24 1.24 0 0 0 .3.49c.05.05.1.1.17.13a2.94 2.94 0 0 0 .57.22l.4.1.32.09c.07.02.08.04.09.05l-.01.04a.26.26 0 0 1-.08.02l-.2.04a6.05 6.05 0 0 0-.68.19c-.1.04-.18.08-.26.14l-.24.2a1.19 1.19 0 0 0-.28.47l-.1.32-.05.35-.04.41a5.2 5.2 0 0 0 .07 1.13 1.3 1.3 0 0 0 .26.5 2.56 2.56 0 0 0 .54.48 2.94 2.94 0 0 0 .95.36 10.86 10.86 0 0 0 3.28.25c.3 0 .51-.03.86-.08.34-.05.83-.14 1.13-.2.31-.08.44-.12.55-.18.1-.05.2-.1.29-.18a1.64 1.64 0 0 0 .47-.56 3.51 3.51 0 0 0 .24-.85 9.75 9.75 0 0 0-.03-.9 1.98 1.98 0 0 0-.1-.42c-.03-.13-.08-.25-.11-.33l-.09-.2a1.48 1.48 0 0 0-.2-.3.66.66 0 0 0-.14-.15.95.95 0 0 0-.2-.11 3.37 3.37 0 0 0-.5-.2 3 3 0 0 0-.23-.07l-.24-.05-.1-.04v-.04c0-.01 0-.03.02-.04a.4.4 0 0 1 .13-.02l.33-.04a3.4 3.4 0 0 0 1.05-.39c.07-.06.14-.14.2-.22.05-.09.1-.17.13-.26s.06-.18.07-.42v-.91a3.22 3.22 0 0 0-.23-1.02 1.79 1.79 0 0 0-.27-.41l-.2-.16a2.6 2.6 0 0 0-.78-.37 5.21 5.21 0 0 0-1.33-.27 16.14 16.14 0 0 0-1.66-.1zm.21.74c.14 0 .3.02.45.05a1.35 1.35 0 0 1 .65.33c.07.1.13.2.17.32a4.65 4.65 0 0 1 .11.97 17.64 17.64 0 0 1-.06 1.31.87.87 0 0 1-.08.28.64.64 0 0 1-.18.21c-.08.06-.18.12-.27.16a1.41 1.41 0 0 1-.63.16 2.5 2.5 0 0 1-.6-.04 2.05 2.05 0 0 1-.45-.14 1.4 1.4 0 0 1-.34-.25.37.37 0 0 1-.06-.08.82.82 0 0 1-.07-.14 6.33 6.33 0 0 1-.08-.84v-.67c0-.23.02-.43.03-.56 0-.13.01-.18.02-.24a.77.77 0 0 1 .22-.44 1.54 1.54 0 0 1 .8-.38h.37zm-.03 4.26a2.47 2.47 0 0 1 .55.09c.12.03.26.08.36.13a.83.83 0 0 1 .36.36 5.18 5.18 0 0 1 .13 1.33 13.75 13.75 0 0 1-.12 1.25.86.86 0 0 1-.35.46 1.84 1.84 0 0 1-1.33.2 2.2 2.2 0 0 1-.68-.28.78.78 0 0 1-.24-.28 1.27 1.27 0 0 1-.1-.39c-.03-.13-.03-.27-.03-.45a15.52 15.52 0 0 1 .01-1.03l.04-.31a1.4 1.4 0 0 1 .14-.58c.04-.06.09-.1.13-.13l.15-.1c.06-.04.12-.09.19-.12l.2-.07a1.55 1.55 0 0 1 .49-.08h.1z" fill="#abad1f"/>
+</svg>
diff --git a/icons/plat/fmt.svg b/icons/plat/fmt.svg
new file mode 100644
index 00000000..f27cec1b
--- /dev/null
+++ b/icons/plat/fmt.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.72 5.2H16V3.25H7.14v1.97h3.24v2.67h2.34Zm.01 2.91h-2.35v4.65h2.35zM0 12.76h2.1v-1h-.55v-1.62l1.21 2.62h.73l1-2.54v1.55h-.37v.99h2.5v-1h-.57V9.03h.57v-.9H4.36l-1 2.36-1.1-2.37H0v.9h.53v2.76H0Zm0-9.52v.98h.52v2.71H0v.95h2.71v-.95h-.55v-.7h1.58v-.96H2.18V4.22h1.95v.9h1.3V3.25Z" fill="#2eb85c"/>
+</svg>
diff --git a/icons/plat/gba.svg b/icons/plat/gba.svg
new file mode 100644
index 00000000..78516d84
--- /dev/null
+++ b/icons/plat/gba.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path fill="#1900cd" d="M0 4h16v8H0z"/>
+<path d="M8.1 9.74H7.03V6.2h1.5c.79 0 1.18.53 1.18 1.68 0 1.4-.42 1.85-1.63 1.85zm7.33-5.01H14.2l-.04.1-1.37 4.18-1.47-4.17-.04-.09H10l.1.28.26.68a2.02 2.02 0 0 0-1.82-.98H5.91v5.42L3.96 4.84l-.03-.09h-1l-.04.09-2.22 6.15-.1.28h2.8V9.82H2.23l1.14-3.39 1.6 4.73.04.09h3.27c1.71 0 2.6-1.12 2.6-3.3 0-.4-.04-.73-.1-1.04l1.57 4.25.03.09h.76l.04-.09 2.15-6.15z" fill="#fff"/>
+</svg>
diff --git a/icons/plat/gbc.svg b/icons/plat/gbc.svg
new file mode 100644
index 00000000..8deac3c0
--- /dev/null
+++ b/icons/plat/gbc.svg
@@ -0,0 +1,5 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M15.5 9.99c-.34-.1-.72-.04-.72-.04s-1.09.11-1.66-.01c-.88-.16-.85-1.14-.85-1.14s-.03-.65.5-1.53C13.43 6.2 14.15 6 14.15 6s.18-.07.35.02c.1.63.7.63.7.63s.84.05.8-1c-.1-.88-.91-1-1.26-1.08-1.02-.2-1.68.37-1.68.37s-.7.39-1.51 1.73a3.61 3.61 0 0 0-.63 1.48s-.07.15-.04.84c-.02.54.18.93.18.93s.3.93 1.23 1.31c.57.25 1.4.24 1.4.24s.81 0 1.43-.04c.34.02.5-.16.5-.16s.38-.25.34-.7c0 0-.07-.46-.46-.58z" fill="#c10b44"/>
+<path d="M1.75 5.28A3.72 3.72 0 0 0 .03 9.04c.19 1.97 2.65 3.2 4.73 1.96.19-.1.12-.1.18-.15l.42-2.75H2.87l-.19 1.27h1.04l-.12.67c-.6.25-1.6.16-2.04-.64-.27-.51-.53-1.64.63-2.68.94-.85 2.56-1 3.5-.56 0 0 .12-.7.2-1.37a4.5 4.5 0 0 0-4.14.5z" fill="#6aab21"/>
+<path d="m6.55 4.65-1.03 6.7h3.02c1.24 0 3.06-1.9 1.4-3.44 1.7-1.82.02-3.24-.89-3.26H6.54zm.74 4.06h1.13c1.06 0 1.03 1.34-.15 1.34H7.09zm.42-2.63h.87c.95 0 .76 1.25-.15 1.25H7.5z" fill="#c79d05"/>
+</svg>
diff --git a/icons/plat/ios.svg b/icons/plat/ios.svg
new file mode 100644
index 00000000..67f742e1
--- /dev/null
+++ b/icons/plat/ios.svg
@@ -0,0 +1,10 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M.06 6.88h1.16V1.97H.06zm.58-5.56c.36 0 .64-.27.64-.62A.63.63 0 0 0 .64.07.63.63 0 0 0 0 .7c0 .35.28.62.64.62zM5.06.08C3.11.08 1.9 1.41 1.9 3.54s1.2 3.45 3.16 3.45 3.17-1.32 3.17-3.45S7.01.08 5.06.08zm0 1.02c1.2 0 1.95.95 1.95 2.44s-.76 2.43-1.95 2.43c-1.2 0-1.95-.94-1.95-2.43 0-1.5.76-2.44 1.95-2.44zM8.72 5c.05 1.23 1.06 2 2.6 2 1.62 0 2.63-.8 2.63-2.07 0-1-.57-1.56-1.93-1.87l-.77-.18c-.82-.2-1.16-.45-1.16-.9 0-.55.51-.92 1.27-.92s1.29.37 1.34 1h1.14c-.02-1.18-1-1.98-2.47-1.98-1.46 0-2.49.8-2.49 2 0 .95.58 1.54 1.82 1.82l.86.2c.85.2 1.19.48 1.19.96 0 .56-.56.96-1.37.96S9.95 5.62 9.88 5H8.72z" fill="url(#a)" style="fill:url(#a)" transform="translate(0 3.95) scale(1.14651)"/>
+<defs>
+<linearGradient id="a" x1=".65" x2="12.67" y1=".71" y2="6.06" gradientUnits="userSpaceOnUse">
+<stop stop-color="#3367ff" offset="0"/>
+<stop stop-color="#8be250" offset=".71"/>
+<stop stop-color="#dbf141" offset="1"/>
+</linearGradient>
+</defs>
+</svg>
diff --git a/icons/plat/lin.svg b/icons/plat/lin.svg
new file mode 100644
index 00000000..ea15db59
--- /dev/null
+++ b/icons/plat/lin.svg
@@ -0,0 +1,7 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M16.005 14.85c-.882-1.195-1.556-2.474-1.538-4.147.037-3.373.37-9.628-5.533-9.636-.24 0-.49.01-.752.03-6.596.535-4.846 7.542-4.944 9.888-.12 1.716-.64 3.453-1.874 5.016L16 16c0-.483.004-1.002.005-1.15z" fill="#4d4d4d"/>
+<path d="M11.521 11.214c-.115-.225-.348-.44-.747-.604l-.002-.001c-.828-.357-1.187-.382-1.65-.684-.751-.486-1.373-.657-1.89-.654-.27 0-.512.049-.728.124-.63.218-1.047.672-1.309.921v.001c-.052.05-.118.094-.279.212-.161.119-.403.298-.752.56-.31.234-.41.538-.303.894.107.356.448.767 1.073 1.122l.002.001c.388.23.652.538.956.784.152.123.312.232.505.315s.417.139.698.155c.66.039 1.147-.16 1.576-.407.43-.247.794-.549 1.212-.685h.001c.857-.27 1.467-.812 1.658-1.326.096-.258.093-.502-.021-.728z" fill="#f3d427"/>
+<path d="M9.349 12.485c-.682.357-1.477.79-2.324.79-.846 0-1.515-.393-1.996-.776-.24-.192-.435-.382-.582-.52-.256-.204-.225-.488-.12-.48.176.023.202.255.313.36.15.14.338.323.565.505.455.362 1.06.714 1.82.714.758 0 1.642-.447 2.183-.752.305-.172.695-.481 1.012-.716.244-.18.235-.395.435-.372s.053.24-.228.486c-.282.246-.721.574-1.078.76z" fill="#202020"/>
+<path d="M3.707 16.07a9.167 9.167 0 0 0 .637-3.015c.076.055.335.231.45.297h.001c.338.2.592.493.921.76.33.265.742.495 1.363.532.06.003.118.005.176.005.64 0 1.14-.21 1.557-.45.453-.26.814-.548 1.157-.66h.001c.724-.228 1.3-.631 1.627-1.1.27 1.067.71 2.36 1.187 3.59l-9.077.041zm7.89-7.425a2.83 2.83 0 0 1-.24 1.202 2.33 2.33 0 0 1-.335.563 10.973 10.973 0 0 0-.583-.242c-.131-.05-.234-.084-.34-.12.077-.094.228-.204.285-.342.085-.208.127-.411.135-.654 0-.01.003-.018.003-.029a1.82 1.82 0 0 0-.094-.634 1.172 1.172 0 0 0-.29-.494.595.595 0 0 0-.416-.19h-.021a.608.608 0 0 0-.406.16 1.17 1.17 0 0 0-.325.472c-.085.208-.13.43-.135.655-.002.01-.002.018-.002.028-.003.134.006.257.027.376-.3-.15-.683-.26-.948-.323a3.242 3.242 0 0 1-.027-.359v-.033c-.005-.441.067-.82.236-1.203.168-.384.377-.66.67-.884a1.47 1.47 0 0 1 .925-.331h.016c.335 0 .62.099.915.313.298.218.513.49.687.872.17.371.251.734.26 1.165 0 .011 0 .02.003.032zm-5.056.44a2.617 2.617 0 0 0-.742.338c.017-.128.02-.257.006-.402l-.001-.023a1.756 1.756 0 0 0-.127-.516.994.994 0 0 0-.259-.381.42.42 0 0 0-.317-.12c-.113.01-.206.065-.294.173a1.003 1.003 0 0 0-.188.42c-.042.18-.054.367-.035.552l.002.022c.019.194.057.355.126.518a.986.986 0 0 0 .311.421c-.11.086-.183.146-.274.213l-.207.153a1.9 1.9 0 0 1-.43-.645 2.898 2.898 0 0 1-.24-1.026V8.78c-.02-.38.017-.708.121-1.047.105-.34.244-.585.447-.786.202-.202.405-.304.651-.316l.057-.002c.222 0 .42.075.626.24.223.18.392.408.533.731.142.323.217.646.238 1.027v.003a3 3 0 0 1-.004.456z" fill="#ccc"/>
+<path d="M7.658 9.997c.029.09.174.075.259.12.073.037.133.121.216.124.08.002.203-.028.213-.107.014-.105-.138-.17-.236-.21-.125-.048-.286-.073-.404-.008-.027.016-.057.051-.048.08zm-.86 0c-.029.09-.174.075-.258.12-.074.037-.134.121-.217.124-.08.002-.203-.028-.213-.107-.013-.105.138-.17.236-.21.126-.048.287-.073.404-.008.027.016.057.051.048.08z" fill="#202020"/>
+</svg>
diff --git a/icons/plat/mac.svg b/icons/plat/mac.svg
new file mode 100644
index 00000000..e71e68a6
--- /dev/null
+++ b/icons/plat/mac.svg
@@ -0,0 +1,9 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m1.89 5.8.01-.02c.82-1.27 2.12-2 3.33-2 1.24 0 2.02.67 3.04.67 1 0 1.6-.68 3.03-.68 1.08 0 2.23.6 3.04 1.61-.22.13-.42.27-.6.42H1.88z" fill="#4d9537"/>
+<path d="M10.21 2.63A3.5 3.5 0 0 0 11 .05c-.85.05-1.85.6-2.43 1.3-.52.65-.96 1.6-.79 2.52.93.03 1.89-.52 2.44-1.24z" fill="#4d9537"/>
+<path d="M1.89 5.8c-.35.56-.6 1.34-.66 2.03h11.36c.12-.76.5-1.47 1.14-2.03H1.9z" fill="#ca8a02"/>
+<path d="M1.29 9.87a7.73 7.73 0 0 1-.06-2.04h11.36c-.11.69-.02 1.4.26 2.04H1.3z" fill="#c35f09"/>
+<path d="M1.84 11.9a9.6 9.6 0 0 1-.55-2.03h11.56c.36.8 1.02 1.5 1.96 1.85l-.08.18H1.84z" fill="#b01c1f"/>
+<path d="M14.73 11.9a10.85 10.85 0 0 1-1.14 2.03H2.92l-.1-.16c-.4-.6-.73-1.24-.98-1.87h12.89z" fill="#903b91"/>
+<path d="M13.6 13.93c-.66.96-1.54 2.01-2.6 2.02-1.04.01-1.3-.67-2.71-.67-1.41.01-1.7.69-2.74.68-1.11-.01-1.98-1.05-2.63-2.03h10.67z" fill="#0092cc"/>
+</svg>
diff --git a/icons/plat/mob.svg b/icons/plat/mob.svg
new file mode 100644
index 00000000..6a615342
--- /dev/null
+++ b/icons/plat/mob.svg
@@ -0,0 +1,22 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<linearGradient id="a" x1="2.15" x2="2.99" y1=".98" y2="2.57" gradientTransform="matrix(.73825 .61946 -.57392 .68397 1.84 -.98)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ccccf4" offset="0"/>
+<stop stop-color="#9ac2ff" offset="1"/>
+</linearGradient>
+</defs>
+<g transform="scale(3.77956)">
+<path d="M2.06.26.14 2.55c-.04.05-.09.1-.07.16.02.06.1.13.13.16l.34.29.78.65.25.2c.03.03.1.09.15.08.06 0 .1-.06.45-.46L3.78 1.7c.06-.06.1-.12.1-.18s-.08-.12-.34-.33L2.6.39C2.35.17 2.28.13 2.22.13s-.11.07-.16.13z" fill="#666"/>
+<path d="m2.02.8-.43.52c-.13.15-.18.21-.17.27 0 .06.07.12.25.26l.62.53c.17.14.24.2.3.2.05 0 .09-.06.22-.2l.47-.57c.13-.15.17-.2.16-.26-.02-.05-.09-.1-.26-.25L2.56.78c-.17-.15-.24-.2-.3-.2-.05 0-.1.04-.11.07L2.02.8z" fill="url(#a)"/>
+<path d="m3.54 1.2.2-.23c.05-.06.07-.1.1-.1.02 0 .04.01.07.04l.1.08.05.06c0 .02 0 .04-.06.1l-.22.26-.25-.2z" fill="#8c8c8c"/>
+<rect transform="rotate(40)" x="2.06" y=".58" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="3.23" y=".58" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.06" y="1.03" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.06" y="1.46" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="3.23" y="1.03" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="3.23" y="1.46" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.64" y="1.46" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.64" y="1.03" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.64" y=".58" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+</g>
+</svg>
diff --git a/icons/plat/msx.svg b/icons/plat/msx.svg
new file mode 100644
index 00000000..b3a7f8c9
--- /dev/null
+++ b/icons/plat/msx.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 3.8h16v8.4H0z"/>
+<path d="m.86 11.34 1.24-6.7h1.15l.6 2.92.61-2.92h1.08l1.02 4.93h2.47c.26 0 .28-.74-.02-.74L7.57 8.8c-.62 0-1.27-.91-1.24-2.13 0-1.13.72-2.01 1.63-2.01h3.68l.93 1.74.92-1.76 1.52.01-1.67 3.22 1.8 3.5-1.51-.01-1.05-2-1 2h-1.54L11.8 7.9l-.77-1.53h-3.2c-.34.01-.33.77 0 .75l1.35.03c.57 0 1.37.82 1.35 2.08.01 1.48-.95 2.15-1.42 2.11l-3.52.01L5 8.34l-.58 3H3.3l-.62-2.99-.53 3z" fill="#e4e4e4"/>
+</svg>
diff --git a/icons/plat/n3d.svg b/icons/plat/n3d.svg
new file mode 100644
index 00000000..2dbf8ed4
--- /dev/null
+++ b/icons/plat/n3d.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.62 10.1c.4.26 1.25.46 1.9.46.73 0 1.03-.4 1.03-.88 0-.44-.28-.7-1.08-1.15-1.06-.62-1.84-1.1-1.84-2.2 0-1.13.94-1.8 2.37-1.8.77 0 1.04.07 1.53.21v1.08c-.48-.14-.9-.39-1.57-.39-.7 0-1 .35-1 .72 0 .53.46.78 1.27 1.23 1.14.64 1.77 1.13 1.77 2.2 0 1.11-.8 1.9-2.58 1.9-.73 0-1.23-.07-1.8-.21zm-3.8-4.62h-.85v5.06h.85c1.3 0 2.13-.88 2.13-2.52 0-1.64-.82-2.54-2.13-2.54zm2.28 5.53c-.42.3-1.21.5-1.9.5H5.53V4.53h2.65c.7 0 1.5.2 1.92.5 1.02.72 1.35 1.88 1.35 2.98s-.33 2.27-1.36 3z" fill="#b1b1b4"/>
+<path d="M3.68 7.66s1.24-.3 1.24-1.5c0-1.18-1.32-1.66-2.73-1.66-1.26 0-2.1.24-2.1.24V5.8c.58-.22 1.13-.4 1.88-.4.8 0 1.42.37 1.42.9 0 .63-.6 1-1.92 1h-.6v.96h.56c1.38 0 2.16.32 2.16 1.1 0 .7-.7 1.14-1.57 1.14-.76 0-1.46-.26-2.02-.5v1.15c.27.07.99.3 2.33.3 1.48 0 2.77-.74 2.77-2.05 0-1.1-.9-1.75-1.42-1.75z" fill="#d0000f"/>
+</svg>
diff --git a/icons/plat/nds.svg b/icons/plat/nds.svg
new file mode 100644
index 00000000..78dd015b
--- /dev/null
+++ b/icons/plat/nds.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.3 10.05a9.5 9.5 0 0 0 2.92.44c1.1 0 1.56-.37 1.56-.85 0-.43-.42-.68-1.64-1.13-1.63-.6-2.83-1.07-2.83-2.15 0-1.1 1.44-1.76 3.63-1.76 1.18 0 1.59.07 2.33.2l.01 1.06c-.74-.13-1.39-.37-2.4-.37-1.08 0-1.54.34-1.54.7 0 .51.7.76 1.96 1.2 1.73.62 2.7 1.1 2.7 2.15 0 1.08-1.21 1.86-3.94 1.86-1.12 0-1.9-.07-2.75-.22zM3.5 5.53H2.2v4.93h1.3c1.99 0 3.25-.86 3.25-2.46S5.49 5.53 3.49 5.53zm3.48 5.38c-.64.29-1.86.48-2.92.48H0V4.62h4.06c1.06 0 2.28.18 2.92.47C8.55 5.8 9.06 6.93 9.06 8s-.5 2.2-2.08 2.91z" fill="#ccc"/>
+</svg>
diff --git a/icons/plat/nes.svg b/icons/plat/nes.svg
new file mode 100644
index 00000000..e634e660
--- /dev/null
+++ b/icons/plat/nes.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.14 3.87a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.52 0zM4.46 2.2c.54.17.84.76.67 1.3l-3.09 9.61a1.04 1.04 0 1 1-1.98-.63l3.09-9.61c.18-.55.76-.85 1.3-.67zM8 7.45a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.53 0zm8.04-3.58a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.52 0zM11.36 2.2c.54.17.84.76.67 1.3l-3.09 9.61a1.04 1.04 0 1 1-1.98-.63l3.09-9.61c.18-.55.76-.85 1.3-.67zm3.54 5.25a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.53 0z" fill="#ca1c02"/>
+</svg>
diff --git a/icons/plat/oth.svg b/icons/plat/oth.svg
new file mode 100644
index 00000000..26d15959
--- /dev/null
+++ b/icons/plat/oth.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 12.53a.98.98 0 1 1 0-1.96.98.98 0 0 1 0 1.96zm1.36-4.45-.02.01a1.1 1.1 0 0 0-.62.97.72.72 0 0 1-1.44 0c0-.96.56-1.85 1.43-2.27l.01-.01A1.7 1.7 0 1 0 6.3 6.24a.72.72 0 0 1-1.44 0 3.15 3.15 0 1 1 4.5 2.84z" fill="#bec10b"/>
+</svg>
diff --git a/icons/plat/p88.svg b/icons/plat/p88.svg
new file mode 100644
index 00000000..cc5cd463
--- /dev/null
+++ b/icons/plat/p88.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.14 6.3c2.4-.29 4.5 1.41 3.76 3.62-.11.34-.77 1.08-.77 1.08s1 .84 1.12 1.37c.45 2.25-1.44 3.58-3.36 3.63-2.03.05-4.27-1.19-3.83-3.5.06-.32.29-.61.4-.8.13-.19.69-.7.69-.7s-.75-1.06-.82-1.51c-.26-1.64.83-2.97 2.8-3.2zM10.66 9c0 1.18 1.98 1.16 1.98.06 0-1.25-1.98-1.27-1.98-.06zm1.02 5.16c1.56 0 1.57-2.1-.02-2.1-1.6 0-1.55 2.1.02 2.1z" fill="#cc6700"/>
+<path d="M12.3.12v1.2c-3.77-.89-3.74 4.41-.04 3.53v1.2C6.53 7.3 6.5-1.03 12.3.12z" fill="#e6e6e6"/>
+<path d="m5.27 3.7.01-1.19c.67 0 1.07-.2 1.07-.62 0-.34-.07-.69-1.42-.69v4.97H3.5V0h1.4c2.7 0 3 .95 3 1.85 0 1.3-1.04 1.86-2.63 1.86z" fill="#e6e6e6"/>
+<path d="M3.7 6.3C6.13 6 8.22 7.7 7.48 9.91 7.36 10.26 6.7 11 6.7 11s1 .84 1.12 1.37c.46 2.25-1.44 3.58-3.36 3.63-2.03.05-4.26-1.2-3.83-3.5.06-.32.29-.61.4-.8.13-.19.69-.7.69-.7S.97 9.94.9 9.49c-.26-1.64.83-2.97 2.8-3.2zM3.24 9c0 1.18 1.98 1.16 1.98.06 0-1.25-1.98-1.27-1.98-.06zm1.03 5.16c1.55 0 1.56-2.1-.03-2.1-1.6 0-1.55 2.1.03 2.1z" fill="#cc6700"/>
+</svg>
diff --git a/icons/plat/p98.svg b/icons/plat/p98.svg
new file mode 100644
index 00000000..d39ea0ea
--- /dev/null
+++ b/icons/plat/p98.svg
@@ -0,0 +1,5 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.7 16H2.2l2.03-3.63c-.55 0-2.07-.26-2.63-.7a2.9 2.9 0 0 1-.9-3.1c.45-1.53 2.38-2.67 4.49-2.2 1.5.34 2.38 1 2.64 2.46a3.4 3.4 0 0 1-.43 2.31zM2.85 9.48c0 1.58 2.76 1.62 2.76.06 0-1.63-2.76-1.65-2.76-.06zm8.29-3.18c2.4-.29 4.5 1.41 3.76 3.62-.11.34-.77 1.08-.77 1.08s1 .83 1.12 1.37c.45 2.25-1.44 3.58-3.36 3.63-2.03.05-4.27-1.19-3.83-3.5.06-.32.29-.61.4-.8.13-.19.69-.7.69-.7s-.75-1.06-.82-1.51c-.26-1.64.83-2.97 2.8-3.2zM10.66 9c0 1.18 1.98 1.16 1.98.06 0-1.25-1.98-1.27-1.98-.06zm1.02 5.16c1.56 0 1.57-2.1-.02-2.1-1.6 0-1.55 2.1.02 2.1z" fill="#c00"/>
+<path d="M12.42.12v1.2c-3.8-.89-3.78 4.41-.05 3.53v1.2c-5.78 1.25-5.8-7.08.05-5.93z" fill="#e6e6e6"/>
+<path d="M5.32 3.7V2.52c.68 0 1.08-.2 1.08-.62 0-.35-.06-.69-1.42-.69v4.97H3.52L3.53 0h1.42c2.71 0 3.03.95 3.03 1.85 0 1.3-1.06 1.86-2.66 1.86z" fill="#e6e6e6"/>
+</svg>
diff --git a/icons/plat/pce.svg b/icons/plat/pce.svg
new file mode 100644
index 00000000..afef2f82
--- /dev/null
+++ b/icons/plat/pce.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.03 12.32s1.16.04 1.34-.7l.07-1.4 1.35-.5A2.84 2.84 0 0 0 5.6 6.9V5.43c0-1.2-1.17-1.46-1.92-1.21L.04 5.53 0 11.26c.05 1.11 1.03 1.06 1.03 1.06zM2.14 6.8c0-.08.05-.13.2-.2l.82-.3c.26-.1.34-.07.34.18V7.7c0 .15-.15.27-.32.33l-.79.28c-.18.06-.25.07-.25-.13zm7.39-1.92 1.75-.62c.18-.07.14-.45.14-.53 0-1.16-1.01-1.7-1.73-1.43l-1.7.58c-1 .38-2.02.92-2.24 2.6v3.26c0 2 1.56 1.64 2.18 1.41L10.8 9.1c.48-.19.84-.68.77-1.32-.05-.4-.1-.79-.17-1.18-.02-.12-.02-.23-.16-.18l-1.7.62c-.16.06-.26.09-.26.28 0 .2.02.36-.27.46l-.9.31c-.1.04-.25.05-.25-.23V5.04c0-.13.05-.2.23-.26l.96-.33c.15-.05.26-.05.26.1v.18c0 .23.1.2.22.15zM14.8.66l-2.8.98c-.11.04-.14.14-.14.29V8.5c0 .21.06.23.22.18l3.6-1.3c.08-.02.11-.01.11-.13v-.6c0-.22-.03-.3-.22-.23L13 7.36c-.18.06-.25.1-.25-.14V5.5c0-.17.04-.18.1-.2l1.5-.55c.1-.04.24-.09.24-.22v-.56c0-.12-.06-.2-.32-.1L13 4.33c-.22.09-.25.04-.25-.16v-1.7c0-.07-.02-.11.17-.18l2-.75c.15-.05.18-.03.18-.18V.8c0-.17-.05-.22-.3-.14z" fill="#cc2000"/>
+<path d="M10.34 11.45 6.9 15.22c-.07.09-.03.12.06.07l4.46-2.78c.08-.05.1-.07.16-.05.07.02 1.67 1.17 1.67 1.17.11.08.17.04.13-.05l-.83-1.81c-.04-.1-.06-.17-.01-.21l3.36-3.67c.08-.12.03-.18-.1-.1l-4.17 2.58c-.1.06-.15.06-.21.02L9.87 9.34c-.16-.09-.21-.05-.17.13l.7 1.72c.05.1.02.17-.06.26z" fill="#cc2000" stroke="#fff" stroke-miterlimit="2.61" stroke-width=".12"/>
+</svg>
diff --git a/icons/plat/pcf.svg b/icons/plat/pcf.svg
new file mode 100644
index 00000000..f834e2a3
--- /dev/null
+++ b/icons/plat/pcf.svg
@@ -0,0 +1,13 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<g fill="#666">
+<path d="M7.91 1.1a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5.5.5 0 0 1 .5.5z"/>
+<path d="m7.4 12.79-3.47-1.98.94-.5c.69 1 2.07 1.08 3.16.98.65-.2-.13.25-.1.43.13.34.51.14.7.09.46-.27.93-.57 1.22-1.1.16-.48.1-.22.47-.63.35-.36.38-.92.78-1.23.43-.39.63-.94.86-1.46.78-.72 1.5-1.67 1.47-2.78.04-.85-.48-1.6-1.1-2.12a7.49 7.49 0 0 0-3.64-1.47c-.3-.1-.6.25-.39.52.6.24 1.26.24 1.86.49.95.35 1.93.88 2.45 1.8.44.89-.02 1.97-.7 2.61l-.02.03a3.76 3.76 0 0 0-.92-1.12A6.5 6.5 0 0 0 7.4 4.22v-2.4L1.14 5.2v7.12h.01v.13L7.41 16l6.25-3.56V9.23zm0-5.44c.71.04 1.44.15 2.05.53.08.06.18.1-.06.17a9.7 9.7 0 0 1-1.24.24s-.42.03-.56.02H7.4zm-.44 1.83c.38-.04.77-.02 1.13.07.21.06.48.06.62.26-.7.12-1.42.13-2.12.09l-.32-.05zm.38 1.48c-.62-.03-1.35-.08-1.8-.57a7.3 7.3 0 0 0 3.8-.06c-.53.48-1.3.61-2 .63zm2.24-1.48c-.17-.2-.4-.32-.63-.38l1.12-.31c.21.39-.16.6-.49.7zm1.2-3.15c.29.26.44.62.54.99-.22.16-.45.32-.69.46-.16.05-.34.25-.5.08a4.94 4.94 0 0 0-2.73-.89V4.9c1.2.02 2.47.3 3.37 1.13z"/>
+</g>
+<path d="M8.8.82c-.2-.27.1-.63.4-.51 1.29.2 2.6.62 3.62 1.46.63.52 1.15 1.27 1.1 2.12.03 1.11-.68 2.06-1.46 2.78-.23.52-.43 1.07-.86 1.46-.4.31-.43.87-.78 1.23-.38.41-.3.15-.47.64a3 3 0 0 1-1.23 1.09c-.18.05-.56.25-.68-.08-.04-.19.74-.65.09-.44-1.1.1-2.47.03-3.15-.97-.13-.3-.11-.4-.11-.57-.5-.36-.85-.9-.8-1.53-.55-.38-1.07-.92-1.1-1.63-.09-.69.45-1.25 1-1.58a6.8 6.8 0 0 1 3.52-.79c1.25.08 2.56.35 3.58 1.13.4.29.67.7.92 1.12l.02-.03c.68-.64 1.14-1.72.7-2.62a4.48 4.48 0 0 0-2.45-1.79c-.6-.25-1.26-.25-1.86-.49zM4.34 5.14c-.63.5-.17 1.51.47 1.71.86-.7 2-.9 3.08-.9.97.06 1.96.28 2.74.89.16.17.34-.03.5-.07.24-.15.47-.3.7-.47a1.9 1.9 0 0 0-.56-.98A5.06 5.06 0 0 0 7.9 4.19c-1.23-.03-2.58.13-3.56.95zm4.3 2.43a9.68 9.68 0 0 0 1.25-.24c.24-.07.14-.1.06-.17a4.28 4.28 0 0 0-2.04-.53c-.77 0-1.58.08-2.27.46-.07.05-.3.09 0 .17.67.23 2.3.33 2.45.33l.56-.02zm-3.52.25c.06.16.19.4.44.55.1-.07.27-.15.41-.22L6.25 8H6c-.31-.04-.58-.1-.88-.18zm4.33.26c.24.07.46.17.63.38.33-.09.7-.3.5-.7-.38.12-.76.22-1.13.32zm-3.09.64c.21.12.5.11.73.16.7.05 1.42.03 2.12-.09-.14-.2-.4-.2-.62-.26a3.94 3.94 0 0 0-2.23.19zm-.33.65c.46.49 1.19.54 1.81.57.7-.02 1.47-.15 2-.63a7.26 7.26 0 0 1-3.8.06z" fill="#01015b"/>
+<path d="M8.41.5a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5.5.5 0 0 1 .5.5zM7.9 15.28l-6.25-3.56V8.51l6.26 3.56z" fill="#01015b"/>
+<path d="m7.9 15.28 6.26-3.56V8.51l-6.25 3.56z" fill="#f00020"/>
+<path d="M1.64 11.6 7.9 8.22V1.1L1.64 4.48z" fill="#cb9a01"/>
+<path d="M8.8.82c-.2-.27.1-.63.4-.51 1.29.2 2.6.62 3.62 1.46.63.52 1.15 1.27 1.1 2.12.03 1.11-.68 2.06-1.46 2.78-.23.52-.43 1.07-.86 1.46-.4.31-.43.87-.78 1.23-.38.41-.3.15-.47.64a3 3 0 0 1-1.23 1.09c-.18.05-.56.25-.68-.08-.04-.19.74-.65.09-.44-1.1.1-2.47.03-3.15-.97-.13-.3-.11-.4-.11-.57-.5-.36-.85-.9-.8-1.53-.55-.38-1.07-.92-1.1-1.63-.09-.69.45-1.25 1-1.58a6.8 6.8 0 0 1 3.52-.79c1.25.08 2.56.35 3.58 1.13.4.29.67.7.92 1.12l.02-.03c.68-.64 1.14-1.72.7-2.62a4.48 4.48 0 0 0-2.45-1.79c-.6-.25-1.26-.25-1.86-.49zM4.34 5.14c-.63.5-.17 1.51.47 1.71.86-.7 2-.9 3.08-.9.97.06 1.96.28 2.74.89.16.17.34-.03.5-.07.24-.15.47-.3.7-.47a1.9 1.9 0 0 0-.56-.98A5.06 5.06 0 0 0 7.9 4.19c-1.23-.03-2.58.13-3.56.95zm4.3 2.43a9.68 9.68 0 0 0 1.25-.24c.24-.07.14-.1.06-.17a4.28 4.28 0 0 0-2.04-.53c-.77 0-1.58.08-2.27.46-.07.05-.3.09 0 .17.67.23 2.3.33 2.45.33l.56-.02zm-3.52.25c.06.16.19.4.44.55.1-.07.27-.15.41-.22L6.25 8H6c-.31-.04-.58-.1-.88-.18zm4.33.26c.24.07.46.17.63.38.33-.09.7-.3.5-.7-.38.12-.76.22-1.13.32zm-3.09.64c.21.12.5.11.73.16.7.05 1.42.03 2.12-.09-.14-.2-.4-.2-.62-.26a3.94 3.94 0 0 0-2.23.19zm-.33.65c.46.49 1.19.54 1.81.57.7-.02 1.47-.15 2-.63a7.26 7.26 0 0 1-3.8.06z" fill="#3737fd"/>
+<path d="M8.41.5a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5.5.5 0 0 1 .5.5zM7.9 15.28l-6.25-3.56V8.51l6.26 3.56z" fill="#3737fd"/>
+<path d="m7.9 15.28 6.26-3.56V8.51l-6.25 3.56z" fill="#cc001b"/>
+</svg>
diff --git a/icons/plat/ps1.svg b/icons/plat/ps1.svg
new file mode 100644
index 00000000..99780486
--- /dev/null
+++ b/icons/plat/ps1.svg
@@ -0,0 +1,10 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m8.97 3.72-.01 11.6-3.11-1V.66l3.97 1.05c2.54.69 4.1 2.01 4.07 4.34-.03 2.7-1.28 3.8-3.72 3.08V3.81c0-.65-1.2-.68-1.2-.09z" fill="#de0029"/>
+<path d="m4.42 12.06-1.47.5c-.95.33-1.76-.45-.88-.76l.7-.25-2.66-.85c-.82.28-1.6.87-1.55 1.7.06.85 1.99 1.05 3.48 1.3a7.6 7.6 0 0 0 3.8-.3v-.88zm4.56 3.27 2.72-.95-2.74-.87v1.75z" fill="#f3c202"/>
+<path d="m16.21 12.79.05-.02c1.19-.41 1.7-1 1.57-1.54-.2-.91-1.66-1.4-3.9-1.57a12 12 0 0 0-4.72.77l-.25.1 2.76.85 1.62-.54c1.7-.32 2.38.24.75.76l-.81.27zM5.85 8.77l-1.23.41 1.23.38z" fill="#326db3"/>
+<path d="m11.7 14.38 4.51-1.6-2.93-.9-4.33 1.47v.16zm-5.85-2.8-1.43.48 1.43.46zm3.1.75v-1.8l2.77.85zm-6.18-.78 3.07-1.1v-.89l-1.22-.38-4.45 1.5-.06.02z" fill="#00aa9e"/>
+<path d="m8.97 3.72-.01 11.6-3.11-1V.66l3.97 1.05c2.54.69 4.1 2.01 4.07 4.34-.03 2.7-1.28 3.8-3.72 3.08V3.81c0-.65-1.2-.68-1.2-.09z" fill="#cc0026"/>
+<path d="m4.42 12.06-1.47.5c-.95.33-1.76-.45-.88-.76l.7-.25-2.66-.85c-.82.28-1.6.87-1.55 1.7.06.85 1.99 1.05 3.48 1.3a7.6 7.6 0 0 0 3.8-.3v-.88zm4.56 3.27 2.72-.95-2.74-.87v1.75z" fill="#caa202"/>
+<path d="m16.21 12.79.05-.02c1.19-.41 1.7-1 1.57-1.54-.2-.91-1.66-1.4-3.9-1.57a12 12 0 0 0-4.72.77l-.25.1 2.76.85 1.62-.54c1.7-.32 2.38.24.75.76l-.81.27zM5.85 8.77l-1.23.41 1.23.38z" fill="#2d639f"/>
+<path d="m11.7 14.38 4.51-1.6-2.93-.9-4.33 1.47v.16zm-5.85-2.8-1.43.48 1.43.46zm3.1.75v-1.8l2.77.85zm-6.18-.78 3.07-1.1v-.89l-1.22-.38-4.45 1.5-.06.02z" fill="#00ccbe"/>
+</svg>
diff --git a/icons/plat/ps2.svg b/icons/plat/ps2.svg
new file mode 100644
index 00000000..40b9e7e4
--- /dev/null
+++ b/icons/plat/ps2.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12h1V9h3V5H0v1h3v2H0Zm8-6h2V5H7v6H4v1h4zm8-1h-5v1h4v2h-4v4h5v-1h-4V9h4z" fill="#7c72b6"/>
+</svg>
diff --git a/icons/plat/ps3.svg b/icons/plat/ps3.svg
new file mode 100644
index 00000000..ac763eec
--- /dev/null
+++ b/icons/plat/ps3.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 5h4v1h1v5h-1v1h-4v-1h4V9h-4V8h4V6h-4ZM0 12h1V9h3V6H3V5H0v1h3v2H0Zm8-6h2V5H8v1H7v5H4v1h4z" fill="#999"/>
+</svg>
diff --git a/icons/plat/ps4.svg b/icons/plat/ps4.svg
new file mode 100644
index 00000000..867244fb
--- /dev/null
+++ b/icons/plat/ps4.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9 11h5v1h1v-1h1v-1h-1V5h-1l-5 5Zm5-5v4h-4zM0 12h1V9h3V6H3V5H0v1h3v2H0Zm8-6h2V5H8v1H7v5H4v1h4Z" fill="#0185cf"/>
+</svg>
diff --git a/icons/plat/ps5.svg b/icons/plat/ps5.svg
new file mode 100644
index 00000000..81d14366
--- /dev/null
+++ b/icons/plat/ps5.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.2 12.27c-1.26-.35-1.47-1.08-.9-1.5a6.18 6.18 0 0 1 1.44-.68l3.74-1.3v1.48l-2.7.95c-.47.17-.54.4-.15.53.38.12 1.08.09 1.55-.08l1.3-.46v1.33l-.26.04a8.6 8.6 0 0 1-4.02-.31zm7.88.15 4.2-1.46c.47-.17.54-.4.16-.53a2.88 2.88 0 0 0-1.56.08l-2.8.97V9.94l.16-.06c.64-.2 1.29-.33 1.95-.4a8.45 8.45 0 0 1 3.61.42c1.23.38 1.37.95 1.06 1.33-.31.39-1.08.66-1.08.66l-5.7 2.01zm.5-9.53c2.2.75 2.95 1.68 2.95 3.77 0 2.04-1.28 2.81-2.9 2.04V4.9c0-.45-.09-.86-.51-.98-.33-.1-.53.2-.53.65v9.5l-2.6-.8V1.91c1.1.2 2.71.68 3.58.97z" fill="#999"/>
+</svg>
diff --git a/icons/plat/psp.svg b/icons/plat/psp.svg
new file mode 100644
index 00000000..808669c3
--- /dev/null
+++ b/icons/plat/psp.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12h1V9h3V5H0v1h3v2H0Zm8-6h2V5H7v6H4v1h4zm4 6h1V9h3V5h-4v1h3v2h-3z" fill="#dedede"/>
+</svg>
diff --git a/icons/plat/psv.svg b/icons/plat/psv.svg
new file mode 100644
index 00000000..a7b919d2
--- /dev/null
+++ b/icons/plat/psv.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12h1V9h3V6H3V5H0v1h3v2H0zm8-6h2V5H8v1H7v5H4v1h4zm2-1 2.5 7h1L16 5h-1l-2 6-2-6Z" fill="#33b4ff"/>
+</svg>
diff --git a/icons/plat/sat.svg b/icons/plat/sat.svg
new file mode 100644
index 00000000..a0a821f3
--- /dev/null
+++ b/icons/plat/sat.svg
@@ -0,0 +1,18 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<radialGradient id="a" cx="113.5" cy="35.63" r="47.61" gradientTransform="translate(-1.84 -.35) scale(.08452)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#fff" offset=".04"/>
+<stop stop-color="#6E93E1" offset=".23"/>
+<stop stop-color="#6867cb" offset="1"/>
+</radialGradient>
+</defs>
+<g transform="translate(0 2.78) scale(1.14286)">
+<path d="M9.49 1.76a13.88 13.88 0 0 1 2.67-.09.52.52 0 0 0 .46-.85.49.49 0 0 0-.35-.18A9.58 9.58 0 0 0 10.4.62c-.44.03-.9.08-1.38.15-.22.03.16 1.02.47.99z" fill="#ccc"/>
+<path d="M9.49 1.76a13.88 13.88 0 0 1 2.67-.09.52.52 0 0 0 .46-.85.49.49 0 0 0-.35-.18A9.58 9.58 0 0 0 10.4.62c-.44.03-.9.08-1.38.15-.22.03.16 1.02.47.99z" fill="#717171"/>
+<path d="M12.28 3.57h.29v.01c.2.12.3.24.3.38-.06.22-.28.47-.63.74-.42.3-1 .63-1.7.96-1.8.86-1.46 1.76.44.95a10.5 10.5 0 0 0 1.9-1.09c.6-.47.95-.91 1.01-1.34a1.1 1.1 0 0 0-.3-1.11c-.12-.13-1.37.5-1.31.5zM4.33 7.46l-1.55.14h-.01c-.39 0-.68-.03-.87-.1a.47.47 0 0 0-.4.02.48.48 0 0 0-.27.29.5.5 0 0 0 .01.4c.06.13.16.22.29.28.28.1.69.15 1.26.15.47 0 1.03-.05 1.68-.15l.33-.04Z" fill="#252525" stroke-width=".12" stroke="#000"/>
+<path d="M2.52 2.78h.01a7.5 7.5 0 0 0-1.38.86 3.1 3.1 0 0 0-.9.98C.02 5.08 0 5.49.2 5.87c.12.38.5.58 1.13.61.37.03.86-.02 1.49-.14l1.08-.27c.24-.06-.05-1.07-.29-1-.37.1-.7.18-1 .24-.51.1-.91.14-1.22.13l-.24-.01-.01-.01a.37.37 0 0 1 .04-.3c.12-.2.32-.42.61-.65.32-.25.73-.5 1.21-.75 2.87-1.13 3.02-2.56-.48-.93Z" fill="#f1f1f1" stroke-width=".12" stroke="#000"/>
+<path d="m1.15 5.42-.01-.01a.37.37 0 0 1 .04-.3c.12-.2.32-.42.61-.65A14.3 14.3 0 0 1 3 3.71l.55-.85s-1.08.51-1.23.6C.12 4.93.79 5.22.72 6.31c.35.1.2.16 1.15.01.08-.01-.78-.8-.72-.9Z" fill="#2f2f2f"/>
+<circle cx="7.03" cy="4.57" fill="url(#a)" stroke="#33317a" stroke-width=".24" r="4.44"/>
+<path d="M2.57 5.31c-.05.01-1.4.3-1.62.08-.06-.06.15.1.36.38.02.03 0 .09-.03.16l-.02.04-.01.05c0 .03-.02.05-.03.07 0 .08-.05.14-.06.19l.02.05c0 .01-.04.02-.03.03l-.04.03v.01l.1.02c.02 0 .14.02.3.02a5.03 5.03 0 0 0 .92-.07l.37-.05c.09-.02.13 0 .14 0a31.21 31.21 0 0 0 3.8-1.12l2.08-.68a49.25 49.25 0 0 1 2.57-.77c.26-.07.49-.11.68-.14.24-.03.41-.04.5-.02.07.05.4.01.55.26.26.4.4 1.1.5.96.15-.21.24-.41.27-.61.16-.58-.1-1.05-.77-1.47-.2-.17-.6-.21-1.19-.13-.24.03-.52.09-.85.17-.2.05-.44.09-.7.16a39.4 39.4 0 0 0-1.9.6l-2.07.67-1.52.5z" fill="#b9b9b9" stroke-width=".12" stroke="#000"/>
+</g>
+</svg>
diff --git a/icons/plat/scd.svg b/icons/plat/scd.svg
new file mode 100644
index 00000000..48dafdbe
--- /dev/null
+++ b/icons/plat/scd.svg
@@ -0,0 +1,8 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 .95h16v14.1H0z"/>
+<path d="m5.7 12.33 2.64-2.6.02-.06-2.66 2 2.7-2.8-2.7 2.06 2.6-2.74-2.6 2.11 2.5-2.8c-.9-.76-3.03-.1-4.88 1.56-1.94 1.73-2.84 3.88-2.01 4.8.82.93 3.06.28 5-1.45.52-.47.97-.97 1.33-1.47z" fill="#609ad2"/>
+<path d="m4.52 10.08-.9-.01v2.28h.9v-.63h.89v1.39H3.18c-.22 0-.43-.22-.43-.43V9.75c0-.31.15-.43.42-.43h2.24v1.51h-.89Z" fill="#080808"/>
+<path d="m10.3 6.62-2.64 2.6-.02.06 2.66-2-2.7 2.8 2.7-2.06-2.6 2.74 2.6-2.11-2.5 2.8c.9.76 3.03.1 4.89-1.56 1.93-1.73 2.83-3.88 2-4.8-.82-.93-3.06-.28-5 1.45-.52.47-.97.97-1.33 1.47z" fill="#e4bb4e"/>
+<path d="M13.31 9.23c0 .22-.22.43-.43.43h-2.23v-3.8h2.23c.22.03.43.22.43.44zm-1.77-.33h.9V6.62h-.9Z"/>
+<path d="M2.93 2.17c.07-.1.18-.22.31-.21h.8l-.01.26c.1-.12.22-.27.4-.26h1.1v2.13h-.75V2.57c-.14-.02-.2.12-.27.21-.26.44-.55.87-.8 1.32h-.78c.31-.53.64-1.04.96-1.56-.16.01-.37-.04-.49.1L2.49 4.1H1.7l1.22-1.93zm2.91.05c.1-.13.3-.28.49-.26h1.63v.47l-1.22.01c-.15.06-.18.25-.28.36H8l-.3.42H6.47v.41h1.5v.47H5.7V2.8a.92.92 0 0 1 .15-.58zm2.46-.07c.07-.09.26-.2.39-.2h1.76v.49H9.3c-.26-.02-.37.27-.45.47 0 .32-.06.32-.02.72.43.03.53.02 1.07 0 .03-.1.01-.22.02-.34-.19-.03-.15-.02-.5-.03l.25-.46h.92v1.07c0 .08-.03.14-.05.21-.7.03-1.4.01-2.1.02-.2.03-.32-.17-.3-.33V2.6c0-.18.07-.3.17-.45zm2.49-.19h1.19c.2-.03.36.15.46.31l1.14 1.83h-.81c-.1-.14-.18-.3-.32-.4h-.72a5.72 5.72 0 0 1-.2-.35v.75h-.74V1.95zm.75.46v.8h.65c-.14-.2-.26-.44-.4-.65a.37.37 0 0 0-.25-.15z" fill="#fff"/>
+</svg>
diff --git a/icons/plat/sfc.svg b/icons/plat/sfc.svg
new file mode 100644
index 00000000..c39ae480
--- /dev/null
+++ b/icons/plat/sfc.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 9.05c0-.44.34-.78.68-1a4.63 4.63 0 0 1 2.24-.61c.93-.03 1.9.1 2.71.57.4.23.8.63.75 1.12-.07.53-.54.87-.99 1.09a5.6 5.6 0 0 1-3.07.38 3.55 3.55 0 0 1-2-.85 1.02 1.02 0 0 1-.32-.7Z" fill="#5f9933"/>
+<path d="M5.9 12.04c0-.46.36-.82.73-1.06a5 5 0 0 1 2.46-.64c.97 0 1.97.14 2.8.65.41.25.8.7.7 1.22-.13.56-.65.9-1.14 1.11-1.05.44-2.24.5-3.35.31a3.4 3.4 0 0 1-1.94-.93 1.03 1.03 0 0 1-.26-.66Z" fill="#c1bc0b"/>
+<path d="M7.27 8.32a3.04 3.04 0 0 0 2.54-1.58c.56-1 .48-2.31-.2-3.23a3.04 3.04 0 0 0-2.99-1.2 3.04 3.04 0 0 0-2.44 2.38l-.12.57h.26a3.04 3.04 0 0 1 2.95 3.06" fill="#1489b8"/>
+<path d="M13.17 11.31a2.96 2.96 0 0 0 2.48-1.55c.55-1 .44-2.32-.28-3.22A2.96 2.96 0 0 0 12.4 5.5a2.96 2.96 0 0 0-2.25 2.43c-.03.12-.06.4-.06.4l.3.01a2.96 2.96 0 0 1 2.79 2.98z" fill="#bd0f14"/>
+</svg>
diff --git a/icons/plat/smd.svg b/icons/plat/smd.svg
new file mode 100644
index 00000000..483e4ebd
--- /dev/null
+++ b/icons/plat/smd.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 3.64h16v8.72H0z"/>
+<path d="M5.06 4.67v6.58H1.02z" fill="#c00"/>
+<path d="M8.44 4.67v6.58H4.4z" fill="#c00"/>
+<path fill="#0c0" d="M9.34 4.8h1.95v6.5H9.34zm2.36 0h-.06l-.02 2.23.1-.01.42-2.18c-.16-.03-.31-.03-.43-.03zm.76.07-.58 2.16c.05 0 .1.02.15.04L12.98 5a2.7 2.7 0 0 0-.52-.14zm.83.29-1.13 1.96.11.07 1.45-1.75c-.13-.1-.28-.2-.43-.28zm.7.53-1.62 1.58c.04.04.08.08.1.12l1.92-1.21a3.17 3.17 0 0 0-.4-.5zm.55.74L12.55 7.5l.06.12 2.18-.57a3.38 3.38 0 0 0-.25-.62zm.34.95-2.23.35.05.24h2.24c0-.2-.03-.4-.06-.6zm-2.18.78c-.05.53-.46.94-.95.94h-.12v-.05 2.25H12c1.59 0 2.88-1.4 2.93-3.14z"/>
+</svg>
diff --git a/icons/plat/swi.svg b/icons/plat/swi.svg
new file mode 100644
index 00000000..6c9b9329
--- /dev/null
+++ b/icons/plat/swi.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<rect x="2.1" y="2.09" width="4.52" height="11.78" ry="0" fill="#cc0010"/>
+<path d="M4.25 3.9c-.19.04-.46.18-.61.3-.3.27-.46.64-.43 1.08 0 .23.02.3.11.48.14.28.35.49.63.63.2.1.24.1.5.11.22.01.3 0 .46-.05.63-.21 1-.82.9-1.45a1.33 1.33 0 0 0-1.56-1.1zM9.2 1.02A370 370 0 0 0 9.17 8c0 6.33 0 6.97.05 6.98.08.03 2.33.02 2.6 0a3.62 3.62 0 0 0 3-2.43c.19-.54.18-.4.18-4.56 0-3.33 0-3.82-.05-4.04A3.6 3.6 0 0 0 12 1.05C11.8 1 11.5 1 10.48 1c-.7 0-1.28 0-1.29.02z" fill="#818990"/>
+<rect x="10.63" y="7.41" width="2.56" height="2.67" fill="#cc0010" ry="1.28"/>
+<path d="M4 1.05a3.62 3.62 0 0 0-2.87 2.63C1 4.18.99 4.46 1 8.26c0 3.5 0 3.57.06 3.84a3.63 3.63 0 0 0 2.83 2.83c.19.04.43.05 2 .06 1.62.01 1.8.01 1.83-.03.05-.05.05-.6.05-6.95 0-4.7 0-6.92-.03-6.96C7.72 1 7.67 1 5.97 1 4.6 1 4.18 1.01 4 1.05zM6.62 8v5.88l-1.18-.02a6.1 6.1 0 0 1-1.42-.07 2.46 2.46 0 0 1-1.82-1.9c-.06-.29-.06-7.5 0-7.79.17-.81.74-1.49 1.5-1.8.38-.15.56-.16 1.8-.17h1.12z" fill="#818990"/>
+</svg>
diff --git a/icons/plat/tdo.svg b/icons/plat/tdo.svg
new file mode 100644
index 00000000..0b918ebb
--- /dev/null
+++ b/icons/plat/tdo.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M4 15.55c0-.25 1.79-.45 4-.45 2.2 0 4 .2 4 .45S10.21 16 8 16s-4-.2-4-.45z" fill="#888"/>
+<path d="M4.6 11.3c0-1.2 1.52-2.18 3.4-2.18s3.4.98 3.4 2.19c0 1.2-1.52 2.18-3.4 2.18s-3.4-.98-3.4-2.18z" fill="#e5ca00"/>
+<path d="M4.83 5.6c-.5.22-.63 1.46-.63 1.46s.13 1.25.63 1.46c.5.21 3.17.34 3.17.34s2.67-.13 3.17-.34c.5-.21.63-1.46.63-1.46s-.13-1.24-.63-1.46C10.67 5.4 8 5.26 8 5.26s-2.67.13-3.17.34z" fill="#77f"/>
+<path fill="#c00" d="M4.02 2.56 8 0l3.98 2.56L8 5.12z"/>
+</svg>
diff --git a/icons/plat/vnd.svg b/icons/plat/vnd.svg
new file mode 100644
index 00000000..108fc3bb
--- /dev/null
+++ b/icons/plat/vnd.svg
@@ -0,0 +1,5 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.14 2h9.71c.63 0 1.13.5 1.13 1.14v9.71c0 .63-.5 1.13-1.13 1.13H3.14c-.63 0-1.14-.5-1.14-1.13V3.14C2 2.5 2.5 2 3.14 2z" fill="#303030" stroke="#bfbfbf" stroke-width="2.08" paint-order="stroke fill markers"/>
+<path d="m2.5 2 2.3 6.01h.83l2.42-6.04H6.8L5.3 6.43 3.66 2z" fill="#bfbfbf"/>
+<path d="M7.94 2v5.94h.9v-4.5l3.03 4.53h.87V2h-.87v4.4L8.84 2zM6.8 8.01H3.06v5.97h3.78v-.71h.87V8.8h-.9zm-2.76.76h1.9v4.53h-1.9zm8.73.04v1.13h-1.73V8.81h-1.6v1.81h3.33v2.65h-.94v.75H8.69v-.75h-.98v-1.33h1.74v1.33h2.38v-1.33h-.8v-.6H8.7v-.68h-.98V8.84h.98v-.79h3.14v.76z" fill="#bfbfbf"/>
+</svg>
diff --git a/icons/plat/web.svg b/icons/plat/web.svg
new file mode 100644
index 00000000..d7064070
--- /dev/null
+++ b/icons/plat/web.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m.75 8.2 1.08.9c.47-.72 1-1.38 1.55-1.98a1.42 1.42 0 0 1-.12-1.01c-.59-.38-1.2-.8-1.82-1.28A7.24 7.24 0 0 0 .72 8v.23l.03-.04zm1.04-4c.63.47 1.23.9 1.81 1.28a1.42 1.42 0 0 1 1.91-.16 11.45 11.45 0 0 1 2.92-1.4 1.57 1.57 0 0 1 .12-.78c-.87-.74-1.9-1.4-3.09-1.97A7.3 7.3 0 0 0 1.79 4.2zM6.46.88c.94.5 1.77 1.06 2.5 1.67.25-.21.56-.35.9-.38l.22-1.15A7.3 7.3 0 0 0 6.46.88zm4.3.38c-.05.34-.12.67-.18 1 .47.18.84.58.98 1.08.64-.05 1.31-.06 2.01-.03a7.42 7.42 0 0 0-2.8-2.05zm3.37 2.8a17 17 0 0 0-2.55 0 1.6 1.6 0 0 1-.49.87 9.68 9.68 0 0 1 1.2 2.62h.14c.64 0 1.18.42 1.36 1a51 51 0 0 0 1.47.03c.02-.2.02-.39.02-.58 0-1.45-.42-2.8-1.15-3.94zm1.04 5.23c-.46 0-.9-.01-1.34-.03-.12.56-.56 1-1.11 1.11.02.67 0 1.36-.07 2.1.4-.05.82-.11 1.24-.19a7.25 7.25 0 0 0 1.28-2.99zm-.87 3.64A7.97 7.97 0 0 1 8 16a7.97 7.97 0 0 1-8-8 7.97 7.97 0 0 1 8-8 7.97 7.97 0 0 1 8 8 8 8 0 0 1-1.7 4.93zm-1.12.19-.62.07-.09.56c.24-.19.47-.39.68-.6l.03-.03zm-1.54 1.19c.08-.37.14-.72.19-1.07-1.3.07-2.55-.02-3.76-.28a1.28 1.28 0 0 1-1.07.46l-.65 1.68a7.24 7.24 0 0 0 5.3-.79zm-5.98.6c.22-.6.44-1.17.67-1.74-.28-.2-.48-.53-.52-.9a16.95 16.95 0 0 1-3.79-2.13c-.2.34-.39.69-.57 1.04a7.31 7.31 0 0 0 4.21 3.72zm-4.56-4.6.36-.61-.64-.52c.07.39.16.77.28 1.13zm4.86-4.44a1.42 1.42 0 0 1 .07.97c.78.37 1.55.67 2.33.9.28-.85.56-1.73.81-2.65a1.6 1.6 0 0 1-.5-.5 10.94 10.94 0 0 0-2.7 1.28zm-.3 1.58a1.42 1.42 0 0 1-1.78.19c-.53.57-1.02 1.2-1.47 1.88a16.4 16.4 0 0 0 3.54 2.03 1.29 1.29 0 0 1 1.29-.7c.3-.78.6-1.59.89-2.43-.83-.25-1.64-.57-2.47-.97zm4.81-2.18a1.6 1.6 0 0 1-.62.06c-.25.9-.52 1.77-.8 2.6.69.18 1.38.3 2.11.4.1-.21.26-.4.46-.53a9.2 9.2 0 0 0-1.15-2.53zM12 10.33a1.43 1.43 0 0 1-1-1.29 17.3 17.3 0 0 1-2.19-.42c-.3.87-.6 1.7-.92 2.51a1.28 1.28 0 0 1 .47 1.16c1.14.23 2.32.3 3.56.23.08-.77.1-1.5.08-2.19z" fill="#5f57ff"/>
+</svg>
diff --git a/icons/plat/wii.svg b/icons/plat/wii.svg
new file mode 100644
index 00000000..3f203b89
--- /dev/null
+++ b/icons/plat/wii.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.74 4.6 7.4 9.88S6.36 5.9 6.2 5.35c-.17-.57-.51-.81-1-.81-.5 0-.84.24-1.01.8-.17.57-1.2 4.55-1.2 4.55l-1.35-5.3H0s1.56 5.65 1.78 6.3c.16.52.55.94 1.13.94.67 0 .98-.49 1.12-.93.14-.44 1.16-4.18 1.16-4.18s1 3.74 1.15 4.18c.14.44.45.93 1.12.93.58 0 .97-.42 1.14-.93.2-.66 1.77-6.3 1.77-6.3zm5.58 7.17h1.54V6.8h-1.54zm-.15-6.73c0 .48.41.87.9.87.52 0 .93-.38.93-.87 0-.48-.4-.87-.92-.87-.5 0-.9.4-.9.87zm-2.98 6.73h1.54V6.8H11.2Zm-.15-6.73c0 .48.4.87.9.87.52 0 .93-.38.93-.87 0-.48-.4-.87-.93-.87-.5 0-.9.4-.9.87z" fill="#b1b1b4"/>
+</svg>
diff --git a/icons/plat/win.svg b/icons/plat/win.svg
new file mode 100644
index 00000000..c14beaf9
--- /dev/null
+++ b/icons/plat/win.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.92 7.12c1.9-.88 3.87-.73 5.92.25l1.6-5.64C7.52.78 5.57.36 3.47 1.54Z" fill="#cc3000"/>
+<path d="M9.93 1.87c2.7 1.23 4.62.8 6.07.4l-1.55 5.4c-2.81 1.37-4.49.5-6.13-.12z" fill="#90c200"/>
+<path d="M8.2 8.06c1.8.76 3.7 1.24 6.08.26l-1.93 6.16c-2.27 1.14-4 .75-5.94-.15z" fill="#cc9f00"/>
+<path d="M0 13.93c1.92-1 3.9-.78 5.92.22L7.7 7.86c-.82-.37-2.7-1.4-5.98-.07z" fill="#028bca"/>
+</svg>
diff --git a/icons/plat/wiu.svg b/icons/plat/wiu.svg
new file mode 100644
index 00000000..8490868c
--- /dev/null
+++ b/icons/plat/wiu.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<g fill="#009bc8">
+<path d="M6.14 7.46c0 3.15 3.72 2.7 3.72.5V2.74H6.14Z"/>
+<path d="M1 10.78c0 1.55 1.08 2.48 2.65 2.48h8.95c1.33 0 2.4-.92 2.4-2.24V4.48c0-.8-.59-1.74-1.32-1.74H11.6v5.47c0 4.09-7.13 4.06-7.13.08V2.74H2.9C1.88 2.74 1 3.39 1 4.4z"/>
+</g>
+</svg>
diff --git a/icons/plat/x1s.svg b/icons/plat/x1s.svg
new file mode 100644
index 00000000..e724053d
--- /dev/null
+++ b/icons/plat/x1s.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.8 3.42 9.88 0h5.3zM10.7.5l1.2 2.13L14 .49zM8.94 0H3.08l4.41 7.6h5.82m-6.71 0H.82l3.65-3.58zm-4.58-.5h3.72L4.37 4.8zM2 16h5.8l4.36-7.6H4.92l-1.68 1.77h2.04" fill="#b2b2b2"/>
+</svg>
diff --git a/icons/plat/x68.svg b/icons/plat/x68.svg
new file mode 100644
index 00000000..97f22e3c
--- /dev/null
+++ b/icons/plat/x68.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m2.16 3.69 5.03 8.62H14L8.98 3.7zm1.79 4.62-3.95 4h6.29zm8.1-.62 3.95-4H9.71l2.34 4" fill="#c00"/>
+</svg>
diff --git a/icons/plat/xb1.svg b/icons/plat/xb1.svg
new file mode 100644
index 00000000..94fec9e4
--- /dev/null
+++ b/icons/plat/xb1.svg
@@ -0,0 +1,65 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<linearGradient id="c" x2="1" gradientTransform="scale(179.49 -179.49) rotate(-51 -2.04 -2.71)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#9dff00" offset=".5"/>
+<stop stop-color="#458c41" offset="1"/>
+</linearGradient>
+<linearGradient id="d" x2="1" gradientTransform="scale(247.4 -247.4) rotate(56 3.22 -.6)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#4a7d62" offset="0"/>
+<stop offset="1"/>
+</linearGradient>
+<linearGradient id="e" x2="1" gradientTransform="scale(266.72 -266.72) rotate(-34 -1.53 -1.47)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#9dff00" offset=".42"/>
+<stop stop-color="#5c5c5c" offset="1"/>
+</linearGradient>
+<linearGradient id="f" x2="1" gradientTransform="scale(358.07 -358.07) rotate(-60 -.29 -.65)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#4a7d62" offset="0"/>
+<stop stop-color="#182920" offset="1"/>
+</linearGradient>
+<linearGradient id="g" x2="1" gradientTransform="scale(-171.77 171.77) rotate(44 -5.21 -2.83)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#9dff00" offset=".48"/>
+<stop stop-color="#4a4a4a" offset="1"/>
+</linearGradient>
+<linearGradient id="h" x2="1" gradientTransform="scale(161.685 -161.685) rotate(-83 -.25 -3.52)" gradientUnits="userSpaceOnUse">
+<stop offset="0"/>
+<stop stop-color="#45755c" offset=".87"/>
+<stop stop-color="#45755c" offset="1"/>
+</linearGradient>
+<linearGradient id="i" x2="1" gradientTransform="scale(135.836 -135.836) rotate(-65 .22 -4.28)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#aeff00" offset=".42"/>
+<stop stop-color="#575757" offset="1"/>
+</linearGradient>
+<linearGradient id="j" x2="1" gradientTransform="scale(-320.164 320.164) rotate(80 -1.36 -.84)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#4a7d62" offset="0"/>
+<stop offset=".83"/>
+<stop offset="1"/>
+</linearGradient>
+<radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="matrix(207.43 -21.51 -19.788 -190.88 415.48 501.29)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#fff" offset="0"/>
+<stop offset=".96"/>
+<stop offset="1"/>
+</radialGradient>
+<radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="matrix(-59.414 -151.8 -183.86 71.965 430.59 518.58)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#fff" offset="0"/>
+<stop offset=".96"/>
+<stop offset="1"/>
+</radialGradient>
+</defs>
+<path d="M0 14.78h16V1.22H0z"/>
+<g transform="matrix(.02503 0 0 -.02503 -2.5 19.48)">
+<path d="M417.09 553.03 242.8 636.76s175.19 135.82 336.52-5.99z" fill="url(#a)"/>
+<path d="m481.12 501.93 114.42 108s101.09-174.09-67.99-254.9z" fill="url(#b)" />
+<path d="M344.64 507.72v-125.5a1488.22 1488.22 0 0 1 72.24 64.1v108.35s-131.23 43.75-214.07 95.92c34.13-38.85 90.55-100.24 141.83-142.88" fill="url(#c)"/>
+<path d="M202.81 650.6c82.85-52.17 214.07-95.92 214.07-95.92v14.45s-170.67 56.89-245.62 118.3c0 0 12.19-14.79 31.55-36.83" fill="url(#d)"/>
+<path d="M176.8 256.1c42.3 28.87 104.91 73.64 167.84 126.12v102.02S258.94 354.18 176.8 256.1" fill="url(#e)" />
+<path d="M124.31 221.47s20.41 12.74 52.5 34.64c82.14 98.07 167.83 228.14 167.83 228.14v23.47S217.32 314.48 124.31 221.46" fill="url(#f)" />
+<path d="M416.89 554.68V446.32a1488.71 1488.71 0 0 1 72.24-64.1v125.5c51.28 42.65 107.71 104.03 141.84 142.89-82.85-52.18-214.08-95.92-214.08-95.92" fill="url(#g)" />
+<path d="M416.89 569.13v-14.45s131.23 43.75 214.08 95.92a1687.36 1687.36 0 0 1 31.54 36.83c-74.95-61.41-245.62-118.3-245.62-118.3" fill="url(#h)" />
+<path d="M489.13 382.23c62.93-52.48 125.54-97.26 167.84-126.12-82.14 98.08-167.84 228.14-167.84 228.14Z" fill="url(#i)" />
+<path d="M489.13 484.25s85.7-130.06 167.84-228.14c32.08-21.9 52.5-34.64 52.5-34.64-93.02 93.02-220.35 286.26-220.35 286.26z" fill="url(#j)" />
+</g>
+</svg>
diff --git a/icons/plat/xb3.svg b/icons/plat/xb3.svg
new file mode 100644
index 00000000..27afd4a9
--- /dev/null
+++ b/icons/plat/xb3.svg
@@ -0,0 +1,37 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<linearGradient id="d" gradientUnits="userSpaceOnUse" x1="10.08" y1="9.83" x2="11.87" y2="8.38">
+<stop offset="0" stop-color="#e6edae"/>
+<stop offset="1" stop-color="#359644"/>
+</linearGradient>
+<linearGradient id="c" gradientUnits="userSpaceOnUse" gradientTransform="matrix(.41805 0 0 .41527 -1.2 .47)" x1="17.96" y1="19.43" x2="15" y2="17.36">
+<stop stop-color="#e5edae" offset="0"/>
+<stop stop-color="#249b47" offset="1"/>
+</linearGradient>
+<linearGradient id="b" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-.41668 0 0 .41565 17.22 .47)" x1="20.3" y1="15.11" x2="22.04" y2="7.83">
+<stop stop-color="#e6eead" offset="0"/>
+<stop stop-color="#46873f" offset="1"/>
+</linearGradient>
+<linearGradient id="a" gradientUnits="userSpaceOnUse" gradientTransform="matrix(.41805 0 0 .41527 -1.24 .47)" x1="21.34" y1="15.45" x2="22.08" y2="7.5">
+<stop stop-color="#e6edae" offset="0"/>
+<stop stop-color="#459743" offset="1"/>
+</linearGradient>
+<radialGradient id="e" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 1.8458 -1.5756 0 41.66 -29.48)" cx="23.69" cy="12.77" r="14.35">
+<stop stop-color="#fff" offset="0"/>
+<stop stop-color="#fff" stop-opacity="0" offset="1"/>
+</radialGradient>
+<radialGradient id="f" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 1.1525 -.98381 0 34.1 -13.06)" cx="23.69" cy="12.77" r="14.35">
+<stop stop-color="#fff" offset="0"/>
+<stop stop-color="#666" stop-opacity="0" offset="1"/>
+</radialGradient>
+</defs>
+<g transform="matrix(.4878 0 0 .46892 -3.38 -.23)">
+<ellipse cx="23.33" cy="17.39" rx="14.35" ry="14.76" fill="#666"/>
+<ellipse cx="23.33" cy="17.39" rx="14.35" ry="14.76" fill="url(#e)"/>
+<ellipse cx="23.33" cy="17.39" rx="14.35" ry="14.76" fill="url(#f)"/>
+</g>
+<path d="M6.23 4.89c-.77.94-4.01 4.74-3.72 7.32.2.25.42.49.65.71-.03-1.97 2.28-4.08 4.08-5.67Z" fill="url(#c)"/>
+<path d="M4.34 2.03c-.4.24-.78.53-1.13.84a5.3 5.3 0 0 1 2.84 2c.53.83.82 1.6 1.2 2.38l.79-.66V3.18a6.89 6.89 0 0 0-3.7-1.15z" fill="url(#a)"/>
+<path d="M9.78 4.89c.77.94 4 4.74 3.72 7.32-.2.25-.42.49-.65.71.03-1.97-2.28-4.08-4.09-5.67z" fill="url(#d)"/>
+<path d="M11.66 2.02c.4.25.78.53 1.12.85a5.29 5.29 0 0 0-2.82 2c-.54.83-.82 1.6-1.2 2.38l-.79-.66V3.17a6.85 6.85 0 0 1 3.68-1.15h.01z" fill="url(#b)"/>
+</svg>
diff --git a/icons/plat/xbo.svg b/icons/plat/xbo.svg
new file mode 100644
index 00000000..e1b7f6c7
--- /dev/null
+++ b/icons/plat/xbo.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.7 14.97a6.99 6.99 0 0 0 3.1-1.1c.8-.51.97-.72.97-1.14 0-.85-.93-2.33-2.52-4.01-.9-.96-2.16-2.08-2.3-2.05-.26.05-2.37 2.12-3.16 3.09-1.26 1.53-1.83 2.79-1.54 3.35.22.42 1.6 1.25 2.61 1.57.84.27 1.94.38 2.84.3zm5.13-3.12a7.3 7.3 0 0 0 1.14-3.42c.06-.47.04-.74-.12-1.7a6.97 6.97 0 0 0-1.7-3.46c-.35-.36-.38-.37-.8-.23-.53.18-1.08.56-1.94 1.34l-.5.46.27.33a21.72 21.72 0 0 1 3.12 5.15c.28.75.39 1.5.27 1.8-.08.22 0 .14.26-.27zm-11.46.17c-.07-.32.01-.9.2-1.48.42-1.26 1.8-3.61 3.08-5.2l.4-.51-.44-.4a9.14 9.14 0 0 0-1.38-1.1 2.7 2.7 0 0 0-1.02-.38c-.12 0-.57.46-.93.96a7.32 7.32 0 0 0-1.17 2.72c-.14.64-.15 2-.02 2.63.1.53.31 1.2.52 1.66.16.35.55 1.01.72 1.23.09.11.09.11.04-.13zM8.58 2.7c.59-.3 1.5-.62 2-.7.17-.04.47-.05.66-.04.41.02.4 0-.27-.32a6.8 6.8 0 0 0-4.34-.54c-.74.15-1.62.48-2.11.78l-.15.1.34-.02c.67-.04 1.64.23 2.69.74.32.16.59.28.61.28l.57-.28z" fill="#1daf1e"/>
+</svg>
diff --git a/icons/plat/xxs.svg b/icons/plat/xxs.svg
new file mode 100644
index 00000000..72d558ac
--- /dev/null
+++ b/icons/plat/xxs.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 4.07h16v7.86H0Z" fill="#0f7a10"/>
+<path fill="#fff" d="m11.21 8.2.68-.93a1.92 1.92 0 0 1-.63-.27.71.71 0 0 1-.28-.6.68.68 0 0 1 .3-.6c.27-.16.57-.23.88-.21 1.19 0 1.5.62 1.7.89l.65-.88a1.98 1.98 0 0 0-.4-.46c-.45-.37-1.1-.55-1.97-.55a2.8 2.8 0 0 0-1.78.5 1.6 1.6 0 0 0-.64 1.34c0 .9.5 1.46 1.49 1.77zm3.28-.05c-.3-.26-.76-.47-1.39-.62l-.68.94c.36.07.7.21 1 .41.2.13.3.36.3.59a.81.81 0 0 1-.35.7c-.3.18-.64.27-.98.24-.99 0-1.46-.32-1.93-1.18l-.68.94c.12.25.3.47.5.65.5.4 1.18.6 2.07.6a3.2 3.2 0 0 0 1.95-.53c.46-.35.73-.9.7-1.49a1.6 1.6 0 0 0-.51-1.25zM8.6 11.27h-.29V4.73h.29zm-7.61 0h1.4L3.48 9.8l-.7-.97zm6.16-6.54h-1.4l-.88 1.2.7.97zm-4.56 0H1.2l4.75 6.54h1.4z"/>
+</svg>
diff --git a/icons/rel/ani-ero.svg b/icons/rel/ani-ero.svg
new file mode 100644
index 00000000..f8fe6d00
--- /dev/null
+++ b/icons/rel/ani-ero.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#914040">
+<path d="M.92.53h14.16c.23.07.47.16.64.35.14.16.22.37.28.57v13.1a1.3 1.3 0 0 1-.35.64c-.16.14-.37.22-.57.28H.92a1.3 1.3 0 0 1-.64-.35 1.41 1.41 0 0 1-.28-.57V1.45c.06-.2.14-.4.28-.57C.45.7.68.6.92.53M2.13 1.6v1.07h2.14V1.6H2.13m3.2 0v1.07h2.14V1.6H5.33m3.2 0v1.07h2.14V1.6H8.53m3.2 0v1.07h2.14V1.6h-2.14m-9.6 2.13v8.54h11.74V3.73H2.13m0 9.6v1.07h2.14v-1.07H2.13m3.2 0v1.07h2.14v-1.07H5.33m3.2 0v1.07h2.14v-1.07H8.53m3.2 0v1.07h2.14v-1.07h-2.14z"/>
+<path d="M3.35 5.28C3.97 4.7 5 4.5 5.72 5.05c.44.3.66.82.97 1.24.3-.04.5-.34.79-.45.51-.3 1.17-.4 1.71-.11a2 2 0 0 1 .95.97c-.36-.17-.72-.37-1.13-.35-1.05-.01-1.94.96-1.92 2-.03.7.35 1.35.89 1.78.25.2-.23.35-.5.44-.55.28-1.1.55-1.7.7-.34.04-.57-.3-.8-.52a14.4 14.4 0 0 1-1.97-2.7A2.36 2.36 0 0 1 3 5.7c.09-.16.2-.3.34-.43Z"/>
+<path d="M11.4 6.3a1.5 1.5 0 0 1 1.66.67c.34.51.3 1.2.02 1.73-.39.73-.9 1.4-1.44 2-.2.23-.46.55-.8.38-.82-.27-1.57-.7-2.3-1.16-.48-.33-.92-.83-.94-1.45-.07-.78.57-1.57 1.36-1.6.48-.03.89.27 1.27.52.22.08.28-.36.44-.5.19-.25.42-.5.73-.59z"/>
+</g>
+</svg>
diff --git a/icons/rel/ani-story.svg b/icons/rel/ani-story.svg
new file mode 100644
index 00000000..f0f4e45f
--- /dev/null
+++ b/icons/rel/ani-story.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#914040">
+<path d="M.85.53h14.27c.43.1.78.45.88.88V14.6c-.1.43-.45.78-.88.88H.88A1.2 1.2 0 0 1 0 14.6V1.4C.1 1 .43.66.85.53M2.13 1.6v1.07h2.14V1.6H2.13m3.2 0v1.07h2.14V1.6H5.33m3.2 0v1.07h2.14V1.6H8.53m3.2 0v1.07h2.14V1.6h-2.14m-9.6 2.13v8.54h11.74V3.73H2.13m0 9.6v1.07h2.14v-1.07H2.13m3.2 0v1.07h2.14v-1.07H5.33m3.2 0v1.07h2.14v-1.07H8.53m3.2 0v1.07h2.14v-1.07z"/>
+<path d="M5.33 4.8 11.72 8l-6.39 3.2z"/>
+</g>
+</svg>
diff --git a/icons/rel/cartridge.svg b/icons/rel/cartridge.svg
new file mode 100644
index 00000000..2693ed7e
--- /dev/null
+++ b/icons/rel/cartridge.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<path fill="#eaeaea" d="M1.64.73h12.72v14.55l-12.72-.02V.73"/>
+<path fill="#706f6f" d="M3.27 2H6v.55H3.27V2zm0 1.45H6V4H3.27v-.55zm0 1.45H6v.55H3.27v-.54zm0 1.46H6v.55H3.27v-.55zm0 1.46H6v.54H3.27v-.54z"/>
+<path fill="#706f6f" d="M1.1 0h13.8v13.08l-.53.02V16H1.63v-2.9l-.55-.02V0m.55.73v11.82h.72v2.72h11.28v-2.72h.72V.73h-1.09v10.36H6.55V.73H1.64"/>
+<path fill="#706f6f" d="M3.27 9.27H6v.55H3.27v-.55zm0 1.46H6v.54H3.27v-.54zm0 1.45H6v.55H3.27v-.55zm0 1.46H6v.54H3.27v-.54z"/>
+<path fill="#939292" d="M6.9.73h6v10h-6v-10z"/>
+</svg>
diff --git a/icons/rel/disk.svg b/icons/rel/disk.svg
new file mode 100644
index 00000000..6975df79
--- /dev/null
+++ b/icons/rel/disk.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<path fill="#bdc3c7" d="M7.16.06a7.95 7.95 0 0 1 6.37 13.67 7.93 7.93 0 0 1-8.47 1.69 8.09 8.09 0 0 1-5.04-7.7A7.94 7.94 0 0 1 7.16.07m.58 5.78a2.17 2.17 0 1 0 2.4 1.79"/>
+<path fill="#ecf0f1" d="M7.74 5.84a2.17 2.17 0 1 1-1.87 2.51 2.16 2.16 0 0 1 1.88-2.51Zm-.03.72c-.8.15-1.37 1.07-1.09 1.85.17.53.66.96 1.23.99.33.03.66-.07.96-.24.3-.23.46-.46.56-.78a1.41 1.41 0 0 0-1.09-1.83 1.51 1.51 0 0 0-.57 0ZM2.4 3.32c.36-.44.78-.85 1.24-1.19.92 1.18 1.88 2.47 2.65 3.51A2.95 2.95 0 0 0 5.13 7.6C3.67 7.44 2.09 7.22.8 7.05a7.24 7.24 0 0 1 1.6-3.73Zm8.5 4.95c1.45.13 3.02.37 4.29.52 0 .37-.11.73-.2 1.09a7.31 7.31 0 0 1-2.64 3.84c-.89-1.13-1.8-2.4-2.58-3.4.79-.55.98-1.29 1.13-2.05z"/>
+</svg>
diff --git a/icons/rel/download.svg b/icons/rel/download.svg
new file mode 100644
index 00000000..f9e0652f
--- /dev/null
+++ b/icons/rel/download.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M5.13.75c.16-.35.5-.61.88-.68C7.62 0 9.4.03 10.82.05c.02 1.68.01 3.36 0 5.04.61.02 1.22 0 1.83 0 .15.02.34.02.41.18.02.15-.08.27-.16.4L8.48 11.4c-.13.16-.32.35-.55.29-.25-.08-.4-.33-.55-.53L3.14 5.72c-.09-.13-.22-.28-.2-.45.06-.17.26-.16.41-.17.55-.01 1.1.01 1.66-.01V1.5c0-.25 0-.52.12-.76z"/>
+<path d="M.9 11.01c.38-.1.78-.04 1.16-.04.02 1.06.01 2.13 0 3.19 4.09 0 8.08.04 11.87 0v-3.2c.4 0 .79-.04 1.17.05.32.09.52.4.63.69v3.9c-.1.22-.3.38-.54.36-5.17-.1-10.1.13-14.58-.03-.17-.03-.26-.19-.34-.32v-3.9c.1-.3.3-.61.63-.7z"/>
+</g>
+</svg>
diff --git a/icons/rel/free.svg b/icons/rel/free.svg
new file mode 100644
index 00000000..cc73c8e3
--- /dev/null
+++ b/icons/rel/free.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<path fill="#b5b5b5" d="M3.8 0c1.5 0 2.8.8 3.8 1.9.4.6-.1 1.3-.8 1.4-1.2.4-2.7.5-3.9 0-1-.7-1-2.2 0-3 .2-.1.6-.3 1-.3Zm0 1.2c-.7.1-.5 1.3.3 1.2h2c0-.7-.8-.7-1.2-1a3 3 0 0 0-1-.2ZM12 0c1.3-.2 2.4 1.6 1.6 2.7-.8 1.1-2.3 1-3.5.9-.9 0-2.3-.7-1.6-1.8A5.6 5.6 0 0 1 11.9 0ZM9.6 2.3c1 .1 2.3.5 3-.5-.4-1.1-1.8-.4-2.4 0l-.6.5zm-8.8 2c.7-.2 1.3-.1 2-.1h4.5V8H1.2c-.4.1-.7-.2-.9-.5V4.8c.2-.2.3-.5.5-.5zm7.9-.1h6.5l.5.6v2.7c-.2.2-.3.5-.6.5H8.7V4.2zM1.2 9.3c.2-.4.7-.5 1-.4h5.1V16H1.8c-.3-.1-.6-.3-.6-.6V9.3zM8.7 9c.9-.2 1.8-.1 2.8-.1h2.7c.3 0 .6.3.6.6v6c-.1.3-.4.4-.6.5H8.7V9z"/>
+</svg>
diff --git a/icons/rel/nonfree.svg b/icons/rel/nonfree.svg
new file mode 100644
index 00000000..ac90e26b
--- /dev/null
+++ b/icons/rel/nonfree.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M7.35.25a5.6 5.6 0 0 1 3.12.62c.18.32-.2.65-.25.99L9.6 3.39c-.9.01-1.79-.03-2.68-.04-.48.07-.44-.52-.6-.83-.1-.45-.48-.93-.44-1.36.32-.5.88-.81 1.46-.9Z"/>
+<path d="M3.96 2.04c.2.06.34.26.52.37.5.41 1.03.81 1.53 1.23.03.14-.07.53.14.3.15-.18.34-.32.58-.26l2.71.05c.18.1.42.27.5.43-.12.14-.22.32-.37.42l-3.15-.06c-.22-.09-.3-.4-.53-.5l-2.26-1.4c.05-.2.23-.4.33-.6ZM3.73 4.2c.13-.03.26 0 .4 0l1.46.09c.09 0 .17 0 .25.02l.18.42c-.2.03-.4.03-.61.06l-1.87.18c.06-.26.11-.52.19-.78zm2.76.5c.03-.02.08-.01.12-.01l2.74.1c.07 0 .12.05.17.1.85.68 1.63 1.46 2.33 2.3.48.6.92 1.24 1.24 1.94a4.44 4.44 0 0 1 .3 3.26 4.28 4.28 0 0 1-2.19 2.77c-.25.13-.53.2-.8.3a9 9 0 0 1-5.47-.16 3.98 3.98 0 0 1-1.23-.65 3.23 3.23 0 0 1-1.03-1.45c-.32-.9-.3-1.88-.08-2.8.02-.16.03-.31.07-.47a6.4 6.4 0 0 1 .8-1.8 16.1 16.1 0 0 1 2.87-3.3c.05-.04.1-.1.16-.13m1.06 1.38c-.04.17 0 .34-.02.51-.58.07-1.18.18-1.7.48-.41.24-.78.6-.95 1.07-.15.42-.16.9.02 1.31.2.43.58.74 1 .95.5.26 1.07.37 1.63.44v1.95c-.05.02-.1 0-.16-.01-.43-.08-.87-.2-1.21-.48-.15-.12-.22-.31-.4-.4a.74.74 0 0 0-.9.19.5.5 0 0 0 0 .58 2 2 0 0 0 .61.62 4.3 4.3 0 0 0 1.8.65l.26.04v.42c.01.11.09.21.17.29.22.16.57.12.72-.12.13-.17.03-.4.09-.58a4.6 4.6 0 0 0 1.83-.5c.42-.25.77-.6.95-1.06a1.9 1.9 0 0 0-.08-1.5c-.23-.4-.63-.7-1.06-.88a5.34 5.34 0 0 0-1.65-.33c-.02-.17 0-.35-.01-.52V7.76c.3 0 .57.06.85.15.26.09.52.2.72.4.1.1.17.24.3.33.26.18.65.15.88-.08a.5.5 0 0 0 .11-.58c-.1-.2-.26-.38-.43-.53a4.01 4.01 0 0 0-2.22-.84c-.07 0-.15 0-.2-.03-.03-.2.05-.44-.1-.6-.2-.3-.73-.24-.85.1z"/>
+<path d="M6.28 8.16c.35-.28.8-.36 1.24-.41.02.04.01.08.01.12V9.7c-.14 0-.27-.03-.4-.05a2.06 2.06 0 0 1-.82-.28.67.67 0 0 1-.28-.34.8.8 0 0 1 .25-.86zm2.22 2.72c.35.02.7.06 1.04.17.25.08.49.25.55.52.05.26 0 .55-.18.75-.19.22-.47.3-.74.39-.22.05-.45.1-.67.12-.02-.43 0-.85-.01-1.27v-.68z"/>
+</g>
+</svg>
diff --git a/icons/rel/notes.svg b/icons/rel/notes.svg
new file mode 100644
index 00000000..2a81a238
--- /dev/null
+++ b/icons/rel/notes.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M0 0h16v16H0V0m1.33 1.33v13.34h13.34V1.33Z"/>
+<path d="M2.67 5.33h10.66v1.34H2.67V5.33Zm0 2.67h10.66v1.33H2.67V8Zm0 2.67h8V12h-8v-1.33Z"/>
+</g>
+</svg>
diff --git a/icons/rel/reso-169.svg b/icons/rel/reso-169.svg
new file mode 100644
index 00000000..480d5aba
--- /dev/null
+++ b/icons/rel/reso-169.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M.63 1.72h14.74c.3.06.56.27.63.57v11.42c-.1.29-.32.54-.63.57H.63a.81.81 0 0 1-.63-.57V2.29c.07-.3.34-.51.63-.57m4.03 4.5c-.42.78-.42 1.7-.35 2.56.05.61.21 1.27.7 1.68.45.43 1.13.5 1.7.35.53-.15.94-.58 1.1-1.09a2.4 2.4 0 0 0-.03-1.44c-.25-.63-.93-1.13-1.62-1-.32.01-.57.22-.8.41.03-.38.06-.78.23-1.13a.68.68 0 0 1 .77-.36c.33.05.39.53.73.55a.5.5 0 0 0 .54-.6c-.09-.28-.34-.49-.58-.64a1.85 1.85 0 0 0-2.39.71m7.64-.67c-.32.19-.56.49-.7.82-.14.33-.14.69-.13 1.04.03.72.6 1.4 1.31 1.52.45.08.9-.1 1.23-.4-.05.43-.05.91-.34 1.26-.21.27-.67.34-.9.07-.16-.2-.37-.47-.66-.38-.2.02-.32.2-.38.37-.05.29.14.53.34.7.4.34.97.4 1.46.3a1.8 1.8 0 0 0 1.28-1.07c.28-.63.3-1.35.28-2.03-.04-.64-.14-1.33-.57-1.84a1.8 1.8 0 0 0-2.22-.36m-9.92-.08c-.23.24-.39.55-.66.75-.25.23-.59.34-.84.57-.23.3.1.8.47.66.3-.12.56-.34.8-.55.02 1.19-.02 2.37.02 3.56.07.38.61.51.87.24.21-.18.13-.49.15-.73V5.9c.05-.41-.5-.7-.81-.44m7.26.58c-.36.08-.66.45-.6.82-.01.46.46.86.91.77.45-.05.8-.54.68-.98a.82.82 0 0 0-1-.61m.04 2.53c-.2.04-.4.17-.5.36-.3.38-.1 1 .34 1.18.47.24 1.1-.13 1.14-.66.08-.53-.47-1-.98-.88z"/>
+<path d="M13.01 6.2c.39-.09.76.23.85.6.1.39.11.87-.18 1.2-.23.21-.6.26-.86.06-.35-.27-.35-.76-.33-1.16.02-.3.2-.64.52-.7zm-7 1.84c.42-.1.82.26.85.67.03.38.08.87-.24 1.16a.6.6 0 0 1-.8 0c-.34-.24-.39-.7-.37-1.08.02-.33.22-.68.56-.74z"/>
+</g>
+</svg>
diff --git a/icons/rel/reso-43.svg b/icons/rel/reso-43.svg
new file mode 100644
index 00000000..89e665bb
--- /dev/null
+++ b/icons/rel/reso-43.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M.54.56h14.92c.26.07.45.3.54.54v13.8c-.06.11-.1.24-.2.34-.1.08-.22.15-.34.2H.54A.84.84 0 0 1 0 14.9V1.1C.1.85.28.63.54.56m11.29 3.96a1.9 1.9 0 0 0-1.52.98.59.59 0 0 0 .2.81c.2.15.48.11.68-.03.19-.18.3-.45.53-.6.34-.2.87-.11 1.07.25.17.36.1.88-.26 1.1-.2.15-.48.16-.68.32-.17.2-.15.51.05.7.22.22.6.1.85.3.3.2.43.6.4.96.02.4-.18.83-.56 1-.38.2-.92.07-1.14-.32-.13-.2-.27-.45-.53-.5-.38-.11-.79.22-.78.61.02.3.22.56.43.77.78.76 2.08.82 2.97.24.42-.28.74-.7.89-1.18.15-.57.1-1.23-.3-1.7-.2-.3-.55-.42-.88-.54.22-.18.49-.33.65-.57.2-.24.31-.56.3-.87 0-.7-.5-1.33-1.14-1.6a2.35 2.35 0 0 0-1.23-.13m-7.93.5-2.3 3.4c-.17.25-.4.53-.33.85.02.43.4.77.83.8.65.02 1.3 0 1.96 0 .04.38-.1.82.15 1.14.2.27.66.3.91.08.32-.32.2-.8.23-1.2.24-.04.54-.02.7-.24.2-.23.17-.63-.08-.8-.17-.17-.42-.13-.63-.15-.01-1.22.03-2.44-.01-3.66-.01-.38-.38-.73-.77-.67-.3-.01-.5.24-.66.47m3.86.08c-.36.07-.67.36-.78.7a1.08 1.08 0 0 0 1.64 1.25c.44-.29.6-.91.35-1.37-.2-.46-.73-.69-1.2-.58m.21 3.62a1.1 1.1 0 0 0-.92.55c-.22.37-.18.88.1 1.22a1.08 1.08 0 0 0 1.9-.95c-.12-.48-.6-.84-1.08-.82z"/>
+<path d="m2.5 8.9 1.56-2.35v2.36H2.5Z"/>
+</g>
+</svg>
diff --git a/icons/rel/reso-custom.svg b/icons/rel/reso-custom.svg
new file mode 100644
index 00000000..2e7f9a51
--- /dev/null
+++ b/icons/rel/reso-custom.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M.5.58h9.6l-.01 3.82-.71.02V1.27H1.19v4.56h4.78v1.62h-3.6l-.02-.32H4.4l.02-.4H.51Z"/>
+<path d="M6.4 4.81H16v6.12h-4.02v.4l2.02.01.01.32-3.97-.01.2-.65-.18-.97h5.22V5.5H7.1v2.57h-.7z"/>
+<path d="m0 8.56 9.6-.02v6.16h-4v.4h2v.32H1.87v-.32h2.07v-.4H0V8.56m.72.7v4.53H8.9V9.27Z"/>
+</g>
+</svg>
diff --git a/icons/rel/voiced.svg b/icons/rel/voiced.svg
new file mode 100644
index 00000000..9f109a1f
--- /dev/null
+++ b/icons/rel/voiced.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#914040">
+<path d="M7.8 0c1.47-.1 2.93.83 3.45 2.21.39.93.2 1.95.25 2.93-.01 1.24.02 2.47-.02 3.7a3.52 3.52 0 0 1-3 3.13 3.51 3.51 0 0 1-3.74-2.2c-.37-.9-.2-1.9-.24-2.84.01-1.27-.02-2.54.02-3.81A3.52 3.52 0 0 1 7.8 0Zm-.29 1.04c-.94.19-1.8.98-1.94 1.96-.13.43-.04.92-.07 1.37V8.7a2.52 2.52 0 0 0 2.11 2.28c1.17.2 2.4-.54 2.76-1.67.22-.66.1-1.37.13-2.05 0-1.33.02-2.66-.01-4A2.47 2.47 0 0 0 8.5 1.08c-.3-.1-.67-.07-.99-.03z"/>
+<path d="M2.77 8.05a.5.5 0 0 1 .64.16c.1.15.09.34.1.51a4.45 4.45 0 0 0 2.7 3.91 4.5 4.5 0 0 0 6.28-3.85c.01-.19-.02-.4.1-.57a.5.5 0 0 1 .64-.16c.13.07.21.2.27.34v.37c-.03.28-.06.57-.11.85a5.48 5.48 0 0 1-4.03 4.22c-.28.08-.57.1-.85.15-.02.35 0 .7 0 1.01.13.02.26.01.4.01h2c.13 0 .28.01.4.1a.5.5 0 0 1 .14.63.66.66 0 0 1-.34.27H4.9a.66.66 0 0 1-.34-.27.5.5 0 0 1 .15-.63c.1-.09.25-.1.4-.1h2.4a17 17 0 0 0-.01-1.02l-.41-.06A5.47 5.47 0 0 1 2.5 8.79v-.4a.66.66 0 0 1 .27-.34zM8.5.96V2h-1V.96zM6.5 2h1v1h-1zm2 0h1v1h-1zm-3 1h1v1h-1c-.02-.01 0-1 0-1Zm2 0h1v1h-1V3zm2 0h1.01l-.01 1h-1Zm-3 1h1v1h-1zm2 0h1v1h-1V4zM5.42 5H6.5v1h-1ZM7.5 5h1v1h-1V5zm2 0h1l.1 1H9.5Zm-3 1h1v1h-1V6zm2 0h1v1h-1V6z"/>
+<path d="M5.4 7h1.1v1H5.4Zm2.1 0h1v1h-1V7zm2 0h1.1l-.1 1h-1Zm-3 1h1v1h-1V8zm2 0h1v1h-1V8zM5.46 9H6.5v1h-.8zM7.5 9h1v1h-1V9zm2 0h1.06l-.37 1H9.5Zm-3 1h1v1.04l-1-.45zm2 0h1v.74l-1 .32Z"/>
+</g>
+</svg>
diff --git a/icons/rss.svg b/icons/rss.svg
new file mode 100644
index 00000000..e57b91ec
--- /dev/null
+++ b/icons/rss.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14">
+<path fill="#F78422" d="M0 0h14v14H0z"/>
+<g fill="#fff">
+<path d="M2 2v2c4 0 8 3 8 8h2A10 10 0 0 0 2 2Z"/>
+<path d="M2 5.3v2c2.6 0 4.7 2 4.7 4.7h2A6.7 6.7 0 0 0 2 5.3z"/>
+<path d="M4.8 10.6a1.4 1.4 0 0 1-1.4 1.5A1.4 1.4 0 0 1 2 10.6a1.4 1.4 0 0 1 1.4-1.4 1.4 1.4 0 0 1 1.4 1.4z"/>
+</g>
+</svg>
diff --git a/data/icons/rtcomplete.png b/icons/rtcomplete.png
index cd9c640b..cd9c640b 100644
--- a/data/icons/rtcomplete.png
+++ b/icons/rtcomplete.png
Binary files differ
diff --git a/data/icons/rtpartial.png b/icons/rtpartial.png
index 89a9b21e..89a9b21e 100644
--- a/data/icons/rtpartial.png
+++ b/icons/rtpartial.png
Binary files differ
diff --git a/data/icons/rttrial.png b/icons/rttrial.png
index 5838692d..5838692d 100644
--- a/data/icons/rttrial.png
+++ b/icons/rttrial.png
Binary files differ
diff --git a/js/README.md b/js/README.md
new file mode 100644
index 00000000..2cbccbc0
--- /dev/null
+++ b/js/README.md
@@ -0,0 +1,70 @@
+# VNDB's JavaScript Mess
+
+(Because there's no way to do JS without it being a mess)
+
+This is very much a work in progress.
+
+
+## Organization
+
+Each subdirectory represents a JS bundle. Each bundle has an `index.js` file
+which is processed by the top-level Makefile and then converted into
+`static/g/<bundle>.js`. `index.js` can include other files with `@include
+file.js` lines, these are substituted with the contents of `file.js` and
+wrapped inside anonymous JS functions for scoping. File names are resolved
+relative to the index.js file itself, the special virtual `.gen/` directory
+resolves to `$VNDB_GEN`, see the top-level Makefile for the generated JS files.
+
+Scripts use the global `window` object to share functions and data, but apart
+from a bit of common library code, most scripts ought to be fairly
+self-contained.
+
+It's up to `index.js` to ensure dependent scripts are included in the proper
+order and it's up to the Perl backend to load the bundles in the proper order.
+This is somewhat brittle, but such is life.
+
+(Why this weird setup instead of CJS or ES6 modules and a proper bundler?
+Because I'm very picky about the software that I run on my dev system and
+there's no bundler included in my Linux distro's package repositories.)
+
+
+## Compatibility
+
+All JS code should be compatible with any 3-year old version of Firefox,
+Chrome, Blink and Safari, and a recent version of Pale Moon. The latter tends
+to be the most limiting, but they've been doing a lot of catching up on modern
+web standards. ES6 is generally no problem.
+
+Specific features to avoid:
+
+- class fields (not supported by Pale Moon 32.1)
+
+
+## Bundles
+
+- `basic`: Primary bundle for functionality and library code common to popular
+ pages on the site. The goal is to keep this below 20kB minified+gzipped.
+- `user`: Bundle for functionality that is commonly used by users with an
+ account.
+- `contrib`: Bundle for edit forms and other database contributions.
+- `graph`: D3.js-based graphs.
+- `search`: *TODO*, the advanced search filter selection thing.
+
+## Widgets
+
+...is the name I chose for components that can be instantiated from the Perl
+backend by adding a `widget($name, $data)` attribute to a HTML tag. They're
+similar to "modules" in Elm.
+
+A widget is a mithril.js component that can be registered anywhere in JS with
+the following line:
+
+```js
+widget('Name', vnode => {
+ let data = vnode.attrs.data;
+ // ...rest of the mithril component
+});
+```
+
+Where `data` is whatever the Perl backend passed to it. Objects and arrays
+referenced by `data` are not used elsewhere and can be freely mutated.
diff --git a/js/basic/TableOpts.js b/js/basic/TableOpts.js
new file mode 100644
index 00000000..89daf9dc
--- /dev/null
+++ b/js/basic/TableOpts.js
@@ -0,0 +1,132 @@
+// JS Widget corresponding to VNWeb::TableOpts, see the Perl implementation for
+// encoding & option details.
+
+// Simple wrapper to abstract away the bitwise crap.
+// These operate on 32bit integers, BigInts are a bit too recent to use I
+// think, but we don't need those yet.
+class Opts {
+ constructor(num) { this.n = num }
+
+ //get view() { return this.n & 3 }
+ get results() { return (this.n >> 2) & 7 }
+ get order() { return (this.n & 32) > 0 }
+ get sortCol() { return (this.n >> 6) & 63 }
+ isVis(v) { return (this.n & (1 << (v + 12))) > 0 }
+
+ //set view(v) { this.n = (this.n & ~3) | v }
+ set results(v) { this.n = (this.n & ~28) | (v << 2) }
+ set order(v) { this.n = v ? (this.n | 32) : (this.n & ~32) }
+ set sortCol(v) { this.n = (this.n & ~4032) | (v << 6) }
+ setVis(v,b) { this.n = b ? (this.n | (1 << (v + 12))) : (this.n & ~(1 << (v + 12))) }
+
+ encode() {
+ const alpha = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-';
+ let n = this.n;
+ let v = n ? '' : alpha[0];
+ while(n > 0) {
+ v = alpha[n & 63].concat(v);
+ n >>= 6;
+ }
+ return v;
+ }
+}
+
+const resultOptions = [50,10,25,100,200];
+
+widget('TableOpts', (vnode) => {
+ const conf = vnode.attrs.data;
+ const opts = new Opts(conf.value);
+ const save = new Api('TableOptsSave');
+
+ // This widget is loaded into an <ul> that contains a hidden input element
+ // - which we don't need, we create our own - and potentially some view
+ // buttons that we do like to keep. This is an ugly hack to load the
+ // pre-existing elements into our vdom by going through an HTML parse.
+ // Would be nicer if we can just pass DOM nodes directly to the vdom, but
+ // Mithril doesn't support that. Maybe contribute that as a new feature?
+ // Doesn't sound too complicated given that "trust" nodes already have the
+ // infrastructure for it.
+ const oldNodes = m.trust(vnode.attrs.oldContents.filter(e => !e.querySelector('input[name=s]')).map(e => e.outerHTML).join(''));
+
+ const submit = (o) => {
+ const e = vnode.dom.parentNode.querySelector('input[name=s]');
+ e.value = o.encode();
+ e.form.submit();
+ };
+
+ const sortBut = (s,desc) => m('a[href=#]', {
+ onclick: ev => {
+ ev.preventDefault();
+ let o = new Opts(opts.n);
+ o.sortCol = s.id;
+ o.order = desc;
+ submit(o);
+ },
+ class: opts.sortCol == s.id && opts.order == desc ? 'checked' : null,
+ title: s.name + ' ' + (desc ? 'descending' : 'ascending'),
+ }, s.num ? (desc ? '9→1' : '1→9') : (desc ? 'Z→A' : 'A→Z'));
+
+ const view = () => [
+ m('li.hidden', m('input[type=hidden][name=s]', { value: opts.encode() })),
+ !conf.save ? null : m('li.maintabs-dd.tableopts-save', m(MainTabsDD, {
+ a_body: m(Icon.Save),
+ a_attrs: { title: 'save display settings' },
+ content: () => [
+ m('h4', 'save display settings'),
+ save.saved({ save: conf.save, value: opts.n }) ? 'Saved!'
+ : save.loading() ? m('span.spinner')
+ : save.error ? m('b', save.error)
+ : m('input[type=button]', {
+ value: 'Save current settings as default',
+ onclick: () => save.call({ save: conf.save, value: opts.n })
+ }),
+ conf.default == opts.n ? null : m('input[type=button]', {
+ value: 'Load default view',
+ onclick: () => submit(new Opts(conf.default)),
+ }),
+ conf.usaved === null || conf.usaved == opts.n ? null : m('input[type=button]', {
+ value: 'Load my saved settings',
+ onclick: () => submit(new Opts(conf.usaved)),
+ }),
+ ],
+ })),
+ m('li.maintabs-dd.tableopts-results', m(MainTabsDD, {
+ a_body: resultOptions[opts.results],
+ a_attrs: { title: 'results per page' },
+ content: () => [
+ m('h4', 'results per page'),
+ [1,2,0,3,4].flatMap(n => [' | ',
+ m('a[href=#]', {
+ onclick: (ev) => { ev.preventDefault(); let o = new Opts(opts.n); o.results = n; submit(o) },
+ }, resultOptions[n])
+ ]).slice(1),
+ ]
+ })),
+ conf.vis.length == 0 ? null : m('li.maintabs-dd.tableopts-cols', m(MainTabsDD, {
+ a_body: m(Icon.Eye),
+ a_attrs: { title: 'visible columns' },
+ content: () => [
+ m('h4', 'visible columns'),
+ conf.vis.map(c => m('label', c.name, ' ', m('input[type=checkbox]', {
+ checked: opts.isVis(c.id),
+ oninput: ev => opts.setVis(c.id, ev.target.checked),
+ }))),
+ m('input[type=submit][value=Update]'),
+ ]
+ })),
+ conf.sorts.length == 0 ? null : m('li.maintabs-dd.tableopts-sort', m(MainTabsDD, {
+ a_body: m(Icon.ArrowDownUp),
+ a_attrs: { title: 'sort options' },
+ content: () => [
+ m('h4', 'sort options'),
+ m('table', conf.sorts.map(s => m('tr',
+ m('td', s.name),
+ m('td', sortBut(s,false)),
+ m('td', sortBut(s,true)),
+ ))),
+ ]
+ })),
+ oldNodes,
+ ];
+ return {view};
+})
diff --git a/js/basic/api.js b/js/basic/api.js
new file mode 100644
index 00000000..0d53b401
--- /dev/null
+++ b/js/basic/api.js
@@ -0,0 +1,73 @@
+// Simple wrapper around XHR to call into the backend, provide friendly error
+// messages and integrate with mithril.js.
+// Can only handle one request at a time.
+// Reports results back with a plain old callback instead of a promise, because
+// VNDB's XHR use is too simple for anything more complex to add much value.
+class Api {
+ constructor(endpoint) {
+ this.endpoint = endpoint;
+ this.abort();
+ }
+
+ loading() {
+ return this.xhr && this.xhr.readyState != 4;
+ }
+
+ abort() {
+ this.error = null;
+ if (this.xhr) this.xhr.abort();
+ this.xhr = null;
+ this._saved = false;
+ this._lastdata = null;
+ }
+
+ _err(cb, msg) {
+ this.error = msg;
+ cb && cb(this.xhr && this.xhr.response);
+ m.redraw();
+ }
+
+ _load(cb, errcb, xhr) {
+ if (xhr.status == 403) return this._err(errcb, 'Permission denied. Your session may have expired, try reloading the page.');
+ if (xhr.status == 413) return this._err(errcb, 'File upload too large.');
+ if (xhr.status == 429) return this._err(errcb, 'Action throttled, please try again later.');
+ if (xhr.status != 200) return this._err(errcb, 'Server error '+xhr.status+', please try again later or report a bug if this persists.');
+ if (xhr.response === null || "object" != typeof xhr.response) return this._err(errcb, 'Invalid response from the server, please report a bug.');
+ if (xhr.response._err) return this._err(errcb, xhr.response._err);
+ if (xhr.response._redir) { location.href = xhr.response._redir; return }
+ this.error = null;
+ this._saved = this._lastdata;
+ cb && cb(xhr.response);
+ m.redraw();
+ }
+
+ // The parsed response JSON is passed as argument to the callback.
+ call(data, cb, errcb) {
+ this.abort();
+
+ var xhr = new XMLHttpRequest();
+ xhr.ontimeout = () => this._err(errcb, 'Network timeout, please try again later.');
+ xhr.onerror = () => this._err(errcb, 'Network error, please try again later.');
+ xhr.onload = () => this._load(cb, errcb, xhr);
+ xhr.open('POST', '/js/'+this.endpoint+'.json', true);
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.responseType = 'json';
+ xhr.send(this._lastdata = JSON.stringify(data));
+ this.xhr = xhr;
+ }
+
+ // Returns true if the given 'data' has been "saved" by the most recent
+ // successful call(). This is level-triggered, once the 'data' is seen as
+ // being different it will remember that state till the next call().
+ saved(data) {
+ if (this._saved === false) return false;
+ if (this._saved !== JSON.stringify(data)) return (this._saved = false);
+ return true;
+ }
+
+ // Manually override the data that's considered as "saved".
+ setsaved(data) {
+ this._saved = JSON.stringify(data);
+ }
+};
+window.Api = Api;
diff --git a/js/basic/checkall.js b/js/basic/checkall.js
new file mode 100644
index 00000000..f74a74f1
--- /dev/null
+++ b/js/basic/checkall.js
@@ -0,0 +1,16 @@
+/* "checkall" checkbox, usage:
+ *
+ * <input type="checkbox" class="checkall" name="$somename">
+ *
+ * Checking that will synchronize all other checkboxes with name="$somename".
+ * The "x-checkall" attribute may also be used instead of "name".
+ */
+$$('input[type=checkbox].checkall').forEach(el =>
+ el.addEventListener('click', () => {
+ const name = el.getAttribute('x-checkall') || el.name;
+ $$('input[type=checkbox][name="'+name+'"], input[type=checkbox][x-checkall="'+name+'"]').forEach(el2 => {
+ if(el2.checked != el.checked)
+ el2.click();
+ });
+ })
+);
diff --git a/js/basic/checkhidden.js b/js/basic/checkhidden.js
new file mode 100644
index 00000000..df1a2679
--- /dev/null
+++ b/js/basic/checkhidden.js
@@ -0,0 +1,11 @@
+/* "checkhidden" checkbox, usage:
+ *
+ * <input type="checkbox" class="checkhidden" value="$somename">
+ *
+ * Checking that will toggle the 'hidden' class of all elements with the "$somename" class.
+ */
+$$('input[type=checkbox].checkhidden').forEach(el => {
+ const f = () => $$('.'+el.value).forEach(el2 => el2.classList.toggle('hidden', !el.checked));
+ f();
+ el.addEventListener('click', f);
+});
diff --git a/js/basic/components.js b/js/basic/components.js
new file mode 100644
index 00000000..96182be1
--- /dev/null
+++ b/js/basic/components.js
@@ -0,0 +1,433 @@
+const langs = Object.fromEntries(vndbTypes.language);
+const plats = Object.fromEntries(vndbTypes.platform);
+window.LangIcon = id => m('abbr', { class: 'icon-lang-'+id, title: langs[id] });
+window.PlatIcon = id => m('abbr', { class: 'icon-plat-'+id, title: plats[id] });
+
+
+// SVG icons from: https://lucide.dev/
+// License: MIT
+// The nice thing about these is that they all have the same viewbox and fill/stroke options.
+// Icon size should be set in CSS.
+const icon = svg => ({
+ view: () => m.trust('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+svg+'</g></svg>'),
+ raw: svg,
+});
+window.Icon = {
+ ArrowBigDown: icon('<path d="M15 6v6h4l-7 7-7-7h4V6h6z"/>'),
+ ArrowBigUp: icon('<path d="M9 18v-6H5l7-7 7 7h-4v6H9z"/>'),
+ ArrowDownUp: icon('<path d="m3 16 4 4 4-4"></path><path d="M7 20V4"></path><path d="m21 8-4-4-4 4"></path><path d="M17 4v16"></path>'),
+ Ban: icon('<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>'),
+ CheckSquare: icon('<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>'),
+ ChevronDown: icon('<polyline points="6 9 12 15 18 9">'),
+ Copy: icon('<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>'),
+ Eye: icon('<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle>'),
+ FolderHeart: icon('<path d="M11 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v1.5"/><path d="M13.9 17.45c-1.2-1.2-1.14-2.8-.2-3.73a2.43 2.43 0 0 1 3.44 0l.36.34.34-.34a2.43 2.43 0 0 1 3.45-.01v0c.95.95 1 2.53-.2 3.74L17.5 21Z"/>'),
+ Globe: icon('<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>'),
+ Info: icon('<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>'),
+ MinusSquare: icon('<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><line x1="8" x2="16" y1="12" y2="12"/>'),
+ Pencil: icon('<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>'),
+ Redo2: icon('<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13"/>'),
+ Replace: icon('<path d="M14 4c0-1.1.9-2 2-2"/><path d="M20 2c1.1 0 2 .9 2 2"/><path d="M22 8c0 1.1-.9 2-2 2"/><path d="M16 10c-1.1 0-2-.9-2-2"/><path d="m3 7 3 3 3-3"/><path d="M6 10V5c0-1.7 1.3-3 3-3h1"/><rect width="8" height="8" x="2" y="14" rx="2"/>'),
+ Save: icon('<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline>'),
+ Search: icon('<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/>'),
+ StepForward: icon('<line x1="6" x2="6" y1="4" y2="20"/><polygon points="10,4 20,12 10,20"/>'),
+ Trash2: icon('<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2M10 11v6M14 11v6"/>'),
+ Tv: icon('<rect width="20" height="15" x="2" y="7" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/>'),
+ Users2: icon('<path d="M14 19a6 6 0 0 0-12 0"/><circle cx="8" cy="9" r="4"/><path d="M22 19a6 6 0 0 0-6-6 4 4 0 1 0 0-8"/>'),
+ X: icon('<line x1="18" x2="6" y1="6" y2="18"/><line x1="6" x2="18" y1="6" y2="18"/>'),
+};
+
+const but = (icon, title) => ({view: vnode => m('button[type=button].icon', { title,
+ onclick: ev => { ev.preventDefault(); vnode.attrs.onclick(ev) },
+ style: !('visible' in vnode.attrs) || vnode.attrs.visible ? null : 'visibility:hidden',
+ }, m(icon)
+)});
+window.Button = {
+ Edit: but(Icon.Pencil, 'Edit'),
+ Del: but(Icon.Trash2, 'Delete item'),
+ Cancel: but(Icon.Ban, 'Cancel'),
+ Up: but(Icon.ArrowBigUp, 'Move up'),
+ Down: but(Icon.ArrowBigDown, 'Move down'),
+ Copy: but(Icon.Copy, 'Copy'),
+ CheckAll: but(Icon.CheckSquare, 'Check all'),
+ UncheckAll: but(Icon.MinusSquare, 'Uncheck all'),
+};
+
+const helpState = {};
+window.HelpButton = id => m('a.help[href=#][title=Info]',
+ { onclick: ev => { ev.preventDefault(); helpState[id] = !helpState[id]; } },
+ m(Icon.Info)
+);
+window.Help = (id, ...content) => helpState[id] ? m('section.help',
+ { oncreate: vnode => vnode.dom.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'}) },
+ m('a[href=#]', { onclick: ev => { ev.preventDefault(); helpState[id] = false; } }, m(Icon.X)),
+ content
+) : null;
+
+
+// Dropdown box for use in a <li class="maintabs-dd">.
+// (This would be trivial enough to inline if it weren't for how tricky it is
+// to get the toggle functionality working as it should)
+window.MainTabsDD = (initVnode) => {
+ let open = false;
+
+ const toggle = (ev) => {
+ if (open && initVnode.dom.nextSibling.contains(ev.target)) return;
+ open = !open;
+ // Defer the listener, otherwise this current event will trigger it.
+ if (open) requestAnimationFrame(() => document.addEventListener('click', toggle));
+ else document.removeEventListener('click', toggle);
+ m.redraw();
+ };
+
+ const view = vnode => [
+ m('a[href=#]', {
+ onclick: (ev) => { ev.preventDefault(); toggle(ev) },
+ ...vnode.attrs.a_attrs,
+ }, vnode.attrs.a_body),
+ open ? m('div', m('div', vnode.attrs.content())) : null,
+ ];
+
+ return {view};
+};
+
+
+const focusElem = el => {
+ if (el.tagName === 'LABEL' && el.htmlFor) el = $('#'+el.htmlFor);
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') el.focus();
+ else el.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'});
+};
+
+// Wrapper around a <form> with a <fieldset> element and some magic.
+// Attrs:
+// - onsubmit - submit event, already has preventDefault()
+// - disabled - set 'disabled' attribute on the fieldset
+// - api - Api object, see below, also sets 'disabled' when api.loading()
+//
+// The .invalid-form class is set on an invalid <form> *after* the user
+// attempts to submit it, to help with styling invalid inputs. The onsubmit
+// event is not dispatched when the form contains a .invalid element.
+window.Form = () => {
+ let submitted = false, report;
+ return { view: vnode => {
+ const api = vnode.attrs.api;
+ return m('form[novalidate]', {
+ onsubmit: ev => {
+ ev.preventDefault();
+ report = true;
+ submitted = api;
+ if (ev.target.querySelector('.invalid')) return;
+ const x = vnode.attrs.onsubmit;
+ x && x(ev);
+ },
+ onupdate: v => requestAnimationFrame(() => {
+ const inv = v.dom.querySelector('.invalid');
+ v.dom.classList.toggle('invalid-form', submitted === api && (inv || (api && api.error)));
+ if (inv && report) {
+ // If we have a FormTabs child, let that component do the reporting.
+ const t = $('#js-formtabs');
+ if (t) t.dispatchEvent(new Event('formerror'));
+ else focusElem(inv);
+ }
+ report = false;
+ }),
+ }, m('fieldset',
+ { disabled: vnode.attrs.disabled || (api && api.loading()) },
+ vnode.children
+ ))
+ }};
+};
+
+
+// Draw a form with multiple tabs, attrs:
+// - tabs - Array of tabs, each tab is a 3-element arrays:
+// [ id, label, func ]
+// func should return the contents of the tab.
+// - sel - Id of initially selected tab.
+//
+// The currently selected tab is tracked in location.hash, so linking to a
+// specific tab is possible.
+//
+// The tabs integrate with a parent Form component to properly report errors:
+// on submission and if there's no error on the currently opened tab, it
+// automatically switches to the first tab with an error and focuses the
+// .invalid element.
+//
+// The list of tabs must be static and known at component creation time,
+// dynamically adding/removing tabs is not supported.
+window.FormTabs = initVnode => {
+ const tabs = initVnode.attrs.tabs;
+ const h = location.hash.replace('#', '');
+ let sel = initVnode.attrs.sel || (
+ h && (h === 'all' || tabs.find(t => t[0] === h)) ? h : tabs[0][0]
+ );
+ let report;
+ const set = n => location.replace('#'+(sel=n));
+ const onclick = ev => {
+ ev.preventDefault();
+ set(ev.target.href.replace(/^.+#/, ''));
+ };
+ const onformerror = ev => {
+ report = true;
+ // Make sure we have a tab open with an error
+ if (tabs.length > 1 && sel !== 'all' && !$('#formtabs_'+sel+' .invalid')) {
+ for (const t of tabs) {
+ if (sel === t[0]) continue;
+ if ($('#formtabs_'+t[0]+' .invalid')) {
+ set(t[0]);
+ break;
+ }
+ }
+ }
+ };
+ const view = () => [
+ tabs.length > 1 ? m('nav', {id: 'js-formtabs', onformerror}, m('menu',
+ tabs.concat([['all', 'All items']]).map(t =>
+ m('li', { key: t[0], id: 'formtabst_'+t[0], class: sel === t[0] ? 'tabselected' : ''},
+ m('a', {onclick, href: '#'+t[0]}, t[1])
+ )
+ ),
+ )) : null,
+ tabs.map(t => m('article',
+ { key: t[0], class: sel === t[0] || sel === 'all' ? '' : 'hidden' },
+ m('fieldset', {id: 'formtabs_'+t[0]}, t[2]())
+ )),
+ ];
+ const onupdate = () => requestAnimationFrame(() => {
+ // Set the 'invalid-tab' class on the tabs. The form state is not known
+ // during the view function, so this has to be done in an onupdate hook.
+ let inv;
+ if (tabs.length > 1)
+ for (const t of tabs) {
+ const el = $('#formtabs_'+t[0]+' .invalid');
+ if (!inv && (sel === 'all' || t[0] === sel)) inv = el;
+ $('#formtabst_'+t[0]).classList.toggle('invalid-tab', !!el);
+ }
+ if (report && inv) requestAnimationFrame(() => focusElem(inv));
+ report = false;
+ });
+ return {view,onupdate};
+};
+
+
+
+// Text input field.
+// Attrs:
+// - class
+// - id
+// - tabindex
+// - placeholder
+// - type
+// 'email', 'password', 'username', 'weburl'
+// 'number' -> Only digits allowed
+// 'textarea' -> <textarea>
+// otherwise -> regular text input field
+// - invalid -> Custom HTML validation message
+// - data + field -> input value is read from and written to 'data[field]'
+// - oninput -> called after 'data[field]' has been modified, takes new value as argument
+// - required / minlength / maxlength / pattern
+// HTML5 validation properties, except with a custom implementation.
+// The length is properly counted in Unicode points rather than UTF-16 digits.
+// - focus -> Bool, set input focus on create
+// - rows / cols -> For texarea
+// - onfocus
+//
+// The HTML5 validity API has some annoying limitations and is not always
+// honored, so this component simply re-implements validation and reporting of
+// errors. When the field fails validation, the following happens:
+// - The input element gets a .invalid class
+// - The input element is followed by a 'p.invalid' element containing the message
+// - If a 'label[for=$id]' exists, that label is also given the .invalid class
+//
+// The Form and FormTabs components detect and handle .invalid inputs.
+window.Input = () => {
+ const validate = a => {
+ const v_ = a.data[a.field];
+ const v = v_ === null ? '' : String(v_).trim();
+ if (a.invalid) return a.invalid;
+ if (!v.length) return a.required ? 'This field is required.' : '';
+ if (a.type === 'username') { a.minlength = 2; a.maxlength = 15; }
+ if (a.type === 'password') { a.minlength = 4; a.maxlength = 500; }
+ if (a.minlength && [...v].length < a.minlength) return 'Please use at least '+a.minlength+' characters.';
+ if (a.maxlength && [...v].length > a.maxlength) return 'Please use at most '+a.maxlength+' characters.';
+ if (a.type === 'username') {
+ if (/^[a-zA-Z][0-9]+$/.test(v)) return 'Username must not look like a VNDB identifier (single alphabetic character followed only by digits).';
+ const dup = {};
+ const chrs = v.replace(/[a-zA-Z0-9-]/g, '').split('').sort().filter(c => !dup[c] && (dup[c]=1));
+ if (chrs.length === 1) return 'The character "'+chrs[0]+'" can not be used.';
+ if (chrs.length) return 'The following characters can not be used: '+chrs.join(', ')+'.';
+ }
+ if (a.type === 'email' && !new RegExp(formVals.email).test(v)) return 'Invalid email address.';
+ if (a.type === 'weburl') {
+ if (!/^https?:\/\//.test(v)) return 'URL must start with http:// or https://.';
+ if (/^https?:\/\/[^/]+$/.test(v)) return "URL must have a path component (hint: add a '/'?).";
+ if (!new RegExp(formVals.weburl).test(v)) return 'Invalid URL.';
+ }
+ if (a.pattern && !new RegExp(a.pattern).test(v)) return 'Invalid format.';
+ return '';
+ };
+ const view = vnode => {
+ const a = vnode.attrs;
+ const invalid = validate(a);
+ const attrs = {
+ id: a.id, tabindex: a.tabindex, placeholder: a.placeholder,
+ rows: a.rows, cols: a.cols, onfocus: a.onfocus,
+ class: (a.class||'') + (invalid ? ' invalid' : ''),
+ oninput: ev => {
+ let v = ev.target.value;
+ if (a.type === 'number') v = Math.floor(v.replace(/[^0-9]+/g, '')||0);
+ a.data[a.field] = v;
+ a.oninput && a.oninput(v);
+ },
+ oncreate: a.focus ? v => v.dom.focus() : null,
+ };
+ return [
+ a.type === 'textarea'
+ ? m('textarea', { ...attrs }, a.data[a.field])
+ : m('input', { ...attrs, value: a.data[a.field] === null ? '' : a.data[a.field],
+ type: a.type === 'email' ? 'email' : a.type === 'password' ? 'password' : 'text',
+ }),
+ invalid ? m('p.invalid', invalid) : null,
+ ];
+ };
+ // Searching the DOM for labels on every update isn't very optimal, but it hasn't been an issue so far.
+ const onupdate = vnode => vnode.attrs.id && $$('label[for='+vnode.attrs.id+']')
+ .map(el => el.classList.toggle('invalid', !!validate(vnode.attrs)));
+ return {view,onupdate};
+};
+
+
+// Handy <select> abstraction.
+// Attrs:
+// - data + field -> value is read from and written to data[field]
+// - value -> alternative to data+field
+// - options -> array of [value,label] options
+// - oninput -> called after value has changed
+// - id
+// - class
+//
+// 'value's are compared with '===' for equality, arrays are recursed into.
+const _eql = (a,b) => a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x,i) => _eql(x,b[i])));
+window.Select = { view: v => m('select',
+ {
+ id: v.attrs.id, class: v.attrs.class,
+ oninput: ev => {
+ const val = v.attrs.options[ev.target.selectedIndex][0];
+ if (v.attrs.data) v.attrs.data[v.attrs.field] = val;
+ v.attrs.oninput && v.attrs.oninput(val);
+ },
+ }, v.attrs.options.map(([value,label]) => m('option', { selected: _eql(v.attrs.data ? v.attrs.data[v.attrs.field] : v.attrs.value, value) }, label))
+)};
+
+
+
+
+// BBCode & Markdown editor with preview button.
+// Attrs:
+// - data + field -> raw text is read from and written to data[field]
+// - header -> element to draw at the top-left
+// - attrs -> attrs to add to the Input
+// - type -> 'bb' || 'markdown', defaults to bb
+// - full -> Add 'full' class for full-width input
+window.TextPreview = initVnode => {
+ var preview = false;
+ var html = null;
+ const {data,field} = initVnode.attrs;
+ const api = new Api(initVnode.attrs.type === 'markdown' ? 'Markdown' : 'BBCode');
+
+ const unload = () => {
+ api.abort();
+ preview = false;
+ return false;
+ };
+
+ const load = () => {
+ if (html) {
+ preview = true;
+ } else {
+ api.call({content: data[field]},
+ res => { preview = true; html = res.html; },
+ () => { preview = true; html = '<b>'+api.error+'</b>'; },
+ );
+ }
+ return false;
+ };
+
+ const view = vnode => m('div.textpreview', { class: vnode.attrs.full ? 'full' : null },
+ m('div',
+ m('div', vnode.attrs.header),
+ m('div', data[field].length == 0 ? {class:'invisible'}:null,
+ api.loading() ? m('span.spinner') : null,
+ preview ? m('a[href=#]', {onclick: unload}, 'Edit') : m('span', 'Edit'),
+ preview ? m('span', 'Preview') : m('a[href=#]', {onclick: load}, 'Preview'),
+ ),
+ ),
+ m(Input, { ...vnode.attrs.attrs,
+ type: 'textarea',
+ class: (vnode.attrs.attrs.class||'') + (preview ? ' hidden' : ''),
+ data, field, oninput: e => html = null
+ }),
+ preview ? m('div.preview', { class: vnode.attrs.type === 'markdown' ? 'docs' : null }, m.trust(html)) : null,
+ );
+ return {view};
+};
+
+
+// Release dates are integers with the following format: 0, 1 or yyyymmdd
+// Special values
+// 0 -> unknown
+// 1 -> "today" (only used as filter)
+// 99999999 -> TBA
+// yyyy9999 -> year known, month & day unknown
+// yyyymm99 -> year & month known, day unknown
+//
+// This component provides a friendly input for such dates.
+// Attrs:
+// - value
+// - oninput -> callback accepting the new value
+// - id -> id of the first select input
+// - today -> bool, whether "today" should be accepted as an option
+// - unknown -> bool, whether "unknown" should be accepted as an option
+window.RDate = {
+ expand: v => ({
+ y: Math.floor(v / 10000),
+ m: Math.floor(v / 100) % 100,
+ d: v % 100,
+ }),
+ compact: ({y,m,d}) => y * 10000 + m * 100 + d,
+ maxDay: ({y,m}) => new Date(y, m, 0).getDate(),
+ normalize: ({y,m,d}) =>
+ y === 0 ? { y: 0, m: 0, d: d?1:0 } :
+ y === 9999 ? { y: 9999, m: 99, d: 99 } :
+ m === 0 || m === 99 ? { y, m: 99, d: 99 } :
+ { y,m, d: d === 0 || d === 99 ? 99 : Math.min(d, RDate.maxDay({y,m})) },
+ months: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ],
+ fmt: ({y,m,d}) =>
+ y === 0 ? (d ? 'Today' : 'Unknown') :
+ y === 9999 ? 'TBA' :
+ String(y) + (m === 0 ? '' : '-'+String(m).padStart(2,0) + (d === 0 ? '' : '-'+String(d).padStart(2,0))),
+ view: vnode => {
+ const v = RDate.expand(vnode.attrs.value);
+ const oninput = ev => vnode.attrs.oninput && vnode.attrs.oninput(Math.floor(ev.target.options[ev.target.selectedIndex].value));
+ const o = (e,l) => {
+ const value = RDate.compact(RDate.normalize({...v, ...e}));
+ return m('option', { value, selected: value === vnode.attrs.value }, l);
+ };
+ return [
+ m('select', {oninput, id: vnode.attrs.id},
+ vnode.attrs.today ? o({y:1}, 'Today') : null,
+ vnode.attrs.unknown ? o({y:0}, 'Unknown') : null,
+ o({y:9999}, 'TBA'),
+ range(new Date().getFullYear()+5, 1980, -1).map(y => o({y},y)),
+ ),
+ v.y > 0 && v.y < 9999 ? m('select', {oninput},
+ o({m:99}, '- month -'),
+ range(1, 12).map(m => o({m}, m + ' (' + RDate.months[m-1] + ')')),
+ ) : null,
+ v.m > 0 && v.m < 99 ? m('select', {oninput},
+ o({d:99}, '- day -'),
+ range(1, RDate.maxDay(v)).map(d => o({d},d)),
+ ) : null,
+ ];
+ },
+};
diff --git a/js/basic/ds.js b/js/basic/ds.js
new file mode 100644
index 00000000..f4756daf
--- /dev/null
+++ b/js/basic/ds.js
@@ -0,0 +1,450 @@
+// Dialog/Dropdown Select/Search.
+// i.e. a selection thingy component.
+
+// global dialog element, initialized lazily, reused by different instances as
+// there can only be one dialog open at a time.
+let globalObj;
+// Points to the DS object that is currently active (or null).
+let activeInstance;
+
+const setupObj = () => {
+ if (globalObj) return;
+ globalObj = document.createElement('div');
+ document.body.appendChild(globalObj);
+ m.mount(globalObj, {
+ view: v => activeInstance ? activeInstance.view(v) : [],
+ });
+};
+
+const keydown = ev => {
+ if (activeInstance) activeInstance.keydown(ev);
+ m.redraw();
+};
+
+const position = () => {
+ const obj = $('#ds');
+ if(!obj) return;
+
+ const margin = 5;
+
+ const inst = activeInstance;
+ const opener = inst.opener.getBoundingClientRect(); // BUG: this doesn't work if ev.target is inside a positioned element
+ const header = obj.children[0].getBoundingClientRect().height;
+ const cols = Math.max(1, Math.min(Math.floor((window.innerWidth - margin*2) / inst.width), inst.maxCols||1));
+ const width = Math.min(window.innerWidth - margin*2, inst.width*cols);
+ const left = Math.max(margin,
+ opener.x + opener.width - width,
+ Math.min(window.innerWidth - width - 2*margin, opener.x),
+ );
+
+ const top = opener.y + opener.height;
+ const height = Math.max(header + 20, Math.min(window.innerHeight - margin*2, window.innerHeight - top - margin));
+
+ obj.style.top = (top + window.scrollY) + 'px';
+ obj.style.left = (left + window.scrollX) + 'px';
+ obj.style.width = width + 'px';
+ const d = obj.children[1];
+ if (d && d.tagName == 'DIV') {
+ d.style.maxHeight = (height - header) + 'px';
+ d.children[0].style.columnCount = cols;
+ }
+
+ const e = obj.querySelector('li.active');
+ if (e) d.scrollTop = Math.max(Math.min(e.offsetTop, d.scrollTop), e.offsetTop + e.offsetHeight - d.offsetHeight);
+};
+
+const close = ev => {
+ if (!activeInstance) return;
+ if (ev && (globalObj.contains(ev.target) || activeInstance.opener.contains(ev.target))) return;
+ if (!ev) activeInstance.opener.focus();
+ if (activeInstance) activeInstance.abort();
+ activeInstance = null;
+ document.removeEventListener('click', close);
+ document.removeEventListener('keydown', keydown);
+ document.removeEventListener('scroll', position);
+ removeEventListener('resize', position);
+ m.redraw();
+};
+
+
+// Constructor options (all optional):
+// - width
+// - maxCols
+// - placeholder
+// - more
+// Adds a "type for more options" as last option if search is empty.
+// - nosearch
+// Disable search input
+// - onselect(obj,checked)
+// Called when an item has been selected. 'checked' is always true for
+// single-selection dropdowns.
+// - props(obj)
+// Called on each displayed object, should return null if the object should
+// be filtered out or an object otherwise. The object supports the
+// following options:
+// - selectable: boolean, default true
+// - append: vdom node to append to the item
+// - checked(obj)
+// Set for multiselection dropdowns.
+// Called on each displayed object, should return whether this item is
+// checked or not.
+// - checkall()
+// Adds a "check all" button.
+// - uncheckall()
+// Adds an "uncheck all" button.
+//
+// To use a DS object as a selection dropdown:
+// m(DS.Button, {ds}, ...)
+// Or to autocomplete an input field:
+// m(DS.Input, {ds, ...})
+// Most of the above constructor options are ignored for autocompletion.
+class DS {
+ constructor(source, opts) {
+ this.width = 400;
+ this.input = '';
+ this.source = source;
+ if (source.opts) Object.assign(this, source.opts);
+ if (opts) Object.assign(this, opts);
+ this.open = this.open.bind(this);
+ this.list = [];
+ }
+
+ open(opener, autocomplete) {
+ if (activeInstance === this) return close();
+ setupObj();
+ activeInstance = this;
+ this.autocomplete = autocomplete;
+ this.opener = opener;
+ this.focus = v => { this.focus = null; v.dom.focus() };
+ document.addEventListener('click', close);
+ document.addEventListener('keydown', keydown);
+ document.addEventListener('scroll', position);
+ addEventListener('resize', position);
+ this.setInput(this.input);
+ }
+
+ select() {
+ const obj = this.list.find(e => e.id === this.selId);
+ if (!obj) return;
+ if (this.autocomplete) this.autocomplete(this.source.stringify ? this.source.stringify(obj) : obj.id);
+ else if (this.onselect) this.onselect(obj, !this.checked || !this.checked(obj));
+ if (!this.checked) {
+ close();
+ this.setInput('');
+ this.selId = null;
+ }
+ }
+
+ setSel(dir=1) {
+ let i = this.list.findIndex(e => e.id === this.selId) + dir;
+ for (; i >= 0 && i < this.list.length; i+=dir)
+ if (this.list[i]._props.selectable) {
+ this.selId = this.list[i].id;
+ return;
+ }
+ }
+
+ // Ignore the hover event for 200ms after calling this. In some cases a
+ // redraw/reselect is done that changes the positioning of the item
+ // currently under the cursor; that will fire an onmouseover event without
+ // it being the user's intent.
+ // The 200ms is a weird magic number that will not work reliably.
+ // This is an ugly hack, I'd rather see a better solution. :/
+ skipHover() {
+ this.doSkipHover = new Date();
+ }
+
+ keydown(ev) {
+ if (ev.key == 'ArrowDown') {
+ this.setSel();
+ this.skipHover();
+ ev.preventDefault();
+ } else if (ev.key == 'ArrowUp') {
+ this.setSel(-1);
+ this.skipHover();
+ ev.preventDefault();
+ } else if (ev.key == 'Escape' || ev.key == 'Esc') {
+ close();
+ } else if (ev.key == 'Tab') {
+ const f = this.list.find(e => e.id === this.selId);
+ ev.shiftKey || !f ? close() : this.select();
+ if (this.checked) close(); // Tab always closes, even on multiselection boxes
+ if (!this.autocomplete) ev.preventDefault();
+ }
+ }
+
+ setList(lst) {
+ this.list = [];
+ this.skipHover();
+ let hasSel = false;
+ for (const e of lst) {
+ e._props = this.props ? this.props(e) : {};
+ if (e._props === null) continue;
+ if (!('selectable' in e._props)) e._props.selectable = true;
+ this.list.push(e);
+ if (e.id === this.selId) hasSel = true;
+ }
+ if(!hasSel && (!this.autocomplete || this.input !== '')) this.setSel();
+ }
+
+ abort() {
+ clearTimeout(this.loadingTimer);
+ this.loadingStr = this.loadingTimer = null;
+ if (this.source.api) this.source.api.abort();
+ }
+
+ setInput(str_, skipTimer) {
+ this.input = str_;
+ if (activeInstance !== this) return;
+ const src = this.source;
+ const str = str_.trim();
+ if (src.init && src._initState !== 2) {
+ src._initState = 1;
+ src.init(src, () => {
+ src._initState = 2;
+ this.setInput(this.input);
+ });
+ return;
+ }
+ if (this.loadingStr === str && !skipTimer) return;
+ this.abort();
+ if (src.cache && src.cache[str]) {
+ this.setList(src.cache[str]);
+ return;
+ }
+ this.loadingStr = str;
+ if (src.api && !skipTimer) {
+ this.loadingTimer = setTimeout(() => { this.setInput(this.input, true); m.redraw() }, 500);
+ return;
+ }
+ src.list(src, str, res => {
+ this.loadingStr = null;
+ this.setList(res);
+ if (src.cache) src.cache[str] = res;
+ });
+ }
+
+ loading() {
+ return this.loadingTimer || (this.source.api && this.source.api.loading());
+ }
+
+ view() {
+ const item = e => {
+ const p = e._props;
+ return m('li', {
+ key: e.id,
+ class: this.selId === e.id ? 'active' : !p.selectable ? 'unselectable' : null,
+ onmouseover: p.selectable ? () => {
+ if (this.doSkipHover && ((new Date()).getTime()-this.doSkipHover.getTime()) < 200) return;
+ this.selId = e.id;
+ } : null,
+ onclick: p.selectable ? () => this.select(this.selId = e.id) : null,
+ }, m('span', p.selectable ? '» ' : 'x '),
+ this.checked ? [ m('input[type=checkbox]', { style: { visible: p.selectable ? 'visible' : 'hidden' }, checked: this.checked(e) }), ' ' ] : null,
+ this.source.view(e),
+ p.append,
+ );
+ };
+ return m('form#ds', {
+ onsubmit: ev => { ev.preventDefault(); this.select() },
+ onupdate: position,
+ oncreate: position,
+ }, m('div', this.nosearch || this.autocomplete ? [] : [
+ m('div',
+ m('input[type=text]', {
+ oncreate: this.focus, onupdate: this.focus,
+ value: this.input,
+ oninput: ev => this.setInput(ev.target.value),
+ placeholder: this.placeholder,
+ }),
+ m('span', {class: this.loading() ? 'spinner' : ''}, this.loading() ? null : m(Icon.Search)),
+ ),
+ this.checkall ? m('div', m(Button.CheckAll, { onclick: this.checkall })) : null,
+ this.uncheckall ? m('div', m(Button.UncheckAll, { onclick: this.uncheckall })) : null,
+ ]),
+ this.source.api && this.source.api.error
+ ? m('b', this.source.api.error)
+ : this.autocomplete && this.loading() ? m('span.spinner')
+ : !this.loading() && this.input.trim() !== '' && this.list.length == 0
+ ? m('em', 'No results')
+ : m('div', m('ul',
+ this.list.map(item),
+ this.more && this.input === '' ? m('li', m('small', 'Type for more options')) : null,
+ )),
+ );
+ }
+};
+
+
+DS.Button = {view: vnode => m('button.ds[type=button]', {
+ class: vnode.attrs.class,
+ onclick: ev => { ev.preventDefault(); vnode.attrs.onclick ? vnode.attrs.onclick(ev) : vnode.attrs.ds && vnode.attrs.ds.open(ev.target, null) },
+ }, vnode.children, m('span.invisible', 'X'), m(Icon.ChevronDown)
+)};
+
+
+// Wrapper around an Input component, accepts the same attrs as an Input in
+// addition to a 'ds' attribute to provide autocompletion.
+DS.Input = {view: vnode => {
+ const a = vnode.attrs;
+ const open = () => {
+ a.ds.setInput(a.data[a.field]);
+ a.ds.open(vnode.dom.childNodes[0], v => {
+ a.data[a.field] = v;
+ a.oninput && a.oninput(v);
+ });
+ };
+ return m('form.ds', {
+ onsubmit: ev => {
+ ev.preventDefault();
+ const par = ev.target.parentNode.closest('form');
+ if (activeInstance === a.ds) { activeInstance.select(); close(); }
+ // requestSubmit() is a fairly recent browser addition, need to test for it.
+ // Browsers without it will simply ignore the enter key, which is 'kay-ish as well.
+ else if (par && par.requestSubmit) par.requestSubmit();
+ } },
+ m(Input, {
+ ...a,
+ onfocus: ev => {
+ open();
+ a.ds.selId = null; // Don't select anything yet, we don't want tabbing in and out of the input to change anything
+ a.ds.setInput(''); // Pretend the input is empty, so we get the default listing when the input hasn't been modified
+ a.onfocus && a.onfocus(ev);
+ },
+ oninput: v => {
+ if (activeInstance !== a.ds) open();
+ a.ds.setInput(v);
+ a.oninput && a.oninput(v);
+ },
+ }),
+ );
+}};
+
+
+// Source interface:
+// - cache
+// Optional cache object, will be used to memoize calls to list()
+// - opts
+// Default DS constructor options.
+// - api
+// Optional Api object.
+// Used for a loading indicator & error reporting.
+// abort() is called whenever the input is changed.
+// If present, calls to list() will be delayed/throttled.
+// - init(source, callback)
+// Optional, called when the source is first used.
+// Should call callback() to signal that list() is ready to be used.
+// - list(source, str, callback)
+// Should run callback([objects]).
+// Each object must have a string 'id'
+// - view(obj)
+// Should return a vnode for the given object
+// - stringify(obj)
+// Should return a string representation of the given object.
+// Only used for autocompletion, defaults to obj.id.
+
+const tt_view = obj => [
+ obj.group_name ? m('small', obj.group_name, ' / ') : null,
+ obj.name,
+ obj.hidden && !obj.locked ? m('small', ' (awaiting approval)') : obj.hidden ? m('small', ' (deleted)') :
+ !obj.searchable && !obj.applicable ? m('small', ' (meta)') :
+ !obj.searchable ? m('small', ' (not searchable)') : !obj.applicable ? m('small', ' (not applicable)') : null
+];
+
+DS.Tags = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search tags...' },
+ api: new Api('Tags'),
+ list: (src, str, cb) => src.api.call({ search: str }, res => cb(res.results)),
+ view: tt_view,
+};
+
+DS.Traits = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search traits...' },
+ api: new Api('Traits'),
+ list: (src, str, cb) => src.api.call({ search: str }, res => cb(res.results)),
+ view: tt_view,
+};
+
+DS.VNs = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search visual novels...' },
+ api: new Api('VN'),
+ list: (src, str, cb) => src.api.call({ search: [str] }, res => cb(res.results)),
+ view: obj => [ m('small', obj.id, ': '), obj.title ],
+};
+
+DS.Producers = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search producers...' },
+ api: new Api('Producers'),
+ list: (src, str, cb) => src.api.call({ search: [str] }, res => cb(res.results)),
+ view: obj => [ m('small', obj.id, ': '), obj.name ],
+};
+
+DS.Engines = {
+ api: new Api('Engines'),
+ opts: { width: 250 },
+ init: (src, cb) => src.api.call({}, res => cb(src.res = res.results, src.api = null)),
+ list: (src, str, cb) => cb(src.res.filter(e => e.id.toLowerCase().includes(str.toLowerCase())).slice(0,30)),
+ view: obj => [ obj.id, m('small', ' ('+obj.count+')') ],
+};
+
+DS.DRM = {
+ api: new Api('DRM'),
+ opts: { width: 250 },
+ init: (src, cb) => src.api.call({}, res => cb(src.res = res.results, src.api = null)),
+ list: (src, str, cb) => cb(src.res.filter(e => e.id.toLowerCase().includes(str.toLowerCase())).slice(0,30)),
+ view: obj => [ obj.id, m('small', obj.state === 2 ? ' (deleted)' : ' ('+obj.count+')') ],
+};
+
+DS.Resolutions = {
+ api: new Api('Resolutions'),
+ opts: { width: 200 },
+ init: (src, cb) => src.api.call({}, res => cb(src.res = res.results, src.api = null)),
+ list: (src, str, cb) => cb(src.res.filter(e => e.id.toLowerCase().includes(str.toLowerCase())).slice(0,30)),
+ view: obj => [ obj.id, m('small', ' ('+obj.count+')') ],
+};
+
+const Lang = f => ({
+ opts: { width: 250, maxCols: 3 },
+ list: (src, str, cb) => cb(vndbTypes.language
+ .filter(([id,label]) => f(id) && (str === id.toLowerCase() || label.toLowerCase().includes(str.toLowerCase())))
+ .anySort(([id,label,,rank]) => [id.toLowerCase() !== str.toLowerCase(), !label.toLowerCase().startsWith(str.toLowerCase()), 99-rank])
+ .map(([id,label]) => ({id,label}))
+ ),
+ view: obj => [ LangIcon(obj.id), obj.label ]
+});
+
+DS.Lang = Lang(() => true);
+// Chinese has separate language entries for the scripts
+DS.ScriptLang = Lang(l => l !== 'zh');
+DS.LocLang = Lang(l => l !== 'zh-Hans' && l !== 'zh-Hant');
+
+DS.Platforms = {
+ opts: { width: 250, maxCols: 3 },
+ list: (src, str, cb) => cb(vndbTypes.platform
+ .filter(([id,label]) => str.toLowerCase() === id.toLowerCase() || label.toLowerCase().includes(str.toLowerCase()))
+ .anySort(([id,label]) => str ? [id.toLowerCase() !== str.toLowerCase(), !label.toLowerCase().startsWith(str.toLowerCase()), label] : 0)
+ .map(([id,label]) => ({id,label}))
+ ),
+ view: obj => [ PlatIcon(obj.id), obj.label ]
+};
+
+
+// Wrap a source to add a "Create new entry" option.
+// Args:
+// - source
+// - createobj: (str) => obj, should return an obj to add an option or null to not add anything.
+// - view: obj => html
+DS.New = (src, createobj, view) => ({...src,
+ list: (x, str, cb) => src.list(x, str, lst => {
+ const obj = createobj(str);
+ if (obj && !lst.find(o => o.id === obj.id)) lst.unshift({...obj, _create:true});
+ cb(lst);
+ }),
+ view: obj => obj._create === true ? view(obj) : src.view(obj),
+});
+
+window.DS = DS;
diff --git a/js/basic/elm-support.js b/js/basic/elm-support.js
new file mode 100644
index 00000000..1f78f2f9
--- /dev/null
+++ b/js/basic/elm-support.js
@@ -0,0 +1,112 @@
+if(!pageVars.elm) return;
+
+// See Lib/Ffi.elm
+window.elmFfi_innerHtml = (wrap) => s => ({$: 'a2', n: 'innerHTML', o: wrap(s)});
+window.elmFfi_elemCall = (wrap,call) => call;
+window.elmFfi_fmtFloat = () => val => prec => val.toLocaleString('en-US', { minimumFractionDigits: prec, maximumFractionDigits: prec });
+
+const url_static = $('link[rel=stylesheet]').href.replace(/^(https?:\/\/[^/]+)\/.*$/, '$1');
+window.elmFfi_urlStatic = () => url_static;
+
+
+
+var preload_urls = {};
+
+const ports = Object.entries({
+ // ImageFlagging
+ preload: () => url => {
+ if(Object.keys(preload_urls).length > 100)
+ preload_urls = {};
+ if(!preload_urls[url]) {
+ preload_urls[url] = new Image();
+ preload_urls[url].src = url;
+ }
+ },
+
+ // UList.LabelEdit
+ ulistLabelChanged: flags => pub => {
+ const l = $('#ulist_public_'+flags.vid);
+ if (l) {
+ l.setAttribute('data-publabel', pub?1:'');
+ l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
+ }
+ },
+
+ // UList.Opt
+ ulistVNDeleted: flags => b => {
+ const e = $('#ulist_tr_'+flags.vid);
+ e.parentNode.removeChild(e.nextElementSibling);
+ e.parentNode.removeChild(e);
+
+ // Have to restripe after deletion :(
+ const rows = $$('.ulist > table > tbody > tr');
+ for(var i=0; i<rows.length; i++)
+ rows[i].classList.toggle('odd', Math.floor(i/2) % 2 == 0);
+ },
+
+ ulistNotesChanged: flags => n => {
+ $('#ulist_notes_'+flags.vid).innerText = n;
+ },
+
+ ulistRelChanged: flags => rels => {
+ const e = $('#ulist_relsum_'+flags.vid);
+ e.classList.toggle('todo', rels[0] != rels[1]);
+ e.classList.toggle('done', rels[1] > 0 && rels[0] == rels[1]);
+ e.innerText = rels[0] + '/' + rels[1];
+ },
+
+ // UList.VoteEdit
+ ulistVoteChanged: flags => voted => {
+ const l = $('#ulist_public_'+flags.vid);
+ if (l) {
+ l.setAttribute('data-voted', voted?1:'');
+ l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
+ }
+ },
+
+ // VNEdit
+ ivRefresh: () => () => setTimeout(ivInit, 10),
+});
+
+
+// Some modules need a wrapper around their init() method.
+const wrap = {
+ ImageFlagging: (init, opt) => {
+ opt.flags.pWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
+ opt.flags.pHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
+ init(opt);
+ },
+
+ 'UList.LabelEdit': (init, opt) => {
+ opt.flags.uid = pageVars.uid;
+ opt.flags.labels = pageVars.labels;
+ init(opt);
+ },
+
+ 'UList.ManageLabels': (init, opt) => {
+ opt.flags = { uid: pageVars.uid, labels: pageVars.labels };
+ init(opt);
+ },
+
+ // This module is typically hidden, lazily load it only when the module is visible to speed up page load time.
+ 'UList.Opt': (init, opt) => {
+ const e = $('#collapse_vid'+opt.flags.vid);
+ if(e.checked) init(opt);
+ else e.addEventListener('click', () => init(opt), { once: true });
+ },
+}
+
+
+pageVars.elm.forEach((e,i) => {
+ const mod = e[0].split('.').reduce((p, c) => p[c], window.Elm);
+ const node = $('#elm'+i);
+ var opt = { node };
+ if (e.length > 1) opt.flags = e[1];
+ const init = o => {
+ var app = mod.init(o);
+ ports.forEach(([port, callback]) => {
+ if (app.ports[port]) app.ports[port].subscribe(callback(opt.flags));
+ });
+ };
+ wrap[e[0]] ? wrap[e[0]](init, opt) : init(opt)
+});
diff --git a/js/basic/index.js b/js/basic/index.js
new file mode 100644
index 00000000..5f599100
--- /dev/null
+++ b/js/basic/index.js
@@ -0,0 +1,55 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only
+// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/js
+// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt Expat
+// @source: https://code.blicky.net/yorhel/mithril-vndb
+// SPDX-License-Identifier: AGPL-3.0-only AND Expat
+
+// ^ LibreJS browser plugin only recognizes the first license tag in the file,
+// so it's kind of incorrect. Their spec doesn't appear to support bundling.
+
+"use strict";
+
+// Log errors to the server. This intentionally uses old-ish syntax and APIs.
+// (though it still won't catch parsing/syntax errors in this bundle...)
+window.onerror = function(ev, source, lineno, colno, error) {
+ if (/\/g\/[a-z]+\.js/.test(source)
+ // No clue what's up with these, sometimes happens in FF. Is Elm being initialized before the DOM is ready or something?
+ && !(/elm\.js/.test(source) && /InvalidStateError/.test(ev))
+ ) {
+ var h = new XMLHttpRequest();
+ var e = encodeURIComponent;
+ h.open('POST', '/js-error?2', true);
+ h.send('ev='+e(ev)+'&source='+e(source)+'&lineno='+e(lineno)+'&colno='+e(colno)+'&stack='+e(error.stack));
+ window.onerror = null; // One error per page is enough
+ }
+ return false;
+};
+
+@include .gen/mithril.js
+@include .gen/types.js
+@include polyfills.js
+
+// Library stuff
+@include utils.js
+@include api.js
+@include components.js
+@include ds.js
+
+// A bunch of old fashioned DOM manipulation features.
+@include checkall.js
+@include checkhidden.js
+@include mainbox-summarize.js
+@include searchtabs.js
+@include sethash.js
+@include ulist-actiontabs.js
+@include ulist-labelfilters.js
+
+@include elm-support.js
+
+// Widgets
+@include TableOpts.js
+
+// Image viewer; after loading Elm modules to ensure it sees the screenshots in VNEdit.
+@include iv.js
+
+// @license-end
diff --git a/elm/iv.js b/js/basic/iv.js
index 06bb6f5a..772a4f42 100644
--- a/elm/iv.js
+++ b/js/basic/iv.js
@@ -1,4 +1,3 @@
-//order:8 - After all regular JS, as other files may modify pageVars or modules in the Elm.* namespace.
/* Simple image viewer widget. Usage:
*
* <a href="full_image.jpg" data-iv="{width}x{height}:{category}:{flagging}">..</a>
@@ -77,7 +76,7 @@ function create_div() {
ivflag = document.createElement('a');
ivlinks.appendChild(ivflag);
- document.querySelector('body').appendChild(ivparent);
+ $('body').appendChild(ivparent);
}
@@ -163,7 +162,7 @@ function show(ev) {
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 = '/img/'+imgid[1]+imgid[2];
+ 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] + ')';
@@ -205,8 +204,8 @@ window.ivClose = function(ev) {
window.ivInit = function() {
cats = {};
- document.querySelectorAll('a[data-iv]').forEach(function(o) {
- if(o == ivnext || o == ivprev)
+ $$('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];
diff --git a/elm/mainbox-summarize.js b/js/basic/mainbox-summarize.js
index 5f940ed5..d6ece92d 100644
--- a/elm/mainbox-summarize.js
+++ b/js/basic/mainbox-summarize.js
@@ -3,31 +3,29 @@
//
// Usage:
//
-// <div class="mainbox" data-mainbox-summarize="200"> .. </div>
+// <article data-mainbox-summarize="200"> .. </div>
-function set(d, h) {
- var expanded = true;
- var a = document.createElement('a');
+const set = (d, h) => {
+ let expanded = true;
+ const a = document.createElement('a');
a.href = '#';
-
- var toggle = function() {
+ 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 ⇓';
- return false;
};
- a.onclick = toggle;
- var t = document.createElement('div');
+ const t = document.createElement('div');
t.className = 'summarize_more';
t.appendChild(a);
d.parentNode.insertBefore(t, d.nextSibling);
- toggle();
-}
+ a.click();
+};
-document.querySelectorAll('.mainbox[data-mainbox-summarize]').forEach(function(d) {
- var h = Math.floor(d.getAttribute('data-mainbox-summarize'));
+$$('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/elm/sethash.js b/js/basic/sethash.js
index 7b054d0b..8a3a8ec8 100644
--- a/elm/sethash.js
+++ b/js/basic/sethash.js
@@ -1,6 +1,6 @@
// Emulate setting a location.hash if none has been set.
if(pageVars.sethash && location.hash.length <= 1) {
- var e = document.getElementById(pageVars.sethash);
+ 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/TagEdit.js b/js/contrib/TagEdit.js
new file mode 100644
index 00000000..ad1b706f
--- /dev/null
+++ b/js/contrib/TagEdit.js
@@ -0,0 +1,121 @@
+widget('TagEdit', vnode => {
+ const data = vnode.attrs.data;
+ const api = new Api('TagEdit');
+ var dups = [];
+
+ const dsProps = obj =>
+ obj.id === data.id ? { selectable: false, append: m('small', ' (this tag)') } :
+ data.parents.find(x => x.parent === obj.id) ? { selectable: false, append: m('small', ' (already listed)') } : {};
+
+ const parentDS = new DS(DS.Tags, {
+ onselect: obj => data.parents.push({parent: obj.id, name: obj.name, main: data.parents.length === 0}),
+ props: dsProps,
+ });
+
+ const mergeDS = new DS(DS.Tags, {
+ onselect: obj => data.merge.push(obj),
+ props: dsProps,
+ });
+
+ const names = () => m('fieldset.form',
+ m('fieldset',
+ m('label[for=name]', 'Primary name'),
+ m(Input, { class: 'lw', id: 'name', data, field: 'name', required: true, maxlength: 250 }),
+ ),
+ m('fieldset',
+ m('label[for=alias]', 'Aliases'),
+ m(Input, { class: 'lw', rows: 5, type: 'textarea', id: 'alias', data, field: 'alias', maxlength: 1024 }),
+ m('p', 'Tag name and aliases must be unique and self-describing.'),
+ (l => l.length === 0 ? '' : m('p.invalid',
+ 'The following tag names are already in the database:',
+ l.map(d => [m('br'), m('a[target=_blank]', {href: '/'+d.id}, d.name)]),
+ ))([data.name].concat(data.alias.split("\n")).flatMap(s => dups.filter(d => d.name.toLowerCase() === s.trim().toLowerCase())))
+ ),
+ );
+
+ const properties = () => m('fieldset.form',
+ m('fieldset', m('label.check',
+ m('input[type=checkbox]', { checked: data.searchable, oninput: e => data.searchable = e.target.checked }),
+ ' Searchable (people can use this tag to find VNs)',
+ )),
+ m('fieldset', m('label.check',
+ m('input[type=checkbox]', { checked: data.applicable, oninput: e => data.applicable = e.target.checked }),
+ ' Applicable (people can apply this tag to VNs)',
+ )),
+ m('fieldset',
+ m('label[for=cat]', 'Category'),
+ m(Select, { class: 'mw', id: 'cat', data, field: 'cat', options: vndbTypes.tagCategory }),
+ ),
+ m('fieldset',
+ m('label[for=defaultspoil]', 'Default spoiler level'),
+ m(Select, { class: 'mw', id: 'defaultspoil', data, field: 'defaultspoil', options: [
+ [0, 'No spoiler'],
+ [1, 'Minor spoiler'],
+ [2, 'Major spoiler'],
+ ]}),
+ ),
+ m('fieldset',
+ m('label[for=description]', 'Description'),
+ m(TextPreview, {
+ data, field: 'description',
+ attrs: { id: 'description', required: true, rows: 12, maxlength: 10240 },
+ }),
+ m('p', 'What should the tag be used for? Having a good description helps users choose which tags to link to a VN.'),
+ ),
+ );
+
+ const parents = () => m('fieldset.form', m('fieldset',
+ m('label', 'Parent tags'),
+ data.parents.length === 0
+ ? m('p', 'No parent tags selected, which makes this a top-level tag.')
+ : m('table', data.parents.map(g => m('tr', {key: g.parent},
+ m('td', m(Button.Del, { onclick: () => {
+ data.parents = data.parents.filter(x => x !== g);
+ if (data.parents.length > 0 && !data.parents.find(x => x.main))
+ data.parents[0].main = true;
+ }})),
+ m('td', m('small', g.parent, ': '), m('a[target=_blank]', { href: '/'+g.parent }, g.name)),
+ m('td', m('label',
+ m('input[type=radio]', { checked: g.main, onclick: () => data.parents.forEach(x => x.main = x === g) }),
+ ' primary'
+ )),
+ ))),
+ m(DS.Button, { ds: parentDS, class: 'mw' }, 'Add parent tag'),
+ ));
+
+ const danger = () => [
+ m('fieldset.form',
+ m('legend', 'DANGER ZONE'),
+ m('p', 'The options below affect tag votes and are therefore not visible in the edit history.'),
+ m('p', 'Your edit summary is not visible anywhere unless you also changed something in the above fields.'),
+ ),
+ m('fieldset.form', m('fieldset',
+ m('input[type=checkbox]', { checked: data.wipevotes, onclick: e => data.wipevotes = e.target.checked }),
+ ' Delete all direct votes on this tag. WARNING: cannot be undone!',
+ m('br'),
+ m('small', 'Does not affect votes on child tags. Old votes may still show up for 24 hours due to database caching.'),
+ )),
+ m('fieldset.form', m('fieldset',
+ m('label', 'Merge votes'),
+ m('p', 'All direct votes on the listed tags will be moved to this tag. WARNING: cannot be undone!'),
+ data.merge.length === 0 ? null : m('table', data.merge.map(g => m('tr', {key: g.id},
+ m('td', m(Button.Del, { onclick: () => data.merge = data.merge.filter(x => x !== g)})),
+ m('td', m('small', g.id, ': '), m('a[target=_blank]', { href: '/'+g.id }, g.name)),
+ ))),
+ m(DS.Button, { ds: mergeDS, class: 'mw' }, 'Add tag to merge'),
+ )),
+ ];
+
+ const onsubmit = () => api.call(data, r => dups = r.dups);
+ const view = () => m(Form, {api,onsubmit},
+ m('article',
+ m('h1', data.id ? 'Edit tag: '+data.name : 'Submit new tag'),
+ names(),
+ properties(),
+ parents(),
+ data.id && data.authmod ? danger() : null,
+ ),
+ m(EditSum, {data,api, approval: data.authmod }),
+ );
+ return {view};
+});
diff --git a/js/contrib/index.js b/js/contrib/index.js
new file mode 100644
index 00000000..8f6ffcf4
--- /dev/null
+++ b/js/contrib/index.js
@@ -0,0 +1,137 @@
+// @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
+// - approval -> null for entries that don't require approval, otherwise a boolean indicating mod status
+//
+// TODO: Better feedback on pointless edit summaries like "-", "..", etc
+const EditSum = vnode => {
+ let {api,data,approval} = 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
+ );
+ if (typeof approval !== 'boolean') approval = null;
+ const mod = approval === null ? pageVars.dbmod : approval;
+ const view = () => m('article.submit',
+ mod ? m('fieldset',
+ rad(false, false, ' Normal '),
+ rad(true , false, ' Locked '),
+ rad(true , true , ' Deleted '),
+ approval === null ? null : rad(false, true, ' Awaiting approval '),
+ 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 TagEdit.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 96ed9c65..8b9dfdbb 100644
--- a/lib/Multi/API.pm
+++ b/lib/Multi/API.pm
@@ -15,11 +15,12 @@ use POE::Filter::VNDBAPI 'encode_filters';
use Encode 'encode_utf8', 'decode_utf8';
use Crypt::URandom 'urandom';
use Crypt::ScryptKDF 'scrypt_raw';;
-use VNDB::Func 'imgurl', 'normalize_query', 'norm_ip', 'resolution';
+use VNDB::Func 'imgurl', 'imgsize', 'norm_ip', 'resolution', 'is_insecurepass';
use VNDB::Types;
use VNDB::Config;
use JSON::XS;
-use PWLookup;
+use List::Util 'min', 'max';
+use VNDB::ExtLinks 'sql_extlinks';
# Linux-specific, not exported by the Socket module.
sub TCP_KEEPIDLE () { 4 }
@@ -146,7 +147,8 @@ sub cres {
writelog $c, '[%2d/%4.0fms %5.0f] %s',
$c->{sqlq}, $c->{sqlt}*1000, length($msg),
@arg ? sprintf $log, @arg : $log;
- cmd_read($c);
+ if($c->{disconnect}) { $c->{h}->push_shutdown() }
+ else { cmd_read($c); }
}
@@ -229,6 +231,16 @@ sub cmd_handle {
return login($c, @arg) if $cmd eq 'login';
return cerr $c, needlogin => 'Not logged in.' if !$c->{client};
+ # logout
+ if($cmd eq 'logout') {
+ return cerr $c, parse => 'Too many arguments to logout command' if @arg > 0;
+ return cerr $c, needlogin => 'No session token associated with this connection' if !$c->{sessiontoken};
+ return pg_cmd 'SELECT user_logout($1, decode($2, \'hex\'))', [ $c->{uid}, $c->{sessiontoken} ], sub {
+ $c->{disconnect} = 1;
+ cres $c, ['ok'], 'Logged out, session invalidated';
+ }
+ }
+
# dbstats
if($cmd eq 'dbstats') {
return cerr $c, parse => 'Too many arguments to dbstats command' if @arg > 0;
@@ -260,42 +272,64 @@ sub login {
!exists $arg->{$_} && return cerr $c, missing => "Required field '$_' is missing", field => $_
for(qw|protocol client clientver|);
- for(qw|protocol client clientver username password|) {
+ for(qw|protocol client clientver username password sessiontoken|) {
exists $arg->{$_} && !defined $arg->{$_} && return cerr $c, badarg => "Field '$_' cannot be null", field => $_;
exists $arg->{$_} && ref $arg->{$_} && return cerr $c, badarg => "Field '$_' must be a scalar", field => $_;
}
return cerr $c, badarg => 'Unknown protocol version', field => 'protocol' if $arg->{protocol} ne '1';
- return cerr $c, badarg => 'The fields "username" and "password" must either both be present or both be missing.', field => 'username'
- if exists $arg->{username} && !exists $arg->{password} || exists $arg->{password} && !exists $arg->{username};
return cerr $c, badarg => 'Invalid client name', field => 'client' if $arg->{client} !~ /^[a-zA-Z0-9 _-]{3,50}$/;
return cerr $c, badarg => 'Invalid client version', field => 'clientver' if $arg->{clientver} !~ /^[a-zA-Z0-9_.\/-]{1,25}$/;
+ return cerr $c, badarg => '"createsession" can only be used when logging in with a password.' if !exists $arg->{password} && exists $arg->{createsession};
+ return cerr $c, badarg => 'Missing "username" field.', field => 'username' if !exists $arg->{username} && (exists $arg->{password} || exists $arg->{sessiontoken});
+
if(!exists $arg->{username}) {
$c->{client} = $arg->{client};
$c->{clientver} = $arg->{clientver};
cres $c, ['ok'], 'Login using client "%s" ver. %s', $c->{client}, $c->{clientver};
- return;
- } else {
- $arg->{username} = lc $arg->{username};
+
+ } elsif(exists $arg->{password}) {
return cerr $c, auth => "Password too weak, please log in on the site and change your password"
- if config->{password_db} && PWLookup::lookup(config->{password_db}, $arg->{password});
- }
+ if is_insecurepass($arg->{password});
+ login_auth($c, $arg);
+
+ } elsif(exists $arg->{sessiontoken}) {
+ return cerr $c, badarg => 'Invalid session token', field => 'sessiontoken' if $arg->{sessiontoken} !~ /^[a-fA-F0-9]{40}$/;
+ cpg $c,
+ 'SELECT u.id, u.username FROM users u JOIN users_shadow us ON us.id = u.id
+ WHERE lower(u.username) = lower($1) AND us.delete_at IS NULL AND user_validate_session(u.id, decode($2, \'hex\'), \'api\') IS DISTINCT FROM NULL',
+ [ $arg->{username}, $arg->{sessiontoken} ], sub {
+ if($_[0]->nRows == 1) {
+ $c->{uid} = $_[0]->value(0,0);
+ $c->{username} = $_[0]->value(0,1);
+ $c->{client} = $arg->{client};
+ $c->{clientver} = $arg->{clientver};
+ $c->{sessiontoken} = $arg->{sessiontoken};
+ cres $c, ['ok'], 'Successful login with session by %s (%s) using client "%s" ver. %s', $c->{username}, $c->{uid}, $c->{client}, $c->{clientver};
+ } else {
+ cerr $c, auth => "Wrong session token for user '$arg->{username}'";
+ }
+ };
- login_auth($c, $arg);
+ } else {
+ return cerr $c, badarg => 'Missing "password" or "sessiontoken" field.';
+ }
}
sub login_auth {
my($c, $arg) = @_;
- # check login throttle
+ # check login throttle (also used when logging in with a session... oh well)
cpg $c, 'SELECT extract(\'epoch\' from timeout) FROM login_throttle WHERE ip = $1', [ norm_ip($c->{ip}) ], sub {
my $tm = $_[0]->nRows ? $_[0]->value(0,0) : AE::time;
return cerr $c, auth => "Too many failed login attempts"
if $tm-AE::time() > config->{login_throttle}[1];
# Fetch user info
- cpg $c, 'SELECT id, encode(user_getscryptargs(id), \'hex\') FROM users WHERE username = $1', [ $arg->{username} ], sub {
+ cpg $c, '
+ SELECT u.id, u.username, encode(user_getscryptargs(u.id), \'hex\') FROM users u JOIN users_shadow us ON us.id = u.id
+ WHERE us.delete_at IS NULL AND lower(u.username) = lower($1)', [ $arg->{username} ], sub {
login_verify($c, $arg, $tm, $_[0]);
};
};
@@ -307,26 +341,32 @@ sub login_verify {
return cerr $c, auth => "No user with the name '$arg->{username}'" if $res->nRows == 0;
my $uid = $res->value(0,0);
- my $sargs = $res->value(0,1);
+ my $username = $res->value(0,1);
+ my $sargs = $res->value(0,2);
return cerr $c, auth => "Account disabled" if !$sargs || length($sargs) != 14*2;
- my $token = urandom(20);
+ my $token = unpack 'H*', urandom(20);
my($N, $r, $p, $salt) = unpack 'NCCa8', pack 'H*', $sargs;
my $passwd = pack 'NCCa8a*', $N, $r, $p, $salt, scrypt_raw(encode_utf8($arg->{password}), config->{scrypt_salt} . $salt, $N, $r, $p, 32);
- cpg $c, 'SELECT user_login($1, decode($2, \'hex\'), decode($3, \'hex\'))', [ $uid, unpack('H*', $passwd), unpack('H*', $token) ], sub {
+ cpg $c, 'SELECT user_login($1, \'api\', decode($2, \'hex\'), decode($3, \'hex\'))', [ $uid, unpack('H*', $passwd), $token ], sub {
if($_[0]->nRows == 1 && ($_[0]->value(0,0)||'') =~ /t/) {
$c->{uid} = $uid;
- $c->{username} = $arg->{username};
+ $c->{username} = $username;
$c->{client} = $arg->{client};
$c->{clientver} = $arg->{clientver};
- pg_cmd 'SELECT user_logout($1, decode($2, \'hex\'))', [ $uid, unpack('H*', $token) ];
- cres $c, ['ok'], 'Successful login by %s (%s) using client "%s" ver. %s', $arg->{username}, $c->{uid}, $c->{client}, $c->{clientver};
+ if($arg->{createsession}) {
+ $c->{sessiontoken} = $token;
+ cres $c, ['session', $token], 'Successful login with password+session by %s (%s) using client "%s" ver. %s', $username, $c->{uid}, $c->{client}, $c->{clientver};
+ } else {
+ pg_cmd 'SELECT user_logout($1, decode($2, \'hex\'))', [ $uid, $token ];
+ cres $c, ['ok'], 'Successful login with password by %s (%s) using client "%s" ver. %s', $username, $c->{uid}, $c->{client}, $c->{clientver};
+ }
} else {
my @a = ( $tm + config->{login_throttle}[0], norm_ip($c->{ip}) );
pg_cmd 'UPDATE login_throttle SET timeout = to_timestamp($1) WHERE ip = $2', \@a;
pg_cmd 'INSERT INTO login_throttle (ip, timeout) SELECT $2, to_timestamp($1) WHERE NOT EXISTS(SELECT 1 FROM login_throttle WHERE ip = $2)', \@a;
- cerr $c, auth => "Wrong password for user '$arg->{username}'";
+ cerr $c, auth => "Wrong password for user '$username'";
}
};
}
@@ -382,8 +422,8 @@ sub image_flagging {
violence_avg => delete $obj->{c_violence_avg},
};
$flag->{votecount} *= 1 if defined $flag->{votecount};
- $flag->{sexual_avg} *= 1 if defined $flag->{sexual_avg};
- $flag->{violence_avg} *= 1 if defined $flag->{violence_avg};
+ $flag->{sexual_avg} /= 100 if defined $flag->{sexual_avg};
+ $flag->{violence_avg} /= 100 if defined $flag->{violence_avg};
$image ? $flag : undef;
}
@@ -409,7 +449,7 @@ sub image_flagging {
# }
# filters => filters args for get_filters() (TODO: Document)
my %GET_VN = (
- sql => 'SELECT %s FROM vn v LEFT JOIN images i ON i.id = v.image 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} = idnum $_[0]{id};
@@ -417,15 +457,15 @@ my %GET_VN = (
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.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};
@@ -435,11 +475,14 @@ my %GET_VN = (
},
},
details => {
- select => 'v.image, i.c_sexual_avg, i.c_violence_avg, i.c_votecount, v.alias AS aliases, v.length, v.desc AS description, v.l_wp, v.l_encubed, v.l_renai, l_wikidata',
+ select => 'v.image, i.c_sexual_avg, i.c_violence_avg, i.c_votecount, i.width AS image_width, i.height AS image_height, v.alias AS aliases,
+ v.length, v.c_length AS length_minutes, v.c_lengthnum AS length_votes, v.description, v.l_wp, v.l_encubed, v.l_renai, l_wikidata',
proc => sub {
$_[0]{aliases} ||= undef;
$_[0]{length} *= 1;
$_[0]{length} ||= undef;
+ $_[0]{length_votes}*= 1;
+ $_[0]{length_minutes}*=1 if defined $_[0]{length_minutes};
$_[0]{description} ||= undef;
$_[0]{links} = {
wikipedia => delete($_[0]{l_wp}) ||undef,
@@ -448,18 +491,33 @@ my %GET_VN = (
wikidata => formatwd(delete $_[0]{l_wikidata}),
};
$_[0]{image} = $_[0]{image} ? imgurl $_[0]{image} : undef;
- $_[0]{image_nsfw} = !$_[0]{image} ? FALSE : !$_[0]{c_votecount} || $_[0]{c_sexual_avg} > 0.4 || $_[0]{c_violence_avg} > 0.4 ? TRUE : FALSE;
+ $_[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 as 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]{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)',
@@ -479,8 +537,8 @@ 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} eq $_->{vid}, @$n ];
@@ -502,7 +560,7 @@ my %GET_VN = (
sub { my($r, $n) = @_;
for my $i (@$r) {
$i->{tags} = [ map
- [ $_->{id}*1, 1*sprintf('%.2f', $_->{score}), 1*sprintf('%.0f', $_->{spoiler}) ],
+ [ idnum($_->{id}), 1*sprintf('%.2f', $_->{score}), 1*sprintf('%.0f', $_->{spoiler}) ],
grep $i->{id} eq $_->{vid}, @$n ];
}
},
@@ -516,11 +574,14 @@ my %GET_VN = (
$i->{screens} = [ grep $i->{id} eq $_->{vid}, @$n ];
}
for (@$n) {
+ $_->{id} = $_->{scr};
+ $_->{thumbnail} = imgurl($_->{scr}, 't');
$_->{image} = imgurl delete $_->{scr};
$_->{rid} = idnum $_->{rid};
- $_->{nsfw} = !$_->{c_votecount} || $_->{c_sexual_avg} > 0.4 || $_->{c_violence_avg} > 0.4 ? TRUE : FALSE;
+ $_->{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};
}
@@ -528,9 +589,8 @@ my %GET_VN = (
]]
},
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} eq $_->{id}, @$n ];
@@ -538,7 +598,7 @@ my %GET_VN = (
for (@$n) {
$_->{aid} *= 1;
$_->{sid} = idnum $_->{sid};
- $_->{original} ||= undef;
+ $_->{original} = undef if $_->{original} eq $_->{name};
$_->{note} ||= undef;
delete $_->{id};
}
@@ -552,17 +612,17 @@ my %GET_VN = (
[ 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|= = != <>|} ],
@@ -583,44 +643,49 @@ my %GET_VN = (
[ 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} = 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} 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 => {
@@ -661,8 +726,23 @@ my %GET_RELEASE = (
} ],
]
},
+ lang => {
+ fetch => [[ 'id', 'SELECT rt.id, rt.lang, rt.title, rt.latin, rt.mtl, rt.lang = r.olang AS main
+ FROM releases_titles rt JOIN releases r ON r.id = rt.id WHERE rt.id IN(%s)',
+ sub { my($r, $n) = @_;
+ for my $i (@$r) {
+ $i->{lang} = [ grep $i->{id} eq $_->{id}, @$n ];
+ }
+ for (@$n) {
+ delete $_->{id};
+ $_->{mtl} = $_->{mtl} =~ /t/ ? TRUE : FALSE,
+ $_->{main} = $_->{main} =~ /t/ ? TRUE : FALSE,
+ }
+ }
+ ]],
+ },
vn => {
- fetch => [[ 'id', 'SELECT rv.id AS rid, v.id, v.title, v.original FROM releases_vn rv JOIN vn v ON v.id = rv.vid
+ fetch => [[ 'id', 'SELECT rv.id AS rid, rv.rtype, v.id, v.title[2], v.title[4] AS original FROM releases_vn rv JOIN vnt v ON v.id = rv.vid
WHERE NOT v.hidden AND rv.id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
@@ -677,22 +757,37 @@ 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} eq $_->{rid}, @$r ];
}
for (@$r) {
$_->{id} = idnum $_->{id};
- $_->{original} ||= undef;
+ $_->{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 => [
@@ -707,13 +802,13 @@ my %GET_RELEASE = (
[ '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|= = != <>|} ],
@@ -723,7 +818,7 @@ my %GET_RELEASE = (
freeware => [ [ bool => 'r.freeware = :value:', {'=',1} ] ],
doujin => [ [ bool => 'r.doujin = :value:', {'=',1} ] ],
type => [
- [ str => 'r.type :op: :value:', {qw|= = != <>|},
+ [ str => 'r.id :op:(SELECT rv.id FROM releases_vn rv WHERE rv.rtype = :value:)', {'=' => 'IN', '!=' => 'NOT IN'},
process => sub { !$RELEASE_TYPE{$_[0]} ? \'No such release type' : $_[0] } ],
],
gtin => [
@@ -733,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' ],
@@ -744,7 +839,7 @@ 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} = idnum $_[0]{id}
@@ -752,17 +847,17 @@ my %GET_PRODUCER = (
sortdef => 'id',
sorts => {
id => 'p.id %s',
- name => 'p.name %s',
+ name => 'p.name %s, p.id',
},
flags => {
basic => {
- select => 'p.type, p.name, p.original, p.lang AS language',
+ select => 'p.type, p.title[2] AS name, p.title[4] AS original, p.lang AS language',
proc => sub {
- $_[0]{original} ||= undef;
+ $_[0]{original} = undef if $_[0]{name} eq $_[0]{original};
},
},
details => {
- select => 'p.website, p.l_wp, p.l_wikidata, p.desc AS description, p.alias AS aliases',
+ select => 'p.website, p.l_wp, p.l_wikidata, p.description, p.alias AS aliases',
proc => sub {
$_[0]{description} ||= undef;
$_[0]{aliases} ||= undef;
@@ -774,15 +869,15 @@ my %GET_PRODUCER = (
},
},
relations => {
- fetch => [[ 'id', 'SELECT pl.id AS pid, p.id, pl.relation, p.name, p.original FROM producers_relations pl
- JOIN producers p ON p.id = pl.pid WHERE pl.id IN(%s)',
+ fetch => [[ 'id', 'SELECT pl.id AS pid, p.id, pl.relation, p.title[2] AS name, p.title[4] AS original FROM producers_relations pl
+ JOIN producerst p ON p.id = pl.pid WHERE pl.id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
$i->{relations} = [ grep $i->{id} eq $_->{pid}, @$r ];
}
for (@$r) {
$_->{id} = idnum $_->{id};
- $_->{original} ||= undef;
+ $_->{original} = undef if $_->{name} eq $_->{original};
delete $_->{pid};
}
},
@@ -795,13 +890,13 @@ my %GET_PRODUCER = (
[ 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|= = != <>|},
@@ -812,13 +907,13 @@ my %GET_PRODUCER = (
[ stra => 'p.lang :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'lang' ],
],
search => [
- [ str => '(p.name ILIKE :value: OR p.original ILIKE :value: OR p.alias ILIKE :value:)', {'~',1}, process => \'like' ],
+ [ str => 'EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = p.id AND sc.label LIKE ALL (search_query(:value:)))', {'~',1} ],
],
},
);
my %GET_CHARACTER = (
- sql => 'SELECT %s FROM chars c LEFT JOIN images i ON i.id = c.image 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} = idnum $_[0]{id};
@@ -826,25 +921,27 @@ my %GET_CHARACTER = (
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.spoil_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, i.c_sexual_avg, i.c_violence_avg, i.c_votecount, c."desc" AS description, c.age',
+ 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]{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};
},
},
@@ -859,7 +956,7 @@ my %GET_CHARACTER = (
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} eq $_->{id}, @$r ];
+ $i->{traits} = [ map [ idnum($_->{tid}), $_->{spoil}*1 ], grep $i->{id} eq $_->{id}, @$r ];
}
},
]],
@@ -892,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} eq $_->{cid} && $_->{id} ne $i->{id}, @$r ];
}
for (@$r) {
$_->{id} = idnum $_->{id};
- $_->{original} ||= undef;
+ $_->{original} = undef if $_->{original} eq $_->{name};
$_->{spoiler}*=1;
delete $_->{cid};
}
@@ -914,31 +1011,31 @@ my %GET_CHARACTER = (
[ 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}, 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} = idnum $_[0]{id};
@@ -949,14 +1046,14 @@ my %GET_STAFF = (
},
flags => {
basic => {
- select => 'sa.name, sa.original, s.gender, s.lang AS language',
+ select => 's.title[2] AS name, s.title[4] AS original, s.gender, s.lang AS language',
proc => sub {
- $_[0]{original} ||= undef;
+ $_[0]{original} = undef if $_[0]{original} eq $_[0]{name};
$_[0]{gender} = undef if $_[0]{gender} eq 'unknown';
},
},
details => {
- select => 's."desc" AS description, s.l_wp, s.l_site, s.l_twitter, s.l_anidb, s.l_wikidata, s.l_pixiv',
+ select => 's.description, s.l_wp, s.l_site, s.l_twitter, s.l_anidb, s.l_wikidata, s.l_pixiv',
proc => sub {
$_[0]{description} ||= undef;
$_[0]{links} = {
@@ -974,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} eq $_->{id}, @$r ];
+ $i->{aliases} = [ map [ $_->{aid}*1, $_->{name}, $_->{original} eq $_->{name} ? undef : $_->{original} ], grep $i->{id} eq $_->{id}, @$r ];
}
},
]],
@@ -1028,15 +1125,15 @@ my %GET_STAFF = (
[ 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 vn v ON v.id = q.vid WHERE NOT v.hidden AND (%s) %s",
- select => "v.id, v.title, q.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};
},
@@ -1087,13 +1184,11 @@ my $VN_FILTER = [
[ inta => 'uv.vid :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, process => \'v', join => ',' ],
];
-my $UV_PUBLIC = 'EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private)';
-
my %GET_VOTELIST = (
islist => 1,
- sql => "SELECT %s FROM ulist_vns uv WHERE uv.vote IS NOT NULL AND (%s) AND $UV_PUBLIC %s",
- sqluser => "SELECT %1\$s FROM ulist_vns uv WHERE uv.vote IS NOT NULL AND (%2\$s) AND (uid = %4\$s OR $UV_PUBLIC) %3\$s",
+ 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} = idnum $_[0]{uid};
@@ -1107,44 +1202,40 @@ my %GET_VOTELIST = (
filters => { uid => [ $UID_FILTER ], vn => $VN_FILTER }
);
-my $SQL_VNLIST = 'FROM ulist_vns uv LEFT JOIN ulist_vns_labels uvl ON uvl.uid = uv.uid AND uvl.vid = uv.vid AND uvl.lbl IN(1,2,3,4)'
- .' WHERE (EXISTS(SELECT 1 FROM ulist_vns_labels uvl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND uvl.lbl IN(1,2,3,4))'
- .' OR NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid))';
+my $SQL_VNLIST = "FROM ulist_vns uv WHERE (labels IN('{}','{7}') OR labels && ARRAY[1,2,3,4]::smallint[])";
my %GET_VNLIST = (
islist => 1,
- sql => "SELECT %s $SQL_VNLIST AND (%s) AND $UV_PUBLIC GROUP BY uv.uid, uv.vid, uv.added, uv.notes %s",
- sqluser => "SELECT %1\$s $SQL_VNLIST AND (%2\$s) AND (uv.uid = %4\$s OR $UV_PUBLIC) GROUP BY uv.uid, uv.vid, uv.added, uv.notes %3\$s",
- select => "uv.uid AS uid, uv.vid as vn, MAX(uvl.lbl) AS status, extract('epoch' from uv.added) AS added, uv.notes",
+ sql => "SELECT %s $SQL_VNLIST AND (%s) AND NOT c_private %s",
+ sqluser => "SELECT %1\$s $SQL_VNLIST AND (%2\$s) AND (uid = %4\$s OR NOT c_private) %3\$s",
+ select => "uid AS uid, vid as vn, labels, extract('epoch' from added) AS added, notes",
proc => sub {
$_[0]{uid} = idnum $_[0]{uid};
$_[0]{vn} = idnum $_[0]{vn};
- $_[0]{status} = defined $_[0]{status} ? $_[0]{status}*1 : 0;
+ my @labels = delete($_[0]{labels}) =~ /^{(.+)}$/ ? split /,/, $1 : ();
+ $_[0]{status} = 1*(max(grep $_ <= 4, @labels) || 0);
$_[0]{added} = int $_[0]{added};
$_[0]{notes} ||= undef;
},
sortdef => 'vn',
- sorts => { vn => 'uv.vid %s' },
+ sorts => { vn => 'vid %s' },
flags => { basic => {} },
filters => { uid => [ $UID_FILTER ], vn => $VN_FILTER }
);
-my $SQL_WISHLIST = "FROM ulist_vns uv JOIN ulist_vns_labels uvl ON uvl.uid = uv.uid AND uvl.vid = uv.vid JOIN ulist_labels ul ON ul.uid = uv.uid AND ul.id = uvl.lbl"
- ." WHERE (uvl.lbl IN(5,6) OR ul.label IN('Wishlist-Low','Wishlist-Medium','Wishlist-High'))";
-
my %GET_WISHLIST = (
islist => 1,
- sql => "SELECT %s $SQL_WISHLIST AND (%s) AND NOT ul.private GROUP BY uv.uid, uv.vid, uv.added %s",
- sqluser => "SELECT %1\$s $SQL_WISHLIST AND (%2\$s) AND (uv.uid = %4\$s OR NOT ul.private) GROUP BY uv.uid, uv.vid, uv.added %3\$s",
- select => "uv.uid AS uid, uv.vid AS vn, MAX(ul.label) AS priority, extract('epoch' from uv.added) AS added",
+ sql => "SELECT %s FROM ulist_vns uv WHERE labels && ARRAY[5,6]::smallint[] AND (%s) AND NOT c_private %s",
+ sqluser => "SELECT %1\$s FROM ulist_vns uv WHERE labels && ARRAY[5,6]::smallint[] AND (%2\$s) AND (uid = %4\$s OR NOT c_private) %3\$s",
+ select => "uid AS uid, vid AS vn, CASE WHEN labels && ARRAY[6]::smallint[] THEN 3 ELSE 1 END AS priority, extract('epoch' from added) AS added",
proc => sub {
$_[0]{uid} = idnum $_[0]{uid};
$_[0]{vn} = idnum $_[0]{vn};
- $_[0]{priority} = {'Wishlist-High' => 0, 'Wishlist-Medium' => 1, 'Wishlist-Low' => 2, 'Blacklist' => 3}->{$_[0]{priority}}//1;
+ $_[0]{priority} *= 1;
$_[0]{added} = int $_[0]{added};
},
sortdef => 'vn',
- sorts => { vn => 'uv.vid %s' },
+ sorts => { vn => 'vid %s' },
flags => { basic => {} },
filters => { uid => [ $UID_FILTER ], vn => $VN_FILTER }
);
@@ -1165,11 +1256,10 @@ my %GET_ULIST_LABELS = (
filters => { uid => [ $UID_FILTER ] },
);
-my $ULIST_PUBLIC = 'EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private)';
my %GET_ULIST = (
islist => 1,
- sql => "SELECT %s FROM ulist_vns uv WHERE (%s) AND ($ULIST_PUBLIC) %s",
- sqluser => "SELECT %1\$s FROM ulist_vns uv WHERE (%2\$s) AND (uv.uid = %4\$s OR $ULIST_PUBLIC) %3\$s",
+ 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};
@@ -1192,9 +1282,11 @@ my %GET_ULIST = (
flags => {
basic => {},
labels => {
- fetch => [[ ['uid','vn'], 'SELECT uvl.uid, uvl.vid, ul.id, ul.label
- FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl
- WHERE (uvl.uid,uvl.vid) IN(%s) AND (NOT ul.private OR uvl.uid = %s OR uvl.lbl = 7)',
+ 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 ];
@@ -1212,8 +1304,7 @@ my %GET_ULIST = (
uid => [ $UID_FILTER ],
vn => $VN_FILTER,
label => [
- [ 'int' => 'EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl
- WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND uvl.lbl = :value: AND (uvl.lbl = 7 OR NOT ul.private))', {'=',1}, range => [1,1e6] ],
+ [ 'int' => '(:value: = 7 OR EXISTS(SELECT 1 FROM ulist_labels ul WHERE ul.uid = uv.uid AND ul.id = :value: AND NOT ul.private)) AND labels && ARRAY[:value:]::smallint[]', {'=',1}, range => [1,32000] ],
],
},
);
@@ -1485,7 +1576,9 @@ sub setpg {
sub set_ulist_ret {
my($c, $obj) = @_;
- setpg $obj, 'SELECT update_users_ulist_stats($1)', [ $c->{uid} ]; # XXX: This can be deferred, to speed up batch updates over the same connection
+ cpg $obj->{c}, 'SELECT update_users_ulist_private($1, $2)', [ $c->{uid}, 'v'.$obj->{id} ], sub {
+ setpg $obj, 'SELECT update_users_ulist_stats($1)', [ $c->{uid} ];
+ };
}
@@ -1521,32 +1614,23 @@ sub set_vnlist {
$vs ||= 0;
$vn ||= '';
- cpg $c, 'INSERT INTO ulist_vns (uid, vid, notes) VALUES ($1, $2, $3) ON CONFLICT (uid, vid) DO UPDATE SET lastmod = NOW()'.($en ? ', notes = $3' : ''),
- [ $c->{uid}, 'v'.$obj->{id}, $vn ], sub {
- if($es) {
- cpg $c, 'DELETE FROM ulist_vns_labels WHERE uid = $1 AND vid = $2 AND lbl IN(1,2,3,4)', [ $c->{uid}, 'v'.$obj->{id} ], sub {
- if($vs) {
- cpg $c, 'INSERT INTO ulist_vns_labels (uid, vid, lbl) VALUES($1, $2, $3)', [ $c->{uid}, 'v'.$obj->{id}, $vs ], sub {
- set_ulist_ret $c, $obj;
- }
- } else {
- set_ulist_ret $c, $obj;
- }
- }
- } else {
- set_ulist_ret $c, $obj;
- }
- }
+ my $l = 'array_remove(array_remove(array_remove(array_remove(ulist_vns.labels, 1), 2), 3), 4)';
+ cpg $c, q{
+ INSERT INTO ulist_vns (uid, vid, notes, labels)
+ VALUES ($1, $2, $3, CASE WHEN $4 = 0 THEN '{}' ELSE ARRAY[$4]::smallint[] END)
+ ON CONFLICT (uid, vid) DO UPDATE SET lastmod = NOW()}
+ .($en ? ', notes = $3' : '')
+ .($es ? ', labels = CASE WHEN $4 = 0 THEN '.$l.' ELSE array_set('.$l.', $4) END' : ''),
+ [ $c->{uid}, 'v'.$obj->{id}, $vn, $vs ], sub { set_ulist_ret $c, $obj; };
}
sub set_wishlist {
my($c, $obj) = @_;
-
- my $sql_label = "(lbl IN(5,6) OR lbl IN(SELECT id FROM ulist_labels WHERE uid = \$1 AND label IN('Wishlist-Low','Wishlist-High','Wishlist-Medium')))";
+ my $l = 'array_remove(array_remove(ulist_vns.labels,5),6)';
# Bug: This will make it appear in the vnlist
- return cpg $c, "DELETE FROM ulist_vns_labels WHERE uid = \$1 AND vid = \$2 AND $sql_label",
+ 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};
@@ -1555,23 +1639,15 @@ sub set_wishlist {
return cerr $c, missing => 'No priority given', field => 'priority' if !$ep;
return cerr $c, badarg => 'Invalid priority', field => 'priority' if ref($vp) || !defined($vp) || $vp !~ /^[0-3]$/;
- # Bug: High/Med/Low statuses are only set if a Wishlist-(High|Medium|Low) label exists; These should probably be created if they don't.
- cpg $c, 'INSERT INTO ulist_vns (uid, vid) VALUES ($1, $2) ON CONFLICT DO NOTHING', [ $c->{uid}, 'v'.$obj->{id} ], sub {
- cpg $c, "DELETE FROM ulist_vns_labels WHERE uid = \$1 AND vid = \$2 AND $sql_label", [ $c->{uid}, 'v'.$obj->{id} ], sub {
- cpg $c, 'INSERT INTO ulist_vns_labels (uid, vid, lbl) VALUES($1, $2, $3)', [ $c->{uid}, 'v'.$obj->{id}, $vp == 3 ? 6 : 5 ], sub {
- if($vp != 3) {
- cpg $c, 'INSERT INTO ulist_vns_labels (uid, vid, lbl) SELECT $1, $2, id FROM ulist_labels WHERE uid = $1 AND label = $3',
- [ $c->{uid}, 'v'.$obj->{id}, ['Wishlist-High', 'Wishlist-Medium', 'Wishlist-Low']->[$vp] ], sub {
- set_ulist_ret $c, $obj;
- }
- } else {
- set_ulist_ret $c, $obj;
- }
- }
- }
- }
+ my $label = $vp == 3 ? 6 : 5; # Other statuses are not supported anymore.
+ cpg $c,
+ 'INSERT INTO ulist_vns (uid, vid, labels) VALUES ($1, $2, ARRAY[$3]::smallint[])
+ ON CONFLICT (uid,vid) DO UPDATE SET lastmod = NOW(), labels = array_set('.$l.', $3)',
+ [ $c->{uid}, 'v'.$obj->{id}, $label ],
+ sub { set_ulist_ret $c, $obj };
}
+
sub set_ulist {
my($c, $obj) = @_;
@@ -1611,17 +1687,12 @@ sub set_ulist {
return cerr $c, badarg => "Labels field expects an array", field => 'labels' if ref $opt->{labels} ne 'ARRAY';
return cerr $c, badarg => "Invalid label: '$_'", field => 'labels' for grep !defined($_) || ref($_) || !/^[0-9]+$/, $opt->{labels}->@*;
my %l = map +($_,1), grep $_ != 7, $opt->{labels}->@*;
- # XXX: This is ugly. Errors (especially: unknown labels) are ignored and
- # the entire set operation ought to run in a single transaction.
- pg_cmd 'SELECT lbl FROM ulist_vns_labels WHERE uid = $1 AND vid = $2', [ $c->{uid}, 'v'.$obj->{id} ], sub {
- return if pg_expect $_[0];
- my %ids = map +($_->{lbl}, 1), $_[0]->rowsAsHashes;
- pg_cmd 'INSERT INTO ulist_vns_labels (uid, vid, lbl) VALUES ($1,$2,$3)', [ $c->{uid}, 'v'.$obj->{id}, $_ ] for grep !$ids{$_}, keys %l;
- pg_cmd 'DELETE FROM ulist_vns_labels WHERE uid = $1 AND vid = $2 AND lbl = $3', [ $c->{uid}, 'v'.$obj->{id}, $_ ] for grep !$l{$_}, keys %ids;
- };
+ # XXX: Labels aren't validated here, so we might actually be writing garbage into the DB. Rest of the code doesn't mind that too much, though.
+ push @bind, '{'.join(',',sort { $a <=> $b } keys %l).'}';
+ push @set, 'labels = $'.@bind;
}
- push @set, 'lastmod = NOW()' if @set || $opt->{labels};
+ push @set, 'lastmod = NOW()' if @set;
return cerr $c, missing => 'No fields to change' if !@set;
cpg $c, 'INSERT INTO ulist_vns (uid, vid) VALUES ($1, $2) ON CONFLICT (uid, vid) DO NOTHING', [ $c->{uid}, 'v'.$obj->{id} ], sub {
diff --git a/lib/Multi/Core.pm b/lib/Multi/Core.pm
index 0cd44e7e..ea1aeb97 100644
--- a/lib/Multi/Core.pm
+++ b/lib/Multi/Core.pm
@@ -12,7 +12,7 @@ use AnyEvent::Log;
use AnyEvent::Pg::Pool;
use Pg::PQ ':pgres';
use DBI;
-use POSIX 'setsid', 'pause', 'SIGUSR1';
+use Fcntl 'LOCK_EX', 'LOCK_NB';
use Exporter 'import';
use VNDB::Config;
@@ -20,9 +20,6 @@ our @EXPORT = qw|pg pg_cmd pg_expect schedule push_watcher throttle|;
my $PG;
-my $logger;
-my $pidfile;
-my $stopcv;
my %throttle; # id => timeout
my @watchers;
@@ -37,41 +34,6 @@ sub push_watcher {
}
-sub daemon_init {
- my $pid = fork();
- die "fork(): $!" if !defined $pid or $pid < 0;
-
- # parent process, log PID and wait for child to initialize
- if($pid > 0) {
- $SIG{CHLD} = sub { die "Initialization failed.\n"; };
- $SIG{ALRM} = sub { kill $pid, 9; die "Initialization timeout.\n"; };
- $SIG{USR1} = sub {
- open my $P, '>', $pidfile or kill($pid, 9) && die $!;
- print $P $pid;
- close $P;
- exit;
- };
- alarm(10);
- pause();
- exit 1;
- }
-}
-
-
-sub daemon_done {
- kill SIGUSR1, getppid();
- setsid();
- chdir '/';
- umask 0022;
- open STDIN, '/dev/null';
- tie *STDOUT, 'Multi::Core::STDIO', 'STDOUT';
- tie *STDERR, 'Multi::Core::STDIO', 'STDERR';
-
- push_watcher AE::signal TERM => sub { $stopcv->send };
- push_watcher AE::signal INT => sub { $stopcv->send };
-}
-
-
sub load_pg {
$PG = AnyEvent::Pg::Pool->new(
config->{Multi}{Core}{db_login},
@@ -117,24 +79,26 @@ sub unload {
sub run {
- my $p = shift;
- $pidfile = config->{root}."/data/multi.pid";
- die "PID file already exists\n" if -e $pidfile;
+ my($quiet) = @_;
- $stopcv = AE::cv;
+ open my $LOCK, '>', config->{var_path}.'/multi.lock' or die "multi.lock: $!\n";
+ flock $LOCK, LOCK_EX|LOCK_NB or die "multi.lock: $!\n";
+
+ my $stopcv = AE::cv;
AnyEvent::Log::ctx('Multi')->attach(AnyEvent::Log::Ctx->new(level => config->{Multi}{Core}{log_level}||'trace',
# Don't use log_to_file, it doesn't accept perl's unicode strings (and, in fact, crashes on them without logging anything).
log_cb => sub {
open(my $F, '>>:utf8', config->{Multi}{Core}{log_dir}.'/multi.log');
print $F $_[0];
+ print $_[0] unless $quiet;
}
));
$AnyEvent::Log::FILTER->level('fatal');
- daemon_init;
load_pg;
load_mods;
- daemon_done;
+ push_watcher AE::signal TERM => sub { $stopcv->send };
+ push_watcher AE::signal INT => sub { $stopcv->send };
AE::log info => "Starting Multi ".config->{version};
push_watcher(schedule(60, 10*60, \&throttle_gc));
diff --git a/lib/Multi/DLsite.pm b/lib/Multi/DLsite.pm
index 46a0263c..a09f0325 100644
--- a/lib/Multi/DLsite.pm
+++ b/lib/Multi/DLsite.pm
@@ -12,7 +12,7 @@ use VNDB::Config;
my %C = (
url => 'https://www.dlsite.com/%s/work/=/product_id/%s.html',
clean_timeout => 48*3600,
- check_timeout => 5*60,
+ check_timeout => 1*60,
);
@@ -22,10 +22,7 @@ sub run {
%C = (%C, @_);
push_watcher schedule 0, $C{clean_timeout}, sub {
- pg_cmd q{DELETE FROM shop_dlsite WHERE id NOT IN(
- SELECT l_dlsite FROM releases WHERE NOT hidden
- UNION ALL
- SELECT l_dlsiteen FROM releases WHERE NOT hidden)};
+ pg_cmd q{DELETE FROM shop_dlsite WHERE id NOT IN(SELECT l_dlsite FROM releases WHERE NOT hidden)};
};
push_watcher schedule 0, $C{check_timeout}, sub {
pg_cmd q{
@@ -34,15 +31,7 @@ sub run {
FROM releases
WHERE NOT hidden AND l_dlsite <> ''
AND NOT EXISTS(SELECT 1 FROM shop_dlsite WHERE id = l_dlsite)
- }, [], sub {
- pg_cmd q{
- INSERT INTO shop_dlsite (id)
- SELECT DISTINCT l_dlsiteen
- FROM releases
- WHERE NOT hidden AND l_dlsiteen <> ''
- AND NOT EXISTS(SELECT 1 FROM shop_dlsite WHERE id = l_dlsiteen)
- }, [], \&sync
- }
+ }, [], \&sync
}
}
@@ -61,7 +50,6 @@ sub data {
$body =~ m{<i class="work_jpy">([0-9,]+) JPY</i></span>} ? sprintf('JP¥ %d', $1 =~ s/,//gr) : '';
$shop = $body =~ /,"category":"([^"]+)"/ ? $1 : '';
- $shop = 'ecchi-eng' if $shop eq 'ecchieng'; # Both work, but DLsite seems to prefer a dash.
return AE::log warn => "$prefix Product found, but no price ($price) or shop ($shop)" if $found && (!$price || !$shop);
diff --git a/lib/Multi/IRC.pm b/lib/Multi/IRC.pm
index e7e1106a..df055b93 100644
--- a/lib/Multi/IRC.pm
+++ b/lib/Multi/IRC.pm
@@ -10,7 +10,6 @@ use warnings;
use Multi::Core;
use AnyEvent::IRC::Client;
use AnyEvent::IRC::Util 'prefix_nick';
-use VNDB::Func 'normalize_query';
use VNDB::Config;
use TUWF::Misc 'uri_escape';
use POSIX 'strftime';
@@ -19,9 +18,9 @@ use Encode 'decode_utf8', 'encode_utf8';
# long subquery used in several places
my $GETBOARDS = q{array_to_string(array(
- SELECT tb.type||COALESCE(':'||COALESCE(u.username, v.title, p.name), '')
+ SELECT tb.type||COALESCE(':'||COALESCE(u.username, v.title[1+1], p.name), '')
FROM threads_boards tb
- LEFT JOIN vn v ON tb.type = 'v' AND v.id = tb.iid
+ LEFT JOIN vnt v ON tb.type = 'v' AND v.id = tb.iid
LEFT JOIN producers p ON tb.type = 'p' AND p.id = tb.iid
LEFT JOIN users u ON tb.type = 'u' AND u.id = tb.iid
WHERE tb.tid = t.id
@@ -37,7 +36,6 @@ my $LIGHT_GREY = "\x0315";
my $irc;
my $connecttimer;
-my @quotew;
my %lastnotify;
@@ -62,7 +60,6 @@ sub run {
set_cbs();
set_logger();
- set_quotew($_) for (0..$#{$O{channels}});
set_notify();
ircconnect();
@@ -86,7 +83,6 @@ sub run {
sub unload {
- @quotew = ();
# TODO: Wait until we've nicely disconnected?
$irc->disconnect('Closing...');
undef $connecttimer;
@@ -107,24 +103,6 @@ sub reconnect {
}
-sub send_quote {
- my $chan = shift;
- pg_cmd 'SELECT quote FROM quotes ORDER BY random() LIMIT 1', undef, sub {
- return if pg_expect $_[0], 1 or !$_[0]->nRows;
- $irc->send_msg(PRIVMSG => $chan, encode_utf8 $_[0]->value(0,0));
- };
-}
-
-
-sub set_quotew {
- my $idx = shift;
- $quotew[$idx] = AE::timer +(18*3600)+rand()*(72*3600), 0, sub {
- send_quote($O{channels}[$idx]) if $irc->registered;
- set_quotew($idx);
- };
-}
-
-
sub set_cbs {
$irc->reg_cb(connect => sub {
return if !$_[1];
@@ -199,14 +177,12 @@ 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 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 newreview};
+ push_watcher pg->listen($_, on_notify => \&notify) for qw{newrevision newpost newreview};
};
}
@@ -292,30 +268,17 @@ sub handleid {
# plain vn/user/producer/thread/tag/trait/release
pg_cmd 'SELECT $1::vndbid AS id, '.(
- $id =~ /^v/ ? 'v.title FROM vn v WHERE v.id = $1' :
- $id =~ /^u/ ? 'u.username AS title FROM users u WHERE u.id = $1' :
- $id =~ /^p/ ? 'p.name AS title FROM producers p WHERE p.id = $1' :
- $id =~ /^c/ ? 'c.name AS title FROM chars c WHERE c.id = $1' :
- $id =~ /^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 = $1' :
$id =~ /^t/ ? 'title, '.$GETBOARDS.' FROM threads t WHERE NOT t.hidden AND NOT t.private AND t.id = $1' :
- $id =~ /^g/ ? 'name AS title FROM tags WHERE id = vndbid_num($1)' :
- $id =~ /^i/ ? 'name AS title FROM traits WHERE id = vndbid_num($1)' :
- $id =~ /^d/ ? 'title FROM docs WHERE id = $1' :
- $id =~ /^w/ ? 'v.title, u.username FROM reviews w JOIN vn v ON v.id = w.vid LEFT JOIN users u ON u.id = w.uid WHERE w.id = $1' :
- 'r.title FROM releases r WHERE r.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::vndbid AS id, $2::integer AS rev, '.(
- $id =~ /^v/ ? 'vh.title, u.username, c.comments FROM changes c JOIN vn_hist vh ON c.id = vh.chid LEFT JOIN users u ON u.id = c.requester WHERE c.itemid = $1 AND c.rev = $2' :
- $id =~ /^r/ ? 'rh.title, u.username, c.comments FROM changes c JOIN releases_hist rh ON c.id = rh.chid LEFT JOIN users u ON u.id = c.requester WHERE c.itemid = $1 AND c.rev = $2' :
- $id =~ /^p/ ? 'ph.name AS title, u.username, c.comments FROM changes c JOIN producers_hist ph ON c.id = ph.chid LEFT JOIN users u ON u.id = c.requester WHERE c.itemid = $1 AND c.rev = $2' :
- $id =~ /^c/ ? 'ch.name AS title, u.username, c.comments FROM changes c JOIN chars_hist ch ON c.id = ch.chid LEFT JOIN users u ON u.id = c.requester WHERE c.itemid = $1 AND c.rev = $2' :
- $id =~ /^s/ ? 'sah.name AS title, u.username, c.comments FROM changes c JOIN staff_hist sh ON c.id = sh.chid LEFT 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.itemid = $1 AND c.rev = $2' :
- $id =~ /^d/ ? 'dh.title, u.username, c.comments FROM changes c JOIN docs_hist dh ON c.id = dh.chid LEFT JOIN users u ON u.id = c.requester WHERE c.itemid = $1 AND c.rev = $2' :
- $id =~ /^w/ ? 'v.title, u.username FROM reviews_posts wp JOIN reviews w ON w.id = wp.id JOIN vn v ON v.id = w.vid LEFT JOIN users u ON u.id = wp.uid WHERE wp.id = $1 AND wp.num = $2' :
- '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, $rev], $c if $rev && $id =~ /^[dvprtcsw]/;
+ $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]/;
}
@@ -327,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]|)([wdvprtcs][1-9][0-9]*)\.([1-9][0-9]*)(?:[^\w].*|)$/ ? [ $1, $2 ] # x+.+
- : /^(?:.*[^\w]|)([wdvprtugics][1-9][0-9]*)(?:[^\w].*|)$/ ? [ $1, '' ] : (); # 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;
}
@@ -338,21 +301,14 @@ sub vndbid {
sub notify {
my(undef, $sel) = @_;
- my $k = {qw|newrevision rev newpost post newtrait trait newtag tag newreview review|}->{$sel};
+ my $k = {qw|newrevision rev newpost post newreview review|}->{$sel};
return if !$k || !$lastnotify{$k};
my $q = {
rev => q{
- SELECT 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 vndbid_type(c.itemid) = 'v' AND c.id = vh.chid
- LEFT JOIN releases_hist rh ON vndbid_type(c.itemid) = 'r' AND c.id = rh.chid
- LEFT JOIN producers_hist ph ON vndbid_type(c.itemid) = 'p' AND c.id = ph.chid
- LEFT JOIN chars_hist ch ON vndbid_type(c.itemid) = 'c' AND c.id = ch.chid
- LEFT JOIN staff_hist sh ON vndbid_type(c.itemid) = 's' AND c.id = sh.chid
- LEFT JOIN staff_alias_hist sah ON vndbid_type(c.itemid) = 's' AND sah.aid = sh.aid AND sah.chid = c.id
- LEFT JOIN docs_hist dh ON vndbid_type(c.itemid) = '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 <> 'u1'
ORDER BY c.id},
@@ -363,22 +319,10 @@ sub notify {
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'||t.id AS 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'||t.id AS 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, u.username, w.id AS lastid
+ SELECT w.id, v.title[1+1], u.username, w.id AS lastid
FROM reviews w
- JOIN vn v ON v.id = w.vid
+ 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}
@@ -409,24 +353,25 @@ 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 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;
@@ -441,11 +386,11 @@ p => [ 0, 0, sub {
return $irc->send_msg(PRIVMSG => $chan, 'You forgot the search query, dummy~~!') if !$q;
pg_cmd q{
SELECT 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 {
+ 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;
diff --git a/lib/Multi/JASTUSA.pm b/lib/Multi/JASTUSA.pm
new file mode 100644
index 00000000..bf4b88f8
--- /dev/null
+++ b/lib/Multi/JASTUSA.pm
@@ -0,0 +1,87 @@
+package Multi::JASTUSA;
+
+use v5.28;
+use Multi::Core;
+use AnyEvent::HTTP;
+use JSON::XS 'decode_json';
+use VNDB::Config;
+
+
+my %C = (
+ sync_timeout => 6*3600,
+ url => 'https://app.jastusa.com/api/v2/shop/es?channelCode=JASTUSA&currency=USD&limit=50&localeCode=en_US&sale=false&sort=newest&zone=US&page=%d',
+);
+
+
+sub run {
+ shift;
+ $C{ua} = sprintf 'VNDB.org Affiliate Crawler (Multi v%s; contact@vndb.org)', config->{version};
+ %C = (%C, @_);
+
+ push_watcher schedule 35*60, $C{sync_timeout}, \&sync;
+}
+
+
+sub slug {
+ # The slug is not included in the API, so presumably generated in JS.
+ # This is reverse engineering attempt based on titles in the store, most likely missing a whole lot of symbols.
+ lc($_[0]) =~ s/[-, \[\]]+/-/rg =~ s/^-//r =~ s/-$//r =~ s/&/and/rg =~ s/♥/love/rg =~ tr/–ω锓*³★・;\/?/-we""/rd
+}
+
+
+sub item {
+ my($prefix, $p) = @_;
+ return 'Invalid object' if !$p->{code} || !$p->{variants}[0] || !$p->{translations}{en_US}{name};
+ my $slug = slug $p->{translations}{en_US}{name};
+ my $var = $p->{variants}[0];
+ return 'Not in stock' if !$var->{inStock};
+ return 'No price info' if !defined $var->{price};
+ my $price = $var->{price} ? sprintf 'US$ %.2f', $var->{price}/100 : 'free';
+ AE::log info => "$prefix $p->{code} at $slug for $price";
+ pg_cmd 'UPDATE shop_jastusa SET lastfetch = NOW(), deadsince = NULL, price = $1, slug = $2 WHERE id = $3',
+ [ $price, $slug, $p->{code} ];
+ 0
+}
+
+
+sub data {
+ my($page, $time, $body, $hdr) = @_;
+ my $prefix = sprintf '[%.1fs] %d', $time, $page;
+ return AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}" if $hdr->{Status} !~ /^2/;
+ my $nfo = decode_json $body;
+ return AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}" if ref $nfo ne 'HASH' || !$nfo->{pages};
+
+ for my $p ($nfo->{products}->@*) {
+ my $r = item($prefix, $p);
+ AE::log warn => "$prefix $p->{code}: $r" if $r;
+ }
+
+ if($page < $nfo->{pages}) {
+ fetch($page+1);
+ } else {
+ pg_cmd "UPDATE shop_jastusa SET deadsince = NOW(), price = '' WHERE deadsince IS NULL AND (lastfetch IS NULL OR lastfetch < NOW()-'1 hour'::interval)";
+ }
+}
+
+
+sub fetch {
+ my($page) = @_;
+ my $ts = AE::now;
+ http_get sprintf($C{url}, $page),
+ headers => {'User-Agent' => $C{ua}},
+ timeout => 60,
+ sub { data($page, AE::now-$ts, @_) };
+}
+
+sub sync {
+ pg_cmd 'DELETE FROM shop_jastusa WHERE id NOT IN(SELECT l_jastusa FROM releases WHERE NOT hidden)';
+ pg_cmd q{
+ INSERT INTO shop_jastusa (id)
+ SELECT DISTINCT l_jastusa
+ FROM releases
+ WHERE NOT hidden AND l_jastusa <> ''
+ AND NOT EXISTS(SELECT 1 FROM shop_jastusa WHERE id = l_jastusa)
+ }, [], sub { fetch(1) }
+}
+
+1;
diff --git a/lib/Multi/JList.pm b/lib/Multi/JList.pm
index 515a34b5..60ce2c1e 100644
--- a/lib/Multi/JList.pm
+++ b/lib/Multi/JList.pm
@@ -5,11 +5,11 @@ use warnings;
use Multi::Core;
use AnyEvent::HTTP;
use VNDB::Config;
+use VNDB::ExtLinks;
my %C = (
- jbox => 'https://www.jbox.com/',
- jlist => 'https://www.jlist.com/',
+ url => 'https://jlist.com/shop/product/%s',
clean_timeout => 48*3600,
check_timeout => 10*60, # Minimum time between fetches.
);
@@ -35,45 +35,34 @@ sub run {
}
-sub trysite {
- my($jbox, $id) = @_;
- my $ts = AE::now;
- my $url = ($jbox eq 't' ? $C{jbox} : $C{jlist}).$id;
- http_get $url, headers => {'User-Agent' => $C{ua} }, timeout => 60,
- sub { data($jbox, AE::now-$ts, $id, @_) };
-}
-
-
sub data {
- my($jbox, $time, $id, $body, $hdr) = @_;
+ my($time, $id, $body, $hdr) = @_;
my $prefix = sprintf '[%.1fs] %s', $time, $id;
return AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}" if $hdr->{Status} !~ /^2/ && $hdr->{Status} ne '404';
- return AE::log warn => "$prefix ERROR: Blocked by StackPath" if $body =~ /StackPath/;
- my $found = $hdr->{Status} ne '404' && $body =~ /fancybox mainProductImage/;
- my $outofstock = $body =~ /<div class="statusBox-detail">[\s\r\n]*Out of stock[\s\r\n]*<\/div>/im;
- my $price = $body =~ /<span class="price"(?: id="product-price-\d+")?>\s*\$(\d+\.\d+)(?:\/\$\d+\.\d+)?\s*<\/span>/ ? sprintf('US$ %.2f', $1) : '';
+ # Extract info from the JSON-LD embedded on the page. Assumes there's either
+ # a single "Product" or none. Also assumes specific JSON formatting, because
+ # I'm too lazy to properly extract out and parse the JSON.
+ my $found = $hdr->{Status} ne '404' && $body =~ /"\@type":"Product"/;
+ my $outofstock = $body !~ m{"availability":"https://schema.org/InStock"};
+ my $price = $body =~ /"price":"([0-9\.]+)"/ ? sprintf('US$ %.2f', $1) : '';
return AE::log warn => "$prefix Product found, but no price" if !$price && $found && !$outofstock;
# Out of stock? Update database.
if($outofstock) {
- pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, jbox = $2, price = '', lastfetch = NOW() WHERE id = $1}, [ $id, $jbox ];
- AE::log debug => "$prefix is out of stock on jbox=$jbox";
+ pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, price = '', lastfetch = NOW() WHERE id = $1}, [ $id ];
+ AE::log debug => "$prefix is out of stock";
# We have a price? Update database.
} elsif($price) {
- pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, jbox = $2, price = $3, lastfetch = NOW() WHERE id = $1}, [ $id, $jbox, $price ];
- AE::log debug => "$prefix for $price on jbox=$jbox";
-
- # No price or stock info? Try J-List
- } elsif($jbox eq 't') {
- trysite 'f', $id;
+ pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, price = $2, lastfetch = NOW() WHERE id = $1}, [ $id, $price ];
+ AE::log debug => "$prefix for $price";
- # Nothing at all? Update database.
+ # Not found? Update database.
} else {
- pg_cmd q{UPDATE shop_jlist SET deadsince = coalesce(deadsince, NOW()), lastfetch = NOW() WHERE id = $1}, [ $id ];
- AE::log info => "$prefix not found on either JBOX or J-List.";
+ pg_cmd q{UPDATE shop_jlist SET deadsince = NOW() WHERE deadsince IS NULL AND id = $1}, [ $id ];
+ AE::log info => "$prefix not found.";
}
}
@@ -82,6 +71,9 @@ sub sync {
pg_cmd 'SELECT id FROM shop_jlist ORDER BY lastfetch ASC NULLS FIRST LIMIT 1', [], sub {
my($res, $time) = @_;
return if pg_expect $res, 1 or !$res->nRows;
- trysite 't', $res->value(0,0);
+ my $id = $res->value(0,0);
+ my $ts = AE::now;
+ http_get sprintf($C{url}, $id), headers => {'User-Agent' => $C{ua} }, timeout => 60,
+ sub { data(AE::now-$ts, $id, @_) };
};
}
diff --git a/lib/Multi/Maintenance.pm b/lib/Multi/Maintenance.pm
index f6e913af..517a5b4e 100644
--- a/lib/Multi/Maintenance.pm
+++ b/lib/Multi/Maintenance.pm
@@ -8,8 +8,7 @@ package Multi::Maintenance;
use strict;
use warnings;
use Multi::Core;
-use PerlIO::gzip;
-use VNDB::Func 'normalize_titles';
+use POSIX 'strftime';
use VNDB::Config;
@@ -17,9 +16,8 @@ my $monthly;
sub run {
+ 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
- push_watcher schedule 0, 3600, \&vnsearch_check;
- push_watcher pg->listen(vnsearch => on_notify => \&vnsearch_check);
set_monthly();
}
@@ -48,12 +46,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)
@@ -71,20 +102,24 @@ my %dailies = (
# takes about 11 seconds, OK
traitcache => 'SELECT traits_chars_calc(NULL)',
- # takes about 5 seconds, OK
- vnstats => 'SELECT update_vnvotestats()',
+ lengthcache => 'SELECT update_vn_length_cache(NULL)',
# takes about 10 seconds, OK
imagecache => 'SELECT update_images_cache(NULL)',
reviewcache => 'SELECT update_reviews_votes_cache(NULL)',
- cleansessions => q|DELETE FROM sessions WHERE expires < NOW()|,
+ 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()|,
);
@@ -103,6 +138,7 @@ sub daily {
run_daily shift(@l), $s if @l;
};
$s->();
+ logrotate;
}
@@ -123,27 +159,6 @@ my %monthlies = (
);
-sub logrotate {
- my $dir = sprintf '%s/old', config->{Multi}{Core}{log_dir};
- mkdir $dir if !-d $dir;
-
- for (glob sprintf '%s/*', config->{Multi}{Core}{log_dir}) {
- next if /^\./ || /~$/ || !-f;
- my $f = /([^\/]+)$/ ? $1 : $_;
- my $n = sprintf '%s/%s.%04d-%02d-%02d.gz', $dir, $f, (localtime)[5]+1900, (localtime)[4]+1, (localtime)[3];
- return if -f $n;
- open my $I, '<', sprintf '%s/%s', config->{Multi}{Core}{log_dir}, $f;
- open my $O, '>:gzip', $n;
- print $O $_ while <$I>;
- close $O;
- close $I;
- open $I, '>', sprintf '%s/%s', config->{Multi}{Core}{log_dir}, $f;
- close $I;
- }
- AE::log info => 'Logs rotated.';
-}
-
-
sub run_monthly {
my($d, $sub) = @_;
pg_cmd $monthlies{$d}, undef, sub {
@@ -159,47 +174,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 %s (%3dms SQL)', $id, ($time+$t2)*1000;
- vnsearch_check;
- };
-}
-
-
1;
diff --git a/lib/Multi/Wikidata.pm b/lib/Multi/Wikidata.pm
index d54fbc8b..44f49a43 100644
--- a/lib/Multi/Wikidata.pm
+++ b/lib/Multi/Wikidata.pm
@@ -94,7 +94,7 @@ sub save {
my $v = $_->{mainsnak}{datavalue}{value};
if(ref $v) {
AE::log warn => "Q$id has a non-scalar value for '$p'";
- } elsif($_->{qualifiers}{P582}) {
+ } elsif($_->{qualifiers}{P582} || $_->{qualifiers}{P8554}) {
AE::log info => "Q$id excluding property '$p' because it has an 'end time'";
} elsif(defined $v) {
push @val, $v;
diff --git a/lib/PWLookup.pm b/lib/PWLookup.pm
deleted file mode 100644
index 6e2f03e4..00000000
--- a/lib/PWLookup.pm
+++ /dev/null
@@ -1,155 +0,0 @@
-#!/usr/bin/perl
-
-# This script is based on the btree.pl that I wrote as part of a little
-# experiment: https://dev.yorhel.nl/doc/pwlookup
-#
-# It is hardcoded to use gzip (because that's available in a standard Perl
-# distribution) compression level 9 (saves a few MiB with no noticable impact
-# on lookup performance) with 4k block sizes (because that is fast enough and
-# offers good compression).
-#
-# Creating the database:
-#
-# perl PWlookup.pm create <sorted-dictionary >dbfile
-#
-# Extracting all passwords from the database:
-#
-# perl PWLookup.pm extract dbfile >sorted-dictionary
-#
-# Performing lookups (from the CLI):
-#
-# perl PWLookup.pm lookup dbfile query
-#
-# Performing lookups (from Perl):
-#
-# use PWLookup;
-# my $pw_exists = PWLookup::lookup($dbfile, $query);
-
-package PWLookup;
-
-use strict;
-use warnings;
-use v5.10;
-use Compress::Zlib qw/compress uncompress/;
-use Encode qw/encode_utf8 decode_utf8/;
-
-my $blocksize = 4096;
-
-# Encode/decode a block reference, [ leaf, length, offset ]. Encoded in a single 64bit integer as (leaf | length << 1 | offset << 16)
-sub eref($) { pack 'Q', ($_[0][0]?1:0) | $_[0][1]<<1 | $_[0][2]<<16 }
-sub dref($) { my $v = unpack 'Q', $_[0]; [$v&1, ($v>>1)&((1<<15)-1), $v>>16] }
-
-# Write a block and return its reference.
-sub writeblock {
- state $off = 0;
- my $buf = compress($_[0], 9);
- my $len = length $buf;
- print $buf;
- my $oldoff = $off;
- $off += $len;
- [$_[1], $len, $oldoff]
-}
-
-# Read a block given a file handle and a reference.
-sub readblock {
- my($F, $ref) = @_;
- die $! if !sysseek $F, $ref->[2], 0;
- die $! if $ref->[1] != sysread $F, (my $buf), $ref->[1];
- uncompress($buf)
-}
-
-sub encode {
- my $leaf = "\0";
- my @nodes = ('');
- my $ref;
-
- my $flush = sub {
- my $minsize = $_[0];
- return if $minsize > length $leaf;
-
- my $str = $leaf =~ /^\x00([^\x00]*)/ && $1;
- $ref = writeblock $leaf, 1;
- $leaf = "\0";
- $nodes[0] .= "$str\x00".eref($ref);
-
- for(my $i=0; $i <= $#nodes && $minsize < length $nodes[$i]; $i++) {
- my $str = $nodes[$i] =~ s/^([^\x00]*)\x00// && $1;
- $ref = writeblock $nodes[$i], 0;
- $nodes[$i] = '';
- if($minsize || $nodes[$i+1]) {
- $nodes[$i+1] ||= '';
- $nodes[$i+1] .= "$str\x00".eref($ref);
- }
- }
- };
-
- my $last;
- while((my $p = <STDIN>)) {
- chomp($p);
- # No need to store passwords that are rejected by form validation
- if(!length($p) || length($p) > 500 || !eval { decode_utf8((local $_=$p), Encode::FB_CROAK); 1 } || $p =~ /\x00/) {
- warn sprintf "Rejecting: %s\n", ($p =~ s/([^\x21-\x7e])/sprintf '%%%02x', ord $1/ger);
- next;
- }
- # Extra check to make sure the input is unique and sorted according to Perl's string comparison
- if(defined($last) && $last ge $p) {
- warn "Rejecting due to uniqueness or incorrect sorting: $p\n";
- next;
- }
- $leaf .= "$p\0";
- $flush->($blocksize);
- }
- $flush->(0);
- print eref $ref;
-}
-
-
-sub lookup_rec {
- my($F, $q, $ref) = @_;
- my $buf = readblock $F, $ref;
- if($ref->[0]) {
- return $buf =~ /\x00\Q$q\E\x00/;
- } else {
- while($buf =~ /(.{8})([^\x00]+)\x00/sg) {
- return lookup_rec($F, $q, dref $1) if $q lt $2;
- }
- return lookup_rec($F, $q, dref substr $buf, -8)
- }
-}
-
-sub lookup {
- my($f, $q) = @_;
- open my $F, '<', $f or die $!;
- sysseek $F, -8, 2 or die $!;
- die $! if 8 != sysread $F, (my $buf), 8;
- lookup_rec($F, encode_utf8($q), dref $buf)
-}
-
-
-sub extract_rec {
- my($F, $ref) = @_;
- my $buf = readblock $F, $ref;
- if($ref->[0]) {
- print "$1\n" while $buf =~ /\x00([^\x00]+)/g;
- } else {
- extract_rec($F, dref $1) while $buf =~ /(.{8})[^\x00]+\x00/sg;
- extract_rec($F, dref substr $buf, -8)
- }
-}
-
-sub extract {
- my($f) = @_;
- open my $F, '<', $f or die $!;
- sysseek $F, -8, 2 or die $!;
- die $! if 8 != sysread $F, (my $buf), 8;
- extract_rec($F, dref $buf)
-}
-
-
-if(!caller) {
- encode() if $ARGV[0] eq 'create';
- extract($ARGV[1]) if $ARGV[0] eq 'extract';
- printf "%s\n", lookup($ARGV[1], decode_utf8 $ARGV[2]) ? 'Found' : 'Not found' if $ARGV[0] eq 'lookup';
-}
-
-1;
diff --git a/lib/VNDB/BBCode.pm b/lib/VNDB/BBCode.pm
index fcba62ca..950dcb8b 100644
--- a/lib/VNDB/BBCode.pm
+++ b/lib/VNDB/BBCode.pm
@@ -139,7 +139,7 @@ sub parse {
while($raw =~ m{(?:
\[ \/? (?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+#+[.+]
- [tdvprcsw][1-9][0-9]*\.[1-9][0-9]* | # v+.+
+ [tdvprcswgi][1-9][0-9]*\.[1-9][0-9]* | # v+.+
[tdvprcsugiw][1-9][0-9]* | # v+
(?:https?|ftp)://[^><"\n\s\]\[]+[\d\w=/-] # link
)}xg) {
@@ -186,7 +186,7 @@ FINAL:
# 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 <b class="spoiler">..
+# default: format as <span class="spoiler">..
sub bb_format {
my($input, %opt) = @_;
$opt{delspoil} = 1 if $opt{text} && !$opt{keepspoil};
@@ -235,8 +235,8 @@ sub bb_format {
} elsif($opt{idonly}) {
$ret .= e $raw;
- } elsif($tag eq 'b_start') { $ret .= $opt{text} ? e '*' : '<b>'
- } elsif($tag eq 'b_end') { $ret .= $opt{text} ? e '*' : '</b>'
+ } 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">'
@@ -262,11 +262,11 @@ sub bb_format {
} elsif($tag eq 'spoiler_start') {
$inspoil = 1;
$ret .= $opt{delspoil} || $opt{keepspoil} ? ''
- : $opt{replacespoil} ? '<b class="grayedout">&lt;hidden by spoiler settings&gt;</b>'
- : '<b class="spoiler">';
+ : $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} ? '' : '</b>';
+ $ret .= $opt{delspoil} || $opt{keepspoil} || $opt{replacespoil} ? '' : '</span>';
} elsif($tag eq 'url_start') {
$ret .= $opt{text} ? '' : sprintf '<a href="%s" rel="nofollow">', xml_escape($arg[0]);
@@ -295,26 +295,15 @@ sub bb_subst_links {
my %lookup;
parse $msg, sub {
my($code, $tag) = @_;
- $lookup{$2}{ $2 eq 'g' || $2 eq 'i' ? $3 : $1 } = 1 if $tag eq 'dblink' && $code =~ /^((.)(\d+))/;
+ $lookup{$1} = 1 if $tag eq 'dblink' && $code =~ /^([vcpgis]\d+)$/;
1;
};
return $msg unless %lookup;
- # Now resolve the links
- state $types = { # Query must return 'id' and 'name' columns, list of IDs will be appended to it.
- v => 'SELECT id, title AS name FROM vn WHERE id IN',
- c => 'SELECT id, name FROM chars WHERE id IN',
- p => 'SELECT id, name FROM producers WHERE id IN',
- g => 'SELECT \'g\'||id AS id, name FROM tags WHERE id IN',
- i => 'SELECT \'i\'||id AS id, name FROM traits WHERE id IN',
- s => 'SELECT s.id, sa.name FROM staff_alias sa JOIN staff s ON s.aid = sa.aid WHERE s.id IN',
- };
- my %links;
- for my $type (keys %$types) {
- next if !$lookup{$type};
- my $lst = $TUWF::OBJ->dbAlli($types->{$type}, [keys %{$lookup{$type}}]);
- $links{$_->{id}} = $_->{name} for @$lst;
- }
+ my $first = 0;
+ my %links = map +($_->{id}, $_->{title}), $TUWF::OBJ->dbAlli(
+ 'SELECT id, title[1+1] FROM (VALUES', (map +($first++ ? ',(' : '(', \"$_", '::vndbid)'), sort keys %lookup), ') n(id), item_info(NULL, n.id, NULL)'
+ )->@*;
return $msg unless %links;
# Now substitute
diff --git a/lib/VNDB/Config.pm b/lib/VNDB/Config.pm
index d360c258..050a0124 100644
--- a/lib/VNDB/Config.pm
+++ b/lib/VNDB/Config.pm
@@ -3,13 +3,19 @@ package VNDB::Config;
use strict;
use warnings;
use Exporter 'import';
+use Cwd 'abs_path';
our @EXPORT = ('config');
my $ROOT = $INC{'VNDB/Config.pm'} =~ s{/lib/VNDB/Config\.pm$}{}r;
+my $GEN = abs_path($ENV{VNDB_GEN} // "$ROOT/gen");
+my $VAR = abs_path($ENV{VNDB_VAR} // "$ROOT/var");
# Default config options
my $config = {
- url => 'http://localhost:3000',
+ gen_path => $GEN,
+ var_path => $VAR,
+
+ url => 'http://localhost:3000',
tuwf => {
db_login => [ 'dbi:Pg:dbname=vndb', 'vndb_site', undef ],
@@ -24,16 +30,22 @@ my $config = {
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',
- convert_path => '/usr/bin/convert',
- identify_path => '/usr/bin/identify',
+ 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 => {},
@@ -41,7 +53,7 @@ my $config = {
};
-my $config_file = do $ROOT.'/data/conf.pl';
+my $config_file = -e "$VAR/conf.pl" ? do("$VAR/conf.pl") || die $! : {};
my $config_merged;
sub config {
@@ -55,7 +67,7 @@ sub config {
$c->{version} ||= `git -C "$ROOT" describe` =~ s/\-g[0-9a-f]+$//rg =~ s/\r?\n//rg;
$c->{root} = $ROOT;
$c->{Multi}{Core}{log_level} ||= 'debug';
- $c->{Multi}{Core}{log_dir} ||= $ROOT.'/data/log';
+ $c->{Multi}{Core}{log_dir} ||= $VAR.'/log';
$c
};
$config_merged
diff --git a/lib/VNDB/ExtLinks.pm b/lib/VNDB/ExtLinks.pm
index 7c9a98ef..7d22ec32 100644
--- a/lib/VNDB/ExtLinks.pm
+++ b/lib/VNDB/ExtLinks.pm
@@ -6,7 +6,12 @@ use VNDB::Config;
use VNDB::Schema;
use Exporter 'import';
-our @EXPORT = ('sql_extlinks', 'enrich_extlinks', 'revision_extlinks', 'validate_extlinks');
+our @EXPORT = qw/
+ sql_extlinks
+ enrich_extlinks
+ revision_extlinks
+ validate_extlinks
+/;
# column name in wikidata table => \%info
@@ -40,15 +45,24 @@ our %WIKIDATA = (
crunchyroll => { type => 'text[]', property => 'P4110', label => undef, fmt => undef },
igdb_game => { type => 'text[]', property => 'P5794', label => 'IGDB', fmt => 'https://www.igdb.com/games/%s' },
giantbomb => { type => 'text[]', property => 'P5247', label => undef, fmt => undef },
- pcgamingwiki => { type => 'text[]', property => 'P6337', label => undef, fmt => undef },
+ pcgamingwiki => { type => 'text[]', property => 'P6337', label => 'PCGamingWiki', fmt => 'https://www.pcgamingwiki.com/wiki/%s' },
steam => { type => 'integer[]', property => 'P1733', label => undef, fmt => undef },
gog => { type => 'text[]', property => 'P2725', label => 'GOG', fmt => 'https://www.gog.com/game/%s' },
pixiv_user => { type => 'integer[]', property => 'P5435', label => 'Pixiv', fmt => 'https://www.pixiv.net/member.php?id=%d' },
doujinshi_author => { type => 'integer[]', property => 'P7511', label => 'Doujinshi.org', fmt => 'https://www.doujinshi.org/browse/author/%d/' },
+ soundcloud => { type => 'text[]', property => 'P3040', label => 'Soundcloud', fmt => 'https://soundcloud.com/%s' },
+ humblestore => { type => 'text[]', property => 'P4477', label => undef, fmt => undef },
+ itchio => { type => 'text[]', property => 'P7294', label => undef, fmt => undef },
+ playstation_jp => { type => 'text[]', property => 'P5999', label => undef, fmt => undef },
+ playstation_na => { type => 'text[]', property => 'P5944', label => undef, fmt => undef },
+ playstation_eu => { type => 'text[]', property => 'P5971', label => undef, fmt => undef },
+ lutris => { type => 'text[]', property => 'P7597', label => 'Lutris', fmt => 'https://lutris.net/games/%s' },
+ wine => { type => 'integer[]', property => 'P600', label => 'Wine AppDB', fmt => 'https://appdb.winehq.org/appview.php?iAppId=%d' },
);
# dbentry_type => column name => \%info
+# Column names are also used for AdvSearch filters, so they should be stable.
# info keys:
# label Name of the link
# fmt How to generate a url (basic version, printf-style only)
@@ -73,40 +87,45 @@ our %LINKS = (
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_erotrail => { label => 'ErogeTrailers'
- , fmt => 'http://erogetrailers.com/soft/%d'
- , regex => qr{(?:www\.)?erogetrailers\.com/soft/([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 (jpn)'
+ 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}).*}
+ , 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_dlsiteen => { label => 'DLsite (eng)'
- , fmt => 'https://www.dlsite.com/eng/work/=/product_id/%s.html'
- , fmt2 => sub { config->{dlsite_url} && sprintf config->{dlsite_url}, shift->{l_dlsiteen_shop}||'eng' }
- , regex => qr{(?:www\.)?dlsite\.com/.*/(?:dlaf/=/link/work/aid/.*/id|work/=/product_id)/([VR]E[0-9]{6}).*}
- , patt => 'https://www.dlsite.com/<store>/work/=/product_id/<VE or RE-code>' },
l_gog => { label => 'GOG'
, fmt => 'https://www.gog.com/game/%s'
- , regex => qr{(?:www\.)?gog\.com/game/([a-z0-9_]+).*} },
+ , 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?/([a-z0-9-]+).*} },
+ , regex => qr{(?:www\.)?denpasoft\.com/products?/([^/&#?:]+).*} },
l_jlist => { label => 'J-List'
- , fmt => 'https://www.jlist.com/%s'
- , fmt2 => sub { config->{ shift->{l_jlist_jbox} ? 'jbox_url' : 'jlist_url' } }
- , regex => qr{(?:www\.)?(?:jlist|jbox)\.com/(?:.+/)?([a-z0-9-]*[0-9][a-z0-9-]*)} },
+ , 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/%s'
- , regex => qr{(?:www\.)?jastusa\.com/([a-z0-9-]+)} },
+ , 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/([^/]+)(?:[/\?].*)?} },
@@ -122,6 +141,10 @@ our %LINKS = (
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]+)} },
@@ -149,7 +172,7 @@ our %LINKS = (
, 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]+)}
+ , 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+.
@@ -157,19 +180,79 @@ our %LINKS = (
, fmt => 'https://ec.toranoana.shop/tora/ec/item/%012d/'
, regex => qr{(?:www\.)?ecs?\.toranoana\.(?:shop|jp)/(?:aqua/ec|(?:tora|joshi)(?:/ec|_r/ec|_d/digi|_rd/digi)?)/item/([0-9]{12}).*}
, patt => 'https://ec.toranoana.<shop or jp>/<shop>/item/<number>/' },
+ l_booth => { label => 'BOOTH'
+ , fmt => 'https://booth.pm/en/items/%d'
+ , regex => qw{(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+).*}
+ , patt => 'https://booth.pm/<language>/items/<id> OR https://<publisher>.booth.pm/items/<id>' },
l_gamejolt => { label => 'Game Jolt'
, fmt => 'https://gamejolt.com/games/vn/%d', # /vn/ should be the game title, but it doesn't matter
, regex => qr{(?:www\.)?gamejolt\.com/games/(?:[^/]+)/([0-9]+)(?:/.*)?} },
l_nutaku => { label => 'Nutaku'
, fmt => 'https://www.nutaku.net/games/%s/'
, regex => qr{(?:www\.)?nutaku\.net/games/(?:mobile/|download/|app/)?([a-z0-9-]+)/?} }, # The section part does sometimes link to different pages, but it's the same game and the non-section link always works.
+ l_playstation_jp => { label => 'PlayStation Store (JP)'
+ , fmt => 'https://store.playstation.com/ja-jp/product/%s'
+ , regex => qr{store\.playstation\.com/(?:[-a-z]+\/)?product\/(JP\d{4}-[A-Z]{4}\d{5}_00-[\dA-Z_]{16})} },
+ l_playstation_na => { label => 'PlayStation Store (NA)'
+ , fmt => 'https://store.playstation.com/en-us/product/%s'
+ , regex => qr{store\.playstation\.com/(?:[-a-z]+\/)?product\/(UP\d{4}-[A-Z]{4}\d{5}_00-[\dA-Z_]{16})} },
+ l_playstation_eu => { label => 'PlayStation Store (EU)'
+ , fmt => 'https://store.playstation.com/en-gb/product/%s'
+ , regex => qr{store\.playstation\.com/(?:[-a-z]+\/)?product\/(EP\d{4}-[A-Z]{4}\d{5}_00-[\dA-Z_]{16})} },
+ l_playstation_hk => { label => 'PlayStation Store (HK)'
+ , fmt => 'https://store.playstation.com/en-hk/product/%s'
+ , regex => qr{store\.playstation\.com/(?:[-a-z]+\/)?product\/(HP\d{4}-[A-Z]{4}\d{5}_00-[\dA-Z_]{16})} },
+ l_nintendo => { label => 'Nintendo'
+ , fmt => 'https://www.nintendo.com/store/products/%s/'
+ , regex => qr{www\.nintendo\.com\/store\/products\/([-a-z0-9]+-(?:switch|wii-u|3ds))\/} },
+ l_nintendo_jp => { label => 'Nintendo (JP)'
+ , fmt => 'https://store-jp.nintendo.com/list/software/%d.html'
+ , regex => qr{store-jp\.nintendo\.com/list/software/([0-9]+).html} },
+ l_nintendo_hk => { label => 'Nintendo (HK)'
+ , fmt => 'https://store.nintendo.com.hk/%d'
+ , regex => qr{store\.nintendo\.com\.hk/([0-9]+)} },
+ # deprecated
+ l_dlsiteen => { label => 'DLsite (eng)', fmt => 'https://www.dlsite.com/eng/work/=/product_id/%s.html' },
+ l_erotrail => { label => 'ErogeTrailers', fmt => 'http://erogetrailers.com/soft/%d' },
},
s => {
l_site => { label => 'Official website', fmt => '%s' },
- l_wikidata => { label => 'Wikidata', fmt => 'https://www.wikidata.org/wiki/Q%d' },
- l_twitter => { label => 'Twitter', fmt => 'https://twitter.com/%s' },
- l_anidb => { label => 'AniDB', fmt => 'https://anidb.net/cr%s' },
- l_pixiv => { label => 'Pixiv', fmt => 'https://www.pixiv.net/member.php?id=%d' },
+ l_wikidata => { label => 'Wikidata'
+ , fmt => 'https://www.wikidata.org/wiki/Q%d'
+ , regex => qr{www\.wikidata\.org/wiki/Q([1-9][0-9]*)} },
+ l_twitter => { label => 'Xitter'
+ , fmt => 'https://twitter.com/%s'
+ , regex => qr{(?:(?:www\.)?twitter\.com|nitter\.[^/]+)/([^?\/ ]{1,16})(?:[?/].*)?} },
+ l_anidb => { label => 'AniDB'
+ , fmt => 'https://anidb.net/cr%s'
+ , regex => qr{anidb\.net/(?:cr|creator/)([1-9][0-9]*)} },
+ l_pixiv => { label => 'Pixiv'
+ , fmt => 'https://www.pixiv.net/member.php?id=%d'
+ , regex => qr{www\.pixiv\.net/(?:member\.php\?id=|en/users/|users/)([0-9]+)} },
+ l_vgmdb => { label => 'VGMdb'
+ , fmt => 'https://vgmdb.net/artist/%d'
+ , regex => qr{vgmdb\.net/artist/([0-9]+)} },
+ l_discogs => { label => 'Discogs'
+ , fmt => 'https://www.discogs.com/artist/%d'
+ , regex => qr{(?:www\.)?discogs\.com/artist/([0-9]+)(?:[?/-].*)?} },
+ l_mobygames=> { label => 'MobyGames'
+ , fmt => 'https://www.mobygames.com/person/%d'
+ , regex => qr{(?:www\.)?mobygames\.com/person/([0-9]+)(?:[?/].*)?} },
+ l_bgmtv => { label => 'Bangumi'
+ , fmt => 'https://bgm.tv/person/%d'
+ , regex => qr{(?:www\.)?(?:bgm|bangumi)\.tv/person/([0-9]+)(?:[?/].*)?} },
+ l_imdb => { label => 'IMDb'
+ , fmt => 'https://www.imdb.com/name/nm%07d'
+ , regex => qr{(?:www\.)?imdb\.com/name/nm([0-9]{7,8})(?:[?/].*)?} },
+ l_mbrainz => { label => 'MusicBrainz'
+ , fmt => 'https://musicbrainz.org/artist/%s'
+ , regex => qr{musicbrainz\.org/artist/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})} },
+ l_scloud => { label => 'SoundCloud'
+ , fmt => 'https://soundcloud.com/%s'
+ , regex => qr{soundcloud\.com/([a-z0-9-]+)} },
+ l_vndb => { label => 'VNDB user'
+ , fmt => 'https://vndb.org/%s'
+ , regex => qr{vndb\.org/(u[1-9][0-9]*)} },
# deprecated
l_wp => { label => 'Wikipedia', fmt => 'https://en.wikipedia.org/wiki/%s' },
},
@@ -187,7 +270,7 @@ sub sql_extlinks {
my($type, $prefix) = @_;
$prefix ||= '';
my $l = $LINKS{$type} || die "DB entry type $type has no links";
- VNWeb::DB::sql_comma(map $prefix.$_, sort keys %$l)
+ join ',', map $prefix.$_, sort keys %$l
}
@@ -195,13 +278,14 @@ sub sql_extlinks {
# following field to each object:
#
# extlinks => [
-# [ $title, $url, $price ],
+# { name, label, id, url, url2, price }, # depending on which fields are $enabled
# ..
# ]
#
-# (It also adds a few other fields in some cases, but you can ignore those)
+# Assumes the columns returned by sql_extlinks() are already available.
sub enrich_extlinks {
- my($type, @obj) = @_;
+ my($type, $enabled, @obj) = @_;
+ $enabled ||= { label => 1, url2 => 1, price => 1 };
@obj = map ref $_ eq 'ARRAY' ? @$_ : ($_), @obj;
my $l = $LINKS{$type} || die "DB entry type $type has no links";
@@ -210,27 +294,38 @@ sub enrich_extlinks {
my $w = @w_ids ? { map +($_->{id}, $_), $TUWF::OBJ->dbAlli('SELECT * FROM wikidata WHERE id IN', \@w_ids)->@* } : {};
# Fetch shop info for releases
+ my @cleanup;
if($type eq 'r') {
VNWeb::DB::enrich_merge(id => q{
SELECT r.id
, smg.price AS l_mg_price, smg.r18 AS l_mg_r18
, sdenpa.price AS l_denpa_price
- , sjlist.price AS l_jlist_price, sjlist.jbox AS l_jlist_jbox
+ , sjast.price AS l_jast_price, sjast.slug AS l_jast_slug
+ , sjlist.price AS l_jlist_price
, sdlsite.price AS l_dlsite_price, sdlsite.shop AS l_dlsite_shop
- , sdlsiteen.price AS l_dlsiteen_price, sdlsiteen.shop AS l_dlsiteen_shop
FROM releases r
LEFT JOIN shop_denpa sdenpa ON sdenpa.id = r.l_denpa AND sdenpa.lastfetch IS NOT NULL AND sdenpa.deadsince IS NULL
LEFT JOIN shop_dlsite sdlsite ON sdlsite.id = r.l_dlsite AND sdlsite.lastfetch IS NOT NULL AND sdlsite.deadsince IS NULL
- LEFT JOIN shop_dlsite sdlsiteen ON sdlsiteen.id = r.l_dlsiteen AND sdlsiteen.lastfetch IS NOT NULL AND sdlsiteen.deadsince IS NULL
+ LEFT JOIN shop_jastusa sjast ON sjast.id = r.l_jastusa AND sjast.lastfetch IS NOT NULL AND sjast.deadsince IS NULL
LEFT JOIN shop_jlist sjlist ON sjlist.id = r.l_jlist AND sjlist.lastfetch IS NOT NULL AND sjlist.deadsince IS NULL
LEFT JOIN shop_mg smg ON smg.id = r.l_mg AND smg.lastfetch IS NOT NULL AND smg.deadsince IS NULL
WHERE r.id IN},
- grep $_->{l_mg}||$_->{l_denpa}||$_->{l_jlist}||$_->{l_dlsite}||$_->{l_dlsiteen}, @obj
- );
- VNWeb::DB::enrich(l_playasia => gtin => gtin =>
- "SELECT gtin, price, url FROM shop_playasia WHERE price <> '' AND gtin IN",
- grep $_->{gtin}, @obj
- );
+ grep $_->{l_mg}||$_->{l_denpa}||$_->{l_jastusa}||$_->{l_jlist}||$_->{l_dlsite}, @obj
+ ) if $enabled->{price} || $enabled->{url2};
+
+ if(grep exists $_->{gtin}, @obj) {
+ VNWeb::DB::enrich(l_playasia => gtin => gtin =>
+ "SELECT gtin, price, url FROM shop_playasia WHERE price <> '' AND gtin IN",
+ grep $_->{gtin}, @obj
+ );
+ } else {
+ VNWeb::DB::enrich(l_playasia => id => id =>
+ "SELECT r.id, s.gtin, s.price, s.url FROM releases r JOIN shop_playasia s ON s.gtin = r.gtin WHERE s.price <> '' AND r.id IN",
+ @obj
+ );
+ }
+
+ @cleanup = qw{l_mg_price l_mg_r18 l_denpa_price l_jast_price l_jast_slug l_jlist_price l_dlsite_price l_dlsite_shop l_playasia};
}
for my $obj (@obj) {
@@ -238,12 +333,36 @@ sub enrich_extlinks {
my sub w {
return if !$obj->{l_wikidata};
my($v, $fmt, $label) = ($w->{$obj->{l_wikidata}}{$_[0]}, @{$WIKIDATA{$_[0]}}{'fmt', 'label'});
- push @links, map [ $label, ref $fmt ? $fmt->($_) : sprintf($fmt, $_), undef ], ref $v ? @$v : $v ? $v : ()
+ push @links, map +{
+ $enabled->{name} ? (name => $_[0]) : (),
+ $enabled->{label} ? (label => $label) : (),
+ $enabled->{id} ? (id => $_) : (),
+ $enabled->{url} ? (url => ref $fmt ? $fmt->($_) : sprintf $fmt, $_) : (),
+ $enabled->{url2} ? (url2 => ref $fmt ? $fmt->($_) : sprintf $fmt, $_) : (),
+ }, ref $v ? @$v : $v ? $v : ()
}
my sub l {
my($f, $price) = @_;
my($v, $fmt, $fmt2, $label) = ($obj->{$f}, $l->{$f} ? @{$l->{$f}}{'fmt', 'fmt2', 'label'} : ());
- push @links, map [ $label, sprintf((ref $fmt2 ? $fmt2->($obj) : $fmt2) || $fmt, $_), $price ], ref $v ? @$v : $v ? $v : ()
+ 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';
@@ -261,29 +380,34 @@ sub enrich_extlinks {
w 'indiedb_game';
w 'howlongtobeat';
w 'igdb_game';
+ w 'pcgamingwiki';
+ w 'lutris';
+ w 'wine';
l 'l_renai';
- push @links, [ 'VNStat', sprintf('https://vnstat.net/novel/%d', $obj->{id} =~ s/^.//r), undef ] if $obj->{c_votecount}>=20;
+ c 'vnstat', 'VNStat', 'https://vnstat.net/novel/%d', $obj->{id} =~ s/^.//r if $obj->{c_votecount}>=20;
}
# Release links
if($type eq 'r') {
l 'l_egs';
- l 'l_erotrail';
l 'l_steam';
- push @links, [ 'SteamDB', sprintf('https://steamdb.info/app/%d/info', $obj->{l_steam}), undef ] if $obj->{l_steam};
+ c 'steamdb', 'SteamDB', 'https://steamdb.info/app/%d/info', $obj->{l_steam} if $obj->{l_steam};
l 'l_dlsite', $obj->{l_dlsite_price};
- l 'l_dlsiteen', $obj->{l_dlsiteen_price};
l 'l_gog';
l 'l_itch';
+ l 'l_patreonp';
+ l 'l_patreon';
+ l 'l_substar';
l 'l_gamejolt';
l 'l_denpa', $obj->{l_denpa_price};
l 'l_jlist', $obj->{l_jlist_price};
- l 'l_jastusa';
+ l 'l_jastusa', $obj->{l_jast_price};
l 'l_fakku';
l 'l_appstore';
l 'l_googplay';
l 'l_animateg';
l 'l_freem';
+ l 'l_freegame';
l 'l_novelgam';
l 'l_gyutto';
l 'l_digiket';
@@ -295,7 +419,15 @@ sub enrich_extlinks {
l 'l_getchudl';
l 'l_dmm';
l 'l_toranoana';
- push @links, map [ 'PlayAsia', $_->{url}, $_->{price} ], @{$obj->{l_playasia}} if $obj->{l_playasia};
+ 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
@@ -303,10 +435,15 @@ sub enrich_extlinks {
l 'l_twitter'; w 'twitter' if !$obj->{l_twitter};
l 'l_anidb'; w 'anidb_person' if !$obj->{l_anidb};
l 'l_pixiv'; w 'pixiv_user' if !$obj->{l_pixiv};
- w 'musicbrainz_artist';
- w 'vgmdb_artist';
- w 'discogs_artist';
- w 'doujinshi_author';
+ l 'l_mbrainz'; w 'musicbrainz_artist' if !$obj->{l_mbrainz};
+ l 'l_vgmdb'; w 'vgmdb_artist' if !$obj->{l_vgmdb};
+ l 'l_discogs'; w 'discogs_artist' if !$obj->{l_discogs};
+ l 'l_scloud'; w 'soundcloud' if !$obj->{l_scloud};
+ l 'l_mobygames';
+ l 'l_bgmtv';
+ l 'l_imdb';
+ l 'l_vndb';
+ #w 'doujinshi_author';
}
# Producer links
@@ -314,11 +451,13 @@ sub enrich_extlinks {
w 'twitter';
w 'mobygames_company';
w 'gamefaqs_company';
- w 'doujinshi_author';
- push @links, [ 'VNStat', sprintf('https://vnstat.net/developer/%d', $obj->{id} =~ s/^.//r), undef ];
+ #w 'doujinshi_author';
+ w 'soundcloud';
+ c 'vnstat', 'VNStat', 'https://vnstat.net/developer/%d', $obj->{id} =~ s/^.//r;
}
- $obj->{extlinks} = \@links
+ $obj->{extlinks} = \@links;
+ delete @{$obj}{ @cleanup };
}
}
@@ -337,31 +476,30 @@ sub revision_extlinks {
sub full_regex { qr{^(?:https?://)?$_[0](?:\#.*)?$} }
-# Returns a TUWF::Validate schema for a hash with links for the given entry type.
+# 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->%*;
- +{ type => 'hash', keys => {
- 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{func} = sub { $val{int} && !$_[0] ? 1 : sprintf($p->{fmt}, $_[0]) =~ full_regex $p->{regex} };
- ($f, $s->{type} =~ /\[\]/
- ? { type => 'array', values => \%val }
- : { required => 0, default => $val{int} ? 0 : '', %val }
- )
- } sort grep $LINKS{$type}{$_}{regex}, keys $LINKS{$type}->%*
- } }
+ 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:
-# { id => $id, name => $label, fmt => $label, regex => $regex, int => $bool, multi => $bool, default => 0||'""'||'[]', pattern => [..] }
+# 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->%*;
@@ -370,8 +508,8 @@ sub extlinks_sites {
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, multi => $s->{type} =~ /\[\]/?1:0
- , default => $s->{type} =~ /\[\]/ ? '[]' : $s->{type} =~ /^(big)?int/ ? 0 : '""'
+ , 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}->%*
}
diff --git a/lib/VNDB/Func.pm b/lib/VNDB/Func.pm
index e1f6ac13..8c448ad8 100644
--- a/lib/VNDB/Func.pm
+++ b/lib/VNDB/Func.pm
@@ -4,10 +4,9 @@ use strict;
use warnings;
use TUWF::Misc 'uri_escape';
use Exporter 'import';
-use POSIX 'strftime';
-use Encode 'encode_utf8';
-use Unicode::Normalize 'NFKD', 'compose';
+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;
@@ -17,15 +16,16 @@ our @EXPORT = ('bb_format', qw|
shorten
resolution
gtintype
- normalize_titles normalize_query
imgsize
norm_ip
minage
- fmtvote fmtmedia fmtage fmtdate fmtrating fmtspoil
+ fmtvote fmtmedia fmtage fmtdate fmtrating fmtspoil fmtanimation
+ rdate
imgpath imgurl
- lang_attr
+ tlang tattr
query_encode
md2html
+ is_insecurepass
|);
@@ -64,7 +64,7 @@ sub resolution {
# GTIN code as argument,
-# Returns 'JAN', 'EAN', 'UPC' or undef,
+# Returns 'JAN', 'EAN', 'UPC', 'ISBN' or undef,
# Also 'normalizes' the first argument in place
sub gtintype {
$_[0] =~ s/[^\d]+//g;
@@ -88,65 +88,12 @@ sub gtintype {
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 '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 :)
}
-# 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
@@ -160,7 +107,7 @@ sub imgsize {
$ow *= $sh/$oh;
$oh = $sh;
}
- return (int $ow, int $oh);
+ return (int ($ow+0.5), int ($oh+0.5));
}
@@ -198,14 +145,15 @@ sub minage {
sub _path {
my($t, $id) = $_[1] =~ /([a-z]+)([0-9]+)/;
- $t = 'st' if $t eq 'sf' && $_[2];
- sprintf '%s/%s/%02d/%d.jpg', $_[0], $t, $id%100, $id;
+ sprintf '%s/%s%s/%02d/%d.%s', $_[0], $t, $_[2] ? ".$_[2]" : '', $id%100, $id, $_[3]||'jpg';
}
-# imgpath($image_id, $thumb)
-sub imgpath { _path config->{root}.'/static', @_ }
+# imgpath($image_id, $dir, $format)
+# $dir = empty || 't' || 'orig'
+# $format = empty || $file_ext
+sub imgpath { _path config->{var_path}.'/static', @_ }
-# imgurl($image_id, $thumb)
+# imgurl($image_id, $dir, $format)
sub imgurl { _path config->{url_static}, @_ }
@@ -242,8 +190,8 @@ sub fmtage {
# argument: unix timestamp and optional format (compact/full)
sub fmtdate {
my($t, $f) = @_;
- return strftime '%Y-%m-%d', gmtime $t if !$f || $f eq 'compact';
- return strftime '%Y-%m-%d at %R', gmtime $t;
+ return strftime '%Y-%m-%d', localtime $t if !$f || $f eq 'compact';
+ return strftime '%Y-%m-%d at %R', localtime $t;
}
# Turn a (natural number) vote into a rating indication
@@ -269,16 +217,46 @@ sub fmtspoil {
}
-# Generates a HTML 'lang' attribute given a list of possible languages.
-# This is used for the 'original language' field, which we can safely assume is not used for latin-alphabet languages.
-sub lang_attr {
- my @l = ref $_[0] ? $_[0]->@* : @_;
- # Choose Japanese, Chinese or Korean (in order of likelyness) if those are in the list.
- return (lang => 'ja') if grep $_ eq 'ja', @l;
- return (lang => 'zh') if grep $_ eq 'zh', @l;
- return (lang => 'ko') if grep $_ eq 'ko', @l;
- return (lang => $l[0]) if @l == 1;
- ()
+sub fmtanimation {
+ my($a, $cat) = @_;
+ return if !defined $a;
+ return $cat ? ucfirst "$cat not animated" : 'Not animated' if !$a;
+ return $cat ? "No $cat" : 'Not applicable' if $a == 1;
+ ($a & 256 ? 'Some scenes ' : $a & 512 ? 'All scenes ' : '').join('/',
+ $a & 4 ? 'Hand drawn' : (),
+ $a & 8 ? 'Vectorial' : (),
+ $a & 16 ? '3D' : (),
+ $a & 32 ? 'Live action' : ()
+ ).($cat ? " $cat" : '');
+}
+
+
+# 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);
+}
+
+
+# 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'))
+ : ();
+}
+
+
+# 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])
}
@@ -323,4 +301,34 @@ sub md2html {
$html
}
+
+sub is_insecurepass {
+ utf8::encode(local $_ = shift);
+ my $hash = sha1 $_;
+ my $dir = config->{var_path}.'/hibp';
+ return 0 if !-d $dir;
+
+ my $prefix = uc unpack 'H4', $hash;
+ my $data = substr $hash, 2, 10;
+ my $F;
+ if(!open $F, '<', "$dir/$prefix") {
+ warn "Unable to lookup password prefix $prefix: $!";
+ return 0;
+ }
+
+ # Plain old binary search.
+ # Would be nicer to search through an mmap'ed view of the file, or at least
+ # use pread(), but alas, neither are easily available in Perl.
+ my($left, $right) = (0, -10 + -s $F);
+ while($left <= $right) {
+ my $off = floor(($left+$right)/20)*10;
+ sysseek $F, $off, 0 or die $!;
+ 10 == sysread $F, my $buf, 10 or die $!;
+ return 1 if $buf eq $data;
+ if($buf lt $data) { $left = $off + 10; }
+ else { $right = $off - 10; }
+ }
+ 0;
+}
+
1;
diff --git a/lib/VNDB/Schema.pm b/lib/VNDB/Schema.pm
index 63c0f258..ffc80e77 100644
--- a/lib/VNDB/Schema.pm
+++ b/lib/VNDB/Schema.pm
@@ -23,9 +23,11 @@ my $ROOT = $INC{'VNDB/Schema.pm'} =~ s{/lib/VNDB/Schema\.pm}{}r;
# type => 'serial',
# decl => 'id SERIAL', # full declaration, exluding comments and PRIMARY KEY marker
# pub => 1,
+# comment => '',
# }, ...
# ],
# primary => ['id'],
+# comment => '',
# }
# }
sub schema {
@@ -35,17 +37,18 @@ sub schema {
while(<$F>) {
chomp;
next if /^\s*--/ || /^\s*$/;
- next if /^\s*CREATE\s+TYPE/;
- next if /^\s*CREATE\s+SEQUENCE/;
+ next if /^\s*CREATE\s+(?:TYPE|SEQUENCE|FUNCTION|DOMAIN|VIEW)/;
if(/^\s*CREATE\s+TABLE\s+([^ ]+)/) {
die "Unexpected 'CREATE TABLE $1'\n" if $table;
+ next if /PARTITION OF/;
$table = $1;
$schema{$table}{name} = $table;
- $schema{$table}{dbentry_type} = $1 if /--.*\s+dbentry_type=(.)/;
+ $schema{$table}{comment} = /--\s*(.*)\s*/ ? $1 : '';
+ $schema{$table}{dbentry_type} = $1 if $schema{$table}{comment} =~ s/\s*dbentry_type=(.)\s*//;
$schema{$table}{cols} = [];
- } elsif(/^\s*\);/) {
+ } elsif(/^\s*\)(?: PARTITION .+)?;/) {
$table = undef;
} elsif(/^\s+(?:CHECK|CONSTRAINT)/) {
@@ -55,22 +58,19 @@ sub schema {
die "Double primary key for '$table'?\n" if $schema{$table}{primary};
$schema{$table}{primary} = [ map s/\s*"?([^\s"]+)"?\s*/$1/r, split /,/, $1 ];
- } elsif($table && s/^\s+"?([^"\( ]+)"?\s+//) {
+ } elsif($table && s/^\s+([^"\( ]+)\s+//) {
my $col = { name => $1 };
push @{$schema{$table}{cols}}, $col;
- $col->{pub} = /--.*\[pub\]/;
- s/,?\s*(?:--.*)?$//;
+ $col->{comment} = (s/,?\s*(?:--(.*))?$// && $1) || '';
+ $col->{pub} = $col->{comment} =~ s/\s*\[pub\]\s*//;
if(s/\s+PRIMARY\s+KEY//i) {
die "Double primary key for '$table'?\n" if $schema{$table}{primary};
$schema{$table}{primary} = [ $col->{name} ];
}
- $col->{decl} = "\"$col->{name}\" $_";
+ $col->{decl} = "$col->{name} $_";
$col->{type} = lc s/^([^ ]+)\s.+/$1/r;
-
- } else {
- die "Unrecognized line in schema.sql: $_\n";
}
}
@@ -89,7 +89,7 @@ sub types {
open my $F, '<', "$ROOT/sql/schema.sql" or die "schema.sql: $!";
while(<$F>) {
chomp;
- if(/^CREATE TYPE ([^ ]+)/) {
+ if(/^CREATE (?:TYPE|DOMAIN) ([^ ]+)/) {
$types{$1} = { decl => $_ };
}
}
@@ -118,9 +118,9 @@ sub references {
decl => $_,
from_table => $1,
name => $2,
- from_cols => [ map s/"//r, split /\s*,\s*/, $3 ],
+ from_cols => [ split /\s*,\s*/, $3 ],
to_table => $4,
- to_cols => [ map s/"//r, split /\s*,\s*/, $5 ]
+ to_cols => [ split /\s*,\s*/, $5 ]
};
}
\@ref
diff --git a/lib/VNDB/Types.pm b/lib/VNDB/Types.pm
index 9c36a4ec..16f730c5 100644
--- a/lib/VNDB/Types.pm
+++ b/lib/VNDB/Types.pm
@@ -15,49 +15,61 @@ sub hash {
# SQL: ENUM language
+# 'latin' indicates whether the language is primarily written in a latin-ish script.
+# 'rank' is for quick selection of commonly used languages.
hash LANGUAGE =>
- ar => 'Arabic',
- bg => 'Bulgarian',
- ca => 'Catalan',
- cs => 'Czech',
- da => 'Danish',
- de => 'German',
- el => 'Greek',
- en => 'English',
- eo => 'Esperanto',
- es => 'Spanish',
- fa => 'Persian',
- fi => 'Finnish',
- fr => 'French',
- ga => 'Irish',
- gd => 'Scottish Gaelic',
- he => 'Hebrew',
- hr => 'Croatian',
- hu => 'Hungarian',
- id => 'Indonesian',
- it => 'Italian',
- ja => 'Japanese',
- ko => 'Korean',
- mk => 'Macedonian',
- ms => 'Malay',
- lt => 'Lithuanian',
- lv => 'Latvian',
- nl => 'Dutch',
- no => 'Norwegian',
- pl => 'Polish',
- 'pt-br' => 'Portuguese (Brazil)',
- 'pt-pt' => 'Portuguese (Portugal)',
- ro => 'Romanian',
- ru => 'Russian',
- sk => 'Slovak',
- sl => 'Slovene',
- sv => 'Swedish',
- ta => 'Tagalog',
- th => 'Thai',
- tr => 'Turkish',
- uk => 'Ukrainian',
- vi => 'Vietnamese',
- zh => 'Chinese';
+ ar => { latin => 0, rank => 0, txt => 'Arabic' },
+ eu => { latin => 1, rank => 0, txt => 'Basque' },
+ be => { latin => 0, rank => 0, txt => 'Belarusian' },
+ bg => { latin => 1, rank => 0, txt => 'Bulgarian' },
+ ca => { latin => 1, rank => 0, txt => 'Catalan' },
+ ck => { latin => 0, rank => 0, txt => 'Cherokee' }, # 'chr' in ISO 639-2 but not present in ISO 639-1, let's just use an unassigned code
+ zh => { latin => 0, rank => 2, txt => 'Chinese' },
+ 'zh-Hans'=> { latin => 0, rank => 2, txt => 'Chinese (simplified)' },
+ 'zh-Hant'=> { latin => 0, rank => 2, txt => 'Chinese (traditional)' },
+ hr => { latin => 1, rank => 0, txt => 'Croatian' },
+ cs => { latin => 1, rank => 0, txt => 'Czech' },
+ da => { latin => 1, rank => 0, txt => 'Danish' },
+ nl => { latin => 1, rank => 0, txt => 'Dutch' },
+ en => { latin => 1, rank => 3, txt => 'English' },
+ eo => { latin => 1, rank => 0, txt => 'Esperanto' },
+ fi => { latin => 1, rank => 0, txt => 'Finnish' },
+ fr => { latin => 1, rank => 1, txt => 'French' },
+ de => { latin => 1, rank => 1, txt => 'German' },
+ el => { latin => 0, rank => 0, txt => 'Greek' },
+ he => { latin => 0, rank => 0, txt => 'Hebrew' },
+ hi => { latin => 0, rank => 0, txt => 'Hindi' },
+ hu => { latin => 1, rank => 0, txt => 'Hungarian' },
+ ga => { latin => 1, rank => 0, txt => 'Irish' },
+ id => { latin => 1, rank => 0, txt => 'Indonesian' },
+ it => { latin => 1, rank => 0, txt => 'Italian' },
+ iu => { latin => 1, rank => 0, txt => 'Inuktitut' },
+ ja => { latin => 0, rank => 4, txt => 'Japanese' },
+ ko => { latin => 0, rank => 1, txt => 'Korean' },
+ la => { latin => 1, rank => 0, txt => 'Latin' },
+ lv => { latin => 1, rank => 0, txt => 'Latvian' },
+ lt => { latin => 1, rank => 0, txt => 'Lithuanian' },
+ mk => { latin => 1, rank => 0, txt => 'Macedonian' },
+ ms => { latin => 1, rank => 0, txt => 'Malay' },
+ no => { latin => 1, rank => 0, txt => 'Norwegian' },
+ fa => { latin => 0, rank => 0, txt => 'Persian' },
+ pl => { latin => 1, rank => 0, txt => 'Polish' },
+ 'pt-br' => { latin => 1, rank => 1, txt => 'Portuguese (Brazil)' },
+ 'pt-pt' => { latin => 1, rank => 1, txt => 'Portuguese (Portugal)' },
+ ro => { latin => 1, rank => 0, txt => 'Romanian' },
+ ru => { latin => 0, rank => 2, txt => 'Russian' },
+ gd => { latin => 1, rank => 0, txt => 'Scottish Gaelic' },
+ sr => { latin => 1, rank => 0, txt => 'Serbian' },
+ sk => { latin => 0, rank => 0, txt => 'Slovak' },
+ sl => { latin => 1, rank => 0, txt => 'Slovene' },
+ es => { latin => 1, rank => 1, txt => 'Spanish' },
+ sv => { latin => 1, rank => 0, txt => 'Swedish' },
+ ta => { latin => 1, rank => 0, txt => 'Tagalog' },
+ th => { latin => 0, rank => 0, txt => 'Thai' },
+ tr => { latin => 1, rank => 0, txt => 'Turkish' },
+ uk => { latin => 0, rank => 1, txt => 'Ukrainian' },
+ ur => { latin => 0, rank => 0, txt => 'Urdu' },
+ vi => { latin => 1, rank => 1, txt => 'Vietnamese' };
@@ -68,13 +80,17 @@ hash PLATFORM =>
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 => 'Famicon',
+ nes => 'Famicom',
+ sfc => 'Super Famicom',
+ fm7 => 'FM-7',
+ fm8 => 'FM-8',
fmt => 'FM Towns',
gba => 'Game Boy Advance',
gbc => 'Game Boy Color',
@@ -93,13 +109,19 @@ hash PLATFORM =>
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',
- sfc => 'Super Nintendo',
- x68 => 'X68000',
+ 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';
@@ -118,6 +140,22 @@ hash VN_RELATION =>
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)
@@ -144,22 +182,25 @@ hash PRODUCER_TYPE =>
# SQL: ENUM credit_type
hash CREDIT_TYPE =>
scenario => 'Scenario',
+ director => 'Director',
chardesign => 'Character design',
art => 'Artist',
music => 'Composer',
songs => 'Vocals',
- director => 'Director',
+ translator => 'Translator',
+ editor => 'Editor',
+ qa => 'Quality assurance',
staff => 'Staff';
hash VN_LENGTH =>
- 0 => { txt => 'Unknown', time => '' },
- 1 => { txt => 'Very short', time => '< 2 hours' },
- 2 => { txt => 'Short', time => '2 - 10 hours' },
- 3 => { txt => 'Medium', time => '10 - 30 hours' },
- 4 => { txt => 'Long', time => '30 - 50 hours' },
- 5 => { txt => 'Very long', time => '> 50 hours' };
+ 0 => { txt => 'Unknown', time => '', low => 0, high => 0 },
+ 1 => { txt => 'Very short', time => '< 2 hours', low => 1, high => 2*60 },
+ 2 => { txt => 'Short', time => '2 - 10 hours', low => 2*60, high => 10*60 },
+ 3 => { txt => 'Medium', time => '10 - 30 hours', low => 10*60, high => 30*60 },
+ 4 => { txt => 'Long', time => '30 - 50 hours', low => 30*60, high => 50*60 },
+ 5 => { txt => 'Very long', time => '> 50 hours', low => 50*60, high => 32767 };
@@ -185,7 +226,7 @@ hash TAG_CATEGORY =>
hash ANIMATED =>
0 => { txt => 'Unknown' },
- 1 => { txt => 'No animations' },
+ 1 => { txt => 'Not animated' },
2 => { txt => 'Simple animations' },
3 => { txt => 'Some fully animated scenes' },
4 => { txt => 'All scenes fully animated' };
@@ -203,6 +244,7 @@ hash 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 => '' },
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
index 3ecd272b..6f226b7f 100644
--- a/lib/VNWeb/AdvSearch.pm
+++ b/lib/VNWeb/AdvSearch.pm
@@ -14,12 +14,13 @@ use warnings;
use B;
use POSIX 'strftime';
use List::Util 'max';
-use TUWF;
+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/;
@@ -205,13 +206,15 @@ sub _dec_str {
_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);
+ my $c1 = ($alpha{_substr($s, $$i++, 1) // return} // return);
return $c1 if $c1 < 49;
- my $n = ($alpha{substr $s, $$i++, 1} // return);
+ 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) for (1..$c1-59+1);
+ $n = $n*64 + ($alpha{_substr($s, $$i++, 1) // return} // return) for (1..$c1-59+1);
$n + (689, 4785, 266929, 17044145, 1090785969)[$c1-59]
}
@@ -224,8 +227,8 @@ sub _dec_query {
[ $c1, $ops[$op],
$type == 0 ? (_dec_int($s, $i) // return) :
$type == 1 ? (_dec_query($s, $i) // return) :
- $type == 2 ? do { my $v = _unescape_str(substr $s, $$i, 2) // return; $$i += 2; $v } :
- $type == 3 ? do { my $v = _unescape_str(substr $s, $$i, 3) // return; $$i += 3; $v } :
+ $type == 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 ]
}
@@ -300,42 +303,40 @@ sub 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 'v.length =', \$_ };
+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_popularity', $_[0], \($_/100) };
-f v => 10 => 'rating', { uint => 1, range => [10, 100] }, sql => sub { sql 'v.c_rating <> 0 AND v.c_rating', $_[0], \$_ };
-f v => 11 => 'vote-count',{ uint => 1, range => [ 0,1<<30] }, sql => sub { sql 'v.c_votecount', $_[0], \$_ };
-f v => 61 => 'has-description', { uint => 1, range => [1,1] }, '=' => sub { 'v."desc" <> \'\'' };
-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, ')' : '1=0' };
-
-f v => 6 => 'developer-id',{ vndbid => 'p' },
- sql_list => sub {
- my($neg, $all, $val) = @_;
- sql 'v.id', $neg ? 'NOT' : '', 'IN(SELECT rv.vid FROM releases r JOIN releases_vn rv ON rv.id = r.id JOIN releases_producers rp ON rp.id = r.id
- WHERE NOT r.hidden AND rp.developer AND rp.pid IN', $val, $all && @$val > 1 ? ('GROUP BY rv.vid HAVING COUNT(rp.pid) =', \scalar @$val) : (), ')';
- };
-
-f v => 8 => 'tag', { type => 'any', func => \&_validate_tag },
- compact => sub { my $id = ($_->[0] =~ s/^g//r)*1; $_->[1] == 0 && $_->[2] == 0 ? $id : [ $id, int($_->[2]*5)*3 + $_->[1] ] },
- sql_list => \&_sql_where_tag;
+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 },
+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) : (), ')';
@@ -347,19 +348,24 @@ 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_alias sa ON sa.aid = vs.aid
- JOIN staff s ON s.id = sa.id
+ 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_lang WHERE lang IN', $val, $all && @$val > 1 ? ('GROUP BY id HAVING COUNT(lang) =', \scalar @$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', { required => 0, default => undef, enum => \%PLATFORM },
+f r => 4 => 'platform', { default => undef, enum => \%PLATFORM },
sql_list_grp => sub { defined $_ },
sql_list => sub {
my($neg, $all, $val) = @_;
@@ -367,68 +373,77 @@ f r => 4 => 'platform', { required => 0, default => undef, enum => \%PLATFORM }
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 => 6 => 'developer-id',{ vndbid => 'p' }, '=' => sub { sql 'r.id IN(SELECT id FROM releases_producers WHERE developer AND pid =', \$_, ')' };
-f r => 17 => 'producer-id', { vndbid => 'p' }, '=' => sub { sql 'r.id IN(SELECT id FROM releases_producers WHERE pid =', \$_, ')' };
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', { required => 0, default => undef, uint => 1, enum => \%AGE_RATING },
+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', { required => 0, default => undef, enum => \%MEDIUM },
+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', { uint => 1, enum => \%VOICED }, '=' => sub { sql 'NOT r.patch AND r.voiced =', \$_ };
+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', { required => 0, default => '' }, '=' => sub { sql 'r.engine =', \$_ };
-f r => 16 => 'rtype', { enum => \%RELEASE_TYPE }, '=' => sub { sql 'r.type =', \$_ };
+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 => 63 => 'doujin', { uint => 1, range => [1,1] }, '=' => sub { 'r.doujin' };
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 => 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 => 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 => 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', { required => 0, default => undef, uint => 1, max => 32767 },
+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', { required => 0, default => undef, uint => 1, max => 32767 },
+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', { required => 0, default => undef, uint => 1, max => 32767 },
+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', { required => 0, default => undef, uint => 1, max => 32767 },
+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', { required => 0, default => undef, uint => 1, max => 32767 },
+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', { required => 0, default => undef, enum => \%CUP_SIZE },
+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', { required => 0, default => undef, uint => 1, max => 32767 },
+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 => sub { my $id = ($_->[0] =~ s/^i//r)*1; $_->[1] == 0 ? $id : [ $id, int $_->[1] ] },
- sql_list => \&_sql_where_trait;
+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_alias sa ON sa.aid = vs.aid JOIN staff s ON s.id = sa.id WHERE NOT s.hidden AND', $_, ')' };
+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 both 'staff s' and 'staff_alias sa' - aliases are treated as separate rows.
+# 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' }, '=' => sub { sql 's.id = ', \$_ };
+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 : '' },
@@ -438,18 +453,94 @@ f s => 5 => 'role', { enum => [ 'seiyuu', keys %CREDIT_TYPE ] },
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 = sa.aid)' if $val->[0] eq 'seiyuu';
- sql 'sa.aid', $neg ? 'NOT' : '', 'IN(SELECT vs.aid FROM vn_staff vs WHERE vs.id = v.id AND vs.role IN', $val, @grp, ')';
+ 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 = sa.aid)' if $val->[0] eq 'seiyuu';
- sql 'sa.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, ')';
+ 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 or [$tag, int($minlevel*5)*3+$maxspoil] (for compact form) or [$tag, $maxspoil, $minlevel]. Normalizes to the latter.
+# 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]);
@@ -457,29 +548,51 @@ sub _validate_tag {
$_[0][0] = $v->data;
if($_[0]->@* == 2) { # compact form
return 0 if !defined $_[0][1] || ref $_[0][1] || $_[0][1] !~ /^[0-9]+$/;
- ($_[0][1],$_[0][2]) = ($_[0][1]%3, int($_[0][1]/3)/5);
+ ($_[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;
+ 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 or [$trait, $maxspoil]. Normalizes to the latter.
+# 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;
- $_[0]->@* == 2 && defined $_[0][1] && !ref $_[0][1] && $_[0][1] =~ /^[0-2]$/
+ 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] = [auth->uid(), $_[0]] if ref $_[0] ne 'ARRAY';
+ $_[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;
@@ -525,25 +638,27 @@ sub _validate_adv {
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) = @_; +{ required => 0, type => 'any', default => bless({type=>$t}, __PACKAGE__), func => sub { _validate_adv $t, @_ } } };
+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) = @_;
- +{ required => 0, type => 'any', default => bless({type=>$t}, __PACKAGE__), func => sub {
+ +{ type => 'any', default => bless({type=>$t}, __PACKAGE__), func => sub {
my $r = _validate_adv $t, @_;
- if(!$r || ref $r eq 'HASH') {
- warn "advsearch validation failed\n";
- $_[0] = bless {type=>$t,error=>1}, __PACKAGE__;
- }
+ $_[0] = bless {type=>$t,error=>1}, __PACKAGE__ if !$r || ref $r eq 'HASH';
1
} }
};
@@ -574,34 +689,46 @@ sub _canon {
}
-# sql_list function for tags
+# returns an sql_list function for tags
sub _sql_where_tag {
- my($neg, $all, $val) = @_;
- my %f; # spoiler -> rating -> list
- my @l;
- push $f{$_->[1]}{$_->[2]}->@*, $_->[0] for @$val;
- for my $s (keys %f) {
- for my $r (keys $f{$s}->%*) {
- push @l, sql_and
- $s < 2 ? sql('spoiler <=', \$s) : (),
- $r > 0 ? sql('rating >=', \$r) : (),
- sql('tag IN', [ map s/^.//r, $f{$s}{$r}->@* ]);
+ 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) : (), ')'
}
- sql 'v.id', $neg ? 'NOT' : (), 'IN(SELECT vid FROM tags_vn_inherit WHERE', sql_or(@l), $all && @$val > 1 ? ('GROUP BY vid HAVING COUNT(tag) =', \scalar @$val) : (), ')'
}
sub _sql_where_trait {
- my($neg, $all, $val) = @_;
- my %f; # spoiler -> list
- my @l;
- push $f{$_->[1]}->@*, $_->[0] for @$val;
- for my $s (keys %f) {
- push @l, sql_and
- $s < 2 ? sql('spoil <=', \$s) : (),
- sql('tid IN', [ map s/^.//r, $f{$s}->@* ]);
+ 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) : (), ')'
}
- sql 'c.id', $neg ? 'NOT' : (), 'IN(SELECT cid FROM traits_chars WHERE', sql_or(@l), $all && @$val > 1 ? ('GROUP BY cid HAVING COUNT(tid) =', \scalar @$val) : (), ')'
}
@@ -609,27 +736,32 @@ sub _sql_where_trait {
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;
- my $onlist = sql 'EXISTS(SELECT 1 FROM ulist_vns WHERE vid = v.id AND uid =', \$uid, ')';
- my $haslbl = sql 'EXISTS(SELECT 1 FROM ulist_vns_labels WHERE vid = v.id AND uid =', \$uid, 'AND lbl <>', \7, ')';
- return $neg ? sql 'NOT', $onlist, 'OR', $haslbl
- : sql $onlist,' AND NOT', $haslbl;
+ return sql $neg ? 'NOT' : (), 'EXISTS(SELECT 1 FROM ulist_vns WHERE vid = v.id AND uid =', \$uid, "AND labels IN('{}','{7}'))";
}
- # Simple, stupid and safe: Don't attempt to query anything if there's a private label.
- # This can be improved to allow querying/displaying results that *are* visible, but it's more complex and not that often needed.
if(!$own) {
- tuwf->req->{lblvis}{$uid} ||= { map +($_->{id},1), tuwf->dbAlli('SELECT id FROM ulist_labels WHERE NOT private AND uid =', \$uid)->@* };
+ # 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 '1=0' if grep !$vis->{$_}, @lbl;
+ 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_labels WHERE uid =', \$uid, 'AND lbl IN', \@lbl, $all && @lbl > 1 ? ('GROUP BY vid HAVING COUNT(lbl) =', \scalar @lbl) : (), ')'
+ 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',
+ ')'
}
@@ -715,7 +847,7 @@ sub _extract_ids {
} 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->{"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};
}
@@ -730,17 +862,17 @@ sub elm_search_query {
_extract_ids($self->{type}, $self->{query}, \%ids) if $self->{query};
$o{producers} = [ map +{id => $_}, grep /^p/, keys %ids ];
- enrich_merge id => 'SELECT id, name, original, hidden FROM producers WHERE id IN', $o{producers};
+ 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 => 'SELECT s.id, sa.aid, sa.name, sa.original FROM staff s JOIN staff_alias sa ON sa.aid = s.aid WHERE s.id IN', $o{staff};
+ 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 => $_=~s/^g//rg}, grep /^g/, keys %ids ];
- enrich_merge id => 'SELECT id, name, searchable, applicable, state FROM tags WHERE id IN', $o{tags};
+ $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 => $_=~s/^i//rg}, grep /^i/, keys %ids ];
- enrich_merge id => 'SELECT t.id, t.name, t.searchable, t.applicable, t.defaultspoil, t.state, g.id AS group_id, g.name AS group_name
- FROM traits t LEFT JOIN traits g ON g.id = t.group WHERE t.id IN', $o{traits};
+ $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};
@@ -752,10 +884,11 @@ sub elm_search_query {
sub elm_ {
- my($self) = @_;
+ my($self, $count, $time) = @_;
+ # TODO: labels can be lazily loaded to reduce page weight
state $schema ||= tuwf->compile({ type => 'hash', keys => {
- uid => { vndbid => 'u', required => 0 },
+ uid => { vndbid => 'u', default => undef },
labels => { aoh => { id => { uint => 1 }, label => {} } },
defaultSpoil => { uint => 1 },
saved => { aoh => { name => {}, query => {} } },
@@ -770,6 +903,21 @@ sub elm_ {
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;
+ }
}
@@ -781,6 +929,27 @@ sub 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) = @_;
diff --git a/lib/VNWeb/Auth.pm b/lib/VNWeb/Auth.pm
index 08ec4dad..442d46f4 100644
--- a/lib/VNWeb/Auth.pm
+++ b/lib/VNWeb/Auth.pm
@@ -7,7 +7,7 @@
# ..user is logged in
# }
#
-# my $success = auth->login($user, $pass);
+# my $success = auth->login($uid, $pass);
# auth->logout;
#
# my $uid = auth->uid;
@@ -27,8 +27,8 @@ use Carp 'croak';
use Digest::SHA qw|sha1 sha1_hex|;
use Crypt::URandom 'urandom';
use Crypt::ScryptKDF 'scrypt_raw';
-use Encode 'encode_utf8';
use MIME::Base64 'encode_base64url';
+use POSIX 'strftime';
use VNDB::Func 'norm_ip';
use VNDB::Config;
@@ -38,14 +38,24 @@ our @EXPORT = ('auth');
sub auth {
tuwf->req->{auth} ||= do {
- 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, '');
-
my $auth = __PACKAGE__->new();
- $auth->_load_session($uid, $token_e);
+ 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
};
- tuwf->req->{auth};
}
@@ -53,7 +63,7 @@ sub auth {
# have a lot of influence in this)
TUWF::set log_format => sub {
my(undef, $uri, $msg) = @_;
- sprintf "[%s] %s %s: %s\n", scalar localtime(), $uri, tuwf->req && auth ? auth->uid : '-', $msg;
+ sprintf "[%s UTC] %s %s: %s\n", strftime('%Y-%m-%d %H:%M:%S', gmtime), $uri, tuwf->req && tuwf->req->{auth} ? auth->uid : '-', $msg;
};
@@ -63,11 +73,11 @@ 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->permImgmod || auth->permBoardmod || auth->permTagmod }
+sub isMod { auth->permUsermod || auth->permDbmod || auth->permBoardmod || auth->permTagmod }
-my @perms = qw/board boardmod edit imgvote imgmod tag dbmod tagmod usermod review/;
+my @perms = qw/board boardmod edit imgvote tag dbmod tagmod usermod review lengthvote/;
sub listPerms { @perms }
@@ -83,10 +93,11 @@ for my $perm (@perms) {
# Pref(erences) are like permissions, we load these columns eagerly so they can
# be accessed through auth->pref().
my @pref_columns = qw/
- skin customcss
+ 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
/;
@@ -103,7 +114,8 @@ sub _preparepass {
my($self, $pass, $salt, $N, $r, $p) = @_;
($N, $r, $p) = @{$self->{scrypt_args}} if !$N;
$salt ||= urandom(8);
- unpack 'H*', pack 'NCCa8a*', $N, $r, $p, $salt, scrypt_raw(encode_utf8($pass), $self->{scrypt_salt} . $salt, $N, $r, $p, 32);
+ utf8::encode(my $utf8pass = $pass);
+ unpack 'H*', pack 'NCCa8a*', $N, $r, $p, $salt, scrypt_raw($utf8pass, $self->{scrypt_salt} . $salt, $N, $r, $p, 32);
}
@@ -120,23 +132,23 @@ sub _encpass {
# Arguments: self, uid, encpass
-# Returns: 0 on error, 1 on success
+# Returns: 0 on error, 1 on success, token on !pretend && deleted account
sub _create_session {
my($self, $uid, $encpass, $pretend) = @_;
my $token = urandom 20;
my $token_db = sha1_hex $token;
return 0 if !tuwf->dbVali('SELECT ',
- sql_func(user_login => \$uid, sql_fromhex($encpass), sql_fromhex $token_db)
+ sql_func(user_login => \$uid, \'web', sql_fromhex($encpass), sql_fromhex $token_db)
);
if($pretend) {
tuwf->dbExeci('SELECT', sql_func user_logout => \$uid, sql_fromhex $token_db);
+ return 1;
} else {
tuwf->resCookie(auth => unpack('H*', $token).'.'.$uid, httponly => 1, expires => time + 31536000);
- $self->_load_session($uid, $token_db);
+ return $self->_load_session($uid, $token_db) ? 1 : $token_db;
}
- return 1;
}
@@ -146,8 +158,11 @@ sub _load_session {
my $user = $uid ? tuwf->dbRowi(
'SELECT ', sql_user(), ',', sql_comma(@pref_columns, map "perm_$_", @perms), '
FROM users u
- WHERE id = ', \$uid,
- 'AND', sql_func(user_isvalidsession => 'id', sql_fromhex($token_db), \'web')
+ 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
@@ -155,6 +170,7 @@ sub _load_session {
$self->{user} = $user;
$self->{token} = $token_db;
+ $user->{user_id};
}
@@ -163,19 +179,17 @@ sub new {
scrypt_salt => config->{scrypt_salt}||die(),
scrypt_args => config->{scrypt_args}||[ 65536, 8, 1 ],
csrf_key => config->{form_salt}||die(),
+ user => {},
}, shift;
}
# Returns 1 on success, 0 on failure
-# When $pretend is true, it only tests if the user/pass combination is correct,
+# When $pretend is true, it only tests if the uid/pass combination is correct,
# but doesn't actually create a session.
sub login {
- my($self, $user, $pass, $pretend) = @_;
- return 0 if $self->uid || !$user || !$pass;
-
- my $uid = tuwf->dbVali('SELECT id FROM users WHERE username =', \$user);
- return 0 if !$uid;
+ my($self, $uid, $pass, $pretend) = @_;
+ return 0 if $self->uid || !$uid || !$pass;
my $encpass = $self->_encpass($uid, $pass);
return 0 if !$encpass;
$self->_create_session($uid, $encpass, $pretend);
@@ -190,24 +204,28 @@ sub logout {
}
+sub wasteTime {
+ my $self = shift;
+ $self->_preparepass(urandom(20));
+}
+
+
# Create a random token that can be used to reset the password.
-# Returns ($uid, $token) if the email address is found in the DB, () otherwise.
+# Returns ($uid, $email, $token) if the email address is found in the DB, () otherwise.
sub resetpass {
my(undef, $mail) = @_;
my $token = unpack 'H*', urandom(20);
- my $id = tuwf->dbVali(
- select => sql_func(user_resetpass => \$mail, sql_fromhex sha1_hex lc $token)
+ my $u = tuwf->dbRowi(
+ 'SELECT uid, mail FROM', sql_func(user_resetpass => \$mail, sql_fromhex sha1_hex lc $token), 'x(uid, mail)'
);
- return $id ? ($id, $token) : ();
+ return $u->{uid} ? ($u->{uid}, $u->{mail}, $token) : ();
}
# Checks if the password reset token is valid
sub isvalidtoken {
my(undef, $uid, $token) = @_;
- tuwf->dbVali(
- select => sql_func(user_isvalidsession => \$uid, sql_fromhex(sha1_hex lc $token), \'pass')
- );
+ tuwf->dbVali('SELECT', sql_func(user_validate_session => \$uid, sql_fromhex(sha1_hex lc $token), \'pass'), 'IS DISTINCT FROM NULL');
}
@@ -295,7 +313,7 @@ sub audit {
tuwf->dbExeci('INSERT INTO audit_log', {
by_uid => $self->uid(),
by_name => $self->{user}{user_name},
- by_ip => tuwf->reqIP(),
+ 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,
@@ -303,4 +321,84 @@ sub audit {
});
}
+
+
+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
index b08f1330..5927ccaf 100644
--- a/lib/VNWeb/Chars/Edit.pm
+++ b/lib/VNWeb/Chars/Edit.pm
@@ -6,41 +6,43 @@ use VNWeb::Releases::Lib;
my $FORM = {
- id => { required => 0, vndbid => 'c' },
- name => { maxlength => 200 },
- original => { required => 0, default => '', maxlength => 200 },
- alias => { required => 0, default => '', maxlength => 500 },
- desc => { required => 0, default => '', maxlength => 5000 },
+ 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=>{ required => 0, enum => \%GENDER },
- b_month => { required => 0, default => 0, uint => 1, range => [ 0, 12 ] },
- b_day => { required => 0, default => 0, uint => 1, range => [ 0, 31 ] },
- age => { required => 0, uint => 1, range => [ 0, 32767 ] },
- s_bust => { required => 0, uint => 1, range => [ 0, 32767 ], default => 0 },
- s_waist => { required => 0, uint => 1, range => [ 0, 32767 ], default => 0 },
- s_hip => { required => 0, uint => 1, range => [ 0, 32767 ], default => 0 },
- height => { required => 0, uint => 1, range => [ 0, 32767 ], default => 0 },
- weight => { required => 0, uint => 1, range => [ 0, 32767 ] },
+ 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 => { required => 0, default => '', enum => \%CUP_SIZE },
- main => { required => 0, vndbid => 'c' },
+ 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 => { required => 0, vndbid => 'ch' },
- image_info => { _when => 'out', required => 0, type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
+ 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 => { id => 1 },
+ tid => { vndbid => 'i' },
spoil => { uint => 1, range => [0,2] },
+ lie => { anybool => 1 },
name => { _when => 'out' },
- group => { _when => 'out', required => 0 },
- state => { _when => 'out', uint => 1 },
+ 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', required => 0 },
+ rid => { vndbid => 'r', default => undef },
spoil => { uint => 1, range => [0,2] },
role => { enum => \%CHAR_ROLE },
title => { _when => 'out' },
@@ -66,14 +68,18 @@ TUWF::get qr{/$RE{crev}/(?<action>edit|copy)} => sub {
my $copy = tuwf->capture('action') eq 'copy';
return tuwf->resDenied if !can_edit c => $copy ? {} : $e;
- $e->{main_name} = $e->{main} ? tuwf->dbVali('SELECT name FROM chars WHERE id =', \$e->{main}) : '';
+ $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 => 'SELECT t.id AS tid, t.name, t.state, t.applicable, g.name AS group, g.order AS order, false AS new FROM traits t LEFT JOIN traits g ON g.id = t.group WHERE t.id IN', $e->{traits};
+ 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 => 'SELECT id AS vid, title FROM vn WHERE id IN', $e->{vns};
- $e->{vns} = [ sort { $a->{title} cmp $b->{title} || idcmp($a->{vid}, $b->{vid}) || idcmp($a->{rid}||'r0', $b->{rid}||'r0') } $e->{vns}->@* ];
+ 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}->@* ];
@@ -88,7 +94,7 @@ TUWF::get qr{/$RE{crev}/(?<action>edit|copy)} => sub {
$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 ').$e->{name};
+ 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;
@@ -99,7 +105,7 @@ TUWF::get qr{/$RE{crev}/(?<action>edit|copy)} => sub {
TUWF::get qr{/$RE{vid}/addchar}, sub {
return tuwf->resDenied if !can_edit c => undef;
- my $v = tuwf->dbRowi('SELECT id, title FROM vn WHERE NOT hidden AND id =', \tuwf->capture('id'));
+ 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);
@@ -124,7 +130,7 @@ elm_api CharEdit => $FORM_OUT, $FORM_IN, sub {
$data->{hidden} = $e->{hidden}||0;
$data->{locked} = $e->{locked}||0;
}
- $data->{desc} = bb_subst_links $data->{desc};
+ $data->{description} = bb_subst_links $data->{description};
$data->{b_day} = 0 if !$data->{b_month};
$data->{main} = undef if $data->{hidden};
@@ -138,7 +144,7 @@ elm_api CharEdit => $FORM_OUT, $FORM_IN, sub {
# 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 ((state = 1+1 AND applicable) OR EXISTS(SELECT 1 FROM chars_traits ct WHERE ct.tid = t.id AND ct.id =', \$e->{id}, ')) AND id IN'),
+ 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}->@*;
diff --git a/lib/VNWeb/Chars/Elm.pm b/lib/VNWeb/Chars/Elm.pm
index ce14f490..ad8d723c 100644
--- a/lib/VNWeb/Chars/Elm.pm
+++ b/lib/VNWeb/Chars/Elm.pm
@@ -2,28 +2,20 @@ package VNWeb::Chars::Elm;
use VNWeb::Prelude;
-elm_api Chars => undef, { search => {} }, sub {
+elm_api Chars => undef, { search => { searchquery => 1 } }, sub {
my $q = shift->{search};
- my $qs = sql_like $q;
- my $l = tuwf->dbPagei({ results => 15, page => 1 },
- 'SELECT c.id, c.name, c.original, c.main, cm.name AS main_name, cm.original AS main_original
- FROM (SELECT MIN(prio), id FROM (',
- sql_join('UNION ALL',
- $q =~ /^$RE{cid}$/ ? sql('SELECT 1, id FROM chars WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(name),' , \$qs, '), id FROM chars WHERE name ILIKE', \"%$qs%"),
- sql('SELECT 10+substr_score(lower(original),', \$qs, "), id FROM chars WHERE translate(original,' ','') ILIKE", \("%$qs%" =~ s/ //gr)),
- sql('SELECT 100, id FROM chars WHERE alias ILIKE', \"%$qs%"),
- ), ') x(prio,id) GROUP BY id) x(prio, id)
- JOIN chars c ON c.id = x.id
- LEFT JOIN chars cm ON cm.id = c.main
+ 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 x.prio, c.name
- ');
+ ORDER BY sc.score DESC, c.sorttitle
+ ') : [];
for (@$l) {
- $_->{main} = { id => $_->{main}, name => $_->{main_name}, original => $_->{main_original} } if $_->{main};
- delete $_->{main_name};
- delete $_->{main_original};
+ $_->{main} = { id => $_->{main}, title => $_->{main_title}, alttitle => $_->{main_alttitle} } if $_->{main};
+ delete $_->{main_title};
+ delete $_->{main_alttitle};
}
elm_CharResult $l;
};
diff --git a/lib/VNWeb/Chars/List.pm b/lib/VNWeb/Chars/List.pm
index 4e4aaf66..87172f4a 100644
--- a/lib/VNWeb/Chars/List.pm
+++ b/lib/VNWeb/Chars/List.pm
@@ -15,58 +15,52 @@ sub listing_ {
my($opt, $list, $count) = @_;
my sub url { '?'.query_encode %$opt, @_ }
- paginate_ \&url, $opt->{p}, [$count, $opt->{s}->results], 't', sub { $opt->{s}->elm_ };
+ paginate_ \&url, $opt->{p}, [$count, $opt->{s}->results], 't', $opt->{s};
- div_ class => 'mainbox browse charb', sub {
+ article_ class => 'browse charb', sub {
table_ class => 'stripe', sub {
tr_ sub {
td_ class => 'tc1', sub {
- abbr_ class => "icons gen $_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
+ abbr_ class => "icon-gen-$_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
};
td_ class => 'tc2', sub {
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
- b_ class => 'grayedout', sub {
- join_ ', ', sub { a_ href => "/$_->{id}", title => $_->{original}||$_->{title}, $_->{title} }, $_->{vn}->@*;
+ a_ href => "/$_->{id}", tattr $_;
+ small_ sub {
+ join_ ', ', sub { a_ href => "/$_->{id}", tattr $_ }, $_->{vn}->@*;
};
};
} for @$list;
}
} if $opt->{s}->rows;
- div_ class => 'mainbox charbcard', sub {
+ 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 => $_->{name}, width => $iw, height => $ih, url => "/$_->{id}", overlay => undef;
+ image_ $_->{image}, alt => $_->{title}[1], width => $iw, height => $ih, url => "/$_->{id}", overlay => undef;
} else {
txt_ 'no image';
}
};
div_ sub {
- abbr_ class => "icons gen $_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ abbr_ class => "icon-gen-$_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
+ a_ href => "/$_->{id}", tattr $_;
br_;
- b_ class => 'grayedout', sub {
- join_ ', ', sub { a_ href => "/$_->{id}", title => $_->{original}||$_->{title}, $_->{title} }, $_->{vn}->@*;
+ small_ sub {
+ join_ ', ', sub { a_ href => "/$_->{id}", tattr $_ }, $_->{vn}->@*;
};
};
} for @$list;
} if $opt->{s}->cards;
- div_ class => 'mainbox charbgrid', sub {
- my($w,$h) = (170,210);
- div_ sub {
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
- div_ sub {
- if($_->{image}) {
- my($iw,$ih) = imgsize $_->{image}{width}*100, $_->{image}{height}*100, $w, $h;
- image_ $_->{image}, alt => $_->{name}, width => $iw, height => $ih, url => "/$_->{id}", overlay => undef;
- } else {
- txt_ 'no image';
- }
- };
+
+ 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;
@@ -77,22 +71,22 @@ sub listing_ {
# 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.original
+ SELECT DISTINCT cv.id AS cid, v.id, v.title, v.sorttitle
FROM chars_vns cv
- JOIN vn v ON v.id = cv.vid
- WHERE NOT v.hidden AND cv.id IN', $_, '
- ORDER BY v.title'
+ 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 => { onerror => undef },
+ q => { searchquery => 1 },
p => { upage => 1 },
f => { advsearch_err => 'c' },
ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
- fil => { required => 0 },
+ fil=>{ onerror => '' },
s => { tableopts => $TABLEOPTS },
)->data;
$opt->{ch} = $opt->{ch}[0];
@@ -112,22 +106,19 @@ TUWF::get qr{/c(?:/(?<char>all|[a-z0]))?}, sub {
$opt->{f} = advsearch_default 'c' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
- my @search = map {
- my $l = '%'.sql_like($_).'%';
- length $_ > 0 ? sql '(c.name ILIKE', \$l, "OR translate(c.original,' ','') ILIKE", \$l, "OR translate(c.alias,' ','') ILIKE", \$l, ')' : ();
- } split /[ -,._]/, $opt->{q}||'';
-
my $where = sql_and
- 'NOT c.hidden', $opt->{f}->sql_where(), @search,
- defined($opt->{ch}) && $opt->{ch} ? sql('LOWER(SUBSTR(c.name, 1, 1)) =', \$opt->{ch}) : (),
- defined($opt->{ch}) && !$opt->{ch} ? sql('(ASCII(c.name) <', \97, 'OR ASCII(c.name) >', \122, ') AND (ASCII(c.name) <', \65, 'OR ASCII(c.name) >', \90, ')') : ();
+ '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 chars c WHERE', $where);
+ $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.name, c.original, c.gender, c.image FROM chars c WHERE', $where, 'ORDER BY c.name, c.id'
+ 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, []));
@@ -137,7 +128,7 @@ TUWF::get qr{/c(?:/(?<char>all|[a-z0]))?}, sub {
framework_ title => 'Browse characters', sub {
form_ action => '/c', method => 'get', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Browse characters';
searchbox_ c => $opt->{q}//'';
p_ class => 'browseopts', sub {
@@ -145,8 +136,7 @@ TUWF::get qr{/c(?:/(?<char>all|[a-z0]))?}, sub {
for (undef, 'a'..'z', 0);
};
input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
- $opt->{f}->elm_;
- advsearch_msg_ $count, $time;
+ $opt->{f}->elm_($count, $time);
};
listing_ $opt, $list, $count if $count;
}
diff --git a/lib/VNWeb/Chars/Page.pm b/lib/VNWeb/Chars/Page.pm
index ca18a2f8..e6ffc7e7 100644
--- a/lib/VNWeb/Chars/Page.pm
+++ b/lib/VNWeb/Chars/Page.pm
@@ -7,27 +7,57 @@ 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.name, sa.original, vs.note
+ SELECT DISTINCT vs.cid, sa.id, sa.title, sa.sorttitle, vs.note
FROM vn_seiyuu vs
- JOIN staff_alias sa ON sa.aid = vs.aid
- WHERE vs.cid IN', $_, $vid ? ('AND vs.id =', \$vid) : (), '
- ORDER BY sa.name'
+ ', $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 => 'SELECT id AS vid, title, original FROM vn WHERE id IN', $c->{vns};
- enrich_merge rid => 'SELECT id AS rid, title AS rtitle, original AS roriginal FROM releases WHERE id IN', grep $_->{rid}, $c->{vns}->@*;
- enrich_merge tid =>
- 'SELECT t.id AS tid, t.name, t.state, t.applicable, t.sexual, coalesce(g.id, t.id) AS group, coalesce(g.name, t.name) AS groupname, coalesce(g.order,0) AS order
- FROM traits t LEFT JOIN traits g ON t.group = g.id WHERE t.id IN', $c->{traits};
-
- $c->{vns} = [ sort { $a->{title} cmp $b->{title} || idcmp($a->{vid}, $b->{vid}) || idcmp($a->{rid}||'r999999', $b->{rid}||'r999999') } $c->{vns}->@* ];
+ 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;
}
@@ -36,27 +66,29 @@ sub enrich_item {
sub fetch_chars {
my($vid, $where) = @_;
my $l = tuwf->dbAlli('
- SELECT id, name, original, alias, "desc", gender, spoil_gender, b_month, b_day, s_bust, s_waist, s_hip, height, weight, bloodt, cup_size, age, image
- FROM chars WHERE NOT hidden AND (', $where, ')
- ORDER BY name
+ 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, v.original, r.title AS rtitle, r.original AS roriginal
+ SELECT cv.id, cv.vid, cv.rid, cv.spoil, cv.role, v.title, r.title AS rtitle
FROM chars_vns cv
- JOIN vn v ON v.id = cv.vid
- LEFT JOIN releases r ON r.id = cv.rid
+ 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.title, cv.vid, cv.rid NULLS LAST'
+ 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, t.name, t.state, t.sexual, coalesce(g.id, t.id) AS group, coalesce(g.name, t.name) AS groupname, coalesce(g.order,0) AS order
+ 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.group = g.id
- WHERE ct.id IN', $_, '
- ORDER BY g.order NULLS FIRST, coalesce(g.name, t.name), t.name'
+ 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;
@@ -69,9 +101,9 @@ sub _rev_ {
my($c) = @_;
revision_ $c, \&enrich_item,
[ name => 'Name' ],
- [ original => 'Original name' ],
+ [ latin => 'Name (latin)' ],
[ alias => 'Aliases' ],
- [ desc => 'Description' ],
+ [ description=> 'Description' ],
[ gender => 'Sex', fmt => \%GENDER ],
[ spoil_gender=> 'Sex (spoiler)',fmt => \%GENDER ],
[ b_month => 'Birthday/month',empty => 0 ],
@@ -85,25 +117,25 @@ sub _rev_ {
[ cup_size => 'Cup size', fmt => \%CUP_SIZE ],
[ age => 'Age', ],
[ main => 'Instance of', empty => 0, fmt => sub {
- my $c = tuwf->dbRowi('SELECT id, name, original FROM chars WHERE id =', \$_);
- a_ href => "/$c->{id}", title => $c->{name}, $c->{id}
+ 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}", title => $_->{original}||$_->{title}, $_->{vid};
+ 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 {
- b_ class => 'grayedout', "$_->{groupname} / " if $_->{group} != $_->{tid};
- a_ href => "/i$_->{tid}", $_->{name};
- txt_ ' ('.fmtspoil($_->{spoil}).')';
- b_ class => 'standout', ' (awaiting moderation)' if $_->{state} == 0;
- b_ class => 'standout', ' (trait deleted)' if $_->{state} == 1;
- b_ class => 'standout', ' (not applicable)' if !$_->{applicable};
+ 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};
} ],
}
@@ -113,23 +145,26 @@ 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->{name} };
+ 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', $c->{name}
- : b_ style => 'margin-right: 10px', $c->{name};
- b_ class => 'grayedout', style => 'margin-right: 10px', $c->{original} if $c->{original};
- abbr_ class => "icons gen $c->{gender}", title => $GENDER{$c->{gender}}, '' if $c->{gender} ne 'unknown';
+ ? 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 => "icons gen $c->{spoil_gender}", title => $GENDER{$c->{spoil_gender}}, '' if $c->{spoil_gender} ne 'unknown';
+ 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 {
@@ -158,16 +193,22 @@ sub chartable_ {
} if defined $c->{age};
my @groups;
- for(grep $_->{state} == 2 && $_->{spoil} <= $view->{spoilers} && (!$_->{sexual} || $view->{traits_sexual}), $c->{traits}->@*) {
- push @groups, $_ if !@groups || $groups[$#groups]{group} != $_->{group};
+ 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_i$_->{group}", sub {
- td_ class => 'key', sub { a_ href => "/i$_->{group}", $_->{groupname} };
- td_ sub { join_ ', ', sub { a_ href => "/i$_->{tid}", $_->{name}; spoil_ $_->{spoil} }, $_->{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;
- my @visvns = grep $_->{spoil} <= $view->{spoilers}, $c->{vns}->@*;
tr_ sub {
td_ class => 'key', $vn ? 'Releases' : 'Visual novels';
td_ sub {
@@ -181,18 +222,18 @@ sub chartable_ {
# 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}", title => $v->{original}||$v->{title}, $v->{title};
+ a_ href => "/$v->{vid}", tattr $v;
spoil_ $v->{spoil};
# With releases
} else {
- a_ href => "/$v->{vid}", title => $v->{original}||$v->{title}, $v->{title} if !$vn;
+ a_ href => "/$v->{vid}", tattr $v if !$vn;
br_ if !$vn;
join_ \&br_, sub {
- b_ class => 'grayedout', '> ';
+ small_ '> ';
txt_ $CHAR_ROLE{$_->{role}}{txt}.' - ';
if($_->{rid}) {
- b_ class => 'grayedout', "$_->{rid}:";
- a_ href => "/$_->{rid}", title => $_->{roriginal}||$_->{rtitle}, $_->{rtitle};
+ small_ "$_->{rid}:";
+ a_ href => "/$_->{rid}", tattr $_->{rtitle};
} else {
txt_ 'All other releases';
}
@@ -207,7 +248,7 @@ sub chartable_ {
td_ class => 'key', 'Voiced by';
td_ sub {
join_ \&br_, sub {
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ a_ href => "/$_->{id}", tattr $_;
txt_ " ($_->{note})" if $_->{note};
}, $c->{seiyuu}->@*;
};
@@ -216,12 +257,25 @@ sub chartable_ {
tr_ class => 'nostripe', sub {
td_ colspan => 2, class => 'chardesc', sub {
h2_ 'Description';
- p_ sub { lit_ bb_format $c->{desc}, replacespoil => $view->{spoilers} != 2, keepspoil => $view->{spoilers} == 2 };
+ p_ sub { lit_ bb_format $c->{description}, replacespoil => $view->{spoilers} != 2, keepspoil => $view->{spoilers} == 2 };
};
- } if $c->{desc};
+ } 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;
}
@@ -244,38 +298,39 @@ TUWF::get qr{/$RE{crev}} => sub {
my $max_spoil = max(
$inst_maxspoil||0,
- (map $_->{spoil}, grep $_->{state} == 2, $c->{traits}->@*),
+ (map $_->{override}//($_->{lie}?2:$_->{spoil}), grep !$_->{hidden} && !(($_->{override}//0) == 3), $c->{traits}->@*),
(map $_->{spoil}, $c->{vns}->@*),
defined $c->{spoil_gender} ? 2 : 0,
- $c->{desc} =~ /\[spoiler\]/i ? 2 : 0, # crude
+ $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 $_->{state} == 2 && $_->{spoil} <= $view->{spoilers} && $_->{sexual}, map $_->{traits}->@*, $c, @$inst;
+ my $has_sex = grep !$_->{hidden} && $_->{sexual} && ($_->{override}//$_->{spoil}) <= $view->{spoilers}, map $_->{traits}->@*, $c, @$inst;
- framework_ title => $c->{name}, index => !tuwf->capture('rev'), dbobj => $c, hiddenmsg => 1,
+ $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->{desc}, text => 1),
+ 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');
- div_ class => 'mainbox', sub {
+ article_ sub {
itemmsg_ $c;
- h1_ sub { txt_ $c->{name}; debug_ $c };
- h2_ class => 'alttitle', $c->{original} if length $c->{original};
+ 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;
}
- b_ class => 'grayedout', ' | ' if $has_sex && $max_spoil;
+ 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;
};
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Other instances';
chartable_ $_, 1, $_ != $inst->[0] for @$inst;
} if @$inst;
diff --git a/lib/VNWeb/Chars/VNTab.pm b/lib/VNWeb/Chars/VNTab.pm
index 6dd9836a..bea983a6 100644
--- a/lib/VNWeb/Chars/VNTab.pm
+++ b/lib/VNWeb/Chars/VNTab.pm
@@ -10,36 +10,44 @@ sub chars_ {
my $max_spoil = max(
map max(
- (map $_->{spoil}, $_->{traits}->@*),
+ (map $_->{override}//($_->{lie}?2:$_->{spoil}), grep !$_->{hidden} && !(($_->{override}//0) == 3), $_->{traits}->@*),
(map $_->{spoil}, $_->{vns}->@*),
defined $_->{spoil_gender} ? 2 : 0,
- $_->{desc} =~ /\[spoiler\]/i ? 2 : 0,
+ $_->{description} =~ /\[spoiler\]/i ? 2 : 0,
), @$chars
);
$chars = [ grep +grep($_->{spoil} <= $view->{spoilers}, $_->{vns}->@*), @$chars ];
- my $has_sex = grep $_->{spoil} <= $view->{spoilers} && $_->{sexual}, map $_->{traits}->@*, @$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;
- div_ class => 'mainbox', sub {
-
- p_ class => 'mainopts', sub {
- 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;
- }
- b_ class => 'grayedout', ' | ' 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;
- } if !$first++;
-
+ 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;
}
@@ -49,7 +57,7 @@ TUWF::get qr{/$RE{vid}/chars}, sub {
VNWeb::VN::Page::enrich_vn($v);
- framework_ title => $v->{title}, index => 1, dbobj => $v, hiddenmsg => 1,
+ framework_ title => $v->{title}[1], index => 1, dbobj => $v, hiddenmsg => 1,
sub {
VNWeb::VN::Page::infobox_($v);
VNWeb::VN::Page::tabs_($v, 'chars');
diff --git a/lib/VNWeb/DB.pm b/lib/VNWeb/DB.pm
index 5a9a3a58..7eae6db8 100644
--- a/lib/VNWeb/DB.pm
+++ b/lib/VNWeb/DB.pm
@@ -10,7 +10,8 @@ use VNDB::Schema;
our @EXPORT = qw/
sql
- sql_identifier sql_join sql_comma sql_and sql_or sql_array sql_func sql_fromhex sql_tohex sql_fromtime sql_totime sql_like sql_user
+ global_settings
+ sql_join sql_comma sql_and sql_or sql_array sql_func sql_fromhex sql_tohex sql_fromtime sql_totime sql_like sql_user
enrich enrich_merge enrich_flatten enrich_obj
db_maytimeout db_entry db_edit
/;
@@ -25,7 +26,9 @@ our @EXPORT = qw/
# (and who'd put effort into escaping strings when placeholders are easier?).
sub interp_warn {
my @r = sql_interp @_;
- carp "Possible SQL injection in '$r[0]'" if tuwf->debug && $r[0] =~ /[2-9](?<!r18)/; # 0 and 1 aren't interesting, "SELECT 1" is a common pattern and so is "x > 0"
+ # 0 and 1 aren't interesting, "SELECT 1" is a common pattern and so is "x > 0".
+ # '{7}' is commonly used in ulist filtering and r18/api2 are a valid database identifiers.
+ carp "Possible SQL injection in '$r[0]'" if tuwf->debug && ($r[0] =~ s/(?:r18|\{7\}|api2)//rg) =~ /[2-9]/;
return @r;
}
@@ -45,13 +48,6 @@ $Carp::Internal{ (__PACKAGE__) }++;
# sql_* are macros for SQL::Interp use
-# A table, column or function name
-sub sql_identifier($) {
- carp "Invalid identifier '$_[0]'" if $_[0] !~ /^[a-z_][a-z0-9_]*$/; # This regex is specific to VNDB
- $_[0] =~ /^(?:desc|group|order)$/ ? qq{"$_[0]"} : $_[0]
-}
-
-
# join(), but for sql objects.
sub sql_join {
my $sep = shift;
@@ -72,7 +68,7 @@ sub sql_array { 'ARRAY[', sql_join(',', map \$_, @_), ']' }
# Call an SQL function
sub sql_func {
my($funcname, @args) = @_;
- sql sql_identifier($funcname), '(', sql_comma(@args), ')';
+ sql $funcname, '(', sql_comma(@args), ')';
}
# Convert a Perl hex value into Postgres bytea
@@ -104,7 +100,7 @@ sub sql_like($) {
# Arguments: Name of the 'users' table (default: 'u'), prefix for the fetched fields (default: 'user_').
# (This function returns a plain string so that old non-SQL-Interp functions can also use it)
sub sql_user {
- my $tbl = sql_identifier(shift||'u');
+ my $tbl = shift||'u';
my $prefix = shift||'user_';
join ', ',
"$tbl.id as ${prefix}id",
@@ -112,7 +108,17 @@ sub sql_user {
"$tbl.support_can as ${prefix}support_can",
"$tbl.support_enabled as ${prefix}support_enabled",
"$tbl.uniname_can as ${prefix}uniname_can",
- "$tbl.uniname as ${prefix}uniname";
+ "$tbl.uniname as ${prefix}uniname",
+ tuwf->req->{auth} && VNWeb::Auth::auth()->isMod ? (
+ "$tbl.perm_board as ${prefix}perm_board",
+ "$tbl.perm_edit as ${prefix}perm_edit"
+ ) : (),
+}
+
+
+# Returns a (potentially cached) version of the global_settings table.
+sub global_settings {
+ tuwf->req->{global_settings} //= tuwf->dbRowi('SELECT * FROM global_settings');
}
@@ -276,45 +282,35 @@ my $entry_types = do {
# id, chid, chrev, maxrev, hidden, locked, entry_hidden, entry_locked
#
# (Ordering of arrays is unspecified)
-#
-# TODO:
-# - Use non _hist tables if $maxrev == $rev (should be faster)
-# - Combine the enrich_merge() calls into a single query.
-# - Fixed ordering of arrays (use primary keys)
sub db_entry {
my($id, $rev) = @_;
my $t = $entry_types->{ substr $id, 0, 1 }||die;
- my $maxrev = tuwf->dbVali('SELECT MAX(rev) FROM changes WHERE itemid =', \$id);
- return undef if !$maxrev;
- $rev ||= $maxrev;
- my $entry = tuwf->dbRowi(q{
- SELECT itemid AS id, id AS chid, rev AS chrev, ihid AS hidden, ilock AS locked
- FROM changes
- WHERE}, { itemid => $id, rev => $rev }
+ my $entry = tuwf->dbRowi('
+ WITH maxrev (iid, maxrev) AS (SELECT itemid, MAX(rev) FROM changes WHERE itemid =', \$id, 'GROUP BY itemid)
+ , lastrev (entry_hidden, entry_locked) AS (SELECT ihid, ilock FROM maxrev, changes WHERE itemid = iid AND rev = maxrev)
+ SELECT itemid AS id, id AS chid, rev AS chrev, ihid AS hidden, ilock AS locked, maxrev, entry_hidden, entry_locked
+ FROM changes, maxrev, lastrev
+ WHERE itemid = iid AND rev = ', $rev ? \$rev : 'maxrev'
);
return undef if !$entry->{id};
- $entry->{maxrev} = $maxrev;
- if($maxrev == $rev) {
- $entry->{entry_hidden} = $entry->{hidden};
- $entry->{entry_locked} = $entry->{locked};
- } else {
- my $base = $t->{base}{name} =~ s/_hist$//r;
- enrich_merge id => sql('SELECT id, hidden AS entry_hidden, locked AS entry_locked FROM', sql_identifier($base), 'WHERE id IN'), $entry;
+ # Fetch data from the main entry tables if rev == maxrev, from the _hist
+ # tables otherwise. This should improve caching a bit.
+ my sub data_table {
+ $entry->{chrev} == $entry->{maxrev} ? sql $_[0] =~ s/_hist$//r, 'WHERE id =', \$id
+ : sql $_[0], 'WHERE chid =', \$entry->{chid}
}
- enrich_merge chid => sql(
- SELECT => sql_comma(map sql_identifier($_->{name}), $t->{base}{cols}->@*),
- FROM => sql_identifier($t->{base}{name}),
- 'WHERE chid IN'
- ), $entry;
+ %$entry = (%$entry, tuwf->dbRowi(
+ SELECT => sql_comma(map $_->{name}, grep $_->{name} ne 'chid', $t->{base}{cols}->@*),
+ FROM => data_table $t->{base}{name}
+ )->%*);
while(my($name, $tbl) = each $t->{tables}->%*) {
$entry->{$name} = tuwf->dbAlli(
- SELECT => sql_comma(map sql_identifier($_->{name}), grep $_->{name} ne 'chid', $tbl->{cols}->@*),
- FROM => sql_identifier($tbl->{name}),
- WHERE => { chid => $entry->{chid} }
+ SELECT => sql_comma(map $_->{name}, grep $_->{name} ne 'chid', $tbl->{cols}->@*),
+ FROM => data_table($tbl->{name}),
);
}
$entry
@@ -338,7 +334,6 @@ sub db_edit {
tuwf->dbExeci("SELECT edit_${type}_init(", \$id, ', (SELECT MAX(rev) FROM changes WHERE itemid = ', \$id, '))');
tuwf->dbExeci('UPDATE edit_revision SET', {
requester => $uid // scalar VNWeb::Auth::auth()->uid(),
- ip => scalar tuwf->reqIP(),
comments => $data->{editsum},
ihid => $data->{hidden},
ilock => $data->{locked},
@@ -354,7 +349,7 @@ sub db_edit {
{
my $base = $t->{base}{name} =~ s/_hist$//r;
tuwf->dbExeci("UPDATE edit_${base} SET ", sql_comma(
- map sql(sql_identifier($_->{name}), ' = ', val $data->{$_->{name}}, $_),
+ map sql($_->{name}, ' = ', val $data->{$_->{name}}, $_),
grep $_->{name} ne 'chid' && exists $data->{$_->{name}}, $t->{base}{cols}->@*
));
}
@@ -362,7 +357,7 @@ sub db_edit {
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 sql_identifier($_->{name}), @cols);
+ my @colnames = sql_comma(map $_->{name}, @cols);
my @rows = map {
my $d = $_;
sql '(', sql_comma(map val($d->{$_->{name}}, $_), @cols), ')'
diff --git a/lib/VNWeb/Discussions/Board.pm b/lib/VNWeb/Discussions/Board.pm
index a5673d49..9fa9e304 100644
--- a/lib/VNWeb/Discussions/Board.pm
+++ b/lib/VNWeb/Discussions/Board.pm
@@ -13,13 +13,14 @@ TUWF::get qr{/t/(all|$BOARD_RE)}, sub {
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}" : $type eq 'all' ? 'All boards' : $BOARD_TYPE{$type}{txt};
+ 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 {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
boardtypes_ $type;
boardsearch_ $type if !$id;
@@ -35,7 +36,7 @@ TUWF::get qr{/t/(all|$BOARD_RE)}, sub {
sort => $type eq 'an' ? 't.id DESC' : undef,
page => $page,
paginate => sub { "?p=$_" }
- or div_ class => 'mainbox', sub {
+ or article_ sub {
h1_ 'An empty board';
p_ class => 'center', sub {
txt_ "Nobody's started a discussion on this board yet. Why not ";
diff --git a/lib/VNWeb/Discussions/Edit.pm b/lib/VNWeb/Discussions/Edit.pm
index 90769d74..06fb2397 100644
--- a/lib/VNWeb/Discussions/Edit.pm
+++ b/lib/VNWeb/Discussions/Edit.pm
@@ -5,25 +5,26 @@ use VNWeb::Discussions::Lib;
my $FORM = {
- tid => { required => 0, vndbid => 't' }, # Thread ID, only when editing a post
+ tid => { default => undef, vndbid => 't' }, # Thread ID, only when editing a post
- title => { required => 0, maxlength => 50 },
- boards => { required => 0, sort_keys => [ 'boardtype', 'iid' ], aoh => $VNWeb::Elm::apis{BoardResult}[0]{aoh} },
- poll => { required => 0, type => 'hash', keys => {
- question => { maxlength => 100 },
+ title => { default => undef, sl => 1, maxlength => 50 },
+ boards => { default => undef, sort_keys => [ 'boardtype', 'iid' ], aoh => $VNWeb::Elm::apis{BoardResult}[0]{aoh} },
+ poll => { default => undef, type => 'hash', keys => {
+ question => { sl => 1, maxlength => 100 },
max_options => { uint => 1, min => 1, max => 20 }, #
- options => { type => 'array', values => { maxlength => 100 }, minlength => 2, maxlength => 20 },
+ options => { type => 'array', values => { sl => 1, maxlength => 100 }, minlength => 2, maxlength => 20 },
} },
- can_mod => { anybool => 1, _when => 'out' },
- can_private => { anybool => 1, _when => 'out' },
- locked => { anybool => 1 }, # When can_mod
- hidden => { 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
+ can_mod => { anybool => 1, _when => 'out' },
+ can_private => { anybool => 1, _when => 'out' },
+ locked => { anybool => 1 }, # When can_mod
+ hidden => { anybool => 1 }, # When can_mod
+ boards_locked => { anybool => 1 }, # When can_mod
+ private => { anybool => 1 }, # When can_private
+ nolastmod => { anybool => 1, _when => 'in' }, # When can_mod
+ delete => { anybool => 1 }, # When can_mod
- msg => { maxlength => 32768 },
+ msg => { maxlength => 32768 },
};
my $FORM_OUT = form_compile out => $FORM;
@@ -35,7 +36,7 @@ elm_api DiscussionsEdit => $FORM_OUT, $FORM_IN, sub {
my $tid = $data->{tid};
my $t = !$tid ? {} : tuwf->dbRowi('
- SELECT t.id, t.poll_question, t.poll_max_options, t.hidden, tp.num, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
+ SELECT t.id, t.poll_question, t.poll_max_options, t.boards_locked, t.hidden, tp.num, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
FROM threads t
JOIN threads_posts tp ON tp.tid = t.id AND tp.num = 1
WHERE t.id =', \$tid,
@@ -75,6 +76,7 @@ elm_api DiscussionsEdit => $FORM_OUT, $FORM_IN, sub {
auth->permBoardmod ? (
hidden => $data->{hidden},
locked => $data->{locked},
+ boards_locked => $data->{boards_locked},
) : (),
auth->isMod ? (
private => $data->{private}
@@ -83,8 +85,10 @@ elm_api DiscussionsEdit => $FORM_OUT, $FORM_IN, sub {
tuwf->dbExeci('UPDATE threads SET', $thread, 'WHERE id =', \$tid) if $tid;
$tid = tuwf->dbVali('INSERT INTO threads', $thread, 'RETURNING id') if !$tid;
- 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(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);
@@ -108,13 +112,15 @@ elm_api DiscussionsEdit => $FORM_OUT, $FORM_IN, sub {
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 = undef if $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.private, t.hidden, t.poll_question, t.poll_max_options, tp.msg, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
+ SELECT t.id, tp.tid, t.title, t.locked, t.boards_locked, t.private, t.hidden, t.poll_question, t.poll_max_options, tp.msg, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
FROM threads t
JOIN threads_posts tp ON tp.tid = t.id AND tp.num = 1
WHERE t.id =', \$tid,
@@ -132,14 +138,13 @@ TUWF::get qr{(?:/t/(?<board>$BOARD_RE)/new|/$RE{tid}\.1/edit)}, sub {
} else {
$t->{boards} = [ {
btype => $board_type,
- iid => $board_id||undef,
- title => !$board_id ? undef :
- tuwf->dbVali('SELECT title FROM', sql_boards(), 'x WHERE btype =', \$board_type, 'AND iid =', \$board_id)
+ iid => $board_id ? $board_id->{id} : undef,
+ title => $board_id ? $board_id->{title} : undef,
} ];
- return tuwf->resNotFound if $board_id && !length $t->{boards}[0]{title};
- push $t->{boards}->@*, { btype => 'u', iid => auth->uid, title => auth->user->{user_name} }
- if $board_type eq 'u' && $board_id ne auth->uid;
+ push $t->{boards}->@*, { btype => 'u', iid => auth->uid, title => [undef,auth->user->{user_name}] }
+ if $board_type eq 'u' && $board_id->{id} ne auth->uid;
}
+ $_->{title} = $_->{title} && $_->{title}[1] for $t->{boards}->@*;
$t->{can_mod} = auth->permBoardmod;
$t->{can_private} = auth->isMod;
@@ -150,6 +155,7 @@ TUWF::get qr{(?:/t/(?<board>$BOARD_RE)/new|/$RE{tid}\.1/edit)}, sub {
$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 {
diff --git a/lib/VNWeb/Discussions/Elm.pm b/lib/VNWeb/Discussions/Elm.pm
index 8b39560e..500cc3b9 100644
--- a/lib/VNWeb/Discussions/Elm.pm
+++ b/lib/VNWeb/Discussions/Elm.pm
@@ -1,44 +1,32 @@
package VNWeb::Discussions::Elm;
use VNWeb::Prelude;
-use VNWeb::Discussions::Lib;
# Autocompletion search results for boards
elm_api Boards => undef, {
- search => {},
+ search => { searchquery => 1 },
}, sub {
return elm_Unauth if !auth->permBoard;
my $q = shift->{search};
- my $qs = sql_like $q;
+ my $qs = sql_like "$q";
- my sub subq {
- my($prio, $where) = @_;
- sql 'SELECT', $prio, ' AS prio, btype, iid, CASE WHEN iid IS NULL THEN NULL ELSE title END AS title
- FROM (',
- sql_join('UNION ALL',
- sql('SELECT btype, iid, title, original FROM', sql_boards(), 'a'),
- map sql('SELECT', \$_, '::board_type, NULL,', \$BOARD_TYPE{$_}{txt}, q{, ''}),
- grep !$BOARD_TYPE{$_}{dbitem} && ($BOARD_TYPE{$_}{post_perm} eq 'board' || auth->permBoardmod),
- keys %BOARD_TYPE
- ),
- ') x WHERE', $where
- }
+ my $uscore = sql 'similarity(username, ', \$qs, ')';
+ $uscore = sql 'CASE WHEN id =', \$qs, 'THEN 1+1 ELSE', $uscore, 'END' if $qs =~ /^u$RE{num}$/;
- # This query is SLOW :(
elm_BoardResult tuwf->dbPagei({ results => 10, page => 1 },
'SELECT btype, iid, title
FROM (',
sql_join('UNION ALL',
- # ID match
- $q =~ /^($BOARD_RE)$/ && $q =~ /^(([a-z]+)[0-9]*)$/
- ? subq(0, sql_and sql('btype =', \"$2"), $1 ne $2 ? sql('iid =', \"$1") : ()) : (),
- subq(
- sql('1+LEAST(substr_score(lower(title),', \$qs, '), substr_score(lower(original),', \$qs, '))'),
- sql('title ILIKE', \"%$qs%", ' OR original ILIKE', \"%$qs%")
- )
- ), ') x
- GROUP BY btype, iid, title
- ORDER BY MIN(prio), btype, iid'
+ (map sql('SELECT 10, ', \"$_", '::board_type, NULL::vndbid, NULL'),
+ grep $qs eq $_ || $BOARD_TYPE{$_}{txt} =~ /\Q$qs/i,
+ grep !$BOARD_TYPE{$_}{dbitem} && ($BOARD_TYPE{$_}{post_perm} eq 'board' || auth->permBoardmod),
+ keys %BOARD_TYPE),
+ sql('SELECT score, \'v\', v.id, title[1+1] FROM', vnt, 'v', $q->sql_join('v', 'v.id'), 'WHERE NOT v.hidden'),
+ sql('SELECT score, \'p\', p.id, title[1+1] FROM', producerst, 'p', $q->sql_join('p', 'p.id'), 'WHERE NOT p.hidden'),
+ sql('SELECT', $uscore, ', \'u\', id, username FROM users WHERE lower(username) LIKE', \lc "%$qs%",
+ $qs =~ /^u$RE{num}$/ ? ('OR id =', \$qs) : ())
+ ), ') x(score, btype, iid, title)
+ ORDER BY score DESC, btype, title'
)
};
diff --git a/lib/VNWeb/Discussions/Index.pm b/lib/VNWeb/Discussions/Index.pm
index 920aa934..1e797d31 100644
--- a/lib/VNWeb/Discussions/Index.pm
+++ b/lib/VNWeb/Discussions/Index.pm
@@ -7,7 +7,7 @@ use VNWeb::Discussions::Lib;
TUWF::get qr{/t}, sub {
framework_ title => 'Discussion board index', sub {
form_ method => 'get', action => '/t/search', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Discussion board index';
boardtypes_ 'index';
boardsearch_;
@@ -18,8 +18,10 @@ TUWF::get qr{/t}, sub {
};
for my $b (keys %BOARD_TYPE) {
- h1_ class => 'boxtitle', sub {
- a_ href => "/t/$b", $BOARD_TYPE{$b}{txt};
+ nav_ sub {
+ h1_ sub {
+ a_ href => "/t/$b", $BOARD_TYPE{$b}{txt};
+ };
};
threadlist_
where => sql('t.id IN(SELECT tid FROM threads_boards WHERE type =', \$b, ')'),
diff --git a/lib/VNWeb/Discussions/Lib.pm b/lib/VNWeb/Discussions/Lib.pm
index 574c8c18..d4e8146a 100644
--- a/lib/VNWeb/Discussions/Lib.pm
+++ b/lib/VNWeb/Discussions/Lib.pm
@@ -3,7 +3,7 @@ package VNWeb::Discussions::Lib;
use VNWeb::Prelude;
use Exporter 'import';
-our @EXPORT = qw/$BOARD_RE sql_visible_threads sql_boards enrich_boards threadlist_ boardsearch_ boardtypes_/;
+our @EXPORT = qw/$BOARD_RE sql_visible_threads enrich_boards threadlist_ boardsearch_ boardtypes_/;
our $BOARD_RE = join '|', map $_.($BOARD_TYPE{$_}{dbitem}?'(?:[1-9][0-9]{0,5})?':''), keys %BOARD_TYPE;
@@ -18,25 +18,15 @@ sub sql_visible_threads {
}
-# Returns a SELECT subquery with all board IDs
-sub sql_boards {
- sql q{( SELECT 'v'::board_type AS btype, id AS iid, title, original FROM vn
- UNION ALL SELECT 'p'::board_type AS btype, id AS iid, name, original FROM producers
- UNION ALL SELECT 'u'::board_type AS btype, id AS iid, username, NULL FROM users
- )}
-}
-
-
# Adds a 'boards' array to threads.
sub enrich_boards {
my($filt, @lst) = @_;
- enrich boards => id => tid => sub { sql q{
- SELECT tb.tid, tb.type AS btype, tb.iid, b.title, b.original
- FROM threads_boards tb
- LEFT JOIN }, sql_boards(), q{b ON b.btype = tb.type AND b.iid = tb.iid
- WHERE }, sql_and(sql('tb.tid IN', $_[0]), $filt||()), q{
+ enrich boards => id => tid => sub { sql '
+ SELECT tb.tid, tb.type AS btype, tb.iid, x.title
+ FROM threads_boards tb, ', item_info('tb.iid', 'NULL'), 'x
+ WHERE ', sql_and(sql('tb.tid IN', $_[0]), $filt||()), '
ORDER BY tb.type, tb.iid
- }}, @lst;
+ '}, @lst;
}
@@ -74,7 +64,7 @@ sub threadlist_ {
enrich_boards $opt{boards}, $lst;
paginate_ $opt{paginate}, $opt{page}, [ $count, $opt{results} ], 't' if $opt{paginate};
- div_ class => 'mainbox browse discussions', sub {
+ article_ class => 'browse discussions', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Topic'; debug_ $lst };
@@ -85,20 +75,22 @@ sub threadlist_ {
tr_ sub {
my $l = $_;
td_ class => 'tc1', sub {
- a_ mkclass(locked => $l->{locked}), href => "/$l->{id}", sub {
+ my $system = $l->{private} && $l->{firstpost_id} && $l->{firstpost_id} eq 'u1';
+ a_ mkclass(locked => !$system && $l->{locked}), href => "/$l->{id}", sub {
span_ class => 'pollflag', '[poll]' if $l->{haspoll};
- span_ class => 'pollflag', '[private]' if $l->{private};
+ span_ class => 'pollflag', $system ? '[system]' : '[private]' if $l->{private};
span_ class => 'pollflag', '[hidden]' if $l->{hidden};
txt_ shorten $l->{title}, 50;
};
- b_ class => 'boards', sub {
+ span_ class => 'boards', sub {
join_ ', ', sub {
a_ href => '/t/'.($_->{iid}||$_->{btype}),
- title => $_->{original}||$BOARD_TYPE{$_->{btype}}{txt},
- shorten $_->{title}||$BOARD_TYPE{$_->{btype}}{txt}, 30;
+ $_->{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_' };
diff --git a/lib/VNWeb/Discussions/PostEdit.pm b/lib/VNWeb/Discussions/PostEdit.pm
index e740c029..d0e4e1d2 100644
--- a/lib/VNWeb/Discussions/PostEdit.pm
+++ b/lib/VNWeb/Discussions/PostEdit.pm
@@ -10,7 +10,7 @@ my $FORM = {
num => { id => 1 },
can_mod => { anybool => 1, _when => 'out' },
- hidden => { anybool => 1 }, # When can_mod
+ hidden => { default => sub { $_[0] } }, # When can_mod
nolastmod => { anybool => 1, _when => 'in' }, # When can_mod
delete => { anybool => 1 }, # When can_mod
@@ -44,7 +44,7 @@ elm_api DiscussionsPostEdit => $FORM_OUT, $FORM_IN, sub {
return tuwf->resNotFound if !$t->{id};
return elm_Unauth if !can_edit t => $t;
- tuwf->dbExeci(q{DELETE FROM notifications WHERE iid =}, \$id, 'AND num =', \$num) if auth->permBoardmod && ($data->{delete} || $data->{hidden});
+ 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");
diff --git a/lib/VNWeb/Discussions/Search.pm b/lib/VNWeb/Discussions/Search.pm
index 3922e4e4..79db2823 100644
--- a/lib/VNWeb/Discussions/Search.pm
+++ b/lib/VNWeb/Discussions/Search.pm
@@ -3,30 +3,34 @@ package VNWeb::Discussions::Search;
use VNWeb::Prelude;
use VNWeb::Discussions::Lib;
+my @BOARDS = (keys %BOARD_TYPE, 'w');
sub filters_ {
state $schema = tuwf->compile({ type => 'hash', keys => {
- bq => { required => 0, default => '' },
- b => { type => 'array', scalar => 1, onerror => [keys %BOARD_TYPE], values => { enum => \%BOARD_TYPE } },
+ bq => { default => '' },
+ uq => { default => '' },
+ b => { type => 'array', scalar => 1, onerror => \@BOARDS, values => { enum => \@BOARDS } },
t => { anybool => 1 },
p => { page => 1 },
}});
my $filt = tuwf->validate(get => $schema)->data;
my %boards = map +($_,1), $filt->{b}->@*;
+ my $u = $filt->{uq} && tuwf->dbVali('SELECT id FROM users WHERE', $filt->{uq} =~ /^u$RE{num}$/ ? 'id = ' : 'lower(username) =', \lc $filt->{uq});
+
form_ method => 'get', action => tuwf->reqPath(), sub {
boardtypes_;
- table_ style => 'margin: 0 auto', sub { tr_ sub {
- td_ style => 'padding: 10px', sub {
- p_ class => 'linkradio', sub {
- join_ \&br_, sub {
- input_ type => 'checkbox', name => 'b', id => "b_$_", value => $_, $boards{$_} ? (checked => 'checked') : ();
- label_ for => "b_$_", $BOARD_TYPE{$_}{txt};
- }, keys %BOARD_TYPE;
+ table_ class => 'boardsearchoptions', sub { tr_ sub {
+ td_ sub {
+ select_ multiple => 1, size => scalar @BOARDS, name => 'b', sub {
+ option_ $boards{$_} ? (selected => 1) : (), value => $_, $_ eq 'w' ? 'Reviews' : $BOARD_TYPE{$_}{txt} for @BOARDS;
}
};
- td_ style => 'padding: 10px', sub {
+ td_ sub {
input_ type => 'text', class => 'text', name => 'bq', style => 'width: 400px', placeholder => 'Search', value => $filt->{bq};
+ br_;
+ input_ type => 'text', class => 'text', name => 'uq', style => 'width: 150px', placeholder => 'Username or id', value => $filt->{uq};
+ b_ 'User not found.' if $filt->{uq} && !$u;
p_ class => 'linkradio', sub {
input_ type => 'checkbox', name => 't', id => 't', value => 1, $filt->{t} ? (checked => 'checked') : ();
@@ -39,12 +43,12 @@ sub filters_ {
};
}
};
- $filt
+ ($filt, $u)
}
sub noresults_ {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'No results';
p_ 'No threads or messages found matching your criteria.';
};
@@ -52,16 +56,18 @@ sub noresults_ {
sub posts_ {
- my($filt) = @_;
+ my($filt, $u) = @_;
- # Turn query into something suitable for to_tsquery()
- # TODO: Use Postgres 11 websearch_to_tsquery() instead.
- (my $ts = $filt->{bq}) =~ y{+|&:*()="';!?$%^\\[]{}<>~` }{ }s;
- $ts =~ s/ +/ /;
- $ts =~ s/^ //;
- $ts =~ s/ $//;
- $ts =~ s/ / & /g;
- $ts =~ s/(?:^| )-([^ ]+)/ !$1 /;
+ # Use websearch_to_tsquery() to convert the query string into a tsquery.
+ # Also match against an empty string to see if the query doesn't consist of only negative matches.
+ my $ts = tuwf->dbVali('
+ WITH q(q) AS (SELECT websearch_to_tsquery(', \$filt->{bq}, '))
+ SELECT CASE WHEN numnode(q) = 0 OR q @@ \'\' THEN NULL ELSE q END FROM q');
+ return noresults_ if !$ts;
+
+ my $reviews = grep $_ eq 'w', $filt->{b}->@*;
+ my @tboards = grep $_ ne 'w', $filt->{b}->@*;
+ return noresults_ if !$reviews && !@tboards;
# HACK: The bbcodes are stripped from the original messages when creating
# the headline, so they are guaranteed not to show up in the message. This
@@ -69,26 +75,43 @@ sub posts_ {
# conflict with the message contents.
my($posts, $np) = tuwf->dbPagei({ results => 20, page => $filt->{p} }, q{
- SELECT tp.tid, tp.num, t.title
+ SELECT m.id, m.num, m.title
, }, sql_user(), q{
- , }, sql_totime('tp.date'), q{as date
- , ts_headline('english', strip_bb_tags(strip_spoilers(tp.msg)), to_tsquery(}, \$ts, '),',
+ , }, sql_totime('m.date'), q{as date
+ , ts_headline('english', strip_bb_tags(strip_spoilers(m.msg)),}, \$ts, ',',
\'MaxFragments=2,MinWords=15,MaxWords=40,StartSel=[raw],StopSel=[/raw],FragmentDelimiter=[code]',
- q{) as headline
- FROM threads_posts tp
- JOIN threads t ON t.id = tp.tid
- LEFT JOIN users u ON u.id = tp.uid
- WHERE NOT t.hidden AND NOT t.private AND NOT tp.hidden
- AND bb_tsvector(tp.msg) @@ to_tsquery(}, \$ts, ')',
- $filt->{b}->@* < keys %BOARD_TYPE ? ('AND t.id IN(SELECT tid FROM threads_boards WHERE type IN', $filt->{b}, ')') : (), q{
- ORDER BY tp.date DESC
- });
+ ') as headline
+ FROM (', sql_join('UNION',
+ @tboards ?
+ sql('SELECT tp.tid, tp.num, t.title, tp.uid, tp.date, tp.msg
+ FROM threads_posts tp
+ JOIN threads t ON t.id = tp.tid
+ WHERE NOT t.hidden AND NOT t.private AND tp.hidden IS NULL
+ AND bb_tsvector(tp.msg) @@', \$ts,
+ $u ? ('AND tp.uid =', \$u) : (),
+ @tboards < keys %BOARD_TYPE ? ('AND t.id IN(SELECT tid FROM threads_boards WHERE type IN', \@tboards, ')') : ()
+ ) : (), $reviews ? (
+ sql('SELECT w.id, 0, v.title[1+1], w.uid, w.date, w.text
+ FROM reviews w
+ JOIN', vnt, 'v ON v.id = w.vid
+ WHERE NOT w.c_flagged AND bb_tsvector(w.text) @@', \$ts,
+ $u ? ('AND w.uid =', \$u) : ()),
+ sql('SELECT wp.id, wp.num, v.title[1+1], wp.uid, wp.date, wp.msg
+ FROM reviews_posts wp
+ JOIN reviews w ON w.id = wp.id
+ JOIN', vnt, 'v ON v.id = w.vid
+ WHERE NOT w.c_flagged AND wp.hidden IS NULL AND bb_tsvector(wp.msg) @@', \$ts,
+ $u ? ('AND wp.uid =', \$u) : ()),
+ ) : ()), ') m (id, num, title, uid, date, msg)
+ LEFT JOIN users u ON u.id = m.uid
+ ORDER BY m.date DESC'
+ );
return noresults_ if !@$posts;
my sub url { '?'.query_encode %$filt, @_ }
paginate_ \&url, $filt->{p}, $np, 't';
- div_ class => 'mainbox browse postsearch', sub {
+ article_ class => 'browse postsearch', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1_1', 'Id';
@@ -99,18 +122,18 @@ sub posts_ {
}};
tr_ sub {
my $l = $_;
- my $link = "/$l->{tid}.$l->{num}";
- td_ class => 'tc1_1', sub { a_ href => $link, $l->{tid} };
- td_ class => 'tc1_2', sub { a_ href => $link, '.'.$l->{num} };
+ my $link = "/$l->{id}".($l->{num}?".$l->{num}":'');
+ td_ class => 'tc1_1', sub { a_ href => $link, $l->{id} };
+ td_ class => 'tc1_2', sub { a_ href => $link, '.'.$l->{num} if $l->{num} };
td_ class => 'tc2', fmtdate $l->{date};
td_ class => 'tc3', sub { user_ $l };
td_ class => 'tc4', sub {
div_ class => 'title', sub { a_ href => $link, $l->{title} };
div_ class => 'thread', sub { lit_(
xml_escape($l->{headline})
- =~ s/\[raw\]/<b class="standout">/gr
+ =~ s/\[raw\]/<b>/gr
=~ s/\[\/raw\]/<\/b>/gr
- =~ s/\[code\]/<b class="grayedout">...<\/b><br \/>/gr
+ =~ s/\[code\]/<small>...<\/small><br \/>/gr
)};
};
} for @$posts;
@@ -121,10 +144,14 @@ sub posts_ {
sub threads_ {
- my($filt) = @_;
+ my($filt, $u) = @_;
+
+ my @boards = grep $_ ne 'w', $filt->{b}->@*; # Can't search reviews by title
+ return noresults_ if !@boards;
my $where = sql_and
- $filt->{b}->@* < keys %BOARD_TYPE ? sql('t.id IN(SELECT tid FROM threads_boards WHERE type IN', $filt->{b}, ')') : (),
+ @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_
@@ -138,13 +165,13 @@ sub threads_ {
TUWF::get qr{/t/search}, sub {
framework_ title => 'Search the discussion board',
sub {
- my $filt;
- div_ class => 'mainbox', sub {
+ my($filt, $u);
+ article_ sub {
h1_ 'Search the discussion board';
- $filt = filters_;
+ ($filt, $u) = filters_;
};
- posts_ $filt if $filt->{bq} && !$filt->{t};
- threads_ $filt if $filt->{bq} && $filt->{t};
+ posts_ $filt, $u if $filt->{bq} && !$filt->{t};
+ threads_ $filt, $u if $filt->{bq} && $filt->{t};
};
};
diff --git a/lib/VNWeb/Discussions/Thread.pm b/lib/VNWeb/Discussions/Thread.pm
index 3836bd46..b3820dd7 100644
--- a/lib/VNWeb/Discussions/Thread.pm
+++ b/lib/VNWeb/Discussions/Thread.pm
@@ -43,34 +43,36 @@ elm_api DiscussionsPoll => $POLL_OUT, $POLL_IN, sub {
-my $REPLY = {
+my $REPLY = form_compile any => {
tid => { vndbid => 't' },
- old => { _when => 'out', anybool => 1 },
- msg => { _when => 'in', maxlength => 32768 }
+ old => { anybool => 1 },
+ msg => { maxlength => 32768 }
};
-my $REPLY_IN = form_compile in => $REPLY;
-my $REPLY_OUT = form_compile out => $REPLY;
-
-elm_api DiscussionsReply => $REPLY_OUT, $REPLY_IN, sub {
+js_api DiscussionReply => $REPLY, sub {
my($data) = @_;
my $t = tuwf->dbRowi('SELECT id, locked FROM threads t WHERE id =', \$data->{tid}, 'AND', sql_visible_threads());
return tuwf->resNotFound if !$t->{id};
- return elm_Unauth if !can_edit t => $t;
+ return tuwf->resDenied if !can_edit t => $t;
my $num = 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');
- elm_Redirect "/$t->{id}.$num#last";
+ +{ _redir => "/$t->{id}.$num#last" };
};
sub metabox_ {
- my($t) = @_;
- div_ class => 'mainbox', sub {
+ 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};
@@ -83,9 +85,9 @@ sub metabox_ {
a_ style => 'font-weight: bold', href => "/t/$_->{iid}", $_->{iid};
txt_ ':';
if($_->{title}) {
- a_ href => "/$_->{iid}", title => $_->{original}||$_->{title}, $_->{title};
+ a_ href => "/$_->{iid}", tattr $_;
} else {
- b_ '[deleted]';
+ strong_ '[deleted]';
}
}
} for $t->{boards}->@*;
@@ -100,12 +102,12 @@ sub posts_ {
my sub url { "/$t->{id}".($_?"/$_":'') }
paginate_ \&url, $page, [ $t->{count}, 25 ], 't';
- div_ class => 'mainbox thread', id => 'threadstart', sub {
+ article_ class => 'thread', id => 'threadstart', sub {
table_ class => 'stripe', sub {
- tr_ mkclass(deleted => $_->{hidden}), id => $_->{num}, 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(!$_->{hidden} || auth->permBoard) {
+ if(!defined $_->{hidden} || auth->permBoard) {
txt_ ' by ';
user_ $_;
br_;
@@ -113,7 +115,7 @@ sub posts_ {
}
};
td_ class => 'tc2', sub {
- i_ class => 'edit', sub {
+ small_ class => 'edit', sub {
txt_ '< ';
if(can_edit t => $_) {
a_ href => "/$t->{id}.$_->{num}/edit", 'edit';
@@ -121,12 +123,15 @@ sub posts_ {
}
a_ href => "/report/$t->{id}.$_->{num}", 'report';
txt_ ' >';
- } if !$_->{hidden} || can_edit t => $_;
- if($_->{hidden}) {
- i_ class => 'deleted', 'Post deleted.';
+ } if !defined $_->{hidden} || can_edit t => $_;
+ if(defined $_->{hidden}) {
+ small_ sub {
+ txt_ 'Post deleted';
+ lit_ length $_->{hidden} ? ': '.bb_format $_->{hidden}, inline => 1 : '.';
+ };
} else {
lit_ bb_format $_->{msg};
- i_ class => 'lastmod', 'Last modified on '.fmtdate($_->{edited}, 'full') if $_->{edited};
+ small_ class => 'lastmod', 'Last modified on '.fmtdate($_->{edited}, 'full') if $_->{edited};
}
};
} for @$posts;
@@ -140,9 +145,9 @@ sub reply_ {
my($t, $posts, $page) = @_;
return if $t->{count} > $page*25;
if(can_edit t => $t) {
- elm_ 'Discussions.Reply' => $REPLY_OUT, { tid => $t->{id}, old => $posts->[$#$posts]{date} < time-182*24*3600 };
+ div_ widget(DiscussionReply => $REPLY, { tid => $t->{id}, old => $posts->[$#$posts]{date} < time-182*24*3600 }), '';
} else {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Reply';
p_ class => 'center',
!auth ? 'You must be logged in to reply to this thread.' :
@@ -189,13 +194,14 @@ TUWF::get qr{/$RE{tid}(?:(?<sep>[\./])$RE{num})?}, sub {
LEFT JOIN users u ON tpv.uid = u.id AND NOT u.ign_votes
LEFT JOIN threads_poll_votes tpm ON tpm.optid = tpo.id AND tpm.uid =', \auth->uid, '
WHERE tpo.tid =', \$id, '
- GROUP BY tpo.id, tpo.option, tpm.optid'
+ GROUP BY tpo.id, tpo.option, tpm.optid
+ ORDER BY tpo.id'
);
auth->notiRead($id, [ map $_->{num}, $posts->@* ]) if @$posts;
- framework_ title => $t->{title}, type => 't', dbobj => $t, $num ? (js => 1, pagevars => {sethash=>$num}) : (), sub {
- metabox_ $t;
+ 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},
diff --git a/lib/VNWeb/Discussions/UPosts.pm b/lib/VNWeb/Discussions/UPosts.pm
index 955f0790..aaa75c1e 100644
--- a/lib/VNWeb/Discussions/UPosts.pm
+++ b/lib/VNWeb/Discussions/UPosts.pm
@@ -9,7 +9,7 @@ sub listing_ {
my sub url { '?'.query_encode @_ }
paginate_ \&url, $page, [ $count, 50 ], 't';
- div_ class => 'mainbox browse uposts', sub {
+ article_ class => 'browse uposts', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { debug_ $list };
@@ -19,12 +19,12 @@ sub listing_ {
}};
tr_ sub {
my $url = "/$_->{id}.$_->{num}";
- td_ class => 'tc1', sub { a_ href => $url, $_->{id} };
- td_ class => 'tc2', sub { a_ href => $url, '.'.$_->{num} };
+ td_ class => 'tc1', sub { a_ href => $url, $_->{hidden} ? (class => 'grayedout') : (), $_->{id} };
+ td_ class => 'tc2', sub { a_ href => $url, $_->{hidden} ? (class => 'grayedout') : (), '.'.$_->{num} };
td_ class => 'tc3', fmtdate $_->{date};
td_ class => 'tc4', sub {
a_ href => $url, $_->{title};
- b_ class => 'grayedout', sub { lit_ bb_format $_->{msg}, maxlength => 150, inline => 1 };
+ small_ sub { lit_ bb_format $_->{msg}, maxlength => 150, inline => 1 };
};
} for @$list;
}
@@ -36,34 +36,34 @@ sub listing_ {
TUWF::get qr{/$RE{uid}/posts}, sub {
my $u = tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \tuwf->capture('id'));
- return tuwf->resNotFound if !$u->{id};
+ return tuwf->resNotFound if !$u->{id} || (!$u->{user_name} && !auth->isMod);
my $page = tuwf->validate(get => p => { upage => 1 })->data;
my $sql = sql '(
- SELECT tp.tid, tp.num, tp.msg, t.title, tp.date
+ 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 NOT t.private AND NOT t.hidden AND NOT tp.hidden AND tp.uid =', \$u->{id}, '
+ 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, rp.date
+ 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 vn v ON v.id = r.vid
- WHERE NOT rp.hidden AND rp.uid =', \$u->{id}, '
- ) p(id,num,msg,title,date)';
+ 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
+ '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, type => 'u', dbobj => $u, tab => 'posts',
+ framework_ title => $title, dbobj => $u, tab => 'posts',
sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
if(!$count) {
p_ +($own ? 'You have' : user_displayname($u).' has').' not posted anything on the forums yet.';
diff --git a/lib/VNWeb/Docs/Edit.pm b/lib/VNWeb/Docs/Edit.pm
index f4551dae..2e33432a 100644
--- a/lib/VNWeb/Docs/Edit.pm
+++ b/lib/VNWeb/Docs/Edit.pm
@@ -6,8 +6,8 @@ use VNWeb::Docs::Lib;
my $FORM = {
id => { vndbid => 'd' },
- title => { maxlength => 200 },
- content => { required => 0, default => '' },
+ title => { sl => 1, maxlength => 200 },
+ content => { default => '' },
hidden => { anybool => 1 },
locked => { anybool => 1 },
@@ -27,29 +27,29 @@ TUWF::get qr{/$RE{drev}/edit} => sub {
framework_ title => "Edit $d->{title}", dbobj => $d, tab => 'edit',
sub {
- elm_ DocEdit => $FORM_OUT, $d;
+ div_ widget(DocEdit => $FORM_OUT, $d), '';
};
};
-elm_api DocEdit => $FORM_OUT, $FORM_IN, sub {
+js_api DocEdit => $FORM_IN, sub {
my $data = shift;
my $doc = db_entry $data->{id} or return tuwf->resNotFound;
- return elm_Unauth if !can_edit d => $doc;
- return elm_Unchanged if !form_changed $FORM_CMP, $data, $doc;
+ return tuwf->resDenied if !can_edit d => $doc;
+ return +{ _err => 'No changes' } if !form_changed $FORM_CMP, $data, $doc;
$data->{html} = md2html $data->{content};
my $c = db_edit d => $doc->{id}, $data;
- elm_Redirect "/$c->{nitemid}.$c->{nrev}";
+ +{ _redir => "/$c->{nitemid}.$c->{nrev}" };
};
-elm_api Markdown => undef, {
- content => { required => 0, default => '' }
+js_api Markdown => {
+ content => { default => '' }
}, sub {
- return elm_Unauth if !auth->permDbmod;
- elm_Content enrich_html md2html shift->{content};
+ return tuwf->resDenied if !auth->permDbmod;
+ +{ html => enrich_html md2html shift->{content} };
};
diff --git a/lib/VNWeb/Docs/Lib.pm b/lib/VNWeb/Docs/Lib.pm
index 2f2b273c..9a0cb6f9 100644
--- a/lib/VNWeb/Docs/Lib.pm
+++ b/lib/VNWeb/Docs/Lib.pm
@@ -6,12 +6,12 @@ use VNDB::Skins;
our @EXPORT = qw/enrich_html/;
-my @special_perms = qw/boardmod dbmod usermod imgmod tagmod/;
+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;
- my $l = tuwf->dbAlli("SELECT id, username, $cols FROM users WHERE $where ORDER BY id LIMIT 100");
+ 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 {
diff --git a/lib/VNWeb/Docs/Page.pm b/lib/VNWeb/Docs/Page.pm
index 29b7ec5a..e9949ab3 100644
--- a/lib/VNWeb/Docs/Page.pm
+++ b/lib/VNWeb/Docs/Page.pm
@@ -6,7 +6,7 @@ use VNWeb::Docs::Lib;
sub _index_ {
ul_ class => 'index', sub {
- li_ sub { b_ 'Guidelines' };
+ li_ sub { strong_ 'Guidelines' };
li_ sub { a_ href => '/d5', 'Editing Guidelines' };
li_ sub { a_ href => '/d2', 'Visual Novels' };
li_ sub { a_ href => '/d15', 'Special Games' };
@@ -17,14 +17,13 @@ sub _index_ {
li_ sub { a_ href => '/d10', 'Tags & Traits' };
li_ sub { a_ href => '/d19', 'Image Flagging' };
li_ sub { a_ href => '/d13', 'Capturing Screenshots' };
- li_ sub { b_ 'About VNDB' };
+ li_ sub { strong_ 'About VNDB' };
li_ sub { a_ href => '/d9', 'Discussion Board' };
li_ sub { a_ href => '/d6', 'FAQ' };
li_ sub { a_ href => '/d7', 'About Us' };
li_ sub { a_ href => '/d17', 'Privacy Policy & Licensing' };
li_ sub { a_ href => '/d11', 'Database API' };
li_ sub { a_ href => '/d14', 'Database Dumps' };
- li_ sub { a_ href => '/d18', 'Database Querying' };
li_ sub { a_ href => '/d8', 'Development' };
}
}
@@ -45,7 +44,7 @@ TUWF::get qr{/$RE{drev}} => sub {
framework_ title => $d->{title}, index => !tuwf->capture('rev'), dbobj => $d, hiddenmsg => 1,
sub {
_rev_ $d if tuwf->capture('rev');
- div_ class => 'mainbox', sub {
+ article_ sub {
itemmsg_ $d;
h1_ $d->{title};
div_ class => 'docs', sub {
diff --git a/lib/VNWeb/Elm.pm b/lib/VNWeb/Elm.pm
index 62f7aaf0..ad4f80a3 100644
--- a/lib/VNWeb/Elm.pm
+++ b/lib/VNWeb/Elm.pm
@@ -1,4 +1,4 @@
-# This module is responsible for generating elm/Gen/*.
+# 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
@@ -38,29 +38,19 @@ our %apis = (
Unchanged => [], # No changes
Success => [],
Redirect => [{}], # Redirect to the given URL
- CSRF => [], # Invalid CSRF token
Invalid => [], # POST data did not validate the schema
Editsum => [], # Invalid edit summary
Content => [{}], # Rendered HTML content (for markdown/bbcode APIs)
- BadLogin => [], # Invalid user or pass
- LoginThrottle => [], # Too many failed login attempts
- InsecurePass => [], # Password is in a dictionary or breach database
- BadEmail => [], # Unknown email address in password reset form
- Bot => [], # User didn't pass bot verification
- Taken => [], # Username already taken
- DoubleEmail => [], # Account with same email already exists
- DoubleIP => [], # Account with same IP already exists
- BadCurPass => [], # Current password is incorrect when changing password
- MailChange => [], # A confirmation mail has been sent to change a user's email address
ImgFormat => [], # Unrecognized image format
+ LabelId => [{uint => 1}], # Label created
DupNames => [ { aoh => { # Duplicate names/aliases (for tags & traits)
- id => { id => 1 },
+ id => { vndbid => ['i','g'] },
name => {},
} } ],
Releases => [ { aoh => { # Response to 'Release'
id => { vndbid => 'r' },
title => {},
- original => { required => 0, default => '' },
+ alttitle => { default => '' },
released => { uint => 1 },
rtype => {},
reso_x => { uint => 1 },
@@ -76,81 +66,86 @@ our %apis = (
engine => {},
count => { uint => 1 },
} } ],
+ DRM => [ { aoh => { # Response to 'DRM'
+ name => {},
+ count => { uint => 1 },
+ } } ],
BoardResult => [ { aoh => { # Response to 'Boards'
btype => { enum => \%BOARD_TYPE },
- iid => { required => 0, vndbid => ['p','v','u'] },
- title => { required => 0 },
+ iid => { default => undef, vndbid => ['p','v','u'] },
+ title => { default => undef },
} } ],
TagResult => [ { aoh => { # Response to 'Tags'
- id => { id => 1 },
+ id => { vndbid => 'g' },
name => {},
searchable => { anybool => 1 },
applicable => { anybool => 1 },
- state => { int => 1 },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
} } ],
TraitResult => [ { aoh => { # Response to 'Traits'
- id => { id => 1 },
+ id => { vndbid => 'i' },
name => {},
searchable => { anybool => 1 },
applicable => { anybool => 1 },
- state => { int => 1 },
defaultspoil => { uint => 1 },
- group_id => { required => 0, uint => 1 },
- group_name => { required => 0 },
+ 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 => {},
- original => { required => 0, default => '' },
hidden => { anybool => 1 },
} } ],
ProducerResult => [ { aoh => { # Response to 'Producers'
id => { vndbid => 'p' },
name => {},
- original => { required => 0, default => '' },
- hidden => { anybool => 1 },
+ altname => { default => undef },
} } ],
StaffResult => [ { aoh => { # Response to 'Staff'
id => { vndbid => 's' },
+ lang => {},
aid => { id => 1 },
- name => {},
- original => { required => 0, default => '' },
+ title => {},
+ alttitle => {},
} } ],
CharResult => [ { aoh => { # Response to 'Chars'
id => { vndbid => 'c' },
- name => {},
- original => { required => 0, default => '' },
- main => { required => 0, type => 'hash', keys => {
+ title => {},
+ alttitle => {},
+ main => { default => undef, type => 'hash', keys => {
id => { vndbid => 'c' },
- name => {},
- original => { required => 0, default => '' },
+ title => {},
+ alttitle => {},
} }
} } ],
AnimeResult => [ { aoh => { # Response to 'Anime'
id => { id => 1 },
title => {},
- original => { required => 0, default => '' },
+ original => { default => '' },
} } ],
ImageResult => [ { aoh => { # Response to 'Images'
id => { vndbid => ['ch','cv','sf'] },
- token => { required => 0 },
+ token => { default => undef },
width => { uint => 1 },
height => { uint => 1 },
votecount => { uint => 1 },
- sexual_avg => { num => 1, required => 0 },
- sexual_stddev => { num => 1, required => 0 },
- violence_avg => { num => 1, required => 0 },
- violence_stddev => { num => 1, required => 0 },
- my_sexual => { uint => 1, required => 0 },
- my_violence => { uint => 1, required => 0 },
+ 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 => { required => 0, type => 'hash', keys => {
+ entry => { default => undef, type => 'hash', keys => {
id => {},
title => {},
} },
votes => { unique => 0, aoh => {
user => {},
- uid => { vndbid => 'u', required => 0 },
+ uid => { vndbid => 'u', default => undef },
sexual => { uint => 1 },
violence => { uint => 1 },
ignore => { anybool => 1 },
@@ -166,7 +161,27 @@ $apis{AdvSearchQuery} = [ { type => 'hash', keys => { # Response to 'AdvSearchLo
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
@@ -256,7 +271,7 @@ sub encoder {
sub write_module {
my($module, $contents) = @_;
- my $fn = sprintf '%s/elm/Gen/%s.elm', config->{root}, $module;
+ my $fn = sprintf '%s/elm/Gen/%s.elm', config->{gen_path}, $module;
# The imports aren't necessary in all the files, but might as well add them.
$contents = <<~"EOF";
@@ -318,11 +333,6 @@ sub elm_api {
$in = comp $in;
TUWF::post qr{/elm/\Q$name\E\.json} => sub {
- if(!samesite && !auth->csrfcheck(tuwf->reqHeader('X-CSRF-Token')||'')) {
- warn "Invalid CSRF token in request\n";
- return elm_CSRF();
- }
-
my $data = tuwf->validate(json => $in);
# Handle failure of the 'editsum' validation as a special case and return elm_Editsum().
if(!$data && $data->err->{errors} && grep $_->{validation} eq 'editsum' || ($_->{validation} eq 'required' && $_->{key} eq 'editsum'), $data->err->{errors}->@*) {
@@ -369,7 +379,7 @@ sub elm_empty {
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} ? $schema->{keys}->%* : () } if $schema->{type} eq 'hash';
+ 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";
}
@@ -418,9 +428,7 @@ sub write_api {
sub write_types {
my $data = '';
- $data .= def adminEMail => String => string config->{admin_email};
- $data .= def skins => 'List (String, String)' => list map tuple(string $_, string skins->{$_}{name}), sort { skins->{$a}{name} cmp skins->{$b}{name} } keys skins->%*;
- $data .= def languages => 'List (String, String)' => list map tuple(string $_, string $LANGUAGE{$_}), sort { $LANGUAGE{$a} cmp $LANGUAGE{$b} } keys %LANGUAGE;
+ $data .= def languages => 'List (String, String)' => list map tuple(string $_, string $LANGUAGE{$_}{txt}), sort { $LANGUAGE{$a}{txt} cmp $LANGUAGE{$b}{txt} } keys %LANGUAGE;
$data .= def platforms => 'List (String, String)' => list map tuple(string $_, string $PLATFORM{$_}), keys %PLATFORM;
$data .= def releaseTypes => 'List (String, String)' => list map tuple(string $_, string $RELEASE_TYPE{$_}), keys %RELEASE_TYPE;
$data .= def media => 'List (String, String, Bool)' => list map tuple(string $_, string $MEDIUM{$_}{txt}, $MEDIUM{$_}{qty}?'True':'False'), keys %MEDIUM;
@@ -428,6 +436,7 @@ sub write_types {
$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;
@@ -449,59 +458,36 @@ sub write_types {
sub write_extlinks {
my $data =<<~'_';
import Regex
- import Gen.ReleaseEdit as GRE
- type alias Site a =
+ type alias Site =
{ name : String
- , fmt : String
- , regex : Regex.Regex
- , multi : Bool
- , links : a -> List String
- , del : Int -> a -> a
- , add : String -> a -> a
- , patt : List String
+ , advid : String
}
-
- reg r = Maybe.withDefault Regex.never (Regex.fromStringWith {caseInsensitive=False, multiline=False} r)
- delidx n l = List.take n l ++ List.drop (n+1) l
- toint v = Maybe.withDefault 0 (String.toInt v)
-
- -- Link extraction functions for `Site.links`, i=integer, s=string, m=multi
- li v = if v == 0 then [] else [String.fromInt v]
- lim = List.map String.fromInt
- ls v = if v == "" then [] else [v]
- lsm v = v
_
my sub links {
- my($name, $type, @links) = @_;
- $data .= def $name.'Sites' => "List (Site $type)" => list map {
+ 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}),
- 'fmt = '.string($l->{fmt}),
- 'regex = reg '.string(TUWF::Validate::Interop::_re_compat($l->{regex})),
- 'multi = '.($l->{multi}?'True':'False'),
- 'links = '.sprintf('(\m -> l%s%s m.%s)', $l->{int}?'i':'s', $l->{multi}?'m':'', $l->{id}),
- 'del = (\i m -> { m | '.$l->{id}.' = '.($l->{multi} ? "delidx i m.$l->{id}" : $l->{default}).' })',
- 'add = (\v m -> { m | '.$l->{id}.' = '.($l->{multi} ? "m.$l->{id} ++ [$addval]" : $addval).' })',
- 'patt = ['.join(', ', map string($_), $l->{pattern}->@*).']'
+ 'advid = '.string($l->{id} =~ s/^l_//r),
)."\n }";
} @links;
}
- links release => 'GRE.RecvExtlinks' => VNDB::ExtLinks::extlinks_sites('r');
+ links release => VNDB::ExtLinks::extlinks_sites('r');
+ links staff => VNDB::ExtLinks::extlinks_sites('s');
write_module ExtLinks => $data;
}
if(tuwf->{elmgen}) {
- mkdir config->{root}.'/elm/Gen';
write_api;
write_types;
write_extlinks;
- open my $F, '>', config->{root}.'/elm/Gen/.generated';
+ open my $F, '>', config->{gen_path}.'/elm/Gen/.generated';
print $F scalar gmtime;
}
diff --git a/lib/VNWeb/Filters.pm b/lib/VNWeb/Filters.pm
index 93776392..b422ad8c 100644
--- a/lib/VNWeb/Filters.pm
+++ b/lib/VNWeb/Filters.pm
@@ -14,8 +14,8 @@ our @EXPORT = qw/filter_parse filter_vn_adv filter_release_adv filter_char_adv f
my $VN = form_compile any => {
- date_before => { required => 0, uint => 1, range => [0, 99999999] }, # don't use 'rdate' validation here, the search form allows invalid dates
- date_after => { required => 0, uint => 1, range => [0, 99999999] }, # ^
+ 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 },
@@ -24,7 +24,7 @@ my $VN = form_compile any => {
tag_exc => { undefarray => { id => 1 } },
taginc => { undefarray => {} }, # [old] Tag search by name
tagexc => { undefarray => {} }, # [old] Tag search by name
- tagspoil => { required => 0, default => 0, uint => 1, range => [0,2] },
+ tagspoil => { default => 0, uint => 1, range => [0,2] },
lang => { undefarray => { enum => \%LANGUAGE } },
olang => { undefarray => { enum => \%LANGUAGE } },
plat => { undefarray => { enum => \%PLATFORM } },
@@ -37,13 +37,13 @@ my $VN = form_compile any => {
};
my $RELEASE = form_compile any => {
- type => { required => 0, enum => \%RELEASE_TYPE },
+ type => { default => undef, enum => \%RELEASE_TYPE },
patch => { undefbool => 1 },
freeware => { undefbool => 1 },
doujin => { undefbool => 1 },
uncensored => { undefbool => 1 },
- date_before => { required => 0, range => [0, 99999999] }, # don't use 'rdate' validation here, the search form allows invalid dates
- date_after => { required => 0, range => [0, 99999999] }, # ^
+ 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 } },
@@ -56,29 +56,29 @@ my $RELEASE = form_compile any => {
voiced => { undefarray => { enum => \%VOICED } },
ani_story => { undefarray => { enum => \%ANIMATED } },
ani_ero => { undefarray => { enum => \%ANIMATED } },
- engine => { required => 0 },
+ engine => { default => undef },
};
my $CHAR = form_compile any => {
gender => { undefarray => { enum => \%GENDER } },
bloodt => { undefarray => { enum => \%BLOOD_TYPE } },
- bust_min => { required => 0, uint => 1, range => [ 0, 32767 ] },
- bust_max => { required => 0, uint => 1, range => [ 0, 32767 ] },
- waist_min => { required => 0, uint => 1, range => [ 0, 32767 ] },
- waist_max => { required => 0, uint => 1, range => [ 0, 32767 ] },
- hip_min => { required => 0, uint => 1, range => [ 0, 32767 ] },
- hip_max => { required => 0, uint => 1, range => [ 0, 32767 ] },
- height_min => { required => 0, uint => 1, range => [ 0, 32767 ] },
- height_max => { required => 0, uint => 1, range => [ 0, 32767 ] },
- weight_min => { required => 0, uint => 1, range => [ 0, 32767 ] },
- weight_max => { required => 0, uint => 1, range => [ 0, 32767 ] },
- cup_min => { required => 0, enum => \%CUP_SIZE },
- cup_max => { required => 0, enum => \%CUP_SIZE },
+ 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 => { required => 0, default => 0, uint => 1, range => [0,2] },
+ tagspoil => { default => 0, uint => 1, range => [0,2] },
role => { undefarray => { enum => \%CHAR_ROLE } },
};
@@ -102,7 +102,7 @@ sub filter_vn_compat {
next if !$l;
$l = [ map lc($_), ref $l ? @$l : $l ];
$fil->{ s/^tag/tag_/rg } ||= [ map $_->{id}, tuwf->dbAlli(
- 'SELECT DISTINCT id FROM tags LEFT JOIN tags_aliases ON id = tag WHERE searchable AND lower(name) IN', $l, 'OR lower(alias) IN', $l
+ 'SELECT DISTINCT id FROM tags WHERE searchable AND lower(name) IN', $l
)->@* ];
$mod++;
}
@@ -161,8 +161,8 @@ sub filter_vn_adv {
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->{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}->@* ] : (),
@@ -211,7 +211,7 @@ 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->{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} ] : (),
diff --git a/lib/VNWeb/Graph.pm b/lib/VNWeb/Graph.pm
index d25bd61c..8505923c 100644
--- a/lib/VNWeb/Graph.pm
+++ b/lib/VNWeb/Graph.pm
@@ -5,7 +5,6 @@ package VNWeb::Graph;
use v5.26;
use AnyEvent::Util;
use TUWF::XML 'xml_escape';
-use Encode 'encode_utf8', 'decode_utf8';
use Exporter 'import';
use List::Util 'max';
use VNDB::Config;
@@ -44,7 +43,7 @@ sub gen_nodes {
sub dot2svg {
my($dot) = @_;
- $dot = encode_utf8 $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";
@@ -55,13 +54,16 @@ sub dot2svg {
# - 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...)
- decode_utf8($out)
- =~ s/<\?xml.+?\?>//r
+ utf8::decode $out or die;
+ $out=~ s/<\?xml.+?\?>//r
=~ s/<!DOCTYPE[^>]*>//r
=~ s/<!--.*?-->//srg
=~ s/<title>.+?<\/title>//gr
=~ s/<polygon.+?\/>//r
- =~ s/(?:stroke|fill)="([^"]+)"/$1 eq '#111111' ? 'class="border"' : $1 eq '#222222' ? 'class="nodebg"' : ''/egr;
+ =~ 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;
}
diff --git a/lib/VNWeb/HTML.pm b/lib/VNWeb/HTML.pm
index 132e46fa..13df2256 100644
--- a/lib/VNWeb/HTML.pm
+++ b/lib/VNWeb/HTML.pm
@@ -4,37 +4,39 @@ use v5.26;
use warnings;
use utf8;
use Algorithm::Diff::XS 'sdiff', 'compact_diff';
-use Encode 'encode_utf8', 'decode_utf8';
use JSON::XS;
use TUWF ':html5_', 'uri_escape', 'html_escape', 'mkclass';
use Exporter 'import';
-use POSIX 'ceil', 'strftime';
+use POSIX 'ceil', 'floor', 'strftime';
use Carp 'croak';
+use Digest::SHA;
use JSON::XS;
use VNDB::Config;
use VNDB::BBCode;
use VNDB::Skins;
+use VNDB::Types;
use VNWeb::Auth;
use VNWeb::Validation;
use VNWeb::DB;
-use VNDB::Func 'fmtdate';
+use VNDB::Func 'fmtdate', 'rdate', 'tattr';
our @EXPORT = qw/
clearfloat_
+ platform_
debug_
join_
- user_ user_displayname
- rdate rdate_
+ user_maybebanned_ user_ user_displayname
+ rdate_
+ vnlength_
spoil_
- elm_
+ elm_ widget
framework_
- revision_
+ revision_patrolled_ revision_
paginate_
sortable_
searchbox_
itemmsg_
editmsg_
- advsearch_msg_
/;
@@ -42,6 +44,12 @@ our @EXPORT = qw/
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;
@@ -64,6 +72,17 @@ sub join_($&@) {
}
+sub user_maybebanned_ {
+ my($obj) = shift;
+ my($prefix) = shift||'user_';
+ my sub f($) { $obj->{"${prefix}$_[0]"} }
+ span_ title => join("\n",
+ !f 'perm_board' ? "Banned from posting" : (),
+ !f 'perm_edit' ? "Banned from editing" : (),
+ ), '🚫' if defined f 'perm_board' && (!f 'perm_board' || !f 'perm_edit');
+}
+
+
# Display a user link, the given object must have the columns as fetched using DB::sql_user().
# Args: $object, $prefix, $capital
sub user_ {
@@ -72,13 +91,16 @@ sub user_ {
my $capital = shift;
my sub f($) { $obj->{"${prefix}$_[0]"} }
- return b_ class => 'grayedout', 'anonymous' if !f 'id';
+ my $softdel = !defined f 'name';
+ return small_ 'anonymous' if ($softdel && !auth->isMod) || !f 'id';
my $fancy = !(auth->pref('nodistract_can') && auth->pref('nodistract_nofancy'));
my $uniname = f 'uniname_can' && f 'uniname';
a_ href => '/'.f('id'),
+ $softdel ? (class => 'grayedout') : (),
$fancy && $uniname ? (title => f('name'), $uniname) :
- (!$fancy && $uniname ? (title => $uniname) : (), $capital ? ucfirst f 'name' : f 'name');
+ (!$fancy && $uniname ? (title => $uniname) : (), ($capital ? f 'name' : f 'name') // f 'id');
txt_ '⭐' if $fancy && f 'support_can' && f 'support_enabled';
+ user_maybebanned_ $obj, $prefix;
}
@@ -90,18 +112,7 @@ sub user_displayname {
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'
-}
-
-
-# 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);
+ $fancy && f 'uniname_can' && f 'uniname' ? f 'uniname' : f('name') // f 'id'
}
# Display a release date.
@@ -111,6 +122,16 @@ sub rdate_ {
}
+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;
@@ -123,29 +144,36 @@ sub spoil_ {
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')) : () ];
- div_ id => sprintf('elm%d', $#{ tuwf->req->{pagevars}{elm} }), $placeholder//'';
+ my @arg = (id => sprintf 'elm%d', $#{ tuwf->req->{pagevars}{elm} });
+ $placeholder ? $placeholder->(@arg) : div_ @arg, '';
}
+# Instantiate a JS widget.
+# Used as attribute to a html tag, which will then be used as parent node for the widget.
+# $schema is optional, if present it is used to normalize the data.
+sub widget {
+ my($name, $schema, $data) = @_;
+ $data = $data ? $schema->analyze->coerce_for_json($data, unknown => 'remove') : $schema;
+ tuwf->req->{widget_id} //= 0;
+ tuwf->req->{js}{ VNWeb::JS::widgets()->{$name} // die "No bundle found for widget '$name'" } = 1;
+ my $id = ++tuwf->req->{widget_id};
+ push tuwf->req->{pagevars}{widget}{$name}->@*, [ $id, $data ];
+ (id => sprintf 'widget%d', $id)
+}
+
-sub _sanitize_css {
- # This function is attempting to do the impossible: Sanitize user provided
- # CSS against various attacks. I'm not expecting this to be bullet-proof.
- # 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.
- s/&/&amp;/g;
- s/</&lt;/g;
- $_;
+# 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;
+ };
}
@@ -153,26 +181,27 @@ sub _head_ {
my $o = shift;
my $fancy = !(auth->pref('nodistract_can') && auth->pref('nodistract_nofancy'));
- my $pubskin = $fancy && $o->{type} && $o->{type} eq 'u' && $o->{dbobj} ? tuwf->dbRowi(
- 'SELECT customcss, skin FROM users WHERE pubskin_can AND pubskin_enabled AND id =', \$o->{dbobj}{id}
+ my $pubskin = $fancy && $o->{dbobj} && $o->{dbobj}{id} =~ /^u/ ? tuwf->dbRowi(
+ 'SELECT u.id, customcss_csum, skin FROM users u JOIN users_prefs up ON up.id = u.id WHERE pubskin_can AND pubskin_enabled AND u.id =', \$o->{dbobj}{id}
) : {};
my $skin = tuwf->reqGet('skin') || $pubskin->{skin} || auth->pref('skin') || '';
$skin = config->{skin_default} if !skins->{$skin};
- my $customcss = $pubskin->{customcss} || auth->pref('customcss');
+ my $customcss = $pubskin->{customcss_csum} ? [ $pubskin->{id}, $pubskin->{customcss_csum} ] :
+ auth->pref('customcss_csum') ? [ auth->uid, auth->pref('customcss_csum') ] : undef;
meta_ charset => 'utf-8';
title_ $o->{title}.' | vndb';
base_ href => tuwf->reqURI();
link_ rel => 'shortcut icon', href => '/favicon.ico', type => 'image/x-icon';
- link_ rel => 'stylesheet', href => config->{url_static}.'/g/'.$skin.'.css?'.config->{version}, type => 'text/css', media => 'all';
+ 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';
- style_ type => 'text/css', sub { lit_ _sanitize_css $customcss } if $customcss;
+ link_ rel => 'stylesheet', href => sprintf '/%s.css?%x', $customcss->[0], $customcss->[1] if $customcss;
+ meta_ name => 'viewport', content => 'width=device-width, initial-scale=1.0, user-scalable=yes' if tuwf->reqGet('mobile-test');
if($o->{feeds}) {
link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/announcements.atom", title => 'Site Announcements';
link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/changes.atom", title => 'Recent Changes';
link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/posts.atom", title => 'Recent Posts';
}
- meta_ name => 'csrf-token', content => auth->csrftoken;
meta_ name => 'robots', content => 'noindex' if !$o->{index} || tuwf->reqGet('view');
# Opengraph metadata
@@ -191,25 +220,24 @@ sub _menu_ {
my $o = shift;
div_ id => 'support', sub {
- a_ href => 'https://www.patreon.com/vndb', id => 'patreon', sub {
- img_ src => config->{url_static}.'/f/patreon.png', alt => 'Support VNDB on Patreon', width => 160, height => 38;
- };
- a_ href => 'https://www.subscribestar.com/vndb', id => 'subscribestar', sub {
- img_ src => config->{url_static}.'/f/subscribestar.png', alt => 'Support VNDB on SubscribeStar', width => 160, height => 38;
- };
+ strong_ 'Support VNDB';
+ p_ sub {
+ a_ href => 'https://www.patreon.com/vndb', 'Patreon';
+ a_ href => 'https://www.subscribestar.com/vndb', 'SubscribeStar';
+ }
} if !(auth->pref('nodistract_can') && auth->pref('nodistract_noads'));
- div_ class => 'menubox', sub {
+ article_ sub {
h2_ 'Menu';
div_ sub {
a_ href => '/', 'Home'; br_;
a_ href => '/v', 'Visual novels'; br_;
- b_ class => 'grayedout', '> '; a_ href => '/g', 'Tags'; br_;
+ small_ '> '; a_ href => '/g', 'Tags'; br_;
a_ href => '/r', 'Releases'; br_;
- a_ href => '/p/all', 'Producers'; br_;
+ a_ href => '/p', 'Producers'; br_;
a_ href => '/s', 'Staff'; br_;
a_ href => '/c', 'Characters'; br_;
- b_ class => 'grayedout', '> '; a_ href => '/i', 'Traits'; 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_;
@@ -217,34 +245,32 @@ sub _menu_ {
a_ href => '/v/rand','Random visual novel'; br_;
a_ href => '/d11', 'API'; lit_ ' - ';
a_ href => '/d14', 'Dumps'; lit_ ' - ';
- a_ href => '/d18', 'Query';
+ a_ href => 'https://query.vndb.org/about', 'Query';
};
- form_ action => '/v', method => 'get', id => 'search', sub {
+ form_ action => '/v', method => 'get', sub {
fieldset_ sub {
- legend_ 'Search';
input_ type => 'text', class => 'text', id => 'sq', name => 'sq', value => $o->{search}||'', placeholder => 'search';
- input_ type => 'submit', class => 'submit', value => 'Search';
+ input_ type => 'submit', class => 'hidden', value => 'Search';
}
}
};
- div_ class => 'menubox', sub {
+ article_ sub {
my $uid = '/'.auth->uid;
- my $nc = auth && tuwf->dbVali('SELECT count(*) FROM notifications WHERE uid =', \auth->uid, 'AND read IS NULL');
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", $nc ? (class => 'notifyget') : (), 'My Notifications'.($nc?" ($nc)":''); br_;
+ a_ href => "$uid/notifies", $o->{unread_noti} ? (class => 'notifyget') : (), 'My Notifications'.($o->{unread_noti}?" ($o->{unread_noti})":''); br_;
a_ href => "$uid/hist", 'My Recent Changes'; br_;
a_ href => '/g/links?u='.auth->uid, 'My Tags'; br_;
br_;
- if(auth->permImgvote) {
+ if(VNWeb::Images::Vote::can_vote()) {
a_ href => '/img/vote', 'Image Flagging'; br_;
}
- if(auth->permEdit) {
+ 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_;
@@ -252,13 +278,15 @@ sub _menu_ {
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 WHERE id =", \auth->uid, ")) AS unseen,
- (SELECT count(*) FROM reports WHERE lastmod > (SELECT last_reports FROM users WHERE id =", \auth->uid, ")) AS upd
+ (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};
- b_ class => 'grayedout', ' | ';
+ 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 {
@@ -268,29 +296,28 @@ sub _menu_ {
}
} if auth;
- div_ class => 'menubox', sub {
+ article_ sub {
h2_ 'User menu';
div_ sub {
- my $ref = uri_escape tuwf->reqPath().tuwf->reqQuery();
+ my $ref = uri_escape(tuwf->reqGet('ref') || tuwf->reqPath().tuwf->reqQuery());
a_ href => "/u/login?ref=$ref", 'Login'; br_;
- a_ href => '/u/newpass', 'Password reset'; br_;
a_ href => '/u/register', 'Register'; br_;
}
- } if !auth;
+ } if !auth && !config->{read_only};
- div_ class => 'menubox', sub {
+ article_ sub {
h2_ 'Database Statistics';
div_ sub {
dl_ sub {
my %stats = map +($_->{section}, $_->{count}), tuwf->dbAll('SELECT * FROM stats_cache')->@*;
dt_ 'Visual Novels'; dd_ $stats{vn};
- dt_ sub { b_ class => 'grayedout', '> '; lit_ 'Tags' };
+ 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 { b_ class => 'grayedout', '> '; lit_ 'Traits' };
+ dt_ sub { small_ '> '; lit_ 'Traits' };
dd_ $stats{traits};
};
clearfloat_;
@@ -300,30 +327,33 @@ sub _menu_ {
sub _footer_ {
- my $q = tuwf->dbRow('SELECT vid, quote FROM quotes ORDER BY RANDOM() LIMIT 1');
- if($q && $q->{vid}) {
+ my($o) = @_;
+ my $q = tuwf->dbRow('SELECT vid, quote FROM quotes WHERE rand <= (SELECT random()) ORDER BY rand DESC LIMIT 1');
+ span_ sub {
lit_ '"';
- a_ href => "/$q->{vid}", style => 'text-decoration: none', $q->{quote};
- txt_ '"';
+ a_ href => "/$q->{vid}", $q->{quote};
+ txt_ '" ';
br_;
- }
+ } if $q && $q->{vid};
a_ href => config->{source_url}, config->{version};
txt_ ' | ';
+ a_ href => '/d17', 'privacy & content policy';
+ txt_ ' | ';
a_ href => '/d7', 'about us';
lit_ ' | ';
- a_ href => 'irc://irc.synirc.net/vndb', '#vndb';
+ a_ href => '/.env', 'security';
+ lit_ ' | ';
+ a_ href => '/ads.txt', 'advertising';
lit_ ' | ';
a_ href => sprintf('mailto:%s', config->{admin_email}), config->{admin_email};
if(tuwf->debug) {
lit_ ' | ';
- a_ href => '#', onclick => 'document.getElementById(\'pagedebuginfo\').classList.toggle(\'hidden\');return false', 'debug';
- lit_ ' | ';
debug_ tuwf->req->{pagevars};
br_;
tuwf->dbCommit; # Hack to measure the commit time
- my(@sql_r, @sql_i) = @_;
+ 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;
@@ -335,15 +365,18 @@ sub _footer_ {
my $sql_r = join "\n", @sql_r;
my $sql_i = join "\n", @sql_i;
my $modules = join "\n", sort keys %INC;
- pre_ id => 'pagedebuginfo', class => 'hidden', 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";
+ 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 !~ /^[twvrpcsdi]/;
+ return if !auth || $id !~ /^[twvrpcsdig]/;
my $noti =
$id =~ /^t/ ? tuwf->dbVali('SELECT SUM(x) FROM (
@@ -356,32 +389,29 @@ sub _maintabs_subscribe_ {
UNION SELECT 1+1 FROM reviews w, users u WHERE u.id =', \auth->uid, 'AND w.uid =', \auth->uid, 'AND w.id =', \$id, 'AND u.notify_comment
) x(x)')
- : $id =~ /^[vrpcsd]/ && auth->pref('notify_dbedit') && tuwf->dbVali('
+ : $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_ id => 'subscribe', sub {
- elm_ Subscribe => $VNWeb::User::Notifications::SUB, {
- id => $id,
- noti => $noti||0,
- subnum => $sub->{subnum},
- subreview => $sub->{subreview}||0,
- subapply => $sub->{subapply}||0,
- }, sub {
- a_ href => '#', class => ($noti && (!defined $sub->{subnum} || $sub->{subnum})) || $sub->{subnum} || $sub->{subreview} || $sub->{subapply} ? 'active' : 'inactive', '🔔';
- };
+ li_ widget(Subscribe => $VNWeb::User::Notifications::SUB, {
+ id => $id,
+ noti => $noti||0,
+ subnum => $sub->{subnum},
+ subreview => $sub->{subreview}||0,
+ subapply => $sub->{subapply}||0,
+ }), class => 'maintabs-dd subscribe', sub {
+ a_ href => '#', class => ($noti && (!defined $sub->{subnum} || $sub->{subnum})) || $sub->{subnum} || $sub->{subreview} || $sub->{subapply} ? 'active' : 'inactive', '🔔';
};
}
sub _maintabs_ {
my $opt = shift;
- my($t, $o, $sel) = @{$opt}{qw/type dbobj tab/};
- return if !$t || !$o;
- return if $t eq 'g' && !auth->permTagmod;
+ my($o, $sel) = @{$opt}{qw/dbobj tab/};
- my $id = $o->{id} =~ /^[0-9]*$/ ? $t.$o->{id} : $o->{id};
+ my $id = $o ? $o->{id} : '';
+ my($t) = $o ? $id =~ /^(.)/ : '';
my sub t {
my($tabname, $url, $text) = @_;
@@ -390,19 +420,24 @@ sub _maintabs_ {
};
};
- div_ class => 'maintabs right', sub {
- ul_ sub {
- t '' => "/$id", $id if $t ne 't';
+ 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 $t ne 't' && can_edit $t, $o;
+ t edit => "/$id/edit", 'edit' if $o && $t ne 't' && can_edit $t, $o;
t copy => "/$id/copy", 'copy' if $t =~ /[rc]/ && can_edit $t, $o;
t tagmod => "/$id/tagmod", 'modify tags' if $t eq 'v' && auth->permTag && !$o->{entry_hidden};
do {
+ t admin => "/$id/admin", 'admin' if auth->isMod;
t list => "/$id/ulist?vnlist=1", 'list';
t votes => "/$id/ulist?votes=1", 'votes';
t wish => "/$id/ulist?wishlist=1", 'wishlist';
@@ -419,19 +454,19 @@ sub _maintabs_ {
t disc => "/t/$id", "discussions ($cnt)";
};
- t hist => "/$id/hist", 'history' if $t =~ /[uvrpcsd]/;
+ t hist => "/$id/hist", 'history' if $t =~ /[uvrpcsdgi]/;
_maintabs_subscribe_ $o, $id;
}
}
}
-# Attempt to figure out the board id from a database entry ($type, $dbobj) combination
+# Attempt to figure out the board id from a database entry
sub _board_id {
- my($type, $obj) = @_;
- $type =~ /[vp]/ ? $obj->{id} :
- $type eq 'r' && $obj->{vn}->@* ? $obj->{vn}[0]{vid} :
- $type eq 'c' && $obj->{vns}->@* ? $obj->{vns}[0]{vid} : 'db';
+ my($obj) = @_;
+ $obj->{id} =~ /^[vp]/ ? $obj->{id} :
+ $obj->{id} =~ /^r/ && $obj->{vn} && $obj->{vn}->@* ? $obj->{vn}[0]{vid} :
+ $obj->{id} =~ /^c/ && $obj->{vns} && $obj->{vns}->@* ? $obj->{vns}[0]{vid} : 'db';
}
@@ -439,36 +474,53 @@ sub _board_id {
sub _hidden_msg_ {
my $o = shift;
- die "Can't use hiddenmsg on an object that is missing 'entry_hidden'" if !exists $o->{dbobj}{entry_hidden};
+ die "Can't use hiddenmsg on an object that is missing 'entry_hidden' or 'entry_locked'"
+ if !exists $o->{dbobj}{entry_hidden} || !exists $o->{dbobj}{entry_locked};
+
return 0 if !$o->{dbobj}{entry_hidden};
- my $msg = tuwf->dbVali(
- 'SELECT comments
+ # Awaiting moderation
+ if(!$o->{dbobj}{entry_locked}) {
+ article_ sub {
+ h1_ $o->{title};
+ div_ class => 'notice', sub {
+ h2_ 'Waiting for approval';
+ p_ 'This entry is waiting for a moderator to approve it.';
+ }
+ };
+ return 0;
+ }
+
+ # Deleted.
+ my $msg = tuwf->dbRowi(
+ 'SELECT comments, rev
FROM changes
WHERE itemid =', \$o->{dbobj}{id},
'ORDER BY id DESC LIMIT 1'
);
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $o->{title};
div_ class => 'warning', sub {
h2_ 'Item deleted';
p_ sub {
- if($o->{type} eq 'r' && $o->{dbobj}{vn}) {
+ if($o->{dbobj}{id} =~ /^r/ && $o->{dbobj}{vn}) {
txt_ 'This was a release entry for ';
- join_ ',', sub { a_ href => "/$_->{vid}", $_->{title} }, $o->{dbobj}{vn}->@*;
+ join_ ',', sub { a_ href => "/$_->{vid}", tattr $_ }, $o->{dbobj}{vn}->@*;
txt_ '.';
br_;
}
txt_ 'This item has been deleted from the database. You may file a request on the ';
- a_ href => '/t/'._board_id($o->{type}, $o->{dbobj}), "discussion board";
+ a_ href => '/t/'._board_id($o->{dbobj}), "discussion board";
txt_ ' if you believe that this entry should be restored.';
- br_;
- br_;
- lit_ bb_format $msg;
+ if($msg->{rev} > 1) {
+ br_;
+ br_;
+ lit_ bb_format $msg->{comments};
+ }
}
}
};
- !auth->permDbmod # dbmods can still see the page
+ $o->{dbobj}{id} !~ /^[gi]/ && !auth->permDbmod # tags/traits are still visible, dbmods can still see all pages
}
@@ -476,50 +528,73 @@ sub _hidden_msg_ {
# title => $title
# index => 1/0, default 0
# feeds => 1/0
-# js => 1/0, set to 1 to ensure 'plain.js' is included on the page even if no elm_() modules are loaded.
+# js => 1/0, set to 1 to ensure 'basic.js' is included on the page even if no elm_() modules or JS widgets are loaded.
# search => $query
# og => { opengraph metadata }
-# type => Database entry type (used for the main tabs & hidden message) (obsolete, inferred from dbobj->{id})
# dbobj => Database entry object (used for the main tabs & hidden message)
# Recognized object fields: id, entry_hidden, entry_locked
# tab => Current tab, or empty for the main tab
# hiddenmsg => 1/0, if true and dbobj is 'hidden', a message will be displayed
-# and the content function will not be called.
+# and the content function may not be called.
# sub { content }
sub framework_ {
my $cont = pop;
my %o = @_;
- tuwf->req->{pagevars} = { $o{pagevars}->%* } if $o{pagevars};
- tuwf->req->{js} ||= $o{js};
- $o{type} ||= $1 if $o{dbobj} && $o{dbobj}{id} =~ /^([a-z])/;
-
+ tuwf->req->{pagevars} = { tuwf->req->{pagevars} ? tuwf->req->{pagevars}->%* : (), $o{pagevars}->%* } if $o{pagevars};
+ $o{unread_noti} = auth && tuwf->dbVali('SELECT count(*) FROM notifications WHERE uid =', \auth->uid, 'AND read IS NULL');
+
+ lit_ "<!--\n"
+ ." This HTML is an unreadable auto-generated mess, sorry for that.\n"
+ ." The full source code of this site can be found at ".config->{source_url}."\n"
+ .(tuwf->req->{trace_loc}[0] ?
+ " This particular page was generated by ".config->{source_url}."/src/branch/master/lib/".(tuwf->req->{trace_loc}[0] =~ s/::/\//rg).".pm\n" : '')
+ ."-->\n";
html_ lang => 'en', sub {
head_ sub { _head_ \%o };
body_ sub {
- div_ id => 'bgright', ' ';
- div_ id => 'header', sub { h1_ sub { a_ href => '/', 'the visual novel database' } };
- div_ id => 'menulist', sub { _menu_ \%o };
- div_ id => 'maincontent', sub {
+ input_ type => 'checkbox', class => 'hidden', id => 'mainmenu', name => 'mainmenu';
+ header_ sub {
+ div_ id => 'bgright', ' ';
+ div_ id => 'readonlymode', config->{read_only} eq 1 ? 'The site is in read-only mode, account functionality is currently disabled.' : config->{read_only} if config->{read_only};
+ h1_ sub { a_ href => '/', 'the visual novel database' };
_maintabs_ \%o;
+ };
+ nav_ sub { _menu_ \%o };
+ main_ sub {
$cont->() unless $o{hiddenmsg} && _hidden_msg_ \%o;
- div_ id => 'footer', \&_footer_;
+ footer_ sub { _footer_ \%o };
};
+
+ # '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_ type => 'application/javascript', src => config->{url_static}.'/g/elm.js?'.config->{version}, '' if tuwf->req->{pagevars}{elm};
- script_ type => 'application/javascript', src => config->{url_static}.'/g/plain.js?'.config->{version}, '' if tuwf->req->{js} || tuwf->req->{pagevars}{elm};
+
+ 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) = @_;
- b_ "Revision $obj->{chrev}";
+ strong_ "Revision $obj->{chrev}";
debug_ $obj;
if(auth) {
lit_ ' (';
@@ -528,6 +603,16 @@ sub _revision_header_ {
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_;
@@ -540,7 +625,7 @@ sub _revision_header_ {
sub _revision_fmtval_ {
my($opt, $val, $obj) = @_;
- return i_ '[empty]' if !defined $val || !length $val || (defined $opt->{empty} && $val eq $opt->{empty});
+ 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};
@@ -556,10 +641,10 @@ sub _revision_fmtcol_ {
my($opt, $i, $l, $obj) = @_;
my $ctx = 100; # Number of characters of context in textual diffs
- my sub sep_ { b_ class => 'standout', '<...>' }; # Context separator
+ my sub sep_ { b_ '<...>' }; # Context separator
td_ class => 'tcval', sub {
- i_ '[empty]' if @$l > 1 && (($i == 1 && !grep $_->[0] ne '+', @$l) || ($i == 2 && !grep $_->[0] ne '-', @$l));
+ 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];
@@ -567,12 +652,12 @@ sub _revision_fmtcol_ {
if($diff) {
my $lastchunk = int (($#$diff-2)/2);
for my $n (0..$lastchunk) {
- my $a = decode_utf8 join '', @{$old}[ $diff->[$n*2] .. $diff->[$n*2+2]-1 ];
- my $b = decode_utf8 join '', @{$new}[ $diff->[$n*2+1] .. $diff->[$n*2+3]-1 ];
+ utf8::decode(my $a = join '', @{$old}[ $diff->[$n*2] .. $diff->[$n*2+2]-1 ]);
+ utf8::decode(my $b = join '', @{$new}[ $diff->[$n*2+1] .. $diff->[$n*2+3]-1 ]);
# Difference, highlight and display in full
if($n % 2) {
- b_ class => $i == 1 ? 'diff_del' : 'diff_add', sub { lit_ html_escape $i == 1 ? $a : $b };
+ span_ class => $i == 1 ? 'diff_del' : 'diff_add', sub { lit_ html_escape $i == 1 ? $a : $b };
# Short context, display in full
} elsif(length $a < $ctx*3) {
lit_ html_escape $a;
@@ -589,9 +674,9 @@ sub _revision_fmtcol_ {
}
} elsif(@$l > 1 && $i == 2 && ($ch eq '+' || $ch eq 'c')) {
- b_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val, $obj };
+ span_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val, $obj };
} elsif(@$l > 1 && $i == 1 && ($ch eq '-' || $ch eq 'c')) {
- b_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val, $obj };
+ span_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val, $obj };
} elsif($ch eq 'u' || @$l == 1) {
_revision_fmtval_ $opt, $val, $obj;
}
@@ -621,6 +706,9 @@ sub _revision_diff_ {
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;
@@ -634,8 +722,8 @@ sub _revision_diff_ {
# Do a word-based diff if this is a large chunk of text, otherwise character-based.
my $split = length $item->[1] > 1024 ? qr/([ ,\n]+)/ : qr//;
- $item->[1] = [map encode_utf8($_), split $split, $item->[1]];
- $item->[2] = [map encode_utf8($_), split $split, $item->[2]];
+ $item->[1] = [map { utf8::encode($_); $_ } split $split, $item->[1]];
+ $item->[2] = [map { utf8::encode($_); $_ } split $split, $item->[2]];
$item->[3] = compact_diff $item->[1], $item->[2];
}
@@ -650,6 +738,9 @@ sub _revision_diff_ {
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 {
@@ -660,7 +751,7 @@ sub _revision_cmp_ {
tr_ sub {
td_ ' ';
td_ colspan => 2, sub {
- b_ "Edit summary for revision $new->{chrev}";
+ strong_ "Edit summary for revision $new->{chrev}";
br_;
br_;
lit_ bb_format $new->{rev_comments}||'-';
@@ -668,8 +759,7 @@ sub _revision_cmp_ {
};
};
_revision_diff_ $old, $new, @$_ for(
- [ hidden => 'Hidden', fmt => 'bool' ],
- [ locked => 'Locked', fmt => 'bool' ],
+ [ _entry_state => 'State', fmt => {0 => 'Normal', 1 => 'Locked', 2 => 'Awaiting approval', 3 => 'Deleted'} ],
@fields,
);
};
@@ -699,6 +789,8 @@ sub _revision_cmp_ {
# If not given, the field is rendered as plain text and changes are highlighted with a diff.
# \%HASH -> Look the field up in the hash table (values should be string or {txt=>string}.
# sub($value) {$_} -> Custom formatting function, should output TUWF::XML data HTML.
+# txt => sub{$_} - Text formatting function for individual values.
+# Alternative to 'fmt' above; the returned value is treated as a text field with diffing support.
# join => sub{} - HTML to join multi-value fields, defaults to \&br_.
# empty => str - What value should be considered "empty", e.g. (empty => 0) for integer fields.
# undef or empty string are always considered empty values.
@@ -708,13 +800,27 @@ sub revision_ {
my $old = $new->{chrev} == 1 ? undef : db_entry $new->{id}, $new->{chrev} - 1;
$enrich->($old) if $old;
+ if(auth->permDbmod) {
+ my $f = tuwf->validate(get =>
+ patrolled => { default => 0, uint => 1 },
+ unpatrolled => { default => 0, uint => 1 },
+ )->data;
+ tuwf->dbExeci('INSERT INTO changes_patrolled', {id => $f->{patrolled}, uid => auth->uid}, 'ON CONFLICT (id,uid) DO NOTHING') if $f->{patrolled};
+ tuwf->dbExeci('DELETE FROM changes_patrolled WHERE', {id => $f->{unpatrolled}, uid => auth->uid}) if $f->{unpatrolled};
+ }
+
enrich_merge chid => sql(
- 'SELECT c.id AS chid, c.comments as rev_comments,', sql_totime('c.added'), 'as rev_added, ', sql_user('u', 'rev_user_'), '
+ 'SELECT c.id AS chid, c.comments as rev_comments,', sql_totime('c.added'), 'as rev_added, ', sql_user('u', 'rev_user_'), ', u.perm_dbmod AS rev_dbmod
FROM changes c LEFT JOIN users u ON u.id = c.requester
WHERE c.id IN'),
$new, $old||();
- div_ class => 'mainbox revision', sub {
+ enrich rev_patrolled => chid => id =>
+ sql('SELECT c.id,', sql_user(), 'FROM changes_patrolled c JOIN users u ON u.id = c.uid WHERE c.id IN'),
+ $new, $old||()
+ if auth->permDbmod;
+
+ article_ class => 'revision', sub {
h1_ "Revision $new->{chrev}";
a_ class => 'prev', href => sprintf('/%s.%d', $new->{id}, $new->{chrev}-1), '<- earlier revision' if $new->{chrev} > 1;
@@ -724,7 +830,7 @@ sub revision_ {
div_ class => 'rev', sub {
_revision_header_ $new;
br_;
- b_ 'Edit summary';
+ strong_ 'Edit summary';
br_; br_;
lit_ bb_format $new->{rev_comments}||'-';
} if !$old;
@@ -740,75 +846,105 @@ sub revision_ {
# current page number (1..n),
# nextpage (0/1 or, if the full count is known: [$total, $perpage]),
# alignment (t/b)
-# func
+# tableopts obj
sub paginate_ {
- my($url, $p, $np, $al, $fun) = @_;
+ my($url, $p, $np, $al, $tbl) = @_;
my($cnt, $pp) = ref($np) ? @$np : ($p+$np, 1);
- return if !$fun && $p == 1 && $cnt <= $pp;
+ return if !$tbl && $p == 1 && $cnt <= $pp;
my sub tab_ {
my($page, $label) = @_;
li_ sub {
local $_ = $page;
my $u = $url->(p => $page);
- a_ href => $u, $label;
+ a_ href => $u,
+ class => $page == $p ? 'highlightselected' : undef,
+ rel => $label && $label =~ /next/ ? 'next' : $label && $label =~ /prev/ ? 'prev' : undef,
+ $label//$page;
}
}
my sub ell_ {
- my($left) = @_;
- li_ mkclass(ellipsis => 1, left => $left), sub { b_ '⋯' };
+ li_ mkclass(ellipsis => 1), '⋯';
}
- my $nc = 5; # max. number of buttons on each side
-
- div_ class => 'maintabs browsetabs '.($al eq 't' ? '' : 'bottom'), sub {
- ul_ sub {
- $p > 2 and ref $np and tab_ 1, '« first';
- $p > $nc+1 and ref $np and ell_;
- $p > $_ and ref $np and tab_ $p-$_, $p-$_ for (reverse 2..($nc>$p-2?$p-2:$nc-1));
- $p > 1 and tab_ $p-1, '‹ previous';
- };
- $fun->() if $fun;
-
- ul_ sub {
- my $l = ceil($cnt/$pp)-$p+1;
- $l > 1 and tab_ $p+1, 'next ›';
- $l > $_ and tab_ $p+$_, $p+$_ for (2..($nc>$l-2?$l-2:$nc-1));
- $l > $nc+1 and ell_;
- $l > 2 and tab_ $l+$p-1, 'last »';
+ nav_ class => $al eq 't' ? undef : 'bottom', sub {
+ my $n = ceil($cnt/$pp);
+ my $l = $n-$p+1;
+ menu_ class => 'browsetabs', sub {
+ $p > 1 and tab_ $p-1, '‹ previous';
+ if(ref $np) {
+ $p > 3 and tab_ 1;
+ $p > 4 and ell_;
+ $_ > 0 and $_ <= $n and tab_ $_ for ($p-2..$p+2);
+ $l > 4 and ell_;
+ $l > 3 and tab_ $n;
+ }
+ $l > 1 and tab_ $p+1, 'next ›';
};
+
+ $tbl->widget_($url) if $tbl;
}
}
# Generate sort buttons for a table header. This function assumes that sorting
-# options are given as query parameters: 's' for the $column_name to sort on
-# and 'o' for order ('a'sc/'d'esc).
+# options are given either as a TableOpts parameter in 's' or as two query
+# parameters: 's' for the $column_name to sort on and 'o' for order ('a'/'d').
# Options: $column_title, $column_name, $opt, $url
# Where $url is a function that is given ('p', undef, 's', $column_name, 'o', $order) and returns a URL.
sub sortable_ {
- my($name, $opt, $url) = @_;
- $opt->{s} eq $name && $opt->{o} eq 'a' ? txt_ ' ▴' : a_ href => $url->(p => undef, s => $name, o => 'a'), ' ▴';
- $opt->{s} eq $name && $opt->{o} eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $name, o => 'd'), '▾';
+ my($name, $opt, $url, $space) = @_;
+ txt_ ' ' if $space || !defined $space;
+ if(ref $opt->{s}) {
+ my $o = $opt->{s}->sorted($name);
+ $o eq 'a' ? txt_ '▴' : a_ href => $url->(p => undef, s => $opt->{s}->sort_param($name, 'a')), '▴';
+ $o eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $opt->{s}->sort_param($name, 'd')), '▾';
+ } else {
+ $opt->{s} eq $name && $opt->{o} eq 'a' ? txt_ '▴' : a_ href => $url->(p => undef, s => $name, o => 'a'), '▴';
+ $opt->{s} eq $name && $opt->{o} eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $name, o => 'd'), '▾';
+ }
}
sub searchbox_ {
- my($sel, $value) = @_;
- tuwf->req->{js} = 1;
+ my($sel, $q) = @_;
+ tuwf->req->{js}{basic} = 1;
+
+ # Only fetch counts for queries that can use the trigram index
+ # (This length requirement is not ideal for Kanji, but pg_trgm doesn't
+ # discriminate between scripts)
+ my %counts = $q && (grep length($_)>=3, $q->words->@*) ?
+ map +($_->{type}, $_->{cnt}), tuwf->dbAlli('
+ SELECT vndbid_type(id) AS type, count(*) AS cnt
+ FROM (
+ SELECT DISTINCT id
+ FROM search_cache sc
+ WHERE', sql_and($q->where()), "
+ AND NOT (id BETWEEN '${sel}1' AND vndbid_max('$sel'))
+ ) x
+ GROUP BY vndbid_type(id)
+ ")->@* : ();
+
+ my sub lnk_ {
+ my($type, $label) = @_;
+ a_ href => "/$type", $sel eq $type ? (class => 'sel') : (), sub {
+ txt_ $label;
+ sup_ class => 'standout', $counts{$type} if $counts{$type};
+ };
+ }
+
fieldset_ class => 'search', sub {
p_ id => 'searchtabs', sub {
- a_ href => '/v', $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', $sel eq 's' ? (class => 'sel') : (), 'Staff';
- a_ href => '/c', $sel eq 'c' ? (class => 'sel') : (), 'Characters';
- a_ href => '/g', $sel eq 'g' ? (class => 'sel') : (), 'Tags';
- a_ href => '/i', $sel eq 'i' ? (class => 'sel') : (), 'Traits';
- a_ href => '/u/all', $sel eq 'u' ? (class => 'sel') : (), 'Users';
+ lnk_ v => 'Visual novels';
+ lnk_ r => 'Releases';
+ lnk_ p => 'Producers';
+ lnk_ s => 'Staff';
+ lnk_ c => 'Characters';
+ lnk_ g => 'Tags';
+ lnk_ i => 'Traits';
};
- input_ type => 'text', name => 'q', id => 'q', class => 'text', value => $value;
- input_ type => 'submit', class => 'submit', value => 'Search!';
+ input_ type => 'text', name => 'q', id => 'q', class => 'text', value => "$q";
+ input_ type => 'submit', class => 'submit', name => 'sb', value => 'Search!';
};
}
@@ -817,19 +953,19 @@ sub searchbox_ {
sub itemmsg_ {
my($obj) = @_;
p_ class => 'itemmsg', sub {
- if($obj->{id} !~ /^[dw]/) {
- if($obj->{entry_locked}) {
+ 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}", 'Report an issue on this page.';
- };
+ a_ href => "/report/$obj->{id}", $obj->{id} =~ /^u/ ? 'report user' : 'Report an issue on this page.';
+ } if !config->{read_only};
}
-# Generate the initial mainbox when adding or editing a database entry, with a
+# Generate the initial box when adding or editing a database entry, with a
# friendly message pointing to the guidelines and stuff.
# Args: $type ('v','r', etc), $obj (from db_entry(), or undef for new page), $page_title, $is_this_a_copy?
sub editmsg_ {
@@ -838,11 +974,20 @@ sub editmsg_ {
my $guidelines = {v => 2, r => 3, p => 4, c => 12, s => 16 }->{$type};
croak "Unknown type: $type" if !$typename;
- div_ class => 'mainbox', sub {
+ 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!";
@@ -855,8 +1000,7 @@ sub editmsg_ {
}
}
}
- # 'lastrev' is for compatibility with VNDB::*
- if($obj && ($obj->{maxrev} ? $obj->{maxrev} != $obj->{chrev} : !$obj->{lastrev})) {
+ 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!";
@@ -873,13 +1017,7 @@ sub editmsg_ {
if($obj) {
li_ sub {
txt_ 'Check for any existing discussions on the ';
- a_ href => '/t/'._board_id($type, $obj), 'discussion board';
- };
- # TODO: Include a list of the most recent edits in this page.
- li_ sub {
- txt_ 'Browse the ';
- a_ href => "/$obj->{id}/hist", 'edit history';
- txt_ ' for any recent changes related to what you want to change.';
+ a_ href => '/t/'._board_id($obj), 'discussion board';
};
} elsif($type ne 'r') {
li_ sub {
@@ -890,22 +1028,8 @@ sub editmsg_ {
li_ 'Fields marked with (*) may cause other fields to become (un)available depending on the selection.' if $type eq 'r';
}
};
- }
-}
-
-
-# Display the number of results and time it took. If the query timed out ($count is undef), an error message is displayed instead.
-sub advsearch_msg_ {
- my($count, $time) = @_;
- p_ class => 'center', 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;
+ };
+ 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
index 74366390..0170d37e 100644
--- a/lib/VNWeb/Images/Lib.pm
+++ b/lib/VNWeb/Images/Lib.pm
@@ -3,7 +3,7 @@ package VNWeb::Images::Lib;
use VNWeb::Prelude;
use Exporter 'import';
-our @EXPORT = qw/enrich_image validate_token image_flagging_display image_ enrich_image_obj/;
+our @EXPORT = qw/enrich_image validate_token image_flagging_display image_hidden image_ enrich_image_obj/;
my @SEX = qw/Safe Suggestive Explicit/;
@@ -21,18 +21,18 @@ 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 AS sexual_avg, i.c_sexual_stddev AS sexual_stddev
- , i.c_violence_avg AS violence_avg, i.c_violence_stddev AS violence_stddev
+ , 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, c.name, vsv.title) AS entry_title
+ , 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 vn v ON i.id BETWEEN 'cv1' AND vndbid_max('cv') AND v.image = i.id
- LEFT JOIN chars c ON i.id BETWEEN 'ch1' AND vndbid_max('ch') AND c.image = i.id
+ 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 vn vsv ON i.id BETWEEN 'sf1' AND vndbid_max('sf') AND vsv.id = vs.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;
@@ -45,7 +45,7 @@ sub enrich_image {
ORDER BY u.username'
}, $l;
- for(@$l) {
+ for(grep defined $_->{width}, @$l) {
$_->{entry} = $_->{entry_id} ? { id => $_->{entry_id}, title => $_->{entry_title} } : undef;
delete $_->{entry_id};
delete $_->{entry_title};
@@ -75,6 +75,25 @@ sub image_flagging_display {
}
+# 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:
@@ -83,28 +102,26 @@ sub image_flagging_display {
# 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 $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));
- my $hide_on_click = $opt{url} ? $hidden : $sexp < 0 || $sex || $vio || !$img->{votecount};
+ 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 => 'visuallyhidden', $hidden ? () : (checked => 'checked') if $hide_on_click;
+ 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/$img->{id}?view=".viewset(show_nsfw=>1), image_flagging_display $img, $small;
+ 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}->();
}
@@ -115,9 +132,9 @@ sub image_ {
txt_ 'This image has been flagged as:';
br_; br_;
}
- txt_ 'Sexual: '; $sexh ? b_ class => 'standout', $SEX[$sex] : txt_ $SEX[$sex];
+ txt_ 'Sexual: '; $hidden & 1 ? b_ $SEX[$sex] : txt_ $SEX[$sex];
br_;
- txt_ 'Violence: '; $vioh ? b_ class => 'standout', $VIO[$vio] : txt_ $VIO[$vio];
+ txt_ 'Violence: '; $hidden & 2 ? b_ $VIO[$vio] : txt_ $VIO[$vio];
} else {
txt_ 'This image has not yet been flagged';
}
@@ -125,7 +142,7 @@ sub image_ {
br_; br_;
span_ class => 'fake_link', 'Show me anyway';
br_; br_;
- b_ class => 'grayedout', 'This warning can be disabled in your account';
+ small_ 'This warning can be disabled in your account';
}
} if $hide_on_click;
}
@@ -134,7 +151,7 @@ sub image_ {
sub enrich_image_obj {
my $field = shift;
- enrich_obj $field => id => 'SELECT id, width, height, c_votecount AS votecount, c_sexual_avg AS sexual_avg, c_violence_avg AS violence_avg FROM images WHERE id IN', @_;
+ 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' ? @$_ : $_), @_) {
diff --git a/lib/VNWeb/Images/List.pm b/lib/VNWeb/Images/List.pm
index 3f3950e1..28713316 100644
--- a/lib/VNWeb/Images/List.pm
+++ b/lib/VNWeb/Images/List.pm
@@ -34,10 +34,10 @@ sub graph_ {
tag_ 'svg', width => '190px', height => '100px', viewBox => '0 0 190 100', sub {
tag_ 'g', sub {
- subgraph_ 'Safe', 'Explicit', $i->{c_sexual_avg}, $i->{c_sexual_stddev}, $i->{my_sexual}, $i->{user_sexual}
+ 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->{c_violence_avg}, $i->{c_violence_stddev}, $i->{my_violence}, $i->{user_violence}
+ subgraph_ 'Tame', 'Brutal', $i->{violence_avg}, $i->{violence_stddev}, $i->{my_violence}, $i->{user_violence}
};
};
}
@@ -48,13 +48,13 @@ sub listing_ {
my $view = viewset(show_nsfw => 1);
paginate_ $url, $opt->{p}, $np, 't';
- div_ class => 'mainbox imagebrowse', sub {
+ article_ class => 'imagebrowse', sub {
div_ class => 'imagecard', sub {
- a_ href => "/img/$_->{id}?view=$view", style => 'background-image: url('.imgurl($_->{id}, 1).')', '';
+ a_ href => "/$_->{id}?view=$view", style => 'background-image: url('.imgurl($_->{id}, $_->{id} =~ /^sf/ ? 't' : '').')', '';
div_ sub {
- a_ href => "/img/$_->{id}?view=$view", $_->{id};
+ a_ href => "/$_->{id}?view=$view", $_->{id};
txt_ sprintf ' / %d', $_->{c_votecount},;
- b_ class => 'grayedout', sprintf ' / w%.0f', $_->{c_weight};
+ small_ sprintf ' / w%d', $_->{c_weight};
br_;
graph_ $_, $opt;
};
@@ -100,6 +100,10 @@ sub opts_ {
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_ ' / ';
@@ -139,6 +143,7 @@ TUWF::get qr{/img/list}, sub {
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;
@@ -149,16 +154,18 @@ TUWF::get qr{/img/list}, sub {
$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->{user_id};
+ 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->{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, i.c_sexual_stddev, i.c_violence_avg, i.c_violence_stddev
+ , 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',
@@ -170,7 +177,7 @@ TUWF::get qr{/img/list}, sub {
sdev => 'i.c_sexual_stddev DESC NULLS LAST',
vdev => 'i.c_violence_stddev DESC NULLS LAST',
date => 'iu.date DESC',
- diff => 'abs(iu.sexual-i.c_sexual_avg) + abs(iu.violence-i.c_violence_avg) DESC',
+ diff => 'abs(iu.sexual*100-i.c_sexual_avg) + abs(iu.violence*100-i.c_violence_avg) DESC',
}->{$opt->{s}}, ', i.id'
);
@@ -179,13 +186,13 @@ TUWF::get qr{/img/list}, sub {
my $title = $u ? 'Images flagged by '.user_displayname($u) : 'Image browser';
framework_ title => $title, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
opts_ $opt, $u;
};
my $nsfw = viewget->{show_nsfw};
listing_ $lst, $np, $opt, \&url if $nsfw && @$lst;
- div_ class => 'mainbox', sub {
+ article_ sub {
div_ class => 'warning', sub {
h2_ 'NSFW Warning';
p_ sub {
diff --git a/lib/VNWeb/Images/Upload.pm b/lib/VNWeb/Images/Upload.pm
index ce2c2ae5..113ef9c8 100644
--- a/lib/VNWeb/Images/Upload.pm
+++ b/lib/VNWeb/Images/Upload.pm
@@ -6,55 +6,67 @@ use AnyEvent::Util;
TUWF::post qr{/elm/ImageUpload.json}, sub {
- if(!auth->csrfcheck(tuwf->reqHeader('X-CSRF-Token')||'')) {
- warn "Invalid CSRF token in request\n";
- return elm_CSRF;
- }
- return elm_Unauth if !auth->permEdit;
+ # 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');
- return elm_ImgFormat if $imgdata !~ /^(\xff\xd8|\x89\x50)/; # JPG or PNG header
+ 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')),
- width => 0,
- height => 0
+ id => sql_func(vndbid => \$type, sql(sql_func(nextval => \$seq), '::int')),
+ uploader => \auth->uid,
+ width => 0,
+ height => 0
}, 'RETURNING id');
- my $fn0 = imgpath($id, 0);
- my $fn1 = imgpath($id, 1);
- my $fntmp = "$fn0-tmp.jpg";
+ my $fno = imgpath($id, 'orig', $fmt);
+ my $fn0 = imgpath($id);
+ my $fn1 = imgpath($id, 't');
+
+ {
+ open my $F, '>', $fno or die $!;
+ print $F $imgdata;
+ }
- sub resize { (-resize => "$_[0][0]x$_[0][1]>", -print => 'r:%wx%h') }
- my @unsharp = (-unsharp => '0x0.75+0.75+0.008');
- my @cmd = (
- config->{convert_path}, '-',
- '-strip', -define => 'filter:Lagrange',
- -background => '#fff', -alpha => 'Remove',
- -quality => 90, -print => 'o:%wx%h',
- $type eq 'ch' ? (resize(config->{ch_size}), -write => $fn0, @unsharp, $fntmp) :
- $type eq 'cv' ? (resize(config->{cv_size}), -write => $fn0, @unsharp, $fntmp) :
- $type eq 'sf' ? (-write => $fn0, resize(config->{scr_size}), @unsharp, $fn1) : die
- );
+ 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);
- run_cmd(\@cmd, '<', \$imgdata, '>', \my $out, '2>', \my $err)->recv;
- warn "convert STDERR: $err" if $err;
- if(!-f $fn0 || $out !~ /^o:([0-9]+)x([0-9]+)r:([0-9]+)x([0-9]+)/) {
- warn "convert STDOUT: $out" if $out;
- warn "Failed to run convert\n";
+ 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;
- unlink $fntmp;
+ tuwf->dbRollBack;
return elm_ImgFormat;
}
- my($ow,$oh,$rw,$rh) = ($1,$2, $type eq 'sf' ? ($1,$2) : ($3,$4));
- tuwf->dbExeci('UPDATE images SET', { width => $rw, height => $rh }, 'WHERE id =', \$id);
-
- rename $fntmp, $fn0 if $ow*$oh > $rw*$rh; # Use the -unsharp'ened image if we did a resize
- unlink $fntmp;
+ 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;
diff --git a/lib/VNWeb/Images/Vote.pm b/lib/VNWeb/Images/Vote.pm
index ad320155..48c1fffb 100644
--- a/lib/VNWeb/Images/Vote.pm
+++ b/lib/VNWeb/Images/Vote.pm
@@ -16,12 +16,15 @@ my $SEND = form_compile any => {
};
+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 !auth->permImgvote;
+ return elm_Unauth if !can_vote;
- state $stats = tuwf->dbRowi('SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE c_weight > 0) AS referenced FROM images');
+ 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
@@ -44,12 +47,12 @@ elm_api Images => $SEND, { excl_voted => { anybool => 1 } }, sub {
my $l = tuwf->dbAlli('
SELECT id
FROM images TABLESAMPLE SYSTEM (', \$tablesample, ')
- WHERE c_weight > 0',
+ 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 u%d with a sample fraction of %f', scalar @$l, auth->uid(), $tablesample if @$l < 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;
};
@@ -65,16 +68,19 @@ elm_api ImageVote => undef, {
} },
}, sub {
my($data) = @_;
- return elm_Unauth if !auth->permImgvote;
- return elm_CSRF if !validate_token $data->{votes};
+ 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->permImgmod;
+ grep $_->{overruled}, $data->{votes}->@* if auth->permDbmod;
for($data->{votes}->@*) {
- $_->{overrule} = 0 if !auth->permImgmod;
+ $_->{overrule} = 0 if !auth->permDbmod;
my $d = {
id => $_->{id},
uid => auth->uid(),
@@ -99,14 +105,14 @@ sub imgflag_ {
elm_ 'ImageFlagging', $SEND, {
my_votes => my_votes(),
nsfw_token => viewset(show_nsfw => 1),
- mod => auth->permImgmod()||0,
+ mod => auth->permDbmod()||0,
@_
};
}
TUWF::get qr{/img/vote}, sub {
- return tuwf->resDenied if !auth->permImgvote;
+ 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;
@@ -117,11 +123,11 @@ TUWF::get qr{/img/vote}, sub {
};
-TUWF::get qr{/img/$RE{imgid}}, sub {
+TUWF::get qr{/$RE{imgid}}, sub {
my $id = tuwf->capture('id');
my $l = [{ id => $id }];
- enrich_image auth->permImgmod() || sub { defined $_[0]{my_sexual} }, $l;
+ 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 {
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
index 1873fa15..ea101ff9 100644
--- a/lib/VNWeb/Misc/AdvSearch.pm
+++ b/lib/VNWeb/Misc/AdvSearch.pm
@@ -5,7 +5,7 @@ use VNWeb::AdvSearch;
elm_api 'AdvSearchSave' => undef, {
- name => { required => 0, default => '', length => [1,50] },
+ name => { default => '', length => [1,50] },
qtype => { enum => \%VNWeb::AdvSearch::FIELDS },
query => {},
}, sub {
@@ -20,7 +20,7 @@ elm_api 'AdvSearchSave' => undef, {
elm_api 'AdvSearchDel' => undef, {
- name => { type => 'array', minlength => 1, values => { required => 0, default => '', length => [1,50] } },
+ name => { type => 'array', minlength => 1, values => { default => '', length => [1,50] } },
qtype => { enum => \%VNWeb::AdvSearch::FIELDS },
}, sub {
my($d) = @_;
@@ -28,13 +28,4 @@ elm_api 'AdvSearchDel' => undef, {
elm_Success
};
-
-elm_api 'AdvSearchLoad' => undef, {
- qtype => { enum => \%VNWeb::AdvSearch::FIELDS },
- query => {},
-}, sub {
- my($d) = @_;
- elm_AdvSearchQuery tuwf->compile({ advsearch => $d->{qtype} })->validate($d->{query})->data->elm_search_query;
-};
-
1;
diff --git a/lib/VNWeb/Misc/BBCode.pm b/lib/VNWeb/Misc/BBCode.pm
index 2c41b6da..ddc744b2 100644
--- a/lib/VNWeb/Misc/BBCode.pm
+++ b/lib/VNWeb/Misc/BBCode.pm
@@ -3,9 +3,15 @@ package VNWeb::Misc::BBCode;
use VNWeb::Prelude;
elm_api BBCode => undef, {
- content => { required => 0, default => '' }
+ content => { default => '' }
}, sub {
elm_Content 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/Feeds.pm b/lib/VNWeb/Misc/Feeds.pm
index ed58b37e..f24144d5 100644
--- a/lib/VNWeb/Misc/Feeds.pm
+++ b/lib/VNWeb/Misc/Feeds.pm
@@ -55,6 +55,7 @@ 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};
}
@@ -69,7 +70,7 @@ TUWF::get qr{/feeds/posts.atom}, sub {
FROM threads_posts tp
JOIN threads t ON t.id = tp.tid
LEFT JOIN users u ON u.id = tp.uid
- WHERE NOT tp.hidden AND NOT t.hidden AND NOT t.private
+ WHERE tp.hidden IS NULL AND NOT t.hidden AND NOT t.private
ORDER BY tp.date DESC
LIMIT ', \25
);
diff --git a/lib/VNWeb/Misc/History.pm b/lib/VNWeb/Misc/History.pm
index f8e15f27..9664363b 100644
--- a/lib/VNWeb/Misc/History.pm
+++ b/lib/VNWeb/Misc/History.pm
@@ -6,6 +6,7 @@ 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 ? ()
@@ -19,36 +20,31 @@ sub fetch {
$filt->{e} && $filt->{e} == 1 ? sql 'c.rev <> 1' : (),
$filt->{e} && $filt->{e} ==-1 ? sql 'c.rev = 1' : (),
- $filt->{h} ? sql $filt->{h} == 1 ? 'NOT' : '',
+ # -2 = awaiting mod, -1 = deleted, 0 = all, 1 = approved
+ $filt->{h} ? sql
'EXISTS(SELECT 1 FROM changes c_i
- WHERE c_i.itemid = c.itemid AND c_i.ihid
+ 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, $np) = tuwf->dbPagei({ page => $filt->{p}, results => $opt->{results}||50 }, q{
- SELECT c.id, c.itemid, c.comments, c.rev,}, sql_totime('c.added'), q{ AS added, }, sql_user(), q{
- FROM changes c
+ 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
- WHERE}, $where, q{
- ORDER BY c.id DESC
- });
-
- # Fetching the titles in a separate query is faster, for some reason.
- enrich_merge id => sql(q{
- SELECT id, title, original FROM (
- SELECT chid, title, original FROM vn_hist
- UNION ALL SELECT chid, title, original FROM releases_hist
- UNION ALL SELECT chid, name, original FROM producers_hist
- UNION ALL SELECT chid, name, original FROM chars_hist
- UNION ALL SELECT chid, title, '' AS original FROM docs_hist
- UNION ALL SELECT sh.chid, name, original FROM staff_hist sh JOIN staff_alias_hist sah ON sah.chid = sh.chid AND sah.aid = sh.aid
- ) t(id, title, original)
- WHERE id IN}), $lst;
+ ORDER BY c.id DESC'
+ );
+ enrich rev_patrolled => id => id =>
+ sql('SELECT c.id,', sql_user(), 'FROM changes_patrolled c JOIN users u ON u.id = c.uid WHERE c.id IN'), $lst
+ if auth->permDbmod;
+ my $np = @$lst > $num ? pop(@$lst)&&1 : 0;
($lst, $np)
}
-# Also used by User::Page.
-# %opt: nopage => 1/0, results => $num
+# Also used by User::Page and VNWeb::HTML.
+# %opt: nopage => 1/0, nouser => 1/0, results => $num
sub tablebox_ {
my($id, $filt, %opt) = @_;
@@ -57,26 +53,32 @@ sub tablebox_ {
my sub url { '?'.query_encode %$filt, p => $_ }
paginate_ \&url, $filt->{p}, $np, 't' unless $opt{nopage};
- div_ class => 'mainbox browse history mainbox-overflow-hack', sub {
+ article_ class => 'browse history overflow-hack', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
+ td_ class => 'tc1_0', '' if auth->permDbmod;
td_ class => 'tc1_1', 'Rev.';
td_ class => 'tc1_2', '';
td_ class => 'tc2', 'Date';
- td_ class => 'tc3', 'User';
+ td_ class => 'tc3', 'User' unless $opt{nouser};
td_ class => 'tc4', sub { txt_ 'Page'; debug_ $lst; };
}};
tr_ sub {
my $i = $_;
my $revurl = "/$i->{itemid}.$i->{rev}";
+ td_ class => 'tc1_0', sub {
+ a_ href => "$revurl?patrolled=$i->{id}", sub {
+ revision_patrolled_ $i;
+ }
+ } if auth->permDbmod;
td_ class => 'tc1_1', sub { a_ href => $revurl, $i->{itemid} };
td_ class => 'tc1_2', sub { a_ href => $revurl, ".$i->{rev}" };
td_ class => 'tc2', fmtdate $i->{added}, 'full';
- td_ class => 'tc3', sub { user_ $i };
+ td_ class => 'tc3', sub { user_ $i } unless $opt{nouser};
td_ class => 'tc4', sub {
- a_ href => $revurl, title => $i->{original}, shorten $i->{title}, 80;
- b_ class => 'grayedout', sub { lit_ bb_format $i->{comments}, maxlength => 150, inline => 1 };
+ a_ href => $revurl, tattr $i;
+ small_ sub { lit_ bb_format $i->{comments}, maxlength => 150, inline => 1 };
};
} for @$lst;
};
@@ -90,18 +92,20 @@ sub filters_ {
my @types = (
[ v => 'Visual novels' ],
+ [ g => 'Tags' ],
[ r => 'Releases' ],
[ p => 'Producers' ],
[ s => 'Staff' ],
[ c => 'Characters' ],
- [ d => 'Docs' ]
+ [ i => 'Traits' ],
+ [ d => 'Docs' ],
);
state $schema = tuwf->compile({ type => 'hash', keys => {
# Types
t => { type => 'array', scalar => 1, onerror => [map $_->[0], @types], values => { enum => [(map $_->[0], @types), 'a'] } },
m => { onerror => undef, enum => [ 0, 1 ] }, # Automated edits
- h => { onerror => 0, enum => [ -1..1 ] }, # Hidden items
+ h => { onerror => 0, enum => [ -2..1 ] }, # Item status (the numbers dont make sense)
e => { onerror => 0, enum => [ -1..1 ] }, # Existing/new items
r => { onerror => 0, enum => [ 0, 1 ] }, # Include releases
p => { page => 1 },
@@ -127,16 +131,14 @@ sub filters_ {
};
form_ method => 'get', action => tuwf->reqPath(), sub {
- table_ style => 'margin: 0 auto', sub { tr_ sub {
- td_ style => 'padding: 10px', sub {
- p_ class => 'linkradio', sub {
- join_ \&br_, sub {
- opt_ checkbox => t => $_->[0], $_->[1], $t{$_->[0]}||0;
- }, @types;
+ table_ class => 'histoptions', sub { tr_ sub {
+ td_ sub {
+ select_ multiple => 1, size => scalar @types, name => 't', sub {
+ option_ $t{$_->[0]} ? (selected => 1) : (), value => $_->[0], $_->[1] for @types;
}
} if exists $filt->{t};
- td_ style => 'padding: 10px', sub {
+ td_ sub {
p_ class => 'linkradio', sub {
opt_ radio => e => 0, 'All'; em_ ' | ';
opt_ radio => e => 1, 'Only changes to existing items'; em_ ' | ';
@@ -144,8 +146,9 @@ sub filters_ {
} if exists $filt->{e};
p_ class => 'linkradio', sub {
opt_ radio => h => 0, 'All'; em_ ' | ';
- opt_ radio => h => 1, 'Only non-deleted items'; em_ ' | ';
- opt_ radio => h =>-1, 'Only deleted';
+ opt_ radio => h => 1, 'Only public items'; em_ ' | ';
+ opt_ radio => h =>-1, 'Only deleted'; em_ ' | ';
+ opt_ radio => h =>-2, 'Only unapproved';
} if exists $filt->{h};
p_ class => 'linkradio', sub {
opt_ checkbox => m => 0, 'Show automated edits' if !$type;
@@ -163,21 +166,22 @@ sub filters_ {
}
-TUWF::get qr{/(?:([upvrcsd][1-9][0-9]{0,6})/)?hist} => sub {
+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}" : 'Recent changes';
+ my $title = $id ? "Edit history of $obj->{title}[1]" : 'Recent changes';
framework_ title => $title, dbobj => $obj, tab => 'hist',
sub {
my $filt;
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
- $filt = filters_($id =~ /^(.)/ or '');
+ $filt = filters_($id =~ /^(.)/ ? $1 : '');
};
- tablebox_ $id, $filt;
+ tablebox_ $id, $filt, nouser => scalar $id =~ /^u/;
};
};
diff --git a/lib/VNWeb/Misc/HomePage.pm b/lib/VNWeb/Misc/HomePage.pm
index b3495643..86254fcd 100644
--- a/lib/VNWeb/Misc/HomePage.pm
+++ b/lib/VNWeb/Misc/HomePage.pm
@@ -5,18 +5,19 @@ 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 <', \0.4, 'and i.c_violence_avg <', \0.4;
+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 / $stats->{subset}) * ($stats->{total} / $stats->{subset});
+ 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 vn v WHERE NOT v.hidden AND ', $filt->sql_where(), ' ORDER BY random() LIMIT', \30, ') v
+ 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, '
@@ -26,17 +27,12 @@ sub screens_ {
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 vn v ON v.id = vs.id
+ JOIN', vnt, 'v ON v.id = vs.id
+ WHERE NOT v.hidden
ORDER BY random()
LIMIT', \4
);
-
- p_ class => 'screenshots', sub {
- a_ href => "/$_->{vid}", title => $_->{title}, sub {
- my($w, $h) = imgsize $_->{width}, $_->{height}, config->{scr_size}->@*;
- img_ src => imgurl($_->{id}, 1), alt => $_->{title}, width => $w, height => $h;
- } for @$lst;
- }
+ ($lst, $filt->{query} && time - $start > 0.3)
}
@@ -44,13 +40,15 @@ 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 => 'icons feed', title => 'Atom Feed', '' };
+ 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}", title => $_->{original}||$_->{title}, $_->{title};
+ a_ href => "/$_->{itemid}.$_->{rev}", tattr $_;
};
span_ sub {
lit_ " by ";
@@ -67,7 +65,7 @@ sub recent_db_posts_ {
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-2*30*24*3600), '
+ 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'
);
@@ -84,7 +82,7 @@ sub recent_db_posts_ {
enrich_boards undef, $lst;
p_ class => 'mainopts', sub {
a_ href => '/t/an', 'Announcements';
- b_ class => 'grayedout', '&';
+ small_ '&';
a_ href => '/t/db', 'VNDB';
};
h1_ sub {
@@ -95,7 +93,7 @@ sub recent_db_posts_ {
a_ href => "/$_->{id}", $_->{title};
} for @$an;
li_ sub {
- my $boards = join ', ', map $BOARD_TYPE{$_->{btype}}{txt}.($_->{iid}?' > '.$_->{title}:''), $_->{boards}->@*;
+ 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};
@@ -112,7 +110,7 @@ sub recent_db_posts_ {
sub recent_vn_posts_ {
my $lst = tuwf->dbAlli('
WITH tposts (id,title,num,date,uid) AS (
- SELECT t.id, t.title, tp.num, tp.date, tp.uid
+ 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\'))
@@ -122,9 +120,9 @@ sub recent_vn_posts_ {
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 vn v ON v.id = w.vid
+ JOIN', vnt, 'v ON v.id = w.vid
LEFT JOIN users u ON wp.uid = u.id
- WHERE NOT w.c_flagged AND NOT wp.hidden
+ 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
@@ -135,7 +133,7 @@ sub recent_vn_posts_ {
enrich_boards undef, $lst;
p_ class => 'mainopts', sub {
a_ href => '/t/all', 'Forums';
- b_ class => 'grayedout', '&';
+ small_ '&';
a_ href => '/w?o=d&s=lastpost', 'Reviews';
};
h1_ sub {
@@ -144,9 +142,9 @@ sub recent_vn_posts_ {
ul_ sub {
li_ sub {
span_ sub {
- my $boards = join ', ', map $BOARD_TYPE{$_->{btype}}{txt}.($_->{iid}?' > '.$_->{title}:''), $_->{boards}->@*;
+ 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', $_->{title};
+ a_ href => "/$_->{id}.$_->{num}#last", title => $boards ? "Posted in $boards" : 'Review', tlang(@{$_->{title}}[0,1]), $_->{title}[1];
};
span_ sub {
lit_ ' by ';
@@ -158,7 +156,7 @@ sub recent_vn_posts_ {
-sub releases_ {
+sub releases {
my($released) = @_;
my $filt = advsearch_default 'r';
@@ -166,19 +164,28 @@ sub releases_ {
# 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} || () ];
- # XXX This query is kinda slow, an index on releases.released would probably help.
+ my $start = time;
my $lst = tuwf->dbAlli('
- SELECT id, title, original, released
- FROM releases r
+ 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_lang 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;
@@ -189,10 +196,10 @@ sub releases_ {
span_ sub {
rdate_ $_->{released};
txt_ ' ';
- abbr_ class => "icons plat $_", title => $PLATFORM{$_}, '' for $_->{plat}->@*;
- abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' for $_->{lang}->@*;
+ platform_ $_ for $_->{plat}->@*;
+ abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' for $_->{lang}->@*;
txt_ ' ';
- a_ href => "/$_->{id}", title => $_->{original}||$_->{title}, $_->{title};
+ a_ href => "/$_->{id}", tattr $_;
}
} for @$lst;
};
@@ -203,7 +210,7 @@ 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 vn v ON v.id = w.vid
+ 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'
@@ -215,8 +222,8 @@ sub reviews_ {
li_ sub {
span_ sub {
txt_ fmtage($_->{date}).' ';
- b_ class => 'grayedout', $_->{isfull} ? ' Full ' : ' Mini ';
- a_ href => "/$_->{id}", title => $_->{title}, $_->{title};
+ small_ $_->{isfull} ? ' Full ' : ' Mini ';
+ a_ href => "/$_->{id}", tattr $_;
};
span_ sub {
lit_ 'by ';
@@ -234,8 +241,13 @@ TUWF::get qr{/}, sub {
'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 {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $meta{title};
p_ class => 'description', sub {
txt_ $meta{description};
@@ -246,17 +258,27 @@ TUWF::get qr{/}, sub {
largest, most accurate and most up-to-date visual novel database on the web.
};
};
- screens_;
- };
- div_ class => 'homepage threelayout', sub {
- div_ \&recent_changes_;
- div_ \&recent_db_posts_;
- div_ \&recent_vn_posts_;
+ 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 threelayout', sub {
- div_ sub { reviews_ };
- div_ sub { releases_ 0 };
- div_ sub { releases_ 1 };
+ 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 };
};
};
};
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/Redirects.pm b/lib/VNWeb/Misc/Redirects.pm
index 72ba7369..e16cf495 100644
--- a/lib/VNWeb/Misc/Redirects.pm
+++ b/lib/VNWeb/Misc/Redirects.pm
@@ -11,7 +11,6 @@ TUWF::get qr{(/.+?)/+}, sub { tuwf->resRedirect(tuwf->capture(1).tuwf->reqQuery(
TUWF::get qr{/notes}, sub { tuwf->resRedirect('/d8', 'perm') };
TUWF::get qr{/faq}, sub { tuwf->resRedirect('/d6', 'perm') };
-TUWF::get qr{/p}, sub { tuwf->resRedirect('/p/all'.tuwf->reqQuery(), 'perm') };
TUWF::get qr{/v/search}, sub { tuwf->resRedirect('/v'.tuwf->reqQuery(), 'perm') };
TUWF::get qr{/experimental/v}, sub { tuwf->resRedirect('/v'.tuwf->reqQuery(), 'perm') };
@@ -24,11 +23,14 @@ TUWF::get qr{/$RE{uid}/tags}, sub { tuwf->resRedirect('/g/links?u='.tuwf->captu
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, (100 / $stats->{subset}) * ($stats->{total} / $stats->{subset});
+ state $sample ||= 100*min 1, (1000 / $stats->{subset}) * ($stats->{total} / $stats->{subset});
my $filt = advsearch_default 'v';
my $vn = tuwf->dbVali('
diff --git a/lib/VNWeb/Misc/Reports.pm b/lib/VNWeb/Misc/Reports.pm
index 3697922f..5c5dcac6 100644
--- a/lib/VNWeb/Misc/Reports.pm
+++ b/lib/VNWeb/Misc/Reports.pm
@@ -5,106 +5,91 @@ 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
+}
+
-# Requires objects with {object,objectnum} fields, adds a HTML-formatted 'title' field, which formats and links to the entry.
-sub enrich_object {
- for my $o (@_) {
- delete $o->{title};
- if($o->{object} =~ /^$RE{wid}$/ && $o->{objectnum}) {
- my $w = tuwf->dbRowi(
- 'SELECT rp.id, rp.num, ', sql_user(), '
- FROM reviews_posts rp LEFT JOIN users u ON u.id = rp.uid
- WHERE NOT rp.hidden AND rp.id =', \$o->{object}, 'AND rp.num =', \$o->{objectnum}
- );
- $o->{title} = xml_string sub {
- txt_ 'Comment ';
- a_ href => "/$o->{object}.$o->{objectnum}", "#$o->{objectnum}";
- txt_ ' on review ';
- a_ href => "/$o->{object}.$o->{objectnum}", $o->{object};
- txt_ ' by ';
- user_ $w;
- } if $w->{id};
-
- } elsif($o->{object} =~ /^$RE{wid}$/) {
- my $w = tuwf->dbRowi('SELECT r.id, v.title,', sql_user(), 'FROM reviews r JOIN vn v ON v.id = r.vid LEFT JOIN users u ON u.id = r.uid WHERE r.id =', \$o->{object});
- $o->{title} = xml_string sub {
- a_ href => "/$o->{object}", "Review of $w->{title}";
- txt_ ' by ';
- user_ $w;
- } if $w->{id};
-
- } elsif($o->{object} =~ /^$RE{tid}$/ && $o->{objectnum}) {
- my $post = tuwf->dbRowi(
- 'SELECT tp.num, t.title, ', sql_user(), '
- 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 =', \$o->{object}, 'AND tp.num =', \$o->{objectnum}
- );
- $o->{title} = xml_string sub {
- txt_ 'Post ';
- a_ href => "/$o->{object}.$o->{objectnum}", "#$post->{num}";
- txt_ ' on ';
- a_ href => "/$o->{object}.$o->{objectnum}", $post->{title};
- txt_ ' by ';
- user_ $post;
- } if $post->{num};
-
- } elsif($o->{object} =~ /^([vrpcsd]$RE{num})$/ && !defined $o->{objectnum}) {
- my $obj = dbobj $1;
- $o->{title} = xml_string sub {
- txt_ {qw/v VN r Release p Producer c Character s Staff d Doc/}->{substr $obj->{id}, 0, 1};
- txt_ ': ';
- a_ href => "/$obj->{id}", $obj->{title};
- } if $obj->{id};
+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 =', \tuwf->reqIP)) >= $reportsperday
+ 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=> { required => 0, uint => 1 },
+ objectnum=> { default => undef, uint => 1 },
title => {},
reason => { maxlength => 50 },
- message => { required => 0, default => '', maxlength => 50000 },
+ message => { default => '', maxlength => 50000 },
loggedin => { anybool => 1 },
};
-elm_api Report => undef, $FORM, sub {
+js_api Report => $FORM, sub {
+ return tuwf->resDenied if is_throttled;
my($data) = @_;
- enrich_object $data;
- return elm_Invalid if !$data->{title};
- return elm_Unauth if is_throttled;
+ my $obj = obj $data->{object}, $data->{objectnum};
+ return 'Invalid object' if !$data;
tuwf->dbExeci('INSERT INTO reports', {
uid => auth->uid,
- ip => auth ? undef : tuwf->reqIP,
+ ip => auth ? undef : ipinfo(),
object => $data->{object},
objectnum=> $data->{objectnum},
reason => $data->{reason},
message => $data->{message},
});
- elm_Success
+ +{}
};
-TUWF::get qr{/report/(?<object>[vrpcsdtw]$RE{num})(?:\.(?<subid>$RE{num}))?}, sub {
- my $obj = { object => tuwf->capture('object'), objectnum => tuwf->capture('subid') };
- enrich_object $obj;
- return tuwf->resNotFound if !$obj->{title};
+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) {
- div_ class => 'mainbox', sub {
+ 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 {
- elm_ Report => $FORM, { elm_empty($FORM)->%*, %$obj, loggedin => !!auth };
+ div_ widget(Report => $FORM, { elm_empty($FORM)->%*, %$obj, loggedin => !!auth, title => xml_string sub { obj_ $obj } }), '';
}
};
};
@@ -115,7 +100,7 @@ sub report_ {
my $objid = $r->{object}.(defined $r->{objectnum} ? ".$r->{objectnum}" : '');
td_ style => 'padding: 3px 5px 5px 20px', sub {
a_ href => "?id=$r->{id}", "#$r->{id}";
- b_ class => 'grayedout', ' '.fmtdate $r->{date}, 'full';
+ small_ ' '.fmtdate $r->{date}, 'full';
txt_ ' by ';
if($r->{uid}) {
a_ href => "/$r->{uid}", $r->{username};
@@ -126,10 +111,17 @@ sub report_ {
txt_ $r->{ip}||'[anonymous]';
}
br_;
- lit_ $r->{title} || '[deleted]';
+ obj_ $r;
br_;
- txt_ $r->{reason};
- div_ class => 'quote', sub { lit_ bb_format $r->{message} } if $r->{message};
+ 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 {
@@ -137,14 +129,24 @@ sub report_ {
input_ type => 'hidden', name => 'url', value => $url;
textarea_ name => 'comment', rows => 2, cols => 25, style => 'width: 290px', placeholder => 'Mod comment... (optional)', '';
br_;
- select_ style => 'width: 100px', name => 'status', sub {
- option_ value => $_, $_ eq $r->{status} ? (selected => 'selected') : (), ucfirst $_ for @STATUS;
- };
- input_ type => 'submit', class => 'submit', value => 'Update';
+ 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_;
+ }
};
}
@@ -154,9 +156,9 @@ TUWF::get qr{/report/list}, sub {
my $opt = tuwf->validate(get =>
p => { upage => 1 },
- s => { enum => ['id','lastmod'], required => 0, default => 'id' },
- status => { enum => \@STATUS, required => 0 },
- id => { id => 1, required => 0 },
+ s => { enum => ['id','lastmod'], default => 'id' },
+ status => { enum => \@STATUS, default => undef },
+ id => { id => 1, default => undef },
)->data;
my $where = sql_and
@@ -166,16 +168,25 @@ TUWF::get qr{/report/list}, sub {
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, u.username, r.ip, r.reason, r.object, r.objectnum, r.status, r.message, r.log
+ '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 users u ON u.id = r.uid
+ 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_object @$lst;
+ 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 SET last_reports = NOW()
+ '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
);
@@ -183,7 +194,7 @@ TUWF::get qr{/report/list}, sub {
my sub url { '?'.query_encode %$opt, @_ }
framework_ title => 'Reports', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Reports';
p_ 'Welcome to the super advanced reports handling interface. Reports can have the following statuses:';
ul_ sub {
@@ -221,7 +232,7 @@ TUWF::get qr{/report/list}, sub {
};
paginate_ \&url, $opt->{p}, [$cnt, 25], 't';
- div_ class => 'mainbox thread', sub {
+ article_ class => 'thread', sub {
table_ class => 'stripe', sub {
my $url = '/report/list'.url;
tr_ sub { report_ $_, $url } for @$lst;
@@ -238,19 +249,21 @@ TUWF::post qr{/report/edit}, sub {
my $frm = tuwf->validate(post =>
id => { id => 1 },
url => { regex => qr{^/report/list} },
- status => { enum => \@STATUS },
- comment => { required => 0, default => '' },
+ 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};
- my $log = join '; ',
- $r->{status} ne $frm->{status} ? "$r->{status} -> $frm->{status}" : (),
- $frm->{comment} ? $frm->{comment} : ();
-
- if($log) {
- $log = sprintf "%s <%s> %s\n", fmtdate(time, 'full'), auth->user->{user_name}, $log;
- tuwf->dbExeci('UPDATE reports SET lastmod = NOW(), status =', \$frm->{status}, ', log = log ||', \$log, 'WHERE id =', \$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');
};
diff --git a/lib/VNWeb/Prelude.pm b/lib/VNWeb/Prelude.pm
index 5a893232..f422aa50 100644
--- a/lib/VNWeb/Prelude.pm
+++ b/lib/VNWeb/Prelude.pm
@@ -19,10 +19,12 @@
# use VNWeb::HTML;
# use VNWeb::DB;
# use VNWeb::Validation;
+# use VNWeb::JS;
# use VNWeb::Elm;
# use VNWeb::TableOpts;
+# use VNWeb::TitlePrefs;
#
-# + A few other handy tools.
+# + A handy dbobj() function.
#
# WARNING: This should not be used from the above modules.
package VNWeb::Prelude;
@@ -35,7 +37,6 @@ use VNWeb::Elm;
use VNWeb::Auth;
use VNWeb::DB;
use TUWF;
-use JSON::XS;
sub import {
@@ -64,67 +65,31 @@ sub import {
use VNWeb::HTML;
use VNWeb::DB;
use VNWeb::Validation;
+ use VNWeb::JS;
use VNWeb::Elm;
use VNWeb::TableOpts;
+ use VNWeb::TitlePrefs;
1;
EOM;
no strict 'refs';
- *{$c.'::RE'} = *RE;
*{$c.'::dbobj'} = \&dbobj;
}
-# 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{i(?<id>$num)},
- did => qr{(?<id>d$num)},
- tid => qr{(?<id>t$num)},
- gid => qr{g(?<id>$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?},
- postid => qr{(?<id>t$num)\.(?<num>$num)},
-);
-
-
# Returns very generic information on a DB entry object.
-# Only { id, title, entry_hidden, entry_locked } for now.
# Suitable for passing to HTML::framework_'s dbobj argument.
-# TODO: Merge with SQL's item_info() or something.
sub dbobj {
my($id) = @_;
- my sub item {
- my($table, $title) = @_;
- tuwf->dbRowi('SELECT id,', $title, ' AS title, hidden AS entry_hidden, locked AS entry_locked FROM', $table, 'WHERE id =', \$id);
- };
-
- my $o = !$id ? undef :
- $id =~ /^u/ ? tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \$id) :
- $id =~ /^p/ ? item producers => 'name' :
- $id =~ /^v/ ? item vn => 'title' :
- $id =~ /^r/ ? item releases => 'title' :
- $id =~ /^c/ ? item chars => 'name' :
- $id =~ /^s/ ? item staff => '(SELECT name FROM staff_alias WHERE aid = staff.aid)' :
- $id =~ /^d/ ? item docs => 'title' : die;
+ 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;
+ }
- $o->{title} = VNWeb::HTML::user_displayname $o if $id =~ /^u/;
- $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
index fbd14e5f..56df8aa3 100644
--- a/lib/VNWeb/Producers/Edit.pm
+++ b/lib/VNWeb/Producers/Edit.pm
@@ -4,26 +4,23 @@ use VNWeb::Prelude;
my $FORM = {
- id => { required => 0, vndbid => 'p' },
- ptype => { default => 'co', enum => \%PRODUCER_TYPE },
- name => { maxlength => 200 },
- original => { required => 0, default => '', maxlength => 200 },
- alias => { required => 0, default => '', maxlength => 500 },
- lang => { default => 'ja', enum => \%LANGUAGE },
- website => { required => 0, default => '', weburl => 1 },
- l_wikidata => { required => 0, uint => 1, max => (1<<31)-1 },
- desc => { required => 0, default => '', maxlength => 5000 },
- relations => { sort_keys => 'pid', aoh => {
+ 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' },
- original => { _when => 'out', required => 0, default => '' },
} },
- hidden => { anybool => 1 },
- locked => { anybool => 1 },
-
- authmod => { _when => 'out', anybool => 1 },
- editsum => { _when => 'in out', editsum => 1 },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
};
my $FORM_OUT = form_compile out => $FORM;
@@ -35,16 +32,15 @@ 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->{authmod} = auth->permDbmod;
$e->{editsum} = $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
- $e->{ptype} = delete $e->{type};
- enrich_merge pid => 'SELECT id AS pid, name, original FROM producers WHERE id IN', $e->{relations};
+ enrich_merge pid => sql('SELECT id AS pid, title[1+1] AS name FROM', producerst, 'p WHERE id IN'), $e->{relations};
- framework_ title => "Edit $e->{name}", dbobj => $e, tab => 'edit',
+ my $title = titleprefs_swap @{$e}{qw/ lang name latin /};
+ framework_ title => "Edit $title->[1]", dbobj => $e, tab => 'edit',
sub {
- editmsg_ p => $e, "Edit $e->{name}";
- elm_ ProducerEdit => $FORM_OUT, $e;
+ editmsg_ p => $e, "Edit $title->[1]";
+ div_ widget(ProducerEdit => $FORM_OUT, $e), '';
};
};
@@ -55,34 +51,32 @@ TUWF::get qr{/p/add}, sub {
framework_ title => 'Add producer',
sub {
editmsg_ p => undef, 'Add producer';
- elm_ ProducerEdit => $FORM_OUT, elm_empty $FORM_OUT;
+ div_ widget(ProducerEdit => $FORM_OUT, elm_empty $FORM_OUT), '';
};
};
-elm_api ProducerEdit => $FORM_OUT, $FORM_IN, sub {
+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 elm_Unauth if !can_edit p => $e;
+ return tuwf->resDenied if !can_edit p => $e;
if(!auth->permDbmod) {
$data->{hidden} = $e->{hidden}||0;
$data->{locked} = $e->{locked}||0;
}
- $data->{desc} = bb_subst_links $data->{desc};
+ $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}->@*;
- $e->{ptype} = $e->{type};
- $data->{type} = $data->{ptype};
- return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
+ 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);
- elm_Redirect "/$ch->{nitemid}.$ch->{nrev}";
+ +{ _redir => "/$ch->{nitemid}.$ch->{nrev}" };
};
diff --git a/lib/VNWeb/Producers/Elm.pm b/lib/VNWeb/Producers/Elm.pm
index dae9709d..cde3bd39 100644
--- a/lib/VNWeb/Producers/Elm.pm
+++ b/lib/VNWeb/Producers/Elm.pm
@@ -3,31 +3,32 @@ package VNWeb::Producers::Elm;
use VNWeb::Prelude;
elm_api Producers => undef, {
- search => { type => 'array', values => { required => 0, default => '' } },
- hidden => { anybool => 1 },
+ search => { type => 'array', values => { searchquery => 1 } },
}, sub {
my($data) = @_;
- my @q = grep length $_, $data->{search}->@*;
- die "No query" if !@q;
+ my @q = grep $_, $data->{search}->@*;
- elm_ProducerResult tuwf->dbPagei({ results => 15, page => 1 },
- 'SELECT p.id, p.name, p.original, p.hidden
- FROM (',
- sql_join('UNION ALL', map {
- my $qs = sql_like $_;
- (
- /^$RE{pid}$/ ? sql('SELECT 1, id FROM producers WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(name),' , \$qs, '), id FROM producers WHERE name ILIKE', \"%$qs%"),
- sql('SELECT 10+substr_score(lower(original),', \$qs, "), id FROM producers WHERE translate(original,' ','') ILIKE", \("%$qs%" =~ s/ //gr)),
- sql('SELECT 100, id FROM producers WHERE alias ILIKE', \"%$qs%"),
- )
- } @q),
- ') x(prio, id)
- JOIN producers p ON p.id = x.id
- WHERE', sql_and($data->{hidden} ? () : 'NOT p.hidden'), '
- GROUP BY p.id, p.name, p.original, p.hidden
- ORDER BY MIN(x.prio), p.name
- ');
+ 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
index 005a2492..4ac14c62 100644
--- a/lib/VNWeb/Producers/Graph.pm
+++ b/lib/VNWeb/Producers/Graph.pm
@@ -5,15 +5,14 @@ use VNWeb::Graph;
TUWF::get qr{/$RE{pid}/rg}, sub {
- my $id = tuwf->capture(1);
my $num = tuwf->validate(get => num => { uint => 1, onerror => 15 })->data;
- my $p = tuwf->dbRowi('SELECT id, name, original, hidden AS entry_hidden, locked AS entry_locked FROM producers WHERE id =', \$id);
+ 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 =}, \$id, q{
+ 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
@@ -21,8 +20,8 @@ TUWF::get qr{/$RE{pid}/rg}, sub {
return tuwf->resNotFound if !@$rel;
# Fetch the nodes
- my $nodes = gen_nodes $id, $rel, $num;
- enrich_merge id => 'SELECT id, name, lang, type FROM producers WHERE id IN', values %$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;
@@ -37,7 +36,7 @@ TUWF::get qr{/$RE{pid}/rg}, sub {
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}} </TD><TD ALIGN="CENTER"> $PRODUCER_TYPE{$n->{type}} </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}->@*;
@@ -46,10 +45,10 @@ TUWF::get qr{/$RE{pid}/rg}, sub {
$rel = [ grep $nodes->{$_->{id0}} && $nodes->{$_->{id1}}, @$rel ];
my $dot = gen_dot \@lines, $nodes, $rel, \%PRODUCER_RELATION;
- framework_ title => "Relations for $p->{name}", type => 'p', dbobj => $p, tab => 'rg',
+ framework_ title => "Relations for $p->{title}[1]", dbobj => $p, tab => 'rg',
sub {
- div_ class => 'mainbox', style => 'float: left; min-width: 100%', sub {
- h1_ "Relations for $p->{name}";
+ 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 };
@@ -59,7 +58,7 @@ TUWF::get qr{/$RE{pid}/rg}, sub {
if($_ == min $num, $total_nodes) {
txt_ $_ ;
} else {
- a_ href => "/$id/rg?num=$_", $_;
+ a_ href => "/$p->{id}/rg?num=$_", $_;
}
}, grep($_ < $total_nodes, 10, 15, 25, 50, 75, 100, 150, 250, 500, 750, 1000), $total_nodes;
txt_ '.';
diff --git a/lib/VNWeb/Producers/List.pm b/lib/VNWeb/Producers/List.pm
index d6193ed5..4b8112f0 100644
--- a/lib/VNWeb/Producers/List.pm
+++ b/lib/VNWeb/Producers/List.pm
@@ -1,6 +1,7 @@
package VNWeb::Producers::List;
use VNWeb::Prelude;
+use VNWeb::AdvSearch;
sub listing_ {
@@ -9,53 +10,65 @@ sub listing_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 150], 't';
- div_ class => 'mainbox producerbrowse', sub {
+ article_ class => 'producerbrowse', sub {
h1_ $opt->{q} ? 'Search results' : 'Browse producers';
- if(!@$list) {
- p_ 'No results found.';
- } else {
- ul_ sub {
- li_ sub {
- abbr_ class => "icons lang $_->{lang}", title => $LANGUAGE{$_->{lang}}, '';
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
- } for @$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{/p/(?<char>all|[a-z0])}, sub {
+TUWF::get qr{/p(?:/(?<char>all|[a-z0]))?}, sub {
my $char = tuwf->capture('char');
my $opt = tuwf->validate(get =>
p => { upage => 1 },
- q => { onerror => '' },
+ 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];
- my $qs = defined $opt->{q} && '%'.sql_like($opt->{q}).'%';
- my $where = sql_and 'NOT p.hidden',
- $qs ? sql 'p.name ILIKE', \$qs, 'OR p.original ILIKE', \$qs, 'OR p.alias ILIKE', \$qs : (),
- $char eq 0 ? "ascii(p.name) not between ascii('a') and ascii('z') AND ascii(p.name) not between ascii('A') and ascii('Z')" :
- $char ne 'all' ? sql 'p.name ILIKE', \"$char%" : ();
+ # compat with old URLs
+ my $oldch = tuwf->capture('char');
+ $opt->{ch} //= $oldch if defined $oldch && $oldch ne 'all';
- my $count = tuwf->dbVali('SELECT COUNT(*) FROM producers p WHERE', $where);
- my $list = tuwf->dbPagei({ results => 150, page => $opt->{p} },
- 'SELECT p.id, p.name, p.original, p.lang FROM producers p WHERE', $where, 'ORDER BY p.name'
- );
+ $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 {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Browse producers';
- form_ action => '/p/all', method => 'get', sub {
+ form_ action => '/p', method => 'get', sub {
searchbox_ p => $opt->{q};
- };
- p_ class => 'browseopts', sub {
- a_ href => "/p/$_", $_ eq $char ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#'
- for ('all', 'a'..'z', 0);
+ 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;
+ listing_ $opt, $list, $count if $count;
};
};
diff --git a/lib/VNWeb/Producers/Page.pm b/lib/VNWeb/Producers/Page.pm
index 128b1ad7..5453d777 100644
--- a/lib/VNWeb/Producers/Page.pm
+++ b/lib/VNWeb/Producers/Page.pm
@@ -2,13 +2,14 @@ package VNWeb::Producers::Page;
use VNWeb::Prelude;
use VNWeb::Releases::Lib;
+use VNWeb::ULists::Lib;
sub enrich_item {
my($p) = @_;
- enrich_extlinks p => $p;
- enrich_merge pid => 'SELECT id AS pid, name, original FROM producers WHERE id IN', $p->{relations};
- $p->{relations} = [ sort { $a->{name} cmp $b->{name} || idcmp($a->{pid}, $b->{pid}) } $p->{relations}->@* ];
+ 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}->@* ];
}
@@ -16,14 +17,14 @@ sub rev_ {
my($p) = @_;
revision_ $p, \&enrich_item,
[ name => 'Name' ],
- [ original => 'Original name' ],
+ [ latin => 'Name (latin)' ],
[ alias => 'Aliases' ],
- [ desc => 'Description' ],
+ [ description=> 'Description' ],
[ type => 'Type', fmt => \%PRODUCER_TYPE ],
[ lang => 'Language', fmt => \%LANGUAGE ],
[ relations => 'Relations', fmt => sub {
txt_ $PRODUCER_RELATION{$_->{relation}}{txt}.': ';
- a_ href => "/$_->{pid}", title => $_->{original}||$_->{name}, $_->{name};
+ a_ href => "/$_->{pid}", tattr $_;
} ],
revision_extlinks 'p'
}
@@ -31,20 +32,18 @@ sub rev_ {
sub info_ {
my($p) = @_;
- h1_ $p->{name};
- h2_ class => 'alttitle', lang => $p->{lang}, $p->{original} if length $p->{original};
p_ class => 'center', sub {
txt_ $PRODUCER_TYPE{$p->{type}};
br_;
- txt_ "Primary language: $LANGUAGE{$p->{lang}}";
+ 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 => $_->[1], $_->[0] }, $p->{extlinks}->@*;
+ join_ ' - ', sub { a_ href => $_->{url2}, $_->{label} }, $p->{extlinks}->@*;
};
p_ class => 'center', sub {
@@ -53,13 +52,11 @@ sub info_ {
br_;
join_ \&br_, sub {
txt_ $PRODUCER_RELATION{$_}{txt}.': ';
- join_ ', ', sub {
- a_ href => "/$_->{pid}", title => $_->{original}||$_->{name}, $_->{name};
- }, $rel{$_}->@*;
+ join_ ', ', sub { a_ href => "/$_->{pid}", tattr $_ }, $rel{$_}->@*;
}, grep $rel{$_}, keys %PRODUCER_RELATION;
} if $p->{relations}->@*;
- div_ class => 'description', sub { lit_ bb_format $p->{desc} } if length $p->{desc};
+ div_ class => 'description', sub { lit_ bb_format $p->{description} } if length $p->{description};
}
@@ -67,17 +64,18 @@ sub rel_ {
my($p) = @_;
my $r = tuwf->dbAlli('
- SELECT r.id, r.type, r.patch, r.released, r.gtin, rp.publisher, rp.developer, ', sql_extlinks(r => 'r.'), '
+ 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, r.id
+ ORDER BY r.released
');
- enrich_extlinks r => $r;
+ $_->{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, v.id, v.title, v.original
- FROM vn v
+ 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
@@ -87,21 +85,24 @@ sub rel_ {
for my $rel (@$r) {
for ($rel->{vn}->@*) {
push @vn, $_ if !$vn{$_->{id}};
- push $vn{$_->{id}}->@*, $rel;
+ 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 {
- # TODO: VN list status & management
td_ colspan => 8, sub {
- a_ href => "/$v->{id}", title => $v->{original}||$v->{title}, $v->{title};
+ ulists_widget_ $v;
+ a_ href => "/$v->{id}", tattr $v;
};
- my $ropt = { id => $v->{id}, prod => 1, lang => 1 };
- release_row_ $_, $ropt for $vn{$v->{id}}->@*;
+ my $ropt = { id => $v->{id}, prod => 1 };
+ release_row_ $_, $ropt for sort_releases(
+ [ map { $_->[1]{rtype} = $_->[0]; $_->[1] } $vn{$v->{id}}->@* ]
+ )->@*;
};
}
} if @$r;
@@ -112,11 +113,11 @@ sub rel_ {
sub vns_ {
my($p) = @_;
my $v = tuwf->dbAlli(q{
- SELECT v.id, v.title, v.original, rels.developer, rels.publisher, rels.released
- FROM vn v
+ 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 r.type <> 'trial'), MIN(r.released))
+ , 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
@@ -124,16 +125,18 @@ sub vns_ {
GROUP BY rv.vid
) rels(vid, developer, publisher, released) ON rels.vid = v.id
WHERE NOT v.hidden
- ORDER BY rels.released
+ 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} };
- a_ href => "/$_->{id}", title => $_->{original}||$_->{title}, $_->{title};
+ ulists_widget_ $_;
+ a_ href => "/$_->{id}", tattr $_;
span_ join ' & ',
$_->{publisher} ? 'Publisher' : (),
$_->{developer} ? 'Developer' : ();
@@ -148,30 +151,32 @@ TUWF::get qr{/$RE{prev}(?:/(?<tab>vn|rel))?}, sub {
return tuwf->resNotFound if !$p;
enrich_item $p;
- my $pref = tuwf->reqCookie('prodrelexpand') ? 'vn' : 'rel';
- my $tab = tuwf->capture('tab') || $pref;
- tuwf->resCookie(prodrelexpand => $tab eq 'vn' ? 1 : undef, expires => time + 315360000) if $tab && $tab ne $pref;
- $tab = 'rel' if !$tab;
+ my $tab = tuwf->capture('tab')
+ || (auth && (tuwf->dbVali('SELECT prodrelexpand FROM users_prefs WHERE id=', \auth->uid) ? 'rel' : 'vn'))
+ || 'rel';
- framework_ title => $p->{name}, index => !tuwf->capture('rev'), dbobj => $p, hiddenmsg => 1,
+ my $title = titleprefs_swap @{$p}{qw/ lang name latin /};
+ framework_ title => $title->[1], index => !tuwf->capture('rev'), dbobj => $p, hiddenmsg => 1,
og => {
- title => $p->{name},
- description => bb_format($p->{desc}, text => 1),
+ title => $title->[1],
+ description => bb_format($p->{description}, text => 1),
},
sub {
rev_ $p if tuwf->capture('rev');
- div_ class => 'mainbox', sub {
+ 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;
};
- div_ class => 'maintabs right', sub {
- ul_ sub {
+ 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' };
};
};
- div_ class => 'mainbox', sub { rel_ $p } if $tab eq 'rel';
- div_ class => 'mainbox', sub { vns_ $p } if $tab eq 'vn';
+ article_ sub { rel_ $p } if $tab eq 'rel';
+ article_ sub { vns_ $p } if $tab eq 'vn';
}
};
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
index ffc8a3b9..b004b7e1 100644
--- a/lib/VNWeb/Releases/Edit.pm
+++ b/lib/VNWeb/Releases/Edit.pm
@@ -4,37 +4,60 @@ use VNWeb::Prelude;
my $FORM = {
- id => { required => 0, vndbid => 'r' },
- title => { maxlength => 300 },
- original => { required => 0, default => '', maxlength => 250 },
- rtype => { default => 'complete', enum => \%RELEASE_TYPE },
+ id => { default => undef, vndbid => 'r' },
official => { anybool => 1 },
patch => { anybool => 1 },
freeware => { anybool => 1 },
doujin => { anybool => 1 },
- lang => { aoh => { lang => { enum => \%LANGUAGE } } },
+ 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 => { required => 0, default => '', maxlength => 50 },
+ catalog => { default => '', sl => 1, maxlength => 50 },
released => { default => 99999999, min => 1, rdate => 1 },
- minage => { required => 0, default => undef, int => 1, enum => \%AGE_RATING },
- uncensored => { anybool => 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 },
- website => { required => 0, default => '', weburl => 1 },
- engine => { required => 0, default => '', maxlength => 50 },
- extlinks => validate_extlinks('r'),
- notes => { required => 0, default => '', maxlength => 10240 },
+ 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' },
@@ -44,65 +67,62 @@ my $FORM = {
} },
hidden => { anybool => 1 },
locked => { anybool => 1 },
-
- authmod => { _when => 'out', 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;
-sub to_extlinks { $_[0]{extlinks} = { map +($_, delete $_[0]{$_}), grep /^l_/, keys $_[0]->%* } }
-
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->{rtype} = delete $e->{type};
$e->{editsum} = $copy ? "Copied from $e->{id}.$e->{chrev}" : $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
- $e->{authmod} = auth->permDbmod;
- to_extlinks $e;
+ $e->{titles} = [ sort { $a->{lang} cmp $b->{lang} } $e->{titles}->@* ];
- enrich_merge vid => 'SELECT id AS vid, title FROM vn WHERE id IN', $e->{vn};
- enrich_merge pid => 'SELECT id AS pid, name FROM producers WHERE id IN', $e->{producers};
+ $e->{vntitles} = $e->{vn}->@* == 1 ? tuwf->dbAlli('SELECT lang, title, latin FROM vn_titles WHERE id =', \$e->{vn}[0]{vid}) : [];
- $e->@{qw/gtin catalog extlinks/} = elm_empty($FORM_OUT)->@{qw/gtin catalog extlinks/} if $copy;
+ 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 $title = ($copy ? 'Copy ' : 'Edit ').$e->{title};
+ 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;
- elm_ ReleaseEdit => $FORM_OUT, $copy ? {%$e, id=>undef} : $e;
+ 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, original FROM vn WHERE id =', \tuwf->capture('id'));
+ 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, r.original FROM releases 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_lang WHERE id IN', $delrel;
+ 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)->%*,
- title => $v->{title},
- original => $v->{original},
- vn => [{vid => $v->{id}, title => $v->{title}}],
+ 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,
};
- $e->{authmod} = auth->permDbmod;
- framework_ title => "Add release to $v->{title}",
+ framework_ title => "Add release to $v->{title}[1]",
sub {
- editmsg_ r => undef, "Add release to $v->{title}";
+ editmsg_ r => undef, "Add release to $v->{title}[1]";
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Deleted releases';
div_ class => 'warning', sub {
p_ q{This visual novel has releases that have been deleted
@@ -112,48 +132,89 @@ TUWF::get qr{/$RE{vid}/add}, sub {
ul_ sub {
li_ sub {
txt_ '['.join(',', $_->{languages}->@*)."] $_->{id}:";
- a_ href => "/$_->{id}", title => $_->{original}||$_->{title}, $_->{title};
+ a_ href => "/$_->{id}", tattr $_;
} for @$delrel;
}
}
} if @$delrel;
- elm_ ReleaseEdit => $FORM_OUT, $e;
+ div_ widget(ReleaseEdit => $FORM_OUT, $e), '';
};
};
-elm_api ReleaseEdit => $FORM_OUT, $FORM_IN, sub {
+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 elm_Unauth if !can_edit r => $e;
+ return tuwf->resDenied if !can_edit r => $e;
if(!auth->permDbmod) {
$data->{hidden} = $e->{hidden}||0;
$data->{locked} = $e->{locked}||0;
}
- $data->{doujin} = $data->{voiced} = $data->{ani_story} = $data->{ani_ero} = 0 if $data->{patch};
- $data->{reso_x} = $data->{reso_y} = 0 if $data->{patch};
- $data->{engine} = '' if $data->{patch};
- $data->{uncensored} = $data->{ani_ero} = 0 if !defined $data->{minage} || $data->{minage} != 18;
+
+ 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});
- to_extlinks $e;
- $e->{rtype} = delete $e->{type};
-
- return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
+ # 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};
+ }
- $data->{$_} = $data->{extlinks}{$_} for $data->{extlinks}->%*;
- delete $data->{extlinks};
- $data->{type} = delete $data->{rtype};
+ return 'No changes' if !$new && !form_changed $FORM_CMP, $data, $e;
my $ch = db_edit r => $e->{id}, $data;
- elm_Redirect "/$ch->{nitemid}.$ch->{nrev}";
+ +{ _redir => "/$ch->{nitemid}.$ch->{nrev}" };
};
+# Set the old ani_story and ani_ero fields to some sort of value based on the
+# new ani_* fields, if they've been changed.
+sub ani_compat {
+ my($r, $old) = @_;
+ return if !grep +($r->{$_}//'_undef_') ne ($old->{$_}//'_undef_'),
+ qw{ ani_story_sp ani_story_cg ani_cutscene ani_ero_sp ani_ero_cg ani_face ani_bg };
+
+ my sub known($) { defined $r->{"ani_$_[0]"} }
+ my sub hasani($) { $r->{"ani_$_[0]"} && $r->{"ani_$_[0]"} > 1 }
+ my sub someani($) { hasani $_[0] && ($r->{"ani_$_[0]"} & 512) == 0 }
+ my sub fullani($) { defined $r->{"ani_$_[0]"} && ($r->{"ani_$_[0]"} & 512) > 0 }
+
+ $r->{ani_story} =
+ !known 'story_sp' && !known 'story_cg' && !known 'cutscene' ? 0 :
+ !hasani 'story_sp' && !hasani 'story_cg' && !hasani 'cutscene' ? 1 :
+ (fullani 'story_sp' || fullani 'story_cg') && !(someani 'story_sp' || someani 'story_cg') ? 4 : 3;
+
+ $r->{ani_ero} =
+ !known 'ero_sp' && !known 'ero_cg' ? 0 :
+ !hasani 'ero_sp' && !hasani 'ero_cg' ? 1 :
+ (fullani 'ero_sp' || fullani 'ero_cg') && !(someani 'ero_sp' || someani 'ero_cg') ? 4 : 3;
+
+ $r->{ani_story} = 2 if $r->{ani_story} < 2 && ($r->{ani_face} || $r->{ani_bg});
+}
+
+
1;
diff --git a/lib/VNWeb/Releases/Elm.pm b/lib/VNWeb/Releases/Elm.pm
index 09c9ede2..4abe0b12 100644
--- a/lib/VNWeb/Releases/Elm.pm
+++ b/lib/VNWeb/Releases/Elm.pm
@@ -26,4 +26,32 @@ elm_api Engines => undef, {}, sub {
});
};
+
+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
index c6e142e2..f5e7e812 100644
--- a/lib/VNWeb/Releases/Engines.pm
+++ b/lib/VNWeb/Releases/Engines.pm
@@ -14,7 +14,7 @@ TUWF::get qr{/r/engines}, sub {
);
framework_ title => 'Engine list', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Engine list';
p_ sub {
lit_ q{
@@ -25,7 +25,7 @@ TUWF::get qr{/r/engines}, sub {
};
};
};
- div_ class => 'mainbox browse', sub {
+ article_ class => 'browse', sub {
table_ class => 'stripe', sub {
my $c = tuwf->compile({advsearch => 'r'});
tr_ sub {
diff --git a/lib/VNWeb/Releases/Lib.pm b/lib/VNWeb/Releases/Lib.pm
index a20d1339..708ed95b 100644
--- a/lib/VNWeb/Releases/Lib.pm
+++ b/lib/VNWeb/Releases/Lib.pm
@@ -3,34 +3,55 @@ package VNWeb::Releases::Lib;
use VNWeb::Prelude;
use Exporter 'import';
-our @EXPORT = qw/enrich_release_elm releases_by_vn enrich_release release_row_/;
+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 => 'SELECT id, title, original, released, type as rtype, reso_x, reso_y FROM releases WHERE id IN', @_;
- enrich_flatten lang => id => id => sub { sql('SELECT id, lang FROM releases_lang WHERE id IN', $_, 'ORDER BY lang') }, @_;
+ 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 FROM releases r JOIN releases_vn rv ON rv.id = r.id WHERE NOT r.hidden AND rv.vid =', \$id, 'ORDER BY r.released, r.title, r.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, type, patch, released, gtin and enrich_extlinks().
+# Assumption: Each release already has id, patch, released, gtin and enrich_extlinks().
sub enrich_release {
my($r) = @_;
- enrich_merge id => 'SELECT id, title, original, notes, minage, official, freeware, doujin, reso_x, reso_y, voiced, ani_story, ani_ero, uncensored FROM releases WHERE id IN', $r;
+ enrich_merge id => sql(
+ 'SELECT 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 lang => id => id => sub { sql 'SELECT id, lang FROM releases_lang WHERE id IN', $_, 'ORDER BY id, lang' }, $r;
enrich_flatten platforms => id => id => sub { sql 'SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY id, platform' }, $r;
+ 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]->@* ];
}
@@ -39,8 +60,8 @@ sub release_extlinks_ {
return if !$r->{extlinks}->@*;
if($r->{extlinks}->@* == 1 && $r->{website}) {
- a_ href => $r->{website}, sub {
- abbr_ class => 'icons external', title => 'Official website', '';
+ a_ href => $r->{extlinks}[0]{url2}, sub {
+ abbr_ class => 'icon-external', title => 'Official website', '';
};
return
}
@@ -49,15 +70,15 @@ sub release_extlinks_ {
div_ class => 'elm_dd', sub {
a_ href => $r->{website}||'#', sub {
txt_ scalar $r->{extlinks}->@*;
- abbr_ class => 'icons external', title => 'External link', '';
+ abbr_ class => 'icon-external', title => 'External link', '';
};
div_ sub {
div_ sub {
ul_ sub {
li_ sub {
- a_ href => $_->[1], sub {
- span_ $_->[2] if length $_->[2];
- txt_ $_->[0];
+ a_ href => $_->{url2}, sub {
+ span_ $_->{price} if length $_->{price};
+ txt_ $_->{label};
}
} for $r->{extlinks}->@*;
}
@@ -70,52 +91,87 @@ sub release_extlinks_ {
# Options
# id: unique identifier if the same release may be listed on a page twice.
-# lang: 0/1 whether to display language icons
+# 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 ? " release_icon_$class" : '';
- img_ src => config->{url_static}."/f/$img.svg", class => "release_icons$class", title => $label;
+ $class = $class ? " icon-rel-$class" : '';
+ abbr_ class => "icon-rel-$img$class", title => $label, '';
}
my sub icons_ {
my($r) = @_;
- icon_ 'voiced', $VOICED{$r->{voiced}}{txt}, "voiced$r->{voiced}" if $r->{voiced};
- icon_ 'story_animated', "Story: $ANIMATED{$r->{ani_story}}{txt}", "anim$r->{ani_story}" if $r->{ani_story};
- icon_ 'ero_animated', "Ero: $ANIMATED{$r->{ani_ero}}{txt}", "anim$r->{ani_ero}" if $r->{ani_ero};
- icon_ 'free', 'Freeware' if $r->{freeware};
- icon_ 'nonfree', 'Non-free' if !$r->{freeware};
- icon_ 'doujin', 'Doujin' if !$r->{patch} && $r->{doujin};
- icon_ 'commercial', 'Commercial' if !$r->{patch} && !$r->{doujin};
+ 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 ? '4-3' : $ratio == 16/9 ? '16-9' : 'custom';
+ my $type = $ratio == 4/3 ? '43' : $ratio == 16/9 ? '169' : 'custom';
# Ugly workaround: PC-98 has non-square pixels, thus not widescreen
- $type = '4-3' if $ratio > 4/3 && grep $_ eq 'p98', $r->{platforms}->@*;
- icon_ "resolution_$type", resolution $r;
+ $type = '43' if $ratio > 4/3 && grep $_ eq 'p98', $r->{platforms}->@*;
+ icon_ "reso-$type", resolution $r;
}
- icon_ $MEDIUM{ $r->{media}[0]{medium} }{icon}, join ', ', map fmtmedia($_->{medium}, $_->{qty}), $r->{media}->@* if $r->{media}->@*;
- icon_ 'uncensor', 'Uncensored' if $r->{uncensored};
- icon_ 'notes', bb_format $r->{notes}, text => 1 if $r->{notes};
+ 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_ sub {
+ tr_ $mtl ? (class => 'mtl') : (), sub {
td_ class => 'tc1', sub { rdate_ $r->{released} };
- td_ class => 'tc2', defined $r->{minage} ? minage $r->{minage} : '';
+ 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 {
- abbr_ class => "icons plat $_", title => $PLATFORM{$_}, '' for $r->{platforms}->@*;
- if($opt->{lang}) {
- abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' for $r->{lang}->@*;
+ platform_ $_ for $r->{platforms}->@*;
+ if(!$opt->{lang}) {
+ abbr_ class => "icon-lang-$_->{lang}".($_->{mtl}?' mtl':''), title => $LANGUAGE{$_->{lang}}{txt}, '' for $r->{titles}->@*;
}
- abbr_ class => "icons rt$r->{type}", title => $r->{type}, '';
+ abbr_ class => "icon-rt$r->{rtype}", title => $r->{rtype}, '';
};
td_ class => 'tc4', sub {
- a_ href => "/$r->{id}", title => $r->{original}||$r->{title}, $r->{title};
- my $note = join ' ', $r->{official} ? () : 'unofficial', $r->{patch} ? 'patch' : ();
- b_ class => 'grayedout', " ($note)" if $note;
+ 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};
diff --git a/lib/VNWeb/Releases/List.pm b/lib/VNWeb/Releases/List.pm
index c3f07e78..a6618dd1 100644
--- a/lib/VNWeb/Releases/List.pm
+++ b/lib/VNWeb/Releases/List.pm
@@ -11,7 +11,7 @@ sub listing_ {
my($opt, $list, $count) = @_;
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 50], 't';
- div_ class => 'mainbox browse', sub {
+ 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; };
@@ -22,7 +22,7 @@ sub listing_ {
td_ class => 'tc5', '';
td_ class => 'tc6', '';
} };
- my $ropt = { id => '', lang => 1 };
+ my $ropt = { id => '' };
release_row_ $_, $ropt for @$list;
}
};
@@ -32,13 +32,15 @@ sub listing_ {
TUWF::get qr{/r}, sub {
my $opt = tuwf->validate(get =>
- q => { onerror => undef },
+ q => { searchquery => 1 },
p => { upage => 1 },
f => { advsearch_err => 'r' },
- s => { onerror => 'title', enum => [qw/released minage title/] },
+ s => { onerror => 'qscore', enum => [qw/qscore released minage title/] },
o => { onerror => 'a', enum => ['a','d'] },
- fil => { required => 0 },
+ 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}) {
@@ -50,42 +52,37 @@ TUWF::get qr{/r}, sub {
$opt->{f} = advsearch_default 'r' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
- my @search = map {
- my $l = '%'.sql_like($_).'%';
- /^\d+$/ && gtintype($_) ? sql 'r.gtin =', \"$_" :
- length $_ > 0 ? sql '(r.title ILIKE', \$l, 'OR r.original ILIKE', \$l, 'OR r.catalog =', \"$_", ')' : ();
- } split /[ -,._]/, $opt->{q}||'';
- my $where = sql_and 'NOT r.hidden', $opt->{f}->sql_where(), @search;
+ 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', $where);
+ $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.type, r.patch, r.released, r.gtin, ', sql_extlinks(r => 'r.'), '
- FROM releases r
+ 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 {
- title => 'r.title %s, r.released %1$s',
- minage => 'r.minage %s, r.title %1$s, r.released %1$s',
- released => 'r.released %s, r.id %1$s',
+ 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 => $list;
+ enrich_extlinks r => 0, $list;
enrich_release $list;
$time = time - $time;
framework_ title => 'Browse releases', sub {
- div_ class => 'mainbox', 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_;
- advsearch_msg_ $count, $time;
+ $opt->{f}->elm_($count, $time);
};
};
listing_ $opt, $list, $count if $count;
diff --git a/lib/VNWeb/Releases/Page.pm b/lib/VNWeb/Releases/Page.pm
index 1f29ad48..17befb1f 100644
--- a/lib/VNWeb/Releases/Page.pm
+++ b/lib/VNWeb/Releases/Page.pm
@@ -1,19 +1,23 @@
package VNWeb::Releases::Page;
use VNWeb::Prelude;
+use TUWF 'uri_escape';
+use VNWeb::Releases::Lib;
sub enrich_item {
my($r) = @_;
- enrich_merge pid => 'SELECT id AS pid, name, original FROM producers WHERE id IN', $r->{producers};
- enrich_merge vid => 'SELECT id AS vid, title, original FROM vn WHERE id IN', $r->{vn};
+ 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->{lang} = [ sort map $_->{lang}, $r->{lang}->@* ];
+ $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->{title} cmp $b->{title} || idcmp($a->{vid}, $b->{vid}) } $r->{vn}->@* ];
- $r->{producers} = [ sort { $a->{name} cmp $b->{name} || idcmp($a->{pid}, $b->{pid}) } $r->{producers}->@* ];
+ $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;
}
@@ -21,19 +25,28 @@ sub enrich_item {
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 { a_ href => "/$_->{vid}", title => $_->{original}||$_->{title}, $_->{title} } ],
- [ type => 'Type' ],
+ [ 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' ],
- [ title => 'Title (Romaji)' ],
- [ original => 'Original title' ],
- [ gtin => 'JAN/EAN/UPC', empty => 0 ],
+ [ gtin => 'JAN/EAN/UPC/ISBN',empty => 0 ],
[ catalog => 'Catalog number' ],
- [ lang => 'Languages', fmt => \%LANGUAGE ],
+ [ 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' ],
@@ -41,19 +54,82 @@ sub _rev_ {
[ media => 'Media', fmt => sub { txt_ fmtmedia $_->{medium}, $_->{qty}; } ],
[ resolution => 'Resolution' ],
[ voiced => 'Voiced', fmt => \%VOICED ],
- [ ani_story => 'Story animation', fmt => \%ANIMATED ],
- [ ani_ero => 'Ero animation', fmt => \%ANIMATED ],
+ $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}", title => $_->{original}||$_->{name}, $_->{name};
+ 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) = @_;
@@ -62,53 +138,53 @@ sub _infotable_ {
td_ class => 'key', 'Relation';
td_ sub {
join_ \&br_, sub {
- a_ href => "/$_->{vid}", title => $_->{original}||$_->{title}, $_->{title};
+ abbr_ class => "icon-rt$_->{rtype}", title => $_->{rtype}, ' ';
+ a_ href => "/$_->{vid}", tattr $_;
+ txt_ " ($_->{rtype})" if $_->{rtype} ne 'complete';
}, $r->{vn}->@*
}
};
- tr_ sub {
- td_ 'Title';
- td_ $r->{title};
- };
-
- tr_ sub {
- td_ 'Original title';
- td_ lang_attr($r->{lang}), $r->{original};
- } if $r->{original};
-
- tr_ sub {
- td_ 'Type';
+ tr_ class => 'titles', sub {
+ td_ $r->{titles}->@* == 1 ? 'Title' : 'Titles';
td_ sub {
- abbr_ class => "icons rt$r->{type}", title => $r->{type}, ' ';
- txt_ ' '.$RELEASE_TYPE{$r->{type}};
- txt_ ', patch' if $r->{patch};
- txt_ ', unofficial' if !$r->{official};
- }
+ 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_ 'Language';
- td_ sub {
- join_ \&br_, sub {
- abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, ' ';
- txt_ ' '.$LANGUAGE{$_};
- }, $r->{lang}->@*;
- }
- };
+ td_ 'Type';
+ td_ !$r->{official} && $r->{patch} ? 'Unofficial patch' :
+ !$r->{official} ? 'Unofficial' : 'Patch';
+ } if !$r->{official} || $r->{patch};
tr_ sub {
td_ 'Publication';
- td_ join ', ',
- $r->{freeware} ? 'Freeware' : 'Non-free',
- $r->{patch} ? () : ($r->{doujin} ? 'doujin' : 'commercial');
+ td_ $r->{freeware} ? 'Freeware' : 'Non-free';
};
tr_ sub {
td_ 'Platform'.($r->{platforms}->@* == 1 ? '' : 's');
td_ sub {
join_ \&br_, sub {
- abbr_ class => "icons plat $_", title => $PLATFORM{$_}, ' ';
+ platform_ $_;
txt_ ' '.$PLATFORM{$_};
}, $r->{platforms}->@*;
}
@@ -131,14 +207,7 @@ sub _infotable_ {
td_ $VOICED{$r->{voiced}}{txt};
} if $r->{voiced};
- tr_ sub {
- td_ 'Animation';
- td_ sub {
- join_ \&br_, sub { txt_ $_ },
- $r->{ani_story} ? "Story: $ANIMATED{$r->{ani_story}}{txt}" : (),
- $r->{ani_ero} ? "Ero scenes: $ANIMATED{$r->{ani_ero}}{txt}" : ();
- }
- } if $r->{ani_story} || $r->{ani_ero};
+ _infotable_animation_ $r;
tr_ sub {
td_ 'Engine';
@@ -148,6 +217,18 @@ sub _infotable_ {
} 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} };
};
@@ -158,9 +239,9 @@ sub _infotable_ {
} if defined $r->{minage};
tr_ sub {
- td_ 'Censoring';
- td_ $r->{uncensored} ? 'No optical censoring (e.g. mosaics)' : 'May include optical censoring (e.g. mosaics)';
- } if $r->{minage} && $r->{minage} == 18;
+ 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}};
@@ -168,7 +249,7 @@ sub _infotable_ {
td_ ucfirst($t).(@prod == 1 ? '' : 's');
td_ sub {
join_ \&br_, sub {
- a_ href => "/$_->{pid}", title => $_->{original}||$_->{name}, $_->{name};
+ a_ href => "/$_->{pid}", tattr $_;
}, @prod
}
} if @prod;
@@ -187,7 +268,7 @@ sub _infotable_ {
tr_ sub {
td_ 'Links';
td_ sub {
- join_ ', ', sub { a_ href => $_->[1], $_->[0] }, $r->{extlinks}->@*;
+ join_ ', ', sub { a_ href => $_->{url2}, $_->{label} }, $r->{extlinks}->@*;
}
} if $r->{extlinks}->@*;
@@ -208,19 +289,20 @@ 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 => $r;
+ enrich_extlinks r => 0, $r;
- framework_ title => $r->{title}, index => !tuwf->capture('rev'), dbobj => $r, hiddenmsg => 1,
+ 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');
- div_ class => 'mainbox release', sub {
+ article_ class => 'release', sub {
itemmsg_ $r;
- h1_ sub { txt_ $r->{title}; debug_ $r };
- h2_ class => 'alttitle', lang_attr($r->{lang}), $r->{original} if length $r->{original};
+ 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};
};
diff --git a/lib/VNWeb/Releases/VNTab.pm b/lib/VNWeb/Releases/VNTab.pm
index de8c1d66..33df7207 100644
--- a/lib/VNWeb/Releases/VNTab.pm
+++ b/lib/VNWeb/Releases/VNTab.pm
@@ -28,38 +28,37 @@ my @rel_cols = (
{ # Title
id => 'tit',
sort_field => 'title',
- sort_sql => 'r.title %s, r.released %1$s',
+ sort_sql => 'r.sorttitle %s, r.released %1$s',
column_string => 'Title',
- draw => sub { a_ href => "/$_[0]{id}", $_[0]{title} },
+ draw => sub { a_ href => "/$_[0]{id}", tattr $_[0] },
}, { # Type
id => 'typ',
sort_field => 'type',
- sort_sql => 'r.patch %s, r.type %1$s, r.released %1$s, r.title %1$s',
+ 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 => "icons rt$_[0]{type}", title => $_[0]{type}, ''; txt_ '(patch)' if $_[0]{patch} },
+ draw => sub { abbr_ class => "icon-rt$_[0]{rtype}", title => $_[0]{rtype}, ''; txt_ '(patch)' if $_[0]{patch} },
}, { # Languages
id => 'lan',
button_string => 'Language',
default => 1,
- has_data => sub { !!@{$_[0]{lang}} },
- draw => sub { join_ \&br_, sub { abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, ''; }, $_[0]{lang}->@* },
+ 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.doujin %s, r.freeware %1$s, r.patch %1$s, r.released %1$s, r.title %1$s',
+ 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_ join ', ', $_[0]{freeware} ? 'Freeware' : 'Non-free', $_[0]{patch} ? () : ($_[0]{doujin} ? 'doujin' : 'commercial') },
+ 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 { abbr_ class => "icons plat $_", title => $PLATFORM{$_}, ''; }, $_[0]{platforms}->@*;
+ join_ \&br_, sub { platform_ $_ }, $_[0]{platforms}->@*;
txt_ 'Unknown' if !$_[0]{platforms}->@*;
},
}, { # Media
@@ -74,7 +73,7 @@ my @rel_cols = (
}, { # 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.title %1$s',
+ 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,
@@ -84,7 +83,7 @@ my @rel_cols = (
}, { # Voiced
id => 'voi',
sort_field => 'voiced',
- sort_sql => 'r.voiced %s, r.patch %1$s, r.released %1$s, r.title %1$s',
+ 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',
@@ -95,7 +94,7 @@ my @rel_cols = (
}, { # 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.title %1$s',
+ 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',
@@ -118,7 +117,7 @@ my @rel_cols = (
}, { # Age rating
id => 'min',
sort_field => 'minage',
- sort_sql => 'r.minage %s, r.released %1$s, r.title %1$s',
+ 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} },
@@ -126,7 +125,7 @@ my @rel_cols = (
}, { # Notes
id => 'not',
sort_field => 'notes',
- sort_sql => 'r.notes %s, r.released %1$s, r.title %1$s',
+ sort_sql => 'r.notes %s, r.released %1$s, r.sorttitle %1$s',
column_string => 'Notes',
column_width => 400,
button_string => 'Notes',
@@ -158,17 +157,17 @@ sub buttons_ {
};
my sub pl {
- my($row, $option, $txt, $csscat) = @_;
- my %opts = map +($_,1), map $_->{$row}->@*, @$r;
+ 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' : abbr_ class => "icons $csscat $_", title => $txt->{$_}, '';
+ $_ eq 'all' ? txt_ 'All' : $icon->($_);
} for ('all', sort keys %opts);
}
};
- pl 'platforms', 'os', \%PLATFORM, '' if $opt->{pla};
- pl 'lang', 'lang',\%LANGUAGE, 'lang' if $opt->{lan};
+ 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};
}
@@ -178,7 +177,7 @@ sub listing_ {
# Apply language and platform filters
my @r = grep +
($opt->{os} eq 'all' || ($_->{platforms} && grep $_ eq $opt->{os}, $_->{platforms}->@*)) &&
- ($opt->{lang} eq 'all' || ($_->{lang} && grep $_ eq $opt->{lang}, $_->{lang}->@*)), @$r;
+ ($opt->{lang} eq 'all' || ($_->{titles} && grep $_ eq $opt->{lang}, map $_->{lang}, $_->{titles}->@*)), @$r;
# Figure out which columns to display
my @col;
@@ -187,7 +186,7 @@ sub listing_ {
push @col, $c if !@r || !$c->{has_data} || grep $c->{has_data}->($_), @r; # Must have relevant data
}
- div_ class => 'mainbox releases_compare', sub {
+ article_ class => 'releases_compare', sub {
table_ sub {
thead_ sub { tr_ sub {
td_ class => 'key', sub {
@@ -237,8 +236,8 @@ TUWF::get qr{/$RE{vid}/releases} => sub {
$opt->{o} = 'd' if $opt->{o} eq 1;
my $r = tuwf->dbAlli('
- SELECT r.id, r.type, r.patch, r.released, r.gtin
- FROM releases r
+ 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')
@@ -247,9 +246,9 @@ TUWF::get qr{/$RE{vid}/releases} => sub {
my sub url { '?'.query_encode %$opt, @_ }
- framework_ title => "Releases for $v->{title}", type => 'v', dbobj => $v, tab => 'releases', sub {
- div_ class => 'mainbox releases_compare', sub {
- h1_ "Releases for $v->{title}";
+ 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 {
diff --git a/lib/VNWeb/Reviews/Edit.pm b/lib/VNWeb/Reviews/Edit.pm
index 00002c6b..925206d2 100644
--- a/lib/VNWeb/Reviews/Edit.pm
+++ b/lib/VNWeb/Reviews/Edit.pm
@@ -5,13 +5,13 @@ use VNWeb::Releases::Lib;
my $FORM = {
- id => { vndbid => 'w', required => 0 },
+ id => { vndbid => 'w', default => undef },
vid => { vndbid => 'v' },
vntitle => { _when => 'out' },
- rid => { vndbid => 'r', required => 0 },
+ rid => { vndbid => 'r', default => undef },
spoiler => { anybool => 1 },
isfull => { anybool => 1 },
- modnote => { maxlength => 1024, required => 0, default => '' },
+ modnote => { maxlength => 1024, default => '' },
text => { maxlength => 100_000, default => '' },
locked => { anybool => 1 },
@@ -25,9 +25,15 @@ 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 FROM vn WHERE NOT hidden AND id =', \tuwf->capture('id'));
+ 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);
@@ -36,13 +42,13 @@ TUWF::get qr{/$RE{vid}/addreview}, sub {
framework_ title => "Write review for $v->{title}", sub {
if(throttled) {
- div_ class => 'mainbox', sub {
+ 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_by_vn($v->{id}), mod => auth->permBoardmod()
+ vid => $v->{id}, vntitle => $v->{title}, releases => releases($v->{id}), mod => auth->permBoardmod()
};
}
};
@@ -51,13 +57,13 @@ TUWF::get qr{/$RE{vid}/addreview}, sub {
TUWF::get qr{/$RE{wid}/edit}, sub {
my $e = tuwf->dbRowi(
- 'SELECT r.id, r.uid AS user_id, r.vid, r.rid, r.isfull, r.modnote, r.text, r.spoiler, r.locked, v.title AS vntitle
- FROM reviews r JOIN vn v ON v.id = r.vid WHERE r.id =', \tuwf->capture('id')
+ '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_by_vn $e->{vid};
+ $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;
@@ -71,6 +77,7 @@ elm_api ReviewsEdit => $FORM_OUT, $FORM_IN, sub {
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) {
@@ -103,6 +110,7 @@ elm_api ReviewsEdit => $FORM_OUT, $FORM_IN, sub {
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});
diff --git a/lib/VNWeb/Reviews/Elm.pm b/lib/VNWeb/Reviews/JS.pm
index f3e28516..32489a33 100644
--- a/lib/VNWeb/Reviews/Elm.pm
+++ b/lib/VNWeb/Reviews/JS.pm
@@ -1,27 +1,24 @@
-package VNWeb::Reviews::Elm;
+package VNWeb::Reviews::JS;
use VNWeb::Prelude;
-my $VOTE = {
+our $VOTE = form_compile any => {
id => { vndbid => 'w' },
- my => { required => 0, jsonbool => 1 },
+ my => { undefbool => 1 },
overrule => { anybool => 1 },
- mod => { _when => 'out', anybool => 1 },
+ mod => { anybool => 1 },
};
-my $VOTE_IN = form_compile in => $VOTE;
-our $VOTE_OUT = form_compile out => $VOTE;
-
-elm_api ReviewsVote => $VOTE_OUT, $VOTE_IN, sub {
+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}?1:0, overrule => auth->permBoardmod ? $data->{overrule}?1:0 : 0, date => sql 'NOW()');
+ 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
);
- elm_Success
+ +{}
};
1;
diff --git a/lib/VNWeb/Reviews/Lib.pm b/lib/VNWeb/Reviews/Lib.pm
index 1f7c6e4e..8ea54a09 100644
--- a/lib/VNWeb/Reviews/Lib.pm
+++ b/lib/VNWeb/Reviews/Lib.pm
@@ -2,13 +2,22 @@ package VNWeb::Reviews::Lib;
use VNWeb::Prelude;
use Exporter 'import';
-our @EXPORT = qw/reviews_vote_ reviews_format/;
+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 {
- elm_ 'Reviews.Vote' => $VNWeb::Reviews::Elm::VOTE_OUT, {%$w, mod => auth->permBoardmod||0} if $w->{can} || auth->permBoardmod;
- b_ class => 'grayedout', sprintf ' %.2f/%.2f', $w->{c_up}/100, $w->{c_down}/100 if auth->permBoardmod;
+ 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;
}
}
diff --git a/lib/VNWeb/Reviews/List.pm b/lib/VNWeb/Reviews/List.pm
index bb65c75b..84985de0 100644
--- a/lib/VNWeb/Reviews/List.pm
+++ b/lib/VNWeb/Reviews/List.pm
@@ -9,7 +9,7 @@ sub tablebox_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 50], 't';
- div_ class => 'mainbox browse reviewlist', sub {
+ 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 };
@@ -26,7 +26,7 @@ sub tablebox_ {
td_ class => 'tc2', sub { user_ $_ };
td_ class => 'tc3', fmtvote $_->{vote};
td_ class => 'tc4', $_->{isfull} ? 'Full' : 'Mini';
- td_ class => 'tc5', sub { a_ href => "/$_->{id}", $_->{title}; b_ class => 'grayedout', ' (flagged)' if $_->{c_flagged} };
+ td_ class => '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 {
@@ -51,16 +51,18 @@ TUWF::get qr{/w}, sub {
$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};
+ return tuwf->resNotFound if $u && (!$u->{id} || (!$u->{user_name} && !auth->isMod));
- my $where = $u ? sql 'w.uid =', \$u->{id} : '1=1';
+ 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 vn v ON v.id = w.vid
+ 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
@@ -71,7 +73,7 @@ TUWF::get qr{/w}, sub {
my $title = $u ? 'Reviews by '.user_displayname($u) : 'Browse reviews';
framework_ title => $title, $u ? (dbobj => $u, tab => 'reviews') : (), sub {
- div_ class => 'mainbox', 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.';
diff --git a/lib/VNWeb/Reviews/Page.pm b/lib/VNWeb/Reviews/Page.pm
index e6d78e4c..3f58905b 100644
--- a/lib/VNWeb/Reviews/Page.pm
+++ b/lib/VNWeb/Reviews/Page.pm
@@ -10,16 +10,16 @@ my $COMMENT = form_compile any => {
msg => { maxlength => 32768 }
};
-elm_api ReviewsComment => undef, $COMMENT, sub {
+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 elm_Unauth if !can_edit t => $w;
+ 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');
- elm_Redirect "/$w->{id}.$num#last";
+ +{ _redir => "/$w->{id}.$num#last" };
};
@@ -27,42 +27,47 @@ elm_api ReviewsComment => undef, $COMMENT, sub {
sub review_ {
my($w) = @_;
- input_ type => 'checkbox', class => 'visuallyhidden', id => 'reviewspoil', (auth->pref('spoilers')||0) == 2 ? ('checked', 'checked') : (), undef;
+ 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}", $w->{title};
+ a_ href => "/$w->{vid}", tattr $w;
if($w->{rid}) {
br_;
- abbr_ class => "icons plat $_", title => $PLATFORM{$_}, '' for $w->{platforms}->@*;
- abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' for $w->{lang}->@*;
- abbr_ class => "icons rt$w->{rtype}", title => $w->{rtype}, '';
- a_ href => "/$w->{rid}", title => $w->{roriginal}||$w->{rtitle}, $w->{rtitle};
+ 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 {
- b_ style => 'float: right; padding-left: 25px', 'Vote: '.fmtvote($w->{vote}) if $w->{vote};
+ 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";
- b_ class => 'grayedout', " last updated on $lastmod" if $lastmod && $date ne $lastmod;
+ 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_;
- b_ class => 'grayedout', 'Flagged: this review is below the voting threshold and not visible on the VN page.';
+ small_ 'Flagged: this review is below the voting threshold and not visible on the VN page.';
}
if($w->{locked}) {
br_;
- b_ class => 'grayedout', 'Locked: commenting on this review has been disabled.';
+ small_ 'Locked: commenting on this review has been disabled.';
}
if($w->{spoiler} && (auth->pref('spoilers')||0) == 2) {
br_;
- b_ 'This review contains spoilers.';
+ strong_ 'This review contains spoilers.';
}
}
};
@@ -94,11 +99,12 @@ 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, rel.original AS roriginal, rel.type AS rtype, rv.vote AS my, COALESCE(rv.overrule,false) AS overrule
+ , 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 vn v ON v.id = r.vid
- LEFT JOIN releases rel ON rel.id = r.rid
+ 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
@@ -108,7 +114,7 @@ TUWF::get qr{/$RE{wid}(?:(?<sep>[\./])$RE{num})?}, sub {
);
return tuwf->resNotFound if !$w->{id};
- enrich_flatten lang => rid => id => sub { sql 'SELECT id, lang FROM releases_lang WHERE id IN', $_, 'ORDER BY id, lang' }, $w;
+ 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
@@ -130,28 +136,30 @@ TUWF::get qr{/$RE{wid}(?:(?<sep>[\./])$RE{num})?}, sub {
auth->notiRead($id, undef);
auth->notiRead($id, [ map $_->{num}, $posts->@* ]) if @$posts;
- my $newreview = auth && auth->uid eq $w->{user_id} && tuwf->reqGet('submit');
+ my $newreview = auth && $w->{user_id} && auth->uid eq $w->{user_id} && tuwf->reqGet('submit');
- my $title = "Review of $w->{title}";
+ my $title = "Review of $w->{title}[1]";
framework_ title => $title, index => 1, dbobj => $w,
- $num||$page>1 ? (pagevars => {sethash=>$num?$num:'threadstart'}) : (),
+ $num||$page>1 ? (pagevars => {sethash=>$num?"p$num":'threadstart'}) : (),
sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
itemmsg_ $w;
h1_ $title;
div_ class => 'notice', sub {
- b_ 'Review has been successfully submitted! ';
+ h2_ 'Review has been successfully submitted! ';
a_ href => "/$w->{id}", "dismiss";
} if $newreview;
review_ $w;
};
- if(grep !$_->{hidden}, @$posts) {
- h1_ class => 'boxtitle', 'Comments';
+ if(grep !defined $_->{hidden}, @$posts) {
+ nav_ sub {
+ h1_ 'Comments';
+ };
VNWeb::Discussions::Thread::posts_($w, $posts, $page);
} else {
div_ id => 'threadstart', '';
}
- elm_ 'Reviews.Comment' => $COMMENT, { id => $w->{id}, msg => '' } if !$newreview && $w->{count} <= $page*25 && can_edit t => $w;
+ div_ widget(ReviewComment => $COMMENT, { id => $w->{id}, msg => '' }), '' if !$newreview && $w->{count} <= $page*25 && can_edit t => $w;
};
};
diff --git a/lib/VNWeb/Reviews/VNTab.pm b/lib/VNWeb/Reviews/VNTab.pm
index e07c3b32..c0e6cbbb 100644
--- a/lib/VNWeb/Reviews/VNTab.pm
+++ b/lib/VNWeb/Reviews/VNTab.pm
@@ -22,51 +22,51 @@ sub reviews_ {
);
return if !@$lst;
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $mini ? 'Mini reviews' : 'Full reviews';
debug_ $lst;
- div_ class => 'reviews', sub {
- article_ class => 'reviewbox', sub {
- my $r = $_;
- div_ sub {
- span_ sub {
- txt_ 'By '; user_ $r; txt_ ' on '.fmtdate $r->{date}, 'compact';
- b_ class => 'grayedout', ' contains spoilers' if $r->{spoiler} && (auth->pref('spoilers')||0) == 2;
- };
- a_ href => "/$r->{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_ 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;
};
- 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 => 'visuallyhidden', (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;
+ 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_ '>';
};
- div_ sub {
- a_ href => "/$r->{id}#threadstart", $r->{c_count} == 1 ? '1 comment' : "$r->{c_count} comments";
- reviews_vote_ $r;
- };
- } for @$lst;
- }
+ 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;
};
}
@@ -77,7 +77,7 @@ TUWF::get qr{/$RE{vid}/(?<mini>mini|full)?reviews}, sub {
return tuwf->resNotFound if !$v;
VNWeb::VN::Page::enrich_vn($v);
- framework_ title => ($mini?'Mini reviews':'Reviews')." for $v->{title}", index => 1, dbobj => $v, hiddenmsg => 1,
+ 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');
diff --git a/lib/VNWeb/Staff/Edit.pm b/lib/VNWeb/Staff/Edit.pm
index 24311d89..42ef2a3d 100644
--- a/lib/VNWeb/Staff/Edit.pm
+++ b/lib/VNWeb/Staff/Edit.pm
@@ -4,28 +4,23 @@ use VNWeb::Prelude;
my $FORM = {
- id => { required => 0, vndbid => 's' },
- aid => { int => 1, range => [ -1000, 1<<40 ] }, # X
+ id => { default => undef, vndbid => 's' },
+ main => { int => 1, range => [ -1000, 1<<40 ] }, # X
alias => { maxlength => 100, sort_keys => 'aid', aoh => {
aid => { int => 1, range => [ -1000, 1<<40 ] }, # X, negative IDs are for new aliases
- name => { maxlength => 200 },
- original => { maxlength => 200, required => 0, default => '' },
+ name => { sl => 1, maxlength => 200 },
+ latin => { sl => 1, maxlength => 200, default => undef },
inuse => { anybool => 1, _when => 'out' },
wantdel => { anybool => 1, _when => 'out' },
} },
- desc => { required => 0, default => '', maxlength => 5000 },
+ description=> { default => '', maxlength => 5000 },
gender => { default => 'unknown', enum => [qw[unknown m f]] },
- lang => { default => 'ja', language => 1 },
- l_site => { required => 0, default => '', weburl => 1 },
- l_wikidata => { required => 0, uint => 1, max => (1<<31)-1 },
- l_twitter => { required => 0, default => '', regex => qr/^\S+$/, maxlength => 16 },
- l_anidb => { required => 0, id => 1, default => undef },
- l_pixiv => { required => 0, id => 1, default => 0 },
+ lang => { language => 1 },
+ l_site => { default => '', weburl => 1 },
hidden => { anybool => 1 },
locked => { anybool => 1 },
-
- authmod => { _when => 'out', anybool => 1 },
editsum => { _when => 'in out', editsum => 1 },
+ validate_extlinks 's'
};
my $FORM_OUT = form_compile out => $FORM;
@@ -37,7 +32,6 @@ 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->{authmod} = auth->permDbmod;
$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)';
@@ -46,15 +40,17 @@ TUWF::get qr{/$RE{srev}/edit} => sub {
# 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, original, true AS inuse, true AS wantdel
+ "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};
- my $name = (grep $_->{aid} == $e->{aid}, @{$e->{alias}})[0]{name};
+ $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";
- elm_ StaffEdit => $FORM_OUT, $e;
+ div_ widget(StaffEdit => $FORM_OUT, $e), '';
};
};
@@ -64,32 +60,32 @@ TUWF::get qr{/s/new}, sub {
framework_ title => 'Add staff member',
sub {
editmsg_ s => undef, 'Add staff member';
- elm_ StaffEdit => $FORM_OUT, {
+ div_ widget(StaffEdit => $FORM_OUT, {
elm_empty($FORM_OUT)->%*,
- alias => [ { aid => -1, name => '', original => '', inuse => 0, wantdel => 0 } ],
- aid => -1
- };
+ alias => [ { aid => -1, name => '', latin => undef, inuse => 0, wantdel => 0 } ],
+ main => -1
+ }), '';
};
};
-elm_api StaffEdit => $FORM_OUT, $FORM_IN, sub {
+js_api StaffEdit => $FORM_IN, sub {
my $data = shift;
my $new = !$data->{id};
my $e = $new ? { id => 0 } : db_entry $data->{id} or return tuwf->resNotFound;
- return elm_Unauth if !can_edit s => $e;
+ return tuwf->resDenied if !can_edit s => $e;
if(!auth->permDbmod) {
$data->{hidden} = $e->{hidden}||0;
$data->{locked} = $e->{locked}||0;
}
$data->{l_wp} = $e->{l_wp}||'';
- $data->{desc} = bb_subst_links $data->{desc};
+ $data->{description} = bb_subst_links $data->{description};
- # The form validation only checks for duplicate aid's, but the name+original should also be unique.
+ # The form validation only checks for duplicate aid's, but the name+latin should also be unique.
my %names;
- die "Duplicate aliases" if grep $names{"$_->{name}\x00$_->{original}"}++, $data->{alias}->@*;
- die "Original = name" if grep $_->{name} eq $_->{original}, $data->{alias}->@*;
+ die "Duplicate aliases" if grep $names{"$_->{name}\x00".($_->{latin}//'')}++, $data->{alias}->@*;
+ die "Latin = name" if grep $_->{latin} && $_->{name} eq $_->{latin}, $data->{alias}->@*;
# For positive alias IDs: Make sure they exist and are (or were) owned by this entry.
validate_dbid
@@ -99,14 +95,15 @@ elm_api StaffEdit => $FORM_OUT, $FORM_IN, sub {
# For negative alias IDs: Assign a new ID.
for my $alias (grep $_->{aid} < 0, $data->{alias}->@*) {
my $new = tuwf->dbVali(select => sql_func nextval => \'staff_alias_aid_seq');
- $data->{aid} = $new if $alias->{aid} == $data->{aid};
+ $data->{main} = $new if $alias->{aid} == $data->{main};
$alias->{aid} = $new;
}
# We rely on Postgres to throw an error if we attempt to delete an alias that is still being referenced.
- return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
+ return +{ _err => 'No changes.' } if !$new && !form_changed $FORM_CMP, $data, $e;
+
my $ch = db_edit s => $e->{id}, $data;
- elm_Redirect "/$ch->{nitemid}.$ch->{nrev}";
+ +{ _redir => "/$ch->{nitemid}.$ch->{nrev}" };
};
1;
diff --git a/lib/VNWeb/Staff/Elm.pm b/lib/VNWeb/Staff/Elm.pm
index c4db154f..43cff16a 100644
--- a/lib/VNWeb/Staff/Elm.pm
+++ b/lib/VNWeb/Staff/Elm.pm
@@ -2,24 +2,33 @@ package VNWeb::Staff::Elm;
use VNWeb::Prelude;
-elm_api Staff => undef, { search => {} }, sub {
- my $q = shift->{search};
- my $qs = sql_like $q;
+elm_api Staff => undef, {
+ search => { type => 'array', values => { searchquery => 1 } },
+}, sub {
+ my($data) = @_;
+ my @q = grep $_, $data->{search}->@*;
- elm_StaffResult tuwf->dbPagei({ results => 15, page => 1 },
- 'SELECT s.id, sa.aid, sa.name, sa.original
- FROM (',
- sql_join('UNION ALL',
- $q =~ /^$RE{sid}$/ ? sql('SELECT 0, aid FROM staff_alias WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(name),', \$qs, ')+substr_score(lower(original),', \$qs, '), aid
- FROM staff_alias WHERE name ILIKE', \"%$qs%", "OR translate(original,' ','') ILIKE", \("%$qs%" =~ s/ //gr)),
- ), ') x(prio, aid)
- JOIN staff_alias sa ON sa.aid = x.aid
- JOIN staff s ON s.id = sa.id
+ 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
- GROUP BY s.id, sa.aid, sa.name, sa.original
- ORDER BY MIN(x.prio), sa.name
- ');
+ 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
index d83e7827..fb92db52 100644
--- a/lib/VNWeb/Staff/List.pm
+++ b/lib/VNWeb/Staff/List.pm
@@ -9,12 +9,12 @@ sub listing_ {
my($opt, $list, $count) = @_;
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 150], 't';
- div_ class => 'mainbox staffbrowse', sub {
+ article_ class => 'staffbrowse', sub {
h1_ 'Staff list';
ul_ sub {
li_ sub {
- abbr_ class => "icons lang $_->{lang}", title => $LANGUAGE{$_->{lang}}, '';
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, '';
+ a_ href => "/$_->{id}", tattr $_;
} for @$list;
};
};
@@ -24,12 +24,12 @@ sub listing_ {
TUWF::get qr{/s(?:/(?<char>all|[a-z0]))?}, sub {
my $opt = tuwf->validate(get =>
- q => { onerror => undef },
+ 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 => { required => 0 },
+ fil => { onerror => '' },
)->data;
$opt->{ch} = $opt->{ch}[0];
$opt->{n} = $opt->{n}[0];
@@ -51,29 +51,26 @@ TUWF::get qr{/s(?:/(?<char>all|[a-z0]))?}, sub {
$opt->{f} = advsearch_default 's' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
- my @search = map {
- my $l = '%'.sql_like($_).'%';
- length $_ > 0 ? sql '(sa.name ILIKE', \$l, "OR translate(sa.original,' ','') ILIKE", \$l, ')' : ();
- } split /[ -,._]/, $opt->{q}||'';
-
my $where = sql_and
- $opt->{n} ? 's.aid = sa.aid' : (),
- 'NOT s.hidden', $opt->{f}->sql_where(), @search,
- defined($opt->{ch}) && $opt->{ch} ? sql('LOWER(SUBSTR(sa.name, 1, 1)) =', \$opt->{ch}) : (),
- defined($opt->{ch}) && !$opt->{ch} ? sql('(ASCII(sa.name) <', \97, 'OR ASCII(sa.name) >', \122, ') AND (ASCII(sa.name) <', \65, 'OR ASCII(sa.name) >', \90, ')') : ();
+ $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 s JOIN staff_alias sa ON sa.id = s.id WHERE', $where);
+ $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, sa.name, sa.original, s.lang FROM staff s JOIN staff_alias sa ON sa.id = s.id WHERE', $where, 'ORDER BY sa.name, sa.aid'
+ 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 {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Browse staff';
form_ action => '/s', method => 'get', sub {
searchbox_ s => $opt->{q}//'';
@@ -87,8 +84,7 @@ TUWF::get qr{/s(?:/(?<char>all|[a-z0]))?}, sub {
};
input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
input_ type => 'hidden', name => 'n', value => $opt->{n}//0;
- $opt->{f}->elm_;
- advsearch_msg_ $count, $time;
+ $opt->{f}->elm_($count, $time);
};
};
listing_ $opt, $list, $count if $count;
diff --git a/lib/VNWeb/Staff/Page.pm b/lib/VNWeb/Staff/Page.pm
index 84962044..0dc1a856 100644
--- a/lib/VNWeb/Staff/Page.pm
+++ b/lib/VNWeb/Staff/Page.pm
@@ -1,30 +1,37 @@
package VNWeb::Staff::Page;
use VNWeb::Prelude;
+use VNWeb::ULists::Lib;
sub enrich_item {
my($s) = @_;
- # Add a 'main' flag to each alias
- $_->{main} = $s->{aid} == $_->{aid} for $s->{alias}->@*;
+ # Add a 'main' flag and title field to each alias
+ for ($s->{alias}->@*) {
+ $_->{main} = $s->{main} == $_->{aid};
+ $_->{title} = titleprefs_swap $s->{lang}, $_->{name}, $_->{latin};
+ }
- # Sort aliases by name
- $s->{alias} = [ sort { $a->{name} cmp $b->{name} || ($a->{original}||'') cmp ($b->{original}||'') } $s->{alias}->@* ];
+ # Sort aliases by aid for more readable comparison at revisions.
+ $s->{alias} = [ sort { $a->{aid} <=> $b->{aid} } $s->{alias}->@* ];
}
sub _rev_ {
my($s) = @_;
+ my %aid;
revision_ $s, \&enrich_item,
[ alias => 'Names', fmt => sub {
+ my $num = ($aid{$_->{aid}} ||= keys %aid);
+ strong_ "$num: ";
txt_ $_->{name};
- txt_ " ($_->{original})" if $_->{original};
- b_ class => 'grayedout', ' (primary)' if $_->{main};
+ txt_ " ($_->{latin})" if $_->{latin};
+ small_ ' (primary)' if $_->{main};
} ],
[ gender => 'Gender', fmt => \%GENDER ],
[ lang => 'Language', fmt => \%LANGUAGE ],
- [ desc => 'Description' ],
+ [ description => 'Description' ],
revision_extlinks 's'
}
@@ -34,25 +41,25 @@ sub _infotable_ {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ colspan => 2, sub {
- b_ style => 'margin-right: 10px', $main->{name};
- b_ class => 'grayedout', style => 'margin-right: 10px', lang => $s->{lang}, $main->{original} if $main->{original};
- abbr_ class => "icons gen $s->{gender}", title => $GENDER{$s->{gender}}, '' if $s->{gender} ne 'unknown';
+ span_ style => 'margin-right: 10px', tlang($main->{title}[0], $main->{title}[1]), $main->{title}[1];
+ small_ style => 'margin-right: 10px', tlang($main->{title}[2], $main->{title}[3]), $main->{title}[3] if $main->{title}[1] ne $main->{title}[3];
+ abbr_ class => "icon-gen-$s->{gender}", title => $GENDER{$s->{gender}}, '' if $s->{gender} ne 'unknown';
}
} };
tr_ sub {
td_ class => 'key', 'Language';
- td_ $LANGUAGE{$s->{lang}};
+ td_ $LANGUAGE{$s->{lang}}{txt};
};
- my @alias = grep !$_->{main}, $s->{alias}->@*;
+ my @alias = sort { ($a->{latin}//$a->{name}) cmp ($b->{latin}//$b->{name}) } grep !$_->{main}, $s->{alias}->@*;
tr_ sub {
td_ @alias == 1 ? 'Alias' : 'Aliases';
td_ sub {
table_ class => 'aliases', sub {
tr_ class => 'nostripe', sub {
- td_ class => 'key', $_->{original} ? () : (colspan => 2), $_->{name};
- td_ lang => $s->{lang}, $_->{original} if $_->{original};
+ td_ class => 'key', $_->{latin} ? () : (colspan => 2), tlang($s->{lang}, $_->{name}), $_->{name};
+ td_ tlang($s->{lang}, $_->{latin}), $_->{latin} if $_->{latin};
} for @alias;
};
};
@@ -61,7 +68,7 @@ sub _infotable_ {
tr_ sub {
td_ class => 'key', 'Links';
td_ sub {
- join_ \&br_, sub { a_ href => $_->[1], $_->[0] }, $s->{extlinks}->@*;
+ join_ \&br_, sub { a_ href => $_->{url2}, $_->{label} }, $s->{extlinks}->@*;
};
} if $s->{extlinks}->@*;
};
@@ -72,34 +79,45 @@ sub _roles_ {
my($s) = @_;
my %alias = map +($_->{aid}, $_), $s->{alias}->@*;
- my $roles = tuwf->dbAlli(q{
- SELECT v.id, vs.aid, vs.role, vs.note, v.c_released, v.title, v.original
+ my $roles = tuwf->dbAlli('
+ SELECT v.id, vs.aid, vs.role, vs.note, ve.name, ve.official, ve.lang, v.c_released, v.title
FROM vn_staff vs
- JOIN vn v ON v.id = vs.id
- WHERE vs.aid IN}, [ keys %alias ], q{
+ JOIN', vnt, 'v ON v.id = vs.id
+ LEFT JOIN vn_editions ve ON ve.id = vs.id AND ve.eid = vs.eid
+ WHERE vs.aid IN', [ keys %alias ], '
AND NOT v.hidden
- ORDER BY v.c_released ASC, v.title ASC, vs.role ASC
- });
+ ORDER BY v.c_released ASC, v.sorttitle ASC, ve.lang NULLS FIRST, ve.name NULLS FIRST, vs.role ASC
+ ');
return if !@$roles;
+ enrich_ulists_widget $roles;
- h1_ class => 'boxtitle', sprintf 'Credits (%d)', scalar @$roles;
- div_ class => 'mainbox browse staffroles', sub {
+ nav_ sub {
+ h1_ sprintf 'Credits (%d)', scalar @$roles;
+ };
+ article_ class => 'browse staffroles', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
+ td_ class => 'tc_ulist', '' if auth;
td_ class => 'tc1', 'Title';
td_ class => 'tc2', 'Released';
td_ class => 'tc3', 'Role';
td_ class => 'tc4', 'As';
td_ class => 'tc5', 'Note';
}};
+ my %vns;
tr_ sub {
my($v, $a) = ($_, $alias{$_->{aid}});
+ td_ class => 'tc_ulist', sub { ulists_widget_ $v if !$vns{$v->{id}}++ } if auth;
td_ class => 'tc1', sub {
- a_ href => "/$v->{id}", title => $v->{original}||$v->{title}, shorten $v->{title}, 60;
+ a_ href => "/$v->{id}", tattr $v;
+ lit_ ' ' if $v->{name};
+ abbr_ class => "icon-lang-$v->{lang}", title => $LANGUAGE{$v->{lang}}{txt}, '' if $v->{lang};
+ txt_ $v->{name} if $v->{name} && $v->{official};
+ small_ $v->{name} if $v->{name} && !$v->{official};
};
td_ class => 'tc2', sub { rdate_ $v->{c_released} };
td_ class => 'tc3', $CREDIT_TYPE{$v->{role}};
- td_ class => 'tc4', title => $a->{original}||$a->{name}, $a->{name};
+ td_ class => 'tc4', tattr $a;
td_ class => 'tc5', $v->{note};
} for @$roles;
};
@@ -111,49 +129,54 @@ sub _cast_ {
my($s) = @_;
my %alias = map +($_->{aid}, $_), $s->{alias}->@*;
- my $cast = tuwf->dbAlli(q{
- SELECT vs.aid, v.id, v.c_released, v.title, v.original, c.id AS cid, c.name AS c_name, c.original AS c_original, vs.note,
+ my $cast = [ grep defined $_->{spoil}, tuwf->dbAlli('
+ SELECT vs.aid, v.id, v.c_released, v.title, c.id AS cid, c.title AS c_title, vs.note,
(SELECT MIN(cv.spoil) FROM chars_vns cv WHERE cv.id = c.id AND cv.vid = v.id) AS spoil
FROM vn_seiyuu vs
- JOIN vn v ON v.id = vs.id
- JOIN chars c ON c.id = vs.cid
- WHERE vs.aid IN}, [ keys %alias ], q{
+ JOIN', vnt, 'v ON v.id = vs.id
+ JOIN', charst, 'c ON c.id = vs.cid
+ WHERE vs.aid IN', [ keys %alias ], '
AND NOT v.hidden
AND NOT c.hidden
- ORDER BY v.c_released ASC, v.title ASC
- });
+ ORDER BY v.c_released ASC, v.sorttitle ASC
+ ')->@* ];
return if !@$cast;
+ enrich_ulists_widget $cast;
my $spoilers = viewget->{spoilers};
my $max_spoil = max(map $_->{spoil}, @$cast);
- div_ class => 'maintabs', sub {
+ nav_ sub {
h1_ sprintf 'Voiced characters (%d)', scalar @$cast;
- ul_ sub {
+ menu_ sub {
li_ mkclass(tabselected => $spoilers == 0), sub { a_ href => '?view='.viewset(spoilers => 0), 'hide spoilers' };
li_ mkclass(tabselected => $spoilers == 1), sub { a_ href => '?view='.viewset(spoilers => 1), 'minor spoilers' };
li_ mkclass(tabselected => $spoilers == 2), sub { a_ href => '?view='.viewset(spoilers => 2), 'spoil me!' } if $max_spoil == 2;
} if $max_spoil;
};
- div_ class => "mainbox browse staffroles", sub {
+ article_ class => "browse staffroles", sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
+ td_ class => 'tc_ulist', '' if auth;
td_ class => 'tc1', sub { txt_ 'Title'; debug_ $cast };
td_ class => 'tc2', 'Released';
td_ class => 'tc3', 'Cast';
td_ class => 'tc4', 'As';
td_ class => 'tc5', 'Note';
}};
+ 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}", title => $v->{original}||$v->{title}, shorten $v->{title}, 60;
+ a_ href => "/$v->{id}", tattr $v;
};
td_ class => 'tc2', sub { rdate_ $v->{c_released} };
td_ class => 'tc3', sub {
- a_ href => "/$v->{cid}", title => $v->{c_original}||$v->{c_name}, $v->{c_name};
+ a_ href => "/$v->{cid}", tattr $v->{c_title};
+ spoil_ $_->{spoil};
};
- td_ class => 'tc4', title => $a->{original}||$a->{name}, $a->{name};
+ td_ class => 'tc4', tattr $a;
td_ class => 'tc5', $v->{note};
} for grep $_->{spoil} <= $spoilers, @$cast;
};
@@ -166,21 +189,21 @@ TUWF::get qr{/$RE{srev}} => sub {
return tuwf->resNotFound if !$s;
enrich_item $s;
- enrich_extlinks s => $s;
- my($main) = grep $_->{aid} == $s->{aid}, $s->{alias}->@*;
+ enrich_extlinks s => 0, $s;
+ my($main) = grep $_->{aid} == $s->{main}, $s->{alias}->@*;
- framework_ title => $main->{name}, index => !tuwf->capture('rev'), dbobj => $s, hiddenmsg => 1,
+ framework_ title => $main->{title}[1], index => !tuwf->capture('rev'), dbobj => $s, hiddenmsg => 1,
og => {
- description => bb_format $s->{desc}, text => 1
+ description => bb_format $s->{description}, text => 1
},
sub {
_rev_ $s if tuwf->capture('rev');
- div_ class => 'mainbox staffpage', sub {
+ article_ class => 'staffpage', sub {
itemmsg_ $s;
- h1_ sub { txt_ $main->{name}; debug_ $s };
- h2_ class => 'alttitle', lang => $s->{lang}, $main->{original} if $main->{original};
+ 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->{desc} };
+ div_ class => 'description', sub { lit_ bb_format $s->{description} };
};
_roles_ $s;
diff --git a/lib/VNWeb/TT/Elm.pm b/lib/VNWeb/TT/Elm.pm
index f109dadd..b30aeff1 100644
--- a/lib/VNWeb/TT/Elm.pm
+++ b/lib/VNWeb/TT/Elm.pm
@@ -2,43 +2,55 @@ package VNWeb::TT::Elm;
use VNWeb::Prelude;
-elm_api Tags => undef, { search => {} }, sub {
+elm_api Tags => undef, { search => { searchquery => 1 } }, sub {
my $q = shift->{search};
- my $qs = sql_like $q;
-
- elm_TagResult tuwf->dbPagei({ results => 15, page => 1 },
- 'SELECT t.id, t.name, t.searchable, t.applicable, t.state
- FROM (',
- sql_join('UNION ALL',
- $q =~ /^$RE{gid}$/ ? sql('SELECT 1, id FROM tags WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(name),', \$qs, '), id FROM tags WHERE name ILIKE', \"%$qs%"),
- sql('SELECT 10+substr_score(lower(alias),', \$qs, '), tag FROM tags_aliases WHERE alias ILIKE', \"%$qs%"),
- ), ') x (prio, id)
- JOIN tags t ON t.id = x.id
- WHERE t.state <> 1
- GROUP BY t.id, t.name, t.searchable, t.applicable, t.state
- ORDER BY MIN(x.prio), t.name
- ')
+
+ elm_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 => {} }, sub {
+elm_api Traits => undef, { search => { searchquery => 1 } }, sub {
my $q = shift->{search};
- my $qs = sql_like $q;
-
- elm_TraitResult tuwf->dbPagei({ results => 15, page => 1 },
- 'SELECT t.id, t.name, t.searchable, t.applicable, t.defaultspoil, t.state, g.id AS group_id, g.name AS group_name
- FROM (SELECT MIN(prio), id FROM (',
- sql_join('UNION ALL',
- $q =~ /^$RE{iid}$/ ? sql('SELECT 1, id FROM traits WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(name),', \$qs, '), id FROM traits WHERE name ILIKE', \"%$qs%"),
- sql('SELECT 10+substr_score(lower(alias),', \$qs, '), id FROM traits WHERE alias ILIKE', \"%$qs%"),
- ), ') x(prio, id) GROUP BY id) x(prio,id)
- JOIN traits t ON t.id = x.id
- LEFT JOIN traits g ON g.id = t.group
- WHERE t.state <> 1
- ORDER BY x.prio, t.name
- ')
+
+ 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
index 5b3b5661..7a8ac10b 100644
--- a/lib/VNWeb/TT/Index.pm
+++ b/lib/VNWeb/TT/Index.pm
@@ -6,7 +6,7 @@ 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 state = 1+1 ORDER BY added DESC LIMIT 10');
+ 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');
@@ -16,8 +16,8 @@ sub recent_ {
li_ sub {
txt_ fmtage $_->{added};
txt_ ' ';
- b_ class => 'grayedout', "$_->{group} / " if $_->{group};
- a_ href => "/$type$_->{id}", $_->{name};
+ small_ "$_->{group} / " if $_->{group};
+ a_ href => "/$_->{id}", $_->{name};
} for @$lst;
};
}
@@ -25,7 +25,7 @@ sub recent_ {
sub popular_ {
my($type) = @_;
- my $lst = tuwf->dbAlli('SELECT id, name, c_items FROM', $type eq 'g' ? 'tags' : 'traits', 'WHERE state = 1+1 AND c_items > 0 AND applicable ORDER BY c_items DESC LIMIT 10');
+ 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';
@@ -33,8 +33,8 @@ sub popular_ {
h1_ 'Popular';
ul_ sub {
li_ sub {
- b_ class => 'grayedout', "$_->{group} / " if $_->{group};
- a_ href => "/$type$_->{id}", $_->{name};
+ small_ "$_->{group} / " if $_->{group};
+ a_ href => "/$_->{id}", $_->{name};
txt_ " ($_->{c_items})";
} for @$lst;
};
@@ -43,7 +43,7 @@ sub popular_ {
sub moderation_ {
my($type) = @_;
- my $lst = tuwf->dbAlli('SELECT id, name, ', sql_totime('added'), 'AS added FROM', $type eq 'g' ? 'tags' : 'traits', 'WHERE state = 0 ORDER BY added DESC LIMIT 10');
+ 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 {
@@ -51,8 +51,8 @@ sub moderation_ {
li_ sub {
txt_ fmtage $_->{added};
txt_ ' ';
- b_ class => 'grayedout', "$_->{group} / " if $_->{group};
- a_ href => "/$type$_->{id}", $_->{name};
+ small_ "$_->{group} / " if $_->{group};
+ a_ href => "/$_->{id}", $_->{name};
} for @$lst;
li_ sub {
br_;
@@ -67,7 +67,7 @@ sub moderation_ {
TUWF::get qr{/(?<type>[gi])}, sub {
my $type = tuwf->capture('type');
framework_ title => $type eq 'g' ? 'Tag index' : 'Trait index', index => 1, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
p_ class => 'mainopts', sub {
a_ href => "/$type/new", 'Create a new '.($type eq 'g' ? 'tag' : 'trait') if can_edit $type => {};
};
@@ -78,9 +78,9 @@ TUWF::get qr{/(?<type>[gi])}, sub {
};
tree_ $type;
div_ class => 'threelayout', sub {
- div_ sub { recent_ $type };
- div_ sub { popular_ $type };
- div_ sub { moderation_ $type };
+ article_ sub { recent_ $type };
+ article_ sub { popular_ $type };
+ article_ sub { moderation_ $type };
};
};
};
diff --git a/lib/VNWeb/TT/Lib.pm b/lib/VNWeb/TT/Lib.pm
index bb8c1375..5ac3e08d 100644
--- a/lib/VNWeb/TT/Lib.pm
+++ b/lib/VNWeb/TT/Lib.pm
@@ -7,7 +7,7 @@ our @EXPORT = qw/ tagscore_ enrich_group tree_ parents_ /;
sub tagscore_ {
my($s, $ign) = @_;
- div_ mkclass(tagscore => 1, negative => $s < 0, ignored => $ign), sub {
+ div_ mkclass(tagscore => 1, negative => $s <= 0, ignored => $ign), sub {
span_ sprintf '%.1f', $s;
div_ style => sprintf('width: %.0fpx', abs $s/3*30), '';
};
@@ -17,32 +17,32 @@ sub tagscore_ {
# Add a 'group' name for traits
sub enrich_group {
my($type, @lst) = @_;
- enrich_merge id => 'SELECT t.id, g.name AS "group" FROM traits t JOIN traits g ON g.id = t."group" WHERE t.id IN', @lst if $type eq 'i';
+ 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' ? 'tag' : 'trait';
+ my $table = $type eq 'g' ? 'tags' : 'traits';
my $top = tuwf->dbAlli(
- "SELECT id, name, c_items FROM ${table}s
- WHERE state = 1+1
- AND", $id ? sql "id IN(SELECT $table FROM ${table}s_parents WHERE parent = ", \$id, ')'
- : "NOT EXISTS(SELECT 1 FROM ${table}s_parents WHERE $table = id)", "
- ORDER BY ", $type eq 'g' || $id ? 'name' : '"order"'
+ "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}s t JOIN ${table}s_parents tp ON tp.$table = t.id WHERE state = 1+1 AND tp.parent IN", $_, 'ORDER BY name'
+ "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 => "/$type$_[0]{id}", $_[0]{name};
- b_ class => 'grayedout', " ($_[0]{c_items})" if $_[0]{c_items};
+ a_ href => "/$_[0]{id}", $_[0]{name};
+ small_ " ($_[0]{c_items})" if $_[0]{c_items};
}
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $id ? ($type eq 'g' ? 'Child tags' : 'Child traits') : $type eq 'g' ? 'Tag tree' : 'Trait tree';
ul_ class => 'tagtree', sub {
li_ sub {
@@ -56,7 +56,7 @@ sub tree_ {
li_ sub {
my $num = @$sub-5;
txt_ '> ';
- a_ href => "/$type$_->{id}", style => 'font-style: italic', sprintf '%d more %s%s', $num, $table, $num == 1 ? '' : 's';
+ 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;
@@ -72,13 +72,13 @@ sub parents_ {
my($type, $t) = @_;
my %t;
- my $name = $type eq 'g' ? 'tag' : 'trait';
- push $t{$_->{child}}->@*, $_ for tuwf->dbAlli('
- WITH RECURSIVE p(id,child,name) AS (
- SELECT ', \$t->{id}, "::int, 0, NULL::text
+ 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 FROM p JOIN ${name}s_parents tp ON tp.${name} = p.id JOIN ${name}s t ON t.id = tp.parent
- ) SELECT * FROM p WHERE child <> 0 ORDER BY name
+ 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 {
@@ -90,7 +90,7 @@ sub parents_ {
a_ href => "/$type", $type eq 'g' ? 'Tags' : 'Traits';
for (@$_) {
txt_ ' > ';
- a_ href => "/$type$_->{id}", $_->{name};
+ a_ href => "/$_->{id}", $_->{name};
}
txt_ ' > ';
txt_ $t->{name};
diff --git a/lib/VNWeb/TT/List.pm b/lib/VNWeb/TT/List.pm
index 8cabc773..537c6d3d 100644
--- a/lib/VNWeb/TT/List.pm
+++ b/lib/VNWeb/TT/List.pm
@@ -10,7 +10,7 @@ sub listing_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 50], 't';
- div_ class => 'mainbox browse taglist', sub {
+ article_ class => 'browse taglist', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Created'; sortable_ 'added', $opt, \&url };
@@ -21,10 +21,10 @@ sub listing_ {
td_ class => 'tc1', fmtage $_->{added};
td_ class => 'tc2', $_->{c_items}||'-';
td_ class => 'tc3', sub {
- b_ class => 'grayedout', "$_->{group} / " if $_->{group};
- a_ href => "/$type$_->{id}", $_->{name};
- join_ ',', sub { b_ class => 'grayedout', ' '.$_ },
- $_->{state} == 0 ? 'awaiting moderation' : $_->{state} == 1 ? 'deleted' : (),
+ small_ "$_->{group} / " if $_->{group};
+ a_ href => "/$_->{id}", $_->{name};
+ join_ ',', sub { small_ ' '.$_ },
+ !$_->{hidden} ? () : $_->{locked} ? 'deleted' : 'awaiting moderation',
!$_->{applicable} ? 'not applicable' : (),
!$_->{searchable} ? 'not searchable' : ();
};
@@ -38,41 +38,38 @@ sub listing_ {
TUWF::get qr{/(?<type>[gi])/list}, sub {
my $type = tuwf->capture('type');
my $opt = tuwf->validate(get =>
- s => { onerror => 'name', enum => ['added', 'name', 'vns', 'items'] },
+ 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 => { onerror => '' },
+ 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 $qs = $opt->{q} && '%'.sql_like($opt->{q}).'%';
my $where = sql_and
- defined $opt->{t} ? sql 't.state =', \$opt->{t} : (),
- defined $opt->{a} ? sql 't.applicable =', \$opt->{a} : (),
- defined $opt->{b} ? sql 't.searchable =', \$opt->{b} : (),
- $type eq 'g' ? (
- $opt->{q} ? sql 't.name ILIKE', \$qs, 'OR t.id IN(SELECT tag FROM tags_aliases WHERE alias ILIKE', \$qs, ')' : ()
- ) : (
- $opt->{q} ? sql 't.name ILIKE', \$qs, 'OR t.alias ILIKE', \$qs : ()
- );
+ !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", $where);
+ 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, t.name, t.state, t.searchable, t.applicable, t.c_items,', sql_totime('t.added'), "as added
- FROM $table t
- WHERE ", $where, '
- ORDER BY', {qw|added id name name items c_items|}->{$opt->{s}}, {qw|a ASC d DESC|}->{$opt->{o}}, ', id'
+ 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 {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ "Browse $table";
form_ action => "/$type/list", method => 'get', sub {
searchbox_ $type => $opt->{q};
diff --git a/lib/VNWeb/TT/TagEdit.pm b/lib/VNWeb/TT/TagEdit.pm
index 4cdd878d..6e72ba19 100644
--- a/lib/VNWeb/TT/TagEdit.pm
+++ b/lib/VNWeb/TT/TagEdit.pm
@@ -5,69 +5,68 @@ use VNWeb::Prelude;
# TODO: Let users edit their own tag while it's still waiting for approval?
my $FORM = {
- id => { required => 0, id => 1 },
- name => { maxlength => 250, regex => qr/^[^,\r\n]+$/ },
- aliases => { type => 'array', values => { maxlength => 250, regex => qr/^[^,\r\n]+$/ } },
- state => { uint => 1, range => [0,2] },
+ 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 => {
- id => { id => 1 },
+ parent => { vndbid => 'g' },
+ main => { anybool => 1 },
name => { _when => 'out' },
} },
wipevotes => { _when => 'in', anybool => 1 },
- merge => { _when => 'in', aoh => { id => { id => 1 } } },
+ merge => { _when => 'in out', aoh => {
+ id => { vndbid => 'g' },
+ name => { _when => 'out' },
+ } },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
- addedby => { _when => 'out' },
- can_mod => { _when => 'out', 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{gid}/edit}, sub {
- my $g = tuwf->dbRowi('
- SELECT g.id, g.name, g.description, g.state, g.cat, g.defaultspoil, g.searchable, g.applicable
- , ', sql_user('u', 'addedby_'), '
- FROM tags g
- LEFT JOIN users u ON g.addedby = u.id
- WHERE g.id =', \tuwf->capture('id')
- );
+TUWF::get qr{/$RE{grev}/edit}, sub {
+ my $g = db_entry tuwf->captures('id','rev');
return tuwf->resNotFound if !$g->{id};
-
- enrich_flatten aliases => id => tag => 'SELECT tag, alias FROM tags_aliases WHERE tag IN', $g;
- enrich parents => id => tag => 'SELECT gp.tag, g.id, g.name FROM tags_parents gp JOIN tags g ON g.id = gp.parent WHERE gp.tag IN', $g;
-
return tuwf->resDenied if !can_edit g => $g;
- $g->{addedby} = xml_string sub { user_ $g, 'addedby_'; };
- $g->{can_mod} = auth->permTagmod;
+ 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}", type => 'g', dbobj => $g, tab => 'edit', sub {
- elm_ TagEdit => $FORM_OUT, $g;
+ framework_ title => "Edit tag: $g->{name}", dbobj => $g, tab => 'edit', sub {
+ div_ widget(TagEdit => $FORM_OUT, $g), '';
};
};
TUWF::get qr{/(?:$RE{gid}/add|g/new)}, sub {
my $id = tuwf->capture('id');
- my $g = tuwf->dbRowi('SELECT id, name, cat FROM tags WHERE id =', \$id);
+ 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->{can_mod} = auth->permTagmod;
+ $e->{authmod} = auth->permTagmod;
if($id) {
- $e->{parents} = [$g];
+ $e->{parents} = [{ parent => $g->{id}, main => 1, name => $g->{name} }];
$e->{cat} = $g->{cat};
}
framework_ title => 'Submit a new tag', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Requesting new tag';
div_ class => 'notice', sub {
h2_ 'Your tag must be approved';
@@ -80,78 +79,76 @@ TUWF::get qr{/(?:$RE{gid}/add|g/new)}, sub {
}
}
} if !auth->permTagmod;
- elm_ TagEdit => $FORM_OUT, $e;
+ div_ widget(TagEdit => $FORM_OUT, $e), '';
};
};
-elm_api TagEdit => $FORM_OUT, $FORM_IN, sub {
+js_api TagEdit => $FORM_IN, sub {
my($data) = @_;
- my $id = delete $data->{id};
- my $g = !$id ? {} : tuwf->dbRowi('SELECT id, addedby, state FROM tags WHERE id =', \$id);
- return tuwf->resNotFound if $id && !$g->{id};
- return elm_Unauth if !can_edit g => $g;
+ my $new = !$data->{id};
+ my $e = $new ? {} : db_entry $data->{id} or return tuwf->resNotFound;
+ return tuwf->resNotFound if !$new && !$e->{id};
+ return tuwf->resDenied if !can_edit g => $e;
- $data->{addedby} = $id ? $g->{addedby} : auth->uid;
if(!auth->permTagmod) {
- $data->{state} = 0;
- $data->{applicable} = $data->{searchable} = 1;
+ $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 tag, alias FROM tags_aliases) n(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(
- $id ? sql 'id <>', \$id : (),
- sql 'lower(name) IN', [ map lc($_), $data->{name}, $data->{aliases}->@* ]
+ $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;
+ return +{ dups => $dups } if @$dups;
# Make sure parent IDs exists and are not a child tag of the current tag (i.e. don't allow cycles)
validate_dbid sub {
'SELECT id FROM tags WHERE', sql_and
- $id ? sql 'id NOT IN(WITH RECURSIVE t(id) AS (SELECT', \$id, '::int UNION SELECT tag FROM tags_parents tp JOIN t ON t.id = tp.parent) SELECT id FROM t)' : (),
+ $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 $_->{id}, $data->{parents}->@*;
+ }, 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 %set = map +($_,$data->{$_}), qw/name description state addedby cat defaultspoil searchable applicable/;
- $set{added} = sql 'NOW()' if $id && $data->{state} == 2 && $g->{state} != 2;
- tuwf->dbExeci('UPDATE tags SET', \%set, 'WHERE id =', \$id) if $id;
- $id = tuwf->dbVali('INSERT INTO tags', \%set, 'RETURNING id') if !$id;
-
- tuwf->dbExeci('DELETE FROM tags_aliases WHERE tag =', \$id);
- tuwf->dbExeci('INSERT INTO tags_aliases (tag,alias) VALUES(', \$id, ',', \$_, ')') for $data->{aliases}->@*;
-
- tuwf->dbExeci('DELETE FROM tags_parents WHERE tag =', \$id);
- tuwf->dbExeci('INSERT INTO tags_parents (tag,parent) VALUES(', \$id, ',', \$_->{id}, ')') for $data->{parents}->@*;
-
- auth->audit(undef, 'tag edit', "g$id") if $id; # Since we don't have edit histories for tags yet.
-
- if(auth->permTagmod && $data->{wipevotes}) {
- my $num = tuwf->dbExeci('DELETE FROM tags_vn WHERE tag =', \$id);
- auth->audit(undef, 'tag wipe', "Wiped $num votes on g$id");
+ 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(auth->permTagmod && $data->{merge}->@*) {
+ 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 ', \$id, ',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 ',', map "g$_", @merge;
- auth->audit(undef, 'tag merge', "Moved $mov/$del votes from $lst to g$id");
+ my $lst = join ',', @merge;
+ auth->audit(undef, 'tag merge', "Moved $mov/$del votes from $lst to $e->{id}");
+ $changed++;
}
- elm_Redirect "/g$id";
+ if($new || form_changed $FORM_CMP, $data, $e) {
+ my $ch = db_edit g => $e->{id}, $data;
+ return +{ _redir => "/$ch->{nitemid}.$ch->{nrev}" };
+ } elsif($changed) {
+ return +{ _redir => "/$e->{id}" };
+ } else {
+ return +{ _err => 'No changes' };
+ }
};
1;
diff --git a/lib/VNWeb/TT/TagLinks.pm b/lib/VNWeb/TT/TagLinks.pm
index 4f2f8846..7b178d58 100644
--- a/lib/VNWeb/TT/TagLinks.pm
+++ b/lib/VNWeb/TT/TagLinks.pm
@@ -8,7 +8,7 @@ sub listing_ {
my($opt, $lst, $np, $url) = @_;
paginate_ $url, $opt->{p}, $np, 't';
- div_ class => 'mainbox browse taglinks', sub {
+ article_ class => 'browse taglinks', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'date', $opt, $url; debug_ $lst; };
@@ -16,31 +16,37 @@ sub listing_ {
td_ class => 'tc3', 'Rating';
td_ class => 'tc4', sub { txt_ 'Tag'; sortable_ 'tag', $opt, $url };
td_ class => 'tc5', 'Spoiler';
- td_ class => 'tc6', 'Visual novel';
- td_ class => 'tc7', 'Note';
+ 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};
+ a_ href => $url->(u => $i->{uid}, p=>undef), class => 'setfil', '> ' if $i->{uid} && !defined $opt->{u} && (defined $i->{user_name} || auth->isMod);
user_ $i;
};
td_ class => 'tc3', sub { tagscore_ $i->{vote}, $i->{ignore} };
td_ class => 'tc4', sub {
a_ href => $url->(t => $i->{tag}, p=>undef), class => 'setfil', '> ' if !defined $opt->{t};
- a_ href => "/g$i->{tag}", $i->{name};
+ a_ href => "/$i->{tag}", $i->{name};
};
td_ class => 'tc5', sub {
my $s = !defined $i->{spoiler} ? '' : fmtspoil $i->{spoiler};
- b_ class => 'grayedout', $s if $i->{ignore};
+ small_ $s if $i->{ignore};
txt_ $s if !$i->{ignore};
};
td_ class => 'tc6', sub {
+ my $s = !defined $i->{lie} ? '' : $i->{lie} ? '+' : '-';
+ small_ $s if $i->{ignore};
+ txt_ $s if !$i->{ignore};
+ };
+ td_ class => 'tc7', sub {
a_ href => $url->(v => $i->{vid}, p=>undef), class => 'setfil', '> ' if !defined $opt->{v};
- a_ href => "/$i->{vid}", shorten $i->{title}, 50;
+ a_ href => "/$i->{vid}", tattr $i;
};
- td_ class => 'tc7', sub { lit_ bb_format $i->{notes}, inline => 1 };
+ td_ class => 'tc8', sub { lit_ bb_format $i->{notes}, inline => 1 };
} for @$lst;
};
};
@@ -55,9 +61,12 @@ TUWF::get qr{/g/links}, sub {
s => { onerror => 'date', enum => [qw|date tag|] },
v => { onerror => undef, vndbid => 'v' },
u => { onerror => undef, vndbid => 'u' },
- t => { onerror => undef, id => 1 },
+ 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}) : (),
@@ -67,9 +76,10 @@ TUWF::get qr{/g/links}, sub {
my $count = $filt && tuwf->dbVali('SELECT COUNT(*) FROM tags_vn tv WHERE', $where);
my($lst, $np) = tuwf->dbPagei({ page => $opt->{p}, results => 50 }, '
- SELECT tv.vid, tv.uid, tv.tag, tv.vote, tv.spoiler,', sql_totime('tv.date'), 'as date, tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) AS ignore, tv.notes, v.title,', sql_user(), ', t.name
+ SELECT tv.vid, tv.uid, tv.tag, tv.vote, tv.spoiler, tv.lie,', sql_totime('tv.date'), 'as date
+ , tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) AS ignore, tv.notes, v.title, ', sql_user(), ', t.name
FROM tags_vn tv
- JOIN vn v ON v.id = tv.vid
+ JOIN', 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, '
@@ -80,7 +90,7 @@ TUWF::get qr{/g/links}, sub {
my sub url { '?'.query_encode %$opt, @_ }
framework_ title => 'Tag link browser', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Tag link browser';
if($filt) {
p_ 'Active filters:';
@@ -88,17 +98,18 @@ TUWF::get qr{/g/links}, sub {
li_ sub {
txt_ '['; a_ href => url(u=>undef, p=>undef), 'remove'; txt_ '] ';
txt_ 'User: ';
- user_ tuwf->dbRowi('SELECT', sql_user(), 'FROM users u WHERE id=', \$opt->{u});
+ user_ $u;
} if defined $opt->{u};
li_ sub {
txt_ '['; a_ href => url(t=>undef, p=>undef), 'remove'; txt_ '] ';
txt_ 'Tag:'; txt_ ' ';
- a_ href => "/g$opt->{t}", tuwf->dbVali('SELECT name FROM tags WHERE id=', \$opt->{t})||'Unknown tag';
+ a_ href => "/$opt->{t}", tuwf->dbVali('SELECT name FROM tags WHERE id=', \$opt->{t})||'Unknown tag';
} if defined $opt->{t};
li_ sub {
txt_ '['; a_ href => url(v=>undef, p=>undef), 'remove'; txt_ '] ';
txt_ 'Visual novel'; txt_ ' ';
- a_ href => "/$opt->{v}", tuwf->dbVali('SELECT title FROM vn WHERE id=', \$opt->{v})||'Unknown VN';
+ my $v = tuwf->dbRowi('SELECT title FROM', vnt, 'v WHERE id=', \$opt->{v});
+ a_ href => "/$opt->{v}", $v->{title} ? tattr $v : ('Unknown VN');
} if defined $opt->{v};
}
}
diff --git a/lib/VNWeb/TT/TagPage.pm b/lib/VNWeb/TT/TagPage.pm
index b0d97e69..c23a7cbe 100644
--- a/lib/VNWeb/TT/TagPage.pm
+++ b/lib/VNWeb/TT/TagPage.pm
@@ -7,31 +7,34 @@ 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 => "/g$t->{id}/add", 'Create child tag';
- } if $t->{state} != 1 && can_edit g => {};
+ a_ href => "/$t->{id}/add", 'Create child tag';
+ } if !$t->{hidden} && can_edit g => {};
h1_ "Tag: $t->{name}";
debug_ $t;
- div_ class => 'warning', sub {
- h2_ 'Tag deleted';
- p_ sub {
- 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.';
- }
- } if $t->{state} == 1;
-
- div_ class => 'notice', sub {
- 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.';
- } if $t->{state} == 0;
-
parents_ g => $t;
div_ class => 'description', sub {
@@ -43,44 +46,48 @@ sub infobox_ {
$t->{applicable} ? () : 'Can not be directly applied to visual novels.'
);
p_ class => 'center', sub {
- b_ 'Properties';
+ strong_ 'Properties';
br_;
join_ \&br_, sub { txt_ $_ }, @prop;
} if @prop;
p_ class => 'center', sub {
- b_ 'Category';
+ strong_ 'Category';
br_;
txt_ $TAG_CATEGORY{$t->{cat}};
};
p_ class => 'center', sub {
- b_ 'Aliases';
+ strong_ 'Aliases';
br_;
- join_ \&br_, sub { txt_ $_ }, $t->{aliases}->@*;
- } if $t->{aliases}->@*;
+ 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 => { onerror => 'tagscore', enum => [qw/tagscore title rel pop rating/] },
- o => { onerror => 'd', enum => ['a','d'] },
+ s => { tableopts => $TABLEOPTS },
m => { onerror => [auth->pref('spoilers')||0], type => 'array', scalar => 1, minlength => 1, values => { enum => [0..2] } },
- fil => { required => 0 },
+ 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 $_ != $t->{id}, $f->{tag_inc}->@* ] if $f->{tag_inc};
+ $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;
@@ -90,63 +97,64 @@ sub vns_ {
$opt->{f} = advsearch_default 'v' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
- my $where = sql 'tvi.tag =', \$t->{id}, 'AND NOT v.hidden AND tvi.spoiler <=', \$opt->{m}, 'AND', $opt->{f}->sql_where();
+ 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 => 50, page => $opt->{p}}, '
- SELECT tvi.rating AS tagscore, v.id, v.title, v.original, v.c_released, v.c_popularity, v.c_votecount, v.c_rating
- , v.c_platforms::text[] AS platforms, v.c_languages::text[] AS lang
- FROM vn v
+ $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', sprintf {
- tagscore => 'tvi.rating %s, v.title',
- title => 'v.title %s',
- rel => 'v.c_released %s, v.title',
- pop => 'v.c_popularity %s NULLS LAST, v.title',
- rating => 'v.c_rating %s NULLS LAST, v.title'
- }->{$opt->{s}}, $opt->{o} eq 'a' ? 'ASC' : 'DESC'
+ ORDER BY', $opt->{s}->sql_order(),
) : [];
} || (($count, $list) = (undef, []));
- VNWeb::VN::List::enrich_userlist $list;
+ VNWeb::VN::List::enrich_listing 1, $opt, $list;
$time = time - $time;
- div_ class => 'mainbox', sub {
- p_ class => 'mainopts', sub {
- a_ href => "/g/links?t=$t->{id}", 'Recently tagged';
- };
- h1_ 'Visual novels';
- form_ action => "/g$t->{id}", method => 'get', sub {
+ 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!';
};
- input_ type => 'hidden', name => 'o', value => $opt->{o};
- input_ type => 'hidden', name => 's', value => $opt->{s};
+ 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_;
- advsearch_msg_ $count, $time;
+ input_ type => 'hidden', name => 'l', value => $opt->{l};
+ $opt->{f}->elm_($count, $time);
};
+ VNWeb::VN::List::listing_ $opt, $list, $count, 1 if $count;
};
- VNWeb::VN::List::listing_ $opt, $list, $count, 1 if $count;
}
-TUWF::get qr{/$RE{gid}}, sub {
- my $t = tuwf->dbRowi('SELECT id, name, description, state, c_items, cat, searchable, applicable FROM tags WHERE id =', \tuwf->capture('id'));
+TUWF::get qr{/$RE{grev}}, sub {
+ my $t = db_entry tuwf->captures('id', 'rev');
return tuwf->resNotFound if !$t->{id};
- enrich_flatten aliases => id => tag => sub { 'SELECT tag, alias FROM tags_aliases WHERE tag IN', $_, 'ORDER BY alias' }, $t;
-
- framework_ index => $t->{state} == 2, title => "Tag: $t->{name}", type => 'g', dbobj => $t, sub {
- div_ class => 'mainbox', sub { infobox_ $t; };
+ 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->{state} == 2;
+ vns_ $t if $t->{searchable} && !$t->{hidden};
};
};
diff --git a/lib/VNWeb/TT/TraitEdit.pm b/lib/VNWeb/TT/TraitEdit.pm
index 08870122..f92efd58 100644
--- a/lib/VNWeb/TT/TraitEdit.pm
+++ b/lib/VNWeb/TT/TraitEdit.pm
@@ -3,50 +3,46 @@ package VNWeb::TT::TraitEdit;
use VNWeb::Prelude;
my $FORM = {
- id => { required => 0, id => 1 },
- name => { maxlength => 250, regex => qr/^[^,\r\n]+$/ },
- alias => { maxlength => 1024, regex => qr/^[^,]+$/, required => 0, default => '' },
- state => { uint => 1, range => [0,2] },
+ 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 => {
- id => { id => 1 },
+ parent => { vndbid => 'i' },
+ main => { anybool => 1 },
name => { _when => 'out' },
- group => { _when => 'out', required => 0 },
+ group => { _when => 'out', default => undef },
} },
- order => { uint => 1 },
+ gorder => { uint => 1 },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
- addedby => { _when => 'out' },
- can_mod => { _when => 'out', 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{iid}/edit}, sub {
- my $e = tuwf->dbRowi('
- SELECT i.id, i.name, i.alias, i.description, i.state, i.sexual, i.defaultspoil, i.searchable, i.applicable, i.order
- , ', sql_user('u', 'addedby_'), '
- FROM traits i
- LEFT JOIN users u ON i.addedby = u.id
- WHERE i.id =', \tuwf->capture('id')
- );
+TUWF::get qr{/$RE{irev}/edit}, sub {
+ my $e = db_entry tuwf->captures('id','rev');
return tuwf->resNotFound if !$e->{id};
-
- enrich parents => id => trait => '
- SELECT ip.trait, i.id, i.name, g.name AS group
- FROM traits_parents ip JOIN traits i ON i.id = ip.parent LEFT JOIN traits g ON g.id = i.group WHERE ip.trait IN', $e;
-
return tuwf->resDenied if !can_edit i => $e;
- $e->{addedby} = xml_string sub { user_ $e, 'addedby_'; };
- $e->{can_mod} = auth->permTagmod;
+ 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};
- framework_ title => "Edit $e->{name}", type => 'i', dbobj => $e, tab => 'edit', sub {
+ $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;
};
};
@@ -54,19 +50,20 @@ TUWF::get qr{/$RE{iid}/edit}, sub {
TUWF::get qr{/(?:$RE{iid}/add|i/new)}, sub {
my $id = tuwf->capture('id');
- my $i = tuwf->dbRowi('SELECT i.id, i.name, g.name AS "group", i.sexual FROM traits i LEFT JOIN traits g ON g.id = i."group" WHERE i.id =', \$id);
+ 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->{id};
+ return tuwf->resNotFound if $id && !$i->{parent};
my $e = elm_empty($FORM_OUT);
- $e->{can_mod} = auth->permTagmod;
+ $e->{authmod} = auth->permTagmod;
if($id) {
+ $i->{main} = 1;
$e->{parents} = [$i];
$e->{sexual} = $i->{sexual};
}
framework_ title => 'Submit a new trait', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Requesting new trait';
div_ class => 'notice', sub {
h2_ 'Your trait must be approved';
@@ -85,29 +82,29 @@ TUWF::get qr{/(?:$RE{iid}/add|i/new)}, sub {
elm_api TraitEdit => $FORM_OUT, $FORM_IN, sub {
my($data) = @_;
- my $id = delete $data->{id};
- my $e = !$id ? {} : tuwf->dbRowi('SELECT id, addedby, state FROM traits WHERE id =', \$id);
- return tuwf->resNotFound if $id && !$e->{id};
+ 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;
-
- $data->{addedby} = $id ? $e->{addedby} : auth->uid;
if(!auth->permTagmod) {
- $data->{state} = 0;
- $data->{applicable} = $data->{searchable} = 1;
+ $data->{hidden} = $e->{hidden}//1;
+ $data->{locked} = $e->{locked}//0;
}
- $data->{order} = 0 if $data->{parents}->@*;
+ $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 $_->{id}, $data->{parents}->@*;
+ my @parents = map $_->{parent}, $data->{parents}->@*;
validate_dbid sub {
'SELECT id FROM traits WHERE', sql_and
- $id ? sql 'id NOT IN(WITH RECURSIVE t(id) AS (SELECT', \$id, '::int UNION SELECT trait FROM traits_parents tp JOIN t ON t.id = tp.parent) SELECT id FROM t)' : (),
+ $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}->@*;
- # It's technically possible for a trait to be in multiple groups, but the DB schema doesn't support that so let's get the group from the first parent (sorted by id).
- $data->{group} = tuwf->dbVali('SELECT coalesce("group",id) FROM traits WHERE id IN', \@parents, 'ORDER BY id LIMIT 1');
+ 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]*';
@@ -116,27 +113,22 @@ elm_api TraitEdit => $FORM_OUT, $FORM_IN, sub {
FROM (SELECT id, name FROM traits UNION ALL SELECT id, s FROM traits, regexp_split_to_table(alias, ', \$re, ') a(s) WHERE s <> \'\') n(id,name)
JOIN traits t ON n.id = t.id
WHERE ', sql_and(
- $id ? sql 'n.id <>', \$id : (),
- sql('t."group" IS NOT DISTINCT FROM', \$data->{group}),
+ $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;
- $data->{description} = bb_subst_links($data->{description});
-
- my %set = map +($_,$data->{$_}), qw/name alias description state addedby sexual defaultspoil searchable applicable/;
- $set{'"group"'} = $data->{group};
- $set{'"order"'} = $data->{order};
- $set{added} = sql 'NOW()' if $id && $data->{state} == 2 && $e->{state} != 2;
- tuwf->dbExeci('UPDATE traits SET', \%set, 'WHERE id =', \$id) if $id;
- $id = tuwf->dbVali('INSERT INTO traits', \%set, 'RETURNING id') if !$id;
-
- tuwf->dbExeci('DELETE FROM traits_parents WHERE trait =', \$id);
- tuwf->dbExeci('INSERT INTO traits_parents (trait,parent) VALUES(', \$id, ',', \$_->{id}, ')') for $data->{parents}->@*;
-
- auth->audit(undef, 'trait edit', "i$id") if $id; # Since we don't have edit histories for traits yet.
- elm_Redirect "/i$id";
+ 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
index 8679ed91..c120d645 100644
--- a/lib/VNWeb/TT/TraitPage.pm
+++ b/lib/VNWeb/TT/TraitPage.pm
@@ -7,31 +7,35 @@ 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 => "/i$t->{id}/add", 'Create child trait';
- } if $t->{state} != 1 && can_edit i => {};
+ a_ href => "/$t->{id}/add", 'Create child trait';
+ } if !$t->{hidden} && can_edit i => {};
h1_ "Trait: $t->{name}";
debug_ $t;
- div_ class => 'warning', sub {
- h2_ 'Trait deleted';
- p_ sub {
- txt_ 'This trait 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.';
- }
- } if $t->{state} == 1;
-
- div_ class => 'notice', sub {
- h2_ 'Waiting for approval';
- p_ 'This trait is waiting for a moderator to approve it.';
- } if $t->{state} == 0;
-
parents_ i => $t;
div_ class => 'description', sub {
@@ -44,13 +48,13 @@ sub infobox_ {
$t->{applicable} ? () : 'Can not be directly applied to characters.',
);
p_ class => 'center', sub {
- b_ 'Properties';
+ strong_ 'Properties';
br_;
join_ \&br_, sub { txt_ $_ }, @prop;
} if @prop;
p_ class => 'center', sub {
- b_ 'Aliases';
+ strong_ 'Aliases';
br_;
join_ \&br_, sub { txt_ $_ }, split /\n/, $t->{alias};
} if $t->{alias};
@@ -64,17 +68,19 @@ sub chars_ {
p => { upage => 1 },
f => { advsearch_err => 'c' },
m => { onerror => [auth->pref('spoilers')||0], type => 'array', scalar => 1, minlength => 1, values => { enum => [0..2] } },
- fil => { required => 0 },
+ 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 $_ != $t->{id}, $f->{trait_inc}->@* ] if $f->{trait_inc};
+ $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;
@@ -84,18 +90,23 @@ sub chars_ {
$opt->{f} = advsearch_default 'c' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
- my $where = sql 'tc.tid =', \$t->{id}, 'AND NOT c.hidden AND tc.spoil <=', \$opt->{m}, 'AND', $opt->{f}->sql_where();
+ 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.name, c.original, c.gender, c.image
- FROM chars c
+ 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.name, c.id'
+ ORDER BY c.sorttitle, c.id'
) : [];
} || (($count, $list) = (undef, []));
@@ -103,31 +114,35 @@ sub chars_ {
enrich_image_obj image => $list if !$opt->{s}->rows;
$time = time - $time;
- form_ action => "/i$t->{id}", method => 'get', sub {
- div_ class => 'mainbox', sub {
+ 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_;
- advsearch_msg_ $count, $time;
+ $opt->{f}->elm_($count, $time);
};
VNWeb::Chars::List::listing_ $opt, $list, $count, 1 if $count;
};
}
-TUWF::get qr{/$RE{iid}}, sub {
- my $t = tuwf->dbRowi('SELECT id, name, alias, description, state, c_items, sexual, searchable, applicable FROM traits WHERE id =', \tuwf->capture('id'));
+TUWF::get qr{/$RE{irev}}, sub {
+ my $t = db_entry tuwf->captures('id', 'rev');
return tuwf->resNotFound if !$t->{id};
- framework_ index => $t->{state} == 2, title => "Trait: $t->{name}", type => 'i', dbobj => $t, sub {
- div_ class => 'mainbox', sub { infobox_ $t; };
+ 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->{state} == 2;
+ chars_ $t if $t->{searchable} && !$t->{hidden};
};
};
diff --git a/lib/VNWeb/TableOpts.pm b/lib/VNWeb/TableOpts.pm
index 6d6384fb..42885fa1 100644
--- a/lib/VNWeb/TableOpts.pm
+++ b/lib/VNWeb/TableOpts.pm
@@ -31,6 +31,7 @@ package VNWeb::TableOpts;
# # 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 => {
@@ -43,11 +44,11 @@ package VNWeb::TableOpts;
#
# my $opts = tuwf->validate(get => s => { tableopts => $config })->data;
#
-# my $sql = sql('.... ORDER BY', $opts->sql_order); (TODO)
+# 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? (TODO)
+# $opts->vis('popularity'); # is the column visible?
#
#
#
@@ -66,11 +67,11 @@ package VNWeb::TableOpts;
use v5.26;
use Carp 'croak';
use Exporter 'import';
-use TUWF;
+use TUWF ':html5_';
use VNWeb::Auth;
use VNWeb::HTML ();
use VNWeb::Validation;
-use VNWeb::Elm;
+use VNWeb::JS;
our @EXPORT = ('tableopts');
@@ -89,13 +90,13 @@ my %results = map +($results[$_], $_), 0..$#results;
# Turn config options into something more efficient to work with
sub tableopts {
my %o = (
- sort_ids => [], # identifier => column name
- vis_ids => [], # identifier => column name
- col_order => [], # column names in the order listed in the config
+ 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') {
@@ -107,37 +108,65 @@ sub tableopts {
next;
}
$o{columns}{$k} = $v;
- push $o{col_order}->@*, $k;
- $o{sort_ids}[$v->{sort_id}] = $k if defined $v->{sort_id};
- $o{vis_ids}[$v->{vis_id}] = $k if defined $v->{vis_id};
+ $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 ? tuwf->dbVali('SELECT', $t->{pref}, 'FROM users WHERE id =', \auth->uid) : undef;
- bless([$d // $t->{default},$t], __PACKAGE__)
+ my $d = $t->{pref} && auth->pref($t->{pref});
+ my $o = bless([$d // $t->{default},$t], __PACKAGE__);
+ $o->fixup;
}, func => sub {
- # TODO: compatibility with the old ?s=<colname> sort parameter
- my $v = _dec($_[0]) // return 0;
+ 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.
- $_[0] = bless [$v, $t], __PACKAGE__;
1;
} }
};
-sub query_encode {
- my($v,$o) = $_[0]->@*;
- $v == $o->{default} ? undef : _enc $v;
+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' }
@@ -145,36 +174,124 @@ 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 => { required => 0 },
+ save => { default => undef },
views => { type => 'array', values => { uint => 1 } },
- default => { uint => 1 },
value => { uint => 1 },
- # TODO: Sorting & column visibility
+ default => { uint => 1 },
+ usaved => { uint => 1, default => undef },
+ sorts => { aoh => { id => { uint => 1 }, name => {}, num => { anybool => 1 } } },
+ vis => { aoh => { id => { uint => 1 }, name => {} } },
};
-elm_api TableOptsSave => $FORM_OUT, {
- save => { enum => ['tableopts_c'] },
- value => { required => 0, uint => 1 }
+js_api TableOptsSave => {
+ save => { enum => ['tableopts_c', 'tableopts_v', 'tableopts_vt'] },
+ value => { default => undef, uint => 1 }
}, sub {
my($f) = @_;
- return elm_Unauth if !auth;
- tuwf->dbExeci('UPDATE users SET', { $f->{save} => $f->{value} }, 'WHERE id =', \auth->uid);
- elm_Success
+ return tuwf->resDenied if !auth;
+ tuwf->dbExeci('UPDATE users_prefs SET', { $f->{save} => $f->{value} }, 'WHERE id =', \auth->uid);
+ {}
};
-sub elm_ {
- my $self = shift;
+
+sub widget_ {
+ my($self,$url) = @_;
my($v,$o) = $self->@*;
- VNWeb::HTML::elm_ TableOpts => $FORM_OUT, {
+ menu_ class => 'tableopts', VNWeb::HTML::widget(TableOpts => $FORM_OUT, {
save => auth ? $o->{pref} : undef,
views => $o->{views},
- default => $o->{default},
value => $v,
- }, sub {
- TUWF::XML::input_ type => 'hidden', name => 's', value => $self->query_encode if defined $self->query_encode
+ 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
index e1a61737..bcc22de1 100644
--- a/lib/VNWeb/ULists/Elm.pm
+++ b/lib/VNWeb/ULists/Elm.pm
@@ -4,21 +4,33 @@ use VNWeb::Prelude;
use VNWeb::ULists::Lib;
-# Should be called after any change to the ulist_* tables.
+# 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 {
- tuwf->dbExeci(SELECT => sql_func update_users_ulist_stats => \shift);
+ 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 => { aoh => {
+ labels => { maxlength => 1500, aoh => {
id => { int => 1 },
- label => { maxlength => 50 },
+ label => { sl => 1, maxlength => 50 },
private => { anybool => 1 },
count => { uint => 1 },
- delete => { required => 0, default => undef, uint => 1, range => [1, 3] }, # 1=keep vns, 2=delete when no other label, 3=delete all
+ delete => { default => undef, uint => 1, range => [1, 3] }, # 1=keep vns, 2=delete when no other label, 3=delete all
} }
};
@@ -28,18 +40,11 @@ elm_api UListManageLabels => undef, $LABELS, sub {
# Insert new labels
my @new = grep $_->{id} < 0 && !$_->{delete}, @$labels;
- # Subquery to get the lowest unused id
- my $newid = sql '(
- SELECT min(x.n)
- FROM generate_series(10,
- greatest((SELECT max(id)+1 from ulist_labels ul WHERE ul.uid =', \$uid, '), 10)
- ) x(n)
- WHERE NOT EXISTS(SELECT 1 FROM ulist_labels ul WHERE ul.uid =', \$uid, 'AND ul.id = x.n)
- )';
- tuwf->dbExeci('INSERT INTO ulist_labels', { id => $newid, uid => $uid, label => $_->{label}, private => $_->{private} }) for @new;
+ tuwf->dbExeci('INSERT INTO ulist_labels', { id => sql_labelid($uid), uid => $uid, label => $_->{label}, private => $_->{private} }) for @new;
# Update private flag
- tuwf->dbExeci(
+ 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;
@@ -58,22 +63,55 @@ elm_api UListManageLabels => undef, $LABELS, sub {
# delete vns with: (a label in option 3) OR ((a label in option 2) AND (no labels other than in option 1 or 2))
my @where = (
- @delete_all ? sql('vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN', \@delete_all, ')') : (),
+ @delete_all ? sql('labels &&', sql_array(@delete_all), '::smallint[]') : (),
@delete_empty ? sql(
- 'vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN', \@delete_empty, ')',
- 'AND NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl WHERE uvl.vid = uv.vid AND uid =', \$uid, 'AND lbl NOT IN', [ @delete_lblonly, @delete_empty ], ')'
+ '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;
- # (This will also delete all relevant vn<->label rows from ulist_vns_labels)
+ $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;
+ 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 => {
@@ -93,7 +131,7 @@ elm_api UListVoteEdit => undef, $VNVOTE, sub {
vote_date => sql $data->{vote} ? 'CASE WHEN ulist_vns.vote IS NULL THEN NOW() ELSE ulist_vns.vote_date END' : 'NULL'
}
);
- updcache $data->{uid};
+ updcache $data->{uid}, $data->{vid};
elm_Success
};
@@ -116,19 +154,18 @@ 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}}, 'ON CONFLICT (uid, vid) DO NOTHING');
- tuwf->dbExeci(
- 'DELETE FROM ulist_vns_labels
- WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid}, 'AND lbl =', \$data->{label}
- ) if !$data->{applied};
tuwf->dbExeci(
- 'INSERT INTO ulist_vns_labels', { uid => $data->{uid}, vid => $data->{vid}, lbl => $data->{label} },
- 'ON CONFLICT (uid, vid, lbl) DO NOTHING'
- ) if $data->{applied};
- tuwf->dbExeci('UPDATE ulist_vns SET lastmod = NOW() WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid});
-
- updcache $data->{uid};
+ '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
};
@@ -138,7 +175,7 @@ elm_api UListLabelEdit => $VNLABELS_OUT, $VNLABELS_IN, sub {
our $VNDATE = form_compile any => {
uid => { vndbid => 'u' },
vid => { vndbid => 'v' },
- date => { required => 0, default => '', regex => qr/^(?:19[7-9][0-9]|20[0-9][0-9])-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/ }, # 1970 - 2099 for sanity
+ date => { default => '', caldate => 1 },
start => { anybool => 1 }, # Field selection, started/finished
};
@@ -149,7 +186,7 @@ elm_api UListDateEdit => undef, $VNDATE, sub {
'UPDATE ulist_vns SET lastmod = NOW(), ', $data->{start} ? 'started' : 'finished', '=', \($data->{date}||undef),
'WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid}
);
- updcache $data->{uid};
+ # Doesn't need `updcache()`
elm_Success
};
@@ -166,25 +203,11 @@ our $VNOPT = form_compile any => {
};
-our $VNPAGE = form_compile any => {
- uid => { vndbid => 'u' },
- vid => { vndbid => 'v' },
- onlist => { anybool => 1 },
- canvote => { anybool => 1 },
- vote => { vnvote => 1 },
- notes => { required => 0, default => '' },
- review => { required => 0, vndbid => 'w' },
- canreview=> { anybool => 1 },
- labels => { aoh => { id => { int => 1 }, label => {}, private => { anybool => 1 } } },
- selected => { type => 'array', values => { id => 1 } },
-};
-
-
-# UListVNNotes module is abused for the UList.Opts and UList.VNPage flag definition
+# UListVNNotes module is abused for the UList.Opts flag definition
elm_api UListVNNotes => $VNOPT, {
uid => { vndbid => 'u' },
vid => { vndbid => 'v' },
- notes => { required => 0, default => '', maxlength => 2000 },
+ notes => { default => '', maxlength => 2000 },
}, sub {
my($data) = @_;
return elm_Unauth if !ulists_own $data->{uid};
@@ -193,7 +216,7 @@ elm_api UListVNNotes => $VNOPT, {
);
# Doesn't need `updcache()`
elm_Success
-}, VNPage => $VNPAGE;
+};
@@ -217,8 +240,8 @@ elm_api UListDel => undef, {
our $RLIST_STATUS = form_compile any => {
uid => { vndbid => 'u' },
rid => { vndbid => 'r' },
- status => { required => 0, uint => 1, enum => \%RLIST_STATUS }, # undef meaning delete
- empty => { required => 0, default => '' }, # An 'out' field
+ 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) = @_;
@@ -235,16 +258,24 @@ elm_api UListRStatus => undef, $RLIST_STATUS, sub {
+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 = (
- # Labels
- l => { onerror => [], type => 'array', scalar => 1, values => { int => 1 } },
+ l => { onerror => [], type => 'array', scalar => 1, values => { int => 1, range => [-1,1600] } },
mul => { anybool => 1 },
- # Sort column & order
- s => { onerror => 'title', enum => [qw[ title label vote voted added modified started finished rel rating ]] },
- o => { onerror => 'a', enum => ['a', 'd'] },
- # Visible columns
- c => { onerror => [], type => 'array', scalar => 1, values => { enum => [qw[ label vote voted added modified started finished rel rating ]] } },
+ s => { onerror => '' }, # TableOpts query string
+ f => { onerror => '' }, # AdvSearch
);
my $SAVED_OPTS = {
@@ -259,7 +290,7 @@ 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 SET ulist_'.$data->{field}, '=', \JSON::XS->new->encode($data->{opts}), 'WHERE id =', \$data->{uid});
+ tuwf->dbExeci('UPDATE users_prefs SET ulist_'.$data->{field}, '=', \JSON::XS->new->encode($data->{opts}), 'WHERE id =', \$data->{uid});
elm_Success
};
diff --git a/lib/VNWeb/ULists/Export.pm b/lib/VNWeb/ULists/Export.pm
index 655bbd54..c9dc6875 100644
--- a/lib/VNWeb/ULists/Export.pm
+++ b/lib/VNWeb/ULists/Export.pm
@@ -15,33 +15,38 @@ sub data {
# 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, v.original, uv.vote, uv.started, uv.finished, uv.notes
- , ', sql_comma(tz('uv.added', 'added'), tz('uv.lastmod', 'lastmod'), tz('uv.vote_date', 'vote_date')), '
+ 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 vn v ON v.id = uv.vid
+ JOIN vnt v ON v.id = uv.vid
WHERE uv.uid =', \$uid, '
- ORDER BY v.title')
+ 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 labels => id => vid => sub { sql '
- SELECT uvl.vid, ul.id, ul.label, ul.private
- FROM ulist_vns_labels uvl
- JOIN ulist_labels ul ON ul.id = uvl.lbl
- WHERE ul.uid =', \$uid, 'AND uvl.uid =', \$uid, '
- ORDER BY lbl'
- }, $d->{vns};
enrich releases => id => vid => sub { sql '
- SELECT rv.vid, r.id, r.title, r.original, r.released, rl.status, ', tz('rl.added', 'added'), '
+ SELECT rv.vid, r.id, r.title, r.released, rl.status, ', tz('rl.added', 'added'), '
FROM rlists rl
- JOIN releases r ON r.id = rl.rid
+ 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
}
@@ -53,6 +58,12 @@ sub filename {
}
+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;
@@ -62,6 +73,8 @@ TUWF::get qr{/$RE{uid}/list-export/xml}, sub {
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 @_ },
@@ -78,9 +91,9 @@ TUWF::get qr{/$RE{uid}/list-export/xml}, sub {
tag label => id => $_->{id}, label => $_->{label}, private => $_->{private}?'true':'false', undef for $d->{labels}->@*;
};
tag vns => sub {
- tag vn => id => $_->{id}, private => grep(!$_->{private}, $_->{labels}->@*)?'false':'true', sub {
- tag title => length($_->{original}) ? (original => $_->{original}) : (), $_->{title};
- tag label => id => $_->{id}, label => $_->{label}, undef for $_->{labels}->@*;
+ 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};
@@ -88,13 +101,26 @@ TUWF::get qr{/$RE{uid}/list-export/xml}, sub {
tag finished => $_->{finished} if $_->{finished};
tag notes => $_->{notes} if length $_->{notes};
tag release => id => $_->{id}, sub {
- tag title => length($_->{original}) ? (original => $_->{original}) : (), $_->{title};
+ 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'}->@*;
+ };
};
};
diff --git a/lib/VNWeb/ULists/Lib.pm b/lib/VNWeb/ULists/Lib.pm
index 637decfb..0e264b3b 100644
--- a/lib/VNWeb/ULists/Lib.pm
+++ b/lib/VNWeb/ULists/Lib.pm
@@ -1,13 +1,96 @@
package VNWeb::ULists::Lib;
use VNWeb::Prelude;
+use VNWeb::Releases::Lib 'releases_by_vn';
use Exporter 'import';
-our @EXPORT = qw/ulists_own/;
+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 && auth->uid eq shift)
+ 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
index ae443955..04ca3e16 100644
--- a/lib/VNWeb/ULists/List.pm
+++ b/lib/VNWeb/ULists/List.pm
@@ -5,27 +5,53 @@ use VNWeb::ULists::Lib;
use VNWeb::Releases::Lib;
+my $TABLEOPTS = VNWeb::VN::List::TABLEOPTS('ulist');
+
+
sub opt {
- my($u, $filtlabels) = @_;
+ 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') ? { mul => 0, p => 1, l => [1,2,3,4,7,-1,0], s => 'title', o => 'a', c => [qw/label vote added started finished/], load 'vnlist' } :
- tuwf->reqGet('votes') ? { mul => 0, p => 1, l => [7], s => 'voted', o => 'd', c => [qw/vote voted/], load 'votes' } :
- tuwf->reqGet('wishlist') ? { mul => 0, p => 1, l => [5], s => 'title', o => 'a', c => [qw/label added/], load 'wish' } :
+ 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 => undef, enum => [ 'a'..'z', 0 ] },
- q => { onerror => undef },
- %VNWeb::ULists::Elm::SAVED_OPTS
+ 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;
-
- # $labels only includes labels we are allowed to see, getting rid of any labels in 'l' that aren't in $labels ensures we only filter on visible labels
- my %accessible_labels = map +($_->{id}, 1), @$filtlabels;
- my %opt_l = map +($_, 1), grep $accessible_labels{$_}, $opt->{l}->@*;
+ $opt->{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 ];
@@ -34,7 +60,7 @@ sub opt {
sub filters_ {
- my($own, $filtlabels, $opt, $opt_labels, $url) = @_;
+ 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') : ();
@@ -42,43 +68,36 @@ sub filters_ {
txt_ " ($_->{count})";
}
- form_ method => 'get', sub {
- input_ type => 'hidden', name => 's', value => $opt->{s};
- input_ type => 'hidden', name => 'o', value => $opt->{o};
- input_ type => 'hidden', name => 'ch', value => $opt->{ch} if defined $opt->{ch};
- input_ type => 'hidden', name => 'c', value => $_ for $opt->{c}->@*;
- p_ class => 'labelfilters', sub {
- input_ type => 'text', class => 'text', name => 'q', value => $opt->{q}||'', style => 'width: 500px', placeholder => 'Search', tabindex => 10;
- br_;
- # XXX: Rather silly that everything in this form is a form element except for the alphabet filter. Meh, behavior seems intuitive enough.
- span_ class => 'browseopts', sub {
- a_ href => $url->(ch => $_, p => undef), ($_//'') eq ($opt->{ch}//'') ? (class => 'optselected') : (), !defined($_) ? 'ALL' : $_ ? uc $_ : '#'
- for (undef, 'a'..'z', 0);
- };
- br_;
- span_ class => 'linkradio', sub {
- join_ sub { em_ ' / ' }, \&lblfilt_, grep $_->{id} < 10, @$filtlabels;
-
- span_ class => 'hidden', sub {
- em_ ' || ';
- input_ type => 'checkbox', name => 'mul', value => 1, id => 'form_l_multi', tabindex => 10, $opt->{mul} ? (checked => 'checked') : ();
- label_ for => 'form_l_multi', 'Multi-select';
- };
- debug_ $filtlabels;
+ 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';
};
- my @cust = grep $_->{id} >= 10, @$filtlabels;
+ debug_ $labels;
+ my @cust = grep $_->{id} >= 10, @$labels;
if(@cust) {
br_;
- span_ class => 'linkradio', sub {
- join_ sub { em_ ' / ' }, \&lblfilt_, @cust;
- }
+ join_ sub { em_ ' / ' }, \&lblfilt_, @cust;
}
- br_;
- input_ type => 'submit', class => 'submit', tabindex => 10, value => 'Update filters';
- input_ type => 'button', class => 'submit', tabindex => 10, id => 'managelabels', value => 'Manage labels' if $own;
- input_ type => 'button', class => 'submit', tabindex => 10, id => 'savedefault', value => 'Save as default' if $own;
- input_ type => 'button', class => 'submit', tabindex => 10, id => 'exportlist', value => 'Export' if $own;
};
+ 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;
};
}
@@ -89,12 +108,12 @@ sub vn_ {
my %labels = map +($_,1), $v->{labels}->@*;
td_ class => 'tc1', sub {
- input_ type => 'checkbox', class => 'checkhidden', name => 'collapse_vid', id => 'collapse_vid'.$v->{id}, value => 'collapsed_vid'.$v->{id};
+ 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}->@*;
- b_ id => 'ulist_relsum_'.$v->{id},
- mkclass(done => $total && $obtained == $total, todo => $obtained < $total, neutral => 1),
+ 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;
@@ -108,54 +127,71 @@ sub vn_ {
};
};
- td_ class => 'tc_voted', $v->{vote_date} ? fmtdate $v->{vote_date}, 'compact' : '-' if in voted => $opt->{c};
+ 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}) }, fmtvote $v->{vote}
- if $own && ($v->{vote} || sprintf('%08d', $v->{c_released}||0) < strftime '%Y%m%d', gmtime);
- } if in vote => $opt->{c};
+ 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)/10;
- b_ class => 'grayedout', sprintf ' (%d)', $v->{c_votecount};
- } if in rating => $opt->{c};
+ 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}->@* ] }, $txt;
+ elm_ 'UList.LabelEdit' => $VNWeb::ULists::Elm::VNLABELS_OUT, { vid => $v->{id}, selected => [ grep $_ != 7, $v->{labels}->@* ] }, sub {
+ div_ @_, $txt;
+ };
} else {
txt_ $txt;
}
- } if in label => $opt->{c};
+ } if $opt->{s}->vis('label');
td_ class => 'tc_title', sub {
- a_ href => "/$v->{id}", title => $v->{original}||$v->{title}, shorten $v->{title}, 70;
- b_ class => 'grayedout', id => 'ulist_notes_'.$v->{id}, $v->{notes} if $v->{notes} || $own;
+ 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 in added => $opt->{c};
- td_ class => 'tc_modified', fmtdate $v->{lastmod}, 'compact' if in modified => $opt->{c};
+ 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 }, $v->{started}||'' if $own;
- } if in started => $opt->{c};
+ 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 }, $v->{finished}||'' if $own;
- } if in finished => $opt->{c};
+ 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 in rel => $opt->{c};
+ 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, uid => $uid, vid => $v->{id}, notes => $v->{notes}, rels => $v->{rels}, relstatus => $relstatus };
+ elm_ 'UList.Opt' => $VNWeb::ULists::Elm::VNOPT, { own => $own?1:0, uid => $uid, vid => $v->{id}, notes => $v->{notes}, rels => $v->{rels}, relstatus => $relstatus };
};
};
}
@@ -165,138 +201,96 @@ sub listing_ {
my($uid, $own, $opt, $labels, $url) = @_;
my @l = grep $_ > 0 && $_ != 7, $opt->{l}->@*;
- my($unlabeled) = grep $_ == -1, $opt->{l}->@*;
- my($voted) = grep $_ == 7, $opt->{l}->@*;
+ my $unlabeled = grep $_ == 0, $opt->{l}->@*;
+ my $voted = grep $_ == 7, $opt->{l}->@*;
my @where_vns = (
- @l ? sql('uv.vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN', \@l, ')') : (),
- $unlabeled ? sql('NOT EXISTS(SELECT 1 FROM ulist_vns_labels WHERE uid =', \$uid, 'AND vid = uv.vid AND lbl <> ', \7, ')') : (),
+ @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),
- !$own ? sql('uv.vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN(SELECT id FROM ulist_labels WHERE uid =', \$uid, 'AND NOT private))') : (),
+ $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) : (),
- $opt->{q} ? map sql('v.c_search like', \"%$_%"), normalize_query $opt->{q} : (),
- defined($opt->{ch}) && $opt->{ch} ? sql('LOWER(SUBSTR(v.title, 1, 1)) =', \$opt->{ch}) : (),
- defined($opt->{ch}) && !$opt->{ch} ? sql('(ASCII(v.title) <', \97, 'OR ASCII(v.title) >', \122, ') AND (ASCII(v.title) <', \65, 'OR ASCII(v.title) >', \90, ')') : ();
+ defined($opt->{ch}) ? sql 'match_firstchar(v.sorttitle, ', \$opt->{ch}, ')' : ();
- my $count = tuwf->dbVali('SELECT count(*) FROM ulist_vns uv JOIN vn v ON v.id = uv.vid WHERE', $where);
+ 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 => 50 },
- 'SELECT v.id, v.title, v.original, uv.vote, uv.notes, uv.started, uv.finished, v.c_rating, v.c_votecount, v.c_released
+ 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
+ ,', 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 vn v ON v.id = uv.vid
+ JOIN', vnt, 'v ON v.id = uv.vid
WHERE', $where, '
- ORDER BY', {
- title => 'v.title',
- label => sql('ARRAY(SELECT ul.label FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND uvl.lbl <> ', \7, ')'),
- vote => 'uv.vote',
- voted => 'uv.vote_date',
- added => 'uv.added',
- modified => 'uv.lastmod',
- started => 'uv.started',
- finished => 'uv.finished',
- rel => 'v.c_released',
- rating => 'v.c_rating',
- }->{$opt->{s}}, $opt->{o} eq 'd' ? 'DESC' : 'ASC', 'NULLS LAST, v.title'
+ ORDER BY', $opt->{s}->sql_order(), 'NULLS LAST, v.sorttitle'
);
- enrich_flatten labels => id => vid => sql('SELECT vid, lbl FROM ulist_vns_labels WHERE uid =', \$uid, 'AND vid IN'), $lst;
-
enrich rels => id => vid => sub { sql '
- SELECT rv.vid, r.id, rl.status
+ SELECT rv.vid, r.id, rl.status, rv.rtype
FROM rlists rl
- JOIN releases r ON rl.rid = r.id
+ 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.title, r.id'
+ 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);
- # TODO: Thumbnail view?
- paginate_ $url, $opt->{p}, [ $count, 50 ], 't', sub {
- elm_ ColSelect => 'raw', [ $url->(), [
- [ voted => 'Vote date' ],
- [ vote => 'Vote' ],
- [ rating => 'Rating' ],
- [ label => 'Labels' ],
- [ added => 'Added' ],
- [ modified => 'Modified' ],
- [ started => 'Start date' ],
- [ finished => 'Finish date' ],
- [ rel => 'Release date' ],
- ] ];
- };
- div_ class => 'mainbox browse ulist', sub {
+ 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', name => 'collapse_vid', id => 'collapse_vid';
+ 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 in voted => $opt->{c};
- td_ class => 'tc_vote', sub { txt_ 'Vote'; sortable_ 'vote', $opt, $url } if in vote => $opt->{c};
- td_ class => 'tc_rating', sub { txt_ 'Rating'; sortable_ 'rating', $opt, $url } if in rating => $opt->{c};
- td_ class => 'tc_labels', sub { txt_ 'Labels'; sortable_ 'label', $opt, $url } if in label => $opt->{c};
+ td_ class => 'tc_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_added', sub { txt_ 'Added'; sortable_ 'added', $opt, $url } if in added => $opt->{c};
- td_ class => 'tc_modified', sub { txt_ 'Modified'; sortable_ 'modified', $opt, $url } if in modified => $opt->{c};
- td_ class => 'tc_started', sub { txt_ 'Start date'; sortable_ 'started', $opt, $url } if in started => $opt->{c};
- td_ class => 'tc_finished', sub { txt_ 'Finish date'; sortable_ 'finished', $opt, $url } if in finished => $opt->{c};
- td_ class => 'tc_rel', sub { txt_ 'Release date';sortable_ 'rel', $opt, $url } if in rel => $opt->{c};
+ 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, 50 ], 'b';
+ paginate_ $url, $opt->{p}, [$count, $opt->{s}->results], 'b';
}
-# TODO: Ability to add VNs from this page
TUWF::get qr{/$RE{uid}/ulist}, sub {
- my $u = tuwf->dbRowi('SELECT id,', sql_user(), ', ulist_votes, ulist_vnlist, ulist_wish FROM users u WHERE id =', \tuwf->capture('id'));
+ 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;
- # Visible and selectable labels
- my $labels = tuwf->dbAlli(
- 'SELECT l.id, l.label, l.private, count(vl.vid) as count, null as delete
- FROM ulist_labels l LEFT JOIN ulist_vns_labels vl ON vl.uid = l.uid AND vl.lbl = l.id
- WHERE', { 'l.uid' => $u->{id}, $own ? () : ('l.private' => 0) },
- 'GROUP BY l.id, l.label, l.private
- ORDER BY CASE WHEN l.id < 10 THEN l.id ELSE 10 END, l.label'
- );
-
- # All visible labels that can be filtered on, including "virtual" labels like 'No label'
- my $filtlabels = [
- @$labels,
- # Consider label 7 (Voted) a virtual label if it's set to private.
- !grep($_->{id} == 7, @$labels) ? {
- id => 7, label => 'Voted', count => tuwf->dbVali(
- 'SELECT count(*)
- FROM ulist_vns uv
- WHERE uv.vote IS NOT NULL AND EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private)
- AND uid =', \$u->{id}
- )
- } : (),
- $own ? {
- id => -1, label => 'No label', count => tuwf->dbVali(
- 'SELECT count(*)
- FROM ulist_vns uv
- WHERE NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND uvl.lbl <>', \7, ')
- AND uid =', \$u->{id}
- )
- } : (),
- ];
-
- my($opt, $opt_labels) = opt $u, $filtlabels;
+ my($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.
@@ -312,29 +306,34 @@ TUWF::get qr{/$RE{uid}/ulist}, sub {
voteprivate => (map \($_->{private}?1:0), grep $_->{id} == 7, @$labels),
} ) : (),
sub {
- my $empty = !grep $_->{count}, @$filtlabels;
- div_ class => 'mainbox', sub {
- h1_ $title;
- if($empty) {
- p_ $own
- ? 'Your list is empty! You can add visual novels to your list from the visual novel pages.'
- : user_displayname($u).' does not have any visible visual novels in their list.';
- } else {
- filters_ $own, $filtlabels, $opt, $opt_labels, \&url;
- elm_ 'UList.ManageLabels' if $own;
- elm_ 'UList.SaveDefault', $VNWeb::ULists::Elm::SAVED_OPTS_OUT, { uid => $u->{id}, opts => $opt } if $own;
- div_ class => 'hidden exportlist', sub {
- b_ '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;
+ 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;
+ }
};
};
diff --git a/lib/VNWeb/User/Admin.pm b/lib/VNWeb/User/Admin.pm
new file mode 100644
index 00000000..36dd4da2
--- /dev/null
+++ b/lib/VNWeb/User/Admin.pm
@@ -0,0 +1,74 @@
+package VNWeb::User::Admin;
+
+use VNWeb::Prelude;
+
+my $FORM = {
+ id => { vndbid => 'u' },
+ username => { default => '' },
+
+ # Permissions of the user editing this account
+ editor_dbmod => { _when => 'out', anybool => 1 },
+ editor_usermod => { _when => 'out', anybool => 1 },
+ editor_tagmod => { _when => 'out', anybool => 1 },
+ editor_boardmod => { _when => 'out', anybool => 1 },
+
+ ign_votes => { anybool => 1 },
+ map +("perm_$_" => { anybool => 1 }), VNWeb::Auth::listPerms
+};
+
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_OUT = form_compile out => $FORM;
+
+sub _userinfo {
+ if(!auth->isMod) { tuwf->resDenied; tuwf->done; }
+ my $u = tuwf->dbRowi('
+ SELECT u.id, username, ign_votes, ', sql_comma(map "perm_$_", auth->listPerms), '
+ FROM users u
+ LEFT JOIN users_shadow us ON us.id = u.id
+ WHERE u.id =', \$_[0]
+ );
+ if(!$u->{id}) { tuwf->resNotFound; tuwf->done; }
+ $u
+}
+
+
+TUWF::get qr{/$RE{uid}/admin}, sub {
+ my $u = _userinfo tuwf->capture('id');
+
+ $u->{editor_dbmod} = auth->permDbmod;
+ $u->{editor_usermod} = auth->permUsermod;
+ $u->{editor_tagmod} = auth->permTagmod;
+ $u->{editor_boardmod} = auth->permBoardmod;
+
+ framework_ title => "Admin settings for ".($u->{username}//$u->{id}), dbobj => $u, tab => 'admin',
+ sub {
+ div_ widget(UserAdmin => $FORM_OUT, $u), '';
+ };
+};
+
+
+js_api UserAdmin => $FORM_IN, sub {
+ my($data) = @_;
+ my $u = _userinfo $data->{id};
+
+ tuwf->dbExeci(select => sql_func user_setperm_usermod => \$u->{id}, \auth->uid, sql_fromhex(auth->token), \$data->{perm_usermod})
+ if auth->permUsermod;
+
+ my @set = (
+ auth->permUsermod
+ ? ('ign_votes', map "perm_$_", grep $_ ne 'usermod', auth->listPerms)
+ : (
+ auth->permBoardmod ? qw/perm_board perm_review/ : (),
+ auth->permDbmod ? qw/perm_edit perm_imgvote perm_lengthvote/ : (),
+ auth->permTagmod ? qw/perm_tag/ : (),
+ ),
+ );
+ tuwf->dbExeci('UPDATE users SET', { map +($_, $data->{$_}), @set }, 'WHERE id =', \$u->{id});
+
+ my $new = _userinfo $u->{id};
+ my @diff = grep $u->{$_} ne $new->{$_}, @set;
+ auth->audit($data->{id}, 'user admin', join '; ', map "$_: $u->{$_} -> $new->{$_}", @diff) if @diff;
+ +{ ok => 1 }
+};
+
+1;
diff --git a/lib/VNWeb/User/Css.pm b/lib/VNWeb/User/Css.pm
new file mode 100644
index 00000000..10d21097
--- /dev/null
+++ b/lib/VNWeb/User/Css.pm
@@ -0,0 +1,37 @@
+package VNWeb::User::Css;
+
+use VNWeb::Prelude;
+
+
+sub _sanitize_css {
+ # This function is attempting to do the impossible: Sanitize user provided
+ # CSS against various attacks. I'm not expecting this to be bullet-proof.
+ # Fortunately, we also have CSP in place to mitigate some problems if they
+ # arise, but I'd rather not rely on it. I'd *love* to disable support for
+ # external url()'s, but unfortunately many people use that to load images.
+ # I'm afraid the only way to work around that is to fetch and cache those
+ # URLs on the server.
+ local $_ = $_[0];
+ s/\\//g; # Get rid of backslashes, could be used to bypass the other regexes.
+ s/@(import|charset|font-face)[^\n\;]*.//ig;
+ s/javascript\s*://ig; # Not sure 'javascript:' URLs do anything, but just in case.
+ s/expression\s*\(//ig; # An old IE thing I guess.
+ s/binding\s*://ig; # Definitely don't want bindings.
+ $_;
+}
+
+
+TUWF::get qr{/$RE{uid}\.css}, sub {
+ my $u = tuwf->dbRowi('
+ SELECT u.id, pubskin_can, pubskin_enabled, customcss
+ FROM users u
+ JOIN users_prefs up ON up.id = u.id
+ WHERE u.id =', \tuwf->capture('id'));
+ return tuwf->resNotFound if !$u->{id};
+ return tuwf->resDenied if !($u->{pubskin_can} && $u->{pubskin_enabled}) && !(auth && auth->uid eq $u->{id});
+ tuwf->resHeader('Content-type', 'text/css; charset=UTF8');
+ tuwf->resHeader('Cache-Control', 'max-age=31536000'); # invalidation is done by adding a checksum to the URL.
+ lit_ _sanitize_css $u->{customcss};
+};
+
+1;
diff --git a/lib/VNWeb/User/Delete.pm b/lib/VNWeb/User/Delete.pm
new file mode 100644
index 00000000..6e7827d4
--- /dev/null
+++ b/lib/VNWeb/User/Delete.pm
@@ -0,0 +1,214 @@
+package VNWeb::User::Delete;
+
+use VNWeb::Prelude;
+
+
+sub _getmail {
+ tuwf->dbVali(select => sql_func user_getmail => \auth->uid, \auth->uid, sql_fromhex auth->token);
+}
+
+sub set_delete {
+ return 0 if tuwf->reqMethod ne 'POST';
+ my $pwd = tuwf->validate(post => password => { password => 1, onerror => undef })->data // return 1;
+ return 1 if !VNWeb::Auth->new->login(auth->uid, $pwd, 1);
+
+ tuwf->dbExeci(select => sql_func user_setdelete => \auth->uid, sql_fromhex(auth->token), \1);
+ auth->audit(auth->uid, 'mark for deletion');
+
+ my $path = '/'.auth->uid.'/del/'.auth->token;
+ my $body = sprintf
+ "Hello %s,"
+ ."\n"
+ ."\nAs per your request, your account is scheduled for deletion in approximately 7 days."
+ ."\nTo view the status of your request or to cancel the deletion, visit the link below before the timer expires:"
+ ."\n"
+ ."\n%s"
+ ."\n"
+ ."\nvndb.org",
+ auth->user->{user_name}, tuwf->reqBaseURI().$path;
+
+ tuwf->mail($body,
+ To => _getmail(),
+ From => 'VNDB <noreply@vndb.org>',
+ Subject => 'Account deletion for '.auth->user->{user_name},
+ );
+ tuwf->resRedirect($path, 'post');
+ tuwf->done;
+}
+
+
+TUWF::any ['get','post'], qr{/$RE{uid}/del}, sub {
+ my $uid = auth->uid;
+ return tuwf->resNotFound if !auth || tuwf->capture('id') ne auth->uid;
+
+ my $invalid = set_delete;
+
+ framework_ title => 'Account deletion', sub {
+ article_ sub {
+ h1_ 'Account deletion';
+ div_ class => 'warning', 'Account deletion is permanent and your data cannot be restored. Proceed with care!';
+
+ h2_ 'E-mail opt-out';
+ p_ sub {
+ txt_ 'You can NOT register a new account in the future with the email address associated with this account: ';
+ strong_ _getmail;
+ txt_ '.';
+ };
+
+ my $vns = tuwf->dbVali('SELECT COUNT(*) FROM ulist_vns WHERE uid =', \$uid);
+ if ($vns) {
+ h2_ 'Visual novel list';
+ p_ sub {
+ a_ href => "/$uid/ulist", 'Your visual novel list';
+ txt_ ' will be deleted with your account.';
+ };
+ p_ sub {
+ txt_ 'Your list currently holds ';
+ strong_ $vns;
+ txt_ ' visual novels, consider making a local backup through the "Export" button before proceeding with the deletion.';
+ };
+ }
+
+ my $posts = tuwf->dbVali('SELECT
+ (SELECT COUNT(*)
+ FROM threads_posts tp
+ WHERE hidden IS NULL AND uid =', \$uid, '
+ AND EXISTS(SELECT 1 FROM threads t WHERE t.id = tp.tid AND NOT t.hidden)
+ ) +
+ (SELECT COUNT(*) FROM reviews_posts WHERE hidden IS NULL AND uid =', \$uid, ')');
+ if ($posts) {
+ h2_ 'Forum posts';
+ p_ sub {
+ a_ href => "/$uid/posts", sub {
+ txt_ 'Your ';
+ strong_ $posts;
+ txt_ ' forum posts';
+ };
+ txt_ ' will remain after your account has been deleted.';
+ };
+ p_ 'Please send an email to '.config->{admin_email}.' if these contain sensitive information that you wish to have deleted.';
+ }
+
+ my $edits = tuwf->dbVali('SELECT COUNT(*) FROM changes WHERE requester =', \$uid);
+ if ($edits) {
+ h2_ 'Database edits';
+ p_ sub {
+ a_ href => "/$uid/hist", sub {
+ txt_ 'Your ';
+ strong_ $edits;
+ txt_ ' database edits';
+ };
+ txt_ ' will remain after your account has been deleted.';
+ };
+ p_ 'Please send an email to '.config->{admin_email}.' if these contain sensitive information that you wish to have deleted.';
+ }
+
+ my $reviews = tuwf->dbVali('SELECT COUNT(*) FROM reviews WHERE uid =', \$uid);
+ if ($reviews) {
+ h2_ 'Reviews';
+ p_ sub {
+ a_ href => "/w?u=$uid", sub {
+ txt_ 'Your ';
+ strong_ $reviews;
+ txt_ ' reviews';
+ };
+ txt_ ' will remain after your account has been deleted.';
+ };
+ p_ "If you don't want this, make sure to delete the reviews by going through the edit form.";
+ }
+
+ my $lengthvotes = tuwf->dbVali('SELECT COUNT(*) FROM vn_length_votes WHERE NOT private AND uid =', \$uid);
+ my $imgvotes = tuwf->dbVali('SELECT COUNT(*) FROM image_votes WHERE uid =', \$uid);
+ my $tags = tuwf->dbVali('SELECT COUNT(*) FROM tags_vn WHERE uid =', \$uid);
+ my $quotes => tuwf->dbVali('SELECT COUNT(*) FROM quotes WHERE addedby =', \$uid);
+ if ($lengthvotes || $imgvotes || $tags || $quotes) {
+ h2_ 'Misc. database contributions';
+ p_ 'Your database contributions will remain after your account has been deleted, these include:';
+ ul_ sub {
+ li_ sub { strong_ $lengthvotes; txt_ ' visual novel play times.'; } if $lengthvotes;
+ li_ sub { strong_ $imgvotes; txt_ ' image flagging votes.'; } if $imgvotes;
+ li_ sub { strong_ $tags; txt_ ' visual novel tags.'; } if $tags;
+ li_ sub { strong_ $quotes; txt_ ' visual novel quotes.'; } if $quotes;
+ };
+ }
+
+ br_;
+ h2_ 'Confirm account deletion';
+ form_ method => 'POST', class => 'invalid-form', sub {
+ fieldset_ class => 'form', sub {
+ fieldset_ sub {
+ label_ for => 'password', 'Password';
+ input_ type => 'password', id => 'password', name => 'password', required => 1, class => 'mw';
+ p_ class => 'invalid', 'Invalid password.' if $invalid;
+ };
+ fieldset_ sub {
+ input_ type => 'submit', value => 'Delete my account';
+ p_ 'Your account will be deleted approximately 7 days after confirmation. You can cancel the deletion before that time.';
+ };
+ };
+ };
+ };
+ };
+};
+
+
+TUWF::any ['post','get'], qr{/$RE{uid}/del/([a-fA-F0-9]{40})}, sub {
+ my($uid, $token) = tuwf->captures(1,2);
+ return tuwf->resRedirect('/', 'temp') if auth && auth->uid ne $uid;
+
+ my $u = tuwf->dbRowi('
+ SELECT ', sql_totime('us.delete_at'), 'delete_at, ', sql_user(), '
+ , ', sql_func(user_validate_session => 'u.id', sql_fromhex($token), \'web'), 'IS DISTINCT FROM NULL AS valid
+ FROM users u
+ JOIN users_shadow us ON us.id = u.id
+ WHERE u.id =', \$uid
+ );
+
+ my $cancelled;
+ if (tuwf->reqMethod eq 'POST' && $u->{valid} && $u->{delete_at}) {
+ # TODO: Ideally this should just auto-login and redirect, but doing so
+ # with the current session token is a bad idea and I'm too lazy to code
+ # a session token renewal thing.
+ # TODO: This should really invalidate all existing session tokens,
+ # given that we could also have reached this page with a fresh token on
+ # login.
+ tuwf->dbExeci(select => sql_func user_setdelete => \$uid, sql_fromhex($token), \0);
+ tuwf->dbExeci(select => sql_func user_logout => \$uid, sql_fromhex $token);
+ auth->audit($uid, 'cancel deletion');
+ $cancelled = 1;
+ }
+
+ framework_ title => 'Account deletion', sub {
+ article_ $cancelled ? sub {
+ h1_ 'Account deletion cancelled';
+ p_ sub {
+ txt_ 'Your account is no longer scheduled for deletion. You can now ';
+ a_ href => '/u/login', 'login to your account again';
+ txt_ '.';
+ };
+ } : !defined $u->{user_name} ? sub {
+ h1_ 'No such user';
+ p_ 'No user found with that ID, perhaps the account has been deleted already.';
+ } : !$u->{valid} ? sub {
+ h1_ 'Invalid token';
+ } : !$u->{delete_at} ? sub {
+ h1_ 'No account deletion pending';
+ p_ 'Your account is not scheduled to be deleted.';
+ } : sub {
+ h1_ 'Account deletion pending';
+ p_ sub {
+ my $days = sprintf '%.0f', ($u->{delete_at}-time())/(24*3600);
+ txt_ 'Your account is scheduled to be deleted ';
+ txt_ $days < 1 ? 'in less than 24 hours.' :
+ $days < 2 ? 'tomorrow.' : "in approximately $days days.";
+ };
+ form_ method => 'POST', sub {
+ p_ sub {
+ input_ type => 'submit', value => 'Cancel account deletion';
+ };
+ };
+ };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/User/Edit.pm b/lib/VNWeb/User/Edit.pm
index 59c0f0d0..a4e42ad8 100644
--- a/lib/VNWeb/User/Edit.pm
+++ b/lib/VNWeb/User/Edit.pm
@@ -2,59 +2,88 @@ 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' },
- title => { _when => 'out' },
- username => { username => 1 }, # Can only be modified with perm_usermod
-
- opts => { _when => 'out', type => 'hash', keys => {
- # Supporter options available to this user
- nodistract_can => { _when => 'out', anybool => 1 },
- support_can => { _when => 'out', anybool => 1 },
- uniname_can => { _when => 'out', anybool => 1 },
- pubskin_can => { _when => 'out', anybool => 1 },
-
- # Permissions of the user editing this account
- perm_dbmod => { _when => 'out', anybool => 1 },
- perm_usermod => { _when => 'out', anybool => 1 },
- perm_tagmod => { _when => 'out', anybool => 1 },
- perm_boardmod => { _when => 'out', anybool => 1 },
- perm_imgmod => { _when => 'out', anybool => 1 },
+ 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 },
} },
- # Settings that require at least one perm_*mod
- admin => { required => 0, type => 'hash', keys => {
- ign_votes => { anybool => 1 },
- map +("perm_$_" => { anybool => 1 }), VNWeb::Auth::listPerms
+ 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 => {},
} },
- # Settings that can only be read/modified by the user itself or a perm_usermod
- prefs => { required => 0, type => 'hash', keys => {
- email => { email => 1 },
- max_sexual => { int => 1, range => [-1, 2 ] },
- max_violence => { uint => 1, range => [ 0, 2 ] },
- traits_sexual => { anybool => 1 },
- tags_all => { anybool => 1 },
- tags_cont => { anybool => 1 },
- tags_ero => { anybool => 1 },
- tags_tech => { anybool => 1 },
- spoilers => { uint => 1, range => [ 0, 2 ] },
- skin => { enum => skins },
- customcss => { required => 0, default => '', maxlength => 2000 },
-
- # Supporter options
- nodistract_noads => { anybool => 1 },
- nodistract_nofancy => { anybool => 1 },
- support_enabled => { anybool => 1 },
- uniname => { required => 0, default => '', regex => qr/^.{2,15}$/ }, # Use regex to check length, HTML5 `maxlength` attribute counts UTF-16 code units...
- pubskin_enabled => { anybool => 1 },
+ 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 },
} },
- password => { _when => 'in', required => 0, type => 'hash', keys => {
- old => { password => 1 },
- new => { password => 1 }
+ api2 => { maxlength => 64, aoh => {
+ token => {},
+ added => {},
+ lastused => { default => '' },
+ notes => { default => '', sl => 1, maxlength => 200 },
+ listread => { anybool => 1 },
+ listwrite => { anybool => 1 },
+ delete => { anybool => 1 },
} },
};
@@ -62,106 +91,111 @@ 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 id, username FROM users WHERE id =', \tuwf->capture('id'));
+ my $u = tuwf->dbRowi(
+ 'SELECT u.id, username, max_sexual, max_violence, traits_sexual, tags_all, tags_cont, tags_ero, tags_tech, prodrelexpand
+ , vnrel_langs::text[], vnrel_olang, vnrel_mtl, staffed_langs::text[], staffed_olang, staffed_unoff
+ , spoilers, skin, customcss, customcss_csum, timezone, titles
+ , nodistract_can, support_can, uniname_can, pubskin_can
+ , nodistract_noads, nodistract_nofancy, support_enabled, uniname, pubskin_enabled
+ FROM users u JOIN users_prefs up ON up.id = u.id WHERE u.id =', \tuwf->capture('id')
+ );
return tuwf->resNotFound if !$u->{id} || !can_edit u => $u;
- $u->{opts} = tuwf->dbRowi('SELECT nodistract_can, support_can, uniname_can, pubskin_can FROM users WHERE id =', \$u->{id});
- $u->{opts}{perm_dbmod} = auth->permDbmod;
- $u->{opts}{perm_usermod} = auth->permUsermod;
- $u->{opts}{perm_tagmod} = auth->permTagmod;
- $u->{opts}{perm_boardmod} = auth->permBoardmod;
- $u->{opts}{perm_imgmod} = auth->permImgmod;
-
- $u->{prefs} = $u->{id} eq auth->uid || auth->permUsermod ?
- tuwf->dbRowi(
- 'SELECT max_sexual, max_violence, traits_sexual, tags_all, tags_cont, tags_ero, tags_tech, spoilers, skin, customcss
- , nodistract_noads, nodistract_nofancy, support_enabled, uniname, pubskin_enabled
- FROM users WHERE id =', \$u->{id}
- ) : undef;
- $u->{prefs}{email} = _getmail $u->{id} if $u->{prefs};
- $u->{prefs}{skin} ||= config->{skin_default} if $u->{prefs};
-
- $u->{admin} = auth->permDbmod || auth->permUsermod || auth->permTagmod || auth->permBoardmod || auth->permImgmod ?
- tuwf->dbRowi('SELECT ign_votes, ', sql_comma(map "perm_$_", auth->listPerms), 'FROM users WHERE id =', \$u->{id}) : undef;
-
- $u->{password} = undef;
-
- $u->{title} = $u->{id} eq auth->uid ? 'My Account' : "Edit $u->{username}";
- framework_ title => $u->{title}, type => 'u', dbobj => $u, tab => 'edit',
+ $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 {
- elm_ 'User.Edit', $FORM_OUT, $u;
+ article_ sub {
+ h1_ $title;
+ };
+ div_ widget(UserEdit => $FORM_OUT, $u), '';
};
};
-elm_api UserEdit => $FORM_OUT, $FORM_IN, sub {
+js_api UserEdit => $FORM_IN, sub {
my $data = shift;
- my $username = tuwf->dbVali('SELECT username FROM users WHERE id =', \$data->{id});
- return tuwf->resNotFound if !$username;
- return elm_Unauth if !can_edit u => $data;
+ my $u = tuwf->dbRowi('SELECT id, username FROM users WHERE id =', \$data->{id});
+ return tuwf->resNotFound if !$u->{id};
+ return tuwf->resDenied if !can_edit u => $u;
- my $own = $data->{id} eq auth->uid || auth->permUsermod;
- my %set;
+ my(%set, %setp);
- if($own) {
- my $p = $data->{prefs};
- $p->{skin} = '' if $p->{skin} eq config->{skin_default};
- $p->{uniname} = '' if $p->{uniname} eq $username;
- return elm_Taken if $p->{uniname} && tuwf->dbVali('SELECT 1 FROM users WHERE id <>', \$data->{id}, 'AND username =', \lc($p->{uniname}));
+ $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}));
- $set{$_} = $p->{$_} for qw/
- max_sexual max_violence traits_sexual tags_all tags_cont tags_ero tags_tech spoilers skin customcss
- nodistract_noads nodistract_nofancy support_enabled uniname pubskin_enabled
- /;
- }
+ $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}->@*).'}';
- if(auth->permUsermod) {
+ $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};
- $set{ign_votes} = $data->{admin}{ign_votes};
- $set{email_confirmed} = 1;
- tuwf->dbExeci(select => sql_func user_setperm_usermod => \$data->{id}, \auth->uid, sql_fromhex(auth->token), \$data->{admin}{perm_usermod});
- $set{"perm_$_"} = $data->{admin}{"perm_$_"} for grep $_ ne 'usermod', auth->listPerms;
+ 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} });
}
- $set{perm_board} = $data->{admin}{perm_board} if auth->permBoardmod;
- $set{perm_review} = $data->{admin}{perm_review} if auth->permBoardmod;
- $set{perm_edit} = $data->{admin}{perm_edit} if auth->permDbmod;
- $set{perm_imgvote} = $data->{admin}{perm_imgvote} if auth->permImgmod;
- $set{perm_tag} = $data->{admin}{perm_tag} if auth->permTagmod;
-
- if($own && $data->{password}) {
- return elm_InsecurePass if is_insecurepass $data->{password}{new};
-
- my $ok = 1;
- if(auth->uid eq $data->{id}) {
- $ok = 0 if !auth->setpass($data->{id}, undef, $data->{password}{old}, $data->{password}{new});
- } else {
- tuwf->dbExeci(select => sql_func user_admin_setpass => \$data->{id}, \auth->uid,
- sql_fromhex(auth->token), sql_fromhex auth->_preparepass($data->{password}{new})
- );
- }
+
+ 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 elm_BadCurPass if !$ok;
+ return +{ code => 'opass', _err => 'Incorrect password' } if !$ok;
}
- my $ret = \&elm_Success;
+ my $ret = {ok=>1};
- my $newmail = $own && $data->{prefs}{email};
- my $oldmail = $own && _getmail $data->{id};
- if($own && $newmail ne $oldmail) {
- return elm_DoubleEmail if tuwf->dbVali('SELECT 1 FROM user_emailtoid(', \$newmail, ') x(id) WHERE id <>', \$data->{id});
- auth->audit($data->{id}, 'email change', "old=$oldmail; new=$newmail");
+ 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), \$newmail);
+ tuwf->dbExeci(select => sql_func user_admin_setmail => \$data->{id}, \auth->uid, sql_fromhex(auth->token), \$data->{email});
} else {
- my $token = auth->setmail_token($newmail);
+ my $token = auth->setmail_token($data->{email});
my $body = sprintf
"Hello %s,"
."\n\n"
@@ -170,27 +204,51 @@ elm_api UserEdit => $FORM_OUT, $FORM_IN, sub {
."%s"
."\n\n"
."vndb.org",
- $username, $oldmail, $newmail, tuwf->reqBaseURI()."/$data->{id}/setmail/$token";
+ $u->{username}, $oldmail, $data->{email}, tuwf->reqBaseURI()."/$data->{id}/setmail/$token";
tuwf->mail($body,
- To => $newmail,
+ To => $data->{email},
From => 'VNDB <noreply@vndb.org>',
- Subject => "Confirm e-mail change for $username",
+ Subject => "Confirm e-mail change for $u->{username}",
);
- $ret = \&elm_MailChange;
+ $ret = {email=>1};
}
}
- my $old = tuwf->dbRowi('SELECT', sql_comma(keys %set), 'FROM users WHERE id =', \$data->{id});
- tuwf->dbExeci('UPDATE users SET', \%set, 'WHERE id =', \$data->{id});
- my $new = tuwf->dbRowi('SELECT', sql_comma(keys %set), 'FROM users WHERE id =', \$data->{id});
+ tuwf->dbExeci('DELETE FROM users_traits WHERE id =', \$data->{id});
+ tuwf->dbExeci('INSERT INTO users_traits', { id => $data->{id}, tid => $_->{tid} }) for $data->{traits}->@*;
+
+ tuwf->dbExeci('DELETE FROM users_prefs_tags WHERE id =', \$data->{id});
+ tuwf->dbExeci('INSERT INTO users_prefs_tags', { id => $data->{id}, %{$_}{qw|tid spoil color childs|} }) for $data->{tagprefs}->@*;
+
+ 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});
- $_ = JSON::XS->new->allow_nonref->encode($_) for values %$old, %$new;
- my @diff = grep $old->{$_} ne $new->{$_}, keys %set;
- auth->audit($data->{id}, 'user edit', join '; ', map "$_: $old->{$_} -> $new->{$_}", @diff)
- if @diff && (auth->uid ne $data->{id} || grep /^(perm_|ign_votes|username)/, @diff);
+ 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;
+ }
- $ret->();
+ return $ret;
};
@@ -198,7 +256,7 @@ TUWF::get qr{/$RE{uid}/setmail/(?<token>[a-f0-9]{40})}, sub {
my $success = auth->setmail_confirm(tuwf->capture('id'), tuwf->capture('token'));
my $title = $success ? 'E-mail confirmed' : 'Error confirming email';
framework_ title => $title, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
div_ class => $success ? 'notice' : 'warning', sub {
p_ "Your e-mail address has been updated!" if $success;
@@ -208,4 +266,9 @@ TUWF::get qr{/$RE{uid}/setmail/(?<token>[a-f0-9]{40})}, sub {
};
};
+
+js_api UserApi2New => { id => { vndbid => 'u' }}, sub {
+ +{ token => auth->api2_set_token($_[0]{id}), added => strftime '%Y-%m-%d', localtime }
+};
+
1;
diff --git a/lib/VNWeb/User/List.pm b/lib/VNWeb/User/List.pm
index 210e6a23..7fe5cb43 100644
--- a/lib/VNWeb/User/List.pm
+++ b/lib/VNWeb/User/List.pm
@@ -9,7 +9,7 @@ sub listing_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 50], 't';
- div_ class => 'mainbox browse userlist', sub {
+ article_ class => 'browse userlist', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Username'; sortable_ 'username', $opt, \&url };
@@ -67,11 +67,13 @@ TUWF::get qr{/u/(?<char>[0a-z]|all)}, sub {
)->data;
my @where = (
- $char eq 'all' ? () : $char eq '0' ? "ascii(username) not between ascii('a') and ascii('z')" : "username like '$char%'",
+ 'username IS NOT NULL',
+ auth->permUsermod ? () : 'email_confirmed',
+ $char eq 'all' ? () : sql('match_firstchar(username, ', \$char, ')'),
$opt->{q} ? sql_or(
- auth->permUsermod && $opt->{q} =~ /@/ ? sql('id IN(SELECT y FROM user_emailtoid(', \$opt->{q}, ') x(y))') : (),
- $opt->{q} =~ /^u?([0-9]{1,6})$/ ? sql 'id =', \"u$1" : (),
- sql('username ILIKE', \('%'.sql_like($opt->{q}).'%')),
+ 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}).'%')),
) : ()
);
@@ -80,7 +82,7 @@ TUWF::get qr{/u/(?<char>[0a-z]|all)}, sub {
FROM users u
WHERE', sql_and(@where),
'ORDER BY', {
- username => 'username',
+ username => 'lower(username)',
registered => 'id',
vns => 'c_vns',
votes => 'c_votes',
@@ -94,15 +96,20 @@ TUWF::get qr{/u/(?<char>[0a-z]|all)}, sub {
my $count = @where ? tuwf->dbVali('SELECT count(*) FROM users WHERE', sql_and @where) : $totalusers;
framework_ title => 'Browse users', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Browse users';
form_ action => '/u/all', method => 'get', sub {
- searchbox_ u => $opt->{q};
+ fieldset_ class => 'search', sub {
+ input_ type => 'text', name => 'q', id => 'q', class => 'text', value => $opt->{q}//'';
+ input_ type => 'submit', class => 'submit', value => 'Search!';
+ }
};
p_ class => 'browseopts', sub {
a_ href => "/u/$_", $_ eq $char ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#'
for ('all', 'a'..'z', 0);
};
+ b_ 'The given email address is on the opt-out list.'
+ if auth->permUsermod && $opt->{q} && $opt->{q} =~ /@/ && tuwf->dbVali('SELECT email_optout_check(', \$opt->{q}, ')');
};
listing_ $opt, $list, $count if $count;
};
diff --git a/lib/VNWeb/User/Login.pm b/lib/VNWeb/User/Login.pm
index fa679325..b4ac76da 100644
--- a/lib/VNWeb/User/Login.pm
+++ b/lib/VNWeb/User/Login.pm
@@ -4,19 +4,19 @@ use VNWeb::Prelude;
TUWF::get '/u/login' => sub {
- return tuwf->resRedirect('/', 'temp') if auth;
+ return tuwf->resRedirect('/', 'temp') if auth || config->{read_only};
my $ref = tuwf->reqGet('ref');
$ref = '/' if !$ref || $ref !~ /^\//;
framework_ title => 'Login', sub {
- elm_ 'User.Login' => tuwf->compile({}), $ref;
+ div_ widget(UserLogin => {ref => $ref}), '';
};
};
-elm_api UserLogin => undef, {
- username => { username => 1 },
+js_api UserLogin => {
+ username => {},
password => { password => 1 }
}, sub {
my $data = shift;
@@ -25,37 +25,56 @@ elm_api UserLogin => undef, {
my $tm = tuwf->dbVali(
'SELECT', sql_totime('greatest(timeout, now())'), 'FROM login_throttle WHERE ip =', \$ip
) || time;
- return elm_LoginThrottle if $tm-time() > config->{login_throttle}[1];
+ return +{ _err => 'Too many failed login attempts, please use the password reset form or try again later.' }
+ if $tm-time() > config->{login_throttle}[1];
+
+ my $ismail = $data->{username} =~ /@/;
+ my $mailmsg = 'Invalid username or password.';
+
+ my $u = tuwf->dbRowi('SELECT id, user_getscryptargs(id) x FROM users WHERE',
+ $ismail ? sql('id IN(SELECT uid FROM user_emailtoid(', \$data->{username}, '))')
+ : sql('lower(username) = lower(', \$data->{username}, ')')
+ );
+ # When logging in with an email, make sure we don't disclose whether or not an account with that email exists.
+ if ($ismail && !$u->{id}) {
+ auth->wasteTime; # make timing attacks a bit harder (not 100% perfect, DB lookups & different scrypt args can still influence timing)
+ return +{ _err => $mailmsg };
+ }
+ return +{ _err => 'No user with that name.' } if !$u->{id};
+ return +{ _err => 'Account disabled, please use the password reset form to re-activate your account.' } if !$u->{x};
my $insecure = is_insecurepass $data->{password};
- if(auth->login($data->{username}, $data->{password}, $insecure)) {
- auth->audit(auth->uid, 'login') if !$insecure;
- return $insecure ? elm_InsecurePass : elm_Success
+ 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(tuwf->dbVali('SELECT id FROM users WHERE username =', \$data->{username}), 'bad password', 'failed login attempt');
+ auth->audit($u->{id}, 'bad password', 'failed login attempt');
my $upd = {
ip => \$ip,
timeout => sql_fromtime $tm + config->{login_throttle}[0]
};
tuwf->dbExeci('INSERT INTO login_throttle', $upd, 'ON CONFLICT (ip) DO UPDATE SET', $upd);
- elm_BadLogin
+ +{ _err => $ismail ? $mailmsg : 'Incorrect password.' }
};
-elm_api UserChangePass => undef, {
- username => { username => 1 },
+js_api UserChangePass => {
+ uid => { vndbid => 'u' },
oldpass => { password => 1 },
newpass => { password => 1 },
}, sub {
my $data = shift;
- my $uid = tuwf->dbVali('SELECT id FROM users WHERE username =', \$data->{username});
- die if !$uid;
- return elm_InsecurePass if is_insecurepass $data->{newpass};
- auth->audit($uid, 'password change', 'after login with an insecure password');
- die if !auth->setpass($uid, undef, $data->{oldpass}, $data->{newpass}); # oldpass should already have been verified.
- elm_Success
+ return +{ _err => 'Your new password has also been leaked.' } if is_insecurepass $data->{newpass};
+ die if !auth->setpass($data->{uid}, undef, $data->{oldpass}, $data->{newpass}); # oldpass should already have been verified.
+ auth->audit($data->{uid}, 'password change', 'after login with an insecure password');
+ {}
};
diff --git a/lib/VNWeb/User/Notifications.pm b/lib/VNWeb/User/Notifications.pm
index 2e6bc7d4..513cec23 100644
--- a/lib/VNWeb/User/Notifications.pm
+++ b/lib/VNWeb/User/Notifications.pm
@@ -71,7 +71,7 @@ sub listing_ {
txt_ ' ';
input_ type => 'submit', class => 'submit', name => 'markread', value => 'mark selected read';
input_ type => 'submit', class => 'submit', name => 'remove', value => 'remove selected';
- b_ class => 'grayedout', ' (Read notifications are automatically removed after one month)';
+ small_ ' (Read notifications are automatically removed after one month)';
}
}};
tr_ $_->{read} ? () : (class => 'unread'), sub {
@@ -93,9 +93,9 @@ sub listing_ {
a_ href => "/$lid", sub {
txt_ $l->{iid} =~ /^w/ ? ($l->{num} ? 'Comment on ' : 'Review of ') :
$l->{iid} =~ /^t/ ? ($l->{num} == 1 ? 'New thread ' : 'Reply to ') : 'Edit of ';
- i_ $l->{title};
+ span_ tattr $l;
txt_ ' by ';
- i_ user_displayname $l;
+ span_ user_displayname $l;
};
};
} for @$list;
@@ -104,7 +104,7 @@ sub listing_ {
form_ action => "/$id/notify_update", method => 'POST', sub {
input_ type => 'hidden', class => 'hidden', name => 'url', value => do { local $_ = $opt->{p}; url };
paginate_ \&url, $opt->{p}, [$count, 25], 't';
- div_ class => 'mainbox browse notifies', sub {
+ article_ class => 'browse notifies', sub {
table_ class => 'stripe', \&tbl_;
};
paginate_ \&url, $opt->{p}, [$count, 25], 'b';
@@ -113,7 +113,7 @@ sub listing_ {
# Redirect so that elm/Subscribe.elm can link to this page without knowing our uid.
-TUWF::get qr{/u/notifies}, sub { auth ? tuwf->resRedirect('/'.auth->uid.'/notifies') : tuwf->resNotFound };
+TUWF::get qr{/u/notifies}, sub { auth ? tuwf->resRedirect('/'.auth->uid.'/notifies', 'temp') : tuwf->resNotFound };
TUWF::get qr{/$RE{uid}/notifies}, sub {
@@ -134,7 +134,7 @@ TUWF::get qr{/$RE{uid}/notifies}, sub {
'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
+ 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'
@@ -142,7 +142,7 @@ TUWF::get qr{/$RE{uid}/notifies}, sub {
framework_ title => 'My notifications', js => 1,
sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'My notifications';
p_ class => 'browseopts', sub {
a_ !$opt->{r} ? (class => 'optselected') : (), href => '?r=0', 'Unread notifications';
@@ -151,7 +151,7 @@ TUWF::get qr{/$RE{uid}/notifies}, sub {
p_ 'No notifications!' if !$count;
};
listing_ $id, $opt, $count, $list;
- div_ class => 'mainbox', sub { settings_ $id };
+ article_ sub { settings_ $id };
};
};
@@ -185,7 +185,7 @@ TUWF::post qr{/$RE{uid}/notify_update}, sub {
my $frm = tuwf->validate(post =>
url => { regex => qr{^/$id/notifies} },
- notifysel => { required => 0, default => [], type => 'array', scalar => 1, values => { id => 1 } },
+ notifysel => { default => [], type => 'array', scalar => 1, values => { id => 1 } },
markread => { anybool => 1 },
remove => { anybool => 1 },
)->data;
@@ -212,26 +212,24 @@ TUWF::get qr{/$RE{uid}/notify/$RE{num}/(?<lid>[a-z0-9\.]+)}, sub {
# It's a bit annoying to add auth->notiRead() to each revision page, so do that in bulk with a simple hook.
TUWF::hook before => sub {
- auth->notiRead($+{vndbid}, $+{rev}) if auth && tuwf->reqPath() =~ qr{^/(?<vndbid>[vrpcsd]$RE{num})\.(?<rev>$RE{num})$};
+ 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|] },
- subnum => { required => 0, jsonbool => 1 },
+ 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 }, # Whether the user already gets 'subnum' notifications for this entry (see HTML.pm for possible values)
+ noti => { uint => 1, default => undef }, # used by the widget, ignored in the backend
};
-elm_api Subscribe => undef, $SUB, sub {
+js_api Subscribe => $SUB, sub {
my($data) = @_;
-
- delete $data->{noti};
- $data->{subnum} = $data->{subnum}?1:0 if defined $data->{subnum}; # 'jsonbool' isn't understood by SQL
$data->{subreview} = 0 if $data->{id} !~ /^v/;
+ delete $data->{noti};
my %where = (iid => delete $data->{id}, uid => auth->uid);
if(!defined $data->{subnum} && !$data->{subreview} && !$data->{subapply}) {
@@ -239,7 +237,7 @@ elm_api Subscribe => undef, $SUB, sub {
} else {
tuwf->dbExeci('INSERT INTO notification_subs', {%where, %$data}, 'ON CONFLICT (iid,uid) DO UPDATE SET', $data);
}
- elm_Success
+ {};
};
1;
diff --git a/lib/VNWeb/User/Page.pm b/lib/VNWeb/User/Page.pm
index b110a444..db4f7a36 100644
--- a/lib/VNWeb/User/Page.pm
+++ b/lib/VNWeb/User/Page.pm
@@ -8,7 +8,7 @@ sub _info_table_ {
my($u, $own) = @_;
my sub sup {
- b_ ' ⭐supporter⭐' if $u->{user_support_can} && $u->{user_support_enabled};
+ strong_ ' ⭐supporter⭐' if $u->{user_support_can} && $u->{user_support_enabled};
}
tr_ sub {
@@ -19,13 +19,22 @@ sub _info_table_ {
};
} if $u->{user_uniname_can} && $u->{user_uniname};
tr_ sub {
+ my $old = tuwf->dbAlli('SELECT date::date, old FROM users_username_hist WHERE id =', \$u->{id},
+ auth->permUsermod ? () : 'AND date > NOW()-\'1 month\'::interval', 'ORDER BY date DESC');
td_ class => 'key', 'Username';
td_ sub {
- txt_ ucfirst $u->{user_name};
+ txt_ $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 {
@@ -47,14 +56,22 @@ sub _info_table_ {
a_ href => "/$u->{id}/ulist?votes=1", 'Browse votes »';
}
};
+ my $lengthvotes = tuwf->dbRowi('SELECT count(*) AS count, sum(length) AS sum, bool_or(not private) as haspub FROM vn_length_votes WHERE uid =', \$u->{id});
+ tr_ sub {
+ td_ 'Play times';
+ td_ sub {
+ vnlength_ $lengthvotes->{sum};
+ txt_ sprintf ' from %d submitted play times. ', $lengthvotes->{count};
+ a_ href => "/$u->{id}/lengthvotes", 'Browse votes »' if $own || $lengthvotes->{haspub};
+ };
+ } if $lengthvotes->{count};
tr_ sub {
my $vns = tuwf->dbVali(
- 'SELECT COUNT(DISTINCT uvl.vid) FROM ulist_vns_labels uvl',
- $own ? () : ('JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl AND NOT ul.private'),
- 'WHERE uvl.lbl NOT IN(', \5, ',', \6, ') AND uvl.uid =', \$u->{id}
+ 'SELECT COUNT(vid) FROM ulist_vns
+ WHERE NOT (labels && ARRAY[', \5, ',', \6, ']::smallint[]) AND uid =', \$u->{id}, $own ? () : 'AND NOT c_private'
)||0;
my $privrel = $own ? '1=1' : 'EXISTS(
- SELECT 1 FROM releases_vn rv JOIN ulist_vns_labels uvl ON uvl.vid = rv.vid JOIN ulist_labels ul ON ul.id = uvl.lbl AND ul.uid = uvl.uid WHERE rv.id = r.rid AND uvl.uid = r.uid AND NOT ul.private
+ SELECT 1 FROM releases_vn rv JOIN ulist_vns uv ON uv.vid = rv.vid WHERE uv.uid = r.uid AND rv.id = r.rid AND NOT uv.c_private
)';
my $rel = tuwf->dbVali('SELECT COUNT(*) FROM rlists r WHERE', $privrel, 'AND r.uid =', \$u->{id})||0;
td_ 'List stats';
@@ -92,8 +109,12 @@ sub _info_table_ {
};
} if $u->{c_imgvotes};
tr_ sub {
- my $stats = tuwf->dbRowi('SELECT COUNT(*) AS posts, COUNT(*) FILTER (WHERE num = 1) AS threads FROM threads_posts WHERE uid =', \$u->{id});
- $stats->{posts} += tuwf->dbVali('SELECT COUNT(*) FROM reviews_posts WHERE uid =', \$u->{id});
+ 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. ',
@@ -102,6 +123,25 @@ sub _info_table_ {
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;
}
@@ -127,24 +167,21 @@ sub _votestats_ {
};
my $recent = tuwf->dbAlli('
- SELECT vn.id, vn.title, vn.original, uv.vote,', sql_totime('uv.vote_date'), 'AS date
+ SELECT v.id, v.title, uv.vote,', sql_totime('uv.vote_date'), 'AS date
FROM ulist_vns uv
- JOIN vn ON vn.id = uv.vid
- WHERE uv.vote IS NOT NULL AND uv.uid =', \$u->{id},
- $own ? () : (
- 'AND EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private)'
- ), '
+ JOIN', vnt, 'v ON v.id = uv.vid
+ WHERE uv.vote IS NOT NULL AND uv.uid =', \$u->{id}, $own ? () : ('AND NOT uv.c_private AND NOT v.hidden'), '
ORDER BY uv.vote_date DESC LIMIT', \8
);
table_ class => 'recentvotes stripe', sub {
thead_ sub { tr_ sub { td_ colspan => 3, sub {
txt_ 'Recent votes';
- b_ sub { txt_ ' ('; a_ href => "/$u->{id}/ulist?votes=1", 'show all'; txt_ ')' };
+ span_ sub { txt_ '('; a_ href => "/$u->{id}/ulist?votes=1", 'show all'; txt_ ')' };
} } };
tr_ sub {
my $v = $_;
- td_ sub { a_ href => "/$v->{id}", title => $v->{original}||$v->{title}, shorten $v->{title}, 30 };
+ td_ sub { a_ href => "/$v->{id}", tattr $v; };
td_ fmtvote $v->{vote};
td_ fmtdate $v->{date};
} for @$recent;
@@ -162,36 +199,35 @@ TUWF::get qr{/$RE{uid}}, sub {
FROM users u
WHERE id =}, \tuwf->capture('id')
);
- return tuwf->resNotFound if !$u->{id};
+ return tuwf->resNotFound if !$u->{id} || (!$u->{user_name} && !auth->isMod);
my $own = (auth && auth->uid eq $u->{id}) || auth->permUsermod;
$u->{votes} = tuwf->dbAlli('
SELECT (uv.vote::numeric/10)::int AS idx, COUNT(uv.vote) as votes, SUM(uv.vote) AS total
FROM ulist_vns uv
- WHERE uv.vote IS NOT NULL AND uv.uid =', \$u->{id},
- $own ? () : (
- 'AND EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private)'
- ), '
+ WHERE uv.vote IS NOT NULL AND uv.uid =', \$u->{id}, $own ? () : 'AND NOT uv.c_private', '
GROUP BY (uv.vote::numeric/10)::int
');
my $title = user_displayname($u)."'s profile";
- framework_ title => $title, dbobj => $u,
- sub {
- div_ class => 'mainbox userpage', sub {
+ framework_ title => $title, dbobj => $u, sub {
+ article_ class => 'userpage', sub {
+ itemmsg_ $u;
h1_ $title;
table_ class => 'stripe', sub { _info_table_ $u, $own };
};
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Vote statistics';
div_ class => 'votestats', sub { _votestats_ $u, $own };
} if grep $_->{votes} > 0, $u->{votes}->@*;
if($u->{c_changes}) {
- h1_ class => 'boxtitle', sub { a_ href => "/$u->{id}/hist", 'Recent changes' };
- VNWeb::Misc::History::tablebox_ $u->{id}, {p=>1}, nopage => 1, results => 10;
+ nav_ sub {
+ h1_ sub { a_ href => "/$u->{id}/hist", 'Recent changes' };
+ };
+ VNWeb::Misc::History::tablebox_ $u->{id}, {p=>1}, nopage => 1, nouser => 1, results => 10;
}
};
};
diff --git a/lib/VNWeb/User/PassReset.pm b/lib/VNWeb/User/PassReset.pm
index 8d40295a..45109f80 100644
--- a/lib/VNWeb/User/PassReset.pm
+++ b/lib/VNWeb/User/PassReset.pm
@@ -3,40 +3,56 @@ package VNWeb::User::PassReset;
use VNWeb::Prelude;
TUWF::get '/u/newpass' => sub {
- return tuwf->resRedirect('/', 'temp') if auth;
+ return tuwf->resRedirect('/', 'temp') if auth || config->{read_only};
framework_ title => 'Password reset', sub {
- elm_ 'User.PassReset';
+ div_ widget(UserPassReset => {}), '';
};
};
-elm_api UserPassReset => undef, {
+js_api UserPassReset => {
email => { email => 1 },
}, sub {
my $data = shift;
- my($id, $token) = auth->resetpass($data->{email});
- return elm_BadEmail if !$id;
+ # Throttle exists to prevent email sending abuse
+ my $ip = norm_ip tuwf->reqIP;
+ my $tm = tuwf->dbVali(
+ 'SELECT', sql_totime('greatest(timeout, now())'), 'FROM reset_throttle WHERE ip =', \$ip
+ ) || time;
+ return 'Too many password reset attempts, try again later.' if $tm-time() > config->{reset_throttle}[1];
- my $name = tuwf->dbVali('SELECT username FROM users WHERE id =', \$id);
- my $body = sprintf
+ my $upd = {ip => $ip, timeout => sql_fromtime $tm + config->{reset_throttle}[0]};
+ tuwf->dbExeci('INSERT INTO reset_throttle', $upd, 'ON CONFLICT (ip) DO UPDATE SET', $upd);
+
+ my($id, $mail, $token) = auth->resetpass($data->{email});
+ my $name = $id ? tuwf->dbVali('SELECT username FROM users WHERE id =', \$id) : $data->{email};
+ my $body = $id ? sprintf
"Hello %s,"
- ."\n\n"
- ."Your VNDB.org login has been disabled, you can now set a new password by following the link below:"
- ."\n\n"
- ."%s"
- ."\n\n"
- ."Now don't forget your password again! :-)"
- ."\n\n"
- ."vndb.org",
- $name, tuwf->reqBaseURI()."/$id/setpass/$token";
+ ."\n"
+ ."\nYou can set a new password for your VNDB.org account by following the link below:"
+ ."\n"
+ ."\n%s"
+ ."\n"
+ ."\nNow don't forget your password again! :-)"
+ ."\n"
+ ."\nvndb.org",
+ $name, tuwf->reqBaseURI()."/$id/setpass/$token"
+ : "Hello,"
+ ."\n"
+ ."\nSomeone has requested a password reset for the VNDB account associated with this email address."
+ ."\nIf this was not done by you, feel free to ignore this email."
+ ."\n"
+ ."\nThere is no VNDB account associated with this email address, perhaps you used another address to sign up?"
+ ."\n"
+ ."\nvndb.org";
tuwf->mail($body,
- To => $data->{email},
+ To => $mail // $data->{email},
From => 'VNDB <noreply@vndb.org>',
Subject => "Password reset for $name",
);
- elm_Success
+ +{}
};
1;
diff --git a/lib/VNWeb/User/PassSet.pm b/lib/VNWeb/User/PassSet.pm
index 2428f8e1..13d6ba2f 100644
--- a/lib/VNWeb/User/PassSet.pm
+++ b/lib/VNWeb/User/PassSet.pm
@@ -2,18 +2,8 @@ package VNWeb::User::PassSet;
use VNWeb::Prelude;
-my $FORM = {
- uid => { vndbid => 'u' },
- token => { regex => qr/[a-f0-9]{40}/ },
- password => { _when => 'in', password => 1 },
-};
-
-my $FORM_IN = form_compile in => $FORM;
-my $FORM_OUT = form_compile out => $FORM;
-
-
TUWF::get qr{/$RE{uid}/setpass/(?<token>[a-f0-9]{40})}, sub {
- return tuwf->resRedirect('/', 'temp') if auth;
+ return tuwf->resRedirect('/', 'temp') if auth || config->{read_only};
my $id = tuwf->capture('id');
my $token = tuwf->capture('token');
@@ -22,22 +12,25 @@ TUWF::get qr{/$RE{uid}/setpass/(?<token>[a-f0-9]{40})}, sub {
return tuwf->resNotFound if !$name || !auth->isvalidtoken($id, $token);
framework_ title => 'Set password', sub {
- elm_ 'User.PassSet', $FORM_OUT, { uid => $id, token => $token };
+ div_ widget(UserPassSet => { uid => $id, token => $token }), '';
};
};
-elm_api UserPassSet => $FORM_OUT, $FORM_IN, sub {
+js_api UserPassSet => {
+ uid => { vndbid => 'u' },
+ token => { regex => qr/^[a-f0-9]{40}$/ },
+ password => { password => 1 },
+}, sub {
my($data) = @_;
- return elm_InsecurePass if is_insecurepass($data->{password});
- # "CSRF" is kind of wrong here, but the message advices to reload the page,
- # which will give a 404, which should be a good enough indication that the
- # token has expired. This case won't happen often.
- return elm_CSRF if !auth->setpass($data->{uid}, $data->{token}, undef, $data->{password});
+ return +{ insecure => 1, _err => 'Your new password is in a public database of leaked passwords, please choose a different password.' }
+ if is_insecurepass($data->{password});
+ return +{ _err => 'Invalid token.' }
+ if !auth->setpass($data->{uid}, $data->{token}, undef, $data->{password});
tuwf->dbExeci('UPDATE users SET email_confirmed = true WHERE id =', \$data->{uid});
auth->audit($data->{uid}, 'password change', 'with email token');
- elm_Success
+ +{ _redir => '/' }
};
1;
diff --git a/lib/VNWeb/User/Register.pm b/lib/VNWeb/User/Register.pm
index 89e34846..85de3599 100644
--- a/lib/VNWeb/User/Register.pm
+++ b/lib/VNWeb/User/Register.pm
@@ -6,35 +6,64 @@ use VNWeb::Prelude;
TUWF::get '/u/register', sub {
return tuwf->resRedirect('/', 'temp') if auth;
framework_ title => 'Register', sub {
- elm_ 'User.Register';
+ if(global_settings->{lockdown_registration} || config->{read_only}) {
+ article_ sub {
+ h1_ 'Create an account';
+ p_ 'Account registration is temporarily disabled. Try again later.';
+ }
+ } else {
+ div_ widget('UserRegister'), '';
+ }
};
};
-elm_api UserRegister => undef, {
+js_api UserRegister => {
username => { username => 1 },
email => { email => 1 },
- vns => { int => 1 },
}, sub {
my $data = shift;
+ return 'Registration disabled.' if global_settings->{lockdown_registration};
- my $num = tuwf->dbVali("SELECT count FROM stats_cache WHERE section = 'vn'");
- return elm_Bot if $data->{vns} < $num*0.995 || $data->{vns} > $num*1.005;
- return elm_Taken if tuwf->dbVali('SELECT 1 FROM users WHERE username =', \$data->{username});
- return elm_DoubleEmail if tuwf->dbVali('SELECT 1 FROM user_emailtoid(', \$data->{email}, ') x');
+ return +{ err => 'username' } if !is_unique_username $data->{username};
+ # Throttle before checking for duplicate email, wouldn't want to be sending too many emails.
my $ip = tuwf->reqIP;
- return elm_DoubleIP if tuwf->dbVali(
- q{SELECT 1 FROM users WHERE registered >= NOW()-'1 day'::interval AND ip <<},
- $ip =~ /:/ ? \"$ip/48" : \"$ip/30"
- );
+ return 'You can only register one account from the same IP within 24 hours.'
+ if tuwf->dbVali('SELECT 1 FROM registration_throttle WHERE timeout > NOW() AND ip =', \norm_ip($ip));
+ my %throttle = (timeout => sql("NOW()+'1 day'::interval"), ip => norm_ip($ip));
+ tuwf->dbExeci('INSERT INTO registration_throttle', \%throttle, 'ON CONFLICT (ip) DO UPDATE SET', \%throttle);
+
+ # Check for opt-out. Returning 'ok' here sucks balls, but otherwise we'd be vulnerable to email enumeration.
+ return +{ ok => 1 } if tuwf->dbVali('SELECT email_optout_check(', \$data->{email}, ')');
+
+ # Check for duplicate email
+ my $dupe = tuwf->dbVali('SELECT u.username FROM users u, user_emailtoid(', \$data->{email}, ') x(id) WHERE x.id = u.id');
+ if (defined $dupe) {
+ tuwf->mail(
+ "Hello $data->{username},"
+ ."\n"
+ ."\nSomeone has attempted to register an account on VNDB.org with your email address,"
+ ."\nbut you already have an account on VNDB with the username '$dupe'."
+ ."\n"
+ ."\nIf you forgot your password, you can recover access to your account through the following link:"
+ ."\n".tuwf->reqBaseURI()."/u/newpass"
+ ."\n"
+ ."\nIf you don't remember creating an account on VNDB.org recently, please ignore this e-mail."
+ ."\n"
+ ."\nvndb.org",
+ To => $data->{email},
+ From => 'VNDB <noreply@vndb.org>',
+ Subject => "Duplicate registration for $data->{username}",
+ );
+ return +{ ok => 1 };
+ }
+
+ my $id = tuwf->dbVali('INSERT INTO users', {username => $data->{username}}, 'RETURNING id');
+ tuwf->dbExeci('INSERT INTO users_prefs', {id => $id});
+ tuwf->dbExeci('INSERT INTO users_shadow', {id => $id, ip => ipinfo(), mail => $data->{email}});
- my $id = tuwf->dbVali('INSERT INTO users', {
- username => $data->{username},
- mail => $data->{email},
- ip => $ip,
- }, 'RETURNING id');
- my(undef, $token) = auth->resetpass($data->{email});
+ my(undef, undef, $token) = auth->resetpass($data->{email});
my $body = sprintf
"Hello %s,"
@@ -53,7 +82,7 @@ elm_api UserRegister => undef, {
From => 'VNDB <noreply@vndb.org>',
Subject => "Confirm registration for $data->{username}",
);
- elm_Success
+ +{ ok => 1 }
};
1;
diff --git a/lib/VNWeb/VN/Edit.pm b/lib/VNWeb/VN/Edit.pm
index 5e50861f..6c8a5f16 100644
--- a/lib/VNWeb/VN/Edit.pm
+++ b/lib/VNWeb/VN/Edit.pm
@@ -6,49 +6,60 @@ use VNWeb::Releases::Lib;
my $FORM = {
- id => { required => 0, vndbid => 'v' },
- title => { maxlength => 250 },
- original => { required => 0, default => '', maxlength => 250 },
- alias => { required => 0, default => '', maxlength => 500 },
- desc => { required => 0, default => '', maxlength => 10240 },
- olang => { enum => \%LANGUAGE, default => 'ja' },
+ 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 => { required => 0, uint => 1, max => (1<<31)-1 },
- l_renai => { required => 0, default => '', maxlength => 100 },
+ 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' },
- original => { _when => 'out', required => 0, default => '' },
} },
anime => { sort_keys => 'aid', aoh => {
aid => { id => 1 },
title => { _when => 'out' },
- original => { _when => 'out', required => 0, default => '' },
+ original => { _when => 'out', default => '' },
} },
- image => { required => 0, vndbid => 'cv' },
- image_info => { _when => 'out', required => 0, type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
- staff => { sort_keys => ['aid','role'], aoh => {
+ 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 => { required => 0, default => '', maxlength => 250 },
+ note => { default => '', sl => 1, maxlength => 250 },
id => { _when => 'out', vndbid => 's' },
- name => { _when => 'out' },
- original => { _when => 'out', required => 0, default => '' },
+ title => { _when => 'out' },
+ alttitle => { _when => 'out' },
} },
seiyuu => { sort_keys => ['aid','cid'], aoh => {
aid => { id => 1 },
cid => { vndbid => 'c' },
- note => { required => 0, default => '', maxlength => 250 },
+ note => { default => '', sl => 1, maxlength => 250 },
# Staff info
id => { _when => 'out', vndbid => 's' },
- name => { _when => 'out' },
- original => { _when => 'out', required => 0, default => '' },
+ title => { _when => 'out' },
+ alttitle => { _when => 'out' },
} },
screenshots=> { sort_keys => 'scr', aoh => {
scr => { vndbid => 'sf' },
- rid => { required => 0, vndbid => 'r' },
+ rid => { default => undef, vndbid => 'r' },
info => { _when => 'out', type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
} },
hidden => { anybool => 1 },
@@ -57,10 +68,11 @@ my $FORM = {
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' },
- name => {},
- original => { required => 0, default => '' },
+ title => {},
+ alttitle => {},
} },
};
@@ -76,6 +88,7 @@ TUWF::get qr{/$RE{vrev}/edit} => sub {
$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}];
@@ -85,27 +98,41 @@ TUWF::get qr{/$RE{vrev}/edit} => sub {
$_->{info} = {id=>$_->{scr}} for $e->{screenshots}->@*;
enrich_image 0, [map $_->{info}, $e->{screenshots}->@*];
- enrich_merge vid => 'SELECT id AS vid, title, original FROM vn WHERE id IN', $e->{relations};
- enrich_merge aid => 'SELECT id AS aid, title_romaji AS title, title_kanji AS original FROM anime WHERE id IN', $e->{anime};
+ 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 => 'SELECT id, aid, name, original FROM staff_alias WHERE aid IN', $e->{staff}, $e->{seiyuu};
+ 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, name, original FROM chars
+ 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 name, id'
+ ORDER BY sorttitle, id'
);
- framework_ title => "Edit $e->{title}", dbobj => $e, tab => 'edit',
+ my $title = titleprefs_obj $e->{olang}, $e->{titles};
+ framework_ title => "Edit $title->[1]", dbobj => $e, tab => 'edit',
sub {
- editmsg_ v => $e, "Edit $e->{title}";
+ editmsg_ v => $e, "Edit $title->[1]";
elm_ VNEdit => $FORM_OUT, $e;
};
};
@@ -132,8 +159,9 @@ elm_api VNEdit => $FORM_OUT, $FORM_IN, sub {
$data->{hidden} = $e->{hidden}||0;
$data->{locked} = $e->{locked}||0;
}
- $data->{desc} = bb_subst_links $data->{desc};
+ $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};
@@ -141,6 +169,10 @@ elm_api VNEdit => $FORM_OUT, $FORM_IN, sub {
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}->@*;
diff --git a/lib/VNWeb/VN/Elm.pm b/lib/VNWeb/VN/Elm.pm
index 3bf02d59..e3486049 100644
--- a/lib/VNWeb/VN/Elm.pm
+++ b/lib/VNWeb/VN/Elm.pm
@@ -3,31 +3,35 @@ package VNWeb::VN::Elm;
use VNWeb::Prelude;
elm_api VN => undef, {
- search => { type => 'array', values => { required => 0, default => '' } },
+ search => { type => 'array', values => { searchquery => 1 } },
hidden => { anybool => 1 },
}, sub {
my($data) = @_;
- my @q = grep length $_, $data->{search}->@*;
- die "No query" if !@q;
+ my @q = grep $_, $data->{search}->@*;
- elm_VNResult tuwf->dbPagei({ results => $data->{hidden}?50:15, page => 1 },
- 'SELECT v.id, v.title, v.original, v.hidden
- FROM (',
- sql_join('UNION ALL', map {
- my $qs = sql_like $_;
- my @qs = normalize_query $_;
- (
- /^$RE{vid}$/ ? sql('SELECT 1, id FROM vn WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(title),', \$qs, '), id FROM vn WHERE title ILIKE', \"$qs%"),
- @qs ? (sql 'SELECT 10, id FROM vn WHERE', sql_and map sql('c_search ILIKE', \"%$_%"), @qs) : ()
- )
- } @q),
- ') x(prio, id)
- JOIN vn v ON v.id = x.id
- WHERE', sql_and($data->{hidden} ? () : 'NOT v.hidden'), '
- GROUP BY v.id, v.title, v.original, v.hidden
- ORDER BY MIN(x.prio), v.title
- ');
+ 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
index a9227108..e1cabbe9 100644
--- a/lib/VNWeb/VN/Graph.pm
+++ b/lib/VNWeb/VN/Graph.pm
@@ -2,13 +2,14 @@ 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 = tuwf->dbRowi('SELECT id, title, original, hidden AS entry_hidden, locked AS entry_locked FROM vn WHERE id =', \$id);
+ 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};
@@ -27,7 +28,7 @@ TUWF::get qr{/$RE{vid}/rg}, sub {
# Fetch the nodes
my $nodes = gen_nodes $id, $rel, $num;
- enrich_merge id => "SELECT id, title, c_released, array_to_string(c_languages, '/') AS lang FROM vn WHERE id IN", values %$nodes;
+ 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;
@@ -53,10 +54,11 @@ TUWF::get qr{/$RE{vid}/rg}, sub {
$rel = [ grep $nodes->{$_->{id0}} && $nodes->{$_->{id1}}, @$rel ];
my $dot = gen_dot \@lines, $nodes, $rel, \%VN_RELATION;
- framework_ title => "Relations for $v->{title}", dbobj => $v, tab => 'rg',
+ framework_ title => "Relations for $v->{title}[1]", dbobj => $v, tab => 'rg',
sub {
- div_ class => 'mainbox', style => 'float: left; min-width: 100%', sub {
- h1_ "Relations for $v->{title}";
+ 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 };
@@ -90,4 +92,52 @@ TUWF::get qr{/$RE{vid}/rg}, sub {
};
};
+
+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
index cf710fbd..42891f81 100644
--- a/lib/VNWeb/VN/List.pm
+++ b/lib/VNWeb/VN/List.pm
@@ -3,90 +3,362 @@ 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) = @_;
+ my($opt, $list, $count, $tagscore, $labels) = @_;
my sub url { '?'.query_encode %$opt, @_ }
- paginate_ \&url, $opt->{p}, [$count, 50], 't';
- div_ class => 'mainbox browse vnbrowse', sub {
+ 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_s',sub { txt_ 'Score'; sortable_ 'tagscore', $opt, \&url } if $tagscore;
- td_ class => $tagscore ? 'tc_t' : 'tc1', sub { txt_ 'Title'; sortable_ 'title', $opt, \&url };
- td_ class => 'tc7', '';
- td_ class => 'tc2', '';
- td_ class => 'tc3', '';
- td_ class => 'tc4', sub { txt_ 'Released'; sortable_ 'rel', $opt, \&url };
- td_ class => 'tc5', sub { txt_ 'Popularity'; sortable_ 'pop', $opt, \&url };
- td_ class => 'tc6', sub { txt_ 'Rating'; sortable_ 'rating', $opt, \&url };
+ 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_s',sub { tagscore_ $_->{tagscore} } if $tagscore;
- td_ class => $tagscore ? 'tc_t' : 'tc1', sub { a_ href => "/$_->{id}", title => $_->{original}||$_->{title}, $_->{title} };
- td_ class => 'tc7', sub {
- b_ class => $_->{userlist_obtained} == $_->{userlist_all} ? 'done' : 'todo', sprintf '%d/%d', $_->{userlist_obtained}, $_->{userlist_all} if $_->{userlist_all};
- abbr_ title => join(', ', $_->{vnlist_labels}->@*), scalar $_->{vnlist_labels}->@* if $_->{vnlist_labels} && $_->{vnlist_labels}->@*;
- abbr_ title => 'No labels', ' ' if $_->{vnlist_labels} && !$_->{vnlist_labels}->@*;
+ td_ class => '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 : '-';
};
- td_ class => 'tc2', sub { join_ '', sub { abbr_ class => "icons plat $_", title => $PLATFORM{$_}, '' if $_ ne 'unk' }, sort $_->{platforms}->@* };
- td_ class => 'tc3', sub { join_ '', sub { abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' }, reverse sort $_->{lang}->@* };
- td_ class => 'tc4', sub { rdate_ $_->{c_released} };
- td_ class => 'tc5', sprintf '%.2f', ($_->{c_popularity}||0)*100;
- td_ class => 'tc6', sub {
- txt_ sprintf '%.2f', ($_->{c_rating}||0)/10;
- b_ class => 'grayedout', sprintf ' (%d)', $_->{c_votecount};
+ } 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};
};
- } for @$list;
+ } 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');
}
- };
- paginate_ \&url, $opt->{p}, [$count, 50], 'b';
+ }
+
+ 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 the userlist fields needed for listing_()
-# Also used by VNWeb::TT::TagPage
-sub enrich_userlist {
- return if !auth;
-
- enrich_merge id => sub { sql '
- SELECT irv.vid AS id
- , COUNT(*) AS userlist_all
- , SUM(CASE WHEN irl.status = 1+1 THEN 1 ELSE 0 END) AS userlist_obtained
- FROM rlists irl
- JOIN releases_vn irv ON irv.id = irl.rid
- WHERE irl.uid =', \auth->uid, 'AND irv.vid IN', $_, '
- GROUP BY irv.vid
- ' }, @_;
-
- enrich_flatten vnlist_labels => id => vid => sub { sql '
- SELECT uvl.vid, ul.label
- FROM ulist_vns_labels uvl
- JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl
- WHERE uvl.uid =', \auth->uid, 'AND uvl.vid IN', $_[0], '
- ORDER BY CASE WHEN ul.id < 10 THEN ul.id ELSE 10 END, ul.label'
- }, @_;
+# 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 => { onerror => undef },
- sq=> { onerror => undef },
+ q => { searchquery => 1 },
+ sq=> { searchquery => 1 },
p => { upage => 1 },
f => { advsearch_err => 'v' },
- s => { onerror => 'title', enum => [qw/title rel pop rating/] },
- o => { onerror => 'a', enum => ['a','d'] },
ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
- fil => { required => 0 },
- rfil => { required => 0 },
- cfil => { required => 0 },
+ fil => { onerror => '' },
+ rfil => { onerror => '' },
+ cfil => { onerror => '' },
)->data;
- $opt->{q} //= $opt->{sq};
+ $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
@@ -113,49 +385,65 @@ TUWF::get qr{/v(?:/(?<char>all|[a-z0]))?}, sub {
my $where = sql_and
'NOT v.hidden', $opt->{f}->sql_where(),
- $opt->{q} ? map sql('v.c_search LIKE', \"%$_%"), normalize_query $opt->{q} : (),
- defined($opt->{ch}) && $opt->{ch} ? sql('LOWER(SUBSTR(v.title, 1, 1)) =', \$opt->{ch}) : (),
- defined($opt->{ch}) && !$opt->{ch} ? sql('(ASCII(v.title) <', \97, 'OR ASCII(v.title) >', \122, ') AND (ASCII(v.title) <', \65, 'OR ASCII(v.title) >', \90, ')') : ();
+ defined($opt->{ch}) ? sql 'match_firstchar(v.sorttitle, ', \$opt->{ch}, ')' : ();
my $time = time;
my($count, $list);
db_maytimeout {
- $count = tuwf->dbVali('SELECT count(*) FROM vn v WHERE', $where);
- $list = $count ? tuwf->dbPagei({results => 50, page => $opt->{p}}, '
- SELECT v.id, v.title, v.original, v.c_released, v.c_popularity, v.c_votecount, v.c_rating, v.c_platforms::text[] AS platforms, v.c_languages::text[] AS lang
- FROM vn v
+ $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', sprintf {
- title => 'v.title %s',
- rel => 'v.c_released %s, v.title',
- pop => 'v.c_popularity %s NULLS LAST, v.title',
- rating => 'v.c_rating %s NULLS LAST, v.title'
- }->{$opt->{s}}, $opt->{o} eq 'a' ? 'ASC' : 'DESC'
+ ORDER BY', $opt->{s}->sql_order(),
) : [];
} || (($count, $list) = (undef, []));
- return tuwf->resRedirect("/$list->[0]{id}") if $count && $count == 1 && $opt->{q} && !defined $opt->{ch};
+ 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]
+ ') : [];
- enrich_userlist $list;
+ 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 {
- div_ class => 'mainbox', sub {
- h1_ 'Browse visual novels';
- form_ action => '/v', method => 'get', sub {
- searchbox_ v => $opt->{q}//'';
+ 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 => 'o', value => $opt->{o};
- input_ type => 'hidden', name => 's', value => $opt->{s};
input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
- $opt->{f}->elm_;
- advsearch_msg_ $count, $time;
+ $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;
};
- listing_ $opt, $list, $count if $count;
};
};
diff --git a/lib/VNWeb/VN/Page.pm b/lib/VNWeb/VN/Page.pm
index 498e9eeb..6262fcc1 100644
--- a/lib/VNWeb/VN/Page.pm
+++ b/lib/VNWeb/VN/Page.pm
@@ -3,17 +3,19 @@ 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
+# Also used by Chars::VNTab, Reviews::VNTab and VN::Quotes
sub enrich_vn {
my($v, $revonly) = @_;
- enrich_merge id => 'SELECT id, c_votecount FROM vn WHERE id IN', $v;
- enrich_merge vid => 'SELECT id AS vid, title, original FROM vn WHERE id IN', $v->{relations};
+ $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 => $v;
+ enrich_extlinks v => 0, $v;
enrich_image_obj image => $v;
enrich_image_obj scr => $v->{screenshots};
@@ -23,27 +25,58 @@ sub enrich_vn {
# 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, r.type, r.patch, r.released, r.gtin,', sql_extlinks(r => 'r.'), '
+ 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 => $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});
+ enrich_extlinks r => 0, $v->{releases};
- my $rating = 'avg(CASE WHEN tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) THEN NULL ELSE tv.vote END)';
- $v->{tags} = tuwf->dbAlli("
- SELECT t.id, t.name, t.cat, $rating as rating
- , coalesce(avg(CASE WHEN tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) THEN NULL ELSE tv.spoiler END), t.defaultspoil) as spoiler
+ $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 tv ON tv.tag = t.id
- LEFT JOIN users u ON u.id = tv.uid
- WHERE t.state = 1+1 AND tv.vid =", \$v->{id}, "
- GROUP BY t.id, t.name, t.cat
- HAVING $rating > 0
- ORDER BY rating DESC"
+ 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'
);
}
@@ -52,12 +85,13 @@ sub enrich_vn {
sub enrich_item {
my($v, $full) = @_;
enrich_vn $v, !$full;
- enrich_merge aid => 'SELECT id AS sid, aid, name, original FROM staff_alias WHERE aid IN', $v->{staff}, $v->{seiyuu};
- enrich_merge cid => 'SELECT id AS cid, name AS char_name, original AS char_original FROM chars WHERE id IN', $v->{seiyuu};
+ 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->{staff} = [ sort { $a->{aid} <=> $b->{aid} || $a->{role} cmp $b->{role} } $v->{staff}->@* ];
+ $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}->@* ];
}
@@ -66,59 +100,94 @@ sub enrich_item {
sub og {
my($v) = @_;
+{
- description => bb_format($v->{desc}, text => 1),
+ 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) = @_;
- my $minreleased = min grep $_, map $_->{released}, $v->{releases}->@*;
- $minreleased && $minreleased <= strftime('%Y%m%d', gmtime)
+ $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,
- [ title => 'Title (romaji)' ],
- [ original => 'Original title' ],
+ [ titles => 'Title(s)', txt => sub {
+ "[$_->{lang}] $_->{title}".($_->{latin} ? " / $_->{latin}" : '').($_->{official} ? '' : ' (unofficial)')
+ }],
[ alias => 'Alias' ],
[ olang => 'Original language', fmt => \%LANGUAGE ],
- [ desc => 'Description' ],
+ [ 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 {
- a_ href => "/$_->{sid}", title => $_->{original}||$_->{name}, $_->{name} if $_->{sid};
- b_ class => 'grayedout', '[removed alias]' if !$_->{sid};
+ 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}", title => $_->{original}||$_->{name}, $_->{name} if $_->{sid};
- b_ class => 'grayedout', '[removed alias]' if !$_->{sid};
+ a_ href => "/$_->{sid}", tattr $_ if $_->{sid};
+ small_ '[removed alias]' if !$_->{sid};
txt_ ' as ';
- a_ href => "/$_->{cid}", title => $_->{char_original}||$_->{char_name}, $_->{char_name};
+ 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}", title => $_->{original}||$_->{title}, $_->{title};
+ 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}::$_->{scr}{sexual}$_->{scr}{violence}$_->{scr}{votecount}", $_->{scr}{id};
- txt_ ' [';
- a_ href => "/img/$_->{scr}{id}", image_flagging_display $_->{scr};
+ 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.
- b_ class => 'grayedout', sprintf 'old flag: %s', $_->{nsfw} ? 'NSFW' : 'Safe' if $_[0]{rev_added} < 1594684800;
+ 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' } ],
@@ -131,68 +200,132 @@ sub infobox_relations_ {
return if !$v->{relations}->@*;
my %rel;
- push $rel{$_->{relation}}->@*, $_ for sort { $a->{title} cmp $b->{title} } $v->{relations}->@*;
+ 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', sub { dl_ sub {
- for(sort keys %rel) {
- dt_ $VN_RELATION{$_}{txt};
- dd_ sub {
- join_ \&br_, sub {
- b_ class => 'grayedout', '[unofficial] ' if !$_->{official};
- a_ href => "/$_->{vid}", title => $_->{original}||$_->{title}, shorten $_->{title}, 40;
- }, $rel{$_}->@*;
+ 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.name, p.original, rl.lang, bool_or(rp.developer) as developer, bool_or(rp.publisher) as publisher, min(r.type) as type, bool_or(r.official) as official
+ 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_lang rl ON rl.id = rv.id
+ JOIN releases_titles rl ON rl.id = rv.id
JOIN releases_producers rp ON rp.id = rv.id
- JOIN producers p ON p.id = rp.pid
- WHERE NOT r.hidden AND rv.vid =', \$v->{id}, '
- GROUP BY p.id, p.name, p.original, rl.lang
- ORDER BY NOT bool_or(r.official), MIN(r.released), p.name
+ 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 $_->{type} eq 'complete', @$p;
+ my $hasfull = grep $_->{rtype} eq 'complete', @$p;
my %dev;
- my @dev = grep $_->{developer} && (!$hasfull || $_->{type} ne 'trial') && !$dev{$_->{id}}++, @$p;
+ my @dev = grep $_->{developer} && (!$hasfull || $_->{rtype} ne 'trial') && !$dev{$_->{id}}++, @$p;
tr_ sub {
td_ 'Developer';
td_ sub {
- join_ ' & ', sub { a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name}; }, @dev;
+ join_ ' & ', sub { a_ href => "/$_->{id}", tattr $_ }, @dev;
};
} if @dev;
my(%lang, @lang, $lang);
- for(grep $_->{publisher} && (!$hasfull || $_->{type} ne 'trial'), @$p) {
+ 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 {
- use sort 'stable';
join_ \&br_, sub {
- abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '';
- join_ ' & ', sub { a_ href => "/$_->{id}", $_->{official} ? () : (class => 'grayedout'), title => $_->{original}||$_->{name}, $_->{name} }, $lang{$_}->@*;
- }, sort { ($b eq $v->{olang}) cmp ($a eq $v->{olang}) } @lang;
+ 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;
}
- } if keys %lang;
+ };
}
@@ -205,25 +338,26 @@ sub infobox_affiliates_ {
# url => [$title, $url, $price, $type]
my %links;
for my $rel ($v->{releases}->@*) {
- my $type = $rel->{patch} ? 4 :
- $rel->{type} eq 'trial' ? 3 :
- $rel->{type} eq 'partial' ? 2 :
- $rel->{num_vns} > 1 ? 0 : 1;
+ my $type = $rel->{patch} ? 4 :
+ $rel->{rtype} eq 'trial' ? 3 :
+ $rel->{rtype} eq 'partial' ? 2 :
+ $rel->{num_vns} > 1 ? 0 : 1;
- $links{$_->[1]} = [ @$_, min $type, $links{$_->[1]}[3]||9 ] for grep $_->[2], $rel->{extlinks}->@*;
+ $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_ class => 'standout', '» ';
+ b_ '» ';
a_ href => $_->[1], sub {
txt_ $_->[2];
- b_ class => 'grayedout', ' @ ';
+ small_ ' @ ';
txt_ $_->[0];
- b_ class => 'grayedout', " ($type[$_->[3]])" if $_->[3] != 1;
+ small_ " ($type[$_->[3]])" if $_->[3] != 1;
};
}, sort { $a->[0] cmp $b->[0] || $a->[2] cmp $b->[2] } values %links;
}
@@ -238,13 +372,13 @@ sub infobox_anime_ {
td_ 'Related anime';
td_ class => 'anime', sub { join_ \&br_, sub {
if(!$_->{lastfetch} || !$_->{year} || !$_->{title_romaji}) {
- b_ sub {
+ span_ sub {
txt_ '[no information available at this time: ';
a_ href => 'https://anidb.net/anime/'.$_->{aid}, "a$_->{aid}";
txt_ ']';
};
} else {
- b_ sub {
+ span_ sub {
txt_ '[';
a_ href => "https://anidb.net/anime/$_->{aid}", title => 'AniDB', 'DB';
if($_->{ann_id}) {
@@ -254,7 +388,7 @@ sub infobox_anime_ {
txt_ '] ';
};
abbr_ title => $_->{title_kanji}||$_->{title_romaji}, shorten $_->{title_romaji}, 50;
- b_ ' ('.(defined $_->{type} ? $ANIME_TYPE{$_->{type}}{txt}.', ' : '').$_->{year}.')';
+ span_ ' ('.(defined $_->{type} ? $ANIME_TYPE{$_->{type}}{txt}.', ' : '').$_->{year}.')';
}
}, sort { ($a->{year}||9999) <=> ($b->{year}||9999) } $v->{anime}->@* }
}
@@ -265,36 +399,42 @@ sub infobox_tags_ {
my($v) = @_;
div_ id => 'tagops', sub {
debug_ $v->{tags};
- for (keys %TAG_CATEGORY) {
- input_ id => "cat_$_", type => 'checkbox', class => 'visuallyhidden',
+ 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 => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 0 ? (checked => 'checked') : ();
+ 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 => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 1 ? (checked => 'checked') : ();
+ 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 => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 2 ? (checked => 'checked') : ();
+ 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 => 'visuallyhidden', name => 'tag_all', auth->pref('tags_all') ? () : (checked => 'checked');
+ 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 => 'visuallyhidden', name => 'tag_all', auth->pref('tags_all') ? (checked => 'checked') : ();
+ 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 = $_->{spoiler} > 1.3 ? 2 : $_->{spoiler} > 0.4 ? 1 : 0;
+ my $spoil = $_->{override}//$_->{spoiler};
my $cnt = $counts{$_->{cat}};
$cnt->[2]++;
$cnt->[1]++ if $spoil < 2;
$cnt->[0]++ if $spoil < 1;
- my $cut = $cnt->[0] > 15 ? ' cut cut2 cut1 cut0' : $cnt->[1] > 15 ? ' cut cut2 cut1' : $cnt->[2] > 15 ? ' cut cut2' : '';
+ 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 => "/g$_->{id}", style => sprintf('font-size: %dpx', $_->{rating}*3.5+6), $_->{name};
- spoil_ $spoil;
- b_ class => 'grayedout', sprintf ' %.1f', $_->{rating};
+ 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}->@*;
}
@@ -302,64 +442,69 @@ sub infobox_tags_ {
}
-sub infobox_useroptions_ {
- my($v) = @_;
- return if !auth;
-
- my $labels = tuwf->dbAlli('
- SELECT l.id, l.label, l.private, uvl.vid IS NOT NULL as assigned
- FROM ulist_labels l
- LEFT JOIN ulist_vns_labels uvl ON uvl.uid = l.uid AND uvl.lbl = l.id AND uvl.vid =', \$v->{id}, '
- WHERE l.uid =', \auth->uid, '
- ORDER BY CASE WHEN l.id < 10 THEN l.id ELSE 10 END, l.label'
- );
- my $lst = tuwf->dbRowi('SELECT vid, vote, notes FROM ulist_vns WHERE uid =', \auth->uid, 'AND vid =', \$v->{id});
- my $review = tuwf->dbVali('SELECT id FROM reviews WHERE uid =', \auth->uid, 'AND vid =', \$v->{id});
-
- tr_ class => 'nostripe', sub {
- td_ colspan => 2, sub {
- elm_ 'UList.VNPage', $VNWeb::ULists::Elm::VNPAGE, {
- uid => auth->uid,
- vid => $v->{id},
- onlist => $lst->{vid}||0,
- canvote => canvote($v),
- vote => fmtvote($lst->{vote}),
- notes => $lst->{notes}||'',
- review => $review,
- canreview=> $review || (canvote($v) && can_edit(w => {})),
- labels => $labels,
- selected => [ map $_->{id}, grep $_->{assigned}, @$labels ],
+# 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};
+ }
+ }
}
}
-}
-
-# Also used by Chars::VNTab & Reviews::VNTab
-sub infobox_ {
- my($v, $notags) = @_;
- div_ class => 'mainbox', sub {
+ article_ sub {
itemmsg_ $v;
- h1_ $v->{title};
- h2_ class => 'alttitle', lang_attr($v->{olang}), $v->{original} if $v->{original};
+ 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}; };
+ div_ class => 'vnimg', sub { image_ $v->{image}, alt => $v->{title}[1]; };
table_ class => 'stripe', sub {
tr_ sub {
- td_ class => 'key', 'Title';
- td_ class => 'title', sub {
- txt_ $v->{title};
- debug_ $v;
- abbr_ class => "icons lang $v->{olang}", title => "Original language: $LANGUAGE{$v->{olang}}", '';
+ td_ 'Title';
+ td_ sub {
+ table_ sub { tlang_ $v->{titles}[0] };
};
- };
-
+ } if $v->{titles}->@* == 1;
tr_ sub {
- td_ 'Original title';
- td_ lang_attr($v->{olang}), $v->{original};
- } if $v->{original};
+ 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';
@@ -367,26 +512,37 @@ sub infobox_ {
} if $v->{alias};
tr_ sub {
- td_ 'Length';
- td_ "$VN_LENGTH{$v->{length}}{txt} ($VN_LENGTH{$v->{length}}{time})";
- } if $v->{length};
+ 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 => $_->[1], $_->[0] }, $v->{extlinks}->@* };
+ td_ sub { join_ ', ', sub { a_ href => $_->{url2}, $_->{label} }, $v->{extlinks}->@* };
} if $v->{extlinks}->@*;
infobox_affiliates_ $v;
infobox_anime_ $v;
- infobox_useroptions_ $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->{desc} ? bb_format $v->{desc} : '-' };
+ p_ sub { lit_ $v->{description} ? bb_format $v->{description} : '-' };
+ debug_ $v;
}
}
}
@@ -397,15 +553,15 @@ sub infobox_ {
}
-# Also used by Chars::VNTab & Reviews::VNTab
+# 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});
- return if !$chars && !$v->{reviews}{full} && !$v->{reviews}{mini} && !auth->permEdit && !auth->permReview;
$tab ||= '';
- div_ class => 'maintabs', sub {
- ul_ sub {
+ 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;
@@ -415,8 +571,9 @@ sub tabs_ {
} 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)" };
};
- ul_ sub {
+ 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 => {};
@@ -434,38 +591,50 @@ sub tabs_ {
sub releases_ {
my($v) = @_;
- # TODO: Organize a long list of releases a bit better somehow? Collapsable language sections?
-
enrich_release $v->{releases};
- $v->{releases} = [ sort { $a->{released} <=> $b->{released} || idcmp($a->{id}, $b->{id}) } $v->{releases}->@* ];
- my %lang;
- my @lang = grep !$lang{$_}++, map +(sort { ($b eq $v->{olang}) cmp ($a eq $v->{olang}) || $a cmp $b } $_->{lang}->@*), $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) = @_;
- tr_ class => 'lang', sub {
- td_ colspan => 7, sub {
- abbr_ class => "icons lang $lang", title => $LANGUAGE{$lang}, '';
- txt_ $LANGUAGE{$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}->@*;
+ };
};
- my $ropt = { id => $lang };
- release_row_ $_, $ropt for grep grep($_ eq $lang, $_->{lang}->@*), $v->{releases}->@*;
}
- div_ class => 'mainbox', sub {
+ article_ class => 'vnreleases', sub {
h1_ 'Releases';
if(!$v->{releases}->@*) {
p_ 'We don\'t have any information about releases of this visual novel yet...';
} else {
- table_ class => 'releases', sub { lang_ $_ for @lang };
+ lang_ $_ for @lang;
}
}
}
-sub staff_ {
- my($v) = @_;
+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
@@ -477,7 +646,7 @@ sub staff_ {
# 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}, $v->{staff}->@*;
+ push $roles{$_->{role}}->@*, $_ for grep $_->{sid}, @$lst;
my $i=0;
my @boxes =
sort { $b->[0] <=> $a->[0] || $a->[1] <=> $b->[1] }
@@ -485,9 +654,9 @@ sub staff_ {
xml_string sub {
li_ class => 'vnstaff_head', $CREDIT_TYPE{$_};
li_ sub {
- a_ href => "/$_->{sid}", title => $_->{original}||$_->{name}, $_->{name};
- b_ title => $_->{note}, class => 'grayedout', $_->{note} if $_->{note};
- } for sort { $a->{name} cmp $b->{name} } $roles{$_}->@*;
+ a_ href => "/$_->{sid}", tattr $_;
+ small_ $_->{note} if $_->{note};
+ } for sort { $a->{title}[1] cmp $b->{title}[1] } $roles{$_}->@*;
}
], grep $roles{$_}, keys %CREDIT_TYPE;
@@ -508,14 +677,45 @@ sub staff_ {
@$c = sort { $a->[1] <=> $b->[1] } @$c;
}
- div_ class => 'mainbox', id => 'staff', 'data-mainbox-summarize' => 200, sub {
+ 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';
- div_ class => sprintf('vnstaff vnstaff-%d', scalar @$_), sub {
- ul_ sub {
- lit_ $_->[2] for $_->[2]->@*;
- } for @$_
- } for @cols;
- } if $v->{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;
+ };
+ }
+ };
}
@@ -524,22 +724,22 @@ sub charsum_ {
my $spoil = viewget->{spoilers};
my $c = tuwf->dbAlli('
- SELECT c.id, c.name, c.original, c.gender, v.role
- FROM chars c
+ 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.name, sa.original, vs.note
+ SELECT vs.cid, sa.id, sa.title, vs.note
FROM vn_seiyuu vs
- JOIN staff_alias sa ON sa.aid = vs.aid
+ JOIN', staff_aliast, 'sa ON sa.aid = vs.aid
WHERE vs.id =', \$v->{id}, 'AND vs.cid IN', $_, '
- ORDER BY sa.name'
+ ORDER BY sa.sorttitle'
) }, $c;
- div_ class => 'mainbox', 'data-mainbox-summarize' => 200, sub {
+ article_ 'data-mainbox-summarize' => 210, sub {
p_ class => 'mainopts', sub {
a_ href => "/$v->{id}/chars#chars", 'Full character list';
};
@@ -548,17 +748,17 @@ sub charsum_ {
div_ class => 'charsum_bubble', sub {
div_ class => 'name', sub {
span_ sub {
- abbr_ class => "icons gen $_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ abbr_ class => "icon-gen-$_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
+ a_ href => "/$_->{id}", tattr $_;
};
- i_ $CHAR_ROLE{$_->{role}}{txt};
+ em_ $CHAR_ROLE{$_->{role}}{txt};
};
div_ class => 'actor', sub {
txt_ 'Voiced by';
$_->{seiyuu}->@* > 1 ? br_ : txt_ ' ';
join_ \&br_, sub {
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
- b_ class => 'grayedout', $_->{note} if $_->{note};
+ a_ href => "/$_->{id}", tattr $_;
+ small_ $_->{note} if $_->{note};
}, $_->{seiyuu}->@*;
} if $_->{seiyuu}->@*;
} for @$c;
@@ -583,8 +783,7 @@ sub stats_ {
my $num = sum map $_->{votes}, @$stats;
my $recent = @$stats && tuwf->dbAlli('
- SELECT uv.vote,', sql_totime('uv.vote_date'), 'as date, ', sql_user(), '
- , NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private) AS hide_list
+ 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
@@ -593,12 +792,18 @@ sub stats_ {
LIMIT', \($v->{reviews}{total} ? 7 : 8)
);
- my $rank = $v->{c_votecount} && tuwf->dbRowi('SELECT c_rating, c_popularity, c_pop_rank, c_rat_rank FROM vn v WHERE id =', \$v->{id});
+ 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, sprintf '%d vote%s total, average %.2f (%s)', $num, $num == 1 ? '' : 's', $sum/$num/10, fmtrating(floor($sum/$num/10)||1) } };
+ 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;
@@ -613,7 +818,7 @@ sub stats_ {
table_ class => 'recentvotes stripe', sub {
thead_ sub { tr_ sub { td_ colspan => 3, sub {
txt_ 'Recent votes';
- b_ sub {
+ span_ sub {
txt_ '(';
a_ href => "/$v->{id}/votes", 'show all';
txt_ ')';
@@ -624,23 +829,17 @@ sub stats_ {
} } } if $v->{reviews}{total};
tr_ sub {
td_ sub {
- b_ class => 'grayedout', 'hidden' if $_->{hide_list};
- user_ $_ if !$_->{hide_list};
+ small_ 'hidden' if $_->{c_private};
+ user_ $_ if !$_->{c_private};
};
td_ fmtvote $_->{vote};
td_ fmtdate $_->{date};
} for @$recent;
} if $recent && @$recent;
-
clearfloat_;
- div_ sub {
- h3_ 'Ranking';
- p_ sprintf 'Popularity: ranked #%d with a score of %.2f', $rank->{c_pop_rank}, $rank->{c_popularity}*100 if defined $rank->{c_popularity};
- p_ sprintf 'Bayesian rating: ranked #%d with a rating of %.2f', $rank->{c_rat_rank}, $rank->{c_rating}/10;
- } if $v->{c_votecount};
}
- div_ class => 'mainbox', id => 'stats', sub {
+ article_ id => 'stats', sub {
h1_ 'User stats';
if(!@$stats) {
p_ 'Nobody has voted on this visual novel yet...';
@@ -669,31 +868,31 @@ sub screenshots_ {
my %rel;
push $rel{$_->{rid}}->@*, $_ for grep $_->{rid}, @$s;
- input_ name => 'scrhide_s', id => "scrhide_s$_", type => 'radio', class => 'visuallyhidden', $sexs == $_ ? (checked => 'checked') : () for 0..2;
- input_ name => 'scrhide_v', id => "scrhide_v$_", type => 'radio', class => 'visuallyhidden', $vios == $_ ? (checked => 'checked') : () for 0..2;
- div_ class => 'mainbox', id => 'screenshots', sub {
+ 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($sex[1] || $sex[2]) {
+ 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];
}
- b_ class => 'grayedout', ' | ' if ($sex[1] || $sex[2]) && ($vio[1] || $vio[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 $sex[1] || $sex[2] || $vio[1] || $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 => "icons lang $_", title => $LANGUAGE{$_}, '' for $r->{languages}->@*;
- abbr_ class => "icons plat $_", title => $PLATFORM{$_}, '' for $r->{platforms}->@*;
- a_ href => "/$r->{id}", $r->{title};
+ 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}),
@@ -708,7 +907,7 @@ sub screenshots_ {
),
sub {
my($w, $h) = imgsize $_->{scr}{width}, $_->{scr}{height}, config->{scr_size}->@*;
- img_ src => imgurl($_->{scr}{id}, 1), width => $w, height => $h, alt => "Screenshot $_->{scr}{id}";
+ img_ src => imgurl($_->{scr}{id}, 't'), width => $w, height => $h, alt => "Screenshot $_->{scr}{id}";
} for $rel{$r->{id}}->@*;
}
}
@@ -719,7 +918,7 @@ sub screenshots_ {
sub tags_ {
my($v) = @_;
if(!$v->{tags}->@*) {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Tags';
p_ 'This VN has no tags assigned to it (yet).';
};
@@ -729,9 +928,9 @@ sub tags_ {
my %tags = map +($_->{id},$_), $v->{tags}->@*;
my $parents = tuwf->dbAlli("
WITH RECURSIVE parents (tag, child) AS (
- SELECT tag::int, NULL::int FROM (VALUES", sql_join(',', map sql('(',\$_,')'), keys %tags), ") AS x(tag)
+ SELECT tag::vndbid, NULL::vndbid FROM (VALUES", sql_join(',', map sql('(',\$_,')'), keys %tags), ") AS x(tag)
UNION
- SELECT tp.parent, tp.tag FROM tags_parents tp, parents a WHERE a.tag = tp.tag
+ SELECT 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"
);
@@ -750,24 +949,30 @@ sub tags_ {
__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;
- $_->{spoiler} = $_->{spoiler} > 1.3 ? 2 : $_->{spoiler} > 0.4 ? 1 : 0 for values %tags;
my $view = viewget;
my sub rec {
my($lvl, $t) = @_;
- return if $t->{spoiler} > $view->{spoilers};
+ return if ($t->{override}//$t->{spoiler}) > $view->{spoilers};
li_ class => "tagvnlist-top", sub {
- h3_ sub { a_ href => "/g$t->{id}", $t->{name} }
+ 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});
- b_ class => 'grayedout', '━━'x($lvl-1).' ' if $lvl > 1;
- a_ href => "/g$t->{id}", $t->{rating} ? () : (class => 'parent'), $t->{name};
+ 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}) {
@@ -775,8 +980,8 @@ sub tags_ {
}
}
- div_ class => 'mainbox', sub {
- my $max_spoil = max map $_->{spoiler}, values %tags;
+ 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';
@@ -800,7 +1005,7 @@ TUWF::get qr{/$RE{vrev}}, sub {
enrich_item $v, 1;
- framework_ title => $v->{title}, index => !tuwf->capture('rev'), dbobj => $v, hiddenmsg => 1, js => 1, og => og($v),
+ 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;
@@ -820,7 +1025,7 @@ TUWF::get qr{/$RE{vid}/tags}, sub {
enrich_vn $v;
- framework_ title => $v->{title}, index => 1, dbobj => $v, hiddenmsg => 1,
+ framework_ title => $v->{title}[1], index => 1, dbobj => $v, hiddenmsg => 1,
sub {
infobox_ $v, 1;
tabs_ $v, 'tags';
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
index 26abcfd7..367d95f0 100644
--- a/lib/VNWeb/VN/Tagmod.pm
+++ b/lib/VNWeb/VN/Tagmod.pm
@@ -7,19 +7,22 @@ my $FORM = {
id => { vndbid => 'v' },
title => { _when => 'out' },
tags => { sort_keys => 'id', aoh => {
- id => { id => 1 },
+ id => { vndbid => 'g' },
vote => { int => 1, enum => [ -3..3 ] },
- spoil => { required => 0, uint => 1, enum => [ 0..2 ] },
+ spoil => { default => undef, uint => 1, enum => [ 0..2 ] },
+ lie => { undefbool => 1 },
overrule => { anybool => 1 },
- notes => { required => 0, default => '', maxlength => 1000 },
+ 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' },
- state => { _when => 'out', uint => 1 },
+ hidden => { _when => 'out', anybool => 1 },
+ locked => { _when => 'out', anybool => 1 },
applicable => { _when => 'out', anybool => 1 },
} },
mod => { _when => 'out', anybool => 1 },
@@ -28,9 +31,13 @@ my $FORM = {
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 !auth->permTag;
+ return elm_Unauth if !can_tag;
$tags = [ grep $_->{vote}, @$tags ];
$_->{overrule} = 0 for auth->permTagmod ? () : @$tags;
@@ -40,7 +47,7 @@ elm_api Tagmod => $FORM_OUT, $FORM_IN, sub {
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 state <> 1 AND applicable AND id IN'
+ SELECT id, 1 as exists FROM tags WHERE NOT (hidden AND locked) AND applicable AND id IN'
), $tags;
$tags = [ grep $_->{exists}, @$tags ];
@@ -52,7 +59,9 @@ elm_api Tagmod => $FORM_OUT, $FORM_IN, sub {
# Add & update tags
for(@$tags) {
- my $row = { uid => auth->uid, vid => $id, tag => $_->{id}, vote => $_->{vote}, spoiler => $_->{spoil}, ignore => ($_->{overruled} && !$_->{overrule})?1:0, notes => $_->{notes} };
+ 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};
}
@@ -67,23 +76,30 @@ elm_api Tagmod => $FORM_OUT, $FORM_IN, sub {
TUWF::get qr{/$RE{vid}/tagmod}, sub {
- my $v = tuwf->dbRowi('SELECT id, title, hidden AS entry_hidden, locked AS entry_locked FROM vn WHERE id =', \tuwf->capture('id'));
+ my $v = dbobj tuwf->capture('id');
return tuwf->resNotFound if !$v->{id} || (!auth->permDbmod && $v->{entry_hidden});
- return tuwf->resDenied if !auth->permTag;
+ return tuwf->resDenied if !can_tag;
my $tags = tuwf->dbAlli('
- SELECT t.id, t.name, t.cat, count(*) as count, t.state, t.applicable
- , coalesce(avg(CASE WHEN tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) THEN NULL ELSE tv.vote END), 0) as rating
- , coalesce(avg(CASE WHEN tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) THEN NULL ELSE tv.spoiler END), t.defaultspoil) as spoiler
- , bool_or(tv.ignore) as overruled
- FROM tags t
- JOIN tags_vn tv ON tv.tag = t.id
- LEFT JOIN users u ON u.id = tv.uid
- WHERE tv.vid =', \$v->{id}, '
- GROUP BY t.id, t.name, t.cat
+ 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, ignore, notes FROM tags_vn WHERE', { uid => auth->uid, vid => $v->{id} } }, $tags;
+ 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;
@@ -91,13 +107,14 @@ TUWF::get qr{/$RE{vid}/tagmod}, sub {
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}", dbobj => $v, tab => 'tagmod', sub {
- elm_ 'Tagmod' => $FORM_OUT, { id => $v->{id}, title => $v->{title}, tags => $tags, mod => auth->permTagmod };
+ 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 };
};
};
diff --git a/lib/VNWeb/VN/Votes.pm b/lib/VNWeb/VN/Votes.pm
index a5bce3f7..08813671 100644
--- a/lib/VNWeb/VN/Votes.pm
+++ b/lib/VNWeb/VN/Votes.pm
@@ -8,7 +8,7 @@ sub listing_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [ $count, 50 ], 't';
- div_ class => 'mainbox browse votelist', sub {
+ article_ class => 'browse votelist', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'date', $opt, \&url; debug_ $lst };
@@ -19,8 +19,8 @@ sub listing_ {
td_ class => 'tc1', fmtdate $_->{date};
td_ class => 'tc2', fmtvote $_->{vote};
td_ class => 'tc3', sub {
- b_ class => 'grayedout', 'hidden' if $_->{hide_list};
- user_ $_ if !$_->{hide_list};
+ small_ 'hidden' if $_->{c_private};
+ user_ $_ if !$_->{c_private};
};
} for @$lst;
};
@@ -30,9 +30,8 @@ sub listing_ {
TUWF::get qr{/$RE{vid}/votes}, sub {
- my $id = tuwf->capture('id');
- my $v = tuwf->dbRowi('SELECT id, title, hidden AS entry_hidden, locked AS entry_locked FROM vn WHERE id =', \$id);
- return tuwf->resNotFound if !$v->{id} || $v->{hidden};
+ my $v = dbobj tuwf->capture('id');
+ return tuwf->resNotFound if !$v->{id} || $v->{entry_hidden};
my $opt = tuwf->validate(get =>
p => { page => 1 },
@@ -48,17 +47,16 @@ TUWF::get qr{/$RE{vid}/votes}, sub {
my $count = tuwf->dbVali('SELECT COUNT(*)', $fromwhere);
- my $hide_list = 'NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private)';
my $lst = tuwf->dbPagei({results => 50, page => $opt->{p}},
- 'SELECT uv.vote,', sql_totime('uv.vote_date'), 'as date, ', sql_user(), ", $hide_list AS hide_list
- ", $fromwhere, 'ORDER BY', sprintf
- { date => 'uv.vote_date %s', vote => 'uv.vote %s', title => "(CASE WHEN $hide_list THEN NULL ELSE u.username END) %s, uv.vote_date" }->{$opt->{s}},
+ 'SELECT uv.vote, uv.c_private, ', sql_totime('uv.vote_date'), 'as date, ', sql_user(),
+ $fromwhere, 'ORDER BY', sprintf
+ { date => 'uv.vote_date %s, uv.vote', vote => 'uv.vote %s, uv.vote_date', title => "(CASE WHEN uv.c_private THEN NULL ELSE u.username END) %s, uv.vote_date" }->{$opt->{s}},
{ a => 'ASC', d => 'DESC' }->{$opt->{o}}
);
- framework_ title => "Votes for $v->{title}", dbobj => $v, sub {
- div_ class => 'mainbox', sub {
- h1_ "Votes for $v->{title}";
+ framework_ title => "Votes for $v->{title}[1]", dbobj => $v, sub {
+ article_ sub {
+ h1_ "Votes for $v->{title}[1]";
p_ 'No votes to list. :(' if !@$lst;
};
listing_ $opt, $count, $lst if @$lst;
diff --git a/lib/VNWeb/Validation.pm b/lib/VNWeb/Validation.pm
index cf95734c..a79a0441 100644
--- a/lib/VNWeb/Validation.pm
+++ b/lib/VNWeb/Validation.pm
@@ -2,7 +2,6 @@ package VNWeb::Validation;
use v5.26;
use TUWF 'uri_escape';
-use PWLookup;
use VNDB::Types;
use VNDB::Config;
use VNWeb::Auth;
@@ -13,8 +12,11 @@ use Carp 'croak';
use Exporter 'import';
our @EXPORT = qw/
+ %RE
samesite
- is_insecurepass
+ is_api
+ is_unique_username
+ ipinfo
form_compile
form_changed
validate_dbid
@@ -23,6 +25,35 @@ our @EXPORT = qw/
/;
+# 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.
@@ -33,21 +64,24 @@ TUWF::set custom_validations => {
my $re = qr/^(?:$types)[1-9][0-9]{0,6}$/;
+{ _analyze_regex => $re, func => sub { $_[0] = "${types}$_[0]" if !$multi && $_[0] =~ /^[1-9][0-9]{0,6}$/; return $_[0] =~ $re } }
},
- editsum => { required => 1, length => [ 2, 5000 ] },
- page => { uint => 1, min => 1, max => 1000, required => 0, default => 1, onerror => 1 },
- upage => { uint => 1, min => 1, required => 0, default => 1, onerror => 1 }, # pagination without a maximum
- username => { regex => qr/^(?!-*[a-z][0-9]+-*$)[a-z0-9-]*$/, minlength => 2, maxlength => 15 },
+ 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 => { required => 0, default => 0, func => sub { $_[0] = 0 if !length $_[0]; $_[0] eq 0 || gtintype($_[0]) } },
+ gtin => { default => 0, func => sub { $_[0] = 0 if !length $_[0]; $_[0] eq 0 || gtintype($_[0]) } },
rdate => { uint => 1, func => \&_validate_rdate },
- fuzzyrdate => { func => \&_validate_fuzzyrdate },
- # A tri-state bool, returns undef if not present or empty, normalizes to 0/1 otherwise
- undefbool => { required => 0, default => undef, func => sub { $_[0] = $_[0] ? 1 : 0; 1 } },
+ 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 { +{ required => 0, default => undef, type => 'array', scalar => 1, values => $_[0] } },
+ undefarray => sub { +{ default => undef, type => 'array', scalar => 1, values => $_[0] } },
# Accepts a user-entered vote string (or '-' or empty) and converts that into a DB vote number (or undef) - opposite of fmtvote()
- vnvote => { required => 0, default => undef, regex => qr/^(?:|-|[1-9]|10|[1-9]\.[0-9]|10\.0)$/, func => sub { $_[0] = $_[0] eq '-' ? undef : 10*$_[0]; 1 } },
+ vnvote => { default => undef, regex => qr/^(?:|-|[1-9]|10|[1-9]\.[0-9]|10\.0)$/, func => sub { $_[0] = $_[0] eq '-' ? undef : 10*$_[0]; 1 } },
# Sort an array by the listed hash keys, using string comparison on each key
sort_keys => sub {
my @keys = ref $_[0] eq 'ARRAY' ? @{$_[0]} : $_[0];
@@ -61,6 +95,16 @@ TUWF::set custom_validations => {
},
# Sorted and unique array-of-hashes (default order is sort_keys on the sorted keys...)
aoh => sub { +{ type => 'array', unique => 1, sort_keys => [sort keys %{$_[0]}], values => { type => 'hash', keys => $_[0] } } },
+ # Fields query parameter for the API, supports multiple values or comma-delimited list, returns a hash.
+ fields => sub {
+ my %keys = map +($_,1), ref $_[0] eq 'ARRAY' ? @{$_[0]} : $_[0];
+ +{ default => {}, type => 'array', values => {}, scalar => 1, func => sub {
+ my @l = map split(/\s*,\s*/,$_), @{$_[0]};
+ return 0 if grep !$keys{$_}, @l;
+ $_[0] = { map +($_,1), @l };
+ 1;
+ } }
+ },
};
sub _validate_rdate {
@@ -81,9 +125,9 @@ sub _validate_rdate {
sub _validate_fuzzyrdate {
- $_[0] = 0 if $_[0] =~ /^unknown$/;
- $_[0] = 1 if $_[0] =~ /^today$/;
- $_[0] = 99999999 if $_[0] =~ /^tba$/;
+ $_[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})$/;
@@ -95,9 +139,41 @@ sub _validate_fuzzyrdate {
# 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\//) }
-sub is_insecurepass {
- config->{password_db} && PWLookup::lookup(config->{password_db}, shift)
+# 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');
}
@@ -214,12 +290,12 @@ sub validate_dbid {
sub can_edit {
my($type, $entry) = @_;
- return auth->permUsermod || auth->permDbmod || auth->permImgmod || auth->permBoardmod || auth->permTagmod || (auth && $entry->{id} eq auth->uid) if $type eq 'u';
+ return auth->permUsermod || (auth && $entry->{id} eq auth->uid) if $type eq 'u';
return auth->permDbmod if $type eq 'd';
if($type eq 't') {
- return 0 if !auth->permBoard;
return 1 if auth->permBoardmod;
+ return 0 if !auth->permBoard || (global_settings->{lockdown_board} && !auth->isMod);
if(!$entry->{id}) {
# Allow at most 5 new threads per day per user.
return auth && tuwf->dbVali('SELECT count(*) < ', \5, 'FROM threads_posts WHERE num = 1 AND date > NOW()-\'1 day\'::interval AND uid =', \auth->uid);
@@ -229,24 +305,31 @@ sub can_edit {
} else {
die "Can't do authorization test when hidden/date/user_id fields aren't present"
if !exists $entry->{hidden} || !exists $entry->{date} || !exists $entry->{user_id};
- return auth && $entry->{user_id} eq auth->uid && !$entry->{hidden} && $entry->{date} > time-config->{board_edit_time};
+ # beware: for threads the 'hidden' field is a non-undef boolean flag, for posts it is a possibly-undef text field.
+ my $hidden = $entry->{id} =~ /^t/ && $entry->{num} == 1 ? $entry->{hidden} : defined $entry->{hidden};
+ return auth && $entry->{user_id} eq auth->uid && !$hidden && $entry->{date} > time-config->{board_edit_time};
}
}
if($type eq 'w') {
return 1 if auth->permBoardmod;
- return auth->permReview if !$entry->{id};
+ 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});
+ return 1 if auth->permTagmod;
+ return auth->permEdit if !$entry->{id};
+ die if !exists $entry->{entry_hidden} || !exists $entry->{entry_locked};
+ # Let users edit their own tags/traits while it's still pending approval.
+ return auth && $entry->{entry_hidden} && !$entry->{entry_locked}
+ && tuwf->dbVali('SELECT 1 FROM changes WHERE itemid =', \$entry->{id}, 'AND rev = 1 AND requester =', \auth->uid);
}
die "Can't do authorization test when entry_hidden/entry_locked fields aren't present"
if $entry->{id} && (!exists $entry->{entry_hidden} || !exists $entry->{entry_locked});
- auth->permDbmod || (auth->permEdit && !($entry->{entry_hidden} || $entry->{entry_locked}));
+ auth->permDbmod || (auth->permEdit && !global_settings->{lockdown_edit} && !($entry->{entry_hidden} || $entry->{entry_locked}));
}
@@ -303,4 +386,80 @@ sub viewset {
'-'.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
index 852719c2..15ae372d 100644
--- a/sql/all.sql
+++ b/sql/all.sql
@@ -1,6 +1,7 @@
-- 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
diff --git a/sql/data.sql b/sql/data.sql
index 627de164..1fd960f1 100644
--- a/sql/data.sql
+++ b/sql/data.sql
@@ -1,4 +1,6 @@
-INSERT INTO users (id, username, mail, notify_dbedit) VALUES ('u1', 'multi', 'multi@vndb.org', FALSE);
+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
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
index 30a47a42..de0a45c3 100644
--- a/sql/func.sql
+++ b/sql/func.sql
@@ -4,49 +4,250 @@
-- 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)
+-- *_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.
--- 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;
+-- 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;
--- 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 $$
+
+
+-- 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
- 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
+ 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
@@ -55,18 +256,20 @@ CREATE OR REPLACE FUNCTION update_vncache(vndbid) RETURNS void AS $$
FROM releases r
JOIN releases_vn rv ON r.id = rv.id
WHERE rv.vid = $1
- AND r.type <> 'trial'
+ 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_lang rl
+ 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 r.type <> 'trial'
+ 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
@@ -78,50 +281,76 @@ CREATE OR REPLACE FUNCTION update_vncache(vndbid) RETURNS void AS $$
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 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 vn.c_popularity, c_rating, c_votecount, c_pop_rank and c_rat_rank
+-- 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)
- ), avgcount(avgcount) AS ( -- Average number of votes per VN
- SELECT COUNT(vote)::real/COUNT(DISTINCT vid)::real FROM votes
), avgavg(avgavg) AS ( -- Average vote average
SELECT AVG(a)::real FROM (SELECT AVG(vote) FROM votes GROUP BY vid) x(a)
- ), ratings(vid, count, rating) AS ( -- Ratings and vote counts
- SELECT vid, COALESCE(COUNT(uid), 0),
- COALESCE(
- ((SELECT avgcount FROM avgcount) * (SELECT avgavg FROM avgavg) + SUM(vote)::real) /
- ((SELECT avgcount FROM avgcount) + COUNT(uid)::real),
- 0)
+ ), 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
- ), popularities(vid, win) AS ( -- Popularity scores (before normalization)
- SELECT vid, SUM(rank)
- FROM (
- SELECT uid, vid, ((rank() OVER (PARTITION BY uid ORDER BY vote))::real - 1) ^ 0.36788 FROM votes
- ) x(uid, vid, rank)
- GROUP BY vid
- ), stats(vid, rating, count, popularity, pop_rank, rat_rank) AS ( -- Combined stats
- SELECT v.id, COALESCE(round(r.rating::numeric, 1), 0)::real, COALESCE(r.count, 0)
- , round((p.win/(SELECT MAX(win) FROM popularities))::numeric, 4)::real
- , CASE WHEN p.win IS NULL THEN NULL ELSE rank() OVER(ORDER BY hidden, p.win DESC NULLS LAST) END
+ ), 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 ratings r ON r.vid = v.id
- LEFT JOIN popularities p ON p.vid = v.id AND p.win > 0
+ LEFT JOIN capped r ON r.vid = v.id
)
- UPDATE vn SET c_rating = rating, c_votecount = count, c_popularity = popularity, c_pop_rank = pop_rank, c_rat_rank = rat_rank
+ 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_popularity, c_pop_rank, c_rat_rank) IS DISTINCT FROM (rating, count, popularity, pop_rank, rat_rank);
+ 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;
@@ -142,20 +371,23 @@ BEGIN
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.*,
- CASE WHEN EXISTS(
+ 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)::real
+ 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)::real AS sexual_avg
- , round(avg(violence) FILTER(WHERE NOT iv.ignore), 2)::real AS violence_avg
- , round(stddev_pop(sexual) FILTER(WHERE NOT iv.ignore), 2)::real AS sexual_stddev
- , round(stddev_pop(violence) FILTER(WHERE NOT iv.ignore), 2)::real AS violence_stddev
+ , 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
@@ -176,8 +408,8 @@ 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 r2.id IS NULL), 0)
- , COALESCE(SUM(CASE WHEN rv.overrule THEN 100000 WHEN rv.ip IS NULL THEN 100 ELSE 1 END) FILTER(WHERE NOT rv.vote AND u.ign_votes IS DISTINCT FROM true AND r2.id IS NULL), 0)
+ , 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
@@ -196,13 +428,12 @@ CREATE OR REPLACE FUNCTION update_users_ulist_stats(vndbid) RETURNS void AS $$
BEGIN
WITH cnt(uid, votes, vns, wish) AS (
SELECT u.id
- , COUNT(DISTINCT uvl.vid) FILTER (WHERE NOT ul.private AND uv.vote IS NOT NULL) -- Voted
- , COUNT(DISTINCT uvl.vid) FILTER (WHERE NOT ul.private AND ul.id NOT IN(5,6)) -- Labelled, but not wishlish/blacklist
- , COUNT(DISTINCT uvl.vid) FILTER (WHERE NOT ul.private AND ul.id = 5) -- Wishlist
+ , 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_labels uvl ON uvl.uid = u.id
- LEFT JOIN ulist_labels ul ON ul.id = uvl.lbl AND ul.uid = u.id
- LEFT JOIN ulist_vns uv ON uv.uid = u.id AND uv.vid = uvl.vid
+ 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
@@ -212,99 +443,163 @@ $$ LANGUAGE plpgsql; -- Don't use "LANGUAGE SQL" here; Make sure to generate a n
--- Recalculate tags_vn_inherit.
+-- 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
- IF uvid IS NULL THEN
- DROP INDEX IF EXISTS tags_vn_inherit_tag_vid;
- TRUNCATE tags_vn_inherit;
- ELSE
- DELETE FROM tags_vn_inherit WHERE vid = uvid;
- END IF;
-
- INSERT INTO tags_vn_inherit (tag, vid, rating, spoiler)
- -- Group votes to generate a list of directly-upvoted (vid, tag) pairs.
- -- This is essentually the same as the tag listing on VN pages.
- WITH RECURSIVE t_avg(tag, vid, vote, spoiler) AS (
- SELECT tv.tag, tv.vid, AVG(tv.vote)::real, CASE WHEN COUNT(tv.spoiler) = 0 THEN MIN(t.defaultspoil) ELSE AVG(tv.spoiler)::real END
- FROM tags_vn tv
- JOIN tags t ON t.id = tv.tag
- LEFT JOIN users u ON u.id = tv.uid
- WHERE NOT tv.ignore AND t.state = 2
- 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 AVG(tv.vote) > 0
- -- Add parent tags
- ), t_all(lvl, tag, vid, vote, spoiler) AS (
- SELECT 15, * FROM t_avg
- UNION ALL
- SELECT ta.lvl-1, tp.parent, ta.vid, ta.vote, ta.spoiler
- FROM t_all ta
- JOIN tags_parents tp ON tp.tag = ta.tag
- WHERE ta.lvl > 0
- )
- -- Merge
- SELECT tag, vid, AVG(vote)
- , (CASE WHEN MIN(spoiler) > 1.3 THEN 2 WHEN MIN(spoiler) > 0.4 THEN 1 ELSE 0 END)::smallint
- FROM t_all
- WHERE tag IN(SELECT id FROM tags WHERE searchable)
- GROUP BY tag, vid;
+ -- 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
- CREATE INDEX tags_vn_inherit_tag_vid ON tags_vn_inherit (tag, vid);
UPDATE tags SET c_items = (SELECT COUNT(*) FROM tags_vn_inherit WHERE tag = id);
END IF;
-
RETURN;
END;
-$$ LANGUAGE plpgsql SECURITY DEFINER;
+$$ 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
- IF ucid IS NULL THEN
- DROP INDEX IF EXISTS traits_chars_tid;
- TRUNCATE traits_chars;
- ELSE
- DELETE FROM traits_chars WHERE cid = ucid;
- END IF;
-
- INSERT INTO traits_chars (tid, cid, spoil)
+ 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 traits_chars_all(lvl, tid, cid, spoiler) AS (
- SELECT 15, tid, ct.id, spoil
+ 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
- FROM traits_chars_all tc
- JOIN traits_parents tp ON tp.trait = tc.tid
+ 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 t.state = 2
+ 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 AS spoiler
- FROM traits_chars_all
+ , (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;
+ 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
- CREATE INDEX traits_chars_tid ON traits_chars (tid);
UPDATE traits SET c_items = (SELECT COUNT(*) FROM traits_chars WHERE tid = id);
END IF;
RETURN;
END;
-$$ LANGUAGE plpgsql SECURITY DEFINER;
+$$ 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
@@ -315,8 +610,8 @@ BEGIN
UPDATE stats_cache SET count = (SELECT COUNT(*) FROM producers WHERE hidden = FALSE) WHERE section = 'producers';
UPDATE stats_cache SET count = (SELECT COUNT(*) FROM chars WHERE hidden = FALSE) WHERE section = 'chars';
UPDATE stats_cache SET count = (SELECT COUNT(*) FROM staff WHERE hidden = FALSE) WHERE section = 'staff';
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM tags WHERE state = 2) WHERE section = 'tags';
- UPDATE stats_cache SET count = (SELECT COUNT(*) FROM traits WHERE state = 2) WHERE section = 'traits';
+ UPDATE stats_cache SET count = (SELECT COUNT(*) FROM 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;
@@ -335,37 +630,58 @@ CREATE OR REPLACE FUNCTION ulist_labels_create(vndbid) RETURNS void AS $$
$$ LANGUAGE SQL;
--- Returns the title and (where applicable) uid of the user who created the thing for almost every supported vndbid + num.
--- While a function like this would be super useful in many places, it's too slow to be used in large or popular listings.
--- A VIEW that can be joined would offer much better optimization possibilities, but I've not managed to write that in a performant way yet.
--- A MATERIALIZED VIEW would likely be the fastest approach, but keeping that up-to-date seems like a pain.
+-- 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.
--
--- Not currently supported: i#, g#, u#, ch#, cv#, sf#
-CREATE OR REPLACE FUNCTION item_info(id vndbid, num int) RETURNS TABLE(title text, uid vndbid) AS $$
- -- x#.#
- SELECT v.title, h.requester FROM changes h JOIN vn_hist v ON h.id = v.chid WHERE vndbid_type($1) = 'v' AND h.itemid = $1 AND $2 IS NOT NULL AND h.rev = $2
- UNION ALL SELECT r.title, h.requester FROM changes h JOIN releases_hist r ON h.id = r.chid WHERE vndbid_type($1) = 'r' AND h.itemid = $1 AND $2 IS NOT NULL AND h.rev = $2
- UNION ALL SELECT p.name, h.requester FROM changes h JOIN producers_hist p ON h.id = p.chid WHERE vndbid_type($1) = 'p' AND h.itemid = $1 AND $2 IS NOT NULL AND h.rev = $2
- UNION ALL SELECT c.name, h.requester FROM changes h JOIN chars_hist c ON h.id = c.chid WHERE vndbid_type($1) = 'c' AND h.itemid = $1 AND $2 IS NOT NULL AND h.rev = $2
- UNION ALL SELECT d.title, h.requester FROM changes h JOIN docs_hist d ON h.id = d.chid WHERE vndbid_type($1) = 'd' AND h.itemid = $1 AND $2 IS NOT NULL AND h.rev = $2
- UNION ALL SELECT sa.name, h.requester FROM changes h JOIN staff_hist s ON h.id = s.chid JOIN staff_alias_hist sa ON sa.chid = s.chid AND sa.aid = s.aid WHERE vndbid_type($1) = 's' AND h.itemid = $1 AND $2 IS NOT NULL AND h.rev = $2
+-- 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#
- UNION ALL SELECT title, NULL FROM vn WHERE vndbid_type($1) = 'v' AND id = $1 AND $2 IS NULL
- UNION ALL SELECT title, NULL FROM releases WHERE vndbid_type($1) = 'r' AND id = $1 AND $2 IS NULL
- UNION ALL SELECT name, NULL FROM producers WHERE vndbid_type($1) = 'p' AND id = $1 AND $2 IS NULL
- UNION ALL SELECT name, NULL FROM chars WHERE vndbid_type($1) = 'c' AND id = $1 AND $2 IS NULL
- UNION ALL SELECT title, NULL FROM docs WHERE vndbid_type($1) = 'd' AND id = $1 AND $2 IS NULL
- UNION ALL SELECT sa.name, NULL FROM staff s JOIN staff_alias sa ON sa.aid = s.aid WHERE vndbid_type($1) = 's' AND s.id = $1 AND $2 IS NOT NULL AND $2 IS NULL
- -- t#
- UNION ALL SELECT title, NULL FROM threads WHERE vndbid_type($1) = 't' AND id = $1 AND $2 IS NULL
- -- t#.#
- UNION ALL SELECT t.title, tp.uid FROM threads t JOIN threads_posts tp ON tp.tid = t.id WHERE vndbid_type($1) = 't' AND t.id = $1 AND $2 IS NOT NULL AND tp.num = $2
- -- w#
- UNION ALL SELECT v.title, w.uid FROM reviews w JOIN vn v ON v.id = w.vid WHERE vndbid_type($1) = 'w' AND w.id = $1 AND $2 IS NULL
- -- w#.#
- UNION ALL SELECT v.title, wp.uid FROM reviews w JOIN vn v ON v.id = w.vid JOIN reviews_posts wp ON wp.id = w.id WHERE vndbid_type($1) = 'w' AND w.id = $1 AND $2 IS NOT NULL AND wp.num = $2
-$$ LANGUAGE SQL ROWS 1;
-
+ 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;
@@ -384,7 +700,6 @@ BEGIN
CREATE TEMPORARY TABLE edit_revision (
itemid vndbid,
requester vndbid,
- ip inet,
comments text,
ihid boolean,
ilock boolean
@@ -406,24 +721,15 @@ DECLARE
BEGIN
SELECT id INTO xoldchid FROM changes WHERE itemid = nitemid AND rev = nrev-1;
- -- Set c_search to NULL and notify when
- -- 1. A new VN entry is created
- -- 2. The vn title/original/alias has changed
- IF vndbid_type(nitemid) = '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 = nchid)
- THEN
- UPDATE vn SET c_search = NULL WHERE id = nitemid;
- NOTIFY vnsearch;
- END IF;
+ -- Update search_cache
+ IF vndbid_type(nitemid) IN('v','r','c','p','s','g','i') THEN
+ PERFORM update_search(nitemid);
END IF;
- -- Set related vn.c_search columns to NULL and notify when
+ -- Update search_cache for related VNs when
-- 1. A new release is created
-- 2. A release has been hidden or unhidden
- -- 3. The release title/original has changed
+ -- 3. The releases_titles have changed
-- 4. The releases_vn table differs from a previous revision
IF vndbid_type(nitemid) = 'r' THEN
IF -- 1.
@@ -431,16 +737,43 @@ BEGIN
-- 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 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 = nchid) OR
+ 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
- UPDATE vn SET c_search = NULL WHERE id IN(SELECT vid FROM releases_vn_hist WHERE chid IN(nchid, xoldchid));
- NOTIFY vnsearch;
+ 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
@@ -498,8 +831,9 @@ CREATE OR REPLACE FUNCTION notify(iid vndbid, num integer, uid vndbid) RETURNS T
FROM (
-- pm
- SELECT 'pm'::notification_ntype, tb.iid
+ 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)
@@ -511,7 +845,7 @@ CREATE OR REPLACE FUNCTION notify(iid vndbid, num integer, uid vndbid) RETURNS T
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')
+ AND $2 > 1 AND vndbid_type($1) IN('v', 'r', 'p', 'c', 's', 'd', 'g', 'i')
-- listdel
UNION
@@ -532,7 +866,7 @@ CREATE OR REPLACE FUNCTION notify(iid vndbid, num integer, uid vndbid) RETURNS T
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')
+ 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)
@@ -541,7 +875,7 @@ CREATE OR REPLACE FUNCTION notify(iid vndbid, num integer, uid vndbid) RETURNS T
UNION
SELECT 'subedit', ns.uid
FROM notification_subs ns
- WHERE $2 > 1 AND vndbid_type($1) IN('v', 'r', 'p', 'c', 's', 'd')
+ 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
@@ -601,8 +935,8 @@ CREATE OR REPLACE FUNCTION notify(iid vndbid, num integer, uid vndbid) RETURNS T
FROM notification_subs
WHERE subapply AND vndbid_type($1) = 'c' AND $2 IS NOT NULL
AND iid IN(
- WITH new(tid) AS (SELECT vndbid('i', tid) FROM chars_traits_hist WHERE chid = (SELECT id FROM changes WHERE itemid = $1 AND rev = $2)),
- old(tid) AS (SELECT vndbid('i', tid) FROM chars_traits_hist WHERE chid = (SELECT id FROM changes WHERE itemid = $1 AND $2 > 1 AND rev = $2-1))
+ 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)
)
@@ -627,58 +961,63 @@ $$ LANGUAGE SQL;
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 WHERE id = $1
+ FROM users_shadow WHERE id = $1
$$ LANGUAGE SQL SECURITY DEFINER;
--- Create a new web session for this user (uid, scryptpass, token)
-CREATE OR REPLACE FUNCTION user_login(vndbid, bytea, bytea) RETURNS boolean AS $$
- INSERT INTO sessions (uid, token, expires, type) SELECT $1, $3, NOW() + '1 month', 'web' FROM users
- WHERE length($2) = 46 AND length($3) = 20
- AND id = $1 AND passwd = $2
+-- 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 = 'web'
+ DELETE FROM sessions WHERE uid = $1 AND token = $2 AND type IN('web','api')
$$ LANGUAGE SQL SECURITY DEFINER;
--- Returns true if the given session token is valid.
--- As a side effect, this also extends the expiration time of web sessions.
-CREATE OR REPLACE FUNCTION user_isvalidsession(vndbid, bytea, session_type) RETURNS bool AS $$
+-- 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 = 'web'
+ WHERE uid = $1 AND token = $2 AND type = $3 AND $3 IN('web', 'api')
AND expires < NOW() + '1 month'::interval - '6 hours'::interval;
- SELECT true FROM sessions WHERE uid = $1 AND token = $2 AND type = $3 AND expires > NOW();
+ -- 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 SETOF vndbid AS $$
- SELECT id FROM users WHERE lower(mail) = lower($1)
-$$ LANGUAGE SQL SECURITY DEFINER;
+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;
--- Create a password reset token. args: email, token. Returns: user id.
+-- 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) RETURNS vndbid AS $$
+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
- WHERE lower(mail) = lower($1) AND length($2) = 20 AND NOT perm_usermod
- RETURNING uid
+ 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 SET passwd = $3
+ UPDATE users_shadow SET passwd = $3
WHERE id = $1
AND length($3) = 46
AND ( (passwd = $2 AND length($2) = 46)
@@ -686,7 +1025,7 @@ CREATE OR REPLACE FUNCTION user_setpass(vndbid, bytea, bytea) RETURNS boolean AS
)
RETURNING id
), del AS( -- Not referenced, but still guaranteed to run
- DELETE FROM sessions WHERE uid IN(SELECT id FROM upd)
+ DELETE FROM sessions WHERE uid IN(SELECT id FROM upd) AND type <> 'api2'
)
SELECT true FROM upd
$$ LANGUAGE SQL SECURITY DEFINER;
@@ -695,7 +1034,7 @@ $$ 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
+ 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)
@@ -705,7 +1044,16 @@ $$ 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 WHERE id = $1 AND user_isauth($1, $2, $3)
+ 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;
@@ -723,23 +1071,127 @@ CREATE OR REPLACE FUNCTION user_setmail_confirm(vndbid, bytea) RETURNS boolean A
WITH u(mail) AS (
DELETE FROM sessions WHERE uid = $1 AND token = $2 AND type = 'mail' AND expires > NOW() RETURNING mail
)
- UPDATE users SET mail = (SELECT mail FROM u) WHERE id = $1 AND EXISTS(SELECT 1 FROM u) RETURNING true;
+ 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 SET perm_usermod = $4 WHERE id = $1 AND user_isauth(NULL, $2, $3)
+ 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 SET passwd = $4 WHERE id = $1 AND user_isauth(NULL, $2, $3) AND length($4) = 46 RETURNING id
+ 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 SET mail = $4 WHERE id = $1 AND user_isauth(NULL, $2, $3)
+ 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
index 783f3699..a67442be 100644
--- a/sql/perms.sql
+++ b/sql/perms.sql
@@ -1,14 +1,23 @@
+-- vndb
+-- these are used by util/dbdump.pl
+
+GRANT EXECUTE ON FUNCTION pg_wal_replay_pause TO vndb;
+GRANT EXECUTE ON FUNCTION pg_wal_replay_resume TO vndb;
+
-- vndb_site
DROP OWNED BY vndb_site;
GRANT CONNECT, TEMP ON DATABASE :DBNAME TO vndb_site;
+GRANT USAGE ON SCHEMA public TO vndb_site;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO vndb_site;
GRANT USAGE, 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;
@@ -16,20 +25,29 @@ GRANT SELECT, INSERT, DELETE ON chars_vns TO vndb_site;
GRANT SELECT, INSERT ON chars_vns_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON docs TO vndb_site;
GRANT SELECT, INSERT ON docs_hist TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON drm TO vndb_site;
+GRANT SELECT , UPDATE ON global_settings TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON images TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON image_votes TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON login_throttle TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON notification_subs TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON notifications TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON producers TO vndb_site;
+GRANT SELECT ON producerst TO vndb_site;
GRANT SELECT, INSERT ON producers_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON producers_relations TO vndb_site;
GRANT SELECT, INSERT ON producers_relations_hist TO vndb_site;
-GRANT SELECT ON quotes TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON quotes TO vndb_site;
+GRANT SELECT, INSERT ON quotes_log TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON quotes_votes TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON registration_throttle TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON releases TO vndb_site;
+GRANT SELECT ON releasest TO vndb_site;
GRANT SELECT, INSERT ON releases_hist TO vndb_site;
-GRANT SELECT, INSERT, DELETE ON releases_lang TO vndb_site;
-GRANT SELECT, INSERT ON releases_lang_hist TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON releases_drm TO vndb_site;
+GRANT SELECT, INSERT ON releases_drm_hist TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON releases_titles TO vndb_site;
+GRANT SELECT, INSERT ON releases_titles_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON releases_media TO vndb_site;
GRANT SELECT, INSERT ON releases_media_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON releases_platforms TO vndb_site;
@@ -39,62 +57,61 @@ 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, DELETE ON tags TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON tags_aliases TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON tags_parents TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON tags TO vndb_site;
+GRANT SELECT, INSERT ON tags_hist TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON tags_parents TO vndb_site;
+GRANT SELECT, INSERT ON tags_parents_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn TO vndb_site;
-GRANT SELECT ON tags_vn_inherit TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_direct TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_inherit TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads_boards TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads_poll_options TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads_poll_votes TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads_posts TO vndb_site;
GRANT INSERT ON trace_log TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON traits TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON traits TO vndb_site;
+GRANT SELECT, INSERT ON traits_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON traits_chars TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON traits_parents TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON traits_parents TO vndb_site;
+GRANT SELECT, INSERT ON traits_parents_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_labels TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns TO vndb_site;
-GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns_labels TO vndb_site;
-
--- users table is special; The 'perm_usermod', 'passwd' and 'mail' columns are
--- protected and can only be accessed through the user_* functions.
-GRANT SELECT ( id, username, registered, ip, ign_votes, email_confirmed, last_reports
- , perm_board, perm_boardmod, perm_dbmod, perm_edit, perm_imgvote, perm_tag, perm_tagmod, perm_usermod, perm_imgmod, perm_review
- , skin, customcss, notify_dbedit, notify_announce, notify_post, notify_comment
- , tags_all, tags_cont, tags_ero, tags_tech, spoilers, traits_sexual, max_sexual, max_violence
- , nodistract_can, nodistract_noads, nodistract_nofancy, support_can, support_enabled, uniname_can, uniname, pubskin_can, pubskin_enabled
- , ulist_votes, ulist_vnlist, ulist_wish, tableopts_c
- , c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags),
- INSERT ( username, mail, ip),
- UPDATE ( username, ign_votes, email_confirmed, last_reports
- , perm_board, perm_boardmod, perm_dbmod, perm_edit, perm_imgvote, perm_tag, perm_tagmod, perm_imgmod, perm_review
- , skin, customcss, notify_dbedit, notify_announce, notify_post, notify_comment
- , tags_all, tags_cont, tags_ero, tags_tech, spoilers, traits_sexual, max_sexual, max_violence
- , nodistract_can, nodistract_noads, nodistract_nofancy, support_can, support_enabled, uniname_can, uniname, pubskin_can, pubskin_enabled
- , ulist_votes, ulist_vnlist, ulist_wish, tableopts_c
- , c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags) ON users TO vndb_site;
-
+GRANT SELECT, INSERT, UPDATE ON users TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON users_prefs TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON users_prefs_tags TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON users_prefs_traits TO vndb_site;
+GRANT SELECT (id, perm_usermod, delete_at), INSERT (id, mail, ip) ON users_shadow TO vndb_site;
+GRANT SELECT, INSERT ON users_username_hist TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON users_traits TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON vn TO vndb_site;
GRANT SELECT, INSERT, DELETE ON vn_anime TO vndb_site;
GRANT SELECT, INSERT ON vn_anime_hist TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON vn_editions TO vndb_site;
+GRANT SELECT, INSERT ON vn_editions_hist TO vndb_site;
GRANT SELECT, INSERT ON vn_hist TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON vn_length_votes TO vndb_site;
GRANT SELECT, INSERT, DELETE ON vn_relations TO vndb_site;
GRANT SELECT, INSERT ON vn_relations_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON vn_screenshots TO vndb_site;
@@ -103,6 +120,9 @@ GRANT SELECT, INSERT, DELETE ON vn_seiyuu TO vndb_site;
GRANT SELECT, INSERT ON vn_seiyuu_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON vn_staff TO vndb_site;
GRANT SELECT, INSERT ON vn_staff_hist TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON vn_titles TO vndb_site;
+GRANT SELECT, INSERT ON vn_titles_hist TO vndb_site;
+GRANT SELECT ON vnt TO vndb_site;
GRANT SELECT, INSERT ON wikidata TO vndb_site;
@@ -113,12 +133,14 @@ GRANT SELECT, INSERT ON wikidata TO vndb_site;
DROP OWNED BY vndb_multi;
GRANT CONNECT, TEMP ON DATABASE :DBNAME TO vndb_multi;
+GRANT USAGE ON SCHEMA public TO vndb_multi;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO vndb_multi;
GRANT USAGE, 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;
@@ -129,24 +151,31 @@ GRANT SELECT ON image_votes TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON login_throttle TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON notifications TO vndb_multi;
GRANT SELECT, UPDATE ON producers TO vndb_multi;
+GRANT SELECT ON producerst TO vndb_multi;
GRANT SELECT ON producers_hist TO vndb_multi;
GRANT SELECT ON producers_relations TO vndb_multi;
-GRANT SELECT ON quotes TO vndb_multi;
+GRANT SELECT, UPDATE ON quotes TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON registration_throttle TO vndb_multi;
GRANT SELECT ON releases TO vndb_multi;
+GRANT SELECT ON releasest TO vndb_multi;
GRANT SELECT ON releases_hist TO vndb_multi;
-GRANT SELECT ON releases_lang TO vndb_multi;
+GRANT SELECT ON releases_titles TO vndb_multi;
+GRANT SELECT ON releases_titles_hist TO vndb_multi;
GRANT SELECT ON releases_media TO vndb_multi;
GRANT SELECT ON releases_platforms TO vndb_multi;
GRANT SELECT ON releases_producers TO vndb_multi;
GRANT SELECT ON releases_vn TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON 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 (expires) ON sessions TO vndb_multi;
+GRANT SELECT ON search_cache TO vndb_multi;
+GRANT SELECT (expires, type) ON sessions TO vndb_multi;
GRANT DELETE ON sessions TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_denpa TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_dlsite TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON shop_jastusa TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_jlist TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_mg TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_playasia TO vndb_multi;
@@ -154,34 +183,41 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON shop_playasia_gtin TO vndb_multi;
GRANT SELECT ON staff TO vndb_multi;
GRANT SELECT ON staff_alias TO vndb_multi;
GRANT SELECT ON staff_alias_hist TO vndb_multi;
+GRANT SELECT ON staff_aliast TO vndb_multi;
GRANT SELECT ON staff_hist TO vndb_multi;
GRANT SELECT, UPDATE ON stats_cache TO vndb_multi;
-GRANT SELECT ON tags TO vndb_multi;
-GRANT SELECT ON tags_aliases TO vndb_multi;
+GRANT SELECT, UPDATE ON tags TO vndb_multi;
+GRANT SELECT ON tags_hist TO vndb_multi;
GRANT SELECT ON tags_parents TO vndb_multi;
-GRANT SELECT ON tags_vn TO vndb_multi;
-GRANT SELECT ON tags_vn_inherit TO vndb_multi; -- tag_vn_calc() is SECURITY DEFINER due to index drop/create, so no extra perms needed here
+GRANT SELECT ON tags_parents_hist TO vndb_multi;
+GRANT SELECT, DELETE ON tags_vn TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_direct TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_inherit TO vndb_multi;
GRANT SELECT ON threads TO vndb_multi;
GRANT SELECT ON threads_boards TO vndb_multi;
GRANT SELECT ON threads_posts TO vndb_multi;
GRANT SELECT, UPDATE ON traits TO vndb_multi;
-GRANT SELECT ON traits_chars TO vndb_multi; -- traits_chars_calc() is SECURITY DEFINER
+GRANT SELECT ON traits_hist TO vndb_multi;
+GRANT SELECT ON traits_chars TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON traits_chars TO vndb_multi;
GRANT SELECT ON traits_parents TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_labels TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns TO vndb_multi;
-GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns_labels TO vndb_multi;
-
-GRANT SELECT (id, username, registered, ign_votes, email_confirmed, notify_dbedit, notify_announce, notify_post, notify_comment, c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags, perm_imgvote, perm_imgmod),
- UPDATE ( c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags ) ON users TO vndb_multi;
-GRANT DELETE ON users TO vndb_multi;
-
+GRANT SELECT, UPDATE, 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
index f6145c1c..beb0b3dd 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -11,12 +11,19 @@
-- -- 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.
+-- 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:
--
@@ -36,9 +43,24 @@
--
-- Columns marked with a '[pub]' comment on the same line are included in the
-- public database dump. Be aware that not all properties of the to-be-dumped
--- data is annotated in this file. Which tables and which rows are exported is
+-- 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.
@@ -50,21 +72,89 @@ CREATE TYPE anime_type AS ENUM ('tv', 'ova', 'mov', 'oth', 'web', 'spe',
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 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', 'bg', 'ca', 'cs', 'da', 'de', 'el', 'en', 'eo', 'es', 'fa', 'fi', 'fr', 'ga', 'gd', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'mk', 'ms', 'lt', 'lv', 'nl', 'no', 'pl', 'pt-pt', 'pt-br', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'th', 'tr', 'uk', 'vi', 'zh');
+CREATE TYPE language AS ENUM ('ar', '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', '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 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');
+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;
@@ -76,20 +166,21 @@ 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 (
- id integer NOT NULL PRIMARY KEY, -- [pub]
- ann_id integer, -- [pub]
+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]
+ nfo_id varchar(200), -- [pub] AnimeNFO identifier (unused, site is long dead)
title_romaji varchar(250), -- [pub]
title_kanji varchar(250) -- [pub]
);
@@ -99,7 +190,7 @@ CREATE TABLE audit_log (
date timestamptz NOT NULL DEFAULT NOW(),
by_uid vndbid,
affected_uid vndbid,
- by_ip inet NOT NULL,
+ by_ip ipinfo,
by_name text,
affected_name text,
action text NOT NULL,
@@ -115,34 +206,41 @@ CREATE TABLE changes (
rev integer NOT NULL DEFAULT 1,
ihid boolean NOT NULL DEFAULT FALSE,
ilock boolean NOT NULL DEFAULT FALSE,
- ip inet NOT NULL DEFAULT '0.0.0.0',
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]
- spoil_gender gender, -- [pub]
- bloodt blood_type NOT NULL DEFAULT 'unknown', -- [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] chars.id
- s_bust smallint NOT NULL DEFAULT 0, -- [pub]
- s_waist smallint NOT NULL DEFAULT 0, -- [pub]
- s_hip smallint NOT NULL DEFAULT 0, -- [pub]
- b_month smallint NOT NULL DEFAULT 0, -- [pub]
- b_day smallint NOT NULL DEFAULT 0, -- [pub]
- height smallint NOT NULL DEFAULT 0, -- [pub]
- weight smallint, -- [pub]
+ 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]
+ age smallint, -- [pub] years
locked boolean NOT NULL DEFAULT FALSE,
hidden boolean NOT NULL DEFAULT FALSE,
name varchar(250) NOT NULL DEFAULT '', -- [pub]
- original varchar(250) NOT NULL DEFAULT '', -- [pub]
+ latin varchar(250), -- [pub]
alias varchar(500) NOT NULL DEFAULT '', -- [pub]
- "desc" text NOT NULL DEFAULT '' -- [pub]
+ description text NOT NULL DEFAULT '', -- [pub]
+ c_lang language NOT NULL DEFAULT 'ja'
);
-- chars_hist
@@ -164,32 +262,34 @@ CREATE TABLE chars_hist (
main_spoil smallint NOT NULL DEFAULT 0,
age smallint,
name varchar(250) NOT NULL DEFAULT '',
- original varchar(250) NOT NULL DEFAULT '',
+ latin varchar(250),
alias varchar(500) NOT NULL DEFAULT '',
- "desc" text NOT NULL DEFAULT ''
+ description text NOT NULL DEFAULT ''
);
-- chars_traits
CREATE TABLE chars_traits (
id vndbid NOT NULL, -- [pub]
- tid integer NOT NULL, -- [pub] traits.id
+ 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 integer NOT NULL, -- traits.id
+ 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] vn.id
- rid vndbid NULL, -- [pub] releases.id
+ vid vndbid NOT NULL, -- [pub]
+ rid vndbid NULL, -- [pub]
role char_role NOT NULL DEFAULT 'main', -- [pub]
spoil smallint NOT NULL DEFAULT 0 -- [pub]
);
@@ -209,7 +309,7 @@ CREATE TABLE docs ( -- dbentry_type=d
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]
+ content text NOT NULL DEFAULT '', -- [pub] In MultiMarkdown format
html text -- cache, can be manually updated with util/update-docs-html-cache.pl
);
@@ -221,18 +321,57 @@ CREATE TABLE docs_hist (
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]
- height smallint NOT NULL, -- [pub]
- c_votecount integer NOT NULL DEFAULT 0, -- [pub] (cached columns are marked [pub] for easy querying...)
- c_sexual_avg real, -- [pub]
- c_sexual_stddev real, -- [pub]
- c_violence_avg real, -- [pub]
- c_violence_stddev real, -- [pub]
- c_weight real NOT NULL DEFAULT 0, -- [pub]
- c_uids vndbid[] NOT NULL DEFAULT '{}'
+ 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
@@ -240,9 +379,9 @@ 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]
- violence smallint NOT NULL CHECK(violence >= 0 AND violence <= 2), -- [pub]
- ignore boolean NOT NULL DEFAULT false -- [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
@@ -286,10 +425,10 @@ CREATE TABLE producers ( -- dbentry_type=p
locked boolean NOT NULL DEFAULT FALSE,
hidden boolean NOT NULL DEFAULT FALSE,
name varchar(200) NOT NULL DEFAULT '', -- [pub]
- original varchar(200) NOT NULL DEFAULT '', -- [pub]
+ latin varchar(200), -- [pub]
alias varchar(500) NOT NULL DEFAULT '', -- [pub]
- website varchar(250) NOT NULL DEFAULT '', -- [pub]
- "desc" text NOT NULL DEFAULT '', -- [pub]
+ website varchar(1024) NOT NULL DEFAULT '', -- [pub]
+ description text NOT NULL DEFAULT '', -- [pub]
l_wp varchar(150) -- (deprecated)
);
@@ -300,17 +439,17 @@ CREATE TABLE producers_hist (
lang language NOT NULL DEFAULT 'ja',
l_wikidata integer,
name varchar(200) NOT NULL DEFAULT '',
- original varchar(200) NOT NULL DEFAULT '',
+ latin varchar(200),
alias varchar(500) NOT NULL DEFAULT '',
- website varchar(250) NOT NULL DEFAULT '',
- "desc" text 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] producers.id
+ pid vndbid NOT NULL, -- [pub]
relation producer_relation NOT NULL, -- [pub]
PRIMARY KEY(id, pid)
);
@@ -325,18 +464,49 @@ CREATE TABLE producers_relations_hist (
-- quotes
CREATE TABLE quotes (
+ id serial PRIMARY KEY, -- [pub]
vid vndbid NOT NULL, -- [pub]
- quote varchar(250) NOT NULL, -- [pub]
- PRIMARY KEY(vid, quote)
+ 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]
- type release_type NOT NULL DEFAULT 'complete', -- [pub]
- gtin bigint NOT NULL DEFAULT 0, -- [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]
@@ -345,33 +515,41 @@ CREATE TABLE releases ( -- dbentry_type=r
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]
+ 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]
- minage smallint, -- [pub]
voiced smallint NOT NULL DEFAULT 0, -- [pub]
- ani_story smallint NOT NULL DEFAULT 0, -- [pub]
- ani_ero 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, -- [pub]
- uncensored 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,
- title varchar(300) NOT NULL DEFAULT '', -- [pub]
- original varchar(250) NOT NULL DEFAULT '', -- [pub]
- website varchar(250) NOT NULL DEFAULT '', -- [pub]
+ 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 '', -- [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]
@@ -380,17 +558,29 @@ CREATE TABLE releases ( -- dbentry_type=r
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_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,
- type release_type NOT NULL DEFAULT 'complete',
+ 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,
@@ -405,20 +595,26 @@ CREATE TABLE releases_hist (
l_animateg integer NOT NULL DEFAULT 0,
l_freem integer NOT NULL DEFAULT 0,
l_novelgam integer NOT NULL DEFAULT 0,
- minage smallint,
voiced smallint NOT NULL DEFAULT 0,
- ani_story smallint NOT NULL DEFAULT 0,
- ani_ero 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 NOT NULL DEFAULT FALSE,
+ uncensored boolean,
official boolean NOT NULL DEFAULT TRUE,
- title varchar(300) NOT NULL DEFAULT '',
- original varchar(250) NOT NULL DEFAULT '',
- website varchar(250) NOT NULL DEFAULT '',
+ website varchar(1024) NOT NULL DEFAULT '',
catalog varchar(50) NOT NULL DEFAULT '',
engine varchar(50) NOT NULL DEFAULT '',
notes text NOT NULL DEFAULT '',
@@ -432,22 +628,34 @@ CREATE TABLE releases_hist (
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_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_lang
-CREATE TABLE releases_lang (
- id vndbid NOT NULL, -- [pub]
- lang language NOT NULL, -- [pub]
- PRIMARY KEY(id, lang)
+-- 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_lang_hist
-CREATE TABLE releases_lang_hist (
- chid integer NOT NULL,
- lang language NOT NULL,
- PRIMARY KEY(chid, lang)
+-- 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
@@ -483,7 +691,7 @@ CREATE TABLE releases_platforms_hist (
-- releases_producers
CREATE TABLE releases_producers (
id vndbid NOT NULL, -- [pub]
- pid vndbid NOT NULL, -- [pub] producers.id
+ 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),
@@ -500,10 +708,31 @@ CREATE TABLE releases_producers_hist (
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] vn.id
+ vid vndbid NOT NULL, -- [pub]
+ rtype release_type NOT NULL, -- [pub]
PRIMARY KEY(id, vid)
);
@@ -511,32 +740,48 @@ CREATE TABLE releases_vn (
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,
- uid vndbid, -- user who created the report, if logged in
+ status report_status NOT NULL DEFAULT 'new',
date timestamptz NOT NULL DEFAULT NOW(),
lastmod timestamptz,
- status report_status NOT NULL DEFAULT 'new',
+ 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 inet, -- IP address of the visitor, if not logged in
+ ip ipinfo, -- IP address of the visitor, if not logged in
reason text NOT NULL,
message text NOT NULL,
- log text NOT NULL DEFAULT ''
+ 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,
- uid vndbid,
- rid vndbid,
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,
@@ -551,32 +796,32 @@ CREATE TABLE reviews (
-- reviews_posts
CREATE TABLE reviews_posts (
- id vndbid NOT NULL,
- uid vndbid,
date timestamptz NOT NULL DEFAULT NOW(),
edited timestamptz,
+ id vndbid NOT NULL,
+ uid vndbid,
num smallint NOT NULL,
- hidden boolean NOT NULL DEFAULT FALSE,
+ 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,
- date timestamptz NOT NULL,
vote boolean NOT NULL, -- true = upvote, false = downvote
overrule boolean NOT NULL DEFAULT false,
ip inet -- Only for anonymous votes
);
-- rlists
-CREATE TABLE 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]
+ status smallint NOT NULL DEFAULT 0, -- [pub] 0 = Unknown, 1 = Pending, 2 = Obtained, 3 = On loan, 4 = Deleted
PRIMARY KEY(uid, rid)
);
@@ -589,14 +834,33 @@ CREATE TABLE saved_queries (
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,
+ 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)
);
@@ -618,11 +882,19 @@ CREATE TABLE shop_dlsite (
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,
- jbox boolean NOT NULL DEFAULT false,
id text NOT NULL PRIMARY KEY,
price text NOT NULL DEFAULT '' -- empty when unknown or not in stock
);
@@ -653,19 +925,27 @@ CREATE TABLE shop_playasia_gtin (
-- 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]
+ 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]
- aid integer NOT NULL DEFAULT 0, -- [pub] staff_alias.aid
+ 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,
- "desc" text NOT NULL DEFAULT '', -- [pub]
+ 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_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
@@ -673,14 +953,22 @@ CREATE TABLE staff_hist (
chid integer NOT NULL PRIMARY KEY,
gender gender NOT NULL DEFAULT 'unknown',
lang language NOT NULL DEFAULT 'ja',
- aid integer NOT NULL DEFAULT 0, -- Can't refer to staff_alias.id, because the alias might have been deleted
+ 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,
- "desc" text NOT NULL DEFAULT '',
+ 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_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
@@ -688,7 +976,7 @@ 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]
- original varchar(200) NOT NULL DEFAULT '' -- [pub]
+ latin varchar(200) -- [pub]
);
-- staff_alias_hist
@@ -696,7 +984,7 @@ 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 '',
+ latin varchar(200),
PRIMARY KEY(chid, aid)
);
@@ -707,51 +995,81 @@ CREATE TABLE stats_cache (
);
-- tags
-CREATE TABLE tags (
- id SERIAL NOT NULL PRIMARY KEY, -- [pub]
+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(),
- addedby vndbid,
+ 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,
- state smallint NOT NULL DEFAULT 0, -- [pub]
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 UNIQUE, -- [pub]
+ name varchar(250) NOT NULL DEFAULT '' UNIQUE, -- [pub]
+ alias varchar(500) NOT NULL DEFAULT '', -- [pub]
description text NOT NULL DEFAULT '' -- [pub]
);
--- tags_aliases
-CREATE TABLE tags_aliases (
- tag integer NOT NULL, -- [pub]
- alias varchar(250) NOT NULL PRIMARY KEY -- [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 (
- tag integer NOT NULL, -- [pub]
- parent integer NOT NULL, -- [pub]
- PRIMARY KEY(tag, parent)
+ 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 (
- tag integer NOT NULL, -- [pub]
+ 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]
+ 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]
- date timestamptz NOT NULL DEFAULT NOW(), -- [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 integer NOT NULL,
+ tag vndbid NOT NULL,
vid vndbid NOT NULL,
rating real NOT NULL,
- spoiler smallint NOT NULL
+ spoiler smallint NOT NULL,
+ lie boolean NOT NULL,
+ PRIMARY KEY(tag, vid)
);
-- threads
@@ -763,6 +1081,7 @@ CREATE TABLE threads (
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)
);
@@ -784,15 +1103,15 @@ CREATE TABLE threads_poll_votes (
-- threads_posts
CREATE TABLE threads_posts (
- tid vndbid NOT NULL,
- uid vndbid,
date timestamptz NOT NULL DEFAULT NOW(),
edited timestamptz,
+ tid vndbid NOT NULL,
+ uid vndbid,
num smallint NOT NULL,
- hidden boolean NOT NULL DEFAULT FALSE,
+ hidden text,
msg text NOT NULL DEFAULT '',
PRIMARY KEY(tid, num),
- CONSTRAINT threads_posts_first_nonhidden CHECK(num > 1 OR NOT hidden)
+ CONSTRAINT threads_posts_first_nonhidden CHECK(num > 1 OR hidden IS NULL)
);
-- threads_boards
@@ -815,99 +1134,120 @@ CREATE TABLE trace_log (
path text NOT NULL,
query text NOT NULL DEFAULT '',
module text,
- elm_mods text[]
+ js text[]
);
-- traits
-CREATE TABLE traits (
- id SERIAL PRIMARY KEY, -- [pub]
- "group" integer, -- [pub]
- added timestamptz NOT NULL DEFAULT NOW(),
- addedby vndbid,
+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,
- state smallint NOT NULL DEFAULT 0, -- [pub]
- "order" smallint NOT NULL DEFAULT 0, -- [pub]
+ 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, -- [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 integer NOT NULL, -- traits (id)
- spoil smallint NOT NULL DEFAULT 0
+ 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 (
- trait integer NOT NULL, -- [pub]
- parent integer NOT NULL, -- [pub]
- PRIMARY KEY(trait, parent)
+ 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 (
- uid vndbid NOT NULL, -- [pub] user.id
- id integer NOT NULL, -- [pub] 0 < builtin < 10 <= custom, ids are reused
+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
-CREATE TABLE ulist_vns (
- uid vndbid NOT NULL, -- [pub] users.id
- vid vndbid NOT NULL, -- [pub] vn.id
+-- 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 anything in this row has changed?
- vote_date timestamptz, -- [pub] Used for "recent votes" - also updated when vote has changed?
+ 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]
+ 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)
);
--- ulist_vns_labels
-CREATE TABLE ulist_vns_labels (
- uid vndbid NOT NULL, -- [pub] user.id
- lbl integer NOT NULL, -- [pub]
- vid vndbid NOT NULL, -- [pub] vn.id
- PRIMARY KEY(uid, lbl, vid)
-);
-
-- users
CREATE TABLE users (
registered timestamptz NOT NULL DEFAULT NOW(),
- last_reports timestamptz, -- For mods: Most recent activity seen on the reports listing
- id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('u', nextval('users_id_seq')::int) CONSTRAINT users_id_check CHECK(vndbid_type(id) = 'u'), -- [pub]
+ 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,
- tableopts_c integer,
- spoilers smallint NOT NULL DEFAULT 0,
- max_sexual smallint NOT NULL DEFAULT 0,
- max_violence smallint NOT NULL DEFAULT 0,
- ign_votes boolean NOT NULL DEFAULT false, -- [pub]
+ 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,
- 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,
notify_post boolean NOT NULL DEFAULT true,
notify_comment boolean NOT NULL DEFAULT true,
nodistract_can boolean NOT NULL DEFAULT false,
@@ -922,18 +1262,79 @@ CREATE TABLE users (
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] (public because this is used in calculating image flagging scores)
- perm_tag boolean NOT NULL DEFAULT true, -- [pub] (public because this is used in calculating VN tag scores)
+ 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_usermod boolean NOT NULL DEFAULT false,
- perm_imgmod boolean NOT NULL DEFAULT false,
perm_review boolean NOT NULL DEFAULT true,
- username varchar(20) NOT NULL UNIQUE, -- [pub]
- uniname text NOT NULL DEFAULT '',
- mail varchar(100) NOT NULL,
- ip inet NOT NULL DEFAULT '0.0.0.0',
+ 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
@@ -941,38 +1342,54 @@ CREATE TABLE users (
-- 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 '',
- ulist_votes jsonb,
- ulist_vnlist jsonb,
- ulist_wish jsonb
+ 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]
+ 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_popularity real, -- [pub]
- c_pop_rank integer,
- c_rating real, -- [pub]
+ c_pop_rank integer NOT NULL DEFAULT 10000000,
c_rat_rank integer,
c_released integer NOT NULL DEFAULT 0,
- length smallint NOT NULL DEFAULT 0, -- [pub]
+ 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,
- title varchar(250) NOT NULL DEFAULT '', -- [pub]
- original varchar(250) NOT NULL DEFAULT '', -- [pub]
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]
- "desc" text NOT NULL DEFAULT '', -- [pub]
- c_search text,
+ 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_platforms platform[] NOT NULL DEFAULT '{}',
+ c_developers vndbid[] NOT NULL DEFAULT '{}'
);
-- vn_hist
@@ -982,20 +1399,19 @@ CREATE TABLE vn_hist (
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,
- title varchar(250) NOT NULL DEFAULT '',
- original varchar(250) NOT NULL DEFAULT '',
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 '',
- "desc" text NOT NULL DEFAULT ''
+ description text NOT NULL DEFAULT ''
);
-- vn_anime
CREATE TABLE vn_anime (
id vndbid NOT NULL, -- [pub]
- aid integer NOT NULL, -- [pub] anime.id
+ aid integer NOT NULL, -- [pub]
PRIMARY KEY(id, aid)
);
@@ -1006,10 +1422,30 @@ CREATE TABLE vn_anime_hist (
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] vn.id
+ vid vndbid NOT NULL, -- [pub]
relation vn_relation NOT NULL, -- [pub]
official boolean NOT NULL DEFAULT TRUE, -- [pub]
PRIMARY KEY(id, vid)
@@ -1027,8 +1463,8 @@ CREATE TABLE vn_relations_hist (
-- 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] images.id
- rid vndbid, -- [pub] releases.id (only NULL for old revisions, nowadays not allowed anymore)
+ 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)
);
@@ -1045,8 +1481,8 @@ CREATE TABLE vn_screenshots_hist (
-- vn_seiyuu
CREATE TABLE vn_seiyuu (
id vndbid NOT NULL, -- [pub]
- aid integer NOT NULL, -- [pub] staff_alias.aid
- cid vndbid NOT NULL, -- [pub] chars.id
+ aid integer NOT NULL, -- [pub]
+ cid vndbid NOT NULL, -- [pub]
note varchar(250) NOT NULL DEFAULT '', -- [pub]
PRIMARY KEY (id, aid, cid)
);
@@ -1055,7 +1491,7 @@ CREATE TABLE vn_seiyuu (
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, -- chars.id
+ cid vndbid NOT NULL,
note varchar(250) NOT NULL DEFAULT '',
PRIMARY KEY (chid, aid, cid)
);
@@ -1063,10 +1499,10 @@ CREATE TABLE vn_seiyuu_hist (
-- vn_staff
CREATE TABLE vn_staff (
id vndbid NOT NULL, -- [pub]
- aid integer NOT NULL, -- [pub] staff_alias.aid
+ aid integer NOT NULL, -- [pub]
role credit_type NOT NULL DEFAULT 'staff', -- [pub]
- note varchar(250) NOT NULL DEFAULT '', -- [pub]
- PRIMARY KEY (id, aid, role)
+ eid smallint, -- [pub]
+ note varchar(250) NOT NULL DEFAULT '' -- [pub]
);
-- vn_staff_hist
@@ -1074,14 +1510,47 @@ 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)
+ 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 (
+CREATE TABLE wikidata ( -- Information fetched from Wikidata
lastfetch timestamptz,
- id integer NOT NULL PRIMARY KEY, -- [pub]
+ id integer NOT NULL PRIMARY KEY, -- [pub] Q-number
enwiki text, -- [pub]
jawiki text, -- [pub]
website text[], -- [pub] P856
@@ -1110,5 +1579,63 @@ CREATE TABLE wikidata (
steam integer[], -- [pub] P1733
gog text[], -- [pub] P2725
pixiv_user integer[], -- [pub] P5435
- doujinshi_author integer[] -- [pub] P7511
-);
+ 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
index 6e94167c..c756584d 100644
--- a/sql/superuser_init.sql
+++ b/sql/superuser_init.sql
@@ -11,5 +11,7 @@ CREATE DATABASE vndb OWNER vndb;
-- The website
CREATE ROLE vndb_site;
+ALTER ROLE vndb_site SET client_min_messages TO WARNING;
+ALTER ROLE vndb_site SET statement_timeout TO 10000;
-- Multi
CREATE ROLE vndb_multi;
diff --git a/sql/tableattrs.sql b/sql/tableattrs.sql
index 516c97e4..a707bf50 100644
--- a/sql/tableattrs.sql
+++ b/sql/tableattrs.sql
@@ -1,6 +1,68 @@
+-- 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;
@@ -16,6 +78,7 @@ ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_rid_fkey
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;
@@ -27,9 +90,21 @@ ALTER TABLE producers_relations ADD CONSTRAINT producers_relations_pid_fkey
ALTER TABLE producers_relations_hist ADD CONSTRAINT producers_relations_hist_id_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE producers_relations_hist ADD CONSTRAINT producers_relations_hist_pid_fkey FOREIGN KEY (pid) REFERENCES producers (id);
ALTER TABLE quotes ADD CONSTRAINT quotes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_cid_fkey FOREIGN KEY (cid) REFERENCES chars (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_addedby_fkey FOREIGN KEY (addedby) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE releases ADD CONSTRAINT releases_olang_fkey FOREIGN KEY (id,olang) REFERENCES releases_titles(id,lang) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE releases_hist ADD CONSTRAINT releases_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-ALTER TABLE releases_lang ADD CONSTRAINT releases_lang_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
-ALTER TABLE releases_lang_hist ADD CONSTRAINT releases_lang_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE releases_hist ADD CONSTRAINT releases_hist_olang_fkey FOREIGN KEY (chid,olang)REFERENCES releases_titles_hist(chid,lang) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE releases_drm ADD CONSTRAINT releases_drm_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
+ALTER TABLE releases_drm ADD CONSTRAINT releases_drm_drm_fkey FOREIGN KEY (drm) REFERENCES drm (id);
+ALTER TABLE releases_drm_hist ADD CONSTRAINT releases_drm_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE releases_drm_hist ADD CONSTRAINT releases_drm_hist_drm_fkey FOREIGN KEY (drm) REFERENCES drm (id);
+ALTER TABLE releases_titles ADD CONSTRAINT releases_titles_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
+ALTER TABLE releases_titles_hist ADD CONSTRAINT releases_titles_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE releases_media ADD CONSTRAINT releases_media_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
ALTER TABLE releases_media_hist ADD CONSTRAINT releases_media_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE releases_platforms ADD CONSTRAINT releases_platforms_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
@@ -42,6 +117,8 @@ ALTER TABLE releases_vn ADD CONSTRAINT releases_vn_id_fkey
ALTER TABLE releases_vn ADD CONSTRAINT releases_vn_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
ALTER TABLE releases_vn_hist ADD CONSTRAINT releases_vn_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE releases_vn_hist ADD CONSTRAINT releases_vn_hist_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_id_fkey FOREIGN KEY (id) REFERENCES reports (id);
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
ALTER TABLE reviews ADD CONSTRAINT reviews_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id) ON DELETE CASCADE;
ALTER TABLE reviews ADD CONSTRAINT reviews_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
ALTER TABLE reviews ADD CONSTRAINT reviews_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) ON DELETE SET DEFAULT;
@@ -53,16 +130,17 @@ ALTER TABLE rlists ADD CONSTRAINT rlists_uid_fkey
ALTER TABLE rlists ADD CONSTRAINT rlists_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
ALTER TABLE saved_queries ADD CONSTRAINT saved_queries_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE sessions ADD CONSTRAINT sessions_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE staff ADD CONSTRAINT staff_aid_fkey FOREIGN KEY (aid) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE staff ADD CONSTRAINT staff_main_fkey FOREIGN KEY (main) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE staff ADD CONSTRAINT staff_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
ALTER TABLE staff_hist ADD CONSTRAINT staff_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE staff_hist ADD CONSTRAINT staff_hist_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
ALTER TABLE staff_alias ADD CONSTRAINT staff_alias_id_fkey FOREIGN KEY (id) REFERENCES staff (id);
ALTER TABLE staff_alias_hist ADD CONSTRAINT staff_alias_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-ALTER TABLE tags ADD CONSTRAINT tags_addedby_fkey FOREIGN KEY (addedby) REFERENCES users (id) ON DELETE SET DEFAULT;
-ALTER TABLE tags_aliases ADD CONSTRAINT tags_aliases_tag_fkey FOREIGN KEY (tag) REFERENCES tags (id);
-ALTER TABLE tags_parents ADD CONSTRAINT tags_parents_tag_fkey FOREIGN KEY (tag) REFERENCES tags (id);
+ALTER TABLE tags_hist ADD CONSTRAINT tags_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id);
+ALTER TABLE tags_parents ADD CONSTRAINT tags_parents_id_fkey FOREIGN KEY (id) REFERENCES tags (id);
ALTER TABLE tags_parents ADD CONSTRAINT tags_parents_parent_fkey FOREIGN KEY (parent) REFERENCES tags (id);
+ALTER TABLE tags_parents_hist ADD CONSTRAINT tags_parents_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id);
+ALTER TABLE tags_parents_hist ADD CONSTRAINT tags_parents_hist_parent_fkey FOREIGN KEY (parent) REFERENCES tags (id);
ALTER TABLE tags_vn ADD CONSTRAINT tags_vn_tag_fkey FOREIGN KEY (tag) REFERENCES tags (id);
ALTER TABLE tags_vn ADD CONSTRAINT tags_vn_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
ALTER TABLE tags_vn ADD CONSTRAINT tags_vn_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
@@ -72,22 +150,31 @@ ALTER TABLE threads_poll_votes ADD CONSTRAINT threads_poll_votes_optid_fke
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_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 ADD CONSTRAINT traits_gid_fkey FOREIGN KEY (gid) REFERENCES traits (id);
+ALTER TABLE traits_hist ADD CONSTRAINT traits_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id);
+ALTER TABLE traits_parents ADD CONSTRAINT traits_parents_id_fkey FOREIGN KEY (id) REFERENCES traits (id);
ALTER TABLE traits_parents ADD CONSTRAINT traits_parents_parent_fkey FOREIGN KEY (parent) REFERENCES traits (id);
+ALTER TABLE traits_parents_hist ADD CONSTRAINT traits_parents_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id);
+ALTER TABLE traits_parents_hist ADD CONSTRAINT traits_parents_hist_parent_fkey FOREIGN KEY (parent) REFERENCES traits (id);
ALTER TABLE ulist_labels ADD CONSTRAINT ulist_labels_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE ulist_vns ADD CONSTRAINT ulist_vns_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE ulist_vns ADD CONSTRAINT ulist_vns_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
-ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_uid_lbl_fkey FOREIGN KEY (uid,lbl) REFERENCES ulist_labels (uid,id) ON DELETE CASCADE;
-ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_uid_vid_fkey FOREIGN KEY (uid,vid) REFERENCES ulist_vns (uid,vid) ON DELETE CASCADE;
+ALTER TABLE 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;
@@ -107,49 +194,10 @@ ALTER TABLE vn_seiyuu ADD CONSTRAINT vn_seiyuu_aid_fkey
ALTER TABLE vn_seiyuu ADD CONSTRAINT vn_seiyuu_cid_fkey FOREIGN KEY (cid) REFERENCES chars (id);
ALTER TABLE vn_seiyuu_hist ADD CONSTRAINT vn_seiyuu_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE vn_seiyuu_hist ADD CONSTRAINT vn_seiyuu_hist_cid_fkey FOREIGN KEY (cid) REFERENCES chars (id);
-ALTER TABLE vn_staff ADD CONSTRAINT vn_staff_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
+ALTER TABLE vn_staff ADD CONSTRAINT vn_staff_id_eid_fkey FOREIGN KEY (id,eid) REFERENCES vn_editions (id,eid) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE vn_staff ADD CONSTRAINT vn_staff_aid_fkey FOREIGN KEY (aid) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
-ALTER TABLE vn_staff_hist ADD CONSTRAINT vn_staff_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-
-
-
--- Indices
-
-CREATE INDEX chars_main ON chars (main) WHERE main IS NOT NULL AND NOT hidden; -- Only used on /c+
-CREATE INDEX chars_vns_vid ON chars_vns (vid);
-CREATE INDEX chars_image ON chars (image);
-CREATE UNIQUE INDEX image_votes_pkey ON image_votes (uid, id);
-CREATE INDEX image_votes_id ON image_votes (id);
-CREATE INDEX notifications_uid_iid ON notifications (uid,iid);
-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 UNIQUE INDEX reviews_vid_uid ON reviews (vid,uid);
-CREATE INDEX reviews_uid ON reviews (uid);
-CREATE INDEX reviews_posts_uid ON reviews_posts (uid);
-CREATE UNIQUE INDEX reviews_votes_id_uid ON reviews_votes (id,uid);
-CREATE UNIQUE INDEX reviews_votes_id_ip ON reviews_votes (id,ip);
-CREATE INDEX staff_alias_id ON staff_alias (id);
-CREATE UNIQUE INDEX tags_vn_pkey ON tags_vn (tag,vid,uid);
-CREATE UNIQUE INDEX threads_boards_pkey ON threads_boards (tid,type,COALESCE(iid, 'r1')); -- 'r1' is an invalid board 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) WHERE uid IS NOT NULL;
-CREATE INDEX tags_vn_vid ON tags_vn (vid);
-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_tid ON traits_chars (tid);
-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 INDEX vn_staff_aid ON vn_staff (aid);
-CREATE UNIQUE INDEX changes_itemrev ON changes (itemid, rev);
-CREATE UNIQUE INDEX chars_vns_pkey ON chars_vns (id, vid, COALESCE(rid, 'v1')); -- 'v1' is an invalid release id, but works as a 'no release specified' value in the UNIQUE qualifier.
-CREATE UNIQUE INDEX chars_vns_hist_pkey ON chars_vns_hist (chid, vid, COALESCE(rid, 'v1'));
-CREATE INDEX ulist_vns_voted ON ulist_vns (vid, vote_date) WHERE vote IS NOT NULL; -- For VN recent votes & vote graph. INCLUDE(vote) speeds up vote graph even more
-CREATE INDEX users_ign_votes ON users (id) WHERE ign_votes;
+ALTER TABLE vn_staff_hist ADD CONSTRAINT vn_staff_hist_chid_eid_fkey FOREIGN KEY (chid,eid) REFERENCES vn_editions_hist (chid,eid) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE vn_titles ADD CONSTRAINT vn_titles_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
+ALTER TABLE vn_titles_hist ADD CONSTRAINT vn_titles_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
diff --git a/sql/triggers.sql b/sql/triggers.sql
index 6890c0aa..dc03feb5 100644
--- a/sql/triggers.sql
+++ b/sql/triggers.sql
@@ -36,24 +36,14 @@ CREATE TRIGGER users_imgvotes_update AFTER INSERT OR DELETE ON image_votes FOR E
-- 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
UPDATE stats_cache SET count = count+1 WHERE section = TG_TABLE_NAME;
ELSIF TG_OP = 'UPDATE' THEN
- IF TG_TABLE_NAME IN('tags', 'traits') THEN
- unhidden := OLD.state <> 2 AND NEW.state = 2;
- hidden := OLD.state = 2 AND NEW.state <> 2;
- ELSE
- unhidden := OLD.hidden AND NOT NEW.hidden;
- hidden := NEW.hidden AND NOT OLD.hidden;
- END IF;
- IF unhidden THEN
+ IF OLD.hidden AND NOT NEW.hidden THEN
UPDATE stats_cache SET count = count+1 WHERE section = TG_TABLE_NAME;
- ELSIF hidden THEN
+ ELSIF NEW.hidden AND NOT OLD.hidden THEN
UPDATE stats_cache SET count = count-1 WHERE section = TG_TABLE_NAME;
END IF;
END IF;
@@ -71,10 +61,10 @@ CREATE TRIGGER stats_cache_new AFTER INSERT ON chars FOR EAC
CREATE TRIGGER stats_cache_edit AFTER UPDATE ON chars FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
CREATE TRIGGER stats_cache_new AFTER INSERT ON staff FOR EACH ROW WHEN (NEW.hidden = FALSE) EXECUTE PROCEDURE update_stats_cache();
CREATE TRIGGER stats_cache_edit AFTER UPDATE ON staff FOR EACH ROW WHEN (OLD.hidden IS DISTINCT FROM NEW.hidden) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_new AFTER INSERT ON tags FOR EACH ROW WHEN (NEW.state = 2) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON tags FOR EACH ROW WHEN (OLD.state IS DISTINCT FROM NEW.state) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_new AFTER INSERT ON traits FOR EACH ROW WHEN (NEW.state = 2) EXECUTE PROCEDURE update_stats_cache();
-CREATE TRIGGER stats_cache_edit AFTER UPDATE ON traits FOR EACH ROW WHEN (OLD.state IS DISTINCT FROM NEW.state) EXECUTE PROCEDURE update_stats_cache();
+CREATE TRIGGER stats_cache_new AFTER INSERT ON 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();
@@ -177,21 +167,18 @@ CREATE TRIGGER ulist_labels_create AFTER INSERT ON users FOR EACH ROW EXECUTE PR
CREATE OR REPLACE FUNCTION ulist_voted_label() RETURNS trigger AS $$
BEGIN
- IF NEW.vote IS NULL THEN
- DELETE FROM ulist_vns_labels WHERE uid = NEW.uid AND vid = NEW.vid AND lbl = 7;
- ELSE
- INSERT INTO ulist_vns_labels (uid, vid, lbl) VALUES (NEW.uid, NEW.vid, 7) ON CONFLICT (uid, vid, lbl) DO NOTHING;
- END IF;
- RETURN NULL;
+ NEW.labels := CASE WHEN NEW.vote IS NULL THEN array_remove(NEW.labels, 7) ELSE array_set(NEW.labels, 7) END;
+ RETURN NEW;
END
$$ LANGUAGE plpgsql;
-CREATE TRIGGER ulist_voted_label AFTER INSERT OR UPDATE ON ulist_vns FOR EACH ROW EXECUTE PROCEDURE ulist_voted_label();
+CREATE TRIGGER ulist_voted_label_ins BEFORE INSERT ON ulist_vns FOR EACH ROW EXECUTE PROCEDURE ulist_voted_label();
+CREATE TRIGGER ulist_voted_label_upd BEFORE UPDATE ON ulist_vns FOR EACH ROW WHEN ((OLD.vote IS NULL) <> (NEW.vote IS NULL)) EXECUTE PROCEDURE ulist_voted_label();
--- NOTIFY on insert into changes/posts/tags/trait/reviews
+-- NOTIFY on insert into changes/posts/reviews
CREATE OR REPLACE FUNCTION insert_notify() RETURNS trigger AS $$
BEGIN
@@ -199,10 +186,6 @@ BEGIN
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;
ELSIF TG_TABLE_NAME = 'reviews' THEN
NOTIFY newreview;
END IF;
@@ -212,22 +195,11 @@ $$ LANGUAGE plpgsql;
CREATE TRIGGER insert_notify AFTER INSERT ON changes FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
CREATE TRIGGER insert_notify AFTER INSERT ON threads_posts FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
-CREATE TRIGGER insert_notify AFTER INSERT ON tags FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
-CREATE TRIGGER insert_notify AFTER INSERT ON traits FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
CREATE TRIGGER insert_notify AFTER INSERT ON reviews FOR EACH STATEMENT EXECUTE PROCEDURE insert_notify();
--- 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;
-
-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 notifications for new posts.
CREATE OR REPLACE FUNCTION notify_post() RETURNS trigger AS $$
@@ -275,8 +247,8 @@ CREATE TRIGGER notify_review AFTER INSERT ON reviews FOR EACH ROW EXECUTE PROCED
CREATE OR REPLACE FUNCTION update_threads_cache() RETURNS trigger AS $$
BEGIN
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)
+ 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
@@ -292,8 +264,8 @@ CREATE TRIGGER update_threads_cache AFTER INSERT OR UPDATE OR DELETE ON threads_
CREATE OR REPLACE FUNCTION update_reviews_cache() RETURNS trigger AS $$
BEGIN
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)
+ 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
@@ -304,6 +276,19 @@ CREATE TRIGGER update_reviews_cache AFTER INSERT OR UPDATE OR DELETE ON reviews_
+-- 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
@@ -330,3 +315,19 @@ 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
index 799ec005..385435c6 100644
--- a/sql/vndbid.sql
+++ b/sql/vndbid.sql
@@ -81,3 +81,8 @@ CREATE OPERATOR CLASS vndbid_btree_ops DEFAULT FOR TYPE vndbid USING btree AS
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/nonfree.svg b/static/f/nonfree.svg
deleted file mode 100644
index 07bdb646..00000000
--- a/static/f/nonfree.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="417pt" height="569pt" viewBox="0 0 417 569" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#231f20ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 187.05 9.04 C 215.43 4.50 244.34 10.44 271.15 19.75 C 281.61 23.76 292.33 27.44 302.01 33.15 C 297.99 44.75 293.08 56.06 288.51 67.46 C 281.64 85.22 274.18 102.74 267.44 120.55 C 263.36 121.55 259.13 120.75 254.99 120.78 C 229.81 120.33 204.63 119.82 179.45 119.46 C 173.37 119.10 167.25 119.59 161.20 118.80 C 151.94 94.16 143.89 69.00 133.14 44.94 C 134.05 41.44 136.85 38.81 138.98 35.99 C 150.85 21.32 168.55 12.04 187.05 9.04 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 66.51 72.43 C 67.69 72.01 68.64 72.77 69.52 73.45 C 90.10 89.89 110.74 106.25 131.33 122.68 C 134.08 124.90 137.05 126.88 139.51 129.43 C 140.16 134.03 139.19 138.69 139.81 143.31 C 145.38 140.26 148.98 134.67 154.25 131.19 C 157.71 130.18 161.43 131.06 165.00 130.95 C 196.69 131.42 228.37 132.18 260.06 132.70 C 263.86 132.54 266.34 135.82 269.13 137.87 C 272.66 141.02 277.00 143.28 280.00 146.97 C 277.00 151.54 272.98 155.32 269.78 159.75 C 268.67 160.90 267.81 162.89 265.95 162.81 C 237.63 162.70 209.33 161.68 181.01 161.53 C 171.44 161.07 161.84 161.39 152.29 160.77 C 147.08 155.61 143.45 148.95 137.81 144.23 C 110.65 128.16 83.86 111.45 56.79 95.23 C 55.88 94.54 54.59 94.10 54.17 92.95 C 57.94 85.92 62.39 79.25 66.51 72.43 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 58.23 149.12 C 60.47 148.64 62.77 148.93 65.03 149.04 C 83.70 150.30 102.40 151.15 121.07 152.31 C 125.21 152.59 129.45 152.32 133.47 153.45 C 135.59 158.31 137.80 163.13 139.79 168.04 C 133.34 169.37 126.69 169.18 120.19 170.19 C 97.27 172.22 74.39 174.77 51.48 176.89 C 53.84 167.66 55.66 158.29 58.23 149.12 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 156.34 167.29 C 157.72 166.49 159.42 166.80 160.94 166.73 C 193.30 168.19 225.68 169.17 258.04 170.60 C 260.55 170.46 262.54 172.14 264.38 173.62 C 294.54 198.22 322.37 225.72 347.13 255.76 C 364.12 277.19 379.76 299.91 391.21 324.82 C 398.56 340.54 404.16 357.17 406.75 374.37 C 410.52 396.52 407.62 419.25 401.94 440.81 C 393.60 473.89 374.52 504.51 347.11 525.09 C 339.91 530.50 332.18 535.20 324.06 539.12 C 315.06 543.81 305.17 546.33 295.60 549.59 C 232.31 568.05 163.25 565.88 101.09 543.98 C 85.32 538.71 70.12 531.29 57.11 520.85 C 40.32 507.72 27.77 489.52 20.53 469.53 C 9.42 437.57 9.81 402.45 17.63 369.79 C 18.52 364.27 18.80 358.65 20.25 353.22 C 25.80 330.50 36.36 309.34 48.48 289.46 C 76.09 245.09 112.44 206.94 150.85 171.82 C 152.61 170.23 154.29 168.51 156.34 167.29 M 194.23 216.22 C 192.75 222.15 194.13 228.34 193.41 234.37 C 172.70 236.70 151.62 240.85 133.33 251.33 C 118.36 259.90 105.43 273.00 99.31 289.34 C 94.00 304.29 93.39 321.47 99.88 336.16 C 107.00 351.25 120.48 362.51 135.27 369.74 C 153.33 379.07 173.57 383.00 193.59 385.34 C 193.55 408.48 193.62 431.63 193.55 454.77 C 191.68 455.53 189.61 454.69 187.70 454.48 C 172.53 451.51 156.93 447.16 144.67 437.32 C 139.32 433.31 136.87 426.31 130.72 423.24 C 120.35 417.78 106.07 420.53 98.74 429.78 C 94.04 435.75 93.97 444.52 98.22 450.75 C 103.99 459.47 111.72 466.82 120.37 472.65 C 139.28 485.53 161.61 492.49 184.10 495.73 C 187.26 496.17 190.46 496.40 193.53 497.28 C 193.86 502.19 193.30 507.11 193.64 512.02 C 193.85 516.18 196.63 519.68 199.68 522.28 C 207.33 528.22 219.94 526.63 225.25 518.26 C 229.88 512.21 226.08 504.15 228.32 497.46 C 250.80 495.20 273.70 490.80 293.55 479.49 C 308.26 470.99 320.89 458.09 327.17 442.14 C 333.30 424.98 333.02 405.00 324.23 388.79 C 316.03 374.14 301.86 363.78 286.57 357.45 C 268.01 349.77 247.83 347.26 227.95 345.75 C 227.18 339.53 227.83 333.25 227.63 327.01 C 227.78 309.95 227.34 292.87 227.83 275.82 C 237.98 275.86 248.03 278.26 257.68 281.26 C 267.00 284.44 276.32 288.60 283.49 295.52 C 287.30 299.08 289.55 304.09 293.87 307.12 C 303.29 313.74 317.33 312.63 325.57 304.57 C 331.06 299.41 332.40 290.60 329.34 283.83 C 325.68 276.54 320.06 270.39 313.94 265.08 C 291.90 246.62 263.21 237.82 234.94 235.22 C 232.60 234.77 229.80 235.32 227.79 233.89 C 226.76 226.76 229.69 218.47 224.64 212.38 C 217.34 201.95 198.61 204.11 194.23 216.22 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 149.17 290.18 C 161.48 280.27 177.69 277.24 193.00 275.62 C 193.89 276.88 193.50 278.53 193.64 279.97 C 193.46 301.54 193.70 323.12 193.52 344.69 C 188.66 344.50 183.89 343.46 179.10 342.69 C 169.06 340.87 158.77 338.58 150.21 332.73 C 145.85 329.79 141.82 325.88 140.18 320.76 C 136.87 310.01 140.15 297.20 149.17 290.18 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 227.83 387.02 C 240.38 387.76 253.13 388.95 265.12 392.96 C 273.70 395.95 282.35 402.01 284.48 411.36 C 286.45 420.54 284.86 431.18 278.17 438.16 C 271.39 445.79 261.30 449.06 251.81 451.84 C 243.93 453.73 235.97 455.64 227.86 456.17 C 227.30 441.13 227.80 426.05 227.63 411.00 C 227.79 403.01 227.31 395.00 227.83 387.02 Z" />
-</g>
-</svg>
diff --git a/static/f/notes.svg b/static/f/notes.svg
deleted file mode 100644
index 8c1f1bb9..00000000
--- a/static/f/notes.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="384pt" height="384pt" viewBox="0 0 384 384" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 0.00 0.00 L 384.00 0.00 L 384.00 384.00 L 0.00 384.00 L 0.00 0.00 M 32.01 32.01 C 32.00 138.67 32.00 245.33 32.01 351.99 C 138.67 352.00 245.33 352.00 351.99 351.99 C 352.00 245.33 352.00 138.67 351.99 32.01 C 245.33 32.00 138.67 32.00 32.01 32.01 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 64.00 128.00 C 149.33 128.00 234.67 128.00 320.00 128.00 C 319.99 138.67 319.99 149.33 320.00 160.00 C 234.67 160.00 149.33 160.00 64.00 160.00 C 64.01 149.33 64.00 138.67 64.00 128.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 64.00 192.00 C 149.33 192.00 234.67 192.00 320.00 192.00 C 319.99 202.67 319.99 213.33 320.00 224.00 C 234.67 224.00 149.33 224.00 64.00 224.00 C 64.01 213.33 64.00 202.67 64.00 192.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 64.00 256.00 C 128.00 255.99 192.00 256.00 256.00 256.00 C 256.00 266.67 256.00 277.33 256.00 288.00 C 192.00 288.00 128.00 288.00 64.00 288.00 C 64.00 277.33 64.00 266.67 64.00 256.00 Z" />
-</g>
-</svg>
diff --git a/static/f/patreon.png b/static/f/patreon.png
deleted file mode 100644
index ae2938b7..00000000
--- a/static/f/patreon.png
+++ /dev/null
Binary files differ
diff --git a/static/f/plat/and.svg b/static/f/plat/and.svg
deleted file mode 100644
index 5c078508..00000000
--- a/static/f/plat/and.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(1 1)" clip-path="url(#a)" fill="#839e2e">
-<path d="m10.443 2.3202 1.0746-1.9864c0.0705-0.13151 0.0454-0.23235-0.0755-0.30326-0.1313-0.061145-0.2322-0.030162-0.3027 0.09076l-1.0896 2.0023c-0.95886-0.42478-1.973-0.63756-3.0424-0.63756-1.0696 0-2.0838 0.21282-3.0423 0.63756l-1.0897-2.0023c-0.07081-0.12092-0.17168-0.15163-0.30266-0.09076-0.12122 0.07123-0.14631 0.17175-0.0755 0.30326l1.0747 1.9864c-1.0897 0.55682-1.9576 1.3323-2.6034 2.3282-0.64575 0.99639-0.96876 2.0852-0.96876 3.2684h14c0-1.1829-0.323-2.2718-0.9687-3.2684-0.6458-0.99584-1.5087-1.7713-2.5881-2.3282zm-6.2131 2.8892c-0.11622 0.11672-0.25502 0.17482-0.41637 0.17482-0.16166 0-0.29764-0.058096-0.40858-0.17482-0.11093-0.11617-0.1664-0.25471-0.1664-0.41692 0-0.16167 0.05547-0.30048 0.1664-0.41692 0.11094-0.11618 0.24724-0.17427 0.40858-0.17427 0.16135 0 0.30015 0.058086 0.41637 0.17427 0.11594 0.11672 0.17419 0.25525 0.17419 0.41692-3.2e-4 0.16194-0.05852 0.30075-0.17419 0.41692zm6.3794 0c-0.1111 0.11672-0.2474 0.17482-0.4085 0.17482-0.1617 0-0.30049-0.058096-0.41648-0.17482-0.11617-0.11617-0.17414-0.25471-0.17414-0.41692 0-0.16167 0.05797-0.30048 0.17414-0.41692 0.11599-0.11618 0.25478-0.17427 0.41648-0.17427 0.1613 0 0.2973 0.058086 0.4085 0.17427 0.111 0.11672 0.1664 0.25525 0.1664 0.41692 0 0.16194-0.0554 0.30075-0.1664 0.41692z" stroke-width="1.001"/>
-<path d="m0 14 14 3.01e-4v-5.5378h-14z" stroke-width="1.0048"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="14" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/bdp.svg b/static/f/plat/bdp.svg
deleted file mode 100644
index 8c6ba02e..00000000
--- a/static/f/plat/bdp.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 16 7.004" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1.1429 0 0 1.1566 0 -4.5945)" clip-path="url(#a)" fill="#00b2ff">
-<path d="m1.4737 4.4825c-0.00916 0-0.0167 0.00485-0.02262 0.01185-0.61974 0.87281-0.99342 1.4635-1.3811 2.1807l-0.038768 0.07915-0.011307 0.02531c-0.033383 0.06946-0.024768 0.14646 0.021538 0.21968 0.22938 0.35861 1.5146 0.77751 4.3851 0.77751 2.1403 0 4.4136-0.34568 4.4136-0.98588 0-0.61813-2.2453-0.9875-4.4136-0.9875-0.72044 0-1.4333 0.10122-1.639 0.13353 0.19599-0.30207 1.0106-1.3989 1.0193-1.4102 0.00377-0.00484 0.00646-0.01023 0.00646-0.01615 0-0.00431-0.00161-0.00862-0.00323-0.01292-0.00646-0.00862-0.01561-0.01508-0.02531-0.01508zm0.83619 2.3088c0-0.13354 0.80497-0.32038 2.1166-0.32038 1.3111 0 2.115 0.18684 2.115 0.32038 0 0.13353-0.80389 0.31983-2.115 0.31983-1.3116 0-2.1166-0.1863-2.1166-0.31983z"/>
-<path d="m3.0976 10.012s10.671 0.434 10.899-3.2953c0.1841-3.025-7.3944-2.7374-7.4014-2.7374s-0.05869 0.00485-0.05869 0.04792c0 0.03608 0.02692 0.04846 0.05438 0.04846 2.1015 0 5.5211 0.83459 5.4097 2.6432-0.0899 1.4732-2.7579 3.1973-8.8988 3.1973-0.03661 0-0.06084 0.02531-0.06084 0.04792 0 0.02262 0.01346 0.04362 0.05707 0.04792z"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="14" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/dos.svg b/static/f/plat/dos.svg
deleted file mode 100644
index 65b14ee6..00000000
--- a/static/f/plat/dos.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 8.967" xmlns="http://www.w3.org/2000/svg">
-<path d="m0 0.32605 2.8781-0.073781c0.55878-0.012245 1.0755 0.049069 1.5983 0.23343v0.10444c-0.45065 0.39302-0.77502 0.85969-1.0696 1.3819l-1.7485 0.012364 0.012004 5.0667 1.0335 0.073494 1.0575-0.012179c0.63094-0.0063205 1.2137-1.038 1.2679-1.5969l0.15608-1.7624c0.066218-0.77393 0.50476-1.3695 1.1838-1.689 0.42665 0.73089 0.61288 1.5415 0.61288 2.3951 0 2.684-1.6281 4.3789-4.1941 4.2807l-2.7879-0.11049z" clip-rule="evenodd" fill="#c00" fill-rule="evenodd" stroke-width="1.0278"/>
-<path d="m8.4962 0.23346c-0.50476 0.38073-0.90129 0.80447-1.1837 1.3817-0.096228-0.00605-0.19235-0.012199-0.28839-0.012199-1.0156 0-1.9408 0.90885-2.061 1.9284l-0.26442 2.2172c-0.036064 0.31327-0.30047 0.63255-0.4988 0.85977-0.16812 0.18421-0.3664 0.22108-0.61279 0.22108h-0.030041c-0.31248-0.73681-0.47474-1.4985-0.47474-2.303 0-2.5241 1.382-4.5263 3.9537-4.5263 0.50476 0 0.98543 0.098316 1.4601 0.23346z" clip-rule="evenodd" fill="#f0f" fill-rule="evenodd" stroke-width="1.0278"/>
-<path d="m5.3537 8.5742 0.83519-0.80472 0.37851-0.55262 0.63084 0.012333c0.30046 0.66937 0.79314 1.1238 1.394 1.517-0.43866 0.14748-0.87125 0.22107-1.328 0.22107-0.66689 0-1.2919-0.15354-1.9106-0.39291z" clip-rule="evenodd" fill="#f0f" fill-rule="evenodd" stroke-width="1.0278"/>
-<path d="m7.2704 6.0129 1.6043 0.012374c0.16828 1.0501 0.84727 1.3388 1.8566 1.3388 0.63104 0 1.5743-0.15352 1.5743-0.97648 0-0.42993-0.33052-0.65116-0.6669-0.8415l0.08407-1.6397c1.2799 0.35606 2.2773 0.84738 2.2773 2.3827 0 1.8181-1.6944 2.5857-3.2566 2.5857-1.1176 0-2.5657-0.3317-3.1606-1.4125-0.18621-0.33166-0.36036-0.76153-0.36036-1.1484 0-0.030791 0.00586-0.061428 0.011891-0.092086z" clip-rule="evenodd" fill="#cca300" fill-rule="evenodd" stroke-width="1.0278"/>
-<path d="m13.718 2.8133h-1.6403c-0.13823-0.81692-0.80534-1.2222-1.6045-1.2222-0.54656 0-1.442 0.24542-1.4719 0.94569-0.00623 0.036937 0 0.067389 0.011901 0.098355l0.32448 0.95187c0.066105 0.19654 0.07817 0.41139 0.07817 0.62027 0 0.23939-0.030103 0.47895-0.060051 0.71853-1.1537-0.33165-1.9649-0.90268-1.9649-2.2294 0-1.7872 1.412-2.6225 3.0223-2.6225 1.7904 0 3.1905 0.85379 3.3047 2.7393z" clip-rule="evenodd" fill="#cca300" fill-rule="evenodd" stroke-width="1.0278"/>
-<path d="m10.743 1.9166c0.40545 0.78013 0.61387 1.6277 0.61387 2.5302 0 0.93979-0.23083 1.818-0.62497 2.6409-0.10144 0.0061-0.20267 0.018284-0.30411 0.018284-0.32651 0-1.1204-0.23938-1.1204-0.6753 0-0.073689 0.011141-0.1475 0.033803-0.21507l0.29822-1.0868c0.062127-0.21505 0.039548-0.4731 0.039548-0.68797 0-0.84145-0.34909-1.3697-0.37733-1.947-0.016814-0.40516 0.68698-0.59564 0.93479-0.59564 0.16886 0 0.33772 0 0.50658 0.018304z" clip-rule="evenodd" fill="#f0f" fill-rule="evenodd" stroke-width="1.0278"/>
-</svg>
diff --git a/static/f/plat/drc.svg b/static/f/plat/drc.svg
deleted file mode 100644
index f37cfc53..00000000
--- a/static/f/plat/drc.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 14 12.417" xmlns="http://www.w3.org/2000/svg">
-<g transform="scale(1 1)" clip-path="url(#a)" fill="#cf3311">
-<path d="m8.523 0.16915c0.52027 0.078119 1.0516 0.2598 1.3287 0.34456 0.55827 0.17062 1.4645 0.70097 1.7845 0.95535 0.1513 0.12027 0.4697 0.43529 0.5929 0.59385 0.1201 0.15447 0.3495 0.48907 0.4959 0.69521 0.2329 0.34024 0.5072 0.84702 0.7399 1.3134 0.2599 0.52105 0.2777 0.67197 0.3563 1.1076l0.1718 0.7679c0.0161 0.13278 0.0295 0.61986-0.2564 0.68813-0.2858 0.06827-0.55-0.06362-0.6746-0.25637-0.1724-0.26678-0.1486-0.64542-0.3436-1.0788-0.152-0.35032-0.2185-0.85941-0.4613-1.2256l-0.1904-0.32199c-0.3369-0.51872-0.6881-0.93045-0.8976-1.199-0.1844-0.22562-0.5289-0.53233-0.8554-0.75053-0.302-0.18313-0.79015-0.39192-1.1422-0.49947-0.11219-0.03652-0.59241-0.17771-0.89083-0.21743-0.40498-0.04548-0.67142-0.10954-1.0563-0.13665-0.47512-0.033527-0.70306-0.075795-1.3194-0.003763-0.23347 0.063293-0.60336 0.088413-0.93631 0.21576-0.26887 0.10966-0.64287 0.28681-1.0092 0.50202-0.39424 0.21621-0.99274 0.65836-1.3896 1.0359-0.39999 0.42744-0.60005 0.65682-0.8759 1.1987-0.12215 0.26811-0.35839 0.68282-0.45366 0.9756-0.06506 0.2972-0.18921 0.85155-0.16829 1.1208 0.0094 0.48232-0.03276 0.58168 0.00343 0.97592 0.00132 0.35497 0.11784 0.60105 0.1663 0.89139 0.0415 0.20061 0.23391 0.89471 0.35297 1.0558 0.54119 1.166 1.2676 1.671 2.0771 2.0353 0.22815 0.1162 0.94217 0.3072 1.1488 0.362 0.3522 0.0635 0.81571 0.1059 1.2618 0.1145 0.46938 0.0092 1.1163-0.0544 1.5089-0.1989 0.42489-0.1562 0.91617-0.3783 0.91052-0.3721 0.19718-0.1087 0.49804-0.286 0.59176-0.3641 0.18113-0.116 0.29023-0.161 0.39756-0.2727 0.1548-0.1354 0.24852-0.2134 0.3658-0.3813 0.06529-0.09461 0.16897-0.23945 0.26997-0.36747 0.1087-0.17803 0.1711-0.36569 0.2772-0.59783 0.1269-0.34335 0.1072-0.32686 0.1313-0.69156 0.0228-0.10567-0.0024-0.46085-0.0215-0.52116-0.0572-0.32774-0.037-0.61687-0.112-0.93509-0.1014-0.43054-0.1856-0.75275-0.3149-1.1658-0.12163-0.46506-0.4788-0.89592-0.74691-1.2036-0.16608-0.19076-0.68137-0.64132-1.3963-0.9227-0.20127-0.06772-0.53078-0.14451-0.74002-0.18268-0.44669-0.0467-0.51938-0.01671-0.94704 0.0146-0.14717 0.02567-0.48885 0.09549-0.61444 0.13964-0.36735 0.12902-0.50245 0.21101-0.878 0.44105-0.17482 0.10146-0.3066 0.17914-0.4853 0.34666-0.14263 0.13201-0.24454 0.3564-0.36005 0.48796-0.15823 0.21533-0.33439 0.50257-0.45256 0.81239-0.12813 0.30816-0.1694 0.74201-0.14993 1.0212 0 0.21278 0.05787 0.60226 0.08089 0.68746 0.0229 0.08531 0.08044 0.34976 0.09294 0.40287 0.0125 0.053 0.15004 0.33284 0.16896 0.37333 0.07757 0.16509 0.22606 0.40232 0.47624 0.64453 0.18456 0.20161 0.5414 0.39945 0.80851 0.47613 0.23955 0.13145 0.64353 0.1538 0.86704 0.15424 0.10999-0.00664 0.39093-0.0239 0.53754-0.05909 0.29731-0.07136 0.35219-0.07325 0.65858-0.20636 0.27496-0.12746 0.46152-0.17338 0.65305-0.3149 0 0 0.2629-0.20603 0.3824-0.35674 0.06307-0.07955 0.17118-0.45864 0.15192-0.69343-0.00896-0.32144 0.03784-0.50379-0.02777-0.76824s-0.17759-0.57095-0.29897-0.75983c-0.12293-0.19131-0.38727-0.37775-0.68813-0.52613-0.16841-0.08321-0.56752-0.16033-0.73681-0.18036-0.32166-0.03817-0.59065 0.10777-0.75396 0.27474-0.14163 0.16188-0.42158 0.49814-0.31004 0.8458 0.10091 0.31458 0.22273 0.64973 0.69454 0.62484 0.32376 0.06738 0.69831-0.03043 0.82301 0.027 0.18511 0.10511 0.25626 0.33725 0.18899 0.49913-0.04692 0.31214-0.34589 0.34954-0.665 0.37333-0.28548 0.0478-1.0106-0.09195-1.1699-0.21134-0.10689-0.06351-0.27054-0.21609-0.38584-0.31911-0.08287-0.07413-0.1767-0.23944-0.24077-0.35817-0.07867-0.14583-0.11839-0.25316-0.13864-0.34821-0.02401-0.11286-0.07347-0.3283-0.05477-0.54373 0.01261-0.14528 0.05156-0.20713 0.09029-0.41958 0.07712-0.24088 0.17294-0.42423 0.32707-0.63313 0.20083-0.20382 0.45588-0.44647 0.70484-0.55922 0.21953-0.11596 0.60414-0.15868 0.95213-0.13832 0.28227 0.027 0.61621 0.06141 0.83695 0.13544 0.37787 0.13499 0.48586 0.16188 0.79601 0.35751 0.26213 0.16221 0.5258 0.46063 0.67297 0.70782 0.31302 0.64054 0.49261 1.3531 0.36204 2.3123-0.01073 0.1621-0.06119 0.32044-0.16221 0.53499-0.09328 0.19806-0.25693 0.43894-0.50246 0.6203-0.09892 0.0915-0.25482 0.20414-0.38174 0.27009-0.18013 0.10932-0.30129 0.16619-0.4894 0.26069-0.16509 0.07756-0.3575 0.1663-0.60846 0.23601-0.27197 0.10755-0.48066 0.11195-0.82267 0.16295-0.33671 0.0845-1.2579 0.0242-1.8066-0.21783-0.37454-0.15745-0.79479-0.43507-1.1142-0.77344-0.17748-0.186-0.35109-0.52348-0.4021-0.59252-0.25405-0.4884-0.30384-0.94782-0.34456-1.0443-0.05533-0.13111-0.08288-0.42511-0.11995-0.62627-0.04182-0.2255-0.01593-1.1712 0.21179-1.7782 0.12835-0.24166 0.20348-0.45709 0.29034-0.57028 0.15613-0.22208 0.23159-0.36083 0.36437-0.51828 0.20016-0.24277 0.34589-0.44083 0.54085-0.58047 0.49681-0.40398 0.91186-0.61709 1.4732-0.88375 0.69753-0.28404 0.90699-0.22528 1.5671-0.21477 0.29698 0.00254 0.72918 0.09593 0.94162 0.13466 0.39458 0.05256 0.62063 0.1476 0.88752 0.22207 0.28536 0.07967 0.45034 0.2068 0.68447 0.34832 0.19275 0.12448 0.41959 0.29499 0.5694 0.43342 0.3833 0.28104 0.5682 0.57648 0.8031 0.93929 0.0426 0.06086 0.3215 0.65538 0.366 0.82091 0.0206 0.08288 0.0838 0.32088 0.1243 0.45997 0.0521 0.22063 0.0773 0.41604 0.1354 0.665 0.052 0.25648 0.1405 0.74079 0.1789 1.1935 0.0645 0.38827-0.0388 1.1808-0.0998 1.4242-0.1357 0.45366-0.3732 1.0074-0.8421 1.5406-0.1992 0.2361-0.411 0.4191-0.65562 0.6177-0.1871 0.1561-0.34766 0.247-0.63667 0.4196-0.39258 0.2342-0.69134 0.3838-1.2416 0.5989-0.22407 0.072-0.50633 0.1666-0.81062 0.2295-0.58942 0.0545-1.1951 0.1067-1.8562 0.0281-0.66123-0.0786-1.3236-0.265-1.7148-0.3851-0.21588-0.081-0.63413-0.2564-0.80951-0.3508-0.11408-0.058-0.49659-0.3195-0.56154-0.3825-0.26213-0.1622-0.84879-0.7139-1.2275-1.2282-0.08299-0.1589-0.38141-0.63569-0.44614-0.84459-0.17206-0.3533-0.292-0.78063-0.36249-0.9632-0.074356-0.42611-0.19994-0.70052-0.24896-1.1283 0.0056431-0.10069-0.020249-0.33759-0.043042-0.46871 0.033305-0.29278-0.011176-0.56873 0.046915-0.8874 0.051009-0.26899 0.11862-0.3813 0.13466-0.6244 0.015712-0.10401 0.1496-0.41681 0.18523-0.55601 0.10799-0.22926 0.13488-0.41814 0.27585-0.67429 0.20315-0.39922 0.41549-0.85853 0.74179-1.2597 0.09317-0.12437 0.3574-0.42279 0.44746-0.52625 0.21289-0.21278 0.33782-0.24641 0.57925-0.45997 0.02512-0.01582 0.18456-0.19761 0.35153-0.26013 0.27674-0.21377 0.51585-0.29222 0.77919-0.48664 0.5278-0.2629 0.72022-0.35164 1.2441-0.56962 0.30274-0.10799 0.72497-0.16774 0.87701-0.22672 0.28923-0.017925 0.58278-0.11021 0.88707-0.091839 0.34157-0.0032088 0.6858 0.021134 1.029 0.052337 0.23226 0.014274 0.53422 0.02556 0.94384 0.11441z" fill="#cf3311"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="12.417" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/dvd.svg b/static/f/plat/dvd.svg
deleted file mode 100644
index dda54cf6..00000000
--- a/static/f/plat/dvd.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 6.294" xmlns="http://www.w3.org/2000/svg">
-<path d="m7.9729 1.09s-0.96199 1.1845-0.91278 1.26c0.068249-0.075535-0.34538-1.273-0.34538-1.273s-0.086308-0.25178-0.35713-1.0771h-5.155l-0.1792 0.77474h1.6714c0.86904 0 1.3996 0.35924 1.2514 0.99574-0.16009 0.69305-0.91859 0.98893-1.7265 0.98893h-0.30211l0.38863-1.6945h-1.3506l-0.57343 2.4757h1.9178c1.4428 0 2.8116-0.77467 3.0584-1.7701 0.043186-0.18298 0.037096-0.64294-0.073782-0.91369 0-0.00651-0.00636-0.018973-0.012453-0.037589-0.00609-0.00651-0.012463-0.050546 0.012453-0.056982 0.012043-0.00644 0.036749 0.018973 0.036749 0.025063 0 0 0.012537 0.031919 0.024569 0.056909l1.2208 3.522 3.1082-3.585 1.3132-0.00644h0.32056c0.86959 0 1.4062 0.35923 1.2578 0.99573-0.16033 0.69306-0.9243 0.98894-1.7325 0.98894h-0.30838l0.39448-1.6945h-1.3501l-0.57384 2.4757h1.9178c1.4428 0 2.8239-0.77467 3.0522-1.7701 0.23436-0.99574-0.77698-1.7705-2.2323-1.7705h-2.8608c-0.75837 0.91341-0.90032 1.09-0.90032 1.09z" fill="#ccc" stroke-width="1.05"/>
-<path d="m6.6095 4.3788c-3.6499 0-6.6095 0.42839-6.6095 0.95779 0 0.52904 2.9596 0.95743 6.6095 0.95743 3.6567 0 6.6166-0.42839 6.6166-0.95743 1.05e-4 -0.5294-2.9598-0.95779-6.6166-0.95779zm-0.23422 1.298c-0.83859 0-1.5106-0.14503-1.5106-0.32164 0-0.17661 0.67192-0.32129 1.5106-0.32129 0.83214 0 1.5048 0.14461 1.5048 0.32129 0 0.17661-0.67269 0.32164-1.5048 0.32164z" fill="#ccc" stroke-width="1.05"/>
-<path d="m7.9729 1.09s-0.96199 1.1845-0.91278 1.26c0.068249-0.075535-0.34538-1.273-0.34538-1.273s-0.086308-0.25178-0.35713-1.0771h-5.155l-0.1792 0.77474h1.6714c0.86904 0 1.3996 0.35924 1.2514 0.99574-0.16009 0.69305-0.91859 0.98893-1.7265 0.98893h-0.30211l0.38863-1.6945h-1.3506l-0.57343 2.4757h1.9178c1.4428 0 2.8116-0.77467 3.0584-1.7701 0.043186-0.18298 0.037096-0.64294-0.073782-0.91369 0-0.00651-0.00636-0.018973-0.012453-0.037589-0.00609-0.00651-0.012463-0.050546 0.012453-0.056982 0.012043-0.00644 0.036749 0.018973 0.036749 0.025063 0 0 0.012537 0.031919 0.024569 0.056909l1.2208 3.522 3.1082-3.585 1.3132-0.00644h0.32056c0.86959 0 1.4062 0.35923 1.2578 0.99573-0.16033 0.69306-0.9243 0.98894-1.7325 0.98894h-0.30838l0.39448-1.6945h-1.3501l-0.57384 2.4757h1.9178c1.4428 0 2.8239-0.77467 3.0522-1.7701 0.23436-0.99574-0.77698-1.7705-2.2323-1.7705h-2.8608c-0.75837 0.91341-0.90032 1.09-0.90032 1.09z" fill="#ccc" stroke-width="1.05"/>
-<path d="m6.6095 4.3788c-3.6499 0-6.6095 0.42839-6.6095 0.95779 0 0.52904 2.9596 0.95743 6.6095 0.95743 3.6567 0 6.6166-0.42839 6.6166-0.95743 1.05e-4 -0.5294-2.9598-0.95779-6.6166-0.95779zm-0.23422 1.298c-0.83859 0-1.5106-0.14503-1.5106-0.32164 0-0.17661 0.67192-0.32129 1.5106-0.32129 0.83214 0 1.5048 0.14461 1.5048 0.32129 0 0.17661-0.67269 0.32164-1.5048 0.32164z" fill="#ccc" stroke-width="1.05"/>
-</svg>
diff --git a/static/f/plat/fmt.svg b/static/f/plat/fmt.svg
deleted file mode 100644
index bbb1673b..00000000
--- a/static/f/plat/fmt.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 8.329" xmlns="http://www.w3.org/2000/svg">
-<path d="m11.13 1.7221h2.8702v-1.7221h-7.7548v1.7221h2.8389v2.3366h2.0457z" fill="#2eb85c" stroke-width="1.2579"/>
-<path d="m11.137 4.2644h-2.0518v4.0648h2.0518z" fill="#2eb85c" stroke-width="1.2684"/>
-<path d="m0 8.3291h1.8366v-0.86693h-0.4791v-1.4259l1.0609 2.2928h0.63878l0.8784-2.2187v1.3517h-0.33083v0.86693h2.1902v-0.86693h-0.49908l-0.0028258-2.4069h0.50191v-0.78999h-1.9774l-0.88015 2.0676-0.95253-2.0685h-1.9848v0.79093h0.46768v2.4069h-0.46768z" fill="#2eb85c" stroke-width="1.2615"/>
-<path d="m0 0v0.85561h0.45428v2.3737h-0.45428v0.82908h2.3737v-0.82769h-0.48695v-0.61329h1.3842v-0.84045h-1.3629v-0.91997h1.7036v0.79362h1.1358v-1.6506z" fill="#2eb85c" stroke-width="1.2616"/>
-</svg>
diff --git a/static/f/plat/gba.svg b/static/f/plat/gba.svg
deleted file mode 100644
index 466757e0..00000000
--- a/static/f/plat/gba.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 8.004" xmlns="http://www.w3.org/2000/svg">
-<rect width="16" height="8.004" fill="#1900cd" fill-rule="evenodd" stroke-width="1.2804"/>
-<path d="m8.0932 5.7371h-1.0503v-3.5262h1.4908c0.79624 0 1.1859 0.53174 1.1859 1.6791 0 1.3993-0.42352 1.8471-1.6263 1.8471zm7.3354-5.0095h-1.2197l-0.03396 0.11194-1.3723 4.1699-1.4738-4.1699-0.033847-0.083958h-1.3044l0.10164 0.27986 0.25412 0.67167c-0.40659-0.64368-1.0165-0.97952-1.8127-0.97952h-2.6258v5.4293l-1.9482-5.3173-0.033874-0.083958h-0.99952l-0.033874 0.083958-2.2193 6.1569-0.10164 0.27987h2.7952v-1.4553h-1.135l1.135-3.3863 1.6094 4.7296 0.033875 0.083968h3.2696c1.711 0 2.592-1.1194 2.592-3.3023 0-0.39182-0.03388-0.72764-0.0847-1.0355l1.5586 4.2539 0.03385 0.083968h0.76238l0.03385-0.083968 2.1515-6.1569z" fill="#fff" stroke-width="1.179"/>
-</svg>
diff --git a/static/f/plat/gbc.svg b/static/f/plat/gbc.svg
deleted file mode 100644
index d9315fce..00000000
--- a/static/f/plat/gbc.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 7.004" xmlns="http://www.w3.org/2000/svg">
-<path d="m15.499 5.4932c-0.33532-0.10361-0.72057-0.034927-0.72057-0.034927s-1.0887 0.10861-1.6554-0.015478c-0.8784-0.1591-0.84857-1.137-0.84857-1.137s-0.02983-0.64815 0.49943-1.5252c0.65314-1.0828 1.3738-1.2769 1.3738-1.2769s0.18023-0.069724 0.34914 0.023289c0.10503 0.62868 0.6944 0.62481 0.6944 0.62481s0.84846 0.058323 0.80709-1.005c-0.0976-0.87338-0.91223-0.9937-1.2538-1.0751-1.0247-0.19021-1.6891 0.37639-1.6891 0.37639s-0.68697 0.38432-1.5089 1.7231c-0.5595 0.8305-0.62694 1.4825-0.62694 1.4825s-0.06753 0.15535-0.04135 0.84619c-0.01486 0.53935 0.18025 0.93122 0.18025 0.93122s0.29662 0.92778 1.235 1.308c0.57052 0.24451 1.4039 0.24061 1.4039 0.24061s0.80709-0.011591 1.4264-0.046601c0.33783 0.027152 0.49932-0.15523 0.49932-0.15523s0.37897-0.25607 0.34149-0.70245c0 0-0.07349-0.46098-0.46549-0.58228z" fill="#c10b44" stroke-width="1.162"/>
-<path d="m1.7547 0.78689c-1.2779 0.81744-1.8925 2.0638-1.7288 3.7586 0.19013 1.9657 2.6525 3.2044 4.7386 1.9566 0.1824-0.10911 0.1112-0.091749 0.17894-0.15414l0.41733-2.7422h-2.4888l-0.19066 1.2632h1.04l-0.11947 0.67811c-0.59573 0.24632-1.609 0.15386-2.041-0.6472-0.2736-0.50731-0.52719-1.6397 0.62612-2.6807 0.93813-0.84722 2.5626-1.0011 3.5016-0.55432 0 0 0.1192-0.70861 0.20826-1.3713-1.8029-0.64678-3.2037-0.10756-4.1421 0.49348z" fill="#6aab21" stroke-width="1.162"/>
-<path d="m6.5548 0.22341-1.0349 6.6966h3.0164c1.2422 0 3.0587-1.8929 1.4051-3.4398 1.7004-1.8194 0.014469-3.2411-0.88746-3.2567-0.72444-0.012421-2.5135 0-2.5135 0zm0.73945 4.0516h1.1237c1.0651 0 1.0355 1.3454-0.14773 1.3454h-1.1829zm0.41383-2.6296h0.87285c0.9463 0 0.75433 1.2536-0.14745 1.2536h-0.9321z" fill="#c79d05" stroke-width="1.162"/>
-</svg>
diff --git a/static/f/plat/ios.svg b/static/f/plat/ios.svg
deleted file mode 100644
index 28139851..00000000
--- a/static/f/plat/ios.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 14 6.946" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<g transform="matrix(1.0032 0 0 1.0032 0 -.068806)" clip-path="url(#b)">
-<path d="m0.064875 6.8814h1.154v-4.9126h-1.154zm0.57469-5.5568c0.3615 0 0.6442-0.27808 0.6442-0.62566 0-0.35223-0.2827-0.6303-0.6442-0.6303-0.35686 0-0.63956 0.27808-0.63956 0.6303 0 0.3476 0.2827 0.62566 0.63956 0.62566zm4.4215-1.2421c-1.9511 0-3.1747 1.3301-3.1747 3.4574 0 2.1273 1.2235 3.4528 3.1747 3.4528 1.9465 0 3.17-1.3255 3.17-3.4528 0-2.1273-1.2235-3.4574-3.17-3.4574zm0 1.0196c1.1911 0 1.9511 0.94545 1.9511 2.4378 0 1.4877-0.76005 2.4332-1.9511 2.4332-1.1957 0-1.9511-0.94545-1.9511-2.4332 0-1.4923 0.75543-2.4378 1.9511-2.4378zm3.6568 3.8977c0.05098 1.2328 1.0613 1.9929 2.6 1.9929 1.6175 0 2.6371-0.79714 2.6371-2.067 0-0.99642-0.5747-1.5572-1.9326-1.8677l-0.7693-0.17612c-0.8204-0.19464-1.1587-0.45418-1.1587-0.89909 0-0.55615 0.5098-0.92691 1.2652-0.92691 0.7647 0 1.2884 0.3754 1.344 1.0011h1.1401c-0.0278-1.1772-1.001-1.9744-2.4748-1.9744-1.4552 0-2.4888 0.80177-2.4888 1.9882 0 0.95472 0.58396 1.548 1.8166 1.8307l0.8667 0.20392c0.8435 0.19929 1.1864 0.47736 1.1864 0.95935 0 0.55614-0.5608 0.95472-1.3671 0.95472-0.8157 0-1.432-0.4032-1.5062-1.0196h-1.1586z" fill="url(#a)"/>
-</g>
-<defs>
-<clipPath id="b">
-<rect width="14.119" height="7" fill="#fff"/>
-</clipPath>
-<linearGradient id="a" x1=".64982" x2="12.669" y1=".71473" y2="6.0622" gradientUnits="userSpaceOnUse">
-<stop stop-color="#3367ff" offset="0"/>
-<stop stop-color="#8be250" offset=".71187"/>
-<stop stop-color="#dbf141" offset="1"/>
-</linearGradient>
-</defs>
-</svg>
diff --git a/static/f/plat/lin.svg b/static/f/plat/lin.svg
deleted file mode 100644
index 8d2242a0..00000000
--- a/static/f/plat/lin.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1 0 0 1.0056 1 1)" clip-path="url(#a)">
-<path d="m20.056 27.633-8e-4 -0.0012c-0.2478-0.2797-0.3659-0.7983-0.4928-1.3507-0.1266-0.5521-0.2683-1.1474-0.7215-1.5333l-0.0029-0.0025c-0.0898-0.0784-0.1821-0.1445-0.2748-0.1996-0.093-0.0552-0.1874-0.1001-0.2817-0.1356 0.6301-1.8685 0.383-3.7292-0.2531-5.4103-0.7803-2.0636-2.1429-3.8614-3.1837-5.0913-1.165-1.4695-2.3042-2.8643-2.2817-4.9247 0.0347-3.1445 0.3458-8.9757-5.1878-8.9835-0.22499-4.0832e-4 -0.45977 0.0089831-0.70435 0.028582-6.1836 0.49774-4.5434 7.0309-4.6353 9.2182-0.1131 1.5998-0.43731 2.8607-1.5377 4.4246-1.2923 1.5369-3.1126 4.0248-3.9746 6.6148-0.40669 1.2221-0.60023 2.4679-0.42179 3.6471-0.05594 0.0502-0.10903 0.1029-0.16007 0.1568-0.37933 0.4055-0.65984 0.8962-0.97221 1.2266-0.29195 0.2916-0.70762 0.4022-1.1649 0.5659-0.45732 0.1642-0.95914 0.4059-1.2638 0.9906l-0.00204 0.0033c-0.14332 0.2674-0.18987 0.5561-0.18987 0.8489 0 0.2707 0.04002 0.5451 0.08044 0.8092 0.08411 0.5496 0.16945 1.0694 0.05635 1.4214-0.36177 0.9894-0.40832 1.6733-0.15353 2.1699 0.25561 0.4973 0.7803 0.7166 1.3736 0.8407 1.1866 0.2474 2.7933 0.1862 4.0595 0.8575l0.10903-0.2054-0.1078 0.2058c1.3556 0.7088 2.73 0.9604 3.8264 0.7101 0.79541-0.1813 1.4406-0.6549 1.7721-1.3834 0.85748-0.0041 1.7987-0.3675 3.3062-0.4504 1.0228-0.0825 2.3005 0.3634 3.77 0.2817 0.0384 0.1593 0.094 0.3128 0.1699 0.4586l0.0025 0.0041c0.5695 1.1392 1.6279 1.6602 2.7561 1.5712 1.1294-0.0891 2.3315-0.7566 3.3026-1.9118 0.9252-1.1221 2.4609-1.5871 3.4797-2.2012 0.5091-0.3071 0.922-0.6917 0.9542-1.2503 0.0318-0.5582-0.2961-1.1837-1.0499-2.0204z" fill="#4d4d4d"/>
-<path d="m9.8009 9.4604c-0.10739-0.21028-0.32665-0.41036-0.69986-0.56348l-8.2e-4 -4.1e-4 -0.00122-4.1e-4c-0.77622-0.33237-1.1131-0.35605-1.5463-0.63779-0.70517-0.45324-1.2878-0.61208-1.7721-0.61004-0.25357 8.2e-4 -0.48019 0.04574-0.68313 0.11597-0.59002 0.20293-0.9816 0.62636-1.227 0.8587l-4.1e-4 4e-4c0 4.1e-4 -4.1e-4 4.1e-4 -4.1e-4 8.2e-4 -0.04818 0.04573-0.11024 0.08738-0.2605 0.19763-0.15149 0.11065-0.37852 0.27725-0.70517 0.52224-0.29032 0.21763-0.38464 0.50101-0.2842 0.83297 0.10004 0.332 0.42017 0.715 1.0057 1.0461l8.1e-4 8e-4 0.00123 4e-4c0.3634 0.2136 0.61166 0.5015 0.89667 0.7305 0.14251 0.1144 0.29236 0.2164 0.47284 0.2936s0.39117 0.1295 0.65494 0.145c0.61902 0.0359 1.0747-0.1499 1.4769-0.3802 0.40301-0.2299 0.74433-0.5112 1.136-0.6382l8.2e-4 -4e-4 8.5e-4 -4e-4c0.80272-0.2507 1.3752-0.7558 1.5544-1.2356 0.08983-0.24007 0.08697-0.46791-0.02001-0.6782z" fill="#f3d427"/>
-<path d="m7.7643 10.645c-0.63864 0.3329-1.3846 0.7367-2.1784 0.7367-0.79337 0-1.4201-0.3667-1.8709-0.724-0.22539-0.1784-0.40832-0.356-0.54633-0.4851-0.23945-0.18899-0.21077-0.4541-0.11239-0.44627 0.16491 0.02059 0.18984 0.23771 0.29368 0.33487 0.14047 0.1314 0.31645 0.3017 0.5296 0.4707 0.42628 0.3377 0.99467 0.6664 1.7064 0.6664 0.71048 0 1.5398-0.4171 2.0461-0.7011 0.28682-0.1609 0.65178-0.4492 0.94962-0.66787 0.22787-0.16727 0.21956-0.36868 0.40771-0.34675s0.04897 0.22294-0.21452 0.45289c-0.26349 0.22993-0.6757 0.53503-1.0105 0.70953z" fill="#202020"/>
-<path d="m17.544 24.274c-0.0886-0.0033-0.176-0.0029-0.2609-9e-4 -0.0078 4e-4 -0.0155 4e-4 -0.0237 4e-4 0.2193-0.6925-0.2658-1.2033-1.5585-1.788-1.3406-0.5896-2.4087-0.5312-2.5892 0.6652-0.0114 0.0624-0.0208 0.1265-0.0278 0.1911-0.1004 0.0351-0.2009 0.0792-0.3021 0.1343-0.6292 0.3446-0.9731 0.9693-1.1641 1.7362-0.1907 0.766-0.2459 1.6917-0.2981 2.7325v8e-4c-0.0323 0.5231-0.2479 1.2311-0.4659 1.9808-2.196 1.5667-5.2437 2.2453-7.8316 0.4789-0.17517-0.2772-0.37647-0.552-0.58349-0.8231-0.13229-0.1731-0.26826-0.3451-0.40301-0.5149 0.26541 4e-4 0.49121-0.0433 0.67373-0.1258 0.22703-0.1033 0.38627-0.2682 0.46549-0.4806 0.15761-0.4242-8.2e-4 -1.0228-0.50591-1.7068-0.5051-0.6835-1.3605-1.4548-2.6173-2.2257v-4e-4c-0.92362-0.5745-1.4397-1.2785-1.6815-2.0429-0.24214-0.7647-0.20825-1.5916-0.02164-2.4078 0.35769-1.5668 1.2764-3.0906 1.8628-4.0469 0.15761-0.116 0.056348 0.2156-0.5937 1.4226-0.58227 1.1033-1.6713 3.6496-0.18048 5.6373 0.040016-1.4145 0.3777-2.857 0.94485-4.2065 0.82604-1.8722 2.5536-5.1196 2.6908-7.7075 0.07104 0.0514 0.314 0.2156 0.4222 0.2772 4.1e-4 4e-4 4.1e-4 4e-4 8.2e-4 4e-4 0.31685 0.1867 0.55491 0.4594 0.86319 0.7073 0.3091 0.2482 0.69496 0.4626 1.278 0.4965 0.05594 0.0032 0.11066 0.0049 0.16415 0.0049 0.60104 0 1.0698-0.196 1.4602-0.4194 0.42429-0.2425 0.76315-0.5112 1.0845-0.6157 4.1e-4 -4e-4 8.2e-4 -4e-4 0.00123-4e-4 0.67903-0.2123 1.2184-0.588 1.5254-1.0257 0.52751 2.0791 1.7541 5.0823 2.5426 6.5478 0.4194 0.7774 1.2531 2.4295 1.6133 4.4201 0.2283-7e-3 0.4798 0.0261 0.7489 0.0951 0.942-2.4422-0.7987-5.0722-1.5949-5.8047-0.3214-0.312-0.3369-0.4516-0.1772-0.4451 0.8632 0.764 1.997 2.2997 2.4095 4.0334 0.1882 0.7905 0.2282 1.6219 0.0265 2.4422 0.0984 0.0408 0.1988 0.0853 0.3005 0.1335 1.512 0.7362 2.071 1.3764 1.8023 2.2503z" fill="#ccc"/>
-<path d="m9.872 7.0652c0.00327 0.41199-0.06778 0.76274-0.22417 1.1208-0.08901 0.20416-0.19146 0.37565-0.31436 0.52428-0.04169-0.02001-0.08493-0.0392-0.12989-0.05757-0.15557-0.06656-0.29318-0.12127-0.41645-0.16782-0.12335-0.04655-0.21959-0.07836-0.31885-0.11266 0.0719-0.08697 0.21355-0.18946 0.26626-0.31808 0.08003-0.19395 0.11923-0.38341 0.12658-0.60921 0-0.00899 0.00286-0.01674 0.00286-0.02736 0.00449-0.21641-0.02409-0.40138-0.08738-0.59084-0.06615-0.19885-0.1503-0.34176-0.27194-0.46059-0.12209-0.11882-0.24377-0.17272-0.38995-0.17762-0.00694-4e-4 -0.01347-4e-4 -0.02041-4e-4 -0.1372 4e-4 -0.25643 0.04777-0.37978 0.15067-0.12944 0.1082-0.22539 0.24662-0.30538 0.43935-0.07963 0.19273-0.11883 0.38382-0.12662 0.61085-0.00119 0.00898-0.00119 0.01674-0.00119 0.02572-0.00286 0.12495 0.00531 0.23928 0.0245 0.35034-0.28092-0.14005-0.64033-0.24218-0.88859-0.30138-0.01429-0.10739-0.02246-0.21805-0.02491-0.33401v-0.03144c-0.00449-0.41077 0.06288-0.76315 0.2209-1.1208 0.15802-0.3581 0.35361-0.61534 0.62878-0.82481 0.27561-0.20906 0.54637-0.30501 0.86686-0.30828h0.01511c0.31359 0 0.58186 0.09228 0.85747 0.29195 0.2797 0.20334 0.48141 0.45732 0.64392 0.81256 0.15925 0.34625 0.23601 0.68475 0.24381 1.0861-4e-5 0.01062-4e-5 0.0196 0.00282 0.03022z" fill="#ccc"/>
-<path d="m5.1322 7.4756c-0.04124 0.01185-0.08126 0.0245-0.12087 0.03798-0.22457 0.07758-0.40288 0.1632-0.57519 0.27712 0.01674-0.11923 0.01919-0.24009 0.00612-0.37525-0.00122-0.00735-0.00122-0.01347-0.00122-0.02082-0.01797-0.17925-0.05594-0.32952-0.11923-0.48141-0.06737-0.15802-0.14291-0.26949-0.24214-0.35524-0.08983-0.07758-0.17476-0.11351-0.26867-0.1127-0.00939 0-0.01919 4.1e-4 -0.02899 0.00123-0.10535 0.00898-0.19273 0.06043-0.27562 0.16128-0.08248 0.10045-0.13679 0.2254-0.17598 0.39118-0.0392 0.16537-0.04941 0.32788-0.03308 0.51448 0 0.00735 0.00164 0.01347 0.00164 0.02082 0.01796 0.18089 0.0543 0.33115 0.11882 0.48305 0.06614 0.15639 0.14291 0.26786 0.24213 0.3536 0.01674 0.0143 0.03307 0.02736 0.04941 0.03879-0.1029 0.07963-0.17203 0.1361-0.25696 0.19817-0.05431 0.0396-0.11883 0.08697-0.19396 0.1425-0.16373-0.15353-0.29154-0.34625-0.40342-0.60064-0.13229-0.30052-0.20293-0.60145-0.22416-0.95669v-0.00286c-0.0196-0.35524 0.0151-0.66066 0.11269-0.9767 0.098-0.31605 0.22866-0.5447 0.41853-0.73253 0.18946-0.18824 0.38056-0.28297 0.61085-0.29481 0.01796-8.2e-4 0.03552-0.00122 0.05308-0.00122 0.20865 4e-4 0.39484 0.06982 0.58757 0.22376 0.20906 0.167 0.36708 0.38055 0.49938 0.68148 0.1327 0.30093 0.20334 0.60187 0.22294 0.95711v0.00285c0.00939 0.14904 0.00817 0.2895-0.00367 0.42547z" fill="#ccc"/>
-<path d="m6.1798 8.3252c0.02641 0.08478 0.16304 0.07073 0.24198 0.11139 0.06927 0.03568 0.12499 0.11388 0.20287 0.11613 0.07433 0.00214 0.19002-0.02575 0.19969-0.09948 0.01277-0.09741-0.12948-0.15931-0.22101-0.195-0.11779-0.04592-0.2687-0.06922-0.37919-0.00778-0.02532 0.01407-0.05296 0.04709-0.04434 0.07474z" fill="#202020"/>
-<path d="m5.3728 8.3252c-0.02642 0.08478-0.16305 0.07073-0.24199 0.11139-0.06926 0.03568-0.12498 0.11388-0.20286 0.11613-0.07433 0.00214-0.19002-0.02575-0.19969-0.09948-0.01277-0.09741 0.12947-0.15931 0.22101-0.195 0.11779-0.04592 0.2687-0.06922 0.37919-0.00778 0.02532 0.01407 0.05295 0.04709 0.04434 0.07474z" fill="#202020"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="13.922" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/mac.svg b/static/f/plat/mac.svg
deleted file mode 100644
index 534e91d3..00000000
--- a/static/f/plat/mac.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg enable-background="new 0 0 499.524 554.546" overflow="visible" version="1.1" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(.012005 0 0 .012005 1.002 -3.2879e-6)" clip-rule="evenodd" fill-rule="evenodd" stroke-miterlimit="2.6131">
-<path d="m54.232 423.01c0.34857-0.57566 0.71814-1.1429 1.0877-1.6995 59.734-92.356 153.96-146.41 242.57-146.41 90.228 0 146.95 49.669 221.53 49.669 72.366 0 116.44-49.74 220.76-49.74 78.815 0 162.38 43.098 221.9 117.62-16.456 9.0502-31.302 19.326-44.571 30.619l-863.27-0.0589z" fill="#4d9537" stroke="#61bb46" stroke-width="6.2809"/>
-<path d="m661.05 191.62c37.864-48.834 66.665-117.79 56.227-188.28-61.907 4.2615-134.32 43.84-176.61 95.362-38.376 46.805-70.059 116.22-57.724 183.65 67.587 2.1107 137.5-38.415 178.11-90.73z" fill="#4d9537" stroke="#61bb46" stroke-width="6.2809"/>
-<path d="m51.151 421.65c-25.724 40.471-43.523 98.477-48.247 149.35l834.55-0.0215c9.0233-55.662 39.027-108.84 86.582-149.27l-872.89-0.0615h0.04925z" fill="#ca8a02"/>
-<path d="m10.483 719.03c-8.1095-51.965-9.4219-102.06-4.3277-148.03l828.05-0.0213c-8.099 49.844-1.3019 101.71 19.184 148.08l-842.91-0.0317z" fill="#c35f09" stroke="#f5821f" stroke-width="6.2809"/>
-<path d="m50.95 867.05c-19.554-49.73-32.942-99.677-40.468-148.01l842.91 0.0317c26.095 59.079 74.386 109.27 142.39 135.06-2.02 4.5104-3.9687 8.8246-5.8438 12.983l-938.98-0.0632z" fill="#b01c1f" stroke="#e03a3e" stroke-width="6.2809"/>
-<path d="m989.93 867.11c-23.346 51.541-36.879 78.291-69.187 127.67-4.3466 6.6316-8.8382 13.415-13.515 20.262l-778.04 0.072c-2.4925-3.7281-4.9619-7.454-7.3935-11.15-29.089-44.49-52.639-90.616-70.838-136.92l938.98 0.0632z" fill="#903b91" stroke="#963d97" stroke-width="6.2809"/>
-<path d="m907.23 1015c-47.595 70.013-111.49 147.06-189.03 147.79-75.657 0.6895-95.066-49.441-197.71-48.876-102.64 0.5757-124.07 49.751-199.73 49.04-80.888-0.7507-143.7-76.416-191.58-147.88l778.04-0.072z" fill="#0092cc" stroke="#009ddc" stroke-width="6.2809"/>
-</g>
-</svg>
diff --git a/static/f/plat/msx.svg b/static/f/plat/msx.svg
deleted file mode 100644
index 9b4ae2bb..00000000
--- a/static/f/plat/msx.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 16 8.404" xmlns="http://www.w3.org/2000/svg">
-<rect width="16" height="8.404" fill="#000" stroke-width="1.3767"/>
-<g transform="matrix(1.0204 0 0 1.4013 .85714 .8404)" clip-path="url(#a)" fill="#e4e4e4">
-<path d="m0 4.7808 1.2145-4.7808h1.1262l0.58518 2.0867 0.60725-2.0867h1.0599l0.99368 3.5221 2.4244-0.00458c0.2545-0.00248 0.27011-0.52876-0.01884-0.52862l-1.4119-0.01886c-0.60629 0.00587-1.2456-0.65005-1.2145-1.5236 0.00647-0.8013 0.7052-1.4323 1.5991-1.4321l3.6012-0.0032344 0.9164 1.2476 0.8944-1.2587 1.4905 0.01104-1.6341 2.2965 1.7666 2.4901-1.4828-0.00457-1.0281-1.4289-0.978 1.4289-1.5127-0.00133 1.7224-2.4622-0.75078-1.0931-3.1305 0.00457c-0.34164 0.00313-0.32911 0.54061 0.00268 0.53053l1.319 0.02018c0.55746 0.00361 1.3426 0.5829 1.323 1.4873 0.00852 1.0514-0.93848 1.5318-1.3944 1.5062l-3.4526 0.00458-0.57413-2.1511-0.57413 2.1465h-1.0898l-0.59944-2.1355-0.52674 2.1387z" fill="#e4e4e4"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="4.7977" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/n3d.svg b/static/f/plat/n3d.svg
deleted file mode 100644
index c32f5559..00000000
--- a/static/f/plat/n3d.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 16 7.004" xmlns="http://www.w3.org/2000/svg">
-<g transform="scale(1.1429 1.4008)" clip-path="url(#a)">
-<path d="m10.17 4.0054c0.3504 0.17667 1.0899 0.31995 1.6668 0.31995 0.6317 0 0.8939-0.27715 0.8939-0.62484 0-0.31197-0.2433-0.49534-0.941-0.82217-0.9326-0.44056-1.6166-0.78717-1.6166-1.57 0-0.81075 0.8229-1.2876 2.0781-1.2876 0.6739 0 0.9052 0.054413 1.3334 0.15471l3e-3 0.76921c-0.4202-0.10211-0.7928-0.27769-1.3716-0.27769-0.6201 0-0.8842 0.25157-0.8842 0.51093 0 0.37636 0.4068 0.55628 1.121 0.88149 0.9937 0.45471 1.5467 0.80548 1.5467 1.572 0 0.78862-0.6923 1.358-2.2541 1.358-0.6415 0-1.0836-0.054053-1.5754-0.15508z" fill="#b1b1b4" stroke-width=".99957"/>
-<path d="m6.846 0.70339h-0.74313v3.611h0.74313c1.141 0 1.8607-0.62608 1.8607-1.7981 0-1.1717-0.71971-1.8129-1.8607-1.8129zm1.9955 3.9474c-0.36802 0.21367-1.0632 0.3492-1.6715 0.3492h-2.3231v-4.9686h2.3231c0.6083 0 1.3035 0.13735 1.6718 0.35048 0.89471 0.51843 1.1868 1.3482 1.1868 2.1344 0 0.78675-0.28938 1.6152-1.1871 2.1346z" fill="#b1b1b4" stroke-width="1.0017"/>
-<path d="m3.2185 2.2594s1.0838-0.22063 1.0838-1.0782c0-0.8383-1.154-1.1812-2.3818-1.1812-1.1082 0-1.8367 0.1721-1.8367 0.1721v0.76163c0.50299-0.1546 0.98398-0.28629 1.6397-0.28629 0.70368 0 1.2407 0.2668 1.2407 0.64382 0 0.4546-0.53109 0.71419-1.674 0.71419h-0.52527v0.68532h0.48794c1.204 0 1.8886 0.23018 1.8886 0.79193 0 0.5015-0.61171 0.80619-1.3744 0.80619-0.66508 0-1.2735-0.18003-1.767-0.35411v0.82151c0.23702 0.051773 0.8659 0.21233 2.0372 0.21233 1.2975 0 2.4265-0.53036 2.4265-1.4652 0-0.784-0.78216-1.244-1.2453-1.244z" fill="#d0000f" stroke-width=".99686"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="5" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/nds.svg b/static/f/plat/nds.svg
deleted file mode 100644
index 33ca9c56..00000000
--- a/static/f/plat/nds.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 14 5.95" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#a)" fill="#ccc">
-<path d="m8.1423 4.7688c0.53562 0.21475 1.6669 0.38529 2.5498 0.38529 0.9664 0 1.3663-0.32845 1.3663-0.74532 0-0.37897-0.3714-0.59373-1.4383-0.98533-1.4268-0.52426-2.4734-0.94113-2.4734-1.8823 0-0.96638 1.2595-1.5412 3.179-1.5412 1.0308 0 1.3845 0.063161 2.0389 0.18317l5e-3 0.92217c-0.643-0.12-1.2127-0.32844-2.0976-0.32844-0.9487 0-1.353 0.30318-1.353 0.61267 0 0.44846 0.62276 0.66321 1.7149 1.0548 1.5203 0.5432 2.3661 0.96007 2.3661 1.8759 0 0.94744-1.0593 1.6296-3.4481 1.6296-0.98089 0-1.6574-0.06316-2.4096-0.18949z"/>
-<path d="m3.0577 0.8157h-1.1369v4.3077h1.1369c1.7452 0 2.8461-0.75164 2.8461-2.1475s-1.1009-2.1602-2.8461-2.1602zm3.0514 4.7056c-0.56214 0.25266-1.6258 0.41688-2.5556 0.41688h-3.5535v-5.9247h3.5535c0.92976 0 1.9934 0.16422 2.5568 0.41687 1.3687 0.619 1.8147 1.6106 1.8147 2.5455s-0.44214 1.9265-1.8159 2.5454z"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="5.9499" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/nes.svg b/static/f/plat/nes.svg
deleted file mode 100644
index 07167434..00000000
--- a/static/f/plat/nes.svg
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.0" viewBox="0 0 16 11.724" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(-.22261)" fill="#ca1c02">
-<g transform="matrix(.011932 0 0 .011932 -3.9022 -.0070676)">
-<path transform="matrix(1.0253 0 0 1.0253 -30.273 -2.8852)" d="m1112 147.46a144.02 144.02 0 1 1-288.03 0 144.02 144.02 0 1 1 288.03 0z"/>
-<rect transform="rotate(17.753)" x="598.35" y="-211.4" width="174.1" height="1019.9" rx="87.049" ry="87.049"/>
-<path transform="matrix(1.0253 0 0 1.0253 -125.27 297.25)" d="m1112 147.46a144.02 144.02 0 1 1-288.03 0 144.02 144.02 0 1 1 288.03 0z"/>
-</g>
-<g transform="matrix(.011932 0 0 .011932 2.9789 -.0070676)">
-<path transform="matrix(1.0253 0 0 1.0253 -30.273 -2.8852)" d="m1112 147.46a144.02 144.02 0 1 1-288.03 0 144.02 144.02 0 1 1 288.03 0z"/>
-<rect transform="rotate(17.753)" x="598.35" y="-211.4" width="174.1" height="1019.9" rx="87.049" ry="87.049"/>
-<path transform="matrix(1.0253 0 0 1.0253 -125.27 297.25)" d="m1112 147.46a144.02 144.02 0 1 1-288.03 0 144.02 144.02 0 1 1 288.03 0z"/>
-</g>
-</g>
-</svg>
diff --git a/static/f/plat/oth.svg b/static/f/plat/oth.svg
deleted file mode 100644
index 4f49830f..00000000
--- a/static/f/plat/oth.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
-<path d="m8 1c-3.8657 0-7 3.1343-7 7 0 3.8657 3.1343 7 7 7 3.8657 0 7-3.1343 7-7 0-3.8657-3.1343-7-7-7zm0 12.534c-0.54143 0-0.98-0.44-0.98-0.98 0-0.54143 0.44-0.98 0.98-0.98 0.54143 0 0.98 0.44 0.98 0.98 0 0.54143-0.43857 0.98-0.98 0.98zm1.3571-4.4528c-0.00571 0.00286-0.011429 0.00428-0.017143 0.00856-0.37429 0.17572-0.6157 0.55715-0.6157 0.97001 0 0.4-0.3243 0.72429-0.7243 0.72429-0.4 0-0.72429-0.32429-0.72429-0.72429 0-0.96429 0.56144-1.8543 1.43-2.2714 0.00571-0.00286 0.011418-0.00572 0.017133-0.00858 0.59571-0.28 0.98001-0.88572 0.98001-1.5429 0-0.94-0.7643-1.7043-1.7043-1.7043s-1.7043 0.76429-1.7043 1.7043c0 0.4-0.32429 0.72429-0.72429 0.72429s-0.72429-0.32429-0.72429-0.72429c0-1.7386 1.4143-3.1514 3.1514-3.1514 1.7386 0 3.1514 1.4143 3.1514 3.1514 0.0029 1.2086-0.69999 2.3229-1.7914 2.8443z" fill="#bec10b" stroke-width="1.0204"/>
-</svg>
diff --git a/static/f/plat/p88.svg b/static/f/plat/p88.svg
deleted file mode 100644
index 9ac81f99..00000000
--- a/static/f/plat/p88.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1.1508 0 0 1.0186 -.57077 0)" clip-path="url(#a)" clip-rule="evenodd" fill-rule="evenodd">
-<path d="m8.9642 5.406c1.8301-0.24018 3.4232 1.2188 2.8632 3.1134-0.08753 0.29628-0.5887 0.92963-0.5887 0.92963s0.76531 0.71775 0.84903 1.1811c0.34911 1.933-1.09 3.0717-2.5509 3.1133-1.5474 0.04428-3.2502-1.0174-2.9152-3.0078 0.045294-0.2691 0.22247-0.52559 0.31239-0.68598 0.090597-0.16165 0.51705-0.60064 0.51705-0.60064s-0.56755-0.9139-0.62118-1.299c-0.19581-1.4066 0.63185-2.5468 2.1343-2.744zm-0.3644 2.3235c0 1.0146 1.5097 1.0014 1.5097 0.051088 0-1.0749-1.5097-1.0881-1.5097-0.051088zm0.7804 4.4308c1.1807 0 1.187-1.7941-0.020195-1.7941-1.2111 0-1.1796 1.7941 0.020195 1.7941z" fill="#cc6700" stroke-width=".89574"/>
-<path d="m9.8537 0.098995v1.0274c-2.8695-0.75733-2.8503 3.7977-0.038513 3.0433v1.0281c-4.3531 1.0697-4.3723-6.0836 0.038513-5.0988z" fill="#e6e6e6" stroke-width="1.2442"/>
-<path d="m4.5066 3.1859 0.00365-1.0261c0.50733 0 0.81108-0.17267 0.81108-0.5339 0-0.29867-0.050264-0.59291-1.0769-0.59291l0.00424 4.2685h-1.1027l0.012513-5.3015 1.0648 1.0106e-4c2.0448 0.0068367 2.2856 0.81297 2.2856 1.5927 0 1.1204-0.79849 1.5932-2.0024 1.5932z" fill="#e6e6e6" stroke-width="1.2448"/>
-<path d="m3.3157 5.406c1.8301-0.24018 3.4232 1.2188 2.8632 3.1134-0.087573 0.29628-0.5887 0.92963-0.5887 0.92963s0.7653 0.71775 0.84902 1.1811c0.34914 1.933-1.09 3.0717-2.5509 3.1133-1.5474 0.04427-3.2502-1.0174-2.9152-3.0078 0.045297-0.2691 0.22247-0.52558 0.31239-0.68598 0.090592-0.16165 0.51706-0.60064 0.51706-0.60064s-0.56756-0.9139-0.62118-1.299c-0.19581-1.4066 0.63185-2.5468 2.1343-2.744zm-0.3644 2.3235c0 1.0146 1.5097 1.0014 1.5097 0.051088 0-1.0749-1.5097-1.0881-1.5097-0.051088zm0.78039 4.4308c1.1808 0 1.1871-1.7941-0.020197-1.7941-1.2111 0-1.1796 1.7941 0.020197 1.7941z" fill="#cc6700" stroke-width=".89574"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="12.753" height="14" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/p98.svg b/static/f/plat/p98.svg
deleted file mode 100644
index 05218723..00000000
--- a/static/f/plat/p98.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1.1614 0 0 1 -.57631 0)" clip-path="url(#a)" clip-rule="evenodd" fill-rule="evenodd">
-<path d="m4.0443 14h-1.8974l1.5352-3.174c-0.41488 0-1.5612-0.22658-1.9793-0.61306-0.54209-0.50097-0.98088-1.5142-0.67574-2.7193 0.33691-1.3303 1.7933-2.3326 3.3785-1.9195 1.1335 0.29538 1.796 0.87909 1.9909 2.1531 0.089774 0.58675 0.052659 1.1574-0.32194 2.0201zm-1.4059-5.7066c0 1.3811 2.0791 1.4192 2.0791 0.053321 0-1.4261-2.0791-1.4394-2.0791-0.053321z" fill="#c00" stroke-width=".88492"/>
-<path d="m8.8869 5.5064c1.8134-0.24464 3.3919 1.2415 2.837 3.1712-0.08678 0.30178-0.58327 0.9469-0.58327 0.9469s0.75825 0.73111 0.84122 1.203c0.34596 1.9689-1.08 3.1288-2.5275 3.1711-1.5332 0.04509-3.2204-1.0363-2.8886-3.0636 0.04488-0.27412 0.22043-0.53541 0.30953-0.69876 0.089759-0.16462 0.51232-0.61176 0.51232-0.61176s-0.56237-0.93088-0.6155-1.3231c-0.19402-1.4327 0.62607-2.5941 2.1148-2.795zm-0.36106 2.3666c0 1.0334 1.4959 1.02 1.4959 0.052031 0-1.0949-1.4959-1.1083-1.4959-0.052031zm0.77326 4.5132c1.17 0 1.1762-1.8274-0.02002-1.8274-1.2 0-1.1688 1.8274 0.02002 1.8274z" fill="#c00" stroke-width=".88509"/>
-<path d="m9.8535 0.10082v1.0465c-2.8696-0.77139-2.8503 3.8683-0.038522 3.0998v1.0472c-4.3531 1.0895-4.3723-6.1966 0.038519-5.1935z" fill="#e6e6e6" stroke-width="1.2351"/>
-<path d="m4.5069 3.2447 0.00366-1.045c0.50732 0 0.81108-0.17586 0.81108-0.54376 0-0.30419-0.050265-0.60386-1.0769-0.60386l0.00422 4.3473h-1.1027l0.012505-5.3994 1.0648 1.0292e-4c2.0448 0.0069629 2.2857 0.82797 2.2857 1.622 0 1.1411-0.7985 1.6226-2.0024 1.6226z" fill="#e6e6e6" stroke-width="1.2356"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="12.966" height="14.234" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/pce.svg b/static/f/plat/pce.svg
deleted file mode 100644
index 7a33d8bd..00000000
--- a/static/f/plat/pce.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 14 12.619" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1.0396 0 0 1.0396 -.22852 0)" clip-path="url(#a)" clip-rule="evenodd" fill="#cc2000" fill-rule="evenodd">
-<path d="m1.0845 9.848s0.98149 0.03059 1.1284-0.59366l0.06026-1.1808 1.1382-0.42475c0.90263-0.39722 1.5121-1.1298 1.5121-2.3616v-1.2403c0-1.0108-0.9811-1.2275-1.6141-1.0194l-3.0583 1.1044-0.031311 4.8212c0.043035 0.93441 0.86472 0.895 0.86472 0.895zm0.93782-4.6501c0-0.06787 0.04247-0.10619 0.17421-0.1656l0.68377-0.25916c0.21669-0.08296 0.28457-0.05418 0.28457 0.15713v1.0279c0 0.12419-0.12835 0.22622-0.27179 0.27609l-0.65838 0.23361c-0.15175 0.05418-0.21238 0.05726-0.21238-0.1105z"/>
-<path d="m8.2369 3.5842c0.103-0.03615 1.4781-0.52679 1.4781-0.52679 0.14762-0.05725 0.11862-0.3769 0.11862-0.44169 0-0.97587-0.85363-1.425-1.461-1.2063l-1.4271 0.49263c-0.8389 0.31426-1.699 0.77304-1.8859 2.1917v2.7354c0 1.6863 1.3168 1.3805 1.8349 1.1893 0.51834-0.19114 2.2652-0.83044 2.4134-0.88661 0.40709-0.1539 0.71274-0.57036 0.65029-1.1055-0.04356-0.33321-0.09069-0.66596-0.14126-0.99818-0.01708-0.09758-0.02144-0.1953-0.13596-0.15299l-1.4274 0.52666c-0.13583 0.05109-0.22069 0.07217-0.22069 0.23792 0 0.1656 0.01271 0.30149-0.22929 0.38229 0 0-0.67545 0.23254-0.75626 0.26332-0.08079 0.03078-0.21237 0.04355-0.21237-0.19546v-2.37c0-0.11466 0.04248-0.16451 0.19545-0.22085 0.15298-0.05633 0.8072-0.28041 0.8072-0.28041 0.12617-0.04031 0.22069-0.04354 0.22069 0.09343v0.14436c0 0.19667 0.07559 0.16375 0.17859 0.12772z"/>
-<path d="m12.682 0.032706-2.37 0.82506c-0.0956 0.031865-0.1221 0.11989-0.1221 0.23993v5.5388c0 0.1815 0.0574 0.19752 0.1868 0.15277 0 0 2.9468-1.0596 3.0243-1.0872 0.0776-0.02766 0.1019-0.01919 0.1019-0.11901v-0.50954c0-0.17636-0.0243-0.24531-0.1868-0.18698l-2.1577 0.78151c-0.1487 0.05415-0.2039 0.0838-0.2039-0.11902v-1.444c0-0.14759 0.0286-0.15082 0.0849-0.16991 0.0564-0.01923 1.2573-0.45892 1.2573-0.45892 0.0892-0.03494 0.2039-0.07726 0.2039-0.18668v-0.47586c0-0.09557-0.0531-0.16344-0.272-0.08511l-1.0704 0.39076c-0.1805 0.07341-0.2039 0.03401-0.2039-0.1359v-1.4271c0-0.06464-0.0223-0.09434 0.1359-0.15266l1.6821-0.62884c0.1284-0.047858 0.1529-0.025537 0.1529-0.15297v-0.47555c0-0.13911-0.036-0.1836-0.2432-0.11358z"/>
-<path d="m8.9231 9.1119-2.8924 3.1728c-0.06048 0.0767-0.02554 0.1022 0.0511 0.0637l3.7462-2.3445c0.07003-0.0446 0.08295-0.05429 0.14036-0.03828 0.05709 0.016 1.4016 0.98128 1.4016 0.98128 0.0955 0.0637 0.1434 0.0351 0.1145-0.0383l-0.7007-1.5292c-0.0382-0.08281-0.0511-0.13699-0.013-0.17838l2.8289-3.0837c0.0734-0.10203 0.0318-0.15297-0.089-0.08925l-3.5043 2.1789c-0.08293 0.05094-0.12741 0.04464-0.1785 0.01278l-1.2997-0.87923c-0.13712-0.07648-0.17822-0.04463-0.14005 0.10204l0.5862 1.4527c0.03801 0.07971 0.01585 0.1402-0.05125 0.21669z" stroke="#fff" stroke-miterlimit="2.613" stroke-width=".10204"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="12.138" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/pcf.svg b/static/f/plat/pcf.svg
deleted file mode 100644
index 22e2c5e0..00000000
--- a/static/f/plat/pcf.svg
+++ /dev/null
@@ -1,20 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg enable-background="new 0 0 407 136" version="1.1" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(.10931 0 0 .10931 .13362 -.53802)" fill="#666">
-<circle cx="58.075" cy="13.716" r="4.039"/>
-<path d="m58.075 107.3-27.825-15.838 7.535-4.062c0.019 0.044 0.031 0.08 0.051 0.125 5.45 7.98 16.47 8.57 25.22 7.76 5.19-1.655-1.06 2.033-0.73 3.49 0.94 2.7 4.047 1.088 5.482 0.668 3.77-2.16 7.5-4.563 9.838-8.747 1.287-3.878 0.724-1.814 3.75-5.09 2.77-2.86 3-7.33 6.24-9.84 3.47-3.12 5.02-7.55 6.87-11.67 6.24-5.78 11.96-13.38 11.74-22.31 0.34-6.76-3.84-12.78-8.83-16.93-8.22-6.71-18.67-10.14-29.04-11.73-2.41-0.89-4.8 1.96-3.14 4.12 4.75 1.95 10.08 1.89 14.83 3.92 7.63 2.8 15.48 7.04 19.62 14.34 3.53 7.16-0.16 15.82-5.54 21l-0.18 0.18c-2.04-3.3-4.2-6.63-7.38-8.95-8.156-6.182-18.558-8.406-28.57-9.014v-19.217l-50.09 27v56.99l0.07-0.038v1.038l50.079 28.505 50.08-28.505v-25.7zm-0.059-43.565h0.06c5.65 0.33 11.49 1.21 16.37 4.26 0.644 0.494 1.383 0.802-0.533 1.385-1.422 0.513-6.265 1.407-9.925 1.917 0 0-3.304 0.208-4.452 0.158-0.194-8e-3 -0.737-0.037-1.52-0.087zm-3.548 14.673c3.073-0.293 6.175-0.152 9.058 0.538 1.72 0.49 3.84 0.49 4.94 2.11-5.6 0.95-11.345 1.071-16.97 0.7-0.789-0.161-1.673-0.244-2.56-0.366zm3.088 11.837c-5.02-0.24-10.81-0.67-14.48-4.57 10.02 2.51 20.59 2.43 30.52-0.5-4.33 3.85-10.43 4.94-16.04 5.07zm17.91-11.82c-1.345-1.67-3.157-2.545-5.06-3.07 3-0.8 6.01-1.61 8.99-2.49 1.68 3.1-1.275 4.82-3.93 5.56zm9.53-25.2c2.38 2.03 3.62 4.94 4.41 7.89-1.8 1.3-3.65 2.53-5.52 3.72-1.27 0.35-2.77 1.98-3.99 0.58-6.223-4.809-14.171-6.574-21.88-7.073v-14.157c9.55 0.15 19.76 2.41 26.98 9.04z"/>
-</g>
-<g transform="matrix(.10931 0 0 .10931 .13362 -.53802)">
-<path d="m69.24 11.49c-1.66-2.16 0.73-5.01 3.14-4.12 10.37 1.59 20.82 5.02 29.04 11.73 4.99 4.15 9.17 10.17 8.83 16.93 0.22 8.93-5.5 16.53-11.74 22.31-1.85 4.12-3.4 8.55-6.87 11.67-3.24 2.51-3.47 6.98-6.24 9.84-3.026 3.276-2.463 1.213-3.75 5.09-2.338 4.185-6.068 6.587-9.838 8.747-1.435 0.42-4.543 2.033-5.482-0.668-0.33-1.457 5.92-5.145 0.73-3.49-8.75 0.81-19.77 0.22-25.22-7.76-1.048-2.416-0.903-3.166-0.91-4.56-3.95-2.85-6.74-7.22-6.4-12.26-4.38-3.04-8.54-7.37-8.73-13.02-0.75-5.54 3.55-10.02 7.92-12.68 8.43-5.05 18.55-6.31 28.21-6.29 10.04 0.6 20.48 2.82 28.66 9.02 3.18 2.32 5.34 5.65 7.38 8.95l0.18-0.18c5.38-5.18 9.07-13.84 5.54-21-4.14-7.3-11.99-11.54-19.62-14.34-4.75-2.029-10.08-1.969-14.83-3.919zm-35.76 34.55c-4.98 3.99-1.3 12.13 3.84 13.72 6.86-5.61 15.97-7.23 24.6-7.18 7.74 0.49 15.73 2.25 21.98 7.08 1.22 1.4 2.72-0.23 3.99-0.58 1.87-1.19 3.72-2.42 5.52-3.72-0.79-2.95-2.03-5.86-4.41-7.89-7.22-6.63-17.43-8.89-26.98-9.04-9.87-0.22-20.69 1.09-28.54 7.61zm34.512 19.502c3.66-0.51 8.503-1.403 9.925-1.917 1.917-0.583 1.177-0.891 0.533-1.385-4.88-3.05-10.72-3.93-16.37-4.26-6.18-0.02-12.67 0.63-18.14 3.72-0.56 0.342-2.357 0.675 0.01 1.35 5.316 1.856 18.443 2.6 19.59 2.65 1.148 0.05 4.452-0.158 4.452-0.158zm-28.242 1.988c0.5 1.298 1.516 3.22 3.547 4.4 0.859-0.571 2.173-1.225 3.273-1.805 0.714-0.289 1.477-0.797 2.21-1.054-0.358-0.025-0.895-0.088-1.952-0.134-2.535-0.309-4.688-0.827-7.078-1.407zm34.66 2.07c1.902 0.525 3.715 1.4 5.06 3.07 2.655-0.74 5.61-2.46 3.93-5.56-2.98 0.88-5.99 1.69-8.99 2.49zm-24.722 5.088c1.687 0.999 4.002 0.942 5.812 1.312 5.625 0.371 11.37 0.25 16.97-0.7-1.1-1.62-3.22-1.62-4.94-2.11-5.73-1.37-12.322-0.583-17.842 1.498zm-2.608 5.232c3.67 3.9 9.46 4.33 14.48 4.57 5.61-0.13 11.71-1.22 16.04-5.07-9.93 2.93-20.5 3.01-30.52 0.5z" fill="#01015b"/>
-</g>
-<circle cx="6.9195" cy=".4415" r=".4415" fill="#01015b" stroke-width=".10931"/>
-<polygon transform="matrix(.10931 0 0 .10931 .13362 -.53802)" points="62.08 127.24 12 98.74 12 73.04 62.08 101.54" fill="#01015b"/>
-<polygon transform="matrix(.10931 0 0 .10931 .13362 -.53802)" points="62.08 127.24 112.16 98.74 112.16 73.04 62.08 101.54" fill="#f00020"/>
-<polygon transform="matrix(.10931 0 0 .10931 .13362 -.53802)" points="11.93 97.74 62.02 70.74 62.02 13.75 11.93 40.75" fill="#cb9a01"/>
-<g transform="matrix(.10931 0 0 .10931 .13362 -.53802)" fill="#3737fd">
-<path d="m69.24 11.49c-1.66-2.16 0.73-5.01 3.14-4.12 10.37 1.59 20.82 5.02 29.04 11.73 4.99 4.15 9.17 10.17 8.83 16.93 0.22 8.93-5.5 16.53-11.74 22.31-1.85 4.12-3.4 8.55-6.87 11.67-3.24 2.51-3.47 6.98-6.24 9.84-3.026 3.276-2.463 1.213-3.75 5.09-2.338 4.185-6.068 6.587-9.838 8.747-1.435 0.42-4.543 2.033-5.482-0.668-0.33-1.457 5.92-5.145 0.73-3.49-8.75 0.81-19.77 0.22-25.22-7.76-1.048-2.416-0.903-3.166-0.91-4.56-3.95-2.85-6.74-7.22-6.4-12.26-4.38-3.04-8.54-7.37-8.73-13.02-0.75-5.54 3.55-10.02 7.92-12.68 8.43-5.05 18.55-6.31 28.21-6.29 10.04 0.6 20.48 2.82 28.66 9.02 3.18 2.32 5.34 5.65 7.38 8.95l0.18-0.18c5.38-5.18 9.07-13.84 5.54-21-4.14-7.3-11.99-11.54-19.62-14.34-4.75-2.029-10.08-1.969-14.83-3.919zm-35.76 34.55c-4.98 3.99-1.3 12.13 3.84 13.72 6.86-5.61 15.97-7.23 24.6-7.18 7.74 0.49 15.73 2.25 21.98 7.08 1.22 1.4 2.72-0.23 3.99-0.58 1.87-1.19 3.72-2.42 5.52-3.72-0.79-2.95-2.03-5.86-4.41-7.89-7.22-6.63-17.43-8.89-26.98-9.04-9.87-0.22-20.69 1.09-28.54 7.61zm34.512 19.502c3.66-0.51 8.503-1.403 9.925-1.917 1.917-0.583 1.177-0.891 0.533-1.385-4.88-3.05-10.72-3.93-16.37-4.26-6.18-0.02-12.67 0.63-18.14 3.72-0.56 0.342-2.357 0.675 0.01 1.35 5.316 1.856 18.443 2.6 19.59 2.65 1.148 0.05 4.452-0.158 4.452-0.158zm-28.242 1.988c0.5 1.298 1.516 3.22 3.547 4.4 0.859-0.571 2.173-1.225 3.273-1.805 0.714-0.289 1.477-0.797 2.21-1.054-0.358-0.025-0.895-0.088-1.952-0.134-2.535-0.309-4.688-0.827-7.078-1.407zm34.66 2.07c1.902 0.525 3.715 1.4 5.06 3.07 2.655-0.74 5.61-2.46 3.93-5.56-2.98 0.88-5.99 1.69-8.99 2.49zm-24.722 5.088c1.687 0.999 4.002 0.942 5.812 1.312 5.625 0.371 11.37 0.25 16.97-0.7-1.1-1.62-3.22-1.62-4.94-2.11-5.73-1.37-12.322-0.583-17.842 1.498zm-2.608 5.232c3.67 3.9 9.46 4.33 14.48 4.57 5.61-0.13 11.71-1.22 16.04-5.07-9.93 2.93-20.5 3.01-30.52 0.5z" fill="#3737fd"/>
-</g>
-<circle cx="6.9195" cy=".4415" r=".4415" fill="#3737fd" stroke-width=".10931"/>
-<polygon transform="matrix(.10931 0 0 .10931 .13362 -.53802)" points="62.08 127.24 12 98.74 12 73.04 62.08 101.54" fill="#3737fd"/>
-<polygon transform="matrix(.10931 0 0 .10931 .13362 -.53802)" points="62.08 127.24 112.16 98.74 112.16 73.04 62.08 101.54" fill="#cc001b"/>
-</svg>
diff --git a/static/f/plat/ps1.svg b/static/f/plat/ps1.svg
deleted file mode 100644
index 6d96dc95..00000000
--- a/static/f/plat/ps1.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="16" height="12.156" enable-background="new 0 0 612.092 560.54" overflow="visible" version="1.0" viewBox="0 0 72.951 66.807" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(-61.686 -312.87)" clip-rule="evenodd" fill-rule="evenodd" stroke-width=".12329">
-<path d="m102.58 326.76-0.0577 52.915-14.177-4.6109v-62.196l18.097 4.7845c11.584 3.1133 18.673 9.165 18.557 19.771-0.11513 12.336-5.8208 17.292-16.944 14.064v-24.325c0-2.9399-5.475-3.112-5.475-0.40271z" fill="#de0029"/>
-<path d="m81.827 364.8-6.6855 2.2486c-4.323 1.499-8.011-2.0169-4.0344-3.4588l3.2272-1.1521-12.161-3.8628c-3.7464 1.2687-7.2622 3.9778-7.0311 7.7824 0.23055 3.8615 9.048 4.7843 15.849 5.8795 6.3392 1.0371 12.103 0.46076 17.348-1.3838v-3.9776zm20.805 14.872 12.391-4.3235-12.506-3.9767v8.0115z" fill="#f3c202"/>
-<path d="m135.6 368.09 0.23038-0.0581c5.4176-1.9016 7.7229-4.5541 7.147-7.0318-0.92222-4.15-7.55-6.3985-17.751-7.148-7.3191-0.51843-14.523 1.0951-21.554 3.5158l-1.1528 0.40395 12.622 3.9196 7.3768-2.479c7.7229-1.4406 10.836 1.0953 3.4006 3.4588l-3.6887 1.2674zm-47.259-18.33-5.591 1.9016 5.591 1.7294z" fill="#326db3"/>
-<path d="m115.02 375.35 20.575-7.2622-13.371-4.1512-19.71 6.7441v0.69254zm-26.684-12.739-6.5126 2.1904 6.5126 2.0752zm14.177 3.4019v-8.2431l12.622 3.9196zm-28.183-3.574 14.005-5.0148v-4.0349l-5.5908-1.7294-20.287 6.8594c-0.05768 0-0.17287 0.0569-0.28806 0.0569z" fill="#00aa9e"/>
-<path d="m102.58 326.76-0.0577 52.915-14.177-4.6109v-62.196l18.097 4.7845c11.584 3.1133 18.673 9.165 18.557 19.771-0.11513 12.336-5.8208 17.292-16.944 14.064v-24.325c0-2.9399-5.475-3.112-5.475-0.40271z" fill="#cc0026"/>
-<path d="m81.827 364.8-6.6855 2.2486c-4.323 1.499-8.011-2.0169-4.0344-3.4588l3.2272-1.1521-12.161-3.8628c-3.7464 1.2687-7.2622 3.9778-7.0311 7.7824 0.23055 3.8615 9.048 4.7843 15.849 5.8795 6.3392 1.0371 12.103 0.46076 17.348-1.3838v-3.9776zm20.805 14.872 12.391-4.3235-12.506-3.9767v8.0115z" fill="#caa202"/>
-<path d="m135.6 368.09 0.23038-0.0581c5.4176-1.9016 7.7229-4.5541 7.147-7.0318-0.92222-4.15-7.55-6.3985-17.751-7.148-7.3191-0.51843-14.523 1.0951-21.554 3.5158l-1.1528 0.40395 12.622 3.9196 7.3768-2.479c7.7229-1.4406 10.836 1.0953 3.4006 3.4588l-3.6887 1.2674zm-47.259-18.33-5.591 1.9016 5.591 1.7294z" fill="#2d639f"/>
-<path d="m115.02 375.35 20.575-7.2622-13.371-4.1512-19.71 6.7441v0.69254zm-26.684-12.739-6.5126 2.1904 6.5126 2.0752zm14.177 3.4019v-8.2431l12.622 3.9196zm-28.183-3.574 14.005-5.0148v-4.0349l-5.5908-1.7294-20.287 6.8594c-0.05768 0-0.17287 0.0569-0.28806 0.0569z" fill="#00ccbe"/>
-</g>
-</svg>
diff --git a/static/f/plat/ps2.svg b/static/f/plat/ps2.svg
deleted file mode 100644
index 4266e39f..00000000
--- a/static/f/plat/ps2.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="16" height="7.0039" version="1.1" viewBox="0 0 4.2333 1.8531" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(.0010335 -1.0573)" fill="#7c72b6">
-<path d="m0 2.9094 0.26458 0.00103 0.001034-0.79478h0.79168v-1.0583h-1.0573v0.26458l0.79272 0.00103v0.52917h-0.79375z"/>
-<path d="m2.1164 1.3213 0.52939 0.0016-0.00103-0.26562h-0.79375v1.5875l-0.79375 0.00103 0.00103 0.26355 1.0583 0.00103z"/>
-<path d="m4.2313 1.0583-1.3219-0.00103 0.00103 0.26458 1.0573 0.00207 0.00206 0.5271h-1.0594v1.0583h1.3208l0.0010334-0.26355-1.0573-0.0010335 0.0010334-0.52813h1.0552z"/>
-</g>
-</svg>
diff --git a/static/f/plat/ps3.svg b/static/f/plat/ps3.svg
deleted file mode 100644
index 5c086b09..00000000
--- a/static/f/plat/ps3.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="16" height="7.004" shape-rendering="crispEdges" version="1.1" viewBox="0 0 4.2333 1.8531" xmlns="http://www.w3.org/2000/svg">
-<g fill="#999">
-<path d="m2.8853-0.0014616 1.0231 0.0029233-0.00146 0.27187 0.3254 0.004382-9.17e-4 0.47064v0.2894l9.725e-4 0.54227-0.31962-0.00292 0.00146 0.27625-1.029-0.00146 0.00146-0.27332 1.0261-0.00146-0.00438-0.54373-1.0217-0.00438 0.00146-0.27332 1.0202 0.00146-0.00146-0.4838-1.0188-0.002923z"/>
-<path d="m0.0010338 1.8521 0.26465 0.00103 0.001034-0.79479h0.79188v-0.79087l-0.26465-0.0018585 0.0026447-0.26562h-0.79555v0.26459l0.79291 0.00103v0.52917h-0.79394z"/>
-<path d="m2.118 0.26402 0.52952 0.0016-0.00103-0.26562h-0.52712l-0.00137 0.26402-0.26545-9.2696e-4v1.3244l-0.79394 0.00103 0.00103 0.26355 1.0586 0.00103z"/>
-</g>
-</svg>
diff --git a/static/f/plat/ps4.svg b/static/f/plat/ps4.svg
deleted file mode 100644
index 6a965136..00000000
--- a/static/f/plat/ps4.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="16" height="7.004" shape-rendering="crispEdges" version="1.1" viewBox="0 0 4.2333 1.8531" xmlns="http://www.w3.org/2000/svg">
-<g fill="#0185cf">
-<path d="m2.4947 1.6553h1.2139c0.00382 0 0.00643 0.00435 0.00643 0.00869v0.17616c0 0.00869 0.00397 0.013037 0.00782 0.013037h0.30048c0.00401 0 0.00779-0.00435 0.00779-0.013037v-0.17182c0-0.00652 0.00401-0.013037 0.00782-0.013037h0.18659c0.00525 0 0.00782-0.00654 0.00782-0.013006v-0.21117c0-0.00654-0.00258-0.013037-0.00782-0.013037h-0.18659c-0.00382 0-0.00782-0.00652-0.00782-0.013036v-1.216c-7e-7 -0.10229-0.022905-0.16743-0.061458-0.18904h-0.1402c-0.020334 0.00866-0.042435 0.024168-0.066141 0.047792l-1.3103 1.342c-0.052138 0.054296-0.070412 0.15245-0.057357 0.19808 0.010394 0.039135 0.040315 0.067392 0.09906 0.067393zm0.27026-0.26763 0.93184-0.9585c0.00654-0.00869 0.019616-0.006501 0.019616 0.01738v0.9585c0 0.00654-0.00401 0.013037-0.00782 0.013037h-0.9372c-0.00903 0-0.01304-0.00432-0.01425-0.00869-0.00143-0.00869 0.0012-0.015221 0.00779-0.021723z" stroke-width=".37452"/>
-<path d="m0.0010338 1.8521 0.26465 0.00103 0.001034-0.79479h0.79188v-0.79087l-0.26465-0.0018585 0.0026447-0.26562h-0.79555v0.26459l0.79291 0.00103v0.52917h-0.79394z"/>
-<path d="m2.118 0.26402 0.52952 0.0016-0.00103-0.26562h-0.52712l-0.00137 0.26402-0.26545-9.2696e-4v1.3244l-0.79394 0.00103 0.00103 0.26355 1.0586 0.00103z"/>
-</g>
-</svg>
diff --git a/static/f/plat/psp.svg b/static/f/plat/psp.svg
deleted file mode 100644
index 9e64a70b..00000000
--- a/static/f/plat/psp.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="16" height="7.004" version="1.1" viewBox="0 0 4.2333 1.8531" xmlns="http://www.w3.org/2000/svg">
-<g fill="#dedede">
-<path d="m0.0010338 1.8521 0.26465 0.00103 0.001034-0.79479h0.79188v-1.0583h-1.0576v0.26459l0.79291 0.00103v0.52917h-0.79394z"/>
-<path d="m2.118 0.26402 0.52952 0.0016-0.00103-0.26562h-0.79394v1.5875l-0.79394 0.00103 0.00103 0.26355 1.0586 0.00103z"/>
-<path d="m3.1768 1.853h0.26465v-0.79414l0.79188 0.00103v-1.0599h-1.0576v0.26471l0.79394-0.00103 0.00207 0.5315-0.79498-0.00104z"/>
-</g>
-</svg>
diff --git a/static/f/plat/psv.svg b/static/f/plat/psv.svg
deleted file mode 100644
index 59b4140c..00000000
--- a/static/f/plat/psv.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="16" height="7.004" version="1.1" viewBox="0 0 4.2333 1.8531" xmlns="http://www.w3.org/2000/svg">
-<g>
-<path d="m0.0010338 1.8521 0.26465 0.00103 0.001034-0.79479h0.79188v-0.79087l-0.26465-0.0018585 0.0026447-0.26562h-0.79555v0.26459l0.79291 0.00103v0.52917h-0.79394z" fill="#33b4ff"/>
-<path d="m2.118 0.26402 0.52952 0.0016-0.00103-0.26562h-0.52712l-0.00137 0.26402-0.26545-9.2696e-4v1.3244l-0.79394 0.00103 0.00103 0.26355 1.0586 0.00103z" fill="#34b6fe"/>
-<path d="m3.4357 1.8528c-0.10657-0.008328-0.15114-0.12542-0.17131-0.17531-0.020163-0.049905-0.61796-1.6775-0.61796-1.6775h0.2974l0.49508 1.3268c0.025791 0.074863 0.084479 0.093298 0.11619 0.00596 0.025919-0.070682 0.49253-1.3328 0.49253-1.3328h0.18578s-0.57897 1.5823-0.60203 1.6364c-0.023057 0.054067-0.08911 0.22481-0.19568 0.21649z" clip-rule="evenodd" display="none" fill="#009bff" fill-rule="evenodd" stroke-width=".34523"/>
-<path d="m2.6464 3.3e-6 0.67749 1.8502 0.26489 0.0058465 0.64459-1.8561h-0.27932l-0.49693 1.5137-0.5104-1.5137z" fill="#34b6fe"/>
-</g>
-</svg>
diff --git a/static/f/plat/sat.svg b/static/f/plat/sat.svg
deleted file mode 100644
index 77b0cda1..00000000
--- a/static/f/plat/sat.svg
+++ /dev/null
@@ -1,44 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 9.131" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<radialGradient id="g" cx="157.5" cy="52.96" r="22.196" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset=".43"/>
-<stop stop-color="#7A7A7A" offset=".64"/>
-<stop offset=".99"/>
-</radialGradient>
-<path d="m12.279 3.573c0.13438-0.012762 0.23141-0.013016 0.29133-0.00127v0.00879c0.19152 0.12187 0.29615 0.24417 0.30494 0.38337-0.06989 0.2178-0.2877 0.46197-0.63624 0.73217-0.4181 0.31339-0.99324 0.6359-1.6991 0.96695-0.3204 0.14613-0.65771 0.28651-1.0123 0.42292-0.40687 0.15669-0.34584 1.2456-0.04725 1.1373 0.53279-0.19304 1.032-0.39816 1.4951-0.61021 0.78423-0.36613 1.4205-0.72338 1.8998-1.0892 0.60996-0.47076 0.95842-0.91489 1.0193-1.342 0.11291-0.41295 0.01428-0.78085-0.29885-1.1137-0.1228-0.13075-1.3775 0.51065-1.3166 0.5049z" fill="url(#g)" stroke-width=".084517"/>
-<radialGradient id="f" cx="50.105" cy="85.897" r="22.91" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset="0"/>
-<stop offset="1"/>
-</radialGradient>
-<path d="m5.2145 7.3119c-0.30621 0.05781-0.60388 0.10827-0.8958 0.15492l0.0088-0.0088c-0.59238 0.08714-1.1068 0.13066-1.5509 0.13945h-0.0088c-0.38328 0-0.67977-0.02603-0.87129-0.095929-0.13075-0.05232-0.26141-0.04353-0.39216 0.01758-0.13945 0.05232-0.22659 0.15669-0.27891 0.28736-0.0524 0.13945-0.04361 0.2702 0.01758 0.40095 0.06077 0.13075 0.15669 0.22667 0.28744 0.27899 0.27891 0.10429 0.68831 0.15669 1.2547 0.15669 0.47921-0.0088 1.0371-0.05232 1.6818-0.14824h0.0088c0.32226-0.04471 0.65349-0.10066 0.99299-0.16641 0.32116-0.06178 0.0704-1.0777-0.25423-1.0166z" fill="url(#f)" stroke-width=".084517"/>
-<radialGradient id="a" cx="61.442" cy="37.691" r="52.493" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset=".8128"/>
-<stop stop-color="#575757" offset="1"/>
-</radialGradient>
-<path d="m5.1463 1.699c-0.29471 0.097701-0.58215 0.19937-0.86258 0.30434-0.65349 0.25296-1.2374 0.50549-1.7602 0.77569h0.0088c-0.56635 0.28744-1.0281 0.56643-1.3854 0.86258v-0.00879c-0.4357 0.33976-0.73184 0.67106-0.88888 0.98445v0.00879c-0.24383 0.44447-0.26141 0.85412-0.06085 1.2374 0.12196 0.37458 0.49654 0.57513 1.1327 0.60996 0.36579 0.02603 0.86258-0.02637 1.49-0.14824 0.31525-0.06508 0.6747-0.15433 1.0804-0.26259 0.24442-0.06533-0.04631-1.0716-0.2871-1.0063-0.37331 0.10167-0.70589 0.18239-1.0026 0.24079-0.51403 0.10438-0.9149 0.1479-1.2198 0.13066-0.12196 0-0.2091-0.00879-0.24383-0.00879l-0.0088-0.00879c-0.02603-0.087136-0.01724-0.19151 0.04361-0.29615v-0.00879c0.11343-0.2003 0.31374-0.4181 0.60997-0.64469 0.32252-0.25262 0.72338-0.50524 1.2111-0.74941 0.488-0.26141 1.0371-0.49653 1.6471-0.73183v0.00879c0.17275-0.065078 0.34855-0.12889 0.52671-0.19092 0.44236-0.15365 0.33376-1.2186-0.03026-1.0982z" fill="url(#a)" stroke-width=".084517"/>
-<path d="m9.487 1.7553c0.33646-0.043273 0.66473-0.07353 0.98336-0.091615 0.62746-0.052316 1.1938-0.043611 1.6903 0.00879 0.14816 0.01758 0.27012-0.026031 0.38328-0.11317 0.11342-0.095926 0.17436-0.20935 0.19185-0.34855 0.0175-0.14824-0.02612-0.2789-0.11317-0.38362-0.09593-0.11317-0.20935-0.18273-0.34855-0.19151-0.5579-0.061106-1.185-0.061106-1.8733-0.01758-0.44202 0.029919-0.90011 0.080207-1.3775 0.15103-0.21941 0.032286 0.15636 1.0255 0.46374 0.98622z" fill="#ccc" stroke-width=".084517"/>
-<radialGradient id="e" cx="162.87" cy="1.0562" r="29.353" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset="0"/>
-<stop offset="1"/>
-</radialGradient>
-<path d="m9.487 1.7553c0.33646-0.043273 0.66473-0.07353 0.98336-0.091615 0.62746-0.052316 1.1938-0.043611 1.6903 0.00879 0.14816 0.01758 0.27012-0.026031 0.38328-0.11317 0.11342-0.095926 0.17436-0.20935 0.19185-0.34855 0.0175-0.14824-0.02612-0.2789-0.11317-0.38362-0.09593-0.11317-0.20935-0.18273-0.34855-0.19151-0.5579-0.061106-1.185-0.061106-1.8733-0.01758-0.44202 0.029919-0.90011 0.080207-1.3775 0.15103-0.21941 0.032286 0.15636 1.0255 0.46374 0.98622z" fill="url(#e)" stroke-width=".084517"/>
-<linearGradient id="d" x1="53.883" x2="44.935" y1="44.714" y2="37.913" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop offset="0"/>
-<stop stop-color="#fff" offset="1"/>
-</linearGradient>
-<path d="m0.78821 4.9003c0 0.56491 0.36156 0.52375 0.36097 0.51919 0-2.537e-4 -5.92e-4 -2.537e-4 -0.0021-2.537e-4l-0.0088-0.00879c-0.02603-0.087136-0.01724-0.19151 0.04361-0.29615v-0.00879c0.11342-0.2003 0.31373-0.4181 0.60996-0.64469 0.32252-0.25262 0.72338-0.50524 1.2111-0.74941 0.488-0.26141 1.0371-0.49653 1.6471-0.73183v0.00879c0.17275-0.065078 0.34855-0.12889 0.52671-0.19092 0.19481-0.067783 0.28288-0.31254 0.28922-0.5546-0.22786-0.031187-0.46265-0.047499-0.70284-0.047499-0.51919 0-1.0139 0.076233-1.4634 0.21391-0.27266 0.12069-0.53094 0.24324-0.77604 0.37001h0.0088c-0.17639 0.089588-0.34246 0.17825-0.4989 0.26682-0.63067 0.48335-1.2454 1.1384-1.2454 1.8542z" fill="url(#d)" stroke-width=".084517"/>
-<path d="m12.736 1.2107c0.017495-0.14824-0.026116-0.2789-0.11317-0.38362-0.095926-0.11317-0.20935-0.18272-0.34855-0.19151-0.5579-0.061105-1.185-0.061105-1.8733-0.017579-0.64512 0.043611-1.3246 0.13066-2.0478 0.26141-0.68848 0.12196-1.3857 0.28769-2.0827 0.47921-0.6971 0.19185-1.359 0.40965-1.9865 0.64469-0.65349 0.25296-1.2374 0.50549-1.7602 0.77569h0.0088c-0.56636 0.28744-1.0281 0.56643-1.3854 0.86258v-0.00879c-0.43569 0.33976-0.73183 0.67106-0.88887 0.98445v0.00879c-0.24383 0.44447-0.26141 0.85412-0.06085 1.2374 0.12196 0.37458 0.48978 0.54809 1.126 0.58283 0.36579 0.02603 0.86934 7.61e-4 1.4968-0.1212 0.50524-0.10438 1.1239-0.2702 1.8646-0.47921 0.54006-0.16548 1.2286-0.38328 2.0649-0.66227l2.074-0.67951c0.75786-0.25262 1.3765-0.44447 1.8558-0.57514 0.58384-0.17427 1.0456-0.27865 1.3941-0.33105 0.24417-0.034821 0.40966-0.043611 0.49679-0.026369v0.00879c0.19152 0.12187 0.29615 0.24417 0.30494 0.38337-0.06989 0.2178-0.2877 0.46197-0.63624 0.73217-0.41811 0.31339-0.99325 0.6359-1.6991 0.96695-0.68831 0.31373-1.455 0.60142-2.3003 0.88007h-0.0088c-0.65349 0.20909-1.3242 0.39216-2.0126 0.55764-0.67098 0.15703-1.2985 0.2702-1.8997 0.36612l0.0088-0.0088c-0.59238 0.08714-1.1068 0.13066-1.5509 0.13945h-0.0088c-0.38328 0-0.67977-0.02603-0.87129-0.095929-0.13075-0.05232-0.26141-0.04353-0.39216 0.01758-0.13945 0.05232-0.22659 0.15669-0.27891 0.28736-0.0524 0.13945-0.04361 0.2702 0.01758 0.40095 0.06077 0.13075 0.1567 0.22667 0.28744 0.27899 0.27891 0.10429 0.68831 0.15669 1.2547 0.15669 0.47921-0.0088 1.0371-0.05232 1.6818-0.14824h0.0088c0.6272-0.08714 1.2895-0.21772 1.9778-0.37458h0.0088c0.71468-0.17427 1.4117-0.36612 2.0913-0.58392 0.88887-0.27865 1.6991-0.59238 2.4221-0.92334 0.78423-0.36613 1.4205-0.72338 1.8998-1.0892 0.60996-0.47076 0.95842-0.91489 1.0193-1.342 0.15702-0.57487-0.095599-1.0629-0.76666-1.4897-0.20909-0.16582-0.60108-0.20935-1.1938-0.13066h-0.0084c-0.38371 0.060768-0.89774 0.17427-1.5336 0.357-0.48792 0.13945-1.1155 0.33122-1.8997 0.59271l-2.0737 0.67951c-0.82776 0.2702-1.4988 0.488-2.0301 0.64469-0.70589 0.20943-1.2897 0.36613-1.7778 0.46197-0.51403 0.10438-0.9149 0.1479-1.2198 0.13066-0.12196 0-0.2091-0.00879-0.24383-0.00879l-0.0088-0.00879c-0.02603-0.087136-0.01724-0.19151 0.04361-0.29615v-0.00879c0.11339-0.2003 0.31369-0.4181 0.60992-0.64469 0.32252-0.25262 0.72338-0.50524 1.2111-0.74941 0.488-0.26141 1.0371-0.49653 1.6471-0.73183v0.00879c0.60117-0.22659 1.2372-0.43568 1.8994-0.61866 0.67107-0.19185 1.333-0.34855 1.9953-0.46171 0.67098-0.13066 1.3158-0.20935 1.9257-0.24417 0.62746-0.052316 1.1938-0.043611 1.6903 0.00879 0.14816 0.01758 0.27012-0.026031 0.38328-0.11317 0.11342-0.095841 0.17427-0.20935 0.19177-0.34855z" fill="none" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width=".1217"/>
-<radialGradient id="c" cx="113.5" cy="35.63" r="47.61" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse" xlink:href="#a">
-<stop stop-color="#fff" offset=".04"/>
-<stop stop-color="#6E93E1" offset=".23"/>
-<stop stop-color="#6867cb" offset="1"/>
-</radialGradient>
-<ellipse cx="7.0311" cy="4.5655" rx="4.4438" ry="4.4438" fill="url(#c)" stroke="#33317a" stroke-linecap="round" stroke-linejoin="round" stroke-width=".24341"/>
-<linearGradient id="b" x1="110.56" x2="106.98" y1="63.437" y2="51.73" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop offset="0"/>
-<stop stop-color="#fff" offset=".9893"/>
-</linearGradient>
-<path d="m2.5694 5.3097c-0.05105 0.0098-1.3936 0.30333-1.6155 0.081474-0.06516-0.065163 0.14123 0.10108 0.35159 0.37475 0.02671 0.034736 0.0095 0.092461-0.02172 0.16354-0.0071 0.01631-0.01487 0.02138-0.02823 0.03896-0.0063 0.0084-0.0081 0.04201-0.0092 0.05054-0.0034 0.02721-0.01775 0.04885-0.02172 0.07285-0.01268 0.076149-0.05756 0.13692-0.06719 0.18551-0.0036 0.01834 0.02375 0.03499 0.02189 0.05003-0.0019 0.01614-0.03474 0.0202-0.03364 0.03305s-0.0371 0.01716-0.03381 0.02781c9.29e-4 3e-3 -0.0012 0.01352 0.0057 0.01513 0.02603 6e-3 0.08781 0.012 0.09643 0.01327 0.01327 0.0019 0.13286 0.01944 0.30443 0.02079 0.17157 0.0013 0.38252 0.0016 0.56728-0.01893 0.11308-0.01251 0.23098-0.02848 0.34187-0.04471 0.14571-0.02138 0.27916-0.04319 0.37323-0.05815 0.08748-0.01386 0.12661-0.0035 0.13895-0.0024 0.48166-0.10497 1.0598-0.26073 1.7414-0.45318 0.54006-0.16548 1.2287-0.38328 2.0649-0.66227l2.074-0.67951c0.41811-0.13945 0.79395-0.26023 1.1273-0.36249 0.27105-0.083164 0.51369-0.15399 0.72854-0.21273 0.26445-0.078685 0.50347-0.14309 0.71856-0.19515 0.26014-0.062965 0.48462-0.10742 0.67555-0.13582 0.24417-0.034821 0.40965-0.043611 0.49679-0.026369v0.00879c0.07353 0.046907 0.39427 0.00964 0.55638 0.25473 0.26116 0.39452 0.38912 1.1049 0.49552 0.95909 0.15217-0.20838 0.24387-0.41232 0.27227-0.61262 0.15703-0.57488-0.095589-1.0411-0.76657-1.468-0.2091-0.16582-0.60117-0.20943-1.1939-0.13066h-0.0084c-0.23242 0.036934-0.51276 0.093222-0.83934 0.1725-0.21205 0.051386-0.4438 0.09077-0.69439 0.16278-0.24197 0.069304-0.51851 0.15128-0.83139 0.24839-0.31762 0.09863-0.67284 0.21239-1.0683 0.34424l-2.0737 0.67951c-0.58663 0.19151-1.0943 0.35674-1.5297 0.49256z" fill="url(#b)" stroke-width=".084517"/>
-<path d="m11.15 2.7096c-0.21214 0.051471-0.50753 0.13675-0.75812 0.20876-0.48792 0.13945-1.1155 0.33122-1.8997 0.59271l-2.0737 0.67951c-0.63109 0.20605-1.165 0.3728-1.6214 0.506-0.19802 0.05781-0.33722 0.088573-0.40839 0.10953-0.6889 0.20419-2.3756 0.69557-3.0307 0.62382" fill="none" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width=".084517"/>
-<path d="m2.8077 6.3253c0.48166-0.10497 1.1954-0.28685 1.877-0.47921 0.54006-0.16548 1.2286-0.38328 2.0649-0.66227l2.074-0.67951c0.75786-0.25262 1.3765-0.44447 1.8558-0.57514 0.26445-0.078685 0.56905-0.15407 0.78415-0.20614" fill="none" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width=".084517"/>
-</svg>
diff --git a/static/f/plat/sfc.svg b/static/f/plat/sfc.svg
deleted file mode 100644
index dccb4641..00000000
--- a/static/f/plat/sfc.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 14 10.041" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#a)" stroke-width="1.0128">
-<path d="m0 5.9382 0.003636-0.072747 0.01082-0.071815 0.01772-0.070873 0.024529-0.06902 0.031152-0.06902 0.037679-0.067157 0.043835-0.065283 0.049991-0.06435 0.055866-0.062487 0.061648-0.060623 0.067245-0.059692 0.072561-0.056887 0.077877-0.055966 0.08282-0.05316 0.087764-0.051297 0.092428-0.049433 0.096902-0.046628 0.10129-0.044774 0.10548-0.041969 0.10941-0.039164 0.11331-0.036378 0.11687-0.034504 0.1204-0.030778 0.12359-0.028914 0.12684-0.025188 0.12964-0.022382 0.13253-0.018655 0.13504-0.01585 0.13748-0.012123 0.13971-0.00933 0.14177-0.0056 0.14363-0.00186c1.5444 0 2.7936 0.63328 2.7936 1.4139 0 0.78344-1.2496 1.4139-2.7936 1.4139-1.5418 6e-7 -2.7915-0.63047-2.7915-1.4139z" fill="#5f9933"/>
-<path d="m5.161 8.556 0.00383-0.076383 0.011372-0.075451 0.018655-0.074337 0.025745-0.073122 0.032732-0.071815 0.039549-0.070509 0.046162-0.06902 0.052512-0.06743 0.05876-0.066215 0.064817-0.06342 0.07061-0.062497 0.076292-0.060613 0.08179-0.057829 0.087107-0.055965 0.092243-0.054092 0.097094-0.051297 0.10184-0.049433 0.10651-0.046628 0.1108-0.043833 0.115-0.041979 0.1191-0.038232 0.12283-0.036378 0.12647-0.032641 0.12992-0.029846 0.13337-0.02611 0.13617-0.023324 0.13897-0.020519 0.1427-0.01585 0.14456-0.013053 0.14642-0.010259 0.14923-0.0056 0.15109-0.00186c1.62 0 2.9361 0.66498 2.9361 1.4855 0 0.81971-1.316 1.4853-2.9361 1.4853-1.6209 0-2.9343-0.66525-2.9343-1.4853z" fill="#c1bc0b"/>
-<path d="m6.3644 5.3003 0.13169-0.0084 0.12992-0.014918 0.12787-0.020519 0.12582-0.027051 0.12339-0.032642 0.12124-0.038241 0.11845-0.044764 0.11565-0.049433 0.11285-0.055034 0.11006-0.059681 0.10632-0.065293 0.10352-0.06901 0.099798-0.074621 0.096061-0.079269 0.092333-0.083005 0.087674-0.086743 0.084869-0.091401 0.079279-0.096061 0.075543-0.098866 0.070884-0.10259 0.066225-0.10539 0.060623-0.10912 0.055955-0.11284 0.050365-0.11473 0.045697-0.11844 0.040105-0.12032 0.033582-0.12311 0.028904-0.12591 0.022392-0.12778 0.01585-0.12963 0.00933-0.13151 0.00373-0.13244c-6.2e-6 -1.4633-1.1882-2.6515-2.6516-2.6515-1.4536 0-2.6344 1.1677-2.6516 2.6189 0.034232-0.00186 0.068463-0.00186 0.1053-0.00186 1.4631 0 2.6516 1.1854 2.6516 2.6516v0.03171" fill="#1489b8"/>
-<path d="m11.523 7.9201 0.12862-0.00839 0.12599-0.013996 0.12498-0.020519 0.12214-0.026109 0.12032-0.032641 0.11748-0.03731 0.11566-0.04291 0.11292-0.048491 0.10999-0.05316 0.10634-0.05876 0.10452-0.063418 0.09976-0.067157 0.09702-0.072747 0.09328-0.076474 0.09044-0.081142 0.08578-0.084869 0.08112-0.089538 0.07839-0.092333 0.07272-0.096993 0.06907-0.099798 0.06431-0.10259 0.05874-0.10633 0.05509-0.11005 0.04942-0.11192 0.04385-0.11472 0.03818-0.11752 0.03362-0.12031 0.02704-0.12218 0.02238-0.12404 0.01489-0.12685 0.0094-0.12777 0.003746-0.12964c0-1.426-1.1537-2.5825-2.5807-2.5825-1.4139 0-2.5629 1.1388-2.5797 2.5508 0.033573-0.00186 0.068078-0.00186 0.10259-0.00186 1.4232 0 2.5807 1.1565 2.5807 2.5825z" fill="#bd0f14"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="13.971" height="10" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/swi.svg b/static/f/plat/swi.svg
deleted file mode 100644
index 771f9196..00000000
--- a/static/f/plat/swi.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
-<rect x="10.461" y="6.9595" width="3.2116" height="3.4805" fill="#cc0010" stroke-width="1.1389"/>
-<rect x="2.1001" y="2.0908" width="4.5209" height="11.784" ry="0" fill="#cc0010" stroke-width="1.1784"/>
-<path d="m4.2466 3.9016c-0.18266 0.034778-0.46099 0.17391-0.60885 0.30436-0.30442 0.26378-0.45518 0.63771-0.43199 1.0783 0.011595 0.22899 0.026094 0.28986 0.11887 0.47538 0.13627 0.28117 0.34212 0.48698 0.62335 0.62612 0.19424 0.095657 0.24353 0.10725 0.49577 0.11595 0.22904 0.0087 0.31022 0 0.46388-0.052179 0.62915-0.2116 1.0089-0.82322 0.90167-1.4493-0.12467-0.74496-0.83209-1.2435-1.5627-1.0986z" fill="#818990" stroke-width="1.1389"/>
-<path d="m9.1927 1.0203c-0.011606 0.0087-0.020297 3.1509-0.020297 6.9829 0 6.322 0.00289 6.9626 0.046391 6.9801 0.078272 0.02892 2.3281 0.01731 2.6065-0.01162 1.177-0.13335 2.215-0.84929 2.7804-1.9131 0.07244-0.13631 0.16812-0.36532 0.21743-0.50732 0.18258-0.5449 0.17689-0.40005 0.17689-4.5654 0-3.3248-0.0058-3.8205-0.04647-4.0321-0.28703-1.5102-1.438-2.6494-2.9514-2.9103-0.20297-0.034784-0.51608-0.04348-1.5251-0.04348-0.69582 0-1.2757 0.0087-1.2844 0.02029zm3.0965 6.3133c0.45218 0.11885 0.82338 0.4638 0.97407 0.90438 0.09567 0.27248 0.09283 0.6725-0.0028 0.92179-0.17689 0.45799-0.52474 0.77975-0.97122 0.89859-0.72486 0.18842-1.4961-0.24929-1.7077-0.96816-0.06378-0.22031-0.06089-0.59133 0.01159-0.81743 0.21745-0.71018 0.97127-1.1276 1.6961-0.93917z" fill="#818990" stroke-width="1.1389"/>
-<path d="m3.9972 1.0456c-1.3946 0.24928-2.5166 1.2812-2.8703 2.6378-0.12757 0.48987-0.13626 0.77684-0.12177 4.577 0.0087 3.4901 0.011597 3.5711 0.069582 3.8408 0.32182 1.4522 1.354 2.4871 2.821 2.829 0.19135 0.0435 0.43489 0.05216 2.0034 0.06093 1.6236 0.01162 1.7917 0.0087 1.8352-0.03484 0.043487-0.0435 0.046391-0.60286 0.046391-6.9452 0-4.7016-0.0087-6.9162-0.028999-6.9568-0.028987-0.052182-0.078283-0.055081-1.7772-0.052182-1.3801 0.0028984-1.7946 0.011595-1.9773 0.04348zm2.6238 6.9568v5.8728l-1.18-0.014577c-1.0872-0.011501-1.2032-0.017309-1.4206-0.072425-0.93355-0.24062-1.6265-0.95941-1.8207-1.8957-0.063784-0.29278-0.063784-7.5047-0.0029045-7.7917 0.17396-0.81454 0.73931-1.4899 1.5018-1.7943 0.3827-0.15363 0.55955-0.17102 1.8004-0.17392l1.122-0.00289z" fill="#818990" stroke-width="1.1389"/>
-</svg>
diff --git a/static/f/plat/web.svg b/static/f/plat/web.svg
deleted file mode 100644
index 8a6e6e77..00000000
--- a/static/f/plat/web.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
-<path d="m0.66012 7.167c0.31388 0.27982 0.62754 0.54437 0.94177 0.79343 0.41471-0.62982 0.86657-1.2073 1.3577-1.7293-0.09274-0.17466-0.14515-0.37416-0.14515-0.5855 0-0.10436 0.01265-0.20565 0.03691-0.30283-0.51315-0.33246-1.0412-0.7032-1.5911-1.1163-0.40662 0.83854-0.63426 1.7793-0.63426 2.7732h0.001367v3.4e-4h-0.001481c0 0.06745 0.001367 0.13467 0.003418 0.20155l0.030762-0.03464zm0.90371-3.4949c0.55041 0.41517 1.0771 0.78727 1.588 1.1201 0.22787-0.24313 0.55212-0.395 0.91135-0.395 0.28494 0 0.54744 0.09559 0.75765 0.25634 0.15039-0.09969 0.30385-0.19585 0.45994-0.28756 0.65033-0.38236 1.3482-0.69419 2.0975-0.93162-0.00547-0.04968-0.00798-0.10004-0.00798-0.15062 0-0.19073 0.03817-0.37256 0.10767-0.53788-0.76119-0.64497-1.6626-1.2161-2.7002-1.7207-0.86577 0.32243-1.6424 0.82761-2.2837 1.4688-0.3541 0.35376-0.66673 0.74933-0.93026 1.1782zm4.0877-2.9028c0.82054 0.4416 1.5526 0.92854 2.1933 1.466 0.21419-0.18776 0.48649-0.31126 0.78613-0.33974 0.06722-0.32915 0.13068-0.66377 0.19095-1.0048-0.57741-0.17215-1.1888-0.26512-1.8217-0.26512v0.001367h-5.7e-4v-0.001367c-0.46257 0-0.9134 0.049902-1.3482 0.14367zm3.7684 0.33336c-0.05298 0.29611-0.10846 0.58778-0.16657 0.87477 0.41779 0.15677 0.74045 0.50814 0.85745 0.94382 0.5656-0.04159 1.1538-0.04968 1.7664-0.02279-0.118-0.14002-0.2415-0.27537-0.3709-0.40491-0.5923-0.59188-1.2999-1.0677-2.0864-1.3909zm2.9423 2.4524c-0.7828-0.05867-1.5252-0.06015-2.2287-0.00717-0.0577 0.301-0.21239 0.56761-0.42989 0.76608 0.18503 0.28517 0.35269 0.58071 0.50259 0.88696 0.2183 0.44673 0.398 0.9159 0.5399 1.4075 0.0432-0.00444 0.087-0.00695 0.1317-0.00695 0.5604 0 1.0347 0.36914 1.1926 0.87819 0.4132 0.01447 0.8403 0.0221 1.2837 0.02484 0.0131-0.16669 0.0202-0.33474 0.0202-0.50438h-0.0014v-5.7e-4h0.0014c-4e-4 -1.2686-0.3719-2.4509-1.0121-3.4445zm0.9118 4.5734c-0.4039-0.00285-0.7945-0.00991-1.1738-0.02244-0.1013 0.48649-0.4861 0.86941-0.9738 0.96808 0.0214 0.58299 0.0014 1.1926-0.0618 1.829 0.3577-0.0377 0.7203-0.0897 1.0894-0.155 0.5563-0.76307 0.9471-1.6537 1.12-2.6196zm-0.7601 3.1832 0.0013 0.0064-0.0075 0.0014c-0.1733 0.2212-0.3596 0.4315-0.5583 0.6302-1.2656 1.2656-3.0156 2.0487-4.9489 2.0489v0.0014h-5.7e-4v-0.0014c-1.9333 0-3.6832-0.7833-4.9494-2.0493-1.2658-1.2659-2.049-3.0159-2.0492-4.9492h-0.0012533v-3.4e-4h0.0012533c0-1.9335 0.7834-3.6834 2.0494-4.9494 1.266-1.266 3.0156-2.0491 4.949-2.0491v-0.0012533h5.7e-4v0.0012533c1.9335 0 3.6835 0.78317 4.9494 2.0494 1.266 1.2657 2.049 3.0156 2.049 4.949h0.0014v5.7e-4h-0.0014c0 1.6268-0.5546 3.1235-1.4847 4.3115zm-0.9795 0.1653c-0.1832 0.0244-0.3648 0.0454-0.5452 0.0629-0.0224 0.1612-0.0477 0.324-0.0752 0.4887 0.2081-0.162 0.406-0.3363 0.5923-0.5226l0.0281-0.029zm-1.3466 1.0421c0.0638-0.3173 0.1181-0.6279 0.1628-0.932-1.1417 0.0594-2.2328-0.023-3.2887-0.2459-0.20645 0.2448-0.51509 0.401-0.86031 0.401-0.02563 0-0.05093-1e-3 -0.07576-0.0025-0.19927 0.5082-0.39216 1.0027-0.56898 1.4697 0.4637 0.1075 0.94701 0.1647 1.443 0.1647v-0.0014h5.7e-4v0.0014c1.1609 0 2.2496-0.3114 3.1875-0.855zm-5.2337 0.5189c0.19357-0.5146 0.3868-1.0113 0.5831-1.5128-0.24723-0.1811-0.41779-0.4609-0.45367-0.7815-1.1326-0.4488-2.2307-1.0714-3.3164-1.8689-0.17408 0.29326-0.34077 0.59723-0.5005 0.91202 0.31206 0.64022 0.72882 1.221 1.2273 1.7195 0.68462 0.6843 1.5236 1.214 2.4601 1.5317zm-3.9964-4.0045c0.10048-0.18343 0.2036-0.36321 0.30922-0.53947-0.18571-0.14617-0.37131-0.29759-0.55656-0.4539 0.056169 0.34077 0.13934 0.67311 0.24735 0.99337zm4.2501-3.8868c0.06687 0.1529 0.10413 0.32163 0.10413 0.49914 0 0.1194-0.01698 0.23504-0.04831 0.34442 0.68599 0.32812 1.3602 0.58857 2.0436 0.79422 0.24837-0.74364 0.48706-1.5159 0.70934-2.3238-0.17466-0.1112-0.32277-0.26-0.43306-0.43511-0.71151 0.22376-1.3724 0.51805-1.9869 0.87922-0.13148 0.07724-0.26148 0.15813-0.38885 0.24187zm-0.26433 1.3845c-0.2257 0.22479-0.53685 0.36356-0.88047 0.36356-0.25088 0-0.48466-0.07417-0.68063-0.20132-0.4637 0.49663-0.89049 1.0472-1.2836 1.6478 1.018 0.75241 2.0434 1.3433 3.0957 1.7734 0.18479-0.36872 0.56635-0.62199 1.0068-0.62199 0.04239 0 0.08397 0.00239 0.12533 0.00706 0.2617-0.68382 0.52397-1.3888 0.7785-2.1224-0.72381-0.21875-1.4369-0.49617-2.1616-0.84606zm4.2129-1.9087c-0.1243 0.03612-0.25578 0.05549-0.39193 0.05549-0.05264 0-0.10482-0.00262-0.15597-0.00855-0.2191 0.7932-0.45323 1.5518-0.69681 2.2821 0.59814 0.15118 1.2084 0.26432 1.8451 0.3476 0.09263-0.18787 0.23094-0.34886 0.40078-0.46871-0.1338-0.47487-0.30565-0.92627-0.51494-1.3547-0.14424-0.29497-0.30648-0.579-0.48626-0.85324zm1.342 4.4177c-0.485-0.15438-0.8415-0.59701-0.86782-1.1267-0.66172-0.08807-1.2972-0.20747-1.9209-0.3672-0.26307 0.75901-0.53411 1.488-0.80459 2.194 0.25658 0.20622 0.42087 0.5227 0.42087 0.8772 0 0.0467-0.00308 0.0926-0.00854 0.1379 0.99759 0.1984 2.0304 0.2654 3.113 0.2002 0.0713-0.6707 0.0935-1.3088 0.068-1.9154z" fill="#5f57ff"/>
-</svg>
diff --git a/static/f/plat/wii.svg b/static/f/plat/wii.svg
deleted file mode 100644
index 4a0979d4..00000000
--- a/static/f/plat/wii.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 16 7.6535" xmlns="http://www.w3.org/2000/svg">
-<g transform="scale(1.1429)" clip-path="url(#a)" fill="#b1b1b4">
-<path d="m7.649 0.36694-1.1821 4.6345s-0.9038-3.4788-1.0508-3.9722c-0.1471-0.49423-0.44968-0.71078-0.87887-0.71078-0.4293 0-0.73245 0.21652-0.87946 0.71078-0.14642 0.49342-1.0505 3.9722-1.0505 3.9722l-1.1829-4.6345h-1.4243s1.368 4.9447 1.5539 5.5175c0.14465 0.44695 0.4873 0.81236 0.99523 0.81236 0.58078 0 0.85234-0.42339 0.97813-0.81236 0.12442-0.38674 1.0099-3.6526 1.0099-3.6526s0.88547 3.2659 1.0096 3.6526c0.12564 0.38897 0.39728 0.81236 0.97786 0.81236 0.50836 0 0.85038-0.36539 0.99593-0.81236 0.18553-0.57267 1.5526-5.5175 1.5526-5.5175zm4.8836 6.2821h1.3484v-4.3616h-1.3484zm-0.1307-5.8866c0 0.42038 0.3547 0.76161 0.7901 0.76161 0.4531 0 0.808-0.3342 0.808-0.76161 0-0.42754-0.3549-0.76248-0.808-0.76248-0.4354 0-0.7901 0.3417-0.7901 0.76248zm-2.6092 5.8866h1.348v-4.3616h-1.348zm-0.13108-5.8866c0 0.42038 0.35393 0.76161 0.78963 0.76161 0.4529 0 0.8085-0.3342 0.8085-0.76161 0-0.42754-0.3556-0.76248-0.8085-0.76248-0.4357 0-0.78963 0.3417-0.78963 0.76248z" fill="#b1b1b4"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="6.6968" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/win.svg b/static/f/plat/win.svg
deleted file mode 100644
index 492c7a8f..00000000
--- a/static/f/plat/win.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 14.362" xmlns="http://www.w3.org/2000/svg">
-<path d="m1.9187 6.2968c1.8977-0.87077 3.8753-0.72942 5.9195 0.25657l1.5944-5.6446c-1.9044-0.95156-3.8574-1.3693-5.9562-0.18327z" fill="#cc3000" stroke-width=".07257"/>
-<path d="m9.9331 1.048c2.6941 1.2294 4.6159 0.79489 6.0669 0.39874l-1.5541 5.4108c-2.8055 1.3647-4.4822 0.48861-6.1256-0.12829z" fill="#90c200" stroke-width=".07257"/>
-<path d="m8.192 7.2424c1.8033 0.76008 3.7011 1.2368 6.0845 0.25657l-1.9243 6.1578c-2.273 1.1393-4.0053 0.75664-5.9379-0.14661z" fill="#cc9f00" stroke-width=".07257"/>
-<path d="m0 13.107c1.9208-1.001 3.9011-0.78055 5.9195 0.21992l1.7777-6.2861c-0.81906-0.37418-2.7013-1.4018-5.9745-0.073307z" fill="#028bca" stroke-width=".07257"/>
-</svg>
diff --git a/static/f/plat/wiu.svg b/static/f/plat/wiu.svg
deleted file mode 100644
index 9e05d911..00000000
--- a/static/f/plat/wiu.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 16 10.521" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1.0047 0 0 1.0047 .96688 -.033146)" clip-path="url(#a)" clip-rule="evenodd" fill="#009bc8" fill-rule="evenodd">
-<path d="m5.1448 4.7326c0 3.1382 3.7102 2.6893 3.7102 0.49471v-5.1943h-3.7102z"/>
-<path d="m0.032959 8.0308c0 1.5505 1.0709 2.4735 2.6384 2.4735h8.9045c1.3226 0 2.3911-0.91319 2.3911-2.2262v-6.5135c0-0.79129-0.5897-1.649-1.3192-1.649h-2.0612v5.3592c0 4.0704-7.0907 4.0391-7.0907 0.08245v-5.5241h-1.5666c-1.0199 0-1.8964 0.6462-1.8964 1.649z"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="10.537" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/x68.svg b/static/f/plat/x68.svg
deleted file mode 100644
index fe81f0ce..00000000
--- a/static/f/plat/x68.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 14 7.549" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#a)">
-<path d="m2.8299 0.47393h4.5594l3.7598 6.4459h-4.5595zm-0.94322-0.47161 4.4021 7.5465h5.9742l-4.4021-7.5465z" fill="#c00"/>
-<path d="m3.2754 5.0331 1.284 2.044h-3.3017zm0.18509-0.98574-3.4605 3.5014h5.5027z" fill="#c00"/>
-<path d="m10.725 2.5156-1.2841-2.0438h3.3015zm-0.1851 0.98527 3.4605-3.5009h-5.5029l2.0424 3.5009" fill="#c00"/>
-<rect transform="matrix(1 0 .48391 .87512 0 0)" x="2.5661" y=".30321" width="4.8813" height="7.7968" ry=".014711" fill="#c00" stroke-width="1.0474"/>
-<path d="m3.5944 5.3253-1.3892 1.3655 1.8157-0.057997-0.62707-0.89008" fill="#ff0005" fill-opacity=".86747" stroke="#c00" stroke-width=".92305px"/>
-<path d="m10.401 2.2081 1.3892-1.3655-1.8157 0.057997 0.62707 0.89008" fill="#ff0005" fill-opacity=".86747" stroke="#c00" stroke-width=".92305px"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="7.5488" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/xb1.svg b/static/f/plat/xb1.svg
deleted file mode 100644
index c157296d..00000000
--- a/static/f/plat/xb1.svg
+++ /dev/null
@@ -1,80 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 11.872" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<defs>
-<clipPath id="k">
-<path d="m481.12 609.93h144.4v-254.9h-144.4z"/>
-</clipPath>
-<clipPath id="l">
-<path d="m242.8 695.39h336.52v-142.36h-336.52z"/>
-</clipPath>
-<radialGradient id="b" 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=".95506"/>
-<stop offset="1"/>
-</radialGradient>
-<radialGradient id="a" 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=".95506"/>
-<stop offset="1"/>
-</radialGradient>
-<linearGradient id="j" x2="1" gradientTransform="matrix(112.95 139.49 139.49 -112.95 242.33 465.08)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#ff0" offset="0"/>
-<stop stop-color="#9dff00" offset=".5"/>
-<stop stop-color="#458c41" offset="1"/>
-</linearGradient>
-<linearGradient id="i" x2="1" gradientTransform="matrix(138.34 -205.1 -205.1 -138.34 228.25 725.86)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#4a7d62" offset="0"/>
-<stop offset="1"/>
-</linearGradient>
-<linearGradient id="h" x2="1" gradientTransform="matrix(221.12 149.15 149.15 -221.12 150.16 295.6)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#ff0" offset="0"/>
-<stop stop-color="#9dff00" offset=".42134"/>
-<stop stop-color="#5c5c5c" offset="1"/>
-</linearGradient>
-<linearGradient id="g" x2="1" gradientTransform="matrix(179.04 310.1 310.1 -179.04 150.04 206.61)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#4a7d62" offset="0"/>
-<stop stop-color="#182920" offset="1"/>
-</linearGradient>
-<linearGradient id="f" x2="1" gradientTransform="matrix(-123.56 119.32 119.32 123.56 588.99 485.63)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#ff0" offset="0"/>
-<stop stop-color="#9dff00" offset=".47752"/>
-<stop stop-color="#4a4a4a" offset="1"/>
-</linearGradient>
-<linearGradient id="e" x2="1" gradientTransform="matrix(19.705 160.48 160.48 -19.705 528.97 540.92)" gradientUnits="userSpaceOnUse">
-<stop offset="0"/>
-<stop stop-color="#45755c" offset=".87079"/>
-<stop stop-color="#45755c" offset="1"/>
-</linearGradient>
-<linearGradient id="d" x2="1" gradientTransform="matrix(57.405 123.11 123.11 -57.405 544.35 308.62)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#ff0" offset="0"/>
-<stop stop-color="#aeff00" offset=".41573"/>
-<stop stop-color="#575757" offset="1"/>
-</linearGradient>
-<linearGradient id="c" x2="1" gradientTransform="matrix(-55.596 315.3 315.3 55.596 625.09 206.59)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#4a7d62" offset="0"/>
-<stop offset=".83147"/>
-<stop offset="1"/>
-</linearGradient>
-</defs>
-<g transform="translate(-27.625 -31.769)">
-<path d="m27.625 43.641h14v-11.872h-14z"/>
-<g transform="matrix(.017523 0 0 .017523 27.141 31.212)">
-<g transform="matrix(1.25 0 0 -1.25 -97.211 943.55)">
-<g clip-path="url(#l)">
-<path d="m417.09 553.03-174.29 83.727s175.19 135.82 336.52-5.986z" fill="url(#b)"/>
-</g>
-<g clip-path="url(#k)">
-<path d="m481.12 501.93 114.42 108s101.09-174.09-67.989-254.9z" fill="url(#a)"/>
-</g>
-<path d="m344.64 507.72v-125.5c24.426 20.367 48.894 41.889 72.242 64.093v108.36s-131.23 43.744-214.07 95.919c34.127-38.852 90.552-100.24 141.83-142.88" fill="url(#j)"/>
-<path d="m202.81 650.6c82.848-52.175 214.07-95.919 214.07-95.919v14.449s-170.67 56.89-245.62 118.3c0 0 12.187-14.789 31.546-36.825" fill="url(#i)"/>
-<path d="m176.8 256.1c42.305 28.866 104.91 73.64 167.84 126.12v102.02s-85.697-130.06-167.84-228.14" fill="url(#h)"/>
-<path d="m124.31 221.47s20.413 12.745 52.494 34.636c82.143 98.077 167.84 228.14 167.84 228.14v23.477s-127.32-193.24-220.33-286.26" fill="url(#g)"/>
-<path d="m416.89 554.68v-108.36c23.349-22.204 47.817-43.726 72.243-64.093v125.5c51.28 42.639 107.71 104.02 141.84 142.88-82.853-52.175-214.08-95.919-214.08-95.919" fill="url(#f)"/>
-<path d="m416.89 569.13v-14.449s131.23 43.744 214.08 95.919c19.355 22.036 31.542 36.825 31.542 36.825-74.95-61.405-245.62-118.3-245.62-118.3" fill="url(#e)"/>
-<path d="m489.13 382.23c62.933-52.482 125.54-97.256 167.84-126.12-82.142 98.077-167.84 228.14-167.84 228.14z" fill="url(#d)"/>
-<path d="m489.13 484.25s85.703-130.06 167.84-228.14c32.077-21.891 52.491-34.636 52.491-34.636-93.011 93.013-220.34 286.26-220.34 286.26z" fill="url(#c)"/>
-</g>
-</g>
-</g>
-</svg>
diff --git a/static/f/plat/xb3.svg b/static/f/plat/xb3.svg
deleted file mode 100644
index a6e2690d..00000000
--- a/static/f/plat/xb3.svg
+++ /dev/null
@@ -1,99 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="16" height="15.841" enable-background="new 2.472168 0.6079102 167 36" version="1.1" viewBox="2.4722 .60791 16 15.841" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<defs>
-<radialGradient id="g" cx="23.69" cy="12.766" r="14.35" gradientTransform="matrix(0 1.8458 -1.5756 0 41.661 -29.484)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset="0"/>
-<stop stop-color="#fff" stop-opacity="0" offset="1"/>
-</radialGradient>
-<radialGradient id="f" cx="23.69" cy="12.766" r="14.35" gradientTransform="matrix(0 1.1525 -.98381 0 34.106 -13.06)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset="0"/>
-<stop stop-color="#666" stop-opacity="0" offset="1"/>
-</radialGradient>
-<linearGradient id="d" x1="17.161" x2="13.206" y1="21.54" y2="16.9" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
-<linearGradient id="a">
-<stop stop-color="#97ca43" offset="0"/>
-<stop stop-color="#97ca43" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="c" x1="11.656" x2="13.586" y1="18.172" y2="19.231" gradientUnits="userSpaceOnUse" xlink:href="#p"/>
-<linearGradient id="p">
-<stop stop-color="#458f41" offset="0"/>
-<stop stop-color="#458f41" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="b" x1="19.903" x2="14.902" y1="19.231" y2="17.2" gradientUnits="userSpaceOnUse" xlink:href="#o"/>
-<linearGradient id="o">
-<stop stop-color="#e5edae" offset="0"/>
-<stop stop-color="#e5edae" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="n" x1="19.663" x2="23.287" y1="13.09" y2="5.0464" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
-<linearGradient id="m" x1="17.497" x2="17.321" y1="4.0984" y2="6.218" gradientUnits="userSpaceOnUse" xlink:href="#e"/>
-<linearGradient id="e">
-<stop stop-color="#459743" offset="0"/>
-<stop stop-color="#459743" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="l" x1="19.339" x2="18.286" y1="4.7166" y2="6.3063" gradientUnits="userSpaceOnUse" xlink:href="#e"/>
-<linearGradient id="k" x1="19.515" x2="21.796" y1="15.668" y2="11.517" gradientUnits="userSpaceOnUse">
-<stop stop-color="#e6edae" offset="0"/>
-<stop stop-color="#e6edae" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="j" x1="17.551" x2="20.647" y1="12.031" y2="6.9877" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
-<linearGradient id="i" x1="19.552" x2="18.76" y1="5.0688" y2="6.3482" gradientUnits="userSpaceOnUse">
-<stop stop-color="#46873f" offset="0"/>
-<stop stop-color="#46873f" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="h" x1="20.124" x2="21.972" y1="15.569" y2="10.981" gradientUnits="userSpaceOnUse">
-<stop stop-color="#e6eead" offset="0"/>
-<stop stop-color="#e6eead" stop-opacity="0" offset="1"/>
-</linearGradient>
-</defs>
-<g transform="matrix(.41494 0 0 .41494 -2.6194 15.99)">
-<g>
-<ellipse transform="matrix(1.1756 0 0 1.1301 4.1196 -37.636)" cx="23.333" cy="17.392" rx="14.35" ry="14.758" fill="#666"/>
-<ellipse transform="matrix(1.1756 0 0 1.1301 4.1196 -37.636)" cx="23.333" cy="17.392" rx="14.35" ry="14.758" fill="url(#g)"/>
-<ellipse transform="matrix(1.1756 0 0 1.1301 4.1196 -37.636)" cx="23.333" cy="17.392" rx="14.35" ry="14.758" fill="url(#f)"/>
-</g>
-<g transform="translate(-4.7344 9.5518)">
-<g transform="matrix(1.0075 0 0 1.0008 -.2371 .027153)"/>
-<g transform="translate(4.75 -9.5)">
-<g transform="matrix(1.0075 0 0 1.0008 -.23709 .027153)">
-<g transform="translate(1.3344 -34.927)">
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="#00a54d"/>
-</g>
-<g transform="translate(1.3344 -34.927)">
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#d)"/>
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#c)"/>
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#b)"/>
-</g>
-</g>
-<g transform="matrix(1.0075 0 0 1.0008 .96807 -34.898)">
-<g>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="#02a74d"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#n)"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#m)"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#l)"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#k)"/>
-</g>
-</g>
-<g transform="matrix(-1.0075 0 0 1.0008 63.323 .027153)">
-<g transform="translate(1.3344 -34.927)">
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="#00a54d"/>
-</g>
-<g transform="translate(1.3344 -34.927)">
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#d)"/>
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#c)"/>
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#b)"/>
-</g>
-</g>
-<g transform="matrix(-1.0042 0 0 1.0017 63.252 .054306)">
-<g transform="translate(1.1962 -34.896)">
-<g>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="#02a74d"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#j)"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#i)"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#h)"/>
-</g>
-</g>
-</g>
-</g>
-</g>
-</g>
-</svg>
diff --git a/static/f/plat/xbo.svg b/static/f/plat/xbo.svg
deleted file mode 100644
index 2ec68662..00000000
--- a/static/f/plat/xbo.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg fill="none" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
-<path d="m8.6981 14.971c1.0785-0.10324 2.1703-0.49026 3.1082-1.102 0.78591-0.51224 0.96336-0.72312 0.96336-1.1436 0-0.84446-0.9292-2.3239-2.5189-4.01-0.90258-0.95786-2.1601-2.08-2.2959-2.0497-0.26457 0.059033-2.3783 2.1197-3.1697 3.0899-1.2513 1.5335-1.8263 2.7896-1.5341 3.3496 0.22211 0.42555 1.6006 1.2575 2.6132 1.5772 0.83474 0.26343 1.931 0.37495 2.8339 0.28857zm5.1331-3.1235c0.65315-1.0015 0.9831-1.9873 1.1422-3.4132 0.052674-0.4708 0.034161-0.73996-0.11926-1.7066-0.19085-1.2034-0.87704-2.5972-1.702-3.4542-0.35112-0.36428-0.38242-0.37403-0.81066-0.22929-0.51906 0.17563-1.0739 0.55878-1.9347 1.3368l-0.50279 0.45438 0.275 0.33664c1.2737 1.5628 2.6181 3.7794 3.1241 5.1504 0.27503 0.74486 0.38569 1.4929 0.26685 1.8043-0.07967 0.21048-0.0064 0.13207 0.26204-0.27866zm-11.464 0.1703c-0.064599-0.31484 0.01708-0.89291 0.20827-1.476 0.41412-1.2628 1.7987-3.6119 3.07-5.2092l0.40029-0.50283-0.43315-0.3973c-0.56528-0.51878-0.95775-0.8294-1.3815-1.0933-0.33405-0.20817-0.81162-0.39242-1.017-0.39242-0.12644 0-0.57212 0.46349-0.93172 0.96763-0.55714 0.78061-0.96686 1.7287-1.1745 2.7142-0.13424 0.6375-0.14547 2.0003-0.021642 2.6362 0.10251 0.52171 0.3173 1.1979 0.52557 1.6567 0.15783 0.34361 0.54672 1.0112 0.71758 1.2284 0.087868 0.11173 0.087868 0.11163 0.03905-0.12941zm6.2153-9.3096c0.58676-0.2976 1.4918-0.61717 1.9917-0.70335 0.17508-0.030094 0.47399-0.047165 0.66389-0.037403 0.41259 0.020812 0.39428-6.542e-4 -0.26736-0.31306-0.54998-0.25971-1.0088-0.41242-1.632-0.54317-0.7005-0.14718-2.0177-0.1488-2.7071-0.0035778-0.74443 0.15693-1.6213 0.483-2.1153 0.78711l-0.1471 0.090099 0.33682-0.016918c0.66974-0.033825 1.6457 0.23662 2.6936 0.74597 0.316 0.15384 0.59066 0.27647 0.61084 0.27322 0.020016-0.0039 0.27759-0.12929 0.57276-0.27891z" fill="#1daf1e" stroke-width="1.0225"/>
-</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/subscribestar.png b/static/f/subscribestar.png
deleted file mode 100644
index 516b7c78..00000000
--- a/static/f/subscribestar.png
+++ /dev/null
Binary files differ
diff --git a/static/f/uncensor.svg b/static/f/uncensor.svg
deleted file mode 100644
index 40ce113a..00000000
--- a/static/f/uncensor.svg
+++ /dev/null
@@ -1,2 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="818.31" height="818.31" enable-background="new 0 0 1174.96 855.746" overflow="visible" version="1.1" viewBox="0 0 818.31 818.31" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-299.99 -.0010071)"><ellipse cx="708.06" cy="424.7" rx="364.48" ry="383.98" fill="#fff"/></g><g transform="translate(-299.99 -.0010071)" display="none"><g display="inline"><rect x="808.32" y="124.1" width="183.31" height="183.31" fill="#0a1623"/><rect x="991.65" y="124.1" width="183.31" height="183.31" fill="#fff"/><rect x="441.7" y="124.1" width="183.31" height="183.31" fill="#0a1623"/><rect x="625.03" y="124.1" width="183.31" height="183.31" fill="#fff"/><rect x="624.81" y="306.42" width="183.31" height="183.31" fill="#0a1623"/><rect x="808.14" y="306.42" width="183.31" height="183.31" fill="#fff"/><rect x="258.39" y="306.62" width="183.31" height="183.31" fill="#0a1623"/><rect x="441.73" y="306.62" width="183.31" height="183.31" fill="#fff"/><rect x="808.32" y="489.92" width="183.31" height="183.31" fill="#0a1623"/><rect x="991.65" y="489.92" width="183.31" height="183.31" fill="#fff"/><rect x="441.7" y="489.92" width="183.31" height="183.31" fill="#0a1623"/><rect x="625.03" y="489.92" width="183.31" height="183.31" fill="#fff"/><rect x="624.81" y="672.24" width="183.31" height="183.31" fill="#0a1623"/><rect x="808.14" y="672.24" width="183.31" height="183.31" fill="#fff"/><rect x="258.39" y="672.44" width="183.31" height="183.31" fill="#0a1623"/><rect x="441.73" y="672.44" width="183.31" height="183.31" fill="#fff"/></g></g><g transform="translate(-299.99 -.0010071)" fill="#0a1623"><rect x="431.19" y="368.64" width="122.22" height="122.22"/><rect x="553.87" y="245.98" width="122.22" height="122.22"/><rect x="677.66" y="122.21" width="122.23" height="122.22"/><rect x="456.96" y="148.65" width="97.328" height="97.327"/><rect transform="rotate(180.02 927.8 381.36)" x="866.69" y="320.25" width="122.22" height="122.22"/><rect transform="rotate(180.02 805.08 503.98)" x="743.97" y="442.87" width="122.22" height="122.22"/><rect transform="rotate(180.02 681.26 627.71)" x="620.14" y="566.6" width="122.23" height="122.22"/><rect transform="rotate(180.02 914.4 613.8)" x="865.74" y="565.13" width="97.328" height="97.327"/></g><g transform="translate(-299.99 -.0010071)"><circle cx="709.06" cy="410.7" r="301.48" fill="none" stroke="#0c1623" stroke-width="50"/></g><g transform="translate(-299.99 -.0010071)"><rect transform="matrix(.7068 .7074 -.7074 .7068 490.1 -396.09)" x="647.45" y="22.881" width="150.9" height="740.68" fill="#0c1623"/></g><g transform="translate(-299.99 -.0010071)" display="none"><g display="inline"><rect y="87.618" width="115" height="115" fill="#0a1623"/><rect x="115.02" y="87.618" width="115" height="115" fill="#fff"/></g></g><g transform="translate(-299.99 -.0010071)"><circle cx="709.14" cy="409.16" r="359.16" fill="none" stroke="#b5b5b5" stroke-width="100"/></g><g transform="translate(-299.99 -.0010071)"><line x1="429.08" x2="991.55" y1="688.69" y2="124.72" fill="none" stroke="#b5b5b5" stroke-width="100"/></g></svg>
diff --git a/static/f/voiced.svg b/static/f/voiced.svg
deleted file mode 100644
index 73e77a23..00000000
--- a/static/f/voiced.svg
+++ /dev/null
@@ -1,33 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="352pt" height="512pt" viewBox="0 0 352 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#546e7aff">
-<path fill="#914040" opacity="1.00" d=" M 169.61 0.00 L 182.39 0.00 C 210.72 1.91 238.32 14.44 257.64 35.39 C 277.03 55.81 288.11 83.84 287.99 112.00 C 288.00 165.33 288.01 218.67 287.99 272.00 C 288.06 281.84 286.63 291.65 284.10 301.14 C 276.40 330.44 256.05 356.08 229.39 370.42 C 205.91 383.27 177.70 387.24 151.59 381.29 C 127.94 376.13 106.23 362.89 90.60 344.42 C 73.52 324.46 63.89 298.29 64.00 272.00 C 64.00 218.34 63.98 164.67 64.02 111.00 C 64.10 81.02 77.01 51.36 98.86 30.85 C 117.78 12.51 143.43 1.83 169.61 0.00 M 160.43 33.42 L 159.96 34.08 C 155.30 34.22 150.91 35.98 146.61 37.61 C 126.01 45.63 109.14 62.67 101.32 83.36 C 99.81 87.42 98.18 91.57 98.10 95.98 L 97.40 96.39 C 95.57 106.79 95.85 117.48 96.13 128.00 C 95.89 138.65 95.88 149.35 96.14 160.00 C 95.88 170.65 95.88 181.35 96.14 192.00 C 95.89 202.65 95.89 213.35 96.14 224.00 C 95.88 234.65 95.88 245.35 96.14 256.00 C 96.08 266.67 95.08 277.67 97.90 288.09 C 99.80 299.63 104.96 310.75 112.19 319.91 C 116.47 326.08 121.92 331.52 128.09 335.81 C 131.86 339.27 136.55 341.53 141.03 343.92 C 147.05 346.68 153.28 349.32 159.91 350.09 C 164.75 351.72 169.94 351.85 175.00 351.98 C 180.72 351.92 186.58 351.84 192.09 350.10 C 203.63 348.18 214.76 343.04 223.91 335.80 C 230.07 331.51 235.51 326.07 239.80 319.91 C 243.27 316.14 245.54 311.46 247.90 306.96 C 250.63 300.93 253.33 294.72 254.10 288.09 C 256.91 277.67 255.92 266.67 255.86 256.00 C 256.12 245.35 256.12 234.65 255.86 224.00 C 256.12 213.35 256.11 202.65 255.86 192.00 C 256.11 181.35 256.13 170.65 255.86 160.00 C 256.11 149.35 256.11 138.65 255.87 128.00 C 256.13 117.49 256.46 106.81 254.57 96.43 L 253.92 95.96 C 253.67 90.25 251.28 84.96 249.19 79.73 C 240.62 60.22 223.93 44.39 203.90 37.05 C 200.08 35.62 196.16 34.18 192.03 34.09 L 191.61 33.40 C 181.34 31.57 170.69 31.52 160.43 33.42 Z" />
-<path fill="#914040" opacity="1.00" d=" M 8.72 257.72 C 15.58 254.12 24.85 256.36 29.16 262.84 C 32.53 267.55 31.95 273.60 32.22 279.07 C 33.27 302.97 40.59 326.55 53.10 346.93 C 68.46 372.14 91.74 392.43 118.84 404.15 C 148.78 417.19 183.25 419.63 214.67 410.69 C 244.14 402.54 270.73 384.59 289.50 360.48 C 307.21 337.86 318.04 309.81 319.68 281.10 C 320.15 274.99 319.10 268.16 322.84 262.84 C 327.15 256.35 336.42 254.13 343.28 257.72 C 347.64 259.78 350.22 264.12 352.00 268.40 L 352.00 280.38 C 351.11 289.42 350.23 298.49 348.36 307.39 C 342.16 338.38 327.11 367.48 305.77 390.77 C 282.80 415.85 252.43 434.15 219.42 442.44 C 210.57 444.97 201.37 445.69 192.37 447.36 C 191.54 452.53 192.08 457.79 192.01 463.00 C 192.13 468.59 191.59 474.20 192.23 479.76 C 196.47 480.29 200.74 480.00 205.00 480.01 C 226.34 479.96 247.69 480.07 269.03 479.94 C 273.44 479.93 278.18 480.38 281.74 483.26 C 287.79 487.72 289.74 496.63 286.27 503.27 C 284.21 507.63 279.87 510.22 275.58 512.00 L 76.43 512.00 C 72.13 510.23 67.79 507.64 65.72 503.28 C 62.26 496.64 64.19 487.72 70.26 483.26 C 73.81 480.37 78.56 479.93 82.97 479.94 C 104.31 480.07 125.65 479.96 147.00 480.01 C 151.25 480.00 155.53 480.30 159.77 479.77 C 160.41 474.20 159.87 468.59 159.99 463.00 C 159.91 457.79 160.46 452.53 159.63 447.36 C 155.34 446.28 150.89 446.13 146.54 445.38 C 116.89 440.49 88.65 427.61 65.31 408.71 C 38.39 387.00 18.08 357.17 7.89 324.12 C 3.38 310.28 1.38 295.80 0.00 281.37 L 0.00 268.41 C 1.76 264.11 4.36 259.77 8.72 257.72 Z" />
-</g>
-<g id="#914040ff">
-<path fill="#914040" opacity="1.00" d=" M 160.43 33.42 C 170.69 31.52 181.34 31.57 191.61 33.40 L 192.03 34.09 C 192.00 44.06 191.99 54.03 192.00 64.00 C 181.33 64.00 170.67 64.00 160.00 64.00 C 160.00 54.03 160.02 44.05 159.96 34.08 L 160.43 33.42 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.21 64.21 C 138.78 63.62 149.40 64.20 160.00 64.00 C 160.00 74.67 160.00 85.33 160.00 96.00 C 149.33 96.00 138.67 96.00 128.00 96.00 C 128.20 85.41 127.63 74.79 128.21 64.21 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 64.00 C 202.59 64.20 213.21 63.63 223.79 64.21 C 224.37 74.79 223.80 85.40 224.00 96.00 C 213.33 96.00 202.67 96.00 192.00 96.00 C 192.00 85.33 192.00 74.67 192.00 64.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 97.40 96.39 L 98.10 95.98 C 108.07 96.00 118.03 96.01 128.00 96.00 C 128.00 106.67 128.00 117.33 128.00 128.00 C 117.38 128.00 106.76 128.00 96.13 128.00 C 95.85 117.48 95.57 106.79 97.40 96.39 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 96.00 C 170.67 96.00 181.33 96.00 192.00 96.00 C 192.00 106.67 192.00 117.33 192.00 128.00 C 181.33 128.00 170.67 128.00 160.00 128.00 C 160.00 117.33 160.00 106.67 160.00 96.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 96.00 C 233.97 96.00 243.94 96.02 253.92 95.96 L 254.57 96.43 C 256.46 106.81 256.13 117.49 255.87 128.00 C 245.24 128.00 234.62 128.00 224.00 128.00 C 224.00 117.33 224.00 106.67 224.00 96.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.00 128.00 C 138.67 128.00 149.33 128.00 160.00 128.00 C 160.00 138.67 160.00 149.33 160.00 160.00 C 149.33 160.00 138.67 160.00 128.00 160.00 C 128.00 149.33 128.00 138.67 128.00 128.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 128.00 C 202.67 128.00 213.33 128.00 224.00 128.00 C 224.00 138.67 224.00 149.33 224.00 160.00 C 213.33 160.00 202.67 160.00 192.00 160.00 C 192.00 149.33 192.00 138.67 192.00 128.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 96.14 160.00 C 106.76 160.00 117.38 160.00 128.00 160.00 C 128.00 170.67 128.00 181.33 128.00 192.00 C 117.38 192.00 106.76 192.00 96.14 192.00 C 95.88 181.35 95.88 170.65 96.14 160.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 160.00 C 170.67 160.00 181.33 160.00 192.00 160.00 C 192.00 170.67 192.00 181.33 192.00 192.00 C 181.33 192.00 170.67 192.00 160.00 192.00 C 160.00 181.33 160.00 170.67 160.00 160.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 160.00 C 234.62 160.00 245.24 160.00 255.86 160.00 C 256.13 170.65 256.11 181.35 255.86 192.00 C 245.24 192.00 234.62 192.00 224.00 192.00 C 224.00 181.33 224.00 170.67 224.00 160.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.00 192.00 C 138.67 192.00 149.33 192.00 160.00 192.00 C 160.00 202.67 160.00 213.33 160.00 224.00 C 149.33 224.00 138.67 224.00 128.00 224.00 C 128.00 213.33 128.00 202.67 128.00 192.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 192.00 C 202.67 192.00 213.33 192.00 224.00 192.00 C 224.00 202.67 224.00 213.33 224.00 224.00 C 213.33 224.00 202.67 224.00 192.00 224.00 C 192.00 213.33 192.00 202.67 192.00 192.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 96.14 224.00 C 106.76 224.00 117.38 224.00 128.00 224.00 C 128.00 234.67 128.00 245.33 128.00 256.00 C 117.38 256.00 106.76 256.00 96.14 256.00 C 95.88 245.35 95.88 234.65 96.14 224.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 224.00 C 170.67 224.00 181.33 224.00 192.00 224.00 C 192.00 234.67 192.00 245.33 192.00 256.00 C 181.33 256.00 170.67 256.00 160.00 256.00 C 160.00 245.33 160.00 234.67 160.00 224.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 224.00 C 234.62 224.00 245.24 224.00 255.86 224.00 C 256.12 234.65 256.12 245.35 255.86 256.00 C 245.24 256.00 234.62 256.00 224.00 256.00 C 224.00 245.33 224.00 234.67 224.00 224.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.00 256.00 C 138.67 256.00 149.33 256.00 160.00 256.00 C 160.00 266.67 160.00 277.33 160.00 288.00 C 149.33 288.00 138.67 288.00 128.00 288.00 C 128.00 277.33 128.00 266.67 128.00 256.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 256.00 C 202.67 256.00 213.33 256.00 224.00 256.00 C 224.00 266.67 224.00 277.33 224.00 288.00 C 213.33 288.00 202.67 288.00 192.00 288.00 C 192.00 277.33 192.00 266.67 192.00 256.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 97.90 288.09 C 107.93 287.91 117.97 288.03 128.00 288.00 C 128.00 298.67 127.99 309.33 128.01 320.00 C 122.73 319.98 117.46 320.11 112.19 319.91 C 104.96 310.75 99.80 299.63 97.90 288.09 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 288.00 C 170.67 288.00 181.33 288.00 192.00 288.00 C 192.00 298.67 192.00 309.33 192.00 320.00 C 181.33 320.00 170.67 320.00 160.00 320.00 C 160.00 309.33 160.00 298.67 160.00 288.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 288.00 C 234.03 288.03 244.06 287.91 254.10 288.09 C 253.33 294.72 250.63 300.93 247.90 306.96 C 245.54 311.46 243.27 316.14 239.80 319.91 C 234.53 320.11 229.26 319.98 223.99 319.99 C 224.01 309.33 224.00 298.66 224.00 288.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.01 320.00 C 138.67 320.01 149.33 320.00 160.00 320.00 C 159.97 330.03 160.08 340.06 159.91 350.09 C 153.28 349.32 147.05 346.68 141.03 343.92 C 136.55 341.53 131.86 339.27 128.09 335.81 C 127.89 330.54 128.01 325.27 128.01 320.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 320.00 C 202.66 320.00 213.33 320.01 223.99 319.99 C 223.98 325.26 224.11 330.53 223.91 335.80 C 214.76 343.04 203.63 348.18 192.09 350.10 C 191.91 340.06 192.03 330.03 192.00 320.00 Z" />
-</g>
-</svg>
diff --git a/util/README.md b/util/README.md
new file mode 100644
index 00000000..2c4d499e
--- /dev/null
+++ b/util/README.md
@@ -0,0 +1,65 @@
+# VNDB utility scripts
+
+(Only interesting scripts are documented here)
+
+dbdump.pl
+: Can generate various database dumps, refer to its help text for details.
+
+devdump.pl
+: Generates a tarball containing a [small subset of the
+ database](https://vndb.org/d8#3) for development purposes.
+
+hibp-dl.pl
+: Utility to fetch the [Pwned
+ Passwords](https://haveibeenpwned.com/Passwords) database and store it in
+ `$VNDB_VAR/hibp`. The web backend can use this to warn about compromised
+ passwords.
+
+multi.pl
+: Runs the background service for the old API and various maintenance tasks.
+ The actual code for the service lives in */lib/Multi/*.
+
+unusedimages.pl
+: Purges unreferenced images from the database and scans `$VNDB_VAR/static/`
+ for files to be deleted.
+
+vndb.pl
+: This is the main entry point of the web backend. This script does some
+ setup and loads all the code from */lib/VNWeb/*. Can be started from CGI or
+ FastCGI context. When run on the command line it will spawn a simple
+ single-threaded web server on port 3000.
+
+vndb-dev-server.pl
+: A handy wrapper around *vndb.pl* for development use. Spawns a web server
+ on port 3000 that will automatically run `make` and reload the backend code
+ on changes.
+
+
+## imgproc.c
+
+*imgproc.c* this is a tool that wraps [libvips](https://www.libvips.org/) image
+processing operations used by VNDB in a simple CLI. It can be built in two ways:
+
+The default *imgproc* links against your system-provided libvips and should be
+portable across various systems.
+
+`make gen/imgproc-custom` builds and links against a custom build of libvips
+with support for better JPEG compression through jpegli. It also enables fairly
+restrictive seccomp rules for secure sandboxing, to protect against potential
+vulnerabilities in the used image codecs. This version likely only works on
+x86\_64 Linux with glibc. To use this custom version, update `imgproc_path` in
+your conf.pl.
+
+Build requirements for *imgproc-custom*:
+
+- C & C++ build system
+- Linux x86\_64 with glibc
+- meson
+- cmake
+- glib
+- lcms
+- libexpat
+- libheif (with libaom for AVIF support)
+- libpng
+- libseccomp
+- libwebp
diff --git a/util/dbdump.pl b/util/dbdump.pl
index 1695d911..fef8c5da 100755
--- a/util/dbdump.pl
+++ b/util/dbdump.pl
@@ -6,8 +6,6 @@ util/dbdump.pl export-db output.tar.zst
Write a full database export as a .tar.zst
- The uncompressed directory is written to "output.tar.zst_dir"
-
util/dbdump.pl export-img output-dir
Create or update a directory with hardlinks to images.
@@ -36,6 +34,7 @@ use DBI;
use DBD::Pg;
use File::Copy 'cp';
use File::Find 'find';
+use File::Path 'rmtree';
use Time::HiRes 'time';
use Cwd 'abs_path';
@@ -44,6 +43,38 @@ BEGIN { ($ROOT = abs_path $0) =~ s{/util/dbdump\.pl$}{}; }
use lib "$ROOT/lib";
use VNDB::Schema;
+use VNDB::ExtLinks;
+
+$ENV{VNDB_VAR} //= 'var';
+
+# Ridiculous query to export 'ulist_vns' with private labels removed.
+# Since doing a lookup in ulist_labels for each row+label in ulist_vns is
+# rather slow, this query takes a shortcut: for users that do not have any
+# private labels at all (i.e. the common case), this query just dumps the rows
+# without any modification. Only for users that have at least one private label
+# are the labels filtered.
+my $sql_ulist_vns_cols = q{
+ uid, vid, date_trunc('day',added) AS added, date_trunc('day',lastmod) AS lastmod
+ , date_trunc('day',vote_date), started, finished, vote, notes
+};
+my $sql_ulist_vns = qq{
+ SELECT * FROM (
+ SELECT $sql_ulist_vns_cols, array_agg(lblid ORDER BY lblid) AS labels
+ FROM ulist_vns, unnest(labels) x(lblid)
+ WHERE NOT c_private
+ AND NOT EXISTS(SELECT 1 FROM ulist_labels WHERE uid = ulist_vns.uid AND id = lblid AND private)
+ AND uid IN(SELECT uid FROM ulist_labels WHERE private)
+ GROUP BY uid, vid
+ UNION ALL
+ SELECT $sql_ulist_vns_cols, labels
+ FROM ulist_vns
+ WHERE NOT c_private
+ AND uid NOT IN(SELECT uid FROM ulist_labels WHERE private)
+ ) z
+ WHERE vid IN(SELECT id FROM vn WHERE NOT hidden)
+ ORDER BY uid, vid
+};
+
# Tables and columns to export.
@@ -56,60 +87,61 @@ use VNDB::Schema;
# interesting references are excluded from the dumps. Keeping all references
# consistent with those omissions complicates the WHERE clauses somewhat.
my %tables = (
- anime => { where => 'id IN(SELECT va.aid FROM vn_anime va JOIN vn v ON v.id = va.id WHERE NOT v.hidden)' },
- chars => { where => 'NOT hidden' },
- chars_traits => { where => 'id IN(SELECT id FROM chars WHERE NOT hidden) AND tid IN(SELECT id FROM traits WHERE state = 2)' },
- chars_vns => { where => 'id IN(SELECT id FROM chars WHERE NOT hidden)'
- .' AND vid IN(SELECT id FROM vn WHERE NOT hidden)'
- .' AND (rid IS NULL OR rid IN(SELECT id FROM releases WHERE NOT hidden))'
- , order => 'id, vid, rid' },
- docs => { where => 'NOT hidden' },
- images => { where => "c_weight > 0" }, # Only images with a positive weight are referenced.
- image_votes => { where => "id IN(SELECT id FROM images WHERE c_weight > 0)", order => 'uid, id' },
- producers => { where => 'NOT hidden' },
- producers_relations => { where => 'id IN(SELECT id FROM producers WHERE NOT hidden)' },
- quotes => { where => 'vid IN(SELECT id FROM vn WHERE NOT hidden)' },
- releases => { where => 'NOT hidden' },
- releases_lang => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden)' },
- releases_media => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden)' },
- releases_platforms => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden)' },
- releases_producers => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden) AND pid IN(SELECT id FROM producers WHERE NOT hidden)' },
- releases_vn => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden) AND vid IN(SELECT id FROM vn WHERE NOT hidden)' },
+ anime => { where => 'x.id IN(SELECT va.aid FROM vn_anime va JOIN vn v ON v.id = va.id WHERE NOT v.hidden)' },
+ chars => { where => 'NOT x.hidden' },
+ chars_traits => { where => 'x.id IN(SELECT id FROM chars WHERE NOT hidden) AND tid IN(SELECT id FROM traits WHERE NOT hidden)' },
+ chars_vns => { where => 'x.id IN(SELECT id FROM chars WHERE NOT hidden)'
+ .' AND x.vid IN(SELECT id FROM vn WHERE NOT hidden)'
+ .' AND (x.rid IS NULL OR x.rid IN(SELECT id FROM releases WHERE NOT hidden))'
+ , order => 'x.id, x.vid, x.rid' },
+ docs => { where => 'NOT x.hidden' },
+ images => { where => "x.c_weight > 0" }, # Only images with a positive weight are referenced.
+ image_votes => { where => "x.id IN(SELECT id FROM images WHERE c_weight > 0)", order => 'x.uid, x.id' },
+ producers => { where => 'NOT x.hidden' },
+ producers_relations => { where => 'x.id IN(SELECT id FROM producers WHERE NOT hidden)' },
+ quotes => { where => 'x.rand IS NOT NULL' },
+ releases => { where => 'NOT x.hidden' },
+ releases_media => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden)' },
+ releases_platforms => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden)' },
+ releases_producers => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden) AND pid IN(SELECT id FROM producers WHERE NOT hidden)' },
+ releases_titles => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden)' },
+ releases_vn => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden) AND vid IN(SELECT id FROM vn WHERE NOT hidden)' },
rlists => { where => 'EXISTS(SELECT 1 FROM releases r'
.' JOIN releases_vn rv ON rv.id = r.id'
.' JOIN vn v ON v.id = rv.vid'
- .' JOIN ulist_vns_labels uvl ON uvl.vid = rv.vid'
- .' JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl'
- .' WHERE r.id = rlists.rid AND uvl.uid = rlists.uid AND NOT r.hidden AND NOT v.hidden AND NOT ul.private)' },
- staff => { where => 'NOT hidden' },
- staff_alias => { where => 'id IN(SELECT id FROM staff WHERE NOT hidden)' },
- tags => { where => 'state = 2' },
- tags_aliases => { where => 'tag IN(SELECT id FROM tags WHERE state = 2)' },
- tags_parents => { where => 'tag IN(SELECT id FROM tags WHERE state = 2)' },
- tags_vn => { where => 'tag IN(SELECT id FROM tags WHERE state = 2) AND vid IN(SELECT id FROM vn WHERE NOT hidden)', order => 'tag, vid, uid, date' },
- traits => { where => 'state = 2' },
- traits_parents => { where => 'trait IN(SELECT id FROM traits WHERE state = 2)' },
- ulist_labels => { where => 'NOT private AND EXISTS(SELECT 1 FROM ulist_vns_labels uvl WHERE uvl.lbl = id AND ulist_labels.uid = uvl.uid)' },
- ulist_vns => { where => 'vid IN(SELECT id FROM vn WHERE NOT hidden)'
- .' AND EXISTS(SELECT 1 FROM ulist_vns_labels uvl'
- .' JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl'
- .' WHERE ulist_vns.uid = uvl.uid AND ulist_vns.vid = uvl.vid AND NOT ul.private)' },
- ulist_vns_labels => { where => 'vid IN(SELECT id FROM vn WHERE NOT hidden)'
- .' AND EXISTS(SELECT 1 FROM ulist_labels ul WHERE ul.uid = ulist_vns_labels.uid AND id = lbl AND NOT ul.private)' },
- users => { where => 'id IN(SELECT DISTINCT uvl.uid FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE NOT ul.private)'
- .' OR id IN(SELECT DISTINCT uid FROM tags_vn)'
- .' OR id IN(SELECT DISTINCT uid FROM image_votes)' },
- vn => { where => 'NOT hidden' },
- vn_anime => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)' },
- vn_relations => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)' },
- vn_screenshots => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)' },
- vn_seiyuu => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)'
- .' AND aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)'
- .' AND cid IN(SELECT id FROM chars WHERE NOT hidden)' },
- vn_staff => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden) AND aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)' },
- wikidata => { where => q{id IN(SELECT l_wikidata FROM producers WHERE NOT hidden
- UNION SELECT l_wikidata FROM staff WHERE NOT hidden
- UNION SELECT l_wikidata FROM vn WHERE NOT hidden)} },
+ .' JOIN ulist_vns uv ON uv.vid = rv.vid'
+ .' WHERE r.id = x.rid AND uv.uid = x.uid AND NOT r.hidden AND NOT v.hidden AND NOT uv.c_private)' },
+ staff => { where => 'NOT x.hidden' },
+ staff_alias => { where => 'x.id IN(SELECT id FROM staff WHERE NOT hidden)' },
+ tags => { where => 'NOT x.hidden' },
+ tags_parents => { where => 'x.id IN(SELECT id FROM tags WHERE NOT hidden)' },
+ tags_vn => { where => 'x.tag IN(SELECT id FROM tags WHERE NOT hidden) AND x.vid IN(SELECT id FROM vn WHERE NOT hidden)', order => 'x.tag, x.vid, x.uid, x.date' },
+ traits => { where => 'NOT x.hidden' },
+ traits_parents => { where => 'x.id IN(SELECT id FROM traits WHERE NOT hidden)' },
+ ulist_labels => { where => 'NOT x.private AND EXISTS(SELECT 1 FROM ulist_vns uv JOIN vn v ON v.id = uv.vid
+ WHERE NOT v.hidden AND uv.labels && ARRAY[x.id] AND x.uid = uv.uid)' },
+ ulist_vns => { sql => $sql_ulist_vns },
+ users => { where => 'x.username IS NOT NULL AND ('
+ .' x.id IN(SELECT DISTINCT uid FROM ulist_vns WHERE NOT c_private)'
+ .' OR x.id IN(SELECT DISTINCT uid FROM tags_vn)'
+ .' OR x.id IN(SELECT DISTINCT uid FROM image_votes)'
+ .' OR x.id IN(SELECT DISTINCT uid FROM vn_length_votes WHERE NOT private))' },
+ vn => { where => 'NOT x.hidden' },
+ vn_anime => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_editions => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_relations => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_screenshots => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_seiyuu => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)'
+ .' AND x.aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)'
+ .' AND x.cid IN(SELECT id FROM chars WHERE NOT hidden)' },
+ vn_staff => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden) AND x.aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)'
+ , order => 'x.id, x.eid, x.aid, x.role' },
+ vn_titles => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_length_votes => { where => 'x.vid IN(SELECT id FROM vn WHERE NOT hidden) AND NOT x.private'
+ , order => 'x.vid, x.uid' },
+ wikidata => { where => q{x.id IN(SELECT l_wikidata FROM producers WHERE NOT hidden
+ UNION SELECT l_wikidata FROM staff WHERE NOT hidden
+ UNION SELECT l_wikidata FROM vn WHERE NOT hidden)} },
);
my @tables = map +{ name => $_, %{$tables{$_}} }, sort keys %tables;
@@ -119,13 +151,27 @@ my $references = VNDB::Schema::references;
my $db = DBI->connect('dbi:Pg:dbname=vndb', 'vndb', undef, { RaiseError => 1, AutoCommit => 0 });
$db->do('SET TIME ZONE +0');
-$db->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
+
+
+sub consistent_snapshot {
+ my($func) = @_;
+ my($standby) = $db->selectrow_array('SELECT pg_is_in_recovery()');
+ if($standby) {
+ $db->do('SELECT pg_wal_replay_pause()');
+ } else {
+ $db->rollback;
+ $db->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
+ }
+ eval { $func->() };
+ warn $@ if length $@;
+ $db->do('SELECT pg_wal_replay_resume()') if $standby;
+}
sub table_order {
my $s = $schema->{$_[0]};
my $c = $tables{$_[0]};
- my $o = $s->{primary} ? join ', ', map "\"$_\"", $s->{primary}->@* : $c ? $c->{order} : '';
+ my $o = $s->{primary} ? join ', ', map "x.$_", $s->{primary}->@* : $c ? $c->{order} : '';
$o ? "ORDER BY $o" : '';
}
@@ -146,14 +192,32 @@ sub export_table {
my $fn = "$dest/$table->{name}";
- # Truncate all timestamptz columns to a day, to avoid leaking privacy-sensitive info.
- my $cols = join ', ', map $_->{type} eq 'timestamptz' ? "date_trunc('day', \"$_->{name}\")" : qq{"$_->{name}"}, @cols;
- my $where = $table->{where} ? "WHERE $table->{where}" : '';
- my $order = table_order $table->{name};
- die "Table '$table->{name}' is missing an ORDER BY clause\n" if !$order;
+ my $sql = $table->{sql} // do {
+ my %isuid =
+ map +($_->{from_cols}[0], 1),
+ grep $_->{to_table} eq 'users' && $_->{to_cols}[0] eq 'id' && $_->{from_table} eq $table->{name}, @$references;
+ my $join = '';
+
+ my $cols = join ', ', map {
+ # For uid columns, check against the users table and export NULL for deleted accounts
+ $isuid{$_->{name}} ? do {
+ my $t = "u_$_->{name}";
+ $join .= " LEFT JOIN users $t ON $t.id = x.$_->{name}";
+ "CASE WHEN $t.username IS NULL THEN NULL ELSE $t.id END"
+ }
+ # Truncate all timestamptz columns to a day, to avoid leaking privacy-sensitive info.
+ : $_->{type} eq 'timestamptz' ? "date_trunc('day', x.$_->{name})"
+ : qq{x.$_->{name}}
+ } @cols;
+
+ my $where = $table->{where} ? "WHERE $table->{where}" : '';
+ my $order = table_order $table->{name};
+ die "Table '$table->{name}' is missing an ORDER BY clause\n" if !$order;
+ qq{SELECT $cols FROM $table->{name} x $join $where $order}
+ };
my $start = time;
- $db->do(qq{COPY (SELECT $cols FROM "$table->{name}" $where $order) TO STDOUT});
+ $db->do(qq{COPY ($sql) TO STDOUT});
open my $F, '>:utf8', $fn;
my $v;
print $F $v while($db->pg_getcopydata($v) >= 0);
@@ -199,10 +263,11 @@ sub export_import_script {
for my $table (@tables) {
my $schema = $schema->{$table->{name}};
+ my @primary = grep { my $n=$_; !!grep $_->{name} eq $n && $_->{pub}, $schema->{cols}->@* } ($schema->{primary}||[])->@*;
print $F "\n";
- print $F "CREATE TABLE \"$table->{name}\" (\n";
- print $F join ",\n", map " $_->{decl}" =~ s/" serial/" integer/ir =~ s/ +(?:check|constraint|default) +.*//ir, grep $_->{pub}, @{$schema->{cols}};
- print $F ",\n PRIMARY KEY(".join(', ', map "\"$_\"", @{$schema->{primary}}).")" if $schema->{primary};
+ print $F "CREATE TABLE $table->{name} (\n";
+ print $F join ",\n", map " $_->{decl}" =~ s/ serial/ integer/ir =~ s/ +(?:check|constraint|default) +.*//ir, grep $_->{pub}, @{$schema->{cols}};
+ print $F ",\n PRIMARY KEY(".join(', ', map "$_", @primary).")" if @primary;
print $F "\n);\n";
}
@@ -218,6 +283,19 @@ sub export_import_script {
next if grep !$pub{$_}, @{$ref->{from_cols}};
print $F "$ref->{decl}\n";
}
+
+ print $F "\n\n";
+ print $F "-- Sparse documentation, but it's something!\n";
+ my $L = \%VNDB::ExtLinks::LINKS;
+ for my $table (@tables) {
+ my $schema = $schema->{$table->{name}};
+ print $F "COMMENT ON TABLE $table->{name} IS ".$db->quote($schema->{comment}).";\n" if $schema->{comment};
+ my $l = ($schema->{dbentry_type} && $L->{$schema->{dbentry_type}}) || {};
+ for (grep $_->{pub}, $schema->{cols}->@*) {
+ $_->{comment} = "$l->{$_->{name}}{label}, $l->{$_->{name}}{fmt} $_->{comment}" if $l->{$_->{name}} && $l->{$_->{name}}{fmt};
+ print $F "COMMENT ON COLUMN $table->{name}.$_->{name} IS ".$db->quote($_->{comment}).";\n" if $_->{comment};
+ }
+ }
}
@@ -232,7 +310,7 @@ sub export_db {
README.txt
};
- # This will die if it already exists, which is good because we want to write to a new empty dir.
+ rmtree "${dest}_dir";
mkdir "${dest}_dir";
mkdir "${dest}_dir/db";
@@ -243,7 +321,8 @@ sub export_db {
export_import_script "${dest}_dir/import.sql";
#print "# Compressing\n";
- `tar -cf "$dest" -I 'zstd -7' --sort=name -C "${dest}_dir" @static import.sql TIMESTAMP db`
+ `tar -cf "$dest" -I 'zstd -7' --sort=name -C "${dest}_dir" @static import.sql TIMESTAMP db`;
+ rmtree "${dest}_dir";
}
@@ -259,36 +338,35 @@ sub cp_p {
sub export_img {
my $dest = shift;
- {
- no autodie;
- mkdir ${dest};
- mkdir sprintf '%s/%s', $dest, $_ for qw/ch cv sf st/;
- mkdir sprintf '%s/%s/%02d', $dest, $_->[0], $_->[1] for map +([ch=>$_], [cv=>$_], [sf=>$_], [st=>$_]), 0..99;
- }
+ no autodie;
+ mkdir ${dest};
+ mkdir sprintf '%s/%s', $dest, $_ for qw/ch cv sf sf.t/;
+ mkdir sprintf '%s/%s/%02d', $dest, $_->[0], $_->[1] for map +([ch=>$_], [cv=>$_], [sf=>$_], ['sf.t'=>$_]), 0..99;
cp_p "$ROOT/util/dump/LICENSE-ODBL.txt", "$dest/LICENSE-ODBL.txt";
cp_p "$ROOT/util/dump/README-img.txt", "$dest/README.txt";
export_timestamp "$dest/TIMESTAMP";
my %scr;
- my %dir = (ch => {}, cv => {}, sf => \%scr, st => \%scr);
- $dir{sf}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(scr) FROM vn_screenshots WHERE $tables{vn_screenshots}{where}");
- $dir{cv}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(image) FROM vn WHERE image IS NOT NULL AND $tables{vn}{where}");
- $dir{ch}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(image) FROM chars WHERE image IS NOT NULL AND $tables{chars}{where}");
+ my %dir = (ch => {}, cv => {}, sf => \%scr, 'sf.t' => \%scr);
+ $dir{sf}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(scr) FROM vn_screenshots x WHERE $tables{vn_screenshots}{where}");
+ $dir{cv}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(image) FROM vn x WHERE image IS NOT NULL AND $tables{vn}{where}");
+ $dir{ch}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(image) FROM chars x WHERE image IS NOT NULL AND $tables{chars}{where}");
$db->rollback;
undef $db;
find {
no_chdir => 1,
wanted => sub {
- unlink $File::Find::name if $File::Find::name =~ m{(cv|ch|sf|st)/[0-9][0-9]/([0-9]+)\.jpg$} && !$dir{$1}{$2};
+ unlink $File::Find::name or warn "Unable to unlink $File::Find::name: $!\n"
+ if $File::Find::name =~ m{(cv|ch|sf|sf\.t)/[0-9][0-9]/([0-9]+)\.jpg$} && !$dir{$1}{$2};
}
}, $dest;
for my $d (keys %dir) {
for my $i (keys %{$dir{$d}}) {
my $f = sprintf('%s/%02d/%d.jpg', $d, $i % 100, $i);
- link "$ROOT/static/$f", "$dest/$f" if !-e "$dest/$f";
+ link "$ENV{VNDB_VAR}/static/$f", "$dest/$f" or warn "Unable to link $f: $!\n" if !-e "$dest/$f";
}
}
}
@@ -301,6 +379,7 @@ sub export_data {
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(
@@ -308,10 +387,10 @@ sub export_data {
) };
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}\"", $t->{cols}->@*;
+ 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}\" $order) TO STDOUT");
+ 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";
@@ -338,7 +417,7 @@ sub export_votes {
WHERE NOT v.hidden
AND NOT u.ign_votes
AND uv.vote IS NOT NULL
- AND EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.id = uvl.lbl AND ul.uid = uvl.uid WHERE uv.uid = uvl.uid AND uv.vid = uvl.vid AND NOT ul.private)
+ AND NOT uv.c_private
ORDER BY uv.vid, uv.uid
) TO STDOUT
});
@@ -353,10 +432,9 @@ sub export_tags {
require PerlIO::gzip;
my $lst = $db->selectall_arrayref(q{
- SELECT id, name, description, searchable, applicable, c_items AS vns, cat,
- (SELECT string_agg(alias,'$$$-$$$') FROM tags_aliases where tag = id) AS aliases,
- (SELECT string_agg(parent::text, ',') FROM tags_parents WHERE tag = id) AS parents
- FROM tags WHERE state = 2 ORDER BY id
+ SELECT vndbid_num(id) AS id, name, description, searchable, applicable, c_items AS vns, cat, alias,
+ (SELECT string_agg(vndbid_num(parent)::text, ',' ORDER BY main desc, parent) FROM tags_parents tp WHERE tp.id = t.id) AS parents
+ FROM tags t WHERE NOT hidden ORDER BY id
}, { Slice => {} });
for(@$lst) {
$_->{id} *= 1;
@@ -364,7 +442,7 @@ sub export_tags {
$_->{searchable} = $_->{searchable} ? JSON::XS::true() : JSON::XS::false();
$_->{applicable} = $_->{applicable} ? JSON::XS::true() : JSON::XS::false();
$_->{vns} *= 1;
- $_->{aliases} = [ split /\$\$\$-\$\$\$/, ($_->{aliases}||'') ];
+ $_->{aliases} = [ split /\n/, delete $_->{alias} ];
$_->{parents} = [ map $_*1, split /,/, ($_->{parents}||'') ];
}
@@ -379,9 +457,9 @@ sub export_traits {
require PerlIO::gzip;
my $lst = $db->selectall_arrayref(q{
- SELECT id, name, alias AS aliases, description, searchable, applicable, c_items AS chars,
- (SELECT string_agg(parent::text, ',') FROM traits_parents WHERE trait = id) AS parents
- FROM traits WHERE state = 2 ORDER BY id
+ SELECT vndbid_num(id) AS id, name, alias AS aliases, description, searchable, applicable, c_items AS chars,
+ (SELECT string_agg(vndbid_num(parent)::text, ',' ORDER BY main desc, parent) FROM traits_parents tp WHERE tp.id = t.id) AS parents
+ FROM traits t WHERE NOT hidden ORDER BY id
}, { Slice => {} });
for(@$lst) {
$_->{id} *= 1;
@@ -399,7 +477,7 @@ sub export_traits {
if($ARGV[0] && $ARGV[0] eq 'export-db' && $ARGV[1]) {
- export_db $ARGV[1];
+ consistent_snapshot sub { export_db $ARGV[1] };
} elsif($ARGV[0] && $ARGV[0] eq 'export-img' && $ARGV[1]) {
export_img $ARGV[1];
} elsif($ARGV[0] && $ARGV[0] eq 'export-data' && $ARGV[1]) {
diff --git a/util/devdump.pl b/util/devdump.pl
index 58844dba..e0f0f80f 100755
--- a/util/devdump.pl
+++ b/util/devdump.pl
@@ -17,38 +17,47 @@ use lib $ROOT.'/lib';
my $db = DBI->connect('dbi:Pg:dbname=vndb', 'vndb', undef, { RaiseError => 1 });
-sub ids { join ',', map "'$_'", @_ }
+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 = (qw/v3 v17 v97 v183 v264 v266 v384 v407 v1910 v2932 v5922 v6438 v9837/);
-my $vids = ids @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.itemid IN(".ids(@$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 $images = $db->selectcol_arrayref(q{
- SELECT image FROM chars_hist ch JOIN changes c ON c.id = ch.chid WHERE c.itemid IN(}.ids(@$characters).qq{) AND ch.image 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";
- warn $dest;
$sql ||= "SELECT * FROM $dest";
$specials ||= {};
@@ -58,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{CASE WHEN vndbid_num("$_") % 10 = 0 THEN NULL ELSE vndbid('u', vndbid_num("$_") % 10) END 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");
@@ -80,7 +85,6 @@ sub copy {
# Helper function to copy a full DB entry with history and all (doesn't handle references)
sub copy_entry {
my($tables, $ids) = @_;
- $ids = ids @$ids;
copy changes => "SELECT * FROM changes WHERE itemid IN($ids)", {requester => 'user', ip => 'del'};
for(@$tables) {
my $add = '';
@@ -97,6 +101,7 @@ sub copy_entry {
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";
@@ -111,41 +116,41 @@ 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_usermod, passwd, email_confirmed) VALUES ('%s', '%s', '%s', %s, decode('%s', 'hex'), true);\n", @$_, $pass for(
- [ 'u2', 'admin', 'admin@vndb.org', 'true' ],
- [ 'u3', 'user1', 'user1@vndb.org', 'false'],
- [ 'u4', 'user2', 'user2@vndb.org', 'false'],
- [ 'u5', 'user3', 'user3@vndb.org', 'false'],
- [ 'u6', 'user4', 'user4@vndb.org', 'false'],
- [ 'u7', 'user5', 'user5@vndb.org', 'false'],
- [ 'u8', 'user6', 'user6@vndb.org', 'false'],
- [ 'u9', 'user7', 'user7@vndb.org', 'false'],
- );
+ 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
- my $image_ids = ids @$images;
- copy images => "SELECT * FROM images WHERE id IN($image_ids)";
- copy image_votes => "SELECT DISTINCT ON (id,vndbid('u', vndbid_num(uid)%10+10)) * FROM image_votes WHERE id IN($image_ids)", { uid => 'user' };
+ 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 ',', map "'$_'", @{ $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 ['docs'], $db->selectcol_arrayref('SELECT id FROM docs');
+ copy_entry ['docs'], 'SELECT id FROM docs';
# Staff
copy_entry [qw/staff staff_alias/], $staff;
@@ -158,18 +163,19 @@ sub copy_entry {
# Visual novels
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_seiyuu vn_staff vn_relations vn_screenshots/], \@vids;
+ copy_entry [qw/vn vn_anime vn_editions vn_seiyuu vn_staff vn_relations vn_screenshots vn_titles/], $vids;
# VN-related niceties
+ 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 vid IN($vids)";
- my $votes = "SELECT vid, vndbid('u', vndbid_num(uid)%8+2) AS uid, (percentile_cont((vndbid_num(uid)%8+1)::float/9) WITHIN GROUP (ORDER BY vote))::smallint AS vote, MIN(vote_date) AS vote_date"
- ." FROM ulist_vns WHERE vid IN($vids) AND vote IS NOT NULL GROUP BY vid, vndbid_num(uid)%8";
- copy ulist_vns => $votes, {uid => 'user'};
- copy ulist_vns_labels => "SELECT vid, uid, 7 AS lbl FROM ($votes) x", {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 [qw/releases releases_lang releases_media releases_platforms releases_producers releases_vn/], $releases;
+ copy 'drm';
+ copy_entry [qw/releases releases_drm releases_media releases_platforms releases_producers releases_titles releases_vn/], $releases;
print "\\i sql/tableattrs.sql\n";
print "\\i sql/triggers.sql\n";
@@ -177,16 +183,18 @@ sub copy_entry {
# Update some caches
print "SELECT tag_vn_calc(NULL);\n";
print "SELECT traits_chars_calc(NULL);\n";
- print "SELECT update_vncache(id) FROM vn;\n";
+ print "SELECT count(*) FROM (SELECT update_vncache(id) FROM vn) x;\n";
print "SELECT update_stats_cache_full();\n";
print "SELECT update_vnvotestats();\n";
print "SELECT update_users_ulist_stats(NULL);\n";
print "SELECT update_images_cache(NULL);\n";
+ print "SELECT count(*) FROM (SELECT update_search(id) FROM $_) x;\n" for (qw/chars producers vn releases staff tags traits/);
print "UPDATE users u SET c_tags = (SELECT COUNT(*) FROM tags_vn v WHERE v.uid = u.id);\n";
print "UPDATE users u SET c_changes = (SELECT COUNT(*) FROM changes c WHERE c.requester = u.id);\n";
print "\\set ON_ERROR_STOP 0\n";
print "\\i sql/perms.sql\n";
+ print "VACUUM ANALYZE;\n";
select STDOUT;
close $OUT;
@@ -194,10 +202,11 @@ sub copy_entry {
-
# Now figure out which images we need, and throw everything in a tarball
-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('st', $id) : ()) } @$images;
+if(!$large) {
+ sub img { sprintf 'static/%s/%02d/%d.jpg', $_[0], $_[1]%100, $_[1] }
+ my @imgpaths = sort map { my($t,$id) = /([a-z]+)([0-9]+)/; (img($t, $id), $t eq 'sf' ? img('sf.t', $id) : ()) } @$imageids;
-system("tar -czf devdump.tar.gz dump.sql ".join ' ', @imgpaths);
-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 8f0ef9b6..e3ae25b8 100755
--- a/util/docker-init.sh
+++ b/util/docker-init.sh
@@ -1,6 +1,6 @@
#!/bin/sh
-VER=`test -f /var/www/Dockerfile && grep VNDB_DOCKER_VERSION= /var/www/Dockerfile | sed -E s/^.+=//`
+VER=`test -f /vndb/Dockerfile && grep VNDB_DOCKER_VERSION= /vndb/Dockerfile | sed -E s/^.+=//`
if [ -z "$VER" -o -z "$VNDB_DOCKER_VERSION" -o "$VER" != "$VNDB_DOCKER_VERSION" ]; then
echo "The Docker image version ($VNDB_DOCKER_VERSION) does not match the version in the currently checked out source code ($VER)."
@@ -24,8 +24,8 @@ mkdevuser() {
# If the owner is root, we're probably running under Docker for Mac or
# similar and don't need to match UID/GID. See https://vndb.org/t9959 #38
# to #44.
- USER_UID=`stat -c '%u' /var/www`
- USER_GID=`stat -c '%g' /var/www`
+ USER_UID=`stat -c '%u' /vndb`
+ USER_GID=`stat -c '%g' /vndb`
if test $USER_UID -eq 0; then
addgroup devgroup
adduser -s /bin/sh devuser
@@ -39,20 +39,25 @@ mkdevuser() {
# Should run as root
installvndbid() {
- make -C /var/www/sql/c install || exit
+ mkdir -p /tmp/vndbid
+ cp /vndb/sql/c/vndbfuncs.c /vndb/sql/c/Makefile /tmp/vndbid
+ make -C /tmp/vndbid install || exit
}
# Should run as devuser
pg_start() {
- if [ ! -d /var/www/data/docker-pg/13 ]; then
- mkdir -p /var/www/data/docker-pg/13
- initdb -D /var/www/data/docker-pg/13 --locale en_US.UTF-8 -A trust
+ cd /vndb
+ make -j4
+ util/setup-var.sh
+
+ if [ ! -d docker/pg15 ]; then
+ mkdir -p docker/pg15
+ initdb -D docker/pg15 --locale en_US.UTF-8 -A trust
fi
- pg_ctl -D /var/www/data/docker-pg/13 -l /var/www/data/docker-pg/13/logfile start
+ pg_ctl -D /vndb/docker/pg15 -l /vndb/docker/pg15/logfile start
- cd /var/www
- if test -f data/docker-pg/vndb-init-done; then
+ if test -f docker/pg15/vndb-init-done; then
echo
echo "Database initialization already done."
echo
@@ -65,13 +70,11 @@ pg_start() {
echo "If you want to have some data to play around with,"
echo "I can download and install a development database for you."
echo "For information, see https://vndb.org/d8#3"
- echo "(Warning: This will also write images to static/)"
echo
echo "Enter n to setup an empty database, y to download the dev database."
[ -f dump.sql ] && echo " Or e to import the existing dump.sql."
read -p "Choice: " opt
- make sql/editfunc.sql
psql postgres -f sql/superuser_init.sql
psql -U devuser vndb -f sql/vndbid.sql
echo "ALTER ROLE vndb LOGIN" | psql postgres
@@ -83,14 +86,14 @@ pg_start() {
psql -U vndb -f dump.sql
elif [ $opt = y ]
then
- curl -L https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -xzf-
- psql -U vndb -f dump.sql
- rm dump.sql
+ curl -sL https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -C docker/var -xzf-
+ psql -U vndb -f docker/var/dump.sql
+ rm docker/var/dump.sql
else
psql -U vndb -f sql/all.sql
fi
- touch data/docker-pg/vndb-init-done
+ touch docker/pg15/vndb-init-done
echo
echo "Database initialization done!"
@@ -100,7 +103,7 @@ pg_start() {
# Should run as devuser
devshell() {
- cd /var/www
+ cd /vndb
util/vndb-dev-server.pl
sh
}
@@ -110,8 +113,8 @@ case "$1" in
'')
mkdevuser
installvndbid
- su devuser -c '/var/www/util/docker-init.sh pg_start'
- exec su devuser -c '/var/www/util/docker-init.sh devshell'
+ su devuser -c '/vndb/util/docker-init.sh pg_start'
+ exec su devuser -c '/vndb/util/docker-init.sh devshell'
;;
pg_start)
pg_start
diff --git a/util/hibp-dl.pl b/util/hibp-dl.pl
new file mode 100755
index 00000000..c3abd8c0
--- /dev/null
+++ b/util/hibp-dl.pl
@@ -0,0 +1,89 @@
+#!/usr/bin/perl
+
+# This script downloads a full copy of the Have I Been Pwned SHA1 database
+# using their range API.
+# -> https://haveibeenpwned.com/API/v3#PwnedPasswords
+#
+# Output database format:
+# var/hibp/#### -> file for hashes prefixed with those two bytes
+#
+# Each file is an ordered concatenation of raw hashes, excluding the first
+# two bytes (part of the filename) and the last 8 bytes (truncated hashes),
+# so each hash is represented with 10 bytes.
+#
+# This means we actually store 96bit truncated SHA1 hashes, which should
+# still provide a very low probability of collision. A bloom filter may have
+# a lower collision probability for the same amount of space, but is also
+# more complex and expensive to manage.
+
+use v5.28;
+use warnings;
+use AE;
+use AnyEvent::HTTP;
+use Cwd 'abs_path';
+
+my $API = 'https://api.pwnedpasswords.com/range/';
+my $concurrency = 5;
+my $lastnum = 0;
+my $run = AE::cv;
+
+my $ROOT = abs_path($0) =~ s{/util/hibp-dl\.pl$}{}r;
+
+$ENV{VNDB_VAR} //= 'var';
+
+mkdir "$ENV{VNDB_VAR}/hibp";
+chdir "$ENV{VNDB_VAR}/hibp" or die $!;
+
+
+$AnyEvent::HTTP::MAX_PER_HOST = $concurrency;
+
+sub save {
+ my($file, $count, $data) = @_;
+ {
+ open my $OUT, '>', "$file~" or die $!;
+ print $OUT $data;
+ }
+ rename "$file~", $file or die $!;
+ say sprintf '%s -> %d hashes, %.0f KiB', $file, $count, length($data)/1024;
+}
+
+sub fetch_one {
+ my($file, $count, $data, $midnum) = @_;
+
+ my $mid = sprintf '%X', $midnum;
+ http_request GET => $API.$file.$mid, persistent => 1, sub {
+ my($body, $hdr) = @_;
+ if($hdr->{Status} =~ /^2/) {
+ for (split /\r?\n/, $body) {
+ # 40-5 -> 35 hex chars per hash; 16 of which we discard so 19 we grab.
+ warn "$file.$mid Unrecognized line: $_\n" if !/^([a-fA-F0-9]{19})[a-fA-F0-9]{16}:[0-9]+$/;
+ $count++;
+ $data .= pack 'H*', $mid.$1;
+ }
+ if($midnum == 15) {
+ save $file, $count, $data;
+ fetch_next();
+ } else {
+ fetch_one($file, $count, $data, $midnum+1);
+ }
+ } else {
+ warn "$file.$mid: $hdr->{Status}\n";
+ fetch_next();
+ }
+ };
+}
+
+sub fetch_next {
+ my $file;
+ do {
+ my $filenum = $lastnum++;
+ return $run->end if $filenum > 65535;
+ $file = sprintf '%04X', $filenum;
+ } while(-s $file);
+
+ fetch_one $file, 0, '', 0;
+}
+
+$run->begin for (1..$concurrency);
+fetch_next() for (1..$concurrency);
+$run->recv;
diff --git a/util/imgproc.c b/util/imgproc.c
new file mode 100644
index 00000000..bb5202af
--- /dev/null
+++ b/util/imgproc.c
@@ -0,0 +1,252 @@
+/*
+ * USAGE: imgproc [commands] <input.png
+ *
+ * Commands:
+ *
+ * size - Output image dimensions to standard error.
+ * jpeg n - Write a jpeg to fd n.
+ * fit x y - Resize the image to fit within the given dimensions. Does not upscale.
+ * composite - For util/pngsprite.pl, must be first and only command.
+ Combine multiple input images and write a png to stdout.
+ */
+#include <stdio.h>
+#include <stdlib.h>
+#include <time.h>
+#include <unistd.h>
+
+#ifndef DISABLE_SECCOMP
+#include <seccomp.h>
+#include <fcntl.h>
+#include <locale.h>
+#include <sys/mman.h>
+#include <sys/prctl.h>
+#include <sys/ioctl.h>
+#include <malloc.h>
+#endif
+
+#include <vips/vips.h>
+
+#define MAX_INPUT_SIZE (10*1024*1024)
+
+char input_buffer[MAX_INPUT_SIZE];
+size_t input_len;
+
+
+#ifndef DISABLE_SECCOMP
+
+static void setup_seccomp() {
+ scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL_PROCESS);
+ if (ctx == NULL) goto err;
+
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap), 2,
+ SCMP_A2_32(SCMP_CMP_EQ, PROT_READ|PROT_WRITE),
+ SCMP_A4_32(SCMP_CMP_EQ, -1)
+ )) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mremap), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(munmap), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(madvise), 1, SCMP_A2_32(SCMP_CMP_EQ, MADV_DONTNEED))) goto err;
+
+ /* Threading, very fiddly :(
+ * These are likely specific to a particular glibc version on x86_64.
+ * I made an attempt to patch libvips to not use threads, but that turned out to be far more challenging.
+ */
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(futex), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(clone3), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rseq), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(set_robust_list), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap), 3,
+ SCMP_A2_32(SCMP_CMP_EQ, PROT_NONE),
+ SCMP_A3_32(SCMP_CMP_MASKED_EQ, MAP_PRIVATE&MAP_ANONYMOUS, MAP_PRIVATE|MAP_ANONYMOUS),
+ SCMP_A4_32(SCMP_CMP_EQ, -1)
+ )) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mprotect), 1, SCMP_A2_32(SCMP_CMP_EQ, PROT_READ|PROT_WRITE))) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(prctl), 1, SCMP_A0_32(SCMP_CMP_EQ, PR_SET_NAME))) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(sched_getaffinity), 0)) goto err;
+
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rt_sigaction), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rt_sigprocmask), 0)) goto err;
+
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(close), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(dup), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 1, SCMP_A0(SCMP_CMP_EQ, 0))) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0)) goto err;
+
+ /* glib logging thing
+ (disabled, no need with our custom logging handler)
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(getpeername), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(getpid), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(ioctl), 1, SCMP_A1(SCMP_CMP_EQ, TCGETS))) goto err;*/
+
+ if (seccomp_load(ctx) < 0) goto err;
+ seccomp_release(ctx);
+ return;
+err:
+ perror("setting up seccomp");
+ exit(1);
+}
+
+#endif
+
+
+/* The default glib logging handler attempt to do charset conversion, color
+ * detection and other unnecessary crap that complicates parsing and sandboxing. */
+static void log_func(const gchar *log_domain, GLogLevelFlags log_level, const gchar *message, gpointer user_data) {
+ if (g_log_writer_default_would_drop(log_level, log_domain)) return;
+ /* https://github.com/libvips/libvips/discussions/2734 - fix not yet in a release */
+ if (strcmp(message, "heifload: ignoring nclx profile") == 0) return;
+ fprintf(stderr, "[%s#%d] %s\n", log_domain, (int)log_level, message);
+}
+
+
+static int composite(void) {
+ if (input_len < 8) return 1;
+
+ int offset = 0;
+#define RDINT ({ offset += 4; *((int *)(input_buffer+offset-4)); })
+
+ int width = RDINT;
+ int height = RDINT;
+ /*fprintf(stderr, "Output of %dx%d\n", width, height);*/
+ VipsImage *img;
+ vips_black(&img, width, height, "bands", 4, NULL);
+
+ while (input_len - offset > 12) {
+ int x = RDINT;
+ int y = RDINT;
+ int bytes = RDINT;
+ /*fprintf(stderr, "Image at %dx%d of %d bytes\n", x, y, bytes);*/
+ if (input_len - offset < bytes) return 1;
+ VipsImage *sub = vips_image_new_from_buffer(input_buffer+offset, bytes, "", NULL);
+ if (!img) vips_error_exit(NULL);
+ offset += bytes;
+
+ VipsImage *tmp;
+ if (!vips_image_hasalpha(sub)) {
+ if (vips_addalpha(sub, &tmp, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(sub);
+ sub = tmp;
+ }
+
+ if (vips_insert(img, sub, &tmp, x, y, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ VIPS_UNREF(sub);
+ img = tmp;
+ }
+
+ VipsTarget *target = vips_target_new_to_descriptor(1);
+ if (vips_pngsave_target(img, target, "strip", TRUE, NULL))
+ vips_error_exit(NULL);
+ VIPS_UNREF(target);
+ return 0;
+}
+
+
+int main(int argc, char **argv) {
+#ifndef DISABLE_SECCOMP
+ /* don't write to temporary files when working with large images,
+ unless we need more than 1g, then we'll just crash. */
+ putenv("VIPS_DISC_THRESHOLD=1g");
+
+ /* error messages go through gettext(), prevent that from loading translation files */
+ putenv("LANGUAGE=C");
+
+ /* Timezone initialization loads data from disk */
+ putenv("TZ=");
+ tzset();
+
+ /* glibc malloc() trim feature attempts to read from /proc */
+ mallopt(M_TRIM_THRESHOLD, -1);
+#endif
+
+ if (VIPS_INIT(argv[0])) vips_error_exit(NULL);
+ g_log_set_default_handler(log_func, NULL);
+
+#ifndef DISABLE_SECCOMP
+ /* vips error logging attempt to do charset stuff
+ (must be a UTF-8 locale otherwise it tries to load iconv modules, sigh) */
+ setlocale(LC_ALL, "C.utf8");
+ g_get_charset(NULL);
+
+ setup_seccomp();
+#endif
+
+ /* Reading into a buffer allows for more strict seccomp rules than using vips_source_new_from_descriptor() */
+ int r = 0;
+ while ((r = read(0, input_buffer + input_len, MAX_INPUT_SIZE - input_len)) > 0)
+ input_len += r;
+ if (r < 0) {
+ perror("reading input");
+ exit(1);
+ }
+
+ if (argc == 2 && strcmp(argv[1], "composite") == 0) return composite();
+
+ VipsImage *img = vips_image_new_from_buffer(input_buffer, input_len, "", NULL);
+ if (!img) vips_error_exit(NULL);
+
+ /* Remove alpha channel */
+ VipsImage *tmp;
+ if (vips_image_hasalpha(img)) {
+ /* "white" is 256 for 8-bit images and 65536 for 16-bit, the latter works for both.
+ (where is this documented!?) */
+ VipsArrayDouble *white = vips_array_double_newv(1, 65536.0);
+ if (vips_flatten(img, &tmp, "background", white, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ img = tmp;
+ }
+
+ /* This approach to processing CLI arguments is sloppy and unsafe, but the
+ * CLI is considered trusted input. */
+ while (*++argv) {
+ if (strcmp(*argv, "size") == 0)
+ fprintf(stderr, "%dx%d\n", vips_image_get_width(img), vips_image_get_height(img));
+
+ else if (strcmp(*argv, "jpeg") == 0) {
+ int fd = atoi(*++argv);
+
+ /* Always save as sRGB (suboptimal for greyscale images... do we have those?) */
+ if (vips_colourspace(img, &tmp, VIPS_INTERPRETATION_sRGB, NULL))
+ vips_error_exit(NULL);
+
+ /* Ignore DPI values from the original image, enforce a consistent 72 DPI */
+ vips_copy(tmp, &img, "xres", 2.83, "yres", 2.83, NULL);
+ VIPS_UNREF(tmp);
+
+ VipsTarget *target = vips_target_new_to_descriptor(fd);
+ if (vips_jpegsave_target(img, target, "Q", 90, "optimize_coding", TRUE, "strip", TRUE, NULL))
+ vips_error_exit(NULL);
+ VIPS_UNREF(target);
+
+ } else if (strcmp(*argv, "fit") == 0) {
+ int width = atoi(*++argv);
+ int height = atoi(*++argv);
+ if (width >= vips_image_get_width(img) && height >= vips_image_get_height(img))
+ continue;
+
+ /* The "linear" option is supposedly quite slow (haven't benchmarked, seems
+ fast enough) but it offers a very significant quality boost. */
+ if (vips_thumbnail_image(img, &tmp, width, "height", height, "linear", TRUE, NULL))
+ vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ img = tmp;
+
+ /* The lanczos3 kernel used by vips_thumbnail tends to be overly blurry for small images.
+ Ideally we should use a sharper downscaler instead, but I couldn't find any in VIPS,
+ so just use a sharpen post-processing filter for now. */
+ if (width * height < 400*400) {
+ if (vips_sharpen(img, &tmp, "m2", 2.0, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ img = tmp;
+ }
+
+ } else {
+ fprintf(stderr, "Unknown argument: %s\n", *argv);
+ return 1;
+ }
+ }
+
+ return 0;
+}
diff --git a/util/jsgen.pl b/util/jsgen.pl
new file mode 100755
index 00000000..87641a12
--- /dev/null
+++ b/util/jsgen.pl
@@ -0,0 +1,70 @@
+#!/usr/bin/perl
+
+use Cwd 'abs_path';
+our $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/jsgen\.pl$}{}; }
+
+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 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 ],
+ tagCategory => [ map [$_, $TAG_CATEGORY{$_}], keys %TAG_CATEGORY ],
+ }).";\n";
+}
+
+sub zones {
+ print 'window.timeZones = '.$js->encode(\@ZONES).";\n";
+}
+
+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";
+}
+
+if ($ARGV[0] eq 'types') { validations; types; }
+if ($ARGV[0] eq 'user') { zones; vskins; }
+if ($ARGV[0] eq 'extlinks') { extlinks; }
diff --git a/util/multi.pl b/util/multi.pl
index 1ad92ef4..6dc3cf5c 100755
--- a/util/multi.pl
+++ b/util/multi.pl
@@ -10,4 +10,6 @@ BEGIN { ($ROOT = abs_path $0) =~ s{/util/multi\.pl$}{} }
use lib $ROOT.'/lib';
use Multi::Core;
-Multi::Core->run();
+my $quiet = grep '-q', @ARGV;
+
+Multi::Core::run $quiet;
diff --git a/util/spritegen.pl b/util/pngsprite.pl
index 26cd4da8..79fc2719 100755
--- a/util/spritegen.pl
+++ b/util/pngsprite.pl
@@ -1,30 +1,27 @@
#!/usr/bin/perl
-use strict;
-use warnings;
-use Cwd 'abs_path';
+use v5.28;
-our $ROOT;
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/spritegen\.pl$}{}; }
+my $GEN = $ENV{VNDB_GEN} // 'gen';
-use lib "$ROOT/lib";
-use VNDB::Config;
-
-my $path = "$ROOT/data/icons";
-my $icons = "$ROOT/static/g/icons.png";
-my $ticons = "$ROOT/static/g/icons~.png";
-my $css = "$ROOT/data/icons/icons.css";
+my $icons = "$GEN/static/icons.png";
+my $ticons = "$GEN/static/icons~.png";
+my $css = "$GEN/png.css";
+my $imgproc = "$GEN/imgproc";
my @img = map {
- my $id = config->{identify_path};
- my($w,$h) = split 'x', `$id -format "%wx%h" "$_"`;
+ 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;
{
- p => $_,
- f => /^\Q$path\E\/(.+)\.png/ && $1,
+ f => /^icons\/(.+)\.png/ && $1,
w => $w,
h => $h,
+ d => $data,
}
-} glob("$path/*.png"), glob("$path/*/*.png");
+} glob("icons/*.png"), glob("icons/*/*.png");
@img = sort { $b->{h} <=> $a->{h} || $b->{w} <=> $a->{w} } @img;
@@ -96,11 +93,9 @@ sub minstrip {
sub img {
my($w, $h) = @_;
- my @cmd = (config->{convert_path}, -size => "${w}x$h", 'canvas:rgba(0,0,0,0)',
- map(+($_->{p}, -geometry => "+$_->{x}+$_->{y}", '-composite'), @img),
- '-strip', "png32:$ticons"
- );
- system(@cmd);
+ 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;
}
@@ -114,11 +109,11 @@ sub css {
$gender = $i;
next;
}
- $i->{f} =~ /([^\/]+)$/;
- printf $F ".icons.%s { background-position: %dpx %dpx }\n", $1, -$i->{x}, -$i->{y};
+ 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 ".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};
+ 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};
}
diff --git a/util/revision-integrity.pl b/util/revision-integrity.pl
index 8c542f92..4bed133d 100755
--- a/util/revision-integrity.pl
+++ b/util/revision-integrity.pl
@@ -22,7 +22,7 @@ use VNDB::Schema;
my $schema = VNDB::Schema::schema;
for my $table (sort { $a->{name} cmp $b->{name} } values %$schema) {
- next if $table->{name} !~ /^(.+)_hist$/;
+ next if $table->{name} !~ /^(.+)_hist$/ || $table->{name} eq 'users_username_hist';
my($main, $type) = ($1, $1);
$type =~ s/_[^_]+$// while !$schema->{$type}{dbentry_type};
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/sqleditfunc.pl b/util/sqleditfunc.pl
index 67aef6cf..59558822 100755
--- a/util/sqleditfunc.pl
+++ b/util/sqleditfunc.pl
@@ -1,9 +1,7 @@
#!/usr/bin/perl
-use strict;
-use warnings;
+use v5.28;
use List::Util 'any';
-
use Cwd 'abs_path';
our $ROOT;
BEGIN { ($ROOT = abs_path $0) =~ s{/util/sqleditfunc\.pl$}{}; }
@@ -19,13 +17,13 @@ sub gensql {
# table_name_without_hist => [ column_names_without_chid ]
my %ts = map
- +($_, [ map "\"$_->{name}\"", grep $_->{name} !~ /^chid$/, @{$schema->{"${_}_hist"}{cols}} ]),
+ +($_, [ map $_->{name}, grep $_->{name} !~ /^chid$/, @{$schema->{"${_}_hist"}{cols}} ]),
map /^${item}_/ && /^(.+)_hist$/ ? $1 : (), keys %$schema;
my %replace = ( item => $item, itemtype => $schema->{$item}{dbentry_type} );
$replace{createtemptables} = join "\n", map sprintf(
- " CREATE TEMPORARY TABLE edit_%s (LIKE %s INCLUDING DEFAULTS INCLUDING CONSTRAINTS);\n".
+ " CREATE TEMPORARY TABLE edit_%s (LIKE %s INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING GENERATED);\n".
" ALTER TABLE edit_%1\$s DROP COLUMN %s;",
$_, $_ eq 'staff_alias' ? ($_, 'id') : ("${_}_hist", 'chid') # staff_alias copies from the non-_hist table, because it needs the sequence
), sort keys %ts;
@@ -52,9 +50,8 @@ sub gensql {
}
-open my $F, '>', "$ROOT/sql/editfunc.sql" or die $!;
-print $F "-- Automatically generated by util/sqleditfunc.pl. DO NOT EDIT.\n";
-print $F gensql $_ for sort grep $schema->{$_}{dbentry_type}, keys %$schema;
+print "-- Automatically generated by util/sqleditfunc.pl. DO NOT EDIT.\n";
+print gensql $_ for sort grep $schema->{$_}{dbentry_type}, keys %$schema;
__DATA__
@@ -96,8 +93,8 @@ BEGIN
INSERT INTO {item} DEFAULT VALUES RETURNING id INTO nitemid;
END IF;
-- insert change
- INSERT INTO changes (itemid, rev, requester, ip, comments, ihid, ilock)
- SELECT nitemid, nrev, requester, ip, comments, ihid, ilock FROM edit_revision RETURNING id INTO nchid;
+ 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}
diff --git a/util/svgsprite.pl b/util/svgsprite.pl
new file mode 100755
index 00000000..910e0454
--- /dev/null
+++ b/util/svgsprite.pl
@@ -0,0 +1,54 @@
+#!/usr/bin/perl
+
+# I had planned to use fragment identifiers as described in
+# https://css-tricks.com/svg-fragment-identifiers-work/
+# But it turns out Firefox doesn't cache/reuse the SVG when referenced with
+# different fragments. :facepalm:
+
+use v5.26;
+use strict;
+use autodie;
+
+my $GEN = $ENV{VNDB_GEN} // 'gen';
+
+my %icons = map +((m{^icons/(.+)\.svg$})[0] =~ s#/#-#rg, $_), glob('icons/*.svg'), glob('icons/*/*.svg');
+my $idnum = 'a';
+my($width, $height) = (-10,0);
+my($defs, $group, $css) = ('','','');
+
+for my $id (sort keys %icons) {
+ my $data = do { local $/=undef; open my $F, '<', $icons{$id}; <$F> };
+ $data =~ s{<\?xml[^>]*>}{};
+ $data =~ s{</svg>}{}g;
+ $data =~ s/\n//g;
+ $data =~ s{<svg [^>]*viewBox="0 0 ([^ ]+) ([^ ]+)"[^>]*>}{};
+ my($w,$h) = ($1,$2);
+ my $viewbox = $w // die "No suitable viewBox property found in $icons{$id}\n";
+
+ # Identifiers must be globally unique, so need to renumber.
+ my %idmap;
+ $data =~ s{(id="|href="#|url\(#)([^"\)]+)}{ $idmap{$2}||=$idnum++; $1.$idmap{$2} }eg;
+
+ # Take out the <defs> and put them in global scope, otherwise some(?) renderers can't find the definitions.
+ $defs .= $1 if $data =~ s{<defs>(.+)</defs>}{};
+
+ $width += 10;
+ $group .= qq{<g transform="translate($width)">$data</g>};
+ $css .= sprintf ".icon-%s { background-position: %dpx 0; width: %dpx; height: %dpx }\n", $id, -$width, $w, $h;
+
+ $width += $w;
+ $height = $h if $height < $h;
+}
+
+{
+ open my $F, '>', "$GEN/svg.css";
+ print $F $css;
+}
+
+{
+ open my $F, '>', "$GEN/static/icons.svg";
+ print $F qq{<svg xmlns="http://www.w3.org/2000/svg" width="$width" height="$height" viewBox="0 0 $width $height">};
+ print $F qq{<defs>$defs</defs>} if $defs;
+ print $F $group;
+ print $F '</svg>';
+}
diff --git a/util/test/basn4a08.png b/util/test/basn4a08.png
new file mode 100644
index 00000000..3e130522
--- /dev/null
+++ b/util/test/basn4a08.png
Binary files differ
diff --git a/util/test/basn6a16.png b/util/test/basn6a16.png
new file mode 100644
index 00000000..984a9952
--- /dev/null
+++ b/util/test/basn6a16.png
Binary files differ
diff --git a/util/bbcode-test.pl b/util/test/bbcode.pl
index e306c952..94128684 100755
--- a/util/bbcode-test.pl
+++ b/util/test/bbcode.pl
@@ -5,13 +5,10 @@
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 lib 'lib';
use VNDB::BBCode;
@@ -37,11 +34,11 @@ my @tests = (
"`some code\n\nalso newlines;`",
'[spoiler]some spoiler[/spoiler]',
- '<b class="spoiler">some spoiler</b>',
+ '<span class="spoiler">some spoiler</span>',
'',
'[b][i][u][s]Formatting![/s][/u][/i][/b]',
- '<b><em><span class="underline"><s>Formatting!</s></span></em></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]",
@@ -49,11 +46,11 @@ my @tests = (
"[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>',
+ '<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"><b>more <b class="spoiler">nesting [code]mkay?</b></b></div>',
+ '<div class="quote"><strong>more <span class="spoiler">nesting [code]mkay?</span></strong></div>',
'"*more *"',
'[url=/v][b]does not work here[/b][/url]',
@@ -82,7 +79,7 @@ my @tests = (
# 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>',
+ '<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]",
@@ -110,11 +107,11 @@ my @tests = (
'http://192.168.1.1:8080/some/path (literal ipv4 address, port included)',
'[Quote]non-lowercase tags [SpOILER]here[/sPOilER][/qUOTe]',
- '<div class="quote">non-lowercase tags <b class="spoiler">here</b></div>',
+ '<div class="quote">non-lowercase tags <span class="spoiler">here</span></div>',
'"non-lowercase tags "',
'some text [spoiler]with (v17) tags[/spoiler] and internal ids such as s1',
- 'some text <b class="spoiler">with (<a href="/v17">v17</a>) tags</b> and internal ids such as <a href="/s1">s1</a>',
+ 'some text <span class="spoiler">with (<a href="/v17">v17</a>) tags</span> and internal ids such as <a href="/s1">s1</a>',
'some text and internal ids such as s1',
'r12.1 v6.3 s1.2 w5.3',
@@ -146,16 +143,16 @@ my @tests = (
'<tag>html escapes (&)</tag>',
'[spoiler]stray open tag',
- '<b class="spoiler">stray open tag</b>',
+ '<span class="spoiler">stray open tag</span>',
'',
# TODO: This isn't ideal
'[quote][spoiler]stray open tag (nested)[/quote]',
- '<div class="quote"><b class="spoiler">stray open tag (nested)[/quote]</b></div>',
+ '<div class="quote"><span class="spoiler">stray open tag (nested)[/quote]</span></div>',
'""',
'[quote][spoiler]two stray open tags',
- '<div class="quote"><b class="spoiler">two stray open tags</b></div>',
+ '<div class="quote"><span class="spoiler">two stray open tags</span></div>',
'""',
"[url=https://cat.xyz/]that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination[/url]",
diff --git a/util/test/imgproc-custom.pl b/util/test/imgproc-custom.pl
new file mode 100755
index 00000000..3318ad42
--- /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', '76b8bbca1df2184319ec9d7e57250e0b8b7b5c2f';
+
+# 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 019d3c9d..01678f77 100755
--- a/util/unusedimages.pl
+++ b/util/unusedimages.pl
@@ -14,10 +14,13 @@ 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 $count = 0;
-my $fnmatch = '/(cv|ch|sf|st)/[0-9][0-9]/([1-9][0-9]{0,6})\.jpg';
+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(%scr, %cv, %ch);
my %dir = (cv => \%cv, ch => \%ch, sf => \%scr, st => \%scr);
@@ -48,18 +51,20 @@ sub cleandb {
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 "desc" FROM vn
- UNION ALL SELECT "desc" FROM vn_hist
- UNION ALL SELECT "desc" FROM chars
- UNION ALL SELECT "desc" FROM chars_hist
- UNION ALL SELECT "desc" FROM producers
- UNION ALL SELECT "desc" FROM producers_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 "desc" FROM staff
- UNION ALL SELECT "desc" FROM staff_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
@@ -91,11 +96,10 @@ sub findunused {
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";
+ print "# Unknown file: $File::Find::name\n" if $File::Find::name =~ /$dirmatch/;
return;
}
if(!$dir{$2}{$3}) {
@@ -107,7 +111,7 @@ sub findunused {
$left++;
}
}
- }, "$ROOT/static/cv", "$ROOT/static/ch", "$ROOT/static/sf", "$ROOT/static/st";
+ }, "$ENV{VNDB_VAR}/static";
printf "# Deleted %d files, left %d files, saved %d KiB\n", $count, $left, $size;
}
diff --git a/util/saved-queries.pl b/util/updates/2021-01-21-update-saved-queries.pl
index f93d4643..f93d4643 100755
--- a/util/saved-queries.pl
+++ b/util/updates/2021-01-21-update-saved-queries.pl
diff --git a/util/updates/2021-03-07-platforms.sql b/util/updates/2021-03-07-platforms.sql
new file mode 100644
index 00000000..a0d19533
--- /dev/null
+++ b/util/updates/2021-03-07-platforms.sql
@@ -0,0 +1,9 @@
+ALTER TYPE platform ADD VALUE 'tdo' BEFORE 'oth';
+ALTER TYPE platform ADD VALUE 'fm7' BEFORE 'fmt';
+ALTER TYPE platform ADD VALUE 'fm8' BEFORE 'fmt';
+ALTER TYPE platform ADD VALUE 'ps5' BEFORE 'psv';
+ALTER TYPE platform ADD VALUE 'smd' BEFORE 'sat';
+ALTER TYPE platform ADD VALUE 'scd' BEFORE 'sat';
+ALTER TYPE platform ADD VALUE 'x1s' BEFORE 'x68';
+ALTER TYPE platform ADD VALUE 'vnd' AFTER 'n3d';
+ALTER TYPE platform ADD VALUE 'xxs' AFTER 'xbo';
diff --git a/util/updates/2021-03-11-platform-mobile.sql b/util/updates/2021-03-11-platform-mobile.sql
new file mode 100644
index 00000000..cf062b4a
--- /dev/null
+++ b/util/updates/2021-03-11-platform-mobile.sql
@@ -0,0 +1 @@
+ALTER TYPE platform ADD VALUE 'mob' BEFORE 'oth';
diff --git a/util/updates/2021-03-11-tag-history.sql b/util/updates/2021-03-11-tag-history.sql
new file mode 100644
index 00000000..ddbdd674
--- /dev/null
+++ b/util/updates/2021-03-11-tag-history.sql
@@ -0,0 +1,89 @@
+BEGIN;
+
+-- 'deleted' state is now represented as (hidden && locked)
+-- (hidden && !locked) now means 'awaiting moderation'
+UPDATE vn SET locked = true WHERE hidden AND NOT locked;
+UPDATE producers SET locked = true WHERE hidden AND NOT locked;
+UPDATE staff SET locked = true WHERE hidden AND NOT locked;
+UPDATE chars SET locked = true WHERE hidden AND NOT locked;
+UPDATE releases SET locked = true WHERE hidden AND NOT locked;
+UPDATE docs SET locked = true WHERE hidden AND NOT locked;
+UPDATE changes SET ilock = true WHERE ihid AND NOT ilock;
+
+ALTER TABLE tags_aliases DROP CONSTRAINT tags_aliases_tag_fkey;
+ALTER TABLE tags_parents DROP CONSTRAINT tags_parents_tag_fkey;
+ALTER TABLE tags_parents DROP CONSTRAINT tags_parents_parent_fkey;
+ALTER TABLE tags_vn DROP CONSTRAINT tags_vn_tag_fkey;
+
+DROP TRIGGER insert_notify ON tags;
+DROP TRIGGER stats_cache_new ON tags;
+DROP TRIGGER stats_cache_edit ON tags;
+
+-- Move tags_alias into tags as 'alias' column, to be consistent with how aliases are stored for traits.
+-- No real need to enforce uniqueness on aliasses as they're just search helpers.
+ALTER TABLE tags ADD COLUMN alias varchar(500) NOT NULL DEFAULT '';
+UPDATE tags SET alias = COALESCE((SELECT string_agg(alias, E'\n') FROM tags_aliases WHERE tag = tags.id), '');
+DROP TABLE tags_aliases;
+
+ALTER TABLE tags ALTER COLUMN name SET DEFAULT '';
+
+-- State -> hidden,locked
+ALTER TABLE tags ADD COLUMN hidden boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE tags ADD COLUMN locked boolean NOT NULL DEFAULT TRUE;
+UPDATE tags SET hidden = (state <> 2), locked = (state = 1);
+ALTER TABLE tags DROP COLUMN state;
+
+-- id -> vndbid
+ALTER TABLE tags ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE tags ALTER COLUMN id TYPE vndbid USING vndbid('g', id);
+ALTER TABLE tags ALTER COLUMN id SET DEFAULT vndbid('g', nextval('tags_id_seq')::int);
+ALTER TABLE tags ADD CONSTRAINT tags_id_check CHECK(vndbid_type(id) = 'g');
+
+ALTER TABLE tags_parents RENAME COLUMN tag TO id;
+ALTER TABLE tags_parents ALTER COLUMN id TYPE vndbid USING vndbid('g', id);
+ALTER TABLE tags_parents ALTER COLUMN parent TYPE vndbid USING vndbid('g', parent);
+
+
+CREATE TABLE tags_hist (
+ chid integer NOT NULL PRIMARY KEY,
+ cat tag_category NOT NULL DEFAULT 'cont',
+ defaultspoil smallint NOT NULL DEFAULT 0,
+ searchable boolean NOT NULL DEFAULT TRUE,
+ applicable boolean NOT NULL DEFAULT TRUE,
+ name varchar(250) NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT '',
+ alias varchar(500) NOT NULL DEFAULT ''
+);
+
+CREATE TABLE tags_parents_hist (
+ chid integer NOT NULL,
+ parent vndbid NOT NULL,
+ PRIMARY KEY(chid, parent)
+);
+
+ALTER TABLE tags_vn ALTER COLUMN tag TYPE vndbid USING vndbid('g', tag);
+ALTER TABLE tags_vn_inherit ALTER COLUMN tag TYPE vndbid USING vndbid('g', tag);
+
+INSERT INTO changes (requester,itemid,rev,ihid,ilock,comments)
+ SELECT 'u1', id, 1, hidden, locked,
+'Automated import from when the tag database did not keep track of change histories.
+This tag was initially submitted by '||coalesce(nullif(addedby::text, 'u1'), 'an anonymous user')||' on '||added::date||', but has no doubt been updated over time by moderators.'
+ FROM tags;
+
+INSERT INTO tags_hist (chid, cat, defaultspoil, searchable, applicable, name, description, alias)
+ SELECT c.id, t.cat, t.defaultspoil, t.searchable, t.applicable, t.name, t.description, t.alias
+ FROM tags t JOIN changes c ON c.itemid = t.id;
+
+INSERT INTO tags_parents_hist (chid, parent) SELECT c.id, t.parent FROM tags_parents t JOIN changes c ON c.itemid = t.id;
+
+ALTER TABLE tags DROP COLUMN addedby;
+
+
+\i sql/func.sql
+\i sql/editfunc.sql
+
+COMMIT;
+
+\i sql/tableattrs.sql
+\i sql/triggers.sql
+\i sql/perms.sql
diff --git a/util/updates/2021-03-16-release-dlsiteen.sql b/util/updates/2021-03-16-release-dlsiteen.sql
new file mode 100644
index 00000000..a3a65a50
--- /dev/null
+++ b/util/updates/2021-03-16-release-dlsiteen.sql
@@ -0,0 +1,16 @@
+-- Create a temporary copy of the DLsite English shop status information in case we want to revert.
+CREATE TABLE shop_dlsiteen_old AS SELECT * FROM shop_dlsite WHERE id LIKE 'RE%';
+DELETE FROM shop_dlsite WHERE id LIKE 'RE%';
+
+CREATE OR REPLACE FUNCTION migrate_dlsiteen_to_dlsite(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_dlsite = regexp_replace(l_dlsiteen, '^RE', 'RJ');
+ UPDATE edit_revision SET requester = 'u1', ip = '0.0.0.0', comments = 'DLsite English has been merged into the main DLsite, automatically migrating shop link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_dlsiteen_to_dlsite(id) FROM releases
+ WHERE NOT hidden AND l_dlsite = '' AND l_dlsiteen <> ''
+ AND NOT EXISTS(SELECT 1 FROM shop_dlsite WHERE id = l_dlsiteen AND deadsince < NOW()-'7 days'::interval);
+DROP FUNCTION migrate_dlsiteen_to_dlsite(vndbid);
diff --git a/util/updates/2021-03-23-trait-history.sql b/util/updates/2021-03-23-trait-history.sql
new file mode 100644
index 00000000..a940799f
--- /dev/null
+++ b/util/updates/2021-03-23-trait-history.sql
@@ -0,0 +1,74 @@
+BEGIN;
+
+ALTER TABLE chars_traits DROP CONSTRAINT chars_traits_tid_fkey;
+ALTER TABLE chars_traits_hist DROP CONSTRAINT chars_traits_hist_tid_fkey;
+ALTER TABLE traits DROP CONSTRAINT traits_group_fkey;
+ALTER TABLE traits_parents DROP CONSTRAINT traits_parents_trait_fkey;
+ALTER TABLE traits_parents DROP CONSTRAINT traits_parents_parent_fkey;
+
+DROP TRIGGER insert_notify ON traits;
+DROP TRIGGER stats_cache_new ON traits;
+DROP TRIGGER stats_cache_edit ON traits;
+
+ALTER TABLE traits ADD COLUMN hidden boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE traits ADD COLUMN locked boolean NOT NULL DEFAULT TRUE;
+UPDATE traits SET hidden = (state <> 2), locked = (state = 1);
+ALTER TABLE traits DROP COLUMN state;
+
+ALTER TABLE traits ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE traits ALTER COLUMN id TYPE vndbid USING vndbid('i', id);
+ALTER TABLE traits ALTER COLUMN id SET DEFAULT vndbid('i', nextval('traits_id_seq')::int);
+ALTER TABLE traits ADD CONSTRAINT traits_id_check CHECK(vndbid_type(id) = 'i');
+
+ALTER TABLE traits ALTER COLUMN "group" TYPE vndbid USING vndbid('i', "group");
+ALTER TABLE traits ALTER COLUMN name SET DEFAULT '';
+
+ALTER TABLE traits_parents RENAME COLUMN trait TO id;
+ALTER TABLE traits_parents ALTER COLUMN id TYPE vndbid USING vndbid('i', id);
+ALTER TABLE traits_parents ALTER COLUMN parent TYPE vndbid USING vndbid('i', parent);
+
+ALTER TABLE traits_chars ALTER COLUMN tid TYPE vndbid USING vndbid('i', tid);
+ALTER TABLE chars_traits ALTER COLUMN tid TYPE vndbid USING vndbid('i', tid);
+ALTER TABLE chars_traits_hist ALTER COLUMN tid TYPE vndbid USING vndbid('i', tid);
+
+CREATE TABLE traits_hist (
+ chid integer NOT NULL,
+ "order" smallint NOT NULL DEFAULT 0,
+ defaultspoil smallint NOT NULL DEFAULT 0,
+ sexual boolean NOT NULL DEFAULT false,
+ searchable boolean NOT NULL DEFAULT true,
+ applicable boolean NOT NULL DEFAULT true,
+ name varchar(250) NOT NULL DEFAULT '',
+ alias varchar(500) NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT ''
+);
+
+CREATE TABLE traits_parents_hist (
+ chid integer NOT NULL,
+ parent vndbid NOT NULL,
+ PRIMARY KEY(chid, parent)
+);
+
+
+INSERT INTO changes (requester,itemid,rev,ihid,ilock,comments)
+ SELECT 'u1', id, 1, hidden, locked,
+'Automated import from when the trait database did not keep track of change histories.
+This trait was initially submitted by '||coalesce(nullif(addedby::text, 'u1'), 'an anonymous user')||' on '||added::date||', but has no doubt been updated over time by moderators.'
+ FROM traits;
+
+INSERT INTO traits_hist (chid, "order", defaultspoil, sexual, searchable, applicable, name, description, alias)
+ SELECT c.id, t."order", t.defaultspoil, t.sexual, t.searchable, t.applicable, t.name, t.description, t.alias
+ FROM traits t JOIN changes c ON c.itemid = t.id;
+
+INSERT INTO traits_parents_hist (chid, parent) SELECT c.id, t.parent FROM traits_parents t JOIN changes c ON c.itemid = t.id;
+
+ALTER TABLE traits DROP COLUMN addedby;
+
+\i sql/func.sql
+\i sql/editfunc.sql
+
+COMMIT;
+
+\i sql/tableattrs.sql
+\i sql/triggers.sql
+\i sql/perms.sql
diff --git a/util/updates/2021-04-09-item-info.sql b/util/updates/2021-04-09-item-info.sql
new file mode 100644
index 00000000..22728127
--- /dev/null
+++ b/util/updates/2021-04-09-item-info.sql
@@ -0,0 +1,2 @@
+DROP FUNCTION item_info(vndbid,int);
+\i sql/func.sql
diff --git a/util/updates/2021-05-05-latin-language.sql b/util/updates/2021-05-05-latin-language.sql
new file mode 100644
index 00000000..7612430e
--- /dev/null
+++ b/util/updates/2021-05-05-latin-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'la' AFTER 'ms';
diff --git a/util/updates/2021-05-14-releases-lang-mtl.sql b/util/updates/2021-05-14-releases-lang-mtl.sql
new file mode 100644
index 00000000..43723117
--- /dev/null
+++ b/util/updates/2021-05-14-releases-lang-mtl.sql
@@ -0,0 +1,4 @@
+ALTER TABLE releases_lang ADD COLUMN mtl boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE releases_lang_hist ADD COLUMN mtl boolean NOT NULL DEFAULT FALSE;
+\i sql/editfunc.sql
+\i sql/func.sql
diff --git a/util/updates/2021-05-21-tt-primary-parent.sql b/util/updates/2021-05-21-tt-primary-parent.sql
new file mode 100644
index 00000000..00f513a4
--- /dev/null
+++ b/util/updates/2021-05-21-tt-primary-parent.sql
@@ -0,0 +1,17 @@
+ALTER TABLE tags_parents ADD COLUMN main boolean NOT NULL DEFAULT false;
+ALTER TABLE tags_parents_hist ADD COLUMN main boolean NOT NULL DEFAULT false;
+ALTER TABLE traits_parents ADD COLUMN main boolean NOT NULL DEFAULT false;
+ALTER TABLE traits_parents_hist ADD COLUMN main boolean NOT NULL DEFAULT false;
+\i sql/editfunc.sql
+
+UPDATE tags_parents tp SET main = true WHERE NOT EXISTS(SELECT 1 FROM tags_parents tp2 WHERE tp2.id = tp.id AND tp2.parent < tp.parent);
+UPDATE tags_parents_hist tp SET main = true WHERE NOT EXISTS(SELECT 1 FROM tags_parents_hist tp2 WHERE tp2.chid = tp.chid AND tp2.parent < tp.parent);
+UPDATE traits_parents tp SET main = true WHERE NOT EXISTS(SELECT 1 FROM traits_parents tp2 WHERE tp2.id = tp.id AND tp2.parent < tp.parent);
+UPDATE traits_parents_hist tp SET main = true WHERE NOT EXISTS(SELECT 1 FROM traits_parents_hist tp2 WHERE tp2.chid = tp.chid AND tp2.parent < tp.parent);
+
+-- Update the traits.group cache for consistency with the above selected 'main' flags.
+WITH RECURSIVE childs (id, grp) AS (
+ SELECT id, id FROM traits t WHERE NOT EXISTS(SELECT 1 FROM traits_parents tp WHERE tp.id = t.id)
+ UNION ALL
+ SELECT tp.id, childs.grp FROM childs JOIN traits_parents tp ON tp.parent = childs.id AND tp.main
+) UPDATE traits SET "group" = grp FROM childs WHERE childs.id = traits.id AND "group" IS DISTINCT FROM grp AND grp <> childs.id;
diff --git a/util/updates/2021-05-25-users-shadow.sql b/util/updates/2021-05-25-users-shadow.sql
new file mode 100644
index 00000000..bf48d0af
--- /dev/null
+++ b/util/updates/2021-05-25-users-shadow.sql
@@ -0,0 +1,19 @@
+CREATE TABLE users_shadow (
+ id vndbid NOT NULL PRIMARY KEY,
+ perm_usermod boolean NOT NULL DEFAULT false,
+ mail varchar(100) NOT NULL,
+ passwd bytea NOT NULL DEFAULT ''
+);
+
+BEGIN;
+INSERT INTO users_shadow SELECT id, perm_usermod, mail, passwd FROM users;
+
+ALTER TABLE users_shadow ADD CONSTRAINT users_shadow_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+
+ALTER TABLE users DROP COLUMN perm_usermod;
+ALTER TABLE users DROP COLUMN mail;
+ALTER TABLE users DROP COLUMN passwd;
+COMMIT;
+
+\i sql/perms.sql
+\i sql/func.sql
diff --git a/util/updates/2021-05-25-users-vnlang.sql b/util/updates/2021-05-25-users-vnlang.sql
new file mode 100644
index 00000000..d480c60a
--- /dev/null
+++ b/util/updates/2021-05-25-users-vnlang.sql
@@ -0,0 +1 @@
+ALTER TABLE users ADD COLUMN vnlang jsonb;
diff --git a/util/updates/2021-06-04-vn-developers-and-average-cache.sql b/util/updates/2021-06-04-vn-developers-and-average-cache.sql
new file mode 100644
index 00000000..4fc6a510
--- /dev/null
+++ b/util/updates/2021-06-04-vn-developers-and-average-cache.sql
@@ -0,0 +1,11 @@
+ALTER TABLE users ADD COLUMN tableopts_v integer;
+ALTER TABLE users ADD COLUMN tableopts_vt integer;
+
+ALTER TABLE vn ADD COLUMN c_developers vndbid[] NOT NULL DEFAULT '{}';
+ALTER TABLE vn ADD COLUMN c_average smallint;
+ALTER TABLE vn ALTER COLUMN c_popularity TYPE smallint USING c_popularity*10000;
+ALTER TABLE vn ALTER COLUMN c_rating TYPE smallint USING c_rating*10;
+\i sql/func.sql
+\timing
+SELECT count(*) FROM (SELECT update_vncache(id) FROM vn) x;
+SELECT update_vnvotestats();
diff --git a/util/updates/2021-06-22-indi-urdu-languages.sql b/util/updates/2021-06-22-indi-urdu-languages.sql
new file mode 100644
index 00000000..9de77172
--- /dev/null
+++ b/util/updates/2021-06-22-indi-urdu-languages.sql
@@ -0,0 +1,2 @@
+ALTER TYPE language ADD VALUE 'hi' AFTER 'he';
+ALTER TYPE language ADD VALUE 'ur' AFTER 'uk';
diff --git a/util/updates/2021-06-28-lockdown-mode.sql b/util/updates/2021-06-28-lockdown-mode.sql
new file mode 100644
index 00000000..d0b51cbe
--- /dev/null
+++ b/util/updates/2021-06-28-lockdown-mode.sql
@@ -0,0 +1,13 @@
+CREATE TABLE global_settings (
+ -- Only permit a single row in this table
+ id boolean NOT NULL PRIMARY KEY DEFAULT FALSE CONSTRAINT global_settings_single_row CHECK(id),
+ -- locks down any DB edits, including image voting and tagging
+ lockdown_edit boolean NOT NULL DEFAULT FALSE,
+ -- locks down any forum & review posting
+ lockdown_board boolean NOT NULL DEFAULT FALSE,
+ lockdown_registration boolean NOT NULL DEFAULT FALSE
+);
+
+INSERT INTO global_settings (id) VALUES (TRUE);
+
+\i sql/perms.sql
diff --git a/util/updates/2021-07-24-more-wikidata-ids.sql b/util/updates/2021-07-24-more-wikidata-ids.sql
new file mode 100644
index 00000000..e5e80359
--- /dev/null
+++ b/util/updates/2021-07-24-more-wikidata-ids.sql
@@ -0,0 +1,3 @@
+ALTER TABLE wikidata ADD COLUMN soundcloud text[];
+ALTER TABLE wikidata ADD COLUMN humblestore text[];
+ALTER TABLE wikidata ADD COLUMN itchio text[];
diff --git a/util/updates/2021-07-28-merge-imgmod.sql b/util/updates/2021-07-28-merge-imgmod.sql
new file mode 100644
index 00000000..eab67c41
--- /dev/null
+++ b/util/updates/2021-07-28-merge-imgmod.sql
@@ -0,0 +1,2 @@
+-- imgmod permissions merged into dbmod, no need to separate these.
+ALTER TABLE users DROP COLUMN perm_imgmod;
diff --git a/util/updates/2021-07-30-vn-length-voting.sql b/util/updates/2021-07-30-vn-length-voting.sql
new file mode 100644
index 00000000..48dedb52
--- /dev/null
+++ b/util/updates/2021-07-30-vn-length-voting.sql
@@ -0,0 +1,17 @@
+CREATE TABLE vn_length_votes (
+ vid vndbid NOT NULL,
+ rid vndbid NOT NULL,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ uid vndbid,
+ length smallint NOT NULL, -- minutes
+ notes text NOT NULL DEFAULT ''
+);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+CREATE UNIQUE INDEX vn_length_votes_pkey ON vn_length_votes (vid, uid);
+
+-- DEFAULT false while it's in development.
+ALTER TABLE users ADD COLUMN perm_lengthvote boolean NOT NULL DEFAULT false;
+
+\i sql/perms.sql
diff --git a/util/updates/2021-08-03-vnlength-speed.sql b/util/updates/2021-08-03-vnlength-speed.sql
new file mode 100644
index 00000000..f2809a59
--- /dev/null
+++ b/util/updates/2021-08-03-vnlength-speed.sql
@@ -0,0 +1,6 @@
+ALTER TABLE vn_length_votes ADD COLUMN speed smallint NOT NULL DEFAULT 0;
+ALTER TABLE vn_length_votes ALTER COLUMN speed DROP DEFAULT;
+ALTER TABLE vn_length_votes ADD COLUMN notes2 text NOT NULL DEFAULT '';
+UPDATE vn_length_votes SET notes2 = notes;
+ALTER TABLE vn_length_votes DROP COLUMN notes;
+ALTER TABLE vn_length_votes RENAME COLUMN notes2 TO notes;
diff --git a/util/updates/2021-08-04-vnlength-index.sql b/util/updates/2021-08-04-vnlength-index.sql
new file mode 100644
index 00000000..f9e93d01
--- /dev/null
+++ b/util/updates/2021-08-04-vnlength-index.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users ALTER COLUMN perm_lengthvote SET DEFAULT true;
+CREATE INDEX vn_length_votes_uid ON vn_length_votes (uid);
diff --git a/util/updates/2021-08-08-lengthvote-ignore.sql b/util/updates/2021-08-08-lengthvote-ignore.sql
new file mode 100644
index 00000000..594961d7
--- /dev/null
+++ b/util/updates/2021-08-08-lengthvote-ignore.sql
@@ -0,0 +1 @@
+ALTER TABLE vn_length_votes ADD COLUMN ignore boolean NOT NULL DEFAULT false;
diff --git a/util/updates/2021-08-09-vnlength-multirelease.sql b/util/updates/2021-08-09-vnlength-multirelease.sql
new file mode 100644
index 00000000..e5917f34
--- /dev/null
+++ b/util/updates/2021-08-09-vnlength-multirelease.sql
@@ -0,0 +1,4 @@
+ALTER TABLE vn_length_votes ADD COLUMN rid2 vndbid[] NOT NULL DEFAULT '{}';
+UPDATE vn_length_votes SET rid2 = ARRAY[rid];
+ALTER TABLE vn_length_votes DROP COLUMN rid;
+ALTER TABLE vn_length_votes RENAME COLUMN rid2 TO rid;
diff --git a/util/updates/2021-08-09b-vnlength-primarykey.sql b/util/updates/2021-08-09b-vnlength-primarykey.sql
new file mode 100644
index 00000000..5bb1df32
--- /dev/null
+++ b/util/updates/2021-08-09b-vnlength-primarykey.sql
@@ -0,0 +1,28 @@
+-- Recreate the vn_length_votes table to cleanly add a primary key and for more efficient storage.
+-- The table layout had gotten messy with all the recent edits.
+BEGIN;
+DROP INDEX vn_length_votes_pkey;
+DROP INDEX vn_length_votes_uid;
+ALTER TABLE vn_length_votes RENAME TO vn_length_votes_tmp;
+
+CREATE TABLE vn_length_votes (
+ id SERIAL PRIMARY KEY,
+ vid vndbid NOT NULL, -- [pub]
+ date timestamptz NOT NULL DEFAULT NOW(), -- [pub]
+ length smallint NOT NULL, -- [pub] minutes
+ speed smallint NOT NULL, -- [pub] 0=slow, 1=normal, 2=fast
+ uid vndbid, -- [pub]
+ ignore boolean NOT NULL DEFAULT false, -- [pub]
+ rid vndbid[] NOT NULL, -- [pub]
+ notes text NOT NULL DEFAULT '' -- [pub]
+);
+
+INSERT INTO vn_length_votes (vid,date,uid,length,speed,ignore,rid,notes)
+ SELECT vid,date,uid,length,speed,ignore,rid,notes FROM vn_length_votes_tmp;
+
+CREATE UNIQUE INDEX vn_length_votes_vid_uid ON vn_length_votes (vid, uid);
+CREATE INDEX vn_length_votes_uid ON vn_length_votes (uid);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE vn_length_votes ADD CONSTRAINT vn_length_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+COMMIT;
+\i sql/perms.sql
diff --git a/util/updates/2021-09-02-some-foreign-key-stuff.sql b/util/updates/2021-09-02-some-foreign-key-stuff.sql
new file mode 100644
index 00000000..09abff70
--- /dev/null
+++ b/util/updates/2021-09-02-some-foreign-key-stuff.sql
@@ -0,0 +1,5 @@
+-- Add an ON UPDATE CASCADE clause to these contraints to simplify moving lists across users or VNs.
+ALTER TABLE ulist_vns_labels DROP CONSTRAINT ulist_vns_labels_uid_lbl_fkey;
+ALTER TABLE ulist_vns_labels DROP CONSTRAINT ulist_vns_labels_uid_vid_fkey;
+ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_uid_lbl_fkey FOREIGN KEY (uid,lbl) REFERENCES ulist_labels (uid,id) ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE ulist_vns_labels ADD CONSTRAINT ulist_vns_labels_uid_vid_fkey FOREIGN KEY (uid,vid) REFERENCES ulist_vns (uid,vid) ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/util/updates/2021-09-26-vn-length-cache.sql b/util/updates/2021-09-26-vn-length-cache.sql
new file mode 100644
index 00000000..40dfa0a0
--- /dev/null
+++ b/util/updates/2021-09-26-vn-length-cache.sql
@@ -0,0 +1,6 @@
+ALTER TABLE vn ADD COLUMN c_length smallint;
+ALTER TABLE vn ADD COLUMN c_lengthnum smallint NOT NULL DEFAULT 0;
+
+\i sql/func.sql
+\i sql/triggers.sql
+select update_vn_length_cache(null);
diff --git a/util/updates/2021-10-27-freegame-mugen.sql b/util/updates/2021-10-27-freegame-mugen.sql
new file mode 100644
index 00000000..cc3f487b
--- /dev/null
+++ b/util/updates/2021-10-27-freegame-mugen.sql
@@ -0,0 +1,3 @@
+ALTER TABLE releases ADD COLUMN l_freegame text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist ADD COLUMN l_freegame text NOT NULL DEFAULT '';
+\i sql/editfunc.sql
diff --git a/util/updates/2021-10-28-username-casefold.sql b/util/updates/2021-10-28-username-casefold.sql
new file mode 100644
index 00000000..88bc1238
--- /dev/null
+++ b/util/updates/2021-10-28-username-casefold.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users DROP CONSTRAINT users_username_key;
+CREATE UNIQUE INDEX users_username_key ON users (lower(username));
diff --git a/util/updates/2021-10-28-username-history.sql b/util/updates/2021-10-28-username-history.sql
new file mode 100644
index 00000000..ac703fc8
--- /dev/null
+++ b/util/updates/2021-10-28-username-history.sql
@@ -0,0 +1,16 @@
+CREATE TABLE users_username_hist (
+ id vndbid NOT NULL,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ old text NOT NULL,
+ new text NOT NULL,
+ PRIMARY KEY(id, date)
+);
+ALTER TABLE users_username_hist ADD CONSTRAINT users_username_hist_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+\i sql/perms.sql
+
+INSERT INTO users_username_hist (id, date, old, new)
+ SELECT affected_uid, date
+ , regexp_replace(detail, 'username: "([^"]+)" -> "([^"]+)"', '\1', '') AS old
+ , regexp_replace(detail, 'username: "([^"]+)" -> "([^"]+)"', '\2', '') AS new
+ FROM audit_log
+ WHERE detail ~ 'username: "([^"]+)" -> "([^"]+)"' AND EXISTS(SELECT 1 FROM users WHERE id = affected_uid);
diff --git a/util/updates/2021-10-28-website-length.sql b/util/updates/2021-10-28-website-length.sql
new file mode 100644
index 00000000..a666e05f
--- /dev/null
+++ b/util/updates/2021-10-28-website-length.sql
@@ -0,0 +1,4 @@
+ALTER TABLE producers ALTER COLUMN website TYPE varchar(1024);
+ALTER TABLE producers_hist ALTER COLUMN website TYPE varchar(1024);
+ALTER TABLE releases ALTER COLUMN website TYPE varchar(1024);
+ALTER TABLE releases_hist ALTER COLUMN website TYPE varchar(1024);
diff --git a/util/updates/2021-10-29-fix-thumbnail-resolution.pl b/util/updates/2021-10-29-fix-thumbnail-resolution.pl
new file mode 100755
index 00000000..8da530f7
--- /dev/null
+++ b/util/updates/2021-10-29-fix-thumbnail-resolution.pl
@@ -0,0 +1,50 @@
+#!/usr/bin/perl
+
+use v5.26;
+use warnings;
+use Cwd 'abs_path';
+use lib ((abs_path $0) =~ s{/\Q$0\E$}{}r).'/lib';
+
+use VNDB::Func 'imgsize', 'imgpath';
+use VNDB::Config;
+use VNWeb::DB;
+use TUWF;
+
+TUWF::set %{ config->{tuwf} };
+
+sub jpgsize {
+ my($f) = @_;
+ my $id = config->{identify_path};
+ return split 'x', `$id -format "%wx%h" "$f"`;
+
+ use bytes;
+ open my $F, '<', $f or die "$f: $!";
+ die "$f: $!" if 1 > read $F, my $buf, 16*1024;
+ die "$f: Not a JPEG\n" if $buf !~ /\xFF[\xC0\xC2]...(....)/s;
+ my($h,$w) = unpack 'nn', $1;
+ return ($w,$h);
+}
+
+for (tuwf->dbAlli('SELECT id, width, height FROM images WHERE id BETWEEN \'sf1\' AND vndbid_max(\'sf\')')->@*) {
+ my $fullpath = imgpath $_->{id};
+ my $thumbpath = imgpath $_->{id}, 1;
+ next if !$_->{width} || !-s $fullpath;
+ my ($thumbw, $thumbh) = imgsize $_->{width}, $_->{height}, config->{scr_size}->@*;
+ my ($filew, $fileh) = jpgsize $thumbpath;
+ if($filew != $thumbw || $fileh != $thumbh) {
+ warn "$thumbpath: dimensions don't match, recreating; file=${filew}x$fileh expected=${thumbw}x$thumbh\n";
+ my $conv = config->{convert_path};
+ my $resize = config->{scr_size}[0].'x'.config->{scr_size}[1].'>';
+ unlink 'tmpimg.jpg';
+ my ($neww, $newh) = split /x/, `$conv "$fullpath" -strip -quality 90 -resize "$resize" -unsharp 0x0.75+0.75+0.008 -print %wx%h tmpimg.jpg`;
+ if(!$neww || !$newh) {
+ warn "$thumbpath: unable to write new image\n";
+ next;
+ }
+ if($neww != $thumbw || $newh != $thumbh) {
+ warn "$thumbpath: new thumbnail doesn't match expected dimensions, got ${neww}x$newh instead.\n";
+ next;
+ }
+ rename 'tmpimg.jpg', $thumbpath;
+ }
+}
diff --git a/util/updates/2021-11-07-posts-hidden-msg.sql b/util/updates/2021-11-07-posts-hidden-msg.sql
new file mode 100644
index 00000000..878ae0ab
--- /dev/null
+++ b/util/updates/2021-11-07-posts-hidden-msg.sql
@@ -0,0 +1,17 @@
+BEGIN;
+ALTER TABLE threads_posts
+ DROP CONSTRAINT threads_posts_first_nonhidden,
+ ALTER COLUMN hidden DROP NOT NULL,
+ ALTER COLUMN hidden DROP DEFAULT,
+ ALTER COLUMN hidden TYPE text USING case when hidden then '' else null end,
+ ADD CONSTRAINT threads_posts_first_nonhidden CHECK(num > 1 OR hidden IS NULL);
+
+ALTER TABLE reviews_posts
+ ALTER COLUMN hidden DROP NOT NULL,
+ ALTER COLUMN hidden DROP DEFAULT,
+ ALTER COLUMN hidden TYPE text USING case when hidden then '' else null end;
+
+\i sql/func.sql
+COMMIT;
+
+\i sql/triggers.sql
diff --git a/util/updates/2021-11-07-threads-board-lock.sql b/util/updates/2021-11-07-threads-board-lock.sql
new file mode 100644
index 00000000..6b25a187
--- /dev/null
+++ b/util/updates/2021-11-07-threads-board-lock.sql
@@ -0,0 +1 @@
+ALTER TABLE threads ADD COLUMN boards_locked boolean NOT NULL DEFAULT FALSE;
diff --git a/util/updates/2021-11-15-release-vn-type.sql b/util/updates/2021-11-15-release-vn-type.sql
new file mode 100644
index 00000000..54916086
--- /dev/null
+++ b/util/updates/2021-11-15-release-vn-type.sql
@@ -0,0 +1,12 @@
+BEGIN;
+ALTER TABLE releases_vn ADD COLUMN rtype release_type NOT NULL DEFAULT 'complete';
+ALTER TABLE releases_vn_hist ADD COLUMN rtype release_type NOT NULL DEFAULT 'complete';
+ALTER TABLE releases_vn ALTER COLUMN rtype DROP DEFAULT;
+ALTER TABLE releases_vn_hist ALTER COLUMN rtype DROP DEFAULT;
+UPDATE releases_vn SET rtype = type FROM releases r WHERE r.id = releases_vn.id;
+UPDATE releases_vn_hist SET rtype = type FROM releases_hist r WHERE r.chid = releases_vn_hist.chid;
+ALTER TABLE releases DROP COLUMN type;
+ALTER TABLE releases_hist DROP COLUMN type;
+\i sql/editfunc.sql
+\i sql/func.sql
+COMMIT;
diff --git a/util/updates/2021-11-15-reviews-fulltext-search.sql b/util/updates/2021-11-15-reviews-fulltext-search.sql
new file mode 100644
index 00000000..c6f60211
--- /dev/null
+++ b/util/updates/2021-11-15-reviews-fulltext-search.sql
@@ -0,0 +1,2 @@
+CREATE INDEX reviews_ts ON reviews USING gin(bb_tsvector(text));
+CREATE INDEX reviews_posts_ts ON reviews_posts USING gin(bb_tsvector(msg));
diff --git a/util/updates/2021-11-18-release-search.sql b/util/updates/2021-11-18-release-search.sql
new file mode 100644
index 00000000..9627a188
--- /dev/null
+++ b/util/updates/2021-11-18-release-search.sql
@@ -0,0 +1,3 @@
+CREATE EXTENSION unaccent;
+\i sql/func.sql
+ALTER TABLE releases ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(hidden, ARRAY[title, original])) STORED;
diff --git a/util/updates/2021-11-19-more-search.sql b/util/updates/2021-11-19-more-search.sql
new file mode 100644
index 00000000..5b6a99b0
--- /dev/null
+++ b/util/updates/2021-11-19-more-search.sql
@@ -0,0 +1,9 @@
+BEGIN;
+\i sql/func.sql
+ALTER TABLE releases DROP COLUMN c_search;
+DROP FUNCTION search_gen(boolean,text[]);
+ALTER TABLE releases ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[title, original])) STORED;
+ALTER TABLE producers ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name, original]::text[]||string_to_array(alias,E'\n'))) STORED;
+ALTER TABLE chars ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name, original]::text[]||string_to_array(alias,E'\n'))) STORED;
+ALTER TABLE staff_alias ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name, original])) STORED;
+COMMIT;
diff --git a/util/updates/2021-11-19-vn-search.sql b/util/updates/2021-11-19-vn-search.sql
new file mode 100644
index 00000000..56ce6661
--- /dev/null
+++ b/util/updates/2021-11-19-vn-search.sql
@@ -0,0 +1,7 @@
+DROP TRIGGER vn_vnsearch_notify ON vn;
+DROP FUNCTION vn_vnsearch_notify();
+\i sql/func.sql
+
+-- Warning: slow
+\timing
+UPDATE vn SET c_search = search_gen_vn(id);
diff --git a/util/updates/2021-11-24-tagtrait-search.sql b/util/updates/2021-11-24-tagtrait-search.sql
new file mode 100644
index 00000000..7e4aaf50
--- /dev/null
+++ b/util/updates/2021-11-24-tagtrait-search.sql
@@ -0,0 +1,2 @@
+ALTER TABLE tags ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name]::text[]||string_to_array(alias,E'\n'))) STORED;
+ALTER TABLE traits ADD COLUMN c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name]::text[]||string_to_array(alias,E'\n'))) STORED;
diff --git a/util/updates/2021-11-29-release-unknown-uncensored.sql b/util/updates/2021-11-29-release-unknown-uncensored.sql
new file mode 100644
index 00000000..a3db3873
--- /dev/null
+++ b/util/updates/2021-11-29-release-unknown-uncensored.sql
@@ -0,0 +1,5 @@
+ALTER TABLE releases ALTER COLUMN uncensored DROP NOT NULL, ALTER COLUMN uncensored DROP DEFAULT;
+ALTER TABLE releases_hist ALTER COLUMN uncensored DROP NOT NULL, ALTER COLUMN uncensored DROP DEFAULT;
+\i sql/editfunc.sql
+UPDATE releases SET uncensored = NULL WHERE minage <> 18;
+UPDATE releases_hist SET uncensored = NULL WHERE minage <> 18;
diff --git a/util/updates/2021-12-06-extlinks-playstation-stores.sql b/util/updates/2021-12-06-extlinks-playstation-stores.sql
new file mode 100644
index 00000000..648fb74d
--- /dev/null
+++ b/util/updates/2021-12-06-extlinks-playstation-stores.sql
@@ -0,0 +1,13 @@
+ALTER TABLE releases
+ ADD COLUMN l_playstation_jp text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_na text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_eu text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist
+ ADD COLUMN l_playstation_jp text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_na text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_eu text NOT NULL DEFAULT '';
+ALTER TABLE wikidata
+ ADD COLUMN playstation_jp text[],
+ ADD COLUMN playstation_na text[],
+ ADD COLUMN playstation_eu text[];
+\i sql/editfunc.sql
diff --git a/util/updates/2021-12-15-api-sessions.sql b/util/updates/2021-12-15-api-sessions.sql
new file mode 100644
index 00000000..005fdb52
--- /dev/null
+++ b/util/updates/2021-12-15-api-sessions.sql
@@ -0,0 +1,3 @@
+ALTER TYPE session_type ADD VALUE 'api';
+DROP FUNCTION user_login(vndbid, bytea, bytea);
+\i sql/func.sql
diff --git a/util/updates/2022-02-05-popularity-non-null.sql b/util/updates/2022-02-05-popularity-non-null.sql
new file mode 100644
index 00000000..238d7867
--- /dev/null
+++ b/util/updates/2022-02-05-popularity-non-null.sql
@@ -0,0 +1,7 @@
+\i sql/func.sql
+SELECT update_vnvotestats();
+ALTER TABLE vn
+ ALTER COLUMN c_popularity SET NOT NULL,
+ ALTER COLUMN c_pop_rank SET NOT NULL,
+ ALTER COLUMN c_popularity SET DEFAULT 0,
+ ALTER COLUMN c_pop_rank SET DEFAULT 0;
diff --git a/util/updates/2022-02-11-vn-titles.sql b/util/updates/2022-02-11-vn-titles.sql
new file mode 100644
index 00000000..9332c2c4
--- /dev/null
+++ b/util/updates/2022-02-11-vn-titles.sql
@@ -0,0 +1,41 @@
+BEGIN;
+
+CREATE TABLE vn_titles (
+ id vndbid NOT NULL,
+ lang language NOT NULL,
+ title text NOT NULL,
+ latin text,
+ official boolean NOT NULL,
+ PRIMARY KEY(id, lang)
+);
+
+CREATE TABLE vn_titles_hist (
+ chid integer NOT NULL,
+ lang language NOT NULL,
+ title text NOT NULL,
+ latin text,
+ official boolean NOT NULL,
+ PRIMARY KEY(chid, lang)
+);
+
+INSERT INTO vn_titles SELECT id, olang, CASE WHEN original = '' THEN title ELSE original END, CASE WHEN original = '' THEN NULL ELSE title END, true FROM vn;
+INSERT INTO vn_titles_hist SELECT chid, olang, CASE WHEN original = '' THEN title ELSE original END, CASE WHEN original = '' THEN NULL ELSE title END, true FROM vn_hist;
+
+ALTER TABLE vn_titles ADD CONSTRAINT vn_titles_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
+ALTER TABLE vn_titles_hist ADD CONSTRAINT vn_titles_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE vn ADD CONSTRAINT vn_olang_fkey FOREIGN KEY (id,olang) REFERENCES vn_titles (id,lang) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE vn_hist ADD CONSTRAINT vn_hist_olang_fkey FOREIGN KEY (chid,olang)REFERENCES vn_titles_hist(chid,lang) DEFERRABLE INITIALLY DEFERRED;
+
+ALTER TABLE vn DROP COLUMN original
+ALTER TABLE vn DROP COLUMN title;
+ALTER TABLE vn_hist DROP COLUMN original
+ALTER TABLE vn_hist DROP COLUMN title;
+
+CREATE VIEW vnt AS SELECT v.*, COALESCE(vo.latin, vo.title) AS title, CASE WHEN vo.latin IS NULL THEN '' ELSE vo.title END AS alttitle FROM vn v JOIN vn_titles vo ON vo.id = v.id AND vo.lang = v.olang;
+
+ALTER TABLE users ADD COLUMN title_langs jsonb, ADD COLUMN alttitle_langs jsonb;
+
+COMMIT;
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-02-12-chinese-languages.sql b/util/updates/2022-02-12-chinese-languages.sql
new file mode 100644
index 00000000..330d9224
--- /dev/null
+++ b/util/updates/2022-02-12-chinese-languages.sql
@@ -0,0 +1,30 @@
+ALTER TYPE language ADD VALUE 'zh-Hans' AFTER 'zh';
+ALTER TYPE language ADD VALUE 'zh-Hant' AFTER 'zh-Hans';
+
+
+CREATE OR REPLACE FUNCTION migrate_notes_to_lang(rid vndbid, rlang language) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases_lang SET lang = rlang WHERE lang = 'zh';
+ UPDATE edit_releases SET notes = regexp_replace(notes, '\s*(Simplified|Traditional) Chinese\.?\s*', '', 'i');
+ UPDATE edit_revision SET requester = 'u1', ip = '0.0.0.0', comments = 'Automatic extraction of Chinese language from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+
+SELECT COUNT(*) FROM (SELECT migrate_notes_to_lang(id, 'zh-Hans')
+--SELECT 'http://whatever.blicky.net/'||r.id, regexp_replace(r.notes, '\s*Simplified Chinese\.?\s*', '', 'i')
+ FROM releases r WHERE NOT hidden
+ AND EXISTS(SELECT 1 FROM releases_lang rl WHERE rl.id = r.id AND rl.lang = 'zh')
+ AND NOT EXISTS(SELECT 1 FROM releases_lang rl WHERE rl.id = r.id AND rl.lang IN('zh-Hans', 'zh-Hant'))
+ AND notes ~* '(^|\n)Simplified Chinese(\.|\n|$)'
+) x;
+
+SELECT COUNT(*) FROM (SELECT migrate_notes_to_lang(id, 'zh-Hant')
+ FROM releases r WHERE NOT hidden
+ AND EXISTS(SELECT 1 FROM releases_lang rl WHERE rl.id = r.id AND rl.lang = 'zh')
+ AND NOT EXISTS(SELECT 1 FROM releases_lang rl WHERE rl.id = r.id AND rl.lang IN('zh-Hans', 'zh-Hant'))
+ AND notes ~* '(^|\n)Traditional Chinese(\.|\n|$)'
+) x;
+
+DROP FUNCTION migrate_notes_to_lang(vndbid, language);
diff --git a/util/updates/2022-02-19-vnt-sorttitle.sql b/util/updates/2022-02-19-vnt-sorttitle.sql
new file mode 100644
index 00000000..189b19fe
--- /dev/null
+++ b/util/updates/2022-02-19-vnt-sorttitle.sql
@@ -0,0 +1,3 @@
+DROP VIEW vnt;
+CREATE VIEW vnt AS SELECT v.*, COALESCE(vo.latin, vo.title) AS title, COALESCE(vo.latin, vo.title) AS sorttitle, CASE WHEN vo.latin IS NULL THEN '' ELSE vo.title END AS alttitle FROM vn v JOIN vn_titles vo ON vo.id = v.id AND vo.lang = v.olang;
+\i sql/perms.sql
diff --git a/util/updates/2022-03-23-vn-length-votes-uncounted.sql b/util/updates/2022-03-23-vn-length-votes-uncounted.sql
new file mode 100644
index 00000000..fa24d44c
--- /dev/null
+++ b/util/updates/2022-03-23-vn-length-votes-uncounted.sql
@@ -0,0 +1,6 @@
+BEGIN;
+ALTER TABLE vn_length_votes ALTER COLUMN speed DROP NOT NULL;
+UPDATE vn_length_votes SET speed = NULL WHERE ignore;
+ALTER TABLE vn_length_votes DROP COLUMN ignore;
+COMMIT;
+\i sql/func.sql
diff --git a/util/updates/2022-03-29-lengthvotes-private.sql b/util/updates/2022-03-29-lengthvotes-private.sql
new file mode 100644
index 00000000..5c721818
--- /dev/null
+++ b/util/updates/2022-03-29-lengthvotes-private.sql
@@ -0,0 +1,3 @@
+ALTER TABLE vn_length_votes ADD COLUMN private boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE vn_length_votes ALTER COLUMN private DROP DEFAULT;
+\i sql/func.sql
diff --git a/util/updates/2022-03-29-release-animation.sql b/util/updates/2022-03-29-release-animation.sql
new file mode 100644
index 00000000..cc6a5a20
--- /dev/null
+++ b/util/updates/2022-03-29-release-animation.sql
@@ -0,0 +1,29 @@
+BEGIN;
+
+CREATE DOMAIN animation AS smallint CHECK(value IS NULL OR value IN(0,1) OR ((value & (4+8+16+32)) > 0 AND (value & (256+512)) <> (256+512)));
+
+ALTER TABLE releases ADD COLUMN ani_story_sp animation;
+ALTER TABLE releases ADD COLUMN ani_story_cg animation;
+ALTER TABLE releases ADD COLUMN ani_cutscene animation;
+ALTER TABLE releases ADD COLUMN ani_ero_sp animation;
+ALTER TABLE releases ADD COLUMN ani_ero_cg animation;
+ALTER TABLE releases ADD COLUMN ani_bg boolean;
+ALTER TABLE releases ADD COLUMN ani_face boolean;
+
+ALTER TABLE releases_hist ADD COLUMN ani_story_sp animation;
+ALTER TABLE releases_hist ADD COLUMN ani_story_cg animation;
+ALTER TABLE releases_hist ADD COLUMN ani_cutscene animation;
+ALTER TABLE releases_hist ADD COLUMN ani_ero_sp animation;
+ALTER TABLE releases_hist ADD COLUMN ani_ero_cg animation;
+ALTER TABLE releases_hist ADD COLUMN ani_bg boolean;
+ALTER TABLE releases_hist ADD COLUMN ani_face boolean;
+
+UPDATE releases SET ani_story_sp = 0, ani_story_cg = 0, ani_face = false, ani_bg = false WHERE ani_story = 1;
+UPDATE releases_hist SET ani_story_sp = 0, ani_story_cg = 0, ani_face = false, ani_bg = false WHERE ani_story = 1;
+UPDATE releases SET ani_ero_sp = 0, ani_ero_cg = 0 WHERE ani_ero = 1;
+UPDATE releases_hist SET ani_ero_sp = 0, ani_ero_cg = 0 WHERE ani_ero = 1;
+
+ALTER TABLE releases ADD CONSTRAINT releases_cutscene_check CHECK(ani_cutscene <> 0 AND (ani_cutscene & (256+512)) = 0);
+
+\i sql/editfunc.sql
+COMMIT;
diff --git a/util/updates/2022-04-01-user-traits.sql b/util/updates/2022-04-01-user-traits.sql
new file mode 100644
index 00000000..a99b3d3a
--- /dev/null
+++ b/util/updates/2022-04-01-user-traits.sql
@@ -0,0 +1,8 @@
+CREATE TABLE users_traits (
+ id vndbid NOT NULL,
+ tid vndbid NOT NULL,
+ PRIMARY KEY(id, tid)
+);
+ALTER TABLE users_traits ADD CONSTRAINT users_traits_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE users_traits ADD CONSTRAINT users_traits_tid_fkey FOREIGN KEY (tid) REFERENCES traits (id);
+GRANT SELECT, INSERT, UPDATE, DELETE ON users_traits TO vndb_site;
diff --git a/util/updates/2022-04-05-releases-has-ero.sql b/util/updates/2022-04-05-releases-has-ero.sql
new file mode 100644
index 00000000..f31d9f04
--- /dev/null
+++ b/util/updates/2022-04-05-releases-has-ero.sql
@@ -0,0 +1,5 @@
+ALTER TABLE releases ADD COLUMN has_ero boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE releases_hist ADD COLUMN has_ero boolean NOT NULL DEFAULT FALSE;
+UPDATE releases SET has_ero = TRUE WHERE minage = 18;
+UPDATE releases_hist SET has_ero = TRUE WHERE minage = 18;
+\i sql/editfunc.sql
diff --git a/util/updates/2022-04-19-vn-default-poprank.sql b/util/updates/2022-04-19-vn-default-poprank.sql
new file mode 100644
index 00000000..080269e2
--- /dev/null
+++ b/util/updates/2022-04-19-vn-default-poprank.sql
@@ -0,0 +1 @@
+ALTER TABLE vn ALTER COLUMN c_pop_rank SET DEFAULT 10000000;
diff --git a/util/updates/2022-04-23-inuktitut-language.sql b/util/updates/2022-04-23-inuktitut-language.sql
new file mode 100644
index 00000000..ae9507c6
--- /dev/null
+++ b/util/updates/2022-04-23-inuktitut-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'iu' AFTER 'it';
diff --git a/util/updates/2022-06-16-users-debloat.sql b/util/updates/2022-06-16-users-debloat.sql
new file mode 100644
index 00000000..aa2ced78
--- /dev/null
+++ b/util/updates/2022-06-16-users-debloat.sql
@@ -0,0 +1,90 @@
+CREATE TABLE users_prefs (
+ id vndbid NOT NULL PRIMARY KEY,
+ max_sexual smallint NOT NULL DEFAULT 0,
+ max_violence smallint NOT NULL DEFAULT 0,
+ last_reports timestamptz, -- For mods: Most recent activity seen on the reports listing
+ tableopts_c integer,
+ tableopts_v integer,
+ tableopts_vt integer, -- VN listing on tag pages
+ spoilers smallint NOT NULL DEFAULT 0,
+ tags_all boolean NOT NULL DEFAULT false,
+ tags_cont boolean NOT NULL DEFAULT true,
+ tags_ero boolean NOT NULL DEFAULT false,
+ tags_tech boolean NOT NULL DEFAULT true,
+ traits_sexual boolean NOT NULL DEFAULT false,
+ skin text NOT NULL DEFAULT '',
+ customcss text NOT NULL DEFAULT '',
+ ulist_votes jsonb,
+ ulist_vnlist jsonb,
+ ulist_wish jsonb,
+ vnlang jsonb, -- '$lang(-mtl)?' => true/false, which languages to expand/collapse on VN pages
+ title_langs jsonb,
+ alttitle_langs jsonb
+);
+
+INSERT INTO users_prefs SELECT id
+ , max_sexual
+ , max_violence
+ , last_reports
+ , tableopts_c
+ , tableopts_v
+ , tableopts_vt
+ , spoilers
+ , tags_all
+ , tags_cont
+ , tags_ero
+ , tags_tech
+ , traits_sexual
+ , skin
+ , customcss
+ , ulist_votes
+ , ulist_vnlist
+ , ulist_wish
+ , vnlang
+ , title_langs
+ , alttitle_langs
+ FROM users;
+
+ALTER TABLE users_prefs ADD CONSTRAINT users_prefs_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+
+ALTER TABLE users DROP COLUMN max_sexual ;
+ALTER TABLE users DROP COLUMN max_violence ;
+ALTER TABLE users DROP COLUMN last_reports ;
+ALTER TABLE users DROP COLUMN tableopts_c ;
+ALTER TABLE users DROP COLUMN tableopts_v ;
+ALTER TABLE users DROP COLUMN tableopts_vt ;
+ALTER TABLE users DROP COLUMN spoilers ;
+ALTER TABLE users DROP COLUMN tags_all ;
+ALTER TABLE users DROP COLUMN tags_cont ;
+ALTER TABLE users DROP COLUMN tags_ero ;
+ALTER TABLE users DROP COLUMN tags_tech ;
+ALTER TABLE users DROP COLUMN traits_sexual ;
+ALTER TABLE users DROP COLUMN skin ;
+ALTER TABLE users DROP COLUMN customcss ;
+ALTER TABLE users DROP COLUMN ulist_votes ;
+ALTER TABLE users DROP COLUMN ulist_vnlist ;
+ALTER TABLE users DROP COLUMN ulist_wish ;
+ALTER TABLE users DROP COLUMN vnlang ;
+ALTER TABLE users DROP COLUMN title_langs ;
+ALTER TABLE users DROP COLUMN alttitle_langs;
+
+ALTER TABLE users_shadow ADD COLUMN ip inet NOT NULL DEFAULT '0.0.0.0';
+UPDATE users_shadow SET ip = users.ip FROM users WHERE users.id = users_shadow.id;
+ALTER TABLE users DROP COLUMN ip;
+
+-- Rewrite the table to properly remove the columns.
+CLUSTER users USING users_pkey;
+
+-- users.ip is not accessible anymore, so we need a separate table to throttle
+-- registrations per IP.
+CREATE TABLE registration_throttle (
+ ip inet NOT NULL PRIMARY KEY,
+ timeout timestamptz NOT NULL
+);
+
+-- While I'm at it, let's remove changes.ip too. I've not used it in the past decade.
+ALTER TABLE changes DROP COLUMN ip;
+
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-06-18-user-prefs-prodrelexpand.sql b/util/updates/2022-06-18-user-prefs-prodrelexpand.sql
new file mode 100644
index 00000000..96fe5fe5
--- /dev/null
+++ b/util/updates/2022-06-18-user-prefs-prodrelexpand.sql
@@ -0,0 +1 @@
+ALTER TABLE users_prefs ADD COLUMN prodrelexpand boolean NOT NULL DEFAULT true;
diff --git a/util/updates/2022-06-19-user-prefs-vnrel.sql b/util/updates/2022-06-19-user-prefs-vnrel.sql
new file mode 100644
index 00000000..f9321b93
--- /dev/null
+++ b/util/updates/2022-06-19-user-prefs-vnrel.sql
@@ -0,0 +1,31 @@
+ALTER TABLE users_prefs ADD COLUMN vnrel_langs language[],
+ ADD COLUMN vnrel_olang boolean NOT NULL DEFAULT true,
+ ADD COLUMN vnrel_mtl boolean NOT NULL DEFAULT false;
+
+-- Attempt to infer vnrel_langs and vnrel_mtl from the old 'vnlang' column.
+BEGIN;
+
+CREATE OR REPLACE FUNCTION vnlang_to_langs(vnlang jsonb) RETURNS language[] AS $$
+DECLARE
+ ret language[];
+ del language;
+BEGIN
+ ret := enum_range(null::language);
+ FOR del IN SELECT key::language FROM jsonb_each(vnlang) x WHERE key NOT LIKE '%-mtl' AND value = 'false'
+ LOOP
+ ret := array_remove(ret, del);
+ END LOOP;
+ RETURN CASE WHEN array_length(ret,1) = array_length(enum_range(null::language),1) THEN NULL ELSE RET END;
+END$$ LANGUAGE plpgsql;
+
+WITH p(id,langs,mtl) AS (
+ SELECT id, vnlang_to_langs(vnlang), vnlang->'en-mtl' is not distinct from 'true'
+ FROM users_prefs WHERE vnlang IS NOT NULL
+) UPDATE users_prefs
+ SET vnrel_langs = langs, vnrel_mtl = mtl
+ FROM p
+ WHERE p.id = users_prefs.id AND (langs IS NOT NULL OR mtl);
+
+DROP FUNCTION vnlang_to_langs(jsonb);
+
+COMMIT;
diff --git a/util/updates/2022-06-20-changes-patrolling.sql b/util/updates/2022-06-20-changes-patrolling.sql
new file mode 100644
index 00000000..32ad9929
--- /dev/null
+++ b/util/updates/2022-06-20-changes-patrolling.sql
@@ -0,0 +1,8 @@
+CREATE TABLE changes_patrolled (
+ id integer NOT NULL,
+ uid vndbid NOT NULL,
+ PRIMARY KEY(id,uid)
+);
+ALTER TABLE changes_patrolled ADD CONSTRAINT changes_patrolled_id_fkey FOREIGN KEY (id) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE changes_patrolled ADD CONSTRAINT changes_patrolled_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+\i sql/perms.sql
diff --git a/util/updates/2022-06-21-tags-vn-lie.sql b/util/updates/2022-06-21-tags-vn-lie.sql
new file mode 100644
index 00000000..b4aafad9
--- /dev/null
+++ b/util/updates/2022-06-21-tags-vn-lie.sql
@@ -0,0 +1 @@
+ALTER TABLE tags_vn ADD COLUMN lie boolean;
diff --git a/util/updates/2022-07-31-vn-devstatus.sql b/util/updates/2022-07-31-vn-devstatus.sql
new file mode 100644
index 00000000..7bc709a0
--- /dev/null
+++ b/util/updates/2022-07-31-vn-devstatus.sql
@@ -0,0 +1,24 @@
+ALTER TABLE vn ADD COLUMN devstatus smallint NOT NULL DEFAULT 0;
+ALTER TABLE vn_hist ADD COLUMN devstatus smallint NOT NULL DEFAULT 0;
+\i sql/editfunc.sql
+
+UPDATE vn SET devstatus = 0 WHERE devstatus <> 0;
+
+-- Heuristic: VN is considered cancelled if it meets all of the following criteria:
+-- * doesn't have a complete release
+-- * doesn't have any release after 2020
+-- * doesn't have multiple partial releases
+-- * doesn't have both a trial and partial release (weird heuristic, but there's many matching in-dev games)
+UPDATE vn SET devstatus = 2 WHERE
+ id NOT IN(SELECT vid FROM releases_vn rv JOIN releases r ON r.id = rv.id WHERE NOT r.hidden AND rtype = 'complete' OR released > 20200000)
+ AND id NOT IN(SELECT vid FROM releases_vn rv JOIN releases r ON r.id = rv.id WHERE NOT r.hidden AND rtype = 'partial' GROUP BY vid HAVING COUNT(r.id) > 1)
+ AND id NOT IN(SELECT vid FROM releases_vn rv JOIN releases r ON r.id = rv.id WHERE NOT r.hidden AND rtype IN('partial','trial') GROUP BY vid HAVING COUNT(DISTINCT rtype) = 2);
+
+-- Heuristic: VN is considerd in development if it's not cancelled and meets one of the following:
+-- * Has a future release date
+-- * Has no complete releases and only a single partial release
+UPDATE vn SET devstatus = 1 WHERE devstatus = 0 AND (c_released > 22020731 OR (
+ id NOT IN(SELECT vid FROM releases_vn rv JOIN releases r ON r.id = rv.id WHERE NOT r.hidden AND rtype = 'complete')
+ AND id IN(SELECT vid FROM releases_vn rv JOIN releases r ON r.id = rv.id WHERE NOT r.hidden AND rtype = 'partial' GROUP BY vid HAVING COUNT(r.id) = 1)));
+
+UPDATE vn_hist SET devstatus = v.devstatus FROM changes c JOIN vn v ON c.itemid = v.id WHERE vn_hist.chid = c.id AND v.devstatus <> vn_hist.devstatus;
diff --git a/util/updates/2022-08-03-tags_vn_direct.sql b/util/updates/2022-08-03-tags_vn_direct.sql
new file mode 100644
index 00000000..e8a2445c
--- /dev/null
+++ b/util/updates/2022-08-03-tags_vn_direct.sql
@@ -0,0 +1,10 @@
+CREATE TABLE tags_vn_direct (
+ tag vndbid NOT NULL,
+ vid vndbid NOT NULL,
+ rating real NOT NULL,
+ spoiler smallint NOT NULL,
+ lie boolean NOT NULL
+);
+\i sql/func.sql
+\i sql/perms.sql
+SELECT tag_vn_calc(NULL);
diff --git a/util/updates/2022-08-24-ipinfo.sql b/util/updates/2022-08-24-ipinfo.sql
new file mode 100644
index 00000000..ffa00708
--- /dev/null
+++ b/util/updates/2022-08-24-ipinfo.sql
@@ -0,0 +1,17 @@
+CREATE TYPE ipinfo AS (
+ ip inet,
+ country text,
+ asn integer,
+ as_name text,
+ anonymous_proxy boolean,
+ sattelite_provider boolean,
+ anycast boolean,
+ drop boolean
+);
+
+ALTER TABLE audit_log ALTER COLUMN by_ip TYPE ipinfo USING ROW(by_ip,null,null,null,null,null,null,null);
+ALTER TABLE reports ALTER COLUMN ip TYPE ipinfo USING CASE WHEN ip IS NULL THEN NULL ELSE ROW(ip,null,null,null,null,null,null,null)::ipinfo END;
+
+ALTER TABLE users_shadow ALTER COLUMN ip DROP DEFAULT;
+ALTER TABLE users_shadow ALTER COLUMN ip DROP NOT NULL;
+ALTER TABLE users_shadow ALTER COLUMN ip TYPE ipinfo USING CASE WHEN ip = '0.0.0.0' THEN NULL ELSE ROW(ip,null,null,null,null,null,null,null)::ipinfo END;
diff --git a/util/updates/2022-08-25-customcss-csum.sql b/util/updates/2022-08-25-customcss-csum.sql
new file mode 100644
index 00000000..8a2a8938
--- /dev/null
+++ b/util/updates/2022-08-25-customcss-csum.sql
@@ -0,0 +1,3 @@
+ALTER TABLE users_prefs ADD COLUMN customcss_csum bigint NOT NULL DEFAULT 0;
+-- '1' is not exactly a checksum, but it'll do fine for the first version.
+UPDATE users_prefs SET customcss_csum = 1 WHERE customcss <> '';
diff --git a/util/updates/2022-08-25-staff-editions.sql b/util/updates/2022-08-25-staff-editions.sql
new file mode 100644
index 00000000..d5a731e5
--- /dev/null
+++ b/util/updates/2022-08-25-staff-editions.sql
@@ -0,0 +1,43 @@
+ALTER TYPE credit_type ADD VALUE 'translator' AFTER 'director';
+ALTER TYPE credit_type ADD VALUE 'editor' AFTER 'translator';
+ALTER TYPE credit_type ADD VALUE 'qa' AFTER 'editor';
+
+CREATE TABLE vn_editions (
+ id vndbid NOT NULL, -- [pub]
+ lang language, -- [pub]
+ eid smallint NOT NULL, -- [pub] (not stable across entry revisions)
+ official boolean NOT NULL DEFAULT TRUE, -- [pub]
+ name text NOT NULL, -- [pub]
+ PRIMARY KEY(id, eid)
+);
+
+CREATE TABLE vn_editions_hist (
+ chid integer NOT NULL,
+ lang language,
+ eid smallint NOT NULL,
+ official boolean NOT NULL DEFAULT TRUE,
+ name text NOT NULL,
+ PRIMARY KEY(chid, eid)
+);
+
+ALTER TABLE vn_staff ADD COLUMN eid smallint;
+ALTER TABLE vn_staff DROP CONSTRAINT vn_staff_pkey;
+CREATE UNIQUE INDEX vn_staff_pkey ON vn_staff (id, COALESCE(eid,-1::smallint), aid, role);
+
+ALTER TABLE vn_staff_hist ADD COLUMN eid smallint;
+ALTER TABLE vn_staff_hist DROP CONSTRAINT vn_staff_hist_pkey;
+CREATE UNIQUE INDEX vn_staff_hist_pkey ON vn_staff_hist (chid, COALESCE(eid,-1::smallint), aid, role);
+
+ALTER TABLE vn_staff DROP CONSTRAINT vn_staff_id_fkey;
+ALTER TABLE vn_staff_hist DROP CONSTRAINT vn_staff_hist_chid_fkey;
+
+ALTER TABLE vn_staff ADD CONSTRAINT vn_staff_id_eid_fkey FOREIGN KEY (id,eid) REFERENCES vn_editions (id,eid) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE vn_staff_hist ADD CONSTRAINT vn_staff_hist_chid_eid_fkey FOREIGN KEY (chid,eid) REFERENCES vn_editions_hist (chid,eid) DEFERRABLE INITIALLY DEFERRED;
+
+ALTER TABLE users_prefs
+ ADD COLUMN staffed_langs language[],
+ ADD COLUMN staffed_olang boolean NOT NULL DEFAULT true,
+ ADD COLUMN staffed_unoff boolean NOT NULL DEFAULT false;
+
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-08-28-basque-language.sql b/util/updates/2022-08-28-basque-language.sql
new file mode 100644
index 00000000..a0bd3899
--- /dev/null
+++ b/util/updates/2022-08-28-basque-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'eu' AFTER 'es';
diff --git a/util/updates/2022-08-30-tag-trait-prefs.sql b/util/updates/2022-08-30-tag-trait-prefs.sql
new file mode 100644
index 00000000..db2bec02
--- /dev/null
+++ b/util/updates/2022-08-30-tag-trait-prefs.sql
@@ -0,0 +1,23 @@
+CREATE TABLE users_prefs_tags (
+ id vndbid NOT NULL,
+ tid vndbid NOT NULL,
+ spoil smallint NOT NULL,
+ childs boolean NOT NULL,
+ PRIMARY KEY(id, tid)
+);
+
+ALTER TABLE users_prefs_tags ADD CONSTRAINT users_prefs_tags_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE users_prefs_tags ADD CONSTRAINT users_prefs_tags_tid_fkey FOREIGN KEY (tid) REFERENCES tags (id) ON DELETE CASCADE;
+
+CREATE TABLE users_prefs_traits (
+ id vndbid NOT NULL,
+ tid vndbid NOT NULL,
+ spoil smallint NOT NULL,
+ childs boolean NOT NULL,
+ PRIMARY KEY(id, tid)
+);
+
+ALTER TABLE users_prefs_traits ADD CONSTRAINT users_prefs_traits_id_fkey FOREIGN KEY (id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE users_prefs_traits ADD CONSTRAINT users_prefs_traits_tid_fkey FOREIGN KEY (tid) REFERENCES traits (id) ON DELETE CASCADE;
+
+\i sql/perms.sql
diff --git a/util/updates/2022-09-28-release-titles.sql b/util/updates/2022-09-28-release-titles.sql
new file mode 100644
index 00000000..4e875a14
--- /dev/null
+++ b/util/updates/2022-09-28-release-titles.sql
@@ -0,0 +1,81 @@
+BEGIN;
+
+CREATE TABLE releases_titles (
+ id vndbid NOT NULL,
+ lang language NOT NULL,
+ mtl boolean NOT NULL DEFAULT false,
+ title text,
+ latin text,
+ PRIMARY KEY(id, lang)
+);
+
+CREATE TABLE releases_titles_hist (
+ chid integer NOT NULL,
+ lang language NOT NULL,
+ mtl boolean NOT NULL DEFAULT false,
+ title text,
+ latin text,
+ PRIMARY KEY(chid, lang)
+);
+
+-- Fixup some old (deleted) entries that are missing a language field
+INSERT INTO releases_lang SELECT rv.id, v.olang, false FROM releases_vn rv JOIN vn v ON v.id = rv.vid WHERE NOT EXISTS(SELECT 1 FROM releases_lang rl WHERE rl.id = rv.id);
+INSERT INTO releases_lang_hist SELECT rv.chid, v.olang, false FROM releases_vn_hist rv JOIN vn v ON v.id = rv.vid WHERE NOT EXISTS(SELECT 1 FROM releases_lang_hist rl WHERE rl.chid = rv.chid);
+
+ALTER TABLE releases ADD COLUMN olang language NOT NULL DEFAULT 'ja';
+ALTER TABLE releases_hist ADD COLUMN olang language NOT NULL DEFAULT 'ja';
+
+-- 'releases' table needs an olang field now in order to select the proper
+-- default title to display. Inherit these from the related (lowest-id) VN
+-- entry if the release language matches, otherwise select an arbitrary one
+-- (preferring English).
+WITH rl (id, ol) AS (
+ SELECT DISTINCT ON(rv.id) rv.id, COALESCE(rl.lang, re.lang, rf.lang, v.olang)
+ FROM releases_vn rv
+ JOIN vn v ON v.id = rv.vid
+ LEFT JOIN releases_lang rl ON rl.id = rv.id AND rl.lang = v.olang
+ LEFT JOIN releases_lang re ON re.id = rv.id AND re.lang = 'en'
+ LEFT JOIN releases_lang rf ON rf.id = rv.id AND (rf.lang <> v.olang AND rf.lang <> 'en')
+ ORDER BY rv.id, rl.id NULLS LAST, rv.vid, rl.lang
+) UPDATE releases SET olang = ol FROM rl WHERE releases.id = rl.id AND ol <> 'ja';
+
+WITH rl (id, ol) AS (
+ SELECT DISTINCT ON(rv.chid) rv.chid, COALESCE(rl.lang, re.lang, rf.lang, v.olang)
+ FROM releases_vn_hist rv
+ JOIN vn v ON v.id = rv.vid
+ LEFT JOIN releases_lang_hist rl ON rl.chid = rv.chid AND rl.lang = v.olang
+ LEFT JOIN releases_lang_hist re ON re.chid = rv.chid AND re.lang = 'en'
+ LEFT JOIN releases_lang_hist rf ON rf.chid = rv.chid AND (rf.lang <> v.olang AND rf.lang <> 'en')
+ ORDER BY rv.chid, rl.chid NULLS LAST, rv.vid, rl.lang
+) UPDATE releases_hist SET olang = ol FROM rl WHERE chid = id AND ol <> 'ja';
+
+-- Copy all languages and set the title only for the "main" language as determined above.
+INSERT INTO releases_titles
+ SELECT rl.id, rl.lang, rl.mtl
+ , CASE WHEN rl.lang <> r.olang THEN NULL WHEN r.original = '' THEN r.title ELSE r.original END
+ , CASE WHEN rl.lang <> r.olang THEN NULL WHEN r.original = '' THEN NULL ELSE r.title END
+ FROM releases_lang rl
+ JOIN releases r ON r.id = rl.id;
+
+INSERT INTO releases_titles_hist
+ SELECT rl.chid, rl.lang, rl.mtl
+ , CASE WHEN rl.lang <> r.olang THEN NULL WHEN r.original = '' THEN r.title ELSE r.original END
+ , CASE WHEN rl.lang <> r.olang THEN NULL WHEN r.original = '' THEN NULL ELSE r.title END
+ FROM releases_lang_hist rl
+ JOIN releases_hist r ON r.chid = rl.chid;
+
+ALTER TABLE releases ALTER COLUMN c_search DROP NOT NULL, ALTER COLUMN c_search DROP EXPRESSION;
+
+ALTER TABLE releases DROP COLUMN title, DROP COLUMN original;
+ALTER TABLE releases_hist DROP COLUMN title, DROP COLUMN original;
+
+CREATE VIEW releasest AS SELECT r.*, COALESCE(ro.latin, ro.title) AS title, COALESCE(ro.latin, ro.title) AS sorttitle, CASE WHEN ro.latin IS NULL THEN '' ELSE ro.title END AS alttitle FROM releases r JOIN releases_titles ro ON ro.id = r.id AND ro.lang = r.olang;
+
+DROP TABLE releases_lang, releases_lang_hist;
+
+COMMIT;
+
+\i sql/tableattrs.sql
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-10-08-images-smallints.sql b/util/updates/2022-10-08-images-smallints.sql
new file mode 100644
index 00000000..316bf0c8
--- /dev/null
+++ b/util/updates/2022-10-08-images-smallints.sql
@@ -0,0 +1,19 @@
+ALTER TABLE images
+ ALTER c_votecount TYPE smallint,
+ ALTER c_weight TYPE smallint,
+ ALTER c_sexual_avg TYPE smallint USING COALESCE(c_sexual_avg *100, 200),
+ ALTER c_sexual_stddev TYPE smallint USING COALESCE(c_sexual_stddev *100, 0),
+ ALTER c_violence_avg TYPE smallint USING COALESCE(c_violence_avg *100, 200),
+ ALTER c_violence_stddev TYPE smallint USING COALESCE(c_violence_stddev*100, 0),
+ ALTER c_sexual_avg SET DEFAULT 200,
+ ALTER c_sexual_stddev SET DEFAULT 0,
+ ALTER c_violence_avg SET DEFAULT 200,
+ ALTER c_violence_stddev SET DEFAULT 0,
+ ALTER c_sexual_avg SET NOT NULL,
+ ALTER c_sexual_stddev SET NOT NULL,
+ ALTER c_violence_avg SET NOT NULL,
+ ALTER c_violence_stddev SET NOT NULL;
+
+\i sql/func.sql
+
+SELECT update_images_cache(NULL);
diff --git a/util/updates/2022-10-16-release-shop-links.sql b/util/updates/2022-10-16-release-shop-links.sql
new file mode 100644
index 00000000..6be52706
--- /dev/null
+++ b/util/updates/2022-10-16-release-shop-links.sql
@@ -0,0 +1,11 @@
+ALTER TABLE releases
+ ADD COLUMN l_nintendo_jp bigint NOT NULL DEFAULT 0,
+ ADD COLUMN l_nintendo_hk bigint NOT NULL DEFAULT 0,
+ ADD COLUMN l_nintendo text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_hk text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist
+ ADD COLUMN l_nintendo_jp bigint NOT NULL DEFAULT 0,
+ ADD COLUMN l_nintendo_hk bigint NOT NULL DEFAULT 0,
+ ADD COLUMN l_nintendo text NOT NULL DEFAULT '',
+ ADD COLUMN l_playstation_hk text NOT NULL DEFAULT '';
+\i sql/editfunc.sql
diff --git a/util/updates/2022-10-22-tags_vn_inherit-lie.sql b/util/updates/2022-10-22-tags_vn_inherit-lie.sql
new file mode 100644
index 00000000..9ab1e329
--- /dev/null
+++ b/util/updates/2022-10-22-tags_vn_inherit-lie.sql
@@ -0,0 +1,4 @@
+ALTER TABLE tags_vn_inherit ADD COLUMN lie boolean;
+\i sql/func.sql
+SELECT tag_vn_calc(null);
+ALTER TABLE tags_vn_inherit ALTER COLUMN lie DROP NOT NULL;
diff --git a/util/updates/2022-10-27-trait-lies.sql b/util/updates/2022-10-27-trait-lies.sql
new file mode 100644
index 00000000..fff91ca8
--- /dev/null
+++ b/util/updates/2022-10-27-trait-lies.sql
@@ -0,0 +1,5 @@
+ALTER TABLE chars_traits ADD COLUMN lie boolean NOT NULL DEFAULT false;
+ALTER TABLE chars_traits_hist ADD COLUMN lie boolean NOT NULL DEFAULT false;
+ALTER TABLE traits_chars ADD COLUMN lie boolean NOT NULL DEFAULT false;
+\i sql/editfunc.sql
+\i sql/func.sql
diff --git a/util/updates/2022-10-31-ulist-vns-labels.sql b/util/updates/2022-10-31-ulist-vns-labels.sql
new file mode 100644
index 00000000..04973343
--- /dev/null
+++ b/util/updates/2022-10-31-ulist-vns-labels.sql
@@ -0,0 +1,137 @@
+-- This migration script is written so that it can be run while keeping VNDB
+-- online in read-only mode. Any writes to the database while this script is
+-- active will likely result in a deadlock or a bit of data loss.
+
+-- (An older version of this script attempted to do an in-place UPDATE on
+-- ulist_vns, but postgres didn't properly optimize that query in production
+-- and ended up taking the site down for 30 minutes. This version is both
+-- faster and doesn't require the site to go fully down)
+
+CREATE TABLE ulist_vns_tmp (
+ uid vndbid NOT NULL,
+ vid vndbid NOT NULL,
+ added timestamptz NOT NULL DEFAULT NOW(),
+ lastmod timestamptz NOT NULL DEFAULT NOW(),
+ vote_date timestamptz,
+ started date,
+ finished date,
+ vote smallint,
+ c_private boolean NOT NULL DEFAULT true,
+ labels smallint[] NOT NULL DEFAULT '{}',
+ notes text NOT NULL DEFAULT ''
+);
+
+INSERT INTO ulist_vns_tmp
+ SELECT uv.uid, uv.vid, uv.added, uv.lastmod, uv.vote_date, uv.started, uv.finished, uv.vote, coalesce(l.private, true), coalesce(l.labels, '{}'), uv.notes
+ FROM ulist_vns uv
+ LEFT JOIN (
+ SELECT uvl.uid, uvl.vid, bool_and(ul.private), array_agg(uvl.lbl::smallint ORDER BY uvl.lbl)
+ FROM ulist_vns_labels uvl
+ JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl
+ GROUP BY uvl.uid, uvl.vid
+ ) l(uid, vid, private, labels) ON l.uid = uv.uid AND l.vid = uv.vid
+ ORDER BY uv.uid, uv.vid;
+
+-- Attempt a perfect reconstruction of 'ulist_vns', so that constraint & index
+-- names match those of a newly created table with the correct name.
+ALTER INDEX ulist_vns_pkey RENAME TO ulist_vns_old_pkey;
+ALTER INDEX ulist_vns_voted RENAME TO ulist_vns_old_voted;
+
+\timing
+ALTER TABLE ulist_vns_tmp ADD CONSTRAINT ulist_vns_pkey PRIMARY KEY (uid, vid);
+ALTER TABLE ulist_vns_tmp ADD CONSTRAINT ulist_vns_vote_check CHECK(vote IS NULL OR vote BETWEEN 10 AND 100);
+CREATE INDEX ulist_vns_voted ON ulist_vns_tmp (vid, vote_date) WHERE vote IS NOT NULL;
+ALTER TABLE ulist_vns_tmp ADD CONSTRAINT ulist_vns_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE ulist_vns_tmp ADD CONSTRAINT ulist_vns_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+
+ANALYZE ulist_vns_tmp;
+GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns_tmp TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns_tmp TO vndb_multi;
+
+BEGIN;
+ALTER TABLE ulist_vns RENAME TO ulist_vns_old;
+ALTER TABLE ulist_vns_tmp RENAME TO ulist_vns;
+COMMIT;
+
+
+-- Let's not \i SQL files here, since we're running this script on an older commit.
+
+-- From util.sql
+
+CREATE OR REPLACE FUNCTION array_set(arr anycompatiblearray, elem anycompatible) RETURNS anycompatiblearray AS $$
+DECLARE
+ ret arr%TYPE;
+ e elem%TYPE;
+ added boolean := false;
+BEGIN
+ FOREACH e IN ARRAY arr LOOP
+ IF e = elem THEN RETURN arr;
+ ELSIF added or e < elem THEN ret := ret || e;
+ ELSE
+ ret := ret || elem || e;
+ added := true;
+ END IF;
+ END LOOP;
+ RETURN CASE WHEN added THEN ret ELSE ret || elem END;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+
+
+-- From func.sql
+
+CREATE OR REPLACE FUNCTION update_users_ulist_stats(vndbid) RETURNS void AS $$
+BEGIN
+ WITH cnt(uid, votes, vns, wish) AS (
+ SELECT u.id
+ , COUNT(uv.vid) FILTER (WHERE NOT uv.c_private AND uv.vote IS NOT NULL) -- Voted
+ , COUNT(uv.vid) FILTER (WHERE NOT uv.c_private AND NOT (uv.labels <@ ARRAY[5,6]::smallint[])) -- Labelled, but not wishlish/blacklist
+ , COUNT(uv.vid) FILTER (WHERE uwish.private IS NOT DISTINCT FROM false AND uv.labels && ARRAY[5::smallint]) -- Wishlist
+ FROM users u
+ LEFT JOIN ulist_vns uv ON uv.uid = u.id
+ LEFT JOIN ulist_labels uwish ON uwish.uid = u.id AND uwish.id = 5
+ WHERE $1 IS NULL OR u.id = $1
+ GROUP BY u.id
+ ) UPDATE users SET c_votes = votes, c_vns = vns, c_wish = wish
+ FROM cnt WHERE id = uid AND (c_votes, c_vns, c_wish) IS DISTINCT FROM (votes, vns, wish);
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION update_users_ulist_private(vndbid, vndbid) RETURNS void AS $$
+BEGIN
+ WITH p(uid,vid,private) AS (
+ SELECT uv.uid, uv.vid, COALESCE(bool_and(l.private), true)
+ FROM ulist_vns uv
+ LEFT JOIN unnest(uv.labels) x(id) ON true
+ LEFT JOIN ulist_labels l ON l.id = x.id AND l.uid = uv.uid
+ WHERE ($1 IS NULL OR uv.uid = $1)
+ AND ($2 IS NULL OR uv.vid = $2)
+ GROUP BY uv.uid, uv.vid
+ ) UPDATE ulist_vns SET c_private = p.private FROM p
+ WHERE ulist_vns.uid = p.uid AND ulist_vns.vid = p.vid AND ulist_vns.c_private <> p.private;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+-- From triggers.sql
+
+CREATE OR REPLACE FUNCTION ulist_voted_label() RETURNS trigger AS $$
+BEGIN
+ NEW.labels := CASE WHEN NEW.vote IS NULL THEN array_remove(NEW.labels, 7) ELSE array_set(NEW.labels, 7) END;
+ RETURN NEW;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER ulist_voted_label_ins BEFORE INSERT ON ulist_vns FOR EACH ROW EXECUTE PROCEDURE ulist_voted_label();
+CREATE TRIGGER ulist_voted_label_upd BEFORE UPDATE ON ulist_vns FOR EACH ROW WHEN ((OLD.vote IS NULL) <> (NEW.vote IS NULL)) EXECUTE PROCEDURE ulist_voted_label();
+
+
+
+
+ALTER TABLE ulist_labels ALTER COLUMN id TYPE smallint;
+
+
+-- These should be run after restarting vndb.pl with the new codebase.
+DROP TABLE ulist_vns_labels;
+DROP TABLE ulist_vns_old;
diff --git a/util/updates/2022-11-11-serbian-language.sql b/util/updates/2022-11-11-serbian-language.sql
new file mode 100644
index 00000000..48cda88e
--- /dev/null
+++ b/util/updates/2022-11-11-serbian-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'sr' AFTER 'sl';
diff --git a/util/updates/2022-11-29-api2-tokens.sql b/util/updates/2022-11-29-api2-tokens.sql
new file mode 100644
index 00000000..f88e9754
--- /dev/null
+++ b/util/updates/2022-11-29-api2-tokens.sql
@@ -0,0 +1,9 @@
+ALTER TYPE session_type ADD VALUE 'api2' AFTER 'api';
+
+ALTER TABLE sessions
+ ADD COLUMN notes text,
+ ADD COLUMN listread boolean NOT NULL DEFAULT false;
+
+\i sql/func.sql
+
+DROP FUNCTION user_isvalidsession(vndbid, bytea, session_type);
diff --git a/util/updates/2022-12-13-users-prefs-timezone.sql b/util/updates/2022-12-13-users-prefs-timezone.sql
new file mode 100644
index 00000000..1e90d967
--- /dev/null
+++ b/util/updates/2022-12-13-users-prefs-timezone.sql
@@ -0,0 +1 @@
+ALTER TABLE users_prefs ADD COLUMN timezone text NOT NULL DEFAULT '';
diff --git a/util/updates/2022-12-18-sql-tags-cache-merge.sql b/util/updates/2022-12-18-sql-tags-cache-merge.sql
new file mode 100644
index 00000000..83730e56
--- /dev/null
+++ b/util/updates/2022-12-18-sql-tags-cache-merge.sql
@@ -0,0 +1,8 @@
+DROP INDEX IF EXISTS tags_vn_direct_tag_vid;
+ALTER TABLE tags_vn_direct ADD PRIMARY KEY (tag, vid);
+
+DROP INDEX IF EXISTS tags_vn_inherit_tag_vid;
+ALTER TABLE tags_vn_inherit ADD PRIMARY KEY (tag, vid);
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-12-19-sql-traits-chars-cache-merge.sql b/util/updates/2022-12-19-sql-traits-chars-cache-merge.sql
new file mode 100644
index 00000000..e6196d68
--- /dev/null
+++ b/util/updates/2022-12-19-sql-traits-chars-cache-merge.sql
@@ -0,0 +1,5 @@
+DROP INDEX traits_chars_tid;
+ALTER TABLE traits_chars ADD PRIMARY KEY (tid, cid);
+CREATE INDEX traits_chars_cid ON traits_chars (cid);
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-12-19-sql-unique-null-not-distinct.sql b/util/updates/2022-12-19-sql-unique-null-not-distinct.sql
new file mode 100644
index 00000000..02c00628
--- /dev/null
+++ b/util/updates/2022-12-19-sql-unique-null-not-distinct.sql
@@ -0,0 +1,12 @@
+DROP INDEX threads_boards_pkey;
+CREATE UNIQUE INDEX threads_boards_pkey ON threads_boards (tid,type,iid) NULLS NOT DISTINCT;
+
+DROP INDEX vn_staff_pkey;
+CREATE UNIQUE INDEX vn_staff_pkey ON vn_staff (id, eid, aid, role) NULLS NOT DISTINCT;
+DROP INDEX vn_staff_hist_pkey;
+CREATE UNIQUE INDEX vn_staff_hist_pkey ON vn_staff_hist (chid, eid, aid, role) NULLS NOT DISTINCT;
+
+DROP INDEX chars_vns_pkey;
+CREATE UNIQUE INDEX chars_vns_pkey ON chars_vns (id, vid, rid) NULLS NOT DISTINCT;
+DROP INDEX chars_vns_hist_pkey;
+CREATE UNIQUE INDEX chars_vns_hist_pkey ON chars_vns_hist (chid, vid, rid) NULLS NOT DISTINCT;
diff --git a/util/updates/2023-01-08-cherokee-language.sql b/util/updates/2023-01-08-cherokee-language.sql
new file mode 100644
index 00000000..f48c5694
--- /dev/null
+++ b/util/updates/2023-01-08-cherokee-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'ck' AFTER 'cs';
diff --git a/util/updates/2023-01-17-api2-listwrite.sql b/util/updates/2023-01-17-api2-listwrite.sql
new file mode 100644
index 00000000..75279206
--- /dev/null
+++ b/util/updates/2023-01-17-api2-listwrite.sql
@@ -0,0 +1,3 @@
+ALTER TABLE sessions ADD COLUMN listwrite boolean NOT NULL DEFAULT false;
+DROP FUNCTION user_api2_set_token(vndbid, vndbid, bytea, bytea, text, boolean);
+\i sql/func.sql
diff --git a/util/updates/2023-01-19-delete-admin-setpass.sql b/util/updates/2023-01-19-delete-admin-setpass.sql
new file mode 100644
index 00000000..3f9b158d
--- /dev/null
+++ b/util/updates/2023-01-19-delete-admin-setpass.sql
@@ -0,0 +1 @@
+DROP FUNCTION user_admin_setpass(vndbid, vndbid, bytea, bytea);
diff --git a/util/updates/2023-02-01-sql-titleprefs.sql b/util/updates/2023-02-01-sql-titleprefs.sql
new file mode 100644
index 00000000..4f49427d
--- /dev/null
+++ b/util/updates/2023-02-01-sql-titleprefs.sql
@@ -0,0 +1,67 @@
+\i sql/schema.sql
+
+-- The old JSON structure is messy; the same language may be listed multiple
+-- times and original language isn't always present or the last option. This
+-- function attempts a clean conversion, where the preference is the same but
+-- without the weirdness.
+CREATE OR REPLACE FUNCTION json2titleprefs(title_langs jsonb, alttitle_langs jsonb) RETURNS titleprefs AS $$
+ WITH t_parsed (rank, lang, latin, prio, official) AS (
+ -- Parse, add rank & prio
+ SELECT row_number() OVER(ROWS CURRENT ROW), lang, COALESCE(latin, false)
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN 3 WHEN official IS NOT DISTINCT FROM true THEN 2 ELSE 1 END
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN NULL ELSE COALESCE(official, false) END
+ FROM jsonb_to_recordset(COALESCE(title_langs, '[{"latin":true}]'))
+ AS x(lang language, latin bool, official bool, original bool)
+ ), t (rank, lang, latin, official) AS (
+ -- Filter, remove duplicates and re-rank
+ SELECT CASE WHEN lang IS NULL THEN NULL ELSE row_number() OVER(ORDER BY rank) END, lang, latin, official
+ FROM t_parsed x
+ WHERE rank <= COALESCE((SELECT MIN(rank) FROM t_parsed WHERE lang IS NULL), 10)
+ AND NOT EXISTS(SELECT 1 FROM t_parsed y WHERE x.lang = y.lang AND y.rank < x.rank AND y.prio <= x.prio)
+
+ -- Same, for alttitle
+ ), a_parsed (rank, lang, latin, prio, official) AS (
+ SELECT row_number() OVER(ROWS CURRENT ROW), lang, COALESCE(latin, false)
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN 3 WHEN official IS NOT DISTINCT FROM true THEN 2 ELSE 1 END
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN NULL ELSE COALESCE(official, false) END
+ FROM jsonb_to_recordset(alttitle_langs)
+ AS x(lang language, latin bool, official bool, original bool)
+ ), a (rank, lang, latin, official) AS (
+ SELECT CASE WHEN lang IS NULL THEN NULL ELSE row_number() OVER(ORDER BY rank) END, lang, latin, official
+ FROM a_parsed x
+ WHERE rank <= COALESCE((SELECT MIN(rank) FROM a_parsed WHERE lang IS NULL), 10)
+ AND NOT EXISTS(SELECT 1 FROM a_parsed y WHERE x.lang = y.lang AND y.rank < x.rank AND y.prio <= x.prio)
+
+ ) SELECT ROW(
+ (SELECT lang FROM t WHERE rank = 1)
+ , (SELECT lang FROM t WHERE rank = 2)
+ , (SELECT lang FROM t WHERE rank = 3)
+ , (SELECT lang FROM t WHERE rank = 4)
+ , (SELECT lang FROM a WHERE rank = 1)
+ , (SELECT lang FROM a WHERE rank = 2)
+ , (SELECT lang FROM a WHERE rank = 3)
+ , (SELECT lang FROM a WHERE rank = 4)
+ , COALESCE((SELECT latin FROM t WHERE rank = 1), false)
+ , COALESCE((SELECT latin FROM t WHERE rank = 2), false)
+ , COALESCE((SELECT latin FROM t WHERE rank = 3), false)
+ , COALESCE((SELECT latin FROM t WHERE rank = 4), false)
+ , COALESCE((SELECT latin FROM t WHERE lang IS NULL), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 1), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 2), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 3), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 4), false)
+ , COALESCE((SELECT latin FROM a WHERE lang IS NULL), false)
+ , (SELECT official FROM t WHERE rank = 1)
+ , (SELECT official FROM t WHERE rank = 2)
+ , (SELECT official FROM t WHERE rank = 3)
+ , (SELECT official FROM t WHERE rank = 4)
+ , (SELECT official FROM a WHERE rank = 1)
+ , (SELECT official FROM a WHERE rank = 2)
+ , (SELECT official FROM a WHERE rank = 3)
+ , (SELECT official FROM a WHERE rank = 4)
+ )::titleprefs
+$$ LANGUAGE SQL IMMUTABLE;
+
+
+ALTER TABLE users_prefs ADD COLUMN titles titleprefs;
+UPDATE users_prefs SET titles = json2titleprefs(title_langs, alttitle_langs) WHERE title_langs IS NOT NULL OR alttitle_langs IS NOT NULL;
diff --git a/util/updates/2023-02-02-sql-titleprefs.sql b/util/updates/2023-02-02-sql-titleprefs.sql
new file mode 100644
index 00000000..73f7c6de
--- /dev/null
+++ b/util/updates/2023-02-02-sql-titleprefs.sql
@@ -0,0 +1,5 @@
+CREATE TYPE item_info_type AS (title text, alttitle text, uid vndbid, hidden boolean, locked boolean);
+\i sql/func.sql
+
+-- Can be dropped after reloading all code.
+--DROP FUNCTION item_info(vndbid, int);
diff --git a/util/updates/2023-02-04-producerst.sql b/util/updates/2023-02-04-producerst.sql
new file mode 100644
index 00000000..ee5804a9
--- /dev/null
+++ b/util/updates/2023-02-04-producerst.sql
@@ -0,0 +1,15 @@
+ALTER TABLE producers ALTER COLUMN original DROP NOT NULL;
+ALTER TABLE producers ALTER COLUMN original DROP DEFAULT;
+ALTER TABLE producers_hist ALTER COLUMN original DROP NOT NULL;
+ALTER TABLE producers_hist ALTER COLUMN original DROP DEFAULT;
+UPDATE producers SET original = NULL WHERE original = '';
+UPDATE producers_hist SET original = NULL WHERE original = '';
+
+CREATE VIEW producerst AS
+ SELECT id, type, lang, l_wikidata, locked, hidden, alias, website, "desc", l_wp, c_search
+ , name, original AS altname, name AS sortname
+ FROM producers;
+
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-02-19-title-langs.sql b/util/updates/2023-02-19-title-langs.sql
new file mode 100644
index 00000000..62510e2b
--- /dev/null
+++ b/util/updates/2023-02-19-title-langs.sql
@@ -0,0 +1,5 @@
+DROP TYPE item_info_type CASCADE;
+DROP VIEW vnt, releasest, producerst CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-02-20-titleprefs-staff.sql b/util/updates/2023-02-20-titleprefs-staff.sql
new file mode 100644
index 00000000..b7e3047e
--- /dev/null
+++ b/util/updates/2023-02-20-titleprefs-staff.sql
@@ -0,0 +1,18 @@
+ALTER TABLE staff_alias ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+ALTER TABLE staff_alias_hist ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+UPDATE staff_alias SET original = null WHERE original = '';
+UPDATE staff_alias_hist SET original = null WHERE original = '';
+
+CREATE VIEW staff_aliast AS
+ -- Everything from 'staff', except 'aid' is renamed to 'main'
+ SELECT s.id, s.gender, s.lang, s.l_anidb, s.l_wikidata, s.l_pixiv, s.locked, s.hidden, s."desc", s.l_wp, s.l_site, s.l_twitter, s.aid AS main
+ , sa.aid, sa.name, sa.original
+ , ARRAY [ s.lang::text, sa.name
+ , s.lang::text, COALESCE(sa.original, sa.name) ] AS title
+ , sa.name AS sorttitle
+ FROM staff s
+ JOIN staff_alias sa ON sa.id = s.id;
+
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-02-21-tt-prefs.sql b/util/updates/2023-02-21-tt-prefs.sql
new file mode 100644
index 00000000..24d527e0
--- /dev/null
+++ b/util/updates/2023-02-21-tt-prefs.sql
@@ -0,0 +1,7 @@
+ALTER TABLE users_prefs_tags ALTER COLUMN spoil DROP NOT NULL;
+ALTER TABLE users_prefs_tags ADD COLUMN color text;
+ALTER TABLE users_prefs_traits ALTER COLUMN spoil DROP NOT NULL;
+ALTER TABLE users_prefs_traits ADD COLUMN color text;
+
+UPDATE users_prefs_tags SET spoil = 0, color = 'standout' WHERE spoil = -1;
+UPDATE users_prefs_traits SET spoil = 0, color = 'standout' WHERE spoil = -1;
diff --git a/util/updates/2023-03-09-chars-lang.sql b/util/updates/2023-03-09-chars-lang.sql
new file mode 100644
index 00000000..dcfffa0d
--- /dev/null
+++ b/util/updates/2023-03-09-chars-lang.sql
@@ -0,0 +1,10 @@
+ALTER TABLE chars ADD COLUMN c_lang language NOT NULL DEFAULT 'ja';
+
+WITH x(id,lang) AS (
+ SELECT DISTINCT ON (cv.id) cv.id, v.olang
+ FROM chars_vns cv
+ JOIN vn v ON v.id = cv.vid
+ ORDER BY cv.id, v.hidden, v.c_released
+) UPDATE chars c SET c_lang = x.lang FROM x WHERE c.id = x.id AND c.c_lang <> x.lang;
+
+\i sql/func.sql
diff --git a/util/updates/2023-03-09b-chars-titleprefs.sql b/util/updates/2023-03-09b-chars-titleprefs.sql
new file mode 100644
index 00000000..c78a62d6
--- /dev/null
+++ b/util/updates/2023-03-09b-chars-titleprefs.sql
@@ -0,0 +1,14 @@
+ALTER TABLE chars ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+ALTER TABLE chars_hist ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+UPDATE chars SET original = NULL WHERE original = '';
+UPDATE chars_hist SET original = NULL WHERE original = '';
+
+CREATE VIEW charst AS
+ SELECT *
+ , ARRAY [ c_lang::text, name
+ , c_lang::text, COALESCE(original, name) ] AS title
+ , name AS sorttitle
+ FROM chars;
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-03-20-producer-name-swap.sql b/util/updates/2023-03-20-producer-name-swap.sql
new file mode 100644
index 00000000..bf04fb12
--- /dev/null
+++ b/util/updates/2023-03-20-producer-name-swap.sql
@@ -0,0 +1,13 @@
+ALTER TABLE producers RENAME COLUMN original TO latin;
+ALTER TABLE producers_hist RENAME COLUMN original TO latin;
+
+UPDATE producers SET name = latin, latin = name WHERE latin IS NOT NULL;
+UPDATE producers_hist SET name = latin, latin = name WHERE latin IS NOT NULL;
+
+DROP FUNCTION titleprefs_swap(titleprefs, language, text, text);
+DROP VIEW producerst CASCADE;
+
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-03-20b-chars-name-swap.sql b/util/updates/2023-03-20b-chars-name-swap.sql
new file mode 100644
index 00000000..952aba80
--- /dev/null
+++ b/util/updates/2023-03-20b-chars-name-swap.sql
@@ -0,0 +1,12 @@
+ALTER TABLE chars RENAME COLUMN original TO latin;
+ALTER TABLE chars_hist RENAME COLUMN original TO latin;
+
+UPDATE chars SET name = latin, latin = name WHERE latin IS NOT NULL;
+UPDATE chars_hist SET name = latin, latin = name WHERE latin IS NOT NULL;
+
+DROP VIEW charst CASCADE;
+
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-03-20c-staff-name-swap.sql b/util/updates/2023-03-20c-staff-name-swap.sql
new file mode 100644
index 00000000..c2474d2f
--- /dev/null
+++ b/util/updates/2023-03-20c-staff-name-swap.sql
@@ -0,0 +1,14 @@
+ALTER TABLE staff_alias RENAME COLUMN original TO latin;
+ALTER TABLE staff_alias_hist RENAME COLUMN original TO latin;
+
+UPDATE staff_alias SET name = latin, latin = name WHERE latin IS NOT NULL;
+UPDATE staff_alias_hist SET name = latin, latin = name WHERE latin IS NOT NULL;
+
+DROP VIEW staff_aliast CASCADE;
+
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+DROP FUNCTION titleprefs_swapold(titleprefs, language, text, text);
diff --git a/util/updates/2023-03-24-search-cache.sql b/util/updates/2023-03-24-search-cache.sql
new file mode 100644
index 00000000..f72034cf
--- /dev/null
+++ b/util/updates/2023-03-24-search-cache.sql
@@ -0,0 +1,44 @@
+-- Part one, can be done while the site is running old code
+
+CREATE EXTENSION pg_trgm;
+
+CREATE TABLE search_cache (
+ id vndbid NOT NULL,
+ subid integer, -- only for staff_alias.id at the moment
+ prio smallint NOT NULL, -- 1 for indirect titles, 2 for aliases, 3 for main titles
+ label text NOT NULL COLLATE "C"
+) PARTITION BY RANGE(id);
+
+CREATE TABLE search_cache_v PARTITION OF search_cache FOR VALUES FROM ('v1') TO (vndbid_max('v'));
+CREATE TABLE search_cache_r PARTITION OF search_cache FOR VALUES FROM ('r1') TO (vndbid_max('r'));
+CREATE TABLE search_cache_c PARTITION OF search_cache FOR VALUES FROM ('c1') TO (vndbid_max('c'));
+CREATE TABLE search_cache_p PARTITION OF search_cache FOR VALUES FROM ('p1') TO (vndbid_max('p'));
+CREATE TABLE search_cache_s PARTITION OF search_cache FOR VALUES FROM ('s1') TO (vndbid_max('s'));
+CREATE TABLE search_cache_g PARTITION OF search_cache FOR VALUES FROM ('g1') TO (vndbid_max('g'));
+CREATE TABLE search_cache_i PARTITION OF search_cache FOR VALUES FROM ('i1') TO (vndbid_max('i'));
+
+CREATE INDEX search_cache_id ON search_cache (id);
+CREATE INDEX search_cache_label ON search_cache USING GIN (label gin_trgm_ops);
+
+\i sql/perms.sql
+\i sql/func.sql
+\i sql/rebuild-search-cache.sql
+
+
+-- Part two, can be done after the site has been reloaded with the new code
+
+ALTER TABLE chars DROP COLUMN c_search CASCADE;
+ALTER TABLE producers DROP COLUMN c_search CASCADE;
+ALTER TABLE releases DROP COLUMN c_search CASCADE;
+ALTER TABLE staff_alias DROP COLUMN c_search CASCADE;
+ALTER TABLE tags DROP COLUMN c_search CASCADE;
+ALTER TABLE traits DROP COLUMN c_search CASCADE;
+ALTER TABLE vn DROP COLUMN c_search CASCADE;
+
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+DROP FUNCTION search_gen_vn(vndbid);
+DROP FUNCTION search_gen_release(vndbid);
+DROP FUNCTION search_gen(text[]);
diff --git a/util/updates/2023-04-03-extlinks-booth.sql b/util/updates/2023-04-03-extlinks-booth.sql
new file mode 100644
index 00000000..7185b289
--- /dev/null
+++ b/util/updates/2023-04-03-extlinks-booth.sql
@@ -0,0 +1,57 @@
+ALTER TABLE releases ADD COLUMN l_booth integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_booth integer NOT NULL DEFAULT 0;
+\i sql/editfunc.sql
+
+DROP VIEW releasest CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+
+-- Extract from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_booth(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_booth = regexp_replace(website, '^https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+).*', '\1')::int, website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to BOOTH link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_website_to_booth(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)';
+DROP FUNCTION migrate_website_to_booth(vndbid);
+
+
+
+-- Extract from notes in "Available at .." format
+CREATE OR REPLACE FUNCTION migrate_notes_to_booth(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET
+ l_booth = regexp_replace(notes, '^.*\s*(?:Also available|Available) (?:on|at|from) \[url=https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*).*$', '\1', 'i')::int,
+ notes = regexp_replace(notes, '\s*(?:Also available|Available) (?:on|at|from) \[url=https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)', '', 'i');
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of BOOTH link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_notes_to_booth(id) FROM releases WHERE NOT hidden AND l_booth = 0
+ AND notes ~* '\s*(?:Also available|Available) (?:on|at|from) \[url=https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)'
+ AND id <> 'r104675';
+DROP FUNCTION migrate_notes_to_booth(vndbid);
+
+
+
+-- Extract from notes when it's the only thing in the note
+CREATE OR REPLACE FUNCTION migrate_notes_to_booth2(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_booth = regexp_replace(notes, '^(?:booth|available on)?:?\s*(?:\[url=)?https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)(?:\][^\[]*\[/url\])?\.?$', '\1', 'i')::int, notes = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of BOOTH link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_notes_to_booth2(id) FROM releases WHERE NOT hidden AND l_booth = 0
+ AND notes ~* '^(?:booth|available on)?:?\s*(?:\[url=)?https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)(?:\][^\[]*\[/url\])?\.?$';
+DROP FUNCTION migrate_notes_to_booth2(vndbid);
+
+
+-- select 'https://vndb.org/'||id, title[2] from releasest where not hidden and notes like '%booth.pm%' order by id;
diff --git a/util/updates/2023-04-05-extlinks-patreon-substar.sql b/util/updates/2023-04-05-extlinks-patreon-substar.sql
new file mode 100644
index 00000000..1699c924
--- /dev/null
+++ b/util/updates/2023-04-05-extlinks-patreon-substar.sql
@@ -0,0 +1,102 @@
+ALTER TABLE releases
+ ADD COLUMN l_patreonp integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_patreon text NOT NULL DEFAULT '',
+ ADD COLUMN l_substar text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist
+ ADD COLUMN l_patreonp integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_patreon text NOT NULL DEFAULT '',
+ ADD COLUMN l_substar text NOT NULL DEFAULT '';
+\i sql/editfunc.sql
+
+DROP VIEW releasest CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+
+
+-- patreonp from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_patreonp(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_patreonp = regexp_replace(website, '^https?://(?:www\.)?patreon\.com/posts/(?:[^/?]+-)?([0-9]+).*$', '\1')::int, website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to Patreon link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_website_to_patreonp(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:www\.)?patreon\.com/posts/(?:[^/?]+-)?([0-9]+)') x;
+DROP FUNCTION migrate_website_to_patreonp(vndbid);
+
+
+
+-- patreon from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_patreon(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_patreon = regexp_replace(website, '^https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+).*$', '\1'), website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to Patreon link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_website_to_patreon(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)') x;
+DROP FUNCTION migrate_website_to_patreon(vndbid);
+
+
+
+
+-- patreon from notes field
+CREATE OR REPLACE FUNCTION migrate_notes_to_patreon(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET
+ l_patreon = regexp_replace(notes, '^.*\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*).*$', '\1', 'i'),
+ notes = regexp_replace(notes, '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)', '', 'i');
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of Patreon link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_notes_to_patreon(id) FROM releases WHERE NOT hidden AND l_patreon = ''
+ AND notes ~* '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)'
+ AND id NOT IN('r55516', 'r54903', 'r50178')
+) x;
+DROP FUNCTION migrate_notes_to_patreon(vndbid);
+
+
+
+
+-- substar from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_substar(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_substar = regexp_replace(website, '^https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+).*$', '\1'), website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to SubscribeStar link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_website_to_substar(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)') x;
+DROP FUNCTION migrate_website_to_substar(vndbid);
+
+
+
+
+-- substar from notes field
+CREATE OR REPLACE FUNCTION migrate_notes_to_substar(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET
+ l_substar = regexp_replace(notes, '^.*\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*).*$', '\1', 'i'),
+ notes = regexp_replace(notes, '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)', '', 'i');
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of SubscribeStar link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_notes_to_substar(id) FROM releases WHERE NOT hidden AND l_substar = ''
+ AND notes ~* '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)'
+) x;
+DROP FUNCTION migrate_notes_to_substar(vndbid);
+
+
+
+--select 'https://vndb.org/'||id, title[2], website from releasest where not hidden and website like 'https://www.patreon.com%' order by id;
+--select 'https://vndb.org/'||id, title[2] from releasest where not hidden and notes like '%https://www.patreon.com%' order by id;
+--select 'https://vndb.org/'||id, title[2] from releasest where not hidden and notes like '%subscribestar%' order by id;
diff --git a/util/updates/2023-04-19-images-uploader.sql b/util/updates/2023-04-19-images-uploader.sql
new file mode 100644
index 00000000..c6255775
--- /dev/null
+++ b/util/updates/2023-04-19-images-uploader.sql
@@ -0,0 +1,29 @@
+ALTER TABLE images ADD COLUMN uploader vndbid;
+ALTER TABLE images ADD CONSTRAINT images_uploader_fkey FOREIGN KEY (uploader) REFERENCES users (id) ON DELETE SET DEFAULT;
+
+
+-- Attempt to find the original uploader of an image by finding the first
+-- change that references it.
+WITH cv (id, uid) AS (
+ SELECT DISTINCT ON (v.image) v.image, c.requester
+ FROM vn_hist v
+ JOIN changes c ON c.id = v.chid
+ WHERE v.image IS NOT NULL AND c.requester IS NOT NULL AND c.requester <> 'u1'
+ ORDER BY v.image, v.chid
+) UPDATE images SET uploader = uid FROM cv WHERE uploader IS NULL AND cv.id = images.id;
+
+WITH sf (id, uid) AS (
+ SELECT DISTINCT ON (v.scr) v.scr, c.requester
+ FROM vn_screenshots_hist v
+ JOIN changes c ON c.id = v.chid
+ WHERE c.requester IS NOT NULL AND c.requester <> 'u1'
+ ORDER BY v.scr, v.chid
+) UPDATE images SET uploader = uid FROM sf WHERE uploader IS NULL AND sf.id = images.id;
+
+WITH ch (id, uid) AS (
+ SELECT DISTINCT ON (v.image) v.image, c.requester
+ FROM chars_hist v
+ JOIN changes c ON c.id = v.chid
+ WHERE v.image IS NOT NULL AND c.requester IS NOT NULL AND c.requester <> 'u1'
+ ORDER BY v.image, v.chid
+) UPDATE images SET uploader = uid FROM ch WHERE uploader IS NULL AND ch.id = images.id;
diff --git a/util/updates/2023-04-19-jastusa-shoplinks.sql b/util/updates/2023-04-19-jastusa-shoplinks.sql
new file mode 100644
index 00000000..7d93fe9e
--- /dev/null
+++ b/util/updates/2023-04-19-jastusa-shoplinks.sql
@@ -0,0 +1,8 @@
+CREATE TABLE shop_jastusa (
+ lastfetch timestamptz,
+ deadsince timestamptz,
+ id text NOT NULL PRIMARY KEY,
+ price text NOT NULL DEFAULT '',
+ slug text NOT NULL DEFAULT ''
+);
+\i sql/perms.sql
diff --git a/util/updates/2023-05-03-sql-noquote.sql b/util/updates/2023-05-03-sql-noquote.sql
new file mode 100644
index 00000000..9a8168a0
--- /dev/null
+++ b/util/updates/2023-05-03-sql-noquote.sql
@@ -0,0 +1,23 @@
+ALTER TABLE chars RENAME COLUMN "desc" TO description;
+ALTER TABLE chars_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE producers RENAME COLUMN "desc" TO description;
+ALTER TABLE producers_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE staff RENAME COLUMN "desc" TO description;
+ALTER TABLE staff_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE vn RENAME COLUMN "desc" TO description;
+ALTER TABLE vn_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE traits RENAME COLUMN "group" TO gid;
+ALTER TABLE traits RENAME COLUMN "order" TO gorder;
+ALTER TABLE traits_hist RENAME COLUMN "order" TO gorder;
+
+ALTER TABLE traits DROP CONSTRAINT traits_group_fkey;
+ALTER TABLE traits ADD CONSTRAINT traits_gid_fkey FOREIGN KEY (gid) REFERENCES traits (id);
+
+DROP VIEW charst CASCADE;
+DROP VIEW producerst CASCADE;
+DROP VIEW staff_aliast CASCADE;
+DROP VIEW vnt CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-06-19-tags-vn-direct-count.sql b/util/updates/2023-06-19-tags-vn-direct-count.sql
new file mode 100644
index 00000000..d784725c
--- /dev/null
+++ b/util/updates/2023-06-19-tags-vn-direct-count.sql
@@ -0,0 +1,4 @@
+ALTER TABLE tags_vn_direct ADD COLUMN count smallint NOT NULL DEFAULT 0;
+\i sql/func.sql
+SELECT tag_vn_calc(NULL);
+ALTER TABLE tags_vn_direct ALTER COLUMN count DROP DEFAULT;
diff --git a/util/updates/2023-07-11-vn-rating.sql b/util/updates/2023-07-11-vn-rating.sql
new file mode 100644
index 00000000..9996df88
--- /dev/null
+++ b/util/updates/2023-07-11-vn-rating.sql
@@ -0,0 +1,8 @@
+DROP VIEW vnt CASCADE;
+ALTER TABLE vn DROP COLUMN c_popularity;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+-- Twice, to stabilize the "top50" variable.
+SELECT update_vnvotestats();
+SELECT update_vnvotestats();
diff --git a/util/updates/2023-09-15-quotes-rand.sql b/util/updates/2023-09-15-quotes-rand.sql
new file mode 100644
index 00000000..001f0770
--- /dev/null
+++ b/util/updates/2023-09-15-quotes-rand.sql
@@ -0,0 +1,37 @@
+BEGIN;
+ALTER TABLE quotes
+ DROP CONSTRAINT quotes_pkey,
+ DROP CONSTRAINT quotes_vid_fkey;
+ALTER TABLE quotes RENAME TO quotes_old;
+
+CREATE TABLE quotes (
+ vid vndbid NOT NULL,
+ rand real,
+ approved boolean NOT NULL DEFAULT FALSE,
+ quote text NOT NULL,
+ PRIMARY KEY(vid, quote)
+);
+
+INSERT INTO quotes SELECT vid, NULL, TRUE, quote FROM quotes_old;
+
+ALTER TABLE quotes ADD CONSTRAINT quotes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+CREATE INDEX quotes_rand ON quotes (rand) WHERE rand IS NOT NULL;
+
+CREATE OR REPLACE FUNCTION quotes_rand_calc() RETURNS void AS $$
+ WITH q(vid,quote) AS (
+ SELECT vid, quote FROM quotes q WHERE approved AND EXISTS(SELECT 1 FROM vn v WHERE v.id = q.vid AND NOT v.hidden)
+ ), r(vid,quote,rand) AS (
+ SELECT vid, quote,
+ -- 'rand' is chosen such that each VN has an equal probability to be selected, regardless of how many quotes it has.
+ ((dense_rank() OVER (ORDER BY vid)) - 1)::real / (SELECT COUNT(DISTINCT vid) FROM q) +
+ (percent_rank() OVER (PARTITION BY vid ORDER BY quote)) / (SELECT COUNT(DISTINCT vid)+1 FROM q)
+ FROM q
+ ), u AS (
+ UPDATE quotes SET rand = NULL WHERE NOT EXISTS(SELECT 1 FROM r WHERE quotes.vid = r.vid AND quotes.quote = r.quote)
+ ) UPDATE quotes SET rand = r.rand FROM r WHERE r.vid = quotes.vid AND r.quote = quotes.quote
+$$ LANGUAGE SQL;
+
+SELECT quotes_rand_calc();
+COMMIT;
+
+\i sql/perms.sql
diff --git a/util/updates/2023-09-17-wikidata-props.sql b/util/updates/2023-09-17-wikidata-props.sql
new file mode 100644
index 00000000..1e58f42a
--- /dev/null
+++ b/util/updates/2023-09-17-wikidata-props.sql
@@ -0,0 +1,3 @@
+ALTER TABLE wikidata
+ ADD COLUMN lutris text[],
+ ADD COLUMN wine integer[];
diff --git a/util/updates/2023-09-21-reset-throttle.sql b/util/updates/2023-09-21-reset-throttle.sql
new file mode 100644
index 00000000..3557f66e
--- /dev/null
+++ b/util/updates/2023-09-21-reset-throttle.sql
@@ -0,0 +1,5 @@
+CREATE TABLE reset_throttle (
+ ip inet NOT NULL PRIMARY KEY,
+ timeout timestamptz NOT NULL
+);
+\i sql/perms.sql
diff --git a/util/updates/2023-10-14-drm.sql b/util/updates/2023-10-14-drm.sql
new file mode 100644
index 00000000..d7c55982
--- /dev/null
+++ b/util/updates/2023-10-14-drm.sql
@@ -0,0 +1,7 @@
+\i sql/schema.sql
+\i sql/tableattrs.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+INSERT INTO drm VALUES (0, 0, 0, false, false, false, false, false, false, false, false, 'DRM-free', 'This release is available without DRM.');
diff --git a/util/updates/2023-12-03-staff-aid.sql b/util/updates/2023-12-03-staff-aid.sql
new file mode 100644
index 00000000..f6deb3e6
--- /dev/null
+++ b/util/updates/2023-12-03-staff-aid.sql
@@ -0,0 +1,11 @@
+ALTER TABLE staff RENAME COLUMN aid TO main;
+ALTER TABLE staff_hist RENAME COLUMN aid TO main;
+
+ALTER TABLE staff DROP CONSTRAINT staff_aid_fkey;
+ALTER TABLE staff ADD CONSTRAINT staff_main_fkey FOREIGN KEY (main) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
+
+DROP VIEW staff_aliast CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-12-03-staff-extlinks.sql b/util/updates/2023-12-03-staff-extlinks.sql
new file mode 100644
index 00000000..96ef0501
--- /dev/null
+++ b/util/updates/2023-12-03-staff-extlinks.sql
@@ -0,0 +1,24 @@
+ALTER TABLE staff
+ ADD COLUMN l_vgmdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_discogs integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_mobygames integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_bgmtv integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_imdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_vndb vndbid,
+ ADD COLUMN l_mbrainz uuid,
+ ADD COLUMN l_scloud text NOT NULL DEFAULT '';
+ALTER TABLE staff_hist
+ ADD COLUMN l_vgmdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_discogs integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_mobygames integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_bgmtv integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_imdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_vndb vndbid,
+ ADD COLUMN l_mbrainz uuid,
+ ADD COLUMN l_scloud text NOT NULL DEFAULT '';
+
+DROP VIEW staff_aliast CASCADE;
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2024-02-23-quotes.sql b/util/updates/2024-02-23-quotes.sql
new file mode 100644
index 00000000..810a2bec
--- /dev/null
+++ b/util/updates/2024-02-23-quotes.sql
@@ -0,0 +1,89 @@
+BEGIN;
+
+CREATE TABLE quotes_tmp (
+ id serial PRIMARY KEY,
+ vid vndbid NOT NULL,
+ cid vndbid,
+ addedby vndbid,
+ rand real,
+ score smallint NOT NULL DEFAULT 0,
+ state smallint NOT NULL DEFAULT 0,
+ quote text NOT NULL
+);
+
+CREATE TABLE quotes_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ uid vndbid,
+ action text NOT NULL
+);
+
+CREATE TABLE quotes_votes (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ uid vndbid NOT NULL,
+ vote smallint NOT NULL,
+ PRIMARY KEY(id, uid)
+);
+
+WITH s (date, uid, vid, quote) AS (
+ SELECT DISTINCT ON (detail) date, by_uid, regexp_replace(detail, '^([^ ]+): .+$', '\1', '')::vndbid, regexp_replace(detail, '^[^ ]+: (.+)$', '\1', '')
+ FROM audit_log a
+ WHERE action = 'submit quote'
+ AND EXISTS(SELECT 1 FROM users WHERE id = by_uid)
+ ORDER BY detail, date
+), q AS (
+ INSERT INTO quotes_tmp (vid, rand, addedby, state, quote, score)
+SELECT q.vid, q.rand, s.uid, CASE WHEN q.approved THEN 1 ELSE 0 END, q.quote, 1
+ FROM quotes q
+ LEFT JOIN s ON s.vid = q.vid AND s.quote = q.quote
+ ORDER BY s.date NULLS FIRST
+ RETURNING id, vid, quote
+), l AS (
+ INSERT INTO quotes_log
+ SELECT COALESCE(s.date, '2023-09-15 12:00 UTC'), q.id, s.uid, CASE WHEN s.uid IS NULL THEN 'Added to the database before the submission form existed' ELSE 'Submitted' END
+ FROM q LEFT JOIN s ON s.vid = q.vid AND s.quote = q.quote
+ RETURNING date, id, uid
+) INSERT INTO quotes_votes
+ SELECT date, id, COALESCE(uid, 'u1'), 1 FROM l;
+
+
+DROP TABLE quotes;
+ALTER TABLE quotes_tmp RENAME TO quotes;
+ALTER INDEX quotes_tmp_pkey RENAME TO quotes_pkey;
+ALTER SEQUENCE quotes_tmp_id_seq RENAME TO quotes_id_seq;
+
+
+CREATE INDEX quotes_rand ON quotes (rand) WHERE rand IS NOT NULL;
+CREATE INDEX quotes_vid ON quotes (vid);
+CREATE INDEX quotes_log_id ON quotes_log (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_cid_fkey FOREIGN KEY (cid) REFERENCES chars (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_addedby_fkey FOREIGN KEY (addedby) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+
+
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON quotes TO vndb_site;
+GRANT SELECT, INSERT ON quotes_log TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON quotes_votes TO vndb_site;
+GRANT SELECT, UPDATE ON quotes TO vndb_multi;
+
+
+CREATE OR REPLACE FUNCTION update_quotes_votes_cache() RETURNS trigger AS $$
+BEGIN
+ UPDATE quotes
+ SET score = (SELECT SUM(vote) FROM quotes_votes WHERE quotes_votes.id = quotes.id)
+ WHERE id IN(OLD.id, NEW.id);
+ RETURN NULL;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER quotes_votes_cache AFTER INSERT OR UPDATE OR DELETE ON quotes_votes FOR EACH ROW EXECUTE PROCEDURE update_quotes_votes_cache();
+
+COMMIT;
+
+\i sql/func.sql
diff --git a/util/updates/2024-02-26-quotes-adjustments.sql b/util/updates/2024-02-26-quotes-adjustments.sql
new file mode 100644
index 00000000..bbb09801
--- /dev/null
+++ b/util/updates/2024-02-26-quotes-adjustments.sql
@@ -0,0 +1,12 @@
+BEGIN;
+ALTER TABLE quotes
+ ADD COLUMN hidden boolean NOT NULL DEFAULT FALSE,
+ ADD COLUMN added timestamptz NOT NULL DEFAULT NOW();
+UPDATE quotes SET hidden = true WHERE state = 2;
+ALTER TABLE quotes DROP COLUMN state;
+
+CREATE INDEX quotes_addedby ON quotes (addedby);
+
+COMMIT;
+
+\i sql/func.sql
diff --git a/util/updates/2024-03-01-reports-log.sql b/util/updates/2024-03-01-reports-log.sql
new file mode 100644
index 00000000..51c7d6f2
--- /dev/null
+++ b/util/updates/2024-03-01-reports-log.sql
@@ -0,0 +1,14 @@
+CREATE TABLE reports_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ status report_status NOT NULL,
+ uid vndbid,
+ message text NOT NULL
+);
+
+CREATE INDEX reports_log_id ON reports_log (id);
+
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_id_fkey FOREIGN KEY (id) REFERENCES reports (id);
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+
+GRANT SELECT, INSERT ON reports_log TO vndb_site;
diff --git a/util/updates/2024-03-08-belarusian-language.sql b/util/updates/2024-03-08-belarusian-language.sql
new file mode 100644
index 00000000..23520558
--- /dev/null
+++ b/util/updates/2024-03-08-belarusian-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'be' AFTER 'ar';
diff --git a/util/updates/2024-03-14-sql-email-normalization.sql b/util/updates/2024-03-14-sql-email-normalization.sql
new file mode 100644
index 00000000..3e9eb93f
--- /dev/null
+++ b/util/updates/2024-03-14-sql-email-normalization.sql
@@ -0,0 +1,9 @@
+\i sql/util.sql
+
+DROP INDEX users_shadow_mail;
+CREATE INDEX users_shadow_mail ON users_shadow (hash_email(mail));
+
+DROP FUNCTION user_emailtoid(text);
+DROP FUNCTION user_resetpass(text, bytea);
+
+\i sql/func.sql
diff --git a/util/updates/2024-03-20-account-softdelete.sql b/util/updates/2024-03-20-account-softdelete.sql
new file mode 100644
index 00000000..1f1cb055
--- /dev/null
+++ b/util/updates/2024-03-20-account-softdelete.sql
@@ -0,0 +1,11 @@
+CREATE TABLE email_optout (
+ mail uuid, -- hash_email()
+ date timestamptz NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (mail)
+);
+
+ALTER TABLE users ALTER COLUMN username DROP NOT NULL;
+ALTER TABLE audit_log ALTER COLUMN by_ip DROP NOT NULL;
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2024-03-22-delayed-account-deletion.sql b/util/updates/2024-03-22-delayed-account-deletion.sql
new file mode 100644
index 00000000..8d55fc13
--- /dev/null
+++ b/util/updates/2024-03-22-delayed-account-deletion.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users_shadow ADD COLUMN delete_at timestamptz;
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/README.md b/util/updates/README.md
new file mode 100644
index 00000000..a6032c4b
--- /dev/null
+++ b/util/updates/README.md
@@ -0,0 +1,51 @@
+# SQL Update Scripts
+
+This directory contains scripts to keep the live database schema synchronized
+with the code in the git repo, in particular with the definitions in the `sql/`
+directory.
+
+## Naming scheme
+
+```sh
+`date +%F`-description.sql
+```
+
+The date is the date on which the script is applied to the production database.
+For work-in-progress updates where that date is not yet known, use a `wip-`
+prefix instead.
+
+(The older `update_{date}.sql` naming scheme is deprecated)
+
+## Applying the updates
+
+Do not blindly apply these scripts in order and expect them to work. Since the
+scripts were written for the sole purpose of updating the live production
+database - which only needs to happen once per update - I often take some
+shortcuts:
+
+- The scripts often directly import other scripts from `sql/`. Later changes to
+ files in `sql/` may break the update scripts, so generally the safest way to
+ apply a particular script is to find the latest commit where the script has
+ been edited, then do a checkout of that commit and run the script in that
+ context.
+- Always run `make` before running a script, it may rely on `sql/editfunc.sql`.
+- Not all changes get an update script. Sometimes just running `sql/func.sql`
+ is sufficient to apply a change. In rare cases an update requires a full dump
+ & reload using `util/dbdump.pl export-data`, such as changes to column order
+ (which I sometimes do around a PostgreSQL version upgrade since those can
+ benefit from a dump & reload anyway) or changes to the definition of an
+ important data type (`vndbid` in particular, but such changes should be very
+ rare).
+
+## Downtime
+
+I'm not consistent with respect to whether these scripts can be run without
+downtime. Most scripts work just fine while the site is up and running, others
+may require that the site is taken down for a few minutes.
+
+Likewise, some scripts will leave the database in a state that an already
+running process can't deal with. That may result in some 500 errors until the
+process is restarted with the new code.
+
+Scripts often contain comments regarding the above. They're worth reading
+before applying, in any case.
diff --git a/util/vndb-dev-server.pl b/util/vndb-dev-server.pl
index a7571e10..214b0ee0 100755
--- a/util/vndb-dev-server.pl
+++ b/util/vndb-dev-server.pl
@@ -15,6 +15,8 @@ use Cwd 'abs_path';
my $listen_port = $ENV{TUWF_HTTP_SERVER_PORT} || 3000;
$ENV{TUWF_HTTP_SERVER_PORT} = $listen_port+1;
+$ENV{VNDB_VAR} //= 'var';
+
my($pid, $prog, $killed);
sub prog_start {
@@ -58,7 +60,7 @@ sub make_run {
print "\n" if !$newline++;
print $d;
};
- my $cb = run_cmd "cd $ROOT && make", '>', $out, '2>', $out;
+ my $cb = run_cmd "cd $ROOT && make -j4", '>', $out, '2>', $out;
$cb->recv;
print "\n" if $newline;
}
@@ -100,10 +102,8 @@ sub checkmod {
}, "$ROOT/lib";
chdir $ROOT;
- $check->($_) for (qw{
- util/vndb.pl
- data/conf.pl
- });
+ $check->('util/vndb.pl');
+ $check->("$ENV{VNDB_VAR}/conf.pl");
my $ismod = $newlastmod > $lastmod;
$lastmod = $newlastmod;
diff --git a/util/vndb.pl b/util/vndb.pl
index f7c94044..6690f4c9 100755
--- a/util/vndb.pl
+++ b/util/vndb.pl
@@ -1,9 +1,17 @@
#!/usr/bin/perl
+# Usage:
+# vndb.pl # Run from the CLI to get a webserver, or spawn from CGI/FastCGI
+# vndb.pl noapi # Same, but disable /api/ calls
+# vndb.pl onlyapi # Same, but disable everything but /api/ calls
+#
+# vndb.pl elmgen # Generate Elm files and quit
+
use v5.24;
use warnings;
use Cwd 'abs_path';
-use TUWF ':html_';
+use JSON::XS;
+use TUWF ':html5_';
use Time::HiRes 'time';
$|=1; # Disable buffering on STDOUT, otherwise vndb-dev-server.pl won't pick up our readyness notification.
@@ -14,16 +22,27 @@ $|=1; # Disable buffering on STDOUT, otherwise vndb-dev-server.pl won't pick up
BEGIN { $ENV{PERL_ANYEVENT_MODEL} = 'Perl'; }
-my $ROOT;
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/vndb\.pl$}{}; }
+our($ROOT, $NOAPI, $ONLYAPI);
+BEGIN {
+ ($ROOT = abs_path $0) =~ s{/util/vndb\.pl$}{};
+ ($NOAPI) = grep $_ eq 'noapi', @ARGV;
+ ($ONLYAPI) = grep $_ eq 'onlyapi', @ARGV;
+}
use lib $ROOT.'/lib';
use 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.
@@ -31,12 +50,22 @@ tuwf->{elmgen} = $ARGV[0] && $ARGV[0] eq 'elmgen';
TUWF::hook before => sub {
- # If we're running standalone, serve www/ and static/ too.
- if(tuwf->{_TUWF}{http}) {
- if(tuwf->resFile("$ROOT/www", tuwf->reqPath) || tuwf->resFile("$ROOT/static", tuwf->reqPath)) {
- tuwf->resHeader('Cache-Control' => 'max-age=31536000');
- tuwf->done;
- }
+ return if VNWeb::Validation::is_api;
+
+ # Serve static files from www/
+ if(tuwf->resFile(config->{var_path}.'/www', tuwf->reqPath)) {
+ tuwf->resHeader('Cache-Control' => 'max-age=86400');
+ tuwf->done;
+ }
+
+ # If we're running standalone, serve static/ too.
+ if(tuwf->{_TUWF}{http} && (
+ tuwf->resFile(config->{var_path}.'/static', tuwf->reqPath) ||
+ tuwf->resFile(config->{gen_path}.'/static', tuwf->reqPath) ||
+ tuwf->resFile("$ROOT/static", tuwf->reqPath)
+ )) {
+ tuwf->resHeader('Cache-Control' => 'max-age=31536000');
+ tuwf->done;
}
# Use a 'SameSite=Strict' cookie to determine whether this page was loaded from internal or external.
@@ -44,13 +73,26 @@ TUWF::hook before => sub {
tuwf->resCookie(samesite => 1, httponly => 1, samesite => 'Strict') if !VNWeb::Validation::samesite;
tuwf->req->{trace_start} = time if config->{trace_log};
+} if !$ONLYAPI;
+
+
+# Provide a default /robots.txt
+TUWF::get '/robots.txt', sub {
+ tuwf->resHeader('Content-Type' => 'text/plain');
+ lit_ "User-agent: *\nDisallow: /\n";
};
+TUWF::set error_400_handler => sub {
+ return eval { VNWeb::API::err(400, 'Invalid request (most likely: invalid JSON or non-UTF8 data).') } if VNWeb::Validation::is_api;
+ TUWF::_error_400();
+};
+
TUWF::set error_404_handler => sub {
+ return eval { VNWeb::API::err(404, 'Not found.') } if VNWeb::Validation::is_api;
tuwf->resStatus(404);
VNWeb::HTML::framework_ title => 'Page Not Found', noindex => 1, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Page not found';
div_ class => 'warning', sub {
h2_ 'Oops!';
@@ -63,11 +105,16 @@ TUWF::set error_404_handler => sub {
}
};
+TUWF::set error_500_handler => sub {
+ return eval { VNWeb::API::err(500, 'Internal server error. Can be temporary, but usually points to a server bug.') } if VNWeb::Validation::is_api;
+ TUWF::_error_500();
+};
+
sub TUWF::Object::resDenied {
tuwf->resStatus(403);
VNWeb::HTML::framework_ title => 'Access Denied', noindex => 1, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Access Denied';
div_ class => 'warning', sub {
if(!auth) {
@@ -79,6 +126,8 @@ sub TUWF::Object::resDenied {
a_ href => '/u/register', 'create an account';
txt_ " if you don't have one yet.";
}
+ } elsif(VNWeb::DB::global_settings()->{lockdown_edit} || VNWeb::DB::global_settings()->{lockdown_board}) {
+ h2_ 'The database is in temporary lockdown.';
} else {
h2_ 'You are not allowed to perform this action.';
p_ 'You do not have the proper rights to perform the action you wanted to perform.';
@@ -89,33 +138,36 @@ sub TUWF::Object::resDenied {
}
-# Intercept TUWF::any() and TUWF::register() to figure out which module is processing the request.
-if(config->{trace_log}) {
+# 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) = caller($i++)) {
- if($pack !~ '^(?:main|TUWF|VNWeb::Elm)') {
- $loc = [$pack,$line];
- last;
- }
+ 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->(@_) });
};
}
-TUWF::set import_modules => 0;
-TUWF::load_recursive('VNWeb');
+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 %elm = (
- tuwf->req->{js} || tuwf->req->{pagevars}{elm} ? ('plain.js' => 1) : (),
- map +($_->[0], 1), tuwf->req->{pagevars}{elm}->@*
+ 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(),
@@ -128,7 +180,7 @@ TUWF::hook after => sub {
perl_time => time() - tuwf->req->{trace_start},
has_txn => VNWeb::DB::sql('txid_current_if_assigned() IS NOT NULL'),
loggedin => auth?1:0,
- elm_mods => '{'.join(',', sort keys %elm).'}'
+ js => '{'.join(',', sort keys %js).'}'
});
} if config->{trace_log};