1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-02 12:47:41 +02:00

Desktop, Cli: Save user settings to JSON file

This commit is contained in:
Laurent Cozic 2021-02-09 17:54:29 +00:00
parent 3a8aea1aa4
commit 71f976f6a5
10 changed files with 1154 additions and 60 deletions

View File

@ -67,6 +67,9 @@ readme/
packages/app-cli/app/LinkSelector.d.ts packages/app-cli/app/LinkSelector.d.ts
packages/app-cli/app/LinkSelector.js packages/app-cli/app/LinkSelector.js
packages/app-cli/app/LinkSelector.js.map packages/app-cli/app/LinkSelector.js.map
packages/app-cli/app/command-settingschema.d.ts
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-settingschema.js.map
packages/app-cli/app/services/plugins/PluginRunner.d.ts packages/app-cli/app/services/plugins/PluginRunner.d.ts
packages/app-cli/app/services/plugins/PluginRunner.js packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/services/plugins/PluginRunner.js.map packages/app-cli/app/services/plugins/PluginRunner.js.map
@ -919,6 +922,9 @@ packages/lib/models/SmartFilter.js.map
packages/lib/models/Tag.d.ts packages/lib/models/Tag.d.ts
packages/lib/models/Tag.js packages/lib/models/Tag.js
packages/lib/models/Tag.js.map packages/lib/models/Tag.js.map
packages/lib/models/settings/FileHandler.d.ts
packages/lib/models/settings/FileHandler.js
packages/lib/models/settings/FileHandler.js.map
packages/lib/models/utils/paginatedFeed.d.ts packages/lib/models/utils/paginatedFeed.d.ts
packages/lib/models/utils/paginatedFeed.js packages/lib/models/utils/paginatedFeed.js
packages/lib/models/utils/paginatedFeed.js.map packages/lib/models/utils/paginatedFeed.js.map

6
.gitignore vendored
View File

@ -54,6 +54,9 @@ lerna-debug.log
packages/app-cli/app/LinkSelector.d.ts packages/app-cli/app/LinkSelector.d.ts
packages/app-cli/app/LinkSelector.js packages/app-cli/app/LinkSelector.js
packages/app-cli/app/LinkSelector.js.map packages/app-cli/app/LinkSelector.js.map
packages/app-cli/app/command-settingschema.d.ts
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-settingschema.js.map
packages/app-cli/app/services/plugins/PluginRunner.d.ts packages/app-cli/app/services/plugins/PluginRunner.d.ts
packages/app-cli/app/services/plugins/PluginRunner.js packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/services/plugins/PluginRunner.js.map packages/app-cli/app/services/plugins/PluginRunner.js.map
@ -906,6 +909,9 @@ packages/lib/models/SmartFilter.js.map
packages/lib/models/Tag.d.ts packages/lib/models/Tag.d.ts
packages/lib/models/Tag.js packages/lib/models/Tag.js
packages/lib/models/Tag.js.map packages/lib/models/Tag.js.map
packages/lib/models/settings/FileHandler.d.ts
packages/lib/models/settings/FileHandler.js
packages/lib/models/settings/FileHandler.js.map
packages/lib/models/utils/paginatedFeed.d.ts packages/lib/models/utils/paginatedFeed.d.ts
packages/lib/models/utils/paginatedFeed.js packages/lib/models/utils/paginatedFeed.js
packages/lib/models/utils/paginatedFeed.js.map packages/lib/models/utils/paginatedFeed.js.map

772
docs/schema/settings.json Normal file
View File

@ -0,0 +1,772 @@
{
"title": "JSON schema for Joplin setting files",
"$id": "https://joplinapp.org/schema/settings.json",
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"clientId": {
"type": "string",
"default": "",
"$comment": "private"
},
"editor.codeView": {
"type": "boolean",
"default": true,
"$comment": "private"
},
"sync.target": {
"type": "integer",
"default": 7,
"enum": [
2,
3,
5,
6,
7,
8,
9
]
},
"sync.upgradeState": {
"type": "integer",
"default": 0,
"$comment": "private"
},
"sync.2.path": {
"type": "string",
"default": "",
"description": "Attention: If you change this location, make sure you copy all your content to it before syncing, otherwise all files will be removed! See the FAQ for more details: https://joplinapp.org/faq/"
},
"sync.5.path": {
"type": "string",
"default": "",
"description": "Attention: If you change this location, make sure you copy all your content to it before syncing, otherwise all files will be removed! See the FAQ for more details: https://joplinapp.org/faq/"
},
"sync.5.username": {
"type": "string",
"default": ""
},
"sync.5.password": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.6.path": {
"type": "string",
"default": "",
"description": "Attention: If you change this location, make sure you copy all your content to it before syncing, otherwise all files will be removed! See the FAQ for more details: https://joplinapp.org/faq/"
},
"sync.6.username": {
"type": "string",
"default": ""
},
"sync.6.password": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.8.path": {
"type": "string",
"default": "",
"description": "Attention: If you change this location, make sure you copy all your content to it before syncing, otherwise all files will be removed! See the FAQ for more details: https://joplinapp.org/faq/"
},
"sync.8.url": {
"type": "string",
"default": "https://s3.amazonaws.com/"
},
"sync.8.username": {
"type": "string",
"default": ""
},
"sync.8.password": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.9.path": {
"type": "string",
"default": "",
"description": "Attention: If you change this location, make sure you copy all your content to it before syncing, otherwise all files will be removed! See the FAQ for more details: https://joplinapp.org/faq/"
},
"sync.9.directory": {
"type": "string",
"default": "Apps/Joplin"
},
"sync.9.username": {
"type": "string",
"default": ""
},
"sync.9.password": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.5.syncTargets": {
"type": "object",
"default": {},
"$comment": "private"
},
"sync.resourceDownloadMode": {
"type": "string",
"default": "always",
"description": "In \"Manual\" mode, attachments are downloaded only when you click on them. In \"Auto\", they are downloaded when you open the note. In \"Always\", all the attachments are downloaded whether you open the note or not.",
"enum": [
"always",
"manual",
"auto"
]
},
"sync.3.auth": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.4.auth": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.7.auth": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.9.auth": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.1.context": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.2.context": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.3.context": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.4.context": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.5.context": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.6.context": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.7.context": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.8.context": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.9.context": {
"type": "string",
"default": "",
"$comment": "private"
},
"sync.maxConcurrentConnections": {
"type": "integer",
"default": 5,
"minimum": 1,
"maximum": 20
},
"activeFolderId": {
"type": "string",
"default": "",
"$comment": "private"
},
"richTextBannerDismissed": {
"type": "boolean",
"default": false,
"$comment": "private"
},
"firstStart": {
"type": "boolean",
"default": true,
"$comment": "private"
},
"locale": {
"type": "string",
"default": "en_GB",
"enum": [
"ar",
"eu",
"bs_BA",
"bg_BG",
"ca",
"hr_HR",
"cs_CZ",
"da_DK",
"de_DE",
"et_EE",
"en_GB",
"en_US",
"es_ES",
"eo",
"fi_FI",
"fr_FR",
"gl_ES",
"id_ID",
"it_IT",
"nl_BE",
"nl_NL",
"nb_NO",
"fa",
"pl_PL",
"pt_BR",
"pt_PT",
"ro",
"sl_SI",
"sv",
"th_TH",
"vi",
"tr_TR",
"el_GR",
"ru_RU",
"sr_RS",
"zh_CN",
"zh_TW",
"ja_JP",
"ko"
]
},
"dateFormat": {
"type": "string",
"default": "DD/MM/YYYY",
"enum": [
"DD/MM/YYYY",
"DD/MM/YY",
"MM/DD/YYYY",
"MM/DD/YY",
"YYYY-MM-DD",
"DD.MM.YYYY",
"YYYY.MM.DD"
]
},
"timeFormat": {
"type": "string",
"default": "HH:mm",
"enum": [
"HH:mm",
"h:mm A"
]
},
"theme": {
"type": "integer",
"default": 1,
"enum": [
1,
2,
3,
4,
5,
6,
7,
22
]
},
"themeAutoDetect": {
"type": "boolean",
"default": false
},
"preferredLightTheme": {
"type": "integer",
"default": 1,
"enum": [
1,
2,
3,
4,
5,
6,
7,
22
]
},
"preferredDarkTheme": {
"type": "integer",
"default": 2,
"enum": [
1,
2,
3,
4,
5,
6,
7,
22
]
},
"notificationPermission": {
"type": "string",
"default": "",
"$comment": "private"
},
"showNoteCounts": {
"type": "boolean",
"default": true,
"$comment": "private"
},
"layoutButtonSequence": {
"type": "integer",
"default": 0,
"enum": [
0,
1,
2,
3
],
"$comment": "private"
},
"uncompletedTodosOnTop": {
"type": "boolean",
"default": true
},
"showCompletedTodos": {
"type": "boolean",
"default": true
},
"notes.sortOrder.field": {
"type": "string",
"default": "user_updated_time",
"enum": [
"user_updated_time",
"user_created_time",
"title",
"order"
]
},
"editor.autoMatchingBraces": {
"type": "boolean",
"default": true
},
"notes.sortOrder.reverse": {
"type": "boolean",
"default": true
},
"folders.sortOrder.field": {
"type": "string",
"default": "title",
"enum": [
"title",
"last_note_user_updated_time"
]
},
"folders.sortOrder.reverse": {
"type": "boolean",
"default": false
},
"trackLocation": {
"type": "boolean",
"default": true
},
"editor.beta": {
"type": "boolean",
"default": false,
"description": "This beta adds list continuation, Markdown preview, and Markdown shortcuts. If you find bugs, please report them in the Discourse forum.",
"$comment": "private"
},
"newTodoFocus": {
"type": "string",
"default": "title",
"enum": [
"title",
"body"
]
},
"newNoteFocus": {
"type": "string",
"default": "body",
"enum": [
"title",
"body"
]
},
"plugins.states": {
"type": "object",
"default": "",
"$comment": "private"
},
"plugins.devPluginPaths": {
"type": "string",
"default": "",
"description": "You may add multiple plugin paths, each separated by a comma. You will need to restart the application for the changes to take effect."
},
"markdown.softbreaks": {
"type": "boolean",
"default": false,
"$comment": "private"
},
"markdown.typographer": {
"type": "boolean",
"default": false,
"$comment": "private"
},
"markdown.plugin.softbreaks": {
"type": "boolean",
"default": false
},
"markdown.plugin.typographer": {
"type": "boolean",
"default": false
},
"markdown.plugin.linkify": {
"type": "boolean",
"default": true
},
"markdown.plugin.katex": {
"type": "boolean",
"default": true
},
"markdown.plugin.fountain": {
"type": "boolean",
"default": false
},
"markdown.plugin.mermaid": {
"type": "boolean",
"default": true
},
"markdown.plugin.audioPlayer": {
"type": "boolean",
"default": true
},
"markdown.plugin.videoPlayer": {
"type": "boolean",
"default": true
},
"markdown.plugin.pdfViewer": {
"type": "boolean",
"default": true
},
"markdown.plugin.mark": {
"type": "boolean",
"default": true
},
"markdown.plugin.footnote": {
"type": "boolean",
"default": true
},
"markdown.plugin.toc": {
"type": "boolean",
"default": true
},
"markdown.plugin.sub": {
"type": "boolean",
"default": false
},
"markdown.plugin.sup": {
"type": "boolean",
"default": false
},
"markdown.plugin.deflist": {
"type": "boolean",
"default": false
},
"markdown.plugin.abbr": {
"type": "boolean",
"default": false
},
"markdown.plugin.emoji": {
"type": "boolean",
"default": false
},
"markdown.plugin.insert": {
"type": "boolean",
"default": false
},
"markdown.plugin.multitable": {
"type": "boolean",
"default": false
},
"showTrayIcon": {
"type": "boolean",
"default": true,
"description": "This will allow Joplin to run in the background. It is recommended to enable this setting so that your notes are constantly being synchronised, thus reducing the number of conflicts."
},
"startMinimized": {
"type": "boolean",
"default": false
},
"collapsedFolderIds": {
"type": "array",
"default": [],
"$comment": "private"
},
"keychain.supported": {
"type": "integer",
"default": -1,
"$comment": "private"
},
"db.ftsEnabled": {
"type": "integer",
"default": -1,
"$comment": "private"
},
"db.fuzzySearchEnabled": {
"type": "integer",
"default": -1,
"$comment": "private"
},
"encryption.enabled": {
"type": "boolean",
"default": false,
"$comment": "private"
},
"encryption.activeMasterKeyId": {
"type": "string",
"default": "",
"$comment": "private"
},
"encryption.passwordCache": {
"type": "object",
"default": {},
"$comment": "private"
},
"encryption.shouldReencrypt": {
"type": "integer",
"default": -1,
"$comment": "private"
},
"style.zoom": {
"type": "integer",
"default": 100,
"minimum": 50,
"maximum": 500,
"$comment": "private"
},
"style.editor.fontSize": {
"type": "integer",
"default": 13,
"minimum": 4,
"maximum": 50
},
"style.editor.fontFamily": {
"type": "string",
"default": "",
"description": "This should be a *monospace* font or some elements will render incorrectly. If the font is incorrect or empty, it will default to a generic monospace font."
},
"ui.layout": {
"type": "object",
"default": {},
"$comment": "private"
},
"autoUpdateEnabled": {
"type": "boolean",
"default": false
},
"autoUpdate.includePreReleases": {
"type": "boolean",
"default": false,
"description": "See the pre-release page for more details: https://joplinapp.org/prereleases"
},
"clipperServer.autoStart": {
"type": "boolean",
"default": false,
"$comment": "private"
},
"sync.interval": {
"type": "integer",
"default": 300,
"enum": [
0,
300,
600,
1800,
3600,
43200,
86400
]
},
"noteVisiblePanes": {
"type": "array",
"default": [
"editor",
"viewer"
],
"$comment": "private"
},
"tagHeaderIsExpanded": {
"type": "boolean",
"default": true,
"$comment": "private"
},
"folderHeaderIsExpanded": {
"type": "boolean",
"default": true,
"$comment": "private"
},
"editor": {
"type": "string",
"default": "",
"description": "The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor."
},
"export.pdfPageSize": {
"type": "string",
"default": "A4",
"enum": [
"A4",
"Letter",
"A3",
"A5",
"Tabloid",
"Legal"
]
},
"export.pdfPageOrientation": {
"type": "string",
"default": "portrait",
"enum": [
"portrait",
"landscape"
]
},
"editor.keyboardMode": {
"type": "string",
"default": "",
"enum": [
"",
"emacs",
"vim"
]
},
"editor.spellcheckBeta": {
"type": "boolean",
"default": false,
"description": "Spell checker in the Markdown editor was previously unstable (cursor location was not stable, sometimes edits would not be saved or reflected in the viewer, etc.) however it appears to be more reliable now. If you notice any issue, please report it on GitHub or the Joplin Forum (Help -> Joplin Forum)",
"$comment": "private"
},
"net.customCertificates": {
"type": "string",
"default": "",
"description": "Comma-separated list of paths to directories to load the certificates from, or path to individual cert files. For example: /my/cert_dir, /other/custom.pem. Note that if you make changes to the TLS settings, you must save your changes before clicking on \"Check synchronisation configuration\"."
},
"net.ignoreTlsErrors": {
"type": "boolean",
"default": false
},
"sync.wipeOutFailSafe": {
"type": "boolean",
"default": true,
"description": "Fail-safe: Do not wipe out local data when sync target is empty (often the result of a misconfiguration or bug)"
},
"api.token": {
"type": "string",
"default": null,
"$comment": "private"
},
"api.port": {
"type": "integer",
"default": null,
"description": "Specify the port that should be used by the API server. If not set, a default will be used."
},
"resourceService.lastProcessedChangeId": {
"type": "integer",
"default": 0,
"$comment": "private"
},
"searchEngine.lastProcessedChangeId": {
"type": "integer",
"default": 0,
"$comment": "private"
},
"revisionService.lastProcessedChangeId": {
"type": "integer",
"default": 0,
"$comment": "private"
},
"searchEngine.initialIndexingDone": {
"type": "boolean",
"default": false,
"$comment": "private"
},
"revisionService.enabled": {
"type": "boolean",
"default": true
},
"revisionService.ttlDays": {
"type": "integer",
"default": 90,
"minimum": 1,
"maximum": 730
},
"revisionService.intervalBetweenRevisions": {
"type": "integer",
"default": 600000,
"$comment": "private"
},
"revisionService.oldNoteInterval": {
"type": "integer",
"default": 604800000,
"$comment": "private"
},
"welcome.wasBuilt": {
"type": "boolean",
"default": false,
"$comment": "private"
},
"welcome.enabled": {
"type": "boolean",
"default": true,
"$comment": "private"
},
"camera.type": {
"type": "integer",
"default": 0,
"$comment": "private"
},
"camera.ratio": {
"type": "string",
"default": "4:3",
"$comment": "private"
},
"spellChecker.enabled": {
"type": "boolean",
"default": true,
"$comment": "private"
},
"spellChecker.language": {
"type": "string",
"default": "",
"$comment": "private"
},
"windowContentZoomFactor": {
"type": "integer",
"default": 100,
"minimum": 30,
"maximum": 300,
"$comment": "private"
},
"layout.folderList.factor": {
"type": "integer",
"default": 1,
"description": "The factor property sets how the item will grow or shrink to fit the available space in its container with respect to the other items. Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.Restart app to see changes."
},
"layout.noteList.factor": {
"type": "integer",
"default": 1,
"description": "The factor property sets how the item will grow or shrink to fit the available space in its container with respect to the other items. Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.Restart app to see changes."
},
"layout.note.factor": {
"type": "integer",
"default": 2,
"description": "The factor property sets how the item will grow or shrink to fit the available space in its container with respect to the other items. Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.Restart app to see changes."
}
}
}

View File

@ -14,9 +14,10 @@
"buildApiDoc": "npm start --prefix=packages/app-cli -- apidoc ../../readme/api/references/rest_api.md", "buildApiDoc": "npm start --prefix=packages/app-cli -- apidoc ../../readme/api/references/rest_api.md",
"buildDoc": "./packages/tools/build-all.sh", "buildDoc": "./packages/tools/build-all.sh",
"buildPluginDoc": "typedoc --name 'Joplin Plugin API Documentation' --mode file -theme './Assets/PluginDocTheme/' --readme './Assets/PluginDocTheme/index.md' --excludeNotExported --excludeExternals --excludePrivate --excludeProtected --out docs/api/references/plugin_api packages/lib/services/plugins/api/", "buildPluginDoc": "typedoc --name 'Joplin Plugin API Documentation' --mode file -theme './Assets/PluginDocTheme/' --readme './Assets/PluginDocTheme/index.md' --excludeNotExported --excludeExternals --excludePrivate --excludeProtected --out docs/api/references/plugin_api packages/lib/services/plugins/api/",
"buildSettingJsonSchema": "npm start --prefix=packages/app-cli -- settingschema ../../docs/schema/settings.json",
"buildTranslations": "npm run tsc && node packages/tools/build-translation.js", "buildTranslations": "npm run tsc && node packages/tools/build-translation.js",
"buildTranslationsNoTsc": "node packages/tools/build-translation.js", "buildTranslationsNoTsc": "node packages/tools/build-translation.js",
"buildWebsite": "npm run buildApiDoc && node ./packages/tools/build-website.js && npm run buildPluginDoc", "buildWebsite": "npm run buildApiDoc && node ./packages/tools/build-website.js && npm run buildPluginDoc && npm run buildSettingJsonSchema",
"circularDependencyCheck": "madge --warning --circular --extensions js ./", "circularDependencyCheck": "madge --warning --circular --extensions js ./",
"clean": "lerna clean -y && lerna run clean", "clean": "lerna clean -y && lerna run clean",
"dependencyTree": "madge", "dependencyTree": "madge",

View File

@ -0,0 +1,72 @@
import Setting, { SettingStorage } from '@joplin/lib/models/Setting';
import { SettingItemType } from '@joplin/lib/services/plugins/api/types';
import shim from '@joplin/lib/shim';
const { BaseCommand } = require('./base-command.js');
function settingTypeToSchemaType(type: SettingItemType): string {
const map: Record<SettingItemType, string> = {
[SettingItemType.Int]: 'integer',
[SettingItemType.String]: 'string',
[SettingItemType.Bool]: 'boolean',
[SettingItemType.Array]: 'array',
[SettingItemType.Object]: 'object',
[SettingItemType.Button]: '',
};
const r = map[type];
if (r === '') return '';
if (!r) throw new Error(`Unsupported type: ${type}`);
return r;
}
class Command extends BaseCommand {
usage() {
return 'settingschema <file>';
}
description() {
return 'Build the setting schema file';
}
enabled() {
return false;
}
async action(args: any) {
const schema: Record<string, any> = {
title: 'JSON schema for Joplin setting files',
'$id': Setting.schemaUrl,
'$schema': 'https://json-schema.org/draft-07/schema#',
type: 'object',
properties: {},
};
const metadata = Setting.metadata();
for (const key of Object.keys(metadata)) {
const md = metadata[key];
const type = settingTypeToSchemaType(md.type);
if (!type) continue;
const props: Record<string, any> = {};
props.type = type;
props.default = md.value;
if (md.description && md.description('desktop')) props.description = md.description('desktop');
if (md.isEnum) props.enum = Object.keys(md.options()).map((v: any) => Setting.formatValue(key, v));
if ('minimum' in md) props.minimum = md.minimum;
if ('maximum' in md) props.maximum = md.maximum;
if (!md.public || md.storage !== SettingStorage.File) props['$comment'] = 'private';
schema.properties[key] = props;
}
const outFilePath = args['file'];
await shim.fsDriver().writeFile(outFilePath, JSON.stringify(schema, null, '\t'), 'utf8');
}
}
module.exports = Command;

View File

@ -1,6 +1,11 @@
import Setting, { SettingSectionSource } from '@joplin/lib/models/Setting'; import Setting, { SettingSectionSource } from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient, expectThrow, expectNotThrow, msleep } from './test-utils';
import * as fs from 'fs-extra';
import Logger from '@joplin/lib/Logger';
const { setupDatabaseAndSynchronizer, switchClient, expectThrow, expectNotThrow } = require('./test-utils.js'); async function loadSettingsFromFile(): Promise<any> {
return JSON.parse(await fs.readFile(Setting.settingFilePath, 'utf8'));
}
describe('models_Setting', function() { describe('models_Setting', function() {
@ -127,4 +132,70 @@ describe('models_Setting', function() {
expect(Setting.sectionNameToLabel('mySection')).toBe('My section'); expect(Setting.sectionNameToLabel('mySection')).toBe('My section');
})); }));
it('should save and load settings from file', (async () => {
Setting.setValue('sync.target', 9); // Saved to file
Setting.setValue('encryption.passwordCache', {}); // Saved to keychain or db
Setting.setValue('plugins.states', { test: true }); // Always saved to db
await Setting.saveAll();
{
const settings = await loadSettingsFromFile();
expect(settings['sync.target']).toBe(9);
expect(settings).not.toContain('encryption.passwordCache');
expect(settings).not.toContain('plugins.states');
}
Setting.setValue('sync.target', 8);
await Setting.saveAll();
{
const settings = await loadSettingsFromFile();
expect(settings['sync.target']).toBe(8);
}
}));
it('should not save to file if nothing has changed', (async () => {
Setting.setValue('sync.target', 9);
await Setting.saveAll();
{
// Double-check that timestamp is indeed changed when the content is
// changed.
const beforeStat = await fs.stat(Setting.settingFilePath);
await msleep(1001);
Setting.setValue('sync.target', 8);
await Setting.saveAll();
const afterStat = await fs.stat(Setting.settingFilePath);
expect(afterStat.mtime.getTime()).toBeGreaterThan(beforeStat.mtime.getTime());
}
{
const beforeStat = await fs.stat(Setting.settingFilePath);
await msleep(1001);
Setting.setValue('sync.target', 8);
const afterStat = await fs.stat(Setting.settingFilePath);
await Setting.saveAll();
expect(afterStat.mtime.getTime()).toBe(beforeStat.mtime.getTime());
}
}));
it('should handle invalid JSON', (async () => {
const badContent = '{ oopsIforgotTheQuotes: true}';
await fs.writeFile(Setting.settingFilePath, badContent, 'utf8');
await Setting.reset();
Logger.globalLogger.enabled = false;
await Setting.load();
Logger.globalLogger.enabled = true;
// Invalid JSON file has been moved to .bak file
expect(await fs.pathExists(Setting.settingFilePath)).toBe(false);
const files = await fs.readdir(Setting.value('profileDir'));
expect(files.length).toBe(1);
expect(files[0].endsWith('.bak')).toBe(true);
expect(await fs.readFile(`${Setting.value('profileDir')}/${files[0]}`, 'utf8')).toBe(badContent);
}));
}); });

View File

@ -109,10 +109,12 @@ const supportDir = `${__dirname}/support`;
// We add a space in the data directory path as that will help uncover // We add a space in the data directory path as that will help uncover
// various space-in-path issues. // various space-in-path issues.
const dataDir = `${__dirname}/test data/${suiteName_}`; const dataDir = `${__dirname}/test data/${suiteName_}`;
const profileDir = `${dataDir}/profile`;
fs.mkdirpSync(logDir, 0o755); fs.mkdirpSync(logDir, 0o755);
fs.mkdirpSync(baseTempDir, 0o755); fs.mkdirpSync(baseTempDir, 0o755);
fs.mkdirpSync(dataDir); fs.mkdirpSync(dataDir);
fs.mkdirpSync(profileDir);
SyncTargetRegistry.addClass(SyncTargetMemory); SyncTargetRegistry.addClass(SyncTargetMemory);
SyncTargetRegistry.addClass(SyncTargetFilesystem); SyncTargetRegistry.addClass(SyncTargetFilesystem);
@ -182,7 +184,8 @@ Setting.setConstant('appId', 'net.cozic.joplintest-cli');
Setting.setConstant('appType', 'cli'); Setting.setConstant('appType', 'cli');
Setting.setConstant('tempDir', baseTempDir); Setting.setConstant('tempDir', baseTempDir);
Setting.setConstant('cacheDir', baseTempDir); Setting.setConstant('cacheDir', baseTempDir);
Setting.setConstant('pluginDataDir', `${dataDir}/plugin-data`); Setting.setConstant('pluginDataDir', `${profileDir}/profile/plugin-data`);
Setting.setConstant('profileDir', profileDir);
Setting.setConstant('env', 'dev'); Setting.setConstant('env', 'dev');
BaseService.logger_ = logger; BaseService.logger_ = logger;

View File

@ -58,17 +58,26 @@ class Logger {
private targets_: Target[] = []; private targets_: Target[] = [];
private level_: LogLevel = LogLevel.Info; private level_: LogLevel = LogLevel.Info;
private lastDbCleanup_: number = time.unixMs(); private lastDbCleanup_: number = time.unixMs();
private enabled_: boolean = true;
static fsDriver() { static fsDriver() {
if (!Logger.fsDriver_) Logger.fsDriver_ = new FsDriverDummy(); if (!Logger.fsDriver_) Logger.fsDriver_ = new FsDriverDummy();
return Logger.fsDriver_; return Logger.fsDriver_;
} }
public get enabled(): boolean {
return this.enabled_;
}
public set enabled(v: boolean) {
this.enabled_ = v;
}
public static initializeGlobalLogger(logger: Logger) { public static initializeGlobalLogger(logger: Logger) {
this.globalLogger_ = logger; this.globalLogger_ = logger;
} }
private static get globalLogger(): Logger { public static get globalLogger(): Logger {
if (!this.globalLogger_) throw new Error('Global logger has not been initialized!!'); if (!this.globalLogger_) throw new Error('Global logger has not been initialized!!');
return this.globalLogger_; return this.globalLogger_;
} }
@ -169,7 +178,7 @@ class Logger {
} }
public log(level: LogLevel, prefix: string, ...object: any[]) { public log(level: LogLevel, prefix: string, ...object: any[]) {
if (!this.targets_.length) return; if (!this.targets_.length || !this.enabled) return;
for (let i = 0; i < this.targets_.length; i++) { for (let i = 0; i < this.targets_.length; i++) {
const target = this.targets_[i]; const target = this.targets_[i];

View File

@ -6,6 +6,7 @@ import BaseModel from '../BaseModel';
import Database from '../database'; import Database from '../database';
const SyncTargetRegistry = require('../SyncTargetRegistry.js'); const SyncTargetRegistry = require('../SyncTargetRegistry.js');
import time from '../time'; import time from '../time';
import FileHandler, { SettingValues } from './settings/FileHandler';
const { sprintf } = require('sprintf-js'); const { sprintf } = require('sprintf-js');
const ObjectUtils = require('../ObjectUtils'); const ObjectUtils = require('../ObjectUtils');
const { toTitleCase } = require('../string-utils.js'); const { toTitleCase } = require('../string-utils.js');
@ -24,6 +25,11 @@ interface KeysOptions {
secureOnly?: boolean; secureOnly?: boolean;
} }
export enum SettingStorage {
Database = 1,
File = 2,
}
// This is the definition of a setting item // This is the definition of a setting item
export interface SettingItem { export interface SettingItem {
value: any; value: any;
@ -49,6 +55,7 @@ export interface SettingItem {
unitLabel?: Function; unitLabel?: Function;
needRestart?: boolean; needRestart?: boolean;
autoSave?: boolean; autoSave?: boolean;
storage?: SettingStorage;
} }
interface SettingItems { interface SettingItems {
@ -81,6 +88,8 @@ interface SettingSections {
class Setting extends BaseModel { class Setting extends BaseModel {
public static schemaUrl = 'https://joplinapp.org/schema/settings.json';
// For backward compatibility // For backward compatibility
public static TYPE_INT = SettingItemType.Int; public static TYPE_INT = SettingItemType.Int;
public static TYPE_STRING = SettingItemType.String; public static TYPE_STRING = SettingItemType.String;
@ -133,7 +142,6 @@ class Setting extends BaseModel {
RENDERED_MARKDOWN: 'userstyle.css', RENDERED_MARKDOWN: 'userstyle.css',
}; };
// Contains constants that are set by the application and // Contains constants that are set by the application and
// cannot be modified by the user: // cannot be modified by the user:
public static constants_: any = { public static constants_: any = {
@ -166,6 +174,7 @@ class Setting extends BaseModel {
private static customMetadata_: SettingItems = {}; private static customMetadata_: SettingItems = {};
private static customSections_: SettingSections = {}; private static customSections_: SettingSections = {};
private static changedKeys_: string[] = []; private static changedKeys_: string[] = [];
private static fileHandler_: FileHandler = null;
static tableName() { static tableName() {
return 'settings'; return 'settings';
@ -185,6 +194,18 @@ class Setting extends BaseModel {
this.keys_ = null; this.keys_ = null;
this.cache_ = []; this.cache_ = [];
this.customMetadata_ = {}; this.customMetadata_ = {};
this.fileHandler_ = null;
}
public static get settingFilePath(): string {
return `${this.value('profileDir')}/settings.json`;
}
private static get fileHandler(): FileHandler {
if (!this.fileHandler_) {
this.fileHandler_ = new FileHandler(this.settingFilePath);
}
return this.fileHandler_;
} }
static keychainService() { static keychainService() {
@ -239,6 +260,7 @@ class Setting extends BaseModel {
type: SettingItemType.Bool, type: SettingItemType.Bool,
public: false, public: false,
appTypes: ['desktop'], appTypes: ['desktop'],
storage: SettingStorage.File,
}, },
'sync.target': { 'sync.target': {
value: SyncTargetRegistry.nameToId('dropbox'), value: SyncTargetRegistry.nameToId('dropbox'),
@ -253,6 +275,7 @@ class Setting extends BaseModel {
options: () => { options: () => {
return SyncTargetRegistry.idAndLabelPlainObject(platform); return SyncTargetRegistry.idAndLabelPlainObject(platform);
}, },
storage: SettingStorage.File,
}, },
'sync.upgradeState': { 'sync.upgradeState': {
@ -278,6 +301,7 @@ class Setting extends BaseModel {
public: true, public: true,
label: () => _('Directory to synchronise with (absolute path)'), label: () => _('Directory to synchronise with (absolute path)'),
description: () => emptyDirWarning, description: () => emptyDirWarning,
storage: SettingStorage.File,
}, },
'sync.5.path': { 'sync.5.path': {
@ -290,6 +314,7 @@ class Setting extends BaseModel {
public: true, public: true,
label: () => _('Nextcloud WebDAV URL'), label: () => _('Nextcloud WebDAV URL'),
description: () => emptyDirWarning, description: () => emptyDirWarning,
storage: SettingStorage.File,
}, },
'sync.5.username': { 'sync.5.username': {
value: '', value: '',
@ -300,6 +325,7 @@ class Setting extends BaseModel {
}, },
public: true, public: true,
label: () => _('Nextcloud username'), label: () => _('Nextcloud username'),
storage: SettingStorage.File,
}, },
'sync.5.password': { 'sync.5.password': {
value: '', value: '',
@ -323,6 +349,7 @@ class Setting extends BaseModel {
public: true, public: true,
label: () => _('WebDAV URL'), label: () => _('WebDAV URL'),
description: () => emptyDirWarning, description: () => emptyDirWarning,
storage: SettingStorage.File,
}, },
'sync.6.username': { 'sync.6.username': {
value: '', value: '',
@ -333,6 +360,7 @@ class Setting extends BaseModel {
}, },
public: true, public: true,
label: () => _('WebDAV username'), label: () => _('WebDAV username'),
storage: SettingStorage.File,
}, },
'sync.6.password': { 'sync.6.password': {
value: '', value: '',
@ -363,6 +391,7 @@ class Setting extends BaseModel {
public: true, public: true,
label: () => _('AWS S3 bucket'), label: () => _('AWS S3 bucket'),
description: () => emptyDirWarning, description: () => emptyDirWarning,
storage: SettingStorage.File,
}, },
'sync.8.url': { 'sync.8.url': {
value: 'https://s3.amazonaws.com/', value: 'https://s3.amazonaws.com/',
@ -373,7 +402,7 @@ class Setting extends BaseModel {
}, },
public: true, public: true,
label: () => _('AWS S3 URL'), label: () => _('AWS S3 URL'),
secure: false, storage: SettingStorage.File,
}, },
'sync.8.username': { 'sync.8.username': {
value: '', value: '',
@ -384,6 +413,7 @@ class Setting extends BaseModel {
}, },
public: true, public: true,
label: () => _('AWS key'), label: () => _('AWS key'),
storage: SettingStorage.File,
}, },
'sync.8.password': { 'sync.8.password': {
value: '', value: '',
@ -407,6 +437,7 @@ class Setting extends BaseModel {
public: true, public: true,
label: () => _('Joplin Server URL'), label: () => _('Joplin Server URL'),
description: () => emptyDirWarning, description: () => emptyDirWarning,
storage: SettingStorage.File,
}, },
'sync.9.directory': { 'sync.9.directory': {
value: 'Apps/Joplin', value: 'Apps/Joplin',
@ -420,6 +451,7 @@ class Setting extends BaseModel {
}, },
public: true, public: true,
label: () => _('Joplin Server Directory'), label: () => _('Joplin Server Directory'),
storage: SettingStorage.File,
}, },
'sync.9.username': { 'sync.9.username': {
value: '', value: '',
@ -430,6 +462,7 @@ class Setting extends BaseModel {
}, },
public: true, public: true,
label: () => _('Joplin Server username'), label: () => _('Joplin Server username'),
storage: SettingStorage.File,
}, },
'sync.9.password': { 'sync.9.password': {
value: '', value: '',
@ -462,6 +495,7 @@ class Setting extends BaseModel {
auto: _('Auto'), auto: _('Auto'),
}; };
}, },
storage: SettingStorage.File,
}, },
'sync.3.auth': { value: '', type: SettingItemType.String, public: false }, 'sync.3.auth': { value: '', type: SettingItemType.String, public: false },
@ -478,7 +512,7 @@ class Setting extends BaseModel {
'sync.8.context': { value: '', type: SettingItemType.String, public: false }, 'sync.8.context': { value: '', type: SettingItemType.String, public: false },
'sync.9.context': { value: '', type: SettingItemType.String, public: false }, 'sync.9.context': { value: '', type: SettingItemType.String, public: false },
'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, public: true, advanced: true, section: 'sync', label: () => _('Max concurrent connections'), minimum: 1, maximum: 20, step: 1 }, 'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, storage: SettingStorage.File, public: true, advanced: true, section: 'sync', label: () => _('Max concurrent connections'), minimum: 1, maximum: 20, step: 1 },
// The active folder ID is guaranteed to be valid as long as there's at least one // The active folder ID is guaranteed to be valid as long as there's at least one
// existing folder, so it is a good default in contexts where there's no currently // existing folder, so it is a good default in contexts where there's no currently
@ -498,6 +532,7 @@ class Setting extends BaseModel {
options: () => { options: () => {
return ObjectUtils.sortByValue(supportedLocalesToLanguages({ includeStats: true })); return ObjectUtils.sortByValue(supportedLocalesToLanguages({ includeStats: true }));
}, },
storage: SettingStorage.File,
}, },
dateFormat: { dateFormat: {
value: Setting.DATE_FORMAT_1, value: Setting.DATE_FORMAT_1,
@ -517,6 +552,7 @@ class Setting extends BaseModel {
options[Setting.DATE_FORMAT_7] = time.formatMsToLocal(now, Setting.DATE_FORMAT_7); options[Setting.DATE_FORMAT_7] = time.formatMsToLocal(now, Setting.DATE_FORMAT_7);
return options; return options;
}, },
storage: SettingStorage.File,
}, },
timeFormat: { timeFormat: {
value: Setting.TIME_FORMAT_1, value: Setting.TIME_FORMAT_1,
@ -531,6 +567,7 @@ class Setting extends BaseModel {
options[Setting.TIME_FORMAT_2] = time.formatMsToLocal(now, Setting.TIME_FORMAT_2); options[Setting.TIME_FORMAT_2] = time.formatMsToLocal(now, Setting.TIME_FORMAT_2);
return options; return options;
}, },
storage: SettingStorage.File,
}, },
theme: { theme: {
@ -545,6 +582,7 @@ class Setting extends BaseModel {
label: () => _('Theme'), label: () => _('Theme'),
section: 'appearance', section: 'appearance',
options: () => themeOptions(), options: () => themeOptions(),
storage: SettingStorage.File,
}, },
themeAutoDetect: { themeAutoDetect: {
@ -554,6 +592,7 @@ class Setting extends BaseModel {
appTypes: ['desktop'], appTypes: ['desktop'],
public: true, public: true,
label: () => _('Automatically switch theme to match system theme'), label: () => _('Automatically switch theme to match system theme'),
storage: SettingStorage.File,
}, },
preferredLightTheme: { preferredLightTheme: {
@ -568,6 +607,7 @@ class Setting extends BaseModel {
label: () => _('Preferred light theme'), label: () => _('Preferred light theme'),
section: 'appearance', section: 'appearance',
options: () => themeOptions(), options: () => themeOptions(),
storage: SettingStorage.File,
}, },
preferredDarkTheme: { preferredDarkTheme: {
@ -582,6 +622,7 @@ class Setting extends BaseModel {
label: () => _('Preferred dark theme'), label: () => _('Preferred dark theme'),
section: 'appearance', section: 'appearance',
options: () => themeOptions(), options: () => themeOptions(),
storage: SettingStorage.File,
}, },
notificationPermission: { notificationPermission: {
@ -590,7 +631,7 @@ class Setting extends BaseModel {
public: false, public: false,
}, },
showNoteCounts: { value: true, type: SettingItemType.Bool, public: false, advanced: true, appTypes: ['desktop'], label: () => _('Show note counts') }, showNoteCounts: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, public: false, advanced: true, appTypes: ['desktop'], label: () => _('Show note counts') },
layoutButtonSequence: { layoutButtonSequence: {
value: Setting.LAYOUT_ALL, value: Setting.LAYOUT_ALL,
@ -604,9 +645,10 @@ class Setting extends BaseModel {
[Setting.LAYOUT_EDITOR_SPLIT]: _('%s / %s', _('Editor'), _('Split View')), [Setting.LAYOUT_EDITOR_SPLIT]: _('%s / %s', _('Editor'), _('Split View')),
[Setting.LAYOUT_VIEWER_SPLIT]: _('%s / %s', _('Viewer'), _('Split View')), [Setting.LAYOUT_VIEWER_SPLIT]: _('%s / %s', _('Viewer'), _('Split View')),
}), }),
storage: SettingStorage.File,
}, },
uncompletedTodosOnTop: { value: true, type: SettingItemType.Bool, section: 'note', public: true, appTypes: ['cli'], label: () => _('Uncompleted to-dos on top') }, uncompletedTodosOnTop: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, appTypes: ['cli'], label: () => _('Uncompleted to-dos on top') },
showCompletedTodos: { value: true, type: SettingItemType.Bool, section: 'note', public: true, appTypes: ['cli'], label: () => _('Show completed to-dos') }, showCompletedTodos: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, appTypes: ['cli'], label: () => _('Show completed to-dos') },
'notes.sortOrder.field': { 'notes.sortOrder.field': {
value: 'user_updated_time', value: 'user_updated_time',
type: SettingItemType.String, type: SettingItemType.String,
@ -624,6 +666,7 @@ class Setting extends BaseModel {
} }
return options; return options;
}, },
storage: SettingStorage.File,
}, },
'editor.autoMatchingBraces': { 'editor.autoMatchingBraces': {
value: true, value: true,
@ -632,8 +675,9 @@ class Setting extends BaseModel {
section: 'note', section: 'note',
appTypes: ['desktop'], appTypes: ['desktop'],
label: () => _('Auto-pair braces, parenthesis, quotations, etc.'), label: () => _('Auto-pair braces, parenthesis, quotations, etc.'),
storage: SettingStorage.File,
}, },
'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] }, 'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] },
'folders.sortOrder.field': { 'folders.sortOrder.field': {
value: 'title', value: 'title',
type: SettingItemType.String, type: SettingItemType.String,
@ -650,9 +694,10 @@ class Setting extends BaseModel {
} }
return options; return options;
}, },
storage: SettingStorage.File,
}, },
'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] }, 'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] },
trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', public: true, label: () => _('Save geo-location with notes') }, trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', storage: SettingStorage.File, public: true, label: () => _('Save geo-location with notes') },
// 2020-10-29: For now disable the beta editor due to // 2020-10-29: For now disable the beta editor due to
// underlying bugs in the TextInput component which we cannot // underlying bugs in the TextInput component which we cannot
@ -683,6 +728,7 @@ class Setting extends BaseModel {
body: _('Focus body'), body: _('Focus body'),
}; };
}, },
storage: SettingStorage.File,
}, },
newNoteFocus: { newNoteFocus: {
value: 'body', value: 'body',
@ -698,6 +744,7 @@ class Setting extends BaseModel {
body: _('Focus body'), body: _('Focus body'),
}; };
}, },
storage: SettingStorage.File,
}, },
'plugins.states': { 'plugins.states': {
@ -719,34 +766,35 @@ class Setting extends BaseModel {
appTypes: ['desktop'], appTypes: ['desktop'],
label: () => 'Development plugins', label: () => 'Development plugins',
description: () => 'You may add multiple plugin paths, each separated by a comma. You will need to restart the application for the changes to take effect.', description: () => 'You may add multiple plugin paths, each separated by a comma. You will need to restart the application for the changes to take effect.',
storage: SettingStorage.File,
}, },
// Deprecated - use markdown.plugin.* // Deprecated - use markdown.plugin.*
'markdown.softbreaks': { value: false, type: SettingItemType.Bool, public: false, appTypes: ['mobile', 'desktop'] }, 'markdown.softbreaks': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, public: false, appTypes: ['mobile', 'desktop'] },
'markdown.typographer': { value: false, type: SettingItemType.Bool, public: false, appTypes: ['mobile', 'desktop'] }, 'markdown.typographer': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, public: false, appTypes: ['mobile', 'desktop'] },
// Deprecated // Deprecated
'markdown.plugin.softbreaks': { value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable soft breaks')}${wysiwygYes}` }, 'markdown.plugin.softbreaks': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable soft breaks')}${wysiwygYes}` },
'markdown.plugin.typographer': { value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable typographer support')}${wysiwygYes}` }, 'markdown.plugin.typographer': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable typographer support')}${wysiwygYes}` },
'markdown.plugin.linkify': { value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable Linkify')}${wysiwygYes}` }, 'markdown.plugin.linkify': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable Linkify')}${wysiwygYes}` },
'markdown.plugin.katex': { value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable math expressions')}${wysiwygYes}` }, 'markdown.plugin.katex': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable math expressions')}${wysiwygYes}` },
'markdown.plugin.fountain': { value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable Fountain syntax support')}${wysiwygYes}` }, 'markdown.plugin.fountain': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable Fountain syntax support')}${wysiwygYes}` },
'markdown.plugin.mermaid': { value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable Mermaid diagrams support')}${wysiwygYes}` }, 'markdown.plugin.mermaid': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable Mermaid diagrams support')}${wysiwygYes}` },
'markdown.plugin.audioPlayer': { value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable audio player')}${wysiwygNo}` }, 'markdown.plugin.audioPlayer': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable audio player')}${wysiwygNo}` },
'markdown.plugin.videoPlayer': { value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable video player')}${wysiwygNo}` }, 'markdown.plugin.videoPlayer': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable video player')}${wysiwygNo}` },
'markdown.plugin.pdfViewer': { value: !mobilePlatform, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['desktop'], label: () => `${_('Enable PDF viewer')}${wysiwygNo}` }, 'markdown.plugin.pdfViewer': { storage: SettingStorage.File, value: !mobilePlatform, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['desktop'], label: () => `${_('Enable PDF viewer')}${wysiwygNo}` },
'markdown.plugin.mark': { value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ==mark== syntax')}${wysiwygNo}` }, 'markdown.plugin.mark': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ==mark== syntax')}${wysiwygNo}` },
'markdown.plugin.footnote': { value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable footnotes')}${wysiwygNo}` }, 'markdown.plugin.footnote': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable footnotes')}${wysiwygNo}` },
'markdown.plugin.toc': { value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable table of contents extension')}${wysiwygNo}` }, 'markdown.plugin.toc': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable table of contents extension')}${wysiwygNo}` },
'markdown.plugin.sub': { value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ~sub~ syntax')}${wysiwygNo}` }, 'markdown.plugin.sub': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ~sub~ syntax')}${wysiwygNo}` },
'markdown.plugin.sup': { value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ^sup^ syntax')}${wysiwygNo}` }, 'markdown.plugin.sup': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ^sup^ syntax')}${wysiwygNo}` },
'markdown.plugin.deflist': { value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable deflist syntax')}${wysiwygNo}` }, 'markdown.plugin.deflist': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable deflist syntax')}${wysiwygNo}` },
'markdown.plugin.abbr': { value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable abbreviation syntax')}${wysiwygNo}` }, 'markdown.plugin.abbr': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable abbreviation syntax')}${wysiwygNo}` },
'markdown.plugin.emoji': { value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable markdown emoji')}${wysiwygNo}` }, 'markdown.plugin.emoji': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable markdown emoji')}${wysiwygNo}` },
'markdown.plugin.insert': { value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ++insert++ syntax')}${wysiwygNo}` }, 'markdown.plugin.insert': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ++insert++ syntax')}${wysiwygNo}` },
'markdown.plugin.multitable': { value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` }, 'markdown.plugin.multitable': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
// Tray icon (called AppIndicator) doesn't work in Ubuntu // Tray icon (called AppIndicator) doesn't work in Ubuntu
// http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html // http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html
@ -762,9 +810,10 @@ class Setting extends BaseModel {
description: () => { description: () => {
return platform === 'linux' ? _('Note: Does not work in all desktop environments.') : _('This will allow Joplin to run in the background. It is recommended to enable this setting so that your notes are constantly being synchronised, thus reducing the number of conflicts.'); return platform === 'linux' ? _('Note: Does not work in all desktop environments.') : _('This will allow Joplin to run in the background. It is recommended to enable this setting so that your notes are constantly being synchronised, thus reducing the number of conflicts.');
}, },
storage: SettingStorage.File,
}, },
startMinimized: { value: false, type: SettingItemType.Bool, section: 'application', public: true, appTypes: ['desktop'], label: () => _('Start application minimised in the tray icon') }, startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: true, appTypes: ['desktop'], label: () => _('Start application minimised in the tray icon') },
collapsedFolderIds: { value: [], type: SettingItemType.Array, public: false }, collapsedFolderIds: { value: [], type: SettingItemType.Array, public: false },
@ -781,9 +830,9 @@ class Setting extends BaseModel {
}, },
// Deprecated in favour of windowContentZoomFactor // Deprecated in favour of windowContentZoomFactor
'style.zoom': { value: 100, type: SettingItemType.Int, public: false, appTypes: ['desktop'], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 }, 'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, appTypes: ['desktop'], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
'style.editor.fontSize': { value: 13, type: SettingItemType.Int, public: true, appTypes: ['desktop'], section: 'appearance', label: () => _('Editor font size'), minimum: 4, maximum: 50, step: 1 }, 'style.editor.fontSize': { value: 13, type: SettingItemType.Int, public: true, storage: SettingStorage.File, appTypes: ['desktop'], section: 'appearance', label: () => _('Editor font size'), minimum: 4, maximum: 50, step: 1 },
'style.editor.fontFamily': 'style.editor.fontFamily':
(mobilePlatform) ? (mobilePlatform) ?
({ ({
@ -809,6 +858,7 @@ class Setting extends BaseModel {
[Setting.FONT_MONOSPACE]: 'Monospace', [Setting.FONT_MONOSPACE]: 'Monospace',
}; };
}, },
storage: SettingStorage.File,
}) : { }) : {
value: '', value: '',
type: SettingItemType.String, type: SettingItemType.String,
@ -819,9 +869,10 @@ class Setting extends BaseModel {
description: () => description: () =>
_('This should be a *monospace* font or some elements will render incorrectly. If the font ' + _('This should be a *monospace* font or some elements will render incorrectly. If the font ' +
'is incorrect or empty, it will default to a generic monospace font.'), 'is incorrect or empty, it will default to a generic monospace font.'),
storage: SettingStorage.File,
}, },
'ui.layout': { value: {}, type: SettingItemType.Object, public: false, appTypes: ['desktop'] }, 'ui.layout': { value: {}, type: SettingItemType.Object, storage: SettingStorage.File, public: false, appTypes: ['desktop'] },
// TODO: Is there a better way to do this? The goal here is to simply have // TODO: Is there a better way to do this? The goal here is to simply have
// a way to display a link to the customizable stylesheets, not for it to // a way to display a link to the customizable stylesheets, not for it to
@ -864,9 +915,9 @@ class Setting extends BaseModel {
description: () => 'CSS file support is provided for your convenience, but they are advanced settings, and styles you define may break from one version to the next. If you want to use them, please know that it might require regular development work from you to keep them working. The Joplin team cannot make a commitment to keep the application HTML structure stable.', description: () => 'CSS file support is provided for your convenience, but they are advanced settings, and styles you define may break from one version to the next. If you want to use them, please know that it might require regular development work from you to keep them working. The Joplin team cannot make a commitment to keep the application HTML structure stable.',
}, },
autoUpdateEnabled: { value: false, type: SettingItemType.Bool, section: 'application', public: platform !== 'linux', appTypes: ['desktop'], label: () => _('Automatically update the application') }, autoUpdateEnabled: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: platform !== 'linux', appTypes: ['desktop'], label: () => _('Automatically update the application') },
'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', public: true, appTypes: ['desktop'], label: () => _('Get pre-releases when checking for updates'), description: () => _('See the pre-release page for more details: %s', 'https://joplinapp.org/prereleases') }, 'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, public: true, appTypes: ['desktop'], label: () => _('Get pre-releases when checking for updates'), description: () => _('See the pre-release page for more details: %s', 'https://joplinapp.org/prereleases') },
'clipperServer.autoStart': { value: false, type: SettingItemType.Bool, public: false }, 'clipperServer.autoStart': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, public: false },
'sync.interval': { 'sync.interval': {
value: 300, value: 300,
type: SettingItemType.Int, type: SettingItemType.Int,
@ -885,12 +936,13 @@ class Setting extends BaseModel {
86400: _('%d hours', 24), 86400: _('%d hours', 24),
}; };
}, },
storage: SettingStorage.File,
}, },
noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, public: false, appTypes: ['desktop'] }, noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, storage: SettingStorage.File, public: false, appTypes: ['desktop'] },
tagHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: ['desktop'] }, tagHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: ['desktop'] },
folderHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: ['desktop'] }, folderHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: ['desktop'] },
editor: { value: '', type: SettingItemType.String, subType: 'file_path_and_args', public: true, appTypes: ['cli', 'desktop'], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') }, editor: { value: '', type: SettingItemType.String, subType: 'file_path_and_args', storage: SettingStorage.File, public: true, appTypes: ['cli', 'desktop'], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') },
'export.pdfPageSize': { value: 'A4', type: SettingItemType.String, advanced: true, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page size for PDF export'), options: () => { 'export.pdfPageSize': { value: 'A4', type: SettingItemType.String, advanced: true, storage: SettingStorage.File, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page size for PDF export'), options: () => {
return { return {
'A4': _('A4'), 'A4': _('A4'),
'Letter': _('Letter'), 'Letter': _('Letter'),
@ -900,7 +952,7 @@ class Setting extends BaseModel {
'Legal': _('Legal'), 'Legal': _('Legal'),
}; };
} }, } },
'export.pdfPageOrientation': { value: 'portrait', type: SettingItemType.String, advanced: true, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page orientation for PDF export'), options: () => { 'export.pdfPageOrientation': { value: 'portrait', type: SettingItemType.String, storage: SettingStorage.File, advanced: true, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page orientation for PDF export'), options: () => {
return { return {
'portrait': _('Portrait'), 'portrait': _('Portrait'),
'landscape': _('Landscape'), 'landscape': _('Landscape'),
@ -922,6 +974,7 @@ class Setting extends BaseModel {
output['vim'] = _('Vim'); output['vim'] = _('Vim');
return output; return output;
}, },
storage: SettingStorage.File,
}, },
'editor.spellcheckBeta': { 'editor.spellcheckBeta': {
@ -945,6 +998,7 @@ class Setting extends BaseModel {
appTypes: ['desktop', 'cli'], appTypes: ['desktop', 'cli'],
label: () => _('Custom TLS certificates'), label: () => _('Custom TLS certificates'),
description: () => _('Comma-separated list of paths to directories to load the certificates from, or path to individual cert files. For example: /my/cert_dir, /other/custom.pem. Note that if you make changes to the TLS settings, you must save your changes before clicking on "Check synchronisation configuration".'), description: () => _('Comma-separated list of paths to directories to load the certificates from, or path to individual cert files. For example: /my/cert_dir, /other/custom.pem. Note that if you make changes to the TLS settings, you must save your changes before clicking on "Check synchronisation configuration".'),
storage: SettingStorage.File,
}, },
'net.ignoreTlsErrors': { 'net.ignoreTlsErrors': {
value: false, value: false,
@ -957,6 +1011,7 @@ class Setting extends BaseModel {
public: true, public: true,
appTypes: ['desktop', 'cli'], appTypes: ['desktop', 'cli'],
label: () => _('Ignore TLS certificate errors'), label: () => _('Ignore TLS certificate errors'),
storage: SettingStorage.File,
}, },
'sync.wipeOutFailSafe': { 'sync.wipeOutFailSafe': {
@ -967,10 +1022,11 @@ class Setting extends BaseModel {
section: 'sync', section: 'sync',
label: () => _('Fail-safe'), label: () => _('Fail-safe'),
description: () => _('Fail-safe: Do not wipe out local data when sync target is empty (often the result of a misconfiguration or bug)'), description: () => _('Fail-safe: Do not wipe out local data when sync target is empty (often the result of a misconfiguration or bug)'),
storage: SettingStorage.File,
}, },
'api.token': { value: null, type: SettingItemType.String, public: false }, 'api.token': { value: null, type: SettingItemType.String, public: false, storage: SettingStorage.File },
'api.port': { value: null, type: SettingItemType.Int, public: true, appTypes: ['cli'], description: () => _('Specify the port that should be used by the API server. If not set, a default will be used.') }, 'api.port': { value: null, type: SettingItemType.Int, storage: SettingStorage.File, public: true, appTypes: ['cli'], description: () => _('Specify the port that should be used by the API server. If not set, a default will be used.') },
'resourceService.lastProcessedChangeId': { value: 0, type: SettingItemType.Int, public: false }, 'resourceService.lastProcessedChangeId': { value: 0, type: SettingItemType.Int, public: false },
'searchEngine.lastProcessedChangeId': { value: 0, type: SettingItemType.Int, public: false }, 'searchEngine.lastProcessedChangeId': { value: 0, type: SettingItemType.Int, public: false },
@ -978,7 +1034,7 @@ class Setting extends BaseModel {
'searchEngine.initialIndexingDone': { value: false, type: SettingItemType.Bool, public: false }, 'searchEngine.initialIndexingDone': { value: false, type: SettingItemType.Bool, public: false },
'revisionService.enabled': { section: 'revisionService', value: true, type: SettingItemType.Bool, public: true, label: () => _('Enable note history') }, 'revisionService.enabled': { section: 'revisionService', storage: SettingStorage.File, value: true, type: SettingItemType.Bool, public: true, label: () => _('Enable note history') },
'revisionService.ttlDays': { 'revisionService.ttlDays': {
section: 'revisionService', section: 'revisionService',
value: 90, value: 90,
@ -991,6 +1047,7 @@ class Setting extends BaseModel {
return value === null ? _('days') : _('%d days', value); return value === null ? _('days') : _('%d days', value);
}, },
label: () => _('Keep note history for'), label: () => _('Keep note history for'),
storage: SettingStorage.File,
}, },
'revisionService.intervalBetweenRevisions': { section: 'revisionService', value: 1000 * 60 * 10, type: SettingItemType.Int, public: false }, 'revisionService.intervalBetweenRevisions': { section: 'revisionService', value: 1000 * 60 * 10, type: SettingItemType.Int, public: false },
'revisionService.oldNoteInterval': { section: 'revisionService', value: 1000 * 60 * 60 * 24 * 7, type: SettingItemType.Int, public: false }, 'revisionService.oldNoteInterval': { section: 'revisionService', value: 1000 * 60 * 60 * 24 * 7, type: SettingItemType.Int, public: false },
@ -1001,8 +1058,8 @@ class Setting extends BaseModel {
'camera.type': { value: 0, type: SettingItemType.Int, public: false, appTypes: ['mobile'] }, 'camera.type': { value: 0, type: SettingItemType.Int, public: false, appTypes: ['mobile'] },
'camera.ratio': { value: '4:3', type: SettingItemType.String, public: false, appTypes: ['mobile'] }, 'camera.ratio': { value: '4:3', type: SettingItemType.String, public: false, appTypes: ['mobile'] },
'spellChecker.enabled': { value: true, type: SettingItemType.Bool, public: false }, 'spellChecker.enabled': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, public: false },
'spellChecker.language': { value: '', type: SettingItemType.String, public: false }, 'spellChecker.language': { value: '', type: SettingItemType.String, storage: SettingStorage.File, public: false },
windowContentZoomFactor: { windowContentZoomFactor: {
value: 100, value: 100,
@ -1012,6 +1069,7 @@ class Setting extends BaseModel {
minimum: 30, minimum: 30,
maximum: 300, maximum: 300,
step: 10, step: 10,
storage: SettingStorage.File,
}, },
'layout.folderList.factor': { 'layout.folderList.factor': {
@ -1026,6 +1084,7 @@ class Setting extends BaseModel {
'to fit the available space in its container with respect to the other items. ' + 'to fit the available space in its container with respect to the other items. ' +
'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' + 'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' +
'Restart app to see changes.'), 'Restart app to see changes.'),
storage: SettingStorage.File,
}, },
'layout.noteList.factor': { 'layout.noteList.factor': {
value: 1, value: 1,
@ -1039,6 +1098,7 @@ class Setting extends BaseModel {
'to fit the available space in its container with respect to the other items. ' + 'to fit the available space in its container with respect to the other items. ' +
'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' + 'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' +
'Restart app to see changes.'), 'Restart app to see changes.'),
storage: SettingStorage.File,
}, },
'layout.note.factor': { 'layout.note.factor': {
value: 2, value: 2,
@ -1052,6 +1112,7 @@ class Setting extends BaseModel {
'to fit the available space in its container with respect to the other items. ' + 'to fit the available space in its container with respect to the other items. ' +
'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' + 'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' +
'Restart app to see changes.'), 'Restart app to see changes.'),
storage: SettingStorage.File,
}, },
}; };
@ -1151,19 +1212,24 @@ class Setting extends BaseModel {
} }
// Low-level method to load a setting directly from the database. Should not be used in most cases. // Low-level method to load a setting directly from the database. Should not be used in most cases.
static loadOne(key: string) { public static async loadOne(key: string) {
if (this.keyStorage(key) === SettingStorage.File) {
const fromFile = await this.fileHandler.load();
return fromFile[key];
} else {
return this.modelSelectOne('SELECT * FROM settings WHERE key = ?', [key]); return this.modelSelectOne('SELECT * FROM settings WHERE key = ?', [key]);
} }
}
static load() { static load() {
this.cancelScheduleSave(); this.cancelScheduleSave();
this.cancelScheduleChangeEvent(); this.cancelScheduleChangeEvent();
this.cache_ = []; this.cache_ = [];
return this.modelSelectAll('SELECT * FROM settings').then(async (rows: any[]) => { return this.modelSelectAll('SELECT * FROM settings').then(async (rows: CacheItem[]) => {
this.cache_ = []; this.cache_ = [];
const pushItemsToCache = (items: any[]) => { const pushItemsToCache = (items: CacheItem[]) => {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const c = items[i]; const c = items[i];
@ -1183,7 +1249,7 @@ class Setting extends BaseModel {
const rowKeys = rows.map((r: any) => r.key); const rowKeys = rows.map((r: any) => r.key);
const secureKeys = this.keys(false, null, { secureOnly: true }); const secureKeys = this.keys(false, null, { secureOnly: true });
const secureItems = []; const secureItems: CacheItem[] = [];
for (const key of secureKeys) { for (const key of secureKeys) {
if (rowKeys.includes(key)) continue; if (rowKeys.includes(key)) continue;
@ -1196,13 +1262,36 @@ class Setting extends BaseModel {
} }
} }
const itemsFromFile: CacheItem[] = [];
if (this.canUseFileStorage()) {
const fromFile = await this.fileHandler.load();
for (const k of Object.keys(fromFile)) {
itemsFromFile.push({
key: k,
value: this.filterValue(k, this.formatValue(k, fromFile[k])),
});
}
}
pushItemsToCache(rows); pushItemsToCache(rows);
pushItemsToCache(secureItems); pushItemsToCache(secureItems);
pushItemsToCache(itemsFromFile);
this.dispatchUpdateAll(); this.dispatchUpdateAll();
}); });
} }
private static canUseFileStorage(): boolean {
return !shim.mobilePlatform();
}
private static keyStorage(key: string): SettingStorage {
if (!this.canUseFileStorage()) return SettingStorage.Database;
const md = this.settingMetadata(key);
return md.storage || SettingStorage.Database;
}
static toPlainObject() { static toPlainObject() {
const keys = this.keys(); const keys = this.keys();
const keyToValues: any = {}; const keyToValues: any = {};
@ -1475,12 +1564,14 @@ class Setting extends BaseModel {
const keys = this.keys(); const keys = this.keys();
const valuesForFile: SettingValues = {};
const queries = []; const queries = [];
queries.push(`DELETE FROM settings WHERE key IN ("${keys.join('","')}")`); queries.push(`DELETE FROM settings WHERE key IN ("${keys.join('","')}")`);
for (let i = 0; i < this.cache_.length; i++) { for (let i = 0; i < this.cache_.length; i++) {
const s = Object.assign({}, this.cache_[i]); const s = Object.assign({}, this.cache_[i]);
s.value = this.valueToString(s.key, s.value); const valueAsString = this.valueToString(s.key, s.value);
if (this.isSecureKey(s.key)) { if (this.isSecureKey(s.key)) {
// We need to be careful here because there's a bug in the macOS keychain that can // We need to be careful here because there's a bug in the macOS keychain that can
@ -1497,8 +1588,8 @@ class Setting extends BaseModel {
try { try {
const passwordName = `setting.${s.key}`; const passwordName = `setting.${s.key}`;
const currentValue = await this.keychainService().password(passwordName); const currentValue = await this.keychainService().password(passwordName);
if (currentValue !== s.value) { if (currentValue !== valueAsString) {
const wasSet = await this.keychainService().setPassword(passwordName, s.value); const wasSet = await this.keychainService().setPassword(passwordName, valueAsString);
if (wasSet) continue; if (wasSet) continue;
} else { } else {
// The value is already in the keychain - so nothing to do // The value is already in the keychain - so nothing to do
@ -1511,11 +1602,20 @@ class Setting extends BaseModel {
} }
} }
queries.push(Database.insertQuery(this.tableName(), s)); if (this.keyStorage(s.key) === SettingStorage.File) {
valuesForFile[s.key] = s.value;
} else {
queries.push(Database.insertQuery(this.tableName(), {
key: s.key,
value: valueAsString,
}));
}
} }
await BaseModel.db().transactionExecBatch(queries); await BaseModel.db().transactionExecBatch(queries);
if (this.canUseFileStorage()) await this.fileHandler.save(valuesForFile);
this.logger().debug('Settings have been saved.'); this.logger().debug('Settings have been saved.');
} }

View File

@ -0,0 +1,54 @@
import Logger from '../../Logger';
import shim from '../../shim';
import Setting from '../Setting';
const logger = Logger.create('Settings');
export type SettingValues = Record<string, any>;
export default class FileHandler {
private filePath_: string;
private valueJsonCache_: string = null;
public constructor(filePath: string) {
this.filePath_ = filePath;
}
public async load(): Promise<SettingValues> {
if (this.valueJsonCache_) return JSON.parse(this.valueJsonCache_);
if (!(await shim.fsDriver().exists(this.filePath_))) {
this.valueJsonCache_ = '{}';
} else {
this.valueJsonCache_ = await shim.fsDriver().readFile(this.filePath_, 'utf8');
}
try {
const values = JSON.parse(this.valueJsonCache_);
delete values['$id'];
delete values['$schema'];
return values;
} catch (error) {
// Most likely the user entered invalid JSON - in this case we move
// the broken file to a new name (otherwise it would be overwritten
// by valid JSON and user will lose all their settings).
logger.error(`Could not parse JSON file: ${this.filePath_}`, error);
await shim.fsDriver().move(this.filePath_, `${this.filePath_}-${Date.now()}-invalid.bak`);
return {};
}
}
public async save(values: SettingValues) {
const json = `${JSON.stringify({
'$schema': Setting.schemaUrl,
...values,
}, null, '\t')}\n`;
if (json === this.valueJsonCache_) return;
await shim.fsDriver().writeFile(this.filePath_, json, 'utf8');
this.valueJsonCache_ = json;
}
}