diff --git a/README.md b/README.md index 98c0c0c5..47e6253d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*. - Tag suggestions - Tag implications (adding a tag automatically adds another) - Tag aliases +- Pools and pool categories - Duplicate detection - Post rating and favoriting; comment rating - Polished UI diff --git a/client/build.js b/client/build.js index 44d8c010..19c7aeb3 100755 --- a/client/build.js +++ b/client/build.js @@ -35,7 +35,7 @@ const app_manifest = { src: baseUrl() + 'img/android-chrome-192x192.png', type: 'image/png', sizes: '192x192' - }, + }, { src: baseUrl() + 'img/android-chrome-512x512.png', type: 'image/png', @@ -301,8 +301,12 @@ function makeOutputDirs() { makeOutputDirs(); bundleConfig(); -bundleBinaryAssets(); -bundleWebAppFiles(); +if (!process.argv.includes('--no-binary-assets')) { + bundleBinaryAssets(); +} +if (!process.argv.includes('--no-web-app-files')) { + bundleWebAppFiles(); +} if (!process.argv.includes('--no-html')) { bundleHtml(); } diff --git a/client/css/pool-categories-view.styl b/client/css/pool-categories-view.styl new file mode 100644 index 00000000..3e876355 --- /dev/null +++ b/client/css/pool-categories-view.styl @@ -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 + diff --git a/client/css/pool-input-control.styl b/client/css/pool-input-control.styl new file mode 100644 index 00000000..3f71abe8 --- /dev/null +++ b/client/css/pool-input-control.styl @@ -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 diff --git a/client/css/pool-list-view.styl b/client/css/pool-list-view.styl new file mode 100644 index 00000000..0f57649b --- /dev/null +++ b/client/css/pool-list-view.styl @@ -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 diff --git a/client/css/pool-view.styl b/client/css/pool-view.styl new file mode 100644 index 00000000..bf16b798 --- /dev/null +++ b/client/css/pool-view.styl @@ -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 diff --git a/client/html/help_search.tpl b/client/html/help_search.tpl index 70737893..e382f2ef 100644 --- a/client/html/help_search.tpl +++ b/client/html/help_search.tpl @@ -4,6 +4,7 @@ -->
  • '>Posts
  • '>Users
  • '>Tags
  • '>Pools
  • diff --git a/client/html/help_search_pools.tpl b/client/html/help_search_pools.tpl new file mode 100644 index 00000000..a1f0c800 --- /dev/null +++ b/client/html/help_search_pools.tpl @@ -0,0 +1,97 @@ +

    Anonymous tokens

    + +

    Same as name token.

    + +

    Named tokens

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    namehaving given name (accepts wildcards)
    categoryhaving given category (accepts wildcards)
    creation-datecreated at given date
    creation-timealias of creation-date
    last-edit-dateedited at given date
    last-edit-timealias of last-edit-date
    edit-datealias of last-edit-date
    edit-timealias of last-edit-date
    post-countalias of usages
    + +

    Sort style tokens

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    randomas random as it can get
    nameA to Z
    categorycategory (A to Z)
    creation-daterecently created first
    creation-timealias of creation-date
    last-edit-daterecently edited first
    last-edit-timealias of creation-time
    edit-datealias of creation-time
    edit-timealias of creation-time
    post-countnumber of posts
    + +

    Special tokens

    + +

    None.

    diff --git a/client/html/help_search_posts.tpl b/client/html/help_search_posts.tpl index d1097b79..e6cb707b 100644 --- a/client/html/help_search_posts.tpl +++ b/client/html/help_search_posts.tpl @@ -20,7 +20,7 @@ uploader - uploaded by given use (accepts wildcards)r + uploaded by given user (accepts wildcards) upload @@ -42,6 +42,10 @@ source having given source URL (accepts wildcards) + + pool + belonging to the pool with the given ID + tag-count having given number of tags diff --git a/client/html/pool.tpl b/client/html/pool.tpl new file mode 100644 index 00000000..e1e5311f --- /dev/null +++ b/client/html/pool.tpl @@ -0,0 +1,18 @@ +
    +

    <%- ctx.pool.names[0] %>

    +
    +
    +
    diff --git a/client/html/pool_categories.tpl b/client/html/pool_categories.tpl new file mode 100644 index 00000000..b0450386 --- /dev/null +++ b/client/html/pool_categories.tpl @@ -0,0 +1,30 @@ +
    +
    +

    Pool categories

    +
    + + + + + + + + + + +
    Category nameCSS colorUsages
    +
    + + <% if (ctx.canCreate) { %> +

    Add new category

    + <% } %> + +
    + + <% if (ctx.canCreate || ctx.canEditName || ctx.canEditColor || ctx.canDelete) { %> +
    + +
    + <% } %> +
    +
    diff --git a/client/html/pool_category_row.tpl b/client/html/pool_category_row.tpl new file mode 100644 index 00000000..5ff4456c --- /dev/null +++ b/client/html/pool_category_row.tpl @@ -0,0 +1,43 @@ +<% if (ctx.poolCategory.isDefault) { %><% + %><% +%><% } else { %><% + %><% +%><% } %> + + <% if (ctx.canEditName) { %> + <%= ctx.makeTextInput({value: ctx.poolCategory.name, required: true}) %> + <% } else { %> + <%- ctx.poolCategory.name %> + <% } %> + + + <% if (ctx.canEditColor) { %> + <%= ctx.makeColorInput({value: ctx.poolCategory.color}) %> + <% } else { %> + <%- ctx.poolCategory.color %> + <% } %> + + + <% if (ctx.poolCategory.name) { %> + '> + <%- ctx.poolCategory.poolCount %> + + <% } else { %> + <%- ctx.poolCategory.poolCount %> + <% } %> + + <% if (ctx.canDelete) { %> + + <% if (ctx.poolCategory.poolCount) { %> + Remove + <% } else { %> + Remove + <% } %> + + <% } %> + <% if (ctx.canSetDefault) { %> + + Make default + + <% } %> + diff --git a/client/html/pool_create.tpl b/client/html/pool_create.tpl new file mode 100644 index 00000000..3c04545c --- /dev/null +++ b/client/html/pool_create.tpl @@ -0,0 +1,42 @@ +
    +
    + + + <% if (ctx.canCreate) { %> +
    + +
    + +
    + <% } %> +
    +
    diff --git a/client/html/pool_delete.tpl b/client/html/pool_delete.tpl new file mode 100644 index 00000000..1ef7fb53 --- /dev/null +++ b/client/html/pool_delete.tpl @@ -0,0 +1,21 @@ +
    +
    +

    This pool has '><%- ctx.pool.postCount %> post(s).

    + + + +
    + +
    + +
    +
    +
    diff --git a/client/html/pool_edit.tpl b/client/html/pool_edit.tpl new file mode 100644 index 00000000..c9f2adb9 --- /dev/null +++ b/client/html/pool_edit.tpl @@ -0,0 +1,50 @@ +
    +
    + + + <% if (ctx.canEditAnything) { %> +
    + +
    + +
    + <% } %> +
    +
    diff --git a/client/html/pool_input.tpl b/client/html/pool_input.tpl new file mode 100644 index 00000000..483e7905 --- /dev/null +++ b/client/html/pool_input.tpl @@ -0,0 +1,7 @@ +
    +
    + +
    + + +
    diff --git a/client/html/pool_merge.tpl b/client/html/pool_merge.tpl new file mode 100644 index 00000000..ea5776ae --- /dev/null +++ b/client/html/pool_merge.tpl @@ -0,0 +1,22 @@ +
    +
    + + +
    + +
    + +
    +
    +
    diff --git a/client/html/pool_summary.tpl b/client/html/pool_summary.tpl new file mode 100644 index 00000000..2b70d270 --- /dev/null +++ b/client/html/pool_summary.tpl @@ -0,0 +1,23 @@ +
    +
    +
    + Category: + '><%- ctx.pool.category %> +
    + +
    + Aliases:
    +
      + <% for (let name of ctx.pool.names.slice(1)) { %> +
    • <%= ctx.makePoolLink(ctx.pool.id, false, false, ctx.pool, name) %>
    • + <% } %> +
    +
    +
    + +
    +
    + <%= ctx.makeMarkdown(ctx.pool.description || 'This pool has no description yet.') %> +

    This pool has '><%- ctx.pool.postCount %> post(s).

    +
    +
    diff --git a/client/html/pools_header.tpl b/client/html/pools_header.tpl new file mode 100644 index 00000000..c54959f2 --- /dev/null +++ b/client/html/pools_header.tpl @@ -0,0 +1,22 @@ +
    +
    + + +
    + + '>Syntax help + + <% if (ctx.canCreate) { %> + '>Add new pool + <% } %> + + <% if (ctx.canEditPoolCategories) { %> + '>Pool categories + <% } %> +
    +
    +
    diff --git a/client/html/pools_page.tpl b/client/html/pools_page.tpl new file mode 100644 index 00000000..0d811808 --- /dev/null +++ b/client/html/pools_page.tpl @@ -0,0 +1,48 @@ +
    + <% if (ctx.response.results.length) { %> + + + + + + + + <% for (let pool of ctx.response.results) { %> + + + + + + <% } %> + +
    + <% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %> + '>Pool name(s) + <% } else { %> + '>Pool name(s) + <% } %> + + <% if (ctx.parameters.query == 'sort:post-count') { %> + '>Post count + <% } else { %> + '>Post count + <% } %> + + <% if (ctx.parameters.query == 'sort:creation-time') { %> + '>Created on + <% } else { %> + '>Created on + <% } %> +
    +
      + <% for (let name of pool.names) { %> +
    • <%= ctx.makePoolLink(pool.id, false, false, pool, name) %>
    • + <% } %> +
    +
    + '><%- pool.postCount %> + + <%= ctx.makeRelativeTime(pool.creationTime) %> +
    + <% } %> +
    diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl index ecc65699..07dcf6f8 100644 --- a/client/html/post_edit_sidebar.tpl +++ b/client/html/post_edit_sidebar.tpl @@ -73,6 +73,12 @@ <% } %> + <% if (ctx.canEditPoolPosts) { %> +
    + <%= ctx.makeTextInput({}) %> +
    + <% } %> + <% if (ctx.canEditPostNotes) { %>
    Add a note diff --git a/client/html/tags_page.tpl b/client/html/tags_page.tpl index 8c973984..2e082398 100644 --- a/client/html/tags_page.tpl +++ b/client/html/tags_page.tpl @@ -3,35 +3,35 @@
    - <% if (ctx.query == 'sort:name' || !ctx.query) { %> + <% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %> '>Tag name(s) <% } else { %> '>Tag name(s) <% } %> - <% if (ctx.query == 'sort:implication-count') { %> + <% if (ctx.parameters.query == 'sort:implication-count') { %> '>Implications <% } else { %> '>Implications <% } %> - <% if (ctx.query == 'sort:suggestion-count') { %> + <% if (ctx.parameters.query == 'sort:suggestion-count') { %> '>Suggestions <% } else { %> '>Suggestions <% } %> - <% if (ctx.query == 'sort:usages') { %> + <% if (ctx.parameters.query == 'sort:usages') { %> '>Usages <% } else { %> '>Usages <% } %> - <% if (ctx.query == 'sort:creation-time') { %> + <% if (ctx.parameters.query == 'sort:creation-time') { %> '>Created on <% } else { %> '>Created on diff --git a/client/js/api.js b/client/js/api.js index 303c0775..befe02fe 100644 --- a/client/js/api.js +++ b/client/js/api.js @@ -84,6 +84,10 @@ class Api extends events.EventTarget { return remoteConfig.tagNameRegex; } + getPoolNameRegex() { + return remoteConfig.poolNameRegex; + } + getPasswordRegex() { return remoteConfig.passwordRegex; } diff --git a/client/js/controllers/auth_controller.js b/client/js/controllers/auth_controller.js index bec4b7a4..a6ec97dc 100644 --- a/client/js/controllers/auth_controller.js +++ b/client/js/controllers/auth_controller.js @@ -3,6 +3,7 @@ const router = require('../router.js'); const api = require('../api.js'); const tags = require('../tags.js'); +const pools = require('../pools.js'); const uri = require('../util/uri.js'); const topNavigation = require('../models/top_navigation.js'); const LoginView = require('../views/login_view.js'); @@ -27,6 +28,7 @@ class LoginController { ctx.controller.showSuccess('Logged in'); // reload tag category color map, this is required when `tag_categories:list` has a permission other than anonymous tags.refreshCategoryColorMap(); + pools.refreshCategoryColorMap(); }, error => { this._loginView.showError(error.message); this._loginView.enableForm(); diff --git a/client/js/controllers/pool_categories_controller.js b/client/js/controllers/pool_categories_controller.js new file mode 100644 index 00000000..4c2c3b7a --- /dev/null +++ b/client/js/controllers/pool_categories_controller.js @@ -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); + }); +}; diff --git a/client/js/controllers/pool_controller.js b/client/js/controllers/pool_controller.js new file mode 100644 index 00000000..a06ba5c2 --- /dev/null +++ b/client/js/controllers/pool_controller.js @@ -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'); + }); +}; diff --git a/client/js/controllers/pool_create_controller.js b/client/js/controllers/pool_create_controller.js new file mode 100644 index 00000000..2b8e7e60 --- /dev/null +++ b/client/js/controllers/pool_create_controller.js @@ -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'); + }); +}; diff --git a/client/js/controllers/pool_list_controller.js b/client/js/controllers/pool_list_controller.js new file mode 100644 index 00000000..dff649f9 --- /dev/null +++ b/client/js/controllers/pool_list_controller.js @@ -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); + }); +}; diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js index d7836073..357386c8 100644 --- a/client/js/controllers/post_list_controller.js +++ b/client/js/controllers/post_list_controller.js @@ -18,18 +18,19 @@ const fields = [ class PostListController { constructor(ctx) { + this._pageController = new PageController(); + if (!api.hasPrivilege('posts:list')) { this._view = new EmptyView(); this._view.showError('You don\'t have privileges to view posts.'); return; } + this._ctx = ctx; + topNavigation.activate('posts'); topNavigation.setTitle('Listing posts'); - this._ctx = ctx; - this._pageController = new PageController(); - this._headerView = new PostsHeaderView({ hostNode: this._pageController.view.pageHeaderHolderNode, parameters: ctx.parameters, diff --git a/client/js/controllers/tag_list_controller.js b/client/js/controllers/tag_list_controller.js index db31cc62..34ff8b24 100644 --- a/client/js/controllers/tag_list_controller.js +++ b/client/js/controllers/tag_list_controller.js @@ -21,18 +21,19 @@ const fields = [ class TagListController { constructor(ctx) { + this._pageController = new PageController(); + if (!api.hasPrivilege('tags:list')) { this._view = new EmptyView(); this._view.showError('You don\'t have privileges to view tags.'); return; } + this._ctx = ctx; + topNavigation.activate('tags'); topNavigation.setTitle('Listing tags'); - this._ctx = ctx; - this._pageController = new PageController(); - this._headerView = new TagsHeaderView({ hostNode: this._pageController.view.pageHeaderHolderNode, parameters: ctx.parameters, diff --git a/client/js/controllers/user_list_controller.js b/client/js/controllers/user_list_controller.js index 13dcf434..9860de4e 100644 --- a/client/js/controllers/user_list_controller.js +++ b/client/js/controllers/user_list_controller.js @@ -12,6 +12,8 @@ const EmptyView = require('../views/empty_view.js'); class UserListController { constructor(ctx) { + this._pageController = new PageController(); + if (!api.hasPrivilege('users:list')) { this._view = new EmptyView(); this._view.showError('You don\'t have privileges to view users.'); @@ -22,7 +24,6 @@ class UserListController { topNavigation.setTitle('Listing users'); this._ctx = ctx; - this._pageController = new PageController(); this._headerView = new UsersHeaderView({ hostNode: this._pageController.view.pageHeaderHolderNode, diff --git a/client/js/controls/pool_auto_complete_control.js b/client/js/controls/pool_auto_complete_control.js new file mode 100644 index 00000000..f7208335 --- /dev/null +++ b/client/js/controls/pool_auto_complete_control.js @@ -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 = ( + '' + + misc.escapeHtml(pool.names[0] + ' (' + pool.postCount + ')') + + ''); + 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; diff --git a/client/js/controls/pool_input_control.js b/client/js/controls/pool_input_control.js new file mode 100644 index 00000000..af1c744c --- /dev/null +++ b/client/js/controls/pool_input_control.js @@ -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; diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js index 11ce2103..495dcdb2 100644 --- a/client/js/controls/post_edit_sidebar_control.js +++ b/client/js/controls/post_edit_sidebar_control.js @@ -7,6 +7,7 @@ const views = require('../util/views.js'); const Note = require('../models/note.js'); const Point = require('../models/point.js'); const TagInputControl = require('./tag_input_control.js'); +const PoolInputControl = require('./pool_input_control.js'); const ExpanderControl = require('../controls/expander_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'), canEditPostContent: api.hasPrivilege('posts:edit:content'), canEditPostThumbnail: api.hasPrivilege('posts:edit:thumbnail'), + canEditPoolPosts: api.hasPrivilege('pools:edit:posts'), canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'), canDeletePosts: api.hasPrivilege('posts:delete'), canFeaturePosts: api.hasPrivilege('posts:feature'), @@ -55,6 +57,10 @@ class PostEditSidebarControl extends events.EventTarget { 'post-notes', 'Notes', this._hostNode.querySelectorAll('.notes')); + this._poolsExpander = new ExpanderControl( + 'post-pools', + `Pools (${this._post.pools.length})`, + this._hostNode.querySelectorAll('.pools')); new ExpanderControl( 'post-content', 'Content', @@ -75,6 +81,11 @@ class PostEditSidebarControl extends events.EventTarget { this._tagInputNode, post.tags); } + if (this._poolInputNode) { + this._poolControl = new PoolInputControl( + this._poolInputNode, post.pools); + } + if (this._contentInputNode) { this._contentFileDropper = new FileDropperControl( this._contentInputNode, {allowUrls: true, @@ -168,6 +179,9 @@ class PostEditSidebarControl extends events.EventTarget { this._post.notes.addEventListener(eventType, e => { this._syncExpanderTitles(); }); + this._post.pools.addEventListener(eventType, e => { + this._syncExpanderTitles(); + }); } this._tagControl.addEventListener( @@ -180,11 +194,18 @@ class PostEditSidebarControl extends events.EventTarget { this._noteTextareaNode.addEventListener( 'change', e => this._evtNoteTextChangeRequest(e)); } + + this._poolControl.addEventListener( + 'change', e => { + this.dispatchEvent(new CustomEvent('change')); + this._syncExpanderTitles(); + }); } _syncExpanderTitles() { this._notesExpander.title = `Notes (${this._post.notes.length})`; this._tagsExpander.title = `Tags (${this._post.tags.length})`; + this._poolsExpander.title = `Pools (${this._post.pools.length})`; } _evtPostContentChange(e) { @@ -337,6 +358,10 @@ class PostEditSidebarControl extends events.EventTarget { misc.splitByWhitespace(this._tagInputNode.value) : undefined, + pools: this._poolInputNode ? + misc.splitByWhitespace(this._poolInputNode.value) : + undefined, + relations: this._relationsInputNode ? misc.splitByWhitespace(this._relationsInputNode.value) .map(x => parseInt(x)) : @@ -373,6 +398,10 @@ class PostEditSidebarControl extends events.EventTarget { return this._formNode.querySelector('.tags input'); } + get _poolInputNode() { + return this._formNode.querySelector('.pools input'); + } + get _loopVideoInputNode() { return this._formNode.querySelector('.flags input[name=loop]'); } diff --git a/client/js/main.js b/client/js/main.js index cb0eb076..8406c79e 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -27,6 +27,7 @@ router.enter( }); const tags = require('./tags.js'); +const pools = require('./pools.js'); const api = require('./api.js'); api.fetchConfig().then(() => { @@ -45,6 +46,10 @@ api.fetchConfig().then(() => { controllers.push(require('./controllers/tag_controller.js')); controllers.push(require('./controllers/tag_list_controller.js')); controllers.push(require('./controllers/tag_categories_controller.js')); + controllers.push(require('./controllers/pool_create_controller.js')); + controllers.push(require('./controllers/pool_controller.js')); + controllers.push(require('./controllers/pool_list_controller.js')); + controllers.push(require('./controllers/pool_categories_controller.js')); controllers.push(require('./controllers/settings_controller.js')); controllers.push(require('./controllers/user_controller.js')); controllers.push(require('./controllers/user_list_controller.js')); @@ -61,6 +66,7 @@ api.fetchConfig().then(() => { }).then(() => { api.loginFromCookies().then(() => { tags.refreshCategoryColorMap(); + pools.refreshCategoryColorMap(); router.start(); }, error => { if (window.location.href.indexOf('login') !== -1) { diff --git a/client/js/models/abstract_list.js b/client/js/models/abstract_list.js index 10edd612..fb4dec8b 100644 --- a/client/js/models/abstract_list.js +++ b/client/js/models/abstract_list.js @@ -86,6 +86,10 @@ class AbstractList extends events.EventTarget { return this._list.map(...args); } + filter(...args) { + return this._list.filter(...args); + } + [Symbol.iterator]() { return this._list[Symbol.iterator](); } diff --git a/client/js/models/pool.js b/client/js/models/pool.js new file mode 100644 index 00000000..8a86e12d --- /dev/null +++ b/client/js/models/pool.js @@ -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; diff --git a/client/js/models/pool_category.js b/client/js/models/pool_category.js new file mode 100644 index 00000000..1ce4b24d --- /dev/null +++ b/client/js/models/pool_category.js @@ -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; diff --git a/client/js/models/pool_category_list.js b/client/js/models/pool_category_list.js new file mode 100644 index 00000000..2699620d --- /dev/null +++ b/client/js/models/pool_category_list.js @@ -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; diff --git a/client/js/models/pool_list.js b/client/js/models/pool_list.js new file mode 100644 index 00000000..8a3b858a --- /dev/null +++ b/client/js/models/pool_list.js @@ -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; diff --git a/client/js/models/post.js b/client/js/models/post.js index be219049..a11d3cb8 100644 --- a/client/js/models/post.js +++ b/client/js/models/post.js @@ -7,6 +7,8 @@ const events = require('../events.js'); const TagList = require('./tag_list.js'); const NoteList = require('./note_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'); class Post extends events.EventTarget { @@ -18,6 +20,7 @@ class Post extends events.EventTarget { obj._tags = new TagList(); obj._notes = new NoteList(); obj._comments = new CommentList(); + obj._pools = new PoolList(); } this._updateFromResponse({}); @@ -111,6 +114,10 @@ class Post extends events.EventTarget { return this._relations; } + get pools() { + return this._pools; + } + get 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) { const files = {}; const detail = {version: this._version}; @@ -232,6 +276,12 @@ class Post extends events.EventTarget { api.post(uri.formatApiLink('posts'), detail, files); 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.dispatchEvent( new CustomEvent('change', {detail: {post: this}})); @@ -243,12 +293,13 @@ class Post extends events.EventTarget { this.dispatchEvent( new CustomEvent('changeThumbnail', {detail: {post: this}})); } + return Promise.resolve(); }, error => { if (error.response && - error.response.name === 'PostAlreadyUploadedError') { + error.response.name === 'PostAlreadyUploadedError') { error.message = - `Post already uploaded (@${error.response.otherPostId})`; + `Post already uploaded (@${error.response.otherPostId})`; } return Promise.reject(error); }); @@ -365,9 +416,9 @@ class Post extends events.EventTarget { mutateContentUrl() { this._contentUrl = - this._orig._contentUrl + - '?bypass-cache=' + - Math.round(Math.random() * 1000); + this._orig._contentUrl + + '?bypass-cache=' + + Math.round(Math.random() * 1000); } _updateFromResponse(response) { @@ -402,6 +453,7 @@ class Post extends events.EventTarget { obj._tags.sync(response.tags); obj._notes.sync(response.notes); obj._comments.sync(response.comments); + obj._pools.sync(response.pools); } Object.assign(this, map()); diff --git a/client/js/models/post_list.js b/client/js/models/post_list.js index eae1128b..cd94e406 100644 --- a/client/js/models/post_list.js +++ b/client/js/models/post_list.js @@ -49,6 +49,31 @@ class PostList extends AbstractList { 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; diff --git a/client/js/models/top_navigation.js b/client/js/models/top_navigation.js index 44ad6712..91b8976b 100644 --- a/client/js/models/top_navigation.js +++ b/client/js/models/top_navigation.js @@ -81,6 +81,7 @@ function _makeTopNavigation() { ret.add('upload', new TopNavigationItem('U', 'Upload', 'upload')); ret.add('comments', new TopNavigationItem('C', 'Comments', 'comments')); ret.add('tags', new TopNavigationItem('T', 'Tags', 'tags')); + ret.add('pools', new TopNavigationItem('O', 'Pools', 'pools')); ret.add('users', new TopNavigationItem('S', 'Users', 'users')); ret.add('account', new TopNavigationItem('A', 'Account', 'user/{me}')); ret.add('register', new TopNavigationItem('R', 'Register', 'register')); diff --git a/client/js/pools.js b/client/js/pools.js new file mode 100644 index 00000000..8484f0b5 --- /dev/null +++ b/client/js/pools.js @@ -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, +}; diff --git a/client/js/util/views.js b/client/js/util/views.js index 0ac9ca77..afdda76b 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -215,6 +215,29 @@ function makeTagLink(name, includeHash, includeCount, tag) { 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) { let text = makeThumbnail(user ? user.avatarUrl : null); text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous'; @@ -393,6 +416,7 @@ function getTemplate(templatePath) { makeDateInput: makeDateInput, makePostLink: makePostLink, makeTagLink: makeTagLink, + makePoolLink: makePoolLink, makeUserLink: makeUserLink, makeFlexboxAlign: makeFlexboxAlign, makeAccessKey: makeAccessKey, @@ -522,6 +546,7 @@ module.exports = { decorateValidator: decorateValidator, makeTagLink: makeTagLink, makePostLink: makePostLink, + makePoolLink: makePoolLink, makeCheckbox: makeCheckbox, makeRadio: makeRadio, syncScrollPosition: syncScrollPosition, diff --git a/client/js/views/help_view.js b/client/js/views/help_view.js index faf2e966..1ab016d7 100644 --- a/client/js/views/help_view.js +++ b/client/js/views/help_view.js @@ -17,6 +17,7 @@ const subsectionTemplates = { 'posts': views.getTemplate('help-search-posts'), 'users': views.getTemplate('help-search-users'), 'tags': views.getTemplate('help-search-tags'), + 'pools': views.getTemplate('help-search-pools'), }, }; diff --git a/client/js/views/pool_categories_view.js b/client/js/views/pool_categories_view.js new file mode 100644 index 00000000..19283581 --- /dev/null +++ b/client/js/views/pool_categories_view.js @@ -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; diff --git a/client/js/views/pool_create_view.js b/client/js/views/pool_create_view.js new file mode 100644 index 00000000..f22cf716 --- /dev/null +++ b/client/js/views/pool_create_view.js @@ -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; diff --git a/client/js/views/pool_delete_view.js b/client/js/views/pool_delete_view.js new file mode 100644 index 00000000..d3707dbe --- /dev/null +++ b/client/js/views/pool_delete_view.js @@ -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; diff --git a/client/js/views/pool_edit_view.js b/client/js/views/pool_edit_view.js new file mode 100644 index 00000000..9c118b3c --- /dev/null +++ b/client/js/views/pool_edit_view.js @@ -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; diff --git a/client/js/views/pool_merge_view.js b/client/js/views/pool_merge_view.js new file mode 100644 index 00000000..09c4e8d0 --- /dev/null +++ b/client/js/views/pool_merge_view.js @@ -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; diff --git a/client/js/views/pool_summary_view.js b/client/js/views/pool_summary_view.js new file mode 100644 index 00000000..a5808cc0 --- /dev/null +++ b/client/js/views/pool_summary_view.js @@ -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; diff --git a/client/js/views/pool_view.js b/client/js/views/pool_view.js new file mode 100644 index 00000000..f296e906 --- /dev/null +++ b/client/js/views/pool_view.js @@ -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; diff --git a/client/js/views/pools_header_view.js b/client/js/views/pools_header_view.js new file mode 100644 index 00000000..65bbe1b1 --- /dev/null +++ b/client/js/views/pools_header_view.js @@ -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; diff --git a/client/js/views/pools_page_view.js b/client/js/views/pools_page_view.js new file mode 100644 index 00000000..234d4467 --- /dev/null +++ b/client/js/views/pools_page_view.js @@ -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; diff --git a/client/js/views/snapshots_page_view.js b/client/js/views/snapshots_page_view.js index 77fbe132..e7ea264e 100644 --- a/client/js/views/snapshots_page_view.js +++ b/client/js/views/snapshots_page_view.js @@ -36,6 +36,8 @@ function _makeResourceLink(type, id) { return views.makeTagLink(id, true); } else if (type === 'tag_category') { return 'category "' + id + '"'; + } else if (type === 'pool') { + return views.makePoolLink(id, true); } } @@ -113,6 +115,19 @@ function _makeItemModification(type, data) { if (diff.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('
    '); diff --git a/client/package.json b/client/package.json index 3d390ec6..e7761265 100644 --- a/client/package.json +++ b/client/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "build": "node build.js", - "watch": "c1=\"\";while :;do c2=$(find html js css img -type f -and -not -iname '*autogen*'|sort|xargs cat|md5sum);[[ $c1 != $c2 ]]&&npm run build -- --no-vendor-js;c1=$c2;sleep 1;done" + "watch": "c1=\"\";while :;do c2=$(find html js css img -type f -and -not -iname '*autogen*'|sort|xargs cat|md5sum);[[ $c1 != $c2 ]]&&npm run build -- --debug --no-vendor-js;c1=$c2;sleep 1;done" }, "dependencies": { "font-awesome": "^4.7.0", diff --git a/doc/API.md b/doc/API.md index 528b665c..51084c06 100644 --- a/doc/API.md +++ b/doc/API.md @@ -44,6 +44,20 @@ - [Getting featured post](#getting-featured-post) - [Featuring post](#featuring-post) - [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 - [Listing comments](#listing-comments) - [Creating comment](#creating-comment) @@ -82,6 +96,8 @@ - [Micro tag](#micro-tag) - [Post](#post) - [Micro post](#micro-post) + - [Pool category](#pool-category) + - [Pool](#pool) - [Note](#note) - [Comment](#comment) - [Snapshot](#snapshot) @@ -724,6 +740,7 @@ data. | `submit` | alias of upload | | `comment` | commented 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 | | `comment-count` | having given number of comments | | `fav-count` | favorited by given number of users | @@ -1118,6 +1135,383 @@ data. 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 `` + 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": , + "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/` + +- **Input** + + ```json5 + { + "version": , + "name": , // optional + "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/` + +- **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/` + +- **Input** + + ```json5 + { + "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//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=&limit=&query=` + +- **Output** + + A [paged search result resource](#paged-search-result), for which + `` is a [pool resource](#pool). + +- **Errors** + + - privileges are too low + +- **Description** + + Searches for pools. + + **Anonymous tokens** + + Same as `name` token. + + **Named tokens** + + | `` | 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** + + | `` | 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": [, , ...], + "category": , + "description": , // optional + "posts": [, , ...], // 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 + [`` 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/` + +- **Input** + + ```json5 + { + "version": , + "names": [, , ...], // optional + "category": , // optional + "description": , // optional + "posts": [, , ...], // 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 + [`` 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/` + +- **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/` + +- **Input** + + ```json5 + { + "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": , + "remove": , + "mergeToVersion": , + "mergeTo": + } + ``` + +- **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 - **Request** @@ -2073,6 +2467,68 @@ A text annotation rendered on top of the post. will draw it inside the post's upper left quarter. - ``: 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": , + "name": , + "color": , + "usages": + "default": +} +``` + +**Field meaning** + +- ``: resource version. See [versioning](#versioning). +- ``: the category name. +- ``: the category color. +- ``: how many pools is the given category used with. +- ``: whether the pool category is the default one. + +## Pool +**Description** + +An ordered list of posts, with a description and category. + +**Structure** + +```json5 +{ + "version": , + "id": + "names": , + "category": , + "posts": , + "creationTime": , + "lastEditTime": , + "postCount": , + "description": +} +``` + +**Field meaning** + +- ``: resource version. See [versioning](#versioning). +- ``: the pool identifier. +- ``: a list of pool names (aliases). +- ``: the name of the category the given pool belongs to. +- ``: an ordered list of posts, serialized as [micro + post resource](#micro-post). Posts are ordered by insertion by default. +- ``: time the pool was created, formatted as per RFC 3339. +- ``: time the pool was edited, formatted as per RFC 3339. +- ``: the number of posts the pool has. +- ``: the pool description (instructions how to use, history etc.) + The client should render it as Markdown. + ## Comment **Description** @@ -2144,6 +2600,7 @@ A snapshot is a version of a database resource. | `"tag"` | first tag name at given time | | `"tag_category"` | tag category name at given time | | `"post"` | post ID | + | `"pool"` | pool ID | - ``: a [micro user resource](#micro-user) representing the user who has made the change. diff --git a/server/config.yaml.dist b/server/config.yaml.dist index 4f8f769d..937c1387 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -49,6 +49,9 @@ enable_safety: yes tag_name_regex: ^\S+$ tag_category_name_regex: ^[^\s%+#/]+$ +pool_name_regex: ^\S+$ +pool_category_name_regex: ^[^\s%+#/]+$ + # don't make these more restrictive unless you want to annoy people; if you do # customize them, make sure to update the instructions in the registration form # template as well. @@ -125,6 +128,24 @@ privileges: 'tag_categories:delete': 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:delete:any': moderator 'comments:delete:own': regular diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index 0d7f75f8..28f0e984 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -4,6 +4,8 @@ import szurubooru.api.user_token_api import szurubooru.api.post_api import szurubooru.api.tag_api import szurubooru.api.tag_category_api +import szurubooru.api.pool_api +import szurubooru.api.pool_category_api import szurubooru.api.comment_api import szurubooru.api.password_reset_api import szurubooru.api.snapshot_api diff --git a/server/szurubooru/api/pool_api.py b/server/szurubooru/api/pool_api.py new file mode 100644 index 00000000..9627ed21 --- /dev/null +++ b/server/szurubooru/api/pool_api.py @@ -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[^/]+)/?') +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[^/]+)/?') +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[^/]+)/?') +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) diff --git a/server/szurubooru/api/pool_category_api.py b/server/szurubooru/api/pool_category_api.py new file mode 100644 index 00000000..f2937247 --- /dev/null +++ b/server/szurubooru/api/pool_category_api.py @@ -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[^/]+)/?') +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[^/]+)/?') +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[^/]+)/?') +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[^/]+)/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) diff --git a/server/szurubooru/func/pool_categories.py b/server/szurubooru/func/pool_categories.py new file mode 100644 index 00000000..83305de0 --- /dev/null +++ b/server/szurubooru/func/pool_categories.py @@ -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) diff --git a/server/szurubooru/func/pools.py b/server/szurubooru/func/pools.py new file mode 100644 index 00000000..4acf9dfd --- /dev/null +++ b/server/szurubooru/func/pools.py @@ -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) diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index f0224b40..d8e984bf 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -5,7 +5,7 @@ from datetime import datetime import sqlalchemy as sa from szurubooru import config, db, model, errors, rest from szurubooru.func import ( - users, scores, comments, tags, util, + users, scores, comments, tags, pools, util, mime, images, files, image_hash, serialization, snapshots) @@ -176,6 +176,7 @@ class PostSerializer(serialization.BaseSerializer): 'hasCustomThumbnail': self.serialize_has_custom_thumbnail, 'notes': self.serialize_notes, 'comments': self.serialize_comments, + 'pools': self.serialize_pools, } def serialize_id(self) -> Any: @@ -299,6 +300,13 @@ class PostSerializer(serialization.BaseSerializer): self.post.comments, 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( post: Optional[model.Post], @@ -334,6 +342,22 @@ def get_post_by_id(post_id: int) -> model.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]: return ( db.session diff --git a/server/szurubooru/func/snapshots.py b/server/szurubooru/func/snapshots.py index eade3a19..66464679 100644 --- a/server/szurubooru/func/snapshots.py +++ b/server/szurubooru/func/snapshots.py @@ -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]: assert post return { @@ -47,6 +65,8 @@ _snapshot_factories = { 'tag_category': lambda entity: get_tag_category_snapshot(entity), 'tag': lambda entity: get_tag_snapshot(entity), 'post': lambda entity: get_post_snapshot(entity), + 'pool_category': lambda entity: get_pool_category_snapshot(entity), + 'pool': lambda entity: get_pool_snapshot(entity), } # type: Dict[model.Base, Callable[[model.Base], Dict[str ,Any]]] diff --git a/server/szurubooru/func/tags.py b/server/szurubooru/func/tags.py index 7d92f1e7..60eda50a 100644 --- a/server/szurubooru/func/tags.py +++ b/server/szurubooru/func/tags.py @@ -1,5 +1,3 @@ -import json -import os import re from typing import Any, Optional, Tuple, List, Dict, Callable from datetime import datetime diff --git a/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py b/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py new file mode 100644 index 00000000..eaa68f27 --- /dev/null +++ b/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py @@ -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() diff --git a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py new file mode 100644 index 00000000..18a0d7af --- /dev/null +++ b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py @@ -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') diff --git a/server/szurubooru/model/__init__.py b/server/szurubooru/model/__init__.py index a043bd1f..4f6cb2a6 100644 --- a/server/szurubooru/model/__init__.py +++ b/server/szurubooru/model/__init__.py @@ -11,6 +11,8 @@ from szurubooru.model.post import ( PostNote, PostFeature, 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.snapshot import Snapshot import szurubooru.model.util diff --git a/server/szurubooru/model/pool.py b/server/szurubooru/model/pool.py new file mode 100644 index 00000000..6fc83610 --- /dev/null +++ b/server/szurubooru/model/pool.py @@ -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, + } diff --git a/server/szurubooru/model/pool_category.py b/server/szurubooru/model/pool_category.py new file mode 100644 index 00000000..a54e5a66 --- /dev/null +++ b/server/szurubooru/model/pool_category.py @@ -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, + } diff --git a/server/szurubooru/model/post.py b/server/szurubooru/model/post.py index f8f5c340..11da8660 100644 --- a/server/szurubooru/model/post.py +++ b/server/szurubooru/model/post.py @@ -2,7 +2,10 @@ from typing import List import sqlalchemy as sa from szurubooru.model.base import Base 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.orderinglist import ordering_list class PostFeature(Base): @@ -224,6 +227,13 @@ class Post(Base): notes = sa.orm.relationship( 'PostNote', cascade='all, delete-orphan', lazy='joined') 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 tag_count = sa.orm.column_property( diff --git a/server/szurubooru/model/util.py b/server/szurubooru/model/util.py index e82539f1..ab86100f 100644 --- a/server/szurubooru/model/util.py +++ b/server/szurubooru/model/util.py @@ -10,6 +10,8 @@ def get_resource_info(entity: Base) -> Tuple[Any, Any, Union[str, int]]: 'tag_category': lambda category: category.name, 'comment': lambda comment: comment.comment_id, 'post': lambda post: post.post_id, + 'pool': lambda pool: pool.pool_id, + 'pool_category': lambda category: category.name, } # type: Dict[str, Callable[[Base], Any]] resource_type = entity.__table__.name diff --git a/server/szurubooru/search/configs/__init__.py b/server/szurubooru/search/configs/__init__.py index c7e3102f..c6a3ea49 100644 --- a/server/szurubooru/search/configs/__init__.py +++ b/server/szurubooru/search/configs/__init__.py @@ -3,3 +3,4 @@ from .tag_search_config import TagSearchConfig from .post_search_config import PostSearchConfig from .snapshot_search_config import SnapshotSearchConfig from .comment_search_config import CommentSearchConfig +from .pool_search_config import PoolSearchConfig diff --git a/server/szurubooru/search/configs/pool_search_config.py b/server/szurubooru/search/configs/pool_search_config.py new file mode 100644 index 00000000..ddc325af --- /dev/null +++ b/server/szurubooru/search/configs/pool_search_config.py @@ -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) + ), + ]) diff --git a/server/szurubooru/search/configs/post_search_config.py b/server/szurubooru/search/configs/post_search_config.py index 9c8de2eb..281826fa 100644 --- a/server/szurubooru/search/configs/post_search_config.py +++ b/server/szurubooru/search/configs/post_search_config.py @@ -104,6 +104,18 @@ def _note_filter( 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): def __init__(self) -> None: self.user = None # type: Optional[model.User] @@ -350,6 +362,11 @@ class PostSearchConfig(BaseSearchConfig): search_util.create_str_filter( model.Post.flags_string, _flag_transformer) ), + + ( + ['pool'], + _pool_filter + ), ]) @property diff --git a/server/szurubooru/tests/api/test_pool_category_creating.py b/server/szurubooru/tests/api/test_pool_category_creating.py new file mode 100644 index 00000000..5e932a63 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_creating.py @@ -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))) diff --git a/server/szurubooru/tests/api/test_pool_category_deleting.py b/server/szurubooru/tests/api/test_pool_category_deleting.py new file mode 100644 index 00000000..72853ac8 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_deleting.py @@ -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 diff --git a/server/szurubooru/tests/api/test_pool_category_retrieving.py b/server/szurubooru/tests/api/test_pool_category_retrieving.py new file mode 100644 index 00000000..4a467c0f --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_retrieving.py @@ -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': '-'}) diff --git a/server/szurubooru/tests/api/test_pool_category_updating.py b/server/szurubooru/tests/api/test_pool_category_updating.py new file mode 100644 index 00000000..028c5209 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_updating.py @@ -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) diff --git a/server/szurubooru/tests/api/test_pool_creating.py b/server/szurubooru/tests/api/test_pool_creating.py new file mode 100644 index 00000000..5eeb2e29 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_creating.py @@ -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))) diff --git a/server/szurubooru/tests/api/test_pool_deleting.py b/server/szurubooru/tests/api/test_pool_deleting.py new file mode 100644 index 00000000..e29656d3 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_deleting.py @@ -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 diff --git a/server/szurubooru/tests/api/test_pool_merging.py b/server/szurubooru/tests/api/test_pool_merging.py new file mode 100644 index 00000000..dc462d2d --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_merging.py @@ -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))) diff --git a/server/szurubooru/tests/api/test_pool_retrieving.py b/server/szurubooru/tests/api/test_pool_retrieving.py new file mode 100644 index 00000000..e48565a0 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_retrieving.py @@ -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}) diff --git a/server/szurubooru/tests/api/test_pool_updating.py b/server/szurubooru/tests/api/test_pool_updating.py new file mode 100644 index 00000000..bd6b71c1 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_updating.py @@ -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}) diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py index a131fece..13b1c009 100644 --- a/server/szurubooru/tests/conftest.py +++ b/server/szurubooru/tests/conftest.py @@ -201,6 +201,53 @@ def post_favorite_factory(user_factory, post_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 def read_asset(): def get(path): diff --git a/server/szurubooru/tests/func/test_posts.py b/server/szurubooru/tests/func/test_posts.py index 49f93be4..ed008792 100644 --- a/server/szurubooru/tests/func/test_posts.py +++ b/server/szurubooru/tests/func/test_posts.py @@ -79,6 +79,8 @@ def test_serialize_post( comment_factory, tag_factory, tag_category_factory, + pool_factory, + pool_category_factory, config_injector): config_injector({'data_url': 'http://example.com/', 'secret': 'test'}) with patch('szurubooru.func.comments.serialize_comment'), \ @@ -150,6 +152,23 @@ def test_serialize_post( time=datetime(1800, 1, 1))]) 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['tags'].sort(key=lambda tag: tag['names'][0]) @@ -183,6 +202,44 @@ def test_serialize_post( ], 'relations': [], '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', 'score': 1, 'ownFavorite': False, diff --git a/server/szurubooru/tests/model/test_pool.py b/server/szurubooru/tests/model/test_pool.py new file mode 100644 index 00000000..589e9ba6 --- /dev/null +++ b/server/szurubooru/tests/model/test_pool.py @@ -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 diff --git a/server/szurubooru/tests/search/configs/test_pool_search_config.py b/server/szurubooru/tests/search/configs/test_pool_search_config.py new file mode 100644 index 00000000..730511ae --- /dev/null +++ b/server/szurubooru/tests/search/configs/test_pool_search_config.py @@ -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)