Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
michael@0 | 1 | /* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- |
michael@0 | 2 | * vim: sw=2 ts=2 sts=2 expandtab filetype=javascript |
michael@0 | 3 | * This Source Code Form is subject to the terms of the Mozilla Public |
michael@0 | 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this |
michael@0 | 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
michael@0 | 6 | |
michael@0 | 7 | this.EXPORTED_SYMBOLS = ["PlacesBackups"]; |
michael@0 | 8 | |
michael@0 | 9 | const Ci = Components.interfaces; |
michael@0 | 10 | const Cu = Components.utils; |
michael@0 | 11 | const Cc = Components.classes; |
michael@0 | 12 | |
michael@0 | 13 | Cu.import("resource://gre/modules/XPCOMUtils.jsm"); |
michael@0 | 14 | Cu.import("resource://gre/modules/Services.jsm"); |
michael@0 | 15 | Cu.import("resource://gre/modules/PlacesUtils.jsm"); |
michael@0 | 16 | Cu.import("resource://gre/modules/Task.jsm"); |
michael@0 | 17 | Cu.import("resource://gre/modules/osfile.jsm"); |
michael@0 | 18 | Cu.import("resource://gre/modules/NetUtil.jsm"); |
michael@0 | 19 | |
michael@0 | 20 | XPCOMUtils.defineLazyModuleGetter(this, "BookmarkJSONUtils", |
michael@0 | 21 | "resource://gre/modules/BookmarkJSONUtils.jsm"); |
michael@0 | 22 | XPCOMUtils.defineLazyModuleGetter(this, "Deprecated", |
michael@0 | 23 | "resource://gre/modules/Deprecated.jsm"); |
michael@0 | 24 | XPCOMUtils.defineLazyModuleGetter(this, "OS", |
michael@0 | 25 | "resource://gre/modules/osfile.jsm"); |
michael@0 | 26 | XPCOMUtils.defineLazyModuleGetter(this, "Sqlite", |
michael@0 | 27 | "resource://gre/modules/Sqlite.jsm"); |
michael@0 | 28 | |
michael@0 | 29 | XPCOMUtils.defineLazyGetter(this, "localFileCtor", |
michael@0 | 30 | () => Components.Constructor("@mozilla.org/file/local;1", |
michael@0 | 31 | "nsILocalFile", "initWithPath")); |
michael@0 | 32 | |
michael@0 | 33 | XPCOMUtils.defineLazyGetter(this, "filenamesRegex", |
michael@0 | 34 | () => new RegExp("^bookmarks-([0-9\-]+)(?:_([0-9]+)){0,1}(?:_([a-z0-9=\+\-]{24})){0,1}\.(json|html)", "i") |
michael@0 | 35 | ); |
michael@0 | 36 | |
michael@0 | 37 | /** |
michael@0 | 38 | * Appends meta-data information to a given filename. |
michael@0 | 39 | */ |
michael@0 | 40 | function appendMetaDataToFilename(aFilename, aMetaData) { |
michael@0 | 41 | let matches = aFilename.match(filenamesRegex); |
michael@0 | 42 | return "bookmarks-" + matches[1] + |
michael@0 | 43 | "_" + aMetaData.count + |
michael@0 | 44 | "_" + aMetaData.hash + |
michael@0 | 45 | "." + matches[4]; |
michael@0 | 46 | } |
michael@0 | 47 | |
michael@0 | 48 | /** |
michael@0 | 49 | * Gets the hash from a backup filename. |
michael@0 | 50 | * |
michael@0 | 51 | * @return the extracted hash or null. |
michael@0 | 52 | */ |
michael@0 | 53 | function getHashFromFilename(aFilename) { |
michael@0 | 54 | let matches = aFilename.match(filenamesRegex); |
michael@0 | 55 | if (matches && matches[3]) |
michael@0 | 56 | return matches[3]; |
michael@0 | 57 | return null; |
michael@0 | 58 | } |
michael@0 | 59 | |
michael@0 | 60 | /** |
michael@0 | 61 | * Given two filenames, checks if they contain the same date. |
michael@0 | 62 | */ |
michael@0 | 63 | function isFilenameWithSameDate(aSourceName, aTargetName) { |
michael@0 | 64 | let sourceMatches = aSourceName.match(filenamesRegex); |
michael@0 | 65 | let targetMatches = aTargetName.match(filenamesRegex); |
michael@0 | 66 | |
michael@0 | 67 | return sourceMatches && targetMatches && |
michael@0 | 68 | sourceMatches[1] == targetMatches[1] && |
michael@0 | 69 | sourceMatches[4] == targetMatches[4]; |
michael@0 | 70 | } |
michael@0 | 71 | |
michael@0 | 72 | /** |
michael@0 | 73 | * Given a filename, searches for another backup with the same date. |
michael@0 | 74 | * |
michael@0 | 75 | * @return OS.File path string or null. |
michael@0 | 76 | */ |
michael@0 | 77 | function getBackupFileForSameDate(aFilename) { |
michael@0 | 78 | return Task.spawn(function* () { |
michael@0 | 79 | let backupFiles = yield PlacesBackups.getBackupFiles(); |
michael@0 | 80 | for (let backupFile of backupFiles) { |
michael@0 | 81 | if (isFilenameWithSameDate(OS.Path.basename(backupFile), aFilename)) |
michael@0 | 82 | return backupFile; |
michael@0 | 83 | } |
michael@0 | 84 | return null; |
michael@0 | 85 | }); |
michael@0 | 86 | } |
michael@0 | 87 | |
michael@0 | 88 | this.PlacesBackups = { |
michael@0 | 89 | /** |
michael@0 | 90 | * Matches the backup filename: |
michael@0 | 91 | * 0: file name |
michael@0 | 92 | * 1: date in form Y-m-d |
michael@0 | 93 | * 2: bookmarks count |
michael@0 | 94 | * 3: contents hash |
michael@0 | 95 | * 4: file extension |
michael@0 | 96 | */ |
michael@0 | 97 | get filenamesRegex() filenamesRegex, |
michael@0 | 98 | |
michael@0 | 99 | get folder() { |
michael@0 | 100 | Deprecated.warning( |
michael@0 | 101 | "PlacesBackups.folder is deprecated and will be removed in a future version", |
michael@0 | 102 | "https://bugzilla.mozilla.org/show_bug.cgi?id=859695"); |
michael@0 | 103 | return this._folder; |
michael@0 | 104 | }, |
michael@0 | 105 | |
michael@0 | 106 | /** |
michael@0 | 107 | * This exists just to avoid spamming deprecate warnings from internal calls |
michael@0 | 108 | * needed to support deprecated methods themselves. |
michael@0 | 109 | */ |
michael@0 | 110 | get _folder() { |
michael@0 | 111 | let bookmarksBackupDir = Services.dirsvc.get("ProfD", Ci.nsILocalFile); |
michael@0 | 112 | bookmarksBackupDir.append(this.profileRelativeFolderPath); |
michael@0 | 113 | if (!bookmarksBackupDir.exists()) { |
michael@0 | 114 | bookmarksBackupDir.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0700", 8)); |
michael@0 | 115 | if (!bookmarksBackupDir.exists()) |
michael@0 | 116 | throw("Unable to create bookmarks backup folder"); |
michael@0 | 117 | } |
michael@0 | 118 | delete this._folder; |
michael@0 | 119 | return this._folder = bookmarksBackupDir; |
michael@0 | 120 | }, |
michael@0 | 121 | |
michael@0 | 122 | /** |
michael@0 | 123 | * Gets backup folder asynchronously. |
michael@0 | 124 | * @return {Promise} |
michael@0 | 125 | * @resolve the folder (the folder string path). |
michael@0 | 126 | */ |
michael@0 | 127 | getBackupFolder: function PB_getBackupFolder() { |
michael@0 | 128 | return Task.spawn(function* () { |
michael@0 | 129 | if (this._backupFolder) { |
michael@0 | 130 | return this._backupFolder; |
michael@0 | 131 | } |
michael@0 | 132 | let profileDir = OS.Constants.Path.profileDir; |
michael@0 | 133 | let backupsDirPath = OS.Path.join(profileDir, this.profileRelativeFolderPath); |
michael@0 | 134 | yield OS.File.makeDir(backupsDirPath, { ignoreExisting: true }); |
michael@0 | 135 | return this._backupFolder = backupsDirPath; |
michael@0 | 136 | }.bind(this)); |
michael@0 | 137 | }, |
michael@0 | 138 | |
michael@0 | 139 | get profileRelativeFolderPath() "bookmarkbackups", |
michael@0 | 140 | |
michael@0 | 141 | /** |
michael@0 | 142 | * Cache current backups in a sorted (by date DESC) array. |
michael@0 | 143 | */ |
michael@0 | 144 | get entries() { |
michael@0 | 145 | Deprecated.warning( |
michael@0 | 146 | "PlacesBackups.entries is deprecated and will be removed in a future version", |
michael@0 | 147 | "https://bugzilla.mozilla.org/show_bug.cgi?id=859695"); |
michael@0 | 148 | return this._entries; |
michael@0 | 149 | }, |
michael@0 | 150 | |
michael@0 | 151 | /** |
michael@0 | 152 | * This exists just to avoid spamming deprecate warnings from internal calls |
michael@0 | 153 | * needed to support deprecated methods themselves. |
michael@0 | 154 | */ |
michael@0 | 155 | get _entries() { |
michael@0 | 156 | delete this._entries; |
michael@0 | 157 | this._entries = []; |
michael@0 | 158 | let files = this._folder.directoryEntries; |
michael@0 | 159 | while (files.hasMoreElements()) { |
michael@0 | 160 | let entry = files.getNext().QueryInterface(Ci.nsIFile); |
michael@0 | 161 | // A valid backup is any file that matches either the localized or |
michael@0 | 162 | // not-localized filename (bug 445704). |
michael@0 | 163 | if (!entry.isHidden() && filenamesRegex.test(entry.leafName)) { |
michael@0 | 164 | // Remove bogus backups in future dates. |
michael@0 | 165 | if (this.getDateForFile(entry) > new Date()) { |
michael@0 | 166 | entry.remove(false); |
michael@0 | 167 | continue; |
michael@0 | 168 | } |
michael@0 | 169 | this._entries.push(entry); |
michael@0 | 170 | } |
michael@0 | 171 | } |
michael@0 | 172 | this._entries.sort((a, b) => { |
michael@0 | 173 | let aDate = this.getDateForFile(a); |
michael@0 | 174 | let bDate = this.getDateForFile(b); |
michael@0 | 175 | return aDate < bDate ? 1 : aDate > bDate ? -1 : 0; |
michael@0 | 176 | }); |
michael@0 | 177 | return this._entries; |
michael@0 | 178 | }, |
michael@0 | 179 | |
michael@0 | 180 | /** |
michael@0 | 181 | * Cache current backups in a sorted (by date DESC) array. |
michael@0 | 182 | * @return {Promise} |
michael@0 | 183 | * @resolve a sorted array of string paths. |
michael@0 | 184 | */ |
michael@0 | 185 | getBackupFiles: function PB_getBackupFiles() { |
michael@0 | 186 | return Task.spawn(function* () { |
michael@0 | 187 | if (this._backupFiles) |
michael@0 | 188 | return this._backupFiles; |
michael@0 | 189 | |
michael@0 | 190 | this._backupFiles = []; |
michael@0 | 191 | |
michael@0 | 192 | let backupFolderPath = yield this.getBackupFolder(); |
michael@0 | 193 | let iterator = new OS.File.DirectoryIterator(backupFolderPath); |
michael@0 | 194 | yield iterator.forEach(function(aEntry) { |
michael@0 | 195 | // Since this is a lazy getter and OS.File I/O is serialized, we can |
michael@0 | 196 | // safely remove .tmp files without risking to remove ongoing backups. |
michael@0 | 197 | if (aEntry.name.endsWith(".tmp")) { |
michael@0 | 198 | OS.File.remove(aEntry.path); |
michael@0 | 199 | return; |
michael@0 | 200 | } |
michael@0 | 201 | |
michael@0 | 202 | if (filenamesRegex.test(aEntry.name)) { |
michael@0 | 203 | // Remove bogus backups in future dates. |
michael@0 | 204 | let filePath = aEntry.path; |
michael@0 | 205 | if (this.getDateForFile(filePath) > new Date()) { |
michael@0 | 206 | return OS.File.remove(filePath); |
michael@0 | 207 | } else { |
michael@0 | 208 | this._backupFiles.push(filePath); |
michael@0 | 209 | } |
michael@0 | 210 | } |
michael@0 | 211 | }.bind(this)); |
michael@0 | 212 | iterator.close(); |
michael@0 | 213 | |
michael@0 | 214 | this._backupFiles.sort((a, b) => { |
michael@0 | 215 | let aDate = this.getDateForFile(a); |
michael@0 | 216 | let bDate = this.getDateForFile(b); |
michael@0 | 217 | return aDate < bDate ? 1 : aDate > bDate ? -1 : 0; |
michael@0 | 218 | }); |
michael@0 | 219 | |
michael@0 | 220 | return this._backupFiles; |
michael@0 | 221 | }.bind(this)); |
michael@0 | 222 | }, |
michael@0 | 223 | |
michael@0 | 224 | /** |
michael@0 | 225 | * Creates a filename for bookmarks backup files. |
michael@0 | 226 | * |
michael@0 | 227 | * @param [optional] aDateObj |
michael@0 | 228 | * Date object used to build the filename. |
michael@0 | 229 | * Will use current date if empty. |
michael@0 | 230 | * @return A bookmarks backup filename. |
michael@0 | 231 | */ |
michael@0 | 232 | getFilenameForDate: function PB_getFilenameForDate(aDateObj) { |
michael@0 | 233 | let dateObj = aDateObj || new Date(); |
michael@0 | 234 | // Use YYYY-MM-DD (ISO 8601) as it doesn't contain illegal characters |
michael@0 | 235 | // and makes the alphabetical order of multiple backup files more useful. |
michael@0 | 236 | return "bookmarks-" + dateObj.toLocaleFormat("%Y-%m-%d") + ".json"; |
michael@0 | 237 | }, |
michael@0 | 238 | |
michael@0 | 239 | /** |
michael@0 | 240 | * Creates a Date object from a backup file. The date is the backup |
michael@0 | 241 | * creation date. |
michael@0 | 242 | * |
michael@0 | 243 | * @param aBackupFile |
michael@0 | 244 | * nsIFile or string path of the backup. |
michael@0 | 245 | * @return A Date object for the backup's creation time. |
michael@0 | 246 | */ |
michael@0 | 247 | getDateForFile: function PB_getDateForFile(aBackupFile) { |
michael@0 | 248 | let filename = (aBackupFile instanceof Ci.nsIFile) ? aBackupFile.leafName |
michael@0 | 249 | : OS.Path.basename(aBackupFile); |
michael@0 | 250 | let matches = filename.match(filenamesRegex); |
michael@0 | 251 | if (!matches) |
michael@0 | 252 | throw("Invalid backup file name: " + filename); |
michael@0 | 253 | return new Date(matches[1].replace(/-/g, "/")); |
michael@0 | 254 | }, |
michael@0 | 255 | |
michael@0 | 256 | /** |
michael@0 | 257 | * Get the most recent backup file. |
michael@0 | 258 | * |
michael@0 | 259 | * @param [optional] aFileExt |
michael@0 | 260 | * Force file extension. Either "html" or "json". |
michael@0 | 261 | * Will check for both if not defined. |
michael@0 | 262 | * @returns nsIFile backup file |
michael@0 | 263 | */ |
michael@0 | 264 | getMostRecent: function PB_getMostRecent(aFileExt) { |
michael@0 | 265 | Deprecated.warning( |
michael@0 | 266 | "PlacesBackups.getMostRecent is deprecated and will be removed in a future version", |
michael@0 | 267 | "https://bugzilla.mozilla.org/show_bug.cgi?id=859695"); |
michael@0 | 268 | |
michael@0 | 269 | let fileExt = aFileExt || "(json|html)"; |
michael@0 | 270 | for (let i = 0; i < this._entries.length; i++) { |
michael@0 | 271 | let rx = new RegExp("\." + fileExt + "$"); |
michael@0 | 272 | if (this._entries[i].leafName.match(rx)) |
michael@0 | 273 | return this._entries[i]; |
michael@0 | 274 | } |
michael@0 | 275 | return null; |
michael@0 | 276 | }, |
michael@0 | 277 | |
michael@0 | 278 | /** |
michael@0 | 279 | * Get the most recent backup file. |
michael@0 | 280 | * |
michael@0 | 281 | * @param [optional] aFileExt |
michael@0 | 282 | * Force file extension. Either "html" or "json". |
michael@0 | 283 | * Will check for both if not defined. |
michael@0 | 284 | * @return {Promise} |
michael@0 | 285 | * @result the path to the file. |
michael@0 | 286 | */ |
michael@0 | 287 | getMostRecentBackup: function PB_getMostRecentBackup(aFileExt) { |
michael@0 | 288 | return Task.spawn(function* () { |
michael@0 | 289 | let fileExt = aFileExt || "(json|html)"; |
michael@0 | 290 | let entries = yield this.getBackupFiles(); |
michael@0 | 291 | for (let entry of entries) { |
michael@0 | 292 | let rx = new RegExp("\." + fileExt + "$"); |
michael@0 | 293 | if (OS.Path.basename(entry).match(rx)) { |
michael@0 | 294 | return entry; |
michael@0 | 295 | } |
michael@0 | 296 | } |
michael@0 | 297 | return null; |
michael@0 | 298 | }.bind(this)); |
michael@0 | 299 | }, |
michael@0 | 300 | |
michael@0 | 301 | /** |
michael@0 | 302 | * Serializes bookmarks using JSON, and writes to the supplied file. |
michael@0 | 303 | * Note: any item that should not be backed up must be annotated with |
michael@0 | 304 | * "places/excludeFromBackup". |
michael@0 | 305 | * |
michael@0 | 306 | * @param aFilePath |
michael@0 | 307 | * OS.File path for the "bookmarks.json" file to be created. |
michael@0 | 308 | * @return {Promise} |
michael@0 | 309 | * @resolves the number of serialized uri nodes. |
michael@0 | 310 | * @deprecated passing an nsIFile is deprecated |
michael@0 | 311 | */ |
michael@0 | 312 | saveBookmarksToJSONFile: function PB_saveBookmarksToJSONFile(aFilePath) { |
michael@0 | 313 | if (aFilePath instanceof Ci.nsIFile) { |
michael@0 | 314 | Deprecated.warning("Passing an nsIFile to PlacesBackups.saveBookmarksToJSONFile " + |
michael@0 | 315 | "is deprecated. Please use an OS.File path instead.", |
michael@0 | 316 | "https://developer.mozilla.org/docs/JavaScript_OS.File"); |
michael@0 | 317 | aFilePath = aFilePath.path; |
michael@0 | 318 | } |
michael@0 | 319 | return Task.spawn(function* () { |
michael@0 | 320 | let { count: nodeCount, hash: hash } = |
michael@0 | 321 | yield BookmarkJSONUtils.exportToFile(aFilePath); |
michael@0 | 322 | |
michael@0 | 323 | let backupFolderPath = yield this.getBackupFolder(); |
michael@0 | 324 | if (OS.Path.dirname(aFilePath) == backupFolderPath) { |
michael@0 | 325 | // We are creating a backup in the default backups folder, |
michael@0 | 326 | // so just update the internal cache. |
michael@0 | 327 | this._entries.unshift(new localFileCtor(aFilePath)); |
michael@0 | 328 | if (!this._backupFiles) { |
michael@0 | 329 | yield this.getBackupFiles(); |
michael@0 | 330 | } |
michael@0 | 331 | this._backupFiles.unshift(aFilePath); |
michael@0 | 332 | } else { |
michael@0 | 333 | // If we are saving to a folder different than our backups folder, then |
michael@0 | 334 | // we also want to copy this new backup to it. |
michael@0 | 335 | // This way we ensure the latest valid backup is the same saved by the |
michael@0 | 336 | // user. See bug 424389. |
michael@0 | 337 | let mostRecentBackupFile = yield this.getMostRecentBackup("json"); |
michael@0 | 338 | if (!mostRecentBackupFile || |
michael@0 | 339 | hash != getHashFromFilename(OS.Path.basename(mostRecentBackupFile))) { |
michael@0 | 340 | let name = this.getFilenameForDate(); |
michael@0 | 341 | let newFilename = appendMetaDataToFilename(name, |
michael@0 | 342 | { count: nodeCount, |
michael@0 | 343 | hash: hash }); |
michael@0 | 344 | let newFilePath = OS.Path.join(backupFolderPath, newFilename); |
michael@0 | 345 | let backupFile = yield getBackupFileForSameDate(name); |
michael@0 | 346 | if (backupFile) { |
michael@0 | 347 | // There is already a backup for today, replace it. |
michael@0 | 348 | yield OS.File.remove(backupFile, { ignoreAbsent: true }); |
michael@0 | 349 | if (!this._backupFiles) |
michael@0 | 350 | yield this.getBackupFiles(); |
michael@0 | 351 | else |
michael@0 | 352 | this._backupFiles.shift(); |
michael@0 | 353 | this._backupFiles.unshift(newFilePath); |
michael@0 | 354 | } else { |
michael@0 | 355 | // There is no backup for today, add the new one. |
michael@0 | 356 | this._entries.unshift(new localFileCtor(newFilePath)); |
michael@0 | 357 | if (!this._backupFiles) |
michael@0 | 358 | yield this.getBackupFiles(); |
michael@0 | 359 | this._backupFiles.unshift(newFilePath); |
michael@0 | 360 | } |
michael@0 | 361 | |
michael@0 | 362 | yield OS.File.copy(aFilePath, newFilePath); |
michael@0 | 363 | } |
michael@0 | 364 | } |
michael@0 | 365 | |
michael@0 | 366 | return nodeCount; |
michael@0 | 367 | }.bind(this)); |
michael@0 | 368 | }, |
michael@0 | 369 | |
michael@0 | 370 | /** |
michael@0 | 371 | * Creates a dated backup in <profile>/bookmarkbackups. |
michael@0 | 372 | * Stores the bookmarks using JSON. |
michael@0 | 373 | * Note: any item that should not be backed up must be annotated with |
michael@0 | 374 | * "places/excludeFromBackup". |
michael@0 | 375 | * |
michael@0 | 376 | * @param [optional] int aMaxBackups |
michael@0 | 377 | * The maximum number of backups to keep. If set to 0 |
michael@0 | 378 | * all existing backups are removed and aForceBackup is |
michael@0 | 379 | * ignored, so a new one won't be created. |
michael@0 | 380 | * @param [optional] bool aForceBackup |
michael@0 | 381 | * Forces creating a backup even if one was already |
michael@0 | 382 | * created that day (overwrites). |
michael@0 | 383 | * @return {Promise} |
michael@0 | 384 | */ |
michael@0 | 385 | create: function PB_create(aMaxBackups, aForceBackup) { |
michael@0 | 386 | let limitBackups = function* () { |
michael@0 | 387 | let backupFiles = yield this.getBackupFiles(); |
michael@0 | 388 | if (typeof aMaxBackups == "number" && aMaxBackups > -1 && |
michael@0 | 389 | backupFiles.length >= aMaxBackups) { |
michael@0 | 390 | let numberOfBackupsToDelete = backupFiles.length - aMaxBackups; |
michael@0 | 391 | while (numberOfBackupsToDelete--) { |
michael@0 | 392 | this._entries.pop(); |
michael@0 | 393 | let oldestBackup = this._backupFiles.pop(); |
michael@0 | 394 | yield OS.File.remove(oldestBackup); |
michael@0 | 395 | } |
michael@0 | 396 | } |
michael@0 | 397 | }.bind(this); |
michael@0 | 398 | |
michael@0 | 399 | return Task.spawn(function* () { |
michael@0 | 400 | if (aMaxBackups === 0) { |
michael@0 | 401 | // Backups are disabled, delete any existing one and bail out. |
michael@0 | 402 | yield limitBackups(0); |
michael@0 | 403 | return; |
michael@0 | 404 | } |
michael@0 | 405 | |
michael@0 | 406 | // Ensure to initialize _backupFiles |
michael@0 | 407 | if (!this._backupFiles) |
michael@0 | 408 | yield this.getBackupFiles(); |
michael@0 | 409 | let newBackupFilename = this.getFilenameForDate(); |
michael@0 | 410 | // If we already have a backup for today we should do nothing, unless we |
michael@0 | 411 | // were required to enforce a new backup. |
michael@0 | 412 | let backupFile = yield getBackupFileForSameDate(newBackupFilename); |
michael@0 | 413 | if (backupFile && !aForceBackup) |
michael@0 | 414 | return; |
michael@0 | 415 | |
michael@0 | 416 | if (backupFile) { |
michael@0 | 417 | // In case there is a backup for today we should recreate it. |
michael@0 | 418 | this._backupFiles.shift(); |
michael@0 | 419 | this._entries.shift(); |
michael@0 | 420 | yield OS.File.remove(backupFile, { ignoreAbsent: true }); |
michael@0 | 421 | } |
michael@0 | 422 | |
michael@0 | 423 | // Now check the hash of the most recent backup, and try to create a new |
michael@0 | 424 | // backup, if that fails due to hash conflict, just rename the old backup. |
michael@0 | 425 | let mostRecentBackupFile = yield this.getMostRecentBackup(); |
michael@0 | 426 | let mostRecentHash = mostRecentBackupFile && |
michael@0 | 427 | getHashFromFilename(OS.Path.basename(mostRecentBackupFile)); |
michael@0 | 428 | |
michael@0 | 429 | // Save bookmarks to a backup file. |
michael@0 | 430 | let backupFolder = yield this.getBackupFolder(); |
michael@0 | 431 | let newBackupFile = OS.Path.join(backupFolder, newBackupFilename); |
michael@0 | 432 | let newFilenameWithMetaData; |
michael@0 | 433 | try { |
michael@0 | 434 | let { count: nodeCount, hash: hash } = |
michael@0 | 435 | yield BookmarkJSONUtils.exportToFile(newBackupFile, |
michael@0 | 436 | { failIfHashIs: mostRecentHash }); |
michael@0 | 437 | newFilenameWithMetaData = appendMetaDataToFilename(newBackupFilename, |
michael@0 | 438 | { count: nodeCount, |
michael@0 | 439 | hash: hash }); |
michael@0 | 440 | } catch (ex if ex.becauseSameHash) { |
michael@0 | 441 | // The last backup already contained up-to-date information, just |
michael@0 | 442 | // rename it as if it was today's backup. |
michael@0 | 443 | this._backupFiles.shift(); |
michael@0 | 444 | this._entries.shift(); |
michael@0 | 445 | newBackupFile = mostRecentBackupFile; |
michael@0 | 446 | newFilenameWithMetaData = appendMetaDataToFilename( |
michael@0 | 447 | newBackupFilename, |
michael@0 | 448 | { count: this.getBookmarkCountForFile(mostRecentBackupFile), |
michael@0 | 449 | hash: mostRecentHash }); |
michael@0 | 450 | } |
michael@0 | 451 | |
michael@0 | 452 | // Append metadata to the backup filename. |
michael@0 | 453 | let newBackupFileWithMetadata = OS.Path.join(backupFolder, newFilenameWithMetaData); |
michael@0 | 454 | yield OS.File.move(newBackupFile, newBackupFileWithMetadata); |
michael@0 | 455 | this._entries.unshift(new localFileCtor(newBackupFileWithMetadata)); |
michael@0 | 456 | this._backupFiles.unshift(newBackupFileWithMetadata); |
michael@0 | 457 | |
michael@0 | 458 | // Limit the number of backups. |
michael@0 | 459 | yield limitBackups(aMaxBackups); |
michael@0 | 460 | }.bind(this)); |
michael@0 | 461 | }, |
michael@0 | 462 | |
michael@0 | 463 | /** |
michael@0 | 464 | * Gets the bookmark count for backup file. |
michael@0 | 465 | * |
michael@0 | 466 | * @param aFilePath |
michael@0 | 467 | * File path The backup file. |
michael@0 | 468 | * |
michael@0 | 469 | * @return the bookmark count or null. |
michael@0 | 470 | */ |
michael@0 | 471 | getBookmarkCountForFile: function PB_getBookmarkCountForFile(aFilePath) { |
michael@0 | 472 | let count = null; |
michael@0 | 473 | let filename = OS.Path.basename(aFilePath); |
michael@0 | 474 | let matches = filename.match(filenamesRegex); |
michael@0 | 475 | if (matches && matches[2]) |
michael@0 | 476 | count = matches[2]; |
michael@0 | 477 | return count; |
michael@0 | 478 | }, |
michael@0 | 479 | |
michael@0 | 480 | /** |
michael@0 | 481 | * Gets a bookmarks tree representation usable to create backups in different |
michael@0 | 482 | * file formats. The root or the tree is PlacesUtils.placesRootId. |
michael@0 | 483 | * Items annotated with PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO and all of their |
michael@0 | 484 | * descendants are excluded. |
michael@0 | 485 | * |
michael@0 | 486 | * @return an object representing a tree with the places root as its root. |
michael@0 | 487 | * Each bookmark is represented by an object having these properties: |
michael@0 | 488 | * * id: the item id (make this not enumerable after bug 824502) |
michael@0 | 489 | * * title: the title |
michael@0 | 490 | * * guid: unique id |
michael@0 | 491 | * * parent: item id of the parent folder, not enumerable |
michael@0 | 492 | * * index: the position in the parent |
michael@0 | 493 | * * dateAdded: microseconds from the epoch |
michael@0 | 494 | * * lastModified: microseconds from the epoch |
michael@0 | 495 | * * type: type of the originating node as defined in PlacesUtils |
michael@0 | 496 | * The following properties exist only for a subset of bookmarks: |
michael@0 | 497 | * * annos: array of annotations |
michael@0 | 498 | * * uri: url |
michael@0 | 499 | * * iconuri: favicon's url |
michael@0 | 500 | * * keyword: associated keyword |
michael@0 | 501 | * * charset: last known charset |
michael@0 | 502 | * * tags: csv string of tags |
michael@0 | 503 | * * root: string describing whether this represents a root |
michael@0 | 504 | * * children: array of child items in a folder |
michael@0 | 505 | */ |
michael@0 | 506 | getBookmarksTree: function () { |
michael@0 | 507 | return Task.spawn(function* () { |
michael@0 | 508 | let dbFilePath = OS.Path.join(OS.Constants.Path.profileDir, "places.sqlite"); |
michael@0 | 509 | let conn = yield Sqlite.openConnection({ path: dbFilePath, |
michael@0 | 510 | sharedMemoryCache: false }); |
michael@0 | 511 | let rows = []; |
michael@0 | 512 | try { |
michael@0 | 513 | rows = yield conn.execute( |
michael@0 | 514 | "SELECT b.id, h.url, IFNULL(b.title, '') AS title, b.parent, " + |
michael@0 | 515 | "b.position AS [index], b.type, b.dateAdded, b.lastModified, " + |
michael@0 | 516 | "b.guid, f.url AS iconuri, " + |
michael@0 | 517 | "( SELECT GROUP_CONCAT(t.title, ',') " + |
michael@0 | 518 | "FROM moz_bookmarks b2 " + |
michael@0 | 519 | "JOIN moz_bookmarks t ON t.id = +b2.parent AND t.parent = :tags_folder " + |
michael@0 | 520 | "WHERE b2.fk = h.id " + |
michael@0 | 521 | ") AS tags, " + |
michael@0 | 522 | "EXISTS (SELECT 1 FROM moz_items_annos WHERE item_id = b.id LIMIT 1) AS has_annos, " + |
michael@0 | 523 | "( SELECT a.content FROM moz_annos a " + |
michael@0 | 524 | "JOIN moz_anno_attributes n ON a.anno_attribute_id = n.id " + |
michael@0 | 525 | "WHERE place_id = h.id AND n.name = :charset_anno " + |
michael@0 | 526 | ") AS charset " + |
michael@0 | 527 | "FROM moz_bookmarks b " + |
michael@0 | 528 | "LEFT JOIN moz_bookmarks p ON p.id = b.parent " + |
michael@0 | 529 | "LEFT JOIN moz_places h ON h.id = b.fk " + |
michael@0 | 530 | "LEFT JOIN moz_favicons f ON f.id = h.favicon_id " + |
michael@0 | 531 | "WHERE b.id <> :tags_folder AND b.parent <> :tags_folder AND p.parent <> :tags_folder " + |
michael@0 | 532 | "ORDER BY b.parent, b.position", |
michael@0 | 533 | { tags_folder: PlacesUtils.tagsFolderId, |
michael@0 | 534 | charset_anno: PlacesUtils.CHARSET_ANNO }); |
michael@0 | 535 | } catch(e) { |
michael@0 | 536 | Cu.reportError("Unable to query the database " + e); |
michael@0 | 537 | } finally { |
michael@0 | 538 | yield conn.close(); |
michael@0 | 539 | } |
michael@0 | 540 | |
michael@0 | 541 | let startTime = Date.now(); |
michael@0 | 542 | // Create a Map for lookup and recursive building of the tree. |
michael@0 | 543 | let itemsMap = new Map(); |
michael@0 | 544 | for (let row of rows) { |
michael@0 | 545 | let id = row.getResultByName("id"); |
michael@0 | 546 | try { |
michael@0 | 547 | let bookmark = sqliteRowToBookmarkObject(row); |
michael@0 | 548 | if (itemsMap.has(id)) { |
michael@0 | 549 | // Since children may be added before parents, we should merge with |
michael@0 | 550 | // the existing object. |
michael@0 | 551 | let original = itemsMap.get(id); |
michael@0 | 552 | for (let prop of Object.getOwnPropertyNames(bookmark)) { |
michael@0 | 553 | original[prop] = bookmark[prop]; |
michael@0 | 554 | } |
michael@0 | 555 | bookmark = original; |
michael@0 | 556 | } |
michael@0 | 557 | else { |
michael@0 | 558 | itemsMap.set(id, bookmark); |
michael@0 | 559 | } |
michael@0 | 560 | |
michael@0 | 561 | // Append bookmark to its parent. |
michael@0 | 562 | if (!itemsMap.has(bookmark.parent)) |
michael@0 | 563 | itemsMap.set(bookmark.parent, {}); |
michael@0 | 564 | let parent = itemsMap.get(bookmark.parent); |
michael@0 | 565 | if (!("children" in parent)) |
michael@0 | 566 | parent.children = []; |
michael@0 | 567 | parent.children.push(bookmark); |
michael@0 | 568 | } catch (e) { |
michael@0 | 569 | Cu.reportError("Error while reading node " + id + " " + e); |
michael@0 | 570 | } |
michael@0 | 571 | } |
michael@0 | 572 | |
michael@0 | 573 | // Handle excluded items, by removing entire subtrees pointed by them. |
michael@0 | 574 | function removeFromMap(id) { |
michael@0 | 575 | // Could have been removed by a previous call, since we can't |
michael@0 | 576 | // predict order of items in EXCLUDE_FROM_BACKUP_ANNO. |
michael@0 | 577 | if (itemsMap.has(id)) { |
michael@0 | 578 | let excludedItem = itemsMap.get(id); |
michael@0 | 579 | if (excludedItem.children) { |
michael@0 | 580 | for (let child of excludedItem.children) { |
michael@0 | 581 | removeFromMap(child.id); |
michael@0 | 582 | } |
michael@0 | 583 | } |
michael@0 | 584 | // Remove the excluded item from its parent's children... |
michael@0 | 585 | let parentItem = itemsMap.get(excludedItem.parent); |
michael@0 | 586 | parentItem.children = parentItem.children.filter(aChild => aChild.id != id); |
michael@0 | 587 | // ...then remove it from the map. |
michael@0 | 588 | itemsMap.delete(id); |
michael@0 | 589 | } |
michael@0 | 590 | } |
michael@0 | 591 | |
michael@0 | 592 | for (let id of PlacesUtils.annotations.getItemsWithAnnotation( |
michael@0 | 593 | PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO)) { |
michael@0 | 594 | removeFromMap(id); |
michael@0 | 595 | } |
michael@0 | 596 | |
michael@0 | 597 | // Report the time taken to build the tree. This doesn't take into |
michael@0 | 598 | // account the time spent in the query since that's off the main-thread. |
michael@0 | 599 | try { |
michael@0 | 600 | Services.telemetry |
michael@0 | 601 | .getHistogramById("PLACES_BACKUPS_BOOKMARKSTREE_MS") |
michael@0 | 602 | .add(Date.now() - startTime); |
michael@0 | 603 | } catch (ex) { |
michael@0 | 604 | Components.utils.reportError("Unable to report telemetry."); |
michael@0 | 605 | } |
michael@0 | 606 | |
michael@0 | 607 | return [itemsMap.get(PlacesUtils.placesRootId), itemsMap.size]; |
michael@0 | 608 | }); |
michael@0 | 609 | } |
michael@0 | 610 | } |
michael@0 | 611 | |
michael@0 | 612 | /** |
michael@0 | 613 | * Helper function to convert a Sqlite.jsm row to a bookmark object |
michael@0 | 614 | * representation. |
michael@0 | 615 | * |
michael@0 | 616 | * @param aRow The Sqlite.jsm result row. |
michael@0 | 617 | */ |
michael@0 | 618 | function sqliteRowToBookmarkObject(aRow) { |
michael@0 | 619 | let bookmark = {}; |
michael@0 | 620 | for (let p of [ "id" ,"guid", "title", "index", "dateAdded", "lastModified" ]) { |
michael@0 | 621 | bookmark[p] = aRow.getResultByName(p); |
michael@0 | 622 | } |
michael@0 | 623 | Object.defineProperty(bookmark, "parent", |
michael@0 | 624 | { value: aRow.getResultByName("parent") }); |
michael@0 | 625 | |
michael@0 | 626 | let type = aRow.getResultByName("type"); |
michael@0 | 627 | |
michael@0 | 628 | // Add annotations. |
michael@0 | 629 | if (aRow.getResultByName("has_annos")) { |
michael@0 | 630 | try { |
michael@0 | 631 | bookmark.annos = PlacesUtils.getAnnotationsForItem(bookmark.id); |
michael@0 | 632 | } catch (e) { |
michael@0 | 633 | Cu.reportError("Unexpected error while reading annotations " + e); |
michael@0 | 634 | } |
michael@0 | 635 | } |
michael@0 | 636 | |
michael@0 | 637 | switch (type) { |
michael@0 | 638 | case Ci.nsINavBookmarksService.TYPE_BOOKMARK: |
michael@0 | 639 | // TODO: What about shortcuts? |
michael@0 | 640 | bookmark.type = PlacesUtils.TYPE_X_MOZ_PLACE; |
michael@0 | 641 | // This will throw if we try to serialize an invalid url and the node will |
michael@0 | 642 | // just be skipped. |
michael@0 | 643 | bookmark.uri = NetUtil.newURI(aRow.getResultByName("url")).spec; |
michael@0 | 644 | // Keywords are cached, so this should be decently fast. |
michael@0 | 645 | let keyword = PlacesUtils.bookmarks.getKeywordForBookmark(bookmark.id); |
michael@0 | 646 | if (keyword) |
michael@0 | 647 | bookmark.keyword = keyword; |
michael@0 | 648 | let charset = aRow.getResultByName("charset"); |
michael@0 | 649 | if (charset) |
michael@0 | 650 | bookmark.charset = charset; |
michael@0 | 651 | let tags = aRow.getResultByName("tags"); |
michael@0 | 652 | if (tags) |
michael@0 | 653 | bookmark.tags = tags; |
michael@0 | 654 | let iconuri = aRow.getResultByName("iconuri"); |
michael@0 | 655 | if (iconuri) |
michael@0 | 656 | bookmark.iconuri = iconuri; |
michael@0 | 657 | break; |
michael@0 | 658 | case Ci.nsINavBookmarksService.TYPE_FOLDER: |
michael@0 | 659 | bookmark.type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER; |
michael@0 | 660 | |
michael@0 | 661 | // Mark root folders. |
michael@0 | 662 | if (bookmark.id == PlacesUtils.placesRootId) |
michael@0 | 663 | bookmark.root = "placesRoot"; |
michael@0 | 664 | else if (bookmark.id == PlacesUtils.bookmarksMenuFolderId) |
michael@0 | 665 | bookmark.root = "bookmarksMenuFolder"; |
michael@0 | 666 | else if (bookmark.id == PlacesUtils.unfiledBookmarksFolderId) |
michael@0 | 667 | bookmark.root = "unfiledBookmarksFolder"; |
michael@0 | 668 | else if (bookmark.id == PlacesUtils.toolbarFolderId) |
michael@0 | 669 | bookmark.root = "toolbarFolder"; |
michael@0 | 670 | break; |
michael@0 | 671 | case Ci.nsINavBookmarksService.TYPE_SEPARATOR: |
michael@0 | 672 | bookmark.type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR; |
michael@0 | 673 | break; |
michael@0 | 674 | default: |
michael@0 | 675 | Cu.reportError("Unexpected bookmark type"); |
michael@0 | 676 | break; |
michael@0 | 677 | } |
michael@0 | 678 | return bookmark; |
michael@0 | 679 | } |