Show changeset comments in the history dialogue
[potlatch2.git] / net / systemeD / halcyon / connection / XMLConnection.as
1 package net.systemeD.halcyon.connection {
2
3     import flash.events.*;
4         import mx.rpc.http.HTTPService;
5         import mx.rpc.events.*;
6         import flash.system.Security;
7         import flash.net.*;
8     import org.iotashan.oauth.*;
9
10         import net.systemeD.halcyon.AttentionEvent;
11         import net.systemeD.halcyon.MapEvent;
12         import net.systemeD.halcyon.ExtendedURLLoader;
13     import net.systemeD.halcyon.connection.bboxes.*;
14
15     /**
16     * XMLConnection provides all the methods required to connect to a live
17     * OSM server. See OSMConnection for connecting to a read-only .osm file
18     *
19     * @see OSMConnection
20     */
21         public class XMLConnection extends XMLBaseConnection {
22
23                 private const MARGIN:Number=0.05;
24
25         /**
26         * Create a new XML connection
27         * @param name The name of the connection
28         * @param api The url of the OSM API server, e.g. http://api06.dev.openstreetmap.org/api/0.6/
29         * @param policy The url of the flash crossdomain policy to load,
30                         e.g. http://api06.dev.openstreetmap.org/api/crossdomain.xml
31         * @param initparams Any further parameters for the connection, such as the serverName
32         */
33                 public function XMLConnection(name:String,api:String,policy:String,initparams:Object) {
34
35                         super(name,api,policy,initparams);
36                         if (policyURL != "") Security.loadPolicyFile(policyURL);
37
38             var oauthPolicy:String = getParam("oauth_policy", "");
39             if (oauthPolicy != "") Security.loadPolicyFile(oauthPolicy);
40                 }
41                 
42                 override public function loadBbox(left:Number,right:Number,
43                                                                 top:Number,bottom:Number):void {
44             purgeIfFull(left,right,top,bottom);
45                         var requestBox:Box=new Box().fromBbox(left,bottom,right,top);
46                         var boxes:Array;
47                         try {
48                                 boxes=fetchSet.getBoxes(requestBox,MAX_BBOXES);
49                         } catch(err:Error) {
50                                 boxes=[requestBox];
51                         }
52                         for each (var box:Box in boxes) {
53                                 // enlarge bbox by given margin on each edge
54                                 var xmargin:Number=(box.right-box.left)*MARGIN;
55                                 var ymargin:Number=(box.top-box.bottom)*MARGIN;
56                                 left  =box.left  -xmargin; right=box.right+xmargin;
57                                 bottom=box.bottom-ymargin; top  =box.top  +ymargin;
58
59                                 dispatchEvent(new MapEvent(MapEvent.DOWNLOAD, {minlon:left, maxlon:right, maxlat:top, minlat:bottom} ));
60
61                                 // send HTTP request
62                                 var mapVars:URLVariables = new URLVariables();
63                                 mapVars.bbox=left+","+bottom+","+right+","+top;
64                                 var mapRequest:URLRequest = new URLRequest(apiBaseURL+"map");
65                                 mapRequest.data = mapVars;
66                                 sendLoadRequest(mapRequest);
67                         }
68                 }
69
70                 override public function loadEntityByID(type:String, id:Number):void {
71                         var url:String=apiBaseURL + type + "/" + id;
72                         if (type=='way') url+="/full";
73                         sendLoadRequest(new URLRequest(url));
74                 }
75
76                 private function sendLoadRequest(request:URLRequest):void {
77                         var mapLoader:URLLoader = new URLLoader();
78             var errorHandler:Function = function(event:IOErrorEvent):void {
79                 errorOnMapLoad(event, request);
80             }
81                         mapLoader.addEventListener(Event.COMPLETE, loadedMap);
82                         mapLoader.addEventListener(IOErrorEvent.IO_ERROR, errorHandler);
83                         mapLoader.addEventListener(HTTPStatusEvent.HTTP_STATUS, mapLoadStatus);
84             request.requestHeaders.push(new URLRequestHeader("X-Error-Format", "XML"));
85                         mapLoader.load(request);
86                         dispatchEvent(new Event(LOAD_STARTED));
87                 }
88
89         private function errorOnMapLoad(event:Event, request:URLRequest):void {
90             var url:String = request.url + '?' + URLVariables(request.data).toString(); // for get reqeusts, at least
91             dispatchEvent(new MapEvent(MapEvent.ERROR, { message: "There was a problem loading the map data.\nPlease check your internet connection, or try zooming in.\n\n" + url } ));
92             dispatchEvent(new Event(LOAD_COMPLETED));
93         }
94
95         private function mapLoadStatus(event:HTTPStatusEvent):void {
96         }
97
98         protected var appID:OAuthConsumer;
99         protected var authToken:OAuthToken;
100
101             override public function setAuthToken(id:Object):void {
102                 authToken = OAuthToken(id);
103             }
104
105         override public function hasAccessToken():Boolean {
106             return !(getAccessToken() == null);
107         }
108
109         override public function setAccessToken(key:String, secret:String):void {
110             if (key && secret) {
111               authToken = new OAuthToken(key, secret);
112             }
113         }
114
115         /* Get the stored access token, or try setting it up from loader params */
116         private function getAccessToken():OAuthToken {
117             if (authToken == null) {
118               var key:String = getParam("oauth_token", null);
119               var secret:String = getParam("oauth_token_secret", null);
120
121               if ( key != null && secret != null ) {
122                   authToken = new OAuthToken(key, secret);
123               }
124             }
125             return authToken;
126         }
127
128         private function getConsumer():OAuthConsumer {
129             if (appID == null) {
130               var key:String = getParam("oauth_consumer_key", null);
131               var secret:String = getParam("oauth_consumer_secret", null);
132
133               if ( key != null && secret != null ) {
134                   appID = new OAuthConsumer(key, secret);
135               }
136             }
137             return appID;
138         }
139
140         private var httpStatus:int = 0;
141         
142         private function recordStatus(event:HTTPStatusEvent):void {
143             httpStatus = event.status;
144         }
145         
146         private var lastUploadedChangesetTags:Object;
147         
148         override public function createChangeset(tags:Object):void {
149             lastUploadedChangesetTags = tags;
150             
151                 var changesetXML:XML = <osm version="0.6"><changeset /></osm>;
152                 var changeset:XML = <changeset />;
153                 for (var tagKey:Object in tags) {
154               var tagXML:XML = <tag/>;
155               tagXML.@k = tagKey;
156               tagXML.@v = tags[tagKey];
157               changesetXML.changeset.appendChild(tagXML);
158             }        
159
160                         sendOAuthPut(apiBaseURL+"changeset/create",
161                                                  changesetXML,
162                                                  changesetCreateComplete, changesetCreateError, recordStatus);
163             }
164
165         private function changesetCreateComplete(event:Event):void {
166             var result:String = URLLoader(event.target).data;
167
168             if (result.match(/^^\d+$/)) {
169                 // response should be a Number changeset id
170                 var id:Number = Number(URLLoader(event.target).data);
171             
172                 // which means we now have a new changeset!
173                 setActiveChangeset(new Changeset(this, id, lastUploadedChangesetTags));
174             } else {
175                 var results:XML = XML(result);
176
177                 throwServerError(results.message);
178             }
179         }
180
181         private function changesetCreateError(event:IOErrorEvent):void {
182             dispatchEvent(new Event(NEW_CHANGESET_ERROR));
183         }
184
185                 override public function closeChangeset():void {
186             var cs:Changeset = getActiveChangeset();
187                         if (!cs) return;
188                         
189                         sendOAuthPut(apiBaseURL+"changeset/"+cs.id+"/close",
190                                                  null,
191                                                  changesetCloseComplete, changesetCloseError, recordStatus);
192                         closeActiveChangeset();
193                 }
194                 
195                 private function changesetCloseComplete(event:Event):void { 
196                         dispatchEvent(new AttentionEvent(AttentionEvent.ALERT, null, "Changeset closed"));
197                 }
198                 private function changesetCloseError(event:Event):void { 
199                         dispatchEvent(new AttentionEvent(AttentionEvent.ALERT, null, "Couldn't close changeset", 1));
200                 }
201
202         private function signedOAuthURL(url:String, method:String):String {
203             // method should be PUT, GET, POST or DELETE
204             var sig:IOAuthSignatureMethod = new OAuthSignatureMethod_HMAC_SHA1();
205             var oauthRequest:OAuthRequest = new OAuthRequest(method, url, null, getConsumer(), authToken);
206             var urlStr:Object = oauthRequest.buildRequest(sig, OAuthRequest.RESULT_TYPE_URL_STRING);
207             return String(urlStr);
208         }
209
210                 private function sendOAuthPut(url:String, xml:XML, onComplete:Function, onError:Function, onStatus:Function):void {
211             // build the request
212             var urlReq:URLRequest = new URLRequest(signedOAuthURL(url, "PUT"));
213             urlReq.method = "POST";
214                         if (xml) { urlReq.data = xml.toXMLString(); } else { urlReq.data = true; }
215             urlReq.contentType = "application/xml";
216             urlReq.requestHeaders = [ new URLRequestHeader("X_HTTP_METHOD_OVERRIDE", "PUT"), 
217                                                   new URLRequestHeader("X-Error-Format", "XML") ];
218             var loader:URLLoader = new URLLoader();
219             loader.addEventListener(Event.COMPLETE, onComplete);
220             loader.addEventListener(IOErrorEvent.IO_ERROR, onError);
221             loader.addEventListener(HTTPStatusEvent.HTTP_STATUS, onStatus);
222                 loader.load(urlReq);
223                 }
224
225         private function sendOAuthGet(url:String, onComplete:Function, onError:Function, onStatus:Function):void {
226             var urlReq:URLRequest = new URLRequest(signedOAuthURL(url, "GET"));
227             urlReq.method = "GET";
228             var loader:URLLoader = new URLLoader();
229             loader.addEventListener(Event.COMPLETE, onComplete);
230             loader.addEventListener(IOErrorEvent.IO_ERROR, onError);
231             loader.addEventListener(HTTPStatusEvent.HTTP_STATUS, onStatus);
232             loader.load(urlReq);
233         }
234
235                 /** Create XML changeset and send it to the server. Returns the XML string for use in the 'Show data' button.
236                     (We don't mind what's returned as long as it implements .toString() ) */
237
238         override public function uploadChanges():* {
239             var changeset:Changeset = getActiveChangeset();
240             var upload:XML = <osmChange version="0.6"/>
241             upload.appendChild(addCreated(changeset, getAllNodeIDs, getNode, serialiseNode));
242             upload.appendChild(addCreated(changeset, getAllWayIDs, getWay, serialiseWay));
243             upload.appendChild(addCreated(changeset, getAllRelationIDs, getRelation, serialiseRelation));
244             upload.appendChild(addModified(changeset, getAllNodeIDs, getNode, serialiseNode));
245             upload.appendChild(addModified(changeset, getAllWayIDs, getWay, serialiseWay));
246             upload.appendChild(addModified(changeset, getAllRelationIDs, getRelation, serialiseRelation));
247             upload.appendChild(addDeleted(changeset, getAllRelationIDs, getRelation, serialiseEntityRoot, false));
248             upload.appendChild(addDeleted(changeset, getAllRelationIDs, getRelation, serialiseEntityRoot, true));
249             upload.appendChild(addDeleted(changeset, getAllWayIDs, getWay, serialiseEntityRoot, false));
250             upload.appendChild(addDeleted(changeset, getAllWayIDs, getWay, serialiseEntityRoot, true));
251             upload.appendChild(addDeleted(changeset, getAllNodeIDs, getNode, serialiseEntityRoot, false));
252             upload.appendChild(addDeleted(changeset, getAllNodeIDs, getNode, serialiseEntityRoot, true));
253
254             // now actually upload them
255             // make an OAuth query
256             var url:String = apiBaseURL+"changeset/" + changeset.id + "/upload";
257
258             // build the actual request
259                         var serv:HTTPService=new HTTPService();
260                         serv.method="POST";
261                         serv.url=signedOAuthURL(url, "POST");
262                         serv.contentType = "text/xml";
263                         serv.headers={'X-Error-Format':'xml'};
264                         serv.request=" ";
265                         serv.resultFormat="e4x";
266                         serv.requestTimeout=0;
267                         serv.addEventListener(ResultEvent.RESULT, diffUploadComplete);
268                         serv.addEventListener(FaultEvent.FAULT, diffUploadIOError);
269                         serv.send(upload);
270                 
271                         dispatchEvent(new Event(SAVE_STARTED));
272                         return upload;
273         }
274
275         private function diffUploadComplete(event:ResultEvent):void {
276                         var results:XML = XML(event.result);
277
278                         // was it an error document?
279                         if (results.name().localName=='osmError') {
280                         dispatchEvent(new SaveCompleteEvent(SAVE_COMPLETED, false));
281                                 diffUploadAPIError(results.status, results.message);
282                                 return;
283                         }
284
285             // response should be XML describing the progress
286             
287             for each( var update:XML in results.child("*") ) {
288                 var oldID:Number = Number(update.@old_id);
289                 var newID:Number = Number(update.@new_id);
290                 var version:uint = uint(update.@new_version);
291                 var type:String = update.name();
292
293                                 if (newID==0) {
294                                         // delete
295                         if      (type == "node"    ) { killNode(oldID); }
296                         else if (type == "way"     ) { killWay(oldID); }
297                         else if (type == "relation") { killRelation(oldID); }
298                                         
299                                 } else {
300                                         // create/update
301                         if      (type == "node"    ) { renumberNode(oldID, newID, version); getNode(newID).markClean(); }
302                         else if (type == "way"     ) { renumberWay(oldID, newID, version); getWay(newID).markClean(); }
303                         else if (type == "relation") { renumberRelation(oldID, newID, version); getRelation(newID).markClean(); }
304                                 }
305             }
306
307             dispatchEvent(new SaveCompleteEvent(SAVE_COMPLETED, true));
308                         freshenActiveChangeset();
309             markClean(); // marks the connection clean. Pressing undo from this point on leads to unexpected results
310             MainUndoStack.getGlobalStack().breakUndo(); // so, for now, break the undo stack
311         }
312
313                 private function diffUploadIOError(event:FaultEvent):void {
314                         trace(event.fault);
315                         dispatchEvent(new MapEvent(MapEvent.ERROR, { message: "Couldn't upload data: "+event.fault.faultString } ));
316                         dispatchEvent(new SaveCompleteEvent(SAVE_COMPLETED, false));
317                 }
318
319                 private function diffUploadAPIError(status:String, message:String):void {
320                         var matches:Array;
321                         switch (status) {
322
323                                 case '409 Conflict':
324                                         if (message.match(/changeset/i)) { throwChangesetError(message); return; }
325                                         matches=message.match(/mismatch.+had: (\d+) of (\w+) (\d+)/i);
326                                         if (matches) { throwConflictError(findEntity(matches[2],matches[3]), Number(matches[1]), message); return; }
327                                         break;
328                                 
329                                 case '410 Gone':
330                                         matches=message.match(/The (\w+) with the id (\d+)/i);
331                                         if (matches) { throwAlreadyDeletedError(findEntity(matches[1],matches[2]), message); return; }
332                                         break;
333                                 
334                                 case '412 Precondition Failed':
335                                         matches=message.match(/Node (\d+) is still used/i);
336                                         if (matches) { throwInUseError(findEntity('Node',matches[1]), message); return; }
337                                         matches=message.match(/relation (\d+) is used/i);
338                                         if (matches) { throwInUseError(findEntity('Relation',matches[1]), message); return; }
339                                         matches=message.match(/Way (\d+) still used/i);
340                                         if (matches) { throwInUseError(findEntity('Way',matches[1]), message); return; }
341                                         matches=message.match(/Cannot update (\w+) (\d+)/i);
342                                         if (matches) { throwEntityError(findEntity(matches[1],matches[2]), message); return; }
343                                         matches=message.match(/Relation with id (\d+)/i);
344                                         if (matches) { throwEntityError(findEntity('Relation',matches[1]), message); return; }
345                                         matches=message.match(/Way (\d+) requires the nodes/i);
346                                         if (matches) { throwEntityError(findEntity('Way',matches[1]), message); return; }
347                                         throwBugError(message); return;
348                                 
349                                 case '404 Not Found':
350                                         throwBugError(message); return;
351                                         
352                                 case '400 Bad Request':
353                                         matches=message.match(/Element (\w+)\/(\d+)/i);
354                                         if (matches) { throwEntityError(findEntity(matches[1],matches[2]), message); return; }
355                                         matches=message.match(/You tried to add \d+ nodes to way (\d+)/i);
356                                         if (matches) { throwEntityError(findEntity('Way',matches[1]), message); return; }
357                                         throwBugError(message); return;
358                         }
359
360                         // Not caught, so just throw a generic server error
361                         throwServerError(message);
362                 }
363
364         private function addCreated(changeset:Changeset, getIDs:Function, get:Function, serialise:Function):XML {
365             var create:XML = <create version="0.6"/>
366             for each( var id:Number in getIDs() ) {
367                 var entity:Entity = get(id);
368                 if ( id >= 0 || entity.deleted )
369                     continue;
370                     
371                 var xml:XML = serialise(entity);
372                 xml.@changeset = changeset.id;
373                 create.appendChild(xml);
374             }
375             return create.hasComplexContent() ? create : <!-- blank create section -->;
376         }
377
378                 private function addDeleted(changeset:Changeset, getIDs:Function, get:Function, serialise:Function, ifUnused:Boolean):XML {
379             var del:XML = <delete version="0.6"/>
380             if (ifUnused) del.@["if-unused"] = "true";
381             for each( var id:Number in getIDs() ) {
382                 var entity:Entity = get(id);
383                 // creates are already included
384                 if ( id < 0 || !entity.deleted || entity.parentsLoaded==ifUnused)
385                     continue;
386                     
387                 var xml:XML = serialise(entity);
388                 xml.@changeset = changeset.id;
389                 del.appendChild(xml);
390             }
391             return del.hasComplexContent() ? del : <!-- blank delete section -->;
392                 }
393
394         private function addModified(changeset:Changeset, getIDs:Function, get:Function, serialise:Function):XML {
395             var modify:XML = <modify version="0.6"/>
396             for each( var id:Number in getIDs() ) {
397                 var entity:Entity = get(id);
398                 // creates and deletes are already included
399                 if ( id < 0 || entity.deleted || !entity.isDirty )
400                     continue;
401                     
402                 var xml:XML = serialise(entity);
403                 xml.@changeset = changeset.id;
404                 modify.appendChild(xml);
405             }
406             return modify.hasComplexContent() ? modify : <!-- blank modify section -->;
407         }
408
409         private function serialiseNode(node:Node):XML {
410             var xml:XML = serialiseEntityRoot(node); //<node/>
411             serialiseEntityTags(node, xml);
412             xml.@lat = node.lat;
413             xml.@lon = node.lon;
414             return xml;
415         }
416
417         private function serialiseWay(way:Way):XML {
418             var xml:XML = serialiseEntityRoot(way); //<node/>
419             serialiseEntityTags(way, xml);
420             for ( var i:uint = 0; i < way.length; i++ ) {
421                 var nd:XML = <nd/>
422                 nd.@ref = way.getNode(i).id;
423                 xml.appendChild(nd);
424             }
425             return xml;
426         }
427
428         private function serialiseRelation(relation:Relation):XML {
429             var xml:XML = serialiseEntityRoot(relation); //<node/>
430             serialiseEntityTags(relation, xml);
431             for ( var i:uint = 0; i < relation.length; i++ ) {
432                 var relMember:RelationMember = relation.getMember(i);
433                 var member:XML = <member/>
434                 member.@ref = relMember.entity.id;
435                 member.@type = relMember.entity.getType();
436                 member.@role = relMember.role;
437                 xml.appendChild(member);
438             }
439             return xml;
440         }
441         
442                 private function serialiseEntityRoot(entity:Object):XML {
443                         var xml:XML;
444                         if      (entity is Way     ) { xml = <way/> }
445                         else if (entity is Node    ) { xml = <node/> }
446                         else if (entity is Relation) { xml = <relation/> }
447                         xml.@id = entity.id;
448                         xml.@version = entity.version;
449                         return xml;
450                 }
451
452         private function serialiseEntityTags(entity:Entity, xml:XML):void {
453             xml.@id = entity.id;
454             xml.@version = entity.version;
455             for each( var tag:Tag in entity.getTagArray() ) {
456               if (tag.key == 'created_by') {
457                 entity.setTag('created_by', null, MainUndoStack.getGlobalStack().addAction);
458                 continue;
459               }
460               var tagXML:XML = <tag/>
461               tagXML.@k = tag.key;
462               tagXML.@v = tag.value;
463               xml.appendChild(tagXML);
464             }
465         }
466
467         override public function fetchUserTraces(refresh:Boolean=false):void {
468             if (traces_loaded && !refresh) {
469               dispatchEvent(new Event(TRACES_LOADED));
470             } else {
471               sendOAuthGet(apiBaseURL+"user/gpx_files", tracesLoadComplete, errorOnMapLoad, mapLoadStatus); //needs error handlers
472               dispatchEvent(new Event(LOAD_STARTED)); //specific to map or reusable?
473             }
474         }
475
476                 private function tracesLoadComplete(event:Event):void {
477                         var files:XML = new XML(URLLoader(event.target).data);
478                         for each(var traceData:XML in files.gpx_file) {
479                                 var t:Trace = findTrace(traceData.@id);
480                                 if (!t) { t=new Trace(this); addTrace(t); }
481                                 t.fromXML(traceData);
482                         }
483                         traces_loaded = true;
484                         dispatchEvent(new Event(LOAD_COMPLETED));
485                         dispatchEvent(new Event(TRACES_LOADED));
486                 }
487
488         override public function fetchTrace(id:Number, callback:Function):void {
489             sendOAuthGet(apiBaseURL+"gpx/"+id+"/data.xml", 
490                                 function(e:Event):void { 
491                         dispatchEvent(new Event(LOAD_COMPLETED));
492                                         callback(e);
493                                 }, errorOnTraceLoad, mapLoadStatus); // needs error handlers
494             dispatchEvent(new Event(LOAD_STARTED)); //specifc to map or reusable?
495         }
496
497         private function errorOnTraceLoad(event:Event):void {
498             trace("Trace load error");
499             dispatchEvent(new Event(LOAD_COMPLETED));
500                 }
501
502         /** Fetch the history for the given entity. The callback function will be given an array of entities of that type, representing the different versions */
503         override public function fetchHistory(entity:Entity, callback:Function):void {
504             if (entity.id >= 0) {
505               var request:URLRequest = new URLRequest(apiBaseURL + entity.getType() + "/" + entity.id + "/history");
506               var loader:ExtendedURLLoader = new ExtendedURLLoader();
507               loader.addEventListener(Event.COMPLETE, loadedHistory);
508               loader.addEventListener(IOErrorEvent.IO_ERROR, errorOnMapLoad); //needs error handlers
509               loader.addEventListener(HTTPStatusEvent.HTTP_STATUS, mapLoadStatus);
510               loader.info['callback'] = callback; //store the callback so we can use it later
511               loader.load(request);
512               dispatchEvent(new Event(LOAD_STARTED));
513             } else {
514               // objects created locally only have one state, their current one
515               callback([entity]);
516             }
517         }
518
519         private function loadedHistory(event:Event):void {
520             var _xml:XML = new XML(ExtendedURLLoader(event.target).data);
521             var results:Array = [];
522             var dummyConn:Connection = new Connection("dummy", null, null);
523
524             dispatchEvent(new Event(LOAD_COMPLETED));
525
526             // only one type of entity should be returned, but this handles any
527
528             for each(var nodeData:XML in _xml.node) {
529                 var newNode:Node = new Node(
530                     dummyConn,
531                     Number(nodeData.@id),
532                     uint(nodeData.@version),
533                     parseTags(nodeData.tag),
534                     true,
535                     Number(nodeData.@lat),
536                     Number(nodeData.@lon),
537                     Number(nodeData.@uid),
538                     nodeData.@timestamp,
539                     nodeData.@user
540                     );
541                 newNode.lastChangeset=nodeData.@changeset;
542                 results.push(newNode);
543             }
544
545             for each(var wayData:XML in _xml.way) {
546                 var nodes:Array = [];
547                 for each(var nd:XML in wayData.nd) {
548                   nodes.push(new Node(dummyConn,Number(nd.@ref), NaN, null, false, NaN, NaN));
549                 }
550                 var newWay:Way = new Way(
551                     dummyConn,
552                     Number(wayData.@id),
553                     uint(wayData.@version),
554                     parseTags(wayData.tag),
555                     true,
556                     nodes,
557                     Number(wayData.@uid),
558                     wayData.@timestamp,
559                     wayData.@user
560                     );
561                 newWay.lastChangeset=wayData.@changeset;
562                 results.push(newWay);
563             }
564
565             for each(var relData:XML in _xml.relation) {
566                 trace("relation history not implemented");
567             }
568
569             // use the callback we stored earlier, and pass it the results
570             ExtendedURLLoader(event.target).info['callback'](results);
571         }
572         }
573 }