feat: Add workspace ordering and changes tracking

This commit introduces workspace ordering and a new mechanism for tracking changes to workspaces.

**Changes:**

- **Workspace Ordering:** Workspaces can now be ordered using a `position` field. This allows for user-defined ordering of workspaces, improving usability.
- **Changes Tracking:** A new `zen_workspaces_changes` table is added to track changes to workspaces. This allows for more efficient sync operations and improved error handling.

**Benefits:**

- **Improved Workspace Management:** Users can now customize the order of their workspaces.
- **More Efficient Sync:** Changes tracking enables faster and more accurate synchronization of workspaces across devices.
- **Enhanced Error Handling:** Changes tracking helps to identify and resolve conflicts during sync.

**Notes:**

- This change requires deleting the zen_workspaces table in places db
This commit is contained in:
Kristijan Ribarić 2024-10-05 19:20:19 +02:00
parent 1c7bc5c501
commit 0d161326ef
3 changed files with 535 additions and 211 deletions

View file

@ -1,221 +1,384 @@
var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
var { LegacyTracker } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs");
var { Store } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs");
var { SyncEngine } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs");
var { Tracker, Store, SyncEngine } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs");
var { CryptoWrapper } = ChromeUtils.importESModule("resource://services-sync/record.sys.mjs");
var { Svc,Utils } = ChromeUtils.importESModule("resource://services-sync/util.sys.mjs");
var { Utils } = ChromeUtils.importESModule("resource://services-sync/util.sys.mjs");
var { SCORE_INCREMENT_XLARGE } = ChromeUtils.importESModule("resource://services-sync/constants.sys.mjs");
function ZenWorkspacesTracker(name, engine) {
LegacyTracker.call(this, name, engine);
}
ZenWorkspacesTracker.prototype = {
__proto__: LegacyTracker.prototype,
start() {
if (this._started) {
return;
}
this._log.trace("Starting tracker");
Services.obs.addObserver(this, "zen-workspace-added");
Services.obs.addObserver(this, "zen-workspace-removed");
Services.obs.addObserver(this, "zen-workspace-updated");
this._started = true;
},
stop() {
if (!this._started) {
return;
}
this._log.trace("Stopping tracker");
Services.obs.removeObserver(this, "zen-workspace-added");
Services.obs.removeObserver(this, "zen-workspace-removed");
Services.obs.removeObserver(this, "zen-workspace-updated");
this._started = false;
},
observe(subject, topic, data) {
switch (topic) {
case "zen-workspace-removed":
case "zen-workspace-updated":
case "zen-workspace-added":
let workspaceID = data;
this._log.trace(`Observed ${topic} for ${workspaceID}`);
this.addChangedID(workspaceID);
this.score += SCORE_INCREMENT_XLARGE;
break;
}
},
};
function ZenWorkspacesStore(name, engine) {
Store.call(this, name, engine);
}
ZenWorkspacesStore.prototype = {
__proto__: Store.prototype,
async getAllIDs() {
try {
let workspaces = await ZenWorkspacesStorage.getWorkspaces();
let ids = {};
for (let workspace of workspaces) {
ids[workspace.uuid] = true;
}
return ids;
} catch (error) {
this._log.error("Error fetching all workspace IDs", error);
throw error;
}
},
async changeItemID(oldID, newID) {
try {
let workspaces = await ZenWorkspacesStorage.getWorkspaces();
let workspace = workspaces.find(ws => ws.uuid === oldID);
if (workspace) {
workspace.uuid = newID;
await ZenWorkspacesStorage.saveWorkspace(workspace);
}
} catch (error) {
this._log.error(`Error changing workspace ID from ${oldID} to ${newID}`, error);
throw error;
}
},
async itemExists(id) {
try {
let workspaces = await ZenWorkspacesStorage.getWorkspaces();
return workspaces.some(ws => ws.uuid === id);
} catch (error) {
this._log.error(`Error checking if workspace exists with ID ${id}`, error);
throw error;
}
},
async createRecord(id, collection) {
try {
let workspaces = await ZenWorkspacesStorage.getWorkspaces();
let workspace = workspaces.find(ws => ws.uuid === id);
let record = new ZenWorkspaceRecord(collection, id);
if (workspace) {
record.name = workspace.name;
record.icon = workspace.icon;
record.default = workspace.default;
record.containerTabId = workspace.containerTabId;
record.deleted = false;
} else {
record.deleted = true;
}
return record;
} catch (error) {
this._log.error(`Error creating record for workspace ID ${id}`, error);
throw error;
}
},
async create(record) {
try {
// Data validation
this._validateRecord(record);
let workspace = {
uuid: record.id,
name: record.name,
icon: record.icon,
default: record.default,
containerTabId: record.containerTabId,
};
await ZenWorkspacesStorage.saveWorkspace(workspace);
} catch (error) {
this._log.error(`Error creating workspace with ID ${record.id}`, error);
throw error;
}
},
async update(record) {
try {
// Data validation
this._validateRecord(record);
await this.create(record);
} catch (error) {
this._log.error(`Error updating workspace with ID ${record.id}`, error);
throw error;
}
},
async remove(record) {
try {
await ZenWorkspacesStorage.removeWorkspace(record.id);
} catch (error) {
this._log.error(`Error removing workspace with ID ${record.id}`, error);
throw error;
}
},
async wipe() {
try {
let workspaces = await ZenWorkspacesStorage.getWorkspaces();
for (let workspace of workspaces) {
await ZenWorkspacesStorage.removeWorkspace(workspace.uuid);
}
} catch (error) {
this._log.error("Error wiping all workspaces", error);
throw error;
}
},
_validateRecord(record) {
if (!record.id || typeof record.id !== "string") {
throw new Error("Invalid workspace ID");
}
if (!record.name || typeof record.name !== "string") {
throw new Error(`Invalid workspace name for ID ${record.id}`);
}
// 'default' is a boolean; if undefined, default to false
if (typeof record.default !== "boolean") {
record.default = false;
}
// 'icon' and 'containerTabId' can be null, but should be validated if present
if (record.icon != null && typeof record.icon !== "string") {
throw new Error(`Invalid icon for workspace ID ${record.id}`);
}
if (record.containerTabId != null && typeof record.containerTabId !== "number") {
throw new Error(`Invalid containerTabId for workspace ID ${record.id}`);
}
},
};
function ZenWorkspacesEngine(service) {
SyncEngine.call(this, "Workspaces", service);
}
ZenWorkspacesEngine.prototype = {
__proto__: SyncEngine.prototype,
_storeObj: ZenWorkspacesStore,
_trackerObj: ZenWorkspacesTracker,
_recordObj: ZenWorkspaceRecord,
};
// Define ZenWorkspaceRecord
function ZenWorkspaceRecord(collection, id) {
CryptoWrapper.call(this, collection, id);
}
ZenWorkspaceRecord.prototype = {
__proto__: CryptoWrapper.prototype,
_logName: "Sync.Record.ZenWorkspace",
ZenWorkspaceRecord.prototype = Object.create(CryptoWrapper.prototype);
ZenWorkspaceRecord.prototype.constructor = ZenWorkspaceRecord;
};
ZenWorkspaceRecord.prototype._logName = "Sync.Record.ZenWorkspace";
Utils.deferGetSet(ZenWorkspaceRecord, "cleartext", [
"name",
"icon",
"default",
"containerTabId",
"position"
]);
// Define ZenWorkspacesStore
function ZenWorkspacesStore(name, engine) {
Store.call(this, name, engine);
}
ZenWorkspacesStore.prototype = Object.create(Store.prototype);
ZenWorkspacesStore.prototype.constructor = ZenWorkspacesStore;
/**
* Initializes the store by loading the current changeset.
*/
ZenWorkspacesStore.prototype.initialize = async function () {
await Store.prototype.initialize.call(this);
// Additional initialization if needed
};
/**
* Retrieves all workspace IDs from the storage.
* @returns {Object} An object mapping workspace UUIDs to true.
*/
ZenWorkspacesStore.prototype.getAllIDs = async function () {
try {
const workspaces = await ZenWorkspacesStorage.getWorkspaces();
const ids = {};
for (const workspace of workspaces) {
ids[workspace.uuid] = true;
}
return ids;
} catch (error) {
this._log.error("Error fetching all workspace IDs", error);
throw error;
}
};
/**
* Handles changing the ID of a workspace.
* @param {String} oldID - The old UUID.
* @param {String} newID - The new UUID.
*/
ZenWorkspacesStore.prototype.changeItemID = async function (oldID, newID) {
try {
const workspaces = await ZenWorkspacesStorage.getWorkspaces();
const workspace = workspaces.find(ws => ws.uuid === oldID);
if (workspace) {
workspace.uuid = newID;
await ZenWorkspacesStorage.saveWorkspace(workspace);
// Mark the new ID as changed for sync
await ZenWorkspacesStorage.markChanged(newID);
}
} catch (error) {
this._log.error(`Error changing workspace ID from ${oldID} to ${newID}`, error);
throw error;
}
};
/**
* Checks if a workspace exists.
* @param {String} id - The UUID of the workspace.
* @returns {Boolean} True if the workspace exists, false otherwise.
*/
ZenWorkspacesStore.prototype.itemExists = async function (id) {
try {
const workspaces = await ZenWorkspacesStorage.getWorkspaces();
return workspaces.some(ws => ws.uuid === id);
} catch (error) {
this._log.error(`Error checking if workspace exists with ID ${id}`, error);
throw error;
}
};
/**
* Creates a record for a workspace.
* @param {String} id - The UUID of the workspace.
* @param {String} collection - The collection name.
* @returns {ZenWorkspaceRecord} The workspace record.
*/
ZenWorkspacesStore.prototype.createRecord = async function (id, collection) {
try {
const workspaces = await ZenWorkspacesStorage.getWorkspaces();
const workspace = workspaces.find(ws => ws.uuid === id);
const record = new ZenWorkspaceRecord(collection, id);
if (workspace) {
record.name = workspace.name;
record.icon = workspace.icon;
record.default = workspace.default;
record.containerTabId = workspace.containerTabId;
record.position = workspace.position;
record.deleted = false;
} else {
record.deleted = true;
}
return record;
} catch (error) {
this._log.error(`Error creating record for workspace ID ${id}`, error);
throw error;
}
};
/**
* Creates a new workspace.
* @param {ZenWorkspaceRecord} record - The workspace record to create.
*/
ZenWorkspacesStore.prototype.create = async function (record) {
try {
this._validateRecord(record);
const workspace = {
uuid: record.id,
name: record.name,
icon: record.icon,
default: record.default,
containerTabId: record.containerTabId,
position: record.position
};
await ZenWorkspacesStorage.saveWorkspace(workspace);
} catch (error) {
this._log.error(`Error creating workspace with ID ${record.id}`, error);
throw error;
}
};
/**
* Updates an existing workspace.
* @param {ZenWorkspaceRecord} record - The workspace record to update.
*/
ZenWorkspacesStore.prototype.update = async function (record) {
try {
this._validateRecord(record);
await this.create(record); // Reuse create for update
} catch (error) {
this._log.error(`Error updating workspace with ID ${record.id}`, error);
throw error;
}
};
/**
* Removes a workspace.
* @param {ZenWorkspaceRecord} record - The workspace record to remove.
*/
ZenWorkspacesStore.prototype.remove = async function (record) {
try {
await ZenWorkspacesStorage.removeWorkspace(record.id);
// Changes are already marked within ZenWorkspacesStorage.removeWorkspace
} catch (error) {
this._log.error(`Error removing workspace with ID ${record.id}`, error);
throw error;
}
};
/**
* Wipes all workspaces from the storage.
*/
ZenWorkspacesStore.prototype.wipe = async function () {
try {
await ZenWorkspacesStorage.wipeAllWorkspaces();
} catch (error) {
this._log.error("Error wiping all workspaces", error);
throw error;
}
};
/**
* Validates a workspace record.
* @param {ZenWorkspaceRecord} record - The workspace record to validate.
*/
ZenWorkspacesStore.prototype._validateRecord = function (record) {
if (!record.id || typeof record.id !== "string") {
throw new Error("Invalid workspace ID");
}
if (!record.name || typeof record.name !== "string") {
throw new Error(`Invalid workspace name for ID ${record.id}`);
}
if (typeof record.default !== "boolean") {
record.default = false;
}
if (record.icon != null && typeof record.icon !== "string") {
throw new Error(`Invalid icon for workspace ID ${record.id}`);
}
if (record.containerTabId != null && typeof record.containerTabId !== "number") {
throw new Error(`Invalid containerTabId for workspace ID ${record.id}`);
}
if(record.position != null && typeof record.position !== "number") {
throw new Error(`Invalid position for workspace ID ${record.id}`);
}
};
/**
* Retrieves changed workspace IDs since the last sync.
* @returns {Object} An object mapping workspace UUIDs to their change timestamps.
*/
ZenWorkspacesStore.prototype.getChangedIDs = async function () {
try {
return await ZenWorkspacesStorage.getChangedIDs();
} catch (error) {
this._log.error("Error retrieving changed IDs from storage", error);
throw error;
}
};
/**
* Clears all recorded changes after a successful sync.
*/
ZenWorkspacesStore.prototype.clearChangedIDs = async function () {
try {
await ZenWorkspacesStorage.clearChangedIDs();
} catch (error) {
this._log.error("Error clearing changed IDs in storage", error);
throw error;
}
};
/**
* Marks a workspace as changed.
* @param {String} uuid - The UUID of the workspace that changed.
*/
ZenWorkspacesStore.prototype.markChanged = async function (uuid) {
try {
await ZenWorkspacesStorage.markChanged(uuid);
} catch (error) {
this._log.error(`Error marking workspace ${uuid} as changed`, error);
throw error;
}
};
/**
* Finalizes the store by ensuring all pending operations are completed.
*/
ZenWorkspacesStore.prototype.finalize = async function () {
await Store.prototype.finalize.call(this);
};
// Define ZenWorkspacesTracker
function ZenWorkspacesTracker(name, engine) {
Tracker.call(this, name, engine);
this._ignoreAll = false;
// Observe profile-before-change to stop the tracker gracefully
Services.obs.addObserver(this.asyncObserver, "profile-before-change");
}
ZenWorkspacesTracker.prototype = Object.create(Tracker.prototype);
ZenWorkspacesTracker.prototype.constructor = ZenWorkspacesTracker;
/**
* Retrieves changed workspace IDs by delegating to the store.
* @returns {Object} An object mapping workspace UUIDs to their change timestamps.
*/
ZenWorkspacesTracker.prototype.getChangedIDs = async function () {
try {
return await this.engine._store.getChangedIDs();
} catch (error) {
this._log.error("Error retrieving changed IDs from store", error);
throw error;
}
};
/**
* Clears all recorded changes after a successful sync.
*/
ZenWorkspacesTracker.prototype.clearChangedIDs = async function () {
try {
await this.engine._store.clearChangedIDs();
} catch (error) {
this._log.error("Error clearing changed IDs in store", error);
throw error;
}
};
/**
* Called when the tracker starts. Registers observers to listen for workspace changes.
*/
ZenWorkspacesTracker.prototype.onStart = function () {
if (this._started) {
return;
}
this._log.trace("Starting tracker");
// Register observers for workspace changes
Services.obs.addObserver(this.asyncObserver, "zen-workspace-added");
Services.obs.addObserver(this.asyncObserver, "zen-workspace-removed");
Services.obs.addObserver(this.asyncObserver, "zen-workspace-updated");
this._started = true;
};
/**
* Called when the tracker stops. Unregisters observers.
*/
ZenWorkspacesTracker.prototype.onStop = function () {
if (!this._started) {
return;
}
this._log.trace("Stopping tracker");
// Unregister observers for workspace changes
Services.obs.removeObserver(this.asyncObserver, "zen-workspace-added");
Services.obs.removeObserver(this.asyncObserver, "zen-workspace-removed");
Services.obs.removeObserver(this.asyncObserver, "zen-workspace-updated");
this._started = false;
};
/**
* Handles observed events and marks workspaces as changed accordingly.
* @param {nsISupports} subject - The subject of the notification.
* @param {String} topic - The topic of the notification.
* @param {String} data - Additional data (workspace UUID).
*/
ZenWorkspacesTracker.prototype.observe = async function (subject, topic, data) {
if (this.ignoreAll) {
return;
}
try {
switch (topic) {
case "profile-before-change":
await this.stop();
break;
case "zen-workspace-removed":
case "zen-workspace-updated":
case "zen-workspace-added":
const workspaceID = data;
this._log.trace(`Observed ${topic} for ${workspaceID}`);
// Inform the store about the change
await this.engine._store.markChanged(workspaceID);
// Bump the score to indicate a high-priority sync
this.score += SCORE_INCREMENT_XLARGE;
break;
}
} catch (error) {
this._log.error(`Error handling ${topic} in observe method`, error);
}
};
/**
* Finalizes the tracker by ensuring all pending operations are completed.
*/
ZenWorkspacesTracker.prototype.finalize = async function () {
await Tracker.prototype.finalize.call(this);
};
// Define ZenWorkspacesEngine
function ZenWorkspacesEngine(service) {
SyncEngine.call(this, "Workspaces", service);
}
ZenWorkspacesEngine.prototype = Object.create(SyncEngine.prototype);
ZenWorkspacesEngine.prototype.constructor = ZenWorkspacesEngine;
ZenWorkspacesEngine.prototype._storeObj = ZenWorkspacesStore;
ZenWorkspacesEngine.prototype._trackerObj = ZenWorkspacesTracker;
ZenWorkspacesEngine.prototype._recordObj = ZenWorkspaceRecord;
ZenWorkspacesEngine.prototype.version = 1;
ZenWorkspacesEngine.prototype.syncPriority = 10;
ZenWorkspacesEngine.prototype.allowSkippedRecord = false;
Object.setPrototypeOf(ZenWorkspacesEngine.prototype, SyncEngine.prototype);