var moduleName = 'commentsModule';
var module;

try {
    module = angular.module(moduleName);
} catch (err) {
    // named module does not exist, so create one
    module = angular.module(moduleName, ['ngResource', 'fileupload', 'doclink', 'link', 'cla.core.lmsg', 'cla.core.growl', 'userDetails']);
}

module
    .controller('commentController', ['$scope', '$timeout', '$http', 'Comment', 'growl.service', 'commentUpdateService', 'profileImageUrlService', 'commentWidthService', '$element', function ($scope, $timeout, $http, Comment, growlService, commentService, profileService, commentWidthService, $element) {
        var cc = this;

        // member variables
        cc.comments = [];

        cc.options = null;
        cc.paging = {};
        cc.pageLoading = false;
        cc.userAuthenticated = true;
        cc.showMore = false;
        cc.can_acknowledge = false;
        cc.acknowledgement_loading = false;
        cc.acknowledgement_loading_unread = false;

        // Liked is rendered in the modal window when 'x people liked this' is clicked.
        cc.liked = [];

        // cc.comments contains all comments and replies mixed together. We need to keep a count of just the comments for pagination
        cc.loadedCommentCount = 0;

        // Use a global for a unique id to allow multiple comments on the same page (which happens in Ideaspace).
        // Could use the object type/id combo but this doesn't allow for the even rarer chance that the same comment stream may appear twice...
        if (typeof _commentsUniqueId === 'undefined')
            _commentsUniqueId = 1;
        else
            _commentsUniqueId++;

        cc.uniqueId = _commentsUniqueId;

        cc.translations = {
            my_comment: lmsg("common.cla_comments.you")
        };

        cc.new_comment = new Comment(); // represents a new comment.

        cc.tab = 0; // for attachments

        cc.files = [];
        cc.can_save = true;

        cc.doc_link = null;
        cc.link = null;

        // determines layout of image grid: grid-item--width2, grid-item--width3
        cc.layout = {
            1: [3],
            2: [2, 2],
            3: [3, 3, 3],
            4: [2, 2, 2, 2],
            5: [2, 2, 3, 3, 3]
        };

        // shared object used to control child directives
        cc.controlToken = {};

        /**
         * Watch the files object
         */
        $scope.$watch('cc.files', function (val) {
            // can't save while we have files pending
            cc.can_save = true;

            // loop files
            for( var i in val )
            {
            	if (val.hasOwnProperty(i))
				{
					// if any file is not done.
					if( !val[i].done && !val[i].cancelled ) {
						cc.can_save = false;
					}
				}
            }
        }, true);

		/**
		 * [public]
		 * Constructor
		 *
		 * @param object_id
		 * @param object_type
		 * @param thread_id
		 * @param new_on_top
		 * @param is_summary
 		 * @param summary_length
 		 * @param comments_number
		 * @param compact_mode
         * @param require_acknowledgement
		 */
        cc.init = function(object_id, object_type, thread_id, new_on_top, is_summary, summary_length, comments_number, compact_mode, require_acknowledgement)
        {
			if (typeof is_summary === 'undefined')
				is_summary = false;

            if (typeof summary_length === 'undefined')
                summary_length = 8000;

            if (typeof comments_number === 'undefined')
                comments_number = is_summary ? 2 : 10;

            if (typeof compact_mode === 'undefined')
                compact_mode = false;

            if (typeof require_acknowledgement === 'undefined')
                require_acknowledgement = false;

	        cc.is_thread_only = thread_id > 0;

            // set config
            this.config = {
                object_id: object_id,
                object_type: object_type,
	            thread_id: thread_id,
                new_on_top: new_on_top,
                user_photo_url: profileService.getProfileImageUrl(),
                is_summary: is_summary,
                summary_length: summary_length,
                limit: comments_number,
                compact_mode: compact_mode,
                showFullInput: !compact_mode,
                require_acknowledgement: require_acknowledgement
            };

            var comments = new Comment(object_type, object_id, thread_id);
			var offset = this.config.is_summary ? - cc.config.limit : 0;

            var context = this;
            comments.getAll(function (comments, options, paging) {
                fetchCallback.call(context, comments, options, paging);
            }, function(data)
            {
            	cc.userAuthenticated = false;
            }, {newest_first: new_on_top, limit: cc.config.limit, offset: offset, isThreaded: thread_id > 0});

            // The unique id won't exist until after the digest so run this after
            $timeout(function () {
                var elem = angular.element('#comments_' + cc.uniqueId);
                // Show more may be added/removed from the DOM via ng-if so this must be delegated
                elem.on('click', '.js-showmore', function (e) {
                    e.preventDefault();
                    cc.loadMoreComments();
                });

                elem.on('click', '.js-more-replies', function (e) {
                    e.preventDefault();
                    cc.loadMoreReplies(angular.element(this).data('id'));
                });

                // Find out how wide the comments are
                commentWidthService.setWidth(elem.width());

                // Monitor the container for resizing, via Pages or window
                var tile = elem.closest('.js-tile');
                tile.off('pages-tile-resize pages-tile-add');
                tile.on('pages-tile-resize pages-tile-add', function () {
                    commentWidthService.setWidth(elem.width());
                });

                $(window).resize(function () {
                    commentWidthService.setWidth(elem.width());
                });
            }, 0);
        };


        /**
         * [public]
         * Display edit form and clone object for reset
         *
         * @param e
         * @param item
         */
        cc.edit = function (e, item) {
            item.clone();
            item.edit_mode = true;

            if (typeof item.updateDisplay !== 'undefined')
                item.updateDisplay();

            // The element won't exist until after the digest loop is complete, so run this after
            // https://tech.endeepak.com/blog/2014/05/03/waiting-for-angularjs-digest-cycle/
            var waitForDigestAndRender = function () {
                if ($http.pendingRequests.length > 0) {
                    $timeout(waitForDigestAndRender); // Wait for all templates to be loaded
                } else {
                    angular.element('#edittext-' + item.id)[0].focus();
                    // Do initial resize of the edit box so there's no problems
                    // with display of highlights going out of alignment on scroll
                    var target = angular.element('#edittext-' + item.id + ' .js-mention-edit');
                    var outerHeight = target.outerHeight();

                    if (outerHeight > target[0].scrollHeight) {
                        target.height(60);
                    }

                    var borderWidth = parseFloat(target.css("borderTopWidth")) + parseFloat(target.css("borderBottomWidth"));
                    while (target.outerHeight() < target[0].scrollHeight + borderWidth) {
                        target.height(target.height() + 1);
                    }
                    // show the edit text box once the resize is complete.
                    angular.element('#edittext-' + item.id).removeClass('mention-edit-outer-hidden');
                }
            };

            $timeout(waitForDigestAndRender)

            e.preventDefault();
        };

        /**
         * [public]
         * Toggles reply mode
         *
         * @param e
         * @param item
         */
        cc.replyMode = function (e, item) {
            item.reply_mode = !item.reply_mode;

            if (item.reply_mode) {
                if (item.user.deleted !== true) {
                    item.new_reply.text = "@[" + item.user.id + "] ";
                }
                item.new_reply.mentioned = [
                    {
                        user_id: item.user.id,
                        name: item.user.name,
                        job_title: item.user.job_title,
                        profile_url: item.user.profile,
                        image_url: item.user.photo
                    }];
                if (typeof item.new_reply.updateDisplay !== 'undefined')
                    item.new_reply.updateDisplay();

                // set focus
                // The element id won't exist until after the digest so run this after
                $timeout(function () {
                    angular.element('#replytext-' + item.id + '-inner')[0].focus();
                }, 0);

            }

            e.preventDefault();
        };

        /**
         * [public]
         * Resets a changed comment back to it's former copy
         *
         * @param e
         */
        cc.reset = function (e, item) {
            item.reset();
            commentService.updateComment(item.id);
            for (var i = 0; i < item.replies.length; i++) {
                commentService.updateComment(item.replies[i].id);
            }

            if (e)
                e.preventDefault();
        };

        /**
         * [public]
         * Edit the object
         *
         * @param e
         */
        cc.update = function (e, item) {
            item.save(
                function (comment) // success
                {
                    // reset
                    comment.edit_mode = false;
                    comment.copy = null;

                    var message = 'common.cla_comments.comment_published';
                    if (item.parent_id > 0)
                        message = 'common.cla_comments.reply_published';
                    growlService.showSuccess(lmsg(message));

                    // Tell anybody listening that a comment or reply was updated
                    var eventType = 'cla.comments.comment_updated';
                    if (item.isReply())
                        eventType = 'cla.comments.reply_updated';
                    $scope.$root.$broadcast(eventType);

                    for (var i = 0; i < cc.comments.length; i++) {
                        if (cc.comments[i].id == comment.id) {
                            cc.comments[i].load(comment);
                            commentService.updateComment(comment.id);
                        } else {
                            for (var j = 0; j < cc.comments[i].replies.length; j++) {
                                if (cc.comments[i].replies[j].id == comment.id) {
                                    cc.comments[i].replies[j].load(comment);
                                    commentService.updateComment(comment.id);
                                }
                            }
                        }
                    }
                },
                function () // error
                {
                    var message = 'common.cla_comments.modify_failed';
                    if (item.parent_id > 0)
                        message = 'common.cla_comments.reply.modify_failed';

                    if (d && d.status == 401) {
                        message = 'common.cla_comments.log_in'
                    }

                    growlService.showError(lmsg(message));
                }
            );

            e.preventDefault();
        };

        /**
         * [public]
         * Create a new comment
         *
         * @param e
         */
        cc.new = function (e) {
            if (this.new_comment === null)
                this.new_comment = {text: ''};

            var text = this.new_comment.text;
            // use uniqueId in case there's multiple comment editors on page
            var input = angular.element("#new_comment_" + this.uniqueId);

            var files = cc.getAttachments();
            // validate if no files have been uploaded
            if (files.length == 0) {
                if (!validate(input, text)) {
                    return false;
                }
            }

            var comment = new Comment();
            comment.load({
                text: text,
                object_id: this.config.object_id,
                object_type: this.config.object_type,
                files: files,
                can_acknowledge: this.can_acknowledge
            });

            var local = this.new_comment;
            var context = this;

            cc.can_save = false;

            //var input = angular.element("#new_comment_" + this.uniqueId);
            comment.save(
                function (comment) // success
                {
                	cc.can_save = true;
                    local.text = "";
                    var textarea = input.find('textarea');
                    textarea.height(20);
                    if (context.config.new_on_top)
                        context.comments.unshift(comment);
                    else
                        context.comments.push(comment);

                    commentService.addComment(comment.id);

                    cc.controlToken.resetFiles();
                    cc.controlToken.resetDocLink();
                    cc.controlToken.resetLink();
                    cc.can_acknowledge = false;
                    cc.tab = 0;

                    cc.new_comment.updateDisplay();

                    var message = 'common.cla_comments.comment_published';
                    growlService.showSuccess(lmsg(message));

                    if (cc.config.compact_mode)
                        cc.hideFullInput();

                    // Tell anybody listening that a comment was added
                    $scope.$root.$broadcast('cla.comments.comment_added', comment);
                },
                function (httpResponse) // error
                {
                    cc.can_save = true;
                    var message = 'common.cla_comments.add_failed';
					if (httpResponse && httpResponse.status === 401) {
                        message = 'common.cla_comments.log_in'
                    }

                    growlService.showError(lmsg(message));
                }
            );

            e.preventDefault();
        };

        /**
         * [public]
         * Deletes a comment
         *
         * @param e
         */
        cc.delete = function (e, item, index) {
            // confirm?
            var message = '';

            if (item.parent_id > 0) {
                if (item.user.is_me)
                    message = 'common.cla_comments.reply.delete_prompt';  // "delete your reply"
                else
                    message = 'common.cla_comments.reply.delete_other_prompt';  // "delete this reply"
            } else {
                if (item.user.is_me)
                    message = 'common.cla_comments.delete_prompt';  // "delete your comment"
                else
                    message = 'common.cla_comments.delete_other_prompt';  // "delete this comment"
            }
            if (window.confirm(lmsg(message))) {
                var context = this;

                item.remove(
                    function () // success
                    {
                        // determine reply
                        var array = context.comments;
                        if (item.isReply()) {
                            var root = context.getRootComment(item);
                            root.reply_count--;
                            array = root.replies;
                        }

                        array.splice(index, 1);
                        commentService.removeComment(item.id);

                        var message = 'common.cla_comments.deleted';
                        if (item.parent_id > 0)
                            message = 'common.cla_comments.reply.deleted';
                        growlService.showSuccess(lmsg(message));

                        // Tell anybody listening that a comment or reply was deleted
                        var eventType = 'cla.comments.comment_deleted';
                        if (item.isReply())
                            eventType = 'cla.comments.reply_deleted';
                        $scope.$root.$broadcast(eventType);
                    },
                    function () // error
                    {
                        var message = 'common.cla_comments.delete_failed';
                        if (item.parent_id > 0)
                            message = 'common.cla_comments.reply.delete_failed';

                        if (d && d.status == 401) {
                            message = 'common.cla_comments.log_in';
                        }

                        growlService.showError(lmsg(message));
                    }
                );
            }

            e.preventDefault();
        };

        // Acknowledge mark post as read
        cc.markAsRead = function (e, item) {
            item.acknowledge(
                function () {
                    item.can_acknowledge = true;
                },
                function () // error
                {
                    item.can_acknowledge = false;

                    if (d && (d.status === 401 || d.status === 403)) {
                        growlService.showError(lmsg('common.cla_comments.log_in'));
                        return;
                    }

                    growlService.showError(lmsg('common.cla_comments.acknowledge_failed'));
                }
            );

            e.preventDefault();
        }


        // initial load
        cc.initAcknowledgementsPerComment = function (e, item, page) {
            this.loadAcknowledgementsPerComment(e, item, page);
            this.loadListUsersWhoCanAcknowledgeComment(e, item, page);
        }

        cc.loadAcknowledgementsPerComment = function (e, item, page) {
            cc.acknowledgement_loading = true;

            // load acknowledgement items from API endpoint: /api/comments/19/acknowledgements
            item.getAcknowledgementsPerComment(
                function () {
                    cc.acknowledgement_loading = false;
                },

                // error callback
                function (error)
                {
                    cc.acknowledgement_loading = false;

                    if (error && error.status === 401) {
                        growlService.showError(lmsg('common.cla_comments.log_in'));

                        return;
                    }

                    growlService.showError(lmsg('common.cla_comments.acknowledge_loading_failed'));
                },
                {
                    limit: item.itemsPerPage,
                    offset: (page - 1) * item.itemsPerPage
                }
            );
        }

        /**
         * Load users list who can acknowledge the comments
         *
         * @param e
         * @param item
         * @param page
         */
        cc.loadListUsersWhoCanAcknowledgeComment = function (e, item, page) {
            cc.acknowledgement_loading_unread = true;

            // load all users list
            item.getUsersWhoCanAcknowledgeComment(
                function () {
                    cc.acknowledgement_loading_unread = false;
                },

                // error callback
                function (error) {
                    cc.acknowledgement_loading_unread = false;

                    if (error && error.status === 401) {
                        growlService.showError(lmsg('common.cla_comments.log_in'));

                        return;
                    }

                    growlService.showError(lmsg('common.cla_comments.acknowledge_loading_failed'));
                },
                {
                    limit: item.itemsPerPage,
                    offset: (page - 1) * item.itemsPerPage
                }
            );
        }


        /**
         * [public]
         * Reply to a comment
         *
         * @param e
         */
        cc.add = function (e, item) {
            var root = this.getRootComment(item);

            var comment = new Comment();
            comment.load({
                text: item.new_reply.text,
                mentioned: item.new_reply.mentioned,
                object_id: this.config.object_id,
                object_type: this.config.object_type,
                parent_id: root.id,
                can_acknowledge: this.can_acknowledge
            });

	        cc.can_save = false;

            comment.save(
                function (reply) // success
                {
                    item.reply_mode = false;
                    item.new_reply.text = "";
                    item.new_reply.mentioned = [];
                    item.new_reply.updateDisplay();
                    root.addReply(reply);
                    commentService.addComment(reply.id);

					root.reply_count++;
	                cc.can_save = true;

					var message = 'common.cla_comments.reply_published';
					growlService.showSuccess(lmsg(message));

                    // Tell anybody listening that a reply was added
                    $scope.$root.$broadcast('cla.comments.reply_added', reply);
                },
                function (d) // error
                {
                    var message = 'common.cla_comments.reply.add_failed';
                    if (d && d.status == 401) {
                        message = 'common.cla_comments.log_in';
                    }

	                cc.can_save = true;
                    growlService.showError(lmsg(message));
                }
            );

            e.preventDefault();
        };

        cc.loadMoreComments = function () {
            var needs_loading = false;
            if ((cc.paging.total - cc.paging.offset) > cc.config.limit)
                needs_loading = true;

            if ((!cc.pageLoading) &&
                (needs_loading))
            {
                cc.pageLoading = true;

				var limit = cc.config.limit;
				// todo: change the behaviour so with "new at the bottom", comments are paginated from the top rather than from bottom - like replies
				/*if (cc.config.new_on_top)
				{
					if (cc.paging.offset < cc.config.limit)
						limit = cc.paging.offset;

					cc.paging.offset -= cc.config.limit;
					if (cc.paging.offset < 0)
						cc.paging.offset = 0;
				} else
				{ */
					if (cc.paging.offset > (cc.paging.total - cc.config.limit))
						limit = cc.paging.total - cc.paging.offset;

					cc.paging.offset += cc.config.limit;
					if (cc.paging.offset >= cc.paging.total)
						cc.paging.offset = cc.paging.total;
				//}

				var comments = new Comment(cc.config.object_type, cc.config.object_id, cc.config.thread_id, cc.paging.offset, limit);
				comments.getAll(function (comments, options, paging)
				{
					for (var i = 0; i < comments.length; i++)
					{
						commentService.addComment(comments[i].id);
					}
					fetchCallback(comments, options, paging);
				}, function(data)
				{
					if (typeof data === 'object')
						cla.showMessage(lmsg('comments.error.access_denied') + ': ' + data.data.message, '', true);
					else
						cla.showMessage(lmsg('comments.error.access_denied') + ': ' + data, '', true);
				}, {newest_first: cc.config.new_on_top});
			}
		};

		cc.loadMoreReplies = function(id)
		{
			var comment = null;
			for (var i = 0 ; i < cc.comments.length; i++)
			{
				if (cc.comments[i].id == id)
					comment = cc.comments[i];
			}

			comment.getAllReplies(function (comment_id, replies)
			{
				comment.loadingReplies = false;
				fetchRepliesCallback(comment, replies);
			}, {id: id, object_type: comment.object_type, object_id: cc.config.object_id});
		};

		cc.like = function($event, comment)
		{
			comment.like();
		};

        cc.unlike = function($event, comment)
        {
            comment.unlike();
        };

		cc.showLikes = function($event, comment)
		{
			comment.getLikes(function(data)
			{
				cc.liked.length = 0;

				for (var i in data.likes)
					if (data.likes.hasOwnProperty(i))
						cc.liked.push(angular.extend(data.likes[i]));

				// remove
				$element.find('.jsDiscuss-list').children().not('.jsMedia-discuss').remove();
				$element.find('.jsWho-liked-this').modal();
			});
		};

		cc.showFullInput = function()
	    {
		    cc.config.showFullInput = true;
		    $timeout(function()
		    {
		        angular.element('#new_comment_' + cc.uniqueId + '-inner').focus();
		    }, 0);
	    };

	    cc.hideFullInput = function()
	    {
		    cc.config.showFullInput = false;
	    };

	    cc.fileDisplayName = function(file)
	    {
	    	var name = file.name;
	    	if (!file.includes_extension && file.extension !== null && file.extension.length > 0)
		    {
		    	name = name + '.' + file.extension;
		    }

		    return name;
	    };

        cc.setCommentAsRequireAcknowledgement = function (){
            cc.can_acknowledge = !cc.can_acknowledge;
        }

        /**
         * [private]
         * Find root comment for a reply
         *
         * @param item
         */
        this.getRootComment = function (item) {
            for (var i in this.comments) {
                var c = this.comments[i];
                if (c.id == item.parent_id) {
                    item = this.getRootComment(c);
                }
            }

            return item;
        };

        /**
         * [private]
         * Gets an array of attachments
         */
        this.getAttachments = function () {
            // uploaded files
            var files = formatFiles(this.files);

            // link
            if (this.link && this.link.path.length > 0) {
                files.push(this.link);
            }

            // documents
            if (this.doc_link && this.doc_link.doc_id > 0) {
                files.push(this.doc_link);
            }

            return files
        };

        /**
         * [private]
         * Determines a layout for displayed image attachments
         *
         * @param $index
         * @param comment
         * @returns {*}
         */
        this.getLayout = function ($index, comment) {
            var num = (comment.numImages() > 5 ? 5 : comment.numImages());
            return cc.layout[num][$index];
        };

        /**
         * [private]
         * Show a validation warning
         *
         * @param input
         */
        var validate = function (input, text) {
            if (!input) {
                return false;
            }

            // check for whitespace
            if ((text.length <= 0 || new RegExp(/^\s+$/).test(text))) {
                input.addClass("input-empty");
                setTimeout(function () {
                    input.removeClass("input-empty");
                }, 3000);

                return false;
            }

            return true;
        };

        /**
         * [private]
         * Format the files object to POST
         *
         * @param files
         */
        var formatFiles = function (files) {
            var formatted = [];

            for( var i in files )
            {
            	if( files.hasOwnProperty( i ) )
				{
					// if this file was cancelled, don't include it in the submission
					if (files[i].cancelled === true)
					{continue;}
					formatted.push(files[i].response);
				}
            }

            return formatted;
        };

        /**
         * [private]
         * Takes the array of comments and sorts them into comments and replies
         *
         * Comment {
         *   replies: []
         * }
         */
        var unflatten = function (comments) {
            var replies = {};

            // find comments that are replies and organise them into parent groups
            var i = comments.length;
            while (i--) {
                var comment = comments[i];
                if (comment.isReply()) {
                    replies[comment.parent_id] = replies[comment.parent_id] || [];
                    replies[comment.parent_id].push(comment);

                    // remove reply from comments array
                    comments.splice(i, 1);
                }
            }

            // find comments with replies, and add the children
            for( i in comments )
            {
            	if (comments.hasOwnProperty(i))
				{
					var comment = comments[i];
					if( comment.hasReplies() )
					{
						if( replies[comment.id] )
						{
							// reverse replies from previous loop
							replies[comment.id].reverse();

							// for replies for this comment
							for( var j in replies[comment.id] ) {
								if (replies[comment.id].hasOwnProperty(j)){
									comment.addReply(replies[comment.id][j]);
								}
							}
						}
					}
				}
            }
        };

        /**
         * [private]
         * Executed when getAll has retrieved all the data from the REST request
         *
         * @param comments
         * @param options
         */
        var fetchCallback = function (comments, options, paging)
        {
            for( var i in comments )
            {
            	if (comments.hasOwnProperty(i))
            	{
					var comment = new Comment();
					comment.load( comments[i] );

					commentService.addComment(comment.id);
					if (cc.config.is_summary)
						comment.text = truncateComment(comment.text, cc.config.summary_length);
					cc.comments.push(comment);

					if (comment.parent_id == 0)
						cc.loadedCommentCount++;
				}
            }

            // un-flatten the comments array to nest replies.
            unflatten(cc.comments);

            cc.options = options;
            cc.paging = paging;

            cc.pageLoading = false;
            cc.showMore = (paging.total > cc.loadedCommentCount);
        };

        /**
         * [private]
         * Executed when getAll has retrieved all the data from the REST request
         *
         * @param comment
         * @param replies
         */
        var fetchRepliesCallback = function (comment, replies) {
            comment.replies = [];

            for (var i in replies) {
                var reply = new Comment();
                reply.load(replies[i]);

                comment.replies.push(reply);
            }

            for (i in comment.replies) {
                commentService.addComment(comment.replies[i].id);
                commentService.updateComment(comment.replies[i].id);
            }
        };

        /**
         * JS version of ClaText::TrimLongHtml()
         * Trims an HTML string by it's output length.
         * Supports HTML, HTML entities and @[id] mentions
         */
        function truncateComment(str, max_length) {
            var output_length = 0; // number of counted characters stored so far in $output
            var position = 0;      // character offset within input string after last tag/entity
            var tag_stack = []; // stack of tags we've encountered but not closed
            var output = '';

            var unpaired_tags = ['doctype', '!doctype',
                'area', 'base', 'basefont', 'bgsound', 'br', 'col',
                'embed', 'frame', 'hr', 'img', 'input', 'link', 'meta',
                'param', 'sound', 'spacer', 'wbr'];

            // loop through, splitting at HTML entities or tags
            var regex = /<\/?([a-z.=\"\s]+)[^>]*>|&(#?[a-zA-Z0-9]+);|(\@\[[0-9]+\])/gmi;
            var match;
            while ((match = regex.exec(str)) !== null) {
                if (match.index === regex.lastIndex) {
                    regex.lastIndex++;
                }

                var tag = match[0];
                var tag_position = match['index'];

                // get text leading up to the tag, and store it (up to max_length)
                var text = str.substring(position, tag_position);
                if (output_length + text.length > max_length) {
                    output += text.substring(0, max_length - output_length);
                    output_length = max_length;
                    break;
                }

                // store everything, it wasn't too long
                output += text;
                output_length += text.length;

                if (tag[0] == '&') // Handle HTML entity by copying straight through
                {
                    output += tag;
                    output_length++; // only counted as one character
                }
                else // Handle HTML tag
                {
                    var tag_inner = tag[1];
                    if (tag_inner == '/') // This is a closing tag.
                    {
                        output += tag;
                        // If input tags aren't balanced, we leave the popped tag
                        // on the stack so hopefully we're not introducing more
                        // problems.
                        if (tag_stack[tag_stack.length - 1] == match[1]) {
                            tag_stack.pop();
                        }
                    }
                    else if (tag[0] == '@') {
                        output += tag;
                        output_length += 15; // Can't look up to the actual name length currently, so some number about right for an average name length
                    }
                    else if (tag[tag.length - 2] == '/'
                        || ($.inArray(match[1].toLowerCase(), unpaired_tags) >= 0)) {
                        // Self-closing or unpaired tag
                        output += tag;
                    }
                    else // Opening tag.
                    {
                        output += tag;
                        tag_stack.push(tag_inner); // push tag onto the stack
                    }
                }


                // Continue after the tag we just found
                if (typeof tag !== 'undefined')
                    position = tag_position + tag.length;
                else
                    position = tag_position + 1;
            }

            // Print any remaining text after the last tag, if there's room.
            if (output_length < max_length && position < str.length) {
                output += str.substring(position, max_length - output_length);
            }

            var truncated = str.length - position > max_length - output_length;

            // add terminator if it was truncated in loop or just above here
            if (truncated)
                output += '&hellip;';

            // Close any open tags
            while (tag_stack.length !== 0)
                output += '</' + tag_stack.pop() + '>';

            return output;
        }
    }])

    // filter images
    .filter('imagesFilter', function () {
        return function (files, is_image) {
            var out = [];
            for (var i in files) {
                if (files[i]['image'] == is_image) {
                    out.push(files[i]);
                }
            }
            return out;
        };
    });
