client+server: add post pools feature
This commit is contained in:
commit
c5358f7f83
90 changed files with 5541 additions and 25 deletions
|
@ -20,6 +20,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
|
||||||
- Tag suggestions
|
- Tag suggestions
|
||||||
- Tag implications (adding a tag automatically adds another)
|
- Tag implications (adding a tag automatically adds another)
|
||||||
- Tag aliases
|
- Tag aliases
|
||||||
|
- Pools and pool categories
|
||||||
- Duplicate detection
|
- Duplicate detection
|
||||||
- Post rating and favoriting; comment rating
|
- Post rating and favoriting; comment rating
|
||||||
- Polished UI
|
- Polished UI
|
||||||
|
|
|
@ -35,7 +35,7 @@ const app_manifest = {
|
||||||
src: baseUrl() + 'img/android-chrome-192x192.png',
|
src: baseUrl() + 'img/android-chrome-192x192.png',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
sizes: '192x192'
|
sizes: '192x192'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: baseUrl() + 'img/android-chrome-512x512.png',
|
src: baseUrl() + 'img/android-chrome-512x512.png',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
|
@ -301,8 +301,12 @@ function makeOutputDirs() {
|
||||||
|
|
||||||
makeOutputDirs();
|
makeOutputDirs();
|
||||||
bundleConfig();
|
bundleConfig();
|
||||||
bundleBinaryAssets();
|
if (!process.argv.includes('--no-binary-assets')) {
|
||||||
bundleWebAppFiles();
|
bundleBinaryAssets();
|
||||||
|
}
|
||||||
|
if (!process.argv.includes('--no-web-app-files')) {
|
||||||
|
bundleWebAppFiles();
|
||||||
|
}
|
||||||
if (!process.argv.includes('--no-html')) {
|
if (!process.argv.includes('--no-html')) {
|
||||||
bundleHtml();
|
bundleHtml();
|
||||||
}
|
}
|
||||||
|
|
30
client/css/pool-categories-view.styl
Normal file
30
client/css/pool-categories-view.styl
Normal 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
|
||||||
|
|
53
client/css/pool-input-control.styl
Normal file
53
client/css/pool-input-control.styl
Normal 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
|
52
client/css/pool-list-view.styl
Normal file
52
client/css/pool-list-view.styl
Normal 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: 84%
|
||||||
|
.post-count
|
||||||
|
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)
|
||||||
|
.posts
|
||||||
|
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
33
client/css/pool-view.styl
Normal 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
|
|
@ -4,6 +4,7 @@
|
||||||
--><li data-name='posts'><a href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Posts</a></li><!--
|
--><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='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='tags'><a href='<%- ctx.formatClientLink('help', 'search', 'tags') %>'>Tags</a></li><!--
|
||||||
|
--><li data-name='pools'><a href='<%- ctx.formatClientLink('help', 'search', 'pools') %>'>Pools</li><!--
|
||||||
--></ul><!--
|
--></ul><!--
|
||||||
--></nav>
|
--></nav>
|
||||||
|
|
||||||
|
|
97
client/html/help_search_pools.tpl
Normal file
97
client/html/help_search_pools.tpl
Normal 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>
|
|
@ -20,7 +20,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>uploader</code></td>
|
<td><code>uploader</code></td>
|
||||||
<td>uploaded by given use (accepts wildcards)r</td>
|
<td>uploaded by given user (accepts wildcards)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>upload</code></td>
|
<td><code>upload</code></td>
|
||||||
|
@ -42,6 +42,10 @@
|
||||||
<td><code>source</code></td>
|
<td><code>source</code></td>
|
||||||
<td>having given source URL (accepts wildcards)</td>
|
<td>having given source URL (accepts wildcards)</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>pool</code></td>
|
||||||
|
<td>belonging to the pool with the given ID</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>tag-count</code></td>
|
<td><code>tag-count</code></td>
|
||||||
<td>having given number of tags</td>
|
<td>having given number of tags</td>
|
||||||
|
|
18
client/html/pool.tpl
Normal file
18
client/html/pool.tpl
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<div class='content-wrapper' id='pool'>
|
||||||
|
<h1><%- ctx.pool.names[0] %></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…</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>
|
30
client/html/pool_categories.tpl
Normal file
30
client/html/pool_categories.tpl
Normal 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>
|
43
client/html/pool_category_row.tpl
Normal file
43
client/html/pool_category_row.tpl
Normal 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>
|
42
client/html/pool_create.tpl
Normal file
42
client/html/pool_create.tpl
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<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>
|
||||||
|
<li class='posts'>
|
||||||
|
<%= ctx.makeTextInput({
|
||||||
|
text: 'Posts',
|
||||||
|
value: '',
|
||||||
|
placeholder: 'space-separated post IDs',
|
||||||
|
}) %>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<% if (ctx.canCreate) { %>
|
||||||
|
<div class='messages'></div>
|
||||||
|
|
||||||
|
<div class='buttons'>
|
||||||
|
<input type='submit' class='save' value='Create pool'>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</form>
|
||||||
|
</div>
|
21
client/html/pool_delete.tpl
Normal file
21
client/html/pool_delete.tpl
Normal 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>
|
50
client/html/pool_edit.tpl
Normal file
50
client/html/pool_edit.tpl
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
<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>
|
||||||
|
<li class='posts'>
|
||||||
|
<% if (ctx.canEditPosts) { %>
|
||||||
|
<%= ctx.makeTextInput({
|
||||||
|
text: 'Posts',
|
||||||
|
placeholder: 'space-separated post IDs',
|
||||||
|
value: ctx.pool.posts.map(post => post.id).join(' ')
|
||||||
|
}) %>
|
||||||
|
<% } %>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<% if (ctx.canEditAnything) { %>
|
||||||
|
<div class='messages'></div>
|
||||||
|
|
||||||
|
<div class='buttons'>
|
||||||
|
<input type='submit' class='save' value='Save changes'>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</form>
|
||||||
|
</div>
|
7
client/html/pool_input.tpl
Normal file
7
client/html/pool_input.tpl
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<div class='pool-input'>
|
||||||
|
<div class='main-control'>
|
||||||
|
<input type='text' placeholder='type to add…'/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class='compact-pools'></ul>
|
||||||
|
</div>
|
22
client/html/pool_merge.tpl
Normal file
22
client/html/pool_merge.tpl
Normal 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 in 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>
|
23
client/html/pool_summary.tpl
Normal file
23
client/html/pool_summary.tpl
Normal 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.id, false, false, ctx.pool, 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: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
22
client/html/pools_header.tpl
Normal file
22
client/html/pools_header.tpl
Normal 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>
|
48
client/html/pools_page.tpl
Normal file
48
client/html/pools_page.tpl
Normal 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.id, false, false, pool, name) %></li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td class='post-count'>
|
||||||
|
<a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + pool.id}) %>'><%- pool.postCount %></a>
|
||||||
|
</td>
|
||||||
|
<td class='creation-time'>
|
||||||
|
<%= ctx.makeRelativeTime(pool.creationTime) %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
|
@ -73,6 +73,12 @@
|
||||||
</section>
|
</section>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
<% if (ctx.canEditPoolPosts) { %>
|
||||||
|
<section class='pools'>
|
||||||
|
<%= ctx.makeTextInput({}) %>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<% if (ctx.canEditPostNotes) { %>
|
<% if (ctx.canEditPostNotes) { %>
|
||||||
<section class='notes'>
|
<section class='notes'>
|
||||||
<a href class='add'>Add a note</a>
|
<a href class='add'>Add a note</a>
|
||||||
|
|
|
@ -3,35 +3,35 @@
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<th class='names'>
|
<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>
|
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:name'}) %>'>Tag name(s)</a>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:name'}) %>'>Tag name(s)</a>
|
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:name'}) %>'>Tag name(s)</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
</th>
|
</th>
|
||||||
<th class='implications'>
|
<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>
|
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:implication-count'}) %>'>Implications</a>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:implication-count'}) %>'>Implications</a>
|
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:implication-count'}) %>'>Implications</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
</th>
|
</th>
|
||||||
<th class='suggestions'>
|
<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>
|
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:suggestion-count'}) %>'>Suggestions</a>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:suggestion-count'}) %>'>Suggestions</a>
|
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:suggestion-count'}) %>'>Suggestions</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
</th>
|
</th>
|
||||||
<th class='usages'>
|
<th class='usages'>
|
||||||
<% if (ctx.query == 'sort:usages') { %>
|
<% if (ctx.parameters.query == 'sort:usages') { %>
|
||||||
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:usages'}) %>'>Usages</a>
|
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:usages'}) %>'>Usages</a>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:usages'}) %>'>Usages</a>
|
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:usages'}) %>'>Usages</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
</th>
|
</th>
|
||||||
<th class='creation-time'>
|
<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>
|
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:creation-time'}) %>'>Created on</a>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:creation-time'}) %>'>Created on</a>
|
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:creation-time'}) %>'>Created on</a>
|
||||||
|
|
|
@ -84,6 +84,10 @@ class Api extends events.EventTarget {
|
||||||
return remoteConfig.tagNameRegex;
|
return remoteConfig.tagNameRegex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPoolNameRegex() {
|
||||||
|
return remoteConfig.poolNameRegex;
|
||||||
|
}
|
||||||
|
|
||||||
getPasswordRegex() {
|
getPasswordRegex() {
|
||||||
return remoteConfig.passwordRegex;
|
return remoteConfig.passwordRegex;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
const router = require('../router.js');
|
const router = require('../router.js');
|
||||||
const api = require('../api.js');
|
const api = require('../api.js');
|
||||||
const tags = require('../tags.js');
|
const tags = require('../tags.js');
|
||||||
|
const pools = require('../pools.js');
|
||||||
const uri = require('../util/uri.js');
|
const uri = require('../util/uri.js');
|
||||||
const topNavigation = require('../models/top_navigation.js');
|
const topNavigation = require('../models/top_navigation.js');
|
||||||
const LoginView = require('../views/login_view.js');
|
const LoginView = require('../views/login_view.js');
|
||||||
|
@ -27,6 +28,7 @@ class LoginController {
|
||||||
ctx.controller.showSuccess('Logged in');
|
ctx.controller.showSuccess('Logged in');
|
||||||
// reload tag category color map, this is required when `tag_categories:list` has a permission other than anonymous
|
// reload tag category color map, this is required when `tag_categories:list` has a permission other than anonymous
|
||||||
tags.refreshCategoryColorMap();
|
tags.refreshCategoryColorMap();
|
||||||
|
pools.refreshCategoryColorMap();
|
||||||
}, error => {
|
}, error => {
|
||||||
this._loginView.showError(error.message);
|
this._loginView.showError(error.message);
|
||||||
this._loginView.enableForm();
|
this._loginView.enableForm();
|
||||||
|
|
57
client/js/controllers/pool_categories_controller.js
Normal file
57
client/js/controllers/pool_categories_controller.js
Normal 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);
|
||||||
|
});
|
||||||
|
};
|
147
client/js/controllers/pool_controller.js
Normal file
147
client/js/controllers/pool_controller.js
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
'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 Post = require('../models/post.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'),
|
||||||
|
canEditPosts: api.hasPrivilege('pools:edit:posts'),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (e.detail.posts !== undefined) {
|
||||||
|
e.detail.pool.posts.clear();
|
||||||
|
for (let postId of e.detail.posts) {
|
||||||
|
e.detail.pool.posts.add(Post.fromResponse({id: parseInt(postId)}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.targetPoolId, e.detail.addAlias)
|
||||||
|
.then(() => {
|
||||||
|
this._view.showSuccess('Pool merged.');
|
||||||
|
this._view.enableForm();
|
||||||
|
router.replace(
|
||||||
|
uri.formatClientLink(
|
||||||
|
'pool', e.detail.targetPoolId, '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');
|
||||||
|
});
|
||||||
|
};
|
58
client/js/controllers/pool_create_controller.js
Normal file
58
client/js/controllers/pool_create_controller.js
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
'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();
|
||||||
|
api.post(uri.formatApiLink('pool'), e.detail)
|
||||||
|
.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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router => {
|
||||||
|
router.enter(['pool', 'create'], (ctx, next) => {
|
||||||
|
ctx.controller = new PoolCreateController(ctx, 'create');
|
||||||
|
});
|
||||||
|
};
|
108
client/js/controllers/pool_list_controller.js
Normal file
108
client/js/controllers/pool_list_controller.js
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
'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',
|
||||||
|
'posts',
|
||||||
|
'creationTime',
|
||||||
|
'postCount',
|
||||||
|
'category'
|
||||||
|
];
|
||||||
|
|
||||||
|
class PoolListController {
|
||||||
|
constructor(ctx) {
|
||||||
|
this._pageController = new PageController();
|
||||||
|
|
||||||
|
if (!api.hasPrivilege('pools:list')) {
|
||||||
|
this._view = new EmptyView();
|
||||||
|
this._view.showError('You don\'t have privileges to view pools.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ctx = ctx;
|
||||||
|
|
||||||
|
topNavigation.activate('pools');
|
||||||
|
topNavigation.setTitle('Listing pools');
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
};
|
|
@ -18,18 +18,19 @@ const fields = [
|
||||||
|
|
||||||
class PostListController {
|
class PostListController {
|
||||||
constructor(ctx) {
|
constructor(ctx) {
|
||||||
|
this._pageController = new PageController();
|
||||||
|
|
||||||
if (!api.hasPrivilege('posts:list')) {
|
if (!api.hasPrivilege('posts:list')) {
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
this._view.showError('You don\'t have privileges to view posts.');
|
this._view.showError('You don\'t have privileges to view posts.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._ctx = ctx;
|
||||||
|
|
||||||
topNavigation.activate('posts');
|
topNavigation.activate('posts');
|
||||||
topNavigation.setTitle('Listing posts');
|
topNavigation.setTitle('Listing posts');
|
||||||
|
|
||||||
this._ctx = ctx;
|
|
||||||
this._pageController = new PageController();
|
|
||||||
|
|
||||||
this._headerView = new PostsHeaderView({
|
this._headerView = new PostsHeaderView({
|
||||||
hostNode: this._pageController.view.pageHeaderHolderNode,
|
hostNode: this._pageController.view.pageHeaderHolderNode,
|
||||||
parameters: ctx.parameters,
|
parameters: ctx.parameters,
|
||||||
|
|
|
@ -21,18 +21,19 @@ const fields = [
|
||||||
|
|
||||||
class TagListController {
|
class TagListController {
|
||||||
constructor(ctx) {
|
constructor(ctx) {
|
||||||
|
this._pageController = new PageController();
|
||||||
|
|
||||||
if (!api.hasPrivilege('tags:list')) {
|
if (!api.hasPrivilege('tags:list')) {
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
this._view.showError('You don\'t have privileges to view tags.');
|
this._view.showError('You don\'t have privileges to view tags.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._ctx = ctx;
|
||||||
|
|
||||||
topNavigation.activate('tags');
|
topNavigation.activate('tags');
|
||||||
topNavigation.setTitle('Listing tags');
|
topNavigation.setTitle('Listing tags');
|
||||||
|
|
||||||
this._ctx = ctx;
|
|
||||||
this._pageController = new PageController();
|
|
||||||
|
|
||||||
this._headerView = new TagsHeaderView({
|
this._headerView = new TagsHeaderView({
|
||||||
hostNode: this._pageController.view.pageHeaderHolderNode,
|
hostNode: this._pageController.view.pageHeaderHolderNode,
|
||||||
parameters: ctx.parameters,
|
parameters: ctx.parameters,
|
||||||
|
|
|
@ -12,6 +12,8 @@ const EmptyView = require('../views/empty_view.js');
|
||||||
|
|
||||||
class UserListController {
|
class UserListController {
|
||||||
constructor(ctx) {
|
constructor(ctx) {
|
||||||
|
this._pageController = new PageController();
|
||||||
|
|
||||||
if (!api.hasPrivilege('users:list')) {
|
if (!api.hasPrivilege('users:list')) {
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
this._view.showError('You don\'t have privileges to view users.');
|
this._view.showError('You don\'t have privileges to view users.');
|
||||||
|
@ -22,7 +24,6 @@ class UserListController {
|
||||||
topNavigation.setTitle('Listing users');
|
topNavigation.setTitle('Listing users');
|
||||||
|
|
||||||
this._ctx = ctx;
|
this._ctx = ctx;
|
||||||
this._pageController = new PageController();
|
|
||||||
|
|
||||||
this._headerView = new UsersHeaderView({
|
this._headerView = new UsersHeaderView({
|
||||||
hostNode: this._pageController.view.pageHeaderHolderNode,
|
hostNode: this._pageController.view.pageHeaderHolderNode,
|
||||||
|
|
48
client/js/controls/pool_auto_complete_control.js
Normal file
48
client/js/controls/pool_auto_complete_control.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
'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');
|
||||||
|
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.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', 'version'])
|
||||||
|
.then(
|
||||||
|
response => resolve(
|
||||||
|
_poolListToMatches(response.results, this._options)),
|
||||||
|
reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
super(input, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PoolAutoCompleteControl;
|
185
client/js/controls/pool_input_control.js
Normal file
185
client/js/controls/pool_input_control.js
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const api = require('../api.js');
|
||||||
|
const pools = require('../pools.js');
|
||||||
|
const misc = require('../util/misc.js');
|
||||||
|
const uri = require('../util/uri.js');
|
||||||
|
const Pool = require('../models/pool.js');
|
||||||
|
const settings = require('../models/settings.js');
|
||||||
|
const events = require('../events.js');
|
||||||
|
const views = require('../util/views.js');
|
||||||
|
const PoolAutoCompleteControl = require('./pool_auto_complete_control.js');
|
||||||
|
|
||||||
|
const KEY_SPACE = 32;
|
||||||
|
const KEY_RETURN = 13;
|
||||||
|
|
||||||
|
const SOURCE_INIT = 'init';
|
||||||
|
const SOURCE_IMPLICATION = 'implication';
|
||||||
|
const SOURCE_USER_INPUT = 'user-input';
|
||||||
|
const SOURCE_CLIPBOARD = 'clipboard';
|
||||||
|
|
||||||
|
const template = views.getTemplate('pool-input');
|
||||||
|
|
||||||
|
function _fadeOutListItemNodeStatus(listItemNode) {
|
||||||
|
if (listItemNode.classList.length) {
|
||||||
|
if (listItemNode.fadeTimeout) {
|
||||||
|
window.clearTimeout(listItemNode.fadeTimeout);
|
||||||
|
}
|
||||||
|
listItemNode.fadeTimeout = window.setTimeout(() => {
|
||||||
|
while (listItemNode.classList.length) {
|
||||||
|
listItemNode.classList.remove(
|
||||||
|
listItemNode.classList.item(0));
|
||||||
|
}
|
||||||
|
listItemNode.fadeTimeout = null;
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PoolInputControl extends events.EventTarget {
|
||||||
|
constructor(hostNode, poolList) {
|
||||||
|
super();
|
||||||
|
this.pools = poolList;
|
||||||
|
this._hostNode = hostNode;
|
||||||
|
this._poolToListItemNode = new Map();
|
||||||
|
|
||||||
|
// dom
|
||||||
|
const editAreaNode = template();
|
||||||
|
this._editAreaNode = editAreaNode;
|
||||||
|
this._poolInputNode = editAreaNode.querySelector('input');
|
||||||
|
this._poolListNode = editAreaNode.querySelector('ul.compact-pools');
|
||||||
|
|
||||||
|
this._autoCompleteControl = new PoolAutoCompleteControl(
|
||||||
|
this._poolInputNode, {
|
||||||
|
getTextToFind: () => {
|
||||||
|
return this._poolInputNode.value;
|
||||||
|
},
|
||||||
|
confirm: pool => {
|
||||||
|
this._poolInputNode.value = '';
|
||||||
|
this.addPool(pool, SOURCE_USER_INPUT);
|
||||||
|
},
|
||||||
|
delete: pool => {
|
||||||
|
this._poolInputNode.value = '';
|
||||||
|
this.deletePool(pool);
|
||||||
|
},
|
||||||
|
verticalShift: -2
|
||||||
|
});
|
||||||
|
|
||||||
|
// show
|
||||||
|
this._hostNode.style.display = 'none';
|
||||||
|
this._hostNode.parentNode.insertBefore(
|
||||||
|
this._editAreaNode, hostNode.nextSibling);
|
||||||
|
|
||||||
|
// add existing pools
|
||||||
|
for (let pool of [...this.pools]) {
|
||||||
|
const listItemNode = this._createListItemNode(pool);
|
||||||
|
this._poolListNode.appendChild(listItemNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addPool(pool, source) {
|
||||||
|
if (source !== SOURCE_INIT && this.pools.hasPoolId(pool.id)) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pools.add(pool, false)
|
||||||
|
|
||||||
|
const listItemNode = this._createListItemNode(pool);
|
||||||
|
if (!pool.category) {
|
||||||
|
listItemNode.classList.add('new');
|
||||||
|
}
|
||||||
|
this._poolListNode.prependChild(listItemNode);
|
||||||
|
_fadeOutListItemNodeStatus(listItemNode);
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('add', {
|
||||||
|
detail: {pool: pool, source: source},
|
||||||
|
}));
|
||||||
|
this.dispatchEvent(new CustomEvent('change'));
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePool(pool) {
|
||||||
|
if (!this.pools.hasPoolId(pool.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pools.removeById(pool.id);
|
||||||
|
this._hideAutoComplete();
|
||||||
|
|
||||||
|
this._deleteListItemNode(pool);
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('remove', {
|
||||||
|
detail: {pool: pool},
|
||||||
|
}));
|
||||||
|
this.dispatchEvent(new CustomEvent('change'));
|
||||||
|
}
|
||||||
|
|
||||||
|
_createListItemNode(pool) {
|
||||||
|
const className = pool.category ?
|
||||||
|
misc.makeCssName(pool.category, 'pool') :
|
||||||
|
null;
|
||||||
|
|
||||||
|
const poolLinkNode = document.createElement('a');
|
||||||
|
if (className) {
|
||||||
|
poolLinkNode.classList.add(className);
|
||||||
|
}
|
||||||
|
poolLinkNode.setAttribute(
|
||||||
|
'href', uri.formatClientLink('pool', pool.names[0]));
|
||||||
|
|
||||||
|
const poolIconNode = document.createElement('i');
|
||||||
|
poolIconNode.classList.add('fa');
|
||||||
|
poolIconNode.classList.add('fa-pool');
|
||||||
|
poolLinkNode.appendChild(poolIconNode);
|
||||||
|
|
||||||
|
const searchLinkNode = document.createElement('a');
|
||||||
|
if (className) {
|
||||||
|
searchLinkNode.classList.add(className);
|
||||||
|
}
|
||||||
|
searchLinkNode.setAttribute(
|
||||||
|
'href', uri.formatClientLink(
|
||||||
|
'posts', {query: "pool:" + pool.id}));
|
||||||
|
searchLinkNode.textContent = pool.names[0] + ' ';
|
||||||
|
|
||||||
|
const usagesNode = document.createElement('span');
|
||||||
|
usagesNode.classList.add('pool-usages');
|
||||||
|
usagesNode.setAttribute('data-pseudo-content', pool.postCount);
|
||||||
|
|
||||||
|
const removalLinkNode = document.createElement('a');
|
||||||
|
removalLinkNode.classList.add('remove-pool');
|
||||||
|
removalLinkNode.setAttribute('href', '');
|
||||||
|
removalLinkNode.setAttribute('data-pseudo-content', '×');
|
||||||
|
removalLinkNode.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.deletePool(pool);
|
||||||
|
});
|
||||||
|
|
||||||
|
const listItemNode = document.createElement('li');
|
||||||
|
listItemNode.appendChild(removalLinkNode);
|
||||||
|
listItemNode.appendChild(poolLinkNode);
|
||||||
|
listItemNode.appendChild(searchLinkNode);
|
||||||
|
listItemNode.appendChild(usagesNode);
|
||||||
|
for (let name of pool.names) {
|
||||||
|
this._poolToListItemNode.set(name, listItemNode);
|
||||||
|
}
|
||||||
|
return listItemNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
_deleteListItemNode(pool) {
|
||||||
|
const listItemNode = this._getListItemNode(pool);
|
||||||
|
if (listItemNode) {
|
||||||
|
listItemNode.parentNode.removeChild(listItemNode);
|
||||||
|
}
|
||||||
|
for (let name of pool.names) {
|
||||||
|
this._poolToListItemNode.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getListItemNode(pool) {
|
||||||
|
return this._poolToListItemNode.get(pool.names[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_hideAutoComplete() {
|
||||||
|
this._autoCompleteControl.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PoolInputControl;
|
|
@ -7,6 +7,7 @@ const views = require('../util/views.js');
|
||||||
const Note = require('../models/note.js');
|
const Note = require('../models/note.js');
|
||||||
const Point = require('../models/point.js');
|
const Point = require('../models/point.js');
|
||||||
const TagInputControl = require('./tag_input_control.js');
|
const TagInputControl = require('./tag_input_control.js');
|
||||||
|
const PoolInputControl = require('./pool_input_control.js');
|
||||||
const ExpanderControl = require('../controls/expander_control.js');
|
const ExpanderControl = require('../controls/expander_control.js');
|
||||||
const FileDropperControl = require('../controls/file_dropper_control.js');
|
const FileDropperControl = require('../controls/file_dropper_control.js');
|
||||||
|
|
||||||
|
@ -37,6 +38,7 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||||
canEditPostFlags: api.hasPrivilege('posts:edit:flags'),
|
canEditPostFlags: api.hasPrivilege('posts:edit:flags'),
|
||||||
canEditPostContent: api.hasPrivilege('posts:edit:content'),
|
canEditPostContent: api.hasPrivilege('posts:edit:content'),
|
||||||
canEditPostThumbnail: api.hasPrivilege('posts:edit:thumbnail'),
|
canEditPostThumbnail: api.hasPrivilege('posts:edit:thumbnail'),
|
||||||
|
canEditPoolPosts: api.hasPrivilege('pools:edit:posts'),
|
||||||
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
|
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
|
||||||
canDeletePosts: api.hasPrivilege('posts:delete'),
|
canDeletePosts: api.hasPrivilege('posts:delete'),
|
||||||
canFeaturePosts: api.hasPrivilege('posts:feature'),
|
canFeaturePosts: api.hasPrivilege('posts:feature'),
|
||||||
|
@ -55,6 +57,10 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||||
'post-notes',
|
'post-notes',
|
||||||
'Notes',
|
'Notes',
|
||||||
this._hostNode.querySelectorAll('.notes'));
|
this._hostNode.querySelectorAll('.notes'));
|
||||||
|
this._poolsExpander = new ExpanderControl(
|
||||||
|
'post-pools',
|
||||||
|
`Pools (${this._post.pools.length})`,
|
||||||
|
this._hostNode.querySelectorAll('.pools'));
|
||||||
new ExpanderControl(
|
new ExpanderControl(
|
||||||
'post-content',
|
'post-content',
|
||||||
'Content',
|
'Content',
|
||||||
|
@ -75,6 +81,11 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||||
this._tagInputNode, post.tags);
|
this._tagInputNode, post.tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._poolInputNode) {
|
||||||
|
this._poolControl = new PoolInputControl(
|
||||||
|
this._poolInputNode, post.pools);
|
||||||
|
}
|
||||||
|
|
||||||
if (this._contentInputNode) {
|
if (this._contentInputNode) {
|
||||||
this._contentFileDropper = new FileDropperControl(
|
this._contentFileDropper = new FileDropperControl(
|
||||||
this._contentInputNode, {allowUrls: true,
|
this._contentInputNode, {allowUrls: true,
|
||||||
|
@ -168,6 +179,9 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||||
this._post.notes.addEventListener(eventType, e => {
|
this._post.notes.addEventListener(eventType, e => {
|
||||||
this._syncExpanderTitles();
|
this._syncExpanderTitles();
|
||||||
});
|
});
|
||||||
|
this._post.pools.addEventListener(eventType, e => {
|
||||||
|
this._syncExpanderTitles();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this._tagControl.addEventListener(
|
this._tagControl.addEventListener(
|
||||||
|
@ -180,11 +194,18 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||||
this._noteTextareaNode.addEventListener(
|
this._noteTextareaNode.addEventListener(
|
||||||
'change', e => this._evtNoteTextChangeRequest(e));
|
'change', e => this._evtNoteTextChangeRequest(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._poolControl.addEventListener(
|
||||||
|
'change', e => {
|
||||||
|
this.dispatchEvent(new CustomEvent('change'));
|
||||||
|
this._syncExpanderTitles();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_syncExpanderTitles() {
|
_syncExpanderTitles() {
|
||||||
this._notesExpander.title = `Notes (${this._post.notes.length})`;
|
this._notesExpander.title = `Notes (${this._post.notes.length})`;
|
||||||
this._tagsExpander.title = `Tags (${this._post.tags.length})`;
|
this._tagsExpander.title = `Tags (${this._post.tags.length})`;
|
||||||
|
this._poolsExpander.title = `Pools (${this._post.pools.length})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtPostContentChange(e) {
|
_evtPostContentChange(e) {
|
||||||
|
@ -337,6 +358,10 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||||
misc.splitByWhitespace(this._tagInputNode.value) :
|
misc.splitByWhitespace(this._tagInputNode.value) :
|
||||||
undefined,
|
undefined,
|
||||||
|
|
||||||
|
pools: this._poolInputNode ?
|
||||||
|
misc.splitByWhitespace(this._poolInputNode.value) :
|
||||||
|
undefined,
|
||||||
|
|
||||||
relations: this._relationsInputNode ?
|
relations: this._relationsInputNode ?
|
||||||
misc.splitByWhitespace(this._relationsInputNode.value)
|
misc.splitByWhitespace(this._relationsInputNode.value)
|
||||||
.map(x => parseInt(x)) :
|
.map(x => parseInt(x)) :
|
||||||
|
@ -373,6 +398,10 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||||
return this._formNode.querySelector('.tags input');
|
return this._formNode.querySelector('.tags input');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get _poolInputNode() {
|
||||||
|
return this._formNode.querySelector('.pools input');
|
||||||
|
}
|
||||||
|
|
||||||
get _loopVideoInputNode() {
|
get _loopVideoInputNode() {
|
||||||
return this._formNode.querySelector('.flags input[name=loop]');
|
return this._formNode.querySelector('.flags input[name=loop]');
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,7 @@ router.enter(
|
||||||
});
|
});
|
||||||
|
|
||||||
const tags = require('./tags.js');
|
const tags = require('./tags.js');
|
||||||
|
const pools = require('./pools.js');
|
||||||
const api = require('./api.js');
|
const api = require('./api.js');
|
||||||
|
|
||||||
api.fetchConfig().then(() => {
|
api.fetchConfig().then(() => {
|
||||||
|
@ -45,6 +46,10 @@ api.fetchConfig().then(() => {
|
||||||
controllers.push(require('./controllers/tag_controller.js'));
|
controllers.push(require('./controllers/tag_controller.js'));
|
||||||
controllers.push(require('./controllers/tag_list_controller.js'));
|
controllers.push(require('./controllers/tag_list_controller.js'));
|
||||||
controllers.push(require('./controllers/tag_categories_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/settings_controller.js'));
|
||||||
controllers.push(require('./controllers/user_controller.js'));
|
controllers.push(require('./controllers/user_controller.js'));
|
||||||
controllers.push(require('./controllers/user_list_controller.js'));
|
controllers.push(require('./controllers/user_list_controller.js'));
|
||||||
|
@ -61,6 +66,7 @@ api.fetchConfig().then(() => {
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
api.loginFromCookies().then(() => {
|
api.loginFromCookies().then(() => {
|
||||||
tags.refreshCategoryColorMap();
|
tags.refreshCategoryColorMap();
|
||||||
|
pools.refreshCategoryColorMap();
|
||||||
router.start();
|
router.start();
|
||||||
}, error => {
|
}, error => {
|
||||||
if (window.location.href.indexOf('login') !== -1) {
|
if (window.location.href.indexOf('login') !== -1) {
|
||||||
|
|
|
@ -86,6 +86,10 @@ class AbstractList extends events.EventTarget {
|
||||||
return this._list.map(...args);
|
return this._list.map(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filter(...args) {
|
||||||
|
return this._list.filter(...args);
|
||||||
|
}
|
||||||
|
|
||||||
[Symbol.iterator]() {
|
[Symbol.iterator]() {
|
||||||
return this._list[Symbol.iterator]();
|
return this._list[Symbol.iterator]();
|
||||||
}
|
}
|
||||||
|
|
175
client/js/models/pool.js
Normal file
175
client/js/models/pool.js
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
'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 PostList = require('./post_list.js');
|
||||||
|
|
||||||
|
super();
|
||||||
|
this._orig = {};
|
||||||
|
|
||||||
|
for (let obj of [this, this._orig]) {
|
||||||
|
obj._posts = new PostList();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._updateFromResponse({});
|
||||||
|
}
|
||||||
|
|
||||||
|
get id() {
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get names() {
|
||||||
|
return this._names;
|
||||||
|
}
|
||||||
|
|
||||||
|
get category() {
|
||||||
|
return this._category;
|
||||||
|
}
|
||||||
|
|
||||||
|
get description() {
|
||||||
|
return this._description;
|
||||||
|
}
|
||||||
|
|
||||||
|
get posts() {
|
||||||
|
return this._posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (misc.arraysDiffer(this._posts, this._orig._posts)) {
|
||||||
|
detail.posts = this._posts.map(post => post.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.postCount || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let obj of [this, this._orig]) {
|
||||||
|
obj._posts.sync(response.posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(this, map);
|
||||||
|
Object.assign(this._orig, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Pool;
|
109
client/js/models/pool_category.js
Normal file
109
client/js/models/pool_category.js
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
'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;
|
82
client/js/models/pool_category_list.js
Normal file
82
client/js/models/pool_category_list.js
Normal 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;
|
47
client/js/models/pool_list.js
Normal file
47
client/js/models/pool_list.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
'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)}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPoolId(poolId) {
|
||||||
|
for (let pool of this._list) {
|
||||||
|
if (pool.id === poolId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeById(poolId) {
|
||||||
|
for (let pool of this._list) {
|
||||||
|
if (pool.id === poolId) {
|
||||||
|
this.remove(pool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PoolList._itemClass = Pool;
|
||||||
|
PoolList._itemName = 'pool';
|
||||||
|
|
||||||
|
module.exports = PoolList;
|
|
@ -7,6 +7,8 @@ const events = require('../events.js');
|
||||||
const TagList = require('./tag_list.js');
|
const TagList = require('./tag_list.js');
|
||||||
const NoteList = require('./note_list.js');
|
const NoteList = require('./note_list.js');
|
||||||
const CommentList = require('./comment_list.js');
|
const CommentList = require('./comment_list.js');
|
||||||
|
const PoolList = require('./pool_list.js');
|
||||||
|
const Pool = require('./pool.js');
|
||||||
const misc = require('../util/misc.js');
|
const misc = require('../util/misc.js');
|
||||||
|
|
||||||
class Post extends events.EventTarget {
|
class Post extends events.EventTarget {
|
||||||
|
@ -18,6 +20,7 @@ class Post extends events.EventTarget {
|
||||||
obj._tags = new TagList();
|
obj._tags = new TagList();
|
||||||
obj._notes = new NoteList();
|
obj._notes = new NoteList();
|
||||||
obj._comments = new CommentList();
|
obj._comments = new CommentList();
|
||||||
|
obj._pools = new PoolList();
|
||||||
}
|
}
|
||||||
|
|
||||||
this._updateFromResponse({});
|
this._updateFromResponse({});
|
||||||
|
@ -111,6 +114,10 @@ class Post extends events.EventTarget {
|
||||||
return this._relations;
|
return this._relations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get pools() {
|
||||||
|
return this._pools;
|
||||||
|
}
|
||||||
|
|
||||||
get score() {
|
get score() {
|
||||||
return this._score;
|
return this._score;
|
||||||
}
|
}
|
||||||
|
@ -191,6 +198,43 @@ class Post extends events.EventTarget {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_savePoolPosts() {
|
||||||
|
const difference = (a, b) => a.filter(post => !b.hasPoolId(post.id));
|
||||||
|
|
||||||
|
// find the pools where the post was added or removed
|
||||||
|
const added = difference(this.pools, this._orig._pools);
|
||||||
|
const removed = difference(this._orig._pools, this.pools);
|
||||||
|
|
||||||
|
let ops = [];
|
||||||
|
|
||||||
|
// update each pool's list of posts
|
||||||
|
for (let pool of added) {
|
||||||
|
let op = Pool.get(pool.id).then(response => {
|
||||||
|
if (!response.posts.hasPostId(this._id)) {
|
||||||
|
response.posts.addById(this._id);
|
||||||
|
return response.save();
|
||||||
|
} else {
|
||||||
|
return Promise.resolve(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ops.push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let pool of removed) {
|
||||||
|
let op = Pool.get(pool.id).then(response => {
|
||||||
|
if (response.posts.hasPostId(this._id)) {
|
||||||
|
response.posts.removeById(this._id);
|
||||||
|
return response.save();
|
||||||
|
} else {
|
||||||
|
return Promise.resolve(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ops.push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(ops);
|
||||||
|
}
|
||||||
|
|
||||||
save(anonymous) {
|
save(anonymous) {
|
||||||
const files = {};
|
const files = {};
|
||||||
const detail = {version: this._version};
|
const detail = {version: this._version};
|
||||||
|
@ -232,6 +276,12 @@ class Post extends events.EventTarget {
|
||||||
api.post(uri.formatApiLink('posts'), detail, files);
|
api.post(uri.formatApiLink('posts'), detail, files);
|
||||||
|
|
||||||
return apiPromise.then(response => {
|
return apiPromise.then(response => {
|
||||||
|
if (misc.arraysDiffer(this._pools, this._orig._pools)) {
|
||||||
|
return this._savePoolPosts()
|
||||||
|
.then(() => Promise.resolve(response));
|
||||||
|
}
|
||||||
|
return Promise.resolve(response);
|
||||||
|
}).then(response => {
|
||||||
this._updateFromResponse(response);
|
this._updateFromResponse(response);
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('change', {detail: {post: this}}));
|
new CustomEvent('change', {detail: {post: this}}));
|
||||||
|
@ -243,12 +293,13 @@ class Post extends events.EventTarget {
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('changeThumbnail', {detail: {post: this}}));
|
new CustomEvent('changeThumbnail', {detail: {post: this}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, error => {
|
}, error => {
|
||||||
if (error.response &&
|
if (error.response &&
|
||||||
error.response.name === 'PostAlreadyUploadedError') {
|
error.response.name === 'PostAlreadyUploadedError') {
|
||||||
error.message =
|
error.message =
|
||||||
`Post already uploaded (@${error.response.otherPostId})`;
|
`Post already uploaded (@${error.response.otherPostId})`;
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
});
|
});
|
||||||
|
@ -365,9 +416,9 @@ class Post extends events.EventTarget {
|
||||||
|
|
||||||
mutateContentUrl() {
|
mutateContentUrl() {
|
||||||
this._contentUrl =
|
this._contentUrl =
|
||||||
this._orig._contentUrl +
|
this._orig._contentUrl +
|
||||||
'?bypass-cache=' +
|
'?bypass-cache=' +
|
||||||
Math.round(Math.random() * 1000);
|
Math.round(Math.random() * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateFromResponse(response) {
|
_updateFromResponse(response) {
|
||||||
|
@ -402,6 +453,7 @@ class Post extends events.EventTarget {
|
||||||
obj._tags.sync(response.tags);
|
obj._tags.sync(response.tags);
|
||||||
obj._notes.sync(response.notes);
|
obj._notes.sync(response.notes);
|
||||||
obj._comments.sync(response.comments);
|
obj._comments.sync(response.comments);
|
||||||
|
obj._pools.sync(response.pools);
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(this, map());
|
Object.assign(this, map());
|
||||||
|
|
|
@ -49,6 +49,31 @@ class PostList extends AbstractList {
|
||||||
return text.trim();
|
return text.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasPostId(testId) {
|
||||||
|
for (let post of this._list) {
|
||||||
|
if (post.id === testId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
addById(id) {
|
||||||
|
if (this.hasPostId(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let post = Post.fromResponse({id: id});
|
||||||
|
this.add(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeById(testId) {
|
||||||
|
for (let post of this._list) {
|
||||||
|
if (post.id === testId) {
|
||||||
|
this.remove(post);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PostList._itemClass = Post;
|
PostList._itemClass = Post;
|
||||||
|
|
|
@ -81,6 +81,7 @@ function _makeTopNavigation() {
|
||||||
ret.add('upload', new TopNavigationItem('U', 'Upload', 'upload'));
|
ret.add('upload', new TopNavigationItem('U', 'Upload', 'upload'));
|
||||||
ret.add('comments', new TopNavigationItem('C', 'Comments', 'comments'));
|
ret.add('comments', new TopNavigationItem('C', 'Comments', 'comments'));
|
||||||
ret.add('tags', new TopNavigationItem('T', 'Tags', 'tags'));
|
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('users', new TopNavigationItem('S', 'Users', 'users'));
|
||||||
ret.add('account', new TopNavigationItem('A', 'Account', 'user/{me}'));
|
ret.add('account', new TopNavigationItem('A', 'Account', 'user/{me}'));
|
||||||
ret.add('register', new TopNavigationItem('R', 'Register', 'register'));
|
ret.add('register', new TopNavigationItem('R', 'Register', 'register'));
|
||||||
|
|
26
client/js/pools.js
Normal file
26
client/js/pools.js
Normal 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,
|
||||||
|
};
|
|
@ -215,6 +215,29 @@ function makeTagLink(name, includeHash, includeCount, tag) {
|
||||||
misc.escapeHtml(text));
|
misc.escapeHtml(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makePoolLink(id, includeHash, includeCount, pool, name) {
|
||||||
|
const category = pool ? pool.category : 'unknown';
|
||||||
|
let text = name ? name : pool.names[0];
|
||||||
|
if (includeHash === true) {
|
||||||
|
text = '#' + text;
|
||||||
|
}
|
||||||
|
if (includeCount === true) {
|
||||||
|
text += ' (' + (pool ? pool.postCount : 0) + ')';
|
||||||
|
}
|
||||||
|
return api.hasPrivilege('pools:view') ?
|
||||||
|
makeElement(
|
||||||
|
'a',
|
||||||
|
{
|
||||||
|
href: uri.formatClientLink('pool', id),
|
||||||
|
class: misc.makeCssName(category, 'pool'),
|
||||||
|
},
|
||||||
|
misc.escapeHtml(text)) :
|
||||||
|
makeElement(
|
||||||
|
'span',
|
||||||
|
{class: misc.makeCssName(category, 'pool')},
|
||||||
|
misc.escapeHtml(text));
|
||||||
|
}
|
||||||
|
|
||||||
function makeUserLink(user) {
|
function makeUserLink(user) {
|
||||||
let text = makeThumbnail(user ? user.avatarUrl : null);
|
let text = makeThumbnail(user ? user.avatarUrl : null);
|
||||||
text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous';
|
text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous';
|
||||||
|
@ -393,6 +416,7 @@ function getTemplate(templatePath) {
|
||||||
makeDateInput: makeDateInput,
|
makeDateInput: makeDateInput,
|
||||||
makePostLink: makePostLink,
|
makePostLink: makePostLink,
|
||||||
makeTagLink: makeTagLink,
|
makeTagLink: makeTagLink,
|
||||||
|
makePoolLink: makePoolLink,
|
||||||
makeUserLink: makeUserLink,
|
makeUserLink: makeUserLink,
|
||||||
makeFlexboxAlign: makeFlexboxAlign,
|
makeFlexboxAlign: makeFlexboxAlign,
|
||||||
makeAccessKey: makeAccessKey,
|
makeAccessKey: makeAccessKey,
|
||||||
|
@ -522,6 +546,7 @@ module.exports = {
|
||||||
decorateValidator: decorateValidator,
|
decorateValidator: decorateValidator,
|
||||||
makeTagLink: makeTagLink,
|
makeTagLink: makeTagLink,
|
||||||
makePostLink: makePostLink,
|
makePostLink: makePostLink,
|
||||||
|
makePoolLink: makePoolLink,
|
||||||
makeCheckbox: makeCheckbox,
|
makeCheckbox: makeCheckbox,
|
||||||
makeRadio: makeRadio,
|
makeRadio: makeRadio,
|
||||||
syncScrollPosition: syncScrollPosition,
|
syncScrollPosition: syncScrollPosition,
|
||||||
|
|
|
@ -17,6 +17,7 @@ const subsectionTemplates = {
|
||||||
'posts': views.getTemplate('help-search-posts'),
|
'posts': views.getTemplate('help-search-posts'),
|
||||||
'users': views.getTemplate('help-search-users'),
|
'users': views.getTemplate('help-search-users'),
|
||||||
'tags': views.getTemplate('help-search-tags'),
|
'tags': views.getTemplate('help-search-tags'),
|
||||||
|
'pools': views.getTemplate('help-search-pools'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
166
client/js/views/pool_categories_view.js
Normal file
166
client/js/views/pool_categories_view.js
Normal 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;
|
132
client/js/views/pool_create_view.js
Normal file
132
client/js/views/pool_create_view.js
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
'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));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._postsFieldNode) {
|
||||||
|
this._postsFieldNode.addEventListener(
|
||||||
|
'input', e => this._evtPostsInput(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let node of this._formNode.querySelectorAll(
|
||||||
|
'input, select, textarea, posts')) {
|
||||||
|
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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtPostsInput(e) {
|
||||||
|
const regex = /^\d+$/;
|
||||||
|
const list = misc.splitByWhitespace(this._postsFieldNode.value);
|
||||||
|
|
||||||
|
for (let item of list) {
|
||||||
|
if (!regex.test(item)) {
|
||||||
|
this._postsFieldNode.setCustomValidity(
|
||||||
|
`Pool ID "${item}" is not an integer.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._postsFieldNode.setCustomValidity('');
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('submit', {
|
||||||
|
detail: {
|
||||||
|
names: misc.splitByWhitespace(this._namesFieldNode.value),
|
||||||
|
category: this._categoryFieldNode.value,
|
||||||
|
description: this._descriptionFieldNode.value,
|
||||||
|
posts: misc.splitByWhitespace(this._postsFieldNode.value)
|
||||||
|
.map(i => parseInt(i))
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _postsFieldNode() {
|
||||||
|
return this._formNode.querySelector('.posts input');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PoolCreateView;
|
53
client/js/views/pool_delete_view.js
Normal file
53
client/js/views/pool_delete_view.js
Normal 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;
|
144
client/js/views/pool_edit_view.js
Normal file
144
client/js/views/pool_edit_view.js
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
'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 Post = require('../models/post.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));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._postsFieldNode) {
|
||||||
|
this._postsFieldNode.addEventListener(
|
||||||
|
'input', e => this._evtPostsInput(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let node of this._formNode.querySelectorAll(
|
||||||
|
'input, select, textarea, posts')) {
|
||||||
|
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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtPostsInput(e) {
|
||||||
|
const regex = /^\d+$/;
|
||||||
|
const list = misc.splitByWhitespace(this._postsFieldNode.value);
|
||||||
|
|
||||||
|
for (let item of list) {
|
||||||
|
if (!regex.test(item)) {
|
||||||
|
this._postsFieldNode.setCustomValidity(
|
||||||
|
`Pool ID "${item}" is not an integer.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._postsFieldNode.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,
|
||||||
|
|
||||||
|
posts: this._postsFieldNode ?
|
||||||
|
misc.splitByWhitespace(this._postsFieldNode.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');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _postsFieldNode() {
|
||||||
|
return this._formNode.querySelector('.posts input');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PoolEditView;
|
80
client/js/views/pool_merge_view.js
Normal file
80
client/js/views/pool_merge_view.js
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
'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;
|
||||||
|
this._targetPoolId = null;
|
||||||
|
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._targetPoolId = pool.id;
|
||||||
|
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,
|
||||||
|
targetPoolId: this._targetPoolId
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
23
client/js/views/pool_summary_view.js
Normal file
23
client/js/views/pool_summary_view.js
Normal 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;
|
106
client/js/views/pool_view.js
Normal file
106
client/js/views/pool_view.js
Normal 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;
|
51
client/js/views/pools_header_view.js
Normal file
51
client/js/views/pools_header_view.js
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
'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;
|
13
client/js/views/pools_page_view.js
Normal file
13
client/js/views/pools_page_view.js
Normal 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;
|
|
@ -36,6 +36,8 @@ function _makeResourceLink(type, id) {
|
||||||
return views.makeTagLink(id, true);
|
return views.makeTagLink(id, true);
|
||||||
} else if (type === 'tag_category') {
|
} else if (type === 'tag_category') {
|
||||||
return 'category "' + id + '"';
|
return 'category "' + id + '"';
|
||||||
|
} else if (type === 'pool') {
|
||||||
|
return views.makePoolLink(id, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,6 +115,19 @@ function _makeItemModification(type, data) {
|
||||||
if (diff.flags) {
|
if (diff.flags) {
|
||||||
_extend(lines, ['Changed flags']);
|
_extend(lines, ['Changed flags']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} else if (type === 'pool') {
|
||||||
|
if (diff.names) {
|
||||||
|
_extend(lines, _formatBasicChange(diff.names, 'names'));
|
||||||
|
}
|
||||||
|
if (diff.category) {
|
||||||
|
_extend(
|
||||||
|
lines, _formatBasicChange(diff.category, 'category'));
|
||||||
|
}
|
||||||
|
if (diff.posts) {
|
||||||
|
_extend(
|
||||||
|
lines, _formatBasicChange(diff.posts, 'posts'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join('<br/>');
|
return lines.join('<br/>');
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.js",
|
"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": {
|
"dependencies": {
|
||||||
"font-awesome": "^4.7.0",
|
"font-awesome": "^4.7.0",
|
||||||
|
|
457
doc/API.md
457
doc/API.md
|
@ -44,6 +44,20 @@
|
||||||
- [Getting featured post](#getting-featured-post)
|
- [Getting featured post](#getting-featured-post)
|
||||||
- [Featuring post](#featuring-post)
|
- [Featuring post](#featuring-post)
|
||||||
- [Reverse image search](#reverse-image-search)
|
- [Reverse image search](#reverse-image-search)
|
||||||
|
- Pool categories
|
||||||
|
- [Listing pool categories](#listing-pool-categories)
|
||||||
|
- [Creating pool category](#creating-pool-category)
|
||||||
|
- [Updating pool category](#updating-pool-category)
|
||||||
|
- [Getting pool category](#getting-pool-category)
|
||||||
|
- [Deleting pool category](#deleting-pool-category)
|
||||||
|
- [Setting default pool category](#setting-default-pool-category)
|
||||||
|
- Pools
|
||||||
|
- [Listing pools](#listing-pool)
|
||||||
|
- [Creating pool](#creating-pool)
|
||||||
|
- [Updating pool](#updating-pool)
|
||||||
|
- [Getting pool](#getting-pool)
|
||||||
|
- [Deleting pool](#deleting-pool)
|
||||||
|
- [Merging pools](#merging-pools)
|
||||||
- Comments
|
- Comments
|
||||||
- [Listing comments](#listing-comments)
|
- [Listing comments](#listing-comments)
|
||||||
- [Creating comment](#creating-comment)
|
- [Creating comment](#creating-comment)
|
||||||
|
@ -82,6 +96,8 @@
|
||||||
- [Micro tag](#micro-tag)
|
- [Micro tag](#micro-tag)
|
||||||
- [Post](#post)
|
- [Post](#post)
|
||||||
- [Micro post](#micro-post)
|
- [Micro post](#micro-post)
|
||||||
|
- [Pool category](#pool-category)
|
||||||
|
- [Pool](#pool)
|
||||||
- [Note](#note)
|
- [Note](#note)
|
||||||
- [Comment](#comment)
|
- [Comment](#comment)
|
||||||
- [Snapshot](#snapshot)
|
- [Snapshot](#snapshot)
|
||||||
|
@ -724,6 +740,7 @@ data.
|
||||||
| `submit` | alias of upload |
|
| `submit` | alias of upload |
|
||||||
| `comment` | commented by given user (accepts wildcards) |
|
| `comment` | commented by given user (accepts wildcards) |
|
||||||
| `fav` | favorited by given user (accepts wildcards) |
|
| `fav` | favorited by given user (accepts wildcards) |
|
||||||
|
| `pool` | belonging to the pool with the given ID |
|
||||||
| `tag-count` | having given number of tags |
|
| `tag-count` | having given number of tags |
|
||||||
| `comment-count` | having given number of comments |
|
| `comment-count` | having given number of comments |
|
||||||
| `fav-count` | favorited by given number of users |
|
| `fav-count` | favorited by given number of users |
|
||||||
|
@ -1118,6 +1135,383 @@ data.
|
||||||
|
|
||||||
Retrieves posts that look like the input image.
|
Retrieves posts that look like the input image.
|
||||||
|
|
||||||
|
## Listing pool categories
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`GET /pool-categories`
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
An [unpaged search result](#unpaged-search-result), for which `<resource>`
|
||||||
|
is a [pool category resource](#pool-category).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Lists all pool categories. Doesn't use paging.
|
||||||
|
|
||||||
|
## Creating pool category
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`POST /pool-categories`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"name": <name>,
|
||||||
|
"color": <color>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [pool category resource](#pool-category).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the name is used by an existing pool category (names are case insensitive)
|
||||||
|
- the name is invalid or missing
|
||||||
|
- the color is invalid or missing
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Creates a new pool category using specified parameters. Name must match
|
||||||
|
`pool_category_name_regex` from server's configuration. First category
|
||||||
|
created becomes the default category.
|
||||||
|
|
||||||
|
## Updating pool category
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`PUT /pool-category/<name>`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"version": <version>,
|
||||||
|
"name": <name>, // optional
|
||||||
|
"color": <color>, // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [pool category resource](#pool-category).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the version is outdated
|
||||||
|
- the pool category does not exist
|
||||||
|
- the name is used by an existing pool category (names are case insensitive)
|
||||||
|
- the name is invalid
|
||||||
|
- the color is invalid
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Updates an existing pool category using specified parameters. Name must
|
||||||
|
match `pool_category_name_regex` from server's configuration. All fields
|
||||||
|
except the [`version`](#versioning) are optional - update concerns only
|
||||||
|
provided fields.
|
||||||
|
|
||||||
|
## Getting pool category
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`GET /pool-category/<name>`
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [pool category resource](#pool-category).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the pool category does not exist
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Retrieves information about an existing pool category.
|
||||||
|
|
||||||
|
## Deleting pool category
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`DELETE /pool-category/<name>`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"version": <version>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the version is outdated
|
||||||
|
- the pool category does not exist
|
||||||
|
- the pool category is used by some pools
|
||||||
|
- the pool category is the last pool category available
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Deletes existing pool category. The pool category to be deleted must have no
|
||||||
|
usages.
|
||||||
|
|
||||||
|
## Setting default pool category
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`PUT /pool-category/<name>/default`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [pool category resource](#pool-category).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the pool category does not exist
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Sets given pool category as default. All new pools created manually or
|
||||||
|
automatically will have this category.
|
||||||
|
|
||||||
|
## Listing pools
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`GET /pools/?offset=<initial-pos>&limit=<page-size>&query=<query>`
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [paged search result resource](#paged-search-result), for which
|
||||||
|
`<resource>` is a [pool resource](#pool).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Searches for pools.
|
||||||
|
|
||||||
|
**Anonymous tokens**
|
||||||
|
|
||||||
|
Same as `name` token.
|
||||||
|
|
||||||
|
**Named tokens**
|
||||||
|
|
||||||
|
| `<key>` | Description |
|
||||||
|
| ------------------- | ----------------------------------------- |
|
||||||
|
| `name` | having given name (accepts wildcards) |
|
||||||
|
| `category` | having given category (accepts wildcards) |
|
||||||
|
| `creation-date` | created at given date |
|
||||||
|
| `creation-time` | alias of `creation-date` |
|
||||||
|
| `last-edit-date` | edited at given date |
|
||||||
|
| `last-edit-time` | alias of `last-edit-date` |
|
||||||
|
| `edit-date` | alias of `last-edit-date` |
|
||||||
|
| `edit-time` | alias of `last-edit-date` |
|
||||||
|
| `post-count` | used in given number of posts |
|
||||||
|
|
||||||
|
**Sort style tokens**
|
||||||
|
|
||||||
|
| `<value>` | Description |
|
||||||
|
| ------------------- | ---------------------------- |
|
||||||
|
| `random` | as random as it can get |
|
||||||
|
| `name` | A to Z |
|
||||||
|
| `category` | category (A to Z) |
|
||||||
|
| `creation-date` | recently created first |
|
||||||
|
| `creation-time` | alias of `creation-date` |
|
||||||
|
| `last-edit-date` | recently edited first |
|
||||||
|
| `last-edit-time` | alias of `creation-time` |
|
||||||
|
| `edit-date` | alias of `creation-time` |
|
||||||
|
| `edit-time` | alias of `creation-time` |
|
||||||
|
| `post-count` | used in most posts first |
|
||||||
|
|
||||||
|
**Special tokens**
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Creating pool
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`POST /pools/create`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"names": [<name1>, <name2>, ...],
|
||||||
|
"category": <category>,
|
||||||
|
"description": <description>, // optional
|
||||||
|
"posts": [<id1>, <id2>, ...], // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [pool resource](#pool).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- any name is invalid
|
||||||
|
- category is invalid
|
||||||
|
- no name was specified
|
||||||
|
- there is at least one duplicate post
|
||||||
|
- at least one post ID does not exist
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Creates a new pool using specified parameters. Names, suggestions and
|
||||||
|
implications must match `pool_name_regex` from server's configuration.
|
||||||
|
Category must exist and is the same as `name` field within
|
||||||
|
[`<pool-category>` resource](#pool-category). `posts` is an optional list of
|
||||||
|
integer post IDs. If the specified posts do not exist, an error will be
|
||||||
|
thrown.
|
||||||
|
|
||||||
|
## Updating pool
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`PUT /pool/<id>`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"version": <version>,
|
||||||
|
"names": [<name1>, <name2>, ...], // optional
|
||||||
|
"category": <category>, // optional
|
||||||
|
"description": <description>, // optional
|
||||||
|
"posts": [<id1>, <id2>, ...], // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [pool resource](#pool).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the version is outdated
|
||||||
|
- the pool does not exist
|
||||||
|
- any name is invalid
|
||||||
|
- category is invalid
|
||||||
|
- no name was specified
|
||||||
|
- there is at least one duplicate post
|
||||||
|
- at least one post ID does not exist
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Updates an existing pool using specified parameters. Names, suggestions and
|
||||||
|
implications must match `pool_name_regex` from server's configuration.
|
||||||
|
Category must exist and is the same as `name` field within
|
||||||
|
[`<pool-category>` resource](#pool-category). `posts` is an optional list of
|
||||||
|
integer post IDs. If the specified posts do not exist yet, an error will be
|
||||||
|
thrown. The full list of post IDs must be provided if they are being
|
||||||
|
updated, and the previous list of posts will be replaced with the new one.
|
||||||
|
All fields except the [`version`](#versioning) are optional - update
|
||||||
|
concerns only provided fields.
|
||||||
|
|
||||||
|
## Getting pool
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`GET /pool/<id>`
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [pool resource](#pool).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the pool does not exist
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Retrieves information about an existing pool.
|
||||||
|
|
||||||
|
## Deleting pool
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`DELETE /pool/<name>`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"version": <version>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the version is outdated
|
||||||
|
- the pool does not exist
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Deletes existing pool. All posts in the pool will only have their relation
|
||||||
|
to the pool removed.
|
||||||
|
|
||||||
|
## Merging pools
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`POST /pool-merge/`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"removeVersion": <source-pool-version>,
|
||||||
|
"remove": <source-pool-id>,
|
||||||
|
"mergeToVersion": <target-pool-version>,
|
||||||
|
"mergeTo": <target-pool-id>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [pool resource](#pool) containing the merged pool.
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the version of either pool is outdated
|
||||||
|
- the source or target pool does not exist
|
||||||
|
- the source pool is the same as the target pool
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Removes source pool and merges all of its posts with the target pool. Other
|
||||||
|
pool properties such as category and aliases do not get transferred and are
|
||||||
|
discarded.
|
||||||
|
|
||||||
## Listing comments
|
## Listing comments
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -2073,6 +2467,68 @@ A text annotation rendered on top of the post.
|
||||||
will draw it inside the post's upper left quarter.
|
will draw it inside the post's upper left quarter.
|
||||||
- `<text>`: the annotation text. The client should render is as Markdown.
|
- `<text>`: the annotation text. The client should render is as Markdown.
|
||||||
|
|
||||||
|
## Pool category
|
||||||
|
**Description**
|
||||||
|
|
||||||
|
A single pool category. The primary purpose of pool categories is to distinguish
|
||||||
|
certain pool types (such as series, relations etc.), which improves user
|
||||||
|
experience.
|
||||||
|
|
||||||
|
**Structure**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"version": <version>,
|
||||||
|
"name": <name>,
|
||||||
|
"color": <color>,
|
||||||
|
"usages": <usages>
|
||||||
|
"default": <is-default>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field meaning**
|
||||||
|
|
||||||
|
- `<version>`: resource version. See [versioning](#versioning).
|
||||||
|
- `<name>`: the category name.
|
||||||
|
- `<color>`: the category color.
|
||||||
|
- `<usages>`: how many pools is the given category used with.
|
||||||
|
- `<is-default>`: whether the pool category is the default one.
|
||||||
|
|
||||||
|
## Pool
|
||||||
|
**Description**
|
||||||
|
|
||||||
|
An ordered list of posts, with a description and category.
|
||||||
|
|
||||||
|
**Structure**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"version": <version>,
|
||||||
|
"id": <id>
|
||||||
|
"names": <names>,
|
||||||
|
"category": <category>,
|
||||||
|
"posts": <suggestions>,
|
||||||
|
"creationTime": <creation-time>,
|
||||||
|
"lastEditTime": <last-edit-time>,
|
||||||
|
"postCount": <post-count>,
|
||||||
|
"description": <description>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field meaning**
|
||||||
|
|
||||||
|
- `<version>`: resource version. See [versioning](#versioning).
|
||||||
|
- `<id>`: the pool identifier.
|
||||||
|
- `<names>`: a list of pool names (aliases).
|
||||||
|
- `<category>`: the name of the category the given pool belongs to.
|
||||||
|
- `<posts>`: an ordered list of posts, serialized as [micro
|
||||||
|
post resource](#micro-post). Posts are ordered by insertion by default.
|
||||||
|
- `<creation-time>`: time the pool was created, formatted as per RFC 3339.
|
||||||
|
- `<last-edit-time>`: time the pool was edited, formatted as per RFC 3339.
|
||||||
|
- `<post-count>`: the number of posts the pool has.
|
||||||
|
- `<description>`: the pool description (instructions how to use, history etc.)
|
||||||
|
The client should render it as Markdown.
|
||||||
|
|
||||||
## Comment
|
## Comment
|
||||||
**Description**
|
**Description**
|
||||||
|
|
||||||
|
@ -2144,6 +2600,7 @@ A snapshot is a version of a database resource.
|
||||||
| `"tag"` | first tag name at given time |
|
| `"tag"` | first tag name at given time |
|
||||||
| `"tag_category"` | tag category name at given time |
|
| `"tag_category"` | tag category name at given time |
|
||||||
| `"post"` | post ID |
|
| `"post"` | post ID |
|
||||||
|
| `"pool"` | pool ID |
|
||||||
|
|
||||||
- `<issuer>`: a [micro user resource](#micro-user) representing the user who
|
- `<issuer>`: a [micro user resource](#micro-user) representing the user who
|
||||||
has made the change.
|
has made the change.
|
||||||
|
|
|
@ -49,6 +49,9 @@ enable_safety: yes
|
||||||
tag_name_regex: ^\S+$
|
tag_name_regex: ^\S+$
|
||||||
tag_category_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
|
# 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
|
# customize them, make sure to update the instructions in the registration form
|
||||||
# template as well.
|
# template as well.
|
||||||
|
@ -125,6 +128,24 @@ privileges:
|
||||||
'tag_categories:delete': moderator
|
'tag_categories:delete': moderator
|
||||||
'tag_categories:set_default': moderator
|
'tag_categories:set_default': moderator
|
||||||
|
|
||||||
|
'pools:create': regular
|
||||||
|
'pools:edit:names': power
|
||||||
|
'pools:edit:category': power
|
||||||
|
'pools:edit:description': power
|
||||||
|
'pools:edit:posts': power
|
||||||
|
'pools:list': regular
|
||||||
|
'pools:view': anonymous
|
||||||
|
'pools:merge': moderator
|
||||||
|
'pools:delete': moderator
|
||||||
|
|
||||||
|
'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
|
||||||
|
|
||||||
'comments:create': regular
|
'comments:create': regular
|
||||||
'comments:delete:any': moderator
|
'comments:delete:any': moderator
|
||||||
'comments:delete:own': regular
|
'comments:delete:own': regular
|
||||||
|
|
|
@ -4,6 +4,8 @@ import szurubooru.api.user_token_api
|
||||||
import szurubooru.api.post_api
|
import szurubooru.api.post_api
|
||||||
import szurubooru.api.tag_api
|
import szurubooru.api.tag_api
|
||||||
import szurubooru.api.tag_category_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.comment_api
|
||||||
import szurubooru.api.password_reset_api
|
import szurubooru.api.password_reset_api
|
||||||
import szurubooru.api.snapshot_api
|
import szurubooru.api.snapshot_api
|
||||||
|
|
106
server/szurubooru/api/pool_api.py
Normal file
106
server/szurubooru/api/pool_api.py
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
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'])
|
||||||
|
|
||||||
|
|
||||||
|
@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('/pool/?')
|
||||||
|
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='')
|
||||||
|
posts = ctx.get_param_as_int_list('posts', default=[])
|
||||||
|
|
||||||
|
pool = pools.create_pool(names, category, posts)
|
||||||
|
pool.last_edit_time = datetime.utcnow()
|
||||||
|
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'))
|
||||||
|
if ctx.has_param('posts'):
|
||||||
|
auth.verify_privilege(ctx.user, 'pools:edit:posts')
|
||||||
|
posts = ctx.get_param_as_int_list('posts')
|
||||||
|
pools.update_pool_posts(pool, posts)
|
||||||
|
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)
|
89
server/szurubooru/api/pool_category_api.py
Normal file
89
server/szurubooru/api/pool_category_api.py
Normal 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)
|
199
server/szurubooru/func/pool_categories.py
Normal file
199
server/szurubooru/func/pool_categories.py
Normal 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)
|
321
server/szurubooru/func/pools.py
Normal file
321
server/szurubooru/func/pools.py
Normal file
|
@ -0,0 +1,321 @@
|
||||||
|
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, posts, serialization
|
||||||
|
|
||||||
|
|
||||||
|
class PoolNotFoundError(errors.NotFoundError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PoolAlreadyExistsError(errors.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PoolIsInUseError(errors.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPoolNameError(errors.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPoolDuplicateError(errors.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPoolCategoryError(errors.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPoolDescriptionError(errors.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPoolRelationError(errors.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPoolNonexistentPostError(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 _duplicates(a: List[int]) -> List[int]:
|
||||||
|
seen = set()
|
||||||
|
dupes = []
|
||||||
|
for x in a:
|
||||||
|
if x not in seen:
|
||||||
|
seen.add(x)
|
||||||
|
else:
|
||||||
|
dupes.append(x)
|
||||||
|
return dupes
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
'posts': self.serialize_posts
|
||||||
|
}
|
||||||
|
|
||||||
|
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_posts(self) -> Any:
|
||||||
|
return [
|
||||||
|
post for post in [
|
||||||
|
posts.serialize_micro_post(rel, None)
|
||||||
|
for rel in self.pool.posts
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
post_ids=[])
|
||||||
|
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_pool_posts(source_pool_id: int, target_pool_id: int) -> None:
|
||||||
|
alias1 = model.PoolPost
|
||||||
|
alias2 = sa.orm.util.aliased(model.PoolPost)
|
||||||
|
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)
|
||||||
|
|
||||||
|
merge_pool_posts(source_pool.pool_id, target_pool.pool_id)
|
||||||
|
delete(source_pool)
|
||||||
|
|
||||||
|
|
||||||
|
def create_pool(
|
||||||
|
names: List[str],
|
||||||
|
category_name: str,
|
||||||
|
post_ids: List[int]) -> model.Pool:
|
||||||
|
pool = model.Pool()
|
||||||
|
pool.creation_time = datetime.utcnow()
|
||||||
|
update_pool_names(pool, names)
|
||||||
|
update_pool_category_name(pool, category_name)
|
||||||
|
update_pool_posts(pool, post_ids)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def update_pool_posts(pool: model.Pool, post_ids: List[int]) -> None:
|
||||||
|
assert pool
|
||||||
|
dupes = _duplicates(post_ids)
|
||||||
|
if len(dupes) > 0:
|
||||||
|
dupes = ', '.join(list(str(x) for x in dupes))
|
||||||
|
raise InvalidPoolDuplicateError('Duplicate post(s) in pool: ' + dupes)
|
||||||
|
ret = posts.get_posts_by_ids(post_ids)
|
||||||
|
if len(post_ids) != len(ret):
|
||||||
|
missing = set(post_ids) - set(post.post_id for post in ret)
|
||||||
|
missing = ', '.join(list(str(x) for x in missing))
|
||||||
|
raise InvalidPoolNonexistentPostError(
|
||||||
|
'The following posts do not exist: ' + missing)
|
||||||
|
pool.posts.clear()
|
||||||
|
for post in ret:
|
||||||
|
pool.posts.append(post)
|
|
@ -5,7 +5,7 @@ from datetime import datetime
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from szurubooru import config, db, model, errors, rest
|
from szurubooru import config, db, model, errors, rest
|
||||||
from szurubooru.func import (
|
from szurubooru.func import (
|
||||||
users, scores, comments, tags, util,
|
users, scores, comments, tags, pools, util,
|
||||||
mime, images, files, image_hash, serialization, snapshots)
|
mime, images, files, image_hash, serialization, snapshots)
|
||||||
|
|
||||||
|
|
||||||
|
@ -176,6 +176,7 @@ class PostSerializer(serialization.BaseSerializer):
|
||||||
'hasCustomThumbnail': self.serialize_has_custom_thumbnail,
|
'hasCustomThumbnail': self.serialize_has_custom_thumbnail,
|
||||||
'notes': self.serialize_notes,
|
'notes': self.serialize_notes,
|
||||||
'comments': self.serialize_comments,
|
'comments': self.serialize_comments,
|
||||||
|
'pools': self.serialize_pools,
|
||||||
}
|
}
|
||||||
|
|
||||||
def serialize_id(self) -> Any:
|
def serialize_id(self) -> Any:
|
||||||
|
@ -299,6 +300,13 @@ class PostSerializer(serialization.BaseSerializer):
|
||||||
self.post.comments,
|
self.post.comments,
|
||||||
key=lambda comment: comment.creation_time)]
|
key=lambda comment: comment.creation_time)]
|
||||||
|
|
||||||
|
def serialize_pools(self) -> List[Any]:
|
||||||
|
return [
|
||||||
|
pools.serialize_pool(pool)
|
||||||
|
for pool in sorted(
|
||||||
|
self.post.pools,
|
||||||
|
key=lambda pool: pool.creation_time)]
|
||||||
|
|
||||||
|
|
||||||
def serialize_post(
|
def serialize_post(
|
||||||
post: Optional[model.Post],
|
post: Optional[model.Post],
|
||||||
|
@ -334,6 +342,22 @@ def get_post_by_id(post_id: int) -> model.Post:
|
||||||
return post
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
def get_posts_by_ids(ids: List[int]) -> List[model.Post]:
|
||||||
|
if len(ids) == 0:
|
||||||
|
return []
|
||||||
|
posts = (
|
||||||
|
db.session.query(model.Post)
|
||||||
|
.filter(
|
||||||
|
sa.sql.or_(
|
||||||
|
model.Post.post_id == post_id
|
||||||
|
for post_id in ids))
|
||||||
|
.all())
|
||||||
|
id_order = {
|
||||||
|
v: k for k, v in enumerate(ids)
|
||||||
|
}
|
||||||
|
return sorted(posts, key=lambda post: id_order.get(post.post_id))
|
||||||
|
|
||||||
|
|
||||||
def try_get_current_post_feature() -> Optional[model.PostFeature]:
|
def try_get_current_post_feature() -> Optional[model.PostFeature]:
|
||||||
return (
|
return (
|
||||||
db.session
|
db.session
|
||||||
|
|
|
@ -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,
|
||||||
|
'posts': [post.post_id for post in pool.posts]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_post_snapshot(post: model.Post) -> Dict[str, Any]:
|
def get_post_snapshot(post: model.Post) -> Dict[str, Any]:
|
||||||
assert post
|
assert post
|
||||||
return {
|
return {
|
||||||
|
@ -47,6 +65,8 @@ _snapshot_factories = {
|
||||||
'tag_category': lambda entity: get_tag_category_snapshot(entity),
|
'tag_category': lambda entity: get_tag_category_snapshot(entity),
|
||||||
'tag': lambda entity: get_tag_snapshot(entity),
|
'tag': lambda entity: get_tag_snapshot(entity),
|
||||||
'post': lambda entity: get_post_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]]]
|
} # type: Dict[model.Base, Callable[[model.Base], Dict[str ,Any]]]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Optional, Tuple, List, Dict, Callable
|
from typing import Any, Optional, Tuple, List, Dict, Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
'''
|
||||||
|
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()
|
|
@ -0,0 +1,64 @@
|
||||||
|
'''
|
||||||
|
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'))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'pool_post',
|
||||||
|
sa.Column('pool_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('post_id', sa.Integer(), nullable=False, index=True),
|
||||||
|
sa.Column('ord', sa.Integer(), nullable=False, index=True),
|
||||||
|
sa.ForeignKeyConstraint(['pool_id'], ['pool.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['post_id'], ['post.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('pool_id', 'post_id'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index(op.f('ix_pool_name_ord'), table_name='pool_name')
|
||||||
|
op.drop_table('pool_post')
|
||||||
|
op.drop_table('pool_name')
|
||||||
|
op.drop_table('pool')
|
||||||
|
op.drop_table('pool_category')
|
|
@ -11,6 +11,8 @@ from szurubooru.model.post import (
|
||||||
PostNote,
|
PostNote,
|
||||||
PostFeature,
|
PostFeature,
|
||||||
PostSignature)
|
PostSignature)
|
||||||
|
from szurubooru.model.pool import Pool, PoolName, PoolPost
|
||||||
|
from szurubooru.model.pool_category import PoolCategory
|
||||||
from szurubooru.model.comment import Comment, CommentScore
|
from szurubooru.model.comment import Comment, CommentScore
|
||||||
from szurubooru.model.snapshot import Snapshot
|
from szurubooru.model.snapshot import Snapshot
|
||||||
import szurubooru.model.util
|
import szurubooru.model.util
|
||||||
|
|
103
server/szurubooru/model/pool.py
Normal file
103
server/szurubooru/model/pool.py
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.orderinglist import ordering_list
|
||||||
|
from sqlalchemy.ext.associationproxy import association_proxy
|
||||||
|
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 PoolPost(Base):
|
||||||
|
__tablename__ = 'pool_post'
|
||||||
|
|
||||||
|
pool_id = sa.Column(
|
||||||
|
'pool_id',
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey('pool.id'),
|
||||||
|
nullable=False,
|
||||||
|
primary_key=True,
|
||||||
|
index=True)
|
||||||
|
post_id = sa.Column(
|
||||||
|
'post_id',
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey('post.id'),
|
||||||
|
nullable=False,
|
||||||
|
primary_key=True,
|
||||||
|
index=True)
|
||||||
|
order = sa.Column('ord', sa.Integer, nullable=False, index=True)
|
||||||
|
|
||||||
|
pool = sa.orm.relationship('Pool', back_populates='_posts')
|
||||||
|
post = sa.orm.relationship('Post', back_populates='_pools')
|
||||||
|
|
||||||
|
def __init__(self, post) -> None:
|
||||||
|
self.post_id = post.post_id
|
||||||
|
|
||||||
|
|
||||||
|
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')
|
||||||
|
_posts = sa.orm.relationship(
|
||||||
|
'PoolPost',
|
||||||
|
cascade='all,delete-orphan',
|
||||||
|
lazy='joined',
|
||||||
|
back_populates='pool',
|
||||||
|
order_by='PoolPost.order',
|
||||||
|
collection_class=ordering_list('order'))
|
||||||
|
posts = association_proxy('_posts', 'post')
|
||||||
|
|
||||||
|
post_count = sa.orm.column_property(
|
||||||
|
(
|
||||||
|
sa.sql.expression.select(
|
||||||
|
[sa.sql.expression.func.count(PoolPost.post_id)])
|
||||||
|
.where(PoolPost.pool_id == pool_id)
|
||||||
|
.as_scalar()
|
||||||
|
),
|
||||||
|
deferred=True)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
29
server/szurubooru/model/pool_category.py
Normal file
29
server/szurubooru/model/pool_category.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
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,
|
||||||
|
}
|
|
@ -2,7 +2,10 @@ from typing import List
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from szurubooru.model.base import Base
|
from szurubooru.model.base import Base
|
||||||
from szurubooru.model.comment import Comment
|
from szurubooru.model.comment import Comment
|
||||||
|
from szurubooru.model.pool import PoolPost
|
||||||
|
from sqlalchemy.ext.associationproxy import association_proxy
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
from sqlalchemy.ext.orderinglist import ordering_list
|
||||||
|
|
||||||
|
|
||||||
class PostFeature(Base):
|
class PostFeature(Base):
|
||||||
|
@ -224,6 +227,13 @@ class Post(Base):
|
||||||
notes = sa.orm.relationship(
|
notes = sa.orm.relationship(
|
||||||
'PostNote', cascade='all, delete-orphan', lazy='joined')
|
'PostNote', cascade='all, delete-orphan', lazy='joined')
|
||||||
comments = sa.orm.relationship('Comment', cascade='all, delete-orphan')
|
comments = sa.orm.relationship('Comment', cascade='all, delete-orphan')
|
||||||
|
_pools = sa.orm.relationship(
|
||||||
|
'PoolPost',
|
||||||
|
cascade='all,delete-orphan',
|
||||||
|
lazy='select',
|
||||||
|
order_by='PoolPost.order',
|
||||||
|
back_populates='post')
|
||||||
|
pools = association_proxy('_pools', 'pool')
|
||||||
|
|
||||||
# dynamic columns
|
# dynamic columns
|
||||||
tag_count = sa.orm.column_property(
|
tag_count = sa.orm.column_property(
|
||||||
|
|
|
@ -10,6 +10,8 @@ def get_resource_info(entity: Base) -> Tuple[Any, Any, Union[str, int]]:
|
||||||
'tag_category': lambda category: category.name,
|
'tag_category': lambda category: category.name,
|
||||||
'comment': lambda comment: comment.comment_id,
|
'comment': lambda comment: comment.comment_id,
|
||||||
'post': lambda post: post.post_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]]
|
} # type: Dict[str, Callable[[Base], Any]]
|
||||||
|
|
||||||
resource_type = entity.__table__.name
|
resource_type = entity.__table__.name
|
||||||
|
|
|
@ -3,3 +3,4 @@ from .tag_search_config import TagSearchConfig
|
||||||
from .post_search_config import PostSearchConfig
|
from .post_search_config import PostSearchConfig
|
||||||
from .snapshot_search_config import SnapshotSearchConfig
|
from .snapshot_search_config import SnapshotSearchConfig
|
||||||
from .comment_search_config import CommentSearchConfig
|
from .comment_search_config import CommentSearchConfig
|
||||||
|
from .pool_search_config import PoolSearchConfig
|
||||||
|
|
109
server/szurubooru/search/configs/pool_search_config.py
Normal file
109
server/szurubooru/search/configs/pool_search_config.py
Normal 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)
|
||||||
|
),
|
||||||
|
])
|
|
@ -104,6 +104,18 @@ def _note_filter(
|
||||||
search_util.create_str_filter)(query, criterion, negated)
|
search_util.create_str_filter)(query, criterion, negated)
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_filter(
|
||||||
|
query: SaQuery,
|
||||||
|
criterion: Optional[criteria.BaseCriterion],
|
||||||
|
negated: bool) -> SaQuery:
|
||||||
|
assert criterion
|
||||||
|
return search_util.create_subquery_filter(
|
||||||
|
model.Post.post_id,
|
||||||
|
model.PoolPost.post_id,
|
||||||
|
model.PoolPost.pool_id,
|
||||||
|
search_util.create_num_filter)(query, criterion, negated)
|
||||||
|
|
||||||
|
|
||||||
class PostSearchConfig(BaseSearchConfig):
|
class PostSearchConfig(BaseSearchConfig):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.user = None # type: Optional[model.User]
|
self.user = None # type: Optional[model.User]
|
||||||
|
@ -350,6 +362,11 @@ class PostSearchConfig(BaseSearchConfig):
|
||||||
search_util.create_str_filter(
|
search_util.create_str_filter(
|
||||||
model.Post.flags_string, _flag_transformer)
|
model.Post.flags_string, _flag_transformer)
|
||||||
),
|
),
|
||||||
|
|
||||||
|
(
|
||||||
|
['pool'],
|
||||||
|
_pool_filter
|
||||||
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
60
server/szurubooru/tests/api/test_pool_category_creating.py
Normal file
60
server/szurubooru/tests/api/test_pool_category_creating.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db, model, errors
|
||||||
|
from szurubooru.func import pool_categories, snapshots
|
||||||
|
|
||||||
|
|
||||||
|
def _update_category_name(category, name):
|
||||||
|
category.name = name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({
|
||||||
|
'privileges': {'pool_categories:create': model.User.RANK_REGULAR},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_creating_category(
|
||||||
|
pool_category_factory, user_factory, context_factory):
|
||||||
|
auth_user = user_factory(rank=model.User.RANK_REGULAR)
|
||||||
|
category = pool_category_factory(name='meta')
|
||||||
|
db.session.add(category)
|
||||||
|
|
||||||
|
with patch('szurubooru.func.pool_categories.create_category'), \
|
||||||
|
patch('szurubooru.func.pool_categories.serialize_category'), \
|
||||||
|
patch('szurubooru.func.pool_categories.update_category_name'), \
|
||||||
|
patch('szurubooru.func.snapshots.create'):
|
||||||
|
pool_categories.create_category.return_value = category
|
||||||
|
pool_categories.update_category_name.side_effect = \
|
||||||
|
_update_category_name
|
||||||
|
pool_categories.serialize_category.return_value = 'serialized category'
|
||||||
|
result = api.pool_category_api.create_pool_category(
|
||||||
|
context_factory(
|
||||||
|
params={'name': 'meta', 'color': 'black'}, user=auth_user))
|
||||||
|
assert result == 'serialized category'
|
||||||
|
pool_categories.create_category.assert_called_once_with(
|
||||||
|
'meta', 'black')
|
||||||
|
snapshots.create.assert_called_once_with(category, auth_user)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('field', ['name', 'color'])
|
||||||
|
def test_trying_to_omit_mandatory_field(user_factory, context_factory, field):
|
||||||
|
params = {
|
||||||
|
'name': 'meta',
|
||||||
|
'color': 'black',
|
||||||
|
}
|
||||||
|
del params[field]
|
||||||
|
with pytest.raises(errors.ValidationError):
|
||||||
|
api.pool_category_api.create_pool_category(
|
||||||
|
context_factory(
|
||||||
|
params=params,
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_create_without_privileges(user_factory, context_factory):
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.pool_category_api.create_pool_category(
|
||||||
|
context_factory(
|
||||||
|
params={'name': 'meta', 'color': 'black'},
|
||||||
|
user=user_factory(rank=model.User.RANK_ANONYMOUS)))
|
76
server/szurubooru/tests/api/test_pool_category_deleting.py
Normal file
76
server/szurubooru/tests/api/test_pool_category_deleting.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db, model, errors
|
||||||
|
from szurubooru.func import pool_categories, snapshots
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({
|
||||||
|
'privileges': {'pool_categories:delete': model.User.RANK_REGULAR},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting(user_factory, pool_category_factory, context_factory):
|
||||||
|
auth_user = user_factory(rank=model.User.RANK_REGULAR)
|
||||||
|
category = pool_category_factory(name='category')
|
||||||
|
db.session.add(pool_category_factory(name='root'))
|
||||||
|
db.session.add(category)
|
||||||
|
db.session.flush()
|
||||||
|
with patch('szurubooru.func.snapshots.delete'):
|
||||||
|
result = api.pool_category_api.delete_pool_category(
|
||||||
|
context_factory(params={'version': 1}, user=auth_user),
|
||||||
|
{'category_name': 'category'})
|
||||||
|
assert result == {}
|
||||||
|
assert db.session.query(model.PoolCategory).count() == 1
|
||||||
|
assert db.session.query(model.PoolCategory).one().name == 'root'
|
||||||
|
snapshots.delete.assert_called_once_with(category, auth_user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_delete_used(
|
||||||
|
user_factory, pool_category_factory, pool_factory, context_factory):
|
||||||
|
category = pool_category_factory(name='category')
|
||||||
|
db.session.add(category)
|
||||||
|
db.session.flush()
|
||||||
|
pool = pool_factory(names=['pool'], category=category)
|
||||||
|
db.session.add(pool)
|
||||||
|
db.session.commit()
|
||||||
|
with pytest.raises(pool_categories.PoolCategoryIsInUseError):
|
||||||
|
api.pool_category_api.delete_pool_category(
|
||||||
|
context_factory(
|
||||||
|
params={'version': 1},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'category_name': 'category'})
|
||||||
|
assert db.session.query(model.PoolCategory).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_delete_last(
|
||||||
|
user_factory, pool_category_factory, context_factory):
|
||||||
|
db.session.add(pool_category_factory(name='root'))
|
||||||
|
db.session.commit()
|
||||||
|
with pytest.raises(pool_categories.PoolCategoryIsInUseError):
|
||||||
|
api.pool_category_api.delete_pool_category(
|
||||||
|
context_factory(
|
||||||
|
params={'version': 1},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'category_name': 'root'})
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_delete_non_existing(user_factory, context_factory):
|
||||||
|
with pytest.raises(pool_categories.PoolCategoryNotFoundError):
|
||||||
|
api.pool_category_api.delete_pool_category(
|
||||||
|
context_factory(user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'category_name': 'bad'})
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_delete_without_privileges(
|
||||||
|
user_factory, pool_category_factory, context_factory):
|
||||||
|
db.session.add(pool_category_factory(name='category'))
|
||||||
|
db.session.commit()
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.pool_category_api.delete_pool_category(
|
||||||
|
context_factory(
|
||||||
|
params={'version': 1},
|
||||||
|
user=user_factory(rank=model.User.RANK_ANONYMOUS)),
|
||||||
|
{'category_name': 'category'})
|
||||||
|
assert db.session.query(model.PoolCategory).count() == 1
|
56
server/szurubooru/tests/api/test_pool_category_retrieving.py
Normal file
56
server/szurubooru/tests/api/test_pool_category_retrieving.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db, model, errors
|
||||||
|
from szurubooru.func import pool_categories
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({
|
||||||
|
'privileges': {
|
||||||
|
'pool_categories:list': model.User.RANK_REGULAR,
|
||||||
|
'pool_categories:view': model.User.RANK_REGULAR,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieving_multiple(
|
||||||
|
user_factory, pool_category_factory, context_factory):
|
||||||
|
db.session.add_all([
|
||||||
|
pool_category_factory(name='c1'),
|
||||||
|
pool_category_factory(name='c2'),
|
||||||
|
])
|
||||||
|
db.session.flush()
|
||||||
|
result = api.pool_category_api.get_pool_categories(
|
||||||
|
context_factory(user=user_factory(rank=model.User.RANK_REGULAR)))
|
||||||
|
assert [cat['name'] for cat in result['results']] == ['c1', 'c2']
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieving_single(
|
||||||
|
user_factory, pool_category_factory, context_factory):
|
||||||
|
db.session.add(pool_category_factory(name='cat'))
|
||||||
|
db.session.flush()
|
||||||
|
result = api.pool_category_api.get_pool_category(
|
||||||
|
context_factory(user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'category_name': 'cat'})
|
||||||
|
assert result == {
|
||||||
|
'name': 'cat',
|
||||||
|
'color': 'dummy',
|
||||||
|
'usages': 0,
|
||||||
|
'default': False,
|
||||||
|
'version': 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_retrieve_single_non_existing(user_factory, context_factory):
|
||||||
|
with pytest.raises(pool_categories.PoolCategoryNotFoundError):
|
||||||
|
api.pool_category_api.get_pool_category(
|
||||||
|
context_factory(user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'category_name': '-'})
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_retrieve_single_without_privileges(
|
||||||
|
user_factory, context_factory):
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.pool_category_api.get_pool_category(
|
||||||
|
context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)),
|
||||||
|
{'category_name': '-'})
|
110
server/szurubooru/tests/api/test_pool_category_updating.py
Normal file
110
server/szurubooru/tests/api/test_pool_category_updating.py
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db, model, errors
|
||||||
|
from szurubooru.func import pool_categories, snapshots
|
||||||
|
|
||||||
|
|
||||||
|
def _update_category_name(category, name):
|
||||||
|
category.name = name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({
|
||||||
|
'privileges': {
|
||||||
|
'pool_categories:edit:name': model.User.RANK_REGULAR,
|
||||||
|
'pool_categories:edit:color': model.User.RANK_REGULAR,
|
||||||
|
'pool_categories:set_default': model.User.RANK_REGULAR,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_updating(user_factory, pool_category_factory, context_factory):
|
||||||
|
auth_user = user_factory(rank=model.User.RANK_REGULAR)
|
||||||
|
category = pool_category_factory(name='name', color='black')
|
||||||
|
db.session.add(category)
|
||||||
|
db.session.flush()
|
||||||
|
with patch('szurubooru.func.pool_categories.serialize_category'), \
|
||||||
|
patch('szurubooru.func.pool_categories.update_category_name'), \
|
||||||
|
patch('szurubooru.func.pool_categories.update_category_color'), \
|
||||||
|
patch('szurubooru.func.snapshots.modify'):
|
||||||
|
pool_categories.update_category_name.side_effect = \
|
||||||
|
_update_category_name
|
||||||
|
pool_categories.serialize_category.return_value = 'serialized category'
|
||||||
|
result = api.pool_category_api.update_pool_category(
|
||||||
|
context_factory(
|
||||||
|
params={'name': 'changed', 'color': 'white', 'version': 1},
|
||||||
|
user=auth_user),
|
||||||
|
{'category_name': 'name'})
|
||||||
|
assert result == 'serialized category'
|
||||||
|
pool_categories.update_category_name.assert_called_once_with(
|
||||||
|
category, 'changed')
|
||||||
|
pool_categories.update_category_color.assert_called_once_with(
|
||||||
|
category, 'white')
|
||||||
|
snapshots.modify.assert_called_once_with(category, auth_user)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('field', ['name', 'color'])
|
||||||
|
def test_omitting_optional_field(
|
||||||
|
user_factory, pool_category_factory, context_factory, field):
|
||||||
|
db.session.add(pool_category_factory(name='name', color='black'))
|
||||||
|
db.session.commit()
|
||||||
|
params = {
|
||||||
|
'name': 'changed',
|
||||||
|
'color': 'white',
|
||||||
|
}
|
||||||
|
del params[field]
|
||||||
|
with patch('szurubooru.func.pool_categories.serialize_category'), \
|
||||||
|
patch('szurubooru.func.pool_categories.update_category_name'):
|
||||||
|
api.pool_category_api.update_pool_category(
|
||||||
|
context_factory(
|
||||||
|
params={**params, **{'version': 1}},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'category_name': 'name'})
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_update_non_existing(user_factory, context_factory):
|
||||||
|
with pytest.raises(pool_categories.PoolCategoryNotFoundError):
|
||||||
|
api.pool_category_api.update_pool_category(
|
||||||
|
context_factory(
|
||||||
|
params={'name': ['dummy']},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'category_name': 'bad'})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('params', [
|
||||||
|
{'name': 'whatever'},
|
||||||
|
{'color': 'whatever'},
|
||||||
|
])
|
||||||
|
def test_trying_to_update_without_privileges(
|
||||||
|
user_factory, pool_category_factory, context_factory, params):
|
||||||
|
db.session.add(pool_category_factory(name='dummy'))
|
||||||
|
db.session.commit()
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.pool_category_api.update_pool_category(
|
||||||
|
context_factory(
|
||||||
|
params={**params, **{'version': 1}},
|
||||||
|
user=user_factory(rank=model.User.RANK_ANONYMOUS)),
|
||||||
|
{'category_name': 'dummy'})
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_as_default(user_factory, pool_category_factory, context_factory):
|
||||||
|
category = pool_category_factory(name='name', color='black')
|
||||||
|
db.session.add(category)
|
||||||
|
db.session.commit()
|
||||||
|
with patch('szurubooru.func.pool_categories.serialize_category'), \
|
||||||
|
patch('szurubooru.func.pool_categories.set_default_category'):
|
||||||
|
pool_categories.update_category_name.side_effect = \
|
||||||
|
_update_category_name
|
||||||
|
pool_categories.serialize_category.return_value = 'serialized category'
|
||||||
|
result = api.pool_category_api.set_pool_category_as_default(
|
||||||
|
context_factory(
|
||||||
|
params={
|
||||||
|
'name': 'changed',
|
||||||
|
'color': 'white',
|
||||||
|
'version': 1,
|
||||||
|
},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'category_name': 'name'})
|
||||||
|
assert result == 'serialized category'
|
||||||
|
pool_categories.set_default_category.assert_called_once_with(category)
|
82
server/szurubooru/tests/api/test_pool_creating.py
Normal file
82
server/szurubooru/tests/api/test_pool_creating.py
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, model, errors
|
||||||
|
from szurubooru.func import pools, posts, snapshots
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({'privileges': {'pools:create': model.User.RANK_REGULAR}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_creating_simple_pools(pool_factory, user_factory, context_factory):
|
||||||
|
auth_user = user_factory(rank=model.User.RANK_REGULAR)
|
||||||
|
pool = pool_factory()
|
||||||
|
with patch('szurubooru.func.pools.create_pool'), \
|
||||||
|
patch('szurubooru.func.pools.get_or_create_pools_by_names'), \
|
||||||
|
patch('szurubooru.func.pools.serialize_pool'), \
|
||||||
|
patch('szurubooru.func.snapshots.create'):
|
||||||
|
posts.get_posts_by_ids.return_value = ([], [])
|
||||||
|
pools.create_pool.return_value = pool
|
||||||
|
pools.serialize_pool.return_value = 'serialized pool'
|
||||||
|
result = api.pool_api.create_pool(
|
||||||
|
context_factory(
|
||||||
|
params={
|
||||||
|
'names': ['pool1', 'pool2'],
|
||||||
|
'category': 'default',
|
||||||
|
'description': 'desc',
|
||||||
|
'posts': [1, 2],
|
||||||
|
},
|
||||||
|
user=auth_user))
|
||||||
|
assert result == 'serialized pool'
|
||||||
|
pools.create_pool.assert_called_once_with(
|
||||||
|
['pool1', 'pool2'], 'default', [1, 2])
|
||||||
|
snapshots.create.assert_called_once_with(pool, auth_user)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('field', ['names', 'category'])
|
||||||
|
def test_trying_to_omit_mandatory_field(user_factory, context_factory, field):
|
||||||
|
params = {
|
||||||
|
'names': ['pool1', 'pool2'],
|
||||||
|
'category': 'default',
|
||||||
|
'description': 'desc',
|
||||||
|
'posts': [],
|
||||||
|
}
|
||||||
|
del params[field]
|
||||||
|
with pytest.raises(errors.ValidationError):
|
||||||
|
api.pool_api.create_pool(
|
||||||
|
context_factory(
|
||||||
|
params=params,
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('field', ['description', 'posts'])
|
||||||
|
def test_omitting_optional_field(
|
||||||
|
pool_factory, user_factory, context_factory, field):
|
||||||
|
params = {
|
||||||
|
'names': ['pool1', 'pool2'],
|
||||||
|
'category': 'default',
|
||||||
|
'description': 'desc',
|
||||||
|
'posts': [],
|
||||||
|
}
|
||||||
|
del params[field]
|
||||||
|
with patch('szurubooru.func.pools.create_pool'), \
|
||||||
|
patch('szurubooru.func.pools.serialize_pool'):
|
||||||
|
pools.create_pool.return_value = pool_factory()
|
||||||
|
api.pool_api.create_pool(
|
||||||
|
context_factory(
|
||||||
|
params=params,
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_create_pool_without_privileges(
|
||||||
|
user_factory, context_factory):
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.pool_api.create_pool(
|
||||||
|
context_factory(
|
||||||
|
params={
|
||||||
|
'names': ['pool'],
|
||||||
|
'category': 'default',
|
||||||
|
'posts': [],
|
||||||
|
},
|
||||||
|
user=user_factory(rank=model.User.RANK_ANONYMOUS)))
|
61
server/szurubooru/tests/api/test_pool_deleting.py
Normal file
61
server/szurubooru/tests/api/test_pool_deleting.py
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db, model, errors
|
||||||
|
from szurubooru.func import pools, snapshots
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({'privileges': {'pools:delete': model.User.RANK_REGULAR}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting(user_factory, pool_factory, context_factory):
|
||||||
|
auth_user = user_factory(rank=model.User.RANK_REGULAR)
|
||||||
|
pool = pool_factory(id=1)
|
||||||
|
db.session.add(pool)
|
||||||
|
db.session.commit()
|
||||||
|
with patch('szurubooru.func.snapshots.delete'):
|
||||||
|
result = api.pool_api.delete_pool(
|
||||||
|
context_factory(params={'version': 1}, user=auth_user),
|
||||||
|
{'pool_id': 1})
|
||||||
|
assert result == {}
|
||||||
|
assert db.session.query(model.Pool).count() == 0
|
||||||
|
snapshots.delete.assert_called_once_with(pool, auth_user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_used(
|
||||||
|
user_factory, pool_factory, context_factory, post_factory):
|
||||||
|
pool = pool_factory(id=1)
|
||||||
|
post = post_factory(id=1)
|
||||||
|
pool.posts.append(post)
|
||||||
|
db.session.add_all([pool, post])
|
||||||
|
db.session.commit()
|
||||||
|
api.pool_api.delete_pool(
|
||||||
|
context_factory(
|
||||||
|
params={'version': 1},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'pool_id': 1})
|
||||||
|
db.session.refresh(post)
|
||||||
|
assert db.session.query(model.Pool).count() == 0
|
||||||
|
assert db.session.query(model.PoolPost).count() == 0
|
||||||
|
assert post.pools == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_delete_non_existing(user_factory, context_factory):
|
||||||
|
with pytest.raises(pools.PoolNotFoundError):
|
||||||
|
api.pool_api.delete_pool(
|
||||||
|
context_factory(user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'pool_id': 9999})
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_delete_without_privileges(
|
||||||
|
user_factory, pool_factory, context_factory):
|
||||||
|
db.session.add(pool_factory(id=1))
|
||||||
|
db.session.commit()
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.pool_api.delete_pool(
|
||||||
|
context_factory(
|
||||||
|
params={'version': 1},
|
||||||
|
user=user_factory(rank=model.User.RANK_ANONYMOUS)),
|
||||||
|
{'pool_id': 1})
|
||||||
|
assert db.session.query(model.Pool).count() == 1
|
98
server/szurubooru/tests/api/test_pool_merging.py
Normal file
98
server/szurubooru/tests/api/test_pool_merging.py
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db, model, errors
|
||||||
|
from szurubooru.func import pools, snapshots
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({'privileges': {'pools:merge': model.User.RANK_REGULAR}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_merging(user_factory, pool_factory, context_factory, post_factory):
|
||||||
|
auth_user = user_factory(rank=model.User.RANK_REGULAR)
|
||||||
|
source_pool = pool_factory(id=1)
|
||||||
|
target_pool = pool_factory(id=2)
|
||||||
|
db.session.add_all([source_pool, target_pool])
|
||||||
|
db.session.flush()
|
||||||
|
assert source_pool.post_count == 0
|
||||||
|
assert target_pool.post_count == 0
|
||||||
|
post = post_factory(id=1)
|
||||||
|
source_pool.posts = [post]
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.commit()
|
||||||
|
assert source_pool.post_count == 1
|
||||||
|
assert target_pool.post_count == 0
|
||||||
|
with patch('szurubooru.func.pools.serialize_pool'), \
|
||||||
|
patch('szurubooru.func.pools.merge_pools'), \
|
||||||
|
patch('szurubooru.func.snapshots.merge'):
|
||||||
|
api.pool_api.merge_pools(
|
||||||
|
context_factory(
|
||||||
|
params={
|
||||||
|
'removeVersion': 1,
|
||||||
|
'mergeToVersion': 1,
|
||||||
|
'remove': 1,
|
||||||
|
'mergeTo': 2,
|
||||||
|
},
|
||||||
|
user=auth_user))
|
||||||
|
pools.merge_pools.called_once_with(source_pool, target_pool)
|
||||||
|
snapshots.merge.assert_called_once_with(
|
||||||
|
source_pool, target_pool, auth_user)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'field', ['remove', 'mergeTo', 'removeVersion', 'mergeToVersion'])
|
||||||
|
def test_trying_to_omit_mandatory_field(
|
||||||
|
user_factory, pool_factory, context_factory, field):
|
||||||
|
db.session.add_all([
|
||||||
|
pool_factory(id=1),
|
||||||
|
pool_factory(id=2),
|
||||||
|
])
|
||||||
|
db.session.commit()
|
||||||
|
params = {
|
||||||
|
'removeVersion': 1,
|
||||||
|
'mergeToVersion': 1,
|
||||||
|
'remove': 1,
|
||||||
|
'mergeTo': 2,
|
||||||
|
}
|
||||||
|
del params[field]
|
||||||
|
with pytest.raises(errors.ValidationError):
|
||||||
|
api.pool_api.merge_pools(
|
||||||
|
context_factory(
|
||||||
|
params=params,
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_merge_non_existing(
|
||||||
|
user_factory, pool_factory, context_factory):
|
||||||
|
db.session.add(pool_factory(id=1))
|
||||||
|
db.session.commit()
|
||||||
|
with pytest.raises(pools.PoolNotFoundError):
|
||||||
|
api.pool_api.merge_pools(
|
||||||
|
context_factory(
|
||||||
|
params={'remove': 1, 'mergeTo': 9999},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)))
|
||||||
|
with pytest.raises(pools.PoolNotFoundError):
|
||||||
|
api.pool_api.merge_pools(
|
||||||
|
context_factory(
|
||||||
|
params={'remove': 9999, 'mergeTo': 1},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_merge_without_privileges(
|
||||||
|
user_factory, pool_factory, context_factory):
|
||||||
|
db.session.add_all([
|
||||||
|
pool_factory(id=1),
|
||||||
|
pool_factory(id=2),
|
||||||
|
])
|
||||||
|
db.session.commit()
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.pool_api.merge_pools(
|
||||||
|
context_factory(
|
||||||
|
params={
|
||||||
|
'removeVersion': 1,
|
||||||
|
'mergeToVersion': 1,
|
||||||
|
'remove': 1,
|
||||||
|
'mergeTo': 2,
|
||||||
|
},
|
||||||
|
user=user_factory(rank=model.User.RANK_ANONYMOUS)))
|
72
server/szurubooru/tests/api/test_pool_retrieving.py
Normal file
72
server/szurubooru/tests/api/test_pool_retrieving.py
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db, model, errors
|
||||||
|
from szurubooru.func import pools
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({
|
||||||
|
'privileges': {
|
||||||
|
'pools:list': model.User.RANK_REGULAR,
|
||||||
|
'pools:view': model.User.RANK_REGULAR,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieving_multiple(user_factory, pool_factory, context_factory):
|
||||||
|
pool1 = pool_factory(id=1)
|
||||||
|
pool2 = pool_factory(id=2)
|
||||||
|
db.session.add_all([pool2, pool1])
|
||||||
|
db.session.flush()
|
||||||
|
with patch('szurubooru.func.pools.serialize_pool'):
|
||||||
|
pools.serialize_pool.return_value = 'serialized pool'
|
||||||
|
result = api.pool_api.get_pools(
|
||||||
|
context_factory(
|
||||||
|
params={'query': '', 'offset': 0},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)))
|
||||||
|
assert result == {
|
||||||
|
'query': '',
|
||||||
|
'offset': 0,
|
||||||
|
'limit': 100,
|
||||||
|
'total': 2,
|
||||||
|
'results': ['serialized pool', 'serialized pool'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_retrieve_multiple_without_privileges(
|
||||||
|
user_factory, context_factory):
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.pool_api.get_pools(
|
||||||
|
context_factory(
|
||||||
|
params={'query': '', 'offset': 0},
|
||||||
|
user=user_factory(rank=model.User.RANK_ANONYMOUS)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieving_single(user_factory, pool_factory, context_factory):
|
||||||
|
db.session.add(pool_factory(id=1))
|
||||||
|
db.session.flush()
|
||||||
|
with patch('szurubooru.func.pools.serialize_pool'):
|
||||||
|
pools.serialize_pool.return_value = 'serialized pool'
|
||||||
|
result = api.pool_api.get_pool(
|
||||||
|
context_factory(
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'pool_id': 1})
|
||||||
|
assert result == 'serialized pool'
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_retrieve_single_non_existing(user_factory, context_factory):
|
||||||
|
with pytest.raises(pools.PoolNotFoundError):
|
||||||
|
api.pool_api.get_pool(
|
||||||
|
context_factory(
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'pool_id': 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_retrieve_single_without_privileges(
|
||||||
|
user_factory, context_factory):
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.pool_api.get_pool(
|
||||||
|
context_factory(
|
||||||
|
user=user_factory(rank=model.User.RANK_ANONYMOUS)),
|
||||||
|
{'pool_id': 1})
|
131
server/szurubooru/tests/api/test_pool_updating.py
Normal file
131
server/szurubooru/tests/api/test_pool_updating.py
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db, model, errors
|
||||||
|
from szurubooru.func import pools, posts, snapshots
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({
|
||||||
|
'privileges': {
|
||||||
|
'pools:create': model.User.RANK_REGULAR,
|
||||||
|
'pools:edit:names': model.User.RANK_REGULAR,
|
||||||
|
'pools:edit:category': model.User.RANK_REGULAR,
|
||||||
|
'pools:edit:description': model.User.RANK_REGULAR,
|
||||||
|
'pools:edit:posts': model.User.RANK_REGULAR,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_updating(user_factory, pool_factory, context_factory):
|
||||||
|
auth_user = user_factory(rank=model.User.RANK_REGULAR)
|
||||||
|
pool = pool_factory(id=1, names=['pool1', 'pool2'])
|
||||||
|
db.session.add(pool)
|
||||||
|
db.session.commit()
|
||||||
|
with patch('szurubooru.func.pools.create_pool'), \
|
||||||
|
patch('szurubooru.func.posts.get_posts_by_ids'), \
|
||||||
|
patch('szurubooru.func.pools.update_pool_names'), \
|
||||||
|
patch('szurubooru.func.pools.update_pool_category_name'), \
|
||||||
|
patch('szurubooru.func.pools.update_pool_description'), \
|
||||||
|
patch('szurubooru.func.pools.update_pool_posts'), \
|
||||||
|
patch('szurubooru.func.pools.serialize_pool'), \
|
||||||
|
patch('szurubooru.func.snapshots.modify'):
|
||||||
|
posts.get_posts_by_ids.return_value = ([], [])
|
||||||
|
pools.serialize_pool.return_value = 'serialized pool'
|
||||||
|
result = api.pool_api.update_pool(
|
||||||
|
context_factory(
|
||||||
|
params={
|
||||||
|
'version': 1,
|
||||||
|
'names': ['pool3'],
|
||||||
|
'category': 'series',
|
||||||
|
'description': 'desc',
|
||||||
|
'posts': [1, 2]
|
||||||
|
},
|
||||||
|
user=auth_user),
|
||||||
|
{'pool_id': 1})
|
||||||
|
assert result == 'serialized pool'
|
||||||
|
pools.create_pool.assert_not_called()
|
||||||
|
pools.update_pool_names.assert_called_once_with(pool, ['pool3'])
|
||||||
|
pools.update_pool_category_name.assert_called_once_with(pool, 'series')
|
||||||
|
pools.update_pool_description.assert_called_once_with(pool, 'desc')
|
||||||
|
pools.update_pool_posts.assert_called_once_with(pool, [1, 2])
|
||||||
|
pools.serialize_pool.assert_called_once_with(pool, options=[])
|
||||||
|
snapshots.modify.assert_called_once_with(pool, auth_user)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'field', [
|
||||||
|
'names',
|
||||||
|
'category',
|
||||||
|
'description',
|
||||||
|
'posts',
|
||||||
|
])
|
||||||
|
def test_omitting_optional_field(
|
||||||
|
user_factory, pool_factory, context_factory, field):
|
||||||
|
db.session.add(pool_factory(id=1))
|
||||||
|
db.session.commit()
|
||||||
|
params = {
|
||||||
|
'names': ['pool1', 'pool2'],
|
||||||
|
'category': 'default',
|
||||||
|
'description': 'desc',
|
||||||
|
'posts': [],
|
||||||
|
}
|
||||||
|
del params[field]
|
||||||
|
with patch('szurubooru.func.pools.create_pool'), \
|
||||||
|
patch('szurubooru.func.pools.update_pool_names'), \
|
||||||
|
patch('szurubooru.func.pools.update_pool_category_name'), \
|
||||||
|
patch('szurubooru.func.pools.serialize_pool'):
|
||||||
|
api.pool_api.update_pool(
|
||||||
|
context_factory(
|
||||||
|
params={**params, **{'version': 1}},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'pool_id': 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_update_non_existing(user_factory, context_factory):
|
||||||
|
with pytest.raises(pools.PoolNotFoundError):
|
||||||
|
api.pool_api.update_pool(
|
||||||
|
context_factory(
|
||||||
|
params={'names': ['dummy']},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'pool_id': 9999})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('params', [
|
||||||
|
{'names': ['whatever']},
|
||||||
|
{'category': 'whatever'},
|
||||||
|
{'posts': [1]},
|
||||||
|
])
|
||||||
|
def test_trying_to_update_without_privileges(
|
||||||
|
user_factory, pool_factory, context_factory, params):
|
||||||
|
db.session.add(pool_factory(id=1))
|
||||||
|
db.session.commit()
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.pool_api.update_pool(
|
||||||
|
context_factory(
|
||||||
|
params={**params, **{'version': 1}},
|
||||||
|
user=user_factory(rank=model.User.RANK_ANONYMOUS)),
|
||||||
|
{'pool_id': 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_trying_to_create_pools_without_privileges(
|
||||||
|
config_injector, context_factory, pool_factory, user_factory):
|
||||||
|
pool = pool_factory(id=1)
|
||||||
|
db.session.add(pool)
|
||||||
|
db.session.commit()
|
||||||
|
config_injector(
|
||||||
|
{
|
||||||
|
'privileges': {
|
||||||
|
'pools:create': model.User.RANK_ADMINISTRATOR,
|
||||||
|
'pools:edit:posts': model.User.RANK_REGULAR,
|
||||||
|
},
|
||||||
|
'delete_source_files': False,
|
||||||
|
})
|
||||||
|
with patch('szurubooru.func.posts.get_posts_by_ids'):
|
||||||
|
posts.get_posts_by_ids.return_value = ([], ['new-post'])
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.pool_api.create_pool(
|
||||||
|
context_factory(
|
||||||
|
params={'posts': [1, 2], 'version': 1},
|
||||||
|
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||||
|
{'pool_id': 1})
|
|
@ -201,6 +201,53 @@ def post_favorite_factory(user_factory, post_factory):
|
||||||
return factory
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pool_category_factory():
|
||||||
|
def factory(name=None, color='dummy', default=False):
|
||||||
|
category = model.PoolCategory()
|
||||||
|
category.name = name or get_unique_name()
|
||||||
|
category.color = color
|
||||||
|
category.default = default
|
||||||
|
return category
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pool_factory():
|
||||||
|
def factory(
|
||||||
|
id=None, names=None, description=None, category=None, time=None):
|
||||||
|
if not category:
|
||||||
|
category = model.PoolCategory(get_unique_name())
|
||||||
|
db.session.add(category)
|
||||||
|
pool = model.Pool()
|
||||||
|
pool.pool_id = id
|
||||||
|
pool.names = []
|
||||||
|
for i, name in enumerate(names or [get_unique_name()]):
|
||||||
|
pool.names.append(model.PoolName(name, i))
|
||||||
|
pool.description = description
|
||||||
|
pool.category = category
|
||||||
|
pool.creation_time = time or datetime(1996, 1, 1)
|
||||||
|
return pool
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pool_post_factory(pool_factory, post_factory):
|
||||||
|
def factory(pool=None, post=None, order=None):
|
||||||
|
if not pool:
|
||||||
|
pool = pool_factory()
|
||||||
|
db.session.add(pool)
|
||||||
|
if not post:
|
||||||
|
post = post_factory()
|
||||||
|
db.session.add(post)
|
||||||
|
pool_post = model.PoolPost(post)
|
||||||
|
pool_post.pool = pool
|
||||||
|
pool_post.post = post
|
||||||
|
pool_post.order = order or 0
|
||||||
|
return pool_post
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def read_asset():
|
def read_asset():
|
||||||
def get(path):
|
def get(path):
|
||||||
|
|
|
@ -79,6 +79,8 @@ def test_serialize_post(
|
||||||
comment_factory,
|
comment_factory,
|
||||||
tag_factory,
|
tag_factory,
|
||||||
tag_category_factory,
|
tag_category_factory,
|
||||||
|
pool_factory,
|
||||||
|
pool_category_factory,
|
||||||
config_injector):
|
config_injector):
|
||||||
config_injector({'data_url': 'http://example.com/', 'secret': 'test'})
|
config_injector({'data_url': 'http://example.com/', 'secret': 'test'})
|
||||||
with patch('szurubooru.func.comments.serialize_comment'), \
|
with patch('szurubooru.func.comments.serialize_comment'), \
|
||||||
|
@ -150,6 +152,23 @@ def test_serialize_post(
|
||||||
time=datetime(1800, 1, 1))])
|
time=datetime(1800, 1, 1))])
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
pool1 = pool_factory(id=1,
|
||||||
|
names=['pool1', 'pool2'],
|
||||||
|
description='desc',
|
||||||
|
category=pool_category_factory('test-cat1'))
|
||||||
|
pool1.last_edit_time = datetime(1998, 1, 1)
|
||||||
|
pool1.posts.append(post)
|
||||||
|
|
||||||
|
pool2 = pool_factory(id=2,
|
||||||
|
names=['pool3'],
|
||||||
|
description='desc2',
|
||||||
|
category=pool_category_factory('test-cat2'))
|
||||||
|
pool2.last_edit_time = datetime(1998, 1, 1)
|
||||||
|
pool2.posts.append(post)
|
||||||
|
|
||||||
|
db.session.add_all([pool1, pool2])
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
result = posts.serialize_post(post, auth_user)
|
result = posts.serialize_post(post, auth_user)
|
||||||
result['tags'].sort(key=lambda tag: tag['names'][0])
|
result['tags'].sort(key=lambda tag: tag['names'][0])
|
||||||
|
|
||||||
|
@ -183,6 +202,44 @@ def test_serialize_post(
|
||||||
],
|
],
|
||||||
'relations': [],
|
'relations': [],
|
||||||
'notes': [],
|
'notes': [],
|
||||||
|
'pools': [
|
||||||
|
{
|
||||||
|
'id': 1,
|
||||||
|
'names': ['pool1', 'pool2'],
|
||||||
|
'description': 'desc',
|
||||||
|
'category': 'test-cat1',
|
||||||
|
'postCount': 1,
|
||||||
|
'posts': [
|
||||||
|
{
|
||||||
|
'id': 1,
|
||||||
|
'thumbnailUrl':
|
||||||
|
'http://example.com/'
|
||||||
|
'generated-thumbnails/1_244c8840887984c4.jpg',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'version': 1,
|
||||||
|
'creationTime': datetime(1996, 1, 1),
|
||||||
|
'lastEditTime': datetime(1998, 1, 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 2,
|
||||||
|
'names': ['pool3'],
|
||||||
|
'description': 'desc2',
|
||||||
|
'category': 'test-cat2',
|
||||||
|
'postCount': 1,
|
||||||
|
'posts': [
|
||||||
|
{
|
||||||
|
'id': 1,
|
||||||
|
'thumbnailUrl':
|
||||||
|
'http://example.com/'
|
||||||
|
'generated-thumbnails/1_244c8840887984c4.jpg',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'version': 1,
|
||||||
|
'creationTime': datetime(1996, 1, 1),
|
||||||
|
'lastEditTime': datetime(1998, 1, 1),
|
||||||
|
}
|
||||||
|
],
|
||||||
'user': 'post author',
|
'user': 'post author',
|
||||||
'score': 1,
|
'score': 1,
|
||||||
'ownFavorite': False,
|
'ownFavorite': False,
|
||||||
|
|
97
server/szurubooru/tests/model/test_pool.py
Normal file
97
server/szurubooru/tests/model/test_pool.py
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
from datetime import datetime
|
||||||
|
import pytest
|
||||||
|
from szurubooru import db, model
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({
|
||||||
|
'delete_source_files': False,
|
||||||
|
'secret': 'secret',
|
||||||
|
'data_dir': ''
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_pool(pool_factory, post_factory):
|
||||||
|
post1 = post_factory()
|
||||||
|
post2 = post_factory()
|
||||||
|
pool = model.Pool()
|
||||||
|
pool.names = [model.PoolName('alias1', 0), model.PoolName('alias2', 1)]
|
||||||
|
pool.posts = []
|
||||||
|
pool.category = model.PoolCategory('category')
|
||||||
|
pool.creation_time = datetime(1997, 1, 1)
|
||||||
|
pool.last_edit_time = datetime(1998, 1, 1)
|
||||||
|
db.session.add_all([pool, post1, post2])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
assert pool.pool_id is not None
|
||||||
|
pool.posts.append(post1)
|
||||||
|
pool.posts.append(post2)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
pool = (
|
||||||
|
db.session
|
||||||
|
.query(model.Pool)
|
||||||
|
.join(model.PoolName)
|
||||||
|
.filter(model.PoolName.name == 'alias1')
|
||||||
|
.one())
|
||||||
|
assert [pool_name.name for pool_name in pool.names] == ['alias1', 'alias2']
|
||||||
|
assert pool.category.name == 'category'
|
||||||
|
assert pool.creation_time == datetime(1997, 1, 1)
|
||||||
|
assert pool.last_edit_time == datetime(1998, 1, 1)
|
||||||
|
assert [post.post_id for post in pool.posts] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cascade_deletions(pool_factory, post_factory):
|
||||||
|
post1 = post_factory()
|
||||||
|
post2 = post_factory()
|
||||||
|
pool = model.Pool()
|
||||||
|
pool.names = [model.PoolName('alias1', 0), model.PoolName('alias2', 1)]
|
||||||
|
pool.posts = []
|
||||||
|
pool.category = model.PoolCategory('category')
|
||||||
|
pool.creation_time = datetime(1997, 1, 1)
|
||||||
|
pool.last_edit_time = datetime(1998, 1, 1)
|
||||||
|
db.session.add_all([pool, post1, post2])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
assert pool.pool_id is not None
|
||||||
|
pool.posts.append(post1)
|
||||||
|
pool.posts.append(post2)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
db.session.delete(pool)
|
||||||
|
db.session.commit()
|
||||||
|
assert db.session.query(model.Pool).count() == 0
|
||||||
|
assert db.session.query(model.PoolName).count() == 0
|
||||||
|
assert db.session.query(model.PoolPost).count() == 0
|
||||||
|
assert db.session.query(model.PoolCategory).count() == 1
|
||||||
|
assert db.session.query(model.Post).count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_tracking_post_count(post_factory, pool_factory):
|
||||||
|
pool1 = pool_factory()
|
||||||
|
pool2 = pool_factory()
|
||||||
|
post1 = post_factory()
|
||||||
|
post2 = post_factory()
|
||||||
|
db.session.add_all([pool1, pool2, post1, post2])
|
||||||
|
db.session.flush()
|
||||||
|
assert pool1.pool_id is not None
|
||||||
|
assert pool2.pool_id is not None
|
||||||
|
pool1.posts.append(post1)
|
||||||
|
pool2.posts.append(post1)
|
||||||
|
pool2.posts.append(post2)
|
||||||
|
db.session.commit()
|
||||||
|
assert len(post1.pools) == 2
|
||||||
|
assert len(post2.pools) == 1
|
||||||
|
assert pool1.post_count == 1
|
||||||
|
assert pool2.post_count == 2
|
||||||
|
db.session.delete(post1)
|
||||||
|
db.session.commit()
|
||||||
|
db.session.refresh(pool1)
|
||||||
|
db.session.refresh(pool2)
|
||||||
|
assert pool1.post_count == 0
|
||||||
|
assert pool2.post_count == 1
|
||||||
|
db.session.delete(post2)
|
||||||
|
db.session.commit()
|
||||||
|
db.session.refresh(pool2)
|
||||||
|
assert pool2.post_count == 0
|
|
@ -0,0 +1,350 @@
|
||||||
|
# pylint: disable=redefined-outer-name
|
||||||
|
from datetime import datetime
|
||||||
|
import pytest
|
||||||
|
from szurubooru import db, errors, search
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def executor():
|
||||||
|
return search.Executor(search.configs.PoolSearchConfig())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def verify_unpaged(executor):
|
||||||
|
def verify(input, expected_pool_names):
|
||||||
|
actual_count, actual_pools = executor.execute(
|
||||||
|
input, offset=0, limit=100)
|
||||||
|
actual_pool_names = [u.names[0].name for u in actual_pools]
|
||||||
|
assert actual_count == len(expected_pool_names)
|
||||||
|
assert actual_pool_names == expected_pool_names
|
||||||
|
return verify
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input,expected_pool_names', [
|
||||||
|
('', ['t1', 't2']),
|
||||||
|
('t1', ['t1']),
|
||||||
|
('t2', ['t2']),
|
||||||
|
('t1,t2', ['t1', 't2']),
|
||||||
|
('T1,T2', ['t1', 't2']),
|
||||||
|
])
|
||||||
|
def test_filter_anonymous(
|
||||||
|
verify_unpaged, pool_factory, input, expected_pool_names):
|
||||||
|
db.session.add(pool_factory(id=1, names=['t1']))
|
||||||
|
db.session.add(pool_factory(id=2, names=['t2']))
|
||||||
|
db.session.flush()
|
||||||
|
verify_unpaged(input, expected_pool_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('db_driver,input,expected_pool_names', [
|
||||||
|
(None, ',', None),
|
||||||
|
(None, 't1,', None),
|
||||||
|
(None, 't1,t2', ['t1', 't2']),
|
||||||
|
(None, 't1\\,', []),
|
||||||
|
(None, 'asd..asd', None),
|
||||||
|
(None, 'asd\\..asd', []),
|
||||||
|
(None, 'asd.\\.asd', []),
|
||||||
|
(None, 'asd\\.\\.asd', []),
|
||||||
|
(None, '-', None),
|
||||||
|
(None, '\\-', ['-']),
|
||||||
|
(None, '--', [
|
||||||
|
't1', 't2', '*', '*asd*', ':', 'asd:asd', '\\', '\\asd', '-asd',
|
||||||
|
]),
|
||||||
|
(None, '\\--', []),
|
||||||
|
(None, '-\\-', [
|
||||||
|
't1', 't2', '*', '*asd*', ':', 'asd:asd', '\\', '\\asd', '-asd',
|
||||||
|
]),
|
||||||
|
(None, '-*', []),
|
||||||
|
(None, '\\-*', ['-', '-asd']),
|
||||||
|
(None, ':', None),
|
||||||
|
(None, '\\:', [':']),
|
||||||
|
(None, '\\:asd', []),
|
||||||
|
(None, '*\\:*', [':', 'asd:asd']),
|
||||||
|
(None, 'asd:asd', None),
|
||||||
|
(None, 'asd\\:asd', ['asd:asd']),
|
||||||
|
(None, '*', [
|
||||||
|
't1', 't2', '*', '*asd*', ':', 'asd:asd', '\\', '\\asd', '-', '-asd'
|
||||||
|
]),
|
||||||
|
(None, '\\*', ['*']),
|
||||||
|
(None, '\\', None),
|
||||||
|
(None, '\\asd', None),
|
||||||
|
('psycopg2', '\\\\', ['\\']),
|
||||||
|
('psycopg2', '\\\\asd', ['\\asd']),
|
||||||
|
])
|
||||||
|
def test_escaping(
|
||||||
|
executor, pool_factory, input, expected_pool_names, db_driver):
|
||||||
|
db.session.add_all([
|
||||||
|
pool_factory(id=1, names=['t1']),
|
||||||
|
pool_factory(id=2, names=['t2']),
|
||||||
|
pool_factory(id=3, names=['*']),
|
||||||
|
pool_factory(id=4, names=['*asd*']),
|
||||||
|
pool_factory(id=5, names=[':']),
|
||||||
|
pool_factory(id=6, names=['asd:asd']),
|
||||||
|
pool_factory(id=7, names=['\\']),
|
||||||
|
pool_factory(id=8, names=['\\asd']),
|
||||||
|
pool_factory(id=9, names=['-']),
|
||||||
|
pool_factory(id=10, names=['-asd'])
|
||||||
|
])
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
if db_driver and db.session.get_bind().driver != db_driver:
|
||||||
|
pytest.xfail()
|
||||||
|
if expected_pool_names is None:
|
||||||
|
with pytest.raises(errors.SearchError):
|
||||||
|
executor.execute(input, offset=0, limit=100)
|
||||||
|
else:
|
||||||
|
actual_count, actual_pools = executor.execute(
|
||||||
|
input, offset=0, limit=100)
|
||||||
|
actual_pool_names = [u.names[0].name for u in actual_pools]
|
||||||
|
assert actual_count == len(expected_pool_names)
|
||||||
|
assert sorted(actual_pool_names) == sorted(expected_pool_names)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_anonymous_starting_with_colon(verify_unpaged, pool_factory):
|
||||||
|
db.session.add(pool_factory(id=1, names=[':t']))
|
||||||
|
db.session.flush()
|
||||||
|
with pytest.raises(errors.SearchError):
|
||||||
|
verify_unpaged(':t', [':t'])
|
||||||
|
verify_unpaged('\\:t', [':t'])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input,expected_pool_names', [
|
||||||
|
('name:pool1', ['pool1']),
|
||||||
|
('name:pool2', ['pool2']),
|
||||||
|
('name:none', []),
|
||||||
|
('name:', []),
|
||||||
|
('name:*1', ['pool1']),
|
||||||
|
('name:*2', ['pool2']),
|
||||||
|
('name:*', ['pool1', 'pool2', 'pool3', 'pool4']),
|
||||||
|
('name:p*', ['pool1', 'pool2', 'pool3', 'pool4']),
|
||||||
|
('name:*o*', ['pool1', 'pool2', 'pool3', 'pool4']),
|
||||||
|
('name:*!*', []),
|
||||||
|
('name:!*', []),
|
||||||
|
('name:*!', []),
|
||||||
|
('-name:pool1', ['pool2', 'pool3', 'pool4']),
|
||||||
|
('-name:pool2', ['pool1', 'pool3', 'pool4']),
|
||||||
|
('name:pool1,pool2', ['pool1', 'pool2']),
|
||||||
|
('-name:pool1,pool3', ['pool2', 'pool4']),
|
||||||
|
('name:pool4', ['pool4']),
|
||||||
|
('name:pool5', ['pool4']),
|
||||||
|
('name:pool4,pool5', ['pool4']),
|
||||||
|
])
|
||||||
|
def test_filter_by_name(
|
||||||
|
verify_unpaged, pool_factory, input, expected_pool_names):
|
||||||
|
db.session.add(pool_factory(id=1, names=['pool1']))
|
||||||
|
db.session.add(pool_factory(id=2, names=['pool2']))
|
||||||
|
db.session.add(pool_factory(id=3, names=['pool3']))
|
||||||
|
db.session.add(pool_factory(id=4, names=['pool4', 'pool5', 'pool6']))
|
||||||
|
db.session.flush()
|
||||||
|
verify_unpaged(input, expected_pool_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input,expected_pool_names', [
|
||||||
|
('category:cat1', ['t1', 't2']),
|
||||||
|
('category:cat2', ['t3']),
|
||||||
|
('category:cat1,cat2', ['t1', 't2', 't3']),
|
||||||
|
])
|
||||||
|
def test_filter_by_category(
|
||||||
|
verify_unpaged,
|
||||||
|
pool_factory,
|
||||||
|
pool_category_factory,
|
||||||
|
input,
|
||||||
|
expected_pool_names):
|
||||||
|
cat1 = pool_category_factory(name='cat1')
|
||||||
|
cat2 = pool_category_factory(name='cat2')
|
||||||
|
pool1 = pool_factory(id=1, names=['t1'], category=cat1)
|
||||||
|
pool2 = pool_factory(id=2, names=['t2'], category=cat1)
|
||||||
|
pool3 = pool_factory(id=3, names=['t3'], category=cat2)
|
||||||
|
db.session.add_all([pool1, pool2, pool3])
|
||||||
|
db.session.flush()
|
||||||
|
verify_unpaged(input, expected_pool_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input,expected_pool_names', [
|
||||||
|
('creation-time:2014', ['t1', 't2']),
|
||||||
|
('creation-date:2014', ['t1', 't2']),
|
||||||
|
('-creation-time:2014', ['t3']),
|
||||||
|
('-creation-date:2014', ['t3']),
|
||||||
|
('creation-time:2014..2014-06', ['t1', 't2']),
|
||||||
|
('creation-time:2014-06..2015-01-01', ['t2', 't3']),
|
||||||
|
('creation-time:2014-06..', ['t2', 't3']),
|
||||||
|
('creation-time:..2014-06', ['t1', 't2']),
|
||||||
|
('-creation-time:2014..2014-06', ['t3']),
|
||||||
|
('-creation-time:2014-06..2015-01-01', ['t1']),
|
||||||
|
('creation-date:2014..2014-06', ['t1', 't2']),
|
||||||
|
('creation-date:2014-06..2015-01-01', ['t2', 't3']),
|
||||||
|
('creation-date:2014-06..', ['t2', 't3']),
|
||||||
|
('creation-date:..2014-06', ['t1', 't2']),
|
||||||
|
('-creation-date:2014..2014-06', ['t3']),
|
||||||
|
('-creation-date:2014-06..2015-01-01', ['t1']),
|
||||||
|
('creation-time:2014-01,2015', ['t1', 't3']),
|
||||||
|
('creation-date:2014-01,2015', ['t1', 't3']),
|
||||||
|
('-creation-time:2014-01,2015', ['t2']),
|
||||||
|
('-creation-date:2014-01,2015', ['t2']),
|
||||||
|
])
|
||||||
|
def test_filter_by_creation_time(
|
||||||
|
verify_unpaged, pool_factory, input, expected_pool_names):
|
||||||
|
pool1 = pool_factory(id=1, names=['t1'])
|
||||||
|
pool2 = pool_factory(id=2, names=['t2'])
|
||||||
|
pool3 = pool_factory(id=3, names=['t3'])
|
||||||
|
pool1.creation_time = datetime(2014, 1, 1)
|
||||||
|
pool2.creation_time = datetime(2014, 6, 1)
|
||||||
|
pool3.creation_time = datetime(2015, 1, 1)
|
||||||
|
db.session.add_all([pool1, pool2, pool3])
|
||||||
|
db.session.flush()
|
||||||
|
verify_unpaged(input, expected_pool_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input,expected_pool_names', [
|
||||||
|
('last-edit-date:2014', ['t1', 't3']),
|
||||||
|
('last-edit-time:2014', ['t1', 't3']),
|
||||||
|
('edit-date:2014', ['t1', 't3']),
|
||||||
|
('edit-time:2014', ['t1', 't3']),
|
||||||
|
])
|
||||||
|
def test_filter_by_edit_time(
|
||||||
|
verify_unpaged, pool_factory, input, expected_pool_names):
|
||||||
|
pool1 = pool_factory(id=1, names=['t1'])
|
||||||
|
pool2 = pool_factory(id=2, names=['t2'])
|
||||||
|
pool3 = pool_factory(id=3, names=['t3'])
|
||||||
|
pool1.last_edit_time = datetime(2014, 1, 1)
|
||||||
|
pool2.last_edit_time = datetime(2015, 1, 1)
|
||||||
|
pool3.last_edit_time = datetime(2014, 1, 1)
|
||||||
|
db.session.add_all([pool1, pool2, pool3])
|
||||||
|
db.session.flush()
|
||||||
|
verify_unpaged(input, expected_pool_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input,expected_pool_names', [
|
||||||
|
('post-count:2', ['t1']),
|
||||||
|
('post-count:1', ['t2']),
|
||||||
|
('post-count:1..', ['t1', 't2']),
|
||||||
|
('post-count-min:1', ['t1', 't2']),
|
||||||
|
('post-count:..1', ['t2']),
|
||||||
|
('post-count-max:1', ['t2']),
|
||||||
|
])
|
||||||
|
def test_filter_by_post_count(
|
||||||
|
verify_unpaged,
|
||||||
|
pool_factory,
|
||||||
|
post_factory,
|
||||||
|
input,
|
||||||
|
expected_pool_names):
|
||||||
|
post1 = post_factory(id=1)
|
||||||
|
post2 = post_factory(id=2)
|
||||||
|
pool1 = pool_factory(id=1, names=['t1'])
|
||||||
|
pool2 = pool_factory(id=2, names=['t2'])
|
||||||
|
db.session.add_all([post1, post2, pool1, pool2])
|
||||||
|
pool1.posts.append(post1)
|
||||||
|
pool1.posts.append(post2)
|
||||||
|
pool2.posts.append(post1)
|
||||||
|
db.session.flush()
|
||||||
|
verify_unpaged(input, expected_pool_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input', [
|
||||||
|
'post-count:..',
|
||||||
|
'post-count:asd',
|
||||||
|
'post-count:asd,1',
|
||||||
|
'post-count:1,asd',
|
||||||
|
'post-count:asd..1',
|
||||||
|
'post-count:1..asd',
|
||||||
|
])
|
||||||
|
def test_filter_by_invalid_input(executor, input):
|
||||||
|
with pytest.raises(errors.SearchError):
|
||||||
|
executor.execute(input, offset=0, limit=100)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input,expected_pool_names', [
|
||||||
|
('', ['t1', 't2']),
|
||||||
|
('sort:name', ['t1', 't2']),
|
||||||
|
('-sort:name', ['t2', 't1']),
|
||||||
|
('sort:name,asc', ['t1', 't2']),
|
||||||
|
('sort:name,desc', ['t2', 't1']),
|
||||||
|
('-sort:name,asc', ['t2', 't1']),
|
||||||
|
('-sort:name,desc', ['t1', 't2']),
|
||||||
|
])
|
||||||
|
def test_sort_by_name(
|
||||||
|
verify_unpaged,
|
||||||
|
pool_factory,
|
||||||
|
input,
|
||||||
|
expected_pool_names):
|
||||||
|
db.session.add(pool_factory(id=2, names=['t2']))
|
||||||
|
db.session.add(pool_factory(id=1, names=['t1']))
|
||||||
|
db.session.flush()
|
||||||
|
verify_unpaged(input, expected_pool_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input,expected_pool_names', [
|
||||||
|
('', ['t1', 't2', 't3']),
|
||||||
|
('sort:creation-date', ['t3', 't2', 't1']),
|
||||||
|
('sort:creation-time', ['t3', 't2', 't1']),
|
||||||
|
])
|
||||||
|
def test_sort_by_creation_time(
|
||||||
|
verify_unpaged, pool_factory, input, expected_pool_names):
|
||||||
|
pool1 = pool_factory(id=1, names=['t1'])
|
||||||
|
pool2 = pool_factory(id=2, names=['t2'])
|
||||||
|
pool3 = pool_factory(id=3, names=['t3'])
|
||||||
|
pool1.creation_time = datetime(1991, 1, 1)
|
||||||
|
pool2.creation_time = datetime(1991, 1, 2)
|
||||||
|
pool3.creation_time = datetime(1991, 1, 3)
|
||||||
|
db.session.add_all([pool3, pool1, pool2])
|
||||||
|
db.session.flush()
|
||||||
|
verify_unpaged(input, expected_pool_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input,expected_pool_names', [
|
||||||
|
('', ['t1', 't2', 't3']),
|
||||||
|
('sort:last-edit-date', ['t3', 't2', 't1']),
|
||||||
|
('sort:last-edit-time', ['t3', 't2', 't1']),
|
||||||
|
('sort:edit-date', ['t3', 't2', 't1']),
|
||||||
|
('sort:edit-time', ['t3', 't2', 't1']),
|
||||||
|
])
|
||||||
|
def test_sort_by_last_edit_time(
|
||||||
|
verify_unpaged, pool_factory, input, expected_pool_names):
|
||||||
|
pool1 = pool_factory(id=1, names=['t1'])
|
||||||
|
pool2 = pool_factory(id=2, names=['t2'])
|
||||||
|
pool3 = pool_factory(id=3, names=['t3'])
|
||||||
|
pool1.last_edit_time = datetime(1991, 1, 1)
|
||||||
|
pool2.last_edit_time = datetime(1991, 1, 2)
|
||||||
|
pool3.last_edit_time = datetime(1991, 1, 3)
|
||||||
|
db.session.add_all([pool3, pool1, pool2])
|
||||||
|
db.session.flush()
|
||||||
|
verify_unpaged(input, expected_pool_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input,expected_pool_names', [
|
||||||
|
('sort:post-count', ['t2', 't1']),
|
||||||
|
])
|
||||||
|
def test_sort_by_post_count(
|
||||||
|
verify_unpaged,
|
||||||
|
pool_factory,
|
||||||
|
post_factory,
|
||||||
|
input,
|
||||||
|
expected_pool_names):
|
||||||
|
post1 = post_factory(id=1)
|
||||||
|
post2 = post_factory(id=2)
|
||||||
|
pool1 = pool_factory(id=1, names=['t1'])
|
||||||
|
pool2 = pool_factory(id=2, names=['t2'])
|
||||||
|
db.session.add_all([post1, post2, pool1, pool2])
|
||||||
|
pool1.posts.append(post1)
|
||||||
|
pool2.posts.append(post1)
|
||||||
|
pool2.posts.append(post2)
|
||||||
|
db.session.flush()
|
||||||
|
verify_unpaged(input, expected_pool_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input,expected_pool_names', [
|
||||||
|
('sort:category', ['t3', 't1', 't2']),
|
||||||
|
])
|
||||||
|
def test_sort_by_category(
|
||||||
|
verify_unpaged,
|
||||||
|
pool_factory,
|
||||||
|
pool_category_factory,
|
||||||
|
input,
|
||||||
|
expected_pool_names):
|
||||||
|
cat1 = pool_category_factory(name='cat1')
|
||||||
|
cat2 = pool_category_factory(name='cat2')
|
||||||
|
pool1 = pool_factory(id=1, names=['t1'], category=cat2)
|
||||||
|
pool2 = pool_factory(id=2, names=['t2'], category=cat2)
|
||||||
|
pool3 = pool_factory(id=3, names=['t3'], category=cat1)
|
||||||
|
db.session.add_all([pool1, pool2, pool3])
|
||||||
|
db.session.flush()
|
||||||
|
verify_unpaged(input, expected_pool_names)
|
Loading…
Reference in a new issue