2104 lines
70 KiB
JavaScript
2104 lines
70 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
Log: "resource://gre/modules/Log.sys.mjs",
|
|
PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
|
|
});
|
|
|
|
/**
|
|
* This module exports functions for Sync to use when applying remote
|
|
* records. The calls are similar to those in `Bookmarks.sys.mjs` and
|
|
* `nsINavBookmarksService`, with special handling for
|
|
* tags, keywords, synced annotations, and missing parents.
|
|
*/
|
|
export var PlacesSyncUtils = {};
|
|
|
|
const { SOURCE_SYNC } = Ci.nsINavBookmarksService;
|
|
|
|
const MICROSECONDS_PER_SECOND = 1000000;
|
|
|
|
const MOBILE_BOOKMARKS_PREF = "browser.bookmarks.showMobileBookmarks";
|
|
|
|
// These are defined as lazy getters to defer initializing the bookmarks
|
|
// service until it's needed.
|
|
ChromeUtils.defineLazyGetter(lazy, "ROOT_RECORD_ID_TO_GUID", () => ({
|
|
menu: lazy.PlacesUtils.bookmarks.menuGuid,
|
|
places: lazy.PlacesUtils.bookmarks.rootGuid,
|
|
tags: lazy.PlacesUtils.bookmarks.tagsGuid,
|
|
toolbar: lazy.PlacesUtils.bookmarks.toolbarGuid,
|
|
unfiled: lazy.PlacesUtils.bookmarks.unfiledGuid,
|
|
mobile: lazy.PlacesUtils.bookmarks.mobileGuid,
|
|
}));
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "ROOT_GUID_TO_RECORD_ID", () => ({
|
|
[lazy.PlacesUtils.bookmarks.menuGuid]: "menu",
|
|
[lazy.PlacesUtils.bookmarks.rootGuid]: "places",
|
|
[lazy.PlacesUtils.bookmarks.tagsGuid]: "tags",
|
|
[lazy.PlacesUtils.bookmarks.toolbarGuid]: "toolbar",
|
|
[lazy.PlacesUtils.bookmarks.unfiledGuid]: "unfiled",
|
|
[lazy.PlacesUtils.bookmarks.mobileGuid]: "mobile",
|
|
}));
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "ROOTS", () =>
|
|
Object.keys(lazy.ROOT_RECORD_ID_TO_GUID)
|
|
);
|
|
|
|
// Gets the history transition values we ignore and do not sync, as a
|
|
// string, which is a comma-separated set of values - ie, something which can
|
|
// be used with sqlite's IN operator. Does *not* includes the parens.
|
|
ChromeUtils.defineLazyGetter(lazy, "IGNORED_TRANSITIONS_AS_SQL_LIST", () =>
|
|
// * We don't sync `TRANSITION_FRAMED_LINK` visits - these are excluded when
|
|
// rendering the history menu, so we use the same constraints for Sync.
|
|
// * We don't sync `TRANSITION_DOWNLOAD` because it makes no sense to see
|
|
// these on other devices - the downloaded file can not exist.
|
|
// * We don't want to sync TRANSITION_EMBED visits, but these aren't
|
|
// stored in the DB, so no need to specify them.
|
|
// * 0 is invalid, and hopefully don't exist, but let's exclude it anyway.
|
|
// Array.toString() semantics are well defined and exactly what we need, so..
|
|
[
|
|
0,
|
|
lazy.PlacesUtils.history.TRANSITION_FRAMED_LINK,
|
|
lazy.PlacesUtils.history.TRANSITION_DOWNLOAD,
|
|
].toString()
|
|
);
|
|
|
|
const HistorySyncUtils = (PlacesSyncUtils.history = Object.freeze({
|
|
SYNC_ID_META_KEY: "sync/history/syncId",
|
|
LAST_SYNC_META_KEY: "sync/history/lastSync",
|
|
|
|
/**
|
|
* Returns the current history sync ID, or `""` if one isn't set.
|
|
*/
|
|
getSyncId() {
|
|
return lazy.PlacesUtils.metadata.get(HistorySyncUtils.SYNC_ID_META_KEY, "");
|
|
},
|
|
|
|
/**
|
|
* Assigns a new sync ID. This is called when we sync for the first time with
|
|
* a new account, and when we're the first to sync after a node reassignment.
|
|
*
|
|
* @returns {Promise} resolved once the ID has been updated.
|
|
* @resolves to the new sync ID.
|
|
*/
|
|
resetSyncId() {
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"HistorySyncUtils: resetSyncId",
|
|
function (db) {
|
|
let newSyncId = lazy.PlacesUtils.history.makeGuid();
|
|
return db.executeTransaction(async function () {
|
|
await setHistorySyncId(db, newSyncId);
|
|
return newSyncId;
|
|
});
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Ensures that the existing local sync ID, if any, is up-to-date with the
|
|
* server. This is called when we sync with an existing account.
|
|
*
|
|
* @param newSyncId
|
|
* The server's sync ID.
|
|
* @returns {Promise} resolved once the ID has been updated.
|
|
*/
|
|
async ensureCurrentSyncId(newSyncId) {
|
|
if (!newSyncId || typeof newSyncId != "string") {
|
|
throw new TypeError("Invalid new history sync ID");
|
|
}
|
|
await lazy.PlacesUtils.withConnectionWrapper(
|
|
"HistorySyncUtils: ensureCurrentSyncId",
|
|
async function (db) {
|
|
let existingSyncId = await lazy.PlacesUtils.metadata.getWithConnection(
|
|
db,
|
|
HistorySyncUtils.SYNC_ID_META_KEY,
|
|
""
|
|
);
|
|
|
|
if (existingSyncId == newSyncId) {
|
|
lazy.HistorySyncLog.trace("History sync ID up-to-date", {
|
|
existingSyncId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
lazy.HistorySyncLog.info(
|
|
"History sync ID changed; resetting metadata",
|
|
{
|
|
existingSyncId,
|
|
newSyncId,
|
|
}
|
|
);
|
|
await db.executeTransaction(function () {
|
|
return setHistorySyncId(db, newSyncId);
|
|
});
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Returns the last sync time, in seconds, for the history collection, or 0
|
|
* if history has never synced before.
|
|
*/
|
|
async getLastSync() {
|
|
let lastSync = await lazy.PlacesUtils.metadata.get(
|
|
HistorySyncUtils.LAST_SYNC_META_KEY,
|
|
0
|
|
);
|
|
return lastSync / 1000;
|
|
},
|
|
|
|
/**
|
|
* Updates the history collection last sync time.
|
|
*
|
|
* @param lastSyncSeconds
|
|
* The collection last sync time, in seconds, as a number or string.
|
|
*/
|
|
async setLastSync(lastSyncSeconds) {
|
|
let lastSync = Math.floor(lastSyncSeconds * 1000);
|
|
if (!Number.isInteger(lastSync)) {
|
|
throw new TypeError("Invalid history last sync timestamp");
|
|
}
|
|
await lazy.PlacesUtils.metadata.set(
|
|
HistorySyncUtils.LAST_SYNC_META_KEY,
|
|
lastSync
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Removes all history visits and pages from the database. Sync calls this
|
|
* method when it receives a command from a remote client to wipe all stored
|
|
* data.
|
|
*
|
|
* @returns {Promise} resolved once all pages and visits have been removed.
|
|
*/
|
|
async wipe() {
|
|
await lazy.PlacesUtils.history.clear();
|
|
await HistorySyncUtils.reset();
|
|
},
|
|
|
|
/**
|
|
* Removes the sync ID and last sync time for the history collection. Unlike
|
|
* `wipe`, this keeps all existing history pages and visits.
|
|
*
|
|
* @returns {Promise} resolved once the metadata have been removed.
|
|
*/
|
|
reset() {
|
|
return lazy.PlacesUtils.metadata.delete(
|
|
HistorySyncUtils.SYNC_ID_META_KEY,
|
|
HistorySyncUtils.LAST_SYNC_META_KEY
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Clamps a history visit date between the current date and the earliest
|
|
* sensible date.
|
|
*
|
|
* @param {Date} visitDate
|
|
* The visit date.
|
|
* @returns {Date} The clamped visit date.
|
|
*/
|
|
clampVisitDate(visitDate) {
|
|
let currentDate = new Date();
|
|
if (visitDate > currentDate) {
|
|
return currentDate;
|
|
}
|
|
if (visitDate.getTime() < BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP) {
|
|
return new Date(BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP);
|
|
}
|
|
return visitDate;
|
|
},
|
|
|
|
/**
|
|
* Fetches the frecency for the URL provided
|
|
*
|
|
* @param url
|
|
* @returns {Promise<number>} The frecency of the given url
|
|
*/
|
|
async fetchURLFrecency(url) {
|
|
let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);
|
|
|
|
let db = await lazy.PlacesUtils.promiseDBConnection();
|
|
let rows = await db.executeCached(
|
|
`
|
|
SELECT frecency
|
|
FROM moz_places
|
|
WHERE url_hash = hash(:url) AND url = :url
|
|
LIMIT 1`,
|
|
{ url: canonicalURL.href }
|
|
);
|
|
|
|
return rows.length ? rows[0].getResultByName("frecency") : -1;
|
|
},
|
|
|
|
/**
|
|
* Filters syncable places from a collection of places guids.
|
|
*
|
|
* @param guids
|
|
*
|
|
* @returns {Promise<string[]>}
|
|
* A new array with the guids that aren't syncable.
|
|
*/
|
|
async determineNonSyncableGuids(guids) {
|
|
// Filter out hidden pages and transitions that we don't sync.
|
|
let db = await lazy.PlacesUtils.promiseDBConnection();
|
|
let nonSyncableGuids = [];
|
|
for (let chunk of lazy.PlacesUtils.chunkArray(guids, db.variableLimit)) {
|
|
let rows = await db.execute(
|
|
`
|
|
SELECT DISTINCT p.guid FROM moz_places p
|
|
JOIN moz_historyvisits v ON p.id = v.place_id
|
|
WHERE p.guid IN (${new Array(chunk.length).fill("?").join(",")}) AND
|
|
(p.hidden = 1 OR v.visit_type IN (${
|
|
lazy.IGNORED_TRANSITIONS_AS_SQL_LIST
|
|
}))
|
|
`,
|
|
chunk
|
|
);
|
|
nonSyncableGuids = nonSyncableGuids.concat(
|
|
rows.map(row => row.getResultByName("guid"))
|
|
);
|
|
}
|
|
return nonSyncableGuids;
|
|
},
|
|
|
|
/**
|
|
* Change the guid of the given uri
|
|
*
|
|
* @param uri
|
|
* @param guid
|
|
*/
|
|
changeGuid(uri, guid) {
|
|
let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(uri);
|
|
let validatedGuid = lazy.PlacesUtils.BOOKMARK_VALIDATORS.guid(guid);
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"PlacesSyncUtils.history: changeGuid",
|
|
async function (db) {
|
|
await db.executeCached(
|
|
`
|
|
UPDATE moz_places
|
|
SET guid = :guid
|
|
WHERE url_hash = hash(:page_url) AND url = :page_url`,
|
|
{ guid: validatedGuid, page_url: canonicalURL.href }
|
|
);
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Fetch the last 20 visits (date and type of it) corresponding to a given url
|
|
*
|
|
* @param url
|
|
* @returns {Promise<{date: Date, type: number}[]>}
|
|
* Each element of the Array is an object with members: date and type
|
|
*/
|
|
async fetchVisitsForURL(url) {
|
|
let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);
|
|
let db = await lazy.PlacesUtils.promiseDBConnection();
|
|
let rows = await db.executeCached(
|
|
`
|
|
SELECT visit_type type, visit_date date,
|
|
json_extract(e.sync_json, '$.unknown_sync_fields') as unknownSyncFields
|
|
FROM moz_historyvisits v
|
|
JOIN moz_places h ON h.id = v.place_id
|
|
LEFT OUTER JOIN moz_historyvisits_extra e ON e.visit_id = v.id
|
|
WHERE url_hash = hash(:url) AND url = :url
|
|
ORDER BY date DESC LIMIT 20`,
|
|
{ url: canonicalURL.href }
|
|
);
|
|
return rows.map(row => {
|
|
let visitDate = row.getResultByName("date");
|
|
let visitType = row.getResultByName("type");
|
|
let visit = { date: visitDate, type: visitType };
|
|
|
|
// We should grab unknown fields to roundtrip them
|
|
// back to the server
|
|
let unknownFields = row.getResultByName("unknownSyncFields");
|
|
if (unknownFields) {
|
|
let unknownFieldsObj = JSON.parse(unknownFields);
|
|
for (const key in unknownFieldsObj) {
|
|
// We have to manually add it to the cleartext since that's
|
|
// what gets processed during upload
|
|
visit[key] = unknownFieldsObj[key];
|
|
}
|
|
}
|
|
return visit;
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Fetches the guid of a uri
|
|
*
|
|
* @param uri
|
|
* @returns {Promise<string>} The guid of the given uri.
|
|
*/
|
|
async fetchGuidForURL(url) {
|
|
let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);
|
|
let db = await lazy.PlacesUtils.promiseDBConnection();
|
|
let rows = await db.executeCached(
|
|
`
|
|
SELECT guid
|
|
FROM moz_places
|
|
WHERE url_hash = hash(:page_url) AND url = :page_url`,
|
|
{ page_url: canonicalURL.href }
|
|
);
|
|
if (!rows.length) {
|
|
return null;
|
|
}
|
|
return rows[0].getResultByName("guid");
|
|
},
|
|
|
|
/**
|
|
* Fetch information about a guid (url, title and frecency)
|
|
*
|
|
* @param guid
|
|
* @returns {Promise<{url: string, title: string, frecency: number}>}
|
|
* An object with three members: url, title and frecency of the given guid.
|
|
*/
|
|
async fetchURLInfoForGuid(guid) {
|
|
let db = await lazy.PlacesUtils.promiseDBConnection();
|
|
let rows = await db.executeCached(
|
|
`
|
|
SELECT url, IFNULL(title, '') AS title, frecency,
|
|
json_extract(e.sync_json, '$.unknown_sync_fields') as unknownSyncFields
|
|
FROM moz_places h
|
|
LEFT OUTER JOIN moz_places_extra e ON e.place_id = h.id
|
|
WHERE guid = :guid`,
|
|
{ guid }
|
|
);
|
|
if (rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
let info = {
|
|
url: rows[0].getResultByName("url"),
|
|
title: rows[0].getResultByName("title"),
|
|
frecency: rows[0].getResultByName("frecency"),
|
|
};
|
|
let unknownFields = rows[0].getResultByName("unknownSyncFields");
|
|
if (unknownFields) {
|
|
// This will be unfurled at the caller since the
|
|
// cleartext process will drop this
|
|
info.unknownFields = unknownFields;
|
|
}
|
|
return info;
|
|
},
|
|
|
|
/**
|
|
* Get all URLs filtered by the specified limit and minimum visit date.
|
|
*
|
|
* @param {object} options
|
|
* @param {number} options.limit
|
|
* Maximum number of URLs to return.
|
|
* @param {Date} options.since
|
|
* Only include URLs visited after this date.
|
|
* @returns {Promise<string[]>}
|
|
* A list of URLs, up to the given limit, that were visited after the date
|
|
* provided. Note that some visit types are explicitly excluded - downloads
|
|
* and framed links.
|
|
*/
|
|
async getAllURLs(options) {
|
|
// Check that the limit property is finite number.
|
|
if (!Number.isFinite(options.limit)) {
|
|
throw new Error("The number provided in options.limit is not finite.");
|
|
}
|
|
// Check that the since property is of type Date.
|
|
if (
|
|
!options.since ||
|
|
Object.prototype.toString.call(options.since) != "[object Date]"
|
|
) {
|
|
throw new Error(
|
|
"The property since of the options object must be of type Date."
|
|
);
|
|
}
|
|
let db = await lazy.PlacesUtils.promiseDBConnection();
|
|
let sinceInMicroseconds = lazy.PlacesUtils.toPRTime(options.since);
|
|
let rows = await db.executeCached(
|
|
`
|
|
SELECT DISTINCT p.url
|
|
FROM moz_places p
|
|
JOIN moz_historyvisits v ON p.id = v.place_id
|
|
WHERE p.last_visit_date > :cutoff_date AND
|
|
p.hidden = 0 AND
|
|
v.visit_type NOT IN (${lazy.IGNORED_TRANSITIONS_AS_SQL_LIST})
|
|
ORDER BY frecency DESC
|
|
LIMIT :max_results`,
|
|
{ cutoff_date: sinceInMicroseconds, max_results: options.limit }
|
|
);
|
|
return rows.map(row => row.getResultByName("url"));
|
|
},
|
|
/**
|
|
* Insert or update the unknownFields that this client doesn't understand (yet)
|
|
* but stores & roundtrips them to prevent other clients from losing that data
|
|
*
|
|
* @param {object[]} updates array of objects
|
|
* an update object needs to have either a:
|
|
* placeId: if we're putting unknownFields for a moz_places item
|
|
* visitId: if we're putting unknownFields for a moz_historyvisits item
|
|
* Note: Supplying none or both will result in that record being ignored
|
|
* unknownFields: the stringified json to insert
|
|
*/
|
|
async updateUnknownFieldsBatch(updates) {
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"HistorySyncUtils: updateUnknownFieldsBatch",
|
|
async function (db) {
|
|
await db.executeTransaction(async () => {
|
|
for await (const update of updates) {
|
|
// Validate we only have one of these props
|
|
if (
|
|
(update.placeId && update.visitId) ||
|
|
(!update.placeId && !update.visitId)
|
|
) {
|
|
continue;
|
|
}
|
|
let tableName = update.placeId
|
|
? "moz_places_extra"
|
|
: "moz_historyvisits_extra";
|
|
let keyName = update.placeId ? "place_id" : "visit_id";
|
|
await db.executeCached(
|
|
`
|
|
INSERT INTO ${tableName} (${keyName}, sync_json)
|
|
VALUES (
|
|
:keyValue,
|
|
json_object('unknown_sync_fields', :unknownFields)
|
|
)
|
|
ON CONFLICT(${keyName}) DO UPDATE SET
|
|
sync_json=json_patch(${tableName}.sync_json, json_object('unknown_sync_fields',:unknownFields))
|
|
`,
|
|
{
|
|
keyValue: update.placeId ?? update.visitId,
|
|
unknownFields: update.unknownFields,
|
|
}
|
|
);
|
|
}
|
|
});
|
|
}
|
|
);
|
|
},
|
|
// End of history freeze
|
|
}));
|
|
|
|
const BookmarkSyncUtils = (PlacesSyncUtils.bookmarks = Object.freeze({
|
|
SYNC_ID_META_KEY: "sync/bookmarks/syncId",
|
|
LAST_SYNC_META_KEY: "sync/bookmarks/lastSync",
|
|
WIPE_REMOTE_META_KEY: "sync/bookmarks/wipeRemote",
|
|
|
|
// Jan 23, 1993 in milliseconds since 1970. Corresponds roughly to the release
|
|
// of the original NCSA Mosiac. We can safely assume that any dates before
|
|
// this time are invalid.
|
|
EARLIEST_BOOKMARK_TIMESTAMP: Date.UTC(1993, 0, 23),
|
|
|
|
KINDS: {
|
|
BOOKMARK: "bookmark",
|
|
QUERY: "query",
|
|
FOLDER: "folder",
|
|
LIVEMARK: "livemark",
|
|
SEPARATOR: "separator",
|
|
},
|
|
|
|
get ROOTS() {
|
|
return lazy.ROOTS;
|
|
},
|
|
|
|
/**
|
|
* Returns the current bookmarks sync ID, or `""` if one isn't set.
|
|
*/
|
|
getSyncId() {
|
|
return lazy.PlacesUtils.metadata.get(
|
|
BookmarkSyncUtils.SYNC_ID_META_KEY,
|
|
""
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Indicates if the bookmarks engine should erase all bookmarks on the server
|
|
* and all other clients, because the user manually restored their bookmarks
|
|
* from a backup on this client.
|
|
*/
|
|
async shouldWipeRemote() {
|
|
let shouldWipeRemote = await lazy.PlacesUtils.metadata.get(
|
|
BookmarkSyncUtils.WIPE_REMOTE_META_KEY,
|
|
false
|
|
);
|
|
return !!shouldWipeRemote;
|
|
},
|
|
|
|
/**
|
|
* Assigns a new sync ID, bumps the change counter, and flags all items as
|
|
* "NEW" for upload. This is called when we sync for the first time with a
|
|
* new account, when we're the first to sync after a node reassignment, and
|
|
* on the first sync after a manual restore.
|
|
*
|
|
* @returns {Promise} resolved once the ID and all items have been updated.
|
|
* @resolves to the new sync ID.
|
|
*/
|
|
resetSyncId() {
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: resetSyncId",
|
|
function (db) {
|
|
let newSyncId = lazy.PlacesUtils.history.makeGuid();
|
|
return db.executeTransaction(async function () {
|
|
await setBookmarksSyncId(db, newSyncId);
|
|
await resetAllSyncStatuses(
|
|
db,
|
|
lazy.PlacesUtils.bookmarks.SYNC_STATUS.NEW
|
|
);
|
|
return newSyncId;
|
|
});
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Ensures that the existing local sync ID, if any, is up-to-date with the
|
|
* server. This is called when we sync with an existing account.
|
|
*
|
|
* We always take the server's sync ID. If we don't have an existing ID,
|
|
* we're either syncing for the first time with an existing account, or Places
|
|
* has automatically restored from a backup. If the sync IDs don't match,
|
|
* we're likely syncing after a node reassignment, where another client
|
|
* uploaded their bookmarks first.
|
|
*
|
|
* @param newSyncId
|
|
* The server's sync ID.
|
|
* @returns {Promise} resolved once the ID and all items have been updated.
|
|
*/
|
|
async ensureCurrentSyncId(newSyncId) {
|
|
if (!newSyncId || typeof newSyncId != "string") {
|
|
throw new TypeError("Invalid new bookmarks sync ID");
|
|
}
|
|
await lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: ensureCurrentSyncId",
|
|
async function (db) {
|
|
let existingSyncId = await lazy.PlacesUtils.metadata.getWithConnection(
|
|
db,
|
|
BookmarkSyncUtils.SYNC_ID_META_KEY,
|
|
""
|
|
);
|
|
|
|
// If we don't have a sync ID, take the server's without resetting
|
|
// sync statuses.
|
|
if (!existingSyncId) {
|
|
lazy.BookmarkSyncLog.info("Taking new bookmarks sync ID", {
|
|
newSyncId,
|
|
});
|
|
await db.executeTransaction(() => setBookmarksSyncId(db, newSyncId));
|
|
return;
|
|
}
|
|
|
|
// If the existing sync ID matches the server, great!
|
|
if (existingSyncId == newSyncId) {
|
|
lazy.BookmarkSyncLog.trace("Bookmarks sync ID up-to-date", {
|
|
existingSyncId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Otherwise, we have a sync ID, but it doesn't match, so we were likely
|
|
// node reassigned. Take the server's sync ID and reset all items to
|
|
// "UNKNOWN" so that we can merge.
|
|
lazy.BookmarkSyncLog.info(
|
|
"Bookmarks sync ID changed; resetting sync statuses",
|
|
{ existingSyncId, newSyncId }
|
|
);
|
|
await db.executeTransaction(async function () {
|
|
await setBookmarksSyncId(db, newSyncId);
|
|
await resetAllSyncStatuses(
|
|
db,
|
|
lazy.PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN
|
|
);
|
|
});
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Returns the last sync time, in seconds, for the bookmarks collection, or 0
|
|
* if bookmarks have never synced before.
|
|
*/
|
|
async getLastSync() {
|
|
let lastSync = await lazy.PlacesUtils.metadata.get(
|
|
BookmarkSyncUtils.LAST_SYNC_META_KEY,
|
|
0
|
|
);
|
|
return lastSync / 1000;
|
|
},
|
|
|
|
/**
|
|
* Updates the bookmarks collection last sync time.
|
|
*
|
|
* @param lastSyncSeconds
|
|
* The collection last sync time, in seconds, as a number or string.
|
|
*/
|
|
async setLastSync(lastSyncSeconds) {
|
|
let lastSync = Math.floor(lastSyncSeconds * 1000);
|
|
if (!Number.isInteger(lastSync)) {
|
|
throw new TypeError("Invalid bookmarks last sync timestamp");
|
|
}
|
|
await lazy.PlacesUtils.metadata.set(
|
|
BookmarkSyncUtils.LAST_SYNC_META_KEY,
|
|
lastSync
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Resets Sync metadata for bookmarks in Places. This function behaves
|
|
* differently depending on the change source, and may be called from
|
|
* `PlacesSyncUtils.bookmarks.reset` or
|
|
* `PlacesUtils.bookmarks.eraseEverything`.
|
|
*
|
|
* - RESTORE: The user is restoring from a backup. Drop the sync ID, last
|
|
* sync time, and tombstones; reset sync statuses for remaining items to
|
|
* "NEW"; then set a flag to wipe the server and all other clients. On the
|
|
* next sync, we'll replace their bookmarks with ours.
|
|
*
|
|
* - RESTORE_ON_STARTUP: Places is automatically restoring from a backup to
|
|
* recover from a corrupt database. The sync ID, last sync time, and
|
|
* tombstones don't exist, since we don't back them up; reset sync statuses
|
|
* for the roots to "UNKNOWN"; but don't wipe the server. On the next sync,
|
|
* we'll merge the restored bookmarks with the ones on the server.
|
|
*
|
|
* - SYNC: Either another client told us to erase our bookmarks
|
|
* (`PlacesSyncUtils.bookmarks.wipe`), or the user disconnected Sync
|
|
* (`PlacesSyncUtils.bookmarks.reset`). In both cases, drop the existing
|
|
* sync ID, last sync time, and tombstones; reset sync statuses for
|
|
* remaining items to "NEW"; and don't wipe the server.
|
|
*
|
|
* @param db
|
|
* the Sqlite.sys.mjs connection handle.
|
|
* @param source
|
|
* the change source constant.
|
|
*/
|
|
async resetSyncMetadata(db, source) {
|
|
if (
|
|
![
|
|
lazy.PlacesUtils.bookmarks.SOURCES.RESTORE,
|
|
lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
|
|
lazy.PlacesUtils.bookmarks.SOURCES.SYNC,
|
|
].includes(source)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Remove the sync ID and last sync time in all cases.
|
|
await lazy.PlacesUtils.metadata.deleteWithConnection(
|
|
db,
|
|
BookmarkSyncUtils.SYNC_ID_META_KEY,
|
|
BookmarkSyncUtils.LAST_SYNC_META_KEY
|
|
);
|
|
|
|
// If we're manually restoring from a backup, wipe the server and other
|
|
// clients, so that we replace their bookmarks with the restored tree. If
|
|
// we're automatically restoring to recover from a corrupt database, don't
|
|
// wipe; we want to merge the restored tree with the one on the server.
|
|
await lazy.PlacesUtils.metadata.setWithConnection(
|
|
db,
|
|
new Map([
|
|
[
|
|
BookmarkSyncUtils.WIPE_REMOTE_META_KEY,
|
|
source == lazy.PlacesUtils.bookmarks.SOURCES.RESTORE,
|
|
],
|
|
])
|
|
);
|
|
|
|
// Reset change counters and sync statuses for roots and remaining
|
|
// items, and drop tombstones.
|
|
let syncStatus =
|
|
source == lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP
|
|
? lazy.PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN
|
|
: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NEW;
|
|
await resetAllSyncStatuses(db, syncStatus);
|
|
},
|
|
|
|
/**
|
|
* Converts a Places GUID to a Sync record ID. Record IDs are identical to
|
|
* Places GUIDs for all items except roots.
|
|
*/
|
|
guidToRecordId(guid) {
|
|
return lazy.ROOT_GUID_TO_RECORD_ID[guid] || guid;
|
|
},
|
|
|
|
/**
|
|
* Converts a Sync record ID to a Places GUID.
|
|
*/
|
|
recordIdToGuid(recordId) {
|
|
return lazy.ROOT_RECORD_ID_TO_GUID[recordId] || recordId;
|
|
},
|
|
|
|
/**
|
|
* Fetches the record IDs for a folder's children, ordered by their position
|
|
* within the folder.
|
|
* Used only be tests - but that includes tps, so it lives here.
|
|
*/
|
|
fetchChildRecordIds(parentRecordId) {
|
|
lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId);
|
|
let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId);
|
|
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: fetchChildRecordIds",
|
|
async function (db) {
|
|
let childGuids = await fetchChildGuids(db, parentGuid);
|
|
return childGuids.map(guid => BookmarkSyncUtils.guidToRecordId(guid));
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Migrates an array of `{ recordId, modified }` tuples from the old JSON-based
|
|
* tracker to the new sync change counter. `modified` is when the change was
|
|
* added to the old tracker, in milliseconds.
|
|
*
|
|
* Sync calls this method before the first bookmark sync after the Places
|
|
* schema migration.
|
|
*/
|
|
migrateOldTrackerEntries(entries) {
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: migrateOldTrackerEntries",
|
|
function (db) {
|
|
return db.executeTransaction(async function () {
|
|
// Mark all existing bookmarks as synced, and clear their change
|
|
// counters to avoid a full upload on the next sync. Note that
|
|
// this means we'll miss changes made between startup and the first
|
|
// post-migration sync, as well as changes made on a new release
|
|
// channel that weren't synced before the user downgraded. This is
|
|
// unfortunate, but no worse than the behavior of the old tracker.
|
|
//
|
|
// We also likely have bookmarks that don't exist on the server,
|
|
// because the old tracker missed them. We'll eventually fix the
|
|
// server once we decide on a repair strategy.
|
|
await db.executeCached(
|
|
`
|
|
WITH RECURSIVE
|
|
syncedItems(id) AS (
|
|
SELECT b.id FROM moz_bookmarks b
|
|
WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
|
|
'mobile______')
|
|
UNION ALL
|
|
SELECT b.id FROM moz_bookmarks b
|
|
JOIN syncedItems s ON b.parent = s.id
|
|
)
|
|
UPDATE moz_bookmarks SET
|
|
syncStatus = :syncStatus,
|
|
syncChangeCounter = 0
|
|
WHERE id IN syncedItems`,
|
|
{ syncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL }
|
|
);
|
|
|
|
await db.executeCached(`DELETE FROM moz_bookmarks_deleted`);
|
|
|
|
await db.executeCached(`CREATE TEMP TABLE moz_bookmarks_tracked (
|
|
guid TEXT PRIMARY KEY,
|
|
time INTEGER
|
|
)`);
|
|
|
|
try {
|
|
for (let { recordId, modified } of entries) {
|
|
let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
|
|
if (!lazy.PlacesUtils.isValidGuid(guid)) {
|
|
lazy.BookmarkSyncLog.warn(
|
|
`migrateOldTrackerEntries: Ignoring ` +
|
|
`change for invalid item ${guid}`
|
|
);
|
|
continue;
|
|
}
|
|
let time = lazy.PlacesUtils.toPRTime(
|
|
Number.isFinite(modified) ? modified : Date.now()
|
|
);
|
|
await db.executeCached(
|
|
`
|
|
INSERT OR IGNORE INTO moz_bookmarks_tracked (guid, time)
|
|
VALUES (:guid, :time)`,
|
|
{ guid, time }
|
|
);
|
|
}
|
|
|
|
// Bump the change counter for existing tracked items.
|
|
await db.executeCached(`
|
|
INSERT OR REPLACE INTO moz_bookmarks (id, fk, type, parent,
|
|
position, title,
|
|
dateAdded, lastModified,
|
|
guid, syncChangeCounter,
|
|
syncStatus)
|
|
SELECT b.id, b.fk, b.type, b.parent, b.position, b.title,
|
|
b.dateAdded, MAX(b.lastModified, t.time), b.guid,
|
|
b.syncChangeCounter + 1, b.syncStatus
|
|
FROM moz_bookmarks b
|
|
JOIN moz_bookmarks_tracked t ON b.guid = t.guid`);
|
|
|
|
// Insert tombstones for nonexistent tracked items, using the most
|
|
// recent deletion date for more accurate reconciliation. We assume
|
|
// the tracked item belongs to a synced root.
|
|
await db.executeCached(`
|
|
INSERT OR REPLACE INTO moz_bookmarks_deleted (guid, dateRemoved)
|
|
SELECT t.guid, MAX(IFNULL((SELECT dateRemoved FROM moz_bookmarks_deleted
|
|
WHERE guid = t.guid), 0), t.time)
|
|
FROM moz_bookmarks_tracked t
|
|
LEFT JOIN moz_bookmarks b ON t.guid = b.guid
|
|
WHERE b.guid IS NULL`);
|
|
} finally {
|
|
await db.executeCached(`DROP TABLE moz_bookmarks_tracked`);
|
|
}
|
|
});
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Reorders a folder's children, based on their order in the array of sync
|
|
* IDs.
|
|
*
|
|
* Sync uses this method to reorder all synced children after applying all
|
|
* incoming records.
|
|
*
|
|
* @returns {Promise} resolved when reordering is complete.
|
|
* @rejects if an error happens while reordering.
|
|
* @throws if the arguments are invalid.
|
|
*/
|
|
order(parentRecordId, childRecordIds) {
|
|
lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId);
|
|
if (!childRecordIds.length) {
|
|
return undefined;
|
|
}
|
|
let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId);
|
|
if (parentGuid == lazy.PlacesUtils.bookmarks.rootGuid) {
|
|
// Reordering roots doesn't make sense, but Sync will do this on the
|
|
// first sync.
|
|
return undefined;
|
|
}
|
|
let orderedChildrenGuids = childRecordIds.map(
|
|
BookmarkSyncUtils.recordIdToGuid
|
|
);
|
|
return lazy.PlacesUtils.bookmarks.reorder(
|
|
parentGuid,
|
|
orderedChildrenGuids,
|
|
{
|
|
source: SOURCE_SYNC,
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Resolves to true if there are known sync changes.
|
|
*/
|
|
havePendingChanges() {
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: havePendingChanges",
|
|
async function (db) {
|
|
let rows = await db.executeCached(`
|
|
WITH RECURSIVE
|
|
syncedItems(id, guid, syncChangeCounter) AS (
|
|
SELECT b.id, b.guid, b.syncChangeCounter
|
|
FROM moz_bookmarks b
|
|
WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
|
|
'mobile______')
|
|
UNION ALL
|
|
SELECT b.id, b.guid, b.syncChangeCounter
|
|
FROM moz_bookmarks b
|
|
JOIN syncedItems s ON b.parent = s.id
|
|
),
|
|
changedItems(guid) AS (
|
|
SELECT guid FROM syncedItems
|
|
WHERE syncChangeCounter >= 1
|
|
UNION ALL
|
|
SELECT guid FROM moz_bookmarks_deleted
|
|
)
|
|
SELECT EXISTS(SELECT guid FROM changedItems) AS haveChanges`);
|
|
return !!rows[0].getResultByName("haveChanges");
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Returns a changeset containing local bookmark changes since the last sync.
|
|
*
|
|
* @returns {Promise} resolved once all items have been fetched.
|
|
* @resolves to an object containing records for changed bookmarks, keyed by
|
|
* the record ID.
|
|
* @see pullSyncChanges for the implementation, and markChangesAsSyncing for
|
|
* an explanation of why we update the sync status.
|
|
*/
|
|
pullChanges() {
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: pullChanges",
|
|
pullSyncChanges
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Updates the sync status of all "NEW" bookmarks to "NORMAL", so that Sync
|
|
* can recover correctly after an interrupted sync.
|
|
*
|
|
* @param changeRecords
|
|
* A changeset containing sync change records, as returned by
|
|
* `pullChanges`.
|
|
* @returns {Promise} resolved once all records have been updated.
|
|
*/
|
|
markChangesAsSyncing(changeRecords) {
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: markChangesAsSyncing",
|
|
db => markChangesAsSyncing(db, changeRecords)
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Decrements the sync change counter, updates the sync status, and cleans up
|
|
* tombstones for successfully synced items. Sync calls this method at the
|
|
* end of each bookmark sync.
|
|
*
|
|
* @param changeRecords
|
|
* A changeset containing sync change records, as returned by
|
|
* `pullChanges`.
|
|
* @returns {Promise} resolved once all records have been updated.
|
|
*/
|
|
pushChanges(changeRecords) {
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: pushChanges",
|
|
async function (db) {
|
|
let skippedCount = 0;
|
|
let weakCount = 0;
|
|
let updateParams = [];
|
|
let tombstoneGuidsToRemove = [];
|
|
|
|
for (let recordId in changeRecords) {
|
|
// Validate change records to catch coding errors.
|
|
let changeRecord = validateChangeRecord(
|
|
"BookmarkSyncUtils: pushChanges",
|
|
changeRecords[recordId],
|
|
{
|
|
tombstone: { required: true },
|
|
counter: { required: true },
|
|
synced: { required: true },
|
|
}
|
|
);
|
|
|
|
// Skip weakly uploaded records.
|
|
if (!changeRecord.counter) {
|
|
weakCount++;
|
|
continue;
|
|
}
|
|
|
|
// Sync sets the `synced` flag for reconciled or successfully
|
|
// uploaded items. If upload failed, ignore the change; we'll
|
|
// try again on the next sync.
|
|
if (!changeRecord.synced) {
|
|
skippedCount++;
|
|
continue;
|
|
}
|
|
|
|
let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
|
|
if (changeRecord.tombstone) {
|
|
tombstoneGuidsToRemove.push(guid);
|
|
} else {
|
|
updateParams.push({
|
|
guid,
|
|
syncChangeDelta: changeRecord.counter,
|
|
syncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Reduce the change counter and update the sync status for
|
|
// reconciled and uploaded items. If the bookmark was updated
|
|
// during the sync, its change counter will still be > 0 for the
|
|
// next sync.
|
|
if (updateParams.length || tombstoneGuidsToRemove.length) {
|
|
await db.executeTransaction(async function () {
|
|
if (updateParams.length) {
|
|
await db.executeCached(
|
|
`
|
|
UPDATE moz_bookmarks
|
|
SET syncChangeCounter = MAX(syncChangeCounter - :syncChangeDelta, 0),
|
|
syncStatus = :syncStatus
|
|
WHERE guid = :guid`,
|
|
updateParams
|
|
);
|
|
// and if there are *both* bookmarks and tombstones for these
|
|
// items, we nuke the tombstones.
|
|
// This should be unlikely, but bad if it happens.
|
|
let dupedGuids = updateParams.map(({ guid }) => guid);
|
|
await removeUndeletedTombstones(db, dupedGuids);
|
|
}
|
|
await removeTombstones(db, tombstoneGuidsToRemove);
|
|
});
|
|
}
|
|
|
|
lazy.BookmarkSyncLog.debug(`pushChanges: Processed change records`, {
|
|
weak: weakCount,
|
|
skipped: skippedCount,
|
|
updated: updateParams.length,
|
|
});
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Removes items from the database. Sync buffers incoming tombstones, and
|
|
* calls this method to apply them at the end of each sync. Deletion
|
|
* happens in three steps:
|
|
*
|
|
* 1. Remove all non-folder items. Deleting a folder on a remote client
|
|
* uploads tombstones for the folder and its children at the time of
|
|
* deletion. This preserves any new children we've added locally since
|
|
* the last sync.
|
|
* 2. Reparent remaining children to the tombstoned folder's parent. This
|
|
* bumps the change counter for the children and their new parent.
|
|
* 3. Remove the tombstoned folder. Because we don't do this in a
|
|
* transaction, the user might move new items into the folder before we
|
|
* can remove it. In that case, we keep the folder and upload the new
|
|
* subtree to the server.
|
|
*
|
|
* See the comment above `BookmarksStore::deletePending` for the details on
|
|
* why delete works the way it does.
|
|
*/
|
|
remove(recordIds) {
|
|
if (!recordIds.length) {
|
|
return null;
|
|
}
|
|
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: remove",
|
|
async function (db) {
|
|
let folderGuids = [];
|
|
for (let recordId of recordIds) {
|
|
if (recordId in lazy.ROOT_RECORD_ID_TO_GUID) {
|
|
lazy.BookmarkSyncLog.warn(
|
|
`remove: Refusing to remove root ${recordId}`
|
|
);
|
|
continue;
|
|
}
|
|
let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
|
|
let bookmarkItem = await lazy.PlacesUtils.bookmarks.fetch(guid);
|
|
if (!bookmarkItem) {
|
|
lazy.BookmarkSyncLog.trace(`remove: Item ${guid} already removed`);
|
|
continue;
|
|
}
|
|
let kind = await getKindForItem(db, bookmarkItem);
|
|
if (kind == BookmarkSyncUtils.KINDS.FOLDER) {
|
|
folderGuids.push(bookmarkItem.guid);
|
|
continue;
|
|
}
|
|
let wasRemoved = await deleteSyncedAtom(bookmarkItem);
|
|
if (wasRemoved) {
|
|
lazy.BookmarkSyncLog.trace(
|
|
`remove: Removed item ${guid} with kind ${kind}`
|
|
);
|
|
}
|
|
}
|
|
|
|
for (let guid of folderGuids) {
|
|
let bookmarkItem = await lazy.PlacesUtils.bookmarks.fetch(guid);
|
|
if (!bookmarkItem) {
|
|
lazy.BookmarkSyncLog.trace(
|
|
`remove: Folder ${guid} already removed`
|
|
);
|
|
continue;
|
|
}
|
|
let wasRemoved = await deleteSyncedFolder(db, bookmarkItem);
|
|
if (wasRemoved) {
|
|
lazy.BookmarkSyncLog.trace(
|
|
`remove: Removed folder ${bookmarkItem.guid}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// TODO (Bug 1313890): Refactor the bookmarks engine to pull change records
|
|
// before uploading, instead of returning records to merge into the engine's
|
|
// initial changeset.
|
|
return pullSyncChanges(db);
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Removes all bookmarks and tombstones from the database. Sync calls this
|
|
* method when it receives a command from a remote client to wipe all stored
|
|
* data.
|
|
*
|
|
* @returns {Promise} resolved once all items have been removed.
|
|
*/
|
|
wipe() {
|
|
return lazy.PlacesUtils.bookmarks.eraseEverything({
|
|
source: SOURCE_SYNC,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Marks all bookmarks as "NEW" and removes all tombstones. Unlike `wipe`,
|
|
* this keeps all existing bookmarks, and only clears their sync change
|
|
* tracking info.
|
|
*
|
|
* @returns {Promise} resolved once all items have been updated.
|
|
*/
|
|
reset() {
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: reset",
|
|
function (db) {
|
|
return db.executeTransaction(async function () {
|
|
await BookmarkSyncUtils.resetSyncMetadata(db, SOURCE_SYNC);
|
|
});
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Fetches a Sync bookmark object for an item in the tree.
|
|
*
|
|
* Should only be used by SYNC TESTS.
|
|
* We should remove this in bug XXXXXX, updating the tests to use
|
|
* PlacesUtils.bookmarks.fetch.
|
|
*
|
|
* The object contains
|
|
* the following properties, depending on the item's kind:
|
|
*
|
|
* - kind (all): A string representing the item's kind.
|
|
* - recordId (all): The item's record ID.
|
|
* - parentRecordId (all): The record ID of the item's parent.
|
|
* - parentTitle (all): The title of the item's parent, used for de-duping.
|
|
* Omitted for the Places root and parents with empty titles.
|
|
* - dateAdded (all): Timestamp in milliseconds, when the bookmark was added
|
|
* or created on a remote device if known.
|
|
* - title ("bookmark", "folder", "query"): The item's title.
|
|
* Omitted if empty.
|
|
* - url ("bookmark", "query"): The item's URL.
|
|
* - tags ("bookmark", "query"): An array containing the item's tags.
|
|
* - keyword ("bookmark"): The bookmark's keyword, if one exists.
|
|
* - childRecordIds ("folder"): An array containing the record IDs of the item's
|
|
* children, used to determine child order.
|
|
* - folder ("query"): The tag folder name, if this is a tag query.
|
|
* - index ("separator"): The separator's position within its parent.
|
|
*/
|
|
async fetch(recordId) {
|
|
let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
|
|
let bookmarkItem = await lazy.PlacesUtils.bookmarks.fetch(guid);
|
|
if (!bookmarkItem) {
|
|
return null;
|
|
}
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: fetch",
|
|
async function (db) {
|
|
// Convert the Places bookmark object to a Sync bookmark and add
|
|
// kind-specific properties. Titles are required for bookmarks,
|
|
// and folders; optional for queries, and omitted for separators.
|
|
let kind = await getKindForItem(db, bookmarkItem);
|
|
let item;
|
|
switch (kind) {
|
|
case BookmarkSyncUtils.KINDS.BOOKMARK:
|
|
item = await fetchBookmarkItem(db, bookmarkItem);
|
|
break;
|
|
|
|
case BookmarkSyncUtils.KINDS.QUERY:
|
|
item = await fetchQueryItem(db, bookmarkItem);
|
|
break;
|
|
|
|
case BookmarkSyncUtils.KINDS.FOLDER:
|
|
item = await fetchFolderItem(db, bookmarkItem);
|
|
break;
|
|
|
|
case BookmarkSyncUtils.KINDS.SEPARATOR:
|
|
item = await placesBookmarkToSyncBookmark(db, bookmarkItem);
|
|
item.index = bookmarkItem.index;
|
|
break;
|
|
|
|
default:
|
|
throw new Error(`Unknown bookmark kind: ${kind}`);
|
|
}
|
|
|
|
// Sync uses the parent title for de-duping. All Sync bookmark objects
|
|
// except the Places root should have this property.
|
|
if (bookmarkItem.parentGuid) {
|
|
let parent = await lazy.PlacesUtils.bookmarks.fetch(
|
|
bookmarkItem.parentGuid
|
|
);
|
|
item.parentTitle = parent.title || "";
|
|
}
|
|
|
|
return item;
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Returns the sync change counter increment for a change source constant.
|
|
*/
|
|
determineSyncChangeDelta(source) {
|
|
// Don't bump the change counter when applying changes made by Sync, to
|
|
// avoid sync loops.
|
|
return source == lazy.PlacesUtils.bookmarks.SOURCES.SYNC ? 0 : 1;
|
|
},
|
|
|
|
/**
|
|
* Returns the sync status for a new item inserted by a change source.
|
|
*/
|
|
determineInitialSyncStatus(source) {
|
|
if (source == lazy.PlacesUtils.bookmarks.SOURCES.SYNC) {
|
|
// Incoming bookmarks are "NORMAL", since they already exist on the server.
|
|
return lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL;
|
|
}
|
|
if (source == lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP) {
|
|
// If the user restores from a backup, or Places automatically recovers
|
|
// from a corrupt database, all prior sync tracking is lost. Setting the
|
|
// status to "UNKNOWN" allows Sync to reconcile restored bookmarks with
|
|
// those on the server.
|
|
return lazy.PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN;
|
|
}
|
|
// For all other sources, mark items as "NEW". We'll update their statuses
|
|
// to "NORMAL" after the first sync.
|
|
return lazy.PlacesUtils.bookmarks.SYNC_STATUS.NEW;
|
|
},
|
|
|
|
/**
|
|
* An internal helper that bumps the change counter for all bookmarks with
|
|
* a given URL. This is used to update bookmarks when adding or changing a
|
|
* tag or keyword entry.
|
|
*
|
|
* @param db
|
|
* the Sqlite.sys.mjs connection handle.
|
|
* @param url
|
|
* the bookmark URL object.
|
|
* @param syncChangeDelta
|
|
* the sync change counter increment.
|
|
* @returns {Promise} resolved when the counters have been updated.
|
|
*/
|
|
addSyncChangesForBookmarksWithURL(db, url, syncChangeDelta) {
|
|
if (!url || !syncChangeDelta) {
|
|
return Promise.resolve();
|
|
}
|
|
return db.executeCached(
|
|
`
|
|
UPDATE moz_bookmarks
|
|
SET syncChangeCounter = syncChangeCounter + :syncChangeDelta
|
|
WHERE type = :type AND
|
|
fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND
|
|
url = :url)`,
|
|
{
|
|
syncChangeDelta,
|
|
type: lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK,
|
|
url: url.href,
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Returns `0` if no sensible timestamp could be found.
|
|
* Otherwise, returns the earliest sensible timestamp between `existingMillis`
|
|
* and `serverMillis`.
|
|
*/
|
|
ratchetTimestampBackwards(
|
|
existingMillis,
|
|
serverMillis,
|
|
lowerBound = BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP
|
|
) {
|
|
const possible = [+existingMillis, +serverMillis].filter(
|
|
n => !isNaN(n) && n > lowerBound
|
|
);
|
|
if (!possible.length) {
|
|
return 0;
|
|
}
|
|
return Math.min(...possible);
|
|
},
|
|
|
|
/**
|
|
* Rebuilds the left pane query for the mobile root under "All Bookmarks" if
|
|
* necessary. Sync calls this method at the end of each bookmark sync. This
|
|
* code should eventually move to `PlacesUIUtils#maybeRebuildLeftPane`; see
|
|
* bug 647605.
|
|
*
|
|
* - If there are no mobile bookmarks, the query will not be created, or
|
|
* will be removed if it already exists.
|
|
* - If there are mobile bookmarks, the query will be created if it doesn't
|
|
* exist, or will be updated with the correct title and URL otherwise.
|
|
*/
|
|
async ensureMobileQuery() {
|
|
let db = await lazy.PlacesUtils.promiseDBConnection();
|
|
|
|
let mobileChildGuids = await fetchChildGuids(
|
|
db,
|
|
lazy.PlacesUtils.bookmarks.mobileGuid
|
|
);
|
|
let hasMobileBookmarks = !!mobileChildGuids.length;
|
|
|
|
Services.prefs.setBoolPref(MOBILE_BOOKMARKS_PREF, hasMobileBookmarks);
|
|
},
|
|
}));
|
|
|
|
PlacesSyncUtils.test = {};
|
|
PlacesSyncUtils.test.bookmarks = Object.freeze({
|
|
/**
|
|
* Inserts a synced bookmark into the tree. Only SYNC TESTS should call this
|
|
* method; other callers should use `PlacesUtils.bookmarks.insert`.
|
|
*
|
|
* It is in this file rather than a test-only file because it makes use of
|
|
* other internal functions here, so moving is not trivial - see bug 1662602.
|
|
*
|
|
* The following properties are supported:
|
|
* - kind: Required.
|
|
* - guid: Required.
|
|
* - parentGuid: Required.
|
|
* - url: Required for bookmarks.
|
|
* - tags: An optional array of tag strings.
|
|
* - keyword: An optional keyword string.
|
|
*
|
|
* Sync doesn't set the index, since it appends and reorders children
|
|
* after applying all incoming items.
|
|
*
|
|
* @param info
|
|
* object representing a synced bookmark.
|
|
*
|
|
* @returns {Promise} resolved when the creation is complete.
|
|
* @resolves to an object representing the created bookmark.
|
|
* @rejects if it's not possible to create the requested bookmark.
|
|
* @throws if the arguments are invalid.
|
|
*/
|
|
insert(info) {
|
|
let insertInfo = validateNewBookmark("BookmarkTestUtils: insert", info);
|
|
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkTestUtils: insert",
|
|
async db => {
|
|
// If we're inserting a tag query, make sure the tag exists and fix the
|
|
// folder ID to refer to the local tag folder.
|
|
insertInfo = await updateTagQueryFolder(db, insertInfo);
|
|
|
|
let bookmarkInfo = syncBookmarkToPlacesBookmark(insertInfo);
|
|
let bookmarkItem =
|
|
await lazy.PlacesUtils.bookmarks.insert(bookmarkInfo);
|
|
let newItem = await insertBookmarkMetadata(
|
|
db,
|
|
bookmarkItem,
|
|
insertInfo
|
|
);
|
|
|
|
return newItem;
|
|
}
|
|
);
|
|
},
|
|
});
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "HistorySyncLog", () => {
|
|
return lazy.Log.repository.getLogger("Sync.Engine.History.HistorySyncUtils");
|
|
});
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "BookmarkSyncLog", () => {
|
|
// Use a sub-log of the bookmarks engine, so setting the level for that
|
|
// engine also adjust the level of this log.
|
|
return lazy.Log.repository.getLogger(
|
|
"Sync.Engine.Bookmarks.BookmarkSyncUtils"
|
|
);
|
|
});
|
|
|
|
function validateSyncBookmarkObject(name, input, behavior) {
|
|
return lazy.PlacesUtils.validateItemProperties(
|
|
name,
|
|
lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS,
|
|
input,
|
|
behavior
|
|
);
|
|
}
|
|
|
|
// Validates a sync change record as returned by `pullChanges` and passed to
|
|
// `pushChanges`.
|
|
function validateChangeRecord(name, changeRecord, behavior) {
|
|
return lazy.PlacesUtils.validateItemProperties(
|
|
name,
|
|
lazy.PlacesUtils.SYNC_CHANGE_RECORD_VALIDATORS,
|
|
changeRecord,
|
|
behavior
|
|
);
|
|
}
|
|
|
|
// Similar to the private `fetchBookmarksByParent` implementation in
|
|
// `Bookmarks.sys.mjs`.
|
|
var fetchChildGuids = async function (db, parentGuid) {
|
|
let rows = await db.executeCached(
|
|
`
|
|
SELECT guid
|
|
FROM moz_bookmarks
|
|
WHERE parent = (
|
|
SELECT id FROM moz_bookmarks WHERE guid = :parentGuid
|
|
)
|
|
ORDER BY position`,
|
|
{ parentGuid }
|
|
);
|
|
return rows.map(row => row.getResultByName("guid"));
|
|
};
|
|
|
|
// Legacy tag queries may use a `place:` URL that refers to the tag folder ID.
|
|
// When we apply a synced tag query from a remote client, we need to update the
|
|
// URL to point to the local tag.
|
|
function updateTagQueryFolder(db, info) {
|
|
if (
|
|
info.kind != BookmarkSyncUtils.KINDS.QUERY ||
|
|
!info.folder ||
|
|
!info.url ||
|
|
info.url.protocol != "place:"
|
|
) {
|
|
return info;
|
|
}
|
|
|
|
let params = new URLSearchParams(info.url.pathname);
|
|
let type = +params.get("type");
|
|
if (type != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) {
|
|
return info;
|
|
}
|
|
|
|
lazy.BookmarkSyncLog.debug(
|
|
`updateTagQueryFolder: Tag query folder: ${info.folder}`
|
|
);
|
|
|
|
// Rewrite the query to directly reference the tag.
|
|
params.delete("queryType");
|
|
params.delete("type");
|
|
params.delete("folder");
|
|
params.set("tag", info.folder);
|
|
info.url = new URL(info.url.protocol + params);
|
|
return info;
|
|
}
|
|
|
|
// Keywords are a 1 to 1 mapping between strings and pairs of (URL, postData).
|
|
// (the postData is not synced, so we ignore it). Sync associates keywords with
|
|
// bookmarks, which is not really accurate. -- We might already have a keyword
|
|
// with that name, or we might already have another bookmark with that URL with
|
|
// a different keyword, etc.
|
|
//
|
|
// If we don't handle those cases by removing the conflicting keywords first,
|
|
// the insertion will fail, and the keywords will either be wrong, or missing.
|
|
// This function handles those cases.
|
|
function removeConflictingKeywords(bookmarkURL, newKeyword) {
|
|
return lazy.PlacesUtils.withConnectionWrapper(
|
|
"BookmarkSyncUtils: removeConflictingKeywords",
|
|
async function (db) {
|
|
let entryForURL = await lazy.PlacesUtils.keywords.fetch({
|
|
url: bookmarkURL.href,
|
|
});
|
|
if (entryForURL && entryForURL.keyword !== newKeyword) {
|
|
await lazy.PlacesUtils.keywords.remove({
|
|
keyword: entryForURL.keyword,
|
|
source: SOURCE_SYNC,
|
|
});
|
|
// This will cause us to reupload this record for this sync, but
|
|
// without it, we will risk data corruption.
|
|
await BookmarkSyncUtils.addSyncChangesForBookmarksWithURL(
|
|
db,
|
|
entryForURL.url,
|
|
1
|
|
);
|
|
}
|
|
if (!newKeyword) {
|
|
return;
|
|
}
|
|
let entryForNewKeyword = await lazy.PlacesUtils.keywords.fetch({
|
|
keyword: newKeyword,
|
|
});
|
|
if (entryForNewKeyword) {
|
|
await lazy.PlacesUtils.keywords.remove({
|
|
keyword: entryForNewKeyword.keyword,
|
|
source: SOURCE_SYNC,
|
|
});
|
|
await BookmarkSyncUtils.addSyncChangesForBookmarksWithURL(
|
|
db,
|
|
entryForNewKeyword.url,
|
|
1
|
|
);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// Sets annotations, keywords, and tags on a new bookmark. Returns a Sync
|
|
// bookmark object.
|
|
async function insertBookmarkMetadata(db, bookmarkItem, insertInfo) {
|
|
let newItem = await placesBookmarkToSyncBookmark(db, bookmarkItem);
|
|
|
|
try {
|
|
newItem.tags = tagItem(bookmarkItem, insertInfo.tags);
|
|
} catch (ex) {
|
|
lazy.BookmarkSyncLog.warn(
|
|
`insertBookmarkMetadata: Error tagging item ${insertInfo.recordId}`,
|
|
ex
|
|
);
|
|
}
|
|
|
|
if (insertInfo.keyword) {
|
|
await removeConflictingKeywords(bookmarkItem.url, insertInfo.keyword);
|
|
await lazy.PlacesUtils.keywords.insert({
|
|
keyword: insertInfo.keyword,
|
|
url: bookmarkItem.url.href,
|
|
source: SOURCE_SYNC,
|
|
});
|
|
newItem.keyword = insertInfo.keyword;
|
|
}
|
|
|
|
return newItem;
|
|
}
|
|
|
|
// Determines the Sync record kind for an existing bookmark.
|
|
async function getKindForItem(db, item) {
|
|
switch (item.type) {
|
|
case lazy.PlacesUtils.bookmarks.TYPE_FOLDER: {
|
|
return BookmarkSyncUtils.KINDS.FOLDER;
|
|
}
|
|
case lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK:
|
|
return item.url.protocol == "place:"
|
|
? BookmarkSyncUtils.KINDS.QUERY
|
|
: BookmarkSyncUtils.KINDS.BOOKMARK;
|
|
|
|
case lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR:
|
|
return BookmarkSyncUtils.KINDS.SEPARATOR;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Returns the `nsINavBookmarksService` bookmark type constant for a Sync
|
|
// record kind.
|
|
function getTypeForKind(kind) {
|
|
switch (kind) {
|
|
case BookmarkSyncUtils.KINDS.BOOKMARK:
|
|
case BookmarkSyncUtils.KINDS.QUERY:
|
|
return lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK;
|
|
|
|
case BookmarkSyncUtils.KINDS.FOLDER:
|
|
return lazy.PlacesUtils.bookmarks.TYPE_FOLDER;
|
|
|
|
case BookmarkSyncUtils.KINDS.SEPARATOR:
|
|
return lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR;
|
|
}
|
|
throw new Error(`Unknown bookmark kind: ${kind}`);
|
|
}
|
|
|
|
function validateNewBookmark(name, info) {
|
|
let insertInfo = validateSyncBookmarkObject(name, info, {
|
|
kind: { required: true },
|
|
recordId: { required: true },
|
|
url: {
|
|
requiredIf: b =>
|
|
[
|
|
BookmarkSyncUtils.KINDS.BOOKMARK,
|
|
BookmarkSyncUtils.KINDS.QUERY,
|
|
].includes(b.kind),
|
|
validIf: b =>
|
|
[
|
|
BookmarkSyncUtils.KINDS.BOOKMARK,
|
|
BookmarkSyncUtils.KINDS.QUERY,
|
|
].includes(b.kind),
|
|
},
|
|
parentRecordId: { required: true },
|
|
title: {
|
|
validIf: b =>
|
|
[
|
|
BookmarkSyncUtils.KINDS.BOOKMARK,
|
|
BookmarkSyncUtils.KINDS.QUERY,
|
|
BookmarkSyncUtils.KINDS.FOLDER,
|
|
].includes(b.kind) || b.title === "",
|
|
},
|
|
query: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY },
|
|
folder: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY },
|
|
tags: {
|
|
validIf: b =>
|
|
[
|
|
BookmarkSyncUtils.KINDS.BOOKMARK,
|
|
BookmarkSyncUtils.KINDS.QUERY,
|
|
].includes(b.kind),
|
|
},
|
|
keyword: {
|
|
validIf: b =>
|
|
[
|
|
BookmarkSyncUtils.KINDS.BOOKMARK,
|
|
BookmarkSyncUtils.KINDS.QUERY,
|
|
].includes(b.kind),
|
|
},
|
|
dateAdded: { required: false },
|
|
});
|
|
|
|
return insertInfo;
|
|
}
|
|
|
|
function tagItem(item, tags) {
|
|
if (!item.url) {
|
|
return [];
|
|
}
|
|
|
|
// Remove leading and trailing whitespace, then filter out empty tags.
|
|
let newTags = tags ? tags.map(tag => tag.trim()).filter(Boolean) : [];
|
|
|
|
// Removing the last tagged item will also remove the tag. To preserve
|
|
// tag IDs, we temporarily tag a dummy URI, ensuring the tags exist.
|
|
let dummyURI = lazy.PlacesUtils.toURI("about:weave#BStore_tagURI");
|
|
let bookmarkURI = lazy.PlacesUtils.toURI(item.url);
|
|
if (newTags && newTags.length) {
|
|
lazy.PlacesUtils.tagging.tagURI(dummyURI, newTags, SOURCE_SYNC);
|
|
}
|
|
lazy.PlacesUtils.tagging.untagURI(bookmarkURI, null, SOURCE_SYNC);
|
|
if (newTags && newTags.length) {
|
|
lazy.PlacesUtils.tagging.tagURI(bookmarkURI, newTags, SOURCE_SYNC);
|
|
}
|
|
lazy.PlacesUtils.tagging.untagURI(dummyURI, null, SOURCE_SYNC);
|
|
|
|
return newTags;
|
|
}
|
|
|
|
// Converts a Places bookmark to a Sync bookmark. This function maps Places
|
|
// GUIDs to record IDs and filters out extra Places properties like date added,
|
|
// last modified, and index.
|
|
async function placesBookmarkToSyncBookmark(db, bookmarkItem) {
|
|
let item = {};
|
|
|
|
for (let prop in bookmarkItem) {
|
|
switch (prop) {
|
|
// Record IDs are identical to Places GUIDs for all items except roots.
|
|
case "guid":
|
|
item.recordId = BookmarkSyncUtils.guidToRecordId(bookmarkItem.guid);
|
|
break;
|
|
|
|
case "parentGuid":
|
|
item.parentRecordId = BookmarkSyncUtils.guidToRecordId(
|
|
bookmarkItem.parentGuid
|
|
);
|
|
break;
|
|
|
|
// Sync uses kinds instead of types, which distinguish between folders,
|
|
// livemarks, bookmarks, and queries.
|
|
case "type":
|
|
item.kind = await getKindForItem(db, bookmarkItem);
|
|
break;
|
|
|
|
case "title":
|
|
case "url":
|
|
item[prop] = bookmarkItem[prop];
|
|
break;
|
|
|
|
case "dateAdded":
|
|
item[prop] = new Date(bookmarkItem[prop]).getTime();
|
|
break;
|
|
}
|
|
}
|
|
|
|
return item;
|
|
}
|
|
|
|
// Converts a Sync bookmark object to a Places bookmark or livemark object.
|
|
// This function maps record IDs to Places GUIDs, and filters out extra Sync
|
|
// properties like keywords, tags. Returns an object that can be passed to
|
|
// `PlacesUtils.bookmarks.{insert, update}`.
|
|
function syncBookmarkToPlacesBookmark(info) {
|
|
let bookmarkInfo = {
|
|
source: SOURCE_SYNC,
|
|
};
|
|
|
|
for (let prop in info) {
|
|
switch (prop) {
|
|
case "kind":
|
|
bookmarkInfo.type = getTypeForKind(info.kind);
|
|
break;
|
|
|
|
// Convert record IDs to Places GUIDs for roots.
|
|
case "recordId":
|
|
bookmarkInfo.guid = BookmarkSyncUtils.recordIdToGuid(info.recordId);
|
|
break;
|
|
|
|
case "dateAdded":
|
|
bookmarkInfo.dateAdded = new Date(info.dateAdded);
|
|
break;
|
|
|
|
case "parentRecordId":
|
|
bookmarkInfo.parentGuid = BookmarkSyncUtils.recordIdToGuid(
|
|
info.parentRecordId
|
|
);
|
|
// Instead of providing an index, Sync reorders children at the end of
|
|
// the sync using `BookmarkSyncUtils.order`. We explicitly specify the
|
|
// default index here to prevent `PlacesUtils.bookmarks.update` from
|
|
// throwing.
|
|
bookmarkInfo.index = lazy.PlacesUtils.bookmarks.DEFAULT_INDEX;
|
|
break;
|
|
|
|
case "title":
|
|
case "url":
|
|
bookmarkInfo[prop] = info[prop];
|
|
break;
|
|
}
|
|
}
|
|
|
|
return bookmarkInfo;
|
|
}
|
|
|
|
// Creates and returns a Sync bookmark object containing the bookmark's
|
|
// tags, keyword.
|
|
var fetchBookmarkItem = async function (db, bookmarkItem) {
|
|
let item = await placesBookmarkToSyncBookmark(db, bookmarkItem);
|
|
|
|
if (!item.title) {
|
|
item.title = "";
|
|
}
|
|
|
|
item.tags = lazy.PlacesUtils.tagging.getTagsForURI(
|
|
lazy.PlacesUtils.toURI(bookmarkItem.url)
|
|
);
|
|
|
|
let keywordEntry = await lazy.PlacesUtils.keywords.fetch({
|
|
url: bookmarkItem.url,
|
|
});
|
|
if (keywordEntry) {
|
|
item.keyword = keywordEntry.keyword;
|
|
}
|
|
|
|
return item;
|
|
};
|
|
|
|
// Creates and returns a Sync bookmark object containing the folder's children.
|
|
async function fetchFolderItem(db, bookmarkItem) {
|
|
let item = await placesBookmarkToSyncBookmark(db, bookmarkItem);
|
|
|
|
if (!item.title) {
|
|
item.title = "";
|
|
}
|
|
|
|
let childGuids = await fetchChildGuids(db, bookmarkItem.guid);
|
|
item.childRecordIds = childGuids.map(guid =>
|
|
BookmarkSyncUtils.guidToRecordId(guid)
|
|
);
|
|
|
|
return item;
|
|
}
|
|
|
|
// Creates and returns a Sync bookmark object containing the query's tag
|
|
// folder name.
|
|
async function fetchQueryItem(db, bookmarkItem) {
|
|
let item = await placesBookmarkToSyncBookmark(db, bookmarkItem);
|
|
|
|
let params = new URLSearchParams(bookmarkItem.url.pathname);
|
|
let tags = params.getAll("tag");
|
|
if (tags.length == 1) {
|
|
item.folder = tags[0];
|
|
}
|
|
|
|
return item;
|
|
}
|
|
|
|
function addRowToChangeRecords(row, changeRecords) {
|
|
let guid = row.getResultByName("guid");
|
|
if (!guid) {
|
|
throw new Error(`Changed item missing GUID`);
|
|
}
|
|
let isTombstone = !!row.getResultByName("tombstone");
|
|
let recordId = BookmarkSyncUtils.guidToRecordId(guid);
|
|
if (recordId in changeRecords) {
|
|
let existingRecord = changeRecords[recordId];
|
|
if (existingRecord.tombstone == isTombstone) {
|
|
// Should never happen: `moz_bookmarks.guid` has a unique index, and
|
|
// `moz_bookmarks_deleted.guid` is the primary key.
|
|
throw new Error(`Duplicate item or tombstone ${recordId} in changeset`);
|
|
}
|
|
if (!existingRecord.tombstone && isTombstone) {
|
|
// Don't replace undeleted items with tombstones...
|
|
lazy.BookmarkSyncLog.warn(
|
|
"addRowToChangeRecords: Ignoring tombstone for undeleted item",
|
|
recordId
|
|
);
|
|
return;
|
|
}
|
|
// ...But replace undeleted tombstones with items.
|
|
lazy.BookmarkSyncLog.warn(
|
|
"addRowToChangeRecords: Replacing tombstone for undeleted item",
|
|
recordId
|
|
);
|
|
}
|
|
let modifiedAsPRTime = row.getResultByName("modified");
|
|
let modified = modifiedAsPRTime / MICROSECONDS_PER_SECOND;
|
|
if (Number.isNaN(modified) || modified <= 0) {
|
|
lazy.BookmarkSyncLog.error(
|
|
"addRowToChangeRecords: Invalid modified date for " + recordId,
|
|
modifiedAsPRTime
|
|
);
|
|
modified = 0;
|
|
}
|
|
changeRecords[recordId] = {
|
|
modified,
|
|
counter: row.getResultByName("syncChangeCounter"),
|
|
status: row.getResultByName("syncStatus"),
|
|
tombstone: isTombstone,
|
|
synced: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Queries the database for synced bookmarks and tombstones, and returns a
|
|
* changeset for the Sync bookmarks engine.
|
|
*
|
|
* @param db
|
|
* The Sqlite.sys.mjs connection handle.
|
|
* @param forGuids
|
|
* Fetch Sync tracking information for only the requested GUIDs.
|
|
* @returns {Promise} resolved once all items have been fetched.
|
|
* @resolves to an object containing records for changed bookmarks, keyed by
|
|
* the record ID.
|
|
*/
|
|
var pullSyncChanges = async function (db, forGuids = []) {
|
|
let changeRecords = {};
|
|
|
|
let itemConditions = ["syncChangeCounter >= 1"];
|
|
let tombstoneConditions = ["1 = 1"];
|
|
if (forGuids.length) {
|
|
let restrictToGuids = `guid IN (${forGuids
|
|
.map(guid => JSON.stringify(guid))
|
|
.join(",")})`;
|
|
itemConditions.push(restrictToGuids);
|
|
tombstoneConditions.push(restrictToGuids);
|
|
}
|
|
|
|
let rows = await db.executeCached(
|
|
`
|
|
WITH RECURSIVE
|
|
syncedItems(id, guid, modified, syncChangeCounter, syncStatus) AS (
|
|
SELECT b.id, b.guid, b.lastModified, b.syncChangeCounter, b.syncStatus
|
|
FROM moz_bookmarks b
|
|
WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
|
|
'mobile______')
|
|
UNION ALL
|
|
SELECT b.id, b.guid, b.lastModified, b.syncChangeCounter, b.syncStatus
|
|
FROM moz_bookmarks b
|
|
JOIN syncedItems s ON b.parent = s.id
|
|
)
|
|
SELECT guid, modified, syncChangeCounter, syncStatus, 0 AS tombstone
|
|
FROM syncedItems
|
|
WHERE ${itemConditions.join(" AND ")}
|
|
UNION ALL
|
|
SELECT guid, dateRemoved AS modified, 1 AS syncChangeCounter,
|
|
:deletedSyncStatus, 1 AS tombstone
|
|
FROM moz_bookmarks_deleted
|
|
WHERE ${tombstoneConditions.join(" AND ")}`,
|
|
{ deletedSyncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL }
|
|
);
|
|
for (let row of rows) {
|
|
addRowToChangeRecords(row, changeRecords);
|
|
}
|
|
|
|
return changeRecords;
|
|
};
|
|
|
|
// Moves a synced folder's remaining children to its parent, and deletes the
|
|
// folder if it's empty.
|
|
async function deleteSyncedFolder(db, bookmarkItem) {
|
|
// At this point, any member in the folder that remains is either a folder
|
|
// pending deletion (which we'll get to in this function), or an item that
|
|
// should not be deleted. To avoid deleting these items, we first move them
|
|
// to the parent of the folder we're about to delete.
|
|
let childGuids = await fetchChildGuids(db, bookmarkItem.guid);
|
|
if (!childGuids.length) {
|
|
// No children -- just delete the folder.
|
|
return deleteSyncedAtom(bookmarkItem);
|
|
}
|
|
|
|
if (lazy.BookmarkSyncLog.level <= lazy.Log.Level.Trace) {
|
|
lazy.BookmarkSyncLog.trace(
|
|
`deleteSyncedFolder: Moving ${JSON.stringify(childGuids)} children of ` +
|
|
`"${bookmarkItem.guid}" to grandparent
|
|
"${BookmarkSyncUtils.guidToRecordId(bookmarkItem.parentGuid)}" before ` +
|
|
`deletion`
|
|
);
|
|
}
|
|
|
|
// Move children out of the parent and into the grandparent
|
|
for (let guid of childGuids) {
|
|
await lazy.PlacesUtils.bookmarks.update({
|
|
guid,
|
|
parentGuid: bookmarkItem.parentGuid,
|
|
index: lazy.PlacesUtils.bookmarks.DEFAULT_INDEX,
|
|
// `SYNC_REPARENT_REMOVED_FOLDER_CHILDREN` bumps the change counter for
|
|
// the child and its new parent, without incrementing the bookmark
|
|
// tracker's score.
|
|
//
|
|
// We intentionally don't check if the child is one we'll remove later,
|
|
// so it's possible we'll bump the change counter of the closest living
|
|
// ancestor when it's not needed. This avoids inconsistency if removal
|
|
// is interrupted, since we don't run this operation in a transaction.
|
|
source:
|
|
lazy.PlacesUtils.bookmarks.SOURCES
|
|
.SYNC_REPARENT_REMOVED_FOLDER_CHILDREN,
|
|
});
|
|
}
|
|
|
|
// Delete the (now empty) parent
|
|
try {
|
|
await lazy.PlacesUtils.bookmarks.remove(bookmarkItem.guid, {
|
|
preventRemovalOfNonEmptyFolders: true,
|
|
// We don't want to bump the change counter for this deletion, because
|
|
// a tombstone for the folder is already on the server.
|
|
source: SOURCE_SYNC,
|
|
});
|
|
} catch (e) {
|
|
// We failed, probably because someone added something to this folder
|
|
// between when we got the children and now (or the database is corrupt,
|
|
// or something else happened...) This is unlikely, but possible. To
|
|
// avoid corruption in this case, we need to reupload the record to the
|
|
// server.
|
|
//
|
|
// (Ideally this whole operation would be done in a transaction, and this
|
|
// wouldn't be possible).
|
|
lazy.BookmarkSyncLog.trace(
|
|
`deleteSyncedFolder: Error removing parent ` +
|
|
`${bookmarkItem.guid} after reparenting children`,
|
|
e
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Removes a synced bookmark or empty folder from the database.
|
|
var deleteSyncedAtom = async function (bookmarkItem) {
|
|
try {
|
|
await lazy.PlacesUtils.bookmarks.remove(bookmarkItem.guid, {
|
|
preventRemovalOfNonEmptyFolders: true,
|
|
source: SOURCE_SYNC,
|
|
});
|
|
} catch (ex) {
|
|
// Likely already removed.
|
|
lazy.BookmarkSyncLog.trace(
|
|
`deleteSyncedAtom: Error removing ` + bookmarkItem.guid,
|
|
ex
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Updates the sync status on all "NEW" and "UNKNOWN" bookmarks to "NORMAL".
|
|
*
|
|
* We do this when pulling changes instead of in `pushChanges` to make sure
|
|
* we write tombstones if a new item is deleted after an interrupted sync. (For
|
|
* example, if a "NEW" record is uploaded or reconciled, then the app is closed
|
|
* before Sync calls `pushChanges`).
|
|
*/
|
|
function markChangesAsSyncing(db, changeRecords) {
|
|
let unsyncedGuids = [];
|
|
for (let recordId in changeRecords) {
|
|
if (changeRecords[recordId].tombstone) {
|
|
continue;
|
|
}
|
|
if (
|
|
changeRecords[recordId].status ==
|
|
lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
|
|
) {
|
|
continue;
|
|
}
|
|
let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
|
|
unsyncedGuids.push(JSON.stringify(guid));
|
|
}
|
|
if (!unsyncedGuids.length) {
|
|
return Promise.resolve();
|
|
}
|
|
return db.execute(
|
|
`
|
|
UPDATE moz_bookmarks
|
|
SET syncStatus = :syncStatus
|
|
WHERE guid IN (${unsyncedGuids.join(",")})`,
|
|
{ syncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Removes tombstones for successfully synced items.
|
|
*
|
|
* @returns {Promise}
|
|
*/
|
|
var removeTombstones = function (db, guids) {
|
|
if (!guids.length) {
|
|
return Promise.resolve();
|
|
}
|
|
return db.execute(`
|
|
DELETE FROM moz_bookmarks_deleted
|
|
WHERE guid IN (${guids.map(guid => JSON.stringify(guid)).join(",")})`);
|
|
};
|
|
|
|
/**
|
|
* Removes tombstones for successfully synced items where the specified GUID
|
|
* exists in *both* the bookmarks and tombstones tables.
|
|
*
|
|
* @returns {Promise}
|
|
*/
|
|
var removeUndeletedTombstones = function (db, guids) {
|
|
if (!guids.length) {
|
|
return Promise.resolve();
|
|
}
|
|
// sqlite can't join in a DELETE, so we use a subquery.
|
|
return db.execute(`
|
|
DELETE FROM moz_bookmarks_deleted
|
|
WHERE guid IN (${guids.map(guid => JSON.stringify(guid)).join(",")})
|
|
AND guid IN (SELECT guid from moz_bookmarks)`);
|
|
};
|
|
|
|
// Sets the history sync ID and clears the last sync time.
|
|
async function setHistorySyncId(db, newSyncId) {
|
|
await lazy.PlacesUtils.metadata.setWithConnection(
|
|
db,
|
|
new Map([[HistorySyncUtils.SYNC_ID_META_KEY, newSyncId]])
|
|
);
|
|
|
|
await lazy.PlacesUtils.metadata.deleteWithConnection(
|
|
db,
|
|
HistorySyncUtils.LAST_SYNC_META_KEY
|
|
);
|
|
}
|
|
|
|
// Sets the bookmarks sync ID and clears the last sync time.
|
|
async function setBookmarksSyncId(db, newSyncId) {
|
|
await lazy.PlacesUtils.metadata.setWithConnection(
|
|
db,
|
|
new Map([[BookmarkSyncUtils.SYNC_ID_META_KEY, newSyncId]])
|
|
);
|
|
|
|
await lazy.PlacesUtils.metadata.deleteWithConnection(
|
|
db,
|
|
BookmarkSyncUtils.LAST_SYNC_META_KEY,
|
|
BookmarkSyncUtils.WIPE_REMOTE_META_KEY
|
|
);
|
|
}
|
|
|
|
// Bumps the change counter and sets the given sync status for all bookmarks,
|
|
// and drops stale tombstones.
|
|
async function resetAllSyncStatuses(db, syncStatus) {
|
|
await db.execute(
|
|
`
|
|
UPDATE moz_bookmarks
|
|
SET syncChangeCounter = 1,
|
|
syncStatus = :syncStatus`,
|
|
{ syncStatus }
|
|
);
|
|
|
|
// Drop stale tombstones.
|
|
await db.execute("DELETE FROM moz_bookmarks_deleted");
|
|
}
|
|
|
|
/**
|
|
* Other clients might have new fields we don't quite understand yet,
|
|
* so we add it to a "unknownFields" field to roundtrip back to the server
|
|
* so other clients don't experience data loss
|
|
*
|
|
* @param record: an object, usually from the server, and will iterate through the
|
|
* the keys and extract any fields that are unknown to this client
|
|
* @param validFields: an array of keys we know are valid and should ignore
|
|
* @returns {string} json object containing unknownfields, null if none found
|
|
*/
|
|
PlacesSyncUtils.extractUnknownFields = (record, validFields) => {
|
|
let result = Object.keys(record).reduce(
|
|
({ unknownFields, hasUnknownFields }, key) => {
|
|
if (validFields.includes(key)) {
|
|
return { unknownFields, hasUnknownFields };
|
|
}
|
|
unknownFields[key] = record[key];
|
|
return { unknownFields, hasUnknownFields: true };
|
|
},
|
|
{ unknownFields: {}, hasUnknownFields: false }
|
|
);
|
|
if (result.hasUnknownFields) {
|
|
// For simplicity, we store the unknown fields as a string
|
|
// since we never operate on it and just need it for roundtripping
|
|
return JSON.stringify(result.unknownFields);
|
|
}
|
|
return null;
|
|
};
|