Add pool CRUD operations/pages

This commit is contained in:
Ruin0x11 2020-05-03 19:53:28 -07:00
parent 6a95a66f12
commit d59ecb8e23
63 changed files with 3294 additions and 82 deletions

View file

@ -0,0 +1,30 @@
@import colors
.content-wrapper.pool-categories
width: 100%
max-width: 45em
table
border-spacing: 0
width: 100%
tr.default td
background: $default-pool-category-background-color
td, th
padding: .4em
&.color
input[type=text]
width: 8em
&.usages
text-align: center
&.remove, &.set-default
white-space: pre
th
white-space: nowrap
&:first-child
padding-left: 0
&:last-child
padding-right: 0
tfoot
display: none
form
width: auto

View file

@ -0,0 +1,53 @@
@import colors
div.pool-input
position: relative
.main-control
display: flex
input
flex: 5
button
flex: 1
margin: 0 0 0 0.5em
ul.compact-pools
width: 100%
margin: 0.5em 0 0 0
padding: 0
li
margin: 0
width: 100%
line-height: 140%
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
transition: background-color 0.5s linear
a
display: inline
a:focus
outline: 0
box-shadow: inset 0 0 0 2px $main-color
&.implication
background: $implied-pool-background-color
color: $implied-pool-text-color
&.new
background: $new-pool-background-color
color: $new-pool-text-color
&.duplicate
background: $duplicate-pool-background-color
color: $duplicate-pool-text-color
i
padding-right: 0.4em
div.pool-input, ul.compact-pools
.pool-usages, .pool-weight, .remove-pool
color: $inactive-link-color
unselectable()
.pool-usages, .pool-weight
font-size: 90%
.pool-usages, .pool-weight
margin-left: 0.7em
.remove-pool
margin-right: 0.5em

View file

@ -0,0 +1,52 @@
@import colors
.pool-list
table
width: 100%
border-spacing: 0
text-align: left
line-height: 1.3em
tr:hover td
background: $top-navigation-color
th, td
padding: 0.1em 0.5em
th
white-space: nowrap
background: $top-navigation-color
.names
width: 28%
.usages
text-align: center
width: 8%
.creation-time
text-align: center
width: 8%
white-space: pre
ul
list-style-type: none
margin: 0
padding: 0
display: inline
li
padding: 0
display: inline
&:not(:last-child):after
content: ', '
@media (max-width: 800px)
.implications, .suggestions
display: none
.pool-list-header
label
display: none !important
text-align: left
form
width: auto
input[name=search-text]
width: 25em
@media (max-width: 1000px)
width: 100%
.append
vertical-align: middle
font-size: 0.95em
color: $inactive-link-color

33
client/css/pool-view.styl Normal file
View file

@ -0,0 +1,33 @@
#pool
width: 100%
max-width: 40em
h1
word-break: break-all
line-height: 130%
margin-top: 0
form
width: 100%
.pool-edit
textarea
height: 10em
.pool-summary
section
&.description
margin: 1.5em 0 0 0
&.details
vertical-align: top
padding-right: 0.5em
ul
margin: 0
padding: 0
list-style-type: none
li
display: inline
margin: 0
padding: 0
li:not(:last-of-type):after
content: ', '
ul:empty:after
content: '(none)'
section
margin-bottom: 1em

View file

@ -4,6 +4,7 @@
--><li data-name='posts'><a href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Posts</a></li><!--
--><li data-name='users'><a href='<%- ctx.formatClientLink('help', 'search', 'users') %>'>Users</a></li><!--
--><li data-name='tags'><a href='<%- ctx.formatClientLink('help', 'search', 'tags') %>'>Tags</a></li><!--
--><li data-name='pools'><a href='<%- ctx.formatClientLink('help', 'search', 'pools') %>'>Pools</a></li><!--
--></ul><!--
--></nav>

View file

@ -0,0 +1,97 @@
<p><strong>Anonymous tokens</strong></p>
<p>Same as <code>name</code> token.</p>
<p><strong>Named tokens</strong></p>
<table>
<tbody>
<tr>
<td><code>name</code></td>
<td>having given name (accepts wildcards)</td>
</tr>
<tr>
<td><code>category</code></td>
<td>having given category (accepts wildcards)</td>
</tr>
<tr>
<td><code>creation-date</code></td>
<td>created at given date</td>
</tr>
<tr>
<td><code>creation-time</code></td>
<td>alias of <code>creation-date</code></td>
</tr>
<tr>
<td><code>last-edit-date</code></td>
<td>edited at given date</td>
</tr>
<tr>
<td><code>last-edit-time</code></td>
<td>alias of <code>last-edit-date</code></td>
</tr>
<tr>
<td><code>edit-date</code></td>
<td>alias of <code>last-edit-date</code></td>
</tr>
<tr>
<td><code>edit-time</code></td>
<td>alias of <code>last-edit-date</code></td>
</tr>
<tr>
<td><code>post-count</code></td>
<td>alias of <code>usages</code></td>
</tr>
</tbody>
</table>
<p><strong>Sort style tokens</strong></p>
<table>
<tbody>
<tr>
<td><code>random</code></td>
<td>as random as it can get</td>
</tr>
<tr>
<td><code>name</code></td>
<td>A to Z</td>
</tr>
<tr>
<td><code>category</code></td>
<td>category (A to Z)</td>
</tr>
<tr>
<td><code>creation-date</code></td>
<td>recently created first</td>
</tr>
<tr>
<td><code>creation-time</code></td>
<td>alias of <code>creation-date</code></td>
</tr>
<tr>
<td><code>last-edit-date</code></td>
<td>recently edited first</td>
</tr>
<tr>
<td><code>last-edit-time</code></td>
<td>alias of <code>creation-time</code></td>
</tr>
<tr>
<td><code>edit-date</code></td>
<td>alias of <code>creation-time</code></td>
</tr>
<tr>
<td><code>edit-time</code></td>
<td>alias of <code>creation-time</code></td>
</tr>
<tr>
<td><code>post-count</code></td>
<td>number of posts</td>
</tr>
</tbody>
</table>
<p><strong>Special tokens</strong></p>
<p>None.</p>

18
client/html/pool.tpl Normal file
View file

@ -0,0 +1,18 @@
<div class='content-wrapper' id='pool'>
<h1><%- ctx.pool.first_name %></h1>
<nav class='buttons'><!--
--><ul><!--
--><li data-name='summary'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id) %>'>Summary</a></li><!--
--><% if (ctx.canEditAnything) { %><!--
--><li data-name='edit'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id, 'edit') %>'>Edit</a></li><!--
--><% } %><!--
--><% if (ctx.canMerge) { %><!--
--><li data-name='merge'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id, 'merge') %>'>Merge with&hellip;</a></li><!--
--><% } %><!--
--><% if (ctx.canDelete) { %><!--
--><li data-name='delete'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id, 'delete') %>'>Delete</a></li><!--
--><% } %><!--
--></ul><!--
--></nav>
<div class='pool-content-holder'></div>
</div>

View file

@ -0,0 +1,30 @@
<div class='content-wrapper pool-categories'>
<form>
<h1>Pool categories</h1>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class='name'>Category name</th>
<th class='color'>CSS color</th>
<th class='usages'>Usages</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<% if (ctx.canCreate) { %>
<p><a href class='add'>Add new category</a></p>
<% } %>
<div class='messages'></div>
<% if (ctx.canCreate || ctx.canEditName || ctx.canEditColor || ctx.canDelete) { %>
<div class='buttons'>
<input type='submit' class='save' value='Save changes'>
</div>
<% } %>
</form>
</div>

View file

@ -0,0 +1,43 @@
<% if (ctx.poolCategory.isDefault) { %><%
%><tr data-category='<%- ctx.poolCategory.name %>' class='default'><%
%><% } else { %><%
%><tr data-category='<%- ctx.poolCategory.name %>'><%
%><% } %>
<td class='name'>
<% if (ctx.canEditName) { %>
<%= ctx.makeTextInput({value: ctx.poolCategory.name, required: true}) %>
<% } else { %>
<%- ctx.poolCategory.name %>
<% } %>
</td>
<td class='color'>
<% if (ctx.canEditColor) { %>
<%= ctx.makeColorInput({value: ctx.poolCategory.color}) %>
<% } else { %>
<%- ctx.poolCategory.color %>
<% } %>
</td>
<td class='usages'>
<% if (ctx.poolCategory.name) { %>
<a href='<%- ctx.formatClientLink('pools', {query: 'category:' + ctx.poolCategory.name}) %>'>
<%- ctx.poolCategory.poolCount %>
</a>
<% } else { %>
<%- ctx.poolCategory.poolCount %>
<% } %>
</td>
<% if (ctx.canDelete) { %>
<td class='remove'>
<% if (ctx.poolCategory.poolCount) { %>
<a class='inactive' title="Can't delete category in use">Remove</a>
<% } else { %>
<a href>Remove</a>
<% } %>
</td>
<% } %>
<% if (ctx.canSetDefault) { %>
<td class='set-default'>
<a href>Make default</a>
</td>
<% } %>
</tr>

View file

@ -0,0 +1,35 @@
<div class='content-wrapper pool-create'>
<form>
<ul class='input'>
<li class='names'>
<%= ctx.makeTextInput({
text: 'Names',
value: '',
required: true,
}) %>
</li>
<li class='category'>
<%= ctx.makeSelect({
text: 'Category',
keyValues: ctx.categories,
selectedKey: 'default',
required: true,
}) %>
</li>
<li class='description'>
<%= ctx.makeTextarea({
text: 'Description',
value: '',
}) %>
</li>
</ul>
<% if (ctx.canCreate) { %>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' class='save' value='Create pool'>
</div>
<% } %>
</form>
</div>

View file

@ -0,0 +1,21 @@
<div class='pool-delete'>
<form>
<p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
<ul class='input'>
<li>
<%= ctx.makeCheckbox({
name: 'confirm-deletion',
text: 'I confirm that I want to delete this pool.',
required: true,
}) %>
</li>
</ul>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Delete pool'/>
</div>
</form>
</div>

41
client/html/pool_edit.tpl Normal file
View file

@ -0,0 +1,41 @@
<div class='content-wrapper pool-edit'>
<form>
<ul class='input'>
<li class='names'>
<% if (ctx.canEditNames) { %>
<%= ctx.makeTextInput({
text: 'Names',
value: ctx.pool.names.join(' '),
required: true,
}) %>
<% } %>
</li>
<li class='category'>
<% if (ctx.canEditCategory) { %>
<%= ctx.makeSelect({
text: 'Category',
keyValues: ctx.categories,
selectedKey: ctx.pool.category,
required: true,
}) %>
<% } %>
</li>
<li class='description'>
<% if (ctx.canEditDescription) { %>
<%= ctx.makeTextarea({
text: 'Description',
value: ctx.pool.description,
}) %>
<% } %>
</li>
</ul>
<% if (ctx.canEditAnything) { %>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' class='save' value='Save changes'>
</div>
<% } %>
</form>
</div>

View file

View file

@ -0,0 +1,22 @@
<div class='pool-merge'>
<form>
<ul class='input'>
<li class='target'>
<%= ctx.makeTextInput({name: 'target-pool', required: true, text: 'Target pool', pattern: ctx.poolNamePattern}) %>
</li>
<li>
<p>Posts between the two pools will be combined.
Category needs to be handled manually.</p>
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this pool.'}) %>
</li>
</ul>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Merge pool'/>
</div>
</form>
</div>

View file

@ -0,0 +1,23 @@
<div class='content-wrapper pool-summary'>
<section class='details'>
<section>
Category:
<span class='<%= ctx.makeCssName(ctx.pool.category, 'pool') %>'><%- ctx.pool.category %></span>
</section>
<section>
Aliases:<br/>
<ul><!--
--><% for (let name of ctx.pool.names.slice(1)) { %><!--
--><li><%= ctx.makePoolLink(ctx.pool, false, false, name) %></li><!--
--><% } %><!--
--></ul>
</section>
</section>
<section class='description'>
<hr/>
<%= ctx.makeMarkdown(ctx.pool.description || 'This pool has no description yet.') %>
<p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeColons(ctx.pool.names[0])}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
</section>
</div>

View file

@ -0,0 +1,22 @@
<div class='pool-list-header'>
<form class='horizontal'>
<ul class='input'>
<li>
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %>
</li>
</ul>
<div class='buttons'>
<input type='submit' value='Search'/>
<a class='button append' href='<%- ctx.formatClientLink('help', 'search', 'pools') %>'>Syntax help</a>
<% if (ctx.canCreate) { %>
<a class='append' href='<%- ctx.formatClientLink('pool', 'create') %>'>Add new pool</a>
<% } %>
<% if (ctx.canEditPoolCategories) { %>
<a class='append' href='<%- ctx.formatClientLink('pool-categories') %>'>Pool categories</a>
<% } %>
</div>
</form>
</div>

View file

@ -0,0 +1,48 @@
<div class='pool-list table-wrap'>
<% if (ctx.response.results.length) { %>
<table>
<thead>
<th class='names'>
<% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %>
<a href='<%- ctx.formatClientLink('pools', {query: '-sort:name'}) %>'>Pool name(s)</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('pools', {query: 'sort:name'}) %>'>Pool name(s)</a>
<% } %>
</th>
<th class='post-count'>
<% if (ctx.parameters.query == 'sort:post-count') { %>
<a href='<%- ctx.formatClientLink('pools', {query: '-sort:post-count'}) %>'>Post Count</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('pools', {query: 'sort:post-count'}) %>'>Post Count</a>
<% } %>
</th>
<th class='creation-time'>
<% if (ctx.parameters.query == 'sort:creation-time') { %>
<a href='<%- ctx.formatClientLink('pools', {query: '-sort:creation-time'}) %>'>Created on</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('pools', {query: 'sort:creation-time'}) %>'>Created on</a>
<% } %>
</th>
</thead>
<tbody>
<% for (let pool of ctx.response.results) { %>
<tr>
<td class='names'>
<ul>
<% for (let name of pool.names) { %>
<li><%= ctx.makePoolLink(pool, false, false, name) %></li>
<% } %>
</ul>
</td>
<td class='post-count'>
<a href='<%- ctx.formatClientLink('pools', {query: 'pool:' + pool.id}) %>'><%- pool.postCount %></a>
</td>
<td class='creation-time'>
<%= ctx.makeRelativeTime(pool.creationTime) %>
</td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
</div>

View file

@ -3,35 +3,35 @@
<table>
<thead>
<th class='names'>
<% if (ctx.query == 'sort:name' || !ctx.query) { %>
<% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:name'}) %>'>Tag name(s)</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:name'}) %>'>Tag name(s)</a>
<% } %>
</th>
<th class='implications'>
<% if (ctx.query == 'sort:implication-count') { %>
<% if (ctx.parameters.query == 'sort:implication-count') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:implication-count'}) %>'>Implications</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:implication-count'}) %>'>Implications</a>
<% } %>
</th>
<th class='suggestions'>
<% if (ctx.query == 'sort:suggestion-count') { %>
<% if (ctx.parameters.query == 'sort:suggestion-count') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:suggestion-count'}) %>'>Suggestions</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:suggestion-count'}) %>'>Suggestions</a>
<% } %>
</th>
<th class='usages'>
<% if (ctx.query == 'sort:usages') { %>
<% if (ctx.parameters.query == 'sort:usages') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:usages'}) %>'>Usages</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:usages'}) %>'>Usages</a>
<% } %>
</th>
<th class='creation-time'>
<% if (ctx.query == 'sort:creation-time') { %>
<% if (ctx.parameters.query == 'sort:creation-time') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:creation-time'}) %>'>Created on</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:creation-time'}) %>'>Created on</a>

View file

@ -84,6 +84,10 @@ class Api extends events.EventTarget {
return remoteConfig.tagNameRegex;
}
getPoolNameRegex() {
return remoteConfig.poolNameRegex;
}
getPasswordRegex() {
return remoteConfig.passwordRegex;
}

View file

@ -3,6 +3,7 @@
const router = require('../router.js');
const api = require('../api.js');
const tags = require('../tags.js');
const pools = require('../pools.js');
const uri = require('../util/uri.js');
const topNavigation = require('../models/top_navigation.js');
const LoginView = require('../views/login_view.js');
@ -27,6 +28,7 @@ class LoginController {
ctx.controller.showSuccess('Logged in');
// reload tag category color map, this is required when `tag_categories:list` has a permission other than anonymous
tags.refreshCategoryColorMap();
pools.refreshCategoryColorMap();
}, error => {
this._loginView.showError(error.message);
this._loginView.enableForm();

View file

@ -0,0 +1,57 @@
'use strict';
const api = require('../api.js');
const pools = require('../pools.js');
const PoolCategoryList = require('../models/pool_category_list.js');
const topNavigation = require('../models/top_navigation.js');
const PoolCategoriesView = require('../views/pool_categories_view.js');
const EmptyView = require('../views/empty_view.js');
class PoolCategoriesController {
constructor() {
if (!api.hasPrivilege('poolCategories:list')) {
this._view = new EmptyView();
this._view.showError(
'You don\'t have privileges to view pool categories.');
return;
}
topNavigation.activate('pools');
topNavigation.setTitle('Listing pools');
PoolCategoryList.get().then(response => {
this._poolCategories = response.results;
this._view = new PoolCategoriesView({
poolCategories: this._poolCategories,
canEditName: api.hasPrivilege('poolCategories:edit:name'),
canEditColor: api.hasPrivilege('poolCategories:edit:color'),
canDelete: api.hasPrivilege('poolCategories:delete'),
canCreate: api.hasPrivilege('poolCategories:create'),
canSetDefault: api.hasPrivilege('poolCategories:setDefault'),
});
this._view.addEventListener('submit', e => this._evtSubmit(e));
}, error => {
this._view = new EmptyView();
this._view.showError(error.message);
});
}
_evtSubmit(e) {
this._view.clearMessages();
this._view.disableForm();
this._poolCategories.save()
.then(() => {
pools.refreshCategoryColorMap();
this._view.enableForm();
this._view.showSuccess('Changes saved.');
}, error => {
this._view.enableForm();
this._view.showError(error.message);
});
}
}
module.exports = router => {
router.enter(['pool-categories'], (ctx, next) => {
ctx.controller = new PoolCategoriesController(ctx, next);
});
};

View file

@ -0,0 +1,141 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const Pool = require('../models/pool.js');
const PoolCategoryList = require('../models/pool_category_list.js');
const topNavigation = require('../models/top_navigation.js');
const PoolView = require('../views/pool_view.js');
const EmptyView = require('../views/empty_view.js');
class PoolController {
constructor(ctx, section) {
if (!api.hasPrivilege('pools:view')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view pools.');
return;
}
Promise.all([
PoolCategoryList.get(),
Pool.get(ctx.parameters.id)
]).then(responses => {
const [poolCategoriesResponse, pool] = responses;
topNavigation.activate('pools');
topNavigation.setTitle('Pool #' + pool.names[0]);
this._name = ctx.parameters.name;
pool.addEventListener('change', e => this._evtSaved(e, section));
const categories = {};
for (let category of poolCategoriesResponse.results) {
categories[category.name] = category.name;
}
this._view = new PoolView({
pool: pool,
section: section,
canEditAnything: api.hasPrivilege('pools:edit'),
canEditNames: api.hasPrivilege('pools:edit:names'),
canEditCategory: api.hasPrivilege('pools:edit:category'),
canEditDescription: api.hasPrivilege('pools:edit:description'),
canMerge: api.hasPrivilege('pools:merge'),
canDelete: api.hasPrivilege('pools:delete'),
categories: categories,
escapeColons: uri.escapeColons,
});
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtUpdate(e));
this._view.addEventListener('merge', e => this._evtMerge(e));
this._view.addEventListener('delete', e => this._evtDelete(e));
},
error => {
this._view = new EmptyView();
this._view.showError(error.message);
});
}
_evtChange(e) {
misc.enableExitConfirmation();
}
_evtSaved(e, section) {
misc.disableExitConfirmation();
if (this._name !== e.detail.pool.names[0]) {
router.replace(
uri.formatClientLink('pool', e.detail.pool.id, section),
null, false);
}
}
_evtUpdate(e) {
this._view.clearMessages();
this._view.disableForm();
if (e.detail.names !== undefined) {
e.detail.pool.names = e.detail.names;
}
if (e.detail.category !== undefined) {
e.detail.pool.category = e.detail.category;
}
if (e.detail.description !== undefined) {
e.detail.pool.description = e.detail.description;
}
e.detail.pool.save().then(() => {
this._view.showSuccess('Pool saved.');
this._view.enableForm();
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
_evtMerge(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.pool
.merge(e.detail.targetPoolName, e.detail.addAlias)
.then(() => {
this._view.showSuccess('Pool merged.');
this._view.enableForm();
router.replace(
uri.formatClientLink(
'pool', e.detail.targetPoolName, 'merge'),
null, false);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
_evtDelete(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.pool.delete()
.then(() => {
const ctx = router.show(uri.formatClientLink('pools'));
ctx.controller.showSuccess('Pool deleted.');
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
}
module.exports = router => {
router.enter(['pool', ':id', 'edit'], (ctx, next) => {
ctx.controller = new PoolController(ctx, 'edit');
});
router.enter(['pool', ':id', 'merge'], (ctx, next) => {
ctx.controller = new PoolController(ctx, 'merge');
});
router.enter(['pool', ':id', 'delete'], (ctx, next) => {
ctx.controller = new PoolController(ctx, 'delete');
});
router.enter(['pool', ':id'], (ctx, next) => {
ctx.controller = new PoolController(ctx, 'summary');
});
};

View file

@ -0,0 +1,63 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const PoolCategoryList = require('../models/pool_category_list.js');
const PoolCreateView = require('../views/pool_create_view.js');
const EmptyView = require('../views/empty_view.js');
class PoolCreateController {
constructor(ctx) {
if (!api.hasPrivilege('pools:create')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to create pools.');
return;
}
PoolCategoryList.get()
.then(poolCategoriesResponse => {
const categories = {};
for (let category of poolCategoriesResponse.results) {
categories[category.name] = category.name;
}
this._view = new PoolCreateView({
canCreate: api.hasPrivilege('pools:create'),
categories: categories,
escapeColons: uri.escapeColons,
});
this._view.addEventListener('submit', e => this._evtCreate(e));
}, error => {
this._view = new EmptyView();
this._view.showError(error.message);
});
}
_evtCreate(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.pool.save()
.then(() => {
this._view.clearMessages();
misc.disableExitConfirmation();
const ctx = router.show(uri.formatClientLink('pools'));
ctx.controller.showSuccess('Pool created.');
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
_evtChange(e) {
misc.enableExitConfirmation();
}
}
module.exports = router => {
router.enter(['pool', 'create'], (ctx, next) => {
ctx.controller = new PoolCreateController(ctx, 'create');
});
};

View file

@ -0,0 +1,105 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const uri = require('../util/uri.js');
const PoolList = require('../models/pool_list.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
const PoolsHeaderView = require('../views/pools_header_view.js');
const PoolsPageView = require('../views/pools_page_view.js');
const EmptyView = require('../views/empty_view.js');
const fields = [
'id',
'names',
/* 'suggestions',
* 'implications', */
'creationTime',
'postCount',
'category'];
class PoolListController {
constructor(ctx) {
if (!api.hasPrivilege('pools:list')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view pools.');
return;
}
topNavigation.activate('pools');
topNavigation.setTitle('Listing pools');
this._ctx = ctx;
this._pageController = new PageController();
this._headerView = new PoolsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
canCreate: api.hasPrivilege('pools:create'),
canEditPoolCategories: api.hasPrivilege('poolCategories:edit'),
});
this._headerView.addEventListener(
'submit', e => this._evtSubmit(e),
'navigate', e => this._evtNavigate(e));
this._syncPageController();
}
showSuccess(message) {
this._pageController.showSuccess(message);
}
showError(message) {
this._pageController.showError(message);
}
_evtSubmit(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.pool.save()
.then(() => {
this._installView(e.detail.pool, 'edit');
this._view.showSuccess('Pool created.');
router.replace(
uri.formatClientLink(
'pool', e.detail.pool.id, 'edit'),
null, false);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
_evtNavigate(e) {
router.showNoDispatch(
uri.formatClientLink('pools', e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
defaultLimit: 50,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign(
{}, this._ctx.parameters, {offset: offset, limit: limit});
return uri.formatClientLink('pools', parameters);
},
requestPage: (offset, limit) => {
return PoolList.search(
this._ctx.parameters.query, offset, limit, fields);
},
pageRenderer: pageCtx => {
return new PoolsPageView(pageCtx);
},
});
}
}
module.exports = router => {
router.enter(
['pools'],
(ctx, next) => { ctx.controller = new PoolListController(ctx); });
};

View file

@ -0,0 +1,57 @@
'use strict';
const misc = require('../util/misc.js');
const PoolList = require('../models/pool_list.js');
const AutoCompleteControl = require('./auto_complete_control.js');
function _poolListToMatches(pools, options) {
return [...pools].sort((pool1, pool2) => {
return pool2.postCount - pool1.postCount;
}).map(pool => {
let cssName = misc.makeCssName(pool.category, 'pool');
// TODO
if (options.isPooledWith(pool.id)) {
cssName += ' disabled';
}
const caption = (
'<span class="' + cssName + '">'
+ misc.escapeHtml(pool.names[0] + ' (' + pool.postCount + ')')
+ '</span>');
return {
caption: caption,
value: pool,
};
});
}
class PoolAutoCompleteControl extends AutoCompleteControl {
constructor(input, options) {
const minLengthForPartialSearch = 3;
options = Object.assign({
isPooledWith: poolId => false,
}, options);
options.getMatches = text => {
const term = misc.escapeSearchTerm(text);
const query = (
text.length < minLengthForPartialSearch
? term + '*'
: '*' + term + '*') + ' sort:post-count';
return new Promise((resolve, reject) => {
PoolList.search(
query, 0, this._options.maxResults,
['id', 'names', 'category', 'postCount'])
.then(
response => resolve(
_poolListToMatches(response.results, this._options)),
reject);
});
};
super(input, options);
}
};
module.exports = PoolAutoCompleteControl;

View file

@ -27,6 +27,7 @@ router.enter(
});
const tags = require('./tags.js');
const pools = require('./pools.js');
const api = require('./api.js');
api.fetchConfig().then(() => {
@ -45,6 +46,10 @@ api.fetchConfig().then(() => {
controllers.push(require('./controllers/tag_controller.js'));
controllers.push(require('./controllers/tag_list_controller.js'));
controllers.push(require('./controllers/tag_categories_controller.js'));
controllers.push(require('./controllers/pool_create_controller.js'));
controllers.push(require('./controllers/pool_controller.js'));
controllers.push(require('./controllers/pool_list_controller.js'));
controllers.push(require('./controllers/pool_categories_controller.js'));
controllers.push(require('./controllers/settings_controller.js'));
controllers.push(require('./controllers/user_controller.js'));
controllers.push(require('./controllers/user_list_controller.js'));
@ -61,6 +66,7 @@ api.fetchConfig().then(() => {
}).then(() => {
api.loginFromCookies().then(() => {
tags.refreshCategoryColorMap();
pools.refreshCategoryColorMap();
router.start();
}, error => {
if (window.location.href.indexOf('login') !== -1) {

155
client/js/models/pool.js Normal file
View file

@ -0,0 +1,155 @@
'use strict';
const api = require('../api.js');
const uri = require('../util/uri.js');
const events = require('../events.js');
const misc = require('../util/misc.js');
class Pool extends events.EventTarget {
constructor() {
// const PoolList = require('./pool_list.js');
super();
this._orig = {};
for (let obj of [this, this._orig]) {
// TODO
// obj._suggestions = new PoolList();
// obj._implications = new PoolList();
}
this._updateFromResponse({});
}
get id() { return this._id; }
get names() { return this._names; }
get category() { return this._category; }
get description() { return this._description; }
/* get suggestions() { return this._suggestions; }
* get implications() { return this._implications; } */
get postCount() { return this._postCount; }
get creationTime() { return this._creationTime; }
get lastEditTime() { return this._lastEditTime; }
set names(value) { this._names = value; }
set category(value) { this._category = value; }
set description(value) { this._description = value; }
static fromResponse(response) {
const ret = new Pool();
ret._updateFromResponse(response);
return ret;
}
static get(id) {
return api.get(uri.formatApiLink('pool', id))
.then(response => {
return Promise.resolve(Pool.fromResponse(response));
});
}
save() {
const detail = {version: this._version};
// send only changed fields to avoid user privilege violation
if (misc.arraysDiffer(this._names, this._orig._names, true)) {
detail.names = this._names;
}
if (this._category !== this._orig._category) {
detail.category = this._category;
}
if (this._description !== this._orig._description) {
detail.description = this._description;
}
// TODO
// if (misc.arraysDiffer(this._implications, this._orig._implications)) {
// detail.implications = this._implications.map(
// relation => relation.names[0]);
// }
// if (misc.arraysDiffer(this._suggestions, this._orig._suggestions)) {
// detail.suggestions = this._suggestions.map(
// relation => relation.names[0]);
// }
let promise = this._id ?
api.put(uri.formatApiLink('pool', this._id), detail) :
api.post(uri.formatApiLink('pools'), detail);
return promise
.then(response => {
this._updateFromResponse(response);
this.dispatchEvent(new CustomEvent('change', {
detail: {
pool: this,
},
}));
return Promise.resolve();
});
}
merge(targetId, addAlias) {
return api.get(uri.formatApiLink('pool', targetId))
.then(response => {
return api.post(uri.formatApiLink('pool-merge'), {
removeVersion: this._version,
remove: this._id,
mergeToVersion: response.version,
mergeTo: targetId,
});
}).then(response => {
if (!addAlias) {
return Promise.resolve(response);
}
return api.put(uri.formatApiLink('pool', targetId), {
version: response.version,
names: response.names.concat(this._names),
});
}).then(response => {
this._updateFromResponse(response);
this.dispatchEvent(new CustomEvent('change', {
detail: {
pool: this,
},
}));
return Promise.resolve();
});
}
delete() {
return api.delete(
uri.formatApiLink('pool', this._id),
{version: this._version})
.then(response => {
this.dispatchEvent(new CustomEvent('delete', {
detail: {
pool: this,
},
}));
return Promise.resolve();
});
}
_updateFromResponse(response) {
const map = {
_id: response.id,
_version: response.version,
_origName: response.names ? response.names[0] : null,
_names: response.names,
_category: response.category,
_description: response.description,
_creationTime: response.creationTime,
_lastEditTime: response.lastEditTime,
_postCount: response.usages || 0,
};
for (let obj of [this, this._orig]) {
// TODO
// obj._suggestions.sync(response.suggestions);
// obj._implications.sync(response.implications);
}
Object.assign(this, map);
Object.assign(this._orig, map);
}
};
module.exports = Pool;

View file

@ -0,0 +1,90 @@
'use strict';
const api = require('../api.js');
const uri = require('../util/uri.js');
const events = require('../events.js');
class PoolCategory extends events.EventTarget {
constructor() {
super();
this._name = '';
this._color = '#000000';
this._poolCount = 0;
this._isDefault = false;
this._origName = null;
this._origColor = null;
}
get name() { return this._name; }
get color() { return this._color; }
get poolCount() { return this._poolCount; }
get isDefault() { return this._isDefault; }
get isTransient() { return !this._origName; }
set name(value) { this._name = value; }
set color(value) { this._color = value; }
static fromResponse(response) {
const ret = new PoolCategory();
ret._updateFromResponse(response);
return ret;
}
save() {
const detail = {version: this._version};
if (this.name !== this._origName) {
detail.name = this.name;
}
if (this.color !== this._origColor) {
detail.color = this.color;
}
if (!Object.keys(detail).length) {
return Promise.resolve();
}
let promise = this._origName ?
api.put(
uri.formatApiLink('pool-category', this._origName),
detail) :
api.post(uri.formatApiLink('pool-categories'), detail);
return promise
.then(response => {
this._updateFromResponse(response);
this.dispatchEvent(new CustomEvent('change', {
detail: {
poolCategory: this,
},
}));
return Promise.resolve();
});
}
delete() {
return api.delete(
uri.formatApiLink('pool-category', this._origName),
{version: this._version})
.then(response => {
this.dispatchEvent(new CustomEvent('delete', {
detail: {
poolCategory: this,
},
}));
return Promise.resolve();
});
}
_updateFromResponse(response) {
this._version = response.version;
this._name = response.name;
this._color = response.color;
this._isDefault = response.default;
this._poolCount = response.usages;
this._origName = this.name;
this._origColor = this.color;
}
}
module.exports = PoolCategory;

View file

@ -0,0 +1,82 @@
'use strict';
const api = require('../api.js');
const uri = require('../util/uri.js');
const AbstractList = require('./abstract_list.js');
const PoolCategory = require('./pool_category.js');
class PoolCategoryList extends AbstractList {
constructor() {
super();
this._defaultCategory = null;
this._origDefaultCategory = null;
this._deletedCategories = [];
this.addEventListener('remove', e => this._evtCategoryDeleted(e));
}
static fromResponse(response) {
const ret = super.fromResponse(response);
ret._defaultCategory = null;
for (let poolCategory of ret) {
if (poolCategory.isDefault) {
ret._defaultCategory = poolCategory;
}
}
ret._origDefaultCategory = ret._defaultCategory;
return ret;
}
static get() {
return api.get(uri.formatApiLink('pool-categories'))
.then(response => {
return Promise.resolve(Object.assign(
{},
response,
{results: PoolCategoryList.fromResponse(response.results)}));
});
}
get defaultCategory() {
return this._defaultCategory;
}
set defaultCategory(poolCategory) {
this._defaultCategory = poolCategory;
}
save() {
let promises = [];
for (let poolCategory of this) {
promises.push(poolCategory.save());
}
for (let poolCategory of this._deletedCategories) {
promises.push(poolCategory.delete());
}
if (this._defaultCategory !== this._origDefaultCategory) {
promises.push(
api.put(
uri.formatApiLink(
'pool-category',
this._defaultCategory.name,
'default')));
}
return Promise.all(promises)
.then(response => {
this._deletedCategories = [];
return Promise.resolve();
});
}
_evtCategoryDeleted(e) {
if (!e.detail.poolCategory.isTransient) {
this._deletedCategories.push(e.detail.poolCategory);
}
}
}
PoolCategoryList._itemClass = PoolCategory;
PoolCategoryList._itemName = 'poolCategory';
module.exports = PoolCategoryList;

View file

@ -0,0 +1,30 @@
'use strict';
const api = require('../api.js');
const uri = require('../util/uri.js');
const AbstractList = require('./abstract_list.js');
const Pool = require('./pool.js');
class PoolList extends AbstractList {
static search(text, offset, limit, fields) {
return api.get(
uri.formatApiLink(
'pools', {
query: text,
offset: offset,
limit: limit,
fields: fields.join(','),
}))
.then(response => {
return Promise.resolve(Object.assign(
{},
response,
{results: PoolList.fromResponse(response.results)}));
});
}
}
PoolList._itemClass = Pool;
PoolList._itemName = 'pool';
module.exports = PoolList;

View file

@ -81,6 +81,7 @@ function _makeTopNavigation() {
ret.add('upload', new TopNavigationItem('U', 'Upload', 'upload'));
ret.add('comments', new TopNavigationItem('C', 'Comments', 'comments'));
ret.add('tags', new TopNavigationItem('T', 'Tags', 'tags'));
ret.add('pools', new TopNavigationItem('O', 'Pools', 'pools'));
ret.add('users', new TopNavigationItem('S', 'Users', 'users'));
ret.add('account', new TopNavigationItem('A', 'Account', 'user/{me}'));
ret.add('register', new TopNavigationItem('R', 'Register', 'register'));

26
client/js/pools.js Normal file
View file

@ -0,0 +1,26 @@
'use strict';
const misc = require('./util/misc.js');
const PoolCategoryList = require('./models/pool_category_list.js');
let _stylesheet = null;
function refreshCategoryColorMap() {
return PoolCategoryList.get().then(response => {
if (_stylesheet) {
document.head.removeChild(_stylesheet);
}
_stylesheet = document.createElement('style');
document.head.appendChild(_stylesheet);
for (let category of response.results) {
const ruleName = misc.makeCssName(category.name, 'pool');
_stylesheet.sheet.insertRule(
`.${ruleName} { color: ${category.color} }`,
_stylesheet.sheet.cssRules.length);
}
});
}
module.exports = {
refreshCategoryColorMap: refreshCategoryColorMap,
};

View file

@ -163,6 +163,11 @@ function escapeHtml(unsafe) {
}
function arraysDiffer(source1, source2, orderImportant) {
if ((source1 instanceof Array && source2 === undefined)
|| (source1 === undefined && source2 instanceof Array)) {
return true
}
source1 = [...source1];
source2 = [...source2];
if (orderImportant === true) {

View file

@ -221,6 +221,29 @@ function makeTagLink(name, includeHash, includeCount, tag) {
misc.escapeHtml(text));
}
function makePoolLink(pool, includeHash, includeCount, name) {
const category = pool.category;
let text = name ? name : pool.names[0];
if (includeHash === true) {
text = '#' + text;
}
if (includeCount === true) {
text += ' (' + pool.postCount + ')';
}
return api.hasPrivilege('pools:view') ?
makeElement(
'a',
{
href: uri.formatClientLink('pool', pool.id),
class: misc.makeCssName(category, 'pool'),
},
misc.escapeHtml(text)) :
makeElement(
'span',
{class: misc.makeCssName(category, 'pool')},
misc.escapeHtml(text));
}
function makeUserLink(user) {
let text = makeThumbnail(user ? user.avatarUrl : null);
text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous';
@ -400,6 +423,7 @@ function getTemplate(templatePath) {
makeDateInput: makeDateInput,
makePostLink: makePostLink,
makeTagLink: makeTagLink,
makePoolLink: makePoolLink,
makeUserLink: makeUserLink,
makeFlexboxAlign: makeFlexboxAlign,
makeAccessKey: makeAccessKey,
@ -529,6 +553,7 @@ module.exports = {
decorateValidator: decorateValidator,
makeTagLink: makeTagLink,
makePostLink: makePostLink,
makePoolLink: makePoolLink,
makeCheckbox: makeCheckbox,
makeRadio: makeRadio,
syncScrollPosition: syncScrollPosition,

View file

@ -17,6 +17,7 @@ const subsectionTemplates = {
'posts': views.getTemplate('help-search-posts'),
'users': views.getTemplate('help-search-users'),
'tags': views.getTemplate('help-search-tags'),
'pools': views.getTemplate('help-search-pools'),
},
};

View file

@ -0,0 +1,166 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const PoolCategory = require('../models/pool_category.js');
const template = views.getTemplate('pool-categories');
const rowTemplate = views.getTemplate('pool-category-row');
class PoolCategoriesView extends events.EventTarget {
constructor(ctx) {
super();
this._ctx = ctx;
this._hostNode = document.getElementById('content-holder');
views.replaceContent(this._hostNode, template(ctx));
views.syncScrollPosition();
views.decorateValidator(this._formNode);
const categoriesToAdd = Array.from(ctx.poolCategories);
categoriesToAdd.sort((a, b) => {
if (b.isDefault) {
return 1;
} else if (a.isDefault) {
return -1;
}
return a.name.localeCompare(b.name);
});
for (let poolCategory of categoriesToAdd) {
this._addPoolCategoryRowNode(poolCategory);
}
if (this._addLinkNode) {
this._addLinkNode.addEventListener(
'click', e => this._evtAddButtonClick(e));
}
ctx.poolCategories.addEventListener(
'add', e => this._evtPoolCategoryAdded(e));
ctx.poolCategories.addEventListener(
'remove', e => this._evtPoolCategoryDeleted(e));
this._formNode.addEventListener(
'submit', e => this._evtSaveButtonClick(e, ctx));
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
clearMessages() {
views.clearMessages(this._hostNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _tableBodyNode() {
return this._hostNode.querySelector('tbody');
}
get _addLinkNode() {
return this._hostNode.querySelector('a.add');
}
_addPoolCategoryRowNode(poolCategory) {
const rowNode = rowTemplate(
Object.assign(
{}, this._ctx, {poolCategory: poolCategory}));
const nameInput = rowNode.querySelector('.name input');
if (nameInput) {
nameInput.addEventListener(
'change', e => this._evtNameChange(e, rowNode));
}
const colorInput = rowNode.querySelector('.color input');
if (colorInput) {
colorInput.addEventListener(
'change', e => this._evtColorChange(e, rowNode));
}
const removeLinkNode = rowNode.querySelector('.remove a');
if (removeLinkNode) {
removeLinkNode.addEventListener(
'click', e => this._evtDeleteButtonClick(e, rowNode));
}
const defaultLinkNode = rowNode.querySelector('.set-default a');
if (defaultLinkNode) {
defaultLinkNode.addEventListener(
'click', e => this._evtSetDefaultButtonClick(e, rowNode));
}
this._tableBodyNode.appendChild(rowNode);
rowNode._poolCategory = poolCategory;
poolCategory._rowNode = rowNode;
}
_removePoolCategoryRowNode(poolCategory) {
const rowNode = poolCategory._rowNode;
rowNode.parentNode.removeChild(rowNode);
}
_evtPoolCategoryAdded(e) {
this._addPoolCategoryRowNode(e.detail.poolCategory);
}
_evtPoolCategoryDeleted(e) {
this._removePoolCategoryRowNode(e.detail.poolCategory);
}
_evtAddButtonClick(e) {
e.preventDefault();
this._ctx.poolCategories.add(new PoolCategory());
}
_evtNameChange(e, rowNode) {
rowNode._poolCategory.name = e.target.value;
}
_evtColorChange(e, rowNode) {
e.target.value = e.target.value.toLowerCase();
rowNode._poolCategory.color = e.target.value;
}
_evtDeleteButtonClick(e, rowNode, link) {
e.preventDefault();
if (e.target.classList.contains('inactive')) {
return;
}
this._ctx.poolCategories.remove(rowNode._poolCategory);
}
_evtSetDefaultButtonClick(e, rowNode) {
e.preventDefault();
this._ctx.poolCategories.defaultCategory = rowNode._poolCategory;
const oldRowNode = rowNode.parentNode.querySelector('tr.default');
if (oldRowNode) {
oldRowNode.classList.remove('default');
}
rowNode.classList.add('default');
}
_evtSaveButtonClick(e, ctx) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit'));
}
}
module.exports = PoolCategoriesView;

View file

@ -0,0 +1,108 @@
'use strict';
const events = require('../events.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const Pool = require('../models/pool.js')
const template = views.getTemplate('pool-create');
class PoolCreateView extends events.EventTarget {
constructor(ctx) {
super();
this._hostNode = document.getElementById('content-holder');
views.replaceContent(this._hostNode, template(ctx));
views.decorateValidator(this._formNode);
if (this._namesFieldNode) {
this._namesFieldNode.addEventListener(
'input', e => this._evtNameInput(e));
}
for (let node of this._formNode.querySelectorAll(
'input, select, textarea')) {
node.addEventListener(
'change', e => {
this.dispatchEvent(new CustomEvent('change'));
});
}
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
clearMessages() {
views.clearMessages(this._hostNode);
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
_evtNameInput(e) {
const regex = new RegExp(api.getPoolNameRegex());
const list = misc.splitByWhitespace(this._namesFieldNode.value);
if (!list.length) {
this._namesFieldNode.setCustomValidity(
'Pools must have at least one name.');
return;
}
for (let item of list) {
if (!regex.test(item)) {
this._namesFieldNode.setCustomValidity(
`Pool name "${item}" contains invalid symbols.`);
return;
}
}
this._namesFieldNode.setCustomValidity('');
}
_evtSubmit(e) {
e.preventDefault();
let pool = new Pool()
pool.names = misc.splitByWhitespace(this._namesFieldNode.value);
pool.category = this._categoryFieldNode.value;
pool.description = this._descriptionFieldNode.value;
this.dispatchEvent(new CustomEvent('submit', {
detail: {
pool: pool,
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _namesFieldNode() {
return this._formNode.querySelector('.names input');
}
get _categoryFieldNode() {
return this._formNode.querySelector('.category select');
}
get _descriptionFieldNode() {
return this._formNode.querySelector('.description textarea');
}
}
module.exports = PoolCreateView;

View file

@ -0,0 +1,53 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const template = views.getTemplate('pool-delete');
class PoolDeleteView extends events.EventTarget {
constructor(ctx) {
super();
this._hostNode = ctx.hostNode;
this._pool = ctx.pool;
views.replaceContent(this._hostNode, template(ctx));
views.decorateValidator(this._formNode);
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
clearMessages() {
views.clearMessages(this._hostNode);
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
pool: this._pool,
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
}
module.exports = PoolDeleteView;

View file

@ -0,0 +1,115 @@
'use strict';
const events = require('../events.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const template = views.getTemplate('pool-edit');
class PoolEditView extends events.EventTarget {
constructor(ctx) {
super();
this._pool = ctx.pool;
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
views.decorateValidator(this._formNode);
if (this._namesFieldNode) {
this._namesFieldNode.addEventListener(
'input', e => this._evtNameInput(e));
}
for (let node of this._formNode.querySelectorAll(
'input, select, textarea')) {
node.addEventListener(
'change', e => {
this.dispatchEvent(new CustomEvent('change'));
});
}
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
clearMessages() {
views.clearMessages(this._hostNode);
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
_evtNameInput(e) {
const regex = new RegExp(api.getPoolNameRegex());
const list = misc.splitByWhitespace(this._namesFieldNode.value);
if (!list.length) {
this._namesFieldNode.setCustomValidity(
'Pools must have at least one name.');
return;
}
for (let item of list) {
if (!regex.test(item)) {
this._namesFieldNode.setCustomValidity(
`Pool name "${item}" contains invalid symbols.`);
return;
}
}
this._namesFieldNode.setCustomValidity('');
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
pool: this._pool,
names: this._namesFieldNode ?
misc.splitByWhitespace(this._namesFieldNode.value) :
undefined,
category: this._categoryFieldNode ?
this._categoryFieldNode.value :
undefined,
description: this._descriptionFieldNode ?
this._descriptionFieldNode.value :
undefined,
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _namesFieldNode() {
return this._formNode.querySelector('.names input');
}
get _categoryFieldNode() {
return this._formNode.querySelector('.category select');
}
get _descriptionFieldNode() {
return this._formNode.querySelector('.description textarea');
}
}
module.exports = PoolEditView;

View file

@ -0,0 +1,77 @@
'use strict';
const events = require('../events.js');
const api = require('../api.js');
const views = require('../util/views.js');
const PoolAutoCompleteControl =
require('../controls/pool_auto_complete_control.js');
const template = views.getTemplate('pool-merge');
class PoolMergeView extends events.EventTarget {
constructor(ctx) {
super();
this._pool = ctx.pool;
this._hostNode = ctx.hostNode;
ctx.poolNamePattern = api.getPoolNameRegex();
views.replaceContent(this._hostNode, template(ctx));
views.decorateValidator(this._formNode);
if (this._targetPoolFieldNode) {
this._autoCompleteControl = new PoolAutoCompleteControl(
this._targetPoolFieldNode,
{
confirm: pool =>
this._autoCompleteControl.replaceSelectedText(
pool.names[0], false),
});
}
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
clearMessages() {
views.clearMessages(this._hostNode);
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
pool: this._pool,
targetPoolName: this._targetPoolFieldNode.value
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _targetPoolFieldNode() {
return this._formNode.querySelector('input[name=target-pool]');
}
get _addAliasCheckboxNode() {
return this._formNode.querySelector('input[name=alias]');
}
}
module.exports = PoolMergeView;

View file

@ -0,0 +1,23 @@
'use strict';
const views = require('../util/views.js');
const template = views.getTemplate('pool-summary');
class PoolSummaryView {
constructor(ctx) {
this._pool = ctx.pool;
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
}
module.exports = PoolSummaryView;

View file

@ -0,0 +1,106 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const misc = require('../util/misc.js');
const PoolSummaryView = require('./pool_summary_view.js');
const PoolEditView = require('./pool_edit_view.js');
const PoolMergeView = require('./pool_merge_view.js');
const PoolDeleteView = require('./pool_delete_view.js');
const EmptyView = require('../views/empty_view.js');
const template = views.getTemplate('pool');
class PoolView extends events.EventTarget {
constructor(ctx) {
super();
this._ctx = ctx;
ctx.pool.addEventListener('change', e => this._evtChange(e));
ctx.section = ctx.section || 'summary';
ctx.getPrettyPoolName = misc.getPrettyPoolName;
this._hostNode = document.getElementById('content-holder');
this._install();
}
_install() {
const ctx = this._ctx;
views.replaceContent(this._hostNode, template(ctx));
for (let item of this._hostNode.querySelectorAll('[data-name]')) {
item.classList.toggle(
'active', item.getAttribute('data-name') === ctx.section);
if (item.getAttribute('data-name') === ctx.section) {
item.parentNode.scrollLeft =
item.getBoundingClientRect().left -
item.parentNode.getBoundingClientRect().left
}
}
ctx.hostNode = this._hostNode.querySelector('.pool-content-holder');
if (ctx.section === 'edit') {
if (!this._ctx.canEditAnything) {
this._view = new EmptyView();
this._view.showError(
'You don\'t have privileges to edit pools.');
} else {
this._view = new PoolEditView(ctx);
events.proxyEvent(this._view, this, 'submit');
}
} else if (ctx.section === 'merge') {
if (!this._ctx.canMerge) {
this._view = new EmptyView();
this._view.showError(
'You don\'t have privileges to merge pools.');
} else {
this._view = new PoolMergeView(ctx);
events.proxyEvent(this._view, this, 'submit', 'merge');
}
} else if (ctx.section === 'delete') {
if (!this._ctx.canDelete) {
this._view = new EmptyView();
this._view.showError(
'You don\'t have privileges to delete pools.');
} else {
this._view = new PoolDeleteView(ctx);
events.proxyEvent(this._view, this, 'submit', 'delete');
}
} else {
this._view = new PoolSummaryView(ctx);
}
events.proxyEvent(this._view, this, 'change');
views.syncScrollPosition();
}
clearMessages() {
this._view.clearMessages();
}
enableForm() {
this._view.enableForm();
}
disableForm() {
this._view.disableForm();
}
showSuccess(message) {
this._view.showSuccess(message);
}
showError(message) {
this._view.showError(message);
}
_evtChange(e) {
this._ctx.pool = e.detail.pool;
this._install(this._ctx);
}
}
module.exports = PoolView;

View file

@ -0,0 +1,52 @@
'use strict';
const events = require('../events.js');
const misc = require('../util/misc.js');
const search = require('../util/search.js');
const views = require('../util/views.js');
const PoolAutoCompleteControl =
require('../controls/pool_auto_complete_control.js');
const template = views.getTemplate('pools-header');
class PoolsHeaderView extends events.EventTarget {
constructor(ctx) {
super();
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
if (this._queryInputNode) {
this._autoCompleteControl = new PoolAutoCompleteControl(
this._queryInputNode,
{
confirm: pool =>
this._autoCompleteControl.replaceSelectedText(
misc.escapeSearchTerm(pool.names[0]), true),
});
}
search.searchInputNodeFocusHelper(this._queryInputNode);
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _queryInputNode() {
return this._hostNode.querySelector('[name=search-text]');
}
_evtSubmit(e) {
e.preventDefault();
this._queryInputNode.blur();
this.dispatchEvent(new CustomEvent('navigate', {detail: {parameters: {
query: this._queryInputNode.value,
page: 1,
}}}));
}
}
module.exports = PoolsHeaderView;

View file

@ -0,0 +1,13 @@
'use strict';
const views = require('../util/views.js');
const template = views.getTemplate('pools-page');
class PoolsPageView {
constructor(ctx) {
views.replaceContent(ctx.hostNode, template(ctx));
}
}
module.exports = PoolsPageView;

View file

@ -3,7 +3,7 @@
"private": true,
"scripts": {
"build": "node build.js",
"watch": "c1=\"\";while :;do c2=$(find html js css img -type f -and -not -iname '*autogen*'|sort|xargs cat|md5sum);[[ $c1 != $c2 ]]&&npm run build -- --no-vendor-js;c1=$c2;sleep 1;done"
"watch": "c1=\"\";while :;do c2=$(find html js css img -type f -and -not -iname '*autogen*'|sort|xargs cat|md5sum);[[ $c1 != $c2 ]]&&npm run build -- --debug --no-vendor-js;c1=$c2;sleep 1;done"
},
"dependencies": {
"font-awesome": "^4.7.0",

View file

@ -3,6 +3,7 @@ script_location = szurubooru/migrations
# overriden by szurubooru's config
sqlalchemy.url =
revision_environment = true
[loggers]
keys = root,sqlalchemy,alembic

View file

@ -4,7 +4,7 @@
# shown in the website title and on the front page
name: szurubooru
# full url to the homepage of this szurubooru site, with no trailing slash
domain: # example: http://example.com
domain: localhost # example: http://example.com
# used to salt the users' password hashes and generate filenames for static content
secret: change
@ -49,6 +49,9 @@ enable_safety: yes
tag_name_regex: ^\S+$
tag_category_name_regex: ^[^\s%+#/]+$
pool_name_regex: ^\S+$
pool_category_name_regex: ^[^\s%+#/]+$
# don't make these more restrictive unless you want to annoy people; if you do
# customize them, make sure to update the instructions in the registration form
# template as well.
@ -58,86 +61,100 @@ user_name_regex: '^[a-zA-Z0-9_-]{1,32}$'
default_rank: regular
privileges:
'users:create:self': anonymous # Registration permission
'users:create:any': administrator
'users:list': regular
'users:view': regular
'users:edit:any:name': moderator
'users:edit:any:pass': moderator
'users:edit:any:email': moderator
'users:edit:any:avatar': moderator
'users:edit:any:rank': moderator
'users:edit:self:name': regular
'users:edit:self:pass': regular
'users:edit:self:email': regular
'users:edit:self:avatar': regular
'users:edit:self:rank': moderator # one can't promote themselves or anyone to upper rank than their own.
'users:delete:any': administrator
'users:delete:self': regular
'users:create:self': anonymous # Registration permission
'users:create:any': administrator
'users:list': regular
'users:view': regular
'users:edit:any:name': moderator
'users:edit:any:pass': moderator
'users:edit:any:email': moderator
'users:edit:any:avatar': moderator
'users:edit:any:rank': moderator
'users:edit:self:name': regular
'users:edit:self:pass': regular
'users:edit:self:email': regular
'users:edit:self:avatar': regular
'users:edit:self:rank': moderator # one can't promote themselves or anyone to upper rank than their own.
'users:delete:any': administrator
'users:delete:self': regular
'user_tokens:list:any': administrator
'user_tokens:list:self': regular
'user_tokens:create:any': administrator
'user_tokens:create:self': regular
'user_tokens:edit:any': administrator
'user_tokens:edit:self': regular
'user_tokens:delete:any': administrator
'user_tokens:delete:self': regular
'user_tokens:list:any': administrator
'user_tokens:list:self': regular
'user_tokens:create:any': administrator
'user_tokens:create:self': regular
'user_tokens:edit:any': administrator
'user_tokens:edit:self': regular
'user_tokens:delete:any': administrator
'user_tokens:delete:self': regular
'posts:create:anonymous': regular
'posts:create:identified': regular
'posts:list': anonymous
'posts:reverse_search': regular
'posts:view': anonymous
'posts:view:featured': anonymous
'posts:edit:content': power
'posts:edit:flags': regular
'posts:edit:notes': regular
'posts:edit:relations': regular
'posts:edit:safety': power
'posts:edit:source': regular
'posts:edit:tags': regular
'posts:edit:thumbnail': power
'posts:feature': moderator
'posts:delete': moderator
'posts:score': regular
'posts:merge': moderator
'posts:favorite': regular
'posts:bulk-edit:tags': power
'posts:bulk-edit:safety': power
'posts:create:anonymous': regular
'posts:create:identified': regular
'posts:list': anonymous
'posts:reverse_search': regular
'posts:view': anonymous
'posts:view:featured': anonymous
'posts:edit:content': power
'posts:edit:flags': regular
'posts:edit:notes': regular
'posts:edit:relations': regular
'posts:edit:safety': power
'posts:edit:source': regular
'posts:edit:tags': regular
'posts:edit:thumbnail': power
'posts:feature': moderator
'posts:delete': moderator
'posts:score': regular
'posts:merge': moderator
'posts:favorite': regular
'posts:bulk-edit:tags': power
'posts:bulk-edit:safety': power
'tags:create': regular
'tags:edit:names': power
'tags:edit:category': power
'tags:edit:description': power
'tags:edit:implications': power
'tags:edit:suggestions': power
'tags:list': regular
'tags:view': anonymous
'tags:merge': moderator
'tags:delete': moderator
'tags:create': regular
'tags:edit:names': power
'tags:edit:category': power
'tags:edit:description': power
'tags:edit:implications': power
'tags:edit:suggestions': power
'tags:list': regular
'tags:view': anonymous
'tags:merge': moderator
'tags:delete': moderator
'tag_categories:create': moderator
'tag_categories:edit:name': moderator
'tag_categories:edit:color': moderator
'tag_categories:list': anonymous
'tag_categories:view': anonymous
'tag_categories:delete': moderator
'tag_categories:set_default': moderator
'tag_categories:create': moderator
'tag_categories:edit:name': moderator
'tag_categories:edit:color': moderator
'tag_categories:list': anonymous
'tag_categories:view': anonymous
'tag_categories:delete': moderator
'tag_categories:set_default': moderator
'comments:create': regular
'comments:delete:any': moderator
'comments:delete:own': regular
'comments:edit:any': moderator
'comments:edit:own': regular
'comments:list': regular
'comments:view': regular
'comments:score': regular
'pools:create': regular
'pools:list': regular
'pools:view': anonymous
'pools:merge': moderator
'pools:delete': moderator
'snapshots:list': power
'pool_categories:create': moderator
'pool_categories:edit:name': moderator
'pool_categories:edit:color': moderator
'pool_categories:list': anonymous
'pool_categories:view': anonymous
'pool_categories:delete': moderator
'pool_categories:set_default': moderator
'uploads:create': regular
'uploads:use_downloader': power
'comments:create': regular
'comments:delete:any': moderator
'comments:delete:own': regular
'comments:edit:any': moderator
'comments:edit:own': regular
'comments:list': regular
'comments:view': regular
'comments:score': regular
'snapshots:list': power
'uploads:create': regular
'uploads:use_downloader': power
## ONLY SET THESE IF DEPLOYING OUTSIDE OF DOCKER
#debug: 0 # generate server logs?

View file

@ -9,3 +9,4 @@ pillow>=4.3.0
pynacl>=1.2.1
pytz>=2018.3
pyRFC3339>=1.0
youtube_dl>=2020.5.3

View file

@ -4,6 +4,8 @@ import szurubooru.api.user_token_api
import szurubooru.api.post_api
import szurubooru.api.tag_api
import szurubooru.api.tag_category_api
import szurubooru.api.pool_api
import szurubooru.api.pool_category_api
import szurubooru.api.comment_api
import szurubooru.api.password_reset_api
import szurubooru.api.snapshot_api

View file

@ -0,0 +1,127 @@
from typing import Optional, List, Dict
from datetime import datetime
from szurubooru import db, model, search, rest
from szurubooru.func import auth, pools, snapshots, serialization, versions
_search_executor = search.Executor(search.configs.PoolSearchConfig())
def _serialize(ctx: rest.Context, pool: model.Pool) -> rest.Response:
return pools.serialize_pool(
pool, options=serialization.get_serialization_options(ctx))
def _get_pool(params: Dict[str, str]) -> model.Pool:
return pools.get_pool_by_id(params['pool_id'])
# def _create_if_needed(pool_names: List[str], user: model.User) -> None:
# if not pool_names:
# return
# _existing_pools, new_pools = pools.get_or_create_pools_by_names(pool_names)
# if len(new_pools):
# auth.verify_privilege(user, 'pools:create')
# db.session.flush()
# for pool in new_pools:
# snapshots.create(pool, user)
@rest.routes.get('/pools/?')
def get_pools(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
auth.verify_privilege(ctx.user, 'pools:list')
return _search_executor.execute_and_serialize(
ctx, lambda pool: _serialize(ctx, pool))
@rest.routes.post('/pools/?')
def create_pool(
ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
auth.verify_privilege(ctx.user, 'pools:create')
names = ctx.get_param_as_string_list('names')
category = ctx.get_param_as_string('category')
description = ctx.get_param_as_string('description', default='')
# TODO
# suggestions = ctx.get_param_as_string_list('suggestions', default=[])
# implications = ctx.get_param_as_string_list('implications', default=[])
# _create_if_needed(suggestions, ctx.user)
# _create_if_needed(implications, ctx.user)
pool = pools.create_pool(names, category)
pools.update_pool_description(pool, description)
ctx.session.add(pool)
ctx.session.flush()
snapshots.create(pool, ctx.user)
ctx.session.commit()
return _serialize(ctx, pool)
@rest.routes.get('/pool/(?P<pool_id>[^/]+)/?')
def get_pool(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
auth.verify_privilege(ctx.user, 'pools:view')
pool = _get_pool(params)
return _serialize(ctx, pool)
@rest.routes.put('/pool/(?P<pool_id>[^/]+)/?')
def update_pool(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
pool = _get_pool(params)
versions.verify_version(pool, ctx)
versions.bump_version(pool)
if ctx.has_param('names'):
auth.verify_privilege(ctx.user, 'pools:edit:names')
pools.update_pool_names(pool, ctx.get_param_as_string_list('names'))
if ctx.has_param('category'):
auth.verify_privilege(ctx.user, 'pools:edit:category')
pools.update_pool_category_name(
pool, ctx.get_param_as_string('category'))
if ctx.has_param('description'):
auth.verify_privilege(ctx.user, 'pools:edit:description')
pools.update_pool_description(
pool, ctx.get_param_as_string('description'))
# TODO
# if ctx.has_param('suggestions'):
# auth.verify_privilege(ctx.user, 'pools:edit:suggestions')
# suggestions = ctx.get_param_as_string_list('suggestions')
# _create_if_needed(suggestions, ctx.user)
# pools.update_pool_suggestions(pool, suggestions)
# if ctx.has_param('implications'):
# auth.verify_privilege(ctx.user, 'pools:edit:implications')
# implications = ctx.get_param_as_string_list('implications')
# _create_if_needed(implications, ctx.user)
# pools.update_pool_implications(pool, implications)
pool.last_edit_time = datetime.utcnow()
ctx.session.flush()
snapshots.modify(pool, ctx.user)
ctx.session.commit()
return _serialize(ctx, pool)
@rest.routes.delete('/pool/(?P<pool_id>[^/]+)/?')
def delete_pool(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
pool = _get_pool(params)
versions.verify_version(pool, ctx)
auth.verify_privilege(ctx.user, 'pools:delete')
snapshots.delete(pool, ctx.user)
pools.delete(pool)
ctx.session.commit()
return {}
@rest.routes.post('/pool-merge/?')
def merge_pools(
ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
source_pool_id = ctx.get_param_as_string('remove')
target_pool_id = ctx.get_param_as_string('mergeTo')
source_pool = pools.get_pool_by_id(source_pool_id)
target_pool = pools.get_pool_by_id(target_pool_id)
versions.verify_version(source_pool, ctx, 'removeVersion')
versions.verify_version(target_pool, ctx, 'mergeToVersion')
versions.bump_version(target_pool)
auth.verify_privilege(ctx.user, 'pools:merge')
pools.merge_pools(source_pool, target_pool)
snapshots.merge(source_pool, target_pool, ctx.user)
ctx.session.commit()
return _serialize(ctx, target_pool)

View file

@ -0,0 +1,89 @@
from typing import Dict
from szurubooru import model, rest
from szurubooru.func import (
auth, pools, pool_categories, snapshots, serialization, versions)
def _serialize(
ctx: rest.Context, category: model.PoolCategory) -> rest.Response:
return pool_categories.serialize_category(
category, options=serialization.get_serialization_options(ctx))
@rest.routes.get('/pool-categories/?')
def get_pool_categories(
ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
auth.verify_privilege(ctx.user, 'pool_categories:list')
categories = pool_categories.get_all_categories()
return {
'results': [_serialize(ctx, category) for category in categories],
}
@rest.routes.post('/pool-categories/?')
def create_pool_category(
ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
auth.verify_privilege(ctx.user, 'pool_categories:create')
name = ctx.get_param_as_string('name')
color = ctx.get_param_as_string('color')
category = pool_categories.create_category(name, color)
ctx.session.add(category)
ctx.session.flush()
snapshots.create(category, ctx.user)
ctx.session.commit()
return _serialize(ctx, category)
@rest.routes.get('/pool-category/(?P<category_name>[^/]+)/?')
def get_pool_category(
ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
auth.verify_privilege(ctx.user, 'pool_categories:view')
category = pool_categories.get_category_by_name(params['category_name'])
return _serialize(ctx, category)
@rest.routes.put('/pool-category/(?P<category_name>[^/]+)/?')
def update_pool_category(
ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
category = pool_categories.get_category_by_name(
params['category_name'], lock=True)
versions.verify_version(category, ctx)
versions.bump_version(category)
if ctx.has_param('name'):
auth.verify_privilege(ctx.user, 'pool_categories:edit:name')
pool_categories.update_category_name(
category, ctx.get_param_as_string('name'))
if ctx.has_param('color'):
auth.verify_privilege(ctx.user, 'pool_categories:edit:color')
pool_categories.update_category_color(
category, ctx.get_param_as_string('color'))
ctx.session.flush()
snapshots.modify(category, ctx.user)
ctx.session.commit()
return _serialize(ctx, category)
@rest.routes.delete('/pool-category/(?P<category_name>[^/]+)/?')
def delete_pool_category(
ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
category = pool_categories.get_category_by_name(
params['category_name'], lock=True)
versions.verify_version(category, ctx)
auth.verify_privilege(ctx.user, 'pool_categories:delete')
pool_categories.delete_category(category)
snapshots.delete(category, ctx.user)
ctx.session.commit()
return {}
@rest.routes.put('/pool-category/(?P<category_name>[^/]+)/default/?')
def set_pool_category_as_default(
ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
auth.verify_privilege(ctx.user, 'pool_categories:set_default')
category = pool_categories.get_category_by_name(
params['category_name'], lock=True)
pool_categories.set_default_category(category)
ctx.session.flush()
snapshots.modify(category, ctx.user)
ctx.session.commit()
return _serialize(ctx, category)

View file

@ -0,0 +1,199 @@
import re
from typing import Any, Optional, Dict, List, Callable
import sqlalchemy as sa
from szurubooru import config, db, model, errors, rest
from szurubooru.func import util, serialization, cache
DEFAULT_CATEGORY_NAME_CACHE_KEY = 'default-pool-category'
class PoolCategoryNotFoundError(errors.NotFoundError):
pass
class PoolCategoryAlreadyExistsError(errors.ValidationError):
pass
class PoolCategoryIsInUseError(errors.ValidationError):
pass
class InvalidPoolCategoryNameError(errors.ValidationError):
pass
class InvalidPoolCategoryColorError(errors.ValidationError):
pass
def _verify_name_validity(name: str) -> None:
name_regex = config.config['pool_category_name_regex']
if not re.match(name_regex, name):
raise InvalidPoolCategoryNameError(
'Name must satisfy regex %r.' % name_regex)
class PoolCategorySerializer(serialization.BaseSerializer):
def __init__(self, category: model.PoolCategory) -> None:
self.category = category
def _serializers(self) -> Dict[str, Callable[[], Any]]:
return {
'name': self.serialize_name,
'version': self.serialize_version,
'color': self.serialize_color,
'usages': self.serialize_usages,
'default': self.serialize_default,
}
def serialize_name(self) -> Any:
return self.category.name
def serialize_version(self) -> Any:
return self.category.version
def serialize_color(self) -> Any:
return self.category.color
def serialize_usages(self) -> Any:
return self.category.pool_count
def serialize_default(self) -> Any:
return self.category.default
def serialize_category(
category: Optional[model.PoolCategory],
options: List[str] = []) -> Optional[rest.Response]:
if not category:
return None
return PoolCategorySerializer(category).serialize(options)
def create_category(name: str, color: str) -> model.PoolCategory:
category = model.PoolCategory()
update_category_name(category, name)
update_category_color(category, color)
if not get_all_categories():
category.default = True
return category
def update_category_name(category: model.PoolCategory, name: str) -> None:
assert category
if not name:
raise InvalidPoolCategoryNameError('Name cannot be empty.')
expr = sa.func.lower(model.PoolCategory.name) == name.lower()
if category.pool_category_id:
expr = expr & (
model.PoolCategory.pool_category_id != category.pool_category_id)
already_exists = (
db.session.query(model.PoolCategory).filter(expr).count() > 0)
if already_exists:
raise PoolCategoryAlreadyExistsError(
'A category with this name already exists.')
if util.value_exceeds_column_size(name, model.PoolCategory.name):
raise InvalidPoolCategoryNameError('Name is too long.')
_verify_name_validity(name)
category.name = name
cache.remove(DEFAULT_CATEGORY_NAME_CACHE_KEY)
def update_category_color(category: model.PoolCategory, color: str) -> None:
assert category
if not color:
raise InvalidPoolCategoryColorError('Color cannot be empty.')
if not re.match(r'^#?[0-9a-z]+$', color):
raise InvalidPoolCategoryColorError('Invalid color.')
if util.value_exceeds_column_size(color, model.PoolCategory.color):
raise InvalidPoolCategoryColorError('Color is too long.')
category.color = color
def try_get_category_by_name(
name: str, lock: bool = False) -> Optional[model.PoolCategory]:
query = (
db.session
.query(model.PoolCategory)
.filter(sa.func.lower(model.PoolCategory.name) == name.lower()))
if lock:
query = query.with_for_update()
return query.one_or_none()
def get_category_by_name(name: str, lock: bool = False) -> model.PoolCategory:
category = try_get_category_by_name(name, lock)
if not category:
raise PoolCategoryNotFoundError('Pool category %r not found.' % name)
return category
def get_all_category_names() -> List[str]:
return [cat.name for cat in get_all_categories()]
def get_all_categories() -> List[model.PoolCategory]:
return db.session.query(model.PoolCategory).order_by(
model.PoolCategory.name.asc()).all()
def try_get_default_category(
lock: bool = False) -> Optional[model.PoolCategory]:
query = (
db.session
.query(model.PoolCategory)
.filter(model.PoolCategory.default))
if lock:
query = query.with_for_update()
category = query.first()
# if for some reason (e.g. as a result of migration) there's no default
# category, get the first record available.
if not category:
query = (
db.session
.query(model.PoolCategory)
.order_by(model.PoolCategory.pool_category_id.asc()))
if lock:
query = query.with_for_update()
category = query.first()
return category
def get_default_category(lock: bool = False) -> model.PoolCategory:
category = try_get_default_category(lock)
if not category:
raise PoolCategoryNotFoundError('No pool category created yet.')
return category
def get_default_category_name() -> str:
if cache.has(DEFAULT_CATEGORY_NAME_CACHE_KEY):
return cache.get(DEFAULT_CATEGORY_NAME_CACHE_KEY)
default_category = get_default_category()
default_category_name = default_category.name
cache.put(DEFAULT_CATEGORY_NAME_CACHE_KEY, default_category_name)
return default_category_name
def set_default_category(category: model.PoolCategory) -> None:
assert category
old_category = try_get_default_category(lock=True)
if old_category:
db.session.refresh(old_category)
old_category.default = False
db.session.refresh(category)
category.default = True
cache.remove(DEFAULT_CATEGORY_NAME_CACHE_KEY)
def delete_category(category: model.PoolCategory) -> None:
assert category
if len(get_all_category_names()) == 1:
raise PoolCategoryIsInUseError('Cannot delete the last category.')
if (category.pool_count or 0) > 0:
raise PoolCategoryIsInUseError(
'Pool category has some usages and cannot be deleted. ' +
'Please remove this category from relevant pools first..')
db.session.delete(category)

View file

@ -0,0 +1,302 @@
import re
from typing import Any, Optional, Tuple, List, Dict, Callable
from datetime import datetime
import sqlalchemy as sa
from szurubooru import config, db, model, errors, rest
from szurubooru.func import util, pool_categories, serialization
class PoolNotFoundError(errors.NotFoundError):
pass
class PoolAlreadyExistsError(errors.ValidationError):
pass
class PoolIsInUseError(errors.ValidationError):
pass
class InvalidPoolNameError(errors.ValidationError):
pass
class InvalidPoolRelationError(errors.ValidationError):
pass
class InvalidPoolCategoryError(errors.ValidationError):
pass
class InvalidPoolDescriptionError(errors.ValidationError):
pass
def _verify_name_validity(name: str) -> None:
if util.value_exceeds_column_size(name, model.PoolName.name):
raise InvalidPoolNameError('Name is too long.')
name_regex = config.config['pool_name_regex']
if not re.match(name_regex, name):
raise InvalidPoolNameError('Name must satisfy regex %r.' % name_regex)
def _get_names(pool: model.Pool) -> List[str]:
assert pool
return [pool_name.name for pool_name in pool.names]
def _lower_list(names: List[str]) -> List[str]:
return [name.lower() for name in names]
def _check_name_intersection(
names1: List[str], names2: List[str], case_sensitive: bool) -> bool:
if not case_sensitive:
names1 = _lower_list(names1)
names2 = _lower_list(names2)
return len(set(names1).intersection(names2)) > 0
def sort_pools(pools: List[model.Pool]) -> List[model.Pool]:
default_category_name = pool_categories.get_default_category_name()
return sorted(
pools,
key=lambda pool: (
default_category_name == pool.category.name,
pool.category.name,
pool.names[0].name)
)
class PoolSerializer(serialization.BaseSerializer):
def __init__(self, pool: model.Pool) -> None:
self.pool = pool
def _serializers(self) -> Dict[str, Callable[[], Any]]:
return {
'id': self.serialize_id,
'names': self.serialize_names,
'category': self.serialize_category,
'version': self.serialize_version,
'description': self.serialize_description,
'creationTime': self.serialize_creation_time,
'lastEditTime': self.serialize_last_edit_time,
'postCount': self.serialize_post_count
}
def serialize_id(self) -> Any:
return self.pool.pool_id
def serialize_names(self) -> Any:
return [pool_name.name for pool_name in self.pool.names]
def serialize_category(self) -> Any:
return self.pool.category.name
def serialize_version(self) -> Any:
return self.pool.version
def serialize_description(self) -> Any:
return self.pool.description
def serialize_creation_time(self) -> Any:
return self.pool.creation_time
def serialize_last_edit_time(self) -> Any:
return self.pool.last_edit_time
def serialize_post_count(self) -> Any:
return self.pool.post_count
def serialize_pool(
pool: model.Pool, options: List[str] = []) -> Optional[rest.Response]:
if not pool:
return None
return PoolSerializer(pool).serialize(options)
def try_get_pool_by_id(pool_id: int) -> Optional[model.Pool]:
return (
db.session
.query(model.Pool)
.filter(model.Pool.pool_id == pool_id)
.one_or_none())
def get_pool_by_id(pool_id: int) -> model.Pool:
pool = try_get_pool_by_id(pool_id)
if not pool:
raise PoolNotFoundError('Pool %r not found.' % pool_id)
return pool
def try_get_pool_by_name(name: str) -> Optional[model.Pool]:
return (
db.session
.query(model.Pool)
.join(model.PoolName)
.filter(sa.func.lower(model.PoolName.name) == name.lower())
.one_or_none())
def get_pool_by_name(name: str) -> model.Pool:
pool = try_get_pool_by_name(name)
if not pool:
raise PoolNotFoundError('Pool %r not found.' % name)
return pool
def get_pools_by_names(names: List[str]) -> List[model.Pool]:
names = util.icase_unique(names)
if len(names) == 0:
return []
return (
db.session.query(model.Pool)
.join(model.PoolName)
.filter(
sa.sql.or_(
sa.func.lower(model.PoolName.name) == name.lower()
for name in names))
.all())
def get_or_create_pools_by_names(
names: List[str]) -> Tuple[List[model.Pool], List[model.Pool]]:
names = util.icase_unique(names)
existing_pools = get_pools_by_names(names)
new_pools = []
pool_category_name = pool_categories.get_default_category_name()
for name in names:
found = False
for existing_pool in existing_pools:
if _check_name_intersection(
_get_names(existing_pool), [name], False):
found = True
break
if not found:
new_pool = create_pool(
names=[name],
category_name=pool_category_name)
db.session.add(new_pool)
new_pools.append(new_pool)
return existing_pools, new_pools
def delete(source_pool: model.Pool) -> None:
assert source_pool
db.session.delete(source_pool)
def merge_pools(source_pool: model.Pool, target_pool: model.Pool) -> None:
assert source_pool
assert target_pool
if source_pool.pool_id == target_pool.pool_id:
raise InvalidPoolRelationError('Cannot merge pool with itself.')
def merge_posts(source_pool_id: int, target_pool_id: int) -> None:
pass
# alias1 = model.PostPool
# alias2 = sa.orm.util.aliased(model.PostPool)
# update_stmt = (
# sa.sql.expression.update(alias1)
# .where(alias1.pool_id == source_pool_id))
# update_stmt = (
# update_stmt
# .where(
# ~sa.exists()
# .where(alias1.post_id == alias2.post_id)
# .where(alias2.pool_id == target_pool_id)))
# update_stmt = update_stmt.values(pool_id=target_pool_id)
# db.session.execute(update_stmt)
def merge_relations(
table: model.Base, source_pool_id: int, target_pool_id: int) -> None:
alias1 = table
alias2 = sa.orm.util.aliased(table)
update_stmt = (
sa.sql.expression.update(alias1)
.where(alias1.parent_id == source_pool_id)
.where(alias1.child_id != target_pool_id)
.where(
~sa.exists()
.where(alias2.child_id == alias1.child_id)
.where(alias2.parent_id == target_pool_id))
.values(parent_id=target_pool_id))
db.session.execute(update_stmt)
update_stmt = (
sa.sql.expression.update(alias1)
.where(alias1.child_id == source_pool_id)
.where(alias1.parent_id != target_pool_id)
.where(
~sa.exists()
.where(alias2.parent_id == alias1.parent_id)
.where(alias2.child_id == target_pool_id))
.values(child_id=target_pool_id))
db.session.execute(update_stmt)
merge_posts(source_pool.pool_id, target_pool.pool_id)
delete(source_pool)
def create_pool(
names: List[str],
category_name: str) -> model.Pool:
pool = model.Pool()
pool.creation_time = datetime.utcnow()
update_pool_names(pool, names)
update_pool_category_name(pool, category_name)
return pool
def update_pool_category_name(pool: model.Pool, category_name: str) -> None:
assert pool
pool.category = pool_categories.get_category_by_name(category_name)
def update_pool_names(pool: model.Pool, names: List[str]) -> None:
# sanitize
assert pool
names = util.icase_unique([name for name in names if name])
if not len(names):
raise InvalidPoolNameError('At least one name must be specified.')
for name in names:
_verify_name_validity(name)
# check for existing pools
expr = sa.sql.false()
for name in names:
expr = expr | (sa.func.lower(model.PoolName.name) == name.lower())
if pool.pool_id:
expr = expr & (model.PoolName.pool_id != pool.pool_id)
existing_pools = db.session.query(model.PoolName).filter(expr).all()
if len(existing_pools):
raise PoolAlreadyExistsError(
'One of names is already used by another pool.')
# remove unwanted items
for pool_name in pool.names[:]:
if not _check_name_intersection([pool_name.name], names, True):
pool.names.remove(pool_name)
# add wanted items
for name in names:
if not _check_name_intersection(_get_names(pool), [name], True):
pool.names.append(model.PoolName(name, -1))
# set alias order to match the request
for i, name in enumerate(names):
for pool_name in pool.names:
if pool_name.name.lower() == name.lower():
pool_name.order = i
def update_pool_description(pool: model.Pool, description: str) -> None:
assert pool
if util.value_exceeds_column_size(description, model.Pool.description):
raise InvalidPoolDescriptionError('Description is too long.')
pool.description = description or None

View file

@ -24,6 +24,24 @@ def get_tag_snapshot(tag: model.Tag) -> Dict[str, Any]:
}
def get_pool_category_snapshot(category: model.PoolCategory) -> Dict[str, Any]:
assert category
return {
'name': category.name,
'color': category.color,
'default': True if category.default else False,
}
def get_pool_snapshot(pool: model.Pool) -> Dict[str, Any]:
assert pool
return {
'names': [pool_name.name for pool_name in pool.names],
'category': pool.category.name,
# TODO
}
def get_post_snapshot(post: model.Post) -> Dict[str, Any]:
assert post
return {
@ -47,6 +65,8 @@ _snapshot_factories = {
'tag_category': lambda entity: get_tag_category_snapshot(entity),
'tag': lambda entity: get_tag_snapshot(entity),
'post': lambda entity: get_post_snapshot(entity),
'pool_category': lambda entity: get_pool_category_snapshot(entity),
'pool': lambda entity: get_pool_snapshot(entity),
} # type: Dict[model.Base, Callable[[model.Base], Dict[str ,Any]]]

View file

@ -1,5 +1,3 @@
import json
import os
import re
from typing import Any, Optional, Tuple, List, Dict, Callable
from datetime import datetime

View file

@ -0,0 +1,60 @@
'''
add default pool category
Revision ID: 54de8acc6cef
Created at: 2020-05-03 14:57:46.825766
'''
import sqlalchemy as sa
from alembic import op
revision = '54de8acc6cef'
down_revision = '6a2f424ec9d2'
branch_labels = None
depends_on = None
Base = sa.ext.declarative.declarative_base()
class PoolCategory(Base):
__tablename__ = 'pool_category'
__table_args__ = {'extend_existing': True}
pool_category_id = sa.Column('id', sa.Integer, primary_key=True)
version = sa.Column('version', sa.Integer, nullable=False)
name = sa.Column('name', sa.Unicode(32), nullable=False)
color = sa.Column('color', sa.Unicode(32), nullable=False)
default = sa.Column('default', sa.Boolean, nullable=False)
__mapper_args__ = {
'version_id_col': version,
'version_id_generator': False,
}
def upgrade():
session = sa.orm.session.Session(bind=op.get_bind())
if session.query(PoolCategory).count() == 0:
category = PoolCategory()
category.name = 'default'
category.color = 'default'
category.version = 1
category.default = True
session.add(category)
session.commit()
def downgrade():
session = sa.orm.session.Session(bind=op.get_bind())
default_category = (
session
.query(PoolCategory)
.filter(PoolCategory.name == 'default')
.filter(PoolCategory.color == 'default')
.filter(PoolCategory.version == 1)
.filter(PoolCategory.default == 1)
.one_or_none())
if default_category:
session.delete(default_category)
session.commit()

View file

@ -0,0 +1,52 @@
'''
create pool tables
Revision ID: 6a2f424ec9d2
Created at: 2020-05-03 14:47:59.136410
'''
import sqlalchemy as sa
from alembic import op
revision = '6a2f424ec9d2'
down_revision = '1e280b5d5df1'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'pool_category',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False, default=1),
sa.Column('name', sa.Unicode(length=32), nullable=False),
sa.Column('color', sa.Unicode(length=32), nullable=False),
sa.Column('default', sa.Boolean(), nullable=False, default=False),
sa.PrimaryKeyConstraint('id'))
op.create_table(
'pool',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False, default=1),
sa.Column('description', sa.UnicodeText(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('creation_time', sa.DateTime(), nullable=False),
sa.Column('last_edit_time', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['category_id'], ['pool_category.id']),
sa.PrimaryKeyConstraint('id'))
op.create_table(
'pool_name',
sa.Column('pool_name_id', sa.Integer(), nullable=False),
sa.Column('pool_id', sa.Integer(), nullable=False),
sa.Column('name', sa.Unicode(length=256), nullable=False),
sa.Column('ord', sa.Integer(), nullable=False, index=True),
sa.ForeignKeyConstraint(['pool_id'], ['pool.id']),
sa.PrimaryKeyConstraint('pool_name_id'),
sa.UniqueConstraint('name'))
def downgrade():
op.drop_index(op.f('ix_pool_name_ord'), table_name='pool_name')
op.drop_table('pool_name')
op.drop_table('pool')
op.drop_table('pool_category')

View file

@ -11,6 +11,8 @@ from szurubooru.model.post import (
PostNote,
PostFeature,
PostSignature)
from szurubooru.model.pool import Pool, PoolName
from szurubooru.model.pool_category import PoolCategory
from szurubooru.model.comment import Comment, CommentScore
from szurubooru.model.snapshot import Snapshot
import szurubooru.model.util

View file

@ -0,0 +1,70 @@
import sqlalchemy as sa
from szurubooru.model.base import Base
class PoolName(Base):
__tablename__ = 'pool_name'
pool_name_id = sa.Column('pool_name_id', sa.Integer, primary_key=True)
pool_id = sa.Column(
'pool_id',
sa.Integer,
sa.ForeignKey('pool.id'),
nullable=False,
index=True)
name = sa.Column('name', sa.Unicode(128), nullable=False, unique=True)
order = sa.Column('ord', sa.Integer, nullable=False, index=True)
def __init__(self, name: str, order: int) -> None:
self.name = name
self.order = order
class Pool(Base):
__tablename__ = 'pool'
pool_id = sa.Column('id', sa.Integer, primary_key=True)
category_id = sa.Column(
'category_id',
sa.Integer,
sa.ForeignKey('pool_category.id'),
nullable=False,
index=True)
version = sa.Column('version', sa.Integer, default=1, nullable=False)
creation_time = sa.Column('creation_time', sa.DateTime, nullable=False)
last_edit_time = sa.Column('last_edit_time', sa.DateTime)
description = sa.Column('description', sa.UnicodeText, default=None)
category = sa.orm.relationship('PoolCategory', lazy='joined')
names = sa.orm.relationship(
'PoolName',
cascade='all,delete-orphan',
lazy='joined',
order_by='PoolName.order')
# post_count = sa.orm.column_property(
# sa.sql.expression.select(
# [sa.sql.expression.func.count(PostPool.post_id)])
# .where(PostPool.pool_id == pool_id)
# .correlate_except(PostPool))
# TODO
from random import randint
post_count = sa.orm.column_property(
sa.sql.expression.select([randint(1, 1000)])
.limit(1)
.as_scalar())
first_name = sa.orm.column_property(
(
sa.sql.expression.select([PoolName.name])
.where(PoolName.pool_id == pool_id)
.order_by(PoolName.order)
.limit(1)
.as_scalar()
),
deferred=True)
__mapper_args__ = {
'version_id_col': version,
'version_id_generator': False,
}

View file

@ -0,0 +1,28 @@
from typing import Optional
import sqlalchemy as sa
from szurubooru.model.base import Base
from szurubooru.model.pool import Pool
class PoolCategory(Base):
__tablename__ = 'pool_category'
pool_category_id = sa.Column('id', sa.Integer, primary_key=True)
version = sa.Column('version', sa.Integer, default=1, nullable=False)
name = sa.Column('name', sa.Unicode(32), nullable=False)
color = sa.Column(
'color', sa.Unicode(32), nullable=False, default='#000000')
default = sa.Column('default', sa.Boolean, nullable=False, default=False)
def __init__(self, name: Optional[str] = None) -> None:
self.name = name
pool_count = sa.orm.column_property(
sa.sql.expression.select([sa.sql.expression.func.count('Pool.pool_id')])
.where(Pool.category_id == pool_category_id)
.correlate_except(sa.table('Pool')))
__mapper_args__ = {
'version_id_col': version,
'version_id_generator': False,
}

View file

@ -10,6 +10,8 @@ def get_resource_info(entity: Base) -> Tuple[Any, Any, Union[str, int]]:
'tag_category': lambda category: category.name,
'comment': lambda comment: comment.comment_id,
'post': lambda post: post.post_id,
'pool': lambda pool: pool.pool_id,
'pool_category': lambda category: category.name,
} # type: Dict[str, Callable[[Base], Any]]
resource_type = entity.__table__.name

View file

@ -3,3 +3,4 @@ from .tag_search_config import TagSearchConfig
from .post_search_config import PostSearchConfig
from .snapshot_search_config import SnapshotSearchConfig
from .comment_search_config import CommentSearchConfig
from .pool_search_config import PoolSearchConfig

View file

@ -0,0 +1,109 @@
from typing import Tuple, Dict
import sqlalchemy as sa
from szurubooru import db, model
from szurubooru.func import util
from szurubooru.search.typing import SaColumn, SaQuery
from szurubooru.search.configs import util as search_util
from szurubooru.search.configs.base_search_config import (
BaseSearchConfig, Filter)
class PoolSearchConfig(BaseSearchConfig):
def create_filter_query(self, _disable_eager_loads: bool) -> SaQuery:
strategy = (
sa.orm.lazyload
if _disable_eager_loads
else sa.orm.subqueryload)
return (
db.session.query(model.Pool)
.join(model.PoolCategory)
.options(
strategy(model.Pool.names)))
def create_count_query(self, _disable_eager_loads: bool) -> SaQuery:
return db.session.query(model.Pool)
def create_around_query(self) -> SaQuery:
raise NotImplementedError()
def finalize_query(self, query: SaQuery) -> SaQuery:
return query.order_by(model.Pool.first_name.asc())
@property
def anonymous_filter(self) -> Filter:
return search_util.create_subquery_filter(
model.Pool.pool_id,
model.PoolName.pool_id,
model.PoolName.name,
search_util.create_str_filter)
@property
def named_filters(self) -> Dict[str, Filter]:
return util.unalias_dict([
(
['name'],
search_util.create_subquery_filter(
model.Pool.pool_id,
model.PoolName.pool_id,
model.PoolName.name,
search_util.create_str_filter)
),
(
['category'],
search_util.create_subquery_filter(
model.Pool.category_id,
model.PoolCategory.pool_category_id,
model.PoolCategory.name,
search_util.create_str_filter)
),
(
['creation-date', 'creation-time'],
search_util.create_date_filter(model.Pool.creation_time)
),
(
['last-edit-date', 'last-edit-time', 'edit-date', 'edit-time'],
search_util.create_date_filter(model.Pool.last_edit_time)
),
(
['post-count'],
search_util.create_num_filter(model.Pool.post_count)
),
])
@property
def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]:
return util.unalias_dict([
(
['random'],
(sa.sql.expression.func.random(), self.SORT_NONE)
),
(
['name'],
(model.Pool.first_name, self.SORT_ASC)
),
(
['category'],
(model.PoolCategory.name, self.SORT_ASC)
),
(
['creation-date', 'creation-time'],
(model.Pool.creation_time, self.SORT_DESC)
),
(
['last-edit-date', 'last-edit-time', 'edit-date', 'edit-time'],
(model.Pool.last_edit_time, self.SORT_DESC)
),
(
['post-count'],
(model.Pool.post_count, self.SORT_DESC)
),
])