whiteboard/0000755000175000017500000000000012251036451012173 5ustar ernieerniewhiteboard/cleanup.py0000644000175000017500000000057212251036335014201 0ustar ernieernie# Richard Darst, April 2009 import os import sys if len(sys.argv) > 1: maxSize = int(sys.argv[1]) else: maxSize = 50 for fileName in os.listdir("data/"): fileName = "data/"+fileName size = os.stat(fileName).st_size if size < maxSize: print size print file(fileName).read() if size == 0: os.unlink(fileName) whiteboard/htaccess0000644000175000017500000000124712251036335013720 0ustar ernieernieHeaderName header.html AddDefaultCharset utf-8 AddCharset utf-8 .txt .html .wb .md .textile .rst IndexOptions Charset=utf-8 AddHandler cgi-script cgi .cgi AddHandler mod_python .mpy PythonHandler q # you really want to not log requests to q.py.mpy. For your logfile, set: # CustomLog combined env=!dontlog # (clearly, the env=!dontlog is the part you need to have) SetEnvIf Request_URI q.py.mpy dontlog SetEnvIf Request_URI q.py.cgi dontlog RewriteEngine On RewriteRule ^[a-zA-Z0-9._+=-]+\.(wb|textile|rst|opts|txt2|md) wb.py.cgi RewriteCond %{QUERY_STRING} .+ RewriteRule ^([a-zA-Z0-9._+=-]+)\.txt wb.py.cgi RewriteRule ^([a-zA-Z0-9._+=-]+)\.txt$ data/wb_$1.txt whiteboard/whiteboard.html0000644000175000017500000000173612251036335015221 0ustar ernieernie
whiteboard/header.html0000644000175000017500000000444012251036335014314 0ustar ernieernieWelcome to the whiteboard. To figure out what this does, open the test page in several tabs at once, and try editing in one tab. Then check the other.

New pages:

Usage notes: About This Service:

This service is run by Richard Darst. It should be considered alpha/beta right now, but stable enough and with a responsive person to help fix stuff.

I certainly didn't do all of this myself, I just trivially hooked it together. See http://code.google.com/p/google-mobwrite/ for the true source. Mobwrite is released under the Apache License, and I hereby release all of my code under the same.

For help/questions, see Richard (nick darst) in #debian-nyc on OFTC.


code and other stuff below this line (will be darcs-able sometime in the future.) whiteboard/q.py0000644000175000017500000000514212251036335013010 0ustar ernieernie#!/usr/bin/python """MobWrite - Real-time Synchronization and Collaboration Service Copyright 2008 Google Inc. http://code.google.com/p/google-mobwrite/ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ """This server-side script connects the Ajax client to the Python daemon. This is a minimal man-in-the-middle script. No input checking from either side. Works either as a CGI script or as a mod_python script. """ __author__ = "fraser@google.com (Neil Fraser)" import socket PORT = 30711 def handler(req): if req == None: # CGI call print 'Content-type: text/plain\n' form = cgi.FieldStorage() else: # mod_python call req.content_type = 'text/plain' # Publisher mode provides req.form, regular mode does not. form = getattr(req, "form", util.FieldStorage(req)) outStr = '\n' if form.has_key('q'): # Client sending a sync. Requesting text return. outStr = form['q'].value elif form.has_key('p'): # Client sending a sync. Requesting JS return. outStr = form['p'].value inStr = '' s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: s.connect(("localhost", PORT)) except socket.error, msg: s = None if not s: # Python CGI can't connect to Python daemon. inStr = '\n' else: # Timeout if MobWrite daemon dosen't respond in 10 seconds. s.settimeout(10.0) s.send(outStr) while 1: line = s.recv(1024) if not line: break inStr += line s.close() if form.has_key('p'): # Client sending a sync. Requesting JS return. inStr = inStr.replace("\\", "\\\\").replace("\"", "\\\"") inStr = inStr.replace("\n", "\\n").replace("\r", "\\r") inStr = "mobwrite.callback(\"%s\");" % inStr if req == None: # CGI call #print "-Sent-\n" #print outStr #print "-Received-\n" print inStr else: # mod_python call #req.write("-Sent-\n\n") #req.write(outStr + "\n") #req.write("-Received-\n\n") req.write(inStr + "\n") return apache.OK if __name__ == "__main__": # CGI call import cgi handler(None) else: # mod_python call from mod_python import apache from mod_python import util whiteboard/options.html.template0000644000175000017500000000162712251036335016375 0ustar ernieernie %(id)s - options - whiteboard Page views: Renderings: Character, word, line counts: %(wordcounts)s whiteboard/mobwrite/0000755000175000017500000000000012251036451014023 5ustar ernieerniewhiteboard/mobwrite/daemon/0000755000175000017500000000000012251036356015272 5ustar ernieerniewhiteboard/mobwrite/daemon/mobwrite_daemon.py0000644000175000017500000006020212251036356021017 0ustar ernieernie#!/usr/bin/python """MobWrite - Real-time Synchronization and Collaboration Service Copyright 2006 Google Inc. http://code.google.com/p/google-mobwrite/ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ """This file is the server-side daemon. Runs in the background listening to a port, accepting synchronization sessions from clients. """ __author__ = "fraser@google.com (Neil Fraser)" import datetime import glob import os import socket import SocketServer import sys import time import thread import urllib sys.path.insert(0, "lib") import mobwrite_core del sys.path[0] # Demo usage should limit the maximum number of connected views. # Set to 0 to disable limit. MAX_VIEWS = 10000 # How should data be stored. MEMORY = 0 FILE = 1 BDB = 2 STORAGE_MODE = MEMORY # Relative location of the data directory. DATA_DIR = "./data" # Port to listen on. LOCAL_PORT = 3017 # If the Telnet connection stalls for more than 2 seconds, give up. TIMEOUT_TELNET = 2.0 # Restrict all Telnet connections to come from this location. # Set to "" to allow connections from anywhere. CONNECTION_ORIGIN = "127.0.0.1" # Dictionary of all text objects. texts = {} # Berkeley Databases texts_db = None lasttime_db = None # Lock to prevent simultaneous changes to the texts dictionary. lock_texts = thread.allocate_lock() class TextObj(mobwrite_core.TextObj): # A persistent object which stores a text. # Object properties: # .lock - Access control for writing to the text on this object. # .views - Count of views currently connected to this text. # .lasttime - The last time that this text was modified. # Inerhited properties: # .name - The unique name for this text, e.g 'proposal'. # .text - The text itself. # .changed - Has the text changed since the last time it was saved. def __init__(self, *args, **kwargs): # Setup this object mobwrite_core.TextObj.__init__(self, *args, **kwargs) self.views = 0 self.lasttime = datetime.datetime.now() self.lock = thread.allocate_lock() self.load() # lock_texts must be acquired by the caller to prevent simultaneous # creations of the same text. assert lock_texts.locked(), "Can't create TextObj unless locked." global texts texts[self.name] = self def setText(self, newText): mobwrite_core.TextObj.setText(self, newText) self.lasttime = datetime.datetime.now() def cleanup(self): # General cleanup task. self.lock.acquire() if self.changed: self.save() if self.views > 0: self.lock.release() return terminate = False # Lock must be acquired to prevent simultaneous deletions. if STORAGE_MODE == MEMORY: if self.lasttime < datetime.datetime.now() - mobwrite_core.TIMEOUT_TEXT: mobwrite_core.LOG.info("Expired text: '%s'" % self.name) terminate = True else: # Delete myself from memory if there are no attached views. mobwrite_core.LOG.info("Unloading text: '%s'" % self.name) terminate = True if terminate: # Save to disk/database. self.save() # Terminate in-memory copy. global texts lock_texts.acquire() try: del texts[self.name] except KeyError: mobwrite_core.LOG.error("Text object not in text list: '%s'" % self.name) lock_texts.release() else: if self.changed: self.save() self.lock.release() def load(self): # Load the text object from non-volatile storage. if STORAGE_MODE == FILE: # Load the text (if present) from disk. filename = "%s/%s.txt" % (DATA_DIR, urllib.quote(self.name, "")) if os.path.exists(filename): try: infile = open(filename, "r") self.setText(infile.read().decode("utf-8")) infile.close() self.changed = False mobwrite_core.LOG.info("Loaded file: '%s'" % filename) except: mobwrite_core.LOG.critical("Can't read file: %s" % filename) else: self.setText(None) self.changed = False if STORAGE_MODE == BDB: # Load the text (if present) from database. if texts_db.has_key(self.name): self.setText(texts_db[self.name].decode("utf-8")) mobwrite_core.LOG.info("Loaded from DB: '%s'" % self.name) else: self.setText(None) self.changed = False def save(self): # Save the text object to non-volatile storage. # Lock must be acquired by the caller to prevent simultaneous saves. assert self.lock.locked(), "Can't save unless locked." if STORAGE_MODE == FILE: # Save the text to disk. filename = "%s/%s.txt" % (DATA_DIR, urllib.quote(self.name, '')) if self.text is None: # Nullified text equates to no file. if os.path.exists(filename): try: os.remove(filename) mobwrite_core.LOG.info("Nullified file: '%s'" % filename) except: mobwrite_core.LOG.critical("Can't nullify file: %s" % filename) else: try: outfile = open(filename, "w") outfile.write(self.text.encode("utf-8")) outfile.close() self.changed = False mobwrite_core.LOG.info("Saved file: '%s'" % filename) except: mobwrite_core.LOG.critical("Can't save file: %s" % filename) if STORAGE_MODE == BDB: # Save the text to database. if self.text is None: if lasttime_db.has_key(self.name): del lasttime_db[self.name] if texts_db.has_key(self.name): del texts_db[self.name] mobwrite_core.LOG.info("Nullified from DB: '%s'" % self.name) else: mobwrite_core.LOG.info("Saved to DB: '%s'" % self.name) texts_db[self.name] = self.text.encode("utf-8") lasttime_db[self.name] = str(int(time.time())) self.changed = False def fetch_textobj(name, view): # Retrieve the named text object. Create it if it doesn't exist. # Add the given view into the text object's list of connected views. # Don't let two simultaneous creations happen, or a deletion during a # retrieval. lock_texts.acquire() if texts.has_key(name): textobj = texts[name] mobwrite_core.LOG.debug("Accepted text: '%s'" % name) else: textobj = TextObj(name=name) mobwrite_core.LOG.debug("Creating text: '%s'" % name) textobj.views += 1 lock_texts.release() return textobj # Dictionary of all view objects. views = {} # Lock to prevent simultaneous changes to the views dictionary. lock_views = thread.allocate_lock() class ViewObj(mobwrite_core.ViewObj): # A persistent object which contains one user's view of one text. # Object properties: # .edit_stack - List of unacknowledged edits sent to the client. # .lasttime - The last time that a web connection serviced this object. # .lock - Access control for writing to the text on this object. # .textobj - The shared text object being worked on. # Inerhited properties: # .username - The name for the user, e.g 'fraser' # .filename - The name for the file, e.g 'proposal' # .shadow - The last version of the text sent to client. # .backup_shadow - The previous version of the text sent to client. # .shadow_client_version - The client's version for the shadow (n). # .shadow_server_version - The server's version for the shadow (m). # .backup_shadow_server_version - the server's version for the backup # shadow (m). def __init__(self, *args, **kwargs): # Setup this object mobwrite_core.ViewObj.__init__(self, *args, **kwargs) self.edit_stack = [] self.lasttime = datetime.datetime.now() self.lock = thread.allocate_lock() self.textobj = fetch_textobj(self.filename, self) # lock_views must be acquired by the caller to prevent simultaneous # creations of the same view. assert lock_views.locked(), "Can't create ViewObj unless locked." global views views[(self.username, self.filename)] = self def cleanup(self): # General cleanup task. # Delete myself if I've been idle too long. # Don't delete during a retrieval. lock_views.acquire() if self.lasttime < datetime.datetime.now() - mobwrite_core.TIMEOUT_VIEW: mobwrite_core.LOG.info("Idle out: '%s@%s'" % (self.username, self.filename)) global views try: del views[(self.username, self.filename)] except KeyError: mobwrite_core.LOG.error("View object not in view list: '%s %s'" % (self.username, self.filename)) self.textobj.views -= 1 lock_views.release() def nullify(self): self.lasttime = datetime.datetime.min self.cleanup() def fetch_viewobj(username, filename): # Retrieve the named view object. Create it if it doesn't exist. # Don't let two simultaneous creations happen, or a deletion during a # retrieval. lock_views.acquire() key = (username, filename) if views.has_key(key): viewobj = views[key] viewobj.lasttime = datetime.datetime.now() mobwrite_core.LOG.debug("Accepting view: '%s@%s'" % key) else: if MAX_VIEWS != 0 and len(views) > MAX_VIEWS: viewobj = None mobwrite_core.LOG.critical("Overflow: Can't create new view.") else: viewobj = ViewObj(username=username, filename=filename) mobwrite_core.LOG.debug("Creating view: '%s@%s'" % key) lock_views.release() return viewobj # Dictionary of all buffer objects. buffers = {} # Lock to prevent simultaneous changes to the buffers dictionary. lock_buffers = thread.allocate_lock() class BufferObj: # A persistent object which assembles large commands from fragments. # Object properties: # .name - The name (and size) of the buffer, e.g. 'alpha:12' # .lasttime - The last time that a web connection wrote to this object. # .data - The contents of the buffer. # .lock - Access control for writing to the text on this object. def __init__(self, name, size): # Setup this object self.name = name self.lasttime = datetime.datetime.now() self.lock = thread.allocate_lock() # Initialize the buffer with a set number of slots. # Null characters form dividers between each slot. array = [] for x in xrange(size - 1): array.append("\0") self.data = "".join(array) # lock_views must be acquired by the caller to prevent simultaneous # creations of the same view. assert lock_buffers.locked(), "Can't create BufferObj unless locked." global buffers buffers[name] = self mobwrite_core.LOG.debug("Buffer initialized to %d slots: %s" % (size, name)) def set(self, n, text): # Set the nth slot of this buffer with text. assert self.lock.locked(), "Can't edit BufferObj unless locked." # n is 1-based. n -= 1 array = self.data.split("\0") assert 0 <= n < len(array), "Invalid buffer insertion" array[n] = text self.data = "\0".join(array) mobwrite_core.LOG.debug("Inserted into slot %d of a %d slot buffer: %s" % (n + 1, len(array), self.name)) def get(self): # Fetch the completed text from the buffer. if ("\0" + self.data + "\0").find("\0\0") == -1: text = self.data.replace("\0", "") # Delete this buffer. self.lasttime = datetime.datetime.min self.cleanup() return text # Not complete yet. return None def cleanup(self): # General cleanup task. # Delete myself if I've been idle too long. # Don't delete during a retrieval. lock_buffers.acquire() if self.lasttime < datetime.datetime.now() - mobwrite_core.TIMEOUT_BUFFER: mobwrite_core.LOG.info("Expired buffer: '%s'" % self.name) global buffers del buffers[self.name] lock_buffers.release() class DaemonMobWrite(SocketServer.StreamRequestHandler, mobwrite_core.MobWrite): def feedBuffer(self, name, size, index, datum): """Add one block of text to the buffer and return the whole text if the buffer is complete. Args: name: Unique name of buffer object. size: Total number of slots in the buffer. index: Which slot to insert this text (note that index is 1-based) datum: The text to insert. Returns: String with all the text blocks merged in the correct order. Or if the buffer is not yet complete returns the empty string. """ # Note that 'index' is 1-based. if not 0 < index <= size: mobwrite_core.LOG.error("Invalid buffer: '%s %d %d'" % (name, size, index)) text = "" elif size == 1 and index == 1: # A buffer with one slot? Pointless. text = datum mobwrite_core.LOG.debug("Buffer with only one slot: '%s'" % name) else: # Retrieve the named buffer object. Create it if it doesn't exist. name += "_%d" % size # Don't let two simultaneous creations happen, or a deletion during a # retrieval. lock_buffers.acquire() if buffers.has_key(name): bufferobj = buffers[name] bufferobj.lasttime = datetime.datetime.now() mobwrite_core.LOG.debug("Found buffer: '%s'" % name) else: bufferobj = BufferObj(name, size) mobwrite_core.LOG.debug("Creating buffer: '%s'" % name) bufferobj.lock.acquire() lock_buffers.release() bufferobj.set(index, datum) # Check if Buffer is complete. text = bufferobj.get() bufferobj.lock.release() if text is None: text = "" return urllib.unquote(text) def handle(self): self.connection.settimeout(TIMEOUT_TELNET) if CONNECTION_ORIGIN and self.client_address[0] != CONNECTION_ORIGIN: raise("Connection refused from " + self.client_address[0]) mobwrite_core.LOG.info("Connection accepted from " + self.client_address[0]) data = [] # Read in all the lines. while 1: try: line = self.rfile.readline() except: # Timeout. mobwrite_core.LOG.warning("Timeout on connection") break data.append(line) if not line.rstrip("\r\n"): # Terminate and execute on blank line. self.wfile.write(self.handleRequest("".join(data))) break # Goodbye mobwrite_core.LOG.debug("Disconnecting.") def handleRequest(self, text): actions = self.parseRequest(text) return self.doActions(actions) def doActions(self, actions): output = [] viewobj = None last_username = None last_filename = None for action_index in xrange(len(actions)): # Use an indexed loop in order to peek ahead one step to detect # username/filename boundaries. action = actions[action_index] # Fetch the requested view object. if not viewobj: viewobj = fetch_viewobj(action["username"], action["filename"]) if viewobj is None: # Too many views connected at once. # Send back nothing. Pretend the return packet was lost. return "" delta_ok = True viewobj.lock.acquire() textobj = viewobj.textobj if action["mode"] == "null": # Nullify the text. mobwrite_core.LOG.debug("Nullifying: '%s@%s'" % (viewobj.username, viewobj.filename)) textobj.lock.acquire() textobj.setText(None) textobj.lock.release() viewobj.nullify(); viewobj.lock.release() viewobj = None continue if (action["server_version"] != viewobj.shadow_server_version and action["server_version"] == viewobj.backup_shadow_server_version): # Client did not receive the last response. Roll back the shadow. mobwrite_core.LOG.warning("Rollback from shadow %d to backup shadow %d" % (viewobj.shadow_server_version, viewobj.backup_shadow_server_version)) viewobj.shadow = viewobj.backup_shadow viewobj.shadow_server_version = viewobj.backup_shadow_server_version viewobj.edit_stack = [] # Remove any elements from the edit stack with low version numbers which # have been acked by the client. x = 0 while x < len(viewobj.edit_stack): if viewobj.edit_stack[x][0] <= action["server_version"]: del viewobj.edit_stack[x] else: x += 1 if action["mode"] == "raw": # It's a raw text dump. data = urllib.unquote(action["data"]).decode("utf-8") mobwrite_core.LOG.info("Got %db raw text: '%s@%s'" % (len(data), viewobj.username, viewobj.filename)) delta_ok = True # First, update the client's shadow. viewobj.shadow = data viewobj.shadow_client_version = action["client_version"] viewobj.shadow_server_version = action["server_version"] viewobj.backup_shadow = viewobj.shadow viewobj.backup_shadow_server_version = viewobj.shadow_server_version viewobj.edit_stack = [] if action["force"] or textobj.text is None: # Clobber the server's text. textobj.lock.acquire() if textobj.text != data: textobj.setText(data) mobwrite_core.LOG.debug("Overwrote content: '%s@%s'" % (viewobj.username, viewobj.filename)) textobj.lock.release() elif action["mode"] == "delta": # It's a delta. mobwrite_core.LOG.info("Got '%s' delta: '%s@%s'" % (action["data"], viewobj.username, viewobj.filename)) if action["server_version"] != viewobj.shadow_server_version: # Can't apply a delta on a mismatched shadow version. delta_ok = False mobwrite_core.LOG.warning("Shadow version mismatch: %d != %d" % (action["server_version"], viewobj.shadow_server_version)) elif action["client_version"] > viewobj.shadow_client_version: # Client has a version in the future? delta_ok = False mobwrite_core.LOG.warning("Future delta: %d > %d" % (action["client_version"], viewobj.shadow_client_version)) elif action["client_version"] < viewobj.shadow_client_version: # We've already seen this diff. pass mobwrite_core.LOG.warning("Repeated delta: %d < %d" % (action["client_version"], viewobj.shadow_client_version)) else: # Expand the delta into a diff using the client shadow. try: diffs = mobwrite_core.DMP.diff_fromDelta(viewobj.shadow, action["data"]) except ValueError: diffs = None delta_ok = False mobwrite_core.LOG.warning("Delta failure, expected %d length: '%s@%s'" % (len(viewobj.shadow), viewobj.username, viewobj.filename)) viewobj.shadow_client_version += 1 if diffs != None: # Textobj lock required for read/patch/write cycle. textobj.lock.acquire() self.applyPatches(viewobj, diffs, action) textobj.lock.release() # Generate output if this is the last action or the username/filename # will change in the next iteration. if ((action_index + 1 == len(actions)) or actions[action_index + 1]["username"] != viewobj.username or actions[action_index + 1]["filename"] != viewobj.filename): output.append(self.generateDiffs(viewobj, last_username, last_filename, action["echo_username"], action["force"], delta_ok)) last_username = viewobj.username last_filename = viewobj.filename # Dereference the view object so that a new one can be created. viewobj.lock.release() viewobj = None return "".join(output) def generateDiffs(self, viewobj, last_username, last_filename, echo_username, force, delta_ok): output = [] if (echo_username and last_username != viewobj.username): output.append("u:%s\n" % viewobj.username) if (last_filename != viewobj.filename or last_username != viewobj.username): output.append("F:%d:%s\n" % (viewobj.shadow_client_version, viewobj.filename)) textobj = viewobj.textobj mastertext = textobj.text if delta_ok: if mastertext is None: mastertext = "" # Create the diff between the view's text and the master text. diffs = mobwrite_core.DMP.diff_main(viewobj.shadow, mastertext) mobwrite_core.DMP.diff_cleanupEfficiency(diffs) text = mobwrite_core.DMP.diff_toDelta(diffs) if force: # Client sending 'D' means number, no error. # Client sending 'R' means number, client error. # Both cases involve numbers, so send back an overwrite delta. viewobj.edit_stack.append((viewobj.shadow_server_version, "D:%d:%s\n" % (viewobj.shadow_server_version, text))) else: # Client sending 'd' means text, no error. # Client sending 'r' means text, client error. # Both cases involve text, so send back a merge delta. viewobj.edit_stack.append((viewobj.shadow_server_version, "d:%d:%s\n" % (viewobj.shadow_server_version, text))) viewobj.shadow_server_version += 1 mobwrite_core.LOG.info("Sent '%s' delta: '%s@%s'" % (text, viewobj.username, viewobj.filename)) else: # Error; server could not parse client's delta. # Send a raw dump of the text. viewobj.shadow_client_version += 1 if mastertext is None: mastertext = "" viewobj.edit_stack.append((viewobj.shadow_server_version, "r:%d:\n" % viewobj.shadow_server_version)) mobwrite_core.LOG.info("Sent empty raw text: '%s@%s'" % (viewobj.username, viewobj.filename)) else: # Force overwrite of client. text = mastertext text = text.encode("utf-8") text = urllib.quote(text, "!~*'();/?:@&=+$,# ") viewobj.edit_stack.append((viewobj.shadow_server_version, "R:%d:%s\n" % (viewobj.shadow_server_version, text))) mobwrite_core.LOG.info("Sent %db raw text: '%s@%s'" % (len(text), viewobj.username, viewobj.filename)) viewobj.shadow = mastertext for edit in viewobj.edit_stack: output.append(edit[1]) return "".join(output) def cleanup_thread(): # Every minute cleanup if STORAGE_MODE == BDB: import bsddb while True: mobwrite_core.LOG.info("Running cleanup task.") for v in views.values(): v.cleanup() for v in texts.values(): v.cleanup() for v in buffers.values(): v.cleanup() timeout = datetime.datetime.now() - mobwrite_core.TIMEOUT_TEXT if STORAGE_MODE == FILE: # Delete old files. files = glob.glob("%s/*.txt" % DATA_DIR) for filename in files: if datetime.datetime.fromtimestamp(os.path.getmtime(filename)) < timeout: os.unlink(filename) mobwrite_core.LOG.info("Deleted file: '%s'" % filename) if STORAGE_MODE == BDB: # Delete old DB records. # Can't delete an entry in a hash while iterating or else order is lost. expired = [] for k, v in lasttime_db.iteritems(): if datetime.datetime.fromtimestamp(int(v)) < timeout: expired.append(k) for k in expired: if texts_db.has_key(k): del texts_db[k] if lasttime_db.has_key(k): del lasttime_db[k] mobwrite_core.LOG.info("Deleted from DB: '%s'" % k) time.sleep(60) def main(): if STORAGE_MODE == BDB: import bsddb global texts_db, lasttime_db texts_db = bsddb.hashopen(DATA_DIR + "/texts.db") lasttime_db = bsddb.hashopen(DATA_DIR + "/lasttime.db") # Start up a thread that does timeouts and cleanup thread.start_new_thread(cleanup_thread, ()) mobwrite_core.LOG.info("Listening on port %d..." % LOCAL_PORT) s = SocketServer.ThreadingTCPServer(("", LOCAL_PORT), DaemonMobWrite) try: s.serve_forever() except KeyboardInterrupt: mobwrite_core.LOG.info("Shutting down.") s.socket.close() if STORAGE_MODE == BDB: texts_db.close() lasttime_db.close() if __name__ == "__main__": mobwrite_core.logging.basicConfig() main() mobwrite_core.logging.shutdown() whiteboard/mobwrite/daemon/q.py0000644000175000017500000000514212251036356016106 0ustar ernieernie#!/usr/bin/python """MobWrite - Real-time Synchronization and Collaboration Service Copyright 2008 Google Inc. http://code.google.com/p/google-mobwrite/ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ """This server-side script connects the Ajax client to the Python daemon. This is a minimal man-in-the-middle script. No input checking from either side. Works either as a CGI script or as a mod_python script. """ __author__ = "fraser@google.com (Neil Fraser)" import socket PORT = 3017 def handler(req): if req == None: # CGI call print 'Content-type: text/plain\n' form = cgi.FieldStorage() else: # mod_python call req.content_type = 'text/plain' # Publisher mode provides req.form, regular mode does not. form = getattr(req, "form", util.FieldStorage(req)) outStr = '\n' if form.has_key('q'): # Client sending a sync. Requesting text return. outStr = form['q'].value elif form.has_key('p'): # Client sending a sync. Requesting JS return. outStr = form['p'].value inStr = '' s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: s.connect(("localhost", PORT)) except socket.error, msg: s = None if not s: # Python CGI can't connect to Python daemon. inStr = '\n' else: # Timeout if MobWrite daemon dosen't respond in 10 seconds. s.settimeout(10.0) s.send(outStr) while 1: line = s.recv(1024) if not line: break inStr += line s.close() if form.has_key('p'): # Client sending a sync. Requesting JS return. inStr = inStr.replace("\\", "\\\\").replace("\"", "\\\"") inStr = inStr.replace("\n", "\\n").replace("\r", "\\r") inStr = "mobwrite.callback(\"%s\");" % inStr if req == None: # CGI call #print "-Sent-\n" #print outStr #print "-Received-\n" print inStr else: # mod_python call #req.write("-Sent-\n\n") #req.write(outStr + "\n") #req.write("-Received-\n\n") req.write(inStr + "\n") return apache.OK if __name__ == "__main__": # CGI call import cgi handler(None) else: # mod_python call from mod_python import apache from mod_python import util whiteboard/mobwrite/daemon/lib/0000755000175000017500000000000012251036356016040 5ustar ernieerniewhiteboard/mobwrite/daemon/lib/diff_match_patch.py0000644000175000017500000017544412251036356021674 0ustar ernieernie#!/usr/bin/python """Diff Match and Patch Copyright 2006 Google Inc. http://code.google.com/p/google-diff-match-patch/ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ """Functions for diff, match and patch. Computes the difference between two texts to create a patch. Applies the patch onto another text, allowing for errors. """ __author__ = 'fraser@google.com (Neil Fraser)' import math import time import urllib import re class diff_match_patch: """Class containing the diff, match and patch methods. Also contains the behaviour settings. """ def __init__(self): """Inits a diff_match_patch object with default settings. Redefine these in your program to override the defaults. """ # Number of seconds to map a diff before giving up (0 for infinity). self.Diff_Timeout = 1.0 # Cost of an empty edit operation in terms of edit characters. self.Diff_EditCost = 4 # The size beyond which the double-ended diff activates. # Double-ending is twice as fast, but less accurate. self.Diff_DualThreshold = 32 # At what point is no match declared (0.0 = perfection, 1.0 = very loose). self.Match_Threshold = 0.5 # How far to search for a match (0 = exact location, 1000+ = broad match). # A match this many characters away from the expected location will add # 1.0 to the score (0.0 is a perfect match). self.Match_Distance = 1000 # When deleting a large block of text (over ~64 characters), how close does # the contents have to match the expected contents. (0.0 = perfection, # 1.0 = very loose). Note that Match_Threshold controls how closely the # end points of a delete need to match. self.Patch_DeleteThreshold = 0.5 # Chunk size for context length. self.Patch_Margin = 4 # How many bits in a number? # Python has no maximum, thus to disable patch splitting set to 0. # However to avoid long patches in certain pathological cases, use 32. # Multiple short patches (using native ints) are much faster than long ones. self.Match_MaxBits = 32 # DIFF FUNCTIONS # The data structure representing a diff is an array of tuples: # [(DIFF_DELETE, "Hello"), (DIFF_INSERT, "Goodbye"), (DIFF_EQUAL, " world.")] # which means: delete "Hello", add "Goodbye" and keep " world." DIFF_DELETE = -1 DIFF_INSERT = 1 DIFF_EQUAL = 0 def diff_main(self, text1, text2, checklines=True): """Find the differences between two texts. Simplifies the problem by stripping any common prefix or suffix off the texts before diffing. Args: text1: Old string to be diffed. text2: New string to be diffed. checklines: Optional speedup flag. If present and false, then don't run a line-level diff first to identify the changed areas. Defaults to true, which does a faster, slightly less optimal diff. Returns: Array of changes. """ # Check for equality (speedup) if text1 == text2: return [(self.DIFF_EQUAL, text1)] # Trim off common prefix (speedup) commonlength = self.diff_commonPrefix(text1, text2) commonprefix = text1[:commonlength] text1 = text1[commonlength:] text2 = text2[commonlength:] # Trim off common suffix (speedup) commonlength = self.diff_commonSuffix(text1, text2) if commonlength == 0: commonsuffix = '' else: commonsuffix = text1[-commonlength:] text1 = text1[:-commonlength] text2 = text2[:-commonlength] # Compute the diff on the middle block diffs = self.diff_compute(text1, text2, checklines) # Restore the prefix and suffix if commonprefix: diffs[:0] = [(self.DIFF_EQUAL, commonprefix)] if commonsuffix: diffs.append((self.DIFF_EQUAL, commonsuffix)) self.diff_cleanupMerge(diffs) return diffs def diff_compute(self, text1, text2, checklines): """Find the differences between two texts. Assumes that the texts do not have any common prefix or suffix. Args: text1: Old string to be diffed. text2: New string to be diffed. checklines: Speedup flag. If false, then don't run a line-level diff first to identify the changed areas. If true, then run a faster, slightly less optimal diff. Returns: Array of changes. """ if not text1: # Just add some text (speedup) return [(self.DIFF_INSERT, text2)] if not text2: # Just delete some text (speedup) return [(self.DIFF_DELETE, text1)] if len(text1) > len(text2): (longtext, shorttext) = (text1, text2) else: (shorttext, longtext) = (text1, text2) i = longtext.find(shorttext) if i != -1: # Shorter text is inside the longer text (speedup) diffs = [(self.DIFF_INSERT, longtext[:i]), (self.DIFF_EQUAL, shorttext), (self.DIFF_INSERT, longtext[i + len(shorttext):])] # Swap insertions for deletions if diff is reversed. if len(text1) > len(text2): diffs[0] = (self.DIFF_DELETE, diffs[0][1]) diffs[2] = (self.DIFF_DELETE, diffs[2][1]) return diffs longtext = shorttext = None # Garbage collect # Check to see if the problem can be split in two. hm = self.diff_halfMatch(text1, text2) if hm: # A half-match was found, sort out the return data. (text1_a, text1_b, text2_a, text2_b, mid_common) = hm # Send both pairs off for separate processing. diffs_a = self.diff_main(text1_a, text2_a, checklines) diffs_b = self.diff_main(text1_b, text2_b, checklines) # Merge the results. return diffs_a + [(self.DIFF_EQUAL, mid_common)] + diffs_b # Perform a real diff. if checklines and (len(text1) < 100 or len(text2) < 100): checklines = False # Too trivial for the overhead. if checklines: # Scan the text on a line-by-line basis first. (text1, text2, linearray) = self.diff_linesToChars(text1, text2) diffs = self.diff_map(text1, text2) if not diffs: # No acceptable result. diffs = [(self.DIFF_DELETE, text1), (self.DIFF_INSERT, text2)] if checklines: # Convert the diff back to original text. self.diff_charsToLines(diffs, linearray) # Eliminate freak matches (e.g. blank lines) self.diff_cleanupSemantic(diffs) # Rediff any replacement blocks, this time character-by-character. # Add a dummy entry at the end. diffs.append((self.DIFF_EQUAL, '')) pointer = 0 count_delete = 0 count_insert = 0 text_delete = '' text_insert = '' while pointer < len(diffs): if diffs[pointer][0] == self.DIFF_INSERT: count_insert += 1 text_insert += diffs[pointer][1] elif diffs[pointer][0] == self.DIFF_DELETE: count_delete += 1 text_delete += diffs[pointer][1] elif diffs[pointer][0] == self.DIFF_EQUAL: # Upon reaching an equality, check for prior redundancies. if count_delete >= 1 and count_insert >= 1: # Delete the offending records and add the merged ones. a = self.diff_main(text_delete, text_insert, False) diffs[pointer - count_delete - count_insert : pointer] = a pointer = pointer - count_delete - count_insert + len(a) count_insert = 0 count_delete = 0 text_delete = '' text_insert = '' pointer += 1 diffs.pop() # Remove the dummy entry at the end. return diffs def diff_linesToChars(self, text1, text2): """Split two texts into an array of strings. Reduce the texts to a string of hashes where each Unicode character represents one line. Args: text1: First string. text2: Second string. Returns: Three element tuple, containing the encoded text1, the encoded text2 and the array of unique strings. The zeroth element of the array of unique strings is intentionally blank. """ lineArray = [] # e.g. lineArray[4] == "Hello\n" lineHash = {} # e.g. lineHash["Hello\n"] == 4 # "\x00" is a valid character, but various debuggers don't like it. # So we'll insert a junk entry to avoid generating a null character. lineArray.append('') def diff_linesToCharsMunge(text): """Split a text into an array of strings. Reduce the texts to a string of hashes where each Unicode character represents one line. Modifies linearray and linehash through being a closure. Args: text: String to encode. Returns: Encoded string. """ chars = [] # Walk the text, pulling out a substring for each line. # text.split('\n') would would temporarily double our memory footprint. # Modifying text would create many large strings to garbage collect. lineStart = 0 lineEnd = -1 while lineEnd < len(text) - 1: lineEnd = text.find('\n', lineStart) if lineEnd == -1: lineEnd = len(text) - 1 line = text[lineStart:lineEnd + 1] lineStart = lineEnd + 1 if line in lineHash: chars.append(unichr(lineHash[line])) else: lineArray.append(line) lineHash[line] = len(lineArray) - 1 chars.append(unichr(len(lineArray) - 1)) return "".join(chars) chars1 = diff_linesToCharsMunge(text1) chars2 = diff_linesToCharsMunge(text2) return (chars1, chars2, lineArray) def diff_charsToLines(self, diffs, lineArray): """Rehydrate the text in a diff from a string of line hashes to real lines of text. Args: diffs: Array of diff tuples. lineArray: Array of unique strings. """ for x in xrange(len(diffs)): text = [] for char in diffs[x][1]: text.append(lineArray[ord(char)]) diffs[x] = (diffs[x][0], "".join(text)) def diff_map(self, text1, text2): """Explore the intersection points between the two texts. Args: text1: Old string to be diffed. text2: New string to be diffed. Returns: Array of diff tuples or None if no diff available. """ # Unlike in most languages, Python counts time in seconds. s_end = time.time() + self.Diff_Timeout # Don't run for too long. # Cache the text lengths to prevent multiple calls. text1_length = len(text1) text2_length = len(text2) max_d = text1_length + text2_length - 1 doubleEnd = self.Diff_DualThreshold * 2 < max_d v_map1 = [] v_map2 = [] v1 = {} v2 = {} v1[1] = 0 v2[1] = 0 footsteps = {} done = False # If the total number of characters is odd, then the front path will # collide with the reverse path. front = (text1_length + text2_length) % 2 for d in xrange(max_d): # Bail out if timeout reached. if self.Diff_Timeout > 0 and time.time() > s_end: return None # Walk the front path one step. v_map1.append({}) for k in xrange(-d, d + 1, 2): if k == -d or k != d and v1[k - 1] < v1[k + 1]: x = v1[k + 1] else: x = v1[k - 1] + 1 y = x - k if doubleEnd: footstep = (x, y) if front and footstep in footsteps: done = True if not front: footsteps[footstep] = d while (not done and x < text1_length and y < text2_length and text1[x] == text2[y]): x += 1 y += 1 if doubleEnd: footstep = (x, y) if front and footstep in footsteps: done = True if not front: footsteps[footstep] = d v1[k] = x v_map1[d][(x, y)] = True if x == text1_length and y == text2_length: # Reached the end in single-path mode. return self.diff_path1(v_map1, text1, text2) elif done: # Front path ran over reverse path. v_map2 = v_map2[:footsteps[footstep] + 1] a = self.diff_path1(v_map1, text1[:x], text2[:y]) b = self.diff_path2(v_map2, text1[x:], text2[y:]) return a + b if doubleEnd: # Walk the reverse path one step. v_map2.append({}) for k in xrange(-d, d + 1, 2): if k == -d or k != d and v2[k - 1] < v2[k + 1]: x = v2[k + 1] else: x = v2[k - 1] + 1 y = x - k footstep = (text1_length - x, text2_length - y) if not front and footstep in footsteps: done = True if front: footsteps[footstep] = d while (not done and x < text1_length and y < text2_length and text1[-x - 1] == text2[-y - 1]): x += 1 y += 1 footstep = (text1_length - x, text2_length - y) if not front and footstep in footsteps: done = True if front: footsteps[footstep] = d v2[k] = x v_map2[d][(x, y)] = True if done: # Reverse path ran over front path. v_map1 = v_map1[:footsteps[footstep] + 1] a = self.diff_path1(v_map1, text1[:text1_length - x], text2[:text2_length - y]) b = self.diff_path2(v_map2, text1[text1_length - x:], text2[text2_length - y:]) return a + b # Number of diffs equals number of characters, no commonality at all. return None def diff_path1(self, v_map, text1, text2): """Work from the middle back to the start to determine the path. Args: v_map: Array of paths. text1: Old string fragment to be diffed. text2: New string fragment to be diffed. Returns: Array of diff tuples. """ path = [] x = len(text1) y = len(text2) last_op = None for d in xrange(len(v_map) - 2, -1, -1): while True: if (x - 1, y) in v_map[d]: x -= 1 if last_op == self.DIFF_DELETE: path[0] = (self.DIFF_DELETE, text1[x] + path[0][1]) else: path[:0] = [(self.DIFF_DELETE, text1[x])] last_op = self.DIFF_DELETE break elif (x, y - 1) in v_map[d]: y -= 1 if last_op == self.DIFF_INSERT: path[0] = (self.DIFF_INSERT, text2[y] + path[0][1]) else: path[:0] = [(self.DIFF_INSERT, text2[y])] last_op = self.DIFF_INSERT break else: x -= 1 y -= 1 assert text1[x] == text2[y], ("No diagonal. " + "Can't happen. (diff_path1)") if last_op == self.DIFF_EQUAL: path[0] = (self.DIFF_EQUAL, text1[x] + path[0][1]) else: path[:0] = [(self.DIFF_EQUAL, text1[x])] last_op = self.DIFF_EQUAL return path def diff_path2(self, v_map, text1, text2): """Work from the middle back to the end to determine the path. Args: v_map: Array of paths. text1: Old string fragment to be diffed. text2: New string fragment to be diffed. Returns: Array of diff tuples. """ path = [] x = len(text1) y = len(text2) last_op = None for d in xrange(len(v_map) - 2, -1, -1): while True: if (x - 1, y) in v_map[d]: x -= 1 if last_op == self.DIFF_DELETE: path[-1] = (self.DIFF_DELETE, path[-1][1] + text1[-x - 1]) else: path.append((self.DIFF_DELETE, text1[-x - 1])) last_op = self.DIFF_DELETE break elif (x, y - 1) in v_map[d]: y -= 1 if last_op == self.DIFF_INSERT: path[-1] = (self.DIFF_INSERT, path[-1][1] + text2[-y - 1]) else: path.append((self.DIFF_INSERT, text2[-y - 1])) last_op = self.DIFF_INSERT break else: x -= 1 y -= 1 assert text1[-x - 1] == text2[-y - 1], ("No diagonal. " + "Can't happen. (diff_path2)") if last_op == self.DIFF_EQUAL: path[-1] = (self.DIFF_EQUAL, path[-1][1] + text1[-x - 1]) else: path.append((self.DIFF_EQUAL, text1[-x - 1])) last_op = self.DIFF_EQUAL return path def diff_commonPrefix(self, text1, text2): """Determine the common prefix of two strings. Args: text1: First string. text2: Second string. Returns: The number of characters common to the start of each string. """ # Quick check for common null cases. if not text1 or not text2 or text1[0] != text2[0]: return 0 # Binary search. # Performance analysis: http://neil.fraser.name/news/2007/10/09/ pointermin = 0 pointermax = min(len(text1), len(text2)) pointermid = pointermax pointerstart = 0 while pointermin < pointermid: if text1[pointerstart:pointermid] == text2[pointerstart:pointermid]: pointermin = pointermid pointerstart = pointermin else: pointermax = pointermid pointermid = int((pointermax - pointermin) / 2 + pointermin) return pointermid def diff_commonSuffix(self, text1, text2): """Determine the common suffix of two strings. Args: text1: First string. text2: Second string. Returns: The number of characters common to the end of each string. """ # Quick check for common null cases. if not text1 or not text2 or text1[-1] != text2[-1]: return 0 # Binary search. # Performance analysis: http://neil.fraser.name/news/2007/10/09/ pointermin = 0 pointermax = min(len(text1), len(text2)) pointermid = pointermax pointerend = 0 while pointermin < pointermid: if (text1[-pointermid:len(text1) - pointerend] == text2[-pointermid:len(text2) - pointerend]): pointermin = pointermid pointerend = pointermin else: pointermax = pointermid pointermid = int((pointermax - pointermin) / 2 + pointermin) return pointermid def diff_halfMatch(self, text1, text2): """Do the two texts share a substring which is at least half the length of the longer text? Args: text1: First string. text2: Second string. Returns: Five element Array, containing the prefix of text1, the suffix of text1, the prefix of text2, the suffix of text2 and the common middle. Or None if there was no match. """ if len(text1) > len(text2): (longtext, shorttext) = (text1, text2) else: (shorttext, longtext) = (text1, text2) if len(longtext) < 10 or len(shorttext) < 1: return None # Pointless. def diff_halfMatchI(longtext, shorttext, i): """Does a substring of shorttext exist within longtext such that the substring is at least half the length of longtext? Closure, but does not reference any external variables. Args: longtext: Longer string. shorttext: Shorter string. i: Start index of quarter length substring within longtext. Returns: Five element Array, containing the prefix of longtext, the suffix of longtext, the prefix of shorttext, the suffix of shorttext and the common middle. Or None if there was no match. """ seed = longtext[i:i + len(longtext) / 4] best_common = '' j = shorttext.find(seed) while j != -1: prefixLength = self.diff_commonPrefix(longtext[i:], shorttext[j:]) suffixLength = self.diff_commonSuffix(longtext[:i], shorttext[:j]) if len(best_common) < suffixLength + prefixLength: best_common = (shorttext[j - suffixLength:j] + shorttext[j:j + prefixLength]) best_longtext_a = longtext[:i - suffixLength] best_longtext_b = longtext[i + prefixLength:] best_shorttext_a = shorttext[:j - suffixLength] best_shorttext_b = shorttext[j + prefixLength:] j = shorttext.find(seed, j + 1) if len(best_common) >= len(longtext) / 2: return (best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common) else: return None # First check if the second quarter is the seed for a half-match. hm1 = diff_halfMatchI(longtext, shorttext, (len(longtext) + 3) / 4) # Check again based on the third quarter. hm2 = diff_halfMatchI(longtext, shorttext, (len(longtext) + 1) / 2) if not hm1 and not hm2: return None elif not hm2: hm = hm1 elif not hm1: hm = hm2 else: # Both matched. Select the longest. if len(hm1[4]) > len(hm2[4]): hm = hm1 else: hm = hm2 # A half-match was found, sort out the return data. if len(text1) > len(text2): (text1_a, text1_b, text2_a, text2_b, mid_common) = hm else: (text2_a, text2_b, text1_a, text1_b, mid_common) = hm return (text1_a, text1_b, text2_a, text2_b, mid_common) def diff_cleanupSemantic(self, diffs): """Reduce the number of edits by eliminating semantically trivial equalities. Args: diffs: Array of diff tuples. """ changes = False equalities = [] # Stack of indices where equalities are found. lastequality = None # Always equal to equalities[-1][1] pointer = 0 # Index of current position. length_changes1 = 0 # Number of chars that changed prior to the equality. length_changes2 = 0 # Number of chars that changed after the equality. while pointer < len(diffs): if diffs[pointer][0] == self.DIFF_EQUAL: # equality found equalities.append(pointer) length_changes1 = length_changes2 length_changes2 = 0 lastequality = diffs[pointer][1] else: # an insertion or deletion length_changes2 += len(diffs[pointer][1]) if (lastequality != None and (len(lastequality) <= length_changes1) and (len(lastequality) <= length_changes2)): # Duplicate record diffs.insert(equalities[-1], (self.DIFF_DELETE, lastequality)) # Change second copy to insert. diffs[equalities[-1] + 1] = (self.DIFF_INSERT, diffs[equalities[-1] + 1][1]) # Throw away the equality we just deleted. equalities.pop() # Throw away the previous equality (it needs to be reevaluated). if len(equalities) != 0: equalities.pop() if len(equalities): pointer = equalities[-1] else: pointer = -1 length_changes1 = 0 # Reset the counters. length_changes2 = 0 lastequality = None changes = True pointer += 1 if changes: self.diff_cleanupMerge(diffs) self.diff_cleanupSemanticLossless(diffs) def diff_cleanupSemanticLossless(self, diffs): """Look for single edits surrounded on both sides by equalities which can be shifted sideways to align the edit to a word boundary. e.g: The cat came. -> The cat came. Args: diffs: Array of diff tuples. """ def diff_cleanupSemanticScore(one, two): """Given two strings, compute a score representing whether the internal boundary falls on logical boundaries. Scores range from 5 (best) to 0 (worst). Closure, but does not reference any external variables. Args: one: First string. two: Second string. Returns: The score. """ if not one or not two: # Edges are the best. return 5 # Each port of this function behaves slightly differently due to # subtle differences in each language's definition of things like # 'whitespace'. Since this function's purpose is largely cosmetic, # the choice has been made to use each language's native features # rather than force total conformity. score = 0 # One point for non-alphanumeric. if not one[-1].isalnum() or not two[0].isalnum(): score += 1 # Two points for whitespace. if one[-1].isspace() or two[0].isspace(): score += 1 # Three points for line breaks. if (one[-1] == "\r" or one[-1] == "\n" or two[0] == "\r" or two[0] == "\n"): score += 1 # Four points for blank lines. if (re.search("\\n\\r?\\n$", one) or re.match("^\\r?\\n\\r?\\n", two)): score += 1 return score pointer = 1 # Intentionally ignore the first and last element (don't need checking). while pointer < len(diffs) - 1: if (diffs[pointer - 1][0] == self.DIFF_EQUAL and diffs[pointer + 1][0] == self.DIFF_EQUAL): # This is a single edit surrounded by equalities. equality1 = diffs[pointer - 1][1] edit = diffs[pointer][1] equality2 = diffs[pointer + 1][1] # First, shift the edit as far left as possible. commonOffset = self.diff_commonSuffix(equality1, edit) if commonOffset: commonString = edit[-commonOffset:] equality1 = equality1[:-commonOffset] edit = commonString + edit[:-commonOffset] equality2 = commonString + equality2 # Second, step character by character right, looking for the best fit. bestEquality1 = equality1 bestEdit = edit bestEquality2 = equality2 bestScore = (diff_cleanupSemanticScore(equality1, edit) + diff_cleanupSemanticScore(edit, equality2)) while edit and equality2 and edit[0] == equality2[0]: equality1 += edit[0] edit = edit[1:] + equality2[0] equality2 = equality2[1:] score = (diff_cleanupSemanticScore(equality1, edit) + diff_cleanupSemanticScore(edit, equality2)) # The >= encourages trailing rather than leading whitespace on edits. if score >= bestScore: bestScore = score bestEquality1 = equality1 bestEdit = edit bestEquality2 = equality2 if diffs[pointer - 1][1] != bestEquality1: # We have an improvement, save it back to the diff. if bestEquality1: diffs[pointer - 1] = (diffs[pointer - 1][0], bestEquality1) else: del diffs[pointer - 1] pointer -= 1 diffs[pointer] = (diffs[pointer][0], bestEdit) if bestEquality2: diffs[pointer + 1] = (diffs[pointer + 1][0], bestEquality2) else: del diffs[pointer + 1] pointer -= 1 pointer += 1 def diff_cleanupEfficiency(self, diffs): """Reduce the number of edits by eliminating operationally trivial equalities. Args: diffs: Array of diff tuples. """ changes = False equalities = [] # Stack of indices where equalities are found. lastequality = '' # Always equal to equalities[-1][1] pointer = 0 # Index of current position. pre_ins = False # Is there an insertion operation before the last equality. pre_del = False # Is there a deletion operation before the last equality. post_ins = False # Is there an insertion operation after the last equality. post_del = False # Is there a deletion operation after the last equality. while pointer < len(diffs): if diffs[pointer][0] == self.DIFF_EQUAL: # equality found if (len(diffs[pointer][1]) < self.Diff_EditCost and (post_ins or post_del)): # Candidate found. equalities.append(pointer) pre_ins = post_ins pre_del = post_del lastequality = diffs[pointer][1] else: # Not a candidate, and can never become one. equalities = [] lastequality = '' post_ins = post_del = False else: # an insertion or deletion if diffs[pointer][0] == self.DIFF_DELETE: post_del = True else: post_ins = True # Five types to be split: # ABXYCD # AXCD # ABXC # AXCD # ABXC if lastequality and ((pre_ins and pre_del and post_ins and post_del) or ((len(lastequality) < self.Diff_EditCost / 2) and (pre_ins + pre_del + post_ins + post_del) == 3)): # Duplicate record diffs.insert(equalities[-1], (self.DIFF_DELETE, lastequality)) # Change second copy to insert. diffs[equalities[-1] + 1] = (self.DIFF_INSERT, diffs[equalities[-1] + 1][1]) equalities.pop() # Throw away the equality we just deleted lastequality = '' if pre_ins and pre_del: # No changes made which could affect previous entry, keep going. post_ins = post_del = True equalities = [] else: if len(equalities): equalities.pop() # Throw away the previous equality if len(equalities): pointer = equalities[-1] else: pointer = -1 post_ins = post_del = False changes = True pointer += 1 if changes: self.diff_cleanupMerge(diffs) def diff_cleanupMerge(self, diffs): """Reorder and merge like edit sections. Merge equalities. Any edit section can move as long as it doesn't cross an equality. Args: diffs: Array of diff tuples. """ diffs.append((self.DIFF_EQUAL, '')) # Add a dummy entry at the end. pointer = 0 count_delete = 0 count_insert = 0 text_delete = '' text_insert = '' while pointer < len(diffs): if diffs[pointer][0] == self.DIFF_INSERT: count_insert += 1 text_insert += diffs[pointer][1] pointer += 1 elif diffs[pointer][0] == self.DIFF_DELETE: count_delete += 1 text_delete += diffs[pointer][1] pointer += 1 elif diffs[pointer][0] == self.DIFF_EQUAL: # Upon reaching an equality, check for prior redundancies. if count_delete != 0 or count_insert != 0: if count_delete != 0 and count_insert != 0: # Factor out any common prefixies. commonlength = self.diff_commonPrefix(text_insert, text_delete) if commonlength != 0: x = pointer - count_delete - count_insert - 1 if x >= 0 and diffs[x][0] == self.DIFF_EQUAL: diffs[x] = (diffs[x][0], diffs[x][1] + text_insert[:commonlength]) else: diffs.insert(0, (self.DIFF_EQUAL, text_insert[:commonlength])) pointer += 1 text_insert = text_insert[commonlength:] text_delete = text_delete[commonlength:] # Factor out any common suffixies. commonlength = self.diff_commonSuffix(text_insert, text_delete) if commonlength != 0: diffs[pointer] = (diffs[pointer][0], text_insert[-commonlength:] + diffs[pointer][1]) text_insert = text_insert[:-commonlength] text_delete = text_delete[:-commonlength] # Delete the offending records and add the merged ones. if count_delete == 0: diffs[pointer - count_insert : pointer] = [ (self.DIFF_INSERT, text_insert)] elif count_insert == 0: diffs[pointer - count_delete : pointer] = [ (self.DIFF_DELETE, text_delete)] else: diffs[pointer - count_delete - count_insert : pointer] = [ (self.DIFF_DELETE, text_delete), (self.DIFF_INSERT, text_insert)] pointer = pointer - count_delete - count_insert + 1 if count_delete != 0: pointer += 1 if count_insert != 0: pointer += 1 elif pointer != 0 and diffs[pointer - 1][0] == self.DIFF_EQUAL: # Merge this equality with the previous one. diffs[pointer - 1] = (diffs[pointer - 1][0], diffs[pointer - 1][1] + diffs[pointer][1]) del diffs[pointer] else: pointer += 1 count_insert = 0 count_delete = 0 text_delete = '' text_insert = '' if diffs[-1][1] == '': diffs.pop() # Remove the dummy entry at the end. # Second pass: look for single edits surrounded on both sides by equalities # which can be shifted sideways to eliminate an equality. # e.g: ABAC -> ABAC changes = False pointer = 1 # Intentionally ignore the first and last element (don't need checking). while pointer < len(diffs) - 1: if (diffs[pointer - 1][0] == self.DIFF_EQUAL and diffs[pointer + 1][0] == self.DIFF_EQUAL): # This is a single edit surrounded by equalities. if diffs[pointer][1].endswith(diffs[pointer - 1][1]): # Shift the edit over the previous equality. diffs[pointer] = (diffs[pointer][0], diffs[pointer - 1][1] + diffs[pointer][1][:-len(diffs[pointer - 1][1])]) diffs[pointer + 1] = (diffs[pointer + 1][0], diffs[pointer - 1][1] + diffs[pointer + 1][1]) del diffs[pointer - 1] changes = True elif diffs[pointer][1].startswith(diffs[pointer + 1][1]): # Shift the edit over the next equality. diffs[pointer - 1] = (diffs[pointer - 1][0], diffs[pointer - 1][1] + diffs[pointer + 1][1]) diffs[pointer] = (diffs[pointer][0], diffs[pointer][1][len(diffs[pointer + 1][1]):] + diffs[pointer + 1][1]) del diffs[pointer + 1] changes = True pointer += 1 # If shifts were made, the diff needs reordering and another shift sweep. if changes: self.diff_cleanupMerge(diffs) def diff_xIndex(self, diffs, loc): """loc is a location in text1, compute and return the equivalent location in text2. e.g. "The cat" vs "The big cat", 1->1, 5->8 Args: diffs: Array of diff tuples. loc: Location within text1. Returns: Location within text2. """ chars1 = 0 chars2 = 0 last_chars1 = 0 last_chars2 = 0 for x in xrange(len(diffs)): (op, text) = diffs[x] if op != self.DIFF_INSERT: # Equality or deletion. chars1 += len(text) if op != self.DIFF_DELETE: # Equality or insertion. chars2 += len(text) if chars1 > loc: # Overshot the location. break last_chars1 = chars1 last_chars2 = chars2 if len(diffs) != x and diffs[x][0] == self.DIFF_DELETE: # The location was deleted. return last_chars2 # Add the remaining len(character). return last_chars2 + (loc - last_chars1) def diff_prettyHtml(self, diffs): """Convert a diff array into a pretty HTML report. Args: diffs: Array of diff tuples. Returns: HTML representation. """ html = [] i = 0 for (op, data) in diffs: text = (data.replace("&", "&").replace("<", "<") .replace(">", ">").replace("\n", "¶
")) if op == self.DIFF_INSERT: html.append("%s" % (i, text)) elif op == self.DIFF_DELETE: html.append("%s" % (i, text)) elif op == self.DIFF_EQUAL: html.append("%s" % (i, text)) if op != self.DIFF_DELETE: i += len(data) return "".join(html) def diff_text1(self, diffs): """Compute and return the source text (all equalities and deletions). Args: diffs: Array of diff tuples. Returns: Source text. """ text = [] for (op, data) in diffs: if op != self.DIFF_INSERT: text.append(data) return "".join(text) def diff_text2(self, diffs): """Compute and return the destination text (all equalities and insertions). Args: diffs: Array of diff tuples. Returns: Destination text. """ text = [] for (op, data) in diffs: if op != self.DIFF_DELETE: text.append(data) return "".join(text) def diff_levenshtein(self, diffs): """Compute the Levenshtein distance; the number of inserted, deleted or substituted characters. Args: diffs: Array of diff tuples. Returns: Number of changes. """ levenshtein = 0 insertions = 0 deletions = 0 for (op, data) in diffs: if op == self.DIFF_INSERT: insertions += len(data) elif op == self.DIFF_DELETE: deletions += len(data) elif op == self.DIFF_EQUAL: # A deletion and an insertion is one substitution. levenshtein += max(insertions, deletions) insertions = 0 deletions = 0 levenshtein += max(insertions, deletions) return levenshtein def diff_toDelta(self, diffs): """Crush the diff into an encoded string which describes the operations required to transform text1 into text2. E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. Operations are tab-separated. Inserted text is escaped using %xx notation. Args: diffs: Array of diff tuples. Returns: Delta text. """ text = [] for (op, data) in diffs: if op == self.DIFF_INSERT: # High ascii will raise UnicodeDecodeError. Use Unicode instead. data = data.encode("utf-8") text.append("+" + urllib.quote(data, "!~*'();/?:@&=+$,# ")) elif op == self.DIFF_DELETE: text.append("-%d" % len(data)) elif op == self.DIFF_EQUAL: text.append("=%d" % len(data)) return "\t".join(text) def diff_fromDelta(self, text1, delta): """Given the original text1, and an encoded string which describes the operations required to transform text1 into text2, compute the full diff. Args: text1: Source string for the diff. delta: Delta text. Returns: Array of diff tuples. Raises: ValueError: If invalid input. """ if type(delta) == unicode: # Deltas should be composed of a subset of ascii chars, Unicode not # required. If this encode raises UnicodeEncodeError, delta is invalid. delta = delta.encode("ascii") diffs = [] pointer = 0 # Cursor in text1 tokens = delta.split("\t") for token in tokens: if token == "": # Blank tokens are ok (from a trailing \t). continue # Each token begins with a one character parameter which specifies the # operation of this token (delete, insert, equality). param = token[1:] if token[0] == "+": param = urllib.unquote(param).decode("utf-8") diffs.append((self.DIFF_INSERT, param)) elif token[0] == "-" or token[0] == "=": try: n = int(param) except ValueError: raise ValueError, "Invalid number in diff_fromDelta: " + param if n < 0: raise ValueError, "Negative number in diff_fromDelta: " + param text = text1[pointer : pointer + n] pointer += n if token[0] == "=": diffs.append((self.DIFF_EQUAL, text)) else: diffs.append((self.DIFF_DELETE, text)) else: # Anything else is an error. raise ValueError, ("Invalid diff operation in diff_fromDelta: " + token[0]) if pointer != len(text1): raise ValueError, ( "Delta length (%d) does not equal source text length (%d)." % (pointer, len(text1))) return diffs # MATCH FUNCTIONS def match_main(self, text, pattern, loc): """Locate the best instance of 'pattern' in 'text' near 'loc'. Args: text: The text to search. pattern: The pattern to search for. loc: The location to search around. Returns: Best match index or -1. """ loc = max(0, min(loc, len(text))) if text == pattern: # Shortcut (potentially not guaranteed by the algorithm) return 0 elif not text: # Nothing to match. return -1 elif text[loc:loc + len(pattern)] == pattern: # Perfect match at the perfect spot! (Includes case of null pattern) return loc else: # Do a fuzzy compare. match = self.match_bitap(text, pattern, loc) return match def match_bitap(self, text, pattern, loc): """Locate the best instance of 'pattern' in 'text' near 'loc' using the Bitap algorithm. Args: text: The text to search. pattern: The pattern to search for. loc: The location to search around. Returns: Best match index or -1. """ # Python doesn't have a maxint limit, so ignore this check. #if self.Match_MaxBits != 0 and len(pattern) > self.Match_MaxBits: # raise ValueError("Pattern too long for this application.") # Initialise the alphabet. s = self.match_alphabet(pattern) def match_bitapScore(e, x): """Compute and return the score for a match with e errors and x location. Accesses loc and pattern through being a closure. Args: e: Number of errors in match. x: Location of match. Returns: Overall score for match (0.0 = good, 1.0 = bad). """ accuracy = float(e) / len(pattern) proximity = abs(loc - x) if not self.Match_Distance: # Dodge divide by zero error. return proximity and 1.0 or accuracy return accuracy + (proximity / float(self.Match_Distance)) # Highest score beyond which we give up. score_threshold = self.Match_Threshold # Is there a nearby exact match? (speedup) best_loc = text.find(pattern, loc) if best_loc != -1: score_threshold = min(match_bitapScore(0, best_loc), score_threshold) # What about in the other direction? (speedup) best_loc = text.rfind(pattern, loc + len(pattern)) if best_loc != -1: score_threshold = min(match_bitapScore(0, best_loc), score_threshold) # Initialise the bit arrays. matchmask = 1 << (len(pattern) - 1) best_loc = -1 bin_max = len(pattern) + len(text) # Empty initialization added to appease pychecker. last_rd = None for d in xrange(len(pattern)): # Scan for the best match each iteration allows for one more error. # Run a binary search to determine how far from 'loc' we can stray at # this error level. bin_min = 0 bin_mid = bin_max while bin_min < bin_mid: if match_bitapScore(d, loc + bin_mid) <= score_threshold: bin_min = bin_mid else: bin_max = bin_mid bin_mid = (bin_max - bin_min) / 2 + bin_min # Use the result from this iteration as the maximum for the next. bin_max = bin_mid start = max(1, loc - bin_mid + 1) finish = min(loc + bin_mid, len(text)) + len(pattern) rd = range(finish + 1) rd.append((1 << d) - 1) for j in xrange(finish, start - 1, -1): if len(text) <= j - 1: # Out of range. charMatch = 0 else: charMatch = s.get(text[j - 1], 0) if d == 0: # First pass: exact match. rd[j] = ((rd[j + 1] << 1) | 1) & charMatch else: # Subsequent passes: fuzzy match. rd[j] = ((rd[j + 1] << 1) | 1) & charMatch | ( ((last_rd[j + 1] | last_rd[j]) << 1) | 1) | last_rd[j + 1] if rd[j] & matchmask: score = match_bitapScore(d, j - 1) # This match will almost certainly be better than any existing match. # But check anyway. if score <= score_threshold: # Told you so. score_threshold = score best_loc = j - 1 if best_loc > loc: # When passing loc, don't exceed our current distance from loc. start = max(1, 2 * loc - best_loc) else: # Already passed loc, downhill from here on in. break # No hope for a (better) match at greater error levels. if match_bitapScore(d + 1, loc) > score_threshold: break last_rd = rd return best_loc def match_alphabet(self, pattern): """Initialise the alphabet for the Bitap algorithm. Args: pattern: The text to encode. Returns: Hash of character locations. """ s = {} for char in pattern: s[char] = 0 for i in xrange(len(pattern)): s[pattern[i]] |= 1 << (len(pattern) - i - 1) return s # PATCH FUNCTIONS def patch_addContext(self, patch, text): """Increase the context until it is unique, but don't let the pattern expand beyond Match_MaxBits. Args: patch: The patch to grow. text: Source text. """ pattern = text[patch.start2 : patch.start2 + patch.length1] padding = 0 while (text.find(pattern) != text.rfind(pattern) and (self.Match_MaxBits == 0 or len(pattern) < self.Match_MaxBits - self.Patch_Margin - self.Patch_Margin)): padding += self.Patch_Margin pattern = text[max(0, patch.start2 - padding) : patch.start2 + patch.length1 + padding] # Add one chunk for good luck. padding += self.Patch_Margin # Add the prefix. prefix = text[max(0, patch.start2 - padding) : patch.start2] if prefix: patch.diffs[:0] = [(self.DIFF_EQUAL, prefix)] # Add the suffix. suffix = text[patch.start2 + patch.length1 : patch.start2 + patch.length1 + padding] if suffix: patch.diffs.append((self.DIFF_EQUAL, suffix)) # Roll back the start points. patch.start1 -= len(prefix) patch.start2 -= len(prefix) # Extend lengths. patch.length1 += len(prefix) + len(suffix) patch.length2 += len(prefix) + len(suffix) def patch_make(self, a, b=None, c=None): """Compute a list of patches to turn text1 into text2. Use diffs if provided, otherwise compute it ourselves. There are four ways to call this function, depending on what data is available to the caller: Method 1: a = text1, b = text2 Method 2: a = diffs Method 3 (optimal): a = text1, b = diffs Method 4 (deprecated, use method 3): a = text1, b = text2, c = diffs Args: a: text1 (methods 1,3,4) or Array of diff tuples for text1 to text2 (method 2). b: text2 (methods 1,4) or Array of diff tuples for text1 to text2 (method 3) or undefined (method 2). c: Array of diff tuples for text1 to text2 (method 4) or undefined (methods 1,2,3). Returns: Array of patch objects. """ text1 = None diffs = None # Note that texts may arrive as 'str' or 'unicode'. if isinstance(a, basestring) and isinstance(b, basestring) and c is None: # Method 1: text1, text2 # Compute diffs from text1 and text2. text1 = a diffs = self.diff_main(text1, b, True) if len(diffs) > 2: self.diff_cleanupSemantic(diffs) self.diff_cleanupEfficiency(diffs) elif isinstance(a, list) and b is None and c is None: # Method 2: diffs # Compute text1 from diffs. diffs = a text1 = self.diff_text1(diffs) elif isinstance(a, basestring) and isinstance(b, list) and c is None: # Method 3: text1, diffs text1 = a diffs = b elif (isinstance(a, basestring) and isinstance(b, basestring) and isinstance(c, list)): # Method 4: text1, text2, diffs # text2 is not used. text1 = a diffs = c else: raise ValueError("Unknown call format to patch_make.") if not diffs: return [] # Get rid of the None case. patches = [] patch = patch_obj() char_count1 = 0 # Number of characters into the text1 string. char_count2 = 0 # Number of characters into the text2 string. prepatch_text = text1 # Recreate the patches to determine context info. postpatch_text = text1 for x in xrange(len(diffs)): (diff_type, diff_text) = diffs[x] if len(patch.diffs) == 0 and diff_type != self.DIFF_EQUAL: # A new patch starts here. patch.start1 = char_count1 patch.start2 = char_count2 if diff_type == self.DIFF_INSERT: # Insertion patch.diffs.append(diffs[x]) patch.length2 += len(diff_text) postpatch_text = (postpatch_text[:char_count2] + diff_text + postpatch_text[char_count2:]) elif diff_type == self.DIFF_DELETE: # Deletion. patch.length1 += len(diff_text) patch.diffs.append(diffs[x]) postpatch_text = (postpatch_text[:char_count2] + postpatch_text[char_count2 + len(diff_text):]) elif (diff_type == self.DIFF_EQUAL and len(diff_text) <= 2 * self.Patch_Margin and len(patch.diffs) != 0 and len(diffs) != x + 1): # Small equality inside a patch. patch.diffs.append(diffs[x]) patch.length1 += len(diff_text) patch.length2 += len(diff_text) if (diff_type == self.DIFF_EQUAL and len(diff_text) >= 2 * self.Patch_Margin): # Time for a new patch. if len(patch.diffs) != 0: self.patch_addContext(patch, prepatch_text) patches.append(patch) patch = patch_obj() # Unlike Unidiff, our patch lists have a rolling context. # http://code.google.com/p/google-diff-match-patch/wiki/Unidiff # Update prepatch text & pos to reflect the application of the # just completed patch. prepatch_text = postpatch_text char_count1 = char_count2 # Update the current character count. if diff_type != self.DIFF_INSERT: char_count1 += len(diff_text) if diff_type != self.DIFF_DELETE: char_count2 += len(diff_text) # Pick up the leftover patch if not empty. if len(patch.diffs) != 0: self.patch_addContext(patch, prepatch_text) patches.append(patch) return patches def patch_deepCopy(self, patches): """Given an array of patches, return another array that is identical. Args: patches: Array of patch objects. Returns: Array of patch objects. """ patchesCopy = [] for patch in patches: patchCopy = patch_obj() # No need to deep copy the tuples since they are immutable. patchCopy.diffs = patch.diffs[:] patchCopy.start1 = patch.start1 patchCopy.start2 = patch.start2 patchCopy.length1 = patch.length1 patchCopy.length2 = patch.length2 patchesCopy.append(patchCopy) return patchesCopy def patch_apply(self, patches, text): """Merge a set of patches onto the text. Return a patched text, as well as a list of true/false values indicating which patches were applied. Args: patches: Array of patch objects. text: Old text. Returns: Two element Array, containing the new text and an array of boolean values. """ if not patches: return (text, []) # Deep copy the patches so that no changes are made to originals. patches = self.patch_deepCopy(patches) nullPadding = self.patch_addPadding(patches) text = nullPadding + text + nullPadding self.patch_splitMax(patches) # delta keeps track of the offset between the expected and actual location # of the previous patch. If there are patches expected at positions 10 and # 20, but the first patch was found at 12, delta is 2 and the second patch # has an effective expected position of 22. delta = 0 results = [] for patch in patches: expected_loc = patch.start2 + delta text1 = self.diff_text1(patch.diffs) end_loc = -1 if len(text1) > self.Match_MaxBits: # patch_splitMax will only provide an oversized pattern in the case of # a monster delete. start_loc = self.match_main(text, text1[:self.Match_MaxBits], expected_loc) if start_loc != -1: end_loc = self.match_main(text, text1[-self.Match_MaxBits:], expected_loc + len(text1) - self.Match_MaxBits) if end_loc == -1 or start_loc >= end_loc: # Can't find valid trailing context. Drop this patch. start_loc = -1 else: start_loc = self.match_main(text, text1, expected_loc) if start_loc == -1: # No match found. :( results.append(False) else: # Found a match. :) results.append(True) delta = start_loc - expected_loc if end_loc == -1: text2 = text[start_loc : start_loc + len(text1)] else: text2 = text[start_loc : end_loc + self.Match_MaxBits] if text1 == text2: # Perfect match, just shove the replacement text in. text = (text[:start_loc] + self.diff_text2(patch.diffs) + text[start_loc + len(text1):]) else: # Imperfect match. # Run a diff to get a framework of equivalent indices. diffs = self.diff_main(text1, text2, False) if (len(text1) > self.Match_MaxBits and self.diff_levenshtein(diffs) / float(len(text1)) > self.Patch_DeleteThreshold): # The end points match, but the content is unacceptably bad. results[-1] = False else: self.diff_cleanupSemanticLossless(diffs) index1 = 0 for (op, data) in patch.diffs: if op != self.DIFF_EQUAL: index2 = self.diff_xIndex(diffs, index1) if op == self.DIFF_INSERT: # Insertion text = text[:start_loc + index2] + data + text[start_loc + index2:] elif op == self.DIFF_DELETE: # Deletion text = text[:start_loc + index2] + text[start_loc + self.diff_xIndex(diffs, index1 + len(data)):] if op != self.DIFF_DELETE: index1 += len(data) # Strip the padding off. text = text[len(nullPadding):-len(nullPadding)] return (text, results) def patch_addPadding(self, patches): """Add some padding on text start and end so that edges can match something. Intended to be called only from within patch_apply. Args: patches: Array of patch objects. Returns: The padding string added to each side. """ nullPadding = "" for x in xrange(1, self.Patch_Margin + 1): nullPadding += chr(x) # Bump all the patches forward. for patch in patches: patch.start1 += len(nullPadding) patch.start2 += len(nullPadding) # Add some padding on start of first diff. patch = patches[0] diffs = patch.diffs if not diffs or diffs[0][0] != self.DIFF_EQUAL: # Add nullPadding equality. diffs.insert(0, (self.DIFF_EQUAL, nullPadding)) patch.start1 -= len(nullPadding) # Should be 0. patch.start2 -= len(nullPadding) # Should be 0. patch.length1 += len(nullPadding) patch.length2 += len(nullPadding) elif len(nullPadding) > len(diffs[0][1]): # Grow first equality. extraLength = len(nullPadding) - len(diffs[0][1]) newText = nullPadding[len(diffs[0][1]):] + diffs[0][1] diffs[0] = (diffs[0][0], newText) patch.start1 -= extraLength patch.start2 -= extraLength patch.length1 += extraLength patch.length2 += extraLength # Add some padding on end of last diff. patch = patches[-1] diffs = patch.diffs if not diffs or diffs[-1][0] != self.DIFF_EQUAL: # Add nullPadding equality. diffs.append((self.DIFF_EQUAL, nullPadding)) patch.length1 += len(nullPadding) patch.length2 += len(nullPadding) elif len(nullPadding) > len(diffs[-1][1]): # Grow last equality. extraLength = len(nullPadding) - len(diffs[-1][1]) newText = diffs[-1][1] + nullPadding[:extraLength] diffs[-1] = (diffs[-1][0], newText) patch.length1 += extraLength patch.length2 += extraLength return nullPadding def patch_splitMax(self, patches): """Look through the patches and break up any which are longer than the maximum limit of the match algorithm. Args: patches: Array of patch objects. """ if self.Match_MaxBits == 0: return for x in xrange(len(patches)): if patches[x].length1 > self.Match_MaxBits: bigpatch = patches[x] # Remove the big old patch. del patches[x] x -= 1 patch_size = self.Match_MaxBits start1 = bigpatch.start1 start2 = bigpatch.start2 precontext = '' while len(bigpatch.diffs) != 0: # Create one of several smaller patches. patch = patch_obj() empty = True patch.start1 = start1 - len(precontext) patch.start2 = start2 - len(precontext) if precontext: patch.length1 = patch.length2 = len(precontext) patch.diffs.append((self.DIFF_EQUAL, precontext)) while (len(bigpatch.diffs) != 0 and patch.length1 < patch_size - self.Patch_Margin): (diff_type, diff_text) = bigpatch.diffs[0] if diff_type == self.DIFF_INSERT: # Insertions are harmless. patch.length2 += len(diff_text) start2 += len(diff_text) patch.diffs.append(bigpatch.diffs.pop(0)) empty = False elif (diff_type == self.DIFF_DELETE and len(patch.diffs) == 1 and patch.diffs[0][0] == self.DIFF_EQUAL and len(diff_text) > 2 * patch_size): # This is a large deletion. Let it pass in one chunk. patch.length1 += len(diff_text) start1 += len(diff_text) empty = False patch.diffs.append((diff_type, diff_text)) del bigpatch.diffs[0] else: # Deletion or equality. Only take as much as we can stomach. diff_text = diff_text[:patch_size - patch.length1 - self.Patch_Margin] patch.length1 += len(diff_text) start1 += len(diff_text) if diff_type == self.DIFF_EQUAL: patch.length2 += len(diff_text) start2 += len(diff_text) else: empty = False patch.diffs.append((diff_type, diff_text)) if diff_text == bigpatch.diffs[0][1]: del bigpatch.diffs[0] else: bigpatch.diffs[0] = (bigpatch.diffs[0][0], bigpatch.diffs[0][1][len(diff_text):]) # Compute the head context for the next patch. precontext = self.diff_text2(patch.diffs) precontext = precontext[-self.Patch_Margin:] # Append the end context for this patch. postcontext = self.diff_text1(bigpatch.diffs)[:self.Patch_Margin] if postcontext: patch.length1 += len(postcontext) patch.length2 += len(postcontext) if len(patch.diffs) != 0 and patch.diffs[-1][0] == self.DIFF_EQUAL: patch.diffs[-1] = (self.DIFF_EQUAL, patch.diffs[-1][1] + postcontext) else: patch.diffs.append((self.DIFF_EQUAL, postcontext)) if not empty: x += 1 patches.insert(x, patch) def patch_toText(self, patches): """Take a list of patches and return a textual representation. Args: patches: Array of patch objects. Returns: Text representation of patches. """ text = [] for patch in patches: text.append(str(patch)) return "".join(text) def patch_fromText(self, textline): """Parse a textual representation of patches and return a list of patch objects. Args: textline: Text representation of patches. Returns: Array of patch objects. Raises: ValueError: If invalid input. """ if type(textline) == unicode: # Patches should be composed of a subset of ascii chars, Unicode not # required. If this encode raises UnicodeEncodeError, patch is invalid. textline = textline.encode("ascii") patches = [] if not textline: return patches text = textline.split('\n') while len(text) != 0: m = re.match("^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@$", text[0]) if not m: raise ValueError, "Invalid patch string: " + text[0] patch = patch_obj() patches.append(patch) patch.start1 = int(m.group(1)) if m.group(2) == '': patch.start1 -= 1 patch.length1 = 1 elif m.group(2) == '0': patch.length1 = 0 else: patch.start1 -= 1 patch.length1 = int(m.group(2)) patch.start2 = int(m.group(3)) if m.group(4) == '': patch.start2 -= 1 patch.length2 = 1 elif m.group(4) == '0': patch.length2 = 0 else: patch.start2 -= 1 patch.length2 = int(m.group(4)) del text[0] while len(text) != 0: if text[0]: sign = text[0][0] else: sign = '' line = urllib.unquote(text[0][1:]) line = line.decode("utf-8") if sign == '+': # Insertion. patch.diffs.append((self.DIFF_INSERT, line)) elif sign == '-': # Deletion. patch.diffs.append((self.DIFF_DELETE, line)) elif sign == ' ': # Minor equality. patch.diffs.append((self.DIFF_EQUAL, line)) elif sign == '@': # Start of next patch. break elif sign == '': # Blank line? Whatever. pass else: # WTF? raise ValueError, "Invalid patch mode: '%s'\n%s" % (sign, line) del text[0] return patches class patch_obj: """Class representing one patch operation. """ def __init__(self): """Initializes with an empty list of diffs. """ self.diffs = [] self.start1 = None self.start2 = None self.length1 = 0 self.length2 = 0 def __str__(self): """Emmulate GNU diff's format. Header: @@ -382,8 +481,9 @@ Indicies are printed as 1-based, not 0-based. Returns: The GNU diff string. """ if self.length1 == 0: coords1 = str(self.start1) + ",0" elif self.length1 == 1: coords1 = str(self.start1 + 1) else: coords1 = str(self.start1 + 1) + "," + str(self.length1) if self.length2 == 0: coords2 = str(self.start2) + ",0" elif self.length2 == 1: coords2 = str(self.start2 + 1) else: coords2 = str(self.start2 + 1) + "," + str(self.length2) text = ["@@ -", coords1, " +", coords2, " @@\n"] # Escape the body of the patch with %xx notation. for (op, data) in self.diffs: if op == diff_match_patch.DIFF_INSERT: text.append("+") elif op == diff_match_patch.DIFF_DELETE: text.append("-") elif op == diff_match_patch.DIFF_EQUAL: text.append(" ") # High ascii will raise UnicodeDecodeError. Use Unicode instead. data = data.encode("utf-8") text.append(urllib.quote(data, "!~*'();/?:@&=+$,# ") + "\n") return "".join(text) whiteboard/mobwrite/daemon/lib/mobwrite_core.py0000644000175000017500000002166212251036356021261 0ustar ernieernie#!/usr/bin/python """MobWrite - Real-time Synchronization and Collaboration Service Copyright 2009 Google Inc. http://code.google.com/p/google-mobwrite/ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ """Core functions for a MobWrite client/server in Python. """ __author__ = "fraser@google.com (Neil Fraser)" import datetime import diff_match_patch as dmp_module import logging import re # Global Diff/Match/Patch object. DMP = dmp_module.diff_match_patch() DMP.Diff_Timeout = 0.1 # Demo usage should limit the maximum size of any text. # Set to 0 to disable limit. MAX_CHARS = 20000 # Delete any view which hasn't been accessed in half an hour. TIMEOUT_VIEW = datetime.timedelta(minutes=30) # Delete any text which hasn't been accessed in a day. # TIMEOUT_TEXT should be longer than the length of TIMEOUT_VIEW TIMEOUT_TEXT = datetime.timedelta(days=1) # Delete any buffer which hasn't been written to in a quarter of an hour. TIMEOUT_BUFFER = datetime.timedelta(minutes=15) LOG = logging.getLogger("mobwrite") # Choose from: CRITICAL, ERROR, WARNING, INFO, DEBUG LOG.setLevel(logging.DEBUG) class TextObj: # An object which stores a text. # Object properties: # .name - The unique name for this text, e.g 'proposal' # .text - The text itself. # .changed - Has the text changed since the last time it was saved. def __init__(self, *args, **kwargs): # Setup this object self.name = kwargs.get("name") self.text = None self.changed = False def setText(self, newtext): # Scrub the text before setting it. if newtext != None: # Keep the text within the length limit. if MAX_CHARS != 0 and len(newtext) > MAX_CHARS: newtext = newtext[-MAX_CHARS:] LOG.warning("Truncated text to %d characters." % MAX_CHARS) # Normalize linebreaks to LF. newtext = re.sub(r"(\r\n|\r|\n)", "\n", newtext) if self.text != newtext: self.text = newtext self.changed = True class ViewObj: # An object which contains one user's view of one text. # Object properties: # .username - The name for the user, e.g 'fraser' # .filename - The name for the file, e.g 'proposal' # .shadow - The last version of the text sent to client. # .backup_shadow - The previous version of the text sent to client. # .shadow_client_version - The client's version for the shadow (n). # .shadow_server_version - The server's version for the shadow (m). # .backup_shadow_server_version - the server's version for the backup # shadow (m). def __init__(self, *args, **kwargs): # Setup this object self.username = kwargs["username"] self.filename = kwargs["filename"] self.shadow_client_version = kwargs.get("shadow_client_version", 0) self.shadow_server_version = kwargs.get("shadow_server_version", 0) self.backup_shadow_server_version = kwargs.get("backup_shadow_server_version", 0) self.shadow = kwargs.get("shadow", u"") self.backup_shadow = kwargs.get("backup_shadow", u"") class MobWrite: def parseRequest(self, data): """Parse the raw MobWrite commands into a list of specific actions. See: http://code.google.com/p/google-mobwrite/wiki/Protocol Args: data: A multi-line string of MobWrite commands. Returns: A list of actions, each action is a dictionary. Typical action: {"username":"fred", "filename":"report", "mode":"delta", "data":"=10+Hello-7=2", "force":False, "server_version":3, "client_version":3, "echo_username":False } """ # Passing a Unicode string is an easy way to cause numerous subtle bugs. if type(data) != str: LOG.critical("parseRequest data type is %s" % type(data)) return [] if not (data.endswith("\n\n") or data.endswith("\r\r") or data.endswith("\n\r\n\r") or data.endswith("\r\n\r\n")): # There must be a linefeed followed by a blank line. # Truncated data. Abort. LOG.warning("Truncated data: '%s'" % data) return [] # Parse the lines actions = [] username = None filename = None server_version = None echo_username = False for line in data.splitlines(): if not line: # Terminate on blank line. break if line.find(":") != 1: # Invalid line. continue (name, value) = (line[:1], line[2:]) # Parse out a version number for file, delta or raw. version = None if ("FfDdRr".find(name) != -1): div = value.find(":") if div > 0: try: version = int(value[:div]) except ValueError: LOG.warning("Invalid version number: %s" % line) continue value = value[div + 1:] else: LOG.warning("Missing version number: %s" % line) continue if name == "b" or name == "B": # Decode and store this entry into a buffer. try: (name, size, index, text) = value.split(" ", 3) size = int(size) index = int(index) except ValueError: LOG.warning("Invalid buffer format: %s" % value) continue # Store this buffer fragment. text = self.feedBuffer(name, size, index, text) # Check to see if the buffer is complete. If so, execute it. if text: LOG.info("Executing buffer: %s_%d" % (name, size)) # Duplicate last character. Should be a line break. # Note that buffers are not intended to be mixed with other commands. return self.parseRequest(text + text[-1]) elif name == "u" or name == "U": # Remember the username. username = value # Client may request explicit usernames in response. echo_username = (name == "U") elif name == "f" or name == "F": # Remember the filename and version. filename = value server_version = version elif name == "n" or name == "N": # Nullify this file. filename = value if username and filename: action = {} action["username"] = username action["filename"] = filename action["mode"] = "null" actions.append(action) else: # A delta or raw action. action = {} if name == "d" or name == "D": action["mode"] = "delta" elif name == "r" or name == "R": action["mode"] = "raw" else: action["mode"] = None if name.isupper(): action["force"] = True else: action["force"] = False action["server_version"] = server_version action["client_version"] = version action["data"] = value action["echo_username"] = echo_username if username and filename and action["mode"]: action["username"] = username action["filename"] = filename actions.append(action) return actions def applyPatches(self, viewobj, diffs, action): """Apply a set of patches onto the view and text objects. This function must be enclosed in a lock or transaction since the text object is shared. Args: textobj: The shared server text to be updated. viewobj: The user's view to be updated. diffs: List of diffs to apply to both the view and the server. action: Parameters for how forcefully to make the patch; may be modified. """ # Expand the fragile diffs into a full set of patches. patches = DMP.patch_make(viewobj.shadow, diffs) # First, update the client's shadow. viewobj.shadow = DMP.diff_text2(diffs) viewobj.backup_shadow = viewobj.shadow viewobj.backup_shadow_server_version = viewobj.shadow_server_version # Second, deal with the server's text. textobj = viewobj.textobj if textobj.text is None: # A view is sending a valid delta on a file we've never heard of. textobj.setText(viewobj.shadow) action["force"] = False LOG.debug("Set content: '%s@%s'" % (viewobj.username, viewobj.filename)) else: if action["force"]: # Clobber the server's text if a change was received. if patches: mastertext = viewobj.shadow LOG.debug("Overwrote content: '%s@%s'" % (viewobj.username, viewobj.filename)) else: mastertext = textobj.text else: (mastertext, results) = DMP.patch_apply(patches, textobj.text) LOG.debug("Patched (%s): '%s@%s'" % (",".join(["%s" % (x) for x in results]), viewobj.username, viewobj.filename)) textobj.setText(mastertext) whiteboard/mobwrite/daemon/.htaccess0000644000175000017500000000007112251036356017066 0ustar ernieernieAddHandler mod_python .py PythonHandler q PythonDebug On whiteboard/mobwrite/daemon/data/0000755000175000017500000000000012251036356016203 5ustar ernieerniewhiteboard/mobwrite/daemon/data/README0000644000175000017500000000020012251036356017053 0ustar ernieernieMobWrite Data Directory This directory contains the saved snapshots of each shared document. Documents are saved once a minute. whiteboard/mobwrite/daemon/q.php0000644000175000017500000000324412251036356016246 0ustar ernieernie whiteboard/mobwrite/daemon/q.jsp0000644000175000017500000000502312251036356016250 0ustar ernieernie<%@ page contentType="text/plain" %><%-- --%><%@ page import="java.net.Socket" %><%-- --%><%@ page import="java.io.OutputStream" %><%-- --%><%@ page import="java.io.InputStream" %><%-- --%><%@ page import="java.io.BufferedInputStream" %><%-- --%><% /* # MobWrite - Real-time Synchronization and Collaboration Service # # Copyright 2006 Google Inc. # http://code.google.com/p/google-mobwrite/ # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # This server-side script connects the Ajax client to the Python daemon. # This is a minimal man-in-the-middle script. No input checking from either side. # JSP MobWrite gateway by Erich Bratton http://bratton.com */ Socket socket = null; try { // Connect to python mobwrite daemon socket = new Socket("localhost", 3017); // Timeout if MobWrite daemon dosen't respond in 10 seconds. socket.setSoTimeout(10 * 1000); String data; if (request.getParameter("q") != null) { // Client sending a sync. Requesting text return. data = request.getParameter("q"); } else if (request.getParameter("p") != null) { // Client sending a sync. Requesting JS return. data = request.getParameter("p"); } else { data = ""; } // Write data to daemon OutputStream outputStream = socket.getOutputStream(); outputStream.write(data.getBytes()); // Read the response from python and copy it to JSP out InputStream inputStream = new BufferedInputStream(socket.getInputStream()); int read; data = ""; while ((read = inputStream.read()) > -1) { data += (char)read; //System.out.println((char)read); } if (request.getParameter("p") != null) { // Client sending a sync. Requesting JS return. data = data.replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); data = data.replaceAll("\n", "\\n").replaceAll("\r", "\\r"); data = "mobwrite.callback(\"" + data + "\");"; } out.write(data); out.write("\n"); } catch (Exception e) { out.write("\n"); } finally { try { if (socket != null) { socket.close(); } } catch (Exception e) { e.printStackTrace(); } } %> whiteboard/mobwrite/demos/0000755000175000017500000000000012251036356015136 5ustar ernieerniewhiteboard/mobwrite/demos/form.html0000644000175000017500000000467512251036356017003 0ustar ernieernie MobWrite as a Collaborative Form

MobWrite as a Collaborative Form

What
When to
Where


Who
Hidden [get/set]
Password
Description
whiteboard/mobwrite/demos/mobwrite-demo.jar0000644000175000017500000031710212251036356020412 0ustar ernieerniePKµŒ;META-INF/MANIFEST.MFþÊóMÌËLK-.Ñ K-*ÎÌϳR0Ô3àåâåPK²îPK ;;name/PK ;; name/fraser/PK ;;name/fraser/neil/PK ;;name/fraser/neil/mobwrite/PK½†;3name/fraser/neil/mobwrite/ShareJTextComponent.class­X{|Sõÿž{Ó¦MoŸ¶*ȳ´…BÅ(­F ‹E@mÓæ¶ ¤IÌM±0tCu n¾Ñ"¢H_€%PQTÜÝS§î¡{¹Í¹9•¹©w~¿$mÚ¦<üøGs÷œó;¿s¾çõ»}õøÓL¦Ùf(„ ^g‹^Üpz Ø«»=Å-¾ú«î ^\Ýì è.ÒÛ‚e¾¿Ï«{ƒf˜#O²eAýrBj0váÌÊåΕζbã*··©X0‹{«.%$Îp{ÝÁ™„’ü“JKb“ÙÕÁK•ޝ!˜Ê|.Ý)ÌÐÖüx‚É ¤iH@"!½ÒíÕç·¶ÔëEÎzNȪô58=5΀[¼Gˆ¦`³Û Wž^ì”làÝî°æ~Æ0RMz°Ìãfi±•08|1 ljÆÂð“ £arfÖ*èvõ Ã1‚ñuåÞÖ–PY*P©aF´=Ф—5³€ÎnÓR€4/Õˆ5WØ•¯a¼ÀÚlèÒýÎ`Cs¬S¶È‰­AFŒ_¡»*ÝFP-¹ÚÝäu[ŒÐüør3úÃî÷8Ý^é¿ËÝØXÛ"N¬•玮¿¥3Y¹0¯XÃ$Nw$»·á–ÑTóÇ/ir–†š1ÑsnÞô,˜Š³EM—òR—]C)f°°ˆG;Xå3ÜA·Ï+EÌÄ,3Î#döàë`+›ô€†Ù˜Ã ­tzZõ„ì|Gl #b¥âعÊQÁ:.WŸhqq±u‘h] Á IÑ$œ Açùê‹E80¥É¨Ä|3æòOu†¨âDòøšØV˜h šÈÉJÉcíc‘Õ„œ82úJݣሚu̯XÀO•+5ãÒ¨’˜„Óêö¸ô@85µ²Ö€á Œà¨ØGX°)ÅË5\Zæ6ºF°Ü£·ÈNÔ»¾"xŠMN õàbMtúýº—Ÿø~•1DªÐ54ŠêK úÂL š±Ü 7!w ¤4¬€‡cÍTÂÄüaˆ×ÈDNz5øDNf²÷Õº‡ä|¬:²8¯ÔÜôXn¹×%ú‡³+ Y6µì¶gã5~€ZíE•µ›„¶ð0øS±ZÃw°†=â#Eö:ââ=×hø®0-‰ë¸FT…°w­†kqW˜Ñ¯ÂL¬Iz|½†õB&ÓˆçñnÜt£·Ç\{ÒOÑϲã{È"¾ÆFÞgt÷ꘆP*öõ «üÑñpѷضãú=#N§’Æ·Ù/û8þ™"·á3nçRŠw¶†;E»5ó°iñW‰>u·†Í¸‡3ÂÕâç²:ã8SÚqŸ[…§±MÃVÜÏC>œÙ.]÷—ùü«úÔWìŠOùô€†±ÂyQîr#÷ü¥  ÙÈ‚kx;Yó~®¯¡5Ünò"í¦×ÈŽ²¥wixOpÊGÊÐåªâ?–ån~bs{ßva»£“,†'FÏS„±§ÔÎdÛ+Za¨OŒvtÀvì×Ð…Ânä ¿Çœçl9s@Ã3xVÔ{P89©Å ŒÞ¡—¦à9¼`Æó\…ý¹‰®nò2füˆ0ù´s=/â% /ã0w{C4Ž)|Çqò«~‚ „±Ó½¦ág"<©Kh,°þ¹†_ˆî•èѽMÁfAú¥†×ñß4ŒÖzC"#§¼#.Èíø•†·ð6+Û×Â6ó(ŽƒxŠCÜ5vá×âÀßôë±Ýgüï™ñ.£‡7Ïäâø=þÀÕØ"L:„=Òðg¼ÏæG@ÜsOÉœ¥–O;þªáoø€³Rjõð(óÍA](ðRÈ‘hLJþ²s2dµs¹•õEÍÝhöy¸›S…ÿ"L:èÏeB2>Â'>Å1vÕçI%ÇËÙ§Õ3D7òEç3ü׌ÿ¦}#>¦$”/¼dv¥@ìK ÿÃWÜGäž6‡×¥ó˜2XÖgøš/àBM¢c~uùÅ‹ØIR4Œ5…L”h¦†{À6¤‘™’x’»½ly°:’ºÓò¿—’ÙAæÔ·õj]Ü ,d!ÍL)„1ýDç8]b€ÊÜÖ û¥û”*nØé~VÏG;V, 8ôJÇj3eD“·{*1ƒ²4Dƒ9ƒ qÃ×ËÎ8-B;³5ÊUk2Ü«uÆl ‘¸Í-¯,_T.ЦQÁ¤€Þâ[©GJ¤†›ÐèL9ÜšÆ|ùa“Ñ÷VÄ_Hód˜¹ëÍq‹›„êòñ­rè ¦ÇÚÛêñt·}j H÷â6*óŸpÖ7˜éS½ÍÏÎë®Z°"Aö/Ñ¢D× ÓÌ|ÙáNŠ"çX,Á)*†0å´Î{J£ZDgÕ£Wš“fo#·hAº§VšÈ扵¼9¥ áÂoáN$}Ê,Õ¾Ö@ƒ^!?àsã|ãO‡1ЯW”yœ†!n˜¦0pÉ zL‚Œ Fð¥AøWÿàU¯ÍHâßd~[ÈtÁO)(ÚKAá^¤îæWéü›&YÃxK,8ü6",ŽLdr5ˆÕ’\ †•w’¸¢DÔ/fi…ŸC :‘BneAAay›aV;`2íÅ™…»ú7†m'>ôåqÙáí‘ã’ø0î2¬œ/Ð"‡Ìf!•,aõãz<°HÆ$Þ8%F]r·õÉÝêøËœyErç¡–ªØoaС°íïá ÷aÊdJæ…çìǹ!”-æ·ó;qÑ,<€ÅK²XdYQ—…P‚+„¦ˆxKD<òêî.da£Ž«úê{ŠÔV=™!\Â÷$ÅCY') 1”ªÄÃÎ ç0.¥ü>ƒ1™‰\œÇ€ÏÂù d æ@G$ÎÅ”c *ð$s^À1Ñ?Ôß!|Ÿå8U±q3c·©eÁ»¥›÷¦™Ä`7Ê?('6pöØØ¼»8p·n†iwƈ™ìx½·R¸ÂÞn+á!») X‡=!ÂÔž(P²%îÇ“ ”Qè´%òc%¥…Ð>µÄ¬–$ñêi»¥CùÜféÂAÂóxÑžlKîÄ­Iu%)BX¼½ÂOíšR’fÓB8’· %ÇdÓÔ¼ŽZSBx³$ÕšªlEE”åè¼cM‘ë:ñòfIš5Å,ÖTkÚ6ä*%©ÈŠî‰Q¤4°DŠ«$‰ÅïÇp›‰_SÃz„GGöã·|²=½¹’—–×›.Uj¶tÆéöŒ¨ÝÃ%9#„¿¬—¤õ^AèÄßïØBŸ¨%™gŸ¶ê ·lƒ"˜|dϲeuâã=ø÷CôHX…53„/JGèÇw`§ݤ²-ƒ9-FÐÏê$0EèïDaK™#.)F]¤šÐ‹Ýj³†(Y-±v ¼Èjí¢4á´•âurçvŒe^vø¸#u\>]”©bñ:+±PY Û¾uàƒ°Å”»ïJ5%VkjÔ¥îíì[]IvÔ#k;&kvo¬a¬ÙV««‹†ªÒþ[Ž´?§¾"kNû­ÛQ—‘ý†0#ª¶ÛŒbrï-òÌ‘u9ÄO7FÝ|±~q„uÑpÂ=ÇG¯3g½|ùJåìÊ -²ÈÔ {®(0¦o³å>]î†JoÓÇtŒ,Êe·ÒI Sתk£-…±„×Uc•È9fŠ!|y•Ƥ#ä^êë<¶¸–¸ɯV,[r‰bÞèE!çYµH\ÍÜZEK.–wpÏÕ mŸù“þ¡Ÿ¸îÜä"éáà˜ß@2‚´ ¸º"w×wgL?r—¡f ¥DÚL@çeÊUÏ»ŸrLÚkJ“ΰØ+Štg⟠„ ÓBç6þdKÜ)Õ ìzÃoeÉvD¡ë€Í£¡èÖph*¶ŽÐô—5ß‘@ðf#!ÿR,R5{5J7‰¶Tg- Y®`c¶NéIÉßÒ"dÓR'|ic?ªJ™—aÍN§UØUºVñ¼ÞfâÈÞ>›‹é5næ%//¡ÝJá˜aÀtž6ánw…,Žƒáì¸Â¡¾äRPÅFÛÝt¯{¦ng¨8^ÝÑ›Ïï¦ðË•6’ó ~I#.¥Tó”S§jyVæž§h*r¯Xùs”ó,îX¡á}ÔQùXc=­Yþ”½òeög¢xÍMÞ ì ã’ž¾””àq g–°ŸäTót +7±Lç:²½r>’ÂÅ@ ×›ëø½ÓWY,"¤Eî,þVIå÷å\! ̯6ék‡Žfüôb7a¼B`·R-_jç¥þPb‘ƒOZú'¶ý]èôûÛÌîiêê5ç6´Â”ÈI¸1E¥"²­„:÷šÅºç¥À‰‘¥§†ìâuac8FÆWÈ×­Tœ"¿75òJ»îc;2B‚¡‹-R¡Ð­ØqcxZÑ<¡ÙÑü¸/„æ±PŒêc‚J¶cA¹k!èÆ¾Ý”¸zŽ%íF;w½É¡ëˆÊª4ý‚U~»Há’]îWW«žK:1‹w±ÙßrÕСç}žõ> Ž-2~ßP¢Wbžâ”ñ‡±]âá¤öŠ GœCÕç™^âÛÂD9CϰHU,…Ôž ––ö™È+i¼‚mT4 –mÔµ<¶èH8}O2ž³÷SXTÊv:,f6wŸ˜Ä°©2M:›Z{° δ–´ß†’&pX®”eÀ“„*ÏÆ1¯Öƒ,™ÍB?ÃÞÅ‚60³3—³^O¯©k ©*³?”T-gè\Íü¼%qÂõÔF>è…ÀRÓŠoL²H˜#Ù§5­úÔÝ[gÑi(«…Ô(@¾ESÏ.‚Và‚«ƒ8X¾D¾BÀ»êõ9xá3 Î}dR£2þé€zyQÔ3TûæŠeoéhˆ‰´a)69,"¥ðÔ'#ìµX3˼ЕҦ|—ëB/þèóìÞ:7ÏÀ±œû:Š͸ƒŸýÐwØ xšNd9h¦Ô ¥gÁ~‘棩J†HŽÜ uiE{Úzà8u`Ö Sϧ=ßiê_ä¡6ö±Q†Q´ ×e«[7Ö ÀÍí‘kC€ü5Mj'~ˆP£ã¯U†î rIã vo¼KpÒãOy©»ÿw%0¶©éÛiÏ‹`g£wO—š•tër€§F3lGÞ,Ò:m±í"ñ믭Œ”¾r¥8Š2¥N HÒœá ÛgÛes©¹è®ñx­ ìà ς«<{qC§oêøÔ7sìÒÄqÛ„íûê´UÆ"z1ˆÃ×,ƒ0óí©×KÍGwT‚˜þ>¯ÕõË!ª·†yýjjvóž/œÿó_¿Ý:ŠÌ\GnohÉà;µŠvëé˜à WðÍçûùׇAÉæìžá,ìŽOî§Sý„Á>æ&¦QþEê73CŸÉhòµŒ¬ì ÑþÇãPh+îÇxbisÆÖ¶ˆŒ© Ž)SM:¬Õ3ŽT”ƒ¡WÔÀGPÇ6¨)ê‹Óî޼<á?´¨ƒSGŽ\¼|hNÙ4ê /“ɨp ÷|+Žü©~4{ÛÏå`ŒÈkßMH’Ö¼Wh…·`·•tDmÒLëùíüa>žºJd‡Æ5^'­14¢&£Êæ gÿª¶zý|£éÜXqÔÏP«êO¥eÉ×8€-H?œ‡5PÇŽŽÿ‡©H³õÑh¢½œß‡Ù=Üz‰{ÈcI;Öƒ÷ˆêXq ÁP$ìøËGw©·p°ãï©,uæ^ ¥nϤŽèÑSJûs;™4\še,fÔ ûÆÍ¼ÕA˜›Yi¸: æ«?õº íŒg:¾±4uŠÆ7iª¬px5[÷Œúë¥ÿÏàæÿPKðv¿£„ôPK½†;0name/fraser/neil/mobwrite/ShareButtonGroup.classU]WUÝ7 LƒPÚ@C¿hl5 c±EM(J#-Ô@¬: —0]ÉLLJ}÷/øPŸô‰×v-KWu­.Ÿý¾ôOX÷$€Z_&wÎ={ïsÎä¯~à L ”mV¥±éš5é¶´*FÕ)n»–'¥-Ó•7êžçØ·\§~_CH þÿ;Å{ÝÅý(XážùÀ|hÔ¶-»lH˜蜰lË›Mëå_“æ%Ïåm.¹"Ê;2‚ ºuhТ‰vŽ]xKG:Õ©OÇ ô èUé–e~‹®²& Öz –-çëÕ¢t—ÍbE ôœ’YY1]K½7!oËbDºðÿu#Í@±ÌÐM«‘öLž²ôòKÚÞ²|è œJ$ºEp4œOi´1ï«€Þ-³6縲E)ŒkìfXîq<†!¥7‹g ÇC£ÏÀ;ýÃýV·¯µO;ñºt“ªµƒíŒ`Ÿhø˜ów¸§:¦pƒk ¿¯›•Ú¡!njÛèSÓ¸Éf0õ¾|¡ÄZrE]Ïè˜UúõQ¿©’™wªUÓÞÐðY³3†¹íòA iuŽà6æÔ Í $ÛTŸ=:‡³’ :>Ç"z¯dÁªyÒ–.‰ÄÉõBÛª-'Nß2V4|!p:¾ÄWœÓ·.HwÓq«Šü…Dû >¯ÆGÂó·©Ç<¨÷]¶Fâ˜XÎV¥YeÞÀƒ½äÔÝ’¼éïwôðþªp.&‡jlêkÄSg a>»ø¶F»ºïK¥Ÿ!’yŠžTð)zŸÐÀI>ûâsѧA ½Â)Z†qˆbðOƒÌ/üÓiúx™fŸš82©ßгKvyޏÀŸHÌ¥w‘z„0FÓzEàçW÷_}ìÃU Î2p‘°ãP;Å%œÅeZâHq(öeš€Â¼Ç,…Žñei×Õûw9ž&| !Š“„¡‡Ó„8·ƒ»éÄù‘ Áíâú#ÌdB»ÈóõV6ôn¯fN_ÆBÁg(d;h½³šíz1Žk;ˆuEµ±lg¬3ÖñKü¨‰¨ ÿúêeºIó—'{$Çü…ù7ÙÍ·(¿Cøêƒ!•i,’È:%}l\÷‰Ïøâ8{p°JýͲ/A€7 ¾AæÉ²ç1ö4GáÖñ5ûºHúwñ éï TöR=üÖŸ„ïþPK‚<{¥ûÁPK½†;/name/fraser/neil/mobwrite/ShareButtonGroup.javaÕTÁnÛ0 ½è?½Ì+2eçu–eé0`Mƒ¤÷vh[©-y’œtúï£lÇ‘½¬÷ù`Ùâã#ùH©Âä3…%‰Ô %#ÉB”:>éèæòâòB–•6v¸G'hOʉYâ¤V ÿ}ó2æ»´Ž™¬vh¡ê’ zØ8Ö“°©21‹­3˜¸Ïµs ê ¦µ}5º®š¤@ka“£¡Àôĉl;Ã}¼ƒß 0½¾ö \ÃCNP³ oRL˜ n¥†¸á€¬!qüO`=ÅV´nS¿TFîÑ„ãQ^a¨¹V\X8m@§!èxG‰C•!Ëú$pF>Uh°„8ƒõÙ$Â8•Á-¿|Ç{ =RÕq!“¿´‹eeØ8ãs󌯽Žì¶®ÈDÍÖM»ãriE |`çÐÆÝÏhž£ÊȲ1ÅÂRcëµ&ŽÈsÅ~m^YHrJû ¹Ú(X(•ã^C¢yå9 »Ô•×fŸ‘›’! ú:‚©|?¿@•ìà“'˜nÑ™££‡ÜkÝDŽöN:áú¨ÃX€1é§Øyõä2…c!톉£í€ Ó„!œÜ’»ø>·ÝÒ!¯®ÎË¿!÷oåÛéj4_ÒáÝ÷ZnÁTïZáÿ×øÜý¬yx£Péa+ØÂ•÷}â³O'–zÊ'ÓFõ šë³;”ÍÓ‰„Í…:×e‰j˹µÁfánr—4ã7'Øãd'Ῐ;Ý/¬ëÛûõÝâËdqoCö”o®hxÇCqüx7Hîh·£ñ„ÞA´‘Vd˜¶d}â<Ÿ›ÙfFùõPK¨®{sÇPK†‹;0name/fraser/neil/mobwrite/DemoFormApplet$1.class¥VksU~M³›°Ò°@Aˆ´4±-—ÒrkÓ Å*ÁBÅMrh’ÝewC[oà]ñ~×ïÎ8~d(cG¿2㌿Áßàp|Ϧq“¡”Z;Óì9Ͼ÷÷Ù÷œßÿþå7]øAÂ*†vC«ðÔ%[s¸2¸^NUÌü´­»<5Ä+æˆiW,«ÌÝX—„Cä²vMK•5c*u2™\ AÕGOU CË—9CÐ-éNìYò‘Y®“~Ò:¨º{˜¡3¾|µÄ$C my ) ‡Ñ‚6!DZâB -£üDµ’çöéZ„jÆ,håIÍÖÅ~ ˆ°v/Û{¬‹Ân±«F•ðC|¹º 6!Ì k…w¨X¶,©=næÏˆEº¬sà c3ÚdleXíÌ…QÍåÓÚ¬ãiÛ±ƒA™âî„f“=—Û ;ã¿SY×Ö©þăPˆì>£`+vŠ=ðZFœ!Qr]«/õoh=IͲËt“³’r ¶n¹NêjÒš•ÑNůh3YŠqÌ @®iå0v#)¡“a­o_¼œâ¶‚ˆ6²¥Ù'Œaýbq‰0»´£‡ÉØ+ÜèF£!²_Á^ôH8@œzxe³%ÍæÇOó7mV,ÓðÊ»ý âß¡.†¡ÿBL/î™”3M!§<ó#:/©†GÖIúâ%¿8]ÒÈ_/eöÅ›4]RL5G׿HI&EÈC †Cî–1J•ñ}‰']BrLÁñFÉ™$»%œxtåòŽkkw°êº¦÷%L(x§|ã{Ò+¯\ºÄ WÍ*Üi⌢V.S˜DúqLŠÚaèj®]sh‹–MBnÉQè¥Xӵͪ%ò;¯à^ôó#ö ¯8¿Û”áEš³Ôà¶è×Yh"Á¢‹ëÁºæn%E=hf…²éPÃÆ¹[2‹ ¾€¸ËÐ)‡5·<™µóÞí]Pi¥ÐZ ë𸇯G¤feÕf’¡'K´ß†ræ±97‡'3b¯>uÛî Öq±ûu¨»ÄÆ{“ 7÷@ž»~B`¼†í©cû6Þ\ÍhŸzxG€´:BÀ 8¦>G@_`ã ÈI5;‡úZçqv9§¾4‡—û‚ó(, \Õçp¥OòíêUaGöGHÈGfÕ×~Åõ\Ëîl.Й͵FÙ\0ÚšÍIÑ`6'G¥l.ŒÊô+ECô+GÃÙ»xó•6tà¿‹CtžLгVôsØ@¿ jVÚЉ-HbÝRè&é’ÞOÒ½tàDžv“î"ÁQüŒÌc ?1„¿0ÌVc„ÅèǨ×D«Ö¨…&¶±MxPó¸q“¢ppѪ…ìþˆi ë_âZµ’ø”VAòdà3ZIäï>§•L^ã Z…Èw/éÜ„¸åQêk|CÏM­oñ½G$æŠþþPK>6³u* PK†‹;.name/fraser/neil/mobwrite/DemoFormApplet.class¥X{|ÕþîÌ&3YØlÂc!@DÔ<ľA°I ²<̆Ä+ÎîÉÀf&îNUñQ©h}Q”úDÑV”l,ßZj}P[[ÛÚ‡¶ö¡}kk[«öÜ™ÝÍîdùý,ý#{gî½ßwÏ=÷œïžÉKŸîÀl6C‚ÀPe¨ÝZÝÚ„šÔu†¦ÇëºÍHB·´º…Z·Ùd&ºë{zâš%ÁÃP¶NíS7Ô%ûu£³nIf ˜a ¨Sû­:­O3¬ºú¨¥›FPOZš¡%ÆÄˆlÍZb[ÓߥZ ƒyTmÚ«I×â±y c‡çÆTK›=¢gCQ<o3Êói‚jD‹Eé0@Ç ´‘aBþÔÆ.-º¾ÁÜ¿`—–Ðysz-Ë4'ÌÞš=:w6™Pæ2öL³|óºôXL3üÃ]=j2Ùo&b ùèéŒ/Æåì\KFz÷ëˆÍp÷Õ'4•Ågè†n-`«ªÛs½VoÄ:TÂoB•íÀº¸JˆÖ^Ãà»¶9OÀ, uéPwÆmˆj¶ß̶]ؓР+d©Ñõm 5JÞbô7.XA^)‰ÒÁXÚâ•Í^0œ¢àTœF Ñ©Y¦Aéa­P ‚¯ªf“ˆ¨tN‰y^ÌňÇ?rTÁ|ÐI—$5+¨n4{ic“«†Yœ¾¥ª¡vSu»„/'²ÃMD#£ð!ÕH†´„¾Ö‹3±Æ"—‹Bm¹s^s3gYœ6&?íd43T.5#ü¸*Õd¥ZÙhÆãjÄL¨–Þ§UòÓó¢ -|… y¬À üš°LÁr¬`hgÜLZ0g_¼'=±UAmŽ Ì^#F1+U57sC¹ëÚtàJ5FÉVS•ëâîÓÐ8S¡N«( ;H«$œK§SX­¼XŠ:Ö5 $ÈüµUFÄFjˆ)(,c-ƒ`™¼§KÁ((üi/d qrEAm’A©.ÕÇ㕤`^¬G ‡] `4Æð×Vþc)èåÞô‘ Ù¥]t«FLF?-j|ÒFòIÜ¡Ë(ÅxßE .Æ%$–ä·^OÏñT!E'÷ËøépL —S¼F2½¸ÌñÑ• ÆÂ'ájRÿ¼í¶ª1Ýt2¾L!JñXÙ”P¨žŒš^\…_¡C 5-çoùO+ç½ïW¦Uåéa}$iQbZ§mëM ò2­¿2l&Ö˸…È–…el#—´™ (SÆvê ‡Wɸ:ºL _'·ºCTÆ´ïú¸ÕdÜM3̈Œ{éaQyâ>rèˆ Á‹ØÅ£þžè«sÂ~ydåÑÌ=ô ‚Rø%<”¾P3”A1ÒC®Ü öc ¾G$|“t$'vã&)Â^ì¢Ðn6H’ãdŠ–ÄQ¤¡"ýQ#ðʇ¾(^èØ­7ÝR™c·T­Ø-ÝþvKwœÝ–¥ç‘”Û-I2µcˆ—¾Tèw½ÕQ˨-ªÄä½ö´)ô[lwŽÇTúUœ ˜†Jj—ŸKF Ô–az˜f<ŽãvAÖ¦0s¯EËæôÃC¿ÇQO3™º„Ì ÚüãŽ4ÿXš5'Zàyä¬Tt+Y4 wÖ¤pr°–¥pºÏ;g†ýõŸ2ˆÆe¾–SS”`Q›©ÉwF–(šžDL1  “:;Kq³¯ÙçwˆxG>•N]z)îÍcÒÓsÖ‡ý݃0ÐC=¥x×·È×â›B‚¿û»SHÚ})làäÜMß“ÝÒ¥®-EFlé2²ãŠlÂUaÿ5ƒ¸vnq Ø¿%…ëÅöU_7_r+ñn§p}-ÿåü¸‘¤€ä¿™$З l€”ÜÊr@öd(Å–\„l#äìnËßB)ÌßCñì‹þ;Baÿ®P¸ÈOhn mlg8P2ˆû°›hwaOøQ<<ˆÇRHñgåݾ_} û¹×vg—r/ùÑá"áá8T?@Þ#¦ð§<¥|ÎEÉÊFP¾@”ßÀA´)Y©Cù§:˜¥ú®›êøT¯Õk8D¸Cž^O·o áÍ0ÄOæz^g•êR¼î[’ÂÏj^ŽÏª[‡UôÜJÚ"Ñj#¡ZIÙÜNYF5Í¥Úµ‰Td)Öàœó b"¸1\ [±Û ãê}€J„}ˆc^„IÕc~‰þˆ$>ÅŠÑÇJé‹ì(l`5¸ŠMl1.b+p1 Óg˜ŠKÉžËY/®`›°™]+ÙVúVÚŽkØNú*ÚƒkÙ~laÏÑ—ÐK¸Ž½­ì-\ÏÞÇ ìCúú7 ŸI›…)¸E8· 3±]8 · p»Ð‚B+î:q§`á.áJÜ-lÃ=Â]¸O؃û…½Ø%<‰„ð p{„wðð>À#§Ø+zð¨XŠÇDútFJ<ƒâì›1$.Çâ¹Q<%xZìÃ3â%xVÜ‚çÄx^¼— u®¤=ŽF¦•´H¼?Ç/HAG‹ÛÈWoÓ©L¯Å;øéöTñ*üšú<¨7á]Rù"¶“„ø7ø-ŠÙì5üŽž$¡˜ÝßÓ“,t²ùxï£„Ö àäyþ—?áÏéÛà>b© ù»éøK ÿØŽfGMþ=Ÿ+WÅvHâ.x(’>å*•óþŸGï%$w»PLRå?ÛÇÄ“‡/¦It/“¹¯Ð•ñ*fà5º&Q½n» Ò1!íþ4‡ñ ¬ Øh6‚OfcqlÚäc†o?æsß~gÛ„ä‚øYÙIÿ#.È„HyAHÔ ™˜W¢»!Èx6¡¤Ç ™œ™È ›ÝŠÈ$6¹d·2%RQp/Ü©9)ljÈA7dZd«,9ä†Tf H øïtv4µ<€§Ûq‚ÿPKõGŒ ¸PK†‹;-name/fraser/neil/mobwrite/DemoFormApplet.javaµYmsÛ6 þÞ»þÌ_*gž;i“´·[óÚfËÚ\.×ív=ZBb6²¨R´¬—ÿ>’,Ò–d7Ùr["x °à†]#ÄlŒþ•d)J?Fùc1œI®ðÕÓ'OŸðq"¤‚/lÊ|6Sþ¡ˆ„|U5+Æc¬š;¡¹Šaœb¬üý@qëç4gL☠#,Ó¶Úr»“F¤%½vntñ÷»^ê¤wq²Š?ƒ^Ì]b.¨ýô.ÞÐ 3vGœ×¨Î™¤ôªPz-k®Õ~e³ó+ðªEÐê“(Z0¢v½ÖH©äe·[Loù„ršåbÜÍÜv¿úÉ]ËYÿÞ~q¼°°Ü˜ÝhÅÓ˜ š²ˆ–Ô×”&S¤ϵy¡Õv ¿‡@ xÇ·šP\2¶Û…ÓëXHìÀ$EŠà+6‰”ÿ0xü¸ ÿ£öë`Ä$¾~T?\RîËãÐL˜Ý|((EÇ:Ý$Ù–;°k®è#0×’mR«#ÜŒ¬”Þ_[zIz¿QºÉ̶ôýaª$ T–‹½¥„íÈÏÇW0uÙ^ÁÊòÞB–_ÀFš±`„ëV]±bA¨h”øÖT‰µàÎ Š#=j\ (9k-QÔ!g‘b°9jÊ´^ì”ôn•ã ë•éS ö曬cm ë¹ß±®ãîm°"¥c{¶c;¥ãØY2ÔÖožÌî‹ÑY}ªó±(jè/h 7gÔ_æCÿw76ÌðœQ¹eê6C`qVä9‹ø?h†5„€y¿–媮ÝS˜kÕÓBŸùAz"nݵ’#uÕ÷)¹–£s=-J?EEê‘vž)}9×5™Ék®c¢·™êƒŒ¨É@i³pÑ^kÀât€’_µ:fÄ?xvÔ^ß™ñ^òPˆyçù¼®Sξà*Âì­hÍg¶H6䵊Îõ4d|DDBR£3Eнð<ô ³¶Î¨× çùÙþé;Rt·½Äz &ÔP{(¾·67;ÐQ…& Cϰ·mÓt°[¢Lº´«†«v‰õ*ݶw;6ºdÏöšú¹å«ÐmÞσƥ³T°ú#Ø õv·¨Í:E\‰ ˆaÜ„˜•:ŠØÎÖã ­zÞ™!\‰ÙÅÕ‹Íõ 3KÌQj&U03m­ßÓîÑôv ×lu†÷×5¼o/übëûíìW©w…ÅÁÑkíGØ­Æ‹¥N¿¿GªÐÿÎsRg«‘%;DSISþ^s™wÔØz-«".S‘¹îñÜÓ©{509ÿⶑظo$>zãìý'©†4ɵ³»¼B}G2ª˜òö‰dqÀÓ`ç‘Iì0§˜¹Ä[zepò¾™¬q¯îíá›{ÕQcÌ3, ®ƒª–pŠw8û$äM% 4zÒµ,ç(m÷é°–¢ÑìžIkÚ m4yfÙÚ !‰¯ÚçùœkJ>XûéÓŸµÍÆní­ol.´ÑX‹ÆÚ³¢iÇŠGïמN·Û°Eîõ_S¢SŒTûFYH­Së@ õ×ñ[÷ДµÈœ²ˆ½ ÀÜúzÓ´håm§g.¾ýƒ³ýÃßÚ ¬MÞÓy}—¶é‹çët ®W²ƒD½c޺緇ú¦¿½óød:²O¢Õ8£\‰\{;C®,ÐU2þà)×7‚W,Jq5Ä£ƒrqB«Çù|ñû`¤u…,Ò‰{$wo…+ð.èW#®{1ñz ©”Öé¶Í£Š3úC5§ G7ÐK—ó»óªf²¤6çVÊ—’%ž’\I­)ê.ÂK‚Îa±~î´|Ìõ)'«/A )%çjoëæ¡ß#üvj!+Åeù•ey© AæêàYÞ‰>ƒ@÷ÀCq <Ń ;ÙoqHïc„§,IÉâŠ^'úò0ÿ‰(ûYÁ¹°nø™¡9G©1Àг~K´nHôõú¼;öñë„Òƒ‡ú.Âí ÛmëRe(D„ÔÁQW{dšúåF™§ÙU–;Í:ØÉè‡LBIQqYƒÖêáÏDÀ´Þ^®Û/è»ðÌÙa™ãÚâhûsáÅÝÐ=À¿PKk¾$_ PKš…;(name/fraser/neil/mobwrite/ShareObj.class­X tTÕþîdÂL†H ˜b2Iˆ M…htÐ,6AÉÌKòpòf|óˆZëRíªÕZ«ÁÖ¥.±®ˆd@Q¡µ.ÕÖ¥V­U[mkk÷][õ»÷M62ÉOïÞ¹÷¿ÿýþý¿<ýÁC8R”zàXh†ûôên+œÔ­jS7bÕ}ñ®Í–aëÕ-½aK_ݵÑ·@áÆð¦pu,löTsIØ9Ѿ„@Uh‡D,l˜¶¾Å®ŽÝÝëûÂv¤w}B~kÜÝFLð‡F¶Ø–aöpÏ;|·@p"Û`Mñ®3ä¤.fè¦ÍsyzÔ°[ìpäl"‡qÊæ‰až­GCFRµ=fØNYä^‘•hÙÚоRÖ.çI_²7on¥Dùuk»n%¸) ¹FŒ›tkdÍÕcvx5шN­O·zôº^rÕ“Ó–¦a/˜]6QåíÔP]<ªûƒ9Šp]VÞîÅ<äAàP ðÊÙ …˜!g‡ið;³Oh˜‰Yrv¸†Ùά\ƒ^*(øˇ æÈó‹5äbšûHA©á(M¹VÉ­FŸOQ1¢ÁƒcfeÓ¬KVÇj˜M €{zsª¯K·ZÃ]ŽCÄ#áX{Ø2äïÌ¢Ûî5¨²Ò)œaØKi§üÝv|Â1Õ¬²òl^Fk#+PÒŽ]))ËîDÒ:ÍÙ÷–H”®QÁ°œ }TÊ* Çc¹Twƒ†“p²ÀtE¶>œHÄúŽŸN÷ÉâÁ>œ‚§Ž‹a‡^Cšé‘ú9©p,¹GfŽ—wJˆk4œ€bz¶&Ý8?‹ŽÕÙ4KO¦bœÌÌ‚A†å8óö'†M|êǨN†jÜl!$é™M‡©$+e)\¹IÑOùøØRoF²ÞLõM’:½X'0cÝ™É`Õ kÏŒ.®<«‚óR6 Ëƒ°@ñ(KïÑ·TS¶n™"ˆÒ`‘x_BeܪlüC“œ®•tkèA/y(àº%pôXÌhV ]G7#zNMÎ!rÚˆ˜ggš!ÑÐs䚤Jx2ø•c ™’Iqò˜™ŠÅŒî~™^lÊçÃ&lñ`³@Ùþ ý8—¼RfRæž,[»?I§¼Ýƒó‡%£Ù•)#Õ­<"¹@Ãgàeq3¢k,½ÛØ"ò" ã^¸)Ké«é¬‹²ÅÞD‹ùp.•u¡XÎ>§áóøˆ™B7£Sy":ÅìK¾ V#¯w6½¸‚®ÞÖÚPu¬Wâ«\Å V,LÝ®n;=ToFX¡h¶«ñ5™=ÔOæö,wï•¡¾®áZ\G…X:£&BvEeuuYˆ=ØÊ”=º\oYqË‹o°¶²X’ýI[ï Dãz2`Æí@2•HÄ-; ZìÃn껑-ÆÔx[{­øf™£jeõ½YÀÕ\#v‹Ôù­s˲ÀÖ¬·³l©]#^ÝffPèQ¥;Öo‰è [u îhØf ʼ(´?Gd§”ì7#2ñzp·ÀüQÍ †5q™u¬zî’ÕwîÓ° ÷Ód±xOŒç9câJ®ñ–êÚ«ÍÃò`Ǹ°¡Ñ7é1 iì$³–úöúÓëÇé8ÂZ/(KXbÜ Œ«Õ¸8Ü‹Ý ýÃkRËH-?ºCO£½ßõà;O&˜†Çð=ÞÃUv6e“bÎÖ›ùðžòàÉáxppX;/»µ‚©n´Õ¢'˺þŒ†à‡ìA3uÀ0G«úÔáÒYžµØÔÊŽê9 Ïã:PÒ8WWY³Q^ö¢†cÛuY$¦‡ÍT¢Eï ›¶‘/kxE%¨ïî6"´G¤_²~UÃOñýŒfj0¬ä„n*c Þ8â@ _^ÇÏ5¼‰·¨’xB·ÂN$,= ºzø }õ—xÛƒ_ ó1Ððk %·þ´¶!(¿Õð;ÌiöÞ}½TÌï5¼«ŠËv_Âî÷âÌ Ñ/þÌq•Ê•¾û7™!§rÕ þ¡áŸø;iÍŽ¯’φÑÄ4¡ýÌ–:ÿƒ÷=x=Âè^#åUž~1þ+óè8™Må@hø‰îD*Ù;Iû';TáÒpVx‚óƇñh¿¥b˜¥WLÓ„G°à-H™z2Nè qË)m–QÇv„é®Ëˆvÿ>Uoò á>jתñ ¾\ Ôòq& D¡¬«¶4cÜ’¢Ž–ÆÌzít᳂Z‰T»ðï–w‰›û_°ƒ î 2¾+w 1ˆ;krÇË>Säì#|Iîˆôrúø RM•c­ÛDë6;Ö­’Ö­Ü%r›”^nƒWÉ4z¯sGÞ>T:Л³!¯’À§ð´©±8÷MwN,{ß°Û5)e‹ü÷ *JÜŠ{EQ³áf«¯a»8ˆMüq+ð°¨cOÝŠ=¢=ò<&töÂ}x\XljÏÇSb+žàYñž'ÚÄ[xQ¼‹—\xÙµ¯¸ê86áUrW°Ò0„2!— MÌQ!·Ò•Ÿ™ùq$øŽÊb."ß!æ²äç:1tn"tèr‰/¡×E„NàæS‰¢Ë!ª 3»o ïÕ<1\òuîêä].q¨L{" pôsåuÅBÈ~1(;±ä#PK³;Ù<' PKš…;'name/fraser/neil/mobwrite/ShareObj.javaíYmoÛ8þ^ ÿ5°8Êî8¤—ö‚&HÛl“ì~hz%Ñ6IÔ’T\ïnþû>CRo¶’˶ûñ‚ ¶$rf8óÌ33JÉ“[¾¬à¹ˆæš¡£BÈ,ÊU¼ÒÒŠ—ÏŸ=&óRiË>ó;I]¦*éŽHOŠD¥²Xœ|IDi¥*^öWÂFWÎÜ2¡7VŠÎdq+Ò3iìÐÓL-‰;‘ -Ðb!¾DçÜZ¡;Ê·ÎSf\V|±Q*çó›œÛdySÒ߯Úí8Ç”UœÉ„ñØXÍË’ŒÃ.–\‹÷ñgöÛógŒíïìÐÛa?ÆòÂJNŽbjÎìR°cHfoI&;w3k®×QØ´´¶<Øß'ÿE ¥™ˆ•ï—ûþb,Ûs–í9Ëöý>÷QjeE‚0±MûYš—/iIßÂK4—™ O0¥ÙÇlŽ»”†:UÊTü"£A-V#XN—ÊXZòVÅ?¼ÞdRHë¯a=DvåvÈ›I¥5fkV€u¡V™H$Ria? ±Ê9¾CÕ¶€ü÷{wÖŸ^9R·´{ëÆJp 0åÙ³ZrÛц%%–)ukå[ñ¨áîT­.iÇ!¶ô’7“Z7Ä”–›Ù¤˜ëªÃæŸÂÎCöÝ ŠÆö‡Tä¨ð›Sq,S'ÍÓñaßq©È,‡Hw·ÔâNªÊ°¥àÚÆ‚Û×M‚IdW&Šyf‹EÂ+|¶ÂÉÚ€g¦*‡³.ÈwÛ³õð‰b¥2Á oÌû[œÇ)Ù:Ód-µ*ä¯>ÑsJÝÚ¥ºìÊ@œs$Â;cD^y ˆ à\h°2e­¦AÏǨAËNIwGTQå1|7c §t‰bX¢Š9øÉâ.ˆ ñàw‚­da? SþfÉ‹d2 ›·a¯@fºJ¬Òco4¢ o©o%í×D`‘Úèÿ”\óÜß=­y§vÜÂxß.Ï­µÄI‡\¦žY™#¦ÈI;lY'ÜÃán!V[ä7™n¬‹ˆ„o.e.ÀiôÏyX²¿ßðËŸb/»¡`É·kî· óTS#¶ ü²rþm*Óy@Ú{Õ`¯‰¦ñ¹¶Ò;o¶v“u7 Yp6 çùxȺua7Õ²˜$U«êN¦¸nKi nË;¸¦ólТ;ª0={‚‘´s:PoÞ¢3™¯»öµÞ‹×Œ—e¶¦ýœ'uøÐá_ù+v¹io½àHk¾¦¾hûª8„_g·ÛÕ±¼ƒ·ÿU-·Av8›ÊÒvå"¡j;*´£aª¬YLðvÂoÜ'AѬ/zÚ½Ži‚‚P‚¤Ã’g‰#Â},XÎk~£U“=Á‘ø¥SM¼Q¿û4mŽè´¹ŽCe™ZÑqW2Ë31¡3ˆ½B¤Š5wƒLÀ¯Î ÌG1?Kä$ÒÑÑŽ_K‹ª²Ô¢õûî'W8 x\& ­šïë¤íƒ, lÊÚ_݇´í£î²rNrCV®–¢€ÇˆƒØŠ?Ò†Ô£•|¹CðÒòŽ8×áKK,Ö…]¿òò§ÃW犲…ݘºuÅÃT±k]Ot¬¨üÀܦ}qÝ ø "%jF©ëð›¦[ ¿¡S¡†à!njÈ‹ê&#O´J¯aÒœ@_"j„aQyß©¥.Q?&£ÿ^_›½×¯¯Óhöi—®þ6ª#ŒyQF®š=q‚Õi™¼ïÀ~›ësTž»îFÁYæù )5—"KQH2£pFWM÷ц?Þ´rbñäÖ6x#hž4©[ë(hžâ%„òNâQ QŠz4m}Æ"álè6ZX3 IMñìÊeÔ#·”£©;lµËô\‹¹üÂvÛÂ^Wj½ni#ìkçÊH¸Ï =˜±ÑÕåéÞ¿FS̆ üDLÆ»ã³q“¸,q,0yle¢OTÈk{€:ôtƒYœ·C-1ç‰ÖJOF—npr‹XJiT@JÐÉœ™ÑhM]Fi`6zw0‚+ÜiwÙøº?Ô(™[Ÿ¡MûE=UÓ† êÐ| aBï»G¤hJ´˜AVÓ]—Õb´.Ø<;¹·² a¿Ñ}£æë@JÂ}}Û(‰IÃÍCØHžX-}­ê®>tyÑ º;îQÍ£ÀVxBðpœçëAÔ®n£O›Î1šnðÔÑ|¿Ã1×2u‚±„œWhº ¹éšŠ™¶(Í|QW°&£èõ üŒ‰{ƒ]œütòá9âêÄnõy„ïñ¨›„ø>0º'Úcˆ5¨ðýí¥¦DkŸÕõ‘M>÷]‰Ÿ¡¶{7§øq¯ ›k•wG8¢ú„6¨®V1ó*ëö(‹)õ+@?âE©“Ý,®–ÝÞ) ²ð†·cù¬ƒµ™’úØr’"ƒô„_±¿÷ÕŸPÉªÊ ‘Óë¡Äom¥=°üdŽ>$ëÍ ÷õ—ºúDOq®žU/Ù÷­Æ~ÿ=<N%ñÉ4R¥Ð¾™Áê÷õEtòãÕÑY?—¼Žíc6hôþòƒåM;Xö÷^{l¥zçhî±*Æhå5¦ÔÎ ÿê…#Y÷B‡ê»§¢¦7ŠùVÖ_t¤ñ U.]»„šfTމ›Þ>“|Õñ"´˜âQV{.¬Å]ËŠ3yõFì×l”‚ê‘`Ç£ix½÷3Ý(1ªãz] «Ž Û §µ³¬ÌrB4UO ¿m뛃ï·äô–íîvwy¸YßiM· û ó,¸½§2akîSi~£Âfâôrê/¡¾T¦”® ¾9•/×X§U^úL4¢S®ÆFÌ5)nò “#ž`äêëõ Îgkce,|+XÒà_ÙˆLêÝýVo ¡7Pó­½Ý×twOîï¾½Ãë€!œtãMxU“ðRœ*íp¥%õnXËL‚^h_¿Òlç‹®£·ùøÉûh¾oòÚHŒ©«a+2üeUGޱœ‚”@ÓDUžžûmÍ7RX˜Ëþ?Ñü ÄÿEM8ßè´ÅHÿ]Œt‡æÞLšw[%—…²‡é†#wQeë·6´áã÷Ÿú2ûsVƒKÜÆïPKŠÚú@A PK½†;*name/fraser/neil/mobwrite/ShareJList.classU[WW݇L˜†‚hÄXlñ‚„Æ+*QªR­Ñ­XK[‡äC“ ˆÚÖÖbïkõµúÒ哯u­¥v¹úÜ_зö—Ôî3 ($Ú®¬LÎ|÷oŸý}ùãŸ_ŸØ‡ïuT ì²Í¬ŒM9f^:1[Z™X67yű\š1y.iå]šÀŽÿ°œœðÏ*{ÉYsÁ\Œå¯XötÌ ¨>fÙ–Û+‰TÐ{¢XƤ`Èu(· h}¹´ ‡Z: P¤’a ^1àGu hÔ±A`CY±I aZºC2#S®•³û_ÀiO¨› 4a‹€‘•δì›a™cõIË–óÙIé\4'3tjLæRffØt,õ^jîŒEÝÉÿƒ,A©šÍÐiÊ*,kM ŽÕöe,i»å"±Ýi/7Ó±S i½øÔ¼•IK'ˆhUðíöVE ´#JV±éa33¯º EÚÇŸKÁ›¥:®¢tèÂÞ¤97'í´@s¤¯¼šRZÏc¯òØ'Ð)ørÏb»@ÀÍ•Ä*?)~¡mó¥VHÁ - 2;ç^%༇ìZÀWmêòÏD/Nèxƒ´YŸ×ÀIœÐnƒS ¸ hxÍÔªÇi…Å™uX¬Pø%X4à¬α?Þ–",).Ñ`…àŠNžŠ\H®;ï’wÃl+GœGk-ñŽŽ·YjE_0Dö3ÍéŒÌ‚“®j+Ñ^‰ï‎á5Àu ³KFÁ±Ñ-;-‹èTh;ÄÞ×ñ^i^‹Ú„íÊié(€?àP®Í_RÆUG¦I¤Hk3^—a…dcªii`J¾®èn]“ÊwÆ€¥dZ¾$øÐ@dE€äpLb¦R—2{H&Jòx-røHÇT®5à€S¤ÙÆu» ÌXPé–íz“§‚.¸Šk¬sÆÌxþØ1E |‚O™0ÿl`vÚJ©‰õGÆÞ¶t=-ãmᦄ«reNŠÜX„çøB+añ›ð¢h}ÒqLOÕ8…›×.¿«s+ °­,Ô± —×ËøÁ¡Ü¼“’g¼ÝWÿl+îQæhá:öñ¯ŠùÔ^çÉÇ³ŽŸ5|“”+ý«Ñ·F; .ÚQ@ýmèÚ=h¾ݧº a>¡ñ¹™šÄl¥t+%-Åü4ÞiS ïô^§· ÍöRÊ'|ð÷ìcì}ˆ]Éè/¨+ ­_ëííÑŸtWûºý÷ÐÖCþý=Ú¶;¨éðÐ1ÒÖ ˆøz—ü"äUß}úKÝÿój}QV¥ª 0WWE#vb7vQÞŠ½hÃaDp”²>JTÝÙ ë(ÕÀ B7«lÀÚaÔF¾e§>ÆÙˆĉ€À1Å^Ä5ª—Ûªß#ˆ>Öô¦ª—¥•Z{«ÿ1γÕþ_7QÝÙÑÒ–1P….0RõêºBÚ#ŒpydI!­sœÄ»Oÿì*`ú·`FHxœíÑï¡9¬/Ãø¸ÝÕaM1HP”âŠÀ§«¼ ­ë÷½Wàô¢žÏý¼º¼’ƒ„ãi7›>Œs<_b{—Ù`Ší]ÇqܤÇ-ž Å\! ° ÅfW/ú6n0–à÷>Ãç¬c{_0g{°Ä£Å—øŠ§ó)™Ÿ‚Ì{‹4ùšqœEÏo<~~ë]éwÿPK_bbEg PK½†;)name/fraser/neil/mobwrite/ShareJList.java…T]OÛ0}Gâ?\žHéÛ+¥ÓXÕM::­ˆ†&'¹iÝ9vd;¥lÚßµã†$í¥VïÇñ¹ç\§dé/¶B¬À8×Ì Ž%r*yÔÜâèøèøˆ¥Ò6lËâÊRöS«t?·‹Í#—«øËœ;z1å2_U†âõô¡s%÷uÇG©`ÆÀrÍ4zlÀE™…Ð"ÙÀWpqvæ8ƒÛ5BEÓœç,%d®-Ut¥DiÁ*HŒkÏâºåÂ¥æ[fêk6a.ôDIcuå•PɆhƒÆR£¡+Ü¥ Œµ ·À‡’iVÀFÀü€’‡êæ\ |¢gQSÌthW‰àiK¡( ÞÀÒjÇÅá œNÔ¦*QG>4ª#vÍMìç…1µµ£êNÖL®Ðød¼Â®KÑNÆph^¼œÝ|žO.§óéäv¶¸ñ°û‚~G¢ˆ[š.˜vj‚p™A£ˆF[i ßãÒÒztJû’õÔÄt"8•ÜRyÔÌ_g?V\d¨Á$4–ÄÇn8ÚK³ðÎÞ?Näõ¸%׳˜Ý1Q¡i¥2 X”öÉ5é C*§Ý‰j` WVÀeƒÞà9D'¾»$÷’˜•%=€èôÇÛÓým^Ø~Ã~.ØÉ™0{&¡)HKVÕ2„1ÜZ¢ý¯Qõêz‹nH××mÚ*žDÛ¤àœkn¦öHcpã°þ7ôÿÂÍg¥~ç}|ª±¥þx]ÍˆÓ õûFø:V¡WÔ4{ãhû€SáÛWþN¿ü7=Š ‡=ã˜Ë w‹<Ú—O4ôµø ¥ºKçﺎw(Æ,#g|‘kíêµÖì)Ìä2=$ã)?ŒšÖ0WoØfó/{TZ,;7ÞóÂÙ‡¦¤LwíZ>™çw4“Oé!u [ëH?ÿPKÝú<´¹PK½†;3name/fraser/neil/mobwrite/ShareAbstractButton.class•SÙRA=mB˜DÂŽ nY€T‚hˆ h@«B!àS'40T2)'àü }âU«J­âÁGÿD?B½=öeMUÏí¾Ë¹÷ô韾í@Z‹¡Ïà¡­˜¼$LÍz^+³›¦n -³ÆM‘Ì–,“笉²e †žKR^e×®ð‰ éu¾Á·´Ò¦n¬j'Ë&|cº¡[ã ‘‹mŸ–çäÉX&$¢ó žTqYàF­ *C8R-°Ô— /|ÒªWÑ€ƒZæªH­Q¨(1°%†º´nˆÙr!+Ì9žÍ †Pº˜ãùynêr_9ôXk:ehéÿâ†uñ,e¯èNå32W…•Êë°æÄ–ÅЉž  ] ®1´Ï˜Šn\g襌ȋœ%–Ü‘è’L¾¡â&ÚB›¥ü¸Í@ÌKÇш"® FŒnAE/úèîÄ»2Ï—NÑN2 ¼„¥©¸‹†Z*}Ôˆ'²—î{*†d'õÔI2géE#U,¸±¬`¤2žÆ7-MlPSš1)í†ñ@^úC†hôé³´M; cxDüB¦õ’% aÒ =‘èÛtUÔƒ D-#©à Cç…*&"Iqûôµ0WŠfAß©Ž`ÏåÈÚ²/?È3B ¯éœ\¾‚N0—ÌAÚÈËfNLÙrl©¢Ø~YtDÊÔ‡K>!²Üd+ðÓZC»%:—þúXï±ø‚1÷ê>Ó™ Wi ÁCk˜²›@3êЂF:évòÈÓØV3Õg¶Õ‚VÊ&  ½‚“¤¿ô5Ǿ ¸‹Ž„c[=Ûð†n}:„sJtÙª“R`¸cŸÈr?(ÎOÿY$^©´‹È.úÍàÌw /J»Áý«×½‡û³ŽctqTÙñ¹G¼Û·*aïਧÕÓ÷ã.¼÷²°7ìûø÷÷Ñø×ˆ,rˆTbˆÓ7I/h†þoÐo÷9DäP/‡T,à) ǧXIE}˜Â3¢l’ú~Ži"•á…òòPKŸÖ‰òÝPK½†;2name/fraser/neil/mobwrite/ShareAbstractButton.java}TMoÚ@½GÊåѥ砨%„H•Z@À½›1,±wÝÝ5PUùïõ—lâp±­™7ï½™u†Ñî¦$bƒ–ŒP$‘êðd¤£ñíÍíL3mðˆONБ”“ÈI­fþ{|=ç§´Ž™K°³°'©vbZg0rϹsZYQ‚ÖÂz†ºQ 3cm«Ø"<À?Ÿ0 ü °Ùä¬äKŒÃCXŽ¢=Eo¡>C¤™‚bzà4„Ö#mEY=ò¯ÌÈ#:‚‹Öø‘g»ïT+Žç‘Ót\Á‚90”²ÜÔ3B¨ÉT]á{†SîÏר˜5±L^ùá‡Ø$ÚŽ¢CøZ·Šù&Ý$õÇãuÚue_j§¡©eË%ÉÍ­xo‚ÿPK‘Í%ç`PK†‹;2name/fraser/neil/mobwrite/DemoEditorApplet$1.class¥T]sÓF=‹d QÓ4¥@ `R$VMJЦơ'¤8 :ìåm"F^©Òš$Ïíé33@˜2íkgú£:½+“š”&ƒ5²vÏÞ»÷ܳwï_ÿö€*~0p€aRòž° y$B[ ׳{~g#t•°¯‰ž_ïºÊçƒÀ*_5dÈ>ä¸íq¹fßê<Ž2p!7Do÷¥äO0Tën”ÿœ¢4÷fŽü.»ÒUWìÂ~‹« Éšßi0¶` e"Œ…4² ‰‚6È4])–ú½ŽW,sMßáÞ*]= &5u†Ê>âç«D=ö¥‰13ð1CiïÞ>ÉâŽ#"ÍÀ †ÂÛýýÎ=¨y®ÊÄqdR8Åp(Ú’Îu®ÄßÒDN[8ƒ³ ÖšPË<¤ý”& ÍትTèʵ¹âëPšö=gá&´N¯-§P`(®+\²ÿ¥6]áA¾ª8~ÏŽœÐ TdÿT ¶R(Ñôøf‹86$yÄ=“¨˜b82Ü_/®‰Ð‚ *ŸTÀÃHÆ0ú&Þ Mó¼…¦X#… :Œ+_ £M¾´pÓ.2L½]ÙÖ:ű©j~/ðe,ïæ,\Æ•á UöWž1óM;Ú Òv`Á^—TüŠ’ï’ý;uG3ÀJ31€´À¶YŒ¸N œ`R:ô°+Ù_÷!—‚Œ–Hê 8ŠcdÈçf©É>Á |ÔÝìK¾F©ŸKGΩ¿Á©ÌZ…ådIÙQœ88î–Šš>é÷ɾ×¶eÉõd`-ª¾:9Nâ¬æóÒœã| oz`NbkŒ2Ð(.2¼tü¼ò¬GÎã’Æ¸¼§ÆØ˜æé$®˜ÁUï¦axÓÍô ÝE1*Ð9ázÁœNðuc¸Áª :·~à1K´œlá§9×óU×!Cco§¦ýæØEåóv|ËJ?ÐDMšø·Ù£²R!ÕÉ–Ö­#˜b´žòˆmaéMt¢+ŠiÞ9Obç"øŒI\g•/{$NW”{ˆEð9©j9S <¨ÃJDõ¶l¢–âV]O¬¯¢'«¼]òž¦äfMÌiqœb-ÊOmUt½ŠvõØÄØl –Zª’½¾fycL¿©CØè¹7u‹ªÓÖ•*°3·¹~)6üµ‹ ìͽ!'TŒÊrYùþ¡cºLÖ=ÿêð¸Àè»Í©7O÷W˜ìÏìÿÅl| ²î•Õ¨¥'yÏFãڌIs8FléûÊÇÞ²º6þ³xÂ7²Íµ‹~9øÜÊ]†«àÚ™Zùͼ‹ÜõÎ )¬@SÄåK)bˆ%7~Çu:K„Mu# ªÄÀ!føz~@0n !ÅÏXh~¶ÂJ`GÐÅÕ–#báÀ»› j-,´J6©H@(aÃQ 0™Ìvçd‘Ãcº„T}@À *û&&ã6~ ‰J`ÉÛJ+¦7[Ò…^„òƒwW¬±z_a´“Ąڤ_pª’'_náS®_Jls?ßm«6 ð 2‚®~¥Ë…kÃK)(¨º~Öæö”+ifö*þ@[¾'ÍÚ[®©á-ê0ðxÁèÂW÷7Aè¹” ñ‚k³·“IÅþ™Q–M–Z§ë‰‰µÈ¬™üò}ÐÂò_ZUèÀ­ù."Ä+Em¸$H÷¸¢£œqm^ÂvÌ…`Ôü b×,Îv1fVP+a/ØÉ®V*Õ8†Ü 5ï=Ï¥e/ @¨h+¼bþkôÀ5Þ,Á¸‡¹;ʇF,8ÅI¾Li²(×½c?† Gë´\ì}/qºö‰Ô1ß4¨36aŒ@_£.àSEýFf‘¹1k#Kke®yŒa­ènÅÅ~Å@ðNeÉŸÖ`ü—C:Íÿ—HdàÇ1cwã?e1‹æÃ %÷¿b̦Æ›HÉé˜'IXØjjÚ;aUaë}¾0ûGÎó/÷£›»ºN/ËþW­Ä¼ÿ¥*=»Þ©k¾ôÀ4‡25Ì—Ö©±9X £Þ^,éU«äZžïE:¤æy¨ƒÐI­ÐX8¥Í–«¿…ëÚw‹™..4ÏB«s|NÐ Ev/q‘ê¤%Ýøääþ¸uܽ·UKÚ\Oð/PKå57“- PKwƒ;.name/fraser/neil/mobwrite/MobWriteClient.class­Y |åÙžw&ÙÉf¸²XEXn’n¸“€ÁŽ„  à’L’%›Ýe«¶(½¨Òz€hÕõV”,W‹WëÙÃV«µÚjk­¶ÖÞWË÷ßÙ$›dñø¾/¿_vÞó¹Ï™§þ{äMq ¦‰A_»UÚñE­HiÐòJÛC¶Dü1«tIhÃ*9¨ø­`ÌA:ÓÀ¾Í¾Ò€/ØRZß±|ML¹ÑmÁÆE¾˜µÅ·)¯¦ûD],â¶”3 ˆùÛ­PÉ$Ó¾WbR?ê/G“M`ÊLHý štš‰“¦ÓLÍ`v"M:¼p‹«II£db_‘œXBù,“†ÐP'FsLšKó@C<èß·ª¡ÎÁ ú”·˜4H2TI TÅ44k³0iÉW×.\ÊäÎDбpúâ,— VšÔ $ Ù›R+gš´šà8è–xž2æ ³˜uß8Ým…'8i- —r]oR¹¤ùƒVm¼}ƒ©÷mX2„}_Ä/ç©E=Öê2~ŽÛôŒ;åáÚI¾ MVsK«c[ = oŠDcñÍ[¶n;×A`jH_¡47['5Óp'm¤€ƒÚ˜úwŸZ⋵šÔNAˆ;â 6…Ú•ÏU:)Lmêël&E)&Ù ¶ÄZÕñjy|³I[!)»þ>'ô‰Õõ¹R¡_c:ybE&µI Ë%„óMº€¾Îä@”‹[K›á˜éšG±c4ÙlW“§”Mvêô3O+^ï-‘ˆW´ƒIKè—˜t)]èþ`“µUBÏh‚Éz4ã^tœøD#Ö‚Yá‡}æÈ˜º"œ²^ÚÞpƒ®ÀFÜ+'ß—¬ÿ€ixfÖm‹Í¥«è]ÐÝ#Ìš´›ö@ J°–aÓCBE(€<ü¡ \G78èzø}¦}“öÒ>™6@|±ÂͪSë€sÝì Âhûîšt Ý á­­±Þ&¥݆ûÅ™ÀI ºÃ¤;¥‹:RÙI®ÝmÒ=Êm¥@ëGtŸIûé~lõEknX܃@N)ãKäÏA“Iíæ|QdX¥aƒŽ0@Úó„}QOÔ¾ä‘8=I‰A?‚²œNG™ô  Mú,[oÐÃY¿Ôc§T¯Â÷¨ÔéOz…¥g§’ z ˆ6Í6è ¦¬•õ ‹g:é)ú™ƒžfr©[A+VºrEMU°éòþ9ýÚ·Ô”ifký2qZyy?ƒ“~IµòçY“ž£ßÀwBˆ´¡`Ð6šNõ¦ntïÁS_ ôÛÎhÓç„I¿£—,£V,µXo>vHh~oÒä¡þ8´Tê„ÜzÕ¤?ÒŸdEeÅ*CKã±°}s Âi «?TjoIË×®"eD^ÿ‹IoЛÄÈ®é'Òܧ×]pôýM¦¥·é&f>#É~Þ6éÒB²¶Ø©þ5ú—Iÿ&7Všñh«ƒÞí Ø‘Âj’ Zˆ¦ï|ç^u° ¶½-iÿФècH´§@‚M#=m”¿GŸJÊ?“ìe<" ‡þ+‡¡u²ÑªýffØ4£žô%Sm*f¿ÃºÉYÊee,s„“ %”Æ@(jÉc'çr?›¶® Umm´ÂÊd¸¿Í®«j¨ZQeð\VmÈáD!2xYÙhçK´ dҫ΄ËÉ•¼ÐÁUåÚ“Í–¬Ôy‘L*†dÆÂš!;¶Áµ¾ÚŒˆÎ0¹†—Ƚ)î D{©£3C@ˆ ƒ"yšBVÔ Å<­¾Í–g´ÁËQ/ö„Uwçõ 0à:“ë%Ð\ØbÌçFϰ¶Éõ“Wñ™0]ÄŠ/.”RK9”à5&¯¥þPX“ˆù–¶Éµ³M^Çhs¬&¿íWZ…Çn³àÞÑÄåC­ÁzÔÝû†±»ÖöE-ÅÜlr‹¬ xyƒ,jßÙoòFéüÙ«=´Ù2XzâÊ`[0´%èi†” \rØÁ¡^‰²Ò²%oR‰²IM%;Q“c” Q"$4…¶ÈJÂÁ›_Ò¼+ E Y©G)Žb ³Ú»‡ÃpJÔ%NŽ3ªØaŒ*¶ðóóp}k$´E¾lø|ÔCiV…ЬŘi‰·£éòpƒQõŽNíz¬h£/lyüAO“¿¹y}s$Ô^)U%åpŸ+¼Ðä‹Èèjÿlc”;›|‰Ú±#EjJãËLþf*([>xU‚¿mòwd6ë'3µ$%eðNôËJ>4.pßO˜ý‰ër“¯bÖ¥– þ>Œq‚×cð•’}¼—›xÚýÑv_¬±µÄYµ5 «°šTSÈWËôs,“3xhWŲö, Å$’ëéë]H ±æ¸|a‘^F‹LÞ+£…cÕüµÕµ‹ ¾òî{;ŒÚ±çÝÿÐä›ù°ÖÔŽž ¸or à•1È¥T©Kñ·>,aó ¾ÝÁ(Š‹¾Â5“ïà;á=µ†ÿ«€½ÝÖà»ÑË)HiZvq¯É÷I§Ô£þs-¹p¿ÉÈJß@dYèHŸî`šü8[‰…>ÀM>ćWP_¢‰P¥åŒ¯"±K;/–çðQþ±ƒQ®Ÿú¿`ò1IJVÕò•ók¤f2ùa~QBÝ‘·Ëº½»·ü2&ŽÿÄäŸ2 {§BÄmÈâóN¨¨@ícü„ÉOòSˆû6d_8@‘3ûä}A¾îŒÁ°çŸ™üsþ…¬µ$Ün¯î.}ûÙ`0:ˆÑ=CB¯ë*(8ø×L#»ÑÚïA†"PHW°sðspÝÎjhe0i­&Õ÷t×IùšDQ–%kˆh¯¼Òõ^•ý²+U¯3¹‡U»,ß"ØŽ-]×à?ªæ +WÂöG–…¤-¡®œ©G.y)·æËðP. Ò/Î²Ž•f&……¤äPâ•<›v _aEã˜ÌÊ=Þ&m w¾QÊÔÐÏÊ`ò%ì⌊žõ•Sº¸vÆÿ°e*\JhƒCí(¡­ôàë³ç¾¸[þíÉžûdçà°=pÒF~Ïä÷ùè¹ÝÑeÉ2¯Z®dòÇöºV‘ïÙ&N\ûe:„‚ýÿcò©±°óJ/A¦`fISéÌ ¥×—*Æ ¡Ã¤%B•bSˆ½(’*v Ã9*iGc¾HÌ!r™Æõ±&ûʶ˜•æ;9aDã¾ËòÅKj ŒÕD2<%Ÿi]uK0¤™ùèkF—xjQ/5Æ#t`›ÍHS‰!†ÂŸVÚ@KGË<ƒÿÊ÷ûNuHVÄ UCŸ"YnÒNrËÑ“ÿ.Gšð˜b”-۬°ì4ÈjèÆAç;SOÌmóDc¡p¤8Ħ=‹ûH$–þÚ-/OÍ矀xFŃvq†Èa¿¥YñWÀ`ÑPmð£uÚÖ«?ñKC ßÒÆ•M1D ”= ýƒ˜lŠ)9ȱà+²ý­LV!Õ¡µ°‚Vù‰Ö3¢š&Qͨ2Ät ºÀ3ê†@KÅ 1KÍgbæ 1OÍO3º&.0D¥œO]`ˆ…˜Þéj¡!c^jˆµy-æs ±LÍçbæ^CÔËù´É†€®yž!ÎT÷§b æã q–:_iˆu˜Ï6Ä9jøÐep‘!šÔ|š!š1kˆV5¯0ì”'" æS 5òLÐ]ªêBñH£2+øYÏ×é%RJ®Õèå#_4*MO—A žÓU3Ð(4"2ñÌ¢lŒX~>RÏœÔÓ™z榞¦:§ËÏEx ù±H­LÍ©}!¿`u°çãüб|9ŒÜø= ³€•ñŒ¤“ óNé ‘…ƒâ“;hTá íÍS8hÂÀW8ÈñLM(Ì+è ¢B­ƒŠåO©ü™R˜7õÚAå…2»ƒæ òû©â(U¯Î;ã -Á|~’–%©>I« ÒÚÕéìZ·_‘v~ç€ ¢vY §…Ó@äI•4žªiÕÒª“Y¨œ‚X Q …ÉO›(Fò¥DFh5*¸si^ŠÉ× O^SÍQjöÖ%“Š‘¿(I¡KÛv&)ž¤m«$½X=ç}æ·FŸ“ w†³;4>e qãñ7&%éÂÚ⼋’t±ØK†”@mñ}]Œ•@kDçCÇ`ôuAÛ©Œ.¤t-ƳŽ.K—‚è˨…¾¥™€Ø#5Ó7Á€Ôÿ:œø6 Oƒ`¾kÐpw}»VwvjUÜN˜DPiJòu¾WS¤ry·B:“´KieÙ*}6×&ôV‡éZ&¯ž Iný0¡“yˆµØ²›'évy>A¦ß%ÇÚlyð^¦=ÇŸ¾›ôýEy$©Cý&÷Ñ”=ä–êÏ;z~¼?ÝDEî#¶})ªV¥ôT«NüÔ>QŸ÷ø!zÒÖŒW?J¿\ 0#Ò¯¼Yî¬$ýÚ›íÎÆÊ¨$=ß9xÙ­'é•£ôÚjwv’^?Hõ:Ü7ÿîv$éŸGéÕGé=µûÁAúä ýÇk$h@±;' —rJe»$k«½9—w8[Îsäôê€ÃÎBÈáBɤÿ¯åuœ›Òþ/Ñ•ÐÆUðÖ«©®)ïæ¯¥åtM7@»û¨kº‰6ÓÍ´…n¥Kè6ÚE·ãô´›î¦è^:B÷Ó#ÔA/x Cô/èéc:Â‚Ž²üÈ0„~̧Ð1Mó z”½ô^ˆçrzŒèq^GOð9ô$[ô4·ÓÏ•¥íU°’”¥õCo6€ʯҼ€!šP:‚óØûz„îâÁ°> \ÈùÊ¥þJqÂCÁß¿(ÈÃØn}á“ødr€Š <4 Å丑õ405:‡²x${Eȯ"¶å:^,D1ýz;¼t[‰‹GÙF #EyG“л—F÷À0¤¤P$ôîl '§;ÜÙYI.C¦™žãâéù0‚™ðöv·1Ћátg¾SìKãrØœ^À´G܆–ïLò¼C\1æ3Ö›ÛãÂé½/\á6òú9 q¾cà½äÄ£y½H¸*#À©/ÞMƒÝF×j™4â)\ÛC­íúRˆ.še0änt’üunã0¯0º†ÎÙJ¡ü¿XïàÕù9Óõâ|V’Ïñ:4Îí´ã7zsݹZÙC\™d+_ÿ!9±ÅH£ò„ /$ÄýÈi“ §Q ö½6—‘}´·ø1±³ØmÀé¹½ƒ# šìuå8nnq;ò¶åô¼Õ6° Ò€ÉÝoì]6ØHZ–)ÏkvjÅ”Zq;Cü…°îÑ÷Á&1Øÿ8ÀÉûùÈ-ô#)¹}T+up“Ü|ªíVÈ5½œ¢¯Br»’ …$ÈêbÐõæ*À¹6Äǽf±Û„aRX¢‘7ž–Zï×É/û éפ_§Öwdq~Ö¤c7~zÿ~¤ÞgéUz%"mʳê9–Ïg‰&ѹÚ-ªmæó´‹µÛ´;ùYímí}í#ÎÕëõ5úÙHšL|µ Ñoôž'ý– èE$ˆß¡úyÕËïiýªè:Ø–Ói5j™³è/(Þ@ñ&ÅßößBzù|öŸt ñ=ôo:@ïÐ3ô.¨ü7>¤×é#fú˜uú”Дó4f®bqŸÉÙÜŽg˜åÞÎN¾œsùJ6y7’ÂM†Ûô"é^Þ¤WsD¯ç8´µY_Ï[õËx›þ]>_ßE¯è×ñE*~†’ É*•@1âߨRmn=Ï/ µ-Ð×ò*VégÊ—êHx§îo1Ê¢åú|•³i§®ó‹ü;ÿWiŸñKX1h·ö1¿Œgtêãßcä„&G"mþrù&*ãWøUrò~Âä&ÊC‘ÄGòŸÈ)ÆÓpŒ^ÃÈO%*Ñ:5?vÿÌë1úM¶Ò~çrµ»SwMí“Ô\ÝKuüœËÕ«©’ßS_ŸÚíÇo‚åBÒS”üµ Ãߺ0`”‚ñÊW!ÛìTMþ*8xn…'ŽÃÿ˜¦YøE‚\¸úM¸ôäKóÛm-;í(TÚcýŸ]ëÅ{е`{Oúö¿ÛÕ¶„Šÿ ‡øÝÔtœ=ýÓîÞÃ+ þäømÄ;i4_Nü}òò•(e®¡Õ|-­áëȳ•¯'?ß@1<·ð^eCl>R6àD!ô 4Å´¾òiŠÛG¡kÙÔi³´ hxYmqa&s¬ <Ňù¸†Ú8­ZZPY]ñv‡àáEÇn<~¬0) җȪs1Ú£å™bh< †øVÔW ÔY·Q¾Ná;h>Öó=´œï¥|¿"¾Ð&«Ó€©N˜¢ °.MTÖLÑ_ðv1 ‹¡gS E%C%` ÔSõÂã4©?y²|SgGƒÓ2È„‡|>ãWuknlˆø0=BÃø(,þGTÅÇ ¡Ãá‡ÓŒv1íb0œbpD×Å –bðéƒm’Á ŠÁtÞ 2ñöcì†ÞŒ=Æž$?ECùišÍ¨Ù±¶‘ŸIc¬­‹±6áFÀ‘Œµ¦sQmŠ1'u9ÞNÕ+•%hXáAq2þO‘nqñ!12AY5ûU(Æ0í>þ§ÎÊZŒ¡ûUê¢!.é–öiÀè@ðÊE‚à4”_B^ ±'S\ºÝ¤,EïPb)&Ê·¢@¦h{NÒŠç®B—(r‰â¤(u‰©.qªÌp‰ÓÔ Ü%f«Á\—˜¯ °J ¹DµœáKÔ`©K,Wƒ:—X©«\bµ¬u‰³Õ`½KøÔ Ñ%,5hq ¿´¹D;÷õÒÍ»ÐË{„ïS5@Mü!}?¢+°Þý*b—ÙobDXýn<ópBDI~pJˆØ<÷ÿPK’,¨p%,.PKwƒ;-name/fraser/neil/mobwrite/MobWriteClient.javaÕ;LÂ’L?¹Ù< A`“ô. ¦3Ŷ77`¯’d 6ŒÇ Ï”Jw76Ɖ/¼)yã$ÚH7ô—õ(-‹à)@¥ðYû"cj&ØAÊÇðaFÖØ?D&—mo“õqBÏ õ{ã.ÉYÄïXœ(–K@É& &nÇ"U,ˆ ‘†Ç‚-5£ ú? d¤8Ìç°"…o{&ãªDœÄ. Ê^’M7B=UnœÏ.×írÑu )Y&>åADîO¯1¶!_°$c|š S âì âé“ÉD-xF¢`~ UŒrå0®À¨·'`Yïà’ /{ìçƒËáåAy;¼úëùõ{{pqqpv5<¾dçìðüìhx5D\gRü¿÷”~ýÕ†qw‡!';C]1ZXtéÇ ¶±*”)ÍG`ð—si h»±/ÙÕ î³Á¦÷`hΧè!bĦ\‰¿óôú7fà‚®àö|í+=‰í³žq…¶î€ Ieš(ò—rœ©’Ÿ¼ô®G”Ù»^¡ëï°à"3C_Uh  xFÌq×<%§ž •Ý!—›èGƒ$WC`n6ç!à·³ ?/g¨Ôª™ÙaHÐE`ÔöW !b %Ù±kÄ€¬]·Ú6=Mâé—Û“ß¶ìÙÜtƒÛ…ñ%›"盂¦€û…9ùá¬>¼b\2•ñ ºC03^DÄûäL»Ò¬¥‹òvÆçä^`T~Ü¢‚ð\%`$]ÂO1~gã6þE ü7hi‹òeàxÙb&(È$‚_pÕxîèÔ¦µ._`ø }g¿,QÀ5ŒI NzÕ!ÁøOx(Ågï Ž"ËC{sà|¹³^óÐÎ'!Ÿ"óâ4`rGL”3Ž!\„"Ü)€1…-ø-ì-v40°s»ë8øú3<2Q 2 Œ“m¤XæÈA·Ù€ˆ.]•…x2úBmÁ—¿ÐP×ÀæaöùèãK³®Å>ÉxírÖÎ……g¬øWÁh9K>†èã±ÂƒßÐHÚ‹Å‚™¸Üd½Ùò^³ÞeÛ…Py†ùj ˜D`¾™«Éž³P(ÐH ˜–®Ÿm÷´¿ýÝêÖæê³Á};?[ÛþËkÏŸm®[ÁfâO™†|QÀ]¢{o ¾ol°“ · Æ f‹Î/Êáë’hƒ—WΣ¯sÌ]!Ë‹%È~.Èç΃$Ô¥ˆIÅßîPJ90« åO ÒÃ⣱/&ÓYðñ&Œâ$ý(äóÅíÝo½={ΙÖÛOKËFeˆÿ}ï!%ªX©ë¿æjæižÕO /ñTÍúøÙ+©»ÌG~L'JfHª5‚xæ>åÍÒ¸ MÉ*²¹µ½óì/ß}ÿü‡õ»^>:Ä€¡ÞÚƒì9|¬®–¬ÿ2ˆß;ü PG¬Jôƒ¾EßQ¯(ô,É#‹®¯VÖ×WÐ i“Ö]†|,Ça‚µÖdQiÍ,˜a¾@Î{>é÷Ö×{ödŸ­oY”íæh6Zø‚¯@ÝTH‚(Xš€?Å¢Ë'u»3¸¢ÏU\@ž£kDH3ÄÔk%dòø†…A\øk[n…¢Vf'%äqù-:…6ÕeçØÂ2ÇNb͸çE®‘þX½ âs¤RæW„r1`üû' žŸö‘UïÀô©5¡òü˜â\¬?·øŽQ}† ‹ã< Šð­_ ÏÔÁÕz§ÙAU,ƒÅ^|­FúÕ œãI¡Ìà•®`õµr¶¹›;Ÿ·â(ÑŸ>ú›j¸Ù b‹B(ͱ>AƒäÓ²­{‘yÔ èržMÂ\Î íÇ!¬‘³Î„LA¶¢r»\,+ÒK÷y5^%ŠÖH`¡hô  Bq†½Ö~Â^™ïa³ä¾¡ã„ަìøH­½…ÙàºA݆jþØ@iƒ¥°ûèRH5_¿ì¶1am+<”1U$–’¢ž:Ôœ‹A²R|Ë-ߊqN;,pS ‚<ÒE§î’"”CŒ©hûÐfýð˜ß;S‚7<ÃÜ]_Ó£¨Z»nZð`Ug§@ž‘$öÓ:2§6þ™BWmý O>¹8íze²bÿðXØhЛæ“ÓPÓYo:kŒkO¨ƒˆ]½· «"„Xz‚ãEóÊ:$ËcÐ;¡½”®#È« å$ÒïÞ:3DC¦a t -R¨"¡ m;fÓ~>Š#ÌŽë…AO7÷àã…ÞÊäöðÄ©,C†4ó]ð¾$9EŒ ä1æN ¤ePô:ð€¡Ái²rp7MSÆ’!˜£ZS‡†0Ķr¶KùÖ K9ÔŽ^SÈl‘SYÙ]qPZ*¤ë8ÃÒ§,I—æäwö*àû Æç¢‰2nÌL3ÀÆd³áuçÏÌ’Pùiß±]¥Ämær@KçF˜qÀu  Qök:WEÇ‘ñEÅ ñ¼)~Í¥ÞÉäÈ¿ÈzeÞF ‡F¥¡øÁ Âå|dªÅÊqZ@¿/ÿq|q ‡$Ð%¬ç-\·ø^Ëbð§¢£5nŽ’ð¥¯I©˜¾¹†4ìíÊqF8$YÄU{¸x üì‹Y(Mdý*Ûj ‡(­`'+ìßÿfå×ÉJÝTO†§Ç r?SÓâ5vFk:NÔ`Òº P9}4 óUâ¹*ÑÂhâTÑ:®Ú dµàõûÎð Æet&YA™Uµnb{Žà;˜Ô±³#ôz±e‚HqLì'@%žÏø­2ë@¡é’¥&'Í=~íõlø³êò¾÷~í,´Z埣We©jªYœÍƒXþ"î´ ë’0Úk ì·lŒ3=R•ó· 7ˆÕâ”1“½š .D”;x|W5Ò'YéÜ RÔôÇž6˜\s“5pZ³‰Ã#<┪@.Poœ˜Ÿâ¸óÅ9YÁ»÷/)ô¼IèÜS$ñ ¬Ñ Ìü¾Ë “[K½—gT»³Á¿\I[ÑN°…½*¦%{î”bß8»M·ùž½Øwy\“$þØ3btö½ýõ¾ QŸc4Oé`‰ó,„ÂZ÷åK˜Ðu|'‹˜–wCIŠÁÜq‘®‹Ì.òªjÚeŸ%ö+/!PÚ@×1@xnGê–9’\j‡YóDf[åÓÑŒ1Ïüdq¥“ÏêtŒŒŠp¢·¥ôÖ›¨¶ìæA#¸™^‚pu#@h0%ÂcÈæ¥‘çNV³ ì«Æã,è‘*è©•2˜ý¡éõÖ˜è@u†bÊÃlš£Ù?€ækB^N]_ ™n§E'†]Àûua¬gæ2!Ç<¥Žn :BÉZêÖ¤¦áë~ÏùÞô Z喻ɚ‡‚×PM×Û8x0¦«P•#\‘˜WêjÇÏ#}Q`¤&-œKšåv¡Jf7Õ¸¤IéS8j^Öt»á€–×ìo…79@Rxžö˜Çv$V^;™Þ.SwÔ©÷ê«p.¤Ï•Ž’lôÑ(VPþDY5Äå­¢N̲C«ÊÜ×¼ë¥Uóó±‚*IÝy 7Y¯«kÁw©#9 Îú¾!æ)Ûôž¹ÅUÓÞ–¡‹ëREX†ëöˆëv×'-·!Oxò gI+ê§¢ õq&DŠo³ [ŸOêMÖKìù oÙ”nLñ Æ3óŠIè×$aW窃“¾VÆD—man¿M9ךk·Ð»q¿Í:¶^7¯”QËŠм¯WvïûrÐrÝ^ß%Á.uÒó<ý¾ÈRÖm÷ÊY]wœ+™øË~µÆ¾¥P<,ßú3ÞÁ½¹aN²ðúV¹@Ÿ°_k »n\>Ëvñ<¿UÜÙ×?ŒHíh©ñBæ7o/™ÂU¿rR¢=,RÉJ¯¥•½ Xváî—H=I¯Ðóø»-@s†ekq¼’ú£jqKãq©x‡Ó8!–¸R-÷Ñ’õ¨Z«ŸuÙ—T›)ëwS4ˆÇhS-*¢}´x äðè1òÕ÷YJéâ!ö#ÄKÓ¾€|õvÿ‹‚¥ @²ÿW…êŠô5¾“.ÊK‡aF”/F¡¿ïH²,áfy\¥Ttjm·¡·í²—}+ß*@¤±öÄͺïT«´ÓòdQ·ßm‡ˆCYžbO¥£@p.~»ü|@P%—7àA“4­ß|3ÏJêWÛð&˜4/ƒFP.£ Ä÷IèÆÂßøœ_Ò˼PEé®ÒõŰxk£e" |ÉJŽâè·ß©é<.jJ?#̪—O LŒ…~××Z¬;oc‘áuPg}†¬ß3PÜÒ{ŠZ/tgQÿÑ„<ÖW°ÍëXëåËXÊ-—¬÷ÍΤ%-b…]¦QGn8ã{xÙÀƒÿ’›²/u>@„°¾~õ§º]ôRŸ6›qæj‡–³„L0½06·1æãø–cßsq=é±õ—¬÷c$ýÍö3ýíÏØ SãÚ²Ÿ näh@¦ÏÀÌõØÄ ³‹×âpŽ¡ØÌmõQâ3W/Êé5'I¦é»Î‚CÒ®õ¦ð»²Tp³#<™†XB1[HÓ“ÞÀzöý1>ûïÞ 2 {Å÷8ºâ¬Ø~ŽÏúî³ðÙ ÊÎÏ8ºç®8Ágγzöc”ÝuV<ÛÄg?¹¿ÃgßvA9ÂÑ}wá·ê>{†ÏþÜÅ—C]sWìà³?Ùn€þ÷?PK«žSOEPK ²`;name/fraser/neil/plaintext/PK\g;6name/fraser/neil/plaintext/diff_match_patch_test.classÍ} `EÖð{U53a€0b´ÑI á‡\AÐphP8I&É@’ 3HÐ]Pt]ïk½¯UT¼Öˆë}®çz»ºÞçºêª«ëêÿªº§§§g’€»ÿ÷}øítwÕ{¯^½zgUw¾'~ºû>(q ÷C(n µ„Ç5ÄBñpl\k8Ò<®­9iM„;ãê# +ZB‰º¦mê7Ž'< ö^ÙÞIH´–ðÚhlÕ¸%Ô3›h ðú–6„¢ª= ;Á=§²ªrI%ÂÄ=A<`Q[8JD¢­DÂUyèá3«ˆÔü…Õ•‡-¡›©br:±4zÌbv´>ìý}Ð|90ð#”ÿ¢}à-’Ò^>È1÷’y>ðyý«"­á…í-µáØ’Pm3ÉÈ_­ 5ŠEä³Ù(M‘8Béž@­ˆœ{<œ8¼Íû#÷Ý :ô—\à¸Jzsjv´¥%Úº8nˆth0 a˜Â­³5Wè Û››õ:h¬cHÌ¡Ú: ‚tÓѹN’.òÁX‡0 aÔ誕¡5¡qÍ¡ÖÆqÕ‰X¤µqJf˘ù^â®Ä¥P†à ÅiR‰ÊÕí¡f’Ôà,$æÏs„åûgã7ÚZÔjãy"‚·¤´l<ñ]nÐ`2‚G>ûT÷ÍBciS´9œ$0VM"d®º½A nFºàŒf»à¤˜fù`¶SLI•é3±8frÍĘ…dGƒù4š…ñpHúL’4l3¡±“üÏ 57( Ñ`!i¯Âk¢¶²M¬«^s±)½ò 'M.Öà0²8ƒ9©%>8Èêú¥S@˜¼{ df›T…,ƒår¶J¤ {Õ¥Eµ+Ãu‰)R1ŽBÐSª&p„zÒG”ŒõÀ1¹N‚‹ÀJJ5¨¥«œo=†4h  © ÍÌmÈBƒ•ZÈxX'™nöA ´’ ¦gÆb¡Î¤OÉÂû‘™Ìgi’óiëu>¥´R12p'Ô‚öæD¤- ŽË™k WRRJ«ªÁšÝГd¯}åR«å6”j™ÖžD8ž¼^(­‡DôëÝbC2¼48‘–¹hZò? N"I“Iü†hw¤Ú¾ß’®:íݧъvd ±[ •CgÑМCC§¡ÆóhèîF¦îßÑÐŽÆN›¿±!¾$:»)‹{à" 'j¡Û丕¢TEâ /\ýåÏ¥>¸ .—®¶žÔoÐè,Z±\ƒ+¥%6·5…¼üžV´6œ·×PàQÓm¶Yák¦ïI´³|X8NÓàzâ jpÝ0d^Ø7É8{3…ôݲøªÔ¼å”•zÿAÚŒšˆšBrN·‘Hm êVúœ;|p'Ü•t¤öY"4ìžÛÙ£ì#SÊGmõAl#§k˜{&TÒö/ÈÆÓÈÀŠ?æ©g;|p7øHuêšÃ¡˜÷PÊÌç#Z‡Ñ2±‘¤>ñÁ§ÐÝnmL4IÜÏ¥Søež£çgO&i†¯/ˆÆÂz¢)Ôª—–O  'ÑÒváD)^ ªÊâ†(%B cHÙ…&IÉ6r~ÝŠ‰Àêh(Ì¥—¹o†¤žVºWzbÜÙ–LŽÇdãgj&©éS치$º$ª´\ƒŸ“î¯ÎÖ\áAܳ"èIÙKRà¤lHµPÕè_X²dÓQIØíCtòî°ò¤˜ãC/ö±Òž%±ö°\ïL Ë¥– ö%½Á~½&[¦ŽdÓI%ׇ $­^*¹Š‡Œ>rf+…5eLšoo]®WÚD.úpîeå¾¶•¡5/K¤À½­<Ù¾¸*î’0JϲRF88kÇÔ=Ÿsº"’mmo[Ž5†å¬÷õá~¨[³¶õ’9u3k9ËáV9aCI«ÃðR : G" ÏÕI@­É G'S t8uÑ•þE‘pœ Ft YnK–€…=‘Œ´Jý5!iA°^Ãbº„5,¥Kƒ†ã{FO„cká5’÷ {B4×It­%:t¥2§ÒµžÚ©ò—…’QÉé¡Öz=®J!â>AJOLÑ’XHÃYÙ§[Ý©'ÁÔGzs¸!As$µÎÍη !il’󣎆8˜ÂNïCè±p]{,QS®’©_]‡†T›ÝÑÒËÊ\L•¯« !ìçPÐêpK¨5©«ŠÆãÍáx\êêá>$o/û`)>−©¾ô¤ªE¬•»‚H… +¯!U.£ºÅt.æŸÚižm)‡Žó¬¬¸jiL©8Ï«IzC¿èkvjKšbáp6r/)ßO¤^Q;<’ÐkÒxH oе|‚†qÄ#;ánôçmòk’±²ñë$…wz˜è<ŠGz­ŽƒÄ{r³¬v­9Ó詤t­š*åëÉŧ˜HtÎK´4kø±täD?%Щ³¦×N7kº†ŸË˜6’(|«xh³p*4üẩՋg.Ô—Ì_RU9mxdZñðé¡‘m¡XhÊÔY‡MŸ:NöNŸ:§²J¯^²L‚ÔÒœcR+FÌ[9¡r”á)ôÒáÓGR½ü~ƒÐß1ŠƒÝ%‡YwKñ;Yüsw÷\³Õ#专k°„’] ÿE¦°²½¥MÓ™QàÿYf]õ£2Ê­GׄcºÆ8-9Z¹HÛôæÐºNyú¨ùȤ¹¤Bc9r¯‹(Å ,é˜%$ €õñ1Ÿ€7… ±~v¥D —D×B¾ßÇéø”\öMNdN¸9ÒUžhs½^mÔØÞI/apGú§Âîå=âcûHz>$jRFDí-ÄÓ~ûNŸST’ ×çL›ST– åL+Ï ZcíO!G¡5Ä¢-"ùŒXK¨Y 6ÂÇ`ùÉs aênnauSlÍFùàYFÿÈlû†™”4V<°qº$Õ›£­Ä+²B+’.¢!i–CŒó±b¹[“o¯çŒd?$•xSTFsF†î æ×•å“3b’.Ù<¿u Õ!õ²t‰…ê¨ $Jús^Û ïÚ çèù£Ü_{íu¯~ŒÆ¦Ê§u¦¥¯±éÉY˜k{xk¤.Zo.¯Æ(ã`QJјei„¹¸IC©OÓƒúz¡>‚Ìêàd‘kXÑ4Š9›d(ïbUÉÒÅÆyV@*"ʃ¿Œ•ÅÉT&mŠYFñ0ª6†§tk~ss¸1Ô<3ÖH)ck¢²£.Ü&#9 µ<²F7ÔÝeÚm¿¤ÝÖÌo­wh¬&9v‡j eŠ…ZãÍ* èôfÙÚ©¬i¹ÉŽJºƒô±?:Ì|Ó ýd±KÙIÀÚÆ¯ ·Æ›áH«Æˆù¶}m$Ѥ'bd"2Mc¯ÎÇêY8²šSH=l‹$ÓŒ(æÖ§  ±iL\K¤¾¾9œ³ùøÅ¡D“Æš“¡§!M´‘'*$¯­>em–cJöÉ}Ïùi;²Uä¦x=R3˜Š7U‡^¶ú÷aíp©‡QåÓ7@}X‡lï¤1Ò÷x4vE›¥û]j4¶ž.dh'Ð¥Lcé2Gc'#ìcDLšE‰uF‹eêû)²0,™U:»Œ O% [ZRZS&§vºÁÎL …¯6ö;Mÿÿ•S{—~Ø9>v.£Æ ·PØÊØÎ6ý±Æ¨¶Áñ£Ò+5FÕ .ÓØ¥t)רåt™«1*?p‚ƨ¾Àƒ4Få.רµv1”f îñ•ås'U ®ñËÊ',—R¸ÉÇnN—B©Æþ@ñ})‰ü6ºR&ÏîpHyN´½6ü]fÎ*!Ô­t·´FÞm#äÙ$ôt]F<îtð˜Aä^ù¾ÆÊRÙýtW6~ÙrbtÍŠ–é_®sYhYcѵ+âá„|Ù"M£¨¯Ì>5bªSq§«½»ý{…²™Í‚4{ª™›‘VÇ!?{ÂÇždT5äXÓv+ /ïF£4Fu—gµìÙäöŒ1²Y"Z{m$ÏçÕá<×iìE¹ÅE×—’‹†ey7½"‘GS±ÀÇSŽÏÞHFskÉÚhÚ¦£*"G!4á½ ¾)ðÔf!•pì{Wê³O•pK"-áh{Bv¼ïcÈÚί:æ³’Õ % Í”m~”Î…É»”¶:ÈfŸÈÒ³Ú4öÍbV¨•þÓØßHUâz(Öéáï4¥æxTc_Q«ÞkÈtà=P.¥þoÉë+Ê& ‘òìÁÆ8fѶ: ™¿¶Ac?’æhì'zܵAãÐÃ`ec5ÎäFGGŽÆÍçµ Ç­ö-j5®%3yåÀÍ¡65î•e^¨³¶Tãò*I‰A)´«#ÔQסñÜniŠÜO&ª¥™¤DJ!ÈW{8e̸ۻì²±¢b£?ªñ}6»d-‰¢6inŽ4Z{Oq :Mz‚œ_Ü;‡ÒºÆN)-êmŒ´Ô*½UpkCµá ïL2¢–HK¼S_V{ùr“-m”Ø…Þ™&Í–hKX‘/¡b¢=Ñ#Ô±^S‚þÜ|=Ô¢`ˆÇN$íÓ£ ´nò6Öª·„VFczc¸5 5zçZ#9hù¸ økÂá„<„’3ˆP’nÌ„*XA_Õ]«FXEæ—Ä+Éã9__ÝM¬7ÈMÙ¸ÞDöEê$þ\J ô!É<–ˆêKC䛣ÑB)ˆhŒ˜$…H„ 9-*2v›2lFº¾¿—Ńfö·iü„³Sïéxÿ/Üj|$1e¼,ÔØYéý¿p«ñÑÉB»5’=¤3Ûëôþ`©øâ…>^ÄÇ&O‘báZyì*“æ8B°»ä1Ë{U/NW(ӜǪܛȩsci.–ß“¿˜zL½A£¶‡kà Ÿ‘.µyy9ô÷òÉ|ЇW$_~QÄg'k*Z9t{§Ÿ[ÝS¼|:Ÿáá&ÉeF+YŒ;SŒÛqÍÎ)}øl^éásÒÒPbÊÇçòƒÈ˶µSB1)K±ÜsùœÌàø|ŠÆÁ`È”‚ªW·«”€âãU|I/†Š‡î_FH²HÔQøvRŸÓÞÖ‘Þ‚"1?Ôx!/$Oz<µ‘DK(¾*=A’„&§·LÍ*å,â›>Å,ÎÔBÏŠ$Bm·ùR¯Š¢ýTóŠ9¤v¡Öº°8dÿ‘>~” øýþTPçTx 5fT+‰Uè•4¸ñ¡ÖŽ¥B.e«4^KËD÷Rœõ>æ `#0z·r,Y÷ñ¦‡—q¥bnûºuv©tr…:š"oíX’#ö¼r.Ædb=bÈ|€”ƒ×vP`¦Òi¯4Pi³ Íѵ$O‘,¯ÑÑQ¬ñu”“%Ÿéñx= yV¸A”«ã•äûü×2Y0Âý„}Q maMðUåÞÐIÌ„°_sòÅ–zò}Ò&J.ùoTj¡Ö²SLê©§5N%Ù4þ„ôÀneÐ%²é):ÕT*›žññge“Çx“L=ïã/ØÚ¨zø3‰ÏÛZ'{_öaÑø«3fÌЋJK K&éÁÒÒÂ’‰úŒ^]îÆ{‹âÞ`¸Þ«›ò^yàë †¼z~ñL¢CiÖëTÂ/6Þ¶M¾;7VòN•¥<3/Û£—m›ˆx"Öf&ªMæÕêÈ‚¿Cy…ñµDƒÙV¡(VZûž¿Ï? X•Þp@/!8¹§Ã?òñ9U›žH¼²¥-Ñ©ÞÝ[®ñϲ Z2V¢üÍÇ¿;@œ ™ PZì×”J)—èÁ)Ý¢7XKâû&‚ôfß‘}*„Â2=X\Xl ÕÖÎ÷ÙpÆοL ”ˆ„4pþMÆ<+Tï5tKã?g£QN~¢¿%ó%Q%qAeëÄ=W]©‡ Rw ùqKÔÅØÁ¢õnŸðª{}v€ÔjÙÞ Ë’æ *Šgš,';Y ¨ÁœëÕ£Ñ`C4ZòÊþ‰²¢Ñ¯ScÌ[Tè Ž%i¦Ä!UÔþtåîé@›ñÎ8BeÆžÓžë´L¯–hgÖ×ÏŽ*xʡğð˽Ÿê[Aåc#ù[Aåz¾)÷ñ$v’¾¡JìÁ8ɱxˆBÍjùBùnÈêöHÝ**Ò£k[õ†h‡žåDNžZIk{ûDžBÖkL>d±„0oÏ^‚4f˜õ%]1”Ü´s€´­‘±šØ¡T­äÄÂ’RZˉRÏäjÉ)d™¯.§D³&O;¤ÛYÝáäÌ3Ç^MPH‰¶7ÚvñëŒN¡úº8ÉK±ÁË„žy¡^Œ4 Lµ¬°T­’´¹4T†õÈDr£?ÅU¨ûš=Qj¶!‹%MIšºœ0Q.¤r¦ûéëz¶¾:ZÑ&YO2õ¡™|Íl©4¶«ãQlÚ}Ia©â£4+#Ù†öšéÆb#ÝXÖD™ÒPŠ‘ÙPÒÏ|•†j¢¡Æ~’}¢58 ›RHú¥‰†L²ù%rJÁxÊ/‘¿•®ÉðKRÿ'úÄ$¹¹âm3óÉf…¥®ò™RLyj”¿%z¤•Jȸ&¨|=Æ`¬Dzs©³ÝÈÅ`¯ô¸ÍéYø(Q|”š|È)Ì )ªˆÇöü"AÆ^³˜M©œ¼Ü”5èJ²•’ì\„ÝV[»=Ðl±±ÌLüÊ'–IÛÊk™bE—)³‘8gþzƒÄ%s$…'»`åAŠn¼%`œLÆÂmáPB¾öÕ7ÜÑFS¸~±ñÕà!ÿ…÷²Sa5•%V·Q³ Ô¡‰c‰¹î+.›œ5QKn´&T[SW_n¨ilª‰¬¬YÕ\ÓÒZm«Y«‰'jÚ×Ô¬í¨é\WS\RSZV3¾¼fÂÄšI“kŠ¥öÖûDX¾—ffq‹‡F+ÅJ¶©¤Q„-fj'Ã]áxa‚5^=T«.uõê"eM—Æ&u‰¬T—UÍêÒÒª.Ñ6uYS—xB]ÚרËÚué\§.Æœ T^X"#Õä¤Ã”S@´°òBpò2a¢ºLšl 5_EFšú2ÕàrgÀ¢E~¬è&[¢Ù„G‰´X0÷¿3¸&âÙ¡ä[´#œ—¶BÊ@{"çÕå½!ÞÉiX=±áÕÕV°a……%ã­¬½(ÚÌâÅZ„Å)µÖ õ&½B/¦k‚®%ú/êÒDg6ª%Ý£öÐe£z\6áR•"~…Сä¤Ä¤~¥:Iy‹hÚ%Ó³‹Ø¸ì 2BN´¹~‰Yi¤%ä‹)’o8‰ä×ûÔ¡‰íÉì¯ÐgEɹ…ëÃq½¡½¹™ft2…S[Ý¥Ö=(I¨DãŸø­|ß,×IJ§QlËqÌL"¿¸$¿¸4¿¸,¿x¼I'½Mg °šeš8 Á]#!èþË!wÃm[(–ˆÈ- AõêP3Éq ·&9Ö2àwãŒE’¹KŠ3“…¬Å!]ˆ ÕпeôO“QË'Åžj¹´7y¶F[eAp9…°L±HZÖ°ô¼Ò,—€åÝš;ÛÆJ·µ5Ë·ÅÕ>qܹdT]sä9vØÚR” w­O\'®Gècò,1SçüÙ?dêö³wóÁ#n ¥;r¹ž7ùàUq³ãsÅnßà³}®(þ 1o¥E½¼{@MÈ]9ïŽ}7q'„=ÉÉs±ö°úÑÄJ«RFŒ c¡ÚÚHÂYr&"²/i”ïù‰.„}ÒZl¨¤ÅÒv„ Kº)žìdílÝM%aÚs©Â [û‡â¢¿gû{9 ¡æxØøÕÄ}TwüwÂO§&v©|y6/ ÈÚ á"ù/hýS½ñÂÈ4ÌŠ4ïl„ õxKH¾Õ¢Þó#á<"¿Å—ìØ%úXwŒ¥ÿs0–ú× cOôÄ¡ÉOÛdÂô±’±ÿ:æâÊ™Šó.¹TÏì&[2yÎ*nLØ…Q=®>Ùjh –Uö ò¥©h´A&‡Ú°ù6ŸÒ=cŸyħɿבú<8¿É£™¸ñájþèlB#óM²œp,­h‰7Êo¬©mE(yCiÿÙß%³Óœ>%6‰øÄ7ð=aªïQKdÓw>ñÏTS©lúÁ'þÅþHƒÊvs¢3þÓ/ø‰\u¤±5”h‘ŒWd=¿è‹Ö¬”Å.æq!ÂÀlãø\Ü%hÅ" ùÕ³ü²|¯Ñv©Ï7Û§ôq¹]šÇå‘ïôgôú\9. É¢Uå`éŸÙ›bÏ!Çèó¹ú‚&¶ä7Ö²µ¿Ï•+å.äÜä0~Ÿ+ ¶ì›Bñ…Š¢»¥SNqÿø’!„õÿ½o¢ѲÐ@ùCÉRÙ/`EÚ©ù–â­Ž¶ÇêÂs#ò»û!Nõ§ÆJÞ)W›ßJov³ü(ŒX2ôgê$-‰õÙ;­ˆJŸ`@à €É?>EwLþI-uÍ1¯^u•}ÀG¿}éi µ#]‡lƒ~wBîVH—A[a0]öÞ CîP¸ûÐï^D ŸÆMÆ€ ž‡R«Ï Ã`_ÅÅ~ÖÀUŸ¯`'è˶Áð­0"EÓKW€±DoœŽÏ¤CžÆ¢³’F—}ãý#yÁáí/è‚ÂíPìïQ ü“̆ £aj²!5žŸx˜`2 ‡)PSm㎷ƞeÜmãΔÃÌ1†™ëŸg6œ×lpŽ;ƒÆEãΡq+³Ž[•'Ó*ʾïü ÑEþC» z;,-ð©Ý G/ãþÕË„?T½Ì寫^æö‡«—yüÕ&t„ ·ÁªÔ„®“Ða ½BB‡lÐÿ¢$ôj:.¡ÛÓi¯5 ;ýÇ%¡eB¯—Ð'Hèz½„>Ù€>Åjútú ½^B¯—ÐgJè³ èsýçÐ)ÑíKŠ 0ráZ–Ð ‹àR8ƒjø–8%Æï,1^#;‚° kvÂ…¤}Wýë»à’¥AÿÆå*y)ð_½6-ó_çßÜ7*VnñßÚ·oƒ-Á.Ønaí4.÷—ûíÈëý´×ûÌ‚\g\¶c=êܪ“]kN“˜;áIbúé…æµBˆ ®Í0'(Ÿ¹¶ÃŸ¶Áóþ?wÁË]ðº$Z”½ãå¥ybë¼íð–¼?É…ƒ\CűWÿ|÷PäíðAU¸ò„¼¸‡æ¹»àcj rÉøgþ/$§y´®INó\²Gr*׉«u:tú­!'³ úÃrrGÒzEàZ·¤ðÇ’z‡hÕj©·ÖBŽƒÂk„S¡ ÎüV÷h­Ð ÷B6x VÃûƒŸ Ž^hÃAÐŽÃ` Ž€µXXXëp)Q“ºp˜tsXcꂆ‹áKøŠ´áØ _Ã?H«î…ËàÒNôÏ#­ù–Œóqâí$êuÝr Ü‚î È1ý@wnø—©cµéáß–ŽF#Ê1Ïðÿ´a h°¿n²´‡.tmG­'ˆíèsÝK Üj½ŽZÉVÌç!þÍòy;ö¯2•yA¡¡Y…†2šÊL ,ì€ÿ'½[ôn5éihR¹é…)•«É—˜àÞ •…JµÜ™:—'²÷Ò¹¹-¥sã ÷0©t;†Jgª[žK^<Ãò<†ÖZ'ì¬Ìó$§nÍ.€ƒ0nÛtRzÙª~דÛ=fÒGàdÒ¨ß@œB­¿¥»Sál8 î3aÝ=Dšø4œ /‘n¼瓦üûøœ‡#á" c9\‚S)E¨„˰ .Çz¸[àJ<®R:x]ÒS#8B¾ëAÚ²š¨Hmd4ÒSJ9u¿ÒFA#v)mtÁ«äç¤6ºi¤¥JÝ4Þb¥¥{ ƒî>Ýe0Ç¡I½t½HZIáÕåå$'Kv]8,€û›MRTît%©KS’þ‡Õ³+©4‘¶Š\þ/¥Ñëøƒ¸ƒ³Ž?J¤ÓÝù½°60+ù1éŠÖ=ù!òCvKzA‘NÃN>·Göô“ì/s§,R ž”üÆ©-ÕP’ÉRYú°,O7€Ë2ywenNÐÎô”L¦yzT©ä¼\I¸3õ¤W£Ù¸J63Í›ÒìtM5…”uÄäº'¶+§ô+]"eåÛQÛìÔk-MñoÌäh¾+‹%ØEpH÷ mt®àA½pd²ìéID œ"ZäåЬQá4Š›ÈC^K™åuä§7“§½ò”áJ¸^†[à-ÊGþ ·á¸§ÃxÜ…À¼¶âNØÆFÃvV ;ØjØÉn‚{Øp/gp?Ÿ »øø#OÀƒüVxˆwÁÃüxTÌ€Çļ‹ŸÃ{l,|ÀáC…Ø•ð {>eoÁg¼|ž2[Þ?i¶Üã4[Õb˜í%–ÙÞešm¾Ãl/ à6³õìIè'‘\åtèãr$úWÿ׈öç©[–ÜŠÚÔcD1ÊPf ÀW9çL JÒB Êu™,mv°t¨Ó¸n´'h9ެàf'Sp2u›3E»Ã™¢Ý•–¢YDsÝNª¹'Ù\ÍA7}n[]nsNnÇn;Ì»á0iÈ=;̽8Ì”1/„ý~IÆóó×drß@ÕKmT5í¢Êè{øÉôpü›j¯Ÿðwð3>ˆÈš±6äì÷èb¯£›½‹~j6η xX†³ ø^Ë€¿3 øh9KÏV¼ÏaÈða›!÷¬·Cü+œóÔitW9Õöq‡œiÄ'ÿ?ó fá/ø%“xÚ©ŸÏ8õó9KiÎ,ÉAÊÿltZ^º3ËÎÁ Nþìäàe»ù{öTè¯fåÁ–å†ÓŒßâ:׆òz&Ûoö*¸·llK<ôöF3ÿêäí]'oï;x“6åˆâè ûB.öƒ½±?ŒÄp"úá4 Àg8½pÆõd®ã|÷aˆÃØÜ—ý÷cÛq¶ ‡ó¡8‚Œÿ¾óùQ÷SNàhÓ äò'`´NàCëdà2ð¨Ó‹Nÿö‘Ó¿}’&EšîgIðoü»)©¯¶ã·©I÷“:XS0UX˜b˜ŒN¿ƒQ£Å`ô{‹ÑoLF7¥++qúƒ“Óœþä  ÒÖ{`€±ôXe&¶PÅ„5[æ0͘-óÒl¬o€õ7¤M_˜`1lÀ8Ká,³‰`“)‚ 0Â!£E‰€ ´¶hŸ"¸ˆÞ{¹ÚÿžRÑzH€ J7æ]`¨’̦¼…64Àö-R’Òƒ²¹°¨‹ 7Dî»ng#å¶ëFcÛUö. °1ÛYp3¸*„B]l¬ÙQbïð¯°2£½<ÙîÐe6Ñ!6Ù!6%ËD¦ØŽ‰Ì °ÙÆD*Ó&’¾÷`9È ¨6ŸÚóD:ÁClApQИIñÒ Çàøœ _ §€(j¸ùCMÝŠIM‰pN‚Ëp2\‡SàVœJáú@x gÃ{8>ÇJÂ>„R‰*tã¢PM^f £ŽËñHú¯ÂV<×â <Cx.µÜ„aÜ‚ x?6ácÁç±ßÅ¥»ã 4u÷<<ÌÔÝËp>«¦ô…غ㸊Ùát',Í6à Í>Â2îMã>·—=·©Ù¢³a”K “tu±eÛ¡¸—â°:G+­ º :)SVǸ&a æa‚LºÎÁ56s>×É$Xæ0g£Å˜ô ëp´ÜœôËîž‹‡˜=ÖcêRmæœ sN߯4Édº%“±9f’ÍJ¦)×N'%:uø‰$ºupëñWp®‡Gp¼„'ØDø²%«2Dx•%•Ijeò°î‡l•Zäz»X‹¼Ëõ’Ãh¹¬ÃßQõxåTReq܉ÃÝx <@žÇË༾Â+á•èūпÇáx5–â58 7Q&v-Öâu¸¯ÇnÆñ<o¤Þ›ðN¼Ä[ðEü¾·â{x~ƒ·37ÞÁãl ÞÅ*p ›…]ìCÜÆ~Ä»yÜɯÄ{ø]¸‹äòä/àCüu|˜¿‹ð¯ñQÁñ1Ñ|BŒÀ'E>%fãÓâü“¨ÆgD>+âøœØˆÏ‹óñq¾(6áŸÅ]ø’x_/à+â]|U|¯‰á®r|Óu0¾í: ßq-Çw]+ñ=W ßw­Ã\§á‡®Kñ#×5ø±ë&üÄu7~êz?s½†Ÿ»ÞÁ¿¹>Á/\?àßÝ^üÒ=¿rà×îRü‡»¿q‚ߺÄïÜ«ðŸî8þà¾ÿå~T¹J‘s˜¹JûR¶‹ý‘2’R÷yìö 0ö!Så/\å/:] ¨‡,¨‡-¨}ä»…ì‘dNãJ9@ü³›×(ÑzÔ¨FûG“ž?že‹Ó±`O;c׈µgLjö\Vr§ÖÔØ )jVÔìe‹¹t3ysœÀ«Î°jcþu‹ù7wg¸Læà ìy¸·ÌÙ½iÍN>÷ÙÊþªò }+{¯Ç3¥ÔQûÐà½Îÿp6Ö‰“¢úÔ)øÏ²úÂÁ<û25Þ×&óߨwYGÌ\›ïÂÚèÖ¿;gìß©ñ~6ÆãàÜ/×ùJ†ã ÁqŠ6Ðfzjâ8M˜™bq—Å÷˜ äxŸlªMÐ}ãÎ6¾A­¿Im@€l‹ï¢5ÏuìánìeV7á*—Ú>5Å`Û=åƒRŒ ÎΘÒF£'4À‡,ð¡ ù*‰ mÛ.…Æ®ËÙTd4M O8ˆjª«~!À÷-06<¸.ßß5µ>ÀGÑü…>Êè¦Àü¸qgrb@ŒY˜êÞ΃枎C®‚—çÊæK³FÝx'Ê÷gao†d˜€uÌ [˜c98‚yq"óáy¬/^Ïà­ÌO‘(€o±øÛ‹-bƒY=Ëc÷³!ìE6”Ï`Ãøál?~Óù&6‚ßÌ£X¾˜ÊF‰J6Z,bcD5+KY¡8š‰66Vl¤û3Y±¸œ•ˆkY¹¸M·°‰â6Y<Á*ÄŸØñ&›*>cÓÄwlºŠód½+þiƇ½ÅÛF-+ªá:¨—;$b)\ ȱÊÔKrB¼ ûð^JwŸ—Ñ˪~ ªúåã­]“¯Õ›òìäå˶ñ‰UÁÜÐv>ɳOÛÁgqJUrkéÙe{®£ga=ø<Ó}vñƒeî—F'7ßA(×ï ”«ÙH-4µp±AËQþ³ƒ@có`›óØ!°˜UÁF¶~ÇÚÊÿ ’'Xp&?ŒWÛO°TË%€Ã­ ÑÆëåx§R÷ú­üƒ…e[ùò?Z3žVxHëâuÛ¡8Àm<ÙñX«¬Æ—ÕµÐW3«1.’í&äÚït%3^´?4ÀåI¶­w'ÛN°Ú6Zp'›CÄNQsømгӄ½û Õ}&ËÚÝGuž-¬ÎsÓp ñœÏ§ ü"w’›KrµT«–BÊõ’pü2s¼+üªÜ½“HWs«u“m ü#…tÏDJ½¿†Ðíaê!À–ÀhvLc5p[ì(8žç²cá:V ;X=<ÍàeÖŸ±|ËVb֌ւ£YKYÄbx4K`kÇãÙZ<—uâel¥­Ç¥Þ²Ç;“oÙóÍ–9}a~¬pu€ß`.ŠÿPšD¿IMójòþõþCE²é6sAýëÝɦ;¬5¾ËjÛjµmó$ÛvX뾓m†”îU‹sŸ¹î»üÍÞ-×.%¼ðÒízÄN Ám„)ìdXÄN:v*¬e§Ã ì ¸ ¿ggÙ>1¸ÚšüCÖfä21ùíÍ];ùÃä%0p+,˜;h+2˜ëßÊÿ̰•?üß9á/šñn+)À_¡òZa°‹¿a·â)ßXf’§¹ú±‹a »†°KAg—Á&v9lfWÀìJ% ݘ®åqnæoò¿0Hø[jùÛ–xÜäj%üõþWC ºø»;ø‡òt\#~( ªüñô.­íç vñ‡ þüïþ•±|_eëþG€kt›­ûŸþƒÑýCf·Ññ#u þI€ˆw×çÐ2_ËMS”«I"›(œ^ eìz²°`5» Îa·Âeì6‚¾¬ì.›?¾>Âà,%ò¾,{¹Ú”Éä€ÀªäÜ7ŽFº„K²/rì}}ì}ÎÑ‚m‡¾l e;iÑî1ì^ ÷Ùl²õuV¹ÉR_(}©þbV°P-¢Ÿ\BÑßZÂý _Nj‡ªç·Š\%:°ËÔ`5 u‰Á±O@ ÚÄœv?»@ŒHƒÍïvT@Œé6¨`‹b\†žW@?ºý#éùƒ$ª‡`•“ãÙ£Yƒjö8Ëž„{ Ö³§á ö ™ÿ³p3{¶³çm«»ÃåP¸ÂÔ}%Vl=Üü樑¤A Wº0 Æi[-ß„ª€˜œg•$zJ2«3º§¥u§ò½ -ó\]â@‚š• %»æP×AYȾùÔW•ÑÄábcèÃb‰­Ûqp/:ª±Øô-®€8"ψæb‰}Q¶ð—(§ñ¯_Àåw$C쇖‡šé³ú8$·þêŸß·õR¹i¦ÒQ“.ŸåiSHÙ‚:×c/‘-¼L ü ÙÃk0˜½N‹ü&²¿ÀöÌe%ó}ŽbïB3{Ÿ|ýp ûÎg“£ûî`ŸÁìoð9û‚ä—Ø}…Ù×$¿¢ ù Éo±Š}‡G°R ü>•µb£©ƒq±è«N`Ö™çz®P§yúârq”:×; ©ìÖEdãsëÓECM4 ¨}äWˆâË"G™d“!ð’¢1¥R Ñ”±Ì+¢ÙæZ‚o-Ì€n£Ü6f, Pk¢#l]@Ÿuy Ûû‰âËϰq?Š3rE¦ss¹ ç¨å¬ä9pï§rœËûÂ5¼ŸÍ%þûÁ¦˜§CXüZ‰9éÆTŸŠõ67f$ý7a( 6êtb@œä˜Ýþf©ê95cƧÄ™âÙqnvDê9?ñ‚€¸È@¼$ .ËŽH=WdÞõâ™úòìË’ðÁ$>˜·7,åyp,«ùPXχÁ9|_¸„ï7qÝæ´n6×®4—òúWZâº*).q#‰ËMc™ÊHS‰TòNüÞ˜Û òs–¨(°iA¡(Ý%6'?^,äpc€ý‹¬ºˆ—u‰[Ì{!ïå7`" n ˆ;Rýq×/¤³5 ¶Ùèð]¿ÎŽ€ØiÑ ˆ{â~Û,ÿø ©>wOõÑ_Hõñ€xÒFõikmþKôÿ”Ƶµö–!%ÕÖˆRò£W±‰4úY#*¤YiËóYP ½!œ3pÒGQ´{ƒÉ÷KñŠM.Ió5G<}(½oX”ügÄ_RmÜ:oÄ;ŠNʼ? ô øðò` Ï'…|4™ø8@„!Á‹` §ðqp/†{x)<ÆËà>¾çå(øDôóI¸/ŸŒù¼ñ)¸œOÃ&>ü@<ŽÏÀëøL¼ÏÆ»ù|ŒWâ‹|.¾Îb.>åòùl?„âU¬„/`SùBVųåüPVÏcm¼šmà‡³3ùìb¾”]Åkؽ|{ŒÉ^âG±wøÑìc~ ÷ðÜÏC|?^Ë x/áõ|óå¼A¹£é†[1ÝQ!gº£I|„xO¼OÎê@>T| n ÙDØWâC.Y ˆäw¹–ÛR˜Êm1hV3·¾„°ä@T¸]|\!Šä¯+˜'ò\i[+êý2¾Ü|•-Í$ô–s0ÈdŽîÄ*W–mõŠ9y÷ÝÉaú‹¿S2)ˆñ%ݹ¬®úÄWÉ>ºsQkl3Ùì$|ù7òƒ…[Ä×Eô?Òyÿ-ÝkÞO÷ß›lÛþËqØ›'àÞž:X|‹í|‹í|bûGÅv>±ý# ë•ÿ¯N€‰Kªâ'ÅÎÛÄž¤pÙ¾è__]UÐ%~^¸/Úár!ìBXP¸ÅÕçNØû:äG•’ª/_an†nqõ32¶j&Wˆî`ˆâ„K¾!x»µ(ÃåwÔ¼úóu°??‚üx¨ä¿†E|=YÀ:ˆñm™@B¥Oý!ìÊ%[@J£æ¹ºÙ2J}ñ…Пœµk/5ëÓÔB‘ôvº`Ù6×ંe®û&>}3 Ès +],ìrí½ô$ŽÃ‰«þÂÆ«z«‰ÿ4~*äñÓ`?ºŽægØø+Àг¡®aJ‚˜×]ûR]¾‰®òj  «>#?L×ý!çÿPKÈM’4á7}PK\g;5name/fraser/neil/plaintext/diff_match_patch_test.javaí]y{Û¸Ñÿ?ŸÕÖ‰¼–e‘ò‘ØŽ»>7~šë½ÓõÖ…$HfB‘ IÙR¶éggð²ìdû¬óD¢ˆá`æ‡Á`príÇGäGrÎü€\SÏa¾Oú®GzV¿5¤A÷új„ŸÍô†%º£©g ®b¶Z›ägרŒœ:Ý&¦^Áh{m­ëöXsÀ“š]w¸6Z?V‘ó*ç¼Ê9¯I®/­.s|Ö#c§Ç<\3²?¢]ø’) òæù–ë³Ù"u$¨É¤Úò²˜ºc2¤Sâ¸û xX ±I—b9dÙuºŒÜZÁ5ÏGráâ<ÜN@œÂ#øÕOH¡I¨îíím“rq›®7X³¡¿öòôðøõÙñ*ˆ,ùűd}[¨Û™:‘º´‚Úô–ütà1H \ùÖ³Ë4ˆïöƒ[ê1dÓ³üÀ³:ã …X( è$̨Cjûgäô¬FöÏNÏÈäýéù‹7¿œ“÷ûïÞí¿>?=>#oÞ‘Ã7¯NÏOß¼†_'dÿõò÷Ó×G Â/ȇMFjbZˆ%ëqàÎK‰€V„¿ýëZ}« ª9ƒ102po˜ç€Fdļ¡åc™ú `ÙØÖÐ hÀoeôÂŒÖ=œ?!#‡Y³ïQŸyM‡YvsdC™lìŽ+@¢!»u½OM´ôCx"A–Ýðø¾çÑéKÀmG“æ+^PÿúiRΘŠÙKËùÄzšœ4·Õypþám=ÍL}>‚;ó<²3ÿÜ=oá¿cþØæbó?AôѸ¶Oº6õýŒ×¹ Ð/ÁóÌéù$,:òû£G„Œ<ë†,ó é (}zó X70rtüòøü˜<Ï¡i š’ ÿï—ý—ùü8IIv§à9Þçó4;7`]¬ï7®Õ#> ~Õ—-pSdmz 3O`9~À] ôk ÝÎGàÔäOž ‚Ãn3dut»äë#Ìø“£Ó“r~|vNN~y}È=O“eÌ¥ÂEÛ;t‡C×yë±¾5I yÄPôà"‡à)àrŠ^¨AC$b½0/8þ<¦¶_¯qѺ žÛäõضIì¥YkVõhfÈê5ÚéBzm2ýR[^æ8–áí:«N’ÿº–¿a¶×!ëc6ø«ZVï¯]hÊå3›/œ<üÏÆý*øûœ¼Á³™u5ü#ÞÅø'øö!: ¿,ŸUþa>ªrðAíþPT¢,øŠÉÔ$Ö¨Ó_a:BAe-!Z”.äÚØÜzú¬…ÒIKœQŸ7n) œAK ð_äóAgpÑ…3øõ·ß!ä,ùãÇü¹Ö¾–’JüÈX@¹Ì¬\±,I óåŠ$‘5IÊXE®WÐ$Z£P2æk0î2¸NC‡ó̹!iä&(¬R÷«Áš_-f²ú<üÇÍ?øE|Ÿ§iÄŸ!›a6Ëän ´³ ¤ó ÿOÓzá¿©Nü*Ó"o ªRM¶ë@¬@T é¤çÞ:Ø'è…†Þó9]¶î ýöH0ý Dà¢áÎPˆ–›Ä„MÚëÕk5õmj®é¥£Ií° ‘( ($¦JËN$o˲É>R¯]Ž[­–Á?Mqà&‹k¸ µœ(®d>‘BÞP'à—ºj\¬l×fÔ›8/:}r2i¡¸F(&±m‹ÏrXr/jªþ_z:ÔîÑÖbû*iSÜ€b…¡Î¾r=ì C˜mnlò›Ð"TÅv«5«P Q9Æ–ý^Ì묬éDõ芞Ànyš•±_» Û ¿\Y ]Ï,æ ¨ÅÆH"ášt4‚ÎY– ”%$Â4o¨=foúõ:¦/“ɲ|àk6sÐô­/¬¾œÒZ:Åç±,+RÒèpQ.i–n6[þ@ÓfÎ ¸Ž2MÒÂcn3LÛHSo•\Ñ8#ÿÁå*ý‘úÜåÙ%›ËÛ&³û e †ZXÁ”àð‰ŸPøÜ³0bNp ë>\ç½]Û,7™ÄW‘–—à­¾›¨“a;*La<ÂV”ÃÅ âá˜]ä±Ç;º¾ìwãí¬ÐŠ–))…蕫۪Р¾«f;îÔ$ƒã4=E}Q—’9e+¬ÄNÝ. Þ~5‡»¶VÝ·þé¤ÿ—œ´¾J‹>Épù>êCÖÝë¥àx%­<Ï‘ƒ]:ãÑ+æ Xª× îJ†Ì÷§œUIG—Q?™E,’ZçíÌðO̦ú{Ú´{a˜Kå3ºµl ίïj9–ÕcbÞ‰ðnGyw¡8ð¯0°˜_€D§’vT¥Œn߃‚=f3>ñ”_C«*:Õ4s`ÉBAËA2½†±¤ )B†É"TQ÷”Í”wû÷RÀ¼[Fo4>VV#G£@‘òRÚ[¨ÝˆÉ> Æþ¡’à5Î<Íã{e $éÃîè$ãÔÎs¡`ÙVÜdÏ ˆÍúAaS÷[æÒZcŠº<îIk¾($ß.º9åsO±#]5©'÷hÄcݱç[Å.¦£Žv'óÀ5ÑøamÝ(T^‘~]AtgÌ«8¦>cCêV÷¥ëû¸^'^ ñÜœ}#¶;°ºÔ&wìô¨g•ZÐÀ– ‚³-.ßßßÇw4£xtt¤£‹O íøøXk2 ÓýÀ¦Î'ѥʷœ„rjÍ@•唚I2¡Ü¼ká%2{®äA@Å>mÂÌK«5TI‡&™Êókè´©suoy<d("_mؽ£øÞõz¥QDm4½„„6j/.´™ÀU-€«åj¡xP÷q\Й.¼«Z #­ ¤¦IÒŒZëÃŽ…÷ p9©XœPO±IJç AªÁñ°h0§W`P½øs…ªQ;_¦QÛž’À³n,+âÁ—Å„ñº{QmŒ ªt=5Þ¬Ð0ª+膋ªïÝ.^‰»â§,1J67Ng¸è›UÁª£(™OÿÊ «ÇT ×í~áÚæJ€ñ5zÊfª×Ì5øh(qØWƒ¦‰?M%+5ñ‚2\tyD«ÃÊ—ÇþÁÕ¾F@cß¼‚ÿ‹‰P£ ¡VFæniìr*G¤ÅÍåq¿ou-æt§ªÓ Ã絘r¼:îYÁ¡Ëç_×wîÖ˜&Ä*vüÀ"TÍ‚¡®®·|¥µ²ä4£³ÑbæE#P©Å}-ïýïüwì­ò!¶*eâë5LH¬b!XßTç×cU±šäàôP¦¦‡IA^Ñ.76ïì¹â¥³l¯áâÎB®h6Ê®ùãºÝÖàštQÝ*ßæb|›­û¹®¶Áë± ˜¾†v²¥wqËœ”l²³1ÓÌ c¤ÛîÁ^gwí`Op?îEÅ ÀwÉ‹«Þj»go÷_“óÓó—ÇÏ/kÖóÖem>Qîì¼ÛÛ]Ãô½]È›œàD0ø‡a×ö''ǛǛ;—µ X<¶ƒƒÇƒ`§ƒWkürw ¸ìí‚ JVÇ›È,˪û˜G;½Ý5xp/¹e(ÖDÚT~dvÎ&Az§Âp4Ä`Ú–.ãQ`È¥‰Ü:÷‚ËãáH]~¾ºà˜zžàæcMè ²kæQÕ¬lúešg¨°Áí¥÷EÖ0='L®egFìp[7ò£Jnf©B´z=›¥ß* í*‚•'nWQy=ÅùÞU^¯¢ryâõŠ*—ç¼ñ°ølTÁ§<ñzâ*`nV±ÌÍ”÷æf0˯W!Þ¬B¼U Ÿ¯UC»(hx¯ö•¡‡¡&V/7ÕQÊ…šÇ¡’G[M|”;Gmº±=Óð«:Iœ°Î!Eó°}„=¾÷†yÑNèxœ€͸õoŠcì[¹EzF8EU±®«U>V£ùA Û†šÇ‰’xSMü³:ÃAo–…ÞŒ _?Þ8Ùü‘_ÿ°±ùÏòGî¸3güög4öGˆÆŒ*Z˜«Eùf̨€–W¹ý°*—ƒŒŠhy•ïºå7eÚCMû´¯nä ³DS”öa%š¢q‚äû ɾz3ô§Û{x·wOV©ãs¿ZTrN•z•:¡í‡U¹|@Þ®ØÉZ€sŠƒä#u”¼® ËkåœS¬‰ƒ‡Ûë{í°à+šxÆ…UÌë»ÞÁ1°ôÍΜ/™¹Ù}ÒˆæIÓõS¼ŽJ5ùšêÏc^I%Q%Ódõh€hAZ´·TjOUz<-¥Çј¿‰3ÇéB;4oÀ›/Ú¤ðÐɆ8¸F-§¾:²ä›ùžCšèO‹Ôd”Õjnôõ*u0—mr<,å …¢°‘Ô†SÕîæF|pU“cª•ø›•ø·Êð?ù2MÉ¿^ÌŸõ'×Ö\9p ÌâðÇd*•*• ºWâ|: Á2 cëÛî­NƒÈO&‰× =ã?Ì2°>žbÏO ‹ß8×Ò*Áû²ã°^.ƒ© .ø8dßÎe_ÓiYö®þèádø§„øârš²%]m\/®ñUlyÊÙ( ]I³¬ V UI‚vb)}n%ÅQœ ‡ÿã3_µ( Bk“éþ-‡OÅU»Œ¹e2â€?­”цBãT!:Ò>Aá–Õë9S¢E,øÛuE–`wôÙóƒñ ?É ©è?JUÀ™lÍüú——ëd"òUYDº•½t]ŸÍ P{~I“øäE ³ór'8IÆóÒÊ*âÙãÄ~”ˆÑ ŽôeÑ¢’[\‰iºpç_Ú:ë4Ó²¾·'na5“êÇñG!ËuW0-lˆ²œûÅÍPÜÿÈo…ÔW® ÚÊq€"<¦Áf“X±õlÞ•F»øôx]X‡y@_KLÇç;ê¸wõ¶l5üMçcj›Uoø«ù=q#\qó6~S5ðkòø·Äš­Ô-ÜEiá-1þ…dÆÓô=¤3¶Â{Å3…ßáÉ—ŽM¿Ìî$õ«ýôY5†ñ”¬˜fÃØ"?ýtéÔâÒYõ/@ÛV ±˜8jñ?:³BÚm‚fo®œó²²œÑ8ðA”h%d Ȫè½s SBmÞ5@ÉEȽ)FÃàh˜ó£¡_’šM NŽèÂ*™ éç¾Çr"cAæ±T‰VH{B,RﱑÇp*±·¼HkÿŽ'æWŸÿúÛååΓFs öÿòÓ]ú×ãëËW+¿ýÏöemwïoy @YF“®Ð,vݞ졇­¯l¦ì.m¶’,m,m-mr) þ!Ì¥c!ÈÒÖÁÒÖÑÒÖáö’i.µ—ÚÇ›ézi0(±ìR†r0DO>qç+F¨Çb„+çÔ]ÙûÁ.ꈆ&*úša³(—zQÖìª,yLb´è¤–\Œ6“ÑYЏÀ}Gï™7¶Ú.¬àw[(M©`ýã‚]-n5 óâGëxlÄhÀgô+Tü åld[Á+š:kß÷Ç|‘F¯dwjr`/°|Ò6›e"}µ×Ï5$l +íí\t{¬1¸¾°>^|²/†Î…;ºøì]øÁÅøæâvr1ýrÑ2.ÌöÅúÆÅæÖÅÓg­Z&õC-CTôȇ¤á¨V8’ƒý«ÆºèF¬\€5ÐŽ¼èöä·¼\Ë \eÄ/>ÙòbèÈ w$/>{òÂäÅøF^ÜNäÅô‹¼É€`£a`þ,н Iˆ¶È/š_lnÉ‹§ÏBfåœWQIÆ…v—«ÙÙ&ù;=´ëÚ=1’“fFîÅ>vŒò»L‹¨®B_Œ~í¬q מ' þö÷,ý`žð X'|kÃXÇ$WÓ(‡4ç4Mt2¤A®É6iÁwß™+)1 §ús’bž *ÊxdØä%Â?¹C˜ÍÀF| ì¤åK©øªÌ¡ Ä ÆòÞBžw³Ëõ õE` ã‚ñ™ß69p¡ee½0ëm{v˜]X÷ r,¡â̸G¨Tqy–‘HöÉÃÞâRËXj™K­öRk=’pæîÝjÑÅÞ#çsÀ9¢.MFà(¿‹»ˆeþð ÎDp͸䘶wþ>À‡þ8Âø»ºŽë0… ë„øð÷ €N ¸nÜMÀ72ÙÓÔl€z)M‰)ýädÂnOejª¹ÝT¥!¿FÅñ=YJb)Á¯¿AÇÁÛAZ ÊÁ’Î9[Ñq]|- ä€W|©:äQn/‡9ÿjü–ŠäÄmøä!Ië7ì˜]5ÜI²ÓÜ Ù)íˬ¨©àe€«úÅ'<IÖìjxzƒG;+˜4°0-°‰³ÞË"ù¾>R âŒV1ŠI¥dÅU<ߌ'Ô²Yb VE.>ò)¿T(ê½Ìd1i¸ˆ8z‡A™‚+È|ÿV¢?þ³HŒï¯p¬8¿€5ˆ?¤6‚3M‡¬¨ü‘Jo5ý7Szñß±ô:p-ÊŽQé-„¨6ëÌ!?îØìÍ72³T•/ZVìãÊ¨Š“ÕKÍ–`nDèÿÚ%¾Õc„õû^òáè0ç;õŠª†Äï]·Š»Õ¶^G “<Ÿ7íÂzܬ9”©©÷Y¯Šcsè6 ó^à­¤j”¢ ¾)¯õx9„Pe(¸ÓžòÛ*øìoo £LGÿ«íGžuƒoá#Ù­ÒáÚ\Áz5ô¸3L×x¸zðâö±áÇî³çý&õù¬møv²,m'CÛQù‡„4" ùÝÉ×ë¥å0ÿÜÅ™[ÿ‡B§¤œ Í>€J+îvÒ+гbÒ&žáÐÐÉ«½äfô€rš3õnTçàá3Ñx¨G¼ßÈ[èŠs/‚[ñ† p©×®¢àeÇ#ž†ÉÄõ¬Å_¢ÞL ê4°ºñýì–|õ²”ôªa¹¹ üwîwk_“Ô|ÝÄpÊ¿¶ÓObõI]¤5£½ówˆõ.X'xÍh¾ S›bÊ?f!&²ye{.óiâp½õ×ù³+2YùY}•!{âõM±!ó·Ý äûc§ËE@x kO ÇÍ«?&†ã¥(1e±ðZÆ¥j6ÓHgÈ…ôxC¥7Õ—aÌŠ¶)~†kÙ“XÄ„‘¯þPKOqW•½ ´PKïv;6name/fraser/neil/plaintext/diff_match_patch$Diff.classT]OA=Óv·.­PD‘Rk?€Q‹ˆ0&U $øB–v€%˶nƒ‰Ä?àšHI41>ûGŒú¨ÏêÝ¡xèÌ»÷žsî;ýúçãg#x,!À0lé[\[³õ*·5‹¦V1uÃrøŽ£•Œµµ•-Ý)n¬TÄš˜!‡„Cë¦þR×LÝZ׿W7yÑaPÊnëŽQ¶Æ çA?HÌ1„DC´ðŸ`Á± k¾5N–áL2RÄ?‰š^"Î|¹ÄâI…‚f†`*½¤€ªTÑ€FaEUHZ †ÅŸmo­r{Q_5¹PZ.êæ’nâì;CΆQe=—LÑ\ªRvÊž6†¶Tú¤â0®¢Kµc—à}SqÝ ’͉¦H:b©|þ ½ õîémÃ,q[Fœ¡AˆI…у„èÉ †öÔiÍ7U¤¦ëÑ+n•†zã‘;©Â§ËÉÈ2âjP@ Õ!0Lñ´@UÑ+ áŠÍçÕ¢;MüŶnVëŠ8Pö\tt\ÅmŒI¸ÇÐõ?$oêÕj^¯:³;E^ñf›•ާC¨áÝ…³’)(¼PÞ¶‹|ÎcÒ^?C"›A}bYÜvóyUÂ4CöƒDãç½ÓY†± ½ż6úhüƒô‡ ^YL¼Úƒd)Óz‰NË!âZ2ûP3Ù=2öyï¦^¦5Š CHþEþfù'ÚÈß륡1Àµ s-A$ˆéN}šuŠЮe> ÒŽ VCgáz–£}ûè'oK Éh¦†,ý¢-5Œ¼;”éò/4É¿]ú˜çÓ7!Ž[DJ×)Â'}í×–Ì~#Áò1ïJj¸ó ¡ÝàîÓà®+>Ž~Üõ9cTä¿hVÈO{¿ÂPGÊO–ŸÄ}ä!A¹x@k“®ú‡˜rÛ(òyêý ísSÿPKáQÌÛÓPKïv;Dname/fraser/neil/plaintext/diff_match_patch$LinesToCharsResult.class¥RMO1g·„„mùN¿K 9ÀB»‚KEU¤JU£VjP®ÈYœÄà8‘íEåÒÿÄ!Bê¡?€…xÞ€PI{h{ñ¿yg÷âòÇOÛ¨Q`¨iÞIÇp+L¢…TÉPq©øæ’CÙéô¹K{C¿VR »?¨÷¸±_…Í”+"d˜;â'9ð§Îí-†Oÿrò·v¼ZXŠ2ÌGˆ°À¬o´J`XŠpSU"ÇèQ„i”f}ÒŸ³~[˜}ÞVÂç8H¹jq#ýþººž´ {ÿûˆ[åWùÓáÍåæ 3©ø ýfù®Øÿh†è£ÖÂÔ·VØ"V6ÿ‘Ü„'¼¢8úQ„>'B¡O)¯”±eÂÌ )Ùû„ǘ¥úPHu)>Ç\¼9Âb¼5Ârüz„‡g¹îcZ+¤ Hš<&MEgûX„ÆêÆãóxŠg@޼–#ï¤#ï%ÀsÂÑ57Mžb_änVð’j™8FoZCtPK¥`ÃPKïv;;name/fraser/neil/plaintext/diff_match_patch$Operation.classSmOÓP~.ÝÖ­TÀ!Ó1|GÝR§‚Æ-‚5Y(-,Yü@.ãJºné:"Ñ¥ãD#ñ³?Êxn³ðüàÚôžóÜžóœçÜžþþóý@s*f=ÞFÝçmážp\£årÇ Ä§ÀØvêõÍj»›-¹N®µ„ϧ驈0 íñ}n¸ÜÛ1L¯Ó`ˆ½5-sÝdxeõÅZ$Šòªm~Xgˆšï7-†Asuce²²hm˜6Ãë}3ÇK5×ñœ`žAÉæ* ‘¥æ¶ˆ#ªaDÇu$©|©’ÊZgÝÙïx;År®’ Ø:¢ˆÅ¡JpS‡*A\‚qqĤ7¡#aØr<±Úil o¹‚!i5kÜ­pß‘8ÜÔ `„ô»N›$ìs·#Èy“ÍõÛ­†IÄhÆ"!H…àV4éØOBJ£v5ÒEÊ®F»HÛ‡È|#’ܦU# |¡‘ùŒ;á®|èOé•,P)UÍOMáîYbŒÐKÒC_Å=©”ßǃ^úõ'ÓS]dªËJþ¤Z’šV”Ì1N½ÄÆN%$hPò=Ž<íÈ·Z’å1õ ìßy fBÏÀ³ÐðíÓ¾ì“ÖñƒZ X;¶ŸÚ™ÎôÏp¦EÛs7*PGÌÌÞ{î¹çžçïÜ›?þþé€*\r£€¡JWFÔЀ©dT3¤«Z*”N)šn©'¬PRèQ¬ÄP_ZŒþ}btÃÁàVŽ)¡”¢†:û‡Õ„Åàâ†åQ{/k‘®¨¦U“Q-c52ð.mPW¬¬©2ìžS¨):ov£±™»2–bZU ,2¹¨fp§T}Ъš¢ˆ×æïê‰ÄÚvõŶ·FÃ~aÍŸ³æÖüSÖü³¬u¦US±4Cg(8$ì4iºf53,(+ïfp´IUÂËðb‰ËŠçŠQÂ2s0”ÈpÂÅPH{jGv¤_5cJŠ’S5Jª[15±Î3ÖFÙ­™WŠì‚Q†<–Ñe™š>HN••G§‹—ã6 ÖÉ!¹±aÅLÖ¬–Jª¦Û(ƒC’° ån”Ý„œ¬Œ*(éÇ”TVí`XV™Ã¤„õŠ\m³÷Ë»=ØB¹®Ü"$«eÔà!ʺ’N«z’!8בY¬¼ß¶±:kñ „z4¸±•aÉ´p„Ò7¨šB Âƒ&R¥8Ñ,Œ>°z®&U‹œl—±’ Úd,ÙYbÚ©j--¾ »(©ÞM_K‹$@•±TÍÈ2LjÉÚØ˜‰äù ±ÜØOȘ½+£ 1R«fV7ךn@·Ì·µ$ çQ‹ÑKÚËÊE8â8$ã1¦^6¦ÛááyArª¨(}PÜ8ÂP{_ dô#A@3̤¦+)»#"õª¨Û€¨[ÛÝêÇ Ã¢·ìä͆”¤×µë%èH»a0,µÅtÕ ÅDÃz‚:ŸÊð¨‚.Õ^2ÔÏÏ{Al£è,KFÇ(4S¥D$TŒ¶9b!÷NX©iŸ¤{dz7lš†éÁÓ btkø2'3–:âKjƧ–/“M§ ÓòÙqm–pψN<͸»Û±!Ó8.î#jOÊçd<ÈÑ!%ÓaçjpP¼ìÆK ó(¬ŒWp–ÚAI$ÔLÆO½_zO=Nð~Áo³5#×óÑ©I»4$>‘PÓ9¬ºÁ%S5EÑ£Pò¿¡Ò+¢ªçÿ2Qa¨p¥Ñ{q̾EÞÍV(ºâ}àCB“}‡Ò­ß@ýwŸ]ÆIß‹®uíGñ°`}*ã3Ár†÷Ç·G碌/l¡HGWø@̯VMg¦ÃèÊ&†Ú55•´áÅ uY3¡¶kâyZ6Ó…Íâ(ƒÑuÕlK)™ŒÄ‘K'ÿÏkê´)¬£ , ?&.šöè¦UÞÌ6ñVÖÄÛX˜ï`;y;ëâµì0Øá x15†µú9 Sj¦¨nV˜§,â½NTÎNQ—q#O]ÇUziÞ ÛÉ›x‹þCï$°½wàòzÄ#›‡m6Ûm×p¾÷78®ôLà½[?K{×ðUÃÑ9 G->¡…krñ9-œ¹Eï8…”xQBŽI®—¢œæ¹]Gs.¢¿ÀÚaØß×öø ¾¥yÓ%> q1œÁw-%$߃ÿ PK@’G? PKïv;1name/fraser/neil/plaintext/diff_match_patch.classŽ|TUö|ν¯Íä¥3ÀÁP¤¤€ƒ ½J4…’"! )VTÀŠT. "*{öµ»ìZw-kŲ¾sî{ófýû}Ÿþ2÷¾ÛïéçÜûÏþöÀÃÐ[>a‚@Ȭ(žî5«º¸&\Ý«"\VÞ«ª¼¸¬¢6¼°¶WiÙ¬Y3æ×–Ì™QÅ¿&hIg/(îU^\1»×ø™g…KjìQܰ l^¸²Žq B¼*]ZV;²²†Ë&"¤¨²QuÅåsªÃ5s*ËKÇ©ñcJœ’Qe5µÅ%aꛋ8Õ) —‡kÃ1m§|\qõì² švœû¸pDYm UÈžJ^nþèÑù£‚yjñuµ´ÍêðìðÂ^Ô½6\]1¦õšN,>¡ad—‰Sr FŽQ0|DÞè. §.œº0œºxpêÒN]ÆW…«‹kË*iEâ Z}j—âêÇ%5´¯â™åaZ;NE°•”—U”ÕAÝ{LFÐFV–†ýÐÒLhOà‰B{d9 bC8¡ui¸¦¬:\:<2ìÄÚâÚº5ÈT t´!­©^YE8¿nÞÌpuOKXÈ«,).Ÿ\\]ÆÏn¡1H-ºÚÐÚòÝmÐÁà\† &XœË²Á–6”(ˆ{Ùàwšô¶!lÎõµ!Þ);Á†§[Á¶`‚oZÅ´ê¡ô3Õa° ƒÚ´€†ÀP³¤r^U¯³g÷¼(@&ÖV—U̘ۣE¬òÔÃmH‚d F5LîLÍ?\7Ú†H& ×Î)#àeçýf ’ñ¹eLyƒšYÙ‘%±K%ÌÌ —æ™óPËfW «i“Uÿ‡¡ý‘-ta†8d Ÿ@1Ά±0ø¨&ŸÚâFtž©7“ÙÜÊ>óÿ/“üÉ-Àd&5’_Î<6LÓ‰ÂóI2b爸=¦š@ÜÚª¹õø¡ºšp&Âñta>˜Å&«ü‘®žx±a&”XGŸ6ixqÙ‚Œ7 g„™íg!äuÿ“ƒ4C¡“4sl(cøÊâÒR&´¹6”Ã<„d5qö¼ÊŠS«Ã³Ê"t;:ÊÏelVÚPó‰sjêfÖ¨ ‹Ýs#Ò ¶7¯áæµŒÝæ[ ,°áì¦ ›X7‹ÆýÙpœKdR®˜];GIÚ\îv¾ 0ãØ‘nUuµaî±Ø†‹XV›e5£çUÕ.b`\bÃXJbŸ€1¦¬º¦¶rS»Ô†Ë¸±IóŠ™Ð®°áJ¸Ê[dy¸¸¢®j\¸z6É`÷æ™’ÕŠ¿dN¸d.©š0Q¸Î)mÝÛÎî#[u«\,¹5 6òhªk<–‘ãPYE?sH!¥6#ghehÍ(ör3Ý\¯¬—ùç••ºÚŒŠ•äW œÜL— ‹6Dzö•&JCÔUÔÎ(UÞš÷XVÁÎ BäU:O‘:³ª’g"T´m‰»˜È\gÍ€ÐçkÚåY4QEølgˆ.M&t$´‡P§¼Öó§ô™‰i„×è€J *cÛ“1‰é$ߢµc‹kæŒ#é‡ÇB×8ì sLìBZ«ñzH``W»awv›ª’qul"ŒnQŸD…cÌ3Mڼ؊ävÁL²9JC)€™öi}¼5öRωӸ¤œÄ”ä°g“ `4-þß;lÁn¾ddW’²Ø‡,ìKÖ¥¹qxö71!¾ÑL6žÈÖq\IeE-QMÍ)áEÜ4dã@$ÏXÎ×þoñì™8$B7±ëôã`fãpÖæd–ú¼Â‘¤p‹ò‘͉RìŒcl<‰»i5eç„yœ\îr²²´{4^zžã bYÅÁ ›YúQmÆLk‹•\àühÖçª%Ž…lQmDÝÌ¿Â=b³¢'Û8Ow-žáµ¬¾s{ŒdhNe¤žq„âmX+U ùq›XÔØ[]TSžg“ˆ& ¹¤®º:\QËA·qeååeNìçdmÔ9dµà,$1;"#T÷¼JvußfÕ’ÚýäØù¹†hgî2gb˜äÑYL͈6VbUÄVžUYY[E»VÖKcŸË­º¡ÓHq8æðHu6.À³I1©‘Èsz+·³Y1ô§Â) ëE6žƒl‘{È…ÚÈÌÄóm¼ ñ‚úöæÕÌP¶ S»íhXÏr”käQŸW¼pF)‡ƒ*ëÈñQTj,`{µw$Cнù‡rHN“Å$|Vy¦ox4R¤¶†‰¢RÑû¬êJ7ò’UH:¼oóDø ¦Q²Ë¢.ÂÆ-ÈÖ~-÷Tã·ÐíÖ¦¸=šuþ#êÄ›l¼™e±‘Åd¶ájoQÆ$ bå±›¸–Œ“èZ¼ìèêêÊj ×#tϯL/-+ž]IÆxÏôô‘ÅÝjÓç(ã·gz÷(Ù÷ðãÜÈö4yùº"Â7W±À#qFe¯á6ëy &­Á ¼ŽwMä5¶oNvGD …[z5}z(ã˜-8?Þƒ÷š¸½‘ü ×xŽ÷±F“ó8â‰Ì<;mlÀ]¤ÂûÐäµ…ŽÊ2Èm΋¦)æÌë­~û°HÝ˪ôAÖ:Í Ëú)Ìõ¸™aZÄØMTO ›Þ&%dì&©ÏôæFM‹¨•í„>ò"üï„>"ãþB5ãD~›øt$öïpImqÉ\?î'YŒûñ9Ÿgã@«ª«™Ã/Øø"HÍèòð<Ò äxàË6¾Â´%kX’ïÇ×lü·“UDXôü†o²³¢‡9@Å=Þ¶ñ.‰#÷åT×}b4¿gãßÙWnßœ¯œWYSS®©áHýB »â~N-«-ã‡ä¼&[a×ê6"Ï#Ñ‘¦3ÜzQB‚stÓaþl´?±ñ_ë 6·‰%•†÷EGkÑÂ¥eµ1E}¼(XÄuÇ“SÅ›^ãÂÄäÏùÑÑA-õ¬nTÕÇu]÷Äâ gýø=þhâÇÁ9"£¸„\ÂÿÐÊjòÂ|ø1¾zTÙì2e}Œì1•ûþbã¯ÜÄ.«™2‡\Þšªâ’0W¶tÄ ‡ïœ>¹~(¤-4¡S•‚#»e}cYgŸH0 W”„y 3Îé4Ð/Lá3…ÕÌAÛÄ~¦:mV+O©4ž¬=»2âʸMlWRF³‘…´_$Ú"‰]zk«Ùw¬³#éæÈÁd@VÖÔ:…NÖ)­)žf˜Z¢ Bf„ÐÓy®tâüºòR’ Âé3ÃáŠôâŠôÞ{’4A[#ÚÑ(dÔL)cÐl0d*7N³Enì¯aCÖmîcô*t§© .Ìåp%G•š7i””Ëÿ P–W–¸RɤõnôÄ„ÈO!êÅNêHzÚAmí¢±µóh -ž›“ìÓÿJiyä–È"ÕÔÕ=iÅ]‹çUqô_o‹Þ‚¶cV‡iT>TÕ"ÿún± 0oôàN3IœÍ®®¬«( u3fÌèœÒ r ¸®l0i|1‚4¾É¿™ Oã‹Ñdev¢ NâSÝ^4åsy²Q£óš›Œ§j<™%NQ}©õG4ñÔáù± Æ(õâRÚ—6GQc>»ÇÑ‚z’Ž/WÔÌ© ³ñÐâYEî_¬OsɘSlqºc¸/d ]‰ß =ñÑ<›ð•ròÎNmå¨pym±%¦3-A>™>©`Lö‰~1S”š¢$"Ž+µ½&MÈ]QRYÊ-,fñ¡¢zlâÿ/ƒ¶ÉÙ•˜ÃÔ[ÆæÏÈæuK°?@v^£ûŽ=*H†v.˜SV“^£üÇôÒÊpMzEemzM]UÙ8éj7=ý¢\Ì'STTGù¹…ÅÌ©®<›xÈZ±û$Ù–X@ ‘)Š…¶X$ÎAèXW®))® ©¬v€2©ºldå‰=-±‘È0²2¥–½#æWx+­°Gzͼb‚ÇïL`Š-ÇFà\!%Ùá¡Òõ=šbIièñuµãg`ÉYCtFmåÜ0Ë ]e(eI?Ï‹-ÿ.ávÌû+¢6™GM‹ ÈQ% »)ÝÒãCQq¯-î÷”tF˜YVË^©Yå\©±ÄNâ/÷~Mzmee:{Sé³*æ$dÈ­,/+QxêÉ£í²Ån±‡èÁ­¸¼jNñL6;·Ìú^˜Øk‹ÅC¤Cb–ãÚÓm»çææ63Ê(ù¯*åèë¨QTBÌù¸-ž`'R8ìØ¦OÙð¶ØÇÃ5®žeÍ„ÚDeÑϨÞ=ãЩåz”|(§V8¯¸†¤±9³¬b†ò’Ý\i$Ç Èñí«KYP8áMƒ ß2Ž„ .õ±õ9Šn9æ]a³†Âë¶xC¼É—/fÖ8H>N-.)©£ž‹”!Z¹°lžÆãþ7FþÈìñ÷lñwqxÔ!„{Ô˧™|‹79Ã%+¾'GeÙDJ“ªj)-YYá‰ýc÷RÔµÀfï¤øÄ'âߦøBï?< ->›°¥°Ö‡GúÜ_p‘é˜Þ¼í/‰Ð`×~mÃÕp ç¾µÅwÑ®½¹è{[üÓµ’ ç}ÿÄV‹––*_ÔˆÜÔ0"w4üU®8˜>ÚÛWEpÍYC!Ž!n–h‹x¸ŠsäYþ*5„aÿ뼦©7qäm•ž¿ï~ÙᦿÞù‹À„Ò²Åi¢„?~®ö¿7î&Ò ñÌÁ²½~†ÿß!…pÇÑÒÍÿïkõ‹OÔŠL´Å‡â#G—–Ìá¸Xœ’êt¼w£'ñ„)‡k˜Àá‹Ø‚SþRx&8C—†ÃU#+«HLßüW0Idü¿÷q.…ŧ:bÓ§ŠBueÈÉÆ¹Ê„,“EÑû­Í_jÑP÷ÎèªÿJ°ݤL;é¶l-uÎu²egQyª#÷¹ò8[vå0­‹M册+^ÈUÝm±MÜù [L=8—e‹‰¢€s=m‘.:°*êÊËO(Ó9¨aõ<•Tvxa-(\ê'ç’²Ë%Ž×{rîŒ×ª0«Ï ’°!Y6ÛUÿãš—ý¹›&Qv±]©QY fêrÄQk³0-<òÔî/Â_aùگ˓møPžÒä:X‹·Ï¢çú£ÿ’•y\Â~dcдl)5ZxÍþ¤®§É‰¦œ9ø^ª±eœÄÒ®Æ9Î>®{3¢¢™óíB9ņ.òôß¹†:Ù’ÄÝ]¦–žÝ}Zif¬¡”dôHŸ–Ùèqذ.~(§Ù0Džù»·øšyÔ3"÷!ˆ­ú…)‹m9S]œsŽj,I6JëH@)ݹçÍqSβål¾Ð­s ³Ê’eä:oÉ¹ÍÆD#:”nIrÎ[5|^ei8½›%+ ÈݨS(Ý”óÉQõnZŒXŒWçfŠÊ:8rTÏØpq)à¼æÞ@Š’¨K6³i¨ãòŽbFjžÑ‚d;'CZò\_êmÉói-y!?÷mÉ‹èùK^¢êû[r)=.UÏ'ZòrzînÉ+ÕóK.§ç–¼†ŸûްäuôLãß êÇXr%=÷²äMªžžWÑóPKÞ¢ž‡[r-=‡,¹žŸû¾6Òó0KÞªúçXr‹j8Ê’õª¤Ÿ%ï ],y—ziÉ»é9Ë’÷¨ç¾–¼—ž;ógmµãÛ†kjºï#¥¶“ï϶òà ¹Ë–»å¾ÁwŸjøpš4íŸ{ÑÁ”{މ ØTN¬+™3¦,\^ªB®$þ'ªÈÓõÊR éP=¹+IåÜŠŠpµz©‹¤9Ú+åÈû”¤'bÞ'Ó•¨€Ž@;›R Ê!¿¢¥RŸ›úÝò8jÅi¼ûœ ê¿‘¥Ò$HVi §I~†¥mù2*ïI­Û@[êä×)åÿ’S°ŽY ¦Vš¼ÒïUCt¢_C5é U OØÅë^O‹æÚÜŒ]p\†½ºe˜;¡GFRúNÈÌHÉÞ =3’å¿vÂñê¡×öSµ9)'&¥ï†ÐN–‘2ÂÍŽŠN¢}ñ´ $ ¥§Q´±p ä´&A7(„,˜}ä[ÐW¾ ƒå;$ÆÞ¥ÎRmga0NR‹Ky'«ñOQ‹Cc3Ì­ŒÌ,­ò¶7Ùòjœt§;çÆÃ©ªÞ‚Ó('ÔØùÞØxl\IXd¬ìÊÌj€‰« }/î‚3BZPÛ Ó ïƒé™» ´fO jÛ3¸ÕY9z¦ è P229­ÎËRÉ8U]GÕ™ °0 S¯ê™)½§Š¼,™å=pó´8¦2àÂÕÐÊ›2h¨9/šM+LU±,#H€¸œVDK— ùD>·ÐfÖ9­¥²upl‚l¸r`3 †-„˜Û!î „Ü ³à.¨mpÜ Kà>X ;¨×N*ÙM¹= ¤Ó¡¤»<îrAʹåD®’ælÅ1BÕyDN.›Ps-ðÛŒ9p\G= š{:\O9n Þ­i §ç ¯'ç4~·§1ªt"zbßx("À^¸ ÚD ³2+Š©¬ÆU7Ç ‘ÑÁXØ f&1QVH)É¢’ÌÔ'·ä˜S¬‡âhUM²œÁƒVP—BT…7Ec,FJ¸aÀäqeD„…!#¤+âYO£Zûàô %û„|AKëò-½O(.h}BvÐ2û„â3‚¾`‘N^(!#èÚ*›Lˆ!Ú`¼7~0±nåyÒ ¼“¤Ò`g©Ì&Ðå ù¨ªƒZ@=Íçßwåñïöqü{ȧªvñðû¡çÜ&ˆH[…£ˆTƒ¾x@‘ìÃÞSsÛȿ̉Ky,dÓ_<7z"”LØû…i¡Äz8´wÀK ðjÏ;™ŸW¢Ë ¿cèïô%q¸^/ Æï†7wÁ»Üã`¼ß‡âëáÆ%~§Ú>²Ú®‡³~m#”âè÷LžþSZ[R="Oœ~°$ I ®hãáO2‚v0^üà«Pr=´ &ï'‚I{à\øÂêÃÒViÃÞniÄ_VARtÃD€HÈLÜØf`ü:%†Ëq ñÀ3Ä—Ï‘ú8@ö’ò/C&¼ ƒà5¿oP‹7¡Þ‚rxªá]8Þƒe”^‡õpøýÔóŸð|¯ÃÇTú åþMÿ _Âgp>‡Ÿi£_¢ _aøÛÁ·˜Mû:ቴ•|ø/žJKŸ¿â8Œ3Ô â\X:žƒ^ˆ^†>\…~¼ãq&b&ãƒÀ°5¾Œmð5l‹obßÅcðl‡_b{üÓ𦉶x¬h‡é¢vý±³a1“±»˜‰â,ÌU˜%b¶¸òWa/q/î£ôAì+Á~âqœ©˜€‰à'v ]Ø”{`—L¹8‚fLQ㽋©˜ªÆûml…ïKÂSk5QÌ#›¿-õHwИA*KR¹íÌÙ-Éaw%+¼•¬ˆ¬¡^θíHåkü*kk¼L#˜”Ü‹í wa‡ü½x,¥CZvÊc{°“€)™ÙAmGR/ËÍ{ak!‹Ýì]Ø#ªÖŽaÛ O ó 'À1X]pôÄÉJEu¦òTÔ@OE t‘h‘±“Ç**³ˆŒ5b™6üÂ-´'Yg÷)$FÔ‘jOÛ‹´ïIí¿ÞQGØKYgQ9[›dŽ&rt-»ðøYg&ùZöÎѺØq,tµR² Z@׊”åÀ™-;hì!2% Ð!hª‡ÅÁ 8ôúÝ8‚ÄÓ”zè“Å 7j‘µGóåäÝ8vž"I*™‘¢H€îL¸ñð"6 >ŽBs!°$†ÁÀل̹‚åÐçA'¬ ýVA·AX'a-ÌÄ0φ q!\Gm×๠êœ{PßÄ/ž)¨oR°fÃ`“‚µ¤ÿ×ãx"4`¼†„†cœ§)¨' 8ÄHÔPcü#cô¤4~õЃ"³o©”q?#“E¯^ùAݽš‹Ž|™cÔC‡ì¬ FÒ>`4à¤=XH ††Ô†ª"€m<üNPË&x턃<”ØÛîõ€×‰7—A/‡4$£¯„”ž‚Ë¡€Ê§ã5 8C”¹?ÃÎ %9Påà¤Aø'mZ’©?§oid¾fâ™#3º=H4~1³±ylO` ‡A»§gì€n©XzÖUÅ#˜rtVó9Dl£ˆ(ФúL}NÀÜä:9–ˤ!_$ã°k\$cã4"7%9»ñ³o7†C‰‘æI2'YM¨Wi"ó¤ÈœÔz¹ž×·rÄÓZ;®X:nÒÌgQÇr‡Ä©µ9­êqK åÖà œ®‡“‚qV.Ñ{¢ÊŠ—m‚öÍçЮ¹1ˆ ãñV¥9 kÔeÐCBÎ %RVAJ0)˜è2¨¡Ñ¶RVÃ. ¤z›¯‡aKâqIrÿþwÿ”&ýÉ«!zé› H› %3O„šÅMZx3¯›šâ£ðR:Æù‘÷` ƒÚ©<аÉP$Ë k·’WÁø _:ë‰Ùý\(!ä熒f…Š,I£Au ‚2/y1PÍ‹êгñ1\€Oâ"܇çá~²$ŸÆ%ø ^Ï’íñnÂXO6dµyŸ"›çü_¾&Rðu²ßéø–ˆo‹ÑøŽÈ£ô4|W”á{¢wˆøOq.~ ®ÀÅõø‘x?á¿Ä¯ø©Ôð3Ù ?—Çá²;~)³ñ+9¿‘CñßËÑøƒœ„?ÊYø“,ßeþ"ϧúkñ[y ~'o£ô<,÷ (ßRº&„¡Ù”& MK¦v¬°´LJ{ ŸÖ?ÔáVmŒHŒZ¦Ú؈¡œkÔPÎ1j´Ae1á©•Ö/RJõ­'^¬4Hgí8¼DÙ£'h©¸„´ƒC4?.eûNÒ—QŽlMù#^JJ: å—d«g“Õ˜FÆÁåÔ7ž°Óßɉ÷#e¡ÝÚ¡x¼ÒW ±ÞN.”Ïà¤á2ù(^Eã%Á•ò!d:ÖÊ»ðjÊ¥À‚Ò5ÀAÂgq+r„ •8 Úáu¤'dUßí”iÉdsª2¥ÙÔS»Àë#» \d7x»¸ú:ó¯ ëÖwÜÑ(Gví2j"æÄJÖ¢âA‚‘`¿Îso•êÔ³•Å™©¬,½4ǪÇÚÌ€eW]+eË‚öF–ӗ臃&¹Å·Aòàp$lŽp'‘ÆÎ§[î¹ dsÔ“Ô‹xÔÔÖµ@±eùSãyK”輆šwe“y³•ÙsT󮤶4hì¼+yÞ‹hK4ú}¾Òœ}8£®‡„½¸¦0×í ðìÓÿü®§¹ëé4ûo+—X‡Öšß–ÇÆ»®fNíÉWI#o¦ÑͱÐV¤Ãq¢Œa´èãÄqpžèKEw¸žÒ•"V‹LX+²à‘ ‰žð”è߉Þð«èƒ~ÑãÉßL'`€|ζâDl'ÿ9€„ˆ|Ç8R Âj1¯ÃðZÑo Is‹—‘|tùבll`¾¥²ñŽGI9æ`ò(q%IMö(ñÅS:[–® Ø–h{3ù‘&™å‹¯XQ»Ç%ÊGw /ã7)›PôÏ.E8оõ÷(º,JÑ}‡[‡…„DÂïÔN4%Ëk*×€·GÛ5JÒ^‹hÔ f:Äi¿GÜeQâni Ù¼±€ÑòVF[¸™˜%(:_Cç™¶Ôhpêú? ™é¿ų\˜Ëë !ù4b€ ĉ ˆ&L!8 `*œ/¦Á2q&ý4Ø fÀQ·‹b"ü™ð´(—D)‚˜…>1Ó)íLj²» ®”cO1{‹ ìO£D%žFéé¢ gˆù8[TãM¢7ˆ:ÜEéâl|DLÆÇÅ$|VœÃ<†8à1Ä!x ñ¤Ç{ ±ÛcˆCÜ~CðâI!*É—u³èY§4¥ÝÒ“ÒèØ~iñˆŽä´DOR¨ ˆ+AŠ«ÀËIÕ)\Nµg.E­ UŽWFZžÖ{·RÅÈØrç\IÏz¹6‚M¶ÚýôK „*>¥QýÙÞs衼Ä!J6hiòì¶åè»ÍÇf¦1¨µ:™õ`1[nU~s@ß|ø=ºdå ‹­ ‹»!NlƒTqÅvZò½Ð‡òƒÄŽ˜˜ÍPoéC½¥u—®CH5’”z?eÔhd,d©íè„ó®j;•n„MÎvÈ4uNr4ïçÞg/a’#¨ëBmû¡ÙÊû_h™å»pGHhEº*0‚ú~ðýÊk58¯³Ðèá–s>-¨›}¨OÐPÉf°‚z=è\]…4·[AA¡ šzŸ‰…ZÐ4(󦤄j5JÌ iö™%Ð |8(‡dñtO â)è/žâYIéXñ<ä‰P@i¡xJÄK/ÃYâ˜/^…â5oÀMâØ$Þ%1pP!cYP4"%¼JïIșϤ©Ð¼Ä¸‡æ/JQåÐ=ÊûBq‹‹¿ãC Í)ðŽ 3Ͻ«ˆÝ1ð*>¢ð:À‹ø(å,ÚÕsø˜ qw…ýø¸ q‡ˆ0žà4Œƒb|’r,7…«<3å)e¦,å€3ÅàÌÜ· ´{åླྀ¿p>“êˆï‰.s ™crPÎ=²êá— µ^Rª·]vÐjÀg§L§U ‘¨}$àäùÄŠµ}p·bø€q+ÜêäÌ[aU=$òПºC­ìo€ËéßF)·A&e“Á”)`ÉVà—H‘­! Û@;„²t”í![¦Áñ² ”é0Bv„1²äÊÎ0Qv…"Ù *ewX$3á<™EÞT&\'{Â*ٶʾDh'À“2ž“'ÂK2oÈAð¶ ïSú¥?’§¬É“ДcÑ/s1^žŒ‰röiØSžŠ}äiØ_NÄA² ÆLì™ ƒÝˆ©ûâA¥âS0ÿÿäˆ)vÇ÷q¶ÃNêTC'ív,~ BGl*Ï7“ÔI‡c&¨Ëj\ާ:£}DeÎíh ä÷쓦|žÊ“豕¢B;h{±a$¾Åš„këñ`¼KÁ¸\oªìÅ\zp\0~óÕ!eHËÈÊfG¾Ù*˜í¯Õ!ݽP LÙqêxSw­aµqa—[ÍÔ¯$ –òe‡üA-G³ìÂ3ÔÌÇEä@‰£‘=ûUM”M#TG‹Öå5®PkŽÍ ˜k#$ZÑ)³Õý† ¯†Òd5‡3ÚúÃË øìxâj(甚Äãƒ>e?÷a€*®j&%ñæÂ{Š—&ýÙsÇI Æ©qR"íB‰Áx>z&|&þ悉û;)ÊqïÉ”-C†!]΂œ ² ¦È³`¦œ óä<¨‘ÄM•°XV7ÕÀrY «eÔ˰Ož ïÊsá <8è|øX^ŸË á[J¿—‹á'y1&ÉK0K.Á~r)†ä2Né(yž"/Çñò œ ¯ÄIò*,”‹q޼Èkñy=^)oÀ•r%®•7âyÞ-oÆr>(Wã“r >'×â«r¾'7à‡r#þKnÂÏåmø•¬Çïäí"A–‰Ty§âÒM/x祭\.­!õßJ…,ÂüTY ‹±?S\ºFàçê¨í2,Å/˜Kqqñ—~ßã—ʘ?ˆñø•R!V*äc4ðuJjˆÅë6¤ãwø-ñu<™ˆo+O€|”JÛðaRÄ%­\^wz¶cS?=áZ u¨p]¦¢¿s]D·¶Ê!™Îá™ ZZ YÒÉ®†ð‰±µ?¯öj^'5©ý-)q=$¹õô°Ž£;`Xfƒ  z§§QYÎÓ*0¨:ÆPgu²änH’’:É'Hp?I‚{ “ûáù4äËgI`?GdöRÖ~W‡'d7C% s|¬Hr¼ù6ði9¾zÊ[¬Þ`™0WA<ŸÆÐÈ«a¼Rô<Ÿ^¾ þÆ-œ3)næäЏq$ë󲯏­±m@ú£¶AaÈ5h<§n¥©Ýðö˜àÄ#0ŽÍ_;b+Ðü1¶‚êÿ;†µÞÿÛ2ÏPˆJ¬ïa0e?$á#²>!á_D^ÿ&áS²>'á?d#|]ä7ÐM~ }åwÐ_"Éö=Œ–?Âtù_ËŸ¡LþBd÷+œMv®†p&àbMƒ+5nÔ,X£ùa½Ö 6kØ­µ†}ZxGk Qú1¥_Qú5¥ßPzHk?jíQÓÚa¼ÖâŽØ]ëŒC´ãp¤ÖÇhÝðT­NÖ2pª–‰ÅZ6ÎÑzá<­7Öjýð\­?.ÑàµÚ ¼QŠ7k#ð>ywi£q¯6ÕÆ*v8Ç!fÏÖx̳5vy¶Æ=ž­±Õ³5nól[E2µc[cƒH¡œ ]p­HUQön¸š$Ok’b}ñj%üЯÊÅÅX"y–È=ž%r[Äá/M7fBã{š… x­6³éõ)fÀq’C ä*)÷)Ëã?2$­z1‡oO™±·§@ ‡èý©!ôמþŠ–hÎ)ÃÕêfô‚”¡Fb ÕȈˆi¡s ü0ùY[…Wg9ʸžD “ËRZ6m‰<¼öð—M‹ÚÊÿ“¶ uîAr·ÙÓó1C^ž¥ çU09ËãESEé‚jg-nLN´vbrM¢pAC|ž)DŠß­¯]–w]Ïi1èƒ?V³ßïdtwQu΢¦Å:h:s|P_iÔ42cƒÝ4E´E,yÀȳžñ”V dEd‹]S£š•ꊡªÉr…@bV#`ÖÃP¾Q ZM!Òheb ë‡èJ‰PVÐ$߸…y‚æþ_We:P50éS+“ï¯É¿"ÒzƵ4b åv=ã"ÖrNÐŽd3ÕŽ¸ê`ƒh» 6ºH႘-Kwã –°°4–,‚vÓ>v0¹DÛ8q`·ßyØa³ÐrH¦Ÿï¬V.HFÏ Æ5EP£™ÝÍD;ÁÂ(‘5]£³œ–&'4Æí–×´÷ÿ¶!à?Boà³PM2è4HÖ&@km"´Õ  6 ŽÕ&Cgm ôÔN‡ÞÚTè§ýéošv&ÌЦ“I4ƒôC釙p‘V«µRX«…aƒ6îÐÊ`»vܯͅ´rx”òûµ xV«„—µ*xƒòµjø—VKz¢~Ð hgcœ¶“é/][„Ý´s0W;'jçãÚ8‹Ò¹ÚbÒ áùÚ¼T[ŠWiËð&íRܬ]FúàR|X»÷iWà3”¾ ]‰¯kWá[”þ][Ži×áÚõxX[!Lm¥j7‹~Ú*1@»Eœ®­Óµµ¢L['ª´ ¢FÛ(ê´Mb‘v«8_Û,.ÒêÅ*mªX§Ý)¶iw‰ûµûEƒÖ öj»Ä«Únñ™¶GJm¯4´e²öl¯="³´Çä`íqyŠö„, ôLz.Òž”jOÉeÚ>¹\Û/WhOËÕÚ3r£ö¬Üª='ŸÔž—ÏjäÛÚ‹ò'í%- ½¬¥SÚY{E롽ª ×^ÓFiohcµ7µ<í-­TÛ«•iïjÚ{Zv0"¸FΙk]]ØZ›®´ OS7 I?i…ꆡFØž¤nê„óÓÔ Cƒ0?Fi;“ð?R«üîoà[uûÛ'ða¥[ýâUܨ¬÷8)q¾²Ùí¨Í®æ_áÍߎ¯ìóÛ‘](:)ÛýLZ Ç,/WZOæ°õ‰*¾úUˆlÁSƒ>ï˜åƈV’vHóBACÜÒ›·@R@‹Fˆ´´ö›Éä²Xh¶JË!E”cð`ê"ØÖ ¹’]mC†'ùâ[Fû€^Z´]Â×>¡}ºöXÚ Nû´¯X_˜¾…Ú!¥ý§i?Ádíg˜ªýgj¿A‘vJ©]…N†”.á"݈¹„¹g¿_î]'»\tV'.…{’tu•ßV7/]Tä..Ç©àHœ+º*$u†á¢›qÎ3.w¢»èáFé§Ãx½&ègÀ }ÌÔgÀ\½æéÅDð“à= —è³by©°¥.À\" •°ÒH0MU„l’`:C «öP €¨Çq©ÄiGˆ£«Õeb€—š’Áª2 fÇÀpq ÇÑ“Óg}d+©5c·(NÊLJo³•Œ8Ë‘!£Cú^QNŽE™á¢êêSãöaŒ‹Û²UÕ5Wå‘_ “_¾ôlåDh56?ó³w‹³ó³·Ó ‡ÁL‹scŠ^CZKø¨#|ÔÁ0}!œ¤/‚Ó)=ƒÒ°~ÌÖσZ}1\¥_×è—@=µ¿W¿ôËà!ý xF¿^ЗÇàè%G/yDý´GÔWºD=âÅùâÂL#.T±½(Ž^òp´rAŠ‹Øˆ §‹cï'ËK¨wÿ=÷J>QcCF, éA½0ä{˜Gf=^ôÌ>!åE‘û`ÔãYACE“ýä95ऻ‰],f(eÓßùô7˜Ó ŸPš*.cãçÂêC9>Ac£‘/.dJ×° íw h®½î‘ìˆåwÝìz2»ý»Åõ9qÄi!;Úwel_›ûâÖ@Z´ÁM1 v‰UðKZ ®¨0GcŸÏ®‡~¡øhóÕÔ< )ÚYC2#„´Î"ž§p ‘4x´ÉŽx¯¶·Ò²#~c´¬ct† Ž;ìEìyQKLTñùßX$-\Ç7»¤MÍ,‰ûéj0Î%–>W¥Ë‰}ðŒØŒ™†Aq›KÒ@O"é[ˆ¤×@œ¾Zéë ‡¾z롟¾é·A©~Ì×ï";h+,Ö·‘>½‡Èú^¸A¿Hû^¸‹݃úNxDßK¤ý <§?DzõaÒ©Á'ú£ð¹þ|¥?ŽB }õýØN{êÏà@ýYCù“õçq’~Ëôñýe<_ /Òÿ†7Qz‹¾·èoâ6ý-|Lß ôýÝhßóHïEÌñ=Å ,ü›b) qø ¶R"®>#nWæ=°ZÜ¡´ö œ!îTîÅ.›ñK?í)wåöT÷âà¸ÑÍ=§j·‚üRÒ?Õk>'C_7×zQíÝdÔGXÔYÉ ÙÝ&îq/~Ü«^°È•êô}7n#u0Ä}V—[×*u±5Ï–Š¸Í­Ð&3ÍyäSw§eÚVõ«Ø ZwecUÿâõ/   mô¯ ƒþ ¤ëßB¶þ Õ¿‡áúO1Ê5דC¹ÞK$¹â~÷H:W™¸4þ6ž³|ù7™bBªPzÆÈYÁ,'È›³9ÚÁq Œ¬ÑÒ¨¢çò:¯p_êLÍ3-k—x ­¾[<¬j ŠÇZj¢nÐi¥ s,‘cf¹‡ÿE9qòAÈ–9ñõ–9¾@;øYø´€¿ÈíóZðü¬6)‰ Ä|¥zyÀW”ãøþ‡_åbz”jE„œîìâG­+'1¨ÑLIÁ¤@¢V¤âi-ã‰9ÉõðœzÉ8Y+Ý mƒZ&çøàA<©®­¯ŸÌIáë)«¢WëSñLK $óŸVÔS[¨-¤\0ž$ç‘¥A[=qÒs1—-vKç†=Öî»4™À€Ô¶© ǶM s1Ã8m35=mNÀ,U[^’|8HØt¸õÕŠÒ\ØÝ´Žt¤“Bö’x Ä386þvsÀŒ0‡þ† í :qa$@o# É0ÔH±Y锎7ZÃ4£ Ì4ÚÂ\#ó(­6Òà<£\ht„+ŒÎpÑVÝáf£¬12`½‘ wYp¯‘ »^ð Ñž¦ò~ðªq4ràkãDøÉ€†ÂxcfC°¯1 Ç#p‚1 ç'á9ÆX\AéJãdÜ`œ‚ÛŒ|l0NÅŒÓðQcî3 ðêÿ®1 ¿0¦ãWF~gt`ÌT ó¬º$½8a!# C9‡a2ð-±_½ëÖ_O‹gHò ÀgųêØí<¼T<§ŽÝn&ô<;é°ÏøeB¸‰”¼¹kÅ‹Àìz¬P·îâá ÄKêº÷×(ÅËÀÿÂìOð³x…Ú%¡^Q7F’ñx2^¥\ Žƒ¥nntÁÿÐZR= ¥Ö)^ãwìø{“®„ºËµ’Oh·LÞ,«8QOkO4ò·ƒxùøÕ丫ÁL¬£¾üž6zÀXÆË*f.¿Ç“F­ÙY0*!Ψ‚Tc>cÔ@7£.æÚ žô9AœïZ5'¨+~B•9ÀäÜýê€K¼EòG§-to;—ÐøK›ž…ú޲P³©œÝöåîKQd¡Šwó³ CæÃ|T¦3+š½YBlŒ?}—j‰Ž|÷uãá÷ÉL¢ÖÃ|°Fä?(hŒ~9zV€dÑ“Îoì !Jh¥$GÓйªgn<üDVÔïéIãB° Ò<ÆEÐѸ˜¸å"h,!ÆR˜`,ƒ ŒKá"Ê_i\°åÀ–+°Ù¸TQš ÚX,þ!øà¤#$‰÷Ű!0Þ½z8´—E|Õ“pON Í=šýÄy};fe±ÚÉW÷'{Xœ÷#'Kr-í™d‡Æ‘¶~sh÷à{·ÐøÏz¾qã¨î^ª~6ÞÙìhœSy7ßš ¾ŠZºs9=Ë›®ù¹]3¶Igç“Ë2 wˆoTè¿t§ø†Ÿ>Ž<}ÌOŸ©'ç[E;Åg\v¨qÙ¡è'8f©3¢› ŸÆ-ÐÖXK(]ÙÆzFé(*;ÍX³ŒMPil†s-$ì6Ãã6¸ŠHk½q'ÜaÜE‚íNxÌØ Ïwûƽð©qühì@ÛØ‰é4¯GØÑ3:ŠÅOÊüé¨ÄŸŸ%*Òàó3[üW! ¬?«˜ácP$~QæŠ_£ï2Šßã_éš&ÝO}¯$ë›!>ƒ÷0ýdˤ_¥e‡É'Z~’Œ' O£ï11¼ÅR.r¹±£÷vpеբTôl¦_¥Y 4Ô‹Õ­@ Êl渌¬ÌF‹ñ³Ü0^Ãx-æ£$ñÞâ]_Ë (: £ÞT¼çMý*} ¥_õp&ó&ç ™Ma ¾ƒb|³gÓ›Òôölzí0›ìÙ)‰ìYóöl+ƒ«PE8pYê&€ž½}¯ø¤p—Œ¹QØÌ™²²Ø÷×ã]A¿Òð5bÕ­}Ð9±Kæ •Dÿ”DøöðE]vã²Ït]ö­j ¾ ÄŸ›P\ásƒÄêˆÄ’ûòéÖ¾ÏÂSÝU‡•V=¼§zÓû³˜¡›ˆð7é†H±Î’dέ0&²ÙÕ00è#`mÙÂïÄ4^w“™›î¤ñ˜¡gtÌÎüE ³A&d³n¤µFÀ‘뀑£»^ÉámÝÔ+Waïè^r Ƈ'~;-:IJ“I²£Äö8OÄö%1ÜWh| ÉÆ·ÄpßAšqÒ ²íÿBWãgR&¿Ôù N7à SÀLú[bjp©ÃFÓ€ûMž¢ôYÓ‚¦šqð©iÃ/f<&š6¶2°™„ÝÍdd¦âIf+,48×l‹ç™Çà¥f;¼Æl7ši¸Þì„·š±Þì†w˜xŸ™‰˜Ùø¬Ùß0~Æš}ðs³/~gößÍþÑ›Ïø«'É~õ¹_{°#wH&)F9?PrNƒ4ü‡LVõt|O¦8ïÛáÛ2U]‰íŠoÈVêJì@\£b(¾˜×~ULæŽ+ùò×%=vk£àš…m‡5.»yÑ­‹#Ñ-ñI~éz6cŒêÍzÈšÃê‹3!œ’Ö YAIÐR$hº©ƒ<Â7Ì„œùx'+"Ê|ÆTÉ™CDŒY¬yfGÃXçĘêóOæp°ÌÐÞ Ç™£`°9N5ÇÀ”›c¡ÂÌ…³Í“a±y \næÁµÔvµ™cV¬ñÄÕqÃ+d[……öP+ƒ{ ÇÁ\y åt tù»q²w”á¼7¿FA×Cò9Êö2̓s†³¶•fN¥ÇMÎŦý!u2kb¡&Ô&nglÿvT7'³Ý;äàs˜WvqΠȾ;ü 2®¬zÑ5hEpÅgãlYèE9¾ Î~Eš#ä96‡…\§uBFVÐ/9O'àkÝØG$tWx ùbAÀ§‹œgjhljè'—ÒÞ­‡¨¸Wt˜ `€& êñ ÎhÊÿÌÑ©ç~£9ƒº¯ª°ý_O^5Ê5ª Å«@Žº*qž˜Œl°(+2¨'.ÇÕ£Ÿ¿X¤¾˜JˆÙ§Œ`BƒÌ\¦Š–UPaŸ•ë¼UÃk\ïÊœDg|çc;;}l')¢V–Rã@bƒÌÎIq ù•ÈÈR)EQ¡žäh‡,·Ø[& w{Þ|[t“±=©‡šÅö(³cˆôfQmC«|h,™æL"üðëël¦ª—C²Ýûã¢ÄÈ/D¸ [Ãhâ¶3À4§Ak³ˆ8n&t2K`€Y CÌY0Üœ £ÌrÈ3çA±YsÍJX`·Ef5,5ëàRsÜ@éÍæÙ°Î\ÌEpy ®4÷Šk)]a>¨$É£7{îñ&W’p™c™v‹e/e™ÅÊËÓ`”˜..Tò¾QO»O{'¢/gòû»NsâÂqYÊTHÍŠ¼Õë°ß¸È…ªl÷#vÞÐ¥†f_Ç1…²™AîÓ¡zXê°Eôá…›a¾ûRï±FN¼JÉŠQŸàÉŽ”D/Çè®Ñ¨ÅŒèRTÞ]HÀˆ.Cå©MÍíxKÌŽ{7Þñ²ßßÕwW[bvUà>7³«¦o¹:Fiã 5³ðì&oŠšOƒ4ŸŸù,t¤4Û|F˜/’š~ ¦S~ŽùÌ7ÿFjúu¸Ú|ÖšoÁ]æÛ°Ç|ö›ï›æ{ð¹y™ÿ !ðOlo¾Ý(Í0?ÀóCa~„ùæÇ8ÕüKÌϰÌü›ÿ!íK\k~…Û̯ñqó[|Îüß6‘ö½0ÍDªù_żUìaˆ€Ç¼—yÏÆu®1&EŠË¼ts™—LŨÌÇÍ®vÂòD>LD &ËêÂùs0VÝ|Ðñmà”Å0[À5œ¹V(s¡kô ¦ ©ÏŽr^Z“EêÊs\0λuç\ŸŸ¸÷Ód(hNX±Ý é¿×0™‹¾@ঃ¹Ó7„qJ>Î1‰¥ëÅóž™Çœõ€áq‘0#<¤Žñö TÄ3u'ÛlŸ)c¯YΞRóè_yüJ~YìóÁ?èSÚò¦;òBü.ÕL'Ë/a±©zÉÔ‹È]òìîÜ“´åYªYCÚŽ•ÄΦn€Ÿ¬ÒÛÏÙ CÕ$ŸE&!"v'1dŽå­?èóá"ÓN©‡=ü1nÝž~æ„s\ûÄß⸼FZñ-î®ZœÛßTÊþIŒYQ·˜úÆMÕà$Ôõˆƒ•¬ÖÝÞ Cý67Õì‘ï ;û“©[~†;‹é–o† WÄ&c?õò$}‘RW*öt¾ -"‹œk/S,g¨›ÝƒªÌ˜²¦wµ˜8´¥5uéÆWûí z÷/®º_õk¯–"hïÿeôºa'¾õ`ÅaÙnÅCg+zY‰k%Až• ­Vpº€Vk˜eµ2«-”[Ç@ÕZía‰•×Zà&ëX¸ÍJ‡¬NðÕXÇÁ{VWøÈêßY=à7+“­Llcea¦•C­žx²u<Z½±Äꃳ¬¾xŽÕ—YýñjëD\Ké&kÞk…p¯5Ÿ°á+Ö`µ eŠu†ìfM—YÖ y‚ÕV³fÊ©V¼œa•*Ùy5»`²(";e‘+;O—BQ¡ì2ñµʲSÜRãP¶Ø×:9¨*Ù©Ã ñ3ÿs9$g‰ïøßÜ!ób¡xßT—Û`¥%d ½‡3äe rªº*ér˜I£Ä\èã•(ɪZÉQÔ åh9Æ“¬'©˜ÎZ÷VO_ï:Ç9IºUŒËKF²Ì:¡ÿj|•G…­y`YÐÁª„.”ö´æÇ„Äúz>f_Pƒò3Ýh‰Ž ‰Q+™Çñ89Næy»AŽW^äñNpLLŽñÖùô4k{fªØ –ì–§æs]ö.99¤¥ÊÂÝòŒQ/: ç{(rzƒì$6È¢ÕÐÙ;w—%ÑO¦DÚ=•ů“ò×¶Hò›Zƒ ï׳j0uÊ»Ÿ¿evÿFs4†Fü_ýœúT9GI0;¨KUÞ¾Q[§‘ð3~2"OM³ñðkÊ~âá©áÍ#†?>ÒÖi¤daÄòÈVùf¿|˜À¯:²õÓHº â ú¢—<ÔƒºäáãKþÈ%¿ºäáBó, toQøÕE +){´‹‘^7}Þ‰*ªÎl\½2¦ú4ªNo\==¦ºUÛÀÇ–9Ñ%”³Yn5àí©’ ¸Èj«ÎNy›ûVýö£“þú¬í q8ŽÇ ² âŸTœ«ÒD…1ïòXÝ_‰ÖbH¶.†öÖ%ÐÕZYÖRâ+ ·u% ³®‚±ÖrœWC)¥s¬ka¾uœc]—Y7À k¬·VÂíְ˺ µVÁs$Âß²ÖÂ'Ö:øÖZO‚ršÖFlmmÂc­Í˜amÁI÷±îÀáÖ]8ÁÚŠÖ6œj݃gZ÷â\ë>\híÀ¥ÖN¼ÔÚ…+¬Ýx£õ®¶öâ6Jï³Â=Ö#ø²õ(¾i=Ž_YOàÖS"ÑÚ'Z[O‹ õœH³ž!J‡X/ˆQÖ"ϺBX/GŸ˜ì“eµs$%ò‡ h/B²F}ü³«8ž¼Àk”©øŽ+îzŠî²’IÜ ¶¬“ ÀÄñ$ Ï€…gÂ'nn.¼¥>WéÃð‰óåBðã¥î• ?Þè^Ùð{ï÷¨ùIv¸ó«‰ªùå"šßÏÿN§{Ðùšû]öë3Rå9©ò<¾#+/H•‹UæâT¹De–¥ÊËTæŠTy•Ê\*¯U™ëSå •¹1UÞ¬2«Så•Y—*7¨Ì¦T‘©2›ù‚_ýŒ/¾qÉm©òv•¹3UnU™m©r;e¢Ÿ+èÀ>¦o¤ú΄ž¾é뛥¾"¸ÐW ×Qyô#î×Kþ”*$Yò~o‡Ç±¥TÏØ-Înòåvß‘Õ'Y伿èv©s2ø>¹£phÛ§ì– ?è?%ƒ8´^ÕÆ“¹0…¦ÓƒyXIºóPx¿ÜÁW–“h¸öòHƒ®ÐƒÒ Ò9”6ÿ½|!T¿ɇo–i0dÿ#¤î¶Rú(íq¥ ŠO(}|ÿPKeTl±+JÒ—PKïv;0name/fraser/neil/plaintext/diff_match_patch.javaí}kwÛF²àwÿŠ6ïÄ"#’’hO’±,'²,OtG¶³–¼¹³±×$  pÐ’2ãûÛ·ýF$%'“¹;:'±4ª«»«ëÕÕU[_Þ_ŠçéÙ™xU“ e±øƒçøê ŸßéùE%FÛÛ_‰?çùù4GÙdˆo/ªjþxkk’ÇÉðœ^ 'ùlk¾Å b<˜!¸Áÿ¿%¡§“$+“X,²8)Du‘ˆýy4䛾øßIQ¦y&FÃmÑÅùªÓÛE7ùBÌ¢‘å•X” ÀHKq–rÉõ$™W"Íà2Ÿ¦Q6IÄUZ]P? ¡ÿW #W4àƒ9üuf7Q%‘j¸WWWÈÐæÅùÖ”–[ÇG‡¯N€²üäm6MÊRÉßiÃ߈h(M¢1 :®D^ˆè¼Hà]•#ÊWEZ¥Ùy_”ùYu ‚‰Ó²*Òñ¢rfL!ã¶ÀœE™è쟈£“Žx¶rtÒG ?~ÿúí©øqÿÍ›ýW§G‡'âõqðúÕó£Ó£×¯à¯bÿÕ_Å_Ž^=ï‹æ úI®çŽÐLq.“˜&î$IÎrF©œ'“ô,ÀвóEtžˆóücRd0"1OŠYZâš–HefšÎÒ*ªèQm\ØÑÖ½{0Ï—(‹fÉð¬ˆÊ¤fI:Χ°fUr]íÞ»¸åE%~Ž>FÃ4¾ÍÊÅŸ$ñaÄ ÝQ@O»Nã,©†oßS«¤¾{žÞ-*À`¿(¢›c˜ú¦weàÅ÷Qyñ2š7¼9IBÀŽÓì2‰zjy|T%ETå!ÜÃ(„»?©` Ï‹ä<¹ßοnhÀ¼ß»·E›âÅ"›ðš#Ù ‹è‹™f>Ä'†Ìzfs è’ˆ›%E‚[yœTWI’‰ê*¸þ%nI‘DlVëû}Üiòsz [ZFÀ1´ñÓ¾ˆ¦Óü ÉqIŠ"/ʡܼßE‹êž2Ñ}gø›è¾/èyÉtëKf—ÓvË:âD° ÷ÚÅ,èqɨNË\}Å“‹ècš/ Q&r„’÷Ã|1ö!&Ôþ@p?0̿߻'ÄÖ–xžœE‹i…ßàŸ°¬6*0‡Zûú­ˆQ á–-Ò˜·x¬A!,¥„_-fc˜C`•%l,¦5˜EsX èÃ|ÂîO?â$,梻M3œfg0/ÕMoHp@9¢³iU$‰>œ¦³$_TbOì ·Ïví~ò²Â^Ã%³yu#’8…s¤s”ÀAØf%¶¡W“‹¨ˆ&ð¬¬õX^ ¹R‡Ð”@ï‰GN§ÈÔÒ_p=n`˜âê"°‰s€’ `[1ºI?ÊžÄsÝç¸tuœMD@÷Q ¤ìZxˆ&“àŸ´aø|MO/€_äÓÐ|8rðܯ5˜¿y<ûÊrIpq´‚²§» ’tÙðYBû¯Ó O`µoÄ4ÏˤiUh‹°»ßþÑ]˜ïó+VdP&QãrG‰.ö“\à AG“Hö¾½½½ ÏÇEÅÜP" öåw$ÔgQvc­£ˆ®@dŸ°q@BÁp`| 0ˆúéTDqÌ pŒ€I§ Ò$Í€ÔT8][cÇ™ä‘?O忨#¤ÿx¬(N¦ nR “ Òj Ø\’&\FtqG‰ÿþê‘5Œ^_\ÀœM¦0ï@LIÉàOdI| X@«’T§G«Ú ëjî.®¯ò wu`I^‘OKƒÔô{dX r-$-ÚXd²Ô[f’ç†hçžAú •‚.Ù%o6$à5î켪ç­ÁÀ_Â”ÃÆ§½ëoÞL3ªq ا¤Á@,€íZk¹_F×ϰ1ï2âQ†¢,šJþ‹xIµ`ƆŒ’I“ì4Í’ò4?€E/»Š¶_“ô¡?bÈò4ÖêR)~^”¬ÏF0û `âf°Ð"B…ÂÆ–’(¡D JI„c«Ç7„ÈøÆjš"`‹”Xîì¶¼ù/Q¥xÂ-ž )94Cn3‡®Ógßí…È•~Âð{r‚¸ÁaÀÚØ°ßÔ»‘óNÃ×îøþÓ=üOŠNñüèÅ ñâí«ÒŠïÕ(*Žª&½XL`áXuÔa…xû“$ îÂJôÅ‹l¬|ü3ÌQù˜¡ýŸu_+á5|~x|xzØïÐJ:À!¼G`]¼9…`†Åã0…ä^÷þ¯·ûÇÐN\åÅ4vzŸ¸‹¯Yeåcµ‰eo}d™2)*—I27@ü-˜ÀÞºO¹Jj UÂÄ̯šÈ)€wõº²®ØÉóf¶ MØ8å­Q`N´ƒrÐég°ŒZ"¤[±rÇúÔ訓KÚ¸RJ³n9Ø8!P9ìmÙéK©iàÇÐpe ×÷*V£™¦4$’2ÅBJqñnîcG¼žâ>%¢çÎhØq½éH¼J®Úš2»QÄu ®«4ÖÃl÷Ի܈„¤Þ–„‡Úu²?ó‰lKú4`´Çq}×_^!NȪ<Ójz‘ƒÊ4;™áàDÌç´³PÈg3 4Øogé5š¤åâŒ~;“ËDÆ€T=±gøð×] njQÆÉD"¨»gÓè†wtd;EwFEjBžmT¢@r–û~ðÃÁ4ù˜0‹³´(‰˜@®rvÃ:ÁˆXqPå"µ/ðzÀ5ëì”ߌ– ‡oûçù“5›Š+àC¶Ñþš0X¢[ò\÷î±ì V2OÏ“í>.»LáF°P[ ¬¶¨ÛÛµ[ Kv±]˜ãR7=ý‰µ{J%kÔN‹tF¤ë’µ;,TJø=ëA€$Í8?û>q¶¤ìZIXj&!ï1vÃr1f’în÷àò[Þ$õÖ MG²é¨¹iÓåþu‡Ü<Üj¾t¸j}ü7C¶?XgðÛrmWø<4!òóQËçšÔÙí¡Ù'Úìøû,ã©´,îÙT«f¿òسµ—L “UÈ!™ái °çiÓæ¾M;ôK¤‡Áa¡Kd•ÒÍ¥\*¬>£6|¤5qK””Ù°ªü”K5Íybg{>w~êóÛoåvzÌ/¹A΄µàŒ$i’Ó K‰ÏxFrýú¬«!÷Œ*’Šû{b°ãLá ¶“Ni4/Ò¬TX„©Þøsj¬¯|ÝûÎG°“µ¿0:Ÿ\Z´ |Þ7#wd^Úë­¥>™éjû¬¡¿TlfÌíÔ„ÿ·¨È¦„l1îÒêü9*Æx5ɧS`+FT“VJ.Ï—Ö6a&ÀÆx"Ê*¹¥¯ò¡Es?½3%¼/¢é¹žBº ’ 4½Ï9T³/ðK>`‹R‹ŒŒÒ˜ã‚¥uTECù©Íö>D€ÅÅì§í÷»¡·c~»z;RߎÂoå·ý· ÉHŽ<Ò è¬Äí8Ýol!uõþ2AñUу.•m×Èe 9ÏnýõæžrÔ i€4VÆAH0Ò4ä”ê4F“´ h®³AÌÔvÈþtÚ•¯dü0³3Á ¦YÖ5ÉYjƒ5Îòáâÿð¹#=·¤ƒe• ÞQ§9:KÒ)ô«ÑI~‘D¬®È­Ys FÒ…G›3€¬ËUq*MŒÆYß ð_1ŽJ Æ¢%ߪWßqçvª±ÆCÏ;)Íù\{&틲¦¼–†çMd1&+™É$/bŠ"s‡¼ (T1ÈÉí\hJ“ã&JC üƒè"EÿLd ÿêS¼ïü5Æ÷Ée`­‚ÄJZ‡VTÀFBÕŽÏjßeZ+¤ áyRuu“~ðH+ûw×ÛÛ>šþ¬=6¤Å¡H£9šˆ“ñâümØ8M/Á¸•J*Õ¹¸J6¦S!·v$~ÆÀ ÞKHÜ1¢á<ÉHžÒ¡8nRái4z>‰[u:JgrBBš÷ËEv®ýÜJ_O±w$ BZàŒÚàÈ­!õc?°AE4ÈP+j¡‘gDÒÉóû`šðs¹£Ý&†ê6t­5Q9ж5(mŽÓ‰S çP2›Ù19¹†µ³öl¿a³®îÆmݶŠ# þ€ÏNªÈ–“êña†‘Mƒ‡ñ…ýàÙ"b¨3‘Œä΋ üM/5%ôÅöQÁÉH{¾ø\‰@¯1~Ìî1¤¹îÆ»l£'®òÅ4–ÿ¯ŒÖ…-|”CÆ…Î@DÂ6>Ëó Ö 3»þe‹p£¦[‘A· ÇqoÖŠŸ»N3†$5s5YO„ã«[943ê¸Tq,}³ Æ’93p÷\k –Ý¡¯=g@¶3ÎEÝ__ÃÚdmC^…õ^Û 9$¦¡ ôýKrCMŽÈbÍç H ¿^$0tb7=Rc{š6‰ë3Î'­ é»ã7Ü–Zï:/ Ò|Á,>6Ä€<¹2»k!a¡X×µj•3Ànˆ…¾I.nbrj·]Üà³( ¼³X%/¥d•@‘äåbg”â£Ú] y;DŒ+$|È´tµ’ußDØ÷¸ k›s¹Ž1ñËÃr·(/x{²Lª6©n`?ÇÊ Õ{ ž»–½•´cˆ©Ø|Œ3³_uoj#Lj“ºã„ó)²9¼žOÕ‘7%‡Êª V}œ‹DåEŸý\!BÃSaRœ€wd¹ 1ÿ¥Sôâ­~Ô‰Ãåª.ˆYù!!yrS‚|NEê†ã¿LAò€µ L­‹m{L::TÿK Œ6‚ë€.sé=Ê´@›-gTúg >¥sÐå¥-´ÄÞuþáúwf#·Ù(Ðl]P\_CÝt¿Ö2@ã²DeE*)–¶òqÇ3/jM–|?Zõû;$+€˜ÔšùOpú¯ûB:³R+€²ð¨rû˜mò·%‡Wzrñ`qÄß<Šjt€…«Uz(»Í¾feI¶óA„¢Õ#=˜W L¼¸u㌦<ŽeÔ¶I£C é¶‚„ªÛêˆHëÀk¨åÐÁ„!ì‰n·…4{â  µ½=-{5Ç™ãÆšEl3YÀåp Rñ\RîRrð%ÚWŒJ‰³‹ŸŠmtصlÿ§’IØü\YHŽ'Æ ¢uXkÒppõ&¼c´ÛZÞ„c¢ïö¤ûf?F_ |PÅF¼¡¾Ö³r‰Ú7ÌÅ¥xþ»¹'F6®8äKRc<½ºD×pŒcŠF tÉ*èó7jy®§ C˜זË×»¼Ö|Óèœ¶ç …è5´°Übt¬£˜‘‹„µ•ˆÍk½‹{Îñú!ž{<ÈÓÇQCÕÓ^Í«(· †íØ@?yܧüMgÈÔ_}÷°ÌoÒP¸OÝÞ×ÒVÐ;žÝÈgj»øª4añ!µ‰kr‰°¨0 †»Z®›ýÆý³e=ÖZ‘»®Iãªøææ•Yumš\Ææ7) .ûâÚúRîhÒêz´±C“ÒsiýZ­»ÊjÑ4W´‡B~]bkÊ‹‹†L“1›™òjÈ;¤ìtÙ¾vo:gºzWóêƒåä÷ÿÂ0¶X;]öªs~k^PÎò/ha¢ŒÇ`³ 8kzõ=RÐѸñ8×½uâÚA¬Î’ ÙaÔýˆ»Õ»‡¾kmýÞZš¨Î?Ù–tpڢƞýš°Q‹p[q³–Ài9#OäŒEŽ#£ Ð Šàq7v@ø´q4gPÍó4꺺ÿ«‹ Ï">ä©ËÆhEêŸùòÉŸVv¢ø+íJ©šØúŒ+yçµ\Ktý*’KÛ*žÄÒlau™%d‚”JÒrÁ(Áîñµ¶¥à¼…\I†…­*)|ºX&8²¢FH¶ëuÑ `TGÇç.|B í·ÄØ*m/²ãoÍÈ>ºq8xo0p‰¦|ßÐ7¢<ÿÕyqi®¼«› 2ö‰®¶“ï¹Âˈ˜ÿýž2íŅ뛢 ÖÎJ¢Í2Yêë:+¢s:ª]ÃéÕúÍRï×ê.&å +eueg•Ã`¯sš»öÀ9r´8±n<Äý<{i0ðÙ¡Fy¯VîsI"Oè™oõl¯®m´m.U€è£¶ûI2pq"©)³ ;XOkÔ#ÌÒù=¶ÂScµué+ÀÛ›×…cÛÖXËrþÕÖE‡®º.ŽAó±þº¸Wš×¥…ô½J Žè®à€¨ó±è¼BtNÒ…8ˆ0ã‚M†ÂÌ^g¥e¦¿ßÙî³oÌÞ}g´Ùhþ/e"v‹ž…ÿoìèßö_YÀr$ïðͽ€ejGD\{‡ýK6xÓµÜ&éTíký®[_ÿ϶5rÝö\Ü|ž5j’´AÛ©ÖàN»oUýNo¸WÐSŸù»íÑçÚÿŒÍûŸ·¡ï,ãUj…HœçyL‘A||•STȹΨdîµ §Ö ÃÔ‹Gm½QAÔ2˜"ŠL|ˆ™Ç0ØãL‚27WކÑÈ£s’`V žE×él13yÒ"î.-ÅŸú£ÑÃþïGýí‡_õ¿ùã£þ×_ÿ±ÿÍö×íßgB¦ õw}ÝôÍÃþWÌ7<7xK®Y:Á´piÆ¥¹º‚)aË  @0¸(§'e SŒ©#q™ÜpÌN?%PRŽ—L“Aè°ì”¨ó"Âæ05³´J?Rö€E)ÊKHD‹*ŸQ´q~­o†ÒÄðu7toïJ ‘Ë_ž<‘¹õ+ØN7»65ñó==wtA/§Ó•Œ hw Ôw3ÛY‘ Ç+„©018´dkÒr4šSf¹¾f.RjĘÞM™–U–dJÒ«öF³\•[£íí¯·v¶·¶ÿ´¥µ4¼üyÏLÝ®Õ\Â1~DÊq©x"2øÇŽ0Y$[L{xôâp÷Ô‘ärrSÅbŒ%[• tjª> á² ÈD;¿% |Þ°¯:EÉo\¯tˆšvˆšö–““ç’P—çq[=y9Áån ¢(¡¿Ä‰çD)`N¥”)€óI0>:ÖM1ñíg¦ÑÀ°õEIB_´ðîY|3Q·ŒþXûI¿õ>ñC·%]MrAÉÝÁ&>èi¯MœeE÷)0“BVÏjê%Éü齟¼aù~ù½¥-9ãhN|äÞáÇ“êz. |ehØ>²`V€Á´˜¾GÝ£S<‚ ˆ/’Ê4œ6ZümQÞ“´”OazY«1™-ji3ôµ =õGzV‘•œñoЇ=±%YѬ„ytŽEÆQÉIöi ^¤…D Ïѯçâéæ@£ŽÍêÒd±ok 'ÿ2t-~ϲ‹ƒPâ€h½ÓØzä´V/1Pµc¾lcFÈo‹¶dR3¨nzôÞ&tí<ù–(ä±Õ¥¦ÃÛåK1œ¿%±Ž7™j °[ë™Z£¿S•>gVés •>çJésF”OÁéjå~ì6 B‚$)±w¶4SI®Ñmˆ±¬@öše• JEÍ—fŒù¾]àh ®Ñ Y …ÁJeJ µJßÁáèP±‰‚%ÝQ}¡e6vƒÜrX>à‰/ýÂ@“²ìö"̾VW`G]O>õk’†ÒÔÒÿxÆ)â9;[êsMiéË”óó³ ˆ|Þf—Ìø÷‚Y K`uuög8´ºÕï]¼ï0y†Ôýsû¥J©‚:¾÷tÜ𙞠÷;óØþP:†»?ÛÙ¥L~°“ÿ,C¾¼¼`8.¦“ãæäŸ¡)[+ÊÏÂÊ€øÙÍÂDÂBš Mé¿îŒ@±qVl-«­˜8(n:óf«Ý.Q‡¹ pF±þŒ—Ñéw×íÖ!·†ó:o1nØ-KPpI·i— ázxа-œÆÅ|ºÜÊbËAoŠk›¹ý¹©ïè¾M9íÂÙèQu‰k]î6–;*)±6T"S1GäŒBäAS©²dV–4ñü”«]Z´ ~뢗À(| Q_€V‰¦B© yv­%6™11¨É&Yj4¹”˜é+‡ú¥JßA¤T·&AU˜GT%‡VÙûPé›^E720‹®¹›Ïñ“CóÒ—No™È‰³¼1¤,©Œ¨œCGyf$ºæö“û9_;æŠùJÀ£³JÖÐZyd ¯’RiiB%Ò‘k©”–œg`þ µ`¤‰ë7ÖÍå…m_Z®Í”7¦¦ÖÐé¥K6ÁLJµÓÀœüæxÔDÙ7 Y&Ï{õÓ4yn+5‹>÷éæ¼°Ÿ?nïTk/GÞ±æÖ–¼–Ñ&¦Y·CI,C=TÌl|7Eg£ãœé¨¨|¨MgwrI”tÖ´]ýƒ±‡“4Ù§L>­»ˆ¿átm†H¥–Ëùކ8eÒvîÌUÏŸ¦#•Ne’ERÎsž&Mg¡þ–d×óû³ Ø;,Ÿ+{zQäW\`Éæ"â*áÂ4<òØF…âÁ-h‰/ô=àju¬ŒþxÄ q)#ZŠ$Á³$°Xâž-]þa}¹qC« ½¨Y^à >íƒEeQ±>\qÑ’äk%®úA“àú¹ÄœðõÐy­Æ›’ú”Ñ™»b˜üÄíTa=lÚ/M[l748{oÖGP%Q¶µ‰ë¶Mó|>l®Mª!™¨Ùsa;NQ¦NoƒŸy\¿Æ÷íÛ«øc4÷¢‚{è|—|eN¶KêÌÍyßœ°žûioÇyY¢Öjî) ÇyÎÉüø†œÔ=ËEQ fÏ&WcþiVJ5u0>ZPé/Ò3*B­IÍÂdEÓôœÝ£TÒ]åfõÅÄ…¬b…¹«ÓÖä p¸§¨Ç<Ù¢ßfÉP žÒ;z5wüj‚ï~%ÕWO^» ,ÕME;;}f_?p}ôÒJ¦?Œ·‚Ÿ¾¨IežR}:64NðךçáÐôIR¿ö‹‘qY«z˜(R©©ªTw뺥®K3ŒÛþv´ß¤s®ó9>¿Ýç5–ôª²ƒp=ô³!ƒÐNÀ.çB£‚wtŽKÒsRit‚º±šªVÝ”5‹)€ë¨ÓTüŠ¥ÙÞöîvö³á–š˜q2ªn˜/AjÌ4ª‰ŸÌ7‰87±ñ£ƒ >s Ã!¨"&:iÏè÷9ìÃTå8aflm— ûÉÛŒžJí|˳íJ){‹¢4–+ƒþ¬Uma`Ž„´çPÿî:VÌã•òŒ;èmúèmóˆ×Âp€ê2–2öÊñoŸ.ÒZ9æÆ7vÂ9¬à©ùóK••ŒKÙÂF:K-]Éáeöt¹Ž¦CžßnðÛ‘õíÈmB TÓŠËèé]f<_ˇ.»·¦X]¥Ñ´Áîëææ¹o§ÑW2t`›n ˜ôc—t Ímîyß(É#º袉dLS†Ýº¼Û¤ßvÚuÚÓ=Jb¸(¨”fUD)¥Ë³ãÈr,$*0²Ð¼ÆJZoÕðh¦¦'_»µ ­4{ 2o%uF¹»·Îwßaæªü™ƒWÍè?&ªNÖ|/ò$aëãC@Ó6‚(—o :ðCŽBþqp{ît9à «Ö­'íl˜£èÖbh½Ï´€[ï3=˜àg:´•ëºý é‘X·à'¡Y°--Gž«E𨰶¨ÁB@üãoQG+/jh¾Âëiij(î²µL9ãM[Çý×Òn}ÈLÕ¿zu{­4pZðçô£¬1&©Ê™ afö䔟½ºH$§“iî@½eHÊô"‡æƒ)}އ ê9R †Vâõó„/Dý‘Ïi¨Pê¶è‚-¸VÆ·„‰Á–2ÒpêGÕ&21$¤þ’cÊJou•Û‡ðÊ’ÄX°ä*o(cu£Ôˆ¤!0¶"O$šôãI1¤rž2…5ÖYdœXpœ -MY3ºž…ù´ð÷¦SP@šVS·¼\šÉ|®².ô¦e>K3>DçÎ0Í*æfV`6ŒH۠ʪ4YH”ù¢˜cÁqxJ [§XŠ®œ%0£}ˆ ¸åX«HF4HÑì°üX™ÚÇ ¬@2œÁ¹(ØK€`l© ªàDeX2À€QçpB)æ°ã5³G52˳A4¨Å,)Ò‰ ̹ ÓJ§åqRÁ/¯‹çéyZÑÒK¥u’ÌÅH¢R‹Ñ½ ¹Â&ˆ ”—Ù$c³v¶5»£u‹1%\ 4áYÃTyb“ÄÆ–«áàÍK r>OªÓ›ù\Qi5Ÿ¼~uúæõ±ÃŽ!(˜.öA ®œ© Š­KÌ`lʪÙâë(ÏŽ÷_ýåøèÕáá«çC>+p<½!첸ë륀µþâätÿÍ©þÙüÆ…$ÛÓIH6£5I–Šþ!afÂF]ÙS/‡(ÀévÞ½ËÞ½+¾¥þO§¯<}º|LÊs#`aè} šþ ‡ÏmI¶ú¹·övüF߇ggé$¶{ó¯pôí;o~GgßG¥Œ=sOFµóJÖ妈@r­9çjº@›Áš!õ$º>i½lø¸vss¬Þ¯îá£>c¦ý—\ޢΩi‹<¶›Ð©Õ>Y=<ž>{²…ÿü×_éÍõæ9¿iúú¿ÖýÀéÎþ8ôA­Ñú=àÿjl9KŠiqT%ܺ]µ«àµ…ðWµ<êwÜÑžzF Z·)ÎÅcHXû¾†Ä·ïlSÂs…zü6-äP䨒ŽûÐÏ#ð‰€ÇûóO½Y¼åÁ£·©Ñ/¯r­¦’¡/ø©ÆKô3©¬˜¬óÔ—I2çyjªðª‰ I‚z²Çv©´Dð4xW EúÜÁHíáH¡¼–+ã JúÛ¯Óƒ}„5{¬¥ú÷µ%ÒõX½£h!ß{?u«5ð†šz/)sÈ·=é¡HîŒþ–I+©:ü‘õÇï=˜¨¤žX+µKª·É5à8–€ §`¡2®o]³Ì÷³§)-?^ŒJNÉÿÂë8OJŒ©˜yéÌç¶ðyÌíÆ=[¶+”Í]±ï-Íè´±¯yãé´î×+qÚ/k¥8W6 qÑ!Ž#dL ëZ»«ˆ«TÞ ÖÝmªºk®¹nx øãd VâmªÃkÏùçéÙ«Í[«©‹Ñ+¨ì:¤PiYRQ7¤ÙØ'›vúâ8-xÔFµÁˇÔÎx—ĸ¶UË $嶇ýëvžy¿šq}æÙM0ÚŽÊôe7nb˜Ô/\lo¡à ?‹¾û6És;°BÈI¥pµå5t|þ‘¹·Vrÿ4QEGáÄ5¯Ê ÒC)$΢lQÑ©#ážhËÜXrþÜÀ){¶£ ×W? jÐÒØü†$Z„ùJˆúi«}Å/¼†Ú:vW–X­àÓjû¯®±d°ßçÝK‹Éû í<©õyµŸ*D`±îšbݧ¦î«®è¶S$|Îo³úÛÎÌß¡³µ.Aíw«*|@¼‘Mh£ÚŽÔ¾o•KÁ&¸Véqß^Ûöª¼ ¬æ^é Ìò³¼¸ŠŠ8|ËP®ñʬ;)?ŒrSSô$Jl&P ‚ë×qìnÏi­Á.Þ´?Ú$úÊòË¡âãdžF­Õì6Ñ ’›7¾OH5uý¦¦¦;óô‡8zäç0&5ÕÝ‘±9 1¢n>ʱs;^"nª´L² )”°ÅœR!7Òÿ¥Š&šƒÆñ˜Žïr§e¥[-êŒ:©™ªú.Ë>yÉŸI7ù^aa¿ù3~² ›³w¼õ„x+д»;hŽ‘=´ëÑñ£2”È*~¡…þ—!3éßà’Ç¿ïwünîwÔOQak•?6˜ããúQé‰{Uƒjئ!é¢T%§Oð4OÇžü6™¡ŸÛ¹ŸHÒ[о€¡w£Å|x« ä[… ß*yÝð㵃›•çåÓpûýÍ?NU£œx”…,“‰ÞYÐÕ‰? ¼G0µ;PõÓVDñ; ÕÖ6ÏRÒ\5”úW[O-ùg„U«2¿$×Kq…ÇxÐÕ×wäQOÁžo>Œá¿œbXù¾Zy•¨"‚ŸÇÏ>Í'ÌÌái¦²Gê8ï,VqR—O?FSYÖG ŽSÒñï  E‡.×FUG|,ùqzNúbgðzùãàé7üÁZÞv“¹m"Ž2¥gÊ“ÍTŠ@›QsÚë#ÌŽÕà±ç dгŽËË(JagKÑÏFî3Ê*jl^ø™Qð"J·B=èÿÝÓ&È“î÷Ûk`<¹²¬Xë$ã Ü%ªs_û_Š@=lÆC@GæxŒÖÀ@¢üÔ^/ÙÕkt/_ä*Ó#ˆéÊšöhcÇ]Xþe·ön¤ÞlÞ "Tlí=½êÙªG* N{Tú‡ör?XyL O–8 åLf4×+y¢TÆ™à~ØsÊpg9îé;åüö9¥XO*XüïO_ãÅ’¼ð²Þ¯Ê$v _P1 lozy@Eûž»ÿ¾šMWº‚ÿl‘Nñ˜ò>С³Ö ûP-5»zéÞµÎÌ„C釧t;€vD³ùn§gž>¡§Ó ZrѼJïÏ+ç#Šžî<ÀùÝ}òìÍSÌ£Îɼ]¼ä 'cH%'bÀÞŠ“Ó¿î½ë ñwNvÂãÿ8üêŋïvßuÄéÑ)½M÷+ù]êªÜ;‚nDš“éi ºzjG"-?UsQ…·ATÑÏŒ*tµUïÎÅôä‡ýW È´ôÆóÉBkÆæì<]‰O;7hœUΠ«˜‡ÉVLÊ|AW|pÓt1Öà kǦJœ•½;±”ÓM++!-d-."·{#ù5D> C’‚Y©¶¢/n·B\…ƒñj\&-ôï¸NϽΖ.Öèw¶Xõýôë.®Ðqò1ÉÊ‹*I±:=˜«Ù$ÙõîÌðaÒX©Z€ªÆÈvL«EE.,• ðN«èdDc§YgŸÜ—Ý£¡d„f¤ŽnèÏ}®ÙÇrüvÔ ®"â ÚëÃòd¥/×HØ:µ¢çÓ’ëÜ+"p•ikº7UeŒèºkÆÚ·ônh.œŒÀjÔÆ`ï‡Ûô¯4\óixÿ‹ò˜ñ¬Èf”ý¡N9 à€“"'\k@„ÌkU m]°½*¢¬Ä[ª²úµíëC´¯÷¾«£wÕ&vÞþ¿`PìC61Ô#õ·<ØÙ€ÖŒæ7òÞq4” nÕŠráÉÍÎüÐÊI4‡¿èþ_\_‹,·kwŸïcÊrUšZT‹ù4©óéi­À¡sjøÛòèÛí_<>²Ýy6Ûîl¥ìí›ãC"’bÈÄb1uÐÈßž¾|ÓYËÕlTý;Ø=K!¬lïzaplo³r1GC+‰ ˜¦ÃëI2§=Ô“xc1§7x¦5Cì3Qò)=f nÆ™?,мèvèÜ€ÛQÜ%’= êÌ;‚âSxW¹œ3Å3ņ×4!KÙŸÓÇÞÝû°Ù“¢tÚ {!±-Oô©ECölŽÕÞÎLÞØÎF«õQ½0,ç´ƒ97ªw½¯³€yÁÔû¶HQ…ˆ ¯=7]jé$ T-ðEÀ “L G,ÒsPÚ¦Ê)…Ìr^Ê€ C]‘—/'Ì¡Û%V^i¯‘ÔûUå[™YªÞšçÉçj¦6_€#¢¯KOH³  ë"º¼Ž¦Óä<šîç <+4ûóHÿÂ8j,4\Osj0;  â¼gíä²&À0[ËkçšS`™Ró`Q”äûc¤¬íñÓ{XÌË„d¾¤]¼˜do2âèjLØ« Ñ7ÞõP|‰Áz0tk_ö…R1¿]ÊC™­õ®².a`MŽ43‡ŸÌ®¤ ŒË89ǰ}y¥•%ãZ#âÁ2n’ºËy2IÏRCÝ ÍØ *߃W"Þd©ÒHÊYa"ÝãÏ‚i´tt4µ¤ý"æÆÑ ã·¨¸¢HIÖÕZw ðp vDG·V(пÆµ‰n¨/FÏl†é‹Rõ)ˆÎç ‹Nî¸KoŒÔü—t ÃÆ=VÇîe4E†üíí›#QÂjcÚ–0bM`»žjÑ‘ ¥Ž'È´¹ÅcÑ¡Zì4ÓaQMÛ~Y(AèµÊÞK^/–pH‹ó‹¡Ópoöœ*‘5SZz–qŽ§Ø 'ðNh5Øô|•›—â6³,¶´¨o3ËÈÂ2ñÄ&½2¯ åÎY› *–ižx+^ÕÎG)y_ <¶-wBgr¯Õë³gè˜-ï²(Ð2.·Kcd$¼Ú'*’‰„l7£ý9®!@¡4ë·F"9¹´Ýqµn.yEˆÃ j±+ƒ±ƒL{˵ñ89‹ÓÊud7”JFAðÍ*dކ=­K©zÓÖd„`¹ú¢+¤r[·TÙL³|fÞ?-•3`cë’$ :ëh©Ó[B¼Ü?=ø^¼xûêàôèõ«?ñö$ªLj1TÈÁ‡ªÄÆœÓçlà”n 0è¨Ó|¢|o¨ßR vpöTq/+ƒ…£2ÓQ$ýò¿Xª˜j$»¤vêwÝU¹úQ¿sºiÚF…‚œžg8FÆPVc+ó ÿZ}ÀóN[!û#F–/ ì']ñ^ñ2$e™qv*L Ü3å°ìÏdQ!‰R!¢&q¾€ÉÚâH;\ËhzfSu1SLGŽ_ºÅì`¤†ÐXÖv.Xi¶*¶Xƒz¥Eü¦š§¦G8RîÁáÑ…ÁôZg þâp^\Ž:—K˜±û”©l2]`°-© @âda)xîð g|V_ÏñTúlñË/ti(1ñ¦†igœVѼËT£ÉI¥1ôæö»QúÞ´Õû û6d0ÿÓ6ª.{kö*Ï÷:›UÞ ëRÁ/£ëg¢½'o3†èØii¯‡éIÈù>1»PÔÍÁ$•‘$]¦ñó&‚+¹eçÑ8‘ÑÓ/£ùm®¯4Ô§í\­j¯·ƒü}z~óÇù ÇÉ ¥³ y•ˆsTí2d,ÎxaŸZ~¹–”ù4j §êÉ®ÆÚäBêÃÈÞëHí»oElÔ$^Ì{Ú˜—õÎ&Ê…¥j v„.½†Í½ªƒA ™¯Z«Ïù7·u•5f&zû9…ÿ<ˆŽ4Å0QÌ;ñvX*Qu§ï]øƒõŠ/GÁ†XÛïiü ô9Æ”øè¨*M‚Lêy•—€ÎŽxòDtk{Ƹ ­ùAÑa$Í>À0úò—x×}]“[À»éJMž$ÇD*QÿEf3‹cvú¡9ÿF#!3z8?½ç ¥"–>*|¶ýÞò"Ñ™»¥b0µ|¬à©]›eö„ÓŒ–NS¶ËÙJùöa8¢C [¢ h†ûÖQñRÊÚáœ`Â\É;aL1:ˆf˜)ó”R¬X@^)– 2Íׯ¯`|ñ { ³¬©êG.†}‚&—³&óz¨ò ‚úä‰jé„{I×è3Vû@~à©£å?Ù«ªc¸„ò!R®›9†¢ê­ï¹ qÀ]Õ~ ú¡ŠsíW°|{oå~lÁâÅ Ù¶V›úp:[Ì4}dÊ3܆(奥ⶖ–¹Ã:ШoZNzÊV ;¢¼°™†·®Z`Q:UogHè›bô^uZÄæéÎ{œJâ11ÕHï)¬BËÍwá÷§{Yñ½CŸžs¤u%ãUþCèï{6ëµlrtFDõ7­míG+V.£¢µòÔ®;GäÝ•ÕÛS°'¢é 3ºM@µªmŒ¼¿ª”‘—Í©Š9¨¦.$žT!ž-*¾ï…m¯"çR‡©±Œ52r¨,Üä Qz Žê EC­)³¶w®AE·¨ESk~¼H2¢.â É×Ú’ë š¬˜Y]TñEÌ6¡­Ÿ”)ÄúFòÒÂ@£ëßÖnÌ µ?-’(¾aÚŽrWÙ®#á@º.y•|TŸ—ìè^èwÿ®IX&"9K:sˆ &¶e•9MÚE>Oˆ›F¸ ø]ÏÂçta³°¥½u¯9nX|œLjšǶMq˜D°Œ¡Ú)Õ…Jù]»ñöÚtL¬€/Ù<Íìòªáµ¹ÑM H¯çl7NÛÛ*›V™uã„ÊãYƒ|‹wð( ’‡ØÝnÞç9ð‘ú}É4mN5Ɇj4ò.a£õZÛ®¾Q«¨álš£Á2­M0ï^—žô`þ¶Òœ‘*¹ç …p¦>ÚTѸ”û×–YÂæàsµ=]ÁD.‘øO¾?¦q‚N¨_’"w5X9YVhfKóXã 6=$Êy©¾ÞÒãsQ Ý-h°´µâå;KÊ!±þ½‚´» ²&±2ä·h²ð=ë>¼ØmîÔÊ—†6jñMPäðŸjŒÆÐªrü”œÈ(l.&°`öw–•:„ÝßÝvÌéÐ]‡U!±ú4AÑßm´1S©ÉiWåÃrSØ»ŽñÚãGÙ¤ [‘ yf´ô‹¬J§˜%þ"KA/êsëñ¢’Bm*kª)M®çÈܤÆqÕè x†t§±iw^äW×\8Ú\eš›3Åñ£Þý€Òã¾}@çÕ4”à9féK¾¬:ê û/6àO^›¹\¾Î”Ðh™Ø«d£HäÄ‚ kšS©ejÝKò!¿SOð„ü4އºîtûÒûAþ]œƒdrÿ4­&csÏi°«_7̼}–àÌû@tÜ:ÚÂsL¢ö%£¿”ïë_•²È8Ê31]L.yŽ[‡eÝãU%ÿ•Nmh”wÄ`OÑ€«…¸+ÜÐ]{×U’ñœó;§=507rBýò³ÑÊß5é»|cÄ?å$ìúºE T{_`ÙOóW²á8NTèaàDfG¼žÆNô y7¯`í‘…î –MJ­ˆ4>Çó{RðiÃæoXìÏÅO” l§©Õ–¾áâ­ƒ- å~& øî畼åmJÞü¾[ãä•«(Žÿ3ÌlŠrN8v9T26ü¿eæÈþ鉓ÉíMʃ·c"Ì;…ò’¢$i÷ïØE"=@£u@\F ô&N>Haö+^ ¶ÐèÆÃ|>½Ñ× Jm1á™,èËÃ=<Ú£>¨º‡dˆta‹sö¶J™™ _ÐßÐ?öq¢§L´4;Ë}íÝÀW‘œ»N ·ÉJwam-Õ)\ÝzI6”¢lŸhé‚3.‘g×øAUuÏ&’ÝP›‘ÓF—ÕùõowKÌÓÊù란¥²¶^Þ¬M¿ûÀKækã×7Pc €Áaù5­šúÞ>¦e3ô™Æ¼Ê0© ¥p[)‘zžìࡆmO;øaéöö½;‚ßé,˜õ|b3ëª=M`bIÌ·âí-›ôuq]Böʇ‡fìi}Æjã=Mgê B3ÿd-<­µô¸¾wMúÕ\¾½[ÿ&á ¤ßCïC2ËÁÛŒJ^¼ÍRD°O:3‡M) ¤‹ÌrJÒÃØ }0U5¼µE>ìó„hpÖ'–ž>UþõÎ@£4ò8IæùÜ)ëjÚKUqü|™:N «Ïÿó؇ï¨×L]ÕU;8ô4<™ÊŸZÀÈéQuß"T_ÀO-Ù¤ Õ6‹i¬•<‰ÿ½n7òÚêí”ØÓ åƒ¦–#¿åÈ£rj«)ÝMÝ1ÅÏùÍ#åe4ʾe‰è¨p%ôeN‡>F¨]%\ ñKüÃÞ8èŒÛ¢œÐKQ]ø˜øº¾†­z£”–Äö}wFûªYË5cYΖ7WÉ’_Ó·Œg_Ș/•o:“þQu{\†Á1×—‰¯yPµíÉàz¯ÚcÍ[2xƦMš¤ÍæETUgçåÀG½Ÿ¶ßòÝðÏ1ÁÇ·mו9s±Ì”Ñ‹d†QªÀ(¯ÕÛ^t2Ú=®£x€ ”ÃBãû}º§y¹|d}„ßHYmÃñ»˜)Ö<´Üòè÷ËèÚïžb!œóDö»`á¿/bsAjY_‰_{d0O© þ *˜T˜¹^Ò+xòkIšÅ˜Àkõ•®|§fËă2å<;ÛØ8ÂpìEeåùfZÇDŒtû?ÞÉD (tFì8Â#•P¾’as¨½ñ~L¨"šþ …Ža42Ž •¦ANš¢¤÷2FUyjÔsE¢ì`¿†x €‰ sx'¬·Î÷`SÀÍëHŒ] <RÁGºÏ,¶"Ëåsj]›{J¸C÷¤Øõ¸ÔÇ.ùcø¤ÛgUœ›XŸÙÊ òV‘ /3 Týj¡z :ðP|¸uxxµäÞ0úÎÄÛÙ’0^P÷åÝ*À3y­hÔñfwD˃áPGí^c„­­S™J…/#|ÍèÀ ”o‘Æœ°|oT§mPv($E>çhí€ía¯ØÀ … Äúù!‡mË­­Á´•} ˆ¿Ž|Èbßéâ±¹0+wúO×ïÅ °_0[Òñ¨{a@nNmÅf –gH¦¥íïw)ÕY\ï7ù5k‡êº7ŸÔô9ºÁÇ'¸†ë˜Ái¹jP›ònCÁ™Æ\^ä“ך‡Ã¹Ùgsº¼P¥LdV R–¤Kwè ¯>:à6z€x$®yôÈåÑÞØ7g©¶퓎C³¹=.ÔZé* Ÿäâ<Á´Ág˜+å*/Hþ›„ä.(ÒTÝÒ^kFÓî©q¥D7EÔsJ É7º`=²vzqýÓ¶Pp®]##4‘(Õ„˜`&CÛ¢ ^dƼÅ.lf% K>–?ÎËrš”¥wç×µóUí7}qMñ]øçÉž-‹d(’“!cÇüß“ ÉÛòú¬Kk=}çÀ87ž-fsÁ™žŒÝ-k W7M_% PÞn ñ¨½±A4DxLs°êløëÌ€^œ‚²¸A“áhT \©"–+½1v-!}â'¡¬(ñ$òöùå„VŽ–µ€ô\|N•ä“ÅÁNÛÖ?Ýòcë`²mÙk§’-Ënä_¨‘xZ_ WpØ“ÿgL'ÄâÏ;i8ôŠ~Û«uÜ ×`Ò«µA¼XXz¸¯=0†Ë7@±%ñ¦«‰,¬MF­M¬…]ÒflÓ¾{QNä\kÇÚºM+kGÞmÏÊZ•ŸË. înÙ±ÿÔ}c×îlÞ6\/1´k¬Z=̵÷Œ®¹Ü2Î[øŽêjuÖ<Ów!d¥3¹NõšŽtŒ¥IeæDG®Ò}<ýÆ£^¼§Ìç9èòÆœ6ê³N3¤R`ÒJ¹ÎåÍÓÆëtÔÓ:±ŠÖ&}.°T¹áûGôMúKâ(5êÊ.¿>È?v}ɼ«1·’¶ÇÈq£ó¹7?Ò{KºInænÇÚy²+õ/SÝTBç¾Uü%T•‘§çš­W~N§î‚ÕCÕ[MœµüG–‰r{\ð§%W®U‹ëÄáUuÇ7ÛThÙ¬½ðÐV-ô9°¨{¬ÏÝ÷æ´V“‘°Ë"˹½¯¿j &2õt1ü6B™ÐebPþBMr0LˆH±æ—uîžöÀ¿1ÐNO¹€Õö‘ºx¥Z4P¹Tµç±·½ö>êáh­W·$ °_uÙR94_´·ƒMcM·þðG3›–šuõÝÚ§LbÍŸú±OJÕ.÷<Ù>'>&kl†î9/üÊD¿7„1¡–µôV•$µ7bÞârÐÞçŠô›’-SÂþ³¾Ü™¼^-w<ÇŠ×0hnÕk&×Ôg/3¨4dôâªbË”ÖTdy’Tx9“Yà‘¤¾¶Ù¼†;+¯áJ-¡eÓêEéðþâ¶ÑÁRç³åfÄ,‚ZWq^œ*ºL0Äe¶@¥¥4)³òYäŸÚ»ÑÌ…£âéCŸú\ÕŒÿp×Åã'ÁÍò«¬aËFip”¯ÍÖa ¹[Z(íÓç£;3‰IÎÚΆk±Â«Soã`Û{\*jd'„E³õð‘ºúÝü†:ŒU0ã"‰b}iÄNmæ©;ê“u¬hÄÖGìÖ–‚Ðpk¼®A8cjÞ]hXSÊü±ÔC"ÙñÈ£góöf Á²BÜ8šÃkx­§¨«Âo:|¾oXM«Ûܳ;]íÀ²šÃŠ—Jèšse9c CP6~ÍnlœûµVÕ݆¡GÓAŒÐ'&¶ˆB7 üŸÛ›môŧ$Jk·+­\P\þÃîBxë>‚–«êÈ×ÃfõÝ~VRåøu·Å‰p‹ÚaË"ˆºU™¨¬åY¡H£=Ù?`±ŠÆ mœgNR°ˆ:‚bVÑU&غڲb˪­P¸ÈÇ#8ZC+¢±^·PÑ­.¯ªÎV¸¿j-0ctŸ¾4g{|@ü¡Ä¿ lUÎ(ë(†`ádƒ©c,ßvU'ŽïÈrpý £éÑ÷ ¾­"{êíP¦Ëívþïw߉A÷Ý»x³×ÿÿý²'Þ½Ûôž|÷ÝTr¾Àö™ñŸ”ø©LÏ3Ç5†ƒu|S÷›dÍLy ÕáŒÁsº£ú//²É™lVºRëÖ5'˜i²(–õ%ºwî„7j/ն纩ª™ ± õ¼»ãW=õÂÂÚ;øNã˜Ñ®0ˈ¶`K…¸³Ý @7P¶=(ëãÑ8î‘=Ïõ9µ}û0¸µUXˆe‰èÊ+¶¢ªŠ\­ZÅGbCvñ­ $íè1õã›,ßÓl+T$ïꘇ ¢éÆÄ¤CõÊÖ­€æO£„_¯j†/þ]Ô,„ØŠš¹êstžèæòLLè{X—¬†¶ò8µ]£n©´D:Lݤszݬ÷ª}¸kv«Š°­Ò­¨wû2ÍlÇÚz}KËg•®¿«w}¢lB†ZŽÞp|ñ§/¾½#=9Zn!±D˜oŠÖl14$¨f®(án©•~‚£):µºÎ ‰W"«¦X—¨×OÄ„>¦3sž" –jBÓùÔc¡HE:ÍaÝiå¤PseΦtÏ6,¸:­*tORÊ@Ä©P½ðö;qŠÍùÐðòM±˜TyA…«eYYk› Ëά,[Ö@Ë 2®žŠe3®¦Ãi„í+‘þxÜíaº Þ(”Þ79H¯‡¤ †Æö*'‘Ú&¶gùEüµ’-¬`OeÍÐ?þŒ?OÁbÁbâb1‹²æ×Ž0»1]š²j”>×5–ýH½È–­«­-c=æl¿ªnN%¶ sSû]†E°ß-¶·Ç_mè=&»ïÐäwÔE 3+°‡ûï:TÅÍÀ‡‡ï:½NëtP¹9ZëV#‘ÎñÕ·ÑÄbßz‹­e¡)c%ƒª>sÅK%³C<8u:^5Py]Öº(àj n·K±ö=hèzÏܪ…`?xà|j;ÐõªXŠ¨Ò ˆDe“N ±ro0i¹3d.½"7dkùïöD¶…ÍZäì`ÿñ¨öع3^{í×àm\LXF¦hwKáeHsÔ.Šck+Öü©ŽÕál¶˜b@ß_½¥.7(Z¬oM²l·?èGxøÍ¨ÿØ|ôÍNÿOð@o¼§“ÊËó1Îí ÆQ‰¹×P×Üæ?j[™­êÝɵµ./™äy—;}ù‹ü çΑ¶P½ÊòCÅÔá0‘íŽ!ySƒÏƒµ†¥,:° |§çÁ¨}CXiŽçe.øð¨aÀ#gÀ£•ûïŽÙÃö_ãË çƒÑ7ø¬ë>û>ë…<|†/wÝ^à³-çÙCzöm}|ùØùàÑ6>ûÎü>{Ðä9¾Üs? ì6ÝgðÙæä_öÝâ³ÿèÈ3ËO÷þPK7M‚¾IÍKPK¡q9 .classpath³±¯ÈÍQ(K-*ÎÌϳU2Ô3PRHÍKÎOÉÌK·U qÓµP²·ãå²IÎI,..H,Ér8¼Ô¼’¢J…ì̼[¥â¢d% ­’’>neÉùy0eùEéz©É9™Å©zY)%z9‰¥yÉ@«õ¼‚\ãýýB=ý\ƒÈ4-«4/³DÏ+ÔÏ3a–¾ >ÓòKK JKP|a£ìuPK\W0PK±^œ5.projectuPË ¬RÅ“-µíJ`ƒ0Êžû’i ú iì!lPÛÑÛeŒçÁ¿.™ùÄ–~´§ßà‚w?HsInøž;ÿÉPKKi‘RĈPKµŒ;²îMETA-INF/MANIFEST.MFþÊPK ;;aname/PK ;; „name/fraser/PK ;;®name/fraser/neil/PK ;;Ýname/fraser/neil/mobwrite/PK½†;0ýieÍ °3name/fraser/neil/mobwrite/ShareJTextComponent.classPK½†;ðv¿£„ô2Cname/fraser/neil/mobwrite/ShareJTextComponent.javaPK½†;‚<{¥ûÁ0'name/fraser/neil/mobwrite/ShareButtonGroup.classPK½†;¨®{sÇ/€name/fraser/neil/mobwrite/ShareButtonGroup.javaPK†‹;>6³u* 0Pname/fraser/neil/mobwrite/DemoFormApplet$1.classPK†‹;õGŒ ¸.#$name/fraser/neil/mobwrite/DemoFormApplet.classPK†‹;k¾$_ - 0name/fraser/neil/mobwrite/DemoFormApplet.javaPKš…;³;Ù<' (Å7name/fraser/neil/mobwrite/ShareObj.classPKš…;ŠÚú@A 'BCname/fraser/neil/mobwrite/ShareObj.javaPK½†;_bbEg *ØLname/fraser/neil/mobwrite/ShareJList.classPK½†;Ýú<´¹)3Rname/fraser/neil/mobwrite/ShareJList.javaPK½†;ŸÖ‰òÝ3>Uname/fraser/neil/mobwrite/ShareAbstractButton.classPK½†;‘Í%ç`2§Xname/fraser/neil/mobwrite/ShareAbstractButton.javaPK†‹;Wü™B2"[name/fraser/neil/mobwrite/DemoEditorApplet$1.classPK†‹; 5è* 0_name/fraser/neil/mobwrite/DemoEditorApplet.classPK†‹;å57“- /|dname/fraser/neil/mobwrite/DemoEditorApplet.javaPKwƒ;’,¨p%,..Zhname/fraser/neil/mobwrite/MobWriteClient.classPKwƒ;«žSOE-Û€name/fraser/neil/mobwrite/MobWriteClient.javaPK ²`;‰•name/fraser/neil/plaintext/PK\g;ÈM’4á7}6•name/fraser/neil/plaintext/diff_match_patch_test.classPK\g;OqW•½ ´5Îname/fraser/neil/plaintext/diff_match_patch_test.javaPKïv;áQÌÛÓ6'íname/fraser/neil/plaintext/diff_match_patch$Diff.classPKïv;¥`ÃDfðname/fraser/neil/plaintext/diff_match_patch$LinesToCharsResult.classPKïv;ýGE=p;›òname/fraser/neil/plaintext/diff_match_patch$Operation.classPKïv;@’G? 7tõname/fraser/neil/plaintext/diff_match_patch$Patch.classPKïv;eTl±+JÒ—1 üname/fraser/neil/plaintext/diff_match_patch.classPKïv;7M‚¾IÍK0ªFname/fraser/neil/plaintext/diff_match_patch.javaPK¡q9\W0 Æ.classpathPK±^œ5Ki‘RĈ›‘.projectPK""— •’whiteboard/mobwrite/demos/editor.html0000644000175000017500000000152312251036356017313 0ustar ernieernie MobWrite as a Collaborative Editor

MobWrite as a Collaborative Editor

whiteboard/mobwrite/demos/spreadsheet.html0000644000175000017500000000430212251036356020332 0ustar ernieernie MobWrite as a Collaborative Spreadsheet

MobWrite as a Collaborative Spreadsheet

whiteboard/mobwrite/demos/java-editor.html0000644000175000017500000000111312251036356020225 0ustar ernieernie MobWrite as a Collaborative Editor Applet

Note that due to Java's same-origin policy, this applet will only sync if the gateway (specified in the HTML source of this page) matches the host serving this page.

whiteboard/mobwrite/demos/java-form.html0000644000175000017500000000125012251036356017704 0ustar ernieernie MobWrite as a Collaborative Form Applet

Note that due to Java's same-origin policy, this applet will only sync if the gateway (specified in the HTML source of this page) matches the host serving this page.

whiteboard/mobwrite/demos/index.html0000644000175000017500000000176112251036356017140 0ustar ernieernie MobWrite Demos

MobWrite Demos

Editor
A simple collaborative plain-text editor. MobWrite is extremely good at resolving collisions which other systems would fail on.
Form
This form demonstrates collaboration with all the standard HTML form elements. Note that the onchange event is called remotely when the checkbox is ticked, thus allowing forms to react normally to changes.
Spreadsheet
This 50-cell spreadsheet is an abuse of MobWrite (there are more efficient ways of synchronizing grids of data). But it shows what can be done.
Java Editor and Java Form
Java applet versions of the above editor and form demos.
whiteboard/mobwrite/diff_match_patch_uncompressed.js0000644000175000017500000021571512251036356022432 0ustar ernieernie/** * Diff Match and Patch * * Copyright 2006 Google Inc. * http://code.google.com/p/google-diff-match-patch/ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @fileoverview Computes the difference between two texts to create a patch. * Applies the patch onto another text, allowing for errors. * @author fraser@google.com (Neil Fraser) */ /** * Class containing the diff, match and patch methods. * @constructor */ function diff_match_patch() { // Defaults. // Redefine these in your program to override the defaults. // Number of seconds to map a diff before giving up (0 for infinity). this.Diff_Timeout = 1.0; // Cost of an empty edit operation in terms of edit characters. this.Diff_EditCost = 4; // The size beyond which the double-ended diff activates. // Double-ending is twice as fast, but less accurate. this.Diff_DualThreshold = 32; // At what point is no match declared (0.0 = perfection, 1.0 = very loose). this.Match_Threshold = 0.5; // How far to search for a match (0 = exact location, 1000+ = broad match). // A match this many characters away from the expected location will add // 1.0 to the score (0.0 is a perfect match). this.Match_Distance = 1000; // When deleting a large block of text (over ~64 characters), how close does // the contents have to match the expected contents. (0.0 = perfection, // 1.0 = very loose). Note that Match_Threshold controls how closely the // end points of a delete need to match. this.Patch_DeleteThreshold = 0.5; // Chunk size for context length. this.Patch_Margin = 4; /** * Compute the number of bits in an int. * The normal answer for JavaScript is 32. * @return {number} Max bits */ function getMaxBits() { var maxbits = 0; var oldi = 1; var newi = 2; while (oldi != newi) { maxbits++; oldi = newi; newi = newi << 1; } return maxbits; } // How many bits in a number? this.Match_MaxBits = getMaxBits(); } // DIFF FUNCTIONS /** * The data structure representing a diff is an array of tuples: * [[DIFF_DELETE, 'Hello'], [DIFF_INSERT, 'Goodbye'], [DIFF_EQUAL, ' world.']] * which means: delete 'Hello', add 'Goodbye' and keep ' world.' */ var DIFF_DELETE = -1; var DIFF_INSERT = 1; var DIFF_EQUAL = 0; /** * Find the differences between two texts. Simplifies the problem by stripping * any common prefix or suffix off the texts before diffing. * @param {string} text1 Old string to be diffed. * @param {string} text2 New string to be diffed. * @param {boolean} opt_checklines Optional speedup flag. If present and false, * then don't run a line-level diff first to identify the changed areas. * Defaults to true, which does a faster, slightly less optimal diff * @return {Array.>} Array of diff tuples. */ diff_match_patch.prototype.diff_main = function(text1, text2, opt_checklines) { // Check for equality (speedup) if (text1 == text2) { return [[DIFF_EQUAL, text1]]; } if (typeof opt_checklines == 'undefined') { opt_checklines = true; } var checklines = opt_checklines; // Trim off common prefix (speedup) var commonlength = this.diff_commonPrefix(text1, text2); var commonprefix = text1.substring(0, commonlength); text1 = text1.substring(commonlength); text2 = text2.substring(commonlength); // Trim off common suffix (speedup) commonlength = this.diff_commonSuffix(text1, text2); var commonsuffix = text1.substring(text1.length - commonlength); text1 = text1.substring(0, text1.length - commonlength); text2 = text2.substring(0, text2.length - commonlength); // Compute the diff on the middle block var diffs = this.diff_compute(text1, text2, checklines); // Restore the prefix and suffix if (commonprefix) { diffs.unshift([DIFF_EQUAL, commonprefix]); } if (commonsuffix) { diffs.push([DIFF_EQUAL, commonsuffix]); } this.diff_cleanupMerge(diffs); return diffs; }; /** * Find the differences between two texts. Assumes that the texts do not * have any common prefix or suffix. * @param {string} text1 Old string to be diffed. * @param {string} text2 New string to be diffed. * @param {boolean} checklines Speedup flag. If false, then don't run a * line-level diff first to identify the changed areas. * If true, then run a faster, slightly less optimal diff * @return {Array.>} Array of diff tuples. * @private */ diff_match_patch.prototype.diff_compute = function(text1, text2, checklines) { var diffs; if (!text1) { // Just add some text (speedup) return [[DIFF_INSERT, text2]]; } if (!text2) { // Just delete some text (speedup) return [[DIFF_DELETE, text1]]; } var longtext = text1.length > text2.length ? text1 : text2; var shorttext = text1.length > text2.length ? text2 : text1; var i = longtext.indexOf(shorttext); if (i != -1) { // Shorter text is inside the longer text (speedup) diffs = [[DIFF_INSERT, longtext.substring(0, i)], [DIFF_EQUAL, shorttext], [DIFF_INSERT, longtext.substring(i + shorttext.length)]]; // Swap insertions for deletions if diff is reversed. if (text1.length > text2.length) { diffs[0][0] = diffs[2][0] = DIFF_DELETE; } return diffs; } longtext = shorttext = null; // Garbage collect // Check to see if the problem can be split in two. var hm = this.diff_halfMatch(text1, text2); if (hm) { // A half-match was found, sort out the return data. var text1_a = hm[0]; var text1_b = hm[1]; var text2_a = hm[2]; var text2_b = hm[3]; var mid_common = hm[4]; // Send both pairs off for separate processing. var diffs_a = this.diff_main(text1_a, text2_a, checklines); var diffs_b = this.diff_main(text1_b, text2_b, checklines); // Merge the results. return diffs_a.concat([[DIFF_EQUAL, mid_common]], diffs_b); } // Perform a real diff. if (checklines && (text1.length < 100 || text2.length < 100)) { // Too trivial for the overhead. checklines = false; } var linearray; if (checklines) { // Scan the text on a line-by-line basis first. var a = this.diff_linesToChars(text1, text2); text1 = a[0]; text2 = a[1]; linearray = a[2]; } diffs = this.diff_map(text1, text2); if (!diffs) { // No acceptable result. diffs = [[DIFF_DELETE, text1], [DIFF_INSERT, text2]]; } if (checklines) { // Convert the diff back to original text. this.diff_charsToLines(diffs, linearray); // Eliminate freak matches (e.g. blank lines) this.diff_cleanupSemantic(diffs); // Rediff any replacement blocks, this time character-by-character. // Add a dummy entry at the end. diffs.push([DIFF_EQUAL, '']); var pointer = 0; var count_delete = 0; var count_insert = 0; var text_delete = ''; var text_insert = ''; while (pointer < diffs.length) { switch (diffs[pointer][0]) { case DIFF_INSERT: count_insert++; text_insert += diffs[pointer][1]; break; case DIFF_DELETE: count_delete++; text_delete += diffs[pointer][1]; break; case DIFF_EQUAL: // Upon reaching an equality, check for prior redundancies. if (count_delete >= 1 && count_insert >= 1) { // Delete the offending records and add the merged ones. var a = this.diff_main(text_delete, text_insert, false); diffs.splice(pointer - count_delete - count_insert, count_delete + count_insert); pointer = pointer - count_delete - count_insert; for (var j = a.length - 1; j >= 0; j--) { diffs.splice(pointer, 0, a[j]); } pointer = pointer + a.length; } count_insert = 0; count_delete = 0; text_delete = ''; text_insert = ''; break; } pointer++; } diffs.pop(); // Remove the dummy entry at the end. } return diffs; }; /** * Split two texts into an array of strings. Reduce the texts to a string of * hashes where each Unicode character represents one line. * @param {string} text1 First string. * @param {string} text2 Second string. * @return {Array.>} Three element Array, containing the * encoded text1, the encoded text2 and the array of unique strings. The * zeroth element of the array of unique strings is intentionally blank. * @private */ diff_match_patch.prototype.diff_linesToChars = function(text1, text2) { var lineArray = []; // e.g. lineArray[4] == 'Hello\n' var lineHash = {}; // e.g. lineHash['Hello\n'] == 4 // '\x00' is a valid character, but various debuggers don't like it. // So we'll insert a junk entry to avoid generating a null character. lineArray[0] = ''; /** * Split a text into an array of strings. Reduce the texts to a string of * hashes where each Unicode character represents one line. * Modifies linearray and linehash through being a closure. * @param {string} text String to encode * @return {string} Encoded string * @private */ function diff_linesToCharsMunge(text) { var chars = ''; // Walk the text, pulling out a substring for each line. // text.split('\n') would would temporarily double our memory footprint. // Modifying text would create many large strings to garbage collect. var lineStart = 0; var lineEnd = -1; // Keeping our own length variable is faster than looking it up. var lineArrayLength = lineArray.length; while (lineEnd < text.length - 1) { lineEnd = text.indexOf('\n', lineStart); if (lineEnd == -1) { lineEnd = text.length - 1; } var line = text.substring(lineStart, lineEnd + 1); lineStart = lineEnd + 1; if (lineHash.hasOwnProperty ? lineHash.hasOwnProperty(line) : (lineHash[line] !== undefined)) { chars += String.fromCharCode(lineHash[line]); } else { chars += String.fromCharCode(lineArrayLength); lineHash[line] = lineArrayLength; lineArray[lineArrayLength++] = line; } } return chars; } var chars1 = diff_linesToCharsMunge(text1); var chars2 = diff_linesToCharsMunge(text2); return [chars1, chars2, lineArray]; }; /** * Rehydrate the text in a diff from a string of line hashes to real lines of * text. * @param {Array.>} diffs Array of diff tuples. * @param {Array.} lineArray Array of unique strings. * @private */ diff_match_patch.prototype.diff_charsToLines = function(diffs, lineArray) { for (var x = 0; x < diffs.length; x++) { var chars = diffs[x][1]; var text = []; for (var y = 0; y < chars.length; y++) { text[y] = lineArray[chars.charCodeAt(y)]; } diffs[x][1] = text.join(''); } }; /** * Explore the intersection points between the two texts. * @param {string} text1 Old string to be diffed. * @param {string} text2 New string to be diffed. * @return {Array.>?} Array of diff tuples or null if no diff * available. * @private */ diff_match_patch.prototype.diff_map = function(text1, text2) { // Don't run for too long. var ms_end = (new Date()).getTime() + this.Diff_Timeout * 1000; // Cache the text lengths to prevent multiple calls. var text1_length = text1.length; var text2_length = text2.length; var max_d = text1_length + text2_length - 1; var doubleEnd = this.Diff_DualThreshold * 2 < max_d; var v_map1 = []; var v_map2 = []; var v1 = {}; var v2 = {}; v1[1] = 0; v2[1] = 0; var x, y; var footstep; // Used to track overlapping paths. var footsteps = {}; var done = false; // Safari 1.x doesn't have hasOwnProperty var hasOwnProperty = !!(footsteps.hasOwnProperty); // If the total number of characters is odd, then the front path will collide // with the reverse path. var front = (text1_length + text2_length) % 2; for (var d = 0; d < max_d; d++) { // Bail out if timeout reached. if (this.Diff_Timeout > 0 && (new Date()).getTime() > ms_end) { return null; } // Walk the front path one step. v_map1[d] = {}; for (var k = -d; k <= d; k += 2) { if (k == -d || k != d && v1[k - 1] < v1[k + 1]) { x = v1[k + 1]; } else { x = v1[k - 1] + 1; } y = x - k; if (doubleEnd) { footstep = x + ',' + y; if (front && (hasOwnProperty ? footsteps.hasOwnProperty(footstep) : (footsteps[footstep] !== undefined))) { done = true; } if (!front) { footsteps[footstep] = d; } } while (!done && x < text1_length && y < text2_length && text1.charAt(x) == text2.charAt(y)) { x++; y++; if (doubleEnd) { footstep = x + ',' + y; if (front && (hasOwnProperty ? footsteps.hasOwnProperty(footstep) : (footsteps[footstep] !== undefined))) { done = true; } if (!front) { footsteps[footstep] = d; } } } v1[k] = x; v_map1[d][x + ',' + y] = true; if (x == text1_length && y == text2_length) { // Reached the end in single-path mode. return this.diff_path1(v_map1, text1, text2); } else if (done) { // Front path ran over reverse path. v_map2 = v_map2.slice(0, footsteps[footstep] + 1); var a = this.diff_path1(v_map1, text1.substring(0, x), text2.substring(0, y)); return a.concat(this.diff_path2(v_map2, text1.substring(x), text2.substring(y))); } } if (doubleEnd) { // Walk the reverse path one step. v_map2[d] = {}; for (var k = -d; k <= d; k += 2) { if (k == -d || k != d && v2[k - 1] < v2[k + 1]) { x = v2[k + 1]; } else { x = v2[k - 1] + 1; } y = x - k; footstep = (text1_length - x) + ',' + (text2_length - y); if (!front && (hasOwnProperty ? footsteps.hasOwnProperty(footstep) : (footsteps[footstep] !== undefined))) { done = true; } if (front) { footsteps[footstep] = d; } while (!done && x < text1_length && y < text2_length && text1.charAt(text1_length - x - 1) == text2.charAt(text2_length - y - 1)) { x++; y++; footstep = (text1_length - x) + ',' + (text2_length - y); if (!front && (hasOwnProperty ? footsteps.hasOwnProperty(footstep) : (footsteps[footstep] !== undefined))) { done = true; } if (front) { footsteps[footstep] = d; } } v2[k] = x; v_map2[d][x + ',' + y] = true; if (done) { // Reverse path ran over front path. v_map1 = v_map1.slice(0, footsteps[footstep] + 1); var a = this.diff_path1(v_map1, text1.substring(0, text1_length - x), text2.substring(0, text2_length - y)); return a.concat(this.diff_path2(v_map2, text1.substring(text1_length - x), text2.substring(text2_length - y))); } } } } // Number of diffs equals number of characters, no commonality at all. return null; }; /** * Work from the middle back to the start to determine the path. * @param {Array.} v_map Array of paths. * @param {string} text1 Old string fragment to be diffed. * @param {string} text2 New string fragment to be diffed. * @return {Array.>} Array of diff tuples. * @private */ diff_match_patch.prototype.diff_path1 = function(v_map, text1, text2) { var path = []; var x = text1.length; var y = text2.length; /** @type {number?} */ var last_op = null; for (var d = v_map.length - 2; d >= 0; d--) { while (1) { if (v_map[d].hasOwnProperty ? v_map[d].hasOwnProperty((x - 1) + ',' + y) : (v_map[d][(x - 1) + ',' + y] !== undefined)) { x--; if (last_op === DIFF_DELETE) { path[0][1] = text1.charAt(x) + path[0][1]; } else { path.unshift([DIFF_DELETE, text1.charAt(x)]); } last_op = DIFF_DELETE; break; } else if (v_map[d].hasOwnProperty ? v_map[d].hasOwnProperty(x + ',' + (y - 1)) : (v_map[d][x + ',' + (y - 1)] !== undefined)) { y--; if (last_op === DIFF_INSERT) { path[0][1] = text2.charAt(y) + path[0][1]; } else { path.unshift([DIFF_INSERT, text2.charAt(y)]); } last_op = DIFF_INSERT; break; } else { x--; y--; //if (text1.charAt(x) != text2.charAt(y)) { // throw new Error('No diagonal. Can\'t happen. (diff_path1)'); //} if (last_op === DIFF_EQUAL) { path[0][1] = text1.charAt(x) + path[0][1]; } else { path.unshift([DIFF_EQUAL, text1.charAt(x)]); } last_op = DIFF_EQUAL; } } } return path; }; /** * Work from the middle back to the end to determine the path. * @param {Array.} v_map Array of paths. * @param {string} text1 Old string fragment to be diffed. * @param {string} text2 New string fragment to be diffed. * @return {Array.>} Array of diff tuples. * @private */ diff_match_patch.prototype.diff_path2 = function(v_map, text1, text2) { var path = []; var pathLength = 0; var x = text1.length; var y = text2.length; /** @type {number?} */ var last_op = null; for (var d = v_map.length - 2; d >= 0; d--) { while (1) { if (v_map[d].hasOwnProperty ? v_map[d].hasOwnProperty((x - 1) + ',' + y) : (v_map[d][(x - 1) + ',' + y] !== undefined)) { x--; if (last_op === DIFF_DELETE) { path[pathLength - 1][1] += text1.charAt(text1.length - x - 1); } else { path[pathLength++] = [DIFF_DELETE, text1.charAt(text1.length - x - 1)]; } last_op = DIFF_DELETE; break; } else if (v_map[d].hasOwnProperty ? v_map[d].hasOwnProperty(x + ',' + (y - 1)) : (v_map[d][x + ',' + (y - 1)] !== undefined)) { y--; if (last_op === DIFF_INSERT) { path[pathLength - 1][1] += text2.charAt(text2.length - y - 1); } else { path[pathLength++] = [DIFF_INSERT, text2.charAt(text2.length - y - 1)]; } last_op = DIFF_INSERT; break; } else { x--; y--; //if (text1.charAt(text1.length - x - 1) != // text2.charAt(text2.length - y - 1)) { // throw new Error('No diagonal. Can\'t happen. (diff_path2)'); //} if (last_op === DIFF_EQUAL) { path[pathLength - 1][1] += text1.charAt(text1.length - x - 1); } else { path[pathLength++] = [DIFF_EQUAL, text1.charAt(text1.length - x - 1)]; } last_op = DIFF_EQUAL; } } } return path; }; /** * Determine the common prefix of two strings * @param {string} text1 First string. * @param {string} text2 Second string. * @return {number} The number of characters common to the start of each * string. */ diff_match_patch.prototype.diff_commonPrefix = function(text1, text2) { // Quick check for common null cases. if (!text1 || !text2 || text1.charCodeAt(0) !== text2.charCodeAt(0)) { return 0; } // Binary search. // Performance analysis: http://neil.fraser.name/news/2007/10/09/ var pointermin = 0; var pointermax = Math.min(text1.length, text2.length); var pointermid = pointermax; var pointerstart = 0; while (pointermin < pointermid) { if (text1.substring(pointerstart, pointermid) == text2.substring(pointerstart, pointermid)) { pointermin = pointermid; pointerstart = pointermin; } else { pointermax = pointermid; } pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); } return pointermid; }; /** * Determine the common suffix of two strings * @param {string} text1 First string. * @param {string} text2 Second string. * @return {number} The number of characters common to the end of each string. */ diff_match_patch.prototype.diff_commonSuffix = function(text1, text2) { // Quick check for common null cases. if (!text1 || !text2 || text1.charCodeAt(text1.length - 1) !== text2.charCodeAt(text2.length - 1)) { return 0; } // Binary search. // Performance analysis: http://neil.fraser.name/news/2007/10/09/ var pointermin = 0; var pointermax = Math.min(text1.length, text2.length); var pointermid = pointermax; var pointerend = 0; while (pointermin < pointermid) { if (text1.substring(text1.length - pointermid, text1.length - pointerend) == text2.substring(text2.length - pointermid, text2.length - pointerend)) { pointermin = pointermid; pointerend = pointermin; } else { pointermax = pointermid; } pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); } return pointermid; }; /** * Do the two texts share a substring which is at least half the length of the * longer text? * @param {string} text1 First string. * @param {string} text2 Second string. * @return {Array.?} Five element Array, containing the prefix of * text1, the suffix of text1, the prefix of text2, the suffix of * text2 and the common middle. Or null if there was no match. */ diff_match_patch.prototype.diff_halfMatch = function(text1, text2) { var longtext = text1.length > text2.length ? text1 : text2; var shorttext = text1.length > text2.length ? text2 : text1; if (longtext.length < 10 || shorttext.length < 1) { return null; // Pointless. } var dmp = this; // 'this' becomes 'window' in a closure. /** * Does a substring of shorttext exist within longtext such that the substring * is at least half the length of longtext? * Closure, but does not reference any external variables. * @param {string} longtext Longer string. * @param {string} shorttext Shorter string. * @param {number} i Start index of quarter length substring within longtext * @return {Array.?} Five element Array, containing the prefix of * longtext, the suffix of longtext, the prefix of shorttext, the suffix * of shorttext and the common middle. Or null if there was no match. * @private */ function diff_halfMatchI(longtext, shorttext, i) { // Start with a 1/4 length substring at position i as a seed. var seed = longtext.substring(i, i + Math.floor(longtext.length / 4)); var j = -1; var best_common = ''; var best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b; while ((j = shorttext.indexOf(seed, j + 1)) != -1) { var prefixLength = dmp.diff_commonPrefix(longtext.substring(i), shorttext.substring(j)); var suffixLength = dmp.diff_commonSuffix(longtext.substring(0, i), shorttext.substring(0, j)); if (best_common.length < suffixLength + prefixLength) { best_common = shorttext.substring(j - suffixLength, j) + shorttext.substring(j, j + prefixLength); best_longtext_a = longtext.substring(0, i - suffixLength); best_longtext_b = longtext.substring(i + prefixLength); best_shorttext_a = shorttext.substring(0, j - suffixLength); best_shorttext_b = shorttext.substring(j + prefixLength); } } if (best_common.length >= longtext.length / 2) { return [best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common]; } else { return null; } } // First check if the second quarter is the seed for a half-match. var hm1 = diff_halfMatchI(longtext, shorttext, Math.ceil(longtext.length / 4)); // Check again based on the third quarter. var hm2 = diff_halfMatchI(longtext, shorttext, Math.ceil(longtext.length / 2)); var hm; if (!hm1 && !hm2) { return null; } else if (!hm2) { hm = hm1; } else if (!hm1) { hm = hm2; } else { // Both matched. Select the longest. hm = hm1[4].length > hm2[4].length ? hm1 : hm2; } // A half-match was found, sort out the return data. var text1_a, text1_b, text2_a, text2_b; if (text1.length > text2.length) { text1_a = hm[0]; text1_b = hm[1]; text2_a = hm[2]; text2_b = hm[3]; } else { text2_a = hm[0]; text2_b = hm[1]; text1_a = hm[2]; text1_b = hm[3]; } var mid_common = hm[4]; return [text1_a, text1_b, text2_a, text2_b, mid_common]; }; /** * Reduce the number of edits by eliminating semantically trivial equalities. * @param {Array.>} diffs Array of diff tuples. */ diff_match_patch.prototype.diff_cleanupSemantic = function(diffs) { var changes = false; var equalities = []; // Stack of indices where equalities are found. var equalitiesLength = 0; // Keeping our own length var is faster in JS. var lastequality = null; // Always equal to equalities[equalitiesLength-1][1] var pointer = 0; // Index of current position. // Number of characters that changed prior to the equality. var length_changes1 = 0; // Number of characters that changed after the equality. var length_changes2 = 0; while (pointer < diffs.length) { if (diffs[pointer][0] == DIFF_EQUAL) { // equality found equalities[equalitiesLength++] = pointer; length_changes1 = length_changes2; length_changes2 = 0; lastequality = diffs[pointer][1]; } else { // an insertion or deletion length_changes2 += diffs[pointer][1].length; if (lastequality !== null && (lastequality.length <= length_changes1) && (lastequality.length <= length_changes2)) { // Duplicate record diffs.splice(equalities[equalitiesLength - 1], 0, [DIFF_DELETE, lastequality]); // Change second copy to insert. diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; // Throw away the equality we just deleted. equalitiesLength--; // Throw away the previous equality (it needs to be reevaluated). equalitiesLength--; pointer = equalitiesLength > 0 ? equalities[equalitiesLength - 1] : -1; length_changes1 = 0; // Reset the counters. length_changes2 = 0; lastequality = null; changes = true; } } pointer++; } if (changes) { this.diff_cleanupMerge(diffs); } this.diff_cleanupSemanticLossless(diffs); }; /** * Look for single edits surrounded on both sides by equalities * which can be shifted sideways to align the edit to a word boundary. * e.g: The cat came. -> The cat came. * @param {Array.>} diffs Array of diff tuples. */ diff_match_patch.prototype.diff_cleanupSemanticLossless = function(diffs) { // Define some regex patterns for matching boundaries. var punctuation = /[^a-zA-Z0-9]/; var whitespace = /\s/; var linebreak = /[\r\n]/; var blanklineEnd = /\n\r?\n$/; var blanklineStart = /^\r?\n\r?\n/; /** * Given two strings, compute a score representing whether the internal * boundary falls on logical boundaries. * Scores range from 5 (best) to 0 (worst). * Closure, makes reference to regex patterns defined above. * @param {string} one First string * @param {string} two Second string * @return {number} The score. */ function diff_cleanupSemanticScore(one, two) { if (!one || !two) { // Edges are the best. return 5; } // Each port of this function behaves slightly differently due to // subtle differences in each language's definition of things like // 'whitespace'. Since this function's purpose is largely cosmetic, // the choice has been made to use each language's native features // rather than force total conformity. var score = 0; // One point for non-alphanumeric. if (one.charAt(one.length - 1).match(punctuation) || two.charAt(0).match(punctuation)) { score++; // Two points for whitespace. if (one.charAt(one.length - 1).match(whitespace) || two.charAt(0).match(whitespace)) { score++; // Three points for line breaks. if (one.charAt(one.length - 1).match(linebreak) || two.charAt(0).match(linebreak)) { score++; // Four points for blank lines. if (one.match(blanklineEnd) || two.match(blanklineStart)) { score++; } } } } return score; } var pointer = 1; // Intentionally ignore the first and last element (don't need checking). while (pointer < diffs.length - 1) { if (diffs[pointer - 1][0] == DIFF_EQUAL && diffs[pointer + 1][0] == DIFF_EQUAL) { // This is a single edit surrounded by equalities. var equality1 = diffs[pointer - 1][1]; var edit = diffs[pointer][1]; var equality2 = diffs[pointer + 1][1]; // First, shift the edit as far left as possible. var commonOffset = this.diff_commonSuffix(equality1, edit); if (commonOffset) { var commonString = edit.substring(edit.length - commonOffset); equality1 = equality1.substring(0, equality1.length - commonOffset); edit = commonString + edit.substring(0, edit.length - commonOffset); equality2 = commonString + equality2; } // Second, step character by character right, looking for the best fit. var bestEquality1 = equality1; var bestEdit = edit; var bestEquality2 = equality2; var bestScore = diff_cleanupSemanticScore(equality1, edit) + diff_cleanupSemanticScore(edit, equality2); while (edit.charAt(0) === equality2.charAt(0)) { equality1 += edit.charAt(0); edit = edit.substring(1) + equality2.charAt(0); equality2 = equality2.substring(1); var score = diff_cleanupSemanticScore(equality1, edit) + diff_cleanupSemanticScore(edit, equality2); // The >= encourages trailing rather than leading whitespace on edits. if (score >= bestScore) { bestScore = score; bestEquality1 = equality1; bestEdit = edit; bestEquality2 = equality2; } } if (diffs[pointer - 1][1] != bestEquality1) { // We have an improvement, save it back to the diff. if (bestEquality1) { diffs[pointer - 1][1] = bestEquality1; } else { diffs.splice(pointer - 1, 1); pointer--; } diffs[pointer][1] = bestEdit; if (bestEquality2) { diffs[pointer + 1][1] = bestEquality2; } else { diffs.splice(pointer + 1, 1); pointer--; } } } pointer++; } }; /** * Reduce the number of edits by eliminating operationally trivial equalities. * @param {Array.>} diffs Array of diff tuples. */ diff_match_patch.prototype.diff_cleanupEfficiency = function(diffs) { var changes = false; var equalities = []; // Stack of indices where equalities are found. var equalitiesLength = 0; // Keeping our own length var is faster in JS. var lastequality = ''; // Always equal to equalities[equalitiesLength-1][1] var pointer = 0; // Index of current position. // Is there an insertion operation before the last equality. var pre_ins = false; // Is there a deletion operation before the last equality. var pre_del = false; // Is there an insertion operation after the last equality. var post_ins = false; // Is there a deletion operation after the last equality. var post_del = false; while (pointer < diffs.length) { if (diffs[pointer][0] == DIFF_EQUAL) { // equality found if (diffs[pointer][1].length < this.Diff_EditCost && (post_ins || post_del)) { // Candidate found. equalities[equalitiesLength++] = pointer; pre_ins = post_ins; pre_del = post_del; lastequality = diffs[pointer][1]; } else { // Not a candidate, and can never become one. equalitiesLength = 0; lastequality = ''; } post_ins = post_del = false; } else { // an insertion or deletion if (diffs[pointer][0] == DIFF_DELETE) { post_del = true; } else { post_ins = true; } /* * Five types to be split: * ABXYCD * AXCD * ABXC * AXCD * ABXC */ if (lastequality && ((pre_ins && pre_del && post_ins && post_del) || ((lastequality.length < this.Diff_EditCost / 2) && (pre_ins + pre_del + post_ins + post_del) == 3))) { // Duplicate record diffs.splice(equalities[equalitiesLength - 1], 0, [DIFF_DELETE, lastequality]); // Change second copy to insert. diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; equalitiesLength--; // Throw away the equality we just deleted; lastequality = ''; if (pre_ins && pre_del) { // No changes made which could affect previous entry, keep going. post_ins = post_del = true; equalitiesLength = 0; } else { equalitiesLength--; // Throw away the previous equality; pointer = equalitiesLength > 0 ? equalities[equalitiesLength - 1] : -1; post_ins = post_del = false; } changes = true; } } pointer++; } if (changes) { this.diff_cleanupMerge(diffs); } }; /** * Reorder and merge like edit sections. Merge equalities. * Any edit section can move as long as it doesn't cross an equality. * @param {Array.>} diffs Array of diff tuples. */ diff_match_patch.prototype.diff_cleanupMerge = function(diffs) { diffs.push([DIFF_EQUAL, '']); // Add a dummy entry at the end. var pointer = 0; var count_delete = 0; var count_insert = 0; var text_delete = ''; var text_insert = ''; var commonlength; while (pointer < diffs.length) { switch (diffs[pointer][0]) { case DIFF_INSERT: count_insert++; text_insert += diffs[pointer][1]; pointer++; break; case DIFF_DELETE: count_delete++; text_delete += diffs[pointer][1]; pointer++; break; case DIFF_EQUAL: // Upon reaching an equality, check for prior redundancies. if (count_delete !== 0 || count_insert !== 0) { if (count_delete !== 0 && count_insert !== 0) { // Factor out any common prefixies. commonlength = this.diff_commonPrefix(text_insert, text_delete); if (commonlength !== 0) { if ((pointer - count_delete - count_insert) > 0 && diffs[pointer - count_delete - count_insert - 1][0] == DIFF_EQUAL) { diffs[pointer - count_delete - count_insert - 1][1] += text_insert.substring(0, commonlength); } else { diffs.splice(0, 0, [DIFF_EQUAL, text_insert.substring(0, commonlength)]); pointer++; } text_insert = text_insert.substring(commonlength); text_delete = text_delete.substring(commonlength); } // Factor out any common suffixies. commonlength = this.diff_commonSuffix(text_insert, text_delete); if (commonlength !== 0) { diffs[pointer][1] = text_insert.substring(text_insert.length - commonlength) + diffs[pointer][1]; text_insert = text_insert.substring(0, text_insert.length - commonlength); text_delete = text_delete.substring(0, text_delete.length - commonlength); } } // Delete the offending records and add the merged ones. if (count_delete === 0) { diffs.splice(pointer - count_delete - count_insert, count_delete + count_insert, [DIFF_INSERT, text_insert]); } else if (count_insert === 0) { diffs.splice(pointer - count_delete - count_insert, count_delete + count_insert, [DIFF_DELETE, text_delete]); } else { diffs.splice(pointer - count_delete - count_insert, count_delete + count_insert, [DIFF_DELETE, text_delete], [DIFF_INSERT, text_insert]); } pointer = pointer - count_delete - count_insert + (count_delete ? 1 : 0) + (count_insert ? 1 : 0) + 1; } else if (pointer !== 0 && diffs[pointer - 1][0] == DIFF_EQUAL) { // Merge this equality with the previous one. diffs[pointer - 1][1] += diffs[pointer][1]; diffs.splice(pointer, 1); } else { pointer++; } count_insert = 0; count_delete = 0; text_delete = ''; text_insert = ''; break; } } if (diffs[diffs.length - 1][1] === '') { diffs.pop(); // Remove the dummy entry at the end. } // Second pass: look for single edits surrounded on both sides by equalities // which can be shifted sideways to eliminate an equality. // e.g: ABAC -> ABAC var changes = false; pointer = 1; // Intentionally ignore the first and last element (don't need checking). while (pointer < diffs.length - 1) { if (diffs[pointer - 1][0] == DIFF_EQUAL && diffs[pointer + 1][0] == DIFF_EQUAL) { // This is a single edit surrounded by equalities. if (diffs[pointer][1].substring(diffs[pointer][1].length - diffs[pointer - 1][1].length) == diffs[pointer - 1][1]) { // Shift the edit over the previous equality. diffs[pointer][1] = diffs[pointer - 1][1] + diffs[pointer][1].substring(0, diffs[pointer][1].length - diffs[pointer - 1][1].length); diffs[pointer + 1][1] = diffs[pointer - 1][1] + diffs[pointer + 1][1]; diffs.splice(pointer - 1, 1); changes = true; } else if (diffs[pointer][1].substring(0, diffs[pointer + 1][1].length) == diffs[pointer + 1][1]) { // Shift the edit over the next equality. diffs[pointer - 1][1] += diffs[pointer + 1][1]; diffs[pointer][1] = diffs[pointer][1].substring(diffs[pointer + 1][1].length) + diffs[pointer + 1][1]; diffs.splice(pointer + 1, 1); changes = true; } } pointer++; } // If shifts were made, the diff needs reordering and another shift sweep. if (changes) { this.diff_cleanupMerge(diffs); } }; /** * loc is a location in text1, compute and return the equivalent location in * text2. * e.g. 'The cat' vs 'The big cat', 1->1, 5->8 * @param {Array.>} diffs Array of diff tuples. * @param {number} loc Location within text1. * @return {number} Location within text2. */ diff_match_patch.prototype.diff_xIndex = function(diffs, loc) { var chars1 = 0; var chars2 = 0; var last_chars1 = 0; var last_chars2 = 0; var x; for (x = 0; x < diffs.length; x++) { if (diffs[x][0] !== DIFF_INSERT) { // Equality or deletion. chars1 += diffs[x][1].length; } if (diffs[x][0] !== DIFF_DELETE) { // Equality or insertion. chars2 += diffs[x][1].length; } if (chars1 > loc) { // Overshot the location. break; } last_chars1 = chars1; last_chars2 = chars2; } // Was the location was deleted? if (diffs.length != x && diffs[x][0] === DIFF_DELETE) { return last_chars2; } // Add the remaining character length. return last_chars2 + (loc - last_chars1); }; /** * Convert a diff array into a pretty HTML report. * @param {Array.>} diffs Array of diff tuples. * @return {string} HTML representation. */ diff_match_patch.prototype.diff_prettyHtml = function(diffs) { var html = []; var i = 0; for (var x = 0; x < diffs.length; x++) { var op = diffs[x][0]; // Operation (insert, delete, equal) var data = diffs[x][1]; // Text of change. var text = data.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '¶
'); switch (op) { case DIFF_INSERT: html[x] = '' + text + ''; break; case DIFF_DELETE: html[x] = '' + text + ''; break; case DIFF_EQUAL: html[x] = '' + text + ''; break; } if (op !== DIFF_DELETE) { i += data.length; } } return html.join(''); }; /** * Compute and return the source text (all equalities and deletions). * @param {Array.>} diffs Array of diff tuples. * @return {string} Source text. */ diff_match_patch.prototype.diff_text1 = function(diffs) { var text = []; for (var x = 0; x < diffs.length; x++) { if (diffs[x][0] !== DIFF_INSERT) { text[x] = diffs[x][1]; } } return text.join(''); }; /** * Compute and return the destination text (all equalities and insertions). * @param {Array.>} diffs Array of diff tuples. * @return {string} Destination text. */ diff_match_patch.prototype.diff_text2 = function(diffs) { var text = []; for (var x = 0; x < diffs.length; x++) { if (diffs[x][0] !== DIFF_DELETE) { text[x] = diffs[x][1]; } } return text.join(''); }; /** * Compute the Levenshtein distance; the number of inserted, deleted or * substituted characters. * @param {Array.>} diffs Array of diff tuples. * @return {number} Number of changes. */ diff_match_patch.prototype.diff_levenshtein = function(diffs) { var levenshtein = 0; var insertions = 0; var deletions = 0; for (var x = 0; x < diffs.length; x++) { var op = diffs[x][0]; var data = diffs[x][1]; switch (op) { case DIFF_INSERT: insertions += data.length; break; case DIFF_DELETE: deletions += data.length; break; case DIFF_EQUAL: // A deletion and an insertion is one substitution. levenshtein += Math.max(insertions, deletions); insertions = 0; deletions = 0; break; } } levenshtein += Math.max(insertions, deletions); return levenshtein; }; /** * Crush the diff into an encoded string which describes the operations * required to transform text1 into text2. * E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. * Operations are tab-separated. Inserted text is escaped using %xx notation. * @param {Array.>} diffs Array of diff tuples. * @return {string} Delta text. */ diff_match_patch.prototype.diff_toDelta = function(diffs) { var text = []; for (var x = 0; x < diffs.length; x++) { switch (diffs[x][0]) { case DIFF_INSERT: text[x] = '+' + encodeURI(diffs[x][1]); break; case DIFF_DELETE: text[x] = '-' + diffs[x][1].length; break; case DIFF_EQUAL: text[x] = '=' + diffs[x][1].length; break; } } // Opera doesn't know how to encode char 0. return text.join('\t').replace(/\x00/g, '%00').replace(/%20/g, ' '); }; /** * Given the original text1, and an encoded string which describes the * operations required to transform text1 into text2, compute the full diff. * @param {string} text1 Source string for the diff. * @param {string} delta Delta text. * @return {Array.>} Array of diff tuples. * @throws {Error} If invalid input. */ diff_match_patch.prototype.diff_fromDelta = function(text1, delta) { var diffs = []; var diffsLength = 0; // Keeping our own length var is faster in JS. var pointer = 0; // Cursor in text1 // Opera doesn't know how to decode char 0. delta = delta.replace(/%00/g, '\0'); var tokens = delta.split(/\t/g); for (var x = 0; x < tokens.length; x++) { // Each token begins with a one character parameter which specifies the // operation of this token (delete, insert, equality). var param = tokens[x].substring(1); switch (tokens[x].charAt(0)) { case '+': try { diffs[diffsLength++] = [DIFF_INSERT, decodeURI(param)]; } catch (ex) { // Malformed URI sequence. throw new Error('Illegal escape in diff_fromDelta: ' + param); } break; case '-': // Fall through. case '=': var n = parseInt(param, 10); if (isNaN(n) || n < 0) { throw new Error('Invalid number in diff_fromDelta: ' + param); } var text = text1.substring(pointer, pointer += n); if (tokens[x].charAt(0) == '=') { diffs[diffsLength++] = [DIFF_EQUAL, text]; } else { diffs[diffsLength++] = [DIFF_DELETE, text]; } break; default: // Blank tokens are ok (from a trailing \t). // Anything else is an error. if (tokens[x]) { throw new Error('Invalid diff operation in diff_fromDelta: ' + tokens[x]); } } } if (pointer != text1.length) { throw new Error('Delta length (' + pointer + ') does not equal source text length (' + text1.length + ').'); } return diffs; }; // MATCH FUNCTIONS /** * Locate the best instance of 'pattern' in 'text' near 'loc'. * @param {string} text The text to search. * @param {string} pattern The pattern to search for. * @param {number} loc The location to search around. * @return {number} Best match index or -1. */ diff_match_patch.prototype.match_main = function(text, pattern, loc) { loc = Math.max(0, Math.min(loc, text.length)); if (text == pattern) { // Shortcut (potentially not guaranteed by the algorithm) return 0; } else if (!text.length) { // Nothing to match. return -1; } else if (text.substring(loc, loc + pattern.length) == pattern) { // Perfect match at the perfect spot! (Includes case of null pattern) return loc; } else { // Do a fuzzy compare. return this.match_bitap(text, pattern, loc); } }; /** * Locate the best instance of 'pattern' in 'text' near 'loc' using the * Bitap algorithm. * @param {string} text The text to search. * @param {string} pattern The pattern to search for. * @param {number} loc The location to search around. * @return {number} Best match index or -1. * @private */ diff_match_patch.prototype.match_bitap = function(text, pattern, loc) { if (pattern.length > this.Match_MaxBits) { throw new Error('Pattern too long for this browser.'); } // Initialise the alphabet. var s = this.match_alphabet(pattern); var dmp = this; // 'this' becomes 'window' in a closure. /** * Compute and return the score for a match with e errors and x location. * Accesses loc and pattern through being a closure. * @param {number} e Number of errors in match. * @param {number} x Location of match. * @return {number} Overall score for match (0.0 = good, 1.0 = bad). * @private */ function match_bitapScore(e, x) { var accuracy = e / pattern.length; var proximity = Math.abs(loc - x); if (!dmp.Match_Distance) { // Dodge divide by zero error. return proximity ? 1.0 : accuracy; } return accuracy + (proximity / dmp.Match_Distance); } // Highest score beyond which we give up. var score_threshold = this.Match_Threshold; // Is there a nearby exact match? (speedup) var best_loc = text.indexOf(pattern, loc); if (best_loc != -1) { score_threshold = Math.min(match_bitapScore(0, best_loc), score_threshold); } // What about in the other direction? (speedup) best_loc = text.lastIndexOf(pattern, loc + pattern.length); if (best_loc != -1) { score_threshold = Math.min(match_bitapScore(0, best_loc), score_threshold); } // Initialise the bit arrays. var matchmask = 1 << (pattern.length - 1); best_loc = -1; var bin_min, bin_mid; var bin_max = pattern.length + text.length; var last_rd; for (var d = 0; d < pattern.length; d++) { // Scan for the best match; each iteration allows for one more error. // Run a binary search to determine how far from 'loc' we can stray at this // error level. bin_min = 0; bin_mid = bin_max; while (bin_min < bin_mid) { if (match_bitapScore(d, loc + bin_mid) <= score_threshold) { bin_min = bin_mid; } else { bin_max = bin_mid; } bin_mid = Math.floor((bin_max - bin_min) / 2 + bin_min); } // Use the result from this iteration as the maximum for the next. bin_max = bin_mid; var start = Math.max(1, loc - bin_mid + 1); var finish = Math.min(loc + bin_mid, text.length) + pattern.length; var rd = Array(finish + 2); rd[finish + 1] = (1 << d) - 1; for (var j = finish; j >= start; j--) { // The alphabet (s) is a sparse hash, so the following line generates // warnings. var charMatch = s[text.charAt(j - 1)]; if (d === 0) { // First pass: exact match. rd[j] = ((rd[j + 1] << 1) | 1) & charMatch; } else { // Subsequent passes: fuzzy match. rd[j] = ((rd[j + 1] << 1) | 1) & charMatch | (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | last_rd[j + 1]; } if (rd[j] & matchmask) { var score = match_bitapScore(d, j - 1); // This match will almost certainly be better than any existing match. // But check anyway. if (score <= score_threshold) { // Told you so. score_threshold = score; best_loc = j - 1; if (best_loc > loc) { // When passing loc, don't exceed our current distance from loc. start = Math.max(1, 2 * loc - best_loc); } else { // Already passed loc, downhill from here on in. break; } } } } // No hope for a (better) match at greater error levels. if (match_bitapScore(d + 1, loc) > score_threshold) { break; } last_rd = rd; } return best_loc; }; /** * Initialise the alphabet for the Bitap algorithm. * @param {string} pattern The text to encode. * @return {Object} Hash of character locations. * @private */ diff_match_patch.prototype.match_alphabet = function(pattern) { var s = {}; for (var i = 0; i < pattern.length; i++) { s[pattern.charAt(i)] = 0; } for (var i = 0; i < pattern.length; i++) { s[pattern.charAt(i)] |= 1 << (pattern.length - i - 1); } return s; }; // PATCH FUNCTIONS /** * Increase the context until it is unique, * but don't let the pattern expand beyond Match_MaxBits. * @param {patch_obj} patch The patch to grow. * @param {string} text Source text. * @private */ diff_match_patch.prototype.patch_addContext = function(patch, text) { var pattern = text.substring(patch.start2, patch.start2 + patch.length1); var padding = 0; while (text.indexOf(pattern) != text.lastIndexOf(pattern) && pattern.length < this.Match_MaxBits - this.Patch_Margin - this.Patch_Margin) { padding += this.Patch_Margin; pattern = text.substring(patch.start2 - padding, patch.start2 + patch.length1 + padding); } // Add one chunk for good luck. padding += this.Patch_Margin; // Add the prefix. var prefix = text.substring(patch.start2 - padding, patch.start2); if (prefix) { patch.diffs.unshift([DIFF_EQUAL, prefix]); } // Add the suffix. var suffix = text.substring(patch.start2 + patch.length1, patch.start2 + patch.length1 + padding); if (suffix) { patch.diffs.push([DIFF_EQUAL, suffix]); } // Roll back the start points. patch.start1 -= prefix.length; patch.start2 -= prefix.length; // Extend the lengths. patch.length1 += prefix.length + suffix.length; patch.length2 += prefix.length + suffix.length; }; /** * Compute a list of patches to turn text1 into text2. * Use diffs if provided, otherwise compute it ourselves. * There are four ways to call this function, depending on what data is * available to the caller: * Method 1: * a = text1, b = text2 * Method 2: * a = diffs * Method 3 (optimal): * a = text1, b = diffs * Method 4 (deprecated, use method 3): * a = text1, b = text2, c = diffs * * @param {string|Array.>} a text1 (methods 1,3,4) or Array of diff * tuples for text1 to text2 (method 2). * @param {string|Array.>} opt_b text2 (methods 1,4) or Array of diff * tuples for text1 to text2 (method 3) or undefined (method 2). * @param {string|Array.>} opt_c Array of diff tuples for text1 to * text2 (method 4) or undefined (methods 1,2,3). * @return {Array.>} Array of patch objects. */ diff_match_patch.prototype.patch_make = function(a, opt_b, opt_c) { var text1, diffs; if (typeof a == 'string' && typeof opt_b == 'string' && typeof opt_c == 'undefined') { // Method 1: text1, text2 // Compute diffs from text1 and text2. text1 = a; diffs = this.diff_main(text1, opt_b, true); if (diffs.length > 2) { this.diff_cleanupSemantic(diffs); this.diff_cleanupEfficiency(diffs); } } else if (typeof a == 'object' && typeof opt_b == 'undefined' && typeof opt_c == 'undefined') { // Method 2: diffs // Compute text1 from diffs. diffs = a; text1 = this.diff_text1(diffs); } else if (typeof a == 'string' && typeof opt_b == 'object' && typeof opt_c == 'undefined') { // Method 3: text1, diffs text1 = a; diffs = opt_b; } else if (typeof a == 'string' && typeof opt_b == 'string' && typeof opt_c == 'object') { // Method 4: text1, text2, diffs // text2 is not used. text1 = a; diffs = opt_c; } else { throw new Error('Unknown call format to patch_make.'); } if (diffs.length === 0) { return []; // Get rid of the null case. } var patches = []; var patch = new patch_obj(); var patchDiffLength = 0; // Keeping our own length var is faster in JS. var char_count1 = 0; // Number of characters into the text1 string. var char_count2 = 0; // Number of characters into the text2 string. // Start with text1 (prepatch_text) and apply the diffs until we arrive at // text2 (postpatch_text). We recreate the patches one by one to determine // context info. var prepatch_text = text1; var postpatch_text = text1; for (var x = 0; x < diffs.length; x++) { var diff_type = diffs[x][0]; var diff_text = diffs[x][1]; if (!patchDiffLength && diff_type !== DIFF_EQUAL) { // A new patch starts here. patch.start1 = char_count1; patch.start2 = char_count2; } switch (diff_type) { case DIFF_INSERT: patch.diffs[patchDiffLength++] = diffs[x]; patch.length2 += diff_text.length; postpatch_text = postpatch_text.substring(0, char_count2) + diff_text + postpatch_text.substring(char_count2); break; case DIFF_DELETE: patch.length1 += diff_text.length; patch.diffs[patchDiffLength++] = diffs[x]; postpatch_text = postpatch_text.substring(0, char_count2) + postpatch_text.substring(char_count2 + diff_text.length); break; case DIFF_EQUAL: if (diff_text.length <= 2 * this.Patch_Margin && patchDiffLength && diffs.length != x + 1) { // Small equality inside a patch. patch.diffs[patchDiffLength++] = diffs[x]; patch.length1 += diff_text.length; patch.length2 += diff_text.length; } else if (diff_text.length >= 2 * this.Patch_Margin) { // Time for a new patch. if (patchDiffLength) { this.patch_addContext(patch, prepatch_text); patches.push(patch); patch = new patch_obj(); patchDiffLength = 0; // Unlike Unidiff, our patch lists have a rolling context. // http://code.google.com/p/google-diff-match-patch/wiki/Unidiff // Update prepatch text & pos to reflect the application of the // just completed patch. prepatch_text = postpatch_text; char_count1 = char_count2; } } break; } // Update the current character count. if (diff_type !== DIFF_INSERT) { char_count1 += diff_text.length; } if (diff_type !== DIFF_DELETE) { char_count2 += diff_text.length; } } // Pick up the leftover patch if not empty. if (patchDiffLength) { this.patch_addContext(patch, prepatch_text); patches.push(patch); } return patches; }; /** * Given an array of patches, return another array that is identical. * @param {Array.} patches Array of patch objects. * @return {Array.} Array of patch objects. */ diff_match_patch.prototype.patch_deepCopy = function(patches) { // Making deep copies is hard in JavaScript. var patchesCopy = []; for (var x = 0; x < patches.length; x++) { var patch = patches[x]; var patchCopy = new patch_obj(); patchCopy.diffs = []; for (var y = 0; y < patch.diffs.length; y++) { patchCopy.diffs[y] = patch.diffs[y].slice(); } patchCopy.start1 = patch.start1; patchCopy.start2 = patch.start2; patchCopy.length1 = patch.length1; patchCopy.length2 = patch.length2; patchesCopy[x] = patchCopy; } return patchesCopy; }; /** * Merge a set of patches onto the text. Return a patched text, as well * as a list of true/false values indicating which patches were applied. * @param {Array.} patches Array of patch objects. * @param {string} text Old text. * @return {Array.>} Two element Array, containing the * new text and an array of boolean values. */ diff_match_patch.prototype.patch_apply = function(patches, text) { if (patches.length == 0) { return [text, []]; } // Deep copy the patches so that no changes are made to originals. patches = this.patch_deepCopy(patches); var nullPadding = this.patch_addPadding(patches); text = nullPadding + text + nullPadding; this.patch_splitMax(patches); // delta keeps track of the offset between the expected and actual location // of the previous patch. If there are patches expected at positions 10 and // 20, but the first patch was found at 12, delta is 2 and the second patch // has an effective expected position of 22. var delta = 0; var results = []; for (var x = 0; x < patches.length; x++) { var expected_loc = patches[x].start2 + delta; var text1 = this.diff_text1(patches[x].diffs); var start_loc; var end_loc = -1; if (text1.length > this.Match_MaxBits) { // patch_splitMax will only provide an oversized pattern in the case of // a monster delete. start_loc = this.match_main(text, text1.substring(0, this.Match_MaxBits), expected_loc); if (start_loc != -1) { end_loc = this.match_main(text, text1.substring(text1.length - this.Match_MaxBits), expected_loc + text1.length - this.Match_MaxBits); if (end_loc == -1 || start_loc >= end_loc) { // Can't find valid trailing context. Drop this patch. start_loc = -1; } } } else { start_loc = this.match_main(text, text1, expected_loc); } if (start_loc == -1) { // No match found. :( results[x] = false; } else { // Found a match. :) results[x] = true; delta = start_loc - expected_loc; var text2; if (end_loc == -1) { text2 = text.substring(start_loc, start_loc + text1.length); } else { text2 = text.substring(start_loc, end_loc + this.Match_MaxBits); } if (text1 == text2) { // Perfect match, just shove the replacement text in. text = text.substring(0, start_loc) + this.diff_text2(patches[x].diffs) + text.substring(start_loc + text1.length); } else { // Imperfect match. Run a diff to get a framework of equivalent // indices. var diffs = this.diff_main(text1, text2, false); if (text1.length > this.Match_MaxBits && this.diff_levenshtein(diffs) / text1.length > this.Patch_DeleteThreshold) { // The end points match, but the content is unacceptably bad. results[x] = false; } else { this.diff_cleanupSemanticLossless(diffs); var index1 = 0; var index2; for (var y = 0; y < patches[x].diffs.length; y++) { var mod = patches[x].diffs[y]; if (mod[0] !== DIFF_EQUAL) { index2 = this.diff_xIndex(diffs, index1); } if (mod[0] === DIFF_INSERT) { // Insertion text = text.substring(0, start_loc + index2) + mod[1] + text.substring(start_loc + index2); } else if (mod[0] === DIFF_DELETE) { // Deletion text = text.substring(0, start_loc + index2) + text.substring(start_loc + this.diff_xIndex(diffs, index1 + mod[1].length)); } if (mod[0] !== DIFF_DELETE) { index1 += mod[1].length; } } } } } } // Strip the padding off. text = text.substring(nullPadding.length, text.length - nullPadding.length); return [text, results]; }; /** * Add some padding on text start and end so that edges can match something. * Intended to be called only from within patch_apply. * @param {Array.} patches Array of patch objects. * @return {string} The padding string added to each side. */ diff_match_patch.prototype.patch_addPadding = function(patches) { var nullPadding = ''; for (var x = 1; x <= this.Patch_Margin; x++) { nullPadding += String.fromCharCode(x); } // Bump all the patches forward. for (var x = 0; x < patches.length; x++) { patches[x].start1 += nullPadding.length; patches[x].start2 += nullPadding.length; } // Add some padding on start of first diff. var patch = patches[0]; var diffs = patch.diffs; if (diffs.length == 0 || diffs[0][0] != DIFF_EQUAL) { // Add nullPadding equality. diffs.unshift([DIFF_EQUAL, nullPadding]); patch.start1 -= nullPadding.length; // Should be 0. patch.start2 -= nullPadding.length; // Should be 0. patch.length1 += nullPadding.length; patch.length2 += nullPadding.length; } else if (nullPadding.length > diffs[0][1].length) { // Grow first equality. var extraLength = nullPadding.length - diffs[0][1].length; diffs[0][1] = nullPadding.substring(diffs[0][1].length) + diffs[0][1]; patch.start1 -= extraLength; patch.start2 -= extraLength; patch.length1 += extraLength; patch.length2 += extraLength; } // Add some padding on end of last diff. patch = patches[patches.length - 1]; diffs = patch.diffs; if (diffs.length == 0 || diffs[diffs.length - 1][0] != DIFF_EQUAL) { // Add nullPadding equality. diffs.push([DIFF_EQUAL, nullPadding]); patch.length1 += nullPadding.length; patch.length2 += nullPadding.length; } else if (nullPadding.length > diffs[diffs.length - 1][1].length) { // Grow last equality. var extraLength = nullPadding.length - diffs[diffs.length - 1][1].length; diffs[diffs.length - 1][1] += nullPadding.substring(0, extraLength); patch.length1 += extraLength; patch.length2 += extraLength; } return nullPadding; }; /** * Look through the patches and break up any which are longer than the maximum * limit of the match algorithm. * @param {Array.} patches Array of patch objects. */ diff_match_patch.prototype.patch_splitMax = function(patches) { for (var x = 0; x < patches.length; x++) { if (patches[x].length1 > this.Match_MaxBits) { var bigpatch = patches[x]; // Remove the big old patch. patches.splice(x--, 1); var patch_size = this.Match_MaxBits; var start1 = bigpatch.start1; var start2 = bigpatch.start2; var precontext = ''; while (bigpatch.diffs.length !== 0) { // Create one of several smaller patches. var patch = new patch_obj(); var empty = true; patch.start1 = start1 - precontext.length; patch.start2 = start2 - precontext.length; if (precontext !== '') { patch.length1 = patch.length2 = precontext.length; patch.diffs.push([DIFF_EQUAL, precontext]); } while (bigpatch.diffs.length !== 0 && patch.length1 < patch_size - this.Patch_Margin) { var diff_type = bigpatch.diffs[0][0]; var diff_text = bigpatch.diffs[0][1]; if (diff_type === DIFF_INSERT) { // Insertions are harmless. patch.length2 += diff_text.length; start2 += diff_text.length; patch.diffs.push(bigpatch.diffs.shift()); empty = false; } else if (diff_type === DIFF_DELETE && patch.diffs.length == 1 && patch.diffs[0][0] == DIFF_EQUAL && diff_text.length > 2 * patch_size) { // This is a large deletion. Let it pass in one chunk. patch.length1 += diff_text.length; start1 += diff_text.length; empty = false; patch.diffs.push([diff_type, diff_text]); bigpatch.diffs.shift(); } else { // Deletion or equality. Only take as much as we can stomach. diff_text = diff_text.substring(0, patch_size - patch.length1 - this.Patch_Margin); patch.length1 += diff_text.length; start1 += diff_text.length; if (diff_type === DIFF_EQUAL) { patch.length2 += diff_text.length; start2 += diff_text.length; } else { empty = false; } patch.diffs.push([diff_type, diff_text]); if (diff_text == bigpatch.diffs[0][1]) { bigpatch.diffs.shift(); } else { bigpatch.diffs[0][1] = bigpatch.diffs[0][1].substring(diff_text.length); } } } // Compute the head context for the next patch. precontext = this.diff_text2(patch.diffs); precontext = precontext.substring(precontext.length - this.Patch_Margin); // Append the end context for this patch. var postcontext = this.diff_text1(bigpatch.diffs) .substring(0, this.Patch_Margin); if (postcontext !== '') { patch.length1 += postcontext.length; patch.length2 += postcontext.length; if (patch.diffs.length !== 0 && patch.diffs[patch.diffs.length - 1][0] === DIFF_EQUAL) { patch.diffs[patch.diffs.length - 1][1] += postcontext; } else { patch.diffs.push([DIFF_EQUAL, postcontext]); } } if (!empty) { patches.splice(++x, 0, patch); } } } } }; /** * Take a list of patches and return a textual representation. * @param {Array.} patches Array of patch objects. * @return {string} Text representation of patches. */ diff_match_patch.prototype.patch_toText = function(patches) { var text = []; for (var x = 0; x < patches.length; x++) { text[x] = patches[x]; } return text.join(''); }; /** * Parse a textual representation of patches and return a list of patch objects. * @param {string} textline Text representation of patches. * @return {Array.} Array of patch objects. * @throws {Error} If invalid input. */ diff_match_patch.prototype.patch_fromText = function(textline) { var patches = []; if (!textline) { return patches; } // Opera doesn't know how to decode char 0. textline = textline.replace(/%00/g, '\0'); var text = textline.split('\n'); var textPointer = 0; while (textPointer < text.length) { var m = text[textPointer].match(/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@$/); if (!m) { throw new Error('Invalid patch string: ' + text[textPointer]); } var patch = new patch_obj(); patches.push(patch); patch.start1 = parseInt(m[1], 10); if (m[2] === '') { patch.start1--; patch.length1 = 1; } else if (m[2] == '0') { patch.length1 = 0; } else { patch.start1--; patch.length1 = parseInt(m[2], 10); } patch.start2 = parseInt(m[3], 10); if (m[4] === '') { patch.start2--; patch.length2 = 1; } else if (m[4] == '0') { patch.length2 = 0; } else { patch.start2--; patch.length2 = parseInt(m[4], 10); } textPointer++; while (textPointer < text.length) { var sign = text[textPointer].charAt(0); try { var line = decodeURI(text[textPointer].substring(1)); } catch (ex) { // Malformed URI sequence. throw new Error('Illegal escape in patch_fromText: ' + line); } if (sign == '-') { // Deletion. patch.diffs.push([DIFF_DELETE, line]); } else if (sign == '+') { // Insertion. patch.diffs.push([DIFF_INSERT, line]); } else if (sign == ' ') { // Minor equality. patch.diffs.push([DIFF_EQUAL, line]); } else if (sign == '@') { // Start of next patch. break; } else if (sign === '') { // Blank line? Whatever. } else { // WTF? throw new Error('Invalid patch mode "' + sign + '" in: ' + line); } textPointer++; } } return patches; }; /** * Class representing one patch operation. * @constructor */ function patch_obj() { this.diffs = []; /** @type {number?} */ this.start1 = null; /** @type {number?} */ this.start2 = null; this.length1 = 0; this.length2 = 0; } /** * Emmulate GNU diff's format. * Header: @@ -382,8 +481,9 @@ * Indicies are printed as 1-based, not 0-based. * @return {string} The GNU diff string. */ patch_obj.prototype.toString = function() { var coords1, coords2; if (this.length1 === 0) { coords1 = this.start1 + ',0'; } else if (this.length1 == 1) { coords1 = this.start1 + 1; } else { coords1 = (this.start1 + 1) + ',' + this.length1; } if (this.length2 === 0) { coords2 = this.start2 + ',0'; } else if (this.length2 == 1) { coords2 = this.start2 + 1; } else { coords2 = (this.start2 + 1) + ',' + this.length2; } var text = ['@@ -' + coords1 + ' +' + coords2 + ' @@\n']; var op; // Escape the body of the patch with %xx notation. for (var x = 0; x < this.diffs.length; x++) { switch (this.diffs[x][0]) { case DIFF_INSERT: op = '+'; break; case DIFF_DELETE: op = '-'; break; case DIFF_EQUAL: op = ' '; break; } text[x + 1] = op + encodeURI(this.diffs[x][1]) + '\n'; } // Opera doesn't know how to encode char 0. return text.join('').replace(/\x00/g, '%00').replace(/%20/g, ' '); }; whiteboard/mobwrite/mobwrite_form.js0000644000175000017500000005777012251036356017260 0ustar ernieernie/** * MobWrite - Real-time Synchronization and Collaboration Service * * Copyright 2008 Google Inc. * http://code.google.com/p/google-mobwrite/ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @fileoverview This client-side code interfaces with form elements. * @author fraser@google.com (Neil Fraser) */ /** * Checks to see if the provided node is still part of the DOM. * @param {Node} node DOM node to verify. * @return {boolean} Is this node part of a DOM? * @private */ mobwrite.validNode_ = function(node) { while (node.parentNode) { node = node.parentNode; } // The topmost node should be type 9, a document. return node.nodeType == 9; }; // FORM /** * Handler to accept forms as elements that can be shared. * Share each of the form's elements. * @param {*} node Object or ID of object to share * @return {Object?} A sharing object or null. */ mobwrite.shareHandlerForm = function(form) { if (typeof form == 'string') { form = document.getElementById(form) || document.forms[form]; } if (form && 'tagName' in form && form.tagName == 'FORM') { for (var x = 0, el; el = form.elements[x]; x++) { mobwrite.share(el); } } return null; }; // Register this shareHandler with MobWrite. mobwrite.shareHandlers.push(mobwrite.shareHandlerForm); // HIDDEN /** * Constructor of shared object representing a hidden input. * @param {Node} node A hidden element. * @constructor */ mobwrite.shareHiddenObj = function(node) { // Call our prototype's constructor. mobwrite.shareObj.apply(this, [node.id]); this.element = node; }; // The hidden input's shared object's parent is a shareObj. mobwrite.shareHiddenObj.prototype = new mobwrite.shareObj(''); /** * Retrieve the user's content. * @return {string} Plaintext content. */ mobwrite.shareHiddenObj.prototype.getClientText = function() { if (!mobwrite.validNode_(this.element)) { mobwrite.unshare(this.file); } // Numeric data should use overwrite mode. this.mergeChanges = !this.element.value.match(/^\s*-?[\d.]+\s*$/); return this.element.value; }; /** * Set the user's content. * @param {string} text New content. */ mobwrite.shareHiddenObj.prototype.setClientText = function(text) { this.element.value = text; }; /** * Handler to accept hidden fields as elements that can be shared. * If the element is a hidden field, create a new sharing object. * @param {*} node Object or ID of object to share. * @return {Object?} A sharing object or null. */ mobwrite.shareHiddenObj.shareHandler = function(node) { if (typeof node == 'string') { node = document.getElementById(node); } if (node && 'type' in node && node.type == 'hidden') { return new mobwrite.shareHiddenObj(node); } return null; }; // Register this shareHandler with MobWrite. mobwrite.shareHandlers.push(mobwrite.shareHiddenObj.shareHandler); // CHECKBOX /** * Constructor of shared object representing a checkbox. * @param {Node} node A checkbox element. * @constructor */ mobwrite.shareCheckboxObj = function(node) { // Call our prototype's constructor. mobwrite.shareObj.apply(this, [node.id]); this.element = node; this.mergeChanges = false; }; // The checkbox shared object's parent is a shareObj. mobwrite.shareCheckboxObj.prototype = new mobwrite.shareObj(''); /** * Retrieve the user's check. * @return {string} Plaintext content. */ mobwrite.shareCheckboxObj.prototype.getClientText = function() { if (!mobwrite.validNode_(this.element)) { mobwrite.unshare(this.file); } return this.element.checked ? this.element.value : ''; }; /** * Set the user's check. * @param {string} text New content. */ mobwrite.shareCheckboxObj.prototype.setClientText = function(text) { // Safari has a blank value if not set, all other browsers have 'on'. var value = this.element.value || 'on'; this.element.checked = (text == value); this.fireChange(this.element); }; /** * Handler to accept checkboxen as elements that can be shared. * If the element is a checkbox, create a new sharing object. * @param {*} node Object or ID of object to share. * @return {Object?} A sharing object or null. */ mobwrite.shareCheckboxObj.shareHandler = function(node) { if (typeof node == 'string') { node = document.getElementById(node); } if (node && 'type' in node && node.type == 'checkbox') { return new mobwrite.shareCheckboxObj(node); } return null; }; // Register this shareHandler with MobWrite. mobwrite.shareHandlers.push(mobwrite.shareCheckboxObj.shareHandler); // SELECT OPTION /** * Constructor of shared object representing a select box. * @param {Node} node A select box element. * @constructor */ mobwrite.shareSelectObj = function(node) { // Call our prototype's constructor. mobwrite.shareObj.apply(this, [node.id]); this.element = node; // If the select box is select-one, use overwrite mode. // If it is select-multiple, use text merge mode. this.mergeChanges = (node.type == 'select-multiple'); }; // The select box shared object's parent is a shareObj. mobwrite.shareSelectObj.prototype = new mobwrite.shareObj(''); /** * Retrieve the user's selection(s). * @return {string} Plaintext content. */ mobwrite.shareSelectObj.prototype.getClientText = function() { if (!mobwrite.validNode_(this.element)) { mobwrite.unshare(this.file); } var selected = []; for (var x = 0, option; option = this.element.options[x]; x++) { if (option.selected) { selected.push(option.value); } } return selected.join('\0'); }; /** * Set the user's selection(s). * @param {string} text New content. */ mobwrite.shareSelectObj.prototype.setClientText = function(text) { text = '\0' + text + '\0'; for (var x = 0, option; option = this.element.options[x]; x++) { option.selected = (text.indexOf('\0' + option.value + '\0') != -1); } this.fireChange(this.element); }; /** * Handler to accept select boxen as elements that can be shared. * If the element is a select box, create a new sharing object. * @param {*} node Object or ID of object to share * @return {Object?} A sharing object or null. */ mobwrite.shareSelectObj.shareHandler = function(node) { if (typeof node == 'string') { node = document.getElementById(node); } if (node && 'type' in node && (node.type == 'select-one' || node.type == 'select-multiple')) { return new mobwrite.shareSelectObj(node); } return null; }; // Register this shareHandler with MobWrite. mobwrite.shareHandlers.push(mobwrite.shareSelectObj.shareHandler); // RADIO BUTTON /** * Constructor of shared object representing a radio button. * @param {Node} node A radio button element. * @constructor */ mobwrite.shareRadioObj = function(node) { // Call our prototype's constructor. mobwrite.shareObj.apply(this, [node.id]); this.elements = [node]; this.form = node.form; this.name = node.name; this.mergeChanges = false; }; // The radio button shared object's parent is a shareObj. mobwrite.shareRadioObj.prototype = new mobwrite.shareObj(''); /** * Retrieve the user's check. * @return {string} Plaintext content. */ mobwrite.shareRadioObj.prototype.getClientText = function() { // TODO: Handle cases where the radio buttons are added or removed. if (!mobwrite.validNode_(this.elements[0])) { mobwrite.unshare(this.file); } // Group of radio buttons for (var x = 0; x < this.elements.length; x++) { if (this.elements[x].checked) { return this.elements[x].value; } } // Nothing checked. return ''; }; /** * Set the user's check. * @param {string} text New content. */ mobwrite.shareRadioObj.prototype.setClientText = function(text) { for (var x = 0; x < this.elements.length; x++) { this.elements[x].checked = (text == this.elements[x].value); this.fireChange(this.elements[x]); } }; /** * Handler to accept radio buttons as elements that can be shared. * If the element is a radio button, create a new sharing object. * @param {*} node Object or ID of object to share. * @return {Object?} A sharing object or null. */ mobwrite.shareRadioObj.shareHandler = function(node) { if (typeof node == 'string') { node = document.getElementById(node); } if (node && 'type' in node && node.type == 'radio') { // Check to see if this is another element of an existing radio button group. for (var id in mobwrite.shared) { if (mobwrite.shared[id].form == node.form && mobwrite.shared[id].name == node.name) { mobwrite.shared[id].elements.push(node); return null; } } // Create new radio button object. return new mobwrite.shareRadioObj(node); } return null; }; // Register this shareHandler with MobWrite. mobwrite.shareHandlers.push(mobwrite.shareRadioObj.shareHandler); // TEXTAREA, TEXT & PASSWORD INPUTS /** * Constructor of shared object representing a text field. * @param {Node} node A textarea, text or password input. * @constructor */ mobwrite.shareTextareaObj = function(node) { // Call our prototype's constructor. mobwrite.shareObj.apply(this, [node.id]); this.element = node; if (node.type == 'password') { // Use overwrite mode for password field, users can't see. this.mergeChanges = false; } }; // The textarea shared object's parent is a shareObj. mobwrite.shareTextareaObj.prototype = new mobwrite.shareObj(''); /** * Retrieve the user's text. * @return {string} Plaintext content. */ mobwrite.shareTextareaObj.prototype.getClientText = function() { if (!mobwrite.validNode_(this.element)) { mobwrite.unshare(this.file); } var text = mobwrite.shareTextareaObj.normalizeLinebreaks_(this.element.value); if (this.element.type == 'text') { // Numeric data should use overwrite mode. this.mergeChanges = !text.match(/^\s*-?[\d.,]+\s*$/); } return text; }; /** * Set the user's text. * @param {string} text New text */ mobwrite.shareTextareaObj.prototype.setClientText = function(text) { this.element.value = text; this.fireChange(this.element); }; /** * Modify the user's plaintext by applying a series of patches against it. * @param {Array.} patches Array of Patch objects. */ mobwrite.shareTextareaObj.prototype.patchClientText = function(patches) { // Set some constants which tweak the matching behaviour. // Maximum distance to search from expected location. this.dmp.Match_Distance = 1000; // At what point is no match declared (0.0 = perfection, 1.0 = very loose) this.dmp.Match_Threshold = 0.6; var oldClientText = this.getClientText(); var cursor = this.captureCursor_(); // Pack the cursor offsets into an array to be adjusted. // See http://neil.fraser.name/writing/cursor/ var offsets = []; if (cursor) { offsets[0] = cursor.startOffset; if ('endOffset' in cursor) { offsets[1] = cursor.endOffset; } } var newClientText = this.patch_apply_(patches, oldClientText, offsets); // Set the new text only if there is a change to be made. if (oldClientText != newClientText) { this.setClientText(newClientText); if (cursor) { // Unpack the offset array. cursor.startOffset = offsets[0]; if (offsets.length > 1) { cursor.endOffset = offsets[1]; if (cursor.startOffset >= cursor.endOffset) { cursor.collapsed = true; } } this.restoreCursor_(cursor); } } }; /** * Merge a set of patches onto the text. Return a patched text. * @param {Array.} patches Array of patch objects. * @param {string} text Old text. * @param {Array.} offsets Offset indices to adjust. * @return {string} New text. */ mobwrite.shareTextareaObj.prototype.patch_apply_ = function(patches, text, offsets) { if (patches.length == 0) { return text; } // Deep copy the patches so that no changes are made to originals. patches = this.dmp.patch_deepCopy(patches); var nullPadding = this.dmp.patch_addPadding(patches); text = nullPadding + text + nullPadding; this.dmp.patch_splitMax(patches); // delta keeps track of the offset between the expected and actual location // of the previous patch. If there are patches expected at positions 10 and // 20, but the first patch was found at 12, delta is 2 and the second patch // has an effective expected position of 22. var delta = 0; for (var x = 0; x < patches.length; x++) { var expected_loc = patches[x].start2 + delta; var text1 = this.dmp.diff_text1(patches[x].diffs); var start_loc; var end_loc = -1; if (text1.length > this.dmp.Match_MaxBits) { // patch_splitMax will only provide an oversized pattern in the case of // a monster delete. start_loc = this.dmp.match_main(text, text1.substring(0, this.dmp.Match_MaxBits), expected_loc); if (start_loc != -1) { end_loc = this.dmp.match_main(text, text1.substring(text1.length - this.dmp.Match_MaxBits), expected_loc + text1.length - this.dmp.Match_MaxBits); if (end_loc == -1 || start_loc >= end_loc) { // Can't find valid trailing context. Drop this patch. start_loc = -1; } } } else { start_loc = this.dmp.match_main(text, text1, expected_loc); } if (start_loc == -1) { // No match found. :( if (mobwrite.debug) { window.console.warn('Patch failed: ' + patches[x]); } } else { // Found a match. :) if (mobwrite.debug) { window.console.info('Patch OK.'); } delta = start_loc - expected_loc; var text2; if (end_loc == -1) { text2 = text.substring(start_loc, start_loc + text1.length); } else { text2 = text.substring(start_loc, end_loc + this.dmp.Match_MaxBits); } // Run a diff to get a framework of equivalent indices. var diffs = this.dmp.diff_main(text1, text2, false); if (text1.length > this.dmp.Match_MaxBits && this.dmp.diff_levenshtein(diffs) / text1.length > this.dmp.Patch_DeleteThreshold) { // The end points match, but the content is unacceptably bad. if (mobwrite.debug) { window.console.warn('Patch contents mismatch: ' + patches[x]); } } else { var index1 = 0; var index2; for (var y = 0; y < patches[x].diffs.length; y++) { var mod = patches[x].diffs[y]; if (mod[0] !== DIFF_EQUAL) { index2 = this.dmp.diff_xIndex(diffs, index1); } if (mod[0] === DIFF_INSERT) { // Insertion text = text.substring(0, start_loc + index2) + mod[1] + text.substring(start_loc + index2); for (var i = 0; i < offsets.length; i++) { if (offsets[i] + nullPadding.length > start_loc + index2) { offsets[i] += mod[1].length; } } } else if (mod[0] === DIFF_DELETE) { // Deletion var del_start = start_loc + index2; var del_end = start_loc + this.dmp.diff_xIndex(diffs, index1 + mod[1].length); text = text.substring(0, del_start) + text.substring(del_end); for (var i = 0; i < offsets.length; i++) { if (offsets[i] + nullPadding.length > del_start) { if (offsets[i] + nullPadding.length < del_end) { offsets[i] = del_start - nullPadding.length; } else { offsets[i] -= del_end - del_start; } } } } if (mod[0] !== DIFF_DELETE) { index1 += mod[1].length; } } } } } // Strip the padding off. text = text.substring(nullPadding.length, text.length - nullPadding.length); return text; }; /** * Record information regarding the current cursor. * @return {Object?} Context information of the cursor. * @private */ mobwrite.shareTextareaObj.prototype.captureCursor_ = function() { if ('activeElement' in this.element && !this.element.activeElement) { // Safari specific code. // Restoring a cursor in an unfocused element causes the focus to jump. return null; } var padLength = this.dmp.Match_MaxBits / 2; // Normally 16. var text = this.element.value; var cursor = {}; if ('selectionStart' in this.element) { // W3 try { var selectionStart = this.element.selectionStart; var selectionEnd = this.element.selectionEnd; } catch (e) { // No cursor; the element may be "display:none". return null; } cursor.startPrefix = text.substring(selectionStart - padLength, selectionStart); cursor.startSuffix = text.substring(selectionStart, selectionStart + padLength); cursor.startOffset = selectionStart; cursor.collapsed = (selectionStart == selectionEnd); if (!cursor.collapsed) { cursor.endPrefix = text.substring(selectionEnd - padLength, selectionEnd); cursor.endSuffix = text.substring(selectionEnd, selectionEnd + padLength); cursor.endOffset = selectionEnd; } } else { // IE // Walk up the tree looking for this textarea's document node. var doc = this.element; while (doc.parentNode) { doc = doc.parentNode; } if (!doc.selection || !doc.selection.createRange) { // Not IE? return null; } var range = doc.selection.createRange(); if (range.parentElement() != this.element) { // Cursor not in this textarea. return null; } var newRange = doc.body.createTextRange(); cursor.collapsed = (range.text == ''); newRange.moveToElementText(this.element); if (!cursor.collapsed) { newRange.setEndPoint('EndToEnd', range); cursor.endPrefix = newRange.text; cursor.endOffset = cursor.endPrefix.length; cursor.endPrefix = cursor.endPrefix.substring(cursor.endPrefix.length - padLength); } newRange.setEndPoint('EndToStart', range); cursor.startPrefix = newRange.text; cursor.startOffset = cursor.startPrefix.length; cursor.startPrefix = cursor.startPrefix.substring(cursor.startPrefix.length - padLength); newRange.moveToElementText(this.element); newRange.setEndPoint('StartToStart', range); cursor.startSuffix = newRange.text.substring(0, padLength); if (!cursor.collapsed) { newRange.setEndPoint('StartToEnd', range); cursor.endSuffix = newRange.text.substring(0, padLength); } } // Record scrollbar locations if ('scrollTop' in this.element) { cursor.scrollTop = this.element.scrollTop / this.element.scrollHeight; cursor.scrollLeft = this.element.scrollLeft / this.element.scrollWidth; } // alert(cursor.startPrefix + '|' + cursor.startSuffix + ' ' + // cursor.startOffset + '\n' + cursor.endPrefix + '|' + // cursor.endSuffix + ' ' + cursor.endOffset + '\n' + // cursor.scrollTop + ' x ' + cursor.scrollLeft); return cursor; }; /** * Attempt to restore the cursor's location. * @param {Object} cursor Context information of the cursor. * @private */ mobwrite.shareTextareaObj.prototype.restoreCursor_ = function(cursor) { // Set some constants which tweak the matching behaviour. // Maximum distance to search from expected location. this.dmp.Match_Distance = 1000; // At what point is no match declared (0.0 = perfection, 1.0 = very loose) this.dmp.Match_Threshold = 0.9; var padLength = this.dmp.Match_MaxBits / 2; // Normally 16. var newText = this.element.value; // Find the start of the selection in the new text. var pattern1 = cursor.startPrefix + cursor.startSuffix; var pattern2, diff; var cursorStartPoint = this.dmp.match_main(newText, pattern1, cursor.startOffset - padLength); if (cursorStartPoint !== null) { pattern2 = newText.substring(cursorStartPoint, cursorStartPoint + pattern1.length); //alert(pattern1 + '\nvs\n' + pattern2); // Run a diff to get a framework of equivalent indicies. diff = this.dmp.diff_main(pattern1, pattern2, false); cursorStartPoint += this.dmp.diff_xIndex(diff, cursor.startPrefix.length); } var cursorEndPoint = null; if (!cursor.collapsed) { // Find the end of the selection in the new text. pattern1 = cursor.endPrefix + cursor.endSuffix; cursorEndPoint = this.dmp.match_main(newText, pattern1, cursor.endOffset - padLength); if (cursorEndPoint !== null) { pattern2 = newText.substring(cursorEndPoint, cursorEndPoint + pattern1.length); //alert(pattern1 + '\nvs\n' + pattern2); // Run a diff to get a framework of equivalent indicies. diff = this.dmp.diff_main(pattern1, pattern2, false); cursorEndPoint += this.dmp.diff_xIndex(diff, cursor.endPrefix.length); } } // Deal with loose ends if (cursorStartPoint === null && cursorEndPoint !== null) { // Lost the start point of the selection, but we have the end point. // Collapse to end point. cursorStartPoint = cursorEndPoint; } else if (cursorStartPoint === null && cursorEndPoint === null) { // Lost both start and end points. // Jump to the offset of start. cursorStartPoint = cursor.startOffset; } if (cursorEndPoint === null) { // End not known, collapse to start. cursorEndPoint = cursorStartPoint; } // Restore selection. if ('selectionStart' in this.element) { // W3 this.element.selectionStart = cursorStartPoint; this.element.selectionEnd = cursorEndPoint; } else { // IE // Walk up the tree looking for this textarea's document node. var doc = this.element; while (doc.parentNode) { doc = doc.parentNode; } if (!doc.selection || !doc.selection.createRange) { // Not IE? return; } // IE's TextRange.move functions treat '\r\n' as one character. var snippet = this.element.value.substring(0, cursorStartPoint); var ieStartPoint = snippet.replace(/\r\n/g, '\n').length; var newRange = doc.body.createTextRange(); newRange.moveToElementText(this.element); newRange.collapse(true); newRange.moveStart('character', ieStartPoint); if (!cursor.collapsed) { snippet = this.element.value.substring(cursorStartPoint, cursorEndPoint); var ieMidLength = snippet.replace(/\r\n/g, '\n').length; newRange.moveEnd('character', ieMidLength); } newRange.select(); } // Restore scrollbar locations if ('scrollTop' in cursor) { this.element.scrollTop = cursor.scrollTop * this.element.scrollHeight; this.element.scrollLeft = cursor.scrollLeft * this.element.scrollWidth; } }; /** * Ensure that all linebreaks are LF * @param {string} text Text with unknown line breaks * @return {string} Text with normalized linebreaks * @private */ mobwrite.shareTextareaObj.normalizeLinebreaks_ = function(text) { return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); }; /** * Handler to accept text fields as elements that can be shared. * If the element is a textarea, text or password input, create a new * sharing object. * @param {*} node Object or ID of object to share. * @return {Object?} A sharing object or null. */ mobwrite.shareTextareaObj.shareHandler = function(node) { if (typeof node == 'string') { node = document.getElementById(node); } if (node && 'value' in node && 'type' in node && (node.type == 'textarea' || node.type == 'text' || node.type == 'password')) { if (mobwrite.UA_webkit) { // Safari needs to track which text element has the focus. node.addEventListener('focus', function() {this.activeElement = true;}, false); node.addEventListener('blur', function() {this.activeElement = false;}, false); node.activeElement = false; } return new mobwrite.shareTextareaObj(node); } return null; }; // Register this shareHandler with MobWrite. mobwrite.shareHandlers.push(mobwrite.shareTextareaObj.shareHandler); whiteboard/mobwrite/tools/0000755000175000017500000000000012251036356015167 5ustar ernieerniewhiteboard/mobwrite/tools/download.py0000644000175000017500000000312012251036356017344 0ustar ernieernie#!/usr/bin/python """MobWrite Downloader Copyright 2009 Google Inc. http://code.google.com/p/google-mobwrite/ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ """This command-line program downloads a file from a MobWrite server. The MobWrite URL and the filename are provided on the command line. The file content is printed to standard output. Version numbers are not tracked. """ __author__ = "fraser@google.com (Neil Fraser)" import mobwritelib import sys if __name__ == "__main__": # Obtain the server URL and the filename from the command line argument. if len(sys.argv) != 3: print >> sys.stderr, "Usage: %s " % sys.argv[0] print >> sys.stderr, " E.g. %s http://mobwrite3.appspot.com/scripts/q.py demo_editor_text" % sys.argv[0] print >> sys.stderr, " E.g. %s telnet://localhost:3017 demo_editor_text" % sys.argv[0] sys.exit(2) url = sys.argv[1] filename = sys.argv[2] results = mobwritelib.download(url, [filename]) if filename in results: if results.get(filename): print results.get(filename), else: sys.exit("Error: MobWrite server failed to provide data.") whiteboard/mobwrite/tools/upload.py0000644000175000017500000000306512251036356017031 0ustar ernieernie#!/usr/bin/python """MobWrite Uploader Copyright 2009 Google Inc. http://code.google.com/p/google-mobwrite/ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ """This command-line program uploads a file to a MobWrite server. The MobWrite URL and the filename are provided on the command line. The file content is taken from standard input. The file is forcibly uploaded, no merging takes place. """ __author__ = "fraser@google.com (Neil Fraser)" import mobwritelib import sys if __name__ == "__main__": # Obtain the server URL and the filename from the command line argument. if len(sys.argv) != 3: print >> sys.stderr, "Usage: %s " % sys.argv[0] print >> sys.stderr, " E.g. %s http://mobwrite3.appspot.com/scripts/q.py demo_editor_text" % sys.argv[0] print >> sys.stderr, " E.g. %s telnet://localhost:3017 demo_editor_text" % sys.argv[0] sys.exit(2) url = sys.argv[1] filename = sys.argv[2] data = "".join(sys.stdin.readlines()) success = mobwritelib.upload(url, {filename: data}) if not success: sys.exit("Error: MobWrite server failed to respond.") whiteboard/mobwrite/tools/demo.cfg0000644000175000017500000000014412251036356016573 0ustar ernieernieserverurl=http://mobwrite3.appspot.com/scripts/q.py localfile=/home/username/demo.txt filename=demo whiteboard/mobwrite/tools/mobwritelib.py0000644000175000017500000001667212251036356020074 0ustar ernieernie#!/usr/bin/python """MobWrite Library Copyright 2009 Google Inc. http://code.google.com/p/google-mobwrite/ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ """Helper functions for interfacing with MobWrite A collection of functions useful for creating 3rd party programs which talk with MobWrite. """ __author__ = "fraser@google.com (Neil Fraser)" import random import telnetlib import urllib def download(url, filenames): """Download one or more files from a MobWrite server. Args: url: An http or telnet URL to a MobWrite server. e.g. "http://mobwrite3.appspot.com/scripts/q.py" e.g. "telnet://localhost:3017" filenames: A list of filenames to request. e.g. ["title", "text"] Returns: A dictionary objects mapping file names with file contents. e.g. {"title": "My Cat", "text": "Once upon a time..."} """ q = ["u:%s" % uniqueId()] for filename in filenames: q.append("f:0:%s\nr:0:" % filename) q.append("\n") # Trailing blank line required. q = "\n".join(q) data = send(url, q) results = {} if (data.endswith("\n\n") or data.endswith("\r\r") or data.endswith("\n\r\n\r") or data.endswith("\r\n\r\n")): # There must be a linefeed followed by a blank line. filename = None for line in data.splitlines(): if not line: # Terminate on blank line. break if line.find(":") != 1: # Invalid line. continue (name, value) = (line[:1], line[2:]) # Trim off the version number from file, delta or raw. if "FfDdRr".find(name) != -1: div = value.find(":") if div == -1: continue value = value[div + 1:] if name == "f" or name == "F": # Remember the filename. filename = value elif filename and (name == "d" or name == "D"): # When sent a 'r:' command, the server is expected to reply with 'd:'. if value == "=0": text = "" elif value and value[0] == "+": text = urllib.unquote(value[1:]) results[filename] = text elif filename and (name == "r" or name == "R"): # The server should not reply with 'r:', but if it does, the answer is # just as informative as 'd:'. results[filename] = urllib.unquote(value) return results def upload(url, dictionary): """Upload one or more files from a MobWrite server. Args: url: An http or telnet URL to a MobWrite server. e.g. "http://mobwrite3.appspot.com/scripts/q.py" e.g. "telnet://localhost:3017" dictionary: A dictionary with filenames as the keys and content as the data. e.g. {"title": "My Cat", "text": "Once upon a time..."} Returns: True or false, depending on whether the MobWrite server answered. """ q = ["u:%s" % uniqueId()] for filename in dictionary: data = dictionary[filename] # High ascii will raise UnicodeDecodeError. Use Unicode instead. data = data.encode("utf-8") data = urllib.quote(data, "!~*'();/?:@&=+$,# ") q.append("f:0:%s\nR:0:%s" % (filename, data)) q.append("\n") # Trailing blank line required. q = "\n".join(q) data = send(url, q) # Ignore the response, but check that there is one. # Maybe in the future this should parse and verify the answer? return data.strip() != "" class ShareObj: # An object which contains one user's view of one text. # Object properties: # .username - The name for the user, e.g. 'fraser' # .filename - The name for the file, e.g 'proposal' # .shadow_text - The last version of the text sent to client. # .shadow_client_version - The client's version for the shadow (n). # .shadow_server_version - The server's version for the shadow (m). # .edit_stack - List of unacknowledged edits sent to the client. # .merge_changes - Synchronization mode; True for text, False for numbers. # .text - The client's version of the text. def __init__(self, username, filename): # Setup this object self.username = username self.filename = filename self.shadow_text = u"" self.shadow_client_version = 0 self.shadow_server_version = 0 self.edit_stack = [] self.text = u"" def syncBlocking(url, textlist): """Upload one or more files from a MobWrite server. Args: url: An http or telnet URL to a MobWrite server. e.g. "http://mobwrite3.appspot.com/scripts/q.py" e.g. "telnet://localhost:3017" textlist: An array of configuration objects, one for each text to sync. As a minimum each object must have a filename. e.g. [{"filename": "title", "serverversion": 2, ...}] Returns: True or false, depending on whether the MobWrite server answered. """ #q = ["u:%s" % uniqueId()] #for filename in dictionary: # data = dictionary[filename] # # High ascii will raise UnicodeDecodeError. Use Unicode instead. # data = data.encode("utf-8") # data = urllib.quote(data, "!~*'();/?:@&=+$,# ") # q.append("f:0:%s\nR:0:%s" % (filename, data)) #q.append("\n") # Trailing blank line required. #q = "\n".join(q) #data = send(url, q) # Ignore the response, but check that there is one. # Maybe in the future this should parse and verify the answer? #return data.strip() != "" return True def send(url, commands): """Send some raw commands to a MobWrite server, return the raw answer. Args: url: An http or telnet URL to a MobWrite server. e.g. "http://mobwrite3.appspot.com/scripts/q.py" e.g. "telnet://localhost:3017" commands: All the commands for this session. e.g. "u:123\nf:0:demo\nd:0:=12\n\n" Returns: The raw output from the server. e.g. "f:0:demo\nd:0:=12\n\n" """ # print "Sending: %s" % commands data = "" if url.startswith("telnet://"): url = url[9:] # Determine the port (default to 23) div = url.find(":") port = 23 if div != -1: host = url[:div] try: port = int(url[div + 1:]) except ValueError: pass # Execute a telnet connection. # print "Connecting to: %s:%s" % (host, port) t = telnetlib.Telnet(host, port) t.write(commands) data = t.read_all() + "\n" t.close() else: # Web connection. params = urllib.urlencode({"q": commands}) f = urllib.urlopen(url, params) data = f.read() data = data.decode("utf-8") # print "Got: %s" % data return data def uniqueId(): """Return a random id that's 8 letters long. 26*(26+10+4)^7 = 4,259,840,000,000 Returns: Random id. """ # First character must be a letter. # IE is case insensitive (in violation of the W3 spec). soup = "abcdefghijklmnopqrstuvwxyz" id = soup[random.randint(0, len(soup) - 1)] # Subsequent characters may include these. soup += '0123456789-_:.' for x in range(7): id += soup[random.randint(0, len(soup) - 1)] # Don't allow IDs with '--' in them since it might close a comment. if id.find("--") != -1: id = uniqueId(); return id # Getting the maximum possible density in the ID is worth the extra code, # since the ID is transmitted to the server a lot." whiteboard/mobwrite/tools/sync.py0000644000175000017500000000720512251036356016521 0ustar ernieernie#!/usr/bin/python """MobWrite Sync Copyright 2009 Google Inc. http://code.google.com/p/google-mobwrite/ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ """This command-line program synchronizes a file with a MobWrite server. First create a three-line config file formatted like this: serverurl=http://mobwrite3.appspot.com/scripts/q.py filename=demo localfile=/home/username/demo.txt Then call this script with the config file as the argument. The specified local file (demo.txt) will be synchronized against the specified MobWrite server. After the first execution, the config file will have several lines of state properties added to it. Warning: No checks are made on the config file to prevent malicious data input. """ __author__ = "fraser@google.com (Neil Fraser)" import mobwritelib import sys class syncShareObject(shareObject) def __init__(self, username, filename): # Setup this object self.username = username self.filename = filename self.shadow_text = u"" self.shadow_client_version = 0 self.shadow_server_version = 0 self.edit_stack = [] self.text = u"" def loadData(configfile): configdata = ShareObj() # Load the data from the configuration file. f = open(configfile) try: for line in f: div = line.find("=") if div > 0: name = line[:div].strip() value = line[div + 1:].strip("\r\n") if name: configdata[name] = value finally: f.close() # Load the main text from the text file. if configdata.has_key("filename"): lines = [] try: f = open(configdata["filename"]) try: lines = f.readlines() finally: f.close() except IOError: pass configdata["text"] = "".join(lines) # Unescape the shadow text. if configdata.has_key("shadowtext"): configdata["shadowtext"] = urllib.unquote(configdata["shadowtext"]) return configdata def saveData(configfile, configdata): # Escape the shadow text. if configdata.has_key("shadowtext"): configdata["shadowtext"] = urllib.quote(configdata["shadowtext"]) # Save the main text to the text file. if configdata.has_key("filename"): f = open(configdata["filename"], "w") try: f.write(configdata.get("text", "")) finally: f.close() if (configdata.has_key("text")): del configdata["text"] # Save the data to the configuration file. f = open(configfile, "w") try: for name in configdata: f.write("%s=%s\n" % (name, configdata[name])) finally: f.close() if __name__ == "__main__": # Obtain the configuration file from the command line argument. if len(sys.argv) != 2: print >> sys.stderr, "Usage: %s " % sys.argv[0] print >> sys.stderr, " E.g. %s demo.cfg" % sys.argv[0] sys.exit(2) configfile = sys.argv[1] configdata = loadData(configfile) for manditory in ["serverurl", "filename", "localfile"]: if not configdata.get(manditory, None): sys.exit("Error: '%s' line not found in %s." % (manditory, configfile)) success = mobwritelib.syncBlocking(configdata["serverurl"], [configdata]) if not success: sys.exit("Error: MobWrite server failed to respond.") saveData(configfile, configdata) whiteboard/mobwrite/tools/loadtest.py0000644000175000017500000000501612251036356017362 0ustar ernieernie#!/usr/bin/python """MobWrite Load Tester Copyright 2009 Google Inc. http://code.google.com/p/google-mobwrite/ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ """This command-line program fires a large number of requests to a MobWrite server. The MobWrite URL and the number of requests per second are provided on the command line. """ __author__ = "fraser@google.com (Neil Fraser)" import mobwritelib import random import sys import thread import time def makeRequest(url): # Compute a user name. username = "loadtester_" + mobwritelib.uniqueId() commands = "U:%s\n" % username for x in xrange(20): commands += singleFile() commands += "\n" startTime = time.time() results = mobwritelib.send(url, commands) endTime = time.time() #print commands #print results delta = endTime - startTime print "%f seconds" % delta def singleFile(): # Compute a file name. filename = "loadtest_" + mobwritelib.uniqueId() mode = random.randint(1, 3) if mode == 1: # Nullify the file. commands = "N:%s\n" % filename else: commands = "F:0:%s\n" % filename if mode == 2: # Force a raw dump. commands += "R:0:Hello world\n" elif mode == 3: # Send a delta. commands += "d:0:+Goodbye world\n" return commands def testLoop(url, hertz): while 1: try: thread.start_new_thread(makeRequest, (url,)) except: print "Unable to start thread." time.sleep(1.0 / hertz) if __name__ == "__main__": # Obtain the server URL and the Hertz from the command line argument. if len(sys.argv) != 3: print >> sys.stderr, "Usage: %s " % sys.argv[0] print >> sys.stderr, " E.g. %s http://mobwrite3.appspot.com/scripts/q.py 5.0" % sys.argv[0] print >> sys.stderr, " E.g. %s telnet://localhost:3017 5.0" % sys.argv[0] sys.exit(2) url = sys.argv[1] try: hertz = float(sys.argv[2]) except ValueError: sys.exit("Error: Hertz must be a number.") print "Starting load test." try: testLoop(url, hertz) except KeyboardInterrupt: pass print "Exiting load test." whiteboard/mobwrite/tests/0000755000175000017500000000000012251036356015171 5ustar ernieerniewhiteboard/mobwrite/tests/client.html0000644000175000017500000004766712251036356017361 0ustar ernieernie

If debugging errors, start with the first reported error, subsequent tests often rely on earlier ones.

whiteboard/mobwrite/tests/q.html0000644000175000017500000000057512251036356016326 0ustar ernieernie Manual test of MobWrite's client-server Protocol

Manual test of MobWrite's client-server Protocol

whiteboard/mobwrite/tests/server.xml0000644000175000017500000003627312251036356017234 0ustar ernieernie Test cases for communicating with a mobwrite server. For a description of the protocol, see: http://code.google.com/p/google-mobwrite/wiki/Protocol [RANDOM] is replaced with a string of random digits. The test harness chooses a new random string each time it runs a test case. The server replies to bad commands with an empty string. ('x' is not a defined command in the protocol.) u:user[RANDOM] f:0:unittest[RANDOM] x:foo UNKNOWN COMMAND The client sends its file to the server with the 'r' command. The same file is already on the server, so it replies that there are no diffs. U:user[RANDOM] f:0:unittest[RANDOM] R:0:Hello world u:user[RANDOM] F:0:unittest[RANDOM] D:0:=11 U:user[RANDOM] f:0:unittest[RANDOM] r:0:Hello world u:user[RANDOM] F:0:unittest[RANDOM] d:0:=11 The client sends its file to the server with the 'r' command. The server has a different file, so it replies with diffs. u:user[RANDOM] F:0:unittest[RANDOM] R:0:Hello world F:0:unittest[RANDOM] D:0:=11 u:user[RANDOM] F:0:unittest[RANDOM] r:0:x F:0:unittest[RANDOM] d:0:-1 +Hello world The client sends its file to the server with the 'r' command. The server has nothing, so it creates its file with the same text, and replies that there are no diffs. u:user[RANDOM] f:0:unittest[RANDOM] r:1:Hello world F:1:unittest[RANDOM] d:0:=11 The client sends a diff with the 'd' command. The server has nothing, so it applies the diff to an empty file, and replies that there are no diffs. U:user[RANDOM] F:0:unittest[RANDOM] d:0:+Hello world u:user[RANDOM] F:1:unittest[RANDOM] d:0:=11 The client sends a diff with the 'd' command. The server has nothing, so it creates the file, but diff cannot be applied to an empty file, so it's ignored. The server tells the client that the file is empty. u:user[RANDOM] f:0:unittest[RANDOM] d:0:-1 F:1:unittest[RANDOM] r:0: The client sends a file and a diff to apply. The server creates the file, applies the diff, and replies that there are no diffs. u:user[RANDOM] F:0:unittest[RANDOM] R:0:Hello world d:0:-5 +Goodbye =6 F:1:unittest[RANDOM] d:0:=13 The client sends a file and a bad diff to apply. The server creates the file, ignores the diff, and tells the client to replace its file with the text before the diff. u:user[RANDOM] f:0:unittest[RANDOM] R:0:Hello world d:0:-5 +Goodbye =5 F:1:unittest[RANDOM] R:0:Hello world Clients A and B and the server have the same file. Client A tells the server to nullify the file. Client C asks the server for updates. The server doesn't have anything for C, so it returns an empty file. Client B asks the server for updates. The server replies that B has the latest file. u:user[RANDOM]a f:0:unittest[RANDOM] r:1:Hello world F:1:unittest[RANDOM] d:0:=11 u:user[RANDOM]b f:0:unittest[RANDOM] r:1:Hello world F:1:unittest[RANDOM] d:0:=11 u:user[RANDOM]a N:unittest[RANDOM] u:user[RANDOM]c f:0:unittest[RANDOM] d:0:=11 F:1:unittest[RANDOM] r:0: u:user[RANDOM]b f:1:unittest[RANDOM] d:1:=11 F:2:unittest[RANDOM] d:1:=11 Same as the previous test, but using 'D' (force delta) instead of 'd'. u:user[RANDOM]a f:0:unittest[RANDOM] r:1:Hello world F:1:unittest[RANDOM] d:0:=11 u:user[RANDOM]b f:0:unittest[RANDOM] r:1:Hello world F:1:unittest[RANDOM] d:0:=11 u:user[RANDOM]a N:unittest[RANDOM] u:user[RANDOM]c f:0:unittest[RANDOM] D:0:=11 F:1:unittest[RANDOM] r:0: u:user[RANDOM]b f:1:unittest[RANDOM] D:1:=11 F:2:unittest[RANDOM] d:1:=11 Client sends a file containing punctuation, and a bad delta. Server ignores the delta and sends the whole file back. (The bad delta is to get the server to send the file.) u:user[RANDOM] f:0:unittest[RANDOM] R:0:A-Z a-z 0-9 - _ . ! ~ * ' ( ) ; / ? : @ & = + $ , # d:0:-1 F:1:unittest[RANDOM] R:0:A-Z a-z 0-9 - _ . ! ~ * ' ( ) ; / ? : @ & = + $ , # Client sends an empty file and a delta containing punctuation. Then asks the server to send the diffs based on a file containing just 'x'. (This is to get the server to send a delta containing punctuation.) The server sends a delta restoring the file. u:user[RANDOM] f:0:unittest[RANDOM] R:0: d:0:+A-Z a-z 0-9 - _ . ! ~ * ' ( ) ; / ? : @ & = + $ , # r:1:x F:1:unittest[RANDOM] d:0:-1 +A-Z a-z 0-9 - _ . ! ~ * ' ( ) ; / ? : @ & = + $ , # Verifies that the null, percent, and newline characters can be sent and received as part of a file, escaped as 2-digit hexadecimal numbers, preceded by percent. u:user[RANDOM] f:0:unittest[RANDOM] R:0:a%00b%25c%0Ad d:0:-1 F:1:unittest[RANDOM] R:0:a%00b%25c%0Ad Verifies that the null, percent, and newline characters can be sent and received as part of a diff. u:user[RANDOM] f:0:unittest[RANDOM] R:0: d:0:+a%00b%25c%0Ad r:1:x F:1:unittest[RANDOM] d:0:-1 +a%00b%25c%0Ad Verifies that the Unicode character 3046 (HIRAGANA LETTER U) can be transmitted in a file in both directions by converting to UTF 8 and sending the hexadecimal escaped form of each byte. u:user[RANDOM] f:0:unittest[RANDOM] R:0:a%E3%81%86b d:0:-1 F:1:unittest[RANDOM] R:0:a%E3%81%86b Verifies that the Unicode character 3046 (HIRAGANA LETTER U) can be transmitted in a delta in both directions by converting to UTF 8 and sending the hexadecimal escaped form of each byte. u:user[RANDOM] f:0:unittest[RANDOM] R:0: d:0:+a%E3%81%86b r:1:x F:1:unittest[RANDOM] d:0:-1 +a%E3%81%86b The client sends a text to the server with a return character. The server tells the client to use a linefeed instead. u:user[RANDOM] f:0:unittest[RANDOM] R:0:a%0Db F:0:unittest[RANDOM] D:0:=1 -1 +%0A =1 The client sends a diff adding a DOS-style line ending to the file. The server tells the client to delete the return character, turning it into a Unix line ending. u:user[RANDOM] f:0:unittest[RANDOM] R:0:ab d:0:=1 +%0D%0A =1 F:1:unittest[RANDOM] d:0:=1 -1 =2 Clients A and B have 'Hello world'. Client B changes 'Hello' to 'Goodbye'. Client A polls for changes. The server tells A to change 'Hello' to 'Goodbye'. F:0:unittest[RANDOM] U:user[RANDOM]a R:0:Hello world U:user[RANDOM]b R:0:Hello world d:0:-5 +Goodbye =6 U:user[RANDOM]a d:0:=11 u:user[RANDOM]a F:0:unittest[RANDOM] D:0:=11 u:user[RANDOM]b F:1:unittest[RANDOM] d:0:=13 u:user[RANDOM]a F:1:unittest[RANDOM] d:0:-5 +Goodbye =6 f:0:unittest[RANDOM] u:user[RANDOM]a R:0:Hello world F:0:unittest[RANDOM] D:0:=11 f:0:unittest[RANDOM] U:user[RANDOM]b R:0:Hello world d:0:-5 +Goodbye =6 u:user[RANDOM]b F:1:unittest[RANDOM] d:0:=13 f:1:unittest[RANDOM] U:user[RANDOM]a d:0:=9 -2 +m u:user[RANDOM]a F:1:unittest[RANDOM] d:1:-5 +Goodbye =5 f:0:unittest[RANDOM] U:user[RANDOM]a R:0:Hello world u:user[RANDOM]a F:0:unittest[RANDOM] D:0:=11 f:0:unittest[RANDOM] U:user[RANDOM]b R:0:Hello world d:0:-5 +Goodbye =6 u:user[RANDOM]b F:1:unittest[RANDOM] d:0:=13 f:1:unittest[RANDOM] U:user[RANDOM]a d:0:-5 +My =6 u:user[RANDOM]a F:1:unittest[RANDOM] d:1:-2 +Goodbye =6 f:0:unittest[RANDOM] U:user[RANDOM]a R:0:bc u:user[RANDOM]a F:0:unittest[RANDOM] D:0:=2 f:0:unittest[RANDOM] U:user[RANDOM]b R:0:bc d:0:+a =2 u:user[RANDOM]b F:1:unittest[RANDOM] d:0:=3 f:1:unittest[RANDOM] U:user[RANDOM]a d:0:=2 +d u:user[RANDOM]a F:1:unittest[RANDOM] d:1:+a =3 f:0:unittest[RANDOM] U:user[RANDOM]a R:0:23 u:user[RANDOM]a F:0:unittest[RANDOM] D:0:=2 f:0:unittest[RANDOM] U:user[RANDOM]b R:0:23 D:0:+1 =2 u:user[RANDOM]b F:1:unittest[RANDOM] D:0:=3 f:1:unittest[RANDOM] U:user[RANDOM]a D:0:=2 +4 u:user[RANDOM]a F:1:unittest[RANDOM] D:1:=3 Send a three-part buffer out of order (2, 3, 1) and verify correct assembly. b:testbuffer1 3 2 3Aunittest[RANDOM]%0AR%3 b:testbuffer1 3 3 A0%3AHello world%0A b:testbuffer1 3 1 U%3Auser[RANDOM]%0Af%3A0% u:user[RANDOM] F:0:unittest[RANDOM] D:0:=11 Send two parts of a three-part buffer (2, 3) and verify no ouput. b:testbuffer2 3 2 f%3A0%3Aunittest[RANDOM]%0A b:testbuffer2 3 3 R%3A0%3AHello world%0A Send a three-part buffer which itself contains a three-part buffer. There is no rational reason to nest a buffer, this test is just for fun. b:testbuffer3 3 2 b%3Atestbuffer4 3 2 f%253A0%253Aunittest[RANDOM]%250A%0A b:testbuffer3 3 1 b%3Atestbuffer4 3 3 R%253A0%253AHello world%250A%0A b:testbuffer3 3 3 b%3Atestbuffer4 3 1 U%253Auser[RANDOM]%250A%0A u:user[RANDOM] F:0:unittest[RANDOM] D:0:=11 Send three invalid buffers and one single-slot buffer. Only the latter should return an answer. b:testbuffer5 0 0 u%3Auser[RANDOM]%0Af%3A0%3Aunittest[RANDOM]a%0AR%3A0%3AHello world%0A b:testbuffer6 0 1 u%3Auser[RANDOM]%0Af%3A0%3Aunittest[RANDOM]b%0AR%3A0%3AHello world%0A b:testbuffer7 1 2 u%3Auser[RANDOM]%0Af%3A0%3Aunittest[RANDOM]c%0AR%3A0%3AHello world%0A b:testbuffer8 1 1 u%3Auser[RANDOM]%0Af%3A0%3Aunittest[RANDOM]d%0AR%3A0%3AHello world%0A F:0:unittest[RANDOM]d D:0:=11 u:user[RANDOM] f:0:unittest[RANDOM] R:0:Hello world F:0:unittest[RANDOM] D:0:=11 u:user[RANDOM] f:1:unittest[RANDOM] d:0:-5 +Goodbye =6 F:1:unittest[RANDOM] d:1:=13 u:user[RANDOM] f:1:unittest[RANDOM] d:0:-5 +Goodbye =6 F:1:unittest[RANDOM] d:1:=13 u:user[RANDOM]a f:0:unittest[RANDOM] R:0:Hello world F:0:unittest[RANDOM] D:0:=11 u:user[RANDOM]b f:0:unittest[RANDOM] R:0:Goodbye world F:0:unittest[RANDOM] D:0:=13 u:user[RANDOM]a f:1:unittest[RANDOM] d:0:=11 F:1:unittest[RANDOM] d:1:-5 +Goodbye =6 u:user[RANDOM]a f:1:unittest[RANDOM] d:0:=11 d:1:=11 F:2:unittest[RANDOM] d:1:-5 +Goodbye =6 whiteboard/mobwrite/tests/server.html0000644000175000017500000002650412251036356017374 0ustar ernieernie Test Harness for MobWrite Server

Test Harness for MobWrite Server

Data:
Server:
whiteboard/mobwrite/tests/index.html0000644000175000017500000000201312251036356017162 0ustar ernieernie MobWrite Tests

MobWrite Tests

MobWrite is a complex system with many moving parts. These tests are comprehensive in ensuring functionality.

Server tests
Send a set of commands to the server-side daemon and verify the answers. The commands and expected answers are defined in server.xml. Commands may be manually issued using q.html.
Client tests
Test some of the JavaScript functions making up the clients.

In addition to these tests, the diff_match_patch libraries have their own unit test suites.

whiteboard/mobwrite/remote-demos/0000755000175000017500000000000012251036356016427 5ustar ernieerniewhiteboard/mobwrite/remote-demos/form.html0000644000175000017500000000520212251036356020257 0ustar ernieernie MobWrite as a Collaborative Form (Remote)

MobWrite as a Collaborative Form

Calling remotely via JSON-P.

What
When to
Where


Who
Hidden [get/set]
Password
Description
whiteboard/mobwrite/remote-demos/editor.html0000644000175000017500000000203212251036356020600 0ustar ernieernie MobWrite as a Collaborative Editor (Remote)

MobWrite as a Collaborative Editor

Calling remotely via JSON-P.

whiteboard/mobwrite/remote-demos/spreadsheet.html0000644000175000017500000000435712251036356021635 0ustar ernieernie MobWrite as a Collaborative Spreadsheet (Remote)

MobWrite as a Collaborative Spreadsheet

Calling remotely via JSON-P.

whiteboard/mobwrite/remote-demos/index.html0000644000175000017500000000155412251036356020431 0ustar ernieernie MobWrite Demos (Remote)

MobWrite Demos (Remote)

Editor
A simple collaborative plain-text editor. MobWrite is extremely good at resolving collisions which other systems would fail on.
Form
This form demonstrates collaboration with all the standard HTML form elements. Note that the onchange event is called remotely when the checkbox is ticked, thus allowing forms to react normally to changes.
Spreadsheet
This 50-cell spreadsheet is an abuse of MobWrite (there are more efficient ways of synchronizing grids of data). But it shows what can be done.
whiteboard/mobwrite/README_daemon0000644000175000017500000000322612251036356016235 0ustar ernieernieMobWrite README_daemon This file. diff_match_patch_uncompressed.js Diff library for client. mobwrite_core.js Core JavaScript for synchronization. mobwrite_form.js Form element handlers. compressed_form.js Diff, Core and Form files compressed together. demos/index.html Start page for demos. demos/editor.html Text synchronization. demos/spreadsheet.html Cell synchronization. demos/form.html Form synchronization. tests/index.html Start page for testing. tests/client.html Unit test for client. tests/server.html Unit test framework for server. tests/server.xml Unit test data for server. tests/q.html Manual test test for server. daemon/mobwrite_daemon.py Python daemon listens on port 3017 daemon/diff_match_patch.py Diff library for daemon. daemon/q.php PHP version of gateway between web and daemon. daemon/q.py Python version of gateway between web and daemon. daemon/q.jsp JSP version of gateway between web and daemon. daemon/.htaccess Hander directives for q.py under mod_python. daemon/lib/mobwrite_core.py MobWrite library (Python). daemon/lib/diff_match_patch.py Diff library (Python). tools/download.py Command-line tool for downloading content from MobWrite. tools/upload.py Command-line tool for uploading content to MobWrite. tools/sync.py Command-line tool for synchronizing content with MobWrite. tools/demo.cfg Sample configuration file for sync.py. tools/mobwritelib.py Python library for connecting with MobWrite. Documentation: http://code.google.com/p/google-mobwrite/ Author: Neil Fraser License: Apache 2 whiteboard/mobwrite/compressed_form.js0000644000175000017500000012517412251036356017566 0ustar ernieerniefunction diff_match_patch(){this.Diff_Timeout=1.0;this.Diff_EditCost=4;this.Diff_DualThreshold=32;this.Match_Threshold=0.5;this.Match_Distance=1000;this.Patch_DeleteThreshold=0.5;this.Patch_Margin=4;function getMaxBits(){var a=0;var b=1;var c=2;while(b!=c){a++;b=c;c=c<<1}return a}this.Match_MaxBits=getMaxBits()}var DIFF_DELETE=-1;var DIFF_INSERT=1;var DIFF_EQUAL=0;diff_match_patch.prototype.diff_main=function(a,b,c){if(a==b){return[[DIFF_EQUAL,a]]}if(typeof c=='undefined'){c=true}var d=c;var e=this.diff_commonPrefix(a,b);var f=a.substring(0,e);a=a.substring(e);b=b.substring(e);e=this.diff_commonSuffix(a,b);var g=a.substring(a.length-e);a=a.substring(0,a.length-e);b=b.substring(0,b.length-e);var h=this.diff_compute(a,b,d);if(f){h.unshift([DIFF_EQUAL,f])}if(g){h.push([DIFF_EQUAL,g])}this.diff_cleanupMerge(h);return h};diff_match_patch.prototype.diff_compute=function(b,c,d){var e;if(!b){return[[DIFF_INSERT,c]]}if(!c){return[[DIFF_DELETE,b]]}var f=b.length>c.length?b:c;var g=b.length>c.length?c:b;var i=f.indexOf(g);if(i!=-1){e=[[DIFF_INSERT,f.substring(0,i)],[DIFF_EQUAL,g],[DIFF_INSERT,f.substring(i+g.length)]];if(b.length>c.length){e[0][0]=e[2][0]=DIFF_DELETE}return e}f=g=null;var h=this.diff_halfMatch(b,c);if(h){var k=h[0];var l=h[1];var m=h[2];var n=h[3];var o=h[4];var p=this.diff_main(k,m,d);var q=this.diff_main(l,n,d);return p.concat([[DIFF_EQUAL,o]],q)}if(d&&(b.length<100||c.length<100)){d=false}var r;if(d){var a=this.diff_linesToChars(b,c);b=a[0];c=a[1];r=a[2]}e=this.diff_map(b,c);if(!e){e=[[DIFF_DELETE,b],[DIFF_INSERT,c]]}if(d){this.diff_charsToLines(e,r);this.diff_cleanupSemantic(e);e.push([DIFF_EQUAL,'']);var s=0;var t=0;var u=0;var v='';var w='';while(s=1&&u>=1){var a=this.diff_main(v,w,false);e.splice(s-t-u,t+u);s=s-t-u;for(var j=a.length-1;j>=0;j--){e.splice(s,0,a[j])}s=s+a.length}u=0;t=0;v='';w='';break}s++}e.pop()}return e};diff_match_patch.prototype.diff_linesToChars=function(g,h){var i=[];var j={};i[0]='';function diff_linesToCharsMunge(a){var b='';var c=0;var d=-1;var e=i.length;while(d0&&(new Date()).getTime()>e){return null}j[d]={};for(var k=-d;k<=d;k+=2){if(k==-d||k!=d&&m[k-1]=0;d--){while(1){if(a[d].hasOwnProperty?a[d].hasOwnProperty((x-1)+','+y):(a[d][(x-1)+','+y]!==undefined)){x--;if(f===DIFF_DELETE){e[0][1]=b.charAt(x)+e[0][1]}else{e.unshift([DIFF_DELETE,b.charAt(x)])}f=DIFF_DELETE;break}else if(a[d].hasOwnProperty?a[d].hasOwnProperty(x+','+(y-1)):(a[d][x+','+(y-1)]!==undefined)){y--;if(f===DIFF_INSERT){e[0][1]=c.charAt(y)+e[0][1]}else{e.unshift([DIFF_INSERT,c.charAt(y)])}f=DIFF_INSERT;break}else{x--;y--;if(f===DIFF_EQUAL){e[0][1]=b.charAt(x)+e[0][1]}else{e.unshift([DIFF_EQUAL,b.charAt(x)])}f=DIFF_EQUAL}}}return e};diff_match_patch.prototype.diff_path2=function(a,b,c){var e=[];var f=0;var x=b.length;var y=c.length;var g=null;for(var d=a.length-2;d>=0;d--){while(1){if(a[d].hasOwnProperty?a[d].hasOwnProperty((x-1)+','+y):(a[d][(x-1)+','+y]!==undefined)){x--;if(g===DIFF_DELETE){e[f-1][1]+=b.charAt(b.length-x-1)}else{e[f++]=[DIFF_DELETE,b.charAt(b.length-x-1)]}g=DIFF_DELETE;break}else if(a[d].hasOwnProperty?a[d].hasOwnProperty(x+','+(y-1)):(a[d][x+','+(y-1)]!==undefined)){y--;if(g===DIFF_INSERT){e[f-1][1]+=c.charAt(c.length-y-1)}else{e[f++]=[DIFF_INSERT,c.charAt(c.length-y-1)]}g=DIFF_INSERT;break}else{x--;y--;if(g===DIFF_EQUAL){e[f-1][1]+=b.charAt(b.length-x-1)}else{e[f++]=[DIFF_EQUAL,b.charAt(b.length-x-1)]}g=DIFF_EQUAL}}}return e};diff_match_patch.prototype.diff_commonPrefix=function(a,b){if(!a||!b||a.charCodeAt(0)!==b.charCodeAt(0)){return 0}var c=0;var d=Math.min(a.length,b.length);var e=d;var f=0;while(ck.length?h:k;var m=h.length>k.length?k:h;if(l.length<10||m.length<1){return null}var n=this;function diff_halfMatchI(a,b,i){var c=a.substring(i,i+Math.floor(a.length/4));var j=-1;var d='';var e,best_longtext_b,best_shorttext_a,best_shorttext_b;while((j=b.indexOf(c,j+1))!=-1){var f=n.diff_commonPrefix(a.substring(i),b.substring(j));var g=n.diff_commonSuffix(a.substring(0,i),b.substring(0,j));if(d.length=a.length/2){return[e,best_longtext_b,best_shorttext_a,best_shorttext_b,d]}else{return null}}var o=diff_halfMatchI(l,m,Math.ceil(l.length/4));var p=diff_halfMatchI(l,m,Math.ceil(l.length/2));var q;if(!o&&!p){return null}else if(!p){q=o}else if(!o){q=p}else{q=o[4].length>p[4].length?o:p}var r,text1_b,text2_a,text2_b;if(h.length>k.length){r=q[0];text1_b=q[1];text2_a=q[2];text2_b=q[3]}else{text2_a=q[0];text2_b=q[1];r=q[2];text1_b=q[3]}var s=q[4];return[r,text1_b,text2_a,text2_b,s]};diff_match_patch.prototype.diff_cleanupSemantic=function(a){var b=false;var c=[];var d=0;var e=null;var f=0;var g=0;var h=0;while(f0?c[d-1]:-1;g=0;h=0;e=null;b=true}}f++}if(b){this.diff_cleanupMerge(a)}this.diff_cleanupSemanticLossless(a)};diff_match_patch.prototype.diff_cleanupSemanticLossless=function(d){var e=/[^a-zA-Z0-9]/;var f=/\s/;var g=/[\r\n]/;var h=/\n\r?\n$/;var i=/^\r?\n\r?\n/;function diff_cleanupSemanticScore(a,b){if(!a||!b){return 5}var c=0;if(a.charAt(a.length-1).match(e)||b.charAt(0).match(e)){c++;if(a.charAt(a.length-1).match(f)||b.charAt(0).match(f)){c++;if(a.charAt(a.length-1).match(g)||b.charAt(0).match(g)){c++;if(a.match(h)||b.match(i)){c++}}}}return c}var j=1;while(j=s){s=t;p=k;q=l;r=m}}if(d[j-1][1]!=p){if(p){d[j-1][1]=p}else{d.splice(j-1,1);j--}d[j][1]=q;if(r){d[j+1][1]=r}else{d.splice(j+1,1);j--}}}j++}};diff_match_patch.prototype.diff_cleanupEfficiency=function(a){var b=false;var c=[];var d=0;var e='';var f=0;var g=false;var h=false;var i=false;var j=false;while(f0?c[d-1]:-1;i=j=false}b=true}}f++}if(b){this.diff_cleanupMerge(a)}};diff_match_patch.prototype.diff_cleanupMerge=function(a){a.push([DIFF_EQUAL,'']);var b=0;var c=0;var d=0;var e='';var f='';var g;while(b0&&a[b-c-d-1][0]==DIFF_EQUAL){a[b-c-d-1][1]+=f.substring(0,g)}else{a.splice(0,0,[DIFF_EQUAL,f.substring(0,g)]);b++}f=f.substring(g);e=e.substring(g)}g=this.diff_commonSuffix(f,e);if(g!==0){a[b][1]=f.substring(f.length-g)+a[b][1];f=f.substring(0,f.length-g);e=e.substring(0,e.length-g)}}if(c===0){a.splice(b-c-d,c+d,[DIFF_INSERT,f])}else if(d===0){a.splice(b-c-d,c+d,[DIFF_DELETE,e])}else{a.splice(b-c-d,c+d,[DIFF_DELETE,e],[DIFF_INSERT,f])}b=b-c-d+(c?1:0)+(d?1:0)+1}else if(b!==0&&a[b-1][0]==DIFF_EQUAL){a[b-1][1]+=a[b][1];a.splice(b,1)}else{b++}d=0;c=0;e='';f='';break}}if(a[a.length-1][1]===''){a.pop()}var h=false;b=1;while(bb){break}e=c;f=d}if(a.length!=x&&a[x][0]===DIFF_DELETE){return f}return f+(b-e)};diff_match_patch.prototype.diff_prettyHtml=function(a){var b=[];var i=0;for(var x=0;x/g,'>').replace(/\n/g,'¶
');switch(c){case DIFF_INSERT:b[x]=''+e+'';break;case DIFF_DELETE:b[x]=''+e+'';break;case DIFF_EQUAL:b[x]=''+e+'';break}if(c!==DIFF_DELETE){i+=d.length}}return b.join('')};diff_match_patch.prototype.diff_text1=function(a){var b=[];for(var x=0;xthis.Match_MaxBits){throw new Error('Pattern too long for this browser.');}var s=this.match_alphabet(f);var h=this;function match_bitapScore(e,x){var a=e/f.length;var b=Math.abs(g-x);if(!h.Match_Distance){return b?1.0:a}return a+(b/h.Match_Distance)}var i=this.Match_Threshold;var k=c.indexOf(f,g);if(k!=-1){i=Math.min(match_bitapScore(0,k),i)}k=c.lastIndexOf(f,g+f.length);if(k!=-1){i=Math.min(match_bitapScore(0,k),i)}var l=1<<(f.length-1);k=-1;var m,bin_mid;var n=f.length+c.length;var o;for(var d=0;d=p;j--){var t=s[c.charAt(j-1)];if(d===0){r[j]=((r[j+1]<<1)|1)&t}else{r[j]=((r[j+1]<<1)|1)&t|(((o[j+1]|o[j])<<1)|1)|o[j+1]}if(r[j]&l){var u=match_bitapScore(d,j-1);if(u<=i){i=u;k=j-1;if(k>g){p=Math.max(1,2*g-k)}else{break}}}}if(match_bitapScore(d+1,g)>i){break}o=r}return k};diff_match_patch.prototype.match_alphabet=function(a){var s={};for(var i=0;i2){this.diff_cleanupSemantic(diffs);this.diff_cleanupEfficiency(diffs)}}else if(typeof a=='object'&&typeof b=='undefined'&&typeof c=='undefined'){diffs=a;d=this.diff_text1(diffs)}else if(typeof a=='string'&&typeof b=='object'&&typeof c=='undefined'){d=a;diffs=b}else if(typeof a=='string'&&typeof b=='string'&&typeof c=='object'){d=a;diffs=c}else{throw new Error('Unknown call format to patch_make.');}if(diffs.length===0){return[]}var e=[];var f=new patch_obj();var g=0;var h=0;var i=0;var j=d;var k=d;for(var x=0;x=2*this.Patch_Margin){if(g){this.patch_addContext(f,j);e.push(f);f=new patch_obj();g=0;j=k;h=i}}break}if(l!==DIFF_INSERT){h+=m.length}if(l!==DIFF_DELETE){i+=m.length}}if(g){this.patch_addContext(f,j);e.push(f)}return e};diff_match_patch.prototype.patch_deepCopy=function(a){var b=[];for(var x=0;xthis.Match_MaxBits){h=this.match_main(b,g.substring(0,this.Match_MaxBits),f);if(h!=-1){i=this.match_main(b,g.substring(g.length-this.Match_MaxBits),f+g.length-this.Match_MaxBits);if(i==-1||h>=i){h=-1}}}else{h=this.match_main(b,g,f)}if(h==-1){e[x]=false}else{e[x]=true;d=h-f;var j;if(i==-1){j=b.substring(h,h+g.length)}else{j=b.substring(h,i+this.Match_MaxBits)}if(g==j){b=b.substring(0,h)+this.diff_text2(a[x].diffs)+b.substring(h+g.length)}else{var k=this.diff_main(g,j,false);if(g.length>this.Match_MaxBits&&this.diff_levenshtein(k)/g.length>this.Patch_DeleteThreshold){e[x]=false}else{this.diff_cleanupSemanticLossless(k);var l=0;var m;for(var y=0;yd[0][1].length){var e=b.length-d[0][1].length;d[0][1]=b.substring(d[0][1].length)+d[0][1];c.start1-=e;c.start2-=e;c.length1+=e;c.length2+=e}c=a[a.length-1];d=c.diffs;if(d.length==0||d[d.length-1][0]!=DIFF_EQUAL){d.push([DIFF_EQUAL,b]);c.length1+=b.length;c.length2+=b.length}else if(b.length>d[d.length-1][1].length){var e=b.length-d[d.length-1][1].length;d[d.length-1][1]+=b.substring(0,e);c.length1+=e;c.length2+=e}return b};diff_match_patch.prototype.patch_splitMax=function(a){for(var x=0;xthis.Match_MaxBits){var b=a[x];a.splice(x--,1);var c=this.Match_MaxBits;var d=b.start1;var e=b.start2;var f='';while(b.diffs.length!==0){var g=new patch_obj();var h=true;g.start1=d-f.length;g.start2=e-f.length;if(f!==''){g.length1=g.length2=f.length;g.diffs.push([DIFF_EQUAL,f])}while(b.diffs.length!==0&&g.length12*c){g.length1+=j.length;d+=j.length;h=false;g.diffs.push([i,j]);b.diffs.shift()}else{j=j.substring(0,c-g.length1-this.Patch_Margin);g.length1+=j.length;d+=j.length;if(i===DIFF_EQUAL){g.length2+=j.length;e+=j.length}else{h=false}g.diffs.push([i,j]);if(j==b.diffs[0][1]){b.diffs.shift()}else{b.diffs[0][1]=b.diffs[0][1].substring(j.length)}}}f=this.diff_text2(g.diffs);f=f.substring(f.length-this.Patch_Margin);var k=this.diff_text1(b.diffs).substring(0,this.Patch_Margin);if(k!==''){g.length1+=k.length;g.length2+=k.length;if(g.diffs.length!==0&&g.diffs[g.diffs.length-1][0]===DIFF_EQUAL){g.diffs[g.diffs.length-1][1]+=k}else{g.diffs.push([DIFF_EQUAL,k])}}if(!h){a.splice(++x,0,g)}}}}};diff_match_patch.prototype.patch_toText=function(a){var b=[];for(var x=0;x2){this.dmp.diff_cleanupSemantic(b);this.dmp.diff_cleanupEfficiency(b)}var c=b.length!=1||b[0][0]!=DIFF_EQUAL;if(c){mobwrite.clientChange_=true;this.shadowText=a}if(c||!this.editStack.length){var d=(this.mergeChanges?'d:':'D:')+this.clientVersion+':'+this.dmp.diff_toDelta(b);this.editStack.push([this.clientVersion,d]);this.clientVersion++;this.onSentDiff(b)}}else{if(this.shadowText!=a){this.shadowText=a}this.clientVersion++;var d='r:'+this.clientVersion+':'+encodeURI(a).replace(/%20/g,' ');this.editStack.push([this.clientVersion,d])}var e='F:'+this.serverVersion+':'+encodeURI(mobwrite.idPrefix+this.file)+'\n';for(var x=0;xc.serverVersion){c.deltaOk=false;if(mobwrite.debug){window.console.error('Server version in future.\n'+'Expected: '+c.serverVersion+' Got: '+h)}}else if(h1){c.endOffset=d[1];if(c.startOffset>=c.endOffset){c.collapsed=true}}this.restoreCursor_(c)}}};mobwrite.shareTextareaObj.prototype.patch_apply_=function(a,b,c){if(a.length==0){return b}a=this.dmp.patch_deepCopy(a);var d=this.dmp.patch_addPadding(a);b=d+b+d;this.dmp.patch_splitMax(a);var e=0;for(var x=0;xthis.dmp.Match_MaxBits){h=this.dmp.match_main(b,g.substring(0,this.dmp.Match_MaxBits),f);if(h!=-1){j=this.dmp.match_main(b,g.substring(g.length-this.dmp.Match_MaxBits),f+g.length-this.dmp.Match_MaxBits);if(j==-1||h>=j){h=-1}}}else{h=this.dmp.match_main(b,g,f)}if(h==-1){if(mobwrite.debug){window.console.warn('Patch failed: '+a[x])}}else{if(mobwrite.debug){window.console.info('Patch OK.')}e=h-f;var k;if(j==-1){k=b.substring(h,h+g.length)}else{k=b.substring(h,j+this.dmp.Match_MaxBits)}var l=this.dmp.diff_main(g,k,false);if(g.length>this.dmp.Match_MaxBits&&this.dmp.diff_levenshtein(l)/g.length>this.dmp.Patch_DeleteThreshold){if(mobwrite.debug){window.console.warn('Patch contents mismatch: '+a[x])}}else{var m=0;var n;for(var y=0;yh+n){c[i]+=o[1].length}}}else if(o[0]===DIFF_DELETE){var p=h+n;var q=h+this.dmp.diff_xIndex(l,m+o[1].length);b=b.substring(0,p)+b.substring(q);for(var i=0;ip){if(c[i]+d.length} */ mobwrite.shareHandlers = []; /** * Prototype of shared object. * @param {string} id Unique file ID. * @constructor */ mobwrite.shareObj = function(id) { if (id) { this.file = id; this.dmp = new diff_match_patch(); this.dmp.Diff_Timeout = 0.5; // List of unacknowledged edits sent to the server. this.editStack = []; if (mobwrite.debug) { window.console.info('Creating shareObj: "' + id + '"'); } } }; /** * Client's understanding of what the server's text looks like. * @type {string} */ mobwrite.shareObj.prototype.shadowText = ''; /** * The client's version for the shadow (n). * @type {number} */ mobwrite.shareObj.prototype.clientVersion = 0; /** * The server's version for the shadow (m). * @type {number} */ mobwrite.shareObj.prototype.serverVersion = 0; /** * Did the client understand the server's delta in the previous heartbeat? * Initialize false because the server and client are out of sync initially. * @type {boolean} */ mobwrite.shareObj.prototype.deltaOk = false; /** * Synchronization mode. * True: Used for text, attempts to gently merge differences together. * False: Used for numbers, overwrites conflicts, last save wins. * @type {boolean} */ mobwrite.shareObj.prototype.mergeChanges = true; /** * Fetch or compute a plaintext representation of the user's text. * @return {string} Plaintext content. */ mobwrite.shareObj.prototype.getClientText = function() { window.alert('Defined by subclass'); return ''; }; /** * Set the user's text based on the provided plaintext. * @param {string} text New text. */ mobwrite.shareObj.prototype.setClientText = function(text) { window.alert('Defined by subclass'); }; /** * Modify the user's plaintext by applying a series of patches against it. * @param {Array.} patches Array of Patch objects. */ mobwrite.shareObj.prototype.patchClientText = function(patches) { var oldClientText = this.getClientText(); var result = this.dmp.patch_apply(patches, oldClientText); // Set the new text only if there is a change to be made. if (oldClientText != result[0]) { // The following will probably destroy any cursor or selection. // Widgets with cursors should override and patch more delicately. this.setClientText(result[0]); } }; /** * Notification of when a diff was sent to the server. * @param {Array.>} diffs Array of diff tuples. */ mobwrite.shareObj.prototype.onSentDiff = function(diffs) { // Potential hook for subclass. }; /** * Fire a synthetic 'change' event to a target element. * Notifies an element that its contents have been changed. * @param {Object} target Element to notify. */ mobwrite.shareObj.prototype.fireChange = function(target) { if ('createEvent' in document) { // W3 var e = document.createEvent('HTMLEvents'); e.initEvent('change', false, false); target.dispatchEvent(e); } else if ('fireEvent' in target) { // IE target.fireEvent('onchange'); } }; /** * Return the command to nullify this field. Also unshares this field. * @return {string} Command to be sent to the server. */ mobwrite.shareObj.prototype.nullify = function() { mobwrite.unshare(this.file); return 'N:' + encodeURI(mobwrite.idPrefix + this.file) + '\n'; }; /** * Asks the shareObj to synchronize. Computes client-made changes since * previous postback. Return '' to skip this synchronization. * @return {string} Commands to be sent to the server. */ mobwrite.shareObj.prototype.syncText = function() { var clientText = this.getClientText(); if (this.deltaOk) { // The last delta postback from the server to this shareObj was successful. // Send a compressed delta. var diffs = this.dmp.diff_main(this.shadowText, clientText, true); if (diffs.length > 2) { this.dmp.diff_cleanupSemantic(diffs); this.dmp.diff_cleanupEfficiency(diffs); } var changed = diffs.length != 1 || diffs[0][0] != DIFF_EQUAL; if (changed) { mobwrite.clientChange_ = true; this.shadowText = clientText; } // Don't bother appending a no-change diff onto the stack if the stack // already contains something. if (changed || !this.editStack.length) { var action = (this.mergeChanges ? 'd:' : 'D:') + this.clientVersion + ':' + this.dmp.diff_toDelta(diffs); this.editStack.push([this.clientVersion, action]); this.clientVersion++; this.onSentDiff(diffs); } } else { // The last delta postback from the server to this shareObj didn't match. // Send a full text dump to get back in sync. This will result in any // changes since the last postback being wiped out. :( if (this.shadowText != clientText) { this.shadowText = clientText; } this.clientVersion++; var action = 'r:' + this.clientVersion + ':' + encodeURI(clientText).replace(/%20/g, ' '); // Append the action to the edit stack. this.editStack.push([this.clientVersion, action]); } // Create the output starting with the file statement, followed by the edits. var data = 'F:' + this.serverVersion + ':' + encodeURI(mobwrite.idPrefix + this.file) + '\n'; for (var x = 0; x < this.editStack.length; x++) { data += this.editStack[x][1] + '\n'; } // Opera doesn't know how to encode char 0. (fixed in Opera 9.63) return data.replace(/\x00/g, '%00'); }; /** * Collect all client-side changes and send them to the server. * @private */ mobwrite.syncRun1_ = function() { // Initialize clientChange_, to be checked at the end of syncRun2_. mobwrite.clientChange_ = false; var data = []; data[0] = 'u:' + mobwrite.syncUsername + '\n'; var empty = true; // Ask every shared object for their deltas. for (var x in mobwrite.shared) { if (mobwrite.shared.hasOwnProperty(x)) { if (mobwrite.nullifyAll) { data.push(mobwrite.shared[x].nullify()); } else { data.push(mobwrite.shared[x].syncText()); } empty = false; } } if (empty) { // No sync objects. if (mobwrite.debug) { window.console.info('MobWrite task stopped.'); } return; } if (data.length == 1) { // No sync data. if (mobwrite.debug) { window.console.info('All objects silent; null sync.'); } mobwrite.syncRun2_('\n\n'); return; } var remote = (mobwrite.syncGateway.indexOf('://') != -1); if (mobwrite.debug) { window.console.info('TO server:\n' + data.join('')); } // Add terminating blank line. data.push('\n'); data = data.join(''); // Schedule a watchdog task to catch us if something horrible happens. mobwrite.syncKillPid_ = window.setTimeout(mobwrite.syncKill_, mobwrite.timeoutInterval); if (remote) { var blocks = mobwrite.splitBlocks_(data); // Add a script tag to the head. var head = document.getElementsByTagName('head')[0]; for (var x = 0; x < blocks.length; x++) { var script = document.getElementById('mobwrite_sync' + x); if (script) { script.parentNode.removeChild(script); // IE allows us to recycle a script tag. // Other browsers need the old one destroyed and a new one created. if (!mobwrite.UA_msie) { // Browsers won't garbage collect the old script. // So castrate it to avoid a major memory leak. for (var prop in script) { delete script[prop]; } script = null; } } if (!script) { script = document.createElement('script'); script.type = 'text/javascript'; script.charset = 'utf-8'; script.id = 'mobwrite_sync' + x; } script.src = blocks[x]; head.appendChild(script); } // Execution will resume in mobwrite.callback(); } else { // Issue Ajax post of client-side changes and request server-side changes. data = 'q=' + encodeURIComponent(data); mobwrite.syncAjaxObj_ = mobwrite.syncLoadAjax_(mobwrite.syncGateway, data, mobwrite.syncCheckAjax_); // Execution will resume in either syncCheckAjax_(), or syncKill_() } }; /** * Encode protocol data into JSONP URLs. Split into multiple URLs if needed. * @param {string} data MobWrite protocol data. * @param {number} opt_minBlocks There will be at least this many blocks. * @return {Array.} Protocol data split into smaller strings. * @private */ mobwrite.splitBlocks_ = function(data, opt_minBlocks) { var encData = encodeURIComponent(data); var prefix = mobwrite.syncGateway + '?p='; var maxchars = mobwrite.get_maxchars - prefix.length; var encPlusData = encData.replace(/%20/g, '+'); if (encPlusData.length <= maxchars) { // Encode as single URL. return [prefix + encPlusData]; } // Digits is the number of characters needed to encode the number of blocks. var digits = 1; if (typeof opt_minBlocks != 'undefined') { digits = String(opt_minBlocks).length; } // Break the data into small blocks. var blocks = []; // Encode the data again because it is being wrapped into another shell. var encEncData = encodeURIComponent(encData); // Compute the size of the overhead for each block. // Small bug: if there are 10+ blocks, we reserve but don't use one extra // byte for blocks 1-9. var id = mobwrite.uniqueId(); var paddingSize = (prefix + 'b%3A' + id + '+++' + '%0A%0A').length + 2 * digits; // Compute length available for each block. var blockLength = mobwrite.get_maxchars - paddingSize; if (blockLength < 3) { if (mobwrite.debug) { window.console.error('mobwrite.get_maxchars too small to send data.'); } // Override this setting (3 is minimum to send the indivisible '%25'). blockLength = 3; } // Compute number of blocks. var bufferBlocks = Math.ceil(encEncData.length / blockLength); if (typeof opt_minBlocks != 'undefined') { bufferBlocks = Math.max(bufferBlocks, opt_minBlocks); } // Obtain a random ID for this buffer. var bufferHeader = 'b%3A' + id + '+' + encodeURIComponent(bufferBlocks) + '+'; var startPointer = 0; for (var x = 1; x <= bufferBlocks; x++) { var endPointer = startPointer + blockLength; // Don't split a '%25' construct. if (encEncData.charAt(endPointer - 1) == '%') { endPointer -= 1; } else if (encEncData.charAt(endPointer - 2) == '%') { endPointer -= 2; } var bufferData = encEncData.substring(startPointer, endPointer); blocks.push(prefix + bufferHeader + x + '+' + bufferData + '%0A%0A'); startPointer = endPointer; } if (startPointer < encEncData.length) { if (mobwrite.debug) { window.console.debug('Recursing splitBlocks_ at n=' + (bufferBlocks + 1)); } return this.splitBlocks_(data, bufferBlocks + 1); } return blocks; }; /** * Callback location for JSON-P requests. * @param {string} text Raw content from server. */ mobwrite.callback = function(text) { // Only process the response if there is a response (don't schedule a new // heartbeat due to one of the many null responses from a buffer push). if (text) { // Add required trailing blank line. mobwrite.syncRun2_(text + '\n'); } else { // This null response proves we got a round-trip of a buffer from the // server. Reschedule the watchdog. window.clearTimeout(mobwrite.syncKillPid_); mobwrite.syncKillPid_ = window.setTimeout(mobwrite.syncKill_, mobwrite.timeoutInterval); } }; /** * Parse all server-side changes and distribute them to the shared objects. * @param {string} text Raw content from server. * @private */ mobwrite.syncRun2_ = function(text) { // Initialize serverChange_, to be checked at the end of syncRun2_. mobwrite.serverChange_ = false; if (mobwrite.debug) { window.console.info('FROM server:\n' + text); } // Opera doesn't know how to decode char 0. (fixed in Opera 9.63) text = text.replace(/%00/g, '\0'); // There must be a linefeed followed by a blank line. if (text.length < 2 || text.substring(text.length - 2) != '\n\n') { text = ''; if (mobwrite.error) { window.console.info('Truncated data. Abort.'); } } var lines = text.split('\n'); var file = null; var clientVersion = null; for (var i = 0; i < lines.length; i++) { var line = lines[i]; if (!line) { // Terminate on blank line. break; } // Divide each line into 'N:value' pairs. if (line.charAt(1) != ':') { if (mobwrite.debug) { window.console.error('Unparsable line: ' + line); } continue; } var name = line.charAt(0); var value = line.substring(2); // Parse out a version number for file, delta or raw. var version; if ('FfDdRr'.indexOf(name) != -1) { var div = value.indexOf(':'); if (div < 1) { if (mobwrite.debug) { window.console.error('No version number: ' + line); } continue; } version = parseInt(value.substring(0, div), 10); if (isNaN(version)) { if (mobwrite.debug) { window.console.error('NaN version number: ' + line); } continue; } value = value.substring(div + 1); } if (name == 'F' || name == 'f') { // File indicates which shared object following delta/raw applies to. if (value.substring(0, mobwrite.idPrefix.length) == mobwrite.idPrefix) { // Trim off the ID prefix. value = value.substring(mobwrite.idPrefix.length); } else { // This file does not have our ID prefix. file = null; if (mobwrite.debug) { window.console.error('File does not have "' + mobwrite.idPrefix + '" prefix: ' + value); } continue; } if (mobwrite.shared.hasOwnProperty(value)) { file = mobwrite.shared[value]; file.deltaOk = true; clientVersion = version; // Remove any elements from the edit stack with low version numbers // which have been acked by the server. for (var x = 0; x < file.editStack.length; x++) { if (file.editStack[x][0] <= clientVersion) { file.editStack.splice(x, 1); x--; } } } else { // This file does not map to a currently shared object. file = null; if (mobwrite.debug) { window.console.error('Unknown file: ' + value); } } } else if (name == 'R' || name == 'r') { // The server reports it was unable to integrate the previous delta. if (file) { file.shadowText = decodeURI(value); file.clientVersion = clientVersion; file.serverVersion = version; file.editStack = []; if (name == 'R') { // Accept the server's raw text dump and wipe out any user's changes. file.setClientText(file.shadowText); } // Server-side activity. mobwrite.serverChange_ = true; } } else if (name == 'D' || name == 'd') { // The server offers a compressed delta of changes to be applied. if (file) { if (clientVersion != file.clientVersion) { // Can't apply a delta on a mismatched shadow version. file.deltaOk = false; if (mobwrite.debug) { window.console.error('Client version number mismatch.\n' + 'Expected: ' + file.clientVersion + ' Got: ' + clientVersion); } } else if (version > file.serverVersion) { // Server has a version in the future? file.deltaOk = false; if (mobwrite.debug) { window.console.error('Server version in future.\n' + 'Expected: ' + file.serverVersion + ' Got: ' + version); } } else if (version < file.serverVersion) { // We've already seen this diff. if (mobwrite.debug) { window.console.warn('Server version in past.\n' + 'Expected: ' + file.serverVersion + ' Got: ' + version); } } else { // Expand the delta into a diff using the client shadow. var diffs; try { diffs = file.dmp.diff_fromDelta(file.shadowText, value); file.serverVersion++; } catch (ex) { // The delta the server supplied does not fit on our copy of // shadowText. diffs = null; // Set deltaOk to false so that on the next sync we send // a complete dump to get back in sync. file.deltaOk = false; // Do the next sync soon because the user will lose any changes. mobwrite.syncInterval = 0; if (mobwrite.debug) { window.console.error('Delta mismatch.\n' + encodeURI(file.shadowText)); } } if (diffs && (diffs.length != 1 || diffs[0][0] != DIFF_EQUAL)) { // Compute and apply the patches. if (name == 'D') { // Overwrite text. file.shadowText = file.dmp.diff_text2(diffs); file.setClientText(file.shadowText); } else { // Merge text. var patches = file.dmp.patch_make(file.shadowText, diffs); // First shadowText. Should be guaranteed to work. var serverResult = file.dmp.patch_apply(patches, file.shadowText); file.shadowText = serverResult[0]; // Second the user's text. file.patchClientText(patches); } // Server-side activity. mobwrite.serverChange_ = true; } } } } } mobwrite.computeSyncInterval_(); // Ensure that there is only one sync task. window.clearTimeout(mobwrite.syncRunPid_); // Schedule the next sync. mobwrite.syncRunPid_ = window.setTimeout(mobwrite.syncRun1_, mobwrite.syncInterval); // Terminate the watchdog task, everything's ok. window.clearTimeout(mobwrite.syncKillPid_); mobwrite.syncKillPid_ = null; }; /** * Compute how long to wait until next synchronization. * @private */ mobwrite.computeSyncInterval_ = function() { var range = mobwrite.maxSyncInterval - mobwrite.minSyncInterval; if (mobwrite.clientChange_) { // Client-side activity. // Cut the sync interval by 40% of the min-max range. mobwrite.syncInterval -= range * 0.4; } if (mobwrite.serverChange_) { // Server-side activity. // Cut the sync interval by 20% of the min-max range. mobwrite.syncInterval -= range * 0.2; } if (!mobwrite.clientChange_ && !mobwrite.serverChange_) { // No activity. // Let the sync interval creep up by 10% of the min-max range. mobwrite.syncInterval += range * 0.1; } // Keep the sync interval constrained between min and max. mobwrite.syncInterval = Math.max(mobwrite.minSyncInterval, mobwrite.syncInterval); mobwrite.syncInterval = Math.min(mobwrite.maxSyncInterval, mobwrite.syncInterval); }; /** * If the Ajax call doesn't complete after a timeout period, start over. * @private */ mobwrite.syncKill_ = function() { mobwrite.syncKillPid_ = null; if (mobwrite.syncAjaxObj_) { // Cleanup old Ajax connection. mobwrite.syncAjaxObj_.abort(); mobwrite.syncAjaxObj_ = null; } if (mobwrite.debug) { window.console.warn('Connection timeout.'); } window.clearTimeout(mobwrite.syncRunPid_); // Initiate a new sync right now. mobwrite.syncRunPid_ = window.setTimeout(mobwrite.syncRun1_, 1); }; /** * Initiate an Ajax network connection. * @param {string} url Location to send request. * @param {string} post Data to be sent. * @param {Function} callback Function to be called when response arrives. * @return {Object?} New Ajax object or null if failure. * @private */ mobwrite.syncLoadAjax_ = function(url, post, callback) { var req = null; // branch for native XMLHttpRequest object if (window.XMLHttpRequest) { try { req = new XMLHttpRequest(); } catch(e1) { req = null; } // branch for IE/Windows ActiveX version } else if (window.ActiveXObject) { try { req = new ActiveXObject('Msxml2.XMLHTTP'); } catch(e2) { try { req = new ActiveXObject('Microsoft.XMLHTTP'); } catch(e3) { req = null; } } } if (req) { req.onreadystatechange = callback; req.open('POST', url, true); req.setRequestHeader('Content-Type','application/x-www-form-urlencoded'); req.send(post); } return req; }; /** * Callback function for Ajax request. Checks network response was ok, * then calls mobwrite.syncRun2_. * @private */ mobwrite.syncCheckAjax_ = function() { if (typeof mobwrite == 'undefined' || !mobwrite.syncAjaxObj_) { // This might be a callback after the page has unloaded, // or this might be a callback which we deemed to have timed out. return; } // Only if req shows "loaded" if (mobwrite.syncAjaxObj_.readyState == 4) { // Only if "OK" if (mobwrite.syncAjaxObj_.status == 200) { var text = mobwrite.syncAjaxObj_.responseText; mobwrite.syncAjaxObj_ = null; mobwrite.syncRun2_(text); } else { if (mobwrite.debug) { window.console.warn('Connection error code: ' + mobwrite.syncAjaxObj_.status); } mobwrite.syncAjaxObj_ = null; } } }; /** * When unloading, run a sync one last time. * @private */ mobwrite.unload_ = function() { if (!mobwrite.syncKillPid_) { // Turn off debug mode since the console disappears on page unload before // this code does. mobwrite.debug = false; mobwrite.syncRun1_(); } // By the time the callback runs mobwrite.syncRun2_, this page will probably // be gone. But that's ok, we are just sending our last changes out, we // don't care what the server says. }; // Attach unload event to window. if (window.addEventListener) { // W3 window.addEventListener('unload', mobwrite.unload_, false); } else if (window.attachEvent) { // IE window.attachEvent('onunload', mobwrite.unload_); } /** * Start sharing the specified object(s). * @param {*} var_args Object(s) or ID(s) of object(s) to share. */ mobwrite.share = function(var_args) { for (var i = 0; i < arguments.length; i++) { var el = arguments[i]; var result = null; // Ask every registered handler if it knows what to do with this object. for (var x = 0; x < mobwrite.shareHandlers.length && !result; x++) { result = mobwrite.shareHandlers[x].call(mobwrite, el); } if (result && result.file) { if (result.file in mobwrite.shared) { // Already exists. // Don't replace, since we don't want to lose state. if (mobwrite.debug) { window.console.warn('Ignoring duplicate share on "' + el + '".'); } continue; } mobwrite.shared[result.file] = result; if (mobwrite.syncRunPid_ === null) { // Startup the main task if it doesn't already exist. if (mobwrite.debug) { window.console.info('MobWrite task started.'); } } else { // Bring sync forward in time. window.clearTimeout(mobwrite.syncRunPid_); } mobwrite.syncRunPid_ = window.setTimeout(mobwrite.syncRun1_, 10); } else { if (mobwrite.debug) { window.console.warn('Share: Unknown widget type: ' + el + '.'); } } } }; /** * Stop sharing the specified object(s). * Does not handle forms recursively. * @param {*} var_args Object(s) or ID(s) of object(s) to unshare. */ mobwrite.unshare = function(var_args) { for (var i = 0; i < arguments.length; i++) { var el = arguments[i]; if (typeof el == 'string' && mobwrite.shared.hasOwnProperty(el)) { delete mobwrite.shared[el]; if (mobwrite.debug) { window.console.info('Unshared: ' + el); } } else { // Pretend to want to share this object, acquire a new shareObj, then use // its ID to locate and kill the existing shareObj that's already shared. var result = null; // Ask every registered handler if it knows what to do with this object. for (var x = 0; x < mobwrite.shareHandlers.length && !result; x++) { result = mobwrite.shareHandlers[x].call(mobwrite, el); } if (result && result.file) { if (mobwrite.shared.hasOwnProperty(result.file)) { delete mobwrite.shared[result.file]; if (mobwrite.debug) { window.console.info('Unshared: ' + el); } } else { if (mobwrite.debug) { window.console.warn('Ignoring ' + el + '. Not currently shared.'); } } } else { if (mobwrite.debug) { window.console.warn('Unshare: Unknown widget type: ' + el + '.'); } } } } }; whiteboard/mobwrite/java-client/0000755000175000017500000000000012251036356016224 5ustar ernieerniewhiteboard/mobwrite/java-client/ShareButtonGroup.java0000644000175000017500000000321012251036356022336 0ustar ernieerniepackage name.fraser.neil.mobwrite; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.util.Enumeration; import javax.swing.AbstractButton; import javax.swing.ButtonGroup; class ShareButtonGroup extends ShareObj { /** * The user-facing radio button group to be shared. */ private ButtonGroup buttonGroup; /** * Constructor of shared object representing a radio button. * @param bg Radio button group to share. * @param file Filename to share as. */ public ShareButtonGroup(ButtonGroup bg, String file) { super(file); this.buttonGroup = bg; this.mergeChanges = false; } /** * Retrieve the user's check. * @return Plaintext content. */ public String getClientText() { Enumeration elements = this.buttonGroup.getElements(); while (elements.hasMoreElements()) { AbstractButton ab = elements.nextElement(); if (ab.isSelected()) { return ab.getName(); } } return ""; } /** * Set the user's check. * @param text New content. */ public void setClientText(String text) { Enumeration elements = this.buttonGroup.getElements(); while (elements.hasMoreElements()) { AbstractButton ab = elements.nextElement(); if (text.equals(ab.getName())) { ab.setSelected(true); // Fire any events. String actionCommand = ab.getActionCommand(); ActionEvent e = new ActionEvent(ab, ActionEvent.ACTION_PERFORMED, actionCommand, 0); for (ActionListener listener : ab.getActionListeners()) { listener.actionPerformed(e); } } } } } whiteboard/mobwrite/java-client/ShareJTextComponent.java0000644000175000017500000001704712251036356023004 0ustar ernieerniepackage name.fraser.neil.mobwrite; import java.util.LinkedList; import java.util.List; import java.util.Vector; import java.util.logging.Level; import javax.swing.text.BadLocationException; import javax.swing.text.Document; import javax.swing.text.JTextComponent; import name.fraser.neil.plaintext.diff_match_patch.Diff; import name.fraser.neil.plaintext.diff_match_patch.Operation; import name.fraser.neil.plaintext.diff_match_patch.Patch; class ShareJTextComponent extends ShareObj { /** * The user-facing text component to be shared. */ private JTextComponent textComponent; /** * Constructor of shared object representing a text field. * @param tc Text component to share. * @param file Filename to share as. */ public ShareJTextComponent(JTextComponent tc, String file) { super(file); this.textComponent = tc; } /** * Retrieve the user's text. * @return Plaintext content. */ public String getClientText() { String text = this.textComponent.getText(); // Numeric data should use overwrite mode. this.mergeChanges = !this.isEnum(text); return text; } /** * Set the user's text. * @param text New text */ public void setClientText(String text) { this.textComponent.setText(text); // TODO: Fire synthetic change events. } /** * Modify the user's plaintext by applying a series of patches against it. * @param patches Array of Patch objects. */ public void patchClientText(LinkedList patches) { if (!this.textComponent.isVisible()) { // If the field is not visible, there's no need to preserve the cursor. super.patchClientText(patches); return; } Vector offsets = new Vector(); offsets.add(this.textComponent.getCaretPosition()); this.mobwrite.logger.log(Level.INFO, "Cursor get: " + offsets.firstElement()); offsets.add(this.textComponent.getSelectionStart()); offsets.add(this.textComponent.getSelectionEnd()); this.patch_apply_(patches, offsets); this.mobwrite.logger.log(Level.INFO, "Cursor set: " + offsets.firstElement()); this.textComponent.setCaretPosition(offsets.get(0)); this.textComponent.setSelectionStart(offsets.get(1)); this.textComponent.setSelectionEnd(offsets.get(2)); } /** * Merge a set of patches onto the text. * @param patches Array of patch objects. * @param offsets Offset indices to adjust. */ protected void patch_apply_(LinkedList patches, List offsets) { if (patches.isEmpty()) { return; } int Match_MaxBits = 32; // Deep copy the patches so that no changes are made to originals. patches = dmp.patch_deepCopy(patches); // Lock the user out of the document for a split second while patching. this.textComponent.setEditable(false); try { String text = this.getClientText(); Document doc = this.textComponent.getDocument(); String nullPadding = dmp.patch_addPadding(patches); text = nullPadding + text + nullPadding; dmp.patch_splitMax(patches); int x = 0; // delta keeps track of the offset between the expected and actual location // of the previous patch. If there are patches expected at positions 10 and // 20, but the first patch was found at 12, delta is 2 and the second patch // has an effective expected position of 22. int delta = 0; for (Patch aPatch : patches) { int expected_loc = aPatch.start2 + delta; String text1 = dmp.diff_text1(aPatch.diffs); int start_loc; int end_loc = -1; if (text1.length() > Match_MaxBits) { // patch_splitMax will only provide an oversized pattern in the case of // a monster delete. start_loc = dmp.match_main(text, text1.substring(0, Match_MaxBits), expected_loc); if (start_loc != -1) { end_loc = dmp.match_main(text, text1.substring(text1.length() - Match_MaxBits), expected_loc + text1.length() - Match_MaxBits); if (end_loc == -1 || start_loc >= end_loc) { // Can't find valid trailing context. Drop this patch. start_loc = -1; } } } else { start_loc = dmp.match_main(text, text1, expected_loc); } if (start_loc == -1) { // No match found. :( } else { // Found a match. :) delta = start_loc - expected_loc; String text2; if (end_loc == -1) { text2 = text.substring(start_loc, Math.min(start_loc + text1.length(), text.length())); } else { text2 = text.substring(start_loc, Math.min(end_loc + Match_MaxBits, text.length())); } // Run a diff to get a framework of equivalent indices. LinkedList diffs = dmp.diff_main(text1, text2, false); if (text1.length() > Match_MaxBits && dmp.diff_levenshtein(diffs) / (float) text1.length() > dmp.Patch_DeleteThreshold) { // The end points match, but the content is unacceptably bad. } else { int index1 = 0; for (Diff aDiff : aPatch.diffs) { if (aDiff.operation != Operation.EQUAL) { int index2 = dmp.diff_xIndex(diffs, index1); if (aDiff.operation == Operation.INSERT) { // Insertion text = text.substring(0, start_loc + index2) + aDiff.text + text.substring(start_loc + index2); try { doc.insertString(start_loc + index2 - nullPadding.length(), aDiff.text, null); } catch (BadLocationException e) { e.printStackTrace(); } for (int i = 0; i < offsets.size(); i++) { if (offsets.get(i) + nullPadding.length() > start_loc + index2) { offsets.set(i, offsets.get(i) + aDiff.text.length()); } } } else if (aDiff.operation == Operation.DELETE) { // Deletion int del_start = start_loc + index2; int del_end = start_loc + dmp.diff_xIndex(diffs, index1 + aDiff.text.length()); text = text.substring(0, del_start) + text.substring(del_end); try { doc.remove(del_start - nullPadding.length(), del_end - del_start); } catch (BadLocationException e) { e.printStackTrace(); } for (int i = 0; i < offsets.size(); i++) { if (offsets.get(i) + nullPadding.length() > del_start) { if (offsets.get(i) + nullPadding.length() < del_end) { offsets.set(i, del_start - nullPadding.length()); } else { offsets.set(i, offsets.get(i) - (del_end - del_start)); } } } } } if (aDiff.operation != Operation.DELETE) { index1 += aDiff.text.length(); } } } } x++; } // Strip the padding off. text = text.substring(nullPadding.length(), text.length() - nullPadding.length()); } finally { this.textComponent.setEditable(true); } } // TODO: Fire synthetic change events. } whiteboard/mobwrite/java-client/diff_match_patch.java0000644000175000017500000024545112251036356022345 0ustar ernieernie/* * Diff Match and Patch * * Copyright 2006 Google Inc. * http://code.google.com/p/google-diff-match-patch/ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package name.fraser.neil.plaintext; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.ListIterator; import java.util.Map; import java.util.Set; import java.util.Stack; import java.util.regex.Matcher; import java.util.regex.Pattern; /* * Functions for diff, match and patch. * Computes the difference between two texts to create a patch. * Applies the patch onto another text, allowing for errors. * * @author fraser@google.com (Neil Fraser) */ /** * Class containing the diff, match and patch methods. * Also contains the behaviour settings. */ public class diff_match_patch { // Defaults. // Set these on your diff_match_patch instance to override the defaults. // Number of seconds to map a diff before giving up (0 for infinity). public float Diff_Timeout = 1.0f; // Cost of an empty edit operation in terms of edit characters. public short Diff_EditCost = 4; // The size beyond which the double-ended diff activates. // Double-ending is twice as fast, but less accurate. public short Diff_DualThreshold = 32; // At what point is no match declared (0.0 = perfection, 1.0 = very loose). public float Match_Threshold = 0.5f; // How far to search for a match (0 = exact location, 1000+ = broad match). // A match this many characters away from the expected location will add // 1.0 to the score (0.0 is a perfect match). public int Match_Distance = 1000; // When deleting a large block of text (over ~64 characters), how close does // the contents have to match the expected contents. (0.0 = perfection, // 1.0 = very loose). Note that Match_Threshold controls how closely the // end points of a delete need to match. public float Patch_DeleteThreshold = 0.5f; // Chunk size for context length. public short Patch_Margin = 4; // The number of bits in an int. private int Match_MaxBits = 32; /** * Internal class for returning results from diff_linesToChars(). * Other less paranoid languages just use a three-element array. */ protected static class LinesToCharsResult { protected String chars1; protected String chars2; protected List lineArray; protected LinesToCharsResult(String chars1, String chars2, List lineArray) { this.chars1 = chars1; this.chars2 = chars2; this.lineArray = lineArray; } } // DIFF FUNCTIONS /**- * The data structure representing a diff is a Linked list of Diff objects: * {Diff(Operation.DELETE, "Hello"), Diff(Operation.INSERT, "Goodbye"), * Diff(Operation.EQUAL, " world.")} * which means: delete "Hello", add "Goodbye" and keep " world." */ public enum Operation { DELETE, INSERT, EQUAL } /** * Find the differences between two texts. * Run a faster slightly less optimal diff * This method allows the 'checklines' of diff_main() to be optional. * Most of the time checklines is wanted, so default to true. * @param text1 Old string to be diffed. * @param text2 New string to be diffed. * @return Linked List of Diff objects. */ public LinkedList diff_main(String text1, String text2) { return diff_main(text1, text2, true); } /** * Find the differences between two texts. Simplifies the problem by * stripping any common prefix or suffix off the texts before diffing. * @param text1 Old string to be diffed. * @param text2 New string to be diffed. * @param checklines Speedup flag. If false, then don't run a * line-level diff first to identify the changed areas. * If true, then run a faster slightly less optimal diff * @return Linked List of Diff objects. */ public LinkedList diff_main(String text1, String text2, boolean checklines) { // Check for equality (speedup) LinkedList diffs; if (text1.equals(text2)) { diffs = new LinkedList(); diffs.add(new Diff(Operation.EQUAL, text1)); return diffs; } // Trim off common prefix (speedup) int commonlength = diff_commonPrefix(text1, text2); String commonprefix = text1.substring(0, commonlength); text1 = text1.substring(commonlength); text2 = text2.substring(commonlength); // Trim off common suffix (speedup) commonlength = diff_commonSuffix(text1, text2); String commonsuffix = text1.substring(text1.length() - commonlength); text1 = text1.substring(0, text1.length() - commonlength); text2 = text2.substring(0, text2.length() - commonlength); // Compute the diff on the middle block diffs = diff_compute(text1, text2, checklines); // Restore the prefix and suffix if (!commonprefix.isEmpty()) { diffs.addFirst(new Diff(Operation.EQUAL, commonprefix)); } if (!commonsuffix.isEmpty()) { diffs.addLast(new Diff(Operation.EQUAL, commonsuffix)); } diff_cleanupMerge(diffs); return diffs; } /** * Find the differences between two texts. Assumes that the texts do not * have any common prefix or suffix. * @param text1 Old string to be diffed. * @param text2 New string to be diffed. * @param checklines Speedup flag. If false, then don't run a * line-level diff first to identify the changed areas. * If true, then run a faster slightly less optimal diff * @return Linked List of Diff objects. */ protected LinkedList diff_compute(String text1, String text2, boolean checklines) { LinkedList diffs = new LinkedList(); if (text1.isEmpty()) { // Just add some text (speedup) diffs.add(new Diff(Operation.INSERT, text2)); return diffs; } if (text2.isEmpty()) { // Just delete some text (speedup) diffs.add(new Diff(Operation.DELETE, text1)); return diffs; } String longtext = text1.length() > text2.length() ? text1 : text2; String shorttext = text1.length() > text2.length() ? text2 : text1; int i = longtext.indexOf(shorttext); if (i != -1) { // Shorter text is inside the longer text (speedup) Operation op = (text1.length() > text2.length()) ? Operation.DELETE : Operation.INSERT; diffs.add(new Diff(op, longtext.substring(0, i))); diffs.add(new Diff(Operation.EQUAL, shorttext)); diffs.add(new Diff(op, longtext.substring(i + shorttext.length()))); return diffs; } longtext = shorttext = null; // Garbage collect // Check to see if the problem can be split in two. String[] hm = diff_halfMatch(text1, text2); if (hm != null) { // A half-match was found, sort out the return data. String text1_a = hm[0]; String text1_b = hm[1]; String text2_a = hm[2]; String text2_b = hm[3]; String mid_common = hm[4]; // Send both pairs off for separate processing. LinkedList diffs_a = diff_main(text1_a, text2_a, checklines); LinkedList diffs_b = diff_main(text1_b, text2_b, checklines); // Merge the results. diffs = diffs_a; diffs.add(new Diff(Operation.EQUAL, mid_common)); diffs.addAll(diffs_b); return diffs; } // Perform a real diff. if (checklines && (text1.length() < 100 || text2.length() < 100)) { checklines = false; // Too trivial for the overhead. } List linearray = null; if (checklines) { // Scan the text on a line-by-line basis first. LinesToCharsResult b = diff_linesToChars(text1, text2); text1 = b.chars1; text2 = b.chars2; linearray = b.lineArray; } diffs = diff_map(text1, text2); if (diffs == null) { // No acceptable result. diffs = new LinkedList(); diffs.add(new Diff(Operation.DELETE, text1)); diffs.add(new Diff(Operation.INSERT, text2)); } if (checklines) { // Convert the diff back to original text. diff_charsToLines(diffs, linearray); // Eliminate freak matches (e.g. blank lines) diff_cleanupSemantic(diffs); // Rediff any replacement blocks, this time character-by-character. // Add a dummy entry at the end. diffs.add(new Diff(Operation.EQUAL, "")); int count_delete = 0; int count_insert = 0; String text_delete = ""; String text_insert = ""; ListIterator pointer = diffs.listIterator(); Diff thisDiff = pointer.next(); while (thisDiff != null) { switch (thisDiff.operation) { case INSERT: count_insert++; text_insert += thisDiff.text; break; case DELETE: count_delete++; text_delete += thisDiff.text; break; case EQUAL: // Upon reaching an equality, check for prior redundancies. if (count_delete >= 1 && count_insert >= 1) { // Delete the offending records and add the merged ones. pointer.previous(); for (int j = 0; j < count_delete + count_insert; j++) { pointer.previous(); pointer.remove(); } for (Diff newDiff : diff_main(text_delete, text_insert, false)) { pointer.add(newDiff); } } count_insert = 0; count_delete = 0; text_delete = ""; text_insert = ""; break; } thisDiff = pointer.hasNext() ? pointer.next() : null; } diffs.removeLast(); // Remove the dummy entry at the end. } return diffs; } /** * Split two texts into a list of strings. Reduce the texts to a string of * hashes where each Unicode character represents one line. * @param text1 First string. * @param text2 Second string. * @return An object containing the encoded text1, the encoded text2 and * the List of unique strings. The zeroth element of the List of * unique strings is intentionally blank. */ protected LinesToCharsResult diff_linesToChars(String text1, String text2) { List lineArray = new ArrayList(); Map lineHash = new HashMap(); // e.g. linearray[4] == "Hello\n" // e.g. linehash.get("Hello\n") == 4 // "\x00" is a valid character, but various debuggers don't like it. // So we'll insert a junk entry to avoid generating a null character. lineArray.add(""); String chars1 = diff_linesToCharsMunge(text1, lineArray, lineHash); String chars2 = diff_linesToCharsMunge(text2, lineArray, lineHash); return new LinesToCharsResult(chars1, chars2, lineArray); } /** * Split a text into a list of strings. Reduce the texts to a string of * hashes where each Unicode character represents one line. * @param text String to encode. * @param lineArray List of unique strings. * @param lineHash Map of strings to indices. * @return Encoded string. */ private String diff_linesToCharsMunge(String text, List lineArray, Map lineHash) { int lineStart = 0; int lineEnd = -1; String line; StringBuilder chars = new StringBuilder(); // Walk the text, pulling out a substring for each line. // text.split('\n') would would temporarily double our memory footprint. // Modifying text would create many large strings to garbage collect. while (lineEnd < text.length() - 1) { lineEnd = text.indexOf('\n', lineStart); if (lineEnd == -1) { lineEnd = text.length() - 1; } line = text.substring(lineStart, lineEnd + 1); lineStart = lineEnd + 1; if (lineHash.containsKey(line)) { chars.append(String.valueOf((char) (int) lineHash.get(line))); } else { lineArray.add(line); lineHash.put(line, lineArray.size() - 1); chars.append(String.valueOf((char) (lineArray.size() - 1))); } } return chars.toString(); } /** * Rehydrate the text in a diff from a string of line hashes to real lines of * text. * @param diffs LinkedList of Diff objects. * @param lineArray List of unique strings. */ protected void diff_charsToLines(LinkedList diffs, List lineArray) { StringBuilder text; for (Diff diff : diffs) { text = new StringBuilder(); for (int y = 0; y < diff.text.length(); y++) { text.append(lineArray.get(diff.text.charAt(y))); } diff.text = text.toString(); } } /** * Explore the intersection points between the two texts. * @param text1 Old string to be diffed. * @param text2 New string to be diffed. * @return LinkedList of Diff objects or null if no diff available. */ protected LinkedList diff_map(String text1, String text2) { long ms_end = System.currentTimeMillis() + (long) (Diff_Timeout * 1000); // Cache the text lengths to prevent multiple calls. int text1_length = text1.length(); int text2_length = text2.length(); int max_d = text1_length + text2_length - 1; boolean doubleEnd = Diff_DualThreshold * 2 < max_d; List> v_map1 = new ArrayList>(); List> v_map2 = new ArrayList>(); Map v1 = new HashMap(); Map v2 = new HashMap(); v1.put(1, 0); v2.put(1, 0); int x, y; Long footstep = 0L; // Used to track overlapping paths. Map footsteps = new HashMap(); boolean done = false; // If the total number of characters is odd, then the front path will // collide with the reverse path. boolean front = ((text1_length + text2_length) % 2 == 1); for (int d = 0; d < max_d; d++) { // Bail out if timeout reached. if (Diff_Timeout > 0 && System.currentTimeMillis() > ms_end) { return null; } // Walk the front path one step. v_map1.add(new HashSet()); // Adds at index 'd'. for (int k = -d; k <= d; k += 2) { if (k == -d || k != d && v1.get(k - 1) < v1.get(k + 1)) { x = v1.get(k + 1); } else { x = v1.get(k - 1) + 1; } y = x - k; if (doubleEnd) { footstep = diff_footprint(x, y); if (front && (footsteps.containsKey(footstep))) { done = true; } if (!front) { footsteps.put(footstep, d); } } while (!done && x < text1_length && y < text2_length && text1.charAt(x) == text2.charAt(y)) { x++; y++; if (doubleEnd) { footstep = diff_footprint(x, y); if (front && (footsteps.containsKey(footstep))) { done = true; } if (!front) { footsteps.put(footstep, d); } } } v1.put(k, x); v_map1.get(d).add(diff_footprint(x, y)); if (x == text1_length && y == text2_length) { // Reached the end in single-path mode. return diff_path1(v_map1, text1, text2); } else if (done) { // Front path ran over reverse path. v_map2 = v_map2.subList(0, footsteps.get(footstep) + 1); LinkedList a = diff_path1(v_map1, text1.substring(0, x), text2.substring(0, y)); a.addAll(diff_path2(v_map2, text1.substring(x), text2.substring(y))); return a; } } if (doubleEnd) { // Walk the reverse path one step. v_map2.add(new HashSet()); // Adds at index 'd'. for (int k = -d; k <= d; k += 2) { if (k == -d || k != d && v2.get(k - 1) < v2.get(k + 1)) { x = v2.get(k + 1); } else { x = v2.get(k - 1) + 1; } y = x - k; footstep = diff_footprint(text1_length - x, text2_length - y); if (!front && (footsteps.containsKey(footstep))) { done = true; } if (front) { footsteps.put(footstep, d); } while (!done && x < text1_length && y < text2_length && text1.charAt(text1_length - x - 1) == text2.charAt(text2_length - y - 1)) { x++; y++; footstep = diff_footprint(text1_length - x, text2_length - y); if (!front && (footsteps.containsKey(footstep))) { done = true; } if (front) { footsteps.put(footstep, d); } } v2.put(k, x); v_map2.get(d).add(diff_footprint(x, y)); if (done) { // Reverse path ran over front path. v_map1 = v_map1.subList(0, footsteps.get(footstep) + 1); LinkedList a = diff_path1(v_map1, text1.substring(0, text1_length - x), text2.substring(0, text2_length - y)); a.addAll(diff_path2(v_map2, text1.substring(text1_length - x), text2.substring(text2_length - y))); return a; } } } } // Number of diffs equals number of characters, no commonality at all. return null; } /** * Work from the middle back to the start to determine the path. * @param v_map List of path sets. * @param text1 Old string fragment to be diffed. * @param text2 New string fragment to be diffed. * @return LinkedList of Diff objects. */ protected LinkedList diff_path1(List> v_map, String text1, String text2) { LinkedList path = new LinkedList(); int x = text1.length(); int y = text2.length(); Operation last_op = null; for (int d = v_map.size() - 2; d >= 0; d--) { while (true) { if (v_map.get(d).contains(diff_footprint(x - 1, y))) { x--; if (last_op == Operation.DELETE) { path.getFirst().text = text1.charAt(x) + path.getFirst().text; } else { path.addFirst(new Diff(Operation.DELETE, text1.substring(x, x + 1))); } last_op = Operation.DELETE; break; } else if (v_map.get(d).contains(diff_footprint(x, y - 1))) { y--; if (last_op == Operation.INSERT) { path.getFirst().text = text2.charAt(y) + path.getFirst().text; } else { path.addFirst(new Diff(Operation.INSERT, text2.substring(y, y + 1))); } last_op = Operation.INSERT; break; } else { x--; y--; assert (text1.charAt(x) == text2.charAt(y)) : "No diagonal. Can't happen. (diff_path1)"; if (last_op == Operation.EQUAL) { path.getFirst().text = text1.charAt(x) + path.getFirst().text; } else { path.addFirst(new Diff(Operation.EQUAL, text1.substring(x, x + 1))); } last_op = Operation.EQUAL; } } } return path; } /** * Work from the middle back to the end to determine the path. * @param v_map List of path sets. * @param text1 Old string fragment to be diffed. * @param text2 New string fragment to be diffed. * @return LinkedList of Diff objects. */ protected LinkedList diff_path2(List> v_map, String text1, String text2) { LinkedList path = new LinkedList(); int x = text1.length(); int y = text2.length(); Operation last_op = null; for (int d = v_map.size() - 2; d >= 0; d--) { while (true) { if (v_map.get(d).contains(diff_footprint(x - 1, y))) { x--; if (last_op == Operation.DELETE) { path.getLast().text += text1.charAt(text1.length() - x - 1); } else { path.addLast(new Diff(Operation.DELETE, text1.substring(text1.length() - x - 1, text1.length() - x))); } last_op = Operation.DELETE; break; } else if (v_map.get(d).contains(diff_footprint(x, y - 1))) { y--; if (last_op == Operation.INSERT) { path.getLast().text += text2.charAt(text2.length() - y - 1); } else { path.addLast(new Diff(Operation.INSERT, text2.substring(text2.length() - y - 1, text2.length() - y))); } last_op = Operation.INSERT; break; } else { x--; y--; assert (text1.charAt(text1.length() - x - 1) == text2.charAt(text2.length() - y - 1)) : "No diagonal. Can't happen. (diff_path2)"; if (last_op == Operation.EQUAL) { path.getLast().text += text1.charAt(text1.length() - x - 1); } else { path.addLast(new Diff(Operation.EQUAL, text1.substring(text1.length() - x - 1, text1.length() - x))); } last_op = Operation.EQUAL; } } } return path; } /** * Compute a good hash of two integers. * @param x First int. * @param y Second int. * @return A long made up of both ints. */ protected Long diff_footprint(int x, int y) { // The maximum size for a long is 9,223,372,036,854,775,807 // The maximum size for an int is 2,147,483,647 // Two ints fit nicely in one long. // The return value is usually destined as a key in a hash, so return an // object rather than a primitive, thus skipping an automatic boxing. long result = x; result = result << 32; result += y; return result; } /** * Determine the common prefix of two strings * @param text1 First string. * @param text2 Second string. * @return The number of characters common to the start of each string. */ public int diff_commonPrefix(String text1, String text2) { // Performance analysis: http://neil.fraser.name/news/2007/10/09/ int n = Math.min(text1.length(), text2.length()); for (int i = 0; i < n; i++) { if (text1.charAt(i) != text2.charAt(i)) { return i; } } return n; } /** * Determine the common suffix of two strings * @param text1 First string. * @param text2 Second string. * @return The number of characters common to the end of each string. */ public int diff_commonSuffix(String text1, String text2) { // Performance analysis: http://neil.fraser.name/news/2007/10/09/ int text1_length = text1.length(); int text2_length = text2.length(); int n = Math.min(text1_length, text2_length); for (int i = 1; i <= n; i++) { if (text1.charAt(text1_length - i) != text2.charAt(text2_length - i)) { return i - 1; } } return n; } /** * Do the two texts share a substring which is at least half the length of * the longer text? * @param text1 First string. * @param text2 Second string. * @return Five element String array, containing the prefix of text1, the * suffix of text1, the prefix of text2, the suffix of text2 and the * common middle. Or null if there was no match. */ protected String[] diff_halfMatch(String text1, String text2) { String longtext = text1.length() > text2.length() ? text1 : text2; String shorttext = text1.length() > text2.length() ? text2 : text1; if (longtext.length() < 10 || shorttext.length() < 1) { return null; // Pointless. } // First check if the second quarter is the seed for a half-match. String[] hm1 = diff_halfMatchI(longtext, shorttext, (longtext.length() + 3) / 4); // Check again based on the third quarter. String[] hm2 = diff_halfMatchI(longtext, shorttext, (longtext.length() + 1) / 2); String[] hm; if (hm1 == null && hm2 == null) { return null; } else if (hm2 == null) { hm = hm1; } else if (hm1 == null) { hm = hm2; } else { // Both matched. Select the longest. hm = hm1[4].length() > hm2[4].length() ? hm1 : hm2; } // A half-match was found, sort out the return data. if (text1.length() > text2.length()) { return hm; //return new String[]{hm[0], hm[1], hm[2], hm[3], hm[4]}; } else { return new String[]{hm[2], hm[3], hm[0], hm[1], hm[4]}; } } /** * Does a substring of shorttext exist within longtext such that the * substring is at least half the length of longtext? * @param longtext Longer string. * @param shorttext Shorter string. * @param i Start index of quarter length substring within longtext. * @return Five element String array, containing the prefix of longtext, the * suffix of longtext, the prefix of shorttext, the suffix of shorttext * and the common middle. Or null if there was no match. */ private String[] diff_halfMatchI(String longtext, String shorttext, int i) { // Start with a 1/4 length substring at position i as a seed. String seed = longtext.substring(i, i + longtext.length() / 4); int j = -1; String best_common = ""; String best_longtext_a = "", best_longtext_b = ""; String best_shorttext_a = "", best_shorttext_b = ""; while ((j = shorttext.indexOf(seed, j + 1)) != -1) { int prefixLength = diff_commonPrefix(longtext.substring(i), shorttext.substring(j)); int suffixLength = diff_commonSuffix(longtext.substring(0, i), shorttext.substring(0, j)); if (best_common.length() < suffixLength + prefixLength) { best_common = shorttext.substring(j - suffixLength, j) + shorttext.substring(j, j + prefixLength); best_longtext_a = longtext.substring(0, i - suffixLength); best_longtext_b = longtext.substring(i + prefixLength); best_shorttext_a = shorttext.substring(0, j - suffixLength); best_shorttext_b = shorttext.substring(j + prefixLength); } } if (best_common.length() >= longtext.length() / 2) { return new String[]{best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common}; } else { return null; } } /** * Reduce the number of edits by eliminating semantically trivial equalities. * @param diffs LinkedList of Diff objects. */ public void diff_cleanupSemantic(LinkedList diffs) { if (diffs.isEmpty()) { return; } boolean changes = false; Stack equalities = new Stack(); // Stack of qualities. String lastequality = null; // Always equal to equalities.lastElement().text ListIterator pointer = diffs.listIterator(); // Number of characters that changed prior to the equality. int length_changes1 = 0; // Number of characters that changed after the equality. int length_changes2 = 0; Diff thisDiff = pointer.next(); while (thisDiff != null) { if (thisDiff.operation == Operation.EQUAL) { // equality found equalities.push(thisDiff); length_changes1 = length_changes2; length_changes2 = 0; lastequality = thisDiff.text; } else { // an insertion or deletion length_changes2 += thisDiff.text.length(); if (lastequality != null && (lastequality.length() <= length_changes1) && (lastequality.length() <= length_changes2)) { //System.out.println("Splitting: '" + lastequality + "'"); // Walk back to offending equality. while (thisDiff != equalities.lastElement()) { thisDiff = pointer.previous(); } pointer.next(); // Replace equality with a delete. pointer.set(new Diff(Operation.DELETE, lastequality)); // Insert a corresponding an insert. pointer.add(new Diff(Operation.INSERT, lastequality)); equalities.pop(); // Throw away the equality we just deleted. if (!equalities.empty()) { // Throw away the previous equality (it needs to be reevaluated). equalities.pop(); } if (equalities.empty()) { // There are no previous equalities, walk back to the start. while (pointer.hasPrevious()) { pointer.previous(); } } else { // There is a safe equality we can fall back to. thisDiff = equalities.lastElement(); while (thisDiff != pointer.previous()) { // Intentionally empty loop. } } length_changes1 = 0; // Reset the counters. length_changes2 = 0; lastequality = null; changes = true; } } thisDiff = pointer.hasNext() ? pointer.next() : null; } if (changes) { diff_cleanupMerge(diffs); } diff_cleanupSemanticLossless(diffs); } /** * Look for single edits surrounded on both sides by equalities * which can be shifted sideways to align the edit to a word boundary. * e.g: The cat came. -> The cat came. * @param diffs LinkedList of Diff objects. */ public void diff_cleanupSemanticLossless(LinkedList diffs) { String equality1, edit, equality2; String commonString; int commonOffset; int score, bestScore; String bestEquality1, bestEdit, bestEquality2; // Create a new iterator at the start. ListIterator pointer = diffs.listIterator(); Diff prevDiff = pointer.hasNext() ? pointer.next() : null; Diff thisDiff = pointer.hasNext() ? pointer.next() : null; Diff nextDiff = pointer.hasNext() ? pointer.next() : null; // Intentionally ignore the first and last element (don't need checking). while (nextDiff != null) { if (prevDiff.operation == Operation.EQUAL && nextDiff.operation == Operation.EQUAL) { // This is a single edit surrounded by equalities. equality1 = prevDiff.text; edit = thisDiff.text; equality2 = nextDiff.text; // First, shift the edit as far left as possible. commonOffset = diff_commonSuffix(equality1, edit); if (commonOffset != 0) { commonString = edit.substring(edit.length() - commonOffset); equality1 = equality1.substring(0, equality1.length() - commonOffset); edit = commonString + edit.substring(0, edit.length() - commonOffset); equality2 = commonString + equality2; } // Second, step character by character right, looking for the best fit. bestEquality1 = equality1; bestEdit = edit; bestEquality2 = equality2; bestScore = diff_cleanupSemanticScore(equality1, edit) + diff_cleanupSemanticScore(edit, equality2); while (!edit.isEmpty() && !equality2.isEmpty() && edit.charAt(0) == equality2.charAt(0)) { equality1 += edit.charAt(0); edit = edit.substring(1) + equality2.charAt(0); equality2 = equality2.substring(1); score = diff_cleanupSemanticScore(equality1, edit) + diff_cleanupSemanticScore(edit, equality2); // The >= encourages trailing rather than leading whitespace on edits. if (score >= bestScore) { bestScore = score; bestEquality1 = equality1; bestEdit = edit; bestEquality2 = equality2; } } if (!prevDiff.text.equals(bestEquality1)) { // We have an improvement, save it back to the diff. if (!bestEquality1.isEmpty()) { prevDiff.text = bestEquality1; } else { pointer.previous(); // Walk past nextDiff. pointer.previous(); // Walk past thisDiff. pointer.previous(); // Walk past prevDiff. pointer.remove(); // Delete prevDiff. pointer.next(); // Walk past thisDiff. pointer.next(); // Walk past nextDiff. } thisDiff.text = bestEdit; if (!bestEquality2.isEmpty()) { nextDiff.text = bestEquality2; } else { pointer.remove(); // Delete nextDiff. nextDiff = thisDiff; thisDiff = prevDiff; } } } prevDiff = thisDiff; thisDiff = nextDiff; nextDiff = pointer.hasNext() ? pointer.next() : null; } } /** * Given two strings, compute a score representing whether the internal * boundary falls on logical boundaries. * Scores range from 5 (best) to 0 (worst). * @param one First string. * @param two Second string. * @return The score. */ private int diff_cleanupSemanticScore(String one, String two) { if (one.isEmpty() || two.isEmpty()) { // Edges are the best. return 5; } // Each port of this function behaves slightly differently due to // subtle differences in each language's definition of things like // 'whitespace'. Since this function's purpose is largely cosmetic, // the choice has been made to use each language's native features // rather than force total conformity. int score = 0; // One point for non-alphanumeric. if (!Character.isLetterOrDigit(one.charAt(one.length() - 1)) || !Character.isLetterOrDigit(two.charAt(0))) { score++; // Two points for whitespace. if (Character.isWhitespace(one.charAt(one.length() - 1)) || Character.isWhitespace(two.charAt(0))) { score++; // Three points for line breaks. if (Character.getType(one.charAt(one.length() - 1)) == Character.CONTROL || Character.getType(two.charAt(0)) == Character.CONTROL) { score++; // Four points for blank lines. if (BLANKLINEEND.matcher(one).find() || BLANKLINESTART.matcher(two).find()) { score++; } } } } return score; } private Pattern BLANKLINEEND = Pattern.compile("\\n\\r?\\n\\Z", Pattern.DOTALL); private Pattern BLANKLINESTART = Pattern.compile("\\A\\r?\\n\\r?\\n", Pattern.DOTALL); /** * Reduce the number of edits by eliminating operationally trivial equalities. * @param diffs LinkedList of Diff objects. */ public void diff_cleanupEfficiency(LinkedList diffs) { if (diffs.isEmpty()) { return; } boolean changes = false; Stack equalities = new Stack(); // Stack of equalities. String lastequality = null; // Always equal to equalities.lastElement().text ListIterator pointer = diffs.listIterator(); // Is there an insertion operation before the last equality. boolean pre_ins = false; // Is there a deletion operation before the last equality. boolean pre_del = false; // Is there an insertion operation after the last equality. boolean post_ins = false; // Is there a deletion operation after the last equality. boolean post_del = false; Diff thisDiff = pointer.next(); Diff safeDiff = thisDiff; // The last Diff that is known to be unsplitable. while (thisDiff != null) { if (thisDiff.operation == Operation.EQUAL) { // equality found if (thisDiff.text.length() < Diff_EditCost && (post_ins || post_del)) { // Candidate found. equalities.push(thisDiff); pre_ins = post_ins; pre_del = post_del; lastequality = thisDiff.text; } else { // Not a candidate, and can never become one. equalities.clear(); lastequality = null; safeDiff = thisDiff; } post_ins = post_del = false; } else { // an insertion or deletion if (thisDiff.operation == Operation.DELETE) { post_del = true; } else { post_ins = true; } /* * Five types to be split: * ABXYCD * AXCD * ABXC * AXCD * ABXC */ if (lastequality != null && ((pre_ins && pre_del && post_ins && post_del) || ((lastequality.length() < Diff_EditCost / 2) && ((pre_ins ? 1 : 0) + (pre_del ? 1 : 0) + (post_ins ? 1 : 0) + (post_del ? 1 : 0)) == 3))) { //System.out.println("Splitting: '" + lastequality + "'"); // Walk back to offending equality. while (thisDiff != equalities.lastElement()) { thisDiff = pointer.previous(); } pointer.next(); // Replace equality with a delete. pointer.set(new Diff(Operation.DELETE, lastequality)); // Insert a corresponding an insert. pointer.add(thisDiff = new Diff(Operation.INSERT, lastequality)); equalities.pop(); // Throw away the equality we just deleted. lastequality = null; if (pre_ins && pre_del) { // No changes made which could affect previous entry, keep going. post_ins = post_del = true; equalities.clear(); safeDiff = thisDiff; } else { if (!equalities.empty()) { // Throw away the previous equality (it needs to be reevaluated). equalities.pop(); } if (equalities.empty()) { // There are no previous questionable equalities, // walk back to the last known safe diff. thisDiff = safeDiff; } else { // There is an equality we can fall back to. thisDiff = equalities.lastElement(); } while (thisDiff != pointer.previous()) { // Intentionally empty loop. } post_ins = post_del = false; } changes = true; } } thisDiff = pointer.hasNext() ? pointer.next() : null; } if (changes) { diff_cleanupMerge(diffs); } } /** * Reorder and merge like edit sections. Merge equalities. * Any edit section can move as long as it doesn't cross an equality. * @param diffs LinkedList of Diff objects. */ public void diff_cleanupMerge(LinkedList diffs) { diffs.add(new Diff(Operation.EQUAL, "")); // Add a dummy entry at the end. ListIterator pointer = diffs.listIterator(); int count_delete = 0; int count_insert = 0; String text_delete = ""; String text_insert = ""; Diff thisDiff = pointer.next(); Diff prevEqual = null; int commonlength; while (thisDiff != null) { switch (thisDiff.operation) { case INSERT: count_insert++; text_insert += thisDiff.text; prevEqual = null; break; case DELETE: count_delete++; text_delete += thisDiff.text; prevEqual = null; break; case EQUAL: if (count_delete != 0 || count_insert != 0) { // Delete the offending records. pointer.previous(); // Reverse direction. while (count_delete-- > 0) { pointer.previous(); pointer.remove(); } while (count_insert-- > 0) { pointer.previous(); pointer.remove(); } if (count_delete != 0 && count_insert != 0) { // Factor out any common prefixies. commonlength = diff_commonPrefix(text_insert, text_delete); if (commonlength != 0) { if (pointer.hasPrevious()) { thisDiff = pointer.previous(); assert thisDiff.operation == Operation.EQUAL : "Previous diff should have been an equality."; thisDiff.text += text_insert.substring(0, commonlength); pointer.next(); } else { pointer.add(new Diff(Operation.EQUAL, text_insert.substring(0, commonlength))); } text_insert = text_insert.substring(commonlength); text_delete = text_delete.substring(commonlength); } // Factor out any common suffixies. commonlength = diff_commonSuffix(text_insert, text_delete); if (commonlength != 0) { thisDiff = pointer.next(); thisDiff.text = text_insert.substring(text_insert.length() - commonlength) + thisDiff.text; text_insert = text_insert.substring(0, text_insert.length() - commonlength); text_delete = text_delete.substring(0, text_delete.length() - commonlength); pointer.previous(); } } // Insert the merged records. if (!text_delete.isEmpty()) { pointer.add(new Diff(Operation.DELETE, text_delete)); } if (!text_insert.isEmpty()) { pointer.add(new Diff(Operation.INSERT, text_insert)); } // Step forward to the equality. thisDiff = pointer.hasNext() ? pointer.next() : null; } else if (prevEqual != null) { // Merge this equality with the previous one. prevEqual.text += thisDiff.text; pointer.remove(); thisDiff = pointer.previous(); pointer.next(); // Forward direction } count_insert = 0; count_delete = 0; text_delete = ""; text_insert = ""; prevEqual = thisDiff; break; } thisDiff = pointer.hasNext() ? pointer.next() : null; } // System.out.println(diff); if (diffs.getLast().text.isEmpty()) { diffs.removeLast(); // Remove the dummy entry at the end. } /* * Second pass: look for single edits surrounded on both sides by equalities * which can be shifted sideways to eliminate an equality. * e.g: ABAC -> ABAC */ boolean changes = false; // Create a new iterator at the start. // (As opposed to walking the current one back.) pointer = diffs.listIterator(); Diff prevDiff = pointer.hasNext() ? pointer.next() : null; thisDiff = pointer.hasNext() ? pointer.next() : null; Diff nextDiff = pointer.hasNext() ? pointer.next() : null; // Intentionally ignore the first and last element (don't need checking). while (nextDiff != null) { if (prevDiff.operation == Operation.EQUAL && nextDiff.operation == Operation.EQUAL) { // This is a single edit surrounded by equalities. if (thisDiff.text.endsWith(prevDiff.text)) { // Shift the edit over the previous equality. thisDiff.text = prevDiff.text + thisDiff.text.substring(0, thisDiff.text.length() - prevDiff.text.length()); nextDiff.text = prevDiff.text + nextDiff.text; pointer.previous(); // Walk past nextDiff. pointer.previous(); // Walk past thisDiff. pointer.previous(); // Walk past prevDiff. pointer.remove(); // Delete prevDiff. pointer.next(); // Walk past thisDiff. thisDiff = pointer.next(); // Walk past nextDiff. nextDiff = pointer.hasNext() ? pointer.next() : null; changes = true; } else if (thisDiff.text.startsWith(nextDiff.text)) { // Shift the edit over the next equality. prevDiff.text += nextDiff.text; thisDiff.text = thisDiff.text.substring(nextDiff.text.length()) + nextDiff.text; pointer.remove(); // Delete nextDiff. nextDiff = pointer.hasNext() ? pointer.next() : null; changes = true; } } prevDiff = thisDiff; thisDiff = nextDiff; nextDiff = pointer.hasNext() ? pointer.next() : null; } // If shifts were made, the diff needs reordering and another shift sweep. if (changes) { diff_cleanupMerge(diffs); } } /** * loc is a location in text1, compute and return the equivalent location in * text2. * e.g. "The cat" vs "The big cat", 1->1, 5->8 * @param diffs LinkedList of Diff objects. * @param loc Location within text1. * @return Location within text2. */ public int diff_xIndex(LinkedList diffs, int loc) { int chars1 = 0; int chars2 = 0; int last_chars1 = 0; int last_chars2 = 0; Diff lastDiff = null; for (Diff aDiff : diffs) { if (aDiff.operation != Operation.INSERT) { // Equality or deletion. chars1 += aDiff.text.length(); } if (aDiff.operation != Operation.DELETE) { // Equality or insertion. chars2 += aDiff.text.length(); } if (chars1 > loc) { // Overshot the location. lastDiff = aDiff; break; } last_chars1 = chars1; last_chars2 = chars2; } if (lastDiff != null && lastDiff.operation == Operation.DELETE) { // The location was deleted. return last_chars2; } // Add the remaining character length. return last_chars2 + (loc - last_chars1); } /** * Convert a Diff list into a pretty HTML report. * @param diffs LinkedList of Diff objects. * @return HTML representation. */ public String diff_prettyHtml(LinkedList diffs) { StringBuilder html = new StringBuilder(); int i = 0; for (Diff aDiff : diffs) { String text = aDiff.text.replace("&", "&").replace("<", "<") .replace(">", ">").replace("\n", "¶
"); switch (aDiff.operation) { case INSERT: html.append("").append(text).append(""); break; case DELETE: html.append("").append(text).append(""); break; case EQUAL: html.append("").append(text) .append(""); break; } if (aDiff.operation != Operation.DELETE) { i += aDiff.text.length(); } } return html.toString(); } /** * Compute and return the source text (all equalities and deletions). * @param diffs LinkedList of Diff objects. * @return Source text. */ public String diff_text1(LinkedList diffs) { StringBuilder text = new StringBuilder(); for (Diff aDiff : diffs) { if (aDiff.operation != Operation.INSERT) { text.append(aDiff.text); } } return text.toString(); } /** * Compute and return the destination text (all equalities and insertions). * @param diffs LinkedList of Diff objects. * @return Destination text. */ public String diff_text2(LinkedList diffs) { StringBuilder text = new StringBuilder(); for (Diff aDiff : diffs) { if (aDiff.operation != Operation.DELETE) { text.append(aDiff.text); } } return text.toString(); } /** * Compute the Levenshtein distance; the number of inserted, deleted or * substituted characters. * @param diffs LinkedList of Diff objects. * @return Number of changes. */ public int diff_levenshtein(LinkedList diffs) { int levenshtein = 0; int insertions = 0; int deletions = 0; for (Diff aDiff : diffs) { switch (aDiff.operation) { case INSERT: insertions += aDiff.text.length(); break; case DELETE: deletions += aDiff.text.length(); break; case EQUAL: // A deletion and an insertion is one substitution. levenshtein += Math.max(insertions, deletions); insertions = 0; deletions = 0; break; } } levenshtein += Math.max(insertions, deletions); return levenshtein; } /** * Crush the diff into an encoded string which describes the operations * required to transform text1 into text2. * E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. * Operations are tab-separated. Inserted text is escaped using %xx notation. * @param diffs Array of diff tuples. * @return Delta text. */ public String diff_toDelta(LinkedList diffs) { StringBuilder text = new StringBuilder(); for (Diff aDiff : diffs) { switch (aDiff.operation) { case INSERT: try { text.append("+").append(URLEncoder.encode(aDiff.text, "UTF-8") .replace('+', ' ')).append("\t"); } catch (UnsupportedEncodingException e) { // Not likely on modern system. throw new Error("This system does not support UTF-8.", e); } break; case DELETE: text.append("-").append(aDiff.text.length()).append("\t"); break; case EQUAL: text.append("=").append(aDiff.text.length()).append("\t"); break; } } String delta = text.toString(); if (!delta.isEmpty()) { // Strip off trailing tab character. delta = delta.substring(0, delta.length() - 1); delta = unescapeForEncodeUriCompatability(delta); } return delta; } /** * Given the original text1, and an encoded string which describes the * operations required to transform text1 into text2, compute the full diff. * @param text1 Source string for the diff. * @param delta Delta text. * @return Array of diff tuples or null if invalid. * @throw IllegalArgumentException If invalid input. */ public LinkedList diff_fromDelta(String text1, String delta) throws IllegalArgumentException { LinkedList diffs = new LinkedList(); int pointer = 0; // Cursor in text1 String[] tokens = delta.split("\t"); for (String token : tokens) { if (token.isEmpty()) { // Blank tokens are ok (from a trailing \t). continue; } // Each token begins with a one character parameter which specifies the // operation of this token (delete, insert, equality). String param = token.substring(1); switch (token.charAt(0)) { case '+': // decode would change all "+" to " " param = param.replace("+", "%2B"); try { param = URLDecoder.decode(param, "UTF-8"); } catch (UnsupportedEncodingException e) { // Not likely on modern system. throw new Error("This system does not support UTF-8.", e); } catch (IllegalArgumentException e) { // Malformed URI sequence. throw new IllegalArgumentException( "Illegal escape in diff_fromDelta: " + param, e); } diffs.add(new Diff(Operation.INSERT, param)); break; case '-': // Fall through. case '=': int n; try { n = Integer.parseInt(param); } catch (NumberFormatException e) { throw new IllegalArgumentException( "Invalid number in diff_fromDelta: " + param, e); } if (n < 0) { throw new IllegalArgumentException( "Negative number in diff_fromDelta: " + param); } String text; try { text = text1.substring(pointer, pointer += n); } catch (StringIndexOutOfBoundsException e) { throw new IllegalArgumentException("Delta length (" + pointer + ") larger than source text length (" + text1.length() + ").", e); } if (token.charAt(0) == '=') { diffs.add(new Diff(Operation.EQUAL, text)); } else { diffs.add(new Diff(Operation.DELETE, text)); } break; default: // Anything else is an error. throw new IllegalArgumentException( "Invalid diff operation in diff_fromDelta: " + token.charAt(0)); } } if (pointer != text1.length()) { throw new IllegalArgumentException("Delta length (" + pointer + ") smaller than source text length (" + text1.length() + ")."); } return diffs; } // MATCH FUNCTIONS /** * Locate the best instance of 'pattern' in 'text' near 'loc'. * Returns -1 if no match found. * @param text The text to search. * @param pattern The pattern to search for. * @param loc The location to search around. * @return Best match index or -1. */ public int match_main(String text, String pattern, int loc) { loc = Math.max(0, Math.min(loc, text.length())); if (text.equals(pattern)) { // Shortcut (potentially not guaranteed by the algorithm) return 0; } else if (text.isEmpty()) { // Nothing to match. return -1; } else if (loc + pattern.length() <= text.length() && text.substring(loc, loc + pattern.length()).equals(pattern)) { // Perfect match at the perfect spot! (Includes case of null pattern) return loc; } else { // Do a fuzzy compare. return match_bitap(text, pattern, loc); } } /** * Locate the best instance of 'pattern' in 'text' near 'loc' using the * Bitap algorithm. Returns -1 if no match found. * @param text The text to search. * @param pattern The pattern to search for. * @param loc The location to search around. * @return Best match index or -1. */ protected int match_bitap(String text, String pattern, int loc) { assert (Match_MaxBits == 0 || pattern.length() <= Match_MaxBits) : "Pattern too long for this application."; // Initialise the alphabet. Map s = match_alphabet(pattern); // Highest score beyond which we give up. double score_threshold = Match_Threshold; // Is there a nearby exact match? (speedup) int best_loc = text.indexOf(pattern, loc); if (best_loc != -1) { score_threshold = Math.min(match_bitapScore(0, best_loc, loc, pattern), score_threshold); } // What about in the other direction? (speedup) best_loc = text.lastIndexOf(pattern, loc + pattern.length()); if (best_loc != -1) { score_threshold = Math.min(match_bitapScore(0, best_loc, loc, pattern), score_threshold); } // Initialise the bit arrays. int matchmask = 1 << (pattern.length() - 1); best_loc = -1; int bin_min, bin_mid; int bin_max = pattern.length() + text.length(); // Empty initialization added to appease Java compiler. int[] last_rd = new int[0]; for (int d = 0; d < pattern.length(); d++) { // Scan for the best match; each iteration allows for one more error. // Run a binary search to determine how far from 'loc' we can stray at // this error level. bin_min = 0; bin_mid = bin_max; while (bin_min < bin_mid) { if (match_bitapScore(d, loc + bin_mid, loc, pattern) <= score_threshold) { bin_min = bin_mid; } else { bin_max = bin_mid; } bin_mid = (bin_max - bin_min) / 2 + bin_min; } // Use the result from this iteration as the maximum for the next. bin_max = bin_mid; int start = Math.max(1, loc - bin_mid + 1); int finish = Math.min(loc + bin_mid, text.length()) + pattern.length(); int[] rd = new int[finish + 2]; rd[finish + 1] = (1 << d) - 1; for (int j = finish; j >= start; j--) { int charMatch; if (text.length() <= j - 1 || !s.containsKey(text.charAt(j - 1))) { // Out of range. charMatch = 0; } else { charMatch = s.get(text.charAt(j - 1)); } if (d == 0) { // First pass: exact match. rd[j] = ((rd[j + 1] << 1) | 1) & charMatch; } else { // Subsequent passes: fuzzy match. rd[j] = ((rd[j + 1] << 1) | 1) & charMatch | (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | last_rd[j + 1]; } if ((rd[j] & matchmask) != 0) { double score = match_bitapScore(d, j - 1, loc, pattern); // This match will almost certainly be better than any existing // match. But check anyway. if (score <= score_threshold) { // Told you so. score_threshold = score; best_loc = j - 1; if (best_loc > loc) { // When passing loc, don't exceed our current distance from loc. start = Math.max(1, 2 * loc - best_loc); } else { // Already passed loc, downhill from here on in. break; } } } } if (match_bitapScore(d + 1, loc, loc, pattern) > score_threshold) { // No hope for a (better) match at greater error levels. break; } last_rd = rd; } return best_loc; } /** * Compute and return the score for a match with e errors and x location. * @param e Number of errors in match. * @param x Location of match. * @param loc Expected location of match. * @param pattern Pattern being sought. * @return Overall score for match (0.0 = good, 1.0 = bad). */ private double match_bitapScore(int e, int x, int loc, String pattern) { float accuracy = (float) e / pattern.length(); int proximity = Math.abs(loc - x); if (Match_Distance == 0) { // Dodge divide by zero error. return proximity == 0 ? 1.0 : accuracy; } return accuracy + (proximity / (float) Match_Distance); } /** * Initialise the alphabet for the Bitap algorithm. * @param pattern The text to encode. * @return Hash of character locations. */ protected Map match_alphabet(String pattern) { Map s = new HashMap(); char[] char_pattern = pattern.toCharArray(); for (char c : char_pattern) { s.put(c, 0); } int i = 0; for (char c : char_pattern) { s.put(c, s.get(c) | (1 << (pattern.length() - i - 1))); i++; } return s; } // PATCH FUNCTIONS /** * Increase the context until it is unique, * but don't let the pattern expand beyond Match_MaxBits. * @param patch The patch to grow. * @param text Source text. */ protected void patch_addContext(Patch patch, String text) { String pattern = text.substring(patch.start2, patch.start2 + patch.length1); int padding = 0; // Increase the context until we're unique (but don't let the pattern // expand beyond Match_MaxBits). while (text.indexOf(pattern) != text.lastIndexOf(pattern) && pattern.length() < Match_MaxBits - Patch_Margin - Patch_Margin) { padding += Patch_Margin; pattern = text.substring(Math.max(0, patch.start2 - padding), Math.min(text.length(), patch.start2 + patch.length1 + padding)); } // Add one chunk for good luck. padding += Patch_Margin; // Add the prefix. String prefix = text.substring(Math.max(0, patch.start2 - padding), patch.start2); if (!prefix.isEmpty()) { patch.diffs.addFirst(new Diff(Operation.EQUAL, prefix)); } // Add the suffix. String suffix = text.substring(patch.start2 + patch.length1, Math.min(text.length(), patch.start2 + patch.length1 + padding)); if (!suffix.isEmpty()) { patch.diffs.addLast(new Diff(Operation.EQUAL, suffix)); } // Roll back the start points. patch.start1 -= prefix.length(); patch.start2 -= prefix.length(); // Extend the lengths. patch.length1 += prefix.length() + suffix.length(); patch.length2 += prefix.length() + suffix.length(); } /** * Compute a list of patches to turn text1 into text2. * A set of diffs will be computed. * @param text1 Old text. * @param text2 New text. * @return LinkedList of Patch objects. */ public LinkedList patch_make(String text1, String text2) { // No diffs provided, compute our own. LinkedList diffs = diff_main(text1, text2, true); if (diffs.size() > 2) { diff_cleanupSemantic(diffs); diff_cleanupEfficiency(diffs); } return patch_make(text1, diffs); } /** * Compute a list of patches to turn text1 into text2. * text1 will be derived from the provided diffs. * @param diffs Array of diff tuples for text1 to text2. * @return LinkedList of Patch objects. */ public LinkedList patch_make(LinkedList diffs) { // No origin string provided, compute our own. String text1 = diff_text1(diffs); return patch_make(text1, diffs); } /** * Compute a list of patches to turn text1 into text2. * text2 is ignored, diffs are the delta between text1 and text2. * @param text1 Old text * @param text2 Ignored. * @param diffs Array of diff tuples for text1 to text2. * @return LinkedList of Patch objects. * @deprecated Prefer patch_make(String text1, LinkedList diffs). */ public LinkedList patch_make(String text1, String text2, LinkedList diffs) { return patch_make(text1, diffs); } /** * Compute a list of patches to turn text1 into text2. * text2 is not provided, diffs are the delta between text1 and text2. * @param text1 Old text. * @param diffs Array of diff tuples for text1 to text2. * @return LinkedList of Patch objects. */ public LinkedList patch_make(String text1, LinkedList diffs) { LinkedList patches = new LinkedList(); if (diffs.isEmpty()) { return patches; // Get rid of the null case. } Patch patch = new Patch(); int char_count1 = 0; // Number of characters into the text1 string. int char_count2 = 0; // Number of characters into the text2 string. // Start with text1 (prepatch_text) and apply the diffs until we arrive at // text2 (postpatch_text). We recreate the patches one by one to determine // context info. String prepatch_text = text1; String postpatch_text = text1; for (Diff aDiff : diffs) { if (patch.diffs.isEmpty() && aDiff.operation != Operation.EQUAL) { // A new patch starts here. patch.start1 = char_count1; patch.start2 = char_count2; } switch (aDiff.operation) { case INSERT: patch.diffs.add(aDiff); patch.length2 += aDiff.text.length(); postpatch_text = postpatch_text.substring(0, char_count2) + aDiff.text + postpatch_text.substring(char_count2); break; case DELETE: patch.length1 += aDiff.text.length(); patch.diffs.add(aDiff); postpatch_text = postpatch_text.substring(0, char_count2) + postpatch_text.substring(char_count2 + aDiff.text.length()); break; case EQUAL: if (aDiff.text.length() <= 2 * Patch_Margin && !patch.diffs.isEmpty() && aDiff != diffs.getLast()) { // Small equality inside a patch. patch.diffs.add(aDiff); patch.length1 += aDiff.text.length(); patch.length2 += aDiff.text.length(); } if (aDiff.text.length() >= 2 * Patch_Margin) { // Time for a new patch. if (!patch.diffs.isEmpty()) { patch_addContext(patch, prepatch_text); patches.add(patch); patch = new Patch(); // Unlike Unidiff, our patch lists have a rolling context. // http://code.google.com/p/google-diff-match-patch/wiki/Unidiff // Update prepatch text & pos to reflect the application of the // just completed patch. prepatch_text = postpatch_text; char_count1 = char_count2; } } break; } // Update the current character count. if (aDiff.operation != Operation.INSERT) { char_count1 += aDiff.text.length(); } if (aDiff.operation != Operation.DELETE) { char_count2 += aDiff.text.length(); } } // Pick up the leftover patch if not empty. if (!patch.diffs.isEmpty()) { patch_addContext(patch, prepatch_text); patches.add(patch); } return patches; } /** * Given an array of patches, return another array that is identical. * @param patches Array of patch objects. * @return Array of patch objects. */ public LinkedList patch_deepCopy(LinkedList patches) { LinkedList patchesCopy = new LinkedList(); for (Patch aPatch : patches) { Patch patchCopy = new Patch(); for (Diff aDiff : aPatch.diffs) { Diff diffCopy = new Diff(aDiff.operation, aDiff.text); patchCopy.diffs.add(diffCopy); } patchCopy.start1 = aPatch.start1; patchCopy.start2 = aPatch.start2; patchCopy.length1 = aPatch.length1; patchCopy.length2 = aPatch.length2; patchesCopy.add(patchCopy); } return patchesCopy; } /** * Merge a set of patches onto the text. Return a patched text, as well * as an array of true/false values indicating which patches were applied. * @param patches Array of patch objects * @param text Old text. * @return Two element Object array, containing the new text and an array of * boolean values. */ public Object[] patch_apply(LinkedList patches, String text) { if (patches.isEmpty()) { return new Object[]{text, new boolean[0]}; } // Deep copy the patches so that no changes are made to originals. patches = patch_deepCopy(patches); String nullPadding = patch_addPadding(patches); text = nullPadding + text + nullPadding; patch_splitMax(patches); int x = 0; // delta keeps track of the offset between the expected and actual location // of the previous patch. If there are patches expected at positions 10 and // 20, but the first patch was found at 12, delta is 2 and the second patch // has an effective expected position of 22. int delta = 0; boolean[] results = new boolean[patches.size()]; for (Patch aPatch : patches) { int expected_loc = aPatch.start2 + delta; String text1 = diff_text1(aPatch.diffs); int start_loc; int end_loc = -1; if (text1.length() > this.Match_MaxBits) { // patch_splitMax will only provide an oversized pattern in the case of // a monster delete. start_loc = match_main(text, text1.substring(0, this.Match_MaxBits), expected_loc); if (start_loc != -1) { end_loc = match_main(text, text1.substring(text1.length() - this.Match_MaxBits), expected_loc + text1.length() - this.Match_MaxBits); if (end_loc == -1 || start_loc >= end_loc) { // Can't find valid trailing context. Drop this patch. start_loc = -1; } } } else { start_loc = match_main(text, text1, expected_loc); } if (start_loc == -1) { // No match found. :( results[x] = false; } else { // Found a match. :) results[x] = true; delta = start_loc - expected_loc; String text2; if (end_loc == -1) { text2 = text.substring(start_loc, Math.min(start_loc + text1.length(), text.length())); } else { text2 = text.substring(start_loc, Math.min(end_loc + this.Match_MaxBits, text.length())); } if (text1.equals(text2)) { // Perfect match, just shove the replacement text in. text = text.substring(0, start_loc) + diff_text2(aPatch.diffs) + text.substring(start_loc + text1.length()); } else { // Imperfect match. Run a diff to get a framework of equivalent // indices. LinkedList diffs = diff_main(text1, text2, false); if (text1.length() > this.Match_MaxBits && diff_levenshtein(diffs) / (float) text1.length() > this.Patch_DeleteThreshold) { // The end points match, but the content is unacceptably bad. results[x] = false; } else { diff_cleanupSemanticLossless(diffs); int index1 = 0; for (Diff aDiff : aPatch.diffs) { if (aDiff.operation != Operation.EQUAL) { int index2 = diff_xIndex(diffs, index1); if (aDiff.operation == Operation.INSERT) { // Insertion text = text.substring(0, start_loc + index2) + aDiff.text + text.substring(start_loc + index2); } else if (aDiff.operation == Operation.DELETE) { // Deletion text = text.substring(0, start_loc + index2) + text.substring(start_loc + diff_xIndex(diffs, index1 + aDiff.text.length())); } } if (aDiff.operation != Operation.DELETE) { index1 += aDiff.text.length(); } } } } } x++; } // Strip the padding off. text = text.substring(nullPadding.length(), text.length() - nullPadding.length()); return new Object[]{text, results}; } /** * Add some padding on text start and end so that edges can match something. * Intended to be called only from within patch_apply. * @param patches Array of patch objects. * @return The padding string added to each side. */ public String patch_addPadding(LinkedList patches) { LinkedList diffs; String nullPadding = ""; for (int x = 1; x <= this.Patch_Margin; x++) { nullPadding += String.valueOf((char) x); } // Bump all the patches forward. for (Patch aPatch : patches) { aPatch.start1 += nullPadding.length(); aPatch.start2 += nullPadding.length(); } // Add some padding on start of first diff. Patch patch = patches.getFirst(); diffs = patch.diffs; if (diffs.isEmpty() || diffs.getFirst().operation != Operation.EQUAL) { // Add nullPadding equality. diffs.addFirst(new Diff(Operation.EQUAL, nullPadding)); patch.start1 -= nullPadding.length(); // Should be 0. patch.start2 -= nullPadding.length(); // Should be 0. patch.length1 += nullPadding.length(); patch.length2 += nullPadding.length(); } else if (nullPadding.length() > diffs.getFirst().text.length()) { // Grow first equality. Diff firstDiff = diffs.getFirst(); int extraLength = nullPadding.length() - firstDiff.text.length(); firstDiff.text = nullPadding.substring(firstDiff.text.length()) + firstDiff.text; patch.start1 -= extraLength; patch.start2 -= extraLength; patch.length1 += extraLength; patch.length2 += extraLength; } // Add some padding on end of last diff. patch = patches.getLast(); diffs = patch.diffs; if (diffs.isEmpty() || diffs.getLast().operation != Operation.EQUAL) { // Add nullPadding equality. diffs.addLast(new Diff(Operation.EQUAL, nullPadding)); patch.length1 += nullPadding.length(); patch.length2 += nullPadding.length(); } else if (nullPadding.length() > diffs.getLast().text.length()) { // Grow last equality. Diff lastDiff = diffs.getLast(); int extraLength = nullPadding.length() - lastDiff.text.length(); lastDiff.text += nullPadding.substring(0, extraLength); patch.length1 += extraLength; patch.length2 += extraLength; } return nullPadding; } /** * Look through the patches and break up any which are longer than the * maximum limit of the match algorithm. * @param patches LinkedList of Patch objects. */ public void patch_splitMax(LinkedList patches) { int patch_size; String precontext, postcontext; Patch patch; int start1, start2; boolean empty; Operation diff_type; String diff_text; ListIterator pointer = patches.listIterator(); Patch bigpatch = pointer.hasNext() ? pointer.next() : null; while (bigpatch != null) { if (bigpatch.length1 <= Match_MaxBits) { bigpatch = pointer.hasNext() ? pointer.next() : null; continue; } // Remove the big old patch. pointer.remove(); patch_size = Match_MaxBits; start1 = bigpatch.start1; start2 = bigpatch.start2; precontext = ""; while (!bigpatch.diffs.isEmpty()) { // Create one of several smaller patches. patch = new Patch(); empty = true; patch.start1 = start1 - precontext.length(); patch.start2 = start2 - precontext.length(); if (!precontext.isEmpty()) { patch.length1 = patch.length2 = precontext.length(); patch.diffs.add(new Diff(Operation.EQUAL, precontext)); } while (!bigpatch.diffs.isEmpty() && patch.length1 < patch_size - Patch_Margin) { diff_type = bigpatch.diffs.getFirst().operation; diff_text = bigpatch.diffs.getFirst().text; if (diff_type == Operation.INSERT) { // Insertions are harmless. patch.length2 += diff_text.length(); start2 += diff_text.length(); patch.diffs.addLast(bigpatch.diffs.removeFirst()); empty = false; } else if (diff_type == Operation.DELETE && patch.diffs.size() == 1 && patch.diffs.getFirst().operation == Operation.EQUAL && diff_text.length() > 2 * patch_size) { // This is a large deletion. Let it pass in one chunk. patch.length1 += diff_text.length(); start1 += diff_text.length(); empty = false; patch.diffs.add(new Diff(diff_type, diff_text)); bigpatch.diffs.removeFirst(); } else { // Deletion or equality. Only take as much as we can stomach. diff_text = diff_text.substring(0, Math.min(diff_text.length(), patch_size - patch.length1 - Patch_Margin)); patch.length1 += diff_text.length(); start1 += diff_text.length(); if (diff_type == Operation.EQUAL) { patch.length2 += diff_text.length(); start2 += diff_text.length(); } else { empty = false; } patch.diffs.add(new Diff(diff_type, diff_text)); if (diff_text.equals(bigpatch.diffs.getFirst().text)) { bigpatch.diffs.removeFirst(); } else { bigpatch.diffs.getFirst().text = bigpatch.diffs.getFirst().text .substring(diff_text.length()); } } } // Compute the head context for the next patch. precontext = diff_text2(patch.diffs); precontext = precontext.substring(Math.max(0, precontext.length() - Patch_Margin)); // Append the end context for this patch. if (diff_text1(bigpatch.diffs).length() > Patch_Margin) { postcontext = diff_text1(bigpatch.diffs).substring(0, Patch_Margin); } else { postcontext = diff_text1(bigpatch.diffs); } if (!postcontext.isEmpty()) { patch.length1 += postcontext.length(); patch.length2 += postcontext.length(); if (!patch.diffs.isEmpty() && patch.diffs.getLast().operation == Operation.EQUAL) { patch.diffs.getLast().text += postcontext; } else { patch.diffs.add(new Diff(Operation.EQUAL, postcontext)); } } if (!empty) { pointer.add(patch); } } bigpatch = pointer.hasNext() ? pointer.next() : null; } } /** * Take a list of patches and return a textual representation. * @param patches List of Patch objects. * @return Text representation of patches. */ public String patch_toText(List patches) { StringBuilder text = new StringBuilder(); for (Patch aPatch : patches) { text.append(aPatch); } return text.toString(); } /** * Parse a textual representation of patches and return a List of Patch * objects. * @param textline Text representation of patches. * @return List of Patch objects. * @throws IllegalArgumentException If invalid input. */ public List patch_fromText(String textline) throws IllegalArgumentException { List patches = new LinkedList(); if (textline.isEmpty()) { return patches; } List textList = Arrays.asList(textline.split("\n")); LinkedList text = new LinkedList(textList); Patch patch; Pattern patchHeader = Pattern.compile("^@@ -(\\d+),?(\\d*) \\+(\\d+),?(\\d*) @@$"); Matcher m; char sign; String line; while (!text.isEmpty()) { m = patchHeader.matcher(text.getFirst()); if (!m.matches()) { throw new IllegalArgumentException( "Invalid patch string: " + text.getFirst()); } patch = new Patch(); patches.add(patch); patch.start1 = Integer.parseInt(m.group(1)); if (m.group(2).isEmpty()) { patch.start1--; patch.length1 = 1; } else if (m.group(2).equals("0")) { patch.length1 = 0; } else { patch.start1--; patch.length1 = Integer.parseInt(m.group(2)); } patch.start2 = Integer.parseInt(m.group(3)); if (m.group(4).isEmpty()) { patch.start2--; patch.length2 = 1; } else if (m.group(4).equals("0")) { patch.length2 = 0; } else { patch.start2--; patch.length2 = Integer.parseInt(m.group(4)); } text.removeFirst(); while (!text.isEmpty()) { try { sign = text.getFirst().charAt(0); } catch (IndexOutOfBoundsException e) { // Blank line? Whatever. text.removeFirst(); continue; } line = text.getFirst().substring(1); line = line.replace("+", "%2B"); // decode would change all "+" to " " try { line = URLDecoder.decode(line, "UTF-8"); } catch (UnsupportedEncodingException e) { // Not likely on modern system. throw new Error("This system does not support UTF-8.", e); } catch (IllegalArgumentException e) { // Malformed URI sequence. throw new IllegalArgumentException( "Illegal escape in patch_fromText: " + line, e); } if (sign == '-') { // Deletion. patch.diffs.add(new Diff(Operation.DELETE, line)); } else if (sign == '+') { // Insertion. patch.diffs.add(new Diff(Operation.INSERT, line)); } else if (sign == ' ') { // Minor equality. patch.diffs.add(new Diff(Operation.EQUAL, line)); } else if (sign == '@') { // Start of next patch. break; } else { // WTF? throw new IllegalArgumentException( "Invalid patch mode '" + sign + "' in: " + line); } text.removeFirst(); } } return patches; } /** * Class representing one diff operation. */ public static class Diff { public Operation operation; // One of: INSERT, DELETE or EQUAL. public String text; // The text associated with this diff operation. /** * Constructor. Initializes the diff with the provided values. * @param operation One of INSERT, DELETE or EQUAL. * @param text The text being applied. */ public Diff(Operation operation, String text) { // Construct a diff with the specified operation and text. this.operation = operation; this.text = text; } /** * Display a human-readable version of this Diff. * @return text version. */ public String toString() { String prettyText = this.text.replace('\n', '\u00b6'); return "Diff(" + this.operation + ",\"" + prettyText + "\")"; } /** * Is this Diff equivalent to another Diff? * @param d Another Diff to compare against. * @return true or false. */ public boolean equals(Object d) { try { return (((Diff) d).operation == this.operation) && (((Diff) d).text.equals(this.text)); } catch (ClassCastException e) { return false; } } } /** * Class representing one patch operation. */ public static class Patch { public LinkedList diffs; public int start1; public int start2; public int length1; public int length2; /** * Constructor. Initializes with an empty list of diffs. */ public Patch() { this.diffs = new LinkedList(); } /** * Emmulate GNU diff's format. * Header: @@ -382,8 +481,9 @@ * Indicies are printed as 1-based, not 0-based. * @return The GNU diff string. */ public String toString() { String coords1, coords2; if (this.length1 == 0) { coords1 = this.start1 + ",0"; } else if (this.length1 == 1) { coords1 = Integer.toString(this.start1 + 1); } else { coords1 = (this.start1 + 1) + "," + this.length1; } if (this.length2 == 0) { coords2 = this.start2 + ",0"; } else if (this.length2 == 1) { coords2 = Integer.toString(this.start2 + 1); } else { coords2 = (this.start2 + 1) + "," + this.length2; } StringBuilder text = new StringBuilder(); text.append("@@ -").append(coords1).append(" +").append(coords2) .append(" @@\n"); // Escape the body of the patch with %xx notation. for (Diff aDiff : this.diffs) { switch (aDiff.operation) { case INSERT: text.append('+'); break; case DELETE: text.append('-'); break; case EQUAL: text.append(' '); break; } try { text.append(URLEncoder.encode(aDiff.text, "UTF-8").replace('+', ' ')) .append("\n"); } catch (UnsupportedEncodingException e) { // Not likely on modern system. throw new Error("This system does not support UTF-8.", e); } } return unescapeForEncodeUriCompatability(text.toString()); } } /** * Unescape selected chars for compatability with JavaScript's encodeURI. * In speed critical applications this could be dropped since the * receiving application will certainly decode these fine. * Note that this function is case-sensitive. Thus "%3f" would not be * unescaped. But this is ok because it is only called with the output of * URLEncoder.encode which returns uppercase hex. * * Example: "%3F" -> "?", "%24" -> "$", etc. * * @param str The string to escape. * @return The escaped string. */ private static String unescapeForEncodeUriCompatability(String str) { return str.replace("%21", "!").replace("%7E", "~") .replace("%27", "'").replace("%28", "(").replace("%29", ")") .replace("%3B", ";").replace("%2F", "/").replace("%3F", "?") .replace("%3A", ":").replace("%40", "@").replace("%26", "&") .replace("%3D", "=").replace("%2B", "+").replace("%24", "$") .replace("%2C", ",").replace("%23", "#"); } } whiteboard/mobwrite/java-client/ShareObj.java0000644000175000017500000001607012251036356020570 0ustar ernieerniepackage name.fraser.neil.mobwrite; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.LinkedList; import java.util.logging.Level; import java.util.regex.Pattern; import name.fraser.neil.plaintext.diff_match_patch; import name.fraser.neil.plaintext.diff_match_patch.*; public abstract class ShareObj { /** * Instantiation of the Diff Match Patch library. * http://code.google.com/p/google-diff-match-patch/ */ protected diff_match_patch dmp; /** * The filename or ID for this shared object. */ protected String file; /** * The hosting MobWriteClient. */ protected MobWriteClient mobwrite; /** * List of currently unacknowledged edits sent to the server. */ protected LinkedList editStack; /** * Client's understanding of what the server's text looks like. */ protected String shadowText = ""; /** * The client's version for the shadow (n). */ protected int clientVersion = 0; /** * The server's version for the shadow (m). */ protected int serverVersion = 0; /** * Did the client understand the server's delta in the previous heartbeat? * Initialize false because the server and client are out of sync initially. */ protected boolean deltaOk = false; /** * Synchronization mode. * True: Used for text, attempts to gently merge differences together. * False: Used for numbers, overwrites conflicts, last save wins. */ protected boolean mergeChanges = true; /** * Constructor. Create a ShareObj with a file ID. * @param file Filename to share as. */ public ShareObj(String file) { this.file = file; this.dmp = new diff_match_patch(); this.dmp.Diff_Timeout = 0.5f; // List of unacknowledged edits sent to the server. this.editStack = new LinkedList(); } /** * Fetch or compute a plaintext representation of the user's text. * @return Plaintext content. */ public abstract String getClientText(); /** * Set the user's text based on the provided plaintext. * @param text New text. */ public abstract void setClientText(String text); /** * Modify the user's plaintext by applying a series of patches against it. * @param patches Array of Patch objects. */ public void patchClientText(LinkedList patches) { String oldClientText = this.getClientText(); Object[] result = this.dmp.patch_apply(patches, oldClientText); // Set the new text only if there is a change to be made. if (!oldClientText.equals(result[0])) { // The following will probably destroy any cursor or selection. // Widgets with cursors should override and patch more delicately. this.setClientText((String) result[0]); } } /** * Notification of when a diff was sent to the server. * @param diffs Array of diff objects. */ private void onSentDiff(LinkedList diffs) { // Potential hook for subclass } /** * Does the text look like unmergable content? * Currently we look for numbers. * @param text Plaintext content. * @return True iff unmergable. */ protected boolean isEnum(String text) { Pattern p = Pattern.compile("^\\s*-?[\\d.,]+\\s*$"); return !p.matcher(text).matches(); } /** * Return the command to nullify this field. Also unshares this field. * @return Command to be sent to the server. */ protected String nullify() { mobwrite.unshare(this); // Create the output starting with the file statement, followed by the edits. String data = mobwrite.idPrefix + this.file; try { data = URLEncoder.encode(data, "UTF-8").replace('+', ' '); } catch (UnsupportedEncodingException e) { // Not likely on modern system. throw new Error("This system does not support UTF-8.", e); } return "N:" + data + '\n'; } /** * Asks the ShareObj to synchronize. Computes client-made changes since * previous postback. Return '' to skip this synchronization. * @return Commands to be sent to the server. */ protected String syncText() { String clientText; try { clientText = this.getClientText(); if (clientText == null) { // Null is not an acceptable result. throw new NullPointerException(); } } catch (Exception e) { // Potential call to untrusted 3rd party code. this.mobwrite.logger.log(Level.SEVERE, "Error calling getClientText on '" + this.file + "': " + e); e.printStackTrace(); return ""; } if (this.deltaOk) { // The last delta postback from the server to this shareObj was successful. // Send a compressed delta. LinkedList diffs = this.dmp.diff_main(this.shadowText, clientText, true); if (diffs.size() > 2) { this.dmp.diff_cleanupSemantic(diffs); this.dmp.diff_cleanupEfficiency(diffs); } boolean changed = diffs.size() != 1 || diffs.getFirst().operation != Operation.EQUAL; if (changed) { this.mobwrite.clientChange_ = true; this.shadowText = clientText; } // Don't bother appending a no-change diff onto the stack if the stack // already contains something. if (changed || this.editStack.isEmpty()) { String action = (this.mergeChanges ? "d:" : "D:") + this.clientVersion + ':' + this.dmp.diff_toDelta(diffs); this.editStack.push(new Object[]{this.clientVersion, action}); this.clientVersion++; try { this.onSentDiff(diffs); } catch (Exception e) { // Potential call to untrusted 3rd party code. this.mobwrite.logger.log(Level.SEVERE, "Error calling onSentDiff on '" + this.file + "': " + e); e.printStackTrace(); } } } else { // The last delta postback from the server to this shareObj didn't match. // Send a full text dump to get back in sync. This will result in any // changes since the last postback being wiped out. :( String data = clientText; try { data = URLEncoder.encode(data, "UTF-8").replace('+', ' '); } catch (UnsupportedEncodingException e) { // Not likely on modern system. throw new Error("This system does not support UTF-8.", e); } data = MobWriteClient.unescapeForEncodeUriCompatability(data); if (this.shadowText != clientText) { this.shadowText = clientText; } this.clientVersion++; String action = "r:" + this.clientVersion + ':' + data; // Append the action to the edit stack. this.editStack.push(new Object[]{this.clientVersion, action}); } // Create the output starting with the file statement, followed by the edits. String data = mobwrite.idPrefix + this.file; try { data = URLEncoder.encode(data, "UTF-8").replace('+', ' '); } catch (UnsupportedEncodingException e) { // Not likely on modern system. throw new Error("This system does not support UTF-8.", e); } data = "F:" + this.serverVersion + ':' + data + '\n'; for (Object[] pair : this.editStack) { data += (String) pair[1] + '\n'; } return data; } } whiteboard/mobwrite/java-client/DemoFormApplet.java0000644000175000017500000001647012251036356021755 0ustar ernieerniepackage name.fraser.neil.mobwrite; import java.awt.Color; import java.awt.Container; import java.awt.Font; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import javax.swing.ButtonGroup; import javax.swing.JApplet; import javax.swing.JCheckBox; import javax.swing.JLabel; import javax.swing.JList; import javax.swing.JPasswordField; import javax.swing.JRadioButton; import javax.swing.JScrollPane; import javax.swing.JTextArea; import javax.swing.JTextField; import javax.swing.SwingUtilities; import javax.swing.border.LineBorder; public class DemoFormApplet extends JApplet implements ActionListener { private JTextField demo_form_what; private JTextField demo_form_date1; private JTextField demo_form_date2; private JLabel lblTo; private JCheckBox demo_form_all_day; private ButtonGroup demo_form_where; private JList demo_form_who; private JTextField demo_form_hidden; private JPasswordField demo_form_password; private JTextArea demo_form_description; @Override public void init() { //Execute a job on the event-dispatching thread: //creating this applet's GUI. try { SwingUtilities.invokeAndWait(new Runnable() { public void run() { createGUI(); MobWriteClient mobwrite = new MobWriteClient(); mobwrite.syncGateway = getParameter("syncGateway"); if (mobwrite.syncGateway == null) { mobwrite.syncGateway = "http://mobwrite3.appspot.com/scripts/q.py"; } try { mobwrite.maxSyncInterval = Integer.parseInt(getParameter("maxSyncInterval")); } catch (Exception e) { // Ignore, use default. } try { mobwrite.minSyncInterval = Integer.parseInt(getParameter("minSyncInterval")); } catch (Exception e) { // Ignore, use default. } ShareObj shareWhat = new ShareJTextComponent(demo_form_what, "demo_form_what"); ShareObj shareDate1 = new ShareJTextComponent(demo_form_date1, "demo_form_date1"); ShareObj shareDate2 = new ShareJTextComponent(demo_form_date2, "demo_form_date2"); ShareObj shareCheck = new ShareAbstractButton(demo_form_all_day, "demo_form_all_day"); ShareObj shareRadio = new ShareButtonGroup(demo_form_where, "demo_form_where1"); ShareObj shareSelect = new ShareJList(demo_form_who, "demo_form_who"); ShareObj shareHidden = new ShareJTextComponent(demo_form_hidden, "demo_form_hidden"); ShareObj sharePassword = new ShareJTextComponent(demo_form_password, "demo_form_password"); ShareObj shareDescription = new ShareJTextComponent(demo_form_description, "demo_form_description"); mobwrite.share(shareWhat, shareDate1, shareDate2, shareCheck, shareRadio, shareSelect, shareHidden, sharePassword, shareDescription); } }); } catch (Exception e) { e.printStackTrace(); } } /** * Lay out the GUI and initialize the form elements. */ private void createGUI() { Container contentPane = this.getContentPane(); contentPane.setLayout(null); int margin = 10; Font headerFont = new Font("SansSerif", Font.BOLD, 12); int headerWidth = 75; // Title JLabel label = new JLabel("MobWrite as a Collaborative Form"); label.setFont(new Font("SansSerif", Font.PLAIN, 18)); label.setBounds(margin, margin, 300, 26); contentPane.add(label); // What label = new JLabel("What"); label.setFont(headerFont); label.setBounds(margin, 48, headerWidth, 14); contentPane.add(label); demo_form_what = new JTextField(); demo_form_what.setBounds(headerWidth + margin, 48, 183, 20); contentPane.add(demo_form_what); // When label = new JLabel("When"); label.setFont(headerFont); label.setBounds(margin, 73, headerWidth, 14); contentPane.add(label); demo_form_date1 = new JTextField(); demo_form_date1.setBounds(headerWidth + margin, 70, 60, 20); contentPane.add(demo_form_date1); lblTo = new JLabel("to"); lblTo.setBounds(148, 73, 18, 14); contentPane.add(lblTo); demo_form_date2 = new JTextField(); demo_form_date2.setBounds(163, 70, 60, 20); contentPane.add(demo_form_date2); demo_form_all_day = new JCheckBox("All day"); demo_form_all_day.setBounds(229, 69, 75, 23); demo_form_all_day.setActionCommand("All day"); demo_form_all_day.setName("on"); demo_form_all_day.addActionListener(this); contentPane.add(demo_form_all_day); // Where label = new JLabel("Where"); label.setFont(headerFont); label.setBounds(margin, 98, headerWidth, 14); contentPane.add(label); demo_form_where = new ButtonGroup(); JRadioButton rdbtnSanFrancisco = new JRadioButton("San Francisco"); rdbtnSanFrancisco.setName("SFO"); rdbtnSanFrancisco.setBounds(headerWidth + margin, 97, 109, 23); demo_form_where.add(rdbtnSanFrancisco); contentPane.add(rdbtnSanFrancisco); JRadioButton rdbtnNewYork = new JRadioButton("New York"); rdbtnNewYork.setName("NYC"); rdbtnNewYork.setBounds(headerWidth + margin, 118, 109, 23); demo_form_where.add(rdbtnNewYork); contentPane.add(rdbtnNewYork); JRadioButton rdbtnToronto = new JRadioButton("Toronto"); rdbtnToronto.setName("YYZ"); rdbtnToronto.setBounds(headerWidth + margin, 139, 109, 23); demo_form_where.add(rdbtnToronto); contentPane.add(rdbtnToronto); // Who label = new JLabel("Who"); label.setFont(headerFont); label.setBounds(margin, 170, headerWidth, 14); contentPane.add(label); String[] data = {"Alice", "Bob", "Eve"}; demo_form_who = new JList(data); demo_form_who.setBorder(new LineBorder(Color.BLACK)); demo_form_who.setBounds(headerWidth + margin, 169, 87, 65); contentPane.add(demo_form_who); // Hidden label = new JLabel("Hidden"); label.setFont(headerFont); label.setBounds(margin, 247, headerWidth, 14); contentPane.add(label); demo_form_hidden = new JTextField(); demo_form_hidden.setBounds(headerWidth + margin, 244, 87, 20); demo_form_hidden.setVisible(false); contentPane.add(demo_form_hidden); // Password label = new JLabel("Password"); label.setFont(headerFont); label.setBounds(margin, 275, headerWidth, 14); contentPane.add(label); demo_form_password = new JPasswordField(); demo_form_password.setBounds(headerWidth + margin, 273, 87, 20); contentPane.add(demo_form_password); // Description label = new JLabel("Description"); label.setFont(headerFont); label.setBounds(margin, 300, headerWidth, 14); contentPane.add(label); demo_form_description = new JTextArea(); demo_form_description.setLineWrap(true); demo_form_description.setWrapStyleWord(true); JScrollPane scrollPane = new JScrollPane(demo_form_description); scrollPane.setBounds(headerWidth + margin, 297, 210, 74); contentPane.add(scrollPane); } /** * When the 'All day' checkbox is ticked, the end time disappears. * @param e Action event. */ public void actionPerformed(ActionEvent e) { if ("All day".equals(e.getActionCommand())) { boolean allDay = demo_form_all_day.isSelected(); lblTo.setVisible(!allDay); demo_form_date2.setVisible(!allDay); demo_form_all_day.setLocation(allDay ? 148 : 229, demo_form_all_day.getLocation().y); } } } whiteboard/mobwrite/java-client/MobWriteClient.java0000644000175000017500000004152312251036356021763 0ustar ernieernie/** * MobWrite - Real-time Synchronization and Collaboration Service * * Copyright 2009 Google Inc. * http://code.google.com/p/google-mobwrite/ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package name.fraser.neil.mobwrite; import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.UnsupportedEncodingException; import java.net.URL; import java.net.URLConnection; import java.net.URLDecoder; import java.net.URLEncoder; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import name.fraser.neil.plaintext.diff_match_patch.*; /** * Class representing a MobWrite client. */ public class MobWriteClient extends Thread { /** * URL of web gateway. */ public String syncGateway = "http://mobwrite3.appspot.com/scripts/q.py"; /** * Time to wait for a connection before giving up and retrying. */ public int timeoutInterval = 30000; /** * Shortest interval (in milliseconds) between connections. */ public int minSyncInterval = 1000; /** * Longest interval (in milliseconds) between connections. */ public int maxSyncInterval = 10000; /** * Initial interval (in milliseconds) for connections. * This value is modified later as traffic rates are established. */ public int syncInterval = 2000; /** * Optional prefix to automatically add to all IDs. */ public String idPrefix = ""; /** * Track whether something changed client-side in each sync. */ protected boolean clientChange_ = false; /** * Track whether something changed server-side in each sync. */ private boolean serverChange_ = false; /** * Flag to nullify all shared elements and terminate. */ public boolean nullifyAll = false; /** * Unique ID for this session. */ private String syncUsername; /** * Hash of all shared objects. */ private Map shared; /** * Logging object. */ protected Logger logger; /** * Constructor. Initializes a MobWrite client. */ public MobWriteClient() { this.logger = Logger.getLogger("MobWrite"); this.syncUsername = this.uniqueId(); logger.log(Level.INFO, "Username: " + this.syncUsername); this.shared = new HashMap(); } /** * Return a random id that's 8 letters long. * 26*(26+10+4)^7 = 4,259,840,000,000 * @return Random id. */ private String uniqueId() { // First character must be a letter. // IE is case insensitive (in violation of the W3 spec). String soup = "abcdefghijklmnopqrstuvwxyz"; StringBuffer sb = new StringBuffer(); sb.append(soup.charAt((int) (Math.random() * soup.length()))); // Subsequent characters may include these. soup += "0123456789-_:."; for (int x = 1; x < 8; x++) { sb.append(soup.charAt((int) (Math.random() * soup.length()))); } String id = sb.toString(); // Don't allow IDs with '--' in them since it might close a comment. if (id.indexOf("--") != -1) { id = this.uniqueId(); } return id; // Getting the maximum possible density in the ID is worth the extra code, // since the ID is transmitted to the server a lot. } /** * Collect all client-side changes and send them to the server. */ private void syncRun1_() { // Initialize clientChange_, to be checked at the end of syncRun2_. this.clientChange_ = false; StringBuilder data = new StringBuilder(); data.append("u:" + this.syncUsername + '\n'); boolean empty = true; // Ask every shared object for their deltas. for (ShareObj share : this.shared.values()) { if (this.nullifyAll) { data.append(share.nullify()); } else { data.append(share.syncText()); } empty = false; } if (empty) { // No sync objects. return; } if (data.indexOf("\n") == data.lastIndexOf("\n")) { // No sync data. this.logger.log(Level.INFO, "All objects silent; null sync."); this.syncRun2_("\n\n"); return; } this.logger.log(Level.INFO, "TO server:\n" + data); // Add terminating blank line. data.append('\n'); // Issue Ajax post of client-side changes and request server-side changes. StringBuffer buffer = new StringBuffer(); try { // Construct data String q = "q=" + URLEncoder.encode(data.toString(), "UTF-8"); // Send data URL url = new URL(this.syncGateway); URLConnection conn = url.openConnection(); conn.setConnectTimeout(this.timeoutInterval); conn.setReadTimeout(this.timeoutInterval); conn.setDoOutput(true); OutputStreamWriter wr = new OutputStreamWriter(conn.getOutputStream()); wr.write(q); wr.flush(); // Get the response BufferedReader rd = new BufferedReader(new InputStreamReader(conn.getInputStream())); String line; while ((line = rd.readLine()) != null) { buffer.append(line).append('\n'); } wr.close(); rd.close(); } catch (Exception e) { e.printStackTrace(); } this.syncRun2_(buffer.toString()); // Execution will resume in either syncCheckAjax_(), or syncKill_() } /** * Parse all server-side changes and distribute them to the shared objects. */ private void syncRun2_(String text) { // Initialize serverChange_, to be checked at the end of syncRun2_. this.serverChange_ = false; this.logger.log(Level.INFO, "FROM server:\n" + text); // There must be a linefeed followed by a blank line. if (!text.endsWith("\n\n")) { text = ""; this.logger.log(Level.INFO, "Truncated data. Abort."); } String[] lines = text.split("\n"); ShareObj file = null; int clientVersion = -1; for (int i = 0; i < lines.length; i++) { String line = lines[i]; if (line.isEmpty()) { // Terminate on blank line. break; } // Divide each line into 'N:value' pairs. if (line.charAt(1) != ':') { this.logger.log(Level.INFO, "Unparsable line: " + line); continue; } char name = line.charAt(0); String value = line.substring(2); // Parse out a version number for file, delta or raw. int version = -1; if ("FfDdRr".indexOf(name) != -1) { int div = value.indexOf(':'); if (div == -1) { this.logger.log(Level.SEVERE, "No version number: " + line); continue; } try { version = Integer.parseInt(value.substring(0, div)); } catch (NumberFormatException e) { this.logger.log(Level.SEVERE, "NaN version number: " + line); continue; } value = value.substring(div + 1); } if (name == 'F' || name == 'f') { // FILE indicates which shared object following delta/raw applies to. if (value.substring(0, this.idPrefix.length()).equals(this.idPrefix)) { // Trim off the ID prefix. value = value.substring(this.idPrefix.length()); } else { // This file does not have our ID prefix. file = null; this.logger.log(Level.SEVERE, "File does not have \"" + this.idPrefix + "\" prefix: " + value); continue; } if (this.shared.containsKey(value)) { file = this.shared.get(value); file.deltaOk = true; clientVersion = version; // Remove any elements from the edit stack with low version numbers // which have been acked by the server. Iterator pairPointer = file.editStack.iterator(); while (pairPointer.hasNext() ){ Object[] pair = pairPointer.next(); if ((Integer) pair[0] <= clientVersion) { pairPointer.remove(); } } } else { // This file does not map to a currently shared object. file = null; this.logger.log(Level.SEVERE, "Unknown file: " + value); } } else if (name == 'R' || name == 'r') { // The server reports it was unable to integrate the previous delta. if (file != null) { try { file.shadowText = URLDecoder.decode(value, "UTF-8"); } catch (UnsupportedEncodingException e) { // Not likely on modern system. throw new Error("This system does not support UTF-8.", e); } catch (IllegalArgumentException e) { // Malformed URI sequence. throw new IllegalArgumentException( "Illegal escape in diff_fromDelta: " + value, e); } file.clientVersion = clientVersion; file.serverVersion = version; file.editStack.clear(); if (name == 'R') { // Accept the server's raw text dump and wipe out any user's changes. try { file.setClientText(file.shadowText); } catch (Exception e) { // Potential call to untrusted 3rd party code. this.logger.log(Level.SEVERE, "Error calling setClientText on '" + file.file + "': " + e); e.printStackTrace(); } } // Server-side activity. this.serverChange_ = true; } } else if (name == 'D' || name == 'd') { // The server offers a compressed delta of changes to be applied. if (file != null) { if (clientVersion != file.clientVersion) { // Can't apply a delta on a mismatched shadow version. file.deltaOk = false; this.logger.log(Level.SEVERE, "Client version number mismatch.\n" + "Expected: " + file.clientVersion + " Got: " + clientVersion); } else if (version > file.serverVersion) { // Server has a version in the future? file.deltaOk = false; this.logger.log(Level.SEVERE, "Server version in future.\n" + "Expected: " + file.serverVersion + " Got: " + version); } else if (version < file.serverVersion) { // We've already seen this diff. this.logger.log(Level.WARNING, "Server version in past.\n" + "Expected: " + file.serverVersion + " Got: " + version); } else { // Expand the delta into a diff using the client shadow. LinkedList diffs; try { diffs = file.dmp.diff_fromDelta(file.shadowText, value); file.serverVersion++; } catch (IllegalArgumentException ex) { // The delta the server supplied does not fit on our copy of // shadowText. diffs = null; // Set deltaOk to false so that on the next sync we send // a complete dump to get back in sync. file.deltaOk = false; // Do the next sync soon because the user will lose any changes. this.syncInterval = 0; try { this.logger.log(Level.WARNING, "Delta mismatch.\n" + URLEncoder.encode(file.shadowText, "UTF-8")); } catch (UnsupportedEncodingException e) { // Not likely on modern system. throw new Error("This system does not support UTF-8.", e); } } if (diffs != null && (diffs.size() != 1 || diffs.getFirst().operation != Operation.EQUAL)) { // Compute and apply the patches. if (name == 'D') { // Overwrite text. file.shadowText = file.dmp.diff_text2(diffs); try { file.setClientText(file.shadowText); } catch (Exception e) { // Potential call to untrusted 3rd party code. this.logger.log(Level.SEVERE, "Error calling setClientText on '" + file.file + "': " + e); e.printStackTrace(); } } else { // Merge text. LinkedList patches = file.dmp.patch_make(file.shadowText, diffs); // First shadowText. Should be guaranteed to work. Object[] serverResult = file.dmp.patch_apply(patches, file.shadowText); file.shadowText = (String) serverResult[0]; // Second the user's text. try { file.patchClientText(patches); } catch (Exception e) { // Potential call to untrusted 3rd party code. this.logger.log(Level.SEVERE, "Error calling patchClientText on '" + file.file + "': " + e); e.printStackTrace(); } } // Server-side activity. this.serverChange_ = true; } } } } } } /** * Compute how long to wait until next synchronization. */ private void computeSyncInterval_() { int range = this.maxSyncInterval - this.minSyncInterval; if (this.clientChange_) { // Client-side activity. // Cut the sync interval by 40% of the min-max range. this.syncInterval -= range * 0.4; } if (this.serverChange_) { // Server-side activity. // Cut the sync interval by 20% of the min-max range. this.syncInterval -= range * 0.2; } if (!this.clientChange_ && !this.serverChange_) { // No activity. // Let the sync interval creep up by 10% of the min-max range. this.syncInterval += range * 0.1; } // Keep the sync interval constrained between min and max. this.syncInterval = Math.max(this.minSyncInterval, this.syncInterval); this.syncInterval = Math.min(this.maxSyncInterval, this.syncInterval); } /** * Start sharing the specified object(s). */ public void share(ShareObj ... shareObjs) { for (int i = 0; i < shareObjs.length; i++) { ShareObj shareObj = shareObjs[i]; shareObj.mobwrite = this; this.shared.put(shareObj.file, shareObj); this.logger.log(Level.INFO, "Sharing shareObj: \"" + shareObj.file + "\""); } try { this.start(); } catch (IllegalThreadStateException e) { // Thread already started. } } /** * Stop sharing the specified object(s). */ public void unshare(ShareObj ... shareObjs) { for (int i = 0; i < shareObjs.length; i++) { ShareObj shareObj = this.shared.remove(shareObjs[i].file); if (shareObj == null) { this.logger.log(Level.INFO, "Ignoring \"" + shareObjs[i].file + "\". Not currently shared."); } else { this.logger.log(Level.INFO, "Unshared: \"" + shareObj.file + "\""); } } } /** * Stop sharing the specified file ID(s). */ public void unshare(String ... shareFiles) { for (int i = 0; i < shareFiles.length; i++) { ShareObj shareObj = this.shared.remove(shareFiles); if (shareObj == null) { this.logger.log(Level.INFO, "Ignoring \"" + shareFiles[i] + "\". Not currently shared."); } else { this.logger.log(Level.INFO, "Unshared: \"" + shareObj.file + "\""); } } } /** * Main execution loop for MobWrite syncronization. */ public void run() { while(!this.shared.isEmpty()) { this.syncRun1_(); this.computeSyncInterval_(); try { Thread.sleep(this.syncInterval); } catch (InterruptedException e) { return; } } this.logger.log(Level.INFO, "MobWrite task stopped."); } /** * Unescape selected chars for compatibility with JavaScript's encodeURI. * In speed critical applications this could be dropped since the * receiving application will certainly decode these fine. * Note that this function is case-sensitive. Thus "%3f" would not be * unescaped. But this is ok because it is only called with the output of * URLEncoder.encode which returns uppercase hex. * * Example: "%3F" -> "?", "%24" -> "$", etc. * * @param str The string to escape. * @return The escaped string. */ protected static String unescapeForEncodeUriCompatability(String str) { return str.replace("%21", "!").replace("%7E", "~") .replace("%27", "'").replace("%28", "(").replace("%29", ")") .replace("%3B", ";").replace("%2F", "/").replace("%3F", "?") .replace("%3A", ":").replace("%40", "@").replace("%26", "&") .replace("%3D", "=").replace("%2B", "+").replace("%24", "$") .replace("%2C", ",").replace("%23", "#"); } } whiteboard/mobwrite/java-client/DemoEditorApplet.java0000644000175000017500000000633512251036356022277 0ustar ernieerniepackage name.fraser.neil.mobwrite; import java.awt.Font; import javax.swing.JApplet; import javax.swing.JLabel; import javax.swing.JScrollPane; import javax.swing.JTextArea; import javax.swing.JTextField; import javax.swing.SpringLayout; import javax.swing.SwingUtilities; public class DemoEditorApplet extends JApplet { private JTextField demo_editor_title; private JTextArea demo_editor_text; @Override public void init() { //Execute a job on the event-dispatching thread: //creating this applet's GUI. try { SwingUtilities.invokeAndWait(new Runnable() { public void run() { createGUI(); MobWriteClient mobwrite = new MobWriteClient(); mobwrite.syncGateway = getParameter("syncGateway"); if (mobwrite.syncGateway == null) { mobwrite.syncGateway = "http://mobwrite3.appspot.com/scripts/q.py"; } try { mobwrite.maxSyncInterval = Integer.parseInt(getParameter("maxSyncInterval")); } catch (Exception e) { // Ignore, use default. } try { mobwrite.minSyncInterval = Integer.parseInt(getParameter("minSyncInterval")); } catch (Exception e) { // Ignore, use default. } ShareObj shareTitle = new ShareJTextComponent(demo_editor_title, "demo_editor_title"); ShareObj shareText = new ShareJTextComponent(demo_editor_text, "demo_editor_text"); mobwrite.share(shareTitle, shareText); } }); } catch (Exception e) { e.printStackTrace(); } } private void createGUI() { SpringLayout springLayout = new SpringLayout(); getContentPane().setLayout(springLayout); JLabel label = new JLabel("MobWrite as a Collaborative Editor"); label.setFont(new Font("SansSerif", Font.PLAIN, 18)); springLayout.putConstraint(SpringLayout.NORTH, label, 10, SpringLayout.NORTH, getContentPane()); springLayout.putConstraint(SpringLayout.WEST, label, 10, SpringLayout.WEST, getContentPane()); springLayout.putConstraint(SpringLayout.EAST, label, -10, SpringLayout.EAST, getContentPane()); getContentPane().add(label); demo_editor_title = new JTextField(); springLayout.putConstraint(SpringLayout.NORTH, demo_editor_title, 6, SpringLayout.SOUTH, label); springLayout.putConstraint(SpringLayout.WEST, demo_editor_title, 10, SpringLayout.WEST, getContentPane()); springLayout.putConstraint(SpringLayout.EAST, demo_editor_title, -10, SpringLayout.EAST, getContentPane()); getContentPane().add(demo_editor_title); JScrollPane scrollPane = new JScrollPane(); springLayout.putConstraint(SpringLayout.NORTH, scrollPane, 6, SpringLayout.SOUTH, demo_editor_title); springLayout.putConstraint(SpringLayout.WEST, scrollPane, 10, SpringLayout.WEST, getContentPane()); springLayout.putConstraint(SpringLayout.EAST, scrollPane, -10, SpringLayout.EAST, getContentPane()); springLayout.putConstraint(SpringLayout.SOUTH, scrollPane, -10, SpringLayout.SOUTH, getContentPane()); getContentPane().add(scrollPane); demo_editor_text = new JTextArea(); demo_editor_text.setLineWrap(true); demo_editor_text.setWrapStyleWord(true); scrollPane.setViewportView(demo_editor_text); } } whiteboard/mobwrite/java-client/ShareAbstractButton.java0000644000175000017500000000246012251036356023013 0ustar ernieerniepackage name.fraser.neil.mobwrite; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import javax.swing.AbstractButton; class ShareAbstractButton extends ShareObj { /** * The user-facing button/checkbox component to be shared. */ private AbstractButton abstractButton; /** * Constructor of shared object representing a checkbox. * @param ab Button/checkbox component to share. * @param file Filename to share as. */ public ShareAbstractButton(AbstractButton ab, String file) { super(file); this.abstractButton = ab; this.mergeChanges = false; } /** * Retrieve the user's check. * @return Plaintext content. */ public String getClientText() { return this.abstractButton.isSelected() ? this.abstractButton.getName() : ""; } /** * Set the user's check. * @param text New content. */ public void setClientText(String text) { this.abstractButton.setSelected(text.equals(this.abstractButton.getName())); // Fire any events. String actionCommand = this.abstractButton.getActionCommand(); ActionEvent e = new ActionEvent(this.abstractButton, ActionEvent.ACTION_PERFORMED, actionCommand, 0); for (ActionListener listener : this.abstractButton.getActionListeners()) { listener.actionPerformed(e); } } } whiteboard/mobwrite/java-client/ShareJList.java0000644000175000017500000000316612251036356021105 0ustar ernieerniepackage name.fraser.neil.mobwrite; import java.util.Vector; import javax.swing.JList; import javax.swing.ListModel; import javax.swing.ListSelectionModel; class ShareJList extends ShareObj { /** * The user-facing list component to be shared. */ private JList jList; /** * Constructor of shared object representing a select box. * @param jl List component to share. * @param file Filename to share as. */ public ShareJList(JList jl, String file) { super(file); this.jList = jl; this.mergeChanges = jl.getSelectionMode() != ListSelectionModel.SINGLE_SELECTION; } /** * Retrieve the user's selection(s). * @return Plaintext content. */ public String getClientText() { StringBuilder sb = new StringBuilder(); Object[] selected = this.jList.getSelectedValues(); boolean empty = true; for (Object item : selected) { if (!empty) { sb.append('\0'); } sb.append(item); empty = false; } return sb.toString(); } /** * Set the user's selection(s). * @param text New content. */ public void setClientText(String text) { text = '\0' + text + '\0'; ListModel list = this.jList.getModel(); Vector selectedVector = new Vector(); for (int i = 0; i < list.getSize(); i++) { if (text.indexOf(list.getElementAt(i).toString()) != -1) { selectedVector.add(i); } } int[] selectedArray = new int[selectedVector.size()]; int i = 0; for (int selected : selectedVector) { selectedArray[i] = selected; i++; } this.jList.setSelectedIndices(selectedArray); } } whiteboard/test_server.py0000644000175000017500000002446212251036335015123 0ustar ernieernieimport BaseHTTPServer import CGIHTTPServer #server = import CGIHTTPServer for name in dir(CGIHTTPServer): globals()[name] = getattr(CGIHTTPServer, name) import re import sys if len(sys.argv) > 1: port = int(sys.argv[1]) else: port = 8080 class server(CGIHTTPServer.CGIHTTPRequestHandler, object): def rewrite_url(self): self.oldpath = self.path self.path = re.sub(r'^/test$', r'/test.sh', self.path) self.path = re.sub(r'^/[a-zA-Z0-9._+=-]+\.wb$', r'/wb.py.cgi', self.path) self.path = re.sub(r'^/([a-zA-Z0-9._+=-]+)\.txt$', r'/data/wb_\1.txt', self.path) self.path = re.sub(r'^/[a-zA-Z0-9._+=-]+\.(rst|textile|rawtxt|rawtxt2)$', r'/wb.py.cgi', self.path) self.path = re.sub(r'^/[a-zA-Z0-9._+=-]+\.txt\?.*', r'/wb.py.cgi', self.path) self.path = re.sub(r'^/sync$', r'/q.py.cgi', self.path) print self.path, self.oldpath def do_POST(self): self.rewrite_url() super(server, self).do_POST() def do_GET(self): self.rewrite_url() super(server, self).do_GET() # The only thing added below is: # env['REQUEST_URI'] = self.oldpath def run_cgi(self): """Execute a CGI script.""" path = self.path dir, rest = self.cgi_info i = path.find('/', len(dir) + 1) while i >= 0: nextdir = path[:i] nextrest = path[i+1:] scriptdir = self.translate_path(nextdir) if os.path.isdir(scriptdir): dir, rest = nextdir, nextrest i = path.find('/', len(dir) + 1) else: break # find an explicit query string, if present. i = rest.rfind('?') if i >= 0: rest, query = rest[:i], rest[i+1:] else: query = '' # dissect the part after the directory name into a script name & # a possible additional path, to be stored in PATH_INFO. i = rest.find('/') if i >= 0: script, rest = rest[:i], rest[i:] else: script, rest = rest, '' scriptname = dir + '/' + script scriptfile = self.translate_path(scriptname) if not os.path.exists(scriptfile): self.send_error(404, "No such CGI script (%r)" % scriptname) return if not os.path.isfile(scriptfile): self.send_error(403, "CGI script is not a plain file (%r)" % scriptname) return ispy = self.is_python(scriptname) if not ispy: if not (self.have_fork or self.have_popen2 or self.have_popen3): self.send_error(403, "CGI script is not a Python script (%r)" % scriptname) return if not self.is_executable(scriptfile): self.send_error(403, "CGI script is not executable (%r)" % scriptname) return # Reference: http://hoohoo.ncsa.uiuc.edu/cgi/env.html # XXX Much of the following could be prepared ahead of time! env = {} env['SERVER_SOFTWARE'] = self.version_string() env['SERVER_NAME'] = self.server.server_name env['GATEWAY_INTERFACE'] = 'CGI/1.1' env['SERVER_PROTOCOL'] = self.protocol_version env['SERVER_PORT'] = str(self.server.server_port) env['REQUEST_METHOD'] = self.command uqrest = urllib.unquote(rest) env['REQUEST_URI'] = self.oldpath env['PATH_INFO'] = uqrest env['PATH_TRANSLATED'] = self.translate_path(uqrest) env['SCRIPT_NAME'] = scriptname if query: env['QUERY_STRING'] = query host = self.address_string() if host != self.client_address[0]: env['REMOTE_HOST'] = host env['REMOTE_ADDR'] = self.client_address[0] authorization = self.headers.getheader("authorization") if authorization: authorization = authorization.split() if len(authorization) == 2: import base64, binascii env['AUTH_TYPE'] = authorization[0] if authorization[0].lower() == "basic": try: authorization = base64.decodestring(authorization[1]) except binascii.Error: pass else: authorization = authorization.split(':') if len(authorization) == 2: env['REMOTE_USER'] = authorization[0] # XXX REMOTE_IDENT if self.headers.typeheader is None: env['CONTENT_TYPE'] = self.headers.type else: env['CONTENT_TYPE'] = self.headers.typeheader length = self.headers.getheader('content-length') if length: env['CONTENT_LENGTH'] = length accept = [] for line in self.headers.getallmatchingheaders('accept'): if line[:1] in "\t\n\r ": accept.append(line.strip()) else: accept = accept + line[7:].split(',') env['HTTP_ACCEPT'] = ','.join(accept) ua = self.headers.getheader('user-agent') if ua: env['HTTP_USER_AGENT'] = ua co = filter(None, self.headers.getheaders('cookie')) if co: env['HTTP_COOKIE'] = ', '.join(co) # XXX Other HTTP_* headers # Since we're setting the env in the parent, provide empty # values to override previously set values for k in ('QUERY_STRING', 'REMOTE_HOST', 'CONTENT_LENGTH', 'HTTP_USER_AGENT', 'HTTP_COOKIE'): env.setdefault(k, "") os.environ.update(env) self.send_response(200, "Script output follows") decoded_query = query.replace('+', ' ') if self.have_fork: # Unix -- fork as we should args = [script] if '=' not in decoded_query: args.append(decoded_query) nobody = nobody_uid() self.wfile.flush() # Always flush before forking pid = os.fork() if pid != 0: # Parent pid, sts = os.waitpid(pid, 0) # throw away additional data [see bug #427345] while select.select([self.rfile], [], [], 0)[0]: if not self.rfile.read(1): break if sts: self.log_error("CGI script exit status %#x", sts) return # Child try: try: os.setuid(nobody) except os.error: pass os.dup2(self.rfile.fileno(), 0) os.dup2(self.wfile.fileno(), 1) os.execve(scriptfile, args, os.environ) except: self.server.handle_error(self.request, self.client_address) os._exit(127) elif self.have_popen2 or self.have_popen3: # Windows -- use popen2 or popen3 to create a subprocess import shutil if self.have_popen3: popenx = os.popen3 else: popenx = os.popen2 cmdline = scriptfile if self.is_python(scriptfile): interp = sys.executable if interp.lower().endswith("w.exe"): # On Windows, use python.exe, not pythonw.exe interp = interp[:-5] + interp[-4:] cmdline = "%s -u %s" % (interp, cmdline) if '=' not in query and '"' not in query: cmdline = '%s "%s"' % (cmdline, query) self.log_message("command: %s", cmdline) try: nbytes = int(length) except (TypeError, ValueError): nbytes = 0 files = popenx(cmdline, 'b') fi = files[0] fo = files[1] if self.have_popen3: fe = files[2] if self.command.lower() == "post" and nbytes > 0: data = self.rfile.read(nbytes) fi.write(data) # throw away additional data [see bug #427345] while select.select([self.rfile._sock], [], [], 0)[0]: if not self.rfile._sock.recv(1): break fi.close() shutil.copyfileobj(fo, self.wfile) if self.have_popen3: errors = fe.read() fe.close() if errors: self.log_error('%s', errors) sts = fo.close() if sts: self.log_error("CGI script exit status %#x", sts) else: self.log_message("CGI script exited OK") else: # Other O.S. -- execute script in this process save_argv = sys.argv save_stdin = sys.stdin save_stdout = sys.stdout save_stderr = sys.stderr try: save_cwd = os.getcwd() try: sys.argv = [scriptfile] if '=' not in decoded_query: sys.argv.append(decoded_query) sys.stdout = self.wfile sys.stdin = self.rfile execfile(scriptfile, {"__name__": "__main__"}) finally: sys.argv = save_argv sys.stdin = save_stdin sys.stdout = save_stdout sys.stderr = save_stderr os.chdir(save_cwd) except SystemExit, sts: self.log_error("CGI script exit status %s", str(sts)) else: self.log_message("CGI script exited OK") server.cgi_directories = ['/test.sh', '/wb.py.cgi', '/q.py.cgi', '/q.py.mpy'] #print server.cgi_directories #for k,v in server.extensions_map.items(): # print k, v server.extensions_map[''] = 'text/plain' server.extensions_map['.sh'] = 'text/plain' server.extensions_map['.py'] = 'text/plain' httpd = BaseHTTPServer.HTTPServer(('',port), server) httpd.serve_forever() whiteboard/rundaemon.py0000644000175000017500000000113612251036335014537 0ustar ernieernie#!/usr/bin/env python # Richard Darst, May 2009 import sys sys.path[0:0] = 'mobwrite/daemon/lib', 'mobwrite/daemon' import mobwrite_core import mobwrite_daemon del sys.path[0:2] mobwrite_daemon.MAX_VIEWS = 100 mobwrite_daemon.STORAGE_MODE = mobwrite_daemon.FILE mobwrite_daemon.LOCAL_PORT = 30711 # adjust mobwrite_core.MAX_CHARS = 30000 mobwrite_core.TIMEOUT_TEXT = mobwrite_core.datetime.timedelta(days=31) mobwrite_core.LOG.setLevel(mobwrite_core.logging.DEBUG) # this is from __main__ in mobwrite_daemon mobwrite_core.logging.basicConfig() mobwrite_daemon.main() mobwrite_core.logging.shutdown() whiteboard/wb.html.template0000644000175000017500000000122312251036335015302 0ustar ernieernie %(id)s - whiteboard
Static link, Options %(pageNews)s whiteboard/wb.py0000644000175000017500000001532212251036335013161 0ustar ernieernie#!/usr/bin/env python # Richard Darst, March 2009 import os import re import sys sys.path.insert(0, 'mobwrite/tools') import mobwritelib del sys.path[0] class WhiteboardPage(object): encoding = "utf-8" template_wb = 'wb.html.template' template_options = 'options.html.template' # Relative URL to the sync gateway. syncGateway = "mobwrite.syncGateway='q.py.mpy';\n" #syncGateway = "mobwrite.syncGateway='q.py.cgi';\n" syncServerGateway = 'telnet://localhost:30711' # additional banner at the bottom. It is HTML. Edit to your desires. pageNews = "" def __init__(self, id, options=None): self.id = id self.options = options def __getitem__(self, key): """Mapping (dictionary) interface""" # This is needed so that template % self will work return getattr(self, key) @property def mobID(self): """The mobwrite ID, which is the text area ID""" return 'wb_' + self.id @property def staticLink(self): """URL to link for for .txt copy""" return self.id + '.txt' @property def textarea_style(self): style = "height:95%" if hasattr(self, 'options') and self.options.getfirst('cols'): try: cols = int(self.options.getfirst('cols')) return 'style="height: 95%%" cols="%d"'%cols except ValueError: pass return 'style="width: 100%; height:95%"' @property def raw_text(self): """Raw text as retrieved by the server.""" return mobwritelib.download(self.syncServerGateway,[self.mobID])[self.mobID] @property def raw_text2(self): """Raw text as retrieved from the filesystem""" return open(self._filename).read() @property def _filename(self): """Filename of storage on the filesystem""" return "data/wb_" + self.id + ".txt" @property def wordcounts(self): data = self.raw_text chars = len(data) words = len(data.split()) lines = len(data.split("\n")) return "%d %d %d"%(chars, words, lines) def exists(self): """Return True if this whiteboard id already""" if os.access(self._filename, os.F_OK): return True return False # Render methods for various outputs. $id.$ext -> ext_$ext() def ext_wb(self): """The editable whiteboard form""" header = "Content-Type: text/html; charset=%s\n\n"%self.encoding page = open(self.template_wb).read() page = page%self return header + page def ext_opts(self): """Controllable whiteboard options""" header = "Content-Type: text/html; charset=%s\n\n"%self.encoding page = open(self.template_options).read() page = page%self return header + page def ext_txt(self): """Raw text contained in this whiteboard, text/plain""" text = self.raw_text header = "Content-Type: text/plain; charset=%s\n\n"%self.encoding # Wrap text? if hasattr(self, 'options') and self.options.getfirst('wrap'): wrap = self.options.getfirst('wrap') try: width = int(wrap) except ValueError: width = 72 newText = [] import textwrap for line in text.split("\n"): newText.append(textwrap.fill(line, width=width)) text = "\n".join(newText) return header + text.encode('utf-8') ext_txt2 = ext_txt def ext_textile(self): """Render the whiteboard using textile.""" header = "Content-Type: text/html; charset=%s\n\n"%self.encoding import textile text = self.raw_text text = text.encode('utf-8') text = textile.textile(text, sanitize=True, encoding='utf-8') return header + text def ext_rst(self): """Render the whiteboard using ReStructuredText""" header = "Content-Type: text/html; charset=%s\n\n"%self.encoding from docutils.core import publish_string text = self.raw_text # text = text.encode('utf-8', 'replace') text = publish_string( source=text, settings_overrides={'file_insertion_enabled': 0, 'raw_enabled': 0, #'output_encoding':'utf-8', }, writer_name='html') # text = text.encode('utf-8', 'replace') return header + text def ext_md(self): header = "Content-Type: text/html; charset=%s\n\n"%self.encoding import markdown text = self.raw_text #text = self.raw_text2 #text = text.decode('utf-8', 'replace') html = markdown.markdown(text, safe_mode="escape") html = html.encode('utf-8', 'replace') return header + html def ext_rawtxt(self): header = "Content-Type: text/plain; charset=%s\n\n"%self.encoding text = self.raw_text text = text.encode('utf-8', 'replace') return header + text def ext_rawtxt2(self): header = "Content-Type: text/plain; charset=%s\n\n"%self.encoding text = self.raw_text2 # text = text.encode('utf-8', 'replace') return header + text def randompageRedirect(data): import random exactName = data.getfirst("exactname") if exactName: wbName = exactName else: prefix = data.getfirst("prefix") while True: wbName = hex(int(random.uniform(0, 16**6)))[2:] if prefix: wbName = prefix + "_" + wbName if not WhiteboardPage(id=wbName).exists(): break urlName = wbName + ".wb" response = ["303 See Other:", "Location: "+urlName, "Content-Type: text/html", "", "Your new page is at "+urlName, ] response = "\n".join(response) return response import cgi import cgitb ; cgitb.enable() data = cgi.FieldStorage() # If asking for a new page, do that redirect if data.getfirst("new"): print randompageRedirect(data) sys.exit(0) pageName = os.environ["REQUEST_URI"] m = re.match("^.*/([a-zA-z0-9._+=-]+)\.([a-zA-Z0-9]+)", pageName) # Handle bad URLs if not m: print "Content-Type: text/plain" print print dir(data) print data.file print os.path.basename(pageName) print "Greetings, Utahraptor." id_ = m.group(1) ext = m.group(2) if hasattr(WhiteboardPage, 'ext_'+ext): wbp = WhiteboardPage(id_, options=data) print getattr(wbp, 'ext_'+ext)() elif data.getfirst('markup') == "rst": print WhiteboardPage(id_, options=data).rst() elif data.getfirst('markup'): print WhiteboardPage(id_, options=data).textile() else: print 'x' whiteboard/README0000644000175000017500000000367012251036335013062 0ustar ernieernie# Install whiteboard. If you are reading this on your local computer, # it may already be installed. darcs get http://code.zgib.net/whiteboard/ cd whiteboard/ # Install mobwrite. Default installion assumes it is in ./mobwrite . # There are local changes to this copy needed for saving properly darcs get http://code.zgib.net/mobwrite/ ls mobwrite/ # At this point the directory layouts should look like this: # ./whiteboard/ # ./whiteboard/mobwrite/ # ./whiteboard/ is the web server root or directory that is being served. # # Set up some file links. # ln -s mobwrite/compressed_form.js . ln -s htaccess .htaccess ln -s wb.py wb.py.cgi # make it a cgi script chmod a+x wb.py mkdir data/ chown USER data/ # You need to make q.py be a cgi script. This gets hit *very* often (every second or so. It's the sync gateway ln -s q.py q.py.mpy # make it a mod_python script for speed ln -s q.py q.py.cgi # Or, you can make it a plain cgi script chmod a+x q.py # Look at .htaccess and see what it does. You don't have to use this, # htaccess, you should integrate it with your server however you'd like to... # Basically, NAME.wb gets remapped to wb.py.cgi # NAME.txt gets remapped to data/wb_NAME.txt # We make sure not to log access to q.* since there are MANY of them # Define the various things to be CGI. # # Local configuration # # syncGateway is what the user's browser uses to communicate with the web # server. # The syncGateway needs to be adjusted in wb.py (leave it blank for the # default, q.py.mpy in the present directory(?)) emacs wb.py # PORT is what is used to communicate between the sync gateway (q.py) and # the mobwrite daemon running on localhost. # The port needs to be configured in three places: emacs q.py rundaemon.py # # Running it # # This is kind of a hack, but runs mobwrite from within the mobwrite/ # directory. Run this as the user that owns the data/ directory. python rundaemon.py