From d59ecb8e230a55e6875076824723580bd3f4419b Mon Sep 17 00:00:00 2001 From: Ruin0x11 Date: Sun, 3 May 2020 19:53:28 -0700 Subject: [PATCH 1/9] Add pool CRUD operations/pages --- client/css/pool-categories-view.styl | 30 ++ client/css/pool-input-control.styl | 53 +++ client/css/pool-list-view.styl | 52 +++ client/css/pool-view.styl | 33 ++ client/html/help_search.tpl | 1 + client/html/help_search_pools.tpl | 97 ++++++ client/html/pool.tpl | 18 ++ client/html/pool_categories.tpl | 30 ++ client/html/pool_category_row.tpl | 43 +++ client/html/pool_create.tpl | 35 ++ client/html/pool_delete.tpl | 21 ++ client/html/pool_edit.tpl | 41 +++ client/html/pool_input.tpl | 0 client/html/pool_merge.tpl | 22 ++ client/html/pool_summary.tpl | 23 ++ client/html/pools_header.tpl | 22 ++ client/html/pools_page.tpl | 48 +++ client/html/tags_page.tpl | 10 +- client/js/api.js | 4 + client/js/controllers/auth_controller.js | 2 + .../controllers/pool_categories_controller.js | 57 ++++ client/js/controllers/pool_controller.js | 141 ++++++++ .../js/controllers/pool_create_controller.js | 63 ++++ client/js/controllers/pool_list_controller.js | 105 ++++++ .../js/controls/pool_auto_complete_control.js | 57 ++++ client/js/main.js | 6 + client/js/models/pool.js | 155 +++++++++ client/js/models/pool_category.js | 90 ++++++ client/js/models/pool_category_list.js | 82 +++++ client/js/models/pool_list.js | 30 ++ client/js/models/top_navigation.js | 1 + client/js/pools.js | 26 ++ client/js/util/misc.js | 5 + client/js/util/views.js | 25 ++ client/js/views/help_view.js | 1 + client/js/views/pool_categories_view.js | 166 ++++++++++ client/js/views/pool_create_view.js | 108 +++++++ client/js/views/pool_delete_view.js | 53 +++ client/js/views/pool_edit_view.js | 115 +++++++ client/js/views/pool_merge_view.js | 77 +++++ client/js/views/pool_summary_view.js | 23 ++ client/js/views/pool_view.js | 106 ++++++ client/js/views/pools_header_view.js | 52 +++ client/js/views/pools_page_view.js | 13 + client/package.json | 2 +- server/alembic.ini | 1 + server/config.yaml.dist | 165 +++++----- server/requirements.txt | 1 + server/szurubooru/api/__init__.py | 2 + server/szurubooru/api/pool_api.py | 127 ++++++++ server/szurubooru/api/pool_category_api.py | 89 ++++++ server/szurubooru/func/pool_categories.py | 199 ++++++++++++ server/szurubooru/func/pools.py | 302 ++++++++++++++++++ server/szurubooru/func/snapshots.py | 20 ++ server/szurubooru/func/tags.py | 2 - .../54de8acc6cef_add_default_pool_category.py | 60 ++++ .../6a2f424ec9d2_create_pool_tables.py | 52 +++ server/szurubooru/model/__init__.py | 2 + server/szurubooru/model/pool.py | 70 ++++ server/szurubooru/model/pool_category.py | 28 ++ server/szurubooru/model/util.py | 2 + server/szurubooru/search/configs/__init__.py | 1 + .../search/configs/pool_search_config.py | 109 +++++++ 63 files changed, 3294 insertions(+), 82 deletions(-) create mode 100644 client/css/pool-categories-view.styl create mode 100644 client/css/pool-input-control.styl create mode 100644 client/css/pool-list-view.styl create mode 100644 client/css/pool-view.styl create mode 100644 client/html/help_search_pools.tpl create mode 100644 client/html/pool.tpl create mode 100644 client/html/pool_categories.tpl create mode 100644 client/html/pool_category_row.tpl create mode 100644 client/html/pool_create.tpl create mode 100644 client/html/pool_delete.tpl create mode 100644 client/html/pool_edit.tpl create mode 100644 client/html/pool_input.tpl create mode 100644 client/html/pool_merge.tpl create mode 100644 client/html/pool_summary.tpl create mode 100644 client/html/pools_header.tpl create mode 100644 client/html/pools_page.tpl create mode 100644 client/js/controllers/pool_categories_controller.js create mode 100644 client/js/controllers/pool_controller.js create mode 100644 client/js/controllers/pool_create_controller.js create mode 100644 client/js/controllers/pool_list_controller.js create mode 100644 client/js/controls/pool_auto_complete_control.js create mode 100644 client/js/models/pool.js create mode 100644 client/js/models/pool_category.js create mode 100644 client/js/models/pool_category_list.js create mode 100644 client/js/models/pool_list.js create mode 100644 client/js/pools.js create mode 100644 client/js/views/pool_categories_view.js create mode 100644 client/js/views/pool_create_view.js create mode 100644 client/js/views/pool_delete_view.js create mode 100644 client/js/views/pool_edit_view.js create mode 100644 client/js/views/pool_merge_view.js create mode 100644 client/js/views/pool_summary_view.js create mode 100644 client/js/views/pool_view.js create mode 100644 client/js/views/pools_header_view.js create mode 100644 client/js/views/pools_page_view.js create mode 100644 server/szurubooru/api/pool_api.py create mode 100644 server/szurubooru/api/pool_category_api.py create mode 100644 server/szurubooru/func/pool_categories.py create mode 100644 server/szurubooru/func/pools.py create mode 100644 server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py create mode 100644 server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py create mode 100644 server/szurubooru/model/pool.py create mode 100644 server/szurubooru/model/pool_category.py create mode 100644 server/szurubooru/search/configs/pool_search_config.py 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..2333a372 --- /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: 28% + .usages + text-align: center + width: 8% + .creation-time + text-align: center + width: 8% + white-space: pre + ul + list-style-type: none + margin: 0 + padding: 0 + display: inline + li + padding: 0 + display: inline + &:not(:last-child):after + content: ', ' + @media (max-width: 800px) + .implications, .suggestions + display: none + +.pool-list-header + label + display: none !important + text-align: left + form + width: auto + input[name=search-text] + width: 25em + @media (max-width: 1000px) + width: 100% + .append + vertical-align: middle + font-size: 0.95em + color: $inactive-link-color 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..d8708b01 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/pool.tpl b/client/html/pool.tpl new file mode 100644 index 00000000..5af10c96 --- /dev/null +++ b/client/html/pool.tpl @@ -0,0 +1,18 @@ +
    +

    <%- ctx.pool.first_name %>

    + +
    +
    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..fec95ef4 --- /dev/null +++ b/client/html/pool_create.tpl @@ -0,0 +1,35 @@ +
    +
    +
      +
    • + <%= ctx.makeTextInput({ + text: 'Names', + value: '', + required: true, + }) %> +
    • +
    • + <%= ctx.makeSelect({ + text: 'Category', + keyValues: ctx.categories, + selectedKey: 'default', + required: true, + }) %> +
    • +
    • + <%= ctx.makeTextarea({ + text: 'Description', + value: '', + }) %> +
    • +
    + + <% 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).

    + +
      +
    • + <%= ctx.makeCheckbox({ + name: 'confirm-deletion', + text: 'I confirm that I want to delete this pool.', + required: true, + }) %> +
    • +
    + +
    + +
    + +
    +
    +
    diff --git a/client/html/pool_edit.tpl b/client/html/pool_edit.tpl new file mode 100644 index 00000000..0a0e1b01 --- /dev/null +++ b/client/html/pool_edit.tpl @@ -0,0 +1,41 @@ +
    +
    +
      +
    • + <% if (ctx.canEditNames) { %> + <%= ctx.makeTextInput({ + text: 'Names', + value: ctx.pool.names.join(' '), + required: true, + }) %> + <% } %> +
    • +
    • + <% if (ctx.canEditCategory) { %> + <%= ctx.makeSelect({ + text: 'Category', + keyValues: ctx.categories, + selectedKey: ctx.pool.category, + required: true, + }) %> + <% } %> +
    • +
    • + <% if (ctx.canEditDescription) { %> + <%= ctx.makeTextarea({ + text: 'Description', + value: ctx.pool.description, + }) %> + <% } %> +
    • +
    + + <% if (ctx.canEditAnything) { %> +
    + +
    + +
    + <% } %> +
    +
    diff --git a/client/html/pool_input.tpl b/client/html/pool_input.tpl new file mode 100644 index 00000000..e69de29b diff --git a/client/html/pool_merge.tpl b/client/html/pool_merge.tpl new file mode 100644 index 00000000..ce0bf925 --- /dev/null +++ b/client/html/pool_merge.tpl @@ -0,0 +1,22 @@ +
    +
    +
      +
    • + <%= ctx.makeTextInput({name: 'target-pool', required: true, text: 'Target pool', pattern: ctx.poolNamePattern}) %> +
    • + +
    • +

      Posts between the two pools will be combined. + Category needs to be handled manually.

      + + <%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this pool.'}) %> +
    • +
    + +
    + +
    + +
    +
    +
    diff --git a/client/html/pool_summary.tpl b/client/html/pool_summary.tpl new file mode 100644 index 00000000..7b7c7636 --- /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, false, false, 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 @@ +
    +
    +
      +
    • + <%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %> +
    • +
    + +
    + + '>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..19f3f2a2 --- /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, false, false, name) %>
    • + <% } %> +
    +
    + '><%- pool.postCount %> + + <%= ctx.makeRelativeTime(pool.creationTime) %> +
    + <% } %> +
    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 @@ - + @@ -42,6 +42,10 @@ + + + + diff --git a/client/html/pool_create.tpl b/client/html/pool_create.tpl index fec95ef4..bf84dc92 100644 --- a/client/html/pool_create.tpl +++ b/client/html/pool_create.tpl @@ -22,6 +22,12 @@ value: '', }) %> +
  • + <%= ctx.makeTextInput({ + text: 'Posts', + value: '', + }) %> +
  • <% if (ctx.canCreate) { %> diff --git a/client/html/pool_edit.tpl b/client/html/pool_edit.tpl index 0a0e1b01..1cbb62e3 100644 --- a/client/html/pool_edit.tpl +++ b/client/html/pool_edit.tpl @@ -28,6 +28,14 @@ }) %> <% } %> +
  • + <% if (ctx.canEditPosts) { %> + <%= ctx.makeTextInput({ + text: 'Posts', + value: ctx.pool.posts.map(post => post.id).join(' ') + }) %> + <% } %> +
  • <% if (ctx.canEditAnything) { %> diff --git a/client/html/pool_merge.tpl b/client/html/pool_merge.tpl index ce0bf925..ea5776ae 100644 --- a/client/html/pool_merge.tpl +++ b/client/html/pool_merge.tpl @@ -6,7 +6,7 @@
  • -

    Posts between the two pools will be combined. +

    Posts in the two pools will be combined. Category needs to be handled manually.

    <%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this pool.'}) %> diff --git a/client/html/pool_summary.tpl b/client/html/pool_summary.tpl index 7b7c7636..8f4e27d0 100644 --- a/client/html/pool_summary.tpl +++ b/client/html/pool_summary.tpl @@ -9,7 +9,7 @@ Aliases:
    @@ -18,6 +18,6 @@

    <%= ctx.makeMarkdown(ctx.pool.description || 'This pool has no description yet.') %> -

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

    +

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

    diff --git a/client/html/pools_page.tpl b/client/html/pools_page.tpl index 19f3f2a2..6b394839 100644 --- a/client/html/pools_page.tpl +++ b/client/html/pools_page.tpl @@ -30,12 +30,12 @@
  • - <% 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 07ec5ec9..14c5bccb 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..8623cf84 --- /dev/null +++ b/client/js/controllers/pool_controller.js @@ -0,0 +1,141 @@ +'use strict'; + +const router = require('../router.js'); +const api = require('../api.js'); +const misc = require('../util/misc.js'); +const uri = require('../util/uri.js'); +const Pool = require('../models/pool.js'); +const PoolCategoryList = require('../models/pool_category_list.js'); +const topNavigation = require('../models/top_navigation.js'); +const PoolView = require('../views/pool_view.js'); +const EmptyView = require('../views/empty_view.js'); + +class PoolController { + constructor(ctx, section) { + if (!api.hasPrivilege('pools:view')) { + this._view = new EmptyView(); + this._view.showError('You don\'t have privileges to view pools.'); + return; + } + + Promise.all([ + PoolCategoryList.get(), + Pool.get(ctx.parameters.id) + ]).then(responses => { + const [poolCategoriesResponse, pool] = responses; + + topNavigation.activate('pools'); + topNavigation.setTitle('Pool #' + pool.names[0]); + + this._name = ctx.parameters.name; + pool.addEventListener('change', e => this._evtSaved(e, section)); + + const categories = {}; + for (let category of poolCategoriesResponse.results) { + categories[category.name] = category.name; + } + + this._view = new PoolView({ + pool: pool, + section: section, + canEditAnything: api.hasPrivilege('pools:edit'), + canEditNames: api.hasPrivilege('pools:edit:names'), + canEditCategory: api.hasPrivilege('pools:edit:category'), + canEditDescription: api.hasPrivilege('pools:edit:description'), + canMerge: api.hasPrivilege('pools:merge'), + canDelete: api.hasPrivilege('pools:delete'), + categories: categories, + escapeColons: uri.escapeColons, + }); + + this._view.addEventListener('change', e => this._evtChange(e)); + this._view.addEventListener('submit', e => this._evtUpdate(e)); + this._view.addEventListener('merge', e => this._evtMerge(e)); + this._view.addEventListener('delete', e => this._evtDelete(e)); + }, + error => { + this._view = new EmptyView(); + this._view.showError(error.message); + }); + } + + _evtChange(e) { + misc.enableExitConfirmation(); + } + + _evtSaved(e, section) { + misc.disableExitConfirmation(); + if (this._name !== e.detail.pool.names[0]) { + router.replace( + uri.formatClientLink('pool', e.detail.pool.id, section), + null, false); + } + } + + _evtUpdate(e) { + this._view.clearMessages(); + this._view.disableForm(); + if (e.detail.names !== undefined) { + e.detail.pool.names = e.detail.names; + } + if (e.detail.category !== undefined) { + e.detail.pool.category = e.detail.category; + } + if (e.detail.description !== undefined) { + e.detail.pool.description = e.detail.description; + } + e.detail.pool.save().then(() => { + this._view.showSuccess('Pool saved.'); + this._view.enableForm(); + }, error => { + this._view.showError(error.message); + this._view.enableForm(); + }); + } + + _evtMerge(e) { + this._view.clearMessages(); + this._view.disableForm(); + e.detail.pool + .merge(e.detail.targetPoolName, e.detail.addAlias) + .then(() => { + this._view.showSuccess('Pool merged.'); + this._view.enableForm(); + router.replace( + uri.formatClientLink( + 'pool', e.detail.targetPoolName, 'merge'), + null, false); + }, error => { + this._view.showError(error.message); + this._view.enableForm(); + }); + } + + _evtDelete(e) { + this._view.clearMessages(); + this._view.disableForm(); + e.detail.pool.delete() + .then(() => { + const ctx = router.show(uri.formatClientLink('pools')); + ctx.controller.showSuccess('Pool deleted.'); + }, error => { + this._view.showError(error.message); + this._view.enableForm(); + }); + } +} + +module.exports = router => { + router.enter(['pool', ':id', 'edit'], (ctx, next) => { + ctx.controller = new PoolController(ctx, 'edit'); + }); + router.enter(['pool', ':id', 'merge'], (ctx, next) => { + ctx.controller = new PoolController(ctx, 'merge'); + }); + router.enter(['pool', ':id', 'delete'], (ctx, next) => { + ctx.controller = new PoolController(ctx, 'delete'); + }); + router.enter(['pool', ':id'], (ctx, next) => { + ctx.controller = new PoolController(ctx, 'summary'); + }); +}; diff --git a/client/js/controllers/pool_create_controller.js b/client/js/controllers/pool_create_controller.js new file mode 100644 index 00000000..0aa3c764 --- /dev/null +++ b/client/js/controllers/pool_create_controller.js @@ -0,0 +1,63 @@ +'use strict'; + +const router = require('../router.js'); +const api = require('../api.js'); +const misc = require('../util/misc.js'); +const uri = require('../util/uri.js'); +const PoolCategoryList = require('../models/pool_category_list.js'); +const PoolCreateView = require('../views/pool_create_view.js'); +const EmptyView = require('../views/empty_view.js'); + +class PoolCreateController { + constructor(ctx) { + if (!api.hasPrivilege('pools:create')) { + this._view = new EmptyView(); + this._view.showError('You don\'t have privileges to create pools.'); + return; + } + + PoolCategoryList.get() + .then(poolCategoriesResponse => { + const categories = {}; + for (let category of poolCategoriesResponse.results) { + categories[category.name] = category.name; + } + + this._view = new PoolCreateView({ + canCreate: api.hasPrivilege('pools:create'), + categories: categories, + escapeColons: uri.escapeColons, + }); + + this._view.addEventListener('submit', e => this._evtCreate(e)); + }, error => { + this._view = new EmptyView(); + this._view.showError(error.message); + }); + } + + _evtCreate(e) { + this._view.clearMessages(); + this._view.disableForm(); + e.detail.pool.save() + .then(() => { + this._view.clearMessages(); + misc.disableExitConfirmation(); + const ctx = router.show(uri.formatClientLink('pools')); + ctx.controller.showSuccess('Pool created.'); + }, error => { + this._view.showError(error.message); + this._view.enableForm(); + }); + } + + _evtChange(e) { + misc.enableExitConfirmation(); + } +} + +module.exports = router => { + router.enter(['pool', 'create'], (ctx, next) => { + ctx.controller = new PoolCreateController(ctx, 'create'); + }); +}; diff --git a/client/js/controllers/pool_list_controller.js b/client/js/controllers/pool_list_controller.js new file mode 100644 index 00000000..626dc1ec --- /dev/null +++ b/client/js/controllers/pool_list_controller.js @@ -0,0 +1,105 @@ +'use strict'; + +const router = require('../router.js'); +const api = require('../api.js'); +const uri = require('../util/uri.js'); +const PoolList = require('../models/pool_list.js'); +const topNavigation = require('../models/top_navigation.js'); +const PageController = require('../controllers/page_controller.js'); +const PoolsHeaderView = require('../views/pools_header_view.js'); +const PoolsPageView = require('../views/pools_page_view.js'); +const EmptyView = require('../views/empty_view.js'); + +const fields = [ + 'id', + 'names', + /* 'suggestions', + * 'implications', */ + 'creationTime', + 'postCount', + 'category']; + +class PoolListController { + constructor(ctx) { + if (!api.hasPrivilege('pools:list')) { + this._view = new EmptyView(); + this._view.showError('You don\'t have privileges to view pools.'); + return; + } + + topNavigation.activate('pools'); + topNavigation.setTitle('Listing pools'); + + this._ctx = ctx; + this._pageController = new PageController(); + + this._headerView = new PoolsHeaderView({ + hostNode: this._pageController.view.pageHeaderHolderNode, + parameters: ctx.parameters, + canCreate: api.hasPrivilege('pools:create'), + canEditPoolCategories: api.hasPrivilege('poolCategories:edit'), + }); + this._headerView.addEventListener( + 'submit', e => this._evtSubmit(e), + 'navigate', e => this._evtNavigate(e)); + + this._syncPageController(); + } + + showSuccess(message) { + this._pageController.showSuccess(message); + } + + showError(message) { + this._pageController.showError(message); + } + + _evtSubmit(e) { + this._view.clearMessages(); + this._view.disableForm(); + e.detail.pool.save() + .then(() => { + this._installView(e.detail.pool, 'edit'); + this._view.showSuccess('Pool created.'); + router.replace( + uri.formatClientLink( + 'pool', e.detail.pool.id, 'edit'), + null, false); + }, error => { + this._view.showError(error.message); + this._view.enableForm(); + }); + } + + _evtNavigate(e) { + router.showNoDispatch( + uri.formatClientLink('pools', e.detail.parameters)); + Object.assign(this._ctx.parameters, e.detail.parameters); + this._syncPageController(); + } + + _syncPageController() { + this._pageController.run({ + parameters: this._ctx.parameters, + defaultLimit: 50, + getClientUrlForPage: (offset, limit) => { + const parameters = Object.assign( + {}, this._ctx.parameters, {offset: offset, limit: limit}); + return uri.formatClientLink('pools', parameters); + }, + requestPage: (offset, limit) => { + return PoolList.search( + this._ctx.parameters.query, offset, limit, fields); + }, + pageRenderer: pageCtx => { + return new PoolsPageView(pageCtx); + }, + }); + } +} + +module.exports = router => { + router.enter( + ['pools'], + (ctx, next) => { ctx.controller = new PoolListController(ctx); }); +}; 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..3c4bff7c --- /dev/null +++ b/client/js/controls/pool_auto_complete_control.js @@ -0,0 +1,57 @@ +'use strict'; + +const misc = require('../util/misc.js'); +const PoolList = require('../models/pool_list.js'); +const AutoCompleteControl = require('./auto_complete_control.js'); + +function _poolListToMatches(pools, options) { + return [...pools].sort((pool1, pool2) => { + return pool2.postCount - pool1.postCount; + }).map(pool => { + let cssName = misc.makeCssName(pool.category, 'pool'); + // TODO + if (options.isPooledWith(pool.id)) { + cssName += ' disabled'; + } + const caption = ( + '' + + misc.escapeHtml(pool.names[0] + ' (' + pool.postCount + ')') + + ''); + return { + caption: caption, + value: pool, + }; + }); +} + +class PoolAutoCompleteControl extends AutoCompleteControl { + constructor(input, options) { + const minLengthForPartialSearch = 3; + + options = Object.assign({ + isPooledWith: poolId => false, + }, options); + + options.getMatches = text => { + const term = misc.escapeSearchTerm(text); + const query = ( + text.length < minLengthForPartialSearch + ? term + '*' + : '*' + term + '*') + ' sort:post-count'; + + return new Promise((resolve, reject) => { + PoolList.search( + query, 0, this._options.maxResults, + ['id', 'names', 'category', 'postCount']) + .then( + response => resolve( + _poolListToMatches(response.results, this._options)), + reject); + }); + }; + + super(input, options); + } +}; + +module.exports = PoolAutoCompleteControl; diff --git a/client/js/main.js b/client/js/main.js index dd974fd8..61469a2d 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/pool.js b/client/js/models/pool.js new file mode 100644 index 00000000..e51832b7 --- /dev/null +++ b/client/js/models/pool.js @@ -0,0 +1,155 @@ +'use strict'; + +const api = require('../api.js'); +const uri = require('../util/uri.js'); +const events = require('../events.js'); +const misc = require('../util/misc.js'); + +class Pool extends events.EventTarget { + constructor() { + // const PoolList = require('./pool_list.js'); + + super(); + this._orig = {}; + + for (let obj of [this, this._orig]) { + // TODO + // obj._suggestions = new PoolList(); + // obj._implications = new PoolList(); + } + + this._updateFromResponse({}); + } + + get id() { return this._id; } + get names() { return this._names; } + get category() { return this._category; } + get description() { return this._description; } + /* get suggestions() { return this._suggestions; } + * get implications() { return this._implications; } */ + get postCount() { return this._postCount; } + get creationTime() { return this._creationTime; } + get lastEditTime() { return this._lastEditTime; } + + set names(value) { this._names = value; } + set category(value) { this._category = value; } + set description(value) { this._description = value; } + + static fromResponse(response) { + const ret = new Pool(); + ret._updateFromResponse(response); + return ret; + } + + static get(id) { + return api.get(uri.formatApiLink('pool', id)) + .then(response => { + return Promise.resolve(Pool.fromResponse(response)); + }); + } + + save() { + const detail = {version: this._version}; + + // send only changed fields to avoid user privilege violation + if (misc.arraysDiffer(this._names, this._orig._names, true)) { + detail.names = this._names; + } + if (this._category !== this._orig._category) { + detail.category = this._category; + } + if (this._description !== this._orig._description) { + detail.description = this._description; + } + // TODO + // if (misc.arraysDiffer(this._implications, this._orig._implications)) { + // detail.implications = this._implications.map( + // relation => relation.names[0]); + // } + // if (misc.arraysDiffer(this._suggestions, this._orig._suggestions)) { + // detail.suggestions = this._suggestions.map( + // relation => relation.names[0]); + // } + + let promise = this._id ? + api.put(uri.formatApiLink('pool', this._id), detail) : + api.post(uri.formatApiLink('pools'), detail); + return promise + .then(response => { + this._updateFromResponse(response); + this.dispatchEvent(new CustomEvent('change', { + detail: { + pool: this, + }, + })); + return Promise.resolve(); + }); + } + + merge(targetId, addAlias) { + return api.get(uri.formatApiLink('pool', targetId)) + .then(response => { + return api.post(uri.formatApiLink('pool-merge'), { + removeVersion: this._version, + remove: this._id, + mergeToVersion: response.version, + mergeTo: targetId, + }); + }).then(response => { + if (!addAlias) { + return Promise.resolve(response); + } + return api.put(uri.formatApiLink('pool', targetId), { + version: response.version, + names: response.names.concat(this._names), + }); + }).then(response => { + this._updateFromResponse(response); + this.dispatchEvent(new CustomEvent('change', { + detail: { + pool: this, + }, + })); + return Promise.resolve(); + }); + } + + delete() { + return api.delete( + uri.formatApiLink('pool', this._id), + {version: this._version}) + .then(response => { + this.dispatchEvent(new CustomEvent('delete', { + detail: { + pool: this, + }, + })); + return Promise.resolve(); + }); + } + + _updateFromResponse(response) { + const map = { + _id: response.id, + _version: response.version, + _origName: response.names ? response.names[0] : null, + _names: response.names, + _category: response.category, + _description: response.description, + _creationTime: response.creationTime, + _lastEditTime: response.lastEditTime, + _postCount: response.usages || 0, + }; + + for (let obj of [this, this._orig]) { + // TODO + // obj._suggestions.sync(response.suggestions); + // obj._implications.sync(response.implications); + } + + Object.assign(this, map); + Object.assign(this._orig, map); + } +}; + +module.exports = Pool; diff --git a/client/js/models/pool_category.js b/client/js/models/pool_category.js new file mode 100644 index 00000000..4cdb68e6 --- /dev/null +++ b/client/js/models/pool_category.js @@ -0,0 +1,90 @@ +'use strict'; + +const api = require('../api.js'); +const uri = require('../util/uri.js'); +const events = require('../events.js'); + +class PoolCategory extends events.EventTarget { + constructor() { + super(); + this._name = ''; + this._color = '#000000'; + this._poolCount = 0; + this._isDefault = false; + this._origName = null; + this._origColor = null; + } + + get name() { return this._name; } + get color() { return this._color; } + get poolCount() { return this._poolCount; } + get isDefault() { return this._isDefault; } + get isTransient() { return !this._origName; } + + set name(value) { this._name = value; } + set color(value) { this._color = value; } + + static fromResponse(response) { + const ret = new PoolCategory(); + ret._updateFromResponse(response); + return ret; + } + + save() { + const detail = {version: this._version}; + + if (this.name !== this._origName) { + detail.name = this.name; + } + if (this.color !== this._origColor) { + detail.color = this.color; + } + + if (!Object.keys(detail).length) { + return Promise.resolve(); + } + + let promise = this._origName ? + api.put( + uri.formatApiLink('pool-category', this._origName), + detail) : + api.post(uri.formatApiLink('pool-categories'), detail); + + return promise + .then(response => { + this._updateFromResponse(response); + this.dispatchEvent(new CustomEvent('change', { + detail: { + poolCategory: this, + }, + })); + return Promise.resolve(); + }); + } + + delete() { + return api.delete( + uri.formatApiLink('pool-category', this._origName), + {version: this._version}) + .then(response => { + this.dispatchEvent(new CustomEvent('delete', { + detail: { + poolCategory: this, + }, + })); + return Promise.resolve(); + }); + } + + _updateFromResponse(response) { + this._version = response.version; + this._name = response.name; + this._color = response.color; + this._isDefault = response.default; + this._poolCount = response.usages; + this._origName = this.name; + this._origColor = this.color; + } +} + +module.exports = PoolCategory; 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..d72ece29 --- /dev/null +++ b/client/js/models/pool_list.js @@ -0,0 +1,30 @@ +'use strict'; + +const api = require('../api.js'); +const uri = require('../util/uri.js'); +const AbstractList = require('./abstract_list.js'); +const Pool = require('./pool.js'); + +class PoolList extends AbstractList { + static search(text, offset, limit, fields) { + return api.get( + uri.formatApiLink( + 'pools', { + query: text, + offset: offset, + limit: limit, + fields: fields.join(','), + })) + .then(response => { + return Promise.resolve(Object.assign( + {}, + response, + {results: PoolList.fromResponse(response.results)})); + }); + } +} + +PoolList._itemClass = Pool; +PoolList._itemName = 'pool'; + +module.exports = PoolList; diff --git a/client/js/models/top_navigation.js b/client/js/models/top_navigation.js index bf2cffe0..41b1def6 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/misc.js b/client/js/util/misc.js index aa62097d..5911a363 100644 --- a/client/js/util/misc.js +++ b/client/js/util/misc.js @@ -163,6 +163,11 @@ function escapeHtml(unsafe) { } function arraysDiffer(source1, source2, orderImportant) { + if ((source1 instanceof Array && source2 === undefined) + || (source1 === undefined && source2 instanceof Array)) { + return true + } + source1 = [...source1]; source2 = [...source2]; if (orderImportant === true) { diff --git a/client/js/util/views.js b/client/js/util/views.js index 9c26fb0a..38020d82 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -221,6 +221,29 @@ function makeTagLink(name, includeHash, includeCount, tag) { misc.escapeHtml(text)); } +function makePoolLink(pool, includeHash, includeCount, name) { + const category = pool.category; + let text = name ? name : pool.names[0]; + if (includeHash === true) { + text = '#' + text; + } + if (includeCount === true) { + text += ' (' + pool.postCount + ')'; + } + return api.hasPrivilege('pools:view') ? + makeElement( + 'a', + { + href: uri.formatClientLink('pool', pool.id), + class: misc.makeCssName(category, 'pool'), + }, + misc.escapeHtml(text)) : + makeElement( + 'span', + {class: misc.makeCssName(category, 'pool')}, + misc.escapeHtml(text)); +} + function makeUserLink(user) { let text = makeThumbnail(user ? user.avatarUrl : null); text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous'; @@ -400,6 +423,7 @@ function getTemplate(templatePath) { makeDateInput: makeDateInput, makePostLink: makePostLink, makeTagLink: makeTagLink, + makePoolLink: makePoolLink, makeUserLink: makeUserLink, makeFlexboxAlign: makeFlexboxAlign, makeAccessKey: makeAccessKey, @@ -529,6 +553,7 @@ module.exports = { decorateValidator: decorateValidator, makeTagLink: makeTagLink, makePostLink: makePostLink, + makePoolLink: makePoolLink, makeCheckbox: makeCheckbox, makeRadio: makeRadio, syncScrollPosition: syncScrollPosition, diff --git a/client/js/views/help_view.js b/client/js/views/help_view.js index 11f6a39a..5d670836 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..20218e53 --- /dev/null +++ b/client/js/views/pool_create_view.js @@ -0,0 +1,108 @@ +'use strict'; + +const events = require('../events.js'); +const api = require('../api.js'); +const misc = require('../util/misc.js'); +const views = require('../util/views.js'); +const Pool = require('../models/pool.js') + +const template = views.getTemplate('pool-create'); + +class PoolCreateView extends events.EventTarget { + constructor(ctx) { + super(); + + this._hostNode = document.getElementById('content-holder'); + views.replaceContent(this._hostNode, template(ctx)); + + views.decorateValidator(this._formNode); + + if (this._namesFieldNode) { + this._namesFieldNode.addEventListener( + 'input', e => this._evtNameInput(e)); + } + + for (let node of this._formNode.querySelectorAll( + 'input, select, textarea')) { + node.addEventListener( + 'change', e => { + this.dispatchEvent(new CustomEvent('change')); + }); + } + + this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + } + + clearMessages() { + views.clearMessages(this._hostNode); + } + + enableForm() { + views.enableForm(this._formNode); + } + + disableForm() { + views.disableForm(this._formNode); + } + + showSuccess(message) { + views.showSuccess(this._hostNode, message); + } + + showError(message) { + views.showError(this._hostNode, message); + } + + _evtNameInput(e) { + const regex = new RegExp(api.getPoolNameRegex()); + const list = misc.splitByWhitespace(this._namesFieldNode.value); + + if (!list.length) { + this._namesFieldNode.setCustomValidity( + 'Pools must have at least one name.'); + return; + } + + for (let item of list) { + if (!regex.test(item)) { + this._namesFieldNode.setCustomValidity( + `Pool name "${item}" contains invalid symbols.`); + return; + } + } + + this._namesFieldNode.setCustomValidity(''); + } + + _evtSubmit(e) { + e.preventDefault(); + let pool = new Pool() + pool.names = misc.splitByWhitespace(this._namesFieldNode.value); + pool.category = this._categoryFieldNode.value; + pool.description = this._descriptionFieldNode.value; + + this.dispatchEvent(new CustomEvent('submit', { + detail: { + pool: pool, + }, + })); + } + + get _formNode() { + return this._hostNode.querySelector('form'); + } + + get _namesFieldNode() { + return this._formNode.querySelector('.names input'); + } + + get _categoryFieldNode() { + return this._formNode.querySelector('.category select'); + } + + get _descriptionFieldNode() { + return this._formNode.querySelector('.description textarea'); + } +} + +module.exports = PoolCreateView; 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..fac84307 --- /dev/null +++ b/client/js/views/pool_edit_view.js @@ -0,0 +1,115 @@ +'use strict'; + +const events = require('../events.js'); +const api = require('../api.js'); +const misc = require('../util/misc.js'); +const views = require('../util/views.js'); + +const template = views.getTemplate('pool-edit'); + +class PoolEditView extends events.EventTarget { + constructor(ctx) { + super(); + + this._pool = ctx.pool; + this._hostNode = ctx.hostNode; + views.replaceContent(this._hostNode, template(ctx)); + + views.decorateValidator(this._formNode); + + if (this._namesFieldNode) { + this._namesFieldNode.addEventListener( + 'input', e => this._evtNameInput(e)); + } + + for (let node of this._formNode.querySelectorAll( + 'input, select, textarea')) { + node.addEventListener( + 'change', e => { + this.dispatchEvent(new CustomEvent('change')); + }); + } + + this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + } + + clearMessages() { + views.clearMessages(this._hostNode); + } + + enableForm() { + views.enableForm(this._formNode); + } + + disableForm() { + views.disableForm(this._formNode); + } + + showSuccess(message) { + views.showSuccess(this._hostNode, message); + } + + showError(message) { + views.showError(this._hostNode, message); + } + + _evtNameInput(e) { + const regex = new RegExp(api.getPoolNameRegex()); + const list = misc.splitByWhitespace(this._namesFieldNode.value); + + if (!list.length) { + this._namesFieldNode.setCustomValidity( + 'Pools must have at least one name.'); + return; + } + + for (let item of list) { + if (!regex.test(item)) { + this._namesFieldNode.setCustomValidity( + `Pool name "${item}" contains invalid symbols.`); + return; + } + } + + this._namesFieldNode.setCustomValidity(''); + } + + _evtSubmit(e) { + e.preventDefault(); + this.dispatchEvent(new CustomEvent('submit', { + detail: { + pool: this._pool, + + names: this._namesFieldNode ? + misc.splitByWhitespace(this._namesFieldNode.value) : + undefined, + + category: this._categoryFieldNode ? + this._categoryFieldNode.value : + undefined, + + description: this._descriptionFieldNode ? + this._descriptionFieldNode.value : + undefined, + }, + })); + } + + get _formNode() { + return this._hostNode.querySelector('form'); + } + + get _namesFieldNode() { + return this._formNode.querySelector('.names input'); + } + + get _categoryFieldNode() { + return this._formNode.querySelector('.category select'); + } + + get _descriptionFieldNode() { + return this._formNode.querySelector('.description textarea'); + } +} + +module.exports = PoolEditView; diff --git a/client/js/views/pool_merge_view.js b/client/js/views/pool_merge_view.js new file mode 100644 index 00000000..ea9fe6f2 --- /dev/null +++ b/client/js/views/pool_merge_view.js @@ -0,0 +1,77 @@ +'use strict'; + +const events = require('../events.js'); +const api = require('../api.js'); +const views = require('../util/views.js'); +const PoolAutoCompleteControl = + require('../controls/pool_auto_complete_control.js'); + +const template = views.getTemplate('pool-merge'); + +class PoolMergeView extends events.EventTarget { + constructor(ctx) { + super(); + + this._pool = ctx.pool; + this._hostNode = ctx.hostNode; + ctx.poolNamePattern = api.getPoolNameRegex(); + views.replaceContent(this._hostNode, template(ctx)); + + views.decorateValidator(this._formNode); + if (this._targetPoolFieldNode) { + this._autoCompleteControl = new PoolAutoCompleteControl( + this._targetPoolFieldNode, + { + confirm: pool => + this._autoCompleteControl.replaceSelectedText( + pool.names[0], false), + }); + } + + this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + } + + clearMessages() { + views.clearMessages(this._hostNode); + } + + enableForm() { + views.enableForm(this._formNode); + } + + disableForm() { + views.disableForm(this._formNode); + } + + showSuccess(message) { + views.showSuccess(this._hostNode, message); + } + + showError(message) { + views.showError(this._hostNode, message); + } + + _evtSubmit(e) { + e.preventDefault(); + this.dispatchEvent(new CustomEvent('submit', { + detail: { + pool: this._pool, + targetPoolName: this._targetPoolFieldNode.value + }, + })); + } + + get _formNode() { + return this._hostNode.querySelector('form'); + } + + get _targetPoolFieldNode() { + return this._formNode.querySelector('input[name=target-pool]'); + } + + get _addAliasCheckboxNode() { + return this._formNode.querySelector('input[name=alias]'); + } +} + +module.exports = PoolMergeView; 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..0c18f186 --- /dev/null +++ b/client/js/views/pools_header_view.js @@ -0,0 +1,52 @@ +'use strict'; + +const events = require('../events.js'); +const misc = require('../util/misc.js'); +const search = require('../util/search.js'); +const views = require('../util/views.js'); +const PoolAutoCompleteControl = + require('../controls/pool_auto_complete_control.js'); + +const template = views.getTemplate('pools-header'); + +class PoolsHeaderView extends events.EventTarget { + constructor(ctx) { + super(); + + this._hostNode = ctx.hostNode; + views.replaceContent(this._hostNode, template(ctx)); + + if (this._queryInputNode) { + this._autoCompleteControl = new PoolAutoCompleteControl( + this._queryInputNode, + { + confirm: pool => + this._autoCompleteControl.replaceSelectedText( + misc.escapeSearchTerm(pool.names[0]), true), + }); + } + + search.searchInputNodeFocusHelper(this._queryInputNode); + + this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + } + + get _formNode() { + return this._hostNode.querySelector('form'); + } + + get _queryInputNode() { + return this._hostNode.querySelector('[name=search-text]'); + } + + _evtSubmit(e) { + e.preventDefault(); + this._queryInputNode.blur(); + this.dispatchEvent(new CustomEvent('navigate', {detail: {parameters: { + query: this._queryInputNode.value, + page: 1, + }}})); + } +} + +module.exports = PoolsHeaderView; 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/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/server/alembic.ini b/server/alembic.ini index 6a4e5ff4..98ab83d4 100644 --- a/server/alembic.ini +++ b/server/alembic.ini @@ -3,6 +3,7 @@ script_location = szurubooru/migrations # overriden by szurubooru's config sqlalchemy.url = +revision_environment = true [loggers] keys = root,sqlalchemy,alembic diff --git a/server/config.yaml.dist b/server/config.yaml.dist index 4f8f769d..68076239 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -4,7 +4,7 @@ # shown in the website title and on the front page name: szurubooru # full url to the homepage of this szurubooru site, with no trailing slash -domain: # example: http://example.com +domain: localhost # example: http://example.com # used to salt the users' password hashes and generate filenames for static content secret: change @@ -49,6 +49,9 @@ enable_safety: yes tag_name_regex: ^\S+$ tag_category_name_regex: ^[^\s%+#/]+$ +pool_name_regex: ^\S+$ +pool_category_name_regex: ^[^\s%+#/]+$ + # don't make these more restrictive unless you want to annoy people; if you do # customize them, make sure to update the instructions in the registration form # template as well. @@ -58,86 +61,100 @@ user_name_regex: '^[a-zA-Z0-9_-]{1,32}$' default_rank: regular privileges: - 'users:create:self': anonymous # Registration permission - 'users:create:any': administrator - 'users:list': regular - 'users:view': regular - 'users:edit:any:name': moderator - 'users:edit:any:pass': moderator - 'users:edit:any:email': moderator - 'users:edit:any:avatar': moderator - 'users:edit:any:rank': moderator - 'users:edit:self:name': regular - 'users:edit:self:pass': regular - 'users:edit:self:email': regular - 'users:edit:self:avatar': regular - 'users:edit:self:rank': moderator # one can't promote themselves or anyone to upper rank than their own. - 'users:delete:any': administrator - 'users:delete:self': regular + 'users:create:self': anonymous # Registration permission + 'users:create:any': administrator + 'users:list': regular + 'users:view': regular + 'users:edit:any:name': moderator + 'users:edit:any:pass': moderator + 'users:edit:any:email': moderator + 'users:edit:any:avatar': moderator + 'users:edit:any:rank': moderator + 'users:edit:self:name': regular + 'users:edit:self:pass': regular + 'users:edit:self:email': regular + 'users:edit:self:avatar': regular + 'users:edit:self:rank': moderator # one can't promote themselves or anyone to upper rank than their own. + 'users:delete:any': administrator + 'users:delete:self': regular - 'user_tokens:list:any': administrator - 'user_tokens:list:self': regular - 'user_tokens:create:any': administrator - 'user_tokens:create:self': regular - 'user_tokens:edit:any': administrator - 'user_tokens:edit:self': regular - 'user_tokens:delete:any': administrator - 'user_tokens:delete:self': regular + 'user_tokens:list:any': administrator + 'user_tokens:list:self': regular + 'user_tokens:create:any': administrator + 'user_tokens:create:self': regular + 'user_tokens:edit:any': administrator + 'user_tokens:edit:self': regular + 'user_tokens:delete:any': administrator + 'user_tokens:delete:self': regular - 'posts:create:anonymous': regular - 'posts:create:identified': regular - 'posts:list': anonymous - 'posts:reverse_search': regular - 'posts:view': anonymous - 'posts:view:featured': anonymous - 'posts:edit:content': power - 'posts:edit:flags': regular - 'posts:edit:notes': regular - 'posts:edit:relations': regular - 'posts:edit:safety': power - 'posts:edit:source': regular - 'posts:edit:tags': regular - 'posts:edit:thumbnail': power - 'posts:feature': moderator - 'posts:delete': moderator - 'posts:score': regular - 'posts:merge': moderator - 'posts:favorite': regular - 'posts:bulk-edit:tags': power - 'posts:bulk-edit:safety': power + 'posts:create:anonymous': regular + 'posts:create:identified': regular + 'posts:list': anonymous + 'posts:reverse_search': regular + 'posts:view': anonymous + 'posts:view:featured': anonymous + 'posts:edit:content': power + 'posts:edit:flags': regular + 'posts:edit:notes': regular + 'posts:edit:relations': regular + 'posts:edit:safety': power + 'posts:edit:source': regular + 'posts:edit:tags': regular + 'posts:edit:thumbnail': power + 'posts:feature': moderator + 'posts:delete': moderator + 'posts:score': regular + 'posts:merge': moderator + 'posts:favorite': regular + 'posts:bulk-edit:tags': power + 'posts:bulk-edit:safety': power - 'tags:create': regular - 'tags:edit:names': power - 'tags:edit:category': power - 'tags:edit:description': power - 'tags:edit:implications': power - 'tags:edit:suggestions': power - 'tags:list': regular - 'tags:view': anonymous - 'tags:merge': moderator - 'tags:delete': moderator + 'tags:create': regular + 'tags:edit:names': power + 'tags:edit:category': power + 'tags:edit:description': power + 'tags:edit:implications': power + 'tags:edit:suggestions': power + 'tags:list': regular + 'tags:view': anonymous + 'tags:merge': moderator + 'tags:delete': moderator - 'tag_categories:create': moderator - 'tag_categories:edit:name': moderator - 'tag_categories:edit:color': moderator - 'tag_categories:list': anonymous - 'tag_categories:view': anonymous - 'tag_categories:delete': moderator - 'tag_categories:set_default': moderator + 'tag_categories:create': moderator + 'tag_categories:edit:name': moderator + 'tag_categories:edit:color': moderator + 'tag_categories:list': anonymous + 'tag_categories:view': anonymous + 'tag_categories:delete': moderator + 'tag_categories:set_default': moderator - 'comments:create': regular - 'comments:delete:any': moderator - 'comments:delete:own': regular - 'comments:edit:any': moderator - 'comments:edit:own': regular - 'comments:list': regular - 'comments:view': regular - 'comments:score': regular + 'pools:create': regular + 'pools:list': regular + 'pools:view': anonymous + 'pools:merge': moderator + 'pools:delete': moderator - 'snapshots:list': power + 'pool_categories:create': moderator + 'pool_categories:edit:name': moderator + 'pool_categories:edit:color': moderator + 'pool_categories:list': anonymous + 'pool_categories:view': anonymous + 'pool_categories:delete': moderator + 'pool_categories:set_default': moderator - 'uploads:create': regular - 'uploads:use_downloader': power + 'comments:create': regular + 'comments:delete:any': moderator + 'comments:delete:own': regular + 'comments:edit:any': moderator + 'comments:edit:own': regular + 'comments:list': regular + 'comments:view': regular + 'comments:score': regular + + 'snapshots:list': power + + 'uploads:create': regular + 'uploads:use_downloader': power ## ONLY SET THESE IF DEPLOYING OUTSIDE OF DOCKER #debug: 0 # generate server logs? diff --git a/server/requirements.txt b/server/requirements.txt index 810452e0..8a337f9e 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -9,3 +9,4 @@ pillow>=4.3.0 pynacl>=1.2.1 pytz>=2018.3 pyRFC3339>=1.0 +youtube_dl>=2020.5.3 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..b2ec11b9 --- /dev/null +++ b/server/szurubooru/api/pool_api.py @@ -0,0 +1,127 @@ +from typing import Optional, List, Dict +from datetime import datetime +from szurubooru import db, model, search, rest +from szurubooru.func import auth, pools, snapshots, serialization, versions + + +_search_executor = search.Executor(search.configs.PoolSearchConfig()) + + +def _serialize(ctx: rest.Context, pool: model.Pool) -> rest.Response: + return pools.serialize_pool( + pool, options=serialization.get_serialization_options(ctx)) + + +def _get_pool(params: Dict[str, str]) -> model.Pool: + return pools.get_pool_by_id(params['pool_id']) + + +# def _create_if_needed(pool_names: List[str], user: model.User) -> None: +# if not pool_names: +# return +# _existing_pools, new_pools = pools.get_or_create_pools_by_names(pool_names) +# if len(new_pools): +# auth.verify_privilege(user, 'pools:create') +# db.session.flush() +# for pool in new_pools: +# snapshots.create(pool, user) + + +@rest.routes.get('/pools/?') +def get_pools(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: + auth.verify_privilege(ctx.user, 'pools:list') + return _search_executor.execute_and_serialize( + ctx, lambda pool: _serialize(ctx, pool)) + + +@rest.routes.post('/pools/?') +def create_pool( + ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: + auth.verify_privilege(ctx.user, 'pools:create') + + names = ctx.get_param_as_string_list('names') + category = ctx.get_param_as_string('category') + description = ctx.get_param_as_string('description', default='') + # TODO + # suggestions = ctx.get_param_as_string_list('suggestions', default=[]) + # implications = ctx.get_param_as_string_list('implications', default=[]) + + # _create_if_needed(suggestions, ctx.user) + # _create_if_needed(implications, ctx.user) + + pool = pools.create_pool(names, category) + pools.update_pool_description(pool, description) + ctx.session.add(pool) + ctx.session.flush() + snapshots.create(pool, ctx.user) + ctx.session.commit() + return _serialize(ctx, pool) + + +@rest.routes.get('/pool/(?P[^/]+)/?') +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')) + # TODO + # if ctx.has_param('suggestions'): + # auth.verify_privilege(ctx.user, 'pools:edit:suggestions') + # suggestions = ctx.get_param_as_string_list('suggestions') + # _create_if_needed(suggestions, ctx.user) + # pools.update_pool_suggestions(pool, suggestions) + # if ctx.has_param('implications'): + # auth.verify_privilege(ctx.user, 'pools:edit:implications') + # implications = ctx.get_param_as_string_list('implications') + # _create_if_needed(implications, ctx.user) + # pools.update_pool_implications(pool, implications) + pool.last_edit_time = datetime.utcnow() + ctx.session.flush() + snapshots.modify(pool, ctx.user) + ctx.session.commit() + return _serialize(ctx, pool) + + +@rest.routes.delete('/pool/(?P[^/]+)/?') +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..eca934a9 --- /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..d35fd496 --- /dev/null +++ b/server/szurubooru/func/pools.py @@ -0,0 +1,302 @@ +import re +from typing import Any, Optional, Tuple, List, Dict, Callable +from datetime import datetime +import sqlalchemy as sa +from szurubooru import config, db, model, errors, rest +from szurubooru.func import util, pool_categories, serialization + + + +class PoolNotFoundError(errors.NotFoundError): + pass + + +class PoolAlreadyExistsError(errors.ValidationError): + pass + + +class PoolIsInUseError(errors.ValidationError): + pass + + +class InvalidPoolNameError(errors.ValidationError): + pass + + +class InvalidPoolRelationError(errors.ValidationError): + pass + + +class InvalidPoolCategoryError(errors.ValidationError): + pass + + +class InvalidPoolDescriptionError(errors.ValidationError): + pass + + +def _verify_name_validity(name: str) -> None: + if util.value_exceeds_column_size(name, model.PoolName.name): + raise InvalidPoolNameError('Name is too long.') + name_regex = config.config['pool_name_regex'] + if not re.match(name_regex, name): + raise InvalidPoolNameError('Name must satisfy regex %r.' % name_regex) + + +def _get_names(pool: model.Pool) -> List[str]: + assert pool + return [pool_name.name for pool_name in pool.names] + + +def _lower_list(names: List[str]) -> List[str]: + return [name.lower() for name in names] + + +def _check_name_intersection( + names1: List[str], names2: List[str], case_sensitive: bool) -> bool: + if not case_sensitive: + names1 = _lower_list(names1) + names2 = _lower_list(names2) + return len(set(names1).intersection(names2)) > 0 + + +def sort_pools(pools: List[model.Pool]) -> List[model.Pool]: + default_category_name = pool_categories.get_default_category_name() + return sorted( + pools, + key=lambda pool: ( + default_category_name == pool.category.name, + pool.category.name, + pool.names[0].name) + ) + + +class PoolSerializer(serialization.BaseSerializer): + def __init__(self, pool: model.Pool) -> None: + self.pool = pool + + def _serializers(self) -> Dict[str, Callable[[], Any]]: + return { + 'id': self.serialize_id, + 'names': self.serialize_names, + 'category': self.serialize_category, + 'version': self.serialize_version, + 'description': self.serialize_description, + 'creationTime': self.serialize_creation_time, + 'lastEditTime': self.serialize_last_edit_time, + 'postCount': self.serialize_post_count + } + + def serialize_id(self) -> Any: + return self.pool.pool_id + + def serialize_names(self) -> Any: + return [pool_name.name for pool_name in self.pool.names] + + def serialize_category(self) -> Any: + return self.pool.category.name + + def serialize_version(self) -> Any: + return self.pool.version + + def serialize_description(self) -> Any: + return self.pool.description + + def serialize_creation_time(self) -> Any: + return self.pool.creation_time + + def serialize_last_edit_time(self) -> Any: + return self.pool.last_edit_time + + def serialize_post_count(self) -> Any: + return self.pool.post_count + + +def serialize_pool( + pool: model.Pool, options: List[str] = []) -> Optional[rest.Response]: + if not pool: + return None + return PoolSerializer(pool).serialize(options) + + +def try_get_pool_by_id(pool_id: int) -> Optional[model.Pool]: + return ( + db.session + .query(model.Pool) + .filter(model.Pool.pool_id == pool_id) + .one_or_none()) + + +def get_pool_by_id(pool_id: int) -> model.Pool: + pool = try_get_pool_by_id(pool_id) + if not pool: + raise PoolNotFoundError('Pool %r not found.' % pool_id) + return pool + + +def try_get_pool_by_name(name: str) -> Optional[model.Pool]: + return ( + db.session + .query(model.Pool) + .join(model.PoolName) + .filter(sa.func.lower(model.PoolName.name) == name.lower()) + .one_or_none()) + + +def get_pool_by_name(name: str) -> model.Pool: + pool = try_get_pool_by_name(name) + if not pool: + raise PoolNotFoundError('Pool %r not found.' % name) + return pool + + +def get_pools_by_names(names: List[str]) -> List[model.Pool]: + names = util.icase_unique(names) + if len(names) == 0: + return [] + return ( + db.session.query(model.Pool) + .join(model.PoolName) + .filter( + sa.sql.or_( + sa.func.lower(model.PoolName.name) == name.lower() + for name in names)) + .all()) + + +def get_or_create_pools_by_names( + names: List[str]) -> Tuple[List[model.Pool], List[model.Pool]]: + names = util.icase_unique(names) + existing_pools = get_pools_by_names(names) + new_pools = [] + pool_category_name = pool_categories.get_default_category_name() + for name in names: + found = False + for existing_pool in existing_pools: + if _check_name_intersection( + _get_names(existing_pool), [name], False): + found = True + break + if not found: + new_pool = create_pool( + names=[name], + category_name=pool_category_name) + db.session.add(new_pool) + new_pools.append(new_pool) + return existing_pools, new_pools + + +def delete(source_pool: model.Pool) -> None: + assert source_pool + db.session.delete(source_pool) + + +def merge_pools(source_pool: model.Pool, target_pool: model.Pool) -> None: + assert source_pool + assert target_pool + if source_pool.pool_id == target_pool.pool_id: + raise InvalidPoolRelationError('Cannot merge pool with itself.') + + def merge_posts(source_pool_id: int, target_pool_id: int) -> None: + pass + # alias1 = model.PostPool + # alias2 = sa.orm.util.aliased(model.PostPool) + # update_stmt = ( + # sa.sql.expression.update(alias1) + # .where(alias1.pool_id == source_pool_id)) + # update_stmt = ( + # update_stmt + # .where( + # ~sa.exists() + # .where(alias1.post_id == alias2.post_id) + # .where(alias2.pool_id == target_pool_id))) + # update_stmt = update_stmt.values(pool_id=target_pool_id) + # db.session.execute(update_stmt) + + def merge_relations( + table: model.Base, source_pool_id: int, target_pool_id: int) -> None: + alias1 = table + alias2 = sa.orm.util.aliased(table) + update_stmt = ( + sa.sql.expression.update(alias1) + .where(alias1.parent_id == source_pool_id) + .where(alias1.child_id != target_pool_id) + .where( + ~sa.exists() + .where(alias2.child_id == alias1.child_id) + .where(alias2.parent_id == target_pool_id)) + .values(parent_id=target_pool_id)) + db.session.execute(update_stmt) + + update_stmt = ( + sa.sql.expression.update(alias1) + .where(alias1.child_id == source_pool_id) + .where(alias1.parent_id != target_pool_id) + .where( + ~sa.exists() + .where(alias2.parent_id == alias1.parent_id) + .where(alias2.child_id == target_pool_id)) + .values(child_id=target_pool_id)) + db.session.execute(update_stmt) + + merge_posts(source_pool.pool_id, target_pool.pool_id) + delete(source_pool) + + +def create_pool( + names: List[str], + category_name: str) -> model.Pool: + pool = model.Pool() + pool.creation_time = datetime.utcnow() + update_pool_names(pool, names) + update_pool_category_name(pool, category_name) + return pool + + +def update_pool_category_name(pool: model.Pool, category_name: str) -> None: + assert pool + pool.category = pool_categories.get_category_by_name(category_name) + + +def update_pool_names(pool: model.Pool, names: List[str]) -> None: + # sanitize + assert pool + names = util.icase_unique([name for name in names if name]) + if not len(names): + raise InvalidPoolNameError('At least one name must be specified.') + for name in names: + _verify_name_validity(name) + + # check for existing pools + expr = sa.sql.false() + for name in names: + expr = expr | (sa.func.lower(model.PoolName.name) == name.lower()) + if pool.pool_id: + expr = expr & (model.PoolName.pool_id != pool.pool_id) + existing_pools = db.session.query(model.PoolName).filter(expr).all() + if len(existing_pools): + raise PoolAlreadyExistsError( + 'One of names is already used by another pool.') + + # remove unwanted items + for pool_name in pool.names[:]: + if not _check_name_intersection([pool_name.name], names, True): + pool.names.remove(pool_name) + # add wanted items + for name in names: + if not _check_name_intersection(_get_names(pool), [name], True): + pool.names.append(model.PoolName(name, -1)) + + # set alias order to match the request + for i, name in enumerate(names): + for pool_name in pool.names: + if pool_name.name.lower() == name.lower(): + pool_name.order = i + + +def update_pool_description(pool: model.Pool, description: str) -> None: + assert pool + if util.value_exceeds_column_size(description, model.Pool.description): + raise InvalidPoolDescriptionError('Description is too long.') + pool.description = description or None + diff --git a/server/szurubooru/func/snapshots.py b/server/szurubooru/func/snapshots.py index eade3a19..e9ce07c9 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, + # TODO + } + + def get_post_snapshot(post: model.Post) -> Dict[str, Any]: assert post return { @@ -47,6 +65,8 @@ _snapshot_factories = { 'tag_category': lambda entity: get_tag_category_snapshot(entity), 'tag': lambda entity: get_tag_snapshot(entity), 'post': lambda entity: get_post_snapshot(entity), + 'pool_category': lambda entity: get_pool_category_snapshot(entity), + 'pool': lambda entity: get_pool_snapshot(entity), } # type: Dict[model.Base, Callable[[model.Base], Dict[str ,Any]]] 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..782833c8 --- /dev/null +++ b/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py @@ -0,0 +1,60 @@ +''' +add default pool category + +Revision ID: 54de8acc6cef +Created at: 2020-05-03 14:57:46.825766 +''' + +import sqlalchemy as sa +from alembic import op + + +revision = '54de8acc6cef' +down_revision = '6a2f424ec9d2' +branch_labels = None +depends_on = None + + +Base = sa.ext.declarative.declarative_base() + + +class PoolCategory(Base): + __tablename__ = 'pool_category' + __table_args__ = {'extend_existing': True} + + pool_category_id = sa.Column('id', sa.Integer, primary_key=True) + version = sa.Column('version', sa.Integer, nullable=False) + name = sa.Column('name', sa.Unicode(32), nullable=False) + color = sa.Column('color', sa.Unicode(32), nullable=False) + default = sa.Column('default', sa.Boolean, nullable=False) + + __mapper_args__ = { + 'version_id_col': version, + 'version_id_generator': False, + } + +def upgrade(): + session = sa.orm.session.Session(bind=op.get_bind()) + if session.query(PoolCategory).count() == 0: + category = PoolCategory() + category.name = 'default' + category.color = 'default' + category.version = 1 + category.default = True + session.add(category) + session.commit() + + +def downgrade(): + session = sa.orm.session.Session(bind=op.get_bind()) + default_category = ( + session + .query(PoolCategory) + .filter(PoolCategory.name == 'default') + .filter(PoolCategory.color == 'default') + .filter(PoolCategory.version == 1) + .filter(PoolCategory.default == 1) + .one_or_none()) + if default_category: + session.delete(default_category) + session.commit() 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..9b663ac6 --- /dev/null +++ b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py @@ -0,0 +1,52 @@ +''' +create pool tables + +Revision ID: 6a2f424ec9d2 +Created at: 2020-05-03 14:47:59.136410 +''' + +import sqlalchemy as sa +from alembic import op + + +revision = '6a2f424ec9d2' +down_revision = '1e280b5d5df1' +branch_labels = None +depends_on = None + +def upgrade(): + op.create_table( + 'pool_category', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False, default=1), + sa.Column('name', sa.Unicode(length=32), nullable=False), + sa.Column('color', sa.Unicode(length=32), nullable=False), + sa.Column('default', sa.Boolean(), nullable=False, default=False), + sa.PrimaryKeyConstraint('id')) + + op.create_table( + 'pool', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False, default=1), + sa.Column('description', sa.UnicodeText(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=False), + sa.Column('creation_time', sa.DateTime(), nullable=False), + sa.Column('last_edit_time', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['pool_category.id']), + sa.PrimaryKeyConstraint('id')) + + op.create_table( + 'pool_name', + sa.Column('pool_name_id', sa.Integer(), nullable=False), + sa.Column('pool_id', sa.Integer(), nullable=False), + sa.Column('name', sa.Unicode(length=256), nullable=False), + sa.Column('ord', sa.Integer(), nullable=False, index=True), + sa.ForeignKeyConstraint(['pool_id'], ['pool.id']), + sa.PrimaryKeyConstraint('pool_name_id'), + sa.UniqueConstraint('name')) + +def downgrade(): + op.drop_index(op.f('ix_pool_name_ord'), table_name='pool_name') + op.drop_table('pool_name') + op.drop_table('pool') + op.drop_table('pool_category') diff --git a/server/szurubooru/model/__init__.py b/server/szurubooru/model/__init__.py index a043bd1f..cf18b0f7 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 +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..2505150e --- /dev/null +++ b/server/szurubooru/model/pool.py @@ -0,0 +1,70 @@ +import sqlalchemy as sa +from szurubooru.model.base import Base + + +class PoolName(Base): + __tablename__ = 'pool_name' + + pool_name_id = sa.Column('pool_name_id', sa.Integer, primary_key=True) + pool_id = sa.Column( + 'pool_id', + sa.Integer, + sa.ForeignKey('pool.id'), + nullable=False, + index=True) + name = sa.Column('name', sa.Unicode(128), nullable=False, unique=True) + order = sa.Column('ord', sa.Integer, nullable=False, index=True) + + def __init__(self, name: str, order: int) -> None: + self.name = name + self.order = order + +class Pool(Base): + __tablename__ = 'pool' + + pool_id = sa.Column('id', sa.Integer, primary_key=True) + category_id = sa.Column( + 'category_id', + sa.Integer, + sa.ForeignKey('pool_category.id'), + nullable=False, + index=True) + version = sa.Column('version', sa.Integer, default=1, nullable=False) + creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) + last_edit_time = sa.Column('last_edit_time', sa.DateTime) + description = sa.Column('description', sa.UnicodeText, default=None) + + category = sa.orm.relationship('PoolCategory', lazy='joined') + names = sa.orm.relationship( + 'PoolName', + cascade='all,delete-orphan', + lazy='joined', + order_by='PoolName.order') + + # post_count = sa.orm.column_property( + # sa.sql.expression.select( + # [sa.sql.expression.func.count(PostPool.post_id)]) + # .where(PostPool.pool_id == pool_id) + # .correlate_except(PostPool)) + # TODO + from random import randint + post_count = sa.orm.column_property( + sa.sql.expression.select([randint(1, 1000)]) + .limit(1) + .as_scalar()) + + first_name = sa.orm.column_property( + ( + sa.sql.expression.select([PoolName.name]) + .where(PoolName.pool_id == pool_id) + .order_by(PoolName.order) + .limit(1) + .as_scalar() + ), + deferred=True) + + + __mapper_args__ = { + 'version_id_col': version, + 'version_id_generator': False, + } diff --git a/server/szurubooru/model/pool_category.py b/server/szurubooru/model/pool_category.py new file mode 100644 index 00000000..5af0b11d --- /dev/null +++ b/server/szurubooru/model/pool_category.py @@ -0,0 +1,28 @@ +from typing import Optional +import sqlalchemy as sa +from szurubooru.model.base import Base +from szurubooru.model.pool import Pool + + +class PoolCategory(Base): + __tablename__ = 'pool_category' + + pool_category_id = sa.Column('id', sa.Integer, primary_key=True) + version = sa.Column('version', sa.Integer, default=1, nullable=False) + name = sa.Column('name', sa.Unicode(32), nullable=False) + color = sa.Column( + 'color', sa.Unicode(32), nullable=False, default='#000000') + default = sa.Column('default', sa.Boolean, nullable=False, default=False) + + def __init__(self, name: Optional[str] = None) -> None: + self.name = name + + pool_count = sa.orm.column_property( + sa.sql.expression.select([sa.sql.expression.func.count('Pool.pool_id')]) + .where(Pool.category_id == pool_category_id) + .correlate_except(sa.table('Pool'))) + + __mapper_args__ = { + 'version_id_col': version, + 'version_id_generator': False, + } 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) + ), + ]) From e6bf102bc03504d9abc584f15da70a5fed7fa950 Mon Sep 17 00:00:00 2001 From: Ruin0x11 Date: Mon, 4 May 2020 00:09:33 -0700 Subject: [PATCH 2/9] Add list of posts to pools --- client/css/pool-list-view.styl | 4 +- client/html/help_search_posts.tpl | 6 +- client/html/pool_create.tpl | 6 ++ client/html/pool_edit.tpl | 8 +++ client/html/pool_merge.tpl | 2 +- client/html/pool_summary.tpl | 4 +- client/html/pools_page.tpl | 4 +- client/js/controllers/pool_controller.js | 8 +++ .../js/controllers/pool_create_controller.js | 6 +- client/js/controllers/pool_list_controller.js | 10 ++-- client/js/controllers/post_list_controller.js | 7 ++- client/js/controllers/tag_list_controller.js | 7 ++- client/js/controllers/user_list_controller.js | 3 +- .../js/controls/pool_auto_complete_control.js | 8 --- client/js/models/pool.js | 27 +++------ client/js/util/misc.js | 5 -- client/js/util/views.js | 8 +-- client/js/views/pool_create_view.js | 36 ++++++++++-- client/js/views/pool_edit_view.js | 31 ++++++++++- client/js/views/snapshots_page_view.js | 15 +++++ server/config.yaml.dist | 4 ++ server/szurubooru/api/pool_api.py | 37 +++---------- server/szurubooru/func/pools.py | 36 ++++++++++-- server/szurubooru/func/posts.py | 16 ++++++ server/szurubooru/func/snapshots.py | 2 +- .../6a2f424ec9d2_create_pool_tables.py | 10 ++++ server/szurubooru/model/__init__.py | 2 +- server/szurubooru/model/pool.py | 55 +++++++++++++++---- .../search/configs/post_search_config.py | 17 ++++++ 29 files changed, 267 insertions(+), 117 deletions(-) diff --git a/client/css/pool-list-view.styl b/client/css/pool-list-view.styl index 2333a372..2daf6941 100644 --- a/client/css/pool-list-view.styl +++ b/client/css/pool-list-view.styl @@ -14,7 +14,7 @@ white-space: nowrap background: $top-navigation-color .names - width: 28% + width: 84% .usages text-align: center width: 8% @@ -33,7 +33,7 @@ &:not(:last-child):after content: ', ' @media (max-width: 800px) - .implications, .suggestions + .posts display: none .pool-list-header diff --git a/client/html/help_search_posts.tpl b/client/html/help_search_posts.tpl index d1097b79..30a986f0 100644 --- a/client/html/help_search_posts.tpl +++ b/client/html/help_search_posts.tpl @@ -20,7 +20,7 @@
    uploaderuploaded by given use (accepts wildcards)ruploaded by given user (accepts wildcards)
    uploadsource having given source URL (accepts wildcards)
    poolbelonging to given pool ID
    tag-count having given number of tags
      <% for (let name of pool.names) { %> -
    • <%= ctx.makePoolLink(pool, false, false, name) %>
    • +
    • <%= ctx.makePoolLink(pool.id, false, false, pool, name) %>
    • <% } %>
    - '><%- pool.postCount %> + '><%- pool.postCount %> <%= ctx.makeRelativeTime(pool.creationTime) %> diff --git a/client/js/controllers/pool_controller.js b/client/js/controllers/pool_controller.js index 8623cf84..99e2e9bc 100644 --- a/client/js/controllers/pool_controller.js +++ b/client/js/controllers/pool_controller.js @@ -5,6 +5,7 @@ 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'); @@ -42,6 +43,7 @@ class PoolController { 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, @@ -84,6 +86,12 @@ class PoolController { if (e.detail.description !== undefined) { e.detail.pool.description = e.detail.description; } + if (e.detail.posts !== undefined) { + e.detail.pool.posts.clear() + for (let post_id of e.detail.posts) { + e.detail.pool.posts.add(Post.fromResponse({ id: parseInt(post_id) })) + } + } e.detail.pool.save().then(() => { this._view.showSuccess('Pool saved.'); this._view.enableForm(); diff --git a/client/js/controllers/pool_create_controller.js b/client/js/controllers/pool_create_controller.js index 0aa3c764..ef6dded2 100644 --- a/client/js/controllers/pool_create_controller.js +++ b/client/js/controllers/pool_create_controller.js @@ -39,7 +39,7 @@ class PoolCreateController { _evtCreate(e) { this._view.clearMessages(); this._view.disableForm(); - e.detail.pool.save() + api.post(uri.formatApiLink('pool'), e.detail) .then(() => { this._view.clearMessages(); misc.disableExitConfirmation(); @@ -50,10 +50,6 @@ class PoolCreateController { this._view.enableForm(); }); } - - _evtChange(e) { - misc.enableExitConfirmation(); - } } module.exports = router => { diff --git a/client/js/controllers/pool_list_controller.js b/client/js/controllers/pool_list_controller.js index 626dc1ec..1de3926c 100644 --- a/client/js/controllers/pool_list_controller.js +++ b/client/js/controllers/pool_list_controller.js @@ -13,26 +13,26 @@ const EmptyView = require('../views/empty_view.js'); const fields = [ 'id', 'names', - /* 'suggestions', - * 'implications', */ + '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._ctx = ctx; - this._pageController = new PageController(); - this._headerView = new PoolsHeaderView({ hostNode: this._pageController.view.pageHeaderHolderNode, parameters: ctx.parameters, diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js index fd1adfea..c79824af 100644 --- a/client/js/controllers/post_list_controller.js +++ b/client/js/controllers/post_list_controller.js @@ -17,18 +17,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 8bc7dbba..e82461d0 100644 --- a/client/js/controllers/tag_list_controller.js +++ b/client/js/controllers/tag_list_controller.js @@ -20,18 +20,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 fa878d85..5d666f8a 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 index 3c4bff7c..0230c41d 100644 --- a/client/js/controls/pool_auto_complete_control.js +++ b/client/js/controls/pool_auto_complete_control.js @@ -9,10 +9,6 @@ function _poolListToMatches(pools, options) { return pool2.postCount - pool1.postCount; }).map(pool => { let cssName = misc.makeCssName(pool.category, 'pool'); - // TODO - if (options.isPooledWith(pool.id)) { - cssName += ' disabled'; - } const caption = ( '' + misc.escapeHtml(pool.names[0] + ' (' + pool.postCount + ')') @@ -28,10 +24,6 @@ class PoolAutoCompleteControl extends AutoCompleteControl { constructor(input, options) { const minLengthForPartialSearch = 3; - options = Object.assign({ - isPooledWith: poolId => false, - }, options); - options.getMatches = text => { const term = misc.escapeSearchTerm(text); const query = ( diff --git a/client/js/models/pool.js b/client/js/models/pool.js index e51832b7..8099d2bd 100644 --- a/client/js/models/pool.js +++ b/client/js/models/pool.js @@ -7,15 +7,13 @@ const misc = require('../util/misc.js'); class Pool extends events.EventTarget { constructor() { - // const PoolList = require('./pool_list.js'); + const PostList = require('./post_list.js'); super(); this._orig = {}; for (let obj of [this, this._orig]) { - // TODO - // obj._suggestions = new PoolList(); - // obj._implications = new PoolList(); + obj._posts = new PostList(); } this._updateFromResponse({}); @@ -25,8 +23,7 @@ class Pool extends events.EventTarget { get names() { return this._names; } get category() { return this._category; } get description() { return this._description; } - /* get suggestions() { return this._suggestions; } - * get implications() { return this._implications; } */ + get posts() { return this._posts; } get postCount() { return this._postCount; } get creationTime() { return this._creationTime; } get lastEditTime() { return this._lastEditTime; } @@ -61,15 +58,9 @@ class Pool extends events.EventTarget { if (this._description !== this._orig._description) { detail.description = this._description; } - // TODO - // if (misc.arraysDiffer(this._implications, this._orig._implications)) { - // detail.implications = this._implications.map( - // relation => relation.names[0]); - // } - // if (misc.arraysDiffer(this._suggestions, this._orig._suggestions)) { - // detail.suggestions = this._suggestions.map( - // relation => relation.names[0]); - // } + 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) : @@ -138,13 +129,11 @@ class Pool extends events.EventTarget { _description: response.description, _creationTime: response.creationTime, _lastEditTime: response.lastEditTime, - _postCount: response.usages || 0, + _postCount: response.postCount || 0, }; for (let obj of [this, this._orig]) { - // TODO - // obj._suggestions.sync(response.suggestions); - // obj._implications.sync(response.implications); + obj._posts.sync(response.posts); } Object.assign(this, map); diff --git a/client/js/util/misc.js b/client/js/util/misc.js index 5911a363..aa62097d 100644 --- a/client/js/util/misc.js +++ b/client/js/util/misc.js @@ -163,11 +163,6 @@ function escapeHtml(unsafe) { } function arraysDiffer(source1, source2, orderImportant) { - if ((source1 instanceof Array && source2 === undefined) - || (source1 === undefined && source2 instanceof Array)) { - return true - } - source1 = [...source1]; source2 = [...source2]; if (orderImportant === true) { diff --git a/client/js/util/views.js b/client/js/util/views.js index 38020d82..1a4d03db 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -221,20 +221,20 @@ function makeTagLink(name, includeHash, includeCount, tag) { misc.escapeHtml(text)); } -function makePoolLink(pool, includeHash, includeCount, name) { - const category = pool.category; +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.postCount + ')'; + text += ' (' + (pool ? pool.postCount : 0) + ')'; } return api.hasPrivilege('pools:view') ? makeElement( 'a', { - href: uri.formatClientLink('pool', pool.id), + href: uri.formatClientLink('pool', id), class: misc.makeCssName(category, 'pool'), }, misc.escapeHtml(text)) : diff --git a/client/js/views/pool_create_view.js b/client/js/views/pool_create_view.js index 20218e53..26fca59c 100644 --- a/client/js/views/pool_create_view.js +++ b/client/js/views/pool_create_view.js @@ -22,8 +22,13 @@ class PoolCreateView extends events.EventTarget { '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')) { + 'input, select, textarea, posts')) { node.addEventListener( 'change', e => { this.dispatchEvent(new CustomEvent('change')); @@ -74,16 +79,31 @@ class PoolCreateView extends events.EventTarget { 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(); - let pool = new Pool() - pool.names = misc.splitByWhitespace(this._namesFieldNode.value); - pool.category = this._categoryFieldNode.value; - pool.description = this._descriptionFieldNode.value; this.dispatchEvent(new CustomEvent('submit', { detail: { - pool: pool, + names: misc.splitByWhitespace(this._namesFieldNode.value), + category: this._categoryFieldNode.value, + description: this._descriptionFieldNode.value, + posts: misc.splitByWhitespace(this._postsFieldNode.value) + .map(i => parseInt(i, 10)) }, })); } @@ -103,6 +123,10 @@ class PoolCreateView extends events.EventTarget { 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_edit_view.js b/client/js/views/pool_edit_view.js index fac84307..7cd8bc9e 100644 --- a/client/js/views/pool_edit_view.js +++ b/client/js/views/pool_edit_view.js @@ -4,6 +4,7 @@ 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'); @@ -22,8 +23,13 @@ class PoolEditView extends events.EventTarget { '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')) { + 'input, select, textarea, posts')) { node.addEventListener( 'change', e => { this.dispatchEvent(new CustomEvent('change')); @@ -74,6 +80,21 @@ class PoolEditView extends events.EventTarget { 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', { @@ -91,6 +112,10 @@ class PoolEditView extends events.EventTarget { description: this._descriptionFieldNode ? this._descriptionFieldNode.value : undefined, + + posts: this._postsFieldNode ? + misc.splitByWhitespace(this._postsFieldNode.value) : + undefined, }, })); } @@ -110,6 +135,10 @@ class PoolEditView extends events.EventTarget { 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/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/server/config.yaml.dist b/server/config.yaml.dist index 68076239..baf3a34e 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -129,6 +129,10 @@ privileges: '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 diff --git a/server/szurubooru/api/pool_api.py b/server/szurubooru/api/pool_api.py index b2ec11b9..69b99515 100644 --- a/server/szurubooru/api/pool_api.py +++ b/server/szurubooru/api/pool_api.py @@ -16,17 +16,6 @@ def _get_pool(params: Dict[str, str]) -> model.Pool: return pools.get_pool_by_id(params['pool_id']) -# def _create_if_needed(pool_names: List[str], user: model.User) -> None: -# if not pool_names: -# return -# _existing_pools, new_pools = pools.get_or_create_pools_by_names(pool_names) -# if len(new_pools): -# auth.verify_privilege(user, 'pools:create') -# db.session.flush() -# for pool in new_pools: -# snapshots.create(pool, user) - - @rest.routes.get('/pools/?') def get_pools(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: auth.verify_privilege(ctx.user, 'pools:list') @@ -34,7 +23,7 @@ def get_pools(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: ctx, lambda pool: _serialize(ctx, pool)) -@rest.routes.post('/pools/?') +@rest.routes.post('/pool/?') def create_pool( ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: auth.verify_privilege(ctx.user, 'pools:create') @@ -42,14 +31,9 @@ def create_pool( names = ctx.get_param_as_string_list('names') category = ctx.get_param_as_string('category') description = ctx.get_param_as_string('description', default='') - # TODO - # suggestions = ctx.get_param_as_string_list('suggestions', default=[]) - # implications = ctx.get_param_as_string_list('implications', default=[]) + posts = ctx.get_param_as_int_list('posts', default=[]) - # _create_if_needed(suggestions, ctx.user) - # _create_if_needed(implications, ctx.user) - - pool = pools.create_pool(names, category) + pool = pools.create_pool(names, category, posts) pools.update_pool_description(pool, description) ctx.session.add(pool) ctx.session.flush() @@ -81,17 +65,10 @@ def update_pool(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: auth.verify_privilege(ctx.user, 'pools:edit:description') pools.update_pool_description( pool, ctx.get_param_as_string('description')) - # TODO - # if ctx.has_param('suggestions'): - # auth.verify_privilege(ctx.user, 'pools:edit:suggestions') - # suggestions = ctx.get_param_as_string_list('suggestions') - # _create_if_needed(suggestions, ctx.user) - # pools.update_pool_suggestions(pool, suggestions) - # if ctx.has_param('implications'): - # auth.verify_privilege(ctx.user, 'pools:edit:implications') - # implications = ctx.get_param_as_string_list('implications') - # _create_if_needed(implications, ctx.user) - # pools.update_pool_implications(pool, implications) + 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) diff --git a/server/szurubooru/func/pools.py b/server/szurubooru/func/pools.py index d35fd496..4fc4d36d 100644 --- a/server/szurubooru/func/pools.py +++ b/server/szurubooru/func/pools.py @@ -3,7 +3,7 @@ from typing import Any, Optional, Tuple, List, Dict, Callable from datetime import datetime import sqlalchemy as sa from szurubooru import config, db, model, errors, rest -from szurubooru.func import util, pool_categories, serialization +from szurubooru.func import util, pool_categories, serialization, posts @@ -23,7 +23,7 @@ class InvalidPoolNameError(errors.ValidationError): pass -class InvalidPoolRelationError(errors.ValidationError): +class InvalidPoolDuplicateError(errors.ValidationError): pass @@ -60,6 +60,10 @@ def _check_name_intersection( return len(set(names1).intersection(names2)) > 0 +def _check_post_duplication(post_ids: List[int]) -> bool: + return len(post_ids) != len(set(post_ids)) + + def sort_pools(pools: List[model.Pool]) -> List[model.Pool]: default_category_name = pool_categories.get_default_category_name() return sorted( @@ -84,7 +88,8 @@ class PoolSerializer(serialization.BaseSerializer): 'description': self.serialize_description, 'creationTime': self.serialize_creation_time, 'lastEditTime': self.serialize_last_edit_time, - 'postCount': self.serialize_post_count + 'postCount': self.serialize_post_count, + 'posts': self.serialize_posts } def serialize_id(self) -> Any: @@ -111,6 +116,13 @@ class PoolSerializer(serialization.BaseSerializer): def serialize_post_count(self) -> Any: return self.pool.post_count + def serialize_posts(self) -> Any: + return [ + { + 'id': post.post_id + } + for post in self.pool.posts] + def serialize_pool( pool: model.Pool, options: List[str] = []) -> Optional[rest.Response]: @@ -180,7 +192,8 @@ def get_or_create_pools_by_names( if not found: new_pool = create_pool( names=[name], - category_name=pool_category_name) + category_name=pool_category_name, + post_ids=[]) db.session.add(new_pool) new_pools.append(new_pool) return existing_pools, new_pools @@ -245,11 +258,13 @@ def merge_pools(source_pool: model.Pool, target_pool: model.Pool) -> None: def create_pool( names: List[str], - category_name: str) -> model.Pool: + 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 @@ -299,4 +314,13 @@ def update_pool_description(pool: model.Pool, description: str) -> None: 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 + if _check_post_duplication(post_ids): + raise InvalidPoolDuplicateError('Duplicate post in pool.') + pool.posts.clear() + for post in posts.get_posts_by_ids(post_ids): + pool.posts.append(post) diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index f0224b40..64b00c99 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -334,6 +334,22 @@ def get_post_by_id(post_id: int) -> model.Post: return post +def get_posts_by_ids(ids: List[int]) -> List[model.Pool]: + 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 e9ce07c9..66464679 100644 --- a/server/szurubooru/func/snapshots.py +++ b/server/szurubooru/func/snapshots.py @@ -38,7 +38,7 @@ def get_pool_snapshot(pool: model.Pool) -> Dict[str, Any]: return { 'names': [pool_name.name for pool_name in pool.names], 'category': pool.category.name, - # TODO + 'posts': [post.post_id for post in pool.posts] } diff --git a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py index 9b663ac6..be87340a 100644 --- a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py +++ b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py @@ -45,8 +45,18 @@ def upgrade(): 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), + 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 cf18b0f7..4f6cb2a6 100644 --- a/server/szurubooru/model/__init__.py +++ b/server/szurubooru/model/__init__.py @@ -11,7 +11,7 @@ from szurubooru.model.post import ( PostNote, PostFeature, PostSignature) -from szurubooru.model.pool import Pool, PoolName +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 diff --git a/server/szurubooru/model/pool.py b/server/szurubooru/model/pool.py index 2505150e..ecd6522c 100644 --- a/server/szurubooru/model/pool.py +++ b/server/szurubooru/model/pool.py @@ -1,5 +1,8 @@ import sqlalchemy as sa +from sqlalchemy.ext.orderinglist import ordering_list +from sqlalchemy.ext.associationproxy import association_proxy from szurubooru.model.base import Base +import szurubooru.model as model class PoolName(Base): @@ -18,6 +21,32 @@ class PoolName(Base): 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') + + def __init__(self, post: model.Post) -> None: + self.post_id = post.post_id class Pool(Base): __tablename__ = 'pool' @@ -40,18 +69,23 @@ class Pool(Base): cascade='all,delete-orphan', lazy='joined', order_by='PoolName.order') + _posts = sa.orm.relationship( + 'PoolPost', + back_populates='pool', + cascade='all,delete-orphan', + lazy='joined', + 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(PostPool.post_id)]) - # .where(PostPool.pool_id == pool_id) - # .correlate_except(PostPool)) - # TODO - from random import randint post_count = sa.orm.column_property( - sa.sql.expression.select([randint(1, 1000)]) - .limit(1) - .as_scalar()) + ( + 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( ( @@ -63,7 +97,6 @@ class Pool(Base): ), deferred=True) - __mapper_args__ = { 'version_id_col': version, 'version_id_generator': False, 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 From 8795279a73e976a3aadbb4b9067209a46f138733 Mon Sep 17 00:00:00 2001 From: Ruin0x11 Date: Mon, 4 May 2020 02:20:23 -0700 Subject: [PATCH 3/9] Add pool input box in post details --- client/html/pool_input.tpl | 8 + client/html/post_edit_sidebar.tpl | 6 + client/js/controls/pool_input_control.js | 230 ++++++++++++++++++ .../js/controls/post_edit_sidebar_control.js | 31 ++- client/js/models/pool_list.js | 17 ++ client/js/models/post.js | 5 + server/szurubooru/db.py | 19 ++ server/szurubooru/func/posts.py | 11 +- .../6a2f424ec9d2_create_pool_tables.py | 2 +- server/szurubooru/model/pool.py | 5 +- server/szurubooru/model/post.py | 9 + 11 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 client/js/controls/pool_input_control.js diff --git a/client/html/pool_input.tpl b/client/html/pool_input.tpl index e69de29b..0c2a3fd9 100644 --- a/client/html/pool_input.tpl +++ b/client/html/pool_input.tpl @@ -0,0 +1,8 @@ +
    +
    + + +
    + +
      +
      diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl index ecc65699..458a6f08 100644 --- a/client/html/post_edit_sidebar.tpl +++ b/client/html/post_edit_sidebar.tpl @@ -87,6 +87,12 @@ <% } %> + <% if (ctx.canEditPoolPosts) { %> +
      + <%= ctx.makeTextInput({}) %> +
      + <% } %> + <% if (ctx.canEditPostContent) { %>
      diff --git a/client/js/controls/pool_input_control.js b/client/js/controls/pool_input_control.js new file mode 100644 index 00000000..09fcf63e --- /dev/null +++ b/client/js/controls/pool_input_control.js @@ -0,0 +1,230 @@ +'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 + }); + + // dom events + this._poolInputNode.addEventListener( + 'keydown', e => this._evtInputKeyDown(e)); + + // 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); + } + } + + addPoolByText(text, source) { + for (let poolName of text.split(/\s+/).filter(word => word).reverse()) { + this.addPoolByName(poolName, source); + } + } + + addPoolByName(name, source) { + name = name.trim(); + if (!name) { + return; + } + return Pool.get(name).then(pool => { + return this.addPool(pool, source); + }, () => { + const pool = new Pool(); + pool.names = [name]; + pool.category = null; + return this.addPool(pool, source); + }); + } + + 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')); + } + + _evtAddPoolButtonClick(e) { + // TODO + // e.preventDefault(); + // this.addPoolByName(this._poolInputNode.value, SOURCE_USER_INPUT); + // this._poolInputNode.value = ''; + } + + _evtInputKeyDown(e) { + // TODO + if (e.which == KEY_RETURN || e.which == KEY_SPACE) { + e.preventDefault(); + // this._hideAutoComplete(); + // this.addPoolByText(this._poolInputNode.value, SOURCE_USER_INPUT); + // this._poolInputNode.value = ''; + } + } + + _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: uri.escapeColons(pool.names[0])})); + searchLinkNode.textContent = pool.names[0] + ' '; + searchLinkNode.addEventListener('click', e => { + e.preventDefault(); + }); + + 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 030bb7a2..c89f9dea 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,7 +38,8 @@ class PostEditSidebarControl extends events.EventTarget { canEditPostFlags: api.hasPrivilege('posts:edit:flags'), canEditPostContent: api.hasPrivilege('posts:edit:content'), canEditPostThumbnail: api.hasPrivilege('posts:edit:thumbnail'), - canEditPostSource : api.hasPrivilege('posts:edit:source'), + canEditPostSource: api.hasPrivilege('posts:edit:source'), + canEditPoolPosts: api.hasPrivilege('pools:edit:posts'), canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'), canDeletePosts: api.hasPrivilege('posts:delete'), canFeaturePosts: api.hasPrivilege('posts:feature'), @@ -56,6 +58,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', @@ -76,6 +82,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, { @@ -170,6 +181,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( @@ -182,11 +196,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) { @@ -338,6 +359,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)) : @@ -374,6 +399,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/models/pool_list.js b/client/js/models/pool_list.js index d72ece29..6a33d55a 100644 --- a/client/js/models/pool_list.js +++ b/client/js/models/pool_list.js @@ -22,6 +22,23 @@ class PoolList extends AbstractList { {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; diff --git a/client/js/models/post.js b/client/js/models/post.js index d2124adb..e547954e 100644 --- a/client/js/models/post.js +++ b/client/js/models/post.js @@ -7,6 +7,7 @@ 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 misc = require('../util/misc.js'); class Post extends events.EventTarget { @@ -18,6 +19,7 @@ class Post extends events.EventTarget { obj._tags = new TagList(); obj._notes = new NoteList(); obj._comments = new CommentList(); + obj._pools = new PoolList(); } this._updateFromResponse({}); @@ -46,6 +48,7 @@ class Post extends events.EventTarget { get notes() { return this._notes; } get comments() { return this._comments; } get relations() { return this._relations; } + get pools() { return this._pools; } get score() { return this._score; } get commentCount() { return this._commentCount; } @@ -128,6 +131,7 @@ class Post extends events.EventTarget { if (this._source !== this._orig._source) { detail.source = this._source; } + // TODO pools let apiPromise = this._id ? api.put(uri.formatApiLink('post', this.id), detail, files) : @@ -304,6 +308,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/server/szurubooru/db.py b/server/szurubooru/db.py index 561b7484..bd300420 100644 --- a/server/szurubooru/db.py +++ b/server/szurubooru/db.py @@ -34,3 +34,22 @@ def _bump_query_count() -> None: sa.event.listen(_engine, 'after_execute', lambda *args: _bump_query_count()) + +import time +import logging + +logger = logging.getLogger("myapp.sqltime") +logger.setLevel(logging.INFO) + +def before_cursor_execute(conn, cursor, statement, + parameters, context, executemany): + conn.info.setdefault('query_start_time', []).append(time.time()) + logger.info("Start Query: %s" % statement) + +def after_cursor_execute(conn, cursor, statement, + parameters, context, executemany): + total = time.time() - conn.info['query_start_time'].pop(-1) + logger.info("Total Time: %f" % total) + +sa.event.listen(_engine, "before_cursor_execute", before_cursor_execute) +sa.event.listen(_engine, "after_cursor_execute", after_cursor_execute) diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index 64b00c99..723a4668 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,14 @@ class PostSerializer(serialization.BaseSerializer): self.post.comments, key=lambda comment: comment.creation_time)] + def serialize_pools(self) -> 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], diff --git a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py index be87340a..88472e3e 100644 --- a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py +++ b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py @@ -48,7 +48,7 @@ def upgrade(): op.create_table( 'pool_post', sa.Column('pool_id', sa.Integer(), nullable=False), - sa.Column('post_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'), diff --git a/server/szurubooru/model/pool.py b/server/szurubooru/model/pool.py index ecd6522c..8f7c605d 100644 --- a/server/szurubooru/model/pool.py +++ b/server/szurubooru/model/pool.py @@ -2,7 +2,6 @@ import sqlalchemy as sa from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.associationproxy import association_proxy from szurubooru.model.base import Base -import szurubooru.model as model class PoolName(Base): @@ -43,9 +42,9 @@ class PoolPost(Base): order = sa.Column('ord', sa.Integer, nullable=False, index=True) pool = sa.orm.relationship('Pool', back_populates='_posts') - post = sa.orm.relationship('Post') + post = sa.orm.relationship('Post', back_populates='_pools') - def __init__(self, post: model.Post) -> None: + def __init__(self, post) -> None: self.post_id = post.post_id class Pool(Base): diff --git a/server/szurubooru/model/post.py b/server/szurubooru/model/post.py index f8f5c340..56eaefa5 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,12 @@ 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', + lazy='select', + order_by='PoolPost.order', + back_populates='post') + pools = association_proxy('_pools', 'pool') # dynamic columns tag_count = sa.orm.column_property( From ffba010ae4b6efebd791a077125df92ef57274d0 Mon Sep 17 00:00:00 2001 From: Ruin0x11 Date: Mon, 4 May 2020 14:44:16 -0700 Subject: [PATCH 4/9] Implement updating pools of a post from details sidebar --- client/build.js | 10 ++++-- client/html/help_search.tpl | 2 +- client/html/pool_create.tpl | 1 + client/html/pool_edit.tpl | 1 + client/html/post_edit_sidebar.tpl | 12 +++---- .../js/controls/pool_auto_complete_control.js | 2 +- client/js/controls/pool_input_control.js | 26 ++-------------- client/js/models/abstract_list.js | 4 +++ client/js/models/post.js | 31 ++++++++++++++++++- client/js/models/post_list.js | 25 +++++++++++++++ 10 files changed, 79 insertions(+), 35 deletions(-) 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/html/help_search.tpl b/client/html/help_search.tpl index d8708b01..f228ee35 100644 --- a/client/html/help_search.tpl +++ b/client/html/help_search.tpl @@ -4,7 +4,7 @@ -->
    • '>Posts
    • '>Users
    • '>Tags
    • '>Pools
    • '>Pools>
    • diff --git a/client/html/pool_create.tpl b/client/html/pool_create.tpl index bf84dc92..3c04545c 100644 --- a/client/html/pool_create.tpl +++ b/client/html/pool_create.tpl @@ -26,6 +26,7 @@ <%= ctx.makeTextInput({ text: 'Posts', value: '', + placeholder: 'space-separated post IDs', }) %> diff --git a/client/html/pool_edit.tpl b/client/html/pool_edit.tpl index 1cbb62e3..c9f2adb9 100644 --- a/client/html/pool_edit.tpl +++ b/client/html/pool_edit.tpl @@ -32,6 +32,7 @@ <% if (ctx.canEditPosts) { %> <%= ctx.makeTextInput({ text: 'Posts', + placeholder: 'space-separated post IDs', value: ctx.pool.posts.map(post => post.id).join(' ') }) %> <% } %> diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl index 458a6f08..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 @@ -87,12 +93,6 @@
      <% } %> - <% if (ctx.canEditPoolPosts) { %> -
      - <%= ctx.makeTextInput({}) %> -
      - <% } %> - <% if (ctx.canEditPostContent) { %>
      diff --git a/client/js/controls/pool_auto_complete_control.js b/client/js/controls/pool_auto_complete_control.js index 0230c41d..d8ffa96a 100644 --- a/client/js/controls/pool_auto_complete_control.js +++ b/client/js/controls/pool_auto_complete_control.js @@ -34,7 +34,7 @@ class PoolAutoCompleteControl extends AutoCompleteControl { return new Promise((resolve, reject) => { PoolList.search( query, 0, this._options.maxResults, - ['id', 'names', 'category', 'postCount']) + ['id', 'names', 'category', 'postCount', 'version']) .then( response => resolve( _poolListToMatches(response.results, this._options)), diff --git a/client/js/controls/pool_input_control.js b/client/js/controls/pool_input_control.js index 09fcf63e..f53c8c96 100644 --- a/client/js/controls/pool_input_control.js +++ b/client/js/controls/pool_input_control.js @@ -80,27 +80,6 @@ class PoolInputControl extends events.EventTarget { } } - addPoolByText(text, source) { - for (let poolName of text.split(/\s+/).filter(word => word).reverse()) { - this.addPoolByName(poolName, source); - } - } - - addPoolByName(name, source) { - name = name.trim(); - if (!name) { - return; - } - return Pool.get(name).then(pool => { - return this.addPool(pool, source); - }, () => { - const pool = new Pool(); - pool.names = [name]; - pool.category = null; - return this.addPool(pool, source); - }); - } - addPool(pool, source) { if (source != SOURCE_INIT && this.pools.hasPoolId(pool.id)) { return Promise.resolve(); @@ -178,10 +157,11 @@ class PoolInputControl extends events.EventTarget { } searchLinkNode.setAttribute( 'href', uri.formatClientLink( - 'posts', {query: uri.escapeColons(pool.names[0])})); + 'posts', {query: "pool:" + pool.id})); searchLinkNode.textContent = pool.names[0] + ' '; searchLinkNode.addEventListener('click', e => { - e.preventDefault(); + // TODO? + // e.preventDefault(); }); const usagesNode = document.createElement('span'); 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/post.js b/client/js/models/post.js index e547954e..e3388ece 100644 --- a/client/js/models/post.js +++ b/client/js/models/post.js @@ -96,6 +96,29 @@ class Post extends events.EventTarget { }); } + _savePoolPosts() { + const difference = (a, b) => a.filter(post => !b.hasPoolId(post.id)); + const added = difference(this.pools, this._orig._pools); + const removed = difference(this._orig._pools, this.pools); + let ops = []; + + for (let pool of added) { + if (!pool.posts.hasPostId(this._id)) { + pool.posts.addById(this._id); + ops.push(pool.save()); + } + } + + for (let pool of removed) { + if (pool.posts.hasPostId(this._id)) { + pool.posts.removeById(this._id); + ops.push(pool.save()); + } + } + + return Promise.all(ops); + } + save(anonymous) { const files = {}; const detail = {version: this._version}; @@ -131,13 +154,18 @@ class Post extends events.EventTarget { if (this._source !== this._orig._source) { detail.source = this._source; } - // TODO pools let apiPromise = this._id ? api.put(uri.formatApiLink('post', this.id), detail, files) : api.post(uri.formatApiLink('posts'), detail, files); return apiPromise.then(response => { + if (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}})); @@ -149,6 +177,7 @@ class Post extends events.EventTarget { this.dispatchEvent( new CustomEvent('changeThumbnail', {detail: {post: this}})); } + return Promise.resolve(); }, error => { if (error.response && diff --git a/client/js/models/post_list.js b/client/js/models/post_list.js index 74f089f3..65d0de2a 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 = new 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; From 6b8e3f251f69c9d7d560148100cdb8df941a81c2 Mon Sep 17 00:00:00 2001 From: Ruin0x11 Date: Mon, 4 May 2020 15:15:30 -0700 Subject: [PATCH 5/9] Implement pool merging --- client/css/pool-list-view.styl | 2 +- client/html/pool.tpl | 2 +- client/html/pools_page.tpl | 4 +- client/js/controllers/pool_controller.js | 4 +- client/js/controls/pool_input_control.js | 25 --- client/js/models/post.js | 213 ++++++++++++----------- client/js/views/pool_merge_view.js | 9 +- server/szurubooru/func/pools.py | 47 ++--- 8 files changed, 137 insertions(+), 169 deletions(-) diff --git a/client/css/pool-list-view.styl b/client/css/pool-list-view.styl index 2daf6941..0f57649b 100644 --- a/client/css/pool-list-view.styl +++ b/client/css/pool-list-view.styl @@ -15,7 +15,7 @@ background: $top-navigation-color .names width: 84% - .usages + .post-count text-align: center width: 8% .creation-time diff --git a/client/html/pool.tpl b/client/html/pool.tpl index 5af10c96..e1e5311f 100644 --- a/client/html/pool.tpl +++ b/client/html/pool.tpl @@ -1,5 +1,5 @@
      -

      <%- ctx.pool.first_name %>

      +

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

        diff --git a/client/html/pool_summary.tpl b/client/html/pool_summary.tpl index 8f4e27d0..2b70d270 100644 --- a/client/html/pool_summary.tpl +++ b/client/html/pool_summary.tpl @@ -6,12 +6,12 @@
        - Aliases:
        -
          <% for (let name of ctx.pool.names.slice(1)) { %>
        • <%= ctx.makePoolLink(ctx.pool.id, false, false, ctx.pool, name) %>
        • <% } %>
        + Aliases:
        +
          + <% for (let name of ctx.pool.names.slice(1)) { %> +
        • <%= ctx.makePoolLink(ctx.pool.id, false, false, ctx.pool, name) %>
        • + <% } %> +
        diff --git a/client/js/controllers/pool_controller.js b/client/js/controllers/pool_controller.js index e243cd89..22920f09 100644 --- a/client/js/controllers/pool_controller.js +++ b/client/js/controllers/pool_controller.js @@ -54,8 +54,7 @@ class PoolController { 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 => { + }, error => { this._view = new EmptyView(); this._view.showError(error.message); }); @@ -68,9 +67,7 @@ class PoolController { _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); + router.replace(uri.formatClientLink('pool', e.detail.pool.id, section), null, false); } } @@ -87,9 +84,9 @@ class PoolController { e.detail.pool.description = e.detail.description; } if (e.detail.posts !== undefined) { - e.detail.pool.posts.clear() + e.detail.pool.posts.clear(); for (let post_id of e.detail.posts) { - e.detail.pool.posts.add(Post.fromResponse({ id: parseInt(post_id) })) + e.detail.pool.posts.add(Post.fromResponse({ id: parseInt(post_id) })); } } e.detail.pool.save().then(() => { diff --git a/client/js/controllers/pool_create_controller.js b/client/js/controllers/pool_create_controller.js index ef6dded2..0684693e 100644 --- a/client/js/controllers/pool_create_controller.js +++ b/client/js/controllers/pool_create_controller.js @@ -16,8 +16,7 @@ class PoolCreateController { return; } - PoolCategoryList.get() - .then(poolCategoriesResponse => { + PoolCategoryList.get().then(poolCategoriesResponse => { const categories = {}; for (let category of poolCategoriesResponse.results) { categories[category.name] = category.name; diff --git a/client/js/controls/pool_input_control.js b/client/js/controls/pool_input_control.js index a8272066..95dfc5cb 100644 --- a/client/js/controls/pool_input_control.js +++ b/client/js/controls/pool_input_control.js @@ -81,105 +81,105 @@ class PoolInputControl extends events.EventTarget { return Promise.resolve(); } - this.pools.add(pool, false) + this.pools.add(pool, false) - const listItemNode = this._createListItemNode(pool); - if (!pool.category) { - listItemNode.classList.add('new'); - } - this._poolListNode.prependChild(listItemNode); - _fadeOutListItemNodeStatus(listItemNode); + 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')); + this.dispatchEvent(new CustomEvent('add', { + detail: {pool: pool, source: source}, + })); + this.dispatchEvent(new CustomEvent('change')); - return Promise.resolve(); + return Promise.resolve(); } - deletePool(pool) { - if (!this.pools.hasPoolId(pool.id)) { - return; + 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')); } - this.pools.removeById(pool.id); - this._hideAutoComplete(); - this._deleteListItemNode(pool); + _createListItemNode(pool) { + const className = pool.category ? + misc.makeCssName(pool.category, 'pool') : + null; - this.dispatchEvent(new CustomEvent('remove', { - detail: {pool: pool}, - })); - this.dispatchEvent(new CustomEvent('change')); - } + const poolLinkNode = document.createElement('a'); + if (className) { + poolLinkNode.classList.add(className); + } + poolLinkNode.setAttribute( + 'href', uri.formatClientLink('pool', pool.names[0])); - _createListItemNode(pool) { - const className = pool.category ? - misc.makeCssName(pool.category, 'pool') : - null; + const poolIconNode = document.createElement('i'); + poolIconNode.classList.add('fa'); + poolIconNode.classList.add('fa-pool'); + poolLinkNode.appendChild(poolIconNode); - const poolLinkNode = document.createElement('a'); - if (className) { - poolLinkNode.classList.add(className); + 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; } - 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); + _deleteListItemNode(pool) { + const listItemNode = this._getListItemNode(pool); + if (listItemNode) { + listItemNode.parentNode.removeChild(listItemNode); + } + for (let name of pool.names) { + this._poolToListItemNode.delete(name); + } } - 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); + _getListItemNode(pool) { + return this._poolToListItemNode.get(pool.names[0]); } - return listItemNode; - } - _deleteListItemNode(pool) { - const listItemNode = this._getListItemNode(pool); - if (listItemNode) { - listItemNode.parentNode.removeChild(listItemNode); + _hideAutoComplete() { + this._autoCompleteControl.hide(); } - 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/models/pool_category.js b/client/js/models/pool_category.js index 4cdb68e6..a77302c2 100644 --- a/client/js/models/pool_category.js +++ b/client/js/models/pool_category.js @@ -17,7 +17,7 @@ class PoolCategory extends events.EventTarget { get name() { return this._name; } get color() { return this._color; } - get poolCount() { return this._poolCount; } + get poolCount() { return this._poolCount; } get isDefault() { return this._isDefault; } get isTransient() { return !this._origName; } diff --git a/server/alembic.ini b/server/alembic.ini index 98ab83d4..6a4e5ff4 100644 --- a/server/alembic.ini +++ b/server/alembic.ini @@ -3,7 +3,6 @@ script_location = szurubooru/migrations # overriden by szurubooru's config sqlalchemy.url = -revision_environment = true [loggers] keys = root,sqlalchemy,alembic diff --git a/server/config.yaml.dist b/server/config.yaml.dist index baf3a34e..37e877f1 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -4,7 +4,7 @@ # shown in the website title and on the front page name: szurubooru # full url to the homepage of this szurubooru site, with no trailing slash -domain: localhost # example: http://example.com +domain: # example: http://example.com # used to salt the users' password hashes and generate filenames for static content secret: change @@ -49,9 +49,6 @@ 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. @@ -61,72 +58,72 @@ user_name_regex: '^[a-zA-Z0-9_-]{1,32}$' default_rank: regular privileges: - 'users:create:self': anonymous # Registration permission - 'users:create:any': administrator - 'users:list': regular - 'users:view': regular - 'users:edit:any:name': moderator - 'users:edit:any:pass': moderator - 'users:edit:any:email': moderator - 'users:edit:any:avatar': moderator - 'users:edit:any:rank': moderator - 'users:edit:self:name': regular - 'users:edit:self:pass': regular - 'users:edit:self:email': regular - 'users:edit:self:avatar': regular - 'users:edit:self:rank': moderator # one can't promote themselves or anyone to upper rank than their own. - 'users:delete:any': administrator - 'users:delete:self': regular + 'users:create:self': anonymous # Registration permission + 'users:create:any': administrator + 'users:list': regular + 'users:view': regular + 'users:edit:any:name': moderator + 'users:edit:any:pass': moderator + 'users:edit:any:email': moderator + 'users:edit:any:avatar': moderator + 'users:edit:any:rank': moderator + 'users:edit:self:name': regular + 'users:edit:self:pass': regular + 'users:edit:self:email': regular + 'users:edit:self:avatar': regular + 'users:edit:self:rank': moderator # one can't promote themselves or anyone to upper rank than their own. + 'users:delete:any': administrator + 'users:delete:self': regular - 'user_tokens:list:any': administrator - 'user_tokens:list:self': regular - 'user_tokens:create:any': administrator - 'user_tokens:create:self': regular - 'user_tokens:edit:any': administrator - 'user_tokens:edit:self': regular - 'user_tokens:delete:any': administrator - 'user_tokens:delete:self': regular + 'user_tokens:list:any': administrator + 'user_tokens:list:self': regular + 'user_tokens:create:any': administrator + 'user_tokens:create:self': regular + 'user_tokens:edit:any': administrator + 'user_tokens:edit:self': regular + 'user_tokens:delete:any': administrator + 'user_tokens:delete:self': regular - 'posts:create:anonymous': regular - 'posts:create:identified': regular - 'posts:list': anonymous - 'posts:reverse_search': regular - 'posts:view': anonymous - 'posts:view:featured': anonymous - 'posts:edit:content': power - 'posts:edit:flags': regular - 'posts:edit:notes': regular - 'posts:edit:relations': regular - 'posts:edit:safety': power - 'posts:edit:source': regular - 'posts:edit:tags': regular - 'posts:edit:thumbnail': power - 'posts:feature': moderator - 'posts:delete': moderator - 'posts:score': regular - 'posts:merge': moderator - 'posts:favorite': regular - 'posts:bulk-edit:tags': power - 'posts:bulk-edit:safety': power + 'posts:create:anonymous': regular + 'posts:create:identified': regular + 'posts:list': anonymous + 'posts:reverse_search': regular + 'posts:view': anonymous + 'posts:view:featured': anonymous + 'posts:edit:content': power + 'posts:edit:flags': regular + 'posts:edit:notes': regular + 'posts:edit:relations': regular + 'posts:edit:safety': power + 'posts:edit:source': regular + 'posts:edit:tags': regular + 'posts:edit:thumbnail': power + 'posts:feature': moderator + 'posts:delete': moderator + 'posts:score': regular + 'posts:merge': moderator + 'posts:favorite': regular + 'posts:bulk-edit:tags': power + 'posts:bulk-edit:safety': power - 'tags:create': regular - 'tags:edit:names': power - 'tags:edit:category': power - 'tags:edit:description': power - 'tags:edit:implications': power - 'tags:edit:suggestions': power - 'tags:list': regular - 'tags:view': anonymous - 'tags:merge': moderator - 'tags:delete': moderator + 'tags:create': regular + 'tags:edit:names': power + 'tags:edit:category': power + 'tags:edit:description': power + 'tags:edit:implications': power + 'tags:edit:suggestions': power + 'tags:list': regular + 'tags:view': anonymous + 'tags:merge': moderator + 'tags:delete': moderator - 'tag_categories:create': moderator - 'tag_categories:edit:name': moderator - 'tag_categories:edit:color': moderator - 'tag_categories:list': anonymous - 'tag_categories:view': anonymous - 'tag_categories:delete': moderator - 'tag_categories:set_default': moderator + 'tag_categories:create': moderator + 'tag_categories:edit:name': moderator + 'tag_categories:edit:color': moderator + 'tag_categories:list': anonymous + 'tag_categories:view': anonymous + 'tag_categories:delete': moderator + 'tag_categories:set_default': moderator 'pools:create': regular 'pools:edit:names': power @@ -146,19 +143,19 @@ privileges: 'pool_categories:delete': moderator 'pool_categories:set_default': moderator - 'comments:create': regular - 'comments:delete:any': moderator - 'comments:delete:own': regular - 'comments:edit:any': moderator - 'comments:edit:own': regular - 'comments:list': regular - 'comments:view': regular - 'comments:score': regular + 'comments:create': regular + 'comments:delete:any': moderator + 'comments:delete:own': regular + 'comments:edit:any': moderator + 'comments:edit:own': regular + 'comments:list': regular + 'comments:view': regular + 'comments:score': regular - 'snapshots:list': power + 'snapshots:list': power - 'uploads:create': regular - 'uploads:use_downloader': power + 'uploads:create': regular + 'uploads:use_downloader': power ## ONLY SET THESE IF DEPLOYING OUTSIDE OF DOCKER #debug: 0 # generate server logs? diff --git a/server/requirements.txt b/server/requirements.txt index 8a337f9e..810452e0 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -9,4 +9,3 @@ pillow>=4.3.0 pynacl>=1.2.1 pytz>=2018.3 pyRFC3339>=1.0 -youtube_dl>=2020.5.3 diff --git a/server/szurubooru/db.py b/server/szurubooru/db.py index bd300420..561b7484 100644 --- a/server/szurubooru/db.py +++ b/server/szurubooru/db.py @@ -34,22 +34,3 @@ def _bump_query_count() -> None: sa.event.listen(_engine, 'after_execute', lambda *args: _bump_query_count()) - -import time -import logging - -logger = logging.getLogger("myapp.sqltime") -logger.setLevel(logging.INFO) - -def before_cursor_execute(conn, cursor, statement, - parameters, context, executemany): - conn.info.setdefault('query_start_time', []).append(time.time()) - logger.info("Start Query: %s" % statement) - -def after_cursor_execute(conn, cursor, statement, - parameters, context, executemany): - total = time.time() - conn.info['query_start_time'].pop(-1) - logger.info("Total Time: %f" % total) - -sa.event.listen(_engine, "before_cursor_execute", before_cursor_execute) -sa.event.listen(_engine, "after_cursor_execute", after_cursor_execute) diff --git a/server/szurubooru/func/pool_categories.py b/server/szurubooru/func/pool_categories.py index eca934a9..83305de0 100644 --- a/server/szurubooru/func/pool_categories.py +++ b/server/szurubooru/func/pool_categories.py @@ -195,5 +195,5 @@ def delete_category(category: model.PoolCategory) -> None: 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..') + '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 index 1d279296..90dede22 100644 --- a/server/szurubooru/func/pools.py +++ b/server/szurubooru/func/pools.py @@ -77,6 +77,7 @@ def _duplicates(a: List[int]) -> List[int]: 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( @@ -131,8 +132,7 @@ class PoolSerializer(serialization.BaseSerializer): def serialize_posts(self) -> Any: return [post for post in - [posts.serialize_micro_post(rel, None) - for rel in self.pool.posts]] + [posts.serialize_micro_post(rel, None) for rel in self.pool.posts]] def serialize_pool( @@ -221,7 +221,7 @@ def merge_pools(source_pool: model.Pool, target_pool: model.Pool) -> None: if source_pool.pool_id == target_pool.pool_id: raise InvalidPoolRelationError('Cannot merge pool with itself.') - def merge_posts(source_pool_id: int, target_pool_id: int) -> None: + 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 = ( @@ -236,7 +236,7 @@ def merge_pools(source_pool: model.Pool, target_pool: model.Pool) -> None: update_stmt = update_stmt.values(pool_id=target_pool_id) db.session.execute(update_stmt) - merge_posts(source_pool.pool_id, target_pool.pool_id) + merge_pool_posts(source_pool.pool_id, target_pool.pool_id) delete(source_pool) @@ -304,8 +304,6 @@ def update_pool_posts(pool: model.Pool, post_ids: List[int]) -> None: assert pool dupes = _duplicates(post_ids) if len(dupes) > 0: - print(str(dupes)) - print(str(post_ids)) 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) diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index 723a4668..62458d8e 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -300,7 +300,7 @@ class PostSerializer(serialization.BaseSerializer): self.post.comments, key=lambda comment: comment.creation_time)] - def serialize_pools(self) -> Any: + def serialize_pools(self) -> List[Any]: return [ pools.serialize_pool(pool) for pool in sorted( @@ -343,7 +343,7 @@ def get_post_by_id(post_id: int) -> model.Post: return post -def get_posts_by_ids(ids: List[int]) -> List[model.Pool]: +def get_posts_by_ids(ids: List[int]) -> List[model.Post]: if len(ids) == 0: return [] posts = ( From b0f1b8c2309277a7b170972d463b4e8d16a79ecc Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Wed, 3 Jun 2020 11:55:50 -0400 Subject: [PATCH 9/9] fix python lint issues --- server/szurubooru/api/pool_api.py | 3 +- server/szurubooru/func/pools.py | 11 +++++-- server/szurubooru/func/posts.py | 1 - .../54de8acc6cef_add_default_pool_category.py | 1 + .../6a2f424ec9d2_create_pool_tables.py | 2 ++ server/szurubooru/model/pool.py | 3 +- server/szurubooru/model/pool_category.py | 3 +- .../tests/api/test_pool_category_creating.py | 6 ++-- .../tests/api/test_pool_category_updating.py | 6 ++-- .../tests/api/test_pool_updating.py | 33 ++++++++++--------- server/szurubooru/tests/conftest.py | 3 +- server/szurubooru/tests/func/test_posts.py | 8 +++-- .../search/configs/test_pool_search_config.py | 18 ++++++++-- 13 files changed, 66 insertions(+), 32 deletions(-) diff --git a/server/szurubooru/api/pool_api.py b/server/szurubooru/api/pool_api.py index 3d4f74cf..9627ed21 100644 --- a/server/szurubooru/api/pool_api.py +++ b/server/szurubooru/api/pool_api.py @@ -17,7 +17,8 @@ def _get_pool(params: Dict[str, str]) -> model.Pool: @rest.routes.get('/pools/?') -def get_pools(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: +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)) diff --git a/server/szurubooru/func/pools.py b/server/szurubooru/func/pools.py index 90dede22..4acf9dfd 100644 --- a/server/szurubooru/func/pools.py +++ b/server/szurubooru/func/pools.py @@ -131,8 +131,12 @@ class PoolSerializer(serialization.BaseSerializer): 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]] + return [ + post for post in [ + posts.serialize_micro_post(rel, None) + for rel in self.pool.posts + ] + ] def serialize_pool( @@ -310,7 +314,8 @@ def update_pool_posts(pool: model.Pool, post_ids: List[int]) -> None: 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) + 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 62458d8e..d8e984bf 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -308,7 +308,6 @@ class PostSerializer(serialization.BaseSerializer): key=lambda pool: pool.creation_time)] - def serialize_post( post: Optional[model.Post], auth_user: model.User, diff --git a/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py b/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py index 782833c8..eaa68f27 100644 --- a/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py +++ b/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py @@ -33,6 +33,7 @@ class PoolCategory(Base): 'version_id_generator': False, } + def upgrade(): session = sa.orm.session.Session(bind=op.get_bind()) if session.query(PoolCategory).count() == 0: diff --git a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py index 88472e3e..18a0d7af 100644 --- a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py +++ b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py @@ -14,6 +14,7 @@ down_revision = '1e280b5d5df1' branch_labels = None depends_on = None + def upgrade(): op.create_table( 'pool_category', @@ -54,6 +55,7 @@ def upgrade(): 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') diff --git a/server/szurubooru/model/pool.py b/server/szurubooru/model/pool.py index 70595d9a..6fc83610 100644 --- a/server/szurubooru/model/pool.py +++ b/server/szurubooru/model/pool.py @@ -20,7 +20,7 @@ class PoolName(Base): def __init__(self, name: str, order: int) -> None: self.name = name self.order = order - + class PoolPost(Base): __tablename__ = 'pool_post' @@ -47,6 +47,7 @@ class PoolPost(Base): def __init__(self, post) -> None: self.post_id = post.post_id + class Pool(Base): __tablename__ = 'pool' diff --git a/server/szurubooru/model/pool_category.py b/server/szurubooru/model/pool_category.py index 5af0b11d..a54e5a66 100644 --- a/server/szurubooru/model/pool_category.py +++ b/server/szurubooru/model/pool_category.py @@ -18,7 +18,8 @@ class PoolCategory(Base): self.name = name pool_count = sa.orm.column_property( - sa.sql.expression.select([sa.sql.expression.func.count('Pool.pool_id')]) + sa.sql.expression.select( + [sa.sql.expression.func.count('Pool.pool_id')]) .where(Pool.category_id == pool_category_id) .correlate_except(sa.table('Pool'))) diff --git a/server/szurubooru/tests/api/test_pool_category_creating.py b/server/szurubooru/tests/api/test_pool_category_creating.py index 4accb923..5e932a63 100644 --- a/server/szurubooru/tests/api/test_pool_category_creating.py +++ b/server/szurubooru/tests/api/test_pool_category_creating.py @@ -26,13 +26,15 @@ def test_creating_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.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') + pool_categories.create_category.assert_called_once_with( + 'meta', 'black') snapshots.create.assert_called_once_with(category, auth_user) diff --git a/server/szurubooru/tests/api/test_pool_category_updating.py b/server/szurubooru/tests/api/test_pool_category_updating.py index 9c9f743b..028c5209 100644 --- a/server/szurubooru/tests/api/test_pool_category_updating.py +++ b/server/szurubooru/tests/api/test_pool_category_updating.py @@ -28,7 +28,8 @@ def test_simple_updating(user_factory, pool_category_factory, context_factory): 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.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( @@ -93,7 +94,8 @@ def test_set_as_default(user_factory, pool_category_factory, context_factory): 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.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( diff --git a/server/szurubooru/tests/api/test_pool_updating.py b/server/szurubooru/tests/api/test_pool_updating.py index 52eddc93..bd6b71c1 100644 --- a/server/szurubooru/tests/api/test_pool_updating.py +++ b/server/szurubooru/tests/api/test_pool_updating.py @@ -23,13 +23,13 @@ def test_simple_updating(user_factory, pool_factory, context_factory): 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'): + 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( @@ -72,9 +72,9 @@ def test_omitting_optional_field( } 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'): + 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}}, @@ -113,11 +113,14 @@ def test_trying_to_create_pools_without_privileges( 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}) + 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): diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py index 249df1a7..13b1c009 100644 --- a/server/szurubooru/tests/conftest.py +++ b/server/szurubooru/tests/conftest.py @@ -214,7 +214,8 @@ def pool_category_factory(): @pytest.fixture def pool_factory(): - def factory(id=None, names=None, description=None, category=None, time=None): + 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) diff --git a/server/szurubooru/tests/func/test_posts.py b/server/szurubooru/tests/func/test_posts.py index b429491f..1fc6d4b8 100644 --- a/server/szurubooru/tests/func/test_posts.py +++ b/server/szurubooru/tests/func/test_posts.py @@ -212,7 +212,9 @@ def test_serialize_post( 'posts': [ { 'id': 1, - 'thumbnailUrl': 'http://example.com/generated-thumbnails/1_244c8840887984c4.jpg', + 'thumbnailUrl': + 'http://example.com/' + 'generated-thumbnails/1_244c8840887984c4.jpg', } ], 'version': 1, @@ -228,7 +230,9 @@ def test_serialize_post( 'posts': [ { 'id': 1, - 'thumbnailUrl': 'http://example.com/generated-thumbnails/1_244c8840887984c4.jpg', + 'thumbnailUrl': + 'http://example.com/' + 'generated-thumbnails/1_244c8840887984c4.jpg', } ], 'version': 1, diff --git a/server/szurubooru/tests/search/configs/test_pool_search_config.py b/server/szurubooru/tests/search/configs/test_pool_search_config.py index e013363b..730511ae 100644 --- a/server/szurubooru/tests/search/configs/test_pool_search_config.py +++ b/server/szurubooru/tests/search/configs/test_pool_search_config.py @@ -222,7 +222,11 @@ def test_filter_by_edit_time( ('post-count-max:1', ['t2']), ]) def test_filter_by_post_count( - verify_unpaged, pool_factory, post_factory, input, expected_pool_names): + 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']) @@ -257,7 +261,11 @@ def test_filter_by_invalid_input(executor, input): ('-sort:name,asc', ['t2', 't1']), ('-sort:name,desc', ['t1', 't2']), ]) -def test_sort_by_name(verify_unpaged, pool_factory, input, expected_pool_names): +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() @@ -306,7 +314,11 @@ def test_sort_by_last_edit_time( ('sort:post-count', ['t2', 't1']), ]) def test_sort_by_post_count( - verify_unpaged, pool_factory, post_factory, input, expected_pool_names): + 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'])