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