diff --git a/client/html/pool_input.tpl b/client/html/pool_input.tpl
index e69de29b..0c2a3fd9 100644
--- a/client/html/pool_input.tpl
+++ b/client/html/pool_input.tpl
@@ -0,0 +1,8 @@
+
diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl
index ecc65699..458a6f08 100644
--- a/client/html/post_edit_sidebar.tpl
+++ b/client/html/post_edit_sidebar.tpl
@@ -87,6 +87,12 @@
<% } %>
+ <% if (ctx.canEditPoolPosts) { %>
+
+ <%= ctx.makeTextInput({}) %>
+
+ <% } %>
+
<% if (ctx.canEditPostContent) { %>
diff --git a/client/js/controls/pool_input_control.js b/client/js/controls/pool_input_control.js
new file mode 100644
index 00000000..09fcf63e
--- /dev/null
+++ b/client/js/controls/pool_input_control.js
@@ -0,0 +1,230 @@
+'use strict';
+
+const api = require('../api.js');
+const pools = require('../pools.js');
+const misc = require('../util/misc.js');
+const uri = require('../util/uri.js');
+const Pool = require('../models/pool.js');
+const settings = require('../models/settings.js');
+const events = require('../events.js');
+const views = require('../util/views.js');
+const PoolAutoCompleteControl = require('./pool_auto_complete_control.js');
+
+const KEY_SPACE = 32;
+const KEY_RETURN = 13;
+
+const SOURCE_INIT = 'init';
+const SOURCE_IMPLICATION = 'implication';
+const SOURCE_USER_INPUT = 'user-input';
+const SOURCE_CLIPBOARD = 'clipboard';
+
+const template = views.getTemplate('pool-input');
+
+function _fadeOutListItemNodeStatus(listItemNode) {
+ if (listItemNode.classList.length) {
+ if (listItemNode.fadeTimeout) {
+ window.clearTimeout(listItemNode.fadeTimeout);
+ }
+ listItemNode.fadeTimeout = window.setTimeout(() => {
+ while (listItemNode.classList.length) {
+ listItemNode.classList.remove(
+ listItemNode.classList.item(0));
+ }
+ listItemNode.fadeTimeout = null;
+ }, 2500);
+ }
+}
+
+class PoolInputControl extends events.EventTarget {
+ constructor(hostNode, poolList) {
+ super();
+ this.pools = poolList;
+ this._hostNode = hostNode;
+ this._poolToListItemNode = new Map();
+
+ // dom
+ const editAreaNode = template();
+ this._editAreaNode = editAreaNode;
+ this._poolInputNode = editAreaNode.querySelector('input');
+ this._poolListNode = editAreaNode.querySelector('ul.compact-pools');
+
+ this._autoCompleteControl = new PoolAutoCompleteControl(
+ this._poolInputNode, {
+ getTextToFind: () => {
+ return this._poolInputNode.value;
+ },
+ confirm: pool => {
+ this._poolInputNode.value = '';
+ this.addPool(pool, SOURCE_USER_INPUT);
+ },
+ delete: pool => {
+ this._poolInputNode.value = '';
+ this.deletePool(pool);
+ },
+ verticalShift: -2
+ });
+
+ // dom events
+ this._poolInputNode.addEventListener(
+ 'keydown', e => this._evtInputKeyDown(e));
+
+ // show
+ this._hostNode.style.display = 'none';
+ this._hostNode.parentNode.insertBefore(
+ this._editAreaNode, hostNode.nextSibling);
+
+ // add existing pools
+ for (let pool of [...this.pools]) {
+ const listItemNode = this._createListItemNode(pool);
+ this._poolListNode.appendChild(listItemNode);
+ }
+ }
+
+ addPoolByText(text, source) {
+ for (let poolName of text.split(/\s+/).filter(word => word).reverse()) {
+ this.addPoolByName(poolName, source);
+ }
+ }
+
+ addPoolByName(name, source) {
+ name = name.trim();
+ if (!name) {
+ return;
+ }
+ return Pool.get(name).then(pool => {
+ return this.addPool(pool, source);
+ }, () => {
+ const pool = new Pool();
+ pool.names = [name];
+ pool.category = null;
+ return this.addPool(pool, source);
+ });
+ }
+
+ addPool(pool, source) {
+ if (source != SOURCE_INIT && this.pools.hasPoolId(pool.id)) {
+ return Promise.resolve();
+ }
+
+ this.pools.add(pool, false)
+
+ const listItemNode = this._createListItemNode(pool);
+ if (!pool.category) {
+ listItemNode.classList.add('new');
+ }
+ this._poolListNode.prependChild(listItemNode);
+ _fadeOutListItemNodeStatus(listItemNode);
+
+ this.dispatchEvent(new CustomEvent('add', {
+ detail: {pool: pool, source: source},
+ }));
+ this.dispatchEvent(new CustomEvent('change'));
+
+ return Promise.resolve();
+ }
+
+ deletePool(pool) {
+ if (!this.pools.hasPoolId(pool.id)) {
+ return;
+ }
+ this.pools.removeById(pool.id);
+ this._hideAutoComplete();
+
+ this._deleteListItemNode(pool);
+
+ this.dispatchEvent(new CustomEvent('remove', {
+ detail: {pool: pool},
+ }));
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+
+ _evtAddPoolButtonClick(e) {
+ // TODO
+ // e.preventDefault();
+ // this.addPoolByName(this._poolInputNode.value, SOURCE_USER_INPUT);
+ // this._poolInputNode.value = '';
+ }
+
+ _evtInputKeyDown(e) {
+ // TODO
+ if (e.which == KEY_RETURN || e.which == KEY_SPACE) {
+ e.preventDefault();
+ // this._hideAutoComplete();
+ // this.addPoolByText(this._poolInputNode.value, SOURCE_USER_INPUT);
+ // this._poolInputNode.value = '';
+ }
+ }
+
+ _createListItemNode(pool) {
+ const className = pool.category ?
+ misc.makeCssName(pool.category, 'pool') :
+ null;
+
+ const poolLinkNode = document.createElement('a');
+ if (className) {
+ poolLinkNode.classList.add(className);
+ }
+ poolLinkNode.setAttribute(
+ 'href', uri.formatClientLink('pool', pool.names[0]));
+
+ const poolIconNode = document.createElement('i');
+ poolIconNode.classList.add('fa');
+ poolIconNode.classList.add('fa-pool');
+ poolLinkNode.appendChild(poolIconNode);
+
+ const searchLinkNode = document.createElement('a');
+ if (className) {
+ searchLinkNode.classList.add(className);
+ }
+ searchLinkNode.setAttribute(
+ 'href', uri.formatClientLink(
+ 'posts', {query: uri.escapeColons(pool.names[0])}));
+ searchLinkNode.textContent = pool.names[0] + ' ';
+ searchLinkNode.addEventListener('click', e => {
+ e.preventDefault();
+ });
+
+ const usagesNode = document.createElement('span');
+ usagesNode.classList.add('pool-usages');
+ usagesNode.setAttribute('data-pseudo-content', pool.postCount);
+
+ const removalLinkNode = document.createElement('a');
+ removalLinkNode.classList.add('remove-pool');
+ removalLinkNode.setAttribute('href', '');
+ removalLinkNode.setAttribute('data-pseudo-content', '×');
+ removalLinkNode.addEventListener('click', e => {
+ e.preventDefault();
+ this.deletePool(pool);
+ });
+
+ const listItemNode = document.createElement('li');
+ listItemNode.appendChild(removalLinkNode);
+ listItemNode.appendChild(poolLinkNode);
+ listItemNode.appendChild(searchLinkNode);
+ listItemNode.appendChild(usagesNode);
+ for (let name of pool.names) {
+ this._poolToListItemNode.set(name, listItemNode);
+ }
+ return listItemNode;
+ }
+
+ _deleteListItemNode(pool) {
+ const listItemNode = this._getListItemNode(pool);
+ if (listItemNode) {
+ listItemNode.parentNode.removeChild(listItemNode);
+ }
+ for (let name of pool.names) {
+ this._poolToListItemNode.delete(name);
+ }
+ }
+
+ _getListItemNode(pool) {
+ return this._poolToListItemNode.get(pool.names[0]);
+ }
+
+ _hideAutoComplete() {
+ this._autoCompleteControl.hide();
+ }
+}
+
+module.exports = PoolInputControl;
diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js
index 030bb7a2..c89f9dea 100644
--- a/client/js/controls/post_edit_sidebar_control.js
+++ b/client/js/controls/post_edit_sidebar_control.js
@@ -7,6 +7,7 @@ const views = require('../util/views.js');
const Note = require('../models/note.js');
const Point = require('../models/point.js');
const TagInputControl = require('./tag_input_control.js');
+const PoolInputControl = require('./pool_input_control.js');
const ExpanderControl = require('../controls/expander_control.js');
const FileDropperControl = require('../controls/file_dropper_control.js');
@@ -37,7 +38,8 @@ class PostEditSidebarControl extends events.EventTarget {
canEditPostFlags: api.hasPrivilege('posts:edit:flags'),
canEditPostContent: api.hasPrivilege('posts:edit:content'),
canEditPostThumbnail: api.hasPrivilege('posts:edit:thumbnail'),
- canEditPostSource : api.hasPrivilege('posts:edit:source'),
+ canEditPostSource: api.hasPrivilege('posts:edit:source'),
+ canEditPoolPosts: api.hasPrivilege('pools:edit:posts'),
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
canDeletePosts: api.hasPrivilege('posts:delete'),
canFeaturePosts: api.hasPrivilege('posts:feature'),
@@ -56,6 +58,10 @@ class PostEditSidebarControl extends events.EventTarget {
'post-notes',
'Notes',
this._hostNode.querySelectorAll('.notes'));
+ this._poolsExpander = new ExpanderControl(
+ 'post-pools',
+ `Pools (${this._post.pools.length})`,
+ this._hostNode.querySelectorAll('.pools'));
new ExpanderControl(
'post-content',
'Content',
@@ -76,6 +82,11 @@ class PostEditSidebarControl extends events.EventTarget {
this._tagInputNode, post.tags);
}
+ if (this._poolInputNode) {
+ this._poolControl = new PoolInputControl(
+ this._poolInputNode, post.pools);
+ }
+
if (this._contentInputNode) {
this._contentFileDropper = new FileDropperControl(
this._contentInputNode, {
@@ -170,6 +181,9 @@ class PostEditSidebarControl extends events.EventTarget {
this._post.notes.addEventListener(eventType, e => {
this._syncExpanderTitles();
});
+ this._post.pools.addEventListener(eventType, e => {
+ this._syncExpanderTitles();
+ });
}
this._tagControl.addEventListener(
@@ -182,11 +196,18 @@ class PostEditSidebarControl extends events.EventTarget {
this._noteTextareaNode.addEventListener(
'change', e => this._evtNoteTextChangeRequest(e));
}
+
+ this._poolControl.addEventListener(
+ 'change', e => {
+ this.dispatchEvent(new CustomEvent('change'));
+ this._syncExpanderTitles();
+ });
}
_syncExpanderTitles() {
this._notesExpander.title = `Notes (${this._post.notes.length})`;
this._tagsExpander.title = `Tags (${this._post.tags.length})`;
+ this._poolsExpander.title = `Pools (${this._post.pools.length})`;
}
_evtPostContentChange(e) {
@@ -338,6 +359,10 @@ class PostEditSidebarControl extends events.EventTarget {
misc.splitByWhitespace(this._tagInputNode.value) :
undefined,
+ pools: this._poolInputNode ?
+ misc.splitByWhitespace(this._poolInputNode.value) :
+ undefined,
+
relations: this._relationsInputNode ?
misc.splitByWhitespace(this._relationsInputNode.value)
.map(x => parseInt(x)) :
@@ -374,6 +399,10 @@ class PostEditSidebarControl extends events.EventTarget {
return this._formNode.querySelector('.tags input');
}
+ get _poolInputNode() {
+ return this._formNode.querySelector('.pools input');
+ }
+
get _loopVideoInputNode() {
return this._formNode.querySelector('.flags input[name=loop]');
}
diff --git a/client/js/models/pool_list.js b/client/js/models/pool_list.js
index d72ece29..6a33d55a 100644
--- a/client/js/models/pool_list.js
+++ b/client/js/models/pool_list.js
@@ -22,6 +22,23 @@ class PoolList extends AbstractList {
{results: PoolList.fromResponse(response.results)}));
});
}
+
+ hasPoolId(poolId) {
+ for (let pool of this._list) {
+ if (pool.id === poolId) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ removeById(poolId) {
+ for (let pool of this._list) {
+ if (pool.id === poolId) {
+ this.remove(pool);
+ }
+ }
+ }
}
PoolList._itemClass = Pool;
diff --git a/client/js/models/post.js b/client/js/models/post.js
index d2124adb..e547954e 100644
--- a/client/js/models/post.js
+++ b/client/js/models/post.js
@@ -7,6 +7,7 @@ const events = require('../events.js');
const TagList = require('./tag_list.js');
const NoteList = require('./note_list.js');
const CommentList = require('./comment_list.js');
+const PoolList = require('./pool_list.js');
const misc = require('../util/misc.js');
class Post extends events.EventTarget {
@@ -18,6 +19,7 @@ class Post extends events.EventTarget {
obj._tags = new TagList();
obj._notes = new NoteList();
obj._comments = new CommentList();
+ obj._pools = new PoolList();
}
this._updateFromResponse({});
@@ -46,6 +48,7 @@ class Post extends events.EventTarget {
get notes() { return this._notes; }
get comments() { return this._comments; }
get relations() { return this._relations; }
+ get pools() { return this._pools; }
get score() { return this._score; }
get commentCount() { return this._commentCount; }
@@ -128,6 +131,7 @@ class Post extends events.EventTarget {
if (this._source !== this._orig._source) {
detail.source = this._source;
}
+ // TODO pools
let apiPromise = this._id ?
api.put(uri.formatApiLink('post', this.id), detail, files) :
@@ -304,6 +308,7 @@ class Post extends events.EventTarget {
obj._tags.sync(response.tags);
obj._notes.sync(response.notes);
obj._comments.sync(response.comments);
+ obj._pools.sync(response.pools);
}
Object.assign(this, map());
diff --git a/server/szurubooru/db.py b/server/szurubooru/db.py
index 561b7484..bd300420 100644
--- a/server/szurubooru/db.py
+++ b/server/szurubooru/db.py
@@ -34,3 +34,22 @@ def _bump_query_count() -> None:
sa.event.listen(_engine, 'after_execute', lambda *args: _bump_query_count())
+
+import time
+import logging
+
+logger = logging.getLogger("myapp.sqltime")
+logger.setLevel(logging.INFO)
+
+def before_cursor_execute(conn, cursor, statement,
+ parameters, context, executemany):
+ conn.info.setdefault('query_start_time', []).append(time.time())
+ logger.info("Start Query: %s" % statement)
+
+def after_cursor_execute(conn, cursor, statement,
+ parameters, context, executemany):
+ total = time.time() - conn.info['query_start_time'].pop(-1)
+ logger.info("Total Time: %f" % total)
+
+sa.event.listen(_engine, "before_cursor_execute", before_cursor_execute)
+sa.event.listen(_engine, "after_cursor_execute", after_cursor_execute)
diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py
index 64b00c99..723a4668 100644
--- a/server/szurubooru/func/posts.py
+++ b/server/szurubooru/func/posts.py
@@ -5,7 +5,7 @@ from datetime import datetime
import sqlalchemy as sa
from szurubooru import config, db, model, errors, rest
from szurubooru.func import (
- users, scores, comments, tags, util,
+ users, scores, comments, tags, pools, util,
mime, images, files, image_hash, serialization, snapshots)
@@ -176,6 +176,7 @@ class PostSerializer(serialization.BaseSerializer):
'hasCustomThumbnail': self.serialize_has_custom_thumbnail,
'notes': self.serialize_notes,
'comments': self.serialize_comments,
+ 'pools': self.serialize_pools,
}
def serialize_id(self) -> Any:
@@ -299,6 +300,14 @@ class PostSerializer(serialization.BaseSerializer):
self.post.comments,
key=lambda comment: comment.creation_time)]
+ def serialize_pools(self) -> Any:
+ return [
+ pools.serialize_pool(pool)
+ for pool in sorted(
+ self.post.pools,
+ key=lambda pool: pool.creation_time)]
+
+
def serialize_post(
post: Optional[model.Post],
diff --git a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py
index be87340a..88472e3e 100644
--- a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py
+++ b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py
@@ -48,7 +48,7 @@ def upgrade():
op.create_table(
'pool_post',
sa.Column('pool_id', sa.Integer(), nullable=False),
- sa.Column('post_id', sa.Integer(), nullable=False),
+ sa.Column('post_id', sa.Integer(), nullable=False, index=True),
sa.Column('ord', sa.Integer(), nullable=False, index=True),
sa.ForeignKeyConstraint(['pool_id'], ['pool.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['post_id'], ['post.id'], ondelete='CASCADE'),
diff --git a/server/szurubooru/model/pool.py b/server/szurubooru/model/pool.py
index ecd6522c..8f7c605d 100644
--- a/server/szurubooru/model/pool.py
+++ b/server/szurubooru/model/pool.py
@@ -2,7 +2,6 @@ import sqlalchemy as sa
from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.ext.associationproxy import association_proxy
from szurubooru.model.base import Base
-import szurubooru.model as model
class PoolName(Base):
@@ -43,9 +42,9 @@ class PoolPost(Base):
order = sa.Column('ord', sa.Integer, nullable=False, index=True)
pool = sa.orm.relationship('Pool', back_populates='_posts')
- post = sa.orm.relationship('Post')
+ post = sa.orm.relationship('Post', back_populates='_pools')
- def __init__(self, post: model.Post) -> None:
+ def __init__(self, post) -> None:
self.post_id = post.post_id
class Pool(Base):
diff --git a/server/szurubooru/model/post.py b/server/szurubooru/model/post.py
index f8f5c340..56eaefa5 100644
--- a/server/szurubooru/model/post.py
+++ b/server/szurubooru/model/post.py
@@ -2,7 +2,10 @@ from typing import List
import sqlalchemy as sa
from szurubooru.model.base import Base
from szurubooru.model.comment import Comment
+from szurubooru.model.pool import PoolPost
+from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.hybrid import hybrid_property
+from sqlalchemy.ext.orderinglist import ordering_list
class PostFeature(Base):
@@ -224,6 +227,12 @@ class Post(Base):
notes = sa.orm.relationship(
'PostNote', cascade='all, delete-orphan', lazy='joined')
comments = sa.orm.relationship('Comment', cascade='all, delete-orphan')
+ _pools = sa.orm.relationship(
+ 'PoolPost',
+ lazy='select',
+ order_by='PoolPost.order',
+ back_populates='post')
+ pools = association_proxy('_pools', 'pool')
# dynamic columns
tag_count = sa.orm.column_property(