summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Dockerfile3
-rw-r--r--Makefile4
-rw-r--r--data/js/iv.js138
-rw-r--r--data/js/main.js11
-rw-r--r--data/js/misc.js19
-rw-r--r--data/js/prodrel.js108
-rw-r--r--data/js/tabs.js49
-rw-r--r--data/js/vncast.js112
-rw-r--r--data/js/vnrel.js124
-rw-r--r--data/js/vnscr.js206
-rw-r--r--data/js/vnstaff.js123
-rw-r--r--data/style.css226
-rw-r--r--elm/CharEdit.elm81
-rw-r--r--elm/Discussions/Edit.elm34
-rw-r--r--elm/Discussions/PostEdit.elm108
-rw-r--r--elm/Discussions/Reply.elm2
-rw-r--r--elm/Lib/Api.elm3
-rw-r--r--elm/Lib/Autocomplete.elm34
-rw-r--r--elm/Lib/Editsum.elm5
-rw-r--r--elm/Lib/Image.elm183
-rw-r--r--elm/ProducerEdit.elm231
-rw-r--r--elm/Report.elm184
-rw-r--r--elm/Reviews/Comment.elm52
-rw-r--r--elm/Reviews/Edit.elm195
-rw-r--r--elm/Reviews/Vote.elm70
-rw-r--r--elm/UList/ManageLabels.js8
-rw-r--r--elm/UList/SaveDefault.js7
-rw-r--r--elm/UList/VNPage.elm33
-rw-r--r--elm/UList/actiontabs.js17
-rw-r--r--elm/User/Edit.elm12
-rw-r--r--elm/VNEdit.elm621
-rw-r--r--elm/VNEdit.js6
-rw-r--r--elm/iv.js61
-rw-r--r--elm/sethash.js8
-rw-r--r--lib/Multi/API.pm40
-rw-r--r--lib/Multi/Anime.pm104
-rw-r--r--lib/Multi/Feed.pm155
-rw-r--r--lib/Multi/IRC.pm45
-rw-r--r--lib/Multi/Maintenance.pm2
-rw-r--r--lib/VNDB/BBCode.pm169
-rw-r--r--lib/VNDB/Config.pm3
-rw-r--r--lib/VNDB/DB/Discussions.pm176
-rw-r--r--lib/VNDB/DB/Misc.pm115
-rw-r--r--lib/VNDB/DB/Producers.pm21
-rw-r--r--lib/VNDB/DB/VN.pm116
-rw-r--r--lib/VNDB/Func.pm4
-rw-r--r--lib/VNDB/Handler/Misc.pm167
-rw-r--r--lib/VNDB/Handler/Producers.pm214
-rw-r--r--lib/VNDB/Handler/Releases.pm25
-rw-r--r--lib/VNDB/Handler/Tags.pm2
-rw-r--r--lib/VNDB/Handler/Traits.pm2
-rw-r--r--lib/VNDB/Handler/VNEdit.pm541
-rw-r--r--lib/VNDB/Handler/VNPage.pm2
-rw-r--r--lib/VNDB/Util/CommonHTML.pm190
-rw-r--r--lib/VNDB/Util/Misc.pm19
-rw-r--r--lib/VNWeb/Auth.pm6
-rw-r--r--lib/VNWeb/Chars/Edit.pm45
-rw-r--r--lib/VNWeb/Chars/Elm.pm6
-rw-r--r--lib/VNWeb/Chars/Page.pm76
-rw-r--r--lib/VNWeb/Chars/VNTab.pm60
-rw-r--r--lib/VNWeb/DB.pm9
-rw-r--r--lib/VNWeb/Discussions/Board.pm7
-rw-r--r--lib/VNWeb/Discussions/Edit.pm110
-rw-r--r--lib/VNWeb/Discussions/Elm.pm2
-rw-r--r--lib/VNWeb/Discussions/Lib.pm23
-rw-r--r--lib/VNWeb/Discussions/PostEdit.pm88
-rw-r--r--lib/VNWeb/Discussions/Search.pm8
-rw-r--r--lib/VNWeb/Discussions/Thread.pm75
-rw-r--r--lib/VNWeb/Discussions/UPosts.pm32
-rw-r--r--lib/VNWeb/Docs/Page.pm2
-rw-r--r--lib/VNWeb/Elm.pm38
-rw-r--r--lib/VNWeb/Filters.pm195
-rw-r--r--lib/VNWeb/HTML.pm44
-rw-r--r--lib/VNWeb/Images/Lib.pm131
-rw-r--r--lib/VNWeb/Images/Upload.pm (renamed from lib/VNWeb/Misc/ImageUpload.pm)14
-rw-r--r--lib/VNWeb/Images/Vote.pm74
-rw-r--r--lib/VNWeb/Misc/BBCode.pm2
-rw-r--r--lib/VNWeb/Misc/ElmAnime.pm23
-rw-r--r--lib/VNWeb/Misc/Feeds.pm79
-rw-r--r--lib/VNWeb/Misc/History.pm17
-rw-r--r--lib/VNWeb/Misc/HomePage.pm255
-rw-r--r--lib/VNWeb/Misc/Reports.pm259
-rw-r--r--lib/VNWeb/Prelude.pm33
-rw-r--r--lib/VNWeb/Producers/Edit.pm120
-rw-r--r--lib/VNWeb/Producers/Elm.pm32
-rw-r--r--lib/VNWeb/Producers/Page.pm9
-rw-r--r--lib/VNWeb/Releases/Elm.pm13
-rw-r--r--lib/VNWeb/Releases/Lib.pm22
-rw-r--r--lib/VNWeb/Releases/Page.pm6
-rw-r--r--lib/VNWeb/Reviews/Edit.pm109
-rw-r--r--lib/VNWeb/Reviews/Elm.pm28
-rw-r--r--lib/VNWeb/Reviews/Lib.pm21
-rw-r--r--lib/VNWeb/Reviews/List.pm85
-rw-r--r--lib/VNWeb/Reviews/Page.pm146
-rw-r--r--lib/VNWeb/Reviews/VNTab.pm87
-rw-r--r--lib/VNWeb/Staff/Elm.pm25
-rw-r--r--lib/VNWeb/Staff/Page.pm4
-rw-r--r--lib/VNWeb/Tags/Elm.pm2
-rw-r--r--lib/VNWeb/Traits/Elm.pm2
-rw-r--r--lib/VNWeb/ULists/Elm.pm265
-rw-r--r--lib/VNWeb/ULists/Export.pm101
-rw-r--r--lib/VNWeb/ULists/Lib.pm13
-rw-r--r--lib/VNWeb/ULists/List.pm (renamed from lib/VNWeb/User/Lists.pm)305
-rw-r--r--lib/VNWeb/User/Edit.pm6
-rw-r--r--lib/VNWeb/User/List.pm4
-rw-r--r--lib/VNWeb/User/Notifications.pm25
-rw-r--r--lib/VNWeb/User/Page.pm9
-rw-r--r--lib/VNWeb/VN/Edit.pm206
-rw-r--r--lib/VNWeb/VN/Elm.pm34
-rw-r--r--lib/VNWeb/VN/Graph.pm28
-rw-r--r--lib/VNWeb/VN/Page.pm217
-rw-r--r--lib/VNWeb/Validation.pm22
-rw-r--r--sql/c/vndbfuncs.c5
-rw-r--r--sql/func.sql73
-rw-r--r--sql/perms.sql25
-rw-r--r--sql/schema.sql124
-rw-r--r--sql/tableattrs.sql31
-rw-r--r--sql/triggers.sql121
-rw-r--r--static/s/angel/conf2
-rwxr-xr-xutil/bbcode-test.pl78
-rwxr-xr-xutil/devdump.pl2
-rwxr-xr-xutil/unusedimages.pl2
-rw-r--r--util/updates/2020-07-23-reports.sql19
-rw-r--r--util/updates/2020-07-25-report-db.sql2
-rw-r--r--util/updates/2020-07-29-reports-last-seen.sql5
-rw-r--r--util/updates/2020-08-07-schema-sync.sql14
-rw-r--r--util/updates/2020-08-07-threads.sql46
-rw-r--r--util/updates/2020-08-17-reviews.sql71
-rw-r--r--util/updates/2020-08-19-reviews-caches.sql14
-rw-r--r--util/updates/2020-08-24-reviews-nosummary.sql6
-rw-r--r--util/updates/2020-08-25-reviews-fixups.sql5
-rw-r--r--util/updates/2020-09-03-reviews-flagging.sql5
-rw-r--r--util/updates/2020-09-05-notifications.sql16
-rw-r--r--util/updates/2020-09-20-reviews-locked.sql1
-rwxr-xr-xutil/vndb.pl5
136 files changed, 5608 insertions, 3914 deletions
diff --git a/.gitignore b/.gitignore
index 584a6483..b93194e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,7 +18,6 @@
/static/f/plain.js
/static/f/plain.min.js
/static/f/plain.min.js.gz
-/static/feeds/
/static/s/*/style.css
/static/s/*/style.min.css
/static/s/*/style.min.css.gz
diff --git a/Dockerfile b/Dockerfile
index 10e78ec8..c01e8972 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
FROM alpine:3.11
MAINTAINER Yoran Heling <contact@vndb.org>
-ENV VNDB_DOCKER_VERSION=3
+ENV VNDB_DOCKER_VERSION=4
CMD /var/www/util/docker-init.sh
RUN apk add --no-cache \
@@ -19,6 +19,7 @@ RUN apk add --no-cache \
perl-module-build \
postgresql \
postgresql-dev \
+ wget \
zlib-dev \
&& cpanm -nq \
Algorithm::Diff::XS \
diff --git a/Makefile b/Makefile
index 35151029..9f87f2f8 100644
--- a/Makefile
+++ b/Makefile
@@ -24,7 +24,7 @@
ALL_KEEP=\
static/ch static/cv static/sf static/st \
- data/log static/f www www/feeds www/api \
+ data/log static/f www www/api \
data/conf.pl \
www/robots.txt static/robots.txt
@@ -64,7 +64,7 @@ static/ch static/cv static/sf static/st:
mkdir -p $@;
for i in $$(seq -w 0 1 99); do mkdir -p "$@/$$i"; done
-data/log www www/feeds www/api static/f:
+data/log www www/api static/f:
mkdir -p $@
data/conf.pl:
diff --git a/data/js/iv.js b/data/js/iv.js
deleted file mode 100644
index 98889c5e..00000000
--- a/data/js/iv.js
+++ /dev/null
@@ -1,138 +0,0 @@
-/* Simple image viewer widget. Usage:
- *
- * <a href="full_image.jpg" data-iv="{width}x{height}:{category}">..</a>
- *
- * Clicking on the above link will cause the image viewer to open
- * full_image.jpg. The {category} part can be empty or absent. If it is not
- * empty, next/previous links will show up to point to the other images within
- * the same category.
- *
- * ivInit() should be called when links with "data-iv" attributes are
- * dynamically added or removed from the DOM.
- */
-
-// Cache of image categories and the list of associated link objects. Used to
-// quickly generate the next/prev links.
-var cats;
-
-function init() {
- cats = {};
- var n = 0;
- var l = byName('a');
- for(var i=0;i<l.length;i++) {
- var o = l[i];
- if(o.getAttribute('data-iv') && o.id != 'ivprev' && o.id != 'ivnext') {
- n++;
- o.onclick = show;
- var cat = o.getAttribute('data-iv').split(':')[1];
- if(cat) {
- if(!cats[cat])
- cats[cat] = [];
- o.iv_i = cats[cat].length;
- cats[cat].push(o);
- }
- }
- }
-
- if(n && !byId('iv_view')) {
- addBody(tag('div', {id: 'iv_view','class':'hidden', onclick: function(ev) { ev.stopPropagation(); return true } },
- tag('b', {id:'ivimg'}, ''),
- tag('br', null),
- tag('a', {href:'#', id:'ivfull'}, ''),
- tag('a', {href:'#', onclick: close, id:'ivclose'}, 'close'),
- tag('a', {href:'#', onclick: show, id:'ivprev'}, '« previous'),
- tag('a', {href:'#', onclick: show, id:'ivnext'}, 'next »')
- ));
- addBody(tag('b', {id:'ivimgload','class':'hidden'}, 'Loading...'));
- }
-}
-
-// Find the next (dir=1) or previous (dir=-1) non-hidden link object for the category.
-function findnav(cat, i, dir) {
- for(var j=i+dir; j>=0 && j<cats[cat].length; j+=dir)
- if(!hasClass(cats[cat][j], 'hidden') && cats[cat][j].offsetWidth > 0 && cats[cat][j].offsetHeight > 0)
- return cats[cat][j];
- return 0
-}
-
-// fix properties of the prev/next links
-function fixnav(lnk, cat, i, dir) {
- var a = cat ? findnav(cat, i, dir) : 0;
- lnk.style.visibility = a ? 'visible' : 'hidden';
- lnk.href = a ? a.href : '#';
- lnk.iv_i = a ? a.iv_i : 0;
- lnk.setAttribute('data-iv', a ? a.getAttribute('data-iv') : '');
-}
-
-function show(ev) {
- var u = this.href;
- var opt = this.getAttribute('data-iv').split(':');
- var idx = this.iv_i;
- var view = byId('iv_view');
- var full = byId('ivfull');
-
- fixnav(byId('ivprev'), opt[1], idx, -1);
- fixnav(byId('ivnext'), opt[1], idx, 1);
-
- // calculate dimensions
- var w = Math.floor(opt[0].split('x')[0]);
- var h = Math.floor(opt[0].split('x')[1]);
- var ww = typeof(window.innerWidth) == 'number' ? window.innerWidth : document.documentElement.clientWidth;
- var wh = typeof(window.innerHeight) == 'number' ? window.innerHeight : document.documentElement.clientHeight;
- var st = typeof(window.pageYOffset) == 'number' ? window.pageYOffset : document.body && document.body.scrollTop ? document.body.scrollTop : document.documentElement.scrollTop;
- if(w+100 > ww || h+70 > wh) {
- full.href = u;
- setText(full, w+'x'+h);
- full.style.visibility = 'visible';
- if(w/h > ww/wh) { // width++
- h *= (ww-100)/w;
- w = ww-100;
- } else { // height++
- w *= (wh-70)/h;
- h = wh-70;
- }
- } else
- full.style.visibility = 'hidden';
- var dw = w;
- var dh = h+20;
- dw = dw < 200 ? 200 : dw;
-
- // update document
- setClass(view, 'hidden', false);
- setContent(byId('ivimg'), tag('img', {src:u, onclick:close,
- onload: function() { setClass(byId('ivimgload'), 'hidden', true); },
- style: 'width: '+w+'px; height: '+h+'px'
- }));
- view.style.width = dw+'px';
- view.style.height = dh+'px';
- view.style.left = ((ww - dw) / 2 - 10)+'px';
- view.style.top = ((wh - dh) / 2 + st - 20)+'px';
- byId('ivimgload').style.left = ((ww - 100) / 2 - 10)+'px';
- byId('ivimgload').style.top = ((wh - 20) / 2 + st)+'px';
- setClass(byId('ivimgload'), 'hidden', false);
-
- document.onclick = close;
- // Capture left/right arrow keys
- document.onkeydown = function(e) {
- if(e.keyCode == 37 && byId('ivprev').style.visibility == 'visible') {
- byId('ivprev').click();
- }
- if(e.keyCode == 39 && byId('ivnext').style.visibility == 'visible') {
- byId('ivnext').click();
- }
- };
- ev.stopPropagation();
- return false;
-}
-
-function close() {
- document.onclick = null;
- document.onkeydown = null;
- setClass(byId('iv_view'), 'hidden', true);
- setClass(byId('ivimgload'), 'hidden', true);
- setText(byId('ivimg'), '');
- return false;
-}
-
-window.ivInit = init;
-init();
diff --git a/data/js/main.js b/data/js/main.js
index b04b02d0..8f3f6ca2 100644
--- a/data/js/main.js
+++ b/data/js/main.js
@@ -28,23 +28,12 @@ VARS.resolutions = [
//include lib.js
// Reusable widgets
-//include iv.js
//include dropdown.js
//include dateselector.js
//include dropdownsearch.js
-//include tabs.js
// Page/functionality-specific widgets
//include filter.js
//include misc.js
-// VN editing (/v+/edit)
-//include vnrel.js
-//include vnscr.js
-//include vnstaff.js
-//include vncast.js
-
-// Producer editing (/p+/edit)
-//include prodrel.js
-
// @license-end
diff --git a/data/js/misc.js b/data/js/misc.js
index 9c151eae..9658bccf 100644
--- a/data/js/misc.js
+++ b/data/js/misc.js
@@ -1,22 +1,3 @@
-// expand/collapse release listing (/p+)
-(function(){
- var lnk = byId('expandprodrel');
- if(!lnk)
- return;
- function setexpand() {
- var exp = !(getCookie('prodrelexpand') == 1);
- setText(lnk, exp ? 'collapse' : 'expand');
- setClass(byId('prodrel'), 'collapse', !exp);
- };
- lnk.onclick = function () {
- setCookie('prodrelexpand', getCookie('prodrelexpand') == 1 ? 0 : 1);
- setexpand();
- return false;
- };
- setexpand();
-})();
-
-
// search tabs
(function(){
function click() {
diff --git a/data/js/prodrel.js b/data/js/prodrel.js
deleted file mode 100644
index ec6082e3..00000000
--- a/data/js/prodrel.js
+++ /dev/null
@@ -1,108 +0,0 @@
-function prrLoad() {
- // read the current relations
- var rels = byId('prodrelations').value.split('|||');
- for(var i=0; i<rels.length && rels[0].length>1; i++) {
- var rel = rels[i].split(',', 3);
- prrAdd(rel[0], rel[1], rel[2]);
- }
- prrEmpty();
-
- // bind the add-link
- byName(byClass(byId('relation_new'), 'td', 'tc_add')[0], 'a')[0].onclick = prrFormAdd;
-
- // dropdown
- dsInit(byName(byClass(byId('relation_new'), 'td', 'tc_prod')[0], 'input')[0], '/xml/producers.xml?q=', function(item, tr) {
- tr.appendChild(tag('td', { style: 'text-align: right; padding-right: 5px'}, 'p'+item.getAttribute('id')));
- tr.appendChild(tag('td', shorten(item.firstChild.nodeValue, 40)));
- }, function(item) {
- return 'p'+item.getAttribute('id')+':'+item.firstChild.nodeValue;
- }, prrFormAdd);
-}
-
-function prrAdd(rel, pid, title) {
- var sel = tag('select', {onchange: prrSerialize});
- var ops = byName(byClass(byId('relation_new'), 'td', 'tc_rel')[0], 'select')[0].options;
- for(var i=0; i<ops.length; i++)
- sel.appendChild(tag('option', {value: ops[i].value, selected: ops[i].value==rel}, getText(ops[i])));
-
- byId('relation_tbl').appendChild(tag('tr', {id:'relation_tr_'+pid},
- tag('td', {'class':'tc_prod' }, 'p'+pid+':', tag('a', {href:'/p'+pid}, shorten(title, 40))),
- tag('td', {'class':'tc_rel' }, sel),
- tag('td', {'class':'tc_add' }, tag('a', {href:'#', onclick:prrDel}, 'remove'))
- ));
-
- prrEmpty();
-}
-
-function prrEmpty() {
- var tbl = byId('relation_tbl');
- if(byName(tbl, 'tr').length < 1)
- tbl.appendChild(tag('tr', {id:'relation_tr_none'}, tag('td', {colspan:4}, 'Nothing selected.')));
- else if(byId('relation_tr_none'))
- tbl.removeChild(byId('relation_tr_none'));
-}
-
-function prrSerialize() {
- var r = [];
- var trs = byName(byId('relation_tbl'), 'tr');
- for(var i=0; i<trs.length; i++) {
- if(trs[i].id == 'relation_tr_none')
- continue;
- var rel = byName(byClass(trs[i], 'td', 'tc_rel')[0], 'select')[0];
- r[r.length] = [
- rel.options[rel.selectedIndex].value,
- trs[i].id.substr(12),
- getText(byName(byClass(trs[i], 'td', 'tc_prod')[0], 'a')[0])
- ].join(',');
- }
- byId('prodrelations').value = r.join('|||');
-}
-
-function prrDel() {
- var tr = this;
- while(tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- byId('relation_tbl').removeChild(tr);
- prrSerialize();
- prrEmpty();
- return false;
-}
-
-function prrFormAdd() {
- var relnew = byId('relation_new');
- var txt = byName(byClass(relnew, 'td', 'tc_prod')[0], 'input')[0];
- var sel = byName(byClass(relnew, 'td', 'tc_rel')[0], 'select')[0];
- var lnk = byName(byClass(relnew, 'td', 'tc_add')[0], 'a')[0];
- var input = txt.value;
-
- if(!input.match(/^p[0-9]+/)) {
- alert('Producer textbox should start with an ID (e.g. "p7:")');
- return false;
- }
-
- txt.disabled = sel.disabled = true;
- txt.value = 'Loading...';
- setText(lnk, 'Loading...');
-
- ajax('/xml/producers.xml?q='+encodeURIComponent(input), function(hr) {
- txt.disabled = sel.disabled = false;
- txt.value = '';
- setText(lnk, 'add');
-
- var items = hr.responseXML.getElementsByTagName('item');
- if(items.length < 1)
- return alert('Producer not found');
-
- var id = items[0].getAttribute('id');
- if(byId('relation_tr_'+id))
- return alert('Producer already selected!');
-
- prrAdd(sel.options[sel.selectedIndex].value, id, items[0].firstChild.nodeValue);
- sel.selectedIndex = 0;
- prrSerialize();
- });
- return false;
-}
-
-if(byId('prodrelations'))
- prrLoad();
diff --git a/data/js/tabs.js b/data/js/tabs.js
deleted file mode 100644
index 470bd077..00000000
--- a/data/js/tabs.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/* Javascript tabs. General usage:
- *
- * <ul id="jt_select">
- * <li><a href="#<name>" id="jt_sel_<name>">..</a></li>
- * ..
- * </ul>
- *
- * Can then be used to show/hide the following box:
- *
- * <div id="jt_box_<name>"> .. </div>
- *
- * The name of the active box will be set to and (at page load) read from
- * location.hash. The parent node of the active link will get the 'tabselected'
- * class. A link with the special name "all" will display all boxes associated
- * with jt_select links.
- *
- * Only one jt_select list-of-tabs can be used on a single page.
- */
-var links = byId('jt_select') ? byName(byId('jt_select'), 'a') : [];
-
-function init() {
- var sel;
- var first;
- for(var i=0; i<links.length; i++) {
- links[i].onclick = function() { set(this.id); return false };
- if(!first)
- first = links[i].id;
- if(location.hash && links[i].id == 'jt_sel_'+location.hash.substr(1))
- sel = links[i].id;
- }
- if(first)
- set(sel||first, 1);
-}
-
-function set(which, nolink) {
- which = which.substr(7);
-
- for(var i=0; i<links.length; i++) {
- var name = links[i].id.substr(7);
- if(name != 'all')
- setClass(byId('jt_box_'+name), 'hidden', which != 'all' && which != name);
- setClass(links[i].parentNode, 'tabselected', name == which);
- }
-
- if(!nolink)
- location.href = '#'+which;
-}
-
-init();
diff --git a/data/js/vncast.js b/data/js/vncast.js
deleted file mode 100644
index 20d7fb39..00000000
--- a/data/js/vncast.js
+++ /dev/null
@@ -1,112 +0,0 @@
-function vncLoad() {
- var cast = jsonParse(byId('seiyuu').value) || [];
- var copt = byId('cast_chars').options;
- var chars = {};
- for(var i = 0; i < copt.length; i++) {
- if(copt[i].value)
- chars[copt[i].value] = copt[i].text;
- }
- cast.sort(function(a, b) {
- if(chars[a.cid] < chars[b.cid]) return -1;
- if(chars[a.cid] > chars[b.cid]) return 1;
- return 0;
- });
- for(var i = 0; i < cast.length; i++) {
- var aid = cast[i].aid;
- if(vnsStaffData[aid]) // vnsStaffData is filled by vnsLoad()
- vncAdd(vnsStaffData[aid], cast[i].cid, cast[i].note);
- }
- vncEmpty();
-
- onSubmit(byName(byId('maincontent'), 'form')[0], vncSerialize);
-
- // dropdown search
- dsInit(byId('cast_input'), '/xml/staff.xml?q=', function(item, tr) {
- tr.appendChild(tag('td', { style: 'text-align: right; padding-right: 5px'}, 's'+item.getAttribute('sid')));
- tr.appendChild(tag('td', item.firstChild.nodeValue));
- tr.appendChild(tag('td', item.getAttribute('orig')));
- }, vncFormAdd);
-}
-
-function vncAdd(seiyuu, chr, note) {
- var tbl = byId('cast_tbl');
-
- var csel = byId('cast_chars').cloneNode(true);
- csel.removeAttribute('id');
- csel.value = chr;
-
- tbl.appendChild(tag('tr', {id:'vnc_a'+seiyuu.aid},
- tag('td', {'class':'tc_char'}, csel),
- tag('td', {'class':'tc_name'},
- tag('input', {type:'hidden', value:seiyuu.aid}),
- tag('a', {href:'/s'+seiyuu.id}, seiyuu.name)),
- tag('td', {'class':'tc_note'}, tag('input', {type:'text', 'class':'text', value:note})),
- tag('td', {'class':'tc_del'}, tag('a', {href:'#', onclick:vncDel}, 'remove'))
- ));
- vncEmpty();
- vncSerialize();
-}
-
-function vncFormAdd(item) {
- var chr = byId('cast_chars').value;
- if (chr) {
- var s = { id:item.getAttribute('sid'), aid:item.getAttribute('id'), name:item.firstChild.nodeValue };
- vncAdd(s, chr, '');
- } else
- alert('Select character first please.');
- return '';
-}
-
-function vncEmpty() {
- var x = byId('cast_loading');
- var tbody = byId('cast_tbl');
- var tbl = tbody.parentNode;
- var thead = byName(tbl, 'thead');
- if(x)
- tbody.removeChild(x);
- if(byName(tbody, 'tr').length < 1) {
- tbody.appendChild(tag('tr', {id:'cast_tr_none'},
- tag('td', {colspan:4}, 'None')));
- if (thead.length)
- tbl.removeChild(thead[0]);
- } else {
- if(byId('cast_tr_none'))
- tbody.removeChild(byId('cast_tr_none'));
- if (thead.length < 1) {
- thead = tag('thead', tag('tr',
- tag('td', {'class':'tc_char'}, 'Character'),
- tag('td', {'class':'tc_name'}, 'Seiyuu'),
- tag('td', {'class':'tc_note'}, 'Note'),
- tag('td', '')));
- tbl.insertBefore(thead, tbody);
- }
- }
-}
-
-function vncSerialize() {
- var l = byName(byId('cast_tbl'), 'tr');
- var c = [];
- for (var i = 0; i < l.length; i++) {
- if(l[i].id == 'cast_tr_none')
- continue;
- var aid = byName(byClass(l[i], 'tc_name')[0], 'input')[0];
- var role = byName(byClass(l[i], 'tc_char')[0], 'select')[0];
- var note = byName(byClass(l[i], 'tc_note')[0], 'input')[0];
- c.push({ aid:Number(aid.value), cid:Number(role.value), note:note.value });
- }
- byId('seiyuu').value = JSON.stringify(c);
- return true;
-}
-
-function vncDel() {
- var tr = this;
- while (tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- byId('cast_tbl').removeChild(tr);
- vncEmpty();
- vncSerialize();
- return false;
-}
-
-if(byId('jt_box_vn_cast'))
- vncLoad();
diff --git a/data/js/vnrel.js b/data/js/vnrel.js
deleted file mode 100644
index 2ccb91bb..00000000
--- a/data/js/vnrel.js
+++ /dev/null
@@ -1,124 +0,0 @@
-function vnrLoad() {
- // read the current relations
- var rels = byId('vnrelations').value.split('|||');
- for(var i=0; i<rels.length && rels[0].length>1; i++) {
- var rel = rels[i].split(',');
- // fix for titles containing commas
- rel[3] = rel.splice(3).join();
- vnrAdd(rel[0], rel[1], rel[2]==1?true:false, rel[3]);
- }
- vnrEmpty();
-
- // make sure the title is up-to-date
- byId('title').onchange = function() {
- var l = byClass(byId('jt_box_vn_rel'), 'td', 'tc_title');
- for(i=0; i<l.length; i++)
- setText(l[i], shorten(this.value, 40));
- };
-
- // bind the add-link
- byName(byClass(byId('relation_new'), 'td', 'tc_add')[0], 'a')[0].onclick = vnrFormAdd;
-
- // dropdown
- dsInit(byName(byClass(byId('relation_new'), 'td', 'tc_vn')[0], 'input')[0], '/xml/vn.xml?q=', function(item, tr) {
- tr.appendChild(tag('td', { style: 'text-align: right; padding-right: 5px'}, 'v'+item.getAttribute('id')));
- tr.appendChild(tag('td', shorten(item.firstChild.nodeValue, 40)));
- }, function(item) {
- return 'v'+item.getAttribute('id')+':'+item.firstChild.nodeValue;
- }, vnrFormAdd);
-}
-
-function vnrAdd(rel, vid, official, title) {
- var sel = tag('select', {onchange: vnrSerialize});
- var ops = byName(byClass(byId('relation_new'), 'td', 'tc_rel')[0], 'select')[0].options;
- for(var i=0; i<ops.length; i++)
- sel.appendChild(tag('option', {value: ops[i].value, selected: ops[i].value==rel}, getText(ops[i])));
-
- byId('relation_tbl').appendChild(tag('tr', {id:'relation_tr_'+vid},
- tag('td', {'class':'tc_vn' }, 'v'+vid+':', tag('a', {href:'/v'+vid}, shorten(title, 40))),
- tag('td', {'class':'tc_rel' },
- 'is an ',
- tag('input', {type: 'checkbox', onclick:vnrSerialize, id:'official_'+vid, checked:official}),
- tag('label', {'for':'official_'+vid}, 'official'),
- sel, ' of'),
- tag('td', {'class':'tc_title'}, shorten(byId('title').value, 40)),
- tag('td', {'class':'tc_add' }, tag('a', {href:'#', onclick:vnrDel}, 'remove'))
- ));
-
- vnrEmpty();
-}
-
-function vnrEmpty() {
- var tbl = byId('relation_tbl');
- if(byName(tbl, 'tr').length < 1)
- tbl.appendChild(tag('tr', {id:'relation_tr_none'}, tag('td', {colspan:4}, 'No relations selected.')));
- else if(byId('relation_tr_none'))
- tbl.removeChild(byId('relation_tr_none'));
-}
-
-function vnrSerialize() {
- var r = [];
- var trs = byName(byId('relation_tbl'), 'tr');
- for(var i=0; i<trs.length; i++) {
- if(trs[i].id == 'relation_tr_none')
- continue;
- var rel = byName(byClass(trs[i], 'td', 'tc_rel')[0], 'select')[0];
- r[r.length] = [
- rel.options[rel.selectedIndex].value, // relation
- trs[i].id.substr(12), // vid
- byName(byClass(trs[i], 'td', 'tc_rel')[0], 'input')[0].checked ? '1' : '0', // official
- getText(byName(byClass(trs[i], 'td', 'tc_vn')[0], 'a')[0]) // title
- ].join(',');
- }
- byId('vnrelations').value = r.join('|||');
-}
-
-function vnrDel() {
- var tr = this;
- while(tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- byId('relation_tbl').removeChild(tr);
- vnrSerialize();
- vnrEmpty();
- return false;
-}
-
-function vnrFormAdd() {
- var relnew = byId('relation_new');
- var txt = byName(byClass(relnew, 'td', 'tc_vn')[0], 'input')[0];
- var off = byName(byClass(relnew, 'td', 'tc_rel')[0], 'input')[0];
- var sel = byName(byClass(relnew, 'td', 'tc_rel')[0], 'select')[0];
- var lnk = byName(byClass(relnew, 'td', 'tc_add')[0], 'a')[0];
- var input = txt.value;
-
- if(!input.match(/^v[0-9]+/)) {
- alert('Visual novel textbox must start with an ID (e.g. v17)');
- return false;
- }
-
- txt.disabled = sel.disabled = off.disabled = true;
- txt.value = 'Loading...';
- setText(lnk, 'Loading...');
-
- ajax('/xml/vn.xml?q='+encodeURIComponent(input), function(hr) {
- txt.disabled = sel.disabled = off.disabled = false;
- txt.value = '';
- setText(lnk, 'add');
-
- var items = hr.responseXML.getElementsByTagName('item');
- if(items.length < 1)
- return alert('Visual novel not found!');
-
- var id = items[0].getAttribute('id');
- if(byId('relation_tr_'+id))
- return alert('This visual novel has already been selected!');
-
- vnrAdd(sel.options[sel.selectedIndex].value, id, off.checked, items[0].firstChild.nodeValue);
- sel.selectedIndex = 0;
- vnrSerialize();
- });
- return false;
-}
-
-if(byId('vnrelations'))
- vnrLoad();
diff --git a/data/js/vnscr.js b/data/js/vnscr.js
deleted file mode 100644
index bf583e15..00000000
--- a/data/js/vnscr.js
+++ /dev/null
@@ -1,206 +0,0 @@
-var rels;
-var defRid = 0;
-var staticUrl;
-
-function init() {
- var data = jsonParse(getText(byId('screendata'))) || {};
- rels = data.rel;
- rels.unshift([ 0, '-- select release --' ]);
- staticUrl = data.staticurl;
-
- var scr = jsonParse(byId('screenshots').value) || {};
- for(i=0; i<scr.length; i++) {
- var r = scr[i];
- var s = data.size[r.id];
- loaded(add(r.nsfw, r.rid), r.id, s[0], s[1]);
- }
-
- var frm = byId('screenshots');
- while(frm.nodeName.toLowerCase() != 'form')
- frm = frm.parentNode;
- onSubmit(frm, handleSubmit);
-
- addLast();
- ivInit();
-}
-
-function handleSubmit() {
- var loading = 0;
- var norelease = 0;
-
- var r = [];
- var l = byName(byId('scr_table'), 'tr');
- for(var i=0; i<l.length-1; i++)
- if(l[i].scr_loading)
- loading = 1;
- else if(l[i].scr_rid == 0)
- norelease = 1;
- else
- r.push({ rid: l[i].scr_rid, nsfw: l[i].scr_nsfw, id: l[i].scr_id });
-
- if(loading)
- alert('Please wait for the screenshots to be uploaded before submitting the form.');
- else if(norelease)
- alert('Please select the appropriate release for every screenshot.');
- else
- byId('screenshots').value = JSON.stringify(r);
- return !loading && !norelease;
-}
-
-function genRels(sel) {
- var r = tag('select', {'class':'scr_relsel'});
- for(var i=0; i<rels.length; i++)
- r.appendChild(tag('option', {value: rels[i][0], selected: rels[i][0] == sel}, rels[i][1]));
- return r;
-}
-
-function URL(id, t) {
- return staticUrl+'/s'+t+'/'+(id%100<10?'0':'')+(id%100)+'/'+id+'.jpg';
-}
-
-// Need to run addLast() after this function
-function add(nsfw, rid) {
- var tr = tag('tr', { scr_id: 0, scr_loading: 1, scr_rid: rid, scr_nsfw: nsfw?1:0},
- tag('td', { 'class': 'thumb'}, 'Loading...'),
- tag('td',
- tag('b', 'Uploading screenshot'),
- tag('br', null),
- 'This can take a while, depending on the file size and your upload speed.',
- tag('br', null),
- tag('a', {href:'#', onclick:del}, 'cancel')
- )
- );
- byId('scr_table').appendChild(tr);
- return tr;
-}
-
-function oddDim(dim) {
- if(dim == '256x384') // special-case NDS resolution (not in the DB)
- return false;
- for(var j=0; j<VARS.resolutions.length; j++) {
- if(typeof VARS.resolutions[j][1] != 'object') {
- if(VARS.resolutions[j][0] == dim)
- return false;
- } else {
- for(var k=1; k<VARS.resolutions[j].length; k++)
- if(VARS.resolutions[j][k][1] == dim)
- return false;;
- }
- }
- return true;
-}
-
-// Need to run ivInit() after this function
-function loaded(tr, id, width, height) {
- var dim = width+'x'+height;
- tr.id = 'scr_tr_'+id;
- tr.scr_id = id;
- tr.scr_loading = 0;
-
- setContent(byName(tr, 'td')[0],
- tag('a', {href: URL(tr.scr_id, 'f'), 'data-iv':dim+':edit'},
- tag('img', {src: URL(tr.scr_id, 't')})
- )
- );
-
- var rel = genRels(tr.scr_rid);
- rel.onchange = function() { tr.scr_rid = this.options[this.selectedIndex].value };
-
- var nsfwid = 'scr_nsfw_'+id;
- setContent(byName(tr, 'td')[1],
- tag('b', 'Screenshot #'+id),
- ' (', tag('a', {href: '#', onclick:del}, 'remove'), ')',
- tag('br', null),
- 'Full size: '+dim,
- !oddDim(dim) ? null : tag('b', {'class':'standout', 'style':'font-weight: bold'},
- ' WARNING: Odd resolution! Please check whether the image has been cropped correctly.'),
- tag('br', null),
- tag('br', null),
- tag('input', {type:'checkbox', name:nsfwid, id:nsfwid, checked: tr.scr_nsfw!=0, onclick: function() { tr.scr_nsfw = this.checked?1:0 }, 'class':'scr_nsfw'}),
- tag('label', {'for':nsfwid}, 'This screenshot is NSFW'),
- tag('br', null),
- rel
- );
-}
-
-function addLast() {
- if(byId('scr_last'))
- byId('scr_table').removeChild(byId('scr_last'));
- var full = byName(byId('scr_table'), 'tr').length >= 10;
-
- var rel = genRels(defRid);
- rel.onchange = function() { defRid = this.options[this.selectedIndex].value };
- rel.id = 'scradd_relsel';
-
- byId('scr_table').appendChild(tag('tr', {id:'scr_last'},
- tag('td', {'class': 'thumb'}),
- full ? tag('td',
- tag('b', 'Enough screenshots'),
- tag('br', null),
- 'The limit of 10 screenshots per visual novel has been reached.\nIf you want to add a new screenshot, please remove an existing one first.'
- ) : tag('td',
- tag('b', 'Add screenshot'),
- tag('br', null),
- 'Image must be smaller than 5MB and in PNG or JPEG format.',
- tag('br', null),
- rel,
- tag('br', null),
- tag('input', {name:'scr_upload', id:'scr_upload', type:'file', 'class':'text', multiple:true}),
- tag('br', null),
- tag('input', {type:'button', value:'Upload!', 'class':'submit', onclick:upload})
- )
- ));
-}
-
-function del(what) {
- var tr = what && what.scr_id != null ? what : this;
- while(tr.scr_id == null)
- tr = tr.parentNode;
- if(tr.scr_ajax)
- tr.scr_ajax.abort();
- byId('scr_table').removeChild(tr);
- addLast();
- ivInit();
- return false;
-}
-
-function uploadFile(f) {
- var tr = add(0, defRid);
- var fname = f.name;
- var frm = new FormData();
- frm.append('file', f);
- tr.scr_ajax = ajax('/xml/screenshots.xml', function(hr) {
- tr.scr_ajax = null;
- var img = hr.responseXML.getElementsByTagName('image')[0];
- var id = img.getAttribute('id');
- if(id < 0) {
- alert(fname + ":\n" + (
- id == -1 ? 'Upload failed!\nOnly JPEG or PNG images are accepted.'
- : 'Upload failed!\nNo file selected, or an empty file?'));
- del(tr);
- } else {
- loaded(tr, id, img.getAttribute('width'), img.getAttribute('height'));
- ivInit();
- }
- }, true, frm);
-}
-
-function upload() {
- var files = byId('scr_upload').files;
-
- if(files.length < 1) {
- alert('Upload failed!\nNo file selected, or an empty file?');
- return false;
- } else if(files.length + byName(byId('scr_table'), 'tr').length - 1 > 10) {
- alert('Too many files selected. The total number of screenshots may not exceed 10.');
- return false;
- }
-
- for(var i=0; i<files.length; i++)
- uploadFile(files[i]);
- addLast();
- return false;
-}
-
-if(byId('jt_box_vn_scr') && byId('screenshots'))
- init();
diff --git a/data/js/vnstaff.js b/data/js/vnstaff.js
deleted file mode 100644
index 62e262e9..00000000
--- a/data/js/vnstaff.js
+++ /dev/null
@@ -1,123 +0,0 @@
-// vnsStaffData maps alias id to staff data { NNN: { id: ..., aid: NNN, name: ...} }
-// used to fill form fields instead of ajax queries in vnsLoad() and vncLoad()
-// Also used by vncast.js
-window.vnsStaffData = {};
-
-function vnsLoad() {
- window.vnsStaffData = jsonParse(getText(byId('staffdata'))) || {};
- var credits = jsonParse(byId('credits').value) || [];
- for(var i = 0; i < credits.length; i++) {
- var aid = credits[i].aid;
- if(window.vnsStaffData[aid])
- vnsAdd(window.vnsStaffData[aid], credits[i].role, credits[i].note);
- }
- vnsEmpty();
-
- onSubmit(byName(byId('maincontent'), 'form')[0], vnsCheckAndSerialize);
-
- // dropdown search
- dsInit(byId('credit_input'), '/xml/staff.xml?q=', function(item, tr) {
- tr.appendChild(tag('td', { style: 'text-align: right; padding-right: 5px'}, 's'+item.getAttribute('sid')));
- tr.appendChild(tag('td', item.firstChild.nodeValue));
- tr.appendChild(tag('td', item.getAttribute('orig')));
- }, vnsFormAdd);
-}
-
-function vnsAdd(staff, role, note) {
- var tbl = byId('credits_tbl');
-
- var rlist = tag('select', {onchange:vnsSerialize});
- var r = VARS.credit_type;
- for (var i = 0; i<r.length; i++)
- rlist.appendChild(tag('option', {value:r[i][0], selected:r[i][0]==role}, r[i][1]));
-
- tbl.appendChild(tag('tr', {id:'vns_a'+staff.aid},
- tag('td', {'class':'tc_name'},
- tag('input', {type:'hidden', value:staff.aid}),
- tag('a', {href:'/s'+staff.id}, staff.name)),
- tag('td', {'class':'tc_role'}, rlist),
- tag('td', {'class':'tc_note'}, tag('input', {type:'text', 'class':'text', value:note})),
- tag('td', {'class':'tc_del'}, tag('a', {href:'#', onclick:vnsDel}, 'remove'))
- ));
- vnsEmpty();
- vnsSerialize();
-}
-
-function vnsEmpty() {
- var x = byId('credits_loading');
- var tbody = byId('credits_tbl');
- var tbl = tbody.parentNode;
- var thead = byName(tbl, 'thead');
- if(x)
- tbody.removeChild(x);
- if(byName(tbody, 'tr').length < 1) {
- tbody.appendChild(tag('tr', {id:'credits_tr_none'},
- tag('td', {colspan:4}, 'None')));
- if (thead.length)
- tbl.removeChild(thead[0]);
- } else {
- if(byId('credits_tr_none'))
- tbody.removeChild(byId('credits_tr_none'));
- if (thead.length < 1) {
- thead = tag('thead', tag('tr',
- tag('td', {'class':'tc_name'}, 'Staff'),
- tag('td', {'class':'tc_role'}, 'Credit'),
- tag('td', {'class':'tc_note'}, 'Note'),
- tag('td', '')));
- tbl.insertBefore(thead, tbody);
- }
- }
-}
-
-function vnsSerialize() {
- var l = byName(byId('credits_tbl'), 'tr');
- var c = [];
- for (var i = 0; i < l.length; i++) {
- if(l[i].id == 'credits_tr_none')
- continue;
- var aid = byName(byClass(l[i], 'tc_name')[0], 'input')[0];
- var role = byName(byClass(l[i], 'tc_role')[0], 'select')[0];
- var note = byName(byClass(l[i], 'tc_note')[0], 'input')[0];
- c.push({ aid:Number(aid.value), role:role.value, note:note.value });
- }
- byId('credits').value = JSON.stringify(c);
- return true;
-}
-
-function vnsCheckAndSerialize() {
- var l = byName(byId('credits_tbl'), 'tr');
- var tbl = {};
- for (var i = 0; i < l.length; i++) {
- if(l[i].id == 'credits_tr_none')
- continue;
- var aid = byName(byClass(l[i], 'tc_name')[0], 'input')[0];
- var name = byName(byClass(l[i], 'tc_name')[0], 'a')[0];
- var role = byName(byClass(l[i], 'tc_role')[0], 'select')[0];
- var idx = aid.value + ' ' + role.value;
- if(tbl[idx]) {
- alert("Invalid input in staff listing: '" + name.innerText + "' is credited multiple times with '" + role.options[role.selectedIndex].value + "'.");
- return false;
- }
- tbl[idx] = 1;
- }
- return vnsSerialize();
-}
-
-function vnsDel() {
- var tr = this;
- while (tr.nodeName.toLowerCase() != 'tr')
- tr = tr.parentNode;
- byId('credits_tbl').removeChild(tr);
- vnsEmpty();
- vnsSerialize();
- return false;
-}
-
-function vnsFormAdd(item) {
- var s = { id:item.getAttribute('sid'), aid:item.getAttribute('id'), name:item.firstChild.nodeValue };
- vnsAdd(s, 'staff', '');
- return '';
-}
-
-if(byId('jt_box_vn_staff'))
- vnsLoad();
diff --git a/data/style.css b/data/style.css
index 60b1455b..5e1301c4 100644
--- a/data/style.css
+++ b/data/style.css
@@ -7,7 +7,7 @@ table th { vertical-align: top; padding: 3px; }
img { border: none; }
a,
-.fake_link { color: $link$; text-decoration: none; cursor:pointer; }
+.fake_link { color: $link$; text-decoration: none; cursor:pointer; border-bottom: 1px solid transparent }
a:hover,
.fake_link:hover { border-bottom: 1px dotted $maintext$; }
@@ -100,17 +100,18 @@ div.warning h2, div.notice h2 { font-size: 13px; font-weight: bold; margin: 0; }
/* general text formatting */
ul, ol { margin-left: 35px; }
-p.locked { float: right; color: $standout$; font-style: italic; margin: 0!important; }
+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 }
+.underline { text-decoration: underline }
#maincontent h2 b { font: 13px "Tahoma", "Arial", sans-serif; font-weight: normal; }
p.description { margin: 10px 100px!important; }
b.done { font-weight: normal; color: $statok$ }
b.todo { font-weight: normal; color: $statnok$ }
b.neutral { font-weight: normal }
p.center { text-align: center; }
-.standout { color: $standout$ }
+.standout { color: $standout$!important }
b.future,
b.standout { font-weight: normal; color: $standout$; }
.clearfloat { clear: both; height: 0; }
@@ -173,10 +174,9 @@ 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 }
-div.formimage > div:nth-child(1) { width: 300px; height: 300px; text-align: center; float: left }
-div.formimage > div:nth-child(1) img { max-width: 290px; max-height: 300px }
-div.formimage > div:nth-child(2) { min-height: 330px }
-div.formimage h2 { margin: 0 }
+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:
@@ -212,7 +212,7 @@ span.spinner { width: 1em; height: 1em }
@keyframes spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
.textpreview > span { display: flex; justify-content: space-between; width: 100% }
-.textpreview > span > p { align-self: flex-end }
+.textpreview > span > p { 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 }
@@ -287,7 +287,7 @@ div.mainbox-overflow-hack { overflow: hidden; width: 100%; box-sizing: border-bo
.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 { margin: 0 5px }
+p.mainopts a, p.mainopts label { margin: 0 5px }
.mainbox.threelayout { border-collapse: separate; border-spacing: 10px; margin: 10px -10px -20px -10px; min-width: 100%; }
.mainbox.threelayout td { width: 32%; padding: 0 2px 10px 2px; }
@@ -393,10 +393,12 @@ div.thread tr:not(:last-child) td { border-bottom: 1px solid $border$; }
div.thread td.tc1 { width: 170px; padding: 5px 10px; border-right: 1px solid $border$; }
div.thread td.tc2 { padding: 10px 20px 10px 10px; }
div.thread tr.deleted td { padding: 1px 10px; }
-div.thread tr:target { outline: 1px dotted $standout$ }
+div.thread 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; }
+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 }
/* threads browser */
div.mainbox.discussions td.tc4 { text-align: right; }
@@ -429,20 +431,11 @@ div.postsearch td.tc3 { width: 90px; }
/***** VN page *******/
-#nsfw_chk:checked ~ * { cursor: pointer; }
-#nsfw_chk:checked ~ * > #nsfw_show { display: none; }
-#nsfw_chk:not(:checked) ~ * > #nsfw_hid { display: none; }
-
-#nsfwhide_chk:checked ~ * #nsfwshown { display: none; }
-#nsfwhide_chk:not(:checked) ~ * .nsfw { display: none; }
-
div.vndetails { margin: 0 auto; max-width: 820px; }
div.vnimg { float: left; width: 250px; margin: 0 10px; }
-div.vnimg i { display: block; width: 100%; text-align: center; font-size: 11px; }
div.vnimg p { text-align: center; padding: 0px; margin: 0; }
div.vndetails h2 { margin: 5px 0 0 0; }
.vndesc p { padding: 0 0 0 5px; }
-p#nsfw_hid { display: block; cursor: pointer; }
div.vndetails table { float: left; width: 500px; }
div.vndetails table td.key { width: 90px; }
div.vndetails table dt { float: left; font-style: italic; }
@@ -498,8 +491,20 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
#screenshots img { border: 3px solid transparent; }
#screenshots a.nsfw img { border: 3px solid $statnok$; }
#screenshots a:hover img { border: 3px solid $border$; }
-#screenshots #nsfwshown { font-style: normal }
-#screenshots p.nsfwtoggle { float: right; margin: 0; }
+
+#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$ }
+
+#screenshots .scrlnk { display: none }
+#scrhide_s0:checked ~ #screenshots .scrlnk_s0 { display: inline }
+#scrhide_s1:checked ~ #screenshots .scrlnk_s1 { display: inline }
+#scrhide_s2:checked ~ #screenshots .scrlnk { display: inline }
+#scrhide_v0:checked ~ #screenshots .scrlnk_v0 { display: none }
+#scrhide_v1:checked ~ #screenshots .scrlnk_v1 { display: none }
.summarize_more {
margin-top: 9px; margin-bottom: -10px; padding: 0; height: 15px;
@@ -508,6 +513,19 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
text-align: center
}
+.reviews { display: flex; justify-content: center; flex-wrap: wrap }
+.reviewbox { margin: 10px }
+.reviewbox > div:nth-child(2) > span:first-child { float: right; color: $grayedout$; font-style: normal; margin: -5px 0 0 0; visibility: hidden }
+.reviewbox > div:nth-child(2):hover > span:first-child,
+.reviewbox > div:nth-child(2):active > span:first-child { visibility: visible }
+.reviewbox .review_spoil input:checked ~ span { display: none }
+.reviewbox .review_spoil input:not(:checked) ~ div { display: none }
+.reviewbox > div { width: 500px }
+.reviewbox > div:first-child { display: flex; justify-content: space-between; background: $secbg$; font-weight: bold }
+.reviewbox > div:first-child > span:first-child { font-weight: bold }
+.reviewbox > div:nth-child(2) { box-sizing: border-box; padding: 5px 0 }
+.reviewbox > div:last-child { display: flex; justify-content: space-between; border-top: 1px solid $border$ }
+.reviewbox .myvote { font-weight: bold; text-decoration: underline }
/***** Vote stats ****/
@@ -545,24 +563,11 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/***** VN edit *****/
-#jt_box_vn_rel table { margin-bottom: 10px; }
-#jt_box_vn_rel h2 { margin: 0 0 3px 0px; }
-#jt_box_vn_rel td { padding: 1px 2px; vertical-align: middle; }
-#jt_box_vn_rel td.tc_vn { width: 300px; text-align: right }
-#jt_box_vn_rel td.tc_rel { width: 220px; white-space: nowrap }
-#jt_box_vn_rel td.tc_title { width: 200px; }
-#jt_box_vn_rel td.tc_add { width: 40px; text-align: right }
-#jt_box_vn_rel td.tc_vn input { width: 280px; }
-#jt_box_vn_rel td.tc_rel select { width: 130px; }
-
-#jt_box_vn_img div.img { float: left; height: 400px; padding-right: 20px; }
-#jt_box_vn_img h2 { margin: 0; }
-
-#jt_box_vn_scr table { width: 95% }
-#scr_table td { height: 108px; border-top: 1px solid #258; padding: 0; padding-right: 5px }
-#scr_table td.thumb { width: 136px; vertical-align: middle }
-#scr_table select { width: 400px; }
-div.scr_uploader { visibility: hidden; overflow: hidden; width: 1px; height: 1px; position: absolute; display: none; left: 0; top: 0; }
+.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 > td:nth-child(1) { padding: 10px; width: 136px }
+.vnedit_scr > tr > td:nth-child(2) { width: 10px; padding-top: 20px }
/***** VN Release tab *****/
@@ -600,17 +605,6 @@ div.producerbrowse { padding-bottom: 10px }
.producerbrowse ul li abbr { margin-right: 5px; margin-top: 1px; }
-/***** Producer edit *****/
-
-#jt_box_pedit_rel table { margin-bottom: 10px; }
-#jt_box_pedit_rel h2 { margin: 0 0 3px 0px; }
-#jt_box_pedit_rel td { padding: 1px 2px; vertical-align: middle; }
-#jt_box_pedit_rel td.tc_prod { width: 290px; padding-left: 10px }
-#jt_box_pedit_rel td.tc_add { width: 40px; text-align: left }
-#jt_box_pedit_rel td.tc_prod input { width: 280px; }
-#jt_box_pedit_rel td.tc_rel select { width: 130px; }
-
-
/***** Release page *****/
@@ -620,6 +614,28 @@ div.producerbrowse { padding-bottom: 10px }
+/***** Review page *****/
+
+.fullreview td { padding: 0 0 15px 10px }
+.fullreview tr > td:first-child { width: 140px; text-align: right; font-weight: bold }
+.fullreview tr > td:last-child { max-width: 700px }
+.fullreview .myvote { font-weight: bold; text-decoration: underline }
+
+#reviewspoil:not(:checked) ~ .fullreview .reviewspoil { display: none }
+#reviewspoil:checked ~ .fullreview .reviewnotspoil { display: none }
+
+
+/***** Review browser *****/
+
+.reviewlist td.tc1 { width: 90px }
+.reviewlist td.tc2 { width: 110px; }
+.reviewlist td.tc3 { width: 50px; text-align: right }
+.reviewlist td.tc4 { width: 50px }
+.reviewlist td.tc6 { width: 80px }
+.reviewlist td.tc7 { width: 30px; text-align: right }
+.reviewlist td.tc8 { width: 250px; text-align: right }
+
+
/***** Release browser *****/
.relbrowse .tc1 { width: 80px }
@@ -628,8 +644,24 @@ div.producerbrowse { padding-bottom: 10px }
+/***** Image hover thingy (VNWeb::Images::Lib::images_) ****/
+
+.imghover { margin: 0 auto; display: block; text-align: center }
+.imghover input:checked ~ div.imghover--warning { display: none }
+.imghover input:not(:checked) ~ div.imghover--visible { display: none }
+.imghover div.imghover--visible { position: relative }
+.imghover div.imghover--visible a { border-bottom: 0 }
+.imghover div.imghover--visible .imghover--overlay { display: none; white-space: nowrap; font-size: 11px }
+.imghover:hover div.imghover--visible .imghover--overlay { display: block; position: absolute; right: 0; bottom: 0; padding: 5px 10px; background: $secbg$; border: 0 }
+.imghover div.imghover--warning { border: 1px solid $border$; background: $secbg$; box-sizing: border-box; padding: 10px 5px }
+
+
+
/***** Char page (also used on VN page) *****/
+p.chardetailopts { margin: -10px auto 7px auto; width: 800px; text-align: right }
+p.chardetailopts a { margin: 0 5px }
+p.chardetailopts a:last-child { margin: 0 0 0 5px }
div.chardetails { margin: 0 auto; width: 800px; }
div.charimg { float: left; width: 250px; margin: 0 10px; text-align: center }
div.charimg p { text-align: center; padding: 0px; margin: 0; }
@@ -639,14 +671,6 @@ div.chardetails table { float: left; width: 530px; }
div.chardetails table td.key { width: 100px; }
div.chardetails.charsep { margin-top: 30px }
-.imghover { margin: 0 auto; position: relative; display: block; text-align: center }
-.imghover input:checked ~ div.imghover--warning { display: none }
-.imghover input:not(:checked) ~ div.imghover--visible { display: none }
-.imghover div.imghover--visible a { display: none; white-space: nowrap; font-size: 11px }
-.imghover:hover div.imghover--visible { position: absolute }
-.imghover:hover div.imghover--visible a { 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 }
-
/***** Char edit *****/
@@ -685,6 +709,7 @@ 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 ul { list-style: none; margin: 0 }
.vnstaff-2 ul { width: 100% } .vnstaff-2 { flex-wrap: wrap }
@@ -717,40 +742,22 @@ div.charsum_list .charsum_bubble {
/***** Staff edit *****/
-#jt_box_vn_cast #cast_import { clear: right; float: right; }
-#jt_box_vn_cast table,
-#jt_box_vn_staff table { margin-bottom: 10px; margin-left: 20px }
-#jt_box_vn_cast h2,
-#jt_box_vn_staff h2 { margin: 0 0 3px 0px; }
-#jt_box_vn_cast td,
-#jt_box_vn_staff td { padding: 1px 2px; vertical-align: middle; }
-#jt_box_vn_cast td.tc_role,
-#jt_box_vn_cast td.tc_role select,
-#jt_box_vn_staff td.tc_role,
-#jt_box_vn_staff td.tc_role select { width: 120px }
-#jt_box_vn_cast td.tc_staff,
-#jt_box_vn_staff td.tc_staff,
-.staffedit td.tc_name,
-.staffedit td.tc_original { width: 200px }
-#jt_box_vn_cast td.tc_staff input,
-#jt_box_vn_staff td.tc_staff input,
-.staffedit td.tc_name input,
-.staffedit td.tc_original input { width: 200px }
-#jt_box_vn_cast td.tc_note,
-#jt_box_vn_cast td.tc_note input,
-#jt_box_vn_staff td.tc_note,
-#jt_box_vn_staff td.tc_note input { width: 250px }
-#jt_box_vn_cast td.tc_add,
-#jt_box_vn_staff td.tc_add,
-.staffedit td.tc_add { width: 40px; text-align: left; white-space: nowrap }
+.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 dd { margin: 5px 0 5px 120px }
.docs dt { float: left }
.docs ul, .docs ol { margin: 5px 0 5px 20px }
@@ -794,6 +801,7 @@ div.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px }
.managelabels tfoot div { float: right; text-align: right }
.savedefault { width: 600px; margin: 10px auto }
+.exportlist { width: 600px; margin: 10px auto }
.ulist .tc1 { white-space: nowrap; width: 70px }
.ulist .tc1 label { cursor: pointer }
@@ -999,18 +1007,48 @@ div#iv_view {
/* ivview childs:
* 1 div -> loading
* 2 div -> img
- * 3 a -> full
- * 4 a -> close
- * 5 a -> prev
- * 6 a -> next */
+ * 3 div -> links
+ * 1 a -> full
+ * 2 a -> prev
+ * 3 a -> next
+ * 4 a -> flagging
+ */
.ivview { position: fixed; background: $boxbg$; border: 2px solid $border$; padding: 5px; text-align: center }
-.ivview a { border: 0; font-weight: bold; font-size: 14px }
.ivview img { cursor: pointer }
.ivview > div:nth-child(1) { position: absolute; left: 48%; top: 48%; width: 30px; height: 30px }
-.ivview > a:nth-child(3) { float: left; padding-right: 10px }
-.ivview > a:nth-child(4) { float: right; padding-left: 10px }
-.ivview > a:nth-child(5) { padding-right: 5px }
-.ivview > a:nth-child(6) { padding-left: 5px }
+.ivview > div:nth-child(2) { position: relative }
+.ivview > div:nth-child(2) .left-pane {
+ position: absolute;
+ border: none;
+ height: 100%;
+ width: 25%;
+ top: 0;
+ transition: opacity 0.25s ease-in-out;
+ opacity: 0;
+ background: linear-gradient(90deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 100%);
+}
+.ivview > div:nth-child(2) .left-pane:hover {
+ opacity: 1;
+}
+.ivview > div:nth-child(2) .right-pane {
+ position: absolute;
+ border: none;
+ height: 100%;
+ width: 25%;
+ top: 0;
+ right: 0;
+ transition: opacity 0.25s ease-in-out;
+ opacity: 0;
+ background: linear-gradient(270deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 100%);
+}
+.ivview > div:nth-child(2) .right-pane:hover {
+ opacity: 1;
+}
+.ivview > div:nth-child(3) { width: 100%; display: flex }
+.ivview > div:nth-child(3) > a { flex: 1; text-align: left; border: 0; font-weight: bold; font-size: 14px; white-space: nowrap }
+.ivview > div:nth-child(3) > a:nth-child(2) { text-align: right; padding-right: 5px }
+.ivview > div:nth-child(3) > a:nth-child(3) { padding-left: 5px }
+.ivview > div:nth-child(3) > a:nth-child(4) { text-align: right; font-size: 11px; font-weight: normal }
/****** filter selector *****/
diff --git a/elm/CharEdit.elm b/elm/CharEdit.elm
index 0f75a357..837fbb4b 100644
--- a/elm/CharEdit.elm
+++ b/elm/CharEdit.elm
@@ -17,6 +17,7 @@ import Lib.Autocomplete as A
import Lib.Api as Api
import Lib.Editsum as Editsum
import Lib.RDate as RDate
+import Lib.Image as Img
import Gen.Release as GR
import Gen.CharEdit as GCE
import Gen.Types as GT
@@ -65,11 +66,7 @@ type alias Model =
, mainName : String
, mainSearch : A.Model GApi.ApiCharResult
, mainSpoil : Int
- , image : Maybe String
- , imageState : Api.State
- , imageNew : Set.Set String
- , imageSex : Maybe Int
- , imageVio : Maybe Int
+ , image : Img.Image
, traits : List GCE.RecvTraits
, traitSearch : A.Model GApi.ApiTraitResult
, traitSelId : Int
@@ -108,11 +105,7 @@ init d =
, mainName = d.main_name
, mainSearch = A.init ""
, mainSpoil = d.main_spoil
- , image = d.image
- , imageState = Api.Normal
- , imageNew = Set.empty
- , imageSex = d.image_sex
- , imageVio = d.image_vio
+ , image = Img.info d.image_info
, traits = d.traits
, traitSearch = A.init ""
, traitSelId = 0
@@ -148,9 +141,7 @@ encode model =
, cup_size = model.cupSize
, main = if model.mainHas then model.main else Nothing
, main_spoil = model.mainSpoil
- , image = model.image
- , image_sex = model.imageSex
- , image_vio = model.imageVio
+ , image = model.image.id
, traits = List.map (\t -> { tid = t.tid, spoil = t.spoil }) model.traits
, vns = List.map (\v -> { vid = v.vid, rid = v.rid, spoil = v.spoil, role = v.role }) model.vns
}
@@ -188,12 +179,10 @@ type Msg
| MainHas Bool
| MainSearch (A.Msg GApi.ApiCharResult)
| MainSpoil Int
- | ImageSet String
+ | ImageSet String Bool
| ImageSelect
| ImageSelected File
- | ImageLoaded GApi.Response
- | ImageSex Int Bool
- | ImageVio Int Bool
+ | ImageMsg Img.Msg
| TraitDel Int
| TraitSel Int Int
| TraitSpoil Int Int
@@ -240,13 +229,10 @@ update msg model =
Nothing -> ({ model | mainSearch = A.clear nm "", main = Just m1.id, mainName = m1.name }, c)
MainSpoil n -> ({ model | mainSpoil = n }, Cmd.none)
- ImageSet s -> ({ model | image = if s == "" then Nothing else Just s}, 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)
- ImageSelected f -> ({ model | imageState = Api.Loading }, Api.postImage Api.Ch f ImageLoaded)
- ImageLoaded (GApi.Image i _ _) -> ({ model | image = Just i, imageNew = Set.insert i model.imageNew, imageState = Api.Normal }, Cmd.none)
- ImageLoaded e -> ({ model | imageState = Api.Error e }, Cmd.none)
- ImageSex i _ -> ({ model | imageSex = Just i }, Cmd.none)
- ImageVio i _ -> ({ model | imageVio = Just i }, Cmd.none)
+ 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)
@@ -258,7 +244,7 @@ update msg model =
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, applicable = t.applicable, new = True }] }, Cmd.none)
+ else ({ model | traitSearch = A.clear nm "", traits = model.traits ++ [{ tid = t.id, spoil = t.defaultspoil, name = t.name, group = t.group_name, applicable = t.applicable, new = True }] }, 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)
@@ -275,7 +261,7 @@ update msg model =
if List.any (\v -> v.vid == vn.id) model.vns
then ({ model | vnSearch = A.clear nm "" }, c)
else ({ model | vnSearch = A.clear nm "", vns = model.vns ++ [{ vid = vn.id, title = vn.title, rid = Nothing, spoil = 0, role = "primary" }] }
- , if Dict.member vn.id model.releases then Cmd.none else GR.send { vid = vn.id } (VnRelGet vn.id))
+ , Cmd.batch [c, if Dict.member vn.id model.releases then Cmd.none else GR.send { vid = vn.id } (VnRelGet vn.id)])
VnRelGet vid (GApi.Releases r) -> ({ model | releases = Dict.insert vid r model.releases }, Cmd.none)
VnRelGet _ r -> ({ model | state = Api.Error r }, Cmd.none) -- XXX
@@ -288,6 +274,7 @@ isValid : Model -> Bool
isValid model = not
( (model.name /= "" && model.name == model.original)
|| hasDuplicates (List.map (\v -> (v.vid, Maybe.withDefault 0 v.rid)) model.vns)
+ || not (Img.isValid model.image)
)
@@ -379,48 +366,28 @@ view model =
]
image =
- div [ class "formimage" ]
- [ div [] [
- case model.image of
- Nothing -> text "No image."
- Just id -> img [ src (imageUrl id) ] []
- ]
- , div []
+ table [ class "formimage" ] [ tr []
+ [ td [] [ Img.viewImg model.image ]
+ , td []
[ h2 [] [ text "Image ID" ]
- , inputText "" (Maybe.withDefault "" model.image) ImageSet GCE.valImage
- , Maybe.withDefault (text "") <| Maybe.map (\i -> a [ href <| "/img/"++i ] [ text " (flagging)" ]) model.image
+ , input ([ type_ "text", class "text", tabindex 10, value (Maybe.withDefault "" model.image.id), onInputValidation ImageSet ] ++ GCE.valImage) []
, br [] []
, text "Use an image that already exists on the server or empty to remove the current image."
, br_ 2
, h2 [] [ text "Upload new image" ]
, inputButton "Browse image" ImageSelect []
- , case model.imageState of
- Api.Normal -> text ""
- Api.Loading -> span [ class "spinner" ] []
- Api.Error e -> b [ class "standout" ] [ text <| Api.showResponse e ]
, br [] []
, text "Image must be in JPEG or PNG format and at most 10 MiB. Images larger than 256x300 will automatically be resized."
- , if not (Set.member (Maybe.withDefault "" model.image) model.imageNew) then text "" else div []
- [ br [] []
- , text "Please flag this image: (see the ", a [ href "/d19" ] [ text "image flagging guidelines" ], text " for guidance)"
- , table []
- [ thead [] [ tr [] [ td [] [ text "Sexual" ], td [] [ text "Violence" ] ] ]
- , tr []
- [ td []
- [ label [] [ inputRadio "" (model.imageSex == Just 0) (ImageSex 0), text " Safe" ], br [] []
- , label [] [ inputRadio "" (model.imageSex == Just 1) (ImageSex 1), text " Suggestive" ], br [] []
- , label [] [ inputRadio "" (model.imageSex == Just 2) (ImageSex 2), text " Explicit" ]
- ]
- , td []
- [ label [] [ inputRadio "" (model.imageVio == Just 0) (ImageVio 0), text " Tame" ], br [] []
- , label [] [ inputRadio "" (model.imageVio == Just 1) (ImageVio 1), text " Violent" ], br [] []
- , label [] [ inputRadio "" (model.imageVio == Just 2) (ImageVio 2), text " Brutal" ]
- ]
+ , case Img.viewVote model.image of
+ Nothing -> text ""
+ Just v ->
+ div []
+ [ br [] []
+ , text "Please flag this image: (see the ", a [ href "/d19" ] [ text "image flagging guidelines" ], text " for guidance)"
+ , Html.map ImageMsg v
]
- ]
- ]
]
- ]
+ ] ]
traits =
let
diff --git a/elm/Discussions/Edit.elm b/elm/Discussions/Edit.elm
index f8873fa7..6008cdef 100644
--- a/elm/Discussions/Edit.elm
+++ b/elm/Discussions/Edit.elm
@@ -25,8 +25,7 @@ main = Browser.element
type alias Model =
{ state : Api.State
- , tid : Maybe Int
- , num : Maybe Int
+ , tid : Maybe String
, can_mod : Bool
, can_private : Bool
, locked : Bool
@@ -50,7 +49,6 @@ init d =
, can_mod = d.can_mod
, can_private = d.can_private
, tid = d.tid
- , num = d.num
, locked = d.locked
, hidden = d.hidden
, private = d.private
@@ -73,7 +71,6 @@ searchConfig = { wrap = BoardSearch, id = "boardadd", source = A.boardSource }
encode : Model -> GDE.Send
encode m =
{ tid = m.tid
- , num = m.num
, locked = m.locked
, hidden = m.hidden
, private = m.private
@@ -148,8 +145,6 @@ update msg model =
view : Model -> Html Msg
view model =
let
- thread = model.tid == Nothing || model.num == Just 1
-
board n bd =
li [] <|
[ text "["
@@ -184,7 +179,7 @@ view model =
else text ""
]
- poll () =
+ poll =
[ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "" [ label [] [ inputCheck "" model.pollEnabled PollEnabled, text " Add poll" ] ]
] ++
@@ -211,26 +206,22 @@ view model =
in
form_ Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
- [ h1 [] [ text <| if model.tid == Nothing then "Create new thread" else "Edit post" ]
+ [ h1 [] [ text <| if model.tid == Nothing then "Create new thread" else "Edit thread" ]
, table [ class "formtable" ] <|
- [ if thread
- then formField "title::Thread title" [ inputText "title" (Maybe.withDefault "" model.title) Title (style "width" "400px" :: required True :: GDE.valTitle) ]
- else formField "Topic" [ a [ href <| "/t" ++ String.fromInt (Maybe.withDefault 0 model.tid) ] [ text (Maybe.withDefault "" model.title) ] ]
- , if thread && model.can_mod
+ [ formField "title::Thread title" [ inputText "title" (Maybe.withDefault "" model.title) Title (style "width" "400px" :: required True :: GDE.valTitle) ]
+ , if model.can_mod
then formField "" [ label [] [ inputCheck "" model.locked Locked, text " Locked" ] ]
else text ""
, if model.can_mod
then formField "" [ label [] [ inputCheck "" model.hidden Hidden, text " Hidden" ] ]
else text ""
- , if thread && model.can_private
+ , if model.can_private
then formField "" [ label [] [ inputCheck "" model.private Private, text " Private" ] ]
else text ""
, if model.tid /= Nothing && model.can_mod
then formField "" [ label [] [ inputCheck "" model.nolastmod Nolastmod, text " Don't update last modification timestamp" ] ]
else text ""
- , if thread
- then formField "boardadd::Boards" (boards ())
- else text ""
+ , formField "boardadd::Boards" (boards ())
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "msg::Message"
[ TP.view "msg" model.msg Content 700 ([rows 12, cols 50] ++ GDE.valMsg)
@@ -239,15 +230,10 @@ view model =
]
]
]
- ++ (if thread then poll () else [])
- ++ (if not model.can_mod then [] else
+ ++ poll
+ ++ (if not model.can_mod || model.tid == Nothing then [] else
[ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "DANGER ZONE" ] ]
- , formField ""
- [ inputCheck "" model.delete Delete
- , text <| " Permanently delete this " ++ if thread then "thread and all replies." else "post."
- , text <| if thread then "" else " This causes all replies after this one to be renumbered."
- , text <| " This action can not be reverted, only do this with obvious spam!"
- ]
+ , 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" ]
diff --git a/elm/Discussions/PostEdit.elm b/elm/Discussions/PostEdit.elm
new file mode 100644
index 00000000..0eb787d2
--- /dev/null
+++ b/elm/Discussions/PostEdit.elm
@@ -0,0 +1,108 @@
+module Discussions.PostEdit exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Browser
+import Browser.Navigation exposing (load)
+import Lib.Html exposing (..)
+import Lib.TextPreview as TP
+import Lib.Api as Api
+import Gen.Api as GApi
+import Gen.DiscussionsPostEdit as GPE
+
+
+main : Program GPE.Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { state : Api.State
+ , id : String
+ , num : Int
+ , can_mod : Bool
+ , hidden : Bool
+ , nolastmod : Bool
+ , delete : Bool
+ , msg : TP.Model
+ }
+
+
+init : GPE.Recv -> Model
+init d =
+ { state = Api.Normal
+ , id = d.id
+ , num = d.num
+ , can_mod = d.can_mod
+ , hidden = d.hidden
+ , nolastmod = False
+ , delete = False
+ , msg = TP.bbcode d.msg
+ }
+
+encode : Model -> GPE.Send
+encode m =
+ { id = m.id
+ , num = m.num
+ , hidden = m.hidden
+ , nolastmod = m.nolastmod
+ , delete = m.delete
+ , msg = m.msg.data
+ }
+
+
+type Msg
+ = Hidden Bool
+ | Nolastmod Bool
+ | Delete Bool
+ | Content TP.Msg
+ | Submit
+ | Submitted GApi.Response
+
+
+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)
+ Content m -> let (nm,nc) = TP.update m model.msg in ({ model | msg = nm }, Cmd.map Content nc)
+
+ Submit -> ({ model | state = Api.Loading }, GPE.send (encode model) Submitted)
+ Submitted (GApi.Redirect s) -> (model, load s)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ form_ Submit (model.state == Api.Loading)
+ [ div [ class "mainbox" ]
+ [ 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" ] ]
+ else text ""
+ , if model.can_mod
+ then formField "" [ label [] [ inputCheck "" model.nolastmod Nolastmod, text " Don't update last modification timestamp" ] ]
+ else text ""
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , formField "msg::Message"
+ [ TP.view "msg" model.msg Content 700 ([rows 12, cols 50] ++ GPE.valMsg)
+ [ b [ class "standout" ] [ text " (English please!) " ]
+ , a [ href "/d9#3" ] [ text "Formatting" ]
+ ]
+ ]
+ ]
+ ++ (if not model.can_mod then [] else
+ [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "DANGER ZONE" ] ]
+ , formField "" [ inputCheck "" model.delete Delete, text " Permanently delete this post. This action can not be reverted, only do this with obvious spam!" ]
+ ])
+ ]
+ , div [ class "mainbox" ]
+ [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ] ]
+ ]
diff --git a/elm/Discussions/Reply.elm b/elm/Discussions/Reply.elm
index a8d25434..3581c91f 100644
--- a/elm/Discussions/Reply.elm
+++ b/elm/Discussions/Reply.elm
@@ -22,7 +22,7 @@ main = Browser.element
type alias Model =
{ state : Api.State
- , tid : Int
+ , tid : String
, old : Bool
, msg : TP.Model
}
diff --git a/elm/Lib/Api.elm b/elm/Lib/Api.elm
index 4af28ea6..fd4a3a7e 100644
--- a/elm/Lib/Api.elm
+++ b/elm/Lib/Api.elm
@@ -45,14 +45,15 @@ showResponse res =
BadCurPass -> "Current password is invalid."
MailChange -> unexp
ImgFormat -> "Unrecognized image format, only JPEG and PNG are accepted."
- Image _ _ _ -> unexp
Releases _ -> unexp
BoardResult _ -> unexp
TagResult _ -> unexp
TraitResult _ -> unexp
VNResult _ -> unexp
ProducerResult _ -> unexp
+ StaffResult _ -> unexp
CharResult _ -> unexp
+ AnimeResult _ -> unexp
ImageResult _ -> unexp
diff --git a/elm/Lib/Autocomplete.elm b/elm/Lib/Autocomplete.elm
index 738f6008..5c5dd33d 100644
--- a/elm/Lib/Autocomplete.elm
+++ b/elm/Lib/Autocomplete.elm
@@ -9,7 +9,9 @@ module Lib.Autocomplete exposing
, traitSource
, vnSource
, producerSource
+ , staffSource
, charSource
+ , animeSource
, init
, clear
, update
@@ -35,7 +37,9 @@ import Gen.Tags as GT
import Gen.Traits as GTR
import Gen.VN as GV
import Gen.Producers as GP
+import Gen.Staff as GS
import Gen.Chars as GC
+import Gen.Anime as GA
type alias Config m a =
@@ -123,7 +127,7 @@ traitSource =
vnSource : SourceConfig m GApi.ApiVNResult
vnSource =
- { source = Endpoint (\s -> GV.send { search = s })
+ { source = Endpoint (\s -> GV.send { search = [s], hidden = False })
<| \x -> case x of
GApi.VNResult e -> Just e
_ -> Nothing
@@ -136,7 +140,7 @@ vnSource =
producerSource : SourceConfig m GApi.ApiProducerResult
producerSource =
- { source = Endpoint (\s -> GP.send { search = s })
+ { source = Endpoint (\s -> GP.send { search = [s], hidden = False })
<| \x -> case x of
GApi.ProducerResult e -> Just e
_ -> Nothing
@@ -147,6 +151,19 @@ producerSource =
}
+staffSource : SourceConfig m GApi.ApiStaffResult
+staffSource =
+ { source = Endpoint (\s -> GS.send { search = s })
+ <| \x -> case x of
+ GApi.StaffResult e -> Just e
+ _ -> Nothing
+ , view = \i ->
+ [ b [ class "grayedout" ] [ text <| "s" ++ String.fromInt i.id ++ ": " ]
+ , text i.name ]
+ , key = \i -> String.fromInt i.aid
+ }
+
+
charSource : SourceConfig m GApi.ApiCharResult
charSource =
{ source = Endpoint (\s -> GC.send { search = s })
@@ -164,6 +181,19 @@ charSource =
}
+animeSource : SourceConfig m GApi.ApiAnimeResult
+animeSource =
+ { source = Endpoint (\s -> GA.send { search = s })
+ <| \x -> case x of
+ GApi.AnimeResult e -> Just e
+ _ -> Nothing
+ , view = \i ->
+ [ b [ class "grayedout" ] [ text <| "a" ++ String.fromInt i.id ++ ": " ]
+ , text i.title ]
+ , key = \i -> String.fromInt i.id
+ }
+
+
type alias Model a =
{ visible : Bool
, value : String
diff --git a/elm/Lib/Editsum.elm b/elm/Lib/Editsum.elm
index 656441e8..20a51872 100644
--- a/elm/Lib/Editsum.elm
+++ b/elm/Lib/Editsum.elm
@@ -59,5 +59,8 @@ 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!)"] ] ]
+ [ b [class "title"] [ text "Edit summary", b [class "standout"] [ text " (English please!)" ] ]
+ , br [] []
+ , text "Summarize the changes you have made, including links to source(s)."
+ ]
]
diff --git a/elm/Lib/Image.elm b/elm/Lib/Image.elm
new file mode 100644
index 00000000..31bab0b3
--- /dev/null
+++ b/elm/Lib/Image.elm
@@ -0,0 +1,183 @@
+module Lib.Image exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Process
+import Task
+import File exposing (File)
+import Lib.Html exposing (..)
+import Lib.Api as Api
+import Lib.Util exposing (imageUrl)
+import Gen.Api as GApi
+import Gen.Image as GI
+import Gen.ImageVote as GIV
+
+
+type State
+ = Normal
+ | Invalid
+ | NotFound
+ | Loading
+ | Error GApi.Response
+
+type alias Image =
+ { id : Maybe String
+ , img : Maybe GApi.ApiImageResult
+ , imgState : State
+ , saveState : Api.State
+ , saveTimer : Bool
+ }
+
+
+info : Maybe GApi.ApiImageResult -> Image
+info img =
+ { id = Maybe.map (\i -> i.id) img
+ , img = img
+ , imgState = Normal
+ , saveState = Api.Normal
+ , saveTimer = False
+ }
+
+
+-- Fetch image info from the ID
+new : Bool -> String -> (Image, Cmd Msg)
+new valid id =
+ ( { id = if id == "" then Nothing else Just id
+ , img = Nothing
+ , imgState = if id == "" then Normal else if valid then Loading else Invalid
+ , saveState = Api.Normal
+ , saveTimer = False
+ }
+ , if valid && id /= "" then GI.send { id = id } Loaded else Cmd.none
+ )
+
+
+-- Upload a new image from a form
+upload : Api.ImageType -> File -> (Image, Cmd Msg)
+upload t f =
+ ( { id = Nothing
+ , img = Nothing
+ , imgState = Loading
+ , saveState = Api.Normal
+ , saveTimer = False
+ }
+ , Api.postImage t f Loaded)
+
+
+type Msg
+ = Loaded GApi.Response
+ | MySex Int Bool
+ | MyVio Int Bool
+ | Save
+ | Saved GApi.Response
+
+
+update : Msg -> Image -> (Image, Cmd Msg)
+update msg model =
+ let
+ save m =
+ if m.saveTimer || Maybe.withDefault True (Maybe.map (\i -> i.token == Nothing || i.my_sexual == Nothing || i.my_violence == Nothing) m.img)
+ then (m, Cmd.none)
+ else ({ m | saveTimer = True }, Task.perform (always Save) (Process.sleep 1000))
+ in
+ case msg of
+ Loaded (GApi.ImageResult [i]) -> ({ model | id = Just i.id, img = Just i, imgState = Normal}, Cmd.none)
+ Loaded (GApi.ImageResult []) -> ({ model | imgState = NotFound}, Cmd.none)
+ Loaded e -> ({ model | imgState = Error e }, Cmd.none)
+
+ MySex v _ -> save { model | img = Maybe.map (\i -> { i | my_sexual = Just v }) model.img }
+ MyVio v _ -> save { model | img = Maybe.map (\i -> { i | my_violence = Just v }) model.img }
+
+ Save ->
+ case Maybe.map (\i -> (i.token, i.my_sexual, i.my_violence)) model.img of
+ Just (Just token, Just sex, Just vio) ->
+ ( { model | saveTimer = False, saveState = Api.Loading }
+ , GIV.send { votes = [{ id = Maybe.withDefault "" model.id, token = token, sexual = sex, violence = vio, overrule = False }] } Saved)
+ _ -> (model, Cmd.none)
+ Saved (GApi.Success) -> ({ model | saveState = Api.Normal}, Cmd.none)
+ Saved e -> ({ model | saveState = Api.Error e }, Cmd.none)
+
+
+
+isValid : Image -> Bool
+isValid img = img.imgState == Normal
+
+
+viewImg : Image -> Html m
+viewImg image =
+ case (image.imgState, image.img) of
+ (Loading, _) -> div [ class "spinner" ] []
+ (NotFound, _) -> b [ class "standout" ] [ text "Image not found." ]
+ (Invalid, _) -> b [ class "standout" ] [ text "Invalid image ID." ]
+ (Error e, _) -> b [ class "standout" ] [ text <| Api.showResponse e ]
+ (_, Nothing) -> text "No image."
+ (_, Just i) ->
+ let
+ maxWidth = toFloat <| if String.startsWith "sf" i.id then 136 else 10000
+ maxHeight = toFloat <| if String.startsWith "sf" i.id then 102 else 10000
+ sWidth = maxWidth / toFloat i.width
+ sHeight = maxHeight / toFloat i.height
+ scale = Basics.min 1 <| if sWidth < sHeight then sWidth else sHeight
+ imgWidth = round <| scale * toFloat i.width
+ imgHeight = round <| scale * toFloat i.height
+ in
+ -- TODO: Onclick iv.js support for screenshot thumbnails
+ label [ class "imghover", style "width" (String.fromInt imgWidth++"px"), style "height" (String.fromInt imgHeight++"px") ]
+ [ div [ class "imghover--visible" ]
+ [ if String.startsWith "sf" i.id
+ then a [ href (imageUrl i.id), attribute "data-iv" <| String.fromInt i.width ++ "x" ++ String.fromInt i.height ++ ":scr" ]
+ [ img [ src <| imageUrl <| String.replace "sf" "st" 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 " / "
+ , text <| if vio > 1.3 then "Brutal" else if vio > 0.4 then "Violent" else "Safe"
+ , text <| " (" ++ String.fromInt i.votecount ++ ")"
+ ]
+ _ -> [ text "Not flagged" ]
+ ]
+ ]
+
+
+viewVote : Image -> Maybe (Html Msg)
+viewVote model =
+ let
+ rad i sex val = input
+ [ type_ "radio"
+ , tabindex 10
+ , required True
+ , onCheck <| (if sex then MySex else MyVio) val
+ , checked <| (if sex then i.my_sexual else i.my_violence) == Just val
+ , name <| "imgvote-" ++ (if sex then "sex" else "vio") ++ "-" ++ Maybe.withDefault "" model.id
+ ] []
+ vote i = table []
+ [ thead [] [ tr []
+ [ td [] [ text "Sexual ", if model.saveState == Api.Loading then span [ class "spinner" ] [] else text "" ]
+ , td [] [ text "Violence" ]
+ ] ]
+ , tfoot [] <|
+ case model.saveState of
+ Api.Error e -> [ tr [] [ td [ colspan 2 ] [ b [ class "standout" ] [ text (Api.showResponse e) ] ] ] ]
+ _ -> []
+ , tr []
+ [ td [ style "white-space" "nowrap" ]
+ [ label [] [ rad i True 0, text " Safe" ], br [] []
+ , label [] [ rad i True 1, text " Suggestive" ], br [] []
+ , label [] [ rad i True 2, text " Explicit" ]
+ ]
+ , td [ style "white-space" "nowrap" ]
+ [ label [] [ rad i False 0, text " Tame" ], br [] []
+ , label [] [ rad i False 1, text " Violent" ], br [] []
+ , label [] [ rad i False 2, text " Brutal" ]
+ ]
+ ]
+ ]
+ in case model.img of
+ Nothing -> Nothing
+ Just i ->
+ if i.token == Nothing then Nothing
+ else Just (vote i)
diff --git a/elm/ProducerEdit.elm b/elm/ProducerEdit.elm
new file mode 100644
index 00000000..0fd78375
--- /dev/null
+++ b/elm/ProducerEdit.elm
@@ -0,0 +1,231 @@
+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 Int
+ , 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 [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "p" ++ String.fromInt p.pid ++ ":" ] ]
+ , td [ style "text-align" "right"] [ a [ href <| "/p" ++ String.fromInt p.pid ] [ text p.name ] ]
+ , td []
+ [ text "is an "
+ , inputSelect "" p.relation (RelRel i) [] GT.producerRelations
+ , text " of this producer"
+ ]
+ , 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" ++ String.fromInt 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/Report.elm b/elm/Report.elm
new file mode 100644
index 00000000..f63a9411
--- /dev/null
+++ b/elm/Report.elm
@@ -0,0 +1,184 @@
+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 / wrong board"
+ , 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 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 = "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
new file mode 100644
index 00000000..fba37168
--- /dev/null
+++ b/elm/Reviews/Comment.elm
@@ -0,0 +1,52 @@
+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
new file mode 100644
index 00000000..925de964
--- /dev/null
+++ b/elm/Reviews/Edit.elm
@@ -0,0 +1,195 @@
+module Reviews.Edit exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Browser
+import Browser.Navigation exposing (load)
+import Lib.Html exposing (..)
+import Lib.TextPreview as TP
+import Lib.Api as Api
+import Lib.Util exposing (..)
+import Lib.RDate as RDate
+import Gen.Api as GApi
+import Gen.ReviewsEdit as GRE
+import Gen.ReviewsDelete as GRD
+
+
+main : Program GRE.Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { state : Api.State
+ , id : Maybe String
+ , vid : Int
+ , vntitle : String
+ , rid : Maybe Int
+ , spoiler : Bool
+ , locked : Bool
+ , isfull : Bool
+ , text : TP.Model
+ , releases : List GRE.RecvReleases
+ , delete : Bool
+ , delState : Api.State
+ , mod : Bool
+ }
+
+
+init : GRE.Recv -> Model
+init d =
+ { state = Api.Normal
+ , id = d.id
+ , vid = d.vid
+ , vntitle = d.vntitle
+ , rid = d.rid
+ , spoiler = d.spoiler
+ , locked = d.locked
+ , isfull = d.isfull
+ , text = TP.bbcode d.text
+ , releases = d.releases
+ , delete = False
+ , delState = Api.Normal
+ , mod = d.mod
+ }
+
+
+encode : Model -> GRE.Send
+encode m =
+ { id = m.id
+ , vid = m.vid
+ , rid = m.rid
+ , spoiler = m.spoiler
+ , locked = m.locked
+ , isfull = m.isfull
+ , text = m.text.data
+ }
+
+
+type Msg
+ = Release (Maybe Int)
+ | Full Bool
+ | Spoiler Bool
+ | Locked Bool
+ | Text TP.Msg
+ | Submit
+ | Submitted GApi.Response
+ | Delete Bool
+ | DoDelete
+ | Deleted GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Release i -> ({ model | rid = i }, Cmd.none)
+ Full b -> ({ model | isfull = b }, Cmd.none)
+ Spoiler b -> ({ model | spoiler = b }, Cmd.none)
+ Locked b -> ({ model | locked = b }, Cmd.none)
+ Text m -> let (nm,nc) = TP.update m model.text in ({ model | text = nm }, Cmd.map Text nc)
+
+ Submit -> ({ model | state = Api.Loading }, GRE.send (encode model) Submitted)
+ Submitted (GApi.Redirect s) -> (model, load s)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+ Delete b -> ({ model | delete = b }, Cmd.none)
+ DoDelete -> ({ model | delState = Api.Loading }, GRD.send ({ id = Maybe.withDefault "" model.id }) Deleted)
+ Deleted GApi.Success -> (model, load <| "/v" ++ String.fromInt model.vid)
+ Deleted r -> ({ model | delState = Api.Error r }, Cmd.none)
+
+
+showrel r = "[" ++ (RDate.format (RDate.expand r.released)) ++ " " ++ (String.join "," r.lang) ++ "] " ++ r.title ++ " (r" ++ String.fromInt r.id ++ ")"
+
+view : Model -> Html Msg
+view model =
+ let minChars = if model.isfull then 1000 else 200
+ maxChars = if model.isfull then 100000 else 800
+ len = String.length model.text.data
+ in
+ form_ Submit (model.state == Api.Loading)
+ [ div [ class "mainbox" ]
+ [ h1 [] [ text <| if model.id == Nothing then "Submit a review" else "Edit review" ]
+ , p [] [ b [] [ text "Rules" ] ]
+ , ul []
+ [ li [] [ text "Submit only reviews you have written yourself!" ]
+ , li [] [ text "Reviews must be in English." ]
+ , li [] [ text "Try to be as objective as possible." ]
+ , li [] [ text "If you have published the review elsewhere (e.g. a personal blog), feel free to include a link at the end of the review. Formatting tip: ", em [] [ text "[Originally published at <link>]" ] ]
+ , li [] [ text "Your vote (if any) will be displayed alongside the review, even if you have marked your list as private." ]
+ ]
+ , br [] []
+ ]
+ , div [ class "mainbox" ]
+ [ table [ class "formtable" ]
+ [ formField "Subject" [ a [ href <| "/v"++String.fromInt 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
+ ++ 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 "" (Maybe.map String.fromInt 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" ]
+ , text <| " - Recommendation-style, maximum 800 characters." ]
+ , br [] []
+ , label [] [ inputRadio "type" (model.isfull == True ) (\_ -> Full True ), b [] [ text " Full review" ]
+ , text " - Longer, more detailed." ]
+ , br [] []
+ , b [ class "grayedout" ] [ 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." ]
+ ]
+ , if not model.mod then text "" else
+ formField "" [ label [] [ inputCheck "" model.locked Locked, text " Locked for commenting." ] ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , formField "text::Review"
+ [ TP.view "sum" model.text Text 700 ([rows (if model.isfull then 30 else 10), cols 50] ++ GRE.valText)
+ [ a [ href "/d9#3" ] [ 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
+ in
+ [ num (len < minChars) (String.fromInt minChars)
+ , text " / "
+ , b [] [ 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)
+ ]
+ ]
+ , if model.id == Nothing then text "" else
+ div [ class "mainbox" ]
+ [ 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:" ]
+ , 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.Normal -> text ""
+ ]
+ ] ]
+ ]
+ ]
diff --git a/elm/Reviews/Vote.elm b/elm/Reviews/Vote.elm
new file mode 100644
index 00000000..490a2b78
--- /dev/null
+++ b/elm/Reviews/Vote.elm
@@ -0,0 +1,70 @@
+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/UList/ManageLabels.js b/elm/UList/ManageLabels.js
index f9f8c68b..3ff2db61 100644
--- a/elm/UList/ManageLabels.js
+++ b/elm/UList/ManageLabels.js
@@ -1,11 +1,3 @@
-document.querySelectorAll('#managelabels').forEach(function(b) {
- b.onclick = function() {
- document.querySelectorAll('.managelabels').forEach(function(e) { e.classList.toggle('hidden') })
- document.querySelectorAll('.savedefault').forEach(function(e) { e.classList.add('hidden') })
- };
- return false;
-});
-
wrap_elm_init('UList.ManageLabels', function(init, opt) {
opt.flags = { uid: pageVars.uid, labels: pageVars.labels };
init(opt);
diff --git a/elm/UList/SaveDefault.js b/elm/UList/SaveDefault.js
deleted file mode 100644
index a253680f..00000000
--- a/elm/UList/SaveDefault.js
+++ /dev/null
@@ -1,7 +0,0 @@
-document.querySelectorAll('#savedefault').forEach(function(b) {
- b.onclick = function() {
- document.querySelectorAll('.savedefault').forEach(function(e) { e.classList.toggle('hidden') })
- document.querySelectorAll('.managelabels').forEach(function(e) { e.classList.add('hidden') })
- };
- return false;
-});
diff --git a/elm/UList/VNPage.elm b/elm/UList/VNPage.elm
index 64c5f99a..b41e6ea1 100644
--- a/elm/UList/VNPage.elm
+++ b/elm/UList/VNPage.elm
@@ -18,26 +18,7 @@ import Gen.UListDel as GDE
import UList.LabelEdit as LE
import UList.VoteEdit as VE
--- We don't have a Gen.* module for this (yet), so define these manually
-type alias RecvLabels =
- { id : Int
- , label : String
- , private : Bool
- }
-
-type alias Recv =
- { uid : Int
- , vid : Int
- , onlist : Bool
- , canvote : Bool
- , vote : Maybe String
- , labels : List RecvLabels
- , selected : List Int
- , notes : String
- }
-
-
-main : Program Recv Model Msg
+main : Program GVN.VNPage Model 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) ]
@@ -46,7 +27,7 @@ main = Browser.element
}
type alias Model =
- { flags : Recv
+ { flags : GVN.VNPage
, onlist : Bool
, del : Bool
, state : Api.State -- For adding/deleting; Vote and label edit widgets have their own state
@@ -58,7 +39,7 @@ type alias Model =
, notesVis : Bool
}
-init : Recv -> Model
+init : GVN.VNPage -> Model
init f =
{ flags = f
, onlist = f.onlist
@@ -165,9 +146,11 @@ view model =
, td []
[ a [ href "#", onClickD 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 ]
- _ -> text ""
+ , case (model.notesState, model.vote.vote /= Nothing && model.flags.canreview, model.flags.review) of
+ (Api.Error e, _, _) -> b [ class "standout" ] [ text <| Api.showResponse e ]
+ (_, False, _) -> text ""
+ (_, True, Nothing) -> a [ href ("/v" ++ String.fromInt model.flags.vid ++ "/addreview") ] [ text " write a review »" ]
+ (_, True, Just w) -> a [ href ("/" ++ w ++ "/edit") ] [ text " edit review »" ]
]
]
else text ""
diff --git a/elm/UList/actiontabs.js b/elm/UList/actiontabs.js
new file mode 100644
index 00000000..0ae2b7f9
--- /dev/null
+++ b/elm/UList/actiontabs.js
@@ -0,0 +1,17 @@
+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/User/Edit.elm b/elm/User/Edit.elm
index c8ecdddb..d09c77ae 100644
--- a/elm/User/Edit.elm
+++ b/elm/User/Edit.elm
@@ -61,6 +61,7 @@ init d =
type AdminMsg
= PermBoard Bool
+ | PermReview Bool
| PermBoardmod Bool
| PermEdit Bool
| PermImgvote Bool
@@ -73,7 +74,6 @@ type AdminMsg
type PrefMsg
= EMail String
- | ShowNsfw Bool
| MaxSexual Int
| MaxViolence Int
| TraitsSexual Bool
@@ -109,6 +109,7 @@ 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 }
@@ -123,7 +124,6 @@ updatePrefs : PrefMsg -> GUE.SendPrefs -> GUE.SendPrefs
updatePrefs msg model =
case msg of
EMail n -> { model | email = n }
- ShowNsfw b -> { model | show_nsfw = b }
MaxSexual n-> { model | max_sexual = n }
MaxViolence n -> { model | max_violence = n }
TraitsSexual b -> { model | traits_sexual = b }
@@ -191,6 +191,7 @@ view model =
, 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 ]
@@ -231,11 +232,8 @@ view model =
prefsform m =
[ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Preferences" ] ]
- , formField "NSFW" [ label [] [ inputCheck "" m.show_nsfw (Prefs << ShowNsfw), text " Show NSFW images by default" ] ]
- , formField ""
- [ b [ class "grayedout" ] [ text "The two options below are only used for character images at the moment, they will eventually replace the above checkbox and apply to all images on the site." ]
- , br [] []
- , inputSelect "" m.max_sexual (Prefs << MaxSexual) [style "width" "400px"]
+ , 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")
diff --git a/elm/VNEdit.elm b/elm/VNEdit.elm
new file mode 100644
index 00000000..4fadbf2d
--- /dev/null
+++ b/elm/VNEdit.elm
@@ -0,0 +1,621 @@
+port module VNEdit exposing (main)
+
+import Html exposing (..)
+import Html.Events exposing (..)
+import Html.Keyed as K
+import Html.Attributes exposing (..)
+import Browser
+import Browser.Navigation exposing (load)
+import Dict
+import Set
+import File exposing (File)
+import File.Select as FSel
+import Lib.Util exposing (..)
+import Lib.Html exposing (..)
+import Lib.TextPreview as TP
+import Lib.Autocomplete as A
+import Lib.RDate as RDate
+import Lib.Api as Api
+import Lib.Editsum as Editsum
+import Lib.Image as Img
+import Gen.VN as GV
+import Gen.VNEdit as GVE
+import Gen.Types as GT
+import Gen.Api as GApi
+
+
+main : Program GVE.Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+port ivRefresh : Bool -> Cmd msg
+
+type Tab
+ = General
+ | Image
+ | Staff
+ | Cast
+ | Screenshots
+ | All
+
+type alias Model =
+ { state : Api.State
+ , tab : Tab
+ , editsum : Editsum.Model
+ , title : String
+ , original : String
+ , alias : String
+ , desc : TP.Model
+ , length : Int
+ , lWikidata : Maybe Int
+ , lRenai : String
+ , vns : List GVE.RecvRelations
+ , vnSearch : A.Model GApi.ApiVNResult
+ , anime : List GVE.RecvAnime
+ , animeSearch : A.Model GApi.ApiAnimeResult
+ , image : Img.Image
+ , staff : List GVE.RecvStaff
+ , staffSearch : A.Model GApi.ApiStaffResult
+ , seiyuu : List GVE.RecvSeiyuu
+ , seiyuuSearch: A.Model GApi.ApiStaffResult
+ , seiyuuDef : Int -- character id for newly added seiyuu
+ , screenshots : List (Int,Img.Image,Maybe Int) -- internal id, img, rel
+ , scrUplRel : Maybe Int
+ , scrUplNum : Maybe Int
+ , scrId : Int -- latest used internal id
+ , releases : List GVE.RecvReleases
+ , chars : List GVE.RecvChars
+ , id : Maybe Int
+ , dupCheck : Bool
+ , dupVNs : List GApi.ApiVNResult
+ }
+
+
+init : GVE.Recv -> Model
+init d =
+ { state = Api.Normal
+ , tab = General
+ , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden }
+ , title = d.title
+ , original = d.original
+ , alias = d.alias
+ , desc = TP.bbcode d.desc
+ , length = d.length
+ , lWikidata = d.l_wikidata
+ , lRenai = d.l_renai
+ , vns = d.relations
+ , vnSearch = A.init ""
+ , anime = d.anime
+ , animeSearch = A.init ""
+ , image = Img.info d.image_info
+ , staff = d.staff
+ , staffSearch = A.init ""
+ , seiyuu = d.seiyuu
+ , seiyuuSearch= A.init ""
+ , seiyuuDef = Maybe.withDefault 0 <| List.head <| List.map (\c -> c.id) d.chars
+ , screenshots = List.indexedMap (\n i -> (n, Img.info (Just i.info), i.rid)) d.screenshots
+ , scrUplRel = Nothing
+ , scrUplNum = Nothing
+ , scrId = 100
+ , releases = d.releases
+ , chars = d.chars
+ , id = d.id
+ , dupCheck = False
+ , dupVNs = []
+ }
+
+
+encode : Model -> GVE.Send
+encode model =
+ { id = model.id
+ , editsum = model.editsum.editsum.data
+ , hidden = model.editsum.hidden
+ , locked = model.editsum.locked
+ , title = model.title
+ , original = model.original
+ , alias = model.alias
+ , desc = model.desc.data
+ , length = model.length
+ , l_wikidata = model.lWikidata
+ , l_renai = model.lRenai
+ , relations = List.map (\v -> { vid = v.vid, relation = v.relation, official = v.official }) model.vns
+ , anime = List.map (\a -> { aid = a.aid }) model.anime
+ , image = model.image.id
+ , 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
+ , screenshots = List.map (\(_,i,r) -> { scr = Maybe.withDefault "" i.id, rid = r }) model.screenshots
+ }
+
+vnConfig : A.Config Msg GApi.ApiVNResult
+vnConfig = { wrap = VNSearch, id = "relationadd", source = A.vnSource }
+
+animeConfig : A.Config Msg GApi.ApiAnimeResult
+animeConfig = { wrap = AnimeSearch, id = "animeadd", source = A.animeSource }
+
+staffConfig : A.Config Msg GApi.ApiStaffResult
+staffConfig = { wrap = StaffSearch, id = "staffadd", source = A.staffSource }
+
+seiyuuConfig : A.Config Msg GApi.ApiStaffResult
+seiyuuConfig = { wrap = SeiyuuSearch, id = "seiyuuadd", source = A.staffSource }
+
+type Msg
+ = Editsum Editsum.Msg
+ | Tab Tab
+ | Submit
+ | Submitted GApi.Response
+ | Title String
+ | Original String
+ | Alias String
+ | Desc TP.Msg
+ | Length Int
+ | LWikidata (Maybe Int)
+ | LRenai String
+ | VNDel Int
+ | VNRel Int String
+ | VNOfficial Int Bool
+ | VNSearch (A.Msg GApi.ApiVNResult)
+ | AnimeDel Int
+ | AnimeSearch (A.Msg GApi.ApiAnimeResult)
+ | ImageSet String Bool
+ | ImageSelect
+ | ImageSelected File
+ | ImageMsg Img.Msg
+ | StaffDel Int
+ | StaffRole Int String
+ | StaffNote Int String
+ | StaffSearch (A.Msg GApi.ApiStaffResult)
+ | SeiyuuDef Int
+ | SeiyuuDel Int
+ | SeiyuuChar Int Int
+ | SeiyuuNote Int String
+ | SeiyuuSearch (A.Msg GApi.ApiStaffResult)
+ | ScrUplRel (Maybe Int)
+ | ScrUplSel
+ | ScrUpl File (List File)
+ | ScrMsg Int Img.Msg
+ | ScrRel Int (Maybe Int)
+ | ScrDel Int
+ | 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)
+ Tab t -> ({ model | tab = t }, 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)
+ Length n -> ({ model | length = n }, Cmd.none)
+ LWikidata n-> ({ model | lWikidata = n }, Cmd.none)
+ LRenai s -> ({ model | lRenai = s }, Cmd.none)
+
+ VNDel idx -> ({ model | vns = delidx idx model.vns }, Cmd.none)
+ VNRel idx rel -> ({ model | vns = modidx idx (\v -> { v | relation = rel }) model.vns }, Cmd.none)
+ VNOfficial idx o -> ({ model | vns = modidx idx (\v -> { v | official = o }) model.vns }, Cmd.none)
+ VNSearch m ->
+ let (nm, c, res) = A.update vnConfig m model.vnSearch
+ in case res of
+ Nothing -> ({ model | vnSearch = nm }, c)
+ Just v ->
+ if List.any (\l -> l.vid == v.id) model.vns
+ then ({ model | vnSearch = A.clear nm "" }, c)
+ else ({ model | vnSearch = A.clear nm "", vns = model.vns ++ [{ vid = v.id, title = v.title, original = v.original, relation = "seq", official = True }] }, c)
+
+ AnimeDel i -> ({ model | anime = delidx i model.anime }, Cmd.none)
+ AnimeSearch m ->
+ let (nm, c, res) = A.update animeConfig m model.animeSearch
+ in case res of
+ Nothing -> ({ model | animeSearch = nm }, c)
+ Just a ->
+ if List.any (\l -> l.aid == a.id) model.anime
+ then ({ model | animeSearch = A.clear nm "" }, c)
+ else ({ model | animeSearch = A.clear nm "", anime = model.anime ++ [{ aid = a.id, title = a.title, original = a.original }] }, c)
+
+ ImageSet s b -> let (nm, nc) = Img.new b s in ({ model | image = nm }, Cmd.map ImageMsg nc)
+ ImageSelect -> (model, FSel.file ["image/png", "image/jpg"] 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)
+
+ 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)
+
+ SeiyuuDef c -> ({ model | seiyuuDef = c }, Cmd.none)
+ SeiyuuDel idx -> ({ model | seiyuu = delidx idx model.seiyuu }, Cmd.none)
+ SeiyuuChar idx v -> ({ model | seiyuu = modidx idx (\s -> { s | cid = v }) model.seiyuu }, Cmd.none)
+ SeiyuuNote idx v -> ({ model | seiyuu = modidx idx (\s -> { s | note = v }) model.seiyuu }, Cmd.none)
+ SeiyuuSearch m ->
+ let (nm, c, res) = A.update seiyuuConfig m model.seiyuuSearch
+ in case res of
+ Nothing -> ({ model | seiyuuSearch = nm }, c)
+ Just s -> ({ model | seiyuuSearch = A.clear nm "", seiyuu = model.seiyuu ++ [{ id = s.id, aid = s.aid, name = s.name, original = s.original, cid = model.seiyuuDef, note = "" }] }, c)
+
+ ScrUplRel s -> ({ model | scrUplRel = s }, Cmd.none)
+ ScrUplSel -> (model, FSel.files ["image/png", "image/jpg"] ScrUpl)
+ ScrUpl f1 fl ->
+ if 1 + List.length fl > 10 - List.length model.screenshots
+ then ({ model | scrUplNum = Just (1 + List.length fl) }, Cmd.none)
+ else
+ let imgs = List.map (Img.upload Api.Sf) (f1::fl)
+ in ( { model
+ | scrId = model.scrId + 100
+ , scrUplNum = Nothing
+ , screenshots = model.screenshots ++ List.indexedMap (\n (i,_) -> (model.scrId+n,i,model.scrUplRel)) imgs
+ }
+ , List.indexedMap (\n (_,c) -> Cmd.map (ScrMsg (model.scrId+n)) c) imgs |> Cmd.batch)
+ ScrMsg id m ->
+ let f (i,s,r) =
+ if i /= id then ((i,s,r), Cmd.none)
+ else let (nm,nc) = Img.update m s in ((i,nm,r), Cmd.map (ScrMsg id) nc)
+ lst = List.map f model.screenshots
+ in ({ model | screenshots = List.map Tuple.first lst }, Cmd.batch (ivRefresh True :: List.map Tuple.second lst))
+ ScrRel n s -> ({ model | screenshots = List.map (\(i,img,r) -> if i == n then (i,img,s) else (i,img,r)) model.screenshots }, Cmd.none)
+ ScrDel n -> ({ model | screenshots = List.filter (\(i,_,_) -> i /= n) model.screenshots }, ivRefresh True)
+
+ DupSubmit ->
+ if List.isEmpty model.dupVNs
+ then ({ model | state = Api.Loading }, GV.send { hidden = True, search = model.title :: model.original :: String.lines model.alias } DupResults)
+ else ({ model | dupCheck = True, dupVNs = [] }, Cmd.none)
+ DupResults (GApi.VNResult vns) ->
+ if List.isEmpty vns
+ then ({ model | state = Api.Normal, dupCheck = True, dupVNs = [] }, Cmd.none)
+ else ({ model | state = Api.Normal, dupVNs = vns }, Cmd.none)
+ DupResults r -> ({ model | state = Api.Error r }, Cmd.none)
+
+ Submit -> ({ model | state = Api.Loading }, GVE.send (encode model) Submitted)
+ Submitted (GApi.Redirect s) -> (model, load s)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+-- TODO: Fuzzier matching? Exclude stuff like 'x Edition', etc.
+relAlias : Model -> Maybe GVE.RecvReleases
+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
+
+
+isValid : Model -> Bool
+isValid model = not
+ ( (model.title /= "" && model.title == model.original)
+ || relAlias model /= Nothing
+ || not (Img.isValid model.image)
+ || List.any (\(_,i,r) -> r == Nothing || not (Img.isValid i)) model.screenshots
+ || hasDuplicates (List.map (\s -> (s.aid, s.role)) model.staff)
+ || hasDuplicates (List.map (\s -> (s.aid, s.cid)) model.seiyuu)
+ )
+
+
+view : Model -> Html Msg
+view model =
+ let
+ titles =
+ [ formField "title::Title (romaji)"
+ [ inputText "title" model.title Title (style "width" "500px" :: GVE.valTitle)
+ , if containsNonLatin model.title
+ then b [ class "standout" ] [ br [] [], text "This title field should only contain latin-alphabet characters, please put the \"actual\" title in the field below and the romanization above." ]
+ else text ""
+ ]
+ , formField "original::Original title"
+ [ inputText "original" model.original Original (style "width" "500px" :: GVE.valOriginal)
+ , 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 ""
+ ]
+ , formField "alias::Aliases"
+ [ inputTextArea "alias" model.alias Alias (rows 3 :: GVE.valAlias)
+ , br [] []
+ , if hasDuplicates <| String.lines <| String.toLower model.alias
+ then b [ class "standout" ] [ text "List contains duplicate aliases.", br [] [] ]
+ else
+ case relAlias model of
+ Nothing -> text ""
+ Just r -> span []
+ [ b [ class "standout" ] [ text "Release titles should not be added as alias." ]
+ , br [] []
+ , text "Release: "
+ , a [ href <| "/r"++String.fromInt 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."
+ , br [] []
+ , text "Titles that are listed in the releases should not be added here!"
+ ]
+ ]
+
+ geninfo = titles ++
+ [ formField "desc::Description"
+ [ TP.view "desc" model.desc Desc 600 (style "height" "180px" :: GVE.valDesc) [ b [ class "standout" ] [ text "English please!" ] ]
+ , text "Short description of the main story. Please do not include spoilers, and don't forget to list the source in case you didn't write the description yourself."
+ ]
+ , formField "length::Length" [ inputSelect "length" model.length Length [] GT.vnLengths ]
+ , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.lWikidata LWikidata ]
+ , formField "l_renai::Renai.us link" [ text "http://renai.us/game/", inputText "l_renai" model.lRenai LRenai [], text ".shtml" ]
+
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Database relations" ] ]
+ , formField "Related VNs"
+ [ if List.isEmpty model.vns then text ""
+ else table [] <| List.indexedMap (\i v -> tr []
+ [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "v" ++ String.fromInt v.vid ++ ":" ] ]
+ , td [ style "text-align" "right"] [ a [ href <| "/v" ++ String.fromInt v.vid ] [ text v.title ] ]
+ , td []
+ [ text "is an "
+ , label [] [ inputCheck "" v.official (VNOfficial i), text " official" ]
+ , inputSelect "" v.relation (VNRel i) [] GT.vnRelations
+ , text " of this VN"
+ ]
+ , td [] [ inputButton "remove" (VNDel i) [] ]
+ ]
+ ) model.vns
+ , A.view vnConfig model.vnSearch [placeholder "Add visual novel..."]
+ ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [] ]
+ , formField "Related anime"
+ [ if List.isEmpty model.anime then text ""
+ else table [] <| List.indexedMap (\i e -> tr []
+ [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "a" ++ String.fromInt e.aid ++ ":" ] ]
+ , td [] [ a [ href <| "https://anidb.net/anime/" ++ String.fromInt e.aid ] [ text e.title ] ]
+ , td [] [ inputButton "remove" (AnimeDel i) [] ]
+ ]
+ ) model.anime
+ , A.view animeConfig model.animeSearch [placeholder "Add anime..."]
+ ]
+ ]
+
+ image =
+ table [ class "formimage" ] [ tr []
+ [ td [] [ Img.viewImg model.image ]
+ , td []
+ [ h2 [] [ text "Image ID" ]
+ , input ([ type_ "text", class "text", tabindex 10, value (Maybe.withDefault "" model.image.id), onInputValidation ImageSet ] ++ GVE.valImage) []
+ , br [] []
+ , text "Use an image that already exists on the server or empty to remove the current image."
+ , br_ 2
+ , h2 [] [ text "Upload new image" ]
+ , inputButton "Browse image" ImageSelect []
+ , br [] []
+ , text "Preferably the cover of the CD/DVD/package. Image must be in JPEG or PNG format and at most 10 MiB. Images larger than 256x400 will automatically be resized."
+ , case Img.viewVote model.image of
+ Nothing -> text ""
+ Just v ->
+ div []
+ [ br [] []
+ , text "Please flag this image: (see the ", a [ href "/d19" ] [ text "image flagging guidelines" ], text " for guidance)"
+ , Html.map ImageMsg v
+ ]
+ ]
+ ] ]
+
+ staff =
+ let
+ head =
+ if List.isEmpty model.staff then [] else [
+ thead [] [ tr []
+ [ td [] []
+ , td [] [ text "Staff" ]
+ , td [] [ text "Role" ]
+ , td [] [ text "Note" ]
+ , td [] []
+ ] ] ]
+ foot =
+ 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 [] [] ]
+ 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." ]
+ ]
+ ] ] ]
+ item n s = tr []
+ [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "s" ++ String.fromInt s.id ++ ":" ] ]
+ , td [] [ a [ href <| "/s" ++ String.fromInt s.id ] [ text s.name ] ]
+ , td [] [ inputSelect "" s.role (StaffRole n) [style "width" "150px" ] GT.creditTypes ]
+ , td [] [ inputText "" s.note (StaffNote n) (style "width" "300px" :: GVE.valStaffNote) ]
+ , td [] [ inputButton "remove" (StaffDel n) [] ]
+ ]
+ in table [] <| head ++ [ foot ] ++ List.indexedMap item model.staff
+
+ cast =
+ let
+ chars = List.map (\c -> (c.id, c.name ++ " (c" ++ String.fromInt c.id ++ ")")) model.chars
+ head =
+ if List.isEmpty model.seiyuu then [] else [
+ thead [] [ tr []
+ [ td [] [ text "Character" ]
+ , td [] [ text "Cast" ]
+ , td [] [ text "Note" ]
+ , td [] []
+ ] ] ]
+ foot =
+ tfoot [] [ tr [] [ td [ colspan 4 ]
+ [ br [] []
+ , b [] [ 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 [] [] ]
+ else text ""
+ , inputSelect "" model.seiyuuDef SeiyuuDef [] chars
+ , text " voiced by "
+ , div [ style "display" "inline-block" ] [ A.view seiyuuConfig model.seiyuuSearch [] ]
+ , br [] []
+ , text "Can't find the person you're looking for? You can "
+ , a [ href "/s/new" ] [ text "create a new entry" ]
+ , text ", but "
+ , a [ href "/s/all" ] [ text "please check for aliasses first." ]
+ ] ] ]
+ item n s = tr []
+ [ td [] [ inputSelect "" s.cid (SeiyuuChar n) []
+ <| chars ++ if List.any (\c -> c.id == s.cid) model.chars then [] else [(s.cid, "[deleted/moved character: c" ++ String.fromInt s.cid ++ "]")] ]
+ , td []
+ [ b [ class "grayedout" ] [ text <| "s" ++ String.fromInt s.id ++ ":" ]
+ , a [ href <| "/s" ++ String.fromInt s.id ] [ text s.name ] ]
+ , td [] [ inputText "" s.note (SeiyuuNote n) (style "width" "300px" :: GVE.valSeiyuuNote) ]
+ , td [] [ inputButton "remove" (SeiyuuDel n) [] ]
+ ]
+ in
+ if model.id == Nothing
+ then text <| "Voice actors can be added to this visual novel once it has character entries associated with it. "
+ ++ "To do so, first create this entry without cast, then create the appropriate character entries, and finally come back to this form by editing the visual novel."
+ else if List.isEmpty model.chars && List.isEmpty model.seiyuu
+ then p []
+ [ text "This visual novel does not have any characters associated with it (yet). Please "
+ , a [ href <| "/v" ++ Maybe.withDefault "" (Maybe.map String.fromInt model.id) ++ "/addchar" ] [ text "add the appropriate character entries" ]
+ , text " first and then come back to this form to assign voice actors."
+ ]
+ else table [] <| head ++ [ foot ] ++ List.indexedMap item model.seiyuu
+
+ screenshots =
+ let
+ showrel r = "[" ++ (RDate.format (RDate.expand r.released)) ++ " " ++ (String.join "," r.lang) ++ "] " ++ r.title ++ " (r" ++ String.fromInt r.id ++ ")"
+ rellist = List.map (\r -> (Just r.id, showrel r)) model.releases
+ scr n (id, i, rel) = (String.fromInt id, tr [] <|
+ let getdim img = Maybe.map (\nfo -> (nfo.width, nfo.height)) img |> Maybe.withDefault (0,0)
+ imgdim = getdim i.img
+ relnfo = List.filter (\r -> Just r.id == rel) model.releases |> List.head
+ reldim = relnfo |> Maybe.andThen (\r -> if r.reso_x == 0 then Nothing else Just (r.reso_x, r.reso_y))
+ dimstr (x,y) = String.fromInt x ++ "x" ++ String.fromInt y
+ in
+ [ td [] [ Img.viewImg i ]
+ , td [] [ Img.viewVote i |> Maybe.map (Html.map (ScrMsg id)) |> Maybe.withDefault (text "") ]
+ , td []
+ [ b [] [ text <| "Screenshot #" ++ String.fromInt (n+1) ]
+ , text " (", a [ href "#", onClickD (ScrDel id) ] [ text "remove" ], text ")"
+ , br [] []
+ , text <| "Image resolution: " ++ dimstr imgdim
+ , br [] []
+ , text <| Maybe.withDefault "" <| Maybe.map (\dim -> "Release resolution: " ++ dimstr dim) reldim
+ , span [] <|
+ if reldim == Just imgdim then [ text " ✔", br [] [] ]
+ else if reldim /= Nothing
+ then [ text " ❌"
+ , br [] []
+ , b [ class "standout" ] [ 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!" ]
+ , br [] []
+ ]
+ else [ br [] [] ]
+ , br [] []
+ , inputSelect "" rel (ScrRel id) [style "width" "500px"] <| rellist ++
+ case (relnfo, rel) of
+ (_, Nothing) -> [(Nothing, "[No release selected]")]
+ (Nothing, Just r) -> [(Just r, "[Deleted or unlinked release: r" ++ String.fromInt r ++ "]")]
+ _ -> []
+ ]
+ ])
+
+ add =
+ let free = 10 - List.length model.screenshots
+ in
+ if free <= 0
+ then [ b [] [ text "Enough screenshots" ]
+ , br [] []
+ , text "The limit of 10 screenshots per visual novel has been reached. If you want to add a new screenshot, please remove an existing one first."
+ ]
+ else
+ [ b [] [ text "Add screenshots" ]
+ , br [] []
+ , text <| String.fromInt free ++ " more screenshot" ++ (if free == 1 then "" else "s") ++ " can be added."
+ , br [] []
+ , inputSelect "" model.scrUplRel ScrUplRel [style "width" "500px"] ((Nothing, "-- select release --") :: rellist)
+ , br [] []
+ , if model.scrUplRel == Nothing then text "" else span []
+ [ inputButton "Select images" ScrUplSel []
+ , case model.scrUplNum of
+ Just num -> text " Too many images selected."
+ Nothing -> text ""
+ , br [] []
+ ]
+ , br [] []
+ , b [] [ text "Important reminder" ]
+ , ul []
+ [ li [] [ text "Screenshots must be in the native resolution of the game" ]
+ , li [] [ text "Screenshots must not include window borders and should not have copyright markings" ]
+ , li [] [ text "Don't only upload event CGs" ]
+ ]
+ , text "Read the ", a [ href "/d2#6" ] [ text "full guidelines" ], text " for more information."
+ ]
+ in
+ if model.id == Nothing
+ then text <| "Screenshots can be uploaded to this visual novel once it has a release entry associated with it. "
+ ++ "To do so, first create this entry without screenshots, then create the appropriate release entries, and finally come back to this form by editing the visual novel."
+ else if List.isEmpty model.screenshots && List.isEmpty model.releases
+ then p []
+ [ text "This visual novel does not have any releases associated with it (yet). Please "
+ , a [ href <| "/v" ++ Maybe.withDefault "" (Maybe.map String.fromInt model.id) ++ "/add" ] [ text "add the appropriate release entries" ]
+ , text " first and then come back to this form to upload screenshots."
+ ]
+ else
+ table [ class "vnedit_scr" ]
+ [ tfoot [] [ tr [] [ td [] [], td [ colspan 2 ] add ] ]
+ , K.node "tbody" [] <| List.indexedMap scr model.screenshots
+ ]
+
+ newform () =
+ form_ DupSubmit (model.state == Api.Loading)
+ [ 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 []
+ [ 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" ++ String.fromInt v.id ] [ text v.title ]
+ , if v.hidden then b [ class "standout" ] [ text " (deleted)" ] else text ""
+ ]
+ ) model.dupVNs
+ ]
+ , fieldset [ class "submit" ] [ submitButton (if List.isEmpty model.dupVNs then "Continue" else "Continue anyway") model.state (isValid model) ]
+ ]
+ ]
+
+ fullform () =
+ form_ Submit (model.state == Api.Loading)
+ [ div [ class "maintabs left" ]
+ [ ul []
+ [ li [ classList [("tabselected", model.tab == General )] ] [ a [ href "#", onClickD (Tab General ) ] [ text "General info" ] ]
+ , li [ classList [("tabselected", model.tab == Image )] ] [ a [ href "#", onClickD (Tab Image ) ] [ text "Image" ] ]
+ , li [ classList [("tabselected", model.tab == Staff )] ] [ a [ href "#", onClickD (Tab Staff ) ] [ text "Staff" ] ]
+ , li [ classList [("tabselected", model.tab == Cast )] ] [ a [ href "#", onClickD (Tab Cast ) ] [ text "Cast" ] ]
+ , li [ classList [("tabselected", model.tab == Screenshots)] ] [ a [ href "#", onClickD (Tab Screenshots) ] [ text "Screenshots" ] ]
+ , li [ classList [("tabselected", model.tab == All )] ] [ a [ href "#", onClickD (Tab All ) ] [ text "All items" ] ]
+ ]
+ ]
+ , 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)
+ ]
+ ]
+ ]
+ in if model.id == Nothing && not model.dupCheck then newform () else fullform ()
diff --git a/elm/VNEdit.js b/elm/VNEdit.js
new file mode 100644
index 00000000..9d07036a
--- /dev/null
+++ b/elm/VNEdit.js
@@ -0,0 +1,6 @@
+wrap_elm_init('VNEdit', function(init, opt) {
+ var app = init(opt);
+ app.ports.ivRefresh.subscribe(function() {
+ setTimeout(ivInit, 10);
+ });
+});
diff --git a/elm/iv.js b/elm/iv.js
index 5892bef8..06bb6f5a 100644
--- a/elm/iv.js
+++ b/elm/iv.js
@@ -1,12 +1,14 @@
//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}">..</a>
+ * <a href="full_image.jpg" data-iv="{width}x{height}:{category}:{flagging}">..</a>
*
* Clicking on the above link will cause the image viewer to open
* full_image.jpg. The {category} part can be empty or absent. If it is not
* empty, next/previous links will show up to point to the other images within
- * the same category.
+ * the same category. The {flagging} part can also be empty or absent,
+ * otherwise it should be a string in the format "svn", where s and v indicate
+ * the sexual/violence scores (0-2) and n the number of votes.
*
* ivInit() should be called when links with "data-iv" attributes are
* dynamically added or removed from the DOM.
@@ -22,8 +24,10 @@ var ivimg;
var ivfull;
var ivnext;
var ivprev;
+var ivhovernext;
+var ivhoverprev;
var ivload;
-var ivclose;
+var ivflag;
var imgw;
var imgh;
@@ -44,24 +48,34 @@ function create_div() {
ivimg = document.createElement('div');
ivparent.appendChild(ivimg);
- ivfull = document.createElement('a');
- ivparent.appendChild(ivfull);
+ var ivlinks = document.createElement('div');
+ ivparent.appendChild(ivlinks);
- ivclose = document.createElement('a');
- ivclose.href = '#';
- ivclose.onclick = ivClose;
- ivclose.textContent = 'close';
- ivparent.appendChild(ivclose);
+ ivfull = document.createElement('a');
+ ivlinks.appendChild(ivfull);
ivprev = document.createElement('a');
ivprev.onclick = show;
ivprev.textContent = '« previous';
- ivparent.appendChild(ivprev);
+ ivlinks.appendChild(ivprev);
ivnext = document.createElement('a');
ivnext.onclick = show;
ivnext.textContent = 'next »';
- ivparent.appendChild(ivnext);
+ ivlinks.appendChild(ivnext);
+
+ ivhoverprev = document.createElement('a');
+ ivhoverprev.onclick = show;
+ ivhoverprev.className = "left-pane";
+ ivimg.appendChild(ivhoverprev);
+
+ ivhovernext = document.createElement('a');
+ ivhovernext.onclick = show;
+ ivhovernext.className = "right-pane";
+ ivimg.appendChild(ivhovernext);
+
+ ivflag = document.createElement('a');
+ ivlinks.appendChild(ivflag);
document.querySelector('body').appendChild(ivparent);
}
@@ -129,25 +143,40 @@ function resize() {
function show(ev) {
var u = this.href;
- var opt = this.getAttribute('data-iv').split(':');
+ var opt = this.getAttribute('data-iv').split(':'); // 0:reso, 1:category, 2:flagging
var idx = this.iv_i;
imgw = Math.floor(opt[0].split('x')[0]);
imgh = Math.floor(opt[0].split('x')[1]);
create_div();
+ var imgs = ivimg.getElementsByTagName("img")
+ if (imgs.length !== 0)
+ ivimg.getElementsByTagName("img")[0].remove()
var img = document.createElement('img');
img.src = u;
ivfull.href = u;
img.onclick = ivClose;
img.onload = function() { ivload.style.display = 'none' };
- ivimg.textContent = '';
ivimg.appendChild(img);
+ var flag = opt[2] ? opt[2].match(/^([0-2])([0-2])([0-9]+)$/) : null;
+ var imgid = u.match(/\/([a-z]{2})\/[0-9]{2}\/([0-9]+)\./);
+ if(flag && imgid) {
+ ivflag.href = '/img/'+imgid[1]+imgid[2];
+ ivflag.textContent = flag[3] == 0 ? 'Not flagged' :
+ (flag[1] == 0 ? 'Safe' : flag[1] == 1 ? 'Suggestive' : 'Explicit') + ' / ' +
+ (flag[2] == 0 ? 'Tame' : flag[2] == 1 ? 'Violent' : 'Brutal' ) + ' (' + flag[3] + ')';
+ ivflag.style.visibility = 'visible';
+ } else
+ ivflag.style.visibility = 'hidden';
+
ivparent.style.display = 'block';
ivload.style.display = 'block';
fixnav(ivprev, opt[1], idx, -1);
fixnav(ivnext, opt[1], idx, 1);
+ fixnav(ivhoverprev, opt[1], idx, -1);
+ fixnav(ivhovernext, opt[1], idx, 1);
resize();
document.addEventListener('click', ivClose);
@@ -167,7 +196,9 @@ window.ivClose = function(ev) {
document.removeEventListener('keydown', keydown);
window.removeEventListener('resize', resize);
ivparent.style.display = 'none';
- ivimg.textContent = '';
+ var imgs = ivimg.getElementsByTagName("img")
+ if (imgs.length !== 0)
+ ivimg.getElementsByTagName("img")[0].remove()
return false;
};
diff --git a/elm/sethash.js b/elm/sethash.js
new file mode 100644
index 00000000..7b054d0b
--- /dev/null
+++ b/elm/sethash.js
@@ -0,0 +1,8 @@
+// Emulate setting a location.hash if none has been set.
+if(pageVars.sethash && location.hash.length <= 1) {
+ var e = document.getElementById(pageVars.sethash);
+ if(e) {
+ e.scrollIntoView();
+ e.classList.add('target');
+ }
+}
diff --git a/lib/Multi/API.pm b/lib/Multi/API.pm
index bef61129..ac496949 100644
--- a/lib/Multi/API.pm
+++ b/lib/Multi/API.pm
@@ -369,6 +369,23 @@ sub splitarray {
}
+# Returns an image flagging structure or undef if $image is false.
+# Assumes $obj has c_votecount, c_sexual_avg and c_violence_avg.
+# Those fields are removed from $obj.
+sub image_flagging {
+ my($image, $obj) = @_;
+ my $flag = {
+ votecount => delete $obj->{c_votecount},
+ sexual_avg => delete $obj->{c_sexual_avg},
+ violence_avg => delete $obj->{c_violence_avg},
+ };
+ $flag->{votecount} *= 1 if defined $flag->{votecount};
+ $flag->{sexual_avg} *= 1 if defined $flag->{sexual_avg};
+ $flag->{violence_avg} *= 1 if defined $flag->{violence_avg};
+ $image ? $flag : undef;
+}
+
+
# sql => str: Main sql query, three printf args: select, where part, order by and limit clauses
# sqluser => str: Alternative to 'sql' if the user is logged in. One additional printf arg: user id.
# If sql is undef and sqluser isn't, the command is only available to logged in users.
@@ -390,7 +407,7 @@ sub splitarray {
# }
# filters => filters args for get_filters() (TODO: Document)
my %GET_VN = (
- sql => 'SELECT %s FROM vn v WHERE NOT v.hidden AND (%s) %s',
+ sql => 'SELECT %s FROM vn v LEFT JOIN images i ON i.id = v.image WHERE NOT v.hidden AND (%s) %s',
select => 'v.id',
proc => sub {
$_[0]{id} *= 1
@@ -416,13 +433,12 @@ my %GET_VN = (
},
},
details => {
- select => 'vndbid_num(v.image) as image, v.img_nsfw, v.alias AS aliases, v.length, v.desc AS description, v.l_wp, v.l_encubed, v.l_renai, l_wikidata',
+ select => 'vndbid_num(v.image) as 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',
proc => sub {
$_[0]{aliases} ||= undef;
$_[0]{length} *= 1;
$_[0]{length} ||= undef;
$_[0]{description} ||= undef;
- $_[0]{image_nsfw} = delete($_[0]{img_nsfw}) =~ /t/ ? TRUE : FALSE;
$_[0]{links} = {
wikipedia => delete($_[0]{l_wp}) ||undef,
encubed => delete($_[0]{l_encubed})||undef,
@@ -430,14 +446,16 @@ my %GET_VN = (
wikidata => formatwd(delete $_[0]{l_wikidata}),
};
$_[0]{image} = $_[0]{image} ? sprintf '%s/cv/%02d/%d.jpg', config->{url_static}, $_[0]{image}%100, $_[0]{image} : undef;
+ $_[0]{image_nsfw} = !$_[0]{image} ? FALSE : !$_[0]{c_votecount} || $_[0]{c_sexual_avg} > 0.4 || $_[0]{c_violence_avg} > 0.4 ? TRUE : FALSE;
+ $_[0]{image_flagging} = image_flagging $_[0]{image}, $_[0];
},
},
stats => {
- select => 'v.c_popularity, v.c_rating, v.c_votecount',
+ select => 'v.c_popularity, v.c_rating, v.c_votecount as votecount',
proc => sub {
$_[0]{popularity} = 1 * sprintf '%.2f', 100*(delete $_[0]{c_popularity} or 0);
$_[0]{rating} = 1 * sprintf '%.2f', 0.1*(delete $_[0]{c_rating} or 0);
- $_[0]{votecount} = 1 * delete $_[0]{c_votecount};
+ $_[0]{votecount} *= 1;
},
},
anime => {
@@ -489,7 +507,7 @@ my %GET_VN = (
]],
},
screens => {
- fetch => [[ 'id', 'SELECT vs.id AS vid, vndbid_num(vs.scr) AS image, vs.rid, vs.nsfw, s.width, s.height
+ fetch => [[ 'id', 'SELECT vs.id AS vid, vndbid_num(vs.scr) AS image, vs.rid, s.width, s.height, s.c_sexual_avg, s.c_violence_avg, s.c_votecount
FROM vn_screenshots vs JOIN images s ON s.id = vs.scr WHERE vs.id IN(%s)',
sub { my($r, $n) = @_;
for my $i (@$r) {
@@ -498,9 +516,10 @@ my %GET_VN = (
for (@$n) {
$_->{image} = sprintf '%s/sf/%02d/%d.jpg', config->{url_static}, $_->{image}%100, $_->{image};
$_->{rid} *= 1;
- $_->{nsfw} = $_->{nsfw} =~ /t/ ? TRUE : FALSE;
+ $_->{nsfw} = !$_->{c_votecount} || $_->{c_sexual_avg} > 0.4 || $_->{c_violence_avg} > 0.4 ? TRUE : FALSE;
$_->{width} *= 1;
$_->{height} *= 1;
+ $_->{flagging} = image_flagging(1, $_);
delete $_->{vid};
}
},
@@ -797,7 +816,7 @@ my %GET_PRODUCER = (
);
my %GET_CHARACTER = (
- sql => 'SELECT %s FROM chars c WHERE NOT c.hidden AND (%s) %s',
+ sql => 'SELECT %s FROM chars c LEFT JOIN images i ON i.id = c.image WHERE NOT c.hidden AND (%s) %s',
select => 'c.id',
proc => sub {
$_[0]{id} *= 1
@@ -818,11 +837,12 @@ my %GET_CHARACTER = (
},
},
details => {
- select => 'c.alias AS aliases, vndbid_num(c.image) as image, c."desc" AS description',
+ select => 'c.alias AS aliases, vndbid_num(c.image) as image, i.c_sexual_avg, i.c_violence_avg, i.c_votecount, c."desc" AS description',
proc => sub {
$_[0]{aliases} ||= undef;
- $_[0]{image} = $_[0]{image} ? sprintf '%s/ch/%02d/%d.jpg', config->{url_static}, $_[0]{image}%100, $_[0]{image} : undef;
$_[0]{description} ||= undef;
+ $_[0]{image} = $_[0]{image} ? sprintf '%s/ch/%02d/%d.jpg', config->{url_static}, $_[0]{image}%100, $_[0]{image} : undef;
+ $_[0]{image_flagging} = image_flagging $_[0]{image}, $_[0];
},
},
meas => {
diff --git a/lib/Multi/Anime.pm b/lib/Multi/Anime.pm
index 671cff55..b9db5003 100644
--- a/lib/Multi/Anime.pm
+++ b/lib/Multi/Anime.pm
@@ -10,8 +10,10 @@ use warnings;
use Multi::Core;
use AnyEvent::Socket;
use AnyEvent::Util;
+use AnyEvent::HTTP;
use Encode 'decode_utf8', 'encode_utf8';
use VNDB::Types;
+use VNDB::Config;
sub LOGIN_ACCEPTED () { 200 }
@@ -33,6 +35,7 @@ my @handled_codes = (
my %O = (
+ titlesurl => 'https://anidb.net/api/anime-titles.dat.gz',
apihost => 'api.anidb.net',
apiport => 9000,
# AniDB UDP API options
@@ -45,6 +48,7 @@ my %O = (
maxtimeoutdelay => 2*3600,
check_delay => 3600,
resolve_delay => 3*3600,
+ titles_delay => 48*3600,
cachetime => '3 months',
);
@@ -63,9 +67,11 @@ my %C = (
sub run {
shift;
+ $O{ua} = sprintf 'VNDB.org Anime Fetcher (Multi v%s; contact@vndb.org)', config->{version};
%O = (%O, @_);
die "No AniDB user/pass configured!" if !$O{user} || !$O{pass};
+ push_watcher schedule 0, $O{titles_delay}, \&titles_import;
push_watcher schedule 0, $O{resolve_delay}, \&resolve;
resolve();
}
@@ -76,6 +82,70 @@ sub unload {
}
+
+# BUGs, kind of:
+# - If the 'ja' title is not present in the titles dump, the title_kanji column will not be set to NULL.
+# - This doesn't attempt to delete rows from the anime table that aren't present in the titles dump.
+# Both can be 'solved' by periodically pruning unreferenced rows from the anime
+# table and setting all title_kanji columns to NULL.
+
+my %T;
+
+sub titles_import {
+ %T = (
+ titles => 0,
+ updates => 0,
+ start_dl => AE::now(),
+ );
+ http_get $O{titlesurl}, headers => {'User-Agent' => $O{ua} }, timeout => 60, sub {
+ my($body, $hdr) = @_;
+ return AE::log warn => "Error fetching titles dump: $hdr->{Status} $hdr->{Reason}" if $hdr->{Status} !~ /^2/;
+
+ $T{start_insert} = AE::now();
+ if(!open $T{fh}, '<:gzip:utf8', \$body) {
+ AE::log warn => "Error parsing titles dump: $!";
+ return;
+ }
+ titles_insert();
+ };
+}
+
+sub titles_next {
+ my $F = $T{fh};
+ while(local $_ = <$F>) {
+ chomp;
+ next if /^#/;
+ my($id,$type,$lang,$title) = split /\|/, $_, 4;
+ return (0, $id, $title) if $type eq '1';
+ return (1, $id, $title) if $type eq '4' && $lang eq 'ja';
+ }
+ ()
+}
+
+sub titles_insert {
+ my($orig, $id, $title) = titles_next();
+
+ if(!defined $orig) {
+ AE::log info => sprintf 'AniDB title import: %d titles, %d updates in %.1fs (fetch) + %.1fs (insert)',
+ $T{titles}, $T{updates}, $T{start_insert}-$T{start_dl}, AE::now()-$T{start_insert};
+ %T = ();
+ return;
+ }
+
+ my $col = $orig ? 'title_kanji' : 'title_romaji';
+ pg_cmd "INSERT INTO anime (id, $col) VALUES (\$1, \$2) ON CONFLICT (id) DO UPDATE SET $col = excluded.$col WHERE anime.$col IS DISTINCT FROM excluded.$col", [ $id, $title ], sub {
+ my($res) = @_;
+ return if pg_expect $res, 0;
+ $T{titles}++;
+ $T{updates} += $res->cmdRows;
+ titles_insert();
+ }
+}
+
+
+
+
+
sub resolve {
AnyEvent::Socket::resolve_sockaddr $O{apihost}, $O{apiport}, 'udp', 0, undef, sub {
if(!@_) {
@@ -104,7 +174,10 @@ sub resolve {
sub check_anime {
return if $C{aid} || $C{tw};
- pg_cmd 'SELECT id FROM anime WHERE lastfetch IS NULL OR lastfetch < NOW() - $1::interval ORDER BY lastfetch DESC NULLS FIRST LIMIT 1', [ $O{cachetime} ], sub {
+ pg_cmd 'SELECT id FROM anime
+ WHERE EXISTS(SELECT 1 FROM vn_anime WHERE aid = anime.id)
+ AND (lastfetch IS NULL OR lastfetch < NOW() - $1::interval)
+ ORDER BY lastfetch DESC NULLS FIRST LIMIT 1', [ $O{cachetime} ], sub {
my $res = shift;
return if pg_expect $res, 1 or $C{aid} or $C{tw} or !$res->rows;
$C{aid} = $res->value(0,0);
@@ -129,7 +202,8 @@ sub nextcmd {
) : ( # logged in, get anime
command => 'ANIME',
aid => $C{aid},
- acode => 3973121, # aid, ANN id, NFO id, year, type, romaji, kanji
+ # aid, year, type, ann, nfo
+ amask => sprintf('%02x%02x%02x%02x%02x%02x%02x', 128+32+16, 0, 0, 0, 64+16, 0, 0),
);
# XXX: We don't have a writability watcher, but since we're only ever sending
@@ -230,27 +304,25 @@ sub handlemsg {
sub update_anime {
my $r = shift;
- # aid, ANN id, NFO id, year, type, romaji, kanji
- my @col = split(/\|/, $r, 7);
+ # aid, year, type, ann, nfo
+ my @col = split(/\|/, $r, 5);
for(@col) {
$_ =~ s/<br \/>/\n/g;
$_ =~ s/`/'/g;
}
- $col[1] = undef if !$col[1];
- $col[2] = undef if !$col[2] || $col[2] =~ /^0,/;
- $col[3] = $col[3] =~ /^([0-9]+)/ ? $1 : undef;
- ($col[4]) = grep lc($col[4]) eq lc($ANIME_TYPE{$_}{anidb}), keys %ANIME_TYPE;
- $col[5] = undef if !$col[5];
- $col[6] = undef if !$col[6];
+ if($col[0] ne $C{aid}) {
+ AE::log warn => sprintf 'Received from aid (%s) for a%d', $col[0], $C{aid};
+ return;
+ }
+ $col[1] = $col[1] =~ /^([0-9]+)/ ? $1 : undef;
+ ($col[2]) = grep lc($col[2]) eq lc($ANIME_TYPE{$_}{anidb}), keys %ANIME_TYPE;
+ $col[3] = undef if !$col[3];
+ $col[4] = undef if !$col[4] || $col[2] =~ /^0,/;
pg_cmd 'UPDATE anime
- SET id = $1, ann_id = $2, nfo_id = $3, year = $4, type = $5,
- title_romaji = $6, title_kanji = $7, lastfetch = NOW()
- WHERE id = $8',
- [ @col, $C{aid} ];
+ SET id = $1, year = $2, type = $3, ann_id = $4, nfo_id = $5, lastfetch = NOW()
+ WHERE id = $1', \@col;
AE::log info => "Fetched anime info for a$C{aid}";
- AE::log warn => "a$C{aid} doesn't have a title or year!"
- if !$col[3] || !$col[5];
}
diff --git a/lib/Multi/Feed.pm b/lib/Multi/Feed.pm
deleted file mode 100644
index 626e837b..00000000
--- a/lib/Multi/Feed.pm
+++ /dev/null
@@ -1,155 +0,0 @@
-
-#
-# Multi::Feed - Generates and updates Atom feeds
-#
-
-package Multi::Feed;
-
-use strict;
-use warnings;
-use TUWF::XML;
-use Multi::Core;
-use POSIX 'strftime';
-use VNDB::BBCode;
-use VNDB::Config;
-
-my %stats; # key = feed, value = [ count, total, max ]
-
-
-sub run {
- my $p = shift;
- my %o = (
- regenerate_interval => 600, # 10 min.
- stats_interval => 86400, # daily
- @_
- );
- push_watcher schedule 0, $o{regenerate_interval}, \&generate;
- push_watcher schedule 0, $o{stats_interval}, \&stats;
-}
-
-
-sub generate {
- # announcements
- pg_cmd q{
- SELECT '/t'||t.id AS id, t.title, extract('epoch' from tp.date) AS published,
- extract('epoch' from tp.edited) AS updated, u.username, u.id AS uid, tp.msg AS summary
- FROM threads t
- JOIN threads_posts tp ON tp.tid = t.id AND tp.num = 1
- JOIN threads_boards tb ON tb.tid = t.id AND tb.type = 'an'
- JOIN users u ON u.id = tp.uid
- WHERE NOT t.hidden AND NOT t.private
- ORDER BY t.id DESC
- LIMIT $1},
- [10],
- sub { write_atom(announcements => '/t/an', 'VNDB Site Announcements', @_) };
-
- # changes
- pg_cmd q{
- SELECT '/'||c.type||COALESCE(v.id, r.id, p.id, ca.id, s.id, d.id)||'.'||c.rev AS id,
- COALESCE(v.title, r.title, p.name, ca.name, sa.name, d.title) AS title, extract('epoch' from c.added) AS updated,
- u.username, u.id AS uid, c.comments AS summary
- FROM changes c
- LEFT JOIN vn v ON c.type = 'v' AND c.itemid = v.id
- LEFT JOIN releases r ON c.type = 'r' AND c.itemid = r.id
- LEFT JOIN producers p ON c.type = 'p' AND c.itemid = p.id
- LEFT JOIN chars ca ON c.type = 'c' AND c.itemid = ca.id
- LEFT JOIN docs d ON c.type = 'd' AND c.itemid = d.id
- LEFT JOIN staff s ON c.type = 's' AND c.itemid = s.id
- LEFT JOIN staff_alias sa ON sa.id = s.id AND sa.aid = s.aid
- JOIN users u ON u.id = c.requester
- WHERE c.requester <> 1
- ORDER BY c.id DESC
- LIMIT $1},
- [25],
- sub { write_atom(changes => '/hist', 'VNDB Recent Changes', @_); };
-
- # posts
- pg_cmd q{
- SELECT '/t'||t.id||'.'||tp.num AS id, t.title||' (#'||tp.num||')' AS title, extract('epoch' from tp.date) AS published,
- extract('epoch' from tp.edited) AS updated, u.username, u.id AS uid, tp.msg AS summary
- FROM threads_posts tp
- JOIN threads t ON t.id = tp.tid
- JOIN users u ON u.id = tp.uid
- WHERE NOT tp.hidden AND NOT t.hidden AND NOT t.private
- ORDER BY tp.date DESC
- LIMIT $1},
- [25],
- sub { write_atom(posts => '/t', 'VNDB Recent Posts', @_); };
-}
-
-
-sub write_atom {
- my($feed, $path, $title, $res, $sqltime) = @_;
- return if pg_expect $res, 1;
-
- my $start = AE::time;
-
- my @r = $res->rowsAsHashes;
- my $updated = 0;
- for(@r) {
- $updated = $_->{published} if $_->{published} && $_->{published} > $updated;
- $updated = $_->{updated} if $_->{updated} && $_->{updated} > $updated;
- }
-
- my $data;
- my $x = TUWF::XML->new(write => sub { $data .= shift }, pretty => 2);
- $x->xml();
- $x->tag(feed => xmlns => 'http://www.w3.org/2005/Atom', 'xml:lang' => 'en', 'xml:base' => config->{url}.'/');
- $x->tag(title => $title);
- $x->tag(updated => datetime($updated));
- $x->tag(id => config->{url}.$path);
- $x->tag(link => rel => 'self', type => 'application/atom+xml', href => config->{url}."/feeds/$feed.atom", undef);
- $x->tag(link => rel => 'alternate', type => 'text/html', href => config->{url}.$path, undef);
-
- for(@r) {
- $x->tag('entry');
- $x->tag(id => config->{url}.$_->{id});
- $x->tag(title => $_->{title});
- $x->tag(updated => datetime($_->{updated} || $_->{published}));
- $x->tag(published => datetime($_->{published})) if $_->{published};
- if($_->{username}) {
- $x->tag('author');
- $x->tag(name => $_->{username});
- $x->tag(uri => config->{url}.'/u'.$_->{uid}) if $_->{uid};
- $x->end;
- }
- $x->tag(link => rel => 'alternate', type => 'text/html', href => config->{url}.$_->{id}, undef);
- $x->tag('summary', type => 'html', bb2html $_->{summary}) if $_->{summary};
- $x->end('entry');
- }
-
- $x->end('feed');
-
- open my $f, '>:utf8', config->{root}."/www/feeds/$feed.atom" || die $!;
- print $f $data;
- close $f;
-
- AE::log debug => sprintf 'Wrote %16s.atom (%d entries, sql:%4dms, perl:%4dms)',
- $feed, scalar(@r), $sqltime*1000, (AE::time-$start)*1000;
-
- my $time = ((AE::time-$start)+$sqltime)*1000;
- $stats{$feed} = [ 0, 0, 0 ] if !$stats{$feed};
- $stats{$feed}[0]++;
- $stats{$feed}[1] += $time;
- $stats{$feed}[2] = $time if $stats{$feed}[2] < $time;
-}
-
-
-sub stats {
- for (keys %stats) {
- my $v = $stats{$_};
- next if !$v->[0];
- AE::log info => sprintf 'Stats summary for %16s.atom: total:%5dms, avg:%4dms, max:%4dms, size: %.1fkB',
- $_, $v->[1], $v->[1]/$v->[0], $v->[2], (-s config->{root}."/www/feeds/$_.atom")/1024;
- }
- %stats = ();
-}
-
-
-sub datetime {
- strftime('%Y-%m-%dT%H:%M:%SZ', gmtime shift);
-}
-
-
-1;
-
diff --git a/lib/Multi/IRC.pm b/lib/Multi/IRC.pm
index 6c86d2f9..b14c78fc 100644
--- a/lib/Multi/IRC.pm
+++ b/lib/Multi/IRC.pm
@@ -201,17 +201,18 @@ sub set_notify {
(SELECT id FROM changes ORDER BY id DESC LIMIT 1) AS rev,
(SELECT id FROM tags ORDER BY id DESC LIMIT 1) AS tag,
(SELECT id FROM traits ORDER BY id DESC LIMIT 1) AS trait,
- (SELECT date FROM threads_posts ORDER BY date DESC LIMIT 1) AS post
+ (SELECT date FROM threads_posts ORDER BY date DESC LIMIT 1) AS post,
+ (SELECT id FROM reviews ORDER BY id DESC LIMIT 1) AS review
}, undef, sub {
return if pg_expect $_[0], 1;
%lastnotify = %{($_[0]->rowsAsHashes())[0]};
- push_watcher pg->listen($_, on_notify => \&notify) for qw{newrevision newpost newtag newtrait};
+ push_watcher pg->listen($_, on_notify => \&notify) for qw{newrevision newpost newtag newtrait newreview};
};
}
# formats and posts database items listed in @res, where each item is a hashref with:
-# type database item in [dvprtug]
+# type database item in [dvprtugw]
# id database id
# title main name or title of the DB entry
# rev (optional) revision, post number
@@ -234,19 +235,22 @@ sub formatid {
i => 'trait',
t => 'thread',
d => 'doc',
+ w => 'review',
);
for (@$res) {
- my $id = $_->{type}.$_->{id} . ($_->{rev} ? '.'.$_->{rev} : '');
+ my $id = ($_->{id} =~ /^[a-z]/ ? '' : $_->{type}) . $_->{id} . ($_->{rev} ? '.'.$_->{rev} : '');
# (always) [x+.+]
my @msg = ("$BOLD$c"."[$NORMAL$BOLD$id$c]$NORMAL");
# (only if username key is present) Edit of / New item / reply to / whatever
push @msg, $c.(
+ $_->{type} eq 'w' && !$_->{rev} ? 'Review of' :
+ $_->{type} eq 'w' ? 'Comment to review of' :
($_->{rev}||1) == 1 ? "New $types{$_->{type}}" :
$_->{type} eq 't' ? 'Reply to' : 'Edit of'
- ).$NORMAL if $_->{username};
+ ).$NORMAL if exists $_->{username};
# (always) main title
push @msg, $_->{title};
@@ -255,7 +259,7 @@ sub formatid {
push @msg, $c."Posted in$NORMAL $_->{boards}" if $_->{boards};
# (only if username key is present) By [username]
- push @msg, $c."By$NORMAL $_->{username}" if $_->{username};
+ push @msg, $c."By$NORMAL ".($_->{username}//'deleted') if exists $_->{username};
# (only if comments key is present) Summary:
$_->{comments} =~ s/\n/ /g if $_->{comments};
@@ -293,12 +297,13 @@ sub handleid {
$t eq 'p' ? 'p.name AS title FROM producers p WHERE p.id = $2' :
$t eq 'c' ? 'c.name AS title FROM chars c WHERE c.id = $2' :
$t eq 's' ? 'sa.name AS title FROM staff s JOIN staff_alias sa ON sa.aid = s.aid AND sa.id = s.id WHERE s.id = $2' :
- $t eq 't' ? 'title, '.$GETBOARDS.' FROM threads t WHERE NOT t.hidden AND NOT t.private AND t.id = $2' :
+ $t eq 't' ? 'title, '.$GETBOARDS.' FROM threads t WHERE NOT t.hidden AND NOT t.private AND t.id = vndbid(\'t\',$2)' :
$t eq 'g' ? 'name AS title FROM tags WHERE id = $2' :
$t eq 'i' ? 'name AS title FROM traits WHERE id = $2' :
$t eq 'd' ? 'title FROM docs WHERE id = $2' :
+ $t eq '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 = vndbid(\'w\',$2)' :
'r.title FROM releases r WHERE r.id = $2'),
- [ $t, $id ], $c if !$rev && $t =~ /[dvprtugics]/;
+ [ $t, $id ], $c if !$rev && $t =~ /[dvprtugicsw]/;
# edit/insert of vn/release/producer or discussion board post
pg_cmd 'SELECT $1::text AS type, $2::integer AS id, $3::integer AS rev, '.(
@@ -308,8 +313,9 @@ sub handleid {
$t eq 'c' ? 'ch.name AS title, u.username, c.comments FROM changes c JOIN chars_hist ch ON c.id = ch.chid JOIN users u ON u.id = c.requester WHERE c.type = \'c\' AND c.itemid = $2 AND c.rev = $3' :
$t eq 's' ? 'sah.name AS title, u.username, c.comments FROM changes c JOIN staff_hist sh ON c.id = sh.chid JOIN users u ON u.id = c.requester JOIN staff_alias_hist sah ON sah.chid = c.id AND sah.aid = sh.aid WHERE c.type = \'s\' AND c.itemid = $2 AND c.rev = $3' :
$t eq 'd' ? 'dh.title, u.username, c.comments FROM changes c JOIN docs_hist dh ON c.id = dh.chid JOIN users u ON u.id = c.requester WHERE c.type = \'d\' AND c.itemid = $2 AND c.rev = $3' :
- 't.title, u.username, '.$GETBOARDS.' FROM threads t JOIN threads_posts tp ON tp.tid = t.id JOIN users u ON u.id = tp.uid WHERE NOT t.hidden AND NOT t.private AND t.id = $2 AND tp.num = $3'),
- [ $t, $id, $rev], $c if $rev && $t =~ /[dvprtcs]/;
+ $t eq '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 = vndbid(\'w\',$2) AND wp.num = $3' :
+ '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 = vndbid(\'t\',$2) AND tp.num = $3'),
+ [ $t, $id, $rev], $c if $rev && $t =~ /[dvprtcsw]/;
}
@@ -321,8 +327,8 @@ sub vndbid {
my @id; # [ type, id, ref ]
for (split /[, ]/, $msg) {
next if length > 15 or m{[a-z]{3,6}://}i; # weed out URLs and too long things
- push @id, /^(?:.*[^\w]|)([dvprtcs])([1-9][0-9]*)\.([1-9][0-9]*)(?:[^\w].*|)$/ ? [ $1, $2, $3 ] # x+.+
- : /^(?:.*[^\w]|)([dvprtugics])([1-9][0-9]*)(?:[^\w].*|)$/ ? [ $1, $2, '' ] : (); # x+
+ push @id, /^(?:.*[^\w]|)([wdvprtcs])([1-9][0-9]*)\.([1-9][0-9]*)(?:[^\w].*|)$/ ? [ $1, $2, $3 ] # x+.+
+ : /^(?:.*[^\w]|)([wdvprtugics])([1-9][0-9]*)(?:[^\w].*|)$/ ? [ $1, $2, '' ] : (); # x+
}
handleid($chan, @$_) for @id;
}
@@ -332,7 +338,7 @@ sub vndbid {
sub notify {
my(undef, $sel) = @_;
- my $k = {qw|newrevision rev newpost post newtrait trait newtag tag|}->{$sel};
+ my $k = {qw|newrevision rev newpost post newtrait trait newtag tag newreview review|}->{$sel};
return if !$k || !$lastnotify{$k};
my $q = {
@@ -351,10 +357,10 @@ sub notify {
WHERE c.id > $1 AND c.requester <> 1
ORDER BY c.id},
post => q{
- SELECT 't' AS type, tp.tid AS id, tp.num AS rev, t.title, u.username, tp.date AS lastid, }.$GETBOARDS.q{
+ SELECT 't' AS type, vndbid_num(tp.tid) AS id, tp.num AS rev, t.title, COALESCE(u.username, 'deleted') AS username, tp.date AS lastid, }.$GETBOARDS.q{
FROM threads_posts tp
JOIN threads t ON t.id = tp.tid
- JOIN users u ON u.id = tp.uid
+ LEFT JOIN users u ON u.id = tp.uid
WHERE tp.date > $1 AND tp.num = 1 AND NOT t.hidden AND NOT t.private
ORDER BY tp.date},
trait => q{
@@ -368,7 +374,14 @@ sub notify {
FROM tags t
JOIN users u ON u.id = t.addedby
WHERE t.id > $1
- ORDER BY t.id}
+ ORDER BY t.id},
+ review => q{
+ SELECT 'w' AS type, w.id, v.title, u.username, w.id AS lastid
+ FROM reviews w
+ JOIN vn v ON v.id = w.vid
+ LEFT JOIN users u ON u.id = w.uid
+ WHERE w.id > $1
+ ORDER BY w.id}
}->{$k};
pg_cmd $q, [ $lastnotify{$k} ], sub {
diff --git a/lib/Multi/Maintenance.pm b/lib/Multi/Maintenance.pm
index a371f29b..57684f37 100644
--- a/lib/Multi/Maintenance.pm
+++ b/lib/Multi/Maintenance.pm
@@ -77,6 +77,8 @@ my %dailies = (
# 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()|,
cleannotifications => q|DELETE FROM notifications WHERE read < NOW()-'1 month'::interval|,
cleannotifications2=> q|DELETE FROM notifications WHERE id IN (
diff --git a/lib/VNDB/BBCode.pm b/lib/VNDB/BBCode.pm
index d11171c5..ecba3e58 100644
--- a/lib/VNDB/BBCode.pm
+++ b/lib/VNDB/BBCode.pm
@@ -5,9 +5,13 @@ use warnings;
use Exporter 'import';
use TUWF::XML 'xml_escape';
-our @EXPORT = qw/bb2html bb2text bb_subst_links/;
+our @EXPORT = qw/bb_format bb_subst_links/;
# Supported BBCode:
+# [b] .. [/b]
+# [i] .. [/i]
+# [u] .. [/u]
+# [s] .. [/s]
# [spoiler] .. [/spoiler]
# [quote] .. [/quote]
# [code] .. [/code]
@@ -17,7 +21,8 @@ our @EXPORT = qw/bb2html bb2text bb_subst_links/;
# dblink: v+, v+.+, d+#+, d+#+.+
#
# Permitted nesting of formatting codes:
-# spoiler -> url, raw, link, dblink
+# inline = b,i,u,s,spoiler
+# inline -> inline, url, raw, link, dblink
# quote -> anything
# code -> nothing
# url -> raw
@@ -29,10 +34,18 @@ our @EXPORT = qw/bb2html bb2text bb_subst_links/;
# Returns: ($token, @arg) on successful parse, () otherwise.
# Trivial open and close actions
+sub _b_start { if(lc$_[1] eq '[b]') { push @{$_[0]}, 'b'; ('b_start') } else { () } }
+sub _i_start { if(lc$_[1] eq '[i]') { push @{$_[0]}, 'i'; ('i_start') } else { () } }
+sub _u_start { if(lc$_[1] eq '[u]') { push @{$_[0]}, 'u'; ('u_start') } else { () } }
+sub _s_start { if(lc$_[1] eq '[s]') { push @{$_[0]}, 's'; ('s_start') } else { () } }
sub _spoiler_start { if(lc$_[1] eq '[spoiler]') { push @{$_[0]}, 'spoiler'; ('spoiler_start') } else { () } }
sub _quote_start { if(lc$_[1] eq '[quote]') { push @{$_[0]}, 'quote'; ('quote_start') } else { () } }
sub _code_start { if(lc$_[1] eq '[code]') { push @{$_[0]}, 'code'; ('code_start') } else { () } }
sub _raw_start { if(lc$_[1] eq '[raw]') { push @{$_[0]}, 'raw'; ('raw_start') } else { () } }
+sub _b_end { if(lc$_[1] eq '[/b]') { pop @{$_[0]}; ('b_end' ) } else { () } }
+sub _i_end { if(lc$_[1] eq '[/i]') { pop @{$_[0]}; ('i_end' ) } else { () } }
+sub _u_end { if(lc$_[1] eq '[/u]') { pop @{$_[0]}; ('u_end' ) } else { () } }
+sub _s_end { if(lc$_[1] eq '[/s]') { pop @{$_[0]}; ('s_end' ) } else { () } }
sub _spoiler_end { if(lc$_[1] eq '[/spoiler]') { pop @{$_[0]}; ('spoiler_end') } else { () } }
sub _quote_end { if(lc$_[1] eq '[/quote]' ) { pop @{$_[0]}; ('quote_end' ) } else { () } }
sub _code_end { if(lc$_[1] eq '[/code]' ) { pop @{$_[0]}; ('code_end' ) } else { () } }
@@ -65,10 +78,15 @@ sub _link {
# Permitted actions to take in each state. The actions are run in order, if
# none succeed then the token is passed through as text.
# The "current state" is the most recent tag in the stack, or '' if no tags are open.
+my @INLINE = (\&_link, \&_url_start, \&_raw_start, \&_b_start, \&_i_start, \&_u_start, \&_s_start, \&_spoiler_start);
my %STATE = (
- '' => [ \&_link, \&_url_start, \&_raw_start, \&_spoiler_start, \&_quote_start, \&_code_start],
- spoiler => [\&_spoiler_end, \&_link, \&_url_start, \&_raw_start],
- quote => [\&_quote_end, \&_link, \&_url_start, \&_raw_start, \&_spoiler_start, \&_quote_start, \&_code_start],
+ '' => [ @INLINE, \&_quote_start, \&_code_start],
+ b => [\&_b_end, @INLINE],
+ i => [\&_i_end, @INLINE],
+ u => [\&_u_end, @INLINE],
+ s => [\&_s_end, @INLINE],
+ spoiler => [\&_spoiler_end, @INLINE],
+ quote => [\&_quote_end, @INLINE, \&_quote_start, \&_code_start],
code => [\&_code_end ],
url => [\&_url_end, \&_raw_start],
raw => [\&_raw_end ],
@@ -88,6 +106,14 @@ my %STATE = (
#
# Tags:
# text -> literal text, $raw is the text to display
+# b_start -> start bold
+# b_end -> end
+# i_start -> start italic
+# i_end -> end
+# u_start -> start underline
+# u_end -> end
+# s_start -> start strike
+# s_end -> end
# spoiler_start -> start a spoiler
# spoiler_end -> end
# quote_start -> start a quote
@@ -111,11 +137,11 @@ sub parse {
my @stack;
while($raw =~ m{(?:
- \[ \/? (?i: spoiler|quote|code|url|raw ) [^\s\]]* \] | # tag
- d[1-9][0-9]* \# [1-9][0-9]* (?: \.[1-9][0-9]* )? | # d+#+[.+]
- [tdvprcs][1-9][0-9]*\.[1-9][0-9]* | # v+.+
- [tdvprcsugi][1-9][0-9]* | # v+
- (?:https?|ftp)://[^><"\n\s\]\[]+[\d\w=/-] # link
+ \[ \/? (?i: b|i|u|s|spoiler|quote|code|url|raw ) [^\s\]]* \] | # tag
+ d[1-9][0-9]* \# [1-9][0-9]* (?: \.[1-9][0-9]* )? | # d+#+[.+]
+ [tdvprcsw][1-9][0-9]*\.[1-9][0-9]* | # v+.+
+ [tdvprcsugiw][1-9][0-9]* | # v+
+ (?:https?|ftp)://[^><"\n\s\]\[]+[\d\w=/-] # link
)}xg) {
my $token = $&;
my $pre = substr $raw, $last, $-[0]-$last;
@@ -147,110 +173,111 @@ FINAL:
}
-# charspoil:
-# 0/undef/missing: Output <b class="spoiler">..
-# 1: Output 'charspoil_*' classes
-# 2: Just output 'hidden by spoiler setting' message
-# 3: Just output the spoilers, unmarked
-sub bb2html {
- my($input, $maxlength, $charspoil) = @_;
+# Options:
+# maxlength => 0/$n - truncate after $n visible characters
+# inline => 0/1 - don't insert line breaks and don't format block elements
+#
+# One of:
+# text => 0/1 - format as plain text, no tags
+# onlyids => 0/1 - format as HTML, but only convert VNDBIDs, leave the rest alone (including [spoiler]s)
+# default: format all to HTML.
+#
+# One of:
+# delspoil => 0/1 - delete [spoiler] tags and its contents
+# replacespoil => 0/1 - replace [spoiler] tags with a "hidden by spoiler settings" message
+# keepsoil => 0/1 - keep the contents of spoiler tags without any special formatting
+# default: format as <b class="spoiler">..
+sub bb_format {
+ my($input, %opt) = @_;
+ $opt{delspoil} = 1 if $opt{text} && !$opt{keepspoil};
my $incode = 0;
+ my $inspoil = 0;
my $rmnewline = 0;
my $length = 0;
my $ret = '';
# escapes, returns string, and takes care of $length and $maxlength; also
# takes care to remove newlines and double spaces when necessary
- my $e = sub {
+ my sub e {
local $_ = shift;
s/^\n// if $rmnewline && $rmnewline--;
s/\n{5,}/\n\n/g if !$incode;
s/ +/ /g if !$incode;
$length += length $_;
- if($maxlength && $length > $maxlength) {
- $_ = substr($_, 0, $maxlength-$length);
+ if($opt{maxlength} && $length > $opt{maxlength}) {
+ $_ = substr($_, 0, $opt{maxlength}-$length);
s/\W+\w*$//; # cleanly cut off on word boundary
}
- s/&/&amp;/g;
- s/>/&gt;/g;
- s/</&lt;/g;
- s/\n/<br>/g if !$maxlength;
- s/\n/ /g if $maxlength;
+ if(!$opt{text}) {
+ s/&/&amp;/g;
+ s/>/&gt;/g;
+ s/</&lt;/g;
+ s/\n/<br>/g if !$opt{inline};
+ }
+ s/\n/ /g if $opt{inline};
$_;
};
parse $input, sub {
my($raw, $tag, @arg) = @_;
- #$ret .= "$tag {$raw}\n";
- #return 1;
+ return 1 if $inspoil && $tag ne 'spoiler_end' && ($opt{delspoil} || $opt{replacespoil});
if($tag eq 'text') {
- $ret .= $e->($raw);
-
- } elsif($tag eq 'spoiler_start') {
- $ret .= !$charspoil ? '<b class="spoiler">' :
- $charspoil == 1 ? '<b class="grayedout charspoil charspoil_-1">&lt;hidden by spoiler settings&gt;</b><span class="charspoil charspoil_2">' :
- $charspoil == 2 ? '<b class="grayedout charspoil charspoil_-1">&lt;hidden by spoiler settings&gt;</b><!--' : '';
- } elsif($tag eq 'spoiler_end') {
- $ret .= !$charspoil ? '</b>' :
- $charspoil == 1 ? '</span>' :
- $charspoil == 2 ? '-->' : '';
+ $ret .= e $raw;
+ } elsif($tag eq 'dblink') {
+ (my $link = $raw) =~ s/^d(\d+)\.(\d+)\.(\d+)$/d$1#$2.$3/;
+ $ret .= $opt{text} ? e $raw : sprintf '<a href="/%s">%s</a>', $link, e $raw;
+
+ } elsif($opt{idonly}) {
+ $ret .= e $raw;
+
+ } elsif($tag eq 'b_start') { $ret .= $opt{text} ? e '*' : '<b>'
+ } elsif($tag eq 'b_end') { $ret .= $opt{text} ? e '*' : '</b>'
+ } elsif($tag eq 'i_start') { $ret .= $opt{text} ? e '/' : '<em>'
+ } elsif($tag eq 'i_end') { $ret .= $opt{text} ? e '/' : '</em>'
+ } elsif($tag eq 'u_start') { $ret .= $opt{text} ? e '_' : '<span class="underline">'
+ } elsif($tag eq 'u_end') { $ret .= $opt{text} ? e '_' : '</span>'
+ } elsif($tag eq 's_start') { $ret .= $opt{text} ? e '-' : '<s>'
+ } elsif($tag eq 's_end') { $ret .= $opt{text} ? e '-' : '</s>'
} elsif($tag eq 'quote_start') {
- $ret .= '<div class="quote">' if !$maxlength;
+ $ret .= $opt{text} || $opt{inline} ? e '"' : '<div class="quote">';
$rmnewline = 1;
} elsif($tag eq 'quote_end') {
- $ret .= '</div>' if !$maxlength;
+ $ret .= $opt{text} || $opt{inline} ? e '"' : '</div>';
$rmnewline = 1;
} elsif($tag eq 'code_start') {
- $ret .= '<pre>' if !$maxlength;
+ $ret .= $opt{text} || $opt{inline} ? e '`' : '<pre>';
$rmnewline = 1;
$incode = 1;
} elsif($tag eq 'code_end') {
- $ret .= '</pre>' if !$maxlength;
+ $ret .= $opt{text} || $opt{inline} ? e '`' : '</pre>';
$rmnewline = 1;
$incode = 0;
+ } elsif($tag eq 'spoiler_start') {
+ $inspoil = 1;
+ $ret .= $opt{delspoil} || $opt{keepspoil} ? ''
+ : $opt{replacespoil} ? '<b class="grayedout">&lt;hidden by spoiler settings&gt;</b>'
+ : '<b class="spoiler">';
+ } elsif($tag eq 'spoiler_end') {
+ $inspoil = 0;
+ $ret .= $opt{delspoil} || $opt{keepspoil} || $opt{replacespoil} ? '' : '</b>';
+
} elsif($tag eq 'url_start') {
- $ret .= sprintf '<a href="%s" rel="nofollow">', xml_escape($arg[0]);
+ $ret .= $opt{text} ? '' : sprintf '<a href="%s" rel="nofollow">', xml_escape($arg[0]);
} elsif($tag eq 'url_end') {
- $ret .= '</a>';
+ $ret .= $opt{text} ? '' : '</a>';
} elsif($tag eq 'link') {
- $ret .= sprintf '<a href="%s" rel="nofollow">%s</a>', xml_escape($raw), $e->('link');
-
- } elsif($tag eq 'dblink') {
- (my $link = $raw) =~ s/^d(\d+)\.(\d+)\.(\d+)$/d$1#$2.$3/;
- $ret .= sprintf '<a href="/%s">%s</a>', $link, $e->($raw);
+ $ret .= $opt{text} ? e $raw : sprintf '<a href="%s" rel="nofollow">%s</a>', xml_escape($raw), e 'link';
}
- !$maxlength || $length < $maxlength;
- };
- $ret;
-}
-
-
-# Convert bbcode into plain text, stripping all tags and spoilers. [url] tags
-# only display the title.
-sub bb2text {
- my $input = shift;
-
- my $inspoil = 0;
- my $ret = '';
- parse $input, sub {
- my($raw, $tag, @arg) = @_;
- if($tag eq 'spoiler_start') {
- $inspoil = 1;
- } elsif($tag eq 'spoiler_end') {
- $inspoil = 0;
- } else {
- $ret .= $raw if !$inspoil && $tag !~ /_(start|end)$/;
- }
- 1;
+ !$opt{maxlength} || $length < $opt{maxlength};
};
$ret;
}
diff --git a/lib/VNDB/Config.pm b/lib/VNDB/Config.pm
index b187251a..01b67a58 100644
--- a/lib/VNDB/Config.pm
+++ b/lib/VNDB/Config.pm
@@ -38,7 +38,6 @@ my $config = {
Multi => {
Core => {},
- Feed => {},
Maintenance => {},
},
};
@@ -55,7 +54,7 @@ sub config {
$c->{tuwf}{$_} = $config_file->{tuwf}{$_} for keys %{ $config_file->{tuwf} || {} };
$c->{url_static} ||= $c->{url};
- $c->{version} ||= `git -C "$ROOT" describe` =~ /^(.+)\-g[0-9a-f]+$/ && $1;
+ $c->{version} ||= `git -C "$ROOT" describe` =~ s/\-g[0-9a-f]+$//rg =~ s/\r?\n//rg;
$c->{root} = $ROOT;
$c->{Multi}{Core}{log_level} ||= 'debug';
$c->{Multi}{Core}{log_dir} ||= $ROOT.'/data/log';
diff --git a/lib/VNDB/DB/Discussions.pm b/lib/VNDB/DB/Discussions.pm
deleted file mode 100644
index 442f8032..00000000
--- a/lib/VNDB/DB/Discussions.pm
+++ /dev/null
@@ -1,176 +0,0 @@
-
-package VNDB::DB::Discussions;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|dbThreadGet dbPostGet|;
-
-
-# Options: id, type, iid, results, page, what, asuser, notusers, search, sort, reverse
-# What: boards, boardtitles, firstpost, lastpost, poll
-# Sort: id lastpost
-sub dbThreadGet {
- my($self, %o) = @_;
- $o{results} ||= 50;
- $o{page} ||= 1;
- $o{what} ||= '';
-
- my @where = (
- $o{id} ? (
- 't.id = ?' => $o{id}
- ) : (
- 'NOT t.hidden' => 0,
- q{(NOT t.private OR EXISTS(SELECT 1 FROM threads_boards WHERE tid = t.id AND type = 'u' AND iid = ?))} => $o{asuser}
- ),
- $o{type} && !$o{iid} ? (
- 'EXISTS(SELECT 1 FROM threads_boards WHERE tid = t.id AND type IN(!l))' => [ ref $o{type} ? $o{type} : [ $o{type} ] ] ) : (),
- $o{type} && $o{iid} ? (
- 'tb.type = ?' => $o{type}, 'tb.iid = ?' => $o{iid} ) : (),
- $o{notusers} ? (
- 'NOT EXISTS(SELECT 1 FROM threads_boards WHERE type = \'u\' AND tid = t.id)' => 1) : (),
- );
-
- if($o{search}) {
- for (split /[ -,._]/, $o{search}) {
- s/%//g;
- push @where, 't.title ilike ?', "%$_%" if length($_) > 0;
- }
- }
-
- my @select = (
- qw|t.id t.title t.count t.locked t.hidden t.private|, 't.poll_question IS NOT NULL AS haspoll',
- $o{what} =~ /lastpost/ ? (q|EXTRACT('epoch' from tpl.date) AS lastpost_date|, VNWeb::DB::sql_user('ul', 'lastpost_')) : (),
- $o{what} =~ /poll/ ? (qw|t.poll_question t.poll_max_options t.poll_preview t.poll_recast|) : (),
- );
-
- my @join = (
- $o{what} =~ /lastpost/ ? (
- 'JOIN threads_posts tpl ON tpl.tid = t.id AND tpl.num = t.count',
- 'JOIN users ul ON ul.id = tpl.uid'
- ) : (),
- $o{type} && $o{iid} ?
- 'JOIN threads_boards tb ON tb.tid = t.id' : (),
- );
-
- my $order = sprintf {
- id => 't.id %s',
- lastpost => 'tpl.date %s',
- }->{ $o{sort}||'id' }, $o{reverse} ? 'DESC' : 'ASC';
-
- my($r, $np) = $self->dbPage(\%o, q|
- SELECT !s
- FROM threads t
- !s
- !W
- ORDER BY !s|,
- join(', ', @select), join(' ', @join), \@where, $order
- );
-
- if($o{what} =~ /(boards|boardtitles|poll)/ && $#$r >= 0) {
- my %r = map {
- $r->[$_]{boards} = [];
- $r->[$_]{poll_options} = [];
- ($r->[$_]{id}, $_)
- } 0..$#$r;
-
- if($o{what} =~ /boards/) {
- push(@{$r->[$r{$_->{tid}}]{boards}}, [ $_->{type}, $_->{iid} ]) for (@{$self->dbAll(q|
- SELECT tid, type, iid
- FROM threads_boards
- WHERE tid IN(!l)|,
- [ keys %r ]
- )});
- }
-
- if($o{what} =~ /poll/) {
- push(@{$r->[$r{$_->{tid}}]{poll_options}}, [ $_->{id}, $_->{option} ]) for (@{$self->dbAll(q|
- SELECT tid, id, option
- FROM threads_poll_options
- WHERE tid IN(!l)|,
- [ keys %r ]
- )});
- }
-
- if($o{what} =~ /firstpost/) {
- do { my $idx = $r{ delete $_->{tid} }; $r->[$idx] = { $r->[$idx]->%*, %$_ } } for (@{$self->dbAll(q|
- SELECT tpf.tid, EXTRACT('epoch' from tpf.date) AS firstpost_date, !s
- FROM threads_posts tpf
- JOIN users uf ON tpf.uid = uf.id
- WHERE tpf.num = 1 AND tpf.tid IN(!l)|,
- VNWeb::DB::sql_user('uf', 'firstpost_'), [ keys %r ]
- )});
- }
-
- if($o{what} =~ /boardtitles/) {
- push(@{$r->[$r{$_->{tid}}]{boards}}, $_) for (@{$self->dbAll(q|
- SELECT tb.tid, tb.type, tb.iid, COALESCE(u.username, v.title, p.name) AS title, COALESCE(u.username, v.original, p.original) AS original
- FROM threads_boards tb
- LEFT JOIN vn v ON tb.type = 'v' AND v.id = tb.iid
- LEFT JOIN producers p ON tb.type = 'p' AND p.id = tb.iid
- LEFT JOIN users u ON tb.type = 'u' AND u.id = tb.iid
- WHERE tb.tid IN(!l)|,
- [ keys %r ]
- )});
- }
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Options: tid, num, what, uid, mindate, hide, search, type, page, results, sort, reverse
-# what: user thread
-sub dbPostGet {
- my($self, %o) = @_;
- $o{results} ||= 50;
- $o{page} ||= 1;
- $o{what} ||= '';
-
- my %where = (
- $o{tid} ? (
- 'tp.tid = ?' => $o{tid} ) : (),
- $o{num} ? (
- 'tp.num = ?' => $o{num} ) : (),
- $o{uid} ? (
- 'tp.uid = ?' => $o{uid} ) : (),
- $o{mindate} ? (
- 'tp.date > to_timestamp(?)' => $o{mindate} ) : (),
- $o{hide} ? (
- 'tp.hidden = FALSE' => 1 ) : (),
- $o{hide} && $o{what} =~ /thread/ ? (
- 't.hidden = FALSE AND t.private = FALSE' => 1 ) : (),
- $o{type} ? (
- 'tp.tid IN(SELECT tid FROM threads_boards WHERE type IN(!l))' => [ ref $o{type} ? $o{type} : [ $o{type} ] ] ) : (),
- );
-
- my @select = (
- qw|tp.tid tp.num tp.hidden|, q|extract('epoch' from tp.date) as date|, q|extract('epoch' from tp.edited) as edited|,
- $o{search} ? () : 'tp.msg',
- $o{what} =~ /user/ ? (VNWeb::DB::sql_user()) : (),
- $o{what} =~ /thread/ ? ('t.title', 't.hidden AS thread_hidden') : (),
- );
- my @join = (
- $o{what} =~ /user/ ? 'JOIN users u ON u.id = tp.uid' : (),
- $o{what} =~ /thread/ ? 'JOIN threads t ON t.id = tp.tid' : (),
- );
-
- my $order = sprintf {
- num => 'tp.num %s',
- date => 'tp.date %s',
- }->{ $o{sort}||'num' }, $o{reverse} ? 'DESC' : 'ASC';
-
- my($r, $np) = $self->dbPage(\%o, q|
- SELECT !s
- FROM threads_posts tp
- !s
- !W
- ORDER BY !s|,
- join(', ', @select), join(' ', @join), \%where, $order
- );
-
- return wantarray ? ($r, $np) : $r;
-}
-
-1;
diff --git a/lib/VNDB/DB/Misc.pm b/lib/VNDB/DB/Misc.pm
deleted file mode 100644
index 3921db3a..00000000
--- a/lib/VNDB/DB/Misc.pm
+++ /dev/null
@@ -1,115 +0,0 @@
-
-package VNDB::DB::Misc;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-our @EXPORT = qw|
- dbItemEdit dbRevisionGet dbWikidata dbImageAdd
-|;
-
-
-# Inserts a new revision into the database
-# Arguments: type [vrpcsd], itemid, rev, %options->{ editsum uid ihid ilock + db[item]RevisionInsert }
-# rev = changes.rev of the revision this edit is based on, undef to create a new DB item
-# Returns: { itemid, chid, rev }
-sub dbItemEdit {
- my($self, $type, $itemid, $rev, %o) = @_;
-
- $self->dbExec('SELECT edit_!s_init(?, ?)', $type, $itemid, $rev);
- $self->dbExec('UPDATE edit_revision !H', {
- 'requester = ?' => $o{uid}||$self->authInfo->{id},
- 'ip = ?' => $self->reqIP,
- 'comments = ?' => $o{editsum},
- exists($o{ihid}) ? ('ihid = ?' => $o{ihid} ?1:0) : (),
- exists($o{ilock}) ? ('ilock = ?' => $o{ilock}?1:0) : (),
- });
-
- $self->dbVNRevisionInsert( \%o) if $type eq 'v';
- $self->dbProducerRevisionInsert(\%o) if $type eq 'p';
-
- return $self->dbRow('SELECT * FROM edit_!s_commit()', $type);
-}
-
-
-# Options: type, itemid, uid, auto, hidden, edit, page, results, releases
-sub dbRevisionGet {
- my($self, %o) = @_;
- $o{results} ||= 10;
- $o{page} ||= 1;
- $o{auto} ||= 0; # 0:show, -1:only, 1:hide
- $o{hidden} ||= 0;
- $o{edit} ||= 0; # 0:both, -1:new, 1:edits
- $o{releases} = 0 if !$o{type} || $o{type} ne 'v' || !$o{itemid};
-
- my %where = (
- $o{releases} ? (
- # This selects all changes of releases that are currently linked to the VN, not release revisions that are linked to the VN.
- # The latter seems more useful, but is also a lot more expensive.
- q{((c.type = 'v' AND c.itemid = ?) OR (c.type = 'r' AND c.itemid = ANY(ARRAY(SELECT rv.id FROM releases_vn rv WHERE rv.vid = ?))))} => [$o{itemid}, $o{itemid}],
- ) : (
- $o{type} ? (
- 'c.type IN(!l)' => [ ref($o{type})?$o{type}:[$o{type}] ] ) : (),
- $o{itemid} ? (
- 'c.itemid = ?' => [ $o{itemid} ] ) : (),
- ),
- $o{uid} ? (
- 'c.requester = ?' => $o{uid} ) : (),
- $o{auto} ? (
- 'c.requester !s 1' => $o{auto} < 0 ? '=' : '<>' ) : (),
- $o{hidden} ? (
- '!s EXISTS(SELECT 1 FROM changes c2 WHERE c2.type = c.type AND c2.itemid = c.itemid AND c2.ihid AND'.
- ' c2.rev = (SELECT MAX(c3.rev) FROM changes c3 WHERE c3.type = c.type AND c3.itemid = c.itemid))' => $o{hidden} == 1 ? 'NOT' : '') : (),
- $o{edit} ? (
- 'c.rev !s 1' => $o{edit} < 0 ? '=' : '>' ) : (),
- );
-
- my($r, $np) = $self->dbPage(\%o, q|
- SELECT c.id, c.type, c.itemid, c.comments, c.rev, extract('epoch' from c.added) as added, !s
- FROM changes c
- JOIN users u ON c.requester = u.id
- !W
- ORDER BY c.id DESC|, VNWeb::DB::sql_user(), \%where
- );
-
- # I couldn't find a way to fetch the titles the main query above without slowing it down considerably, so let's just do it this way.
- if(@$r) {
- my %r = map +($_->{id}, $_), @$r;
- my $w = join ' OR ', ('(type = ? AND id = ?)') x @$r;
- my @w = map +($_->{type}, $_->{id}), @$r;
-
- $r{ $_->{id} }{ititle} = $_->{title}, $r{ $_->{id} }{ioriginal} = $_->{original} for(@{$self->dbAll("
- SELECT id, title, original FROM (
- SELECT 'v'::dbentry_type, chid, title, original FROM vn_hist
- UNION ALL SELECT 'r'::dbentry_type, chid, title, original FROM releases_hist
- UNION ALL SELECT 'p'::dbentry_type, chid, name, original FROM producers_hist
- UNION ALL SELECT 'c'::dbentry_type, chid, name, original FROM chars_hist
- UNION ALL SELECT 'd'::dbentry_type, chid, title, '' AS original FROM docs_hist
- UNION ALL SELECT 's'::dbentry_type, sh.chid, name, original FROM staff_hist sh JOIN staff_alias_hist sah ON sah.chid = sh.chid AND sah.aid = sh.aid
- ) x(type, id, title, original)
- WHERE $w
- ", @w
- )});
- }
-
- return wantarray ? ($r, $np) : $r;
-}
-
-
-# Returns a row from wikidata
-sub dbWikidata {
- return $_[0]->dbRow('SELECT * FROM wikidata WHERE id = ?', $_[1]);
-}
-
-
-# insert a new image and return its ID
-sub dbImageAdd {
- my($s, $type, $width, $height) = @_;
- my $seq = {qw/sf screenshots_seq cv covers_seq ch charimg_seq/}->{$type}||die;
- return $s->dbRow(q|INSERT INTO images (id, width, height) VALUES (vndbid(?, nextval(?)::int), ?, ?) RETURNING vndbid_num(id) as id|, $type, $seq, $width, $height)->{id};
-}
-
-
-1;
-
diff --git a/lib/VNDB/DB/Producers.pm b/lib/VNDB/DB/Producers.pm
index 9497a8eb..c9d4f95f 100644
--- a/lib/VNDB/DB/Producers.pm
+++ b/lib/VNDB/DB/Producers.pm
@@ -5,7 +5,7 @@ use strict;
use warnings;
use Exporter 'import';
-our @EXPORT = qw|dbProducerGet dbProducerGetRev dbProducerRevisionInsert|;
+our @EXPORT = qw|dbProducerGet dbProducerGetRev|;
# options: results, page, id, search, char, sort, inc_hidden
@@ -104,24 +104,5 @@ sub _enrich {
return wantarray ? ($r, $np) : $r;
}
-
-# Updates the edit_* tables, used from dbItemEdit()
-# Arguments: { columns in producers_rev + relations },
-sub dbProducerRevisionInsert {
- my($self, $o) = @_;
-
- my %set = map exists($o->{$_}) ? (qq|"$_" = ?|, $o->{$_}) : (),
- qw|name original website l_wp l_wikidata type lang desc alias|;
- $self->dbExec('UPDATE edit_producers !H', \%set) if keys %set;
-
- if($o->{relations}) {
- $self->dbExec('DELETE FROM edit_producers_relations');
- my $q = join ',', map '(?,?)', @{$o->{relations}};
- my @q = map +($_->[1], $_->[0]), @{$o->{relations}};
- $self->dbExec("INSERT INTO edit_producers_relations (pid, relation) VALUES $q", @q) if @q;
- }
-}
-
-
1;
diff --git a/lib/VNDB/DB/VN.pm b/lib/VNDB/DB/VN.pm
index 1f12d0c8..668b7fec 100644
--- a/lib/VNDB/DB/VN.pm
+++ b/lib/VNDB/DB/VN.pm
@@ -9,13 +9,13 @@ use POSIX 'strftime';
use Exporter 'import';
use VNDB::Func 'normalize_query', 'gtintype';
-our @EXPORT = qw|dbVNGet dbVNGetRev dbVNRevisionInsert dbScreenshotGet dbScreenshotRandom|;
+our @EXPORT = qw|dbVNGet dbVNGetRev|;
# Options: id, char, search, gtin, length, lang, olang, plat, tag_inc, tag_exc, tagspoil,
# hasani, hasshot, ul_notblack, ul_onwish, results, page, what, sort,
# reverse, inc_hidden, date_before, date_after, released, release, character
-# What: extended anime staff seiyuu relations screenshots rating ranking vnlist
+# What: extended anime staff seiyuu relations rating ranking vnlist
# Note: vnlist is ignored (no db search) unless a user is logged in
# Sort: id rel pop rating title tagscore rand
sub dbVNGet {
@@ -111,7 +111,7 @@ sub dbVNGet {
my @select = ( # see https://rt.cpan.org/Ticket/Display.html?id=54224 for the cast on c_languages and c_platforms
qw|v.id v.locked v.hidden v.c_released v.c_languages::text[] v.c_olang::text[] v.c_platforms::text[] v.title v.original|,
$o{what} =~ /extended/ ? (
- qw|v.alias v.img_nsfw v.length v.desc v.l_wp v.l_encubed v.l_renai v.l_wikidata|, 'coalesce(vndbid_num(v.image),0) as image' ) : (),
+ qw|v.alias v.length v.desc v.l_wp v.l_encubed v.l_renai v.l_wikidata|, 'coalesce(vndbid_num(v.image),0) as image' ) : (),
$o{what} =~ /rating/ ? (qw|v.c_popularity v.c_rating v.c_votecount|) : (),
$o{what} =~ /ranking/ ? (
'(SELECT COUNT(*)+1 FROM vn iv WHERE iv.hidden = false AND iv.c_popularity > COALESCE(v.c_popularity, 0.0)) AS p_ranking',
@@ -159,7 +159,7 @@ sub dbVNGetRev {
my $select = 'c.itemid AS id, vo.c_released, vo.c_languages::text[], vo.c_olang::text[], vo.c_platforms::text[], v.title, v.original';
$select .= ', extract(\'epoch\' from c.added) as added, c.comments, c.rev, c.ihid, c.ilock, '.VNWeb::DB::sql_user();
$select .= ', c.id AS cid, NOT EXISTS(SELECT 1 FROM changes c2 WHERE c2.type = c.type AND c2.itemid = c.itemid AND c2.rev = c.rev+1) AS lastrev';
- $select .= ', v.alias, coalesce(vndbid_num(v.image), 0) as image, v.img_nsfw, v.length, v.desc, v.l_wp, v.l_encubed, v.l_renai, v.l_wikidata, vo.hidden, vo.locked' if $o{what} =~ /extended/;
+ $select .= ', v.alias, coalesce(vndbid_num(v.image), 0) as image, v.length, v.desc, v.l_wp, v.l_encubed, v.l_renai, v.l_wikidata, vo.hidden, vo.locked' if $o{what} =~ /extended/;
$select .= ', vo.c_popularity, vo.c_rating, vo.c_votecount' if $o{what} =~ /rating/;
$select .= ', (SELECT COUNT(*)+1 FROM vn iv WHERE iv.hidden = false AND iv.c_popularity > COALESCE(vo.c_popularity, 0.0)) AS p_ranking'
.', (SELECT COUNT(*)+1 FROM vn iv WHERE iv.hidden = false AND iv.c_rating > COALESCE(vo.c_rating, 0.0)) AS r_ranking' if $o{what} =~ /ranking/;
@@ -181,14 +181,13 @@ sub dbVNGetRev {
sub _enrich {
my($self, $r, $np, $rev, $what) = @_;
- if(@$r && $what =~ /anime|relations|screenshots|staff|seiyuu/) {
+ if(@$r && $what =~ /anime|relations|staff|seiyuu/) {
my($col, $hist, $colname) = $rev ? ('cid', '_hist', 'chid') : ('id', '', 'id');
my %r = map {
$r->[$_]{anime} = [];
$r->[$_]{credits} = [];
$r->[$_]{seiyuu} = [];
$r->[$_]{relations} = [];
- $r->[$_]{screenshots} = [];
($r->[$_]{$col}, $_)
} 0..$#$r;
@@ -241,17 +240,6 @@ sub _enrich {
[ keys %r ]
)});
}
-
- if($what =~ /screenshots/) {
- push(@{$r->[$r{ delete $_->{xid} }]{screenshots}}, $_) for (@{$self->dbAll("
- SELECT vs.$colname AS xid, vndbid_num(s.id) as id, vs.nsfw, vs.rid, s.width, s.height
- FROM vn_screenshots$hist vs
- JOIN images s ON vs.scr = s.id
- WHERE vs.$colname IN(!l)
- ORDER BY vs.scr",
- [ keys %r ]
- )});
- }
}
VNWeb::DB::enrich_flatten(vnlist_labels => id => vid => sub { VNWeb::DB::sql('
@@ -266,98 +254,4 @@ sub _enrich {
}
-# Updates the edit_* tables, used from dbItemEdit()
-# Arguments: { columns in producers_rev + anime + relations + screenshots }
-# screenshots = [ [ scrid, nsfw, rid ], .. ]
-# relations = [ [ rel, vid ], .. ]
-# anime = [ aid, .. ]
-sub dbVNRevisionInsert {
- my($self, $o) = @_;
-
- $o->{img_nsfw} = $o->{img_nsfw}?1:0 if exists $o->{img_nsfw};
- my %set = map exists($o->{$_}) ? (qq|"$_" = ?| => $o->{$_}) : (),
- qw|title original desc alias img_nsfw length l_wp l_encubed l_renai l_wikidata|;
- $set{'image = vndbid(\'cv\',?)'} = $o->{image}||undef if exists $o->{image};
- $self->dbExec('UPDATE edit_vn !H', \%set) if keys %set;
-
- if($o->{screenshots}) {
- $self->dbExec('DELETE FROM edit_vn_screenshots');
- my $q = join ',', map '(vndbid(\'sf\', ?), ?, ?)', @{$o->{screenshots}};
- my @val = map +($_->{id}, $_->{nsfw}?1:0, $_->{rid}), @{$o->{screenshots}};
- $self->dbExec("INSERT INTO edit_vn_screenshots (scr, nsfw, rid) VALUES $q", @val) if @val;
- }
-
- if($o->{relations}) {
- $self->dbExec('DELETE FROM edit_vn_relations');
- my $q = join ',', map '(?, ?, ?)', @{$o->{relations}};
- my @val = map +($_->[1], $_->[0], $_->[2]?1:0), @{$o->{relations}};
- $self->dbExec("INSERT INTO edit_vn_relations (vid, relation, official) VALUES $q", @val) if @val;
- }
-
- if($o->{anime}) {
- $self->dbExec('DELETE FROM edit_vn_anime');
- my $q = join ',', map '(?)', @{$o->{anime}};
- $self->dbExec("INSERT INTO edit_vn_anime (aid) VALUES $q", @{$o->{anime}}) if @{$o->{anime}};
- }
-
- if($o->{credits}) {
- $self->dbExec('DELETE FROM edit_vn_staff');
- my $q = join ',', ('(?, ?, ?)') x @{$o->{credits}};
- my @val = map +($_->{aid}, $_->{role}, $_->{note}), @{$o->{credits}};
- $self->dbExec("INSERT INTO edit_vn_staff (aid, role, note) VALUES $q", @val) if @val;
- }
-
- if($o->{seiyuu}) {
- $self->dbExec('DELETE FROM edit_vn_seiyuu');
- my $q = join ',', ('(?, ?, ?)') x @{$o->{seiyuu}};
- my @val = map +($_->{aid}, $_->{cid}, $_->{note}), @{$o->{seiyuu}};
- $self->dbExec("INSERT INTO edit_vn_seiyuu (aid, cid, note) VALUES $q", @val) if @val;
- }
-}
-
-
-# arrayref of screenshot IDs as argument
-sub dbScreenshotGet {
- return shift->dbAll(q|SELECT vndbid_num(id) AS id, width, height FROM images WHERE id IN(SELECT vndbid('sf', n::integer) FROM unnest(ARRAY[!l]) x(n))|, shift);
-}
-
-
-# Fetch random VN + screenshots
-# if any arguments are given, it will return one random screenshot for each VN
-sub dbScreenshotRandom {
- my($self, @vids) = @_;
- if(!@vids) {
- my $where = q{c_weight > 0 and vndbid_type(id) = 'sf' and c_sexual_avg < 0.4 and c_violence_avg < 0.4};
- state $stats ||= $self->dbRow("SELECT count(*) as total, count(*) filter(where $where) as subset from images");
- my $sample = 100*List::Util::min(1, (1000 / $stats->{subset}) * ($stats->{total} / $stats->{subset}));
- return $self->dbAll(q{
- SELECT vndbid_num(i.id) AS scr, i.width, i.height, v.id AS vid, v.title
- FROM (
- SELECT id, width, height
- FROM images TABLESAMPLE SYSTEM (?)
- WHERE c_weight > 0 and vndbid_type(id) = 'sf' and c_sexual_avg < 0.4 and c_violence_avg < 0.4
- ORDER BY random()
- LIMIT 4
- ) i(id)
- JOIN vn_screenshots vs ON vs.scr = i.id
- JOIN vn v ON v.id = vs.id
- ORDER BY random()
- LIMIT 4
- }, $sample);
- }
-
- # this query is faster than it looks
- return $self->dbAll(join(' UNION ALL ', map
- q|SELECT vndbid_num(s.id) AS scr, s.width, s.height, v.id AS vid, v.title, RANDOM() AS position
- FROM (
- SELECT vs2.id, vs2.scr FROM vn_screenshots vs2
- WHERE vs2.id = ? AND NOT vs2.nsfw
- ORDER BY RANDOM() LIMIT 1
- ) vs
- JOIN vn v ON v.id = vs.id
- JOIN images s ON s.id = vs.scr
- |, @vids).' ORDER BY position', @vids);
-}
-
-
1;
diff --git a/lib/VNDB/Func.pm b/lib/VNDB/Func.pm
index 2a169552..508b2272 100644
--- a/lib/VNDB/Func.pm
+++ b/lib/VNDB/Func.pm
@@ -10,7 +10,7 @@ use JSON::XS;
use VNDBUtil;
use VNDB::Types;
use VNDB::BBCode;
-our @EXPORT = (@VNDBUtil::EXPORT, 'bb2html', 'bb2text', qw|
+our @EXPORT = (@VNDBUtil::EXPORT, 'bb_format', qw|
clearfloat cssicon minage fil_parse fil_serialize parenttags
childtags charspoil imgpath imgurl
fmtvote fmtmedia fmtvnlen fmtage fmtdatestr fmtdate fmtrating fmtspoil
@@ -72,7 +72,7 @@ sub fil_serialize {
my @v = ref $fil->{$_} ? @{$fil->{$_}} : ($fil->{$_});
s/$e/_$fil_escape{$1}/g for(@v);
$_.'-'.join '~', @v
- } grep defined($fil->{$_}), keys %$fil;
+ } grep defined($fil->{$_}), sort keys %$fil;
}
diff --git a/lib/VNDB/Handler/Misc.pm b/lib/VNDB/Handler/Misc.pm
index 25d10c39..565523e6 100644
--- a/lib/VNDB/Handler/Misc.pm
+++ b/lib/VNDB/Handler/Misc.pm
@@ -10,7 +10,6 @@ use VNDB::Types;
TUWF::register(
- qr{}, \&homepage,
qr{nospam}, \&nospam,
qr{xml/prefs\.xml}, \&prefs,
qr{opensearch\.xml}, \&opensearch,
@@ -29,172 +28,6 @@ TUWF::register(
);
-sub homepage {
- my $self = shift;
-
- my $title = 'The Visual Novel Database';
- my $desc = 'VNDB.org strives to be a comprehensive database for information about visual novels.';
-
- my $metadata = {
- 'og:type' => 'website',
- 'og:title' => $title,
- 'og:description' => $desc,
- };
-
- $self->htmlHeader(title => $title, feeds => 1, metadata => $metadata);
-
- div class => 'mainbox';
- h1 $title;
- p class => 'description';
- txt $desc;
- br;
- txt 'This website is built as a wiki, meaning that anyone can freely add'
- .' and contribute information to the database, allowing us to create the'
- .' largest, most accurate and most up-to-date visual novel database on the web.';
- end;
-
- # with filters applied it's signifcantly slower, so special-code the situations with and without filters
- my @vns;
- if($self->authPref('filter_vn')) {
- my $r = $self->filFetchDB(vn => undef, undef, {hasshot => 1, results => 4, sort => 'rand'});
- @vns = map $_->{id}, @$r;
- }
- my $scr = $self->dbScreenshotRandom(@vns);
- p class => 'screenshots';
- for (@$scr) {
- my($w, $h) = imgsize($_->{width}, $_->{height}, @{$self->{scr_size}});
- a href => "/v$_->{vid}", title => $_->{title};
- img src => imgurl(st => $_->{scr}), alt => $_->{title}, width => $w, height => $h;
- end;
- }
- end;
- end 'div';
-
- table class => 'mainbox threelayout';
- Tr;
-
- # Recent changes
- td;
- h1;
- a href => '/hist', 'Recent Changes'; txt ' ';
- a href => '/feeds/changes.atom'; cssicon 'feed', 'Atom Feed'; end;
- end;
- my $changes = $self->dbRevisionGet(results => 10, auto => 1);
- ul;
- for (@$changes) {
- li;
- txt "$_->{type}:";
- a href => "/$_->{type}$_->{itemid}.$_->{rev}", title => $_->{ioriginal}||$_->{ititle}, shorten $_->{ititle}, 33;
- lit " by ";
- VNWeb::HTML::user_($_);
- end;
- }
- end;
- end 'td';
-
- # Announcements
- td;
- my $an = $self->dbThreadGet(type => 'an', sort => 'id', reverse => 1, results => 2);
- h1;
- a href => '/t/an', 'Announcements'; txt ' ';
- a href => '/feeds/announcements.atom'; cssicon 'feed', 'Atom Feed'; end;
- end;
- for (@$an) {
- my $post = $self->dbPostGet(tid => $_->{id}, num => 1)->[0];
- h2;
- a href => "/t$_->{id}", $_->{title};
- end;
- p;
- lit bb2html $post->{msg}, 150;
- end;
- }
- end 'td';
-
- # Recent posts
- td;
- h1;
- a href => '/t/all', 'Recent Posts'; txt ' ';
- a href => '/feeds/posts.atom'; cssicon 'feed', 'Atom Feed'; end;
- end;
- my $posts = $self->dbThreadGet(what => 'lastpost boardtitles', results => 10, sort => 'lastpost', reverse => 1, notusers => 1);
- ul;
- for (@$posts) {
- my $boards = join ', ', map $BOARD_TYPE{$_->{type}}{txt}.($_->{iid}?' > '.$_->{title}:''), @{$_->{boards}};
- li;
- txt fmtage($_->{lastpost_date}).' ';
- a href => VNWeb::Discussions::Lib::post_url($_->{id}, $_->{count}, 'last'), title => "Posted in $boards", shorten $_->{title}, 25;
- lit ' by ';
- VNWeb::HTML::user_($_, 'lastpost_');
- end;
- }
- end;
- end 'td';
-
- end 'tr';
- Tr;
-
- # Random visual novels
- td;
- h1;
- a href => '/v/rand', 'Random visual novels';
- end;
- my $random = $self->filFetchDB(vn => undef, undef, {results => 10, sort => 'rand'});
- ul;
- for (@$random) {
- li;
- a href => "/v$_->{id}", title => $_->{original}||$_->{title}, shorten $_->{title}, 40;
- end;
- }
- end;
- end 'td';
-
- # Upcoming releases
- td;
- h1;
- a href => '/r?fil=released-0;o=a;s=released', 'Upcoming releases';
- end;
- my $upcoming = $self->filFetchDB(release => undef, undef, {results => 10, released => 0, what => 'platforms'});
- ul;
- for (@$upcoming) {
- li;
- lit fmtdatestr $_->{released};
- txt ' ';
- cssicon $_, $PLATFORM{$_} for (@{$_->{platforms}});
- cssicon "lang $_", $LANGUAGE{$_} for (@{$_->{languages}});
- txt ' ';
- a href => "/r$_->{id}", title => $_->{original}||$_->{title}, shorten $_->{title}, 30;
- end;
- }
- end;
- end 'td';
-
- # Just released
- td;
- h1;
- a href => '/r?fil=released-1;o=d;s=released', 'Just released';
- end;
- my $justrel = $self->filFetchDB(release => undef, undef, {results => 10, sort => 'released', reverse => 1, released => 1, what => 'platforms'});
- ul;
- for (@$justrel) {
- li;
- lit fmtdatestr $_->{released};
- txt ' ';
- cssicon $_, $PLATFORM{$_} for (@{$_->{platforms}});
- cssicon "lang $_", $LANGUAGE{$_} for (@{$_->{languages}});
- txt ' ';
- a href => "/r$_->{id}", title => $_->{original}||$_->{title}, shorten $_->{title}, 30;
- end;
- }
- end;
- end 'td';
-
- end 'tr';
- end 'table';
-
- $self->htmlFooter;
-}
-
-
sub nospam {
my $self = shift;
$self->htmlHeader(title => 'Could not send form', noindex => 1);
diff --git a/lib/VNDB/Handler/Producers.pm b/lib/VNDB/Handler/Producers.pm
index d8b2cea1..e25e3320 100644
--- a/lib/VNDB/Handler/Producers.pm
+++ b/lib/VNDB/Handler/Producers.pm
@@ -9,225 +9,11 @@ use VNDB::Types;
TUWF::register(
- qr{p/add} => \&addform,
- qr{p(?:([1-9]\d*)(?:\.([1-9]\d*))?/edit|/new)}
- => \&edit,
qr{p/([a-z0]|all)} => \&list,
qr{xml/producers\.xml} => \&pxml,
);
-sub addform {
- my $self = shift;
- return $self->htmlDenied if !$self->authCan('edit');
-
- my $frm;
- my $l = [];
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'name', maxlength => 200 },
- { post => 'original', required => 0, maxlength => 200, default => '' },
- { post => 'alias', required => 0, maxlength => 500, default => '' },
- { post => 'continue_ign',required => 0 },
- );
-
- # look for duplicates
- if(!$frm->{_err} && !$frm->{continue_ign}) {
- $l = $self->dbProducerGet(search => $frm->{name}, what => 'extended', results => 50, inc_hidden => 1);
- push @$l, @{$self->dbProducerGet(search => $frm->{original}, what => 'extended', results => 50, inc_hidden => 1)} if $frm->{original};
- $_ && push @$l, @{$self->dbProducerGet(search => $_, what => 'extended', results => 50, inc_hidden => 1)} for(split /\n/, $frm->{alias});
- my %ids = map +($_->{id}, $_), @$l;
- $l = [ map $ids{$_}, sort { $ids{$a}{name} cmp $ids{$b}{name} } keys %ids ];
- }
-
- return edit($self, undef, undef, 1) if !@$l && !$frm->{_err};
- }
-
- $self->htmlHeader(title => 'Add a new producer', noindex => 1);
- if(@$l) {
- div class => 'mainbox';
- h1 'Possible duplicates found';
- div class => 'warning';
- p;
- txt 'The following is a list of producers that match the name(s) you gave.'
- .' Please check this list to avoid creating a duplicate producer entry.'
- .' Be especially wary of items that have been deleted! To see why an entry has been deleted, click on its title.';
- br; br;
- txt 'To add the producer anyway, hit the "Continue and ignore duplicates" button below.';
- end;
- end;
- ul;
- for(@$l) {
- li;
- a href => "/p$_->{id}", title => $_->{original}||$_->{name}, "p$_->{id}: ".shorten($_->{name}, 50);
- b class => 'standout', ' deleted' if $_->{hidden};
- end;
- }
- end;
- end 'div';
- }
-
- $self->htmlForm({ frm => $frm, action => '/p/add', continue => @$l ? 2 : 1 },
- vn_add => [ 'Add a new producer',
- [ input => name => 'Name (romaji)', short => 'name' ],
- [ input => name => 'Original name', short => 'original' ],
- [ static => content => 'The original name of the producer, leave blank if it is already in the Latin alphabet.' ],
- [ textarea => short => 'alias', name => 'Aliases', rows => 4 ],
- [ static => content => '(Un)official aliases, separated by a newline.' ],
- ]);
- $self->htmlFooter;
-}
-
-
-# pid as argument = edit producer
-# no arguments = add new producer
-sub edit {
- my($self, $pid, $rev, $nosubmit) = @_;
-
- my $p = $pid && $self->dbProducerGetRev(id => $pid, what => 'extended relations', rev => $rev)->[0];
- return $self->resNotFound if $pid && !$p->{id};
- $rev = undef if !$p || $p->{lastrev};
-
- return $self->htmlDenied if !$self->authCan('edit')
- || $pid && (($p->{locked} || $p->{hidden}) && !$self->authCan('dbmod'));
-
- my %b4 = !$pid ? () : (
- (map { $_ => $p->{$_} } qw|type name original lang website l_wikidata desc alias ihid ilock|),
- prodrelations => join('|||', map $_->{relation}.','.$_->{id}.','.$_->{name}, sort { $a->{id} <=> $b->{id} } @{$p->{relations}}),
- );
- my $frm;
-
- if($self->reqMethod eq 'POST') {
- return if !$nosubmit && !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'type', required => !$nosubmit, enum => [ keys %PRODUCER_TYPE ] },
- { post => 'name', maxlength => 200 },
- { post => 'original', required => 0, maxlength => 200, default => '' },
- { post => 'alias', required => 0, maxlength => 500, default => '' },
- { post => 'lang', required => !$nosubmit, enum => [ keys %LANGUAGE ] },
- { post => 'website', required => 0, maxlength => 250, default => '', template => 'weburl' },
- { post => 'l_wikidata', required => 0, template => 'wikidata' },
- { post => 'desc', required => 0, maxlength => 5000, default => '' },
- { post => 'prodrelations', required => 0, maxlength => 5000, default => '' },
- { post => 'editsum', required => !$nosubmit, template => 'editsum' },
- { post => 'ihid', required => 0 },
- { post => 'ilock', required => 0 },
- );
- $frm->{original} = '' if $frm->{original} eq $frm->{name};
- if(!$nosubmit && !$frm->{_err}) {
- # parse
- my $relations = [ map { /^([a-z]+),([0-9]+),(.+)$/ && (!$pid || $2 != $pid) ? [ $1, $2, $3 ] : () } split /\|\|\|/, $frm->{prodrelations} ];
-
- # normalize
- $frm->{ihid} = $frm->{ihid}?1:0;
- $frm->{ilock} = $frm->{ilock}?1:0;
- $frm->{desc} = $self->bbSubstLinks($frm->{desc});
- $relations = [] if $frm->{ihid};
- $frm->{prodrelations} = join '|||', map $_->[0].','.$_->[1].','.$_->[2], sort { $a->[1] <=> $b->[1]} @{$relations};
-
- return $self->resRedirect("/p$pid", 'post')
- if $pid && !grep +(($frm->{$_}//'') ne ($b4{$_}//'')), keys %b4;
-
- $frm->{relations} = $relations;
- my $nrev = $self->dbItemEdit(p => $pid||undef, $pid ? $p->{rev} : undef, %$frm);
-
- # update reverse relations
- if(!$pid && $#$relations >= 0 || $pid && $frm->{prodrelations} ne $b4{prodrelations}) {
- my %old = $pid ? (map { $_->{id} => $_->{relation} } @{$p->{relations}}) : ();
- my %new = map { $_->[1] => $_->[0] } @$relations;
- _updreverse($self, \%old, \%new, $nrev->{itemid}, $nrev->{rev});
- }
-
- return $self->resRedirect("/p$nrev->{itemid}.$nrev->{rev}", 'post');
- }
- }
-
- !defined $frm->{$_} && ($frm->{$_} = $b4{$_}) for keys %b4;
- $frm->{lang} = 'ja' if !$pid && !defined $frm->{lang};
- $frm->{editsum} = sprintf 'Reverted to revision p%d.%d', $pid, $rev if $rev && !defined $frm->{editsum};
-
- my $title = $pid ? "Edit $p->{name}" : 'Add new producer';
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('p', $p, 'edit') if $pid;
- $self->htmlEditMessage('p', $p, $title);
- $self->htmlForm({ frm => $frm, action => $pid ? "/p$pid/edit" : '/p/new', editsum => 1 },
- 'pedit_geninfo' => [ 'General info',
- [ select => name => 'Type', short => 'type',
- options => [ map [ $_, $PRODUCER_TYPE{$_} ], keys %PRODUCER_TYPE ] ],
- [ input => name => 'Name (romaji)', short => 'name' ],
- [ input => name => 'Original name', short => 'original' ],
- [ static => content => 'The original name of the producer, leave blank if it is already in the Latin alphabet.' ],
- [ textarea => short => 'alias', name => 'Aliases', rows => 4 ],
- [ static => content => '(Un)official aliases, separated by a newline.' ],
- [ select => name => 'Primary language', short => 'lang',
- options => [ map [ $_, "$LANGUAGE{$_} ($_)" ], sort { $LANGUAGE{$a} cmp $LANGUAGE{$b} } keys %LANGUAGE ] ],
- [ input => name => 'Website', short => 'website' ],
- [ input => short => 'l_wikidata',name => 'Wikidata ID',
- value => $frm->{l_wikidata} ? "Q$frm->{l_wikidata}" : '',
- post => qq{ (<a href="$self->{url_static}/f/wikidata.png">How to find this</a>)}
- ],
- [ text => name => 'Description<br /><b class="standout">English please!</b>', short => 'desc', rows => 6 ],
- ], 'pedit_rel' => [ 'Relations',
- [ hidden => short => 'prodrelations' ],
- [ static => nolabel => 1, content => sub {
- h2 'Selected producers';
- table;
- tbody id => 'relation_tbl';
- # to be filled using javascript
- end;
- end;
-
- h2 'Add producer';
- table;
- Tr id => 'relation_new';
- td class => 'tc_prod';
- input type => 'text', class => 'text';
- end;
- td class => 'tc_rel';
- Select;
- option value => $_, $PRODUCER_RELATION{$_}{txt}
- for (keys %PRODUCER_RELATION);
- end;
- end;
- td class => 'tc_add';
- a href => '#', 'add';
- end;
- end;
- end 'table';
- }],
- ]);
- $self->htmlFooter;
-}
-
-sub _updreverse {
- my($self, $old, $new, $pid, $rev) = @_;
- my %upd;
-
- # compare %old and %new
- for (keys %$old, keys %$new) {
- if(exists $$old{$_} and !exists $$new{$_}) {
- $upd{$_} = undef;
- } elsif((!exists $$old{$_} and exists $$new{$_}) || ($$old{$_} ne $$new{$_})) {
- $upd{$_} = $PRODUCER_RELATION{$$new{$_}}{reverse};
- }
- }
- return if !keys %upd;
-
- # edit all related producers
- for my $i (keys %upd) {
- my $r = $self->dbProducerGetRev(id => $i, what => 'relations')->[0];
- my @newrel = map $_->{id} != $pid ? [ $_->{relation}, $_->{id} ] : (), @{$r->{relations}};
- push @newrel, [ $upd{$i}, $pid ] if $upd{$i};
- $self->dbItemEdit(p => $i, $r->{rev},
- relations => \@newrel,
- editsum => "Reverse relation update caused by revision p$pid.$rev",
- uid => 1,
- );
- }
-}
-
-
sub list {
my($self, $char) = @_;
diff --git a/lib/VNDB/Handler/Releases.pm b/lib/VNDB/Handler/Releases.pm
index 72f32106..49d9441a 100644
--- a/lib/VNDB/Handler/Releases.pm
+++ b/lib/VNDB/Handler/Releases.pm
@@ -11,7 +11,6 @@ use VNDB::Types;
TUWF::register(
qr{r} => \&browse,
qr{r/engines} => \&engines,
- qr{xml/releases.xml} => \&relxml,
qr{xml/engines.xml} => \&enginexml,
);
@@ -164,30 +163,6 @@ sub engines {
}
-sub relxml {
- my $self = shift;
-
- my $f = $self->formValidate(
- { get => 'v', required => 1, multi => 1, mincount => 1, template => 'id' }
- );
- return $self->resNotFound if $f->{_err};
-
- my $vns = $self->dbVNGet(id => $f->{v}, order => 'title', results => 100);
- my $rel = $self->dbReleaseGet(vid => $f->{v}, results => 100, what => 'vn');
-
- $self->resHeader('Content-type' => 'text/xml; charset=UTF-8');
- xml;
- tag 'vns';
- for my $v (@$vns) {
- tag 'vn', id => $v->{id}, title => $v->{title};
- tag 'release', id => $_->{id}, lang => join(',', @{$_->{languages}}), $_->{title}
- for (grep (grep $_->{vid} == $v->{id}, @{$_->{vn}}), @$rel);
- end;
- }
- end;
-}
-
-
sub enginexml {
my $self = shift;
diff --git a/lib/VNDB/Handler/Tags.pm b/lib/VNDB/Handler/Tags.pm
index c44529cf..55bf99db 100644
--- a/lib/VNDB/Handler/Tags.pm
+++ b/lib/VNDB/Handler/Tags.pm
@@ -83,7 +83,7 @@ sub tagpage {
if($t->{description}) {
p class => 'description';
- lit bb2html $t->{description};
+ lit bb_format $t->{description};
end;
}
if(!$t->{applicable} || !$t->{searchable}) {
diff --git a/lib/VNDB/Handler/Traits.pm b/lib/VNDB/Handler/Traits.pm
index f9802cff..e69b673e 100644
--- a/lib/VNDB/Handler/Traits.pm
+++ b/lib/VNDB/Handler/Traits.pm
@@ -64,7 +64,7 @@ sub traitpage {
if($t->{description}) {
p class => 'description';
- lit bb2html $t->{description};
+ lit bb_format $t->{description};
end;
}
if(!$t->{applicable} || !$t->{searchable}) {
diff --git a/lib/VNDB/Handler/VNEdit.pm b/lib/VNDB/Handler/VNEdit.pm
deleted file mode 100644
index 49e383a7..00000000
--- a/lib/VNDB/Handler/VNEdit.pm
+++ /dev/null
@@ -1,541 +0,0 @@
-
-package VNDB::Handler::VNEdit;
-
-use strict;
-use warnings;
-use TUWF ':html', ':xml';
-use Image::Magick;
-use VNDB::Func;
-use VNDB::Types;
-
-
-TUWF::register(
- qr{v(?:([1-9]\d*)(?:\.([1-9]\d*))?/edit|/new)}
- => \&edit,
- qr{v/add} => \&addform,
- qr{xml/vn\.xml} => \&vnxml,
- qr{xml/screenshots\.xml} => \&scrxml,
-);
-
-
-sub addform {
- my $self = shift;
- return $self->htmlDenied if !$self->authCan('edit');
-
- my $frm;
- my $l = [];
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'title', maxlength => 250 },
- { post => 'original', required => 0, maxlength => 250, default => '' },
- { post => 'alias', required => 0, maxlength => 500, default => '' },
- { post => 'continue_ign',required => 0 },
- );
-
- # look for duplicates
- if(!$frm->{_err} && !$frm->{continue_ign}) {
- $l = $self->dbVNGet(search => $frm->{title}, what => 'changes', results => 50, inc_hidden => 1);
- push @$l, @{$self->dbVNGet(search => $frm->{original}, what => 'changes', results => 50, inc_hidden => 1)} if $frm->{original};
- $_ && push @$l, @{$self->dbVNGet(search => $_, what => 'changes', results => 50, inc_hidden => 1)} for(split /\n/, $frm->{alias});
- my %ids = map +($_->{id}, $_), @$l;
- $l = [ map $ids{$_}, sort { $ids{$a}{title} cmp $ids{$b}{title} } keys %ids ];
- }
-
- return edit($self, undef, undef, 1) if !@$l && !$frm->{_err};
- }
-
- $self->htmlHeader(title => 'Add a new visual novel', noindex => 1);
- if(@$l) {
- div class => 'mainbox';
- h1 'Possible duplicates found';
- div class => 'warning';
- p;
- txt 'The following is a list of visual novels that match the title(s) you gave.'
- .' Please check this list to avoid creating a duplicate visual novel entry.'
- .' Be especially wary of items that have been deleted! To see why an entry has been deleted, click on its title.';
- br; br;
- txt 'To add the visual novel anyway, hit the "Continue and ignore duplicates" button below.';
- end;
- end;
- ul;
- for(@$l) {
- li;
- a href => "/v$_->{id}", title => $_->{original}||$_->{title}, "v$_->{id}: ".shorten($_->{title}, 50);
- b class => 'standout', ' deleted' if $_->{hidden};
- end;
- }
- end;
- end 'div';
- }
-
- $self->htmlForm({ frm => $frm, action => '/v/add', continue => @$l ? 2 : 1 },
- vn_add => [ 'Add a new visual novel',
- [ input => short => 'title', name => 'Title (romaji)', width => 450 ],
- [ input => short => 'original', name => 'Original title', width => 450 ],
- [ static => content => 'The original title of this visual novel, leave blank if it already is in the Latin alphabet.' ],
- [ textarea => short => 'alias', name => 'Aliases', rows => 4 ],
- [ static => content => 'List of alternative titles or abbreviations. One line for each alias.' ],
- ]);
- $self->htmlFooter;
-}
-
-
-sub edit {
- my($self, $vid, $rev, $nosubmit) = @_;
-
- my $v = $vid && $self->dbVNGetRev(id => $vid, what => 'extended screenshots relations anime staff seiyuu changes', $rev ? (rev => $rev) : ())->[0];
- return $self->resNotFound if $vid && !$v->{id};
- $rev = undef if !$vid || $v->{lastrev};
-
- return $self->htmlDenied if !$self->authCan('edit')
- || $vid && (($v->{locked} || $v->{hidden}) && !$self->authCan('dbmod'));
-
- my $r = $v ? $self->dbReleaseGet(vid => $v->{id}) : [];
- my $chars = $v ? $self->dbCharGet(vid => $v->{id}, results => 500) : [];
-
- my %b4 = !$vid ? () : (
- (map { $_ => $v->{$_} } qw|title original desc alias length l_renai l_wikidata image img_nsfw ihid ilock|),
- credits => [
- map { my $c = $_; +{ map { $_ => $c->{$_} } qw|aid role note| } }
- sort { $a->{aid} <=> $b->{aid} || $a->{role} cmp $b->{role} } @{$v->{credits}}
- ],
- seiyuu => [
- map { my $c = $_; +{ map { $_ => $c->{$_} } qw|aid cid note| } }
- sort { $a->{aid} <=> $b->{aid} || $a->{cid} <=> $b->{cid} } @{$v->{seiyuu}}
- ],
- anime => join(' ', sort { $a <=> $b } map $_->{id}, @{$v->{anime}}),
- vnrelations => join('|||', map $_->{relation}.','.$_->{id}.','.($_->{official}?1:0).','.$_->{title}, sort { $a->{id} <=> $b->{id} } @{$v->{relations}}),
- screenshots => [
- map +{ id => $_->{id}, nsfw => $_->{nsfw}?1:0, rid => $_->{rid} },
- sort { $a->{id} <=> $b->{id} } @{$v->{screenshots}}
- ]
- );
-
- my $frm;
- if($self->reqMethod eq 'POST') {
- return if !$nosubmit && !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'title', maxlength => 250 },
- { post => 'original', required => 0, maxlength => 250, default => '' },
- { post => 'alias', required => 0, maxlength => 500, default => '' },
- { post => 'desc', required => 0, default => '', maxlength => 10240 },
- { post => 'length', required => 0, default => 0, enum => [ keys %VN_LENGTH ] },
- { post => 'l_renai', required => 0, default => '', maxlength => 100 },
- { post => 'l_wikidata', required => 0, template => 'wikidata' },
- { post => 'anime', required => 0, default => '' },
- { post => 'image', required => 0, default => 0, template => 'id' },
- { post => 'img_nsfw', required => 0, default => 0 },
- { post => 'credits', required => 0, template => 'json', json_unique => ['aid','role'], json_sort => ['aid','role'], json_fields => [
- { field => 'aid', required => 1, template => 'id' },
- { field => 'role', required => 1, enum => [ keys %CREDIT_TYPE ] },
- { field => 'note', required => 0, maxlength => 250, default => '' },
- ]},
- { post => 'seiyuu', required => 0, template => 'json', json_unique => ['aid','cid'], json_sort => ['aid','cid'], json_fields => [
- { field => 'aid', required => 1, template => 'id' },
- { field => 'cid', required => 1, template => 'id' },
- { field => 'note', required => 0, maxlength => 250, default => '' },
- ]},
- { post => 'vnrelations', required => 0, default => '', maxlength => 5000 },
- { post => 'screenshots', required => 0, template => 'json', json_maxitems => 10, json_unique => 'id', json_sort => 'id', json_fields => [
- { field => 'id', required => 1, template => 'id' },
- { field => 'rid', required => 1, template => 'id' },
- { field => 'nsfw', required => 1, template => 'uint', enum => [0,1] },
- ]},
- { post => 'editsum', required => !$nosubmit, template => 'editsum' },
- { post => 'ihid', required => 0 },
- { post => 'ilock', required => 0 },
- );
- $frm->{original} = '' if $frm->{original} eq $frm->{title};
-
- # handle image upload
- $frm->{image} = _uploadimage($self, $frm) if !$nosubmit;
-
- if(!$nosubmit && !$frm->{_err}) {
- # normalize aliases
- $frm->{alias} = join "\n", map { s/^ +//g; s/ +$//g; $_?($_):() } split /\n/, $frm->{alias};
- # throw error on duplicate/existing aliases
- my %alias = map +(lc($_),1), $frm->{title}, $frm->{original}, map +($_->{title}, $_->{original}), @$r;
- my @e = map $alias{ lc($_) }++ ? "Duplicate alias '$_', or the alias is already used as a release title" : (), split /\n/, $frm->{alias};
- $frm->{_err} = \@e if @e;
- }
- if(!$nosubmit && !$frm->{_err}) {
- # parse and re-sort fields that have multiple representations of the same information
- my $anime = { map +($_=>1), grep /^[0-9]+$/, split /[ ,]+/, $frm->{anime} };
- my $relations = [ map { /^([a-z]+),([0-9]+),([01]),(.+)$/ && (!$vid || $2 != $vid) ? [ $1, $2, $3, $4 ] : () } split /\|\|\|/, $frm->{vnrelations} ];
-
- # Ensure submitted alias / character IDs exist within database
- my @alist = map $_->{aid}, @{$frm->{credits}}, @{$frm->{seiyuu}};
- my %staff = @alist ? map +($_->{aid}, 1), @{$self->dbStaffGet(aid => \@alist, results => 200)} : ();
- my %vn_chars = map +($_->{id} => 1), @$chars;
- $frm->{credits} = [ grep $staff{$_->{aid}}, @{$frm->{credits}} ];
- $frm->{seiyuu} = [ grep $staff{$_->{aid}} && $vn_chars{$_->{cid}}, @$chars ? @{$frm->{seiyuu}} : () ];
-
- $frm->{ihid} = $frm->{ihid}?1:0;
- $frm->{ilock} = $frm->{ilock}?1:0;
- $frm->{desc} = $self->bbSubstLinks($frm->{desc});
- $relations = [] if $frm->{ihid};
- $frm->{anime} = join ' ', sort { $a <=> $b } keys %$anime;
- $frm->{vnrelations} = join '|||', map $_->[0].','.$_->[1].','.($_->[2]?1:0).','.$_->[3], sort { $a->[1] <=> $b->[1]} @{$relations};
- $frm->{img_nsfw} = $frm->{img_nsfw} ? 1 : 0;
- $frm->{screenshots} = [ sort { $a->{id} <=> $b->{id} } @{$frm->{screenshots}} ];
-
- # nothing changed? just redirect
- return $self->resRedirect("/v$vid", 'post') if $vid && !form_compare(\%b4, $frm);
-
- # perform the edit/add
- my $nrev = $self->dbItemEdit(v => $vid ? ($v->{id}, $v->{rev}) : (undef, undef),
- (map { $_ => $frm->{$_} } qw|title original image alias desc length l_renai l_wikidata editsum img_nsfw ihid ilock credits seiyuu screenshots|),
- anime => [ keys %$anime ],
- relations => $relations,
- );
-
- # update reverse relations & relation graph
- if(!$vid && $#$relations >= 0 || $vid && $frm->{vnrelations} ne $b4{vnrelations}) {
- my %old = $vid ? (map +($_->{id} => [ $_->{relation}, $_->{official} ]), @{$v->{relations}}) : ();
- my %new = map +($_->[1] => [ $_->[0], $_->[2] ]), @$relations;
- _updreverse($self, \%old, \%new, $nrev->{itemid}, $nrev->{rev});
- }
-
- return $self->resRedirect("/v$nrev->{itemid}.$nrev->{rev}", 'post');
- }
- }
-
- !exists $frm->{$_} && ($frm->{$_} = $b4{$_}) for (keys %b4);
- $frm->{editsum} = sprintf 'Reverted to revision v%d.%d', $vid, $rev if $rev && !defined $frm->{editsum};
-
- my $title = $vid ? "Edit $v->{title}" : 'Add a new visual novel';
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('v', $v, 'edit') if $vid;
- $self->htmlEditMessage('v', $v, $title);
- _form($self, $v, $frm, $r, $chars);
- $self->htmlFooter;
-}
-
-
-sub _uploadimage {
- my($self, $frm) = @_;
-
- if($frm->{_err} || !$self->reqPost('img')) {
- return 0 if !$frm->{image};
- push @{$frm->{_err}}, 'No image with that ID' if !-s imgpath(cv => $frm->{image});
- return $frm->{image};
- }
-
- # perform some elementary checks
- my $imgdata = $self->reqUploadRaw('img');
- $frm->{_err} = [ 'Image must be in JPEG or PNG format' ] if $imgdata !~ /^(\xff\xd8|\x89\x50)/; # JPG or PNG headers
- $frm->{_err} = [ 'Image is too large, only 5MB allowed' ] if length($imgdata) > 5*1024*1024;
- return undef if $frm->{_err};
-
- # resize/compress
- my $im = Image::Magick->new;
- $im->BlobToImage($imgdata);
- $im->Set(magick => 'JPEG');
- my($ow, $oh) = ($im->Get('width'), $im->Get('height'));
- my($nw, $nh) = imgsize($ow, $oh, @{$self->{cv_size}});
- $im->Set(background => '#ffffff');
- $im->Set(alpha => 'Remove');
- if($ow != $nw || $oh != $nh) {
- $im->GaussianBlur(geometry => '0.5x0.5');
- $im->Resize(width => $nw, height => $nh);
- $im->UnsharpMask(radius => 0, sigma => 0.75, amount => 0.75, threshold => 0.008);
- }
- $im->Set(quality => 90);
-
- # Get ID and save
- my $imgid = $self->dbImageAdd(cv => $nw, $nh);
- my $fn = imgpath(cv => $imgid);
- $im->Write($fn);
- chmod 0666, $fn;
-
- return $imgid;
-}
-
-
-sub _form {
- my($self, $v, $frm, $r, $chars) = @_;
- $self->htmlForm({ frm => $frm, action => $v ? "/v$v->{id}/edit" : '/v/new', editsum => 1, upload => 1 },
- vn_geninfo => [ 'General info',
- [ input => short => 'title', name => 'Title (romaji)', width => 450 ],
- [ input => short => 'original', name => 'Original title', width => 450 ],
- [ static => content => 'The original title of this visual novel, leave blank if it already is in the Latin alphabet.' ],
- [ textarea => short => 'alias', name => 'Aliases', rows => 4 ],
- [ static => content =>
- 'List of alternative titles or abbreviations. One line for each alias.'
- .' Can include both official (japanese/english) titles and unofficial titles used around net.<br />'
- .' Titles that are listed in the releases should not be added here!' ],
- [ textarea => short => 'desc', name => 'Description<br /><b class="standout">English please!</b>', rows => 10 ],
- [ static => content =>
- 'Short description of the main story. Please do not include spoilers, and don\'t forget to list'
- .' the source in case you didn\'t write the description yourself. Formatting codes are allowed.' ],
- [ select => short => 'length', name => 'Length', options =>
- [ map [ $_ => fmtvnlen $_, 1 ], keys %VN_LENGTH ] ],
-
- [ input => short => 'l_wikidata',name => 'Wikidata ID',
- pre => 'https://www.wikidata.org/wiki/',
- value => $frm->{l_wikidata} ? "Q$frm->{l_wikidata}" : '',
- post => qq{ (<a href="$self->{url_static}/f/wikidata.png">How to find this</a>)}
- ],
- [ input => short => 'l_renai', name => 'Renai.us link', pre => 'http://renai.us/game/', post => '.shtml' ],
-
- [ input => short => 'anime', name => 'Anime' ],
- [ static => content =>
- 'Whitespace separated list of <a href="http://anidb.net/">AniDB</a> anime IDs.'
- .' E.g. "1015 3348" will add <a href="http://anidb.net/a1015">Shingetsutan Tsukihime</a>'
- .' and <a href="http://anidb.net/a3348">Fate/stay night</a> as related anime.<br />'
- .' Note: It can take a few minutes for the anime titles to appear on the VN page.' ],
- ],
-
- vn_img => [ 'Image', [ static => nolabel => 1, content => sub {
- div class => 'img';
- p 'No image uploaded yet' if !$frm->{image};
- img src => imgurl(cv => $frm->{image}) if $frm->{image};
- end;
-
- div;
- h2 'Image ID';
- input type => 'text', class => 'text', name => 'image', id => 'image', value => $frm->{image}||'';
- p 'Use a VN image that is already on the server. Set to \'0\' to remove the current image.';
- br; br;
-
- h2 'Upload new image';
- input type => 'file', class => 'text', name => 'img', id => 'img';
- p 'Preferably the cover of the CD/DVD/package. Image must be in JPEG or PNG format'
- .' and at most 5MB. Images larger than 256x400 will automatically be resized.';
- br; br; br;
-
- h2 'NSFW';
- input type => 'checkbox', class => 'checkbox', id => 'img_nsfw', name => 'img_nsfw',
- $frm->{img_nsfw} ? (checked => 'checked') : ();
- label class => 'checkbox', for => 'img_nsfw', 'Not Safe For Work';
- p 'Please check this option if the image contains nudity, gore, or is otherwise not safe in a work-friendly environment.';
- end 'div';
- }]],
-
- vn_staff => [ 'Staff',
- [ json => short => 'credits' ],
- [ static => nolabel => 1, content => sub {
- # propagate staff ids and names to javascript
- my @alist = map $_->{aid}, @{$frm->{credits}}, @{$frm->{seiyuu}};
- script_json staffdata => {
- map +($_->{aid}, {id => $_->{id}, aid => $_->{aid}, name => $_->{name}}),
- @alist ? @{$self->dbStaffGet(aid => \@alist, results => 200)} : ()
- };
- div class => 'warning';
- lit 'Please check the <a href="/d2#3">staff editing guidelines</a>. You can'
- .' <a href="/s/new">create a new staff entry</a> if it is not in the database yet,'
- .' but please <a href="/s/all">check for aliasses first</a>.';
- end;
- br;
- table; tbody id => 'credits_tbl';
- Tr id => 'credits_loading'; td colspan => '4', 'Loading...'; end;
- end; end;
- h2 'Add staff';
- table; Tr;
- td class => 'tc_staff';
- input id => 'credit_input', type => 'text', class => 'text', style => 'width: 300px'; end;
- td colspan => 3, '';
- end; end;
- }]],
-
- # Cast tab is only shown for VNs with some characters listed.
- # There's no way to add voice actors in new VN edits since character list
- # would be empty anyway.
- @{$chars} ? (vn_cast => [ 'Cast',
- [ json => short => 'seiyuu' ],
- [ static => nolabel => 1, content => sub {
- table; tbody id => 'cast_tbl';
- Tr id => 'cast_loading'; td colspan => '4', 'Loading...'; end;
- end; end;
- h2 'Add cast';
- table; Tr;
- td class => 'tc_char';
- Select id =>'cast_chars';
- option value => '', 'Select character';
- for my $i (0..$#$chars) {
- my($name, $id) = @{$chars->[$i]}{qw|name id|};
- # append character IDs to coinciding names
- # (assume dbCharGet sorted characters by name)
- $name .= ' - c'.$id if $name eq ($chars->[$i+1]{name}//'')
- .. $name ne ($chars->[$i+1]{name}//'');
- option value => $id, $name;
- }
- end;
- txt ' voiced by';
- end;
- td class => 'tc_staff';
- input id => 'cast_input', type => 'text', class => 'text', style => 'width: 300px';
- end;
- td colspan => 2, '';
- end; end;
- }]]) : (),
-
- vn_rel => [ 'Relations',
- [ hidden => short => 'vnrelations' ],
- [ static => nolabel => 1, content => sub {
- h2 'Selected relations';
- table;
- tbody id => 'relation_tbl';
- # to be filled using javascript
- end;
- end;
-
- h2 'Add relation';
- table;
- Tr id => 'relation_new';
- td class => 'tc_vn';
- input type => 'text', class => 'text';
- end;
- td class => 'tc_rel';
- txt 'is an ';
- input type => 'checkbox', id => 'official', checked => 'checked';
- label for => 'official', 'official';
- Select;
- option value => $_, $VN_RELATION{$_}{txt}
- for (keys %VN_RELATION);
- end;
- txt ' of';
- end;
- td class => 'tc_title', $v ? $v->{title} : '';
- td class => 'tc_add';
- a href => '#', 'add';
- end;
- end;
- end 'table';
- }],
- ],
-
- vn_scr => [ 'Screenshots', !@$r ? (
- [ static => nolabel => 1, content => 'No releases in the database yet. Screenshots can only be uploaded after a release has been added.' ],
- ) : (
- [ json => short => 'screenshots' ],
- [ static => nolabel => 1, content => sub {
- my @scr = map $_->{id}, @{$frm->{screenshots}};
- my %scr = map +($_->{id}, [ $_->{width}, $_->{height}]), @scr ? @{$self->dbScreenshotGet(\@scr)} : ();
- my @rels = map [ $_->{id}, sprintf '[%s] %s (r%d)', join(',', @{$_->{languages}}), $_->{title}, $_->{id} ], @$r;
- script_json screendata => {
- size => \%scr,
- rel => \@rels,
- staticurl => $self->{url_static},
- };
- div class => 'warning';
- lit 'Please keep the following in mind when uploading screenshots:<br />'
- .'- Screenshots have to be in the native resolution of the game,<br />'
- .'- Remove any window borders and make sure the image is unmarked,<br />'
- .'- Don\'t only upload event CGs.<br />'
- .'Please read the <a href="/d2#6">guidelines</a> for more information.<br />'
- .'Make sure to submit the form after the upload has finished!';
- end;
- br;
- table class => 'stripe';
- tbody id => 'scr_table', '';
- end;
- }],
- )]
-
- );
-}
-
-
-# Update reverse relations and regenerate relation graph
-# Arguments: %old. %new, vid, rev
-# %old,%new -> { vid => [ relation, official ], .. }
-# from the perspective of vid
-# rev is of the related edit
-sub _updreverse {
- my($self, $old, $new, $vid, $rev) = @_;
- my %upd;
-
- # compare %old and %new
- for (keys %$old, keys %$new) {
- if(exists $$old{$_} and !exists $$new{$_}) {
- $upd{$_} = undef;
- } elsif((!exists $$old{$_} and exists $$new{$_}) || ($$old{$_}[0] ne $$new{$_}[0] || !$$old{$_}[1] != !$$new{$_}[1])) {
- $upd{$_} = [ $VN_RELATION{ $$new{$_}[0] }{reverse}, $$new{$_}[1] ];
- }
- }
- return if !keys %upd;
-
- # edit all related VNs
- for my $i (keys %upd) {
- my $r = $self->dbVNGetRev(id => $i, what => 'relations')->[0];
- my @newrel = map $_->{id} != $vid ? [ $_->{relation}, $_->{id}, $_->{official} ] : (), @{$r->{relations}};
- push @newrel, [ $upd{$i}[0], $vid, $upd{$i}[1] ] if $upd{$i};
- $self->dbItemEdit(v => $r->{id}, $r->{rev},
- relations => \@newrel,
- editsum => "Reverse relation update caused by revision v$vid.$rev",
- uid => 1, # Multi
- );
- }
-}
-
-
-# peforms a (simple) search and returns the results in XML format
-sub vnxml {
- my $self = shift;
-
- my $q = $self->formValidate({ get => 'q', maxlength => 500 });
- return $self->resNotFound if $q->{_err};
- $q = $q->{q};
-
- my($list, $np) = $self->dbVNGet(
- $q =~ /^v([1-9]\d*)/ ? (id => $1) : (search => $q),
- results => 10,
- page => 1,
- );
-
- $self->resHeader('Content-type' => 'text/xml; charset=UTF-8');
- xml;
- tag 'vns', more => $np ? 'yes' : 'no', query => $q;
- for(@$list) {
- tag 'item', id => $_->{id}, $_->{title};
- }
- end;
-}
-
-
-# handles uploading screenshots and fetching information about them
-sub scrxml {
- my $self = shift;
- return $self->htmlDenied if !$self->authCan('edit') || $self->reqMethod ne 'POST';
-
- # upload new screenshot
- my $id = 0;
- my $imgdata = $self->reqUploadRaw('file');
- $id = -2 if !$imgdata;
- $id = -1 if !$id && $imgdata !~ /^(\xff\xd8|\x89\x50)/; # JPG or PNG headers
-
- # no error? process it
- my($ow, $oh);
- if(!$id) {
- my $im = Image::Magick->new;
- $im->BlobToImage($imgdata);
- $im->Set(background => '#000000');
- $im->Set(alpha => 'Remove');
- $im->Set(magick => 'JPEG');
- $im->Set(quality => 90);
- ($ow, $oh) = ($im->Get('width'), $im->Get('height'));
-
- $id = $self->dbImageAdd(sf => $ow, $oh);
- my $fn = imgpath(sf => $id);
- $im->Write($fn);
- chmod 0666, $fn;
-
- # thumbnail
- my($nw, $nh) = imgsize($ow, $oh, @{$self->{scr_size}});
- $im->Thumbnail(width => $nw, height => $nh);
- $im->Set(quality => 90);
- $fn = imgpath(st => $id);
- $im->Write($fn);
- chmod 0666, $fn;
- }
-
- $self->resHeader('Content-type' => 'text/xml; charset=UTF-8');
- xml;
- tag 'image', id => $id, $id > 0 ? (width => $ow, height => $oh) : (), undef;
-}
-
-
-1;
-
diff --git a/lib/VNDB/Handler/VNPage.pm b/lib/VNDB/Handler/VNPage.pm
index 1e11aa7b..1198a421 100644
--- a/lib/VNDB/Handler/VNPage.pm
+++ b/lib/VNDB/Handler/VNPage.pm
@@ -149,7 +149,7 @@ my @rel_cols = (
default => 1,
what => 'extended',
has_data => sub { !!$_[0]{notes} },
- draw => sub { lit bb2html $_[0]{notes} },
+ draw => sub { lit bb_format $_[0]{notes} },
}
);
diff --git a/lib/VNDB/Util/CommonHTML.pm b/lib/VNDB/Util/CommonHTML.pm
index c052f726..85722f1b 100644
--- a/lib/VNDB/Util/CommonHTML.pm
+++ b/lib/VNDB/Util/CommonHTML.pm
@@ -3,16 +3,11 @@ package VNDB::Util::CommonHTML;
use strict;
use warnings;
-use TUWF ':html', 'xml_escape', 'html_escape';
use Exporter 'import';
-use Algorithm::Diff::XS 'compact_diff';
-use Encode 'encode_utf8', 'decode_utf8';
use VNDB::Func;
-use POSIX 'ceil';
our @EXPORT = qw|
- htmlMainTabs htmlDenied htmlHiddenMessage htmlRevision
- htmlEditMessage htmlItemMessage htmlSearchBox
+ htmlMainTabs htmlDenied htmlSearchBox
|;
@@ -31,189 +26,6 @@ sub htmlMainTabs {
sub htmlDenied { shift->resDenied }
-# Generates message saying that the current item has been deleted,
-# Arguments: [pvrc], obj
-# Returns 1 if the use doesn't have access to the page, 0 otherwise
-sub htmlHiddenMessage {
- my($self, $type, $obj) = @_;
- return 0 if !$obj->{hidden};
- my $board = $type =~ /[csd]/ ? 'db' : $type eq 'r' ? 'v'.$obj->{vn}[0]{vid} : $type.$obj->{id};
- # fetch edit summary (not present in $obj, requires the db*GetRev() methods)
- my $editsum = $type eq 'v' ? $self->dbVNGetRev(id => $obj->{id})->[0]{comments}
- : $type eq 'r' ? $self->dbReleaseGetRev(id => $obj->{id})->[0]{comments}
- : $type eq 'c' ? $self->dbCharGetRev(id => $obj->{id})->[0]{comments}
- : $self->dbProducerGetRev(id => $obj->{id})->[0]{comments};
- div class => 'mainbox';
- h1 $obj->{title}||$obj->{name};
- div class => 'warning';
- h2 'Item deleted';
- p;
- lit 'This item has been deleted from the database. File a request on the <a href="/t/'.$board.'">discussion board</a> to undelete this page.';
- br; br;
- lit bb2html $editsum;
- end;
- end;
- end 'div';
- return $self->htmlFooter() || 1 if !$self->authCan('dbmod');
- return 0;
-}
-
-
-# Shows a revision, including diff if there is a previous revision.
-# Arguments: v|p|r|c|d, old revision, new revision, @fields
-# Where @fields is a list of fields as arrayrefs with:
-# [ shortname, displayname, %options ],
-# Where %options:
-# diff => 1/0/regex, whether to show a diff on this field, and what to split it with (1 = character-level diff)
-# short_diff=> 1/0, when set, cut off long context in diffs
-# serialize => coderef, should convert the field into a readable string, no HTML allowed
-# htmlize => same as serialize, but HTML is allowed and this can't be diff'ed
-# split => coderef, should return an array of HTML strings that can be diff'ed. (implies diff => 1)
-# join => used in combination with split, specifies the string used for joining the HTML strings
-sub htmlRevision {
- my($self, $type, $old, $new, @fields) = @_;
- div class => 'mainbox revision';
- h1 "Revision $new->{rev}";
-
- # previous/next revision links
- a class => 'prev', href => sprintf('/%s%d.%d', $type, $new->{id}, $new->{rev}-1), '<- earlier revision' if $new->{rev} > 1;
- a class => 'next', href => sprintf('/%s%d.%d', $type, $new->{id}, $new->{rev}+1), 'later revision ->' if !$new->{lastrev};
- p class => 'center';
- a href => "/$type$new->{id}", "$type$new->{id}";
- end;
-
- # no previous revision, just show info about the revision itself
- if(!$old) {
- div class => 'rev';
- revheader($self, $type, $new);
- br;
- b 'Edit summary';
- br; br;
- lit bb2html($new->{comments})||'-';
- end;
- }
-
- # otherwise, compare the two revisions
- else {
- table class => 'stripe';
- thead;
- Tr;
- td; lit '&#xa0;'; end;
- td; revheader($self, $type, $old); end;
- td; revheader($self, $type, $new); end;
- end;
- Tr;
- td; lit '&#xa0;'; end;
- td colspan => 2;
- b "Edit summary of revision $new->{rev}:";
- br; br;
- lit bb2html($new->{comments})||'-';
- end;
- end;
- end;
- revdiff($type, $old, $new, @$_) for (
- [ ihid => 'Deleted', serialize => sub { $_[0] ? 'Yes' : 'No' } ],
- [ ilock => 'Locked', serialize => sub { $_[0] ? 'Yes' : 'No' } ],
- @fields
- );
- end 'table';
- }
- end 'div';
-}
-
-sub revheader { # type, obj
- my($self, $type, $obj) = @_;
- b "Revision $obj->{rev}";
- txt ' (';
- a href => "/$type$obj->{id}.$obj->{rev}/edit", 'revert to';
- if($obj->{user_id} && $self->authCan('board')) {
- lit ' / ';
- a href => "/t/u$obj->{user_id}/new?title=Regarding%20$type$obj->{id}.$obj->{rev}", 'msg user';
- }
- txt ')';
- br;
- txt 'By ';
- VNWeb::HTML::user_($obj);
- txt ' on ';
- txt fmtdate $obj->{added}, 'full';
-}
-
-sub revdiff {
- my($type, $old, $new, $short, $display, %o) = @_;
-
- $o{serialize} ||= $o{htmlize};
- $o{diff} = 1 if $o{split};
- $o{join} ||= '';
-
- my $ser1 = $o{serialize} ? $o{serialize}->($old->{$short}, $old) : $old->{$short};
- my $ser2 = $o{serialize} ? $o{serialize}->($new->{$short}, $new) : $new->{$short};
- return if $ser1 eq $ser2;
-
- if($o{diff} && $ser1 && $ser2) {
- my $sep = ref $o{diff} ? qr/($o{diff})/ : qr//;
- my @ser1 = map encode_utf8($_), $o{split} ? $o{split}->($ser1) : map html_escape($_), split $sep, $ser1;
- my @ser2 = map encode_utf8($_), $o{split} ? $o{split}->($ser2) : map html_escape($_), split $sep, $ser2;
- return if $o{split} && $#ser1 == $#ser2 && !grep $ser1[$_] ne $ser2[$_], 0..$#ser1;
-
- $ser1 = $ser2 = '';
- my @d = compact_diff(\@ser1, \@ser2);
- my $lastchunk = int (($#d-2)/2);
- for my $i (0..$lastchunk) {
- # $i % 2 == 0 -> equal, otherwise it's different
- my $a = join($o{join}, @ser1[ $d[$i*2] .. $d[$i*2+2]-1 ]);
- my $b = join($o{join}, @ser2[ $d[$i*2+1] .. $d[$i*2+3]-1 ]);
- # Reduce context if we have too much
- if($o{short_diff} && $i % 2 == 0 && length($a) > 300) {
- my $sep = '<b class="standout">&lt;...&gt;</b>';
- my $ctx = 100;
- $a = $i == 0 ? $sep.'<br>'.substr $a, -$ctx :
- $i == $lastchunk ? substr($a, 0, $ctx).'<br>'.$sep :
- substr($a, 0, $ctx)."<br><br>$sep<br><br>".substr($a, -$ctx);
- $b = $a;
- }
- $ser1 .= ($ser1?$o{join}:'').($i % 2 ? qq|<b class="diff_del">$a</b>| : $a) if $a ne '';
- $ser2 .= ($ser2?$o{join}:'').($i % 2 ? qq|<b class="diff_add">$b</b>| : $b) if $b ne '';
- }
- $ser1 = decode_utf8($ser1);
- $ser2 = decode_utf8($ser2);
- } elsif(!$o{htmlize}) {
- $ser1 = html_escape $ser1;
- $ser2 = html_escape $ser2;
- }
-
- $ser1 = '[empty]' if !$ser1 && $ser1 ne '0';
- $ser2 = '[empty]' if !$ser2 && $ser2 ne '0';
-
- Tr;
- td $display;
- td class => 'tcval'; lit $ser1; end;
- td class => 'tcval'; lit $ser2; end;
- end;
-}
-
-
-# Generates a generic message to show as the header of the edit forms
-# Arguments: v/r/p, obj, title, copy
-sub htmlEditMessage {
- shift; VNWeb::HTML::editmsg_(@_);
-}
-
-
-# Generates a small message when the user can't edit the item,
-# or the item is locked.
-# Arguments: v/r/p/c, obj
-sub htmlItemMessage {
- my($self, $type, $obj) = @_;
- # $type isn't being used at all... oh well.
-
- if($obj->{locked}) {
- p class => 'locked', 'Locked for editing';
- } elsif($self->authInfo->{id} && !$self->authCan('edit')) {
- p class => 'locked', 'You are not allowed to edit this page';
- }
-}
-
-
sub htmlSearchBox {
shift; VNWeb::HTML::searchbox_(@_);
}
diff --git a/lib/VNDB/Util/Misc.pm b/lib/VNDB/Util/Misc.pm
index c08fe1bb..0423e35b 100644
--- a/lib/VNDB/Util/Misc.pm
+++ b/lib/VNDB/Util/Misc.pm
@@ -40,6 +40,8 @@ sub filFetchDB {
my $filters = fil_parse $overwrite // $pref, @{$filfields{$type}};
+ VNWeb::Filters::debug_validate($type, $filters);
+
# compatibility
my $compat = $self->filCompat($type, $filters);
$self->authPref($prefname => fil_serialize $filters) if $compat && !defined $overwrite;
@@ -84,22 +86,7 @@ sub filFetchDB {
# Compatibility with old filters. Modifies the filter in-place and returns the number of changes made.
sub filCompat {
my($self, $type, $fil) = @_;
- my $mod = 0;
-
- # older tag specification (by name rather than ID)
- if($type eq 'vn' && ($fil->{taginc} || $fil->{tagexc})) {
- my $tagfind = sub {
- return map {
- my $i = $self->dbTagGet(name => $_)->[0];
- $i && $i->{searchable} ? $i->{id} : ();
- } grep $_, ref $_[0] ? @{$_[0]} : ($_[0]||'')
- };
- $fil->{tag_inc} //= [ $tagfind->(delete $fil->{taginc}) ] if $fil->{taginc};
- $fil->{tag_exc} //= [ $tagfind->(delete $fil->{tagexc}) ] if $fil->{tagexc};
- $mod++;
- }
-
- $mod;
+ $type eq 'vn' ? VNWeb::Filters::filter_vn_compat($fil) : 0
}
diff --git a/lib/VNWeb/Auth.pm b/lib/VNWeb/Auth.pm
index d93faa33..907fb2f4 100644
--- a/lib/VNWeb/Auth.pm
+++ b/lib/VNWeb/Auth.pm
@@ -62,10 +62,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 }
-my @perms = qw/board boardmod edit imgvote imgmod tag dbmod tagmod usermod/;
+my @perms = qw/board boardmod edit imgvote imgmod tag dbmod tagmod usermod review/;
sub listPerms { @perms }
@@ -260,7 +261,8 @@ sub csrfcheck {
# TODO: Measure global usage of the pref() and prefSet() calls to see if this cache is actually necessary.
my @pref_columns = qw/
- email_confirmed skin customcss filter_vn filter_release show_nsfw notify_dbedit notify_announce
+ email_confirmed skin customcss filter_vn filter_release
+ notify_dbedit notify_announce notify_post notify_comment
vn_list_own vn_list_wish tags_all tags_cont tags_ero tags_tech spoilers traits_sexual
max_sexual max_violence nodistract_can nodistract_noads nodistract_nofancy
/;
diff --git a/lib/VNWeb/Chars/Edit.pm b/lib/VNWeb/Chars/Edit.pm
index f5ccca38..392f8f35 100644
--- a/lib/VNWeb/Chars/Edit.pm
+++ b/lib/VNWeb/Chars/Edit.pm
@@ -1,6 +1,8 @@
package VNWeb::Chars::Edit;
use VNWeb::Prelude;
+use VNWeb::Images::Lib 'enrich_image';
+use VNWeb::Releases::Lib;
my $FORM = {
@@ -25,9 +27,8 @@ my $FORM = {
main_spoil => { uint => 1, range => [0,2] },
main_ref => { _when => 'out', anybool => 1 },
main_name => { _when => 'out', default => '' },
- image => { required => 0, regex => qr/ch[1-9][0-9]{0,6}/ },
- image_sex => { _when => 'in out', required => 0, uint => 1, range => [0,2] },
- image_vio => { _when => 'in out', required => 0, uint => 1, range => [0,2] },
+ image => { required => 0, vndbid => 'ch' },
+ image_info => { _when => 'out', required => 0, type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
traits => { sort_keys => 'id', aoh => {
tid => { id => 1 },
spoil => { uint => 1, range => [0,2] },
@@ -59,23 +60,6 @@ my $FORM_IN = form_compile in => $FORM;
my $FORM_CMP = form_compile cmp => $FORM;
-sub enrich_releases {
- my($e) = @_;
- my %vns;
- $e->{releases} = [ map !$vns{$_->{vid}}++ ? { id => $_->{vid} } : (), $e->{vns}->@* ];
-
- enrich rels => id => vid => sub { sql '
- SELECT rv.vid, r.id, r.title, r.original, r.released, r.type as rtype
- FROM releases r
- JOIN releases_vn rv ON rv.id = r.id
- WHERE NOT r.hidden AND rv.vid IN', $_, '
- ORDER BY r.released, r.title, r.id'
- }, $e->{releases};
- enrich_flatten lang => id => id => sub { sql('SELECT id, lang FROM releases_lang WHERE id IN', $_, 'ORDER BY lang') }, map $_->{rels}, $e->{releases}->@*;
- enrich_flatten platforms => id => id => sub { sql('SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY platform') }, map $_->{rels}, $e->{releases}->@*;
-}
-
-
TUWF::get qr{/$RE{crev}/(?<action>edit|copy)} => sub {
my $e = db_entry c => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
my $copy = tuwf->capture('action') eq 'copy';
@@ -89,9 +73,17 @@ TUWF::get qr{/$RE{crev}/(?<action>edit|copy)} => sub {
enrich_merge vid => 'SELECT id AS vid, title FROM vn WHERE id IN', $e->{vns};
$e->{vns} = [ sort { $a->{title} cmp $b->{title} || $a->{vid} <=> $b->{vid} || ($a->{rid}||0) <=> ($b->{rid}||0) } $e->{vns}->@* ];
- enrich_releases $e;
- $e->{image_sex} = $e->{image_vio} = undef;
+ my %vns;
+ $e->{releases} = [ map !$vns{$_->{vid}}++ ? { id => $_->{vid}, rels => releases_by_vn $_->{vid} } : (), $e->{vns}->@* ];
+
+ if($e->{image}) {
+ $e->{image_info} = { id => $e->{image} };
+ enrich_image 0, [$e->{image_info}];
+ } else {
+ $e->{image_info} = undef;
+ }
+
$e->{authmod} = auth->permDbmod;
$e->{editsum} = $copy ? "Copied from c$e->{id}.$e->{chrev}" : $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision c$e->{id}.$e->{chrev}";
@@ -111,7 +103,7 @@ TUWF::get qr{/$RE{vid}/addchar}, sub {
my $e = elm_empty($FORM_OUT);
$e->{vns} = [{ vid => $v->{id}, title => $v->{title}, rid => undef, spoil => 0, role => 'primary' }];
- enrich_releases $e;
+ $e->{releases} = [{ id => $v->{id}, rels => releases_by_vn $v->{id} }];
framework_ title => 'Add character',
sub {
@@ -141,6 +133,8 @@ elm_api CharEdit => $FORM_OUT, $FORM_IN, sub {
$data->{main} = undef if $data->{main} && !tuwf->dbVali('SELECT 1 FROM chars WHERE NOT hidden AND main IS NULL AND id =', \$data->{main});
$data->{main_spoil} = 0 if !$data->{main};
+ validate_dbid 'SELECT id FROM images WHERE id IN', $data->{image} if $data->{image};
+
# Allow non-applicable 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'),
@@ -154,11 +148,6 @@ elm_api CharEdit => $FORM_OUT, $FORM_IN, sub {
die "Bad release for v$_->{vid}: r$_->{rid}\n" if defined $_->{rid} && !tuwf->dbVali('SELECT 1 FROM releases_vn WHERE id =', \$_->{rid}, 'AND vid =', \$_->{vid});
}
- tuwf->dbExeci(
- 'INSERT INTO image_votes', { id => $data->{image}, uid => auth->uid, sexual => $data->{image_sex}, violence => $data->{image_vio} },
- ' ON CONFLICT (id, uid) DO NOTHING'
- ) if $data->{image} && defined $data->{image_sex} && defined $data->{image_vio} && tuwf->dbVali('SELECT 1 FROM images WHERE c_votecount = 0 AND id =', \$data->{image});
-
return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
my($id,undef,$rev) = db_edit c => $e->{id}, $data;
elm_Redirect "/c$id.$rev";
diff --git a/lib/VNWeb/Chars/Elm.pm b/lib/VNWeb/Chars/Elm.pm
index c995dc61..ce14f490 100644
--- a/lib/VNWeb/Chars/Elm.pm
+++ b/lib/VNWeb/Chars/Elm.pm
@@ -4,15 +4,15 @@ use VNWeb::Prelude;
elm_api Chars => undef, { search => {} }, sub {
my $q = shift->{search};
- my $qs = $q =~ s/[%_]//gr;
+ 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 original ILIKE', \"%$qs%"),
+ 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
diff --git a/lib/VNWeb/Chars/Page.pm b/lib/VNWeb/Chars/Page.pm
index ebb6224b..8a9966ae 100644
--- a/lib/VNWeb/Chars/Page.pm
+++ b/lib/VNWeb/Chars/Page.pm
@@ -1,6 +1,7 @@
package VNWeb::Chars::Page;
use VNWeb::Prelude;
+use VNWeb::Images::Lib qw/image_ enrich_image_obj/;
sub enrich_seiyuu {
@@ -15,15 +16,10 @@ sub enrich_seiyuu {
}
-sub enrich_image {
- enrich_obj image => 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', @_;
-}
-
-
sub enrich_item {
my($c) = @_;
- enrich_image $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 =>
@@ -36,7 +32,7 @@ sub enrich_item {
# Fetch multiple character entries with a format suitable for chartable_()
-# Also used by VN::Page
+# Also used by Chars::VNTab.
sub fetch_chars {
my($vid, $where) = @_;
my $l = tuwf->dbAlli('
@@ -64,55 +60,11 @@ sub fetch_chars {
}, $l;
enrich_seiyuu $vid, $l;
- enrich_image $l;
+ enrich_image_obj image => $l;
$l
}
-# This and enrich_image() can prolly be used for VN cover images as well.
-sub image_ {
- my($c) = @_;
- my $img = $c->{image};
- return p_ 'No image' if !$img;
-
- # XXX: no clue why I chose these thresholds.
- my $sex = $img->{sexual_avg} > 1.3 ? 2 : $img->{sexual_avg} > 0.4 ? 1 : 0 if $img->{votecount};
- my $vio = $img->{violence_avg} > 1.3 ? 2 : $img->{violence_avg} > 0.4 ? 1 : 0 if $img->{votecount};
- my $sexd = ['Safe', 'Suggestive', 'Explicit']->[$sex] if $img->{votecount};
- my $viod = ['Tame', 'Violent', 'Brutal' ]->[$vio] if $img->{votecount};
- 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 = $sexp < 0 || $sex || $vio || !$img->{votecount};
-
- label_ class => 'imghover', style => "width: $img->{width}px; height: $img->{height}px", sub {
- input_ type => 'checkbox', class => 'visuallyhidden', $hidden ? () : (checked => 'checked') if $hide_on_click;
- div_ class => 'imghover--visible', sub {
- img_ src => tuwf->imgurl($img->{id}), alt => $c->{name};
- a_ href => "/img/$img->{id}?view=".viewset(show_nsfw=>1),
- $img->{votecount} ? sprintf '%s / %s (%d)', $sexd, $viod, $img->{votecount} : 'Not flagged';
- };
- div_ class => 'imghover--warning', sub {
- if($img->{votecount}) {
- txt_ 'This image has been flagged as:';
- br_; br_;
- txt_ 'Sexual: '; $sexh ? b_ class => 'standout', $sexd : txt_ $sexd;
- br_;
- txt_ 'Violence '; $vioh ? b_ class => 'standout', $viod : txt_ $viod;
- } else {
- txt_ 'This image has not yet been flagged';
- }
- br_; br_;
- span_ class => 'fake_link', 'Show me anyway';
- br_; br_;
- b_ class => 'grayedout', 'This warning can be disabled in your account';
- } if $hide_on_click;
- }
-}
-
-
sub _rev_ {
my($c) = @_;
revision_ c => $c, \&enrich_item,
@@ -131,13 +83,13 @@ sub _rev_ {
[ weight => 'Weight', ],
[ bloodt => 'Blood type', fmt => \%BLOOD_TYPE ],
[ cup_size => 'Cup size', fmt => \%CUP_SIZE ],
- [ age => 'Age', empty => 0 ],
+ [ age => 'Age', ],
[ main => 'Instance of', empty => 0, fmt => sub {
my $c = tuwf->dbRowi('SELECT id, name, original FROM chars WHERE id =', \$_);
a_ href => "/c$c->{id}", title => $c->{name}, "c$c->{id}"
} ],
[ main_spoil => 'Spoiler', fmt => sub { txt_ fmtspoil $_ } ],
- [ image => 'Image', empty => 0, fmt => \&image_ ],
+ [ image => 'Image', fmt => sub { image_ $_ } ],
[ vns => 'Visual novels', fmt => sub {
a_ href => "/v$_->{vid}", title => $_->{original}||$_->{title}, "v$_->{vid}";
if($_->{rid}) {
@@ -153,13 +105,13 @@ sub _rev_ {
}
-# Also used by VN::Page
+# Also used by Chars::VNTab
sub chartable_ {
my($c, $link, $sep, $vn) = @_;
my $view = viewget;
div_ mkclass(chardetails => 1, charsep => $sep), sub {
- div_ class => 'charimg', sub { image_ $c };
+ div_ class => 'charimg', sub { image_ $c->{image}, alt => $c->{name} };
table_ class => 'stripe', sub {
thead_ sub { tr_ sub { td_ colspan => 2, sub {
$link
@@ -261,7 +213,7 @@ sub chartable_ {
tr_ class => 'nostripe', sub {
td_ colspan => 2, class => 'chardesc', sub {
h2_ 'Description';
- p_ sub { lit_ bb2html $c->{desc}, 0, $view->{spoilers} == 2 ? 3 : 2 };
+ p_ sub { lit_ bb_format $c->{desc}, replacespoil => $view->{spoilers} != 2, keepspoil => $view->{spoilers} == 2 };
};
} if $c->{desc};
};
@@ -299,14 +251,16 @@ TUWF::get qr{/$RE{crev}} => sub {
framework_ title => $c->{name}, index => !tuwf->capture('rev'), type => 'c', dbobj => $c, hiddenmsg => 1,
og => {
- description => bb2text($c->{desc}),
- image => $c->{image} && $c->{image}{votecount} && $c->{image}{sexual_avg} < 0.4 && $c->{image}{violence_avg} < 0.4 ? tuwf->imgurl($c->{image}{id}) : undef,
+ description => bb_format($c->{desc}, text => 1),
+ image => $c->{image} && $c->{image}{votecount} && !$c->{image}{sexual} && !$c->{image}{violence} ? tuwf->imgurl($c->{image}{id}) : undef,
},
sub {
_rev_ $c if tuwf->capture('rev');
div_ class => 'mainbox', sub {
itemmsg_ c => $c;
- p_ class => 'mainopts', sub {
+ h1_ sub { txt_ $c->{name}; debug_ $c };
+ h2_ class => 'alttitle', $c->{original} if length $c->{original};
+ 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';
@@ -315,8 +269,6 @@ TUWF::get qr{/$RE{crev}} => sub {
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}), 'Show sexual traits' if $has_sex;
};
- h1_ sub { txt_ $c->{name}; debug_ $c };
- h2_ class => 'alttitle', $c->{original} if length $c->{original};
chartable_ $c;
};
diff --git a/lib/VNWeb/Chars/VNTab.pm b/lib/VNWeb/Chars/VNTab.pm
new file mode 100644
index 00000000..fbcc9550
--- /dev/null
+++ b/lib/VNWeb/Chars/VNTab.pm
@@ -0,0 +1,60 @@
+package VNWeb::Chars::VNTab;
+
+use VNWeb::Prelude;
+
+sub chars_ {
+ my($v) = @_;
+ my $view = viewget;
+ my $chars = VNWeb::Chars::Page::fetch_chars($v->{id}, sql('id IN(SELECT id FROM chars_vns WHERE vid =', \$v->{id}, ')'));
+ return if !@$chars;
+
+ my $max_spoil = max(
+ map max(
+ (map $_->{spoil}, $_->{traits}->@*),
+ (map $_->{spoil}, $_->{vns}->@*),
+ defined $_->{spoil_gender} ? 2 : 0,
+ $_->{desc} =~ /\[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 %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++;
+
+ h1_ $CHAR_ROLE{$r}{ @c > 1 ? 'plural' : 'txt' };
+ VNWeb::Chars::Page::chartable_($_, 1, $_ != $c[0], 1) for @c;
+ }
+ }
+}
+
+
+TUWF::get qr{/$RE{vid}/chars}, sub {
+ my $v = db_entry v => tuwf->capture('id');
+ return tuwf->resNotFound if !$v;
+
+ VNWeb::VN::Page::enrich_vn($v);
+
+ framework_ title => $v->{title}, index => 1, type => 'v', dbobj => $v, hiddenmsg => 1,
+ sub {
+ VNWeb::VN::Page::infobox_($v);
+ VNWeb::VN::Page::tabs_($v, 'chars');
+ chars_ $v;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/DB.pm b/lib/VNWeb/DB.pm
index b597a21c..d3128b1c 100644
--- a/lib/VNWeb/DB.pm
+++ b/lib/VNWeb/DB.pm
@@ -10,7 +10,7 @@ use VNDB::Schema;
our @EXPORT = qw/
sql
- sql_identifier sql_join sql_comma sql_and sql_or sql_array sql_func sql_fromhex sql_tohex sql_fromtime sql_totime sql_user
+ 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
enrich enrich_merge enrich_flatten enrich_obj
db_entry db_edit
/;
@@ -95,6 +95,11 @@ sub sql_totime($) {
sql "extract('epoch' from ", $_[0], ')';
}
+# Escape a string to be used as a literal match in a LIKE pattern.
+sub sql_like($) {
+ $_[0] =~ s/([%_\\])/\\$1/rg
+}
+
# Returns a list of column names to fetch for displaying a username with HTML::user_().
# Arguments: Name of the 'users' table (default: 'u'), prefix for the fetched fields (default: 'user_').
# (This function returns a plain string so that old non-SQL-Interp functions can also use it)
@@ -331,7 +336,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}}, $_),
- grep exists $data->{$_->{name}}, $t->{base}{cols}->@*
+ grep $_->{name} ne 'chid' && exists $data->{$_->{name}}, $t->{base}{cols}->@*
));
}
diff --git a/lib/VNWeb/Discussions/Board.pm b/lib/VNWeb/Discussions/Board.pm
index edce6789..6262deaf 100644
--- a/lib/VNWeb/Discussions/Board.pm
+++ b/lib/VNWeb/Discussions/Board.pm
@@ -9,13 +9,10 @@ TUWF::get qr{/t/(all|$BOARD_RE)}, sub {
my $page = tuwf->validate(get => p => { upage => 1 })->data;
- my $obj = !$id ? undef :
- $type eq 'v' ? tuwf->dbRowi('SELECT id, title, original, hidden AS entry_hidden, locked AS entry_locked FROM vn WHERE id =', \$id) :
- $type eq 'p' ? tuwf->dbRowi('SELECT id, name, original, hidden AS entry_hidden, locked AS entry_locked FROM producers WHERE id =', \$id) :
- $type eq 'u' ? tuwf->dbRowi('SELECT id,', sql_user(), 'FROM users u WHERE id =', \$id) : undef;
+ my $obj = $id ? dbobj $type, $id : undef;
return tuwf->resNotFound if $id && !$obj->{id};
- my $ititle = $obj && ($obj->{title} || $obj->{name} || user_displayname $obj);
+ my $ititle = $obj && ($obj->{title} || user_displayname $obj);
my $title = $obj ? "Related discussions for $ititle" : $type eq 'all' ? 'All boards' : $BOARD_TYPE{$type}{txt};
my $createurl = '/t/'.($id ? $type.$id : $type eq 'db' ? 'db' : 'ge').'/new';
diff --git a/lib/VNWeb/Discussions/Edit.pm b/lib/VNWeb/Discussions/Edit.pm
index f8108c8a..dddc1ac8 100644
--- a/lib/VNWeb/Discussions/Edit.pm
+++ b/lib/VNWeb/Discussions/Edit.pm
@@ -5,10 +5,8 @@ use VNWeb::Discussions::Lib;
my $FORM = {
- tid => { required => 0, id => 1 }, # Thread ID, only when editing a post
- num => { required => 0, id => 1 }, # Post number, only when editing
+ tid => { required => 0, vndbid => 't' }, # Thread ID, only when editing a post
- # Only when num = 1 || tid = undef
title => { required => 0, maxlength => 50 },
boards => { required => 0, sort_keys => [ 'boardtype', 'iid' ], aoh => {
btype => { enum => \%BOARD_TYPE },
@@ -23,9 +21,9 @@ my $FORM = {
can_mod => { anybool => 1, _when => 'out' },
can_private => { anybool => 1, _when => 'out' },
- locked => { anybool => 1 }, # When can_mod && (num = 1 || tid = undef)
+ locked => { anybool => 1 }, # When can_mod
hidden => { anybool => 1 }, # When can_mod
- private => { anybool => 1 }, # When can_private && (num = 1 || tid = undef)
+ private => { anybool => 1 }, # When can_private
nolastmod => { anybool => 1, _when => 'in' }, # When can_mod
delete => { anybool => 1 }, # When can_mod
@@ -39,75 +37,57 @@ my $FORM_IN = form_compile in => $FORM;
elm_api DiscussionsEdit => $FORM_OUT, $FORM_IN, sub {
my($data) = @_;
my $tid = $data->{tid};
- my $num = $data->{num} || 1;
my $t = !$tid ? {} : tuwf->dbRowi('
- SELECT t.id, tp.num, t.poll_question, t.poll_max_options, tp.hidden, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
+ SELECT t.id, t.poll_question, t.poll_max_options, t.hidden, tp.num, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
FROM threads t
- JOIN threads_posts tp ON tp.tid = t.id AND tp.num =', \$num,
- 'WHERE t.id =', \$tid,
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num = 1
+ WHERE t.id =', \$tid,
'AND', sql_visible_threads());
return tuwf->resNotFound if $tid && !$t->{id};
return elm_Unauth if !can_edit t => $t;
- if($data->{delete} && auth->permBoardmod) {
- auth->audit($t->{user_id}, 'post delete', "deleted t$tid.$num");
- if($num == 1) {
- # (This could be a single query if there were proper ON DELETE CASCADE in the DB, though that's hard for notifications...)
- tuwf->dbExeci('DELETE FROM threads_posts WHERE tid =', \$tid);
- tuwf->dbExeci('DELETE FROM threads_boards WHERE tid =', \$tid);
- tuwf->dbExeci('DELETE FROM threads WHERE id =', \$tid);
- tuwf->dbExeci(q{DELETE FROM notifications WHERE ltype = 't' AND iid =}, \$tid);
- return elm_Redirect '/t';
- } else {
- tuwf->dbExeci('DELETE FROM threads_posts WHERE tid =', \$tid, 'AND num =', \$num);
- tuwf->dbExeci('UPDATE threads_posts SET num = num - 1 WHERE tid =', \$tid, 'AND num >', \$num);
- tuwf->dbExeci('UPDATE threads SET count = count - 1 WHERE id =', \$tid);
- tuwf->dbExeci(q{DELETE FROM notifications WHERE ltype = 't' AND iid =}, \$tid, 'AND subid =', \$num);
- tuwf->dbExeci(q{UPDATE notifications SET subid = subid - 1 WHERE ltype = 't' AND iid =}, \$tid, 'AND subid >', \$num);
- return elm_Redirect "/t$tid";
- }
- }
- auth->audit($t->{user_id}, 'post edit', "edited t$tid.$num") if $tid && $t->{user_id} != auth->uid;
-
- my $pollchanged = !$data->{tid} && $data->{poll};
- if($num == 1) {
- die "Invalid title" if !length $data->{title};
- die "Invalid boards" if !$data->{boards} || grep +(!$BOARD_TYPE{$_->{btype}}{dbitem})^(!$_->{iid}), $data->{boards}->@*;
-
- validate_dbid 'SELECT id FROM vn WHERE id IN', map $_->{btype} eq 'v' ? $_->{iid} : (), $data->{boards}->@*;
- validate_dbid 'SELECT id FROM producers WHERE id IN', map $_->{btype} eq 'p' ? $_->{iid} : (), $data->{boards}->@*;
- # Do not validate user boards here, it's possible to have threads assigned to deleted users.
-
- die "Invalid max_options" if $data->{poll} && $data->{poll}{max_options} > $data->{poll}{options}->@*;
- $pollchanged = 1 if $tid && $data->{poll} && (
- $data->{poll}{question} ne ($t->{poll_question}||'')
- || $data->{poll}{max_options} != $t->{poll_max_options}
- || join("\n", $data->{poll}{options}->@*) ne
- join("\n", map $_->{option}, tuwf->dbAlli('SELECT option FROM threads_poll_options WHERE tid =', \$tid, 'ORDER BY id')->@*)
- )
+ if($tid && $data->{delete} && auth->permBoardmod) {
+ auth->audit($t->{user_id}, 'post delete', "deleted $tid.1");
+ tuwf->dbExeci('DELETE FROM threads WHERE id =', \$tid);
+ tuwf->dbExeci(q{DELETE FROM notifications WHERE iid =}, \$tid);
+ return elm_Redirect '/t';
}
+ auth->audit($t->{user_id}, 'post edit', "edited $tid.1") if $tid && $t->{user_id} != auth->uid;
+
+
+ die "Invalid title" if !length $data->{title};
+ die "Invalid boards" if !$data->{boards} || grep +(!$BOARD_TYPE{$_->{btype}}{dbitem})^(!$_->{iid}), $data->{boards}->@*;
+
+ validate_dbid 'SELECT id FROM vn WHERE id IN', map $_->{btype} eq 'v' ? $_->{iid} : (), $data->{boards}->@*;
+ validate_dbid 'SELECT id FROM producers WHERE id IN', map $_->{btype} eq 'p' ? $_->{iid} : (), $data->{boards}->@*;
+ # Do not validate user boards here, it's possible to have threads assigned to deleted users.
+
+ die "Invalid max_options" if $data->{poll} && $data->{poll}{max_options} > $data->{poll}{options}->@*;
+ my $pollchanged = (!$tid && $data->{poll}) || ($tid && $data->{poll} && (
+ $data->{poll}{question} ne ($t->{poll_question}||'')
+ || $data->{poll}{max_options} != $t->{poll_max_options}
+ || join("\n", $data->{poll}{options}->@*) ne
+ join("\n", map $_->{option}, tuwf->dbAlli('SELECT option FROM threads_poll_options WHERE tid =', \$tid, 'ORDER BY id')->@*)
+ ));
my $thread = {
title => $data->{title},
poll_question => $data->{poll} ? $data->{poll}{question} : undef,
poll_max_options => $data->{poll} ? $data->{poll}{max_options} : 1,
- $tid ? () : (count => 1),
auth->permBoardmod ? (
hidden => $data->{hidden},
locked => $data->{locked},
) : (),
- auth->permBoardmod || auth->permDbmod || auth->permUsermod ? (
+ auth->isMod ? (
private => $data->{private}
) : (),
};
- tuwf->dbExeci('UPDATE threads SET', $thread, 'WHERE id =', \$tid) if $tid && $num == 1;
+ tuwf->dbExeci('UPDATE threads SET', $thread, 'WHERE id =', \$tid) if $tid;
$tid = tuwf->dbVali('INSERT INTO threads', $thread, 'RETURNING id') if !$tid;
- if($num == 1) {
- tuwf->dbExeci('DELETE FROM threads_boards WHERE tid =', \$tid);
- tuwf->dbExeci('INSERT INTO threads_boards', { tid => $tid, type => $_->{btype}, iid => $_->{iid}//0 }) for $data->{boards}->@*;
- }
+ tuwf->dbExeci('DELETE FROM threads_boards WHERE tid =', \$tid);
+ tuwf->dbExeci('INSERT INTO threads_boards', { tid => $tid, type => $_->{btype}, iid => $_->{iid}//0 }) for $data->{boards}->@*;
if($pollchanged) {
tuwf->dbExeci('DELETE FROM threads_poll_options WHERE tid =', \$tid);
@@ -116,30 +96,29 @@ elm_api DiscussionsEdit => $FORM_OUT, $FORM_IN, sub {
my $post = {
tid => $tid,
- num => $num,
+ num => 1,
msg => bb_subst_links($data->{msg}),
$data->{tid} ? () : (uid => auth->uid),
- auth->permBoardmod && $num != 1 ? (hidden => $data->{hidden}) : (),
!$data->{tid} || (auth->permBoardmod && $data->{nolastmod}) ? () : (edited => sql 'NOW()')
};
tuwf->dbExeci('INSERT INTO threads_posts', $post) if !$data->{tid};
- tuwf->dbExeci('UPDATE threads_posts SET', $post, 'WHERE', { tid => $tid, num => $num }) if $data->{tid};
+ tuwf->dbExeci('UPDATE threads_posts SET', $post, 'WHERE', { tid => $tid, num => 1 }) if $data->{tid};
- elm_Redirect post_url $tid, $num, $num;
+ elm_Redirect "/$tid.1";
};
-TUWF::get qr{(?:/t/(?<board>$BOARD_RE)/new|/$RE{postid}/edit)}, sub {
+TUWF::get qr{(?:/t/(?<board>$BOARD_RE)/new|/$RE{tid}\.1/edit)}, sub {
my($board_type, $board_id) = (tuwf->capture('board')||'') =~ /^([^0-9]+)([0-9]*)$/;
- my($tid, $num) = (tuwf->capture('id'), tuwf->capture('num'));
+ my $tid = tuwf->capture('id');
$board_type = 'ge' if $board_type && $board_type eq 'an' && !auth->permBoardmod;
my $t = !$tid ? {} : tuwf->dbRowi('
- SELECT t.id, tp.tid, tp.num, t.title, t.locked, t.private, t.hidden AS thread_hidden, t.poll_question, t.poll_max_options, tp.hidden, tp.msg, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
+ SELECT t.id, tp.tid, t.title, t.locked, t.private, t.hidden, t.poll_question, t.poll_max_options, tp.msg, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
FROM threads t
- JOIN threads_posts tp ON tp.tid = t.id AND tp.num =', \$num,
- 'WHERE t.id =', \$tid,
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num = 1
+ WHERE t.id =', \$tid,
'AND', sql_visible_threads());
return tuwf->resNotFound if $tid && !$t->{id};
return tuwf->resDenied if !can_edit t => $t;
@@ -164,18 +143,17 @@ TUWF::get qr{(?:/t/(?<board>$BOARD_RE)/new|/$RE{postid}/edit)}, sub {
}
$t->{can_mod} = auth->permBoardmod;
- $t->{can_private} = auth->permBoardmod || auth->permDbmod || auth->permUsermod;
+ $t->{can_private} = auth->isMod;
- $t->{hidden} = $tid && $num == 1 ? $t->{thread_hidden}//0 : $t->{hidden}//0;
+ $t->{hidden} //= 0;
$t->{msg} //= '';
$t->{title} //= tuwf->reqGet('title');
$t->{tid} //= undef;
- $t->{num} //= undef;
- $t->{private} //= 0;
+ $t->{private} //= auth->isMod && tuwf->reqGet('priv') ? 1 : 0;
$t->{locked} //= 0;
$t->{delete} = 0;
- framework_ title => $tid ? 'Edit post' : 'Create new thread', sub {
+ framework_ title => $tid ? 'Edit thread' : 'Create new thread', sub {
elm_ 'Discussions.Edit' => $FORM_OUT, $t;
};
};
diff --git a/lib/VNWeb/Discussions/Elm.pm b/lib/VNWeb/Discussions/Elm.pm
index 77944926..81fe7a9b 100644
--- a/lib/VNWeb/Discussions/Elm.pm
+++ b/lib/VNWeb/Discussions/Elm.pm
@@ -9,7 +9,7 @@ elm_api Boards => undef, {
}, sub {
return elm_Unauth if !auth->permBoard;
my $q = shift->{search};
- my $qs = $q =~ s/[%_]//gr;
+ my $qs = sql_like $q;
my sub subq {
my($prio, $where) = @_;
diff --git a/lib/VNWeb/Discussions/Lib.pm b/lib/VNWeb/Discussions/Lib.pm
index 9f77397e..986ce90a 100644
--- a/lib/VNWeb/Discussions/Lib.pm
+++ b/lib/VNWeb/Discussions/Lib.pm
@@ -3,19 +3,12 @@ package VNWeb::Discussions::Lib;
use VNWeb::Prelude;
use Exporter 'import';
-our @EXPORT = qw/$BOARD_RE post_url sql_visible_threads sql_boards enrich_boards threadlist_ boardsearch_ boardtypes_/;
+our @EXPORT = qw/$BOARD_RE sql_visible_threads sql_boards enrich_boards threadlist_ boardsearch_ boardtypes_/;
our $BOARD_RE = join '|', map $_.($BOARD_TYPE{$_}{dbitem}?'(?:[1-9][0-9]{0,5})?':''), keys %BOARD_TYPE;
-# Returns the URL to the thread page holding the given post (with optional location.hash)
-sub post_url {
- my($id, $num, $hash) = @_;
- "/t$id".($num > 25 ? '/'.ceil($num/25) : '').($hash ? "#$hash" : '');
-}
-
-
# Returns a WHERE condition to filter threads that the current user is allowed to see.
sub sql_visible_threads {
return '1=1' if auth && auth->uid == 2; # Yorhel sees everything
@@ -65,14 +58,14 @@ sub threadlist_ {
return 0 if $opt{paginate} && !$count;
my $lst = tuwf->dbPagei(\%opt, q{
- SELECT t.id, t.title, t.count, t.locked, t.private, t.hidden, t.poll_question IS NOT NULL AS haspoll
+ SELECT t.id, t.title, t.c_count, t.c_lastnum, t.locked, t.private, t.hidden, t.poll_question IS NOT NULL AS haspoll
, }, sql_user('tfu', 'firstpost_'), ',', sql_totime('tf.date'), q{ as firstpost_date
, }, sql_user('tlu', 'lastpost_'), ',', sql_totime('tl.date'), q{ as lastpost_date
FROM threads t
JOIN threads_posts tf ON tf.tid = t.id AND tf.num = 1
- JOIN threads_posts tl ON tl.tid = t.id AND tl.num = t.count
- JOIN users tfu ON tfu.id = tf.uid
- JOIN users tlu ON tlu.id = tl.uid
+ JOIN threads_posts tl ON tl.tid = t.id AND tl.num = t.c_lastnum
+ LEFT JOIN users tfu ON tfu.id = tf.uid
+ LEFT JOIN users tlu ON tlu.id = tl.uid
WHERE }, $where, q{
ORDER BY}, $opt{sort}||'tl.date DESC'
);
@@ -92,7 +85,7 @@ sub threadlist_ {
tr_ sub {
my $l = $_;
td_ class => 'tc1', sub {
- a_ mkclass(locked => $l->{locked}), href => "/t$l->{id}", sub {
+ a_ mkclass(locked => $l->{locked}), href => "/$l->{id}", sub {
span_ class => 'pollflag', '[poll]' if $l->{haspoll};
span_ class => 'pollflag', '[private]' if $l->{private};
span_ class => 'pollflag', '[hidden]' if $l->{hidden};
@@ -107,12 +100,12 @@ sub threadlist_ {
txt_ ', ...' if $l->{boards}->@* > 4;
};
};
- td_ class => 'tc2', $l->{count}-1;
+ td_ class => 'tc2', $l->{c_count}-1;
td_ class => 'tc3', sub { user_ $l, 'firstpost_' };
td_ class => 'tc4', sub {
user_ $l, 'lastpost_';
txt_ ' @ ';
- a_ href => post_url($l->{id}, $l->{count}, 'last'), fmtdate $l->{lastpost_date}, 'full';
+ a_ href => "/$l->{id}.$l->{c_lastnum}#last", fmtdate $l->{lastpost_date}, 'full';
};
} for @$lst;
}
diff --git a/lib/VNWeb/Discussions/PostEdit.pm b/lib/VNWeb/Discussions/PostEdit.pm
new file mode 100644
index 00000000..a645fb6f
--- /dev/null
+++ b/lib/VNWeb/Discussions/PostEdit.pm
@@ -0,0 +1,88 @@
+package VNWeb::Discussions::PostEdit;
+# Also used for editing review comments, which follow the exact same format.
+
+use VNWeb::Prelude;
+use VNWeb::Discussions::Lib;
+
+
+my $FORM = {
+ id => { vndbid => ['t','w'] },
+ num => { id => 1 },
+
+ can_mod => { anybool => 1, _when => 'out' },
+ hidden => { anybool => 1 }, # When can_mod
+ nolastmod => { anybool => 1, _when => 'in' }, # When can_mod
+ delete => { anybool => 1 }, # When can_mod
+
+ msg => { maxlength => 32768 },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+
+
+sub _info {
+ my($id,$num) = @_;
+ tuwf->dbRowi('
+ SELECT t.id, tp.num, tp.hidden, tp.msg, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date
+ FROM threads t
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num =', \$num, '
+ WHERE t.id =', \$id, 'AND', sql_visible_threads(),'
+ UNION ALL
+ SELECT id, num, hidden, msg, uid AS user_id,', sql_totime('date'), 'AS date
+ FROM reviews_posts WHERE id =', \$id, 'AND num =', \$num
+ );
+}
+
+
+elm_api DiscussionsPostEdit => $FORM_OUT, $FORM_IN, sub {
+ my($data) = @_;
+ my $id = $data->{id};
+ my $num = $data->{num};
+
+ my $t = _info $id, $num;
+ return tuwf->resNotFound if !$t->{id};
+ return elm_Unauth if !can_edit t => $t;
+
+ if($data->{delete} && auth->permBoardmod) {
+ auth->audit($t->{user_id}, 'post delete', "deleted $id.$num");
+ tuwf->dbExeci('DELETE FROM threads_posts WHERE tid =', \$id, 'AND num =', \$num);
+ tuwf->dbExeci('DELETE FROM reviews_posts WHERE id =', \$id, 'AND num =', \$num);
+ tuwf->dbExeci(q{DELETE FROM notifications WHERE iid =}, \$id, 'AND num =', \$num);
+ return elm_Redirect "/$id";
+ }
+ auth->audit($t->{user_id}, 'post edit', "edited $id.$num") if $t->{user_id} != auth->uid;
+
+ my $post = {
+ tid => $id,
+ num => $num,
+ msg => bb_subst_links($data->{msg}),
+ auth->permBoardmod ? (hidden => $data->{hidden}) : (),
+ (auth->permBoardmod && $data->{nolastmod}) ? () : (edited => sql 'NOW()')
+ };
+ tuwf->dbExeci('UPDATE threads_posts SET', $post, 'WHERE', { tid => $id, num => $num });
+ $post->{id} = delete $post->{tid};
+ tuwf->dbExeci('UPDATE reviews_posts SET', $post, 'WHERE', { id => $id, num => $num });
+
+ elm_Redirect "/$id.$num";
+};
+
+
+TUWF::get qr{/(?:$RE{tid}|$RE{wid})\.$RE{num}/edit}, sub {
+ my($id, $num) = (tuwf->capture('id'), tuwf->capture('num'));
+ tuwf->pass if $id =~ /^t/ && $num == 1; # t#.1 goes to Discussions::Edit.
+
+ my $t = _info $id, $num;
+ return tuwf->resNotFound if $id && !$t->{id};
+ return tuwf->resDenied if !can_edit t => $t;
+
+ $t->{can_mod} = auth->permBoardmod;
+ $t->{delete} = 0;
+
+ framework_ title => 'Edit post', sub {
+ elm_ 'Discussions.PostEdit' => $FORM_OUT, $t;
+ };
+};
+
+
+1;
diff --git a/lib/VNWeb/Discussions/Search.pm b/lib/VNWeb/Discussions/Search.pm
index 6b56b47b..3922e4e4 100644
--- a/lib/VNWeb/Discussions/Search.pm
+++ b/lib/VNWeb/Discussions/Search.pm
@@ -77,7 +77,7 @@ sub posts_ {
q{) as headline
FROM threads_posts tp
JOIN threads t ON t.id = tp.tid
- JOIN users u ON u.id = tp.uid
+ LEFT JOIN users u ON u.id = tp.uid
WHERE 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{
@@ -99,8 +99,8 @@ sub posts_ {
}};
tr_ sub {
my $l = $_;
- my $link = "/t$l->{tid}.$l->{num}";
- td_ class => 'tc1_1', sub { a_ href => $link, 't'.$l->{tid} };
+ 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} };
td_ class => 'tc2', fmtdate $l->{date};
td_ class => 'tc3', sub { user_ $l };
@@ -125,7 +125,7 @@ sub threads_ {
my $where = sql_and
$filt->{b}->@* < keys %BOARD_TYPE ? sql('t.id IN(SELECT tid FROM threads_boards WHERE type IN', $filt->{b}, ')') : (),
- map sql('t.title ilike', \('%'.($_ =~ s/%//gr).'%')), grep length($_) > 0, split /[ -,._]/, $filt->{bq};
+ map sql('t.title ilike', \('%'.sql_like($_).'%')), grep length($_) > 0, split /[ ,._-]/, $filt->{bq};
noresults_ if !threadlist_
where => $where,
diff --git a/lib/VNWeb/Discussions/Thread.pm b/lib/VNWeb/Discussions/Thread.pm
index ed357b5d..3fd67dbe 100644
--- a/lib/VNWeb/Discussions/Thread.pm
+++ b/lib/VNWeb/Discussions/Thread.pm
@@ -10,7 +10,7 @@ my $POLL_OUT = form_compile any => {
num_votes => { uint => 1 },
can_vote => { anybool => 1 },
preview => { anybool => 1 },
- tid => { id => 1 },
+ tid => { vndbid => 't' },
options => { aoh => {
id => { id => 1 },
option => {},
@@ -20,7 +20,7 @@ my $POLL_OUT = form_compile any => {
};
my $POLL_IN = form_compile any => {
- tid => { id => 1 },
+ tid => { vndbid => 't' },
options => { type => 'array', values => { id => 1 } },
};
@@ -32,10 +32,11 @@ elm_api DiscussionsPoll => $POLL_OUT, $POLL_IN, sub {
return tuwf->resNotFound if !$t->{poll_question};
die 'Too many options' if $data->{options}->@* > $t->{poll_max_options};
- validate_dbid sql('SELECT id FROM threads_poll_options WHERE tid =', \$data->{tid}, 'AND id IN'), $data->{options}->@*;
+ my %opt = map +($_->{id},1), tuwf->dbAlli('SELECT id FROM threads_poll_options WHERE tid =', \$data->{tid})->@*;
+ die 'Invalid option' if grep !$opt{$_}, $data->{options}->@*;
- tuwf->dbExeci('DELETE FROM threads_poll_votes WHERE tid =', \$data->{tid}, 'AND uid =', \auth->uid);
- tuwf->dbExeci('INSERT INTO threads_poll_votes', { tid => $data->{tid}, uid => auth->uid, optid => $_ }) for $data->{options}->@*;
+ tuwf->dbExeci('DELETE FROM threads_poll_votes WHERE optid IN', [ keys %opt ], 'AND uid =', \auth->uid);
+ tuwf->dbExeci('INSERT INTO threads_poll_votes', { uid => auth->uid, optid => $_ }) for $data->{options}->@*;
elm_Success
};
@@ -43,7 +44,7 @@ elm_api DiscussionsPoll => $POLL_OUT, $POLL_IN, sub {
my $REPLY = {
- tid => { id => 1 },
+ tid => { vndbid => 't' },
old => { _when => 'out', anybool => 1 },
msg => { _when => 'in', maxlength => 32768 }
};
@@ -53,15 +54,14 @@ my $REPLY_OUT = form_compile out => $REPLY;
elm_api DiscussionsReply => $REPLY_OUT, $REPLY_IN, sub {
my($data) = @_;
- my $t = tuwf->dbRowi('SELECT id, locked, count FROM threads t WHERE id =', \$data->{tid}, 'AND', sql_visible_threads());
+ my $t = tuwf->dbRowi('SELECT id, locked FROM threads t WHERE id =', \$data->{tid}, 'AND', sql_visible_threads());
return tuwf->resNotFound if !$t->{id};
return elm_Unauth if !can_edit t => $t;
- my $num = $t->{count}+1;
+ my $num = sql '(SELECT MAX(num)+1 FROM threads_posts WHERE tid =', \$data->{tid}, ')';
my $msg = bb_subst_links $data->{msg};
- tuwf->dbExeci('INSERT INTO threads_posts', { tid => $t->{id}, num => $num, uid => auth->uid, msg => $msg });
- tuwf->dbExeci('UPDATE threads SET count =', \$num, 'WHERE id =', \$t->{id});
- elm_Redirect post_url $t->{id}, $num, 'last';
+ $num = tuwf->dbVali('INSERT INTO threads_posts', { tid => $t->{id}, num => $num, uid => auth->uid, msg => $msg }, 'RETURNING num');
+ elm_Redirect "/$t->{id}.$num#last";
};
@@ -70,7 +70,7 @@ elm_api DiscussionsReply => $REPLY_OUT, $REPLY_IN, sub {
sub metabox_ {
my($t) = @_;
div_ class => 'mainbox', sub {
- h1_ $t->{title};
+ h1_ sub { lit_ bb_format $t->{title}, idonly => 1 };
h2_ 'Hidden' if $t->{hidden};
h2_ 'Private' if $t->{private};
h2_ 'Locked' if $t->{locked};
@@ -94,16 +94,17 @@ sub metabox_ {
}
+# Also used by Reviews::Page for review comments.
sub posts_ {
my($t, $posts, $page) = @_;
- my sub url { "/t$t->{id}".($_?"/$_":'') }
+ my sub url { "/$t->{id}".($_?"/$_":'') }
paginate_ \&url, $page, [ $t->{count}, 25 ], 't';
- div_ class => 'mainbox thread', sub {
+ div_ class => 'mainbox thread', id => 'threadstart', sub {
table_ class => 'stripe', sub {
tr_ mkclass(deleted => $_->{hidden}), id => $_->{num}, sub {
- td_ class => 'tc1', $t->{count} == $_->{num} ? (id => 'last') : (), sub {
- a_ href => "/t$t->{id}.$_->{num}", "#$_->{num}";
+ td_ class => 'tc1', $_ == $posts->[$#$posts] ? (id => 'last') : (), sub {
+ a_ href => "/$t->{id}.$_->{num}", "#$_->{num}";
if(!$_->{hidden} || auth->permBoard) {
txt_ ' by ';
user_ $_;
@@ -114,13 +115,17 @@ sub posts_ {
td_ class => 'tc2', sub {
i_ class => 'edit', sub {
txt_ '< ';
- a_ href => "/t$t->{id}.$_->{num}/edit", 'edit';
+ if(can_edit t => $_) {
+ a_ href => "/$t->{id}.$_->{num}/edit", 'edit';
+ txt_ ' - ';
+ }
+ a_ href => "/report/$t->{id}.$_->{num}", 'report';
txt_ ' >';
- } if can_edit t => $_;
+ };
if($_->{hidden}) {
i_ class => 'deleted', 'Post deleted.';
} else {
- lit_ bb2html $_->{msg};
+ lit_ bb_format $_->{msg};
i_ class => 'lastmod', 'Last modified on '.fmtdate($_->{edited}, 'full') if $_->{edited};
}
};
@@ -147,12 +152,13 @@ sub reply_ {
}
-TUWF::get qr{/$RE{tid}(?:/$RE{num})?}, sub {
- my($id, $page) = (tuwf->capture('id'), tuwf->capture('num')||1);
+TUWF::get qr{/$RE{tid}(?:(?<sep>[\./])$RE{num})?}, sub {
+ my($id, $sep, $num) = (tuwf->capture('id'), tuwf->capture('sep')||'', tuwf->capture('num'));
my $t = tuwf->dbRowi(
- 'SELECT id, title, count, hidden, locked, private
+ 'SELECT id, title, hidden, locked, private
, poll_question, poll_max_options
+ , (SELECT COUNT(*) FROM threads_posts WHERE tid = id) AS count
FROM threads t
WHERE', sql_visible_threads(), 'AND id =', \$id
);
@@ -160,17 +166,21 @@ TUWF::get qr{/$RE{tid}(?:/$RE{num})?}, sub {
enrich_boards '', $t;
+ my $page = $sep eq '/' ? $num||1 : $sep ne '.' ? 1
+ : ceil((tuwf->dbVali('SELECT COUNT(*) FROM threads_posts WHERE num <=', \$num, 'AND tid =', \$id)||9999)/25);
+ $num = 0 if $sep ne '.';
+
my $posts = tuwf->dbPagei({ results => 25, page => $page },
'SELECT tp.tid as id, tp.num, tp.hidden, tp.msg',
',', sql_user(),
',', sql_totime('tp.date'), ' as date',
',', sql_totime('tp.edited'), ' as edited
FROM threads_posts tp
- JOIN users u ON tp.uid = u.id
+ LEFT JOIN users u ON tp.uid = u.id
WHERE tp.tid =', \$id, '
ORDER BY tp.num'
);
- return tuwf->resNotFound if !@$posts;
+ return tuwf->resNotFound if !@$posts || ($num && !grep $_->{num} == $num, @$posts);
my $poll_options = $t->{poll_question} && tuwf->dbAlli(
'SELECT tpo.id, tpo.option, count(u.id) as votes, tpm.optid IS NOT NULL as my
@@ -184,15 +194,20 @@ TUWF::get qr{/$RE{tid}(?:/$RE{num})?}, sub {
# Mark a notification for this thread as read, if there is one.
tuwf->dbExeci(
- 'UPDATE notifications SET read = NOW() WHERE uid =', \auth->uid, 'AND ltype = \'t\' AND iid = ', \$id, 'AND read IS NULL'
+ 'UPDATE notifications SET read = NOW() WHERE uid =', \auth->uid, 'AND iid =', \$id, 'AND read IS NULL'
) if auth && $t->{count} <= $page*25;
- framework_ title => $t->{title}, sub {
+ framework_ title => $t->{title}, $num ? (js => 1, pagevars => {sethash=>$num}) : (), sub {
metabox_ $t;
elm_ 'Discussions.Poll' => $POLL_OUT, {
question => $t->{poll_question},
max_options => $t->{poll_max_options},
- num_votes => tuwf->dbVali('SELECT COUNT(DISTINCT tpv.uid) FROM threads_poll_votes tpv JOIN users u ON tpv.uid = u.id WHERE NOT u.ign_votes AND tid =', \$id),
+ num_votes => tuwf->dbVali(
+ 'SELECT COUNT(DISTINCT tpv.uid)
+ FROM threads_poll_votes tpv
+ JOIN threads_poll_options tpo ON tpo.id = tpv.optid
+ JOIN users u ON tpv.uid = u.id
+ WHERE NOT u.ign_votes AND tpo.tid =', \$id),
preview => !!tuwf->reqGet('pollview'), # Old non-Elm way to preview poll results
can_vote => !!auth,
tid => $id,
@@ -203,10 +218,4 @@ TUWF::get qr{/$RE{tid}(?:/$RE{num})?}, sub {
}
};
-
-TUWF::get qr{/$RE{postid}}, sub {
- my($id, $num) = (tuwf->capture('id'), tuwf->capture('num'));
- tuwf->resRedirect(post_url($id, $num, $num), 'perm')
-};
-
1;
diff --git a/lib/VNWeb/Discussions/UPosts.pm b/lib/VNWeb/Discussions/UPosts.pm
index 45be3f0b..d3bfa95c 100644
--- a/lib/VNWeb/Discussions/UPosts.pm
+++ b/lib/VNWeb/Discussions/UPosts.pm
@@ -18,13 +18,13 @@ sub listing_ {
td_ class => 'tc4', 'Title';
}};
tr_ sub {
- my $url = "/t$_->{tid}.$_->{num}";
- td_ class => 'tc1', sub { a_ href => $url, 't'.$_->{tid} };
+ my $url = "/$_->{id}.$_->{num}";
+ td_ class => 'tc1', sub { a_ href => $url, $_->{id} };
td_ class => 'tc2', sub { a_ href => $url, '.'.$_->{num} };
td_ class => 'tc3', fmtdate $_->{date};
td_ class => 'tc4', sub {
a_ href => $url, $_->{title};
- b_ class => 'grayedout', sub { lit_ bb2html $_->{msg}, 150 };
+ b_ class => 'grayedout', sub { lit_ bb_format $_->{msg}, maxlength => 150, inline => 1 };
};
} for @$list;
}
@@ -40,17 +40,23 @@ TUWF::get qr{/$RE{uid}/posts}, sub {
my $page = tuwf->validate(get => p => { upage => 1 })->data;
- my $from_and_where = sql
- 'FROM threads_posts tp
- JOIN threads t ON t.id = tp.tid
- WHERE NOT t.private AND NOT t.hidden AND NOT tp.hidden AND tp.uid =', \$u->{id};
+ my $sql = sql '(
+ SELECT tp.tid, tp.num, tp.msg, t.title, tp.date
+ 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}, '
+ UNION ALL
+ SELECT rp.id, rp.num, rp.msg, v.title, rp.date
+ 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)';
- my $count = tuwf->dbVali('SELECT count(*)', $from_and_where);
- my $list = $count && tuwf->dbPagei(
- { results => 50, page => $page },
- 'SELECT tp.tid, tp.num, substring(tp.msg from 1 for 1000) as msg, t.title
- , ', sql_totime('tp.date'), 'as date',
- $from_and_where, 'ORDER BY tp.date DESC'
+ my $count = tuwf->dbVali('SELECT count(*) FROM', $sql);
+ my $list = $count && tuwf->dbPagei({ results => 50, page => $page },
+ 'SELECT id, num, substring(msg from 1 for 1000) as msg, title, ', sql_totime('date'), 'as date
+ FROM ', $sql, 'ORDER BY date DESC'
);
my $own = auth && $u->{id} == auth->uid;
diff --git a/lib/VNWeb/Docs/Page.pm b/lib/VNWeb/Docs/Page.pm
index 54c10b68..eeda0d00 100644
--- a/lib/VNWeb/Docs/Page.pm
+++ b/lib/VNWeb/Docs/Page.pm
@@ -15,6 +15,7 @@ sub _index_ {
li_ sub { a_ href => '/d16', 'Staff' };
li_ sub { a_ href => '/d12', 'Characters' };
li_ sub { a_ href => '/d10', 'Tags & Traits' };
+ li_ sub { a_ href => '/d19', 'Image Flagging' };
li_ sub { a_ href => '/d13', 'Capturing Screenshots' };
li_ sub { b_ 'About VNDB' };
li_ sub { a_ href => '/d9', 'Discussion Board' };
@@ -45,6 +46,7 @@ TUWF::get qr{/$RE{drev}} => sub {
sub {
_rev_ $d if tuwf->capture('rev');
div_ class => 'mainbox', sub {
+ itemmsg_ d => $d;
h1_ $d->{title};
div_ class => 'docs', sub {
_index_;
diff --git a/lib/VNWeb/Elm.pm b/lib/VNWeb/Elm.pm
index b8598438..13165959 100644
--- a/lib/VNWeb/Elm.pm
+++ b/lib/VNWeb/Elm.pm
@@ -51,13 +51,14 @@ our %apis = (
BadCurPass => [], # Current password is incorrect when changing password
MailChange => [], # A confirmation mail has been sent to change a user's email address
ImgFormat => [], # Unrecognized image format
- Image => [ {}, { uint => 1 }, { uint => 1 } ], # Uploaded image id, width, height
Releases => [ { aoh => { # Response to 'Release'
id => { id => 1 },
title => {},
original => { required => 0, default => '' },
released => { uint => 1 },
rtype => {},
+ reso_x => { uint => 1 },
+ reso_y => { uint => 1 },
lang => { type => 'array', values => {} },
platforms=> { type => 'array', values => {} },
} } ],
@@ -87,11 +88,19 @@ our %apis = (
id => { id => 1 },
title => {},
original => { required => 0, default => '' },
+ hidden => { anybool => 1 },
} } ],
ProducerResult => [ { aoh => { # Response to 'Producers'
id => { id => 1 },
name => {},
original => { required => 0, default => '' },
+ hidden => { anybool => 1 },
+ } } ],
+ StaffResult => [ { aoh => { # Response to 'Staff'
+ id => { id => 1 },
+ aid => { id => 1 },
+ name => {},
+ original => { required => 0, default => '' },
} } ],
CharResult => [ { aoh => { # Response to 'Chars'
id => { id => 1 },
@@ -103,6 +112,11 @@ our %apis = (
original => { required => 0, default => '' },
} }
} } ],
+ AnimeResult => [ { aoh => { # Response to 'Anime'
+ id => { id => 1 },
+ title => {},
+ original => { required => 0, default => '' },
+ } } ],
ImageResult => [ { aoh => { # Response to 'Images'
id => { }, # image id...
token => { required => 0 },
@@ -131,15 +145,16 @@ our %apis = (
);
-# Generate the elm_Response() functions
+# Compile %apis into a %schema and generate the elm_Response() functions
+my %schemas;
for my $name (keys %apis) {
no strict 'refs';
- $apis{$name} = [ map tuwf->compile($_), $apis{$name}->@* ];
+ $schemas{$name} = [ map tuwf->compile($_), $apis{$name}->@* ];
*{'elm_'.$name} = sub {
my @args = map {
- $apis{$name}[$_]->validate($_[$_])->data if tuwf->debug;
- $apis{$name}[$_]->analyze->coerce_for_json($_[$_], unknown => 'reject')
- } 0..$#{$apis{$name}};
+ $schemas{$name}[$_]->validate($_[$_])->data if tuwf->debug;
+ $schemas{$name}[$_]->analyze->coerce_for_json($_[$_], unknown => 'reject')
+ } 0..$#{$schemas{$name}};
tuwf->resJSON({$name, \@args})
};
push @EXPORT, 'elm_'.$name;
@@ -342,9 +357,9 @@ sub write_api {
# of the Elm code, similar to def_type().
my(@union, @decode);
my $data = '';
- my $len = max map length, keys %apis;
- for (sort keys %apis) {
- my($name, $schema) = ($_, $apis{$_});
+ my $len = max map length, keys %schemas;
+ for (sort keys %schemas) {
+ my($name, $schema) = ($_, $schemas{$_});
my $def = $name;
my $dec = sprintf 'JD.field "%s"%s <| %s', $name,
' 'x($len-(length $name)),
@@ -396,6 +411,11 @@ sub write_types {
$data .= def cupSizes => 'List (String, String)' => list map tuple(string $_, string $CUP_SIZE{$_}), keys %CUP_SIZE;
$data .= def bloodTypes => 'List (String, String)' => list map tuple(string $_, string $BLOOD_TYPE{$_}), keys %BLOOD_TYPE;
$data .= def charRoles => 'List (String, String)' => list map tuple(string $_, string $CHAR_ROLE{$_}{txt}), keys %CHAR_ROLE;
+ $data .= def vnLengths => 'List (Int, String)' => list map tuple($_, string $VN_LENGTH{$_}{txt}.($VN_LENGTH{$_}{time}?" ($VN_LENGTH{$_}{time})":'')), keys %VN_LENGTH;
+ $data .= def vnRelations=> 'List (String, String)' => list map tuple(string $_, string $VN_RELATION{$_}{txt}), keys %VN_RELATION;
+ $data .= def creditTypes=> 'List (String, String)' => list map tuple(string $_, string $CREDIT_TYPE{$_}), keys %CREDIT_TYPE;
+ $data .= def producerRelations=> 'List (String, String)' => list map tuple(string $_, string $PRODUCER_RELATION{$_}{txt}), keys %PRODUCER_RELATION;
+ $data .= def producerTypes=> 'List (String, String)' => list map tuple(string $_, string $PRODUCER_TYPE{$_}), keys %PRODUCER_TYPE;
$data .= def curYear => Int => (gmtime)[5]+1900;
write_module Types => $data;
diff --git a/lib/VNWeb/Filters.pm b/lib/VNWeb/Filters.pm
new file mode 100644
index 00000000..75e82336
--- /dev/null
+++ b/lib/VNWeb/Filters.pm
@@ -0,0 +1,195 @@
+package VNWeb::Filters;
+
+# This module implements validating and querying the search filters. I'm not
+# sure yet if this filter system will continue to exist in this form or if
+# there will be a better advanced search system to replace it, but either way
+# we'll need to support these filters for the forseeable future.
+
+use VNWeb::Prelude;
+use Exporter 'import';
+
+our @EXPORT = qw/filter_parse filter_vn_query filter_release_query/;
+
+
+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] }, # ^
+ released => { undefbool => 1 },
+ length => { undefarray => { enum => \%VN_LENGTH } },
+ hasani => { undefbool => 1 },
+ hasshot => { undefbool => 1 },
+ tag_inc => { undefarray => { id => 1 } },
+ tag_exc => { undefarray => { id => 1 } },
+ taginc => { undefarray => {} }, # [old] Tag search by name
+ tagexc => { undefarray => {} }, # [old] Tag search by name
+ tagspoil => { required => 0, default => 0, uint => 1, range => [0,2] },
+ lang => { undefarray => { enum => \%LANGUAGE } },
+ olang => { undefarray => { enum => \%LANGUAGE } },
+ plat => { undefarray => { enum => \%PLATFORM } },
+ staff_inc => { undefarray => { id => 1 } },
+ staff_exc => { undefarray => { id => 1 } },
+ ul_notblack => { undefbool => 1 },
+ ul_onwish => { undefbool => 1 },
+ ul_voted => { undefbool => 1 },
+ ul_onlist => { undefbool => 1 },
+};
+
+my $RELEASE = form_compile any => {
+ type => { required => 0, 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] }, # ^
+ released => { undefbool => 1 },
+ minage => { undefarray => { enum => \%AGE_RATING } },
+ lang => { undefarray => { enum => \%LANGUAGE } },
+ olang => { undefarray => { enum => \%LANGUAGE } },
+ resolution => { undefarray => {} },
+ plat => { undefarray => { enum => [ 'unk', keys %PLATFORM ] } },
+ prod_inc => { undefarray => { id => 1 } },
+ prod_exc => { undefarray => { id => 1 } },
+ med => { undefarray => { enum => [ 'unk', keys %MEDIUM ] } },
+ voiced => { undefarray => { enum => \%VOICED } },
+ ani_story => { undefarray => { enum => \%ANIMATED } },
+ ani_ero => { undefarray => { enum => \%ANIMATED } },
+ engine => { required => 0 },
+};
+
+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 },
+ 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] },
+ role => { undefarray => { enum => \%CHAR_ROLE } },
+};
+
+my $STAFF = form_compile any => {
+ gender => { undefarray => { enum => [qw[unknown m f]] } },
+ role => { undefarray => { enum => [ 'seiyuu', keys %CREDIT_TYPE ] } },
+ truename => { undefbool => 1 },
+ lang => { undefarray => { enum => \%LANGUAGE } },
+};
+
+
+sub debug_validate {
+ my($type, $data) = @_;
+ my $s = {vn => $VN, release => $RELEASE, char => $CHAR, staff => $STAFF}->{$type};
+ my $v = $s->validate($data);
+ if(!$v) {
+ warn sprintf "Filter validation failed!\nData: %s\nError: %s", JSON::XS->new->canonical->pretty->encode($data), JSON::XS->new->canonical->pretty->encode($v->err);
+ } else {
+ #warn sprintf "Filter validated: %sSerialized: %s", JSON::XS->new->canonical->pretty->encode($v->data), VNDB::Func::fil_serialize($v->data);
+ }
+}
+
+
+# Compatibility with old VN filters. Modifies the filter in-place and returns the number of changes made.
+sub filter_vn_compat {
+ my($fil) = @_; #XXX: This function is called from old VNDB:: code and the filter data may not have been normalized as per the schema.
+ my $mod = 0;
+
+ # older tag specification (by name rather than ID)
+ for ('taginc', 'tagexc') {
+ my $l = delete $fil->{$_};
+ next if !$l;
+ $l = [ map lc($_), ref $l ? @$l : $l ];
+ $fil->{ s/^tag/tag_/rg } ||= [ map $_->{id}, tuwf->dbAlli(
+ 'SELECT DISTINCT id FROM tags LEFT JOIN tags_aliases ON id = tag WHERE searchable AND lower(name) IN', $l, 'OR lower(alias) IN', $l
+ )->@* ];
+ $mod++;
+ }
+
+ $mod;
+}
+
+
+# Throws error on failure.
+sub filter_parse {
+ my($type, $str) = @_;
+ my $s = {v => $VN, r => $RELEASE, c => $CHAR, s => $STAFF}->{$type};
+ my $data = ref $str ? $str : $str =~ /^{/ ? JSON::XS->new->decode($str) : VNDB::Func::fil_parse $str, keys $s->{known_keys}->%*;
+ die "Invalid filter data: $str\n" if !$data;
+ my $f = $s->validate($data)->data;
+ filter_vn_compat $f if $type eq 'vn';
+ $f
+}
+
+
+# Returns an SQL expression for use in a WHERE clause. Assumption: 'v' is an alias to the vn table being queried.
+sub filter_vn_query {
+ my($fil) = @_;
+ sql_and
+ defined $fil->{date_before} ? sql 'v.c_released <=', \$fil->{date_before} : (),
+ defined $fil->{date_after} ? sql 'v.c_released >=', \$fil->{date_after} : (),
+ defined $fil->{released} ? sql 'v.c_released', $fil->{released} ? '<=' : '>', \strftime('%Y%m%d', gmtime) : (),
+ defined $fil->{length} ? sql 'v.length IN', $fil->{length} : (),
+ defined $fil->{hasani} ? sql($fil->{hasani} ?'':'NOT', 'EXISTS(SELECT 1 FROM vn_anime iva WHERE iva.id = v.id)') : (),
+ defined $fil->{hasshot} ? sql($fil->{hasshot}?'':'NOT', 'EXISTS(SELECT 1 FROM vn_screenshots ivs WHERE ivs.id = v.id)') : (),
+ defined $fil->{tag_inc} ? sql
+ 'v.id IN(SELECT vid FROM tags_vn_inherit WHERE tag IN', $fil->{tag_inc}, 'AND spoiler <=', \$fil->{tagspoil}, 'GROUP BY vid HAVING COUNT(tag) =', scalar $fil->{tag_inc}->@*, ')' : (),
+ defined $fil->{tag_exc} ? sql 'v.id NOT IN(SELECT vid FROM tags_vn_inherit WHERE tag IN', $fil->{tag_exc}, ')' : (),
+ defined $fil->{lang} ? sql 'v.c_languages && ARRAY', $fil->{lang}, '::language[]' : (),
+ defined $fil->{olang} ? sql 'v.c_olang && ARRAY', $fil->{olang}, '::language[]' : (),
+ defined $fil->{plat} ? sql 'v.c_platforms && ARRAY', $fil->{plat}, '::platform[]' : (),
+ defined $fil->{staff_inc} ? sql 'v.id IN(SELECT ivs.id FROM vn_staff ivs JOIN staff_alias isa ON isa.aid = ivs.aid WHERE isa.id IN', $fil->{staff_inc}, ')' : (),
+ defined $fil->{staff_exc} ? sql 'v.id NOT IN(SELECT ivs.id FROM vn_staff ivs JOIN staff_alias isa ON isa.aid = ivs.aid WHERE isa.id IN', $fil->{staff_exc}, ')' : (),
+ auth ? (
+ # TODO: onwish, voted and onlist should respect the label filters in users.ulist_*
+ defined $fil->{ul_notblack} ? sql 'v.id NOT IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \auth->uid, 'AND lbl =', \6, ')' : (),
+ defined $fil->{ul_onwish} ? sql 'v.id', $fil->{ul_onwish}?'':'NOT', 'IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \auth->uid, 'AND lbl =', \5, ')' : (),
+ defined $fil->{ul_voted} ? sql 'v.id', $fil->{ul_voted} ?'':'NOT', 'IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \auth->uid, 'AND lbl =', \7, ')' : (),
+ defined $fil->{ul_onlist} ? sql 'v.id', $fil->{ul_onlist}?'':'NOT', 'IN(SELECT vid FROM ulist_vns WHERE uid =', \auth->uid, ')' : (),
+ ) : (),
+}
+
+
+# Assumption: 'r' is an alias to the release table being queried.
+sub filter_release_query {
+ my($fil) = @_;
+ sql_and
+ defined $fil->{type} ? sql 'r.type =', \$fil->{type} : (),
+ defined $fil->{patch} ? sql($fil->{patch} ?'':'NOT', 'r.patch' ) : (),
+ defined $fil->{freeware} ? sql($fil->{freeware} ?'':'NOT', 'r.freeware' ) : (),
+ defined $fil->{doujin} ? sql($fil->{doujin} ?'':'NOT', 'r.doujin AND NOT r.patch') : (),
+ defined $fil->{uncensored} ? sql($fil->{uncensored}?'':'NOT', 'r.uncensored') : (),
+ defined $fil->{date_before} ? sql 'r.released <=', \$fil->{date_before} : (),
+ defined $fil->{date_after} ? sql 'r.released >=', \$fil->{date_after} : (),
+ defined $fil->{released} ? sql 'r.released', $fil->{released} ? '<=' : '>', \strftime('%Y%m%d', gmtime) : (),
+ defined $fil->{minage} ? sql 'r.minage IN', $fil->{minage} : (),
+ defined $fil->{lang} ? sql 'r.id IN(SELECT irl.id FROM releases_lang irl WHERE irl.lang IN', $fil->{lang}, ')' : (),
+ defined $fil->{olang} ? sql 'r.id IN(SELECT irv.id FROM releases_vn irv JOIN vn iv ON irv.vid = iv.id WHERE iv.c_olang && ARRAY', $fil->{olang}, '::language[])' : (),
+ defined $fil->{resolution} ? sql 'NOT r.patch AND ARRAY[r.reso_x,r.reso_y] IN', [ map $_ eq 'unknown' ? '{0,0}' : $_ eq 'nonstandard' ? '{0,1}' : '{'.(s/x/,/r).'}', $fil->{resolution}->@* ] : (),
+ defined $fil->{plat} ? sql_or(
+ grep( /^unk$/, $fil->{plat}->@*) ? sql 'NOT EXISTS(SELECT 1 FROM releases_platforms irp WHERE irp.id = r.id)' : (),
+ grep(!/^unk$/, $fil->{plat}->@*) ? sql 'r.id IN(SELECT irp.id FROM releases_platforms irp WHERE irp.platform IN', [grep !/^unk$/, $fil->{plat}->@*], ')' : (),
+ ) : (),
+ defined $fil->{prod_inc} ? sql 'r.id IN(SELECT irp.id FROM releases_producers irp WHERE irp.pid IN', $fil->{prod_inc}, ')' : (),
+ defined $fil->{prod_exc} ? sql 'r.id NOT IN(SELECT irp.id FROM releases_producers irp WHERE irp.pid IN', $fil->{prod_exc}, ')' : (),
+ defined $fil->{med} ? sql_or(
+ grep( /^unk$/, $fil->{med}->@*) ? sql 'NOT EXISTS(SELECT 1 FROM releases_media irm WHERE irm.id = r.id)' : (),
+ grep(!/^unk$/, $fil->{med}->@*) ? sql 'r.id IN(SELECT irm.id FROM releases_media irm WHERE irm.medium IN', [grep !/^unk$/, $fil->{med}->@*], ')' : (),
+ ) : (),
+ defined $fil->{voiced} ? sql 'NOT r.patch AND r.voiced IN', $fil->{voiced} : (),
+ defined $fil->{ani_story} ? sql 'NOT r.patch AND r.ani_story IN', $fil->{ani_story} : (),
+ defined $fil->{ani_ero} ? sql 'NOT r.patch AND r.ani_ero IN', $fil->{ani_ero} : (),
+ defined $fil->{engine} ? sql 'r.engine =', \$fil->{engine} : (),
+}
+
+1;
diff --git a/lib/VNWeb/HTML.pm b/lib/VNWeb/HTML.pm
index 89a5fa6f..772f3ebc 100644
--- a/lib/VNWeb/HTML.pm
+++ b/lib/VNWeb/HTML.pm
@@ -116,10 +116,12 @@ sub spoil_ {
}
-# Instantiate an Elm module
+# Instantiate an Elm module.
+# $schema can be set to the string 'raw' to encode the JSON directly, without a normalizing through a schema.
sub elm_ {
my($mod, $schema, $data, $placeholder) = @_;
- push tuwf->req->{pagevars}{elm}->@*, [ $mod, $data ? ($schema ? $schema->analyze->coerce_for_json($data, unknown => 'remove') : $data) : () ];
+ die "Elm data without a schema" if defined $data && !defined $schema;
+ 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//'';
}
@@ -246,6 +248,17 @@ sub _menu_ {
a_ href => '/p/add', 'Add Producer'; br_;
a_ href => '/s/new', 'Add Staff'; br_;
}
+ if(auth->isMod) {
+ my $stats = tuwf->dbRowi("SELECT
+ (SELECT count(*) FROM reports WHERE status = 'new') as new,
+ (SELECT count(*) FROM reports WHERE status = 'new' AND date > (SELECT last_reports FROM users WHERE id =", \auth->uid, ")) AS unseen,
+ (SELECT count(*) FROM reports WHERE lastmod > (SELECT last_reports FROM users 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', ' | ';
+ a_ href => '/report/list?s=lastmod', sprintf '%d upd', $stats->{upd};
+ br_;
+ }
br_;
form_ action => "$uid/logout", method => 'post', sub {
input_ type => 'hidden', class => 'hidden', name => 'csrf', value => auth->csrftoken;
@@ -329,7 +342,7 @@ sub _maintabs_ {
return if !$t || !$o;
return if $t eq 'g' && !auth->permTagmod;
- my $id = $t.$o->{id};
+ my $id = $o->{id} =~ /^[0-9]*$/ ? $t.$o->{id} : $o->{id};
my sub t {
my($tabname, $url, $text) = @_;
@@ -354,6 +367,7 @@ sub _maintabs_ {
t list => "/$id/ulist?vnlist=1", 'list';
t votes => "/$id/ulist?votes=1", 'votes';
t wish => "/$id/ulist?wishlist=1", 'wishlist';
+ t reviews => "/w?u=$o->{id}", 'reviews';
t posts => "/$id/posts", 'posts';
} if $t eq 'u';
@@ -404,7 +418,7 @@ sub _hidden_msg_ {
txt_ ' if you believe that this entry should be restored.';
br_;
br_;
- lit_ bb2html $msg;
+ lit_ bb_format $msg;
}
}
};
@@ -600,7 +614,7 @@ sub _revision_cmp_ {
b_ "Edit summary for revision $new->{chrev}";
br_;
br_;
- lit_ bb2html $new->{rev_comments}||'-';
+ lit_ bb_format $new->{rev_comments}||'-';
};
};
};
@@ -663,7 +677,7 @@ sub revision_ {
br_;
b_ 'Edit summary';
br_; br_;
- lit_ bb2html $new->{rev_comments}||'-';
+ lit_ bb_format $new->{rev_comments}||'-';
} if !$old;
_revision_cmp_ $type, $old, $new, @fields if $old;
@@ -749,14 +763,20 @@ sub searchbox_ {
}
-# Generate a message to display on an entry page when the entry has been locked or the user can't edit it.
+# Generate a message to display on an entry page to report the entry and to indicate it has been locked or the user can't edit it.
sub itemmsg_ {
my($type, $obj) = @_;
- if($obj->{entry_locked}) {
- p_ class => 'locked', 'Locked for editing';
- } elsif(auth && !can_edit $type => $obj) {
- p_ class => 'locked', 'You can not edit this page';
- }
+ p_ class => 'itemmsg', sub {
+ if($type ne 'd' && $type ne 'w') {
+ if($obj->{entry_locked}) {
+ txt_ 'Locked for editing. ';
+ } elsif(auth && !can_edit $type => $obj) {
+ txt_ 'You can not edit this page. ';
+ }
+ }
+ my $id = $obj->{id} =~ /^[0-9]*$/ ? "$type$obj->{id}" : $obj->{id};
+ a_ href => "/report/$id", 'Report an issue on this page.';
+ };
}
diff --git a/lib/VNWeb/Images/Lib.pm b/lib/VNWeb/Images/Lib.pm
new file mode 100644
index 00000000..adf9186a
--- /dev/null
+++ b/lib/VNWeb/Images/Lib.pm
@@ -0,0 +1,131 @@
+package VNWeb::Images::Lib;
+
+use VNWeb::Prelude;
+use Exporter 'import';
+
+our @EXPORT = qw/enrich_image validate_token image_flagging_display image_ enrich_image_obj/;
+
+
+my @SEX = qw/Safe Suggestive Explicit/;
+my @VIO = qw/Tame Violent Brutal /;
+
+# Enrich images so that they match the format expected by the 'ImageResult' Elm
+# API response.
+#
+# Also adds signed tokens to the image list - indicating that the current user
+# is permitted to vote on these images. These tokens ensure that non-moderators
+# can only vote on images that they have been randomly assigned, thus
+# preventing possible abuse when a single person uses multiple accounts to
+# influence the rating of a single image.
+sub enrich_image {
+ my($canvote, $l) = @_;
+ enrich_merge id => sub { sql q{
+ SELECT i.id, i.width, i.height, i.c_votecount AS votecount
+ , i.c_sexual_avg AS sexual_avg, i.c_sexual_stddev AS sexual_stddev
+ , i.c_violence_avg AS violence_avg, i.c_violence_stddev 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'||v.id, 'c'||c.id, 'v'||vsv.id) AS entry_id
+ , COALESCE(v.title, c.name, vsv.title) 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 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
+ WHERE i.id IN}, $_
+ }, $l;
+
+ enrich votes => id => id => sub { sql '
+ SELECT iv.id, iv.uid, iv.sexual, iv.violence, iv.ignore OR (u.id IS NOT NULL AND NOT u.perm_imgvote) AS ignore, ', sql_user(), '
+ FROM image_votes iv
+ LEFT JOIN users u ON u.id = iv.uid
+ WHERE iv.id IN', $_,
+ auth ? ('AND (iv.uid IS NULL OR iv.uid <> ', \auth->uid, ')') : (), '
+ ORDER BY u.username'
+ }, $l;
+
+ for(@$l) {
+ $_->{entry} = $_->{entry_id} ? { id => $_->{entry_id}, title => $_->{entry_title} } : undef;
+ delete $_->{entry_id};
+ delete $_->{entry_title};
+ for my $v ($_->{votes}->@*) {
+ $v->{user} = xml_string sub { user_ $v }; # Easier than duplicating user_() in Elm
+ delete $v->{$_} for grep /^user_/, keys %$v;
+ }
+ $_->{token} = ($_->{votecount} == 0 && auth->permImgvote) || (ref $canvote eq 'CODE' ? $canvote->($_) : $canvote) ? auth->csrftoken(0, "imgvote-$_->{id}") : undef;
+ }
+}
+
+# Validates the token generated by enrich_image;
+sub validate_token {
+ my($l) = @_;
+ my $ok = 1;
+ $ok &&= $_->{token} && auth->csrfcheck($_->{token}, "imgvote-$_->{id}") for @$l;
+ $ok;
+}
+
+
+# Returns a string like 'Not flagged' or 'Safe / Tame (5)'
+sub image_flagging_display {
+ my($img) = @_;
+ $img->{votecount}
+ ? sprintf '%s / %s (%d)', $SEX[$img->{sexual}], $VIO[$img->{violence}], $img->{votecount}
+ : 'Not flagged'
+}
+
+
+# Display (or not) an image with preference toggle and hover-information.
+# Given $img is assumed to be an object generated by enrich_image_obj().
+sub image_ {
+ my($img, %opt) = @_;
+ return p_ 'No image' if !$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));
+ my $hide_on_click = $sexp < 0 || $sex || $vio || !$img->{votecount};
+
+ label_ class => 'imghover', style => "width: $img->{width}px; height: $img->{height}px", sub {
+ input_ type => 'checkbox', class => 'visuallyhidden', $hidden ? () : (checked => 'checked') if $hide_on_click;
+ div_ class => 'imghover--visible', sub {
+ img_ src => tuwf->imgurl($img->{id}), $opt{alt} ? (alt => $opt{alt}) : ();
+ a_ class => 'imghover--overlay', href => "/img/$img->{id}?view=".viewset(show_nsfw=>1), image_flagging_display $img;
+ };
+ div_ class => 'imghover--warning', sub {
+ if($img->{votecount}) {
+ txt_ 'This image has been flagged as:';
+ br_; br_;
+ txt_ 'Sexual: '; $sexh ? b_ class => 'standout', $SEX[$sex] : txt_ $SEX[$sex];
+ br_;
+ txt_ 'Violence '; $vioh ? b_ class => 'standout', $VIO[$vio] : txt_ $VIO[$vio];
+ } else {
+ txt_ 'This image has not yet been flagged';
+ }
+ br_; br_;
+ span_ class => 'fake_link', 'Show me anyway';
+ br_; br_;
+ b_ class => 'grayedout', 'This warning can be disabled in your account';
+ } if $hide_on_click;
+ }
+}
+
+
+sub enrich_image_obj {
+ my $field = shift;
+ enrich_obj $field => id => 'SELECT id, width, height, c_votecount AS votecount, c_sexual_avg AS sexual_avg, c_violence_avg AS violence_avg FROM images WHERE id IN', @_;
+
+ # Also add our final verdict. Still no clue why I chose these thresholds, but they seem to work.
+ for (map +(ref $_ eq 'ARRAY' ? @$_ : $_), @_) {
+ local $_ = $_->{$field};
+ if(ref $_) {
+ $_->{sexual} = !$_->{votecount} ? 2 : $_->{sexual_avg} > 1.3 ? 2 : $_->{sexual_avg} > 0.4 ? 1 : 0;
+ $_->{violence} = !$_->{votecount} ? 2 : $_->{violence_avg} > 1.3 ? 2 : $_->{violence_avg} > 0.4 ? 1 : 0;
+ }
+ }
+}
+
+1;
diff --git a/lib/VNWeb/Misc/ImageUpload.pm b/lib/VNWeb/Images/Upload.pm
index 4fd1ed0c..dd5bbb4d 100644
--- a/lib/VNWeb/Misc/ImageUpload.pm
+++ b/lib/VNWeb/Images/Upload.pm
@@ -1,6 +1,7 @@
package VNWeb::Misc::ImageUpload;
use VNWeb::Prelude;
+use VNWeb::Images::Lib;
use Image::Magick;
sub save_img {
@@ -40,6 +41,7 @@ TUWF::post qr{/elm/ImageUpload.json}, sub {
$im->Set(quality => 90);
my($ow, $oh) = ($im->Get('width'), $im->Get('height'));
+ return elm_ImgFormat if !$ow || !$oh;
my($nw, $nh) =
$type eq 'ch' ? imgsize $ow, $oh, tuwf->{ch_size}->@* :
$type eq 'cv' ? imgsize $ow, $oh, tuwf->{cv_size}->@* : ($ow, $oh);
@@ -54,7 +56,17 @@ TUWF::post qr{/elm/ImageUpload.json}, sub {
save_img $im, $id, 0, $ow, $oh, $nw, $nh;
save_img $im, $id, 1, $nw, $nh, tuwf->{scr_size}->@* if $type eq 'sf';
- elm_Image $id, $ow, $oh;
+ my $l = [{id => $id}];
+ enrich_image 1, $l;
+ elm_ImageResult $l;
+};
+
+
+elm_api Image => undef, { id => { vndbid => [qw/ch cv sf/] } }, sub {
+ my($data) = @_;
+ my $l = tuwf->dbAlli('SELECT id FROM images WHERE id =', \$data->{id});
+ enrich_image 0, $l;
+ elm_ImageResult $l;
};
1;
diff --git a/lib/VNWeb/Images/Vote.pm b/lib/VNWeb/Images/Vote.pm
index a1aa4570..e8a8b2f1 100644
--- a/lib/VNWeb/Images/Vote.pm
+++ b/lib/VNWeb/Images/Vote.pm
@@ -1,66 +1,7 @@
package VNWeb::Images::Vote;
use VNWeb::Prelude;
-
-
-# Add signed tokens to the image ist - indicating that the current user is
-# permitted to vote on these images. These tokens ensure that non-moderators
-# can only vote on images that they have been randomly assigned, thus
-# preventing possible abuse when a single person uses multiple accounts to
-# influence the rating of a single image.
-sub enrich_token {
- my($canvote, $l) = @_;
- $_->{token} = $canvote || ($_->{votecount} == 0 && auth->permImgvote) ? auth->csrftoken(0, "imgvote-$_->{id}") : undef for @$l;
-}
-
-
-# Does the reverse of enrich_token. Returns true if all tokens validated.
-sub validate_token {
- my($l) = @_;
- my $ok = 1;
- $ok &&= $_->{token} && auth->csrfcheck($_->{token}, "imgvote-$_->{id}") for @$l;
- $ok;
-}
-
-
-sub enrich_image {
- my($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
- , 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'||v.id, 'c'||c.id, 'v'||vsv.id) AS entry_id
- , COALESCE(v.title, c.name, vsv.title) 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 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
- WHERE i.id IN}, $_
- }, $l;
-
- enrich votes => id => id => sub { sql '
- SELECT iv.id, iv.uid, iv.sexual, iv.violence, iv.ignore OR (u.id IS NOT NULL AND NOT u.perm_imgvote) AS ignore, ', sql_user(), '
- FROM image_votes iv
- LEFT JOIN users u ON u.id = iv.uid
- WHERE iv.id IN', $_,
- auth ? ('AND (iv.uid IS NULL OR iv.uid <> ', \auth->uid, ')') : (), '
- ORDER BY u.username'
- }, $l;
-
- for(@$l) {
- $_->{entry} = $_->{entry_id} ? { id => $_->{entry_id}, title => $_->{entry_title} } : undef;
- delete $_->{entry_id};
- delete $_->{entry_title};
- for my $v ($_->{votes}->@*) {
- $v->{user} = xml_string sub { user_ $v }; # Easier than duplicating user_() in Elm
- delete $v->{$_} for grep /^user_/, keys %$v;
- }
- }
-}
+use VNWeb::Images::Lib;
my $SEND = form_compile any => {
@@ -74,6 +15,7 @@ my $SEND = form_compile any => {
nsfw_token => {},
};
+
# Fetch a list of images for the user to vote on.
elm_api Images => $SEND, { excl_voted => { anybool => 1 } }, sub {
my($data) = @_;
@@ -112,15 +54,14 @@ elm_api Images => $SEND, { excl_voted => { anybool => 1 } }, sub {
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;
- enrich_image $l;
- enrich_token 1, $l;
+ enrich_image 1, $l;
elm_ImageResult $l;
};
elm_api ImageVote => undef, {
votes => { sort_keys => 'id', aoh => {
- id => { regex => qr/^(?:ch|cv|sf)[1-9][0-9]*$/ },
+ id => { vndbid => [qw/ch cv sf/] },
token => {},
sexual => { uint => 1, range => [0,2] },
violence => { uint => 1, range => [0,2] },
@@ -172,8 +113,7 @@ TUWF::get qr{/img/vote}, sub {
return tuwf->resDenied if !auth->permImgvote;
my $recent = tuwf->dbAlli('SELECT id FROM image_votes WHERE uid =', \auth->uid, 'ORDER BY date DESC LIMIT', \30);
- enrich_image $recent;
- enrich_token 1, $recent;
+ enrich_image 1, $recent;
framework_ title => 'Image flagging', sub {
imgflag_ images => [ reverse @$recent ], single => 0, warn => 1;
@@ -185,11 +125,9 @@ TUWF::get qr{/img/$RE{imgid}}, sub {
my $id = tuwf->capture('id');
my $l = [{ id => $id }];
- enrich_image $l;
+ enrich_image auth->permImgmod() || sub { defined $_[0]{my_sexual} }, $l;
return tuwf->resNotFound if !defined $l->[0]{width};
- enrich_token defined($l->[0]{my_sexual}) || auth->permImgmod(), $l;
-
framework_ title => "Image flagging for $id", sub {
imgflag_ images => $l, single => 1, warn => !viewget->{show_nsfw};
};
diff --git a/lib/VNWeb/Misc/BBCode.pm b/lib/VNWeb/Misc/BBCode.pm
index 5d6f2e0b..2c41b6da 100644
--- a/lib/VNWeb/Misc/BBCode.pm
+++ b/lib/VNWeb/Misc/BBCode.pm
@@ -5,7 +5,7 @@ use VNWeb::Prelude;
elm_api BBCode => undef, {
content => { required => 0, default => '' }
}, sub {
- elm_Content bb2html bb_subst_links shift->{content};
+ elm_Content bb_format bb_subst_links shift->{content};
};
1;
diff --git a/lib/VNWeb/Misc/ElmAnime.pm b/lib/VNWeb/Misc/ElmAnime.pm
new file mode 100644
index 00000000..97260dd4
--- /dev/null
+++ b/lib/VNWeb/Misc/ElmAnime.pm
@@ -0,0 +1,23 @@
+package VNWeb::Misc::ElmAnime;
+
+use VNWeb::Prelude;
+
+elm_api Anime => undef, { search => {} }, sub {
+ my $q = shift->{search};
+ my $qs = sql_like $q;
+
+ elm_AnimeResult tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT a.id, a.title_romaji AS title, coalesce(a.title_kanji, \'\') AS original
+ FROM (',
+ sql_join('UNION ALL',
+ $q =~ /^a([0-9]+)$/ ? sql('SELECT 1, id FROM anime WHERE id =', \"$1") : (),
+ sql('SELECT 1+substr_score(lower(title_romaji),', \$qs, '), id FROM anime WHERE title_romaji ILIKE', \"%$qs%"),
+ sql('SELECT 10+substr_score(lower(title_kanji),', \$qs, '), id FROM anime WHERE title_kanji ILIKE', \"%$qs%"),
+ ), ') x(prio, id)
+ JOIN anime a ON a.id = x.id
+ GROUP BY a.id, a.title_romaji, a.title_kanji
+ ORDER BY MIN(x.prio), a.title_romaji
+ ');
+};
+
+1;
diff --git a/lib/VNWeb/Misc/Feeds.pm b/lib/VNWeb/Misc/Feeds.pm
new file mode 100644
index 00000000..fdc6606c
--- /dev/null
+++ b/lib/VNWeb/Misc/Feeds.pm
@@ -0,0 +1,79 @@
+package VNWeb::Misc::Feeds;
+
+use VNWeb::Prelude;
+use TUWF::XML ':xml';
+
+
+sub datetime { strftime '%Y-%m-%dT%H:%M:%SZ', gmtime shift }
+
+
+sub feed {
+ my($path, $title, $data) = @_;
+ my $base = tuwf->reqBaseURI();
+
+ tuwf->resHeader('Content-Type', 'application/atom+xml; charset=UTF-8');
+ xml;
+ tag feed => xmlns => 'http://www.w3.org/2005/Atom', 'xml:lang' => 'en', 'xml:base' => "$base/", sub {
+ tag title => $title;
+ tag updated => datetime max grep $_, map +($_->{published}, $_->{updated}), @$data;
+ tag id => $base.$path;
+ tag link => rel => 'self', type => 'application/atom+xml', href => $base.tuwf->reqPath(), undef;
+ tag link => rel => 'alternate', type => 'text/html', href => $base.$path, undef;
+
+ tag entry => sub {
+ tag id => "$base/$_->{id}";
+ tag title => $_->{title};
+ tag updated => datetime($_->{updated} || $_->{published});
+ tag published => datetime $_->{published} if $_->{published};
+ tag author => sub {
+ tag name => $_->{user_name};
+ tag uri => "$base/u$_->{user_id}";
+ } if $_->{user_id};
+ tag link => rel => 'alternate', type => 'text/html', href => "$base/$_->{id}", undef;
+ tag summary => type => 'html', bb_format $_->{summary}, maxlength => 300 if $_->{summary};
+ } for @$data;
+ }
+}
+
+
+TUWF::get qr{/feeds/announcements.atom}, sub {
+ feed '/t/an', 'VNDB Site Announcements', tuwf->dbAlli('
+ SELECT t.id, t.title, tp.msg AS summary
+ , ', sql_totime('tp.date'), 'AS published,', sql_totime('tp.edited'), 'AS updated,', sql_user(), '
+ FROM threads t
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num = 1
+ JOIN threads_boards tb ON tb.tid = t.id AND tb.type = \'an\'
+ LEFT JOIN users u ON u.id = tp.uid
+ WHERE NOT t.hidden AND NOT t.private
+ ORDER BY tb.tid DESC
+ LIMIT 10'
+ );
+};
+
+
+TUWF::get qr{/feeds/changes.atom}, sub {
+ my($lst) = VNWeb::Misc::History::fetch(undef, undef, {m=>1,h=>1,p=>1}, {results=>25});
+ for (@$lst) {
+ $_->{id} = "$_->{type}$_->{itemid}.$_->{rev}";
+ $_->{summary} = $_->{comments};
+ $_->{updated} = $_->{added};
+ }
+ feed '/hist', 'VNDB Recent Changes', $lst;
+};
+
+
+TUWF::get qr{/feeds/posts.atom}, sub {
+ feed '/t', 'VNDB Recent Posts', tuwf->dbAlli('
+ SELECT t.id||\'.\'||tp.num AS id, t.title||\' (#\'||tp.num||\')\' AS title, tp.msg AS summary
+ , ', sql_totime('tp.date'), 'AS published,', sql_totime('tp.edited'), 'AS updated,', sql_user(), '
+ FROM threads_posts tp
+ JOIN threads t ON t.id = tp.tid
+ LEFT JOIN users u ON u.id = tp.uid
+ WHERE NOT tp.hidden AND NOT t.hidden AND NOT t.private
+ ORDER BY tp.date DESC
+ LIMIT ', \25
+ );
+};
+
+
+1;
diff --git a/lib/VNWeb/Misc/History.pm b/lib/VNWeb/Misc/History.pm
index 26ef5f48..dcddfb4b 100644
--- a/lib/VNWeb/Misc/History.pm
+++ b/lib/VNWeb/Misc/History.pm
@@ -3,6 +3,7 @@ package VNWeb::Misc::History;
use VNWeb::Prelude;
+# Also used by Misc::HomePage and Misc::Feeds
sub fetch {
my($type, $id, $filt, $opt) = @_;
@@ -80,7 +81,7 @@ sub tablebox_ {
td_ class => 'tc3', sub { user_ $i };
td_ class => 'tc4', sub {
a_ href => $revurl, title => $i->{original}, shorten $i->{title}, 80;
- b_ class => 'grayedout', sub { lit_ bb2html $i->{comments}, 150 };
+ b_ class => 'grayedout', sub { lit_ bb_format $i->{comments}, maxlength => 150, inline => 1 };
};
} for @$lst;
};
@@ -170,19 +171,7 @@ sub filters_ {
TUWF::get qr{/(?:([upvrcsd])([1-9]\d*)/)?hist} => sub {
my($type, $id) = (tuwf->capture(1)||'', tuwf->capture(2));
- my sub dbitem {
- my($table, $title) = @_;
- tuwf->dbRowi('SELECT id,', $title, ' AS title, hidden AS entry_hidden, locked AS entry_locked FROM', $table, 'WHERE id =', \$id);
- };
-
- my $obj = !$type ? undef :
- $type eq 'u' ? tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \$id) :
- $type eq 'p' ? dbitem producers => 'name' :
- $type eq 'v' ? dbitem vn => 'title' :
- $type eq 'r' ? dbitem releases => 'title' :
- $type eq 'c' ? dbitem chars => 'name' :
- $type eq 's' ? dbitem staff => '(SELECT name FROM staff_alias WHERE aid = staff.aid)' :
- $type eq 'd' ? dbitem docs => 'title' : die;
+ my $obj = dbobj $type, $id;
return tuwf->resNotFound if $type && !$obj->{id};
$obj->{title} = user_displayname $obj if $type eq 'u';
diff --git a/lib/VNWeb/Misc/HomePage.pm b/lib/VNWeb/Misc/HomePage.pm
new file mode 100644
index 00000000..2ad4f85b
--- /dev/null
+++ b/lib/VNWeb/Misc/HomePage.pm
@@ -0,0 +1,255 @@
+package VNWeb::Misc::HomePage;
+
+use VNWeb::Prelude;
+use VNWeb::Filters;
+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;
+ 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});
+
+ my $filt = auth->pref('filter_vn') && eval { filter_parse v => auth->pref('filter_vn') };
+ my $lst = $filt ? 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 ', filter_vn_query($filt), ' ORDER BY random() LIMIT', \30, ') v
+ JOIN vn_screenshots vs ON v.id = vs.id
+ JOIN images i ON i.id = vs.scr
+ WHERE ', $where, '
+ ORDER BY v.id
+ ) x ORDER BY random() LIMIT', \4
+ ) : tuwf->dbAlli('
+ SELECT i.id, i.width, i.height, v.id AS vid, v.title
+ FROM (SELECT id, width, height FROM images i TABLESAMPLE SYSTEM (', \$sample, ') WHERE', $where, ' ORDER BY random() LIMIT', \4, ') i(id)
+ JOIN vn_screenshots vs ON vs.scr = i.id
+ JOIN vn v ON v.id = vs.id
+ ORDER BY random()
+ LIMIT', \4
+ );
+
+ p_ class => 'screenshots', sub {
+ a_ href => "/v$_->{vid}", title => $_->{title}, sub {
+ my($w, $h) = imgsize $_->{width}, $_->{height}, tuwf->{scr_size}->@*;
+ img_ src => tuwf->imgurl($_->{id}, 1), alt => $_->{title}, width => $w, height => $h;
+ } for @$lst;
+ }
+}
+
+
+sub recent_changes_ {
+ my($lst) = VNWeb::Misc::History::fetch(undef, 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', '' };
+ };
+ ul_ sub {
+ li_ sub {
+ txt_ "$_->{type}:";
+ a_ href => "/$_->{type}$_->{itemid}.$_->{rev}", title => $_->{original}||$_->{title}, shorten $_->{title}, 33;
+ lit_ " by ";
+ user_ $_;
+ } for @$lst;
+ };
+}
+
+
+sub announcements_ {
+ my $lst = tuwf->dbAlli('
+ SELECT t.id, t.title, substring(tp.msg, 1, 100+100+100) AS msg
+ 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
+ ORDER BY tb.tid DESC
+ LIMIT 1+1'
+ );
+ h1_ sub {
+ a_ href => '/t/an', 'Announcements'; txt_ ' ';
+ a_ href => '/feeds/announcements.atom', sub { abbr_ class => 'icons feed', title => 'Atom Feed', '' };
+ };
+ for (@$lst) {
+ h2_ sub { a_ href => "/$_->{id}", $_->{title} };
+ p_ sub { lit_ bb_format $_->{msg}, maxlength => 150, inline => 1 };
+ }
+}
+
+
+sub recent_posts_ {
+ my $lst = tuwf->dbAlli('
+ SELECT t.id, t.title, tp.num,', sql_totime('tp.date'), 'AS date, ', sql_user(), '
+ FROM threads t
+ JOIN threads_posts tp ON tp.tid = t.id AND tp.num = t.c_lastnum
+ LEFT JOIN users u ON tp.uid = u.id
+ WHERE NOT EXISTS(SELECT 1 FROM threads_boards tb WHERE tb.tid = t.id AND tb.type = \'u\')
+ AND NOT t.hidden AND NOT t.private
+ ORDER BY tp.date DESC
+ LIMIT 10'
+ );
+ enrich_boards undef, $lst;
+ h1_ sub {
+ a_ href => '/t/all', 'Recent Posts'; txt_ ' ';
+ a_ href => '/feeds/posts.atom', sub { abbr_ class => 'icons feed', title => 'Atom Feed', ''; };
+ };
+ ul_ sub {
+ li_ sub {
+ my $boards = join ', ', map $BOARD_TYPE{$_->{btype}}{txt}.($_->{iid}?' > '.$_->{title}:''), $_->{boards}->@*;
+ txt_ fmtage($_->{date}).' ';
+ a_ href => "/$_->{id}.$_->{num}#last", title => "Posted in $boards", shorten $_->{title}, 25;
+ lit_ ' by ';
+ user_ $_;
+ } for @$lst;
+ };
+}
+
+
+sub random_vns_ {
+ state $stats ||= tuwf->dbRowi('SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE NOT hidden) AS subset FROM vn');
+ state $sample ||= 100*min 1, (100 / $stats->{subset}) * ($stats->{total} / $stats->{subset});
+
+ my $filt = auth->pref('filter_vn') && eval { filter_parse v => auth->pref('filter_vn') };
+ my $lst = tuwf->dbAlli('
+ SELECT id, title, original
+ FROM vn v', $filt ? '' : ('TABLESAMPLE SYSTEM (', \$sample, ')'), '
+ WHERE NOT hidden AND', filter_vn_query($filt||{}), '
+ ORDER BY random() LIMIT 10'
+ );
+
+ h1_ sub {
+ a_ href => '/v/rand', 'Random visual novels';
+ };
+ ul_ sub {
+ li_ sub {
+ a_ href => "/v$_->{id}", title => $_->{original}||$_->{title}, shorten $_->{title}, 40;
+ } for @$lst;
+ }
+}
+
+
+sub releases_ {
+ my($released) = @_;
+
+ my $filt = auth->pref('filter_release') && eval { filter_parse r => auth->pref('filter_release') };
+ $filt = { $filt ? %$filt : (), date_before => undef, date_after => undef, released => $released?1:0 };
+
+ # XXX This query is kinda slow, an index on releases.released would probably help.
+ my $lst = tuwf->dbAlli('
+ SELECT id, title, original, released
+ FROM releases r
+ WHERE NOT hidden AND released', $released ? '<=' : '>', \strftime('%Y%m%d', gmtime), '
+ AND ', filter_release_query($filt), '
+ ORDER BY released', $released ? 'DESC' : '', ', id LIMIT 10'
+ );
+ 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;
+
+ h1_ sub {
+ a_ href => '/r?fil='.VNDB::Func::fil_serialize($filt).';o=a;s=released', 'Upcoming Releases' if !$released;
+ a_ href => '/r?fil='.VNDB::Func::fil_serialize($filt).';o=d;s=released', 'Just Released' if $released;
+ };
+ ul_ sub {
+ li_ sub {
+ rdate_ $_->{released};
+ txt_ ' ';
+ abbr_ class => "icons $_", title => $PLATFORM{$_}, '' for $_->{plat}->@*;
+ abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' for $_->{lang}->@*;
+ txt_ ' ';
+ a_ href => "/r$_->{id}", title => $_->{original}||$_->{title}, shorten $_->{title}, 30;
+ } for @$lst;
+ };
+}
+
+
+sub reviews_ {
+ my($full) = @_;
+ my $lst = tuwf->dbAlli('
+ SELECT w.id, v.title,', sql_user(), ',', sql_totime('w.date'), 'AS date
+ FROM reviews w
+ JOIN vn v ON v.id = w.vid
+ LEFT JOIN users u ON u.id = w.uid
+ WHERE NOT w.c_flagged AND', $full ? '' : 'NOT', 'w.isfull
+ ORDER BY w.id DESC LIMIT 10'
+ );
+ h1_ sub {
+ a_ href => '/w', $full ? 'Latest Full Reviews' : 'Latest Mini Reviews';
+ };
+ ul_ sub {
+ li_ sub {
+ txt_ fmtage($_->{date}).' ';
+ a_ href => "/$_->{id}", title => $_->{title}, shorten $_->{title}, 25;
+ lit_ ' by ';
+ user_ $_;
+ } for @$lst;
+ }
+}
+
+
+sub recent_comments_ {
+ my $lst = tuwf->dbAlli('
+ SELECT w.id, wp.num, v.title,', sql_user(), ',', sql_totime('wp.date'), 'AS date
+ 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
+ LEFT JOIN users u ON u.id = wp.uid
+ WHERE NOT w.c_flagged
+ ORDER BY wp.date DESC LIMIT 10'
+ );
+ h1_ sub {
+ a_ href => '/w?s=lastpost', 'Recent Review Comments';
+ };
+ ul_ sub {
+ li_ sub {
+ txt_ fmtage($_->{date}).' ';
+ a_ href => "/$_->{id}.$_->{num}#last", title => $_->{title}, shorten $_->{title}, 25;
+ lit_ ' by ';
+ user_ $_;
+ } for @$lst;
+ };
+}
+
+
+TUWF::get qr{/}, sub {
+ my %meta = (
+ 'type' => 'website',
+ 'title' => 'The Visual Novel Database',
+ 'description' => 'VNDB.org strives to be a comprehensive database for information about visual novels.',
+ );
+
+ framework_ title => $meta{title}, feeds => 1, og => \%meta, index => 1, sub {
+ div_ class => 'mainbox', sub {
+ h1_ $meta{title};
+ p_ class => 'description', sub {
+ txt_ $meta{description};
+ br_;
+ txt_ q{
+ This website is built as a wiki, meaning that anyone can freely add
+ and contribute information to the database, allowing us to create the
+ largest, most accurate and most up-to-date visual novel database on the web.
+ };
+ };
+ screens_;
+ };
+ table_ class => 'mainbox threelayout', sub {
+ tr_ sub {
+ td_ \&recent_changes_;
+ td_ \&announcements_;
+ td_ \&recent_posts_;
+ };
+ tr_ sub {
+ td_ \&random_vns_;
+ td_ sub { releases_ 0 };
+ td_ sub { releases_ 1 };
+ };
+ tr_ sub {
+ td_ sub { reviews_ 0 };
+ td_ sub { reviews_ 1 };
+ td_ \&recent_comments_;
+ };
+ };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Misc/Reports.pm b/lib/VNWeb/Misc/Reports.pm
new file mode 100644
index 00000000..c275efa9
--- /dev/null
+++ b/lib/VNWeb/Misc/Reports.pm
@@ -0,0 +1,259 @@
+package VNWeb::Misc::Reports;
+
+use VNWeb::Prelude;
+
+my $reportsperday = 5;
+
+my @STATUS = qw/new busy done dismissed/;
+
+# 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($t,$id) = ($1, $+{num});
+ my $obj = dbobj $t, $id;
+ $o->{title} = xml_string sub {
+ txt_ {qw/v VN r Release p Producer c Character s Staff d Doc/}->{$t};
+ txt_ ': ';
+ a_ href => "/$t$id", $obj->{title};
+ } if $obj->{id};
+ }
+ }
+}
+
+
+sub is_throttled {
+ tuwf->dbVali('SELECT COUNT(*) FROM reports WHERE date > NOW()-\'1 day\'::interval AND', auth ? ('uid =', \auth->uid) : ('ip =', \tuwf->reqIP)) >= $reportsperday
+}
+
+
+my $FORM = form_compile any => {
+ object => {},
+ objectnum=> { required => 0, uint => 1 },
+ title => {},
+ reason => { maxlength => 50 },
+ message => { required => 0, default => '', maxlength => 50000 },
+ loggedin => { anybool => 1 },
+};
+
+elm_api Report => undef, $FORM, sub {
+ my($data) = @_;
+ enrich_object $data;
+ return elm_Invalid if !$data->{title};
+ return elm_Unauth if is_throttled;
+
+ tuwf->dbExeci('INSERT INTO reports', {
+ uid => auth->uid,
+ ip => auth ? undef : tuwf->reqIP,
+ 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};
+
+ framework_ title => 'Submit report', sub {
+ if(is_throttled) {
+ div_ class => 'mainbox', 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 };
+ }
+ };
+};
+
+
+sub report_ {
+ my($r, $url) = @_;
+ my $objid = $r->{object}.(defined $r->{objectnum} ? ".$r->{objectnum}" : '');
+ td_ style => 'padding: 3px 5px 5px 20px', sub {
+ a_ href => "?id=$r->{id}", "#$r->{id}";
+ b_ class => 'grayedout', ' '.fmtdate $r->{date}, 'full';
+ txt_ ' by ';
+ if($r->{uid}) {
+ a_ href => "/u$r->{uid}", $r->{username};
+ txt_ ' (';
+ a_ href => "/t/u$r->{uid}/new?title=Regarding your report on $objid&priv=1", 'pm';
+ txt_ ')';
+ } else {
+ txt_ $r->{ip}||'[anonymous]';
+ }
+ br_;
+ lit_ $r->{title} || '[deleted]';
+ br_;
+ txt_ $r->{reason};
+ div_ class => 'quote', sub { lit_ bb_format $r->{message} } if $r->{message};
+ };
+ td_ style => 'width: 300px', sub {
+ form_ method => 'post', action => '/report/edit', sub {
+ input_ type => 'hidden', name => 'id', value => $r->{id};
+ input_ type => 'hidden', name => 'url', value => $url;
+ textarea_ name => 'comment', rows => 2, cols => 25, style => 'width: 290px', placeholder => 'Mod comment... (optional)', '';
+ br_;
+ select_ style => 'width: 100px', name => 'status', sub {
+ option_ value => $_, $_ eq $r->{status} ? (selected => 'selected') : (), ucfirst $_ for @STATUS;
+ };
+ input_ type => 'submit', class => 'submit', value => 'Update';
+ };
+ };
+ td_ sub {
+ lit_ bb_format $r->{log};
+ };
+}
+
+
+TUWF::get qr{/report/list}, sub {
+ return tuwf->resDenied if !auth->isMod;
+
+ 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 },
+ )->data;
+
+ my $where = sql_and
+ $opt->{id} ? sql 'r.id =', \$opt->{id} : (),
+ $opt->{status} ? sql 'r.status =', \$opt->{status} : (),
+ $opt->{s} eq 'lastmod' ? 'r.lastmod IS NOT NULL' : ();
+
+ my $cnt = tuwf->dbVali('SELECT count(*) FROM reports r WHERE', $where);
+ my $lst = tuwf->dbPagei({results => 25, page => $opt->{p}},
+ 'SELECT r.id,', sql_totime('r.date'), 'as date, r.uid, u.username, r.ip, r.reason, r.object, r.objectnum, r.status, r.message, r.log
+ FROM reports r
+ LEFT JOIN users u ON u.id = r.uid
+ WHERE', $where, '
+ ORDER BY', {id => 'r.id DESC', lastmod => 'r.lastmod DESC'}->{$opt->{s}}
+ );
+ enrich_object @$lst;
+
+ tuwf->dbExeci(
+ 'UPDATE users SET last_reports = NOW()
+ WHERE (last_reports IS NULL OR EXISTS(SELECT 1 FROM reports WHERE lastmod > last_reports OR date > last_reports))
+ AND id =', \auth->uid
+ );
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ framework_ title => 'Reports', sub {
+ div_ class => 'mainbox', sub {
+ h1_ 'Reports';
+ p_ 'Welcome to the super advanced reports handling interface. Reports can have the following statuses:';
+ ul_ sub {
+ li_ 'New: Default status for newly submitted reports';
+ li_ 'Busy: You can use this state to indicate that you\'re working on it.';
+ li_ 'Done: Report handled.';
+ li_ 'Dismissed: Report ignored.';
+ };
+ p_ q{
+ There's no flowchart you have to follow, if you can quickly handle a report you can go directly from 'New' to 'Done' or 'Dismissed'.
+ If you want to bring an older report to other's attention you can go back from any existing state to 'New'.
+ };
+ p_ q{
+ Feel free to skip over reports that you can't or don't want to handle, someone else will eventually pick it up.
+ };
+ p_ q{
+ Changing the status and/or adding a comment will add an entry to the log, so other mods can see what is going on. Everything on this page is only visible to moderators.
+ };
+ p_ q{
+ BUG: Deleting the last post from a thread (not "hiding", but actually deleting it) will cause the report
+ to refer to an innocent post when someone adds a new post to that thread, as the reply will get the same number as the deleted post.
+ Not a huge problem, but something to be aware of when browsing through handled reports.
+ };
+ br_;
+ br_;
+ p_ class => 'browseopts', sub {
+ a_ href => url(p => undef, status => undef), !$opt->{status} ? (class => 'optselected') : (), 'All';
+ a_ href => url(p => undef, status => $_), $opt->{status} && $opt->{status} eq $_ ? (class => 'optselected') : (), ucfirst $_ for @STATUS;
+ };
+ p_ class => 'browseopts', sub {
+ txt_ 'Sort by ';
+ a_ href => url(p => undef, s => 'id'), $opt->{s} eq 'id' ? (class => 'optselected') : (), 'newest';
+ a_ href => url(p => undef, s => 'lastmod'), $opt->{s} eq 'lastmod' ? (class => 'optselected') : (), 'last updated';
+ };
+ };
+
+ paginate_ \&url, $opt->{p}, [$cnt, 25], 't';
+ div_ class => 'mainbox thread', sub {
+ table_ class => 'stripe', sub {
+ my $url = '/report/list'.url;
+ tr_ sub { report_ $_, $url } for @$lst;
+ tr_ sub { td_ style => 'text-align: center', 'Nothing to report! (heh)' } if !@$lst;
+ };
+ };
+ paginate_ \&url, $opt->{p}, [$cnt, 25], 'b';
+ };
+};
+
+
+TUWF::post qr{/report/edit}, sub {
+ return tuwf->resDenied if !auth->isMod;
+ my $frm = tuwf->validate(post =>
+ id => { id => 1 },
+ url => { regex => qr{^/report/list} },
+ status => { enum => \@STATUS },
+ comment => { required => 0, 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});
+ }
+ tuwf->resRedirect($frm->{url}, 'post');
+};
+
+1;
diff --git a/lib/VNWeb/Prelude.pm b/lib/VNWeb/Prelude.pm
index c79cc09b..198d09e2 100644
--- a/lib/VNWeb/Prelude.pm
+++ b/lib/VNWeb/Prelude.pm
@@ -8,7 +8,7 @@
# use Exporter 'import';
# use Time::HiRes 'time';
# use List::Util 'min', 'max', 'sum';
-# use POSIX 'ceil', 'floor';
+# use POSIX 'ceil', 'floor', 'strftime';
#
# use VNDBUtil;
# use VNDB::BBCode;
@@ -33,6 +33,7 @@ use feature ':5.26';
use utf8;
use VNWeb::Elm;
use VNWeb::Auth;
+use VNWeb::DB;
use TUWF;
use JSON::XS;
@@ -52,7 +53,7 @@ sub import {
use Exporter 'import';
use Time::HiRes 'time';
use List::Util 'min', 'max', 'sum';
- use POSIX 'ceil', 'floor';
+ use POSIX 'ceil', 'floor', 'strftime';
use VNDBUtil;
use VNDB::BBCode;
@@ -72,6 +73,7 @@ sub import {
*{$c.'::RE'} = *RE;
*{$c.'::in'} = \&in;
*{$c.'::idcmp'} = \&idcmp;
+ *{$c.'::dbobj'} = \&dbobj;
}
@@ -89,8 +91,9 @@ our %RE = (
pid => qr{p$id},
iid => qr{i$id},
did => qr{d$id},
- tid => qr{t$id},
+ tid => qr{(?<id>t$num)},
gid => qr{g$id},
+ wid => qr{(?<id>w$num)},
imgid=> qr{(?<id>(?:ch|cv|sf)$num)},
vrev => qr{v$id$rev?},
rrev => qr{r$id$rev?},
@@ -98,7 +101,7 @@ our %RE = (
srev => qr{s$id$rev?},
crev => qr{c$id$rev?},
drev => qr{d$id$rev?},
- postid => qr{t$id\.(?<num>$num)},
+ postid => qr{(?<id>t$num)\.(?<num>$num)},
);
@@ -122,4 +125,26 @@ sub idcmp($$) {
$a1 cmp $b1 || $a2 <=> $b2
}
+
+# 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.
+sub dbobj {
+ my($type, $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);
+ };
+
+ !$type ? undef :
+ $type eq 'u' ? tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \$id) :
+ $type eq 'p' ? item producers => 'name' :
+ $type eq 'v' ? item vn => 'title' :
+ $type eq 'r' ? item releases => 'title' :
+ $type eq 'c' ? item chars => 'name' :
+ $type eq 's' ? item staff => '(SELECT name FROM staff_alias WHERE aid = staff.aid)' :
+ $type eq 'd' ? item docs => 'title' : die;
+}
+
1;
diff --git a/lib/VNWeb/Producers/Edit.pm b/lib/VNWeb/Producers/Edit.pm
new file mode 100644
index 00000000..0a5df222
--- /dev/null
+++ b/lib/VNWeb/Producers/Edit.pm
@@ -0,0 +1,120 @@
+package VNWeb::Producers::Edit;
+
+use VNWeb::Prelude;
+
+
+my $FORM = {
+ id => { required => 0, id => 1 },
+ 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 => {
+ pid => { id => 1 },
+ 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 },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_CMP = form_compile cmp => $FORM;
+
+
+TUWF::get qr{/$RE{prev}/edit} => sub {
+ my $e = db_entry p => tuwf->capture('id'), tuwf->capture('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 p$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};
+
+ framework_ title => "Edit $e->{name}", type => 'p', dbobj => $e, tab => 'edit',
+ sub {
+ editmsg_ p => $e, "Edit $e->{name}";
+ elm_ ProducerEdit => $FORM_OUT, $e;
+ };
+};
+
+
+TUWF::get qr{/p/add}, sub {
+ return tuwf->resDenied if !can_edit p => undef;
+
+ framework_ title => 'Add producer',
+ sub {
+ editmsg_ p => undef, 'Add producer';
+ elm_ ProducerEdit => $FORM_OUT, elm_empty $FORM_OUT;
+ };
+};
+
+
+elm_api ProducerEdit => $FORM_OUT, $FORM_IN, sub {
+ my $data = shift;
+ my $new = !$data->{id};
+ my $e = $new ? { id => 0 } : db_entry p => $data->{id} or return tuwf->resNotFound;
+ return elm_Unauth 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->{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} == $e->{id}, $data->{relations}->@*;
+
+ $e->{ptype} = $e->{type};
+ $data->{type} = $data->{ptype};
+ return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
+ my($id,undef,$rev) = db_edit p => $e->{id}, $data;
+ update_reverse($id, $rev, $e, $data);
+ elm_Redirect "/p$id.$rev";
+};
+
+
+sub update_reverse {
+ my($id, $rev, $old, $new) = @_;
+
+ my %old = map +($_->{pid}, $_), $old->{relations} ? $old->{relations}->@* : ();
+ my %new = map +($_->{pid}, $_), $new->{relations}->@*;
+
+ # Updates to be performed, pid => { pid => x, relation => y } or undef if the relation should be removed.
+ my %upd;
+
+ for my $i (keys %old, keys %new) {
+ if($old{$i} && !$new{$i}) {
+ $upd{$i} = undef;
+ } elsif(!$old{$i} || $old{$i}{relation} ne $new{$i}{relation}) {
+ $upd{$i} = {
+ pid => $id,
+ relation => $PRODUCER_RELATION{ $new{$i}{relation} }{reverse},
+ };
+ }
+ }
+
+ for my $i (keys %upd) {
+ my $e = db_entry p => $i;
+ $e->{relations} = [
+ $upd{$i} ? $upd{$i} : (),
+ grep $_->{pid} != $id, $e->{relations}->@*
+ ];
+ $e->{editsum} = "Reverse relation update caused by revision p$id.$rev";
+ db_edit p => $i, $e, 1;
+ }
+}
+
+1;
diff --git a/lib/VNWeb/Producers/Elm.pm b/lib/VNWeb/Producers/Elm.pm
index ea541130..dae9709d 100644
--- a/lib/VNWeb/Producers/Elm.pm
+++ b/lib/VNWeb/Producers/Elm.pm
@@ -2,22 +2,30 @@ package VNWeb::Producers::Elm;
use VNWeb::Prelude;
-elm_api Producers => undef, { search => {} }, sub {
- my $q = shift->{search};
- my $qs = $q =~ s/[%_]//gr;
+elm_api Producers => undef, {
+ search => { type => 'array', values => { required => 0, default => '' } },
+ hidden => { anybool => 1 },
+}, sub {
+ my($data) = @_;
+ my @q = grep length $_, $data->{search}->@*;
+ die "No query" if !@q;
elm_ProducerResult tuwf->dbPagei({ results => 15, page => 1 },
- 'SELECT p.id, p.name, p.original
+ 'SELECT p.id, p.name, p.original, p.hidden
FROM (',
- sql_join('UNION ALL',
- $q =~ /^$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 original ILIKE', \"%$qs%"),
- sql('SELECT 100, id FROM producers WHERE alias ILIKE', \"%$qs%"),
- ), ') x(prio, id)
+ 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 NOT p.hidden
- GROUP BY p.id, p.name, p.original
+ 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
');
};
diff --git a/lib/VNWeb/Producers/Page.pm b/lib/VNWeb/Producers/Page.pm
index 65196e76..eac6f3e4 100644
--- a/lib/VNWeb/Producers/Page.pm
+++ b/lib/VNWeb/Producers/Page.pm
@@ -59,7 +59,7 @@ sub info_ {
}, grep $rel{$_}, keys %PRODUCER_RELATION;
} if $p->{relations}->@*;
- p_ class => 'description', sub { lit_ bb2html $p->{desc} } if length $p->{desc};
+ p_ class => 'description', sub { lit_ bb_format $p->{desc} } if length $p->{desc};
}
@@ -155,11 +155,14 @@ TUWF::get qr{/$RE{prev}(?:/(?<tab>vn|rel))?}, sub {
framework_ title => $p->{name}, index => !tuwf->capture('rev'), type => 'p', dbobj => $p, hiddenmsg => 1,
og => {
title => $p->{name},
- description => bb2text($p->{desc}),
+ description => bb_format($p->{desc}, text => 1),
},
sub {
rev_ $p if tuwf->capture('rev');
- div_ class => 'mainbox', sub { info_ $p };
+ div_ class => 'mainbox', sub {
+ itemmsg_ p => $p;
+ info_ $p;
+ };
div_ class => 'maintabs right', sub {
ul_ sub {
li_ mkclass(tabselected => $tab eq 'vn'), sub { a_ href => "/p$p->{id}/vn", 'Visual Novels' };
diff --git a/lib/VNWeb/Releases/Elm.pm b/lib/VNWeb/Releases/Elm.pm
index b151de41..f4ab8975 100644
--- a/lib/VNWeb/Releases/Elm.pm
+++ b/lib/VNWeb/Releases/Elm.pm
@@ -1,22 +1,13 @@
package VNWeb::Releases::Elm;
use VNWeb::Prelude;
+use VNWeb::Releases::Lib;
# Used by UList.Opt and CharEdit to fetch releases from a VN id.
elm_api Release => undef, { vid => { id => 1 } }, sub {
my($data) = @_;
- my $l = tuwf->dbAlli(
- 'SELECT r.id, r.title, r.original, r.type AS rtype, r.released
- FROM releases r
- JOIN releases_vn rv ON rv.id = r.id
- WHERE NOT r.hidden
- AND rv.vid =', \$data->{vid},
- 'ORDER BY r.released, r.title, r.id'
- );
- enrich_flatten lang => id => id => sub { sql('SELECT id, lang FROM releases_lang WHERE id IN', $_, 'ORDER BY lang') }, $l;
- enrich_flatten platforms => id => id => sub { sql('SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY platform') }, $l;
- elm_Releases $l;
+ elm_Releases releases_by_vn $data->{vid};
};
1;
diff --git a/lib/VNWeb/Releases/Lib.pm b/lib/VNWeb/Releases/Lib.pm
index 7b36a290..4aad7b50 100644
--- a/lib/VNWeb/Releases/Lib.pm
+++ b/lib/VNWeb/Releases/Lib.pm
@@ -3,7 +3,23 @@ package VNWeb::Releases::Lib;
use VNWeb::Prelude;
use Exporter 'import';
-our @EXPORT = qw/enrich_release release_row_/;
+our @EXPORT = qw/enrich_release_elm releases_by_vn enrich_release release_row_/;
+
+
+# Enrich a list of releases so that it's suitable as 'Releases' Elm response.
+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_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');
+ enrich_release_elm $l;
+ $l
+}
# Enrich a list of releases so that it's suitable for release_row_().
@@ -76,7 +92,7 @@ sub release_row_ {
}
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', bb2text $r->{notes} if $r->{notes};
+ icon_ 'notes', bb_format $r->{notes}, text => 1 if $r->{notes};
}
tr_ sub {
@@ -96,7 +112,7 @@ sub release_row_ {
td_ class => 'tc_icons', sub { icons_ $r };
td_ class => 'tc_prod', join ' & ', $r->{publisher} ? 'Pub' : (), $r->{developer} ? 'Dev' : () if $prodpage;
td_ class => 'tc5 elm_dd_left', sub {
- elm_ 'UList.ReleaseEdit', $VNWeb::User::Lists::RLIST_STATUS, { rid => $r->{id}, uid => auth->uid, status => $r->{rlist_status}, empty => '--' } if auth;
+ elm_ 'UList.ReleaseEdit', $VNWeb::ULists::Elm::RLIST_STATUS, { rid => $r->{id}, uid => auth->uid, status => $r->{rlist_status}, empty => '--' } if auth;
};
td_ class => 'tc6', sub { release_extlinks_ $r, "${id}_$r->{id}" };
}
diff --git a/lib/VNWeb/Releases/Page.pm b/lib/VNWeb/Releases/Page.pm
index ccf4b6fb..e60d84b6 100644
--- a/lib/VNWeb/Releases/Page.pm
+++ b/lib/VNWeb/Releases/Page.pm
@@ -195,7 +195,7 @@ sub _infotable_ {
td_ sub {
div_ class => 'elm_dd_input', style => 'width: 150px', sub {
my $d = tuwf->dbVali('SELECT status FROM rlists WHERE', { rid => $r->{id}, uid => auth->uid });
- elm_ 'UList.ReleaseEdit', $VNWeb::User::Lists::RLIST_STATUS, { rid => $r->{id}, uid => auth->uid, status => $d, empty => 'not on your list' };
+ elm_ 'UList.ReleaseEdit', $VNWeb::ULists::Elm::RLIST_STATUS, { rid => $r->{id}, uid => auth->uid, status => $d, empty => 'not on your list' };
}
};
} if auth;
@@ -212,7 +212,7 @@ TUWF::get qr{/$RE{rrev}} => sub {
framework_ title => $r->{title}, index => !tuwf->capture('rev'), type => 'r', dbobj => $r, hiddenmsg => 1,
og => {
- description => bb2text $r->{notes}
+ description => bb_format $r->{notes}, text => 1
},
sub {
_rev_ $r if tuwf->capture('rev');
@@ -221,7 +221,7 @@ TUWF::get qr{/$RE{rrev}} => sub {
h1_ sub { txt_ $r->{title}; debug_ $r };
h2_ class => 'alttitle', lang_attr($r->{lang}), $r->{original} if length $r->{original};
_infotable_ $r;
- p_ class => 'description', sub { lit_ bb2html $r->{notes} } if $r->{notes};
+ p_ class => 'description', sub { lit_ bb_format $r->{notes} } if $r->{notes};
};
};
};
diff --git a/lib/VNWeb/Reviews/Edit.pm b/lib/VNWeb/Reviews/Edit.pm
new file mode 100644
index 00000000..a3323d62
--- /dev/null
+++ b/lib/VNWeb/Reviews/Edit.pm
@@ -0,0 +1,109 @@
+package VNWeb::Reviews::Edit;
+
+use VNWeb::Prelude;
+use VNWeb::Releases::Lib;
+
+
+my $FORM = {
+ id => { vndbid => 'w', required => 0 },
+ vid => { id => 1 },
+ vntitle => { _when => 'out' },
+ rid => { id => 1, required => 0 },
+ spoiler => { anybool => 1 },
+ isfull => { anybool => 1 },
+ text => { maxlength => 100_000, required => 0, default => '' },
+ locked => { anybool => 1 },
+
+ mod => { _when => 'out', anybool => 1 },
+ releases => { _when => 'out', $VNWeb::Elm::apis{Releases}[0]->%* },
+};
+
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_OUT = form_compile out => $FORM;
+
+
+sub throttled { tuwf->dbVali('SELECT COUNT(*) FROM reviews WHERE uid =', \auth->uid, 'AND date > date_trunc(\'day\', NOW())') >= 5 }
+
+
+TUWF::get qr{/$RE{vid}/addreview}, sub {
+ my $v = tuwf->dbRowi('SELECT id, title FROM vn WHERE NOT hidden AND id =', \tuwf->capture('id'));
+ return tuwf->resNotFound if !$v->{id};
+
+ my $id = tuwf->dbVali('SELECT id FROM reviews WHERE vid =', \$v->{id}, 'AND uid =', \auth->uid);
+ return tuwf->resRedirect("/$id/edit") if $id;
+ return tuwf->resDenied if !can_edit w => {};
+
+ framework_ title => "Write review for $v->{title}", sub {
+ if(throttled) {
+ div_ class => 'mainbox', 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()
+ };
+ }
+ };
+};
+
+
+TUWF::get qr{/$RE{wid}/edit}, sub {
+ my $e = tuwf->dbRowi(
+ 'SELECT r.id, r.uid AS user_id, r.vid, r.rid, r.isfull, r.text, r.spoiler, r.locked, v.title AS vntitle
+ FROM reviews r JOIN vn v ON v.id = r.vid WHERE r.id =', \tuwf->capture('id')
+ );
+ return tuwf->resNotFound if !$e->{id};
+ return tuwf->resDenied if !can_edit w => $e;
+
+ $e->{releases} = releases_by_vn $e->{vid};
+ $e->{mod} = auth->permBoardmod;
+ framework_ title => "Edit review for $e->{vntitle}", type => 'w', dbobj => $e, tab => 'edit', sub {
+ elm_ 'Reviews.Edit' => $FORM_OUT, $e;
+ };
+};
+
+
+
+elm_api ReviewsEdit => $FORM_OUT, $FORM_IN, sub {
+ my($data) = @_;
+ my $id = delete $data->{id};
+
+ my $review = $id ? tuwf->dbRowi('SELECT id, locked, uid AS user_id FROM reviews WHERE id =', \$id) : {};
+ return elm_Unauth if !can_edit w => $review;
+
+ $data->{locked} = $review->{locked}||0 if !auth->permBoardmod;
+
+ validate_dbid 'SELECT id FROM vn WHERE id IN', $data->{vid};
+ validate_dbid 'SELECT id FROM releases WHERE id IN', $data->{rid} if defined $data->{rid};
+
+ die "Review too long" if !$data->{isfull} && length $data->{text} > 800;
+ $data->{text} = bb_subst_links $data->{text} if $data->{isfull};
+
+ if($id) {
+ $data->{lastmod} = sql 'NOW()';
+ tuwf->dbExeci('UPDATE reviews SET', $data, 'WHERE id =', \$id) if $id;
+ auth->audit($review->{user_id}, 'review edit', "edited $review->{id}") if auth->uid != $review->{user_id};
+
+ } else {
+ return elm_Unauth if tuwf->dbVali('SELECT 1 FROM reviews WHERE vid =', \$data->{vid}, 'AND uid =', \auth->uid);
+ return elm_Unauth if throttled;
+ $data->{uid} = auth->uid;
+ $id = tuwf->dbVali('INSERT INTO reviews', $data, 'RETURNING id');
+ }
+
+ elm_Redirect "/$id"
+};
+
+
+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 elm_Unauth if !can_edit w => $review;
+ auth->audit($review->{user_id}, 'review delete', "deleted $review->{id}");
+ tuwf->dbExeci('DELETE FROM reviews WHERE id =', \$data->{id});
+ elm_Success
+};
+
+
+1;
diff --git a/lib/VNWeb/Reviews/Elm.pm b/lib/VNWeb/Reviews/Elm.pm
new file mode 100644
index 00000000..385c8b0f
--- /dev/null
+++ b/lib/VNWeb/Reviews/Elm.pm
@@ -0,0 +1,28 @@
+package VNWeb::Reviews::Elm;
+
+use VNWeb::Prelude;
+
+my $VOTE = {
+ id => { vndbid => 'w' },
+ my => { required => 0, jsonbool => 1 },
+ overrule => { anybool => 1 },
+ mod => { _when => 'out', anybool => 1 },
+};
+
+my $VOTE_IN = form_compile in => $VOTE;
+our $VOTE_OUT = form_compile out => $VOTE;
+
+elm_api ReviewsVote => $VOTE_OUT, $VOTE_IN, sub {
+ return elm_Unauth if !auth;
+ my($data) = @_;
+ my %id = (uid => auth->uid, id => $data->{id});
+ my %val = (vote => $data->{my}?1:0, overrule => auth->permBoardmod ? $data->{overrule}?1:0 : 0, date => sql 'NOW()');
+ tuwf->dbExeci(
+ defined $data->{my}
+ ? sql 'INSERT INTO reviews_votes', {%id,%val}, 'ON CONFLICT (id,uid) DO UPDATE SET', \%val
+ : sql 'DELETE FROM reviews_votes WHERE', \%id
+ );
+ elm_Success
+};
+
+1;
diff --git a/lib/VNWeb/Reviews/Lib.pm b/lib/VNWeb/Reviews/Lib.pm
new file mode 100644
index 00000000..2872966c
--- /dev/null
+++ b/lib/VNWeb/Reviews/Lib.pm
@@ -0,0 +1,21 @@
+package VNWeb::Reviews::Lib;
+
+use VNWeb::Prelude;
+use Exporter 'import';
+our @EXPORT = qw/reviews_vote_ reviews_format/;
+
+sub reviews_vote_ {
+ my($w) = @_;
+ span_ sub {
+ elm_ 'Reviews.Vote' => $VNWeb::Reviews::Elm::VOTE_OUT, {%$w, mod => auth->permBoardmod} if auth && ($w->{can} || auth->permBoardmod);
+ b_ class => 'grayedout', sprintf ' %d/%d', $w->{c_up}, $w->{c_down} if auth->permBoardmod;
+ }
+}
+
+# Mini-reviews don't expand vndbids on submission, so they need an extra bb_subst_links() pass.
+sub reviews_format {
+ my($w, @opt) = @_;
+ bb_format($w->{isfull} ? $w->{text} : bb_subst_links($w->{text}), @opt);
+}
+
+1;
diff --git a/lib/VNWeb/Reviews/List.pm b/lib/VNWeb/Reviews/List.pm
new file mode 100644
index 00000000..94c65625
--- /dev/null
+++ b/lib/VNWeb/Reviews/List.pm
@@ -0,0 +1,85 @@
+package VNWeb::Reviews::List;
+
+use VNWeb::Prelude;
+
+
+sub tablebox_ {
+ my($opt, $lst, $count) = @_;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ paginate_ \&url, $opt->{p}, [$count, 50], 't';
+ div_ class => 'mainbox browse reviewlist', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'id', $opt, \&url; debug_ $lst };
+ td_ class => 'tc2', 'By';
+ td_ class => 'tc3', 'Vote';
+ td_ class => 'tc4', 'Type';
+ td_ class => 'tc5', 'Review';
+ td_ class => 'tc6', sub { txt_ 'Score*'; sortable_ 'rating', $opt, \&url } if auth->isMod;
+ td_ class => 'tc7', 'C#';
+ td_ class => 'tc8', sub { txt_ 'Last comment'; sortable_ 'lastpost', $opt, \&url };
+ } };
+ tr_ sub {
+ td_ class => 'tc1', fmtdate $_->{date}, 'compact';
+ td_ class => 'tc2', sub { user_ $_ };
+ td_ class => 'tc3', fmtvote $_->{vote};
+ td_ class => 'tc4', $_->{isfull} ? 'Full' : 'Mini';
+ td_ class => 'tc5', sub { a_ href => "/$_->{id}", $_->{title}; b_ class => 'grayedout', ' (flagged)' if $_->{c_flagged} };
+ td_ class => 'tc6', sprintf '👍 %d 👎 %d', $_->{c_up}, $_->{c_down} if auth->isMod;
+ td_ class => 'tc7', $_->{c_count};
+ td_ class => 'tc8', $_->{c_lastnum} ? sub {
+ user_ $_, 'lu_';
+ txt_ ' @ ';
+ a_ href => "/$_->{id}.$_->{c_lastnum}#last", fmtdate $_->{ldate}, 'full';
+ } : '';
+ } for @$lst;
+ };
+ };
+ paginate_ \&url, $opt->{p}, [$count, 50], 'b';
+}
+
+
+TUWF::get qr{/w}, sub {
+ my $opt = tuwf->validate(get =>
+ p => { page => 1 },
+ s => { onerror => 'id', enum => [qw[id lastpost rating]] },
+ o => { onerror => 'd', enum => [qw[a d]] },
+ u => { onerror => 0, id => 1 },
+ )->data;
+ $opt->{s} = 'id' if $opt->{s} eq 'rating' && !auth->isMod;
+
+ my $u = $opt->{u} && tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \$opt->{u});
+ return tuwf->resNotFound if $u && !$u->{id};
+
+ my $where = $u ? sql 'w.uid =', \$u->{id} : '1=1';
+ 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
+ LEFT JOIN users u ON u.id = w.uid
+ LEFT JOIN reviews_posts wp ON w.id = wp.id AND w.c_lastnum = wp.num
+ LEFT JOIN users wpu ON wpu.id = wp.uid
+ LEFT JOIN ulist_vns uv ON uv.uid = w.uid AND uv.vid = w.vid
+ WHERE', $where, '
+ ORDER BY', {id => 'w.id', lastpost => 'wp.date', rating => 'w.c_up-w.c_down'}->{$opt->{s}}, {a=>'ASC',d=>'DESC'}->{$opt->{o}}, 'NULLS LAST'
+ );
+
+ my $title = $u ? 'Reviews by '.user_displayname($u) : 'Browse reviews';
+ framework_ title => $title, $u ? (type => 'u', dbobj => $u, tab => 'reviews') : (), sub {
+ div_ class => 'mainbox', sub {
+ h1_ $title;
+ if($u && !$count) {
+ p_ +(auth && $u->{id} == auth->uid ? 'You have' : user_displayname($u).' has').' not submitted any reviews yet.';
+ }
+ p_ 'Note: The score column is only visible to moderators.' if auth->isMod;
+ };
+ tablebox_ $opt, $lst, $count if $count;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Reviews/Page.pm b/lib/VNWeb/Reviews/Page.pm
new file mode 100644
index 00000000..927a39f4
--- /dev/null
+++ b/lib/VNWeb/Reviews/Page.pm
@@ -0,0 +1,146 @@
+package VNWeb::Reviews::Page;
+
+use VNWeb::Prelude;
+use VNWeb::Releases::Lib;
+use VNWeb::Reviews::Lib;
+
+
+my $COMMENT = form_compile any => {
+ id => { vndbid => 'w' },
+ msg => { maxlength => 32768 }
+};
+
+elm_api ReviewsComment => undef, $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;
+
+ 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";
+};
+
+
+
+sub review_ {
+ my($w) = @_;
+
+ input_ type => 'checkbox', class => 'visuallyhidden', 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 => "/v$w->{vid}", $w->{title};
+ if($w->{rid}) {
+ br_;
+ abbr_ class => "icons $_", title => $PLATFORM{$_}, '' for grep $_ ne 'oth', $w->{platforms}->@*;
+ abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' for $w->{lang}->@*;
+ abbr_ class => "icons rt$w->{rtype}", title => $w->{rtype}, '';
+ a_ href => "/r$w->{rid}", title => $w->{roriginal}||$w->{rtitle}, $w->{rtitle};
+ }
+ };
+ };
+ tr_ sub {
+ td_ 'By';
+ td_ sub {
+ b_ style => 'float: right', 'Vote: '.fmtvote($w->{vote}) if $w->{vote};
+ user_ $w;
+ my($date, $lastmod) = map $_&&fmtdate($_,'compact'), $w->@{'date', 'lastmod'};
+ txt_ " on $date";
+ b_ class => 'grayedout', " last updated on $lastmod" if $lastmod && $date ne $lastmod;
+ br_ if $w->{c_flagged} || $w->{locked};
+ if($w->{c_flagged}) {
+ br_;
+ b_ class => 'grayedout', 'Flagged: this review is below the voting threshold and not visible on the VN page.';
+ }
+ if($w->{locked}) {
+ br_;
+ b_ class => 'grayedout', 'Locked: commenting on this review has been disabled.';
+ }
+ }
+ };
+ tr_ class => 'reviewnotspoil', sub {
+ td_ '';
+ td_ sub {
+ label_ class => 'fake_link', for => 'reviewspoil', 'This review contains spoilers, click to view.';
+ };
+ } if $w->{spoiler};
+ tr_ @spoil, sub {
+ td_ 'Review';
+ td_ sub { lit_ reviews_format $w }
+ };
+ tr_ @spoil, sub {
+ td_ '';
+ td_ style => 'text-align: right', sub {
+ reviews_vote_ $w;
+ };
+ };
+ }
+}
+
+
+TUWF::get qr{/$RE{wid}(?:(?<sep>[\./])$RE{num})?}, sub {
+ my($id, $sep, $num) = (tuwf->capture('id'), tuwf->capture('sep')||'', tuwf->capture('num'));
+ my $w = tuwf->dbRowi(
+ 'SELECT r.id, r.vid, r.rid, r.isfull, r.text, r.spoiler, r.locked, COALESCE(c.count,0) AS count, r.c_flagged, r.c_up, r.c_down, uv.vote, rm.id IS NULL AS can
+ , v.title, rel.title AS rtitle, rel.original AS roriginal, rel.type AS rtype, rv.vote AS my, COALESCE(rv.overrule,false) AS overrule
+ , ', sql_user(), ',', sql_totime('r.date'), 'AS date,', sql_totime('r.lastmod'), 'AS lastmod
+ FROM reviews r
+ JOIN vn v ON v.id = r.vid
+ LEFT JOIN releases rel ON rel.id = r.rid
+ LEFT JOIN users u ON u.id = r.uid
+ LEFT JOIN ulist_vns uv ON uv.uid = r.uid AND uv.vid = r.vid
+ LEFT JOIN (SELECT id, COUNT(*) FROM reviews_posts GROUP BY id) AS c(id,count) ON c.id = r.id
+ LEFT JOIN reviews_votes rv ON rv.id = r.id AND rv.uid =', \auth->uid, '
+ LEFT JOIN reviews rm ON rm.vid = r.vid AND rm.uid =', \auth->uid, '
+ WHERE r.id =', \$id
+ );
+ return tuwf->resNotFound if !$w->{id};
+
+ enrich_flatten lang => rid => id => sub { sql 'SELECT id, lang FROM releases_lang WHERE id IN', $_, 'ORDER BY id, lang' }, $w;
+ enrich_flatten platforms => rid => id => sub { sql 'SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY id, platform' }, $w;
+
+ my $page = $sep eq '/' ? $num||1 : $sep ne '.' ? 1
+ : ceil((tuwf->dbVali('SELECT COUNT(*) FROM reviews_posts WHERE num <=', \$num, 'AND id =', \$id)||9999)/25);
+ $num = 0 if $sep ne '.';
+
+ my $posts = tuwf->dbPagei({ results => 25, page => $page },
+ 'SELECT rp.id, rp.num, rp.hidden, rp.msg',
+ ',', sql_user(),
+ ',', sql_totime('rp.date'), ' as date',
+ ',', sql_totime('rp.edited'), ' as edited
+ FROM reviews_posts rp
+ LEFT JOIN users u ON rp.uid = u.id
+ WHERE rp.id =', \$id, '
+ ORDER BY rp.num'
+ );
+ return tuwf->resNotFound if $num && !grep $_->{num} == $num, @$posts;
+
+ # Mark a notification for this thread as read, if there is one.
+ tuwf->dbExeci(
+ 'UPDATE notifications SET read = NOW() WHERE uid =', \auth->uid, 'AND iid =', \$id, 'AND read IS NULL'
+ ) if auth && $w->{count} <= $page*25;
+
+ my $title = "Review of $w->{title}";
+ framework_ title => $title, index => 1, type => 'w', dbobj => $w,
+ $num||$page>1 ? (pagevars => {sethash=>$num?$num:'threadstart'}) : (),
+ sub {
+ div_ class => 'mainbox', sub {
+ itemmsg_ w => $w;
+ h1_ $title;
+ review_ $w;
+ };
+ if(grep !$_->{hidden}, @$posts) {
+ h1_ class => 'boxtitle', 'Comments';
+ VNWeb::Discussions::Thread::posts_($w, $posts, $page);
+ } else {
+ div_ id => 'threadstart', '';
+ }
+ elm_ 'Reviews.Comment' => $COMMENT, { id => $w->{id}, msg => '' } if $w->{count} <= $page*25 && can_edit t => $w;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Reviews/VNTab.pm b/lib/VNWeb/Reviews/VNTab.pm
new file mode 100644
index 00000000..796193b1
--- /dev/null
+++ b/lib/VNWeb/Reviews/VNTab.pm
@@ -0,0 +1,87 @@
+package VNWeb::Reviews::VNTab;
+
+use VNWeb::Prelude;
+use VNWeb::Reviews::Lib;
+
+
+sub reviews_ {
+ my($v, $mini) = @_;
+
+ # TODO: Better order, pagination, option to show flagged reviews
+ my $lst = tuwf->dbAlli(
+ 'SELECT r.id, r.rid, r.text, r.spoiler, r.c_count, r.c_up, r.c_down, uv.vote, rv.vote AS my, COALESCE(rv.overrule,false) AS overrule, NOT r.isfull AND rm.id IS NULL AS can
+ , ', sql_totime('r.date'), 'AS date, ', sql_user(), '
+ FROM reviews r
+ LEFT JOIN users u ON r.uid = u.id
+ LEFT JOIN ulist_vns uv ON uv.uid = r.uid AND uv.vid = r.vid
+ LEFT JOIN reviews_votes rv ON rv.uid =', \auth->uid, ' AND rv.id = r.id
+ LEFT JOIN reviews rm ON rm.vid = r.vid AND rm.uid =', \auth->uid, '
+ WhERE NOT r.c_flagged AND r.vid =', \$v->{id}, 'AND', ($mini ? 'NOT' : ''), 'r.isfull
+ ORDER BY r.c_up-r.c_down DESC'
+ );
+ return if !@$lst;
+
+ div_ class => 'mainbox', 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' };
+ a_ href => "/r$r->{rid}", "r$r->{rid}" if $r->{rid};
+ span_ "Vote: ".fmtvote($r->{vote}) if $r->{vote};
+ };
+ 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 .= '...' 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;
+ }
+ };
+ div_ sub {
+ a_ href => "/$r->{id}#review", 'Full review »' if !$mini;
+ a_ href => "/$r->{id}#threadstart", $r->{c_count} == 1 ? '1 comment' : "$r->{c_count} comments";
+ reviews_vote_ $r;
+ };
+ } for @$lst;
+ }
+ };
+}
+
+
+TUWF::get qr{/$RE{vid}/(?<mini>mini|full)?reviews}, sub {
+ my $mini = !tuwf->capture('mini') ? undef : tuwf->capture('mini') eq 'mini' ? 1 : 0;
+ my $v = db_entry v => tuwf->capture('id');
+ return tuwf->resNotFound if !$v;
+ VNWeb::VN::Page::enrich_vn($v);
+
+ framework_ title => ($mini?'Mini reviews':'Reviews')." for $v->{title}", index => 1, type => 'v', dbobj => $v, hiddenmsg => 1,
+ sub {
+ VNWeb::VN::Page::infobox_($v);
+ VNWeb::VN::Page::tabs_($v, !defined $mini ? 'reviews' : $mini ? 'minireviews' : 'fullreviews');
+ if(defined $mini) {
+ reviews_ $v, $mini;
+ } else {
+ reviews_ $v, 1;
+ reviews_ $v, 0;
+ }
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Staff/Elm.pm b/lib/VNWeb/Staff/Elm.pm
new file mode 100644
index 00000000..c4db154f
--- /dev/null
+++ b/lib/VNWeb/Staff/Elm.pm
@@ -0,0 +1,25 @@
+package VNWeb::Staff::Elm;
+
+use VNWeb::Prelude;
+
+elm_api Staff => undef, { search => {} }, sub {
+ my $q = shift->{search};
+ my $qs = sql_like $q;
+
+ 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
+ WHERE NOT s.hidden
+ GROUP BY s.id, sa.aid, sa.name, sa.original
+ ORDER BY MIN(x.prio), sa.name
+ ');
+};
+
+1;
diff --git a/lib/VNWeb/Staff/Page.pm b/lib/VNWeb/Staff/Page.pm
index 72227559..8f6e0897 100644
--- a/lib/VNWeb/Staff/Page.pm
+++ b/lib/VNWeb/Staff/Page.pm
@@ -171,7 +171,7 @@ TUWF::get qr{/$RE{srev}} => sub {
framework_ title => $main->{name}, index => !tuwf->capture('rev'), type => 's', dbobj => $s, hiddenmsg => 1,
og => {
- description => bb2text $s->{desc}
+ description => bb_format $s->{desc}, text => 1
},
sub {
_rev_ $s if tuwf->capture('rev');
@@ -180,7 +180,7 @@ TUWF::get qr{/$RE{srev}} => sub {
h1_ sub { txt_ $main->{name}; debug_ $s };
h2_ class => 'alttitle', lang => $s->{lang}, $main->{original} if $main->{original};
_infotable_ $main, $s;
- p_ class => 'description', sub { lit_ bb2html $s->{desc} };
+ p_ class => 'description', sub { lit_ bb_format $s->{desc} };
};
_roles_ $s;
diff --git a/lib/VNWeb/Tags/Elm.pm b/lib/VNWeb/Tags/Elm.pm
index 0f816bad..089487d7 100644
--- a/lib/VNWeb/Tags/Elm.pm
+++ b/lib/VNWeb/Tags/Elm.pm
@@ -4,7 +4,7 @@ use VNWeb::Prelude;
elm_api Tags => undef, { search => {} }, sub {
my $q = shift->{search};
- my $qs = $q =~ s/[%_]//gr;
+ my $qs = sql_like $q;
elm_TagResult tuwf->dbPagei({ results => 15, page => 1 },
'SELECT t.id, t.name, t.searchable, t.applicable, t.state
diff --git a/lib/VNWeb/Traits/Elm.pm b/lib/VNWeb/Traits/Elm.pm
index c913f421..fc0d0207 100644
--- a/lib/VNWeb/Traits/Elm.pm
+++ b/lib/VNWeb/Traits/Elm.pm
@@ -4,7 +4,7 @@ use VNWeb::Prelude;
elm_api Traits => undef, { search => {} }, sub {
my $q = shift->{search};
- my $qs = $q =~ s/[%_]//gr;
+ 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
diff --git a/lib/VNWeb/ULists/Elm.pm b/lib/VNWeb/ULists/Elm.pm
new file mode 100644
index 00000000..4fd032dc
--- /dev/null
+++ b/lib/VNWeb/ULists/Elm.pm
@@ -0,0 +1,265 @@
+package VNWeb::ULists::Elm;
+
+use VNWeb::Prelude;
+use VNWeb::ULists::Lib;
+
+
+# Should be called after any change to the ulist_* tables.
+# (Normally I'd do this with triggers, but that seemed like a more complex and less efficient solution in this case)
+sub updcache {
+ tuwf->dbExeci(SELECT => sql_func update_users_ulist_stats => \shift);
+}
+
+
+our $LABELS = form_compile any => {
+ uid => { id => 1 },
+ labels => { aoh => {
+ id => { int => 1 },
+ label => { maxlength => 50 },
+ private => { anybool => 1 },
+ count => { uint => 1 },
+ delete => { required => 0, default => undef, uint => 1, range => [1, 3] }, # 1=keep vns, 2=delete when no other label, 3=delete all
+ } }
+};
+
+elm_api UListManageLabels => undef, $LABELS, sub {
+ my($uid, $labels) = ($_[0]{uid}, $_[0]{labels});
+ return elm_Unauth if !ulists_own $uid;
+
+ # Insert new labels
+ my @new = grep $_->{id} < 0 && !$_->{delete}, @$labels;
+ # Subquery to get the lowest unused id
+ my $newid = sql '(
+ SELECT min(x.n)
+ FROM generate_series(10,
+ greatest((SELECT max(id)+1 from ulist_labels ul WHERE ul.uid =', \$uid, '), 10)
+ ) x(n)
+ WHERE NOT EXISTS(SELECT 1 FROM ulist_labels ul WHERE ul.uid =', \$uid, 'AND ul.id = x.n)
+ )';
+ tuwf->dbExeci('INSERT INTO ulist_labels', { id => $newid, uid => $uid, label => $_->{label}, private => $_->{private} }) for @new;
+
+ # Update private flag
+ tuwf->dbExeci(
+ 'UPDATE ulist_labels SET private =', \$_->{private},
+ 'WHERE uid =', \$uid, 'AND id =', \$_->{id}, 'AND private <>', \$_->{private}
+ ) for grep $_->{id} > 0 && !$_->{delete}, @$labels;
+
+ # Update label
+ tuwf->dbExeci(
+ 'UPDATE ulist_labels SET label =', \$_->{label},
+ 'WHERE uid =', \$uid, 'AND id =', \$_->{id}, 'AND label <>', \$_->{label}
+ ) for grep $_->{id} >= 10 && !$_->{delete}, @$labels;
+
+ # Delete labels
+ my @delete = grep $_->{id} >= 10 && $_->{delete}, @$labels;
+ my @delete_lblonly = map $_->{id}, grep $_->{delete} == 1, @delete;
+ my @delete_empty = map $_->{id}, grep $_->{delete} == 2, @delete;
+ my @delete_all = map $_->{id}, grep $_->{delete} == 3, @delete;
+
+ # delete vns with: (a label in option 3) OR ((a label in option 2) AND (no labels other than in option 1 or 2))
+ my @where =
+ @delete_all ? sql('vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN', \@delete_all, ')') : (),
+ @delete_empty ? sql(
+ 'vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN', \@delete_empty, ')',
+ 'AND NOT EXISTS(SELECT 1 FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl NOT IN(', [ @delete_lblonly, @delete_empty ], '))'
+ ) : ();
+ tuwf->dbExeci('DELETE FROM ulist_vns WHERE uid =', \$uid, 'AND (', sql_or(@where), ')') if @where;
+
+ # (This will also delete all relevant vn<->label rows from ulist_vns_labels)
+ tuwf->dbExeci('DELETE FROM ulist_labels WHERE uid =', \$uid, 'AND id IN', [ map $_->{id}, @delete ]) if @delete;
+
+ updcache $uid;
+ elm_Success
+};
+
+
+
+
+our $VNVOTE = form_compile any => {
+ uid => { id => 1 },
+ vid => { id => 1 },
+ vote => { vnvote => 1 },
+};
+
+elm_api UListVoteEdit => undef, $VNVOTE, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ tuwf->dbExeci(
+ 'INSERT INTO ulist_vns', { %$data, vote_date => sql $data->{vote} ? 'NOW()' : 'NULL' },
+ 'ON CONFLICT (uid, vid) DO UPDATE
+ SET', { %$data,
+ lastmod => sql('NOW()'),
+ vote_date => sql $data->{vote} ? 'CASE WHEN ulist_vns.vote IS NULL THEN NOW() ELSE ulist_vns.vote_date END' : 'NULL'
+ }
+ );
+ updcache $data->{uid};
+ elm_Success
+};
+
+
+
+
+my $VNLABELS = {
+ uid => { id => 1 },
+ vid => { id => 1 },
+ label => { _when => 'in', id => 1 },
+ applied => { _when => 'in', anybool => 1 },
+ labels => { _when => 'out', aoh => { id => { int => 1 }, label => {}, private => { anybool => 1 } } },
+ selected => { _when => 'out', type => 'array', values => { id => 1 } },
+};
+
+our $VNLABELS_OUT = form_compile out => $VNLABELS;
+my $VNLABELS_IN = form_compile in => $VNLABELS;
+
+elm_api UListLabelEdit => $VNLABELS_OUT, $VNLABELS_IN, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ die "Attempt to set vote label" if $data->{label} == 7;
+
+ tuwf->dbExeci('INSERT INTO ulist_vns', {uid => $data->{uid}, vid => $data->{vid}}, 'ON CONFLICT (uid, vid) DO NOTHING');
+ tuwf->dbExeci(
+ 'DELETE FROM ulist_vns_labels
+ WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid}, 'AND lbl =', \$data->{label}
+ ) if !$data->{applied};
+ tuwf->dbExeci(
+ 'INSERT INTO ulist_vns_labels', { uid => $data->{uid}, vid => $data->{vid}, lbl => $data->{label} },
+ 'ON CONFLICT (uid, vid, lbl) DO NOTHING'
+ ) if $data->{applied};
+ tuwf->dbExeci('UPDATE ulist_vns SET lastmod = NOW() WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid});
+
+ updcache $data->{uid};
+ elm_Success
+};
+
+
+
+
+our $VNDATE = form_compile any => {
+ uid => { id => 1 },
+ vid => { id => 1 },
+ date => { required => 0, default => '', regex => qr/^(?:19[7-9][0-9]|20[0-9][0-9])-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/ }, # 1970 - 2099 for sanity
+ start => { anybool => 1 }, # Field selection, started/finished
+};
+
+elm_api UListDateEdit => undef, $VNDATE, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ tuwf->dbExeci(
+ 'UPDATE ulist_vns SET lastmod = NOW(), ', $data->{start} ? 'started' : 'finished', '=', \($data->{date}||undef),
+ 'WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid}
+ );
+ updcache $data->{uid};
+ elm_Success
+};
+
+
+
+
+our $VNOPT = form_compile any => {
+ own => { anybool => 1 },
+ uid => { id => 1 },
+ vid => { id => 1 },
+ notes => {},
+ rels => $VNWeb::Elm::apis{Releases}[0],
+ relstatus => { type => 'array', values => { uint => 1 } }, # List of release statuses, same order as rels
+};
+
+
+our $VNPAGE = form_compile any => {
+ uid => { id => 1 },
+ vid => { id => 1 },
+ 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
+elm_api UListVNNotes => $VNOPT, {
+ uid => { id => 1 },
+ vid => { id => 1 },
+ notes => { required => 0, default => '', maxlength => 2000 },
+}, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ tuwf->dbExeci(
+ 'INSERT INTO ulist_vns', \%$data, 'ON CONFLICT (uid, vid) DO UPDATE SET', { %$data, lastmod => sql('NOW()') }
+ );
+ # Doesn't need `updcache()`
+ elm_Success
+}, VNPage => $VNPAGE;
+
+
+
+
+elm_api UListDel => undef, {
+ uid => { id => 1 },
+ vid => { id => 1 },
+}, sub {
+ my($data) = @_;
+ return elm_Unauth if !ulists_own $data->{uid};
+ tuwf->dbExeci('DELETE FROM ulist_vns WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid});
+ updcache $data->{uid};
+ elm_Success
+};
+
+
+
+
+# Adds the release when not in the list.
+# $RLIST_STATUS is also referenced from VNWeb::Releases::Page.
+our $RLIST_STATUS = form_compile any => {
+ uid => { id => 1 },
+ rid => { id => 1 },
+ status => { required => 0, uint => 1, enum => \%RLIST_STATUS }, # undef meaning delete
+ empty => { required => 0, default => '' }, # An 'out' field
+};
+elm_api UListRStatus => undef, $RLIST_STATUS, sub {
+ my($data) = @_;
+ delete $data->{empty};
+ return elm_Unauth if !ulists_own $data->{uid};
+ if(!defined $data->{status}) {
+ tuwf->dbExeci('DELETE FROM rlists WHERE uid =', \$data->{uid}, 'AND rid =', \$data->{rid})
+ } else {
+ tuwf->dbExeci('INSERT INTO rlists', $data, 'ON CONFLICT (uid, rid) DO UPDATE SET status =', \$data->{status})
+ }
+ # Doesn't need `updcache()`
+ elm_Success
+};
+
+
+
+
+our %SAVED_OPTS = (
+ # Labels
+ l => { onerror => [], type => 'array', scalar => 1, values => { int => 1 } },
+ mul => { anybool => 1 },
+ # Sort column & order
+ s => { onerror => 'title', enum => [qw[ title label vote voted added modified started finished rel rating ]] },
+ o => { onerror => 'a', enum => ['a', 'd'] },
+ # Visible columns
+ c => { onerror => [], type => 'array', scalar => 1, values => { enum => [qw[ label vote voted added modified started finished rel rating ]] } },
+);
+
+my $SAVED_OPTS = {
+ uid => { id => 1 },
+ opts => { type => 'hash', keys => \%SAVED_OPTS },
+ field => { _when => 'in', enum => [qw/ vnlist votes wish /] },
+};
+
+my $SAVED_OPTS_IN = form_compile in => $SAVED_OPTS;
+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});
+ elm_Success
+};
+
+1;
diff --git a/lib/VNWeb/ULists/Export.pm b/lib/VNWeb/ULists/Export.pm
new file mode 100644
index 00000000..acfbcccf
--- /dev/null
+++ b/lib/VNWeb/ULists/Export.pm
@@ -0,0 +1,101 @@
+package VNWeb::ULists::Export;
+
+use TUWF::XML ':xml';
+use VNWeb::Prelude;
+use VNWeb::ULists::Lib;
+
+# XXX: Reading someone's entire list into memory (multiple times even) is not
+# the most efficient way to implement an export function. Might want to switch
+# to an async background process for this to reduce the footprint of web
+# workers.
+
+sub data {
+ my($uid) = @_;
+
+ # We'd like ISO7601/RFC3339 timestamps in UTC with accuracy to the second.
+ my sub tz { sql 'to_char(', $_[0], ' at time zone \'utc\',', \'YYYY-MM-DD"T"HH24:MM:SS"Z"', ') as', $_[1] }
+
+ 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')), '
+ FROM ulist_vns uv
+ JOIN vn v ON v.id = uv.vid
+ WHERE uv.uid =', \$uid, '
+ ORDER BY v.title')
+ };
+ 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'), '
+ FROM rlists rl
+ JOIN releases 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};
+ $d
+}
+
+
+sub filename {
+ my($d, $ext) = @_;
+ my $date = $d->{'export-date'} =~ s/[-TZ:]//rg;
+ "vndb-list-export-$d->{user}{name}-$date.$ext"
+}
+
+
+TUWF::get qr{/$RE{uid}/list-export/xml}, sub {
+ my $uid = tuwf->capture('id');
+ return tuwf->resDenied if !ulists_own $uid;
+ my $d = data $uid;
+ return tuwf->resNotFound if !$d->{user}{id};
+
+ tuwf->resHeader('Content-Disposition', sprintf 'attachment; filename="%s"', filename $d, 'xml');
+ tuwf->resHeader('Content-Type', 'application/xml; charset=UTF-8');
+
+ my $fd = tuwf->resFd;
+ TUWF::XML->new(
+ write => sub { print $fd $_ for @_ },
+ pretty => 2,
+ default => 1,
+ );
+ xml;
+ tag 'vndb-export' => version => '1.0', date => $d->{'export-date'}, sub {
+ tag user => sub {
+ tag name => $d->{user}{name};
+ tag url => config->{url}.'/u'.$d->{user}{id};
+ };
+ tag labels => sub {
+ tag label => id => $_->{id}, label => $_->{label}, private => $_->{private}?'true':'false', undef for $d->{labels}->@*;
+ };
+ tag vns => sub {
+ tag vn => id => "v$_->{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 added => $_->{added};
+ tag modified => $_->{lastmod} if $_->{added} ne $_->{lastmod};
+ tag vote => timestamp => $_->{vote_date}, fmtvote $_->{vote} if $_->{vote};
+ tag started => $_->{started} if $_->{started};
+ tag finished => $_->{finished} if $_->{finished};
+ tag notes => $_->{notes} if length $_->{notes};
+ tag release => id => "r$_->{id}", sub {
+ tag title => length($_->{original}) ? (original => $_->{original}) : (), $_->{title};
+ tag 'release-date' => rdate $_->{released};
+ tag status => $RLIST_STATUS{$_->{status}};
+ tag added => $_->{added};
+ } for $_->{releases}->@*;
+ } for $d->{vns}->@*;
+ };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/ULists/Lib.pm b/lib/VNWeb/ULists/Lib.pm
new file mode 100644
index 00000000..d831692a
--- /dev/null
+++ b/lib/VNWeb/ULists/Lib.pm
@@ -0,0 +1,13 @@
+package VNWeb::ULists::Lib;
+
+use VNWeb::Prelude;
+use Exporter 'import';
+
+our @EXPORT = qw/ulists_own/;
+
+# 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 == shift)
+}
+
+1;
diff --git a/lib/VNWeb/User/Lists.pm b/lib/VNWeb/ULists/List.pm
index bbc986d2..dbdfddc9 100644
--- a/lib/VNWeb/User/Lists.pm
+++ b/lib/VNWeb/ULists/List.pm
@@ -1,269 +1,8 @@
-package VNWeb::User::Lists;
+package VNWeb::ULists::Main;
use VNWeb::Prelude;
-use POSIX 'strftime';
-
-
-# Do we have "ownership" access to this users' list (i.e. can we edit and see private stuff)?
-sub own {
- auth->permUsermod || (auth && auth->uid == shift)
-}
-
-
-# Should be called after any change to the ulist_* tables.
-# (Normally I'd do this with triggers, but that seemed like a more complex and less efficient solution in this case)
-sub updcache {
- tuwf->dbExeci(SELECT => sql_func update_users_ulist_stats => \shift);
-}
-
-
-my $LABELS = form_compile any => {
- uid => { id => 1 },
- labels => { aoh => {
- id => { int => 1 },
- label => { maxlength => 50 },
- private => { anybool => 1 },
- count => { uint => 1 },
- delete => { required => 0, default => undef, uint => 1, range => [1, 3] }, # 1=keep vns, 2=delete when no other label, 3=delete all
- } }
-};
-
-elm_api UListManageLabels => undef, $LABELS, sub {
- my($uid, $labels) = ($_[0]{uid}, $_[0]{labels});
- return elm_Unauth if !own $uid;
-
- # Insert new labels
- my @new = grep $_->{id} < 0 && !$_->{delete}, @$labels;
- # Subquery to get the lowest unused id
- my $newid = sql '(
- SELECT min(x.n)
- FROM generate_series(10,
- greatest((SELECT max(id)+1 from ulist_labels ul WHERE ul.uid =', \$uid, '), 10)
- ) x(n)
- WHERE NOT EXISTS(SELECT 1 FROM ulist_labels ul WHERE ul.uid =', \$uid, 'AND ul.id = x.n)
- )';
- tuwf->dbExeci('INSERT INTO ulist_labels', { id => $newid, uid => $uid, label => $_->{label}, private => $_->{private} }) for @new;
-
- # Update private flag
- tuwf->dbExeci(
- 'UPDATE ulist_labels SET private =', \$_->{private},
- 'WHERE uid =', \$uid, 'AND id =', \$_->{id}, 'AND private <>', \$_->{private}
- ) for grep $_->{id} > 0 && !$_->{delete}, @$labels;
-
- # Update label
- tuwf->dbExeci(
- 'UPDATE ulist_labels SET label =', \$_->{label},
- 'WHERE uid =', \$uid, 'AND id =', \$_->{id}, 'AND label <>', \$_->{label}
- ) for grep $_->{id} >= 10 && !$_->{delete}, @$labels;
-
- # Delete labels
- my @delete = grep $_->{id} >= 10 && $_->{delete}, @$labels;
- my @delete_lblonly = map $_->{id}, grep $_->{delete} == 1, @delete;
- my @delete_empty = map $_->{id}, grep $_->{delete} == 2, @delete;
- my @delete_all = map $_->{id}, grep $_->{delete} == 3, @delete;
-
- # delete vns with: (a label in option 3) OR ((a label in option 2) AND (no labels other than in option 1 or 2))
- my @where =
- @delete_all ? sql('vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN', \@delete_all, ')') : (),
- @delete_empty ? sql(
- 'vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN', \@delete_empty, ')',
- 'AND NOT EXISTS(SELECT 1 FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl NOT IN(', [ @delete_lblonly, @delete_empty ], '))'
- ) : ();
- tuwf->dbExeci('DELETE FROM ulist_vns WHERE uid =', \$uid, 'AND (', sql_or(@where), ')') if @where;
-
- # (This will also delete all relevant vn<->label rows from ulist_vns_labels)
- tuwf->dbExeci('DELETE FROM ulist_labels WHERE uid =', \$uid, 'AND id IN', [ map $_->{id}, @delete ]) if @delete;
-
- updcache $uid;
- elm_Success
-};
-
-
-
-
-my $VNVOTE = form_compile any => {
- uid => { id => 1 },
- vid => { id => 1 },
- vote => { vnvote => 1 },
-};
-
-elm_api UListVoteEdit => undef, $VNVOTE, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- tuwf->dbExeci(
- 'INSERT INTO ulist_vns', { %$data, vote_date => sql $data->{vote} ? 'NOW()' : 'NULL' },
- 'ON CONFLICT (uid, vid) DO UPDATE
- SET', { %$data,
- lastmod => sql('NOW()'),
- vote_date => sql $data->{vote} ? 'CASE WHEN ulist_vns.vote IS NULL THEN NOW() ELSE ulist_vns.vote_date END' : 'NULL'
- }
- );
- updcache $data->{uid};
- elm_Success
-};
-
-
-
-
-my $VNLABELS = {
- uid => { id => 1 },
- vid => { id => 1 },
- label => { _when => 'in', id => 1 },
- applied => { _when => 'in', anybool => 1 },
- labels => { _when => 'out', aoh => { id => { int => 1 }, label => {}, private => { anybool => 1 } } },
- selected => { _when => 'out', type => 'array', values => { id => 1 } },
-};
-
-my $VNLABELS_OUT = form_compile out => $VNLABELS;
-my $VNLABELS_IN = form_compile in => $VNLABELS;
-
-elm_api UListLabelEdit => $VNLABELS_OUT, $VNLABELS_IN, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- die "Attempt to set vote label" if $data->{label} == 7;
-
- tuwf->dbExeci('INSERT INTO ulist_vns', {uid => $data->{uid}, vid => $data->{vid}}, 'ON CONFLICT (uid, vid) DO NOTHING');
- tuwf->dbExeci(
- 'DELETE FROM ulist_vns_labels
- WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid}, 'AND lbl =', \$data->{label}
- ) if !$data->{applied};
- tuwf->dbExeci(
- 'INSERT INTO ulist_vns_labels', { uid => $data->{uid}, vid => $data->{vid}, lbl => $data->{label} },
- 'ON CONFLICT (uid, vid, lbl) DO NOTHING'
- ) if $data->{applied};
- tuwf->dbExeci('UPDATE ulist_vns SET lastmod = NOW() WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid});
-
- updcache $data->{uid};
- elm_Success
-};
-
-
-
-
-my $VNDATE = form_compile any => {
- uid => { id => 1 },
- vid => { id => 1 },
- date => { required => 0, default => '', regex => qr/^(?:19[7-9][0-9]|20[0-9][0-9])-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/ }, # 1970 - 2099 for sanity
- start => { anybool => 1 }, # Field selection, started/finished
-};
-
-elm_api UListDateEdit => undef, $VNDATE, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- tuwf->dbExeci(
- 'UPDATE ulist_vns SET lastmod = NOW(), ', $data->{start} ? 'started' : 'finished', '=', \($data->{date}||undef),
- 'WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid}
- );
- updcache $data->{uid};
- elm_Success
-};
-
-
-
-
-my $VNOPT = form_compile any => {
- own => { anybool => 1 },
- uid => { id => 1 },
- vid => { id => 1 },
- notes => {},
- rels => { aoh => { # Same structure as 'elm_Releases' response
- id => { id => 1 },
- title => {},
- original => {},
- released => { uint => 1 },
- rtype => {},
- lang => { type => 'array', values => {} },
- platforms=> { type => 'array', values => {} },
- } },
- relstatus => { type => 'array', values => { uint => 1 } }, # List of release statuses, same order as rels
-};
-
-
-
-# UListVNNotes module is abused for the UList.Opts flag definition
-elm_api UListVNNotes => $VNOPT, {
- uid => { id => 1 },
- vid => { id => 1 },
- notes => { required => 0, default => '', maxlength => 2000 },
-}, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- tuwf->dbExeci(
- 'INSERT INTO ulist_vns', \%$data, 'ON CONFLICT (uid, vid) DO UPDATE SET', { %$data, lastmod => sql('NOW()') }
- );
- # Doesn't need `updcache()`
- elm_Success
-};
-
-
-
-
-elm_api UListDel => undef, {
- uid => { id => 1 },
- vid => { id => 1 },
-}, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- tuwf->dbExeci('DELETE FROM ulist_vns WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid});
- updcache $data->{uid};
- elm_Success
-};
-
-
-
-
-# Adds the release when not in the list.
-# $RLIST_STATUS is also referenced from VNWeb::Releases::Page.
-our $RLIST_STATUS = form_compile any => {
- uid => { id => 1 },
- rid => { id => 1 },
- status => { required => 0, uint => 1, enum => \%RLIST_STATUS }, # undef meaning delete
- empty => { required => 0, default => '' }, # An 'out' field
-};
-elm_api UListRStatus => undef, $RLIST_STATUS, sub {
- my($data) = @_;
- delete $data->{empty};
- return elm_Unauth if !own $data->{uid};
- if(!defined $data->{status}) {
- tuwf->dbExeci('DELETE FROM rlists WHERE uid =', \$data->{uid}, 'AND rid =', \$data->{rid})
- } else {
- tuwf->dbExeci('INSERT INTO rlists', $data, 'ON CONFLICT (uid, rid) DO UPDATE SET status =', \$data->{status})
- }
- # Doesn't need `updcache()`
- elm_Success
-};
-
-
-
-
-my %SAVED_OPTS = (
- # Labels
- l => { onerror => [], type => 'array', scalar => 1, values => { int => 1 } },
- mul => { anybool => 1 },
- # Sort column & order
- s => { onerror => 'title', enum => [qw[ title label vote voted added modified started finished rel rating ]] },
- o => { onerror => 'a', enum => ['a', 'd'] },
- # Visible columns
- c => { onerror => [], type => 'array', scalar => 1, values => { enum => [qw[ label vote voted added modified started finished rel rating ]] } },
-);
-
-my $SAVED_OPTS = {
- uid => { id => 1 },
- opts => { type => 'hash', keys => \%SAVED_OPTS },
- field => { _when => 'in', enum => [qw/ vnlist votes wish /] },
-};
-
-my $SAVED_OPTS_IN = form_compile in => $SAVED_OPTS;
-my $SAVED_OPTS_OUT = form_compile out => $SAVED_OPTS;
-
-elm_api UListSaveDefault => $SAVED_OPTS_OUT, $SAVED_OPTS_IN, sub {
- my($data) = @_;
- return elm_Unauth if !own $data->{uid};
- tuwf->dbExeci('UPDATE users SET ulist_'.$data->{field}, '=', \JSON::XS->new->encode($data->{opts}), 'WHERE id =', \$data->{uid});
- elm_Success
-};
-
-
+use VNWeb::ULists::Lib;
+use VNWeb::Releases::Lib;
sub opt {
@@ -281,7 +20,7 @@ sub opt {
p => { upage => 1 },
ch=> { onerror => undef, enum => [ 'a'..'z', 0 ] },
q => { onerror => undef },
- %SAVED_OPTS
+ %VNWeb::ULists::Elm::SAVED_OPTS
)->data;
# $labels only includes labels we are allowed to see, getting rid of any labels in 'l' that aren't in $labels ensures we only filter on visible labels
@@ -338,6 +77,7 @@ sub filters_ {
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;
};
};
}
@@ -372,7 +112,7 @@ sub vn_ {
td_ mkclass(tc_vote => 1, compact => $own, stealth => $own), sub {
txt_ fmtvote $v->{vote} if !$own;
- elm_ 'UList.VoteEdit' => $VNVOTE, { uid => $uid, vid => $v->{id}, vote => fmtvote($v->{vote}) }, fmtvote $v->{vote}
+ 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};
@@ -385,7 +125,7 @@ sub vn_ {
my @l = grep $labels{$_->{id}} && $_->{id} != 7, @$labels;
my $txt = @l ? join ', ', map $_->{label}, @l : '-';
if($own) {
- elm_ 'UList.LabelEdit' => $VNLABELS_OUT, { vid => $v->{id}, selected => [ grep $_ != 7, $v->{labels}->@* ] }, $txt;
+ elm_ 'UList.LabelEdit' => $VNWeb::ULists::Elm::VNLABELS_OUT, { vid => $v->{id}, selected => [ grep $_ != 7, $v->{labels}->@* ] }, $txt;
} else {
txt_ $txt;
}
@@ -401,12 +141,12 @@ sub vn_ {
td_ class => 'tc_started', sub {
txt_ $v->{started}||'' if !$own;
- elm_ 'UList.DateEdit' => $VNDATE, { uid => $uid, vid => $v->{id}, date => $v->{started}||'', start => 1 }, $v->{started}||'' if $own;
+ 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};
td_ class => 'tc_finished', sub {
txt_ $v->{finished}||'' if !$own;
- elm_ 'UList.DateEdit' => $VNDATE, { uid => $uid, vid => $v->{id}, date => $v->{finished}||'', start => 0 }, $v->{finished}||'' if $own;
+ 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};
td_ class => 'tc_rel', sub { rdate_ $v->{c_released} } if in rel => $opt->{c};
@@ -415,7 +155,7 @@ sub vn_ {
tr_ mkclass(hidden => 1, 'collapsed_vid'.$v->{id} => 1, odd => $n % 2 == 0), sub {
td_ colspan => 7, class => 'tc_opt', sub {
my $relstatus = [ map $_->{status}, $v->{rels}->@* ];
- elm_ 'UList.Opt' => $VNOPT, { own => $own, uid => $uid, vid => $v->{id}, notes => $v->{notes}, rels => $v->{rels}, relstatus => $relstatus };
+ elm_ 'UList.Opt' => $VNWeb::ULists::Elm::VNOPT, { own => $own, uid => $uid, vid => $v->{id}, notes => $v->{notes}, rels => $v->{rels}, relstatus => $relstatus };
};
};
}
@@ -469,21 +209,19 @@ sub listing_ {
enrich_flatten labels => id => vid => sql('SELECT vid, lbl FROM ulist_vns_labels WHERE uid =', \$uid, 'AND vid IN'), $lst;
enrich rels => id => vid => sub { sql '
- SELECT rv.vid, r.id, r.title, r.original, r.released, r.type as rtype, rl.status
+ SELECT rv.vid, r.id, rl.status
FROM rlists rl
JOIN releases r ON rl.rid = r.id
JOIN releases_vn rv ON rv.id = r.id
WHERE rl.uid =', \$uid, '
AND rv.vid IN', $_, '
- ORDER BY r.released ASC'
+ ORDER BY r.released, r.title, r.id'
}, $lst;
-
- enrich_flatten lang => id => id => sub { sql('SELECT id, lang FROM releases_lang WHERE id IN', $_, 'ORDER BY lang') }, map $_->{rels}, @$lst;
- enrich_flatten platforms => id => id => sub { sql('SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY platform') }, map $_->{rels}, @$lst;
+ enrich_release_elm map $_->{rels}, @$lst;
# TODO: Thumbnail view?
paginate_ $url, $opt->{p}, [ $count, 50 ], 't', sub {
- elm_ ColSelect => undef, [ $url->(), [
+ elm_ ColSelect => 'raw', [ $url->(), [
[ voted => 'Vote date' ],
[ vote => 'Vote' ],
[ rating => 'Rating' ],
@@ -525,7 +263,7 @@ TUWF::get qr{/$RE{uid}/ulist}, sub {
my $u = tuwf->dbRowi('SELECT id,', sql_user(), ', ulist_votes, ulist_vnlist, ulist_wish FROM users u WHERE id =', \tuwf->capture('id'));
return tuwf->resNotFound if !$u->{id};
- my $own = own $u->{id};
+ my $own = ulists_own $u->{id};
# Visible and selectable labels
my $labels = tuwf->dbAlli(
@@ -570,7 +308,7 @@ TUWF::get qr{/$RE{uid}/ulist}, sub {
framework_ title => $title, type => 'u', dbobj => $u, tab => $tab, js => 1,
$own ? ( pagevars => {
uid => $u->{id}*1,
- labels => $LABELS->analyze->{keys}{labels}->coerce_for_json($labels),
+ labels => $VNWeb::ULists::Elm::LABELS->analyze->{keys}{labels}->coerce_for_json($labels),
voteprivate => (map \($_->{private}?1:0), grep $_->{id} == 7, @$labels),
} ) : (),
sub {
@@ -584,7 +322,16 @@ TUWF::get qr{/$RE{uid}/ulist}, sub {
} else {
filters_ $own, $filtlabels, $opt, $opt_labels, \&url;
elm_ 'UList.ManageLabels' if $own;
- elm_ 'UList.SaveDefault', $SAVED_OPTS_OUT, { uid => $u->{id}, opts => $opt } if $own;
+ 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$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/Edit.pm b/lib/VNWeb/User/Edit.pm
index e4cc2313..76dbab5c 100644
--- a/lib/VNWeb/User/Edit.pm
+++ b/lib/VNWeb/User/Edit.pm
@@ -32,7 +32,6 @@ my $FORM = {
# Settings that can only be read/modified by the user itself or a perm_usermod
prefs => { required => 0, type => 'hash', keys => {
email => { email => 1 },
- show_nsfw => { anybool => 1 },
max_sexual => { int => 1, range => [-1, 2 ] },
max_violence => { uint => 1, range => [ 0, 2 ] },
traits_sexual => { anybool => 1 },
@@ -81,7 +80,7 @@ TUWF::get qr{/$RE{uid}/edit}, sub {
$u->{prefs} = $u->{id} == auth->uid || auth->permUsermod ?
tuwf->dbRowi(
- 'SELECT show_nsfw, max_sexual, max_violence, traits_sexual, tags_all, tags_cont, tags_ero, tags_tech, spoilers, skin, customcss
+ '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;
@@ -118,7 +117,7 @@ elm_api UserEdit => $FORM_OUT, $FORM_IN, sub {
return elm_Taken if $p->{uniname} && tuwf->dbVali('SELECT 1 FROM users WHERE id <>', \$data->{id}, 'AND username =', \lc($p->{uniname}));
$set{$_} = $p->{$_} for qw/
- show_nsfw max_sexual max_violence traits_sexual tags_all tags_cont tags_ero tags_tech spoilers skin customcss
+ 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
/;
}
@@ -131,6 +130,7 @@ elm_api UserEdit => $FORM_OUT, $FORM_IN, sub {
$set{"perm_$_"} = $data->{admin}{"perm_$_"} for grep $_ ne 'usermod', auth->listPerms;
}
$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;
diff --git a/lib/VNWeb/User/List.pm b/lib/VNWeb/User/List.pm
index 04c5d420..16fdae76 100644
--- a/lib/VNWeb/User/List.pm
+++ b/lib/VNWeb/User/List.pm
@@ -69,8 +69,8 @@ TUWF::get qr{/u/(?<char>[0a-z]|all)}, sub {
my @where = (
$char eq 'all' ? () : $char eq '0' ? "ascii(username) not between ascii('a') and ascii('z')" : "username like '$char%'",
$opt->{q} ? sql_or(
- $opt->{q} =~ /^u?([0-9]+)$/ ? sql 'id =', \"$1" : (),
- sql 'position(', \$opt->{q}, 'in username) > 0'
+ $opt->{q} =~ /^u?([0-9]{1,6})$/ ? sql 'id =', \"$1" : (),
+ sql('username ILIKE', \('%'.sql_like($opt->{q}).'%')),
) : ()
);
diff --git a/lib/VNWeb/User/Notifications.pm b/lib/VNWeb/User/Notifications.pm
index 59512f05..40417c63 100644
--- a/lib/VNWeb/User/Notifications.pm
+++ b/lib/VNWeb/User/Notifications.pm
@@ -5,9 +5,11 @@ use VNWeb::Prelude;
my %ntypes = (
pm => 'Private Message',
dbdel => 'Entry you contributed to has been deleted',
- listdel => 'VN in your (wish)list has been deleted',
+ listdel => 'VN in your list has been deleted',
dbedit => 'Entry you contributed to has been edited',
announce => 'Site announcement',
+ post => 'Reply to a thread you\'ve posted in',
+ comment => 'Comment on your review',
);
@@ -24,6 +26,16 @@ sub settings_ {
};
br_;
label_ sub {
+ input_ type => 'checkbox', name => 'post', auth->pref('notify_post') ? (checked => 'checked') : ();
+ txt_ ' Notify me about replies to threads I posted in.';
+ };
+ br_;
+ label_ sub {
+ input_ type => 'checkbox', name => 'comment', auth->pref('notify_comment') ? (checked => 'checked') : ();
+ txt_ ' Notify me about comments to my reviews.';
+ };
+ br_;
+ label_ sub {
input_ type => 'checkbox', name => 'announce', auth->pref('notify_announce') ? (checked => 'checked') : ();
txt_ ' Notify me about site announcements.';
};
@@ -58,7 +70,7 @@ sub listing_ {
}};
tr_ $_->{read} ? () : (class => 'unread'), sub {
my $l = $_;
- my $lid = $l->{ltype}.$l->{iid}.($l->{subid}?'.'.$l->{subid}:'');
+ my $lid = $l->{iid}.($l->{num}?'.'.$l->{num}:'');
my $url = "/u$id/notify/$l->{id}/$lid";
td_ class => 'tc1', sub { input_ type => 'checkbox', name => 'notifysel', value => $l->{id}; };
td_ class => 'tc2', $ntypes{$l->{ntype}};
@@ -66,7 +78,8 @@ sub listing_ {
td_ class => 'tc4', sub { a_ href => $url, $lid };
td_ class => 'tc5', sub {
a_ href => $url, sub {
- txt_ $l->{ltype} ne 't' ? 'Edit of ' : $l->{subid} == 1 ? 'New thread ' : 'Reply to ';
+ txt_ $l->{iid} =~ /^w/ ? ($l->{num} ? 'Comment on ' : 'Review of ') :
+ $l->{iid} =~ /^t/ ? ($l->{num} == 1 ? 'New thread ' : 'Reply to ') : 'Edit of ';
i_ $l->{c_title};
txt_ ' by ';
i_ user_displayname $l;
@@ -101,7 +114,7 @@ TUWF::get qr{/$RE{uid}/notifies}, sub {
);
my $count = tuwf->dbVali('SELECT count(*) FROM notifications WHERE', $where);
my $list = tuwf->dbPagei({ results => 25, page => $opt->{p} },
- 'SELECT n.id, n.ntype, n.ltype, n.iid, n.subid, n.c_title
+ 'SELECT n.id, n.ntype, n.iid, n.num, n.c_title
, ', sql_totime('n.date'), ' as date
, ', sql_totime('n.read'), ' as read
, ', sql_user(),
@@ -135,11 +148,15 @@ TUWF::post qr{/$RE{uid}/notify_options}, sub {
csrf => {},
dbedit => { anybool => 1 },
announce => { anybool => 1 },
+ post => { anybool => 1 },
+ comment => { anybool => 1 },
)->data;
return tuwf->resNotFound if !auth->csrfcheck($frm->{csrf});
auth->prefSet(notify_dbedit => $frm->{dbedit});
auth->prefSet(notify_announce => $frm->{announce});
+ auth->prefSet(notify_post => $frm->{post});
+ auth->prefSet(notify_comment => $frm->{comment});
tuwf->resRedirect("/u$id/notifies", 'post');
};
diff --git a/lib/VNWeb/User/Page.pm b/lib/VNWeb/User/Page.pm
index 0b1b1770..f225bd76 100644
--- a/lib/VNWeb/User/Page.pm
+++ b/lib/VNWeb/User/Page.pm
@@ -66,6 +66,14 @@ sub _info_table_ {
};
};
tr_ sub {
+ my $cnt = tuwf->dbVali('SELECT COUNT(*) FROM reviews WHERE uid =', \$u->{id});
+ td_ 'Reviews';
+ td_ !$cnt ? '-' : sub {
+ txt_ sprintf '%d review%s. ', $cnt, $cnt == 1 ? '' : 's';
+ a_ href => "/w?u=$u->{id}", 'Browse reviews »';
+ };
+ };
+ tr_ sub {
my $stats = tuwf->dbRowi('SELECT COUNT(DISTINCT tag) AS tags, COUNT(DISTINCT vid) AS vns FROM tags_vn WHERE uid =', \$u->{id});
td_ 'Tags';
td_ !$u->{c_tags} ? '-' : !$stats->{tags} ? '-' : sub {
@@ -85,6 +93,7 @@ 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});
td_ 'Forum stats';
td_ !$stats->{posts} ? '-' : sub {
txt_ sprintf '%d post%s, %d new thread%s. ',
diff --git a/lib/VNWeb/VN/Edit.pm b/lib/VNWeb/VN/Edit.pm
new file mode 100644
index 00000000..0a37e453
--- /dev/null
+++ b/lib/VNWeb/VN/Edit.pm
@@ -0,0 +1,206 @@
+package VNWeb::VN::Edit;
+
+use VNWeb::Prelude;
+use VNWeb::Images::Lib 'enrich_image';
+use VNWeb::Releases::Lib;
+
+
+my $FORM = {
+ id => { required => 0, id => 1 },
+ title => { maxlength => 250 },
+ original => { required => 0, default => '', maxlength => 250 },
+ alias => { required => 0, default => '', maxlength => 500 },
+ desc => { required => 0, default => '', maxlength => 10240 },
+ length => { uint => 1, enum => \%VN_LENGTH },
+ l_wikidata => { required => 0, uint => 1, max => (1<<31)-1 },
+ l_renai => { required => 0, default => '', maxlength => 100 },
+ relations => { sort_keys => 'vid', aoh => {
+ vid => { id => 1 },
+ 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 => '' },
+ } },
+ 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 => {
+ aid => { id => 1 },
+ role => { enum => \%CREDIT_TYPE },
+ note => { required => 0, default => '', maxlength => 250 },
+ id => { _when => 'out', id => 1 },
+ name => { _when => 'out' },
+ original => { _when => 'out', required => 0, default => '' },
+ } },
+ seiyuu => { sort_keys => ['aid','cid'], aoh => {
+ aid => { id => 1 },
+ cid => { id => 1 },
+ note => { required => 0, default => '', maxlength => 250 },
+ # Staff info
+ id => { _when => 'out', id => 1 },
+ name => { _when => 'out' },
+ original => { _when => 'out', required => 0, default => '' },
+ } },
+ screenshots=> { sort_keys => 'scr', aoh => {
+ scr => { vndbid => 'sf' },
+ rid => { required => 0, id => 1 },
+ info => { _when => 'out', type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
+ } },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+
+ authmod => { _when => 'out', anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+ releases => { _when => 'out', $VNWeb::Elm::apis{Releases}[0]->%* },
+ chars => { _when => 'out', aoh => {
+ id => { id => 1 },
+ name => {},
+ original => { required => 0, default => '' },
+ } },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_CMP = form_compile cmp => $FORM;
+
+
+TUWF::get qr{/$RE{vrev}/edit} => sub {
+ my $e = db_entry v => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit v => $e;
+
+ $e->{authmod} = auth->permDbmod;
+ $e->{editsum} = $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision v$e->{id}.$e->{chrev}";
+
+ if($e->{image}) {
+ $e->{image_info} = { id => $e->{image} };
+ enrich_image 0, [$e->{image_info}];
+ } else {
+ $e->{image_info} = undef;
+ }
+ $_->{info} = {id=>$_->{scr}} for $e->{screenshots}->@*;
+ enrich_image 0, [map $_->{info}, $e->{screenshots}->@*];
+
+ enrich_merge vid => '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 aid => 'SELECT id, aid, name, original FROM staff_alias 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}->@* ];
+
+ $e->{releases} = releases_by_vn $e->{id};
+
+ $e->{chars} = tuwf->dbAlli('
+ SELECT id, name, original FROM chars
+ WHERE NOT hidden AND id IN(SELECT id FROM chars_vns WHERE vid =', \$e->{id},')
+ ORDER BY name, id'
+ );
+
+ framework_ title => "Edit $e->{title}", type => 'v', dbobj => $e, tab => 'edit',
+ sub {
+ editmsg_ v => $e, "Edit $e->{title}";
+ elm_ VNEdit => $FORM_OUT, $e;
+ };
+};
+
+
+TUWF::get qr{/v/add}, sub {
+ return tuwf->resDenied if !can_edit v => undef;
+
+ framework_ title => 'Add visual novel',
+ sub {
+ editmsg_ v => undef, 'Add visual novel';
+ elm_ VNEdit => $FORM_OUT, elm_empty($FORM_OUT);
+ };
+};
+
+
+elm_api VNEdit => $FORM_OUT, $FORM_IN, sub {
+ my $data = shift;
+ my $new = !$data->{id};
+ my $e = $new ? { id => 0 } : db_entry v => $data->{id} or return tuwf->resNotFound;
+ return elm_Unauth if !can_edit v => $e;
+
+ if(!auth->permDbmod) {
+ $data->{hidden} = $e->{hidden}||0;
+ $data->{locked} = $e->{locked}||0;
+ }
+ $data->{desc} = bb_subst_links $data->{desc};
+ $data->{alias} =~ s/\n\n+/\n/;
+
+ validate_dbid 'SELECT id FROM anime WHERE id IN', map $_->{aid}, $data->{anime}->@*;
+ validate_dbid 'SELECT id FROM images WHERE id IN', $data->{image} if $data->{image};
+ validate_dbid 'SELECT id FROM images WHERE id IN', map $_->{scr}, $data->{screenshots}->@*;
+ validate_dbid 'SELECT aid FROM staff_alias WHERE aid IN', map $_->{aid}, $data->{staff}->@*;
+ validate_dbid 'SELECT aid FROM staff_alias WHERE aid IN', map $_->{aid}, $data->{seiyuu}->@*;
+
+ $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} == $e->{id}, $data->{relations}->@*;
+
+ die "Screenshot without releases assigned" if grep !$_->{rid}, $data->{screenshots}->@*; # This is only the case for *very* old revisions, form disallows this now.
+ # Allow linking to deleted or moved releases only if the previous revision also had that.
+ # (The form really should encourage the user to fix that, but disallowing the edit seems a bit overkill)
+ validate_dbid sub { '
+ SELECT r.id FROM releases r JOIN releases_vn rv ON r.id = rv.id WHERE NOT r.hidden AND rv.vid =', \$e->{id}, ' AND r.id IN', $_, '
+ UNION
+ SELECT rid FROM vn_screenshots WHERE id =', \$e->{id}, 'AND rid IN', $_
+ }, map $_->{rid}, $data->{screenshots}->@*;
+
+ # Likewise, allow linking to deleted or moved characters.
+ validate_dbid sub { '
+ SELECT c.id FROM chars c JOIN chars_vns cv ON c.id = cv.id WHERE NOT c.hidden AND cv.vid =', \$e->{id}, ' AND c.id IN', $_, '
+ UNION
+ SELECT cid FROM vn_seiyuu WHERE id =', \$e->{id}, 'AND cid IN', $_
+ }, map $_->{cid}, $data->{seiyuu}->@*;
+
+ $data->{image_nsfw} = $e->{image_nsfw}||0;
+ my %oldscr = map +($_->{scr}, $_->{nsfw}), @{ $e->{screenshots}||[] };
+ $_->{nsfw} = $oldscr{$_->{scr}}||0 for $data->{screenshots}->@*;
+
+ return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
+ my($id,undef,$rev) = db_edit v => $e->{id}, $data;
+ update_reverse($id, $rev, $e, $data);
+ elm_Redirect "/v$id.$rev";
+};
+
+
+sub update_reverse {
+ my($id, $rev, $old, $new) = @_;
+
+ my %old = map +($_->{vid}, $_), $old->{relations} ? $old->{relations}->@* : ();
+ my %new = map +($_->{vid}, $_), $new->{relations}->@*;
+
+ # Updates to be performed, vid => { vid => x, relation => y, official => z } or undef if the relation should be removed.
+ my %upd;
+
+ for my $i (keys %old, keys %new) {
+ if($old{$i} && !$new{$i}) {
+ $upd{$i} = undef;
+ } elsif(!$old{$i} || $old{$i}{relation} ne $new{$i}{relation} || !$old{$i}{official} != !$new{$i}{official}) {
+ $upd{$i} = {
+ vid => $id,
+ relation => $VN_RELATION{ $new{$i}{relation} }{reverse},
+ official => $new{$i}{official}
+ };
+ }
+ }
+
+ for my $i (keys %upd) {
+ my $v = db_entry v => $i;
+ $v->{relations} = [
+ $upd{$i} ? $upd{$i} : (),
+ grep $_->{vid} != $id, $v->{relations}->@*
+ ];
+ $v->{editsum} = "Reverse relation update caused by revision v$id.$rev";
+ db_edit v => $i, $v, 1;
+ }
+}
+
+1;
diff --git a/lib/VNWeb/VN/Elm.pm b/lib/VNWeb/VN/Elm.pm
index fd43c8ca..3bf02d59 100644
--- a/lib/VNWeb/VN/Elm.pm
+++ b/lib/VNWeb/VN/Elm.pm
@@ -2,22 +2,30 @@ package VNWeb::VN::Elm;
use VNWeb::Prelude;
-elm_api VN => undef, { search => {} }, sub {
- my $q = shift->{search};
- my $qs = $q =~ s/[%_]//gr;
- my @q = normalize_query $q;
+elm_api VN => undef, {
+ search => { type => 'array', values => { required => 0, default => '' } },
+ hidden => { anybool => 1 },
+}, sub {
+ my($data) = @_;
+ my @q = grep length $_, $data->{search}->@*;
+ die "No query" if !@q;
- elm_VNResult tuwf->dbPagei({ results => 15, page => 1 },
- 'SELECT v.id, v.title, v.original
+ 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',
- $q =~ /^$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%"),
- @q ? (sql 'SELECT 10, id FROM vn WHERE', sql_and map sql('c_search ILIKE', \"%$_%"), @q) : ()
- ), ') x(prio, id)
+ 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 NOT v.hidden
- GROUP BY v.id, v.title, v.original
+ 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
');
};
diff --git a/lib/VNWeb/VN/Graph.pm b/lib/VNWeb/VN/Graph.pm
index 45e8ea73..e00f656e 100644
--- a/lib/VNWeb/VN/Graph.pm
+++ b/lib/VNWeb/VN/Graph.pm
@@ -7,11 +7,11 @@ use VNWeb::Graph;
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 => { anybool => 1 })->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 $hasofficial = tuwf->dbVali('SELECT 1 FROM vn_relations WHERE official AND id =', \$id, 'LIMIT 1');
- $unoff = 1 if !$hasofficial;
+ my $has = tuwf->dbRowi('SELECT bool_or(official) AS official, bool_or(not official) AS unofficial FROM vn_relations WHERE id =', \$id, 'GROUP BY id');
+ $unoff = 1 if !$has->{official};
# Big list of { id0, id1, relation } hashes.
# Each relation is included twice, with id0 and id1 reversed.
@@ -61,7 +61,7 @@ TUWF::get qr{/$RE{vid}/rg}, sub {
txt_ sprintf "Displaying %d out of %d related visual novels.", $visible_nodes, $total_nodes;
debug_ +{ nodes => $nodes, rel => $rel };
br_;
- if($hasofficial) {
+ if($has->{official}) {
if($unoff) {
txt_ 'Show / ';
a_ href => "?num=$num&unoff=0", 'Hide';
@@ -72,16 +72,18 @@ TUWF::get qr{/$RE{vid}/rg}, sub {
txt_ ' unofficial relations. ';
br_;
}
- txt_ 'Adjust graph size: ';
- join_ ', ', sub {
- if($_ == min $num, $total_nodes) {
- txt_ $_ ;
- } else {
- a_ href => "/v$id/rg?num=$_", $_;
- }
- }, grep($_ < $total_nodes, 10, 15, 25, 50, 75, 100, 150, 250, 500, 750, 1000), $total_nodes;
+ if($total_nodes > 10) {
+ txt_ 'Adjust graph size: ';
+ join_ ', ', sub {
+ if($_ == min $num, $total_nodes) {
+ txt_ $_ ;
+ } else {
+ a_ href => "/v$id/rg?num=$_", $_;
+ }
+ }, grep($_ < $total_nodes, 10, 15, 25, 50, 75, 100, 150, 250, 500, 750, 1000), $total_nodes;
+ }
txt_ '.';
- } if $total_nodes > 10;
+ } if $total_nodes > 10 || $has->{unofficial};
p_ class => 'center', sub { lit_ dot2svg $dot };
};
clearfloat_;
diff --git a/lib/VNWeb/VN/Page.pm b/lib/VNWeb/VN/Page.pm
index c718a53a..ed4f6e75 100644
--- a/lib/VNWeb/VN/Page.pm
+++ b/lib/VNWeb/VN/Page.pm
@@ -2,17 +2,20 @@ package VNWeb::VN::Page;
use VNWeb::Prelude;
use VNWeb::Releases::Lib;
+use VNWeb::Images::Lib qw/image_flagging_display image_ enrich_image_obj/;
use VNDB::Func 'fmtrating';
-use POSIX 'strftime';
# Enrich everything necessary to at least render infobox_().
+# Also used by Chars::VNTab & Reviews::VNTab
sub enrich_vn {
my($v) = @_;
enrich_merge id => 'SELECT id, c_votecount, c_olang::text[] AS c_olang FROM vn WHERE id IN', $v;
enrich_merge vid => 'SELECT id AS vid, title, original FROM vn WHERE id IN', $v->{relations};
enrich_merge aid => 'SELECT id AS aid, title_romaji, title_kanji, year, type, ann_id, lastfetch FROM anime WHERE id IN', $v->{anime};
enrich_extlinks v => $v;
+ enrich_image_obj image => $v;
+ enrich_image_obj scr => $v->{screenshots};
# 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)
@@ -33,26 +36,33 @@ sub enrich_item {
enrich_vn $v;
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 scr => 'SELECT id AS scr, width, height FROM images WHERE id IN', $v->{screenshots};
$v->{relations} = [ sort { $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->{seiyuu} = [ sort { $a->{aid} <=> $b->{aid} || $a->{cid} <=> $b->{cid} || $a->{note} cmp $b->{note} } $v->{seiyuu}->@* ];
- $v->{screenshots} = [ sort { idcmp($a->{scr}, $b->{scr}) } $v->{screenshots}->@* ];
+ $v->{screenshots} = [ sort { idcmp($a->{scr}{id}, $b->{scr}{id}) } $v->{screenshots}->@* ];
}
sub og {
my($v) = @_;
+{
- description => bb2text($v->{desc}),
- image => $v->{image} && !$v->{img_nsfw} ? tuwf->imgurl($v->{image}) :
- [map $_->{nsfw}?():(tuwf->imgurl($_->{scr})), $v->{screenshots}->@*]->[0]
+ description => bb_format($v->{desc}, text => 1),
+ image => $v->{image} && !$v->{image}{sexual} && !$v->{image}{violence} ? tuwf->imgurl($v->{image}{id}) :
+ [map $_->{scr}{sexual}||$_->{scr}{violence}?():(tuwf->imgurl($_->{scr}{id})), $v->{screenshots}->@*]->[0]
}
}
+# 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)
+}
+
+
sub rev_ {
my($v) = @_;
revision_ v => $v, \&enrich_item,
@@ -84,43 +94,18 @@ sub rev_ {
a_ href => "/r$_->{rid}", "r$_->{rid}" if $_->{rid};
txt_ 'no release' if !$_->{rid};
txt_ '] ';
- a_ href => tuwf->imgurl($_->{scr}), 'data-iv' => "$_->{width}x$_->{height}", $_->{scr};
- txt_ $_->{nsfw} ? ' (Not safe)' : ' (Safe)';
+ a_ href => tuwf->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};
+ txt_ '] ';
+ b_ class => 'grayedout', sprintf 'old flag: %s', $_->{nsfw} ? 'NSFW' : 'Safe';
}],
- [ image => 'Image', fmt => sub {
- !viewget->{show_nsfw} && $_[0]{img_nsfw}
- ? a_ href => tuwf->imgurl($_), '(NSFW)'
- : img_ src => tuwf->imgurl($_)
- } ],
- [ img_nsfw => 'Image NSFW', fmt => sub { txt_ $_ ? 'Not safe' : 'Safe' } ],
+ [ image => 'Image', fmt => sub { image_ $_ } ],
+ [ img_nsfw => 'Image NSFW (unused)', fmt => sub { txt_ $_ ? 'Not safe' : 'Safe' } ],
revision_extlinks 'v'
}
-sub infobox_img_ {
- my($v) = @_;
- p_ 'No image uploaded yet.' if !$v->{image};
- img_ src => tuwf->imgurl($v->{image}), alt => $v->{title} if $v->{image} && !$v->{img_nsfw};
-
- p_ class => 'nsfw_pic', sub {
- input_ id => 'nsfw_chk', type => 'checkbox', class => 'visuallyhidden', tuwf->authPref('show_nsfw') ? (checked => 'checked') : ();
- label_ for => 'nsfw_chk', sub {
- span_ id => 'nsfw_show', sub {
- txt_ 'This image has been flagged as Not Safe For Work.';
- br_; br_;
- span_ class => 'fake_link', 'Show me anyway';
- br_; br_;
- txt_ '(This warning can be disabled in your account)';
- };
- span_ id => 'nsfw_hid', sub {
- img_ src => tuwf->imgurl($v->{image}), alt => $v->{title};
- i_ 'Flagged as NSFW';
- };
- };
- } if $v->{image} && $v->{img_nsfw};
-}
-
-
sub infobox_relations_ {
my($v) = @_;
return if !$v->{relations}->@*;
@@ -313,9 +298,6 @@ sub infobox_useroptions_ {
my($v) = @_;
return if !auth;
- # Voting option is hidden if nothing has been released yet
- my $minreleased = min grep $_, map $_->{released}, $v->{releases}->@*;
-
my $labels = tuwf->dbAlli('
SELECT l.id, l.label, l.private, uvl.vid IS NOT NULL as assigned
FROM ulist_labels l
@@ -324,17 +306,20 @@ sub infobox_useroptions_ {
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', undef, { # TODO: Go through a TUWF::Validation schema
- uid => 1*auth->uid,
- vid => 1*$v->{id},
- onlist => $lst->{vid}?\1:\0,
- canvote => $minreleased && $minreleased < strftime('%Y%m%d', gmtime) ? \1 : \0,
- vote => fmtvote($lst->{vote}).'',
+ 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}||'',
- labels => [ map +{ id => 1*$_->{id}, label => $_->{label}, private => $_->{private}?\1:\0 }, @$labels ],
+ review => $review,
+ canreview=> $review || (canvote($v) && can_edit(w => {})),
+ labels => $labels,
selected => [ map $_->{id}, grep $_->{assigned}, @$labels ],
};
}
@@ -342,14 +327,16 @@ sub infobox_useroptions_ {
}
+# Also used by Chars::VNTab & Reviews::VNTab
sub infobox_ {
my($v) = @_;
div_ class => 'mainbox', sub {
+ itemmsg_ v => $v;
h1_ $v->{title};
h2_ class => 'alttitle', lang_attr($v->{c_olang}), $v->{original} if $v->{original};
div_ class => 'vndetails', sub {
- div_ class => 'vnimg', sub { infobox_img_ $v };
+ div_ class => 'vnimg', sub { image_ $v->{image}, alt => $v->{title}; };
table_ class => 'stripe', sub {
tr_ sub {
@@ -387,7 +374,7 @@ sub infobox_ {
tr_ class => 'nostripe', sub {
td_ class => 'vndesc', colspan => 2, sub {
h2_ 'Description';
- p_ sub { lit_ $v->{desc} ? bb2html $v->{desc} : '-' };
+ p_ sub { lit_ $v->{desc} ? bb_format $v->{desc} : '-' };
}
}
}
@@ -398,20 +385,31 @@ sub infobox_ {
}
+# Also used by Chars::VNTab & Reviews::VNTab
sub tabs_ {
- my($v, $char) = @_;
- # XXX: This query is kind of silly because we'll be fetching a list of characters regardless of which tab we have open.
- my $haschars = tuwf->dbVali('SELECT 1 FROM chars c JOIN chars_vns cv ON cv.id = c.id WHERE NOT c.hidden AND cv.vid =', \$v->{id}, 'LIMIT 1');
+ my($v, $tab) = @_;
+ my $chars = tuwf->dbVali('SELECT COUNT(DISTINCT c.id) FROM chars c JOIN chars_vns cv ON cv.id = c.id WHERE NOT c.hidden AND cv.vid =', \$v->{id});
+ my $reviews = tuwf->dbRowi('SELECT COUNT(*) FILTER(WHERE isfull) AS full, COUNT(*) FILTER(WHERE NOT isfull) AS mini FROM reviews WHERE NOT c_flagged AND vid =', \$v->{id});
- return if !$haschars && !auth->permEdit;
+ return if !$chars && !$reviews->{full} && !$reviews->{mini} && !auth->permEdit && !auth->permReview;
+ $tab ||= '';
div_ class => 'maintabs', sub {
ul_ sub {
- if($haschars) {
- li_ class => (!$char ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}#main", name => 'main', 'main' };
- li_ class => ($char ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}/chars#chars", name => 'chars', 'characters' };
+ li_ class => ($tab eq '' ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}#main", name => 'main', 'main' } if $chars || $reviews;
+ li_ class => ($tab eq 'chars' ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}/chars#chars", name => 'chars', "characters ($chars)" } if $chars;
+ if($reviews->{mini} > 4 || $tab eq 'minireviews' || $tab eq 'fullreviews') {
+ li_ class => ($tab eq 'minireviews'?' tabselected' : ''), sub { a_ href => "/v$v->{id}/minireviews#review", name => 'review', "mini reviews ($reviews->{mini})" } if $reviews->{mini};
+ li_ class => ($tab eq 'fullreviews'?' tabselected' : ''), sub { a_ href => "/v$v->{id}/fullreviews#review", name => 'review', "full reviews ($reviews->{full})" } if $reviews->{full};
+ } elsif($reviews->{mini} || $reviews->{full}) {
+ li_ class => ($tab =~ /reviews/ ?' tabselected':''), sub { a_ href => "/v$v->{id}/reviews#review", name => 'review', sprintf 'reviews (%d)', $reviews->{mini}+$reviews->{full} };
}
};
ul_ 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$v->{id}/addreview", 'add review' } if !$id && can_edit w => {};
+ li_ sub { a_ href => "/$id/edit", 'edit review' } if $id;
+ }
if(auth->permEdit) {
li_ sub { a_ href => "/v$v->{id}/add", 'add release' };
li_ sub { a_ href => "/v$v->{id}/addchar", 'add character' };
@@ -640,19 +638,36 @@ sub screenshots_ {
my $s = $v->{screenshots};
return if !@$s;
+ my $sexp = auth->pref('max_sexual')||0;
+ my $viop = auth->pref('max_violence')||0;
+ $viop = 0 if $sexp < 0;
+ my $sexs = min($sexp, max map $_->{scr}{sexual}, @$s);
+ my $vios = min($viop, max map $_->{scr}{violence}, @$s);
+
+ my @sex = (0,0,0);
+ my @vio = (0,0,0);
+ for (@$s) { $sex[$_->{scr}{sexual}]++; $vio[$_->{scr}{violence}]++ }
+
my %rel;
push $rel{$_->{rid}}->@*, $_ for grep $_->{rid}, @$s;
- input_ id => 'nsfwhide_chk', type => 'checkbox', class => 'visuallyhidden', auth->pref('show_nsfw') ? (checked => 'checked') : ();
+ 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 {
- p_ class => 'nsfwtoggle', sub {
- txt_ 'Showing ';
- i_ id => 'nsfwshown', scalar grep !$_->{nsfw}, @$s;
- span_ class => 'nsfw', scalar @$s;
- txt_ sprintf ' out of %d screenshot%s. ', scalar @$s, @$s == 1 ? '' : 's';
- label_ for => 'nsfwhide_chk', class => 'fake_link', 'show/hide NSFW';
- } if grep $_->{nsfw}, @$s;
+ p_ class => 'mainopts', sub {
+ if($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]);
+ 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];
h1_ 'Screenshots';
@@ -663,9 +678,19 @@ sub screenshots_ {
a_ href => "/r$r->{id}", $r->{title};
};
div_ class => 'scr', sub {
- a_ href => tuwf->imgurl($_->{scr}), class => sprintf('scrlnk%s', $_->{nsfw} ? ' nsfw':''), 'data-iv' => "$_->{width}x$_->{height}:scr", sub {
- my($w, $h) = imgsize $_->{width}, $_->{height}, tuwf->{scr_size}->@*;
- img_ src => tuwf->imgurl($_->{scr}, 1), width => $w, height => $h, alt => "Screenshot #$_->{scr}";
+ a_ href => tuwf->imgurl($_->{scr}{id}),
+ 'data-iv' => "$_->{scr}{width}x$_->{scr}{height}:scr:$_->{scr}{sexual}$_->{scr}{violence}$_->{scr}{votecount}",
+ mkclass(
+ scrlnk => 1,
+ scrlnk_s0 => $_->{scr}{sexual} <= 0,
+ scrlnk_s1 => $_->{scr}{sexual} <= 1,
+ scrlnk_v0 => $_->{scr}{violence} >= 1,
+ scrlnk_v1 => $_->{scr}{violence} >= 2,
+ nsfw => $_->{scr}{sexual} || $_->{scr}{violence},
+ ),
+ sub {
+ my($w, $h) = imgsize $_->{scr}{width}, $_->{scr}{height}, tuwf->{scr_size}->@*;
+ img_ src => tuwf->imgurl($_->{scr}{id}, 1), width => $w, height => $h, alt => "Screenshot $_->{scr}{id}";
} for $rel{$r->{id}}->@*;
}
}
@@ -673,47 +698,6 @@ sub screenshots_ {
}
-sub chars_ {
- my($v) = @_;
- my $view = viewget;
- my $chars = VNWeb::Chars::Page::fetch_chars($v->{id}, sql('id IN(SELECT id FROM chars_vns WHERE vid =', \$v->{id}, ')'));
- return if !@$chars;
-
- my $max_spoil = max(
- map max(
- (map $_->{spoil}, $_->{traits}->@*),
- (map $_->{spoil}, $_->{vns}->@*),
- defined $_->{spoil_gender} ? 2 : 0,
- $_->{desc} =~ /\[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 %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++;
-
- h1_ $CHAR_ROLE{$r}{ @c > 1 ? 'plural' : 'txt' };
- VNWeb::Chars::Page::chartable_($_, 1, $_ != $c[0], 1) for @c;
- }
- }
-}
-
-
TUWF::get qr{/$RE{vrev}}, sub {
my $v = db_entry v => tuwf->capture('id'), tuwf->capture('rev');
return tuwf->resNotFound if !$v;
@@ -733,19 +717,4 @@ TUWF::get qr{/$RE{vrev}}, sub {
};
};
-
-TUWF::get qr{/$RE{vid}/chars}, sub {
- my $v = db_entry v => tuwf->capture('id');
- return tuwf->resNotFound if !$v;
-
- enrich_vn $v;
-
- framework_ title => $v->{title}, index => 1, type => 'v', dbobj => $v, hiddenmsg => 1, og => og($v),
- sub {
- infobox_ $v;
- tabs_ $v, 1;
- chars_ $v;
- };
-};
-
1;
diff --git a/lib/VNWeb/Validation.pm b/lib/VNWeb/Validation.pm
index acee0fdf..4d398aac 100644
--- a/lib/VNWeb/Validation.pm
+++ b/lib/VNWeb/Validation.pm
@@ -24,6 +24,11 @@ our @EXPORT = qw/
TUWF::set custom_validations => {
id => { uint => 1, max => (1<<26)-1 },
+ # 'vndbid' SQL type, accepts an arrayref with accepted prefixes.
+ vndbid => sub {
+ my $types = ref $_[0] ? join '|', $_[0]->@* : $_[0];
+ +{ regex => qr/^(?:$types)[1-9][0-9]{0,6}$/ }
+ },
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
@@ -32,6 +37,10 @@ TUWF::set custom_validations => {
language => { enum => \%LANGUAGE },
gtin => { required => 0, default => 0, func => sub { $_[0] = 0 if !length $_[0]; $_[0] eq 0 || gtintype($_[0]) } },
rdate => { uint => 1, func => \&_validate_rdate },
+ # A tri-state bool, returns undef if not present or empty, normalizes to 0/1 otherwise
+ undefbool => { required => 0, default => undef, func => sub { $_[0] = $_[0] ? 1 : 0; 1 } },
+ # An array that may be either missing (returns undef), a single scalar (returns single-element array) or a proper array
+ undefarray => sub { +{ required => 0, 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 } },
# Sort an array by the listed hash keys, using string comparison on each key
@@ -168,6 +177,11 @@ sub validate_dbid {
# Otherwise, checks if the user can edit the post.
# Requires the 'user_id', 'date' and 'hidden' fields.
#
+# w:
+# If no 'id' field, checks if the user can submit a new review.
+# Otherwise, checks if the user can edit the review.
+# Requires the 'uid' field.
+#
# 'dbentry_type's:
# If no 'id' field, checks whether the user can create a new entry.
# Otherwise, requires 'entry_hidden' and 'entry_locked' fields.
@@ -194,6 +208,12 @@ sub can_edit {
}
}
+ if($type eq 'w') {
+ return 1 if auth->permBoardmod;
+ return auth->permReview if !$entry->{id};
+ return auth && auth->uid == $entry->{user_id};
+ }
+
die "Can't do authorization test when entry_hidden/entry_locked fields aren't present"
if $entry->{id} && (!exists $entry->{entry_hidden} || !exists $entry->{entry_locked});
@@ -237,7 +257,7 @@ sub viewget {
{
spoilers => $sp // auth->pref('spoilers') || 0,
traits_sexual => !$ts ? auth->pref('traits_sexual') : $ts eq 's',
- show_nsfw => !$ns ? auth->pref('show_nsfw') : $ns eq 'n',
+ show_nsfw => !$ns ? (auth->pref('max_sexual')||0)==2 && (auth->pref('max_violence')||0)>0 : $ns eq 'n',
}
};
tuwf->req->{view}
diff --git a/sql/c/vndbfuncs.c b/sql/c/vndbfuncs.c
index c2dd2084..a327838b 100644
--- a/sql/c/vndbfuncs.c
+++ b/sql/c/vndbfuncs.c
@@ -36,7 +36,10 @@ PG_MODULE_MAGIC;
X( 8, "v" , 'v', 0)\
X( 9, "ch", 'c', 'h')\
X(10, "cv", 'c', 'v')\
- X(11, "sf", 's', 'f')
+ X(11, "sf", 's', 'f')\
+ X(12, "w", 'w', 0)\
+ X(13, "u", 'u', 0)\
+ X(14, "t", 't', 0)
#define VNDBID_TYPE(_x) ((_x) >> 26)
#define VNDBID_NUM(_x) ((_x) & 0x03FFFFFF)
diff --git a/sql/func.sql b/sql/func.sql
index 8492b412..6b77d30a 100644
--- a/sql/func.sql
+++ b/sql/func.sql
@@ -61,18 +61,24 @@ CREATE OR REPLACE FUNCTION update_vncache(integer) RETURNS void AS $$
GROUP BY rv.vid
), 0),
c_olang = ARRAY(
- SELECT lang
- FROM releases_lang
- WHERE id = (
- SELECT r.id
- FROM releases_vn rv
- JOIN releases r ON rv.id = r.id
- WHERE r.released > 0
- AND NOT r.hidden
- AND rv.vid = $1
- ORDER BY r.released
- LIMIT 1
- )
+ SELECT rl.lang
+ FROM releases_lang rl
+ JOIN releases r ON r.id = rl.id
+ JOIN releases_vn rv ON r.id = rv.id
+ WHERE rv.vid = $1
+ AND NOT r.hidden
+ AND r.released > 0
+ AND NOT EXISTS(
+ SELECT 1
+ FROM releases r2
+ JOIN releases_vn rv2 ON r2.id = rv2.id
+ WHERE rv2.vid = $1
+ AND NOT r2.hidden
+ AND r2.released > 0
+ AND r2.released < r.released
+ )
+ GROUP BY rl.lang
+ ORDER BY rl.lang
),
c_languages = ARRAY(
SELECT rl.lang
@@ -148,9 +154,6 @@ $$ LANGUAGE SQL;
--
-- This isn't very grounded in theory, I've no clue how statistics work. I
-- suspect confidence intervals/levels are more appropriate for this use case.
---
--- Non-'ch' image weights are currently reduced to 20% in order to prioritize
--- character images.
CREATE OR REPLACE FUNCTION update_images_cache(vndbid) RETURNS void AS $$
BEGIN
UPDATE images
@@ -163,8 +166,7 @@ BEGIN
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 (pow(2, greatest(0, 14 - s.votecount)) + coalesce(pow(s.sexual_stddev, 2), 0)*100 + coalesce(pow(s.violence_stddev, 2), 0)*100)
- * (CASE WHEN vndbid_type(s.id) = 'ch' THEN 1 ELSE 0.2 END)
+ THEN 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
@@ -183,6 +185,31 @@ END; $$ LANGUAGE plpgsql;
+-- Update reviews.c_up, c_down and c_flagged
+CREATE OR REPLACE FUNCTION update_reviews_votes_cache(vndbid) RETURNS void AS $$
+BEGIN
+ WITH stats(id,up,down,flag) AS (
+ SELECT r.id
+ , COUNT(*) FILTER(WHERE rv.vote AND NOT u.ign_votes AND r2.id IS NULL)
+ , COUNT(*) FILTER(WHERE NOT rv.vote AND NOT u.ign_votes AND r2.id IS NULL)
+ -- flag score = up-down < -10, overrule votes count for 10000 (this algorithm is subject to tuning)
+ , COALESCE(
+ SUM((CASE WHEN rv.vote THEN 1 ELSE -1 END)*(CASE WHEN rv.overrule THEN 10000 ELSE 1 END))
+ FILTER(WHERE NOT u.ign_votes AND (r2.id IS NULL OR rv.overrule)),
+ 0) < -1000
+ FROM reviews r
+ LEFT JOIN reviews_votes rv ON rv.id = r.id
+ LEFT JOIN users u ON u.id = rv.uid
+ LEFT JOIN reviews r2 ON r2.vid = r.vid AND r2.uid = rv.uid
+ WHERE $1 IS NULL OR r.id = $1
+ GROUP BY r.id
+ )
+ UPDATE reviews SET c_up = up, c_down = down, c_flagged = flag
+ FROM stats WHERE reviews.id = stats.id AND (reviews.c_up,reviews.c_down,reviews.c_flagged) <> (stats.up,stats.down,stats.flag);
+END; $$ LANGUAGE plpgsql;
+
+
+
-- Update users.c_vns, c_votes and c_wish for one user (when given an id) or all users (when given NULL)
CREATE OR REPLACE FUNCTION update_users_ulist_stats(integer) RETURNS void AS $$
BEGIN
@@ -497,8 +524,8 @@ $$ LANGUAGE plpgsql;
-- called when an entry has been deleted
CREATE OR REPLACE FUNCTION notify_dbdel(xtype dbentry_type, xedit edit_rettype) RETURNS void AS $$
- INSERT INTO notifications (ntype, ltype, uid, iid, subid, c_title, c_byuser)
- SELECT DISTINCT 'dbdel'::notification_ntype, xtype::text::notification_ltype, h.requester, xedit.itemid, xedit.rev, x.title, h2.requester
+ INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
+ SELECT DISTINCT 'dbdel'::notification_ntype, h.requester, vndbid(xtype::text, xedit.itemid), xedit.rev, x.title, h2.requester
FROM changes h
-- join info about the deletion itself
JOIN changes h2 ON h2.id = xedit.chid
@@ -520,8 +547,8 @@ $$ LANGUAGE sql;
-- Called when a non-deleted item has been edited.
CREATE OR REPLACE FUNCTION notify_dbedit(xtype dbentry_type, xedit edit_rettype) RETURNS void AS $$
- INSERT INTO notifications (ntype, ltype, uid, iid, subid, c_title, c_byuser)
- SELECT DISTINCT 'dbedit'::notification_ntype, xtype::text::notification_ltype, h.requester, xedit.itemid, xedit.rev, x.title, h2.requester
+ INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
+ SELECT DISTINCT 'dbedit'::notification_ntype, h.requester, vndbid(xtype::text, xedit.itemid), xedit.rev, x.title, h2.requester
FROM changes h
-- join info about the edit itself
JOIN changes h2 ON h2.id = xedit.chid
@@ -544,8 +571,8 @@ $$ LANGUAGE sql;
-- called when a VN/release entry has been deleted
CREATE OR REPLACE FUNCTION notify_listdel(xtype dbentry_type, xedit edit_rettype) RETURNS void AS $$
- INSERT INTO notifications (ntype, ltype, uid, iid, subid, c_title, c_byuser)
- SELECT DISTINCT 'listdel'::notification_ntype, xtype::text::notification_ltype, u.uid, xedit.itemid, xedit.rev, x.title, c.requester
+ INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
+ SELECT DISTINCT 'listdel'::notification_ntype, u.uid, vndbid(xtype::text, xedit.itemid), xedit.rev, x.title, c.requester
-- look for users who should get this notify
FROM (
SELECT uid FROM ulist_vns WHERE xtype = 'v' AND vid = xedit.itemid
diff --git a/sql/perms.sql b/sql/perms.sql
index 406cb344..6ce6393d 100644
--- a/sql/perms.sql
+++ b/sql/perms.sql
@@ -37,6 +37,10 @@ GRANT SELECT, INSERT, DELETE ON releases_producers TO vndb_site;
GRANT SELECT, INSERT ON releases_producers_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON releases_vn TO vndb_site;
GRANT SELECT, INSERT ON releases_vn_hist TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON reports TO vndb_site;
+GRANT SELECT, INSERT, 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;
-- No access to the 'sessions' table, managed by the user_* functions.
GRANT SELECT ON shop_denpa TO vndb_site;
@@ -69,18 +73,18 @@ 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
- , perm_board, perm_boardmod, perm_dbmod, perm_edit, perm_imgvote, perm_tag, perm_tagmod, perm_usermod, perm_imgmod
- , skin, customcss, show_nsfw, notify_dbedit, notify_announce
+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, show_nsfw, notify_dbedit, notify_announce, notify_post, notify_comment
, tags_all, tags_cont, tags_ero, tags_tech, spoilers, traits_sexual, max_sexual, max_violence
, filter_vn, filter_release, vn_list_own, vn_list_wish
, nodistract_can, nodistract_noads, nodistract_nofancy, support_can, support_enabled, uniname_can, uniname, pubskin_can, pubskin_enabled
, ulist_votes, ulist_vnlist, ulist_wish
, c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags),
INSERT ( username, mail, ip),
- UPDATE ( username, ign_votes, email_confirmed
- , perm_board, perm_boardmod, perm_dbmod, perm_edit, perm_imgvote, perm_tag, perm_tagmod, perm_imgmod
- , skin, customcss, show_nsfw, notify_dbedit, notify_announce
+ 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, show_nsfw, notify_dbedit, notify_announce, notify_post, notify_comment
, tags_all, tags_cont, tags_ero, tags_tech, spoilers, traits_sexual, max_sexual, max_violence
, filter_vn, filter_release, vn_list_own, vn_list_wish
, nodistract_can, nodistract_noads, nodistract_nofancy, support_can, support_enabled, uniname_can, uniname, pubskin_can, pubskin_enabled
@@ -112,7 +116,7 @@ GRANT CONNECT, TEMP ON DATABASE :DBNAME 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, UPDATE ON anime TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE ON anime TO vndb_multi;
GRANT SELECT ON changes TO vndb_multi;
GRANT SELECT ON chars TO vndb_multi;
GRANT SELECT ON chars_hist TO vndb_multi;
@@ -135,6 +139,9 @@ 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, 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 DELETE ON sessions TO vndb_multi;
@@ -164,8 +171,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_labels TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns_labels TO vndb_multi;
-GRANT SELECT (id, username, registered, ign_votes, email_confirmed, notify_dbedit, notify_announce, c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags, perm_imgvote),
- UPDATE ( c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags ) ON users TO vndb_multi;
+GRANT SELECT (id, username, registered, ign_votes, email_confirmed, notify_dbedit, notify_announce, notify_post, notify_comment, c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags, perm_imgvote),
+ UPDATE ( c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags ) ON users TO vndb_multi;
GRANT DELETE ON users TO vndb_multi;
GRANT SELECT, UPDATE ON vn TO vndb_multi;
diff --git a/sql/schema.sql b/sql/schema.sql
index da475d5d..3605e9cc 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -57,20 +57,22 @@ CREATE TYPE edit_rettype AS (itemid integer, chid integer, rev integer);
CREATE TYPE gender AS ENUM ('unknown', 'm', 'f', 'b');
CREATE TYPE language AS ENUM ('ar', 'bg', 'ca', 'cs', 'da', 'de', 'el', 'en', 'eo', 'es', 'fi', 'fr', 'gd', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'mk', 'ms', 'lt', 'lv', 'nl', 'no', 'pl', 'pt-pt', 'pt-br', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'th', 'tr', 'uk', 'vi', 'zh');
CREATE TYPE medium AS ENUM ('cd', 'dvd', 'gdr', 'blr', 'flp', 'mrt', 'mem', 'umd', 'nod', 'in', 'otc');
-CREATE TYPE notification_ntype AS ENUM ('pm', 'dbdel', 'listdel', 'dbedit', 'announce');
-CREATE TYPE notification_ltype AS ENUM ('v', 'r', 'p', 'c', 't', 's', 'd');
+CREATE TYPE notification_ntype AS ENUM ('pm', 'dbdel', 'listdel', 'dbedit', 'announce', 'post', 'comment');
CREATE TYPE platform AS ENUM ('win', 'dos', 'lin', 'mac', 'ios', 'and', 'dvd', 'bdp', 'fmt', 'gba', 'gbc', 'msx', 'nds', 'nes', 'p88', 'p98', 'pce', 'pcf', 'psp', 'ps1', 'ps2', 'ps3', 'ps4', 'psv', 'drc', 'sat', 'sfc', 'swi', 'wii', 'wiu', 'n3d', 'x68', 'xb1', 'xb3', 'xbo', 'web', 'oth');
CREATE TYPE producer_type AS ENUM ('co', 'in', 'ng');
CREATE TYPE producer_relation AS ENUM ('old', 'new', 'sub', 'par', 'imp', 'ipa', 'spa', 'ori');
CREATE TYPE release_type AS ENUM ('complete', 'partial', 'trial');
+CREATE TYPE 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');
--- Sequences used for ID generation of items not in the DB
-CREATE SEQUENCE covers_seq;
+-- Sequences used for ID generation
CREATE SEQUENCE charimg_seq;
+CREATE SEQUENCE covers_seq;
+CREATE SEQUENCE reviews_seq;
CREATE SEQUENCE screenshots_seq;
+CREATE SEQUENCE threads_id_seq;
@@ -245,16 +247,15 @@ CREATE TABLE login_throttle (
-- notifications
CREATE TABLE notifications (
- id serial PRIMARY KEY,
- uid integer NOT NULL,
- date timestamptz NOT NULL DEFAULT NOW(),
- read timestamptz,
- ntype notification_ntype NOT NULL,
- ltype notification_ltype NOT NULL,
- iid integer NOT NULL,
- subid integer,
- c_title text NOT NULL,
- c_byuser integer NOT NULL DEFAULT 0
+ id serial PRIMARY KEY,
+ uid integer NOT NULL,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ read timestamptz,
+ ntype notification_ntype NOT NULL,
+ iid vndbid NOT NULL,
+ num integer,
+ c_title text NOT NULL,
+ c_byuser integer
);
-- producers
@@ -452,7 +453,7 @@ CREATE TABLE releases_producers (
pid integer NOT NULL, -- [pub] producers.id
developer boolean NOT NULL DEFAULT FALSE, -- [pub]
publisher boolean NOT NULL DEFAULT TRUE, -- [pub]
- CHECK(developer OR publisher),
+ CONSTRAINT releases_producers_check1 CHECK(developer OR publisher),
PRIMARY KEY(id, pid)
);
@@ -480,6 +481,61 @@ CREATE TABLE releases_vn_hist (
PRIMARY KEY(chid, vid)
);
+-- reports
+CREATE TABLE reports (
+ id SERIAL PRIMARY KEY,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ lastmod timestamptz,
+ uid integer, -- user who created the report, if logged in
+ ip inet, -- IP address of the visitor, if not logged in
+ reason text NOT NULL,
+ status report_status NOT NULL DEFAULT 'new',
+ object vndbid NOT NULL, -- The id of the thing being reported
+ message text NOT NULL,
+ log text NOT NULL DEFAULT '',
+ objectnum integer -- The sub-id of the thing to be reported
+);
+
+-- 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 int NOT NULL,
+ uid int,
+ rid int,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ lastmod timestamptz,
+ text text,
+ spoiler boolean NOT NULL,
+ c_up int NOT NULL DEFAULT 0,
+ c_down int NOT NULL DEFAULT 0,
+ c_count smallint NOT NULL DEFAULT 0,
+ c_lastnum smallint,
+ isfull boolean NOT NULL,
+ c_flagged boolean NOT NULL DEFAULT false,
+ locked boolean NOT NULL DEFAULT false
+);
+
+-- reviews_posts
+CREATE TABLE reviews_posts (
+ id vndbid NOT NULL,
+ num smallint NOT NULL,
+ uid integer,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ edited timestamptz,
+ hidden boolean NOT NULL DEFAULT FALSE,
+ msg text NOT NULL DEFAULT '',
+ PRIMARY KEY(id, num)
+);
+
+-- reviews_votes
+CREATE TABLE reviews_votes (
+ id vndbid NOT NULL,
+ uid int,
+ date timestamptz NOT NULL,
+ vote boolean NOT NULL, -- true = upvote, false = downvote
+ overrule boolean NOT NULL DEFAULT false
+);
+
-- rlists
CREATE TABLE rlists (
uid integer NOT NULL DEFAULT 0, -- [pub]
@@ -656,49 +712,50 @@ CREATE TABLE tags_vn_inherit (
-- threads
CREATE TABLE threads (
- id SERIAL NOT NULL PRIMARY KEY,
+ id vndbid PRIMARY KEY DEFAULT vndbid('t', nextval('threads_id_seq')::int) CONSTRAINT threads_id_check CHECK(vndbid_type(id) = 't'),
title varchar(50) NOT NULL DEFAULT '',
locked boolean NOT NULL DEFAULT FALSE,
hidden boolean NOT NULL DEFAULT FALSE,
- count smallint NOT NULL DEFAULT 0,
poll_question varchar(100),
poll_max_options smallint NOT NULL DEFAULT 1,
poll_preview boolean NOT NULL DEFAULT FALSE, -- deprecated
poll_recast boolean NOT NULL DEFAULT FALSE, -- deprecated
- private boolean NOT NULL DEFAULT FALSE
+ private boolean NOT NULL DEFAULT FALSE,
+ c_count smallint NOT NULL DEFAULT 0, -- Number of non-hidden posts
+ c_lastnum smallint NOT NULL DEFAULT 1 -- 'num' of the most recent non-hidden post
);
-- threads_poll_options
CREATE TABLE threads_poll_options (
id SERIAL PRIMARY KEY,
- tid integer NOT NULL,
+ tid vndbid NOT NULL,
option varchar(100) NOT NULL
);
-- threads_poll_votes
CREATE TABLE threads_poll_votes (
- tid integer NOT NULL,
uid integer NOT NULL,
optid integer NOT NULL,
date timestamptz DEFAULT NOW(),
- PRIMARY KEY (tid, uid, optid)
+ PRIMARY KEY (optid, uid)
);
-- threads_posts
CREATE TABLE threads_posts (
- tid integer NOT NULL DEFAULT 0,
- num smallint NOT NULL DEFAULT 0,
- uid integer NOT NULL DEFAULT 0,
+ tid vndbid NOT NULL,
+ num smallint NOT NULL,
+ uid integer,
date timestamptz NOT NULL DEFAULT NOW(),
edited timestamptz,
- msg text NOT NULL DEFAULT '',
hidden boolean NOT NULL DEFAULT FALSE,
- PRIMARY KEY(tid, num)
+ msg text NOT NULL DEFAULT '',
+ PRIMARY KEY(tid, num),
+ CONSTRAINT threads_posts_first_nonhidden CHECK(num > 1 OR NOT hidden)
);
-- threads_boards
CREATE TABLE threads_boards (
- tid integer NOT NULL DEFAULT 0,
+ tid vndbid NOT NULL,
type board_type NOT NULL,
iid integer NOT NULL DEFAULT 0,
PRIMARY KEY(tid, type, iid)
@@ -715,6 +772,7 @@ CREATE TABLE trace_log (
sql_num integer,
sql_time float,
perl_time float,
+ has_txn boolean,
loggedin boolean,
elm_mods text[]
);
@@ -809,7 +867,7 @@ CREATE TABLE users (
customcss text NOT NULL DEFAULT '',
filter_vn text NOT NULL DEFAULT '',
filter_release text NOT NULL DEFAULT '',
- show_nsfw boolean NOT NULL DEFAULT FALSE,
+ show_nsfw boolean NOT NULL DEFAULT FALSE, -- Not used anymore, replaced by max_sexual and max_violence
notify_dbedit boolean NOT NULL DEFAULT TRUE,
notify_announce boolean NOT NULL DEFAULT FALSE,
vn_list_own boolean NOT NULL DEFAULT FALSE,
@@ -845,7 +903,11 @@ CREATE TABLE users (
perm_usermod boolean NOT NULL DEFAULT false,
perm_imgmod boolean NOT NULL DEFAULT false,
max_sexual smallint NOT NULL DEFAULT 0,
- max_violence smallint NOT NULL DEFAULT 0
+ max_violence smallint NOT NULL DEFAULT 0,
+ last_reports timestamptz, -- For mods: Most recent activity seen on the reports listing
+ perm_review boolean NOT NULL DEFAULT false, -- TODO: DEFAULT true when out of beta.
+ notify_post boolean NOT NULL DEFAULT true,
+ notify_comment boolean NOT NULL DEFAULT true
);
-- vn
@@ -857,7 +919,7 @@ CREATE TABLE vn ( -- dbentry_type=v
original varchar(250) NOT NULL DEFAULT '', -- [pub]
alias varchar(500) NOT NULL DEFAULT '', -- [pub]
length smallint NOT NULL DEFAULT 0, -- [pub]
- img_nsfw boolean NOT NULL DEFAULT FALSE, -- [pub]
+ img_nsfw boolean NOT NULL DEFAULT FALSE, -- (deprecated)
image vndbid CONSTRAINT vn_image_check CHECK(vndbid_type(image) = 'cv'), -- [pub]
"desc" text NOT NULL DEFAULT '', -- [pub]
l_wp varchar(150) NOT NULL DEFAULT '', -- [pub] (deprecated)
@@ -929,7 +991,7 @@ CREATE TABLE vn_screenshots (
id integer NOT NULL, -- [pub]
scr vndbid NOT NULL CONSTRAINT vn_screenshots_scr_check CHECK(vndbid_type(scr) = 'sf'), -- [pub] images.id
rid integer, -- [pub] releases.id (only NULL for old revisions, nowadays not allowed anymore)
- nsfw boolean NOT NULL DEFAULT FALSE, -- [pub]
+ nsfw boolean NOT NULL DEFAULT FALSE, -- (deprecated)
PRIMARY KEY(id, scr)
);
diff --git a/sql/tableattrs.sql b/sql/tableattrs.sql
index 60fc1bb9..f00db33d 100644
--- a/sql/tableattrs.sql
+++ b/sql/tableattrs.sql
@@ -11,11 +11,11 @@ ALTER TABLE chars_traits ADD CONSTRAINT chars_traits_tid_fkey
ALTER TABLE chars_traits_hist ADD CONSTRAINT chars_traits_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE chars_traits_hist ADD CONSTRAINT chars_traits_hist_tid_fkey FOREIGN KEY (tid) REFERENCES traits (id);
ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_id_fkey FOREIGN KEY (id) REFERENCES chars (id);
-ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id) DEFERRABLE;
-ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) DEFERRABLE;
+ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
-ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id) DEFERRABLE;
-ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) DEFERRABLE;
+ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
ALTER TABLE 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 notifications ADD CONSTRAINT notifications_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
@@ -42,6 +42,13 @@ 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 reviews ADD CONSTRAINT reviews_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id) ON DELETE CASCADE;
+ALTER TABLE reviews ADD CONSTRAINT reviews_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews ADD CONSTRAINT reviews_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews_posts ADD CONSTRAINT reviews_posts_id_fkey FOREIGN KEY (id) REFERENCES reviews (id) ON DELETE CASCADE;
+ALTER TABLE reviews_posts ADD CONSTRAINT reviews_posts_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews_votes ADD CONSTRAINT reviews_votes_id_fkey FOREIGN KEY (id) REFERENCES reviews (id) ON DELETE CASCADE;
+ALTER TABLE reviews_votes ADD CONSTRAINT reviews_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE rlists ADD CONSTRAINT rlists_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE rlists ADD CONSTRAINT rlists_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
ALTER TABLE sessions ADD CONSTRAINT sessions_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
@@ -58,14 +65,12 @@ ALTER TABLE tags_parents ADD CONSTRAINT tags_parents_parent_fkey
ALTER TABLE tags_vn ADD CONSTRAINT tags_vn_tag_fkey FOREIGN KEY (tag) REFERENCES tags (id);
ALTER TABLE tags_vn ADD CONSTRAINT tags_vn_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
ALTER TABLE tags_vn ADD CONSTRAINT tags_vn_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
-ALTER TABLE threads ADD CONSTRAINT threads_id_fkey FOREIGN KEY (id, count) REFERENCES threads_posts (tid, num) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE threads_poll_options ADD CONSTRAINT threads_poll_options_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
-ALTER TABLE threads_poll_votes ADD CONSTRAINT threads_poll_votes_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
ALTER TABLE threads_poll_votes ADD CONSTRAINT threads_poll_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE threads_poll_votes ADD CONSTRAINT threads_poll_votes_optid_fkey FOREIGN KEY (optid) REFERENCES threads_poll_options (id) ON DELETE CASCADE;
-ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id);
+ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
-ALTER TABLE threads_boards ADD CONSTRAINT threads_boards_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id);
+ALTER TABLE 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);
@@ -92,10 +97,10 @@ ALTER TABLE vn_relations_hist ADD CONSTRAINT vn_relations_chid_fkey
ALTER TABLE vn_relations_hist ADD CONSTRAINT vn_relations_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_scr_fkey FOREIGN KEY (scr) REFERENCES images (id);
-ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) DEFERRABLE;
+ALTER TABLE vn_screenshots ADD CONSTRAINT vn_screenshots_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_scr_fkey FOREIGN KEY (scr) REFERENCES images (id);
-ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) DEFERRABLE;
+ALTER TABLE vn_screenshots_hist ADD CONSTRAINT vn_screenshots_hist_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
ALTER TABLE vn_seiyuu ADD CONSTRAINT vn_seiyuu_id_fkey FOREIGN KEY (id) REFERENCES vn (id);
ALTER TABLE vn_seiyuu ADD CONSTRAINT vn_seiyuu_aid_fkey FOREIGN KEY (aid) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE vn_seiyuu ADD CONSTRAINT vn_seiyuu_cid_fkey FOREIGN KEY (cid) REFERENCES chars (id);
@@ -117,6 +122,12 @@ CREATE INDEX image_votes_id ON image_votes (id);
CREATE INDEX notifications_uid ON notifications (uid);
CREATE INDEX releases_producers_pid ON releases_producers (pid);
CREATE INDEX releases_vn_vid ON releases_vn (vid);
+CREATE INDEX 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 INDEX staff_alias_id ON staff_alias (id);
CREATE UNIQUE INDEX tags_vn_pkey ON tags_vn (tag,vid,uid);
CREATE INDEX tags_vn_date ON tags_vn (date);
diff --git a/sql/triggers.sql b/sql/triggers.sql
index b7ade0c4..2fd34f1c 100644
--- a/sql/triggers.sql
+++ b/sql/triggers.sql
@@ -191,7 +191,7 @@ CREATE TRIGGER ulist_voted_label AFTER INSERT OR UPDATE ON ulist_vns FOR EACH RO
--- NOTIFY on insert into changes/posts/tags/trait
+-- NOTIFY on insert into changes/posts/tags/trait/reviews
CREATE OR REPLACE FUNCTION insert_notify() RETURNS trigger AS $$
BEGIN
@@ -203,6 +203,8 @@ BEGIN
NOTIFY newtag;
ELSIF TG_TABLE_NAME = 'traits' THEN
NOTIFY newtrait;
+ ELSIF TG_TABLE_NAME = 'reviews' THEN
+ NOTIFY newreview;
END IF;
RETURN NULL;
END;
@@ -212,6 +214,7 @@ CREATE TRIGGER insert_notify AFTER INSERT ON changes FOR EACH STATEMENT EX
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();
@@ -229,8 +232,8 @@ CREATE TRIGGER vn_vnsearch_notify AFTER UPDATE ON vn FOR EACH ROW WHEN (OLD.c_se
CREATE OR REPLACE FUNCTION notify_pm() RETURNS trigger AS $$
BEGIN
- INSERT INTO notifications (ntype, ltype, uid, iid, subid, c_title, c_byuser)
- SELECT 'pm', 't', tb.iid, t.id, NEW.num, t.title, NEw.uid
+ INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
+ SELECT 'pm', tb.iid, t.id, NEW.num, t.title, NEW.uid
FROM threads t
JOIN threads_boards tb ON tb.tid = t.id
WHERE t.id = NEW.tid
@@ -240,7 +243,6 @@ BEGIN
SELECT 1
FROM notifications n
WHERE n.uid = tb.iid
- AND n.ntype = 'pm'
AND n.iid = t.id
AND n.read IS NULL
);
@@ -257,8 +259,8 @@ CREATE TRIGGER notify_pm AFTER INSERT ON threads_posts FOR EACH ROW EXECUTE PROC
CREATE OR REPLACE FUNCTION notify_announce() RETURNS trigger AS $$
BEGIN
- INSERT INTO notifications (ntype, ltype, uid, iid, subid, c_title, c_byuser)
- SELECT 'announce', 't', u.id, t.id, 1, t.title, NEW.uid
+ INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
+ SELECT 'announce', u.id, t.id, 1, t.title, NEW.uid
FROM threads t
JOIN threads_boards tb ON tb.tid = t.id
-- get the users who want this announcement
@@ -275,6 +277,99 @@ CREATE TRIGGER notify_announce AFTER INSERT ON threads_posts FOR EACH ROW WHEN (
+-- Add a notification on new posts
+
+CREATE OR REPLACE FUNCTION notify_post() RETURNS trigger AS $$
+BEGIN
+ INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
+ SELECT DISTINCT 'post'::notification_ntype, u.id, t.id, NEW.num, t.title, NEW.uid
+ FROM threads t
+ JOIN threads_posts tp ON tp.tid = t.id
+ JOIN users u ON tp.uid = u.id
+ WHERE t.id = NEW.tid
+ AND u.notify_post
+ AND u.id <> NEW.uid
+ AND NOT t.hidden
+ AND NOT t.private -- don't leak posts in private threads, these are handled by notify_pm anyway
+ AND NOT EXISTS( -- don't notify when you haven't read an earlier post in the thread yet (also avoids double notification with notify_pm)
+ SELECT 1
+ FROM notifications n
+ WHERE n.uid = u.id
+ AND n.iid = t.id
+ AND n.read IS NULL
+ );
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER notify_post AFTER INSERT ON threads_posts FOR EACH ROW EXECUTE PROCEDURE notify_post();
+
+
+
+
+-- Add a notification on new comment to review
+
+CREATE OR REPLACE FUNCTION notify_comment() RETURNS trigger AS $$
+BEGIN
+ INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
+ SELECT 'comment', u.id, w.id, NEW.num, v.title, NEW.uid
+ FROM reviews w
+ JOIN vn v ON v.id = w.vid
+ JOIN users u ON w.uid = u.id
+ WHERE w.id = NEW.id
+ AND u.notify_comment
+ AND u.id <> NEW.uid
+ AND NOT EXISTS( -- don't notify when you haven't read earlier comments yet
+ SELECT 1
+ FROM notifications n
+ WHERE n.uid = u.id
+ AND n.iid = w.id
+ AND n.read IS NULL
+ );
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER notify_comment AFTER INSERT ON reviews_posts FOR EACH ROW EXECUTE PROCEDURE notify_comment();
+
+
+
+
+-- Update threads.c_count and c_lastnum
+
+CREATE OR REPLACE FUNCTION update_threads_cache() RETURNS trigger AS $$
+BEGIN
+ UPDATE threads
+ SET c_count = (SELECT COUNT(*) FROM threads_posts WHERE NOT hidden AND tid = threads.id)
+ , c_lastnum = (SELECT MAX(num) FROM threads_posts WHERE NOT hidden AND tid = threads.id)
+ WHERE id IN(OLD.tid,NEW.tid);
+ RETURN NULL;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER update_threads_cache AFTER INSERT OR UPDATE OR DELETE ON threads_posts FOR EACH ROW EXECUTE PROCEDURE update_threads_cache();
+
+
+
+
+-- Update reviews.c_count and c_lastnum
+
+CREATE OR REPLACE FUNCTION update_reviews_cache() RETURNS trigger AS $$
+BEGIN
+ UPDATE reviews
+ SET c_count = COALESCE((SELECT COUNT(*) FROM reviews_posts WHERE NOT hidden AND id = reviews.id), 0)
+ , c_lastnum = (SELECT MAX(num) FROM reviews_posts WHERE NOT hidden AND id = reviews.id)
+ WHERE id IN(OLD.id,NEW.id);
+ RETURN NULL;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER update_reviews_cache AFTER INSERT OR UPDATE OR DELETE ON reviews_posts FOR EACH ROW EXECUTE PROCEDURE update_reviews_cache();
+
+
+
+
+
-- Call update_images_cache() for every change on image_votes
CREATE OR REPLACE FUNCTION update_images_cache() RETURNS trigger AS $$
@@ -286,3 +381,17 @@ $$ LANGUAGE plpgsql;
CREATE TRIGGER image_votes_cache1 AFTER INSERT OR DELETE ON image_votes FOR EACH ROW EXECUTE PROCEDURE update_images_cache();
CREATE TRIGGER image_votes_cache2 AFTER UPDATE ON image_votes FOR EACH ROW WHEN ((OLD.id, OLD.sexual, OLD.violence, OLD.ignore) IS DISTINCT FROM (NEW.id, NEW.sexual, NEW.violence, NEW.ignore)) EXECUTE PROCEDURE update_images_cache();
+
+
+
+
+-- Call update_reviews_votes_cache() for every change on reviews_votes
+
+CREATE OR REPLACE FUNCTION update_reviews_votes_cache() RETURNS trigger AS $$
+BEGIN
+ PERFORM update_reviews_votes_cache(id) FROM (SELECT OLD.id UNION SELECT NEW.id) AS x(id) WHERE id IS NOT NULL;
+ RETURN NULL;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER reviews_votes_cache AFTER INSERT OR UPDATE OR DELETE ON reviews_votes FOR EACH ROW EXECUTE PROCEDURE update_reviews_votes_cache();
diff --git a/static/s/angel/conf b/static/s/angel/conf
index 4fe674ac..f0317919 100644
--- a/static/s/angel/conf
+++ b/static/s/angel/conf
@@ -3,7 +3,7 @@ userid 2
// text
maintext #ddd // primary text color (also used for the menu links)
-grayedout #258 // color used for grayed-out/non-important things
+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)
diff --git a/util/bbcode-test.pl b/util/bbcode-test.pl
index 0ad0f3ea..e306c952 100755
--- a/util/bbcode-test.pl
+++ b/util/bbcode-test.pl
@@ -12,7 +12,7 @@ use Benchmark 'timethese';
our($ROOT, %S);
BEGIN { ($ROOT = abs_path $0) =~ s{/util/bbcode-test\.pl$}{}; }
use lib "$ROOT/lib";
-use VNDB::BBCode qw/bb2html bb2text/;
+use VNDB::BBCode;
my @tests = (
@@ -30,27 +30,43 @@ my @tests = (
'[quote]some quote[/quote]',
'<div class="quote">some quote</div>',
- 'some quote',
+ '"some quote"',
"[code]some code\n\nalso newlines;[/code]",
'<pre>some code<br><br>also newlines;</pre>',
- "some code\n\nalso newlines;",
+ "`some code\n\nalso newlines;`",
'[spoiler]some spoiler[/spoiler]',
'<b class="spoiler">some spoiler</b>',
'',
+ '[b][i][u][s]Formatting![/s][/u][/i][/b]',
+ '<b><em><span class="underline"><s>Formatting!</s></span></em></b>',
+ '*/_-Formatting!-_/*',
+
"[raw][quote]not parsed\n[url=https://vndb.org/]valid url[/url]\n[url=asdf]invalid url[/url][/quote][/raw]",
"[quote]not parsed<br>[url=https://vndb.org/]valid url[/url]<br>[url=asdf]invalid url[/url][/quote]",
"[quote]not parsed\n[url=https://vndb.org/]valid url[/url]\n[url=asdf]invalid url[/url][/quote]",
'[quote]basic [spoiler]single[/spoiler]-line [spoiler][url=/g]tag[/url] nesting [raw](without [url=/v3333]special[/url] cases)[/raw][/spoiler][/quote]',
'<div class="quote">basic <b class="spoiler">single</b>-line <b class="spoiler"><a href="/g" rel="nofollow">tag</a> nesting (without [url=/v3333]special[/url] cases)</b></div>',
- 'basic -line ',
+ '"basic -line "',
+
+ '[quote][b]more [spoiler]nesting [code]mkay?',
+ '<div class="quote"><b>more <b class="spoiler">nesting [code]mkay?</b></b></div>',
+ '"*more *"',
+
+ '[url=/v][b]does not work here[/b][/url]',
+ '<a href="/v" rel="nofollow">[b]does not work here[/b]</a>',
+ '[b]does not work here[/b]',
+
+ '[s] v5 [url=/p1]x[/url] [/s]',
+ '<s> <a href="/v5">v5</a> <a href="/p1" rel="nofollow">x</a> </s>',
+ '- v5 x -',
"[quote]rmnewline after closing tag[/quote]\n",
'<div class="quote">rmnewline after closing tag</div>',
- "rmnewline after closing tag\n",
+ "\"rmnewline after closing tag\"",
'[url=/v19]some vndb url[/url]',
'<a href="/v19" rel="nofollow">some vndb url</a>',
@@ -58,20 +74,20 @@ my @tests = (
"quite\n\n\n\n\n\n\na\n\n\n\n\n lot of\n\n\n\nunneeded whitespace",
'quite<br><br>a<br><br> lot of<br><br><br><br>unneeded whitespace',
- "quite\n\n\n\n\n\n\na\n\n\n\n\n lot of\n\n\n\nunneeded whitespace",
+ "quite\n\na\n\n lot of\n\n\n\nunneeded whitespace",
"[quote]\nsimple\nrmnewline\ntest\n[/quote]",
'<div class="quote">simple<br>rmnewline<br>test<br></div>',
- "\nsimple\nrmnewline\ntest\n",
+ "\"simple\nrmnewline\ntest\n\"",
# the new implementation doesn't special-case [code], as the first newline shouldn't matter either way
"[quote]\n\nhello, rmnewline test[code]\n#!/bin/sh\n\nfunction random_username() {\n </dev/urandom tr -cd 'a-zA-Z0-9' | dd bs=1 count=16 2>/dev/null\n}\n[/code]\nsome text after the code tag\n[/quote]\n\n[spoiler]\nsome newlined spoiler\n[/spoiler]",
'<div class="quote"><br>hello, rmnewline test<pre>#!/bin/sh<br><br>function random_username() {<br> &lt;/dev/urandom tr -cd \'a-zA-Z0-9\' | dd bs=1 count=16 2&gt;/dev/null<br>}<br></pre>some text after the code tag<br></div><br><b class="spoiler"><br>some newlined spoiler<br></b>',
- "\n\nhello, rmnewline test\n#!/bin/sh\n\nfunction random_username() {\n </dev/urandom tr -cd 'a-zA-Z0-9' | dd bs=1 count=16 2>/dev/null\n}\n\nsome text after the code tag\n\n\n",
+ "\"\nhello, rmnewline test`#!/bin/sh\n\nfunction random_username() {\n </dev/urandom tr -cd 'a-zA-Z0-9' | dd bs=1 count=16 2>/dev/null\n}\n`some text after the code tag\n\"\n",
"[quote]\n[raw]\nrmnewline test with made-up elements\n[/raw]\nwelp\n[dumbtag]\nnone\n[/dumbtag]\n[/quote]",
'<div class="quote"><br>rmnewline test with made-up elements<br><br>welp<br>[dumbtag]<br>none<br>[/dumbtag]<br></div>',
- "\n\nrmnewline test with made-up elements\n\nwelp\n[dumbtag]\nnone\n[/dumbtag]\n",
+ "\"\nrmnewline test with made-up elements\n\nwelp\n[dumbtag]\nnone\n[/dumbtag]\n\"",
'[url=http://example.com/]markup in [raw][url][/raw][/url]',
'<a href="http://example.com/" rel="nofollow">markup in [url]</a>',
@@ -95,23 +111,23 @@ my @tests = (
'[Quote]non-lowercase tags [SpOILER]here[/sPOilER][/qUOTe]',
'<div class="quote">non-lowercase tags <b class="spoiler">here</b></div>',
- 'non-lowercase tags ',
+ '"non-lowercase tags "',
'some text [spoiler]with (v17) tags[/spoiler] and internal ids such as s1',
'some text <b class="spoiler">with (<a href="/v17">v17</a>) tags</b> and internal ids such as <a href="/s1">s1</a>',
'some text and internal ids such as s1',
- 'r12.1 v6.3 s1.2',
- '<a href="/r12.1">r12.1</a> <a href="/v6.3">v6.3</a> <a href="/s1.2">s1.2</a>',
- 'r12.1 v6.3 s1.2',
+ 'r12.1 v6.3 s1.2 w5.3',
+ '<a href="/r12.1">r12.1</a> <a href="/v6.3">v6.3</a> <a href="/s1.2">s1.2</a> <a href="/w5.3">w5.3</a>',
+ 'r12.1 v6.3 s1.2 w5.3',
'd3 d1.3 d2#4 d5#6.7',
'<a href="/d3">d3</a> <a href="/d1.3">d1.3</a> <a href="/d2#4">d2#4</a> <a href="/d5#6.7">d5#6.7</a>',
'd3 d1.3 d2#4 d5#6.7',
- 'v17 text dds16v21 more text1 v9 _d5_ d3-',
- '<a href="/v17">v17</a> text dds16v21 more text1 <a href="/v9">v9</a> _d5_ d3-',
- 'v17 text dds16v21 more text1 v9 _d5_ d3-',
+ 'v17 text dds16v21 more text1 v9 _d5_ d3- m10',
+ '<a href="/v17">v17</a> text dds16v21 more text1 <a href="/v9">v9</a> _d5_ d3- m10',
+ 'v17 text dds16v21 more text1 v9 _d5_ d3- m10',
# https://vndb.org/t2520.233
'[From[url=http://densetsu.com/display.php?id=468&style=alphabetical] Anime Densetsu[/url]]',
@@ -136,11 +152,11 @@ my @tests = (
# TODO: This isn't ideal
'[quote][spoiler]stray open tag (nested)[/quote]',
'<div class="quote"><b class="spoiler">stray open tag (nested)[/quote]</b></div>',
- '',
+ '""',
'[quote][spoiler]two stray open tags',
'<div class="quote"><b class="spoiler">two stray open tags</b></div>',
- '',
+ '""',
"[url=https://cat.xyz/]that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination[/url]",
'<a href="https://cat.xyz/" rel="nofollow">that\'s [spoiler]some [quote]uncommon[/quote][/spoiler] combination</a>',
@@ -154,13 +170,21 @@ my @tests = (
#'<a href="http://[fedc:ba98:7654:3210:fedc:ba98:7654:3210]/some/path" rel="nofollow">link</a> (literal ipv6 address)',
# test shortening
- [ "[url=https://cat.xyz/]that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination[/url]", 10 ],
+ [ "[url=https://cat.xyz/]that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination[/url]", maxlength => 10 ],
'<a href="https://cat.xyz/" rel="nofollow">that\'s </a>',
- "that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination",
+ "that's ",
- [ "A https://blicky.net/ only takes 4 characters", 8 ],
+ [ "A https://blicky.net/ only takes 4 characters", maxlength => 8 ],
'A <a href="https://blicky.net/" rel="nofollow">link</a>',
- "A https://blicky.net/ only takes 4 characters",
+ "A https", # Yeah, uh... word boundary
+
+ [ 'vndbids only [url=/v9]nothing[/url] [b] [spoiler]p5', idonly => 1 ],
+ 'vndbids only [url=/v9]nothing[/url] [b] [spoiler]<a href="/p5">p5</a>',
+ 'vndbids only [url=/v9]nothing[/url] [b] [spoiler]p5',
+
+ [ 'this [spoiler]spoiler will be[/spoiler] kept', keepspoil => 1 ],
+ 'this spoiler will be kept',
+ 'this spoiler will be kept',
);
@@ -196,8 +220,8 @@ sub test {
my @arg = ref $input ? @$input : ($input);
(my $msg = $arg[0]) =~ s/\n/\\n/g;
is identity($arg[0]), $arg[0], "id: $msg";
- is bb2html(@arg), $html, "html: $msg";
- is bb2text($arg[0]), $plain, "plain: $msg";
+ is bb_format(@arg), $html, "html: $msg";
+ is bb_format(@arg, text => 1), $plain, "plain: $msg";
}
}
@@ -208,9 +232,9 @@ sub bench {
my $short = "Nobody ev3r v10 uses v5 so s1 many [url=https://blicky.net/]x[raw]y[/raw][/url] tags. ";
my $heavy = $short x100;
timethese(0, {
- short => sub { bb2html($short) },
- plain => sub { bb2html($plain) },
- heavy => sub { bb2html($heavy) },
+ short => sub { bb_format($short) },
+ plain => sub { bb_format($plain) },
+ heavy => sub { bb_format($heavy) },
});
# old:
# heavy: 3 wallclock secs ( 3.15 usr + 0.00 sys = 3.15 CPU) @ 357.46/s (n=1126)
diff --git a/util/devdump.pl b/util/devdump.pl
index af7bd776..73c79566 100755
--- a/util/devdump.pl
+++ b/util/devdump.pl
@@ -137,7 +137,7 @@ sub copy_entry {
copy image_votes => "SELECT DISTINCT ON (id,uid%10) * FROM image_votes WHERE id IN($image_ids)", { uid => 'user' };
# Threads (announcements)
- my $threads = join ',', @{ $db->selectcol_arrayref("SELECT tid FROM threads_boards b WHERE b.type = 'an'") };
+ my $threads = join ',', map "'$_'", @{ $db->selectcol_arrayref("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' };
diff --git a/util/unusedimages.pl b/util/unusedimages.pl
index 55797c97..019d3c9d 100755
--- a/util/unusedimages.pl
+++ b/util/unusedimages.pl
@@ -62,6 +62,8 @@ sub cleandb {
UNION ALL SELECT description FROM traits
UNION ALL SELECT comments FROM changes
UNION ALL SELECT msg FROM threads_posts
+ UNION ALL SELECT msg FROM reviews_posts
+ UNION ALL SELECT text FROM reviews
) x(text), regexp_matches(text, '}.$fnmatch.q{', 'g') as y(img)
)
) x
diff --git a/util/updates/2020-07-23-reports.sql b/util/updates/2020-07-23-reports.sql
new file mode 100644
index 00000000..1738fd72
--- /dev/null
+++ b/util/updates/2020-07-23-reports.sql
@@ -0,0 +1,19 @@
+CREATE TYPE report_status AS ENUM ('new', 'busy', 'done', 'dismissed');
+CREATE TYPE report_type AS ENUM ('t');
+
+CREATE TABLE reports (
+ id SERIAL PRIMARY KEY,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ lastmod timestamptz,
+ uid integer, -- user who created the report, if logged in
+ ip inet, -- IP address of the visitor, if not logged in
+ reason text NOT NULL,
+ rtype report_type NOT NULL,
+ status report_status NOT NULL DEFAULT 'new',
+ object text NOT NULL, -- The id of the thing being reported
+ message text NOT NULL,
+ log text NOT NULL DEFAULT ''
+);
+CREATE INDEX reports_status ON reports (status,id);
+
+\i sql/perms.sql
diff --git a/util/updates/2020-07-25-report-db.sql b/util/updates/2020-07-25-report-db.sql
new file mode 100644
index 00000000..54dbd03d
--- /dev/null
+++ b/util/updates/2020-07-25-report-db.sql
@@ -0,0 +1,2 @@
+ALTER TYPE report_type ADD VALUE 'db' AFTER 't';
+
diff --git a/util/updates/2020-07-29-reports-last-seen.sql b/util/updates/2020-07-29-reports-last-seen.sql
new file mode 100644
index 00000000..e38c5f54
--- /dev/null
+++ b/util/updates/2020-07-29-reports-last-seen.sql
@@ -0,0 +1,5 @@
+ALTER TABLE users ADD COLUMN last_reports timestamptz;
+DROP INDEX reports_status;
+CREATE INDEX reports_new ON reports (date) WHERE status = 'new';
+CREATE INDEX reports_lastmod ON reports (lastmod);
+\i sql/perms.sql
diff --git a/util/updates/2020-08-07-schema-sync.sql b/util/updates/2020-08-07-schema-sync.sql
new file mode 100644
index 00000000..9e6229da
--- /dev/null
+++ b/util/updates/2020-08-07-schema-sync.sql
@@ -0,0 +1,14 @@
+-- The credit_type definition used in production was... wrong.
+-- It had more values than in the schema and values were ordered incorrectly.
+-- Redefine it with the proper definition.
+ALTER TYPE credit_type RENAME TO old_credit_type;
+CREATE TYPE credit_type AS ENUM ('scenario', 'chardesign', 'art', 'music', 'songs', 'director', 'staff');
+
+ALTER TABLE vn_staff ALTER COLUMN role DROP DEFAULT;
+ALTER TABLE vn_staff ALTER COLUMN role TYPE credit_type USING role::text::credit_type;
+ALTER TABLE vn_staff ALTER COLUMN role SET DEFAULT 'staff';
+ALTER TABLE vn_staff_hist ALTER COLUMN role DROP DEFAULT;
+ALTER TABLE vn_staff_hist ALTER COLUMN role TYPE credit_type USING role::text::credit_type;
+ALTER TABLE vn_staff_hist ALTER COLUMN role SET DEFAULT 'staff';
+
+DROP TYPE old_credit_type;
diff --git a/util/updates/2020-08-07-threads.sql b/util/updates/2020-08-07-threads.sql
new file mode 100644
index 00000000..ede9621b
--- /dev/null
+++ b/util/updates/2020-08-07-threads.sql
@@ -0,0 +1,46 @@
+-- * Convert thread identifiers to vndbids
+-- * Remove threads_poll_votes.tid
+-- * Add two ON DELETE CASCADE's
+-- * Replace threads.count with threads.c_(count,lastnum)
+
+ALTER TABLE threads_poll_votes DROP COLUMN tid;
+ALTER TABLE threads_poll_votes ADD PRIMARY KEY (optid,uid);
+
+ALTER TABLE threads_poll_options DROP CONSTRAINT threads_poll_options_tid_fkey;
+ALTER TABLE threads_poll_options ALTER COLUMN tid TYPE vndbid USING vndbid('t', tid);
+
+ALTER TABLE threads_boards DROP CONSTRAINT threads_boards_tid_fkey;
+ALTER TABLE threads_boards ALTER COLUMN tid DROP DEFAULT;
+ALTER TABLE threads_boards ALTER COLUMN tid TYPE vndbid USING vndbid('t', tid);
+
+ALTER TABLE threads DROP CONSTRAINT threads_id_fkey;
+ALTER TABLE threads_posts DROP CONSTRAINT threads_posts_tid_fkey;
+ALTER TABLE threads_posts ALTER COLUMN tid DROP DEFAULT;
+ALTER TABLE threads_posts ALTER COLUMN tid TYPE vndbid USING vndbid('t', tid);
+
+ALTER TABLE threads ALTER COLUMN id DROP DEFAULT;
+ALTER TABLE threads ALTER COLUMN id TYPE vndbid USING vndbid('t', id);
+ALTER TABLE threads ALTER COLUMN id SET DEFAULT vndbid('t', nextval('threads_id_seq')::int);
+ALTER TABLE threads ADD CONSTRAINT threads_id_check CHECK(vndbid_type(id) = 't');
+
+ALTER TABLE threads ADD CONSTRAINT threads_id_fkey FOREIGN KEY (id, count) REFERENCES threads_posts (tid, num) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE threads_poll_options ADD CONSTRAINT threads_poll_options_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
+ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
+ALTER TABLE threads_boards ADD CONSTRAINT threads_boards_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
+
+ALTER TABLE threads DROP COLUMN count;
+ALTER TABLE threads ADD COLUMN c_count smallint NOT NULL DEFAULT 0; -- Number of non-hidden posts
+ALTER TABLE threads ADD COLUMN c_lastnum smallint NOT NULL DEFAULT 1; -- 'num' of the most recent non-hidden post
+
+ALTER TABLE threads_posts ALTER COLUMN num DROP DEFAULT;
+ALTER TABLE threads_posts ALTER COLUMN uid DROP DEFAULT;
+ALTER TABLE threads_posts ALTER COLUMN uid DROP NOT NULL;
+ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_first_nonhidden CHECK(num > 1 OR NOT hidden);
+
+UPDATE threads
+ SET c_count = (SELECT COUNT(*) FROM threads_posts WHERE NOT hidden AND tid = threads.id)
+ , c_lastnum = (SELECT MAX(num) FROM threads_posts WHERE NOT hidden AND tid = threads.id);
+
+UPDATE threads_posts SET uid = NULL WHERE uid = 0;
+
+\i sql/triggers.sql
diff --git a/util/updates/2020-08-17-reviews.sql b/util/updates/2020-08-17-reviews.sql
new file mode 100644
index 00000000..87ded565
--- /dev/null
+++ b/util/updates/2020-08-17-reviews.sql
@@ -0,0 +1,71 @@
+ALTER TABLE reports ADD COLUMN objectnum integer;
+UPDATE reports SET objectnum = regexp_replace(object, '^.+\.([0-9]+)$', '\1')::integer WHERE object LIKE '%.%';
+ALTER TABLE reports ALTER COLUMN object TYPE vndbid USING regexp_replace(object, '\.[0-9]+$','')::vndbid;
+ALTER TABLE reports DROP COLUMN rtype;
+DROP TYPE report_type;
+
+
+
+CREATE SEQUENCE reviews_seq;
+
+CREATE TABLE reviews (
+ id vndbid PRIMARY KEY DEFAULT vndbid('w', nextval('reviews_seq')::int) CONSTRAINT reviews_id_check CHECK(vndbid_type(id) = 'w'),
+ vid int NOT NULL,
+ uid int,
+ rid int,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ lastmod timestamptz,
+ summary text NOT NULL,
+ text text,
+ spoiler boolean NOT NULL
+);
+
+CREATE TABLE reviews_posts (
+ id vndbid NOT NULL,
+ num smallint NOT NULL,
+ uid integer,
+ date timestamptz NOT NULL DEFAULT NOW(),
+ edited timestamptz,
+ hidden boolean NOT NULL DEFAULT FALSE,
+ msg text NOT NULL DEFAULT '',
+ PRIMARY KEY(id, num)
+);
+
+CREATE TABLE reviews_votes (
+ id vndbid NOT NULL,
+ uid int,
+ date timestamptz NOT NULL,
+ vote boolean NOT NULL -- true = upvote, false = downvote
+);
+
+CREATE UNIQUE INDEX reviews_vid_uid ON reviews (vid,uid);
+CREATE INDEX reviews_uid ON reviews (uid);
+CREATE UNIQUE INDEX reviews_votes_id_uid ON reviews_votes (id,uid);
+
+ALTER TABLE reviews ADD CONSTRAINT reviews_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id) ON DELETE CASCADE;
+ALTER TABLE reviews ADD CONSTRAINT reviews_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews ADD CONSTRAINT reviews_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews_posts ADD CONSTRAINT reviews_posts_id_fkey FOREIGN KEY (id) REFERENCES reviews (id) ON DELETE CASCADE;
+ALTER TABLE reviews_posts ADD CONSTRAINT reviews_posts_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE reviews_votes ADD CONSTRAINT reviews_votes_id_fkey FOREIGN KEY (id) REFERENCES reviews (id) ON DELETE CASCADE;
+ALTER TABLE reviews_votes ADD CONSTRAINT reviews_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+
+ALTER TABLE users ADD COLUMN perm_review boolean NOT NULL DEFAULT false;
+UPDATE users SET perm_review = false WHERE not perm_dbmod;
+
+\i sql/perms.sql
+
+--c_votes int NOT NULL DEFAULT 0,
+--c_avg float
+--
+--CREATE OR REPLACE FUNCTION update_reviews_vote_cache() RETURNS trigger AS $$
+--BEGIN
+-- WITH stats(id,cnt,avg) AS (
+-- SELECT id, COUNT(*), AVG(vote::int) FROM reviews_votes WHERE id IN(OLD.id,NEW.id) GROUP BY id
+-- ) UPDATE reviews SET c_votes = cnt, c_avg = avg FROM stats WHERE reviews.id = stats.id;
+-- RETURN NULL;
+--END
+--$$ LANGUAGE plpgsql;
+--
+--CREATE TRIGGER reviews_votes_cache1 AFTER INSERT OR DELETE ON reviews_votes FOR EACH ROW EXECUTE PROCEDURE update_reviews_vote_cache();
+--CREATE TRIGGER reviews_votes_cache2 AFTER UPDATE ON reviews_votes FOR EACH ROW WHEN ((OLD.id, OLD.vote) IS DISTINCT FROM (NEW.id, NEW.vote)) EXECUTE PROCEDURE update_reviews_vote_cache();
diff --git a/util/updates/2020-08-19-reviews-caches.sql b/util/updates/2020-08-19-reviews-caches.sql
new file mode 100644
index 00000000..9c37808d
--- /dev/null
+++ b/util/updates/2020-08-19-reviews-caches.sql
@@ -0,0 +1,14 @@
+CREATE UNIQUE INDEX reviews_posts_uid ON reviews_posts (uid);
+
+ALTER TABLE reviews ADD COLUMN c_up int NOT NULL DEFAULT 0;
+ALTER TABLE reviews ADD COLUMN c_down int NOT NULL DEFAULT 0;
+ALTER TABLE reviews ADD COLUMN c_count smallint NOT NULL DEFAULT 0;
+ALTER TABLE reviews ADD COLUMN c_lastnum smallint;
+
+\i sql/func.sql
+\i sql/triggers.sql
+
+SELECT update_reviews_votes_cache(NULL);
+UPDATE reviews
+ SET c_count = COALESCE((SELECT COUNT(*) FROM reviews_posts WHERE NOT hidden AND id = reviews.id), 0)
+ , c_lastnum = (SELECT MAX(num) FROM reviews_posts WHERE NOT hidden AND id = reviews.id);
diff --git a/util/updates/2020-08-24-reviews-nosummary.sql b/util/updates/2020-08-24-reviews-nosummary.sql
new file mode 100644
index 00000000..7333a2fc
--- /dev/null
+++ b/util/updates/2020-08-24-reviews-nosummary.sql
@@ -0,0 +1,6 @@
+ALTER TABLE reviews ADD COLUMN isfull boolean NOT NULL DEFAULT false;
+UPDATE reviews SET isfull = text <> '';
+UPDATE reviews SET text = summary WHERE NOT isfull;
+UPDATE reviews SET text = summary || text WHERE isfull;
+ALTER TABLE reviews ALTER COLUMN isfull DROP DEFAULT;
+ALTER TABLE reviews DROP COLUMN summary;
diff --git a/util/updates/2020-08-25-reviews-fixups.sql b/util/updates/2020-08-25-reviews-fixups.sql
new file mode 100644
index 00000000..86f77b2a
--- /dev/null
+++ b/util/updates/2020-08-25-reviews-fixups.sql
@@ -0,0 +1,5 @@
+DROP INDEX reviews_posts_uid;
+CREATE INDEX reviews_posts_uid ON reviews_posts (uid);
+
+\i sql/func.sql
+\i sql/triggers.sql
diff --git a/util/updates/2020-09-03-reviews-flagging.sql b/util/updates/2020-09-03-reviews-flagging.sql
new file mode 100644
index 00000000..3cf8ee2c
--- /dev/null
+++ b/util/updates/2020-09-03-reviews-flagging.sql
@@ -0,0 +1,5 @@
+ALTER TABLE reviews ADD COLUMN c_flagged boolean NOT NULL DEFAULT false;
+ALTER TABLE reviews_votes ADD COLUMN overrule boolean NOT NULL DEFAULT false;
+
+\i sql/func.sql
+select update_reviews_votes_cache(null);
diff --git a/util/updates/2020-09-05-notifications.sql b/util/updates/2020-09-05-notifications.sql
new file mode 100644
index 00000000..1c3e6bd6
--- /dev/null
+++ b/util/updates/2020-09-05-notifications.sql
@@ -0,0 +1,16 @@
+ALTER TABLE notifications ALTER COLUMN iid TYPE vndbid USING vndbid(ltype::text, iid);
+ALTER TABLE notifications RENAME COLUMN subid TO num;
+ALTER TABLE notifications DROP COLUMN ltype;
+ALTER TABLE notifications ALTER COLUMN c_byuser DROP DEFAULT;
+ALTER TABLE notifications ALTER COLUMN c_byuser DROP NOT NULL;
+DROP TYPE notification_ltype;
+UPDATE notifications SET c_byuser = NULL WHERE c_byuser = 0;
+
+ALTER TABLE users ADD COLUMN notify_post boolean NOT NULL DEFAULT true;
+ALTER TABLE users ADD COLUMN notify_comment boolean NOT NULL DEFAULT true;
+ALTER TYPE notification_ntype ADD VALUE 'post' AFTER 'announce';
+ALTER TYPE notification_ntype ADD VALUE 'comment' AFTER 'post';
+
+\i sql/func.sql
+\i sql/triggers.sql
+\i sql/perms.sql
diff --git a/util/updates/2020-09-20-reviews-locked.sql b/util/updates/2020-09-20-reviews-locked.sql
new file mode 100644
index 00000000..ee66cf71
--- /dev/null
+++ b/util/updates/2020-09-20-reviews-locked.sql
@@ -0,0 +1 @@
+ALTER TABLE reviews ADD COLUMN locked boolean NOT NULL DEFAULT false;
diff --git a/util/vndb.pl b/util/vndb.pl
index 5c6d5a6a..49e27b6a 100755
--- a/util/vndb.pl
+++ b/util/vndb.pl
@@ -130,8 +130,8 @@ if(config->{trace_log}) {
}
}
no warnings 'redefine';
- my $x = \&TUWF::register; *TUWF::register = wrap($x);# sub { $x->(map ref($_) eq 'CODE' ? wrap($_) : $_, @_) };
- my $y = \&TUWF::any; *TUWF::any = wrap($y);# sub { $y->(map ref($_) eq 'CODE' ? wrap($_) : $_, @_) };
+ my $x = \&TUWF::register; *TUWF::register = wrap($x);
+ my $y = \&TUWF::any; *TUWF::any = wrap($y);
}
TUWF::load_recursive('VNDB::Util', 'VNDB::DB', 'VNDB::Handler');
@@ -151,6 +151,7 @@ TUWF::hook after => sub {
sql_num => scalar grep($_->[0] ne 'ping/rollback' && $_->[0] ne 'commit', tuwf->{_TUWF}{DB}{queries}->@*),
sql_time => $sqlt,
perl_time => time() - tuwf->req->{trace_start},
+ has_txn => VNWeb::DB::sql('txid_current_if_assigned() IS NOT NULL'),
loggedin => auth?1:0,
elm_mods => '{'.join(',', sort keys %elm).'}'
});