mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-20 21:49:32 +01:00
251 lines
6.8 KiB
JavaScript
251 lines
6.8 KiB
JavaScript
/**
|
|
* GENERAL PROCESS:
|
|
* make a get request to server with current lastSyncedTimestamp
|
|
* parse the response. the response should contain everything that user has on the server
|
|
* decrypt the response
|
|
* merge everything into the database and look for conflicts
|
|
* send the conflicts (if any) to the end-user for resolution
|
|
* once the conflicts have been resolved, send the updated data back to the server
|
|
*/
|
|
|
|
/**
|
|
* MERGING:
|
|
* Locally, get everything that was editted/added after the lastSyncedTimestamp
|
|
* Run forEach loop on the server response.
|
|
* Add items that do not exist in the local collections
|
|
* Remove items (without asking) that need to be removed
|
|
* Update items that were editted before the lastSyncedTimestamp
|
|
* Try to merge items that were edited after the lastSyncedTimestamp
|
|
* Items in which the content has changed, send them for conflict resolution
|
|
* Otherwise, keep the most recently updated copy.
|
|
*/
|
|
|
|
/**
|
|
* CONFLICTS:
|
|
* Syncing should pause until all the conflicts have been resolved
|
|
* And then it should continue.
|
|
*/
|
|
import Database from "./index";
|
|
import { HOST, HEADERS } from "../utils/constants";
|
|
var tfun = require("transfun/transfun.js").tfun;
|
|
if (!tfun) {
|
|
tfun = global.tfun;
|
|
}
|
|
|
|
export default class Sync {
|
|
/**
|
|
*
|
|
* @param {Database} db
|
|
*/
|
|
constructor(db) {
|
|
this.db = db;
|
|
}
|
|
|
|
async _fetch(lastSyncedTimestamp) {
|
|
let token = await this.db.user.token();
|
|
if (!token) throw new Error("You are not logged in");
|
|
let response = await fetch(`${HOST}sync?lst=${lastSyncedTimestamp}`, {
|
|
headers: { ...HEADERS, Authorization: `Bearer ${token}` },
|
|
});
|
|
//TODO decrypt the response.
|
|
return await response.json();
|
|
}
|
|
|
|
async throwOnConflicts() {
|
|
let hasConflicts = await this.db.context.read("hasConflicts");
|
|
if (hasConflicts) {
|
|
const mergeConflictError = new Error(
|
|
"Merge conflicts detected. Please resolve all conflicts to continue syncing."
|
|
);
|
|
mergeConflictError.code = "MERGE_CONFLICT";
|
|
throw mergeConflictError;
|
|
}
|
|
}
|
|
|
|
async start() {
|
|
let user = await this.db.user.get();
|
|
if (!user) throw new Error("You need to login to sync.");
|
|
|
|
await this.db.conflicts.recalculate();
|
|
await this.throwOnConflicts();
|
|
|
|
let lastSyncedTimestamp = user.lastSynced || 0;
|
|
let serverResponse = await this._fetch(lastSyncedTimestamp);
|
|
|
|
// we prepare local data before merging so we always have correct data
|
|
const prepare = new Prepare(this.db, user);
|
|
const data = await prepare.get(lastSyncedTimestamp);
|
|
|
|
// merge the server response
|
|
const merger = new Merger(this.db, lastSyncedTimestamp);
|
|
const mergeResult = await merger.merge(serverResponse);
|
|
await this.throwOnConflicts();
|
|
// send the data back to server
|
|
await this._send(data);
|
|
|
|
// update our lastSynced time
|
|
if (mergeResult || !areAllEmpty(data))
|
|
await this.db.user.set({ lastSynced: data.lastSynced });
|
|
}
|
|
|
|
async _send(data) {
|
|
//TODO encrypt the payload
|
|
let token = await this.db.user.token();
|
|
if (!token) return;
|
|
let response = await fetch(`${HOST}sync`, {
|
|
method: "POST",
|
|
headers: { ...HEADERS, Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify(data),
|
|
});
|
|
return response.ok;
|
|
}
|
|
}
|
|
|
|
class Merger {
|
|
/**
|
|
*
|
|
* @param {Database} db
|
|
*/
|
|
constructor(db, lastSynced) {
|
|
this._db = db;
|
|
this._lastSynced = lastSynced;
|
|
}
|
|
|
|
async _mergeItem(remoteItem, get, add) {
|
|
let localItem = await get(remoteItem.id);
|
|
remoteItem = { ...JSON.parse(remoteItem.data), remote: true };
|
|
if (!localItem || remoteItem.dateEdited > localItem.dateEdited) {
|
|
await add(remoteItem);
|
|
}
|
|
}
|
|
|
|
async _mergeArray(array, get, set) {
|
|
return Promise.all(
|
|
array.map(async (item) => await this._mergeItem(item, get, set))
|
|
);
|
|
}
|
|
|
|
async _mergeItemWithConflicts(remoteItem, get, add, resolve) {
|
|
let localItem = await get(remoteItem.id);
|
|
if (!localItem) {
|
|
await add({ ...JSON.parse(remoteItem.data), remote: true });
|
|
} else if (localItem.dateEdited > this._lastSynced) {
|
|
// we have a conflict
|
|
await resolve(localItem, JSON.parse(remoteItem.data));
|
|
}
|
|
}
|
|
|
|
async _mergeArrayWithConflicts(array, get, set, resolve) {
|
|
return Promise.all(
|
|
array.map(
|
|
async (item) =>
|
|
await this._mergeItemWithConflicts(item, get, set, resolve)
|
|
)
|
|
);
|
|
}
|
|
|
|
async merge(serverResponse) {
|
|
const {
|
|
notes,
|
|
synced,
|
|
notebooks,
|
|
delta,
|
|
text,
|
|
tags,
|
|
colors,
|
|
trash,
|
|
} = serverResponse;
|
|
|
|
if (synced || areAllEmpty(serverResponse)) return false;
|
|
|
|
await this._mergeArray(
|
|
notes,
|
|
(id) => this._db.notes.note(id),
|
|
(item) => this._db.notes.add(item)
|
|
);
|
|
await this._mergeArray(
|
|
notebooks,
|
|
(id) => this._db.notebooks.notebook(id),
|
|
(item) => this._db.notebooks.add(item)
|
|
);
|
|
|
|
await this._mergeArrayWithConflicts(
|
|
delta,
|
|
(id) => this._db.delta.raw(id),
|
|
(item) => this._db.delta.add(item),
|
|
async (local, remote) => {
|
|
await this._db.delta.add({ ...local, conflicted: remote });
|
|
await this._db.notes.add({ id: local.noteId, conflicted: true });
|
|
await this._db.context.write("hasConflicts", true);
|
|
}
|
|
);
|
|
|
|
await this._mergeArray(
|
|
text,
|
|
(id) => this._db.text.raw(id),
|
|
(item) => this._db.text.add(item)
|
|
);
|
|
|
|
await this._mergeArray(
|
|
tags,
|
|
(id) => this._db.tags.tag(id),
|
|
(item) => this._db.tags.merge(item)
|
|
);
|
|
|
|
await this._mergeArray(
|
|
colors,
|
|
(id) => this._db.colors.tag(id),
|
|
(item) => this._db.colors.merge(item)
|
|
);
|
|
|
|
await this._mergeArray(
|
|
trash,
|
|
() => undefined,
|
|
(item) => this._db.trash.add(item)
|
|
);
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class Prepare {
|
|
/**
|
|
*
|
|
* @param {Database} db
|
|
* @param {Object} user
|
|
* @param {Number} lastSyncedTimestamp
|
|
*/
|
|
constructor(db, user) {
|
|
this._db = db;
|
|
this._user = user;
|
|
}
|
|
|
|
async get(lastSyncedTimestamp) {
|
|
this._lastSyncedTimestamp = lastSyncedTimestamp;
|
|
return {
|
|
notes: this._prepareForServer(this._db.notes.raw),
|
|
notebooks: this._prepareForServer(this._db.notebooks.raw),
|
|
delta: this._prepareForServer(await this._db.delta.all()),
|
|
text: this._prepareForServer(await this._db.text.all()),
|
|
tags: this._prepareForServer(this._db.tags.raw),
|
|
colors: this._prepareForServer(this._db.colors.raw),
|
|
trash: this._prepareForServer(this._db.trash.raw),
|
|
lastSynced: Date.now(),
|
|
};
|
|
}
|
|
|
|
_prepareForServer(array) {
|
|
return tfun
|
|
.filter((item) => item.dateEdited > this._lastSyncedTimestamp)
|
|
.map((item) => ({
|
|
id: item.id,
|
|
data: JSON.stringify(item),
|
|
}))(array);
|
|
}
|
|
}
|
|
|
|
function areAllEmpty(obj) {
|
|
const arrays = Object.values(obj).filter((v) => v.length !== undefined);
|
|
return arrays.every((array) => array.length === 0);
|
|
}
|