Source code

Revision control

Other Tools

1
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
2
* vim: sw=2 ts=2 sts=2 expandtab filetype=javascript
3
* This Source Code Form is subject to the terms of the Mozilla Public
4
* License, v. 2.0. If a copy of the MPL was not distributed with this
5
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6
7
var EXPORTED_SYMBOLS = ["PlacesBackups"];
8
9
const { XPCOMUtils } = ChromeUtils.import(
11
);
12
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
13
14
XPCOMUtils.defineLazyModuleGetters(this, {
18
});
19
20
XPCOMUtils.defineLazyGetter(
21
this,
22
"filenamesRegex",
23
() =>
24
/^bookmarks-([0-9-]+)(?:_([0-9]+)){0,1}(?:_([a-z0-9=+-]{24})){0,1}\.(json(lz4)?)$/i
25
);
26
27
async function limitBackups(aMaxBackups, backupFiles) {
28
if (
29
typeof aMaxBackups == "number" &&
30
aMaxBackups > -1 &&
31
backupFiles.length >= aMaxBackups
32
) {
33
let numberOfBackupsToDelete = backupFiles.length - aMaxBackups;
34
while (numberOfBackupsToDelete--) {
35
let oldestBackup = backupFiles.pop();
36
await OS.File.remove(oldestBackup);
37
}
38
}
39
}
40
41
/**
42
* Appends meta-data information to a given filename.
43
*/
44
function appendMetaDataToFilename(aFilename, aMetaData) {
45
let matches = aFilename.match(filenamesRegex);
46
return (
47
"bookmarks-" +
48
matches[1] +
49
"_" +
50
aMetaData.count +
51
"_" +
52
aMetaData.hash +
53
"." +
54
matches[4]
55
);
56
}
57
58
/**
59
* Gets the hash from a backup filename.
60
*
61
* @return the extracted hash or null.
62
*/
63
function getHashFromFilename(aFilename) {
64
let matches = aFilename.match(filenamesRegex);
65
if (matches && matches[3]) {
66
return matches[3];
67
}
68
return null;
69
}
70
71
/**
72
* Given two filenames, checks if they contain the same date.
73
*/
74
function isFilenameWithSameDate(aSourceName, aTargetName) {
75
let sourceMatches = aSourceName.match(filenamesRegex);
76
let targetMatches = aTargetName.match(filenamesRegex);
77
78
return sourceMatches && targetMatches && sourceMatches[1] == targetMatches[1];
79
}
80
81
/**
82
* Given a filename, searches for another backup with the same date.
83
*
84
* @return OS.File path string or null.
85
*/
86
function getBackupFileForSameDate(aFilename) {
87
return (async function() {
88
let backupFiles = await PlacesBackups.getBackupFiles();
89
for (let backupFile of backupFiles) {
90
if (isFilenameWithSameDate(OS.Path.basename(backupFile), aFilename)) {
91
return backupFile;
92
}
93
}
94
return null;
95
})();
96
}
97
98
var PlacesBackups = {
99
/**
100
* Matches the backup filename:
101
* 0: file name
102
* 1: date in form Y-m-d
103
* 2: bookmarks count
104
* 3: contents hash
105
* 4: file extension
106
*/
107
get filenamesRegex() {
108
return filenamesRegex;
109
},
110
111
/**
112
* Gets backup folder asynchronously.
113
* @return {Promise}
114
* @resolve the folder (the folder string path).
115
*/
116
getBackupFolder: function PB_getBackupFolder() {
117
return (async () => {
118
if (this._backupFolder) {
119
return this._backupFolder;
120
}
121
let profileDir = OS.Constants.Path.profileDir;
122
let backupsDirPath = OS.Path.join(
123
profileDir,
124
this.profileRelativeFolderPath
125
);
126
await OS.File.makeDir(backupsDirPath, { ignoreExisting: true });
127
return (this._backupFolder = backupsDirPath);
128
})();
129
},
130
131
get profileRelativeFolderPath() {
132
return "bookmarkbackups";
133
},
134
135
/**
136
* Cache current backups in a sorted (by date DESC) array.
137
* @return {Promise}
138
* @resolve a sorted array of string paths.
139
*/
140
getBackupFiles: function PB_getBackupFiles() {
141
return (async () => {
142
if (this._backupFiles) {
143
return this._backupFiles;
144
}
145
146
this._backupFiles = [];
147
148
let backupFolderPath = await this.getBackupFolder();
149
let iterator = new OS.File.DirectoryIterator(backupFolderPath);
150
await iterator.forEach(aEntry => {
151
// Since this is a lazy getter and OS.File I/O is serialized, we can
152
// safely remove .tmp files without risking to remove ongoing backups.
153
if (aEntry.name.endsWith(".tmp")) {
154
OS.File.remove(aEntry.path);
155
return undefined;
156
}
157
158
if (filenamesRegex.test(aEntry.name)) {
159
// Remove bogus backups in future dates.
160
let filePath = aEntry.path;
161
if (this.getDateForFile(filePath) > new Date()) {
162
return OS.File.remove(filePath);
163
}
164
this._backupFiles.push(filePath);
165
}
166
167
return undefined;
168
});
169
iterator.close();
170
171
this._backupFiles.sort((a, b) => {
172
let aDate = this.getDateForFile(a);
173
let bDate = this.getDateForFile(b);
174
return bDate - aDate;
175
});
176
177
return this._backupFiles;
178
})();
179
},
180
181
/**
182
* Generates a ISO date string (YYYY-MM-DD) from a Date object.
183
*
184
* @param dateObj
185
* The date object to parse.
186
* @return an ISO date string.
187
*/
188
toISODateString: function toISODateString(dateObj) {
189
if (!dateObj || dateObj.constructor.name != "Date" || !dateObj.getTime()) {
190
throw new Error("invalid date object");
191
}
192
let padDate = val => ("0" + val).substr(-2, 2);
193
return [
194
dateObj.getFullYear(),
195
padDate(dateObj.getMonth() + 1),
196
padDate(dateObj.getDate()),
197
].join("-");
198
},
199
200
/**
201
* Creates a filename for bookmarks backup files.
202
*
203
* @param [optional] aDateObj
204
* Date object used to build the filename.
205
* Will use current date if empty.
206
* @param [optional] bool - aCompress
207
* Determines if file extension is json or jsonlz4
208
Default is json
209
* @return A bookmarks backup filename.
210
*/
211
getFilenameForDate: function PB_getFilenameForDate(aDateObj, aCompress) {
212
let dateObj = aDateObj || new Date();
213
// Use YYYY-MM-DD (ISO 8601) as it doesn't contain illegal characters
214
// and makes the alphabetical order of multiple backup files more useful.
215
return (
216
"bookmarks-" +
217
PlacesBackups.toISODateString(dateObj) +
218
".json" +
219
(aCompress ? "lz4" : "")
220
);
221
},
222
223
/**
224
* Creates a Date object from a backup file. The date is the backup
225
* creation date.
226
*
227
* @param {Sring} aBackupFile The path of the backup.
228
* @return {Date} A Date object for the backup's creation time.
229
*/
230
getDateForFile: function PB_getDateForFile(aBackupFile) {
231
let filename = OS.Path.basename(aBackupFile);
232
let matches = filename.match(filenamesRegex);
233
if (!matches) {
234
throw new Error(`Invalid backup file name: ${filename}`);
235
}
236
return new Date(matches[1].replace(/-/g, "/"));
237
},
238
239
/**
240
* Get the most recent backup file.
241
*
242
* @return {Promise}
243
* @result the path to the file.
244
*/
245
getMostRecentBackup: function PB_getMostRecentBackup() {
246
return (async () => {
247
let entries = await this.getBackupFiles();
248
for (let entry of entries) {
249
let rx = /\.json(lz4)?$/;
250
if (OS.Path.basename(entry).match(rx)) {
251
return entry;
252
}
253
}
254
return null;
255
})();
256
},
257
258
/**
259
* Serializes bookmarks using JSON, and writes to the supplied file.
260
*
261
* @param aFilePath
262
* OS.File path for the "bookmarks.json" file to be created.
263
* @return {Promise}
264
* @resolves the number of serialized uri nodes.
265
*/
266
async saveBookmarksToJSONFile(aFilePath) {
267
let { count: nodeCount, hash: hash } = await BookmarkJSONUtils.exportToFile(
268
aFilePath
269
);
270
271
let backupFolderPath = await this.getBackupFolder();
272
if (OS.Path.dirname(aFilePath) == backupFolderPath) {
273
// We are creating a backup in the default backups folder,
274
// so just update the internal cache.
275
if (!this._backupFiles) {
276
await this.getBackupFiles();
277
}
278
this._backupFiles.unshift(aFilePath);
279
} else {
280
let aMaxBackup = Services.prefs.getIntPref(
281
"browser.bookmarks.max_backups"
282
);
283
if (aMaxBackup === 0) {
284
if (!this._backupFiles) {
285
await this.getBackupFiles();
286
}
287
limitBackups(aMaxBackup, this._backupFiles);
288
return nodeCount;
289
}
290
// If we are saving to a folder different than our backups folder, then
291
// we also want to create a new compressed version in it.
292
// This way we ensure the latest valid backup is the same saved by the
293
// user. See bug 424389.
294
let mostRecentBackupFile = await this.getMostRecentBackup();
295
if (
296
!mostRecentBackupFile ||
297
hash != getHashFromFilename(OS.Path.basename(mostRecentBackupFile))
298
) {
299
let name = this.getFilenameForDate(undefined, true);
300
let newFilename = appendMetaDataToFilename(name, {
301
count: nodeCount,
302
hash,
303
});
304
let newFilePath = OS.Path.join(backupFolderPath, newFilename);
305
let backupFile = await getBackupFileForSameDate(name);
306
if (backupFile) {
307
// There is already a backup for today, replace it.
308
await OS.File.remove(backupFile, { ignoreAbsent: true });
309
if (!this._backupFiles) {
310
await this.getBackupFiles();
311
} else {
312
this._backupFiles.shift();
313
}
314
this._backupFiles.unshift(newFilePath);
315
} else {
316
// There is no backup for today, add the new one.
317
if (!this._backupFiles) {
318
await this.getBackupFiles();
319
}
320
this._backupFiles.unshift(newFilePath);
321
}
322
let jsonString = await OS.File.read(aFilePath);
323
await OS.File.writeAtomic(newFilePath, jsonString, {
324
compression: "lz4",
325
});
326
await limitBackups(aMaxBackup, this._backupFiles);
327
}
328
}
329
return nodeCount;
330
},
331
332
/**
333
* Creates a dated backup in <profile>/bookmarkbackups.
334
* Stores the bookmarks using a lz4 compressed JSON file.
335
*
336
* @param [optional] int aMaxBackups
337
* The maximum number of backups to keep. If set to 0
338
* all existing backups are removed and aForceBackup is
339
* ignored, so a new one won't be created.
340
* @param [optional] bool aForceBackup
341
* Forces creating a backup even if one was already
342
* created that day (overwrites).
343
* @return {Promise}
344
*/
345
create: function PB_create(aMaxBackups, aForceBackup) {
346
return (async () => {
347
if (aMaxBackups === 0) {
348
// Backups are disabled, delete any existing one and bail out.
349
if (!this._backupFiles) {
350
await this.getBackupFiles();
351
}
352
await limitBackups(0, this._backupFiles);
353
return;
354
}
355
356
// Ensure to initialize _backupFiles
357
if (!this._backupFiles) {
358
await this.getBackupFiles();
359
}
360
let newBackupFilename = this.getFilenameForDate(undefined, true);
361
// If we already have a backup for today we should do nothing, unless we
362
// were required to enforce a new backup.
363
let backupFile = await getBackupFileForSameDate(newBackupFilename);
364
if (backupFile && !aForceBackup) {
365
return;
366
}
367
368
if (backupFile) {
369
// In case there is a backup for today we should recreate it.
370
this._backupFiles.shift();
371
await OS.File.remove(backupFile, { ignoreAbsent: true });
372
}
373
374
// Now check the hash of the most recent backup, and try to create a new
375
// backup, if that fails due to hash conflict, just rename the old backup.
376
let mostRecentBackupFile = await this.getMostRecentBackup();
377
let mostRecentHash =
378
mostRecentBackupFile &&
379
getHashFromFilename(OS.Path.basename(mostRecentBackupFile));
380
381
// Save bookmarks to a backup file.
382
let backupFolder = await this.getBackupFolder();
383
let newBackupFile = OS.Path.join(backupFolder, newBackupFilename);
384
let newFilenameWithMetaData;
385
try {
386
let {
387
count: nodeCount,
388
hash: hash,
389
} = await BookmarkJSONUtils.exportToFile(newBackupFile, {
390
compress: true,
391
failIfHashIs: mostRecentHash,
392
});
393
newFilenameWithMetaData = appendMetaDataToFilename(newBackupFilename, {
394
count: nodeCount,
395
hash,
396
});
397
} catch (ex) {
398
if (!ex.becauseSameHash) {
399
throw ex;
400
}
401
// The last backup already contained up-to-date information, just
402
// rename it as if it was today's backup.
403
this._backupFiles.shift();
404
newBackupFile = mostRecentBackupFile;
405
// Ensure we retain the proper extension when renaming
406
// the most recent backup file.
407
if (/\.json$/.test(OS.Path.basename(mostRecentBackupFile))) {
408
newBackupFilename = this.getFilenameForDate();
409
}
410
newFilenameWithMetaData = appendMetaDataToFilename(newBackupFilename, {
411
count: this.getBookmarkCountForFile(mostRecentBackupFile),
412
hash: mostRecentHash,
413
});
414
}
415
416
// Append metadata to the backup filename.
417
let newBackupFileWithMetadata = OS.Path.join(
418
backupFolder,
419
newFilenameWithMetaData
420
);
421
await OS.File.move(newBackupFile, newBackupFileWithMetadata);
422
this._backupFiles.unshift(newBackupFileWithMetadata);
423
424
// Limit the number of backups.
425
await limitBackups(aMaxBackups, this._backupFiles);
426
})();
427
},
428
429
/**
430
* Gets the bookmark count for backup file.
431
*
432
* @param aFilePath
433
* File path The backup file.
434
*
435
* @return the bookmark count or null.
436
*/
437
getBookmarkCountForFile: function PB_getBookmarkCountForFile(aFilePath) {
438
let count = null;
439
let filename = OS.Path.basename(aFilePath);
440
let matches = filename.match(filenamesRegex);
441
if (matches && matches[2]) {
442
count = matches[2];
443
}
444
return count;
445
},
446
447
/**
448
* Gets a bookmarks tree representation usable to create backups in different
449
* file formats. The root or the tree is PlacesUtils.bookmarks.rootGuid.
450
*
451
* @return an object representing a tree with the places root as its root.
452
* Each bookmark is represented by an object having these properties:
453
* * id: the item id (make this not enumerable after bug 824502)
454
* * title: the title
455
* * guid: unique id
456
* * parent: item id of the parent folder, not enumerable
457
* * index: the position in the parent
458
* * dateAdded: microseconds from the epoch
459
* * lastModified: microseconds from the epoch
460
* * type: type of the originating node as defined in PlacesUtils
461
* The following properties exist only for a subset of bookmarks:
462
* * annos: array of annotations
463
* * uri: url
464
* * iconuri: favicon's url
465
* * keyword: associated keyword
466
* * charset: last known charset
467
* * tags: csv string of tags
468
* * root: string describing whether this represents a root
469
* * children: array of child items in a folder
470
*/
471
async getBookmarksTree() {
472
let startTime = Date.now();
473
let root = await PlacesUtils.promiseBookmarksTree(
474
PlacesUtils.bookmarks.rootGuid,
475
{
476
includeItemIds: true,
477
}
478
);
479
480
try {
481
Services.telemetry
482
.getHistogramById("PLACES_BACKUPS_BOOKMARKSTREE_MS")
483
.add(Date.now() - startTime);
484
} catch (ex) {
485
Cu.reportError("Unable to report telemetry.");
486
}
487
return [root, root.itemsCount];
488
},
489
};