]> git.donarmstrong.com Git - dactyl.git/blob - common/modules/storage.jsm
Import 1.0rc1 supporting Firefox up to 11.*
[dactyl.git] / common / modules / storage.jsm
1 // Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
2 //
3 // This work is licensed for reuse under an MIT license. Details are
4 // given in the LICENSE.txt file included with this file.
5 /* use strict */
6
7 Components.utils.import("resource://dactyl/bootstrap.jsm");
8 defineModule("storage", {
9     exports: ["File", "Storage", "storage"],
10     require: ["services", "util"]
11 }, this);
12
13 this.lazyRequire("config", ["config"]);
14 this.lazyRequire("io", ["IO"]);
15
16 var win32 = /^win(32|nt)$/i.test(services.runtime.OS);
17 var myObject = JSON.parse("{}").constructor;
18
19 function loadData(name, store, type) {
20     try {
21         let file = storage.infoPath.child(name);
22         if (file.exists()) {
23             let data = file.read();
24             let result = JSON.parse(data);
25             if (result instanceof type)
26                 return result;
27         }
28     }
29     catch (e) {
30         util.reportError(e);
31     }
32 }
33
34 function saveData(obj) {
35     if (obj.privateData && storage.privateMode)
36         return;
37     if (obj.store && storage.infoPath)
38         storage.infoPath.child(obj.name).write(obj.serial);
39 }
40
41 var StoreBase = Class("StoreBase", {
42     OPTIONS: ["privateData", "replacer"],
43
44     fireEvent: function (event, arg) { storage.fireEvent(this.name, event, arg); },
45
46     get serial() JSON.stringify(this._object, this.replacer),
47
48     init: function (name, store, load, options) {
49         this._load = load;
50
51         this.__defineGetter__("store", function () store);
52         this.__defineGetter__("name", function () name);
53         for (let [k, v] in Iterator(options))
54             if (this.OPTIONS.indexOf(k) >= 0)
55                 this[k] = v;
56         this.reload();
57     },
58
59     changed: function () { this.timer.tell(); },
60
61     reload: function reload() {
62         this._object = this._load() || this._constructor();
63         this.fireEvent("change", null);
64     },
65
66     delete: function delete_() {
67         delete storage.keys[this.name];
68         delete storage[this.name];
69         storage.infoPath.child(this.name).remove(false);
70     },
71
72     save: function () { saveData(this); },
73
74     __iterator__: function () Iterator(this._object)
75 });
76
77 var ArrayStore = Class("ArrayStore", StoreBase, {
78     _constructor: Array,
79
80     get length() this._object.length,
81
82     set: function set(index, value, quiet) {
83         var orig = this._object[index];
84         this._object[index] = value;
85         if (!quiet)
86             this.fireEvent("change", index);
87
88         return orig;
89     },
90
91     push: function push(value) {
92         this._object.push(value);
93         this.fireEvent("push", this._object.length);
94     },
95
96     pop: function pop(value, ord) {
97         if (ord == null)
98             var res = this._object.pop();
99         else
100             res = this._object.splice(ord, 1)[0];
101
102         this.fireEvent("pop", this._object.length, ord);
103         return res;
104     },
105
106     shift: function shift(value) {
107         var res = this._object.shift();
108         this.fireEvent("shift", this._object.length);
109         return res;
110     },
111
112     insert: function insert(value, ord) {
113         if (ord == 0)
114             this._object.unshift(value);
115         else
116             this._object = this._object.slice(0, ord)
117                                .concat([value])
118                                .concat(this._object.slice(ord));
119         this.fireEvent("insert", this._object.length, ord);
120     },
121
122     truncate: function truncate(length, fromEnd) {
123         var res = this._object.length;
124         if (this._object.length > length) {
125             if (fromEnd)
126                 this._object.splice(0, this._object.length - length);
127             this._object.length = length;
128             this.fireEvent("truncate", length);
129         }
130         return res;
131     },
132
133     // XXX: Awkward.
134     mutate: function mutate(funcName) {
135         var _funcName = funcName;
136         arguments[0] = this._object;
137         this._object = Array[_funcName].apply(Array, arguments);
138         this.fireEvent("change", null);
139     },
140
141     get: function get(index) index >= 0 ? this._object[index] : this._object[this._object.length + index]
142 });
143
144 var ObjectStore = Class("ObjectStore", StoreBase, {
145     _constructor: myObject,
146
147     clear: function () {
148         this._object = {};
149         this.fireEvent("clear");
150     },
151
152     get: function get(key, default_) {
153         return key in this._object  ? this._object[key] :
154                arguments.length > 1 ? this.set(key, default_) :
155                                       undefined;
156     },
157
158     keys: function keys() Object.keys(this._object),
159
160     remove: function remove(key) {
161         var res = this._object[key];
162         delete this._object[key];
163         this.fireEvent("remove", key);
164         return res;
165     },
166
167     set: function set(key, val) {
168         var defined = key in this._object;
169         var orig = this._object[key];
170         this._object[key] = val;
171         if (!defined)
172             this.fireEvent("add", key);
173         else if (orig != val)
174             this.fireEvent("change", key);
175         return val;
176     }
177 });
178
179 var Storage = Module("Storage", {
180     alwaysReload: {},
181
182     init: function () {
183         this.cleanup();
184
185         if (services.bootstrap && !services.bootstrap.session)
186             services.bootstrap.session = {};
187         this.session = services.bootstrap ? services.bootstrap.session : {};
188     },
189
190     cleanup: function () {
191         this.saveAll();
192
193         for (let key in keys(this.keys)) {
194             if (this[key].timer)
195                 this[key].timer.flush();
196             delete this[key];
197         }
198
199         this.keys = {};
200         this.observers = {};
201     },
202
203     infoPath: Class.Memoize(function ()
204         File(IO.runtimePath.replace(/,.*/, ""))
205             .child("info").child(config.profileName)),
206
207     exists: function exists(key) this.infoPath.child(key).exists(),
208
209     remove: function remove(key) {
210         if (this.exists(key)) {
211             if (this[key] && this[key].timer)
212                 this[key].timer.flush();
213             delete this[key];
214             delete this.keys[key];
215             this.infoPath.child(key).remove(false);
216         }
217     },
218
219     newObject: function newObject(key, constructor, params) {
220         if (params == null || !isObject(params))
221             throw Error("Invalid argument type");
222
223         if (!(key in this.keys) || params.reload || this.alwaysReload[key]) {
224             if (key in this && !(params.reload || this.alwaysReload[key]))
225                 throw Error();
226             let load = function () loadData(key, params.store, params.type || myObject);
227
228             this.keys[key] = new constructor(key, params.store, load, params);
229             this.keys[key].timer = new Timer(1000, 10000, function () storage.save(key));
230             this.__defineGetter__(key, function () this.keys[key]);
231         }
232         return this.keys[key];
233     },
234
235     newMap: function newMap(key, options) {
236         return this.newObject(key, ObjectStore, options);
237     },
238
239     newArray: function newArray(key, options) {
240         return this.newObject(key, ArrayStore, update({ type: Array }, options));
241     },
242
243     addObserver: function addObserver(key, callback, ref) {
244         if (ref) {
245             let refs = overlay.getData(ref, "storage-refs");
246             refs.push(callback);
247             var callbackRef = util.weakReference(callback);
248         }
249         else {
250             callbackRef = { get: function () callback };
251         }
252         this.removeDeadObservers();
253         if (!(key in this.observers))
254             this.observers[key] = [];
255         if (!this.observers[key].some(function (o) o.callback.get() == callback))
256             this.observers[key].push({ ref: ref && Cu.getWeakReference(ref), callback: callbackRef });
257     },
258
259     removeObserver: function (key, callback) {
260         this.removeDeadObservers();
261         if (!(key in this.observers))
262             return;
263         this.observers[key] = this.observers[key].filter(function (elem) elem.callback.get() != callback);
264         if (this.observers[key].length == 0)
265             delete obsevers[key];
266     },
267
268     removeDeadObservers: function () {
269         for (let [key, ary] in Iterator(this.observers)) {
270             this.observers[key] = ary = ary.filter(function (o) o.callback.get()
271                                                              && (!o.ref || o.ref.get()
272                                                                         && overlay.getData(o.ref.get(), "storage-refs", null)));
273             if (!ary.length)
274                 delete this.observers[key];
275         }
276     },
277
278     fireEvent: function fireEvent(key, event, arg) {
279         this.removeDeadObservers();
280         if (key in this.observers)
281             // Safe, since we have our own Array object here.
282             for each (let observer in this.observers[key])
283                 observer.callback.get()(key, event, arg);
284         if (key in this.keys)
285             this[key].timer.tell();
286     },
287
288     load: function load(key) {
289         if (this[key].store && this[key].reload)
290             this[key].reload();
291     },
292
293     save: function save(key) {
294         if (this[key])
295             saveData(this.keys[key]);
296     },
297
298     saveAll: function storeAll() {
299         for each (let obj in this.keys)
300             saveData(obj);
301     },
302
303     _privateMode: false,
304     get privateMode() this._privateMode,
305     set privateMode(val) {
306         if (val && !this._privateMode)
307             this.saveAll();
308         if (!val && this._privateMode)
309             for (let key in this.keys)
310                 this.load(key);
311         return this._privateMode = Boolean(val);
312     }
313 }, {
314     Replacer: {
315         skipXpcom: function skipXpcom(key, val) val instanceof Ci.nsISupports ? null : val
316     }
317 }, {
318     cleanup: function (dactyl, modules, window) {
319         overlay.setData(window, "storage-refs", null);
320         this.removeDeadObservers();
321     }
322 });
323
324 /**
325  * @class File A class to wrap nsIFile objects and simplify operations
326  * thereon.
327  *
328  * @param {nsIFile|string} path Expanded according to {@link IO#expandPath}
329  * @param {boolean} checkPWD Whether to allow expansion relative to the
330  *          current directory. @default true
331  * @param {string} charset The charset of the file. @default File.defaultEncoding
332  */
333 var File = Class("File", {
334     init: function (path, checkPWD, charset) {
335         let file = services.File();
336
337         if (charset)
338             this.charset = charset;
339
340         if (path instanceof Ci.nsIFileURL)
341             path = path.file;
342
343         if (path instanceof Ci.nsIFile)
344             file = path.clone();
345         else if (/file:\/\//.test(path))
346             file = services["file:"].getFileFromURLSpec(path);
347         else {
348             try {
349                 let expandedPath = File.expandPath(path);
350
351                 if (!File.isAbsolutePath(expandedPath) && checkPWD)
352                     file = checkPWD.child(expandedPath);
353                 else
354                     file.initWithPath(expandedPath);
355             }
356             catch (e) {
357                 util.reportError(e);
358                 return File.DoesNotExist(path, e);
359             }
360         }
361         let self = XPCSafeJSObjectWrapper(file.QueryInterface(Ci.nsILocalFile));
362         self.__proto__ = this;
363         return self;
364     },
365
366     charset: Class.Memoize(function () File.defaultEncoding),
367
368     /**
369      * @property {nsIFileURL} Returns the nsIFileURL object for this file.
370      */
371     URI: Class.Memoize(function () {
372         let uri = services.io.newFileURI(this).QueryInterface(Ci.nsIFileURL);
373         uri.QueryInterface(Ci.nsIMutable).mutable = false;
374         return uri;
375     }),
376
377     /**
378      * Iterates over the objects in this directory.
379      */
380     iterDirectory: function () {
381         if (!this.exists())
382             throw Error(_("io.noSuchFile"));
383         if (!this.isDirectory())
384             throw Error(_("io.eNotDir"));
385         for (let file in iter(this.directoryEntries))
386             yield File(file);
387     },
388
389     /**
390      * Returns a new file for the given child of this directory entry.
391      */
392     child: function (name) {
393         let f = this.constructor(this);
394         for each (let elem in name.split(File.pathSplit))
395             f.append(elem);
396         return f;
397     },
398
399     /**
400      * Returns an iterator for all lines in a file.
401      */
402     get lines() File.readLines(services.FileInStream(this, -1, 0, 0),
403                                this.charset),
404
405     /**
406      * Reads this file's entire contents in "text" mode and returns the
407      * content as a string.
408      *
409      * @param {string} encoding The encoding from which to decode the file.
410      *          @default #charset
411      * @returns {string}
412      */
413     read: function (encoding) {
414         let ifstream = services.FileInStream(this, -1, 0, 0);
415
416         return File.readStream(ifstream, encoding || this.charset);
417     },
418
419     /**
420      * Returns the list of files in this directory.
421      *
422      * @param {boolean} sort Whether to sort the returned directory
423      *     entries.
424      * @returns {[nsIFile]}
425      */
426     readDirectory: function (sort) {
427         if (!this.isDirectory())
428             throw Error(_("io.eNotDir"));
429
430         let array = [e for (e in this.iterDirectory())];
431         if (sort)
432             array.sort(function (a, b) b.isDirectory() - a.isDirectory() || String.localeCompare(a.path, b.path));
433         return array;
434     },
435
436     /**
437      * Returns a new nsIFileURL object for this file.
438      *
439      * @returns {nsIFileURL}
440      */
441     toURI: function toURI() services.io.newFileURI(this),
442
443     /**
444      * Writes the string *buf* to this file.
445      *
446      * @param {string} buf The file content.
447      * @param {string|number} mode The file access mode, a bitwise OR of
448      *     the following flags:
449      *       {@link #MODE_RDONLY}:   0x01
450      *       {@link #MODE_WRONLY}:   0x02
451      *       {@link #MODE_RDWR}:     0x04
452      *       {@link #MODE_CREATE}:   0x08
453      *       {@link #MODE_APPEND}:   0x10
454      *       {@link #MODE_TRUNCATE}: 0x20
455      *       {@link #MODE_SYNC}:     0x40
456      *     Alternatively, the following abbreviations may be used:
457      *       ">"  is equivalent to {@link #MODE_WRONLY} | {@link #MODE_CREATE} | {@link #MODE_TRUNCATE}
458      *       ">>" is equivalent to {@link #MODE_WRONLY} | {@link #MODE_CREATE} | {@link #MODE_APPEND}
459      * @default ">"
460      * @param {number} perms The file mode bits of the created file. This
461      *     is only used when creating a new file and does not change
462      *     permissions if the file exists.
463      * @default 0644
464      * @param {string} encoding The encoding to used to write the file.
465      * @default #charset
466      */
467     write: function (buf, mode, perms, encoding) {
468         function getStream(defaultChar) {
469             return services.ConvOutStream(ofstream, encoding, 0, defaultChar);
470         }
471         if (buf instanceof File)
472             buf = buf.read();
473
474         if (!encoding)
475             encoding = this.charset;
476
477         if (mode == ">>")
478             mode = File.MODE_WRONLY | File.MODE_CREATE | File.MODE_APPEND;
479         else if (!mode || mode == ">")
480             mode = File.MODE_WRONLY | File.MODE_CREATE | File.MODE_TRUNCATE;
481
482         if (!perms)
483             perms = octal(644);
484         if (!this.exists()) // OCREAT won't create the directory
485             this.create(this.NORMAL_FILE_TYPE, perms);
486
487         let ofstream = services.FileOutStream(this, mode, perms, 0);
488         try {
489             var ocstream = getStream(0);
490             ocstream.writeString(buf);
491         }
492         catch (e if e.result == Cr.NS_ERROR_LOSS_OF_SIGNIFICANT_DATA) {
493             ocstream.close();
494             ocstream = getStream("?".charCodeAt(0));
495             ocstream.writeString(buf);
496             return false;
497         }
498         finally {
499             try {
500                 ocstream.close();
501             }
502             catch (e) {}
503             ofstream.close();
504         }
505         return true;
506     }
507 }, {
508     /**
509      * @property {number} Open for reading only.
510      * @final
511      */
512     MODE_RDONLY: 0x01,
513
514     /**
515      * @property {number} Open for writing only.
516      * @final
517      */
518     MODE_WRONLY: 0x02,
519
520     /**
521      * @property {number} Open for reading and writing.
522      * @final
523      */
524     MODE_RDWR: 0x04,
525
526     /**
527      * @property {number} If the file does not exist, the file is created.
528      *     If the file exists, this flag has no effect.
529      * @final
530      */
531     MODE_CREATE: 0x08,
532
533     /**
534      * @property {number} The file pointer is set to the end of the file
535      *     prior to each write.
536      * @final
537      */
538     MODE_APPEND: 0x10,
539
540     /**
541      * @property {number} If the file exists, its length is truncated to 0.
542      * @final
543      */
544     MODE_TRUNCATE: 0x20,
545
546     /**
547      * @property {number} If set, each write will wait for both the file
548      *     data and file status to be physically updated.
549      * @final
550      */
551     MODE_SYNC: 0x40,
552
553     /**
554      * @property {number} With MODE_CREATE, if the file does not exist, the
555      *     file is created. If the file already exists, no action and NULL
556      *     is returned.
557      * @final
558      */
559     MODE_EXCL: 0x80,
560
561     /**
562      * @property {string} The current platform's path separator.
563      */
564     PATH_SEP: Class.Memoize(function () {
565         let f = services.directory.get("CurProcD", Ci.nsIFile);
566         f.append("foo");
567         return f.path.substr(f.parent.path.length, 1);
568     }),
569
570     pathSplit: Class.Memoize(function () util.regexp("(?:/|" + util.regexp.escape(this.PATH_SEP) + ")", "g")),
571
572     DoesNotExist: function DoesNotExist(path, error) ({
573         path: path,
574         exists: function () false,
575         __noSuchMethod__: function () { throw error || Error("Does not exist"); }
576     }),
577
578     defaultEncoding: "UTF-8",
579
580     /**
581      * Expands "~" and environment variables in *path*.
582      *
583      * "~" is expanded to to the value of $HOME. On Windows if this is not
584      * set then the following are tried in order:
585      *   $USERPROFILE
586      *   ${HOMDRIVE}$HOMEPATH
587      *
588      * The variable notation is $VAR (terminated by a non-word character)
589      * or ${VAR}. %VAR% is also supported on Windows.
590      *
591      * @param {string} path The unexpanded path string.
592      * @param {boolean} relative Whether the path is relative or absolute.
593      * @returns {string}
594      */
595     expandPath: function expandPath(path, relative) {
596         function getenv(name) services.environment.get(name);
597
598         // expand any $ENV vars - this is naive but so is Vim and we like to be compatible
599         // TODO: Vim does not expand variables set to an empty string (and documents it).
600         // Kris reckons we shouldn't replicate this 'bug'. --djk
601         // TODO: should we be doing this for all paths?
602         function expand(path) path.replace(
603             !win32 ? /\$(\w+)\b|\${(\w+)}/g
604                    : /\$(\w+)\b|\${(\w+)}|%(\w+)%/g,
605             function (m, n1, n2, n3) getenv(n1 || n2 || n3) || m
606         );
607         path = expand(path);
608
609         // expand ~
610         // Yuck.
611         if (!relative && RegExp("~(?:$|[/" + util.regexp.escape(File.PATH_SEP) + "])").test(path)) {
612             // Try $HOME first, on all systems
613             let home = getenv("HOME");
614
615             // Windows has its own idiosyncratic $HOME variables.
616             if (win32 && (!home || !File(home).exists()))
617                 home = getenv("USERPROFILE") ||
618                        getenv("HOMEDRIVE") + getenv("HOMEPATH");
619
620             path = home + path.substr(1);
621         }
622
623         // TODO: Vim expands paths twice, once before checking for ~, once
624         // after, but doesn't document it. Is this just a bug? --Kris
625         path = expand(path);
626         return path.replace("/", File.PATH_SEP, "g");
627     },
628
629     expandPathList: function (list) list.map(this.expandPath),
630
631     readURL: function readURL(url, encoding) {
632         let channel = services.io.newChannel(url, null, null);
633         channel.contentType = "text/plain";
634         return this.readStream(channel.open(), encoding);
635     },
636
637     readStream: function readStream(ifstream, encoding) {
638         try {
639             var icstream = services.CharsetStream(
640                     ifstream, encoding || File.defaultEncoding, 4096, // buffer size
641                     services.CharsetStream.DEFAULT_REPLACEMENT_CHARACTER);
642
643             let buffer = [];
644             let str = {};
645             while (icstream.readString(4096, str) != 0)
646                 buffer.push(str.value);
647             return buffer.join("");
648         }
649         finally {
650             icstream.close();
651             ifstream.close();
652         }
653     },
654
655     readLines: function readLines(ifstream, encoding) {
656         try {
657             var icstream = services.CharsetStream(
658                     ifstream, encoding || File.defaultEncoding, 4096, // buffer size
659                     services.CharsetStream.DEFAULT_REPLACEMENT_CHARACTER);
660
661             var value = {};
662             while (icstream.readLine(value))
663                 yield value.value;
664         }
665         finally {
666             icstream.close();
667             ifstream.close();
668         }
669     },
670
671
672     isAbsolutePath: function isAbsolutePath(path) {
673         try {
674             services.File().initWithPath(path);
675             return true;
676         }
677         catch (e) {
678             return false;
679         }
680     },
681
682     joinPaths: function joinPaths(head, tail, cwd) {
683         let path = this(head, cwd);
684         try {
685             // FIXME: should only expand environment vars and normalize path separators
686             path.appendRelativePath(this.expandPath(tail, true));
687         }
688         catch (e) {
689             return File.DoesNotExist(e);
690         }
691         return path;
692     },
693
694     replacePathSep: function (path) path.replace("/", File.PATH_SEP, "g")
695 });
696
697 endModule();
698
699 // catch(e){ dump(e + "\n" + (e.stack || Error().stack)); Components.utils.reportError(e) }
700
701 // vim: set fdm=marker sw=4 sts=4 et ft=javascript: