You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-23 23:33:01 +02:00
Compare commits
72 Commits
release-2.
...
multi_prof
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6cd988939 | ||
|
|
a99eec7cdf | ||
|
|
7ef09dfa77 | ||
|
|
b11573a2a7 | ||
|
|
f4034b1ff0 | ||
|
|
58bf93a112 | ||
|
|
5962b0813e | ||
|
|
cffea3ea1e | ||
|
|
dfadacd7f4 | ||
|
|
ecc7b17708 | ||
|
|
ee6ab55649 | ||
|
|
b0d64e2f51 | ||
|
|
f6e21e0180 | ||
|
|
e02422070e | ||
|
|
727d64b646 | ||
|
|
23e54a60d9 | ||
|
|
0d4978223e | ||
|
|
0b32a29cce | ||
|
|
f322d40910 | ||
|
|
557cb9a6c3 | ||
|
|
d5a55c7908 | ||
|
|
7308bbd3ca | ||
|
|
c1e8f9befd | ||
|
|
a0d77d10ba | ||
|
|
bdd9c6cf35 | ||
|
|
f2bfa30e04 | ||
|
|
8077117e65 | ||
|
|
7e8927398a | ||
|
|
09dcee876c | ||
|
|
23b56f4f70 | ||
|
|
b3d09ce776 | ||
|
|
84d40b805e | ||
|
|
c097a82b7b | ||
|
|
dfa22b560e | ||
|
|
7d31a3fe90 | ||
|
|
79aabc2d06 | ||
|
|
70e82ca64f | ||
|
|
a0662412b2 | ||
|
|
220b48ef02 | ||
|
|
cb637e817b | ||
|
|
27198a16a4 | ||
|
|
1a5bff3bf4 | ||
|
|
571147acbb | ||
|
|
9d9420a35c | ||
|
|
a79bc69604 | ||
|
|
3153d3a1b6 | ||
|
|
df8c265ee4 | ||
|
|
3725b14e04 | ||
|
|
a73d822998 | ||
|
|
a62e1fba96 | ||
|
|
37d51c3b58 | ||
|
|
d5dfecc19f | ||
|
|
8f8cc12d79 | ||
|
|
8e1802409f | ||
|
|
42b2f2146c | ||
|
|
2ba1563d92 | ||
|
|
a679b21119 | ||
|
|
dd10b6ac65 | ||
|
|
32600df7ce | ||
|
|
8a1cfabfc6 | ||
|
|
1b2046f2fa | ||
|
|
a1d5168918 | ||
|
|
047c1fb1a5 | ||
|
|
e83d662555 | ||
|
|
5c3b2671bf | ||
|
|
02a2dce605 | ||
|
|
c4e158d0fb | ||
|
|
fa8a1c2122 | ||
|
|
3f732939d0 | ||
|
|
fb8886db4b | ||
|
|
eb86e9c896 | ||
|
|
85e3a44276 |
@@ -148,6 +148,9 @@ packages/app-desktop/checkForUpdates.js.map
|
||||
packages/app-desktop/commands/copyDevCommand.d.ts
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
packages/app-desktop/commands/copyDevCommand.js.map
|
||||
packages/app-desktop/commands/editProfileConfig.d.ts
|
||||
packages/app-desktop/commands/editProfileConfig.js
|
||||
packages/app-desktop/commands/editProfileConfig.js.map
|
||||
packages/app-desktop/commands/exportFolders.d.ts
|
||||
packages/app-desktop/commands/exportFolders.js
|
||||
packages/app-desktop/commands/exportFolders.js.map
|
||||
@@ -175,6 +178,18 @@ packages/app-desktop/commands/startExternalEditing.js.map
|
||||
packages/app-desktop/commands/stopExternalEditing.d.ts
|
||||
packages/app-desktop/commands/stopExternalEditing.js
|
||||
packages/app-desktop/commands/stopExternalEditing.js.map
|
||||
packages/app-desktop/commands/switchProfile.d.ts
|
||||
packages/app-desktop/commands/switchProfile.js
|
||||
packages/app-desktop/commands/switchProfile.js.map
|
||||
packages/app-desktop/commands/switchProfile1.d.ts
|
||||
packages/app-desktop/commands/switchProfile1.js
|
||||
packages/app-desktop/commands/switchProfile1.js.map
|
||||
packages/app-desktop/commands/switchProfile2.d.ts
|
||||
packages/app-desktop/commands/switchProfile2.js
|
||||
packages/app-desktop/commands/switchProfile2.js.map
|
||||
packages/app-desktop/commands/switchProfile3.d.ts
|
||||
packages/app-desktop/commands/switchProfile3.js
|
||||
packages/app-desktop/commands/switchProfile3.js.map
|
||||
packages/app-desktop/commands/toggleExternalEditing.d.ts
|
||||
packages/app-desktop/commands/toggleExternalEditing.js
|
||||
packages/app-desktop/commands/toggleExternalEditing.js.map
|
||||
@@ -259,6 +274,9 @@ packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js.map
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.d.ts
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.js
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.js
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js.map
|
||||
@@ -490,6 +508,12 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js.map
|
||||
@@ -724,6 +748,9 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map
|
||||
packages/app-desktop/gui/utils/loadScript.d.ts
|
||||
packages/app-desktop/gui/utils/loadScript.js
|
||||
packages/app-desktop/gui/utils/loadScript.js.map
|
||||
packages/app-desktop/loadResources.testEnv.d.ts
|
||||
packages/app-desktop/loadResources.testEnv.js
|
||||
packages/app-desktop/loadResources.testEnv.js.map
|
||||
packages/app-desktop/plugins/GotoAnything.d.ts
|
||||
packages/app-desktop/plugins/GotoAnything.js
|
||||
packages/app-desktop/plugins/GotoAnything.js.map
|
||||
@@ -1237,6 +1264,9 @@ packages/lib/services/DecryptionWorker.js.map
|
||||
packages/lib/services/ExternalEditWatcher.d.ts
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
packages/lib/services/ExternalEditWatcher.js.map
|
||||
packages/lib/services/ExternalEditWatcher/utils.d.ts
|
||||
packages/lib/services/ExternalEditWatcher/utils.js
|
||||
packages/lib/services/ExternalEditWatcher/utils.js.map
|
||||
packages/lib/services/ItemChangeUtils.d.ts
|
||||
packages/lib/services/ItemChangeUtils.js
|
||||
packages/lib/services/ItemChangeUtils.js.map
|
||||
@@ -1561,6 +1591,24 @@ packages/lib/services/plugins/utils/validatePluginVersion.js.map
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.d.ts
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.js
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.js.map
|
||||
packages/lib/services/profileConfig/index.d.ts
|
||||
packages/lib/services/profileConfig/index.js
|
||||
packages/lib/services/profileConfig/index.js.map
|
||||
packages/lib/services/profileConfig/index.test.d.ts
|
||||
packages/lib/services/profileConfig/index.test.js
|
||||
packages/lib/services/profileConfig/index.test.js.map
|
||||
packages/lib/services/profileConfig/initProfile.d.ts
|
||||
packages/lib/services/profileConfig/initProfile.js
|
||||
packages/lib/services/profileConfig/initProfile.js.map
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.d.ts
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js.map
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.d.ts
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js.map
|
||||
packages/lib/services/profileConfig/types.d.ts
|
||||
packages/lib/services/profileConfig/types.js
|
||||
packages/lib/services/profileConfig/types.js.map
|
||||
packages/lib/services/rest/Api.d.ts
|
||||
packages/lib/services/rest/Api.js
|
||||
packages/lib/services/rest/Api.js.map
|
||||
@@ -1639,6 +1687,9 @@ packages/lib/services/searchengine/SearchEngineUtils.js.map
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.d.ts
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.js
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.js.map
|
||||
packages/lib/services/searchengine/SearchFilter.test.d.ts
|
||||
packages/lib/services/searchengine/SearchFilter.test.js
|
||||
packages/lib/services/searchengine/SearchFilter.test.js.map
|
||||
packages/lib/services/searchengine/filterParser.d.ts
|
||||
packages/lib/services/searchengine/filterParser.js
|
||||
packages/lib/services/searchengine/filterParser.js.map
|
||||
@@ -2011,6 +2062,9 @@ packages/tools/website/build.js.map
|
||||
packages/tools/website/updateDownloadPage.d.ts
|
||||
packages/tools/website/updateDownloadPage.js
|
||||
packages/tools/website/updateDownloadPage.js.map
|
||||
packages/tools/website/updateNews.d.ts
|
||||
packages/tools/website/updateNews.js
|
||||
packages/tools/website/updateNews.js.map
|
||||
packages/tools/website/utils/frontMatter.d.ts
|
||||
packages/tools/website/utils/frontMatter.js
|
||||
packages/tools/website/utils/frontMatter.js.map
|
||||
|
||||
3
.github/workflows/github-actions-main.yml
vendored
3
.github/workflows/github-actions-main.yml
vendored
@@ -5,7 +5,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-2016]
|
||||
# Removed windows-2016 for now - discontinued by GitHub
|
||||
os: [macos-latest, ubuntu-latest]
|
||||
steps:
|
||||
|
||||
# Silence apt-get update errors (for example when a module doesn't
|
||||
|
||||
54
.gitignore
vendored
54
.gitignore
vendored
@@ -138,6 +138,9 @@ packages/app-desktop/checkForUpdates.js.map
|
||||
packages/app-desktop/commands/copyDevCommand.d.ts
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
packages/app-desktop/commands/copyDevCommand.js.map
|
||||
packages/app-desktop/commands/editProfileConfig.d.ts
|
||||
packages/app-desktop/commands/editProfileConfig.js
|
||||
packages/app-desktop/commands/editProfileConfig.js.map
|
||||
packages/app-desktop/commands/exportFolders.d.ts
|
||||
packages/app-desktop/commands/exportFolders.js
|
||||
packages/app-desktop/commands/exportFolders.js.map
|
||||
@@ -165,6 +168,18 @@ packages/app-desktop/commands/startExternalEditing.js.map
|
||||
packages/app-desktop/commands/stopExternalEditing.d.ts
|
||||
packages/app-desktop/commands/stopExternalEditing.js
|
||||
packages/app-desktop/commands/stopExternalEditing.js.map
|
||||
packages/app-desktop/commands/switchProfile.d.ts
|
||||
packages/app-desktop/commands/switchProfile.js
|
||||
packages/app-desktop/commands/switchProfile.js.map
|
||||
packages/app-desktop/commands/switchProfile1.d.ts
|
||||
packages/app-desktop/commands/switchProfile1.js
|
||||
packages/app-desktop/commands/switchProfile1.js.map
|
||||
packages/app-desktop/commands/switchProfile2.d.ts
|
||||
packages/app-desktop/commands/switchProfile2.js
|
||||
packages/app-desktop/commands/switchProfile2.js.map
|
||||
packages/app-desktop/commands/switchProfile3.d.ts
|
||||
packages/app-desktop/commands/switchProfile3.js
|
||||
packages/app-desktop/commands/switchProfile3.js.map
|
||||
packages/app-desktop/commands/toggleExternalEditing.d.ts
|
||||
packages/app-desktop/commands/toggleExternalEditing.js
|
||||
packages/app-desktop/commands/toggleExternalEditing.js.map
|
||||
@@ -249,6 +264,9 @@ packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js.map
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.d.ts
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.js
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.js
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js.map
|
||||
@@ -480,6 +498,12 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js.map
|
||||
@@ -714,6 +738,9 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map
|
||||
packages/app-desktop/gui/utils/loadScript.d.ts
|
||||
packages/app-desktop/gui/utils/loadScript.js
|
||||
packages/app-desktop/gui/utils/loadScript.js.map
|
||||
packages/app-desktop/loadResources.testEnv.d.ts
|
||||
packages/app-desktop/loadResources.testEnv.js
|
||||
packages/app-desktop/loadResources.testEnv.js.map
|
||||
packages/app-desktop/plugins/GotoAnything.d.ts
|
||||
packages/app-desktop/plugins/GotoAnything.js
|
||||
packages/app-desktop/plugins/GotoAnything.js.map
|
||||
@@ -1227,6 +1254,9 @@ packages/lib/services/DecryptionWorker.js.map
|
||||
packages/lib/services/ExternalEditWatcher.d.ts
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
packages/lib/services/ExternalEditWatcher.js.map
|
||||
packages/lib/services/ExternalEditWatcher/utils.d.ts
|
||||
packages/lib/services/ExternalEditWatcher/utils.js
|
||||
packages/lib/services/ExternalEditWatcher/utils.js.map
|
||||
packages/lib/services/ItemChangeUtils.d.ts
|
||||
packages/lib/services/ItemChangeUtils.js
|
||||
packages/lib/services/ItemChangeUtils.js.map
|
||||
@@ -1551,6 +1581,24 @@ packages/lib/services/plugins/utils/validatePluginVersion.js.map
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.d.ts
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.js
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.js.map
|
||||
packages/lib/services/profileConfig/index.d.ts
|
||||
packages/lib/services/profileConfig/index.js
|
||||
packages/lib/services/profileConfig/index.js.map
|
||||
packages/lib/services/profileConfig/index.test.d.ts
|
||||
packages/lib/services/profileConfig/index.test.js
|
||||
packages/lib/services/profileConfig/index.test.js.map
|
||||
packages/lib/services/profileConfig/initProfile.d.ts
|
||||
packages/lib/services/profileConfig/initProfile.js
|
||||
packages/lib/services/profileConfig/initProfile.js.map
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.d.ts
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js.map
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.d.ts
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js.map
|
||||
packages/lib/services/profileConfig/types.d.ts
|
||||
packages/lib/services/profileConfig/types.js
|
||||
packages/lib/services/profileConfig/types.js.map
|
||||
packages/lib/services/rest/Api.d.ts
|
||||
packages/lib/services/rest/Api.js
|
||||
packages/lib/services/rest/Api.js.map
|
||||
@@ -1629,6 +1677,9 @@ packages/lib/services/searchengine/SearchEngineUtils.js.map
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.d.ts
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.js
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.js.map
|
||||
packages/lib/services/searchengine/SearchFilter.test.d.ts
|
||||
packages/lib/services/searchengine/SearchFilter.test.js
|
||||
packages/lib/services/searchengine/SearchFilter.test.js.map
|
||||
packages/lib/services/searchengine/filterParser.d.ts
|
||||
packages/lib/services/searchengine/filterParser.js
|
||||
packages/lib/services/searchengine/filterParser.js.map
|
||||
@@ -2001,6 +2052,9 @@ packages/tools/website/build.js.map
|
||||
packages/tools/website/updateDownloadPage.d.ts
|
||||
packages/tools/website/updateDownloadPage.js
|
||||
packages/tools/website/updateDownloadPage.js.map
|
||||
packages/tools/website/updateNews.d.ts
|
||||
packages/tools/website/updateNews.js
|
||||
packages/tools/website/updateNews.js.map
|
||||
packages/tools/website/utils/frontMatter.d.ts
|
||||
packages/tools/website/utils/frontMatter.js
|
||||
packages/tools/website/utils/frontMatter.js.map
|
||||
|
||||
@@ -1073,6 +1073,10 @@ footer .bottom-links-row p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.joplin-cloud-feature-list table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.price-row .plan-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
</div>
|
||||
|
||||
<div class="plan-price plan-price-monthly">
|
||||
{{priceMonthly.formattedMonthlyAmount}}<sub class="per-month"> /month</sub>
|
||||
{{priceMonthly.formattedMonthlyAmount}}<sub class="per-month"> /month{{#footnote}} (*){{/footnote}}</sub>
|
||||
</div>
|
||||
|
||||
<div class="plan-price plan-price-yearly">
|
||||
{{priceYearly.formattedMonthlyAmount}}<sub class="per-month"> /month</sub>
|
||||
{{priceYearly.formattedMonthlyAmount}}<sub class="per-month"> /month{{#footnote}} (*){{/footnote}}</sub>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,17 +20,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#featuresOn}}
|
||||
{{#featureLabelsOn}}
|
||||
<p><i class="fas fa-check feature feature-on"></i>{{.}}</p>
|
||||
{{/featuresOn}}
|
||||
{{/featureLabelsOn}}
|
||||
|
||||
{{#featuresOff}}
|
||||
{{#featureLabelsOff}}
|
||||
<p class="unchecked-text"><i class="fas fa-times feature feature-off"></i>{{.}}</p>
|
||||
{{/featuresOff}}
|
||||
{{/featureLabelsOff}}
|
||||
|
||||
<p class="text-center subscribe-wrapper">
|
||||
<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton">{{cfaLabel}}</a>
|
||||
</p>
|
||||
|
||||
{{#footnote}}<sub>(*) {{.}}</sub>{{/footnote}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -42,13 +42,23 @@
|
||||
{{> plan}}
|
||||
{{/plans.pro}}
|
||||
|
||||
{{#plans.business}}
|
||||
{{#plans.teams}}
|
||||
{{> plan}}
|
||||
{{/plans.business}}
|
||||
{{/plans.teams}}
|
||||
|
||||
<p class="joplin-cloud-login-info">Already have a Joplin Cloud account? <a href="https://joplincloud.com">Login now</a></p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<h1>Feature comparison</h1>
|
||||
<div class="joplin-cloud-feature-list">
|
||||
{{{featureListHtml}}}
|
||||
</div>
|
||||
<p> </p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{{{faqHtml}}}
|
||||
</div>
|
||||
|
||||
@@ -28,11 +28,11 @@ Three types of applications are available: for **desktop** (Windows, macOS and L
|
||||
|
||||
Operating System | Download
|
||||
---|---
|
||||
Windows (32 and 64-bit) | <a href='https://github.com/laurent22/joplin/releases/download/v2.7.14/Joplin-Setup-2.7.14.exe'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://github.com/laurent22/joplin/releases/download/v2.7.14/Joplin-2.7.14.dmg'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://github.com/laurent22/joplin/releases/download/v2.7.14/Joplin-2.7.14.AppImage'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
|
||||
Windows (32 and 64-bit) | <a href='https://github.com/laurent22/joplin/releases/download/v2.7.15/Joplin-Setup-2.7.15.exe'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://github.com/laurent22/joplin/releases/download/v2.7.15/Joplin-2.7.15.dmg'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://github.com/laurent22/joplin/releases/download/v2.7.15/Joplin-2.7.15.AppImage'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
|
||||
|
||||
**On Windows**, you may also use the <a href='https://github.com/laurent22/joplin/releases/download/v2.7.14/JoplinPortable.exe'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
|
||||
**On Windows**, you may also use the <a href='https://github.com/laurent22/joplin/releases/download/v2.7.15/JoplinPortable.exe'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
|
||||
|
||||
**On Linux**, the recommended way is to use the following installation script as it will handle the desktop icon too:
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
# This is a sample docker-compose file that can be used to run Joplin Server
|
||||
# along with a PostgreSQL server.
|
||||
#
|
||||
# All environment variables are optional. If you don't set them, you will get a
|
||||
# warning from docker-compose, however the app should use working defaults.
|
||||
# Update the following fields in the stanza below:
|
||||
#
|
||||
# POSTGRES_USER
|
||||
# POSTGRES_PASSWORD
|
||||
# APP_BASE_URL
|
||||
#
|
||||
# APP_BASE_URL: This is the base public URL where the service will be running.
|
||||
# - If Joplin Server needs to be accessible over the internet, configure APP_BASE_URL as follows: https://example.com/joplin.
|
||||
# - If Joplin Server does not need to be accessible over the internet, set the the APP_BASE_URL to your server's hostname.
|
||||
# For Example: http://[hostname]:22300. The base URL can include the port.
|
||||
# APP_PORT: The local port on which the Docker container will listen.
|
||||
# - This would typically be mapped to port to 443 (TLS) with a reverse proxy.
|
||||
# - If Joplin Server does not need to be accessible over the internet, the port can be mapped to 22300.
|
||||
|
||||
version: '3'
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"buildCommandIndex": "gulp buildCommandIndex",
|
||||
"buildPluginDoc": "typedoc --name 'Joplin Plugin API Documentation' --mode file -theme './Assets/PluginDocTheme/' --readme './Assets/PluginDocTheme/index.md' --excludeNotExported --excludeExternals --excludePrivate --excludeProtected --out ../joplin-website/docs/api/references/plugin_api packages/lib/services/plugins/api/",
|
||||
"updateMarkdownDoc": "node ./packages/tools/updateMarkdownDoc",
|
||||
"updateNews": "node ./packages/tools/website/updateNews",
|
||||
"buildSettingJsonSchema": "yarn workspace joplin start settingschema ../../../joplin-website/docs/schema/settings.json",
|
||||
"buildTranslations": "node packages/tools/build-translation.js",
|
||||
"buildWebsite": "node ./packages/tools/website/build.js && yarn run buildPluginDoc && yarn run buildSettingJsonSchema",
|
||||
|
||||
@@ -313,6 +313,14 @@ async function fetchAllNotes() {
|
||||
lines.push('');
|
||||
lines.push('\tcurl -F \'data=@/path/to/file.jpg\' -F \'props={"title":"my resource title"}\' http://localhost:41184/resources');
|
||||
lines.push('');
|
||||
lines.push('To **update** the resource content, you can make a PUT request with the same arguments:');
|
||||
lines.push('');
|
||||
lines.push('\tcurl -X PUT -F \'data=@/path/to/file.jpg\' -F \'props={"title":"my modified title"}\' http://localhost:41184/resources/8fe1417d7b184324bf6b0122b76c4696');
|
||||
lines.push('');
|
||||
lines.push('Or if you only need to update the resource properties (title, etc.), without changing the content, you can make a regular PUT request:');
|
||||
lines.push('');
|
||||
lines.push('\tcurl -X PUT --data \'{"title": "My new title"}\' http://localhost:41184/resources/8fe1417d7b184324bf6b0122b76c4696');
|
||||
lines.push('');
|
||||
lines.push('The "data" field is required, while the "props" one is not. If not specified, default values will be used.');
|
||||
lines.push('');
|
||||
lines.push('**From a plugin** the syntax to create a resource is also a bit special:');
|
||||
@@ -368,6 +376,11 @@ async function fetchAllNotes() {
|
||||
lines.push(`Sets the properties of the ${singular} with ID :id`);
|
||||
lines.push('');
|
||||
|
||||
if (model.type === BaseModel.TYPE_RESOURCE) {
|
||||
lines.push('You may also update the file data by specifying a file (See `POST /resources` example).');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`## DELETE /${tableName}/:id`);
|
||||
lines.push('');
|
||||
lines.push(`Deletes the ${singular} with ID :id`);
|
||||
|
||||
@@ -242,9 +242,9 @@ describe('MdToHtml', function() {
|
||||
{
|
||||
const input = '# Head\nFruits\n- Apple\n';
|
||||
const result = await mdToHtml.render(input, null, { bodyOnly: true, mapsToLine: true });
|
||||
expect(result.html.trim()).toBe('<h1 id="head" class="maps-to-line" source-line="0">Head</h1>\n' +
|
||||
'<p class="maps-to-line" source-line="1">Fruits</p>\n' +
|
||||
'<ul>\n<li class="maps-to-line" source-line="2">Apple</li>\n</ul>'
|
||||
expect(result.html.trim()).toBe('<h1 id="head" class="maps-to-line" source-line="0" source-line-end="1">Head</h1>\n' +
|
||||
'<p class="maps-to-line" source-line="1" source-line-end="2">Fruits</p>\n' +
|
||||
'<ul>\n<li class="maps-to-line" source-line="2" source-line-end="3">Apple</li>\n</ul>'
|
||||
);
|
||||
}
|
||||
}));
|
||||
|
||||
19
packages/app-desktop/commands/editProfileConfig.ts
Normal file
19
packages/app-desktop/commands/editProfileConfig.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import Setting from '../../lib/models/Setting';
|
||||
import { openFileWithExternalEditor } from '../../lib/services/ExternalEditWatcher/utils';
|
||||
import bridge from '../services/bridge';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'editProfileConfig',
|
||||
label: () => _('Edit profile configuration...'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await openFileWithExternalEditor(`${Setting.value('rootProfileDir')}/profiles.json`, bridge());
|
||||
},
|
||||
enabledCondition: 'hasMultiProfiles',
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
// AUTO-GENERATED using `gulp buildCommandIndex`
|
||||
import * as copyDevCommand from './copyDevCommand';
|
||||
import * as editProfileConfig from './editProfileConfig';
|
||||
import * as exportFolders from './exportFolders';
|
||||
import * as exportNotes from './exportNotes';
|
||||
import * as focusElement from './focusElement';
|
||||
@@ -8,11 +9,16 @@ import * as replaceMisspelling from './replaceMisspelling';
|
||||
import * as restoreNoteRevision from './restoreNoteRevision';
|
||||
import * as startExternalEditing from './startExternalEditing';
|
||||
import * as stopExternalEditing from './stopExternalEditing';
|
||||
import * as switchProfile from './switchProfile';
|
||||
import * as switchProfile1 from './switchProfile1';
|
||||
import * as switchProfile2 from './switchProfile2';
|
||||
import * as switchProfile3 from './switchProfile3';
|
||||
import * as toggleExternalEditing from './toggleExternalEditing';
|
||||
import * as toggleSafeMode from './toggleSafeMode';
|
||||
|
||||
const index:any[] = [
|
||||
copyDevCommand,
|
||||
editProfileConfig,
|
||||
exportFolders,
|
||||
exportNotes,
|
||||
focusElement,
|
||||
@@ -21,6 +27,10 @@ const index:any[] = [
|
||||
restoreNoteRevision,
|
||||
startExternalEditing,
|
||||
stopExternalEditing,
|
||||
switchProfile,
|
||||
switchProfile1,
|
||||
switchProfile2,
|
||||
switchProfile3,
|
||||
toggleExternalEditing,
|
||||
toggleSafeMode,
|
||||
];
|
||||
|
||||
26
packages/app-desktop/commands/switchProfile.ts
Normal file
26
packages/app-desktop/commands/switchProfile.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { saveProfileConfig } from '@joplin/lib/services/profileConfig';
|
||||
import { ProfileConfig } from '@joplin/lib/services/profileConfig/types';
|
||||
import bridge from '../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'switchProfile',
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, profileIndex: number) => {
|
||||
const currentConfig = context.state.profileConfig;
|
||||
if (currentConfig.currentProfile === profileIndex) return;
|
||||
|
||||
const newConfig: ProfileConfig = {
|
||||
...currentConfig,
|
||||
currentProfile: profileIndex,
|
||||
};
|
||||
|
||||
await saveProfileConfig(`${Setting.value('rootProfileDir')}/profiles.json`, newConfig);
|
||||
bridge().restart();
|
||||
},
|
||||
};
|
||||
};
|
||||
15
packages/app-desktop/commands/switchProfile1.ts
Normal file
15
packages/app-desktop/commands/switchProfile1.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'switchProfile1',
|
||||
label: () => _('Switch to profile %d', 1),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await CommandService.instance().execute('switchProfile', 0);
|
||||
},
|
||||
};
|
||||
};
|
||||
15
packages/app-desktop/commands/switchProfile2.ts
Normal file
15
packages/app-desktop/commands/switchProfile2.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'switchProfile2',
|
||||
label: () => _('Switch to profile %d', 2),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await CommandService.instance().execute('switchProfile', 1);
|
||||
},
|
||||
};
|
||||
};
|
||||
15
packages/app-desktop/commands/switchProfile3.ts
Normal file
15
packages/app-desktop/commands/switchProfile3.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'switchProfile3',
|
||||
label: () => _('Switch to profile %d', 3),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await CommandService.instance().execute('switchProfile', 2);
|
||||
},
|
||||
};
|
||||
};
|
||||
34
packages/app-desktop/gui/MainScreen/commands/addProfile.ts
Normal file
34
packages/app-desktop/gui/MainScreen/commands/addProfile.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { createNewProfile, saveProfileConfig } from '@joplin/lib/services/profileConfig';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import bridge from '../../../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'addProfile',
|
||||
label: () => _('Create new profile...'),
|
||||
};
|
||||
|
||||
export const runtime = (comp: any): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext) => {
|
||||
comp.setState({
|
||||
promptOptions: {
|
||||
label: _('Profile name:'),
|
||||
buttons: ['create', 'cancel'],
|
||||
value: '',
|
||||
onClose: async (answer: string) => {
|
||||
if (answer) {
|
||||
const newConfig = await createNewProfile(context.state.profileConfig, answer);
|
||||
newConfig.currentProfile = newConfig.profiles.length - 1;
|
||||
await saveProfileConfig(`${Setting.value('rootProfileDir')}/profiles.json`, newConfig);
|
||||
bridge().restart();
|
||||
}
|
||||
|
||||
comp.setState({ promptOptions: null });
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
// AUTO-GENERATED using `gulp buildCommandIndex`
|
||||
import * as addProfile from './addProfile';
|
||||
import * as commandPalette from './commandPalette';
|
||||
import * as editAlarm from './editAlarm';
|
||||
import * as exportPdf from './exportPdf';
|
||||
@@ -38,6 +39,7 @@ import * as toggleSideBar from './toggleSideBar';
|
||||
import * as toggleVisiblePanes from './toggleVisiblePanes';
|
||||
|
||||
const index:any[] = [
|
||||
addProfile,
|
||||
commandPalette,
|
||||
editAlarm,
|
||||
exportPdf,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import { TagEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'setTags',
|
||||
@@ -15,16 +14,23 @@ export const runtime = (comp: any): CommandRuntime => {
|
||||
noteIds = noteIds || context.state.selectedNoteIds;
|
||||
|
||||
const tags = await Tag.commonTagsByNoteIds(noteIds);
|
||||
const sortedTags = Tag.sortTags(tags);
|
||||
const startTags = sortedTags
|
||||
.map((a: TagEntity) => {
|
||||
const startTags = tags
|
||||
.map((a: any) => {
|
||||
return { value: a.id, label: a.title };
|
||||
})
|
||||
.sort((a: any, b: any) => {
|
||||
// sensitivity accent will treat accented characters as differemt
|
||||
// but treats caps as equal
|
||||
return a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' });
|
||||
});
|
||||
const allTags = await Tag.allWithNotes();
|
||||
const sortedAllTags = Tag.sortTags(allTags);
|
||||
const tagSuggestions = sortedAllTags
|
||||
.map((a: TagEntity) => {
|
||||
return { value: a.id, label: a.title };
|
||||
const tagSuggestions = allTags.map((a: any) => {
|
||||
return { value: a.id, label: a.title };
|
||||
})
|
||||
.sort((a: any, b: any) => {
|
||||
// sensitivity accent will treat accented characters as differemt
|
||||
// but treats caps as equal
|
||||
return a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' });
|
||||
});
|
||||
|
||||
comp.setState({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { AppState } from '../app.reducer';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
@@ -18,9 +18,9 @@ import menuCommandNames from './menuCommandNames';
|
||||
import stateToWhenClauseContext from '../services/commands/stateToWhenClauseContext';
|
||||
import bridge from '../services/bridge';
|
||||
import checkForUpdates from '../checkForUpdates';
|
||||
|
||||
const { connect } = require('react-redux');
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import { ProfileConfig } from '../../lib/services/profileConfig/types';
|
||||
const packageInfo = require('../packageInfo.js');
|
||||
const { clipboard } = require('electron');
|
||||
const Menu = bridge().Menu;
|
||||
@@ -39,7 +39,7 @@ function pluginMenuItemsCommandNames(menuItems: MenuItem[]): string[] {
|
||||
return output;
|
||||
}
|
||||
|
||||
function pluginCommandNames(plugins: PluginStates): string[] {
|
||||
function getPluginCommandNames(plugins: PluginStates): string[] {
|
||||
let output: string[] = [];
|
||||
|
||||
for (const view of pluginUtils.viewsByType(plugins, 'menu')) {
|
||||
@@ -70,6 +70,42 @@ function createPluginMenuTree(label: string, menuItems: MenuItem[], onMenuItemCl
|
||||
return output;
|
||||
}
|
||||
|
||||
const useSwitchProfileMenuItems = (profileConfig: ProfileConfig, menuItemDic: any) => {
|
||||
return useMemo(() => {
|
||||
const switchProfileMenuItems: any[] = [];
|
||||
|
||||
for (let i = 0; i < profileConfig.profiles.length; i++) {
|
||||
const profile = profileConfig.profiles[i];
|
||||
|
||||
let menuItem: any = {};
|
||||
const profileNum = i + 1;
|
||||
|
||||
if (menuItemDic[`switchProfile${profileNum}`]) {
|
||||
menuItem = { ...menuItemDic[`switchProfile${profileNum}`] };
|
||||
} else {
|
||||
menuItem = {
|
||||
label: profile.name,
|
||||
click: () => {
|
||||
void CommandService.instance().execute('switchProfile', i);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
menuItem.label = profile.name;
|
||||
menuItem.type = 'checkbox';
|
||||
menuItem.checked = profileConfig.currentProfile === i;
|
||||
|
||||
switchProfileMenuItems.push(menuItem);
|
||||
}
|
||||
|
||||
switchProfileMenuItems.push({ type: 'separator' });
|
||||
switchProfileMenuItems.push(menuItemDic.addProfile);
|
||||
switchProfileMenuItems.push(menuItemDic.editProfileConfig);
|
||||
|
||||
return switchProfileMenuItems;
|
||||
}, [profileConfig, menuItemDic]);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
dispatch: Function;
|
||||
menuItemProps: any;
|
||||
@@ -90,6 +126,7 @@ interface Props {
|
||||
plugins: PluginStates;
|
||||
customCss: string;
|
||||
locale: string;
|
||||
profileConfig: ProfileConfig;
|
||||
}
|
||||
|
||||
const commandNames: string[] = menuCommandNames();
|
||||
@@ -241,6 +278,18 @@ function useMenu(props: Props) {
|
||||
const onImportModuleClickRef = useRef(null);
|
||||
onImportModuleClickRef.current = onImportModuleClick;
|
||||
|
||||
const pluginCommandNames = useMemo(() => props.pluginMenuItems.map((view: any) => view.commandName), [props.pluginMenuItems]);
|
||||
|
||||
const menuItemDic = useMemo(() => {
|
||||
return menuUtils.commandsToMenuItems(
|
||||
commandNames.concat(pluginCommandNames),
|
||||
(commandName: string) => onMenuItemClickRef.current(commandName),
|
||||
props.locale
|
||||
);
|
||||
}, [commandNames, pluginCommandNames, props.locale]);
|
||||
|
||||
const switchProfileMenuItems: any[] = useSwitchProfileMenuItems(props.profileConfig, menuItemDic);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: any = null;
|
||||
|
||||
@@ -249,13 +298,6 @@ function useMenu(props: Props) {
|
||||
|
||||
const keymapService = KeymapService.instance();
|
||||
|
||||
const pluginCommandNames = props.pluginMenuItems.map((view: any) => view.commandName);
|
||||
const menuItemDic = menuUtils.commandsToMenuItems(
|
||||
commandNames.concat(pluginCommandNames),
|
||||
(commandName: string) => onMenuItemClickRef.current(commandName),
|
||||
props.locale
|
||||
);
|
||||
|
||||
const quitMenuItem = {
|
||||
label: _('Quit'),
|
||||
accelerator: keymapService.getAccelerator('quit'),
|
||||
@@ -385,6 +427,10 @@ function useMenu(props: Props) {
|
||||
const newFolderItem = menuItemDic.newFolder;
|
||||
const newSubFolderItem = menuItemDic.newSubFolder;
|
||||
const printItem = menuItemDic.print;
|
||||
const switchProfileItem = {
|
||||
label: _('Switch profile'),
|
||||
submenu: switchProfileMenuItems,
|
||||
};
|
||||
|
||||
let toolsItems: any[] = [];
|
||||
|
||||
@@ -499,6 +545,8 @@ function useMenu(props: Props) {
|
||||
platforms: ['darwin'],
|
||||
},
|
||||
|
||||
shim.isMac() ? noItem : switchProfileItem,
|
||||
|
||||
shim.isMac() ? {
|
||||
label: _('Hide %s', 'Joplin'),
|
||||
platforms: ['darwin'],
|
||||
@@ -545,6 +593,7 @@ function useMenu(props: Props) {
|
||||
type: 'separator',
|
||||
},
|
||||
printItem,
|
||||
switchProfileItem,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -848,7 +897,21 @@ function useMenu(props: Props) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
};
|
||||
}, [props.routeName, props.pluginMenuItems, props.pluginMenus, keymapLastChangeTime, modulesLastChangeTime, props['spellChecker.language'], props['spellChecker.enabled'], props.plugins, props.customCss, props.locale]);
|
||||
}, [
|
||||
props.routeName,
|
||||
props.pluginMenuItems,
|
||||
props.pluginMenus,
|
||||
keymapLastChangeTime,
|
||||
modulesLastChangeTime,
|
||||
props['spellChecker.language'],
|
||||
props['spellChecker.enabled'],
|
||||
props.plugins,
|
||||
props.customCss,
|
||||
props.locale,
|
||||
props.profileConfig,
|
||||
switchProfileMenuItems,
|
||||
menuItemDic,
|
||||
]);
|
||||
|
||||
useMenuStates(menu, props);
|
||||
|
||||
@@ -889,7 +952,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state);
|
||||
|
||||
return {
|
||||
menuItemProps: menuUtils.commandsToMenuItemProps(commandNames.concat(pluginCommandNames(state.pluginService.plugins)), whenClauseContext),
|
||||
menuItemProps: menuUtils.commandsToMenuItemProps(commandNames.concat(getPluginCommandNames(state.pluginService.plugins)), whenClauseContext),
|
||||
locale: state.settings.locale,
|
||||
routeName: state.route.routeName,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
@@ -907,6 +970,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
['spellChecker.enabled']: state.settings['spellChecker.enabled'],
|
||||
plugins: state.pluginService.plugins,
|
||||
customCss: state.customCss,
|
||||
profileConfig: state.profileConfig,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -649,6 +649,11 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
// undefined. Maybe due to the error boundary that unmount components.
|
||||
// Since we can't do much about it we just print an error.
|
||||
if (webviewRef.current && webviewRef.current.wrappedInstance) {
|
||||
// To keep consistency among CodeMirror's editing and scroll percents
|
||||
// of Editor and Viewer.
|
||||
const percent = getLineScrollPercent();
|
||||
setEditorPercentScroll(percent);
|
||||
options.percent = percent;
|
||||
webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options);
|
||||
} else {
|
||||
console.error('Trying to set HTML on an undefined webview ref');
|
||||
@@ -732,15 +737,19 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
// It might be buggy, refer to the below issue
|
||||
// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703
|
||||
useEffect(() => {
|
||||
function pointerInsideEditor(x: number, y: number) {
|
||||
function pointerInsideEditor(params: any) {
|
||||
const x = params.x, y = params.y, isEditable = params.isEditable, inputFieldType = params.inputFieldType;
|
||||
const elements = document.getElementsByClassName('codeMirrorEditor');
|
||||
if (!elements.length) return null;
|
||||
|
||||
// inputFieldType: The input field type of CodeMirror is "textarea" so the inputFieldType = "none",
|
||||
// and any single-line input above codeMirror has inputFieldType value according to the type of input e.g.(text = plainText, password = password, ...).
|
||||
if (!elements.length || !isEditable || inputFieldType !== 'none') return null;
|
||||
const rect = convertToScreenCoordinates(Setting.value('windowContentZoomFactor'), elements[0].getBoundingClientRect());
|
||||
return rect.x < x && rect.y < y && rect.right > x && rect.bottom > y;
|
||||
}
|
||||
|
||||
async function onContextMenu(_event: any, params: any) {
|
||||
if (!pointerInsideEditor(params.x, params.y)) return;
|
||||
if (!pointerInsideEditor(params)) return;
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface EditorProps {
|
||||
function Editor(props: EditorProps, ref: any) {
|
||||
const [editor, setEditor] = useState(null);
|
||||
const editorParent = useRef(null);
|
||||
const lastEditTime = useRef(NaN);
|
||||
|
||||
// Codemirror plugins add new commands to codemirror (or change it's behavior)
|
||||
// This command adds the smartListIndent function which will be bound to tab
|
||||
@@ -120,6 +121,7 @@ function Editor(props: EditorProps, ref: any) {
|
||||
const editor_change = useCallback((cm: any, change: any) => {
|
||||
if (props.onChange && change.origin !== 'setValue') {
|
||||
props.onChange(cm.getValue());
|
||||
lastEditTime.current = Date.now();
|
||||
}
|
||||
}, [props.onChange]);
|
||||
|
||||
@@ -154,7 +156,8 @@ function Editor(props: EditorProps, ref: any) {
|
||||
}, [props.onResize]);
|
||||
|
||||
const editor_update = useCallback((cm: any) => {
|
||||
props.onUpdate(cm);
|
||||
const edited = Date.now() - lastEditTime.current <= 100;
|
||||
props.onUpdate(cm, edited);
|
||||
}, [props.onUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
const now = Date.now();
|
||||
if (now >= ignoreNextEditorScrollTime_.current) ignoreNextEditorScrollEventCount_.current = 0;
|
||||
if (ignoreNextEditorScrollEventCount_.current < 10) { // for safety
|
||||
ignoreNextEditorScrollTime_.current = now + 200;
|
||||
ignoreNextEditorScrollTime_.current = now + 1000;
|
||||
ignoreNextEditorScrollEventCount_.current += 1;
|
||||
}
|
||||
};
|
||||
@@ -157,8 +157,9 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
// When heights of lines are updated in CodeMirror, 'update' events are raised.
|
||||
// If such an update event is raised, scroll position should be restored.
|
||||
// See https://github.com/laurent22/joplin/issues/5981
|
||||
const editor_update = useCallback((cm) => {
|
||||
const editor_update = useCallback((cm: any, edited: boolean) => {
|
||||
if (isCodeMirrorReady(cm)) {
|
||||
if (edited) return;
|
||||
const linesHeight = cm.heightAtLine(cm.lineCount()) - cm.heightAtLine(0);
|
||||
if (lastLinesHeight_.current !== linesHeight) {
|
||||
// To avoid cancelling intentional scroll position changes,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
|
||||
import { useEffect } from 'react';
|
||||
import bridge from '../../../../../services/bridge';
|
||||
import { menuItems, ContextMenuOptions, ContextMenuItemType } from '../../../utils/contextMenu';
|
||||
import { ContextMenuOptions, ContextMenuItemType } from '../../../utils/contextMenuUtils';
|
||||
import { menuItems } from '../../../utils/contextMenu';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import convertToScreenCoordinates from '../../../../utils/convertToScreenCoordinates';
|
||||
@@ -67,6 +68,8 @@ export default function(editor: any, plugins: PluginStates, dispatch: Function)
|
||||
contextMenuActionOptions.current = {
|
||||
itemType,
|
||||
resourceId,
|
||||
filename: null,
|
||||
mime: null,
|
||||
linkToCopy,
|
||||
textToCopy: null,
|
||||
htmlToCopy: editor.selection ? editor.selection.getContent() : '',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/** @jest-environment ./loadResources.testEnv */
|
||||
// eslint-disable-next-line strict, lines-around-directive
|
||||
'use strict';
|
||||
// use strict is necessary here so that typescript doesn't place "use strict" above the jest docblock
|
||||
// https://github.com/microsoft/TypeScript/issues/15819#issuecomment-782235619
|
||||
|
||||
import { textToDataUri, svgUriToPng } from './contextMenuUtils';
|
||||
|
||||
jest.mock('@joplin/lib/models/Resource');
|
||||
|
||||
describe('contextMenu', () => {
|
||||
it('should provide proper copy path', async () => {
|
||||
const testCase = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve">test</svg>',
|
||||
'image/svg+xml',
|
||||
];
|
||||
const expectedText = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4bWw6c3BhY2U9InByZXNlcnZlIj50ZXN0PC9zdmc+';
|
||||
expect(textToDataUri(testCase[0], testCase[1])).toBe(expectedText);
|
||||
});
|
||||
|
||||
it('should convert to png binary', async () => {
|
||||
const testCase = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4PSIwcHgiIHk9IjBweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDEwMCAxMDAiIHhtbDpzcGFjZT0icHJlc2VydmUiIGhlaWdodD0iMTAwcHgiIHdpZHRoPSIxMDBweCI+CjxnPgoJPHBhdGggZD0iTTI4LjEsMzYuNmM0LjYsMS45LDEyLjIsMS42LDIwLjksMS4xYzguOS0wLjQsMTktMC45LDI4LjksMC45YzYuMywxLjIsMTEuOSwzLjEsMTYuOCw2Yy0xLjUtMTIuMi03LjktMjMuNy0xOC42LTMxLjMgICBjLTQuOS0wLjItOS45LDAuMy0xNC44LDEuNEM0Ny44LDE3LjksMzYuMiwyNS42LDI4LjEsMzYuNnoiLz4KCTxwYXRoIGQ9Ik03MC4zLDkuOEM1Ny41LDMuNCw0Mi44LDMuNiwzMC41LDkuNWMtMyw2LTguNCwxOS42LTUuMywyNC45YzguNi0xMS43LDIwLjktMTkuOCwzNS4yLTIzLjFDNjMuNywxMC41LDY3LDEwLDcwLjMsOS44eiIvPgoJPHBhdGggZD0iTTE2LjUsNTEuM2MwLjYtMS43LDEuMi0zLjQsMi01LjFjLTMuOC0zLjQtNy41LTctMTEtMTAuOGMtMi4xLDYuMS0yLjgsMTIuNS0yLjMsMTguN0M5LjYsNTEuMSwxMy40LDUwLjIsMTYuNSw1MS4zeiIvPgoJPHBhdGggZD0iTTksMzEuNmMzLjUsMy45LDcuMiw3LjYsMTEuMSwxMS4xYzAuOC0xLjYsMS43LTMuMSwyLjYtNC42YzAuMS0wLjIsMC4zLTAuNCwwLjQtMC42Yy0yLjktMy4zLTMuMS05LjItMC42LTE3LjYgICBjMC44LTIuNywxLjgtNS4zLDIuNy03LjRjLTUuMiwzLjQtOS44LDgtMTMuMywxMy43QzEwLjgsMjcuOSw5LjgsMjkuNyw5LDMxLjZ6Ii8+Cgk8cGF0aCBkPSJNMTUuNCw1NC43Yy0yLjYtMS02LjEsMC43LTkuNywzLjRjMS4yLDYuNiwzLjksMTMsOCwxOC41QzEzLDY5LjMsMTMuNSw2MS44LDE1LjQsNTQuN3oiLz4KCTxwYXRoIGQ9Ik0zOS44LDU3LjZDNTQuMyw2Ni43LDcwLDczLDg2LjUsNzYuNGMwLjYtMC44LDEuMS0xLjYsMS43LTIuNWM0LjgtNy43LDctMTYuMyw2LjgtMjQuOGMtMTMuOC05LjMtMzEuMy04LjQtNDUuOC03LjcgICBjLTkuNSwwLjUtMTcuOCwwLjktMjMuMi0xLjdjLTAuMSwwLjEtMC4yLDAuMy0wLjMsMC40Yy0xLDEuNy0yLDMuNC0yLjksNS4xQzI4LjIsNDkuNywzMy44LDUzLjksMzkuOCw1Ny42eiIvPgoJPHBhdGggZD0iTTI2LjIsODguMmMzLjMsMiw2LjcsMy42LDEwLjIsNC43Yy0zLjUtNi4yLTYuMy0xMi42LTguOC0xOC41Yy0zLjEtNy4yLTUuOC0xMy41LTktMTcuMmMtMS45LDgtMiwxNi40LTAuMywyNC43ICAgQzIwLjYsODQuMiwyMy4yLDg2LjMsMjYuMiw4OC4yeiIvPgoJPHBhdGggZD0iTTMwLjksNzNjMi45LDYuOCw2LjEsMTQuNCwxMC41LDIxLjJjMTUuNiwzLDMyLTIuMyw0Mi42LTE0LjZDNjcuNyw3Niw1Mi4yLDY5LjYsMzcuOSw2MC43QzMyLDU3LDI2LjUsNTMsMjEuMyw0OC42ICAgYy0wLjYsMS41LTEuMiwzLTEuNyw0LjZDMjQuMSw1Ny4xLDI3LjMsNjQuNSwzMC45LDczeiIvPgo8L2c+Cjwvc3ZnPg==';
|
||||
const png = await svgUriToPng(document, testCase);
|
||||
expect(png).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it('should throw error on invalid svg uri', async () => {
|
||||
// We are mocking console.error since jsdom throws errors to console when we try to load an invalid img
|
||||
// https://github.com/facebook/jest/pull/5267#issuecomment-356605468
|
||||
const consoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
const testCases: Array<string> = [
|
||||
'data:image/svg+xml;base64,error',
|
||||
'invalid',
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
await expect(svgUriToPng(document, testCase)).rejects.toBeInstanceOf(Error);
|
||||
}
|
||||
console.error = consoleError;
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import ResourceEditWatcher from '@joplin/lib/services/ResourceEditWatcher/index'
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { copyHtmlToClipboard } from './clipboardUtils';
|
||||
import bridge from '../../../services/bridge';
|
||||
import { ContextMenuItemType, ContextMenuOptions, ContextMenuItems, resourceInfo, textToDataUri, svgUriToPng } from './contextMenuUtils';
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
@@ -10,43 +11,10 @@ import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { processPastedHtml } from './resourceHandling';
|
||||
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
const fs = require('fs-extra');
|
||||
const { writeFile } = require('fs-extra');
|
||||
const { clipboard } = require('electron');
|
||||
const { toSystemSlashes } = require('@joplin/lib/path-utils');
|
||||
|
||||
export enum ContextMenuItemType {
|
||||
None = '',
|
||||
Image = 'image',
|
||||
Resource = 'resource',
|
||||
Text = 'text',
|
||||
Link = 'link',
|
||||
}
|
||||
|
||||
export interface ContextMenuOptions {
|
||||
itemType: ContextMenuItemType;
|
||||
resourceId: string;
|
||||
linkToCopy: string;
|
||||
textToCopy: string;
|
||||
htmlToCopy: string;
|
||||
insertContent: Function;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuItem {
|
||||
label: string;
|
||||
onAction: Function;
|
||||
isActive: Function;
|
||||
}
|
||||
|
||||
interface ContextMenuItems {
|
||||
[key: string]: ContextMenuItem;
|
||||
}
|
||||
|
||||
async function resourceInfo(options: ContextMenuOptions): Promise<any> {
|
||||
const resource = options.resourceId ? await Resource.load(options.resourceId) : null;
|
||||
const resourcePath = resource ? Resource.fullPath(resource) : '';
|
||||
return { resource, resourcePath };
|
||||
}
|
||||
|
||||
function handleCopyToClipboard(options: ContextMenuOptions) {
|
||||
if (options.textToCopy) {
|
||||
clipboard.writeText(options.textToCopy);
|
||||
@@ -55,6 +23,12 @@ function handleCopyToClipboard(options: ContextMenuOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFileData(data: any, filename: string) {
|
||||
const newFilePath = await bridge().showSaveDialog({ defaultPath: filename });
|
||||
if (!newFilePath) return;
|
||||
await writeFile(newFilePath, data);
|
||||
}
|
||||
|
||||
export async function openItemById(itemId: string, dispatch: Function, hash: string = '') {
|
||||
|
||||
const item = await BaseItem.loadItemById(itemId);
|
||||
@@ -100,7 +74,7 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
await openItemById(options.resourceId, dispatch);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource),
|
||||
},
|
||||
saveAs: {
|
||||
label: _('Save as...'),
|
||||
@@ -112,7 +86,32 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
if (!filePath) return;
|
||||
await fs.copy(resourcePath, filePath);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
||||
// We handle images received as text seperately as it can be saved in multiple formats
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource),
|
||||
},
|
||||
saveAsSvg: {
|
||||
label: _('Save as SVG'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
await saveFileData(options.textToCopy, options.filename);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy && itemType === ContextMenuItemType.Image && options.mime?.startsWith('image/svg'),
|
||||
},
|
||||
saveAsPng: {
|
||||
label: _('Save as PNG'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
// First convert it to png then save
|
||||
if (options.mime != 'image/svg+xml') {
|
||||
throw new Error(`Unsupported image type: ${options.mime}`);
|
||||
}
|
||||
if (!options.filename) {
|
||||
throw new Error('Filename is needed to save as png');
|
||||
}
|
||||
const dataUri = textToDataUri(options.textToCopy, options.mime);
|
||||
const png = await svgUriToPng(document, dataUri);
|
||||
const filename = options.filename.replace('.svg', '.png');
|
||||
await saveFileData(png, filename);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy && itemType === ContextMenuItemType.Image && options.mime?.startsWith('image/svg'),
|
||||
},
|
||||
revealInFolder: {
|
||||
label: _('Reveal file in folder'),
|
||||
@@ -120,13 +119,20 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
const { resourcePath } = await resourceInfo(options);
|
||||
bridge().showItemInFolder(resourcePath);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
||||
},
|
||||
copyPathToClipboard: {
|
||||
label: _('Copy path to clipboard'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
const { resourcePath } = await resourceInfo(options);
|
||||
clipboard.writeText(toSystemSlashes(resourcePath));
|
||||
let path = '';
|
||||
if (options.textToCopy && options.mime) {
|
||||
path = textToDataUri(options.textToCopy, options.mime);
|
||||
} else {
|
||||
const { resourcePath } = await resourceInfo(options);
|
||||
if (resourcePath) path = toSystemSlashes(resourcePath);
|
||||
}
|
||||
if (!path) return;
|
||||
clipboard.writeText(path);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
||||
},
|
||||
@@ -137,7 +143,7 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
const image = bridge().createImageFromPath(resourcePath);
|
||||
clipboard.writeImage(image);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image,
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && itemType === ContextMenuItemType.Image,
|
||||
},
|
||||
cut: {
|
||||
label: _('Cut'),
|
||||
@@ -145,14 +151,14 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
handleCopyToClipboard(options);
|
||||
options.insertContent('');
|
||||
},
|
||||
isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.isReadOnly && (!!options.textToCopy || !!options.htmlToCopy),
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => itemType != ContextMenuItemType.Image && (!options.isReadOnly && (!!options.textToCopy || !!options.htmlToCopy)),
|
||||
},
|
||||
copy: {
|
||||
label: _('Copy'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
handleCopyToClipboard(options);
|
||||
},
|
||||
isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy || !!options.htmlToCopy,
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => itemType != ContextMenuItemType.Image && (!!options.textToCopy || !!options.htmlToCopy),
|
||||
},
|
||||
paste: {
|
||||
label: _('Paste'),
|
||||
@@ -184,7 +190,6 @@ export default async function contextMenu(options: ContextMenuOptions, dispatch:
|
||||
const items = menuItems(dispatch);
|
||||
|
||||
if (!('readyOnly' in options)) options.isReadOnly = true;
|
||||
|
||||
for (const itemKey in items) {
|
||||
const item = items[itemKey];
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
|
||||
export enum ContextMenuItemType {
|
||||
None = '',
|
||||
Image = 'image',
|
||||
Resource = 'resource',
|
||||
Text = 'text',
|
||||
Link = 'link',
|
||||
}
|
||||
|
||||
export interface ContextMenuOptions {
|
||||
itemType: ContextMenuItemType;
|
||||
resourceId: string;
|
||||
mime: string;
|
||||
filename: string;
|
||||
linkToCopy: string;
|
||||
textToCopy: string;
|
||||
htmlToCopy: string;
|
||||
insertContent: Function;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
onAction: Function;
|
||||
isActive: Function;
|
||||
}
|
||||
|
||||
export interface ContextMenuItems {
|
||||
[key: string]: ContextMenuItem;
|
||||
}
|
||||
|
||||
export async function resourceInfo(options: ContextMenuOptions): Promise<any> {
|
||||
const resource = options.resourceId ? await Resource.load(options.resourceId) : null;
|
||||
const filePath = resource ? Resource.fullPath(resource) : null;
|
||||
const filename = resource ? (resource.filename ? resource.filename : resource.title) : options.filename ? options.filename : '';
|
||||
return { resource, filePath, filename };
|
||||
}
|
||||
|
||||
export function textToDataUri(text: string, mime: string): string {
|
||||
return `data:${mime};base64,${Buffer.from(text).toString('base64')}`;
|
||||
}
|
||||
|
||||
export const svgUriToPng = (document: Document, svg: string) => {
|
||||
return new Promise<Uint8Array>((resolve, reject) => {
|
||||
let canvas: HTMLCanvasElement;
|
||||
let img: HTMLImageElement;
|
||||
|
||||
const cleanUpAndReject = (e: Error) => {
|
||||
if (canvas) canvas.remove();
|
||||
if (img) img.remove();
|
||||
return reject(e);
|
||||
};
|
||||
|
||||
try {
|
||||
img = document.createElement('img');
|
||||
if (!img) throw new Error('Failed to create img element');
|
||||
} catch (e) {
|
||||
return cleanUpAndReject(e);
|
||||
}
|
||||
|
||||
img.onload = function() {
|
||||
try {
|
||||
canvas = document.createElement('canvas');
|
||||
if (!canvas) throw new Error('Failed to create canvas element');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Failed to get context');
|
||||
ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, img.width, img.height);
|
||||
const pngUri = canvas.toDataURL('image/png');
|
||||
if (!pngUri) throw new Error('Failed to generate png uri');
|
||||
const pngBase64 = pngUri.split(',')[1];
|
||||
const byteString = atob(pngBase64);
|
||||
// write the bytes of the string to a typed array
|
||||
const buff = new Uint8Array(byteString.length);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
buff[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
canvas.remove();
|
||||
img.remove();
|
||||
resolve(buff);
|
||||
} catch (err) {
|
||||
cleanUpAndReject(err);
|
||||
}
|
||||
};
|
||||
img.onerror = function(e) {
|
||||
cleanUpAndReject(new Error(e.toString()));
|
||||
};
|
||||
img.src = svg;
|
||||
});
|
||||
};
|
||||
@@ -35,6 +35,8 @@ export default function useMessageHandler(scrollWhenReady: any, setScrollWhenRea
|
||||
const menu = await contextMenu({
|
||||
itemType: arg0 && arg0.type,
|
||||
resourceId: arg0.resourceId,
|
||||
filename: arg0.filename,
|
||||
mime: arg0.mime,
|
||||
textToCopy: arg0.textToCopy,
|
||||
linkToCopy: arg0.linkToCopy || null,
|
||||
htmlToCopy: '',
|
||||
|
||||
@@ -231,6 +231,13 @@ class PromptDialog extends React.Component {
|
||||
}
|
||||
|
||||
const buttonComps = [];
|
||||
if (buttonTypes.indexOf('create') >= 0) {
|
||||
buttonComps.push(
|
||||
<button key="create" disabled={!this.state.answer} style={styles.button} onClick={() => onClose(true, 'create')}>
|
||||
{_('Create')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (buttonTypes.indexOf('ok') >= 0) {
|
||||
buttonComps.push(
|
||||
<button key="ok" disabled={!this.state.answer} style={styles.button} onClick={() => onClose(true, 'ok')}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
@@ -15,6 +15,7 @@ import Button from './Button/Button';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../app.reducer';
|
||||
import { getEncryptionEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import SyncTargetRegistry from '../../lib/SyncTargetRegistry';
|
||||
const { clipboard } = require('electron');
|
||||
|
||||
interface Props {
|
||||
@@ -22,6 +23,7 @@ interface Props {
|
||||
noteIds: Array<string>;
|
||||
onClose: Function;
|
||||
shares: StateShare[];
|
||||
syncTargetId: number;
|
||||
}
|
||||
|
||||
function styles_(props: Props) {
|
||||
@@ -69,9 +71,10 @@ export function ShareNoteDialog(props: Props) {
|
||||
console.info('Render ShareNoteDialog');
|
||||
|
||||
const [notes, setNotes] = useState<NoteEntity[]>([]);
|
||||
const [recursiveShare, setRecursiveShare] = useState<boolean>(false);
|
||||
const [sharesState, setSharesState] = useState<string>('unknown');
|
||||
// const [shares, setShares] = useState<SharesMap>({});
|
||||
|
||||
const syncTargetInfo = useMemo(() => SyncTargetRegistry.infoById(props.syncTargetId), [props.syncTargetId]);
|
||||
const noteCount = notes.length;
|
||||
const theme = themeStyle(props.themeId);
|
||||
const styles = styles_(props);
|
||||
@@ -102,7 +105,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
clipboard.writeText(links.join('\n'));
|
||||
};
|
||||
|
||||
const shareLinkButton_click = async () => {
|
||||
const shareLinkButton_click = useCallback(async () => {
|
||||
const service = ShareService.instance();
|
||||
|
||||
let hasSynced = false;
|
||||
@@ -121,7 +124,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
const newShares: StateShare[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
const share = await service.shareNote(note.id);
|
||||
const share = await service.shareNote(note.id, recursiveShare);
|
||||
newShares.push(share);
|
||||
}
|
||||
|
||||
@@ -149,17 +152,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// const removeNoteButton_click = (event: any) => {
|
||||
// const newNotes = [];
|
||||
// for (let i = 0; i < notes.length; i++) {
|
||||
// const n = notes[i];
|
||||
// if (n.id === event.noteId) continue;
|
||||
// newNotes.push(n);
|
||||
// }
|
||||
// setNotes(newNotes);
|
||||
// };
|
||||
}, [recursiveShare, notes]);
|
||||
|
||||
const unshareNoteButton_click = async (event: any) => {
|
||||
await ShareService.instance().unshareNote(event.noteId);
|
||||
@@ -171,22 +164,6 @@ export function ShareNoteDialog(props: Props) {
|
||||
<Button tooltip={_('Unpublish note')} iconName="fas fa-share-alt" onClick={() => unshareNoteButton_click({ noteId: note.id })}/>
|
||||
);
|
||||
|
||||
// const removeButton = notes.length <= 1 ? null : (
|
||||
// <Button iconName="fa fa-times" onClick={() => removeNoteButton_click({ noteId: note.id })}/>
|
||||
// );
|
||||
|
||||
// const unshareButton = !shares[note.id] ? null : (
|
||||
// <button onClick={() => unshareNoteButton_click({ noteId: note.id })} style={styles.noteRemoveButton}>
|
||||
// <i style={styles.noteRemoveButtonIcon} className={'fas fa-share-alt'}></i>
|
||||
// </button>
|
||||
// );
|
||||
|
||||
// const removeButton = notes.length <= 1 ? null : (
|
||||
// <button onClick={() => removeNoteButton_click({ noteId: note.id })} style={styles.noteRemoveButton}>
|
||||
// <i style={styles.noteRemoveButtonIcon} className={'fa fa-times'}></i>
|
||||
// </button>
|
||||
// );
|
||||
|
||||
return (
|
||||
<div key={note.id} style={styles.note}>
|
||||
<span style={styles.noteTitle}>{note.title}</span>{unshareButton}
|
||||
@@ -214,11 +191,26 @@ export function ShareNoteDialog(props: Props) {
|
||||
return <div style={theme.textStyle}>{_('Note: When a note is shared, it will no longer be encrypted on the server.')}<hr/></div>;
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
const onRecursiveShareChange = useCallback(() => {
|
||||
setRecursiveShare(v => !v);
|
||||
}, []);
|
||||
|
||||
const renderRecursiveShareCheckbox = () => {
|
||||
if (!syncTargetInfo.supportsRecursiveLinkedNotes) return null;
|
||||
|
||||
return (
|
||||
<div style={styles.root}>
|
||||
<div className="form-input-group form-input-group-checkbox">
|
||||
<input id="recursiveShare" name="recursiveShare" type="checkbox" checked={!!recursiveShare} onChange={onRecursiveShareChange} /> <label htmlFor="recursiveShare">{_('Also publish linked notes')}</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<div style={styles.root} className="form">
|
||||
<DialogTitle title={_('Publish Notes')}/>
|
||||
{renderNoteList(notes)}
|
||||
{renderRecursiveShareCheckbox()}
|
||||
<button disabled={['creating', 'synchronizing'].indexOf(sharesState) >= 0} style={styles.copyShareLinkButton} onClick={shareLinkButton_click}>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
|
||||
<div style={theme.textStyle}>{statusMessage(sharesState)}</div>
|
||||
{renderEncryptionWarningMessage()}
|
||||
@@ -230,7 +222,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog renderContent={renderContent}/>
|
||||
@@ -240,6 +232,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
shares: state.shareService.shares.filter(s => !!s.note_id),
|
||||
syncTargetId: state.settings['sync.target'],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { AppState } from '../app.reducer';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
|
||||
const { connect } = require('react-redux');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
@@ -29,7 +28,11 @@ function TagList(props: Props) {
|
||||
}, [props.style, props.themeId]);
|
||||
|
||||
const tags = useMemo(() => {
|
||||
const output = Tag.sortTags(props.items.slice());
|
||||
const output = props.items.slice();
|
||||
|
||||
output.sort((a: any, b: any) => {
|
||||
return a.title < b.title ? -1 : +1;
|
||||
});
|
||||
|
||||
return output;
|
||||
}, [props.items]);
|
||||
|
||||
@@ -60,5 +60,10 @@ export default function() {
|
||||
'gotoAnything',
|
||||
'commandPalette',
|
||||
'openMasterPasswordDialog',
|
||||
'addProfile',
|
||||
'editProfileConfig',
|
||||
'switchProfile1',
|
||||
'switchProfile2',
|
||||
'switchProfile3',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
const now = Date.now();
|
||||
if (now >= ignoreNextScrollTime_) ignoreNextScrollEventCount_ = 0;
|
||||
if (ignoreNextScrollEventCount_ < 10) { // for safety
|
||||
ignoreNextScrollTime_ = now + 200;
|
||||
ignoreNextScrollTime_ = now + 1000;
|
||||
ignoreNextScrollEventCount_ += 1;
|
||||
}
|
||||
};
|
||||
@@ -293,7 +293,7 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!heightChanged) return;
|
||||
if (!heightChanged && cause !== 'dom-changed') return;
|
||||
const restoreAndRefresh = () => {
|
||||
scrollmap.refresh();
|
||||
restorePercentScroll();
|
||||
@@ -337,7 +337,11 @@
|
||||
contentElement.innerHTML = html;
|
||||
|
||||
scrollmap.create(event.options.markupLineCount);
|
||||
restorePercentScroll(); // First, a quick treatment is applied.
|
||||
if (typeof event.options.percent !== 'number') {
|
||||
restorePercentScroll(); // First, a quick treatment is applied.
|
||||
} else {
|
||||
setPercentScroll(event.options.percent);
|
||||
}
|
||||
|
||||
addPluginAssets(event.options.pluginAssets);
|
||||
|
||||
@@ -586,9 +590,24 @@
|
||||
}));
|
||||
|
||||
document.addEventListener('contextmenu', webviewLib.logEnabledEventHandler(event => {
|
||||
let element = event.target;
|
||||
|
||||
// To handle right clicks on resource icons
|
||||
let element = event.target;
|
||||
|
||||
// Mermaid svgs are wrapped inside a <pre> with class "mermaid"
|
||||
let mermaidElement = element.closest(".mermaid")?.children[0];
|
||||
if (mermaidElement) {
|
||||
const svgString = new XMLSerializer().serializeToString(mermaidElement);
|
||||
if (!!svgString) {
|
||||
ipcProxySendToHost('contextMenu', {
|
||||
type: 'image',
|
||||
textToCopy: svgString,
|
||||
mime: 'image/svg+xml',
|
||||
filename: mermaidElement.id + '.svg',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (element && !element.getAttribute('data-resource-id')) element = element.parentElement;
|
||||
|
||||
if (element && element.getAttribute('data-resource-id')) {
|
||||
|
||||
@@ -45,7 +45,8 @@ scrollmap.get_ = () => {
|
||||
// Each map entry is total-ordered.
|
||||
let last = 0;
|
||||
for (let i = 0; i < elems.length; i++) {
|
||||
const top = elems[i].getBoundingClientRect().top - offset;
|
||||
const rect = elems[i].getBoundingClientRect();
|
||||
const top = rect.top - offset;
|
||||
const line = Number(elems[i].getAttribute('source-line'));
|
||||
const percent = Math.max(0, Math.min(1, top / height));
|
||||
if (map.line[last] < line && map.percent[last] < percent) {
|
||||
@@ -53,12 +54,20 @@ scrollmap.get_ = () => {
|
||||
map.percent.push(percent);
|
||||
last += 1;
|
||||
}
|
||||
const bottom = rect.bottom - offset;
|
||||
const lineEnd = Number(elems[i].getAttribute('source-line-end'));
|
||||
const percentEnd = Math.max(0, Math.min(1, bottom / height));
|
||||
if (map.line[last] < lineEnd && map.percent[last] < percentEnd) {
|
||||
map.line.push(lineEnd);
|
||||
map.percent.push(percentEnd);
|
||||
last += 1;
|
||||
}
|
||||
}
|
||||
const lineCount = scrollmap.lineCount_;
|
||||
if (lineCount) {
|
||||
map.lineCount = lineCount;
|
||||
} else {
|
||||
if (map.lineCount <= map.line[last]) map.lineCount = map.line[last] + 1;
|
||||
if (map.lineCount < map.line[last]) map.lineCount = map.line[last];
|
||||
}
|
||||
if (map.percent[last] < 1) {
|
||||
map.line.push(lineCount || 1e10);
|
||||
|
||||
20
packages/app-desktop/loadResources.testEnv.ts
Normal file
20
packages/app-desktop/loadResources.testEnv.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* A Jest custom test Environment to load the resources for the tests.
|
||||
* Use this test envirenment when you work with resources like images, files.
|
||||
* See gui/NoteEditor/utils/contextMenu.test.ts for an example.
|
||||
*/
|
||||
|
||||
const JSDOMEnvironment = require('jest-environment-jsdom');
|
||||
import type { EnvironmentContext } from '@jest/environment';
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
|
||||
export default class CustomEnvironment extends JSDOMEnvironment {
|
||||
constructor(config: Config.ProjectConfig, context?: EnvironmentContext) {
|
||||
// Resources is set to 'usable' to enable fetching of resources like images and fonts while testing
|
||||
// Which does not happen by default in jest
|
||||
// https://stackoverflow.com/a/49482563
|
||||
config.testEnvironmentOptions.resources = 'usable';
|
||||
super(config, context);
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,15 @@ document.addEventListener('auxclick', event => event.preventDefault());
|
||||
// Each link (rendered as a button or list item) has its own custom click event
|
||||
// so disable the default. In particular this will disable Ctrl+Clicking a link
|
||||
// which would open a new browser window.
|
||||
document.addEventListener('click', (event) => event.preventDefault());
|
||||
document.addEventListener('click', (event) => {
|
||||
// We don't apply this to labels and inputs because it would break
|
||||
// checkboxes. Such a global event handler is probably not a good idea
|
||||
// anyway but keeping it for now, as it doesn't seem to break anything else.
|
||||
// https://github.com/facebook/react/issues/13477#issuecomment-489274045
|
||||
if (['LABEL', 'INPUT'].includes(event.target.nodeName)) return;
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
app().start(bridge().processArgv()).then((result) => {
|
||||
if (!result || !result.action) {
|
||||
|
||||
@@ -180,6 +180,22 @@ h2 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.form > .form-input-group-checkbox {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form > .form-input-group-checkbox > input {
|
||||
display: flex;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.form > .form-input-group-checkbox > label {
|
||||
display: flex;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"allowToChangeInstallationDirectory": false,
|
||||
"differentialPackage": false
|
||||
},
|
||||
"portable": {
|
||||
@@ -116,6 +116,7 @@
|
||||
"app-builder-bin": "^1.9.11",
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"canvas": "^2.9.0",
|
||||
"electron": "14.1.0",
|
||||
"electron-builder": "^22.11.7",
|
||||
"electron-notarize": "^1.0.0",
|
||||
|
||||
@@ -38,10 +38,9 @@ class Dropdown extends React.Component {
|
||||
const listTop = Math.min(maxListTop, this.state.headerSize.y + this.state.headerSize.height);
|
||||
|
||||
const wrapperStyle = {
|
||||
width: this.state.headerSize.width,
|
||||
height: listHeight + 2, // +2 for the border (otherwise it makes the scrollbar appear)
|
||||
marginTop: listTop,
|
||||
marginLeft: this.state.headerSize.x,
|
||||
alignSelf: 'center',
|
||||
};
|
||||
|
||||
const itemListStyle = Object.assign({}, this.props.itemListStyle ? this.props.itemListStyle : {}, {
|
||||
@@ -87,6 +86,7 @@ class Dropdown extends React.Component {
|
||||
if (this.props.labelTransform && this.props.labelTransform === 'trim') headerLabel = headerLabel.trim();
|
||||
|
||||
const closeList = () => {
|
||||
if (this.props.onClose()) this.props.onClose();
|
||||
this.setState({ listVisible: false });
|
||||
};
|
||||
|
||||
@@ -116,6 +116,7 @@ class Dropdown extends React.Component {
|
||||
onPress={() => {
|
||||
this.updateHeaderCoordinates();
|
||||
this.setState({ listVisible: true });
|
||||
if (this.props.onOpen()) this.props.onOpen();
|
||||
}}
|
||||
>
|
||||
<Text ellipsizeMode="tail" numberOfLines={1} style={headerStyle}>
|
||||
|
||||
@@ -19,6 +19,8 @@ interface CodeMirrorResult {
|
||||
editor: EditorView;
|
||||
undo: Function;
|
||||
redo: Function;
|
||||
select: (anchor: number, head: number)=> void;
|
||||
insertText: (text: string)=> void;
|
||||
}
|
||||
|
||||
function postMessage(name: string, data: any) {
|
||||
@@ -36,25 +38,53 @@ function logMessage(...msg: any[]) {
|
||||
//
|
||||
// https://github.com/codemirror/theme-one-dark/blob/main/src/one-dark.ts
|
||||
//
|
||||
// For a tutorial, see:
|
||||
//
|
||||
// https://codemirror.net/6/examples/styling/#themes
|
||||
//
|
||||
// Use Safari developer tools to view the content of the CodeMirror iframe while
|
||||
// the app is running. It seems that what appears as ".ͼ1" in the CSS is the
|
||||
// equivalent of "&" in the theme object. So to target ".ͼ1.cm-focused", you'd
|
||||
// use '&.cm-focused' in the theme.
|
||||
const createTheme = (theme: any): Extension => {
|
||||
const isDarkTheme = theme.appearance === 'dark';
|
||||
|
||||
const baseGlobalStyle: Record<string, string> = {
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontFamily: theme.fontFamily,
|
||||
fontSize: `${theme.fontSize}px`,
|
||||
};
|
||||
const baseCursorStyle: Record<string, string> = { };
|
||||
const baseContentStyle: Record<string, string> = { };
|
||||
const baseSelectionStyle: Record<string, string> = { };
|
||||
|
||||
// If we're in dark mode, the caret and selection are difficult to see.
|
||||
// Adjust them appropriately
|
||||
if (isDarkTheme) {
|
||||
// Styling the caret requires styling both the caret itself
|
||||
// and the CodeMirror caret.
|
||||
// See https://codemirror.net/6/examples/styling/#themes
|
||||
baseContentStyle.caretColor = 'white';
|
||||
baseCursorStyle.borderLeftColor = 'white';
|
||||
|
||||
baseSelectionStyle.backgroundColor = '#6b6b6b';
|
||||
}
|
||||
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'&': {
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontFamily: theme.fontFamily,
|
||||
fontSize: `${theme.fontSize}px`,
|
||||
},
|
||||
'&': baseGlobalStyle,
|
||||
|
||||
// These must be !important or more specific than CodeMirror's built-ins
|
||||
'.cm-content': baseContentStyle,
|
||||
'&.cm-focused .cm-cursor': baseCursorStyle,
|
||||
'&.cm-focused .cm-selectionBackground, ::selection': baseSelectionStyle,
|
||||
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
const appearanceTheme = EditorView.theme({}, { dark: theme.appearance === 'dark' });
|
||||
const appearanceTheme = EditorView.theme({}, { dark: isDarkTheme });
|
||||
|
||||
const baseHeadingStyle = {
|
||||
fontWeight: 'bold',
|
||||
@@ -152,6 +182,13 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
postMessage('onChange', { value: editor.state.doc.toString() });
|
||||
schedulePostUndoRedoDepthChange(editor);
|
||||
}
|
||||
|
||||
if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
||||
const mainRange = viewUpdate.state.selection.main;
|
||||
const selStart = mainRange.from;
|
||||
const selEnd = mainRange.to;
|
||||
postMessage('onSelectionChange', { selection: { start: selStart, end: selEnd } });
|
||||
}
|
||||
}),
|
||||
],
|
||||
doc: initialText,
|
||||
@@ -169,5 +206,14 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
redo(editor);
|
||||
schedulePostUndoRedoDepthChange(editor, true);
|
||||
},
|
||||
select: (anchor: number, head: number) => {
|
||||
editor.dispatch(editor.state.update({
|
||||
selection: { anchor, head },
|
||||
scrollIntoView: true,
|
||||
}));
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
editor.dispatch(editor.state.replaceSelection(text));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
const React = require('react');
|
||||
const { forwardRef, useImperativeHandle, useEffect, useState, useCallback, useRef } = require('react');
|
||||
const { forwardRef, useImperativeHandle, useEffect, useMemo, useState, useCallback, useRef } = require('react');
|
||||
const { WebView } = require('react-native-webview');
|
||||
const { editorFont } = require('../global-style');
|
||||
|
||||
@@ -15,14 +15,27 @@ export interface UndoRedoDepthChangeEvent {
|
||||
redoDepth: number;
|
||||
}
|
||||
|
||||
export interface Selection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SelectionChangeEvent {
|
||||
selection: Selection;
|
||||
}
|
||||
|
||||
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
||||
type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
initialText: string;
|
||||
initialSelection?: Selection;
|
||||
style: any;
|
||||
|
||||
onChange: ChangeEventHandler;
|
||||
onSelectionChange: SelectionChangeEventHandler;
|
||||
onUndoRedoDepthChange: UndoRedoDepthChangeHandler;
|
||||
}
|
||||
|
||||
@@ -31,6 +44,7 @@ function fontFamilyFromSettings() {
|
||||
return [f, 'sans-serif'].join(', ');
|
||||
}
|
||||
|
||||
// Obsolete with CodeMirror 6. See ./CodeMirror.ts for styling.
|
||||
// function useCss(themeId:number):string {
|
||||
// const [css, setCss] = useState('');
|
||||
|
||||
@@ -169,6 +183,17 @@ function fontFamilyFromSettings() {
|
||||
// return css;
|
||||
// }
|
||||
|
||||
function useCss(themeId: number): string {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return `
|
||||
:root {
|
||||
background-color: ${theme.backgroundColor};
|
||||
}
|
||||
`;
|
||||
}, [themeId]);
|
||||
}
|
||||
|
||||
function useHtml(css: string): string {
|
||||
const [html, setHtml] = useState('');
|
||||
|
||||
@@ -211,11 +236,15 @@ function NoteEditor(props: Props, ref: any) {
|
||||
const [source, setSource] = useState(undefined);
|
||||
const webviewRef = useRef(null);
|
||||
|
||||
const setInitialSelectionJS = props.initialSelection ? `
|
||||
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
|
||||
` : '';
|
||||
|
||||
const injectedJavaScript = `
|
||||
function postMessage(name, data) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
data,
|
||||
name,
|
||||
name,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -226,7 +255,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
// This variable is not used within this script
|
||||
// but is called using "injectJavaScript" from
|
||||
// the wrapper component.
|
||||
let cm = null;
|
||||
window.cm = null;
|
||||
|
||||
try {
|
||||
${shim.injectedJs('codeMirrorBundle')};
|
||||
@@ -236,6 +265,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
const initialText = ${JSON.stringify(props.initialText)};
|
||||
|
||||
cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, theme);
|
||||
${setInitialSelectionJS}
|
||||
} catch (e) {
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
} finally {
|
||||
@@ -243,8 +273,8 @@ function NoteEditor(props: Props, ref: any) {
|
||||
}
|
||||
`;
|
||||
|
||||
// const css = useCss(props.themeId);
|
||||
const html = useHtml('');
|
||||
const css = useCss(props.themeId);
|
||||
const html = useHtml(css);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
@@ -254,6 +284,14 @@ function NoteEditor(props: Props, ref: any) {
|
||||
redo: function() {
|
||||
webviewRef.current.injectJavaScript('cm.redo(); true;');
|
||||
},
|
||||
select: (anchor: number, head: number) => {
|
||||
webviewRef.current.injectJavaScript(
|
||||
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)}); true;`
|
||||
);
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
webviewRef.current.injectJavaScript(`cm.insertText(${JSON.stringify(text)}); true;`);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -300,6 +338,10 @@ function NoteEditor(props: Props, ref: any) {
|
||||
console.info('onUndoRedoDepthChange', event);
|
||||
props.onUndoRedoDepthChange(event);
|
||||
},
|
||||
|
||||
onSelectionChange: (event: SelectionChangeEvent) => {
|
||||
props.onSelectionChange(event);
|
||||
},
|
||||
};
|
||||
|
||||
if (handlers[msg.name]) {
|
||||
|
||||
@@ -103,7 +103,7 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any>
|
||||
return (
|
||||
<View style={{ flex: 0, margin: 20, alignItems: 'center' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{ this.state.date && <Text style={{ ...theme.normalText, marginRight: 10 }}>{time.formatDateToLocal(this.state.date)}</Text> }
|
||||
{ this.state.date && <Text style={{ ...theme.normalText,color: theme.color, marginRight: 10 }}>{time.formatDateToLocal(this.state.date)}</Text> }
|
||||
<Button title="Set date" onPress={this.onSetDate} />
|
||||
</View>
|
||||
<DateTimePickerModal
|
||||
|
||||
@@ -29,8 +29,10 @@ class ScreenHeaderComponent extends React.PureComponent {
|
||||
constructor() {
|
||||
super();
|
||||
this.styles_ = {};
|
||||
this.state = { showUndoRedoButtons: true };
|
||||
}
|
||||
|
||||
|
||||
styles() {
|
||||
const themeId = Setting.value('theme');
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
@@ -256,7 +258,7 @@ class ScreenHeaderComponent extends React.PureComponent {
|
||||
}
|
||||
|
||||
const renderTopButton = (options) => {
|
||||
if (!options.visible) return null;
|
||||
if (!options.visible || !this.state.showUndoRedoButtons) return null;
|
||||
|
||||
const icon = <Icon name={options.iconName} style={this.styles().topIcon} />;
|
||||
const viewStyle = options.disabled ? this.styles().iconButtonDisabled : this.styles().iconButton;
|
||||
@@ -422,6 +424,16 @@ class ScreenHeaderComponent extends React.PureComponent {
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
}}
|
||||
onOpen={() => {
|
||||
this.setState({
|
||||
showUndoRedoButtons: false,
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
this.setState({
|
||||
showUndoRedoButtons: true,
|
||||
});
|
||||
}}
|
||||
onValueChange={async (folderId, itemIndex) => {
|
||||
// If onValueChange is specified, use this as a callback, otherwise do the default
|
||||
// which is to take the selectedNoteIds from the state and move them to the
|
||||
|
||||
@@ -530,9 +530,9 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
if (this.state.profileExportStatus === 'prompt') {
|
||||
const profileExportPrompt = (
|
||||
<View style={this.styles().settingContainer} key="profileExport">
|
||||
<Text style={this.styles().settingText}>Path:</Text>
|
||||
<TextInput style={{ ...this.styles().textInput, paddingRight: 20 }} onChange={(event: any) => this.setState({ profileExportPath: event.nativeEvent.text })} value={this.state.profileExportPath} placeholder="/path/to/sdcard" keyboardAppearance={theme.keyboardAppearance}></TextInput>
|
||||
<Button title="OK" onPress={this.exportProfileButtonPress2_}></Button>
|
||||
<Text style={{ ...this.styles().settingText, flex: 0 }}>Path:</Text>
|
||||
<TextInput style={{ ...this.styles().textInput, paddingRight: 20, width: '75%', marginRight: 'auto' }} onChange={(event: any) => this.setState({ profileExportPath: event.nativeEvent.text })} value={this.state.profileExportPath} placeholder="/path/to/sdcard" keyboardAppearance={theme.keyboardAppearance} />
|
||||
<Button title="OK" onPress={this.exportProfileButtonPress2_} />
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -488,7 +488,11 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
}
|
||||
|
||||
body_selectionChange(event: any) {
|
||||
this.selection = event.nativeEvent.selection;
|
||||
if (this.useEditorBeta()) {
|
||||
this.selection = event.selection;
|
||||
} else {
|
||||
this.selection = event.nativeEvent.selection;
|
||||
}
|
||||
}
|
||||
|
||||
makeSaveAction() {
|
||||
@@ -708,9 +712,17 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
const newNote = Object.assign({}, this.state.note);
|
||||
|
||||
if (this.state.mode == 'edit' && !!this.selection) {
|
||||
const newText = `\n${resourceTag}\n`;
|
||||
|
||||
const prefix = newNote.body.substring(0, this.selection.start);
|
||||
const suffix = newNote.body.substring(this.selection.end);
|
||||
newNote.body = `${prefix}\n${resourceTag}\n${suffix}`;
|
||||
newNote.body = `${prefix}${newText}${suffix}`;
|
||||
|
||||
if (this.useEditorBeta()) {
|
||||
// The beta editor needs to be explicitly informed of changes
|
||||
// to the note's body
|
||||
this.editorRef.current.insertText(newText);
|
||||
}
|
||||
} else {
|
||||
newNote.body += `\n${resourceTag}`;
|
||||
}
|
||||
@@ -879,11 +891,6 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
output.push({
|
||||
title: _('Attach...'),
|
||||
onPress: async () => {
|
||||
if (this.state.mode === 'edit' && this.useEditorBeta()) {
|
||||
alert('Attaching files from the beta editor is not yet supported. You may do so from the viewer mode instead.');
|
||||
return;
|
||||
}
|
||||
|
||||
const buttons = [];
|
||||
|
||||
// On iOS, it will show "local files", which means certain files saved from the browser
|
||||
@@ -1125,7 +1132,9 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
ref={this.editorRef}
|
||||
themeId={this.props.themeId}
|
||||
initialText={note.body}
|
||||
initialSelection={this.selection}
|
||||
onChange={this.onBodyChange}
|
||||
onSelectionChange={this.body_selectionChange}
|
||||
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
|
||||
style={this.styles().bodyTextInput}
|
||||
/>;
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
"author": "<%= pluginAuthor %>",
|
||||
"homepage_url": "<%= pluginHomepageUrl %>",
|
||||
"repository_url": "<%= pluginRepositoryUrl %>",
|
||||
"keywords": []
|
||||
"keywords": [],
|
||||
"categories": []
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const userConfig = Object.assign({}, {
|
||||
|
||||
const manifestPath = `${srcDir}/manifest.json`;
|
||||
const packageJsonPath = `${rootDir}/package.json`;
|
||||
const allPossibleCategories = ['appearance', 'developer tools', 'productivity', 'themes', 'integrations', 'viewer', 'search', 'tags', 'editor', 'files', 'personal knowledge management'];
|
||||
const manifest = readManifest(manifestPath);
|
||||
const pluginArchiveFilePath = path.resolve(publishDir, `${manifest.id}.jpl`);
|
||||
const pluginInfoFilePath = path.resolve(publishDir, `${manifest.id}.json`);
|
||||
@@ -67,10 +68,19 @@ function currentGitInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
function validateCategories(categories) {
|
||||
if (!categories) return null;
|
||||
if ((categories.length !== new Set(categories).size)) throw new Error('Repeated categories are not allowed');
|
||||
categories.forEach(category => {
|
||||
if (!allPossibleCategories.includes(category)) throw new Error(`${category} is not a valid category. Please make sure that the category name is lowercase. Valid Categories are: \n${allPossibleCategories}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
function readManifest(manifestPath) {
|
||||
const content = fs.readFileSync(manifestPath, 'utf8');
|
||||
const output = JSON.parse(content);
|
||||
if (!output.id) throw new Error(`Manifest plugin ID is not set in ${manifestPath}`);
|
||||
validateCategories(output.categories);
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ import SyncTargetNone from './SyncTargetNone';
|
||||
import { setRSA } from './services/e2ee/ppk';
|
||||
import RSA from './services/e2ee/RSA.node';
|
||||
import Resource from './models/Resource';
|
||||
import { ProfileConfig } from './services/profileConfig/types';
|
||||
import initProfile from './services/profileConfig/initProfile';
|
||||
|
||||
const appLogger: LoggerWrapper = Logger.create('App');
|
||||
|
||||
@@ -70,6 +72,7 @@ export default class BaseApplication {
|
||||
private eventEmitter_: any;
|
||||
private scheduleAutoAddResourcesIID_: any = null;
|
||||
private database_: any = null;
|
||||
private profileConfig_: ProfileConfig = null;
|
||||
|
||||
protected showStackTraces_: boolean = false;
|
||||
protected showPromptString_: boolean = false;
|
||||
@@ -646,6 +649,12 @@ export default class BaseApplication {
|
||||
public initRedux() {
|
||||
this.store_ = createStore(this.reducer, applyMiddleware(this.generalMiddlewareFn() as any));
|
||||
setStore(this.store_);
|
||||
|
||||
this.store_.dispatch({
|
||||
type: 'PROFILE_CONFIG_SET',
|
||||
value: this.profileConfig_,
|
||||
});
|
||||
|
||||
BaseModel.dispatch = this.store().dispatch;
|
||||
FoldersScreenUtils.dispatch = this.store().dispatch;
|
||||
// reg.dispatch = this.store().dispatch;
|
||||
@@ -714,14 +723,16 @@ export default class BaseApplication {
|
||||
// https://immerjs.github.io/immer/docs/freezing
|
||||
setAutoFreeze(initArgs.env === 'dev');
|
||||
|
||||
const profileDir = this.determineProfileDir(initArgs);
|
||||
const rootProfileDir = this.determineProfileDir(initArgs);
|
||||
const { profileDir, profileConfig, isSubProfile } = await initProfile(rootProfileDir);
|
||||
this.profileConfig_ = profileConfig;
|
||||
|
||||
const resourceDirName = 'resources';
|
||||
const resourceDir = `${profileDir}/${resourceDirName}`;
|
||||
const tempDir = `${profileDir}/tmp`;
|
||||
const cacheDir = `${profileDir}/cache`;
|
||||
|
||||
Setting.setConstant('env', initArgs.env);
|
||||
Setting.setConstant('profileDir', profileDir);
|
||||
Setting.setConstant('resourceDirName', resourceDirName);
|
||||
Setting.setConstant('resourceDir', resourceDir);
|
||||
Setting.setConstant('tempDir', tempDir);
|
||||
@@ -778,6 +789,7 @@ export default class BaseApplication {
|
||||
|
||||
|
||||
appLogger.info(`Profile directory: ${profileDir}`);
|
||||
appLogger.info(`Root profile directory: ${rootProfileDir}`);
|
||||
|
||||
this.database_ = new JoplinDatabase(new DatabaseDriverNode());
|
||||
this.database_.setLogExcludedQueryTypes(['SELECT']);
|
||||
@@ -820,7 +832,7 @@ export default class BaseApplication {
|
||||
// Setting.setValue('sync.10.path', 'https://api.joplincloud.com');
|
||||
// Setting.setValue('sync.10.userContentPath', 'https://joplinusercontent.com');
|
||||
Setting.setValue('sync.10.path', 'http://api.joplincloud.local:22300');
|
||||
Setting.setValue('sync.10.userContentPath', 'http://joplinusercontent.local:22300');
|
||||
Setting.setValue('sync.10.userContentPath', 'http://joplincloud.local:22300');
|
||||
}
|
||||
|
||||
// For now always disable fuzzy search due to performance issues:
|
||||
@@ -838,6 +850,7 @@ export default class BaseApplication {
|
||||
}
|
||||
|
||||
if ('welcomeDisabled' in initArgs) Setting.setValue('welcome.enabled', !initArgs.welcomeDisabled);
|
||||
if (isSubProfile) Setting.setValue('welcome.enabled', false);
|
||||
|
||||
if (!Setting.value('api.token')) {
|
||||
void EncryptionService.instance()
|
||||
|
||||
@@ -33,6 +33,10 @@ export default class BaseSyncTarget {
|
||||
return true;
|
||||
}
|
||||
|
||||
public static supportsRecursiveLinkedNotes(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public option(name: string, defaultValue: any = null) {
|
||||
return this.options_ && name in this.options_ ? this.options_[name] : defaultValue;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ export default class SyncTargetJoplinCloud extends BaseSyncTarget {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static supportsRecursiveLinkedNotes(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async isAuthenticated() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4,33 +4,16 @@ export interface SyncTargetInfo {
|
||||
label: string;
|
||||
supportsSelfHosted: boolean;
|
||||
supportsConfigCheck: boolean;
|
||||
supportsRecursiveLinkedNotes: boolean;
|
||||
description: string;
|
||||
classRef: any;
|
||||
}
|
||||
|
||||
// const syncTargetOrder = [
|
||||
// 'joplinCloud',
|
||||
// 'dropbox',
|
||||
// 'onedrive',
|
||||
// ];
|
||||
|
||||
export default class SyncTargetRegistry {
|
||||
|
||||
private static reg_: Record<number, SyncTargetInfo> = {};
|
||||
|
||||
private static get reg() {
|
||||
// if (!this.reg_[0]) {
|
||||
// this.reg_[0] = {
|
||||
// id: 0,
|
||||
// name: SyncTargetNone.targetName(),
|
||||
// label: SyncTargetNone.label(),
|
||||
// classRef: SyncTargetNone,
|
||||
// description: SyncTargetNone.description(),
|
||||
// supportsSelfHosted: false,
|
||||
// supportsConfigCheck: false,
|
||||
// };
|
||||
// }
|
||||
|
||||
return this.reg_;
|
||||
}
|
||||
|
||||
@@ -47,6 +30,10 @@ export default class SyncTargetRegistry {
|
||||
throw new Error(`Unknown name: ${name}`);
|
||||
}
|
||||
|
||||
public static infoById(id: number): SyncTargetInfo {
|
||||
return this.infoByName(this.idToName(id));
|
||||
}
|
||||
|
||||
public static addClass(SyncTargetClass: any) {
|
||||
this.reg[SyncTargetClass.id()] = {
|
||||
id: SyncTargetClass.id(),
|
||||
@@ -56,6 +43,7 @@ export default class SyncTargetRegistry {
|
||||
description: SyncTargetClass.description(),
|
||||
supportsSelfHosted: SyncTargetClass.supportsSelfHosted(),
|
||||
supportsConfigCheck: SyncTargetClass.supportsConfigCheck(),
|
||||
supportsRecursiveLinkedNotes: SyncTargetClass.supportsRecursiveLinkedNotes(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const Folder = require('../../models/Folder').default;
|
||||
const Setting = require('../../models/Setting').default;
|
||||
const BaseModel = require('../../BaseModel').default;
|
||||
const Tag = require('../../models/Tag').default;
|
||||
|
||||
const shared = {};
|
||||
|
||||
@@ -51,7 +50,20 @@ shared.renderFolders = function(props, renderItem) {
|
||||
};
|
||||
|
||||
shared.renderTags = function(props, renderItem) {
|
||||
const tags = Tag.sortTags(props.tags.slice());
|
||||
const tags = props.tags.slice();
|
||||
tags.sort((a, b) => {
|
||||
// It seems title can sometimes be undefined (perhaps when syncing
|
||||
// and before tag has been decrypted?). It would be best to find
|
||||
// the root cause but for now that will do.
|
||||
//
|
||||
// Fixes https://github.com/laurent22/joplin/issues/4051
|
||||
if (!a || !a.title || !b || !b.title) return 0;
|
||||
|
||||
// Note: while newly created tags are normalized and lowercase
|
||||
// imported tags might be any case, so we need to do case-insensitive
|
||||
// sort.
|
||||
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
|
||||
});
|
||||
const tagItems = [];
|
||||
const order = [];
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import Setting, { SettingSectionSource } from '../models/Setting';
|
||||
import Setting, { SettingSectionSource, SettingStorage } from '../models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, expectThrow, expectNotThrow, msleep } from '../testing/test-utils';
|
||||
import * as fs from 'fs-extra';
|
||||
import { readFile, stat, mkdirp, writeFile, pathExists, readdir } from 'fs-extra';
|
||||
import Logger from '../Logger';
|
||||
import { defaultProfileConfig } from '../services/profileConfig/types';
|
||||
import { createNewProfile, saveProfileConfig } from '../services/profileConfig';
|
||||
import initProfile from '../services/profileConfig/initProfile';
|
||||
|
||||
async function loadSettingsFromFile(): Promise<any> {
|
||||
return JSON.parse(await fs.readFile(Setting.settingFilePath, 'utf8'));
|
||||
return JSON.parse(await readFile(Setting.settingFilePath, 'utf8'));
|
||||
}
|
||||
|
||||
const switchToSubProfileSettings = async () => {
|
||||
await Setting.reset();
|
||||
const rootProfileDir = Setting.value('profileDir');
|
||||
const profileConfigPath = `${rootProfileDir}/profiles.json`;
|
||||
let profileConfig = defaultProfileConfig();
|
||||
profileConfig = createNewProfile(profileConfig, 'Sub-profile');
|
||||
profileConfig.currentProfile = 1;
|
||||
await saveProfileConfig(profileConfigPath, profileConfig);
|
||||
const { profileDir } = await initProfile(rootProfileDir);
|
||||
await mkdirp(profileDir);
|
||||
await Setting.load();
|
||||
};
|
||||
|
||||
describe('models/Setting', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
@@ -180,19 +196,19 @@ describe('models/Setting', function() {
|
||||
{
|
||||
// Double-check that timestamp is indeed changed when the content is
|
||||
// changed.
|
||||
const beforeStat = await fs.stat(Setting.settingFilePath);
|
||||
const beforeStat = await stat(Setting.settingFilePath);
|
||||
await msleep(1001);
|
||||
Setting.setValue('sync.mobileWifiOnly', false);
|
||||
await Setting.saveAll();
|
||||
const afterStat = await fs.stat(Setting.settingFilePath);
|
||||
const afterStat = await stat(Setting.settingFilePath);
|
||||
expect(afterStat.mtime.getTime()).toBeGreaterThan(beforeStat.mtime.getTime());
|
||||
}
|
||||
|
||||
{
|
||||
const beforeStat = await fs.stat(Setting.settingFilePath);
|
||||
const beforeStat = await stat(Setting.settingFilePath);
|
||||
await msleep(1001);
|
||||
Setting.setValue('sync.mobileWifiOnly', false);
|
||||
const afterStat = await fs.stat(Setting.settingFilePath);
|
||||
const afterStat = await stat(Setting.settingFilePath);
|
||||
await Setting.saveAll();
|
||||
expect(afterStat.mtime.getTime()).toBe(beforeStat.mtime.getTime());
|
||||
}
|
||||
@@ -200,7 +216,7 @@ describe('models/Setting', function() {
|
||||
|
||||
it('should handle invalid JSON', (async () => {
|
||||
const badContent = '{ oopsIforgotTheQuotes: true}';
|
||||
await fs.writeFile(Setting.settingFilePath, badContent, 'utf8');
|
||||
await writeFile(Setting.settingFilePath, badContent, 'utf8');
|
||||
await Setting.reset();
|
||||
|
||||
Logger.globalLogger.enabled = false;
|
||||
@@ -208,12 +224,12 @@ describe('models/Setting', function() {
|
||||
Logger.globalLogger.enabled = true;
|
||||
|
||||
// Invalid JSON file has been moved to .bak file
|
||||
expect(await fs.pathExists(Setting.settingFilePath)).toBe(false);
|
||||
expect(await pathExists(Setting.settingFilePath)).toBe(false);
|
||||
|
||||
const files = await fs.readdir(Setting.value('profileDir'));
|
||||
const files = await 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);
|
||||
expect(await readFile(`${Setting.value('profileDir')}/${files[0]}`, 'utf8')).toBe(badContent);
|
||||
}));
|
||||
|
||||
it('should allow applying default migrations', (async () => {
|
||||
@@ -256,4 +272,67 @@ describe('models/Setting', function() {
|
||||
expect(Setting.value('style.editor.contentMaxWidth')).toBe(600); // Changed
|
||||
}));
|
||||
|
||||
it('should load sub-profile settings', async () => {
|
||||
await Setting.reset();
|
||||
|
||||
Setting.setValue('locale', 'fr_FR'); // Global setting
|
||||
Setting.setValue('theme', Setting.THEME_DARK); // Global setting
|
||||
Setting.setValue('sync.target', 9); // Local setting
|
||||
await Setting.saveAll();
|
||||
|
||||
await switchToSubProfileSettings();
|
||||
|
||||
expect(Setting.value('locale')).toBe('fr_FR'); // Should come from the root profile
|
||||
expect(Setting.value('theme')).toBe(Setting.THEME_DARK); // Should come from the root profile
|
||||
expect(Setting.value('sync.target')).toBe(0); // Should come from the local profile
|
||||
|
||||
// Also check that the special loadOne() function works as expected
|
||||
|
||||
expect((await Setting.loadOne('locale')).value).toBe('fr_FR');
|
||||
expect((await Setting.loadOne('theme')).value).toBe(Setting.THEME_DARK);
|
||||
expect((await Setting.loadOne('sync.target')).value).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should save sub-profile settings', async () => {
|
||||
await Setting.reset();
|
||||
Setting.setValue('locale', 'fr_FR'); // Global setting
|
||||
Setting.setValue('theme', Setting.THEME_DARK); // Global setting
|
||||
await Setting.saveAll();
|
||||
|
||||
await switchToSubProfileSettings();
|
||||
|
||||
Setting.setValue('locale', 'en_GB'); // Should be saved to global
|
||||
Setting.setValue('sync.target', 8); // Should be saved to local
|
||||
|
||||
await Setting.saveAll();
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
|
||||
expect(Setting.value('locale')).toBe('en_GB');
|
||||
expect(Setting.value('theme')).toBe(Setting.THEME_DARK);
|
||||
expect(Setting.value('sync.target')).toBe(8);
|
||||
|
||||
// Double-check that actual file content is correct
|
||||
|
||||
const globalSettings = JSON.parse(await readFile(`${Setting.value('rootProfileDir')}/settings-1.json`, 'utf8'));
|
||||
const localSettings = JSON.parse(await readFile(`${Setting.value('profileDir')}/settings-1.json`, 'utf8'));
|
||||
|
||||
expect(globalSettings).toEqual({
|
||||
'$schema': 'https://joplinapp.org/schema/settings.json',
|
||||
locale: 'en_GB',
|
||||
theme: 2,
|
||||
});
|
||||
|
||||
expect(localSettings).toEqual({
|
||||
'$schema': 'https://joplinapp.org/schema/settings.json',
|
||||
'sync.target': 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('all global settings should be saved to file', async () => {
|
||||
for (const [k, v] of Object.entries(Setting.metadata())) {
|
||||
if (v.isGlobal && v.storage !== SettingStorage.File) throw new Error(`Setting "${k}" is global but storage is not "file"`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import SyncTargetRegistry from '../SyncTargetRegistry';
|
||||
import time from '../time';
|
||||
import FileHandler, { SettingValues } from './settings/FileHandler';
|
||||
import Logger from '../Logger';
|
||||
import mergeGlobalAndLocalSettings from '../services/profileConfig/mergeGlobalAndLocalSettings';
|
||||
import splitGlobalAndLocalSettings from '../services/profileConfig/splitGlobalAndLocalSettings';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const ObjectUtils = require('../ObjectUtils');
|
||||
const { toTitleCase } = require('../string-utils.js');
|
||||
@@ -59,6 +61,18 @@ export interface SettingItem {
|
||||
autoSave?: boolean;
|
||||
storage?: SettingStorage;
|
||||
hideLabel?: boolean;
|
||||
|
||||
// In a multi-profile context, all settings are by default local - they take
|
||||
// their value from the current profile. This flag can be set to specify
|
||||
// that the setting is global and that its value should come from the root
|
||||
// profile. This flag only applies to sub-profiles.
|
||||
//
|
||||
// At the moment, all global settings must be saved to file (have the
|
||||
// storage attribute set to "file") because it's simpler to load the root
|
||||
// profile settings.json than load the whole SQLite database. This
|
||||
// restriction is not an issue normally since all settings that are
|
||||
// considered global are also the user-facing ones.
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
interface SettingItems {
|
||||
@@ -112,6 +126,7 @@ export interface Constants {
|
||||
resourceDirName: string;
|
||||
resourceDir: string;
|
||||
profileDir: string;
|
||||
rootProfileDir: string;
|
||||
tempDir: string;
|
||||
pluginDataDir: string;
|
||||
cacheDir: string;
|
||||
@@ -119,6 +134,7 @@ export interface Constants {
|
||||
flagOpenDevTools: boolean;
|
||||
syncVersion: number;
|
||||
startupDevPlugins: string[];
|
||||
isSubProfile: boolean;
|
||||
}
|
||||
|
||||
interface SettingSections {
|
||||
@@ -243,6 +259,7 @@ class Setting extends BaseModel {
|
||||
resourceDirName: '',
|
||||
resourceDir: '',
|
||||
profileDir: '',
|
||||
rootProfileDir: '',
|
||||
tempDir: '',
|
||||
pluginDataDir: '',
|
||||
cacheDir: '',
|
||||
@@ -250,6 +267,7 @@ class Setting extends BaseModel {
|
||||
flagOpenDevTools: false,
|
||||
syncVersion: 3,
|
||||
startupDevPlugins: [],
|
||||
isSubProfile: false,
|
||||
};
|
||||
|
||||
public static autoSaveEnabled = true;
|
||||
@@ -264,6 +282,7 @@ class Setting extends BaseModel {
|
||||
private static customSections_: SettingSections = {};
|
||||
private static changedKeys_: string[] = [];
|
||||
private static fileHandler_: FileHandler = null;
|
||||
private static rootFileHandler_: FileHandler = null;
|
||||
private static settingFilename_: string = 'settings.json';
|
||||
|
||||
static tableName() {
|
||||
@@ -291,6 +310,10 @@ class Setting extends BaseModel {
|
||||
return `${this.value('profileDir')}/${this.settingFilename_}`;
|
||||
}
|
||||
|
||||
public static get rootSettingFilePath(): string {
|
||||
return `${this.value('rootProfileDir')}/${this.settingFilename_}`;
|
||||
}
|
||||
|
||||
public static get settingFilename(): string {
|
||||
return this.settingFilename_;
|
||||
}
|
||||
@@ -306,6 +329,13 @@ class Setting extends BaseModel {
|
||||
return this.fileHandler_;
|
||||
}
|
||||
|
||||
public static get rootFileHandler(): FileHandler {
|
||||
if (!this.rootFileHandler_) {
|
||||
this.rootFileHandler_ = new FileHandler(this.rootSettingFilePath);
|
||||
}
|
||||
return this.rootFileHandler_;
|
||||
}
|
||||
|
||||
static keychainService() {
|
||||
if (!this.keychainService_) throw new Error('keychainService has not been set!!');
|
||||
return this.keychainService_;
|
||||
@@ -359,6 +389,7 @@ class Setting extends BaseModel {
|
||||
public: false,
|
||||
appTypes: [AppType.Desktop],
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'sync.openSyncWizard': {
|
||||
@@ -669,6 +700,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'sync.3.auth': { value: '', type: SettingItemType.String, public: false },
|
||||
@@ -687,7 +719,7 @@ class Setting extends BaseModel {
|
||||
'sync.9.context': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.10.context': { value: '', type: SettingItemType.String, public: false },
|
||||
|
||||
'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 },
|
||||
'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, storage: SettingStorage.File, isGlobal: true, 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
|
||||
// existing folder, so it is a good default in contexts where there's no currently
|
||||
@@ -695,7 +727,7 @@ class Setting extends BaseModel {
|
||||
// to the last folder that was selected.
|
||||
activeFolderId: { value: '', type: SettingItemType.String, public: false },
|
||||
|
||||
richTextBannerDismissed: { value: false, type: SettingItemType.Bool, public: false },
|
||||
richTextBannerDismissed: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: false },
|
||||
|
||||
firstStart: { value: true, type: SettingItemType.Bool, public: false },
|
||||
locale: {
|
||||
@@ -708,6 +740,7 @@ class Setting extends BaseModel {
|
||||
return ObjectUtils.sortByValue(supportedLocalesToLanguages({ includeStats: true }));
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
dateFormat: {
|
||||
value: Setting.DATE_FORMAT_1,
|
||||
@@ -730,6 +763,7 @@ class Setting extends BaseModel {
|
||||
return options;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
timeFormat: {
|
||||
value: Setting.TIME_FORMAT_1,
|
||||
@@ -746,6 +780,7 @@ class Setting extends BaseModel {
|
||||
return options;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
theme: {
|
||||
@@ -761,6 +796,7 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
options: () => themeOptions(),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
themeAutoDetect: {
|
||||
@@ -771,6 +807,7 @@ class Setting extends BaseModel {
|
||||
public: true,
|
||||
label: () => _('Automatically switch theme to match system theme'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
preferredLightTheme: {
|
||||
@@ -786,6 +823,7 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
options: () => themeOptions(),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
preferredDarkTheme: {
|
||||
@@ -801,6 +839,7 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
options: () => themeOptions(),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
notificationPermission: {
|
||||
@@ -809,7 +848,7 @@ class Setting extends BaseModel {
|
||||
public: false,
|
||||
},
|
||||
|
||||
showNoteCounts: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, public: false, advanced: true, appTypes: [AppType.Desktop], label: () => _('Show note counts') },
|
||||
showNoteCounts: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: false, advanced: true, appTypes: [AppType.Desktop], label: () => _('Show note counts') },
|
||||
|
||||
layoutButtonSequence: {
|
||||
value: Setting.LAYOUT_ALL,
|
||||
@@ -824,9 +863,10 @@ class Setting extends BaseModel {
|
||||
[Setting.LAYOUT_VIEWER_SPLIT]: _('%s / %s', _('Viewer'), _('Split View')),
|
||||
}),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
uncompletedTodosOnTop: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Uncompleted to-dos on top') },
|
||||
showCompletedTodos: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Show completed to-dos') },
|
||||
uncompletedTodosOnTop: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Uncompleted to-dos on top') },
|
||||
showCompletedTodos: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Show completed to-dos') },
|
||||
'notes.sortOrder.field': {
|
||||
value: 'user_updated_time',
|
||||
type: SettingItemType.String,
|
||||
@@ -845,6 +885,7 @@ class Setting extends BaseModel {
|
||||
return options;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'editor.autoMatchingBraces': {
|
||||
value: true,
|
||||
@@ -854,8 +895,9 @@ class Setting extends BaseModel {
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Auto-pair braces, parenthesis, quotations, etc.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
|
||||
'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
|
||||
// NOTE: A setting whose name starts with 'notes.sortOrder' is special,
|
||||
// which implies changing the setting automatically triggers the reflesh of notes.
|
||||
// See lib/BaseApplication.ts/generalMiddleware() for details.
|
||||
@@ -868,6 +910,7 @@ class Setting extends BaseModel {
|
||||
label: () => _('Show sort order buttons'),
|
||||
// description: () => _('If true, sort order buttons (field + reverse) for notes are shown at the top of Note List.'),
|
||||
appTypes: [AppType.Desktop],
|
||||
isGlobal: true,
|
||||
},
|
||||
'notes.perFieldReversalEnabled': {
|
||||
value: true,
|
||||
@@ -931,8 +974,8 @@ class Setting extends BaseModel {
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
|
||||
trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', storage: SettingStorage.File, public: true, label: () => _('Save geo-location with notes') },
|
||||
'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
|
||||
trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Save geo-location with notes') },
|
||||
|
||||
// 2020-10-29: For now disable the beta editor due to
|
||||
// underlying bugs in the TextInput component which we cannot
|
||||
@@ -947,6 +990,8 @@ class Setting extends BaseModel {
|
||||
appTypes: [AppType.Mobile],
|
||||
label: () => 'Opt-in to the editor beta',
|
||||
description: () => 'This beta adds list continuation and syntax highlighting. If you find bugs, please report them in the Discourse forum.',
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
newTodoFocus: {
|
||||
@@ -964,6 +1009,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
newNoteFocus: {
|
||||
value: 'body',
|
||||
@@ -980,6 +1026,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'plugins.states': {
|
||||
@@ -1005,31 +1052,31 @@ class Setting extends BaseModel {
|
||||
},
|
||||
|
||||
// Deprecated - use markdown.plugin.*
|
||||
'markdown.softbreaks': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] },
|
||||
'markdown.typographer': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] },
|
||||
'markdown.softbreaks': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] },
|
||||
'markdown.typographer': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] },
|
||||
// Deprecated
|
||||
|
||||
'markdown.plugin.softbreaks': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable soft breaks')}${wysiwygYes}` },
|
||||
'markdown.plugin.typographer': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable typographer support')}${wysiwygYes}` },
|
||||
'markdown.plugin.linkify': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Linkify')}${wysiwygYes}` },
|
||||
'markdown.plugin.softbreaks': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable soft breaks')}${wysiwygYes}` },
|
||||
'markdown.plugin.typographer': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable typographer support')}${wysiwygYes}` },
|
||||
'markdown.plugin.linkify': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Linkify')}${wysiwygYes}` },
|
||||
|
||||
'markdown.plugin.katex': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable math expressions')}${wysiwygYes}` },
|
||||
'markdown.plugin.fountain': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Fountain syntax support')}${wysiwygYes}` },
|
||||
'markdown.plugin.mermaid': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Mermaid diagrams support')}${wysiwygYes}` },
|
||||
'markdown.plugin.katex': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable math expressions')}${wysiwygYes}` },
|
||||
'markdown.plugin.fountain': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Fountain syntax support')}${wysiwygYes}` },
|
||||
'markdown.plugin.mermaid': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Mermaid diagrams support')}${wysiwygYes}` },
|
||||
|
||||
'markdown.plugin.audioPlayer': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable audio player')}${wysiwygNo}` },
|
||||
'markdown.plugin.videoPlayer': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable video player')}${wysiwygNo}` },
|
||||
'markdown.plugin.pdfViewer': { storage: SettingStorage.File, value: !mobilePlatform, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Desktop], label: () => `${_('Enable PDF viewer')}${wysiwygNo}` },
|
||||
'markdown.plugin.mark': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ==mark== syntax')}${wysiwygYes}` },
|
||||
'markdown.plugin.footnote': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable footnotes')}${wysiwygNo}` },
|
||||
'markdown.plugin.toc': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable table of contents extension')}${wysiwygNo}` },
|
||||
'markdown.plugin.sub': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ~sub~ syntax')}${wysiwygYes}` },
|
||||
'markdown.plugin.sup': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ^sup^ syntax')}${wysiwygYes}` },
|
||||
'markdown.plugin.deflist': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable deflist syntax')}${wysiwygNo}` },
|
||||
'markdown.plugin.abbr': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable abbreviation syntax')}${wysiwygNo}` },
|
||||
'markdown.plugin.emoji': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable markdown emoji')}${wysiwygNo}` },
|
||||
'markdown.plugin.insert': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` },
|
||||
'markdown.plugin.multitable': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
|
||||
'markdown.plugin.audioPlayer': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable audio player')}${wysiwygNo}` },
|
||||
'markdown.plugin.videoPlayer': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable video player')}${wysiwygNo}` },
|
||||
'markdown.plugin.pdfViewer': { storage: SettingStorage.File, isGlobal: true, value: !mobilePlatform, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Desktop], label: () => `${_('Enable PDF viewer')}${wysiwygNo}` },
|
||||
'markdown.plugin.mark': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ==mark== syntax')}${wysiwygYes}` },
|
||||
'markdown.plugin.footnote': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable footnotes')}${wysiwygNo}` },
|
||||
'markdown.plugin.toc': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable table of contents extension')}${wysiwygNo}` },
|
||||
'markdown.plugin.sub': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ~sub~ syntax')}${wysiwygYes}` },
|
||||
'markdown.plugin.sup': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ^sup^ syntax')}${wysiwygYes}` },
|
||||
'markdown.plugin.deflist': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable deflist syntax')}${wysiwygNo}` },
|
||||
'markdown.plugin.abbr': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable abbreviation syntax')}${wysiwygNo}` },
|
||||
'markdown.plugin.emoji': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable markdown emoji')}${wysiwygNo}` },
|
||||
'markdown.plugin.insert': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` },
|
||||
'markdown.plugin.multitable': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
|
||||
|
||||
// Tray icon (called AppIndicator) doesn't work in Ubuntu
|
||||
// http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html
|
||||
@@ -1046,9 +1093,10 @@ class Setting extends BaseModel {
|
||||
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,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: true, appTypes: [AppType.Desktop], label: () => _('Start application minimised in the tray icon') },
|
||||
startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: true, appTypes: [AppType.Desktop], label: () => _('Start application minimised in the tray icon') },
|
||||
|
||||
collapsedFolderIds: { value: [], type: SettingItemType.Array, public: false },
|
||||
|
||||
@@ -1072,9 +1120,9 @@ class Setting extends BaseModel {
|
||||
},
|
||||
|
||||
// Deprecated in favour of windowContentZoomFactor
|
||||
'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, appTypes: [AppType.Desktop], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
|
||||
'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, isGlobal: true, appTypes: [AppType.Desktop], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
|
||||
|
||||
'style.editor.fontSize': { value: 15, type: SettingItemType.Int, public: true, storage: SettingStorage.File, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor font size'), minimum: 4, maximum: 50, step: 1 },
|
||||
'style.editor.fontSize': { value: 15, type: SettingItemType.Int, public: true, storage: SettingStorage.File, isGlobal: true, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor font size'), minimum: 4, maximum: 50, step: 1 },
|
||||
'style.editor.fontFamily':
|
||||
(mobilePlatform) ?
|
||||
({
|
||||
@@ -1101,6 +1149,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
}) : {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
@@ -1111,6 +1160,7 @@ class Setting extends BaseModel {
|
||||
description: () =>
|
||||
_('Used for most text in the markdown editor. If not found, a generic proportional (variable width) font is used.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'style.editor.monospaceFontFamily': {
|
||||
value: '',
|
||||
@@ -1122,9 +1172,10 @@ class Setting extends BaseModel {
|
||||
description: () =>
|
||||
_('Used where a fixed width font is needed to lay out text legibly (e.g. tables, checkboxes, code). If not found, a generic monospace (fixed width) font is used.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'style.editor.contentMaxWidth': { value: 0, type: SettingItemType.Int, public: true, storage: SettingStorage.File, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor maximum width'), description: () => _('Set it to 0 to make it take the complete available space. Recommended width is 600.') },
|
||||
'style.editor.contentMaxWidth': { value: 0, type: SettingItemType.Int, public: true, storage: SettingStorage.File, isGlobal: true,appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor maximum width'), description: () => _('Set it to 0 to make it take the complete available space. Recommended width is 600.') },
|
||||
|
||||
'ui.layout': { value: {}, type: SettingItemType.Object, storage: SettingStorage.File, public: false, appTypes: [AppType.Desktop] },
|
||||
|
||||
@@ -1149,6 +1200,8 @@ class Setting extends BaseModel {
|
||||
label: () => _('Custom stylesheet for rendered Markdown'),
|
||||
section: 'appearance',
|
||||
advanced: true,
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'style.customCss.joplinApp': {
|
||||
value: null,
|
||||
@@ -1167,6 +1220,8 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
advanced: true,
|
||||
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.',
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'sync.clearLocalSyncStateButton': {
|
||||
@@ -1192,9 +1247,9 @@ class Setting extends BaseModel {
|
||||
},
|
||||
|
||||
|
||||
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') },
|
||||
'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, public: true, appTypes: [AppType.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, storage: SettingStorage.File, public: false },
|
||||
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') },
|
||||
'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, isGlobal: true, public: true, appTypes: [AppType.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, storage: SettingStorage.File, isGlobal: true, public: false },
|
||||
'sync.interval': {
|
||||
value: 300,
|
||||
type: SettingItemType.Int,
|
||||
@@ -1214,6 +1269,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'sync.mobileWifiOnly': {
|
||||
value: false,
|
||||
@@ -1223,12 +1279,13 @@ class Setting extends BaseModel {
|
||||
label: () => _('Synchronise only over WiFi connection'),
|
||||
storage: SettingStorage.File,
|
||||
appTypes: [AppType.Mobile],
|
||||
isGlobal: true,
|
||||
},
|
||||
noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, storage: SettingStorage.File, public: false, appTypes: [AppType.Desktop] },
|
||||
noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, storage: SettingStorage.File, isGlobal: true, public: false, appTypes: [AppType.Desktop] },
|
||||
tagHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
|
||||
folderHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
|
||||
editor: { value: '', type: SettingItemType.String, subType: 'file_path_and_args', storage: SettingStorage.File, public: true, appTypes: [AppType.Cli, AppType.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, storage: SettingStorage.File, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page size for PDF export'), options: () => {
|
||||
editor: { value: '', type: SettingItemType.String, subType: 'file_path_and_args', storage: SettingStorage.File, isGlobal: true, public: true, appTypes: [AppType.Cli, AppType.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, storage: SettingStorage.File, isGlobal: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page size for PDF export'), options: () => {
|
||||
return {
|
||||
'A4': _('A4'),
|
||||
'Letter': _('Letter'),
|
||||
@@ -1238,7 +1295,7 @@ class Setting extends BaseModel {
|
||||
'Legal': _('Legal'),
|
||||
};
|
||||
} },
|
||||
'export.pdfPageOrientation': { value: 'portrait', type: SettingItemType.String, storage: SettingStorage.File, advanced: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page orientation for PDF export'), options: () => {
|
||||
'export.pdfPageOrientation': { value: 'portrait', type: SettingItemType.String, storage: SettingStorage.File, isGlobal: true, advanced: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page orientation for PDF export'), options: () => {
|
||||
return {
|
||||
'portrait': _('Portrait'),
|
||||
'landscape': _('Landscape'),
|
||||
@@ -1261,6 +1318,7 @@ class Setting extends BaseModel {
|
||||
return output;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'editor.spellcheckBeta': {
|
||||
@@ -1270,6 +1328,8 @@ class Setting extends BaseModel {
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => 'Enable spell checking in Markdown editor? (WARNING BETA feature)',
|
||||
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)',
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'net.customCertificates': {
|
||||
@@ -1324,8 +1384,8 @@ class Setting extends BaseModel {
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'api.token': { value: null, type: SettingItemType.String, public: false, storage: SettingStorage.File },
|
||||
'api.port': { value: null, type: SettingItemType.Int, storage: SettingStorage.File, public: true, appTypes: [AppType.Cli], description: () => _('Specify the port that should be used by the API server. If not set, a default will be used.') },
|
||||
'api.token': { value: null, type: SettingItemType.String, public: false, storage: SettingStorage.File, isGlobal: true },
|
||||
'api.port': { value: null, type: SettingItemType.Int, storage: SettingStorage.File, isGlobal: true, public: true, appTypes: [AppType.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 },
|
||||
'searchEngine.lastProcessedChangeId': { value: 0, type: SettingItemType.Int, public: false },
|
||||
@@ -1357,8 +1417,8 @@ class Setting extends BaseModel {
|
||||
'camera.type': { value: 0, type: SettingItemType.Int, public: false, appTypes: [AppType.Mobile] },
|
||||
'camera.ratio': { value: '4:3', type: SettingItemType.String, public: false, appTypes: [AppType.Mobile] },
|
||||
|
||||
'spellChecker.enabled': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, public: false },
|
||||
'spellChecker.language': { value: '', type: SettingItemType.String, storage: SettingStorage.File, public: false },
|
||||
'spellChecker.enabled': { value: true, type: SettingItemType.Bool, isGlobal: true, storage: SettingStorage.File, public: false },
|
||||
'spellChecker.language': { value: '', type: SettingItemType.String, isGlobal: true, storage: SettingStorage.File, public: false },
|
||||
|
||||
windowContentZoomFactor: {
|
||||
value: 100,
|
||||
@@ -1369,6 +1429,7 @@ class Setting extends BaseModel {
|
||||
maximum: 300,
|
||||
step: 10,
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'layout.folderList.factor': {
|
||||
@@ -1384,6 +1445,7 @@ class Setting extends BaseModel {
|
||||
'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.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'layout.noteList.factor': {
|
||||
value: 1,
|
||||
@@ -1398,6 +1460,7 @@ class Setting extends BaseModel {
|
||||
'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.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'layout.note.factor': {
|
||||
value: 2,
|
||||
@@ -1412,6 +1475,7 @@ class Setting extends BaseModel {
|
||||
'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.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'syncInfoCache': {
|
||||
@@ -1452,9 +1516,17 @@ class Setting extends BaseModel {
|
||||
|
||||
this.metadata_ = Object.assign(this.metadata_, this.customMetadata_);
|
||||
|
||||
if (this.value('env') === Env.Dev) this.validateMetadata(this.metadata_);
|
||||
|
||||
return this.metadata_;
|
||||
}
|
||||
|
||||
private static validateMetadata(md: SettingItems) {
|
||||
for (const [k, v] of Object.entries(md)) {
|
||||
if (v.isGlobal && v.storage !== SettingStorage.File) throw new Error(`Setting "${k}" is global but storage is not "file"`);
|
||||
}
|
||||
}
|
||||
|
||||
public static skipDefaultMigrations() {
|
||||
logger.info('Skipping all default migrations...');
|
||||
|
||||
@@ -1594,10 +1666,17 @@ class Setting extends BaseModel {
|
||||
// Low-level method to load a setting directly from the database. Should not be used in most cases.
|
||||
public static async loadOne(key: string): Promise<CacheItem | null> {
|
||||
if (this.keyStorage(key) === SettingStorage.File) {
|
||||
const fromFile = await this.fileHandler.load();
|
||||
let fileSettings = await this.fileHandler.load();
|
||||
|
||||
const md = this.settingMetadata(key);
|
||||
if (md.isGlobal) {
|
||||
const rootFileSettings = await this.rootFileHandler.load();
|
||||
fileSettings = mergeGlobalAndLocalSettings(rootFileSettings, fileSettings);
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
value: fromFile[key],
|
||||
value: fileSettings[key],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1664,11 +1743,17 @@ class Setting extends BaseModel {
|
||||
const itemsFromFile: CacheItem[] = [];
|
||||
|
||||
if (this.canUseFileStorage()) {
|
||||
const fromFile = await this.fileHandler.load();
|
||||
for (const k of Object.keys(fromFile)) {
|
||||
let fileSettings = await this.fileHandler.load();
|
||||
|
||||
if (this.value('isSubProfile')) {
|
||||
const rootFileSettings = await this.rootFileHandler.load();
|
||||
fileSettings = mergeGlobalAndLocalSettings(rootFileSettings, fileSettings);
|
||||
}
|
||||
|
||||
for (const k of Object.keys(fileSettings)) {
|
||||
itemsFromFile.push({
|
||||
key: k,
|
||||
value: fromFile[k],
|
||||
value: fileSettings[k],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2018,7 +2103,15 @@ class Setting extends BaseModel {
|
||||
|
||||
await BaseModel.db().transactionExecBatch(queries);
|
||||
|
||||
if (this.canUseFileStorage()) await this.fileHandler.save(valuesForFile);
|
||||
if (this.canUseFileStorage()) {
|
||||
if (this.value('isSubProfile')) {
|
||||
const { globalSettings, localSettings } = splitGlobalAndLocalSettings(valuesForFile);
|
||||
await this.rootFileHandler.save(globalSettings);
|
||||
await this.fileHandler.save(localSettings);
|
||||
} else {
|
||||
await this.fileHandler.save(valuesForFile);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Settings have been saved.');
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('models/Tag', function() {
|
||||
it('should return tags with note counts', (async () => {
|
||||
const folder1 = await Folder.save({ title: 'folder1' });
|
||||
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
||||
const note2 = await Note.save({ title: 'ma 2nd note',parent_id: folder1.id });
|
||||
const note2 = await Note.save({ title: 'ma 2nd note', parent_id: folder1.id });
|
||||
const todo1 = await Note.save({ title: 'todo 1', parent_id: folder1.id, is_todo: 1, todo_completed: 1590085027710 });
|
||||
await Tag.setNoteTagsByTitles(note1.id, ['un']);
|
||||
await Tag.setNoteTagsByTitles(note2.id, ['un']);
|
||||
@@ -156,24 +156,4 @@ describe('models/Tag', function() {
|
||||
expect(commonTagIds.includes(tagc.id)).toBe(true);
|
||||
}));
|
||||
|
||||
it('should sort tags', (async () => {
|
||||
// test for tags with titles
|
||||
const unsortedTags = [{ title: '@⏲15 min' },{ title: '#house' },{ title: '#coding' }, { title: '@⏲60 min' }, { title: '#wait' }, { title: '@⏲30 min' }];
|
||||
const sortedTags = Tag.sortTags(unsortedTags);
|
||||
expect(sortedTags).toEqual([{ title: '@⏲15 min' }, { title: '@⏲30 min' }, { title: '@⏲60 min' }, { title: '#coding' }, { title: '#house' }, { title: '#wait' }]);
|
||||
|
||||
// test for tags without titles
|
||||
const unsortedTags2 = [{ id: '40' } , { id: '50' }, { id: '10' }, { id: '30' }, { id: '20' }];
|
||||
const sortedTags2 = Tag.sortTags(unsortedTags2);
|
||||
expect(sortedTags2).toEqual([{ id: '40' } , { id: '50' }, { id: '10' }, { id: '30' }, { id: '20' }]);
|
||||
|
||||
// test for tags with titles, without titles and empty tags
|
||||
const unsortedTags3 = [{ id: '1' }, { id: '2', title: 'two' }, {}, { id: '3' }, { id: '4', title: 'four' }, { id: '5',title: 'five' }];
|
||||
const sortedTags3 = Tag.sortTags(unsortedTags3);
|
||||
expect(sortedTags3).toEqual([{ id: '1' }, { id: '2', title: 'two' }, {}, { id: '3' }, { id: '5', title: 'five' }, { id: '4', title: 'four' }]);
|
||||
|
||||
// test for empty list
|
||||
const emptyListSort = Tag.sortTags([]);
|
||||
expect(emptyListSort).toEqual([]);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -216,18 +216,4 @@ export default class Tag extends BaseItem {
|
||||
return tag;
|
||||
});
|
||||
}
|
||||
|
||||
static sortTags(tags: TagEntity[]) {
|
||||
return tags.sort((a: TagEntity,b: TagEntity) => {
|
||||
// It seems title can sometimes be undefined (perhaps when syncing and before tag has been decrypted?). It would be best to find the root cause but for now that will do.
|
||||
// Fixes https://github.com/laurent22/joplin/issues/4051
|
||||
if (!a || !a.title || !b || !b.title) return 0;
|
||||
|
||||
// Note: while newly created tags are normalized and lowercase
|
||||
// imported tags might be any case, so we need to do case-insensitive
|
||||
// sort.
|
||||
return a.title.localeCompare(b.title, undefined, { sensitivity: 'accent' });
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@joplin/renderer": "~2.7",
|
||||
"@joplin/turndown": "^4.0.61",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.43",
|
||||
"@types/nanoid": "^3.0.0",
|
||||
"async-mutex": "^0.1.3",
|
||||
"base-64": "^0.1.0",
|
||||
"base64-stream": "^1.0.0",
|
||||
|
||||
@@ -5,6 +5,7 @@ import Note from './models/Note';
|
||||
import Folder from './models/Folder';
|
||||
import BaseModel from './BaseModel';
|
||||
import { Store } from 'redux';
|
||||
import { ProfileConfig } from './services/profileConfig/types';
|
||||
const ArrayUtils = require('./ArrayUtils.js');
|
||||
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
|
||||
const { createSelectorCreator, defaultMemoize } = require('reselect');
|
||||
@@ -92,6 +93,7 @@ export interface State {
|
||||
isInsertingNotes: boolean;
|
||||
hasEncryptedItems: boolean;
|
||||
needApiAuth: boolean;
|
||||
profileConfig: ProfileConfig;
|
||||
|
||||
// Extra reducer keys go here:
|
||||
pluginService: PluginServiceState;
|
||||
@@ -162,6 +164,7 @@ export const defaultState: State = {
|
||||
isInsertingNotes: false,
|
||||
hasEncryptedItems: false,
|
||||
needApiAuth: false,
|
||||
profileConfig: null,
|
||||
|
||||
pluginService: pluginServiceDefaultState,
|
||||
shareService: shareServiceDefaultState,
|
||||
@@ -1138,6 +1141,10 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
|
||||
draft.needApiAuth = action.value;
|
||||
break;
|
||||
|
||||
case 'PROFILE_CONFIG_SET':
|
||||
draft.profileConfig = action.value;
|
||||
break;
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import Logger from '../Logger';
|
||||
import Setting from '../models/Setting';
|
||||
import shim from '../shim';
|
||||
import { fileExtension, basename, toSystemSlashes } from '../path-utils';
|
||||
import { basename, toSystemSlashes } from '../path-utils';
|
||||
import time from '../time';
|
||||
import { NoteEntity } from './database/types';
|
||||
|
||||
import Note from '../models/Note';
|
||||
import { openFileWithExternalEditor } from './ExternalEditWatcher/utils';
|
||||
const EventEmitter = require('events');
|
||||
const { splitCommandString } = require('../string-utils');
|
||||
const spawn = require('child_process').spawn;
|
||||
const chokidar = require('chokidar');
|
||||
const { ErrorNotFound } = require('./rest/utils/errors');
|
||||
|
||||
@@ -213,68 +211,6 @@ export default class ExternalEditWatcher {
|
||||
return false;
|
||||
}
|
||||
|
||||
textEditorCommand() {
|
||||
const editorCommand = Setting.value('editor');
|
||||
if (!editorCommand) return null;
|
||||
|
||||
const s = splitCommandString(editorCommand, { handleEscape: false });
|
||||
const path = s.splice(0, 1);
|
||||
if (!path.length) throw new Error(`Invalid editor command: ${editorCommand}`);
|
||||
|
||||
return {
|
||||
path: path[0],
|
||||
args: s,
|
||||
};
|
||||
}
|
||||
|
||||
async spawnCommand(path: string, args: string[], options: any) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// App bundles need to be opened using the `open` command.
|
||||
// Additional args can be specified after --args, and the
|
||||
// -n flag is needed to ensure that the app is always launched
|
||||
// with the arguments. Without it, if the app is already opened,
|
||||
// it will just bring it to the foreground without opening the file.
|
||||
// So the full command is:
|
||||
//
|
||||
// open -n /path/to/editor.app --args -app-flag -bla /path/to/file.md
|
||||
//
|
||||
if (shim.isMac() && fileExtension(path) === 'app') {
|
||||
args = args.slice();
|
||||
args.splice(0, 0, '--args');
|
||||
args.splice(0, 0, path);
|
||||
args.splice(0, 0, '-n');
|
||||
path = 'open';
|
||||
}
|
||||
|
||||
const wrapError = (error: any) => {
|
||||
if (!error) return error;
|
||||
const msg = error.message ? [error.message] : [];
|
||||
msg.push(`Command was: "${path}" ${args.join(' ')}`);
|
||||
error.message = msg.join('\n\n');
|
||||
return error;
|
||||
};
|
||||
|
||||
try {
|
||||
const subProcess = spawn(path, args, options);
|
||||
|
||||
const iid = shim.setInterval(() => {
|
||||
if (subProcess && subProcess.pid) {
|
||||
this.logger().debug(`Started editor with PID ${subProcess.pid}`);
|
||||
shim.clearInterval(iid);
|
||||
resolve(null);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
subProcess.on('error', (error: any) => {
|
||||
shim.clearInterval(iid);
|
||||
reject(wrapError(error));
|
||||
});
|
||||
} catch (error) {
|
||||
throw wrapError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async openAndWatch(note: NoteEntity) {
|
||||
if (!note || !note.id) {
|
||||
this.logger().warn('ExternalEditWatcher: Cannot open note: ', note);
|
||||
@@ -285,13 +221,7 @@ export default class ExternalEditWatcher {
|
||||
if (!filePath) return;
|
||||
this.watch(filePath);
|
||||
|
||||
const cmd = this.textEditorCommand();
|
||||
if (!cmd) {
|
||||
this.bridge_().openExternal(`file://${filePath}`);
|
||||
} else {
|
||||
cmd.args.push(filePath);
|
||||
await this.spawnCommand(cmd.path, cmd.args, { detached: true });
|
||||
}
|
||||
await openFileWithExternalEditor(filePath, this.bridge_());
|
||||
|
||||
this.dispatch({
|
||||
type: 'NOTE_FILE_WATCHER_ADD',
|
||||
|
||||
82
packages/lib/services/ExternalEditWatcher/utils.ts
Normal file
82
packages/lib/services/ExternalEditWatcher/utils.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
const { splitCommandString } = require('../../string-utils');
|
||||
import { spawn } from 'child_process';
|
||||
import Logger from '../../Logger';
|
||||
import Setting from '../../models/Setting';
|
||||
import { fileExtension } from '../../path-utils';
|
||||
import shim from '../../shim';
|
||||
|
||||
const logger = Logger.create('ExternalEditWatcher/utils');
|
||||
|
||||
const spawnCommand = async (path: string, args: string[], options: any) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// App bundles need to be opened using the `open` command.
|
||||
// Additional args can be specified after --args, and the
|
||||
// -n flag is needed to ensure that the app is always launched
|
||||
// with the arguments. Without it, if the app is already opened,
|
||||
// it will just bring it to the foreground without opening the file.
|
||||
// So the full command is:
|
||||
//
|
||||
// open -n /path/to/editor.app --args -app-flag -bla /path/to/file.md
|
||||
//
|
||||
if (shim.isMac() && fileExtension(path) === 'app') {
|
||||
args = args.slice();
|
||||
args.splice(0, 0, '--args');
|
||||
args.splice(0, 0, path);
|
||||
args.splice(0, 0, '-n');
|
||||
path = 'open';
|
||||
}
|
||||
|
||||
const wrapError = (error: any) => {
|
||||
if (!error) return error;
|
||||
const msg = error.message ? [error.message] : [];
|
||||
msg.push(`Command was: "${path}" ${args.join(' ')}`);
|
||||
error.message = msg.join('\n\n');
|
||||
return error;
|
||||
};
|
||||
|
||||
try {
|
||||
const subProcess = spawn(path, args, options);
|
||||
|
||||
const iid = shim.setInterval(() => {
|
||||
if (subProcess && subProcess.pid) {
|
||||
logger.debug(`Started editor with PID ${subProcess.pid}`);
|
||||
shim.clearInterval(iid);
|
||||
resolve(null);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
subProcess.on('error', (error: any) => {
|
||||
shim.clearInterval(iid);
|
||||
reject(wrapError(error));
|
||||
});
|
||||
} catch (error) {
|
||||
throw wrapError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const textEditorCommand = () => {
|
||||
const editorCommand = Setting.value('editor');
|
||||
if (!editorCommand) return null;
|
||||
|
||||
const s = splitCommandString(editorCommand, { handleEscape: false });
|
||||
const path = s.splice(0, 1);
|
||||
if (!path.length) throw new Error(`Invalid editor command: ${editorCommand}`);
|
||||
|
||||
return {
|
||||
path: path[0],
|
||||
args: s,
|
||||
};
|
||||
};
|
||||
|
||||
export const openFileWithExternalEditor = async (filePath: string, bridge: any) => {
|
||||
const cmd = textEditorCommand();
|
||||
if (!cmd) {
|
||||
bridge.openExternal(`file://${filePath}`);
|
||||
} else {
|
||||
cmd.args.push(filePath);
|
||||
await spawnCommand(cmd.path, cmd.args, { detached: true });
|
||||
}
|
||||
};
|
||||
@@ -55,6 +55,9 @@ const defaultKeymapItems = {
|
||||
{ accelerator: 'Option+Cmd+A', command: 'editor.sortSelectedLines' },
|
||||
{ accelerator: 'Option+Up', command: 'editor.swapLineUp' },
|
||||
{ accelerator: 'Option+Down', command: 'editor.swapLineDown' },
|
||||
{ accelerator: 'Option+Cmd+1', command: 'switchProfile1' },
|
||||
{ accelerator: 'Option+Cmd+2', command: 'switchProfile2' },
|
||||
{ accelerator: 'Option+Cmd+3', command: 'switchProfile3' },
|
||||
],
|
||||
default: [
|
||||
{ accelerator: 'Ctrl+N', command: 'newNote' },
|
||||
@@ -97,6 +100,9 @@ const defaultKeymapItems = {
|
||||
{ accelerator: 'Ctrl+Alt+S', command: 'editor.sortSelectedLines' },
|
||||
{ accelerator: 'Alt+Up', command: 'editor.swapLineUp' },
|
||||
{ accelerator: 'Alt+Down', command: 'editor.swapLineDown' },
|
||||
{ accelerator: 'Ctrl+Alt+1', command: 'switchProfile1' },
|
||||
{ accelerator: 'Ctrl+Alt+2', command: 'switchProfile2' },
|
||||
{ accelerator: 'Ctrl+Alt+3', command: 'switchProfile3' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -6,13 +6,15 @@ import propsHaveChanged from './propsHaveChanged';
|
||||
const { createSelectorCreator, defaultMemoize } = require('reselect');
|
||||
const { createCachedSelector } = require('re-reselect');
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
click: Function;
|
||||
export interface MenuItem {
|
||||
id?: string;
|
||||
label?: string;
|
||||
click?: Function;
|
||||
role?: any;
|
||||
type?: string;
|
||||
accelerator?: string;
|
||||
enabled: boolean;
|
||||
checked?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface MenuItems {
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface WhenClauseContext {
|
||||
folderIsShared: boolean;
|
||||
folderIsShareRoot: boolean;
|
||||
joplinServerConnected: boolean;
|
||||
hasMultiProfiles: boolean;
|
||||
}
|
||||
|
||||
export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext {
|
||||
@@ -82,5 +83,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
|
||||
folderIsShared: commandFolder ? !!commandFolder.share_id : false,
|
||||
|
||||
joplinServerConnected: [9, 10].includes(state.settings['sync.target']),
|
||||
|
||||
hasMultiProfiles: state.profileConfig && state.profileConfig.profiles.length > 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import InteropService_Importer_Md from '../../services/interop/InteropService_Importer_Md';
|
||||
import Note from '../../models/Note';
|
||||
import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../testing/test-utils';
|
||||
import Folder from '../../models/Folder';
|
||||
import * as fs from 'fs-extra';
|
||||
import { createTempDir, setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../testing/test-utils';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import { FolderEntity } from '../database/types';
|
||||
|
||||
|
||||
describe('InteropService_Importer_Md: importLocalImages', function() {
|
||||
describe('InteropService_Importer_Md', function() {
|
||||
let tempDir: string;
|
||||
async function importNote(path: string) {
|
||||
const importer = new InteropService_Importer_Md();
|
||||
importer.setMetadata({ fileExtensions: ['md', 'html'] });
|
||||
return await importer.importFile(path, 'notebook');
|
||||
}
|
||||
|
||||
async function importNoteDirectory(path: string) {
|
||||
const importer = new InteropService_Importer_Md();
|
||||
importer.setMetadata({ fileExtensions: ['md', 'html'] });
|
||||
return await importer.importDirectory(path, 'notebook');
|
||||
}
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
tempDir = await createTempDir();
|
||||
done();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.remove(tempDir);
|
||||
});
|
||||
it('should import linked files and modify tags appropriately', async function() {
|
||||
const note = await importNote(`${supportDir}/test_notes/md/sample.md`);
|
||||
|
||||
@@ -117,4 +129,30 @@ describe('InteropService_Importer_Md: importLocalImages', function() {
|
||||
const preservedAlt = note.body.includes('alt="../../photo.jpg"');
|
||||
expect(preservedAlt).toBe(true);
|
||||
});
|
||||
it('should import non-empty directory', async function() {
|
||||
await fs.mkdirp(`${tempDir}/non-empty/non-empty`);
|
||||
await fs.writeFile(`${tempDir}/non-empty/non-empty/sample.md`, '# Sample');
|
||||
|
||||
await importNoteDirectory(`${tempDir}/non-empty`);
|
||||
const allFolders = await Folder.all();
|
||||
expect(allFolders.map((f: FolderEntity) => f.title).indexOf('non-empty')).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
it('should not import empty directory', async function() {
|
||||
await fs.mkdirp(`${tempDir}/empty/empty`);
|
||||
|
||||
await importNoteDirectory(`${tempDir}/empty`);
|
||||
const allFolders = await Folder.all();
|
||||
expect(allFolders.map((f: FolderEntity) => f.title).indexOf('empty')).toBe(-1);
|
||||
});
|
||||
it('should import directory with non-empty subdirectory', async function() {
|
||||
await fs.mkdirp(`${tempDir}/non-empty-subdir/non-empty-subdir/subdir-empty`);
|
||||
await fs.mkdirp(`${tempDir}/non-empty-subdir/non-empty-subdir/subdir-non-empty`);
|
||||
await fs.writeFile(`${tempDir}/non-empty-subdir/non-empty-subdir/subdir-non-empty/sample.md`, '# Sample');
|
||||
|
||||
await importNoteDirectory(`${tempDir}/non-empty-subdir`);
|
||||
const allFolders = await Folder.all();
|
||||
expect(allFolders.map((f: FolderEntity) => f.title).indexOf('non-empty-subdir')).toBeGreaterThanOrEqual(0);
|
||||
expect(allFolders.map((f: FolderEntity) => f.title).indexOf('subdir-empty')).toBe(-1);
|
||||
expect(allFolders.map((f: FolderEntity) => f.title).indexOf('subdir-non-empty')).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,13 +47,16 @@ export default class InteropService_Importer_Md extends InteropService_Importer_
|
||||
|
||||
async importDirectory(dirPath: string, parentFolderId: string) {
|
||||
console.info(`Import: ${dirPath}`);
|
||||
|
||||
const supportedFileExtension = this.metadata().fileExtensions;
|
||||
const stats = await shim.fsDriver().readDirStats(dirPath);
|
||||
for (let i = 0; i < stats.length; i++) {
|
||||
const stat = stats[i];
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (await this.isDirectoryEmpty(`${dirPath}/${stat.path}`)) {
|
||||
console.info(`Ignoring empty directory: ${stat.path}`);
|
||||
continue;
|
||||
}
|
||||
const folderTitle = await Folder.findUniqueItemTitle(basename(stat.path));
|
||||
const folder = await Folder.save({ title: folderTitle, parent_id: parentFolderId });
|
||||
await this.importDirectory(`${dirPath}/${basename(stat.path)}`, folder.id);
|
||||
@@ -63,6 +66,24 @@ export default class InteropService_Importer_Md extends InteropService_Importer_
|
||||
}
|
||||
}
|
||||
|
||||
private async isDirectoryEmpty(dirPath: string) {
|
||||
const supportedFileExtension = this.metadata().fileExtensions;
|
||||
const innerStats = await shim.fsDriver().readDirStats(dirPath);
|
||||
for (let i = 0; i < innerStats.length; i++) {
|
||||
const innerStat = innerStats[i];
|
||||
|
||||
if (innerStat.isDirectory()) {
|
||||
if (!(await this.isDirectoryEmpty(`${dirPath}/${innerStat.path}`))) {
|
||||
return false;
|
||||
}
|
||||
} else if (supportedFileExtension.indexOf(fileExtension(innerStat.path).toLowerCase()) >= 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
private trimAnchorLink(link: string) {
|
||||
if (link.indexOf('#') <= 0) return link;
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
//
|
||||
// If the userContentBaseUrl is an empty string, the baseUrl is returned instead.
|
||||
export default function(userId: string, baseUrl: string, userContentBaseUrl: string) {
|
||||
// Special case for development, because it's difficult to get wildcard domains working locally.
|
||||
if (userContentBaseUrl === 'http://joplincloud.local:22300') return 'http://joplincloud.local:22300';
|
||||
|
||||
if (userContentBaseUrl && baseUrl !== userContentBaseUrl) {
|
||||
if (!userId) throw new Error('User ID must be specified');
|
||||
const url = new URL(userContentBaseUrl);
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function manifestFromObject(o: any): PluginManifest {
|
||||
homepage_url: getString('homepage_url', false),
|
||||
repository_url: getString('repository_url', false),
|
||||
keywords: getStrings('keywords', false),
|
||||
categories: getStrings('categories', false),
|
||||
permissions: permissions,
|
||||
|
||||
_recommended: getBoolean('_recommended', false, false),
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PluginManifest {
|
||||
homepage_url?: string;
|
||||
repository_url?: string;
|
||||
keywords?: string[];
|
||||
categories?: string[];
|
||||
permissions?: PluginPermission[];
|
||||
|
||||
// Private keys
|
||||
|
||||
85
packages/lib/services/profileConfig/index.test.ts
Normal file
85
packages/lib/services/profileConfig/index.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { writeFile } from 'fs-extra';
|
||||
import { createNewProfile, getProfileFullPath, loadProfileConfig, saveProfileConfig } from '.';
|
||||
import { tempFilePath } from '../../testing/test-utils';
|
||||
import { defaultProfile, defaultProfileConfig, ProfileConfig } from './types';
|
||||
|
||||
describe('profileConfig/index', () => {
|
||||
|
||||
it('should load a default profile config', async () => {
|
||||
const filePath = tempFilePath('json');
|
||||
const config = await loadProfileConfig(filePath);
|
||||
expect(config).toEqual(defaultProfileConfig());
|
||||
});
|
||||
|
||||
it('should load a profile config', async () => {
|
||||
const filePath = tempFilePath('json');
|
||||
const config = {
|
||||
profiles: [
|
||||
{
|
||||
name: 'Testing',
|
||||
path: '.',
|
||||
},
|
||||
],
|
||||
};
|
||||
await writeFile(filePath, JSON.stringify(config), 'utf8');
|
||||
|
||||
const loadedConfig = await loadProfileConfig(filePath);
|
||||
|
||||
const expected: ProfileConfig = {
|
||||
version: 1,
|
||||
currentProfile: 0,
|
||||
profiles: [
|
||||
{
|
||||
name: 'Testing',
|
||||
path: '.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(loadedConfig).toEqual(expected);
|
||||
});
|
||||
|
||||
|
||||
it('should load a save a config', async () => {
|
||||
const filePath = tempFilePath('json');
|
||||
const config = defaultProfileConfig();
|
||||
await saveProfileConfig(filePath, config);
|
||||
|
||||
const loadedConfig = await loadProfileConfig(filePath);
|
||||
expect(config).toEqual(loadedConfig);
|
||||
});
|
||||
|
||||
it('should get a profile full path', async () => {
|
||||
const profile1 = {
|
||||
...defaultProfile(),
|
||||
path: 'profile-abcd',
|
||||
};
|
||||
|
||||
const profile2 = {
|
||||
...defaultProfile(),
|
||||
path: '.',
|
||||
};
|
||||
|
||||
const profile3 = {
|
||||
...defaultProfile(),
|
||||
path: 'profiles/pro/',
|
||||
};
|
||||
|
||||
expect(getProfileFullPath(profile1, '/test/root')).toBe('/test/root/profile-abcd');
|
||||
expect(getProfileFullPath(profile2, '/test/root')).toBe('/test/root');
|
||||
expect(getProfileFullPath(profile3, '/test/root')).toBe('/test/root/profiles/pro');
|
||||
});
|
||||
|
||||
it('should create a new profile', async () => {
|
||||
let config = defaultProfileConfig();
|
||||
config = createNewProfile(config, 'new profile 1');
|
||||
config = createNewProfile(config, 'new profile 2');
|
||||
|
||||
expect(config.profiles.length).toBe(3);
|
||||
expect(config.profiles[1].name).toBe('new profile 1');
|
||||
expect(config.profiles[2].name).toBe('new profile 2');
|
||||
|
||||
expect(config.profiles[1].path).not.toBe(config.profiles[2].path);
|
||||
});
|
||||
|
||||
});
|
||||
64
packages/lib/services/profileConfig/index.ts
Normal file
64
packages/lib/services/profileConfig/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { rtrimSlashes, trimSlashes } from '../../path-utils';
|
||||
import shim from '../../shim';
|
||||
import { defaultProfile, defaultProfileConfig, Profile, ProfileConfig } from './types';
|
||||
import { customAlphabet } from 'nanoid/non-secure';
|
||||
|
||||
export const loadProfileConfig = async (profileConfigPath: string): Promise<ProfileConfig> => {
|
||||
if (!(await shim.fsDriver().exists(profileConfigPath))) {
|
||||
return defaultProfileConfig();
|
||||
}
|
||||
|
||||
try {
|
||||
const configContent = await shim.fsDriver().readFile(profileConfigPath, 'utf8');
|
||||
const parsed = JSON.parse(configContent) as ProfileConfig;
|
||||
if (!parsed.profiles || !parsed.profiles.length) throw new Error(`Profile config should contain at least one profile: ${profileConfigPath}`);
|
||||
|
||||
const output: ProfileConfig = {
|
||||
...defaultProfileConfig(),
|
||||
...parsed,
|
||||
};
|
||||
|
||||
for (let i = 0; i < output.profiles.length; i++) {
|
||||
output.profiles[i] = {
|
||||
...defaultProfile(),
|
||||
...output.profiles[i],
|
||||
};
|
||||
}
|
||||
|
||||
if (output.currentProfile < 0 || output.currentProfile >= output.profiles.length) throw new Error(`Profile index out of range: ${output.currentProfile}`);
|
||||
return output;
|
||||
} catch (error) {
|
||||
error.message = `Could not parse profile configuration: ${profileConfigPath}: ${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveProfileConfig = async (profileConfigPath: string, config: ProfileConfig) => {
|
||||
await shim.fsDriver().writeFile(profileConfigPath, JSON.stringify(config, null, '\t'), 'utf8');
|
||||
};
|
||||
|
||||
export const getCurrentProfile = (config: ProfileConfig): Profile => {
|
||||
return { ...config.profiles[config.currentProfile] };
|
||||
};
|
||||
|
||||
export const getProfileFullPath = (profile: Profile, rootProfilePath: string): string => {
|
||||
let p = trimSlashes(profile.path);
|
||||
if (p === '.') p = '';
|
||||
return rtrimSlashes(`${rtrimSlashes(rootProfilePath)}/${p}`);
|
||||
};
|
||||
|
||||
const profileIdGenerator = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 8);
|
||||
|
||||
export const createNewProfile = (config: ProfileConfig, profileName: string) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
profiles: config.profiles.slice(),
|
||||
};
|
||||
|
||||
newConfig.profiles.push({
|
||||
name: profileName,
|
||||
path: `profile-${profileIdGenerator()}`,
|
||||
});
|
||||
|
||||
return newConfig;
|
||||
};
|
||||
16
packages/lib/services/profileConfig/initProfile.ts
Normal file
16
packages/lib/services/profileConfig/initProfile.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getCurrentProfile, getProfileFullPath, loadProfileConfig } from '.';
|
||||
import Setting from '../../models/Setting';
|
||||
|
||||
export default async (rootProfileDir: string) => {
|
||||
const profileConfig = await loadProfileConfig(`${rootProfileDir}/profiles.json`);
|
||||
const profileDir = getProfileFullPath(getCurrentProfile(profileConfig), rootProfileDir);
|
||||
const isSubProfile = profileConfig.currentProfile !== 0;
|
||||
Setting.setConstant('isSubProfile', isSubProfile);
|
||||
Setting.setConstant('rootProfileDir', rootProfileDir);
|
||||
Setting.setConstant('profileDir', profileDir);
|
||||
return {
|
||||
profileConfig,
|
||||
profileDir,
|
||||
isSubProfile,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import Setting from '../../models/Setting';
|
||||
|
||||
export default (rootSettings: Record<string, any>, subProfileSettings: Record<string, any>) => {
|
||||
const output: Record<string, any> = { ...subProfileSettings };
|
||||
|
||||
for (const k of Object.keys(output)) {
|
||||
const md = Setting.settingMetadata(k);
|
||||
if (md.isGlobal) {
|
||||
delete output[k];
|
||||
if (k in rootSettings) output[k] = rootSettings[k];
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of Object.keys(rootSettings)) {
|
||||
const md = Setting.settingMetadata(k);
|
||||
if (md.isGlobal) {
|
||||
output[k] = rootSettings[k];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import { SettingValues } from '../../models/settings/FileHandler';
|
||||
|
||||
export default (settings: SettingValues) => {
|
||||
const globalSettings: SettingValues = {};
|
||||
const localSettings: SettingValues = {};
|
||||
|
||||
for (const [k, v] of Object.entries(settings)) {
|
||||
const md = Setting.settingMetadata(k);
|
||||
|
||||
if (md.isGlobal) {
|
||||
globalSettings[k] = v;
|
||||
} else {
|
||||
localSettings[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return { globalSettings, localSettings };
|
||||
};
|
||||
27
packages/lib/services/profileConfig/types.ts
Normal file
27
packages/lib/services/profileConfig/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface Profile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ProfileConfig {
|
||||
version: number;
|
||||
currentProfile: number;
|
||||
profiles: Profile[];
|
||||
}
|
||||
|
||||
export const defaultProfile = (): Profile => {
|
||||
return {
|
||||
name: 'Default',
|
||||
path: '.',
|
||||
};
|
||||
};
|
||||
|
||||
export const defaultProfileConfig = (): ProfileConfig => {
|
||||
return {
|
||||
version: 1,
|
||||
currentProfile: 0,
|
||||
profiles: [defaultProfile()],
|
||||
};
|
||||
};
|
||||
|
||||
export type ProfileSwitchClickHandler = (profileIndex: number)=> void;
|
||||
@@ -9,6 +9,7 @@ import Tag from '../../models/Tag';
|
||||
import NoteTag from '../../models/NoteTag';
|
||||
import ResourceService from '../../services/ResourceService';
|
||||
import SearchEngine from '../../services/searchengine/SearchEngine';
|
||||
import { ResourceEntity } from '../database/types';
|
||||
|
||||
const createFolderForPagination = async (num: number, time: number) => {
|
||||
await Folder.save({
|
||||
@@ -325,7 +326,7 @@ describe('services_rest_Api', function() {
|
||||
expect(response.body.indexOf(resource.id) >= 0).toBe(true);
|
||||
}));
|
||||
|
||||
it('should not compress images uploaded through resource api', (async () => {
|
||||
it('should not compress images uploaded through resource API', (async () => {
|
||||
const originalImagePath = `${supportDir}/photo-large.png`;
|
||||
await api.route(RequestMethod.POST, 'resources', null, JSON.stringify({
|
||||
title: 'testing resource',
|
||||
@@ -345,6 +346,58 @@ describe('services_rest_Api', function() {
|
||||
expect(originalImageSize).toEqual(uploadedImageSize);
|
||||
}));
|
||||
|
||||
it('should update a resource', (async () => {
|
||||
await api.route(RequestMethod.POST, 'resources', null, JSON.stringify({
|
||||
title: 'resource',
|
||||
}), [
|
||||
{
|
||||
path: `${supportDir}/photo.jpg`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resourceV1: ResourceEntity = (await Resource.all())[0];
|
||||
|
||||
await msleep(1);
|
||||
|
||||
await api.route(RequestMethod.PUT, `resources/${resourceV1.id}`, null, JSON.stringify({
|
||||
title: 'resource mod',
|
||||
}), [
|
||||
{
|
||||
path: `${supportDir}/photo-large.png`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resourceV2: ResourceEntity = (await Resource.all())[0];
|
||||
|
||||
expect(resourceV2.title).toBe('resource mod');
|
||||
expect(resourceV2.mime).toBe('image/png');
|
||||
expect(resourceV2.file_extension).toBe('png');
|
||||
expect(resourceV2.updated_time).toBeGreaterThan(resourceV1.updated_time);
|
||||
expect(resourceV2.created_time).toBe(resourceV1.created_time);
|
||||
expect(resourceV2.size).toBeGreaterThan(resourceV1.size);
|
||||
|
||||
expect(resourceV2.size).toBe((await shim.fsDriver().stat(Resource.fullPath(resourceV2))).size);
|
||||
}));
|
||||
|
||||
it('should update resource properties', (async () => {
|
||||
await api.route(RequestMethod.POST, 'resources', null, JSON.stringify({
|
||||
title: 'resource',
|
||||
}), [{ path: `${supportDir}/photo.jpg` }]);
|
||||
|
||||
const resourceV1: ResourceEntity = (await Resource.all())[0];
|
||||
|
||||
await msleep(1);
|
||||
|
||||
await api.route(RequestMethod.PUT, `resources/${resourceV1.id}`, null, JSON.stringify({
|
||||
title: 'my new title',
|
||||
}));
|
||||
|
||||
const resourceV2: ResourceEntity = (await Resource.all())[0];
|
||||
|
||||
expect(resourceV2.title).toBe('my new title');
|
||||
expect(resourceV2.mime).toBe(resourceV1.mime);
|
||||
}));
|
||||
|
||||
it('should delete resources', (async () => {
|
||||
const f = await Folder.save({ title: 'mon carnet' });
|
||||
|
||||
|
||||
@@ -47,13 +47,27 @@ export default async function(request: Request, id: string = null, link: string
|
||||
if (link) throw new ErrorNotFound();
|
||||
}
|
||||
|
||||
if (request.method === RequestMethod.POST) {
|
||||
if (!request.files.length) throw new ErrorBadRequest('Resource cannot be created without a file');
|
||||
if (request.method === RequestMethod.POST || request.method === RequestMethod.PUT) {
|
||||
const isUpdate = request.method === RequestMethod.PUT;
|
||||
|
||||
if (!request.files.length) {
|
||||
if (request.method === RequestMethod.PUT) {
|
||||
// In that case, we don't try to update the resource blob, we
|
||||
// just update the properties.
|
||||
return defaultAction(BaseModel.TYPE_RESOURCE, request, id, link);
|
||||
} else {
|
||||
// If it's a POST request, the file content is required.
|
||||
throw new ErrorBadRequest('Resource cannot be created without a file');
|
||||
}
|
||||
}
|
||||
|
||||
if (isUpdate && !id) throw new ErrorBadRequest('Missing resource ID');
|
||||
const filePath = request.files[0].path;
|
||||
const defaultProps = request.bodyJson(readonlyProperties('POST'));
|
||||
const defaultProps = request.bodyJson(readonlyProperties(request.method));
|
||||
return shim.createResourceFromPath(filePath, defaultProps, {
|
||||
userSideValidation: true,
|
||||
resizeLargeImages: 'never',
|
||||
destinationResourceId: isUpdate ? id : '',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars, prefer-const */
|
||||
/* @typescript-eslint/prefer-const */
|
||||
|
||||
const time = require('../../time').default;
|
||||
const { setupDatabaseAndSynchronizer, supportDir, db, createNTestNotes, switchClient } = require('../../testing/test-utils.js');
|
||||
const SearchEngine = require('../../services/searchengine/SearchEngine').default;
|
||||
const Note = require('../../models/Note').default;
|
||||
const Folder = require('../../models/Folder').default;
|
||||
const Tag = require('../../models/Tag').default;
|
||||
const shim = require('../../shim').default;
|
||||
const ResourceService = require('../../services/ResourceService').default;
|
||||
import time from '@joplin/lib/time';
|
||||
import { setupDatabaseAndSynchronizer, supportDir, db, createNTestNotes, switchClient } from '@joplin/lib/testing//test-utils';
|
||||
import SearchEngine from '@joplin/lib/services/searchengine/SearchEngine';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import ResourceService from '@joplin/lib/services/ResourceService';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
|
||||
let engine = null;
|
||||
let engine: any = null;
|
||||
|
||||
const ids = (array) => array.map(a => a.id);
|
||||
const ids = (array: NoteEntity[]) => array.map(a => a.id);
|
||||
|
||||
describe('services_SearchFilter', function() {
|
||||
beforeEach(async (done) => {
|
||||
@@ -67,25 +68,59 @@ describe('services_SearchFilter', function() {
|
||||
for (const searchType of [SearchEngine.SEARCH_TYPE_FTS, SearchEngine.SEARCH_TYPE_NONLATIN_SCRIPT]) {
|
||||
|
||||
describe(`search type ${searchType}`, () => {
|
||||
it('should return note matching title', (async () => {
|
||||
it('Check case insensitivity for filter keywords', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'abcd', body: 'body 1' });
|
||||
const n2 = await Note.save({ title: 'efgh', body: 'body 2' });
|
||||
const notebook1 = await Folder.save({ title: 'folderA' });
|
||||
const notebook2 = await Folder.save({ title: 'folderB' });
|
||||
const note1 = await Note.save({ title: 'Note1', body: 'obelix', parent_id: notebook1.id });
|
||||
const note2 = await Note.save({ title: 'Note2', body: 'asterix', parent_id: notebook2.id });
|
||||
const note3 = await Note.save({ title: 'Note3', body: 'rom', parent_id: notebook1.id });
|
||||
|
||||
await Tag.setNoteTagsByTitles(note1.id, ['tag1', 'tag2']);
|
||||
await Tag.setNoteTagsByTitles(note2.id, ['tag2', 'tag3']);
|
||||
await Tag.setNoteTagsByTitles(note3.id, ['tag3', 'tag4', 'space travel']);
|
||||
|
||||
await engine.syncTables();
|
||||
rows = await engine.search('title: abcd', { searchType });
|
||||
|
||||
const testCases = [
|
||||
{ searchString: 'tag:tag2', expectedResults: 2, expectedtNoteIds: [note1.id, note2.id] },
|
||||
{ searchString: 'tAg:tag2', expectedResults: 2, expectedtNoteIds: [note1.id, note2.id] },
|
||||
{ searchString: 'Tag:tag2', expectedResults: 2, expectedtNoteIds: [note1.id, note2.id] },
|
||||
{ searchString: '-tag:tag2', expectedResults: 1, expectedtNoteIds: [note3.id] },
|
||||
{ searchString: '-Tag:tag2', expectedResults: 1, expectedtNoteIds: [note3.id] },
|
||||
{ searchString: 'title:Note1', expectedResults: 1, expectedtNoteIds: [note1.id] },
|
||||
{ searchString: 'Title:Note1', expectedResults: 1, expectedtNoteIds: [note1.id] },
|
||||
{ searchString: 'Any:1 -tag:tag1 -notebook:folderB', expectedResults: 1, expectedtNoteIds: [note3.id] },
|
||||
{ searchString: 'notebook:folderA', expectedResults: 2, expectedtNoteIds: [note1.id, note3.id] },
|
||||
{ searchString: 'notebooK:folderA', expectedResults: 2, expectedtNoteIds: [note1.id, note3.id] },
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
rows = await engine.search(testCase.searchString, { searchType });
|
||||
expect(rows.length).toBe(testCase.expectedResults);
|
||||
for (const expectedNoteId of testCase.expectedtNoteIds) {
|
||||
expect(ids(rows)).toContain(expectedNoteId);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
it('should return note matching title', (async () => {
|
||||
const n1 = await Note.save({ title: 'abcd', body: 'body 1' });
|
||||
await Note.save({ title: 'efgh', body: 'body 2' });
|
||||
|
||||
await engine.syncTables();
|
||||
const rows = await engine.search('title: abcd', { searchType });
|
||||
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].id).toBe(n1.id);
|
||||
}));
|
||||
|
||||
it('should return note matching negated title', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'abcd', body: 'body 1' });
|
||||
await Note.save({ title: 'abcd', body: 'body 1' });
|
||||
const n2 = await Note.save({ title: 'efgh', body: 'body 2' });
|
||||
|
||||
await engine.syncTables();
|
||||
rows = await engine.search('-title: abcd', { searchType });
|
||||
const rows = await engine.search('-title: abcd', { searchType });
|
||||
|
||||
expect(rows.length).toBe(1);
|
||||
|
||||
@@ -93,12 +128,11 @@ describe('services_SearchFilter', function() {
|
||||
}));
|
||||
|
||||
it('should return note matching body', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'abcd', body: 'body1' });
|
||||
const n2 = await Note.save({ title: 'efgh', body: 'body2' });
|
||||
await Note.save({ title: 'efgh', body: 'body2' });
|
||||
|
||||
await engine.syncTables();
|
||||
rows = await engine.search('body: body1', { searchType });
|
||||
const rows = await engine.search('body: body1', { searchType });
|
||||
|
||||
expect(rows.length).toBe(1);
|
||||
|
||||
@@ -106,36 +140,33 @@ describe('services_SearchFilter', function() {
|
||||
}));
|
||||
|
||||
it('should return note matching negated body', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'abcd', body: 'body1' });
|
||||
await Note.save({ title: 'abcd', body: 'body1' });
|
||||
const n2 = await Note.save({ title: 'efgh', body: 'body2' });
|
||||
|
||||
await engine.syncTables();
|
||||
rows = await engine.search('-body: body1', { searchType });
|
||||
const rows = await engine.search('-body: body1', { searchType });
|
||||
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].id).toBe(n2.id);
|
||||
}));
|
||||
|
||||
it('should return note matching title containing multiple words', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'abcd xyz', body: 'body1' });
|
||||
const n2 = await Note.save({ title: 'efgh ijk', body: 'body2' });
|
||||
await Note.save({ title: 'efgh ijk', body: 'body2' });
|
||||
|
||||
await engine.syncTables();
|
||||
rows = await engine.search('title: "abcd xyz"', { searchType });
|
||||
const rows = await engine.search('title: "abcd xyz"', { searchType });
|
||||
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].id).toBe(n1.id);
|
||||
}));
|
||||
|
||||
it('should return note matching body containing multiple words', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'abcd', body: 'ho ho ho' });
|
||||
await Note.save({ title: 'abcd', body: 'ho ho ho' });
|
||||
const n2 = await Note.save({ title: 'efgh', body: 'foo bar' });
|
||||
|
||||
await engine.syncTables();
|
||||
rows = await engine.search('body: "foo bar"', { searchType });
|
||||
const rows = await engine.search('body: "foo bar"', { searchType });
|
||||
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].id).toBe(n2.id);
|
||||
@@ -143,7 +174,7 @@ describe('services_SearchFilter', function() {
|
||||
|
||||
it('should return note matching title AND body', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'abcd', body: 'ho ho ho' });
|
||||
await Note.save({ title: 'abcd', body: 'ho ho ho' });
|
||||
const n2 = await Note.save({ title: 'efgh', body: 'foo bar' });
|
||||
|
||||
await engine.syncTables();
|
||||
@@ -163,8 +194,8 @@ describe('services_SearchFilter', function() {
|
||||
await engine.syncTables();
|
||||
rows = await engine.search('any:1 title: abcd body: "foo bar"', { searchType });
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows.map(r=>r.id)).toContain(n1.id);
|
||||
expect(rows.map(r=>r.id)).toContain(n2.id);
|
||||
expect(ids(rows)).toContain(n1.id);
|
||||
expect(ids(rows)).toContain(n2.id);
|
||||
|
||||
rows = await engine.search('any:1 title: wxyz body: "blah blah"', { searchType });
|
||||
expect(rows.length).toBe(0);
|
||||
@@ -181,12 +212,12 @@ describe('services_SearchFilter', function() {
|
||||
// Note: This is NOT saying to match notes containing foo bar in title/body
|
||||
rows = await engine.search('foo bar', { searchType });
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows.map(r=>r.id)).toContain(n1.id);
|
||||
expect(rows.map(r=>r.id)).toContain(n2.id);
|
||||
expect(ids(rows)).toContain(n1.id);
|
||||
expect(ids(rows)).toContain(n2.id);
|
||||
|
||||
rows = await engine.search('foo -bar', { searchType });
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows.map(r=>r.id)).toContain(n3.id);
|
||||
expect(ids(rows)).toContain(n3.id);
|
||||
|
||||
rows = await engine.search('foo efgh', { searchType });
|
||||
expect(rows.length).toBe(1);
|
||||
@@ -197,65 +228,60 @@ describe('services_SearchFilter', function() {
|
||||
}));
|
||||
|
||||
it('should return notes matching any negated text', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'abc', body: 'def' });
|
||||
const n2 = await Note.save({ title: 'def', body: 'ghi' });
|
||||
const n3 = await Note.save({ title: 'ghi', body: 'jkl' });
|
||||
await engine.syncTables();
|
||||
|
||||
rows = await engine.search('any:1 -abc -ghi', { searchType });
|
||||
const rows = await engine.search('any:1 -abc -ghi', { searchType });
|
||||
expect(rows.length).toBe(3);
|
||||
expect(rows.map(r=>r.id)).toContain(n1.id);
|
||||
expect(rows.map(r=>r.id)).toContain(n2.id);
|
||||
expect(rows.map(r=>r.id)).toContain(n3.id);
|
||||
expect(ids(rows)).toContain(n1.id);
|
||||
expect(ids(rows)).toContain(n2.id);
|
||||
expect(ids(rows)).toContain(n3.id);
|
||||
}));
|
||||
|
||||
it('should return notes matching any negated title', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'abc', body: 'def' });
|
||||
const n2 = await Note.save({ title: 'def', body: 'ghi' });
|
||||
const n3 = await Note.save({ title: 'ghi', body: 'jkl' });
|
||||
await engine.syncTables();
|
||||
|
||||
rows = await engine.search('any:1 -title:abc -title:ghi', { searchType });
|
||||
const rows = await engine.search('any:1 -title:abc -title:ghi', { searchType });
|
||||
expect(rows.length).toBe(3);
|
||||
expect(rows.map(r=>r.id)).toContain(n1.id);
|
||||
expect(rows.map(r=>r.id)).toContain(n2.id);
|
||||
expect(rows.map(r=>r.id)).toContain(n3.id);
|
||||
expect(ids(rows)).toContain(n1.id);
|
||||
expect(ids(rows)).toContain(n2.id);
|
||||
expect(ids(rows)).toContain(n3.id);
|
||||
}));
|
||||
|
||||
it('should return notes matching any negated body', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'abc', body: 'def' });
|
||||
const n2 = await Note.save({ title: 'def', body: 'ghi' });
|
||||
const n3 = await Note.save({ title: 'ghi', body: 'jkl' });
|
||||
await engine.syncTables();
|
||||
|
||||
rows = await engine.search('any:1 -body:xyz -body:ghi', { searchType });
|
||||
const rows = await engine.search('any:1 -body:xyz -body:ghi', { searchType });
|
||||
expect(rows.length).toBe(3);
|
||||
expect(rows.map(r=>r.id)).toContain(n1.id);
|
||||
expect(rows.map(r=>r.id)).toContain(n2.id);
|
||||
expect(rows.map(r=>r.id)).toContain(n3.id);
|
||||
expect(ids(rows)).toContain(n1.id);
|
||||
expect(ids(rows)).toContain(n2.id);
|
||||
expect(ids(rows)).toContain(n3.id);
|
||||
}));
|
||||
|
||||
it('should support phrase search', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'foo beef', body: 'bar dog' });
|
||||
const n2 = await Note.save({ title: 'bar efgh', body: 'foo dog' });
|
||||
await Note.save({ title: 'bar efgh', body: 'foo dog' });
|
||||
await engine.syncTables();
|
||||
|
||||
rows = await engine.search('"bar dog"', { searchType });
|
||||
const rows = await engine.search('"bar dog"', { searchType });
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].id).toBe(n1.id);
|
||||
}));
|
||||
|
||||
it('should support prefix search', (async () => {
|
||||
let rows;
|
||||
const n1 = await Note.save({ title: 'foo beef', body: 'bar dog' });
|
||||
const n2 = await Note.save({ title: 'bar efgh', body: 'foo dog' });
|
||||
await engine.syncTables();
|
||||
|
||||
rows = await engine.search('"bar*"', { searchType });
|
||||
const rows = await engine.search('"bar*"', { searchType });
|
||||
expect(rows.length).toBe(2);
|
||||
expect(ids(rows)).toContain(n1.id);
|
||||
expect(ids(rows)).toContain(n2.id);
|
||||
@@ -338,38 +364,35 @@ describe('services_SearchFilter', function() {
|
||||
}));
|
||||
|
||||
it('should support filtering by notebook', (async () => {
|
||||
let rows;
|
||||
const folder0 = await Folder.save({ title: 'notebook0' });
|
||||
const folder1 = await Folder.save({ title: 'notebook1' });
|
||||
const notes0 = await createNTestNotes(5, folder0);
|
||||
const notes1 = await createNTestNotes(5, folder1);
|
||||
await createNTestNotes(5, folder1);
|
||||
|
||||
await engine.syncTables();
|
||||
|
||||
rows = await engine.search('notebook:notebook0', { searchType });
|
||||
const rows = await engine.search('notebook:notebook0', { searchType });
|
||||
expect(rows.length).toBe(5);
|
||||
expect(ids(rows).sort()).toEqual(ids(notes0).sort());
|
||||
|
||||
}));
|
||||
|
||||
it('should support filtering by nested notebook', (async () => {
|
||||
let rows;
|
||||
const folder0 = await Folder.save({ title: 'notebook0' });
|
||||
const folder00 = await Folder.save({ title: 'notebook00', parent_id: folder0.id });
|
||||
const folder1 = await Folder.save({ title: 'notebook1' });
|
||||
const notes0 = await createNTestNotes(5, folder0);
|
||||
const notes00 = await createNTestNotes(5, folder00);
|
||||
const notes1 = await createNTestNotes(5, folder1);
|
||||
await createNTestNotes(5, folder1);
|
||||
|
||||
await engine.syncTables();
|
||||
|
||||
rows = await engine.search('notebook:notebook0', { searchType });
|
||||
const rows = await engine.search('notebook:notebook0', { searchType });
|
||||
expect(rows.length).toBe(10);
|
||||
expect(ids(rows).sort()).toEqual(ids(notes0.concat(notes00)).sort());
|
||||
}));
|
||||
|
||||
it('should support filtering by multiple notebooks', (async () => {
|
||||
let rows;
|
||||
const folder0 = await Folder.save({ title: 'notebook0' });
|
||||
const folder00 = await Folder.save({ title: 'notebook00', parent_id: folder0.id });
|
||||
const folder1 = await Folder.save({ title: 'notebook1' });
|
||||
@@ -377,11 +400,11 @@ describe('services_SearchFilter', function() {
|
||||
const notes0 = await createNTestNotes(5, folder0);
|
||||
const notes00 = await createNTestNotes(5, folder00);
|
||||
const notes1 = await createNTestNotes(5, folder1);
|
||||
const notes2 = await createNTestNotes(5, folder2);
|
||||
await createNTestNotes(5, folder2);
|
||||
|
||||
await engine.syncTables();
|
||||
|
||||
rows = await engine.search('notebook:notebook0 notebook:notebook1', { searchType });
|
||||
const rows = await engine.search('notebook:notebook0 notebook:notebook1', { searchType });
|
||||
expect(rows.length).toBe(15);
|
||||
expect(ids(rows).sort()).toEqual(ids(notes0).concat(ids(notes00).concat(ids(notes1))).sort());
|
||||
}));
|
||||
@@ -610,7 +633,7 @@ describe('services_SearchFilter', function() {
|
||||
let rows;
|
||||
const t1 = await Note.save({ title: 'This is a ', body: 'todo', is_todo: 1 });
|
||||
const t2 = await Note.save({ title: 'This is another', body: 'todo but completed', is_todo: 1, todo_completed: 1590085027710 });
|
||||
const t3 = await Note.save({ title: 'This is NOT a ', body: 'todo' });
|
||||
await Note.save({ title: 'This is NOT a ', body: 'todo' });
|
||||
|
||||
await engine.syncTables();
|
||||
|
||||
@@ -634,14 +657,13 @@ describe('services_SearchFilter', function() {
|
||||
}));
|
||||
|
||||
it('should support filtering by type note', (async () => {
|
||||
let rows;
|
||||
const t1 = await Note.save({ title: 'This is a ', body: 'todo', is_todo: 1 });
|
||||
const t2 = await Note.save({ title: 'This is another', body: 'todo but completed', is_todo: 1, todo_completed: 1590085027710 });
|
||||
await Note.save({ title: 'This is a ', body: 'todo', is_todo: 1 });
|
||||
await Note.save({ title: 'This is another', body: 'todo but completed', is_todo: 1, todo_completed: 1590085027710 });
|
||||
const t3 = await Note.save({ title: 'This is NOT a ', body: 'todo' });
|
||||
|
||||
await engine.syncTables();
|
||||
|
||||
rows = await engine.search('type:note', { searchType });
|
||||
const rows = await engine.search('type:note', { searchType });
|
||||
expect(rows.length).toBe(1);
|
||||
expect(ids(rows)).toContain(t3.id);
|
||||
}));
|
||||
@@ -650,7 +672,7 @@ describe('services_SearchFilter', function() {
|
||||
let rows;
|
||||
const toDo1 = await Note.save({ title: 'ToDo 1', body: 'todo', is_todo: 1, todo_due: Date.parse('2021-04-27') });
|
||||
const toDo2 = await Note.save({ title: 'ToDo 2', body: 'todo', is_todo: 1, todo_due: Date.parse('2021-03-17') });
|
||||
const note1 = await Note.save({ title: 'Note 1', body: 'Note' });
|
||||
await Note.save({ title: 'Note 1', body: 'Note' });
|
||||
|
||||
await engine.syncTables();
|
||||
|
||||
@@ -864,8 +886,8 @@ describe('services_SearchFilter', function() {
|
||||
|
||||
|
||||
const subFolder = await Folder.save({ title: 'child', parent_id: parentFolder.id });
|
||||
const n3 = await Note.save({ title: 'task3', body: 'baz', parent_id: subFolder.id });
|
||||
const n4 = await Note.save({ title: 'task4', body: 'blah', parent_id: subFolder.id });
|
||||
await Note.save({ title: 'task3', body: 'baz', parent_id: subFolder.id });
|
||||
await Note.save({ title: 'task4', body: 'blah', parent_id: subFolder.id });
|
||||
|
||||
|
||||
await engine.syncTables();
|
||||
@@ -886,20 +908,20 @@ describe('services_SearchFilter', function() {
|
||||
|
||||
rows = await engine.search(`id:${note1.id}`, { searchType });
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows.map(r=>r.id)).toContain(note1.id);
|
||||
expect(ids(rows)).toContain(note1.id);
|
||||
|
||||
rows = await engine.search(`any:1 id:${note1.id} id:${note2.id}`, { searchType });
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows.map(r=>r.id)).toContain(note1.id);
|
||||
expect(rows.map(r=>r.id)).toContain(note2.id);
|
||||
expect(ids(rows)).toContain(note1.id);
|
||||
expect(ids(rows)).toContain(note2.id);
|
||||
|
||||
rows = await engine.search(`any:0 id:${note1.id} id:${note2.id}`, { searchType });
|
||||
expect(rows.length).toBe(0);
|
||||
|
||||
rows = await engine.search(`-id:${note2.id}`, { searchType });
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows.map(r=>r.id)).toContain(note1.id);
|
||||
expect(rows.map(r=>r.id)).toContain(note3.id);
|
||||
expect(ids(rows)).toContain(note1.id);
|
||||
expect(ids(rows)).toContain(note3.id);
|
||||
}));
|
||||
|
||||
});
|
||||
@@ -54,8 +54,8 @@ const getTerms = (query: string, validFilters: Set<string>): Term[] => {
|
||||
}
|
||||
|
||||
if (c === ':' && !inQuote && !inTerm &&
|
||||
(validFilters.has(currentTerm) || currentTerm[0] === '-' && validFilters.has(currentTerm.substr(1, currentTerm.length)))) {
|
||||
currentCol = currentTerm;
|
||||
(validFilters.has(currentTerm.toLowerCase()) || currentTerm[0] === '-' && validFilters.has(currentTerm.toLowerCase().substr(1, currentTerm.length)))) {
|
||||
currentCol = currentTerm.toLowerCase();
|
||||
currentTerm = '';
|
||||
inTerm = true; // to ignore any other ':' before a space eg.'sourceurl:https://www.google.com'
|
||||
continue;
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('ShareService', function() {
|
||||
},
|
||||
});
|
||||
await msleep(1);
|
||||
await service.shareNote(note.id);
|
||||
await service.shareNote(note.id, false);
|
||||
|
||||
function checkTimestamps(previousNote: NoteEntity, newNote: NoteEntity) {
|
||||
// After sharing or unsharing, only the updated_time property should
|
||||
|
||||
@@ -228,11 +228,14 @@ export default class ShareService {
|
||||
}
|
||||
}
|
||||
|
||||
public async shareNote(noteId: string): Promise<StateShare> {
|
||||
public async shareNote(noteId: string, recursive: boolean): Promise<StateShare> {
|
||||
const note = await Note.load(noteId);
|
||||
if (!note) throw new Error(`No such note: ${noteId}`);
|
||||
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, { note_id: noteId });
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, {
|
||||
note_id: noteId,
|
||||
recursive: recursive ? 1 : 0,
|
||||
});
|
||||
|
||||
await Note.save({
|
||||
id: note.id,
|
||||
|
||||
@@ -221,10 +221,15 @@ function shimInit(options = null) {
|
||||
return true;
|
||||
};
|
||||
|
||||
// This is a bit of an ugly method that's used to both create a new resource
|
||||
// from a file, and update one. To update a resource, pass the
|
||||
// destinationResourceId option. This method is indirectly tested in
|
||||
// Api.test.ts.
|
||||
shim.createResourceFromPath = async function(filePath, defaultProps = null, options = null) {
|
||||
options = Object.assign({
|
||||
resizeLargeImages: 'always', // 'always', 'ask' or 'never'
|
||||
userSideValidation: false,
|
||||
destinationResourceId: '',
|
||||
}, options);
|
||||
|
||||
const readChunk = require('read-chunk');
|
||||
@@ -236,9 +241,10 @@ function shimInit(options = null) {
|
||||
|
||||
defaultProps = defaultProps ? defaultProps : {};
|
||||
|
||||
const resourceId = defaultProps.id ? defaultProps.id : uuid.create();
|
||||
let resourceId = defaultProps.id ? defaultProps.id : uuid.create();
|
||||
if (options.destinationResourceId) resourceId = options.destinationResourceId;
|
||||
|
||||
const resource = Resource.new();
|
||||
let resource = options.destinationResourceId ? {} : Resource.new();
|
||||
resource.id = resourceId;
|
||||
resource.mime = mimeUtils.fromFilename(filePath);
|
||||
resource.title = basename(filePath);
|
||||
@@ -281,7 +287,18 @@ function shimInit(options = null) {
|
||||
|
||||
const saveOptions = { isNew: true };
|
||||
if (options.userSideValidation) saveOptions.userSideValidation = true;
|
||||
return Resource.save(resource, saveOptions);
|
||||
|
||||
if (options.destinationResourceId) {
|
||||
saveOptions.isNew = false;
|
||||
const tempPath = `${targetPath}.tmp`;
|
||||
await shim.fsDriver().move(targetPath, tempPath);
|
||||
resource = await Resource.save(resource, saveOptions);
|
||||
await Resource.updateResourceBlobContent(resource.id, tempPath);
|
||||
await shim.fsDriver().remove(tempPath);
|
||||
return resource;
|
||||
} else {
|
||||
return Resource.save(resource, saveOptions);
|
||||
}
|
||||
};
|
||||
|
||||
shim.attachFileToNoteBody = async function(noteBody, filePath, position = null, options = null) {
|
||||
|
||||
@@ -106,6 +106,7 @@ const supportDir = `${oldTestDir}/support`;
|
||||
// various space-in-path issues.
|
||||
const dataDir = `${oldTestDir}/test data/${suiteName_}`;
|
||||
const profileDir = `${dataDir}/profile`;
|
||||
const rootProfileDir = profileDir;
|
||||
|
||||
fs.mkdirpSync(logDir);
|
||||
fs.mkdirpSync(baseTempDir);
|
||||
@@ -185,6 +186,7 @@ Setting.setConstant('tempDir', baseTempDir);
|
||||
Setting.setConstant('cacheDir', baseTempDir);
|
||||
Setting.setConstant('pluginDataDir', `${profileDir}/profile/plugin-data`);
|
||||
Setting.setConstant('profileDir', profileDir);
|
||||
Setting.setConstant('rootProfileDir', rootProfileDir);
|
||||
Setting.setConstant('env', 'dev');
|
||||
|
||||
BaseService.logger_ = logger;
|
||||
@@ -271,6 +273,8 @@ async function switchClient(id: number, options: any = null) {
|
||||
await Setting.reset();
|
||||
Setting.settingFilename = `settings-${id}.json`;
|
||||
|
||||
Setting.setConstant('profileDir', rootProfileDir);
|
||||
Setting.setConstant('rootProfileDir', rootProfileDir);
|
||||
Setting.setConstant('resourceDirName', resourceDirName(id));
|
||||
Setting.setConstant('resourceDir', resourceDir(id));
|
||||
Setting.setConstant('pluginDir', pluginDir(id));
|
||||
@@ -330,6 +334,9 @@ async function setupDatabase(id: number = null, options: any = null) {
|
||||
// running.
|
||||
await Setting.reset();
|
||||
|
||||
Setting.setConstant('profileDir', rootProfileDir);
|
||||
Setting.setConstant('rootProfileDir', rootProfileDir);
|
||||
|
||||
if (databases_[id]) {
|
||||
BaseModel.setDb(databases_[id]);
|
||||
await clearDatabase(id);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const fs = require('fs-extra');
|
||||
import { pathExistsSync, readFileSync } from 'fs-extra';
|
||||
|
||||
export async function credentialDir() {
|
||||
// All these calls used to be async but certain scripts need to load config
|
||||
// files early, so they've been converted to sync calls. Do not convert them
|
||||
// back to async.
|
||||
|
||||
export function credentialDir() {
|
||||
const username = require('os').userInfo().username;
|
||||
|
||||
const toTry = [
|
||||
@@ -11,23 +15,23 @@ export async function credentialDir() {
|
||||
];
|
||||
|
||||
for (const dirPath of toTry) {
|
||||
if (await fs.pathExists(dirPath)) return dirPath;
|
||||
if (pathExistsSync(dirPath)) return dirPath;
|
||||
}
|
||||
|
||||
throw new Error(`Could not find credential directory in any of these paths: ${JSON.stringify(toTry)}`);
|
||||
}
|
||||
|
||||
export async function credentialFile(filename: string) {
|
||||
const rootDir = await credentialDir();
|
||||
export function credentialFile(filename: string) {
|
||||
const rootDir = credentialDir();
|
||||
const output = `${rootDir}/${filename}`;
|
||||
if (!(await fs.pathExists(output))) throw new Error(`No such file: ${output}`);
|
||||
if (!(pathExistsSync(output))) throw new Error(`No such file: ${output}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function readCredentialFile(filename: string, defaultValue: string = '') {
|
||||
export function readCredentialFile(filename: string, defaultValue: string = '') {
|
||||
try {
|
||||
const filePath = await credentialFile(filename);
|
||||
const r = await fs.readFile(filePath);
|
||||
const filePath = credentialFile(filename);
|
||||
const r = readFileSync(filePath);
|
||||
// There's normally no reason to keep the last new line character and it
|
||||
// can cause problems in certain scripts, so trim it. Any other white
|
||||
// space should also not be relevant.
|
||||
@@ -36,3 +40,16 @@ export async function readCredentialFile(filename: string, defaultValue: string
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function readCredentialFileJson<T>(filename: string, defaultValue: T = null): T {
|
||||
const v = readCredentialFile(filename);
|
||||
if (!v) return defaultValue;
|
||||
|
||||
try {
|
||||
const o = JSON.parse(v);
|
||||
return o;
|
||||
} catch (error) {
|
||||
error.message = `Could not parse JSON file ${filename}: ${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import markdownUtils, { MarkdownTableHeader, MarkdownTableRow } from '../markdownUtils';
|
||||
|
||||
type FeatureId = string;
|
||||
|
||||
export enum PlanName {
|
||||
Basic = 'basic',
|
||||
Pro = 'pro',
|
||||
Teams = 'teams',
|
||||
}
|
||||
|
||||
interface PlanFeature {
|
||||
title: string;
|
||||
basic: boolean;
|
||||
pro: boolean;
|
||||
teams: boolean;
|
||||
basicInfo?: string;
|
||||
proInfo?: string;
|
||||
teamsInfo?: string;
|
||||
basicInfoShort?: string;
|
||||
proInfoShort?: string;
|
||||
teamsInfoShort?: string;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
name: string;
|
||||
@@ -7,10 +29,13 @@ export interface Plan {
|
||||
priceYearly: StripePublicConfigPrice;
|
||||
featured: boolean;
|
||||
iconName: string;
|
||||
featuresOn: string[];
|
||||
featuresOff: string[];
|
||||
featuresOn: FeatureId[];
|
||||
featuresOff: FeatureId[];
|
||||
featureLabelsOn: string[];
|
||||
featureLabelsOff: string[];
|
||||
cfaLabel: string;
|
||||
cfaUrl: string;
|
||||
footnote: string;
|
||||
}
|
||||
|
||||
export enum PricePeriod {
|
||||
@@ -40,31 +65,6 @@ export interface StripePublicConfig {
|
||||
webhookBaseUrl: string;
|
||||
}
|
||||
|
||||
export interface PlanFeature {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function getFeatureList(plan: Plan): PlanFeature[] {
|
||||
const output: PlanFeature[] = [];
|
||||
|
||||
for (const f of plan.featuresOn) {
|
||||
output.push({
|
||||
label: f,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const f of plan.featuresOff) {
|
||||
output.push({
|
||||
label: f,
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function formatPrice(amount: string | number, currency: PriceCurrency): string {
|
||||
amount = typeof amount === 'number' ? (Math.ceil(amount * 100) / 100).toFixed(2) : amount;
|
||||
if (currency === PriceCurrency.EUR) return `${amount}€`;
|
||||
@@ -110,28 +110,181 @@ export function findPrice(prices: StripePublicConfigPrice[], query: FindPriceQue
|
||||
return output;
|
||||
}
|
||||
|
||||
const businessAccountEmailBody = `Hello,
|
||||
const features: Record<FeatureId, PlanFeature> = {
|
||||
maxItemSize: {
|
||||
title: 'Max note or attachment size',
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
basicInfo: '10 MB per note or attachment',
|
||||
proInfo: '200 MB per note or attachment',
|
||||
teamsInfo: '200 MB per note or attachment',
|
||||
basicInfoShort: '10 MB',
|
||||
proInfoShort: '200 MB',
|
||||
teamsInfoShort: '200 MB',
|
||||
},
|
||||
maxStorage: {
|
||||
title: 'Storage space',
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
basicInfo: '1 GB storage space',
|
||||
proInfo: '200 GB storage space',
|
||||
teamsInfo: '200 GB storage space',
|
||||
basicInfoShort: '1 GB',
|
||||
proInfoShort: '200 GB',
|
||||
teamsInfoShort: '200 GB',
|
||||
},
|
||||
publishNote: {
|
||||
title: 'Publish notes to the internet',
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
},
|
||||
sync: {
|
||||
title: 'Sync as many devices as you want',
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
},
|
||||
clipper: {
|
||||
title: 'Web Clipper',
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
},
|
||||
collaborate: {
|
||||
title: 'Share and collaborate on a notebook',
|
||||
basic: false,
|
||||
pro: true,
|
||||
teams: true,
|
||||
},
|
||||
multiUsers: {
|
||||
title: 'Manage multiple users',
|
||||
basic: false,
|
||||
pro: false,
|
||||
teams: true,
|
||||
},
|
||||
consolidatedBilling: {
|
||||
title: 'Consolidated billing',
|
||||
basic: false,
|
||||
pro: false,
|
||||
teams: true,
|
||||
},
|
||||
sharingAccessControl: {
|
||||
title: 'Sharing access control',
|
||||
basic: false,
|
||||
pro: false,
|
||||
teams: true,
|
||||
},
|
||||
prioritySupport: {
|
||||
title: 'Priority support',
|
||||
basic: false,
|
||||
pro: false,
|
||||
teams: true,
|
||||
},
|
||||
};
|
||||
|
||||
This is an automatically generated email. The Business feature is coming soon, and in the meantime we offer a business discount if you would like to register multiple users.
|
||||
export const getFeatureIdsByPlan = (planName: PlanName, featureOn: boolean): FeatureId[] => {
|
||||
const output: FeatureId[] = [];
|
||||
|
||||
If so please let us know the following details and we will get back to you as soon as possible:
|
||||
for (const [k, v] of Object.entries(features)) {
|
||||
if (v[planName] === featureOn) {
|
||||
output.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
- Name:
|
||||
return output;
|
||||
};
|
||||
|
||||
- Email:
|
||||
export const getFeatureLabelsByPlan = (planName: PlanName, featureOn: boolean): string[] => {
|
||||
const output: FeatureId[] = [];
|
||||
|
||||
- Number of users: `;
|
||||
for (const [featureId, v] of Object.entries(features)) {
|
||||
if (v[planName] === featureOn) {
|
||||
output.push(getFeatureLabel(planName, featureId));
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlans(stripeConfig: StripePublicConfig): Record<string, Plan> {
|
||||
const features = {
|
||||
publishNote: 'Publish notes to the internet',
|
||||
sync: 'Sync as many devices as you want',
|
||||
clipper: 'Web Clipper',
|
||||
collaborate: 'Share and collaborate on a notebook',
|
||||
multiUsers: 'Up to 10 users',
|
||||
prioritySupport: 'Priority support',
|
||||
return output;
|
||||
};
|
||||
|
||||
export const getAllFeatureIds = (): FeatureId[] => {
|
||||
return Object.keys(features);
|
||||
};
|
||||
|
||||
export const getFeatureById = (featureId: FeatureId): PlanFeature => {
|
||||
return features[featureId];
|
||||
};
|
||||
|
||||
export const getFeaturesByPlan = (planName: PlanName, featureOn: boolean): PlanFeature[] => {
|
||||
const output: PlanFeature[] = [];
|
||||
|
||||
for (const [, v] of Object.entries(features)) {
|
||||
if (v[planName] === featureOn) {
|
||||
output.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
export const getFeatureLabel = (planName: PlanName, featureId: FeatureId): string => {
|
||||
const feature = features[featureId];
|
||||
const k = `${planName}Info`;
|
||||
if ((feature as any)[k]) return (feature as any)[k];
|
||||
return feature.title;
|
||||
};
|
||||
|
||||
export const getFeatureEnabled = (planName: PlanName, featureId: FeatureId): boolean => {
|
||||
const feature = features[featureId];
|
||||
return feature[planName];
|
||||
};
|
||||
|
||||
export const createFeatureTableMd = () => {
|
||||
const headers: MarkdownTableHeader[] = [
|
||||
{
|
||||
name: 'featureLabel',
|
||||
label: 'Feature',
|
||||
},
|
||||
{
|
||||
name: 'basic',
|
||||
label: 'Basic',
|
||||
},
|
||||
{
|
||||
name: 'pro',
|
||||
label: 'Pro',
|
||||
},
|
||||
{
|
||||
name: 'teams',
|
||||
label: 'Teams',
|
||||
},
|
||||
];
|
||||
|
||||
const rows: MarkdownTableRow[] = [];
|
||||
|
||||
const getCellInfo = (planName: PlanName, feature: PlanFeature) => {
|
||||
if (!feature[planName]) return '-';
|
||||
const infoShort: string = (feature as any)[`${planName}InfoShort`];
|
||||
if (infoShort) return infoShort;
|
||||
return '✔️';
|
||||
};
|
||||
|
||||
for (const [, feature] of Object.entries(features)) {
|
||||
const row: MarkdownTableRow = {
|
||||
featureLabel: feature.title,
|
||||
basic: getCellInfo(PlanName.Basic, feature),
|
||||
pro: getCellInfo(PlanName.Pro, feature),
|
||||
teams: getCellInfo(PlanName.Teams, feature),
|
||||
};
|
||||
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return markdownUtils.createMarkdownTable(headers, rows);
|
||||
};
|
||||
|
||||
export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Plan> {
|
||||
return {
|
||||
basic: {
|
||||
name: 'basic',
|
||||
@@ -146,20 +299,13 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<string, Plan>
|
||||
}),
|
||||
featured: false,
|
||||
iconName: 'basic-icon',
|
||||
featuresOn: [
|
||||
'Max 10 MB per note or attachment',
|
||||
features.publishNote,
|
||||
features.sync,
|
||||
features.clipper,
|
||||
'1 GB storage space',
|
||||
],
|
||||
featuresOff: [
|
||||
features.collaborate,
|
||||
features.multiUsers,
|
||||
features.prioritySupport,
|
||||
],
|
||||
featuresOn: getFeatureIdsByPlan(PlanName.Basic, true),
|
||||
featuresOff: getFeatureIdsByPlan(PlanName.Basic, false),
|
||||
featureLabelsOn: getFeatureLabelsByPlan(PlanName.Basic, true),
|
||||
featureLabelsOff: getFeatureLabelsByPlan(PlanName.Basic, false),
|
||||
cfaLabel: 'Try it now',
|
||||
cfaUrl: '',
|
||||
footnote: '',
|
||||
},
|
||||
|
||||
pro: {
|
||||
@@ -175,42 +321,35 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<string, Plan>
|
||||
}),
|
||||
featured: true,
|
||||
iconName: 'pro-icon',
|
||||
featuresOn: [
|
||||
'Max 200 MB per note or attachment',
|
||||
features.publishNote,
|
||||
features.sync,
|
||||
features.clipper,
|
||||
'10 GB storage space',
|
||||
features.collaborate,
|
||||
],
|
||||
featuresOff: [
|
||||
features.multiUsers,
|
||||
features.prioritySupport,
|
||||
],
|
||||
featuresOn: getFeatureIdsByPlan(PlanName.Pro, true),
|
||||
featuresOff: getFeatureIdsByPlan(PlanName.Pro, false),
|
||||
featureLabelsOn: getFeatureLabelsByPlan(PlanName.Pro, true),
|
||||
featureLabelsOff: getFeatureLabelsByPlan(PlanName.Pro, false),
|
||||
cfaLabel: 'Try it now',
|
||||
cfaUrl: '',
|
||||
footnote: '',
|
||||
},
|
||||
|
||||
business: {
|
||||
name: 'business',
|
||||
title: 'Business',
|
||||
priceMonthly: { accountType: 3, formattedMonthlyAmount: '49.99€' } as any,
|
||||
priceYearly: { accountType: 3, formattedMonthlyAmount: '39.99€', formattedAmount: '479.88€' } as any,
|
||||
teams: {
|
||||
name: 'teams',
|
||||
title: 'Teams',
|
||||
priceMonthly: findPrice(stripeConfig.prices, {
|
||||
accountType: 3,
|
||||
period: PricePeriod.Monthly,
|
||||
}),
|
||||
priceYearly: findPrice(stripeConfig.prices, {
|
||||
accountType: 3,
|
||||
period: PricePeriod.Yearly,
|
||||
}),
|
||||
featured: false,
|
||||
iconName: 'business-icon',
|
||||
featuresOn: [
|
||||
'Max 200 MB per note or attachment',
|
||||
features.publishNote,
|
||||
features.sync,
|
||||
features.clipper,
|
||||
'10 GB storage space',
|
||||
features.collaborate,
|
||||
features.multiUsers,
|
||||
features.prioritySupport,
|
||||
],
|
||||
featuresOff: [],
|
||||
cfaLabel: 'Contact us',
|
||||
cfaUrl: `mailto:business@joplincloud.com?subject=${encodeURIComponent('Joplin Cloud Business Account Order')}&body=${encodeURIComponent(businessAccountEmailBody)}`,
|
||||
featuresOn: getFeatureIdsByPlan(PlanName.Teams, true),
|
||||
featuresOff: getFeatureIdsByPlan(PlanName.Teams, false),
|
||||
featureLabelsOn: getFeatureLabelsByPlan(PlanName.Teams, true),
|
||||
featureLabelsOff: getFeatureLabelsByPlan(PlanName.Teams, false),
|
||||
cfaLabel: 'Try it now',
|
||||
cfaUrl: '',
|
||||
footnote: 'Per user. Minimum of 2 users.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const createUuidV4 = require('uuid/v4');
|
||||
const { customAlphabet } = require('nanoid/non-secure');
|
||||
import { customAlphabet } from 'nanoid/non-secure';
|
||||
|
||||
// https://zelark.github.io/nano-id-cc/
|
||||
// https://security.stackexchange.com/a/41749/1873
|
||||
|
||||
@@ -148,6 +148,7 @@ export interface RuleOptions {
|
||||
|
||||
// Used by checkboxes to specify how it should be rendered
|
||||
checkboxRenderingType?: number;
|
||||
checkboxDisabled?: boolean;
|
||||
|
||||
// Used by the keyword highlighting plugin (mobile only)
|
||||
highlightedKeywords?: any[];
|
||||
|
||||
@@ -53,7 +53,7 @@ function pluginAssets(theme: any) {
|
||||
];
|
||||
}
|
||||
|
||||
function createPrefixTokens(Token: any, id: string, checked: boolean, label: string, postMessageSyntax: string, sourceToken: any): any[] {
|
||||
function createPrefixTokens(Token: any, id: string, checked: boolean, label: string, postMessageSyntax: string, sourceToken: any, disabled: boolean): any[] {
|
||||
let token = null;
|
||||
const tokens = [];
|
||||
|
||||
@@ -89,6 +89,7 @@ function createPrefixTokens(Token: any, id: string, checked: boolean, label: str
|
||||
|
||||
token = new Token('checkbox_input', 'input', 0);
|
||||
token.attrs = [['type', 'checkbox'], ['id', id], ['onclick', js]];
|
||||
if (disabled) token.attrs.push(['disabled', 'disabled']);
|
||||
if (checked) token.attrs.push(['checked', 'checked']);
|
||||
tokens.push(token);
|
||||
|
||||
@@ -169,7 +170,7 @@ function checkboxPlugin(markdownIt: any, options: RuleOptions) {
|
||||
// Prepend the text content with the checkbox markup and the opening <label> tag
|
||||
// then append the </label> tag at the end of the text content.
|
||||
|
||||
const prefix = createPrefixTokens(Token, id, checked, label, options.postMessageSyntax, token);
|
||||
const prefix = createPrefixTokens(Token, id, checked, label, options.postMessageSyntax, token, !!options.checkboxDisabled);
|
||||
const suffix = createSuffixTokens(Token);
|
||||
|
||||
token.children = markdownIt.utils.arrayReplaceAt(token.children, 0, prefix);
|
||||
|
||||
@@ -22,8 +22,10 @@ export default {
|
||||
markdownIt.renderer.rules[key] = (tokens: any[], idx: number, options: any, env: any, self: any) => {
|
||||
if (!!tokens[idx].map && tokens[idx].level <= allowedLevel) {
|
||||
const line = tokens[idx].map[0];
|
||||
const lineEnd = tokens[idx].map[1];
|
||||
tokens[idx].attrJoin('class', 'maps-to-line');
|
||||
tokens[idx].attrSet('source-line', `${line}`);
|
||||
tokens[idx].attrSet('source-line-end', `${lineEnd}`);
|
||||
}
|
||||
if (precedentRule) {
|
||||
return precedentRule(tokens, idx, options, env, self);
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
# Installing
|
||||
|
||||
## Configuration
|
||||
## Requirements
|
||||
|
||||
First copy `.env-sample` to `.env` and edit the values in there:
|
||||
- Docker Engine runs Joplin Server. See [Install Docker Engine](https://docs.docker.com/engine/install/) for steps to install Docker Engine for your operating system.
|
||||
- Docker Compose is required to store item contents (notes, tags, etc.) if PostgreSQL is not used. See [Install Docker Compose](https://docs.docker.com/compose/install/) for steps to install Docker Compose for your operating system.
|
||||
|
||||
- `APP_BASE_URL`: This is the base public URL where the service will be running. For example, if you want it to run from `https://example.com/joplin`, this is what you should set the URL to. The base URL can include the port.
|
||||
- `APP_PORT`: The local port on which the Docker container will listen. You would typically map this port to 443 (TLS) with a reverse proxy.
|
||||
## Configure Docker for Joplin Server
|
||||
|
||||
## Running the server
|
||||
|
||||
To start the server with default configuration, run:
|
||||
1. Copy `.env-sample` (located [here](https://github.com/laurent22/joplin/blob/dev/.env-sample)) to the location of your Docker configuration files. Example: /home/[user]/docker
|
||||
2. Rename the file `.env-sample` to `.env`.
|
||||
3. Run the following command to test starting the server using the default configuration:
|
||||
|
||||
```shell
|
||||
docker run --env-file .env -p 22300:22300 joplin/server:latest
|
||||
```
|
||||
|
||||
This will start the server, which will listen on port **22300** on **localhost**. By default it will use SQLite, which allows you to test the app without setting up a database. To run it for production though, you'll want to connect the container to a database, as described below.
|
||||
The server will listen on port **22300** on **localhost**. By default, the server will use SQLite, which allows you to test the app without setting up a database. When running the server for production use, you should connect the container to a database, as described below.
|
||||
|
||||
## Supported docker tags
|
||||
|
||||
## Supported Docker tags
|
||||
|
||||
The following tags are available:
|
||||
|
||||
@@ -29,7 +30,7 @@ The following tags are available:
|
||||
|
||||
## Setup the database
|
||||
|
||||
You can setup the container to either use an existing PostgreSQL server, or connect it to a new one using docker-compose
|
||||
You can setup the container to either use an existing PostgreSQL server, or connect it to a new database using docker-compose.
|
||||
|
||||
### Using an existing PostgreSQL server
|
||||
|
||||
@@ -44,39 +45,48 @@ POSTGRES_PORT=5432
|
||||
POSTGRES_HOST=localhost
|
||||
```
|
||||
|
||||
Make sure that the provided database and user exist as the server will not create them. When running on macOS or Windows through Docker Desktop, a mapping of localhost is made automatically. On Linux, you can add `--net=host --add-host=host.docker.internal:127.0.0.1` to the `docker run` command line to make the mapping happen. Any other `POSTGRES_HOST` than localhost or 127.0.0.1 should work as expected without further action.
|
||||
Ensure that the provided database and user exist as Joplin Server will not create them. When running on macOS or Windows through Docker Desktop, a mapping of localhost is made automatically. On Linux, you can add `--net=host --add-host=host.docker.internal:127.0.0.1` to the `docker run` command line to make the mapping happen. Any other `POSTGRES_HOST` than localhost or 127.0.0.1 should work as expected without further action.
|
||||
|
||||
### Using docker-compose
|
||||
|
||||
A [sample docker-compose file](https://github.com/laurent22/joplin/blob/dev/docker-compose.server.yml
|
||||
) is available to show how to use Docker to install both the database and server and connect them:
|
||||
1. Using the [sample docker-compose file](https://github.com/laurent22/joplin/blob/dev/docker-compose.server.yml), create a docker compose file in the location of your Docker configuration files. Example: /home/[user]/docker/docker-compose.yml
|
||||
2. Update the fields in the docker-compose file as seen in the sample file.
|
||||
|
||||
|
||||
## Setup reverse proxy
|
||||
|
||||
Once Joplin Server is running, you will then need to expose it to the internet by setting up a reverse proxy, and that will depend on how your server is currently configured, and whether you already have Nginx or Apache running:
|
||||
This step is optional.
|
||||
|
||||
Configuring a reverse proxy is not required for core functionality and is only required if Joplin Server needs to be accessible over the internet. See the following documentation for configuring a reverse proxy with Apache or Nginx.
|
||||
|
||||
- [Apache Reverse Proxy](https://httpd.apache.org/docs/current/mod/mod_proxy.html)
|
||||
- [Nginx Reverse Proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/)
|
||||
|
||||
## Setup storage
|
||||
|
||||
By default, the item contents (notes, tags, etc.) are stored in the database and you don't need to do anything special to get that working.
|
||||
This step is optional.
|
||||
|
||||
However since that content can be quite large, you also have the option to store it outside the database by setting the `STORAGE_DRIVER` environment variable.
|
||||
By default, the item contents (notes, tags, etc.) are stored in the database and no additional steps are required to get that working.
|
||||
|
||||
However, since that content can be quite large, you have the option to store it outside the database by setting the `STORAGE_DRIVER` environment variable.
|
||||
|
||||
### Setting up storage on a new installation
|
||||
|
||||
Again this is optional - by default items will simply be saved to the database. To save to the local filesystem instead, use:
|
||||
This step is optional.
|
||||
|
||||
To save item contents (notes, tags, etc.) to the local filesystem instead, use:
|
||||
|
||||
STORAGE_DRIVER=Type=Filesystem; Path=/path/to/dir
|
||||
|
||||
Then all item data will be saved under this `/path/to/dir` directory.
|
||||
After this is set, all item contents will be saved under the defined `/path/to/dir` directory.
|
||||
|
||||
### Migrating storage for an existing installation
|
||||
|
||||
Migrating storage is a bit more complicated because the old content will have to be migrated to the new storage. This is done by providing a fallback driver, which tells the server where to look if a particular item is not yet available on the new storage.
|
||||
This step is optional.
|
||||
|
||||
To migrate from the database to the file system for example, you would set the environment variables like so:
|
||||
Migrating storage is a bit more complicated because the old content will have to be migrated to the new storage. This is done by providing a fallback driver, which tells the server where to look if a particular item is not yet available on the new storage.
|
||||
|
||||
To migrate from the database to the file system, you would set the environment variables as follows:
|
||||
|
||||
STORAGE_DRIVER=Type=Filesystem; Path=/path/to/dir
|
||||
STORAGE_DRIVER_FALLBACK=Type=Database; Mode=ReadAndWrite
|
||||
@@ -111,13 +121,15 @@ Besides the database and filesystem, it's also possible to use AWS S3 for storag
|
||||
|
||||
STORAGE_DRIVER=Type=S3; Region=YOUR_REGION_CODE; AccessKeyId=YOUR_ACCESS_KEY; SecretAccessKeyId=YOUR_SECRET_ACCESS_KEY; Bucket=YOUR_BUCKET
|
||||
|
||||
## Setup the website
|
||||
## Verify access to the admin page
|
||||
|
||||
Once the server is exposed to the internet, you can open the admin UI and get it ready for synchronisation. For the following instructions, we'll assume that the Joplin server is running on `https://example.com/joplin`.
|
||||
Once Joplin Server is exposed to the internet, open the admin UI. For the following instructions, we'll assume that Joplin Server is running on `https://example.com/joplin`.
|
||||
|
||||
### Secure the admin user
|
||||
If Joplin Server is running running locally only, access the Admin Page using `http://[hostname]:22300`
|
||||
|
||||
By default, the instance will be setup with an admin user with email **admin@localhost** and password **admin** and you should change this. To do so, open `https://example.com/joplin/login` and login as admin. Then go to the Profile section and change the admin password.
|
||||
### Update the admin user credentials
|
||||
|
||||
By default, Joplin Server will be setup with an admin user with email **admin@localhost** and password **admin**. For security purposes, the admin user's credentials should be changed. On the Admin Page, login as the admin user. In the upper right, select the Profile button update the admin password.
|
||||
|
||||
### Create a user for sync
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { makeUrl, redirect, SubPath, UrlType } from '../../utils/routeUtils';
|
||||
import { redirect, SubPath } from '../../utils/routeUtils';
|
||||
import Router from '../../utils/Router';
|
||||
import { RouteType } from '../../utils/types';
|
||||
import { AppContext } from '../../utils/types';
|
||||
import { ErrorBadRequest, ErrorForbidden, ErrorMethodNotAllowed } from '../../utils/errors';
|
||||
import { ErrorBadRequest, ErrorForbidden } from '../../utils/errors';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import { yesOrNo } from '../../utils/strings';
|
||||
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
|
||||
@@ -18,108 +18,105 @@ router.get('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
const user = ctx.joplin.owner;
|
||||
if (!user.is_admin) throw new ErrorForbidden();
|
||||
|
||||
if (ctx.method === 'GET') {
|
||||
const pagination = makeTablePagination(ctx.query, 'scheduled_time', PaginationOrderDir.ASC);
|
||||
const page = await ctx.joplin.models.userDeletion().allPaginated(pagination);
|
||||
const users = await ctx.joplin.models.user().loadByIds(page.items.map(d => d.user_id), { fields: ['id', 'email'] });
|
||||
const pagination = makeTablePagination(ctx.query, 'scheduled_time', PaginationOrderDir.ASC);
|
||||
const page = await ctx.joplin.models.userDeletion().allPaginated(pagination);
|
||||
const users = await ctx.joplin.models.user().loadByIds(page.items.map(d => d.user_id), { fields: ['id', 'email'] });
|
||||
|
||||
const table: Table = {
|
||||
baseUrl: adminUserDeletionsUrl(),
|
||||
requestQuery: ctx.query,
|
||||
pageCount: page.page_count,
|
||||
pagination,
|
||||
headers: [
|
||||
const table: Table = {
|
||||
baseUrl: adminUserDeletionsUrl(),
|
||||
requestQuery: ctx.query,
|
||||
pageCount: page.page_count,
|
||||
pagination,
|
||||
headers: [
|
||||
{
|
||||
name: 'select',
|
||||
label: '',
|
||||
canSort: false,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
stretch: true,
|
||||
canSort: false,
|
||||
},
|
||||
{
|
||||
name: 'process_data',
|
||||
label: 'Data?',
|
||||
},
|
||||
{
|
||||
name: 'process_account',
|
||||
label: 'Account?',
|
||||
},
|
||||
{
|
||||
name: 'scheduled_time',
|
||||
label: 'Scheduled',
|
||||
},
|
||||
{
|
||||
name: 'start_time',
|
||||
label: 'Start',
|
||||
},
|
||||
{
|
||||
name: 'end_time',
|
||||
label: 'End',
|
||||
},
|
||||
{
|
||||
name: 'success',
|
||||
label: 'Success?',
|
||||
},
|
||||
{
|
||||
name: 'error',
|
||||
label: 'Error',
|
||||
},
|
||||
],
|
||||
rows: page.items.map(d => {
|
||||
const isDone = d.end_time && d.success;
|
||||
|
||||
const row: Row = [
|
||||
{
|
||||
name: 'select',
|
||||
label: '',
|
||||
canSort: false,
|
||||
value: `checkbox_${d.id}`,
|
||||
checkbox: true,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
value: isDone ? d.user_id : users.find(u => u.id === d.user_id).email,
|
||||
stretch: true,
|
||||
url: isDone ? '' : userUrl(d.user_id),
|
||||
},
|
||||
{
|
||||
name: 'process_data',
|
||||
label: 'Data?',
|
||||
value: yesOrNo(d.process_data),
|
||||
},
|
||||
{
|
||||
name: 'process_account',
|
||||
label: 'Account?',
|
||||
value: yesOrNo(d.process_account),
|
||||
},
|
||||
{
|
||||
name: 'scheduled_time',
|
||||
label: 'Scheduled',
|
||||
value: formatDateTime(d.scheduled_time),
|
||||
},
|
||||
{
|
||||
name: 'start_time',
|
||||
label: 'Start',
|
||||
value: formatDateTime(d.start_time),
|
||||
},
|
||||
{
|
||||
name: 'end_time',
|
||||
label: 'End',
|
||||
value: formatDateTime(d.end_time),
|
||||
},
|
||||
{
|
||||
name: 'success',
|
||||
label: 'Success?',
|
||||
value: d.end_time ? yesOrNo(d.success) : '-',
|
||||
},
|
||||
{
|
||||
name: 'error',
|
||||
label: 'Error',
|
||||
value: d.error,
|
||||
},
|
||||
],
|
||||
rows: page.items.map(d => {
|
||||
const isDone = d.end_time && d.success;
|
||||
];
|
||||
|
||||
const row: Row = [
|
||||
{
|
||||
value: `checkbox_${d.id}`,
|
||||
checkbox: true,
|
||||
},
|
||||
{
|
||||
value: isDone ? d.user_id : users.find(u => u.id === d.user_id).email,
|
||||
stretch: true,
|
||||
url: isDone ? '' : userUrl(d.user_id),
|
||||
},
|
||||
{
|
||||
value: yesOrNo(d.process_data),
|
||||
},
|
||||
{
|
||||
value: yesOrNo(d.process_account),
|
||||
},
|
||||
{
|
||||
value: formatDateTime(d.scheduled_time),
|
||||
},
|
||||
{
|
||||
value: formatDateTime(d.start_time),
|
||||
},
|
||||
{
|
||||
value: formatDateTime(d.end_time),
|
||||
},
|
||||
{
|
||||
value: d.end_time ? yesOrNo(d.success) : '-',
|
||||
},
|
||||
{
|
||||
value: d.error,
|
||||
},
|
||||
];
|
||||
return row;
|
||||
}),
|
||||
};
|
||||
|
||||
return row;
|
||||
}),
|
||||
};
|
||||
const view = defaultView('admin/user_deletions', 'User deletions');
|
||||
view.content = {
|
||||
userDeletionTable: makeTableView(table),
|
||||
postUrl: adminUserDeletionsUrl(),
|
||||
csrfTag: await createCsrfTag(ctx),
|
||||
};
|
||||
view.cssFiles = ['index/user_deletions'];
|
||||
|
||||
const view = defaultView('admin/user_deletions', 'User deletions');
|
||||
view.content = {
|
||||
userDeletionTable: makeTableView(table),
|
||||
postUrl: makeUrl(UrlType.UserDeletions),
|
||||
csrfTag: await createCsrfTag(ctx),
|
||||
};
|
||||
view.cssFiles = ['index/user_deletions'];
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
throw new ErrorMethodNotAllowed();
|
||||
return view;
|
||||
});
|
||||
|
||||
router.post('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
@@ -142,7 +139,7 @@ router.post('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
throw new ErrorBadRequest('Invalid action');
|
||||
}
|
||||
|
||||
return redirect(ctx, makeUrl(UrlType.UserDeletions));
|
||||
return redirect(ctx, adminUserDeletionsUrl());
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { SubPath, redirect } from '../../utils/routeUtils';
|
||||
import Router from '../../utils/Router';
|
||||
import { RouteType } from '../../utils/types';
|
||||
import { AppContext } from '../../utils/types';
|
||||
import { findPrice, getFeatureList, getPlans, PricePeriod } from '@joplin/lib/utils/joplinCloud';
|
||||
import { findPrice, PricePeriod, PlanName, getFeatureLabel, getFeatureEnabled, getAllFeatureIds } from '@joplin/lib/utils/joplinCloud';
|
||||
import config from '../../config';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import { stripeConfig, stripePriceIdByUserId, updateSubscriptionType } from '../../utils/stripe';
|
||||
@@ -28,21 +28,23 @@ router.get('upgrade', async (_path: SubPath, ctx: AppContext) => {
|
||||
proLabel: string;
|
||||
}
|
||||
|
||||
const plans = getPlans(stripeConfig());
|
||||
const basicFeatureList = getFeatureList(plans.basic);
|
||||
const proFeatureList = getFeatureList(plans.pro);
|
||||
const featureIds = getAllFeatureIds();
|
||||
|
||||
const planRows: PlanRow[] = [];
|
||||
|
||||
for (let i = 0; i < basicFeatureList.length; i++) {
|
||||
const basic = basicFeatureList[i];
|
||||
const pro = proFeatureList[i];
|
||||
for (let i = 0; i < featureIds.length; i++) {
|
||||
const featureId = featureIds[i];
|
||||
|
||||
if (basic.label === pro.label && basic.enabled === pro.enabled) continue;
|
||||
const basicLabel = getFeatureLabel(PlanName.Basic, featureId);
|
||||
const proLabel = getFeatureLabel(PlanName.Pro, featureId);
|
||||
const basicEnabled = getFeatureEnabled(PlanName.Basic, featureId);
|
||||
const proEnabled = getFeatureEnabled(PlanName.Pro, featureId);
|
||||
|
||||
if (basicLabel === proLabel && basicEnabled === proEnabled) continue;
|
||||
|
||||
planRows.push({
|
||||
basicLabel: basic.enabled ? basic.label : '-',
|
||||
proLabel: pro.label,
|
||||
basicLabel: basicEnabled ? basicLabel : '-',
|
||||
proLabel: proLabel,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,7 @@ async function renderNote(share: Share, note: NoteEntity, resourceInfos: Resourc
|
||||
audioPlayerEnabled: false,
|
||||
videoPlayerEnabled: false,
|
||||
pdfViewerEnabled: false,
|
||||
checkboxDisabled: true,
|
||||
|
||||
linkRenderingType: 2,
|
||||
};
|
||||
|
||||
@@ -46,17 +46,12 @@ export default function(env: Env, models: Models, config: Config, services: Serv
|
||||
run: (models: Models) => models.user().handleOversizedAccounts(),
|
||||
},
|
||||
|
||||
// This should be enabled eventually. As of version 2.5
|
||||
// (2021-11-08T11:07:11Z) all Joplin clients support handling of expired
|
||||
// sessions, however we don't know how many people have Joplin 2.5+ so
|
||||
// be safe we don't enable it just yet.
|
||||
|
||||
// {
|
||||
// id: TaskId.DeleteExpiredSessions,
|
||||
// description: taskIdToLabel(TaskId.DeleteExpiredSessions),
|
||||
// schedule: '0 */6 * * *',
|
||||
// run: (models: Models) => models.session().deleteExpiredSessions(),
|
||||
// },
|
||||
{
|
||||
id: TaskId.DeleteExpiredSessions,
|
||||
description: taskIdToLabel(TaskId.DeleteExpiredSessions),
|
||||
schedule: '0 */6 * * *',
|
||||
run: (models: Models) => models.session().deleteExpiredSessions(),
|
||||
},
|
||||
];
|
||||
|
||||
if (config.USER_DATA_AUTO_DELETE_ENABLED) {
|
||||
|
||||
@@ -30,6 +30,20 @@
|
||||
"period": "yearly",
|
||||
"amount": "57.48",
|
||||
"currency": "EUR"
|
||||
},
|
||||
{
|
||||
"accountType": 3,
|
||||
"id": "price_1Kl9uBLx4fybOTqJhx7q4zzj",
|
||||
"period": "monthly",
|
||||
"amount": "7.99",
|
||||
"currency": "EUR"
|
||||
},
|
||||
{
|
||||
"accountType": 3,
|
||||
"id": "price_1Kl9uNLx4fybOTqJpsB2l3Kg",
|
||||
"period": "yearly",
|
||||
"amount": "80.28",
|
||||
"currency": "EUR"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -64,6 +78,20 @@
|
||||
"period": "yearly",
|
||||
"amount": "57.48",
|
||||
"currency": "EUR"
|
||||
},
|
||||
{
|
||||
"accountType": 3,
|
||||
"id": "price_1Kl9jyLx4fybOTqJN0i1A88B",
|
||||
"period": "monthly",
|
||||
"amount": "7.99",
|
||||
"currency": "EUR"
|
||||
},
|
||||
{
|
||||
"accountType": 3,
|
||||
"id": "price_1Kl9nLLx4fybOTqJYTtts35z",
|
||||
"period": "yearly",
|
||||
"amount": "80.28",
|
||||
"currency": "EUR"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ function platformFromTag(tagName: string): Platform {
|
||||
}
|
||||
|
||||
function filterLogs(logs: LogEntry[], platform: Platform) {
|
||||
const output = [];
|
||||
const output: LogEntry[] = [];
|
||||
const revertedLogs = [];
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
@@ -126,7 +126,7 @@ function filterLogs(logs: LogEntry[], platform: Platform) {
|
||||
if (platform === 'cli' && prefix.indexOf('cli') >= 0) addIt = true;
|
||||
if (platform === 'clipper' && prefix.indexOf('clipper') >= 0) addIt = true;
|
||||
if (platform === 'server' && prefix.indexOf('server') >= 0) addIt = true;
|
||||
if (platform === 'cloud' && (prefix.indexOf('cloud') >= 0 || prefix.indexOf('server'))) addIt = true;
|
||||
if (platform === 'cloud' && (prefix.indexOf('cloud') >= 0 || prefix.indexOf('server') >= 0)) addIt = true;
|
||||
|
||||
// Translation updates often comes in format "Translation: Update pt_PT.po"
|
||||
// but that's not useful in a changelog especially since most people
|
||||
@@ -137,6 +137,11 @@ function filterLogs(logs: LogEntry[], platform: Platform) {
|
||||
addIt = false;
|
||||
}
|
||||
|
||||
// Remove duplicate messages
|
||||
if (output.find(l => l.message === log.message)) {
|
||||
addIt = false;
|
||||
}
|
||||
|
||||
if (addIt) output.push(log);
|
||||
}
|
||||
|
||||
|
||||
@@ -175,8 +175,8 @@ msgid ""
|
||||
"list all the tags (use -l for long option)."
|
||||
msgstr ""
|
||||
"<tag-command> pot ser «add», «remove», «list» o «notetags» per a assignar o "
|
||||
"suprimir l'[etiqueta] de la [nota], o per a llistar les notes associades amb "
|
||||
"l'[etiqueta]. L'ordre «tag list» es pot usar per a llistar totes les "
|
||||
"suprimir l'[etiqueta] de la [nota], o per a llistar les notes associades amb"
|
||||
" l'[etiqueta]. L'ordre «tag list» es pot usar per a llistar totes les "
|
||||
"etiquetes (useu -l per l'opció llarga)."
|
||||
|
||||
#: packages/app-cli/app/command-todo.js:14
|
||||
@@ -189,8 +189,8 @@ msgstr ""
|
||||
"<todo-command> pot ser «toggle» o «clear». Useu «toggle» per a canviar els "
|
||||
"llistats de tasques entre l'estat de finalitzat i no finalitzat (si "
|
||||
"l'objectiu és una nota normal, es convertirà a un llistat de tasques "
|
||||
"pendents). Useu «clear» per a convertir un llistat de tasques pendents a una "
|
||||
"nota normal."
|
||||
"pendents). Useu «clear» per a convertir un llistat de tasques pendents a una"
|
||||
" nota normal."
|
||||
|
||||
#: packages/lib/models/Setting.ts:1235
|
||||
msgid "A3"
|
||||
@@ -223,8 +223,8 @@ msgid ""
|
||||
"Accelerator \"%s\" is used for \"%s\" and \"%s\" commands. This may lead to "
|
||||
"unexpected behaviour."
|
||||
msgstr ""
|
||||
"L'accelerador «%s» s'utilitza per a les ordres «%s» i «%s». Això pot conduir "
|
||||
"a un comportament inesperat."
|
||||
"L'accelerador «%s» s'utilitza per a les ordres «%s» i «%s». Això pot conduir"
|
||||
" a un comportament inesperat."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.tsx:613
|
||||
msgid "Accept"
|
||||
@@ -270,11 +270,11 @@ msgstr "Afegeix al diccionari"
|
||||
#: packages/server/src/services/MustacheService.ts:183
|
||||
#: packages/server/src/services/MustacheService.ts:307
|
||||
msgid "Admin"
|
||||
msgstr ""
|
||||
msgstr "Administració"
|
||||
|
||||
#: packages/server/src/routes/admin/dashboard.ts:10
|
||||
msgid "Admin dashboard"
|
||||
msgstr ""
|
||||
msgstr "Tauler d'administració"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:189
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:147
|
||||
@@ -402,16 +402,15 @@ msgstr "Automàtic"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:39
|
||||
msgid "Auto-add disabled accounts for deletion"
|
||||
msgstr ""
|
||||
msgstr "Afegeix automàticament per a eliminar els comptes deshabilitats"
|
||||
|
||||
#: packages/lib/models/Setting.ts:855
|
||||
msgid "Auto-pair braces, parenthesis, quotations, etc."
|
||||
msgstr "Aparellament automàtic de claus, parèntesis, cites, etc."
|
||||
|
||||
#: packages/lib/models/Setting.ts:1195
|
||||
#, fuzzy
|
||||
msgid "Automatically check for updates"
|
||||
msgstr "Comprova les actualitzacions..."
|
||||
msgstr "Comprova les actualitzacions automàticament"
|
||||
|
||||
#: packages/lib/models/Setting.ts:772
|
||||
msgid "Automatically switch theme to match system theme"
|
||||
@@ -492,8 +491,8 @@ msgid "Cannot copy note to \"%s\" notebook"
|
||||
msgstr "No es pot copiar la nota al bloc de notes «%s»"
|
||||
|
||||
#: packages/app-cli/app/command-attach.js:21
|
||||
#: packages/app-cli/app/command-cat.js:25 packages/app-cli/app/command-cp.js:24
|
||||
#: packages/app-cli/app/command-cp.js:27
|
||||
#: packages/app-cli/app/command-cat.js:25
|
||||
#: packages/app-cli/app/command-cp.js:24 packages/app-cli/app/command-cp.js:27
|
||||
#: packages/app-cli/app/command-done.js:20
|
||||
#: packages/app-cli/app/command-export.js:36
|
||||
#: packages/app-cli/app/command-export.js:40
|
||||
@@ -629,8 +628,9 @@ msgid ""
|
||||
"Click \"%s\" to restore the note. It will be copied in the notebook named "
|
||||
"\"%s\". The current version of the note will not be replaced or modified."
|
||||
msgstr ""
|
||||
"Feu clic «%s» per a restaurar la nota. Aquesta serà copiada al bloc de notes "
|
||||
"anomenat «%s». La versió actual de la nota no serà substituïda o modificada."
|
||||
"Feu clic «%s» per a restaurar la nota. Aquesta serà copiada al bloc de notes"
|
||||
" anomenat «%s». La versió actual de la nota no serà substituïda o "
|
||||
"modificada."
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteEditor.tsx:376
|
||||
msgid "Click to add tags..."
|
||||
@@ -676,9 +676,10 @@ msgstr "Alarmes programades"
|
||||
#: packages/lib/models/Setting.ts:1290
|
||||
msgid ""
|
||||
"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\"."
|
||||
"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\"."
|
||||
msgstr ""
|
||||
"Una llista separada per comes de camins a directoris d'on carregar els "
|
||||
"certificats, o el camí a fitxers de certificats concrets. Per exemple, "
|
||||
@@ -716,7 +717,7 @@ msgstr "Completat: %s (%s)"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:37
|
||||
msgid "Compress old changes"
|
||||
msgstr ""
|
||||
msgstr "Comprimeix els canvis vells"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:637
|
||||
#: packages/app-mobile/components/side-menu-content.js:332
|
||||
@@ -778,9 +779,8 @@ msgid "Copy external link"
|
||||
msgstr "Copia l'enllaç extern"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:134
|
||||
#, fuzzy
|
||||
msgid "Copy image"
|
||||
msgstr "Copia testimoni"
|
||||
msgstr "Copia la imatge"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:172
|
||||
msgid "Copy Link Address"
|
||||
@@ -822,8 +822,7 @@ msgstr ""
|
||||
|
||||
#: packages/lib/JoplinServerApi.ts:92
|
||||
msgid ""
|
||||
"Could not connect to Joplin Server. Please check the Synchronisation options "
|
||||
"in the config screen. Full error was:\n"
|
||||
"Could not connect to Joplin Server. Please check the Synchronisation options in the config screen. Full error was:\n"
|
||||
"\n"
|
||||
"%s"
|
||||
msgstr ""
|
||||
@@ -844,13 +843,11 @@ msgstr "No s'ha pogut instal·lar l'extensió: %s"
|
||||
|
||||
#: packages/app-desktop/services/share/invitationRespond.ts:20
|
||||
msgid ""
|
||||
"Could not respond to the invitation. Please try again, or check with the "
|
||||
"notebook owner if they are still sharing it.\n"
|
||||
"Could not respond to the invitation. Please try again, or check with the notebook owner if they are still sharing it.\n"
|
||||
"\n"
|
||||
"The error was: \"%s\""
|
||||
msgstr ""
|
||||
"No s'ha pogut contestar a la invitació. Torneu-ho a provar, o comproveu amb "
|
||||
"el propietari del bloc de notes si encara l'està compartint.\n"
|
||||
"No s'ha pogut contestar a la invitació. Torneu-ho a provar, o comproveu amb el propietari del bloc de notes si encara l'està compartint.\n"
|
||||
"\n"
|
||||
"L'error és \"%s\""
|
||||
|
||||
@@ -871,14 +868,12 @@ msgid "Create a notebook"
|
||||
msgstr "Crea un bloc de notes nou"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:161
|
||||
#, fuzzy
|
||||
msgid "Create notebook"
|
||||
msgstr "Crea un bloc de notes nou"
|
||||
msgstr "Crea un bloc de notes"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:171
|
||||
#, fuzzy
|
||||
msgid "Create user"
|
||||
msgstr "Creació: %s"
|
||||
msgstr "Crea un usuari"
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.min.js:29
|
||||
msgid "Created"
|
||||
@@ -970,7 +965,7 @@ msgstr "Fosc"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:136
|
||||
msgid "Dashboard"
|
||||
msgstr ""
|
||||
msgstr "Tauler de control"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:625
|
||||
msgid "Database v%s"
|
||||
@@ -1027,14 +1022,12 @@ msgid "Delete attachment \"%s\"?"
|
||||
msgstr "Voleu suprimir l'adjunt \"%s\"?"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:36
|
||||
#, fuzzy
|
||||
msgid "Delete expired sessions"
|
||||
msgstr "Activa les expressions matemàtiques"
|
||||
msgstr "Suprimeix les sessions expirades"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:31
|
||||
#, fuzzy
|
||||
msgid "Delete expired tokens"
|
||||
msgstr "Voleu suprimir aquestes %d notes?"
|
||||
msgstr "Suprimeix els tokens expirats"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:88
|
||||
msgid "Delete line"
|
||||
@@ -1088,8 +1081,8 @@ msgid ""
|
||||
"Delete this invitation? The recipient will no longer have access to this "
|
||||
"shared notebook."
|
||||
msgstr ""
|
||||
"Voleu suprimir aquesta invitació? El destinatari ja no tindrà accés a aquest "
|
||||
"quadern compartit."
|
||||
"Voleu suprimir aquesta invitació? El destinatari ja no tindrà accés a aquest"
|
||||
" quadern compartit."
|
||||
|
||||
#: packages/lib/Synchronizer.ts:186
|
||||
msgid "Deleted local items: %d."
|
||||
@@ -1234,8 +1227,8 @@ msgid ""
|
||||
"way to decrypt the data! To enable encryption, please enter your password "
|
||||
"below."
|
||||
msgstr ""
|
||||
"No perdeu les contrasenyes perquè, per motius de seguretat, és l'única forma "
|
||||
"de desxifrar les dades. Per habilitar el xifrat, introduïu la vostra "
|
||||
"No perdeu les contrasenyes perquè, per motius de seguretat, és l'única forma"
|
||||
" de desxifrar les dades. Per habilitar el xifrat, introduïu la vostra "
|
||||
"contrasenya."
|
||||
|
||||
#: packages/app-desktop/checkForUpdates.ts:199
|
||||
@@ -1356,12 +1349,12 @@ msgstr "Emacs"
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.tsx:236
|
||||
#: packages/server/src/routes/admin/emails.ts:128
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
msgstr "Correu electrònic"
|
||||
|
||||
#: packages/server/src/routes/admin/emails.ts:112
|
||||
#: packages/server/src/services/MustacheService.ts:152
|
||||
msgid "Emails"
|
||||
msgstr ""
|
||||
msgstr "Correus electrònics"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx:194
|
||||
msgid "emphasised text"
|
||||
@@ -1474,7 +1467,8 @@ msgid ""
|
||||
"re-synchronised and sent encrypted to the sync target."
|
||||
msgstr ""
|
||||
"Habilitar el xifrat significa que totes les notes i adjunts es tornaran a "
|
||||
"sincronitzar i s'enviaran de forma xifrada a la destinació de sincronització."
|
||||
"sincronitzar i s'enviaran de forma xifrada a la destinació de "
|
||||
"sincronització."
|
||||
|
||||
#: packages/lib/models/BaseItem.ts:808
|
||||
msgid "Encrypted"
|
||||
@@ -1607,8 +1601,8 @@ msgid ""
|
||||
"Exports Joplin data to the given path. By default, it will export the "
|
||||
"complete database including notebooks, notes, tags and resources."
|
||||
msgstr ""
|
||||
"Exporta les dades del Joplin al camí indicat. Per defecte, exportarà tota la "
|
||||
"base de dades, incloent-hi blocs de notes, notes, etiquetes i recursos."
|
||||
"Exporta les dades del Joplin al camí indicat. Per defecte, exportarà tota la"
|
||||
" base de dades, incloent-hi blocs de notes, notes, etiquetes i recursos."
|
||||
|
||||
#: packages/app-cli/app/command-export.js:23
|
||||
msgid "Exports only the given note."
|
||||
@@ -1689,7 +1683,8 @@ msgid "Folders"
|
||||
msgstr "Carpetes"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:528
|
||||
msgid "For debugging purpose only: export your profile to an external SD card."
|
||||
msgid ""
|
||||
"For debugging purpose only: export your profile to an external SD card."
|
||||
msgstr ""
|
||||
"Només en cas depuració: exporta el vostre perfil a una targeta SD externa."
|
||||
|
||||
@@ -1701,8 +1696,8 @@ msgstr ""
|
||||
|
||||
#: packages/app-mobile/components/screens/encryption-config.tsx:291
|
||||
msgid ""
|
||||
"For more information about End-To-End Encryption (E2EE) and advice on how to "
|
||||
"enable it please check the documentation:"
|
||||
"For more information about End-To-End Encryption (E2EE) and advice on how to"
|
||||
" enable it please check the documentation:"
|
||||
msgstr ""
|
||||
"Per a més informació sobre el xifratge d'extrem a extrem (E2EE de l'anglès) "
|
||||
"i consells sobre com activar-lo, llegiu la documentació:"
|
||||
@@ -1761,8 +1756,8 @@ msgstr "Obtén prellançaments quan cerqui actualitzacions"
|
||||
#: packages/app-cli/app/command-config.js:13
|
||||
msgid ""
|
||||
"Gets or sets a config value. If [value] is not provided, it will show the "
|
||||
"value of [name]. If neither [name] nor [value] is provided, it will list the "
|
||||
"current configuration."
|
||||
"value of [name]. If neither [name] nor [value] is provided, it will list the"
|
||||
" current configuration."
|
||||
msgstr ""
|
||||
"Obté o estableix un valor de configuració. Si no s'indica [valor], mostrarà "
|
||||
"el valor de [nom]. Si no s'indica ni [nom] ni [valor], es mostrarà un "
|
||||
@@ -1852,7 +1847,7 @@ msgstr "Ignora els errors de certificat TLS"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:102
|
||||
msgid "Images"
|
||||
msgstr ""
|
||||
msgstr "Imatges"
|
||||
|
||||
#: packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.tsx:170
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:484
|
||||
@@ -1896,44 +1891,35 @@ msgstr ""
|
||||
|
||||
#: packages/app-mobile/components/screens/Note.tsx:402
|
||||
msgid ""
|
||||
"In order to associate a geo-location with the note, the app needs your "
|
||||
"permission to access your location.\n"
|
||||
"In order to associate a geo-location with the note, the app needs your permission to access your location.\n"
|
||||
"\n"
|
||||
"You may turn off this option at any time in the Configuration screen."
|
||||
msgstr ""
|
||||
"Per tal d'associar una geolocalització a la nota, l'aplicació necessita "
|
||||
"permís per a accedir a la vostra ubicació.\n"
|
||||
"Per tal d'associar una geolocalització a la nota, l'aplicació necessita permís per a accedir a la vostra ubicació.\n"
|
||||
"\n"
|
||||
"Podeu desactivar aquesta opció en qualsevol moment a la pantalla de "
|
||||
"configuració."
|
||||
"Podeu desactivar aquesta opció en qualsevol moment a la pantalla de configuració."
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:95
|
||||
msgid ""
|
||||
"In order to do so, your entire data set will have to be encrypted and "
|
||||
"synchronised, so it is best to run it overnight.\n"
|
||||
"In order to do so, your entire data set will have to be encrypted and synchronised, so it is best to run it overnight.\n"
|
||||
"\n"
|
||||
"To start, please follow these instructions:\n"
|
||||
"\n"
|
||||
"1. Synchronise all your devices.\n"
|
||||
"2. Click \"%s\".\n"
|
||||
"3. Let it run to completion. While it runs, avoid changing any note on your "
|
||||
"other devices, to avoid conflicts.\n"
|
||||
"4. Once sync is done on this device, sync all your other devices and let it "
|
||||
"run to completion.\n"
|
||||
"3. Let it run to completion. While it runs, avoid changing any note on your other devices, to avoid conflicts.\n"
|
||||
"4. Once sync is done on this device, sync all your other devices and let it run to completion.\n"
|
||||
"\n"
|
||||
"Important: you only need to run this ONCE on one device."
|
||||
msgstr ""
|
||||
"Per a fer-ho, totes les vostres dades hauran de ser encriptades i "
|
||||
"sincronitzades, per tant és millor executar-ho durant la nit.\n"
|
||||
"Per a fer-ho, totes les vostres dades hauran de ser encriptades i sincronitzades, per tant és millor executar-ho durant la nit.\n"
|
||||
"\n"
|
||||
"Per a començar, seguiu aquestes instruccions:\n"
|
||||
"\n"
|
||||
"1. Sincronitzeu tots els vostres dispositius.\n"
|
||||
"2. Feu click en \"%s\".\n"
|
||||
"3. Deixeu-lo executant-se fins que acabi. Mentre s'executa, eviteu fer "
|
||||
"canvis en cap nota des dels altres dispositius per a evitar els conflictes.\n"
|
||||
"4. Un cop la sincronització està acabada en aquest dispositiu, sincronitzeu "
|
||||
"tots els altres dispositius, i deixeu-los executant-se fins que acabin.\n"
|
||||
"3. Deixeu-lo executant-se fins que acabi. Mentre s'executa, eviteu fer canvis en cap nota des dels altres dispositius per a evitar els conflictes.\n"
|
||||
"4. Un cop la sincronització està acabada en aquest dispositiu, sincronitzeu tots els altres dispositius, i deixeu-los executant-se fins que acabin.\n"
|
||||
"\n"
|
||||
"Important: només cal executar això UNA SOLA VEGADA en un dispositiu."
|
||||
|
||||
@@ -2082,11 +2068,11 @@ msgstr "Fitxer d'exportació del Joplin"
|
||||
#: packages/lib/services/ReportService.ts:210
|
||||
msgid ""
|
||||
"Joplin failed to decrypt these items multiple times, possibly because they "
|
||||
"are corrupted or too large. These items will remain on the device but Joplin "
|
||||
"will no longer attempt to decrypt them."
|
||||
"are corrupted or too large. These items will remain on the device but Joplin"
|
||||
" will no longer attempt to decrypt them."
|
||||
msgstr ""
|
||||
"Joplin no ha pogut desxifrar aquests elements diverses vegades, possiblement "
|
||||
"perquè estan danyats o són massa grans. Aquests elements romandran al "
|
||||
"Joplin no ha pogut desxifrar aquests elements diverses vegades, possiblement"
|
||||
" perquè estan danyats o són massa grans. Aquests elements romandran al "
|
||||
"dispositiu, però Joplin ja no intentarà desxifrar-los."
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:713
|
||||
@@ -2112,8 +2098,8 @@ msgstr "URL del servidor Joplin"
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:137
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:128
|
||||
msgid ""
|
||||
"Joplin Web Clipper allows saving web pages and screenshots from your browser "
|
||||
"to Joplin."
|
||||
"Joplin Web Clipper allows saving web pages and screenshots from your browser"
|
||||
" to Joplin."
|
||||
msgstr ""
|
||||
"El porta-retalls de webs del Joplin us permet desar pàgines web i captures "
|
||||
"de pantalla del navegador web al Joplin."
|
||||
@@ -2261,7 +2247,7 @@ msgstr "Desconnecta"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:179
|
||||
msgid "Logs"
|
||||
msgstr ""
|
||||
msgstr "Registres"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:716
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:583
|
||||
@@ -2407,8 +2393,7 @@ msgid "New Notebook"
|
||||
msgstr "Bloc de notes nou"
|
||||
|
||||
#: packages/app-desktop/gui/ImportScreen.min.js:61
|
||||
msgid ""
|
||||
"New notebook \"%s\" will be created and file \"%s\" will be imported into it"
|
||||
msgid "New notebook \"%s\" will be created and file \"%s\" will be imported into it"
|
||||
msgstr "Es crearà un bloc de notes «%s» i s'hi importarà el fitxer «%s»"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/newSubFolder.ts:6
|
||||
@@ -2719,9 +2704,8 @@ msgid "Or create an account."
|
||||
msgstr "O creeu un compte."
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:352
|
||||
#, fuzzy
|
||||
msgid "Other applications..."
|
||||
msgstr "Surt de l'aplicació."
|
||||
msgstr "Altres aplicacions"
|
||||
|
||||
#: packages/app-cli/app/command-import.js:27
|
||||
msgid "Output format: %s"
|
||||
@@ -2788,8 +2772,8 @@ msgstr "Confirmeu que voleu tornar a xifrar la vostra base de dades completa."
|
||||
|
||||
#: packages/lib/components/EncryptionConfigScreen/utils.ts:208
|
||||
msgid ""
|
||||
"Please enter your password in the master key list below before upgrading the "
|
||||
"key."
|
||||
"Please enter your password in the master key list below before upgrading the"
|
||||
" key."
|
||||
msgstr ""
|
||||
"Entreu la vostra contrasenya en la llista de claus mestres d'aquí a sota "
|
||||
"abans d'actualitzar la clau."
|
||||
@@ -2842,8 +2826,8 @@ msgstr "Si us plau, actualitzeu Joplin per a utilitzar aquesta extensió"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx:1164
|
||||
msgid ""
|
||||
"Please wait for all attachments to be downloaded and decrypted. You may also "
|
||||
"switch to %s to edit the note."
|
||||
"Please wait for all attachments to be downloaded and decrypted. You may also"
|
||||
" switch to %s to edit the note."
|
||||
msgstr ""
|
||||
"Espereu que tots els adjunts hagin estat descarregats i desxifrats. També "
|
||||
"podeu canviar a %s per a editar la nota."
|
||||
@@ -2924,19 +2908,19 @@ msgstr "Política de privacitat"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:35
|
||||
msgid "Process failed payment subscriptions"
|
||||
msgstr ""
|
||||
msgstr "Processa els pagaments de subscripcions fallits"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:33
|
||||
msgid "Process oversized accounts"
|
||||
msgstr ""
|
||||
msgstr "Processa els comptes més grans del permés"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:38
|
||||
msgid "Process user deletions"
|
||||
msgstr ""
|
||||
msgstr "Processa les supressions d'usuaris"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:168
|
||||
msgid "Profile"
|
||||
msgstr ""
|
||||
msgstr "Perfil"
|
||||
|
||||
#: packages/lib/versionInfo.ts:26
|
||||
msgid "Profile Version: %s"
|
||||
@@ -3216,14 +3200,12 @@ msgid "Select all"
|
||||
msgstr "Seleccioneu tot"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:140
|
||||
#, fuzzy
|
||||
msgid "Select emoji..."
|
||||
msgstr "Seleccioneu una data"
|
||||
msgstr "Selecciona un emoji..."
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:144
|
||||
#, fuzzy
|
||||
msgid "Select file..."
|
||||
msgstr "Seleccioneu tot"
|
||||
msgstr "Selecciona un fitxer..."
|
||||
|
||||
#: packages/app-cli/app/command-server.js:38
|
||||
msgid "Server is already running on port %d"
|
||||
@@ -3253,8 +3235,8 @@ msgid ""
|
||||
"Set it to 0 to make it take the complete available space. Recommended width "
|
||||
"is 600."
|
||||
msgstr ""
|
||||
"Definiu-ho a 0 perquè ocupe tot l'espai disponible. L'amplària recomanada és "
|
||||
"600."
|
||||
"Definiu-ho a 0 perquè ocupe tot l'espai disponible. L'amplària recomanada és"
|
||||
" 600."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.tsx:627
|
||||
msgid "Set the password"
|
||||
@@ -3262,13 +3244,11 @@ msgstr "Establiu la contrasenya"
|
||||
|
||||
#: packages/app-cli/app/command-set.js:22
|
||||
msgid ""
|
||||
"Sets the property <name> of the given <note> to the given [value]. Possible "
|
||||
"properties are:\n"
|
||||
"Sets the property <name> of the given <note> to the given [value]. Possible properties are:\n"
|
||||
"\n"
|
||||
"%s"
|
||||
msgstr ""
|
||||
"Estableix la propietat <name> de la <note> indicada al [valor] donat. Les "
|
||||
"propietats possibles són:\n"
|
||||
"Estableix la propietat <name> de la <note> indicada al [valor] donat. Les propietats possibles són:\n"
|
||||
"\n"
|
||||
"%s"
|
||||
|
||||
@@ -3363,7 +3343,8 @@ msgstr "Alguns elements no s'han pogut sincronitzar."
|
||||
|
||||
#: packages/app-mobile/components/screen-header.js:460
|
||||
msgid "Some items cannot be synchronised. Press for more info."
|
||||
msgstr "Alguns elements no es poden sincronitzar. Premeu per a més informació."
|
||||
msgstr ""
|
||||
"Alguns elements no es poden sincronitzar. Premeu per a més informació."
|
||||
|
||||
#: packages/lib/models/Setting.ts:922
|
||||
msgid "Sort notebooks by"
|
||||
@@ -3414,8 +3395,8 @@ msgstr "Inicieu l'aplicació minimitzada a la icona de la safata"
|
||||
|
||||
#: packages/app-cli/app/command-server.js:14
|
||||
msgid ""
|
||||
"Start, stop or check the API server. To specify on which port it should run, "
|
||||
"set the api.port config variable. Commands are (%s)."
|
||||
"Start, stop or check the API server. To specify on which port it should run,"
|
||||
" set the api.port config variable. Commands are (%s)."
|
||||
msgstr ""
|
||||
"Arrenca, atura o verifica el servidor API. Per a especificar a quin port ha "
|
||||
"de córrer, estableix la variable api.port. Les ordres són (%s)."
|
||||
@@ -3662,8 +3643,8 @@ msgstr ""
|
||||
msgid ""
|
||||
"The app is now going to close. Please relaunch it to complete the process."
|
||||
msgstr ""
|
||||
"L'aplicació es tancarà ara. Si us plau, torneu-la a executar per a completar "
|
||||
"el procés."
|
||||
"L'aplicació es tancarà ara. Si us plau, torneu-la a executar per a completar"
|
||||
" el procés."
|
||||
|
||||
#: packages/lib/onedrive-api-node-utils.js:86
|
||||
msgid ""
|
||||
@@ -3720,11 +3701,11 @@ msgstr ""
|
||||
|
||||
#: packages/lib/models/Setting.ts:1230
|
||||
msgid ""
|
||||
"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."
|
||||
"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."
|
||||
msgstr ""
|
||||
"L'ordre de l'editor (que pot incloure arguments) que s'usarà per a obrir una "
|
||||
"nota. Si no se'n proporciona cap, es detectarà automàticament l'editor "
|
||||
"L'ordre de l'editor (que pot incloure arguments) que s'usarà per a obrir una"
|
||||
" nota. Si no se'n proporciona cap, es detectarà automàticament l'editor "
|
||||
"predeterminat."
|
||||
|
||||
#: packages/lib/models/Setting.ts:1382 packages/lib/models/Setting.ts:1396
|
||||
@@ -3751,14 +3732,15 @@ msgid ""
|
||||
"recommended to upgrade them. The upgraded key will still be able to decrypt "
|
||||
"and encrypt your data as usual."
|
||||
msgstr ""
|
||||
"Les següents claus utilitzen un algorisme de xifrat obsolet i és recomanable "
|
||||
"actualitzar-les. La clau actualitzada podrà xifrar i desxifrar les dades com "
|
||||
"és habitual."
|
||||
"Les següents claus utilitzen un algorisme de xifrat obsolet i és recomanable"
|
||||
" actualitzar-les. La clau actualitzada podrà xifrar i desxifrar les dades "
|
||||
"com és habitual."
|
||||
|
||||
#: packages/app-mobile/components/screens/Note.tsx:193
|
||||
msgid "The Joplin mobile app does not currently support this type of link: %s"
|
||||
msgstr ""
|
||||
"L'aplicació mòbil del Joplin, ara per ara, no admet aquest tipus d'enllaç: %s"
|
||||
"L'aplicació mòbil del Joplin, ara per ara, no admet aquest tipus d'enllaç: "
|
||||
"%s"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:246
|
||||
msgid ""
|
||||
@@ -3785,8 +3767,8 @@ msgstr "La clau mestra s'ha actualitzat amb èxit!"
|
||||
#: packages/app-mobile/components/screens/encryption-config.tsx:272
|
||||
msgid ""
|
||||
"The master keys with these IDs are used to encrypt some of your items, "
|
||||
"however the application does not currently have access to them. It is likely "
|
||||
"they will eventually be downloaded via synchronisation."
|
||||
"however the application does not currently have access to them. It is likely"
|
||||
" they will eventually be downloaded via synchronisation."
|
||||
msgstr ""
|
||||
"Les claus mestres amb aquests ID s'usen per a xifrar alguns dels elements. "
|
||||
"Tot i això l'aplicació actualment no hi té accés. Probablement es baixaran "
|
||||
@@ -3870,7 +3852,8 @@ msgid "Theme"
|
||||
msgstr "Tema"
|
||||
|
||||
#: packages/app-mobile/components/note-list.js:105
|
||||
msgid "There are currently no notes. Create one by clicking on the (+) button."
|
||||
msgid ""
|
||||
"There are currently no notes. Create one by clicking on the (+) button."
|
||||
msgstr "Ara mateix no hi ha cap nota. Creeu-ne una fent clic en el botó (+)."
|
||||
|
||||
#: packages/app-desktop/gui/NoteList/NoteList.tsx:469
|
||||
@@ -3900,14 +3883,11 @@ msgstr "Hi ha hagut un error en baixar aquest adjunt:"
|
||||
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.tsx:224
|
||||
msgid ""
|
||||
"There was an error setting up your Joplin Cloud account. Please verify your "
|
||||
"email and password and try again. Error was:\n"
|
||||
"There was an error setting up your Joplin Cloud account. Please verify your email and password and try again. Error was:\n"
|
||||
"\n"
|
||||
"%s"
|
||||
msgstr ""
|
||||
"S'ha produït un error configurant el vostre compte de Joplin Cloud. "
|
||||
"Verifiqueu el vostre correu electrònic i la contrasenya, i proveu de nou. "
|
||||
"L'error ha sigut:\n"
|
||||
"S'ha produït un error configurant el vostre compte de Joplin Cloud. Verifiqueu el vostre correu electrònic i la contrasenya, i proveu de nou. L'error ha sigut:\n"
|
||||
"\n"
|
||||
"%s"
|
||||
|
||||
@@ -3923,8 +3903,8 @@ msgstr ""
|
||||
|
||||
#: packages/lib/models/Setting.ts:2160
|
||||
msgid ""
|
||||
"These plugins enhance the Markdown renderer with additional features. Please "
|
||||
"note that, while these features might be useful, they are not standard "
|
||||
"These plugins enhance the Markdown renderer with additional features. Please"
|
||||
" note that, while these features might be useful, they are not standard "
|
||||
"Markdown and thus most of them will only work in Joplin. Additionally, some "
|
||||
"of them are *incompatible* with the WYSIWYG editor. If you open a note that "
|
||||
"uses one of these plugins in that editor, you will lose the plugin "
|
||||
@@ -3951,8 +3931,8 @@ msgstr "Aquest adjunt no s'ha descarregat o no s'ha desxifrat encara."
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:214
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:155
|
||||
msgid ""
|
||||
"This authorisation token is only needed to allow third-party applications to "
|
||||
"access Joplin."
|
||||
"This authorisation token is only needed to allow third-party applications to"
|
||||
" access Joplin."
|
||||
msgstr ""
|
||||
"Aquest testimoni d'autorització només és necessari per a permetre l'accés "
|
||||
"d'aplicacions de tercers al Joplin."
|
||||
@@ -3963,9 +3943,9 @@ msgid ""
|
||||
"notes. Please be careful when deleting one of them as they cannot be "
|
||||
"restored afterwards."
|
||||
msgstr ""
|
||||
"Aquesta és una eina avançada per a mostrar els adjunts que estan enllaçats a "
|
||||
"les vostres notes. Tingueu precaució en suprimir-ne un, ja que després no es "
|
||||
"poden restaurar."
|
||||
"Aquesta és una eina avançada per a mostrar els adjunts que estan enllaçats a"
|
||||
" les vostres notes. Tingueu precaució en suprimir-ne un, ja que després no "
|
||||
"es poden restaurar."
|
||||
|
||||
#: packages/lib/models/Note.ts:103
|
||||
msgid "This note does not have geolocation information."
|
||||
@@ -3992,15 +3972,15 @@ msgid ""
|
||||
"This Rich Text editor has a number of limitations and it is recommended to "
|
||||
"be aware of them before using it."
|
||||
msgstr ""
|
||||
"Aquest editor de text enriquit té una sèrie de limitacions i es recomana ser "
|
||||
"conscient d'elles abans d'utilitzar-lo."
|
||||
"Aquest editor de text enriquit té una sèrie de limitacions i es recomana ser"
|
||||
" conscient d'elles abans d'utilitzar-lo."
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:155
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:133
|
||||
msgid ""
|
||||
"This service allows the browser extension to communicate with Joplin. When "
|
||||
"enabling it your firewall may ask you to give permission to Joplin to listen "
|
||||
"to a particular port."
|
||||
"enabling it your firewall may ask you to give permission to Joplin to listen"
|
||||
" to a particular port."
|
||||
msgstr ""
|
||||
"Aquest servei permet que l'extensió del navegador pugui comunicar-se amb el "
|
||||
"Joplin. En activar-la, el tallafoc us podria demanar de donar permís al "
|
||||
@@ -4008,8 +3988,8 @@ msgstr ""
|
||||
|
||||
#: packages/lib/models/Setting.ts:1046
|
||||
msgid ""
|
||||
"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 "
|
||||
"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."
|
||||
msgstr ""
|
||||
"Això permetrà que Joplin s’executi en segon pla. Es recomana habilitar "
|
||||
@@ -4076,8 +4056,8 @@ msgid ""
|
||||
"To manually sort the notes, the sort order must be changed to \"%s\" in the "
|
||||
"menu \"%s\" > \"%s\""
|
||||
msgstr ""
|
||||
"Per a ordenar les notes manualment, l'ordre de classificació s'ha de canviar "
|
||||
"a \"%s\" en el menú \"%s\" > \"%s\""
|
||||
"Per a ordenar les notes manualment, l'ordre de classificació s'ha de canviar"
|
||||
" a \"%s\" en el menú \"%s\" > \"%s\""
|
||||
|
||||
#: packages/app-cli/app/command-help.js:81
|
||||
msgid "To maximise/minimise the console, press \"tc\"."
|
||||
@@ -4091,8 +4071,8 @@ msgstr "Per a desplaçar-vos d'un panell a un altre, premeu Tab o Maj+Tab."
|
||||
msgid ""
|
||||
"To retry decryption of these items. Run `e2ee decrypt --retry-failed-items`"
|
||||
msgstr ""
|
||||
"Per a reintentar el desxifratge d'aquests elements. Executa \"e2ee decrypt --"
|
||||
"retry-failed-items\""
|
||||
"Per a reintentar el desxifratge d'aquests elements. Executa \"e2ee decrypt "
|
||||
"--retry-failed-items\""
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:559
|
||||
msgid ""
|
||||
@@ -4100,7 +4080,8 @@ msgid ""
|
||||
"them in your phone settings, in Apps > Joplin > Permissions"
|
||||
msgstr ""
|
||||
"Per a funcionar correctament, l'aplicació requereix els permisos següents. "
|
||||
"Habiliteu-los a la configuració del telèfon a Aplicacions > Joplin > Permisos"
|
||||
"Habiliteu-los a la configuració del telèfon a Aplicacions > Joplin > "
|
||||
"Permisos"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.tsx:110
|
||||
msgid "to-do"
|
||||
@@ -4168,8 +4149,8 @@ msgid ""
|
||||
"Type `help [command]` for more information about a command; or type `help "
|
||||
"all` for the complete usage information."
|
||||
msgstr ""
|
||||
"Escriviu «help [ordre]» per a més informació sobre l'ordre; o escriviu «help "
|
||||
"all» per a la informació d'ús completa."
|
||||
"Escriviu «help [ordre]» per a més informació sobre l'ordre; o escriviu «help"
|
||||
" all» per a la informació d'ús completa."
|
||||
|
||||
#: packages/app-cli/app/main.js:93
|
||||
msgid "Type `joplin help` for usage information."
|
||||
@@ -4181,9 +4162,9 @@ msgid ""
|
||||
"by a tag name, or @ followed by a notebook name. Or type : to search for "
|
||||
"commands."
|
||||
msgstr ""
|
||||
"Escriviu un títol de nota o part del seu contingut per a saltar a la nota. O "
|
||||
"escriviu # seguit d'un nom d'etiqueta, o @ seguit d'un nom de bloc de notes. "
|
||||
"O escriviu \":\" per a cercar entre les ordres."
|
||||
"Escriviu un títol de nota o part del seu contingut per a saltar a la nota. O"
|
||||
" escriviu # seguit d'un nom d'etiqueta, o @ seguit d'un nom de bloc de "
|
||||
"notes. O escriviu \":\" per a cercar entre les ordres."
|
||||
|
||||
#: packages/app-mobile/components/screens/NoteTagsDialog.js:180
|
||||
msgid "Type new tags or select from list"
|
||||
@@ -4249,14 +4230,12 @@ msgstr "Actualització"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:171
|
||||
#: packages/server/src/routes/index/users.ts:89
|
||||
#, fuzzy
|
||||
msgid "Update profile"
|
||||
msgstr "Exporta el perfil"
|
||||
msgstr "Actualitza el perfil"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:32
|
||||
#, fuzzy
|
||||
msgid "Update total sizes"
|
||||
msgstr "Elements locals actualitzats: %d."
|
||||
msgstr "Actualitza les mides totals"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:208
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:209
|
||||
@@ -4310,7 +4289,8 @@ msgstr "URL"
|
||||
msgid "Usage: %s"
|
||||
msgstr "Ús: %s"
|
||||
|
||||
#: packages/app-cli/app/command-ls.js:32 packages/app-cli/app/command-tag.js:18
|
||||
#: packages/app-cli/app/command-ls.js:32
|
||||
#: packages/app-cli/app/command-tag.js:18
|
||||
msgid ""
|
||||
"Use long list format. Format is ID, NOTE_COUNT (for notebook), DATE, "
|
||||
"TODO_CHECKED (for to-dos), TITLE"
|
||||
@@ -4358,13 +4338,13 @@ msgid ""
|
||||
"tables, checkboxes, code). If not found, a generic monospace (fixed width) "
|
||||
"font is used."
|
||||
msgstr ""
|
||||
"S'utilitza quan es necessita un tipus de lletra d'amplada fixa per a mostrar "
|
||||
"text de manera llegible (p. ex. taules, caselles de selecció, codi). Si no "
|
||||
"S'utilitza quan es necessita un tipus de lletra d'amplada fixa per a mostrar"
|
||||
" text de manera llegible (p. ex. taules, caselles de selecció, codi). Si no "
|
||||
"es troba, s'utilitza un tipus de lletra genèric monoespai (amplada fixa)."
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:144
|
||||
msgid "User deletions"
|
||||
msgstr ""
|
||||
msgstr "Supressions d'usuari"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:107
|
||||
#: packages/server/src/services/MustacheService.ts:140
|
||||
@@ -4438,18 +4418,15 @@ msgstr "Lloc web i documentació"
|
||||
msgid ""
|
||||
"Welcome to Joplin!\n"
|
||||
"\n"
|
||||
"Type `:help shortcuts` for the list of keyboard shortcuts, or just `:help` "
|
||||
"for usage information.\n"
|
||||
"Type `:help shortcuts` for the list of keyboard shortcuts, or just `:help` for usage information.\n"
|
||||
"\n"
|
||||
"For example, to create a notebook press `mb`; to create a note press `mn`."
|
||||
msgstr ""
|
||||
"Us donem la benvinguda al Joplin!\n"
|
||||
"\n"
|
||||
"Escriviu «:help shortcuts» per a llistar les dreceres de teclat, o "
|
||||
"simplement «:help» per a informació d'ús.\n"
|
||||
"Escriviu «:help shortcuts» per a llistar les dreceres de teclat, o simplement «:help» per a informació d'ús.\n"
|
||||
"\n"
|
||||
"Per exemple, per a crear un bloc de notes premeu «mb»; per a crear una nota "
|
||||
"premeu «mn»."
|
||||
"Per exemple, per a crear un bloc de notes premeu «mb»; per a crear una nota premeu «mn»."
|
||||
|
||||
#: packages/lib/models/Setting.ts:975
|
||||
msgid "When creating a new note:"
|
||||
@@ -4487,8 +4464,8 @@ msgid ""
|
||||
"You are about to attach a large image (%dx%d pixels). Would you like to "
|
||||
"resize it down to %d pixels before attaching it?"
|
||||
msgstr ""
|
||||
"Esteu a punt d'adjuntar una imatge gran (%dx%d pixels). Voleu reduir-la a %d "
|
||||
"pixels abans d'adjuntar-la?"
|
||||
"Esteu a punt d'adjuntar una imatge gran (%dx%d pixels). Voleu reduir-la a %d"
|
||||
" pixels abans d'adjuntar-la?"
|
||||
|
||||
#: packages/app-mobile/components/note-list.js:97
|
||||
msgid "You currently have no notebooks."
|
||||
|
||||
@@ -7,7 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Joplin-CLI 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: miucci\n"
|
||||
"PO-Revision-Date: 2022-03-26\n"
|
||||
"Last-Translator: mrkaato\n"
|
||||
"Language-Team: \n"
|
||||
"Language: fi_FI\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -265,11 +266,11 @@ msgstr "Lisää sanakirjaan"
|
||||
#: packages/server/src/services/MustacheService.ts:183
|
||||
#: packages/server/src/services/MustacheService.ts:307
|
||||
msgid "Admin"
|
||||
msgstr ""
|
||||
msgstr "Ylläpitäjä"
|
||||
|
||||
#: packages/server/src/routes/admin/dashboard.ts:10
|
||||
msgid "Admin dashboard"
|
||||
msgstr ""
|
||||
msgstr "Ylläpitäjän hallintapaneeli"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:189
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:147
|
||||
@@ -340,7 +341,7 @@ msgstr "Liitä valokuva"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note.tsx:880
|
||||
msgid "Attach..."
|
||||
msgstr "Liitä..."
|
||||
msgstr "Liittää..."
|
||||
|
||||
#: packages/app-cli/app/command-attach.js:13
|
||||
msgid "Attaches the given file to the note."
|
||||
@@ -395,7 +396,7 @@ msgstr "Automaattinen"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:39
|
||||
msgid "Auto-add disabled accounts for deletion"
|
||||
msgstr ""
|
||||
msgstr "Lisää käytöstä poistetut tilit automaattisesti poistettavaksi"
|
||||
|
||||
#: packages/lib/models/Setting.ts:855
|
||||
msgid "Auto-pair braces, parenthesis, quotations, etc."
|
||||
@@ -558,6 +559,9 @@ msgid ""
|
||||
"enabled end-to-end encryption. They may do so from the screen Configuration "
|
||||
"> Encryption."
|
||||
msgstr ""
|
||||
"Salattua muistikirjaa ei voi jakaa vastaanottajan %s kanssa, koska hän ei "
|
||||
"ole ottanut päästä päähän -salausta käyttöön. Sen voi tehdä Asetukset> "
|
||||
"Salauksen määritys."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.ts:7
|
||||
msgid "Change application layout"
|
||||
@@ -706,12 +710,12 @@ msgstr "Valmis: %s (%s)"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:37
|
||||
msgid "Compress old changes"
|
||||
msgstr ""
|
||||
msgstr "Pakkaa vanhat muutokset"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:637
|
||||
#: packages/app-mobile/components/side-menu-content.js:332
|
||||
msgid "Configuration"
|
||||
msgstr "Konfigurointi"
|
||||
msgstr "Asetukset"
|
||||
|
||||
#: packages/app-mobile/components/screens/encryption-config.tsx:137
|
||||
msgid "Confirm password cannot be empty"
|
||||
@@ -739,9 +743,8 @@ msgid "Conflicts (attachments)"
|
||||
msgstr "Ristiriidat (liitteet)"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.tsx:108
|
||||
#, fuzzy
|
||||
msgid "Content provided by %s"
|
||||
msgstr "Sisällön ominaisuudet"
|
||||
msgstr "Sisällön toimittaa %s"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note.tsx:933
|
||||
msgid "Convert to note"
|
||||
@@ -765,9 +768,8 @@ msgstr "Kopioi kehitystila komento leikepöydälle"
|
||||
#: packages/app-desktop/gui/Sidebar/Sidebar.tsx:355
|
||||
#: packages/app-desktop/gui/Sidebar/Sidebar.tsx:369
|
||||
#: packages/app-desktop/gui/utils/NoteListUtils.ts:139
|
||||
#, fuzzy
|
||||
msgid "Copy external link"
|
||||
msgstr "Lopeta ulkoinen muokkaus"
|
||||
msgstr "Kopioi ulkoinen linkki"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:134
|
||||
msgid "Copy image"
|
||||
@@ -824,9 +826,8 @@ msgstr ""
|
||||
"%s"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx:302
|
||||
#, fuzzy
|
||||
msgid "Could not connect to plugin repository."
|
||||
msgstr "Yhteyden muodostaminen laajennusten arkistoon epäonnistui"
|
||||
msgstr "Yhteyden muodostaminen laajennusten arkistoon epäonnistui."
|
||||
|
||||
#: packages/app-desktop/InteropServiceHelper.ts:190
|
||||
msgid "Could not export notes: %s"
|
||||
@@ -843,6 +844,10 @@ msgid ""
|
||||
"\n"
|
||||
"The error was: \"%s\""
|
||||
msgstr ""
|
||||
"Kutsuun ei voitu vastata. Yritä uudelleen tai tarkista muistikirjan "
|
||||
"omistajalta, jakaako hän sitä edelleen.\n"
|
||||
"\n"
|
||||
"Virhe oli: \"%s\""
|
||||
|
||||
#: packages/lib/components/EncryptionConfigScreen/utils.ts:219
|
||||
msgid "Could not upgrade master key: %s"
|
||||
@@ -853,6 +858,8 @@ msgid ""
|
||||
"Could not verify the share status of this notebook - aborting. Please try "
|
||||
"again when you are connected to the internet."
|
||||
msgstr ""
|
||||
"Tämän muistikirjan jaon tilaa ei voitu vahvistaa - keskeytetään. Yritä "
|
||||
"uudelleen, kun olet muodostanut yhteyden Internetiin."
|
||||
|
||||
#: packages/app-mobile/components/note-list.js:101
|
||||
msgid "Create a notebook"
|
||||
@@ -956,7 +963,7 @@ msgstr "Tumma"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:136
|
||||
msgid "Dashboard"
|
||||
msgstr ""
|
||||
msgstr "Koontinäyttö"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:625
|
||||
msgid "Database v%s"
|
||||
@@ -1013,14 +1020,12 @@ msgid "Delete attachment \"%s\"?"
|
||||
msgstr "Poista muistiinpano \"%s\"?"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:36
|
||||
#, fuzzy
|
||||
msgid "Delete expired sessions"
|
||||
msgstr "Ota matemaattiset lausekkeet käyttöön"
|
||||
msgstr "Poista vanhentuneet istunnot"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:31
|
||||
#, fuzzy
|
||||
msgid "Delete expired tokens"
|
||||
msgstr "Poistetaanko nämä %d muistiinpanot?"
|
||||
msgstr "Poista vanhentuneet tunnukset"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:88
|
||||
msgid "Delete line"
|
||||
@@ -1213,16 +1218,14 @@ msgid "Do not ask for confirmation."
|
||||
msgstr "Älä kysy vahvistusta."
|
||||
|
||||
#: packages/lib/components/EncryptionConfigScreen/utils.ts:56
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"Do not lose the password as, for security purposes, this will be the *only* "
|
||||
"way to decrypt the data! To enable encryption, please enter your password "
|
||||
"below."
|
||||
msgstr ""
|
||||
"Salaus otetaan käyttöön *kaikissa* muistiinpanosi ja liitteesi synkronoidaan "
|
||||
"uudelleen ja lähetetään salattuina synkronointikohteeseen. Älä unohda "
|
||||
"salasanaa, koska turvallisuussyistä tämä on *ainoa* tapa salauksen "
|
||||
"purkamiseen! Ota salaus käyttöön antamalla salasanasi alla."
|
||||
"Älä kadota salasanaa, sillä tämä on turvallisuussyistä *ainoa* tapa purkaa "
|
||||
"tietojen salaus! Voit ottaa salauksen käyttöön kirjoittamalla salasanasi "
|
||||
"alle."
|
||||
|
||||
#: packages/app-desktop/checkForUpdates.ts:199
|
||||
msgid "Download"
|
||||
@@ -1295,7 +1298,7 @@ msgstr "Muokkaa"
|
||||
|
||||
#: packages/app-desktop/commands/startExternalEditing.ts:10
|
||||
msgid "Edit in external editor"
|
||||
msgstr "Muokkaa ulkoisessa editorissa"
|
||||
msgstr "Muokkaa ulkoisessa tekstieditorissa"
|
||||
|
||||
#: packages/app-cli/app/command-edit.js:17
|
||||
msgid "Edit note."
|
||||
@@ -1456,14 +1459,13 @@ msgid "Enabled"
|
||||
msgstr "Käytössä"
|
||||
|
||||
#: packages/lib/components/EncryptionConfigScreen/utils.ts:51
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"Enabling encryption means *all* your notes and attachments are going to be "
|
||||
"re-synchronised and sent encrypted to the sync target."
|
||||
msgstr ""
|
||||
"Salaus poistetaan käytöstä *kaikista* muistiinpanosi ja liitteesi "
|
||||
"synkronoidaan uudelleen ja lähetetään salaamattomina synkronointikohteeseen. "
|
||||
"Haluatko jatkaa?"
|
||||
"Salauksen ottaminen käyttöön tarkoittaa, että *kaikki* muistiinpanosi ja "
|
||||
"liitteesi synkronoidaan uudelleen ja lähetetään salattuina "
|
||||
"synkronointikohteeseen."
|
||||
|
||||
#: packages/lib/models/BaseItem.ts:808
|
||||
msgid "Encrypted"
|
||||
@@ -1500,9 +1502,8 @@ msgid "Encryption:"
|
||||
msgstr "Salaus:"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:247
|
||||
#, fuzzy
|
||||
msgid "End-to-end encryption"
|
||||
msgstr "Ota salaus käyttöön"
|
||||
msgstr "Päästä päähän salaus"
|
||||
|
||||
#: packages/app-mobile/components/screens/dropbox-login.js:66
|
||||
msgid "Enter code here"
|
||||
@@ -1511,7 +1512,7 @@ msgstr "Syötä koodi tähän"
|
||||
#: packages/app-cli/app/command-e2ee.ts:38
|
||||
#: packages/app-cli/app/command-e2ee.ts:84
|
||||
msgid "Enter master password:"
|
||||
msgstr "Syötä pääsalasana:"
|
||||
msgstr "Kirjoita pääsalasana:"
|
||||
|
||||
#: packages/app-mobile/components/screens/folder.js:110
|
||||
msgid "Enter notebook title"
|
||||
@@ -1700,7 +1701,7 @@ msgstr ""
|
||||
|
||||
#: packages/lib/models/Setting.ts:569
|
||||
msgid "Force path style"
|
||||
msgstr ""
|
||||
msgstr "Pakota polun tyyli"
|
||||
|
||||
#: packages/lib/commands/historyForward.ts:6
|
||||
msgid "Forward"
|
||||
@@ -1725,9 +1726,8 @@ msgid "General"
|
||||
msgstr "Yleiset"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:252
|
||||
#, fuzzy
|
||||
msgid "Generated"
|
||||
msgstr "Yleiset"
|
||||
msgstr "Luotu"
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.tsx:207
|
||||
msgid "Generating link..."
|
||||
@@ -1779,9 +1779,8 @@ msgid "Hide %s"
|
||||
msgstr "Piilota %s"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:172
|
||||
#, fuzzy
|
||||
msgid "Hide disabled keys"
|
||||
msgstr "Piilota käytöstä poistetut pääavaimet"
|
||||
msgstr "Piilota käytöstä poistetut avaimet"
|
||||
|
||||
#: packages/app-desktop/gui/KeymapConfig/utils/getLabel.ts:22
|
||||
msgid "Hide Joplin"
|
||||
@@ -1794,7 +1793,7 @@ msgstr "Korosta"
|
||||
#: packages/server/src/services/MustacheService.ts:167
|
||||
#: packages/server/src/services/MustacheService.ts:300
|
||||
msgid "Home"
|
||||
msgstr ""
|
||||
msgstr "Koti"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:78
|
||||
msgid "Horizontal Rule"
|
||||
@@ -2011,7 +2010,7 @@ msgstr "Virheellinen asetusarvo: \"%s\". Mahdolliset arvot ovat: %s."
|
||||
|
||||
#: packages/app-cli/app/command-e2ee.ts:46
|
||||
msgid "Invalid password"
|
||||
msgstr "Virheellinen salasana"
|
||||
msgstr "Väärä salasana"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:38
|
||||
msgid "Italic"
|
||||
@@ -2024,7 +2023,7 @@ msgstr "Kohdetta \"%s\" ei voitu ladata: %s"
|
||||
#: packages/server/src/services/MustacheService.ts:175
|
||||
#: packages/server/src/services/MustacheService.ts:302
|
||||
msgid "Items"
|
||||
msgstr ""
|
||||
msgstr "Kohteet"
|
||||
|
||||
#: packages/lib/services/ReportService.ts:208
|
||||
msgid "Items that cannot be decrypted"
|
||||
@@ -2137,9 +2136,8 @@ msgid "Keychain Supported: %s"
|
||||
msgstr "Avainnippu tuettu: %s"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:71
|
||||
#, fuzzy
|
||||
msgid "Keys that need upgrading"
|
||||
msgstr "Pääavaimet, jotka on päivitettävä"
|
||||
msgstr "Päivitystä vaativat avaimet"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1244
|
||||
msgid "Landscape"
|
||||
@@ -2166,9 +2164,8 @@ msgid "Layout button sequence"
|
||||
msgstr "Aseta painike järjestys"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.ts:10
|
||||
#, fuzzy
|
||||
msgid "Leave notebook..."
|
||||
msgstr "Muistikirjan jakaminen..."
|
||||
msgstr "Poistu muistikirjasta..."
|
||||
|
||||
#: packages/lib/models/Setting.ts:1238
|
||||
msgid "Legal"
|
||||
@@ -2203,7 +2200,6 @@ msgid "List item"
|
||||
msgstr "Luettelon kohde"
|
||||
|
||||
#: packages/app-mobile/components/screens/encryption-config.tsx:212
|
||||
#, fuzzy
|
||||
msgid "Loaded"
|
||||
msgstr "Ladattu"
|
||||
|
||||
@@ -2244,11 +2240,11 @@ msgstr "Kirjaudu sisään OneDrive"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:306
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
msgstr "Uloskirjaus"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:179
|
||||
msgid "Logs"
|
||||
msgstr ""
|
||||
msgstr "Lokit"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:716
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:583
|
||||
@@ -2261,7 +2257,7 @@ msgstr "Pääsalasanan hallinta"
|
||||
|
||||
#: packages/lib/commands/openMasterPasswordDialog.ts:6
|
||||
msgid "Manage master password..."
|
||||
msgstr "Pääsalasanan hallinta..."
|
||||
msgstr "Hallitse pääsalasanaa..."
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx:314
|
||||
msgid "Manage your plugins"
|
||||
@@ -2269,12 +2265,11 @@ msgstr "Hallitse laajennuksia"
|
||||
|
||||
#. `generate-ppk`
|
||||
#: packages/app-cli/app/command-e2ee.ts:20
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"Manages E2EE configuration. Commands are `enable`, `disable`, `decrypt`, "
|
||||
"`status`, `decrypt-file`, and `target-status`."
|
||||
msgstr ""
|
||||
"Hallitsee E2EE-määrityksiä. Komennot ovat `enable`, `disable`, `decrypt`, "
|
||||
"Hallitse E2EE määrityksiä. Komennot ovat `enable`, `disable`, `decrypt`, "
|
||||
"`status`, `decrypt-file` ja `target-status`."
|
||||
|
||||
#: packages/lib/models/Setting.ts:667
|
||||
@@ -2290,7 +2285,7 @@ msgstr "Markdown"
|
||||
#: packages/lib/services/interop/InteropService.ts:119
|
||||
#: packages/lib/services/interop/InteropService.ts:66
|
||||
msgid "Markdown + Front Matter"
|
||||
msgstr ""
|
||||
msgstr "Markdown + Front Matter"
|
||||
|
||||
#: packages/app-cli/app/command-done.js:14
|
||||
msgid "Marks a to-do as done."
|
||||
@@ -2324,9 +2319,8 @@ msgid "Max concurrent connections"
|
||||
msgstr "Samanaikaiset yhteydet enintään"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:324
|
||||
#, fuzzy
|
||||
msgid "Missing keys"
|
||||
msgstr "Puuttuvat pääavaimet"
|
||||
msgstr "Puuttuvat avaimet"
|
||||
|
||||
#: packages/app-mobile/components/screens/encryption-config.tsx:271
|
||||
msgid "Missing Master Keys"
|
||||
@@ -2500,7 +2494,7 @@ msgstr "Ei ladattu"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:252
|
||||
msgid "Not generated"
|
||||
msgstr ""
|
||||
msgstr "Ei luotu"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.tsx:110
|
||||
#: packages/server/src/models/UserModel.ts:214
|
||||
@@ -2539,7 +2533,7 @@ msgstr "Huomautus on tallennettu."
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.min.js:34
|
||||
#: packages/lib/models/Setting.ts:2149
|
||||
msgid "Note History"
|
||||
msgstr "Muistiinpanohistoria"
|
||||
msgstr "Muistiinpano historia"
|
||||
|
||||
#: packages/app-cli/app/command-done.js:21
|
||||
msgid "Note is not a to-do: \"%s\""
|
||||
@@ -2707,9 +2701,8 @@ msgid "Or create an account."
|
||||
msgstr "Tai luo tili."
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:352
|
||||
#, fuzzy
|
||||
msgid "Other applications..."
|
||||
msgstr "Sulkee sovelluksen."
|
||||
msgstr "Muut sovellukset..."
|
||||
|
||||
#: packages/app-cli/app/command-import.js:27
|
||||
msgid "Output format: %s"
|
||||
@@ -2767,7 +2760,7 @@ msgstr "Lupa käyttää kameraa"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:271
|
||||
msgid "Please click on \"%s\" to proceed"
|
||||
msgstr ""
|
||||
msgstr "Napsauta \"%s\" jatkaaksesi"
|
||||
|
||||
#: packages/lib/components/EncryptionConfigScreen/utils.ts:65
|
||||
msgid ""
|
||||
@@ -2787,6 +2780,8 @@ msgid ""
|
||||
"Please note that if it is a large notebook, it may take a few minutes for "
|
||||
"all the notes to show up on the recipient's device."
|
||||
msgstr ""
|
||||
"Huomaa, että jos kyseessä on suuri muistikirja, voi kestää minuutteja, ennen "
|
||||
"kuin kaikki muistiinpanot näkyvät vastaanottajan laitteessa."
|
||||
|
||||
#: packages/lib/onedrive-api-node-utils.js:116
|
||||
msgid ""
|
||||
@@ -2910,19 +2905,19 @@ msgstr "Tietosuojakäytäntö"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:35
|
||||
msgid "Process failed payment subscriptions"
|
||||
msgstr ""
|
||||
msgstr "Käsittele epäonnistuneet maksutilaukset"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:33
|
||||
msgid "Process oversized accounts"
|
||||
msgstr ""
|
||||
msgstr "Käsittele ylisuuria tilejä"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:38
|
||||
msgid "Process user deletions"
|
||||
msgstr ""
|
||||
msgstr "Käsittele käyttäjien poistot"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:168
|
||||
msgid "Profile"
|
||||
msgstr ""
|
||||
msgstr "Profiili"
|
||||
|
||||
#: packages/lib/versionInfo.ts:26
|
||||
msgid "Profile Version: %s"
|
||||
@@ -2934,7 +2929,7 @@ msgstr "Ominaisuudet"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:252
|
||||
msgid "Public-private key pair:"
|
||||
msgstr ""
|
||||
msgstr "Julkinen ja yksityinen avainpari:"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.ts:6
|
||||
msgid "Publish note..."
|
||||
@@ -3042,9 +3037,8 @@ msgstr "Uusi tunnus"
|
||||
|
||||
#: packages/app-desktop/gui/MasterPasswordDialog/Dialog.tsx:212
|
||||
#: packages/app-desktop/gui/MasterPasswordDialog/Dialog.tsx:213
|
||||
#, fuzzy
|
||||
msgid "Reset master password"
|
||||
msgstr "Syötä pääsalasana:"
|
||||
msgstr "Pääsalasanan vaihtaminen"
|
||||
|
||||
#: packages/app-cli/app/command-import.js:51
|
||||
#: packages/app-desktop/gui/ImportScreen.min.js:72
|
||||
@@ -3105,31 +3099,27 @@ msgstr ""
|
||||
|
||||
#: packages/lib/SyncTargetAmazonS3.js:28
|
||||
msgid "S3"
|
||||
msgstr ""
|
||||
msgstr "S3"
|
||||
|
||||
#: packages/lib/models/Setting.ts:547
|
||||
#, fuzzy
|
||||
msgid "S3 access key"
|
||||
msgstr "AWS key"
|
||||
msgstr "S3 access key"
|
||||
|
||||
#: packages/lib/models/Setting.ts:507
|
||||
#, fuzzy
|
||||
msgid "S3 bucket"
|
||||
msgstr "AWS S3 bucket"
|
||||
msgstr "S3 bucket"
|
||||
|
||||
#: packages/lib/models/Setting.ts:536
|
||||
msgid "S3 region"
|
||||
msgstr ""
|
||||
msgstr "S3 region"
|
||||
|
||||
#: packages/lib/models/Setting.ts:558
|
||||
#, fuzzy
|
||||
msgid "S3 secret key"
|
||||
msgstr "AWS secret"
|
||||
msgstr "S3 secret key"
|
||||
|
||||
#: packages/lib/models/Setting.ts:522
|
||||
#, fuzzy
|
||||
msgid "S3 URL"
|
||||
msgstr "AWS S3 osoite (URL)"
|
||||
msgstr "S3 URL"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.tsx:579
|
||||
msgid ""
|
||||
@@ -3207,7 +3197,7 @@ msgstr "Valitse kaikki"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:140
|
||||
msgid "Select emoji..."
|
||||
msgstr ""
|
||||
msgstr "Valitse emoji..."
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:144
|
||||
msgid "Select file..."
|
||||
@@ -3237,11 +3227,12 @@ msgid "Set alarm:"
|
||||
msgstr "Aseta hälytys:"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1127
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"Set it to 0 to make it take the complete available space. Recommended width "
|
||||
"is 600."
|
||||
msgstr "Aseta arvoksi 0, jotta se vie koko käytettävissä olevan tilan."
|
||||
msgstr ""
|
||||
"Aseta se arvoksi 0, jotta se vie koko käytettävissä olevan tilan. Suositeltu "
|
||||
"leveys on 600."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.tsx:627
|
||||
msgid "Set the password"
|
||||
@@ -3294,18 +3285,16 @@ msgid "Show completed to-dos"
|
||||
msgstr "Näytä valmiit tehtävät"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:172
|
||||
#, fuzzy
|
||||
msgid "Show disabled keys"
|
||||
msgstr "Näytä käytöstä poistetut pääavaimet"
|
||||
msgstr "Näytä käytöstä poistetut avaimet"
|
||||
|
||||
#: packages/lib/models/Setting.ts:812
|
||||
msgid "Show note counts"
|
||||
msgstr "Näytä muistiinpanojen määrä"
|
||||
|
||||
#: packages/lib/models/Setting.ts:868
|
||||
#, fuzzy
|
||||
msgid "Show sort order buttons"
|
||||
msgstr "Näytä muistiinpanojen määrä"
|
||||
msgstr "Näytä lajittelujärjestyspainikkeet"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1044
|
||||
msgid "Show tray icon"
|
||||
@@ -3480,7 +3469,7 @@ msgstr "Seis"
|
||||
|
||||
#: packages/app-desktop/commands/stopExternalEditing.ts:8
|
||||
msgid "Stop external editing"
|
||||
msgstr "Lopeta ulkoinen muokkaus"
|
||||
msgstr "Lopeta ulkoisen tekstieditorin muokkaus"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.ts:19
|
||||
msgid "Strikethrough"
|
||||
@@ -3635,7 +3624,7 @@ msgstr "Ota valokuva"
|
||||
#: packages/server/src/services/MustacheService.ts:148
|
||||
#: packages/server/src/services/MustacheService.ts:304
|
||||
msgid "Tasks"
|
||||
msgstr ""
|
||||
msgstr "Tehtävät"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1230
|
||||
msgid "Text editor command"
|
||||
@@ -3729,14 +3718,13 @@ msgid "The following attachments are being watched for changes:"
|
||||
msgstr "Seuraavia liitteitä tarkkaillaan muutosten vuoksi:"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:72
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The following keys use an out-dated encryption algorithm and it is "
|
||||
"recommended to upgrade them. The upgraded key will still be able to decrypt "
|
||||
"and encrypt your data as usual."
|
||||
msgstr ""
|
||||
"Seuraavat pääavaimet käyttävät vanhentunutta salausalgoritmia, ja ne on "
|
||||
"suositeltavaa päivittää. Päivitetty pääavain pystyy edelleen purkamaan ja "
|
||||
"Seuraavat avaimet käyttävät vanhentunutta salausalgoritmia, ja ne on "
|
||||
"suositeltavaa päivittää. Päivitetty avain pystyy edelleen purkamaan ja "
|
||||
"salaamaan tietosi tavalliseen tapaan."
|
||||
|
||||
#: packages/app-mobile/components/screens/Note.tsx:193
|
||||
@@ -3749,15 +3737,16 @@ msgid ""
|
||||
"The Joplin team has vetted this plugin and it meets our standards for "
|
||||
"security and performance."
|
||||
msgstr ""
|
||||
"Joplin tiimi on käynyt läpi tämän laajennuksen ja se täyttää turvallisuus- "
|
||||
"ja suorituskykystandardimme."
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:325
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The keys with these IDs are used to encrypt some of your items, however the "
|
||||
"application does not currently have access to them. It is likely they will "
|
||||
"eventually be downloaded via synchronisation."
|
||||
msgstr ""
|
||||
"Näillä tunnuksilla varustettuja pääavaimia käytetään joidenkin kohteiden "
|
||||
"Näillä tunnuksilla varustettuja avaimia käytetään joidenkin kohteiden "
|
||||
"salaamiseen, mutta sovelluksella ei tällä hetkellä ole pääsyä niihin. On "
|
||||
"todennäköistä, että ne lopulta ladataan synkronoinnin avulla."
|
||||
|
||||
@@ -3798,6 +3787,9 @@ msgid ""
|
||||
"\n"
|
||||
"The error was: \"%s\""
|
||||
msgstr ""
|
||||
"Vastaanottajaa ei voitu poistaa luettelosta. Yritä uudelleen.\n"
|
||||
"\n"
|
||||
"Virhe oli: \"%s\""
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.tsx:585
|
||||
msgid ""
|
||||
@@ -3810,9 +3802,8 @@ msgstr ""
|
||||
"käynnistettävä uudelleen. Jatka napsauttamalla linkkiä."
|
||||
|
||||
#: packages/app-mobile/components/screen-header.js:461
|
||||
#, fuzzy
|
||||
msgid "The sync target needs to be upgraded. Press this banner to proceed."
|
||||
msgstr "Synkronointikohde on päivitettävä! Suorita `%s` jatkaaksesi."
|
||||
msgstr "Synkronointikohde on päivitettävä. Jatka painamalla tätä banneria."
|
||||
|
||||
#: packages/lib/models/Tag.ts:204
|
||||
msgid "The tag \"%s\" already exists. Please choose a different name."
|
||||
@@ -3999,13 +3990,12 @@ msgid "This will open a new screen. Save your current changes?"
|
||||
msgstr "Tämä avaa uuden ikkunan. Tallennetaanko nykyiset muutokset?"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.ts:16
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"This will remove the notebook from your collection and you will no longer "
|
||||
"have access to its content. Do you wish to continue?"
|
||||
msgstr ""
|
||||
"Poistetaanko tämän muistikirjan jakaminen? Vastaanottajilla ei ole enää "
|
||||
"pääsyä sen sisältöön."
|
||||
"Tämä poistaa muistikirjan kokoelmasta, etkä voi enää käyttää sen sisältöä. "
|
||||
"Haluatko jatkaa?"
|
||||
|
||||
#: packages/lib/models/Setting.ts:739
|
||||
msgid "Time format"
|
||||
@@ -4029,9 +4019,8 @@ msgstr ""
|
||||
"Jos haluat, että Joplin voi synkronoida Dropboxin kanssa, toimi seuraavasti:"
|
||||
|
||||
#: packages/lib/components/EncryptionConfigScreen/utils.ts:54
|
||||
#, fuzzy
|
||||
msgid "To continue, please enter your master password below."
|
||||
msgstr "Syötä pääsalasana:"
|
||||
msgstr "Jatka kirjoittamalla pääsalasanasi alle."
|
||||
|
||||
#: packages/app-cli/app/app-gui.js:452
|
||||
msgid "To delete a tag, untag the associated notes."
|
||||
@@ -4103,16 +4092,15 @@ msgstr "Vaihda editoria"
|
||||
|
||||
#: packages/app-desktop/commands/toggleExternalEditing.ts:8
|
||||
msgid "Toggle external editing"
|
||||
msgstr "Vaihda ulkoiseen muokkaukseen"
|
||||
msgstr "Vaihda ulkoiseen tekstieditoriin"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/toggleNoteList.ts:9
|
||||
msgid "Toggle note list"
|
||||
msgstr "Muistiinpanoluettelon vaihtaminen"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.ts:7
|
||||
#, fuzzy
|
||||
msgid "Toggle own sort order"
|
||||
msgstr "Vaihda turvalliseen tilaan"
|
||||
msgstr "Vaihda omaa lajittelujärjestystä"
|
||||
|
||||
#: packages/app-desktop/commands/toggleSafeMode.ts:8
|
||||
msgid "Toggle safe mode"
|
||||
@@ -4123,9 +4111,8 @@ msgid "Toggle sidebar"
|
||||
msgstr "Näytä sivupalkki"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.ts:7
|
||||
#, fuzzy
|
||||
msgid "Toggle sort order field"
|
||||
msgstr "Vaihda turvalliseen tilaan"
|
||||
msgstr "Vaihda lajittelujärjestyskenttää"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:40
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:41
|
||||
@@ -4228,14 +4215,12 @@ msgstr "Päivitä"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:171
|
||||
#: packages/server/src/routes/index/users.ts:89
|
||||
#, fuzzy
|
||||
msgid "Update profile"
|
||||
msgstr "Vie profiili"
|
||||
msgstr "Päivitä profiili"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:32
|
||||
#, fuzzy
|
||||
msgid "Update total sizes"
|
||||
msgstr "Päivitetyt paikalliset kohteet: %d."
|
||||
msgstr "Päivitä kokonaismäärät"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:208
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:209
|
||||
@@ -4341,7 +4326,7 @@ msgstr ""
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:144
|
||||
msgid "User deletions"
|
||||
msgstr ""
|
||||
msgstr "Käyttäjien poisto"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:107
|
||||
#: packages/server/src/services/MustacheService.ts:140
|
||||
@@ -4495,13 +4480,15 @@ msgstr "Tietosi salataan ja synkronoidaan uudelleen."
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:271
|
||||
msgid "Your master password is needed to decrypt some of your data."
|
||||
msgstr ""
|
||||
msgstr "Pääsalasanaa tarvitaan joidenkin tietojen salauksen purkamiseen."
|
||||
|
||||
#: packages/app-cli/app/command-sync.ts:242
|
||||
msgid ""
|
||||
"Your password is needed to decrypt some of your data. Type `:e2ee decrypt` "
|
||||
"to set it."
|
||||
msgstr ""
|
||||
"Salasanaa tarvitaan joidenkin tietojen salauksen purkamiseen. Aseta se "
|
||||
"kirjoittamalla `:e2ee decrypt`."
|
||||
|
||||
#: packages/app-mobile/components/CameraView.tsx:189
|
||||
msgid "Your permission to use your camera is required."
|
||||
@@ -4520,38 +4507,6 @@ msgstr "Lähennä"
|
||||
msgid "Zoom Out"
|
||||
msgstr "Loitonna"
|
||||
|
||||
#~ msgid "Automatically update the application"
|
||||
#~ msgstr "Päivitä sovellus automaattisesti"
|
||||
|
||||
#~ msgid "Notebook title:"
|
||||
#~ msgstr "Muistikirjan otsikko:"
|
||||
|
||||
#~ msgid "AWS S3"
|
||||
#~ msgstr "AWS S3"
|
||||
|
||||
#~ msgid "Master keys that need upgrading"
|
||||
#~ msgstr "Pääavaimet, jotka on päivitettävä"
|
||||
|
||||
#~ msgid ""
|
||||
#~ "The following master keys use an out-dated encryption algorithm and it is "
|
||||
#~ "recommended to upgrade them. The upgraded master key will still be able "
|
||||
#~ "to decrypt and encrypt your data as usual."
|
||||
#~ msgstr ""
|
||||
#~ "Seuraavat pääavaimet käyttävät vanhentunutta salausalgoritmia, ja ne on "
|
||||
#~ "suositeltavaa päivittää. Päivitetty pääavain pystyy edelleen purkamaan ja "
|
||||
#~ "salaamaan tietosi tavalliseen tapaan."
|
||||
|
||||
#~ msgid ""
|
||||
#~ "Enabling encryption means *all* your notes and attachments are going to "
|
||||
#~ "be re-synchronised and sent encrypted to the sync target. Do not lose the "
|
||||
#~ "password as, for security purposes, this will be the *only* way to "
|
||||
#~ "decrypt the data! To enable encryption, please enter your password below."
|
||||
#~ msgstr ""
|
||||
#~ "Salaus otetaan käyttöön *kaikissa* muistiinpanosi ja liitteesi "
|
||||
#~ "synkronoidaan uudelleen ja lähetetään salattuina synkronointikohteeseen. "
|
||||
#~ "Älä unohda salasanaa, koska turvallisuussyistä tämä on *ainoa* tapa "
|
||||
#~ "salauksen purkamiseen! Ota salaus käyttöön antamalla salasanasi alla."
|
||||
|
||||
#~ msgid "Master Keys"
|
||||
#~ msgstr "Pääavaimet"
|
||||
|
||||
@@ -4571,16 +4526,25 @@ msgstr "Loitonna"
|
||||
#~ "purkamiseen sen mukaan, miten muistiinpanot tai muistikirjat alun perin "
|
||||
#~ "salattiin."
|
||||
|
||||
#~ msgid "Encryption is:"
|
||||
#~ msgstr "Salaus on:"
|
||||
#~ msgid "Notebook title:"
|
||||
#~ msgstr "Muistikirjan otsikko:"
|
||||
|
||||
#~ msgid "Delete these notes?"
|
||||
#~ msgstr "Poistetaanko nämä muistiinpanot?"
|
||||
|
||||
#~ msgid "AWS S3"
|
||||
#~ msgstr "AWS S3"
|
||||
|
||||
#, javascript-format
|
||||
#~ msgid "Encryption will be enabled using the master key created on %s"
|
||||
#~ msgstr "Salaus otetaan käyttöön käyttämällä luotua pääavainta %s"
|
||||
|
||||
#~ msgid "AWS key"
|
||||
#~ msgstr "AWS key"
|
||||
|
||||
#~ msgid "Automatically update the application"
|
||||
#~ msgstr "Päivitä sovellus automaattisesti"
|
||||
|
||||
#~ msgid "Do not resize images"
|
||||
#~ msgstr "Älä muuta kuvien kokoa"
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
# Copyright (C) 2001 Laurent Cozic
|
||||
# This file is distributed under the same license as the Joplin-CLI package.
|
||||
# Hrvoje Mandić <trbuhom@net.hr>
|
||||
# Milo Ivir <mail@milotype.de>, 2021.
|
||||
# Milo Ivir <mail@milotype.de>, 2021., 2022.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Joplin 1.7.11\n"
|
||||
"Project-Id-Version: Joplin\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: Milo Ivir <mail@milotype.de>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: hr\n"
|
||||
@@ -267,11 +269,11 @@ msgstr "Dodaj u rječnik"
|
||||
#: packages/server/src/services/MustacheService.ts:183
|
||||
#: packages/server/src/services/MustacheService.ts:307
|
||||
msgid "Admin"
|
||||
msgstr ""
|
||||
msgstr "Administrator"
|
||||
|
||||
#: packages/server/src/routes/admin/dashboard.ts:10
|
||||
msgid "Admin dashboard"
|
||||
msgstr ""
|
||||
msgstr "Pregledna ploča administratora"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:189
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:147
|
||||
@@ -396,16 +398,15 @@ msgstr "Automatski"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:39
|
||||
msgid "Auto-add disabled accounts for deletion"
|
||||
msgstr ""
|
||||
msgstr "Automatski dodaj deaktivirane račune za brisanje"
|
||||
|
||||
#: packages/lib/models/Setting.ts:855
|
||||
msgid "Auto-pair braces, parenthesis, quotations, etc."
|
||||
msgstr "Automatski uskladi zagrade, navodnike itd."
|
||||
|
||||
#: packages/lib/models/Setting.ts:1195
|
||||
#, fuzzy
|
||||
msgid "Automatically check for updates"
|
||||
msgstr "Traži nove verzije …"
|
||||
msgstr "Automatski traži nove verzije"
|
||||
|
||||
#: packages/lib/models/Setting.ts:772
|
||||
msgid "Automatically switch theme to match system theme"
|
||||
@@ -709,7 +710,7 @@ msgstr "Završeno: %s (%s)"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:37
|
||||
msgid "Compress old changes"
|
||||
msgstr ""
|
||||
msgstr "Komprimiraj stare promjene"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:637
|
||||
#: packages/app-mobile/components/side-menu-content.js:332
|
||||
@@ -771,9 +772,8 @@ msgid "Copy external link"
|
||||
msgstr "Kopiraj vanjsku poveznicu"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:134
|
||||
#, fuzzy
|
||||
msgid "Copy image"
|
||||
msgstr "Kopiraj ključ"
|
||||
msgstr "Kopiraj sliku"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:172
|
||||
msgid "Copy Link Address"
|
||||
@@ -860,20 +860,20 @@ msgid ""
|
||||
"Could not verify the share status of this notebook - aborting. Please try "
|
||||
"again when you are connected to the internet."
|
||||
msgstr ""
|
||||
"Nije moguće provjeriti stanje dijeljenja ove bilježnice – prekid. Pokušaj "
|
||||
"ponovo kad si povezan/a s internetom."
|
||||
|
||||
#: packages/app-mobile/components/note-list.js:101
|
||||
msgid "Create a notebook"
|
||||
msgstr "Stvori bilježnicu"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:161
|
||||
#, fuzzy
|
||||
msgid "Create notebook"
|
||||
msgstr "Stvori bilježnicu"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:171
|
||||
#, fuzzy
|
||||
msgid "Create user"
|
||||
msgstr "Stvoreno: %s"
|
||||
msgstr "Stvori korisnika"
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.min.js:29
|
||||
msgid "Created"
|
||||
@@ -965,7 +965,7 @@ msgstr "Tamna"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:136
|
||||
msgid "Dashboard"
|
||||
msgstr ""
|
||||
msgstr "Pregledna ploča"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:625
|
||||
msgid "Database v%s"
|
||||
@@ -1022,14 +1022,12 @@ msgid "Delete attachment \"%s\"?"
|
||||
msgstr "Izbrisati privitak „%s”?"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:36
|
||||
#, fuzzy
|
||||
msgid "Delete expired sessions"
|
||||
msgstr "Aktiviraj matematičke izraze"
|
||||
msgstr "Izbriši istekle sesije"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:31
|
||||
#, fuzzy
|
||||
msgid "Delete expired tokens"
|
||||
msgstr "Izbrisati ove %d bilješke?"
|
||||
msgstr "Izbriši istekle tokene"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:88
|
||||
msgid "Delete line"
|
||||
@@ -1349,12 +1347,12 @@ msgstr "Emacs"
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.tsx:236
|
||||
#: packages/server/src/routes/admin/emails.ts:128
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
msgstr "E-mail"
|
||||
|
||||
#: packages/server/src/routes/admin/emails.ts:112
|
||||
#: packages/server/src/services/MustacheService.ts:152
|
||||
msgid "Emails"
|
||||
msgstr ""
|
||||
msgstr "E-mailovi"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx:194
|
||||
msgid "emphasised text"
|
||||
@@ -1702,7 +1700,7 @@ msgstr "Za popis tipkovnih prečaca i opcija konfiguracije, upiši `help keymap`
|
||||
|
||||
#: packages/lib/models/Setting.ts:569
|
||||
msgid "Force path style"
|
||||
msgstr ""
|
||||
msgstr "Prisili stil putanje"
|
||||
|
||||
#: packages/lib/commands/historyForward.ts:6
|
||||
msgid "Forward"
|
||||
@@ -1773,7 +1771,6 @@ msgid "Heading"
|
||||
msgstr "Naslov"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:305
|
||||
#, fuzzy
|
||||
msgid "Help"
|
||||
msgstr "Pomoć"
|
||||
|
||||
@@ -1796,7 +1793,7 @@ msgstr "Istaknuto"
|
||||
#: packages/server/src/services/MustacheService.ts:167
|
||||
#: packages/server/src/services/MustacheService.ts:300
|
||||
msgid "Home"
|
||||
msgstr ""
|
||||
msgstr "Početak"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:78
|
||||
msgid "Horizontal Rule"
|
||||
@@ -1816,7 +1813,7 @@ msgstr "Poveznica"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:136
|
||||
msgid "Icon"
|
||||
msgstr ""
|
||||
msgstr "Ikona"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:179
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:329
|
||||
@@ -1840,7 +1837,7 @@ msgstr "Zanemari greške TLS certifikata"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:102
|
||||
msgid "Images"
|
||||
msgstr ""
|
||||
msgstr "Slike"
|
||||
|
||||
#: packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.tsx:170
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:484
|
||||
@@ -2012,9 +2009,8 @@ msgid "Invalid option value: \"%s\". Possible values are: %s."
|
||||
msgstr "Neispravna vrijednost opcije: „%s”. Moguće vrijednosti su: %s."
|
||||
|
||||
#: packages/app-cli/app/command-e2ee.ts:46
|
||||
#, fuzzy
|
||||
msgid "Invalid password"
|
||||
msgstr "Neispravan odgovor: %s"
|
||||
msgstr "Neispravna lozinka"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:38
|
||||
msgid "Italic"
|
||||
@@ -2027,7 +2023,7 @@ msgstr "Nije bilo moguće preuzeti element „%s”: %s"
|
||||
#: packages/server/src/services/MustacheService.ts:175
|
||||
#: packages/server/src/services/MustacheService.ts:302
|
||||
msgid "Items"
|
||||
msgstr ""
|
||||
msgstr "Elementi"
|
||||
|
||||
#: packages/lib/services/ReportService.ts:208
|
||||
msgid "Items that cannot be decrypted"
|
||||
@@ -2246,11 +2242,11 @@ msgstr "Prijavi se s OneDrive"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:306
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
msgstr "Odjava"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:179
|
||||
msgid "Logs"
|
||||
msgstr ""
|
||||
msgstr "Dnevnici"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:716
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:583
|
||||
@@ -2580,9 +2576,8 @@ msgid "Note&book"
|
||||
msgstr "Biljež&nica"
|
||||
|
||||
#: packages/lib/models/Setting.ts:2145
|
||||
#, fuzzy
|
||||
msgid "Notebook"
|
||||
msgstr "Bilježnice"
|
||||
msgstr "Bilježnica"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1380
|
||||
msgid "Notebook list growth factor"
|
||||
@@ -2603,9 +2598,8 @@ msgstr "Naslov „%s” je rezerviran i ne može se koristiti za bilježnice."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.ts:8
|
||||
#: packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.ts:9
|
||||
#, fuzzy
|
||||
msgid "Notes"
|
||||
msgstr "Bilješka"
|
||||
msgstr "Bilješke"
|
||||
|
||||
#: packages/lib/models/Setting.ts:2161
|
||||
msgid "Notes and settings are stored in: %s"
|
||||
@@ -2712,9 +2706,8 @@ msgid "Or create an account."
|
||||
msgstr "Ili stvori novi račun."
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:352
|
||||
#, fuzzy
|
||||
msgid "Other applications..."
|
||||
msgstr "Zatvara program."
|
||||
msgstr "Drugi programi …"
|
||||
|
||||
#: packages/app-cli/app/command-import.js:27
|
||||
msgid "Output format: %s"
|
||||
@@ -2915,19 +2908,19 @@ msgstr "Politika privatnosti"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:35
|
||||
msgid "Process failed payment subscriptions"
|
||||
msgstr ""
|
||||
msgstr "Obradi neuspjela plaćanja pretplata"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:33
|
||||
msgid "Process oversized accounts"
|
||||
msgstr ""
|
||||
msgstr "Obradi prevelike račune"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:38
|
||||
msgid "Process user deletions"
|
||||
msgstr ""
|
||||
msgstr "Obradi brisanja korisnika"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:168
|
||||
msgid "Profile"
|
||||
msgstr ""
|
||||
msgstr "Profil"
|
||||
|
||||
#: packages/lib/versionInfo.ts:26
|
||||
msgid "Profile Version: %s"
|
||||
@@ -3111,31 +3104,27 @@ msgstr ""
|
||||
|
||||
#: packages/lib/SyncTargetAmazonS3.js:28
|
||||
msgid "S3"
|
||||
msgstr ""
|
||||
msgstr "S3"
|
||||
|
||||
#: packages/lib/models/Setting.ts:547
|
||||
#, fuzzy
|
||||
msgid "S3 access key"
|
||||
msgstr "AWS ključ"
|
||||
msgstr "S3 pristupni ključ"
|
||||
|
||||
#: packages/lib/models/Setting.ts:507
|
||||
#, fuzzy
|
||||
msgid "S3 bucket"
|
||||
msgstr "AWS S3 bucket"
|
||||
msgstr "S3 bucket"
|
||||
|
||||
#: packages/lib/models/Setting.ts:536
|
||||
msgid "S3 region"
|
||||
msgstr ""
|
||||
msgstr "S3 područje"
|
||||
|
||||
#: packages/lib/models/Setting.ts:558
|
||||
#, fuzzy
|
||||
msgid "S3 secret key"
|
||||
msgstr "AWS tajna"
|
||||
msgstr "S3 tajni ključ"
|
||||
|
||||
#: packages/lib/models/Setting.ts:522
|
||||
#, fuzzy
|
||||
msgid "S3 URL"
|
||||
msgstr "AWS S3 URL"
|
||||
msgstr "S3 URL"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.tsx:579
|
||||
msgid ""
|
||||
@@ -3212,14 +3201,12 @@ msgid "Select all"
|
||||
msgstr "Označi sve"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:140
|
||||
#, fuzzy
|
||||
msgid "Select emoji..."
|
||||
msgstr "Odaberi datum"
|
||||
msgstr "Odaberi emoji …"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:144
|
||||
#, fuzzy
|
||||
msgid "Select file..."
|
||||
msgstr "Označi sve"
|
||||
msgstr "Odaberi datoteku …"
|
||||
|
||||
#: packages/app-cli/app/command-server.js:38
|
||||
msgid "Server is already running on port %d"
|
||||
@@ -3311,9 +3298,8 @@ msgid "Show note counts"
|
||||
msgstr "Prikaži broj bilježaka"
|
||||
|
||||
#: packages/lib/models/Setting.ts:868
|
||||
#, fuzzy
|
||||
msgid "Show sort order buttons"
|
||||
msgstr "Prikaži broj bilježaka"
|
||||
msgstr "Prikaži gumbe za mijenjanje redoslijeda"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1044
|
||||
msgid "Show tray icon"
|
||||
@@ -3644,7 +3630,7 @@ msgstr "Snimi sliku"
|
||||
#: packages/server/src/services/MustacheService.ts:148
|
||||
#: packages/server/src/services/MustacheService.ts:304
|
||||
msgid "Tasks"
|
||||
msgstr ""
|
||||
msgstr "Zadaci"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1230
|
||||
msgid "Text editor command"
|
||||
@@ -4117,9 +4103,8 @@ msgid "Toggle note list"
|
||||
msgstr "Uključi/Isključi popis bilježaka"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.ts:7
|
||||
#, fuzzy
|
||||
msgid "Toggle own sort order"
|
||||
msgstr "Uključi/Isključi sigurni modus"
|
||||
msgstr "Uključi/Isključi vlastiti redoslijed"
|
||||
|
||||
#: packages/app-desktop/commands/toggleSafeMode.ts:8
|
||||
msgid "Toggle safe mode"
|
||||
@@ -4130,9 +4115,8 @@ msgid "Toggle sidebar"
|
||||
msgstr "Uključi/Isključi bočnu traku"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.ts:7
|
||||
#, fuzzy
|
||||
msgid "Toggle sort order field"
|
||||
msgstr "Uključi/Isključi sigurni modus"
|
||||
msgstr "Uključi/Isključi redoslijed polja"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:40
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:41
|
||||
@@ -4236,14 +4220,12 @@ msgstr "Aktualiziraj"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:171
|
||||
#: packages/server/src/routes/index/users.ts:89
|
||||
#, fuzzy
|
||||
msgid "Update profile"
|
||||
msgstr "Izvezi profil"
|
||||
msgstr "Aktualiziraj profil"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:32
|
||||
#, fuzzy
|
||||
msgid "Update total sizes"
|
||||
msgstr "Aktualizirani lokalni elementi: %d."
|
||||
msgstr "Aktualiziraj ukupne veličine"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:208
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:209
|
||||
@@ -4349,13 +4331,13 @@ msgstr ""
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:144
|
||||
msgid "User deletions"
|
||||
msgstr ""
|
||||
msgstr "Brisanja korisnika"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:107
|
||||
#: packages/server/src/services/MustacheService.ts:140
|
||||
#: packages/server/src/services/MustacheService.ts:301
|
||||
msgid "Users"
|
||||
msgstr ""
|
||||
msgstr "Korisnici"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:182
|
||||
msgid "Valid"
|
||||
@@ -4510,12 +4492,12 @@ msgstr ""
|
||||
"Tvoja glavna lozinka je potrebna za dešifriranje nekih tvojih podataka."
|
||||
|
||||
#: packages/app-cli/app/command-sync.ts:242
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"Your password is needed to decrypt some of your data. Type `:e2ee decrypt` "
|
||||
"to set it."
|
||||
msgstr ""
|
||||
"Tvoja glavna lozinka je potrebna za dešifriranje nekih tvojih podataka."
|
||||
"Tvoja lozinka je potrebna za dešifriranje nekih tvojih podataka. Za "
|
||||
"postavljanje utipkaj `:e2ee decrypt`."
|
||||
|
||||
#: packages/app-mobile/components/CameraView.tsx:189
|
||||
msgid "Your permission to use your camera is required."
|
||||
@@ -4528,11 +4510,11 @@ msgstr "Tvoja verzija: %s"
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:653
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:659
|
||||
msgid "Zoom In"
|
||||
msgstr "Uvećaj"
|
||||
msgstr "Uvećaj prikaz"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:666
|
||||
msgid "Zoom Out"
|
||||
msgstr "Umanji"
|
||||
msgstr "Umanji prikaz"
|
||||
|
||||
#~ msgid "Automatically update the application"
|
||||
#~ msgstr "Automatski aktualiziraj program"
|
||||
|
||||
@@ -7,14 +7,16 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Joplin-CLI 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: genneko <genneko217@gmail.com>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: ja_JP\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 2.4.2\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Poedit 3.0.1\n"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:565
|
||||
msgid "- Camera: to allow taking a picture and attaching it to a note."
|
||||
@@ -264,11 +266,11 @@ msgstr "辞書に追加"
|
||||
#: packages/server/src/services/MustacheService.ts:183
|
||||
#: packages/server/src/services/MustacheService.ts:307
|
||||
msgid "Admin"
|
||||
msgstr ""
|
||||
msgstr "管理者"
|
||||
|
||||
#: packages/server/src/routes/admin/dashboard.ts:10
|
||||
msgid "Admin dashboard"
|
||||
msgstr ""
|
||||
msgstr "管理者ダッシュボード"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:189
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:147
|
||||
@@ -393,16 +395,15 @@ msgstr "自動"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:39
|
||||
msgid "Auto-add disabled accounts for deletion"
|
||||
msgstr ""
|
||||
msgstr "無効アカウントを削除に自動追加"
|
||||
|
||||
#: packages/lib/models/Setting.ts:855
|
||||
msgid "Auto-pair braces, parenthesis, quotations, etc."
|
||||
msgstr "始めの括弧や引用符入力時に終わりの括弧や引用符を自動入力する。"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1195
|
||||
#, fuzzy
|
||||
msgid "Automatically check for updates"
|
||||
msgstr "アップデートのチェック..."
|
||||
msgstr "アップデートを自動的に確認する"
|
||||
|
||||
#: packages/lib/models/Setting.ts:772
|
||||
msgid "Automatically switch theme to match system theme"
|
||||
@@ -705,7 +706,7 @@ msgstr "完了: %s (%s)"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:37
|
||||
msgid "Compress old changes"
|
||||
msgstr ""
|
||||
msgstr "古い変更を圧縮する"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:637
|
||||
#: packages/app-mobile/components/side-menu-content.js:332
|
||||
@@ -767,9 +768,8 @@ msgid "Copy external link"
|
||||
msgstr "外部リンクをコピー"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:134
|
||||
#, fuzzy
|
||||
msgid "Copy image"
|
||||
msgstr "トークンのコピー"
|
||||
msgstr "画像のコピー"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:172
|
||||
msgid "Copy Link Address"
|
||||
@@ -861,14 +861,12 @@ msgid "Create a notebook"
|
||||
msgstr "ノートブックを作成します"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:161
|
||||
#, fuzzy
|
||||
msgid "Create notebook"
|
||||
msgstr "ノートブックを作成します"
|
||||
msgstr "ノートブックの作成"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:171
|
||||
#, fuzzy
|
||||
msgid "Create user"
|
||||
msgstr "作成しました:%s"
|
||||
msgstr "ユーザーを作成"
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.min.js:29
|
||||
msgid "Created"
|
||||
@@ -960,7 +958,7 @@ msgstr "暗い"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:136
|
||||
msgid "Dashboard"
|
||||
msgstr ""
|
||||
msgstr "ダッシュボード"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:625
|
||||
msgid "Database v%s"
|
||||
@@ -1017,14 +1015,12 @@ msgid "Delete attachment \"%s\"?"
|
||||
msgstr "添付ファイル \"%s\" を削除しますか?"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:36
|
||||
#, fuzzy
|
||||
msgid "Delete expired sessions"
|
||||
msgstr "数式表現を有効にする"
|
||||
msgstr "期限切れセッションを削除"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:31
|
||||
#, fuzzy
|
||||
msgid "Delete expired tokens"
|
||||
msgstr "ノート \"%d\" を削除しますか?"
|
||||
msgstr "期限切れトークンを削除"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:88
|
||||
msgid "Delete line"
|
||||
@@ -1342,12 +1338,12 @@ msgstr "Emacs"
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.tsx:236
|
||||
#: packages/server/src/routes/admin/emails.ts:128
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
msgstr "Eメール"
|
||||
|
||||
#: packages/server/src/routes/admin/emails.ts:112
|
||||
#: packages/server/src/services/MustacheService.ts:152
|
||||
msgid "Emails"
|
||||
msgstr ""
|
||||
msgstr "Eメール"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx:194
|
||||
msgid "emphasised text"
|
||||
@@ -1697,9 +1693,8 @@ msgstr ""
|
||||
"力してください"
|
||||
|
||||
#: packages/lib/models/Setting.ts:569
|
||||
#, fuzzy
|
||||
msgid "Force path style"
|
||||
msgstr "Path Styleを強制する"
|
||||
msgstr "パス形式を強制する"
|
||||
|
||||
#: packages/lib/commands/historyForward.ts:6
|
||||
msgid "Forward"
|
||||
@@ -1789,7 +1784,7 @@ msgstr "ハイライト"
|
||||
#: packages/server/src/services/MustacheService.ts:167
|
||||
#: packages/server/src/services/MustacheService.ts:300
|
||||
msgid "Home"
|
||||
msgstr ""
|
||||
msgstr "ホーム"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:78
|
||||
msgid "Horizontal Rule"
|
||||
@@ -1833,7 +1828,7 @@ msgstr "TLS証明書のエラーを無視"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:102
|
||||
msgid "Images"
|
||||
msgstr ""
|
||||
msgstr "画像"
|
||||
|
||||
#: packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.tsx:170
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:484
|
||||
@@ -2016,7 +2011,7 @@ msgstr "アイテム \"%s\" はダウンロードできませんでした:%s"
|
||||
#: packages/server/src/services/MustacheService.ts:175
|
||||
#: packages/server/src/services/MustacheService.ts:302
|
||||
msgid "Items"
|
||||
msgstr ""
|
||||
msgstr "アイテム"
|
||||
|
||||
#: packages/lib/services/ReportService.ts:208
|
||||
msgid "Items that cannot be decrypted"
|
||||
@@ -2231,11 +2226,11 @@ msgstr "OneDriveログイン"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:306
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
msgstr "ログアウト"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:179
|
||||
msgid "Logs"
|
||||
msgstr ""
|
||||
msgstr "ログ"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:716
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:583
|
||||
@@ -2697,9 +2692,8 @@ msgid "Or create an account."
|
||||
msgstr "またはアカウントを作成。"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:352
|
||||
#, fuzzy
|
||||
msgid "Other applications..."
|
||||
msgstr "アプリケーションの終了。"
|
||||
msgstr "その他のアプリケーション..."
|
||||
|
||||
#: packages/app-cli/app/command-import.js:27
|
||||
msgid "Output format: %s"
|
||||
@@ -2901,19 +2895,19 @@ msgstr "プライバシーポリシー"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:35
|
||||
msgid "Process failed payment subscriptions"
|
||||
msgstr ""
|
||||
msgstr "サブスクリプションの支払い処理に失敗しました"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:33
|
||||
msgid "Process oversized accounts"
|
||||
msgstr ""
|
||||
msgstr "容量オーバーのアカウントを処理する"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:38
|
||||
msgid "Process user deletions"
|
||||
msgstr ""
|
||||
msgstr "ユーザー削除を実施する"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:168
|
||||
msgid "Profile"
|
||||
msgstr ""
|
||||
msgstr "プロファイル"
|
||||
|
||||
#: packages/lib/versionInfo.ts:26
|
||||
msgid "Profile Version: %s"
|
||||
@@ -3099,27 +3093,22 @@ msgid "S3"
|
||||
msgstr "S3"
|
||||
|
||||
#: packages/lib/models/Setting.ts:547
|
||||
#, fuzzy
|
||||
msgid "S3 access key"
|
||||
msgstr "アクセスキー"
|
||||
msgstr "S3 アクセスキー"
|
||||
|
||||
#: packages/lib/models/Setting.ts:507
|
||||
#, fuzzy
|
||||
msgid "S3 bucket"
|
||||
msgstr "S3 バケット"
|
||||
|
||||
#: packages/lib/models/Setting.ts:536
|
||||
#, fuzzy
|
||||
msgid "S3 region"
|
||||
msgstr "リージョン"
|
||||
msgstr "S3 リージョン"
|
||||
|
||||
#: packages/lib/models/Setting.ts:558
|
||||
#, fuzzy
|
||||
msgid "S3 secret key"
|
||||
msgstr "シークレットアクセスキー"
|
||||
msgstr "S3 シークレットアクセスキー"
|
||||
|
||||
#: packages/lib/models/Setting.ts:522
|
||||
#, fuzzy
|
||||
msgid "S3 URL"
|
||||
msgstr "S3 URL"
|
||||
|
||||
@@ -3198,14 +3187,12 @@ msgid "Select all"
|
||||
msgstr "すべて選択"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:140
|
||||
#, fuzzy
|
||||
msgid "Select emoji..."
|
||||
msgstr "日付の選択"
|
||||
msgstr "絵文字の選択..."
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:144
|
||||
#, fuzzy
|
||||
msgid "Select file..."
|
||||
msgstr "すべて選択"
|
||||
msgstr "ファイルの選択..."
|
||||
|
||||
#: packages/app-cli/app/command-server.js:38
|
||||
msgid "Server is already running on port %d"
|
||||
@@ -3625,7 +3612,7 @@ msgstr "写真を撮影する"
|
||||
#: packages/server/src/services/MustacheService.ts:148
|
||||
#: packages/server/src/services/MustacheService.ts:304
|
||||
msgid "Tasks"
|
||||
msgstr ""
|
||||
msgstr "タスク"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1230
|
||||
msgid "Text editor command"
|
||||
@@ -4014,7 +4001,7 @@ msgstr "DropboxでJoplinを同期する場合は、次のステップを実行
|
||||
|
||||
#: packages/lib/components/EncryptionConfigScreen/utils.ts:54
|
||||
msgid "To continue, please enter your master password below."
|
||||
msgstr "続行するには、マスターパスワードを入力してください"
|
||||
msgstr "続行するには、マスターパスワードを入力してください。"
|
||||
|
||||
#: packages/app-cli/app/app-gui.js:452
|
||||
msgid "To delete a tag, untag the associated notes."
|
||||
@@ -4210,14 +4197,12 @@ msgstr "アップデート"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:171
|
||||
#: packages/server/src/routes/index/users.ts:89
|
||||
#, fuzzy
|
||||
msgid "Update profile"
|
||||
msgstr "プロファイルをエクスポート"
|
||||
msgstr "プロファイルの更新"
|
||||
|
||||
#: packages/server/src/services/TaskService.ts:32
|
||||
#, fuzzy
|
||||
msgid "Update total sizes"
|
||||
msgstr "ローカルアイテムの更新: %d."
|
||||
msgstr "総容量を更新する"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:208
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:209
|
||||
@@ -4321,13 +4306,13 @@ msgstr ""
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:144
|
||||
msgid "User deletions"
|
||||
msgstr ""
|
||||
msgstr "ユーザー削除"
|
||||
|
||||
#: packages/server/src/routes/admin/users.ts:107
|
||||
#: packages/server/src/services/MustacheService.ts:140
|
||||
#: packages/server/src/services/MustacheService.ts:301
|
||||
msgid "Users"
|
||||
msgstr ""
|
||||
msgstr "ユーザー"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:182
|
||||
msgid "Valid"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user