1 /*
  2  * URLContent.js
  3  *
  4  * Sweet Home 3D, Copyright (c) 2024 Space Mushrooms <info@sweethome3d.com>
  5  * 
  6  * This program is free software; you can redistribute it and/or modify
  7  * it under the terms of the GNU General Public License as published by
  8  * the Free Software Foundation; either version 2 of the License, or
  9  * (at your option) any later version.
 10  *
 11  * This program is distributed in the hope that it will be useful,
 12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
 13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
 14  * GNU General Public License for more details.
 15  *
 16  * You should have received a copy of the GNU General Public License
 17  * along with this program; if not, write to the Free Software
 18  * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA02111-1307USA
 19  */
 20 
 21 /**
 22  * Content wrapper for strings used as URLs.
 23  * @param {string} url  the URL from which this content will be read
 24  * @constructor
 25  * @author Emmanuel Puybaret
 26  */
 27 function URLContent(url) {
 28   this.url = url;  
 29 }
 30 
 31 URLContent["__class"] = "com.eteks.sweethome3d.tools.URLContent";
 32 URLContent["__interfaces"] = ["com.eteks.sweethome3d.model.Content"];
 33 
 34 URLContent.urlContents = {};
 35 
 36 /**
 37  * Returns an instance of <code>URLContent</code> matching the given <code>url</code>.
 38  * @param {string} url
 39  * @return {URLContent}
 40  */
 41 URLContent.fromURL = function(url) {
 42   var urlContent = URLContent.urlContents [url];
 43   if (urlContent == null) {
 44     if (url.indexOf(LocalStorageURLContent.LOCAL_STORAGE_PREFIX) === 0) {
 45       urlContent = new LocalStorageURLContent(url);
 46     } else if (url.indexOf(IndexedDBURLContent.INDEXED_DB_PREFIX) === 0) {
 47       urlContent = new IndexedDBURLContent(url);
 48     } else {
 49       urlContent = new URLContent(url);
 50     }
 51     // Keep content in cache
 52     URLContent.urlContents [url] = urlContent;
 53   }
 54   return urlContent;
 55 }
 56 
 57 /**
 58  * Returns the URL of this content.
 59  * @return {string}
 60  */
 61 URLContent.prototype.getURL = function() {
 62   if (typeof document !== "undefined") {
 63     var httpsSchemeIndex = this.url.indexOf("https://");
 64     var httpSchemeIndex = this.url.indexOf("http://");
 65     if (httpsSchemeIndex !== -1
 66         || httpSchemeIndex !== -1) {
 67       var scripts = document.getElementsByTagName("script");
 68       if (scripts && scripts.length > 0) {
 69         var scriptUrl = document.getElementsByTagName("script") [0].src;
 70         var scriptColonSlashIndex = scriptUrl.indexOf("://");
 71         var scriptScheme = scriptUrl.substring(0, scriptColonSlashIndex);
 72         var scheme = httpsSchemeIndex !== -1  ? "https"  : "http";
 73         // If scheme is different from script one, replace scheme and port with script ones to avoid CORS issues
 74         if (scriptScheme != scheme) {
 75           var scriptServer = scriptUrl.substring(scriptColonSlashIndex + "://".length, scriptUrl.indexOf("/", scriptColonSlashIndex + "://".length));
 76           var scriptPort = "";
 77           var colonIndex = scriptServer.indexOf(":");
 78           if (colonIndex > 0) {
 79             scriptPort = scriptServer.substring(colonIndex);
 80             scriptServer = scriptServer.substring(0, colonIndex);
 81           }
 82           var schemeIndex = httpsSchemeIndex !== -1  ? httpsSchemeIndex  : httpSchemeIndex;
 83           var colonSlashIndex = this.url.indexOf("://", schemeIndex);
 84           var fileIndex = this.url.indexOf("/", colonSlashIndex + "://".length);
 85           var server = this.url.substring(colonSlashIndex + "://".length, fileIndex);
 86           if (server.indexOf(":") > 0) {
 87             server = server.substring(0, server.indexOf(":"));
 88           }
 89           if (scriptServer == server) {
 90             return this.url.substring(0, schemeIndex) + scriptScheme + "://" + scriptServer + scriptPort + this.url.substring(fileIndex); 
 91           }
 92         }
 93       }
 94     } 
 95   } 
 96   
 97   return this.url;
 98 }
 99 
100 /**
101  * Retrieves asynchronously an URL of this content usable for JavaScript functions with URL paramater.
102  * @param  {{urlReady: function, urlError: function}} observer optional observer 
103       which <code>urlReady</code> function will be called asynchronously once URL is available. 
104  */
105 URLContent.prototype.getStreamURL = function(observer) {
106   observer.urlReady(this.getURL());
107 }
108 
109 /**
110  * Returns <code>true</code> if this content URL is available to be read. 
111  */
112 URLContent.prototype.isStreamURLReady = function() {
113   return true;
114 }
115 
116 /**
117  * Returns <code>true</code> if the URL stored by this content 
118  * references an entry in a JAR.
119  * @return {boolean}
120  */
121 URLContent.prototype.isJAREntry = function() {
122   return this.url.indexOf("jar:") === 0 && this.url.indexOf("!/") !== -1; 
123 }
124 
125 /**
126  * Returns the URL base of a JAR entry.
127  * @return {string}
128  */
129 URLContent.prototype.getJAREntryURL = function() {
130   if (!this.isJAREntry()) {
131     throw new IllegalStateException("Content isn't a JAR entry");
132   }
133   // Use URL returned by getURL() rather that this.url to get adjusted URL
134   var url = this.getURL(); 
135   return url.substring("jar:".length, url.indexOf("!/"));
136 }
137 
138 /**
139  * Returns the name of a JAR entry. 
140  * If the JAR entry in the URL given at creation time was encoded in application/x-www-form-urlencoded format,
141  * this method will return it unchanged and not decoded.
142  * @return {string}
143  * @throws IllegalStateException if the URL of this content 
144  *                    doesn't reference an entry in a JAR URL.
145  */
146 URLContent.prototype.getJAREntryName = function() {
147   if (!this.isJAREntry()) {
148     throw new IllegalStateException("Content isn't a JAR entry");
149   }
150   return this.url.substring(this.url.indexOf("!/") + 2);
151 }
152 
153 /**
154  * Returns <code>true</code> if the object in parameter is an URL content
155  * that references the same URL as this object.
156  * @return {boolean}
157  */
158 URLContent.prototype.equals = function(obj) {
159   if (obj === this) {
160     return true;
161   } else if (obj instanceof URLContent) {
162     return obj.url == this.url;
163   } else {
164     return false;
165   }
166 }
167 
168 /**
169  * Returns a hash code for this object.
170  * @return {Number}
171  */
172 URLContent.prototype.hashCode = function() {
173   return this.url.split("").reduce(function(a, b) {
174       a = ((a << 5) - a) + b.charCodeAt(0);
175       return a & a;
176     }, 0);
177 }
178 
179 
180 /**
181  * An URL content read from a home stream.
182  * @param {string} url  the URL from which this content will be read
183  * @constructor
184  * @ignore
185  * @author Emmanuel Puybaret
186  */
187 function HomeURLContent(url) {
188   URLContent.call(this, url);
189 }
190 HomeURLContent.prototype = Object.create(URLContent.prototype);
191 HomeURLContent.prototype.constructor = HomeURLContent;
192 
193 HomeURLContent["__class"] = "com.eteks.sweethome3d.io.HomeURLContent";
194 HomeURLContent["__interfaces"] = ["com.eteks.sweethome3d.model.Content"];
195 
196 
197 /**
198  * Content read from a URL with no dependency on other content when this URL is a JAR entry.
199  * @constructor
200  * @ignore
201  * @author Emmanuel Puybaret
202  */
203 function SimpleURLContent(url) {
204   URLContent.call(this, url);
205 }
206 SimpleURLContent.prototype = Object.create(URLContent.prototype);
207 SimpleURLContent.prototype.constructor = SimpleURLContent;
208 
209 SimpleURLContent["__class"] = "com.eteks.sweethome3d.tools.SimpleURLContent";
210 SimpleURLContent["__interfaces"] = ["com.eteks.sweethome3d.model.Content"];
211 
212 
213 /**
214  * Content read from local data. 
215  * Abstract base class for blobs, files, local storage and indexedDB content.
216  * @constructor
217  * @author Emmanuel Puybaret
218  */
219 function LocalURLContent(url) {
220   URLContent.call(this, url);
221   this.savedContent = null;
222 }
223 LocalURLContent.prototype = Object.create(URLContent.prototype);
224 LocalURLContent.prototype.constructor = LocalURLContent;
225 
226 /**
227  * Returns the content saved on server.
228  * @return {URLContent} content on server or <code>null</code> if not saved on server yet 
229  */
230 LocalURLContent.prototype.getSavedContent = function() {
231   return this.savedContent;
232 }
233 
234 /**
235  * Sets the content saved on server.
236  * @param {URLContent} savedContent content on server 
237  */
238 LocalURLContent.prototype.setSavedContent = function(savedContent) {
239   this.savedContent = savedContent;
240 }
241 
242 /**
243  * Retrieves asynchronously an URL of this content usable for JavaScript functions with URL paramater.
244  * @param  {{urlReady: function, urlError: function}} observer optional observer 
245       which <code>urlReady</code> function will be called asynchronously once URL is available. 
246  */
247 LocalURLContent.prototype.getStreamURL = function(observer) {
248   throw new UnsupportedOperationException("LocalURLContent abstract class");
249 }
250 
251 /**
252  * Returns the blob stored by this content, possibly asynchronously if <code>observer</code> parameter is given.
253  * @param  {{blobReady: function, blobError: function}} [observer] optional observer 
254       which blobReady function will be called asynchronously once blob is available. 
255  * @return {Blob} blob content 
256  */
257 LocalURLContent.prototype.getBlob = function(observer) {
258   throw new UnsupportedOperationException("LocalURLContent abstract class");
259 }
260 
261 /**
262  * Writes the blob bound to this content with the request matching <code>writeBlobUrl</code>.
263  * @param {string} writeBlobUrl the URL used to save the blob 
264              (containing possibly %s which will be replaced by <code>blobName</code>)
265  * @param {string|[string]} blobName the name or path used to save the blob, 
266              or an array of values used to format <code>writeBlobUrl</code> including blob name
267  * @param {blobSaved: function(LocalURLContent, blobName)
268            blobError: function} observer called when content is saved or if writing fails
269  * @return {abort: function} an object containing <code>abort</code> method to abort the write operation
270  */
271 LocalURLContent.prototype.writeBlob = function(writeBlobUrl, blobName, observer) { 
272   var content = this;
273   var abortableOperations = [];
274   this.getBlob({
275       blobReady: function(blob) {
276         var formatArguments;        
277         if (Array.isArray(blobName)) {
278           var firstArg = blobName[0];
279           formatArguments = new Array(blobName.length);
280           for (var i = 0; i < blobName.length; i++) {
281             formatArguments [i] = encodeURIComponent(blobName [i]);
282              }
283              blobName = firstArg;
284         } else {
285           formatArguments = encodeURIComponent(blobName);
286         }
287         var url = CoreTools.format(writeBlobUrl.replace(/(%[^s^\d])/g, "%$1"), formatArguments);
288         if (url.indexOf(LocalStorageURLContent.LOCAL_STORAGE_PREFIX) === 0) {
289           var path = url.substring(url.indexOf(LocalStorageURLContent.LOCAL_STORAGE_PREFIX) + LocalStorageURLContent.LOCAL_STORAGE_PREFIX.length);
290           var storageKey = decodeURIComponent(path.indexOf('?') > 0 ? path.substring(0, path.indexOf('?')) : path);
291           return LocalURLContent.convertBlobToBase64(blob, function(data) {
292               try {
293                 localStorage.setItem(storageKey, data);
294                 observer.blobSaved(content, blobName);
295               } catch (ex) {
296                 if (observer.blobError !== undefined) {
297                   observer.blobError(ex, ex.message);
298                 }
299               }
300             });
301         } else if (url.indexOf(IndexedDBURLContent.INDEXED_DB_PREFIX) === 0) {
302           // Parse URL of the form indexeddb://database/objectstore?keyPathField=name&contentField=content&dateField=date&name=key
303           var databaseNameIndex = url.indexOf(IndexedDBURLContent.INDEXED_DB_PREFIX) + IndexedDBURLContent.INDEXED_DB_PREFIX.length;
304           var slashIndex = url.indexOf('/', databaseNameIndex);
305           var questionMarkIndex = url.indexOf('?', slashIndex + 1);
306           var databaseName = url.substring(databaseNameIndex, slashIndex);
307           var objectStore = url.substring(slashIndex + 1, questionMarkIndex);
308           var fields = url.substring(questionMarkIndex + 1).split('&');
309           var key = null;
310           var keyPathField = null;
311           var contentField = null;
312           var dateField = null;
313           for (var i = 0; i < fields.length; i++) {
314             var equalIndex = fields [i].indexOf('=');
315             var parameter = fields [i].substring(0, equalIndex);
316             var value = fields [i].substring(equalIndex + 1);
317             switch (parameter) {
318               case "keyPathField": 
319                 keyPathField = value; 
320                 break;
321               case "contentField": 
322                 contentField = value; 
323                 break;
324               case "dateField": 
325                 dateField = value; 
326                 break;
327             }
328           }
329           // Parse a second time fields to retrieve parameters value (key and other other ones)
330           var otherFields = {};
331           for (var i = 0; i < fields.length; i++) {
332             var equalIndex = fields [i].indexOf('=');
333             var parameter = fields [i].substring(0, equalIndex);
334             var value = fields [i].substring(equalIndex + 1);
335             if (keyPathField === parameter) {
336               key = decodeURIComponent(value);
337             } else if (parameter.indexOf("Field", parameter.length - "Field".length) === -1) {
338               otherFields [parameter] = decodeURIComponent(value);
339             }
340           }
341           
342           var databaseUpgradeNeeded = function(ev) { 
343               var database = ev.target.result;
344               if (!database.objectStoreNames.contains(objectStore)) {
345                 database.createObjectStore(objectStore, {keyPath: keyPathField});
346               } 
347             };
348           var databaseError = function(ev) { 
349               if (observer.blobError !== undefined) {
350                 observer.blobError(ev.target.errorCode, "Can't connect to database " + databaseName);
351               }
352             };
353           var databaseSuccess = function(ev) {
354               var database = ev.target.result; 
355               try {
356                 if (!database.objectStoreNames.contains(objectStore)) {
357                   // Reopen the database to create missing object store  
358                   database.close(); 
359                   var requestUpgrade = indexedDB.open(databaseName, database.version + 1);
360                   requestUpgrade.addEventListener("upgradeneeded", databaseUpgradeNeeded);
361                   requestUpgrade.addEventListener("error", databaseError);
362                   requestUpgrade.addEventListener("success", databaseSuccess);
363                 } else {
364                   var transaction = database.transaction(objectStore, 'readwrite');
365                   var store = transaction.objectStore(objectStore);
366                   var storedResource = {};
367                   storedResource [keyPathField] = key;
368                   storedResource [contentField] = blob;
369                   if (dateField != null) {
370                     storedResource [dateField] = Date.now();
371                   }
372                   for (var i in otherFields) {
373                     storedResource [i] = otherFields [i];
374                   }
375                   var query = store.put(storedResource);
376                   query.addEventListener("error", function(ev) { 
377                       if (observer.blobError !== undefined) {
378                         observer.blobError(ev.target.errorCode, "Can't store item in " + objectStore);
379                       }
380                     }); 
381                   query.addEventListener("success", function(ev) {
382                       observer.blobSaved(content, blobName);
383                     }); 
384                   transaction.addEventListener("complete", function(ev) { 
385                       database.close(); 
386                     }); 
387                   abortableOperations.push(transaction);
388                 }
389               } catch (ex) {
390                 if (observer.blobError !== undefined) {
391                   observer.blobError(ex, ex.message);
392                 }
393               }
394             };
395             
396           if (indexedDB != null) {
397             var request = indexedDB.open(databaseName);
398             request.addEventListener("upgradeneeded", databaseUpgradeNeeded);
399             request.addEventListener("error", databaseError);
400             request.addEventListener("success", databaseSuccess);
401           } else {
402             observer.blobError(new Error("indexedDB"), "indexedDB unavailable");
403           }
404         } else {
405           var request = new XMLHttpRequest();
406           request.open("POST", url, true);
407           request.addEventListener('load', function (ev) {
408               if (request.readyState === XMLHttpRequest.DONE) {
409                 if (request.status === 200) {
410                   observer.blobSaved(content, blobName);
411                 } else if (observer.blobError !== undefined) {
412                   observer.blobError(request.status, request.responseText);
413                 }
414               }
415             });
416           var errorListener = function(ev) {
417               if (observer.blobError !== undefined) {
418                 observer.blobError(0, "Can't post " + url);
419               }
420             };
421           request.addEventListener("error", errorListener);
422           request.addEventListener("timeout", errorListener);
423           request.send(blob);
424           abortableOperations.push(request);
425         }
426       },
427       blobError: function(status, error) {
428          if (observer.blobError !== undefined) {
429           observer.blobError(status, error);
430         }
431       }
432     });
433     
434   return {
435       abort: function() {
436         for (var i = 0; i < abortableOperations.length; i++) {
437           abortableOperations [i].abort();
438         }
439       }
440     };
441 }
442 
443 /**
444  * @param {Blob} blob
445  * @param {function} observer
446  * @return {abort: function} an object containing <code>abort</code> method to abort the conversion
447  * @private
448  */
449 LocalURLContent.convertBlobToBase64 = function(blob, observer) {
450   var reader = new FileReader();
451   // Use onload rather that addEventListener for Cordova support
452   reader.onload = function() {
453       observer(reader.result);
454     };
455   reader.readAsDataURL(blob);
456   return reader;
457 }
458 
459 /**
460  * Content read from the URL of a <code>Blob</code> instance.
461  * Note that this class may also handle a <code>File</code> instance which is a sub type of <code>Blob</code>.
462  * @constructor
463  * @param {Blob} blob 
464  * @author Louis Grignon
465  * @author Emmanuel Puybaret
466  */
467 function BlobURLContent(blob) {
468   LocalURLContent.call(this, URL.createObjectURL(blob));
469   this.blob = blob;
470 }
471 BlobURLContent.prototype = Object.create(LocalURLContent.prototype);
472 BlobURLContent.prototype.constructor = BlobURLContent;
473 
474 BlobURLContent["__class"] = "com.eteks.sweethome3d.tools.BlobURLContent";
475 BlobURLContent["__interfaces"] = ["com.eteks.sweethome3d.model.Content"];
476 
477 BlobURLContent.BLOB_PREFIX = "blob:";
478 
479 /**
480  * Returns an instance of <code>BlobURLContent</code> for the given <code>blob</code>.
481  * @param {Blob} blob
482  * @return {BlobURLContent}
483  */
484 BlobURLContent.fromBlob = function(blob) { 
485   // Check blob content is in cache
486   for (var i in URLContent.urlContents) {
487     if (URLContent.urlContents [i] instanceof BlobURLContent
488         && URLContent.urlContents [i].blob === blob) {
489       return URLContent.urlContents [i];
490     }
491   }
492   var content = new BlobURLContent(blob);
493   URLContent.urlContents [content.getURL()] = content;
494   return content;
495 }
496 
497 /**
498  * Generates a BlobURLContent instance from an image.
499  * @param {HTMLImageElement} image the image to be used as content source
500  * @param {string} imageType resulting image blob mime type
501  * @param {function(BlobURLContent)} observer callback called when content is ready, with content instance as only parameter
502  */
503 BlobURLContent.fromImage = function(image, imageType, observer) {
504   var canvas = document.createElement("canvas");
505   var context = canvas.getContext("2d");
506   canvas.width = image.width;
507   canvas.height = image.height;
508   context.drawImage(image, 0, 0, image.width, image.height);
509   if (canvas.msToBlob) {
510     observer(BlobURLContent.fromBlob(canvas.msToBlob()));
511   } else {
512     canvas.toBlob(function (blob) {
513         observer(BlobURLContent.fromBlob(blob));
514       }, imageType, 0.7);
515   }
516 }
517 
518 /**
519  * Retrieves asynchronously an URL of this content usable for JavaScript functions with URL paramater.
520  * @param  {{urlReady: function, urlError: function}} observer optional observer 
521       which <code>urlReady</code> function will be called asynchronously once URL is available. 
522  */
523 BlobURLContent.prototype.getStreamURL = function(observer) {
524   observer.urlReady(this.getURL());
525 }
526 
527 /**
528  * Returns the blob stored by this content, possibly asynchronously if <code>observer</code> parameter is given.
529  * @param  {{blobReady: function, blobError: function}} [observer] optional observer 
530       which blobReady function will be called asynchronously once blob is available. 
531  * @return {Blob} blob content 
532  */
533 BlobURLContent.prototype.getBlob = function(observer) {
534   if (observer !== undefined) {
535     observer.blobReady(this.blob);
536   }
537   return this.blob;
538 }
539 
540 
541 /**
542  * Content read from local storage stored in a blob encoded in Base 64.
543  * @constructor
544  * @param {string} url an URL of the form <code>localstorage://key</code> 
545        where <code>key</code> is the key of the blob to read from local storage
546  * @ignore
547  * @author Emmanuel Puybaret
548  */
549 function LocalStorageURLContent(url) {
550   LocalURLContent.call(this, url);
551   this.blob = null;
552   this.blobUrl = null;
553 }
554 LocalStorageURLContent.prototype = Object.create(LocalURLContent.prototype);
555 LocalStorageURLContent.prototype.constructor = LocalStorageURLContent;
556 
557 LocalStorageURLContent["__class"] = "com.eteks.sweethome3d.tools.LocalStorageURLContent";
558 LocalStorageURLContent["__interfaces"] = ["com.eteks.sweethome3d.model.Content"];
559 
560 LocalStorageURLContent.LOCAL_STORAGE_PREFIX = "localstorage://";
561 
562 /**
563  * Retrieves asynchronously an URL of this content usable for JavaScript functions with URL paramater.
564  * @param  {{urlReady: function, urlError: function}} observer optional observer 
565       which <code>urlReady</code> function will be called asynchronously once URL is available. 
566  */
567 LocalStorageURLContent.prototype.getStreamURL = function(observer) {
568   if (this.blobUrl == null) {
569     var urlContent = this;
570     this.getBlob({
571         blobReady: function(blob) {
572           observer.urlReady(urlContent.blobUrl);
573         },
574         blobError: function(status, error) {
575           if (observer.urlError !== undefined) {
576             observer.urlError(status, error);
577           }
578         }
579       });
580   } else {
581     observer.urlReady(this.blobUrl);
582   }
583 }
584 
585 /**
586  * Returns the blob stored by this content.
587  * @param  {{blobReady: function, blobError: function}} [observer] optional observer 
588       which blobReady function will be called asynchronously once blob is available. 
589  * @return {Blob} blob content 
590  */
591 LocalStorageURLContent.prototype.getBlob = function(observer) {
592   if (this.blob == null) {
593     var url = this.getURL();
594     if (url.indexOf(LocalStorageURLContent.LOCAL_STORAGE_PREFIX) === 0) {
595       var path = url.substring(url.indexOf(LocalStorageURLContent.LOCAL_STORAGE_PREFIX) + LocalStorageURLContent.LOCAL_STORAGE_PREFIX.length);
596       var key = decodeURIComponent(path.indexOf('?') > 0 ? path.substring(0, path.indexOf('?')) : path);
597       var data = localStorage.getItem(key);
598       if (data != null) {
599         var contentType = data.substring("data:".length, data.indexOf(';'));
600         var chars = atob(data.substring(data.indexOf(',') + 1));
601         var numbers = new Array(chars.length);
602         for (var i = 0; i < numbers.length; i++) {
603           numbers[i] = chars.charCodeAt(i);
604         }
605         var byteArray = new Uint8Array(numbers);
606         this.blob = new Blob([byteArray], {type: contentType});
607         this.blobUrl = URL.createObjectURL(this.blob);
608       } else {
609         if (observer.urlError !== undefined) {
610           observer.urlError(1, "No key '" + key + "' in localStorage");
611         }
612       }
613     } else {
614       if (observer.urlError !== undefined) {
615         observer.urlError(1, url + " not a local storage url");
616       }
617     }
618   }
619   if (observer !== undefined
620       && observer.blobReady !== undefined
621       && this.blob != null) {
622     observer.blobReady(this.blob);
623   }
624   return this.blob;
625 }
626 
627 
628 /**
629  * Content read from IndexedDB stored in a blob.
630  * @constructor
631  * @param {string} url an URL of the form <code>indexeddb://database/objectstore/field?keyPathField=key</code> 
632        where <code>database</code> is the database name, <code>objectstore</code> the object store where 
633        the blob is stored in the given <code>field</code> and <code>key</code> the key value  
634        of <code>keyPathField</code> used to select the blob. If the database doesn't exist, it will be 
635        created with a keyPath equal to <code>keyPathField</code>.
636  * @ignore
637  * @author Emmanuel Puybaret
638  */
639 function IndexedDBURLContent(url) {
640   LocalURLContent.call(this, url);
641   this.blob = null;
642   this.blobUrl = null;
643 }
644 IndexedDBURLContent.prototype = Object.create(LocalURLContent.prototype);
645 IndexedDBURLContent.prototype.constructor = IndexedDBURLContent;
646 
647 IndexedDBURLContent["__class"] = "com.eteks.sweethome3d.tools.IndexedDBURLContent";
648 IndexedDBURLContent["__interfaces"] = ["com.eteks.sweethome3d.model.Content"];
649 
650 IndexedDBURLContent.INDEXED_DB_PREFIX = "indexeddb://";
651 
652 /**
653  * Retrieves asynchronously an URL of this content usable for JavaScript functions with URL paramater.
654  * @param  {{urlReady: function, urlError: function}} observer optional observer 
655       which <code>urlReady</code> function will be called asynchronously once URL is available. 
656  */
657 IndexedDBURLContent.prototype.getStreamURL = function(observer) {
658   if (this.blobUrl == null) {
659     var urlContent = this;
660     this.getBlob({
661         blobReady: function(blob) {
662           observer.urlReady(urlContent.blobUrl);
663         },
664         blobError: function(status, error) {
665           if (observer.urlError !== undefined) {
666             observer.urlError(status, error);
667           }
668         }
669       });
670   } else {
671     observer.urlReady(this.blobUrl);
672   }
673 }
674 
675 /**
676  * Returns the blob stored by this content, reading it asynchronously.
677  * @param  {{blobReady: function, blobError: function}} [observer] optional observer 
678       which blobReady function will be called asynchronously if blob is not available yet. 
679  * @return {Blob} blob content or <code>null</code> if blob wasn't read yet
680  */
681 IndexedDBURLContent.prototype.getBlob = function(observer) {
682   if (observer !== undefined) {
683     if (this.blob != null) {
684       observer.blobReady(this.blob);
685     } else {
686       var url = this.getURL();
687       if (url.indexOf(IndexedDBURLContent.INDEXED_DB_PREFIX) >= 0) {
688          // Parse URL of the form indexeddb://database/objectstore/field?keyPathField=key
689         var databaseNameIndex = url.indexOf(IndexedDBURLContent.INDEXED_DB_PREFIX) + IndexedDBURLContent.INDEXED_DB_PREFIX.length;
690         var firstPathSlashIndex = url.indexOf('/', databaseNameIndex);
691         var secondPathSlashIndex = url.indexOf('/', firstPathSlashIndex + 1);
692         var questionMarkIndex = url.indexOf('?', secondPathSlashIndex + 1);
693         var equalIndex = url.indexOf('=', questionMarkIndex + 1);
694         var ampersandIndex = url.indexOf('&', equalIndex + 1);
695         var databaseName = url.substring(databaseNameIndex, firstPathSlashIndex);
696         var objectStore = url.substring(firstPathSlashIndex + 1, secondPathSlashIndex);
697         var contentField = url.substring(secondPathSlashIndex + 1, questionMarkIndex);
698         var keyPathField = url.substring(questionMarkIndex + 1, equalIndex);
699         var key = decodeURIComponent(url.substring(equalIndex + 1, ampersandIndex > 0 ? ampersandIndex : url.length));
700         var urlContent = this;
701         
702         var databaseUpgradeNeeded = function(ev) { 
703             var database = ev.target.result;
704             if (!database.objectStoreNames.contains(objectStore)) {
705               database.createObjectStore(objectStore, {keyPath: keyPathField});
706             } 
707           };
708         var databaseError = function(ev) { 
709             if (observer.blobError !== undefined) {
710               observer.blobError(ev.target.errorCode, "Can't connect to database " + databaseName);
711             }
712           };
713         var databaseSuccess = function(ev) {
714             var database = ev.target.result;
715             try {
716               if (!database.objectStoreNames.contains(objectStore)) {
717                 // Reopen the database to create missing object store  
718                 database.close(); 
719                 var requestUpgrade = indexedDB.open(databaseName, database.version + 1);
720                 requestUpgrade.addEventListener("upgradeneeded", databaseUpgradeNeeded);
721                 requestUpgrade.addEventListener("error", databaseError);
722                 requestUpgrade.addEventListener("success", databaseSuccess);
723               } else {
724                 var transaction = database.transaction(objectStore, 'readonly'); 
725                 var store = transaction.objectStore(objectStore); 
726                 var query = store.get(key); 
727                 query.addEventListener("error", function(ev) { 
728                     if (observer.blobError !== undefined) {
729                       observer.blobError(ev.target.errorCode, "Can't query in " + objectStore);
730                     }
731                   }); 
732                 query.addEventListener("success", function(ev) {
733                     if (ev.target.result !== undefined) {
734                       urlContent.blob = ev.target.result [contentField];
735                       // Store other properties in blob properties
736                       for (var i in ev.target.result) {
737                         var propertyName = ev.target.result [i];
738                         if (propertyName !== keyPathField
739                             && propertyName != contentField
740                             && urlContent.blob [propertyName] === undefined) {
741                           urlContent.blob [propertyName] = ev.target.result [propertyName]; 
742                         }
743                       }
744                       urlContent.blobUrl = URL.createObjectURL(urlContent.blob);
745                       if (observer.blobReady !== undefined) {
746                         observer.blobReady(urlContent.blob);
747                       }
748                     } else if (observer.blobError !== undefined) {
749                       observer.blobError(-1, "Blob with key " + key + " not found");
750                     }
751                   }); 
752                 transaction.addEventListener("complete", function(ev) { 
753                     database.close(); 
754                   }); 
755               }
756             } catch (ex) {
757               if (observer.blobError !== undefined) {
758                 observer.blobError(ex, ex.message);
759               }
760             }
761           };
762      
763         if (indexedDB != null) {     
764           var request = indexedDB.open(databaseName);
765           request.addEventListener("upgradeneeded", databaseUpgradeNeeded);
766           request.addEventListener("error", databaseError);
767           request.addEventListener("success", databaseSuccess);
768         } else {
769           observer.blobError(new Error("indexedDB"), "indexedDB unavailable");
770         }
771       } else if (observer.urlError !== undefined) {
772         observer.urlError(1, url + " not an indexedDB url");
773       }
774     }
775   }
776   return this.blob;
777 }
778 
779 /**
780  * Returns <code>true</code> if this content URL is available. 
781  */
782 IndexedDBURLContent.prototype.isStreamURLReady = function() {
783   return this.blobUrl != null;
784 }
785 
786 
787 /**
788  * Utilities about the system environment.
789  * @class
790  * @ignore
791  * @author Emmanuel Puybaret
792  */
793 var OperatingSystem = {}
794 
795 /**
796  * Returns <code>true</code> if the operating system is Linux.
797  */
798 OperatingSystem.isLinux = function() {
799   if (navigator && navigator.platform) {
800     return navigator.platform.indexOf("Linux") !== -1;
801   } else {
802     return false;
803   }
804 }
805 
806 /**
807  * Returns <code>true</code> if the operating system is Windows.
808  */
809 OperatingSystem.isWindows = function() {
810   if (navigator && navigator.platform) {
811     return navigator.platform.indexOf("Windows") !== -1 || navigator.platform.indexOf("Win") !== -1;
812   } else {
813     return false;
814   }
815 }
816 
817 /**
818  * Returns <code>true</code> if the operating system is Mac OS X.
819  */
820 OperatingSystem.isMacOSX = function() {
821   if (navigator && navigator.platform) {
822     return navigator.platform.indexOf("Mac") !== -1;
823   } else {
824     return false;
825   }
826 }
827 
828 /**
829  * Returns the operating system name used to filter some information.
830  */
831 OperatingSystem.getName = function() {
832   if (OperatingSystem.isMacOSX()) {
833     return "Mac OS X";
834   } else if (OperatingSystem.isLinux()) {
835     return "Linux";
836   } else if (OperatingSystem.isWindows()) {
837     return "Windows";
838   } else {
839     return "Other";
840   }
841 }
842 
843 /**
844  * Returns <code>true</code> if the current browser is Internet Explorer or Edge (note based on Chromium).
845  */
846 OperatingSystem.isInternetExplorerOrLegacyEdge = function() {
847   // IE and Edge test from https://stackoverflow.com/questions/31757852/how-can-i-detect-internet-explorer-ie-and-microsoft-edge-using-javascript
848   return (document.documentMode || /Edge/.test(navigator.userAgent));
849 }
850 
851 /**
852  * Returns <code>true</code> if the current browser is Internet Explorer.
853  */
854 OperatingSystem.isInternetExplorer = function() {
855   // IE test from https://stackoverflow.com/questions/31757852/how-can-i-detect-internet-explorer-ie-and-microsoft-edge-using-javascript
856   return document.documentMode;
857 }
858 
859 
860 /**
861  * ZIP reading utilities.
862  * @class
863  * @author Emmanuel Puybaret
864  */
865 var ZIPTools = {};
866 
867 ZIPTools.READING = "Reading";
868 
869 ZIPTools.openedZips = {};
870 ZIPTools.runningRequests = [];
871 
872 /**
873  * Reads the ZIP data in the given URL.
874  * @param {string} url the URL of a zip file containing an OBJ entry that will be loaded
875  *            or an URL noted as jar:url!/objEntry where objEntry will be loaded.
876  * @param {boolean} [synchronous] optional parameter equal to false by default
877  * @param {{zipReady, zipError, progression}} zipObserver An observer containing zipReady(zip), 
878  *            zipError(error), progression(part, info, percentage) methods that
879  *            will called at various phases.
880  */
881 ZIPTools.getZIP = function(url, synchronous, zipObserver) {
882   if (zipObserver === undefined) {
883     zipObserver = synchronous;
884     synchronous = false;
885   }
886   if (url in ZIPTools.openedZips) {
887     zipObserver.zipReady(ZIPTools.openedZips [url]); 
888   } else {
889     var urlContent = URLContent.fromURL(url);
890     if (synchronous 
891         && !urlContent.isStreamURLReady()) {
892       throw new IllegalStateException("Can't run synchronously with unavailable URL");
893     }    
894     urlContent.getStreamURL({
895         urlReady: function(streamUrl) {
896           try {
897             var request = new XMLHttpRequest();
898             request.open('GET', streamUrl, !synchronous);
899             request.responseType = "arraybuffer";
900             request.withCredentials = true;
901             request.overrideMimeType("application/octet-stream");
902             request.addEventListener("readystatechange", 
903                 function(ev) {
904                   if (request.readyState === XMLHttpRequest.DONE) {
905                     if ((request.status === 200 || request.status === 0)
906                         && request.response != null) {
907                       try {
908                         ZIPTools.runningRequests.splice(ZIPTools.runningRequests.indexOf(request), 1);
909                         var zip = new JSZip(request.response);                        
910                         ZIPTools.openedZips [url] = zip;
911                         zipObserver.zipReady(ZIPTools.openedZips [url]); 
912                       } catch (ex) {
913                         zipObserver.zipError(ex);
914                       }
915                     } else {
916                       // Report error for requests that weren't aborted
917                       var index = ZIPTools.runningRequests.indexOf(request);              
918                       if (index >= 0) {
919                         ZIPTools.runningRequests.splice(index, 1);                
920                         zipObserver.zipError(new Error(request.status + " while requesting " + url)); 
921                       }
922                     }
923                   }
924                 });
925             request.addEventListener("progress", 
926                 function(ev) {
927                   if (ev.lengthComputable
928                       && zipObserver.progression !== undefined) {
929                     zipObserver.progression(ZIPTools.READING, url, ev.loaded / ev.total);
930                   }
931                 });
932             request.send();
933             ZIPTools.runningRequests.push(request);
934           } catch (ex) {
935             zipObserver.zipError(ex);
936           }
937         },
938       urlError: function(status, error) {
939         if (zipObserver.zipError !== undefined) {
940           zipObserver.zipError(error);
941         }
942       }    
943     });
944   }
945 }
946 
947 
948 /**
949  * Clears cache and aborts running requests.
950  */
951 ZIPTools.clear = function() {
952   ZIPTools.openedZips = {};
953   // Abort running requests
954   while (ZIPTools.runningRequests.length > 0) {
955     var request = ZIPTools.runningRequests [ZIPTools.runningRequests.length - 1];
956     ZIPTools.runningRequests.splice(ZIPTools.runningRequests.length - 1, 1);
957     request.abort();
958   }
959 }
960 
961 /**
962  * Removes from cache the content matching the given <code>url</code>. 
963  */
964 ZIPTools.disposeZIP = function(url) {
965   delete ZIPTools.openedZips [url];
966 }
967 
968 /**
969  * Returns true if the given image data describes a GIF file.
970  * @param {string|Uint8Array} imageData
971  * @package
972  * @ignore
973  */
974 ZIPTools.isGIFImage = function(imageData) {
975   if (imageData.length <= 6) {
976     return false;
977   } else if (typeof imageData === "string") { 
978     return imageData.charCodeAt(0) === 0x47  
979         && imageData.charCodeAt(1) === 0x49
980         && imageData.charCodeAt(2) === 0x46
981         && imageData.charCodeAt(3) === 0x38 
982         && (imageData.charCodeAt(4) === 0x37 || imageData.charCodeAt(4) === 0x39)
983         && imageData.charCodeAt(5) === 0x61;
984   } else {
985     return imageData [0] === 0x47  
986         && imageData [1] === 0x49
987         && imageData [2] === 0x46
988         && imageData [3] === 0x38 
989         && (imageData [4] === 0x37 || imageData [4] === 0x39)
990         && imageData [5] === 0x61;
991   }
992 }
993 
994 /**
995  * Returns true if the given image data describes a BMP file.
996  * @param {string|Uint8Array} imageData
997  * @package
998  * @ignore
999  */
1000 ZIPTools.isBMPImage = function(imageData) {
1001   if (imageData.length <= 2) {
1002     return false;
1003   } else if (typeof imageData === "string") {
1004     return imageData.charCodeAt(0) === 0x42  
1005         && imageData.charCodeAt(1) === 0x4D;
1006   } else {
1007     return imageData [0] === 0x42  
1008         && imageData [1] === 0x4D;
1009   }
1010 }
1011 
1012 /**
1013  * Returns true if the given image data describes a JPEG file.
1014  * @param {string|Uint8Array} imageData
1015  * @package
1016  * @ignore
1017  */
1018 ZIPTools.isJPEGImage = function(imageData) {
1019   if (imageData.length <= 3) {
1020     return false;
1021   } else if (typeof imageData === "string") {
1022     return imageData.charCodeAt(0) === 0xFF 
1023       && (imageData.charCodeAt(1) === 0xD8 || imageData.charCodeAt(1) === 0x4F) 
1024       && imageData.charCodeAt(2) === 0xFF;
1025   } else {
1026     return imageData [0] === 0xFF 
1027       && (imageData [1] === 0xD8 || imageData [1] === 0x4F) 
1028       && imageData [2] === 0xFF;
1029   }
1030 }
1031 
1032 /**
1033  * Returns true if the given image data describes a PNG file.
1034  * @param {string|Uint8Array} imageData
1035  * @package
1036  * @ignore
1037  */
1038 ZIPTools.isPNGImage = function(imageData) {
1039   if (imageData.length <= 8) {
1040     return false;
1041   } else if (typeof imageData === "string") {
1042     return imageData.charCodeAt(0) === 0x89 
1043       && imageData.charCodeAt(1) === 0x50 
1044       && imageData.charCodeAt(2) === 0x4E 
1045       && imageData.charCodeAt(3) === 0x47 
1046       && imageData.charCodeAt(4) === 0x0D 
1047       && imageData.charCodeAt(5) === 0x0A 
1048       && imageData.charCodeAt(6) === 0x1A 
1049       && imageData.charCodeAt(7) === 0x0A;
1050   } else {
1051     return imageData [0] === 0x89 
1052       && imageData [1] === 0x50 
1053       && imageData [2] === 0x4E 
1054       && imageData [3] === 0x47 
1055       && imageData [4] === 0x0D 
1056       && imageData [5] === 0x0A 
1057       && imageData [6] === 0x1A 
1058       && imageData [7] === 0x0A;
1059    }
1060 }
1061 
1062 /**
1063  * Returns true if the given image data describes a transparent PNG file.
1064  * @param {string|Uint8Array} imageData
1065  * @package
1066  * @ignore
1067  */
1068 ZIPTools.isTransparentImage = function(imageData) {
1069   if (imageData.length > 26) {
1070     if (typeof imageData === "string") {
1071       return (imageData.charCodeAt(25) === 4
1072           || imageData.charCodeAt(25) === 6
1073           || (imageData.indexOf("PLTE") !== -1 && imageData.indexOf("tRNS") !== -1));
1074     } else {
1075       if (imageData [25] === 4
1076           || imageData [25] === 6) {
1077         return true;
1078       } else {
1079         // Search if imageData contains PLTE and tRNS
1080         for (var i = 0; i < imageData.length; i++) {
1081           if (imageData [i] === 0x50
1082               && imageData [i + 1] === 0x4C
1083               && imageData [i + 2] === 0x54
1084               && imageData [i + 3] === 0x45) {
1085             for (var j = 0; j < imageData.length; j++) {
1086               if (imageData [j] === 0x74
1087                   && imageData [j + 1] === 0x52
1088                   && imageData [j + 2] === 0x4E
1089                   && imageData [j + 3] === 0x53) {
1090                 return true;
1091               }
1092             }
1093           }
1094         }
1095       }
1096     }
1097   }
1098   return false;
1099 }
1100 
1101 /**
1102  * Returns the folder where a given Javascript .js file was read from.
1103  * @param {string|RegExp} [script] the URL of a script used in the program  
1104  * @package
1105  * @ignore
1106  */
1107 ZIPTools.getScriptFolder = function(script) {
1108   if (script === undefined) {
1109     // Consider this script is always here because ZIPTools itself requires it
1110     script = "jszip.min.js"; 
1111   }
1112   // Search the base URL of this script
1113   if (typeof document !== "undefined") {
1114     var scripts = document.getElementsByTagName("script");      
1115     for (var i = 0; i < scripts.length; i++) {
1116       if (script instanceof RegExp && scripts[i].src.match(script)
1117           || typeof script === "string" && scripts[i].src.indexOf(script) !== -1) {
1118         return scripts[i].src.substring(0, scripts[i].src.lastIndexOf("/") + 1);
1119       }
1120     }
1121     
1122     if (scripts.length > 0) {
1123       return scripts[0].src.substring(0, scripts[0].src.lastIndexOf("/") + 1);
1124     } 
1125   }
1126   return "https://www.sweethome3d.com/libjs/";
1127 }
1128