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 @@ +
    +
    + + + <% if (ctx.canCreate) { %> +
    + +
    + +
    + <% } %> +
    +
    diff --git a/client/html/pool_delete.tpl b/client/html/pool_delete.tpl new file mode 100644 index 00000000..1ef7fb53 --- /dev/null +++ b/client/html/pool_delete.tpl @@ -0,0 +1,21 @@ +
    +
    +

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

    + + + +
    + +
    + +
    +
    +
    diff --git a/client/html/pool_edit.tpl b/client/html/pool_edit.tpl new file mode 100644 index 00000000..0a0e1b01 --- /dev/null +++ b/client/html/pool_edit.tpl @@ -0,0 +1,41 @@ +
    +
    + + + <% 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 @@ +
    +
    + + +
    + +
    + +
    +
    +
    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 @@ +
    +
    + + +
    + + '>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 @@
    - <% 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) + ), + ])