antville/code/Story/Story.js

793 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The Antville Project
// http://code.google.com/p/antville
//
// Copyright 20012014 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: "<link rel='amphtml' href='" + this.href('amp.html') + "'>"
});
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 storys 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('<b>' + title + '</b> ');
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 '<i>' + ngettext('{0} character', '{0} characters', raw.join(String.EMPTY).length) + '</i>';
}
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 <a> element is
// between <ul> and <li> elements. Must be added to the skin from now on…?
//html.openTag('a', {name: this._id});
//html.closeTag('a');
this.renderSkin(this.parent.constructor === Story ? 'Comment#main' : 'Comment#reply');
});
}
return;
}
/**
*
* @param {Object} param
* @param {String} mode
*/
Story.prototype.tags_macro = function(param, mode) {
if (mode === 'link') {
var tags = [];
this.tags.list().forEach(function(item) {
res.push();
if (item.tag) {
renderLink(param, item.tag.href(), item.tag.name);
tags.push(res.pop());
}
});
return res.write(tags.join(Tag.DELIMITER));
} else if (mode === 'count') {
return this.tags.count();
}
return res.write(this.getFormValue('tags'));
}
/**
*
* @param {Object} param
* @param {Number} limit
*/
Story.prototype.referrers_macro = function(param, limit) {
if (!User.require(User.PRIVILEGED) &&
!Membership.require(Membership.OWNER)) {
return;
}
limit = Math.min(limit || param.limit || 100, 100);
if (limit < 1) {
return;
}
var self = this;
var sql = new Sql;
sql.retrieve(Sql.REFERRERS, 'Story', this._id);
res.push();
var n = 0;
sql.traverse(function() {
if (n < limit && this.requests && this.referrer) {
this.text = encode(this.referrer.head(50));
this.referrer = encode(this.referrer);
self.site.renderSkin('$Site#referrer', this);
}
n += 1;
});
res.data.list = res.pop();
if (res.data.list) {
this.site.renderSkin('$Site#referrerTable', param);
}
return;
}
/**
*
* @param {Object} value
* @param {Object} param
* @param {String} mode
* @returns {String}
*/
Story.prototype.format_filter = function(value, param, mode) {
if (value) {
if (!param) param = {};
switch (mode) {
case 'quotes':
return stripTags(value).replace(/(?:\x22|\x27)/g, function(quote) {
return '&#' + quote.charCodeAt(0) + ';';
});
case 'image':
var image = HopObject.getFromPath(value, 'images');
if (image) {
res.push();
image.render_macro(param);
return res.pop();
}
break;
default:
value = this.linebreak_filter(value, param, 'markdown');
value = this.macro_filter(value, param);
value = this.markdown_filter(value, param);
value = this.url_filter(value, param);
return value;
}
}
return String.EMTPY;
}
Story.prototype.linebreak_filter = function (value, param, mode) {
if (mode === 'markdown') {
var mdLineBreakMarker = new RegExp('<!--av-break-->', 'g');
var mdQuoteMarker = new RegExp('<!--av-quote-->', 'g');
var mdCodeMarker = new RegExp('<!--av-code-->', 'g');
return value
// Prevent Markdown for linebreaks (lines ending with 2 spaces) as well as
// code segments (4 spaces) to be removed by Helmas format() method
.replace(/ {2}$/gm, mdLineBreakMarker.source)
.replace(/^ {4}/gm, mdCodeMarker.source)
// Prevent Markdown for quote segments (lines starting with >)
// to be removed by Helmas format method()
.replace(/^(>+)/gm, function(item) {
return mdQuoteMarker.source.repeat(item.length);
})
// Apply Helmas format() method for good
// FIXME: This should probably be moved to the compat layer
.format(value)
// Replace trailing HTML linebreaks with Markdown ones
.replace(/<br[^>]*>$/gm, String.SPACE.repeat(2))
// Replace trailing HTML linebreaks inserted by Helmas format() method with Markdown ones
//.replace(/<br\s*class=['"]?helma-format['"]?\s*\/?>/g, String.SPACE.repeat(2))
// Restore Markdown quote segments
.replace(mdQuoteMarker, '>')
// Restore Markdown linebreaks and code segments
.replace(mdLineBreakMarker, String.SPACE.repeat(2))
.replace(mdCodeMarker, String.SPACE.repeat(4));
} else {
var parts = value.split(/(?:\n\n|\r\r|\r\n\r\n)+/);
value = format('<p>' + parts.join('</p><p>') + '</p>');
}
return value;
};
Story.prototype.code_filter = function (value, param) {
value = this.linebreak_filter(value, param);
return this.markdown_filter(value, param);
};
/**
* Enables certain macros for being used in a story or comment thus, any content object.
* @param {String|Skin} value A skin object or a string that is going to be turned into a skin object.
* @returns {String}
*/
Story.prototype.macro_filter = function(value) {
var skin = value.constructor === String ? createSkin(value) : value;
Story.ALLOWED_MACROS.forEach(function(value, index) {
skin.allowMacro(value);
});
var site;
if (this.site !== res.handlers.site) {
site = res.handlers.site;
res.handlers.site = this.site;
}
value = this.renderSkinAsString(skin);
site && (res.handlers.site = site);
return value;
}
/**
*
* @param {String} value
* @param {Object} param
* @param {String} mode
* @returns {String}
*/
Story.prototype.url_filter = function(value, param, mode) {
var re = />\s*((?:(?:ftp|https?):)?\/\/)([^/]+)(\/[^<]*)?<\/a>/g;
if (!mode) {
return value.replace(re, function (str, head, domain, tail) {
return '>' + domain + '</a>';
});
} else if (mode === 'plain') {
var limit = param.limit || 10;
return value.replace(re, function (str, head, domain, tail) {
tail = tail ? tail.clip(limit) : String.EMPTY
return '>' + head + domain + tail + '</a>';
});
} else if (mode === 'extended') {
return value.replace(re, function (str, head, domain, tail) {
var baseName = tail ? tail.split('/').pop() : null;
if (baseName) {
baseName = baseName.split(/[?#]/).shift();
return '>' + baseName + '</a> <span class="uk-text-muted">(' + domain + ')</span>';
}
return '>' + domain + '</a>';
});
}
return value;
};
Story.prototype.markdown_filter = function (value, param) {
return marked(value);
};
/**
* @returns {String}
*/
Story.prototype.getConfirmText = function() {
return gettext("You are about to delete a story by {0}.",
this.creator ? this.creator.name : 'null');
}