You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-30 20:39:46 +02:00
Compare commits
25 Commits
android-v1
...
android-v1
Author | SHA1 | Date | |
---|---|---|---|
|
5d1a08707c | ||
|
4f822df80e | ||
|
951be5cbf6 | ||
|
b6c2341542 | ||
|
a6e6b49a9d | ||
|
3a4bbd571e | ||
|
feccc6150e | ||
|
a37b599a6b | ||
|
9347683fe3 | ||
|
3551c26e28 | ||
|
cfca0107eb | ||
|
81bc975193 | ||
|
7908fda451 | ||
|
cdbb7c4b0d | ||
|
414e57ec55 | ||
|
1871123066 | ||
|
87bc08bef5 | ||
|
214a39c3d3 | ||
|
ef0cc5e33e | ||
|
3a1fa583ab | ||
|
c1161ae017 | ||
|
1023ec6206 | ||
|
7841421c0d | ||
|
995d8c35dd | ||
|
b179471eff |
2
CliClient/package-lock.json
generated
2
CliClient/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "joplin",
|
||||
"version": "0.10.93",
|
||||
"version": "1.0.95",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "1.0.93",
|
||||
"version": "1.0.95",
|
||||
"bin": {
|
||||
"joplin": "./main.js"
|
||||
},
|
||||
|
@@ -19,7 +19,7 @@ process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000; // The first test is slow because the database needs to be built
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000 + 30000; // The first test is slow because the database needs to be built
|
||||
|
||||
async function allItems() {
|
||||
let folders = await Folder.all();
|
||||
@@ -60,6 +60,7 @@ async function allSyncTargetItemsEncrypted() {
|
||||
}
|
||||
|
||||
async function localItemsSameAsRemote(locals, expect) {
|
||||
let error = null;
|
||||
try {
|
||||
let files = await fileApi().list();
|
||||
files = files.items;
|
||||
@@ -81,12 +82,15 @@ async function localItemsSameAsRemote(locals, expect) {
|
||||
// }
|
||||
|
||||
let remoteContent = await fileApi().get(path);
|
||||
|
||||
remoteContent = dbItem.type_ == BaseModel.TYPE_NOTE ? await Note.unserialize(remoteContent) : await Folder.unserialize(remoteContent);
|
||||
expect(remoteContent.title).toBe(dbItem.title);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBe(null);
|
||||
}
|
||||
|
||||
let insideBeforeEach = false;
|
||||
@@ -985,4 +989,14 @@ describe('Synchronizer', function() {
|
||||
expect(resource1.encryption_blob_encrypted).toBe(0);
|
||||
}));
|
||||
|
||||
it('should create remote items with UTF-8 content', asyncTest(async () => {
|
||||
let folder = await Folder.save({ title: "Fahrräder" });
|
||||
await Note.save({ title: "Fahrräder", body: "Fahrräder", parent_id: folder.id });
|
||||
let all = await allItems();
|
||||
|
||||
await synchronizer().start();
|
||||
|
||||
await localItemsSameAsRemote(all, expect);
|
||||
}));
|
||||
|
||||
});
|
@@ -56,7 +56,7 @@ const syncTargetId_ = SyncTargetRegistry.nameToId('nextcloud');
|
||||
//const syncTargetId_ = SyncTargetRegistry.nameToId('filesystem');
|
||||
const syncDir = __dirname + '/../tests/sync';
|
||||
|
||||
const sleepTime = syncTargetId_ == SyncTargetRegistry.nameToId('filesystem') ? 1001 : 10;//400;
|
||||
const sleepTime = syncTargetId_ == SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;//400;
|
||||
|
||||
console.info('Testing with sync target: ' + SyncTargetRegistry.idToName(syncTargetId_));
|
||||
|
||||
@@ -75,6 +75,8 @@ BaseItem.loadClass('MasterKey', MasterKey);
|
||||
Setting.setConstant('appId', 'net.cozic.joplin-cli');
|
||||
Setting.setConstant('appType', 'cli');
|
||||
|
||||
Setting.autoSaveEnabled = false;
|
||||
|
||||
function syncTargetId() {
|
||||
return syncTargetId_;
|
||||
}
|
||||
@@ -262,6 +264,7 @@ function fileApi() {
|
||||
|
||||
fileApi_.setLogger(logger);
|
||||
fileApi_.setSyncTargetId(syncTargetId_);
|
||||
fileApi_.requestRepeatCount_ = 0;
|
||||
return fileApi_;
|
||||
}
|
||||
|
||||
|
@@ -239,17 +239,17 @@ class Application extends BaseApplication {
|
||||
label: _('Edit'),
|
||||
submenu: [{
|
||||
label: _('Copy'),
|
||||
screens: ['Main', 'OneDriveLogin'],
|
||||
screens: ['Main', 'OneDriveLogin', 'Config', 'EncryptionConfig'],
|
||||
role: 'copy',
|
||||
accelerator: 'CommandOrControl+C',
|
||||
}, {
|
||||
label: _('Cut'),
|
||||
screens: ['Main', 'OneDriveLogin'],
|
||||
screens: ['Main', 'OneDriveLogin', 'Config', 'EncryptionConfig'],
|
||||
role: 'cut',
|
||||
accelerator: 'CommandOrControl+X',
|
||||
}, {
|
||||
label: _('Paste'),
|
||||
screens: ['Main', 'OneDriveLogin'],
|
||||
screens: ['Main', 'OneDriveLogin', 'Config', 'EncryptionConfig'],
|
||||
role: 'paste',
|
||||
accelerator: 'CommandOrControl+V',
|
||||
}, {
|
||||
@@ -305,7 +305,7 @@ class Application extends BaseApplication {
|
||||
}, {
|
||||
label: _('Check for updates...'),
|
||||
click: () => {
|
||||
bridge().checkForUpdates(false, this.checkForUpdateLoggerPath());
|
||||
bridge().checkForUpdates(false, bridge().window(), this.checkForUpdateLoggerPath());
|
||||
}
|
||||
}, {
|
||||
label: _('About Joplin'),
|
||||
@@ -422,13 +422,14 @@ class Application extends BaseApplication {
|
||||
if (shim.isWindows() || shim.isMac()) {
|
||||
const runAutoUpdateCheck = () => {
|
||||
if (Setting.value('autoUpdateEnabled')) {
|
||||
bridge().checkForUpdates(true, this.checkForUpdateLoggerPath());
|
||||
bridge().checkForUpdates(true, bridge().window(), this.checkForUpdateLoggerPath());
|
||||
}
|
||||
}
|
||||
|
||||
// Initial check on startup
|
||||
setTimeout(() => { runAutoUpdateCheck() }, 5000);
|
||||
// For those who leave the app always open
|
||||
setInterval(() => { runAutoUpdateCheck() }, 2 * 60 * 60 * 1000);
|
||||
// Then every x hours
|
||||
setInterval(() => { runAutoUpdateCheck() }, 12 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
this.updateTray();
|
||||
|
@@ -43,7 +43,7 @@ class Bridge {
|
||||
const {dialog} = require('electron');
|
||||
if (!options) options = {};
|
||||
if (!('defaultPath' in options) && this.lastSelectedPath_) options.defaultPath = this.lastSelectedPath_;
|
||||
const filePath = dialog.showSaveDialog(options);
|
||||
const filePath = dialog.showSaveDialog(this.window(), options);
|
||||
if (filePath) {
|
||||
this.lastSelectedPath_ = filePath;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ class Bridge {
|
||||
if (!options) options = {};
|
||||
if (!('defaultPath' in options) && this.lastSelectedPath_) options.defaultPath = this.lastSelectedPath_;
|
||||
if (!('createDirectory' in options)) options.createDirectory = true;
|
||||
const filePaths = dialog.showOpenDialog(options);
|
||||
const filePaths = dialog.showOpenDialog(this.window(), options);
|
||||
if (filePaths && filePaths.length) {
|
||||
this.lastSelectedPath_ = dirname(filePaths[0]);
|
||||
}
|
||||
@@ -64,18 +64,18 @@ class Bridge {
|
||||
|
||||
showMessageBox(options) {
|
||||
const {dialog} = require('electron');
|
||||
return dialog.showMessageBox(options);
|
||||
return dialog.showMessageBox(this.window(), options);
|
||||
}
|
||||
|
||||
showErrorMessageBox(message) {
|
||||
return this.showMessageBox({
|
||||
return this.showMessageBox(this.window(), {
|
||||
type: 'error',
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
|
||||
showConfirmMessageBox(message) {
|
||||
const result = this.showMessageBox({
|
||||
const result = this.showMessageBox(this.window(), {
|
||||
type: 'question',
|
||||
message: message,
|
||||
buttons: [_('OK'), _('Cancel')],
|
||||
@@ -84,7 +84,7 @@ class Bridge {
|
||||
}
|
||||
|
||||
showInfoMessageBox(message) {
|
||||
const result = this.showMessageBox({
|
||||
const result = this.showMessageBox(this.window(), {
|
||||
type: 'info',
|
||||
message: message,
|
||||
buttons: [_('OK')],
|
||||
@@ -108,26 +108,26 @@ class Bridge {
|
||||
return require('electron').shell.openItem(fullPath)
|
||||
}
|
||||
|
||||
async checkForUpdatesAndNotify(logFilePath) {
|
||||
if (!this.autoUpdater_) {
|
||||
this.autoUpdateLogger_ = new Logger();
|
||||
this.autoUpdateLogger_.addTarget('file', { path: logFilePath });
|
||||
this.autoUpdateLogger_.setLevel(Logger.LEVEL_DEBUG);
|
||||
this.autoUpdateLogger_.info('checkForUpdatesAndNotify: Initializing...');
|
||||
this.autoUpdater_ = require("electron-updater").autoUpdater;
|
||||
this.autoUpdater_.logger = this.autoUpdateLogger_;
|
||||
}
|
||||
// async checkForUpdatesAndNotify(logFilePath) {
|
||||
// if (!this.autoUpdater_) {
|
||||
// this.autoUpdateLogger_ = new Logger();
|
||||
// this.autoUpdateLogger_.addTarget('file', { path: logFilePath });
|
||||
// this.autoUpdateLogger_.setLevel(Logger.LEVEL_DEBUG);
|
||||
// this.autoUpdateLogger_.info('checkForUpdatesAndNotify: Initializing...');
|
||||
// this.autoUpdater_ = require("electron-updater").autoUpdater;
|
||||
// this.autoUpdater_.logger = this.autoUpdateLogger_;
|
||||
// }
|
||||
|
||||
try {
|
||||
await this.autoUpdater_.checkForUpdatesAndNotify();
|
||||
} catch (error) {
|
||||
this.autoUpdateLogger_.error(error);
|
||||
}
|
||||
}
|
||||
// try {
|
||||
// await this.autoUpdater_.checkForUpdatesAndNotify();
|
||||
// } catch (error) {
|
||||
// this.autoUpdateLogger_.error(error);
|
||||
// }
|
||||
// }
|
||||
|
||||
checkForUpdates(inBackground, logFilePath) {
|
||||
checkForUpdates(inBackground, window, logFilePath) {
|
||||
const { checkForUpdates } = require('./checkForUpdates.js');
|
||||
checkForUpdates(inBackground, logFilePath);
|
||||
checkForUpdates(inBackground, window, logFilePath);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -5,18 +5,14 @@ const { _ } = require('lib/locale.js');
|
||||
|
||||
let autoUpdateLogger_ = new Logger();
|
||||
let checkInBackground_ = false;
|
||||
let isCheckingForUpdate_ = false;
|
||||
let parentWindow_ = null;
|
||||
|
||||
// Note: Electron Builder's autoUpdater is incredibly buggy so currently it's only used
|
||||
// to detect if a new version is present. If it is, the download link is simply opened
|
||||
// in a new browser window.
|
||||
autoUpdater.autoDownload = false;
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
autoUpdateLogger_.error(error);
|
||||
if (checkInBackground_) return;
|
||||
dialog.showErrorBox(_('Error'), error == null ? "unknown" : (error.stack || error).toString())
|
||||
})
|
||||
|
||||
function htmlToText_(html) {
|
||||
let output = html.replace(/\n/g, '');
|
||||
output = output.replace(/<li>/g, '- ');
|
||||
@@ -28,11 +24,35 @@ function htmlToText_(html) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function showErrorMessageBox(message) {
|
||||
return dialog.showMessageBox(parentWindow_, {
|
||||
type: 'error',
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
|
||||
function onCheckStarted() {
|
||||
autoUpdateLogger_.info('checkForUpdates: Starting...');
|
||||
isCheckingForUpdate_ = true;
|
||||
}
|
||||
|
||||
function onCheckEnded() {
|
||||
autoUpdateLogger_.info('checkForUpdates: Done.');
|
||||
isCheckingForUpdate_ = false;
|
||||
}
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
autoUpdateLogger_.error(error);
|
||||
if (checkInBackground_) return onCheckEnded();
|
||||
showErrorMessageBox(error == null ? "unknown" : (error.stack || error).toString())
|
||||
onCheckEnded();
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
if (!info.version || !info.path) {
|
||||
if (checkInBackground_) return;
|
||||
dialog.showErrorBox(_('Error'), ('Could not get version info: ' + JSON.stringify(info)));
|
||||
return;
|
||||
if (checkInBackground_) return onCheckEnded();
|
||||
showErrorMessageBox(('Could not get version info: ' + JSON.stringify(info)));
|
||||
return onCheckEnded();
|
||||
}
|
||||
|
||||
const downloadUrl = 'https://github.com/laurent22/joplin/releases/download/v' + info.version + '/' + info.path;
|
||||
@@ -40,37 +60,33 @@ autoUpdater.on('update-available', (info) => {
|
||||
let releaseNotes = info.releaseNotes + '';
|
||||
if (releaseNotes) releaseNotes = '\n\n' + _('Release notes:\n\n%s', htmlToText_(releaseNotes));
|
||||
|
||||
dialog.showMessageBox({
|
||||
const buttonIndex = dialog.showMessageBox(parentWindow_, {
|
||||
type: 'info',
|
||||
message: _('An update is available, do you want to download it now?' + releaseNotes),
|
||||
buttons: [_('Yes'), _('No')]
|
||||
}, (buttonIndex) => {
|
||||
if (buttonIndex === 0) {
|
||||
require('electron').shell.openExternal(downloadUrl);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onCheckEnded();
|
||||
|
||||
if (buttonIndex === 0) require('electron').shell.openExternal(downloadUrl);
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
if (checkInBackground_) return;
|
||||
|
||||
if (checkInBackground_) return onCheckEnded();
|
||||
dialog.showMessageBox({ message: _('Current version is up-to-date.') })
|
||||
onCheckEnded();
|
||||
})
|
||||
|
||||
// autoUpdater.on('update-downloaded', () => {
|
||||
// dialog.showMessageBox({ message: _('New version downloaded - application will quit now and update...') }, () => {
|
||||
// setTimeout(() => {
|
||||
// try {
|
||||
// autoUpdater.quitAndInstall();
|
||||
// } catch (error) {
|
||||
// autoUpdateLogger_.error(error);
|
||||
// dialog.showErrorBox(_('Error'), _('Could not install the update: %s', error.message));
|
||||
// }
|
||||
// }, 100);
|
||||
// })
|
||||
// })
|
||||
function checkForUpdates(inBackground, window, logFilePath) {
|
||||
if (isCheckingForUpdate_) {
|
||||
autoUpdateLogger_.info('checkForUpdates: Skipping check because it is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
parentWindow_ = window;
|
||||
|
||||
onCheckStarted();
|
||||
|
||||
function checkForUpdates(inBackground, logFilePath) {
|
||||
if (logFilePath && !autoUpdateLogger_.targets().length) {
|
||||
autoUpdateLogger_ = new Logger();
|
||||
autoUpdateLogger_.addTarget('file', { path: logFilePath });
|
||||
@@ -85,7 +101,8 @@ function checkForUpdates(inBackground, logFilePath) {
|
||||
autoUpdater.checkForUpdates()
|
||||
} catch (error) {
|
||||
autoUpdateLogger_.error(error);
|
||||
if (!checkInBackground_) dialog.showErrorBox(_('Error'), error.message);
|
||||
if (!checkInBackground_) showErrorMessageBox(error.message);
|
||||
onCheckEnded();
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -15,10 +15,6 @@ class ConfigScreenComponent extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
settings: {},
|
||||
};
|
||||
|
||||
shared.init(this);
|
||||
|
||||
this.checkSyncConfig_ = async () => {
|
||||
@@ -68,9 +64,7 @@ class ConfigScreenComponent extends React.Component {
|
||||
};
|
||||
|
||||
const updateSettingValue = (key, value) => {
|
||||
const settings = Object.assign({}, this.state.settings);
|
||||
settings[key] = Setting.formatValue(key, value);
|
||||
this.setState({ settings: settings });
|
||||
return shared.updateSettingValue(this, key, value);
|
||||
}
|
||||
|
||||
// Component key needs to be key+value otherwise it doesn't update when the settings change.
|
||||
@@ -142,10 +136,7 @@ class ConfigScreenComponent extends React.Component {
|
||||
}
|
||||
|
||||
onSaveClick() {
|
||||
for (let n in this.state.settings) {
|
||||
if (!this.state.settings.hasOwnProperty(n)) continue;
|
||||
Setting.setValue(n, this.state.settings[n]);
|
||||
}
|
||||
shared.saveSettings(this);
|
||||
this.props.dispatch({ type: 'NAV_BACK' });
|
||||
}
|
||||
|
||||
@@ -167,24 +158,11 @@ class ConfigScreenComponent extends React.Component {
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
display: this.state.settings === this.props.settings ? 'none' : 'inline-block',
|
||||
display: this.state.changedSettingKeys.length ? 'inline-block' : 'none',
|
||||
marginRight: 10,
|
||||
}
|
||||
|
||||
let settingComps = [];
|
||||
let keys = Setting.keys(true, 'desktop');
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
if (!(key in settings)) {
|
||||
console.warn('Missing setting: ' + key);
|
||||
continue;
|
||||
}
|
||||
const md = Setting.settingMetadata(key);
|
||||
if (md.show && !md.show(settings)) continue;
|
||||
const comp = this.settingToComponent(key, settings[key]);
|
||||
if (!comp) continue;
|
||||
settingComps.push(comp);
|
||||
}
|
||||
const settingComps = shared.settingsToComponents(this, 'desktop', settings);
|
||||
|
||||
const syncTargetMd = SyncTargetRegistry.idToMetadata(settings['sync.target']);
|
||||
|
||||
|
@@ -25,9 +25,7 @@ class ImportScreenComponent extends React.Component {
|
||||
doImport: true,
|
||||
filePath: newProps.filePath,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
this.doImport();
|
||||
}, () => { this.doImport() });
|
||||
}
|
||||
}
|
||||
|
||||
|
2
ElectronClient/app/package-lock.json
generated
2
ElectronClient/app/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Joplin",
|
||||
"version": "0.10.61",
|
||||
"version": "1.0.63",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Joplin",
|
||||
"version": "1.0.61",
|
||||
"version": "1.0.63",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
@@ -79,6 +79,10 @@ async function main(argv) {
|
||||
const macOsUrl = downloadUrl(release, 'macos');
|
||||
const linuxUrl = downloadUrl(release, 'linux');
|
||||
|
||||
console.info('Windows: ', winUrl);
|
||||
console.info('macOS: ', macOsUrl);
|
||||
console.info('Linux: ', linuxUrl);
|
||||
|
||||
let content = readmeContent();
|
||||
|
||||
if (winUrl) content = content.replace(/(https:\/\/github.com\/laurent22\/joplin\/releases\/download\/.*?\.exe)/, winUrl);
|
||||
|
25
README.md
25
README.md
@@ -18,15 +18,15 @@ Three types of applications are available: for the **desktop** (Windows, macOS a
|
||||
|
||||
Operating System | Download
|
||||
-----------------|--------
|
||||
Windows | <a href='https://github.com/laurent22/joplin/releases/download/v0.10.61/Joplin-Setup-0.10.61.exe'><img alt='Get it on Windows' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://github.com/laurent22/joplin/releases/download/v0.10.61/Joplin-0.10.61.dmg'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://github.com/laurent22/joplin/releases/download/v0.10.61/Joplin-0.10.61-x86_64.AppImage'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeLinux.png'/></a>
|
||||
Windows | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.63/Joplin-Setup-1.0.63.exe'><img alt='Get it on Windows' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.63/Joplin-1.0.63.dmg'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.63/Joplin-1.0.63-x86_64.AppImage'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeLinux.png'/></a>
|
||||
|
||||
## Mobile applications
|
||||
|
||||
Operating System | Download | Alt. Download
|
||||
-----------------|----------|----------------
|
||||
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeAndroid.png'/></a> | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.94/joplin-v1.0.94.apk)
|
||||
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeAndroid.png'/></a> | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.98/joplin-v1.0.98.apk)
|
||||
iOS | <a href='https://itunes.apple.com/us/app/joplin/id1315599797'><img alt='Get it on the App Store' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeIOS.png'/></a> | -
|
||||
|
||||
## Terminal application
|
||||
@@ -51,13 +51,15 @@ For usage information, please refer to the full [Joplin Terminal Application Doc
|
||||
# Features
|
||||
|
||||
- Desktop, mobile and terminal applications.
|
||||
- Import Enex files (Evernote export format)
|
||||
- Support notes, to-dos, tags and notebooks.
|
||||
- Support for alarms (notifications) in mobile and desktop applications.
|
||||
- Offline first, so the entire data is always available on the device even without an internet connection.
|
||||
- Ability to synchronise with multiple targets, including NextCloud, the file system and OneDrive (Dropbox is planned).
|
||||
- Synchronisation with various services, including NextCloud, WebDAV and OneDrive. Dropbox is planned.
|
||||
- End To End Encryption (E2EE)
|
||||
- Synchronises to a plain text format, which can be easily manipulated, backed up, or exported to a different format.
|
||||
- Markdown notes, which are rendered with images and formatting in the desktop and mobile applications.
|
||||
- Tag support
|
||||
- File attachment support (images are displayed, and other files are linked and can be opened in the relevant application).
|
||||
- Markdown notes, which are rendered with images and formatting in the desktop and mobile applications. Support for extra features such as math notation and checkboxes.
|
||||
- File attachment support - images are displayed, and other files are linked and can be opened in the relevant application.
|
||||
- Search functionality.
|
||||
- Geo-location support.
|
||||
- Supports multiple languages
|
||||
@@ -106,6 +108,13 @@ If synchronisation does not work, please consult the logs in the app profile dir
|
||||
|
||||
Select the "WebDAV" synchronisation target and follow the same instructions as for Nextcloud above.
|
||||
|
||||
Known compatible services that use WebDAV:
|
||||
|
||||
- [Box.com](https://www.box.com/)
|
||||
- [DriveHQ](https://www.drivehq.com)
|
||||
- [Zimbra](https://www.zimbra.com/)
|
||||
- [Seafile](https://www.seafile.com/)
|
||||
|
||||
## OneDrive synchronisation
|
||||
|
||||
When syncing with OneDrive, Joplin creates a sub-directory in OneDrive, in /Apps/Joplin and read/write the notes and notebooks from it. The application does not have access to anything outside this directory.
|
||||
|
@@ -244,7 +244,7 @@ The following commands are available in [command-line mode](#command-line-mode):
|
||||
Possible values: HH:mm (20:30), h:mm A (8:30 PM).
|
||||
Default: "HH:mm"
|
||||
|
||||
uncompletedTodosOnTop Show uncompleted todos on top of the lists.
|
||||
uncompletedTodosOnTop Show uncompleted to-dos on top of the lists.
|
||||
Type: bool.
|
||||
Default: true
|
||||
|
||||
|
@@ -90,8 +90,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 22
|
||||
versionCode 2097272
|
||||
versionName "1.0.94"
|
||||
versionCode 2097276
|
||||
versionName "1.0.98"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86"
|
||||
}
|
||||
|
@@ -17,11 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.10.9</string>
|
||||
<string>1.0.12</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>9</string>
|
||||
<string>12</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
@@ -37,7 +37,7 @@ class SyncTargetNextcloud extends BaseSyncTarget {
|
||||
}
|
||||
|
||||
async initFileApi() {
|
||||
const fileApi = await SyncTargetWebDAV.initFileApi_({
|
||||
const fileApi = await SyncTargetWebDAV.initFileApi_(SyncTargetNextcloud.id(), {
|
||||
path: Setting.value('sync.5.path'),
|
||||
username: Setting.value('sync.5.username'),
|
||||
password: Setting.value('sync.5.password'),
|
||||
|
@@ -28,7 +28,7 @@ class SyncTargetWebDAV extends BaseSyncTarget {
|
||||
return true;
|
||||
}
|
||||
|
||||
static async initFileApi_(options) {
|
||||
static async initFileApi_(syncTargetId, options) {
|
||||
const apiOptions = {
|
||||
baseUrl: () => options.path,
|
||||
username: () => options.username,
|
||||
@@ -38,12 +38,12 @@ class SyncTargetWebDAV extends BaseSyncTarget {
|
||||
const api = new WebDavApi(apiOptions);
|
||||
const driver = new FileApiDriverWebDav(api);
|
||||
const fileApi = new FileApi('', driver);
|
||||
fileApi.setSyncTargetId(this.id());
|
||||
fileApi.setSyncTargetId(syncTargetId);
|
||||
return fileApi;
|
||||
}
|
||||
|
||||
static async checkConfig(options) {
|
||||
const fileApi = await SyncTargetWebDAV.initFileApi_(options);
|
||||
const fileApi = await SyncTargetWebDAV.initFileApi_(SyncTargetWebDAV.id(), options);
|
||||
|
||||
const output = {
|
||||
ok: false,
|
||||
@@ -63,7 +63,7 @@ class SyncTargetWebDAV extends BaseSyncTarget {
|
||||
}
|
||||
|
||||
async initFileApi() {
|
||||
const fileApi = await SyncTargetWebDAV.initFileApi_({
|
||||
const fileApi = await SyncTargetWebDAV.initFileApi_(SyncTargetWebDAV.id(), {
|
||||
path: Setting.value('sync.6.path'),
|
||||
username: Setting.value('sync.6.username'),
|
||||
password: Setting.value('sync.6.password'),
|
||||
|
@@ -133,6 +133,42 @@ class WebDavApi {
|
||||
return this.valueFromJson(json, keys, 'array');
|
||||
}
|
||||
|
||||
resourcePropByName(resource, outputType, propName) {
|
||||
const propStats = resource['d:propstat'];
|
||||
let output = null;
|
||||
if (!Array.isArray(propStats)) throw new Error('Missing d:propstat property');
|
||||
for (let i = 0; i < propStats.length; i++) {
|
||||
const props = propStats[i]['d:prop'];
|
||||
if (!Array.isArray(props) || !props.length) continue;
|
||||
const prop = props[0];
|
||||
if (Array.isArray(prop[propName])) {
|
||||
output = prop[propName];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (outputType === 'string') {
|
||||
// If the XML has not attribute the value is directly a string
|
||||
// If the XML node has attributes, the value is under "_".
|
||||
// Eg for this XML, the string will be under {"_":"Thu, 01 Feb 2018 17:24:05 GMT"}:
|
||||
// <a:getlastmodified b:dt="dateTime.rfc1123">Thu, 01 Feb 2018 17:24:05 GMT</a:getlastmodified>
|
||||
// For this XML, the value will be "Thu, 01 Feb 2018 17:24:05 GMT"
|
||||
// <a:getlastmodified>Thu, 01 Feb 2018 17:24:05 GMT</a:getlastmodified>
|
||||
|
||||
output = output[0];
|
||||
|
||||
if (typeof output === 'object' && '_' in output) output = output['_'];
|
||||
if (typeof output !== 'string') return null;
|
||||
return output;
|
||||
}
|
||||
|
||||
if (outputType === 'array') {
|
||||
return output;
|
||||
}
|
||||
|
||||
throw new Error('Invalid output type: ' + outputType);
|
||||
}
|
||||
|
||||
async execPropFind(path, depth, fields = null, options = null) {
|
||||
if (fields === null) fields = ['d:getlastmodified'];
|
||||
|
||||
@@ -158,6 +194,22 @@ class WebDavApi {
|
||||
return this.exec('PROPFIND', path, body, { 'Depth': depth }, options);
|
||||
}
|
||||
|
||||
requestToCurl_(url, options) {
|
||||
let output = [];
|
||||
output.push('curl');
|
||||
if (options.method) output.push('-X ' + options.method);
|
||||
if (options.headers) {
|
||||
for (let n in options.headers) {
|
||||
if (!options.headers.hasOwnProperty(n)) continue;
|
||||
output.push('-H ' + '"' + n + ': ' + options.headers[n] + '"');
|
||||
}
|
||||
}
|
||||
if (options.body) output.push('--data ' + "'" + options.body + "'");
|
||||
output.push(url);
|
||||
|
||||
return output.join(' ');
|
||||
}
|
||||
|
||||
// curl -u admin:123456 'http://nextcloud.local/remote.php/dav/files/admin/' -X PROPFIND --data '<?xml version="1.0" encoding="UTF-8"?>
|
||||
// <d:propfind xmlns:d="DAV:">
|
||||
// <d:prop xmlns:oc="http://owncloud.org/ns">
|
||||
@@ -175,8 +227,6 @@ class WebDavApi {
|
||||
|
||||
if (authToken) headers['Authorization'] = 'Basic ' + authToken;
|
||||
|
||||
if (typeof body === 'string') headers['Content-Length'] = body.length;
|
||||
|
||||
const fetchOptions = {};
|
||||
fetchOptions.headers = headers;
|
||||
fetchOptions.method = method;
|
||||
@@ -188,10 +238,16 @@ class WebDavApi {
|
||||
let response = null;
|
||||
|
||||
// console.info('WebDAV Call', method + ' ' + url, headers, options);
|
||||
// console.info(this.requestToCurl_(url, fetchOptions));
|
||||
|
||||
if (options.source == 'file' && (method == 'POST' || method == 'PUT')) {
|
||||
if (fetchOptions.path) {
|
||||
const fileStat = await shim.fsDriver().stat(fetchOptions.path);
|
||||
if (fileStat) fetchOptions.headers['Content-Length'] = fileStat.size + '';
|
||||
}
|
||||
response = await shim.uploadBlob(url, fetchOptions);
|
||||
} else if (options.target == 'string') {
|
||||
if (typeof body === 'string') fetchOptions.headers['Content-Length'] = shim.stringByteLength(body) + '';
|
||||
response = await shim.fetch(url, fetchOptions);
|
||||
} else { // file
|
||||
response = await shim.fetchBlob(url, fetchOptions);
|
||||
|
@@ -20,11 +20,6 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
super();
|
||||
this.styles_ = {};
|
||||
|
||||
this.state = {
|
||||
settings: {},
|
||||
settingsChanged: false,
|
||||
};
|
||||
|
||||
shared.init(this);
|
||||
|
||||
this.checkSyncConfig_ = async () => {
|
||||
@@ -32,11 +27,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
}
|
||||
|
||||
this.saveButton_press = () => {
|
||||
for (let n in this.state.settings) {
|
||||
if (!this.state.settings.hasOwnProperty(n)) continue;
|
||||
Setting.setValue(n, this.state.settings[n]);
|
||||
}
|
||||
this.setState({settingsChanged:false});
|
||||
return shared.saveSettings(this);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,6 +65,11 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
fontSize: theme.fontSize,
|
||||
flex: 1,
|
||||
},
|
||||
descriptionText: {
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
flex: 1,
|
||||
},
|
||||
settingControl: {
|
||||
color: theme.color,
|
||||
flex: 1,
|
||||
@@ -113,12 +109,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
let output = null;
|
||||
|
||||
const updateSettingValue = (key, value) => {
|
||||
const settings = Object.assign({}, this.state.settings);
|
||||
settings[key] = Setting.formatValue(key, value);
|
||||
this.setState({
|
||||
settings: settings,
|
||||
settingsChanged: true,
|
||||
});
|
||||
return shared.updateSettingValue(this, key, value);
|
||||
}
|
||||
|
||||
const md = Setting.settingMetadata(key);
|
||||
@@ -187,20 +178,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
render() {
|
||||
const settings = this.state.settings;
|
||||
|
||||
const keys = Setting.keys(true, 'mobile');
|
||||
let settingComps = [];
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
//if (key == 'sync.target' && !settings.showAdvancedOptions) continue;
|
||||
if (!Setting.isPublic(key)) continue;
|
||||
|
||||
const md = Setting.settingMetadata(key);
|
||||
if (md.show && !md.show(settings)) continue;
|
||||
|
||||
const comp = this.settingToComponent(key, settings[key]);
|
||||
if (!comp) continue;
|
||||
settingComps.push(comp);
|
||||
}
|
||||
const settingComps = shared.settingsToComponents(this, 'mobile', settings);
|
||||
|
||||
const syncTargetMd = SyncTargetRegistry.idToMetadata(settings['sync.target']);
|
||||
|
||||
@@ -208,8 +186,8 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
const messages = shared.checkSyncConfigMessages(this);
|
||||
const statusComp = !messages.length ? null : (
|
||||
<View style={{flex:1, marginTop: 10}}>
|
||||
<Text>{messages[0]}</Text>
|
||||
{messages.length >= 1 ? (<Text style={{marginTop:10}}>{messages[1]}</Text>) : null}
|
||||
<Text style={this.styles().descriptionText}>{messages[0]}</Text>
|
||||
{messages.length >= 1 ? (<View style={{marginTop:10}}><Text style={this.styles().descriptionText}>{messages[1]}</Text></View>) : null}
|
||||
</View>);
|
||||
|
||||
settingComps.push(
|
||||
@@ -244,7 +222,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
<ScreenHeader
|
||||
title={_('Configuration')}
|
||||
showSaveButton={true}
|
||||
saveButtonDisabled={!this.state.settingsChanged}
|
||||
saveButtonDisabled={!this.state.changedSettingKeys.length}
|
||||
onSaveButtonPress={this.saveButton_press}
|
||||
/>
|
||||
<ScrollView >
|
||||
|
@@ -1,5 +1,5 @@
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { TextInput, TouchableOpacity, Linking, View, Switch, Slider, StyleSheet, Text, Button, ScrollView } = require('react-native');
|
||||
const { TextInput, TouchableOpacity, Linking, View, Switch, Slider, StyleSheet, Text, Button, ScrollView, Platform } = require('react-native');
|
||||
const EncryptionService = require('lib/services/EncryptionService');
|
||||
const { connect } = require('react-redux');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
@@ -109,13 +109,20 @@ class EncryptionConfigScreenComponent extends BaseScreenComponent {
|
||||
const passwordOk = this.state.passwordChecks[mk.id] === true ? '✔' : '❌';
|
||||
const active = this.props.activeMasterKeyId === mk.id ? '✔' : '';
|
||||
|
||||
const inputStyle = {flex:1, marginRight: 10, color: theme.color};
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
inputStyle.borderBottomWidth = 1;
|
||||
inputStyle.borderBottomColor = theme.dividerColor;
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={mk.id}>
|
||||
<Text style={this.styles().titleText}>{_('Master Key %s', mk.id.substr(0,6))}</Text>
|
||||
<Text style={this.styles().normalText}>{_('Created: %s', time.formatMsToLocal(mk.created_time))}</Text>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center'}}>
|
||||
<Text style={{flex:0, fontSize: theme.fontSize, marginRight: 10, color: theme.color}}>{_('Password:')}</Text>
|
||||
<TextInput secureTextEntry={true} value={password} onChangeText={(text) => onPasswordChange(text)} style={{flex:1, marginRight: 10, color: theme.color}}></TextInput>
|
||||
<TextInput secureTextEntry={true} value={password} onChangeText={(text) => onPasswordChange(text)} style={inputStyle}></TextInput>
|
||||
<Text style={{fontSize: theme.fontSize, marginRight: 10, color: theme.color}}>{passwordOk}</Text>
|
||||
<Button title={_('Save')} onPress={() => onSaveClick()}></Button>
|
||||
</View>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { ListView, View, Text, Button, StyleSheet } = require('react-native');
|
||||
const { ListView, View, Text, Button, StyleSheet, Platform } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
@@ -43,12 +43,15 @@ class LogScreenComponent extends BaseScreenComponent {
|
||||
paddingBottom:0,
|
||||
},
|
||||
rowText: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
color: theme.color,
|
||||
},
|
||||
};
|
||||
|
||||
if (Platform.OS !== 'ios') { // Crashes on iOS with error "Unrecognized font family 'monospace'"
|
||||
styles.rowText.fontFamily = 'monospace';
|
||||
}
|
||||
|
||||
styles.rowTextError = Object.assign({}, styles.rowText);
|
||||
styles.rowTextError.color = theme.colorError;
|
||||
|
||||
|
@@ -7,6 +7,8 @@ const shared = {}
|
||||
shared.init = function(comp) {
|
||||
if (!comp.state) comp.state = {};
|
||||
comp.state.checkSyncConfigResult = null;
|
||||
comp.state.settings = {};
|
||||
comp.state.changedSettingKeys = [];
|
||||
}
|
||||
|
||||
shared.checkSyncConfig = async function(comp, settings) {
|
||||
@@ -15,7 +17,6 @@ shared.checkSyncConfig = async function(comp, settings) {
|
||||
const options = Setting.subValues('sync.' + syncTargetId, settings);
|
||||
comp.setState({ checkSyncConfigResult: 'checking' });
|
||||
const result = await SyncTargetClass.checkConfig(options);
|
||||
console.info(result);
|
||||
comp.setState({ checkSyncConfigResult: result });
|
||||
}
|
||||
|
||||
@@ -35,4 +36,46 @@ shared.checkSyncConfigMessages = function(comp) {
|
||||
return output;
|
||||
}
|
||||
|
||||
shared.updateSettingValue = function(comp, key, value) {
|
||||
const settings = Object.assign({}, comp.state.settings);
|
||||
const changedSettingKeys = comp.state.changedSettingKeys.slice();
|
||||
settings[key] = Setting.formatValue(key, value);
|
||||
if (changedSettingKeys.indexOf(key) < 0) changedSettingKeys.push(key);
|
||||
|
||||
comp.setState({
|
||||
settings: settings,
|
||||
changedSettingKeys: changedSettingKeys,
|
||||
});
|
||||
}
|
||||
|
||||
shared.saveSettings = function(comp) {
|
||||
for (let key in comp.state.settings) {
|
||||
if (!comp.state.settings.hasOwnProperty(key)) continue;
|
||||
if (comp.state.changedSettingKeys.indexOf(key) < 0) continue;
|
||||
console.info("Saving", key, comp.state.settings[key]);
|
||||
Setting.setValue(key, comp.state.settings[key]);
|
||||
}
|
||||
|
||||
comp.setState({ changedSettingKeys: [] });
|
||||
}
|
||||
|
||||
shared.settingsToComponents = function(comp, device, settings) {
|
||||
const keys = Setting.keys(true, device);
|
||||
const settingComps = [];
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
if (!Setting.isPublic(key)) continue;
|
||||
|
||||
const md = Setting.settingMetadata(key);
|
||||
if (md.show && !md.show(settings)) continue;
|
||||
|
||||
const settingComp = comp.settingToComponent(key, settings[key]);
|
||||
if (!settingComp) continue;
|
||||
settingComps.push(settingComp);
|
||||
}
|
||||
|
||||
return settingComps
|
||||
}
|
||||
|
||||
module.exports = shared;
|
@@ -18,7 +18,7 @@ class FileApiDriverMemory {
|
||||
}
|
||||
|
||||
decodeContent_(content) {
|
||||
return Buffer.from(content, 'base64').toString('ascii');
|
||||
return Buffer.from(content, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
itemIndexByPath(path) {
|
||||
@@ -49,14 +49,13 @@ class FileApiDriverMemory {
|
||||
return Promise.resolve(item ? Object.assign({}, item) : null);
|
||||
}
|
||||
|
||||
setTimestamp(path, timestampMs) {
|
||||
async setTimestamp(path, timestampMs) {
|
||||
let item = this.itemByPath(path);
|
||||
if (!item) return Promise.reject(new Error('File not found: ' + path));
|
||||
item.updated_time = timestampMs;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
list(path, options) {
|
||||
async list(path, options) {
|
||||
let output = [];
|
||||
|
||||
for (let i = 0; i < this.items_.length; i++) {
|
||||
@@ -95,11 +94,10 @@ class FileApiDriverMemory {
|
||||
return output;
|
||||
}
|
||||
|
||||
mkdir(path) {
|
||||
async mkdir(path) {
|
||||
let index = this.itemIndexByPath(path);
|
||||
if (index >= 0) return Promise.resolve();
|
||||
if (index >= 0) return;
|
||||
this.items_.push(this.newItem(path, true));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async put(path, content, options = null) {
|
||||
@@ -116,10 +114,9 @@ class FileApiDriverMemory {
|
||||
this.items_[index].content = this.encodeContent_(content);
|
||||
this.items_[index].updated_time = time.unix();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
async delete(path) {
|
||||
let index = this.itemIndexByPath(path);
|
||||
if (index >= 0) {
|
||||
let item = Object.assign({}, this.items_[index]);
|
||||
@@ -128,20 +125,17 @@ class FileApiDriverMemory {
|
||||
this.deletedItems_.push(item);
|
||||
this.items_.splice(index, 1);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
move(oldPath, newPath) {
|
||||
async move(oldPath, newPath) {
|
||||
let sourceItem = this.itemByPath(oldPath);
|
||||
if (!sourceItem) return Promise.reject(new Error('Path not found: ' + oldPath));
|
||||
this.delete(newPath); // Overwrite if newPath already exists
|
||||
sourceItem.path = newPath;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
format() {
|
||||
async format() {
|
||||
this.items_ = [];
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async delta(path, options = null) {
|
||||
@@ -159,9 +153,8 @@ class FileApiDriverMemory {
|
||||
return output;
|
||||
}
|
||||
|
||||
clearRoot() {
|
||||
async clearRoot() {
|
||||
this.items_ = [];
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -27,7 +27,7 @@ class FileApiDriverWebDav {
|
||||
const result = await this.api().execPropFind(path, 0, [
|
||||
'd:getlastmodified',
|
||||
'd:resourcetype',
|
||||
'd:getcontentlength', // Remove this once PUT call issue is sorted out
|
||||
// 'd:getcontentlength', // Remove this once PUT call issue is sorted out
|
||||
]);
|
||||
|
||||
const resource = this.api().objectFromJson(result, ['d:multistatus', 'd:response', 0]);
|
||||
@@ -39,23 +39,41 @@ class FileApiDriverWebDav {
|
||||
}
|
||||
|
||||
statFromResource_(resource, path) {
|
||||
const isCollection = this.api().stringFromJson(resource, ['d:propstat', 0, 'd:prop', 0, 'd:resourcetype', 0, 'd:collection', 0]);
|
||||
const lastModifiedString = this.api().stringFromJson(resource, ['d:propstat', 0, 'd:prop', 0, 'd:getlastmodified', 0]);
|
||||
// WebDAV implementations are always slighly different from one server to another but, at the minimum,
|
||||
// a resource should have a propstat key - if not it's probably an error.
|
||||
const propStat = this.api().arrayFromJson(resource, ['d:propstat']);
|
||||
if (!Array.isArray(propStat)) throw new Error('Invalid WebDAV resource format: ' + JSON.stringify(resource));
|
||||
|
||||
const sizeDONOTUSE = Number(this.api().stringFromJson(resource, ['d:propstat', 0, 'd:prop', 0, 'd:getcontentlength', 0]));
|
||||
if (isNaN(sizeDONOTUSE)) throw new Error('Cannot get content size: ' + JSON.stringify(resource));
|
||||
const resourceTypes = this.api().resourcePropByName(resource, 'array', 'd:resourcetype');
|
||||
let isDir = false;
|
||||
if (Array.isArray(resourceTypes)) {
|
||||
for (let i = 0; i < resourceTypes.length; i++) {
|
||||
const t = resourceTypes[i];
|
||||
if (typeof t === 'object' && 'd:collection' in t) {
|
||||
isDir = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastModifiedString) throw new Error('Could not get lastModified date: ' + JSON.stringify(resource));
|
||||
const lastModifiedString = this.api().resourcePropByName(resource, 'string', 'd:getlastmodified');
|
||||
|
||||
const lastModifiedDate = new Date(lastModifiedString);
|
||||
// const sizeDONOTUSE = Number(this.api().stringFromJson(resource, ['d:propstat', 0, 'd:prop', 0, 'd:getcontentlength', 0]));
|
||||
// if (isNaN(sizeDONOTUSE)) throw new Error('Cannot get content size: ' + JSON.stringify(resource));
|
||||
|
||||
|
||||
// Note: Not all WebDAV servers return a getlastmodified date (eg. Seafile, which doesn't return the
|
||||
// property for folders) so we can only throw an error if it's a file.
|
||||
if (!lastModifiedString && !isDir) throw new Error('Could not get lastModified date for resource: ' + JSON.stringify(resource));
|
||||
const lastModifiedDate = lastModifiedString ? new Date(lastModifiedString) : new Date();
|
||||
if (isNaN(lastModifiedDate.getTime())) throw new Error('Invalid date: ' + lastModifiedString);
|
||||
|
||||
return {
|
||||
path: path,
|
||||
// created_time: lastModifiedDate.getTime(),
|
||||
updated_time: lastModifiedDate.getTime(),
|
||||
isDir: isCollection === '',
|
||||
sizeDONOTUSE: sizeDONOTUSE, // This property is used only for the WebDAV PUT hack (see below) so mark it as such so that it can be removed with the hack later on.
|
||||
isDir: isDir,
|
||||
// sizeDONOTUSE: sizeDONOTUSE, // This property is used only for the WebDAV PUT hack (see below) so mark it as such so that it can be removed with the hack later on.
|
||||
};
|
||||
}
|
||||
|
||||
@@ -260,7 +278,7 @@ class FileApiDriverWebDav {
|
||||
]);
|
||||
|
||||
const resources = this.api().arrayFromJson(result, ['d:multistatus', 'd:response']);
|
||||
const stats = this.statsFromResources_(resources)
|
||||
const stats = this.statsFromResources_(resources);
|
||||
|
||||
return {
|
||||
items: stats,
|
||||
@@ -304,32 +322,7 @@ class FileApiDriverWebDav {
|
||||
}
|
||||
|
||||
async put(path, content, options = null) {
|
||||
// In theory, if a client doesn't complete an upload, the file will not appear in the Nextcloud app. Likewise if
|
||||
// the server interrupts the upload midway, the client should receive some kind of error and try uploading the
|
||||
// file again next time. At the very least the file should not appear half-uploaded on the server. In practice
|
||||
// however it seems some files might end up half uploaded on the server (at least on ocloud.de) so, for now,
|
||||
// instead of doing a simple PUT, we do it to a temp file on Nextcloud, then check the file size and, if it
|
||||
// matches, move it its actual place (hoping the server won't mess up and only copy half of the file).
|
||||
// This is innefficient so once the bug is better understood it should hopefully be possible to go back to
|
||||
// using a single PUT call.
|
||||
|
||||
let contentSize = 0;
|
||||
if (content) contentSize = content.length;
|
||||
if (options && options.path) {
|
||||
const stat = await shim.fsDriver().stat(options.path);
|
||||
contentSize = stat.size;
|
||||
}
|
||||
|
||||
const tempPath = this.fileApi_.tempDirName() + '/' + basename(path) + '_' + Date.now();
|
||||
await this.api().exec('PUT', tempPath, content, null, options);
|
||||
|
||||
const stat = await this.stat(tempPath);
|
||||
if (stat.sizeDONOTUSE != contentSize) {
|
||||
// await this.delete(tempPath);
|
||||
throw new Error('WebDAV PUT - Size check failed for ' + tempPath + ' Expected: ' + contentSize + '. Found: ' + stat.sizeDONOTUSE);
|
||||
}
|
||||
|
||||
await this.move(tempPath, path);
|
||||
return await this.api().exec('PUT', path, content, null, options);
|
||||
}
|
||||
|
||||
async delete(path) {
|
||||
|
@@ -39,12 +39,14 @@ class FileApi {
|
||||
this.syncTargetId_ = null;
|
||||
this.tempDirName_ = null;
|
||||
this.driver_.fileApi_ = this;
|
||||
this.requestRepeatCount_ = null; // For testing purpose only - normally this value should come from the driver
|
||||
}
|
||||
|
||||
// Ideally all requests repeating should be done at the FileApi level to remove duplicate code in the drivers, but
|
||||
// historically some drivers (eg. OneDrive) are already handling request repeating, so this is optional, per driver,
|
||||
// and it defaults to no repeating.
|
||||
requestRepeatCount() {
|
||||
if (this.requestRepeatCount_ !== null) return this.requestRepeatCount_;
|
||||
if (this.driver_.requestRepeatCount) return this.driver_.requestRepeatCount();
|
||||
return 0;
|
||||
}
|
||||
|
@@ -62,7 +62,7 @@ class FsDriverRN {
|
||||
const r = await RNFS.stat(path);
|
||||
return this.rnfsStatToStd_(r, path);
|
||||
} catch (error) {
|
||||
if (error && error.message && error.message.indexOf('exist') >= 0) {
|
||||
if (error && ((error.message && error.message.indexOf('exist') >= 0) || error.code === 'ENOENT')) {
|
||||
// Probably { [Error: File does not exist] framesToPop: 1, code: 'EUNSPECIFIED' }
|
||||
// which unfortunately does not have a proper error code. Can be ignored.
|
||||
return null;
|
||||
@@ -120,7 +120,7 @@ class FsDriverRN {
|
||||
try {
|
||||
await RNFS.unlink(path);
|
||||
} catch (error) {
|
||||
if (error && error.message && error.message.indexOf('exist') >= 0) {
|
||||
if (error && ((error.message && error.message.indexOf('exist') >= 0) || error.code === 'ENOENT')) {
|
||||
// Probably { [Error: File does not exist] framesToPop: 1, code: 'EUNSPECIFIED' }
|
||||
// which unfortunately does not have a proper error code. Can be ignored.
|
||||
} else {
|
||||
|
@@ -58,7 +58,7 @@ class Setting extends BaseModel {
|
||||
// recent: _('Non-completed and recently completed ones'),
|
||||
// nonCompleted: _('Non-completed ones only'),
|
||||
// })},
|
||||
'uncompletedTodosOnTop': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Show uncompleted todos on top of the lists') },
|
||||
'uncompletedTodosOnTop': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Show uncompleted to-dos on top of the lists') },
|
||||
'trackLocation': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Save geo-location with notes') },
|
||||
'newTodoFocus': { value: 'title', type: Setting.TYPE_STRING, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('When creating a new to-do:'), options: () => {
|
||||
return {
|
||||
@@ -429,7 +429,7 @@ class Setting extends BaseModel {
|
||||
// }
|
||||
// }
|
||||
|
||||
static saveAll() {
|
||||
static async saveAll() {
|
||||
if (!this.saveTimeoutId_) return Promise.resolve();
|
||||
|
||||
this.logger().info('Saving settings...');
|
||||
@@ -444,12 +444,14 @@ class Setting extends BaseModel {
|
||||
queries.push(Database.insertQuery(this.tableName(), s));
|
||||
}
|
||||
|
||||
return BaseModel.db().transactionExecBatch(queries).then(() => {
|
||||
this.logger().info('Settings have been saved.');
|
||||
});
|
||||
await BaseModel.db().transactionExecBatch(queries);
|
||||
|
||||
this.logger().info('Settings have been saved.');
|
||||
}
|
||||
|
||||
static scheduleSave() {
|
||||
if (!Setting.autoSaveEnabled) return;
|
||||
|
||||
if (this.saveTimeoutId_) clearTimeout(this.saveTimeoutId_);
|
||||
|
||||
this.saveTimeoutId_ = setTimeout(() => {
|
||||
@@ -521,4 +523,6 @@ Setting.constants_ = {
|
||||
openDevTools: false,
|
||||
}
|
||||
|
||||
Setting.autoSaveEnabled = true;
|
||||
|
||||
module.exports = Setting;
|
@@ -74,18 +74,19 @@ class DecryptionWorker {
|
||||
const item = items[i];
|
||||
|
||||
// Temp hack
|
||||
if (['edf44b7a0e4f8cbf248e206cd8dfa800', '2ccb3c9af0b1adac2ec6b66a5961fbb1'].indexOf(item.id) >= 0) {
|
||||
excludedIds.push(item.id);
|
||||
continue;
|
||||
}
|
||||
// if (['edf44b7a0e4f8cbf248e206cd8dfa800', '2ccb3c9af0b1adac2ec6b66a5961fbb1'].indexOf(item.id) >= 0) {
|
||||
// excludedIds.push(item.id);
|
||||
// continue;
|
||||
// }
|
||||
|
||||
const ItemClass = BaseItem.itemClass(item);
|
||||
this.logger().info('DecryptionWorker: decrypting: ' + item.id + ' (' + ItemClass.tableName() + ')');
|
||||
try {
|
||||
await ItemClass.decrypt(item);
|
||||
} catch (error) {
|
||||
excludedIds.push(item.id);
|
||||
|
||||
if (error.code === 'masterKeyNotLoaded' && options.materKeyNotLoadedHandler === 'dispatch') {
|
||||
excludedIds.push(item.id);
|
||||
if (notLoadedMasterKeyDisptaches.indexOf(error.masterKeyId) < 0) {
|
||||
this.dispatch({
|
||||
type: 'MASTERKEY_ADD_NOT_LOADED',
|
||||
|
@@ -172,6 +172,10 @@ function shimInit() {
|
||||
return shim.fetch(url, options);
|
||||
}
|
||||
|
||||
shim.stringByteLength = function(string) {
|
||||
return Buffer.byteLength(string, 'utf-8');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { shimInit };
|
@@ -5,6 +5,7 @@ const RNFetchBlob = require('react-native-fetch-blob').default;
|
||||
const { generateSecureRandom } = require('react-native-securerandom');
|
||||
const FsDriverRN = require('lib/fs-driver-rn.js').FsDriverRN;
|
||||
const urlValidator = require('valid-url');
|
||||
const { Buffer } = require('buffer');
|
||||
|
||||
function shimInit() {
|
||||
shim.Geolocation = GeolocationReact;
|
||||
@@ -111,6 +112,10 @@ function shimInit() {
|
||||
shim.readLocalFileBase64 = async function(path) {
|
||||
return RNFetchBlob.fs.readFile(path, 'base64')
|
||||
}
|
||||
|
||||
shim.stringByteLength = function(string) {
|
||||
return Buffer.byteLength(string, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { shimInit };
|
@@ -118,6 +118,7 @@ shim.setInterval = function(fn, interval) {
|
||||
shim.clearInterval = function(id) {
|
||||
return clearInterval(id);
|
||||
}
|
||||
shim.stringByteLength = function(string) { throw new Error('Not implemented'); }
|
||||
shim.detectAndSetLocale = null;
|
||||
shim.attachFileToNote = async (note, filePath) => {}
|
||||
|
||||
|
@@ -218,15 +218,15 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Windows</td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v0.10.61/Joplin-Setup-0.10.61.exe'><img alt='Get it on Windows' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeWindows.png'/></a></td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.0.63/Joplin-Setup-1.0.63.exe'><img alt='Get it on Windows' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeWindows.png'/></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>macOS</td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v0.10.61/Joplin-0.10.61.dmg'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeMacOS.png'/></a></td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.0.63/Joplin-1.0.63.dmg'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeMacOS.png'/></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linux</td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v0.10.61/Joplin-0.10.61-x86_64.AppImage'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeLinux.png'/></a></td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.0.63/Joplin-1.0.63-x86_64.AppImage'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeLinux.png'/></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -243,7 +243,7 @@
|
||||
<tr>
|
||||
<td>Android</td>
|
||||
<td><a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeAndroid.png'/></a></td>
|
||||
<td>or <a href="https://github.com/laurent22/joplin-android/releases/download/android-v1.0.94/joplin-v1.0.94.apk">Download APK File</a></td>
|
||||
<td>or <a href="https://github.com/laurent22/joplin-android/releases/download/android-v1.0.98/joplin-v1.0.98.apk">Download APK File</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>iOS</td>
|
||||
@@ -265,13 +265,15 @@ sudo ln -s ~/.joplin-bin/bin/joplin /usr/bin/joplin
|
||||
<h1 id="features">Features</h1>
|
||||
<ul>
|
||||
<li>Desktop, mobile and terminal applications.</li>
|
||||
<li>Import Enex files (Evernote export format)</li>
|
||||
<li>Support notes, to-dos, tags and notebooks.</li>
|
||||
<li>Support for alarms (notifications) in mobile and desktop applications.</li>
|
||||
<li>Offline first, so the entire data is always available on the device even without an internet connection.</li>
|
||||
<li>Ability to synchronise with multiple targets, including NextCloud, the file system and OneDrive (Dropbox is planned).</li>
|
||||
<li>Synchronisation with various services, including NextCloud, WebDAV and OneDrive. Dropbox is planned.</li>
|
||||
<li>End To End Encryption (E2EE)</li>
|
||||
<li>Synchronises to a plain text format, which can be easily manipulated, backed up, or exported to a different format.</li>
|
||||
<li>Markdown notes, which are rendered with images and formatting in the desktop and mobile applications.</li>
|
||||
<li>Tag support</li>
|
||||
<li>File attachment support (images are displayed, and other files are linked and can be opened in the relevant application).</li>
|
||||
<li>Markdown notes, which are rendered with images and formatting in the desktop and mobile applications. Support for extra features such as math notation and checkboxes.</li>
|
||||
<li>File attachment support - images are displayed, and other files are linked and can be opened in the relevant application.</li>
|
||||
<li>Search functionality.</li>
|
||||
<li>Geo-location support.</li>
|
||||
<li>Supports multiple languages</li>
|
||||
@@ -306,6 +308,13 @@ sudo ln -s ~/.joplin-bin/bin/joplin /usr/bin/joplin
|
||||
</code></pre><p>If synchronisation does not work, please consult the logs in the app profile directory - it is often due to a misconfigured URL or password. The log should indicate what the exact issue is.</p>
|
||||
<h2 id="webdav-synchronisation">WebDAV synchronisation</h2>
|
||||
<p>Select the "WebDAV" synchronisation target and follow the same instructions as for Nextcloud above.</p>
|
||||
<p>Known compatible services that use WebDAV:</p>
|
||||
<ul>
|
||||
<li><a href="https://www.box.com/">Box.com</a></li>
|
||||
<li><a href="https://www.drivehq.com">DriveHQ</a></li>
|
||||
<li><a href="https://www.zimbra.com/">Zimbra</a></li>
|
||||
<li><a href="https://www.seafile.com/">Seafile</a></li>
|
||||
</ul>
|
||||
<h2 id="onedrive-synchronisation">OneDrive synchronisation</h2>
|
||||
<p>When syncing with OneDrive, Joplin creates a sub-directory in OneDrive, in /Apps/Joplin and read/write the notes and notebooks from it. The application does not have access to anything outside this directory.</p>
|
||||
<p>On the <strong>desktop application</strong> or <strong>mobile application</strong>, select "OneDrive" as the synchronisation target in the config screen (it is selected by default). Then, to initiate the synchronisation process, click on the "Synchronise" button in the sidebar. You will be asked to login to OneDrive to authorise the application (simply input your Microsoft credentials - you do not need to register with OneDrive).</p>
|
||||
@@ -383,14 +392,14 @@ $$
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/hr.png" alt=""></td>
|
||||
<td>Croatian</td>
|
||||
<td>hr_HR</td>
|
||||
<td>Hrvoje Mandić <a href="mailto:trbuhom@net.hr">trbuhom@net.hr</a></td>
|
||||
<td>Hrvoje Mandić <a href="mailto:trbuhom@net.hr">trbuhom@net.hr</a></td>
|
||||
<td>72%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/de.png" alt=""></td>
|
||||
<td>Deutsch</td>
|
||||
<td>de_DE</td>
|
||||
<td>Tobias Strobel <a href="mailto:git@strobeltobias.de">git@strobeltobias.de</a></td>
|
||||
<td>Tobias Strobel <a href="mailto:git@strobeltobias.de">git@strobeltobias.de</a></td>
|
||||
<td>91%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -446,14 +455,14 @@ $$
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/ru.png" alt=""></td>
|
||||
<td>Русский</td>
|
||||
<td>ru_RU</td>
|
||||
<td>Artyom Karlov <a href="mailto:artyom.karlov@gmail.com">artyom.karlov@gmail.com</a></td>
|
||||
<td>Artyom Karlov <a href="mailto:artyom.karlov@gmail.com">artyom.karlov@gmail.com</a></td>
|
||||
<td>94%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/cn.png" alt=""></td>
|
||||
<td>中文 (简体)</td>
|
||||
<td>zh_CN</td>
|
||||
<td>RCJacH <a href="mailto:RCJacH@outlook.com">RCJacH@outlook.com</a></td>
|
||||
<td>RCJacH <a href="mailto:RCJacH@outlook.com">RCJacH@outlook.com</a></td>
|
||||
<td>75%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@@ -395,7 +395,7 @@ Possible keys/values:
|
||||
Possible values: HH:mm (20:30), h:mm A (8:30 PM).
|
||||
Default: "HH:mm"
|
||||
|
||||
uncompletedTodosOnTop Show uncompleted todos on top of the lists.
|
||||
uncompletedTodosOnTop Show uncompleted to-dos on top of the lists.
|
||||
Type: bool.
|
||||
Default: true
|
||||
|
||||
|
@@ -18,6 +18,8 @@
|
||||
"*.min.js",
|
||||
"ElectronClient/app/gui/note-viewer/highlight/*.pack.js",
|
||||
"ElectronClient/app/css/font-awesome.min.css",
|
||||
"docs/*.html",
|
||||
"docs/*.svg",
|
||||
],
|
||||
"folder_exclude_patterns":
|
||||
[
|
||||
|
Reference in New Issue
Block a user