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 return; 55 } 56 while (this.comments.size() > 0) { 57 Comment.remove.call(this.comments.get(0)); 58 } 59 this.setTags(null); 60 this.remove(); 61 return; 62 } 63 64 this.handleMetadata("title"); 65 this.handleMetadata("text"); 66 67 /** 68 * @name Story 69 * @constructor 70 * @property {Comment[]} _children 71 * @property {String} commentMode 72 * @property {Comment[]} comments 73 * @property {Date} created 74 * @property {User} creator 75 * @property {Metadata} metadata 76 * @property {String} mode 77 * @property {Date} modified 78 * @property {User} modifier 79 * @property {String} name 80 * @property {Number} parent_id 81 * @property {String} parent_type 82 * @property {String} prototype 83 * @property {Number} requests 84 * @property {Site} site 85 * @property {String} status 86 * @property {TagHub[]} tags 87 * @property {String} text 88 * @property {String} title 89 * @extends HopObject 90 */ 91 Story.prototype.constructor = function() { 92 this.name = String.EMPTY; 93 this.requests = 0; 94 this.status = Story.PUBLIC; 95 this.mode = Story.FEATURED; 96 this.commentMode = Story.OPEN; 97 this.creator = this.modifier = session.user; 98 this.created = this.modified = new Date; 99 return this; 100 } 101 102 /** 103 * 104 * @param {String} action 105 * @returns {Boolean} 106 */ 107 Story.prototype.getPermission = function(action) { 108 if (!this.site.getPermission("main")) { 109 return false; 110 } 111 switch (action) { 112 case ".": 113 case "main": 114 return this.status !== Story.CLOSED || 115 this.creator === session.user || 116 Membership.require(Membership.MANAGER) || 117 User.require(User.PRIVILEGED); 118 case "comment": 119 return this.site.commentMode === Site.ENABLED && 120 (this.commentMode === Story.OPEN || 121 this.commentMode === Story.MODERATED); 122 case "delete": 123 return this.creator === session.user || 124 Membership.require(Membership.MANAGER) || 125 User.require(User.PRIVILEGED); 126 case "edit": 127 case "rotate": 128 return this.creator === session.user || 129 Membership.require(Membership.MANAGER) || 130 (this.status === Story.SHARED && 131 Membership.require(Membership.CONTRIBUTOR)) || 132 (this.status === Story.OPEN && 133 Membership.require(Membership.SUBSCRIBER)) || 134 User.require(User.PRIVILEGED); 135 } 136 return false; 137 } 138 139 Story.prototype.main_action = function() { 140 res.data.title = this.getTitle(5); 141 res.data.body = this.renderSkinAsString("Story#main"); 142 this.site.renderSkin("Site#page"); 143 this.site.log(); 144 this.count(); 145 this.log(); 146 return; 147 } 148 149 /** 150 * 151 * @param {Number} limit 152 * @returns {String} 153 */ 154 Story.prototype.getTitle = function(limit) { 155 var key = this + ":title:" + limit; 156 if (!res.meta[key]) { 157 if (this.title) { 158 res.meta[key] = stripTags(this.title).clip(limit, "...", "\\s"); 159 } else if (this.text) { 160 var parts = stripTags(this.text).embody(limit, "...", "\\s"); 161 res.meta[key] = parts.head; 162 res.meta[this + ":text:" + limit] = parts.tail; 163 } 164 } 165 return String(res.meta[key]) || "..."; 166 } 167 168 Story.prototype.edit_action = function() { 169 if (req.postParams.save) { 170 try { 171 this.update(req.postParams); 172 delete session.data.backup; 173 res.message = gettext("The story was successfully updated."); 174 res.redirect(this.href()); 175 } catch (ex) { 176 res.message = ex; 177 app.log(ex); 178 } 179 } 180 181 res.data.action = this.href(req.action); 182 res.data.title = gettext('Edit story: {0}', this.getTitle(3)); 183 res.data.body = this.renderSkinAsString("Story#edit"); 184 this.site.renderSkin("Site#page"); 185 return; 186 } 187 188 /** 189 * 190 * @param {Object} data 191 */ 192 Story.prototype.update = function(data) { 193 var site = this.site || res.handlers.site; 194 195 if (!data.title && !data.text) { 196 throw Error(gettext("Please enter at least something into the 'title' or 'text' field.")); 197 } 198 if (data.created) { 199 try { 200 this.created = data.created.toDate("yyyy-MM-dd HH:mm", 201 site.getTimeZone()); 202 } catch (ex) { 203 throw Error(gettext("Cannot parse timestamp {0} as a date.", data.created)); 204 app.log(ex); 205 } 206 } 207 208 // Get difference to current content before applying changes 209 var delta = this.getDelta(data); 210 this.title = data.title ? data.title.trim() : String.EMPTY; 211 this.text = data.text ? data.text.trim() : String.EMPTY; 212 this.status = data.status || Story.PUBLIC; 213 this.mode = data.mode || Story.FEATURED; 214 this.commentMode = data.commentMode || Story.OPEN; 215 this.setMetadata(data); 216 217 // FIXME: To be removed resp. moved to Stories.create_action and 218 // Story.edit_action if work-around for Helma bug #607 fails 219 // We need persistence for setting the tags 220 this.isTransient() && this.persist(); 221 this.setTags(data.tags || data.tag_array); 222 223 if (delta > 50) { 224 this.modified = new Date; 225 if (this.status !== Story.CLOSED) { 226 site.modified = this.modified; 227 } 228 site.callback(this); 229 // FIXME: Where did this.notify(req.action) go? 230 } 231 232 this.clearCache(); 233 this.modifier = session.user; 234 return; 235 } 236 237 Story.prototype.rotate_action = function() { 238 if (this.status === Story.CLOSED) { 239 this.status = this.cache.status || Story.PUBLIC; 240 } else if (this.mode === Story.FEATURED) { 241 this.mode = Story.HIDDEN; 242 } else { 243 this.cache.status = this.status; 244 this.mode = Story.FEATURED; 245 this.status = Story.CLOSED; 246 } 247 return res.redirect(req.data.http_referer || this._parent.href()); 248 } 249 250 Story.prototype.comment_action = function() { 251 // Check if user is logged in since we allow linking here for any user 252 if (!User.require(User.REGULAR)) { 253 User.setLocation(this.href(req.action) + "#form"); 254 res.message = gettext("Please login first."); 255 res.redirect(this.site.members.href("login")); 256 } 257 var comment = new Comment(this); 258 if (req.postParams.save) { 259 try { 260 comment.update(req.postParams); 261 this.add(comment); 262 // Force addition to aggressively cached collection 263 (this.story || this).comments.add(comment); 264 comment.notify(req.action); 265 delete session.data.backup; 266 res.message = gettext("The comment was successfully created."); 267 res.redirect(comment.href()); 268 } catch (ex) { 269 res.message = ex; 270 app.log(ex); 271 } 272 } 273 res.handlers.parent = this; 274 res.data.action = this.href(req.action); 275 res.data.title = gettext("Add comment to {0}", this.getTitle()); 276 res.data.body = comment.renderSkinAsString("Comment#edit"); 277 this.site.renderSkin("Site#page"); 278 return; 279 } 280 281 /** 282 * 283 * @param {String} name 284 * @returns {Object} 285 */ 286 Story.prototype.getFormValue = function(name) { 287 if (req.isPost()) { 288 return req.postParams[name]; 289 } 290 switch (name) { 291 case "commentMode": 292 return this.commentMode || Story.OPEN; 293 case "mode": 294 return this.mode || Story.FEATURED; 295 case "status": 296 return this.status || Story.PUBLIC; 297 case "tags": 298 return this.getTags(); 299 } 300 return this[name]; 301 } 302 303 /** 304 * 305 * @param {String} name 306 * @returns {String[]} 307 */ 308 Story.prototype.getFormOptions = function(name) { 309 switch (name) { 310 case "commentMode": 311 return Story.getCommentModes(); 312 case "mode": 313 return Story.getModes(); 314 case "status": 315 return Story.getStatus(); 316 case "tags": 317 // FIXME: This could become a huge select element... 318 return []; 319 } 320 return; 321 } 322 323 /** 324 * 325 * @param {Object} data 326 */ 327 Story.prototype.setMetadata = function(data) { 328 var name; 329 for (var key in data) { 330 if (this.isMetadata(key)) { 331 this.metadata.set(key, data[key]); 332 } 333 } 334 return; 335 } 336 337 /** 338 * 339 * @param {String} name 340 */ 341 Story.prototype.isMetadata = function(name) { 342 return this[name] === undefined && name !== "save"; 343 } 344 345 /** 346 * Increment the request counter 347 */ 348 Story.prototype.count = function() { 349 if (session.user === this.creator) { 350 return; 351 } 352 var story; 353 var key = "Story#" + this._id; 354 if (story = app.data.requests[key]) { 355 story.requests += 1; 356 } else { 357 app.data.requests[key] = { 358 type: this.constructor, 359 id: this._id, 360 requests: this.requests + 1 361 }; 362 } 363 return; 364 } 365 366 /** 367 * Calculate the difference of a story’s current and its updated content 368 * @param {Object} data 369 * @returns {Number} 370 */ 371 Story.prototype.getDelta = function(data) { 372 if (this.isTransient()) { 373 return Infinity; 374 } 375 376 var deltify = function(s1, s2) { 377 var len1 = s1 ? String(s1).length : 0; 378 var len2 = s2 ? String(s2).length : 0; 379 return Math.abs(len1 - len2); 380 }; 381 382 var delta = 0; 383 delta += deltify(data.title, this.title); 384 delta += deltify(data.text, this.text); 385 for (var key in data) { 386 if (this.isMetadata(key)) { 387 delta += deltify(data[key], this.metadata.get(key)) 388 } 389 } 390 // In-between updates (1 hour) get zero delta 391 var timex = (new Date - this.modified) > Date.ONEHOUR ? 1 : 0; 392 return delta * timex; 393 } 394 395 /** 396 * 397 * @param {String} name 398 * @returns {HopObject} 399 */ 400 Story.prototype.getMacroHandler = function(name) { 401 if (name === "metadata") { 402 return this.metadata; 403 } 404 return null; 405 } 406 407 /** 408 * 409 * @param {Object} param 410 * @param {String} action 411 * @param {String} text 412 */ 413 Story.prototype.link_macro = function(param, action, text) { 414 switch (action) { 415 case "rotate": 416 if (this.status === Story.CLOSED) { 417 text = gettext("Publish"); 418 } else if (this.mode === Story.FEATURED) { 419 text = gettext("Hide"); 420 } else { 421 text = gettext("Close"); 422 } 423 } 424 return HopObject.prototype.link_macro.call(this, param, action, text); 425 } 426 427 /** 428 * 429 * @param {Object} param 430 */ 431 Story.prototype.summary_macro = function(param) { 432 param.limit || (param.limit = 15); 433 var keys, summary; 434 if (arguments.length > 1) { 435 res.push(); 436 var content; 437 for (var i=1; i<arguments.length; i+=1) { 438 if (content = this.metadata.get("metadata_" + arguments[i])) { 439 res.write(content); 440 res.write(String.SPACE); 441 } 442 } 443 summary = res.pop(); 444 } 445 if (!summary) { 446 summary = (this.title || String.EMPTY) + String.SPACE + 447 (this.text || String.EMPTY); 448 } 449 var clipped = stripTags(summary).clip(param.limit, param.clipping, "\\s"); 450 var head = clipped.split(/(\s)/, param.limit * 0.6).join(String.EMPTY); 451 var tail = clipped.substring(head.length).trim(); 452 head = head.trim(); 453 if (!head && !tail) { 454 head = "..."; 455 } 456 html.link({href: this.href()}, head); 457 res.writeln("\n"); 458 res.write(tail); 459 return; 460 } 461 462 /** 463 * 464 * @param {Object} param 465 * @param {String} mode 466 */ 467 Story.prototype.comments_macro = function(param, mode) { 468 var story = this.story || this; 469 if (story.site.commentMode === Site.DISABLED || 470 story.commentMode === Site.CLOSED) { 471 return; 472 } else if (mode) { 473 var n = this.comments.size() || 0; 474 var text = ngettext("{0} comment", "{0} comments", n); 475 if (mode === "count" || mode === "size") { 476 res.write(text); 477 } else if (mode === "link") { 478 n < 1 ? res.write(text) : 479 html.link({href: this.href() + "#comments"}, text); 480 } 481 } else { 482 this.comments.prefetchChildren(); 483 this.forEach(function() { 484 html.openTag("a", {name: this._id}); 485 html.closeTag("a"); 486 this.renderSkin(this.parent.constructor === Story ? 487 "Comment#main" : "Comment#reply"); 488 }); 489 } 490 return; 491 } 492 493 /** 494 * 495 * @param {Object} param 496 * @param {String} mode 497 */ 498 Story.prototype.tags_macro = function(param, mode) { 499 if (mode === "link") { 500 var links = []; 501 this.tags.list().forEach(function(item) { 502 res.push(); 503 if (item.tag) { 504 renderLink(param, item.tag.href(), item.tag.name); 505 links.push(res.pop()); 506 } 507 }); 508 return res.write(links.join(", ")); 509 } 510 return res.write(this.getFormValue("tags")); 511 } 512 513 /** 514 * 515 * @param {Object} param 516 * @param {Number} limit 517 */ 518 Story.prototype.referrers_macro = function(param, limit) { 519 if (!User.require(User.PRIVILEGED) && 520 !Membership.require(Membership.OWNER)) { 521 return; 522 } 523 524 limit = Math.min(limit || param.limit || 100, 100); 525 if (limit < 1) { 526 return; 527 } 528 529 var self = this; 530 var sql = new Sql(); 531 sql.retrieve("select referrer, count(*) as requests from " + 532 "log where context_type = 'Story' and context_id = $0 and action = " + 533 "'main' and created > date_add(now(), interval -1 day) group " + 534 "by referrer order by requests desc, referrer asc", 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