mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-03 08:35:29 +02:00
327 lines
9.0 KiB
JavaScript
327 lines
9.0 KiB
JavaScript
const BaseItem = require('lib/models/BaseItem.js');
|
|
const { time } = require('lib/time-utils.js');
|
|
|
|
// NOTE: when synchronising with the file system the time resolution is the second (unlike milliseconds for OneDrive for instance).
|
|
// What it means is that if, for example, client 1 changes a note at time t, and client 2 changes the same note within the same second,
|
|
// both clients will not know about each others updates during the next sync. They will simply both sync their note and whoever
|
|
// comes last will overwrite (on the remote storage) the note of the other client. Both client will then have a different note at
|
|
// that point and that will only be resolved if one of them changes the note and sync (if they don't change it, it will never get resolved).
|
|
//
|
|
// This is compound with the fact that we can't have a reliable delta API on the file system so we need to check all the timestamps
|
|
// every time and rely on this exclusively to know about changes.
|
|
//
|
|
// This explains occasional failures of the fuzzing program (it finds that the clients end up with two different notes after sync). To
|
|
// check that it is indeed the problem, check log-database.txt of both clients, search for the note ID, and most likely both notes
|
|
// will have been modified at the same exact second at some point. If not, it's another bug that needs to be investigated.
|
|
|
|
class FileApiDriverLocal {
|
|
|
|
fsErrorToJsError_(error, path = null) {
|
|
let msg = error.toString();
|
|
if (path !== null) msg += '. Path: ' + path;
|
|
let output = new Error(msg);
|
|
if (error.code) output.code = error.code;
|
|
return output;
|
|
}
|
|
|
|
fsDriver() {
|
|
if (!FileApiDriverLocal.fsDriver_) throw new Error('FileApiDriverLocal.fsDriver_ not set!');
|
|
return FileApiDriverLocal.fsDriver_;
|
|
}
|
|
|
|
async stat(path) {
|
|
try {
|
|
const s = await this.fsDriver().stat(path);
|
|
if (!s) return null;
|
|
return this.metadataFromStat_(s);
|
|
} catch (error) {
|
|
throw this.fsErrorToJsError_(error);
|
|
}
|
|
}
|
|
|
|
metadataFromStat_(stat) {
|
|
return {
|
|
path: stat.path,
|
|
created_time: stat.birthtime.getTime(),
|
|
updated_time: stat.mtime.getTime(),
|
|
isDir: stat.isDirectory(),
|
|
};
|
|
}
|
|
|
|
metadataFromStats_(stats) {
|
|
let output = [];
|
|
for (let i = 0; i < stats.length; i++) {
|
|
const mdStat = this.metadataFromStat_(stats[i]);
|
|
output.push(mdStat);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
async setTimestamp(path, timestampMs) {
|
|
try {
|
|
await this.fsDriver().setTimestamp(path, new Date(timestampMs));
|
|
} catch (error) {
|
|
throw this.fsErrorToJsError_(error);
|
|
}
|
|
}
|
|
|
|
contextFromOptions_(options) {
|
|
let output = {
|
|
timestamp: 0,
|
|
filesAtTimestamp: [],
|
|
statsCache: null,
|
|
};
|
|
|
|
if (!options || !options.context) return output;
|
|
const d = new Date(options.context.timestamp);
|
|
|
|
output.timestamp = isNaN(d.getTime()) ? 0 : options.context.timestamp;
|
|
output.filesAtTimestamp = Array.isArray(options.context.filesAtTimestamp) ? options.context.filesAtTimestamp.slice() : [];
|
|
output.statsCache = options.context && options.context.statsCache ? options.context.statsCache : null;
|
|
|
|
return output;
|
|
}
|
|
|
|
async delta(path, options) {
|
|
const outputLimit = 1000;
|
|
const itemIds = await options.allItemIdsHandler();
|
|
|
|
try {
|
|
const context = this.contextFromOptions_(options);
|
|
|
|
let newContext = {
|
|
timestamp: context.timestamp,
|
|
filesAtTimestamp: context.filesAtTimestamp.slice(),
|
|
statsCache: context.statsCache,
|
|
};
|
|
|
|
// Stats are cached until all items have been processed (until hasMore is false)
|
|
if (newContext.statsCache === null) {
|
|
const stats = await this.fsDriver().readDirStats(path);
|
|
newContext.statsCache = this.metadataFromStats_(stats);
|
|
newContext.statsCache.sort(function(a, b) {
|
|
return a.updated_time - b.updated_time;
|
|
});
|
|
}
|
|
|
|
let output = [];
|
|
|
|
// Find out which files have been changed since the last time. Note that we keep
|
|
// both the timestamp of the most recent change, *and* the items that exactly match
|
|
// this timestamp. This to handle cases where an item is modified while this delta
|
|
// function is running. For example:
|
|
// t0: Item 1 is changed
|
|
// t0: Sync items - run delta function
|
|
// t0: While delta() is running, modify Item 2
|
|
// Since item 2 was modified within the same millisecond, it would be skipped in the
|
|
// next sync if we relied exclusively on a timestamp.
|
|
for (let i = 0; i < newContext.statsCache.length; i++) {
|
|
const stat = newContext.statsCache[i];
|
|
|
|
if (stat.isDir) continue;
|
|
|
|
if (stat.updated_time < context.timestamp) continue;
|
|
|
|
// Special case for items that exactly match the timestamp
|
|
if (stat.updated_time === context.timestamp) {
|
|
if (context.filesAtTimestamp.indexOf(stat.path) >= 0) continue;
|
|
}
|
|
|
|
if (stat.updated_time > newContext.timestamp) {
|
|
newContext.timestamp = stat.updated_time;
|
|
newContext.filesAtTimestamp = [];
|
|
}
|
|
|
|
newContext.filesAtTimestamp.push(stat.path);
|
|
output.push(stat);
|
|
|
|
if (output.length >= outputLimit) break;
|
|
}
|
|
|
|
if (!Array.isArray(itemIds)) throw new Error('Delta API not supported - local IDs must be provided');
|
|
|
|
let deletedItems = [];
|
|
for (let i = 0; i < itemIds.length; i++) {
|
|
if (output.length + deletedItems.length >= outputLimit) break;
|
|
|
|
const itemId = itemIds[i];
|
|
let found = false;
|
|
for (let j = 0; j < newContext.statsCache.length; j++) {
|
|
const item = newContext.statsCache[j];
|
|
if (BaseItem.pathToId(item.path) == itemId) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
deletedItems.push({
|
|
path: BaseItem.systemPath(itemId),
|
|
isDeleted: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
output = output.concat(deletedItems);
|
|
|
|
const hasMore = output.length >= outputLimit;
|
|
if (!hasMore) newContext.statsCache = null;
|
|
|
|
return {
|
|
hasMore: hasMore,
|
|
context: newContext,
|
|
items: output,
|
|
};
|
|
} catch(error) {
|
|
throw this.fsErrorToJsError_(error, path);
|
|
}
|
|
}
|
|
|
|
async list(path, options) {
|
|
try {
|
|
const stats = await this.fsDriver().readDirStats(path);
|
|
const output = this.metadataFromStats_(stats);
|
|
|
|
return {
|
|
items: output,
|
|
hasMore: false,
|
|
context: null,
|
|
};
|
|
} catch(error) {
|
|
throw this.fsErrorToJsError_(error, path);
|
|
}
|
|
}
|
|
|
|
async get(path, options) {
|
|
let output = null;
|
|
|
|
try {
|
|
if (options.target === 'file') {
|
|
//output = await fs.copy(path, options.path, { overwrite: true });
|
|
output = await this.fsDriver().copy(path, options.path);
|
|
} else {
|
|
//output = await fs.readFile(path, options.encoding);
|
|
output = await this.fsDriver().readFile(path, options.encoding);
|
|
}
|
|
} catch (error) {
|
|
if (error.code == 'ENOENT') return null;
|
|
throw this.fsErrorToJsError_(error, path);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
async mkdir(path) {
|
|
if (await this.fsDriver().exists(path)) return;
|
|
|
|
try {
|
|
await this.fsDriver().mkdir(path);
|
|
} catch (error) {
|
|
throw this.fsErrorToJsError_(error, path);
|
|
}
|
|
|
|
// return new Promise((resolve, reject) => {
|
|
// fs.exists(path, (exists) => {
|
|
// if (exists) {
|
|
// resolve();
|
|
// return;
|
|
// }
|
|
|
|
// fs.mkdirp(path, (error) => {
|
|
// if (error) {
|
|
// reject(this.fsErrorToJsError_(error));
|
|
// } else {
|
|
// resolve();
|
|
// }
|
|
// });
|
|
// });
|
|
// });
|
|
}
|
|
|
|
async put(path, content, options = null) {
|
|
if (!options) options = {};
|
|
|
|
try {
|
|
if (options.source === 'file') {
|
|
await this.fsDriver().copy(options.path, path);
|
|
return;
|
|
}
|
|
|
|
await this.fsDriver().writeFile(path, content, 'utf8');
|
|
} catch (error) {
|
|
throw this.fsErrorToJsError_(error, path);
|
|
}
|
|
|
|
// if (!options) options = {};
|
|
|
|
// if (options.source === 'file') content = await fs.readFile(options.path);
|
|
|
|
// return new Promise((resolve, reject) => {
|
|
// fs.writeFile(path, content, function(error) {
|
|
// if (error) {
|
|
// reject(this.fsErrorToJsError_(error));
|
|
// } else {
|
|
// resolve();
|
|
// }
|
|
// });
|
|
// });
|
|
}
|
|
|
|
async delete(path) {
|
|
try {
|
|
await this.fsDriver().unlink(path);
|
|
} catch (error) {
|
|
throw this.fsErrorToJsError_(error, path);
|
|
}
|
|
|
|
// return new Promise((resolve, reject) => {
|
|
// fs.unlink(path, function(error) {
|
|
// if (error) {
|
|
// if (error && error.code == 'ENOENT') {
|
|
// // File doesn't exist - it's fine
|
|
// resolve();
|
|
// } else {
|
|
// reject(this.fsErrorToJsError_(error));
|
|
// }
|
|
// } else {
|
|
// resolve();
|
|
// }
|
|
// });
|
|
// });
|
|
}
|
|
|
|
async move(oldPath, newPath) {
|
|
try {
|
|
await this.fsDriver().move(oldPath, newPath);
|
|
} catch (error) {
|
|
throw this.fsErrorToJsError_(error, path);
|
|
}
|
|
|
|
// let lastError = null;
|
|
|
|
// for (let i = 0; i < 5; i++) {
|
|
// try {
|
|
// let output = await fs.move(oldPath, newPath, { overwrite: true });
|
|
// return output;
|
|
// } catch (error) {
|
|
// lastError = error;
|
|
// // Normally cannot happen with the `overwrite` flag but sometime it still does.
|
|
// // In this case, retry.
|
|
// if (error.code == 'EEXIST') {
|
|
// await time.sleep(1);
|
|
// continue;
|
|
// }
|
|
// throw this.fsErrorToJsError_(error);
|
|
// }
|
|
// }
|
|
|
|
// throw lastError;
|
|
}
|
|
|
|
format() {
|
|
throw new Error('Not supported');
|
|
}
|
|
|
|
}
|
|
|
|
module.exports = { FileApiDriverLocal }; |