Compare commits
186 Commits
wip-commen
...
dillo
Author | SHA1 | Date | |
---|---|---|---|
b969854592 | |||
4e21b41ba6 | |||
db9cb09c68 | |||
d424febfeb | |||
defa5abd18 | |||
26858f01b7 | |||
3bb35d0ab8 | |||
8ba7122a01 | |||
38e4c7c937 | |||
15d5ac687c | |||
402f9f23b5 | |||
486fb20dcf | |||
34f2372082 | |||
c217ec194f | |||
b68af6da8b | |||
06f5bc8f01 | |||
53eb9f30fd | |||
43d464c60c | |||
d0ef76c19e | |||
a43eca4237 | |||
af020d4653 | |||
2c207b35e2 | |||
3f3172e00e | |||
26a09a900f | |||
90154896fb | |||
95d611d0c5 | |||
312b0a276a | |||
dc7d7bab4a | |||
d047943a07 | |||
b64b75eecb | |||
152dc50715 | |||
73edd5c5d2 | |||
3d8ee61b03 | |||
ee5a1a8bb7 | |||
ccc78af742 | |||
de40b4b2b6 | |||
fe2f350013 | |||
1b42d114ad | |||
e58db61d2a | |||
c6333cecfe | |||
ee6fd3386d | |||
700e7d2fc4 | |||
619dfda6fa | |||
985e96f20b | |||
37e09c2943 | |||
62af8c2cbf | |||
0b12436a31 | |||
7f12c9b4ad | |||
1171a8e437 | |||
54abda883d | |||
ad0f9b939a | |||
4d5a8613af | |||
ff314c0a7d | |||
18ec206a40 | |||
8f3f3b6698 | |||
ad5dbdf094 | |||
67a56dc797 | |||
093f4101cf | |||
b96731a939 | |||
32361a0e70 | |||
4f5746e0b7 | |||
1d65ea9de0 | |||
c31ef97c9e | |||
3906bab2ac | |||
c93393ad10 | |||
a37aec61b2 | |||
1b96c6e37e | |||
119900337d | |||
1d476d03d7 | |||
77a7b15a73 | |||
562e21d57a | |||
c80234bac2 | |||
f31253dd17 | |||
46bbd1297b | |||
5556bfee52 | |||
72a42c2bf8 | |||
da337df82b | |||
50aec93515 | |||
4187d17f1f | |||
ba299b2a4c | |||
c8adfc5595 | |||
50d17de278 | |||
f72c1fffca | |||
afc8acff83 | |||
4c857e63b2 | |||
48cb216c4a | |||
1fd17303a5 | |||
d5a4c247b0 | |||
a3b8a8933c | |||
5c8181ae41 | |||
ff43fa19fd | |||
f73b7e5c41 | |||
c089b0b603 | |||
4499f911de | |||
465f1eb87e | |||
f6056f4f7e | |||
64cb7abcba | |||
1f671a2375 | |||
898379d0d3 | |||
87ff681750 | |||
db11b03c39 | |||
1525ceafd5 | |||
9c1e345252 | |||
237c135c31 | |||
85706fc264 | |||
4cd182e2d2 | |||
69806d96a9 | |||
4977829da7 | |||
cd94eb237f | |||
97cda1ef6b | |||
5cba6f53f5 | |||
072a1793e4 | |||
375182a781 | |||
022fc9a1b2 | |||
b26402412b | |||
6c4e6088d3 | |||
5aed4ceff7 | |||
dfd61c8bd8 | |||
6bae6a39df | |||
66e6ba1467 | |||
d5f2996704 | |||
a104117618 | |||
0ee1d0d3da | |||
cfff5ef189 | |||
58ff236a99 | |||
ace091c998 | |||
4136da110f | |||
01da240f54 | |||
379f743864 | |||
d1143bad3e | |||
d22c4182bf | |||
c64e24d80d | |||
69251de995 | |||
57a180dc00 | |||
446d31d807 | |||
12d8a282aa | |||
145d512aa7 | |||
fbcd4c9250 | |||
bf63148852 | |||
a3f58ef8fe | |||
c7b0842779 | |||
5bcfa5218a | |||
da14d34551 | |||
812d911195 | |||
32e25ce129 | |||
250c7e2631 | |||
2f5f73843d | |||
a5bae513e1 | |||
1101b8e716 | |||
f0031d44b2 | |||
5660f4b606 | |||
f35c2529a6 | |||
ecfd27094c | |||
6b6a5310f8 | |||
f531685ba8 | |||
ef89b9a1dd | |||
c505694b2d | |||
3b59d3ee9a | |||
5eae0f6122 | |||
b5a74ce7b9 | |||
a32fb6a208 | |||
974ac6867c | |||
a756632cad | |||
c28d3e333a | |||
004bd47e22 | |||
64bd2150a4 | |||
a23e063002 | |||
903fbf8b0d | |||
beac125ff9 | |||
ef259345ce | |||
b87c5b3728 | |||
efeea87249 | |||
fb28059ae7 | |||
a84d4d13a0 | |||
cb265e1975 | |||
5b3de5f551 | |||
fbcce7a6d8 | |||
bba1448acd | |||
da7dc19f66 | |||
de8633a5a4 | |||
de5c7a98a5 | |||
ac092587af | |||
a10b42afe6 | |||
6377379144 | |||
82071bf922 | |||
1c0476699a |
22
gulpfile.js
22
gulpfile.js
@@ -40,7 +40,8 @@ let destination = {
|
|||||||
let source = {
|
let source = {
|
||||||
bootstrap: 'node_modules/bootstrap/',
|
bootstrap: 'node_modules/bootstrap/',
|
||||||
jquery: 'node_modules/jquery/',
|
jquery: 'node_modules/jquery/',
|
||||||
popper: 'node_modules/popper.js/'
|
popper: 'node_modules/popper.js/',
|
||||||
|
vue: 'node_modules/vue/',
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stylesheets */
|
/* Stylesheets */
|
||||||
@@ -106,10 +107,26 @@ function browserify_base(entry) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcompile and package common modules to be included in tutti.js.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* src/scripts/js/es6/common/api/init.js
|
||||||
|
* src/scripts/js/es6/common/events/init.js
|
||||||
|
* Everything exported in api/init.js will end up in module pillar.api.*, and everything exported in events/init.js
|
||||||
|
* will end up in pillar.events.*
|
||||||
|
*/
|
||||||
function browserify_common() {
|
function browserify_common() {
|
||||||
return glob.sync('src/scripts/js/es6/common/**/init.js').map(browserify_base);
|
return glob.sync('src/scripts/js/es6/common/**/init.js').map(browserify_base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcompile and package individual modules.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* src/scripts/js/es6/individual/coolstuff/init.js
|
||||||
|
* Will create a coolstuff.js and everything exported in init.js will end up in namespace pillar.coolstuff.*
|
||||||
|
*/
|
||||||
gulp.task('scripts_browserify', function(done) {
|
gulp.task('scripts_browserify', function(done) {
|
||||||
glob('src/scripts/js/es6/individual/**/init.js', function(err, files) {
|
glob('src/scripts/js/es6/individual/**/init.js', function(err, files) {
|
||||||
if(err) done(err);
|
if(err) done(err);
|
||||||
@@ -127,7 +144,7 @@ gulp.task('scripts_browserify', function(done) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js
|
/* Collection of scripts in src/scripts/tutti/ and src/scripts/js/es6/common/ to merge into tutti.min.js
|
||||||
* Since it's always loaded, it's only for functions that we want site-wide.
|
* Since it's always loaded, it's only for functions that we want site-wide.
|
||||||
* It also includes jQuery and Bootstrap (and its dependency popper), since
|
* It also includes jQuery and Bootstrap (and its dependency popper), since
|
||||||
* the site doesn't work without it anyway.*/
|
* the site doesn't work without it anyway.*/
|
||||||
@@ -135,6 +152,7 @@ gulp.task('scripts_concat_tutti', function(done) {
|
|||||||
|
|
||||||
let toUglify = [
|
let toUglify = [
|
||||||
source.jquery + 'dist/jquery.min.js',
|
source.jquery + 'dist/jquery.min.js',
|
||||||
|
source.vue + (enabled.uglify ? 'dist/vue.min.js' : 'dist/vue.js'),
|
||||||
source.popper + 'dist/umd/popper.min.js',
|
source.popper + 'dist/umd/popper.min.js',
|
||||||
source.bootstrap + 'js/dist/index.js',
|
source.bootstrap + 'js/dist/index.js',
|
||||||
source.bootstrap + 'js/dist/util.js',
|
source.bootstrap + 'js/dist/util.js',
|
||||||
|
5453
package-lock.json
generated
5453
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -37,10 +37,18 @@
|
|||||||
"bootstrap": "4.1.3",
|
"bootstrap": "4.1.3",
|
||||||
"glob": "7.1.3",
|
"glob": "7.1.3",
|
||||||
"jquery": "3.3.1",
|
"jquery": "3.3.1",
|
||||||
|
"natives": "^1.1.6",
|
||||||
"popper.js": "1.14.4",
|
"popper.js": "1.14.4",
|
||||||
"video.js": "7.2.2"
|
"video.js": "7.2.2",
|
||||||
|
"vue": "2.5.17"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"__COMMENTS__": [
|
||||||
|
"natives@1.1.6 for Gulp 3.x on Node 10.x: https://github.com/gulpjs/gulp/issues/2162#issuecomment-385197164"
|
||||||
|
],
|
||||||
|
"resolutions": {
|
||||||
|
"natives": "1.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -790,7 +790,7 @@ class PillarServer(BlinkerCompatibleEve):
|
|||||||
return 'basic ' + base64.b64encode('%s:%s' % (username, subclient_id))
|
return 'basic ' + base64.b64encode('%s:%s' % (username, subclient_id))
|
||||||
|
|
||||||
def post_internal(self, resource: str, payl=None, skip_validation=False):
|
def post_internal(self, resource: str, payl=None, skip_validation=False):
|
||||||
"""Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810"""
|
"""Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
|
||||||
from eve.methods.post import post_internal
|
from eve.methods.post import post_internal
|
||||||
|
|
||||||
url = self.config['URLS'][resource]
|
url = self.config['URLS'][resource]
|
||||||
@@ -800,7 +800,7 @@ class PillarServer(BlinkerCompatibleEve):
|
|||||||
|
|
||||||
def put_internal(self, resource: str, payload=None, concurrency_check=False,
|
def put_internal(self, resource: str, payload=None, concurrency_check=False,
|
||||||
skip_validation=False, **lookup):
|
skip_validation=False, **lookup):
|
||||||
"""Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810"""
|
"""Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
|
||||||
from eve.methods.put import put_internal
|
from eve.methods.put import put_internal
|
||||||
|
|
||||||
url = self.config['URLS'][resource]
|
url = self.config['URLS'][resource]
|
||||||
@@ -811,7 +811,7 @@ class PillarServer(BlinkerCompatibleEve):
|
|||||||
|
|
||||||
def patch_internal(self, resource: str, payload=None, concurrency_check=False,
|
def patch_internal(self, resource: str, payload=None, concurrency_check=False,
|
||||||
skip_validation=False, **lookup):
|
skip_validation=False, **lookup):
|
||||||
"""Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810"""
|
"""Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
|
||||||
from eve.methods.patch import patch_internal
|
from eve.methods.patch import patch_internal
|
||||||
|
|
||||||
url = self.config['URLS'][resource]
|
url = self.config['URLS'][resource]
|
||||||
@@ -822,7 +822,7 @@ class PillarServer(BlinkerCompatibleEve):
|
|||||||
|
|
||||||
def delete_internal(self, resource: str, concurrency_check=False,
|
def delete_internal(self, resource: str, concurrency_check=False,
|
||||||
suppress_callbacks=False, **lookup):
|
suppress_callbacks=False, **lookup):
|
||||||
"""Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810"""
|
"""Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
|
||||||
from eve.methods.delete import deleteitem_internal
|
from eve.methods.delete import deleteitem_internal
|
||||||
|
|
||||||
url = self.config['URLS'][resource]
|
url = self.config['URLS'][resource]
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
from flask import request, current_app
|
from flask import request, current_app
|
||||||
from pillar.api.utils import gravatar
|
from pillar.api.utils import gravatar
|
||||||
@@ -7,6 +8,15 @@ from pillar.auth import current_user
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CommentHTMLParser(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
HTMLParser.__init__(self)
|
||||||
|
self.data = []
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
self.data.append(data)
|
||||||
|
|
||||||
|
|
||||||
def notification_parse(notification):
|
def notification_parse(notification):
|
||||||
activities_collection = current_app.data.driver.db['activities']
|
activities_collection = current_app.data.driver.db['activities']
|
||||||
activities_subscriptions_collection = \
|
activities_subscriptions_collection = \
|
||||||
@@ -30,9 +40,14 @@ def notification_parse(notification):
|
|||||||
object_type = 'comment'
|
object_type = 'comment'
|
||||||
object_name = ''
|
object_name = ''
|
||||||
object_id = activity['object']
|
object_id = activity['object']
|
||||||
|
context_object_type = node['parent']['node_type']
|
||||||
|
|
||||||
|
# If node_type is 'dillo_post', just call it 'post'
|
||||||
|
node_type = 'post' if context_object_type.endswith('_post') else \
|
||||||
|
context_object_type
|
||||||
|
|
||||||
if node['parent']['user'] == current_user.user_id:
|
if node['parent']['user'] == current_user.user_id:
|
||||||
owner = "your {0}".format(node['parent']['node_type'])
|
owner = f"your {node_type}"
|
||||||
else:
|
else:
|
||||||
parent_comment_user = users_collection.find_one(
|
parent_comment_user = users_collection.find_one(
|
||||||
{'_id': node['parent']['user']})
|
{'_id': node['parent']['user']})
|
||||||
@@ -40,10 +55,22 @@ def notification_parse(notification):
|
|||||||
user_name = 'their'
|
user_name = 'their'
|
||||||
else:
|
else:
|
||||||
user_name = "{0}'s".format(parent_comment_user['username'])
|
user_name = "{0}'s".format(parent_comment_user['username'])
|
||||||
owner = "{0} {1}".format(user_name, node['parent']['node_type'])
|
|
||||||
|
|
||||||
context_object_type = node['parent']['node_type']
|
owner = f"{user_name} {node_type}"
|
||||||
context_object_name = owner
|
|
||||||
|
context_object_name = f"{node['parent']['name'][:50]}..."
|
||||||
|
if context_object_type == 'comment':
|
||||||
|
# Parse the comment content, which might be HTML and extract
|
||||||
|
# some text from it.
|
||||||
|
parser = CommentHTMLParser()
|
||||||
|
# Trim the comment content to 50 chars, the parser will handle it
|
||||||
|
parser.feed(node['properties']['content'][:50])
|
||||||
|
try:
|
||||||
|
comment_content = parser.data[0]
|
||||||
|
except KeyError:
|
||||||
|
comment_content = '...'
|
||||||
|
# Trim the parsed text down to 15 charss
|
||||||
|
context_object_name = f"{comment_content[:50]}..."
|
||||||
context_object_id = activity['context_object']
|
context_object_id = activity['context_object']
|
||||||
if activity['verb'] == 'replied':
|
if activity['verb'] == 'replied':
|
||||||
action = 'replied to'
|
action = 'replied to'
|
||||||
@@ -52,13 +79,15 @@ def notification_parse(notification):
|
|||||||
else:
|
else:
|
||||||
action = activity['verb']
|
action = activity['verb']
|
||||||
|
|
||||||
|
action = f'{action} {owner}'
|
||||||
|
|
||||||
lookup = {
|
lookup = {
|
||||||
'user': current_user.user_id,
|
'user': current_user.user_id,
|
||||||
'context_object_type': 'node',
|
'context_object_type': 'node',
|
||||||
'context_object': context_object_id,
|
'context_object': context_object_id,
|
||||||
}
|
}
|
||||||
subscription = activities_subscriptions_collection.find_one(lookup)
|
subscription = activities_subscriptions_collection.find_one(lookup)
|
||||||
if subscription and subscription['notifications']['web'] == True:
|
if subscription and subscription['notifications']['web'] is True:
|
||||||
is_subscribed = True
|
is_subscribed = True
|
||||||
else:
|
else:
|
||||||
is_subscribed = False
|
is_subscribed = False
|
||||||
@@ -119,6 +148,8 @@ def activity_subscribe(user_id, context_object_type, context_object_id):
|
|||||||
|
|
||||||
# If no subscription exists, we create one
|
# If no subscription exists, we create one
|
||||||
if not subscription:
|
if not subscription:
|
||||||
|
# Workaround for issue: https://github.com/pyeve/eve/issues/1174
|
||||||
|
lookup['notifications'] = {}
|
||||||
current_app.post_internal('activities-subscriptions', lookup)
|
current_app.post_internal('activities-subscriptions', lookup)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import copy
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -6,36 +5,12 @@ from bson import ObjectId, tz_util
|
|||||||
from eve.io.mongo import Validator
|
from eve.io.mongo import Validator
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
import pillar.markdown
|
from pillar import markdown
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ValidateCustomFields(Validator):
|
class ValidateCustomFields(Validator):
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
# Will be reference to the actual document being validated, so that we can
|
|
||||||
# modify it during validation.
|
|
||||||
self.__real_document = None
|
|
||||||
|
|
||||||
def validate(self, document, *args, **kwargs):
|
|
||||||
# Keep a reference to the actual document, because Cerberus validates copies.
|
|
||||||
self.__real_document = document
|
|
||||||
result = super().validate(document, *args, **kwargs)
|
|
||||||
|
|
||||||
# Store the in-place modified document as self.document, so that Eve's post_internal
|
|
||||||
# can actually pick it up as the validated document. We need to make a copy so that
|
|
||||||
# further modifications (like setting '_etag' etc.) aren't done in-place.
|
|
||||||
self.document = copy.deepcopy(document)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _get_child_validator(self, *args, **kwargs):
|
|
||||||
child = super()._get_child_validator(*args, **kwargs)
|
|
||||||
# Pass along our reference to the actual document.
|
|
||||||
child.__real_document = self.__real_document
|
|
||||||
return child
|
|
||||||
|
|
||||||
# TODO: split this into a convert_property(property, schema) and call that from this function.
|
# TODO: split this into a convert_property(property, schema) and call that from this function.
|
||||||
def convert_properties(self, properties, node_schema):
|
def convert_properties(self, properties, node_schema):
|
||||||
@@ -137,8 +112,7 @@ class ValidateCustomFields(Validator):
|
|||||||
if val:
|
if val:
|
||||||
# This ensures the modifications made by v's coercion rules are
|
# This ensures the modifications made by v's coercion rules are
|
||||||
# visible to this validator's output.
|
# visible to this validator's output.
|
||||||
# TODO(fsiddi): this no longer works due to Cerberus internal changes.
|
self.document[field] = v.document
|
||||||
# self.current[field] = v.current
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
log.warning('Error validating properties for node %s: %s', self.document, v.errors)
|
log.warning('Error validating properties for node %s: %s', self.document, v.errors)
|
||||||
@@ -183,36 +157,19 @@ class ValidateCustomFields(Validator):
|
|||||||
if ip.prefixlen() == 0:
|
if ip.prefixlen() == 0:
|
||||||
self._error(field_name, 'Zero-length prefix is not allowed')
|
self._error(field_name, 'Zero-length prefix is not allowed')
|
||||||
|
|
||||||
def _validator_markdown(self, field, value):
|
def _normalize_coerce_markdown(self, markdown_field: str) -> str:
|
||||||
"""Convert MarkDown.
|
|
||||||
"""
|
"""
|
||||||
my_log = log.getChild('_validator_markdown')
|
Cache markdown as html.
|
||||||
|
|
||||||
# Find this field inside the original document
|
:param markdown_field: name of the field containing Markdown
|
||||||
my_subdoc = self._subdoc_in_real_document()
|
:return: html string
|
||||||
if my_subdoc is None:
|
|
||||||
# If self.update==True we are validating an update document, which
|
|
||||||
# may not contain all fields, so then a missing field is fine.
|
|
||||||
if not self.update:
|
|
||||||
self._error(field, f'validator_markdown: unable to find sub-document '
|
|
||||||
f'for path {self.document_path}')
|
|
||||||
return
|
|
||||||
|
|
||||||
my_log.debug('validating field %r with value %r', field, value)
|
|
||||||
save_to = pillar.markdown.cache_field_name(field)
|
|
||||||
html = pillar.markdown.markdown(value)
|
|
||||||
my_log.debug('saving result to %r in doc with id %s', save_to, id(my_subdoc))
|
|
||||||
my_subdoc[save_to] = html
|
|
||||||
|
|
||||||
def _subdoc_in_real_document(self):
|
|
||||||
"""Return a reference to the current sub-document inside the real document.
|
|
||||||
|
|
||||||
This allows modification of the document being validated.
|
|
||||||
"""
|
"""
|
||||||
my_subdoc = getattr(self, 'persisted_document') or self.__real_document
|
my_log = log.getChild('_normalize_coerce_markdown')
|
||||||
for item in self.document_path:
|
mdown = self.document.get(markdown_field, '')
|
||||||
my_subdoc = my_subdoc[item]
|
html = markdown.markdown(mdown)
|
||||||
return my_subdoc
|
my_log.debug('Generated html for markdown field %s in doc with id %s',
|
||||||
|
markdown_field, id(self.document))
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
@@ -1,5 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
from pillar.api.node_types.utils import markdown_fields
|
||||||
|
|
||||||
|
STORAGE_BACKENDS = ["local", "pillar", "cdnsun", "gcs", "unittest"]
|
||||||
URL_PREFIX = 'api'
|
URL_PREFIX = 'api'
|
||||||
|
|
||||||
# Enable reads (GET), inserts (POST) and DELETE for resources/collections
|
# Enable reads (GET), inserts (POST) and DELETE for resources/collections
|
||||||
@@ -183,12 +186,7 @@ organizations_schema = {
|
|||||||
'maxlength': 128,
|
'maxlength': 128,
|
||||||
'required': True
|
'required': True
|
||||||
},
|
},
|
||||||
'description': {
|
**markdown_fields('description', maxlength=256),
|
||||||
'type': 'string',
|
|
||||||
'maxlength': 256,
|
|
||||||
'validator': 'markdown',
|
|
||||||
},
|
|
||||||
'_description_html': {'type': 'string'},
|
|
||||||
'website': {
|
'website': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'maxlength': 256,
|
'maxlength': 256,
|
||||||
@@ -321,11 +319,7 @@ nodes_schema = {
|
|||||||
'maxlength': 128,
|
'maxlength': 128,
|
||||||
'required': True,
|
'required': True,
|
||||||
},
|
},
|
||||||
'description': {
|
**markdown_fields('description'),
|
||||||
'type': 'string',
|
|
||||||
'validator': 'markdown',
|
|
||||||
},
|
|
||||||
'_description_html': {'type': 'string'},
|
|
||||||
'picture': _file_embedded_schema,
|
'picture': _file_embedded_schema,
|
||||||
'order': {
|
'order': {
|
||||||
'type': 'integer',
|
'type': 'integer',
|
||||||
@@ -463,7 +457,7 @@ files_schema = {
|
|||||||
'backend': {
|
'backend': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'required': True,
|
'required': True,
|
||||||
'allowed': ["local", "pillar", "cdnsun", "gcs", "unittest"]
|
'allowed': STORAGE_BACKENDS,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Where the file is in the backend storage itself. In the case of GCS,
|
# Where the file is in the backend storage itself. In the case of GCS,
|
||||||
@@ -575,11 +569,7 @@ projects_schema = {
|
|||||||
'maxlength': 128,
|
'maxlength': 128,
|
||||||
'required': True,
|
'required': True,
|
||||||
},
|
},
|
||||||
'description': {
|
**markdown_fields('description'),
|
||||||
'type': 'string',
|
|
||||||
'validator': 'markdown',
|
|
||||||
},
|
|
||||||
'_description_html': {'type': 'string'},
|
|
||||||
# Short summary for the project
|
# Short summary for the project
|
||||||
'summary': {
|
'summary': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
@@ -589,6 +579,8 @@ projects_schema = {
|
|||||||
'picture_square': _file_embedded_schema,
|
'picture_square': _file_embedded_schema,
|
||||||
# Header
|
# Header
|
||||||
'picture_header': _file_embedded_schema,
|
'picture_header': _file_embedded_schema,
|
||||||
|
# Picture with a 16:9 aspect ratio (for Open Graph)
|
||||||
|
'picture_16_9': _file_embedded_schema,
|
||||||
'header_node': dict(
|
'header_node': dict(
|
||||||
nullable=True,
|
nullable=True,
|
||||||
**_node_embedded_schema
|
**_node_embedded_schema
|
||||||
|
@@ -5,6 +5,7 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import typing
|
import typing
|
||||||
import uuid
|
import uuid
|
||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
@@ -185,8 +186,8 @@ def _video_duration_seconds(filename: pathlib.Path) -> typing.Optional[int]:
|
|||||||
str(filename),
|
str(filename),
|
||||||
]
|
]
|
||||||
|
|
||||||
duration = run(ffprobe_from_stream_args) or\
|
duration = run(ffprobe_from_stream_args) or \
|
||||||
run(ffprobe_from_container_args) or\
|
run(ffprobe_from_container_args) or \
|
||||||
None
|
None
|
||||||
return duration
|
return duration
|
||||||
|
|
||||||
@@ -609,6 +610,7 @@ def refresh_links_for_backend(backend_name, chunk_size, expiry_seconds):
|
|||||||
import gcloud.exceptions
|
import gcloud.exceptions
|
||||||
|
|
||||||
my_log = log.getChild(f'refresh_links_for_backend.{backend_name}')
|
my_log = log.getChild(f'refresh_links_for_backend.{backend_name}')
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
# Retrieve expired links.
|
# Retrieve expired links.
|
||||||
files_collection = current_app.data.driver.db['files']
|
files_collection = current_app.data.driver.db['files']
|
||||||
@@ -632,10 +634,10 @@ def refresh_links_for_backend(backend_name, chunk_size, expiry_seconds):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if 0 < chunk_size == document_count:
|
if 0 < chunk_size == document_count:
|
||||||
my_log.info('Found %d documents to refresh, probably limited by the chunk size.',
|
my_log.info('Found %d documents to refresh, probably limited by the chunk size %d',
|
||||||
document_count)
|
document_count, chunk_size)
|
||||||
else:
|
else:
|
||||||
my_log.info('Found %d documents to refresh.', document_count)
|
my_log.info('Found %d documents to refresh, chunk size=%d', document_count, chunk_size)
|
||||||
|
|
||||||
refreshed = 0
|
refreshed = 0
|
||||||
report_chunks = min(max(5, document_count // 25), 100)
|
report_chunks = min(max(5, document_count // 25), 100)
|
||||||
@@ -679,8 +681,10 @@ def refresh_links_for_backend(backend_name, chunk_size, expiry_seconds):
|
|||||||
'links', refreshed)
|
'links', refreshed)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if refreshed % report_chunks != 0:
|
||||||
my_log.info('Refreshed %i links', refreshed)
|
my_log.info('Refreshed %i links', refreshed)
|
||||||
|
|
||||||
|
my_log.info('Refresh took %s', datetime.timedelta(seconds=time.time() - start_time))
|
||||||
|
|
||||||
@require_login()
|
@require_login()
|
||||||
def create_file_doc(name, filename, content_type, length, project,
|
def create_file_doc(name, filename, content_type, length, project,
|
||||||
|
@@ -90,12 +90,11 @@ class Blob(metaclass=abc.ABCMeta):
|
|||||||
|
|
||||||
def __init__(self, name: str, bucket: Bucket) -> None:
|
def __init__(self, name: str, bucket: Bucket) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
|
"""Name of this blob in the bucket."""
|
||||||
|
|
||||||
self.bucket = bucket
|
self.bucket = bucket
|
||||||
self._size_in_bytes: typing.Optional[int] = None
|
self._size_in_bytes: typing.Optional[int] = None
|
||||||
|
|
||||||
self.filename: str = None
|
|
||||||
"""Name of the file for the Content-Disposition header when downloading it."""
|
|
||||||
|
|
||||||
self._log = logging.getLogger(f'{__name__}.Blob')
|
self._log = logging.getLogger(f'{__name__}.Blob')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -133,12 +132,19 @@ class Blob(metaclass=abc.ABCMeta):
|
|||||||
file_size=file_size)
|
file_size=file_size)
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def update_filename(self, filename: str):
|
def update_filename(self, filename: str, *, is_attachment=True):
|
||||||
"""Sets the filename which is used when downloading the file.
|
"""Sets the filename which is used when downloading the file.
|
||||||
|
|
||||||
Not all storage backends support this, and will use the on-disk filename instead.
|
Not all storage backends support this, and will use the on-disk filename instead.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def update_content_type(self, content_type: str, content_encoding: str = ''):
|
||||||
|
"""Set the content type (and optionally content encoding).
|
||||||
|
|
||||||
|
Not all storage backends support this.
|
||||||
|
"""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def get_url(self, *, is_public: bool) -> str:
|
def get_url(self, *, is_public: bool) -> str:
|
||||||
"""Returns the URL to access this blob.
|
"""Returns the URL to access this blob.
|
||||||
|
@@ -174,7 +174,7 @@ class GoogleCloudStorageBlob(Blob):
|
|||||||
self.gblob.reload()
|
self.gblob.reload()
|
||||||
self._size_in_bytes = self.gblob.size
|
self._size_in_bytes = self.gblob.size
|
||||||
|
|
||||||
def update_filename(self, filename: str):
|
def update_filename(self, filename: str, *, is_attachment=True):
|
||||||
"""Set the ContentDisposition metadata so that when a file is downloaded
|
"""Set the ContentDisposition metadata so that when a file is downloaded
|
||||||
it has a human-readable name.
|
it has a human-readable name.
|
||||||
"""
|
"""
|
||||||
@@ -182,7 +182,17 @@ class GoogleCloudStorageBlob(Blob):
|
|||||||
if '"' in filename:
|
if '"' in filename:
|
||||||
raise ValueError(f'Filename is not allowed to have double quote in it: {filename!r}')
|
raise ValueError(f'Filename is not allowed to have double quote in it: {filename!r}')
|
||||||
|
|
||||||
|
if is_attachment:
|
||||||
self.gblob.content_disposition = f'attachment; filename="{filename}"'
|
self.gblob.content_disposition = f'attachment; filename="{filename}"'
|
||||||
|
else:
|
||||||
|
self.gblob.content_disposition = f'filename="{filename}"'
|
||||||
|
self.gblob.patch()
|
||||||
|
|
||||||
|
def update_content_type(self, content_type: str, content_encoding: str = ''):
|
||||||
|
"""Set the content type (and optionally content encoding)."""
|
||||||
|
|
||||||
|
self.gblob.content_type = content_type
|
||||||
|
self.gblob.content_encoding = content_encoding
|
||||||
self.gblob.patch()
|
self.gblob.patch()
|
||||||
|
|
||||||
def get_url(self, *, is_public: bool) -> str:
|
def get_url(self, *, is_public: bool) -> str:
|
||||||
|
@@ -113,10 +113,13 @@ class LocalBlob(Blob):
|
|||||||
|
|
||||||
self._size_in_bytes = file_size
|
self._size_in_bytes = file_size
|
||||||
|
|
||||||
def update_filename(self, filename: str):
|
def update_filename(self, filename: str, *, is_attachment=True):
|
||||||
# TODO: implement this for local storage.
|
# TODO: implement this for local storage.
|
||||||
self._log.info('update_filename(%r) not supported', filename)
|
self._log.info('update_filename(%r) not supported', filename)
|
||||||
|
|
||||||
|
def update_content_type(self, content_type: str, content_encoding: str = ''):
|
||||||
|
self._log.info('update_content_type(%r, %r) not supported', content_type, content_encoding)
|
||||||
|
|
||||||
def make_public(self):
|
def make_public(self):
|
||||||
# No-op on this storage backend.
|
# No-op on this storage backend.
|
||||||
pass
|
pass
|
||||||
|
@@ -23,14 +23,6 @@ attachments_embedded_schema = {
|
|||||||
'type': 'objectid',
|
'type': 'objectid',
|
||||||
'required': True,
|
'required': True,
|
||||||
},
|
},
|
||||||
'link': {
|
|
||||||
'type': 'string',
|
|
||||||
'allowed': ['self', 'none', 'custom'],
|
|
||||||
'default': 'self',
|
|
||||||
},
|
|
||||||
'link_custom': {
|
|
||||||
'type': 'string',
|
|
||||||
},
|
|
||||||
'collection': {
|
'collection': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'allowed': ['files'],
|
'allowed': ['files'],
|
||||||
|
@@ -1,15 +1,15 @@
|
|||||||
|
from pillar.api.node_types import attachments_embedded_schema
|
||||||
|
from pillar.api.node_types.utils import markdown_fields
|
||||||
|
|
||||||
node_type_comment = {
|
node_type_comment = {
|
||||||
'name': 'comment',
|
'name': 'comment',
|
||||||
'description': 'Comments for asset nodes, pages, etc.',
|
'description': 'Comments for asset nodes, pages, etc.',
|
||||||
'dyn_schema': {
|
'dyn_schema': {
|
||||||
# The actual comment content
|
# The actual comment content
|
||||||
'content': {
|
**markdown_fields(
|
||||||
'type': 'string',
|
'content',
|
||||||
'minlength': 5,
|
minlength=5,
|
||||||
'required': True,
|
required=True),
|
||||||
'validator': 'markdown',
|
|
||||||
},
|
|
||||||
'_content_html': {'type': 'string'},
|
|
||||||
'status': {
|
'status': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'allowed': [
|
'allowed': [
|
||||||
@@ -51,7 +51,8 @@ node_type_comment = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'confidence': {'type': 'float'},
|
'confidence': {'type': 'float'},
|
||||||
'is_reply': {'type': 'boolean'}
|
'is_reply': {'type': 'boolean'},
|
||||||
|
'attachments': attachments_embedded_schema,
|
||||||
},
|
},
|
||||||
'form_schema': {},
|
'form_schema': {},
|
||||||
'parent': ['asset', 'comment'],
|
'parent': ['asset', 'comment'],
|
||||||
|
@@ -1,17 +1,14 @@
|
|||||||
from pillar.api.node_types import attachments_embedded_schema
|
from pillar.api.node_types import attachments_embedded_schema
|
||||||
|
from pillar.api.node_types.utils import markdown_fields
|
||||||
|
|
||||||
node_type_post = {
|
node_type_post = {
|
||||||
'name': 'post',
|
'name': 'post',
|
||||||
'description': 'A blog post, for any project',
|
'description': 'A blog post, for any project',
|
||||||
'dyn_schema': {
|
'dyn_schema': {
|
||||||
'content': {
|
**markdown_fields('content',
|
||||||
'type': 'string',
|
minlength=5,
|
||||||
'minlength': 5,
|
maxlength=90000,
|
||||||
'maxlength': 90000,
|
required=True),
|
||||||
'required': True,
|
|
||||||
'validator': 'markdown',
|
|
||||||
},
|
|
||||||
'_content_html': {'type': 'string'},
|
|
||||||
'status': {
|
'status': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'allowed': [
|
'allowed': [
|
||||||
|
34
pillar/api/node_types/utils.py
Normal file
34
pillar/api/node_types/utils.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from pillar import markdown
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_fields(field: str, **kwargs) -> dict:
|
||||||
|
"""
|
||||||
|
Creates a field for the markdown, and a field for the cached html.
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
schema = {'myDoc': {
|
||||||
|
'type': 'list',
|
||||||
|
'schema': {
|
||||||
|
'type': 'dict',
|
||||||
|
'schema': {
|
||||||
|
**markdown_fields('content', required=True),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
:param field:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
cache_field = markdown.cache_field_name(field)
|
||||||
|
return {
|
||||||
|
field: {
|
||||||
|
'type': 'string',
|
||||||
|
**kwargs
|
||||||
|
},
|
||||||
|
cache_field: {
|
||||||
|
'type': 'string',
|
||||||
|
'readonly': True,
|
||||||
|
'default': field, # Name of the field containing the markdown. Will be input to the coerce function.
|
||||||
|
'coerce': 'markdown',
|
||||||
|
}
|
||||||
|
}
|
@@ -6,14 +6,15 @@ import pymongo.errors
|
|||||||
import werkzeug.exceptions as wz_exceptions
|
import werkzeug.exceptions as wz_exceptions
|
||||||
from flask import current_app, Blueprint, request
|
from flask import current_app, Blueprint, request
|
||||||
|
|
||||||
from pillar.api.nodes import eve_hooks
|
from pillar.api.nodes import eve_hooks, comments, activities
|
||||||
from pillar.api.utils import str2id, jsonify
|
from pillar.api.utils import str2id, jsonify
|
||||||
from pillar.api.utils.authorization import check_permissions, require_login
|
from pillar.api.utils.authorization import check_permissions, require_login
|
||||||
from pillar.web.utils import pretty_date
|
from pillar.web.utils import pretty_date
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
blueprint = Blueprint('nodes_api', __name__)
|
blueprint = Blueprint('nodes_api', __name__)
|
||||||
ROLES_FOR_SHARING = {'subscriber', 'demo'}
|
# TODO(fsiddi) Propose changes to make commenting roles a configuration value.
|
||||||
|
ROLES_FOR_SHARING = ROLES_FOR_COMMENTING = set()
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
|
@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
|
||||||
@@ -51,6 +52,47 @@ def share_node(node_id):
|
|||||||
return jsonify(eve_hooks.short_link_info(short_code), status=status)
|
return jsonify(eve_hooks.short_link_info(short_code), status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/<string(length=24):node_path>/comments', methods=['GET'])
|
||||||
|
def get_node_comments(node_path: str):
|
||||||
|
node_id = str2id(node_path)
|
||||||
|
return comments.get_node_comments(node_id)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/<string(length=24):node_path>/comments', methods=['POST'])
|
||||||
|
@require_login(require_roles=ROLES_FOR_COMMENTING)
|
||||||
|
def post_node_comment(node_path: str):
|
||||||
|
node_id = str2id(node_path)
|
||||||
|
msg = request.json['msg']
|
||||||
|
attachments = request.json.get('attachments', {})
|
||||||
|
return comments.post_node_comment(node_id, msg, attachments)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/<string(length=24):node_path>/comments/<string(length=24):comment_path>', methods=['PATCH'])
|
||||||
|
@require_login(require_roles=ROLES_FOR_COMMENTING)
|
||||||
|
def patch_node_comment(node_path: str, comment_path: str):
|
||||||
|
node_id = str2id(node_path)
|
||||||
|
comment_id = str2id(comment_path)
|
||||||
|
msg = request.json['msg']
|
||||||
|
attachments = request.json.get('attachments', {})
|
||||||
|
return comments.patch_node_comment(node_id, comment_id, msg, attachments)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/<string(length=24):node_path>/comments/<string(length=24):comment_path>/vote', methods=['POST'])
|
||||||
|
@require_login(require_roles=ROLES_FOR_COMMENTING)
|
||||||
|
def post_node_comment_vote(node_path: str, comment_path: str):
|
||||||
|
node_id = str2id(node_path)
|
||||||
|
comment_id = str2id(comment_path)
|
||||||
|
vote_str = request.json['vote']
|
||||||
|
vote = int(vote_str)
|
||||||
|
return comments.post_node_comment_vote(node_id, comment_id, vote)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/<string(length=24):node_path>/activities', methods=['GET'])
|
||||||
|
def activities_for_node(node_path: str):
|
||||||
|
node_id = str2id(node_path)
|
||||||
|
return jsonify(activities.for_node(node_id))
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/tagged/')
|
@blueprint.route('/tagged/')
|
||||||
@blueprint.route('/tagged/<tag>')
|
@blueprint.route('/tagged/<tag>')
|
||||||
def tagged(tag=''):
|
def tagged(tag=''):
|
||||||
@@ -212,14 +254,12 @@ def setup_app(app, url_prefix):
|
|||||||
app.on_fetched_resource_nodes += eve_hooks.before_returning_nodes
|
app.on_fetched_resource_nodes += eve_hooks.before_returning_nodes
|
||||||
|
|
||||||
app.on_replace_nodes += eve_hooks.before_replacing_node
|
app.on_replace_nodes += eve_hooks.before_replacing_node
|
||||||
app.on_replace_nodes += eve_hooks.parse_markdown
|
|
||||||
app.on_replace_nodes += eve_hooks.texture_sort_files
|
app.on_replace_nodes += eve_hooks.texture_sort_files
|
||||||
app.on_replace_nodes += eve_hooks.deduct_content_type_and_duration
|
app.on_replace_nodes += eve_hooks.deduct_content_type_and_duration
|
||||||
app.on_replace_nodes += eve_hooks.node_set_default_picture
|
app.on_replace_nodes += eve_hooks.node_set_default_picture
|
||||||
app.on_replaced_nodes += eve_hooks.after_replacing_node
|
app.on_replaced_nodes += eve_hooks.after_replacing_node
|
||||||
|
|
||||||
app.on_insert_nodes += eve_hooks.before_inserting_nodes
|
app.on_insert_nodes += eve_hooks.before_inserting_nodes
|
||||||
app.on_insert_nodes += eve_hooks.parse_markdowns
|
|
||||||
app.on_insert_nodes += eve_hooks.nodes_deduct_content_type_and_duration
|
app.on_insert_nodes += eve_hooks.nodes_deduct_content_type_and_duration
|
||||||
app.on_insert_nodes += eve_hooks.nodes_set_default_picture
|
app.on_insert_nodes += eve_hooks.nodes_set_default_picture
|
||||||
app.on_insert_nodes += eve_hooks.textures_sort_files
|
app.on_insert_nodes += eve_hooks.textures_sort_files
|
||||||
@@ -231,3 +271,5 @@ def setup_app(app, url_prefix):
|
|||||||
app.on_deleted_item_nodes += eve_hooks.after_deleting_node
|
app.on_deleted_item_nodes += eve_hooks.after_deleting_node
|
||||||
|
|
||||||
app.register_api_blueprint(blueprint, url_prefix=url_prefix)
|
app.register_api_blueprint(blueprint, url_prefix=url_prefix)
|
||||||
|
|
||||||
|
activities.setup_app(app)
|
||||||
|
43
pillar/api/nodes/activities.py
Normal file
43
pillar/api/nodes/activities.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from eve.methods import get
|
||||||
|
|
||||||
|
from pillar.api.utils import gravatar
|
||||||
|
|
||||||
|
|
||||||
|
def for_node(node_id):
|
||||||
|
activities, _, _, status, _ =\
|
||||||
|
get('activities',
|
||||||
|
{
|
||||||
|
'$or': [
|
||||||
|
{'object_type': 'node',
|
||||||
|
'object': node_id},
|
||||||
|
{'context_object_type': 'node',
|
||||||
|
'context_object': node_id},
|
||||||
|
],
|
||||||
|
},)
|
||||||
|
|
||||||
|
for act in activities['_items']:
|
||||||
|
act['actor_user'] = _user_info(act['actor_user'])
|
||||||
|
|
||||||
|
return activities
|
||||||
|
|
||||||
|
|
||||||
|
def _user_info(user_id):
|
||||||
|
users, _, _, status, _ = get('users', {'_id': user_id})
|
||||||
|
if len(users['_items']) > 0:
|
||||||
|
user = users['_items'][0]
|
||||||
|
user['gravatar'] = gravatar(user['email'])
|
||||||
|
|
||||||
|
public_fields = {'full_name', 'username', 'gravatar'}
|
||||||
|
for field in list(user.keys()):
|
||||||
|
if field not in public_fields:
|
||||||
|
del user[field]
|
||||||
|
|
||||||
|
return user
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def setup_app(app):
|
||||||
|
global _user_info
|
||||||
|
|
||||||
|
decorator = app.cache.memoize(timeout=300, make_name='%s.public_user_info' % __name__)
|
||||||
|
_user_info = decorator(_user_info)
|
298
pillar/api/nodes/comments.py
Normal file
298
pillar/api/nodes/comments.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pymongo
|
||||||
|
import typing
|
||||||
|
|
||||||
|
import bson
|
||||||
|
import attr
|
||||||
|
import werkzeug.exceptions as wz_exceptions
|
||||||
|
|
||||||
|
import pillar
|
||||||
|
from pillar import current_app, shortcodes
|
||||||
|
from pillar.api.nodes.custom.comment import patch_comment
|
||||||
|
from pillar.api.utils import jsonify, gravatar
|
||||||
|
from pillar.auth import current_user
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True)
|
||||||
|
class UserDO:
|
||||||
|
id: str
|
||||||
|
full_name: str
|
||||||
|
gravatar: str
|
||||||
|
badges_html: str
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True)
|
||||||
|
class CommentPropertiesDO:
|
||||||
|
attachments: typing.Dict
|
||||||
|
rating_positive: int = 0
|
||||||
|
rating_negative: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True)
|
||||||
|
class CommentDO:
|
||||||
|
id: bson.ObjectId
|
||||||
|
parent: bson.ObjectId
|
||||||
|
project: bson.ObjectId
|
||||||
|
user: UserDO
|
||||||
|
msg_html: str
|
||||||
|
msg_markdown: str
|
||||||
|
properties: CommentPropertiesDO
|
||||||
|
created: datetime
|
||||||
|
updated: datetime
|
||||||
|
etag: str
|
||||||
|
replies: typing.List['CommentDO'] = []
|
||||||
|
current_user_rating: typing.Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True)
|
||||||
|
class CommentTreeDO:
|
||||||
|
node_id: bson.ObjectId
|
||||||
|
project: bson.ObjectId
|
||||||
|
nbr_of_comments: int = 0
|
||||||
|
comments: typing.List[CommentDO] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_markdowned_html(document: dict, field_name: str) -> str:
|
||||||
|
cache_field_name = pillar.markdown.cache_field_name(field_name)
|
||||||
|
html = document.get(cache_field_name)
|
||||||
|
if html is None:
|
||||||
|
markdown_src = document.get(field_name) or ''
|
||||||
|
html = pillar.markdown.markdown(markdown_src)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def jsonify_data_object(data_object: attr):
|
||||||
|
return jsonify(
|
||||||
|
attr.asdict(data_object,
|
||||||
|
recurse=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CommentTreeBuilder:
|
||||||
|
def __init__(self, node_id: bson.ObjectId):
|
||||||
|
self.node_id = node_id
|
||||||
|
self.nbr_of_Comments: int = 0
|
||||||
|
|
||||||
|
def build(self) -> CommentTreeDO:
|
||||||
|
enriched_comments = self.child_comments(self.node_id,
|
||||||
|
sort={'properties.rating_positive': pymongo.DESCENDING,
|
||||||
|
'_created': pymongo.DESCENDING})
|
||||||
|
project_id = self.get_project_id()
|
||||||
|
return CommentTreeDO(
|
||||||
|
node_id=self.node_id,
|
||||||
|
project=project_id,
|
||||||
|
nbr_of_comments=self.nbr_of_Comments,
|
||||||
|
comments=enriched_comments
|
||||||
|
)
|
||||||
|
|
||||||
|
def child_comments(self, node_id: bson.ObjectId, sort: dict) -> typing.List[CommentDO]:
|
||||||
|
raw_comments = self.mongodb_comments(node_id, sort)
|
||||||
|
return [self.enrich(comment) for comment in raw_comments]
|
||||||
|
|
||||||
|
def enrich(self, mongo_comment: dict) -> CommentDO:
|
||||||
|
self.nbr_of_Comments += 1
|
||||||
|
comment = to_comment_data_object(mongo_comment)
|
||||||
|
comment.replies = self.child_comments(mongo_comment['_id'],
|
||||||
|
sort={'_created': pymongo.ASCENDING})
|
||||||
|
return comment
|
||||||
|
|
||||||
|
def get_project_id(self):
|
||||||
|
nodes_coll = current_app.db('nodes')
|
||||||
|
result = nodes_coll.find_one({'_id': self.node_id})
|
||||||
|
return result['project']
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def mongodb_comments(cls, node_id: bson.ObjectId, sort: dict) -> typing.Iterator:
|
||||||
|
nodes_coll = current_app.db('nodes')
|
||||||
|
return nodes_coll.aggregate([
|
||||||
|
{'$match': {'node_type': 'comment',
|
||||||
|
'_deleted': {'$ne': True},
|
||||||
|
'properties.status': 'published',
|
||||||
|
'parent': node_id}},
|
||||||
|
{'$lookup': {"from": "users",
|
||||||
|
"localField": "user",
|
||||||
|
"foreignField": "_id",
|
||||||
|
"as": "user"}},
|
||||||
|
{'$unwind': {'path': "$user"}},
|
||||||
|
{'$sort': sort},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_comments(node_id: bson.ObjectId):
|
||||||
|
comments_tree = CommentTreeBuilder(node_id).build()
|
||||||
|
return jsonify_data_object(comments_tree)
|
||||||
|
|
||||||
|
|
||||||
|
def post_node_comment(parent_id: bson.ObjectId, markdown_msg: str, attachments: dict):
|
||||||
|
parent_node = find_node_or_raise(parent_id,
|
||||||
|
'User %s tried to update comment with bad parent_id %s',
|
||||||
|
current_user.objectid,
|
||||||
|
parent_id)
|
||||||
|
|
||||||
|
is_reply = parent_node['node_type'] == 'comment'
|
||||||
|
comment = dict(
|
||||||
|
parent=parent_id,
|
||||||
|
project=parent_node['project'],
|
||||||
|
name='Comment',
|
||||||
|
user=current_user.objectid,
|
||||||
|
node_type='comment',
|
||||||
|
properties=dict(
|
||||||
|
content=markdown_msg,
|
||||||
|
status='published',
|
||||||
|
is_reply=is_reply,
|
||||||
|
confidence=0,
|
||||||
|
rating_positive=0,
|
||||||
|
rating_negative=0,
|
||||||
|
attachments=attachments,
|
||||||
|
),
|
||||||
|
permissions=dict(
|
||||||
|
users=[dict(
|
||||||
|
user=current_user.objectid,
|
||||||
|
methods=['PUT'])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
r, _, _, status = current_app.post_internal('nodes', comment)
|
||||||
|
|
||||||
|
if status != 201:
|
||||||
|
log.warning('Unable to post comment on %s as %s: %s',
|
||||||
|
parent_id, current_user.objectid, r)
|
||||||
|
raise wz_exceptions.InternalServerError('Unable to create comment')
|
||||||
|
|
||||||
|
comment_do = get_comment(parent_id, r['_id'])
|
||||||
|
|
||||||
|
return jsonify_data_object(comment_do), 201
|
||||||
|
|
||||||
|
|
||||||
|
def find_node_or_raise(node_id, *args):
|
||||||
|
nodes_coll = current_app.db('nodes')
|
||||||
|
node_to_comment = nodes_coll.find_one({
|
||||||
|
'_id': node_id,
|
||||||
|
'_deleted': {'$ne': True},
|
||||||
|
})
|
||||||
|
if not node_to_comment:
|
||||||
|
log.warning(args)
|
||||||
|
raise wz_exceptions.UnprocessableEntity()
|
||||||
|
return node_to_comment
|
||||||
|
|
||||||
|
|
||||||
|
def patch_node_comment(parent_id: bson.ObjectId, comment_id: bson.ObjectId, markdown_msg: str, attachments: dict):
|
||||||
|
_, _ = find_parent_and_comment_or_raise(parent_id, comment_id)
|
||||||
|
|
||||||
|
patch = dict(
|
||||||
|
op='edit',
|
||||||
|
content=markdown_msg,
|
||||||
|
attachments=attachments
|
||||||
|
)
|
||||||
|
|
||||||
|
json_result = patch_comment(comment_id, patch)
|
||||||
|
if json_result.json['result'] != 200:
|
||||||
|
raise wz_exceptions.InternalServerError('Failed to update comment')
|
||||||
|
|
||||||
|
comment_do = get_comment(parent_id, comment_id)
|
||||||
|
|
||||||
|
return jsonify_data_object(comment_do), 200
|
||||||
|
|
||||||
|
|
||||||
|
def find_parent_and_comment_or_raise(parent_id, comment_id):
|
||||||
|
parent = find_node_or_raise(parent_id,
|
||||||
|
'User %s tried to update comment with bad parent_id %s',
|
||||||
|
current_user.objectid,
|
||||||
|
parent_id)
|
||||||
|
comment = find_node_or_raise(comment_id,
|
||||||
|
'User %s tried to update comment with bad id %s',
|
||||||
|
current_user.objectid,
|
||||||
|
comment_id)
|
||||||
|
validate_comment_parent_relation(comment, parent)
|
||||||
|
return parent, comment
|
||||||
|
|
||||||
|
|
||||||
|
def validate_comment_parent_relation(comment, parent):
|
||||||
|
if comment['parent'] != parent['_id']:
|
||||||
|
log.warning('User %s tried to update comment with bad parent/comment pair. parent_id: %s comment_id: %s',
|
||||||
|
current_user.objectid,
|
||||||
|
parent['_id'],
|
||||||
|
comment['_id'])
|
||||||
|
raise wz_exceptions.BadRequest()
|
||||||
|
|
||||||
|
|
||||||
|
def get_comment(parent_id: bson.ObjectId, comment_id: bson.ObjectId) -> CommentDO:
|
||||||
|
nodes_coll = current_app.db('nodes')
|
||||||
|
mongo_comment = list(nodes_coll.aggregate([
|
||||||
|
{'$match': {'node_type': 'comment',
|
||||||
|
'_deleted': {'$ne': True},
|
||||||
|
'properties.status': 'published',
|
||||||
|
'parent': parent_id,
|
||||||
|
'_id': comment_id}},
|
||||||
|
{'$lookup': {"from": "users",
|
||||||
|
"localField": "user",
|
||||||
|
"foreignField": "_id",
|
||||||
|
"as": "user"}},
|
||||||
|
{'$unwind': {'path': "$user"}},
|
||||||
|
]))[0]
|
||||||
|
|
||||||
|
return to_comment_data_object(mongo_comment)
|
||||||
|
|
||||||
|
|
||||||
|
def to_comment_data_object(mongo_comment: dict) -> CommentDO:
|
||||||
|
def current_user_rating():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
for rating in mongo_comment['properties'].get('ratings', ()):
|
||||||
|
if str(rating['user']) != current_user.objectid:
|
||||||
|
continue
|
||||||
|
return rating['is_positive']
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_dict = mongo_comment['user']
|
||||||
|
user = UserDO(
|
||||||
|
id=str(mongo_comment['user']['_id']),
|
||||||
|
full_name=user_dict['full_name'],
|
||||||
|
gravatar=gravatar(user_dict['email']),
|
||||||
|
badges_html=user_dict.get('badges', {}).get('html', '')
|
||||||
|
)
|
||||||
|
html = _get_markdowned_html(mongo_comment['properties'], 'content')
|
||||||
|
html = shortcodes.render_commented(html, context=mongo_comment['properties'])
|
||||||
|
return CommentDO(
|
||||||
|
id=mongo_comment['_id'],
|
||||||
|
parent=mongo_comment['parent'],
|
||||||
|
project=mongo_comment['project'],
|
||||||
|
user=user,
|
||||||
|
msg_html=html,
|
||||||
|
msg_markdown=mongo_comment['properties']['content'],
|
||||||
|
current_user_rating=current_user_rating(),
|
||||||
|
created=mongo_comment['_created'],
|
||||||
|
updated=mongo_comment['_updated'],
|
||||||
|
etag=mongo_comment['_etag'],
|
||||||
|
properties=CommentPropertiesDO(
|
||||||
|
attachments=mongo_comment['properties'].get('attachments', {}),
|
||||||
|
rating_positive=mongo_comment['properties']['rating_positive'],
|
||||||
|
rating_negative=mongo_comment['properties']['rating_negative']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def post_node_comment_vote(parent_id: bson.ObjectId, comment_id: bson.ObjectId, vote: int):
|
||||||
|
normalized_vote = min(max(vote, -1), 1)
|
||||||
|
_, _ = find_parent_and_comment_or_raise(parent_id, comment_id)
|
||||||
|
|
||||||
|
actions = {
|
||||||
|
1: 'upvote',
|
||||||
|
0: 'revoke',
|
||||||
|
-1: 'downvote',
|
||||||
|
}
|
||||||
|
|
||||||
|
patch = dict(
|
||||||
|
op=actions[normalized_vote]
|
||||||
|
)
|
||||||
|
|
||||||
|
json_result = patch_comment(comment_id, patch)
|
||||||
|
if json_result.json['_status'] != 'OK':
|
||||||
|
raise wz_exceptions.InternalServerError('Failed to vote on comment')
|
||||||
|
|
||||||
|
comment_do = get_comment(parent_id, comment_id)
|
||||||
|
return jsonify_data_object(comment_do), 200
|
@@ -5,7 +5,7 @@ import logging
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
import werkzeug.exceptions as wz_exceptions
|
import werkzeug.exceptions as wz_exceptions
|
||||||
|
|
||||||
from pillar.api.utils import authorization, authentication, jsonify
|
from pillar.api.utils import authorization, authentication, jsonify, remove_private_keys
|
||||||
|
|
||||||
from . import register_patch_handler
|
from . import register_patch_handler
|
||||||
|
|
||||||
@@ -135,10 +135,7 @@ def edit_comment(user_id, node_id, patch):
|
|||||||
# we can pass this stuff to Eve's patch_internal; that way the validation &
|
# we can pass this stuff to Eve's patch_internal; that way the validation &
|
||||||
# authorisation system has enough info to work.
|
# authorisation system has enough info to work.
|
||||||
nodes_coll = current_app.data.driver.db['nodes']
|
nodes_coll = current_app.data.driver.db['nodes']
|
||||||
projection = {'user': 1,
|
node = nodes_coll.find_one(node_id)
|
||||||
'project': 1,
|
|
||||||
'node_type': 1}
|
|
||||||
node = nodes_coll.find_one(node_id, projection=projection)
|
|
||||||
if node is None:
|
if node is None:
|
||||||
log.warning('User %s wanted to patch non-existing node %s' % (user_id, node_id))
|
log.warning('User %s wanted to patch non-existing node %s' % (user_id, node_id))
|
||||||
raise wz_exceptions.NotFound('Node %s not found' % node_id)
|
raise wz_exceptions.NotFound('Node %s not found' % node_id)
|
||||||
@@ -146,12 +143,12 @@ def edit_comment(user_id, node_id, patch):
|
|||||||
if node['user'] != user_id and not authorization.user_has_role('admin'):
|
if node['user'] != user_id and not authorization.user_has_role('admin'):
|
||||||
raise wz_exceptions.Forbidden('You can only edit your own comments.')
|
raise wz_exceptions.Forbidden('You can only edit your own comments.')
|
||||||
|
|
||||||
# Use Eve to PATCH this node, as that also updates the etag.
|
node = remove_private_keys(node)
|
||||||
r, _, _, status = current_app.patch_internal('nodes',
|
node['properties']['content'] = patch['content']
|
||||||
{'properties.content': patch['content'],
|
node['properties']['attachments'] = patch.get('attachments', {})
|
||||||
'project': node['project'],
|
# Use Eve to PUT this node, as that also updates the etag and we want to replace attachments.
|
||||||
'user': node['user'],
|
r, _, _, status = current_app.put_internal('nodes',
|
||||||
'node_type': node['node_type']},
|
node,
|
||||||
concurrency_check=False,
|
concurrency_check=False,
|
||||||
_id=node_id)
|
_id=node_id)
|
||||||
if status != 200:
|
if status != 200:
|
||||||
|
@@ -7,7 +7,6 @@ from bson import ObjectId
|
|||||||
from werkzeug import exceptions as wz_exceptions
|
from werkzeug import exceptions as wz_exceptions
|
||||||
|
|
||||||
from pillar import current_app
|
from pillar import current_app
|
||||||
import pillar.markdown
|
|
||||||
from pillar.api.activities import activity_subscribe, activity_object_add
|
from pillar.api.activities import activity_subscribe, activity_object_add
|
||||||
from pillar.api.file_storage_backends.gcs import update_file_name
|
from pillar.api.file_storage_backends.gcs import update_file_name
|
||||||
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
|
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
|
||||||
@@ -70,6 +69,22 @@ def before_replacing_node(item, original):
|
|||||||
check_permissions('nodes', original, 'PUT')
|
check_permissions('nodes', original, 'PUT')
|
||||||
update_file_name(item)
|
update_file_name(item)
|
||||||
|
|
||||||
|
# XXX Dillo specific feature (for Graphicall)
|
||||||
|
if 'download' in original['properties']:
|
||||||
|
# Check if the file referenced in the download property was updated.
|
||||||
|
# If so, mark the old file as deleted. A cronjob will take care of
|
||||||
|
# removing the actual file based on the _delete status of file docs.
|
||||||
|
original_file_id = original['properties']['download']
|
||||||
|
new_file_id = item['properties']['download']
|
||||||
|
|
||||||
|
if original_file_id == new_file_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Mark the original file as _deleted
|
||||||
|
files = current_app.data.driver.db['files']
|
||||||
|
files.update_one({'_id': original_file_id}, {'$set': {'_deleted': True}})
|
||||||
|
log.info('Marking file %s as _deleted' % original_file_id)
|
||||||
|
|
||||||
|
|
||||||
def after_replacing_node(item, original):
|
def after_replacing_node(item, original):
|
||||||
"""Push an update to the Algolia index when a node item is updated. If the
|
"""Push an update to the Algolia index when a node item is updated. If the
|
||||||
@@ -123,38 +138,41 @@ def before_inserting_nodes(items):
|
|||||||
item.setdefault('user', current_user.user_id)
|
item.setdefault('user', current_user.user_id)
|
||||||
|
|
||||||
|
|
||||||
def after_inserting_nodes(items):
|
def get_comment_verb_and_context_object_id(comment):
|
||||||
for item in items:
|
|
||||||
# Skip subscriptions for first level items (since the context is not a
|
|
||||||
# node, but a project).
|
|
||||||
# TODO: support should be added for mixed context
|
|
||||||
if 'parent' not in item:
|
|
||||||
return
|
|
||||||
context_object_id = item['parent']
|
|
||||||
if item['node_type'] == 'comment':
|
|
||||||
nodes_collection = current_app.data.driver.db['nodes']
|
nodes_collection = current_app.data.driver.db['nodes']
|
||||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
verb = 'commented'
|
||||||
# Always subscribe to the parent node
|
parent = nodes_collection.find_one({'_id': comment['parent']})
|
||||||
activity_subscribe(item['user'], 'node', item['parent'])
|
context_object_id = comment['parent']
|
||||||
if parent['node_type'] == 'comment':
|
while parent['node_type'] == 'comment':
|
||||||
# If the parent is a comment, we provide its own parent as
|
# If the parent is a comment, we provide its own parent as
|
||||||
# context. We do this in order to point the user to an asset
|
# context. We do this in order to point the user to an asset
|
||||||
# or group when viewing the notification.
|
# or group when viewing the notification.
|
||||||
verb = 'replied'
|
verb = 'replied'
|
||||||
context_object_id = parent['parent']
|
context_object_id = parent['parent']
|
||||||
# Subscribe to the parent of the parent comment (post or group)
|
parent = nodes_collection.find_one({'_id': parent['parent']})
|
||||||
activity_subscribe(item['user'], 'node', parent['parent'])
|
return verb, context_object_id
|
||||||
else:
|
|
||||||
activity_subscribe(item['user'], 'node', item['_id'])
|
|
||||||
verb = 'commented'
|
|
||||||
elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
|
||||||
verb = 'posted'
|
|
||||||
activity_subscribe(item['user'], 'node', item['_id'])
|
|
||||||
else:
|
|
||||||
# Don't automatically create activities for non-Pillar node types,
|
|
||||||
# as we don't know what would be a suitable verb (among other things).
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
|
def after_inserting_nodes(items):
|
||||||
|
for item in items:
|
||||||
|
context_object_id = None
|
||||||
|
# TODO: support should be added for mixed context
|
||||||
|
if item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
||||||
|
activity_subscribe(item['user'], 'node', item['_id'])
|
||||||
|
verb = 'posted'
|
||||||
|
context_object_id = item.get('parent')
|
||||||
|
if item['node_type'] == 'comment':
|
||||||
|
# Always subscribe to the parent node
|
||||||
|
activity_subscribe(item['user'], 'node', item['parent'])
|
||||||
|
verb, context_object_id = get_comment_verb_and_context_object_id(item)
|
||||||
|
# Subscribe to the parent of the parent comment (post or group)
|
||||||
|
activity_subscribe(item['user'], 'node', context_object_id)
|
||||||
|
|
||||||
|
if context_object_id and item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
||||||
|
# * Skip activity for first level items (since the context is not a
|
||||||
|
# node, but a project).
|
||||||
|
# * Don't automatically create activities for non-Pillar node types,
|
||||||
|
# as we don't know what would be a suitable verb (among other things).
|
||||||
activity_object_add(
|
activity_object_add(
|
||||||
item['user'],
|
item['user'],
|
||||||
verb,
|
verb,
|
||||||
@@ -322,46 +340,6 @@ def textures_sort_files(nodes):
|
|||||||
texture_sort_files(node)
|
texture_sort_files(node)
|
||||||
|
|
||||||
|
|
||||||
def parse_markdown(node, original=None):
|
|
||||||
import copy
|
|
||||||
|
|
||||||
projects_collection = current_app.data.driver.db['projects']
|
|
||||||
project = projects_collection.find_one({'_id': node['project']}, {'node_types': 1})
|
|
||||||
# Query node type directly using the key
|
|
||||||
node_type = next(nt for nt in project['node_types']
|
|
||||||
if nt['name'] == node['node_type'])
|
|
||||||
|
|
||||||
# Create a copy to not overwrite the actual schema.
|
|
||||||
schema = copy.deepcopy(current_app.config['DOMAIN']['nodes']['schema'])
|
|
||||||
schema['properties'] = node_type['dyn_schema']
|
|
||||||
|
|
||||||
def find_markdown_fields(schema, node):
|
|
||||||
"""Find and process all makrdown validated fields."""
|
|
||||||
for k, v in schema.items():
|
|
||||||
if not isinstance(v, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if v.get('validator') == 'markdown':
|
|
||||||
# If there is a match with the validator: markdown pair, assign the sibling
|
|
||||||
# property (following the naming convention _<property>_html)
|
|
||||||
# the processed value.
|
|
||||||
if k in node:
|
|
||||||
html = pillar.markdown.markdown(node[k])
|
|
||||||
field_name = pillar.markdown.cache_field_name(k)
|
|
||||||
node[field_name] = html
|
|
||||||
if isinstance(node, dict) and k in node:
|
|
||||||
find_markdown_fields(v, node[k])
|
|
||||||
|
|
||||||
find_markdown_fields(schema, node)
|
|
||||||
|
|
||||||
return 'ok'
|
|
||||||
|
|
||||||
|
|
||||||
def parse_markdowns(items):
|
|
||||||
for item in items:
|
|
||||||
parse_markdown(item)
|
|
||||||
|
|
||||||
|
|
||||||
def short_link_info(short_code):
|
def short_link_info(short_code):
|
||||||
"""Returns the short link info in a dict."""
|
"""Returns the short link info in a dict."""
|
||||||
|
|
||||||
|
@@ -9,6 +9,7 @@ def setup_app(app, api_prefix):
|
|||||||
app.on_replace_projects += hooks.override_is_private_field
|
app.on_replace_projects += hooks.override_is_private_field
|
||||||
app.on_replace_projects += hooks.before_edit_check_permissions
|
app.on_replace_projects += hooks.before_edit_check_permissions
|
||||||
app.on_replace_projects += hooks.protect_sensitive_fields
|
app.on_replace_projects += hooks.protect_sensitive_fields
|
||||||
|
app.on_replace_projects += hooks.parse_markdown
|
||||||
|
|
||||||
app.on_update_projects += hooks.override_is_private_field
|
app.on_update_projects += hooks.override_is_private_field
|
||||||
app.on_update_projects += hooks.before_edit_check_permissions
|
app.on_update_projects += hooks.before_edit_check_permissions
|
||||||
@@ -19,6 +20,8 @@ def setup_app(app, api_prefix):
|
|||||||
|
|
||||||
app.on_insert_projects += hooks.before_inserting_override_is_private_field
|
app.on_insert_projects += hooks.before_inserting_override_is_private_field
|
||||||
app.on_insert_projects += hooks.before_inserting_projects
|
app.on_insert_projects += hooks.before_inserting_projects
|
||||||
|
app.on_insert_projects += hooks.parse_markdowns
|
||||||
|
|
||||||
app.on_inserted_projects += hooks.after_inserting_projects
|
app.on_inserted_projects += hooks.after_inserting_projects
|
||||||
|
|
||||||
app.on_fetched_item_projects += hooks.before_returning_project_permissions
|
app.on_fetched_item_projects += hooks.before_returning_project_permissions
|
||||||
|
@@ -3,6 +3,7 @@ import logging
|
|||||||
|
|
||||||
from flask import request, abort
|
from flask import request, abort
|
||||||
|
|
||||||
|
import pillar
|
||||||
from pillar import current_app
|
from pillar import current_app
|
||||||
from pillar.api.node_types.asset import node_type_asset
|
from pillar.api.node_types.asset import node_type_asset
|
||||||
from pillar.api.node_types.comment import node_type_comment
|
from pillar.api.node_types.comment import node_type_comment
|
||||||
@@ -246,3 +247,37 @@ def project_node_type_has_method(response):
|
|||||||
def projects_node_type_has_method(response):
|
def projects_node_type_has_method(response):
|
||||||
for project in response['_items']:
|
for project in response['_items']:
|
||||||
project_node_type_has_method(project)
|
project_node_type_has_method(project)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_markdown(project, original=None):
|
||||||
|
schema = current_app.config['DOMAIN']['projects']['schema']
|
||||||
|
|
||||||
|
def find_markdown_fields(schema, project):
|
||||||
|
"""Find and process all Markdown coerced fields.
|
||||||
|
|
||||||
|
- look for fields with a 'coerce': 'markdown' property
|
||||||
|
- parse the name of the field and generate the sibling field name (_<field_name>_html -> <field_name>)
|
||||||
|
- parse the content of the <field_name> field as markdown and save it in _<field_name>_html
|
||||||
|
"""
|
||||||
|
for field_name, field_value in schema.items():
|
||||||
|
if not isinstance(field_value, dict):
|
||||||
|
continue
|
||||||
|
if field_value.get('coerce') != 'markdown':
|
||||||
|
continue
|
||||||
|
if field_name not in project:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Construct markdown source field name (strip the leading '_' and the trailing '_html')
|
||||||
|
source_field_name = field_name[1:-5]
|
||||||
|
html = pillar.markdown.markdown(project[source_field_name])
|
||||||
|
project[field_name] = html
|
||||||
|
|
||||||
|
if isinstance(project, dict) and field_name in project:
|
||||||
|
find_markdown_fields(field_value, project[field_name])
|
||||||
|
|
||||||
|
find_markdown_fields(schema, project)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_markdowns(items):
|
||||||
|
for item in items:
|
||||||
|
parse_markdown(item)
|
||||||
|
@@ -7,6 +7,7 @@ from werkzeug.exceptions import abort
|
|||||||
|
|
||||||
from pillar import current_app
|
from pillar import current_app
|
||||||
from pillar.auth import current_user
|
from pillar.auth import current_user
|
||||||
|
from pillar.api import file_storage_backends
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -155,6 +156,18 @@ def project_id(project_url: str) -> ObjectId:
|
|||||||
return proj['_id']
|
return proj['_id']
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_url(project_id: ObjectId) -> str:
|
||||||
|
"""Returns the project URL, or raises a ValueError when not found."""
|
||||||
|
|
||||||
|
proj_coll = current_app.db('projects')
|
||||||
|
proj = proj_coll.find_one({'_id': project_id, '_deleted': {'$ne': True}},
|
||||||
|
projection={'url': True})
|
||||||
|
|
||||||
|
if not proj:
|
||||||
|
raise ValueError(f'project with id={project_id} not found')
|
||||||
|
return proj['url']
|
||||||
|
|
||||||
|
|
||||||
def get_project(project_url: str) -> dict:
|
def get_project(project_url: str) -> dict:
|
||||||
"""Find a project in the database, raises ValueError if not found.
|
"""Find a project in the database, raises ValueError if not found.
|
||||||
|
|
||||||
@@ -187,3 +200,14 @@ def put_project(project: dict):
|
|||||||
if status_code != 200:
|
if status_code != 200:
|
||||||
raise ValueError(f"Can't update project {pid}, "
|
raise ValueError(f"Can't update project {pid}, "
|
||||||
f"status {status_code} with issues: {result}")
|
f"status {status_code} with issues: {result}")
|
||||||
|
|
||||||
|
|
||||||
|
def storage(project_id: ObjectId) -> file_storage_backends.Bucket:
|
||||||
|
"""Return the storage bucket for this project.
|
||||||
|
|
||||||
|
For now this returns a bucket in the default storage backend, since
|
||||||
|
individual projects do not have a 'storage backend' setting (this is
|
||||||
|
set per file, not per project).
|
||||||
|
"""
|
||||||
|
|
||||||
|
return file_storage_backends.default_storage_backend(str(project_id))
|
||||||
|
@@ -49,13 +49,12 @@ def search_nodes():
|
|||||||
result = queries.do_node_search(searchword, terms, page_idx, project_id)
|
result = queries.do_node_search(searchword, terms, page_idx, project_id)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
@blueprint_search.route('/multisearch', methods=['GET'])
|
@blueprint_search.route('/multisearch', methods=['POST'])
|
||||||
def multi_search_nodes():
|
def multi_search_nodes():
|
||||||
import json
|
|
||||||
if len(request.args) != 1:
|
if len(request.args) != 1:
|
||||||
log.info(f'Expected 1 argument, received {len(request.args)}')
|
log.info(f'Expected 1 argument, received {len(request.args)}')
|
||||||
|
|
||||||
json_obj = json.loads([a for a in request.args][0])
|
json_obj = request.json
|
||||||
q = []
|
q = []
|
||||||
for row in json_obj:
|
for row in json_obj:
|
||||||
q.append({
|
q.append({
|
||||||
|
@@ -46,6 +46,7 @@ class SearchHelper:
|
|||||||
created = {'_created': {'$gt': continue_from}}
|
created = {'_created': {'$gt': continue_from}}
|
||||||
return {'_deleted': {'$ne': True},
|
return {'_deleted': {'$ne': True},
|
||||||
'node_type': {'$in': ['asset', 'post']},
|
'node_type': {'$in': ['asset', 'post']},
|
||||||
|
'properties.status': {'$eq': 'published'},
|
||||||
'project': {'$in': self._project_ids},
|
'project': {'$in': self._project_ids},
|
||||||
**created,
|
**created,
|
||||||
}
|
}
|
||||||
|
@@ -44,10 +44,16 @@ def remove_private_keys(document):
|
|||||||
"""Removes any key that starts with an underscore, returns result as new
|
"""Removes any key that starts with an underscore, returns result as new
|
||||||
dictionary.
|
dictionary.
|
||||||
"""
|
"""
|
||||||
doc_copy = copy.deepcopy(document)
|
def do_remove(doc):
|
||||||
for key in list(doc_copy.keys()):
|
for key in list(doc.keys()):
|
||||||
if key.startswith('_'):
|
if key.startswith('_'):
|
||||||
del doc_copy[key]
|
del doc[key]
|
||||||
|
elif isinstance(doc[key], dict):
|
||||||
|
doc[key] = do_remove(doc[key])
|
||||||
|
return doc
|
||||||
|
|
||||||
|
doc_copy = copy.deepcopy(document)
|
||||||
|
do_remove(doc_copy)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
del doc_copy['allowed_methods']
|
del doc_copy['allowed_methods']
|
||||||
@@ -57,7 +63,7 @@ def remove_private_keys(document):
|
|||||||
return doc_copy
|
return doc_copy
|
||||||
|
|
||||||
|
|
||||||
def pretty_duration(seconds):
|
def pretty_duration(seconds: typing.Union[None, int, float]):
|
||||||
if seconds is None:
|
if seconds is None:
|
||||||
return ''
|
return ''
|
||||||
seconds = round(seconds)
|
seconds = round(seconds)
|
||||||
@@ -69,6 +75,27 @@ def pretty_duration(seconds):
|
|||||||
return f'{minutes:02}:{seconds:02}'
|
return f'{minutes:02}:{seconds:02}'
|
||||||
|
|
||||||
|
|
||||||
|
def pretty_duration_fractional(seconds: typing.Union[None, int, float]):
|
||||||
|
if seconds is None:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# Remove fraction of seconds from the seconds so that the rest is done as integers.
|
||||||
|
seconds, fracs = divmod(seconds, 1)
|
||||||
|
hours, seconds = divmod(int(seconds), 3600)
|
||||||
|
minutes, seconds = divmod(seconds, 60)
|
||||||
|
msec = int(round(fracs * 1000))
|
||||||
|
|
||||||
|
if msec == 0:
|
||||||
|
msec_str = ''
|
||||||
|
else:
|
||||||
|
msec_str = f'.{msec:03}'
|
||||||
|
|
||||||
|
if hours > 0:
|
||||||
|
return f'{hours:02}:{minutes:02}:{seconds:02}{msec_str}'
|
||||||
|
else:
|
||||||
|
return f'{minutes:02}:{seconds:02}{msec_str}'
|
||||||
|
|
||||||
|
|
||||||
class PillarJSONEncoder(json.JSONEncoder):
|
class PillarJSONEncoder(json.JSONEncoder):
|
||||||
"""JSON encoder with support for Pillar resources."""
|
"""JSON encoder with support for Pillar resources."""
|
||||||
|
|
||||||
@@ -196,7 +223,8 @@ def doc_diff(doc1, doc2, *, falsey_is_equal=True, superkey: str = None):
|
|||||||
function won't report differences between DoesNotExist, False, '', and 0.
|
function won't report differences between DoesNotExist, False, '', and 0.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
private_keys = {'_id', '_etag', '_deleted', '_updated', '_created'}
|
def is_private(key):
|
||||||
|
return str(key).startswith('_')
|
||||||
|
|
||||||
def combine_key(some_key):
|
def combine_key(some_key):
|
||||||
"""Combine this key with the superkey.
|
"""Combine this key with the superkey.
|
||||||
@@ -217,7 +245,7 @@ def doc_diff(doc1, doc2, *, falsey_is_equal=True, superkey: str = None):
|
|||||||
|
|
||||||
if isinstance(doc1, dict) and isinstance(doc2, dict):
|
if isinstance(doc1, dict) and isinstance(doc2, dict):
|
||||||
for key in set(doc1.keys()).union(set(doc2.keys())):
|
for key in set(doc1.keys()).union(set(doc2.keys())):
|
||||||
if key in private_keys:
|
if is_private(key):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
val1 = doc1.get(key, DoesNotExist)
|
val1 = doc1.get(key, DoesNotExist)
|
||||||
|
@@ -331,8 +331,9 @@ def require_login(*, require_roles=set(),
|
|||||||
|
|
||||||
def render_error() -> Response:
|
def render_error() -> Response:
|
||||||
if error_view is None:
|
if error_view is None:
|
||||||
abort(403)
|
resp = Forbidden().get_response()
|
||||||
resp: Response = error_view()
|
else:
|
||||||
|
resp = error_view()
|
||||||
resp.status_code = 403
|
resp.status_code = 403
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
@@ -9,12 +9,8 @@ string = functools.partial(attr.ib, validator=attr.validators.instance_of(str))
|
|||||||
|
|
||||||
|
|
||||||
def log(name):
|
def log(name):
|
||||||
"""Returns a logger attr.ib
|
"""Returns a logger
|
||||||
|
|
||||||
:param name: name to pass to logging.getLogger()
|
:param name: name to pass to logging.getLogger()
|
||||||
:rtype: attr.ib
|
|
||||||
"""
|
"""
|
||||||
return attr.ib(default=logging.getLogger(name),
|
return logging.getLogger(name)
|
||||||
repr=False,
|
|
||||||
hash=False,
|
|
||||||
cmp=False)
|
|
||||||
|
48
pillar/auth/cors.py
Normal file
48
pillar/auth/cors.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Support for adding CORS headers to responses."""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
|
||||||
|
import flask
|
||||||
|
import werkzeug.wrappers as wz_wrappers
|
||||||
|
import werkzeug.exceptions as wz_exceptions
|
||||||
|
|
||||||
|
|
||||||
|
def allow(*, allow_credentials=False):
|
||||||
|
"""Flask endpoint decorator, adds CORS headers to the response.
|
||||||
|
|
||||||
|
If the request has a non-empty 'Origin' header, the response header
|
||||||
|
'Access-Control-Allow-Origin' is set to the value of that request header,
|
||||||
|
and some other CORS headers are set.
|
||||||
|
"""
|
||||||
|
def decorator(wrapped):
|
||||||
|
@functools.wraps(wrapped)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
request_origin = flask.request.headers.get('Origin')
|
||||||
|
if not request_origin:
|
||||||
|
# No CORS headers requested, so don't bother touching the response.
|
||||||
|
return wrapped(*args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = wrapped(*args, **kwargs)
|
||||||
|
except wz_exceptions.HTTPException as ex:
|
||||||
|
response = ex.get_response()
|
||||||
|
else:
|
||||||
|
if isinstance(response, tuple):
|
||||||
|
response = flask.make_response(*response)
|
||||||
|
elif isinstance(response, str):
|
||||||
|
response = flask.make_response(response)
|
||||||
|
elif isinstance(response, wz_wrappers.Response):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise TypeError(f'unknown response type {type(response)}')
|
||||||
|
|
||||||
|
assert isinstance(response, wz_wrappers.Response)
|
||||||
|
|
||||||
|
response.headers.set('Access-Control-Allow-Origin', request_origin)
|
||||||
|
response.headers.set('Access-Control-Allow-Headers', 'x-requested-with')
|
||||||
|
if allow_credentials:
|
||||||
|
response.headers.set('Access-Control-Allow-Credentials', 'true')
|
||||||
|
|
||||||
|
return response
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
@@ -1,7 +1,9 @@
|
|||||||
|
import collections
|
||||||
import copy
|
import copy
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath, Path
|
||||||
import re
|
import re
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ from flask_script import Manager
|
|||||||
import pymongo
|
import pymongo
|
||||||
|
|
||||||
from pillar import current_app
|
from pillar import current_app
|
||||||
|
import pillar.api.utils
|
||||||
|
|
||||||
# Collections to skip when finding file references (during orphan file detection).
|
# Collections to skip when finding file references (during orphan file detection).
|
||||||
# This collection can be added to from PillarExtension.setup_app().
|
# This collection can be added to from PillarExtension.setup_app().
|
||||||
@@ -736,113 +739,6 @@ def iter_markdown(proj_node_types: dict, some_node: dict, callback: typing.Calla
|
|||||||
doc[key] = new_value
|
doc[key] = new_value
|
||||||
|
|
||||||
|
|
||||||
@manager_maintenance.option('-p', '--project', dest='proj_url', nargs='?',
|
|
||||||
help='Project URL')
|
|
||||||
@manager_maintenance.option('-a', '--all', dest='all_projects', action='store_true', default=False,
|
|
||||||
help='Replace on all projects.')
|
|
||||||
@manager_maintenance.option('-g', '--go', dest='go', action='store_true', default=False,
|
|
||||||
help='Actually perform the changes (otherwise just show as dry-run).')
|
|
||||||
def upgrade_attachment_usage(proj_url=None, all_projects=False, go=False):
|
|
||||||
"""Replaces '@[slug]' with '{attachment slug}'.
|
|
||||||
|
|
||||||
Also moves links from the attachment dict to the attachment shortcode.
|
|
||||||
"""
|
|
||||||
if bool(proj_url) == all_projects:
|
|
||||||
log.error('Use either --project or --all.')
|
|
||||||
return 1
|
|
||||||
|
|
||||||
import html
|
|
||||||
from pillar.api.projects.utils import node_type_dict
|
|
||||||
from pillar.api.utils import remove_private_keys
|
|
||||||
from pillar.api.utils.authentication import force_cli_user
|
|
||||||
|
|
||||||
force_cli_user()
|
|
||||||
|
|
||||||
nodes_coll = current_app.db('nodes')
|
|
||||||
total_nodes = 0
|
|
||||||
failed_node_ids = set()
|
|
||||||
|
|
||||||
# Use a mixture of the old slug RE that still allowes spaces in the slug
|
|
||||||
# name and the new RE that allows dashes.
|
|
||||||
old_slug_re = re.compile(r'@\[([a-zA-Z0-9_\- ]+)\]')
|
|
||||||
for proj in _db_projects(proj_url, all_projects, go=go):
|
|
||||||
proj_id = proj['_id']
|
|
||||||
proj_url = proj.get('url', '-no-url-')
|
|
||||||
nodes = nodes_coll.find({
|
|
||||||
'_deleted': {'$ne': True},
|
|
||||||
'project': proj_id,
|
|
||||||
'properties.attachments': {'$exists': True},
|
|
||||||
})
|
|
||||||
node_count = nodes.count()
|
|
||||||
if node_count == 0:
|
|
||||||
log.debug('Skipping project %s (%s)', proj_url, proj_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
proj_node_types = node_type_dict(proj)
|
|
||||||
|
|
||||||
for node in nodes:
|
|
||||||
attachments = node['properties']['attachments']
|
|
||||||
replaced = False
|
|
||||||
|
|
||||||
# Inner functions because of access to the node's attachments.
|
|
||||||
def replace(match):
|
|
||||||
nonlocal replaced
|
|
||||||
slug = match.group(1)
|
|
||||||
log.debug(' - OLD STYLE attachment slug %r', slug)
|
|
||||||
try:
|
|
||||||
att = attachments[slug]
|
|
||||||
except KeyError:
|
|
||||||
log.info("Attachment %r not found for node %s", slug, node['_id'])
|
|
||||||
link = ''
|
|
||||||
else:
|
|
||||||
link = att.get('link', '')
|
|
||||||
if link == 'self':
|
|
||||||
link = " link='self'"
|
|
||||||
elif link == 'custom':
|
|
||||||
url = att.get('link_custom')
|
|
||||||
if url:
|
|
||||||
link = " link='%s'" % html.escape(url)
|
|
||||||
replaced = True
|
|
||||||
return '{attachment %r%s}' % (slug.replace(' ', '-'), link)
|
|
||||||
|
|
||||||
def update_markdown(value: str) -> str:
|
|
||||||
return old_slug_re.sub(replace, value)
|
|
||||||
|
|
||||||
iter_markdown(proj_node_types, node, update_markdown)
|
|
||||||
|
|
||||||
# Remove no longer used properties from attachments
|
|
||||||
new_attachments = {}
|
|
||||||
for slug, attachment in attachments.items():
|
|
||||||
replaced |= 'link' in attachment # link_custom implies link
|
|
||||||
attachment.pop('link', None)
|
|
||||||
attachment.pop('link_custom', None)
|
|
||||||
new_attachments[slug.replace(' ', '-')] = attachment
|
|
||||||
node['properties']['attachments'] = new_attachments
|
|
||||||
|
|
||||||
if replaced:
|
|
||||||
total_nodes += 1
|
|
||||||
else:
|
|
||||||
# Nothing got replaced,
|
|
||||||
continue
|
|
||||||
|
|
||||||
if go:
|
|
||||||
# Use Eve to PUT, so we have schema checking.
|
|
||||||
db_node = remove_private_keys(node)
|
|
||||||
r, _, _, status = current_app.put_internal('nodes', db_node, _id=node['_id'])
|
|
||||||
if status != 200:
|
|
||||||
log.error('Error %i storing altered node %s %s', status, node['_id'], r)
|
|
||||||
failed_node_ids.add(node['_id'])
|
|
||||||
# raise SystemExit('Error storing node; see log.')
|
|
||||||
log.debug('Updated node %s: %s', node['_id'], r)
|
|
||||||
|
|
||||||
log.info('Project %s (%s) has %d nodes with attachments',
|
|
||||||
proj_url, proj_id, node_count)
|
|
||||||
log.info('%s %d nodes', 'Updated' if go else 'Would update', total_nodes)
|
|
||||||
if failed_node_ids:
|
|
||||||
log.warning('Failed to update %d of %d nodes: %s', len(failed_node_ids), total_nodes,
|
|
||||||
', '.join(str(nid) for nid in failed_node_ids))
|
|
||||||
|
|
||||||
|
|
||||||
def _db_projects(proj_url: str, all_projects: bool, project_id='', *, go: bool) \
|
def _db_projects(proj_url: str, all_projects: bool, project_id='', *, go: bool) \
|
||||||
-> typing.Iterable[dict]:
|
-> typing.Iterable[dict]:
|
||||||
"""Yields a subset of the projects in the database.
|
"""Yields a subset of the projects in the database.
|
||||||
@@ -882,25 +778,12 @@ def _db_projects(proj_url: str, all_projects: bool, project_id='', *, go: bool)
|
|||||||
log.info('Command took %s', duration)
|
log.info('Command took %s', duration)
|
||||||
|
|
||||||
|
|
||||||
def _find_orphan_files() -> typing.Set[bson.ObjectId]:
|
def find_object_ids(something: typing.Any) -> typing.Iterable[bson.ObjectId]:
|
||||||
"""Finds all non-referenced files for the given project.
|
"""Generator, yields all ObjectIDs referenced by the given object.
|
||||||
|
|
||||||
Returns an iterable of all orphan file IDs.
|
Assumes 'something' comes from a MongoDB. This function wasn't made for
|
||||||
|
generic Python objects.
|
||||||
"""
|
"""
|
||||||
log.debug('Finding orphan files')
|
|
||||||
|
|
||||||
# Get all file IDs that belong to this project.
|
|
||||||
files_coll = current_app.db('files')
|
|
||||||
cursor = files_coll.find({'_deleted': {'$ne': True}}, projection={'_id': 1})
|
|
||||||
file_ids = {doc['_id'] for doc in cursor}
|
|
||||||
if not file_ids:
|
|
||||||
log.debug('No files found')
|
|
||||||
return set()
|
|
||||||
|
|
||||||
total_file_count = len(file_ids)
|
|
||||||
log.debug('Found %d files in total', total_file_count)
|
|
||||||
|
|
||||||
def find_object_ids(something: typing.Any) -> typing.Iterable[bson.ObjectId]:
|
|
||||||
if isinstance(something, bson.ObjectId):
|
if isinstance(something, bson.ObjectId):
|
||||||
yield something
|
yield something
|
||||||
elif isinstance(something, str) and len(something) == 24:
|
elif isinstance(something, str) and len(something) == 24:
|
||||||
@@ -913,9 +796,30 @@ def _find_orphan_files() -> typing.Set[bson.ObjectId]:
|
|||||||
for item in something:
|
for item in something:
|
||||||
yield from find_object_ids(item)
|
yield from find_object_ids(item)
|
||||||
elif isinstance(something, dict):
|
elif isinstance(something, dict):
|
||||||
|
for item in something.keys():
|
||||||
|
yield from find_object_ids(item)
|
||||||
for item in something.values():
|
for item in something.values():
|
||||||
yield from find_object_ids(item)
|
yield from find_object_ids(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_orphan_files() -> typing.Set[bson.ObjectId]:
|
||||||
|
"""Finds all non-referenced files.
|
||||||
|
|
||||||
|
Returns an iterable of all orphan file IDs.
|
||||||
|
"""
|
||||||
|
log.debug('Finding orphan files')
|
||||||
|
|
||||||
|
# Get all file IDs and make a set; we'll remove any referenced object ID later.
|
||||||
|
files_coll = current_app.db('files')
|
||||||
|
cursor = files_coll.find({'_deleted': {'$ne': True}}, projection={'_id': 1})
|
||||||
|
file_ids = {doc['_id'] for doc in cursor}
|
||||||
|
if not file_ids:
|
||||||
|
log.debug('No files found')
|
||||||
|
return set()
|
||||||
|
|
||||||
|
total_file_count = len(file_ids)
|
||||||
|
log.debug('Found %d files in total', total_file_count)
|
||||||
|
|
||||||
# Find all references by iterating through the project itself and every document that has a
|
# Find all references by iterating through the project itself and every document that has a
|
||||||
# 'project' key set to this ObjectId.
|
# 'project' key set to this ObjectId.
|
||||||
db = current_app.db()
|
db = current_app.db()
|
||||||
@@ -945,7 +849,6 @@ def find_orphan_files():
|
|||||||
This is a heavy operation that inspects *everything* in MongoDB. Use with care.
|
This is a heavy operation that inspects *everything* in MongoDB. Use with care.
|
||||||
"""
|
"""
|
||||||
from jinja2.filters import do_filesizeformat
|
from jinja2.filters import do_filesizeformat
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
output_fpath = Path(current_app.config['STORAGE_DIR']) / 'orphan-files.txt'
|
output_fpath = Path(current_app.config['STORAGE_DIR']) / 'orphan-files.txt'
|
||||||
if output_fpath.exists():
|
if output_fpath.exists():
|
||||||
@@ -991,7 +894,6 @@ def delete_orphan_files():
|
|||||||
Use 'find_orphan_files' first to generate orphan-files.txt.
|
Use 'find_orphan_files' first to generate orphan-files.txt.
|
||||||
"""
|
"""
|
||||||
import pymongo.results
|
import pymongo.results
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
output_fpath = Path(current_app.config['STORAGE_DIR']) / 'orphan-files.txt'
|
output_fpath = Path(current_app.config['STORAGE_DIR']) / 'orphan-files.txt'
|
||||||
with output_fpath.open('r', encoding='ascii') as infile:
|
with output_fpath.open('r', encoding='ascii') as infile:
|
||||||
@@ -1030,7 +932,6 @@ def find_video_files_without_duration():
|
|||||||
|
|
||||||
This is a heavy operation. Use with care.
|
This is a heavy operation. Use with care.
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
output_fpath = Path(current_app.config['STORAGE_DIR']) / 'video_files_without_duration.txt'
|
output_fpath = Path(current_app.config['STORAGE_DIR']) / 'video_files_without_duration.txt'
|
||||||
if output_fpath.exists():
|
if output_fpath.exists():
|
||||||
@@ -1062,13 +963,13 @@ def find_video_files_without_duration():
|
|||||||
with output_fpath.open('w', encoding='ascii') as outfile:
|
with output_fpath.open('w', encoding='ascii') as outfile:
|
||||||
outfile.write('\n'.join(sorted(file_ids)))
|
outfile.write('\n'.join(sorted(file_ids)))
|
||||||
|
|
||||||
|
|
||||||
@manager_maintenance.command
|
@manager_maintenance.command
|
||||||
def find_video_nodes_without_duration():
|
def find_video_nodes_without_duration():
|
||||||
"""Finds video nodes without any duration
|
"""Finds video nodes without any duration
|
||||||
|
|
||||||
This is a heavy operation. Use with care.
|
This is a heavy operation. Use with care.
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
output_fpath = Path(current_app.config['STORAGE_DIR']) / 'video_nodes_without_duration.txt'
|
output_fpath = Path(current_app.config['STORAGE_DIR']) / 'video_nodes_without_duration.txt'
|
||||||
if output_fpath.exists():
|
if output_fpath.exists():
|
||||||
@@ -1140,7 +1041,8 @@ def reconcile_node_video_duration(nodes_to_update=None, all_nodes=False, go=Fals
|
|||||||
{'$unwind': '$_files.variations'},
|
{'$unwind': '$_files.variations'},
|
||||||
{'$match': {'_files.variations.duration': {'$gt': 0}}},
|
{'$match': {'_files.variations.duration': {'$gt': 0}}},
|
||||||
{'$addFields': {
|
{'$addFields': {
|
||||||
'need_update': {'$ne': ['$_files.variations.duration', '$properties.duration_seconds']}
|
'need_update': {
|
||||||
|
'$ne': ['$_files.variations.duration', '$properties.duration_seconds']}
|
||||||
}},
|
}},
|
||||||
{'$match': {'need_update': True}},
|
{'$match': {'need_update': True}},
|
||||||
{'$project': {
|
{'$project': {
|
||||||
@@ -1175,3 +1077,259 @@ def reconcile_node_video_duration(nodes_to_update=None, all_nodes=False, go=Fals
|
|||||||
duration = end_timestamp - start_timestamp
|
duration = end_timestamp - start_timestamp
|
||||||
log.info('Operation took %s', duration)
|
log.info('Operation took %s', duration)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
@manager_maintenance.option('-g', '--go', dest='go', action='store_true', default=False,
|
||||||
|
help='Actually perform the changes (otherwise just show as dry-run).')
|
||||||
|
def delete_projectless_files(go=False):
|
||||||
|
"""Soft-deletes file documents of projects that have been deleted.
|
||||||
|
|
||||||
|
WARNING: this also soft-deletes file documents that do not have a project
|
||||||
|
property at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
start_timestamp = datetime.datetime.now()
|
||||||
|
|
||||||
|
files_coll = current_app.db('files')
|
||||||
|
aggr = files_coll.aggregate([
|
||||||
|
{'$match': {'_deleted': {'$ne': True}}},
|
||||||
|
{'$lookup': {
|
||||||
|
'from': 'projects',
|
||||||
|
'localField': 'project',
|
||||||
|
'foreignField': '_id',
|
||||||
|
'as': '_project'
|
||||||
|
}},
|
||||||
|
{'$match': {'$or': [
|
||||||
|
{'_project': []},
|
||||||
|
{'_project._deleted': True},
|
||||||
|
]}},
|
||||||
|
{'$project': {'_id': True}},
|
||||||
|
])
|
||||||
|
|
||||||
|
files_to_delete: typing.List[ObjectId] = [doc['_id'] for doc in aggr]
|
||||||
|
orphan_count = len(files_to_delete)
|
||||||
|
log.info('Total number of files to soft-delete: %d', orphan_count)
|
||||||
|
|
||||||
|
total_count = files_coll.count_documents({'_deleted': {'$ne': True}})
|
||||||
|
log.info('Total nr of orphan files: %d', orphan_count)
|
||||||
|
log.info('Total nr of files : %d', total_count)
|
||||||
|
log.info('Orphan percentage : %d%%', 100 * orphan_count / total_count)
|
||||||
|
|
||||||
|
if go:
|
||||||
|
log.info('Soft-deleting all %d projectless files', orphan_count)
|
||||||
|
now = pillar.api.utils.utcnow()
|
||||||
|
etag = pillar.api.utils.random_etag()
|
||||||
|
result = files_coll.update_many(
|
||||||
|
{'_id': {'$in': files_to_delete}},
|
||||||
|
{'$set': {
|
||||||
|
'_deleted': True,
|
||||||
|
'_updated': now,
|
||||||
|
'_etag': etag,
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
log.info('Matched count: %d', result.matched_count)
|
||||||
|
log.info('Modified count: %d', result.modified_count)
|
||||||
|
|
||||||
|
end_timestamp = datetime.datetime.now()
|
||||||
|
duration = end_timestamp - start_timestamp
|
||||||
|
|
||||||
|
if go:
|
||||||
|
verb = 'Soft-deleting'
|
||||||
|
else:
|
||||||
|
verb = 'Finding'
|
||||||
|
log.info('%s orphans took %s', verb, duration)
|
||||||
|
|
||||||
|
|
||||||
|
@manager_maintenance.command
|
||||||
|
def find_projects_for_files():
|
||||||
|
"""For file documents without project, tries to find in which project files are used.
|
||||||
|
|
||||||
|
This is a heavy operation that inspects *everything* in MongoDB. Use with care.
|
||||||
|
"""
|
||||||
|
|
||||||
|
output_fpath = Path(current_app.config['STORAGE_DIR']) / 'files-without-project.json'
|
||||||
|
if output_fpath.exists():
|
||||||
|
log.error('Output filename %s already exists, remove it first.', output_fpath)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
start_timestamp = datetime.datetime.now()
|
||||||
|
|
||||||
|
log.info('Finding files to fix...')
|
||||||
|
files_coll = current_app.db('files')
|
||||||
|
query = {'project': {'$exists': False},
|
||||||
|
'_deleted': {'$ne': True}}
|
||||||
|
|
||||||
|
files_to_fix = {file_doc['_id']: None for file_doc in files_coll.find(query)}
|
||||||
|
if not files_to_fix:
|
||||||
|
log.info('No files without projects found, congratulations.')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Find all references by iterating through every node and project, and
|
||||||
|
# hoping that they reference the file.
|
||||||
|
projects_coll = current_app.db('projects')
|
||||||
|
existing_projects: typing.MutableSet[ObjectId] = set()
|
||||||
|
for doc in projects_coll.find():
|
||||||
|
project_id = doc['_id']
|
||||||
|
existing_projects.add(project_id)
|
||||||
|
|
||||||
|
for obj_id in find_object_ids(doc):
|
||||||
|
if obj_id not in files_to_fix:
|
||||||
|
continue
|
||||||
|
|
||||||
|
files_to_fix[obj_id] = project_id
|
||||||
|
|
||||||
|
nodes_coll = current_app.db('nodes')
|
||||||
|
for doc in nodes_coll.find():
|
||||||
|
project_id = doc.get('project')
|
||||||
|
if not project_id:
|
||||||
|
log.warning('Skipping node %s, as it is not part of any project', doc['_id'])
|
||||||
|
continue
|
||||||
|
if project_id not in existing_projects:
|
||||||
|
log.warning('Skipping node %s, as its project %s does not exist',
|
||||||
|
doc['_id'], project_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for obj_id in find_object_ids(doc):
|
||||||
|
if obj_id not in files_to_fix:
|
||||||
|
continue
|
||||||
|
|
||||||
|
files_to_fix[obj_id] = project_id
|
||||||
|
|
||||||
|
orphans = {oid for oid, project_id in files_to_fix.items()
|
||||||
|
if project_id is None}
|
||||||
|
fixable = {str(oid): str(project_id)
|
||||||
|
for oid, project_id in files_to_fix.items()
|
||||||
|
if project_id is not None}
|
||||||
|
|
||||||
|
log.info('Total nr of orphan files : %d', len(orphans))
|
||||||
|
log.info('Total nr of fixable files: %d', len(fixable))
|
||||||
|
|
||||||
|
projects = set(fixable.values())
|
||||||
|
log.info('Fixable project count : %d', len(projects))
|
||||||
|
for project_id in projects:
|
||||||
|
project = projects_coll.find_one(ObjectId(project_id))
|
||||||
|
log.info(' - %40s /p/%-20s created on %s, ',
|
||||||
|
project['name'], project['url'], project['_created'])
|
||||||
|
|
||||||
|
end_timestamp = datetime.datetime.now()
|
||||||
|
duration = end_timestamp - start_timestamp
|
||||||
|
log.info('Finding projects took %s', duration)
|
||||||
|
|
||||||
|
log.info('Writing {file_id: project_id} mapping to %s', output_fpath)
|
||||||
|
with output_fpath.open('w', encoding='ascii') as outfile:
|
||||||
|
json.dump(fixable, outfile, indent=4, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
@manager_maintenance.option('filepath', type=Path,
|
||||||
|
help='JSON file produced by find_projects_for_files')
|
||||||
|
@manager_maintenance.option('-g', '--go', dest='go', action='store_true', default=False,
|
||||||
|
help='Actually perform the changes (otherwise just show as dry-run).')
|
||||||
|
def fix_projects_for_files(filepath: Path, go=False):
|
||||||
|
"""Assigns file documents to projects.
|
||||||
|
|
||||||
|
Use 'manage.py maintenance find_projects_for_files` to produce the JSON
|
||||||
|
file that contains the file ID to project ID mapping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
log.info('Loading %s', filepath)
|
||||||
|
with filepath.open('r', encoding='ascii') as infile:
|
||||||
|
mapping: typing.Mapping[str, str] = json.load(infile)
|
||||||
|
|
||||||
|
# Group IDs per project for more efficient querying.
|
||||||
|
log.info('Grouping per project')
|
||||||
|
project_to_file_ids: typing.Mapping[ObjectId, typing.List[ObjectId]] = \
|
||||||
|
collections.defaultdict(list)
|
||||||
|
for file_id, project_id in mapping.items():
|
||||||
|
project_to_file_ids[ObjectId(project_id)].append(ObjectId(file_id))
|
||||||
|
|
||||||
|
MockUpdateResult = collections.namedtuple('MockUpdateResult', 'matched_count modified_count')
|
||||||
|
|
||||||
|
files_coll = current_app.db('files')
|
||||||
|
total_matched = total_modified = 0
|
||||||
|
for project_oid, file_oids in project_to_file_ids.items():
|
||||||
|
query = {'_id': {'$in': file_oids}}
|
||||||
|
|
||||||
|
if go:
|
||||||
|
result = files_coll.update_many(query, {'$set': {'project': project_oid}})
|
||||||
|
else:
|
||||||
|
found = files_coll.count_documents(query)
|
||||||
|
result = MockUpdateResult(found, 0)
|
||||||
|
|
||||||
|
total_matched += result.matched_count
|
||||||
|
total_modified += result.modified_count
|
||||||
|
|
||||||
|
if result.matched_count != len(file_oids):
|
||||||
|
log.warning('Matched only %d of %d files; modified %d; for project %s',
|
||||||
|
result.matched_count, len(file_oids), result.modified_count, project_oid)
|
||||||
|
else:
|
||||||
|
log.info('Matched all %d files; modified %d; for project %s',
|
||||||
|
result.matched_count, result.modified_count, project_oid)
|
||||||
|
|
||||||
|
log.info('Done updating %d files (found %d, modified %d) on %d projects',
|
||||||
|
len(mapping), total_matched, total_modified, len(project_to_file_ids))
|
||||||
|
|
||||||
|
|
||||||
|
@manager_maintenance.option('-u', '--user', dest='user', nargs='?',
|
||||||
|
help='Update subscriptions for single user.')
|
||||||
|
@manager_maintenance.option('-o', '--object', dest='context_object', nargs='?',
|
||||||
|
help='Update subscriptions for context_object.')
|
||||||
|
@manager_maintenance.option('-g', '--go', dest='go', action='store_true', default=False,
|
||||||
|
help='Actually perform the changes (otherwise just show as dry-run).')
|
||||||
|
def fix_missing_activities_subscription_defaults(user=None, context_object=None, go=False):
|
||||||
|
"""Assign default values to activities-subscriptions documents where values are missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
subscriptions_collection = current_app.db('activities-subscriptions')
|
||||||
|
lookup_is_subscribed = {
|
||||||
|
'is_subscribed': {'$exists': False},
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup_notifications = {
|
||||||
|
'notifications.web': {'$exists': False},
|
||||||
|
}
|
||||||
|
|
||||||
|
if user:
|
||||||
|
lookup_is_subscribed['user'] = ObjectId(user)
|
||||||
|
lookup_notifications['user'] = ObjectId(user)
|
||||||
|
|
||||||
|
if context_object:
|
||||||
|
lookup_is_subscribed['context_object'] = ObjectId(context_object)
|
||||||
|
lookup_notifications['context_object'] = ObjectId(context_object)
|
||||||
|
|
||||||
|
num_need_is_subscribed_update = subscriptions_collection.count(lookup_is_subscribed)
|
||||||
|
log.info("Found %d documents that needs to be update 'is_subscribed'", num_need_is_subscribed_update)
|
||||||
|
num_need_notification_web_update = subscriptions_collection.count(lookup_notifications)
|
||||||
|
log.info("Found %d documents that needs to be update 'notifications.web'", num_need_notification_web_update)
|
||||||
|
|
||||||
|
if not go:
|
||||||
|
return
|
||||||
|
|
||||||
|
if num_need_is_subscribed_update > 0:
|
||||||
|
log.info("Updating 'is_subscribed'")
|
||||||
|
resp = subscriptions_collection.update(
|
||||||
|
lookup_is_subscribed,
|
||||||
|
{
|
||||||
|
'$set': {'is_subscribed': True}
|
||||||
|
},
|
||||||
|
multi=True,
|
||||||
|
upsert=False
|
||||||
|
)
|
||||||
|
if resp['nModified'] is not num_need_is_subscribed_update:
|
||||||
|
log.warning("Expected % documents to be update, was %d",
|
||||||
|
num_need_is_subscribed_update, resp['nModified'])
|
||||||
|
|
||||||
|
if num_need_notification_web_update > 0:
|
||||||
|
log.info("Updating 'notifications.web'")
|
||||||
|
resp = subscriptions_collection.update(
|
||||||
|
lookup_notifications,
|
||||||
|
{
|
||||||
|
'$set': {'notifications.web': True}
|
||||||
|
},
|
||||||
|
multi=True,
|
||||||
|
upsert=False
|
||||||
|
)
|
||||||
|
if resp['nModified'] is not num_need_notification_web_update:
|
||||||
|
log.warning("Expected % documents to be update, was %d",
|
||||||
|
num_need_notification_web_update, resp['nModified'])
|
||||||
|
|
||||||
|
log.info("Done updating 'activities-subscriptions' documents")
|
||||||
|
@@ -195,7 +195,7 @@ BLENDER_CLOUD_ADDON_VERSION = '1.4'
|
|||||||
TLS_CERT_FILE = requests.certs.where()
|
TLS_CERT_FILE = requests.certs.where()
|
||||||
|
|
||||||
CELERY_BACKEND = 'redis://redis/1'
|
CELERY_BACKEND = 'redis://redis/1'
|
||||||
CELERY_BROKER = 'amqp://guest:guest@rabbit//'
|
CELERY_BROKER = 'redis://redis/2'
|
||||||
|
|
||||||
# This configures the Celery task scheduler in such a way that we don't
|
# This configures the Celery task scheduler in such a way that we don't
|
||||||
# have to import the pillar.celery.XXX modules. Remember to run
|
# have to import the pillar.celery.XXX modules. Remember to run
|
||||||
|
@@ -174,6 +174,10 @@ class AbstractPillarTest(TestMinimal):
|
|||||||
for modname in remove:
|
for modname in remove:
|
||||||
del sys.modules[modname]
|
del sys.modules[modname]
|
||||||
|
|
||||||
|
def url_for(self, endpoint, **values):
|
||||||
|
with self.app.app_context():
|
||||||
|
return flask.url_for(endpoint, **values)
|
||||||
|
|
||||||
def ensure_file_exists(self, file_overrides=None, *, example_file=None) -> (ObjectId, dict):
|
def ensure_file_exists(self, file_overrides=None, *, example_file=None) -> (ObjectId, dict):
|
||||||
if example_file is None:
|
if example_file is None:
|
||||||
example_file = ctd.EXAMPLE_FILE
|
example_file = ctd.EXAMPLE_FILE
|
||||||
@@ -351,13 +355,15 @@ class AbstractPillarTest(TestMinimal):
|
|||||||
|
|
||||||
# TODO: rename to 'create_auth_token' now that 'expire_in_days' can be negative.
|
# TODO: rename to 'create_auth_token' now that 'expire_in_days' can be negative.
|
||||||
def create_valid_auth_token(self,
|
def create_valid_auth_token(self,
|
||||||
user_id: ObjectId,
|
user_id: typing.Union[str, ObjectId],
|
||||||
token='token',
|
token='token',
|
||||||
*,
|
*,
|
||||||
oauth_scopes: typing.Optional[typing.List[str]]=None,
|
oauth_scopes: typing.Optional[typing.List[str]]=None,
|
||||||
expire_in_days=1) -> dict:
|
expire_in_days=1) -> dict:
|
||||||
from pillar.api.utils import utcnow
|
from pillar.api.utils import utcnow
|
||||||
|
|
||||||
|
if isinstance(user_id, str):
|
||||||
|
user_id = ObjectId(user_id)
|
||||||
future = utcnow() + datetime.timedelta(days=expire_in_days)
|
future = utcnow() + datetime.timedelta(days=expire_in_days)
|
||||||
|
|
||||||
with self.app.test_request_context():
|
with self.app.test_request_context():
|
||||||
|
@@ -14,6 +14,7 @@ import werkzeug.exceptions as wz_exceptions
|
|||||||
import pillarsdk
|
import pillarsdk
|
||||||
|
|
||||||
import pillar.api.utils
|
import pillar.api.utils
|
||||||
|
from pillar import auth
|
||||||
from pillar.api.utils import pretty_duration
|
from pillar.api.utils import pretty_duration
|
||||||
from pillar.web.utils import pretty_date
|
from pillar.web.utils import pretty_date
|
||||||
from pillar.web.nodes.routes import url_for_node
|
from pillar.web.nodes.routes import url_for_node
|
||||||
@@ -34,6 +35,10 @@ def format_pretty_duration(s):
|
|||||||
return pretty_duration(s)
|
return pretty_duration(s)
|
||||||
|
|
||||||
|
|
||||||
|
def format_pretty_duration_fractional(s):
|
||||||
|
return pillar.api.utils.pretty_duration_fractional(s)
|
||||||
|
|
||||||
|
|
||||||
def format_undertitle(s):
|
def format_undertitle(s):
|
||||||
"""Underscore-replacing title filter.
|
"""Underscore-replacing title filter.
|
||||||
|
|
||||||
@@ -206,9 +211,24 @@ def do_yesno(value, arg=None):
|
|||||||
return no
|
return no
|
||||||
|
|
||||||
|
|
||||||
|
def user_to_dict(user: auth.UserClass) -> dict:
|
||||||
|
return dict(
|
||||||
|
user_id=str(user.user_id),
|
||||||
|
username=user.username,
|
||||||
|
full_name=user.full_name,
|
||||||
|
gravatar=user.gravatar,
|
||||||
|
email=user.email,
|
||||||
|
capabilities=list(user.capabilities),
|
||||||
|
badges_html=user.badges_html,
|
||||||
|
is_authenticated=user.is_authenticated
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def do_json(some_object) -> str:
|
def do_json(some_object) -> str:
|
||||||
if isinstance(some_object, pillarsdk.Resource):
|
if isinstance(some_object, pillarsdk.Resource):
|
||||||
some_object = some_object.to_dict()
|
some_object = some_object.to_dict()
|
||||||
|
if isinstance(some_object, auth.UserClass):
|
||||||
|
some_object = user_to_dict(some_object)
|
||||||
return json.dumps(some_object)
|
return json.dumps(some_object)
|
||||||
|
|
||||||
|
|
||||||
@@ -216,6 +236,7 @@ def setup_jinja_env(jinja_env, app_config: dict):
|
|||||||
jinja_env.filters['pretty_date'] = format_pretty_date
|
jinja_env.filters['pretty_date'] = format_pretty_date
|
||||||
jinja_env.filters['pretty_date_time'] = format_pretty_date_time
|
jinja_env.filters['pretty_date_time'] = format_pretty_date_time
|
||||||
jinja_env.filters['pretty_duration'] = format_pretty_duration
|
jinja_env.filters['pretty_duration'] = format_pretty_duration
|
||||||
|
jinja_env.filters['pretty_duration_fractional'] = format_pretty_duration_fractional
|
||||||
jinja_env.filters['undertitle'] = format_undertitle
|
jinja_env.filters['undertitle'] = format_undertitle
|
||||||
jinja_env.filters['hide_none'] = do_hide_none
|
jinja_env.filters['hide_none'] = do_hide_none
|
||||||
jinja_env.filters['pluralize'] = do_pluralize
|
jinja_env.filters['pluralize'] = do_pluralize
|
||||||
|
@@ -1,246 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from flask import current_app
|
|
||||||
from flask import request
|
|
||||||
from flask import jsonify
|
|
||||||
from flask import render_template
|
|
||||||
from flask_login import login_required, current_user
|
|
||||||
from pillarsdk import Node
|
|
||||||
from pillarsdk import Project
|
|
||||||
import werkzeug.exceptions as wz_exceptions
|
|
||||||
|
|
||||||
from pillar.api.utils import utcnow
|
|
||||||
from pillar.web import subquery
|
|
||||||
from pillar.web.nodes.routes import blueprint
|
|
||||||
from pillar.web.utils import gravatar
|
|
||||||
from pillar.web.utils import pretty_date
|
|
||||||
from pillar.web.utils import system_util
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/comments/create', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def comments_create():
|
|
||||||
content = request.form['content']
|
|
||||||
parent_id = request.form.get('parent_id')
|
|
||||||
|
|
||||||
if not parent_id:
|
|
||||||
log.warning('User %s tried to create comment without parent_id', current_user.objectid)
|
|
||||||
raise wz_exceptions.UnprocessableEntity()
|
|
||||||
|
|
||||||
api = system_util.pillar_api()
|
|
||||||
parent_node = Node.find(parent_id, api=api)
|
|
||||||
if not parent_node:
|
|
||||||
log.warning('Unable to create comment for user %s, parent node %r not found',
|
|
||||||
current_user.objectid, parent_id)
|
|
||||||
raise wz_exceptions.UnprocessableEntity()
|
|
||||||
|
|
||||||
log.info('Creating comment for user %s on parent node %r',
|
|
||||||
current_user.objectid, parent_id)
|
|
||||||
|
|
||||||
comment_props = dict(
|
|
||||||
project=parent_node.project,
|
|
||||||
name='Comment',
|
|
||||||
user=current_user.objectid,
|
|
||||||
node_type='comment',
|
|
||||||
properties=dict(
|
|
||||||
content=content,
|
|
||||||
status='published',
|
|
||||||
confidence=0,
|
|
||||||
rating_positive=0,
|
|
||||||
rating_negative=0))
|
|
||||||
|
|
||||||
if parent_id:
|
|
||||||
comment_props['parent'] = parent_id
|
|
||||||
|
|
||||||
# Get the parent node and check if it's a comment. In which case we flag
|
|
||||||
# the current comment as a reply.
|
|
||||||
parent_node = Node.find(parent_id, api=api)
|
|
||||||
if parent_node.node_type == 'comment':
|
|
||||||
comment_props['properties']['is_reply'] = True
|
|
||||||
|
|
||||||
comment = Node(comment_props)
|
|
||||||
comment.create(api=api)
|
|
||||||
|
|
||||||
return jsonify({'node_id': comment._id}), 201
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/comments/<string(length=24):comment_id>', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def comment_edit(comment_id):
|
|
||||||
"""Allows a user to edit their comment."""
|
|
||||||
from pillar.web import jinja
|
|
||||||
|
|
||||||
api = system_util.pillar_api()
|
|
||||||
|
|
||||||
comment = Node({'_id': comment_id})
|
|
||||||
result = comment.patch({'op': 'edit', 'content': request.form['content']}, api=api)
|
|
||||||
assert result['_status'] == 'OK'
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'data': {
|
|
||||||
'content': result.properties.content or '',
|
|
||||||
'content_html': jinja.do_markdowned(result.properties, 'content'),
|
|
||||||
}})
|
|
||||||
|
|
||||||
|
|
||||||
def format_comment(comment, is_reply=False, is_team=False, replies=None):
|
|
||||||
"""Format a comment node into a simpler dictionary.
|
|
||||||
|
|
||||||
:param comment: the comment object
|
|
||||||
:param is_reply: True if the comment is a reply to another comment
|
|
||||||
:param is_team: True if the author belongs to the group that owns the node
|
|
||||||
:param replies: list of replies (formatted with this function)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
is_own = (current_user.objectid == comment.user._id) \
|
|
||||||
if current_user.is_authenticated else False
|
|
||||||
except AttributeError:
|
|
||||||
current_app.bugsnag.notify(Exception(
|
|
||||||
'Missing user for embedded user ObjectId'),
|
|
||||||
meta_data={'nodes_info': {'node_id': comment['_id']}})
|
|
||||||
return
|
|
||||||
is_rated = False
|
|
||||||
is_rated_positive = None
|
|
||||||
if comment.properties.ratings:
|
|
||||||
for rating in comment.properties.ratings:
|
|
||||||
if current_user.is_authenticated and rating.user == current_user.objectid:
|
|
||||||
is_rated = True
|
|
||||||
is_rated_positive = rating.is_positive
|
|
||||||
break
|
|
||||||
|
|
||||||
return dict(_id=comment._id,
|
|
||||||
gravatar=gravatar(comment.user.email, size=32),
|
|
||||||
time_published=pretty_date(comment._created or utcnow(), detail=True),
|
|
||||||
rating=comment.properties.rating_positive - comment.properties.rating_negative,
|
|
||||||
author=comment.user.full_name,
|
|
||||||
author_username=comment.user.username,
|
|
||||||
content=comment.properties.content,
|
|
||||||
is_reply=is_reply,
|
|
||||||
is_own=is_own,
|
|
||||||
is_rated=is_rated,
|
|
||||||
is_rated_positive=is_rated_positive,
|
|
||||||
is_team=is_team,
|
|
||||||
replies=replies)
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/<string(length=24):node_id>/comments')
|
|
||||||
def comments_for_node(node_id):
|
|
||||||
"""Shows the comments attached to the given node.
|
|
||||||
|
|
||||||
The URL can be overridden in order to define can_post_comments in a different way
|
|
||||||
"""
|
|
||||||
|
|
||||||
api = system_util.pillar_api()
|
|
||||||
|
|
||||||
node = Node.find(node_id, api=api)
|
|
||||||
project = Project({'_id': node.project})
|
|
||||||
can_post_comments = project.node_type_has_method('comment', 'POST', api=api)
|
|
||||||
can_comment_override = request.args.get('can_comment', 'True') == 'True'
|
|
||||||
can_post_comments = can_post_comments and can_comment_override
|
|
||||||
|
|
||||||
return render_comments_for_node(node_id, can_post_comments=can_post_comments)
|
|
||||||
|
|
||||||
|
|
||||||
def render_comments_for_node(node_id: str, *, can_post_comments: bool):
|
|
||||||
"""Render the list of comments for a node.
|
|
||||||
|
|
||||||
Comments are first sorted by confidence, see:
|
|
||||||
https://redditblog.com/2009/10/15/reddits-new-comment-sorting-system/
|
|
||||||
and then by creation date.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# TODO(fsiddi) Implement confidence calculation on node rating in Pillar core.
|
|
||||||
# Currently this feature is being developed in the Dillo extension.
|
|
||||||
api = system_util.pillar_api()
|
|
||||||
|
|
||||||
# Query for all children, i.e. comments on the node.
|
|
||||||
comments = Node.all({
|
|
||||||
'where': {'node_type': 'comment', 'parent': node_id},
|
|
||||||
'sort': [('properties.confidence', -1), ('_created', -1)],
|
|
||||||
}, api=api)
|
|
||||||
|
|
||||||
def enrich(some_comment):
|
|
||||||
some_comment['_user'] = subquery.get_user_info(some_comment['user'])
|
|
||||||
some_comment['_is_own'] = some_comment['user'] == current_user.objectid
|
|
||||||
some_comment['_current_user_rating'] = None # tri-state boolean
|
|
||||||
some_comment[
|
|
||||||
'_rating'] = some_comment.properties.rating_positive - some_comment.properties.rating_negative
|
|
||||||
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
for rating in some_comment.properties.ratings or ():
|
|
||||||
if rating.user != current_user.objectid:
|
|
||||||
continue
|
|
||||||
|
|
||||||
some_comment['_current_user_rating'] = rating.is_positive
|
|
||||||
|
|
||||||
for comment in comments['_items']:
|
|
||||||
# Query for all grandchildren, i.e. replies to comments on the node.
|
|
||||||
comment['_replies'] = Node.all({
|
|
||||||
'where': {'node_type': 'comment', 'parent': comment['_id']},
|
|
||||||
'sort': [('properties.confidence', -1), ('_created', -1)],
|
|
||||||
}, api=api)
|
|
||||||
|
|
||||||
enrich(comment)
|
|
||||||
for reply in comment['_replies']['_items']:
|
|
||||||
enrich(reply)
|
|
||||||
nr_of_comments = sum(1 + comment['_replies']['_meta']['total']
|
|
||||||
for comment in comments['_items'])
|
|
||||||
return render_template('nodes/custom/comment/list_embed.html',
|
|
||||||
node_id=node_id,
|
|
||||||
comments=comments,
|
|
||||||
nr_of_comments=nr_of_comments,
|
|
||||||
show_comments=True,
|
|
||||||
can_post_comments=can_post_comments)
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/<string(length=24):node_id>/commentform')
|
|
||||||
def commentform_for_node(node_id):
|
|
||||||
"""Shows only the comment for for comments attached to the given node.
|
|
||||||
|
|
||||||
i.e. does not show the comments themselves, just the form to post a new comment.
|
|
||||||
"""
|
|
||||||
|
|
||||||
api = system_util.pillar_api()
|
|
||||||
|
|
||||||
node = Node.find(node_id, api=api)
|
|
||||||
project = Project({'_id': node.project})
|
|
||||||
can_post_comments = project.node_type_has_method('comment', 'POST', api=api)
|
|
||||||
|
|
||||||
return render_template('nodes/custom/comment/list_embed.html',
|
|
||||||
node_id=node_id,
|
|
||||||
show_comments=False,
|
|
||||||
can_post_comments=can_post_comments)
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/comments/<comment_id>/rate/<operation>", methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def comments_rate(comment_id, operation):
|
|
||||||
"""Comment rating function
|
|
||||||
|
|
||||||
:param comment_id: the comment id
|
|
||||||
:type comment_id: str
|
|
||||||
:param rating: the rating (is cast from 0 to False and from 1 to True)
|
|
||||||
:type rating: int
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
if operation not in {'revoke', 'upvote', 'downvote'}:
|
|
||||||
raise wz_exceptions.BadRequest('Invalid operation')
|
|
||||||
|
|
||||||
api = system_util.pillar_api()
|
|
||||||
|
|
||||||
# PATCH the node and return the result.
|
|
||||||
comment = Node({'_id': comment_id})
|
|
||||||
result = comment.patch({'op': operation}, api=api)
|
|
||||||
assert result['_status'] == 'OK'
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'data': {
|
|
||||||
'op': operation,
|
|
||||||
'rating_positive': result.properties.rating_positive,
|
|
||||||
'rating_negative': result.properties.rating_negative,
|
|
||||||
}})
|
|
@@ -109,6 +109,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
|||||||
project.blog_archive_prev = None
|
project.blog_archive_prev = None
|
||||||
|
|
||||||
navigation_links = project_navigation_links(project, api)
|
navigation_links = project_navigation_links(project, api)
|
||||||
|
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
template_path,
|
template_path,
|
||||||
@@ -121,6 +122,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
|||||||
node_type_post=project.get_node_type('post'),
|
node_type_post=project.get_node_type('post'),
|
||||||
can_create_blog_posts=can_create_blog_posts,
|
can_create_blog_posts=can_create_blog_posts,
|
||||||
navigation_links=navigation_links,
|
navigation_links=navigation_links,
|
||||||
|
extension_sidebar_links=extension_sidebar_links,
|
||||||
api=api)
|
api=api)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -48,9 +48,12 @@ def find_for_comment(project, node):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parent = Node.find(parent.parent, api=api)
|
parent = Node.find_one({'where': {
|
||||||
|
'_id': parent.parent,
|
||||||
|
'_deleted': {'$ne': True}
|
||||||
|
}}, api=api)
|
||||||
except ResourceNotFound:
|
except ResourceNotFound:
|
||||||
log.warning(
|
log.debug(
|
||||||
'url_for_node(node_id=%r): Unable to find parent node %r',
|
'url_for_node(node_id=%r): Unable to find parent node %r',
|
||||||
node['_id'], parent.parent)
|
node['_id'], parent.parent)
|
||||||
raise ValueError('Unable to find parent node %r' % parent.parent)
|
raise ValueError('Unable to find parent node %r' % parent.parent)
|
||||||
|
@@ -50,6 +50,7 @@ def iter_node_properties(node_type):
|
|||||||
@functools.lru_cache(maxsize=1)
|
@functools.lru_cache(maxsize=1)
|
||||||
def tag_choices() -> typing.List[typing.Tuple[str, str]]:
|
def tag_choices() -> typing.List[typing.Tuple[str, str]]:
|
||||||
"""Return (value, label) tuples for the NODE_TAGS config setting."""
|
"""Return (value, label) tuples for the NODE_TAGS config setting."""
|
||||||
|
#TODO(fsiddi) consider allowing tags based on custom_properties in the project.
|
||||||
tags = current_app.config.get('NODE_TAGS') or []
|
tags = current_app.config.get('NODE_TAGS') or []
|
||||||
return [(tag, tag.title()) for tag in tags] # (value, label) tuples
|
return [(tag, tag.title()) for tag in tags] # (value, label) tuples
|
||||||
|
|
||||||
@@ -70,9 +71,7 @@ def add_form_properties(form_class, node_type):
|
|||||||
# Recursive call if detects a dict
|
# Recursive call if detects a dict
|
||||||
field_type = schema_prop['type']
|
field_type = schema_prop['type']
|
||||||
|
|
||||||
if prop_name == 'tags' and field_type == 'list':
|
if field_type == 'dict':
|
||||||
field = SelectMultipleField(choices=tag_choices())
|
|
||||||
elif field_type == 'dict':
|
|
||||||
assert prop_name == 'attachments'
|
assert prop_name == 'attachments'
|
||||||
field = attachments.attachment_form_group_create(schema_prop)
|
field = attachments.attachment_form_group_create(schema_prop)
|
||||||
elif field_type == 'list':
|
elif field_type == 'list':
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pillarsdk
|
import pillarsdk
|
||||||
|
from pillar import shortcodes
|
||||||
from pillarsdk import Node
|
from pillarsdk import Node
|
||||||
from pillarsdk import Project
|
from pillarsdk import Project
|
||||||
from pillarsdk.exceptions import ResourceNotFound
|
from pillarsdk.exceptions import ResourceNotFound
|
||||||
@@ -17,15 +17,12 @@ from flask import request
|
|||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
from flask import abort
|
from flask import abort
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from flask_wtf.csrf import validate_csrf
|
|
||||||
|
|
||||||
import werkzeug.exceptions as wz_exceptions
|
import werkzeug.exceptions as wz_exceptions
|
||||||
from wtforms import SelectMultipleField
|
from wtforms import SelectMultipleField
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
from jinja2.exceptions import TemplateNotFound
|
from jinja2.exceptions import TemplateNotFound
|
||||||
|
|
||||||
from pillar.api.utils.authorization import check_permissions
|
|
||||||
from pillar.web.utils import caching
|
|
||||||
from pillar.markdown import markdown
|
from pillar.markdown import markdown
|
||||||
from pillar.web.nodes.forms import get_node_form
|
from pillar.web.nodes.forms import get_node_form
|
||||||
from pillar.web.nodes.forms import process_node_form
|
from pillar.web.nodes.forms import process_node_form
|
||||||
@@ -108,6 +105,11 @@ def view(node_id, extra_template_args: dict=None):
|
|||||||
|
|
||||||
node_type_name = node.node_type
|
node_type_name = node.node_type
|
||||||
|
|
||||||
|
if node_type_name == 'page':
|
||||||
|
# HACK: The 'edit node' page GETs this endpoint, but for pages it's plain wrong,
|
||||||
|
# so we just redirect to the correct URL.
|
||||||
|
return redirect(url_for_node(node=node))
|
||||||
|
|
||||||
if node_type_name == 'post' and not request.args.get('embed'):
|
if node_type_name == 'post' and not request.args.get('embed'):
|
||||||
# Posts shouldn't be shown at this route (unless viewed embedded, tipically
|
# Posts shouldn't be shown at this route (unless viewed embedded, tipically
|
||||||
# after an edit. Redirect to the correct one.
|
# after an edit. Redirect to the correct one.
|
||||||
@@ -487,11 +489,14 @@ def preview_markdown():
|
|||||||
current_app.csrf.protect()
|
current_app.csrf.protect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = request.form['content']
|
content = request.json['content']
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return jsonify({'_status': 'ERR',
|
return jsonify({'_status': 'ERR',
|
||||||
'message': 'The field "content" was not specified.'}), 400
|
'message': 'The field "content" was not specified.'}), 400
|
||||||
return jsonify(content=markdown(content))
|
html = markdown(content)
|
||||||
|
attachmentsdict = request.json.get('attachments', {})
|
||||||
|
html = shortcodes.render_commented(html, context={'attachments': attachmentsdict})
|
||||||
|
return jsonify(content=html)
|
||||||
|
|
||||||
|
|
||||||
def ensure_lists_exist_as_empty(node_doc, node_type):
|
def ensure_lists_exist_as_empty(node_doc, node_type):
|
||||||
@@ -604,5 +609,94 @@ def url_for_node(node_id=None, node=None):
|
|||||||
return finders.find_url_for_node(node)
|
return finders.find_url_for_node(node)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<node_id>/breadcrumbs")
|
||||||
|
def breadcrumbs(node_id: str):
|
||||||
|
"""Return breadcrumbs for the given node, as JSON.
|
||||||
|
|
||||||
|
Note that a missing parent is still returned in the breadcrumbs,
|
||||||
|
but with `{_exists: false, name: '-unknown-'}`.
|
||||||
|
|
||||||
|
The breadcrumbs start with the top-level parent, and end with the node
|
||||||
|
itself (marked by {_self: true}). Returns JSON like this:
|
||||||
|
|
||||||
|
{breadcrumbs: [
|
||||||
|
...,
|
||||||
|
{_id: "parentID",
|
||||||
|
name: "The Parent Node",
|
||||||
|
node_type: "group",
|
||||||
|
url: "/p/project/parentID"},
|
||||||
|
{_id: "deadbeefbeefbeefbeeffeee",
|
||||||
|
_self: true,
|
||||||
|
name: "The Node Itself",
|
||||||
|
node_type: "asset",
|
||||||
|
url: "/p/project/nodeID"},
|
||||||
|
]}
|
||||||
|
|
||||||
|
When a parent node is missing, it has a breadcrumb like this:
|
||||||
|
|
||||||
|
{_id: "deadbeefbeefbeefbeeffeee",
|
||||||
|
_exists': false,
|
||||||
|
name': '-unknown-'}
|
||||||
|
"""
|
||||||
|
|
||||||
|
api = system_util.pillar_api()
|
||||||
|
is_self = True
|
||||||
|
|
||||||
|
def make_crumb(some_node: None) -> dict:
|
||||||
|
"""Construct a breadcrumb for this node."""
|
||||||
|
nonlocal is_self
|
||||||
|
|
||||||
|
crumb = {
|
||||||
|
'_id': some_node._id,
|
||||||
|
'name': some_node.name,
|
||||||
|
'node_type': some_node.node_type,
|
||||||
|
'url': finders.find_url_for_node(some_node),
|
||||||
|
}
|
||||||
|
if is_self:
|
||||||
|
crumb['_self'] = True
|
||||||
|
is_self = False
|
||||||
|
return crumb
|
||||||
|
|
||||||
|
def make_missing_crumb(some_node_id: None) -> dict:
|
||||||
|
"""Construct 'missing parent' breadcrumb."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
'_id': some_node_id,
|
||||||
|
'_exists': False,
|
||||||
|
'name': '-unknown-',
|
||||||
|
}
|
||||||
|
|
||||||
|
# The first node MUST exist.
|
||||||
|
try:
|
||||||
|
node = Node.find(node_id, api=api)
|
||||||
|
except ResourceNotFound:
|
||||||
|
log.warning('breadcrumbs(node_id=%r): Unable to find node', node_id)
|
||||||
|
raise wz_exceptions.NotFound(f'Unable to find node {node_id}')
|
||||||
|
except ForbiddenAccess:
|
||||||
|
log.warning('breadcrumbs(node_id=%r): access denied to current user', node_id)
|
||||||
|
raise wz_exceptions.Forbidden(f'No access to node {node_id}')
|
||||||
|
|
||||||
|
crumbs = []
|
||||||
|
while True:
|
||||||
|
crumbs.append(make_crumb(node))
|
||||||
|
|
||||||
|
child_id = node._id
|
||||||
|
node_id = node.parent
|
||||||
|
if not node_id:
|
||||||
|
break
|
||||||
|
|
||||||
|
# If a subsequent node doesn't exist any more, include that in the breadcrumbs.
|
||||||
|
# Forbidden nodes are handled as if they don't exist.
|
||||||
|
try:
|
||||||
|
node = Node.find(node_id, api=api)
|
||||||
|
except (ResourceNotFound, ForbiddenAccess):
|
||||||
|
log.warning('breadcrumbs: Unable to find node %r but it is marked as parent of %r',
|
||||||
|
node_id, child_id)
|
||||||
|
crumbs.append(make_missing_crumb(node_id))
|
||||||
|
break
|
||||||
|
|
||||||
|
return jsonify({'breadcrumbs': list(reversed(crumbs))})
|
||||||
|
|
||||||
|
|
||||||
# Import of custom modules (using the same nodes decorator)
|
# Import of custom modules (using the same nodes decorator)
|
||||||
from .custom import comments, groups, storage, posts
|
from .custom import groups, storage, posts
|
||||||
|
@@ -30,6 +30,7 @@ class ProjectForm(FlaskForm):
|
|||||||
('deleted', 'Deleted')])
|
('deleted', 'Deleted')])
|
||||||
picture_header = FileSelectField('Picture header', file_format='image')
|
picture_header = FileSelectField('Picture header', file_format='image')
|
||||||
picture_square = FileSelectField('Picture square', file_format='image')
|
picture_square = FileSelectField('Picture square', file_format='image')
|
||||||
|
picture_16_9 = FileSelectField('Picture 16:9', file_format='image')
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
rv = FlaskForm.validate(self)
|
rv = FlaskForm.validate(self)
|
||||||
|
@@ -349,8 +349,7 @@ def project_navigation_links(project: typing.Type[Project], api) -> list:
|
|||||||
|
|
||||||
|
|
||||||
def render_project(project, api, extra_context=None, template_name=None):
|
def render_project(project, api, extra_context=None, template_name=None):
|
||||||
project.picture_square = utils.get_file(project.picture_square, api=api)
|
utils.attach_project_pictures(project, api)
|
||||||
project.picture_header = utils.get_file(project.picture_header, api=api)
|
|
||||||
|
|
||||||
def load_latest(list_of_ids, node_type=None):
|
def load_latest(list_of_ids, node_type=None):
|
||||||
"""Loads a list of IDs in reversed order."""
|
"""Loads a list of IDs in reversed order."""
|
||||||
@@ -415,9 +414,8 @@ def render_project(project, api, extra_context=None, template_name=None):
|
|||||||
embed_string = ''
|
embed_string = ''
|
||||||
template_name = "projects/view{0}.html".format(embed_string)
|
template_name = "projects/view{0}.html".format(embed_string)
|
||||||
|
|
||||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
|
||||||
|
|
||||||
navigation_links = project_navigation_links(project, api)
|
navigation_links = project_navigation_links(project, api)
|
||||||
|
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||||
|
|
||||||
return render_template(template_name,
|
return render_template(template_name,
|
||||||
api=api,
|
api=api,
|
||||||
@@ -425,7 +423,7 @@ def render_project(project, api, extra_context=None, template_name=None):
|
|||||||
node=None,
|
node=None,
|
||||||
show_node=False,
|
show_node=False,
|
||||||
show_project=True,
|
show_project=True,
|
||||||
og_picture=project.picture_header,
|
og_picture=project.picture_16_9,
|
||||||
activity_stream=activity_stream,
|
activity_stream=activity_stream,
|
||||||
navigation_links=navigation_links,
|
navigation_links=navigation_links,
|
||||||
extension_sidebar_links=extension_sidebar_links,
|
extension_sidebar_links=extension_sidebar_links,
|
||||||
@@ -490,12 +488,14 @@ def view_node(project_url, node_id):
|
|||||||
raise wz_exceptions.NotFound('No such project')
|
raise wz_exceptions.NotFound('No such project')
|
||||||
|
|
||||||
navigation_links = []
|
navigation_links = []
|
||||||
|
extension_sidebar_links = ''
|
||||||
og_picture = node.picture = utils.get_file(node.picture, api=api)
|
og_picture = node.picture = utils.get_file(node.picture, api=api)
|
||||||
if project:
|
if project:
|
||||||
|
utils.attach_project_pictures(project, api)
|
||||||
if not node.picture:
|
if not node.picture:
|
||||||
og_picture = utils.get_file(project.picture_header, api=api)
|
og_picture = project.picture_16_9
|
||||||
project.picture_square = utils.get_file(project.picture_square, api=api)
|
|
||||||
navigation_links = project_navigation_links(project, api)
|
navigation_links = project_navigation_links(project, api)
|
||||||
|
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||||
|
|
||||||
# Append _theatre to load the proper template
|
# Append _theatre to load the proper template
|
||||||
theatre = '_theatre' if theatre_mode else ''
|
theatre = '_theatre' if theatre_mode else ''
|
||||||
@@ -506,10 +506,9 @@ def view_node(project_url, node_id):
|
|||||||
node=node,
|
node=node,
|
||||||
project=project,
|
project=project,
|
||||||
navigation_links=navigation_links,
|
navigation_links=navigation_links,
|
||||||
|
extension_sidebar_links=extension_sidebar_links,
|
||||||
og_picture=og_picture,)
|
og_picture=og_picture,)
|
||||||
|
|
||||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
|
||||||
|
|
||||||
return render_template('projects/view{}.html'.format(theatre),
|
return render_template('projects/view{}.html'.format(theatre),
|
||||||
api=api,
|
api=api,
|
||||||
project=project,
|
project=project,
|
||||||
@@ -518,7 +517,7 @@ def view_node(project_url, node_id):
|
|||||||
show_project=False,
|
show_project=False,
|
||||||
og_picture=og_picture,
|
og_picture=og_picture,
|
||||||
navigation_links=navigation_links,
|
navigation_links=navigation_links,
|
||||||
extension_sidebar_links=extension_sidebar_links)
|
extension_sidebar_links=extension_sidebar_links,)
|
||||||
|
|
||||||
|
|
||||||
def find_project_or_404(project_url, embedded=None, api=None):
|
def find_project_or_404(project_url, embedded=None, api=None):
|
||||||
@@ -541,8 +540,7 @@ def search(project_url):
|
|||||||
"""Search into a project"""
|
"""Search into a project"""
|
||||||
api = system_util.pillar_api()
|
api = system_util.pillar_api()
|
||||||
project = find_project_or_404(project_url, api=api)
|
project = find_project_or_404(project_url, api=api)
|
||||||
project.picture_square = utils.get_file(project.picture_square, api=api)
|
utils.attach_project_pictures(project, api)
|
||||||
project.picture_header = utils.get_file(project.picture_header, api=api)
|
|
||||||
|
|
||||||
return render_template('nodes/search.html',
|
return render_template('nodes/search.html',
|
||||||
project=project,
|
project=project,
|
||||||
@@ -583,6 +581,8 @@ def edit(project_url):
|
|||||||
project.picture_square = form.picture_square.data
|
project.picture_square = form.picture_square.data
|
||||||
if form.picture_header.data:
|
if form.picture_header.data:
|
||||||
project.picture_header = form.picture_header.data
|
project.picture_header = form.picture_header.data
|
||||||
|
if form.picture_16_9.data:
|
||||||
|
project.picture_16_9 = form.picture_16_9.data
|
||||||
|
|
||||||
# Update world permissions from is_private checkbox
|
# Update world permissions from is_private checkbox
|
||||||
if form.is_private.data:
|
if form.is_private.data:
|
||||||
@@ -598,6 +598,8 @@ def edit(project_url):
|
|||||||
form.picture_square.data = project.picture_square._id
|
form.picture_square.data = project.picture_square._id
|
||||||
if project.picture_header:
|
if project.picture_header:
|
||||||
form.picture_header.data = project.picture_header._id
|
form.picture_header.data = project.picture_header._id
|
||||||
|
if project.picture_16_9:
|
||||||
|
form.picture_16_9.data = project.picture_16_9._id
|
||||||
|
|
||||||
# List of fields from the form that should be hidden to regular users
|
# List of fields from the form that should be hidden to regular users
|
||||||
if current_user.has_role('admin'):
|
if current_user.has_role('admin'):
|
||||||
|
File diff suppressed because one or more lines are too long
@@ -31,8 +31,10 @@ def check_oauth_provider(provider):
|
|||||||
|
|
||||||
@blueprint.route('/authorize/<provider>')
|
@blueprint.route('/authorize/<provider>')
|
||||||
def oauth_authorize(provider):
|
def oauth_authorize(provider):
|
||||||
if not current_user.is_anonymous:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('main.homepage'))
|
next_after_login = session.pop('next_after_login', None) or url_for('main.homepage')
|
||||||
|
log.debug('Redirecting user to %s', next_after_login)
|
||||||
|
return redirect(next_after_login)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
oauth = OAuthSignIn.get_provider(provider)
|
oauth = OAuthSignIn.get_provider(provider)
|
||||||
@@ -52,8 +54,10 @@ def oauth_callback(provider):
|
|||||||
from pillar.api.utils.authentication import store_token
|
from pillar.api.utils.authentication import store_token
|
||||||
from pillar.api.utils import utcnow
|
from pillar.api.utils import utcnow
|
||||||
|
|
||||||
|
next_after_login = session.pop('next_after_login', None) or url_for('main.homepage')
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('main.homepage'))
|
log.debug('Redirecting user to %s', next_after_login)
|
||||||
|
return redirect(next_after_login)
|
||||||
|
|
||||||
oauth = OAuthSignIn.get_provider(provider)
|
oauth = OAuthSignIn.get_provider(provider)
|
||||||
try:
|
try:
|
||||||
@@ -63,11 +67,14 @@ def oauth_callback(provider):
|
|||||||
raise wz_exceptions.Forbidden()
|
raise wz_exceptions.Forbidden()
|
||||||
if oauth_user.id is None:
|
if oauth_user.id is None:
|
||||||
log.debug('Authentication failed for user with {}'.format(provider))
|
log.debug('Authentication failed for user with {}'.format(provider))
|
||||||
return redirect(url_for('main.homepage'))
|
return redirect(next_after_login)
|
||||||
|
|
||||||
# Find or create user
|
# Find or create user
|
||||||
user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
|
user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
|
||||||
db_user = find_user_in_db(user_info, provider=provider)
|
db_user = find_user_in_db(user_info, provider=provider)
|
||||||
|
if '_deleted' in db_user and db_user['_deleted'] is True:
|
||||||
|
log.debug('User has been deleted and will not be logge in')
|
||||||
|
return redirect(next_after_login)
|
||||||
db_id, status = upsert_user(db_user)
|
db_id, status = upsert_user(db_user)
|
||||||
|
|
||||||
# TODO(Sybren): If the user doesn't have any badges, but the access token
|
# TODO(Sybren): If the user doesn't have any badges, but the access token
|
||||||
@@ -88,11 +95,8 @@ def oauth_callback(provider):
|
|||||||
# Check with Blender ID to update certain user roles.
|
# Check with Blender ID to update certain user roles.
|
||||||
update_subscription()
|
update_subscription()
|
||||||
|
|
||||||
next_after_login = session.pop('next_after_login', None)
|
|
||||||
if next_after_login:
|
|
||||||
log.debug('Redirecting user to %s', next_after_login)
|
log.debug('Redirecting user to %s', next_after_login)
|
||||||
return redirect(next_after_login)
|
return redirect(next_after_login)
|
||||||
return redirect(url_for('main.homepage'))
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/login')
|
@blueprint.route('/login')
|
||||||
|
@@ -45,6 +45,7 @@ def attach_project_pictures(project, api):
|
|||||||
|
|
||||||
project.picture_square = get_file(project.picture_square, api=api)
|
project.picture_square = get_file(project.picture_square, api=api)
|
||||||
project.picture_header = get_file(project.picture_header, api=api)
|
project.picture_header = get_file(project.picture_header, api=api)
|
||||||
|
project.picture_16_9 = get_file(project.picture_16_9, api=api)
|
||||||
|
|
||||||
|
|
||||||
def mass_attach_project_pictures(projects: typing.Iterable[pillarsdk.Project], *,
|
def mass_attach_project_pictures(projects: typing.Iterable[pillarsdk.Project], *,
|
||||||
|
@@ -42,9 +42,9 @@ asn1crypto==0.24.0
|
|||||||
Babel==2.6.0
|
Babel==2.6.0
|
||||||
billiard==3.5.0.4
|
billiard==3.5.0.4
|
||||||
Cerberus==1.2
|
Cerberus==1.2
|
||||||
cffi==1.10.0
|
cffi==1.12.2
|
||||||
click==6.7
|
click==6.7
|
||||||
cryptography==2.0.3
|
cryptography==2.6.1
|
||||||
Events==0.3
|
Events==0.3
|
||||||
future==0.16.0
|
future==0.16.0
|
||||||
googleapis-common-protos==1.5.3
|
googleapis-common-protos==1.5.3
|
||||||
@@ -52,7 +52,7 @@ html5lib==1.0.1
|
|||||||
idna==2.5
|
idna==2.5
|
||||||
ipaddress==1.0.22
|
ipaddress==1.0.22
|
||||||
itsdangerous==0.24
|
itsdangerous==0.24
|
||||||
Jinja2==2.10
|
Jinja2==2.10.1
|
||||||
kombu==4.2.1
|
kombu==4.2.1
|
||||||
oauth2client==4.1.2
|
oauth2client==4.1.2
|
||||||
oauthlib==2.1.0
|
oauthlib==2.1.0
|
||||||
@@ -61,14 +61,14 @@ protobuf==3.6.0
|
|||||||
protorpc==0.12.0
|
protorpc==0.12.0
|
||||||
pyasn1==0.4.4
|
pyasn1==0.4.4
|
||||||
pyasn1-modules==0.2.2
|
pyasn1-modules==0.2.2
|
||||||
pycparser==2.17
|
pycparser==2.19
|
||||||
pymongo==3.7.0
|
pymongo==3.7.0
|
||||||
pyOpenSSL==16.2.0
|
pyOpenSSL==16.2.0
|
||||||
pytz==2018.5
|
pytz==2018.5
|
||||||
requests-oauthlib==1.0.0
|
requests-oauthlib==1.0.0
|
||||||
rsa==3.4.2
|
rsa==3.4.2
|
||||||
simplejson==3.16.0
|
simplejson==3.16.0
|
||||||
six==1.10.0
|
six==1.12.0
|
||||||
urllib3==1.22
|
urllib3==1.22
|
||||||
vine==1.1.4
|
vine==1.1.4
|
||||||
webencodings==0.5.1
|
webencodings==0.5.1
|
||||||
|
2
src/scripts/js/es6/common/README.md
Normal file
2
src/scripts/js/es6/common/README.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Gulp will transpile everything in this folder. Every sub folder containing a init.js file exporting functions/classes
|
||||||
|
will be packed into a module in tutti.js under the namespace pillar.FOLDER_NAME.
|
46
src/scripts/js/es6/common/api/comments.js
Normal file
46
src/scripts/js/es6/common/api/comments.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
function thenGetComments(parentId) {
|
||||||
|
return $.getJSON(`/api/nodes/${parentId}/comments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function thenCreateComment(parentId, msg, attachments) {
|
||||||
|
let data = JSON.stringify({
|
||||||
|
msg: msg,
|
||||||
|
attachments: attachments
|
||||||
|
});
|
||||||
|
return $.ajax({
|
||||||
|
url: `/api/nodes/${parentId}/comments`,
|
||||||
|
type: 'POST',
|
||||||
|
data: data,
|
||||||
|
dataType: 'json',
|
||||||
|
contentType: 'application/json; charset=UTF-8'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function thenUpdateComment(parentId, commentId, msg, attachments) {
|
||||||
|
let data = JSON.stringify({
|
||||||
|
msg: msg,
|
||||||
|
attachments: attachments
|
||||||
|
});
|
||||||
|
return $.ajax({
|
||||||
|
url: `/api/nodes/${parentId}/comments/${commentId}`,
|
||||||
|
type: 'PATCH',
|
||||||
|
data: data,
|
||||||
|
dataType: 'json',
|
||||||
|
contentType: 'application/json; charset=UTF-8'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function thenVoteComment(parentId, commentId, vote) {
|
||||||
|
let data = JSON.stringify({
|
||||||
|
vote: vote
|
||||||
|
});
|
||||||
|
return $.ajax({
|
||||||
|
url: `/api/nodes/${parentId}/comments/${commentId}/vote`,
|
||||||
|
type: 'POST',
|
||||||
|
data: data,
|
||||||
|
dataType: 'json',
|
||||||
|
contentType: 'application/json; charset=UTF-8'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { thenGetComments, thenCreateComment, thenUpdateComment, thenVoteComment }
|
54
src/scripts/js/es6/common/api/files.js
Normal file
54
src/scripts/js/es6/common/api/files.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
function thenUploadFile(projectId, file, progressCB=(total, loaded)=>{}) {
|
||||||
|
let formData = createFormData(file)
|
||||||
|
return $.ajax({
|
||||||
|
url: `/api/storage/stream/${projectId}`,
|
||||||
|
type: 'POST',
|
||||||
|
data: formData,
|
||||||
|
|
||||||
|
cache: false,
|
||||||
|
contentType: false,
|
||||||
|
processData: false,
|
||||||
|
|
||||||
|
xhr: () => {
|
||||||
|
let myxhr = $.ajaxSettings.xhr();
|
||||||
|
if (myxhr.upload) {
|
||||||
|
// For handling the progress of the upload
|
||||||
|
myxhr.upload.addEventListener('progress', function(e) {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
progressCB(e.total, e.loaded);
|
||||||
|
}
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
return myxhr;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFormData(file) {
|
||||||
|
let formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function thenGetFileDocument(fileId) {
|
||||||
|
return $.get(`/api/files/${fileId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileVariation(fileDoc, size = 'm') {
|
||||||
|
var show_variation = null;
|
||||||
|
if (typeof fileDoc.variations != 'undefined') {
|
||||||
|
for (var variation of fileDoc.variations) {
|
||||||
|
if (variation.size != size) continue;
|
||||||
|
show_variation = variation;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (show_variation == null) {
|
||||||
|
throw 'Image not found: ' + fileDoc._id + ' size: ' + size;
|
||||||
|
}
|
||||||
|
return show_variation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { thenUploadFile, thenGetFileDocument, getFileVariation }
|
7
src/scripts/js/es6/common/api/init.js
Normal file
7
src/scripts/js/es6/common/api/init.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Functions for communicating with the pillar server api
|
||||||
|
*/
|
||||||
|
export { thenMarkdownToHtml } from './markdown'
|
||||||
|
export { thenGetProject } from './projects'
|
||||||
|
export { thenGetNodes, thenGetNode, thenGetNodeActivities, thenUpdateNode, thenDeleteNode } from './nodes'
|
||||||
|
export { thenGetProjectUsers } from './users'
|
17
src/scripts/js/es6/common/api/markdown.js
Normal file
17
src/scripts/js/es6/common/api/markdown.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
function thenMarkdownToHtml(markdown, attachments={}) {
|
||||||
|
let data = JSON.stringify({
|
||||||
|
content: markdown,
|
||||||
|
attachments: attachments
|
||||||
|
});
|
||||||
|
return $.ajax({
|
||||||
|
url: "/nodes/preview-markdown",
|
||||||
|
type: 'POST',
|
||||||
|
headers: {"X-CSRFToken": csrf_token},
|
||||||
|
headers: {},
|
||||||
|
data: data,
|
||||||
|
dataType: 'json',
|
||||||
|
contentType: 'application/json; charset=UTF-8'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { thenMarkdownToHtml }
|
82
src/scripts/js/es6/common/api/nodes.js
Normal file
82
src/scripts/js/es6/common/api/nodes.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
function thenGetNodes(where, embedded={}, sort='') {
|
||||||
|
let encodedWhere = encodeURIComponent(JSON.stringify(where));
|
||||||
|
let encodedEmbedded = encodeURIComponent(JSON.stringify(embedded));
|
||||||
|
let encodedSort = encodeURIComponent(sort);
|
||||||
|
|
||||||
|
return $.ajax({
|
||||||
|
url: `/api/nodes?where=${encodedWhere}&embedded=${encodedEmbedded}&sort=${encodedSort}`,
|
||||||
|
cache: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function thenGetNode(nodeId) {
|
||||||
|
return $.ajax({
|
||||||
|
url: `/api/nodes/${nodeId}`,
|
||||||
|
cache: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function thenGetNodeActivities(nodeId, sort='[("_created", -1)]', max_results=20, page=1) {
|
||||||
|
let encodedSort = encodeURIComponent(sort);
|
||||||
|
return $.ajax({
|
||||||
|
url: `/api/nodes/${nodeId}/activities?sort=${encodedSort}&max_results=${max_results}&page=${page}`,
|
||||||
|
cache: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function thenUpdateNode(node) {
|
||||||
|
let id = node['_id'];
|
||||||
|
let etag = node['_etag'];
|
||||||
|
|
||||||
|
let nodeToSave = removePrivateKeys(node);
|
||||||
|
let data = JSON.stringify(nodeToSave);
|
||||||
|
return $.ajax({
|
||||||
|
url: `/api/nodes/${id}`,
|
||||||
|
type: 'PUT',
|
||||||
|
data: data,
|
||||||
|
dataType: 'json',
|
||||||
|
contentType: 'application/json; charset=UTF-8',
|
||||||
|
headers: {'If-Match': etag},
|
||||||
|
}).then(updatedInfo => {
|
||||||
|
return thenGetNode(updatedInfo['_id'])
|
||||||
|
.then(node => {
|
||||||
|
pillar.events.Nodes.triggerUpdated(node);
|
||||||
|
return node;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function thenDeleteNode(node) {
|
||||||
|
let id = node['_id'];
|
||||||
|
let etag = node['_etag'];
|
||||||
|
|
||||||
|
return $.ajax({
|
||||||
|
url: `/api/nodes/${id}`,
|
||||||
|
type: 'DELETE',
|
||||||
|
headers: {'If-Match': etag},
|
||||||
|
}).then(() => {
|
||||||
|
pillar.events.Nodes.triggerDeleted(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePrivateKeys(doc) {
|
||||||
|
function doRemove(d) {
|
||||||
|
for (const key in d) {
|
||||||
|
if (key.startsWith('_')) {
|
||||||
|
delete d[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let val = d[key];
|
||||||
|
if(typeof val === 'object') {
|
||||||
|
doRemove(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let docCopy = JSON.parse(JSON.stringify(doc));
|
||||||
|
doRemove(docCopy);
|
||||||
|
delete docCopy['allowed_methods']
|
||||||
|
|
||||||
|
return docCopy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { thenGetNodes, thenGetNode, thenGetNodeActivities, thenUpdateNode, thenDeleteNode }
|
5
src/scripts/js/es6/common/api/projects.js
Normal file
5
src/scripts/js/es6/common/api/projects.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
function thenGetProject(projectId) {
|
||||||
|
return $.get(`/api/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { thenGetProject }
|
7
src/scripts/js/es6/common/api/users.js
Normal file
7
src/scripts/js/es6/common/api/users.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
function thenGetProjectUsers(projectId) {
|
||||||
|
return $.ajax({
|
||||||
|
url: `/api/p/users?project_id=${projectId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { thenGetProjectUsers }
|
167
src/scripts/js/es6/common/events/Nodes.js
Normal file
167
src/scripts/js/es6/common/events/Nodes.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Helper class to trigger/listen to global events on new/updated/deleted nodes.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* function myCallback(event) {
|
||||||
|
* console.log('Updated node:', event.detail);
|
||||||
|
* }
|
||||||
|
* // Register a callback:
|
||||||
|
* Nodes.onUpdated('5c1cc4a5a013573d9787164b', myCallback);
|
||||||
|
* // When changing the node, notify the listeners:
|
||||||
|
* Nodes.triggerUpdated(myUpdatedNode);
|
||||||
|
*/
|
||||||
|
|
||||||
|
class EventName {
|
||||||
|
static parentCreated(parentId, node_type) {
|
||||||
|
return `pillar:node:${parentId}:created-${node_type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static globalCreated(node_type) {
|
||||||
|
return `pillar:node:created-${node_type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static updated(nodeId) {
|
||||||
|
return `pillar:node:${nodeId}:updated`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static deleted(nodeId) {
|
||||||
|
return `pillar:node:${nodeId}:deleted`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static loaded() {
|
||||||
|
return `pillar:node:loaded`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trigger(eventName, data) {
|
||||||
|
document.dispatchEvent(new CustomEvent(eventName, {detail: data}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function on(eventName, cb) {
|
||||||
|
document.addEventListener(eventName, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function off(eventName, cb) {
|
||||||
|
document.removeEventListener(eventName, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Nodes {
|
||||||
|
/**
|
||||||
|
* Trigger events that node has been created
|
||||||
|
* @param {Object} node
|
||||||
|
*/
|
||||||
|
static triggerCreated(node) {
|
||||||
|
if (node.parent) {
|
||||||
|
trigger(
|
||||||
|
EventName.parentCreated(node.parent, node.node_type),
|
||||||
|
node);
|
||||||
|
}
|
||||||
|
trigger(
|
||||||
|
EventName.globalCreated(node.node_type),
|
||||||
|
node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notified when new nodes where parent === parentId and node_type === node_type
|
||||||
|
* @param {String} parentId
|
||||||
|
* @param {String} node_type
|
||||||
|
* @param {Function(Event)} cb
|
||||||
|
*/
|
||||||
|
static onParentCreated(parentId, node_type, cb){
|
||||||
|
on(
|
||||||
|
EventName.parentCreated(parentId, node_type),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static offParentCreated(parentId, node_type, cb){
|
||||||
|
off(
|
||||||
|
EventName.parentCreated(parentId, node_type),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notified when new nodes where node_type === node_type is created
|
||||||
|
* @param {String} node_type
|
||||||
|
* @param {Function(Event)} cb
|
||||||
|
*/
|
||||||
|
static onCreated(node_type, cb){
|
||||||
|
on(
|
||||||
|
EventName.globalCreated(node_type),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static offCreated(node_type, cb){
|
||||||
|
off(
|
||||||
|
EventName.globalCreated(node_type),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static triggerUpdated(node) {
|
||||||
|
trigger(
|
||||||
|
EventName.updated(node._id),
|
||||||
|
node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notified when node with _id === nodeId is updated
|
||||||
|
* @param {String} nodeId
|
||||||
|
* @param {Function(Event)} cb
|
||||||
|
*/
|
||||||
|
static onUpdated(nodeId, cb) {
|
||||||
|
on(
|
||||||
|
EventName.updated(nodeId),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static offUpdated(nodeId, cb) {
|
||||||
|
off(
|
||||||
|
EventName.updated(nodeId),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify that node has been deleted.
|
||||||
|
* @param {String} nodeId
|
||||||
|
*/
|
||||||
|
static triggerDeleted(nodeId) {
|
||||||
|
trigger(
|
||||||
|
EventName.deleted(nodeId),
|
||||||
|
nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to events of nodes being deleted where _id === nodeId
|
||||||
|
* @param {String} nodeId
|
||||||
|
* @param {Function(Event)} cb
|
||||||
|
*/
|
||||||
|
static onDeleted(nodeId, cb) {
|
||||||
|
on(
|
||||||
|
EventName.deleted(nodeId),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static offDeleted(nodeId, cb) {
|
||||||
|
off(
|
||||||
|
EventName.deleted(nodeId),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static triggerLoaded(nodeId) {
|
||||||
|
trigger(EventName.loaded(), {nodeId: nodeId});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to events of nodes being loaded for display
|
||||||
|
* @param {Function(Event)} cb
|
||||||
|
*/
|
||||||
|
static onLoaded(cb) {
|
||||||
|
on(EventName.loaded(), cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static offLoaded(cb) {
|
||||||
|
off(EventName.loaded(), cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Nodes }
|
4
src/scripts/js/es6/common/events/init.js
Normal file
4
src/scripts/js/es6/common/events/init.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Collecting Custom Pillar events here
|
||||||
|
*/
|
||||||
|
export {Nodes} from './Nodes'
|
@@ -44,7 +44,13 @@ export class MultiSearch {
|
|||||||
|
|
||||||
thenExecute() {
|
thenExecute() {
|
||||||
let data = JSON.stringify(this.getAllParams());
|
let data = JSON.stringify(this.getAllParams());
|
||||||
let rawAjax = $.getJSON(this.apiUrl, data);
|
let rawAjax = $.ajax({
|
||||||
|
url: this.apiUrl,
|
||||||
|
type: 'POST',
|
||||||
|
data: data,
|
||||||
|
dataType: 'json',
|
||||||
|
contentType: 'application/json; charset=UTF-8'
|
||||||
|
});
|
||||||
let prettyPromise = rawAjax.then(this.parseResult.bind(this));
|
let prettyPromise = rawAjax.then(this.parseResult.bind(this));
|
||||||
prettyPromise['abort'] = rawAjax.abort.bind(rawAjax); // Hack to be able to abort the promise down the road
|
prettyPromise['abort'] = rawAjax.abort.bind(rawAjax); // Hack to be able to abort the promise down the road
|
||||||
return prettyPromise;
|
return prettyPromise;
|
||||||
|
2
src/scripts/js/es6/common/templates/DEPRECATED.md
Normal file
2
src/scripts/js/es6/common/templates/DEPRECATED.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
This module is used to render nodes/users dynamically. It was written before we introduced vue.js into the project.
|
||||||
|
Current best practice is to use vue for this type of work.
|
@@ -2,25 +2,50 @@ import { ComponentCreatorInterface } from './ComponentCreatorInterface'
|
|||||||
|
|
||||||
const REGISTERED_CREATORS = []
|
const REGISTERED_CREATORS = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a jQuery renderable element from a mongo document using registered creators.
|
||||||
|
* @deprecated use vue instead
|
||||||
|
*/
|
||||||
export class Component extends ComponentCreatorInterface {
|
export class Component extends ComponentCreatorInterface {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Object} doc
|
||||||
|
* @returns {$element}
|
||||||
|
*/
|
||||||
static create$listItem(doc) {
|
static create$listItem(doc) {
|
||||||
let creator = Component.getCreator(doc);
|
let creator = Component.getCreator(doc);
|
||||||
return creator.create$listItem(doc);
|
return creator.create$listItem(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} doc
|
||||||
|
* @returns {$element}
|
||||||
|
*/
|
||||||
static create$item(doc) {
|
static create$item(doc) {
|
||||||
let creator = Component.getCreator(doc);
|
let creator = Component.getCreator(doc);
|
||||||
return creator.create$item(doc);
|
return creator.create$item(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} candidate
|
||||||
|
* @returns {Boolean}
|
||||||
|
*/
|
||||||
static canCreate(candidate) {
|
static canCreate(candidate) {
|
||||||
return !!Component.getCreator(candidate);
|
return !!Component.getCreator(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register component creator to handle a node type
|
||||||
|
* @param {ComponentCreatorInterface} creator
|
||||||
|
*/
|
||||||
static regiseterCreator(creator) {
|
static regiseterCreator(creator) {
|
||||||
REGISTERED_CREATORS.push(creator);
|
REGISTERED_CREATORS.push(creator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} doc
|
||||||
|
* @returns {ComponentCreatorInterface}
|
||||||
|
*/
|
||||||
static getCreator(doc) {
|
static getCreator(doc) {
|
||||||
if (doc) {
|
if (doc) {
|
||||||
for (let candidate of REGISTERED_CREATORS) {
|
for (let candidate of REGISTERED_CREATORS) {
|
||||||
|
@@ -1,6 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @deprecated use vue instead
|
||||||
|
*/
|
||||||
export class ComponentCreatorInterface {
|
export class ComponentCreatorInterface {
|
||||||
/**
|
/**
|
||||||
* @param {JSON} doc
|
* Create a $element to render document in a list
|
||||||
|
* @param {Object} doc
|
||||||
* @returns {$element}
|
* @returns {$element}
|
||||||
*/
|
*/
|
||||||
static create$listItem(doc) {
|
static create$listItem(doc) {
|
||||||
@@ -8,8 +12,8 @@ export class ComponentCreatorInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Create a $element to render the full doc
|
||||||
* @param {JSON} doc
|
* @param {Object} doc
|
||||||
* @returns {$element}
|
* @returns {$element}
|
||||||
*/
|
*/
|
||||||
static create$item(doc) {
|
static create$item(doc) {
|
||||||
@@ -17,8 +21,7 @@ export class ComponentCreatorInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* @param {Object} candidate
|
||||||
* @param {JSON} candidate
|
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
static canCreate(candidate) {
|
static canCreate(candidate) {
|
||||||
|
@@ -1,6 +1,10 @@
|
|||||||
import { NodesBase } from "./NodesBase";
|
import { NodesBase } from "./NodesBase";
|
||||||
import { thenLoadVideoProgress } from '../utils';
|
import { thenLoadVideoProgress } from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create $element from a node of type asset
|
||||||
|
* @deprecated use vue instead
|
||||||
|
*/
|
||||||
export class Assets extends NodesBase{
|
export class Assets extends NodesBase{
|
||||||
static create$listItem(node) {
|
static create$listItem(node) {
|
||||||
var markIfPublic = true;
|
var markIfPublic = true;
|
||||||
|
@@ -3,6 +3,10 @@ import { ComponentCreatorInterface } from '../component/ComponentCreatorInterfac
|
|||||||
|
|
||||||
let CREATE_NODE_ITEM_MAP = {}
|
let CREATE_NODE_ITEM_MAP = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create $element from node object
|
||||||
|
* @deprecated use vue instead
|
||||||
|
*/
|
||||||
export class Nodes extends ComponentCreatorInterface {
|
export class Nodes extends ComponentCreatorInterface {
|
||||||
/**
|
/**
|
||||||
* Creates a small list item out of a node document
|
* Creates a small list item out of a node document
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
import { thenLoadImage, prettyDate } from '../utils';
|
import { prettyDate } from '../../utils/prettydate';
|
||||||
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
|
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use vue instead
|
||||||
|
*/
|
||||||
export class NodesBase extends ComponentCreatorInterface {
|
export class NodesBase extends ComponentCreatorInterface {
|
||||||
static create$listItem(node) {
|
static create$listItem(node) {
|
||||||
let nid = (node._id || node.objectID); // To support both mongo and elastic nodes
|
let nid = (node._id || node.objectID); // To support both mongo and elastic nodes
|
||||||
@@ -19,7 +22,7 @@ export class NodesBase extends ComponentCreatorInterface {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$(window).trigger('pillar:workStart');
|
$(window).trigger('pillar:workStart');
|
||||||
thenLoadImage(node.picture)
|
pillar.utils.thenLoadImage(node.picture)
|
||||||
.fail(warnNoPicture)
|
.fail(warnNoPicture)
|
||||||
.then((imgVariation) => {
|
.then((imgVariation) => {
|
||||||
let img = $('<img class="card-img-top">')
|
let img = $('<img class="card-img-top">')
|
||||||
|
@@ -1,10 +1,16 @@
|
|||||||
import { NodesBase } from "./NodesBase";
|
import { NodesBase } from "./NodesBase";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create $element from a node of type post
|
||||||
|
* @deprecated use vue instead
|
||||||
|
*/
|
||||||
export class Posts extends NodesBase {
|
export class Posts extends NodesBase {
|
||||||
static create$item(post) {
|
static create$item(post) {
|
||||||
let content = [];
|
let content = [];
|
||||||
let $title = $('<div>')
|
let $title = $('<a>')
|
||||||
.addClass('h1 text-uppercase mt-4 mb-3')
|
.attr('href', '/nodes/' + post._id + '/redir')
|
||||||
|
.attr('title', post.name)
|
||||||
|
.addClass('h1 text-uppercase font-weight-bold d-block pt-5 pb-2')
|
||||||
.text(post.name);
|
.text(post.name);
|
||||||
content.push($title);
|
content.push($title);
|
||||||
let $post = $('<div>')
|
let $post = $('<div>')
|
||||||
|
@@ -1,7 +1,12 @@
|
|||||||
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
|
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create $elements from user objects
|
||||||
|
* @deprecated use vue instead
|
||||||
|
*/
|
||||||
export class Users extends ComponentCreatorInterface {
|
export class Users extends ComponentCreatorInterface {
|
||||||
static create$listItem(userDoc) {
|
static create$listItem(userDoc) {
|
||||||
|
let roles = userDoc.roles || [];
|
||||||
return $('<div>')
|
return $('<div>')
|
||||||
.addClass('users p-2 border-bottom')
|
.addClass('users p-2 border-bottom')
|
||||||
.attr('data-user-id', userDoc._id || userDoc.objectID )
|
.attr('data-user-id', userDoc._id || userDoc.objectID )
|
||||||
@@ -13,7 +18,7 @@ export class Users extends ComponentCreatorInterface {
|
|||||||
.text(userDoc.username),
|
.text(userDoc.username),
|
||||||
$('<small>')
|
$('<small>')
|
||||||
.addClass('d-block roles text-info')
|
.addClass('d-block roles text-info')
|
||||||
.text(userDoc.roles.join(', '))
|
.text(roles.join(', '))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,122 +1,5 @@
|
|||||||
function thenLoadImage(imgId, size = 'm') {
|
|
||||||
return $.get('/api/files/' + imgId)
|
|
||||||
.then((resp)=> {
|
|
||||||
var show_variation = null;
|
|
||||||
if (typeof resp.variations != 'undefined') {
|
|
||||||
for (var variation of resp.variations) {
|
|
||||||
if (variation.size != size) continue;
|
|
||||||
show_variation = variation;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (show_variation == null) {
|
|
||||||
throw 'Image not found: ' + imgId + ' size: ' + size;
|
|
||||||
}
|
|
||||||
return show_variation;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function thenLoadVideoProgress(nodeId) {
|
function thenLoadVideoProgress(nodeId) {
|
||||||
return $.get('/api/users/video/' + nodeId + '/progress')
|
return $.get('/api/users/video/' + nodeId + '/progress')
|
||||||
}
|
}
|
||||||
|
|
||||||
function prettyDate(time, detail=false) {
|
export { thenLoadVideoProgress };
|
||||||
/**
|
|
||||||
* time is anything Date can parse, and we return a
|
|
||||||
pretty string like 'an hour ago', 'Yesterday', '3 months ago',
|
|
||||||
'just now', etc
|
|
||||||
*/
|
|
||||||
let theDate = new Date(time);
|
|
||||||
if (!time || isNaN(theDate)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let pretty = '';
|
|
||||||
let now = new Date(Date.now()); // Easier to mock Date.now() in tests
|
|
||||||
let second_diff = Math.round((now - theDate) / 1000);
|
|
||||||
|
|
||||||
let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24)
|
|
||||||
|
|
||||||
if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) {
|
|
||||||
// "Jul 16, 2018"
|
|
||||||
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
|
|
||||||
}
|
|
||||||
else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) {
|
|
||||||
// "Jul 16"
|
|
||||||
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
|
|
||||||
}
|
|
||||||
else if (day_diff < -7){
|
|
||||||
let week_count = Math.round(-day_diff / 7);
|
|
||||||
if (week_count == 1)
|
|
||||||
pretty = "in 1 week";
|
|
||||||
else
|
|
||||||
pretty = "in " + week_count +" weeks";
|
|
||||||
}
|
|
||||||
else if (day_diff < -1)
|
|
||||||
// "next Tuesday"
|
|
||||||
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
|
|
||||||
else if (day_diff === 0) {
|
|
||||||
if (second_diff < 0) {
|
|
||||||
let seconds = Math.abs(second_diff);
|
|
||||||
if (seconds < 10)
|
|
||||||
return 'just now';
|
|
||||||
if (seconds < 60)
|
|
||||||
return 'in ' + seconds +'s';
|
|
||||||
if (seconds < 120)
|
|
||||||
return 'in a minute';
|
|
||||||
if (seconds < 3600)
|
|
||||||
return 'in ' + Math.round(seconds / 60) + 'm';
|
|
||||||
if (seconds < 7200)
|
|
||||||
return 'in an hour';
|
|
||||||
if (seconds < 86400)
|
|
||||||
return 'in ' + Math.round(seconds / 3600) + 'h';
|
|
||||||
} else {
|
|
||||||
let seconds = second_diff;
|
|
||||||
if (seconds < 10)
|
|
||||||
return "just now";
|
|
||||||
if (seconds < 60)
|
|
||||||
return seconds + "s ago";
|
|
||||||
if (seconds < 120)
|
|
||||||
return "a minute ago";
|
|
||||||
if (seconds < 3600)
|
|
||||||
return Math.round(seconds / 60) + "m ago";
|
|
||||||
if (seconds < 7200)
|
|
||||||
return "an hour ago";
|
|
||||||
if (seconds < 86400)
|
|
||||||
return Math.round(seconds / 3600) + "h ago";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
else if (day_diff == 1)
|
|
||||||
pretty = "yesterday";
|
|
||||||
|
|
||||||
else if (day_diff <= 7)
|
|
||||||
// "last Tuesday"
|
|
||||||
pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
|
|
||||||
|
|
||||||
else if (day_diff <= 22) {
|
|
||||||
let week_count = Math.round(day_diff / 7);
|
|
||||||
if (week_count == 1)
|
|
||||||
pretty = "1 week ago";
|
|
||||||
else
|
|
||||||
pretty = week_count + " weeks ago";
|
|
||||||
}
|
|
||||||
else if (theDate.getFullYear() === now.getFullYear())
|
|
||||||
// "Jul 16"
|
|
||||||
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
|
|
||||||
|
|
||||||
else
|
|
||||||
// "Jul 16", 2009
|
|
||||||
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
|
|
||||||
|
|
||||||
if (detail){
|
|
||||||
// "Tuesday at 04:20"
|
|
||||||
let paddedHour = ('00' + theDate.getUTCHours()).substr(-2);
|
|
||||||
let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2);
|
|
||||||
return pretty + ' at ' + paddedHour + ':' + paddedMin;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pretty;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { thenLoadImage, thenLoadVideoProgress, prettyDate };
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { prettyDate } from '../utils'
|
import { prettyDate } from '../init'
|
||||||
|
|
||||||
describe('prettydate', () => {
|
describe('prettydate', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -28,7 +28,7 @@ describe('prettydate', () => {
|
|||||||
expect(pd({minutes: -5, detailed: true})).toBe('5m ago')
|
expect(pd({minutes: -5, detailed: true})).toBe('5m ago')
|
||||||
expect(pd({days: -7, detailed: true})).toBe('last Tuesday at 11:46')
|
expect(pd({days: -7, detailed: true})).toBe('last Tuesday at 11:46')
|
||||||
expect(pd({days: -8, detailed: true})).toBe('1 week ago at 11:46')
|
expect(pd({days: -8, detailed: true})).toBe('1 week ago at 11:46')
|
||||||
// summer time bellow
|
// summer time below
|
||||||
expect(pd({days: -14, detailed: true})).toBe('2 weeks ago at 10:46')
|
expect(pd({days: -14, detailed: true})).toBe('2 weeks ago at 10:46')
|
||||||
expect(pd({days: -31, detailed: true})).toBe('8 Oct at 10:46')
|
expect(pd({days: -31, detailed: true})).toBe('8 Oct at 10:46')
|
||||||
expect(pd({days: -(31 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46')
|
expect(pd({days: -(31 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46')
|
34
src/scripts/js/es6/common/utils/currentuser.js
Normal file
34
src/scripts/js/es6/common/utils/currentuser.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
class User{
|
||||||
|
constructor(kwargs) {
|
||||||
|
this.user_id = kwargs['user_id'] || '';
|
||||||
|
this.username = kwargs['username'] || '';
|
||||||
|
this.full_name = kwargs['full_name'] || '';
|
||||||
|
this.gravatar = kwargs['gravatar'] || '';
|
||||||
|
this.email = kwargs['email'] || '';
|
||||||
|
this.capabilities = kwargs['capabilities'] || [];
|
||||||
|
this.badges_html = kwargs['badges_html'] || '';
|
||||||
|
this.is_authenticated = kwargs['is_authenticated'] || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* """Returns True iff the user has one or more of the given capabilities."""
|
||||||
|
* @param {...String} args
|
||||||
|
*/
|
||||||
|
hasCap(...args) {
|
||||||
|
for(let cap of args) {
|
||||||
|
if (this.capabilities.indexOf(cap) != -1) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentUser;
|
||||||
|
function initCurrentUser(kwargs){
|
||||||
|
currentUser = new User(kwargs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentUser() {
|
||||||
|
return currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getCurrentUser, initCurrentUser }
|
20
src/scripts/js/es6/common/utils/files.js
Normal file
20
src/scripts/js/es6/common/utils/files.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
function thenLoadImage(imgId, size = 'm') {
|
||||||
|
return $.get('/api/files/' + imgId)
|
||||||
|
.then((resp)=> {
|
||||||
|
var show_variation = null;
|
||||||
|
if (typeof resp.variations != 'undefined') {
|
||||||
|
for (var variation of resp.variations) {
|
||||||
|
if (variation.size != size) continue;
|
||||||
|
show_variation = variation;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (show_variation == null) {
|
||||||
|
throw 'Image not found: ' + imgId + ' size: ' + size;
|
||||||
|
}
|
||||||
|
return show_variation;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { thenLoadImage }
|
@@ -1 +1,36 @@
|
|||||||
export { transformPlaceholder } from './placeholder'
|
export { transformPlaceholder } from './placeholder'
|
||||||
|
export { prettyDate } from './prettydate'
|
||||||
|
export { getCurrentUser, initCurrentUser } from './currentuser'
|
||||||
|
export { thenLoadImage } from './files'
|
||||||
|
|
||||||
|
|
||||||
|
export function debounced(fn, delay=1000) {
|
||||||
|
let timerId;
|
||||||
|
return function (...args) {
|
||||||
|
if (timerId) {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
timerId = setTimeout(() => {
|
||||||
|
fn(...args);
|
||||||
|
timerId = null;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts error message from error of type String, Error or xhrError
|
||||||
|
* @param {*} err
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
export function messageFromError(err){
|
||||||
|
if (typeof err === "string") {
|
||||||
|
// type String
|
||||||
|
return err;
|
||||||
|
} else if(typeof err.message === "string") {
|
||||||
|
// type Error
|
||||||
|
return err.message;
|
||||||
|
} else {
|
||||||
|
// type xhr probably
|
||||||
|
return xhrErrorResponseMessage(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
97
src/scripts/js/es6/common/utils/prettydate.js
Normal file
97
src/scripts/js/es6/common/utils/prettydate.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
export function prettyDate(time, detail=false) {
|
||||||
|
/**
|
||||||
|
* time is anything Date can parse, and we return a
|
||||||
|
pretty string like 'an hour ago', 'Yesterday', '3 months ago',
|
||||||
|
'just now', etc
|
||||||
|
*/
|
||||||
|
let theDate = new Date(time);
|
||||||
|
if (!time || isNaN(theDate)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let pretty = '';
|
||||||
|
let now = new Date(Date.now()); // Easier to mock Date.now() in tests
|
||||||
|
let second_diff = Math.round((now - theDate) / 1000);
|
||||||
|
|
||||||
|
let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24)
|
||||||
|
|
||||||
|
if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) {
|
||||||
|
// "Jul 16, 2018"
|
||||||
|
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
|
||||||
|
}
|
||||||
|
else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) {
|
||||||
|
// "Jul 16"
|
||||||
|
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
|
||||||
|
}
|
||||||
|
else if (day_diff < -7){
|
||||||
|
let week_count = Math.round(-day_diff / 7);
|
||||||
|
if (week_count == 1)
|
||||||
|
pretty = "in 1 week";
|
||||||
|
else
|
||||||
|
pretty = "in " + week_count +" weeks";
|
||||||
|
}
|
||||||
|
else if (day_diff < 0)
|
||||||
|
// "next Tuesday"
|
||||||
|
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
|
||||||
|
else if (day_diff === 0) {
|
||||||
|
if (second_diff < 0) {
|
||||||
|
let seconds = Math.abs(second_diff);
|
||||||
|
if (seconds < 10)
|
||||||
|
return 'just now';
|
||||||
|
if (seconds < 60)
|
||||||
|
return 'in ' + seconds +'s';
|
||||||
|
if (seconds < 120)
|
||||||
|
return 'in a minute';
|
||||||
|
if (seconds < 3600)
|
||||||
|
return 'in ' + Math.round(seconds / 60) + 'm';
|
||||||
|
if (seconds < 7200)
|
||||||
|
return 'in an hour';
|
||||||
|
if (seconds < 86400)
|
||||||
|
return 'in ' + Math.round(seconds / 3600) + 'h';
|
||||||
|
} else {
|
||||||
|
let seconds = second_diff;
|
||||||
|
if (seconds < 10)
|
||||||
|
return "just now";
|
||||||
|
if (seconds < 60)
|
||||||
|
return seconds + "s ago";
|
||||||
|
if (seconds < 120)
|
||||||
|
return "a minute ago";
|
||||||
|
if (seconds < 3600)
|
||||||
|
return Math.round(seconds / 60) + "m ago";
|
||||||
|
if (seconds < 7200)
|
||||||
|
return "an hour ago";
|
||||||
|
if (seconds < 86400)
|
||||||
|
return Math.round(seconds / 3600) + "h ago";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
else if (day_diff == 1)
|
||||||
|
pretty = "yesterday";
|
||||||
|
|
||||||
|
else if (day_diff <= 7)
|
||||||
|
// "last Tuesday"
|
||||||
|
pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
|
||||||
|
|
||||||
|
else if (day_diff <= 22) {
|
||||||
|
let week_count = Math.round(day_diff / 7);
|
||||||
|
if (week_count == 1)
|
||||||
|
pretty = "1 week ago";
|
||||||
|
else
|
||||||
|
pretty = week_count + " weeks ago";
|
||||||
|
}
|
||||||
|
else if (theDate.getFullYear() === now.getFullYear())
|
||||||
|
// "Jul 16"
|
||||||
|
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
|
||||||
|
|
||||||
|
else
|
||||||
|
// "Jul 16", 2009
|
||||||
|
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
|
||||||
|
|
||||||
|
if (detail){
|
||||||
|
// "Tuesday at 04:20"
|
||||||
|
let paddedHour = ('00' + theDate.getUTCHours()).substr(-2);
|
||||||
|
let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2);
|
||||||
|
return pretty + ' at ' + paddedHour + ':' + paddedMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pretty;
|
||||||
|
}
|
35
src/scripts/js/es6/common/vuecomponents/README.md
Normal file
35
src/scripts/js/es6/common/vuecomponents/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Vue components
|
||||||
|
[Vue.js](https://vuejs.org/) is a javascript framework for writing interactive ui components.
|
||||||
|
Vue.js is packed into tutti.js, and hence available site wide.
|
||||||
|
|
||||||
|
### Absolute must read
|
||||||
|
- https://vuejs.org/v2/api/#Options-Data
|
||||||
|
- https://vuejs.org/v2/api/#v-bind
|
||||||
|
- https://vuejs.org/v2/api/#v-model
|
||||||
|
- https://vuejs.org/v2/guide/conditional.html
|
||||||
|
- https://vuejs.org/v2/guide/list.html#v-for-with-an-Object
|
||||||
|
- https://vuejs.org/v2/api/#vm-emit
|
||||||
|
- https://vuejs.org/v2/api/#v-on
|
||||||
|
|
||||||
|
### Styling and animation of components
|
||||||
|
- https://vuejs.org/v2/guide/class-and-style.html#Binding-HTML-Classes
|
||||||
|
- https://vuejs.org/v2/guide/transitions.html
|
||||||
|
|
||||||
|
### More advanced, but important topics
|
||||||
|
- https://vuejs.org/v2/api/#is
|
||||||
|
- https://vuejs.org/v2/guide/components-slots.html#Slot-Content
|
||||||
|
- https://vuejs.org/v2/guide/mixins.html
|
||||||
|
|
||||||
|
### Rule of thumbs
|
||||||
|
- [Have a dash in your component name](https://vuejs.org/v2/guide/components-registration.html#Component-Names)
|
||||||
|
- Have one prop binding per line in component templates.
|
||||||
|
~~~
|
||||||
|
// Good!
|
||||||
|
<my-component
|
||||||
|
:propA="propX"
|
||||||
|
:propB="propY"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Bad!
|
||||||
|
<my-component :propA="propX" :propB="propY"/>
|
||||||
|
~~~
|
@@ -0,0 +1,52 @@
|
|||||||
|
const TEMPLATE = `
|
||||||
|
<div class='breadcrumbs' v-if="breadcrumbs.length">
|
||||||
|
<ul>
|
||||||
|
<li v-for="crumb in breadcrumbs">
|
||||||
|
<a :href="crumb.url" v-if="!crumb._self" @click.prevent="navigateToNode(crumb._id)">{{ crumb.name }}</a>
|
||||||
|
<span v-else>{{ crumb.name }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
Vue.component("node-breadcrumbs", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
created() {
|
||||||
|
this.loadBreadcrumbs();
|
||||||
|
pillar.events.Nodes.onLoaded(event => {
|
||||||
|
this.nodeId = event.detail.nodeId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
nodeId: String,
|
||||||
|
},
|
||||||
|
data() { return {
|
||||||
|
breadcrumbs: [],
|
||||||
|
}},
|
||||||
|
watch: {
|
||||||
|
nodeId() {
|
||||||
|
this.loadBreadcrumbs();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadBreadcrumbs() {
|
||||||
|
// The node ID may not exist (when at project level, for example).
|
||||||
|
if (!this.nodeId) {
|
||||||
|
this.breadcrumbs = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$.get(`/nodes/${this.nodeId}/breadcrumbs`)
|
||||||
|
.done(data => {
|
||||||
|
this.breadcrumbs = data.breadcrumbs;
|
||||||
|
})
|
||||||
|
.fail(error => {
|
||||||
|
toastr.error(xhrErrorResponseMessage(error), "Unable to load breadcrumbs");
|
||||||
|
})
|
||||||
|
;
|
||||||
|
},
|
||||||
|
navigateToNode(nodeId) {
|
||||||
|
this.$emit('navigate', nodeId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
@@ -0,0 +1,120 @@
|
|||||||
|
import { thenGetFileDocument, getFileVariation } from '../../api/files'
|
||||||
|
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
|
||||||
|
|
||||||
|
const VALID_NAME_REGEXP = /[a-zA-Z0-9_\-]+/g;
|
||||||
|
const NON_VALID_NAME_REGEXP = /[^a-zA-Z0-9_\-]+/g;
|
||||||
|
const TEMPLATE = `
|
||||||
|
<div class="attachment"
|
||||||
|
:class="{error: !isSlugOk}"
|
||||||
|
>
|
||||||
|
<div class="thumbnail-container"
|
||||||
|
@click="$emit('insert', oid)"
|
||||||
|
title="Click to add to comment"
|
||||||
|
>
|
||||||
|
<i :class="thumbnailBackup"
|
||||||
|
v-show="!thumbnail"
|
||||||
|
/>
|
||||||
|
<img class="preview-thumbnail"
|
||||||
|
v-if="!!thumbnail"
|
||||||
|
:src="thumbnail"
|
||||||
|
width=50
|
||||||
|
height=50
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input class="form-control"
|
||||||
|
title="Slug"
|
||||||
|
v-model="newSlug"
|
||||||
|
/>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="action delete"
|
||||||
|
@click="$emit('delete', oid)"
|
||||||
|
>
|
||||||
|
<i class="pi-trash"/>
|
||||||
|
Delete
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Vue.component('comment-attachment-editor', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
mixins: [UnitOfWorkTracker],
|
||||||
|
props: {
|
||||||
|
slug: String,
|
||||||
|
allSlugs: Array,
|
||||||
|
oid: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
newSlug: this.slug,
|
||||||
|
thumbnail: '',
|
||||||
|
thumbnailBackup: 'pi-spin spin',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isValidAttachmentName() {
|
||||||
|
let regexpMatch = this.slug.match(VALID_NAME_REGEXP);
|
||||||
|
return !!regexpMatch && regexpMatch.length === 1 && regexpMatch[0] === this.slug;
|
||||||
|
},
|
||||||
|
isUnique() {
|
||||||
|
let countOccurrences = 0;
|
||||||
|
for (let s of this.allSlugs) {
|
||||||
|
// Don't worry about unicode. isValidAttachmentName denies those anyway
|
||||||
|
if (s.toUpperCase() === this.slug.toUpperCase()) {
|
||||||
|
countOccurrences++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return countOccurrences === 1;
|
||||||
|
},
|
||||||
|
isSlugOk() {
|
||||||
|
return this.isValidAttachmentName && this.isUnique;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
newSlug(newValue, oldValue) {
|
||||||
|
this.$emit('rename', newValue, this.oid);
|
||||||
|
},
|
||||||
|
isSlugOk(newValue, oldValue) {
|
||||||
|
this.$emit('validation', this.oid, newValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.newSlug = this.makeSafeAttachmentString(this.slug);
|
||||||
|
this.$emit('validation', this.oid, this.isSlugOk);
|
||||||
|
|
||||||
|
this.unitOfWork(
|
||||||
|
thenGetFileDocument(this.oid)
|
||||||
|
.then((fileDoc) => {
|
||||||
|
let content_type = fileDoc.content_type
|
||||||
|
if (content_type.startsWith('image')) {
|
||||||
|
try {
|
||||||
|
let imgFile = getFileVariation(fileDoc, 's');
|
||||||
|
this.thumbnail = imgFile.link;
|
||||||
|
} catch (error) {
|
||||||
|
this.thumbnailBackup = 'pi-image';
|
||||||
|
}
|
||||||
|
} else if(content_type.startsWith('video')) {
|
||||||
|
this.thumbnailBackup = 'pi-video';
|
||||||
|
} else {
|
||||||
|
this.thumbnailBackup = 'pi-file';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Replaces all spaces with underscore and removes all o
|
||||||
|
* @param {String} unsafe
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
makeSafeAttachmentString(unsafe) {
|
||||||
|
let candidate = (unsafe);
|
||||||
|
let matchSpace = / /g;
|
||||||
|
candidate = candidate
|
||||||
|
.replace(matchSpace, '_')
|
||||||
|
.replace(NON_VALID_NAME_REGEXP, '')
|
||||||
|
|
||||||
|
return candidate || `${this.oid}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
168
src/scripts/js/es6/common/vuecomponents/comments/Comment.js
Normal file
168
src/scripts/js/es6/common/vuecomponents/comments/Comment.js
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import '../user/Avatar'
|
||||||
|
import '../utils/PrettyCreated'
|
||||||
|
import './CommentEditor'
|
||||||
|
import './Rating'
|
||||||
|
import { Linkable } from '../mixins/Linkable'
|
||||||
|
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
|
||||||
|
import { EventBus, Events } from './EventBus'
|
||||||
|
|
||||||
|
const TEMPLATE = `
|
||||||
|
<div class="comment-branch">
|
||||||
|
<div class="comment-container"
|
||||||
|
:class="{'is-first': !isReply, 'is-reply': isReply, 'comment-linked': isLinked}"
|
||||||
|
:id="comment.id">
|
||||||
|
<div class="comment-avatar">
|
||||||
|
<user-avatar
|
||||||
|
:user="comment.user"
|
||||||
|
/>
|
||||||
|
<div class="user-badges"
|
||||||
|
v-html="comment.user.badges_html">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">
|
||||||
|
<div class="comment-body"
|
||||||
|
v-if="!isUpdating"
|
||||||
|
>
|
||||||
|
<p class="comment-author">
|
||||||
|
{{ comment.user.full_name }}
|
||||||
|
</p>
|
||||||
|
<span class="comment-msg">
|
||||||
|
<p v-html="comment.msg_html"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<comment-editor
|
||||||
|
v-if="isUpdating"
|
||||||
|
@unit-of-work="childUnitOfWork"
|
||||||
|
:mode="editorMode"
|
||||||
|
:comment="comment"
|
||||||
|
:user="user"
|
||||||
|
:parentId="comment.id"
|
||||||
|
/>
|
||||||
|
<div class="comment-meta">
|
||||||
|
<comment-rating
|
||||||
|
:comment="comment"
|
||||||
|
@unit-of-work="childUnitOfWork"
|
||||||
|
/>
|
||||||
|
<div class="comment-action">
|
||||||
|
<span class="action" title="Reply to this comment"
|
||||||
|
v-if="canReply"
|
||||||
|
@click="showReplyEditor"
|
||||||
|
>
|
||||||
|
Reply
|
||||||
|
</span>
|
||||||
|
<span class="action" title="Edit comment"
|
||||||
|
v-if="canUpdate"
|
||||||
|
@click="showUpdateEditor"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</span>
|
||||||
|
<span class="action" title="Cancel changes"
|
||||||
|
v-if="canCancel"
|
||||||
|
@click="cancleEdit"
|
||||||
|
>
|
||||||
|
<i class="pi-cancel"></i>Cancel
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pretty-created
|
||||||
|
:created="comment.created"
|
||||||
|
:updated="comment.updated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comment-reply-container is-reply"
|
||||||
|
v-if="isReplying"
|
||||||
|
>
|
||||||
|
<user-avatar
|
||||||
|
:user="user"
|
||||||
|
/>
|
||||||
|
<comment-editor
|
||||||
|
v-if="isReplying"
|
||||||
|
@unit-of-work="childUnitOfWork"
|
||||||
|
:mode="editorMode"
|
||||||
|
:comment="comment"
|
||||||
|
:user="user"
|
||||||
|
:parentId="comment.id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="comments-list">
|
||||||
|
<comment
|
||||||
|
v-for="c in comment.replies"
|
||||||
|
@unit-of-work="childUnitOfWork"
|
||||||
|
:isReply="true"
|
||||||
|
:readOnly="readOnly"
|
||||||
|
:comment="c"
|
||||||
|
:user="user"
|
||||||
|
:key="c.id"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Vue.component('comment', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
mixins: [Linkable, UnitOfWorkTracker],
|
||||||
|
props: {
|
||||||
|
user: Object,
|
||||||
|
comment: Object,
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isReply: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isReplying: false,
|
||||||
|
isUpdating: false,
|
||||||
|
id: this.comment.id,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
canUpdate() {
|
||||||
|
return !this.readOnly && this.comment.user.id === this.user.user_id && !this.isUpdating && !this.isReplying;
|
||||||
|
},
|
||||||
|
canReply() {
|
||||||
|
return !this.readOnly && !this.isUpdating && !this.isReplying;
|
||||||
|
},
|
||||||
|
canCancel() {
|
||||||
|
return this.isReplying || this.isUpdating;
|
||||||
|
},
|
||||||
|
editorMode() {
|
||||||
|
if(this.isReplying) {
|
||||||
|
return 'reply';
|
||||||
|
}
|
||||||
|
if(this.isUpdating) {
|
||||||
|
return 'update';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
EventBus.$on(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
|
||||||
|
EventBus.$on(Events.EDIT_DONE, this.doHideEditors);
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
|
||||||
|
EventBus.$off(Events.EDIT_DONE, this.doHideEditors);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
showReplyEditor() {
|
||||||
|
EventBus.$emit(Events.BEFORE_SHOW_EDITOR, this.comment.id );
|
||||||
|
this.isReplying = true;
|
||||||
|
},
|
||||||
|
showUpdateEditor() {
|
||||||
|
EventBus.$emit(Events.BEFORE_SHOW_EDITOR, this.comment.id );
|
||||||
|
this.isUpdating = true;
|
||||||
|
},
|
||||||
|
cancleEdit() {
|
||||||
|
this.doHideEditors();
|
||||||
|
EventBus.$emit(Events.EDIT_DONE);
|
||||||
|
},
|
||||||
|
doHideEditors() {
|
||||||
|
this.isReplying = false;
|
||||||
|
this.isUpdating = false;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
@@ -0,0 +1,331 @@
|
|||||||
|
import '../utils/MarkdownPreview'
|
||||||
|
import './AttachmentEditor'
|
||||||
|
import './UploadProgress'
|
||||||
|
import { thenCreateComment, thenUpdateComment } from '../../api/comments'
|
||||||
|
import { thenUploadFile } from '../../api/files'
|
||||||
|
import { Droptarget } from '../mixins/Droptarget'
|
||||||
|
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
|
||||||
|
import { EventBus, Events } from './EventBus'
|
||||||
|
|
||||||
|
const MAX_ATTACHMENTS = 5;
|
||||||
|
|
||||||
|
const TEMPLATE =`
|
||||||
|
<div class="comment-reply-form"
|
||||||
|
:class="dropTargetClasses"
|
||||||
|
>
|
||||||
|
<div class="attachments">
|
||||||
|
<comment-attachment-editor
|
||||||
|
v-for="a in attachments"
|
||||||
|
@delete="attachmentDelete"
|
||||||
|
@insert="insertAttachment"
|
||||||
|
@rename="attachmentRename"
|
||||||
|
@validation="attachmentValidation"
|
||||||
|
@unit-of-work="childUnitOfWork"
|
||||||
|
:slug="a.slug"
|
||||||
|
:allSlugs="allSlugs"
|
||||||
|
:oid="a.oid"
|
||||||
|
:key="a.oid"
|
||||||
|
/>
|
||||||
|
<upload-progress
|
||||||
|
v-if="uploads.nbrOfActive > 0"
|
||||||
|
:label="uploadProgressLabel"
|
||||||
|
:progress="uploadProgressPercent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="comment-reply-field"
|
||||||
|
:class="{filled: isMsgLongEnough}"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref="inputField"
|
||||||
|
@keyup="keyUp"
|
||||||
|
v-model="msg"
|
||||||
|
id="comment_field"
|
||||||
|
placeholder="Join the conversation...">
|
||||||
|
</textarea>
|
||||||
|
<div class="comment-reply-meta">
|
||||||
|
<button class="comment-action-submit"
|
||||||
|
:class="{disabled: !canSubmit}"
|
||||||
|
@click="submit"
|
||||||
|
type="button"
|
||||||
|
title="Post Comment (Ctrl+Enter)">
|
||||||
|
<span>
|
||||||
|
<i :class="submitButtonIcon"/>{{ submitButtonText }}
|
||||||
|
</span>
|
||||||
|
<span class="hotkey">Ctrl + Enter</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<markdown-preview
|
||||||
|
v-show="msg.length > 0"
|
||||||
|
:markdown="msg"
|
||||||
|
:attachments="attachmentsAsObject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Vue.component('comment-editor', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
mixins: [Droptarget, UnitOfWorkTracker],
|
||||||
|
props: {
|
||||||
|
user: Object,
|
||||||
|
parentId: String,
|
||||||
|
projectId: String,
|
||||||
|
comment: Object,
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'reply', // reply or update
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
msg: this.initialMsg(),
|
||||||
|
attachments: this.initialAttachments(),
|
||||||
|
uploads: {
|
||||||
|
nbrOfActive: 0,
|
||||||
|
nbrOfTotal: 0,
|
||||||
|
total: 0,
|
||||||
|
loaded: 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
submitButtonText() {
|
||||||
|
switch(this.mode) {
|
||||||
|
case 'reply': return 'Send';
|
||||||
|
case 'update': return 'Update';
|
||||||
|
default: console.error('Unknown mode: ', this.mode);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submitButtonIcon() {
|
||||||
|
if (this.isBusyWorking) {
|
||||||
|
return 'pi-spin spin';
|
||||||
|
}else{
|
||||||
|
switch(this.mode) {
|
||||||
|
case 'reply': return 'pi-paper-plane';
|
||||||
|
case 'update': return 'pi-check';
|
||||||
|
default: console.error('Unknown mode: ', this.mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
attachmentsAsObject() {
|
||||||
|
let attachmentsObject = {};
|
||||||
|
for (let a of this.attachments) {
|
||||||
|
attachmentsObject[a.slug] = {oid: a.oid};
|
||||||
|
}
|
||||||
|
return attachmentsObject;
|
||||||
|
},
|
||||||
|
allSlugs() {
|
||||||
|
return this.attachments.map((a) => {
|
||||||
|
return a['slug'];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isMsgLongEnough() {
|
||||||
|
return this.msg.length >= 5;
|
||||||
|
},
|
||||||
|
isAttachmentsValid() {
|
||||||
|
for (let att of this.attachments) {
|
||||||
|
if(!att.isSlugValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
isValid() {
|
||||||
|
return this.isAttachmentsValid && this.isMsgLongEnough;
|
||||||
|
},
|
||||||
|
canSubmit() {
|
||||||
|
return this.isValid && !this.isBusyWorking;
|
||||||
|
},
|
||||||
|
uploadProgressPercent() {
|
||||||
|
if (this.uploads.nbrOfActive === 0 || this.uploads.total === 0) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return this.uploads.loaded / this.uploads.total * 100;
|
||||||
|
},
|
||||||
|
uploadProgressLabel() {
|
||||||
|
if (this.uploadProgressPercent === 100) {
|
||||||
|
return 'Processing'
|
||||||
|
}
|
||||||
|
if (this.uploads.nbrOfTotal === 1) {
|
||||||
|
return 'Uploading file';
|
||||||
|
} else {
|
||||||
|
let fileOf = this.uploads.nbrOfTotal - this.uploads.nbrOfActive + 1;
|
||||||
|
return `Uploading ${fileOf}/${this.uploads.nbrOfTotal} files`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch:{
|
||||||
|
msg(){
|
||||||
|
this.autoSizeInputField();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if(this.comment) {
|
||||||
|
this.$nextTick(function () {
|
||||||
|
this.autoSizeInputField();
|
||||||
|
this.$refs.inputField.focus();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
initialMsg() {
|
||||||
|
if (this.comment) {
|
||||||
|
if (this.mode === 'reply') {
|
||||||
|
return `***@${this.comment.user.full_name}*** `;
|
||||||
|
}
|
||||||
|
if (this.mode === 'update') {
|
||||||
|
return this.comment.msg_markdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
initialAttachments() {
|
||||||
|
// Transforming the attacmentobject to an array of attachments
|
||||||
|
let attachmentsList = []
|
||||||
|
if(this.mode === 'update') {
|
||||||
|
let attachmentsObj = this.comment.properties.attachments
|
||||||
|
for (let k in attachmentsObj) {
|
||||||
|
if (attachmentsObj.hasOwnProperty(k)) {
|
||||||
|
let a = {
|
||||||
|
slug: k,
|
||||||
|
oid: attachmentsObj[k]['oid'],
|
||||||
|
isSlugValid: true
|
||||||
|
}
|
||||||
|
attachmentsList.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attachmentsList;
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
if(!this.canSubmit) return;
|
||||||
|
this.unitOfWork(
|
||||||
|
this.thenSubmit()
|
||||||
|
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to submit comment')})
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
EventBus.$emit(Events.EDIT_DONE);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
thenSubmit() {
|
||||||
|
if (this.mode === 'reply') {
|
||||||
|
return this.thenCreateComment();
|
||||||
|
} else {
|
||||||
|
return this.thenUpdateComment();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyUp(e) {
|
||||||
|
if ((e.keyCode == 13 || e.key === 'Enter') && e.ctrlKey) {
|
||||||
|
this.submit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
thenCreateComment() {
|
||||||
|
return thenCreateComment(this.parentId, this.msg, this.attachmentsAsObject)
|
||||||
|
.then((newComment) => {
|
||||||
|
EventBus.$emit(Events.NEW_COMMENT, newComment);
|
||||||
|
this.cleanUp();
|
||||||
|
})
|
||||||
|
},
|
||||||
|
thenUpdateComment() {
|
||||||
|
return thenUpdateComment(this.comment.parent, this.comment.id, this.msg, this.attachmentsAsObject)
|
||||||
|
.then((updatedComment) => {
|
||||||
|
EventBus.$emit(Events.UPDATED_COMMENT, updatedComment);
|
||||||
|
this.cleanUp();
|
||||||
|
})
|
||||||
|
},
|
||||||
|
canHandleDrop(event) {
|
||||||
|
let dataTransfer = event.dataTransfer;
|
||||||
|
let items = [...dataTransfer.items];
|
||||||
|
let nbrOfAttachments = items.length + this.uploads.nbrOfActive + this.attachments.length;
|
||||||
|
if(nbrOfAttachments > MAX_ATTACHMENTS) {
|
||||||
|
// Exceeds the limit
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Only files in drop
|
||||||
|
return [...dataTransfer.items].reduce((prev, it) => {
|
||||||
|
let isFile = it.kind === 'file' && !!it.type;
|
||||||
|
return prev && isFile;
|
||||||
|
}, !!items.length);
|
||||||
|
},
|
||||||
|
onDrop(event) {
|
||||||
|
let files = [...event.dataTransfer.files];
|
||||||
|
for (let f of files) {
|
||||||
|
this.unitOfWork(
|
||||||
|
this.thenUploadFile(f)
|
||||||
|
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'File upload failed')})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
thenUploadFile(file){
|
||||||
|
let lastReportedTotal = 0;
|
||||||
|
let lastReportedLoaded = 0;
|
||||||
|
let progressCB = (total, loaded) => {
|
||||||
|
this.uploads.loaded += loaded - lastReportedLoaded;
|
||||||
|
this.uploads.total += total - lastReportedTotal;
|
||||||
|
lastReportedLoaded = loaded;
|
||||||
|
lastReportedTotal = total;
|
||||||
|
}
|
||||||
|
this.uploads.nbrOfActive++;
|
||||||
|
this.uploads.nbrOfTotal++;
|
||||||
|
return thenUploadFile(this.projectId || this.comment.project, file, progressCB)
|
||||||
|
.then((resp) => {
|
||||||
|
let attachment = {
|
||||||
|
slug: file.name,
|
||||||
|
oid: resp['file_id'],
|
||||||
|
isSlugValid: false
|
||||||
|
}
|
||||||
|
this.attachments.push(attachment);
|
||||||
|
this.msg += this.getAttachmentMarkdown(attachment);
|
||||||
|
})
|
||||||
|
.always(()=>{
|
||||||
|
this.uploads.nbrOfActive--;
|
||||||
|
if(this.uploads.nbrOfActive === 0) {
|
||||||
|
this.uploads.loaded = 0;
|
||||||
|
this.uploads.total = 0;
|
||||||
|
this.uploads.nbrOfTotal = 0;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getAttachmentMarkdown(attachment){
|
||||||
|
return `{attachment ${attachment.slug}}`;
|
||||||
|
},
|
||||||
|
insertAttachment(oid){
|
||||||
|
let attachment = this.getAttachment(oid);
|
||||||
|
this.msg += this.getAttachmentMarkdown(attachment);
|
||||||
|
},
|
||||||
|
attachmentDelete(oid) {
|
||||||
|
let attachment = this.getAttachment(oid);
|
||||||
|
let markdownToRemove = this.getAttachmentMarkdown(attachment);
|
||||||
|
this.msg = this.msg.replace(new RegExp(markdownToRemove,'g'), '');
|
||||||
|
this.attachments = this.attachments.filter((a) => {return a.oid !== oid});
|
||||||
|
},
|
||||||
|
attachmentRename(newName, oid) {
|
||||||
|
let attachment = this.getAttachment(oid);
|
||||||
|
let oldMarkdownAttachment = this.getAttachmentMarkdown(attachment);
|
||||||
|
attachment.slug = newName;
|
||||||
|
let newMarkdownAttachment = this.getAttachmentMarkdown(attachment);
|
||||||
|
|
||||||
|
this.msg = this.msg.replace(new RegExp(oldMarkdownAttachment,'g'), newMarkdownAttachment);
|
||||||
|
},
|
||||||
|
getAttachment(oid) {
|
||||||
|
for (let a of this.attachments) {
|
||||||
|
if (a.oid === oid) return a;
|
||||||
|
}
|
||||||
|
console.error('No attachment found:', oid);
|
||||||
|
},
|
||||||
|
attachmentValidation(oid, isValid) {
|
||||||
|
let attachment = this.getAttachment(oid);
|
||||||
|
attachment.isSlugValid = isValid;
|
||||||
|
},
|
||||||
|
cleanUp() {
|
||||||
|
this.msg = '';
|
||||||
|
this.attachments = [];
|
||||||
|
},
|
||||||
|
autoSizeInputField() {
|
||||||
|
let elInputField = this.$refs.inputField;
|
||||||
|
elInputField.style.cssText = 'height:auto; padding:0';
|
||||||
|
let newInputHeight = elInputField.scrollHeight + 20;
|
||||||
|
elInputField.style.cssText = `height:${ newInputHeight }px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
161
src/scripts/js/es6/common/vuecomponents/comments/CommentTree.js
Normal file
161
src/scripts/js/es6/common/vuecomponents/comments/CommentTree.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import './CommentEditor'
|
||||||
|
import './Comment'
|
||||||
|
import './CommentsLocked'
|
||||||
|
import '../user/Avatar'
|
||||||
|
import '../utils/GenericPlaceHolder'
|
||||||
|
import { thenGetComments } from '../../api/comments'
|
||||||
|
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
|
||||||
|
import { EventBus, Events } from './EventBus'
|
||||||
|
|
||||||
|
const TEMPLATE = `
|
||||||
|
<section class="comments-tree">
|
||||||
|
<div class="comment-reply-container"
|
||||||
|
v-if="canReply"
|
||||||
|
>
|
||||||
|
<user-avatar
|
||||||
|
:user="user"
|
||||||
|
/>
|
||||||
|
<comment-editor
|
||||||
|
v-if="canReply"
|
||||||
|
mode="reply"
|
||||||
|
@unit-of-work="childUnitOfWork"
|
||||||
|
:projectId="projectId"
|
||||||
|
:parentId="parentId"
|
||||||
|
:user="user"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<comments-locked
|
||||||
|
v-if="readOnly||!isLoggedIn"
|
||||||
|
:user="user"
|
||||||
|
/>
|
||||||
|
<div class="comments-list-title">{{ numberOfCommentsStr }}</div>
|
||||||
|
<div class="comments-list">
|
||||||
|
<comment
|
||||||
|
v-for="c in comments"
|
||||||
|
@unit-of-work="childUnitOfWork"
|
||||||
|
:readOnly=readOnly||!isLoggedIn
|
||||||
|
:comment="c"
|
||||||
|
:user="user"
|
||||||
|
:key="c.id"/>
|
||||||
|
</div>
|
||||||
|
<generic-placeholder
|
||||||
|
v-show="showLoadingPlaceholder"
|
||||||
|
label="Loading Comments..."
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Vue.component('comments-tree', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
mixins: [UnitOfWorkTracker],
|
||||||
|
props: {
|
||||||
|
parentId: String,
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
replyHidden: false,
|
||||||
|
nbrOfComments: 0,
|
||||||
|
projectId: '',
|
||||||
|
comments: [],
|
||||||
|
showLoadingPlaceholder: true,
|
||||||
|
user: pillar.utils.getCurrentUser(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
numberOfCommentsStr() {
|
||||||
|
let pluralized = this.nbrOfComments === 1 ? 'Comment' : 'Comments'
|
||||||
|
return `${ this.nbrOfComments } ${ pluralized }`;
|
||||||
|
},
|
||||||
|
isLoggedIn() {
|
||||||
|
return this.user.is_authenticated;
|
||||||
|
},
|
||||||
|
canReply() {
|
||||||
|
return !this.readOnly && !this.replyHidden && this.isLoggedIn;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
isBusyWorking(isBusy) {
|
||||||
|
if(isBusy) {
|
||||||
|
$(document).trigger('pillar:workStart');
|
||||||
|
} else {
|
||||||
|
$(document).trigger('pillar:workStop');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parentId() {
|
||||||
|
this.fetchComments();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
EventBus.$on(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
|
||||||
|
EventBus.$on(Events.EDIT_DONE, this.showReplyComponent);
|
||||||
|
EventBus.$on(Events.NEW_COMMENT, this.onNewComment);
|
||||||
|
EventBus.$on(Events.UPDATED_COMMENT, this.onCommentUpdated);
|
||||||
|
this.fetchComments()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
|
||||||
|
EventBus.$off(Events.EDIT_DONE, this.showReplyComponent);
|
||||||
|
EventBus.$off(Events.NEW_COMMENT, this.onNewComment);
|
||||||
|
EventBus.$off(Events.UPDATED_COMMENT, this.onCommentUpdated);
|
||||||
|
if(this.isBusyWorking) {
|
||||||
|
$(document).trigger('pillar:workStop');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchComments() {
|
||||||
|
this.showLoadingPlaceholder = true;
|
||||||
|
this.unitOfWork(
|
||||||
|
thenGetComments(this.parentId)
|
||||||
|
.then((commentsTree) => {
|
||||||
|
this.nbrOfComments = commentsTree['nbr_of_comments'];
|
||||||
|
this.comments = commentsTree['comments'];
|
||||||
|
this.projectId = commentsTree['project'];
|
||||||
|
})
|
||||||
|
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to load comments')})
|
||||||
|
.always(()=>this.showLoadingPlaceholder = false)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
doHideEditors() {
|
||||||
|
this.replyHidden = true;
|
||||||
|
},
|
||||||
|
showReplyComponent() {
|
||||||
|
this.replyHidden = false;
|
||||||
|
},
|
||||||
|
onNewComment(newComment) {
|
||||||
|
this.nbrOfComments++;
|
||||||
|
let parentArray;
|
||||||
|
if(newComment.parent === this.parentId) {
|
||||||
|
parentArray = this.comments;
|
||||||
|
} else {
|
||||||
|
let parentComment = this.findComment(this.comments, (comment) => {
|
||||||
|
return comment.id === newComment.parent;
|
||||||
|
});
|
||||||
|
parentArray = parentComment.replies;
|
||||||
|
}
|
||||||
|
parentArray.unshift(newComment);
|
||||||
|
this.$emit('new-comment');
|
||||||
|
},
|
||||||
|
onCommentUpdated(updatedComment) {
|
||||||
|
let commentInTree = this.findComment(this.comments, (comment) => {
|
||||||
|
return comment.id === updatedComment.id;
|
||||||
|
});
|
||||||
|
delete updatedComment.replies; // No need to apply these since they should be the same
|
||||||
|
Object.assign(commentInTree, updatedComment);
|
||||||
|
},
|
||||||
|
findComment(arrayOfComments, matcherCB) {
|
||||||
|
for(let comment of arrayOfComments) {
|
||||||
|
if(matcherCB(comment)) {
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
let match = this.findComment(comment.replies, matcherCB);
|
||||||
|
if (match) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
@@ -0,0 +1,53 @@
|
|||||||
|
const TEMPLATE = `
|
||||||
|
<div class="comments-locked">
|
||||||
|
<div
|
||||||
|
v-if="msgToShow === 'PROJECT_MEMBERS_ONLY'"
|
||||||
|
>
|
||||||
|
<i class="pi-lock"/>
|
||||||
|
Only project members can comment.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="msgToShow === 'RENEW'"
|
||||||
|
>
|
||||||
|
<i class="pi-heart"/>
|
||||||
|
Join the conversation!
|
||||||
|
<a href="/renew" target="_blank"> Renew your subscription </a>
|
||||||
|
to comment.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="msgToShow === 'JOIN'"
|
||||||
|
>
|
||||||
|
<i class="pi-heart"/>
|
||||||
|
Join the conversation!
|
||||||
|
<a href="https://store.blender.org/product/membership/" target="_blank"> Subscribe to Blender Cloud </a>
|
||||||
|
to comment.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="msgToShow === 'LOGIN'"
|
||||||
|
>
|
||||||
|
<a href="/login"> Log in to comment</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Vue.component('comments-locked', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {user: Object},
|
||||||
|
computed: {
|
||||||
|
msgToShow() {
|
||||||
|
if(this.user && this.user.is_authenticated) {
|
||||||
|
if (this.user.hasCap('subscriber')) {
|
||||||
|
return 'PROJECT_MEMBERS_ONLY';
|
||||||
|
} else if(this.user.hasCap('can-renew-subscription')) {
|
||||||
|
return 'RENEW';
|
||||||
|
} else {
|
||||||
|
return 'JOIN';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'LOGIN';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
@@ -0,0 +1,7 @@
|
|||||||
|
export const Events = {
|
||||||
|
NEW_COMMENT: 'new-comment',
|
||||||
|
UPDATED_COMMENT: 'updated-comment',
|
||||||
|
EDIT_DONE: 'edit-done',
|
||||||
|
BEFORE_SHOW_EDITOR: 'before-show-editor'
|
||||||
|
}
|
||||||
|
export const EventBus = new Vue();
|
52
src/scripts/js/es6/common/vuecomponents/comments/Rating.js
Normal file
52
src/scripts/js/es6/common/vuecomponents/comments/Rating.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { EventBus, Events } from './EventBus'
|
||||||
|
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
|
||||||
|
import { thenVoteComment } from '../../api/comments'
|
||||||
|
const TEMPLATE = `
|
||||||
|
<div class="comment-rating"
|
||||||
|
:class="{rated: currentUserHasRated, positive: currentUserRatedPositive }"
|
||||||
|
>
|
||||||
|
<div class="comment-rating-value" title="Number of likes">{{ rating }}</div>
|
||||||
|
<div class="comment-action-rating up" title="Like comment"
|
||||||
|
v-if="canVote"
|
||||||
|
@click="upVote"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Vue.component('comment-rating', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
mixins: [UnitOfWorkTracker],
|
||||||
|
props: {comment: Object},
|
||||||
|
computed: {
|
||||||
|
positiveRating() {
|
||||||
|
return this.comment.properties.rating_positive || 0;
|
||||||
|
},
|
||||||
|
negativeRating() {
|
||||||
|
return this.comment.properties.rating_negative || 0;
|
||||||
|
},
|
||||||
|
rating() {
|
||||||
|
return this.positiveRating - this.negativeRating;
|
||||||
|
},
|
||||||
|
currentUserRatedPositive() {
|
||||||
|
return this.comment.current_user_rating === true;
|
||||||
|
},
|
||||||
|
currentUserHasRated() {
|
||||||
|
return typeof this.comment.current_user_rating === "boolean" ;
|
||||||
|
},
|
||||||
|
canVote() {
|
||||||
|
return this.comment.user.id !== pillar.utils.getCurrentUser().user_id;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
upVote() {
|
||||||
|
let vote = this.comment.current_user_rating === true ? 0 : 1; // revoke if set
|
||||||
|
this.unitOfWork(
|
||||||
|
thenVoteComment(this.comment.parent, this.comment.id, vote)
|
||||||
|
.then((updatedComment) => {
|
||||||
|
EventBus.$emit(Events.UPDATED_COMMENT, updatedComment);
|
||||||
|
})
|
||||||
|
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to vote on comment')})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@@ -0,0 +1,23 @@
|
|||||||
|
const TEMPLATE = `
|
||||||
|
<div class="upload-progress">
|
||||||
|
<label>
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<progress class="progress-uploading"
|
||||||
|
max="100"
|
||||||
|
:value="progress"
|
||||||
|
>
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Vue.component('upload-progress', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
label: String,
|
||||||
|
progress: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Directive to detect clicks outside of component.
|
||||||
|
* Code from https://stackoverflow.com/a/42389266
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <div
|
||||||
|
* v-click-outside="()=>{console.log('User clicked outside component')}"
|
||||||
|
* >
|
||||||
|
* ...
|
||||||
|
* </div>
|
||||||
|
*/
|
||||||
|
Vue.directive('click-outside', {
|
||||||
|
bind: function (el, binding, vnode) {
|
||||||
|
el.clickOutsideEvent = function (event) {
|
||||||
|
// here I check that click was outside the el and his childrens
|
||||||
|
if (!(el == event.target || el.contains(event.target))) {
|
||||||
|
// and if it did, call method provided in attribute value
|
||||||
|
vnode.context[binding.expression](event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.body.addEventListener('click', el.clickOutsideEvent)
|
||||||
|
},
|
||||||
|
unbind: function (el) {
|
||||||
|
document.body.removeEventListener('click', el.clickOutsideEvent)
|
||||||
|
},
|
||||||
|
});
|
61
src/scripts/js/es6/common/vuecomponents/init.js
Normal file
61
src/scripts/js/es6/common/vuecomponents/init.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import './breadcrumbs/Breadcrumbs'
|
||||||
|
import './comments/CommentTree'
|
||||||
|
import './customdirectives/click-outside'
|
||||||
|
import { UnitOfWorkTracker } from './mixins/UnitOfWorkTracker'
|
||||||
|
import { BrowserHistoryState, StateSaveMode } from './mixins/BrowserHistoryState'
|
||||||
|
import { PillarTable } from './table/Table'
|
||||||
|
import { CellPrettyDate } from './table/cells/renderer/CellPrettyDate'
|
||||||
|
import { CellDefault } from './table/cells/renderer/CellDefault'
|
||||||
|
import { ColumnBase } from './table/columns/ColumnBase'
|
||||||
|
import { Created } from './table/columns/Created'
|
||||||
|
import { Updated } from './table/columns/Updated'
|
||||||
|
import { DateColumnBase } from './table/columns/DateColumnBase'
|
||||||
|
import { ColumnFactoryBase } from './table/columns/ColumnFactoryBase'
|
||||||
|
import { RowObjectsSourceBase } from './table/rows/RowObjectsSourceBase'
|
||||||
|
import { RowBase } from './table/rows/RowObjectBase'
|
||||||
|
import { RowFilter } from './table/rows/filter/RowFilter'
|
||||||
|
import { EnumFilter } from './table/rows/filter/EnumFilter'
|
||||||
|
import { StatusFilter } from './table/rows/filter/StatusFilter'
|
||||||
|
import { TextFilter } from './table/rows/filter/TextFilter'
|
||||||
|
import { NameFilter } from './table/rows/filter/NameFilter'
|
||||||
|
import { UserAvatar } from './user/Avatar'
|
||||||
|
|
||||||
|
let mixins = {
|
||||||
|
UnitOfWorkTracker,
|
||||||
|
BrowserHistoryState,
|
||||||
|
StateSaveMode
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = {
|
||||||
|
PillarTable,
|
||||||
|
columns: {
|
||||||
|
ColumnBase,
|
||||||
|
Created,
|
||||||
|
Updated,
|
||||||
|
DateColumnBase,
|
||||||
|
ColumnFactoryBase,
|
||||||
|
},
|
||||||
|
cells: {
|
||||||
|
renderer: {
|
||||||
|
CellDefault,
|
||||||
|
CellPrettyDate
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
filter: {
|
||||||
|
RowFilter,
|
||||||
|
EnumFilter,
|
||||||
|
StatusFilter,
|
||||||
|
TextFilter,
|
||||||
|
NameFilter
|
||||||
|
},
|
||||||
|
RowObjectsSourceBase,
|
||||||
|
RowBase,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = {
|
||||||
|
UserAvatar
|
||||||
|
}
|
||||||
|
|
||||||
|
export { mixins, table, user }
|
42
src/scripts/js/es6/common/vuecomponents/menu/DropDown.js
Normal file
42
src/scripts/js/es6/common/vuecomponents/menu/DropDown.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-dropdown">
|
||||||
|
<div class="pillar-dropdown-button action"
|
||||||
|
:class="buttonClasses"
|
||||||
|
@click="toggleShowMenu"
|
||||||
|
>
|
||||||
|
<slot name="button"/>
|
||||||
|
</div>
|
||||||
|
<div class="pillar-dropdown-menu"
|
||||||
|
v-show="showMenu"
|
||||||
|
v-click-outside="closeMenu"
|
||||||
|
>
|
||||||
|
<slot name="menu"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let DropDown = Vue.component('pillar-dropdown', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
buttonClasses() {
|
||||||
|
return {'is-open': this.showMenu};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleShowMenu(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.showMenu = !this.showMenu;
|
||||||
|
},
|
||||||
|
closeMenu(event) {
|
||||||
|
this.showMenu = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { DropDown }
|
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Vue helper mixin to push app state into browser history.
|
||||||
|
*
|
||||||
|
* How to use:
|
||||||
|
* Override browserHistoryState so it return the state you want to store
|
||||||
|
* Override historyStateUrl so it return the url you want to store with your state
|
||||||
|
* Override applyHistoryState to apply your state
|
||||||
|
*/
|
||||||
|
|
||||||
|
const StateSaveMode = Object.freeze({
|
||||||
|
IGNORE: Symbol("ignore"),
|
||||||
|
PUSH: Symbol("push"),
|
||||||
|
REPLACE: Symbol("replace")
|
||||||
|
});
|
||||||
|
|
||||||
|
let BrowserHistoryState = {
|
||||||
|
created() {
|
||||||
|
window.onpopstate = this._popHistoryState;
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
_lastApplyedHistoryState: undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
/**
|
||||||
|
* Override and return state object
|
||||||
|
* @returns {Object} state object
|
||||||
|
*/
|
||||||
|
browserHistoryState() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Override and return url to this state
|
||||||
|
* @returns {String} url to state
|
||||||
|
*/
|
||||||
|
historyStateUrl() {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
browserHistoryState(newState) {
|
||||||
|
if(JSON.stringify(newState) === JSON.stringify(window.history.state)) return; // Don't save state on apply
|
||||||
|
|
||||||
|
let mode = this.stateSaveMode(newState, window.history.state);
|
||||||
|
switch(mode) {
|
||||||
|
case StateSaveMode.IGNORE: break;
|
||||||
|
case StateSaveMode.PUSH:
|
||||||
|
this._pushHistoryState();
|
||||||
|
break;
|
||||||
|
case StateSaveMode.REPLACE:
|
||||||
|
this._replaceHistoryState();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('Unknown state save mode', mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Override to apply your state
|
||||||
|
* @param {Object} newState The state object you returned in @function browserHistoryState
|
||||||
|
*/
|
||||||
|
applyHistoryState(newState) {
|
||||||
|
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Override to
|
||||||
|
* @param {Object} newState
|
||||||
|
* @param {Object} oldState
|
||||||
|
* @returns {StateSaveMode} Enum value to instruct how state change should be handled
|
||||||
|
*/
|
||||||
|
stateSaveMode(newState, oldState) {
|
||||||
|
if (!oldState) {
|
||||||
|
// Initial state. Replace what we have so we can go back to this state
|
||||||
|
return StateSaveMode.REPLACE;
|
||||||
|
}
|
||||||
|
return StateSaveMode.PUSH;
|
||||||
|
},
|
||||||
|
_pushHistoryState() {
|
||||||
|
let currentState = this.browserHistoryState;
|
||||||
|
if (!currentState) return;
|
||||||
|
|
||||||
|
let url = this.historyStateUrl;
|
||||||
|
window.history.pushState(
|
||||||
|
currentState,
|
||||||
|
undefined,
|
||||||
|
url
|
||||||
|
);
|
||||||
|
},
|
||||||
|
_replaceHistoryState() {
|
||||||
|
let currentState = this.browserHistoryState;
|
||||||
|
if (!currentState) return;
|
||||||
|
|
||||||
|
let url = this.historyStateUrl;
|
||||||
|
window.history.replaceState(
|
||||||
|
currentState,
|
||||||
|
undefined,
|
||||||
|
url
|
||||||
|
);
|
||||||
|
},
|
||||||
|
_popHistoryState(event) {
|
||||||
|
let newState = event.state;
|
||||||
|
if (!newState) return;
|
||||||
|
this.applyHistoryState(newState);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BrowserHistoryState, StateSaveMode }
|
86
src/scripts/js/es6/common/vuecomponents/mixins/Droptarget.js
Normal file
86
src/scripts/js/es6/common/vuecomponents/mixins/Droptarget.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Vue mixin that makes the component a droptarget
|
||||||
|
* override canHandleDrop(event) and onDrop(event)
|
||||||
|
* dragOverClasses can be bound to target class
|
||||||
|
*/
|
||||||
|
|
||||||
|
var Droptarget = {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
droptargetCounter: 0,
|
||||||
|
droptargetCanHandle: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isDragingOver() {
|
||||||
|
return this.droptargetCounter > 0;
|
||||||
|
},
|
||||||
|
dropTargetClasses() {
|
||||||
|
return {
|
||||||
|
'drag-hover': this.isDragingOver,
|
||||||
|
'unsupported-drop': this.isDragingOver && !this.droptargetCanHandle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$nextTick(function () {
|
||||||
|
this.$el.addEventListener('dragenter', this._onDragEnter);
|
||||||
|
this.$el.addEventListener('dragleave', this._onDragLeave);
|
||||||
|
this.$el.addEventListener('dragend', this._onDragEnd);
|
||||||
|
this.$el.addEventListener('dragover', this._onDragOver);
|
||||||
|
this.$el.addEventListener('drop', this._onDrop);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$el.removeEventListener('dragenter', this._onDragEnter);
|
||||||
|
this.$el.removeEventListener('dragleave', this._onDragLeave);
|
||||||
|
this.$el.removeEventListener('dragend', this._onDragEnd);
|
||||||
|
this.$el.removeEventListener('dragover', this._onDragOver);
|
||||||
|
this.$el.removeEventListener('drop', this._onDrop);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
canHandleDrop(event) {
|
||||||
|
throw Error('Not implemented');
|
||||||
|
},
|
||||||
|
onDrop(event) {
|
||||||
|
throw Error('Not implemented');
|
||||||
|
},
|
||||||
|
_onDragEnter(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.droptargetCounter++;
|
||||||
|
if(this.droptargetCounter === 1) {
|
||||||
|
try {
|
||||||
|
this.droptargetCanHandle = this.canHandleDrop(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error);
|
||||||
|
this.droptargetCanHandle = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_onDragLeave() {
|
||||||
|
this.droptargetCounter--;
|
||||||
|
},
|
||||||
|
_onDragEnd() {
|
||||||
|
this.droptargetCounter = 0;
|
||||||
|
},
|
||||||
|
_onDragOver(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
},
|
||||||
|
_onDrop(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if(this.droptargetCanHandle) {
|
||||||
|
try {
|
||||||
|
this.onDrop(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.console.warn(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.droptargetCounter = 0;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Droptarget }
|
24
src/scripts/js/es6/common/vuecomponents/mixins/Linkable.js
Normal file
24
src/scripts/js/es6/common/vuecomponents/mixins/Linkable.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Vue mixin that scrolls element into view if id matches #value in url
|
||||||
|
* @param {String} id identifier that is set by the user of the mixin
|
||||||
|
* @param {Boolean} isLinked true if Component is linked
|
||||||
|
*/
|
||||||
|
let hash = window.location.hash.substr(1).split('?')[0];
|
||||||
|
var Linkable = {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
isLinked: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted: function () {
|
||||||
|
this.$nextTick(function () {
|
||||||
|
if(hash && this.id === hash) {
|
||||||
|
this.isLinked = true;
|
||||||
|
this.$el.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Linkable }
|
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Vue helper mixin to keep track if work is in progress or not.
|
||||||
|
* Example use:
|
||||||
|
* Keep track of work in own component:
|
||||||
|
* this.unitOfWork(
|
||||||
|
* thenDostuff()
|
||||||
|
* .then(...)
|
||||||
|
* .fail(...)
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* Keep track of work in child components:
|
||||||
|
* <myChild
|
||||||
|
* @unit-of-work="childUnitOfWork"
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* Use the information to enable class:
|
||||||
|
* <div :class="{disabled: 'isBusyWorking'}">
|
||||||
|
*/
|
||||||
|
var UnitOfWorkTracker = {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
unitOfWorkCounter: 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isBusyWorking() {
|
||||||
|
if(this.unitOfWorkCounter < 0) {
|
||||||
|
console.error('UnitOfWork missmatch!')
|
||||||
|
}
|
||||||
|
return this.unitOfWorkCounter > 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
isBusyWorking(isBusy) {
|
||||||
|
if(isBusy) {
|
||||||
|
this.$emit('unit-of-work', 1);
|
||||||
|
} else {
|
||||||
|
this.$emit('unit-of-work', -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
if(this.unitOfWorkCounter !== 0) {
|
||||||
|
this.$emit('unit-of-work', -this.unitOfWorkCounter);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
unitOfWork(promise) {
|
||||||
|
this.unitOfWorkBegin();
|
||||||
|
if (promise.always) {
|
||||||
|
// jQuery Promise
|
||||||
|
return promise.always(this.unitOfWorkDone);
|
||||||
|
}
|
||||||
|
if (promise.finally) {
|
||||||
|
// Native js Promise
|
||||||
|
return promise.finally(this.unitOfWorkDone);
|
||||||
|
}
|
||||||
|
throw Error('Unsupported promise type');
|
||||||
|
},
|
||||||
|
unitOfWorkBegin() {
|
||||||
|
this.unitOfWorkCounter++;
|
||||||
|
},
|
||||||
|
unitOfWorkDone() {
|
||||||
|
this.unitOfWorkCounter--;
|
||||||
|
},
|
||||||
|
childUnitOfWork(direction) {
|
||||||
|
this.unitOfWorkCounter += direction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { UnitOfWorkTracker }
|
273
src/scripts/js/es6/common/vuecomponents/table/Table.js
Normal file
273
src/scripts/js/es6/common/vuecomponents/table/Table.js
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import './rows/renderer/Head'
|
||||||
|
import './rows/renderer/Row'
|
||||||
|
import './columns/filter/ColumnFilter'
|
||||||
|
import './rows/filter/RowFilter'
|
||||||
|
import {UnitOfWorkTracker} from '../mixins/UnitOfWorkTracker'
|
||||||
|
import {RowFilter} from './rows/filter/RowFilter'
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentState {
|
||||||
|
/**
|
||||||
|
* Serializable state of this component.
|
||||||
|
*
|
||||||
|
* @param {Object} rowFilter
|
||||||
|
* @param {Object} columnFilter
|
||||||
|
*/
|
||||||
|
constructor(rowFilter, columnFilter) {
|
||||||
|
this.rowFilter = rowFilter;
|
||||||
|
this.columnFilter = columnFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-table-container"
|
||||||
|
:class="$options.name"
|
||||||
|
>
|
||||||
|
<div class="pillar-table-menu">
|
||||||
|
<pillar-table-row-filter
|
||||||
|
:rowObjects="sortedRowObjects"
|
||||||
|
:config="rowFilterConfig"
|
||||||
|
:componentState="(componentState || {}).rowFilter"
|
||||||
|
@visible-row-objects-changed="onVisibleRowObjectsChanged"
|
||||||
|
@component-state-changed="onRowFilterStateChanged"
|
||||||
|
/>
|
||||||
|
<pillar-table-actions
|
||||||
|
@item-clicked="onItemClicked"
|
||||||
|
/>
|
||||||
|
<pillar-table-column-filter
|
||||||
|
:columns="columns"
|
||||||
|
:componentState="(componentState || {}).columnFilter"
|
||||||
|
@visible-columns-changed="onVisibleColumnsChanged"
|
||||||
|
@component-state-changed="onColumnFilterStateChanged"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pillar-table">
|
||||||
|
<pillar-table-head
|
||||||
|
:columns="visibleColumns"
|
||||||
|
@sort="onSort"
|
||||||
|
/>
|
||||||
|
<transition-group name="pillar-table-row" tag="div" class="pillar-table-row-group">
|
||||||
|
<pillar-table-row
|
||||||
|
v-for="rowObject in visibleRowObjects"
|
||||||
|
:columns="visibleColumns"
|
||||||
|
:rowObject="rowObject"
|
||||||
|
:key="rowObject.getId()"
|
||||||
|
@item-clicked="onItemClicked"
|
||||||
|
/>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table renders RowObject instances for the rows, and ColumnBase instances for the Columns.
|
||||||
|
* Extend the table to fit your needs.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* Extend RowBase to wrap the data you want in your row
|
||||||
|
* Extend ColumnBase once per column type you need
|
||||||
|
* Extend RowObjectsSourceBase to fetch and initialize your rows
|
||||||
|
* Extend ColumnFactoryBase to create the rows for your table
|
||||||
|
* Extend This Table with your ColumnFactory and RowSource
|
||||||
|
*
|
||||||
|
* @emits is-initialized When all rows has been fetched, and are initialized.
|
||||||
|
* @emits selected-items-changed(selectedItems) When selected rows has changed.
|
||||||
|
* @emits component-state-changed(newState) When table state changed. Filtered rows, visible columns...
|
||||||
|
*/
|
||||||
|
let PillarTable = Vue.component('pillar-table-base', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
mixins: [UnitOfWorkTracker],
|
||||||
|
props: {
|
||||||
|
selectedIds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => {return []}
|
||||||
|
},
|
||||||
|
canChangeSelectionCB: {
|
||||||
|
type: Function,
|
||||||
|
default: () => true
|
||||||
|
},
|
||||||
|
canMultiSelect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
componentState: {
|
||||||
|
// Instance of ComponentState (but type Object since it has been deserialized)
|
||||||
|
type: Object,
|
||||||
|
default: undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: function() {
|
||||||
|
return {
|
||||||
|
currentlySelectedIds: [],
|
||||||
|
columns: [],
|
||||||
|
visibleColumns: [],
|
||||||
|
visibleRowObjects: [],
|
||||||
|
rowsSource: undefined, // Override with your implementations of RowSource
|
||||||
|
columnFactory: undefined, // Override with your implementations of ColumnFactoryBase
|
||||||
|
rowFilterConfig: undefined,
|
||||||
|
isInitialized: false,
|
||||||
|
rowFilterState: (this.componentState || {}).rowFilter,
|
||||||
|
columnFilterState: (this.componentState || {}).columnFilter,
|
||||||
|
compareRowsCB: (row1, row2) => 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
rowObjects() {
|
||||||
|
return this.rowsSource.rowObjects || [];
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Rows sorted with a column sorter
|
||||||
|
*/
|
||||||
|
sortedRowObjects() {
|
||||||
|
return this.rowObjects.concat().sort(this.compareRowsCB);
|
||||||
|
},
|
||||||
|
rowAndChildObjects() {
|
||||||
|
let all = [];
|
||||||
|
for (const row of this.rowObjects) {
|
||||||
|
all.push(row, ...row.getChildObjects());
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
},
|
||||||
|
selectedItems() {
|
||||||
|
return this.rowAndChildObjects.filter(it => it.isSelected)
|
||||||
|
.map(it => it.underlyingObject);
|
||||||
|
},
|
||||||
|
currentComponentState() {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
return new ComponentState(
|
||||||
|
this.rowFilterState,
|
||||||
|
this.columnFilterState
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
selectedIds(newValue) {
|
||||||
|
this.currentlySelectedIds = newValue;
|
||||||
|
},
|
||||||
|
currentlySelectedIds(newValue) {
|
||||||
|
this.rowAndChildObjects.forEach(item => {
|
||||||
|
item.isSelected = newValue.includes(item.getId());
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedItems(newValue, oldValue) {
|
||||||
|
// Deep compare to avoid spamming un needed events
|
||||||
|
let hasChanged = JSON.stringify(newValue ) !== JSON.stringify(oldValue);
|
||||||
|
if (hasChanged) {
|
||||||
|
this.$emit('selected-items-changed', newValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isInitialized(newValue) {
|
||||||
|
if (newValue) {
|
||||||
|
this.$emit('is-initialized');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currentComponentState(newValue, oldValue) {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
// Deep compare to avoid spamming un needed events
|
||||||
|
let hasChanged = JSON.stringify(newValue ) !== JSON.stringify(oldValue);
|
||||||
|
if (hasChanged) {
|
||||||
|
this.$emit('component-state-changed', newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.unitOfWork(
|
||||||
|
Promise.all([
|
||||||
|
this.columnFactory.thenGetColumns(),
|
||||||
|
this.rowsSource.thenGetRowObjects()
|
||||||
|
])
|
||||||
|
.then((resp) => {
|
||||||
|
this.columns = resp[0];
|
||||||
|
return this.rowsSource.thenInit();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (this.currentlySelectedIds.length === 0) {
|
||||||
|
this.currentlySelectedIds = this.selectedIds;
|
||||||
|
} else {
|
||||||
|
// User has clicked on a row while we inited the rows. Keep that selection!
|
||||||
|
}
|
||||||
|
this.isInitialized = true;
|
||||||
|
})
|
||||||
|
.catch((err) => {toastr.error(pillar.utils.messageFromError(err), 'Loading table failed')})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onVisibleColumnsChanged(visibleColumns) {
|
||||||
|
this.visibleColumns = visibleColumns;
|
||||||
|
},
|
||||||
|
onColumnFilterStateChanged(newComponentState) {
|
||||||
|
this.columnFilterState = newComponentState;
|
||||||
|
},
|
||||||
|
onVisibleRowObjectsChanged(visibleRowObjects) {
|
||||||
|
this.visibleRowObjects = visibleRowObjects;
|
||||||
|
},
|
||||||
|
onRowFilterStateChanged(newComponentState) {
|
||||||
|
this.rowFilterState = newComponentState;
|
||||||
|
},
|
||||||
|
onSort(column, direction) {
|
||||||
|
function compareRows(r1, r2) {
|
||||||
|
return column.compareRows(r1, r2) * direction;
|
||||||
|
}
|
||||||
|
this.compareRowsCB = compareRows;
|
||||||
|
},
|
||||||
|
onItemClicked(clickEvent, itemId) {
|
||||||
|
if(!this.canChangeSelectionCB()) return;
|
||||||
|
|
||||||
|
if(this.isMultiToggleClick(clickEvent) && this.canMultiSelect) {
|
||||||
|
let slectedIdsWithoutClicked = this.currentlySelectedIds.filter(id => id !== itemId);
|
||||||
|
if (slectedIdsWithoutClicked.length < this.currentlySelectedIds.length) {
|
||||||
|
this.currentlySelectedIds = slectedIdsWithoutClicked;
|
||||||
|
} else {
|
||||||
|
this.currentlySelectedIds = [itemId, ...this.currentlySelectedIds];
|
||||||
|
}
|
||||||
|
} else if(this.isSelectBetweenClick(clickEvent) && this.canMultiSelect) {
|
||||||
|
if (this.currentlySelectedIds.length > 0) {
|
||||||
|
let betweenA = this.currentlySelectedIds[this.currentlySelectedIds.length -1];
|
||||||
|
let betweenB = itemId;
|
||||||
|
this.currentlySelectedIds = this.rowsBetween(betweenA, betweenB).map(it => it.getId());
|
||||||
|
|
||||||
|
} else {
|
||||||
|
this.currentlySelectedIds = [itemId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.currentlySelectedIds = [itemId];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isSelectBetweenClick(clickEvent) {
|
||||||
|
return clickEvent.shiftKey;
|
||||||
|
},
|
||||||
|
isMultiToggleClick(clickEvent) {
|
||||||
|
return clickEvent.ctrlKey ||
|
||||||
|
clickEvent.metaKey; // Mac command key
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get visible rows between id1 and id2
|
||||||
|
* @param {String} id1
|
||||||
|
* @param {String} id2
|
||||||
|
* @returns {Array(RowObjects)}
|
||||||
|
*/
|
||||||
|
rowsBetween(id1, id2) {
|
||||||
|
let hasFoundFirst = false;
|
||||||
|
let hasFoundLast = false;
|
||||||
|
return this.visibleRowObjects.filter((it) => {
|
||||||
|
if (hasFoundLast) return false;
|
||||||
|
if (!hasFoundFirst) {
|
||||||
|
hasFoundFirst = [id1, id2].includes(it.getId());
|
||||||
|
return hasFoundFirst;
|
||||||
|
}
|
||||||
|
hasFoundLast = [id1, id2].includes(it.getId());
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
'pillar-table-row-filter': RowFilter,
|
||||||
|
'pillar-table-actions': {template:'<div/>'},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { PillarTable }
|
@@ -0,0 +1,28 @@
|
|||||||
|
import {ColumnBase} from '../../columns/ColumnBase'
|
||||||
|
import {RowBase} from '../../rows/RowObjectBase'
|
||||||
|
|
||||||
|
const TEMPLATE =`
|
||||||
|
<div>
|
||||||
|
{{ cellValue }}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default cell renderer. Takes raw cell value and formats it.
|
||||||
|
* Override for custom formatting of value.
|
||||||
|
*/
|
||||||
|
let CellDefault = Vue.component('pillar-cell-default', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
column: ColumnBase,
|
||||||
|
rowObject: RowBase,
|
||||||
|
rawCellValue: [String,Number,Boolean,Array,Object,Date,Function,Symbol,],
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
cellValue() {
|
||||||
|
return this.rawCellValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { CellDefault }
|
@@ -0,0 +1,16 @@
|
|||||||
|
import { CellDefault } from './CellDefault'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats raw values as "pretty date".
|
||||||
|
* Expects rawCellValue to be a date.
|
||||||
|
*/
|
||||||
|
let CellPrettyDate = Vue.component('pillar-cell-pretty-date', {
|
||||||
|
extends: CellDefault,
|
||||||
|
computed: {
|
||||||
|
cellValue() {
|
||||||
|
return pillar.utils.prettyDate(this.rawCellValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { CellPrettyDate }
|
@@ -0,0 +1,55 @@
|
|||||||
|
import {RowBase} from '../../rows/RowObjectBase'
|
||||||
|
import {ColumnBase} from '../../columns/ColumnBase'
|
||||||
|
|
||||||
|
const TEMPLATE =`
|
||||||
|
<component class="pillar-cell"
|
||||||
|
:class="cellClasses"
|
||||||
|
:title="cellTitle"
|
||||||
|
:is="cellRenderer"
|
||||||
|
:rowObject="rowObject"
|
||||||
|
:column="column"
|
||||||
|
:rawCellValue="rawCellValue"
|
||||||
|
@item-clicked="$emit('item-clicked', ...arguments)"
|
||||||
|
/>
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the cell that the column requests.
|
||||||
|
*
|
||||||
|
* @emits item-clicked(mouseEvent,itemId) Re-emits if real cell is emitting it
|
||||||
|
*/
|
||||||
|
let CellProxy = Vue.component('pillar-cell-proxy', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
column: ColumnBase,
|
||||||
|
rowObject: RowBase,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
/**
|
||||||
|
* Raw unformated cell value
|
||||||
|
*/
|
||||||
|
rawCellValue() {
|
||||||
|
return this.column.getRawCellValue(this.rowObject) || '';
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Name of the cell render component to be rendered
|
||||||
|
*/
|
||||||
|
cellRenderer() {
|
||||||
|
return this.column.getCellRenderer(this.rowObject);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Css classes to apply to the cell
|
||||||
|
*/
|
||||||
|
cellClasses() {
|
||||||
|
return this.column.getCellClasses(this.rawCellValue, this.rowObject);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Cell tooltip
|
||||||
|
*/
|
||||||
|
cellTitle() {
|
||||||
|
return this.column.getCellTitle(this.rawCellValue, this.rowObject);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { CellProxy }
|
@@ -0,0 +1,54 @@
|
|||||||
|
import {ColumnBase} from '../../columns/ColumnBase'
|
||||||
|
|
||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-cell header-cell"
|
||||||
|
:class="cellClasses"
|
||||||
|
@mouseenter="onMouseEnter"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
|
>
|
||||||
|
<div class="cell-content">
|
||||||
|
<div class="header-label"
|
||||||
|
:title="column.displayName"
|
||||||
|
>
|
||||||
|
{{ column.displayName }}
|
||||||
|
</div>
|
||||||
|
<div class="column-sort"
|
||||||
|
v-if="column.isSortable"
|
||||||
|
>
|
||||||
|
<i class="sort-action pi-angle-up"
|
||||||
|
title="Sort Ascending"
|
||||||
|
@click="$emit('sort', column, 1)"
|
||||||
|
/>
|
||||||
|
<i class="sort-action pi-angle-down"
|
||||||
|
title="Sort Descending"
|
||||||
|
@click="$emit('sort', column, -1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cell in the Header of the table
|
||||||
|
*
|
||||||
|
* @emits sort(column,direction) When user clicks column sort arrows.
|
||||||
|
*/
|
||||||
|
Vue.component('pillar-head-cell', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
column: ColumnBase,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
cellClasses() {
|
||||||
|
return this.column.getHeaderCellClasses();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onMouseEnter() {
|
||||||
|
this.column.highlightColumn(true);
|
||||||
|
},
|
||||||
|
onMouseLeave() {
|
||||||
|
this.column.highlightColumn(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
@@ -0,0 +1,101 @@
|
|||||||
|
import { CellDefault } from '../cells/renderer/CellDefault'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column logic
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ColumnBase {
|
||||||
|
constructor(displayName, columnType) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
this.columnType = columnType;
|
||||||
|
this.isMandatory = false;
|
||||||
|
this.includedByDefault = true;
|
||||||
|
this.isSortable = true;
|
||||||
|
this.isHighLighted = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {RowObject} rowObject
|
||||||
|
* @returns {String} Name of the Cell renderer component
|
||||||
|
*/
|
||||||
|
getCellRenderer(rowObject) {
|
||||||
|
return CellDefault.options.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {RowObject} rowObject
|
||||||
|
* @returns {*} Raw unformated value
|
||||||
|
*/
|
||||||
|
getRawCellValue(rowObject) {
|
||||||
|
// Should be overridden
|
||||||
|
throw Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell tooltip
|
||||||
|
* @param {Any} rawCellValue
|
||||||
|
* @param {RowObject} rowObject
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
getCellTitle(rawCellValue, rowObject) {
|
||||||
|
// Should be overridden
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object with css classes to use on the column
|
||||||
|
* @returns {Object} Object with css classes
|
||||||
|
*/
|
||||||
|
getColumnClasses() {
|
||||||
|
// Should be overridden
|
||||||
|
let classes = {}
|
||||||
|
classes[this.columnType] = true;
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object with css classes to use on the header cell
|
||||||
|
* @returns {Object} Object with css classes
|
||||||
|
*/
|
||||||
|
getHeaderCellClasses() {
|
||||||
|
// Should be overridden
|
||||||
|
return this.getColumnClasses();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object with css classes to use on the cell
|
||||||
|
* @param {*} rawCellValue
|
||||||
|
* @param {*} rowObject
|
||||||
|
* @returns {Any} Object with css classes
|
||||||
|
*/
|
||||||
|
getCellClasses(rawCellValue, rowObject) {
|
||||||
|
// Should be overridden
|
||||||
|
let classes = this.getColumnClasses();
|
||||||
|
classes['highlight'] = !!this.isHighLighted;
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two rows to sort them. Can be overridden for more complex situations.
|
||||||
|
*
|
||||||
|
* @param {RowObject} rowObject1
|
||||||
|
* @param {RowObject} rowObject2
|
||||||
|
* @returns {Number} -1, 0, 1
|
||||||
|
*/
|
||||||
|
compareRows(rowObject1, rowObject2) {
|
||||||
|
let rawCellValue1 = this.getRawCellValue(rowObject1);
|
||||||
|
let rawCellValue2 = this.getRawCellValue(rowObject2);
|
||||||
|
if (rawCellValue1 === rawCellValue2) return 0;
|
||||||
|
return rawCellValue1 < rawCellValue2 ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Boolean}
|
||||||
|
*/
|
||||||
|
highlightColumn(value) {
|
||||||
|
this.isHighLighted += !!value ? 1 : -1;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Provides the columns that are available in a table.
|
||||||
|
*/
|
||||||
|
class ColumnFactoryBase{
|
||||||
|
/**
|
||||||
|
* To be overridden for your purposes
|
||||||
|
* @returns {Promise(ColumnBase)} The columns that are available in the table.
|
||||||
|
*/
|
||||||
|
thenGetColumns() {
|
||||||
|
throw Error('Not implemented')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ColumnFactoryBase }
|
||||||
|
|
@@ -0,0 +1,19 @@
|
|||||||
|
import {DateColumnBase} from './DateColumnBase'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column showing the objects _created prettyfied
|
||||||
|
*/
|
||||||
|
export class Created extends DateColumnBase{
|
||||||
|
constructor() {
|
||||||
|
super('Created', 'row-created');
|
||||||
|
this.includedByDefault = false;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {RowObject} rowObject
|
||||||
|
* @returns {DateString}
|
||||||
|
*/
|
||||||
|
getRawCellValue(rowObject) {
|
||||||
|
return rowObject.underlyingObject['_created'];
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,41 @@
|
|||||||
|
import { CellPrettyDate } from '../cells/renderer/CellPrettyDate'
|
||||||
|
import { ColumnBase } from './ColumnBase'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column showing a pretty date
|
||||||
|
*/
|
||||||
|
export class DateColumnBase extends ColumnBase{
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {RowObject} rowObject
|
||||||
|
* @returns {String} Name of the Cell renderer component
|
||||||
|
*/
|
||||||
|
getCellRenderer(rowObject) {
|
||||||
|
return CellPrettyDate.options.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell tooltip
|
||||||
|
* @param {Any} rawCellValue
|
||||||
|
* @param {RowObject} rowObject
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
getCellTitle(rawCellValue, rowObject) {
|
||||||
|
return rawCellValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {RowObject} rowObject1
|
||||||
|
* @param {RowObject} rowObject2
|
||||||
|
* @returns {Number} -1, 0, 1
|
||||||
|
*/
|
||||||
|
compareRows(rowObject1, rowObject2) {
|
||||||
|
let dueDateStr1 = this.getRawCellValue(rowObject1);
|
||||||
|
let dueDateStr2 = this.getRawCellValue(rowObject2);
|
||||||
|
if (dueDateStr1 === dueDateStr2) return 0;
|
||||||
|
if (dueDateStr1 && dueDateStr2) {
|
||||||
|
return new Date(dueDateStr1) < new Date(dueDateStr2) ? -1 : 1;
|
||||||
|
}
|
||||||
|
return dueDateStr1 ? -1 : 1;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,19 @@
|
|||||||
|
import {DateColumnBase} from './DateColumnBase'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column showing the objects _updated prettyfied
|
||||||
|
*/
|
||||||
|
export class Updated extends DateColumnBase{
|
||||||
|
constructor() {
|
||||||
|
super('Updated', 'row-updated');
|
||||||
|
this.includedByDefault = false;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {RowObject} rowObject
|
||||||
|
* @returns {DateString}
|
||||||
|
*/
|
||||||
|
getRawCellValue(rowObject) {
|
||||||
|
return rowObject.underlyingObject['_updated'];
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,130 @@
|
|||||||
|
import '../../../menu/DropDown'
|
||||||
|
|
||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-table-column-filter">
|
||||||
|
<pillar-dropdown>
|
||||||
|
<i class="pi-cog"
|
||||||
|
slot="button"
|
||||||
|
title="Table Settings"/>
|
||||||
|
|
||||||
|
<ul class="settings-menu"
|
||||||
|
slot="menu"
|
||||||
|
>
|
||||||
|
Columns:
|
||||||
|
<li class="attract-column-select action"
|
||||||
|
v-for="c in columnStates"
|
||||||
|
:key="c.displayName"
|
||||||
|
@click="toggleColumn(c)"
|
||||||
|
>
|
||||||
|
<input type="checkbox"
|
||||||
|
v-model="c.isVisible"
|
||||||
|
/>
|
||||||
|
{{ c.displayName }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</pillar-dropdown>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
class ColumnState{
|
||||||
|
constructor() {
|
||||||
|
this.displayName;
|
||||||
|
this.isVisible;
|
||||||
|
this.isMandatory;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createDefault(column) {
|
||||||
|
let state = new ColumnState;
|
||||||
|
state.displayName = column.displayName;
|
||||||
|
state.isVisible = !!column.includedByDefault;
|
||||||
|
state.isMandatory = !!column.isMandatory;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComponentState {
|
||||||
|
/**
|
||||||
|
* Serializable state of this component.
|
||||||
|
*
|
||||||
|
* @param {Array} selected The columns that should be visible
|
||||||
|
*/
|
||||||
|
constructor(selected) {
|
||||||
|
this.selected = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to select what columns to render in the table.
|
||||||
|
*
|
||||||
|
* @emits visible-columns-changed(columns) When visible columns has changed
|
||||||
|
* @emits component-state-changed(newState) When column filter state changed.
|
||||||
|
*/
|
||||||
|
let Filter = Vue.component('pillar-table-column-filter', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
columns: Array, // Instances of ColumnBase
|
||||||
|
componentState: Object, // Instance of ComponentState (but type Object since it has been deserialized)
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
columnStates: this.createInitialColumnStates(), // Instances of ColumnState
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
visibleColumns() {
|
||||||
|
return this.columns.filter((candidate) => {
|
||||||
|
return candidate.isMandatory || this.isColumnStateVisible(candidate);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
columnFilterState() {
|
||||||
|
return new ComponentState(this.visibleColumns.map(it => it.displayName));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
columns() {
|
||||||
|
this.columnStates = this.createInitialColumnStates();
|
||||||
|
},
|
||||||
|
visibleColumns(visibleColumns) {
|
||||||
|
this.$emit('visible-columns-changed', visibleColumns);
|
||||||
|
},
|
||||||
|
columnFilterState(newValue) {
|
||||||
|
this.$emit('component-state-changed', newValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$emit('visible-columns-changed', this.visibleColumns);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
createInitialColumnStates() {
|
||||||
|
let columnStateCB = ColumnState.createDefault;
|
||||||
|
if (this.componentState && this.componentState.selected) {
|
||||||
|
let selected = this.componentState.selected;
|
||||||
|
columnStateCB = (column) => {
|
||||||
|
let state = ColumnState.createDefault(column);
|
||||||
|
state.isVisible = selected.includes(column.displayName);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.columns.reduce((states, c) => {
|
||||||
|
if(!c.isMandatory) {
|
||||||
|
states.push(columnStateCB(c));
|
||||||
|
}
|
||||||
|
return states;
|
||||||
|
}, []);
|
||||||
|
},
|
||||||
|
isColumnStateVisible(column) {
|
||||||
|
for (let state of this.columnStates) {
|
||||||
|
if (state.displayName === column.displayName) {
|
||||||
|
return state.isVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
toggleColumn(column) {
|
||||||
|
column.isVisible = !column.isVisible;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { Filter }
|
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Each object to be visualized in the table is wrapped in a RowBase object. Column cells interact with it,
|
||||||
|
*/
|
||||||
|
class RowBase {
|
||||||
|
constructor(underlyingObject) {
|
||||||
|
this.underlyingObject = underlyingObject;
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.isCorrupt = false;
|
||||||
|
this.isSelected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after the row has been created to initalize async properties. Fetching child objects for instance
|
||||||
|
*/
|
||||||
|
thenInit() {
|
||||||
|
return this._thenInitImpl()
|
||||||
|
.then(() => {
|
||||||
|
this.isInitialized = true;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn(err);
|
||||||
|
this.isCorrupt = true;
|
||||||
|
throw err;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override to initialize async properties such as fetching child objects.
|
||||||
|
*/
|
||||||
|
_thenInitImpl() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
getName() {
|
||||||
|
return this.underlyingObject.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId() {
|
||||||
|
return this.underlyingObject._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProperties() {
|
||||||
|
return this.underlyingObject.properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The css classes that should be applied to the row in the table
|
||||||
|
*/
|
||||||
|
getRowClasses() {
|
||||||
|
return {
|
||||||
|
"active": this.isSelected,
|
||||||
|
"is-busy": !this.isInitialized,
|
||||||
|
"is-corrupt": this.isCorrupt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row could have children (shots has tasks for example). Children should also be instances of RowObject
|
||||||
|
*/
|
||||||
|
getChildObjects() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RowBase }
|
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* The provider of RowObjects to a table.
|
||||||
|
* Extend to fit your purpose.
|
||||||
|
*/
|
||||||
|
class RowObjectsSourceBase {
|
||||||
|
constructor() {
|
||||||
|
this.rowObjects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should be overriden to fetch and create the row objects to we rendered in the table. The Row objects should be stored in
|
||||||
|
* this.rowObjects
|
||||||
|
*/
|
||||||
|
thenGetRowObjects() {
|
||||||
|
throw Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inits all its row objects.
|
||||||
|
*/
|
||||||
|
thenInit() {
|
||||||
|
return Promise.all(
|
||||||
|
this.rowObjects.map(it => it.thenInit())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RowObjectsSourceBase }
|
@@ -0,0 +1,153 @@
|
|||||||
|
const TEMPLATE =`
|
||||||
|
<pillar-dropdown>
|
||||||
|
<i class="pi-filter"
|
||||||
|
slot="button"
|
||||||
|
:class="enumButtonClasses"
|
||||||
|
title="Filter rows"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul class="settings-menu"
|
||||||
|
slot="menu"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
{{ label }}:
|
||||||
|
</li>
|
||||||
|
<li class="action"
|
||||||
|
@click="toggleAll"
|
||||||
|
>
|
||||||
|
<input type="checkbox"
|
||||||
|
:checked="includesRows"
|
||||||
|
/> Toggle All
|
||||||
|
</li>
|
||||||
|
<li class="input-group-separator"/>
|
||||||
|
<li v-for="val in enumVisibilities"
|
||||||
|
class="action"
|
||||||
|
:key="val.value"
|
||||||
|
@click="toggleEnum(val.value)"
|
||||||
|
>
|
||||||
|
<input type="checkbox"
|
||||||
|
v-model="enumVisibilities[val.value].isVisible"
|
||||||
|
/> {{ val.displayName }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</pillar-dropdown>
|
||||||
|
`;
|
||||||
|
|
||||||
|
class EnumState{
|
||||||
|
constructor(displayName, value, isVisible) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
this.value = value;
|
||||||
|
this.isVisible = isVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComponentState {
|
||||||
|
/**
|
||||||
|
* Serializable state of this component.
|
||||||
|
*
|
||||||
|
* @param {Array} selected The enums that should be visible
|
||||||
|
*/
|
||||||
|
constructor(selected) {
|
||||||
|
this.selected = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter row objects based on enumeratable values.
|
||||||
|
*
|
||||||
|
* @emits visibleRowObjectsChanged(rowObjects) When the objects to be visible has changed.
|
||||||
|
* @emits component-state-changed(newState) When row filter state changed.
|
||||||
|
*/
|
||||||
|
let EnumFilter = {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
label: String,
|
||||||
|
availableValues: Array, // Array with valid values [{value: abc, displayName: xyz},...]
|
||||||
|
componentState: Object, // Instance of ComponentState (but type Object since it has been deserialized)
|
||||||
|
valueExtractorCB: {
|
||||||
|
// Callback to extract enumvalue from a rowObject
|
||||||
|
type: Function,
|
||||||
|
default: (rowObject) => {throw Error("Not Implemented")}
|
||||||
|
},
|
||||||
|
rowObjects: Array,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
enumVisibilities: this.initEnumVisibilities(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
visibleRowObjects() {
|
||||||
|
return this.rowObjects.filter((row) => {
|
||||||
|
return this.shouldBeVisible(row);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
includesRows() {
|
||||||
|
for (const key in this.enumVisibilities) {
|
||||||
|
if(!this.enumVisibilities[key].isVisible) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
enumButtonClasses() {
|
||||||
|
return {
|
||||||
|
'filter-active': !this.includesRows
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currentComponentState() {
|
||||||
|
let visibleEnums = [];
|
||||||
|
for (const key in this.enumVisibilities) {
|
||||||
|
const enumState = this.enumVisibilities[key];
|
||||||
|
if (enumState.isVisible) {
|
||||||
|
visibleEnums.push(enumState.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ComponentState(visibleEnums);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
visibleRowObjects(visibleRowObjects) {
|
||||||
|
this.$emit('visible-row-objects-changed', visibleRowObjects);
|
||||||
|
},
|
||||||
|
currentComponentState(newValue) {
|
||||||
|
this.$emit('component-state-changed', newValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$emit('visible-row-objects-changed', this.visibleRowObjects);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
shouldBeVisible(rowObject) {
|
||||||
|
let value = this.valueExtractorCB(rowObject);
|
||||||
|
if (typeof this.enumVisibilities[value] === 'undefined') {
|
||||||
|
console.warn(`RowObject ${rowObject.getId()} has an invalid ${this.label} enum: ${value}`)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.enumVisibilities[value].isVisible;
|
||||||
|
},
|
||||||
|
initEnumVisibilities() {
|
||||||
|
let initialValueCB = () => true;
|
||||||
|
if (this.componentState && this.componentState.selected) {
|
||||||
|
initialValueCB = (val) => {
|
||||||
|
return this.componentState.selected.includes(val.value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.availableValues.reduce((agg, val)=> {
|
||||||
|
agg[val.value] = new EnumState(val.displayName, val.value, initialValueCB(val));
|
||||||
|
return agg;
|
||||||
|
}, {});
|
||||||
|
},
|
||||||
|
toggleEnum(value) {
|
||||||
|
this.enumVisibilities[value].isVisible = !this.enumVisibilities[value].isVisible;
|
||||||
|
},
|
||||||
|
toggleAll() {
|
||||||
|
let newValue = !this.includesRows;
|
||||||
|
for (const key in this.enumVisibilities) {
|
||||||
|
this.enumVisibilities[key].isVisible = newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EnumFilter }
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user