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