diff --git a/.gitignore b/.gitignore index 7d194c329..f296e5507 100755 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ app/data/uploads/ sparse_test.php INFO.md /web/env.php -sync_staging.sh \ No newline at end of file +sync_staging.sh +*.swp \ No newline at end of file diff --git a/CliClient/app/cmd.js b/CliClient/app/cmd.js new file mode 100644 index 000000000..57dd735e3 --- /dev/null +++ b/CliClient/app/cmd.js @@ -0,0 +1,211 @@ +import { FileApi } from 'src/file-api.js'; +import { FileApiDriverLocal } from 'src/file-api-driver-local.js'; +import { Database } from 'src/database.js'; +import { DatabaseDriverNode } from 'src/database-driver-node.js'; +import { BaseModel } from 'src/base-model.js'; +import { Folder } from 'src/models/folder.js'; +import { Note } from 'src/models/note.js'; +import { Synchronizer } from 'src/synchronizer.js'; +import { uuid } from 'src/uuid.js'; +import { sprintf } from 'sprintf-js'; +import { _ } from 'src/locale.js'; +import { NoteFolderService } from 'src/services/note-folder-service.js'; + +const vorpal = require('vorpal')(); + +let db = new Database(new DatabaseDriverNode()); +db.setDebugEnabled(false); +db.open({ name: '/home/laurent/Temp/test.sqlite3' }).then(() => { + BaseModel.db_ = db; + + let commands = []; + let currentFolder = null; + + function switchCurrentFolder(folder) { + currentFolder = folder; + updatePrompt(); + } + + function promptString() { + let path = '~'; + if (currentFolder) { + path += '/' + currentFolder.title; + } + return 'joplin:' + path + '$ '; + } + + function updatePrompt() { + vorpal.delimiter(promptString()); + } + + process.stdin.on('keypress', (_, key) => { + if (key && key.name === 'return') { + updatePrompt(); + } + }); + + commands.push({ + usage: 'cd ', + description: 'Moved to [list-title] - all further operations will happen within this list. Use `cd ..` to go back one level.', + action: function (args, end) { + let folderTitle = args['list-title']; + + if (folderTitle == '..') { + switchCurrentFolder(null); + end(); + return; + } + + if (folderTitle == '.') { + end(); + return; + } + + Folder.loadByField('title', folderTitle).then((folder) => { + switchCurrentFolder(folder); + end(); + }); + }, + }); + + commands.push({ + usage: 'mklist ', + description: 'Creates a new list', + action: function (args, end) { + NoteFolderService.save('folder', { title: args['list-title'] }).catch((error) => { + this.log(error); + }).then((folder) => { + switchCurrentFolder(folder); + end(); + }); + }, + }); + + commands.push({ + usage: 'mknote ', + description: 'Creates a new note', + action: function (args, end) { + if (!currentFolder) { + this.log('Notes can only be created within a list.'); + end(); + return; + } + + let note = { + title: args['note-title'], + parent_id: currentFolder.id, + }; + NoteFolderService.save('note', note).catch((error) => { + this.log(error); + }).then((note) => { + end(); + }); + }, + }); + + commands.push({ + usage: 'edit [prop-value]', + description: 'Sets the given of the given item.', + action: function (args, end) { + let promise = null; + let title = args['item-title']; + let propName = args['prop-name']; + let propValue = args['prop-value']; + + if (!currentFolder) { + promise = Folder.loadByField('title', title); + } else { + promise = Folder.loadNoteByField(currentFolder.id, 'title', title); + } + + promise.then((item) => { + if (!item) { + this.log(_('No item with title "%s" found.', title)); + end(); + return; + } + + let newItem = Object.assign({}, item); + newItem[propName] = propValue; + let itemType = currentFolder ? 'note' : 'folder'; + return NoteFolderService.save(itemType, newItem, item); + }).catch((error) => { + this.log(error); + }).then(() => { + end(); + }); + }, + }); + + commands.push({ + usage: 'ls [list-title]', + description: 'Lists items in [list-title].', + action: function (args, end) { + let folderTitle = args['list-title']; + + let promise = null; + + if (folderTitle) { + promise = Folder.loadByField('title', folderTitle); + } else if (currentFolder) { + promise = Promise.resolve(currentFolder); + } else { + promise = Promise.resolve('root'); + } + + promise.then((folder) => { + let p = null + let postfix = ''; + if (folder === 'root') { + p = Folder.all(); + postfix = '/'; + } else if (!folder) { + throw new Error(_('Unknown list: "%s"', folderTitle)); + } else { + p = Note.previews(folder.id); + } + + return p.then((previews) => { + for (let i = 0; i < previews.length; i++) { + this.log(previews[i].title + postfix); + } + }); + }).catch((error) => { + this.log(error); + }).then(() => { + end(); + }); + }, + }); + + // commands.push({ + // usage: 'sync', + // description: 'Synchronizes with remote storage.', + // action: function (args, end) { + + // }, + // }); + + for (let i = 0; i < commands.length; i++) { + let c = commands[i]; + let o = vorpal.command(c.usage, c.description); + o.action(c.action); + } + + + let driver = new FileApiDriverLocal(); + let api = new FileApi('/home/laurent/Temp/TestImport', driver); + //let api = new FileApi('/home/laurent/Temp/backup_test_dest', driver); + + // api.list('', true).then((files) => { + // console.info(files); + // }).catch((error) => { + // console.error(error); + // }); + let synchronizer = new Synchronizer(db, api); + synchronizer.start().catch((error) => { + console.error(error); + }); + + //vorpal.delimiter(promptString()).show(); +}); \ No newline at end of file diff --git a/CliClient/app/file-api-test.js b/CliClient/app/file-api-test.js new file mode 100644 index 000000000..3d73268c4 --- /dev/null +++ b/CliClient/app/file-api-test.js @@ -0,0 +1,93 @@ +import { FileApi } from 'src/file-api.js'; +import { FileApiDriverLocal } from 'src/file-api-driver-local.js'; +import { Database } from 'src/database.js'; +import { DatabaseDriverNode } from 'src/database-driver-node.js'; +import { Log } from 'src/log.js'; + +const fs = require('fs'); + +// let driver = new FileApiDriverLocal(); +// let api = new FileApi('/home/laurent/Temp/TestImport', driver); + +// api.list('/').then((items) => { +// console.info(items); +// }).then(() => { +// return api.get('un.txt'); +// }).then((content) => { +// console.info(content); +// }).then(() => { +// return api.mkdir('TESTING'); +// }).then(() => { +// return api.put('un.txt', 'testing change'); +// }).then(() => { +// return api.delete('deux.txt'); +// }).catch((error) => { +// console.error('ERROR', error); +// }); + +Log.setLevel(Log.LEVEL_DEBUG); + +let db = new Database(new DatabaseDriverNode()); +db.setDebugEnabled(true); +db.open({ name: '/home/laurent/Temp/test.sqlite3' }).then(() => { + return db.selectAll('SELECT * FROM table_fields'); +}).then((rows) => { + +}); + + //'/home/laurent/Temp/TestImport' + + +// var sqlite3 = require('sqlite3').verbose(); +// var db = new sqlite3.Database(':memory:'); + +// db.run("CREATE TABLE lorem (info TEXT)", () => { +// db.exec('INSERT INTO lorem VALUES "un"', () => { +// db.exec('INSERT INTO lorem VALUES "deux"', () => { +// let st = db.prepare("SELECT rowid AS id, info FROM lorem", () => { +// st.get((error, row) => { +// console.info(row); +// }); +// }); +// }); +// }); +// }); + +// var stmt = db.prepare("INSERT INTO lorem VALUES (?)"); +// for (var i = 0; i < 10; i++) { +// stmt.run("Ipsum " + i); +// } +// stmt.finalize(); + +// let st = db.prepare("SELECT rowid AS id, info FROM lorem"); +// st.get({}, (row) => { +// console.info('xx',row); +// }); + + +// st.finalize(); + + +//db.serialize(function() { + // db.run("CREATE TABLE lorem (info TEXT)"); + + // var stmt = db.prepare("INSERT INTO lorem VALUES (?)"); + // for (var i = 0; i < 10; i++) { + // stmt.run("Ipsum " + i); + // } + // stmt.finalize(); + + // let st = db.prepare("SELECT rowid AS id, info FROM lorem"); + // st.get({}, (row) => { + // console.info('xx',row); + // }); + + + // st.finalize(); + + // db.each("SELECT rowid AS id, info FROM lorem", function(err, row) { + // console.log(row.id + ": " + row.info); + // }); +//}); + +//db.close(); \ No newline at end of file diff --git a/CliClient/app/import-enex.js b/CliClient/app/import-enex.js index a54ef4080..152140aae 100644 --- a/CliClient/app/import-enex.js +++ b/CliClient/app/import-enex.js @@ -4,6 +4,7 @@ import { uuid } from 'src/uuid.js'; import moment from 'moment'; import { promiseChain } from 'src/promise-chain.js'; import { WebApi } from 'src/web-api.js' +import { folderItemFilename } from 'src/string-utils.js' import jsSHA from "jssha"; let webApi = new WebApi('http://joplin.local'); @@ -519,13 +520,124 @@ function saveNoteToWebApi(note) { delete data.tags; webApi.post('notes', null, data).then((r) => { - console.info(r); + //console.info(r); }).catch((error) => { console.error("Error for note: " + note.title); console.error(error); }); } +function noteToFriendlyString_format(propName, propValue) { + if (['created_time', 'updated_time'].indexOf(propName) >= 0) { + if (!propValue) return ''; + propValue = moment.unix(propValue).format('YYYY-MM-DD hh:mm:ss'); + } else if (propValue === null || propValue === undefined) { + propValue = ''; + } + + return propValue; +} + +function noteToFriendlyString(note) { + let shownKeys = ["author", "longitude", "latitude", "is_todo", "todo_due", "todo_completed", 'created_time', 'updated_time']; + let output = []; + + output.push(note.title); + output.push(""); + output.push(note.body); + output.push(''); + for (let i = 0; i < shownKeys.length; i++) { + let v = note[shownKeys[i]]; + v = noteToFriendlyString_format(shownKeys[i], v); + output.push(shownKeys[i] + ': ' + v); + } + + return output.join("\n"); +} + +// function folderItemFilename(item) { +// let output = escapeFilename(item.title).trim(); +// if (!output.length) output = '_'; +// return output + '.' + item.id.substr(0, 7); +// } + +function noteFilename(note) { + return folderItemFilename(note) + '.md'; +} + +function folderFilename(folder) { + return folderItemFilename(folder); +} + +function filePutContents(filePath, content) { + return new Promise((resolve, reject) => { + fs.writeFile(filePath, content, function(error) { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +function setModifiedTime(filePath, time) { + return new Promise((resolve, reject) => { + fs.utimes(filePath, time, time, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }) + }); +} + +function createDirectory(path) { + return new Promise((resolve, reject) => { + fs.exists(path, (exists) => { + if (exists) { + resolve(); + return; + } + + const mkdirp = require('mkdirp'); + + mkdirp(path, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }); +} + +const baseNoteDir = '/home/laurent/Temp/TestImport'; + +// createDirectory('/home/laurent/Temp/TestImport').then(() => { +// console.info('OK'); +// }).catch((error) => { +// console.error(error); +// }); + +function saveNoteToDisk(folder, note) { + const noteContent = noteToFriendlyString(note); + const notePath = baseNoteDir + '/' + folderFilename(folder) + '/' + noteFilename(note); + + // console.info('==================================================='); + // console.info(note);//noteContent); + return filePutContents(notePath, noteContent).then(() => { + return setModifiedTime(notePath, note.updated_time ? note.updated_time : note.created_time); + }); +} + +function saveFolderToDisk(folder) { + let path = baseNoteDir + '/' + folderFilename(folder); + return createDirectory(path); +} + function createNoteId(note) { let shaObj = new jsSHA("SHA-256", "TEXT"); shaObj.update(note.title + '_' + note.body + "_" + note.created_time + "_" + note.updated_time + "_"); @@ -533,7 +645,7 @@ function createNoteId(note) { return hash.substr(0, 32); } -function importEnex(parentId, stream) { +function importEnex(parentFolder, stream) { return new Promise((resolve, reject) => { let options = {}; let strict = true; @@ -576,10 +688,16 @@ function importEnex(parentId, stream) { firstAttachment = false; } - note.parent_id = parentId; + note.parent_id = parentFolder.id; note.body = processMdArrayNewLines(result.lines); + note.id = uuid.create(); - saveNoteToWebApi(note); + saveNoteToDisk(parentFolder, note); + + // console.info(noteToFriendlyString(note)); + // console.info('========================================================================================================================='); + + //saveNoteToWebApi(note); // console.info('======== NOTE ============================================================================'); // let c = note.content; @@ -669,7 +787,7 @@ function importEnex(parentId, stream) { if (notes.length >= 10) { stream.pause(); processNotes().then(() => { - //stream.resume(); + stream.resume(); }).catch((error) => { console.info('Error processing note', error); }); @@ -722,8 +840,8 @@ function importEnex(parentId, stream) { // TODO: make it persistent and random const clientId = 'AB78AB78AB78AB78AB78AB78AB78AB78'; -//const folderTitle = 'Laurent'; -const folderTitle = 'Voiture'; +const folderTitle = 'Laurent'; +//const folderTitle = 'Voiture'; webApi.post('sessions', null, { email: 'laurent@cozic.net', @@ -745,11 +863,15 @@ webApi.post('sessions', null, { } return folder ? Promise.resolve(folder) : webApi.post('folders', null, { title: folderTitle }); +}).then((folder) => { + return saveFolderToDisk(folder).then(() => { + return folder; + }); }).then((folder) => { let fileStream = fs.createReadStream('/mnt/c/Users/Laurent/Desktop/' + folderTitle + '.enex'); //let fileStream = fs.createReadStream('/mnt/c/Users/Laurent/Desktop/afaire.enex'); //let fileStream = fs.createReadStream('/mnt/c/Users/Laurent/Desktop/testtags.enex'); - importEnex(folder.id, fileStream).then(() => { + importEnex(folder, fileStream).then(() => { //console.info('DONE IMPORTING'); }).catch((error) => { console.error('Cannot import', error); diff --git a/CliClient/package.json b/CliClient/package.json index b1cf53005..2bdc97b1c 100644 --- a/CliClient/package.json +++ b/CliClient/package.json @@ -5,14 +5,19 @@ "dependencies": { "app-module-path": "^2.2.0", "form-data": "^2.1.4", + "fs-extra": "^3.0.1", "jssha": "^2.3.0", + "mkdirp": "^0.5.1", "moment": "^2.18.1", "node-fetch": "^1.7.1", "promise": "^7.1.1", "react": "16.0.0-alpha.6", "sax": "^1.2.2", + "sprintf-js": "^1.1.1", + "sqlite3": "^3.1.8", "string-to-stream": "^1.1.0", - "uuid": "^3.0.1" + "uuid": "^3.0.1", + "vorpal": "^1.12.0" }, "devDependencies": { "babel-changed": "^7.0.0", @@ -22,7 +27,8 @@ "query-string": "4.3.4" }, "scripts": { - "build": "babel-changed app -d build", + "babelbuild": "babel app -d build", + "build": "babel-changed app -d build && babel-changed app/src/models -d build/src/models && babel-changed app/src/services -d build/src/services", "clean": "babel-changed --reset" } } diff --git a/CliClient/run.sh b/CliClient/run.sh index 27a97b253..c7282b854 100755 --- a/CliClient/run.sh +++ b/CliClient/run.sh @@ -4,4 +4,6 @@ CLIENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" rm -f "$CLIENT_DIR/app/src" ln -s "$CLIENT_DIR/../ReactNativeClient/src" "$CLIENT_DIR/app" -npm run build && NODE_PATH="$CLIENT_DIR/build/" node build/import-enex.js \ No newline at end of file +#npm run build && NODE_PATH="$CLIENT_DIR/build/" node build/import-enex.js +#npm run build && NODE_PATH="$CLIENT_DIR/build/" node build/file-api-test.js +npm run build && NODE_PATH="$CLIENT_DIR/build/" node build/cmd.js \ No newline at end of file diff --git a/ReactNativeClient/android/app/build.gradle b/ReactNativeClient/android/app/build.gradle index 925723627..89e1f9026 100644 --- a/ReactNativeClient/android/app/build.gradle +++ b/ReactNativeClient/android/app/build.gradle @@ -126,6 +126,7 @@ android { } dependencies { + compile project(':react-native-fs') compile fileTree(dir: "libs", include: ["*.jar"]) compile "com.android.support:appcompat-v7:23.0.1" compile "com.facebook.react:react-native:+" // From node_modules diff --git a/ReactNativeClient/android/app/src/main/java/com/awesomeproject/MainApplication.java b/ReactNativeClient/android/app/src/main/java/com/awesomeproject/MainApplication.java index 30d8948fb..11a6cd825 100644 --- a/ReactNativeClient/android/app/src/main/java/com/awesomeproject/MainApplication.java +++ b/ReactNativeClient/android/app/src/main/java/com/awesomeproject/MainApplication.java @@ -3,6 +3,7 @@ package com.awesomeproject; import android.app.Application; import com.facebook.react.ReactApplication; +import com.rnfs.RNFSPackage; import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; import com.facebook.react.shell.MainReactPackage; @@ -24,7 +25,8 @@ public class MainApplication extends Application implements ReactApplication { protected List getPackages() { return Arrays.asList( new SQLitePluginPackage(), - new MainReactPackage() + new MainReactPackage(), + new RNFSPackage() ); } }; diff --git a/ReactNativeClient/android/settings.gradle b/ReactNativeClient/android/settings.gradle index 3058e0831..2582fded8 100644 --- a/ReactNativeClient/android/settings.gradle +++ b/ReactNativeClient/android/settings.gradle @@ -1,4 +1,6 @@ rootProject.name = 'AwesomeProject' +include ':react-native-fs' +project(':react-native-fs').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fs/android') include ':app' diff --git a/ReactNativeClient/ios/AwesomeProject.xcodeproj/project.pbxproj b/ReactNativeClient/ios/AwesomeProject.xcodeproj/project.pbxproj index a555ac34c..cf16f18e0 100644 --- a/ReactNativeClient/ios/AwesomeProject.xcodeproj/project.pbxproj +++ b/ReactNativeClient/ios/AwesomeProject.xcodeproj/project.pbxproj @@ -5,7 +5,6 @@ }; objectVersion = 46; objects = { - /* Begin PBXBuildFile section */ 00C302E51ABCBA2D00DB3ED1 /* libRCTActionSheet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 00C302AC1ABCB8CE00DB3ED1 /* libRCTActionSheet.a */; }; 00C302E71ABCBA2D00DB3ED1 /* libRCTGeolocation.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 00C302BA1ABCB90400DB3ED1 /* libRCTGeolocation.a */; }; @@ -36,6 +35,7 @@ 2DCD954D1E0B4F2C00145EB5 /* AwesomeProjectTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* AwesomeProjectTests.m */; }; 5E9157361DD0AC6A00FF2AA8 /* libRCTAnimation.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E9157331DD0AC6500FF2AA8 /* libRCTAnimation.a */; }; 832341BD1AAA6AB300B99B32 /* libRCTText.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 832341B51AAA6A8300B99B32 /* libRCTText.a */; }; + EA51DDC9EBFC469F8214B3AD /* libRNFS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A8E646D13B9444DE81EC441D /* libRNFS.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -255,6 +255,8 @@ 5E91572D1DD0AC6500FF2AA8 /* RCTAnimation.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTAnimation.xcodeproj; path = "../node_modules/react-native/Libraries/NativeAnimation/RCTAnimation.xcodeproj"; sourceTree = ""; }; 78C398B01ACF4ADC00677621 /* RCTLinking.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTLinking.xcodeproj; path = "../node_modules/react-native/Libraries/LinkingIOS/RCTLinking.xcodeproj"; sourceTree = ""; }; 832341B01AAA6A8300B99B32 /* RCTText.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTText.xcodeproj; path = "../node_modules/react-native/Libraries/Text/RCTText.xcodeproj"; sourceTree = ""; }; + 8C2AA97067234408AD5BFD90 /* RNFS.xcodeproj */ = {isa = PBXFileReference; name = "RNFS.xcodeproj"; path = "../node_modules/react-native-fs/RNFS.xcodeproj"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = wrapper.pb-project; explicitFileType = undefined; includeInIndex = 0; }; + A8E646D13B9444DE81EC441D /* libRNFS.a */ = {isa = PBXFileReference; name = "libRNFS.a"; path = "libRNFS.a"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = archive.ar; explicitFileType = undefined; includeInIndex = 0; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -281,6 +283,7 @@ 832341BD1AAA6AB300B99B32 /* libRCTText.a in Frameworks */, 00C302EA1ABCBA2D00DB3ED1 /* libRCTVibration.a in Frameworks */, 139FDEF61B0652A700C62182 /* libRCTWebSocket.a in Frameworks */, + EA51DDC9EBFC469F8214B3AD /* libRNFS.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -447,6 +450,7 @@ 832341B01AAA6A8300B99B32 /* RCTText.xcodeproj */, 00C302DF1ABCB9EE00DB3ED1 /* RCTVibration.xcodeproj */, 139FDEE61B06529A00C62182 /* RCTWebSocket.xcodeproj */, + 8C2AA97067234408AD5BFD90 /* RNFS.xcodeproj */, ); name = Libraries; sourceTree = ""; @@ -564,7 +568,7 @@ 83CBB9F71A601CBA00E9B192 /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 0610; + LastUpgradeCheck = 610; ORGANIZATIONNAME = Facebook; TargetAttributes = { 00E356ED1AD99517003FC87E = { @@ -972,6 +976,14 @@ ); PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AwesomeProject.app/AwesomeProject"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + ); + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../node_modules/react-native-fs/**", + ); }; name = Debug; }; @@ -989,6 +1001,14 @@ ); PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AwesomeProject.app/AwesomeProject"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + ); + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../node_modules/react-native-fs/**", + ); }; name = Release; }; @@ -1007,6 +1027,10 @@ ); PRODUCT_NAME = AwesomeProject; VERSIONING_SYSTEM = "apple-generic"; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../node_modules/react-native-fs/**", + ); }; name = Debug; }; @@ -1024,6 +1048,10 @@ ); PRODUCT_NAME = AwesomeProject; VERSIONING_SYSTEM = "apple-generic"; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../node_modules/react-native-fs/**", + ); }; name = Release; }; @@ -1050,6 +1078,14 @@ SDKROOT = appletvos; TARGETED_DEVICE_FAMILY = 3; TVOS_DEPLOYMENT_TARGET = 9.2; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + ); + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../node_modules/react-native-fs/**", + ); }; name = Debug; }; @@ -1076,6 +1112,14 @@ SDKROOT = appletvos; TARGETED_DEVICE_FAMILY = 3; TVOS_DEPLOYMENT_TARGET = 9.2; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + ); + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../node_modules/react-native-fs/**", + ); }; name = Release; }; @@ -1097,6 +1141,10 @@ SDKROOT = appletvos; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AwesomeProject-tvOS.app/AwesomeProject-tvOS"; TVOS_DEPLOYMENT_TARGET = 10.1; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + ); }; name = Debug; }; @@ -1118,6 +1166,10 @@ SDKROOT = appletvos; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AwesomeProject-tvOS.app/AwesomeProject-tvOS"; TVOS_DEPLOYMENT_TARGET = 10.1; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + ); }; name = Release; }; diff --git a/ReactNativeClient/package.json b/ReactNativeClient/package.json index 50c9e0e51..f60af41a6 100644 --- a/ReactNativeClient/package.json +++ b/ReactNativeClient/package.json @@ -7,12 +7,15 @@ "test": "jest" }, "dependencies": { + "dropbox": "^2.5.4", "form-data": "^2.1.4", + "moment": "^2.18.1", "node-fetch": "^1.7.1", "react": "16.0.0-alpha.6", "react-native": "0.44.0", "react-native-action-button": "^2.6.9", "react-native-checkbox": "^1.1.0", + "react-native-fs": "^2.3.3", "react-native-popup-menu": "^0.7.4", "react-native-side-menu": "^0.20.1", "react-native-vector-icons": "^2.0.3", diff --git a/ReactNativeClient/src/base-model.js b/ReactNativeClient/src/base-model.js index d4d9691b3..abb12042c 100644 --- a/ReactNativeClient/src/base-model.js +++ b/ReactNativeClient/src/base-model.js @@ -72,7 +72,12 @@ class BaseModel { } static load(id) { - return this.db().selectOne('SELECT * FROM ' + this.tableName() + ' WHERE id = ?', [id]); + return this.loadByField('id', id); + //return this.db().selectOne('SELECT * FROM ' + this.tableName() + ' WHERE id = ?', [id]); + } + + static loadByField(fieldName, fieldValue) { + return this.db().selectOne('SELECT * FROM ' + this.tableName() + ' WHERE `' + fieldName + '` = ?', [fieldValue]); } static applyPatch(model, patch) { @@ -143,43 +148,44 @@ class BaseModel { options = this.modOptions(options); let isNew = options.isNew == 'auto' ? !o.id : options.isNew; - let query = this.saveQuery(o, isNew); - return this.db().transaction((tx) => { - tx.executeSql(query.sql, query.params); + let queries = []; + let saveQuery = this.saveQuery(o, isNew); + let itemId = saveQuery.id; - if (options.trackChanges && this.trackChanges()) { - // Cannot import this class the normal way due to cyclical dependencies between Change and BaseModel - // which are not handled by React Native. - const { Change } = require('src/models/change.js'); + queries.push(saveQuery); + + if (options.trackChanges && this.trackChanges()) { + // Cannot import this class the normal way due to cyclical dependencies between Change and BaseModel + // which are not handled by React Native. + const { Change } = require('src/models/change.js'); + + if (isNew) { + let change = Change.newChange(); + change.type = Change.TYPE_CREATE; + change.item_id = itemId; + change.item_type = this.itemType(); + + queries.push(Change.saveQuery(change)); + } else { + for (let n in o) { + if (!o.hasOwnProperty(n)) continue; + if (n == 'id') continue; - if (isNew) { let change = Change.newChange(); - change.type = Change.TYPE_CREATE; - change.item_id = query.id; + change.type = Change.TYPE_UPDATE; + change.item_id = itemId; change.item_type = this.itemType(); + change.item_field = n; - let changeQuery = Change.saveQuery(change); - tx.executeSql(changeQuery.sql, changeQuery.params); - } else { - for (let n in o) { - if (!o.hasOwnProperty(n)) continue; - if (n == 'id') continue; - - let change = Change.newChange(); - change.type = Change.TYPE_UPDATE; - change.item_id = query.id; - change.item_type = this.itemType(); - change.item_field = n; - - let changeQuery = Change.saveQuery(change); - tx.executeSql(changeQuery.sql, changeQuery.params); - } + queries.push(Change.saveQuery(change)); } } - }).then((r) => { + } + + return this.db().transactionExecBatch(queries).then(() => { o = Object.assign({}, o); - o.id = query.id; + o.id = itemId; return o; }).catch((error) => { Log.error('Cannot save model', error); @@ -220,5 +226,6 @@ BaseModel.ITEM_TYPE_FOLDER = 2; BaseModel.tableInfo_ = null; BaseModel.tableKeys_ = null; BaseModel.db_ = null; +BaseModel.dispatch = function(o) {}; export { BaseModel }; \ No newline at end of file diff --git a/ReactNativeClient/src/components/screen-header.js b/ReactNativeClient/src/components/screen-header.js index 2371467aa..820f30b3b 100644 --- a/ReactNativeClient/src/components/screen-header.js +++ b/ReactNativeClient/src/components/screen-header.js @@ -17,10 +17,6 @@ const styles = StyleSheet.create({ class ScreenHeaderComponent extends Component { - static defaultProps = { - menuOptions: [], - }; - showBackButton() { // Note: this is hardcoded for now because navigation.state doesn't tell whether // it's possible to go back or not. Maybe it's possible to get this information @@ -111,6 +107,10 @@ class ScreenHeaderComponent extends Component { } +ScreenHeaderComponent.defaultProps = { + menuOptions: [], +}; + const ScreenHeader = connect( (state) => { return { user: state.user }; diff --git a/ReactNativeClient/src/components/screens/login.js b/ReactNativeClient/src/components/screens/login.js index f41466355..3bca677be 100644 --- a/ReactNativeClient/src/components/screens/login.js +++ b/ReactNativeClient/src/components/screens/login.js @@ -61,7 +61,7 @@ class LoginScreenComponent extends React.Component { Registry.api().setSession(session.id); - Registry.synchronizer().start(); + //Registry.synchronizer().start(); }).catch((error) => { this.setState({ errorMessage: _('Could not login: %s)', error.message) }); }); diff --git a/ReactNativeClient/src/database-driver-node.js b/ReactNativeClient/src/database-driver-node.js new file mode 100644 index 000000000..5e478964c --- /dev/null +++ b/ReactNativeClient/src/database-driver-node.js @@ -0,0 +1,63 @@ +const sqlite3 = require('sqlite3').verbose(); +const Promise = require('promise'); + +class DatabaseDriverNode { + + open(options) { + return new Promise((resolve, reject) => { + this.db_ = new sqlite3.Database(options.name, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + + setDebugEnabled(v) { + // ?? + } + + selectOne(sql, params = null) { + if (!params) params = {}; + return new Promise((resolve, reject) => { + this.db_.get(sql, params, (error, row) => { + if (error) { + reject(error); + return; + } + resolve(row); + }); + }); + } + + selectAll(sql, params = null) { + if (!params) params = {}; + return new Promise((resolve, reject) => { + this.db_.all(sql, params, (error, row) => { + if (error) { + reject(error); + return; + } + resolve(row); + }); + }); + } + + exec(sql, params = null) { + if (!params) params = {}; + return new Promise((resolve, reject) => { + this.db_.run(sql, params, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + +} + +export { DatabaseDriverNode }; \ No newline at end of file diff --git a/ReactNativeClient/src/database-driver-react-native.js b/ReactNativeClient/src/database-driver-react-native.js new file mode 100644 index 000000000..08cc5486f --- /dev/null +++ b/ReactNativeClient/src/database-driver-react-native.js @@ -0,0 +1,52 @@ +import SQLite from 'react-native-sqlite-storage'; + +class DatabaseDriverReactNative { + + open(options) { + return new Promise((resolve, reject) => { + SQLite.openDatabase({ name: options.name }, (db) => { + this.db_ = db; + resolve(); + }, (error) => { + reject(error); + }); + }); + } + + setDebugEnabled(v) { + SQLite.DEBUG(v); + } + + selectOne(sql, params = null) { + return new Promise((resolve, reject) => { + this.db_.executeSql(sql, params, (r) => { + resolve(r.rows.length ? r.rows.item(0) : null); + }, (error) => { + reject(error); + }); + }); + } + + selectAll(sql, params = null) { + return this.exec(sql, params).then((r) => { + let output = [] + for (let i = 0; i < r.rows.length; i++) { + output.push(r.rows.item(i)); + } + return output; + }); + } + + exec(sql, params = null) { + return new Promise((resolve, reject) => { + this.db_.executeSql(sql, params, (r) => { + resolve(r); + }, (error) => { + reject(error); + }); + }); + } + +} + +export { DatabaseDriverReactNative } \ No newline at end of file diff --git a/ReactNativeClient/src/database.js b/ReactNativeClient/src/database.js index 4af0169a7..328c0dd0a 100644 --- a/ReactNativeClient/src/database.js +++ b/ReactNativeClient/src/database.js @@ -1,4 +1,3 @@ -import SQLite from 'react-native-sqlite-storage'; import { Log } from 'src/log.js'; import { uuid } from 'src/uuid.js'; import { promiseChain } from 'src/promise-chain.js'; @@ -94,14 +93,15 @@ INSERT INTO version (version) VALUES (1); class Database { - constructor() { + constructor(driver) { this.debugMode_ = false; this.initialized_ = false; this.tableFields_ = null; + this.driver_ = driver; } setDebugEnabled(v) { - SQLite.DEBUG(v); + this.driver_.setDebugEnabled(v); this.debugMode_ = v; } @@ -113,14 +113,43 @@ class Database { return this.initialized_; } - open() { - this.db_ = SQLite.openDatabase({ name: '/storage/emulated/0/Download/joplin-32.sqlite' }, (db) => { + driver() { + return this.driver_; + } + + open(options) { + return this.driver().open(options).then((db) => { Log.info('Database was open successfully'); - }, (error) => { + return this.initialize(); + }).catch((error) => { Log.error('Cannot open database: ', error); }); + } - return this.initialize(); + selectOne(sql, params = null) { + this.logQuery(sql, params); + return this.driver().selectOne(sql, params); + } + + selectAll(sql, params = null) { + this.logQuery(sql, params); + return this.driver().selectAll(sql, params); + } + + exec(sql, params = null) { + this.logQuery(sql, params); + return this.driver().exec(sql, params); + } + + transactionExecBatch(queries) { + let chain = []; + for (let i = 0; i < queries.length; i++) { + let query = this.wrapQuery(queries[i]); + chain.push(() => { + return this.exec(query.sql, query.params); + }); + } + return promiseChain(chain); } static enumId(type, s) { @@ -177,41 +206,7 @@ class Database { logQuery(sql, params = null) { if (!this.debugMode()) return; - //Log.debug('DB: ' + sql, params); - } - - selectOne(sql, params = null) { - this.logQuery(sql, params); - - return new Promise((resolve, reject) => { - this.db_.executeSql(sql, params, (r) => { - resolve(r.rows.length ? r.rows.item(0) : null); - }, (error) => { - reject(error); - }); - }); - } - - selectAll(sql, params = null) { - this.logQuery(sql, params); - - return this.exec(sql, params); - } - - exec(sql, params = null) { - this.logQuery(sql, params); - - return new Promise((resolve, reject) => { - this.db_.executeSql(sql, params, (r) => { - resolve(r); - }, (error) => { - reject(error); - }); - }); - } - - executeSql(sql, params = null) { - return this.exec(sql, params); + Log.debug('DB: ' + sql, params); } static insertQuery(tableName, data) { @@ -254,29 +249,53 @@ class Database { } transaction(readyCallack) { - return new Promise((resolve, reject) => { - this.db_.transaction( - readyCallack, - (error) => { reject(error); }, - () => { resolve(); } - ); - }); + throw new Error('transaction() DEPRECATED'); + // return new Promise((resolve, reject) => { + // this.db_.transaction( + // readyCallack, + // (error) => { reject(error); }, + // () => { resolve(); } + // ); + // }); + } + + wrapQueries(queries) { + let output = []; + for (let i = 0; i < queries.length; i++) { + output.push(this.wrapQuery(queries[i])); + } + return output; + } + + wrapQuery(sql, params = null) { + if (!sql) throw new Error('Cannot wrap empty string: ' + sql); + + if (sql.constructor === Array) { + let output = {}; + output.sql = sql[0]; + output.params = sql.length >= 2 ? sql[1] : null; + return output; + } else if (typeof sql === 'string') { + return { sql: sql, params: params }; + } else { + return sql; // Already wrapped + } } refreshTableFields() { - return this.exec('SELECT name FROM sqlite_master WHERE type="table"').then((tableResults) => { + let queries = []; + queries.push(this.wrapQuery('DELETE FROM table_fields')); + + return this.selectAll('SELECT name FROM sqlite_master WHERE type="table"').then((tableRows) => { let chain = []; - for (let i = 0; i < tableResults.rows.length; i++) { - let row = tableResults.rows.item(i); - let tableName = row.name; + for (let i = 0; i < tableRows.length; i++) { + let tableName = tableRows[i].name; if (tableName == 'android_metadata') continue; if (tableName == 'table_fields') continue; - - chain.push((queries) => { - if (!queries) queries = []; - return this.exec('PRAGMA table_info("' + tableName + '")').then((pragmaResult) => { - for (let i = 0; i < pragmaResult.rows.length; i++) { - let item = pragmaResult.rows.item(i); + chain.push(() => { + return this.selectAll('PRAGMA table_info("' + tableName + '")').then((pragmas) => { + for (let i = 0; i < pragmas.length; i++) { + let item = pragmas[i]; // In SQLite, if the default value is a string it has double quotes around it, so remove them here let defaultValue = item.dflt_value; if (typeof defaultValue == 'string' && defaultValue.length >= 2 && defaultValue[0] == '"' && defaultValue[defaultValue.length - 1] == '"') { @@ -290,19 +309,13 @@ class Database { }); queries.push(q); } - return queries; }); }); } return promiseChain(chain); - }).then((queries) => { - return this.transaction((tx) => { - tx.executeSql('DELETE FROM table_fields'); - for (let i = 0; i < queries.length; i++) { - tx.executeSql(queries[i].sql, queries[i].params); - } - }); + }).then(() => { + return this.transactionExecBatch(queries); }); } @@ -314,12 +327,13 @@ class Database { // TODO: version update logic // TODO: only do this if db has been updated: - return this.refreshTableFields(); + // return this.refreshTableFields(); }).then(() => { - return this.exec('SELECT * FROM table_fields').then((r) => { - this.tableFields_ = {}; - for (let i = 0; i < r.rows.length; i++) { - let row = r.rows.item(i); + this.tableFields_ = {}; + + return this.selectAll('SELECT * FROM table_fields').then((rows) => { + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; if (!this.tableFields_[row.table_name]) this.tableFields_[row.table_name] = []; this.tableFields_[row.table_name].push({ name: row.field_name, @@ -329,7 +343,6 @@ class Database { } }); - // }).then(() => { // let p = this.exec('DELETE FROM notes').then(() => { @@ -348,11 +361,9 @@ class Database { // return p; - - - }).catch((error) => { - if (error && error.code != 0) { + //console.info(error.code); + if (error && error.code != 0 && error.code != 'SQLITE_ERROR') { Log.error(error); return; } @@ -364,19 +375,18 @@ class Database { Log.info('Database is new - creating the schema...'); - let statements = this.sqlStringToLines(structureSql) - return this.transaction((tx) => { - for (let i = 0; i < statements.length; i++) { - tx.executeSql(statements[i]); - } - tx.executeSql('INSERT INTO settings (`key`, `value`, `type`) VALUES ("clientId", "' + uuid.create() + '", "' + Database.enumId('settings', 'string') + '")'); - tx.executeSql('INSERT INTO folders (`id`, `title`, `is_default`, `created_time`) VALUES ("' + uuid.create() + '", "' + _('Default list') + '", 1, ' + Math.round((new Date()).getTime() / 1000) + ')'); - }).then(() => { + let queries = this.wrapQueries(this.sqlStringToLines(structureSql)); + queries.push(this.wrapQuery('INSERT INTO settings (`key`, `value`, `type`) VALUES ("clientId", "' + uuid.create() + '", "' + Database.enumId('settings', 'string') + '")')); + queries.push(this.wrapQuery('INSERT INTO folders (`id`, `title`, `is_default`, `created_time`) VALUES ("' + uuid.create() + '", "' + _('Default list') + '", 1, ' + Math.round((new Date()).getTime() / 1000) + ')')); + + return this.transactionExecBatch(queries).then(() => { Log.info('Database schema created successfully'); // Calling initialize() now that the db has been created will make it go through // the normal db update process (applying any additional patch). + return this.refreshTableFields(); + }).then(() => { return this.initialize(); - }) + }); }); } diff --git a/ReactNativeClient/src/file-api-driver-local.js b/ReactNativeClient/src/file-api-driver-local.js new file mode 100644 index 000000000..627229861 --- /dev/null +++ b/ReactNativeClient/src/file-api-driver-local.js @@ -0,0 +1,143 @@ +import fs from 'fs'; +import fse from 'fs-extra'; +import { promiseChain } from 'src/promise-chain.js'; +import moment from 'moment'; + +class FileApiDriverLocal { + + stat(path) { + return new Promise((resolve, reject) => { + fs.stat(path, (error, s) => { + if (error) { + reject(error); + return; + } + resolve(s); + }); + }); + } + + statTimeToUnixTimestamp_(time) { + let m = moment(time, 'YYYY-MM-DDTHH:mm:ss.SSSZ'); + if (!m.isValid()) { + throw new Error('Invalid date: ' + time); + } + return Math.round(m.toDate().getTime() / 1000); + } + + metadataFromStats_(name, stats) { + return { + name: name, + createdTime: this.statTimeToUnixTimestamp_(stats.birthtime), + updatedTime: this.statTimeToUnixTimestamp_(stats.mtime), + isDir: stats.isDirectory(), + }; + } + + list(path) { + return new Promise((resolve, reject) => { + fs.readdir(path, (error, items) => { + if (error) { + reject(error); + return; + } + + let chain = []; + for (let i = 0; i < items.length; i++) { + chain.push((output) => { + if (!output) output = []; + return this.stat(path + '/' + items[i]).then((stat) => { + let md = this.metadataFromStats_(items[i], stat); + output.push(md); + return output; + }); + }); + } + + return promiseChain(chain).then((results) => { + if (!results) results = []; + resolve(results); + }).catch((error) => { + reject(error); + }); + }); + }); + } + + get(path) { + return new Promise((resolve, reject) => { + fs.readFile(path, 'utf8', (error, content) => { + if (error) { + reject(error); + return; + } + return resolve(content); + }); + }); + } + + mkdir(path) { + return new Promise((resolve, reject) => { + fs.exists(path, (exists) => { + if (exists) { + resolve(); + return; + } + + const mkdirp = require('mkdirp'); + + mkdirp(path, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }); + } + + put(path, content) { + return new Promise((resolve, reject) => { + fs.writeFile(path, content, function(error) { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + delete(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(error); + } + } else { + resolve(); + } + }); + }); + } + + move(oldPath, newPath) { + return new Promise((resolve, reject) => { + fse.move(oldPath, newPath, function(error) { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + +} + +export { FileApiDriverLocal }; \ No newline at end of file diff --git a/ReactNativeClient/src/file-api.js b/ReactNativeClient/src/file-api.js new file mode 100644 index 000000000..bd4557e2f --- /dev/null +++ b/ReactNativeClient/src/file-api.js @@ -0,0 +1,60 @@ +import { promiseChain } from 'src/promise-chain.js'; + +class FileApi { + + constructor(baseDir, driver) { + this.baseDir_ = baseDir; + this.driver_ = driver; + } + + list(path, recursive = false) { + return this.driver_.list(this.baseDir_ + '/' + path, recursive).then((items) => { + if (recursive) { + let chain = []; + for (let i = 0; i < items.length; i++) { + let item = items[i]; + if (!item.isDir) continue; + + chain.push(() => { + return this.list(path + '/' + item.name, true).then((children) => { + for (let j = 0; j < children.length; j++) { + let md = children[j]; + md.name = item.name + '/' + md.name; + items.push(md); + } + }); + }); + } + + return promiseChain(chain).then(() => { + return items; + }); + } else { + return items; + } + }); + } + + mkdir(path) { + return this.driver_.mkdir(this.baseDir_ + '/' + path); + } + + get(path) { + return this.driver_.get(this.baseDir_ + '/' + path); + } + + put(path, content) { + return this.driver_.put(this.baseDir_ + '/' + path, content); + } + + delete(path) { + return this.driver_.delete(this.baseDir_ + '/' + path); + } + + move(oldPath, newPath) { + return this.driver_.move(this.baseDir_ + '/' + oldPath, this.baseDir_ + '/' + newPath); + } + +} + +export { FileApi }; \ No newline at end of file diff --git a/ReactNativeClient/src/geolocation.js b/ReactNativeClient/src/geolocation.js index f5b325a76..deaf88aec 100644 --- a/ReactNativeClient/src/geolocation.js +++ b/ReactNativeClient/src/geolocation.js @@ -16,6 +16,11 @@ class Geolocation { } static currentPosition(options = null) { + if (typeof navigator === 'undefined') { + // TODO + return Promise.resolve(this.currentPosition_testResponse()); + } + if (!options) options = {}; if (!('enableHighAccuracy' in options)) options.enableHighAccuracy = true; if (!('timeout' in options)) options.timeout = 10000; diff --git a/ReactNativeClient/src/log.js b/ReactNativeClient/src/log.js index 25113e02a..45975cc71 100644 --- a/ReactNativeClient/src/log.js +++ b/ReactNativeClient/src/log.js @@ -7,12 +7,12 @@ class Log { } static level() { - return this.level_ === undefined ? Log.LEVEL_ERROR : this.level_; + return this.level_ === undefined ? Log.LEVEL_DEBUG : this.level_; } static debug(...o) { if (Log.level() > Log.LEVEL_DEBUG) return; - console.debug(...o); + console.info(...o); } static info(...o) { diff --git a/ReactNativeClient/src/models/change.js b/ReactNativeClient/src/models/change.js index e514b159e..7cf7ced11 100644 --- a/ReactNativeClient/src/models/change.js +++ b/ReactNativeClient/src/models/change.js @@ -18,24 +18,20 @@ class Change extends BaseModel { } static all() { - return this.db().selectAll('SELECT * FROM changes').then((r) => { - let output = []; - for (let i = 0; i < r.rows.length; i++) { - output.push(r.rows.item(i)); - } - return output; - }); + return this.db().selectAll('SELECT * FROM changes'); } static deleteMultiple(ids) { if (ids.length == 0) return Promise.resolve(); - return this.db().transaction((tx) => { - let sql = ''; - for (let i = 0; i < ids.length; i++) { - tx.executeSql('DELETE FROM changes WHERE id = ?', [ids[i]]); - } - }); + console.warn('TODO: deleteMultiple: CHECK THAT IT WORKS'); + + let queries = []; + for (let i = 0; i < ids.length; i++) { + queries.push(['DELETE FROM changes WHERE id = ?', [ids[i]]]); + } + + return this.db().transactionExecBatch(queries); } static mergeChanges(changes) { @@ -45,6 +41,7 @@ class Change extends BaseModel { for (let i = 0; i < changes.length; i++) { let change = changes[i]; + let mergedChange = null; if (itemChanges[change.item_id]) { mergedChange = itemChanges[change.item_id]; diff --git a/ReactNativeClient/src/models/folder.js b/ReactNativeClient/src/models/folder.js index 37503f9be..bd3e8760e 100644 --- a/ReactNativeClient/src/models/folder.js +++ b/ReactNativeClient/src/models/folder.js @@ -2,6 +2,7 @@ import { BaseModel } from 'src/base-model.js'; import { Log } from 'src/log.js'; import { promiseChain } from 'src/promise-chain.js'; import { Note } from 'src/models/note.js'; +import { folderItemFilename } from 'src/string-utils.js' import { _ } from 'src/locale.js'; class Folder extends BaseModel { @@ -10,6 +11,14 @@ class Folder extends BaseModel { return 'folders'; } + static filename(folder) { + return folderItemFilename(folder); + } + + static systemPath(parent, folder) { + return this.filename(folder); + } + static useUuid() { return true; } @@ -21,7 +30,7 @@ class Folder extends BaseModel { static trackChanges() { return true; } - + static newFolder() { return { id: null, @@ -30,10 +39,10 @@ class Folder extends BaseModel { } static noteIds(id) { - return this.db().exec('SELECT id FROM notes WHERE parent_id = ?', [id]).then((r) => { + return this.db().selectAll('SELECT id FROM notes WHERE parent_id = ?', [id]).then((rows) => { let output = []; - for (let i = 0; i < r.rows.length; i++) { - let row = r.rows.item(i); + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; output.push(row.id); } return output; @@ -66,23 +75,25 @@ class Folder extends BaseModel { }); } + static loadNoteByField(folderId, field, value) { + return this.db().selectOne('SELECT * FROM notes WHERE `parent_id` = ? AND `' + field + '` = ?', [folderId, value]); + } + static all() { - return this.db().selectAll('SELECT * FROM folders').then((r) => { - let output = []; - for (let i = 0; i < r.rows.length; i++) { - output.push(r.rows.item(i)); - } - return output; - }); + return this.db().selectAll('SELECT * FROM folders'); } static save(o, options = null) { - return super.save(o, options).then((folder) => { - this.dispatch({ - type: 'FOLDERS_UPDATE_ONE', - folder: folder, + return Folder.loadByField('title', o.title).then((existingFolder) => { + if (existingFolder && existingFolder.id != o.id) throw new Error(_('A folder with title "%s" already exists', o.title)); + + return super.save(o, options).then((folder) => { + this.dispatch({ + type: 'FOLDERS_UPDATE_ONE', + folder: folder, + }); + return folder; }); - return folder; }); } diff --git a/ReactNativeClient/src/models/note.js b/ReactNativeClient/src/models/note.js index eda4da246..198127ea8 100644 --- a/ReactNativeClient/src/models/note.js +++ b/ReactNativeClient/src/models/note.js @@ -1,6 +1,9 @@ import { BaseModel } from 'src/base-model.js'; import { Log } from 'src/log.js'; +import { Folder } from 'src/models/folder.js'; import { Geolocation } from 'src/geolocation.js'; +import { folderItemFilename } from 'src/string-utils.js' +import moment from 'moment'; class Note extends BaseModel { @@ -8,6 +11,42 @@ class Note extends BaseModel { return 'notes'; } + static toFriendlyString_format(propName, propValue) { + if (['created_time', 'updated_time'].indexOf(propName) >= 0) { + if (!propValue) return ''; + propValue = moment.unix(propValue).format('YYYY-MM-DD hh:mm:ss'); + } else if (propValue === null || propValue === undefined) { + propValue = ''; + } + + return propValue; + } + + static toFriendlyString(note) { + let shownKeys = ["author", "longitude", "latitude", "is_todo", "todo_due", "todo_completed", 'created_time', 'updated_time']; + let output = []; + + output.push(note.title); + output.push(""); + output.push(note.body); + output.push(''); + for (let i = 0; i < shownKeys.length; i++) { + let v = note[shownKeys[i]]; + v = this.toFriendlyString_format(shownKeys[i], v); + output.push(shownKeys[i] + ': ' + v); + } + + return output.join("\n"); + } + + static filename(note) { + return folderItemFilename(note) + '.md'; + } + + static systemPath(parentFolder, note) { + return Folder.systemPath(null, parentFolder) + '/' + this.filename(note); + } + static useUuid() { return true; } @@ -37,13 +76,7 @@ class Note extends BaseModel { } static previews(parentId) { - return this.db().selectAll('SELECT ' + this.previewFieldsSql() + ' FROM notes WHERE parent_id = ?', [parentId]).then((r) => { - let output = []; - for (let i = 0; i < r.rows.length; i++) { - output.push(r.rows.item(i)); - } - return output; - }); + return this.db().selectAll('SELECT ' + this.previewFieldsSql() + ' FROM notes WHERE parent_id = ?', [parentId]); } static preview(noteId) { diff --git a/ReactNativeClient/src/models/setting.js b/ReactNativeClient/src/models/setting.js index 76095af67..33d84e515 100644 --- a/ReactNativeClient/src/models/setting.js +++ b/ReactNativeClient/src/models/setting.js @@ -27,10 +27,8 @@ class Setting extends BaseModel { static load() { this.cache_ = []; - return this.db().selectAll('SELECT * FROM settings').then((r) => { - for (let i = 0; i < r.rows.length; i++) { - this.cache_.push(r.rows.item(i)); - } + return this.db().selectAll('SELECT * FROM settings').then((rows) => { + this.cache_ = rows; }); } @@ -89,13 +87,13 @@ class Setting extends BaseModel { clearTimeout(this.updateTimeoutId_); this.updateTimeoutId_ = null; - return BaseModel.db().transaction((tx) => { - tx.executeSql('DELETE FROM settings'); - for (let i = 0; i < this.cache_.length; i++) { - let q = Database.insertQuery(this.tableName(), this.cache_[i]); - tx.executeSql(q.sql, q.params); - } - }).then(() => { + let queries = []; + queries.push('DELETE FROM settings'); + for (let i = 0; i < this.cache_.length; i++) { + queries.push(Database.insertQuery(this.tableName(), this.cache_[i])); + } + + return BaseModel.db().transactionExecBatch(queries).then(() => { Log.info('Settings have been saved.'); }).catch((error) => { Log.warn('Could not save settings', error); diff --git a/ReactNativeClient/src/root.js b/ReactNativeClient/src/root.js index 5d73aaa4d..be2d61719 100644 --- a/ReactNativeClient/src/root.js +++ b/ReactNativeClient/src/root.js @@ -25,6 +25,7 @@ import { MenuContext } from 'react-native-popup-menu'; import { SideMenu } from 'src/components/side-menu.js'; import { SideMenuContent } from 'src/components/side-menu-content.js'; import { NoteFolderService } from 'src/services/note-folder-service.js'; +import { DatabaseDriverReactNative } from 'src/database-driver-react-native'; let defaultState = { notes: [], @@ -89,8 +90,6 @@ const reducer = (state = defaultState, action) => { // update it within the note array if it already exists. case 'NOTES_UPDATE_ONE': - Log.info('NOITTEOJTNEONTOE', action.note); - let newNotes = state.notes.splice(0); var found = false; for (let i = 0; i < newNotes.length; i++) { @@ -191,7 +190,7 @@ const AppNavigator = StackNavigator({ class AppComponent extends React.Component { componentDidMount() { - let db = new Database(); + let db = new Database(new DatabaseDriverReactNative()); //db.setDebugEnabled(Registry.debugMode()); db.setDebugEnabled(false); @@ -199,7 +198,7 @@ class AppComponent extends React.Component { BaseModel.db_ = db; NoteFolderService.dispatch = this.props.dispatch; - db.open().then(() => { + db.open({ name: '/storage/emulated/0/Download/joplin-42.sqlite' }).then(() => { Log.info('Database is ready.'); Registry.setDb(db); }).then(() => { @@ -207,6 +206,21 @@ class AppComponent extends React.Component { return Setting.load(); }).then(() => { let user = Setting.object('user'); + + if (!user || !user.session) { + user = { + email: 'laurent@cozic.net', + session: "02d0e9ca42cbbc2d35efb1bc790b9eec", + } + Setting.setObject('user', user); + this.props.dispatch({ + type: 'USER_SET', + user: user, + }); + } + + Setting.setValue('sync.lastRevId', '123456'); + Log.info('Client ID', Setting.value('clientId')); Log.info('User', user); @@ -241,9 +255,25 @@ class AppComponent extends React.Component { // folderId: folder.id, // }); }).then(() => { - let synchronizer = new Synchronizer(db, Registry.api()); - Registry.setSynchronizer(synchronizer); - synchronizer.start(); + + var Dropbox = require('dropbox'); + var dropboxApi = new Dropbox({ accessToken: '' }); + // dbx.filesListFolder({path: '/Joplin/Laurent.4e847cc'}) + // .then(function(response) { + // //console.log('DROPBOX RESPONSE', response); + // console.log('DROPBOX RESPONSE', response.entries.length, response.has_more); + // }) + // .catch(function(error) { + // console.log('DROPBOX ERROR', error); + // }); + + // return this.api_; + + + // let synchronizer = new Synchronizer(db, Registry.api()); + // let synchronizer = new Synchronizer(db, dropboxApi); + // Registry.setSynchronizer(synchronizer); + // synchronizer.start(); }).catch((error) => { Log.error('Initialization error:', error); }); diff --git a/ReactNativeClient/src/services/note-folder-service.js b/ReactNativeClient/src/services/note-folder-service.js index 168d91869..a9099c102 100644 --- a/ReactNativeClient/src/services/note-folder-service.js +++ b/ReactNativeClient/src/services/note-folder-service.js @@ -31,21 +31,21 @@ class NoteFolderService extends BaseService { output = item; if (isNew && type == 'note') return Note.updateGeolocation(item.id); }).then(() => { - Registry.synchronizer().start(); +// Registry.synchronizer().start(); return output; }); } - static setField(type, itemId, fieldName, fieldValue, oldValue = undefined) { - // TODO: not really consistent as the promise will return 'null' while - // this.save will return the note or folder. Currently not used, and maybe not needed. - if (oldValue !== undefined && fieldValue === oldValue) return Promise.resolve(); + // static setField(type, itemId, fieldName, fieldValue, oldValue = undefined) { + // // TODO: not really consistent as the promise will return 'null' while + // // this.save will return the note or folder. Currently not used, and maybe not needed. + // if (oldValue !== undefined && fieldValue === oldValue) return Promise.resolve(); - let item = { id: itemId }; - item[fieldName] = fieldValue; - let oldItem = { id: itemId }; - return this.save(type, item, oldItem); - } + // let item = { id: itemId }; + // item[fieldName] = fieldValue; + // let oldItem = { id: itemId }; + // return this.save(type, item, oldItem); + // } static openNoteList(folderId) { return Note.previews(folderId).then((notes) => { diff --git a/ReactNativeClient/src/string-utils.js b/ReactNativeClient/src/string-utils.js new file mode 100644 index 000000000..abd4d08f2 --- /dev/null +++ b/ReactNativeClient/src/string-utils.js @@ -0,0 +1,122 @@ +function removeDiacritics(str) { + + var defaultDiacriticsRemovalMap = [ + {'base':'A', 'letters':/[\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F]/g}, + {'base':'AA','letters':/[\uA732]/g}, + {'base':'AE','letters':/[\u00C6\u01FC\u01E2]/g}, + {'base':'AO','letters':/[\uA734]/g}, + {'base':'AU','letters':/[\uA736]/g}, + {'base':'AV','letters':/[\uA738\uA73A]/g}, + {'base':'AY','letters':/[\uA73C]/g}, + {'base':'B', 'letters':/[\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181]/g}, + {'base':'C', 'letters':/[\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E]/g}, + {'base':'D', 'letters':/[\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779]/g}, + {'base':'DZ','letters':/[\u01F1\u01C4]/g}, + {'base':'Dz','letters':/[\u01F2\u01C5]/g}, + {'base':'E', 'letters':/[\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E]/g}, + {'base':'F', 'letters':/[\u0046\u24BB\uFF26\u1E1E\u0191\uA77B]/g}, + {'base':'G', 'letters':/[\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E]/g}, + {'base':'H', 'letters':/[\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D]/g}, + {'base':'I', 'letters':/[\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197]/g}, + {'base':'J', 'letters':/[\u004A\u24BF\uFF2A\u0134\u0248]/g}, + {'base':'K', 'letters':/[\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2]/g}, + {'base':'L', 'letters':/[\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780]/g}, + {'base':'LJ','letters':/[\u01C7]/g}, + {'base':'Lj','letters':/[\u01C8]/g}, + {'base':'M', 'letters':/[\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C]/g}, + {'base':'N', 'letters':/[\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4]/g}, + {'base':'NJ','letters':/[\u01CA]/g}, + {'base':'Nj','letters':/[\u01CB]/g}, + {'base':'O', 'letters':/[\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C]/g}, + {'base':'OI','letters':/[\u01A2]/g}, + {'base':'OO','letters':/[\uA74E]/g}, + {'base':'OU','letters':/[\u0222]/g}, + {'base':'P', 'letters':/[\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754]/g}, + {'base':'Q', 'letters':/[\u0051\u24C6\uFF31\uA756\uA758\u024A]/g}, + {'base':'R', 'letters':/[\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782]/g}, + {'base':'S', 'letters':/[\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784]/g}, + {'base':'T', 'letters':/[\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786]/g}, + {'base':'TZ','letters':/[\uA728]/g}, + {'base':'U', 'letters':/[\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244]/g}, + {'base':'V', 'letters':/[\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245]/g}, + {'base':'VY','letters':/[\uA760]/g}, + {'base':'W', 'letters':/[\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72]/g}, + {'base':'X', 'letters':/[\u0058\u24CD\uFF38\u1E8A\u1E8C]/g}, + {'base':'Y', 'letters':/[\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE]/g}, + {'base':'Z', 'letters':/[\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762]/g}, + {'base':'a', 'letters':/[\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u00E4\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250]/g}, + {'base':'aa','letters':/[\uA733]/g}, + {'base':'ae','letters':/[\u00E6\u01FD\u01E3]/g}, + {'base':'ao','letters':/[\uA735]/g}, + {'base':'au','letters':/[\uA737]/g}, + {'base':'av','letters':/[\uA739\uA73B]/g}, + {'base':'ay','letters':/[\uA73D]/g}, + {'base':'b', 'letters':/[\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253]/g}, + {'base':'c', 'letters':/[\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184]/g}, + {'base':'d', 'letters':/[\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A]/g}, + {'base':'dz','letters':/[\u01F3\u01C6]/g}, + {'base':'e', 'letters':/[\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD]/g}, + {'base':'f', 'letters':/[\u0066\u24D5\uFF46\u1E1F\u0192\uA77C]/g}, + {'base':'g', 'letters':/[\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F]/g}, + {'base':'h', 'letters':/[\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265]/g}, + {'base':'hv','letters':/[\u0195]/g}, + {'base':'i', 'letters':/[\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131]/g}, + {'base':'j', 'letters':/[\u006A\u24D9\uFF4A\u0135\u01F0\u0249]/g}, + {'base':'k', 'letters':/[\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3]/g}, + {'base':'l', 'letters':/[\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747]/g}, + {'base':'lj','letters':/[\u01C9]/g}, + {'base':'m', 'letters':/[\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F]/g}, + {'base':'n', 'letters':/[\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5]/g}, + {'base':'nj','letters':/[\u01CC]/g}, + {'base':'o', 'letters':/[\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u00F6\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275]/g}, + {'base':'oi','letters':/[\u01A3]/g}, + {'base':'ou','letters':/[\u0223]/g}, + {'base':'oo','letters':/[\uA74F]/g}, + {'base':'p','letters':/[\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755]/g}, + {'base':'q','letters':/[\u0071\u24E0\uFF51\u024B\uA757\uA759]/g}, + {'base':'r','letters':/[\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783]/g}, + {'base':'s','letters':/[\u0073\u24E2\uFF53\u00DF\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B]/g}, + {'base':'t','letters':/[\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787]/g}, + {'base':'tz','letters':/[\uA729]/g}, + {'base':'u','letters':/[\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u00FC\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289]/g}, + {'base':'v','letters':/[\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C]/g}, + {'base':'vy','letters':/[\uA761]/g}, + {'base':'w','letters':/[\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73]/g}, + {'base':'x','letters':/[\u0078\u24E7\uFF58\u1E8B\u1E8D]/g}, + {'base':'y','letters':/[\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF]/g}, + {'base':'z','letters':/[\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763]/g} + ]; + + for(var i=0; i|"; // In Windows + for (let i = 0; i < unsafe.length; i++) { + output = output.replace(unsafe[i], '_'); + } + + if (output.toLowerCase() == 'nul') output = 'n_l'; // For Windows... + + return output.substr(0, maxLength); +} + +function folderItemFilename(item) { + let output = escapeFilename(item.title).trim(); + if (!output.length) output = '_'; + return output + '.' + item.id.substr(0, 7); +} + +export { removeDiacritics, escapeFilename, folderItemFilename }; \ No newline at end of file diff --git a/ReactNativeClient/src/synchronizer.js b/ReactNativeClient/src/synchronizer.js index bb301a0cc..6ad2f5a91 100644 --- a/ReactNativeClient/src/synchronizer.js +++ b/ReactNativeClient/src/synchronizer.js @@ -5,6 +5,10 @@ import { Folder } from 'src/models/folder.js'; import { Note } from 'src/models/note.js'; import { BaseModel } from 'src/base-model.js'; import { promiseChain } from 'src/promise-chain.js'; +import moment from 'moment'; + +const fs = require('fs'); +const path = require('path'); class Synchronizer { @@ -26,63 +30,213 @@ class Synchronizer { return this.api_; } + loadParentAndItem(change) { + if (change.item_type == BaseModel.ITEM_TYPE_NOTE) { + return Note.load(change.item_id).then((note) => { + if (!note) return { parent:null, item: null }; + + return Folder.load(note.parent_id).then((folder) => { + return Promise.resolve({ parent: folder, item: note }); + }); + }); + } else { + return Folder.load(change.item_id).then((folder) => { + return Promise.resolve({ parent: null, item: folder }); + }); + } + } + + remoteFileByName(remoteFiles, name) { + for (let i = 0; i < remoteFiles.length; i++) { + if (remoteFiles[i].name == name) return remoteFiles[i]; + } + return null; + } + + conflictDir(remoteFiles) { + let d = this.remoteFileByName('Conflicts'); + if (!d) { + return this.api().mkdir('Conflicts').then(() => { + return 'Conflicts'; + }); + } else { + return Promise.resolve('Conflicts'); + } + } + + moveConflict(item) { + // No need to handle folder conflicts + if (item.isDir) return Promise.resolve(); + + return this.conflictDir().then((conflictDirPath) => { + let p = path.basename(item.name).split('.'); + let pos = item.isDir ? p.length - 1 : p.length - 2; + p.splice(pos, 0, moment().format('YYYYMMDDThhmmss')); + let newName = p.join('.'); + return this.api().move(item.name, conflictDirPath + '/' + newName); + }); + } + processState_uploadChanges() { - Change.all().then((changes) => { + let remoteFiles = []; + let processedChangeIds = []; + return this.api().list('', true).then((items) => { + remoteFiles = items; + return Change.all(); + }).then((changes) => { let mergedChanges = Change.mergeChanges(changes); let chain = []; - let processedChangeIds = []; for (let i = 0; i < mergedChanges.length; i++) { let c = mergedChanges[i]; chain.push(() => { let p = null; let ItemClass = null; - let path = null; if (c.item_type == BaseModel.ITEM_TYPE_FOLDER) { ItemClass = Folder; - path = 'folders'; } else if (c.item_type == BaseModel.ITEM_TYPE_NOTE) { ItemClass = Note; - path = 'notes'; } if (c.type == Change.TYPE_NOOP) { p = Promise.resolve(); } else if (c.type == Change.TYPE_CREATE) { - p = ItemClass.load(c.item_id).then((item) => { - return this.api().put(path + '/' + item.id, null, item); + p = this.loadParentAndItem(c).then((result) => { + if (!result.item) return; // Change refers to an object that doesn't exist (has probably been deleted directly in the database) + + let path = ItemClass.systemPath(result.parent, result.item); + + let remoteFile = this.remoteFileByName(remoteFiles, path); + let p = null; + if (remoteFile) { + p = this.moveConflict(remoteFile); + } else { + p = Promise.resolve(); + } + + return p.then(() => { + if (c.item_type == BaseModel.ITEM_TYPE_FOLDER) { + return this.api().mkdir(path); + } else { + return this.api().put(path, Note.toFriendlyString(result.item)); + } + }); }); - } else if (c.type == Change.TYPE_UPDATE) { - p = ItemClass.load(c.item_id).then((item) => { - return this.api().patch(path + '/' + item.id, null, item); - }); - } else if (c.type == Change.TYPE_DELETE) { - p = this.api().delete(path + '/' + c.item_id); } + // TODO: handle UPDATE + // TODO: handle DELETE + return p.then(() => { processedChangeIds = processedChangeIds.concat(c.ids); }).catch((error) => { - Log.warn('Failed applying changes', c.ids, error.message, error.type); + Log.warn('Failed applying changes', c.ids, error); // This is fine - trying to apply changes to an object that has been deleted - if (error.type == 'NotFoundException') { - processedChangeIds = processedChangeIds.concat(c.ids); - } else { - throw error; - } + // if (error.type == 'NotFoundException') { + // processedChangeIds = processedChangeIds.concat(c.ids); + // } else { + // throw error; + // } }); }); } - return promiseChain(chain).catch((error) => { - Log.warn('Synchronization was interrupted due to an error:', error); - }).then(() => { - Log.info('IDs to delete: ', processedChangeIds); - Change.deleteMultiple(processedChangeIds); - }); + return promiseChain(chain); + }).catch((error) => { + Log.warn('Synchronization was interrupted due to an error:', error); + }).then(() => { + Log.info('IDs to delete: ', processedChangeIds); + // Change.deleteMultiple(processedChangeIds); }).then(() => { this.processState('downloadChanges'); }); + + + // }).then(() => { + // return Change.all(); + // }).then((changes) => { + // let mergedChanges = Change.mergeChanges(changes); + // let chain = []; + // let processedChangeIds = []; + // for (let i = 0; i < mergedChanges.length; i++) { + // let c = mergedChanges[i]; + // chain.push(() => { + // let p = null; + + // let ItemClass = null; + // let path = null; + // if (c.item_type == BaseModel.ITEM_TYPE_FOLDER) { + // ItemClass = Folder; + // path = 'folders'; + // } else if (c.item_type == BaseModel.ITEM_TYPE_NOTE) { + // ItemClass = Note; + // path = 'notes'; + // } + + // if (c.type == Change.TYPE_NOOP) { + // p = Promise.resolve(); + // } else if (c.type == Change.TYPE_CREATE) { + // p = this.loadParentAndItem(c).then((result) => { + // // let options = { + // // contents: Note.toFriendlyString(result.item), + // // path: Note.systemPath(result.parent, result.item), + // // mode: 'overwrite', + // // // client_modified: + // // }; + + // // return this.api().filesUpload(options).then((result) => { + // // console.info('DROPBOX', result); + // // }); + // }); + // // p = ItemClass.load(c.item_id).then((item) => { + + // // console.info(item); + // // let options = { + // // contents: Note.toFriendlyString(item), + // // path: Note.systemPath(item), + // // mode: 'overwrite', + // // // client_modified: + // // }; + + // // // console.info(options); + + // // //let content = Note.toFriendlyString(item); + // // //console.info(content); + + // // //console.info('SYNC', item); + // // //return this.api().put(path + '/' + item.id, null, item); + // // }); + // } else if (c.type == Change.TYPE_UPDATE) { + // p = ItemClass.load(c.item_id).then((item) => { + // //return this.api().patch(path + '/' + item.id, null, item); + // }); + // } else if (c.type == Change.TYPE_DELETE) { + // p = this.api().delete(path + '/' + c.item_id); + // } + + // return p.then(() => { + // processedChangeIds = processedChangeIds.concat(c.ids); + // }).catch((error) => { + // // Log.warn('Failed applying changes', c.ids, error.message, error.type); + // // This is fine - trying to apply changes to an object that has been deleted + // if (error.type == 'NotFoundException') { + // processedChangeIds = processedChangeIds.concat(c.ids); + // } else { + // throw error; + // } + // }); + // }); + // } + + // return promiseChain(chain).catch((error) => { + // Log.warn('Synchronization was interrupted due to an error:', error); + // }).then(() => { + // // Log.info('IDs to delete: ', processedChangeIds); + // // Change.deleteMultiple(processedChangeIds); + // }).then(() => { + // this.processState('downloadChanges'); + // }); + // }); } processState_downloadChanges() { @@ -150,9 +304,10 @@ class Synchronizer { this.state_ = state; if (state == 'uploadChanges') { - processState_uploadChanges(); + return this.processState_uploadChanges(); } else if (state == 'downloadChanges') { - processState_downloadChanges(); + return this.processState('idle'); + //this.processState_downloadChanges(); } else if (state == 'idle') { // Nothing } else { @@ -168,12 +323,12 @@ class Synchronizer { return; } - if (!this.api().session()) { - Log.info("Sync: cannot start synchronizer because user is not logged in."); - return; - } + // if (!this.api().session()) { + // Log.info("Sync: cannot start synchronizer because user is not logged in."); + // return; + // } - this.processState('uploadChanges'); + return this.processState('uploadChanges'); } } diff --git a/ReactNativeClient/src/synchronizer_old.js b/ReactNativeClient/src/synchronizer_old.js new file mode 100644 index 000000000..61a1d0bf2 --- /dev/null +++ b/ReactNativeClient/src/synchronizer_old.js @@ -0,0 +1,224 @@ +import { Log } from 'src/log.js'; +import { Setting } from 'src/models/setting.js'; +import { Change } from 'src/models/change.js'; +import { Folder } from 'src/models/folder.js'; +import { Note } from 'src/models/note.js'; +import { BaseModel } from 'src/base-model.js'; +import { promiseChain } from 'src/promise-chain.js'; + +class Synchronizer { + + constructor(db, api) { + this.state_ = 'idle'; + this.db_ = db; + this.api_ = api; + } + + state() { + return this.state_; + } + + db() { + return this.db_; + } + + api() { + return this.api_; + } + + loadParentAndItem(change) { + if (change.item_type == BaseModel.ITEM_TYPE_NOTE) { + return Note.load(change.item_id).then((note) => { + return Folder.load(note.parent_id).then((folder) => { + console.info('xxxxxxxxx',note); + return Promise.resolve({ parent: folder, item: note }); + }); + }); + } else { + return Folder.load(change.item_id).then((folder) => { + return Promise.resolve({ parent: null, item: folder }); + }); + } + } + + processState_uploadChanges() { + Change.all().then((changes) => { + let mergedChanges = Change.mergeChanges(changes); + let chain = []; + let processedChangeIds = []; + for (let i = 0; i < mergedChanges.length; i++) { + let c = mergedChanges[i]; + chain.push(() => { + let p = null; + + let ItemClass = null; + let path = null; + if (c.item_type == BaseModel.ITEM_TYPE_FOLDER) { + ItemClass = Folder; + path = 'folders'; + } else if (c.item_type == BaseModel.ITEM_TYPE_NOTE) { + ItemClass = Note; + path = 'notes'; + } + + if (c.type == Change.TYPE_NOOP) { + p = Promise.resolve(); + } else if (c.type == Change.TYPE_CREATE) { + p = this.loadParentAndItem(c).then((result) => { + let options = { + contents: Note.toFriendlyString(result.item), + path: Note.systemPath(result.parent, result.item), + mode: 'overwrite', + // client_modified: + }; + + return this.api().filesUpload(options).then((result) => { + console.info('DROPBOX', result); + }); + }); + // p = ItemClass.load(c.item_id).then((item) => { + + // console.info(item); + // let options = { + // contents: Note.toFriendlyString(item), + // path: Note.systemPath(item), + // mode: 'overwrite', + // // client_modified: + // }; + + // // console.info(options); + + // //let content = Note.toFriendlyString(item); + // //console.info(content); + + // //console.info('SYNC', item); + // //return this.api().put(path + '/' + item.id, null, item); + // }); + } else if (c.type == Change.TYPE_UPDATE) { + p = ItemClass.load(c.item_id).then((item) => { + //return this.api().patch(path + '/' + item.id, null, item); + }); + } else if (c.type == Change.TYPE_DELETE) { + p = this.api().delete(path + '/' + c.item_id); + } + + return p.then(() => { + processedChangeIds = processedChangeIds.concat(c.ids); + }).catch((error) => { + // Log.warn('Failed applying changes', c.ids, error.message, error.type); + // This is fine - trying to apply changes to an object that has been deleted + if (error.type == 'NotFoundException') { + processedChangeIds = processedChangeIds.concat(c.ids); + } else { + throw error; + } + }); + }); + } + + return promiseChain(chain).catch((error) => { + Log.warn('Synchronization was interrupted due to an error:', error); + }).then(() => { + // Log.info('IDs to delete: ', processedChangeIds); + // Change.deleteMultiple(processedChangeIds); + }); + }).then(() => { + this.processState('downloadChanges'); + }); + } + + processState_downloadChanges() { + let maxRevId = null; + let hasMore = false; + this.api().get('synchronizer', { rev_id: Setting.value('sync.lastRevId') }).then((syncOperations) => { + hasMore = syncOperations.has_more; + let chain = []; + for (let i = 0; i < syncOperations.items.length; i++) { + let syncOp = syncOperations.items[i]; + if (syncOp.id > maxRevId) maxRevId = syncOp.id; + + let ItemClass = null; + if (syncOp.item_type == 'folder') { + ItemClass = Folder; + } else if (syncOp.item_type == 'note') { + ItemClass = Note; + } + + if (syncOp.type == 'create') { + chain.push(() => { + let item = ItemClass.fromApiResult(syncOp.item); + // TODO: automatically handle NULL fields by checking type and default value of field + if ('parent_id' in item && !item.parent_id) item.parent_id = ''; + return ItemClass.save(item, { isNew: true, trackChanges: false }); + }); + } + + if (syncOp.type == 'update') { + chain.push(() => { + return ItemClass.load(syncOp.item_id).then((item) => { + if (!item) return; + item = ItemClass.applyPatch(item, syncOp.item); + return ItemClass.save(item, { trackChanges: false }); + }); + }); + } + + if (syncOp.type == 'delete') { + chain.push(() => { + return ItemClass.delete(syncOp.item_id, { trackChanges: false }); + }); + } + } + return promiseChain(chain); + }).then(() => { + Log.info('All items synced. has_more = ', hasMore); + if (maxRevId) { + Setting.setValue('sync.lastRevId', maxRevId); + return Setting.saveAll(); + } + }).then(() => { + if (hasMore) { + this.processState('downloadChanges'); + } else { + this.processState('idle'); + } + }).catch((error) => { + Log.warn('Sync error', error); + }); + } + + processState(state) { + Log.info('Sync: processing: ' + state); + this.state_ = state; + + if (state == 'uploadChanges') { + this.processState_uploadChanges(); + } else if (state == 'downloadChanges') { + this.processState('idle'); + //this.processState_downloadChanges(); + } else if (state == 'idle') { + // Nothing + } else { + throw new Error('Invalid state: ' . state); + } + } + + start() { + Log.info('Sync: start'); + + if (this.state() != 'idle') { + Log.info("Sync: cannot start synchronizer because synchronization already in progress. State: " + this.state()); + return; + } + + // if (!this.api().session()) { + // Log.info("Sync: cannot start synchronizer because user is not logged in."); + // return; + // } + + this.processState('uploadChanges'); + } + +} + +export { Synchronizer }; \ No newline at end of file diff --git a/ReactNativeClient/src/web-api.js b/ReactNativeClient/src/web-api.js index f0b3899e8..53b92d560 100644 --- a/ReactNativeClient/src/web-api.js +++ b/ReactNativeClient/src/web-api.js @@ -3,6 +3,7 @@ import { isNode } from 'src/env.js'; import { stringify } from 'query-string'; if (isNode()) { + // TODO: doesn't work in React-Native - FormData gets set to "undefined" // Needs to be in a variable otherwise ReactNative will try to load this module (and fails due to // missing node modules), even if isNode() is false. let modulePath = 'src/shim.js'; @@ -29,6 +30,7 @@ class WebApi { constructor(baseUrl) { this.baseUrl_ = baseUrl; this.session_ = null; + this.retryInterval_ = 500; } setSession(v) { @@ -88,42 +90,62 @@ class WebApi { return cmd.join(' '); } - exec(method, path, query, data) { + delay(milliseconds) { return new Promise((resolve, reject) => { - if (this.session_) { - query = query ? Object.assign({}, query) : {}; - if (!query.session) query.session = this.session_; + setTimeout(() => { + resolve(); + }, milliseconds); + }); + } + + exec(method, path, query, data) { + return this.execNoRetry(method, path, query, data).then((data) => { + this.retryInterval_ = 500; + return data; + }).catch((error) => { + if (error.errno == 'ECONNRESET') { + this.retryInterval_ += 500; + console.warn('Got error ' + error.errno + '. Retrying in ' + this.retryInterval_); + return this.delay(this.retryInterval_).then(() => { + return this.exec(method, path, query, data); + }); + } else { + this.retryInterval_ = 500; + reject(error); + } + }); + } + + execNoRetry(method, path, query, data) { + if (this.session_) { + query = query ? Object.assign({}, query) : {}; + if (!query.session) query.session = this.session_; + } + + let r = this.makeRequest(method, path, query, data); + + Log.debug(WebApi.toCurl(r, data)); + //console.info(WebApi.toCurl(r, data)); + + return fetch(r.url, r.options).then((response) => { + let responseClone = response.clone(); + + if (!response.ok) { + return responseClone.text().then((text) => { + throw new WebApiError('HTTP ' + response.status + ': ' + response.statusText + ': ' + text); + }); } - let r = this.makeRequest(method, path, query, data); - - //Log.debug(WebApi.toCurl(r, data)); - //console.info(WebApi.toCurl(r, data)); - - fetch(r.url, r.options).then(function(response) { - let responseClone = response.clone(); - - if (!response.ok) { - return responseClone.text().then(function(text) { - reject(new WebApiError('HTTP ' + response.status + ': ' + response.statusText + ': ' + text)); - }); + return response.json().then((data) => { + if (data && data.error) { + throw new WebApiError(data); + } else { + return data; } - - return response.json().then(function(data) { - if (data && data.error) { - reject(new WebApiError(data)); - } else { - resolve(data); - } - }).catch(function(error) { - responseClone.text().then(function(text) { - reject(new WebApiError('Cannot parse JSON: ' + text)); - }); + }).catch((error) => { + return responseClone.text().then((text) => { + throw new WebApiError('Cannot parse JSON: ' + text); }); - }).then(function(data) { - resolve(data); - }).catch(function(error) { - reject(error); }); }); } diff --git a/composer.json b/composer.json index f1052a1f0..142239270 100755 --- a/composer.json +++ b/composer.json @@ -28,7 +28,6 @@ "sensio/distribution-bundle": "^5.0", "sensio/framework-extra-bundle": "^3.0.2", "incenteev/composer-parameter-handler": "^2.0", - "illuminate/database": "*", "yetanotherape/diff-match-patch": "*" }, diff --git a/src/AppBundle/Controller/SynchronizerController.php b/src/AppBundle/Controller/SynchronizerController.php index 4e16962a6..6b3be0414 100755 --- a/src/AppBundle/Controller/SynchronizerController.php +++ b/src/AppBundle/Controller/SynchronizerController.php @@ -15,11 +15,13 @@ class SynchronizerController extends ApiController { * @Route("/synchronizer") */ public function allAction(Request $request) { - $lastChangeId = (int)$request->query->get('rev_id'); - if (!$this->user() || !$this->session()) throw new UnauthorizedException(); - $actions = Change::changesDoneAfterId($this->user()->id, $this->session()->client_id, $lastChangeId); + $lastChangeId = (int)$request->query->get('rev_id'); + $limit = (int)$request->query->get('limit'); + //$curl 'http://192.168.1.3/synchronizer?rev_id=6973&session=02d0e9ca42cbbc2d35efb1bc790b9eec' + + $actions = Change::changesDoneAfterId($this->user()->id, $this->session()->client_id, $lastChangeId, $limit); return static::successResponse($actions); } diff --git a/src/AppBundle/Diff.php b/src/AppBundle/Diff.php index 6b3db757f..94be9ed36 100755 --- a/src/AppBundle/Diff.php +++ b/src/AppBundle/Diff.php @@ -14,11 +14,26 @@ class Diff { return self::$dmp_; } + // Temporary fix to go around diffmatchpach bug: + // https://github.com/yetanotherape/diff-match-patch/issues/9 + static private function encodingFix($s) { + return $s; + return iconv('UTF-8', 'ISO-8859-1//IGNORE', $s); + } + + static public function decodeFix($s) { + return $s; + return iconv('ISO-8859-1', 'UTF-8', $s); + } + static public function diff($from, $to) { + $from = self::encodingFix($from); + $to = self::encodingFix($to); return self::dmp()->patch_toText(self::dmp()->patch_make($from, $to)); } static public function patch($from, $diff) { + $from = self::encodingFix($from); return self::dmp()->patch_apply(self::dmp()->patch_fromText($diff), $from); } diff --git a/src/AppBundle/Model/Change.php b/src/AppBundle/Model/Change.php index f8e3f32a5..93b6d5a20 100755 --- a/src/AppBundle/Model/Change.php +++ b/src/AppBundle/Model/Change.php @@ -10,7 +10,7 @@ class Change extends BaseModel { 'type' => array('create', 'update', 'delete'), ); - static public function changesDoneAfterId($userId, $clientId, $fromChangeId) { + static public function changesDoneAfterId($userId, $clientId, $fromChangeId, $limit = null) { // Simplification: // // - If create, update, delete => return nothing @@ -19,7 +19,8 @@ class Change extends BaseModel { // - If update, update, update => return last // $limit = 10000; - $limit = 100; + if ((int)$limit <= 0) $limit = 100; + if ($limit > 1000) $limit = 1000; $changes = self::where('id', '>', $fromChangeId) ->where('user_id', '=', $userId) ->where('client_id', '!=', $clientId) diff --git a/tests/setup.php b/tests/setup.php index afcdeed6e..9b5d4d630 100755 --- a/tests/setup.php +++ b/tests/setup.php @@ -6,6 +6,19 @@ require_once dirname(__FILE__) . '/TestUtils.php'; require_once dirname(__FILE__) . '/BaseTestCase.php'; require_once dirname(__FILE__) . '/BaseControllerTestCase.php'; + + +// use DiffMatchPatch\DiffMatchPatch; +// $dmp = new DiffMatchPatch(); +// $diff = $dmp->patch_make('car', 'car 🚘'); +// var_dump($dmp->patch_toText($diff)); +// var_dump($dmp->patch_apply($diff, 'car')); + +// //$dmp->patch_toText($dmp->patch_make($from, $to)); +// die(); + + + $dbConfig = array( 'dbName' => 'notes_test', 'user' => 'root', diff --git a/web/app.php b/web/app.php index 975c7ca62..0b122e38f 100755 --- a/web/app.php +++ b/web/app.php @@ -31,6 +31,7 @@ try { 'error' => $e->getMessage(), 'code' => $e->getCode(), 'type' => $errorType, + 'trace' => $e->getTraceAsString(), ); if ($errorType == 'NotFoundHttpException') { header('HTTP/1.1 404 Not found');