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