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