Compare commits
	
		
			1 Commits
		
	
	
		
			dillo
			...
			wip-commen
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d0e12401c0 | 
							
								
								
									
										22
									
								
								gulpfile.js
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								gulpfile.js
									
									
									
									
									
								
							@@ -40,8 +40,7 @@ let destination = {
 | 
			
		||||
let source = {
 | 
			
		||||
    bootstrap: 'node_modules/bootstrap/',
 | 
			
		||||
    jquery: 'node_modules/jquery/',
 | 
			
		||||
    popper: 'node_modules/popper.js/',
 | 
			
		||||
    vue: 'node_modules/vue/',
 | 
			
		||||
    popper: 'node_modules/popper.js/'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Stylesheets */
 | 
			
		||||
@@ -107,26 +106,10 @@ 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() {
 | 
			
		||||
    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) {
 | 
			
		||||
    glob('src/scripts/js/es6/individual/**/init.js', function(err, files) {
 | 
			
		||||
        if(err) done(err);
 | 
			
		||||
@@ -144,7 +127,7 @@ gulp.task('scripts_browserify', function(done) {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* Collection of scripts in src/scripts/tutti/ and src/scripts/js/es6/common/ to merge into tutti.min.js
 | 
			
		||||
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js
 | 
			
		||||
 * 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
 | 
			
		||||
 * the site doesn't work without it anyway.*/
 | 
			
		||||
@@ -152,7 +135,6 @@ gulp.task('scripts_concat_tutti', function(done) {
 | 
			
		||||
 | 
			
		||||
    let toUglify = [
 | 
			
		||||
        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.bootstrap + 'js/dist/index.js',
 | 
			
		||||
        source.bootstrap + 'js/dist/util.js',
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										25575
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										25575
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										10
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								package.json
									
									
									
									
									
								
							@@ -37,18 +37,10 @@
 | 
			
		||||
    "bootstrap": "4.1.3",
 | 
			
		||||
    "glob": "7.1.3",
 | 
			
		||||
    "jquery": "3.3.1",
 | 
			
		||||
    "natives": "^1.1.6",
 | 
			
		||||
    "popper.js": "1.14.4",
 | 
			
		||||
    "video.js": "7.2.2",
 | 
			
		||||
    "vue": "2.5.17"
 | 
			
		||||
    "video.js": "7.2.2"
 | 
			
		||||
  },
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "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))
 | 
			
		||||
 | 
			
		||||
    def post_internal(self, resource: str, payl=None, skip_validation=False):
 | 
			
		||||
        """Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
 | 
			
		||||
        """Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810"""
 | 
			
		||||
        from eve.methods.post import post_internal
 | 
			
		||||
 | 
			
		||||
        url = self.config['URLS'][resource]
 | 
			
		||||
@@ -800,7 +800,7 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
 | 
			
		||||
    def put_internal(self, resource: str, payload=None, concurrency_check=False,
 | 
			
		||||
                     skip_validation=False, **lookup):
 | 
			
		||||
        """Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
 | 
			
		||||
        """Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810"""
 | 
			
		||||
        from eve.methods.put import put_internal
 | 
			
		||||
 | 
			
		||||
        url = self.config['URLS'][resource]
 | 
			
		||||
@@ -811,7 +811,7 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
 | 
			
		||||
    def patch_internal(self, resource: str, payload=None, concurrency_check=False,
 | 
			
		||||
                       skip_validation=False, **lookup):
 | 
			
		||||
        """Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
 | 
			
		||||
        """Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810"""
 | 
			
		||||
        from eve.methods.patch import patch_internal
 | 
			
		||||
 | 
			
		||||
        url = self.config['URLS'][resource]
 | 
			
		||||
@@ -822,7 +822,7 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
 | 
			
		||||
    def delete_internal(self, resource: str, concurrency_check=False,
 | 
			
		||||
                        suppress_callbacks=False, **lookup):
 | 
			
		||||
        """Workaround for Eve issue https://github.com/pyeve/eve/issues/810"""
 | 
			
		||||
        """Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810"""
 | 
			
		||||
        from eve.methods.delete import deleteitem_internal
 | 
			
		||||
 | 
			
		||||
        url = self.config['URLS'][resource]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
import logging
 | 
			
		||||
from html.parser import HTMLParser
 | 
			
		||||
 | 
			
		||||
from flask import request, current_app
 | 
			
		||||
from pillar.api.utils import gravatar
 | 
			
		||||
@@ -8,15 +7,6 @@ from pillar.auth import current_user
 | 
			
		||||
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):
 | 
			
		||||
    activities_collection = current_app.data.driver.db['activities']
 | 
			
		||||
    activities_subscriptions_collection = \
 | 
			
		||||
@@ -40,14 +30,9 @@ def notification_parse(notification):
 | 
			
		||||
    object_type = 'comment'
 | 
			
		||||
    object_name = ''
 | 
			
		||||
    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:
 | 
			
		||||
        owner = f"your {node_type}"
 | 
			
		||||
        owner = "your {0}".format(node['parent']['node_type'])
 | 
			
		||||
    else:
 | 
			
		||||
        parent_comment_user = users_collection.find_one(
 | 
			
		||||
            {'_id': node['parent']['user']})
 | 
			
		||||
@@ -55,22 +40,10 @@ def notification_parse(notification):
 | 
			
		||||
            user_name = 'their'
 | 
			
		||||
        else:
 | 
			
		||||
            user_name = "{0}'s".format(parent_comment_user['username'])
 | 
			
		||||
        owner = "{0} {1}".format(user_name, node['parent']['node_type'])
 | 
			
		||||
 | 
			
		||||
        owner = f"{user_name} {node_type}"
 | 
			
		||||
 | 
			
		||||
    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_type = node['parent']['node_type']
 | 
			
		||||
    context_object_name = owner
 | 
			
		||||
    context_object_id = activity['context_object']
 | 
			
		||||
    if activity['verb'] == 'replied':
 | 
			
		||||
        action = 'replied to'
 | 
			
		||||
@@ -79,15 +52,13 @@ def notification_parse(notification):
 | 
			
		||||
    else:
 | 
			
		||||
        action = activity['verb']
 | 
			
		||||
 | 
			
		||||
    action = f'{action} {owner}'
 | 
			
		||||
 | 
			
		||||
    lookup = {
 | 
			
		||||
        'user': current_user.user_id,
 | 
			
		||||
        'context_object_type': 'node',
 | 
			
		||||
        'context_object': context_object_id,
 | 
			
		||||
    }
 | 
			
		||||
    subscription = activities_subscriptions_collection.find_one(lookup)
 | 
			
		||||
    if subscription and subscription['notifications']['web'] is True:
 | 
			
		||||
    if subscription and subscription['notifications']['web'] == True:
 | 
			
		||||
        is_subscribed = True
 | 
			
		||||
    else:
 | 
			
		||||
        is_subscribed = False
 | 
			
		||||
@@ -148,8 +119,6 @@ def activity_subscribe(user_id, context_object_type, context_object_id):
 | 
			
		||||
 | 
			
		||||
    # If no subscription exists, we create one
 | 
			
		||||
    if not subscription:
 | 
			
		||||
        # Workaround for issue: https://github.com/pyeve/eve/issues/1174
 | 
			
		||||
        lookup['notifications'] = {}
 | 
			
		||||
        current_app.post_internal('activities-subscriptions', lookup)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import copy
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
@@ -5,12 +6,36 @@ from bson import ObjectId, tz_util
 | 
			
		||||
from eve.io.mongo import Validator
 | 
			
		||||
from flask import current_app
 | 
			
		||||
 | 
			
		||||
from pillar import markdown
 | 
			
		||||
import pillar.markdown
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
    def convert_properties(self, properties, node_schema):
 | 
			
		||||
@@ -112,7 +137,8 @@ class ValidateCustomFields(Validator):
 | 
			
		||||
        if val:
 | 
			
		||||
            # This ensures the modifications made by v's coercion rules are
 | 
			
		||||
            # visible to this validator's output.
 | 
			
		||||
            self.document[field] = v.document
 | 
			
		||||
            # TODO(fsiddi): this no longer works due to Cerberus internal changes.
 | 
			
		||||
            # self.current[field] = v.current
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        log.warning('Error validating properties for node %s: %s', self.document, v.errors)
 | 
			
		||||
@@ -157,19 +183,36 @@ class ValidateCustomFields(Validator):
 | 
			
		||||
        if ip.prefixlen() == 0:
 | 
			
		||||
            self._error(field_name, 'Zero-length prefix is not allowed')
 | 
			
		||||
 | 
			
		||||
    def _normalize_coerce_markdown(self, markdown_field: str) -> str:
 | 
			
		||||
    def _validator_markdown(self, field, value):
 | 
			
		||||
        """Convert MarkDown.
 | 
			
		||||
        """
 | 
			
		||||
        Cache markdown as html.
 | 
			
		||||
        my_log = log.getChild('_validator_markdown')
 | 
			
		||||
 | 
			
		||||
        :param markdown_field: name of the field containing Markdown
 | 
			
		||||
        :return: html string
 | 
			
		||||
        # Find this field inside the original document
 | 
			
		||||
        my_subdoc = self._subdoc_in_real_document()
 | 
			
		||||
        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_log = log.getChild('_normalize_coerce_markdown')
 | 
			
		||||
        mdown = self.document.get(markdown_field, '')
 | 
			
		||||
        html = markdown.markdown(mdown)
 | 
			
		||||
        my_log.debug('Generated html for markdown field %s in doc with id %s',
 | 
			
		||||
                     markdown_field, id(self.document))
 | 
			
		||||
        return html
 | 
			
		||||
        my_subdoc = getattr(self, 'persisted_document') or self.__real_document
 | 
			
		||||
        for item in self.document_path:
 | 
			
		||||
            my_subdoc = my_subdoc[item]
 | 
			
		||||
        return my_subdoc
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,5 @@
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
from pillar.api.node_types.utils import markdown_fields
 | 
			
		||||
 | 
			
		||||
STORAGE_BACKENDS = ["local", "pillar", "cdnsun", "gcs", "unittest"]
 | 
			
		||||
URL_PREFIX = 'api'
 | 
			
		||||
 | 
			
		||||
# Enable reads (GET), inserts (POST) and DELETE for resources/collections
 | 
			
		||||
@@ -186,7 +183,12 @@ organizations_schema = {
 | 
			
		||||
        'maxlength': 128,
 | 
			
		||||
        'required': True
 | 
			
		||||
    },
 | 
			
		||||
    **markdown_fields('description', maxlength=256),
 | 
			
		||||
    'description': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
        'maxlength': 256,
 | 
			
		||||
        'validator': 'markdown',
 | 
			
		||||
    },
 | 
			
		||||
    '_description_html': {'type': 'string'},
 | 
			
		||||
    'website': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
        'maxlength': 256,
 | 
			
		||||
@@ -319,7 +321,11 @@ nodes_schema = {
 | 
			
		||||
        'maxlength': 128,
 | 
			
		||||
        'required': True,
 | 
			
		||||
    },
 | 
			
		||||
    **markdown_fields('description'),
 | 
			
		||||
    'description': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
        'validator': 'markdown',
 | 
			
		||||
    },
 | 
			
		||||
    '_description_html': {'type': 'string'},
 | 
			
		||||
    'picture': _file_embedded_schema,
 | 
			
		||||
    'order': {
 | 
			
		||||
        'type': 'integer',
 | 
			
		||||
@@ -457,7 +463,7 @@ files_schema = {
 | 
			
		||||
    'backend': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
        'required': True,
 | 
			
		||||
        'allowed': STORAGE_BACKENDS,
 | 
			
		||||
        'allowed': ["local", "pillar", "cdnsun", "gcs", "unittest"]
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    # Where the file is in the backend storage itself. In the case of GCS,
 | 
			
		||||
@@ -569,7 +575,11 @@ projects_schema = {
 | 
			
		||||
        'maxlength': 128,
 | 
			
		||||
        'required': True,
 | 
			
		||||
    },
 | 
			
		||||
    **markdown_fields('description'),
 | 
			
		||||
    'description': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
        'validator': 'markdown',
 | 
			
		||||
    },
 | 
			
		||||
    '_description_html': {'type': 'string'},
 | 
			
		||||
    # Short summary for the project
 | 
			
		||||
    'summary': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
@@ -579,8 +589,6 @@ projects_schema = {
 | 
			
		||||
    'picture_square': _file_embedded_schema,
 | 
			
		||||
    # Header
 | 
			
		||||
    'picture_header': _file_embedded_schema,
 | 
			
		||||
    # Picture with a 16:9 aspect ratio (for Open Graph)
 | 
			
		||||
    'picture_16_9': _file_embedded_schema,
 | 
			
		||||
    'header_node': dict(
 | 
			
		||||
        nullable=True,
 | 
			
		||||
        **_node_embedded_schema
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,6 @@ import mimetypes
 | 
			
		||||
import os
 | 
			
		||||
import pathlib
 | 
			
		||||
import tempfile
 | 
			
		||||
import time
 | 
			
		||||
import typing
 | 
			
		||||
import uuid
 | 
			
		||||
from hashlib import md5
 | 
			
		||||
@@ -186,8 +185,8 @@ def _video_duration_seconds(filename: pathlib.Path) -> typing.Optional[int]:
 | 
			
		||||
        str(filename),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    duration = run(ffprobe_from_stream_args) or \
 | 
			
		||||
               run(ffprobe_from_container_args) or \
 | 
			
		||||
    duration = run(ffprobe_from_stream_args) or\
 | 
			
		||||
               run(ffprobe_from_container_args) or\
 | 
			
		||||
               None
 | 
			
		||||
    return duration
 | 
			
		||||
 | 
			
		||||
@@ -610,7 +609,6 @@ def refresh_links_for_backend(backend_name, chunk_size, expiry_seconds):
 | 
			
		||||
    import gcloud.exceptions
 | 
			
		||||
 | 
			
		||||
    my_log = log.getChild(f'refresh_links_for_backend.{backend_name}')
 | 
			
		||||
    start_time = time.time()
 | 
			
		||||
 | 
			
		||||
    # Retrieve expired links.
 | 
			
		||||
    files_collection = current_app.data.driver.db['files']
 | 
			
		||||
@@ -634,10 +632,10 @@ def refresh_links_for_backend(backend_name, chunk_size, expiry_seconds):
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    if 0 < chunk_size == document_count:
 | 
			
		||||
        my_log.info('Found %d documents to refresh, probably limited by the chunk size %d',
 | 
			
		||||
                    document_count, chunk_size)
 | 
			
		||||
        my_log.info('Found %d documents to refresh, probably limited by the chunk size.',
 | 
			
		||||
                    document_count)
 | 
			
		||||
    else:
 | 
			
		||||
        my_log.info('Found %d documents to refresh, chunk size=%d', document_count, chunk_size)
 | 
			
		||||
        my_log.info('Found %d documents to refresh.', document_count)
 | 
			
		||||
 | 
			
		||||
    refreshed = 0
 | 
			
		||||
    report_chunks = min(max(5, document_count // 25), 100)
 | 
			
		||||
@@ -681,10 +679,8 @@ def refresh_links_for_backend(backend_name, chunk_size, expiry_seconds):
 | 
			
		||||
                           'links', refreshed)
 | 
			
		||||
            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()
 | 
			
		||||
def create_file_doc(name, filename, content_type, length, project,
 | 
			
		||||
 
 | 
			
		||||
@@ -90,11 +90,12 @@ class Blob(metaclass=abc.ABCMeta):
 | 
			
		||||
 | 
			
		||||
    def __init__(self, name: str, bucket: Bucket) -> None:
 | 
			
		||||
        self.name = name
 | 
			
		||||
        """Name of this blob in the bucket."""
 | 
			
		||||
 | 
			
		||||
        self.bucket = bucket
 | 
			
		||||
        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')
 | 
			
		||||
 | 
			
		||||
    def __repr__(self):
 | 
			
		||||
@@ -132,19 +133,12 @@ class Blob(metaclass=abc.ABCMeta):
 | 
			
		||||
                                  file_size=file_size)
 | 
			
		||||
 | 
			
		||||
    @abc.abstractmethod
 | 
			
		||||
    def update_filename(self, filename: str, *, is_attachment=True):
 | 
			
		||||
    def update_filename(self, filename: str):
 | 
			
		||||
        """Sets the filename which is used when downloading the file.
 | 
			
		||||
        
 | 
			
		||||
        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
 | 
			
		||||
    def get_url(self, *, is_public: bool) -> str:
 | 
			
		||||
        """Returns the URL to access this blob.
 | 
			
		||||
 
 | 
			
		||||
@@ -174,7 +174,7 @@ class GoogleCloudStorageBlob(Blob):
 | 
			
		||||
        self.gblob.reload()
 | 
			
		||||
        self._size_in_bytes = self.gblob.size
 | 
			
		||||
 | 
			
		||||
    def update_filename(self, filename: str, *, is_attachment=True):
 | 
			
		||||
    def update_filename(self, filename: str):
 | 
			
		||||
        """Set the ContentDisposition metadata so that when a file is downloaded
 | 
			
		||||
        it has a human-readable name.
 | 
			
		||||
        """
 | 
			
		||||
@@ -182,17 +182,7 @@ class GoogleCloudStorageBlob(Blob):
 | 
			
		||||
        if '"' in filename:
 | 
			
		||||
            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}"'
 | 
			
		||||
        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.content_disposition = f'attachment; filename="{filename}"'
 | 
			
		||||
        self.gblob.patch()
 | 
			
		||||
 | 
			
		||||
    def get_url(self, *, is_public: bool) -> str:
 | 
			
		||||
 
 | 
			
		||||
@@ -113,13 +113,10 @@ class LocalBlob(Blob):
 | 
			
		||||
 | 
			
		||||
        self._size_in_bytes = file_size
 | 
			
		||||
 | 
			
		||||
    def update_filename(self, filename: str, *, is_attachment=True):
 | 
			
		||||
    def update_filename(self, filename: str):
 | 
			
		||||
        # TODO: implement this for local storage.
 | 
			
		||||
        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):
 | 
			
		||||
        # No-op on this storage backend.
 | 
			
		||||
        pass
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,14 @@ attachments_embedded_schema = {
 | 
			
		||||
                'type': 'objectid',
 | 
			
		||||
                'required': True,
 | 
			
		||||
            },
 | 
			
		||||
            'link': {
 | 
			
		||||
                'type': 'string',
 | 
			
		||||
                'allowed': ['self', 'none', 'custom'],
 | 
			
		||||
                'default': 'self',
 | 
			
		||||
            },
 | 
			
		||||
            'link_custom': {
 | 
			
		||||
                'type': 'string',
 | 
			
		||||
            },
 | 
			
		||||
            'collection': {
 | 
			
		||||
                'type': 'string',
 | 
			
		||||
                '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 = {
 | 
			
		||||
    'name': 'comment',
 | 
			
		||||
    'description': 'Comments for asset nodes, pages, etc.',
 | 
			
		||||
    'dyn_schema': {
 | 
			
		||||
        # The actual comment content
 | 
			
		||||
        **markdown_fields(
 | 
			
		||||
            'content',
 | 
			
		||||
            minlength=5,
 | 
			
		||||
            required=True),
 | 
			
		||||
        'content': {
 | 
			
		||||
            'type': 'string',
 | 
			
		||||
            'minlength': 5,
 | 
			
		||||
            'required': True,
 | 
			
		||||
            'validator': 'markdown',
 | 
			
		||||
        },
 | 
			
		||||
        '_content_html': {'type': 'string'},
 | 
			
		||||
        'status': {
 | 
			
		||||
            'type': 'string',
 | 
			
		||||
            'allowed': [
 | 
			
		||||
@@ -51,8 +51,7 @@ node_type_comment = {
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        'confidence': {'type': 'float'},
 | 
			
		||||
        'is_reply': {'type': 'boolean'},
 | 
			
		||||
        'attachments': attachments_embedded_schema,
 | 
			
		||||
        'is_reply': {'type': 'boolean'}
 | 
			
		||||
    },
 | 
			
		||||
    'form_schema': {},
 | 
			
		||||
    'parent': ['asset', 'comment'],
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,17 @@
 | 
			
		||||
from pillar.api.node_types import attachments_embedded_schema
 | 
			
		||||
from pillar.api.node_types.utils import markdown_fields
 | 
			
		||||
 | 
			
		||||
node_type_post = {
 | 
			
		||||
    'name': 'post',
 | 
			
		||||
    'description': 'A blog post, for any project',
 | 
			
		||||
    'dyn_schema': {
 | 
			
		||||
        **markdown_fields('content',
 | 
			
		||||
                          minlength=5,
 | 
			
		||||
                          maxlength=90000,
 | 
			
		||||
                          required=True),
 | 
			
		||||
        'content': {
 | 
			
		||||
            'type': 'string',
 | 
			
		||||
            'minlength': 5,
 | 
			
		||||
            'maxlength': 90000,
 | 
			
		||||
            'required': True,
 | 
			
		||||
            'validator': 'markdown',
 | 
			
		||||
        },
 | 
			
		||||
        '_content_html': {'type': 'string'},
 | 
			
		||||
        'status': {
 | 
			
		||||
            'type': 'string',
 | 
			
		||||
            'allowed': [
 | 
			
		||||
 
 | 
			
		||||
@@ -1,34 +0,0 @@
 | 
			
		||||
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,15 +6,14 @@ import pymongo.errors
 | 
			
		||||
import werkzeug.exceptions as wz_exceptions
 | 
			
		||||
from flask import current_app, Blueprint, request
 | 
			
		||||
 | 
			
		||||
from pillar.api.nodes import eve_hooks, comments, activities
 | 
			
		||||
from pillar.api.nodes import eve_hooks
 | 
			
		||||
from pillar.api.utils import str2id, jsonify
 | 
			
		||||
from pillar.api.utils.authorization import check_permissions, require_login
 | 
			
		||||
from pillar.web.utils import pretty_date
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
blueprint = Blueprint('nodes_api', __name__)
 | 
			
		||||
# TODO(fsiddi) Propose changes to make commenting roles a configuration value.
 | 
			
		||||
ROLES_FOR_SHARING = ROLES_FOR_COMMENTING = set()
 | 
			
		||||
ROLES_FOR_SHARING = {'subscriber', 'demo'}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
 | 
			
		||||
@@ -52,47 +51,6 @@ def share_node(node_id):
 | 
			
		||||
    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/<tag>')
 | 
			
		||||
def tagged(tag=''):
 | 
			
		||||
@@ -254,12 +212,14 @@ def setup_app(app, url_prefix):
 | 
			
		||||
    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.parse_markdown
 | 
			
		||||
    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.node_set_default_picture
 | 
			
		||||
    app.on_replaced_nodes += eve_hooks.after_replacing_node
 | 
			
		||||
 | 
			
		||||
    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_set_default_picture
 | 
			
		||||
    app.on_insert_nodes += eve_hooks.textures_sort_files
 | 
			
		||||
@@ -271,5 +231,3 @@ def setup_app(app, url_prefix):
 | 
			
		||||
    app.on_deleted_item_nodes += eve_hooks.after_deleting_node
 | 
			
		||||
 | 
			
		||||
    app.register_api_blueprint(blueprint, url_prefix=url_prefix)
 | 
			
		||||
 | 
			
		||||
    activities.setup_app(app)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,43 +0,0 @@
 | 
			
		||||
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)
 | 
			
		||||
@@ -1,298 +0,0 @@
 | 
			
		||||
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,8 @@ import logging
 | 
			
		||||
from flask import current_app
 | 
			
		||||
import werkzeug.exceptions as wz_exceptions
 | 
			
		||||
 | 
			
		||||
from pillar.api.utils import authorization, authentication, jsonify, remove_private_keys
 | 
			
		||||
from pillar.api.utils import authorization, authentication, jsonify
 | 
			
		||||
from pillar.api.utils.rating import confidence
 | 
			
		||||
 | 
			
		||||
from . import register_patch_handler
 | 
			
		||||
 | 
			
		||||
@@ -25,6 +26,13 @@ def patch_comment(node_id, patch):
 | 
			
		||||
        assert patch['op'] == 'edit', 'Invalid patch operation %s' % patch['op']
 | 
			
		||||
        result, node = edit_comment(user_id, node_id, patch)
 | 
			
		||||
 | 
			
		||||
    # Calculate and update confidence.
 | 
			
		||||
    rating_confidence = confidence(
 | 
			
		||||
        node['properties']['rating_positive'], node['properties']['rating_negative'])
 | 
			
		||||
    current_app.data.driver.db['nodes'].update_one(
 | 
			
		||||
        {'_id': node_id},
 | 
			
		||||
        {'$set': {'properties.confidence': rating_confidence}})
 | 
			
		||||
 | 
			
		||||
    return jsonify({'_status': 'OK',
 | 
			
		||||
                    'result': result,
 | 
			
		||||
                    'properties': node['properties']
 | 
			
		||||
@@ -135,7 +143,10 @@ def edit_comment(user_id, node_id, patch):
 | 
			
		||||
    # we can pass this stuff to Eve's patch_internal; that way the validation &
 | 
			
		||||
    # authorisation system has enough info to work.
 | 
			
		||||
    nodes_coll = current_app.data.driver.db['nodes']
 | 
			
		||||
    node = nodes_coll.find_one(node_id)
 | 
			
		||||
    projection = {'user': 1,
 | 
			
		||||
                  'project': 1,
 | 
			
		||||
                  'node_type': 1}
 | 
			
		||||
    node = nodes_coll.find_one(node_id, projection=projection)
 | 
			
		||||
    if node is None:
 | 
			
		||||
        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)
 | 
			
		||||
@@ -143,14 +154,14 @@ def edit_comment(user_id, node_id, patch):
 | 
			
		||||
    if node['user'] != user_id and not authorization.user_has_role('admin'):
 | 
			
		||||
        raise wz_exceptions.Forbidden('You can only edit your own comments.')
 | 
			
		||||
 | 
			
		||||
    node = remove_private_keys(node)
 | 
			
		||||
    node['properties']['content'] = patch['content']
 | 
			
		||||
    node['properties']['attachments'] = patch.get('attachments', {})
 | 
			
		||||
    # Use Eve to PUT this node, as that also updates the etag and we want to replace attachments.
 | 
			
		||||
    r, _, _, status = current_app.put_internal('nodes',
 | 
			
		||||
                                               node,
 | 
			
		||||
                                               concurrency_check=False,
 | 
			
		||||
                                               _id=node_id)
 | 
			
		||||
    # Use Eve to PATCH this node, as that also updates the etag.
 | 
			
		||||
    r, _, _, status = current_app.patch_internal('nodes',
 | 
			
		||||
                                                 {'properties.content': patch['content'],
 | 
			
		||||
                                                  'project': node['project'],
 | 
			
		||||
                                                  'user': node['user'],
 | 
			
		||||
                                                  'node_type': node['node_type']},
 | 
			
		||||
                                                 concurrency_check=False,
 | 
			
		||||
                                                 _id=node_id)
 | 
			
		||||
    if status != 200:
 | 
			
		||||
        log.error('Error %i editing comment %s for user %s: %s',
 | 
			
		||||
                  status, node_id, user_id, r)
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ from bson import ObjectId
 | 
			
		||||
from werkzeug import exceptions as wz_exceptions
 | 
			
		||||
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
import pillar.markdown
 | 
			
		||||
from pillar.api.activities import activity_subscribe, activity_object_add
 | 
			
		||||
from pillar.api.file_storage_backends.gcs import update_file_name
 | 
			
		||||
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
 | 
			
		||||
@@ -69,22 +70,6 @@ def before_replacing_node(item, original):
 | 
			
		||||
    check_permissions('nodes', original, 'PUT')
 | 
			
		||||
    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):
 | 
			
		||||
    """Push an update to the Algolia index when a node item is updated. If the
 | 
			
		||||
@@ -138,49 +123,46 @@ def before_inserting_nodes(items):
 | 
			
		||||
        item.setdefault('user', current_user.user_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_comment_verb_and_context_object_id(comment):
 | 
			
		||||
    nodes_collection = current_app.data.driver.db['nodes']
 | 
			
		||||
    verb = 'commented'
 | 
			
		||||
    parent = nodes_collection.find_one({'_id': comment['parent']})
 | 
			
		||||
    context_object_id = comment['parent']
 | 
			
		||||
    while parent['node_type'] == 'comment':
 | 
			
		||||
        # 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
 | 
			
		||||
        # or group when viewing the notification.
 | 
			
		||||
        verb = 'replied'
 | 
			
		||||
        context_object_id = parent['parent']
 | 
			
		||||
        parent = nodes_collection.find_one({'_id': parent['parent']})
 | 
			
		||||
    return verb, context_object_id
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def after_inserting_nodes(items):
 | 
			
		||||
    for item in items:
 | 
			
		||||
        context_object_id = None
 | 
			
		||||
        # 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 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)
 | 
			
		||||
        if 'parent' not in item:
 | 
			
		||||
            return
 | 
			
		||||
        context_object_id = item['parent']
 | 
			
		||||
        if item['node_type'] == 'comment':
 | 
			
		||||
            nodes_collection = current_app.data.driver.db['nodes']
 | 
			
		||||
            parent = nodes_collection.find_one({'_id': item['parent']})
 | 
			
		||||
            # Always subscribe to the parent node
 | 
			
		||||
            activity_subscribe(item['user'], 'node', item['parent'])
 | 
			
		||||
            if parent['node_type'] == 'comment':
 | 
			
		||||
                # 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
 | 
			
		||||
                # or group when viewing the notification.
 | 
			
		||||
                verb = 'replied'
 | 
			
		||||
                context_object_id = parent['parent']
 | 
			
		||||
                # 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,
 | 
			
		||||
                activity_subscribe(item['user'], 'node', parent['parent'])
 | 
			
		||||
            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).
 | 
			
		||||
            activity_object_add(
 | 
			
		||||
                item['user'],
 | 
			
		||||
                verb,
 | 
			
		||||
                'node',
 | 
			
		||||
                item['_id'],
 | 
			
		||||
                'node',
 | 
			
		||||
                context_object_id
 | 
			
		||||
            )
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        activity_object_add(
 | 
			
		||||
            item['user'],
 | 
			
		||||
            verb,
 | 
			
		||||
            'node',
 | 
			
		||||
            item['_id'],
 | 
			
		||||
            'node',
 | 
			
		||||
            context_object_id
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def deduct_content_type_and_duration(node_doc, original=None):
 | 
			
		||||
@@ -340,6 +322,46 @@ def textures_sort_files(nodes):
 | 
			
		||||
        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):
 | 
			
		||||
    """Returns the short link info in a dict."""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@ def setup_app(app, api_prefix):
 | 
			
		||||
    app.on_replace_projects += hooks.override_is_private_field
 | 
			
		||||
    app.on_replace_projects += hooks.before_edit_check_permissions
 | 
			
		||||
    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.before_edit_check_permissions
 | 
			
		||||
@@ -20,8 +19,6 @@ def setup_app(app, api_prefix):
 | 
			
		||||
 | 
			
		||||
    app.on_insert_projects += hooks.before_inserting_override_is_private_field
 | 
			
		||||
    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_fetched_item_projects += hooks.before_returning_project_permissions
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,6 @@ import logging
 | 
			
		||||
 | 
			
		||||
from flask import request, abort
 | 
			
		||||
 | 
			
		||||
import pillar
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
from pillar.api.node_types.asset import node_type_asset
 | 
			
		||||
from pillar.api.node_types.comment import node_type_comment
 | 
			
		||||
@@ -247,37 +246,3 @@ def project_node_type_has_method(response):
 | 
			
		||||
def projects_node_type_has_method(response):
 | 
			
		||||
    for project in response['_items']:
 | 
			
		||||
        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,7 +7,6 @@ from werkzeug.exceptions import abort
 | 
			
		||||
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
from pillar.auth import current_user
 | 
			
		||||
from pillar.api import file_storage_backends
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -156,18 +155,6 @@ def project_id(project_url: str) -> ObjectId:
 | 
			
		||||
    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:
 | 
			
		||||
    """Find a project in the database, raises ValueError if not found.
 | 
			
		||||
 | 
			
		||||
@@ -200,14 +187,3 @@ def put_project(project: dict):
 | 
			
		||||
    if status_code != 200:
 | 
			
		||||
        raise ValueError(f"Can't update project {pid}, "
 | 
			
		||||
                         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,12 +49,13 @@ def search_nodes():
 | 
			
		||||
    result = queries.do_node_search(searchword, terms, page_idx, project_id)
 | 
			
		||||
    return jsonify(result)
 | 
			
		||||
 | 
			
		||||
@blueprint_search.route('/multisearch', methods=['POST'])
 | 
			
		||||
@blueprint_search.route('/multisearch', methods=['GET'])
 | 
			
		||||
def multi_search_nodes():
 | 
			
		||||
    import json
 | 
			
		||||
    if len(request.args) != 1:
 | 
			
		||||
        log.info(f'Expected 1 argument, received {len(request.args)}')
 | 
			
		||||
 | 
			
		||||
    json_obj = request.json
 | 
			
		||||
    json_obj = json.loads([a for a in request.args][0])
 | 
			
		||||
    q = []
 | 
			
		||||
    for row in json_obj:
 | 
			
		||||
        q.append({
 | 
			
		||||
 
 | 
			
		||||
@@ -46,7 +46,6 @@ class SearchHelper:
 | 
			
		||||
                created = {'_created': {'$gt': continue_from}}
 | 
			
		||||
        return {'_deleted': {'$ne': True},
 | 
			
		||||
                'node_type': {'$in': ['asset', 'post']},
 | 
			
		||||
                'properties.status': {'$eq': 'published'},
 | 
			
		||||
                'project': {'$in': self._project_ids},
 | 
			
		||||
                **created,
 | 
			
		||||
                }
 | 
			
		||||
 
 | 
			
		||||
@@ -44,16 +44,10 @@ def remove_private_keys(document):
 | 
			
		||||
    """Removes any key that starts with an underscore, returns result as new
 | 
			
		||||
    dictionary.
 | 
			
		||||
    """
 | 
			
		||||
    def do_remove(doc):
 | 
			
		||||
        for key in list(doc.keys()):
 | 
			
		||||
            if key.startswith('_'):
 | 
			
		||||
                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)
 | 
			
		||||
    for key in list(doc_copy.keys()):
 | 
			
		||||
        if key.startswith('_'):
 | 
			
		||||
            del doc_copy[key]
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        del doc_copy['allowed_methods']
 | 
			
		||||
@@ -63,7 +57,7 @@ def remove_private_keys(document):
 | 
			
		||||
    return doc_copy
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def pretty_duration(seconds: typing.Union[None, int, float]):
 | 
			
		||||
def pretty_duration(seconds):
 | 
			
		||||
    if seconds is None:
 | 
			
		||||
        return ''
 | 
			
		||||
    seconds = round(seconds)
 | 
			
		||||
@@ -75,27 +69,6 @@ def pretty_duration(seconds: typing.Union[None, int, float]):
 | 
			
		||||
        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):
 | 
			
		||||
    """JSON encoder with support for Pillar resources."""
 | 
			
		||||
 | 
			
		||||
@@ -223,8 +196,7 @@ def doc_diff(doc1, doc2, *, falsey_is_equal=True, superkey: str = None):
 | 
			
		||||
    function won't report differences between DoesNotExist, False, '', and 0.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def is_private(key):
 | 
			
		||||
        return str(key).startswith('_')
 | 
			
		||||
    private_keys = {'_id', '_etag', '_deleted', '_updated', '_created'}
 | 
			
		||||
 | 
			
		||||
    def combine_key(some_key):
 | 
			
		||||
        """Combine this key with the superkey.
 | 
			
		||||
@@ -245,7 +217,7 @@ def doc_diff(doc1, doc2, *, falsey_is_equal=True, superkey: str = None):
 | 
			
		||||
 | 
			
		||||
    if isinstance(doc1, dict) and isinstance(doc2, dict):
 | 
			
		||||
        for key in set(doc1.keys()).union(set(doc2.keys())):
 | 
			
		||||
            if is_private(key):
 | 
			
		||||
            if key in private_keys:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            val1 = doc1.get(key, DoesNotExist)
 | 
			
		||||
 
 | 
			
		||||
@@ -331,9 +331,8 @@ def require_login(*, require_roles=set(),
 | 
			
		||||
 | 
			
		||||
    def render_error() -> Response:
 | 
			
		||||
        if error_view is None:
 | 
			
		||||
            resp = Forbidden().get_response()
 | 
			
		||||
        else:
 | 
			
		||||
            resp = error_view()
 | 
			
		||||
            abort(403)
 | 
			
		||||
        resp: Response = error_view()
 | 
			
		||||
        resp.status_code = 403
 | 
			
		||||
        return resp
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,8 +9,12 @@ string = functools.partial(attr.ib, validator=attr.validators.instance_of(str))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def log(name):
 | 
			
		||||
    """Returns a logger
 | 
			
		||||
    """Returns a logger attr.ib
 | 
			
		||||
 | 
			
		||||
    :param name: name to pass to logging.getLogger()
 | 
			
		||||
    :rtype: attr.ib
 | 
			
		||||
    """
 | 
			
		||||
    return logging.getLogger(name)
 | 
			
		||||
    return attr.ib(default=logging.getLogger(name),
 | 
			
		||||
                   repr=False,
 | 
			
		||||
                   hash=False,
 | 
			
		||||
                   cmp=False)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,48 +0,0 @@
 | 
			
		||||
"""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,9 +1,7 @@
 | 
			
		||||
import collections
 | 
			
		||||
import copy
 | 
			
		||||
import datetime
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
from pathlib import PurePosixPath, Path
 | 
			
		||||
from pathlib import PurePosixPath
 | 
			
		||||
import re
 | 
			
		||||
import typing
 | 
			
		||||
 | 
			
		||||
@@ -14,7 +12,6 @@ from flask_script import Manager
 | 
			
		||||
import pymongo
 | 
			
		||||
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
import pillar.api.utils
 | 
			
		||||
 | 
			
		||||
# Collections to skip when finding file references (during orphan file detection).
 | 
			
		||||
# This collection can be added to from PillarExtension.setup_app().
 | 
			
		||||
@@ -739,6 +736,113 @@ def iter_markdown(proj_node_types: dict, some_node: dict, callback: typing.Calla
 | 
			
		||||
            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) \
 | 
			
		||||
        -> typing.Iterable[dict]:
 | 
			
		||||
    """Yields a subset of the projects in the database.
 | 
			
		||||
@@ -778,38 +882,14 @@ def _db_projects(proj_url: str, all_projects: bool, project_id='', *, go: bool)
 | 
			
		||||
    log.info('Command took %s', duration)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def find_object_ids(something: typing.Any) -> typing.Iterable[bson.ObjectId]:
 | 
			
		||||
    """Generator, yields all ObjectIDs referenced by the given object.
 | 
			
		||||
 | 
			
		||||
    Assumes 'something' comes from a MongoDB. This function wasn't made for
 | 
			
		||||
    generic Python objects.
 | 
			
		||||
    """
 | 
			
		||||
    if isinstance(something, bson.ObjectId):
 | 
			
		||||
        yield something
 | 
			
		||||
    elif isinstance(something, str) and len(something) == 24:
 | 
			
		||||
        try:
 | 
			
		||||
            yield bson.ObjectId(something)
 | 
			
		||||
        except (bson.objectid.InvalidId, TypeError):
 | 
			
		||||
            # It apparently wasn't an ObjectID after all.
 | 
			
		||||
            pass
 | 
			
		||||
    elif isinstance(something, (list, set, tuple)):
 | 
			
		||||
        for item in something:
 | 
			
		||||
            yield from find_object_ids(item)
 | 
			
		||||
    elif isinstance(something, dict):
 | 
			
		||||
        for item in something.keys():
 | 
			
		||||
            yield from find_object_ids(item)
 | 
			
		||||
        for item in something.values():
 | 
			
		||||
            yield from find_object_ids(item)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _find_orphan_files() -> typing.Set[bson.ObjectId]:
 | 
			
		||||
    """Finds all non-referenced files.
 | 
			
		||||
    """Finds all non-referenced files for the given project.
 | 
			
		||||
 | 
			
		||||
    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.
 | 
			
		||||
    # 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}
 | 
			
		||||
@@ -820,6 +900,22 @@ def _find_orphan_files() -> typing.Set[bson.ObjectId]:
 | 
			
		||||
    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):
 | 
			
		||||
            yield something
 | 
			
		||||
        elif isinstance(something, str) and len(something) == 24:
 | 
			
		||||
            try:
 | 
			
		||||
                yield bson.ObjectId(something)
 | 
			
		||||
            except (bson.objectid.InvalidId, TypeError):
 | 
			
		||||
                # It apparently wasn't an ObjectID after all.
 | 
			
		||||
                pass
 | 
			
		||||
        elif isinstance(something, (list, set, tuple)):
 | 
			
		||||
            for item in something:
 | 
			
		||||
                yield from find_object_ids(item)
 | 
			
		||||
        elif isinstance(something, dict):
 | 
			
		||||
            for item in something.values():
 | 
			
		||||
                yield from find_object_ids(item)
 | 
			
		||||
 | 
			
		||||
    # Find all references by iterating through the project itself and every document that has a
 | 
			
		||||
    # 'project' key set to this ObjectId.
 | 
			
		||||
    db = current_app.db()
 | 
			
		||||
@@ -849,6 +945,7 @@ def find_orphan_files():
 | 
			
		||||
    This is a heavy operation that inspects *everything* in MongoDB. Use with care.
 | 
			
		||||
    """
 | 
			
		||||
    from jinja2.filters import do_filesizeformat
 | 
			
		||||
    from pathlib import Path
 | 
			
		||||
 | 
			
		||||
    output_fpath = Path(current_app.config['STORAGE_DIR']) / 'orphan-files.txt'
 | 
			
		||||
    if output_fpath.exists():
 | 
			
		||||
@@ -894,6 +991,7 @@ def delete_orphan_files():
 | 
			
		||||
    Use 'find_orphan_files' first to generate orphan-files.txt.
 | 
			
		||||
    """
 | 
			
		||||
    import pymongo.results
 | 
			
		||||
    from pathlib import Path
 | 
			
		||||
 | 
			
		||||
    output_fpath = Path(current_app.config['STORAGE_DIR']) / 'orphan-files.txt'
 | 
			
		||||
    with output_fpath.open('r', encoding='ascii') as infile:
 | 
			
		||||
@@ -932,6 +1030,7 @@ def find_video_files_without_duration():
 | 
			
		||||
 | 
			
		||||
    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'
 | 
			
		||||
    if output_fpath.exists():
 | 
			
		||||
@@ -963,13 +1062,13 @@ def find_video_files_without_duration():
 | 
			
		||||
    with output_fpath.open('w', encoding='ascii') as outfile:
 | 
			
		||||
        outfile.write('\n'.join(sorted(file_ids)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@manager_maintenance.command
 | 
			
		||||
def find_video_nodes_without_duration():
 | 
			
		||||
    """Finds video nodes without any duration
 | 
			
		||||
 | 
			
		||||
    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'
 | 
			
		||||
    if output_fpath.exists():
 | 
			
		||||
@@ -1041,8 +1140,7 @@ def reconcile_node_video_duration(nodes_to_update=None, all_nodes=False, go=Fals
 | 
			
		||||
            {'$unwind': '$_files.variations'},
 | 
			
		||||
            {'$match': {'_files.variations.duration': {'$gt': 0}}},
 | 
			
		||||
            {'$addFields': {
 | 
			
		||||
                'need_update': {
 | 
			
		||||
                    '$ne': ['$_files.variations.duration', '$properties.duration_seconds']}
 | 
			
		||||
                'need_update': {'$ne': ['$_files.variations.duration', '$properties.duration_seconds']}
 | 
			
		||||
            }},
 | 
			
		||||
            {'$match': {'need_update': True}},
 | 
			
		||||
            {'$project': {
 | 
			
		||||
@@ -1077,259 +1175,3 @@ def reconcile_node_video_duration(nodes_to_update=None, all_nodes=False, go=Fals
 | 
			
		||||
    duration = end_timestamp - start_timestamp
 | 
			
		||||
    log.info('Operation took %s', duration)
 | 
			
		||||
    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()
 | 
			
		||||
 | 
			
		||||
CELERY_BACKEND = 'redis://redis/1'
 | 
			
		||||
CELERY_BROKER = 'redis://redis/2'
 | 
			
		||||
CELERY_BROKER = 'amqp://guest:guest@rabbit//'
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
 
 | 
			
		||||
@@ -174,10 +174,6 @@ class AbstractPillarTest(TestMinimal):
 | 
			
		||||
        for modname in remove:
 | 
			
		||||
            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):
 | 
			
		||||
        if example_file is None:
 | 
			
		||||
            example_file = ctd.EXAMPLE_FILE
 | 
			
		||||
@@ -355,15 +351,13 @@ class AbstractPillarTest(TestMinimal):
 | 
			
		||||
 | 
			
		||||
    # TODO: rename to 'create_auth_token' now that 'expire_in_days' can be negative.
 | 
			
		||||
    def create_valid_auth_token(self,
 | 
			
		||||
                                user_id: typing.Union[str, ObjectId],
 | 
			
		||||
                                user_id: ObjectId,
 | 
			
		||||
                                token='token',
 | 
			
		||||
                                *,
 | 
			
		||||
                                oauth_scopes: typing.Optional[typing.List[str]]=None,
 | 
			
		||||
                                expire_in_days=1) -> dict:
 | 
			
		||||
        from pillar.api.utils import utcnow
 | 
			
		||||
 | 
			
		||||
        if isinstance(user_id, str):
 | 
			
		||||
            user_id = ObjectId(user_id)
 | 
			
		||||
        future = utcnow() + datetime.timedelta(days=expire_in_days)
 | 
			
		||||
 | 
			
		||||
        with self.app.test_request_context():
 | 
			
		||||
 
 | 
			
		||||
@@ -73,9 +73,9 @@ EXAMPLE_PROJECT = {
 | 
			
		||||
    'nodes_featured': [],
 | 
			
		||||
    'nodes_latest': [],
 | 
			
		||||
    'permissions': {'groups': [{'group': EXAMPLE_ADMIN_GROUP_ID,
 | 
			
		||||
                                'methods': ['GET', 'POST', 'PUT', 'DELETE']}],
 | 
			
		||||
                    'users': [],
 | 
			
		||||
                    'world': ['GET']},
 | 
			
		||||
                                  'methods': ['GET', 'POST', 'PUT', 'DELETE']}],
 | 
			
		||||
                     'users': [],
 | 
			
		||||
                     'world': ['GET']},
 | 
			
		||||
    'picture_header': ObjectId('5673f260c379cf0007b31bc4'),
 | 
			
		||||
    'picture_square': ObjectId('5673f256c379cf0007b31bc3'),
 | 
			
		||||
    'status': 'published',
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,6 @@ import werkzeug.exceptions as wz_exceptions
 | 
			
		||||
import pillarsdk
 | 
			
		||||
 | 
			
		||||
import pillar.api.utils
 | 
			
		||||
from pillar import auth
 | 
			
		||||
from pillar.api.utils import pretty_duration
 | 
			
		||||
from pillar.web.utils import pretty_date
 | 
			
		||||
from pillar.web.nodes.routes import url_for_node
 | 
			
		||||
@@ -35,10 +34,6 @@ def format_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):
 | 
			
		||||
    """Underscore-replacing title filter.
 | 
			
		||||
 | 
			
		||||
@@ -211,24 +206,9 @@ def do_yesno(value, arg=None):
 | 
			
		||||
    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:
 | 
			
		||||
    if isinstance(some_object, pillarsdk.Resource):
 | 
			
		||||
        some_object = some_object.to_dict()
 | 
			
		||||
    if isinstance(some_object, auth.UserClass):
 | 
			
		||||
        some_object = user_to_dict(some_object)
 | 
			
		||||
    return json.dumps(some_object)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -236,7 +216,6 @@ def setup_jinja_env(jinja_env, app_config: dict):
 | 
			
		||||
    jinja_env.filters['pretty_date'] = format_pretty_date
 | 
			
		||||
    jinja_env.filters['pretty_date_time'] = format_pretty_date_time
 | 
			
		||||
    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['hide_none'] = do_hide_none
 | 
			
		||||
    jinja_env.filters['pluralize'] = do_pluralize
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										246
									
								
								pillar/web/nodes/custom/comments.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										246
									
								
								pillar/web/nodes/custom/comments.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,246 @@
 | 
			
		||||
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,7 +109,6 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
 | 
			
		||||
        project.blog_archive_prev = None
 | 
			
		||||
 | 
			
		||||
    navigation_links = project_navigation_links(project, api)
 | 
			
		||||
    extension_sidebar_links = current_app.extension_sidebar_links(project)
 | 
			
		||||
 | 
			
		||||
    return render_template(
 | 
			
		||||
        template_path,
 | 
			
		||||
@@ -122,7 +121,6 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
 | 
			
		||||
        node_type_post=project.get_node_type('post'),
 | 
			
		||||
        can_create_blog_posts=can_create_blog_posts,
 | 
			
		||||
        navigation_links=navigation_links,
 | 
			
		||||
        extension_sidebar_links=extension_sidebar_links,
 | 
			
		||||
        api=api)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -48,12 +48,9 @@ def find_for_comment(project, node):
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            parent = Node.find_one({'where': {
 | 
			
		||||
                '_id': parent.parent,
 | 
			
		||||
                '_deleted': {'$ne': True}
 | 
			
		||||
            }}, api=api)
 | 
			
		||||
            parent = Node.find(parent.parent, api=api)
 | 
			
		||||
        except ResourceNotFound:
 | 
			
		||||
            log.debug(
 | 
			
		||||
            log.warning(
 | 
			
		||||
                'url_for_node(node_id=%r): Unable to find parent node %r',
 | 
			
		||||
                node['_id'], parent.parent)
 | 
			
		||||
            raise ValueError('Unable to find parent node %r' % parent.parent)
 | 
			
		||||
 
 | 
			
		||||
@@ -50,7 +50,6 @@ def iter_node_properties(node_type):
 | 
			
		||||
@functools.lru_cache(maxsize=1)
 | 
			
		||||
def tag_choices() -> typing.List[typing.Tuple[str, str]]:
 | 
			
		||||
    """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 []
 | 
			
		||||
    return [(tag, tag.title()) for tag in tags]  # (value, label) tuples
 | 
			
		||||
 | 
			
		||||
@@ -71,7 +70,9 @@ def add_form_properties(form_class, node_type):
 | 
			
		||||
        # Recursive call if detects a dict
 | 
			
		||||
        field_type = schema_prop['type']
 | 
			
		||||
 | 
			
		||||
        if field_type == 'dict':
 | 
			
		||||
        if prop_name == 'tags' and field_type == 'list':
 | 
			
		||||
            field = SelectMultipleField(choices=tag_choices())
 | 
			
		||||
        elif field_type == 'dict':
 | 
			
		||||
            assert prop_name == 'attachments'
 | 
			
		||||
            field = attachments.attachment_form_group_create(schema_prop)
 | 
			
		||||
        elif field_type == 'list':
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
import os
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
 | 
			
		||||
import pillarsdk
 | 
			
		||||
from pillar import shortcodes
 | 
			
		||||
from pillarsdk import Node
 | 
			
		||||
from pillarsdk import Project
 | 
			
		||||
from pillarsdk.exceptions import ResourceNotFound
 | 
			
		||||
@@ -17,12 +17,15 @@ from flask import request
 | 
			
		||||
from flask import jsonify
 | 
			
		||||
from flask import abort
 | 
			
		||||
from flask_login import current_user
 | 
			
		||||
from flask_wtf.csrf import validate_csrf
 | 
			
		||||
 | 
			
		||||
import werkzeug.exceptions as wz_exceptions
 | 
			
		||||
from wtforms import SelectMultipleField
 | 
			
		||||
from flask_login import login_required
 | 
			
		||||
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.web.nodes.forms import get_node_form
 | 
			
		||||
from pillar.web.nodes.forms import process_node_form
 | 
			
		||||
@@ -105,11 +108,6 @@ def view(node_id, extra_template_args: dict=None):
 | 
			
		||||
 | 
			
		||||
    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'):
 | 
			
		||||
        # Posts shouldn't be shown at this route (unless viewed embedded, tipically
 | 
			
		||||
        # after an edit. Redirect to the correct one.
 | 
			
		||||
@@ -489,14 +487,11 @@ def preview_markdown():
 | 
			
		||||
    current_app.csrf.protect()
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        content = request.json['content']
 | 
			
		||||
        content = request.form['content']
 | 
			
		||||
    except KeyError:
 | 
			
		||||
        return jsonify({'_status': 'ERR',
 | 
			
		||||
                        'message': 'The field "content" was not specified.'}), 400
 | 
			
		||||
    html = markdown(content)
 | 
			
		||||
    attachmentsdict = request.json.get('attachments', {})
 | 
			
		||||
    html = shortcodes.render_commented(html, context={'attachments': attachmentsdict})
 | 
			
		||||
    return jsonify(content=html)
 | 
			
		||||
    return jsonify(content=markdown(content))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def ensure_lists_exist_as_empty(node_doc, node_type):
 | 
			
		||||
@@ -609,94 +604,5 @@ def url_for_node(node_id=None, node=None):
 | 
			
		||||
    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)
 | 
			
		||||
from .custom import groups, storage, posts
 | 
			
		||||
from .custom import comments, groups, storage, posts
 | 
			
		||||
 
 | 
			
		||||
@@ -30,7 +30,6 @@ class ProjectForm(FlaskForm):
 | 
			
		||||
        ('deleted', 'Deleted')])
 | 
			
		||||
    picture_header = FileSelectField('Picture header', file_format='image')
 | 
			
		||||
    picture_square = FileSelectField('Picture square', file_format='image')
 | 
			
		||||
    picture_16_9 = FileSelectField('Picture 16:9', file_format='image')
 | 
			
		||||
 | 
			
		||||
    def validate(self):
 | 
			
		||||
        rv = FlaskForm.validate(self)
 | 
			
		||||
 
 | 
			
		||||
@@ -349,7 +349,8 @@ def project_navigation_links(project: typing.Type[Project], api) -> list:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def render_project(project, api, extra_context=None, template_name=None):
 | 
			
		||||
    utils.attach_project_pictures(project, api)
 | 
			
		||||
    project.picture_square = utils.get_file(project.picture_square, api=api)
 | 
			
		||||
    project.picture_header = utils.get_file(project.picture_header, api=api)
 | 
			
		||||
 | 
			
		||||
    def load_latest(list_of_ids, node_type=None):
 | 
			
		||||
        """Loads a list of IDs in reversed order."""
 | 
			
		||||
@@ -414,16 +415,17 @@ def render_project(project, api, extra_context=None, template_name=None):
 | 
			
		||||
            embed_string = ''
 | 
			
		||||
        template_name = "projects/view{0}.html".format(embed_string)
 | 
			
		||||
 | 
			
		||||
    navigation_links = project_navigation_links(project, api)
 | 
			
		||||
    extension_sidebar_links = current_app.extension_sidebar_links(project)
 | 
			
		||||
 | 
			
		||||
    navigation_links = project_navigation_links(project, api)
 | 
			
		||||
 | 
			
		||||
    return render_template(template_name,
 | 
			
		||||
                           api=api,
 | 
			
		||||
                           project=project,
 | 
			
		||||
                           node=None,
 | 
			
		||||
                           show_node=False,
 | 
			
		||||
                           show_project=True,
 | 
			
		||||
                           og_picture=project.picture_16_9,
 | 
			
		||||
                           og_picture=project.picture_header,
 | 
			
		||||
                           activity_stream=activity_stream,
 | 
			
		||||
                           navigation_links=navigation_links,
 | 
			
		||||
                           extension_sidebar_links=extension_sidebar_links,
 | 
			
		||||
@@ -488,14 +490,12 @@ def view_node(project_url, node_id):
 | 
			
		||||
                raise wz_exceptions.NotFound('No such project')
 | 
			
		||||
 | 
			
		||||
    navigation_links = []
 | 
			
		||||
    extension_sidebar_links = ''
 | 
			
		||||
    og_picture = node.picture = utils.get_file(node.picture, api=api)
 | 
			
		||||
    if project:
 | 
			
		||||
        utils.attach_project_pictures(project, api)
 | 
			
		||||
        if not node.picture:
 | 
			
		||||
            og_picture = project.picture_16_9
 | 
			
		||||
            og_picture = utils.get_file(project.picture_header, api=api)
 | 
			
		||||
        project.picture_square = utils.get_file(project.picture_square, api=api)
 | 
			
		||||
        navigation_links = project_navigation_links(project, api)
 | 
			
		||||
        extension_sidebar_links = current_app.extension_sidebar_links(project)
 | 
			
		||||
 | 
			
		||||
    # Append _theatre to load the proper template
 | 
			
		||||
    theatre = '_theatre' if theatre_mode else ''
 | 
			
		||||
@@ -506,9 +506,10 @@ def view_node(project_url, node_id):
 | 
			
		||||
                               node=node,
 | 
			
		||||
                               project=project,
 | 
			
		||||
                               navigation_links=navigation_links,
 | 
			
		||||
                               extension_sidebar_links=extension_sidebar_links,
 | 
			
		||||
                               og_picture=og_picture,)
 | 
			
		||||
 | 
			
		||||
    extension_sidebar_links = current_app.extension_sidebar_links(project)
 | 
			
		||||
 | 
			
		||||
    return render_template('projects/view{}.html'.format(theatre),
 | 
			
		||||
                           api=api,
 | 
			
		||||
                           project=project,
 | 
			
		||||
@@ -517,7 +518,7 @@ def view_node(project_url, node_id):
 | 
			
		||||
                           show_project=False,
 | 
			
		||||
                           og_picture=og_picture,
 | 
			
		||||
                           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):
 | 
			
		||||
@@ -540,7 +541,8 @@ def search(project_url):
 | 
			
		||||
    """Search into a project"""
 | 
			
		||||
    api = system_util.pillar_api()
 | 
			
		||||
    project = find_project_or_404(project_url, api=api)
 | 
			
		||||
    utils.attach_project_pictures(project, api)
 | 
			
		||||
    project.picture_square = utils.get_file(project.picture_square, api=api)
 | 
			
		||||
    project.picture_header = utils.get_file(project.picture_header, api=api)
 | 
			
		||||
 | 
			
		||||
    return render_template('nodes/search.html',
 | 
			
		||||
                           project=project,
 | 
			
		||||
@@ -581,8 +583,6 @@ def edit(project_url):
 | 
			
		||||
            project.picture_square = form.picture_square.data
 | 
			
		||||
        if 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
 | 
			
		||||
        if form.is_private.data:
 | 
			
		||||
@@ -598,8 +598,6 @@ def edit(project_url):
 | 
			
		||||
            form.picture_square.data = project.picture_square._id
 | 
			
		||||
        if project.picture_header:
 | 
			
		||||
            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
 | 
			
		||||
    if current_user.has_role('admin'):
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								pillar/web/static/assets/css/vendor/bootstrap.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								pillar/web/static/assets/css/vendor/bootstrap.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -31,10 +31,8 @@ def check_oauth_provider(provider):
 | 
			
		||||
 | 
			
		||||
@blueprint.route('/authorize/<provider>')
 | 
			
		||||
def oauth_authorize(provider):
 | 
			
		||||
    if current_user.is_authenticated:
 | 
			
		||||
        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)
 | 
			
		||||
    if not current_user.is_anonymous:
 | 
			
		||||
        return redirect(url_for('main.homepage'))
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        oauth = OAuthSignIn.get_provider(provider)
 | 
			
		||||
@@ -54,10 +52,8 @@ def oauth_callback(provider):
 | 
			
		||||
    from pillar.api.utils.authentication import store_token
 | 
			
		||||
    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:
 | 
			
		||||
        log.debug('Redirecting user to %s', next_after_login)
 | 
			
		||||
        return redirect(next_after_login)
 | 
			
		||||
        return redirect(url_for('main.homepage'))
 | 
			
		||||
 | 
			
		||||
    oauth = OAuthSignIn.get_provider(provider)
 | 
			
		||||
    try:
 | 
			
		||||
@@ -67,14 +63,11 @@ def oauth_callback(provider):
 | 
			
		||||
        raise wz_exceptions.Forbidden()
 | 
			
		||||
    if oauth_user.id is None:
 | 
			
		||||
        log.debug('Authentication failed for user with {}'.format(provider))
 | 
			
		||||
        return redirect(next_after_login)
 | 
			
		||||
        return redirect(url_for('main.homepage'))
 | 
			
		||||
 | 
			
		||||
    # Find or create user
 | 
			
		||||
    user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
    # TODO(Sybren): If the user doesn't have any badges, but the access token
 | 
			
		||||
@@ -95,8 +88,11 @@ def oauth_callback(provider):
 | 
			
		||||
        # Check with Blender ID to update certain user roles.
 | 
			
		||||
        update_subscription()
 | 
			
		||||
 | 
			
		||||
    log.debug('Redirecting user to %s', next_after_login)
 | 
			
		||||
    return redirect(next_after_login)
 | 
			
		||||
    next_after_login = session.pop('next_after_login', None)
 | 
			
		||||
    if next_after_login:
 | 
			
		||||
        log.debug('Redirecting user to %s', next_after_login)
 | 
			
		||||
        return redirect(next_after_login)
 | 
			
		||||
    return redirect(url_for('main.homepage'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@blueprint.route('/login')
 | 
			
		||||
 
 | 
			
		||||
@@ -45,7 +45,6 @@ def attach_project_pictures(project, api):
 | 
			
		||||
 | 
			
		||||
    project.picture_square = get_file(project.picture_square, 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], *,
 | 
			
		||||
 
 | 
			
		||||
@@ -42,9 +42,9 @@ asn1crypto==0.24.0
 | 
			
		||||
Babel==2.6.0
 | 
			
		||||
billiard==3.5.0.4
 | 
			
		||||
Cerberus==1.2
 | 
			
		||||
cffi==1.12.2
 | 
			
		||||
cffi==1.10.0
 | 
			
		||||
click==6.7
 | 
			
		||||
cryptography==2.6.1
 | 
			
		||||
cryptography==2.0.3
 | 
			
		||||
Events==0.3
 | 
			
		||||
future==0.16.0
 | 
			
		||||
googleapis-common-protos==1.5.3
 | 
			
		||||
@@ -52,7 +52,7 @@ html5lib==1.0.1
 | 
			
		||||
idna==2.5
 | 
			
		||||
ipaddress==1.0.22
 | 
			
		||||
itsdangerous==0.24
 | 
			
		||||
Jinja2==2.10.1
 | 
			
		||||
Jinja2==2.10
 | 
			
		||||
kombu==4.2.1
 | 
			
		||||
oauth2client==4.1.2
 | 
			
		||||
oauthlib==2.1.0
 | 
			
		||||
@@ -61,14 +61,14 @@ protobuf==3.6.0
 | 
			
		||||
protorpc==0.12.0
 | 
			
		||||
pyasn1==0.4.4
 | 
			
		||||
pyasn1-modules==0.2.2
 | 
			
		||||
pycparser==2.19
 | 
			
		||||
pycparser==2.17
 | 
			
		||||
pymongo==3.7.0
 | 
			
		||||
pyOpenSSL==16.2.0
 | 
			
		||||
pytz==2018.5
 | 
			
		||||
requests-oauthlib==1.0.0
 | 
			
		||||
rsa==3.4.2
 | 
			
		||||
simplejson==3.16.0
 | 
			
		||||
six==1.12.0
 | 
			
		||||
six==1.10.0
 | 
			
		||||
urllib3==1.22
 | 
			
		||||
vine==1.1.4
 | 
			
		||||
webencodings==0.5.1
 | 
			
		||||
 
 | 
			
		||||
@@ -1,2 +0,0 @@
 | 
			
		||||
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.
 | 
			
		||||
@@ -1,46 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,54 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,7 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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'
 | 
			
		||||
@@ -1,17 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,82 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
function thenGetProject(projectId) {
 | 
			
		||||
    return $.get(`/api/projects/${projectId}`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { thenGetProject }
 | 
			
		||||
@@ -1,7 +0,0 @@
 | 
			
		||||
function thenGetProjectUsers(projectId) {
 | 
			
		||||
    return $.ajax({
 | 
			
		||||
        url: `/api/p/users?project_id=${projectId}`,
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { thenGetProjectUsers }
 | 
			
		||||
@@ -1,167 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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 }
 | 
			
		||||
@@ -1,4 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Collecting Custom Pillar events here
 | 
			
		||||
 */
 | 
			
		||||
export {Nodes} from './Nodes'
 | 
			
		||||
@@ -44,13 +44,7 @@ export class MultiSearch {
 | 
			
		||||
 | 
			
		||||
    thenExecute() {
 | 
			
		||||
        let data = JSON.stringify(this.getAllParams());
 | 
			
		||||
        let rawAjax = $.ajax({
 | 
			
		||||
            url: this.apiUrl,
 | 
			
		||||
            type: 'POST',
 | 
			
		||||
            data: data,
 | 
			
		||||
            dataType: 'json',
 | 
			
		||||
            contentType: 'application/json; charset=UTF-8'
 | 
			
		||||
        });
 | 
			
		||||
        let rawAjax = $.getJSON(this.apiUrl, data);
 | 
			
		||||
        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
 | 
			
		||||
        return prettyPromise;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,2 +0,0 @@
 | 
			
		||||
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.
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { prettyDate } from '../init'
 | 
			
		||||
import { prettyDate } from '../utils'
 | 
			
		||||
 | 
			
		||||
describe('prettydate', () => {
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
@@ -28,7 +28,7 @@ describe('prettydate', () => {
 | 
			
		||||
        expect(pd({minutes: -5, detailed: true})).toBe('5m ago')
 | 
			
		||||
        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')
 | 
			
		||||
        // summer time below
 | 
			
		||||
        // summer time bellow
 | 
			
		||||
        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 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46')
 | 
			
		||||
@@ -2,50 +2,25 @@ import { ComponentCreatorInterface } from './ComponentCreatorInterface'
 | 
			
		||||
 | 
			
		||||
const REGISTERED_CREATORS = []
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Create a jQuery renderable element from a mongo document using registered creators.
 | 
			
		||||
 * @deprecated use vue instead
 | 
			
		||||
 */
 | 
			
		||||
export class Component extends ComponentCreatorInterface {
 | 
			
		||||
    /**
 | 
			
		||||
     *
 | 
			
		||||
     * @param {Object} doc
 | 
			
		||||
     * @returns {$element}
 | 
			
		||||
     */
 | 
			
		||||
    static create$listItem(doc) {
 | 
			
		||||
        let creator = Component.getCreator(doc);
 | 
			
		||||
        return creator.create$listItem(doc);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param {Object} doc
 | 
			
		||||
     * @returns {$element}
 | 
			
		||||
     */
 | 
			
		||||
    static create$item(doc) {
 | 
			
		||||
        let creator = Component.getCreator(doc);
 | 
			
		||||
        return creator.create$item(doc);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param {Object} candidate
 | 
			
		||||
     * @returns {Boolean}
 | 
			
		||||
     */
 | 
			
		||||
    static canCreate(candidate) {
 | 
			
		||||
        return !!Component.getCreator(candidate);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Register component creator to handle a node type
 | 
			
		||||
     * @param {ComponentCreatorInterface} creator
 | 
			
		||||
     */
 | 
			
		||||
    static regiseterCreator(creator) {
 | 
			
		||||
        REGISTERED_CREATORS.push(creator);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param {Object} doc
 | 
			
		||||
     * @returns {ComponentCreatorInterface}
 | 
			
		||||
     */
 | 
			
		||||
    static getCreator(doc) {
 | 
			
		||||
        if (doc) {
 | 
			
		||||
            for (let candidate of REGISTERED_CREATORS) {
 | 
			
		||||
@@ -56,4 +31,4 @@ export class Component extends ComponentCreatorInterface {
 | 
			
		||||
        }
 | 
			
		||||
        throw 'Can not create component using: ' + JSON.stringify(doc);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
@@ -1,10 +1,6 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @deprecated use vue instead
 | 
			
		||||
 */
 | 
			
		||||
export class ComponentCreatorInterface {
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a $element to render document in a list
 | 
			
		||||
     * @param {Object} doc
 | 
			
		||||
     * @param {JSON} doc 
 | 
			
		||||
     * @returns {$element}
 | 
			
		||||
     */
 | 
			
		||||
    static create$listItem(doc) {
 | 
			
		||||
@@ -12,8 +8,8 @@ export class ComponentCreatorInterface {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a $element to render the full doc
 | 
			
		||||
     * @param {Object} doc
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {JSON} doc 
 | 
			
		||||
     * @returns {$element}
 | 
			
		||||
     */
 | 
			
		||||
    static create$item(doc) {
 | 
			
		||||
@@ -21,10 +17,11 @@ export class ComponentCreatorInterface {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param {Object} candidate
 | 
			
		||||
     * @returns {boolean}
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {JSON} candidate
 | 
			
		||||
     * @returns {boolean} 
 | 
			
		||||
     */
 | 
			
		||||
    static canCreate(candidate) {
 | 
			
		||||
        throw 'Not Implemented';
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
@@ -1,10 +1,6 @@
 | 
			
		||||
import { NodesBase } from "./NodesBase";
 | 
			
		||||
import { thenLoadVideoProgress } from '../utils';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Create $element from a node of type asset
 | 
			
		||||
 * @deprecated use vue instead
 | 
			
		||||
 */
 | 
			
		||||
export class Assets extends NodesBase{
 | 
			
		||||
    static create$listItem(node) {
 | 
			
		||||
        var markIfPublic = true;
 | 
			
		||||
 
 | 
			
		||||
@@ -3,10 +3,6 @@ import { ComponentCreatorInterface } from '../component/ComponentCreatorInterfac
 | 
			
		||||
 | 
			
		||||
let CREATE_NODE_ITEM_MAP = {}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Create $element from node object
 | 
			
		||||
 * @deprecated use vue instead
 | 
			
		||||
 */
 | 
			
		||||
export class Nodes extends ComponentCreatorInterface {
 | 
			
		||||
    /**
 | 
			
		||||
     * Creates a small list item out of a node document
 | 
			
		||||
@@ -41,7 +37,7 @@ export class Nodes extends ComponentCreatorInterface {
 | 
			
		||||
            let $link = $('<a>')
 | 
			
		||||
                .addClass('btn btn-outline-primary px-5 mb-auto btn-block js-load-next')
 | 
			
		||||
                .attr('href', 'javascript:void(0);')
 | 
			
		||||
                .click((e)=> {
 | 
			
		||||
                .click((e)=> { 
 | 
			
		||||
                    let $target = $(e.target);
 | 
			
		||||
                    $target.replaceWith(Nodes.createListOf$nodeItems(nodesLeftToRender, loadNext, loadNext));
 | 
			
		||||
                 })
 | 
			
		||||
@@ -64,4 +60,4 @@ export class Nodes extends ComponentCreatorInterface {
 | 
			
		||||
    static registerTemplate(node_type, klass) {
 | 
			
		||||
        CREATE_NODE_ITEM_MAP[node_type] = klass;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
@@ -1,9 +1,6 @@
 | 
			
		||||
import { prettyDate } from '../../utils/prettydate';
 | 
			
		||||
import { thenLoadImage, prettyDate } from '../utils';
 | 
			
		||||
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @deprecated use vue instead
 | 
			
		||||
 */
 | 
			
		||||
export class NodesBase extends ComponentCreatorInterface {
 | 
			
		||||
    static create$listItem(node) {
 | 
			
		||||
        let nid = (node._id || node.objectID); // To support both mongo and elastic nodes
 | 
			
		||||
@@ -22,7 +19,7 @@ export class NodesBase extends ComponentCreatorInterface {
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            $(window).trigger('pillar:workStart');
 | 
			
		||||
            pillar.utils.thenLoadImage(node.picture)
 | 
			
		||||
            thenLoadImage(node.picture)
 | 
			
		||||
                .fail(warnNoPicture)
 | 
			
		||||
                .then((imgVariation) => {
 | 
			
		||||
                    let img = $('<img class="card-img-top">')
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,10 @@
 | 
			
		||||
import { NodesBase } from "./NodesBase";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Create $element from a node of type post
 | 
			
		||||
 * @deprecated use vue instead
 | 
			
		||||
 */
 | 
			
		||||
export class Posts extends NodesBase {
 | 
			
		||||
    static create$item(post) {
 | 
			
		||||
        let content = [];
 | 
			
		||||
        let $title = $('<a>')
 | 
			
		||||
            .attr('href', '/nodes/' + post._id + '/redir')
 | 
			
		||||
            .attr('title', post.name)
 | 
			
		||||
            .addClass('h1 text-uppercase font-weight-bold d-block pt-5 pb-2')
 | 
			
		||||
        let $title = $('<div>')
 | 
			
		||||
            .addClass('h1 text-uppercase mt-4 mb-3')
 | 
			
		||||
            .text(post.name);
 | 
			
		||||
        content.push($title);
 | 
			
		||||
        let $post = $('<div>')
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,7 @@
 | 
			
		||||
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Create $elements from user objects
 | 
			
		||||
 * @deprecated use vue instead
 | 
			
		||||
 */
 | 
			
		||||
export class Users extends ComponentCreatorInterface {
 | 
			
		||||
    static create$listItem(userDoc) {
 | 
			
		||||
        let roles = userDoc.roles || [];
 | 
			
		||||
        return $('<div>')
 | 
			
		||||
            .addClass('users p-2 border-bottom')
 | 
			
		||||
            .attr('data-user-id', userDoc._id || userDoc.objectID )
 | 
			
		||||
@@ -18,11 +13,11 @@ export class Users extends ComponentCreatorInterface {
 | 
			
		||||
                    .text(userDoc.username),
 | 
			
		||||
                $('<small>')
 | 
			
		||||
                    .addClass('d-block roles text-info')
 | 
			
		||||
                    .text(roles.join(', '))
 | 
			
		||||
                    .text(userDoc.roles.join(', '))
 | 
			
		||||
            )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static canCreate(candidate) {
 | 
			
		||||
        return !!candidate.username;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
@@ -1,5 +1,122 @@
 | 
			
		||||
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) {
 | 
			
		||||
    return $.get('/api/users/video/' + nodeId + '/progress')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { thenLoadVideoProgress };
 | 
			
		||||
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 < -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,34 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,20 +0,0 @@
 | 
			
		||||
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,36 +1 @@
 | 
			
		||||
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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
export { transformPlaceholder } from './placeholder'
 | 
			
		||||
@@ -1,97 +0,0 @@
 | 
			
		||||
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;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,35 +0,0 @@
 | 
			
		||||
# 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"/>
 | 
			
		||||
~~~
 | 
			
		||||
@@ -1,52 +0,0 @@
 | 
			
		||||
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);
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,120 +0,0 @@
 | 
			
		||||
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}`
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
@@ -1,168 +0,0 @@
 | 
			
		||||
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;
 | 
			
		||||
        },
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
@@ -1,331 +0,0 @@
 | 
			
		||||
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`;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
@@ -1,161 +0,0 @@
 | 
			
		||||
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;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,53 +0,0 @@
 | 
			
		||||
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';
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,7 +0,0 @@
 | 
			
		||||
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();
 | 
			
		||||
@@ -1,52 +0,0 @@
 | 
			
		||||
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')})
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
@@ -1,23 +0,0 @@
 | 
			
		||||
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
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,26 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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)
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
@@ -1,61 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,42 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,111 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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 }
 | 
			
		||||
@@ -1,86 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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 }
 | 
			
		||||
@@ -1,24 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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 }
 | 
			
		||||
@@ -1,72 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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 }
 | 
			
		||||
@@ -1,273 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,28 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,16 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,55 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,54 +0,0 @@
 | 
			
		||||
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);
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,101 +0,0 @@
 | 
			
		||||
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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,15 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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 }
 | 
			
		||||
 | 
			
		||||
@@ -1,19 +0,0 @@
 | 
			
		||||
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'];
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,41 +0,0 @@
 | 
			
		||||
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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,19 +0,0 @@
 | 
			
		||||
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'];
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,130 +0,0 @@
 | 
			
		||||
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 }
 | 
			
		||||
@@ -1,65 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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 }
 | 
			
		||||
@@ -1,28 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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 }
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user