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