--- a/toolkit/components/places/Bookmarks.jsm
+++ b/toolkit/components/places/Bookmarks.jsm
@@ -88,16 +88,25 @@ var Bookmarks = Object.freeze({
* Item's type constants.
* These should stay consistent with nsINavBookmarksService.idl
*/
TYPE_BOOKMARK: 1,
TYPE_FOLDER: 2,
TYPE_SEPARATOR: 3,
/**
+ * Sync status constants, stored for each item.
+ */
+ SYNC_STATUS: {
+ UNKNOWN: Ci.nsINavBookmarksService.SYNC_STATUS_UNKNOWN,
+ NEW: Ci.nsINavBookmarksService.SYNC_STATUS_NEW,
+ NORMAL: Ci.nsINavBookmarksService.SYNC_STATUS_NORMAL,
+ },
+
+ /**
* Default index used to append a bookmark-item at the end of a folder.
* This should stay consistent with nsINavBookmarksService.idl
*/
DEFAULT_INDEX: -1,
/**
* Bookmark change source constants, passed as optional properties and
* forwarded to observers. See nsINavBookmarksService.idl for an explanation.
@@ -470,21 +479,24 @@ var Bookmarks = Object.freeze({
*/
eraseEverything: function(options = {}) {
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: eraseEverything",
db => db.executeTransaction(function* () {
const folderGuids = [this.toolbarGuid, this.menuGuid, this.unfiledGuid,
this.mobileGuid];
yield removeFoldersContents(db, folderGuids, options);
const time = PlacesUtils.toPRTime(new Date());
+ const syncChangeDelta =
+ PlacesSyncUtils.bookmarks.determineSyncChangeDelta(options.source);
for (let folderGuid of folderGuids) {
yield db.executeCached(
- `UPDATE moz_bookmarks SET lastModified = :time
+ `UPDATE moz_bookmarks SET lastModified = :time,
+ syncChangeCounter = syncChangeCounter + :syncChangeDelta
WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid )
- `, { folderGuid, time });
+ `, { folderGuid, time, syncChangeDelta });
}
}.bind(this))
);
},
/**
* Returns a list of recently bookmarked items.
*
@@ -687,35 +699,36 @@ var Bookmarks = Object.freeze({
* - source: The change source, forwarded to all bookmark observers.
* Defaults to nsINavBookmarksService::SOURCE_DEFAULT.
*
* @return {Promise} resolved when reordering is complete.
* @rejects if an error happens while reordering.
* @throws if the arguments are invalid.
*/
reorder(parentGuid, orderedChildrenGuids, options = {}) {
- let info = { guid: parentGuid, source: this.SOURCES.DEFAULT };
+ let info = { guid: parentGuid };
info = validateBookmarkObject(info, { guid: { required: true } });
if (!Array.isArray(orderedChildrenGuids) || !orderedChildrenGuids.length)
throw new Error("Must provide a sorted array of children GUIDs.");
try {
orderedChildrenGuids.forEach(PlacesUtils.BOOKMARK_VALIDATORS.guid);
} catch (ex) {
throw new Error("Invalid GUID found in the sorted children array.");
}
return Task.spawn(function* () {
let parent = yield fetchBookmark(info);
if (!parent || parent.type != this.TYPE_FOLDER)
throw new Error("No folder found for the provided GUID.");
- let sortedChildren = yield reorderChildren(parent, orderedChildrenGuids);
+ let sortedChildren = yield reorderChildren(parent, orderedChildrenGuids,
+ options);
- let { source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = options;
+ let { source = Bookmarks.SOURCES.DEFAULT } = options;
let observers = PlacesUtils.bookmarks.getObservers();
// Note that child.index is the old index.
for (let i = 0; i < sortedChildren.length; ++i) {
let child = sortedChildren[i];
notify(observers, "onItemMoved", [ child._id, child._parentId,
child.index, child._parentId,
i, child.type,
child.guid, child.parentGuid,
@@ -816,22 +829,25 @@ function notify(observers, notification,
// Update implementation.
function updateBookmark(info, item, newParent) {
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
Task.async(function*(db) {
let tuples = new Map();
- if (info.hasOwnProperty("lastModified"))
- tuples.set("lastModified", { value: PlacesUtils.toPRTime(info.lastModified) });
+ tuples.set("lastModified", { value: PlacesUtils.toPRTime(info.lastModified) });
if (info.hasOwnProperty("title"))
tuples.set("title", { value: info.title });
yield db.executeTransaction(function* () {
+ let isTagging = item._grandParentId == PlacesUtils.tagsFolderId;
+ let syncChangeDelta =
+ PlacesSyncUtils.bookmarks.determineSyncChangeDelta(info.source);
+
if (info.hasOwnProperty("url")) {
// Ensure a page exists in moz_places for this URL.
yield maybeInsertPlace(db, info.url);
// Update tuples for the update query.
tuples.set("url", { value: info.url.href
, fragment: "fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url)" });
}
@@ -839,49 +855,102 @@ function updateBookmark(info, item, newP
// For simplicity, update the index regardless.
let newIndex = info.hasOwnProperty("index") ? info.index : item.index;
tuples.set("position", { value: newIndex });
if (newParent.guid == item.parentGuid) {
// Moving inside the original container.
// When moving "up", add 1 to each index in the interval.
// Otherwise when moving down, we subtract 1.
+ // Only the parent needs a sync change, which is handled in
+ // `setAncestorsLastModified`.
let sign = newIndex < item.index ? +1 : -1;
yield db.executeCached(
`UPDATE moz_bookmarks SET position = position + :sign
WHERE parent = :newParentId
AND position BETWEEN :lowIndex AND :highIndex
`, { sign: sign, newParentId: newParent._id,
lowIndex: Math.min(item.index, newIndex),
highIndex: Math.max(item.index, newIndex) });
} else {
- // Moving across different containers.
+ // Moving across different containers. In this case, both parents and
+ // the child need sync changes. `setAncestorsLastModified` handles the
+ // parents; the `needsSyncChange` check below handles the child.
tuples.set("parent", { value: newParent._id} );
yield db.executeCached(
`UPDATE moz_bookmarks SET position = position + :sign
WHERE parent = :oldParentId
AND position >= :oldIndex
`, { sign: -1, oldParentId: item._parentId, oldIndex: item.index });
yield db.executeCached(
`UPDATE moz_bookmarks SET position = position + :sign
WHERE parent = :newParentId
AND position >= :newIndex
`, { sign: +1, newParentId: newParent._id, newIndex: newIndex });
- yield setAncestorsLastModified(db, item.parentGuid, info.lastModified);
+ yield setAncestorsLastModified(db, item.parentGuid, info.lastModified,
+ syncChangeDelta);
+ }
+ yield setAncestorsLastModified(db, newParent.guid, info.lastModified,
+ syncChangeDelta);
+ }
+
+ if (syncChangeDelta) {
+ let isChangingIndex = info.hasOwnProperty("index") &&
+ info.index != item.index;
+ // Sync stores child indices in the parent's record, so we only bump the
+ // item's counter if we're updating at least one more property in
+ // addition to the index and last modified time.
+ let needsSyncChange = isChangingIndex ? tuples.size > 2 : tuples.size > 1;
+ if (needsSyncChange) {
+ tuples.set("syncChangeDelta", { value: syncChangeDelta
+ , fragment: "syncChangeCounter = syncChangeCounter + :syncChangeDelta" });
}
- yield setAncestorsLastModified(db, newParent.guid, info.lastModified);
+ }
+
+ if (isTagging) {
+ // If we're updating a tag entry, bump the sync change counter for
+ // bookmarks with the tagged URL.
+ yield PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL(
+ db, item.url, syncChangeDelta);
+ if (info.hasOwnProperty("url")) {
+ // Changing the URL of a tag entry is equivalent to untagging the
+ // old URL and tagging the new one, so we bump the change counter
+ // for the new URL here.
+ yield PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL(
+ db, info.url, syncChangeDelta);
+ }
+ }
+
+ let isChangingTagFolder = item._parentId == PlacesUtils.tagsFolderId;
+ if (isChangingTagFolder) {
+ // If we're updating a tag folder (for example, changing a tag's title),
+ // bump the change counter for all tagged bookmarks.
+ yield addSyncChangesForBookmarksInFolder(db, item, syncChangeDelta);
}
yield db.executeCached(
`UPDATE moz_bookmarks
SET ${Array.from(tuples.keys()).map(v => tuples.get(v).fragment || `${v} = :${v}`).join(", ")}
WHERE guid = :guid
`, Object.assign({ guid: info.guid },
[...tuples.entries()].reduce((p, c) => { p[c[0]] = c[1].value; return p; }, {})));
+
+ if (newParent) {
+ // Remove the Sync orphan annotation from reparented items. We don't
+ // notify annotation observers about this because this is a temporary,
+ // internal anno that's only used by Sync.
+ yield db.executeCached(
+ `DELETE FROM moz_items_annos
+ WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes
+ WHERE name = :orphanAnno) AND
+ item_id = :id`,
+ { orphanAnno: PlacesSyncUtils.bookmarks.SYNC_PARENT_ANNO,
+ id: item._id });
+ }
});
// If the parent changed, update related non-enumerable properties.
let additionalParentInfo = {};
if (newParent) {
Object.defineProperty(additionalParentInfo, "_parentId",
{ value: newParent._id, enumerable: false });
Object.defineProperty(additionalParentInfo, "_grandParentId",
@@ -901,49 +970,74 @@ function updateBookmark(info, item, newP
// Insert implementation.
function insertBookmark(item, parent) {
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: insertBookmark",
Task.async(function*(db) {
// If a guid was not provided, generate one, so we won't need to fetch the
// bookmark just after having created it.
- if (!item.hasOwnProperty("guid"))
+ let hasExistingGuid = item.hasOwnProperty("guid");
+ if (!hasExistingGuid)
item.guid = (yield db.executeCached("SELECT GENERATE_GUID() AS guid"))[0].getResultByName("guid");
+ let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
+
yield db.executeTransaction(function* transaction() {
if (item.type == Bookmarks.TYPE_BOOKMARK) {
// Ensure a page exists in moz_places for this URL.
// The IGNORE conflict can trigger on `guid`.
yield maybeInsertPlace(db, item.url);
}
// Adjust indices.
yield db.executeCached(
`UPDATE moz_bookmarks SET position = position + 1
WHERE parent = :parent
AND position >= :index
`, { parent: parent._id, index: item.index });
+ let syncChangeDelta =
+ PlacesSyncUtils.bookmarks.determineSyncChangeDelta(item.source);
+ let syncStatus =
+ PlacesSyncUtils.bookmarks.determineInitialSyncStatus(item.source);
+
// Insert the bookmark into the database.
yield db.executeCached(
`INSERT INTO moz_bookmarks (fk, type, parent, position, title,
- dateAdded, lastModified, guid)
+ dateAdded, lastModified, guid,
+ syncChangeCounter, syncStatus)
VALUES ((SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url), :type, :parent,
- :index, :title, :date_added, :last_modified, :guid)
+ :index, :title, :date_added, :last_modified, :guid,
+ :syncChangeCounter, :syncStatus)
`, { url: item.hasOwnProperty("url") ? item.url.href : "nonexistent",
type: item.type, parent: parent._id, index: item.index,
title: item.title, date_added: PlacesUtils.toPRTime(item.dateAdded),
- last_modified: PlacesUtils.toPRTime(item.lastModified), guid: item.guid });
+ last_modified: PlacesUtils.toPRTime(item.lastModified), guid: item.guid,
+ syncChangeCounter: syncChangeDelta, syncStatus });
+
+ if (hasExistingGuid) {
+ // Remove stale tombstones if we're reinserting an item.
+ yield db.executeCached(
+ `DELETE FROM moz_bookmarks_deleted WHERE guid = :guid`,
+ { guid: item.guid });
+ }
- yield setAncestorsLastModified(db, item.parentGuid, item.dateAdded);
+ if (isTagging) {
+ // New tag entry; bump the change counter for bookmarks with the
+ // tagged URL.
+ yield PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL(
+ db, item.url, syncChangeDelta);
+ }
+
+ yield setAncestorsLastModified(db, item.parentGuid, item.dateAdded,
+ syncChangeDelta);
});
// If not a tag recalculate frecency...
- let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
if (item.type == Bookmarks.TYPE_BOOKMARK && !isTagging) {
// ...though we don't wait for the calculation.
updateFrecency(db, [item.url]).then(null, Cu.reportError);
}
// Don't return an empty title to the caller.
if (item.hasOwnProperty("title") && item.title === null)
delete item.title;
@@ -984,17 +1078,18 @@ function queryBookmarks(info) {
// hence setting them to NULL
let rows = yield db.executeCached(
`SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
b.dateAdded, b.lastModified, b.type, b.title,
h.url AS url, b.parent, p.parent,
NULL AS _id,
NULL AS _childCount,
NULL AS _grandParentId,
- NULL AS _parentId
+ NULL AS _parentId,
+ NULL AS _syncStatus
FROM moz_bookmarks b
LEFT JOIN moz_bookmarks p ON p.id = b.parent
LEFT JOIN moz_places h ON h.id = b.fk
${queryString}
`, queryParams);
return rowsToItemsArray(rows);
}));
@@ -1007,17 +1102,17 @@ function fetchBookmark(info) {
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmark",
Task.async(function*(db) {
let rows = yield db.executeCached(
`SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
b.id AS _id, b.parent AS _parentId,
(SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
- p.parent AS _grandParentId
+ p.parent AS _grandParentId, b.syncStatus AS _syncStatus
FROM moz_bookmarks b
LEFT JOIN moz_bookmarks p ON p.id = b.parent
LEFT JOIN moz_places h ON h.id = b.fk
WHERE b.guid = :guid
`, { guid: info.guid });
return rows.length ? rowsToItemsArray(rows)[0] : null;
}));
@@ -1028,17 +1123,17 @@ function fetchBookmarkByPosition(info) {
Task.async(function*(db) {
let index = info.index == Bookmarks.DEFAULT_INDEX ? null : info.index;
let rows = yield db.executeCached(
`SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
b.id AS _id, b.parent AS _parentId,
(SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
- p.parent AS _grandParentId
+ p.parent AS _grandParentId, b.syncStatus AS _syncStatus
FROM moz_bookmarks b
LEFT JOIN moz_bookmarks p ON p.id = b.parent
LEFT JOIN moz_places h ON h.id = b.fk
WHERE p.guid = :parentGuid
AND b.position = IFNULL(:index, (SELECT count(*) - 1
FROM moz_bookmarks
WHERE parent = p.id))
`, { parentGuid: info.parentGuid, index });
@@ -1052,17 +1147,17 @@ function fetchBookmarksByURL(info) {
Task.async(function*(db) {
let rows = yield db.executeCached(
`/* do not warn (bug no): not worth to add an index */
SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
b.id AS _id, b.parent AS _parentId,
(SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
- p.parent AS _grandParentId
+ p.parent AS _grandParentId, b.syncStatus AS _syncStatus
FROM moz_bookmarks b
LEFT JOIN moz_bookmarks p ON p.id = b.parent
LEFT JOIN moz_places h ON h.id = b.fk
WHERE h.url_hash = hash(:url) AND h.url = :url
AND _grandParentId <> :tags_folder
ORDER BY b.lastModified DESC
`, { url: info.url.href,
tags_folder: PlacesUtils.tagsFolderId });
@@ -1073,17 +1168,18 @@ function fetchBookmarksByURL(info) {
function fetchRecentBookmarks(numberOfItems) {
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchRecentBookmarks",
Task.async(function*(db) {
let rows = yield db.executeCached(
`SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
- NULL AS _id, NULL AS _parentId, NULL AS _childCount, NULL AS _grandParentId
+ NULL AS _id, NULL AS _parentId, NULL AS _childCount, NULL AS _grandParentId,
+ NULL AS _syncStatus
FROM moz_bookmarks b
LEFT JOIN moz_bookmarks p ON p.id = b.parent
LEFT JOIN moz_places h ON h.id = b.fk
WHERE p.parent <> :tags_folder
ORDER BY b.dateAdded DESC, b.ROWID DESC
LIMIT :numberOfItems
`, { tags_folder: PlacesUtils.tagsFolderId, numberOfItems });
@@ -1095,17 +1191,17 @@ function fetchBookmarksByParent(info) {
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByParent",
Task.async(function*(db) {
let rows = yield db.executeCached(
`SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
b.id AS _id, b.parent AS _parentId,
(SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
- p.parent AS _grandParentId
+ p.parent AS _grandParentId, b.syncStatus AS _syncStatus
FROM moz_bookmarks b
LEFT JOIN moz_bookmarks p ON p.id = b.parent
LEFT JOIN moz_places h ON h.id = b.fk
WHERE p.guid = :parentGuid
ORDER BY b.position ASC
`, { parentGuid: info.parentGuid });
return rowsToItemsArray(rows);
@@ -1142,32 +1238,46 @@ function removeBookmark(item, options) {
`DELETE FROM moz_bookmarks WHERE guid = :guid`, { guid: item.guid });
// Fix indices in the parent.
yield db.executeCached(
`UPDATE moz_bookmarks SET position = position - 1 WHERE
parent = :parentId AND position > :index
`, { parentId: item._parentId, index: item.index });
- yield setAncestorsLastModified(db, item.parentGuid, new Date());
+ let syncChangeDelta =
+ PlacesSyncUtils.bookmarks.determineSyncChangeDelta(options.source);
+
+ if (isUntagging) {
+ // If we're removing a tag entry, increment the change counter for all
+ // bookmarks with the tagged URL.
+ yield PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL(
+ db, item.url, syncChangeDelta);
+ }
+
+ // Write a tombstone for the removed item.
+ yield insertTombstone(db, item, syncChangeDelta);
+
+ yield setAncestorsLastModified(db, item.parentGuid, new Date(),
+ syncChangeDelta);
});
// If not a tag recalculate frecency...
if (item.type == Bookmarks.TYPE_BOOKMARK && !isUntagging) {
// ...though we don't wait for the calculation.
updateFrecency(db, [item.url]).then(null, Cu.reportError);
}
return item;
}));
}
// Reorder implementation.
-function reorderChildren(parent, orderedChildrenGuids) {
+function reorderChildren(parent, orderedChildrenGuids, options) {
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
db => db.executeTransaction(function* () {
// Select all of the direct children for the given parent.
let children = yield fetchBookmarksByParent({ parentGuid: parent.guid });
if (!children.length)
return undefined;
// Build a map of GUIDs to indices for fast lookups in the comparator
@@ -1212,16 +1322,27 @@ function reorderChildren(parent, ordered
END
FROM sorting a
JOIN sorting b ON b.p <= a.p
WHERE a.g = guid
)
WHERE parent = :parentId
`, { parentId: parent._id});
+ let syncChangeDelta =
+ PlacesSyncUtils.bookmarks.determineSyncChangeDelta(options.source);
+ if (syncChangeDelta) {
+ // Flag the parent as having a change.
+ yield db.executeCached(`
+ UPDATE moz_bookmarks SET
+ syncChangeCounter = syncChangeCounter + :syncChangeDelta
+ WHERE id = :parentId`,
+ { parentId: parent._id, syncChangeDelta });
+ }
+
// Update position of items that could have been inserted in the meanwhile.
// Since this can happen rarely and it's only done for schema coherence
// resonds, we won't notify about these changes.
yield db.executeCached(
`CREATE TEMP TRIGGER moz_bookmarks_reorder_trigger
AFTER UPDATE OF position ON moz_bookmarks
WHEN NEW.position = -1
BEGIN
@@ -1235,16 +1356,33 @@ function reorderChildren(parent, ordered
END
`);
yield db.executeCached(
`UPDATE moz_bookmarks SET position = -1 WHERE position < 0`);
yield db.executeCached(`DROP TRIGGER moz_bookmarks_reorder_trigger`);
+ // Remove the Sync orphan annotation from the reordered children, so that
+ // Sync doesn't try to reparent them once it sees the original parents. We
+ // only do this for explicitly ordered children, to avoid removing orphan
+ // annos set by Sync.
+ let possibleOrphanIds = [];
+ for (let child of children) {
+ if (guidIndices.has(child.guid)) {
+ possibleOrphanIds.push(child._id);
+ }
+ }
+ yield db.executeCached(
+ `DELETE FROM moz_items_annos
+ WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes
+ WHERE name = :orphanAnno) AND
+ item_id IN (${possibleOrphanIds.join(", ")})`,
+ { orphanAnno: PlacesSyncUtils.bookmarks.SYNC_PARENT_ANNO });
+
return children;
}.bind(this))
);
}
// Helpers.
/**
@@ -1309,17 +1447,18 @@ function rowsToItemsArray(rows) {
for (let prop of ["dateAdded", "lastModified"]) {
item[prop] = PlacesUtils.toDate(row.getResultByName(prop));
}
for (let prop of ["title", "parentGuid", "url" ]) {
let val = row.getResultByName(prop);
if (val)
item[prop] = prop === "url" ? new URL(val) : val;
}
- for (let prop of ["_id", "_parentId", "_childCount", "_grandParentId"]) {
+ for (let prop of ["_id", "_parentId", "_childCount", "_grandParentId",
+ "_syncStatus"]) {
let val = row.getResultByName(prop);
if (val !== null) {
// These properties should not be returned to the API consumer, thus
// they are non-enumerable and removed through Object.assign just before
// the object is returned.
// Configurable is set to support mergeIntoNewObject overwrites.
Object.defineProperty(item, prop, { value: val, enumerable: false,
configurable: true });
@@ -1411,58 +1550,70 @@ var removeAnnotationsForItem = Task.asyn
* the Sqlite.jsm connection handle.
* @param folderGuid
* the GUID of the folder whose ancestors should be updated.
* @param time
* a Date object to use for the update.
*
* @note the folder itself is also updated.
*/
-var setAncestorsLastModified = Task.async(function* (db, folderGuid, time) {
+var setAncestorsLastModified = Task.async(function* (db, folderGuid, time, syncChangeDelta) {
yield db.executeCached(
`WITH RECURSIVE
ancestors(aid) AS (
SELECT id FROM moz_bookmarks WHERE guid = :guid
UNION ALL
SELECT parent FROM moz_bookmarks
JOIN ancestors ON id = aid
WHERE type = :type
)
UPDATE moz_bookmarks SET lastModified = :time
WHERE id IN ancestors
`, { guid: folderGuid, type: Bookmarks.TYPE_FOLDER,
time: PlacesUtils.toPRTime(time) });
+
+ if (syncChangeDelta) {
+ // Flag the folder as having a change.
+ yield db.executeCached(`
+ UPDATE moz_bookmarks SET
+ syncChangeCounter = syncChangeCounter + :syncChangeDelta
+ WHERE guid = :guid`,
+ { guid: folderGuid, syncChangeDelta });
+ }
});
/**
* Remove all descendants of one or more bookmark folders.
*
* @param db
* the Sqlite.jsm connection handle.
* @param folderGuids
* array of folder guids.
*/
var removeFoldersContents =
Task.async(function* (db, folderGuids, options) {
+ let syncChangeDelta =
+ PlacesSyncUtils.bookmarks.determineSyncChangeDelta(options.source);
+
let itemsRemoved = [];
for (let folderGuid of folderGuids) {
let rows = yield db.executeCached(
`WITH RECURSIVE
descendants(did) AS (
SELECT b.id FROM moz_bookmarks b
JOIN moz_bookmarks p ON b.parent = p.id
WHERE p.guid = :folderGuid
UNION ALL
SELECT id FROM moz_bookmarks
JOIN descendants ON parent = did
)
SELECT b.id AS _id, b.parent AS _parentId, b.position AS 'index',
b.type, url, b.guid, p.guid AS parentGuid, b.dateAdded,
b.lastModified, b.title, p.parent AS _grandParentId,
- NULL AS _childCount
+ NULL AS _childCount, b.syncStatus AS _syncStatus
FROM descendants
JOIN moz_bookmarks b ON did = b.id
JOIN moz_bookmarks p ON p.id = b.parent
LEFT JOIN moz_places h ON b.fk = h.id`, { folderGuid });
itemsRemoved = itemsRemoved.concat(rowsToItemsArray(rows));
yield db.executeCached(
@@ -1473,31 +1624,38 @@ Task.async(function* (db, folderGuids, o
WHERE p.guid = :folderGuid
UNION ALL
SELECT id FROM moz_bookmarks
JOIN descendants ON parent = did
)
DELETE FROM moz_bookmarks WHERE id IN descendants`, { folderGuid });
}
+ // Write tombstones for removed items.
+ yield insertTombstones(db, itemsRemoved, syncChangeDelta);
+
+ // Bump the change counter for all tagged bookmarks when removing tag
+ // folders.
+ yield addSyncChangesForRemovedTagFolders(db, itemsRemoved, syncChangeDelta);
+
// Cleanup orphans.
yield removeOrphanAnnotations(db);
// TODO (Bug 1087576): this may leave orphan tags behind.
let urls = itemsRemoved.filter(item => "url" in item).map(item => item.url);
updateFrecency(db, urls).then(null, Cu.reportError);
// Send onItemRemoved notifications to listeners.
// TODO (Bug 1087580): for the case of eraseEverything, this should send a
// single clear bookmarks notification rather than notifying for each
// bookmark.
// Notify listeners in reverse order to serve children before parents.
- let { source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = options;
+ let { source = Bookmarks.SOURCES.DEFAULT } = options;
let observers = PlacesUtils.bookmarks.getObservers();
for (let item of itemsRemoved.reverse()) {
let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
notify(observers, "onItemRemoved", [ item._id, item._parentId,
item.index, item.type, uri,
item.guid, item.parentGuid,
source ],
// Notify observers that this item is being
@@ -1529,8 +1687,81 @@ function maybeInsertPlace(db, url) {
`INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid)
VALUES (:url, hash(:url), :rev_host, 0, :frecency,
IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url),
GENERATE_GUID()))
`, { url: url.href,
rev_host: PlacesUtils.getReversedHost(url),
frecency: url.protocol == "place:" ? 0 : -1 });
}
+
+// Indicates whether we should write a tombstone for an item that has been
+// uploaded to the server. We ignore "NEW" and "UNKNOWN" items: "NEW" items
+// haven't been uploaded yet, and "UNKNOWN" items need a full reconciliation
+// with the server.
+function needsTombstone(item) {
+ return item._syncStatus == Bookmarks.SYNC_STATUS.NORMAL;
+}
+
+// Inserts a tombstone for a removed synced item. Tombstones are stored as rows
+// in the `moz_bookmarks_deleted` table, and only written for "NORMAL" items.
+// After each sync, `PlacesSyncUtils.bookmarks.pushChanges` drops successfully
+// uploaded tombstones.
+function insertTombstone(db, item, syncChangeDelta) {
+ if (!syncChangeDelta || !needsTombstone(item)) {
+ return Promise.resolve();
+ }
+ return db.executeCached(`
+ INSERT INTO moz_bookmarks_deleted (guid, dateRemoved)
+ VALUES (:guid, :dateRemoved)`,
+ { guid: item.guid,
+ dateRemoved: PlacesUtils.toPRTime(Date.now()) });
+}
+
+// Inserts tombstones for removed synced items.
+function insertTombstones(db, itemsRemoved, syncChangeDelta) {
+ if (!syncChangeDelta) {
+ return Promise.resolve();
+ }
+ let syncedItems = itemsRemoved.filter(needsTombstone);
+ if (!syncedItems.length) {
+ return Promise.resolve();
+ }
+ let dateRemoved = PlacesUtils.toPRTime(Date.now());
+ let valuesTable = syncedItems.map(item => `(
+ ${JSON.stringify(item.guid)},
+ ${dateRemoved}
+ )`).join(",");
+ return db.execute(`
+ INSERT INTO moz_bookmarks_deleted (guid, dateRemoved)
+ VALUES ${valuesTable}`
+ );
+}
+
+// Bumps the change counter for all bookmarks with URLs referenced in removed
+// tag folders.
+var addSyncChangesForRemovedTagFolders = Task.async(function* (db, itemsRemoved, syncChangeDelta) {
+ if (!syncChangeDelta) {
+ return;
+ }
+ for (let item of itemsRemoved) {
+ let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
+ if (isUntagging) {
+ yield PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL(
+ db, item.url, syncChangeDelta);
+ }
+ }
+});
+
+// Bumps the change counter for all bookmarked URLs within `folders`.
+// This is used to update tagged bookmarks when changing a tag folder.
+function addSyncChangesForBookmarksInFolder(db, folder, syncChangeDelta) {
+ if (!syncChangeDelta) {
+ return Promise.resolve();
+ }
+ return db.execute(`
+ UPDATE moz_bookmarks SET
+ syncChangeCounter = syncChangeCounter + :syncChangeDelta
+ WHERE type = :type AND
+ fk = (SELECT fk FROM moz_bookmarks WHERE parent = :parent)
+ `,
+ { syncChangeDelta, type: Bookmarks.TYPE_BOOKMARK, parent: folder._id });
+}
--- a/toolkit/components/places/tests/unit/test_sync_utils.js
+++ b/toolkit/components/places/tests/unit/test_sync_utils.js
@@ -1,9 +1,14 @@
Cu.import("resource://gre/modules/PlacesSyncUtils.jsm");
+const {
+ // `fetchGuidsWithAnno` isn't exported, but we can still access it here via a
+ // backstage pass.
+ fetchGuidsWithAnno,
+} = Cu.import("resource://gre/modules/PlacesSyncUtils.jsm");
Cu.import("resource://testing-common/httpd.js");
Cu.importGlobalProperties(["crypto", "URLSearchParams"]);
const DESCRIPTION_ANNO = "bookmarkProperties/description";
const LOAD_IN_SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar";
const SYNC_PARENT_ANNO = "sync/parent";
function makeGuid() {
@@ -974,18 +979,16 @@ add_task(function* test_insert_orphans()
kind: "folder",
parentSyncId: "menu",
syncId: grandParentGuid,
});
equal(PlacesUtils.annotations.getItemAnnotation(childId, SYNC_PARENT_ANNO),
parentGuid, "Child should still have orphan anno");
}
- // Note that only `PlacesSyncUtils` reparents orphans, though Sync adds an
- // observer that removes the orphan anno if the orphan is manually moved.
do_print("Insert the missing parent");
{
let parent = yield PlacesSyncUtils.bookmarks.insert({
kind: "folder",
parentSyncId: grandParentGuid,
syncId: parentGuid,
});
equal(parent.syncId, parentGuid, "Should insert parent with requested GUID");
@@ -997,16 +1000,197 @@ add_task(function* test_insert_orphans()
let child = yield PlacesUtils.bookmarks.fetch({ guid: childGuid });
equal(child.parentGuid, parentGuid,
"Should reparent child after inserting missing parent");
}
yield PlacesUtils.bookmarks.eraseEverything();
});
+add_task(function* test_move_orphans() {
+ let nonexistentSyncId = makeGuid();
+ let fxBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ syncId: makeGuid(),
+ parentSyncId: nonexistentSyncId,
+ url: "http://getfirefox.com",
+ });
+ let tbBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ syncId: makeGuid(),
+ parentSyncId: nonexistentSyncId,
+ url: "http://getthunderbird.com",
+ });
+
+ do_print("Verify synced orphan annos match");
+ {
+ let orphanGuids = yield fetchGuidsWithAnno(SYNC_PARENT_ANNO,
+ nonexistentSyncId);
+ deepEqual(orphanGuids.sort(), [fxBmk.syncId, tbBmk.syncId].sort(),
+ "Orphaned bookmarks should match before moving");
+ }
+
+ do_print("Move synced orphan using async API");
+ {
+ yield PlacesUtils.bookmarks.update({
+ guid: fxBmk.syncId,
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ });
+ let orphanGuids = yield fetchGuidsWithAnno(SYNC_PARENT_ANNO,
+ nonexistentSyncId);
+ deepEqual(orphanGuids, [tbBmk.syncId],
+ "Should remove orphan annos from updated bookmark");
+ }
+
+ do_print("Move synced orphan using sync API");
+ {
+ let tbId = yield syncIdToId(tbBmk.syncId);
+ PlacesUtils.bookmarks.moveItem(tbId, PlacesUtils.toolbarFolderId,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ let orphanGuids = yield fetchGuidsWithAnno(SYNC_PARENT_ANNO,
+ nonexistentSyncId);
+ deepEqual(orphanGuids, [],
+ "Should remove orphan annos from moved bookmark");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_reorder_orphans() {
+ let nonexistentSyncId = makeGuid();
+ let fxBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ syncId: makeGuid(),
+ parentSyncId: nonexistentSyncId,
+ url: "http://getfirefox.com",
+ });
+ let tbBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ syncId: makeGuid(),
+ parentSyncId: nonexistentSyncId,
+ url: "http://getthunderbird.com",
+ });
+ let mozBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ syncId: makeGuid(),
+ parentSyncId: nonexistentSyncId,
+ url: "https://mozilla.org",
+ });
+
+ do_print("Verify synced orphan annos match");
+ {
+ let orphanGuids = yield fetchGuidsWithAnno(SYNC_PARENT_ANNO,
+ nonexistentSyncId);
+ deepEqual(orphanGuids.sort(), [
+ fxBmk.syncId,
+ tbBmk.syncId,
+ mozBmk.syncId,
+ ].sort(), "Orphaned bookmarks should match before reordering");
+ }
+
+ do_print("Reorder synced orphans");
+ {
+ yield PlacesUtils.bookmarks.reorder(PlacesUtils.bookmarks.unfiledGuid,
+ [tbBmk.syncId, fxBmk.syncId]);
+ let orphanGuids = yield fetchGuidsWithAnno(SYNC_PARENT_ANNO,
+ nonexistentSyncId);
+ deepEqual(orphanGuids, [mozBmk.syncId],
+ "Should remove orphan annos from explicitly reordered bookmarks");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_set_orphan_indices() {
+ let nonexistentSyncId = makeGuid();
+ let fxBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ syncId: makeGuid(),
+ parentSyncId: nonexistentSyncId,
+ url: "http://getfirefox.com",
+ });
+ let tbBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ syncId: makeGuid(),
+ parentSyncId: nonexistentSyncId,
+ url: "http://getthunderbird.com",
+ });
+
+ do_print("Verify synced orphan annos match");
+ {
+ let orphanGuids = yield fetchGuidsWithAnno(SYNC_PARENT_ANNO,
+ nonexistentSyncId);
+ deepEqual(orphanGuids.sort(), [fxBmk.syncId, tbBmk.syncId].sort(),
+ "Orphaned bookmarks should match before changing indices");
+ }
+
+ do_print("Set synced orphan indices");
+ {
+ let fxId = yield syncIdToId(fxBmk.syncId);
+ let tbId = yield syncIdToId(tbBmk.syncId);
+ PlacesUtils.bookmarks.runInBatchMode(_ => {
+ PlacesUtils.bookmarks.setItemIndex(fxId, 1);
+ PlacesUtils.bookmarks.setItemIndex(tbId, 0);
+ }, null);
+ let orphanGuids = yield fetchGuidsWithAnno(SYNC_PARENT_ANNO,
+ nonexistentSyncId);
+ deepEqual(orphanGuids, [],
+ "Should remove orphan annos after updating indices");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_unsynced_orphans() {
+ let nonexistentSyncId = makeGuid();
+ let newBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ syncId: makeGuid(),
+ parentSyncId: nonexistentSyncId,
+ url: "http://getfirefox.com",
+ });
+ let unknownBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ syncId: makeGuid(),
+ parentSyncId: nonexistentSyncId,
+ url: "http://getthunderbird.com",
+ });
+ yield PlacesTestUtils.setBookmarkSyncFields({
+ guid: newBmk.syncId,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW,
+ }, {
+ guid: unknownBmk.syncId,
+ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN,
+ });
+
+ do_print("Reorder unsynced orphans");
+ {
+ yield PlacesUtils.bookmarks.reorder(PlacesUtils.bookmarks.unfiledGuid,
+ [unknownBmk.syncId, newBmk.syncId]);
+ let orphanGuids = yield fetchGuidsWithAnno(SYNC_PARENT_ANNO,
+ nonexistentSyncId);
+ deepEqual(orphanGuids.sort(), [newBmk.syncId, unknownBmk.syncId].sort(),
+ "Should not remove orphan annos from reordered unsynced bookmarks");
+ }
+
+ do_print("Move unsynced orphan");
+ {
+ let unknownId = yield syncIdToId(unknownBmk.syncId);
+ PlacesUtils.bookmarks.moveItem(unknownId, PlacesUtils.toolbarFolderId,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ let orphanGuids = yield fetchGuidsWithAnno(SYNC_PARENT_ANNO,
+ nonexistentSyncId);
+ deepEqual(orphanGuids.sort(), [newBmk.syncId, unknownBmk.syncId].sort(),
+ "Should not remove orphan annos from moved unsynced bookmarks");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
add_task(function* test_fetch() {
let folder = yield PlacesSyncUtils.bookmarks.insert({
syncId: makeGuid(),
parentSyncId: "menu",
kind: "folder",
description: "Folder description",
});
let bmk = yield PlacesSyncUtils.bookmarks.insert({