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(