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