Hack 98. Animate Wikipedia History

 < Day Day Up > 

Watch a full-screen timeline of how a Wikipedia page evolved.

The fundamentally fascinating thing about Wikipedia is that is can be edited by anyone. If you see a mistake, you can correct it. If you know something more about a topic, you can add it. If you think an image would be helpful as a reference, you can upload it. And, of course, if you're just a jackass who likes to destroy other people's work, you can deface it.

All of these actions are recorded, and you can roll back the clock to see what a page looked like at a specific revision. This hack takes this revision history one step further by constructing an animated timeline of the life of a Wikipedia entry, from its inception to its current state.

12.5.1. The Code

This user script runs on all Wikipedia history pages. It adds an "Animate changes" button to the history page that acts as the main entry point for the rest of the script. The animation itself is a series of calls to Wikipedia's revision history interface.

We create an XMLHttpRequest object to retrieve the actual revision text and associated metadata (such as the author and revision date). The script also constructs a slider (really, a styled <div> with appropriate styling and event handlers) that tracks the current status of the animation, from the first version of the page to the current revision.

Save the following user script as wikipedia-animate.user.js:

 // ==UserScript== // @name Wikipedia Animate // @namespace http://phiffer.org/greasemonkey/ // @description Animates page modifications between two specific edit points // @include http://*.wikipedia.tld/*action=history* // ==/UserScript== // based on code by Dan Phiffer // and included here with his gracious permission function Animation() { if (!document.getElementById('bodyContent')) { return; } var url = window.location.href; this.base_url = url.substr(0, url.indexOf('&')); this.hostname = url.substr(7, url.indexOf('/', 8) - 7); this.add_buttons(); this.add_options(); this.add_css(); } Animation.prototype.add_buttons = function() { // Create the animate buttons var button1 = document.createElement('input'); button1.className = 'historysubmit'; button1.style.marginLeft = '5px'; button1.setAttribute('type', 'button'); button1.value = 'Animate changes'; button1.addEventListener('click', function() { animate.start(); }, true);  button1.setAttribute('id', 'animate_button1'); var button2 = button1.cloneNode(true);     button2.addEventListener('click', function() { animate.start(); },  true);  button2.setAttribute('id', 'animate_button2'); // Add the buttons to the page var history = document.getElementById('pagehistory'); history.parentNode.insertBefore(button1, history); history.parentNode.appendChild(document.createTextNode(' ')); history.parentNode.appendChild(button2); } Animation.prototype.add_options = function() { // Create the options box var toolbox = document.getElementById('p-tb'); var options = document.createElement('div'); options.className = 'portlet'; options.innerHTML = '<h5>animate options</h5><div ><ul>' + // Range selection '<li>Animate over:' + '<div><input type="radio" name="animate_range"  value="selected" checked="checked"/> Selected</div>' +  '<div><input type="radio" name="animate_range"  value="all"/> All versions</div>' + '<div><input type="checkbox" /> Skip minor edits</div>' + // Diffs '</li><li>Highlight diffs:' + '<div><input type="radio" name="animate_diff"  value="yes" checked="checked"/> Yes</div>' + '<div><input type="radio" name="animate_diff"  value="no"/> No</div>' + // Speed '</li><li>Animate speed:' + '<div>Pause <input type="text"  value="0.5" size="3" style="font-size: 10px" onblur="animate.option(this);"/> sec</div>' + // Info '</li><li>Include info:' + '<div><input type="checkbox"  checked="checked"/> Date/time</div>' + '<div><input type="checkbox"  checked="checked"/ > Author</div>' + '<div><input type="checkbox"  checked="checked"/> Change summary</div>' + '</li></ul></div>'; toolbox.parentNode.appendChild(options); } Animation.prototype.add_css = function( ) { // Add some CSS formatting rules for diffs var head = document.getElementsByTagName('head')[0]; var style = document.createElement('style'); style.type = 'text/css'; style.innerHTML = 'ins.diff { display: inline; background-color: #CFC; font-weight: bold; } ' + 'del.diff { background-color: #FFA; display: inline; } ' + '#animate_main { width: 100%; position: relative; } ' + '#animate_controls { position: absolute; top: 0; left: 0; background: transparent url(data:image/ png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAA8CAYAAACuGnCAAAAABGdBTUEAANbY1E9YM gAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAABnSURBVHjaYvz/// 9NBjTAxIAFjAqOCo4KDl7Bf0D8H4rBgAWIvwExIxIGC36G6oBLgAQ/oakEC75HspAJJvgOm/ a3uAQZkSSY8KrEEES2iAnm+I/ Y3PkJ6ka4ICOwWOOA+RkmCBBgAIPPFd35TefZAAAAAElFTkSuQmCC) repeat-x; width: 100%; } ' + '#animate_controls span.text { background: #FFF; } ' + '#animate_main div.content { position: absolute; top: 60px; display: none; } ' + '#animate_button { float: left; margin-left: 0; margin-top: 5px; width: 50px; } ' + '#animate_scrubber { position: relative; width: 402px; height: 11px; border: 1px solid #AAA; background: #F5F5F5; float: left; margin: 8px; } ' + '#animate_load_progress { position: absolute; top: 1px; left: 1px; background: #E1E1E1; height: 9px; width: 5px; visibility: hidden; } ' + '#animate_playhead { position: absolute; left: 1px; top: 1px; cursor: pointer; background: transparent url(data:image/ gif;base64,R0lGODlhCQAJAIAAAP/// wAAACH5BAEAAAAALAAAAAAJAAkAAAIRhBGnwYrcDJxvwkplPtchVQAAOw==) no-repeat; height: 9px; width: 9px; } ' + '#animate_status { font: 10px verdana, sans-serif; float: left; margin-top: 8px; } ' + '#animate_info { font-size: 10px; margin: 0 0 20px 58px; }'; head.appendChild(style); } Animation.prototype.start = function( ) { // Initialize variables this.urls = new Array( ); this.info = new Array( ); this.pages = new Array( ); this.activity = new Array( ); this.activity_max = 0; this.num_loaded = 0; this.pos = 0; this.interval = -1; this.prev = -1; this.status = 1; /* Status codes: 0: history 1: loading 2: playing 3: paused 4: playhead scrub */ var history = document.getElementById('pagehistory'); var items = history.getElementsByTagName('li'); // Cache the current history view var bodyContent = document.getElementById('bodyContent'); this.history_content = this.mediawiki_content(bodyContent.innerHTML); // Check whether to animate over all article revisions if (document.getElementById('animate_range_all').checked) { // Check to see if the current history page already contains every revision var last_row = items[items.length - 1]; var last_links = last_row.getElementsByTagName('a'); var first_row = items[0]; var first_links = first_row.getElementsByTagName('a'); // The first and last list items each lack a 'last' and 'cur' link, respectively if (last_links[1].firstChild.nodeValue == 'last' || first_links[0].firstChild.nodeValue == 'cur') { this.get_full_history( ); return; } else { first_row.getElementsByTagName('input')[1].checked = true; last_row.getElementsByTagName('input')[0].checked = true; } } this.parse_history( ); this.setup_markup( ); this.start_loading( ); } Animation.prototype.get_full_history = function( ) { // Disable the animate buttons while we load var button1 = document.getElementById('animate_button1'); button1.value = 'Loading…'; button1.setAttribute('disabled', 'disabled'); var button2 = document.getElementById('animate_button2'); button2.value = 'Loading…'; button2.setAttribute('disabled', 'disabled'); // Load in the full history var request = new XMLHttpRequest( ); request.open('GET', this.base_url + '&action=history&limit=5000&offset=0', true); request.onreadystatechange = function( ) { if (request.readyState == 4) { var content = animate.mediawiki_content(request.responseText); document.getElementById('bodyContent').innerHTML = content; var history = document.getElementById('pagehistory'); var items = history.getElementsByTagName('li'); var inputs = items[items.length - 1]. getElementsByTagName('input'); inputs[0].checked = true; animate.parse_history( ); animate.setup_markup( ); animate.start_loading( ); } } request.send(null); } Animation.prototype.parse_history = function( ) { var history = document.getElementById('pagehistory'); var items = history.getElementsByTagName('li'); var skip_minor = document.getElementById('animate_skip_minor').checked; var found_start = false; for (var i = 0; i < items.length; i++) { var radios = items[i].getElementsByTagName('input'); var skip = false; // Skip this revision if it's been labeled 'minor' if (skip_minor) { var spans = items[i].getElementsByTagName('span'); for (var j = 0; j < spans.length; j++) { if (spans[j].className == 'minor') { skip = true; } } } if (radios[1] && radios[1].checked) { var links = items[i].getElementsByTagName('a'); if (links[0].firstChild.nodeValue != 'cur' && !skip) { this.urls.unshift(links[1].getAttribute('href')); this.info.unshift(this.parse_info(items[i], 1)); } else if (!skip) { this.urls.unshift(links[2].getAttribute('href')); this.info.unshift(this.parse_info(items[i], 2)); } found_start = true; } else if (radios[0] && radios[0].checked) { var links = items[i].getElementsByTagName('a'); if (links[1].firstChild.nodeValue != 'last' && !skip) { this.urls.unshift(links[1].getAttribute('href')); this.info.unshift(this.parse_info(items[i], 1)); } else if (!skip) { this.urls.unshift(links[2].getAttribute('href')); this.info.unshift(this.parse_info(items[i], 2)); } break; } else if (found_start && !skip) { var links = items[i].getElementsByTagName('a'); this.urls.unshift(links[2].getAttribute('href')); this.info.unshift(this.parse_info(items[i], 2)); } } } Animation.prototype.setup_markup = function( ) { this.add_nav_link( ); var content = '<div >' + '<div >' + '<input type="button" value="Pause"  /> ' + '<div >' + '<div ></div>' + '<div ></div></div>' + '<div >Loading…</div>' + '<br style="clear: both;"/>' + '<div ></div>' + '</div></div>'; document.getElementById('bodyContent').innerHTML = content; document.getElementById('bodyContent').style.height = '250px'; this.content = 1; var playhead = document.getElementById('animate_playhead'); playhead.addEventListener('mousedown', function(e) { animate.playhead(e); e.preventDefault( ); }, true); var button = document.getElementById('animate_button'); button.addEventListener('click', function( ) { animate.button( ); }, true); var body = document.getElementsByTagName('body').item(0); body.addEventListener('mouseup', function(e) { animate.mouseup(e); }, true); body.addEventListener('mousemove', function(e) { animate.mousemove(e); }, true); var top = 0; var curr = document.getElementById('animate_main'); while (curr.offsetParent) { top += curr.offsetTop; curr = curr.offsetParent; } this.scroll_origin = top; window.setInterval(function( ) { animate.check_scroll( ); }, 50); } Animation.prototype.add_nav_link = function( ) { var history_nav = document.getElementById('ca-history'); history_nav.className = ''; history_nav.getElementsByTagName('a').item(0).addEventListener( 'click', function(event) { animate.status = 0; document.getElementById('bodyContent').innerHTML = animate.history_ content; histrowinit( ); this.parentNode.className = 'selected'; var animate_nav = document.getElementById('animate_nav'); this.parentNode.parentNode.removeChild(animate_nav); document.getElementById('animate_button1').addEventListener( 'click', function( ) { animate.start( ); }, true); document.getElementById('animate_button2').addEventListener( 'click', function( ) { animate.start( ); }, true); event.preventDefault( ); }, true); var animate_nav = document.createElement('li'); var link = animate_nav.appendChild(document.createElement('a')); animate_nav.id = 'animate_nav'; link.appendChild(document.createTextNode('animate')); link.setAttribute('href', '#'); link.addEventListener('click', function(event) { event.preventDefault( ); }, true); animate_nav.className = 'selected'; history_nav.parentNode.appendChild(animate_nav); } Animation.prototype.start_loading = function( ) { var url = 'http://' + this.hostname + this.urls[0]; var request = new XMLHttpRequest( ); request.open('GET', url, true); request.onreadystatechange = function( ) { if (request.readyState == 4) { animate.loaded(request); } } request.send(null); } Animation.prototype.parse_info = function(item, l) { var info = ''; var links = item.getElementsByTagName('a'); if (document.getElementById('animate_info_date').checked) { var href = links.item(l).getAttribute('href'); var text = links.item(l).firstChild.nodeValue; info += '<a href="' + href + '">' + text + '</a> '; } if (document.getElementById('animate_info_author').checked) { var href = links.item(l + 1).getAttribute('href'); var text = links.item(l + 1).firstChild.nodeValue; info += 'by <a href="' + href + '">' + text + '</a> '; } if (document.getElementById('animate_info_summary').checked) { var em = item.getElementsByTagName('em'); if (em.length == 1) { info += '&nbsp;&nbsp;&nbsp;' + em.item(0).innerHTML; } } info = '<span >' + info + '</span>'; return info; } Animation.prototype.loaded = function(details) { var content = this.mediawiki_content(details.responseText); this.pages[this.pages.length] = content; if (this.num_loaded > 0 && document.getElementById('animate_diff_yes'). checked) { content = diffString(this.pages[this.num_loaded - 1], content); } var frame = document.createElement('div'); var main = document.getElementById('animate_main'); var controls = document.getElementById('animate_controls'); main.insertBefore(frame, controls); frame.innerHTML = content; frame.className = 'content'; frame.setAttribute('id', 'frame' + this.num_loaded); var load_progress = document.getElementById('animate_load_progress'); load_progress.style.width = 5 + (395 * this.num_loaded / (this.urls. length - 1)) + 'px'; load_progress.style.visibility = 'visible'; if (this.num_loaded > 0 && document.getElementById('animate_diff_yes'). checked) { var activity = 0; // Check for added content var b_list = frame.getElementsByTagName('b'); for (var i = 0; i < b_list.length; i++) { if (b_list[i].className == 'diff') { activity++; } } // Check for deleted content var s_list = frame.getElementsByTagName('s'); for (var i = 0; i < s_list.length; i++) { if (s_list[i].className == 'diff') { activity++; } } var id = this.activity.length; this.activity[id] = activity; var a = document.createElement('div'); document.getElementById('animate_load_progress').appendChild(a); a.setAttribute('id', 'animate_activity' + id); a.style.position = 'absolute'; a.style.left = (395 * (this.num_loaded - 1) / (this.urls.length - 1)) + 'px'; a.style.width = (395 / (this.urls.length - 1)) + 'px'; if (this.num_loaded == 1) { a.style.width = parseFloat(a.style.width) + 5 + 'px'; } else { a.style.left = parseFloat(a.style.left) + 5 + 'px'; } a.style.height = '9px'; a.style.top = '0px'; if (this.activity_max == 0) { if (activity == 0) { var digit = 225; } else { this.activity_max = activity; var digit = 153; } a.style.background = 'rgb(' + digit + ',' + digit + ',' + digit + ')'; } else { if (activity > this.activity_max) { this.activity_max = activity; this.normalize_activity( ); } else { var digit = parseInt(225 - 72 * activity / this.activity_ max); a.style.background = 'rgb(' + digit + ',' + digit + ',' + digit + ')'; } } } if (this.status == 1) { this.swap_content(this.num_loaded); var playhead = document.getElementById('animate_playhead'); playhead.style.left = 1 + (390 * this.num_loaded / (this.urls.length - 1)) + 'px'; this.set_info(this.num_loaded); this.pos = this.num_loaded; } this.num_loaded++; if (this.num_loaded < this.urls.length && this.status != 0) { var url = 'http://' + this.hostname + this.urls[this.num_loaded]; var request = new XMLHttpRequest( ); request.open('GET', url, true); var _this = this; request.onreadystatechange = function( ) { if (request.readyState == 4) { _this.loaded(request); } } request.send(null); } else if (this.num_loaded == this.urls.length) { this.pause( ); } } Animation.prototype.normalize_activity = function( ) { for (var i = 0; i < this.activity.length; i++) { var a = document.getElementById('animate_activity' + i); var digit = parseInt(225 - 72 * this.activity[i] / this.activity_ max); a.style.background = 'rgb(' + digit + ',' + digit + ',' + digit + ')'; } } Animation.prototype.button = function( ) { if (this.status == 3) { this.play( ); } else { this.pause( ); } } Animation.prototype.play = function( ) { this.status = 2; var button = document.getElementById('animate_button').value = 'Pause'; if (this.pos + 1 == this.urls.length) { var playhead = document.getElementById('animate_playhead'); playhead.style.left = '1px'; this.pos = 0; } this.show_frame(this.pos); var delay = Math.round(parseFloat(document.getElementById('animate_ delay').value) * 1000); this.interval = window.setInterval(function( ) { animate.show_frame( ); }, delay); } Animation.prototype.pause = function( ) { this.status = 3; var button = document.getElementById('animate_button').value = 'Play'; if (this.interval != -1) { clearInterval(this.interval); } } Animation.prototype.show_frame = function(num) { if (this.status == 0 || this.status == 3) { return; } // If not scrubbing if (this.status != 4) { var num = this.pos; var playhead = document.getElementById('animate_playhead'); playhead.style.left = 1 + (390 * num / (this.urls.length - 1)) + 'px'; } this.swap_content(num); this.set_info(num); if (this.status == 2) { if (this.pos + 1 >= this.pages.length) { this.pause( ); } else { this.pos++; } } } Animation.prototype.set_info = function(num) { document.getElementById('animate_info').innerHTML = this.info[num]; var prev = (num > 0) ? '<a href="#" onclick="animate.prev_frame( ); return false;" accesskey="p">&larr;</a> ' : '&larr; '; var frame = (num + 1) + ' / ' + this.urls.length; var next = (num < this.urls.length - 1) ? ' <a href="#" onclick="animate.next_frame( ); return false;" accesskey="nw">&rarr; </a>' : ' &rarr;'; document.getElementById('animate_status').innerHTML = '<span >' + prev + frame + next + '</span>'; } Animation.prototype.playhead = function(e) { this.status = 4; return false; } Animation.prototype.prev_frame = function( ) { this.pos--; this.show_frame(this.pos); this.status = 3; } Animation.prototype.next_frame = function( ) { if (this.pos + 1 < this.num_loaded) { this.pos++; this.show_frame(this.pos); this.status = 3; } } Animation.prototype.mousemove = function(e) { // Make sure the user has clicked on the playhead if (animate.status != 4) { return; } var scrubber = document.getElementById('animate_scrubber'); var left = 0; var curr = scrubber; while (curr.offsetParent) { left += curr.offsetLeft; curr = curr.offsetParent; } var playhead = document.getElementById('animate_playhead'); var x = e.pageX - left - 5; if (x > 391) { x = 391; } else if (x < 1) { x = 1; } var load_progress = document.getElementById('animate_load_progress'); if (x > parseInt(load_progress.style.width - 5)) { x = parseInt(load_progress.style.width - 5); } playhead.style.left = x + 'px'; var snap = Math.floor((x - 1) * (animate.urls.length - 1) / 390); if (snap != animate.pos) { animate.pos = snap; animate.show_frame(snap); } } Animation.prototype.mouseup = function(e) { if (animate.status != 4) { return; } var scrubber = document.getElementById('animate_scrubber'); var left = 0; var curr = scrubber; while (curr.offsetParent) { left += curr.offsetLeft; curr = curr.offsetParent; } var playhead = document.getElementById('animate_playhead'); var x = e.pageX - left - 5; if (x > 391) { x = 391; } else if (x < 1) { x = 1; } var load_progress = document.getElementById('animate_load_progress'); if (x > parseInt(load_progress.style.width)) { x = parseInt(load_progress.style.width); } var snap = Math.floor((x - 1) * (animate.urls.length - 1) / 390); if (snap != animate.pos) { animate.pos = snap; animate.show_frame(snap); } animate.status = 3; } Animation.prototype.option = function(input) { } Animation.prototype.swap_content = function(num) { var frame = document.getElementById('frame' + num); frame.style.display = 'block'; var height = parseInt(frame.offsetHeight) + 60; document.getElementById('bodyContent').style.height = height + 'px'; if (this.prev != -1) { var prev = document.getElementById('frame' + this.prev); prev.style.display = 'none'; } this.prev = num; } Animation.prototype.mediawiki_content = function(text) { text = '' + text; var start = text.indexOf('<!-- start content -->'); var end = text.indexOf('<!-- end content -->'); return text.substr(start, end - start); } Animation.prototype.check_scroll = function( ) { var controls = document.getElementById('animate_controls'); if (self.pageYOffset > this.scroll_origin) { controls.style.top = (self.pageYOffset - this.scroll_origin) + 'px'; } else { controls.style.top = 0; } } var animate = new Animation( ); // JavaScript diff code thanks to John Resig (http://ejohn.org) // http://ejohn.org/files/jsdiff.js function diffString( o, n ) { var out = diff( o.split(/\s+/), n.split(/\s+/) ); var str = ""; for ( var i = 0; i < out.n.length - 1; i++ ) { if ( out.n[i].text == null ) { if ( out.n[i].indexOf('"') == -1 && out.n[i].indexOf('<') == -1 && out.n[i].indexOf('=') == -1 ) { str += "<b style='background:#E6FFE6;' class='diff'> " + out.n[i] +"</b>"; } else { str += " " + out.n[i]; } } else { var pre = ""; if ( out.n[i].text.indexOf('"') == -1 && out.n[i].text. indexOf('<') == -1 && out.n[i].text.indexOf('=') == -1 ) { var n = out.n[i].row + 1; while ( n < out.o.length && out.o[n].text == null ) { if ( out.o[n].indexOf('"') == -1 && out.o[n]. indexOf('<') == -1 && out.o[n].indexOf(':') == -1 && out.o[n].indexOf(';') == -1 && out.o[n].indexOf('=') == -1 ) { pre += " <s style='background:#FFE6E6;' class='diff'>" + out.o[n] +" </s>"; } n++; } } str += " " + out.n[i].text + pre; } } return str; } function diff( o, n ) { var ns = new Array( ); var os = new Array( ); for ( var i = 0; i < n.length; i++ ) { if ( ns[ n[i] ] == null ) { ns[ n[i] ] = { rows: new Array( ), o: null }; } if (ns[n[i]].rows) { ns[ n[i] ].rows.push( i ); } } for ( var i = 0; i < o.length; i++ ) { if ( os[ o[i] ] == null ) { os[ o[i] ] = { rows: new Array( ), n: null }; } if (os[o[i]].rows) { os[ o[i] ].rows.push( i ); } } for ( var i in ns ) { if ( ns[i].rows.length == 1 && typeof(os[i]) != "undefined" && os[i].rows.length == 1 ) { n[ ns[i].rows[0] ] = { text: n[ ns[i].rows[0] ], row: os[i]. rows[0] }; o[ os[i].rows[0] ] = { text: o[ os[i].rows[0] ], row: ns[i]. rows[0] }; } } for ( var i = 0; i < n.length - 1; i++ ) { if ( n[i].text != null && n[i+1].text == null && o[ n[i].row + 1 ]. text == null &&  n[i+1] == o[ n[i].row + 1 ] ) { n[i+1] = { text: n[i+1], row: n[i].row + 1 }; o[n[i].row+1] = { text: o[n[i].row+1], row: i + 1 }; } } for ( var i = n.length - 1; i > 0; i-- ) { if ( n[i].text != null && n[i-1].text == null && o[ n[i].row - 1 ]. text == null &&  n[i-1] == o[ n[i].row - 1 ] ) { n[i-1] = { text: n[i-1], row: n[i].row - 1 }; o[n[i].row-1] = { text: o[n[i].row-1], row: i - 1 }; } } return { o: o, n: n }; } 

12.5.2. Running the Hack

After installing the user script (Tools Install This User Script), go to http://en.wikipedia.org/wiki/Heavy_metal_umlaut and click the History tab. You will see a new "Animate changes button, as shown in Figure 12-6.

Figure 12-6. Wikipedia "Animate changes" button


Select two revisions from the History page, and then click the "Animate changes" button. The script will go to work, fetching the selected revisions one by one. This might take some time, because Wikipedia is not optimized for fetching older revisions of a page (since this is not a frequent operation). As the script loads each revision, it displays the page as it once appeared. A slider at the top of the page shows the current progress, as shown in Figure 12-7.

The script adds an options panel on the left side of the page. You can skip edits that were marked as minor when the author made the revision. You can set the delay between revisions, although I have not found this to be terribly useful, since Wikipedia is so slow to begin with. You can include metadata such as the revision date, the author (if the person who made the change had logged into Wikipedia), and the summary the author entered when he made the revision.

Figure 12-7. Animation progress


You can also show differences between each revision by highlighting added, removed, and modified text with separate colors.

The script caches revisions as it fetches them. Once it has retrieved a few revisions, you can move the slider back or forward to quickly jump between the cached revisions. This is a fascinating effect; sadly, a single screenshot cannot do it justice. Try it for yourself, and watch as people add information, deface the page, revert the vandalism, correct typos, add links, and generally evolve the page into what it has become today.

     < Day Day Up > 


    Greasemonkey Hacks
    Greasemonkey Hacks: Tips & Tools for Remixing the Web with Firefox
    ISBN: 0596101651
    EAN: 2147483647
    Year: 2005
    Pages: 168
    Authors: Mark Pilgrim

    flylib.com © 2008-2017.
    If you may any questions please contact us: flylib@qtcs.net