1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Got Nextcloud sync to work in Electron

This commit is contained in:
Laurent Cozic 2018-01-25 19:01:14 +00:00
parent 6ade09c228
commit 1cc27f2509
13 changed files with 234 additions and 49 deletions

View File

@ -72,18 +72,36 @@ process.stdout.on('error', function( err ) {
// async function main() { // async function main() {
// const WebDavApi = require('lib/WebDavApi'); // const WebDavApi = require('lib/WebDavApi');
// const api = new WebDavApi('http://nextcloud.local/remote.php/dav/files/admin/Joplin', { username: 'admin', password: '123456' }); // const api = new WebDavApi('http://nextcloud.local/remote.php/dav/files/admin/Joplin', { username: 'admin', password: '1234567' });
// const { FileApiDriverWebDav } = new require('lib/file-api-driver-webdav'); // const { FileApiDriverWebDav } = new require('lib/file-api-driver-webdav');
// const driver = new FileApiDriverWebDav(api); // const driver = new FileApiDriverWebDav(api);
// //await driver.stat('testing.txt'); // const stat = await driver.stat('');
// const stat = await driver.stat('testing.txt');
// console.info(stat); // console.info(stat);
// //await api.execPropFind('');
// // const stat = await driver.stat('testing.txt'); // // const stat = await driver.stat('testing.txt');
// // console.info(stat); // // console.info(stat);
// // const content = await driver.get('testing.txta');
// // console.info(content);
// // const content = await driver.get('testing.txta', { target: 'file', path: '/var/www/joplin/CliClient/testing-file.txt' });
// // console.info(content);
// // const content = await driver.mkdir('newdir5');
// // console.info(content);
// //await driver.put('myfile4.md', 'this is my content');
// // await driver.put('testimg.jpg', null, { source: 'file', path: '/mnt/d/test.jpg' });
// // await driver.delete('myfile4.md');
// // const deltaResult = await driver.delta('', {
// // allItemIdsHandler: () => { return []; }
// // });
// // console.info(deltaResult);
// } // }
// main().catch((error) => { console.error(error); }); // main().catch((error) => { console.error(error); });

View File

@ -5334,6 +5334,22 @@
"integrity": "sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=", "integrity": "sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=",
"dev": true "dev": true
}, },
"xml2js": {
"version": "0.4.19",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz",
"integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==",
"requires": {
"sax": "1.2.4",
"xmlbuilder": "9.0.4"
},
"dependencies": {
"xmlbuilder": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.4.tgz",
"integrity": "sha1-UZy0ymhtAFqEINNJbz8MruzKWA8="
}
}
},
"xmlbuilder": { "xmlbuilder": {
"version": "8.2.2", "version": "8.2.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz",

View File

@ -84,6 +84,7 @@
"string-padding": "^1.0.2", "string-padding": "^1.0.2",
"string-to-stream": "^1.1.0", "string-to-stream": "^1.1.0",
"tcp-port-used": "^0.1.2", "tcp-port-used": "^0.1.2",
"uuid": "^3.1.0" "uuid": "^3.1.0",
"xml2js": "^0.4.19"
} }
} }

View File

@ -26,12 +26,14 @@ const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js'); const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js');
const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js'); const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js');
const SyncTargetOneDriveDev = require('lib/SyncTargetOneDriveDev.js'); const SyncTargetOneDriveDev = require('lib/SyncTargetOneDriveDev.js');
const SyncTargetNextcloud = require('lib/SyncTargetNextcloud.js');
const EncryptionService = require('lib/services/EncryptionService'); const EncryptionService = require('lib/services/EncryptionService');
const DecryptionWorker = require('lib/services/DecryptionWorker'); const DecryptionWorker = require('lib/services/DecryptionWorker');
SyncTargetRegistry.addClass(SyncTargetFilesystem); SyncTargetRegistry.addClass(SyncTargetFilesystem);
SyncTargetRegistry.addClass(SyncTargetOneDrive); SyncTargetRegistry.addClass(SyncTargetOneDrive);
SyncTargetRegistry.addClass(SyncTargetOneDriveDev); SyncTargetRegistry.addClass(SyncTargetOneDriveDev);
SyncTargetRegistry.addClass(SyncTargetNextcloud);
class BaseApplication { class BaseApplication {

View File

@ -30,6 +30,10 @@ class BaseSyncTarget {
return false; return false;
} }
authRouteName() {
return null;
}
static id() { static id() {
throw new Error('id() not implemented'); throw new Error('id() not implemented');
} }

View File

@ -0,0 +1,65 @@
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
const { _ } = require('lib/locale.js');
const Setting = require('lib/models/Setting.js');
const { FileApi } = require('lib/file-api.js');
const { Synchronizer } = require('lib/synchronizer.js');
const WebDavApi = require('lib/WebDavApi');
const { FileApiDriverWebDav } = new require('lib/file-api-driver-webdav');
class SyncTargetNextcloud extends BaseSyncTarget {
static id() {
return 5;
}
constructor(db, options = null) {
super(db, options);
// this.authenticated_ = false;
}
static targetName() {
return 'nextcloud';
}
static label() {
return _('Nextcloud');
}
isAuthenticated() {
return true;
//return this.authenticated_;
}
async initFileApi() {
const options = {
baseUrl: () => Setting.value('sync.5.path'),
username: () => Setting.value('sync.5.username'),
password: () => Setting.value('sync.5.password'),
};
//const api = new WebDavApi('http://nextcloud.local/remote.php/dav/files/admin/Joplin', { username: 'admin', password: '123456' });
const api = new WebDavApi(options);
const driver = new FileApiDriverWebDav(api);
// this.authenticated_ = true;
// try {
// await driver.stat('');
// } catch (error) {
// console.info(error);
// this.authenticated_ = false;
// if (error.code !== 401) throw error;
// }
const fileApi = new FileApi('', driver);
fileApi.setSyncTargetId(SyncTargetNextcloud.id());
fileApi.setLogger(this.logger());
return fileApi;
}
async initSynchronizer() {
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
}
}
module.exports = SyncTargetNextcloud;

View File

@ -9,15 +9,15 @@ const { FileApiDriverOneDrive } = require('lib/file-api-driver-onedrive.js');
class SyncTargetOneDrive extends BaseSyncTarget { class SyncTargetOneDrive extends BaseSyncTarget {
static id() {
return 3;
}
constructor(db, options = null) { constructor(db, options = null) {
super(db, options); super(db, options);
this.api_ = null; this.api_ = null;
} }
static id() {
return 3;
}
static targetName() { static targetName() {
return 'onedrive'; return 'onedrive';
} }
@ -38,6 +38,10 @@ class SyncTargetOneDrive extends BaseSyncTarget {
return parameters().oneDrive; return parameters().oneDrive;
} }
authRouteName() {
return 'OneDriveLogin';
}
api() { api() {
if (this.api_) return this.api_; if (this.api_) return this.api_;

View File

@ -2,6 +2,8 @@ const { Logger } = require('lib/logger.js');
const { shim } = require('lib/shim.js'); const { shim } = require('lib/shim.js');
const parseXmlString = require('xml2js').parseString; const parseXmlString = require('xml2js').parseString;
const JoplinError = require('lib/JoplinError'); const JoplinError = require('lib/JoplinError');
const urlParser = require("url");
const { rtrimSlashes, ltrimSlashes } = require('lib/path-utils.js');
// Note that the d: namespace (the DAV namespace) is specific to Nextcloud. The RFC for example uses "D:" however // Note that the d: namespace (the DAV namespace) is specific to Nextcloud. The RFC for example uses "D:" however
// we make all the tags and attributes lowercase so we handle both the Nextcloud style and RFC. Hopefully other // we make all the tags and attributes lowercase so we handle both the Nextcloud style and RFC. Hopefully other
@ -11,10 +13,11 @@ const JoplinError = require('lib/JoplinError');
class WebDavApi { class WebDavApi {
constructor(baseUrl, options) { constructor(options) {
this.logger_ = new Logger(); this.logger_ = new Logger();
this.baseUrl_ = baseUrl.replace(/\/+$/, ""); // Remove last trailing slashes
this.options_ = options; this.options_ = options;
} }
setLogger(l) { setLogger(l) {
@ -26,12 +29,17 @@ class WebDavApi {
} }
authToken() { authToken() {
if (!this.options_.username || !this.options_.password) return null; if (!this.options_.username() || !this.options_.password()) return null;
return (new Buffer(this.options_.username + ':' + this.options_.password)).toString('base64'); return (new Buffer(this.options_.username() + ':' + this.options_.password())).toString('base64');
} }
baseUrl() { baseUrl() {
return this.baseUrl_; return this.options_.baseUrl();
}
relativeBaseUrl() {
const url = urlParser.parse(this.baseUrl(), true);
return url.path;
} }
async xmlToJson(xml) { async xmlToJson(xml) {
@ -62,9 +70,14 @@ class WebDavApi {
valueFromJson(json, keys, type) { valueFromJson(json, keys, type) {
let output = json; let output = json;
for (let i = 0; i < keys.length; i++) { for (let i = 0; i < keys.length; i++) {
const key = keys[i]; const key = keys[i];
if (!output || !output[key]) return null;
// console.info(key, typeof key, typeof output, typeof output === 'object' && (key in output), Array.isArray(output));
if (typeof key === 'number' && !Array.isArray(output)) return null;
if (typeof key === 'string' && (typeof output !== 'object' || !(key in output))) return null;
output = output[key]; output = output[key];
} }
@ -78,6 +91,10 @@ class WebDavApi {
return null; return null;
} }
if (type === 'array') {
return Array.isArray(output) ? output : null;
}
return null; return null;
} }
@ -89,7 +106,11 @@ class WebDavApi {
return this.valueFromJson(json, keys, 'object'); return this.valueFromJson(json, keys, 'object');
} }
async execPropFind(path, fields = null) { arrayFromJson(json, keys) {
return this.valueFromJson(json, keys, 'array');
}
async execPropFind(path, depth, fields = null) {
if (fields === null) fields = ['d:getlastmodified']; if (fields === null) fields = ['d:getlastmodified'];
let fieldsXml = ''; let fieldsXml = '';
@ -111,7 +132,7 @@ class WebDavApi {
</d:prop> </d:prop>
</d:propfind>`; </d:propfind>`;
return this.exec('PROPFIND', path, body); return this.exec('PROPFIND', path, body, { 'Depth': depth });
} }
// curl -u admin:123456 'http://nextcloud.local/remote.php/dav/files/admin/' -X PROPFIND --data '<?xml version="1.0" encoding="UTF-8"?> // curl -u admin:123456 'http://nextcloud.local/remote.php/dav/files/admin/' -X PROPFIND --data '<?xml version="1.0" encoding="UTF-8"?>
@ -151,33 +172,45 @@ class WebDavApi {
const responseText = await response.text(); const responseText = await response.text();
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
// JSON. That way the error message will still show there's a problem but without filling up the log or screen.
const shortResponseText = () => {
return (responseText + '').substr(0, 1024);
}
let responseJson_ = null; let responseJson_ = null;
const loadResponseJson = async () => { const loadResponseJson = async () => {
if (!responseText) return null; if (!responseText) return null;
if (responseJson_) return responseJson_; if (responseJson_) return responseJson_;
responseJson_ = await this.xmlToJson(responseText); responseJson_ = await this.xmlToJson(responseText);
if (!responseJson_) throw new JoplinError('Cannot parse JSON response: ' + responseText, response.status); if (!responseJson_) throw new JoplinError('Cannot parse JSON response: ' + shortResponseText(), response.status);
return responseJson_; return responseJson_;
} }
if (!response.ok) { if (!response.ok) {
// When using fetchBlob we only get a string (not xml or json) back // When using fetchBlob we only get a string (not xml or json) back
if (options.target === 'file') throw new JoplinError(responseText, response.status); if (options.target === 'file') throw new JoplinError(shortResponseText(), response.status);
const json = await loadResponseJson(); const json = await loadResponseJson();
if (json['d:error']) { if (json['d:error']) {
const code = json['d:error']['s:exception'] ? json['d:error']['s:exception'].join(' ') : response.status; const code = json['d:error']['s:exception'] ? json['d:error']['s:exception'].join(' ') : response.status;
const message = json['d:error']['s:message'] ? json['d:error']['s:message'].join("\n") : responseText; const message = json['d:error']['s:message'] ? json['d:error']['s:message'].join("\n") : shortResponseText();
throw new JoplinError(message + ' (' + code + ')', response.status); throw new JoplinError(message + ' (' + code + ')', response.status);
} }
throw new JoplinError(responseText, response.status); throw new JoplinError(shortResponseText(), response.status);
} }
if (options.responseFormat === 'text') return responseText; if (options.responseFormat === 'text') return responseText;
return await loadResponseJson(); const output = await loadResponseJson();
// Check that we didn't get for example an HTML page (as an error) instead of the JSON response
// null responses are possible, for example for DELETE calls
if (output !== null && typeof output === 'object' && !('d:multistatus' in output)) throw new Error('Not a valid JSON response: ' + shortResponseText());
return output;
} }
} }

View File

@ -37,13 +37,18 @@ shared.synchronize_press = async function(comp) {
const action = comp.props.syncStarted ? 'cancel' : 'start'; const action = comp.props.syncStarted ? 'cancel' : 'start';
if (!reg.syncTarget().isAuthenticated()) { if (!reg.syncTarget().isAuthenticated()) {
if (reg.syncTarget().authRouteName()) {
comp.props.dispatch({ comp.props.dispatch({
type: 'NAV_GO', type: 'NAV_GO',
routeName: 'OneDriveLogin', routeName: reg.syncTarget().authRouteName(),
}); });
return 'auth'; return 'auth';
} }
reg.logger().info('Not authentified with sync target - please check your credential.');
return 'error';
}
let sync = null; let sync = null;
try { try {
sync = await reg.syncTarget().synchronizer(); sync = await reg.syncTarget().synchronizer();

View File

@ -1,5 +1,7 @@
const BaseItem = require('lib/models/BaseItem.js'); const BaseItem = require('lib/models/BaseItem.js');
const { time } = require('lib/time-utils.js'); const { time } = require('lib/time-utils.js');
const { basicDelta } = require('lib/file-api');
const { rtrimSlashes, ltrimSlashes } = require('lib/path-utils.js');
class FileApiDriverWebDav { class FileApiDriverWebDav {
@ -12,19 +14,25 @@ class FileApiDriverWebDav {
} }
async stat(path) { async stat(path) {
const result = await this.api().execPropFind(path, [ try {
const result = await this.api().execPropFind(path, 0, [
'd:getlastmodified', 'd:getlastmodified',
'd:resourcetype', 'd:resourcetype',
]); ]);
return this.metadataFromStat_(result, path); const resource = this.api().objectFromJson(result, ['d:multistatus', 'd:response', 0]);
return this.statFromResource_(resource, path);
} catch (error) {
if (error.code === 404) return null;
throw error;
}
} }
metadataFromStat_(stat, path) { statFromResource_(resource, path) {
const isCollection = this.api().stringFromJson(stat, ['d:multistatus', 'd:response', 0, 'd:propstat', 0, 'd:prop', 0, 'd:resourcetype', 0, 'd:collection', 0]); const isCollection = this.api().stringFromJson(resource, ['d:propstat', 0, 'd:prop', 0, 'd:resourcetype', 0, 'd:collection', 0]);
const lastModifiedString = this.api().stringFromJson(stat, ['d:multistatus', 'd:response', 0, 'd:propstat', 0, 'd:prop', 0, 'd:getlastmodified', 0]); const lastModifiedString = this.api().stringFromJson(resource, ['d:propstat', 0, 'd:prop', 0, 'd:getlastmodified', 0]);
if (!lastModifiedString) throw new Error('Could not get lastModified date: ' + JSON.stringify(stat)); if (!lastModifiedString) throw new Error('Could not get lastModified date: ' + JSON.stringify(resource));
const lastModifiedDate = new Date(lastModifiedString); const lastModifiedDate = new Date(lastModifiedString);
if (isNaN(lastModifiedDate.getTime())) throw new Error('Invalid date: ' + lastModifiedString); if (isNaN(lastModifiedDate.getTime())) throw new Error('Invalid date: ' + lastModifiedString);
@ -37,11 +45,17 @@ class FileApiDriverWebDav {
}; };
} }
metadataFromStats_(stats) { statsFromResources_(resources) {
const relativeBaseUrl = this.api().relativeBaseUrl();
let output = []; let output = [];
for (let i = 0; i < stats.length; i++) { for (let i = 0; i < resources.length; i++) {
const mdStat = this.metadataFromStat_(stats[i]); const resource = resources[i];
output.push(mdStat); const href = this.api().stringFromJson(resource, ['d:href', 0]);
if (href.indexOf(relativeBaseUrl) < 0) throw new Error('Path not inside base URL: ' + relativeBaseUrl); // Normally not possible
const path = rtrimSlashes(ltrimSlashes(href.substr(relativeBaseUrl.length)));
if (path === '') continue; // The list of resources includes the root dir too, which we don't want
const stat = this.statFromResource_(resources[i], path);
output.push(stat);
} }
return output; return output;
} }
@ -51,7 +65,17 @@ class FileApiDriverWebDav {
} }
async delta(path, options) { async delta(path, options) {
const getDirStats = async (path) => {
const result = await this.api().execPropFind(path, 1, [
'd:getlastmodified',
'd:resourcetype',
]);
const resources = this.api().arrayFromJson(result, ['d:multistatus', 'd:response']);
return this.statsFromResources_(resources);
};
return await basicDelta(path, getDirStats, options);
} }
async list(path, options) { async list(path, options) {

View File

@ -139,9 +139,9 @@ function basicDeltaContextFromOptions_(options) {
} }
// This is the basic delta algorithm, which can be used in case the cloud service does not have // This is the basic delta algorithm, which can be used in case the cloud service does not have
// a built-on delta API. OneDrive and Dropbox have one for example, but Nextcloud and obviously // a built-in delta API. OneDrive and Dropbox have one for example, but Nextcloud and obviously
// the file system do not. // the file system do not.
async function basicDelta(path, getStatFn, options) { async function basicDelta(path, getDirStatFn, options) {
const outputLimit = 1000; const outputLimit = 1000;
const itemIds = await options.allItemIdsHandler(); const itemIds = await options.allItemIdsHandler();
if (!Array.isArray(itemIds)) throw new Error('Delta API not supported - local IDs must be provided'); if (!Array.isArray(itemIds)) throw new Error('Delta API not supported - local IDs must be provided');
@ -156,7 +156,7 @@ async function basicDelta(path, getStatFn, options) {
// Stats are cached until all items have been processed (until hasMore is false) // Stats are cached until all items have been processed (until hasMore is false)
if (newContext.statsCache === null) { if (newContext.statsCache === null) {
newContext.statsCache = await getStatFn(path); newContext.statsCache = await getDirStatFn(path);
newContext.statsCache.sort(function(a, b) { newContext.statsCache.sort(function(a, b) {
return a.updated_time - b.updated_time; return a.updated_time - b.updated_time;
}); });
@ -196,6 +196,8 @@ async function basicDelta(path, getStatFn, options) {
if (output.length >= outputLimit) break; if (output.length >= outputLimit) break;
} }
// Find out which items have been deleted on the sync target by comparing the items
// we have to the items on the target.
let deletedItems = []; let deletedItems = [];
for (let i = 0; i < itemIds.length; i++) { for (let i = 0; i < itemIds.length; i++) {
if (output.length + deletedItems.length >= outputLimit) break; if (output.length + deletedItems.length >= outputLimit) break;

View File

@ -82,6 +82,9 @@ class Setting extends BaseModel {
return SyncTargetRegistry.idAndLabelPlainObject(); return SyncTargetRegistry.idAndLabelPlainObject();
}}, }},
'sync.2.path': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return settings['sync.target'] == SyncTargetRegistry.nameToId('filesystem') }, public: true, label: () => _('Directory to synchronise with (absolute path)'), description: () => _('The path to synchronise with when file system synchronisation is enabled. See `sync.target`.') }, 'sync.2.path': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return settings['sync.target'] == SyncTargetRegistry.nameToId('filesystem') }, public: true, label: () => _('Directory to synchronise with (absolute path)'), description: () => _('The path to synchronise with when file system synchronisation is enabled. See `sync.target`.') },
'sync.5.path': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return settings['sync.target'] == SyncTargetRegistry.nameToId('nextcloud') }, public: true, label: () => _('Nexcloud directory URL to synchronise with') },
'sync.5.username': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return settings['sync.target'] == SyncTargetRegistry.nameToId('nextcloud') }, public: true, label: () => _('Nexcloud username') },
'sync.5.password': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return settings['sync.target'] == SyncTargetRegistry.nameToId('nextcloud') }, public: true, label: () => _('Nexcloud password') },
'sync.3.auth': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.3.auth': { value: '', type: Setting.TYPE_STRING, public: false },
'sync.4.auth': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.4.auth': { value: '', type: Setting.TYPE_STRING, public: false },
'sync.1.context': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.1.context': { value: '', type: Setting.TYPE_STRING, public: false },

View File

@ -45,4 +45,12 @@ function toSystemSlashes(path, os) {
return path.replace(/\\/g, "/"); return path.replace(/\\/g, "/");
} }
module.exports = { basename, dirname, filename, isHidden, fileExtension, safeFileExtension, toSystemSlashes }; function rtrimSlashes(path) {
return path.replace(/\/+$/, '');
}
function ltrimSlashes(path) {
return path.replace(/^\/+/, '');
}
module.exports = { basename, dirname, filename, isHidden, fileExtension, safeFileExtension, toSystemSlashes, rtrimSlashes, ltrimSlashes };