1 //
  2 // The Antville Project
  3 // http://code.google.com/p/antville
  4 //
  5 // Copyright 2001-2007 by The Antville People
  6 //
  7 // Licensed under the Apache License, Version 2.0 (the ``License'');
  8 // you may not use this file except in compliance with the License.
  9 // You may obtain a copy of the License at
 10 //
 11 //    http://www.apache.org/licenses/LICENSE-2.0
 12 //
 13 // Unless required by applicable law or agreed to in writing, software
 14 // distributed under the License is distributed on an ``AS IS'' BASIS,
 15 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 16 // See the License for the specific language governing permissions and
 17 // limitations under the License.
 18 //
 19 // $Revision$
 20 // $LastChangedBy$
 21 // $LastChangedDate$
 22 // $URL$
 23 //
 24 
 25 /**
 26  * @fileOverview Defindes the Story prototype.
 27  */
 28 
 29 markgettext("Story");
 30 markgettext("story");
 31 
 32 /**
 33  * @function
 34  * @returns {String[]}
 35  * @see defineConstants
 36  */
 37 Story.getStatus = defineConstants(Story, markgettext("closed"), 
 38       markgettext("public"), markgettext("shared"), markgettext("open"));
 39 /**
 40  * @function
 41  * @returns {String[]}
 42  * @see defineConstants
 43  */
 44 Story.getModes = defineConstants(Story, markgettext("hidden"), 
 45       markgettext("featured"));
 46 /**
 47  * @function
 48  * @returns {String[]}
 49  * @see defineConstants
 50  */
 51 Story.getCommentModes = defineConstants(Story, markgettext("closed"), 
 52       /* markgettext("readonly"), markgettext("moderated"), */ 
 53       markgettext("open"));
 54 
 55 /**
 56  * 
 57  */
 58 Story.remove = function() {
 59    if (this.constructor === Story) {
 60       HopObject.remove.call(this.comments);
 61       this.setTags(null);
 62       this.remove();
 63    }
 64    return;
 65 }
 66 
 67 this.handleMetadata("title");
 68 this.handleMetadata("text");
 69 
 70 /**
 71  * @name Story
 72  * @constructor
 73  * @property {Comment[]} _children
 74  * @property {String} commentMode
 75  * @property {Comment[]} comments
 76  * @property {Date} created
 77  * @property {User} creator
 78  * @property {Metadata} metadata
 79  * @property {String} mode
 80  * @property {Date} modified
 81  * @property {User} modifier
 82  * @property {String} name
 83  * @property {Number} parent_id
 84  * @property {String} parent_type
 85  * @property {String} prototype
 86  * @property {Number} requests
 87  * @property {Site} site
 88  * @property {String} status
 89  * @property {TagHub[]} tags
 90  * @property {String} text
 91  * @property {String} title
 92  * @extends HopObject
 93  */
 94 Story.prototype.constructor = function() {
 95    this.name = String.EMPTY;
 96    this.requests = 0;
 97    this.status = Story.PUBLIC;
 98    this.mode = Story.FEATURED;
 99    this.commentMode = Story.OPEN;
100    this.creator = this.modifier = session.user;
101    this.created = this.modified = new Date;
102    return this;
103 }
104 
105 /**
106  * 
107  * @param {String} action
108  * @returns {Boolean}
109  */
110 Story.prototype.getPermission = function(action) {
111    if (!this.site.getPermission("main")) {
112       return false;
113    }
114    switch (action) {
115       case ".":
116       case "main":
117       return this.status !== Story.CLOSED || 
118             this.creator === session.user || 
119             Membership.require(Membership.MANAGER) || 
120             User.require(User.PRIVILEGED);
121       case "comment":
122       return this.site.commentMode === Site.ENABLED &&
123             (this.commentMode === Story.OPEN ||
124             this.commentMode === Story.MODERATED);
125       case "delete":
126       return this.creator === session.user || 
127             Membership.require(Membership.MANAGER) ||
128             User.require(User.PRIVILEGED);            
129       case "edit":
130       case "rotate":
131       return this.creator === session.user || 
132             Membership.require(Membership.MANAGER) || 
133             (this.status === Story.SHARED &&
134             Membership.require(Membership.CONTRIBUTOR)) || 
135             (this.status === Story.OPEN && 
136             Membership.require(Membership.SUBSCRIBER)) ||
137             User.require(User.PRIVILEGED);
138    }
139    return false;
140 }
141 
142 Story.prototype.main_action = function() {
143    res.data.title = this.getTitle(5);
144    res.data.body = this.renderSkinAsString("Story#main");
145    this.site.renderSkin("Site#page");
146    this.site.log();
147    this.count();
148    this.log();
149    return;
150 }
151 
152 /**
153  * 
154  * @param {Number} limit
155  * @returns {String}
156  */
157 Story.prototype.getTitle = function(limit) {
158    var key = this + ":title:" + limit;
159    if (!res.meta[key]) {
160       if (this.title) {
161          res.meta[key] = stripTags(this.title).clip(limit, "...", "\\s");
162       } else if (this.text) {
163          var parts = stripTags(this.text).embody(limit, "...", "\\s");
164          res.meta[key] = parts.head;
165          res.meta[this + ":text:" + limit] = parts.tail;
166       }
167    }
168    return String(res.meta[key]) || "..."; 
169 }
170 
171 Story.prototype.edit_action = function() {
172    if (req.postParams.save) {
173       try {
174          this.update(req.postParams);
175          delete session.data.backup;
176          res.message = gettext("The story was successfully updated.");
177          res.redirect(this.href());
178       } catch (ex) {
179          res.message = ex;
180          app.log(ex);
181       }
182    }
183    
184    res.data.action = this.href(req.action);
185    res.data.title = gettext('Edit Story');
186    res.data.body = this.renderSkinAsString("Story#edit");
187    this.site.renderSkin("Site#page");
188    return;
189 }
190 
191 /**
192  * 
193  * @param {Object} data
194  */
195 Story.prototype.update = function(data) {
196    var site = this.site || res.handlers.site;
197    
198    if (!data.title && !data.text) {
199       throw Error(gettext("Please enter at least something into the “title” or “text” field."));
200    }
201    if (data.created) {
202       try {
203          this.created = data.created.toDate(SHORTDATEFORMAT, 
204                site.getTimeZone());
205       } catch (ex) {
206          throw Error(gettext("Cannot parse timestamp {0} as a date.", data.created));
207          app.log(ex);
208       }
209    }
210    
211    // Get difference to current content before applying changes
212    var delta = this.getDelta(data);
213    this.title = data.title ? data.title.trim() : String.EMPTY;
214    this.text = data.text ? data.text.trim() : String.EMPTY;
215    this.status = data.status || Story.PUBLIC;
216    this.mode = data.mode || Story.FEATURED;
217    this.commentMode = data.commentMode || Story.OPEN;
218    this.setMetadata(data);
219 
220    // FIXME: To be removed resp. moved to Stories.create_action and 
221    // Story.edit_action if work-around for Helma bug #607 fails
222    // We need persistence for setting the tags
223    this.isTransient() && this.persist();
224    this.setTags(data.tags || data.tag_array);
225 
226    if (delta > 50) {
227       this.modified = new Date;
228       if (this.status !== Story.CLOSED) {
229          site.modified = this.modified;
230       }
231       site.callback(this);
232       // Notification is sent in Stories.create_action()
233    }
234    
235    this.clearCache();
236    this.modifier = session.user;
237    return;
238 }
239 
240 Story.prototype.rotate_action = function() {
241    if (this.status === Story.CLOSED) {
242       this.status = this.cache.status || Story.PUBLIC;
243    } else if (this.mode === Story.FEATURED) {
244       this.mode = Story.HIDDEN;
245    } else {
246       this.cache.status = this.status;
247       this.mode = Story.FEATURED;
248       this.status = Story.CLOSED;
249    }
250    return res.redirect(req.data.http_referer || this._parent.href());
251 }
252 
253 Story.prototype.comment_action = function() {
254    // Check if user is logged in since we allow linking here for any user
255    if (!User.require(User.REGULAR)) {
256       User.setLocation(this.href(req.action) + "#form");
257       res.message = gettext("Please login first.");
258       res.redirect(this.site.members.href("login"));
259    }
260    var comment = new Comment(this);
261    if (req.postParams.save) {
262       try {
263          comment.update(req.postParams);
264          this.add(comment);
265          // Force addition to aggressively cached collection
266          (this.story || this).comments.add(comment);
267          comment.notify(req.action);
268          delete session.data.backup;
269          res.message = gettext("The comment was successfully created.");
270          res.redirect(comment.href());
271       } catch (ex) {
272          res.message = ex;
273          app.log(ex);
274       }
275    }
276    res.handlers.parent = this;
277    res.data.action = this.href(req.action);
278    res.data.title = gettext("Add Comment");
279    res.data.body = comment.renderSkinAsString("Comment#edit");
280    this.site.renderSkin("Site#page");
281    return;
282 }
283 
284 /**
285  * 
286  * @param {String} name
287  * @returns {Object}
288  */
289 Story.prototype.getFormValue = function(name) {
290    if (req.isPost()) {
291       return req.postParams[name];
292    }
293    switch (name) {
294       case "commentMode":
295       return this.commentMode || Story.OPEN;
296       case "mode":
297       return this.mode || Story.FEATURED;
298       case "status":
299       return this.status || Story.PUBLIC;
300       case "tags":
301       return this.getTags().join(Tag.DELIMITER);
302    }
303    return this[name];
304 }
305 
306 /**
307  * 
308  * @param {String} name
309  * @returns {String[]}
310  */
311 Story.prototype.getFormOptions = function(name) {
312    switch (name) {
313       case "commentMode":
314       return Story.getCommentModes();
315       case "mode":
316       return Story.getModes();
317       case "status":
318       return Story.getStatus();
319       case "tags":
320       // FIXME: This could become a huge select element...
321       return [];
322    }
323    return;
324 }
325 
326 /**
327  * 
328  * @param {Object} data
329  */
330 Story.prototype.setMetadata = function(data) {
331    var name;
332    for (var key in data) {
333       if (this.isMetadata(key)) {
334          this.metadata.set(key, data[key]);
335       }
336    }
337    return;
338 }
339 
340 /**
341  * 
342  * @param {String} name
343  */
344 Story.prototype.isMetadata = function(name) {
345    return this[name] === undefined && name !== "save";
346 }
347 
348 /**
349  * Increment the request counter
350  */
351 Story.prototype.count = function() {
352    if (session.user === this.creator) {
353       return;
354    }
355    var story;
356    var key = "Story#" + this._id;
357    if (story = app.data.requests[key]) {
358       story.requests += 1;
359    } else {
360       app.data.requests[key] = {
361          type: this.constructor,
362          id: this._id,
363          requests: this.requests + 1
364       };
365    }
366    return;
367 }
368 
369 /**
370  * Calculate the difference of a story’s current and its updated content
371  * @param {Object} data
372  * @returns {Number}
373  */
374 Story.prototype.getDelta = function(data) {
375    if (this.isTransient()) {
376       return Infinity;
377    }
378 
379    var deltify = function(s1, s2) {
380       var len1 = s1 ? String(s1).length : 0;
381       var len2 = s2 ? String(s2).length : 0;
382       return Math.abs(len1 - len2);
383    };
384 
385    var delta = 0;
386    delta += deltify(data.title, this.title);
387    delta += deltify(data.text, this.text);
388    for (var key in data) {
389       if (this.isMetadata(key)) {
390          delta += deltify(data[key], this.metadata.get(key))
391       }
392    }
393    // In-between updates (1 hour) get zero delta
394    var timex = (new Date - this.modified) > Date.ONEHOUR ? 1 : 0;
395    return delta * timex;
396 }
397 
398 /**
399  * 
400  * @param {String} name
401  * @returns {HopObject}
402  */
403 Story.prototype.getMacroHandler = function(name) {
404    if (name === "metadata") {
405       return this.metadata;
406    }
407    return null;
408 }
409 
410 /**
411  * 
412  * @param {Object} param
413  * @param {String} action
414  * @param {String} text
415  */
416 Story.prototype.link_macro = function(param, action, text) {
417    switch (action) {
418       case "rotate":
419       if (this.status === Story.CLOSED) {
420          text = gettext("Publish");
421       } else if (this.mode === Story.FEATURED) {
422          text = gettext("Hide");
423       } else {
424          text = gettext("Close");
425       }
426    }
427    return HopObject.prototype.link_macro.call(this, param, action, text);
428 }
429 
430 /**
431  * 
432  * @param {Object} param
433  */
434 Story.prototype.summary_macro = function(param) {
435    param.limit || (param.limit = 15);
436    var keys, summary;
437    if (arguments.length > 1) {
438       res.push();
439       var content;
440       for (var i=1; i<arguments.length; i+=1) {
441          if (content = this.metadata.get("metadata_" + arguments[i])) {
442             res.write(content);
443             res.write(String.SPACE);
444          }
445       }      
446       summary = res.pop();
447    }
448    if (!summary) {
449       summary = (this.title || String.EMPTY) + String.SPACE + 
450             (this.text || String.EMPTY);
451    }
452    var clipped = stripTags(summary).clip(param.limit, param.clipping, "\\s");
453    var head = clipped.split(/(\s)/, param.limit * 0.6).join(String.EMPTY);
454    var tail = clipped.substring(head.length).trim();
455    head = head.trim();
456    if (!head && !tail) {
457       head = "...";
458    }
459    html.link({href: this.href()}, head);
460    res.writeln("\n");
461    res.write(tail);
462    return;
463 }
464 
465 /**
466  * 
467  * @param {Object} param
468  * @param {String} mode
469  */
470 Story.prototype.comments_macro = function(param, mode) {
471    var story = this.story || this;
472    if (story.site.commentMode === Site.DISABLED || 
473          story.commentMode === Site.CLOSED) {
474       return;
475    } else if (mode) {
476       var n = this.comments.size() || 0;
477       var text = ngettext("{0} comment", "{0} comments", n);
478       if (mode === "count" || mode === "size") {
479          res.write(text);
480       } else if (mode === "link") {
481          n < 1 ? res.write(text) : 
482                html.link({href: this.href() + "#comments"}, text);
483       }
484    } else {
485       this.prefetchChildren();
486       this.forEach(function() {
487          html.openTag("a", {name: this._id});
488          html.closeTag("a");
489          this.renderSkin(this.parent.constructor === Story ? 
490                "Comment#main" : "Comment#reply");
491       });
492    }
493    return;
494 }
495 
496 /**
497  * 
498  * @param {Object} param
499  * @param {String} mode
500  */
501 Story.prototype.tags_macro = function(param, mode) {
502    if (mode === "link") {
503       var tags = [];
504       this.tags.list().forEach(function(item) {
505          res.push();
506          if (item.tag) {
507             renderLink(param, item.tag.href(), item.tag.name);
508             tags.push(res.pop());
509          }
510       });
511       return res.write(tags.join(Tag.DELIMITER));
512    }
513    return res.write(this.getFormValue("tags"));
514 }
515 
516 /**
517  * 
518  * @param {Object} param
519  * @param {Number} limit
520  */
521 Story.prototype.referrers_macro = function(param, limit) {
522    if (!User.require(User.PRIVILEGED) && 
523          !Membership.require(Membership.OWNER)) {
524       return;
525    }
526 
527    limit = Math.min(limit || param.limit || 100, 100);
528    if (limit < 1) {
529       return;
530    }
531 
532    var self = this;
533    var sql = new Sql;
534    sql.retrieve(Sql.REFERRERS, "Story", this._id);
535 
536    res.push();
537    var n = 0;
538    sql.traverse(function() {
539       if (n < limit && this.requests && this.referrer) {
540          this.text = encode(this.referrer.head(50));
541          this.referrer = encode(this.referrer);
542          self.renderSkin("$Story#referrer", this);
543       }
544       n += 1;
545    });
546    param.referrers = res.pop();
547    if (param.referrers) {
548       this.renderSkin("$Story#referrers", param);
549    }
550    return;   
551 }
552 
553 /**
554  * 
555  * @param {Object} value
556  * @param {Object} param
557  * @param {String} mode
558  * @returns {String}
559  */
560 Story.prototype.format_filter = function(value, param, mode) {
561    if (value) {
562       switch (mode) {
563          case "plain":
564          return this.url_filter(stripTags(value), param, mode);
565          
566          case "quotes":
567          return stripTags(value).replace(/(\"|\')/g, function(str, quotes) {
568             return "&#" + quotes.charCodeAt(0) + ";";
569          });
570          
571          case "image":
572          var image = HopObject.getFromPath(value, "images");
573          if (image) {
574             res.push();
575             image.render_macro(param);
576             return res.pop();
577          }
578          break;
579          
580          default:
581          value = this.macro_filter(format(value), param);
582          return this.url_filter(value, param);
583       }
584    }
585    return String.EMTPY;
586 }
587 
588 /**
589  * 
590  * @param {String|Skin} value
591  * @param {Object} param
592  * @returns {String}
593  */
594 Story.prototype.macro_filter = function(value, param) {
595    var skin = value.constructor === String ? createSkin(value) : value;
596    skin.allowMacro("image");
597    skin.allowMacro("this.image");
598    skin.allowMacro("site.image");
599    skin.allowMacro("story.image");
600    skin.allowMacro("thumbnail");
601    skin.allowMacro("this.thumbnail");
602    skin.allowMacro("site.thumbnail");
603    skin.allowMacro("story.thumbnail");
604    skin.allowMacro("link");
605    skin.allowMacro("this.link");
606    skin.allowMacro("site.link");
607    skin.allowMacro("story.link");
608    skin.allowMacro("file");
609    skin.allowMacro("poll");
610    skin.allowMacro("logo");
611    skin.allowMacro("storylist");
612    skin.allowMacro("fakemail");
613    skin.allowMacro("this.topic");
614    skin.allowMacro("story.topic");
615    skin.allowMacro("imageoftheday");
616    skin.allowMacro("spacer");
617 
618    var site;
619    if (this.site !== res.handlers.site) {
620       site = res.handlers.site;
621       res.handlers.site = this.site;
622    }
623    value = this.renderSkinAsString(skin);
624    site && (res.handlers.site = site);
625    return value;
626 }
627 
628 /**
629  * 
630  * @param {String} value
631  * @param {Object} param
632  * @param {String} mode
633  * @returns {String}
634  */
635 Story.prototype.url_filter = function(value, param, mode) {
636    param.limit || (param.limit = 50);
637    // FIXME: The first RegExp has troubles with <a href=http://... (no quotes)
638    //var re = /(^|\/>|\s+)([\w+-_]+:\/\/[^\s]+?)([\.,;:\)\]\"]?)(?=[\s<]|$)/gim;
639    var re = /(^|\/>|\s+)([!fhtpsr]+:\/\/[^\s]+?)([\.,;:\)\]\"]?)(?=[\s<]|$)/gim
640    return value.replace(re, function(str, head, url, tail) {
641       if (url.startsWith("!")) {
642          return head + url.substring(1) + tail;
643       }
644       res.push();
645       res.write(head);
646       if (mode === "plain") {
647          res.write(url.clip(param.limit));
648       } else {
649          var text, location = /:\/\/([^\/]*)/.exec(url)[1];
650          text = location;
651          if (mode === "extended") {
652             text = url.replace(/^.+\/([^\/]*)$/, "$1");
653          }
654          html.link({href: url, title: url}, text.clip(param.limit));
655          if (mode === "extended" && text !== location) {
656             res.write(" <small>(" + location + ")</small>");
657          }
658       }
659       res.write(tail);
660       return res.pop();
661    });
662 }
663 
664 /**
665  * @returns {String}
666  */
667 Story.prototype.getConfirmText = function() {
668    return gettext("You are about to delete a story by user {0}.", 
669          this.creator.name);
670 }
671