// The Antville Project // http://code.google.com/p/antville // // Copyright 2001–2014 by the Workers of Antville. // // Licensed under the Apache License, Version 2.0 (the ``License''); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an ``AS IS'' BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * @fileOverview Defines the Story prototype. */ markgettext('Story'); markgettext('story'); markgettext('a story // accusative'); this.handleMetadata('title'); this.handleMetadata('text'); Story.ALLOWED_MACROS = [ 'file', 'image', 'link', 'poll', 'story.link', 'value' ]; /** * @function * @param {Object} data * @param {Site} site * @param {User} user * @returns {Story} */ Story.add = function(data, site, user) { HopObject.confirmConstructor(Story); site || (site = res.handlers.site); user || (user = session.user); var story = new Story; story.name = String.EMPTY; story.requests = 0; story.created = story.modified = new Date; story.site = site; story.creator = story.modifier = user; story.update(data); site.stories.add(story); return story; } /** * @function */ Story.remove = function() { if (this.constructor === Story) { HopObject.remove.call(this.comments); this.setTags(null); this.deleteMetadata(); this.remove(); } return; } /** * @function * @returns {String[]} * @see defineConstants */ Story.getStatus = defineConstants(Story, markgettext('closed'), markgettext('public'), markgettext('shared'), markgettext('open')); /** * @function * @returns {String[]} * @see defineConstants */ Story.getModes = defineConstants(Story, markgettext('hidden'), markgettext('featured')); /** * @function * @returns {String[]} * @see defineConstants */ Story.getCommentModes = defineConstants(Story, markgettext('closed'), /* markgettext('readonly'), markgettext('moderated'), */ markgettext('open')); /** * @name Story * @constructor * @property {Comment[]} _children * @property {String} commentMode * @property {Comment[]} comments * @property {Date} created * @property {User} creator * @property {Metadata} metadata * @property {String} mode * @property {Date} modified * @property {User} modifier * @property {String} name * @property {Number} parent_id * @property {String} parent_type * @property {String} prototype * @property {Number} requests * @property {Site} site * @property {String} status * @property {TagHub[]} tags * @property {String} text * @property {String} title * @extends HopObject */ Story.prototype.constructor = function() { HopObject.confirmConstructor(this); return this; } /** * * @param {String} action * @returns {Boolean} */ Story.prototype.getPermission = function(action) { if (!this.site.getPermission('main')) { return false; } switch (action) { case '.': case 'main': case 'amp.html': return this.status !== Story.CLOSED || this.creator === session.user || Membership.require(Membership.MANAGER) || User.require(User.PRIVILEGED); case 'comment': return this.site.commentMode === Site.ENABLED && (this.commentMode === Story.OPEN || this.commentMode === Story.MODERATED); case 'delete': return this.creator === session.user || Membership.require(Membership.MANAGER) || User.require(User.PRIVILEGED); case 'edit': case 'mode': case 'rotate': // FIXME: Action moved to compat layer case 'status': return this.creator === session.user || Membership.require(Membership.MANAGER) || (this.status === Story.SHARED && Membership.require(Membership.CONTRIBUTOR)) || (this.status === Story.OPEN && Membership.require(Membership.SUBSCRIBER)) || User.require(User.PRIVILEGED); } return false; }; Story.prototype.main_action = function() { var title = this.title ? stripTags(this.format_filter(this.title, {}, 'quotes')).clip(10, String.ELLIPSIS, '\\s') : null; var description = this.getDescription(20); if (!title && description) { title = description; description = null; } this.site.renderPage({ type: 'article', schema: 'http://schema.org/Article', title: title, description: description, body: this.renderSkinAsString('Story#main'), images: this.getMetadata('og:image'), videos: this.getMetadata('og:video'), links: "" }); this.site.log(); this.count(); this.log(); return; }; /** * * @param {Number} limit * @returns {String} */ Story.prototype.getTitle = function(limit) { var key = this + ':title:' + limit; if (!res.meta[key]) { if (this.title) { res.meta[key] = stripTags(this.title).clip(limit, String.ELLIPSIS, '\\s'); } else if (this.text) { var parts = stripTags(this.text).embody(limit, String.ELLIPSIS, '\\s'); res.meta[key] = parts.head; res.meta[this + ':text:' + limit] = parts.tail; } } return String(res.meta[key]) || String.ELLIPSIS; }; Story.prototype.getDescription = function(limit) { var key = this + ':text:' + limit; if (!res.meta[key]) { if (this.text) { res.meta[key] = stripTags(this.format_filter(this.text, {}, 'markdown')).clip(limit, String.ELLIPSIS, '\\s').replace(/\s+/g, String.SPACE).trim(); } } return res.meta[key] || String.EMPTY; }; Story.prototype.edit_action = function() { if (req.isPost()) { try { this.update(req.postParams); (function (images, videos) { this.setMetadata('og:image', images ? Array.prototype.slice.call(images) : null); this.setMetadata('og:video', videos ? Array.prototype.slice.call(videos) : null); }).call(this, req.postParams['og:image_array'], req.postParams['og:video_array']); delete session.data.backup; res.message = gettext('The story was successfully updated.'); res.redirect(this.href()); } catch (ex) { res.message = ex; app.log(ex); } } res.data.action = this.href(req.action); res.data.title = gettext('Edit Story'); res.data.body = this.renderSkinAsString('Story#edit'); res.data.body += this.renderSkinAsString('$Story#editor'); this.site.renderSkin('Site#page'); return; } /** * * @param {Object} data */ Story.prototype.update = function(data) { var site = this.site || res.handlers.site; data.title = data.title ? stripTags(data.title) : String.EMPTY; if (!data.title && !data.text) { throw Error(gettext('Please enter at least something into the “title” or “text” field.')); } var delta = this.getDelta(data); this.title = data.title; this.text = data.text; if (data.created) { try { this.created = data.created.toDate('yyyy-MM-dd HH:mm', site.getTimeZone()); } catch (ex) { throw Error(gettext('Cannot parse timestamp {0} as a date.', data.created)); app.log(ex); } } this.status = data.status || Story.PUBLIC; this.mode = data.mode || Story.FEATURED; this.commentMode = data.commentMode || Story.OPEN; this.setCustomContent(data); // FIXME: To be removed resp. moved to Stories.create_action and // Story.edit_action if work-around for Helma bug #607 fails // We need persistence for setting the tags this.isTransient() && this.persist(); this.setTags(data.tags || data.tag_array); if (delta > 50) { this.modified = new Date; if (this.status !== Story.CLOSED) { site.modified = this.modified; } site.callback(this); // Notification is sent in Stories.create_action() } this.clearCache(); this.modifier = session.user; return; } Story.prototype.status_action = function () { this.status = (this.status === Story.CLOSED ? Story.PUBLIC : Story.CLOSED); return res.redirect(req.data.http_referer || this._parent.href()); }; Story.prototype.mode_action = function () { this.mode = (this.mode === Story.HIDDEN ? Story.FEATURED : Story.HIDDEN); return res.redirect(req.data.http_referer || this._parent.href()); }; Story.prototype.comment_action = function() { // Check if user is logged in since we allow linking here for any user if (!User.require(User.REGULAR)) { User.setLocation(this.href(req.action) + '#form'); res.message = gettext('Please login first.'); res.redirect(this.site.members.href('login')); } if (req.postParams.save) { try { var comment = Comment.add(req.postParams, this); comment.notify(req.action); delete session.data.backup; res.message = gettext('The comment was successfully created.'); res.redirect(comment.href()); } catch (ex) { res.message = ex; app.log(ex); } } res.handlers.parent = this; res.data.action = this.href(req.action); res.data.title = gettext('Add Comment'); HopObject.confirmConstructor(Comment); var comment = new Comment(); res.data.body = comment.renderSkinAsString('Comment#edit'); res.data.body += comment.renderSkinAsString('$Story#editor'); this.site.renderSkin('Site#page'); return; } Story.prototype.amp_html_action = function() { var spec = sanitizeHtml.defaults; var text = this.format_filter(this.text); text = sanitizeHtml(text, spec); this.renderSkin('$Story#amp', { text: text, title: this.title || formatDate(this.created, 'date') }); }; /** * * @param {String} name * @returns {Object} */ Story.prototype.getFormValue = function(name) { if (req.isPost()) { return req.postParams[name]; } switch (name) { case 'commentMode': return this.commentMode || Story.OPEN; case 'mode': return this.mode || Story.FEATURED; case 'status': return this.status || Story.PUBLIC; case 'tags': return this.getTags().join(Tag.DELIMITER); } return this[name] || this.getMetadata(name) || req.queryParams[name]; } /** * * @param {String} name * @returns {String[]} */ Story.prototype.getFormOptions = function(name) { switch (name) { case 'commentMode': return Story.getCommentModes(); case 'mode': return Story.getModes(); case 'status': return Story.getStatus(); case 'tags': // FIXME: This could become a huge select element... return []; } return; } /** * * @param {Object} data */ Story.prototype.setCustomContent = function(data) { var metadata = {}; for (var key in data) { if (this.isCustomContent(key)) { metadata[key] = data[key]; } } return HopObject.prototype.setMetadata.call(this, metadata); } /** * * @param {String} name */ Story.prototype.isCustomContent = function(key) { var reservedKeys = [ 'addtofront', 'addtotopic', 'antvillehash', 'antvilleuser', 'cancel', 'commentmode', 'discussions', 'editableby', 'hopsession', 'mode', 'og:image', 'og:video', 'publish', 'save', 'status', 'submit', 'text', 'tags', 'title', 'topic' ]; return reservedKeys.indexOf(key.toLowerCase()) < 0; } /** * Increment the request counter */ Story.prototype.count = function() { if (session.user === this.creator) { return; } var story; var key = 'Story#' + this._id; if (story = app.data.requests[key]) { story.requests += 1; } else { app.data.requests[key] = { type: this.constructor, id: this._id, requests: this.requests + 1 }; } return; } /** * Calculate the difference of a story’s current and its updated content * @param {Object} data * @returns {Number} */ Story.prototype.getDelta = function(data) { if (this.isTransient()) { return Infinity; } // In-between updates (1 hour) get zero delta if ((new Date() - this.modified) < Date.ONEHOUR) { return 0; } var getDelta = function(a, b) { var lenA = a ? String(a).length : 0; var lenB = b ? String(b).length : 0; return Math.abs(lenA - lenB); }; var delta = 0; delta += getDelta(data.title, this.title); delta += getDelta(data.text, this.text); /* FIXME: Custom content needs refactoring for (var key in data) { if (this.isCustomContent(key)) { delta += getDelta(data[key], this.getMetadata(key)) } } */ return delta; } /** * * @param {String} name * @returns {HopObject} */ Story.prototype.getMacroHandler = function(name) { if (name === 'metadata') { return this.getMetadata(); } else if (name === 'site') { return this.site; } return null; } Story.prototype.getAbstract = function (param) { param || (param = {}); var limit = param.limit || 10; var ratio = 0.5; // Use title and text equivalently var result = [], raw = []; raw.push(this.title, this.text); var titleLimit = Math[ratio >= 0.5 ? 'ceil' : 'floor'](limit * ratio); var title = this.title && stripTags(this.title).clip(titleLimit, null, '\\s'); var titleLength = title ? title.split(/\s/).length : 0; if (titleLength < titleLimit) { ratio = titleLength / limit; } var textLimit = Math[ratio < 0.5 ? 'ceil' : 'floor'](limit * (1 - ratio)); var text = this.text && stripTags(this.text).clip(textLimit, null, '\\s').trim(); title && result.push('' + title + ' '); text && result.push(text); // FIXME: Custom content needs refactoring. // Too much information stored in Story.metadata! /* if (result.length < 1) { var contentArgs = Array.prototype.slice.call(arguments, 1); // Remove first argument (param) if (!contentArgs.length) { for (var key in this.getMetadata()) { if (key !== 'title' && key !== 'text') { contentArgs.push(key); } } } ratio = 1 / contentArgs.length; for (var i = 0, buffer, key = contentArgs[i]; i < contentArgs.length; i += 1) { if (key && (buffer = this.getMetadata(key))) { raw.push(buffer); buffer = stripTags(buffer).clip(limit * ratio, null, '\\s'); buffer && result.push(buffer); } } } */ if (result.length < 1 && typeof param['default'] !== 'string') { return '' + ngettext('{0} character', '{0} characters', raw.join(String.EMPTY).length) + ''; } return result.join(String.SPACE); }; /** * * @param {Object} param */ Story.prototype.abstract_macro = function(param) { return res.write(this.getAbstract.call(this, param)); }; /** * * @param {Object} param * @param {String} mode */ Story.prototype.comments_macro = function(param, mode) { var story = this.story || this; var comments = this.story ? this : this.comments; if (story.isTransient() || story.site.commentMode === Site.DISABLED || story.commentMode === Site.CLOSED) { return; } else if (mode) { var n = comments.size() || 0; if (mode === 'count' || mode === 'size') { res.write(n); } else if (mode === 'link' || mode === 'summary') { var text = ngettext('{0} comment', '{0} comments', n); if (n < 1 || mode === 'summary') { res.write(text); } else { html.link({href: this.href() + '#comments'}, text); } } } else { this.prefetchChildren(); this.forEach(function() { // FIXME: This interferes with (UIKit comment) lists because the element is // between