root/lang/javascript/vimperator-plugins/trunk/twittperator/twlist-win.tw @ 38497

Revision 38497, 21.4 kB (checked in by teramako, 4 years ago)

add context menu

Line 
1/*
2ほぼ、マウス前提なので、Vimperatorらしからぬプラグインですが...
3短縮URLはアイテムを選択すると展開されるはず、
4あと、画像っぽいURLも展開する(まだ出来るものが少ない)
5
6ToDo: YouTubeとかも展開出来るとイイね!
7
8== Settings ==
9
10g:twittperator_plugin_twlist_win = 1
11  $RUNTIMEDIR/plugin/twittperator に入れている場合は設定してください。
12
13g:twittperator_screen_name = "<your screen name>"
14
15g:twlist_max_rows = num
16  表示するアイテム数 (default: 50)
17
18== Command ==
19
20:showtwin
21  ウィンドウの表示/非表示
22  ToDo: 表示位置と幅、高さを維持したい
23
24 */
25let win = null;
26let winXML =
27<window id="twlist-window"
28        pack="start"
29        title="Twittperator"
30        width="500"
31        height="600"
32        onload="init()"
33        onunload="twlist.onClose()"
34        xmlns={XUL}
35        xmlns:xhtml={XHTML}>
36<script type="application/javascript; version=1.8"><![CDATA[
37  const XUL = new Namespace("xul", "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"),
38        XHTML = new Namespace("xhtml", "http://www.w3.org/1999/xhtml");
39  var liberator, twlist, timelineBox, mentionsBox, dmBox, tabBox;
40  function $(id) document.getElementById(id);
41  function init(){
42    liberator = window.arguments[0];
43    twlist    = window.arguments[1];
44    timelineBox = $("twlist-timeline");
45    mentionsBox = $("twlist-mentions");
46    dmBox = $("twlist-dm");
47    tabBox = $("twlist-tabbox");
48
49    liberator.plugins.twittperator.Tweets.slice(0,twlist.maxRows).reverse().forEach(add);
50  }
51  function keepMaxRows(box) {
52    if (box.getRowCount() > twlist.maxRows){
53      box.removeChild(box.lastChild);
54    }
55  }
56  function add (msg) {
57    let xml = twlist.getItemXML(msg);
58    let dom = xmlToDom(xml, XUL);
59    if ("direct_message" in msg){
60      dmBox.insertBefore(dom, dmBox.firstChild);
61      keepMaxRows(dmBox);
62      setNewSymbol(2);
63    } else {
64      timelineBox.insertBefore(dom, timelineBox.firstChild);
65      if (twlist.screenName && msg.in_reply_to_screen_name == twlist.screenName) {
66        let repDom = dom.cloneNode(true);
67        mentionsBox.insertBefore(repDom, mentionsBox.firstChild);
68        keepMaxRows(mentionsBox);
69        setNewSymbol(1);
70      }
71      keepMaxRows(timelineBox);
72    }
73  }
74  function xmlToDom(xml, xmlns) {
75    XML.prettyPrinting = true;
76    XML.ignoreWhitespace = true;
77    var doc = (new DOMParser).parseFromString(
78      '<root xmlns="' + xmlns + '">' + xml.toXMLString() + "</root>",
79      "application/xml");
80    var imported = document.importNode(doc.documentElement, true);
81    var range = document.createRange();
82    range.selectNodeContents(imported);
83    var fragment = range.extractContents();
84    range.detach();
85    return fragment.childNodes.length > 1 ? fragment : fragment.firstChild;
86  }
87  function setNewSymbol(index){
88    let tab = tabBox.tabs.getItemAtIndex(index);
89    if (tab.label.indexOf("*") == -1){
90      tab.label = "*" + tab.label;
91    }
92  }
93  function onTabSelect(evt){
94    let tab = $("twlist-tabbox").tabs.selectedItem;
95    if (tab.label.indexOf("*") == 0){
96      tab.label = tab.label.substr(1);
97    }
98  }
99  function getCurrentListBox(){
100    return tabBox.tabpanels.selectedPanel.firstChild;
101  }
102  function getParent(node, class){
103    let elm = node;
104    while (elm != document.documentElement){
105      if (elm instanceof class)
106        return elm;
107      elm = elm.parentNode;
108    }
109    return null;
110  }
111  var gActions = (function(){
112    function getItemIndex(listbox, arg){
113      let index = 0, currentIndex = listbox.selectedIndex;
114      switch (arg) {
115        case "0":
116          return 0;
117        case "$":
118          return listbox.itemCount - 1;
119        case "+1":
120          if (currentIndex == listbox.itemCount - 1)
121            return null;
122          return currentIndex + 1;
123        case "-1":
124          if (currentIndex == -1)
125            return null;
126          return currentIndex - 1;
127        default:
128          return null;
129      }
130    }
131    function getCopyItem(target){
132      let listbox = getCurrentListBox();
133      let item = listbox.selectedItem;
134      if (!item)
135        return null;
136      switch (target){
137        case "ID":
138          return item.value;
139        case "SCREENNAME":
140          return item.querySelector(".twlist-screenname").textContent;
141        case "TEXT":
142          return item.querySelector(".twlist-text").textContent;
143        case "SELECTION":
144          return window.getSelection().toString();
145        case "URL":
146          let node = document.popupNode;
147          if (node) {
148            let elm = getParent(node, HTMLAnchorElement);
149            if (elm)
150              return elm.getAttribute("href");
151          }
152      }
153      return null;
154    }
155    var self = {
156      copy: function(target){
157        let text = getCopyItem(target);
158        if (text) {
159          liberator.modules.util.copyToClipboard(text, true);
160        }
161      },
162      select: function(arg){
163        let listbox = getCurrentListBox();
164        let index = getItemIndex(listbox, arg);
165        listbox.ensureIndexIsVisible( listbox.selectedIndex = index );
166      },
167      reply: function(){
168        let listbox = getCurrentListBox();
169        let item = listbox.selectedItem;
170        if (!item)
171          return;
172        twlist.onReply(item.querySelector(".twlist-reply"), (listbox == dmBox));
173      },
174      retweet: function(){
175        let listbox = getCurrentListBox();
176        if (listbox == dmBox)
177          return;
178        let item = listbox.selectedItem;
179        if (!item)
180          return;
181        twlist.onRetweet(item.querySelector(".twlist-retweet"));
182      },
183      fav: function(){
184        let listbox = getCurrentListBox();
185        if (listbox == dmBox)
186          return;
187        let item = listbox.selectedItem;
188        if (!item)
189          return;
190        twlist.onFav(item.querySelector(".twlist-fav"));
191      }
192    };
193    return self;
194  })();
195  var gContext = (function(){
196    var popupNode = null, anchor;
197    function showItem (item, show, text){
198      if (show){
199        if (item.hasAttribute("hidden"))
200          item.removeAttribute("hidden");
201        if (text)
202          item.setAttribute("tooltiptext", text);
203      } else {
204        item.setAttribute("hidden", true);
205        if (item.hasAttribute("tooltiptext"))
206          item.removeAttribute("tooltiptext");
207      }
208    }
209    function getSelText(text){
210      if (text.length > 20)
211        return text.substr(20) + "...";
212      return text;
213    }
214    var self = {
215      showing: function gContextShowing(){
216        let listbox = getCurrentListBox();
217        if (!listbox.selectedItem)
218          return false;
219        popupNode = document.popupNode;
220        let openLinkMenu = $("twlist-menuitem-openlink"),
221            openLinkTabMenu = $("twlist-menuitem-openlinktab"),
222            copyURLMenu = $("twlist-menuitem-copy-url"),
223            isAnchor = false,
224            href = null;
225        anchor = getParent(popupNode, HTMLAnchorElement);
226        if (anchor){
227          isAnchor = true;
228          href = anchor.getAttribute("href");
229        }
230        showItem(openLinkMenu, isAnchor, href);
231        showItem(openLinkTabMenu, isAnchor, href);
232        showItem(copyURLMenu, isAnchor, href);
233        let sel = window.getSelection(),
234            selectMenu = $("twlist-menuitem-copy-selection");
235        showItem(selectMenu, (sel.toString() != ""), getSelText(sel.toString()));
236        return true;
237      },
238      hiding: function gContextHiding(){
239        anchor = null;
240      },
241      openLink: function(newTab) {
242        if (popupNode instanceof HTMLAnchorElement) {
243          liberator.open(popupNode.getAttribute("href"),
244                         {where: (newTab ? liberator.NEW_TAB : liberator.CURRENT_TAB) });
245        }
246      }
247    };
248    return self;
249  })();
250]]></script>
251<commandset id="twlist-commandset">
252  <command id="cmd_select_next" oncommand="gActions.select('+1')"/>
253  <command id="cmd_select_prev" oncommand="gActions.select('-1')"/>
254  <command id="cmd_select_first" oncommand="gActions.select('0')"/>
255  <command id="cmd_select_last" oncommand="gActions.select('$')"/>
256  <command id="cmd_reply" oncommand="gActions.reply()"/>
257  <command id="cmd_retweet" oncommand="gActions.retweet()"/>
258  <command id="cmd_fav" oncommand="gActions.fav()"/>
259</commandset>
260<keyset id="twlist-keyset">
261  <key id="key_select_next" command="cmd_select_next" key="J"/>
262  <key id="key_select_prev" command="cmd_select_prev" key="K"/>
263  <key id="key_select_first" command="cmd_select_first" key="G"/>
264  <key id="key_select_last" command="cmd_select_last" key="G" modifiers="shift"/>
265  <key id="key_reply" command="cmd_reply" key="R"/>
266  <key id="key_retweet" command="cmd_retweet" key="R" modifiers="shift"/>
267  <key id="key_fav" command="cmd_fav" key="F"/>
268</keyset>
269<popupset>
270  <popup id="twlist-context"
271         onpopupshowing="if(event.target!=this) return true; return gContext.showing();"
272         onpopuphiding="if(event.target==this){gContext.hiding();}">
273    <menuitem id="twlist-menuitem-openlink" label="Open" oncommand="gContext.openLink()"/>
274    <menuitem id="twlist-menuitem-openlinktab" label="Open in a new tab" oncommand="gContext.openLink(true)"/>
275    <menu id="twlist-menu-copy" label="Copy" accesskey="C">
276      <menupopup>
277        <menuitem id="twlist-menuitem-copy-text" label="Text" accesskey="T" oncommand="gActions.copy('TEXT')"/>
278        <menuitem id="twlist-menuitem-copy-id" label="ID" accesskey="I" oncommand="gActions.copy('ID')"/>
279        <menuitem id="twlist-menuitem-copy-name" label="ScreenName" accesskey="S" oncommand="gActions.copy('SCREENNAME')"/>
280        <menuitem id="twlist-menuitem-copy-selection" label="Copy Selection" oncommand="gActions.copy('SELECTION')"/>
281        <menuitem id="twlist-menuitem-copy-url" label="URL" accesskey="U" oncommand="gActions.copy('URL')"/>
282      </menupopup>
283    </menu>
284    <menuitem id="twlist-menuitem-rt" label="RT" accesskey="T" oncommand="gActions.retweet()"/>
285    <menuitem id="twlist-menuitem-reply" label="Reply" accesskey="R" oncommand="gActions.reply()"/>
286    <menuitem id="twlist-menuitem-fav" label="Fav" accesskey="F" oncommand="gActions.fav()"/>
287  </popup>
288</popupset>
289<vbox id="twlist-box" flex="1">
290  <tabbox id="twlist-tabbox" flex="1">
291    <tabs id="twlist-tabs" onselect="onTabSelect(event)">
292      <tab label="TimeLine"/>
293      <tab label="Mentions"/>
294      <tab label="DM"/>
295    </tabs>
296    <tabpanels id="twlist-panels" flex="1" style="background: transparent;" contextmenu="twlist-context">
297      <tabpanel flex="1">
298        <richlistbox id="twlist-timeline"
299                     flex="1" onselect="twlist.onSelect(event)"/>
300      </tabpanel>
301      <tabpanel flex="1">
302        <richlistbox id="twlist-mentions" flex="1"
303                     onselect="twlist.onSelect(event)"/>
304      </tabpanel>
305      <tabpanel flex="1">
306        <richlistbox id="twlist-dm" flex="1"
307                     onselect="twlist.onSelect(event)"/>
308      </tabpanel>
309    </tabpanels>
310  </tabbox>
311</vbox>
312<statusbar id="status-bar">
313  <spacer flex="1"/>
314</statusbar>
315</window>.toXMLString();
316
317let URL = "data:application/vnd.mozilla.xul+xml;base64," +
318  btoa('<?xml-stylesheet type="text/css" href="chrome://browser/skin/"?>' + winXML);
319
320function setStyleSheet() {
321  styles.addSheet(true, "twlist-styles", "data:*",
322  <><![CDATA[
323    #twlist-panels {
324      background-color: transparent !important;
325      border: none !important;
326      padding: 0 !important;
327    }
328    .twlist-item-content {
329      -moz-user-select: -moz-all;
330      border-bottom: solid thin silver;
331    }
332    .twlist-item-content[selected=true] {
333      background-color: rgb(240,240,240) !important;
334      color: -moz-fieldtext !important;
335    }
336    .twlist-rt-mark {
337      color: white; font-weight: bold; background-color: gray;
338      padding: 2px 5px; margin: 0;
339      -moz-border-radius: 4px;
340    }
341    .twlist-reply, .twlist-retweet, .twlist-fav {
342      color: white; font-weight: bold; background-color: gray;
343      padding: 2px; margin:0;
344      -moz-border-radius: 2px;
345    }
346    .twlist-fav {
347      color: yellow;
348    }
349    .twlist-text { margin: 2px 1em; }
350    .twlist-text>label { margin: 1px 2px 2px 2px !important; }
351    .twlist-screenname { font-weight: bold; }
352    .twlist-link { color: -moz-hyperlinktext; }
353    .twlist-link:hover { chrome://browser/content/browser.xul  cursor: pointer !important; }
354    .twlist-hash { color: DarkGreen !important; }
355    .twlist-image { max-height: 300px; border:thin solid; }
356  ]]></>.toString());
357}
358
359function TweetItem(msg){ this.init.call(this, msg); }
360TweetItem.prototype = {
361  init: function(msg) {
362  },
363};
364function getItemXML(msg) {
365  XML.prettyPrinting = true;
366  XML.ignoreWhitespace = true;
367  let xml;
368  if ("direct_message" in msg) {
369    xml = <richlistitem value={msg.direct_message.id}
370                        searchlabel={msg.direct_message.sender_screen_name}
371                        xmlns={XUL} class="twlist-item-content twlist-item-dm">
372      <vbox class="twlist-profile-image">
373        <image src={msg.direct_message.sender.profile_image_url} width="48" height="48"/>
374        <spacer flex="1"/>
375      </vbox>
376      <vbox flex="1" class="twlist-content">
377        <hbox>
378          <label class="twlist-screenname">{msg.direct_message.sender.screen_name}</label>
379          <hbox class="twlist-matainfo">
380            <label class="twlist-username">{"(" + msg.direct_message.sender.name + ")"}</label>
381            <label>{(new Date(msg.direct_message.sender.created_at)).toLocaleFormat()}</label>
382          </hbox>
383        </hbox>
384        {formatText(msg.direct_message.text.replace(/[\01-\10\14\16-\37]/g,""))}
385      </vbox>
386      <vbox>
387        <spacer flex="1"/>
388        <label value={"\u21A9"} class="twlist-reply" onclick="twlist.onReply(this, true)"/>
389        <spacer flex="1"/>
390      </vbox>
391    </richlistitem>;
392  } else if ("retweeted_status" in msg) {
393    xml =
394    <richlistitem value={msg.retweeted_status.id}
395                  searchlabel={msg.retweeted_status.user.screen_name+"#"+msg.retweeted_status.id}
396                  xmlns={XUL} class="twlist-item-content twlist-item-rt">
397      <vbox class="twlist-profile-image">
398        <image src={msg.retweeted_status.user.profile_image_url} width="48" height="48"/>
399        <spacer flex="1"/>
400      </vbox>
401      <vbox flex="1" class="twlist-content">
402        <hbox>
403          <label value={"\u21BB"} class="twlist-rt-mark"/>
404          <label class="twlist-screenname">{msg.retweeted_status.user.screen_name}</label>
405          <hbox class="twlist-metainfo">
406            <label class="twlist-username">{"(" + msg.retweeted_status.user.name + ")"}</label>
407            <label>{(new Date(msg.created_at)).toLocaleFormat()}</label>
408            <label>{"By " + msg.user.screen_name}</label>
409          </hbox>
410        </hbox>
411        {formatText(msg.retweeted_status.text.replace(/[\01-\10\14\16-\37]/g,""))}
412      </vbox>
413      <vbox>
414        <spacer flex="1"/>
415        <label value={"\u21A9"} class="twlist-reply" onclick="twlist.onReply(this)"/>
416        <label value={msg.favorited ? "\u2605" : "\u2606"} class="twlist-fav" onclick="twlist.onFav(this)"/>
417        <label value={"\u21BB"} class="twlist-retweet" onclick="twlist.onRetweet(this)"/>
418        <spacer flex="1"/>
419      </vbox>
420    </richlistitem>;
421  } else {
422    xml =
423    <richlistitem value={msg.id} searchlabel={msg.user.screen_name+"#"+msg.id}
424                  xmlns={XUL} class="twlist-item-content">
425      <vbox class="twlist-profile-image">
426        <image src={msg.user.profile_image_url} width="48" height="48"/>
427        <spacer flex="1"/>
428      </vbox>
429      <vbox flex="1" class="twlist-content">
430        <hbox>
431          <label class="twlist-screenname">{msg.user.screen_name}</label>
432          <hbox class="twlist-metainfo">
433            <label class="twlist-username">{"(" + msg.user.name + ")"}</label>
434            <label>{(new Date(msg.created_at)).toLocaleFormat()}</label>
435          </hbox>
436        </hbox>
437        {formatText(msg.text.replace(/[\01-\10\14\16-\37]/g,""))}
438      </vbox>
439      <vbox>
440        <spacer flex="1"/>
441        <label value={"\u21A9"} class="twlist-reply" onclick="twlist.onReply(this)"/>
442        <label value={msg.favorited ? "\u2605" : "\u2606"} class="twlist-fav" onclick="twlist.onFav(this)"/>
443        <label value={"\u21BB"} class="twlist-retweet" onclick="twlist.onRetweet(this)"/>
444        <spacer flex="1"/>
445      </vbox>
446    </richlistitem>;
447  }
448  return xml;
449}
450
451function onLoad () {
452  let gv = liberator.globalVariables;
453  __context__.__defineGetter__("screenName", function() gv.twittperator_screen_name || "");
454  __context__.__defineGetter__("maxRows", function() gv.twlist_max_rows || 50);
455
456  setStyleSheet();
457
458  plugins.twittperator.ChirpUserStream.addListener(streamListener);
459
460  commands.addUserCommand(["showtwin"], "popup/hide twittperator window",
461    function(arg){
462      if (!win) {
463        open()
464      } else {
465        win.close();
466      }
467    },{
468      bang: true
469    }, true);
470}
471
472function open(){
473  win = openDialog(URL, null, "chrome", liberator, __context__ );
474}
475function onClose(){
476  win = null;
477}
478
479function onUnload () {
480  if (win)
481    win.close();
482  plugins.twittperator.ChirpUserStream.removeListener(streamListener);
483}
484
485function streamListener(msg, raw) {
486  if (!win)
487    return;
488  if ((msg.text && msg.user) || ("direct_message" in msg)) {
489    win.add(msg);
490  }
491}
492function getMedia (uri) {
493  if (/\.gif$|\.jpe?g$|\.pi?ng$/.test(uri.path))
494    return ["image", uri.spec];
495  switch (uri.host) {
496    case "twitpic.com":
497      return ["image", "http://twitpic.com/show/thumb" + uri.path + ".jpg"];
498    case "movapic.com":
499      return ["image", "http://image.movapic.com/pic/m_" + uri.path.substr(uri.path.lastIndexOf("/")+1) + ".jpeg"];
500    case "gyazo.com":
501      return ["image", uri.spec];
502    case "twittgoo.com":
503      let elm = util.httpGet(uri.spec + "/?format=atom").responseXML.getElementsByTagName("icon")[0];
504      return ["image", elm.textContent];
505    case "www.flickr.com":
506    case "f.hatena.ne.jp":
507    default:
508      return [];
509  }
510}
511function isShortenURL (uri) {
512  switch (uri.host) {
513    case "bit.ly":
514    case "is.gd":
515    case "j.mp":
516    case "goo.gl":
517    case "htn.to":
518    case "tinyurl.com":
519    case "ff.im":
520    case "youtu.be":
521      return true;
522  }
523  return false;
524}
525function getRedirectedURL (aURI, aElement, aCallback){
526  if (!aURI.schemeIs("http") && !aURI.schemeIs("https"))
527    return;
528
529  if (isShortenURL(aURI)){
530    let x = new XMLHttpRequest;
531    x.open("HEAD", aURI.spec, true);
532    x.onreadystatechange = function(){
533      if (x.readyState == 4){
534        aCallback.call(aElement, x.channel.URI);
535      }
536    };
537    x.send(null);
538  } else {
539    aCallback.call(aElement, aURI);
540  }
541}
542function onSelect (evt) {
543  let item = evt.target.selectedItem;
544  if (!item) return;
545  let links = item.querySelectorAll("a.twlist-url");
546
547  function detectMedia (uri) {
548    this.setAttribute("href", uri.spec);
549    this.textContent = uri.spec;
550    let [type, src] = getMedia(uri);
551    if (type && src) {
552      switch (type) {
553        case "image":
554          if (this.hasAttribute("shown") && this.getAttribute("shown") == "true")
555            break;
556          let img = document.createElementNS(XHTML, "img");
557          img.setAttribute("src", src);
558          img.setAttribute("class", "twlist-image");
559          img.setAttribute("align", "right");
560          this.parentNode.appendChild(img);
561          this.setAttribute("shown", "true");
562          break;
563        default:
564      }
565    }
566  }
567  for (let i=0; i < links.length; i++) {
568    let elm = links[i];
569    let uri = util.newURI(elm.getAttribute("href"));
570    getRedirectedURL(uri, elm, detectMedia);
571  }
572}
573
574function formatText (str) {
575  str = str.trim();
576  let reg = /https?:\/\/[^\s]+|[#@]\w+/g;
577  XML.ignoreWhitespace = false;
578  let m, i = 0, buf = "", x = <xhtml:p class="twlist-text" xmlns:xhtml={XHTML}/>;
579  while((m=reg.exec(str))){
580    buf = str.substring(i, m.index);
581    if (buf)
582      x.appendChild(buf);
583    let class = "twlist-link", href = "";
584    switch (m[0].charAt(0)){
585      case "@":
586        class += " twlist-user";
587        href = "http://twitter.com/" + m[0].substr(1);
588        break;
589      case "#":
590        class += " twlist-hash";
591        href = "http://twitter.com/search?q=%23" + m[0].substr(1);
592        break;
593      default:
594        class += " twlist-url";
595        href = m[0];
596    }
597    x.appendChild(<xhtml:a class={class} href={href}
598                         onclick="twlist.onClick(event)" xmlns:xhtml={XHTML}>{m[0]}</xhtml:a>);
599    i=reg.lastIndex;
600  }
601  buf = str.substr(i);
602  if (buf)
603    x.appendChild(buf);
604  return x;
605}
606
607function onClick (evt) {
608  if (evt.button == 2)
609    return;
610  evt.preventDefault();
611  evt.stopPropagation();
612  let where = (evt.ctrlKey || evt.button == 1) ? liberator.NEW_TAB : liberator.CURRENT_TAB;
613  let url = evt.target.getAttribute("href");
614  liberator.open(url, {where: where});
615}
616function onReply (elm, isDirectMessage) {
617  let item = elm.parentNode.parentNode;
618  let label = item.getAttribute("searchlabel");
619  let cmd = "tw " + (isDirectMessage ? "D @" : "@") + label + " ";
620  commandline.open(":", cmd, modes.EX);
621  window.focus();
622}
623function onRetweet(elm){
624  let id = elm.parentNode.parentNode.value;
625  plugins.twittperator.OAuth.post("http://api.twitter.com/1/statues/retweet/" + id + ".json",
626    null, function(text){
627    });
628}
629function onFav (elm) {
630  let id = elm.parentNode.parentNode.value;
631  let fav = elm.value;
632  if (fav == "\u2605") {
633    plugins.twittperator.OAuth.post("http://api.twitter.com/1/favorites/destroy/" + id + ".json",
634      null, function(text){
635        elm.value = "\u2606";
636      });
637  } else {
638    plugins.twittperator.OAuth.post("http://api.twitter.com/1/favorites/create/" + id + ".json",
639      null, function(text){
640        elm.value = "\u2605";
641      });
642  }
643}
644
645onLoad();
646
647
648// vim: sw=2 ts=2 et filetype=javascript:
Note: See TracBrowser for help on using the browser.