1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-09-05 20:56:22 +02:00

Compare commits

..

24 Commits

Author SHA1 Message Date
Laurent Cozic
cfe4546a0b Server v2.0.3 2021-05-25 20:09:18 +02:00
Laurent Cozic
f45e0d106f Merge branch 'dev' into release-2.0 2021-05-25 20:05:29 +02:00
Laurent Cozic
12a66342db Server: Fixed handling of request origin 2021-05-25 20:04:54 +02:00
Laurent Cozic
f2b17560e6 Fixed tests 2021-05-25 19:18:33 +02:00
Laurent Cozic
ba30dce6c8 server-v2.0.2 2021-05-25 19:17:42 +02:00
Laurent Cozic
f5984313be package lock 2021-05-25 18:11:49 +02:00
Laurent Cozic
df058352a5 Merge branch 'dev' into release-2.0 2021-05-25 17:51:55 +02:00
Laurent Cozic
cde25fad92 Fixed tests and server build 2021-05-25 17:50:51 +02:00
Laurent Cozic
d89bbc5571 Merge branch 'dev' into release-2.0 2021-05-25 17:23:43 +02:00
Laurent Cozic
71a7fc015a Server: Use external directory to store Postgres data in Docker-compose config 2021-05-25 17:20:22 +02:00
Laurent Cozic
83cef7a824 Server: Allow using a different domain for API, main website and user content 2021-05-25 16:42:21 +02:00
Laurent Cozic
f65de0c9eb Merge branch 'dev' into release-2.0 2021-05-25 13:07:29 +02:00
Laurent Cozic
3edf74e6d2 Merge branch 'release-2.0' into dev 2021-05-25 13:06:36 +02:00
Laurent Cozic
b01aa7eb45 Server: Make it more difficult to delete all data 2021-05-25 12:33:19 +02:00
Laurent Cozic
e59e3aa7d1 Server: Defaults to enabling share when creating user from admin UI 2021-05-25 12:25:26 +02:00
Laurent Cozic
51051e0ee0 Server: Redirect to correct page when trying to access the root 2021-05-25 12:21:35 +02:00
Laurent Cozic
b20ab19f13 Desktop: Rename Joplin Server to Joplin Cloud in UI 2021-05-25 12:16:57 +02:00
Laurent Cozic
68e79f1573 Server: Allow setting the path to the SQLite database using SQLITE_DATABASE env variable 2021-05-25 12:13:35 +02:00
Laurent Cozic
ed8ee67048 Server: Add mailer service 2021-05-25 11:49:47 +02:00
Laurent Cozic
68b516998d Update website 2021-05-24 01:16:43 +02:00
Laurent Cozic
0fa7a66fb6 Doc: Android doc title 2021-05-24 01:16:21 +02:00
Laurent Cozic
13f39b9bd5 Update website 2021-05-24 01:15:21 +02:00
albertopasqualetto
013d37bd09 All: Translation: Update it_IT.po (#5003) 2021-05-23 16:36:02 -04:00
mbalint
4760e5e8ba Desktop: Fixes #4864: Fixes panels overflowing window (#4991) 2021-05-22 18:30:11 +01:00
98 changed files with 2571 additions and 826 deletions

View File

@@ -104,6 +104,9 @@ packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js.map
packages/app-cli/tests/services/plugins/sandboxProxy.d.ts packages/app-cli/tests/services/plugins/sandboxProxy.d.ts
packages/app-cli/tests/services/plugins/sandboxProxy.js packages/app-cli/tests/services/plugins/sandboxProxy.js
packages/app-cli/tests/services/plugins/sandboxProxy.js.map packages/app-cli/tests/services/plugins/sandboxProxy.js.map
packages/app-cli/tests/testUtils.d.ts
packages/app-cli/tests/testUtils.js
packages/app-cli/tests/testUtils.js.map
packages/app-desktop/ElectronAppWrapper.d.ts packages/app-desktop/ElectronAppWrapper.d.ts
packages/app-desktop/ElectronAppWrapper.js packages/app-desktop/ElectronAppWrapper.js
packages/app-desktop/ElectronAppWrapper.js.map packages/app-desktop/ElectronAppWrapper.js.map

3
.gitignore vendored
View File

@@ -90,6 +90,9 @@ packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js.map
packages/app-cli/tests/services/plugins/sandboxProxy.d.ts packages/app-cli/tests/services/plugins/sandboxProxy.d.ts
packages/app-cli/tests/services/plugins/sandboxProxy.js packages/app-cli/tests/services/plugins/sandboxProxy.js
packages/app-cli/tests/services/plugins/sandboxProxy.js.map packages/app-cli/tests/services/plugins/sandboxProxy.js.map
packages/app-cli/tests/testUtils.d.ts
packages/app-cli/tests/testUtils.js
packages/app-cli/tests/testUtils.js.map
packages/app-desktop/ElectronAppWrapper.d.ts packages/app-desktop/ElectronAppWrapper.d.ts
packages/app-desktop/ElectronAppWrapper.js packages/app-desktop/ElectronAppWrapper.js
packages/app-desktop/ElectronAppWrapper.js.map packages/app-desktop/ElectronAppWrapper.js.map

View File

@@ -9,6 +9,8 @@ version: '3'
services: services:
db: db:
image: postgres:13.1 image: postgres:13.1
volumes:
- ./data/postgres:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "5432:5432"
restart: unless-stopped restart: unless-stopped

View File

@@ -108,11 +108,12 @@
</aside> </aside>
<div class="tsd-comment tsd-typography"> <div class="tsd-comment tsd-typography">
<div class="lead"> <div class="lead">
<p>Defines whether the command should be enabled or disabled, which in turns affects <p>Defines whether the command should be enabled or disabled, which in turns
the enabled state of any associated button or menu item.</p> affects the enabled state of any associated button or menu item.</p>
</div> </div>
<p>The condition should be expressed as a &quot;when-clause&quot; (as in Visual Studio Code). It&#39;s a simple boolean expression that evaluates to <p>The condition should be expressed as a &quot;when-clause&quot; (as in Visual Studio
<code>true</code> or <code>false</code>. It supports the following operators:</p> Code). It&#39;s a simple boolean expression that evaluates to <code>true</code> or
<code>false</code>. It supports the following operators:</p>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -142,7 +143,17 @@
<td>&quot;oneNoteSelected &amp;&amp; !inConflictFolder&quot;</td> <td>&quot;oneNoteSelected &amp;&amp; !inConflictFolder&quot;</td>
</tr> </tr>
</tbody></table> </tbody></table>
<p>Currently the supported context variables aren&#39;t documented, but you can <a href="https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts">find the list here</a>.</p> <p>Joplin, unlike VSCode, also supports parenthesis, which allows creating
more complex expressions such as <code>cond1 || (cond2 &amp;&amp; cond3)</code>. Only one
level of parenthesis is possible (nested ones aren&#39;t supported).</p>
<p>Currently the supported context variables aren&#39;t documented, but you can
find the list below:</p>
<ul>
<li>[Global When
Clauses](<a href="https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts">https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts</a>).</li>
<li>[Desktop app When
Clauses](<a href="https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts">https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts</a>).</li>
</ul>
<p>Note: Commands are enabled by default unless you use this property.</p> <p>Note: Commands are enabled by default unless you use this property.</p>
</div> </div>
</section> </section>

View File

@@ -721,6 +721,11 @@ async function fetchAllNotes() {
<td></td> <td></td>
</tr> </tr>
<tr> <tr>
<td>share_id</td>
<td>text</td>
<td></td>
</tr>
<tr>
<td>body_html</td> <td>body_html</td>
<td>text</td> <td>text</td>
<td>Note body, in HTML format</td> <td>Note body, in HTML format</td>
@@ -841,6 +846,11 @@ async function fetchAllNotes() {
<td>int</td> <td>int</td>
<td></td> <td></td>
</tr> </tr>
<tr>
<td>share_id</td>
<td>text</td>
<td></td>
</tr>
</tbody> </tbody>
</table> </table>
<h2>GET /folders<a name="get-folders" href="#get-folders" class="heading-anchor">🔗</a></h2> <h2>GET /folders<a name="get-folders" href="#get-folders" class="heading-anchor">🔗</a></h2>
@@ -937,6 +947,11 @@ async function fetchAllNotes() {
<td>int</td> <td>int</td>
<td></td> <td></td>
</tr> </tr>
<tr>
<td>share_id</td>
<td>text</td>
<td></td>
</tr>
</tbody> </tbody>
</table> </table>
<h2>GET /resources<a name="get-resources" href="#get-resources" class="heading-anchor">🔗</a></h2> <h2>GET /resources<a name="get-resources" href="#get-resources" class="heading-anchor">🔗</a></h2>

View File

@@ -405,6 +405,46 @@ https://github.com/laurent22/joplin/blob/dev/readme/changelog.md
<p><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&amp;business=E8JMYD2LQ8MMA&amp;lc=GB&amp;item_name=Joplin+Development&amp;currency_code=EUR&amp;bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted"><img src="https://joplinapp.org/images/badges/Donate-PayPal-green.svg" alt="Donate using PayPal"></a> <a href="https://github.com/sponsors/laurent22/"><img src="https://joplinapp.org/images/badges/GitHub-Badge.svg" alt="Sponsor on GitHub"></a> <a href="https://www.patreon.com/joplin"><img src="https://joplinapp.org/images/badges/Patreon-Badge.svg" alt="Become a patron"></a> <a href="https://joplinapp.org/donate/#donations"><img src="https://joplinapp.org/images/badges/Donate-IBAN.svg" alt="Donate using IBAN"></a></p> <p><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&amp;business=E8JMYD2LQ8MMA&amp;lc=GB&amp;item_name=Joplin+Development&amp;currency_code=EUR&amp;bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted"><img src="https://joplinapp.org/images/badges/Donate-PayPal-green.svg" alt="Donate using PayPal"></a> <a href="https://github.com/sponsors/laurent22/"><img src="https://joplinapp.org/images/badges/GitHub-Badge.svg" alt="Sponsor on GitHub"></a> <a href="https://www.patreon.com/joplin"><img src="https://joplinapp.org/images/badges/Patreon-Badge.svg" alt="Become a patron"></a> <a href="https://joplinapp.org/donate/#donations"><img src="https://joplinapp.org/images/badges/Donate-IBAN.svg" alt="Donate using IBAN"></a></p>
<hr> <hr>
<h1>Joplin changelog<a name="joplin-changelog" href="#joplin-changelog" class="heading-anchor">🔗</a></h1> <h1>Joplin changelog<a name="joplin-changelog" href="#joplin-changelog" class="heading-anchor">🔗</a></h1>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/v2.0.2">v2.0.2</a> (Pre-release) - 2021-05-21T18:07:48Z<a name="v2-0-2-https-github-com-laurent22-joplin-releases-tag-v2-0-2-pre-release-2021-05-21t18-07-48z" href="#v2-0-2-https-github-com-laurent22-joplin-releases-tag-v2-0-2-pre-release-2021-05-21t18-07-48z" class="heading-anchor">🔗</a></h2>
<ul>
<li>New: Add Share Notebook menu item (6f2f241)</li>
<li>New: Add classnames to DOM elements for theming purposes (<a href="https://github.com/laurent22/joplin/issues/4933">#4933</a> by <a href="https://github.com/ajilderda">@ajilderda</a>)</li>
<li>Improved: Allow unsharing a note (f7d164b)</li>
<li>Improved: Displays error info when Joplin Server fails (3f0586e)</li>
<li>Improved: Handle too large items for Joplin Server (d29624c)</li>
<li>Improved: Import SVG as images when importing ENEX files (<a href="https://github.com/laurent22/joplin/issues/4968">#4968</a>)</li>
<li>Improved: Import linked local files when importing Markdown files (<a href="https://github.com/laurent22/joplin/issues/4966">#4966</a>) (<a href="https://github.com/laurent22/joplin/issues/4433">#4433</a> by <a href="https://github.com/JackGruber">@JackGruber</a>)</li>
<li>Improved: Improved usability when plugin repository cannot be connected to (<a href="https://github.com/laurent22/joplin/issues/4462">#4462</a>)</li>
<li>Improved: Made sync more reliable by making it skip items that time out, and improved sync status screen (15fe119)</li>
<li>Improved: Pass custom CSS property to all export handlers and renderers (bd08041)</li>
<li>Improved: Regression: It was no longer possible to add list items in an empty note (6577f4f)</li>
<li>Improved: Regression: Pasting plain text in Rich Text editor was broken (9e9bf63)</li>
<li>Fixed: Fixed issue with empty panels being created by plugins (<a href="https://github.com/laurent22/joplin/issues/4926">#4926</a>)</li>
<li>Fixed: Fixed pasting HTML in Rich Text editor, and improved pasting plain text (2226b79)</li>
<li>Fixed: Improved importing Evernote notes that contain codeblocks (<a href="https://github.com/laurent22/joplin/issues/4965">#4965</a>)</li>
<li>Fixed: Prevent cursor from jumping to top of page when pasting image (<a href="https://github.com/laurent22/joplin/issues/4591">#4591</a>)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/v2.0.1">v2.0.1</a> (Pre-release) - 2021-05-15T13:22:58Z<a name="v2-0-1-https-github-com-laurent22-joplin-releases-tag-v2-0-1-pre-release-2021-05-15t13-22-58z" href="#v2-0-1-https-github-com-laurent22-joplin-releases-tag-v2-0-1-pre-release-2021-05-15t13-22-58z" class="heading-anchor">🔗</a></h2>
<ul>
<li>New: Add support for sharing notebooks with Joplin Server (<a href="https://github.com/laurent22/joplin/issues/4772">#4772</a>)</li>
<li>New: Add new date format YYMMDD (<a href="https://github.com/laurent22/joplin/issues/4954">#4954</a> by Helmut K. C. Tessarek)</li>
<li>New: Added button to skip an application update (a31b402)</li>
<li>Fixed: Display proper error message when JEX file is corrupted (<a href="https://github.com/laurent22/joplin/issues/4958">#4958</a>)</li>
<li>Fixed: Show or hide completed todos in search results based on user settings (<a href="https://github.com/laurent22/joplin/issues/4951">#4951</a>) (<a href="https://github.com/laurent22/joplin/issues/4581">#4581</a> by <a href="https://github.com/JackGruber">@JackGruber</a>)</li>
<li>Fixed: Solve &quot;Resource Id not provided&quot; error (<a href="https://github.com/laurent22/joplin/issues/4943">#4943</a>) (<a href="https://github.com/laurent22/joplin/issues/4891">#4891</a> by <a href="https://github.com/Subhra264">@Subhra264</a>)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/v1.8.5">v1.8.5</a> - 2021-05-10T11:58:14Z<a name="v1-8-5-https-github-com-laurent22-joplin-releases-tag-v1-8-5-2021-05-10t11-58-14z" href="#v1-8-5-https-github-com-laurent22-joplin-releases-tag-v1-8-5-2021-05-10t11-58-14z" class="heading-anchor">🔗</a></h2>
<ul>
<li>Fixed: Fixed pasting of text and images from Word on Windows (<a href="https://github.com/laurent22/joplin/issues/4916">#4916</a>)</li>
<li>Security: Filter out NOSCRIPT tags that could be used to cause an XSS (found by <a href="https://twitter.com/jubairfolder">Jubair Rehman Yousafzai</a>) (9c20d59)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/v1.8.4">v1.8.4</a> (Pre-release) - 2021-05-09T18:05:05Z<a name="v1-8-4-https-github-com-laurent22-joplin-releases-tag-v1-8-4-pre-release-2021-05-09t18-05-05z" href="#v1-8-4-https-github-com-laurent22-joplin-releases-tag-v1-8-4-pre-release-2021-05-09t18-05-05z" class="heading-anchor">🔗</a></h2>
<ul>
<li>Improved: Improve display of release notes for new versions (f76f99b)</li>
<li>Fixed: Ensure that image paths that contain spaces are pasted correctly in the Rich Text editor (<a href="https://github.com/laurent22/joplin/issues/4916">#4916</a>)</li>
<li>Fixed: Make sure sync startup operations are cleared after startup (<a href="https://github.com/laurent22/joplin/issues/4919">#4919</a>)</li>
<li>Security: Apply npm audit security fixes (0b67446)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/v1.8.3">v1.8.3</a> (Pre-release) - 2021-05-04T10:38:16Z<a name="v1-8-3-https-github-com-laurent22-joplin-releases-tag-v1-8-3-pre-release-2021-05-04t10-38-16z" href="#v1-8-3-https-github-com-laurent22-joplin-releases-tag-v1-8-3-pre-release-2021-05-04t10-38-16z" class="heading-anchor">🔗</a></h2> <h2><a href="https://github.com/laurent22/joplin/releases/tag/v1.8.3">v1.8.3</a> (Pre-release) - 2021-05-04T10:38:16Z<a name="v1-8-3-https-github-com-laurent22-joplin-releases-tag-v1-8-3-pre-release-2021-05-04t10-38-16z" href="#v1-8-3-https-github-com-laurent22-joplin-releases-tag-v1-8-3-pre-release-2021-05-04t10-38-16z" class="heading-anchor">🔗</a></h2>
<ul> <ul>
<li>New: Add &quot;id&quot; and &quot;due&quot; search filters (<a href="https://github.com/laurent22/joplin/issues/4898">#4898</a> by <a href="https://github.com/JackGruber">@JackGruber</a>)</li> <li>New: Add &quot;id&quot; and &quot;due&quot; search filters (<a href="https://github.com/laurent22/joplin/issues/4898">#4898</a> by <a href="https://github.com/JackGruber">@JackGruber</a>)</li>

View File

@@ -0,0 +1,443 @@
<!doctype html>
<html>
<!--
!!! WARNING !!!
This file was auto-generated from readme/changelog_android.md and any manual change
made to it will be overwritten. To make a change to this file please modify
the source Markdown file:
https://github.com/laurent22/joplin/blob/dev/readme/changelog_android.md
-->
<head>
<title>Joplin Android app changelog | Joplin</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://joplinapp.org/css/bootstrap.min.css">
<link rel="shortcut icon" type="image/x-icon" href="https://joplinapp.org/favicon.ico">
<!-- <link rel="stylesheet" href="https://joplinapp.org/css/fontawesome-all.min.css"> -->
<link rel="stylesheet" href="https://joplinapp.org/css/fork-awesome.min.css">
<script src="https://joplinapp.org/js/jquery-3.2.1.slim.min.js"></script>
<style>
body {
background-color: #F1F1F1;
color: #333333;
}
.root {
overflow: hidden;
}
a[href^="mailto:"] {
word-break: break-all;
}
table {
margin-bottom: 1em;
}
td, th {
padding: .8em;
border: 1px solid #ccc;
}
.page-markdown table pre,
.page-markdown table blockquote {
margin-bottom: 0;
}
.page-markdown table pre,
.page-markdown table blockquote {
margin-bottom: 0;
}
.page-markdown table pre {
background-color: rgba(0,0,0,0);
border: none;
margin: 0;
padding: 0;
}
h1, h2 {
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-weight: 600;
font-size: 2em;
margin-bottom: 16px;
}
h2 {
font-size: 1.6em;
}
h3 {
font-size: 1.3em;
}
code {
color: black;
background-color: #eee;
border: 1px solid #ccc;
font-size: .85em;
/* word-break: break-all; */
}
pre code {
border: none;
}
pre {
font-size: .85em;
}
blockquote {
font-size: 1em;
color: #555;
};
#toc ul {
margin-bottom: 10px;
}
#toc > ul > li {
margin-bottom: 10px;
}
#toc {
padding-bottom: 1em;
}
.title {
display: flex;
align-items: center;
}
.title-icon {
display: flex;
height: 1em;
}
.title-text {
display: flex;
font-weight: normal;
margin-bottom: .2em;
margin-left: .5em;
}
.sub-title {
font-weight: normal;
}
.container {
background-color: white;
padding: 0;
box-shadow: 0 10px 20px #888888;
}
table.screenshots {
margin-top: 2em;
margin-bottom: 2em;
}
table.screenshots th {
height: 3em;
text-align: center;
}
table.screenshots th,
table.screenshots td {
border: 1px solid #C2C2C2;
}
img[align="left"] {
margin-right: 10px;
margin-bottom: 10px;
}
.mobile-screenshot {
height: 40em;
padding: 1em;
}
.cli-screenshot-wrapper {
background-color: black;
vertical-align: top;
padding: 1em 2em 1em 1em;
}
.cli-screenshot {
font-family: "Monaco", "Inconsolata", "CONSOLAS", "Deja Vu Sans Mono", "Droid Sans Mono", "Andale Mono", monospace;
background-color: black;
color: white;
border: none;
}
.cli-screenshot .prompt {
color: #48C2F0;
}
.top-screenshot {
margin-top: 2em;
text-align: center;
}
.header {
position: relative;
padding-left: 2em;
padding-right: 2em;
padding-top: 1em;
padding-bottom: 1em;
color: white;
background-color: #2B2B3D;
}
.header a h1 {
color: white;
}
.header a:hover {
text-decoration: none;
}
.content {
padding-left: 2em;
padding-right: 2em;
padding-bottom: 2em;
padding-top: 2em;
}
.forkme {
position: absolute;
right: 0;
top:0;
}
.nav-wrapper {
position: relative;
width: inherit;
}
.nav {
background-color: black;
display: flex;
flex-direction: row;
align-items: center;
}
.nav.sticky {
position:fixed;
top: 0;
width: inherit;
box-shadow: 0 0 10px #000000;
}
.nav a {
color: white;
display: inline-block;
padding: .6em .9em .6em .9em;
}
.nav ul {
padding-left: 2em;
margin-bottom: 0;
display: table-cell;
display: flex;
width: 100%;
/* For GSoC: */
min-width: 470px;
}
.nav ul li {
display: inline-block;
padding: 0;
}
.nav li.selected {
background-color: #222;
font-weight: bold;
}
.nav-right {
display: flex;
text-align: right;
vertical-align: middle;
line-height: 0;
margin-right: 10px;
}
.nav-right .share-btn {
display: none;
}
.nav-right .small-share-btn {
display: none;
}
.footer {
padding: 2em;
border-top: 1px solid #d4d4d4;
margin-top: 2em;
color: gray;
font-size: .9em;
}
a.heading-anchor {
display: inline-block;
opacity: 0;
width: 1.3em;
font-size: 0.7em;
margin-left: 0.4em;
line-height: 1em;
text-decoration: none;
transition: opacity 0.3s;
}
a.heading-anchor:hover,
h1:hover a.heading-anchor,
h2:hover a.heading-anchor,
h3:hover a.heading-anchor,
h4:hover a.heading-anchor,
h5:hover a.heading-anchor,
h6:hover a.heading-anchor {
opacity: 1;
}
@media (min-width: 992px) {
.content{
display: flex;
}
#toc{
display: block!important;
align-self: flex-start;
width: 300px;
position: sticky; top: 20px; left: 0;
}
.main{
width: calc(100% - 300px);
}
}
.bottom-links {
display: flex;
justify-content: center;
border-top: 1px solid #d4d4d4;
margin-top: 30px;
padding-top: 25px;
}
@media all and (min-width: 400px) {
.nav-right .share-btn {
display: inline-block;
}
.nav-right .small-share-btn {
display: none;
}
}
</style>
</head>
<body>
<div class="container root page-changelog_android">
<div class="header">
<a class="forkme" href="https://github.com/laurent22/joplin"><img src="https://joplinapp.org/images/ForkMe.png"/></a>
<a href="https://joplinapp.org"><h1 class="title"><img class="title-icon" src="https://joplinapp.org/images/Icon512.png"><span class="title-text">Joplin</span></h1></a>
<p class="sub-title">An open source note taking and to-do application with synchronisation capabilities</p>
</div>
<div class="nav-wrapper">
<div class="nav">
<ul>
<li class=""><a href="https:&#x2F;&#x2F;joplinapp.org/" title="Home"><i class="fa fa-home"></i></a></li>
<li><a href="https://discourse.joplinapp.org" title="Forum">Forum</a></li>
<li><a class="gsoc" href="https://joplinapp.org/gsoc2021/index/" title="Google Summer of Code 2021">GSoC 2021</a></li>
</ul>
<div class="nav-right">
<iframe class="share-btn share-btn-github" src="https://ghbtns.com/github-btn.html?user=laurent22&repo=joplin&type=star&count=true" frameborder="0" scrolling="0" width="115px" height="20px"></iframe>
</div>
</div>
</div>
<div class="content">
<div id="toc"><ul>
<li>
<p>Applications</p>
<ul>
<li><a href="https://joplinapp.org/desktop/">Desktop application</a></li>
<li><a href="https://joplinapp.org/mobile/">Mobile applications</a></li>
<li><a href="https://joplinapp.org/terminal/">Terminal application</a></li>
<li><a href="https://joplinapp.org/clipper/">Web Clipper</a></li>
</ul>
</li>
<li>
<p>Support</p>
<ul>
<li><a href="https://discourse.joplinapp.org">Joplin Forum</a></li>
<li><a href="https://joplinapp.org/markdown/">Markdown Guide</a></li>
<li><a href="https://joplinapp.org/e2ee/">How to enable end-to-end encryption</a></li>
<li><a href="https://joplinapp.org/conflict/">What is a conflict?</a></li>
<li><a href="https://joplinapp.org/debugging/">How to enable debug mode</a></li>
<li><a href="https://joplinapp.org/rich_text_editor/">About the Rich Text editor limitations</a></li>
<li><a href="https://joplinapp.org/faq/">FAQ</a></li>
</ul>
</li>
<li>
<p>Joplin API - Get Started</p>
<ul>
<li><a href="https://joplinapp.org/api/overview/">Joplin API Overview</a></li>
<li><a href="https://joplinapp.org/api/get_started/plugins/">Plugin development</a></li>
<li><a href="https://joplinapp.org/api/tutorials/toc_plugin/">Plugin tutorial</a></li>
</ul>
</li>
<li>
<p>Joplin API - References</p>
<ul>
<li><a href="https://joplinapp.org/api/references/plugin_api/classes/joplin.html">Plugin API</a></li>
<li><a href="https://joplinapp.org/api/references/rest_api/">Data API</a></li>
<li><a href="https://joplinapp.org/api/references/plugin_manifest/">Plugin manifest</a></li>
<li><a href="https://joplinapp.org/api/references/plugin_loading_rules/">Plugin loading rules</a></li>
<li><a href="https://joplinapp.org/api/references/plugin_theming/">Plugin theming</a></li>
</ul>
</li>
<li>
<p>Development</p>
<ul>
<li><a href="https://github.com/laurent22/joplin/blob/dev/BUILD.md">How to build the apps</a></li>
<li><a href="https://joplinapp.org/spec/e2ee/">End-to-end encryption spec</a></li>
<li><a href="https://joplinapp.org/spec/history/">Note History spec</a></li>
<li><a href="https://joplinapp.org/spec/sync_lock/">Sync Lock spec</a></li>
<li><a href="https://joplinapp.org/spec/plugins/">Plugin Architecture spec</a></li>
<li><a href="https://joplinapp.org/spec/search_sorting/">Search Sorting spec</a></li>
<li><a href="https://joplinapp.org/spec/server_file_url_format/">Server: File URL Format</a></li>
<li><a href="https://joplinapp.org/spec/server_delta_sync/">Server: Delta Sync</a></li>
<li><a href="https://joplinapp.org/spec/server_sharing/">Server: Sharing</a></li>
</ul>
</li>
<li>
<p>Google Summer of Code 2021</p>
<ul>
<li><a href="https://joplinapp.org/gsoc2021/index/">Google Summer of Code 2021</a></li>
<li><a href="https://joplinapp.org/gsoc2021/pull_request_guidelines/">How to submit a GSoC pull request</a></li>
<li><a href="https://joplinapp.org/gsoc2021/ideas/">Project Ideas</a></li>
</ul>
</li>
<li>
<p>About</p>
<ul>
<li><a href="https://joplinapp.org/changelog/">Changelog (Desktop App)</a></li>
<li><a href="https://joplinapp.org/changelog_cli/">Changelog (CLI App)</a></li>
<li><a href="https://joplinapp.org/changelog_server/">Changelog (Server)</a></li>
<li><a href="https://joplinapp.org/stats/">Stats</a></li>
<li><a href="https://joplinapp.org/donate/">Donate</a></li>
</ul>
</li>
</ul>
</div>
<div class="main">
<p><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&amp;business=E8JMYD2LQ8MMA&amp;lc=GB&amp;item_name=Joplin+Development&amp;currency_code=EUR&amp;bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted"><img src="https://joplinapp.org/images/badges/Donate-PayPal-green.svg" alt="Donate using PayPal"></a> <a href="https://github.com/sponsors/laurent22/"><img src="https://joplinapp.org/images/badges/GitHub-Badge.svg" alt="Sponsor on GitHub"></a> <a href="https://www.patreon.com/joplin"><img src="https://joplinapp.org/images/badges/Patreon-Badge.svg" alt="Become a patron"></a> <a href="https://joplinapp.org/donate/#donations"><img src="https://joplinapp.org/images/badges/Donate-IBAN.svg" alt="Donate using IBAN"></a></p>
<hr>
<h1>Joplin Android app changelog<a name="joplin-android-app-changelog" href="#joplin-android-app-changelog" class="heading-anchor">🔗</a></h1>
<div class="bottom-links">
<a href="https://github.com/laurent22/joplin/blob/dev/readme/changelog_android.md">
<i class="fa fa-github"></i> Improve this doc
</a>
</div>
<script>
function stickyHeader() {
return; // Disabled
if ($(window).scrollTop() > 179) {
$('.nav').addClass('sticky');
} else {
$('.nav').removeClass('sticky');
}
}
$(window).scroll(function() {
stickyHeader();
});
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-103586105-1', 'auto');
ga('send', 'pageview');
</script>
</div></div>
<div class="footer">
Copyright (C) 2016-2021 Laurent Cozic
</div>
</body>
</html>

View File

@@ -405,6 +405,16 @@ https://github.com/laurent22/joplin/blob/dev/readme/changelog_server.md
<p><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&amp;business=E8JMYD2LQ8MMA&amp;lc=GB&amp;item_name=Joplin+Development&amp;currency_code=EUR&amp;bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted"><img src="https://joplinapp.org/images/badges/Donate-PayPal-green.svg" alt="Donate using PayPal"></a> <a href="https://github.com/sponsors/laurent22/"><img src="https://joplinapp.org/images/badges/GitHub-Badge.svg" alt="Sponsor on GitHub"></a> <a href="https://www.patreon.com/joplin"><img src="https://joplinapp.org/images/badges/Patreon-Badge.svg" alt="Become a patron"></a> <a href="https://joplinapp.org/donate/#donations"><img src="https://joplinapp.org/images/badges/Donate-IBAN.svg" alt="Donate using IBAN"></a></p> <p><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&amp;business=E8JMYD2LQ8MMA&amp;lc=GB&amp;item_name=Joplin+Development&amp;currency_code=EUR&amp;bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted"><img src="https://joplinapp.org/images/badges/Donate-PayPal-green.svg" alt="Donate using PayPal"></a> <a href="https://github.com/sponsors/laurent22/"><img src="https://joplinapp.org/images/badges/GitHub-Badge.svg" alt="Sponsor on GitHub"></a> <a href="https://www.patreon.com/joplin"><img src="https://joplinapp.org/images/badges/Patreon-Badge.svg" alt="Become a patron"></a> <a href="https://joplinapp.org/donate/#donations"><img src="https://joplinapp.org/images/badges/Donate-IBAN.svg" alt="Donate using IBAN"></a></p>
<hr> <hr>
<h1>Joplin Server Changelog<a name="joplin-server-changelog" href="#joplin-server-changelog" class="heading-anchor">🔗</a></h1> <h1>Joplin Server Changelog<a name="joplin-server-changelog" href="#joplin-server-changelog" class="heading-anchor">🔗</a></h1>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/server-v2.0.1">server-v2.0.1</a> (Pre-release) - 2021-05-14T13:55:45Z<a name="server-v2-0-1-https-github-com-laurent22-joplin-releases-tag-server-v2-0-1-pre-release-2021-05-14t13-55-45z" href="#server-v2-0-1-https-github-com-laurent22-joplin-releases-tag-server-v2-0-1-pre-release-2021-05-14t13-55-45z" class="heading-anchor">🔗</a></h2>
<ul>
<li>New: Add support for sharing notes via a link (ccbc329)</li>
<li>New: Add support for sharing a folder (#4772)</li>
<li>New: Added log page to view latest changes to files (874f301)</li>
<li>Fixed: Prevent new user password from being hashed twice (76c143e)</li>
<li>Fixed: Fixed crash when rendering note with links to non-existing resources or notes (07484de)</li>
<li>Fixed: Fixed error handling when no session is provided (63a5bfa)</li>
<li>Fixed: Fixed uploading empty file to the API (#4402)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/server-v1.7.2">server-v1.7.2</a> - 2021-01-24T19:11:10Z<a name="server-v1-7-2-https-github-com-laurent22-joplin-releases-tag-server-v1-7-2-2021-01-24t19-11-10z" href="#server-v1-7-2-https-github-com-laurent22-joplin-releases-tag-server-v1-7-2-2021-01-24t19-11-10z" class="heading-anchor">🔗</a></h2> <h2><a href="https://github.com/laurent22/joplin/releases/tag/server-v1.7.2">server-v1.7.2</a> - 2021-01-24T19:11:10Z<a name="server-v1-7-2-https-github-com-laurent22-joplin-releases-tag-server-v1-7-2-2021-01-24t19-11-10z" href="#server-v1-7-2-https-github-com-laurent22-joplin-releases-tag-server-v1-7-2-2021-01-24t19-11-10z" class="heading-anchor">🔗</a></h2>
<ul> <ul>
<li>Fixed: Fixed password hashing when changing password</li> <li>Fixed: Fixed password hashing when changing password</li>

View File

@@ -424,19 +424,19 @@ https://github.com/laurent22/joplin/blob/dev/README.md
<tbody> <tbody>
<tr> <tr>
<td>Windows (32 and 64-bit)</td> <td>Windows (32 and 64-bit)</td>
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.7.11/Joplin-Setup-1.7.11.exe'><img alt='Get it on Windows' width="134px" src='https://joplinapp.org/images/BadgeWindows.png'/></a></td> <td><a href='https://github.com/laurent22/joplin/releases/download/v1.8.5/Joplin-Setup-1.8.5.exe'><img alt='Get it on Windows' width="134px" src='https://joplinapp.org/images/BadgeWindows.png'/></a></td>
</tr> </tr>
<tr> <tr>
<td>macOS</td> <td>macOS</td>
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.7.11/Joplin-1.7.11.dmg'><img alt='Get it on macOS' width="134px" src='https://joplinapp.org/images/BadgeMacOS.png'/></a></td> <td><a href='https://github.com/laurent22/joplin/releases/download/v1.8.5/Joplin-1.8.5.dmg'><img alt='Get it on macOS' width="134px" src='https://joplinapp.org/images/BadgeMacOS.png'/></a></td>
</tr> </tr>
<tr> <tr>
<td>Linux</td> <td>Linux</td>
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.7.11/Joplin-1.7.11.AppImage'><img alt='Get it on Linux' width="134px" src='https://joplinapp.org/images/BadgeLinux.png'/></a></td> <td><a href='https://github.com/laurent22/joplin/releases/download/v1.8.5/Joplin-1.8.5.AppImage'><img alt='Get it on Linux' width="134px" src='https://joplinapp.org/images/BadgeLinux.png'/></a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p><strong>On Windows</strong>, you may also use the <a href='https://github.com/laurent22/joplin/releases/download/v1.7.11/JoplinPortable.exe'>Portable version</a>. The <a href="https://en.wikipedia.org/wiki/Portable_application">portable application</a> 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 &quot;JoplinProfile&quot; next to the executable file.</p> <p><strong>On Windows</strong>, you may also use the <a href='https://github.com/laurent22/joplin/releases/download/v1.8.5/JoplinPortable.exe'>Portable version</a>. The <a href="https://en.wikipedia.org/wiki/Portable_application">portable application</a> 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 &quot;JoplinProfile&quot; next to the executable file.</p>
<p><strong>On Linux</strong>, the recommended way is to use the following installation script as it will handle the desktop icon too:</p> <p><strong>On Linux</strong>, the recommended way is to use the following installation script as it will handle the desktop icon too:</p>
<pre><code style="word-break: break-all">wget -O - https://raw.githubusercontent.com/laurent22/joplin/dev/Joplin_install_and_update.sh | bash</code></pre> <pre><code style="word-break: break-all">wget -O - https://raw.githubusercontent.com/laurent22/joplin/dev/Joplin_install_and_update.sh | bash</code></pre>
<h2>Mobile applications<a name="mobile-applications" href="#mobile-applications" class="heading-anchor">🔗</a></h2> <h2>Mobile applications<a name="mobile-applications" href="#mobile-applications" class="heading-anchor">🔗</a></h2>
@@ -662,7 +662,7 @@ Joplin is also capable of exporting to a number of other formats including HTML
&quot;s3:DeleteObject&quot;, &quot;s3:DeleteObject&quot;,
&quot;s3:DeleteObjectVersion&quot;, &quot;s3:DeleteObjectVersion&quot;,
&quot;s3:PutObject&quot; &quot;s3:PutObject&quot;
] ],
&quot;Resource&quot;: [ &quot;Resource&quot;: [
&quot;arn:aws:s3:::joplin-bucket&quot;, &quot;arn:aws:s3:::joplin-bucket&quot;,
&quot;arn:aws:s3:::joplin-bucket/*&quot; &quot;arn:aws:s3:::joplin-bucket/*&quot;
@@ -692,7 +692,7 @@ Joplin is also capable of exporting to a number of other formats including HTML
<ul> <ul>
<li><strong>Windows</strong>: &gt;= 8. Make sure the Action Center is enabled on Windows. Task bar balloon for Windows &lt; 8. Growl as fallback. Growl takes precedence over Windows balloons.</li> <li><strong>Windows</strong>: &gt;= 8. Make sure the Action Center is enabled on Windows. Task bar balloon for Windows &lt; 8. Growl as fallback. Growl takes precedence over Windows balloons.</li>
<li><strong>macOS</strong>: &gt;= 10.8 or Growl if earlier.</li> <li><strong>macOS</strong>: &gt;= 10.8 or Growl if earlier.</li>
<li><strong>Linux</strong>: <code>notify-osd</code> or <code>libnotify-bin</code> installed (Ubuntu should have this by default). Growl otherwise</li> <li><strong>Linux</strong>: <code>notify-send</code> tool, delivered through packages <code>notify-osd</code>, <code>libnotify-bin</code> or <code>libnotify-tools</code>. GNOME should have this by default, but install <code>libnotify-tools</code> if using KDE Plasma.</li>
</ul> </ul>
<p>See <a href="https://github.com/mikaelbr/node-notifier/blob/master/DECISION_FLOW.md">documentation and flow chart for reporter choice</a></p> <p>See <a href="https://github.com/mikaelbr/node-notifier/blob/master/DECISION_FLOW.md">documentation and flow chart for reporter choice</a></p>
<p>On mobile, the alarms will be displayed using the built-in notification system.</p> <p>On mobile, the alarms will be displayed using the built-in notification system.</p>
@@ -989,49 +989,49 @@ Eg. <code>:search -- &quot;-tag:tag1&quot;</code>.</p>
<td>Arabic</td> <td>Arabic</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po">ar</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po">ar</a></td>
<td><a href="mailto:Whaell@protonmail.com">Whaell O</a></td> <td><a href="mailto:Whaell@protonmail.com">Whaell O</a></td>
<td>99%</td> <td>96%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/es/basque_country.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/es/basque_country.png" alt=""></td>
<td>Basque</td> <td>Basque</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eu.po">eu</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eu.po">eu</a></td>
<td>juan.abasolo@ehu.eus</td> <td>juan.abasolo@ehu.eus</td>
<td>31%</td> <td>30%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/ba.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/ba.png" alt=""></td>
<td>Bosnian (Bosna i Hercegovina)</td> <td>Bosnian (Bosna i Hercegovina)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bs_BA.po">bs_BA</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bs_BA.po">bs_BA</a></td>
<td><a href="mailto:dervis.t@pm.me">Derviš T.</a></td> <td><a href="mailto:dervis.t@pm.me">Derviš T.</a></td>
<td>74%</td> <td>75%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/bg.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/bg.png" alt=""></td>
<td>Bulgarian (България)</td> <td>Bulgarian (България)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po">bg_BG</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po">bg_BG</a></td>
<td></td> <td></td>
<td>60%</td> <td>58%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/es/catalonia.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/es/catalonia.png" alt=""></td>
<td>Catalan</td> <td>Catalan</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ca.po">ca</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ca.po">ca</a></td>
<td>jmontane, 2019</td> <td>jmontane, 2019</td>
<td>85%</td> <td>83%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/hr.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/hr.png" alt=""></td>
<td>Croatian (Hrvatska)</td> <td>Croatian (Hrvatska)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hr_HR.po">hr_HR</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hr_HR.po">hr_HR</a></td>
<td><a href="mailto:mail@milotype.de">Milo Ivir</a></td> <td><a href="mailto:mail@milotype.de">Milo Ivir</a></td>
<td>99%</td> <td>96%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/cz.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/cz.png" alt=""></td>
<td>Czech (Česká republika)</td> <td>Czech (Česká republika)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po">cs_CZ</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po">cs_CZ</a></td>
<td><a href="mailto:lukas@aiya.cz">Lukas Helebrandt</a></td> <td><a href="mailto:lukas@aiya.cz">Lukas Helebrandt</a></td>
<td>89%</td> <td>86%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/dk.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/dk.png" alt=""></td>
@@ -1045,14 +1045,14 @@ Eg. <code>:search -- &quot;-tag:tag1&quot;</code>.</p>
<td>Deutsch (Deutschland)</td> <td>Deutsch (Deutschland)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/de_DE.po">de_DE</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/de_DE.po">de_DE</a></td>
<td><a href="mailto:atalanttore@googlemail.com">Atalanttore</a></td> <td><a href="mailto:atalanttore@googlemail.com">Atalanttore</a></td>
<td>98%</td> <td>95%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/ee.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/ee.png" alt=""></td>
<td>Eesti Keel (Eesti)</td> <td>Eesti Keel (Eesti)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/et_EE.po">et_EE</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/et_EE.po">et_EE</a></td>
<td></td> <td></td>
<td>58%</td> <td>57%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/gb.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/gb.png" alt=""></td>
@@ -1073,203 +1073,203 @@ Eg. <code>:search -- &quot;-tag:tag1&quot;</code>.</p>
<td>Español (España)</td> <td>Español (España)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po">es_ES</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po">es_ES</a></td>
<td><a href="mailto:mario.campo@gmail.com">Mario Campo</a></td> <td><a href="mailto:mario.campo@gmail.com">Mario Campo</a></td>
<td>97%</td> <td>94%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/esperanto.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/esperanto.png" alt=""></td>
<td>Esperanto</td> <td>Esperanto</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eo.po">eo</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eo.po">eo</a></td>
<td>Marton Paulo</td> <td>Marton Paulo</td>
<td>34%</td> <td>33%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/fi.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/fi.png" alt=""></td>
<td>Finnish (Suomi)</td> <td>Finnish (Suomi)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po">fi_FI</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po">fi_FI</a></td>
<td>mrkaato</td> <td>mrkaato</td>
<td>97%</td> <td>94%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/fr.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/fr.png" alt=""></td>
<td>Français (France)</td> <td>Français (France)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fr_FR.po">fr_FR</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fr_FR.po">fr_FR</a></td>
<td>Laurent Cozic</td> <td>Laurent Cozic</td>
<td>95%</td> <td>99%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/es/galicia.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/es/galicia.png" alt=""></td>
<td>Galician (España)</td> <td>Galician (España)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po">gl_ES</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po">gl_ES</a></td>
<td><a href="mailto:marcoslansgarza@gmail.com">Marcos Lans</a></td> <td><a href="mailto:marcoslansgarza@gmail.com">Marcos Lans</a></td>
<td>39%</td> <td>38%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/id.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/id.png" alt=""></td>
<td>Indonesian (Indonesia)</td> <td>Indonesian (Indonesia)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po">id_ID</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po">id_ID</a></td>
<td><a href="mailto:42007357+eresytter@users.noreply.github.com">eresytter</a></td> <td><a href="mailto:42007357+eresytter@users.noreply.github.com">eresytter</a></td>
<td>96%</td> <td>93%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/it.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/it.png" alt=""></td>
<td>Italiano (Italia)</td> <td>Italiano (Italia)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po">it_IT</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po">it_IT</a></td>
<td><a href="mailto:mailfilledwithspam@gmail.com">Alessandro Bernardello</a></td> <td><a href="mailto:mailfilledwithspam@gmail.com">Alessandro Bernardello</a></td>
<td>97%</td> <td>94%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/hu.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/hu.png" alt=""></td>
<td>Magyar (Magyarország)</td> <td>Magyar (Magyarország)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po">hu_HU</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po">hu_HU</a></td>
<td><a href="mailto:mail@szokesandor.hu">Szőke Sándor</a></td> <td><a href="mailto:mail@szokesandor.hu">Szőke Sándor</a></td>
<td>91%</td> <td>88%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/be.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/be.png" alt=""></td>
<td>Nederlands (België, Belgique, Belgien)</td> <td>Nederlands (België, Belgique, Belgien)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_BE.po">nl_BE</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_BE.po">nl_BE</a></td>
<td></td> <td></td>
<td>95%</td> <td>92%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/nl.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/nl.png" alt=""></td>
<td>Nederlands (Nederland)</td> <td>Nederlands (Nederland)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_NL.po">nl_NL</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_NL.po">nl_NL</a></td>
<td><a href="mailto:metbril@users.noreply.github.com">MetBril</a></td> <td><a href="mailto:metbril@users.noreply.github.com">MetBril</a></td>
<td>98%</td> <td>95%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/no.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/no.png" alt=""></td>
<td>Norwegian (Norge, Noreg)</td> <td>Norwegian (Norge, Noreg)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po">nb_NO</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po">nb_NO</a></td>
<td><a href="mailto:code@mxe.no">Mats Estensen</a></td> <td><a href="mailto:code@mxe.no">Mats Estensen</a></td>
<td>78%</td> <td>76%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/ir.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/ir.png" alt=""></td>
<td>Persian</td> <td>Persian</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fa.po">fa</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fa.po">fa</a></td>
<td><a href="mailto:kourox@protonmail.com">Kourosh Firoozbakht</a></td> <td><a href="mailto:kourox@protonmail.com">Kourosh Firoozbakht</a></td>
<td>74%</td> <td>71%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/pl.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/pl.png" alt=""></td>
<td>Polski (Polska)</td> <td>Polski (Polska)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po">pl_PL</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po">pl_PL</a></td>
<td><a href="mailto:hello.konhi@gmail.com">konhi</a></td> <td><a href="mailto:hello.konhi@gmail.com">konhi</a></td>
<td>97%</td> <td>94%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/br.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/br.png" alt=""></td>
<td>Português (Brasil)</td> <td>Português (Brasil)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po">pt_BR</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po">pt_BR</a></td>
<td><a href="mailto:nicolas.suzuki@pm.me">Nicolas Suzuki</a></td> <td><a href="mailto:nicolas.suzuki@pm.me">Nicolas Suzuki</a></td>
<td>97%</td> <td>94%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/pt.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/pt.png" alt=""></td>
<td>Português (Portugal)</td> <td>Português (Portugal)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po">pt_PT</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po">pt_PT</a></td>
<td><a href="mailto:dcaveiro@yahoo.com">Diogo Caveiro</a></td> <td><a href="mailto:dcaveiro@yahoo.com">Diogo Caveiro</a></td>
<td>97%</td> <td>94%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/ro.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/ro.png" alt=""></td>
<td>Română</td> <td>Română</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ro.po">ro</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ro.po">ro</a></td>
<td><a href="mailto:cristi.duluta@gmail.com">Cristi Duluta</a></td> <td><a href="mailto:cristi.duluta@gmail.com">Cristi Duluta</a></td>
<td>68%</td> <td>66%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/si.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/si.png" alt=""></td>
<td>Slovenian (Slovenija)</td> <td>Slovenian (Slovenija)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sl_SI.po">sl_SI</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sl_SI.po">sl_SI</a></td>
<td><a href="mailto:martin.korelic@protonmail.com">Martin Korelič</a></td> <td><a href="mailto:martin.korelic@protonmail.com">Martin Korelič</a></td>
<td>99%</td> <td>96%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/se.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/se.png" alt=""></td>
<td>Svenska</td> <td>Svenska</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sv.po">sv</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sv.po">sv</a></td>
<td><a href="mailto:jonatan@autistici.org">Jonatan Nyberg</a></td> <td><a href="mailto:jonatan@autistici.org">Jonatan Nyberg</a></td>
<td>63%</td> <td>61%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/th.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/th.png" alt=""></td>
<td>Thai (ประเทศไทย)</td> <td>Thai (ประเทศไทย)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/th_TH.po">th_TH</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/th_TH.po">th_TH</a></td>
<td></td> <td></td>
<td>47%</td> <td>45%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/vi.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/vi.png" alt=""></td>
<td>Tiếng Việt</td> <td>Tiếng Việt</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/vi.po">vi</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/vi.po">vi</a></td>
<td></td> <td></td>
<td>75%</td> <td>73%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/tr.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/tr.png" alt=""></td>
<td>Türkçe (Türkiye)</td> <td>Türkçe (Türkiye)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/tr_TR.po">tr_TR</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/tr_TR.po">tr_TR</a></td>
<td><a href="mailto:arda@kilicdagi.com">Arda Kılıçdağı</a></td> <td><a href="mailto:arda@kilicdagi.com">Arda Kılıçdağı</a></td>
<td>97%</td> <td>94%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/ua.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/ua.png" alt=""></td>
<td>Ukrainian (Україна)</td> <td>Ukrainian (Україна)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po">uk_UA</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po">uk_UA</a></td>
<td><a href="mailto:vandreykiv@gmail.com">Vyacheslav Andreykiv</a></td> <td><a href="mailto:vandreykiv@gmail.com">Vyacheslav Andreykiv</a></td>
<td>97%</td> <td>94%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/gr.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/gr.png" alt=""></td>
<td>Ελληνικά (Ελλάδα)</td> <td>Ελληνικά (Ελλάδα)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/el_GR.po">el_GR</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/el_GR.po">el_GR</a></td>
<td><a href="mailto:xaris@tuta.io">Harris Arvanitis</a></td> <td><a href="mailto:xaris@tuta.io">Harris Arvanitis</a></td>
<td>85%</td> <td>97%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/ru.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/ru.png" alt=""></td>
<td>Русский (Россия)</td> <td>Русский (Россия)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po">ru_RU</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po">ru_RU</a></td>
<td><a href="mailto:thesermanarm@gmail.com">Sergey Segeda</a></td> <td><a href="mailto:thesermanarm@gmail.com">Sergey Segeda</a></td>
<td>97%</td> <td>94%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/rs.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/rs.png" alt=""></td>
<td>српски језик (Србија)</td> <td>српски језик (Србија)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sr_RS.po">sr_RS</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sr_RS.po">sr_RS</a></td>
<td></td> <td></td>
<td>73%</td> <td>71%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/cn.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/cn.png" alt=""></td>
<td>中文 (简体)</td> <td>中文 (简体)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po">zh_CN</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po">zh_CN</a></td>
<td><a href="mailto:zyangmath@gmail.com">Yang Zhang</a></td> <td><a href="mailto:zyangmath@gmail.com">Yang Zhang</a></td>
<td>97%</td> <td>94%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/tw.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/tw.png" alt=""></td>
<td>中文 (繁體)</td> <td>中文 (繁體)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po">zh_TW</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po">zh_TW</a></td>
<td><a href="mailto:yaozeye@yahoo.co.jp">Yaoze Ye</a></td> <td><a href="mailto:yaozeye@yahoo.co.jp">Yaoze Ye</a></td>
<td>95%</td> <td>92%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/jp.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/jp.png" alt=""></td>
<td>日本語 (日本)</td> <td>日本語 (日本)</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ja_JP.po">ja_JP</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ja_JP.po">ja_JP</a></td>
<td><a href="mailto:genneko217@gmail.com">genneko</a></td> <td><a href="mailto:genneko217@gmail.com">genneko</a></td>
<td>98%</td> <td>97%</td>
</tr> </tr>
<tr> <tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/kr.png" alt=""></td> <td><img src="https://joplinapp.org/images/flags/country-4x3/kr.png" alt=""></td>
<td>한국어</td> <td>한국어</td>
<td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po">ko</a></td> <td><a href="https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po">ko</a></td>
<td><a href="mailto:potatogim@potatogim.net">Ji-Hyeon Gim</a></td> <td><a href="mailto:potatogim@potatogim.net">Ji-Hyeon Gim</a></td>
<td>97%</td> <td>96%</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -101,15 +101,10 @@
"default": "", "default": "",
"description": "Joplin Server URL. Attention: If you change this location, make sure you copy all your content to it before syncing, otherwise all files will be removed! See the FAQ for more details: https://joplinapp.org/faq/" "description": "Joplin Server URL. Attention: If you change this location, make sure you copy all your content to it before syncing, otherwise all files will be removed! See the FAQ for more details: https://joplinapp.org/faq/"
}, },
"sync.9.directory": {
"type": "string",
"default": "Apps/Joplin",
"description": "Joplin Server Directory"
},
"sync.9.username": { "sync.9.username": {
"type": "string", "type": "string",
"default": "", "default": "",
"description": "Joplin Server username" "description": "Joplin Server email"
}, },
"sync.9.password": { "sync.9.password": {
"type": "string", "type": "string",
@@ -602,6 +597,11 @@
"default": -1, "default": -1,
"$comment": "private" "$comment": "private"
}, },
"sync.userId": {
"type": "string",
"default": "",
"$comment": "private"
},
"style.zoom": { "style.zoom": {
"type": "integer", "type": "integer",
"default": 100, "default": 100,
@@ -633,7 +633,7 @@
}, },
"autoUpdateEnabled": { "autoUpdateEnabled": {
"type": "boolean", "type": "boolean",
"default": false, "default": true,
"description": "Automatically update the application" "description": "Automatically update the application"
}, },
"autoUpdate.includePreReleases": { "autoUpdate.includePreReleases": {
@@ -726,12 +726,6 @@
"description": "Enable spell checking in Markdown editor? (WARNING BETA feature). 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)", "description": "Enable spell checking in Markdown editor? (WARNING BETA feature). Spell checker in the Markdown editor was previously unstable (cursor location was not stable, sometimes edits would not be saved or reflected in the viewer, etc.) however it appears to be more reliable now. If you notice any issue, please report it on GitHub or the Joplin Forum (Help -> Joplin Forum)",
"$comment": "private" "$comment": "private"
}, },
"image.noresizing": {
"type": "boolean",
"default": false,
"description": "Do not resize images",
"$comment": "private"
},
"net.customCertificates": { "net.customCertificates": {
"type": "string", "type": "string",
"default": "", "default": "",

File diff suppressed because it is too large Load Diff

View File

@@ -33,8 +33,7 @@ module.exports = {
'<rootDir>/node_modules/', '<rootDir>/node_modules/',
'<rootDir>/tests/support/', '<rootDir>/tests/support/',
'<rootDir>/build/', '<rootDir>/build/',
'<rootDir>/tests/test-utils.js', '<rootDir>/tests/testUtils.js',
'<rootDir>/tests/test-utils-synchronizer.js',
'<rootDir>/tests/tmp/', '<rootDir>/tests/tmp/',
'<rootDir>/tests/test data/', '<rootDir>/tests/test data/',
], ],

View File

@@ -7,8 +7,8 @@ import Setting from '@joplin/lib/models/Setting';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import Note from '@joplin/lib/models/Note'; import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder'; import Folder from '@joplin/lib/models/Folder';
import { newPluginScript } from '@joplin/lib/testing/test-utils';
import { expectNotThrow, setupDatabaseAndSynchronizer, switchClient, expectThrow, createTempDir, supportDir } from '@joplin/lib/testing/test-utils'; import { expectNotThrow, setupDatabaseAndSynchronizer, switchClient, expectThrow, createTempDir, supportDir } from '@joplin/lib/testing/test-utils';
import { newPluginScript } from '../../testUtils';
const testPluginDir = `${supportDir}/plugins`; const testPluginDir = `${supportDir}/plugins`;

View File

@@ -1,7 +1,7 @@
import Setting from '@joplin/lib/models/Setting'; import Setting from '@joplin/lib/models/Setting';
import PluginService from '@joplin/lib/services/plugins/PluginService'; import { waitForFolderCount, setupDatabaseAndSynchronizer, switchClient, afterEachCleanUp } from '@joplin/lib/testing/test-utils';
const { waitForFolderCount, newPluginService, newPluginScript, setupDatabaseAndSynchronizer, switchClient, afterEachCleanUp } = require('@joplin/lib/testing/test-utils');
import Folder from '@joplin/lib/models/Folder'; import Folder from '@joplin/lib/models/Folder';
import { newPluginScript, newPluginService } from '../../../testUtils';
describe('JoplinSettings', () => { describe('JoplinSettings', () => {
@@ -16,7 +16,7 @@ describe('JoplinSettings', () => {
}); });
test('should listen to setting change event', async () => { test('should listen to setting change event', async () => {
const service = new newPluginService() as PluginService; const service = newPluginService();
const pluginScript = newPluginScript(` const pluginScript = newPluginScript(`
joplin.plugins.register({ joplin.plugins.register({
@@ -68,7 +68,7 @@ describe('JoplinSettings', () => {
}); });
test('should allow registering multiple settings', async () => { test('should allow registering multiple settings', async () => {
const service = new newPluginService() as PluginService; const service = newPluginService();
const pluginScript = newPluginScript(` const pluginScript = newPluginScript(`
joplin.plugins.register({ joplin.plugins.register({

View File

@@ -1,6 +1,6 @@
import KeymapService from '@joplin/lib/services/KeymapService'; import KeymapService from '@joplin/lib/services/KeymapService';
import PluginService from '@joplin/lib/services/plugins/PluginService'; import { setupDatabaseAndSynchronizer, switchClient, afterEachCleanUp } from '@joplin/lib/testing/test-utils';
const { newPluginService, newPluginScript, setupDatabaseAndSynchronizer, switchClient, afterEachCleanUp } = require('@joplin/lib/testing/test-utils'); import { newPluginScript, newPluginService } from '../../../testUtils';
describe('JoplinViewMenuItem', () => { describe('JoplinViewMenuItem', () => {
@@ -15,7 +15,7 @@ describe('JoplinViewMenuItem', () => {
}); });
test('should register commands with the keymap service', async () => { test('should register commands with the keymap service', async () => {
const service = new newPluginService() as PluginService; const service = newPluginService();
KeymapService.instance().initialize(); KeymapService.instance().initialize();

View File

@@ -1,8 +1,9 @@
import Setting from '@joplin/lib/models/Setting'; import Setting from '@joplin/lib/models/Setting';
import { newPluginService, newPluginScript, setupDatabaseAndSynchronizer, switchClient, afterEachCleanUp } from '@joplin/lib/testing/test-utils'; import { setupDatabaseAndSynchronizer, switchClient, afterEachCleanUp } from '@joplin/lib/testing/test-utils';
import Note from '@joplin/lib/models/Note'; import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder'; import Folder from '@joplin/lib/models/Folder';
import ItemChange from '@joplin/lib/models/ItemChange'; import ItemChange from '@joplin/lib/models/ItemChange';
import { newPluginScript, newPluginService } from '../../../testUtils';
describe('JoplinWorkspace', () => { describe('JoplinWorkspace', () => {

View File

@@ -1,6 +1,5 @@
import sandboxProxy, { Target } from '@joplin/lib/services/plugins/sandboxProxy'; import sandboxProxy, { Target } from '@joplin/lib/services/plugins/sandboxProxy';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
const { setupDatabaseAndSynchronizer, switchClient } = require('@joplin/lib/testing/test-utils.js');
describe('services_plugins_sandboxProxy', function() { describe('services_plugins_sandboxProxy', function() {

View File

@@ -0,0 +1,41 @@
import PluginService from '@joplin/lib/services/plugins/PluginService';
import PluginRunner from '../app/services/plugins/PluginRunner';
export interface PluginServiceOptions {
getState?(): Record<string, any>;
}
export function newPluginService(appVersion = '1.4', options: PluginServiceOptions = null): PluginService {
options = options || {};
const runner = new PluginRunner();
const service = new PluginService();
service.initialize(
appVersion,
{
joplin: {},
},
runner,
{
dispatch: () => {},
getState: options.getState ? options.getState : () => {},
}
);
return service;
}
export function newPluginScript(script: string) {
return `
/* joplin-manifest:
{
"id": "org.joplinapp.plugins.PluginTest",
"manifest_version": 1,
"app_min_version": "1.4",
"name": "JS Bundle test",
"version": "1.0.0"
}
*/
${script}
`;
}

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import { useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect } from 'react';
import useWindowResizeEvent from './utils/useWindowResizeEvent'; import useWindowResizeEvent from './utils/useWindowResizeEvent';
import setLayoutItemProps from './utils/setLayoutItemProps'; import setLayoutItemProps from './utils/setLayoutItemProps';
import useLayoutItemSizes, { LayoutItemSizes, itemSize } from './utils/useLayoutItemSizes'; import useLayoutItemSizes, { LayoutItemSizes, itemSize, calculateMaxSizeAvailableForItem, itemMinWidth, itemMinHeight } from './utils/useLayoutItemSizes';
import validateLayout from './utils/validateLayout'; import validateLayout from './utils/validateLayout';
import { Size, LayoutItem } from './utils/types'; import { Size, LayoutItem } from './utils/types';
import { canMove, MoveDirection } from './utils/movements'; import { canMove, MoveDirection } from './utils/movements';
@@ -11,9 +11,6 @@ import { StyledWrapperRoot, StyledMoveOverlay, MoveModeRootWrapper, MoveModeRoot
import { Resizable } from 're-resizable'; import { Resizable } from 're-resizable';
const EventEmitter = require('events'); const EventEmitter = require('events');
const itemMinWidth = 20;
const itemMinHeight = 20;
interface onResizeEvent { interface onResizeEvent {
layout: LayoutItem; layout: LayoutItem;
} }
@@ -35,7 +32,7 @@ function itemVisible(item: LayoutItem, moveMode: boolean) {
return item.visible !== false; return item.visible !== false;
} }
function renderContainer(item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, onResizeStart: Function, onResize: Function, onResizeStop: Function, children: any[], isLastChild: boolean, moveMode: boolean): any { function renderContainer(item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, resizedItemMaxSize: Size | null, onResizeStart: Function, onResize: Function, onResizeStop: Function, children: any[], isLastChild: boolean, moveMode: boolean): any {
const style: any = { const style: any = {
display: itemVisible(item, moveMode) ? 'flex' : 'none', display: itemVisible(item, moveMode) ? 'flex' : 'none',
flexDirection: item.direction, flexDirection: item.direction,
@@ -68,6 +65,8 @@ function renderContainer(item: LayoutItem, parent: LayoutItem | null, sizes: Lay
enable={enable} enable={enable}
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth} minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight} minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
maxWidth={resizedItemMaxSize?.width}
maxHeight={resizedItemMaxSize?.height}
> >
{children} {children}
</Resizable> </Resizable>
@@ -114,6 +113,7 @@ function ResizableLayout(props: Props) {
key: item.key, key: item.key,
initialWidth: sizes[item.key].width, initialWidth: sizes[item.key].width,
initialHeight: sizes[item.key].height, initialHeight: sizes[item.key].height,
maxSize: calculateMaxSizeAvailableForItem(item, parent, sizes),
}); });
} }
@@ -143,6 +143,7 @@ function ResizableLayout(props: Props) {
setResizedItem(null); setResizedItem(null);
} }
const resizedItemMaxSize = item.key === resizedItem?.key ? resizedItem.maxSize : null;
if (!item.children) { if (!item.children) {
const size = itemSize(item, parent, sizes, false); const size = itemSize(item, parent, sizes, false);
@@ -155,7 +156,7 @@ function ResizableLayout(props: Props) {
const wrapper = renderItemWrapper(comp, item, parent, size, props.moveMode); const wrapper = renderItemWrapper(comp, item, parent, size, props.moveMode);
return renderContainer(item, parent, sizes, onResizeStart, onResize, onResizeStop, [wrapper], isLastChild, props.moveMode); return renderContainer(item, parent, sizes, resizedItemMaxSize, onResizeStart, onResize, onResizeStop, [wrapper], isLastChild, props.moveMode);
} else { } else {
const childrenComponents = []; const childrenComponents = [];
for (let i = 0; i < item.children.length; i++) { for (let i = 0; i < item.children.length; i++) {
@@ -163,7 +164,7 @@ function ResizableLayout(props: Props) {
childrenComponents.push(renderLayoutItem(child, item, sizes, isVisible && itemVisible(child, props.moveMode), i === item.children.length - 1)); childrenComponents.push(renderLayoutItem(child, item, sizes, isVisible && itemVisible(child, props.moveMode), i === item.children.length - 1));
} }
return renderContainer(item, parent, sizes, onResizeStart, onResize, onResizeStop, childrenComponents, isLastChild, props.moveMode); return renderContainer(item, parent, sizes, resizedItemMaxSize, onResizeStart, onResize, onResizeStop, childrenComponents, isLastChild, props.moveMode);
} }
} }

View File

@@ -1,4 +1,4 @@
import useLayoutItemSizes, { itemSize } from './useLayoutItemSizes'; import useLayoutItemSizes, { itemSize, calculateMaxSizeAvailableForItem } from './useLayoutItemSizes';
import { LayoutItem, LayoutItemDirection } from './types'; import { LayoutItem, LayoutItemDirection } from './types';
import { renderHook } from '@testing-library/react-hooks'; import { renderHook } from '@testing-library/react-hooks';
import validateLayout from './validateLayout'; import validateLayout from './validateLayout';
@@ -138,4 +138,219 @@ describe('useLayoutItemSizes', () => {
expect(itemSize(parent.children[1], parent, sizes, false)).toEqual({ width: 95, height: 50 }); expect(itemSize(parent.children[1], parent, sizes, false)).toEqual({ width: 95, height: 50 });
}); });
test('should decrease size of the largest item if the total size would be larger than the container', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 110,
},
{
key: 'col2',
width: 100,
},
{
key: 'col3',
minWidth: 50,
},
],
});
const { result } = renderHook(() => useLayoutItemSizes(layout));
const sizes = result.current;
expect(sizes.col1.width).toBe(50);
expect(sizes.col2.width).toBe(100);
expect(sizes.col3.width).toBe(50);
});
test('should not allow a minWidth of 0, should still make space for the item', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 210,
},
{
key: 'col2',
minWidth: 0,
},
],
});
const { result } = renderHook(() => useLayoutItemSizes(layout));
const sizes = result.current;
expect(sizes.col1.width).toBe(160);
expect(sizes.col2.width).toBe(40); // default minWidth is 40
});
test('should ignore invisible items when counting remaining size', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 110,
visible: false,
},
{
key: 'col2',
width: 100,
},
{
key: 'col3',
minWidth: 50,
},
],
});
const { result } = renderHook(() => useLayoutItemSizes(layout));
const sizes = result.current;
expect(sizes.col1.width).toBe(0);
expect(sizes.col2.width).toBe(100);
expect(sizes.col3.width).toBe(100);
});
test('should ignore invisible items when selecting largest child', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 110,
visible: false,
},
{
key: 'col2',
width: 100,
},
{
key: 'col3',
width: 110,
},
{
key: 'col4',
minWidth: 50,
},
],
});
const { result } = renderHook(() => useLayoutItemSizes(layout));
const sizes = result.current;
expect(sizes.col1.width).toBe(0);
expect(sizes.col2.width).toBe(100);
expect(sizes.col3.width).toBe(50);
expect(sizes.col4.width).toBe(50);
});
});
describe('calculateMaxSizeAvailableForItem', () => {
test('should give maximum available space this item can take up during resizing', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 50,
},
{
key: 'col2',
width: 70,
},
{
key: 'col3',
},
],
});
const { result } = renderHook(() => useLayoutItemSizes(layout));
const sizes = result.current;
const maxSize1 = calculateMaxSizeAvailableForItem(layout.children[0], layout, sizes);
const maxSize2 = calculateMaxSizeAvailableForItem(layout.children[1], layout, sizes);
// maxSize = totalSize - ( [size of items with set size, except for the current item] + [minimum size of items with no set size] )
expect(maxSize1.width).toBe(90); // 90 = layout.width - (col2.width + col3.minWidth(=40) )
expect(maxSize2.width).toBe(110); // 110 = layout.width - (col1.width + col3.minWidth(=40) )
});
test('should respect minimum sizes', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 50,
},
{
key: 'col2',
width: 70,
},
{
key: 'col3',
minWidth: 60,
},
],
});
const { result } = renderHook(() => useLayoutItemSizes(layout));
const sizes = result.current;
const maxSize1 = calculateMaxSizeAvailableForItem(layout.children[0], layout, sizes);
const maxSize2 = calculateMaxSizeAvailableForItem(layout.children[1], layout, sizes);
// maxSize = totalSize - ( [size of items with set size, except for the current item] + [minimum size of items with no set size] )
expect(maxSize1.width).toBe(70); // 70 = layout.width - (col2.width + col3.minWidth)
expect(maxSize2.width).toBe(90); // 90 = layout.width - (col1.width + col3.minWidth)
});
test('should not allow a minWidth of 0, should still leave space for the item', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 50,
},
{
key: 'col2',
minWidth: 0,
},
],
});
const { result } = renderHook(() => useLayoutItemSizes(layout));
const sizes = result.current;
const maxSize1 = calculateMaxSizeAvailableForItem(layout.children[0], layout, sizes);
// maxSize = totalSize - ( [size of items with set size, except for the current item] + [minimum size of items with no set size] )
expect(maxSize1.width).toBe(160); // 160 = layout.width - col2.minWidth(=40)
});
}); });

View File

@@ -3,6 +3,9 @@ import { LayoutItem, Size } from './types';
const dragBarThickness = 5; const dragBarThickness = 5;
export const itemMinWidth = 40;
export const itemMinHeight = 40;
export interface LayoutItemSizes { export interface LayoutItemSizes {
[key: string]: Size; [key: string]: Size;
} }
@@ -17,8 +20,8 @@ export function itemSize(item: LayoutItem, parent: LayoutItem | null, sizes: Lay
const bottomGap = !isContainer && (item.resizableBottom || parentResizableBottom) ? dragBarThickness : 0; const bottomGap = !isContainer && (item.resizableBottom || parentResizableBottom) ? dragBarThickness : 0;
return { return {
width: ('width' in item ? item.width : sizes[item.key].width) - rightGap, width: sizes[item.key].width - rightGap,
height: ('height' in item ? item.height : sizes[item.key].height) - bottomGap, height: sizes[item.key].height - bottomGap,
}; };
} }
@@ -38,6 +41,10 @@ function calculateChildrenSizes(item: LayoutItem, parent: LayoutItem | null, siz
const noWidthChildren: any[] = []; const noWidthChildren: any[] = [];
const noHeightChildren: any[] = []; const noHeightChildren: any[] = [];
// The minimum space required for items with no defined size
let noWidthChildrenMinWidth = 0;
let noHeightChildrenMinHeight = 0;
for (const child of item.children) { for (const child of item.children) {
let w = 'width' in child ? child.width : null; let w = 'width' in child ? child.width : null;
let h = 'height' in child ? child.height : null; let h = 'height' in child ? child.height : null;
@@ -47,10 +54,43 @@ function calculateChildrenSizes(item: LayoutItem, parent: LayoutItem | null, siz
} }
sizes[child.key] = { width: w, height: h }; sizes[child.key] = { width: w, height: h };
if (w !== null) remainingSize.width -= w; if (w !== null) remainingSize.width -= w;
if (h !== null) remainingSize.height -= h; if (h !== null) remainingSize.height -= h;
if (w === null) noWidthChildren.push({ item: child, parent: item }); if (w === null) {
if (h === null) noHeightChildren.push({ item: child, parent: item }); noWidthChildren.push({ item: child, parent: item });
noWidthChildrenMinWidth += child.minWidth || itemMinWidth;
}
if (h === null) {
noHeightChildren.push({ item: child, parent: item });
noHeightChildrenMinHeight += child.minHeight || itemMinHeight;
}
}
while (remainingSize.width < noWidthChildrenMinWidth) {
// There is not enough space, the widest item will be made smaller
let widestChild = item.children[0].key;
for (const child of item.children) {
if (!child.visible) continue;
if (sizes[child.key].width > sizes[widestChild].width) widestChild = child.key;
}
const dw = Math.abs(remainingSize.width - noWidthChildrenMinWidth);
sizes[widestChild].width -= dw;
remainingSize.width += dw;
}
while (remainingSize.height < noHeightChildrenMinHeight) {
// There is not enough space, the tallest item will be made smaller
let tallestChild = item.children[0].key;
for (const child of item.children) {
if (!child.visible) continue;
if (sizes[child.key].height > sizes[tallestChild].height) tallestChild = child.key;
}
const dh = Math.abs(remainingSize.height - noHeightChildrenMinHeight);
sizes[tallestChild].height -= dh;
remainingSize.height += dh;
} }
if (noWidthChildren.length) { if (noWidthChildren.length) {
@@ -77,6 +117,24 @@ function calculateChildrenSizes(item: LayoutItem, parent: LayoutItem | null, siz
return sizes; return sizes;
} }
// Gives the maximum available space for this item that it can take up during resizing
// availableSize = totalSize - ( [size of items with set size, except for the current item] + [minimum size of items with no set size] )
export function calculateMaxSizeAvailableForItem(item: LayoutItem, parent: LayoutItem, sizes: LayoutItemSizes): Size {
const availableSize: Size = { ...sizes[parent.key] };
for (const sibling of parent.children) {
if (!sibling.visible) continue;
availableSize.width -= 'width' in sibling ? sizes[sibling.key].width : (sibling.minWidth || itemMinWidth);
availableSize.height -= 'height' in sibling ? sizes[sibling.key].height : (sibling.minHeight || itemMinHeight);
}
availableSize.width += sizes[item.key].width;
availableSize.height += sizes[item.key].height;
return availableSize;
}
export default function useLayoutItemSizes(layout: LayoutItem, makeAllVisible: boolean = false) { export default function useLayoutItemSizes(layout: LayoutItem, makeAllVisible: boolean = false) {
return useMemo(() => { return useMemo(() => {
let sizes: LayoutItemSizes = {}; let sizes: LayoutItemSizes = {};

View File

@@ -26,12 +26,12 @@ if [ "$RESET_ALL" == "1" ]; then
echo "config keychain.supported 0" >> "$CMD_FILE" echo "config keychain.supported 0" >> "$CMD_FILE"
echo "config sync.target 9" >> "$CMD_FILE" echo "config sync.target 9" >> "$CMD_FILE"
echo "config sync.9.path http://localhost:22300" >> "$CMD_FILE" echo "config sync.9.path http://api-joplincloud.local:22300" >> "$CMD_FILE"
echo "config sync.9.username $USER_EMAIL" >> "$CMD_FILE" echo "config sync.9.username $USER_EMAIL" >> "$CMD_FILE"
echo "config sync.9.password 123456" >> "$CMD_FILE" echo "config sync.9.password 123456" >> "$CMD_FILE"
if [ "$1" == "1" ]; then if [ "$1" == "1" ]; then
curl --data '{"action": "createTestUsers"}' http://localhost:22300/api/debug curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://api-joplincloud.local:22300/api/debug
echo 'mkbook "shared"' >> "$CMD_FILE" echo 'mkbook "shared"' >> "$CMD_FILE"
echo 'mkbook "other"' >> "$CMD_FILE" echo 'mkbook "other"' >> "$CMD_FILE"

View File

@@ -4905,6 +4905,11 @@
"resolved": "https://registry.npmjs.org/jetifier/-/jetifier-1.6.6.tgz", "resolved": "https://registry.npmjs.org/jetifier/-/jetifier-1.6.6.tgz",
"integrity": "sha512-JNAkmPeB/GS2tCRqUzRPsTOHpGDah7xP18vGJfIjZC+W2sxEHbxgJxetIjIqhjQ3yYbYNEELkM/spKLtwoOSUQ==" "integrity": "sha512-JNAkmPeB/GS2tCRqUzRPsTOHpGDah7xP18vGJfIjZC+W2sxEHbxgJxetIjIqhjQ3yYbYNEELkM/spKLtwoOSUQ=="
}, },
"joplin-rn-alarm-notification": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/joplin-rn-alarm-notification/-/joplin-rn-alarm-notification-1.0.3.tgz",
"integrity": "sha512-HZGDrLmYf6aMVgzk02w4DS9CjaTogE1hnOLdMDsrWkZzRskO6g3bZw+Bwlc63cCX4ZLZeeWIaABzHoWKAbLzpQ=="
},
"js-tokens": { "js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7097,11 +7102,6 @@
"prop-types": "^15.5.10" "prop-types": "^15.5.10"
} }
}, },
"react-native-alarm-notification": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/react-native-alarm-notification/-/react-native-alarm-notification-1.7.1.tgz",
"integrity": "sha512-cvfSqCCfw48NyeFTEL5WOF/tkeWLNI7X1mVoEQ/9aY+2fuBtkCfZUoJ7vvOOHeryPbDJrlDNpRWTi3erLphZ+w=="
},
"react-native-camera": { "react-native-camera": {
"version": "3.40.0", "version": "3.40.0",
"resolved": "https://registry.npmjs.org/react-native-camera/-/react-native-camera-3.40.0.tgz", "resolved": "https://registry.npmjs.org/react-native-camera/-/react-native-camera-3.40.0.tgz",

View File

@@ -77,7 +77,7 @@ export default class JoplinServerApi {
public static connectionErrorMessage(error: any) { public static connectionErrorMessage(error: any) {
const msg = error && error.message ? error.message : 'Unknown error'; const msg = error && error.message ? error.message : 'Unknown error';
return _('Could not connect to Joplin Server. Please check the Synchronisation options in the config screen. Full error was:\n\n%s', msg); return _('Could not connect to Joplin Cloud. Please check the Synchronisation options in the config screen. Full error was:\n\n%s', msg);
} }
private requestToCurl_(url: string, options: any) { private requestToCurl_(url: string, options: any) {

View File

@@ -27,7 +27,7 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
} }
public static label() { public static label() {
return `${_('Joplin Server')} (Beta)`; return `${_('Joplin Cloud')} (Beta)`;
} }
public async isAuthenticated() { public async isAuthenticated() {

View File

@@ -472,7 +472,7 @@ class Setting extends BaseModel {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer'); return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
}, },
public: true, public: true,
label: () => _('Joplin Server URL'), label: () => _('Joplin Cloud URL'),
description: () => emptyDirWarning, description: () => emptyDirWarning,
storage: SettingStorage.File, storage: SettingStorage.File,
}, },
@@ -487,7 +487,7 @@ class Setting extends BaseModel {
// return value ? ltrimSlashes(rtrimSlashes(value)) : ''; // return value ? ltrimSlashes(rtrimSlashes(value)) : '';
// }, // },
// public: true, // public: true,
// label: () => _('Joplin Server Directory'), // label: () => _('Joplin Cloud Directory'),
// storage: SettingStorage.File, // storage: SettingStorage.File,
// }, // },
'sync.9.username': { 'sync.9.username': {
@@ -498,7 +498,7 @@ class Setting extends BaseModel {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer'); return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
}, },
public: true, public: true,
label: () => _('Joplin Server email'), label: () => _('Joplin Cloud email'),
storage: SettingStorage.File, storage: SettingStorage.File,
}, },
'sync.9.password': { 'sync.9.password': {
@@ -509,7 +509,7 @@ class Setting extends BaseModel {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer'); return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
}, },
public: true, public: true,
label: () => _('Joplin Server password'), label: () => _('Joplin Cloud password'),
secure: true, secure: true,
}, },

View File

@@ -48,10 +48,8 @@ export interface Command {
* Currently the supported context variables aren't documented, but you can * Currently the supported context variables aren't documented, but you can
* find the list below: * find the list below:
* *
* - [Global When * - [Global When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts)
* Clauses](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts). * - [Desktop app When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts)
* - [Desktop app When
* Clauses](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts).
* *
* Note: Commands are enabled by default unless you use this property. * Note: Commands are enabled by default unless you use this property.
*/ */

View File

@@ -13,8 +13,6 @@ import KeymapService from '../services/KeymapService';
import KvStore from '../services/KvStore'; import KvStore from '../services/KvStore';
import KeychainServiceDriver from '../services/keychain/KeychainServiceDriver.node'; import KeychainServiceDriver from '../services/keychain/KeychainServiceDriver.node';
import KeychainServiceDriverDummy from '../services/keychain/KeychainServiceDriver.dummy'; import KeychainServiceDriverDummy from '../services/keychain/KeychainServiceDriver.dummy';
import PluginRunner from '../../app-cli/app/services/plugins/PluginRunner';
import PluginService from '../services/plugins/PluginService';
import FileApiDriverJoplinServer from '../file-api-driver-joplinServer'; import FileApiDriverJoplinServer from '../file-api-driver-joplinServer';
import OneDriveApi from '../onedrive-api'; import OneDriveApi from '../onedrive-api';
import SyncTargetOneDrive from '../SyncTargetOneDrive'; import SyncTargetOneDrive from '../SyncTargetOneDrive';
@@ -766,45 +764,6 @@ async function createTempDir() {
return tempDirPath; return tempDirPath;
} }
interface PluginServiceOptions {
getState?(): Record<string, any>;
}
function newPluginService(appVersion = '1.4', options: PluginServiceOptions = null): PluginService {
options = options || {};
const runner = new PluginRunner();
const service = new PluginService();
service.initialize(
appVersion,
{
joplin: {},
},
runner,
{
dispatch: () => {},
getState: options.getState ? options.getState : () => {},
}
);
return service;
}
function newPluginScript(script: string) {
return `
/* joplin-manifest:
{
"id": "org.joplinapp.plugins.PluginTest",
"manifest_version": 1,
"app_min_version": "1.4",
"name": "JS Bundle test",
"version": "1.0.0"
}
*/
${script}
`;
}
async function waitForFolderCount(count: number) { async function waitForFolderCount(count: number) {
const timeout = 2000; const timeout = 2000;
const startTime = Date.now(); const startTime = Date.now();
@@ -901,4 +860,4 @@ class TestApp extends BaseApplication {
} }
} }
export { supportDir, waitForFolderCount, afterAllCleanUp, exportDir, newPluginService, newPluginScript, synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp }; export { supportDir, waitForFolderCount, afterAllCleanUp, exportDir, synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };

View File

@@ -38,11 +38,16 @@ function credentialFile(filename) {
}); });
} }
exports.credentialFile = credentialFile; exports.credentialFile = credentialFile;
function readCredentialFile(filename) { function readCredentialFile(filename, defaultValue = '') {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
const filePath = yield credentialFile(filename); try {
const r = yield fs.readFile(filePath); const filePath = yield credentialFile(filename);
return r.toString(); const r = yield fs.readFile(filePath);
return r.toString();
}
catch (error) {
return defaultValue;
}
}); });
} }
exports.readCredentialFile = readCredentialFile; exports.readCredentialFile = readCredentialFile;

View File

@@ -24,8 +24,12 @@ export async function credentialFile(filename: string) {
return output; return output;
} }
export async function readCredentialFile(filename: string) { export async function readCredentialFile(filename: string, defaultValue: string = '') {
const filePath = await credentialFile(filename); try {
const r = await fs.readFile(filePath); const filePath = await credentialFile(filename);
return r.toString(); const r = await fs.readFile(filePath);
return r.toString();
} catch (error) {
return defaultValue;
}
} }

View File

@@ -6,4 +6,5 @@ db-*.sqlite
*.pid *.pid
logs/ logs/
tests/temp/ tests/temp/
temp/ temp/
.env

View File

@@ -1,4 +1,9 @@
{ {
"verbose": true, "verbose": true,
"watch": ["dist/", "../renderer", "../lib"] "watch": [
"dist/",
"../renderer",
"../lib",
"src/views"
]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@joplin/server", "name": "@joplin/server",
"version": "2.0.1", "version": "2.0.3",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -1389,6 +1389,15 @@
"integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w==", "integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w==",
"dev": true "dev": true
}, },
"@types/nodemailer": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.1.tgz",
"integrity": "sha512-8081UY/0XTTDpuGqCnDc8IY+Q3DSg604wB3dBH0CaZlj4nZWHWuxtZ3NRZ9c9WUrz1Vfm6wioAUnqL3bsh49uQ==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/normalize-package-data": { "@types/normalize-package-data": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
@@ -5958,6 +5967,19 @@
"minimist": "^1.2.5" "minimist": "^1.2.5"
} }
}, },
"moment": {
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
},
"moment-timezone": {
"version": "0.5.33",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz",
"integrity": "sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==",
"requires": {
"moment": ">= 2.9.0"
}
},
"ms": { "ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -6045,6 +6067,14 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true "dev": true
}, },
"node-cron": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.0.tgz",
"integrity": "sha512-DDwIvvuCwrNiaU7HEivFDULcaQualDv7KoNlB/UU1wPW0n1tDEmBJKhEIE6DlF2FuoOHcNbLJ8ITL2Iv/3AWmA==",
"requires": {
"moment-timezone": "^0.5.31"
}
},
"node-env-file": { "node-env-file": {
"version": "0.1.8", "version": "0.1.8",
"resolved": "https://registry.npmjs.org/node-env-file/-/node-env-file-0.1.8.tgz", "resolved": "https://registry.npmjs.org/node-env-file/-/node-env-file-0.1.8.tgz",
@@ -6148,6 +6178,11 @@
} }
} }
}, },
"nodemailer": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.6.0.tgz",
"integrity": "sha512-ikSMDU1nZqpo2WUPE0wTTw/NGGImTkwpJKDIFPZT+YvvR9Sj+ze5wzu95JHkBMglQLoG2ITxU21WukCC/XsFkg=="
},
"nodemon": { "nodemon": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.6.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.6.tgz",

View File

@@ -1,9 +1,9 @@
{ {
"name": "@joplin/server", "name": "@joplin/server",
"version": "2.0.1", "version": "2.0.3",
"private": true, "private": true,
"scripts": { "scripts": {
"start-dev": "nodemon --config nodemon.json dist/app.js --env dev", "start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
"start": "node dist/app.js", "start": "node dist/app.js",
"generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && mv db-buildTypes.sqlite schema.sqlite", "generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && mv db-buildTypes.sqlite schema.sqlite",
"tsc": "tsc --project tsconfig.json", "tsc": "tsc --project tsconfig.json",
@@ -30,11 +30,13 @@
"mustache": "^3.1.0", "mustache": "^3.1.0",
"nanoid": "^2.1.1", "nanoid": "^2.1.1",
"node-env-file": "^0.1.8", "node-env-file": "^0.1.8",
"nodemailer": "^6.6.0",
"nodemon": "^2.0.6", "nodemon": "^2.0.6",
"pg": "^8.5.1", "pg": "^8.5.1",
"pretty-bytes": "^5.6.0", "pretty-bytes": "^5.6.0",
"query-string": "^6.8.3", "query-string": "^6.8.3",
"sqlite3": "^4.1.0", "sqlite3": "^4.1.0",
"node-cron": "^3.0.0",
"yargs": "^14.0.0" "yargs": "^14.0.0"
}, },
"devDependencies": { "devDependencies": {
@@ -46,6 +48,7 @@
"@types/koa": "^2.0.49", "@types/koa": "^2.0.49",
"@types/markdown-it": "^12.0.0", "@types/markdown-it": "^12.0.0",
"@types/mustache": "^0.8.32", "@types/mustache": "^0.8.32",
"@types/nodemailer": "^6.4.1",
"@types/yargs": "^13.0.2", "@types/yargs": "^13.0.2",
"jest": "^26.6.3", "jest": "^26.6.3",
"jsdom": "^16.4.0", "jsdom": "^16.4.0",

Binary file not shown.

View File

@@ -7,7 +7,7 @@ import { argv } from 'yargs';
import Logger, { LoggerWrapper, TargetType } from '@joplin/lib/Logger'; import Logger, { LoggerWrapper, TargetType } from '@joplin/lib/Logger';
import config, { initConfig, runningInDocker, EnvVariables } from './config'; import config, { initConfig, runningInDocker, EnvVariables } from './config';
import { createDb, dropDb } from './tools/dbTools'; import { createDb, dropDb } from './tools/dbTools';
import { dropTables, connectDb, disconnectDb, migrateDb, waitForConnection, sqliteFilePath } from './db'; import { dropTables, connectDb, disconnectDb, migrateDb, waitForConnection, sqliteDefaultDir } from './db';
import { AppContext, Env } from './utils/types'; import { AppContext, Env } from './utils/types';
import FsDriverNode from '@joplin/lib/fs-driver-node'; import FsDriverNode from '@joplin/lib/fs-driver-node';
import routeHandler from './middleware/routeHandler'; import routeHandler from './middleware/routeHandler';
@@ -26,12 +26,14 @@ const env: Env = argv.env as Env || Env.Prod;
const envVariables: Record<Env, EnvVariables> = { const envVariables: Record<Env, EnvVariables> = {
dev: { dev: {
SQLITE_DATABASE: 'dev', SQLITE_DATABASE: `${sqliteDefaultDir}/db-dev.sqlite`,
}, },
buildTypes: { buildTypes: {
SQLITE_DATABASE: 'buildTypes', SQLITE_DATABASE: `${sqliteDefaultDir}/db-buildTypes.sqlite`,
},
prod: {
SQLITE_DATABASE: `${sqliteDefaultDir}/db-prod.sqlite`,
}, },
prod: {}, // Actually get the env variables from the environment
}; };
let appLogger_: LoggerWrapper = null; let appLogger_: LoggerWrapper = null;
@@ -67,11 +69,22 @@ function markPasswords(o: Record<string, any>): Record<string, any> {
return output; return output;
} }
async function main() { async function getEnvFilePath(env: Env, argv: any): Promise<string> {
if (argv.envFile) { if (argv.envFile) return argv.envFile;
nodeEnvFile(argv.envFile);
if (env === Env.Dev) {
const envFilePath = `${require('os').homedir()}/joplin-credentials/server.env`;
if (await fs.pathExists(envFilePath)) return envFilePath;
} }
return '';
}
async function main() {
const envFilePath = await getEnvFilePath(env, argv);
if (envFilePath) nodeEnvFile(envFilePath);
if (!envVariables[env]) throw new Error(`Invalid env: ${env}`); if (!envVariables[env]) throw new Error(`Invalid env: ${env}`);
initConfig({ initConfig({
@@ -91,6 +104,8 @@ async function main() {
}); });
Logger.initializeGlobalLogger(globalLogger); Logger.initializeGlobalLogger(globalLogger);
if (envFilePath) appLogger().info(`Env variables were loaded from: ${envFilePath}`);
const pidFile = argv.pidfile as string; const pidFile = argv.pidfile as string;
if (pidFile) { if (pidFile) {
@@ -115,9 +130,10 @@ async function main() {
appLogger().info(`Starting server (${env}) on port ${config().port} and PID ${process.pid}...`); appLogger().info(`Starting server (${env}) on port ${config().port} and PID ${process.pid}...`);
appLogger().info('Running in Docker:', runningInDocker()); appLogger().info('Running in Docker:', runningInDocker());
appLogger().info('Public base URL:', config().baseUrl); appLogger().info('Public base URL:', config().baseUrl);
appLogger().info('API base URL:', config().apiBaseUrl);
appLogger().info('User content base URL:', config().userContentBaseUrl);
appLogger().info('Log dir:', config().logDir); appLogger().info('Log dir:', config().logDir);
appLogger().info('DB Config:', markPasswords(config().database)); appLogger().info('DB Config:', markPasswords(config().database));
if (config().database.client === 'sqlite3') appLogger().info('DB file:', sqliteFilePath(config().database.name));
appLogger().info('Trying to connect to database...'); appLogger().info('Trying to connect to database...');
const connectionCheck = await waitForConnection(config().database); const connectionCheck = await waitForConnection(config().database);
@@ -129,7 +145,7 @@ async function main() {
const appContext = app.context as AppContext; const appContext = app.context as AppContext;
await setupAppContext(appContext, env, connectionCheck.connection, appLogger); await setupAppContext(appContext, env, connectionCheck.connection, appLogger);
await initializeJoplinUtils(config(), appContext.models); await initializeJoplinUtils(config(), appContext.models, appContext.services.mustache);
appLogger().info('Migrating database...'); appLogger().info('Migrating database...');
await migrateDb(appContext.db); await migrateDb(appContext.db);
@@ -144,7 +160,7 @@ async function main() {
// } // }
// } // }
appLogger().info(`Call this for testing: \`curl ${config().baseUrl}/api/ping\``); appLogger().info(`Call this for testing: \`curl ${config().apiBaseUrl}/api/ping\``);
// const tree: any = { // const tree: any = {
// '000000000000000000000000000000F1': {}, // '000000000000000000000000000000F1': {},

View File

@@ -1,9 +1,12 @@
import { rtrimSlashes } from '@joplin/lib/path-utils'; import { rtrimSlashes } from '@joplin/lib/path-utils';
import { Config, DatabaseConfig, DatabaseConfigClient } from './utils/types'; import { Config, DatabaseConfig, DatabaseConfigClient, MailerConfig, RouteType } from './utils/types';
import * as pathUtils from 'path'; import * as pathUtils from 'path';
export interface EnvVariables { export interface EnvVariables {
APP_BASE_URL?: string; APP_BASE_URL?: string;
USER_CONTENT_BASE_URL?: string;
API_BASE_URL?: string;
APP_PORT?: string; APP_PORT?: string;
DB_CLIENT?: string; DB_CLIENT?: string;
RUNNING_IN_DOCKER?: string; RUNNING_IN_DOCKER?: string;
@@ -14,6 +17,16 @@ export interface EnvVariables {
POSTGRES_HOST?: string; POSTGRES_HOST?: string;
POSTGRES_PORT?: string; POSTGRES_PORT?: string;
MAILER_ENABLED?: string;
MAILER_HOST?: string;
MAILER_PORT?: string;
MAILER_SECURE?: string;
MAILER_AUTH_USER?: string;
MAILER_AUTH_PASSWORD?: string;
MAILER_NOREPLY_NAME?: string;
MAILER_NOREPLY_EMAIL?: string;
// This must be the full path to the database file
SQLITE_DATABASE?: string; SQLITE_DATABASE?: string;
} }
@@ -52,11 +65,24 @@ function databaseConfigFromEnv(runningInDocker: boolean, env: EnvVariables): Dat
return { return {
client: DatabaseConfigClient.SQLite, client: DatabaseConfigClient.SQLite,
name: env.SQLITE_DATABASE || 'prod', name: env.SQLITE_DATABASE,
asyncStackTraces: true, asyncStackTraces: true,
}; };
} }
function mailerConfigFromEnv(env: EnvVariables): MailerConfig {
return {
enabled: env.MAILER_ENABLED !== '0',
host: env.MAILER_HOST || '',
port: Number(env.MAILER_PORT || 587),
secure: !!Number(env.MAILER_SECURE) || true,
authUser: env.MAILER_AUTH_USER || '',
authPassword: env.MAILER_AUTH_PASSWORD || '',
noReplyName: env.MAILER_NOREPLY_NAME || '',
noReplyEmail: env.MAILER_NOREPLY_EMAIL || '',
};
}
function baseUrlFromEnv(env: any, appPort: number): string { function baseUrlFromEnv(env: any, appPort: number): string {
if (env.APP_BASE_URL) { if (env.APP_BASE_URL) {
return rtrimSlashes(env.APP_BASE_URL); return rtrimSlashes(env.APP_BASE_URL);
@@ -73,6 +99,7 @@ export function initConfig(env: EnvVariables, overrides: any = null) {
const rootDir = pathUtils.dirname(__dirname); const rootDir = pathUtils.dirname(__dirname);
const viewDir = `${pathUtils.dirname(__dirname)}/src/views`; const viewDir = `${pathUtils.dirname(__dirname)}/src/views`;
const appPort = env.APP_PORT ? Number(env.APP_PORT) : 22300; const appPort = env.APP_PORT ? Number(env.APP_PORT) : 22300;
const baseUrl = baseUrlFromEnv(env, appPort);
config_ = { config_ = {
rootDir: rootDir, rootDir: rootDir,
@@ -81,12 +108,29 @@ export function initConfig(env: EnvVariables, overrides: any = null) {
tempDir: `${rootDir}/temp`, tempDir: `${rootDir}/temp`,
logDir: `${rootDir}/logs`, logDir: `${rootDir}/logs`,
database: databaseConfigFromEnv(runningInDocker_, env), database: databaseConfigFromEnv(runningInDocker_, env),
mailer: mailerConfigFromEnv(env),
port: appPort, port: appPort,
baseUrl: baseUrlFromEnv(env, appPort), baseUrl,
apiBaseUrl: env.API_BASE_URL ? env.API_BASE_URL : baseUrl,
userContentBaseUrl: env.USER_CONTENT_BASE_URL ? env.USER_CONTENT_BASE_URL : baseUrl,
...overrides, ...overrides,
}; };
} }
export function baseUrl(type: RouteType): string {
if (type === RouteType.Web) return config().baseUrl;
if (type === RouteType.Api) return config().apiBaseUrl;
if (type === RouteType.UserContent) return config().userContentBaseUrl;
throw new Error(`Unknown type: ${type}`);
}
// User content URL is not supported for now so only show the URL if the
// user content is hosted on the same domain. Needs to get cookie working
// across domains to get user content url working.
export function showItemUrls(config: Config): boolean {
return config.userContentBaseUrl === config.baseUrl;
}
function config(): Config { function config(): Config {
if (!config_) throw new Error('Config has not been initialized!'); if (!config_) throw new Error('Config has not been initialized!');
return config_; return config_;

View File

@@ -17,7 +17,7 @@ require('pg').types.setTypeParser(20, function(val: any) {
const logger = Logger.create('db'); const logger = Logger.create('db');
const migrationDir = `${__dirname}/migrations`; const migrationDir = `${__dirname}/migrations`;
const sqliteDbDir = pathUtils.dirname(__dirname); export const sqliteDefaultDir = pathUtils.dirname(__dirname);
export const defaultAdminEmail = 'admin@localhost'; export const defaultAdminEmail = 'admin@localhost';
export const defaultAdminPassword = 'admin'; export const defaultAdminPassword = 'admin';
@@ -47,15 +47,11 @@ export interface ConnectionCheckResult {
connection: DbConnection; connection: DbConnection;
} }
export function sqliteFilePath(name: string): string {
return `${sqliteDbDir}/db-${name}.sqlite`;
}
export function makeKnexConfig(dbConfig: DatabaseConfig): KnexDatabaseConfig { export function makeKnexConfig(dbConfig: DatabaseConfig): KnexDatabaseConfig {
const connection: DbConfigConnection = {}; const connection: DbConfigConnection = {};
if (dbConfig.client === 'sqlite3') { if (dbConfig.client === 'sqlite3') {
connection.filename = sqliteFilePath(dbConfig.name); connection.filename = dbConfig.name;
} else { } else {
connection.database = dbConfig.name; connection.database = dbConfig.name;
connection.host = dbConfig.host; connection.host = dbConfig.host;
@@ -218,6 +214,11 @@ export enum ItemType {
User, User,
} }
export enum EmailSender {
NoReply = 1,
Support = 2,
}
export enum ChangeType { export enum ChangeType {
Create = 1, Create = 1,
Update = 2, Update = 2,
@@ -277,6 +278,8 @@ export interface User extends WithDates, WithUuid {
is_admin?: number; is_admin?: number;
max_item_size?: number; max_item_size?: number;
can_share?: number; can_share?: number;
email_confirmed?: number;
must_set_password?: number;
} }
export interface Session extends WithDates, WithUuid { export interface Session extends WithDates, WithUuid {
@@ -370,6 +373,25 @@ export interface Change extends WithDates, WithUuid {
user_id?: Uuid; user_id?: Uuid;
} }
export interface Email extends WithDates {
id?: number;
recipient_name?: string;
recipient_email?: string;
recipient_id?: Uuid;
sender_id?: EmailSender;
subject?: string;
body?: string;
sent_time?: number;
sent_success?: number;
error?: string;
}
export interface Token extends WithDates {
id?: number;
value?: string;
user_id?: Uuid;
}
export const databaseSchema: DatabaseTables = { export const databaseSchema: DatabaseTables = {
users: { users: {
id: { type: 'string' }, id: { type: 'string' },
@@ -381,6 +403,8 @@ export const databaseSchema: DatabaseTables = {
created_time: { type: 'string' }, created_time: { type: 'string' },
max_item_size: { type: 'number' }, max_item_size: { type: 'number' },
can_share: { type: 'number' }, can_share: { type: 'number' },
email_confirmed: { type: 'number' },
must_set_password: { type: 'number' },
}, },
sessions: { sessions: {
id: { type: 'string' }, id: { type: 'string' },
@@ -485,5 +509,26 @@ export const databaseSchema: DatabaseTables = {
previous_item: { type: 'string' }, previous_item: { type: 'string' },
user_id: { type: 'string' }, user_id: { type: 'string' },
}, },
emails: {
id: { type: 'number' },
recipient_name: { type: 'string' },
recipient_email: { type: 'string' },
recipient_id: { type: 'string' },
sender_id: { type: 'number' },
subject: { type: 'string' },
body: { type: 'string' },
sent_time: { type: 'string' },
sent_success: { type: 'number' },
error: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
tokens: {
id: { type: 'number' },
value: { type: 'string' },
user_id: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
}; };
// AUTO-GENERATED-TYPES // AUTO-GENERATED-TYPES

View File

@@ -19,7 +19,7 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
ctx.owner.id, ctx.owner.id,
'change_admin_password', 'change_admin_password',
NotificationLevel.Important, NotificationLevel.Important,
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl()) _('The default admin password is insecure and has not been changed! [Change it now](%s)', ctx.models.user().profileUrl())
); );
} else { } else {
await notificationModel.markAsRead(ctx.owner.id, 'change_admin_password'); await notificationModel.markAsRead(ctx.owner.id, 'change_admin_password');

View File

@@ -1,15 +1,6 @@
import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils'; import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils';
import { AppContext, Env } from '../utils/types'; import { AppContext, Env } from '../utils/types';
import MustacheService, { isView, View } from '../services/MustacheService'; import { isView, View } from '../services/MustacheService';
import config from '../config';
let mustache_: MustacheService = null;
function mustache(): MustacheService {
if (!mustache_) {
mustache_ = new MustacheService(config().viewDir, config().baseUrl);
}
return mustache_;
}
export default async function(ctx: AppContext) { export default async function(ctx: AppContext) {
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`); ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
@@ -21,7 +12,7 @@ export default async function(ctx: AppContext) {
ctx.response = responseObject.response; ctx.response = responseObject.response;
} else if (isView(responseObject)) { } else if (isView(responseObject)) {
ctx.response.status = 200; ctx.response.status = 200;
ctx.response.body = await mustache().renderView(responseObject, { ctx.response.body = await ctx.services.mustache.renderView(responseObject, {
notifications: ctx.notifications || [], notifications: ctx.notifications || [],
hasNotifications: !!ctx.notifications && !!ctx.notifications.length, hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
owner: ctx.owner, owner: ctx.owner,
@@ -44,7 +35,9 @@ export default async function(ctx: AppContext) {
const responseFormat = routeResponseFormat(ctx); const responseFormat = routeResponseFormat(ctx);
if (responseFormat === RouteResponseFormat.Html) { if (error.code === 'invalidOrigin') {
ctx.response.body = error.message;
} else if (responseFormat === RouteResponseFormat.Html) {
ctx.response.set('Content-Type', 'text/html'); ctx.response.set('Content-Type', 'text/html');
const view: View = { const view: View = {
name: 'error', name: 'error',
@@ -55,7 +48,7 @@ export default async function(ctx: AppContext) {
owner: ctx.owner, owner: ctx.owner,
}, },
}; };
ctx.response.body = await mustache().renderView(view); ctx.response.body = await ctx.services.mustache.renderView(view);
} else { // JSON } else { // JSON
ctx.response.set('Content-Type', 'application/json'); ctx.response.set('Content-Type', 'application/json');
const r: any = { error: error.message }; const r: any = { error: error.message };

View File

@@ -0,0 +1,47 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('users', function(table: Knex.CreateTableBuilder) {
table.integer('email_confirmed').defaultTo(0).notNullable();
table.integer('must_set_password').defaultTo(0).notNullable();
});
await db.schema.createTable('emails', function(table: Knex.CreateTableBuilder) {
table.increments('id').unique().primary().notNullable();
table.text('recipient_name', 'mediumtext').defaultTo('').notNullable();
table.text('recipient_email', 'mediumtext').defaultTo('').notNullable();
table.string('recipient_id', 32).defaultTo(0).notNullable();
table.integer('sender_id').notNullable();
table.string('subject', 128).notNullable();
table.text('body').notNullable();
table.bigInteger('sent_time').defaultTo(0).notNullable();
table.integer('sent_success').defaultTo(0).notNullable();
table.text('error').defaultTo('').notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
await db.schema.createTable('tokens', function(table: Knex.CreateTableBuilder) {
table.increments('id').unique().primary().notNullable();
table.string('value', 32).notNullable();
table.string('user_id', 32).defaultTo('').notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
await db.schema.alterTable('emails', function(table: Knex.CreateTableBuilder) {
table.index(['sent_time']);
table.index(['sent_success']);
});
await db('users').update({ email_confirmed: 1 });
await db.schema.alterTable('tokens', function(table: Knex.CreateTableBuilder) {
table.index(['value', 'user_id']);
});
}
export async function down(_db: DbConnection): Promise<any> {
}

View File

@@ -272,10 +272,10 @@ export default abstract class BaseModel<T> {
return this.db(this.tableName).select(options.fields || this.defaultFields).where({ id: id }).first(); return this.db(this.tableName).select(options.fields || this.defaultFields).where({ id: id }).first();
} }
public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> { public async delete(id: string | string[] | number | number[], options: DeleteOptions = {}): Promise<void> {
if (!id) throw new Error('id cannot be empty'); if (!id) throw new Error('id cannot be empty');
const ids = typeof id === 'string' ? [id] : id; const ids = (typeof id === 'string' || typeof id === 'number') ? [id] : id;
if (!ids.length) throw new Error('no id provided'); if (!ids.length) throw new Error('no id provided');

View File

@@ -0,0 +1,33 @@
import { Uuid, Email, EmailSender } from '../db';
import BaseModel from './BaseModel';
export interface EmailToSend {
sender_id: EmailSender;
recipient_email: string;
subject: string;
body: string;
recipient_name?: string;
recipient_id?: Uuid;
}
export default class EmailModel extends BaseModel<Email> {
public get tableName(): string {
return 'emails';
}
protected hasUuid(): boolean {
return false;
}
public async push(email: EmailToSend) {
EmailModel.eventEmitter.emit('saved');
return super.save({ ...email });
}
public async needToBeSent(): Promise<Email[]> {
return this.db(this.tableName).where('sent_time', '=', 0);
}
}

View File

@@ -0,0 +1,30 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models } from '../utils/testing/testUtils';
describe('TokenModel', function() {
beforeAll(async () => {
await beforeAllDb('TokenModel');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should delete old tokens', async function() {
const { user: user1 } = await createUserAndSession(1);
await models().token().generate(user1.id);
const [token1, token2] = await models().token().all();
await models().token().save({ id: token1.id, created_time: Date.now() - 2629746000 });
await models().token().deleteExpiredTokens();
const tokens = await models().token().all();
expect(tokens.length).toBe(1);
expect(tokens[0].id).toBe(token2.id);
});
});

View File

@@ -0,0 +1,62 @@
import { Token, Uuid } from '../db';
import { ErrorForbidden } from '../utils/errors';
import uuidgen from '../utils/uuidgen';
import BaseModel from './BaseModel';
export default class TokenModel extends BaseModel<Token> {
private tokenTtl_: number = 7 * 24 * 60 * 1000;
public get tableName(): string {
return 'tokens';
}
protected hasUuid(): boolean {
return false;
}
public async generate(userId: Uuid): Promise<string> {
const token = await this.save({
value: uuidgen(32),
user_id: userId,
});
return token.value;
}
public async checkToken(userId: string, tokenValue: string): Promise<void> {
if (!(await this.isValid(userId, tokenValue))) throw new ErrorForbidden('Invalid or expired token');
}
private async byUser(userId: string, tokenValue: string): Promise<Token> {
return this
.db(this.tableName)
.select(['id'])
.where('user_id', '=', userId)
.where('value', '=', tokenValue)
.first();
}
public async isValid(userId: string, tokenValue: string): Promise<boolean> {
const token = await this.byUser(userId, tokenValue);
return !!token;
}
public async deleteExpiredTokens() {
const cutOffDate = Date.now() - this.tokenTtl_;
await this.db(this.tableName).where('created_time', '<', cutOffDate).delete();
}
public async deleteByValue(userId: Uuid, value: string) {
const token = await this.byUser(userId, value);
if (token) await this.delete(token.id);
}
public async allByUserId(userId: Uuid): Promise<Token[]> {
return this
.db(this.tableName)
.select(this.defaultFields)
.where('user_id', '=', userId);
}
}

View File

@@ -1,5 +1,5 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils'; import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils';
import { User } from '../db'; import { EmailSender, User } from '../db';
import { ErrorUnprocessableEntity } from '../utils/errors'; import { ErrorUnprocessableEntity } from '../utils/errors';
describe('UserModel', function() { describe('UserModel', function() {
@@ -68,4 +68,22 @@ describe('UserModel', function() {
expect((await models().userItem().all()).length).toBe(0); expect((await models().userItem().all()).length).toBe(0);
}); });
test('should push an email when creating a new user', async function() {
const { user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
const emails = await models().email().all();
expect(emails.length).toBe(2);
expect(emails.find(e => e.recipient_email === user1.email)).toBeTruthy();
expect(emails.find(e => e.recipient_email === user2.email)).toBeTruthy();
const email = emails[0];
expect(email.subject.trim()).toBeTruthy();
expect(email.body.includes('/confirm?token=')).toBeTruthy();
expect(email.sender_id).toBe(EmailSender.NoReply);
expect(email.sent_success).toBe(0);
expect(email.sent_time).toBe(0);
expect(email.error).toBe('');
});
}); });

View File

@@ -1,7 +1,7 @@
import BaseModel, { AclAction, SaveOptions, ValidateOptions } from './BaseModel'; import BaseModel, { AclAction, SaveOptions, ValidateOptions } from './BaseModel';
import { Item, User } from '../db'; import { EmailSender, Item, User, Uuid } from '../db';
import * as auth from '../utils/auth'; import * as auth from '../utils/auth';
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge } from '../utils/errors'; import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound } from '../utils/errors';
import { ModelType } from '@joplin/lib/BaseModel'; import { ModelType } from '@joplin/lib/BaseModel';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import prettyBytes = require('pretty-bytes'); import prettyBytes = require('pretty-bytes');
@@ -134,10 +134,14 @@ export default class UserModel extends BaseModel<User> {
return !!s[0].length && !!s[1].length; return !!s[0].length && !!s[1].length;
} }
public async profileUrl(): Promise<string> { public profileUrl(): string {
return `${this.baseUrl}/users/me`; return `${this.baseUrl}/users/me`;
} }
public confirmUrl(userId: Uuid, validationToken: string): string {
return `${this.baseUrl}/users/${userId}/confirm?token=${validationToken}`;
}
public async delete(id: string): Promise<void> { public async delete(id: string): Promise<void> {
const shares = await this.models().share().sharesByUser(id); const shares = await this.models().share().sharesByUser(id);
@@ -151,6 +155,13 @@ export default class UserModel extends BaseModel<User> {
}, 'UserModel::delete'); }, 'UserModel::delete');
} }
public async confirmEmail(userId: Uuid, token: string) {
await this.models().token().checkToken(userId, token);
const user = await this.models().user().load(userId);
if (!user) throw new ErrorNotFound('No such user');
await this.save({ id: user.id, email_confirmed: 1 });
}
// Note that when the "password" property is provided, it is going to be // Note that when the "password" property is provided, it is going to be
// hashed automatically. It means that it is not safe to do: // hashed automatically. It means that it is not safe to do:
// //
@@ -160,8 +171,30 @@ export default class UserModel extends BaseModel<User> {
// Because the password would be hashed twice. // Because the password would be hashed twice.
public async save(object: User, options: SaveOptions = {}): Promise<User> { public async save(object: User, options: SaveOptions = {}): Promise<User> {
const user = { ...object }; const user = { ...object };
if (user.password) user.password = auth.hashPassword(user.password); if (user.password) user.password = auth.hashPassword(user.password);
return super.save(user, options);
const isNew = await this.isNew(object, options);
return this.withTransaction(async () => {
const savedUser = await super.save(user, options);
if (isNew) {
const validationToken = await this.models().token().generate(savedUser.id);
const validationUrl = encodeURI(this.confirmUrl(savedUser.id, validationToken));
await this.models().email().push({
sender_id: EmailSender.NoReply,
recipient_id: savedUser.id,
recipient_email: savedUser.email,
recipient_name: savedUser.full_name || '',
subject: 'Verify your email',
body: `Click this: ${validationUrl}`,
});
}
return savedUser;
});
} }
} }

View File

@@ -63,9 +63,11 @@ import SessionModel from './SessionModel';
import ChangeModel from './ChangeModel'; import ChangeModel from './ChangeModel';
import NotificationModel from './NotificationModel'; import NotificationModel from './NotificationModel';
import ShareModel from './ShareModel'; import ShareModel from './ShareModel';
import EmailModel from './EmailModel';
import ItemResourceModel from './ItemResourceModel'; import ItemResourceModel from './ItemResourceModel';
import ShareUserModel from './ShareUserModel'; import ShareUserModel from './ShareUserModel';
import KeyValueModel from './KeyValueModel'; import KeyValueModel from './KeyValueModel';
import TokenModel from './TokenModel';
export class Models { export class Models {
@@ -85,10 +87,18 @@ export class Models {
return new UserModel(this.db_, newModelFactory, this.baseUrl_); return new UserModel(this.db_, newModelFactory, this.baseUrl_);
} }
public email() {
return new EmailModel(this.db_, newModelFactory, this.baseUrl_);
}
public userItem() { public userItem() {
return new UserItemModel(this.db_, newModelFactory, this.baseUrl_); return new UserItemModel(this.db_, newModelFactory, this.baseUrl_);
} }
public token() {
return new TokenModel(this.db_, newModelFactory, this.baseUrl_);
}
public itemResource() { public itemResource() {
return new ItemResourceModel(this.db_, newModelFactory, this.baseUrl_); return new ItemResourceModel(this.db_, newModelFactory, this.baseUrl_);
} }

View File

@@ -2,10 +2,11 @@ import config from '../../config';
import { createTestUsers } from '../../tools/debugTools'; import { createTestUsers } from '../../tools/debugTools';
import { bodyFields } from '../../utils/requestUtils'; import { bodyFields } from '../../utils/requestUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { SubPath } from '../../utils/routeUtils'; import { SubPath } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
const router = new Router(); const router = new Router(RouteType.Api);
router.public = true; router.public = true;

View File

@@ -1,6 +1,7 @@
import { ErrorNotFound } from '../../utils/errors'; import { ErrorNotFound } from '../../utils/errors';
import { bodyFields } from '../../utils/requestUtils'; import { bodyFields } from '../../utils/requestUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { SubPath } from '../../utils/routeUtils'; import { SubPath } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
@@ -14,7 +15,7 @@ const supportedEvents: Record<string, Function> = {
}, },
}; };
const router = new Router(); const router = new Router(RouteType.Api);
router.post('api/events', async (_path: SubPath, ctx: AppContext) => { router.post('api/events', async (_path: SubPath, ctx: AppContext) => {
const event = await bodyFields<Event>(ctx.req); const event = await bodyFields<Event>(ctx.req);

View File

@@ -2,6 +2,7 @@ import { Item, Uuid } from '../../db';
import { formParse } from '../../utils/requestUtils'; import { formParse } from '../../utils/requestUtils';
import { respondWithItemContent, SubPath } from '../../utils/routeUtils'; import { respondWithItemContent, SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import { ErrorMethodNotAllowed, ErrorNotFound } from '../../utils/errors'; import { ErrorMethodNotAllowed, ErrorNotFound } from '../../utils/errors';
@@ -10,7 +11,7 @@ import { requestDeltaPagination, requestPagination } from '../../models/utils/pa
import { AclAction } from '../../models/BaseModel'; import { AclAction } from '../../models/BaseModel';
import { safeRemove } from '../../utils/fileUtils'; import { safeRemove } from '../../utils/fileUtils';
const router = new Router(); const router = new Router(RouteType.Api);
// Note about access control: // Note about access control:
// //

View File

@@ -1,6 +1,7 @@
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
const router = new Router(); const router = new Router(RouteType.Api);
router.public = true; router.public = true;

View File

@@ -1,11 +1,12 @@
import { SubPath } from '../../utils/routeUtils'; import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { ErrorForbidden } from '../../utils/errors'; import { ErrorForbidden } from '../../utils/errors';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import { bodyFields } from '../../utils/requestUtils'; import { bodyFields } from '../../utils/requestUtils';
import { User } from '../../db'; import { User } from '../../db';
const router = new Router(); const router = new Router(RouteType.Api);
router.public = true; router.public = true;

View File

@@ -2,10 +2,11 @@ import { ErrorBadRequest, ErrorNotFound } from '../../utils/errors';
import { bodyFields } from '../../utils/requestUtils'; import { bodyFields } from '../../utils/requestUtils';
import { SubPath } from '../../utils/routeUtils'; import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import { AclAction } from '../../models/BaseModel'; import { AclAction } from '../../models/BaseModel';
const router = new Router(); const router = new Router(RouteType.Api);
router.patch('api/share_users/:id', async (path: SubPath, ctx: AppContext) => { router.patch('api/share_users/:id', async (path: SubPath, ctx: AppContext) => {
const shareUserModel = ctx.models.shareUser(); const shareUserModel = ctx.models.shareUser();

View File

@@ -3,6 +3,7 @@ import { Share, ShareType } from '../../db';
import { bodyFields, ownerRequired } from '../../utils/requestUtils'; import { bodyFields, ownerRequired } from '../../utils/requestUtils';
import { SubPath } from '../../utils/routeUtils'; import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import { AclAction } from '../../models/BaseModel'; import { AclAction } from '../../models/BaseModel';
@@ -11,7 +12,7 @@ interface ShareApiInput extends Share {
note_id?: string; note_id?: string;
} }
const router = new Router(); const router = new Router(RouteType.Api);
router.public = true; router.public = true;

View File

@@ -2,12 +2,13 @@ import { User } from '../../db';
import { bodyFields } from '../../utils/requestUtils'; import { bodyFields } from '../../utils/requestUtils';
import { SubPath } from '../../utils/routeUtils'; import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import { ErrorNotFound } from '../../utils/errors'; import { ErrorNotFound } from '../../utils/errors';
import { AclAction } from '../../models/BaseModel'; import { AclAction } from '../../models/BaseModel';
import uuidgen from '../../utils/uuidgen'; import uuidgen from '../../utils/uuidgen';
const router = new Router(); const router = new Router(RouteType.Api);
async function fetchUser(path: SubPath, ctx: AppContext): Promise<User> { async function fetchUser(path: SubPath, ctx: AppContext): Promise<User> {
const user = await ctx.models.user().load(path.id); const user = await ctx.models.user().load(path.id);
@@ -30,8 +31,9 @@ router.post('api/users', async (_path: SubPath, ctx: AppContext) => {
const user = await postedUserFromContext(ctx); const user = await postedUserFromContext(ctx);
// We set a random password because it's required, but user will have to // We set a random password because it's required, but user will have to
// set it by clicking on the confirmation link. // set it after clicking on the confirmation link.
user.password = uuidgen(); user.password = uuidgen();
user.must_set_password = 1;
const output = await ctx.models.user().save(user); const output = await ctx.models.user().save(user);
return ctx.models.user().toApiOutput(output); return ctx.models.user().toApiOutput(output);
}); });

View File

@@ -1,10 +1,10 @@
import { SubPath, Response, ResponseType } from '../utils/routeUtils'; import { SubPath, Response, ResponseType, redirect } from '../utils/routeUtils';
import Router from '../utils/Router'; import Router from '../utils/Router';
import { ErrorNotFound, ErrorForbidden } from '../utils/errors'; import { ErrorNotFound, ErrorForbidden } from '../utils/errors';
import { dirname, normalize } from 'path'; import { dirname, normalize } from 'path';
import { pathExists } from 'fs-extra'; import { pathExists } from 'fs-extra';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import { AppContext } from '../utils/types'; import { AppContext, RouteType } from '../utils/types';
import { localFileFromUrl } from '../utils/joplinUtils'; import { localFileFromUrl } from '../utils/joplinUtils';
const { mime } = require('@joplin/lib/mime-utils.js'); const { mime } = require('@joplin/lib/mime-utils.js');
@@ -44,13 +44,22 @@ async function findLocalFile(path: string): Promise<string> {
return localPath; return localPath;
} }
const router = new Router(); const router = new Router(RouteType.Web);
router.public = true; router.public = true;
// Used to serve static files, so it needs to be public because for example the // Used to serve static files, so it needs to be public because for example the
// login page, which is public, needs access to the CSS files. // login page, which is public, needs access to the CSS files.
router.get('', async (path: SubPath, ctx: AppContext) => { router.get('', async (path: SubPath, ctx: AppContext) => {
// Redirect to either /login or /home when trying to access the root
if (!path.id && !path.link) {
if (ctx.owner) {
return redirect(ctx, 'home');
} else {
return redirect(ctx, 'login');
}
}
const localPath = await findLocalFile(path.raw); const localPath = await findLocalFile(path.raw);
let mimeType: string = mime.fromFilename(localPath); let mimeType: string = mime.fromFilename(localPath);

View File

@@ -1,14 +1,16 @@
import { SubPath } from '../../utils/routeUtils'; import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import { changeTypeToString } from '../../db'; import { changeTypeToString } from '../../db';
import { PaginationOrderDir } from '../../models/utils/pagination'; import { PaginationOrderDir } from '../../models/utils/pagination';
import { formatDateTime } from '../../utils/time'; import { formatDateTime } from '../../utils/time';
import defaultView from '../../utils/defaultView'; import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService'; import { View } from '../../services/MustacheService';
import { makeTablePagination, Table, Row, makeTableView, tablePartials } from '../../utils/views/table'; import { makeTablePagination, Table, Row, makeTableView } from '../../utils/views/table';
import config, { showItemUrls } from '../../config';
const router = new Router(); const router = new Router(RouteType.Web);
router.get('changes', async (_path: SubPath, ctx: AppContext) => { router.get('changes', async (_path: SubPath, ctx: AppContext) => {
const pagination = makeTablePagination(ctx.query, 'updated_time', PaginationOrderDir.DESC); const pagination = makeTablePagination(ctx.query, 'updated_time', PaginationOrderDir.DESC);
@@ -40,7 +42,7 @@ router.get('changes', async (_path: SubPath, ctx: AppContext) => {
{ {
value: change.item_name, value: change.item_name,
stretch: true, stretch: true,
url: items.find(i => i.id === change.item_id) ? ctx.models.item().itemContentUrl(change.item_id) : '', url: showItemUrls(config()) ? (items.find(i => i.id === change.item_id) ? ctx.models.item().itemContentUrl(change.item_id) : '') : null,
}, },
{ {
value: changeTypeToString(change.type), value: changeTypeToString(change.type),
@@ -57,7 +59,6 @@ router.get('changes', async (_path: SubPath, ctx: AppContext) => {
const view: View = defaultView('changes'); const view: View = defaultView('changes');
view.content.changeTable = makeTableView(table), view.content.changeTable = makeTableView(table),
view.cssFiles = ['index/changes']; view.cssFiles = ['index/changes'];
view.partials = view.partials.concat(tablePartials());
return view; return view;
}); });

View File

@@ -1,11 +1,12 @@
import { SubPath } from '../../utils/routeUtils'; import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import { contextSessionId } from '../../utils/requestUtils'; import { contextSessionId } from '../../utils/requestUtils';
import { ErrorMethodNotAllowed } from '../../utils/errors'; import { ErrorMethodNotAllowed } from '../../utils/errors';
import defaultView from '../../utils/defaultView'; import defaultView from '../../utils/defaultView';
const router: Router = new Router(); const router: Router = new Router(RouteType.Web);
router.get('home', async (_path: SubPath, ctx: AppContext) => { router.get('home', async (_path: SubPath, ctx: AppContext) => {
contextSessionId(ctx); contextSessionId(ctx);

View File

@@ -1,17 +1,18 @@
import { SubPath, redirect, respondWithItemContent } from '../../utils/routeUtils'; import { SubPath, redirect, respondWithItemContent } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import { formParse } from '../../utils/requestUtils'; import { formParse } from '../../utils/requestUtils';
import { ErrorNotFound } from '../../utils/errors'; import { ErrorNotFound } from '../../utils/errors';
import config from '../../config'; import config, { showItemUrls } from '../../config';
import { formatDateTime } from '../../utils/time'; import { formatDateTime } from '../../utils/time';
import defaultView from '../../utils/defaultView'; import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService'; import { View } from '../../services/MustacheService';
import { makeTablePagination, makeTableView, Row, Table, tablePartials } from '../../utils/views/table'; import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
import { PaginationOrderDir } from '../../models/utils/pagination'; import { PaginationOrderDir } from '../../models/utils/pagination';
const prettyBytes = require('pretty-bytes'); const prettyBytes = require('pretty-bytes');
const router = new Router(); const router = new Router(RouteType.Web);
router.get('items', async (_path: SubPath, ctx: AppContext) => { router.get('items', async (_path: SubPath, ctx: AppContext) => {
const pagination = makeTablePagination(ctx.query, 'name', PaginationOrderDir.ASC); const pagination = makeTablePagination(ctx.query, 'name', PaginationOrderDir.ASC);
@@ -46,7 +47,7 @@ router.get('items', async (_path: SubPath, ctx: AppContext) => {
{ {
value: item.name, value: item.name,
stretch: true, stretch: true,
url: `${config().baseUrl}/items/${item.id}/content`, url: showItemUrls(config()) ? `${config().userContentBaseUrl}/items/${item.id}/content` : null,
}, },
{ {
value: prettyBytes(item.content_size), value: prettyBytes(item.content_size),
@@ -67,7 +68,6 @@ router.get('items', async (_path: SubPath, ctx: AppContext) => {
view.content.itemTable = makeTableView(table), view.content.itemTable = makeTableView(table),
view.content.postUrl = `${config().baseUrl}/items`; view.content.postUrl = `${config().baseUrl}/items`;
view.cssFiles = ['index/items']; view.cssFiles = ['index/items'];
view.partials = view.partials.concat(tablePartials());
return view; return view;
}); });
@@ -76,7 +76,7 @@ router.get('items/:id/content', async (path: SubPath, ctx: AppContext) => {
const item = await itemModel.loadWithContent(path.id); const item = await itemModel.loadWithContent(path.id);
if (!item) throw new ErrorNotFound(); if (!item) throw new ErrorNotFound();
return respondWithItemContent(ctx.response, item, item.content); return respondWithItemContent(ctx.response, item, item.content);
}); }, RouteType.UserContent);
router.post('items', async (_path: SubPath, ctx: AppContext) => { router.post('items', async (_path: SubPath, ctx: AppContext) => {
const body = await formParse(ctx.req); const body = await formParse(ctx.req);

View File

@@ -1,5 +1,6 @@
import { SubPath, redirect } from '../../utils/routeUtils'; import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import { formParse } from '../../utils/requestUtils'; import { formParse } from '../../utils/requestUtils';
import config from '../../config'; import config from '../../config';
@@ -9,11 +10,11 @@ import { View } from '../../services/MustacheService';
function makeView(error: any = null): View { function makeView(error: any = null): View {
const view = defaultView('login'); const view = defaultView('login');
view.content.error = error; view.content.error = error;
view.partials = ['errorBanner']; view.navbar = false;
return view; return view;
} }
const router: Router = new Router(); const router: Router = new Router(RouteType.Web);
router.public = true; router.public = true;

View File

@@ -1,10 +1,11 @@
import { SubPath, redirect } from '../../utils/routeUtils'; import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import config from '../../config'; import config from '../../config';
import { contextSessionId } from '../../utils/requestUtils'; import { contextSessionId } from '../../utils/requestUtils';
const router = new Router(); const router = new Router(RouteType.Web);
router.post('logout', async (_path: SubPath, ctx: AppContext) => { router.post('logout', async (_path: SubPath, ctx: AppContext) => {
const sessionId = contextSessionId(ctx, false); const sessionId = contextSessionId(ctx, false);

View File

@@ -1,11 +1,12 @@
import { SubPath } from '../../utils/routeUtils'; import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import { bodyFields } from '../../utils/requestUtils'; import { bodyFields } from '../../utils/requestUtils';
import { ErrorNotFound } from '../../utils/errors'; import { ErrorNotFound } from '../../utils/errors';
import { Notification } from '../../db'; import { Notification } from '../../db';
const router = new Router(); const router = new Router(RouteType.Web);
router.patch('notifications/:id', async (path: SubPath, ctx: AppContext) => { router.patch('notifications/:id', async (path: SubPath, ctx: AppContext) => {
const fields: Notification = await bodyFields(ctx.req); const fields: Notification = await bodyFields(ctx.req);

View File

@@ -1,5 +1,6 @@
import { SubPath, ResponseType, Response } from '../../utils/routeUtils'; import { SubPath, ResponseType, Response } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import { ErrorNotFound } from '../../utils/errors'; import { ErrorNotFound } from '../../utils/errors';
import { Item, Share } from '../../db'; import { Item, Share } from '../../db';
@@ -18,7 +19,7 @@ async function renderItem(context: AppContext, item: Item, share: Share): Promis
}; };
} }
const router: Router = new Router(); const router: Router = new Router(RouteType.Web);
router.public = true; router.public = true;

View File

@@ -1,7 +1,7 @@
import { User } from '../../db'; import { User } from '../../db';
import routeHandler from '../../middleware/routeHandler'; import routeHandler from '../../middleware/routeHandler';
import { ErrorForbidden } from '../../utils/errors'; import { ErrorForbidden } from '../../utils/errors';
import { execRequest } from '../../utils/testing/apiUtils'; import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError } from '../../utils/testing/testUtils'; import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError } from '../../utils/testing/testUtils';
export async function postUser(sessionId: string, email: string, password: string): Promise<User> { export async function postUser(sessionId: string, email: string, password: string): Promise<User> {
@@ -153,6 +153,76 @@ describe('index_users', function() {
expect(result).toContain(user2.email); expect(result).toContain(user2.email);
}); });
test('should allow user to set a password for new accounts', async function() {
const { user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
const email = (await models().email().all()).find(e => e.recipient_id === user1.id);
const matches = email.body.match(/\/(users\/.*)(\?token=)(.{32})/);
const path = matches[1];
const token = matches[3];
// Check that the token is valid
expect(await models().token().isValid(user1.id, token)).toBe(true);
// Check that we can't set the password without the token
{
const context = await execRequestC('', 'POST', path, {
password: 'newpassword',
password2: 'newpassword',
});
const sessionId = context.cookies.get('sessionId');
expect(sessionId).toBeFalsy();
}
// Check that we can't set the password with someone else's token
{
const token2 = (await models().token().allByUserId(user2.id))[0].value;
const context = await execRequestC('', 'POST', path, {
password: 'newpassword',
password2: 'newpassword',
token: token2,
});
const sessionId = context.cookies.get('sessionId');
expect(sessionId).toBeFalsy();
}
const context = await execRequestC('', 'POST', path, {
password: 'newpassword',
password2: 'newpassword',
token: token,
});
// Check that the user has been logged in
const sessionId = context.cookies.get('sessionId');
const session = await models().session().load(sessionId);
expect(session.user_id).toBe(user1.id);
// Check that the password has been set
const loggedInUser = await models().user().login(user1.email, 'newpassword');
expect(loggedInUser.id).toBe(user1.id);
// Check that the token has been cleared
expect(await models().token().isValid(user1.id, token)).toBe(false);
// Check that a notification has been created
const notification = (await models().notification().all())[0];
expect(notification.key).toBe('passwordSet');
});
// test('should handle invalid email validation', async function() {
// await createUserAndSession(1);
// const email = (await models().email().all())[0];
// const matches = email.body.match(/\/(users\/.*)(\?token=)(.{32})/);
// const path = matches[1];
// const token = matches[3];
// // Valid path but invalid token
// await expectHttpError(async () => execRequest(null, 'GET', path, null, { query: { token: 'invalid' } }), ErrorNotFound.httpCode);
// // Valid token but invalid path
// await expectHttpError(async () => execRequest(null, 'GET', 'users/abcd1234/confirm', null, { query: { token } }), ErrorNotFound.httpCode);
// });
test('should apply ACL', async function() { test('should apply ACL', async function() {
const { user: admin, session: adminSession } = await createUserAndSession(1, true); const { user: admin, session: adminSession } = await createUserAndSession(1, true);
const { user: user1, session: session1 } = await createUserAndSession(2, false); const { user: user1, session: session1 } = await createUserAndSession(2, false);

View File

@@ -1,15 +1,27 @@
import { SubPath, redirect } from '../../utils/routeUtils'; import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext, HttpMethod } from '../../utils/types'; import { AppContext, HttpMethod } from '../../utils/types';
import { formParse } from '../../utils/requestUtils'; import { bodyFields, formParse } from '../../utils/requestUtils';
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors'; import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
import { User } from '../../db'; import { NotificationLevel, User } from '../../db';
import config from '../../config'; import config from '../../config';
import { View } from '../../services/MustacheService'; import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView'; import defaultView from '../../utils/defaultView';
import { AclAction } from '../../models/BaseModel'; import { AclAction } from '../../models/BaseModel';
const prettyBytes = require('pretty-bytes'); const prettyBytes = require('pretty-bytes');
function checkPassword(fields: SetPasswordFormData, required: boolean): string {
if (fields.password) {
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
return fields.password;
} else {
if (required) throw new ErrorUnprocessableEntity('Password is required');
}
return '';
}
function makeUser(isNew: boolean, fields: any): User { function makeUser(isNew: boolean, fields: any): User {
const user: User = {}; const user: User = {};
@@ -19,16 +31,20 @@ function makeUser(isNew: boolean, fields: any): User {
if ('max_item_size' in fields) user.max_item_size = fields.max_item_size; if ('max_item_size' in fields) user.max_item_size = fields.max_item_size;
user.can_share = fields.can_share ? 1 : 0; user.can_share = fields.can_share ? 1 : 0;
if (fields.password) { const password = checkPassword(fields, false);
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match'); if (password) user.password = password;
user.password = fields.password;
}
if (!isNew) user.id = fields.id; if (!isNew) user.id = fields.id;
return user; return user;
} }
function defaultUser(): User {
return {
can_share: 1,
};
}
function userIsNew(path: SubPath): boolean { function userIsNew(path: SubPath): boolean {
return path.id === 'new'; return path.id === 'new';
} }
@@ -37,7 +53,7 @@ function userIsMe(path: SubPath): boolean {
return path.id === 'me'; return path.id === 'me';
} }
const router = new Router(); const router = new Router(RouteType.Web);
router.get('users', async (_path: SubPath, ctx: AppContext) => { router.get('users', async (_path: SubPath, ctx: AppContext) => {
const userModel = ctx.models.user(); const userModel = ctx.models.user();
@@ -63,6 +79,7 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
const userId = userIsMe(path) ? owner.id : path.id; const userId = userIsMe(path) ? owner.id : path.id;
user = !isNew ? user || await userModel.load(userId) : null; user = !isNew ? user || await userModel.load(userId) : null;
if (isNew && !user) user = defaultUser();
await userModel.checkIfAllowed(ctx.owner, AclAction.Read, user); await userModel.checkIfAllowed(ctx.owner, AclAction.Read, user);
@@ -83,11 +100,63 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
view.content.error = error; view.content.error = error;
view.content.postUrl = postUrl; view.content.postUrl = postUrl;
view.content.showDeleteButton = !isNew && !!owner.is_admin && owner.id !== user.id; view.content.showDeleteButton = !isNew && !!owner.is_admin && owner.id !== user.id;
view.partials.push('errorBanner');
return view; return view;
}); });
router.publicSchemas.push('users/:id/confirm');
router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Error = null) => {
const userId = path.id;
const token = ctx.query.token;
if (token) await ctx.models.user().confirmEmail(userId, token);
const user = await ctx.models.user().load(userId);
const view: View = {
...defaultView('users/confirm'),
content: {
user,
error,
token,
postUrl: ctx.models.user().confirmUrl(userId, token),
},
navbar: false,
};
return view;
});
interface SetPasswordFormData {
token: string;
password: string;
password2: string;
}
router.post('users/:id/confirm', async (path: SubPath, ctx: AppContext) => {
const userId = path.id;
try {
const fields = await bodyFields<SetPasswordFormData>(ctx.req);
await ctx.models.token().checkToken(userId, fields.token);
const password = checkPassword(fields, true);
await ctx.models.user().save({ id: userId, password });
await ctx.models.token().deleteByValue(userId, fields.token);
const session = await ctx.models.session().createUserSession(userId);
ctx.cookies.set('sessionId', session.id);
await ctx.models.notification().add(userId, 'passwordSet', NotificationLevel.Normal, 'Welcome to Joplin Cloud! Your password has been set successfully.');
return redirect(ctx, `${config().baseUrl}/home`);
} catch (error) {
const endPoint = router.findEndPoint(HttpMethod.GET, 'users/:id/confirm');
return endPoint.handler(path, ctx, error);
}
});
router.alias(HttpMethod.POST, 'users/:id', 'users'); router.alias(HttpMethod.POST, 'users/:id', 'users');
router.post('users', async (path: SubPath, ctx: AppContext) => { router.post('users', async (path: SubPath, ctx: AppContext) => {
@@ -124,7 +193,7 @@ router.post('users', async (path: SubPath, ctx: AppContext) => {
} catch (error) { } catch (error) {
if (error instanceof ErrorForbidden) throw error; if (error instanceof ErrorForbidden) throw error;
const endPoint = router.findEndPoint(HttpMethod.GET, 'users/:id'); const endPoint = router.findEndPoint(HttpMethod.GET, 'users/:id');
return endPoint(path, ctx, user, error); return endPoint.handler(path, ctx, user, error);
} }
}); });

View File

@@ -1,45 +0,0 @@
import { Models } from '../models/factory';
import { Config } from '../utils/types';
import MustacheService from './MustacheService';
export default class BaseApplication {
private appName_: string;
private config_: Config = null;
private models_: Models = null;
private mustache_: MustacheService = null;
private rootDir_: string;
protected get mustache(): MustacheService {
return this.mustache_;
}
protected get config(): Config {
return this.config_;
}
protected get models(): Models {
return this.models_;
}
public get rootDir(): string {
return this.rootDir_;
}
public get appBaseUrl(): string {
return `${this.config.baseUrl}/apps/${this.appName_}`;
}
public initBase_(appName: string, config: Config, models: Models) {
this.appName_ = appName;
this.rootDir_ = `${config.rootDir}/src/apps/${appName}`;
this.config_ = config;
this.models_ = models;
this.mustache_ = new MustacheService(`${this.rootDir}/views`, `${config.baseUrl}/apps/${appName}`);
}
public async localFileFromUrl(_url: string): Promise<string> {
return null;
}
}

View File

@@ -0,0 +1,96 @@
import Logger from '@joplin/lib/Logger';
import { Models } from '../models/factory';
import { msleep } from '../utils/time';
import { Config, Env } from '../utils/types';
const logger = Logger.create('BaseService');
export default class BaseService {
private env_: Env;
private models_: Models;
private config_: Config;
protected enabled_: boolean = true;
private destroyed_: boolean = false;
protected maintenanceInterval_: number = 10000;
private scheduledMaintenances_: boolean[] = [];
private maintenanceInProgress_: boolean = false;
public constructor(env: Env, models: Models, config: Config) {
this.env_ = env;
this.models_ = models;
this.config_ = config;
this.scheduleMaintenance = this.scheduleMaintenance.bind(this);
}
public async destroy() {
if (this.destroyed_) throw new Error('Already destroyed');
this.destroyed_ = true;
this.scheduledMaintenances_ = [];
while (this.maintenanceInProgress_) {
await msleep(500);
}
}
protected get models(): Models {
return this.models_;
}
protected get env(): Env {
return this.env_;
}
protected get config(): Config {
return this.config_;
}
public get enabled(): boolean {
return this.enabled_;
}
public get maintenanceInProgress(): boolean {
return !!this.scheduledMaintenances_.length;
}
protected async scheduleMaintenance() {
if (this.destroyed_) return;
// Every time a maintenance is scheduled we push a task to this array.
// Whenever the maintenance actually runs, that array is cleared. So it
// means, that if new tasks are pushed to the array while the
// maintenance is runing, it will run again once it's finished, so as to
// process any item that might have been added.
this.scheduledMaintenances_.push(true);
if (this.scheduledMaintenances_.length !== 1) return;
while (this.scheduledMaintenances_.length) {
await msleep(this.env === Env.Dev ? 2000 : this.maintenanceInterval_);
if (this.destroyed_) return;
const itemCount = this.scheduledMaintenances_.length;
await this.runMaintenance();
this.scheduledMaintenances_.splice(0, itemCount);
}
}
private async runMaintenance() {
this.maintenanceInProgress_ = true;
try {
await this.maintenance();
} catch (error) {
logger.error('Could not run maintenance', error);
}
this.maintenanceInProgress_ = false;
}
protected async maintenance() {
throw new Error('Not implemented');
}
public async runInBackground() {
await this.runMaintenance();
}
}

View File

@@ -0,0 +1,12 @@
import BaseService from './BaseService';
const cron = require('node-cron');
export default class CronService extends BaseService {
public async runInBackground() {
cron.schedule('0 */6 * * *', async () => {
await this.models.token().deleteExpiredTokens();
});
}
}

View File

@@ -0,0 +1,122 @@
import Logger from '@joplin/lib/Logger';
import UserModel from '../models/UserModel';
import BaseService from './BaseService';
import Mail = require('nodemailer/lib/mailer');
import { createTransport } from 'nodemailer';
import { Email, EmailSender } from '../db';
import { errorToString } from '../utils/errors';
const logger = Logger.create('EmailService');
interface Participant {
name: string;
email: string;
}
export default class EmailService extends BaseService {
private transport_: any;
private async transport(): Promise<Mail> {
if (!this.transport_) {
this.transport_ = createTransport({
host: this.config.mailer.host,
port: this.config.mailer.port,
secure: this.config.mailer.secure,
auth: {
user: this.config.mailer.authUser,
pass: this.config.mailer.authPassword,
},
});
try {
await this.transport_.verify();
} catch (error) {
this.enabled_ = false;
this.transport_ = null;
error.message = `Could not initialize transporter. Service will be disabled: ${error.message}`;
throw error;
}
}
return this.transport_;
}
private senderInfo(senderId: EmailSender): Participant {
if (senderId === EmailSender.NoReply) {
return {
name: this.config.mailer.noReplyName,
email: this.config.mailer.noReplyEmail,
};
}
throw new Error(`Invalid sender ID: ${senderId}`);
}
private escapeEmailField(f: string): string {
return f.replace(/[\n\r"<>]/g, '');
}
private formatNameAndEmail(email: string, name: string = ''): string {
if (!email) throw new Error('Email is required');
const output: string[] = [];
if (name) output.push(`"${this.escapeEmailField(name)}"`);
output.push((name ? '<' : '') + this.escapeEmailField(email) + (name ? '>' : ''));
return output.join(' ');
}
protected async maintenance() {
if (!this.enabled_) return;
logger.info('Starting maintenance...');
const startTime = Date.now();
try {
const emails = await this.models.email().needToBeSent();
const transport = await this.transport();
for (const email of emails) {
const sender = this.senderInfo(email.sender_id);
const mailOptions: Mail.Options = {
from: this.formatNameAndEmail(sender.email, sender.name),
to: this.formatNameAndEmail(email.recipient_email, email.recipient_name),
subject: email.subject,
text: email.body,
};
const emailToSave: Email = {
id: email.id,
sent_time: Date.now(),
};
try {
await transport.sendMail(mailOptions);
emailToSave.sent_success = 1;
emailToSave.error = '';
} catch (error) {
emailToSave.sent_success = 0;
emailToSave.error = errorToString(error);
}
await this.models.email().save(emailToSave);
}
} catch (error) {
logger.error('Could not run maintenance:', error);
}
logger.info(`Maintenance completed in ${Date.now() - startTime}ms`);
}
public async runInBackground() {
if (!this.config.mailer.host || !this.config.mailer.enabled) {
this.enabled_ = false;
logger.info('Service will be disabled because mailer config is not set or is explicitly disabled');
return;
}
UserModel.eventEmitter.on('created', this.scheduleMaintenance);
await super.runInBackground();
}
}

View File

@@ -1,6 +1,9 @@
import * as Mustache from 'mustache'; import * as Mustache from 'mustache';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import config from '../config'; import config from '../config';
import { filename } from '@joplin/lib/path-utils';
import { NotificationView } from '../utils/types';
import { User } from '../db';
export interface RenderOptions { export interface RenderOptions {
partials?: any; partials?: any;
@@ -11,12 +14,21 @@ export interface RenderOptions {
export interface View { export interface View {
name: string; name: string;
path: string; path: string;
navbar?: boolean;
content?: any; content?: any;
partials?: string[]; partials?: string[];
cssFiles?: string[]; cssFiles?: string[];
jsFiles?: string[]; jsFiles?: string[];
} }
interface GlobalParams {
baseUrl?: string;
prefersDarkEnabled?: boolean;
notifications?: NotificationView[];
hasNotifications?: boolean;
owner?: User;
}
export function isView(o: any): boolean { export function isView(o: any): boolean {
if (typeof o !== 'object' || !o) return false; if (typeof o !== 'object' || !o) return false;
return 'path' in o && 'name' in o; return 'path' in o && 'name' in o;
@@ -27,12 +39,27 @@ export default class MustacheService {
private viewDir_: string; private viewDir_: string;
private baseAssetUrl_: string; private baseAssetUrl_: string;
private prefersDarkEnabled_: boolean = true; private prefersDarkEnabled_: boolean = true;
private partials_: Record<string, string> = {};
public constructor(viewDir: string, baseAssetUrl: string) { public constructor(viewDir: string, baseAssetUrl: string) {
this.viewDir_ = viewDir; this.viewDir_ = viewDir;
this.baseAssetUrl_ = baseAssetUrl; this.baseAssetUrl_ = baseAssetUrl;
} }
public async loadPartials() {
const files = await fs.readdir(this.partialDir);
for (const f of files) {
const name = filename(f);
const templateContent = await this.loadTemplateContent(`${this.partialDir}/${f}`);
this.partials_[name] = templateContent;
}
}
public get partialDir(): string {
return `${this.viewDir_}/partials`;
}
public get prefersDarkEnabled(): boolean { public get prefersDarkEnabled(): boolean {
return this.prefersDarkEnabled_; return this.prefersDarkEnabled_;
} }
@@ -45,7 +72,7 @@ export default class MustacheService {
return `${config().layoutDir}/default.mustache`; return `${config().layoutDir}/default.mustache`;
} }
private get defaultLayoutOptions(): any { private get defaultLayoutOptions(): GlobalParams {
return { return {
baseUrl: config().baseUrl, baseUrl: config().baseUrl,
prefersDarkEnabled: this.prefersDarkEnabled_, prefersDarkEnabled: this.prefersDarkEnabled_,
@@ -64,17 +91,9 @@ export default class MustacheService {
return output; return output;
} }
public async renderView(view: View, globalParams: any = null): Promise<string> { public async renderView(view: View, globalParams: GlobalParams = null): Promise<string> {
const partials = view.partials || [];
const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []); const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []);
const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []); const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []);
const partialContents: any = {};
for (const partialName of partials) {
const filePath = `${this.viewDir_}/partials/${partialName}.mustache`;
partialContents[partialName] = await this.loadTemplateContent(filePath);
}
const filePath = `${this.viewDir_}/${view.path}.mustache`; const filePath = `${this.viewDir_}/${view.path}.mustache`;
globalParams = { globalParams = {
@@ -88,19 +107,20 @@ export default class MustacheService {
...view.content, ...view.content,
global: globalParams, global: globalParams,
}, },
partialContents this.partials_
); );
const layoutView: any = Object.assign({}, { const layoutView: any = {
global: globalParams, global: globalParams,
pageName: view.name, pageName: view.name,
contentHtml: contentHtml, contentHtml: contentHtml,
cssFiles: cssFiles, cssFiles: cssFiles,
jsFiles: jsFiles, jsFiles: jsFiles,
navbar: view.navbar,
...view.content, ...view.content,
}); };
return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView, partialContents); return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView, this.partials_);
} }
} }

View File

@@ -1,3 +1,4 @@
import config from '../config';
import { shareFolderWithUser } from '../utils/testing/shareApiUtils'; import { shareFolderWithUser } from '../utils/testing/shareApiUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, updateNote, msleep } from '../utils/testing/testUtils'; import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, updateNote, msleep } from '../utils/testing/testUtils';
import { Env } from '../utils/types'; import { Env } from '../utils/types';
@@ -23,7 +24,7 @@ describe('ShareService', function() {
const { user: user1, session: session1 } = await createUserAndSession(1); const { user: user1, session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2); const { user: user2, session: session2 } = await createUserAndSession(2);
const service = new ShareService(Env.Dev, models()); const service = new ShareService(Env.Dev, models(), config());
void service.runInBackground(); void service.runInBackground();
await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F2', { await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F2', {

View File

@@ -1,73 +1,27 @@
import Logger from '@joplin/lib/Logger'; import Logger from '@joplin/lib/Logger';
import ChangeModel from '../models/ChangeModel'; import ChangeModel from '../models/ChangeModel';
import { Models } from '../models/factory'; import BaseService from './BaseService';
import { Env } from '../utils/types';
const logger = Logger.create('ShareService'); const logger = Logger.create('ShareService');
export default class ShareService { export default class ShareService extends BaseService {
private env_: Env;
private models_: Models;
private maintenanceScheduled_: boolean = false;
private maintenanceInProgress_: boolean = false;
private scheduleMaintenanceTimeout_: any = null;
public constructor(env: Env, models: Models) {
this.env_ = env;
this.models_ = models;
this.scheduleMaintenance = this.scheduleMaintenance.bind(this);
}
public async destroy() {
if (this.scheduleMaintenanceTimeout_) {
clearTimeout(this.scheduleMaintenanceTimeout_);
this.scheduleMaintenanceTimeout_ = null;
}
}
public get models(): Models {
return this.models_;
}
public get env(): Env {
return this.env_;
}
public get maintenanceInProgress(): boolean {
return this.maintenanceInProgress_;
}
private async scheduleMaintenance() {
if (this.maintenanceScheduled_) return;
this.maintenanceScheduled_ = true;
this.scheduleMaintenanceTimeout_ = setTimeout(() => {
this.maintenanceScheduled_ = false;
void this.maintenance();
}, this.env === Env.Dev ? 2000 : 10000);
}
private async maintenance() {
if (this.maintenanceInProgress_) return;
protected async maintenance() {
logger.info('Starting maintenance...'); logger.info('Starting maintenance...');
const startTime = Date.now(); const startTime = Date.now();
this.maintenanceInProgress_ = true;
try { try {
await this.models.share().updateSharedItems3(); await this.models.share().updateSharedItems3();
} catch (error) { } catch (error) {
logger.error('Could not update share items:', error); logger.error('Could not update share items:', error);
} }
this.maintenanceInProgress_ = false;
logger.info(`Maintenance completed in ${Date.now() - startTime}ms`); logger.info(`Maintenance completed in ${Date.now() - startTime}ms`);
} }
public async runInBackground() { public async runInBackground() {
ChangeModel.eventEmitter.on('saved', this.scheduleMaintenance); ChangeModel.eventEmitter.on('saved', this.scheduleMaintenance);
await this.maintenance(); await super.runInBackground();
} }
} }

View File

@@ -1,5 +1,11 @@
import CronService from './CronService';
import EmailService from './EmailService';
import MustacheService from './MustacheService';
import ShareService from './ShareService'; import ShareService from './ShareService';
export interface Services { export interface Services {
share: ShareService; share: ShareService;
email: EmailService;
cron: CronService;
mustache: MustacheService;
} }

View File

@@ -1,4 +1,4 @@
import { connectDb, disconnectDb, migrateDb, sqliteFilePath } from '../db'; import { connectDb, disconnectDb, migrateDb } from '../db';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import { DatabaseConfig } from '../utils/types'; import { DatabaseConfig } from '../utils/types';
@@ -33,7 +33,7 @@ export async function createDb(config: DatabaseConfig, options: CreateDbOptions
await execCommand(cmd.join(' '), { env: { PGPASSWORD: config.password } }); await execCommand(cmd.join(' '), { env: { PGPASSWORD: config.password } });
} else if (config.client === 'sqlite3') { } else if (config.client === 'sqlite3') {
const filePath = sqliteFilePath(config.name); const filePath = config.name;
if (await fs.pathExists(filePath)) { if (await fs.pathExists(filePath)) {
if (options.dropIfExists) { if (options.dropIfExists) {
@@ -71,6 +71,6 @@ export async function dropDb(config: DatabaseConfig, options: DropDbOptions = nu
throw error; throw error;
} }
} else if (config.client === 'sqlite3') { } else if (config.client === 'sqlite3') {
await fs.remove(sqliteFilePath(config.name)); await fs.remove(config.name);
} }
} }

View File

@@ -31,6 +31,8 @@ const config = {
'main.shares': 'WithDates, WithUuid', 'main.shares': 'WithDates, WithUuid',
'main.share_users': 'WithDates, WithUuid', 'main.share_users': 'WithDates, WithUuid',
'main.user_items': 'WithDates', 'main.user_items': 'WithDates',
'main.emails': 'WithDates',
'main.tokens': 'WithDates',
}, },
}; };
@@ -41,6 +43,8 @@ const propertyTypes: Record<string, string> = {
'shares.type': 'ShareType', 'shares.type': 'ShareType',
'items.content': 'Buffer', 'items.content': 'Buffer',
'share_users.status': 'ShareUserStatus', 'share_users.status': 'ShareUserStatus',
'emails.sender_id': 'EmailSender',
'emails.sent_time': 'number',
}; };
function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void { function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void {

View File

@@ -1,7 +1,12 @@
import { ErrorMethodNotAllowed, ErrorNotFound } from './errors'; import { ErrorMethodNotAllowed, ErrorNotFound } from './errors';
import { HttpMethod } from './types'; import { HttpMethod, RouteType } from './types';
import { RouteResponseFormat, RouteHandler } from './routeUtils'; import { RouteResponseFormat, RouteHandler } from './routeUtils';
interface RouteInfo {
handler: RouteHandler;
type?: RouteType;
}
export default class Router { export default class Router {
// When the router is public, we do not check that a valid session is // When the router is public, we do not check that a valid session is
@@ -9,59 +14,71 @@ export default class Router {
// not logged in, can access any route of this router. End points that // not logged in, can access any route of this router. End points that
// should not be publicly available should call ownerRequired(ctx); // should not be publicly available should call ownerRequired(ctx);
public public: boolean = false; public public: boolean = false;
public publicSchemas: string[] = [];
public responseFormat: RouteResponseFormat = null; public responseFormat: RouteResponseFormat = null;
private routes_: Record<string, Record<string, RouteHandler>> = {}; private routes_: Record<string, Record<string, RouteInfo>> = {};
private aliases_: Record<string, Record<string, string>> = {}; private aliases_: Record<string, Record<string, string>> = {};
private type_: RouteType;
public findEndPoint(method: HttpMethod, schema: string): RouteHandler { public constructor(type: RouteType) {
this.type_ = type;
}
public findEndPoint(method: HttpMethod, schema: string): RouteInfo {
if (this.aliases_[method]?.[schema]) { return this.findEndPoint(method, this.aliases_[method]?.[schema]); } if (this.aliases_[method]?.[schema]) { return this.findEndPoint(method, this.aliases_[method]?.[schema]); }
if (!this.routes_[method]) { throw new ErrorMethodNotAllowed(`Not allowed: ${method} ${schema}`); } if (!this.routes_[method]) { throw new ErrorMethodNotAllowed(`Not allowed: ${method} ${schema}`); }
const endPoint = this.routes_[method][schema]; const endPoint = this.routes_[method][schema];
if (!endPoint) { throw new ErrorNotFound(`Not found: ${method} ${schema}`); } if (!endPoint) { throw new ErrorNotFound(`Not found: ${method} ${schema}`); }
let endPointFn = endPoint; let endPointInfo = endPoint;
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
if (typeof endPointFn === 'string') { if (typeof endPointInfo === 'string') {
endPointFn = this.routes_[method]?.[endPointFn]; endPointInfo = this.routes_[method]?.[endPointInfo];
} else { } else {
return endPointFn; const output = { ...endPointInfo };
if (!output.type) output.type = this.type_;
return output;
} }
} }
throw new ErrorNotFound(`Could not resolve: ${method} ${schema}`); throw new ErrorNotFound(`Could not resolve: ${method} ${schema}`);
} }
public isPublic(schema: string): boolean {
return this.public || this.publicSchemas.includes(schema);
}
public alias(method: HttpMethod, path: string, target: string) { public alias(method: HttpMethod, path: string, target: string) {
if (!this.aliases_[method]) { this.aliases_[method] = {}; } if (!this.aliases_[method]) { this.aliases_[method] = {}; }
this.aliases_[method][path] = target; this.aliases_[method][path] = target;
} }
public get(path: string, handler: RouteHandler) { public get(path: string, handler: RouteHandler, type: RouteType = null) {
if (!this.routes_.GET) { this.routes_.GET = {}; } if (!this.routes_.GET) { this.routes_.GET = {}; }
this.routes_.GET[path] = handler; this.routes_.GET[path] = { handler, type };
} }
public post(path: string, handler: RouteHandler) { public post(path: string, handler: RouteHandler, type: RouteType = null) {
if (!this.routes_.POST) { this.routes_.POST = {}; } if (!this.routes_.POST) { this.routes_.POST = {}; }
this.routes_.POST[path] = handler; this.routes_.POST[path] = { handler, type };
} }
public patch(path: string, handler: RouteHandler) { public patch(path: string, handler: RouteHandler, type: RouteType = null) {
if (!this.routes_.PATCH) { this.routes_.PATCH = {}; } if (!this.routes_.PATCH) { this.routes_.PATCH = {}; }
this.routes_.PATCH[path] = handler; this.routes_.PATCH[path] = { handler, type };
} }
public del(path: string, handler: RouteHandler) { public del(path: string, handler: RouteHandler, type: RouteType = null) {
if (!this.routes_.DELETE) { this.routes_.DELETE = {}; } if (!this.routes_.DELETE) { this.routes_.DELETE = {}; }
this.routes_.DELETE[path] = handler; this.routes_.DELETE[path] = { handler, type };
} }
public put(path: string, handler: RouteHandler) { public put(path: string, handler: RouteHandler, type: RouteType = null) {
if (!this.routes_.PUT) { this.routes_.PUT = {}; } if (!this.routes_.PUT) { this.routes_.PUT = {}; }
this.routes_.PUT[path] = handler; this.routes_.PUT[path] = { handler, type };
} }
} }

View File

@@ -6,9 +6,6 @@ export default function(name: string): View {
name: name, name: name,
path: `index/${name}`, path: `index/${name}`,
content: {}, content: {},
partials: [ navbar: true,
'navbar',
'notifications',
],
}; };
} }

View File

@@ -26,8 +26,8 @@ export class ErrorMethodNotAllowed extends ApiError {
export class ErrorNotFound extends ApiError { export class ErrorNotFound extends ApiError {
public static httpCode: number = 404; public static httpCode: number = 404;
public constructor(message: string = 'Not Found') { public constructor(message: string = 'Not Found', code: string = undefined) {
super(message, ErrorNotFound.httpCode); super(message, ErrorNotFound.httpCode, code);
Object.setPrototypeOf(this, ErrorNotFound.prototype); Object.setPrototypeOf(this, ErrorNotFound.prototype);
} }
} }
@@ -86,3 +86,10 @@ export class ErrorPayloadTooLarge extends ApiError {
Object.setPrototypeOf(this, ErrorPayloadTooLarge.prototype); Object.setPrototypeOf(this, ErrorPayloadTooLarge.prototype);
} }
} }
export function errorToString(error: Error): string {
const msg: string[] = [];
msg.push(error.message ? error.message : 'Unknown error');
if (error.stack) msg.push(error.stack);
return msg.join(': ');
}

View File

@@ -1,5 +1,5 @@
import JoplinDatabase from '@joplin/lib/JoplinDatabase'; import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import Logger from '@joplin/lib/Logger'; // import Logger from '@joplin/lib/Logger';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import BaseItem from '@joplin/lib/models/BaseItem'; import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note'; import Note from '@joplin/lib/models/Note';
@@ -24,7 +24,7 @@ import Setting from '@joplin/lib/models/Setting';
import { Models } from '../models/factory'; import { Models } from '../models/factory';
import MustacheService from '../services/MustacheService'; import MustacheService from '../services/MustacheService';
const logger = Logger.create('JoplinUtils'); // const logger = Logger.create('JoplinUtils');
export interface FileViewerResponse { export interface FileViewerResponse {
body: any; body: any;
@@ -55,15 +55,16 @@ let baseUrl_: string = null;
export const resourceDirName = '.resource'; export const resourceDirName = '.resource';
export async function initializeJoplinUtils(config: Config, models: Models) { export async function initializeJoplinUtils(config: Config, models: Models, mustache: MustacheService) {
models_ = models; models_ = models;
baseUrl_ = config.baseUrl; baseUrl_ = config.baseUrl;
mustache_ = mustache;
const filePath = `${config.tempDir}/joplin.sqlite`; const filePath = `${config.tempDir}/joplin.sqlite`;
await fs.remove(filePath); await fs.remove(filePath);
db_ = new JoplinDatabase(new DatabaseDriverNode()); db_ = new JoplinDatabase(new DatabaseDriverNode());
db_.setLogger(logger as Logger); // db_.setLogger(logger as Logger);
await db_.open({ name: filePath }); await db_.open({ name: filePath });
BaseModel.setDb(db_); BaseModel.setDb(db_);
@@ -78,8 +79,8 @@ export async function initializeJoplinUtils(config: Config, models: Models) {
BaseItem.loadClass('MasterKey', MasterKey); BaseItem.loadClass('MasterKey', MasterKey);
BaseItem.loadClass('Revision', Revision); BaseItem.loadClass('Revision', Revision);
mustache_ = new MustacheService(config.viewDir, config.baseUrl); // mustache_ = new MustacheService(config.viewDir, config.baseUrl);
mustache_.prefersDarkEnabled = false; // mustache_.prefersDarkEnabled = false;
} }
export function linkedResourceIds(body: string): string[] { export function linkedResourceIds(body: string): string[] {
@@ -210,7 +211,7 @@ async function renderNote(share: Share, note: NoteEntity, resourceInfos: Resourc
}; };
`, `,
}, },
}); }, { prefersDarkEnabled: false });
return { return {
body: bodyHtml, body: bodyHtml,

View File

@@ -22,6 +22,8 @@ export async function formParse(req: any): Promise<FormParseResult> {
return output; return output;
} }
// Note that for Formidable to work, the content-type must be set in the
// headers
return new Promise((resolve: Function, reject: Function) => { return new Promise((resolve: Function, reject: Function) => {
const form = formidable({ multiples: true }); const form = formidable({ multiples: true });
form.parse(req, (error: any, fields: any, files: any) => { form.parse(req, (error: any, fields: any, files: any) => {
@@ -36,29 +38,8 @@ export async function formParse(req: any): Promise<FormParseResult> {
} }
export async function bodyFields<T>(req: any/* , filter:string[] = null*/): Promise<T> { export async function bodyFields<T>(req: any/* , filter:string[] = null*/): Promise<T> {
// Formidable needs the content-type to be 'application/json' so on our side
// we explicitely set it to that. However save the previous value so that it
// can be restored.
let previousContentType = null;
if (req.headers['content-type'] !== 'application/json') {
previousContentType = req.headers['content-type'];
req.headers['content-type'] = 'application/json';
}
const form = await formParse(req); const form = await formParse(req);
if (previousContentType) req.headers['content-type'] = previousContentType;
return form.fields as T; return form.fields as T;
// if (filter) {
// const output:BodyFields = {};
// Object.keys(form.fields).forEach(f => {
// if (filter.includes(f)) output[f] = form.fields[f];
// });
// return output;
// } else {
// return form.fields;
// }
} }
export function ownerRequired(ctx: AppContext) { export function ownerRequired(ctx: AppContext) {

View File

@@ -1,4 +1,4 @@
import { parseSubPath, splitItemPath } from './routeUtils'; import { isValidOrigin, parseSubPath, splitItemPath } from './routeUtils';
import { ItemAddressingType } from '../db'; import { ItemAddressingType } from '../db';
describe('routeUtils', function() { describe('routeUtils', function() {
@@ -41,4 +41,46 @@ describe('routeUtils', function() {
} }
}); });
it('should check the request origin', async function() {
const testCases: any[] = [
[
'https://example.com', // Request origin
'https://example.com', // Config base URL
true,
],
[
// Apache ProxyPreserveHost somehow converts https:// to http://
// but in this context it's valid as only the domain matters.
'http://example.com',
'https://example.com',
true,
],
[
// With Apache ProxyPreserveHost, the request might be eg
// https://example.com/joplin/api/ping but the origin part, as
// forwarded by Apache will be https://example.com/api/ping
// (without /joplin). In that case the request is valid anyway
// since we only care about the domain.
'https://example.com',
'https://example.com/joplin',
true,
],
[
'https://bad.com',
'https://example.com',
false,
],
[
'http://bad.com',
'https://example.com',
false,
],
];
for (const testCase of testCases) {
const [requestOrigin, configBaseUrl, expected] = testCase;
expect(isValidOrigin(requestOrigin, configBaseUrl)).toBe(expected);
}
});
}); });

View File

@@ -1,7 +1,9 @@
import { baseUrl } from '../config';
import { Item, ItemAddressingType } from '../db'; import { Item, ItemAddressingType } from '../db';
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors'; import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors';
import Router from './Router'; import Router from './Router';
import { AppContext, HttpMethod } from './types'; import { AppContext, HttpMethod } from './types';
import { URL } from 'url';
const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils'); const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils');
@@ -151,6 +153,12 @@ export function parseSubPath(basePath: string, p: string, rawPath: string = null
return output; return output;
} }
export function isValidOrigin(requestOrigin: string, endPointBaseUrl: string): boolean {
const host1 = (new URL(requestOrigin)).host;
const host2 = (new URL(endPointBaseUrl)).host;
return host1 === host2;
}
export function routeResponseFormat(context: AppContext): RouteResponseFormat { export function routeResponseFormat(context: AppContext): RouteResponseFormat {
// const rawPath = context.path; // const rawPath = context.path;
// if (match && match.route.responseFormat) return match.route.responseFormat; // if (match && match.route.responseFormat) return match.route.responseFormat;
@@ -166,14 +174,15 @@ export async function execRequest(routes: Routers, ctx: AppContext) {
const match = findMatchingRoute(ctx.path, routes); const match = findMatchingRoute(ctx.path, routes);
if (!match) throw new ErrorNotFound(); if (!match) throw new ErrorNotFound();
const routeHandler = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema); const endPoint = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema);
if (ctx.URL && !isValidOrigin(ctx.URL.origin, baseUrl(endPoint.type))) throw new ErrorNotFound('Invalid origin', 'invalidOrigin');
// This is a generic catch-all for all private end points - if we // This is a generic catch-all for all private end points - if we
// couldn't get a valid session, we exit now. Individual end points // couldn't get a valid session, we exit now. Individual end points
// might have additional permission checks depending on the action. // might have additional permission checks depending on the action.
if (!match.route.public && !ctx.owner) throw new ErrorForbidden(); if (!match.route.isPublic(match.subPath.schema) && !ctx.owner) throw new ErrorForbidden();
return routeHandler(match.subPath, ctx); return endPoint.handler(match.subPath, ctx);
} }
// In a path such as "/api/files/SOME_ID/content" we want to find: // In a path such as "/api/files/SOME_ID/content" we want to find:

View File

@@ -2,22 +2,32 @@ import { LoggerWrapper } from '@joplin/lib/Logger';
import config from '../config'; import config from '../config';
import { DbConnection } from '../db'; import { DbConnection } from '../db';
import newModelFactory, { Models } from '../models/factory'; import newModelFactory, { Models } from '../models/factory';
import { AppContext, Env } from './types'; import { AppContext, Config, Env } from './types';
import routes from '../routes/routes'; import routes from '../routes/routes';
import ShareService from '../services/ShareService'; import ShareService from '../services/ShareService';
import { Services } from '../services/types'; import { Services } from '../services/types';
import EmailService from '../services/EmailService';
import CronService from '../services/CronService';
import MustacheService from '../services/MustacheService';
function setupServices(env: Env, models: Models): Services { async function setupServices(env: Env, models: Models, config: Config): Promise<Services> {
return { const output: Services = {
share: new ShareService(env, models), share: new ShareService(env, models, config),
email: new EmailService(env, models, config),
cron: new CronService(env, models, config),
mustache: new MustacheService(config.viewDir, config.baseUrl),
}; };
await output.mustache.loadPartials();
return output;
} }
export default async function(appContext: AppContext, env: Env, dbConnection: DbConnection, appLogger: ()=> LoggerWrapper) { export default async function(appContext: AppContext, env: Env, dbConnection: DbConnection, appLogger: ()=> LoggerWrapper) {
appContext.env = env; appContext.env = env;
appContext.db = dbConnection; appContext.db = dbConnection;
appContext.models = newModelFactory(appContext.db, config().baseUrl); appContext.models = newModelFactory(appContext.db, config().baseUrl);
appContext.services = setupServices(env, appContext.models); appContext.services = await setupServices(env, appContext.models, config());
appContext.appLogger = appLogger; appContext.appLogger = appLogger;
appContext.routes = { ...routes }; appContext.routes = { ...routes };

View File

@@ -1,5 +1,9 @@
import { AppContext } from './types'; import { AppContext } from './types';
export default function startServices(appContext: AppContext) { export default async function startServices(appContext: AppContext) {
void appContext.services.share.runInBackground(); const services = appContext.services;
void services.share.runInBackground();
void services.email.runInBackground();
void services.cron.runInBackground();
} }

View File

@@ -1,4 +1,4 @@
import { User, Session, DbConnection, connectDb, disconnectDb, truncateTables, sqliteFilePath, Item, Uuid } from '../../db'; import { User, Session, DbConnection, connectDb, disconnectDb, truncateTables, Item, Uuid } from '../../db';
import { createDb } from '../../tools/dbTools'; import { createDb } from '../../tools/dbTools';
import modelFactory from '../../models/factory'; import modelFactory from '../../models/factory';
import { AppContext, Env } from '../types'; import { AppContext, Env } from '../types';
@@ -9,6 +9,7 @@ import FakeRequest from './koa/FakeRequest';
import FakeResponse from './koa/FakeResponse'; import FakeResponse from './koa/FakeResponse';
import * as httpMocks from 'node-mocks-http'; import * as httpMocks from 'node-mocks-http';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as path from 'path';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import * as jsdom from 'jsdom'; import * as jsdom from 'jsdom';
import setupAppContext from '../setupAppContext'; import setupAppContext from '../setupAppContext';
@@ -17,10 +18,11 @@ import { putApi } from './apiUtils';
import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types'; import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import { ModelType } from '@joplin/lib/BaseModel'; import { ModelType } from '@joplin/lib/BaseModel';
import { initializeJoplinUtils } from '../joplinUtils'; import { initializeJoplinUtils } from '../joplinUtils';
import MustacheService from '../../services/MustacheService';
// Takes into account the fact that this file will be inside the /dist directory // Takes into account the fact that this file will be inside the /dist directory
// when it runs. // when it runs.
const packageRootDir = `${__dirname}/../../..`; const packageRootDir = path.dirname(path.dirname(path.dirname(__dirname)));
let db_: DbConnection = null; let db_: DbConnection = null;
@@ -54,9 +56,9 @@ function initGlobalLogger() {
Logger.initializeGlobalLogger(globalLogger); Logger.initializeGlobalLogger(globalLogger);
} }
let createdDbName_: string = null; let createdDbPath_: string = null;
export async function beforeAllDb(unitName: string) { export async function beforeAllDb(unitName: string) {
createdDbName_ = unitName; createdDbPath_ = `${packageRootDir}/db-test-${unitName}.sqlite`;
const tempDir = `${packageRootDir}/temp/test-${unitName}`; const tempDir = `${packageRootDir}/temp/test-${unitName}`;
await fs.mkdirp(tempDir); await fs.mkdirp(tempDir);
@@ -66,7 +68,7 @@ export async function beforeAllDb(unitName: string) {
// initConfig({ // initConfig({
// DB_CLIENT: 'pg', // DB_CLIENT: 'pg',
// POSTGRES_DATABASE: createdDbName_, // POSTGRES_DATABASE: createdDbPath_,
// POSTGRES_USER: 'joplin', // POSTGRES_USER: 'joplin',
// POSTGRES_PASSWORD: 'joplin', // POSTGRES_PASSWORD: 'joplin',
// }, { // }, {
@@ -74,7 +76,7 @@ export async function beforeAllDb(unitName: string) {
// }); // });
initConfig({ initConfig({
SQLITE_DATABASE: createdDbName_, SQLITE_DATABASE: createdDbPath_,
}, { }, {
tempDir: tempDir, tempDir: tempDir,
}); });
@@ -84,7 +86,10 @@ export async function beforeAllDb(unitName: string) {
await createDb(config().database, { dropIfExists: true }); await createDb(config().database, { dropIfExists: true });
db_ = await connectDb(config().database); db_ = await connectDb(config().database);
await initializeJoplinUtils(config(), models()); const mustache = new MustacheService(config().viewDir, config().baseUrl);
await mustache.loadPartials();
await initializeJoplinUtils(config(), models(), mustache);
} }
export async function afterAllTests() { export async function afterAllTests() {
@@ -98,10 +103,9 @@ export async function afterAllTests() {
tempDir_ = null; tempDir_ = null;
} }
if (createdDbName_) { if (createdDbPath_) {
const filePath = sqliteFilePath(createdDbName_); await fs.remove(createdDbPath_);
await fs.remove(filePath); createdDbPath_ = null;
createdDbName_ = null;
} }
} }

View File

@@ -36,6 +36,8 @@ export enum DatabaseConfigClient {
export interface DatabaseConfig { export interface DatabaseConfig {
client: DatabaseConfigClient; client: DatabaseConfigClient;
// For Postgres, this is the actual database name. For SQLite, this is the
// path to the SQLite file.
name: string; name: string;
host?: string; host?: string;
port?: number; port?: number;
@@ -44,17 +46,31 @@ export interface DatabaseConfig {
asyncStackTraces?: boolean; asyncStackTraces?: boolean;
} }
export interface MailerConfig {
enabled: boolean;
host: string;
port: number;
secure: boolean;
authUser: string;
authPassword: string;
noReplyName: string;
noReplyEmail: string;
}
export interface Config { export interface Config {
port: number; port: number;
rootDir: string; rootDir: string;
viewDir: string; viewDir: string;
layoutDir: string; layoutDir: string;
// Not that, for now, nothing is being logged to file. Log is just printed // Note that, for now, nothing is being logged to file. Log is just printed
// to stdout, which is then handled by Docker own log mechanism // to stdout, which is then handled by Docker own log mechanism
logDir: string; logDir: string;
tempDir: string; tempDir: string;
database: DatabaseConfig;
baseUrl: string; baseUrl: string;
apiBaseUrl: string;
userContentBaseUrl: string;
database: DatabaseConfig;
mailer: MailerConfig;
} }
export enum HttpMethod { export enum HttpMethod {
@@ -65,4 +81,10 @@ export enum HttpMethod {
HEAD = 'HEAD', HEAD = 'HEAD',
} }
export enum RouteType {
Web = 1,
Api = 2,
UserContent = 3,
}
export type KoaNext = ()=> Promise<void>; export type KoaNext = ()=> Promise<void>;

View File

@@ -7,10 +7,10 @@
{{/stack}} {{/stack}}
</div> </div>
{{#owner}} {{#owner}}
<p><a href="{{{global.baseUrl}}}/home">Back to home page</a></p> <p><a href="{{{global.baseUrl}}}/home">Go to home page</a></p>
{{/owner}} {{/owner}}
{{^owner}} {{^owner}}
<p><a href="{{{global.baseUrl}}}/login">Back to login page</a></p> <p><a href="{{{global.baseUrl}}}/login">Go to login page</a></p>
{{/owner}} {{/owner}}
</div> </div>
</div> </div>

View File

@@ -1,17 +1,17 @@
<form id="item_form" action="{{{postUrl}}}" method="POST" class="block">
<input type="submit" name="delete_all_button" class="button is-danger" value="Delete all" />
</form>
{{#itemTable}} {{#itemTable}}
{{>table}} {{>table}}
{{/itemTable}} {{/itemTable}}
<form id="item_form" action="{{{postUrl}}}" method="POST" class="block">
<input type="submit" name="delete_all_button" class="button is-danger" value="Delete all" />
</form>
<script> <script>
onDocumentReady(function() { onDocumentReady(function() {
document.getElementById("item_form").addEventListener('submit', function(event) { document.getElementById("item_form").addEventListener('submit', function(event) {
if (event.submitter.getAttribute('name') === 'delete_all_button') { if (event.submitter.getAttribute('name') === 'delete_all_button') {
const ok = confirm('Delete all items?'); const response = prompt('This will DELETE all your notes, and it cannot be undone. If you wish to continue, please type "confirm".');
if (!ok) event.preventDefault(); if (response !== 'confirm') event.preventDefault();
} }
}); });
}); });

View File

@@ -0,0 +1,33 @@
<section class="section">
<div class="container">
<h1 class="title">Welcome to Joplin!</h1>
<h2 class="subtitle">Please enter your password to start using your account.</h2>
<form action="{{postUrl}}" method="POST">
<input class="input" type="hidden" name="token" value="{{token}}"/>
<div class="field">
<label class="label">Email</label>
<div class="control">
<input class="input" type="email" disabled value="{{user.email}}"/>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input class="input" type="password" name="password"/>
</div>
</div>
<div class="field">
<label class="label">Repeat password</label>
<div class="control">
<input class="input" type="password" name="password2"/>
</div>
</div>
{{> errorBanner}}
<div class="control">
<button class="button is-primary">Save password</button>
</div>
</form>
</div>
</section>

View File

@@ -1,8 +1,8 @@
{{#error}} {{#error}}
<div class="notification is-danger"> <div class="notification is-danger">
<strong>{{error.message}}</strong> <strong>{{message}}</strong>
{{#error.stack}} {{#stack}}
<pre>{{.}}</pre> <pre>{{.}}</pre>
{{/error.stack}} {{/stack}}
</div> </div>
{{/error}} {{/error}}

View File

@@ -1,26 +1,28 @@
<nav class="navbar" role="navigation" aria-label="main navigation"> {{#navbar}}
<div class="navbar-brand logo-container"> <nav class="navbar" role="navigation" aria-label="main navigation">
<a class="navbar-item" href="{{{global.baseUrl}}}/home"> <div class="navbar-brand logo-container">
<img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/> <a class="navbar-item" href="{{{global.baseUrl}}}/home">
</a> <img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/>
</div> </a>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">Home</a>
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/users">Users</a>
{{/global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/items">Items</a>
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">Log</a>
</div> </div>
<div class="navbar-end"> <div class="navbar-menu is-active">
<div class="navbar-item">{{global.owner.email}}</div> <div class="navbar-start">
<a class="navbar-item" href="{{{global.baseUrl}}}/users/me">Profile</a> <a class="navbar-item" href="{{{global.baseUrl}}}/home">Home</a>
<div class="navbar-item"> {{#global.owner.is_admin}}
<form method="post" action="{{{global.baseUrl}}}/logout"> <a class="navbar-item" href="{{{global.baseUrl}}}/users">Users</a>
<button class="button is-primary">Logout</button> {{/global.owner.is_admin}}
</form> <a class="navbar-item" href="{{{global.baseUrl}}}/items">Items</a>
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">Log</a>
</div>
<div class="navbar-end">
<div class="navbar-item">{{global.owner.email}}</div>
<a class="navbar-item" href="{{{global.baseUrl}}}/users/me">Profile</a>
<div class="navbar-item">
<form method="post" action="{{{global.baseUrl}}}/logout">
<button class="button is-primary">Logout</button>
</form>
</div>
</div> </div>
</div> </div>
</div> </nav>
</nav> {{/navbar}}

View File

@@ -7,14 +7,17 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Joplin-CLI 1.0.0\n" "Project-Id-Version: Joplin-CLI 1.0.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"Last-Translator: Alessandro Bernardello <mailfilledwithspam@gmail.com>\n" "Last-Translator: Alberto Pasqualetto <39854348+albertopasqualetto@users."
"noreply.github.com>\n"
"Language-Team: \n" "Language-Team: \n"
"Language: it\n" "Language: it\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.2\n" "X-Generator: Poedit 2.4.3\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
#: packages/app-desktop/bridge.js:106 packages/app-desktop/bridge.js:110 #: packages/app-desktop/bridge.js:106 packages/app-desktop/bridge.js:110
#: packages/app-desktop/bridge.js:126 packages/app-desktop/bridge.js:134 #: packages/app-desktop/bridge.js:126 packages/app-desktop/bridge.js:134
@@ -124,11 +127,11 @@ msgstr "Scarica"
#: packages/app-desktop/checkForUpdates.js:189 #: packages/app-desktop/checkForUpdates.js:189
msgid "Skip this version" msgid "Skip this version"
msgstr "" msgstr "Salta questa versione"
#: packages/app-desktop/checkForUpdates.js:189 #: packages/app-desktop/checkForUpdates.js:189
msgid "Full changelog" msgid "Full changelog"
msgstr "" msgstr "Changelog completo"
#: packages/app-desktop/gui/NoteRevisionViewer.min.js:75 #: packages/app-desktop/gui/NoteRevisionViewer.min.js:75
#, javascript-format #, javascript-format
@@ -277,12 +280,11 @@ msgstr "Riprova"
#: packages/app-desktop/gui/StatusScreen/StatusScreen.js:113 #: packages/app-desktop/gui/StatusScreen/StatusScreen.js:113
#, fuzzy #, fuzzy
msgid "Advanced tools" msgid "Advanced tools"
msgstr "Opzioni avanzate" msgstr "Strumenti avanzati"
#: packages/app-desktop/gui/StatusScreen/StatusScreen.js:115 #: packages/app-desktop/gui/StatusScreen/StatusScreen.js:115
#, fuzzy
msgid "Export debug report" msgid "Export debug report"
msgstr "Esporta il Report di Debug" msgstr "Esporta il report di debug"
#: packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js:183 #: packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js:183
msgid "strong text" msgid "strong text"
@@ -340,23 +342,23 @@ msgstr "Lista di caselle di spunta"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:17 #: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:17
msgid "Highlight" msgid "Highlight"
msgstr "" msgstr "Evidenzia"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:22 #: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:22
msgid "Strikethrough" msgid "Strikethrough"
msgstr "" msgstr "Barrato"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:27 #: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:27
msgid "Insert" msgid "Insert"
msgstr "" msgstr "Inserisci"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:33 #: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:33
msgid "Superscript" msgid "Superscript"
msgstr "" msgstr "Apice"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:39 #: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:39
msgid "Subscript" msgid "Subscript"
msgstr "" msgstr "Pedice"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js:544 #: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js:544
#: packages/app-mobile/components/screens/Note.js:1016 #: packages/app-mobile/components/screens/Note.js:1016
@@ -523,9 +525,8 @@ msgid "Delete line"
msgstr "Elimina riga" msgstr "Elimina riga"
#: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:92 #: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:92
#, fuzzy
msgid "Duplicate line" msgid "Duplicate line"
msgstr "Duplicare" msgstr "Duplica riga"
#: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:96 #: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:96
msgid "Undo" msgid "Undo"
@@ -719,10 +720,12 @@ msgid ""
"Safe mode is currently active. Note rendering and all plugins are " "Safe mode is currently active. Note rendering and all plugins are "
"temporarily disabled." "temporarily disabled."
msgstr "" msgstr ""
"La modalità sicura è attiva. La renderizzazione delle note e tutti i plugin "
"sono temporaneamente disattivati."
#: packages/app-desktop/gui/MainScreen/MainScreen.js:438 #: packages/app-desktop/gui/MainScreen/MainScreen.js:438
msgid "Disable safe mode and restart" msgid "Disable safe mode and restart"
msgstr "" msgstr "Disabilita modalità sicura e riavvia"
#: packages/app-desktop/gui/MainScreen/MainScreen.js:442 #: packages/app-desktop/gui/MainScreen/MainScreen.js:442
msgid "" msgid ""
@@ -767,15 +770,15 @@ msgstr "Maggiori informazioni"
#: packages/app-desktop/gui/MainScreen/MainScreen.js:468 #: packages/app-desktop/gui/MainScreen/MainScreen.js:468
#, javascript-format #, javascript-format
msgid "%s (%s) would like to share a notebook with you." msgid "%s (%s) would like to share a notebook with you."
msgstr "" msgstr "%s (%s) vuole condividere un taccuino con te."
#: packages/app-desktop/gui/MainScreen/MainScreen.js:470 #: packages/app-desktop/gui/MainScreen/MainScreen.js:470
msgid "Accept" msgid "Accept"
msgstr "" msgstr "Accetta"
#: packages/app-desktop/gui/MainScreen/MainScreen.js:472 #: packages/app-desktop/gui/MainScreen/MainScreen.js:472
msgid "Reject" msgid "Reject"
msgstr "" msgstr "Rifiuta"
#: packages/app-desktop/gui/MainScreen/MainScreen.js:476 #: packages/app-desktop/gui/MainScreen/MainScreen.js:476
msgid "Some items cannot be synchronised." msgid "Some items cannot be synchronised."
@@ -861,9 +864,8 @@ msgid "Toggle editors"
msgstr "Attiva / disattiva editor" msgstr "Attiva / disattiva editor"
#: packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js:16 #: packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js:16
#, fuzzy
msgid "Share notebook..." msgid "Share notebook..."
msgstr "Condividi note..." msgstr "Condividi taccuino..."
#: packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js:16 #: packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js:16
msgid "Change application layout" msgid "Change application layout"
@@ -943,7 +945,7 @@ msgstr "Il token è stato copiato negli appunti!"
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:44 #: packages/app-desktop/gui/ClipperConfigScreen.min.js:44
msgid "Are you sure you want to renew the authorisation token?" msgid "Are you sure you want to renew the authorisation token?"
msgstr "" msgstr "Sei sicuro di voler rinnovare il token di autorizzazione?"
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:84 #: packages/app-desktop/gui/ClipperConfigScreen.min.js:84
msgid "The web clipper service is enabled and set to auto-start." msgid "The web clipper service is enabled and set to auto-start."
@@ -1028,7 +1030,7 @@ msgstr ""
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:222 #: packages/app-desktop/gui/ClipperConfigScreen.min.js:222
msgid "Renew token" msgid "Renew token"
msgstr "" msgstr "Rinnova token"
#: packages/app-desktop/gui/MenuBar.js:167 #: packages/app-desktop/gui/MenuBar.js:167
#, javascript-format #, javascript-format
@@ -1516,13 +1518,12 @@ msgid "You do not have any installed plugin."
msgstr "Non hai installato nessun plugin." msgstr "Non hai installato nessun plugin."
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:232 #: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:232
#, fuzzy
msgid "Could not connect to plugin repository" msgid "Could not connect to plugin repository"
msgstr "Non è possibile installare il plugin: %s" msgstr "Non è possibile connettersi al catalogo dei plugin"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:234 #: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:234
msgid "Try again" msgid "Try again"
msgstr "" msgstr "Riprova"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:242 #: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:242
msgid "Plugin tools" msgid "Plugin tools"
@@ -1743,19 +1744,20 @@ msgstr[0] "Copia link condivisibile"
msgstr[1] "Copia link condivisibili" msgstr[1] "Copia link condivisibili"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:138 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:138
#, fuzzy
msgid "Unshare" msgid "Unshare"
msgstr "Condividi" msgstr "Disattiva condivisione"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:180 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:180
msgid "" msgid ""
"Delete this invitation? The recipient will no longer have access to this " "Delete this invitation? The recipient will no longer have access to this "
"shared notebook." "shared notebook."
msgstr "" msgstr ""
"Cancellare questo invito? Il destinatario non avrà più accesso a questo "
"taccuino condiviso."
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:194 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:194
msgid "Add recipient:" msgid "Add recipient:"
msgstr "" msgstr "Aggiungi destinatario:"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:197 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:197
#: packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js:28 #: packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js:28
@@ -1765,45 +1767,43 @@ msgstr "Condividi"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:206 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:206
msgid "Recipient has not yet accepted the invitation" msgid "Recipient has not yet accepted the invitation"
msgstr "" msgstr "Il destinatario non ha ancora accettato l'invito"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:207 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:207
msgid "Recipient has rejected the invitation" msgid "Recipient has rejected the invitation"
msgstr "" msgstr "Il destinatario ha rifiutato l'invito"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:208 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:208
msgid "Recipient has accepted the invitation" msgid "Recipient has accepted the invitation"
msgstr "" msgstr "Il destinatario ha accettato l'invito"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:218 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:218
msgid "Recipients:" msgid "Recipients:"
msgstr "" msgstr "Destinatari:"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:230 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:230
#, fuzzy
msgid "Synchronizing..." msgid "Synchronizing..."
msgstr "Sincronizzazione..." msgstr "Sincronizzazione..."
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:231 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:231
#, fuzzy
msgid "Sharing notebook..." msgid "Sharing notebook..."
msgstr "Condividi note..." msgstr "Condividendo taccuino..."
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:241 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:241
msgid "" msgid ""
"Unshare this notebook? The recipients will no longer have access to its " "Unshare this notebook? The recipients will no longer have access to its "
"content." "content."
msgstr "" msgstr ""
"Rimuovere la condivisione di questo taccuino? I destinatari non avranno più "
"accesso a questo contenuto."
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:251 #: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:251
#, fuzzy
msgid "Share Notebook" msgid "Share Notebook"
msgstr "Condividi note" msgstr "Condividi Taccuino"
#: packages/app-desktop/commands/toggleSafeMode.js:18 #: packages/app-desktop/commands/toggleSafeMode.js:18
#, fuzzy
msgid "Toggle safe mode" msgid "Toggle safe mode"
msgstr "Attiva / disattiva barra laterale" msgstr "Attiva / disattiva modalità sicura"
#: packages/app-desktop/commands/toggleExternalEditing.js:18 #: packages/app-desktop/commands/toggleExternalEditing.js:18
msgid "Toggle external editing" msgid "Toggle external editing"
@@ -1895,7 +1895,7 @@ msgstr "Configurazione"
#: packages/app-mobile/components/side-menu-content.js:351 #: packages/app-mobile/components/side-menu-content.js:351
msgid "Mobile data - auto-sync disabled" msgid "Mobile data - auto-sync disabled"
msgstr "" msgstr "Sincronizzazione automatica con dati mobili disattivata"
#: packages/app-mobile/components/note-list.js:97 #: packages/app-mobile/components/note-list.js:97
msgid "You currently have no notebooks." msgid "You currently have no notebooks."
@@ -2431,9 +2431,8 @@ msgid "Joplin Server URL"
msgstr "URL Joplin Server" msgstr "URL Joplin Server"
#: packages/lib/models/Setting.js:335 #: packages/lib/models/Setting.js:335
#, fuzzy
msgid "Joplin Server email" msgid "Joplin Server email"
msgstr "Joplin Server" msgstr "Email Server Joplin"
#: packages/lib/models/Setting.js:346 #: packages/lib/models/Setting.js:346
msgid "Joplin Server password" msgid "Joplin Server password"
@@ -2677,11 +2676,14 @@ msgid ""
"Used for most text in the markdown editor. If not found, a generic " "Used for most text in the markdown editor. If not found, a generic "
"proportional (variable width) font is used." "proportional (variable width) font is used."
msgstr "" msgstr ""
"Utilizzato per la maggior parte del testo degli editor di markdown. Se non "
"trovato, sarà utilizzato un carattere proporzionale (larghezza variabile) "
"generico."
#: packages/lib/models/Setting.js:726 #: packages/lib/models/Setting.js:726
#, fuzzy #, fuzzy
msgid "Editor monospace font family" msgid "Editor monospace font family"
msgstr "Editor famiglia caratteri" msgstr "Editor famiglia caratteri monospace (larghezza fissa)"
#: packages/lib/models/Setting.js:727 #: packages/lib/models/Setting.js:727
msgid "" msgid ""
@@ -2689,6 +2691,10 @@ msgid ""
"tables, checkboxes, code). If not found, a generic monospace (fixed width) " "tables, checkboxes, code). If not found, a generic monospace (fixed width) "
"font is used." "font is used."
msgstr "" msgstr ""
"Utilizzato dove un carattere a larghezza fissa è necessario per mostrare il "
"testo in maniera leggibile (ad es. tabelle, caselle di controllo, codice). "
"Se non trovato, sarà utilizzato un carattere monospace (larghezza fissa) "
"generico."
#: packages/lib/models/Setting.js:748 #: packages/lib/models/Setting.js:748
msgid "Custom stylesheet for rendered Markdown" msgid "Custom stylesheet for rendered Markdown"
@@ -2700,11 +2706,12 @@ msgstr "CSS per gli stili Joplin-wide app"
#: packages/lib/models/Setting.js:774 #: packages/lib/models/Setting.js:774
msgid "Re-upload local data to sync target" msgid "Re-upload local data to sync target"
msgstr "" msgstr "Ricarica i dati locali sulla destinazione di sincronizzazione"
#: packages/lib/models/Setting.js:784 #: packages/lib/models/Setting.js:784
msgid "Delete local data and re-download from sync target" msgid "Delete local data and re-download from sync target"
msgstr "" msgstr ""
"Elimina i dati locali e riscaricali dalla destinazione di sincronizzazione"
#: packages/lib/models/Setting.js:789 #: packages/lib/models/Setting.js:789
msgid "Automatically update the application" msgid "Automatically update the application"
@@ -2741,7 +2748,7 @@ msgstr "%d ore"
#: packages/lib/models/Setting.js:817 #: packages/lib/models/Setting.js:817
msgid "Synchronise only over WiFi connection" msgid "Synchronise only over WiFi connection"
msgstr "" msgstr "Sincronizza solo con la connessione WiFi"
#: packages/lib/models/Setting.js:824 #: packages/lib/models/Setting.js:824
msgid "Text editor command" msgid "Text editor command"
@@ -3347,9 +3354,9 @@ msgid "\"%s\" is missing the required \"%s\" property."
msgstr "A \"% s\" manca la proprietà \"% s\" richiesta." msgstr "A \"% s\" manca la proprietà \"% s\" richiesta."
# Non è chiaro cosa sia un accelerator senza contesto, non lo trovo nell'applicazione e non credo sia corretto tradurlo con acceleratore. # Non è chiaro cosa sia un accelerator senza contesto, non lo trovo nell'applicazione e non credo sia corretto tradurlo con acceleratore.
# Cercando online ho trovato che # Cercando online ho trovato che
# "accelerator" fa parte di opzioni # "accelerator" fa parte di opzioni
# relative ai comandi delle API dei # relative ai comandi delle API dei
# Plugins. # Plugins.
#: packages/lib/services/KeymapService.js:280 #: packages/lib/services/KeymapService.js:280
#: packages/lib/services/KeymapService.js:287 #: packages/lib/services/KeymapService.js:287
@@ -3872,7 +3879,7 @@ msgstr "Allega il seguente file alla nota."
msgid "" msgid ""
"Runs the commands contained in the text file. There should be one command " "Runs the commands contained in the text file. There should be one command "
"per line." "per line."
msgstr "" msgstr "Esegue comandi contenuti in un file di testo. Un comando per riga."
#: packages/app-cli/app/command-version.js:11 #: packages/app-cli/app/command-version.js:11
msgid "Displays version information" msgid "Displays version information"
@@ -4244,7 +4251,6 @@ msgstr "La nota non è un \"Cose-da-fare\": \"%s\""
#~ msgid "Notebook properties" #~ msgid "Notebook properties"
#~ msgstr "Proprietà del taccuino" #~ msgstr "Proprietà del taccuino"
#, javascript-format
#~ msgid "This file could not be opened: %s" #~ msgid "This file could not be opened: %s"
#~ msgstr "Questo file non può essere aperto: %s" #~ msgstr "Questo file non può essere aperto: %s"
@@ -4269,7 +4275,6 @@ msgstr "La nota non è un \"Cose-da-fare\": \"%s\""
#~ msgid "Move to notebook..." #~ msgid "Move to notebook..."
#~ msgstr "Sposta sul Taccuino..." #~ msgstr "Sposta sul Taccuino..."
#, javascript-format
#~ msgid "Move %d notes to notebook \"%s\"?" #~ msgid "Move %d notes to notebook \"%s\"?"
#~ msgstr "Spostare le note %d sul Taccuino \"%s\"?" #~ msgstr "Spostare le note %d sul Taccuino \"%s\"?"
@@ -4287,11 +4292,9 @@ msgstr "La nota non è un \"Cose-da-fare\": \"%s\""
#~ msgid "Errors only" #~ msgid "Errors only"
#~ msgstr "Solo gli errori" #~ msgstr "Solo gli errori"
#, javascript-format
#~ msgid "Database v%s" #~ msgid "Database v%s"
#~ msgstr "Database v%s" #~ msgstr "Database v%s"
#, javascript-format
#~ msgid "FTS enabled: %d" #~ msgid "FTS enabled: %d"
#~ msgstr "FTS attivato: %d" #~ msgstr "FTS attivato: %d"

View File

@@ -175,6 +175,7 @@ encryption_cipher_text | text |
encryption_applied | int | encryption_applied | int |
markup_language | int | markup_language | int |
is_shared | int | is_shared | int |
share_id | text |
body_html | text | Note body, in HTML format body_html | text | Note body, in HTML format
base_url | text | If `body_html` is provided and contains relative URLs, provide the `base_url` parameter too so that all the URLs can be converted to absolute ones. The base URL is basically where the HTML was fetched from, minus the query (everything after the '?'). For example if the original page was `https://stackoverflow.com/search?q=%5Bjava%5D+test`, the base URL is `https://stackoverflow.com/search`. base_url | text | If `body_html` is provided and contains relative URLs, provide the `base_url` parameter too so that all the URLs can be converted to absolute ones. The base URL is basically where the HTML was fetched from, minus the query (everything after the '?'). For example if the original page was `https://stackoverflow.com/search?q=%5Bjava%5D+test`, the base URL is `https://stackoverflow.com/search`.
image_data_url | text | An image to attach to the note, in [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. image_data_url | text | An image to attach to the note, in [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format.
@@ -248,6 +249,7 @@ encryption_cipher_text | text |
encryption_applied | int | encryption_applied | int |
parent_id | text | parent_id | text |
is_shared | int | is_shared | int |
share_id | text |
## GET /folders ## GET /folders
@@ -295,6 +297,7 @@ encryption_applied | int |
encryption_blob_encrypted | int | encryption_blob_encrypted | int |
size | int | size | int |
is_shared | int | is_shared | int |
share_id | text |
## GET /resources ## GET /resources

View File

@@ -1,5 +1,45 @@
# Joplin changelog # Joplin changelog
## [v2.0.2](https://github.com/laurent22/joplin/releases/tag/v2.0.2) (Pre-release) - 2021-05-21T18:07:48Z
- New: Add Share Notebook menu item (6f2f241)
- New: Add classnames to DOM elements for theming purposes ([#4933](https://github.com/laurent22/joplin/issues/4933) by [@ajilderda](https://github.com/ajilderda))
- Improved: Allow unsharing a note (f7d164b)
- Improved: Displays error info when Joplin Server fails (3f0586e)
- Improved: Handle too large items for Joplin Server (d29624c)
- Improved: Import SVG as images when importing ENEX files ([#4968](https://github.com/laurent22/joplin/issues/4968))
- Improved: Import linked local files when importing Markdown files ([#4966](https://github.com/laurent22/joplin/issues/4966)) ([#4433](https://github.com/laurent22/joplin/issues/4433) by [@JackGruber](https://github.com/JackGruber))
- Improved: Improved usability when plugin repository cannot be connected to ([#4462](https://github.com/laurent22/joplin/issues/4462))
- Improved: Made sync more reliable by making it skip items that time out, and improved sync status screen (15fe119)
- Improved: Pass custom CSS property to all export handlers and renderers (bd08041)
- Improved: Regression: It was no longer possible to add list items in an empty note (6577f4f)
- Improved: Regression: Pasting plain text in Rich Text editor was broken (9e9bf63)
- Fixed: Fixed issue with empty panels being created by plugins ([#4926](https://github.com/laurent22/joplin/issues/4926))
- Fixed: Fixed pasting HTML in Rich Text editor, and improved pasting plain text (2226b79)
- Fixed: Improved importing Evernote notes that contain codeblocks ([#4965](https://github.com/laurent22/joplin/issues/4965))
- Fixed: Prevent cursor from jumping to top of page when pasting image ([#4591](https://github.com/laurent22/joplin/issues/4591))
## [v2.0.1](https://github.com/laurent22/joplin/releases/tag/v2.0.1) (Pre-release) - 2021-05-15T13:22:58Z
- New: Add support for sharing notebooks with Joplin Server ([#4772](https://github.com/laurent22/joplin/issues/4772))
- New: Add new date format YYMMDD ([#4954](https://github.com/laurent22/joplin/issues/4954) by Helmut K. C. Tessarek)
- New: Added button to skip an application update (a31b402)
- Fixed: Display proper error message when JEX file is corrupted ([#4958](https://github.com/laurent22/joplin/issues/4958))
- Fixed: Show or hide completed todos in search results based on user settings ([#4951](https://github.com/laurent22/joplin/issues/4951)) ([#4581](https://github.com/laurent22/joplin/issues/4581) by [@JackGruber](https://github.com/JackGruber))
- Fixed: Solve "Resource Id not provided" error ([#4943](https://github.com/laurent22/joplin/issues/4943)) ([#4891](https://github.com/laurent22/joplin/issues/4891) by [@Subhra264](https://github.com/Subhra264))
## [v1.8.5](https://github.com/laurent22/joplin/releases/tag/v1.8.5) - 2021-05-10T11:58:14Z
- Fixed: Fixed pasting of text and images from Word on Windows ([#4916](https://github.com/laurent22/joplin/issues/4916))
- Security: Filter out NOSCRIPT tags that could be used to cause an XSS (found by [Jubair Rehman Yousafzai](https://twitter.com/jubairfolder)) (9c20d59)
## [v1.8.4](https://github.com/laurent22/joplin/releases/tag/v1.8.4) (Pre-release) - 2021-05-09T18:05:05Z
- Improved: Improve display of release notes for new versions (f76f99b)
- Fixed: Ensure that image paths that contain spaces are pasted correctly in the Rich Text editor ([#4916](https://github.com/laurent22/joplin/issues/4916))
- Fixed: Make sure sync startup operations are cleared after startup ([#4919](https://github.com/laurent22/joplin/issues/4919))
- Security: Apply npm audit security fixes (0b67446)
## [v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (Pre-release) - 2021-05-04T10:38:16Z ## [v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (Pre-release) - 2021-05-04T10:38:16Z
- New: Add "id" and "due" search filters ([#4898](https://github.com/laurent22/joplin/issues/4898) by [@JackGruber](https://github.com/JackGruber)) - New: Add "id" and "due" search filters ([#4898](https://github.com/laurent22/joplin/issues/4898) by [@JackGruber](https://github.com/JackGruber))

View File

@@ -0,0 +1 @@
# Joplin Android app changelog

View File

@@ -1,5 +1,29 @@
# Joplin Server Changelog # Joplin Server Changelog
## [server-v2.0.3](https://github.com/laurent22/joplin/releases/tag/server-v2.0.3) (Pre-release) - 2021-05-25T18:08:46Z
- Fixed: Fixed handling of request origin (12a6634)
## [server-v2.0.2](https://github.com/laurent22/joplin/releases/tag/server-v2.0.2) (Pre-release) - 2021-05-25T19:15:50Z
- New: Add mailer service (ed8ee67)
- New: Add support for item size limit (6afde54)
- New: Added API end points to manage users (77b284f)
- Improved: Allow enabling or disabling the sharing feature per user (daaaa13)
- Improved: Allow setting the path to the SQLite database using SQLITE_DATABASE env variable (68e79f1)
- Improved: Allow using a different domain for API, main website and user content (83cef7a)
- Improved: Generate only one share link per note (e156ee1)
- Improved: Go back to home page when there is an error and user is logged in (a24b009)
- Improved: Improved Items table and added item size to it (7f05420)
- Improved: Improved log table too and made it sortable (ec7f0f4)
- Improved: Make it more difficult to delete all data (b01aa7e)
- Improved: Redirect to correct page when trying to access the root (51051e0)
- Improved: Use external directory to store Postgres data in Docker-compose config (71a7fc0)
- Fixed: Fixed /items page when using Postgres (2d0580f)
- Fixed: Fixed bug when unsharing a notebook that has no recipients (6ddb69e)
- Fixed: Fixed deleting a note that has been shared (489995d)
- Fixed: Make sure temp files are deleted after upload is done (#4540)
## [server-v2.0.1](https://github.com/laurent22/joplin/releases/tag/server-v2.0.1) (Pre-release) - 2021-05-14T13:55:45Z ## [server-v2.0.1](https://github.com/laurent22/joplin/releases/tag/server-v2.0.1) (Pre-release) - 2021-05-14T13:55:45Z
- New: Add support for sharing notes via a link (ccbc329) - New: Add support for sharing notes via a link (ccbc329)

View File

@@ -2,9 +2,9 @@
Name | Value Name | Value
--- | --- --- | ---
Total Windows downloads | 1,393,455 Total Windows downloads | 1,425,567
Total macOs downloads | 542,237 Total macOs downloads | 554,909
Total Linux downloads | 447,181 Total Linux downloads | 463,554
Windows % | 58% Windows % | 58%
macOS % | 23% macOS % | 23%
Linux % | 19% Linux % | 19%
@@ -17,131 +17,135 @@ Linux % | 19%
Version | Date | Windows | macOS | Linux | Total Version | Date | Windows | macOS | Linux | Total
--- | --- | --- | --- | --- | --- --- | --- | --- | --- | --- | ---
[v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (p) | 2021-05-04T10:38:16Z | 94 | 21 | 25 | 140 [v2.0.2](https://github.com/laurent22/joplin/releases/tag/v2.0.2) (p) | 2021-05-21T18:07:48Z | 594 | 179 | 448 | 1,221
[v1.8.2](https://github.com/laurent22/joplin/releases/tag/v1.8.2) (p) | 2021-04-25T10:50:51Z | 1,381 | 417 | 1,216 | 3,014 [v2.0.1](https://github.com/laurent22/joplin/releases/tag/v2.0.1) (p) | 2021-05-15T13:22:58Z | 770 | 243 | 984 | 1,997
[v1.8.1](https://github.com/laurent22/joplin/releases/tag/v1.8.1) (p) | 2021-03-29T10:46:41Z | 2,941 | 798 | 2,408 | 6,147 [v1.8.5](https://github.com/laurent22/joplin/releases/tag/v1.8.5) | 2021-05-10T11:58:14Z | 11,870 | 6,670 | 5,753 | 24,293
[v1.7.11](https://github.com/laurent22/joplin/releases/tag/v1.7.11) | 2021-02-03T12:50:01Z | 97,784 | 37,416 | 56,290 | 191,490 [v1.8.4](https://github.com/laurent22/joplin/releases/tag/v1.8.4) (p) | 2021-05-09T18:05:05Z | 623 | 120 | 433 | 1,176
[v1.7.10](https://github.com/laurent22/joplin/releases/tag/v1.7.10) | 2021-01-30T13:25:29Z | 13,806 | 4,828 | 4,422 | 23,056 [v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (p) | 2021-05-04T10:38:16Z | 1,049 | 290 | 912 | 2,251
[v1.7.9](https://github.com/laurent22/joplin/releases/tag/v1.7.9) (p) | 2021-01-28T09:50:21Z | 479 | 122 | 483 | 1,084 [v1.8.2](https://github.com/laurent22/joplin/releases/tag/v1.8.2) (p) | 2021-04-25T10:50:51Z | 1,445 | 421 | 1,261 | 3,127
[v1.7.6](https://github.com/laurent22/joplin/releases/tag/v1.7.6) (p) | 2021-01-27T10:36:05Z | 279 | 82 | 276 | 637 [v1.8.1](https://github.com/laurent22/joplin/releases/tag/v1.8.1) (p) | 2021-03-29T10:46:41Z | 3,003 | 805 | 2,418 | 6,226
[v1.7.5](https://github.com/laurent22/joplin/releases/tag/v1.7.5) (p) | 2021-01-26T09:53:05Z | 361 | 195 | 444 | 1,000 [v1.7.11](https://github.com/laurent22/joplin/releases/tag/v1.7.11) | 2021-02-03T12:50:01Z | 113,794 | 42,526 | 64,040 | 220,360
[v1.7.4](https://github.com/laurent22/joplin/releases/tag/v1.7.4) (p) | 2021-01-22T17:58:38Z | 669 | 195 | 613 | 1,477 [v1.7.10](https://github.com/laurent22/joplin/releases/tag/v1.7.10) | 2021-01-30T13:25:29Z | 13,825 | 4,831 | 4,425 | 23,081
[v1.6.8](https://github.com/laurent22/joplin/releases/tag/v1.6.8) | 2021-01-20T18:11:34Z | 17,988 | 7,657 | 7,575 | 33,220 [v1.7.9](https://github.com/laurent22/joplin/releases/tag/v1.7.9) (p) | 2021-01-28T09:50:21Z | 480 | 123 | 483 | 1,086
[v1.7.3](https://github.com/laurent22/joplin/releases/tag/v1.7.3) (p) | 2021-01-20T11:23:50Z | 331 | 68 | 435 | 834 [v1.7.6](https://github.com/laurent22/joplin/releases/tag/v1.7.6) (p) | 2021-01-27T10:36:05Z | 283 | 82 | 277 | 642
[v1.6.7](https://github.com/laurent22/joplin/releases/tag/v1.6.7) | 2021-01-11T23:20:33Z | 10,319 | 4,616 | 4,530 | 19,465 [v1.7.5](https://github.com/laurent22/joplin/releases/tag/v1.7.5) (p) | 2021-01-26T09:53:05Z | 364 | 195 | 444 | 1,003
[v1.6.6](https://github.com/laurent22/joplin/releases/tag/v1.6.6) | 2021-01-09T16:15:31Z | 12,351 | 3,405 | 4,775 | 20,531 [v1.7.4](https://github.com/laurent22/joplin/releases/tag/v1.7.4) (p) | 2021-01-22T17:58:38Z | 673 | 195 | 613 | 1,481
[v1.6.5](https://github.com/laurent22/joplin/releases/tag/v1.6.5) (p) | 2021-01-09T01:24:32Z | 517 | 57 | 300 | 874 [v1.6.8](https://github.com/laurent22/joplin/releases/tag/v1.6.8) | 2021-01-20T18:11:34Z | 18,064 | 7,662 | 7,578 | 33,304
[v1.6.4](https://github.com/laurent22/joplin/releases/tag/v1.6.4) (p) | 2021-01-07T19:11:32Z | 377 | 72 | 197 | 646 [v1.7.3](https://github.com/laurent22/joplin/releases/tag/v1.7.3) (p) | 2021-01-20T11:23:50Z | 334 | 68 | 436 | 838
[v1.6.2](https://github.com/laurent22/joplin/releases/tag/v1.6.2) (p) | 2021-01-04T22:34:55Z | 661 | 221 | 577 | 1,459 [v1.6.7](https://github.com/laurent22/joplin/releases/tag/v1.6.7) | 2021-01-11T23:20:33Z | 10,375 | 4,617 | 4,531 | 19,523
[v1.5.14](https://github.com/laurent22/joplin/releases/tag/v1.5.14) | 2020-12-30T01:48:46Z | 10,793 | 5,191 | 5,512 | 21,496 [v1.6.6](https://github.com/laurent22/joplin/releases/tag/v1.6.6) | 2021-01-09T16:15:31Z | 12,359 | 3,405 | 4,776 | 20,540
[v1.6.5](https://github.com/laurent22/joplin/releases/tag/v1.6.5) (p) | 2021-01-09T01:24:32Z | 553 | 57 | 300 | 910
[v1.6.4](https://github.com/laurent22/joplin/releases/tag/v1.6.4) (p) | 2021-01-07T19:11:32Z | 381 | 72 | 197 | 650
[v1.6.2](https://github.com/laurent22/joplin/releases/tag/v1.6.2) (p) | 2021-01-04T22:34:55Z | 664 | 221 | 577 | 1,462
[v1.5.14](https://github.com/laurent22/joplin/releases/tag/v1.5.14) | 2020-12-30T01:48:46Z | 10,847 | 5,191 | 5,512 | 21,550
[v1.6.1](https://github.com/laurent22/joplin/releases/tag/v1.6.1) (p) | 2020-12-29T19:37:45Z | 162 | 32 | 156 | 350 [v1.6.1](https://github.com/laurent22/joplin/releases/tag/v1.6.1) (p) | 2020-12-29T19:37:45Z | 162 | 32 | 156 | 350
[v1.5.13](https://github.com/laurent22/joplin/releases/tag/v1.5.13) | 2020-12-29T18:29:15Z | 608 | 212 | 190 | 1,010 [v1.5.13](https://github.com/laurent22/joplin/releases/tag/v1.5.13) | 2020-12-29T18:29:15Z | 609 | 212 | 190 | 1,011
[v1.5.12](https://github.com/laurent22/joplin/releases/tag/v1.5.12) | 2020-12-28T15:14:08Z | 2,366 | 1,760 | 911 | 5,037 [v1.5.12](https://github.com/laurent22/joplin/releases/tag/v1.5.12) | 2020-12-28T15:14:08Z | 2,374 | 1,762 | 911 | 5,047
[v1.5.11](https://github.com/laurent22/joplin/releases/tag/v1.5.11) | 2020-12-27T19:54:07Z | 13,970 | 4,605 | 4,251 | 22,826 [v1.5.11](https://github.com/laurent22/joplin/releases/tag/v1.5.11) | 2020-12-27T19:54:07Z | 13,999 | 4,605 | 4,253 | 22,857
[v1.5.10](https://github.com/laurent22/joplin/releases/tag/v1.5.10) (p) | 2020-12-26T12:35:36Z | 288 | 102 | 254 | 644 [v1.5.10](https://github.com/laurent22/joplin/releases/tag/v1.5.10) (p) | 2020-12-26T12:35:36Z | 288 | 102 | 255 | 645
[v1.5.9](https://github.com/laurent22/joplin/releases/tag/v1.5.9) (p) | 2020-12-23T18:01:08Z | 321 | 367 | 399 | 1,087 [v1.5.9](https://github.com/laurent22/joplin/releases/tag/v1.5.9) (p) | 2020-12-23T18:01:08Z | 321 | 367 | 399 | 1,087
[v1.5.8](https://github.com/laurent22/joplin/releases/tag/v1.5.8) (p) | 2020-12-20T09:45:19Z | 556 | 158 | 631 | 1,345 [v1.5.8](https://github.com/laurent22/joplin/releases/tag/v1.5.8) (p) | 2020-12-20T09:45:19Z | 559 | 158 | 631 | 1,348
[v1.5.7](https://github.com/laurent22/joplin/releases/tag/v1.5.7) (p) | 2020-12-10T12:58:33Z | 876 | 248 | 982 | 2,106 [v1.5.7](https://github.com/laurent22/joplin/releases/tag/v1.5.7) (p) | 2020-12-10T12:58:33Z | 880 | 248 | 982 | 2,110
[v1.5.4](https://github.com/laurent22/joplin/releases/tag/v1.5.4) (p) | 2020-12-05T12:07:49Z | 681 | 161 | 623 | 1,465 [v1.5.4](https://github.com/laurent22/joplin/releases/tag/v1.5.4) (p) | 2020-12-05T12:07:49Z | 686 | 161 | 623 | 1,470
[v1.4.19](https://github.com/laurent22/joplin/releases/tag/v1.4.19) | 2020-12-01T11:11:16Z | 25,445 | 13,330 | 11,606 | 50,381 [v1.4.19](https://github.com/laurent22/joplin/releases/tag/v1.4.19) | 2020-12-01T11:11:16Z | 25,492 | 13,350 | 11,610 | 50,452
[v1.4.18](https://github.com/laurent22/joplin/releases/tag/v1.4.18) | 2020-11-28T12:21:41Z | 11,077 | 3,870 | 3,069 | 18,016 [v1.4.18](https://github.com/laurent22/joplin/releases/tag/v1.4.18) | 2020-11-28T12:21:41Z | 11,082 | 3,870 | 3,076 | 18,028
[v1.4.16](https://github.com/laurent22/joplin/releases/tag/v1.4.16) | 2020-11-27T19:40:16Z | 1,446 | 822 | 584 | 2,852 [v1.4.16](https://github.com/laurent22/joplin/releases/tag/v1.4.16) | 2020-11-27T19:40:16Z | 1,452 | 822 | 584 | 2,858
[v1.4.15](https://github.com/laurent22/joplin/releases/tag/v1.4.15) | 2020-11-27T13:25:43Z | 878 | 482 | 262 | 1,622 [v1.4.15](https://github.com/laurent22/joplin/releases/tag/v1.4.15) | 2020-11-27T13:25:43Z | 878 | 482 | 262 | 1,622
[v1.4.12](https://github.com/laurent22/joplin/releases/tag/v1.4.12) | 2020-11-23T18:58:07Z | 2,965 | 1,316 | 1,287 | 5,568 [v1.4.12](https://github.com/laurent22/joplin/releases/tag/v1.4.12) | 2020-11-23T18:58:07Z | 2,983 | 1,316 | 1,287 | 5,586
[v1.4.11](https://github.com/laurent22/joplin/releases/tag/v1.4.11) (p) | 2020-11-19T23:06:51Z | 899 | 147 | 574 | 1,620 [v1.4.11](https://github.com/laurent22/joplin/releases/tag/v1.4.11) (p) | 2020-11-19T23:06:51Z | 946 | 147 | 574 | 1,667
[v1.4.10](https://github.com/laurent22/joplin/releases/tag/v1.4.10) (p) | 2020-11-14T09:53:15Z | 608 | 186 | 676 | 1,470 [v1.4.10](https://github.com/laurent22/joplin/releases/tag/v1.4.10) (p) | 2020-11-14T09:53:15Z | 614 | 186 | 676 | 1,476
[v1.4.9](https://github.com/laurent22/joplin/releases/tag/v1.4.9) (p) | 2020-11-11T14:23:17Z | 487 | 133 | 393 | 1,013 [v1.4.9](https://github.com/laurent22/joplin/releases/tag/v1.4.9) (p) | 2020-11-11T14:23:17Z | 497 | 133 | 393 | 1,023
[v1.4.7](https://github.com/laurent22/joplin/releases/tag/v1.4.7) (p) | 2020-11-07T18:23:29Z | 507 | 166 | 506 | 1,179 [v1.4.7](https://github.com/laurent22/joplin/releases/tag/v1.4.7) (p) | 2020-11-07T18:23:29Z | 511 | 166 | 506 | 1,183
[v1.3.18](https://github.com/laurent22/joplin/releases/tag/v1.3.18) | 2020-11-06T12:07:02Z | 30,538 | 11,315 | 10,494 | 52,347 [v1.3.18](https://github.com/laurent22/joplin/releases/tag/v1.3.18) | 2020-11-06T12:07:02Z | 30,609 | 11,316 | 10,495 | 52,420
[v1.3.17](https://github.com/laurent22/joplin/releases/tag/v1.3.17) (p) | 2020-11-06T11:35:15Z | 44 | 16 | 15 | 75 [v1.3.17](https://github.com/laurent22/joplin/releases/tag/v1.3.17) (p) | 2020-11-06T11:35:15Z | 44 | 16 | 15 | 75
[v1.4.6](https://github.com/laurent22/joplin/releases/tag/v1.4.6) (p) | 2020-11-05T22:44:12Z | 331 | 86 | 45 | 462 [v1.4.6](https://github.com/laurent22/joplin/releases/tag/v1.4.6) (p) | 2020-11-05T22:44:12Z | 339 | 86 | 45 | 470
[v1.3.15](https://github.com/laurent22/joplin/releases/tag/v1.3.15) | 2020-11-04T12:22:50Z | 2,214 | 1,290 | 836 | 4,340 [v1.3.15](https://github.com/laurent22/joplin/releases/tag/v1.3.15) | 2020-11-04T12:22:50Z | 2,221 | 1,290 | 836 | 4,347
[v1.3.11](https://github.com/laurent22/joplin/releases/tag/v1.3.11) (p) | 2020-10-31T13:22:20Z | 693 | 177 | 471 | 1,341 [v1.3.11](https://github.com/laurent22/joplin/releases/tag/v1.3.11) (p) | 2020-10-31T13:22:20Z | 693 | 177 | 471 | 1,341
[v1.3.10](https://github.com/laurent22/joplin/releases/tag/v1.3.10) (p) | 2020-10-29T13:27:14Z | 361 | 107 | 307 | 775 [v1.3.10](https://github.com/laurent22/joplin/releases/tag/v1.3.10) (p) | 2020-10-29T13:27:14Z | 368 | 107 | 307 | 782
[v1.3.9](https://github.com/laurent22/joplin/releases/tag/v1.3.9) (p) | 2020-10-23T16:04:26Z | 827 | 233 | 624 | 1,684 [v1.3.9](https://github.com/laurent22/joplin/releases/tag/v1.3.9) (p) | 2020-10-23T16:04:26Z | 830 | 233 | 624 | 1,687
[v1.3.8](https://github.com/laurent22/joplin/releases/tag/v1.3.8) (p) | 2020-10-21T18:46:29Z | 505 | 104 | 321 | 930 [v1.3.8](https://github.com/laurent22/joplin/releases/tag/v1.3.8) (p) | 2020-10-21T18:46:29Z | 510 | 104 | 321 | 935
[v1.3.7](https://github.com/laurent22/joplin/releases/tag/v1.3.7) (p) | 2020-10-20T11:35:55Z | 285 | 76 | 334 | 695 [v1.3.7](https://github.com/laurent22/joplin/releases/tag/v1.3.7) (p) | 2020-10-20T11:35:55Z | 291 | 76 | 334 | 701
[v1.3.5](https://github.com/laurent22/joplin/releases/tag/v1.3.5) (p) | 2020-10-17T14:26:35Z | 461 | 126 | 397 | 984 [v1.3.5](https://github.com/laurent22/joplin/releases/tag/v1.3.5) (p) | 2020-10-17T14:26:35Z | 464 | 126 | 397 | 987
[v1.3.3](https://github.com/laurent22/joplin/releases/tag/v1.3.3) (p) | 2020-10-17T10:56:57Z | 113 | 36 | 25 | 174 [v1.3.3](https://github.com/laurent22/joplin/releases/tag/v1.3.3) (p) | 2020-10-17T10:56:57Z | 113 | 36 | 25 | 174
[v1.3.2](https://github.com/laurent22/joplin/releases/tag/v1.3.2) (p) | 2020-10-11T20:39:49Z | 659 | 173 | 556 | 1,388 [v1.3.2](https://github.com/laurent22/joplin/releases/tag/v1.3.2) (p) | 2020-10-11T20:39:49Z | 659 | 173 | 556 | 1,388
[v1.3.1](https://github.com/laurent22/joplin/releases/tag/v1.3.1) (p) | 2020-10-11T15:10:18Z | 77 | 45 | 35 | 157 [v1.3.1](https://github.com/laurent22/joplin/releases/tag/v1.3.1) (p) | 2020-10-11T15:10:18Z | 77 | 45 | 35 | 157
[v1.2.6](https://github.com/laurent22/joplin/releases/tag/v1.2.6) | 2020-10-09T13:56:59Z | 44,102 | 17,713 | 14,023 | 75,838 [v1.2.6](https://github.com/laurent22/joplin/releases/tag/v1.2.6) | 2020-10-09T13:56:59Z | 44,164 | 17,713 | 14,024 | 75,901
[v1.2.4](https://github.com/laurent22/joplin/releases/tag/v1.2.4) (p) | 2020-09-30T07:34:29Z | 804 | 240 | 791 | 1,835 [v1.2.4](https://github.com/laurent22/joplin/releases/tag/v1.2.4) (p) | 2020-09-30T07:34:29Z | 808 | 240 | 791 | 1,839
[v1.2.3](https://github.com/laurent22/joplin/releases/tag/v1.2.3) (p) | 2020-09-29T15:13:02Z | 207 | 61 | 72 | 340 [v1.2.3](https://github.com/laurent22/joplin/releases/tag/v1.2.3) (p) | 2020-09-29T15:13:02Z | 212 | 61 | 72 | 345
[v1.2.2](https://github.com/laurent22/joplin/releases/tag/v1.2.2) (p) | 2020-09-22T20:31:55Z | 768 | 199 | 631 | 1,598 [v1.2.2](https://github.com/laurent22/joplin/releases/tag/v1.2.2) (p) | 2020-09-22T20:31:55Z | 777 | 199 | 631 | 1,607
[v1.1.4](https://github.com/laurent22/joplin/releases/tag/v1.1.4) | 2020-09-21T11:20:09Z | 27,547 | 13,489 | 7,740 | 48,776 [v1.1.4](https://github.com/laurent22/joplin/releases/tag/v1.1.4) | 2020-09-21T11:20:09Z | 27,572 | 13,489 | 7,740 | 48,801
[v1.1.3](https://github.com/laurent22/joplin/releases/tag/v1.1.3) (p) | 2020-09-17T10:30:37Z | 557 | 147 | 457 | 1,161 [v1.1.3](https://github.com/laurent22/joplin/releases/tag/v1.1.3) (p) | 2020-09-17T10:30:37Z | 557 | 147 | 457 | 1,161
[v1.1.2](https://github.com/laurent22/joplin/releases/tag/v1.1.2) (p) | 2020-09-15T12:58:38Z | 368 | 112 | 244 | 724 [v1.1.2](https://github.com/laurent22/joplin/releases/tag/v1.1.2) (p) | 2020-09-15T12:58:38Z | 372 | 112 | 244 | 728
[v1.1.1](https://github.com/laurent22/joplin/releases/tag/v1.1.1) (p) | 2020-09-11T23:32:47Z | 514 | 195 | 342 | 1,051 [v1.1.1](https://github.com/laurent22/joplin/releases/tag/v1.1.1) (p) | 2020-09-11T23:32:47Z | 519 | 195 | 342 | 1,056
[v1.0.245](https://github.com/laurent22/joplin/releases/tag/v1.0.245) | 2020-09-09T12:56:10Z | 21,111 | 9,999 | 5,634 | 36,744 [v1.0.245](https://github.com/laurent22/joplin/releases/tag/v1.0.245) | 2020-09-09T12:56:10Z | 21,148 | 9,999 | 5,634 | 36,781
[v1.0.242](https://github.com/laurent22/joplin/releases/tag/v1.0.242) | 2020-09-04T22:00:34Z | 12,425 | 6,418 | 3,015 | 21,858 [v1.0.242](https://github.com/laurent22/joplin/releases/tag/v1.0.242) | 2020-09-04T22:00:34Z | 12,439 | 6,418 | 3,015 | 21,872
[v1.0.241](https://github.com/laurent22/joplin/releases/tag/v1.0.241) | 2020-09-04T18:06:00Z | 23,575 | 5,742 | 4,991 | 34,308 [v1.0.241](https://github.com/laurent22/joplin/releases/tag/v1.0.241) | 2020-09-04T18:06:00Z | 23,628 | 5,748 | 4,994 | 34,370
[v1.0.239](https://github.com/laurent22/joplin/releases/tag/v1.0.239) (p) | 2020-09-01T21:56:36Z | 592 | 226 | 400 | 1,218 [v1.0.239](https://github.com/laurent22/joplin/releases/tag/v1.0.239) (p) | 2020-09-01T21:56:36Z | 599 | 226 | 400 | 1,225
[v1.0.237](https://github.com/laurent22/joplin/releases/tag/v1.0.237) (p) | 2020-08-29T15:38:04Z | 588 | 923 | 338 | 1,849 [v1.0.237](https://github.com/laurent22/joplin/releases/tag/v1.0.237) (p) | 2020-08-29T15:38:04Z | 588 | 923 | 338 | 1,849
[v1.0.236](https://github.com/laurent22/joplin/releases/tag/v1.0.236) (p) | 2020-08-28T09:16:54Z | 315 | 110 | 104 | 529 [v1.0.236](https://github.com/laurent22/joplin/releases/tag/v1.0.236) (p) | 2020-08-28T09:16:54Z | 315 | 110 | 104 | 529
[v1.0.235](https://github.com/laurent22/joplin/releases/tag/v1.0.235) (p) | 2020-08-18T22:08:01Z | 1,661 | 489 | 920 | 3,070 [v1.0.235](https://github.com/laurent22/joplin/releases/tag/v1.0.235) (p) | 2020-08-18T22:08:01Z | 1,671 | 489 | 920 | 3,080
[v1.0.234](https://github.com/laurent22/joplin/releases/tag/v1.0.234) (p) | 2020-08-17T23:13:02Z | 536 | 125 | 100 | 761 [v1.0.234](https://github.com/laurent22/joplin/releases/tag/v1.0.234) (p) | 2020-08-17T23:13:02Z | 536 | 125 | 100 | 761
[v1.0.233](https://github.com/laurent22/joplin/releases/tag/v1.0.233) | 2020-08-01T14:51:15Z | 43,038 | 18,188 | 12,358 | 73,584 [v1.0.233](https://github.com/laurent22/joplin/releases/tag/v1.0.233) | 2020-08-01T14:51:15Z | 43,098 | 18,188 | 12,358 | 73,644
[v1.0.232](https://github.com/laurent22/joplin/releases/tag/v1.0.232) (p) | 2020-07-28T22:34:40Z | 652 | 222 | 178 | 1,052 [v1.0.232](https://github.com/laurent22/joplin/releases/tag/v1.0.232) (p) | 2020-07-28T22:34:40Z | 652 | 222 | 178 | 1,052
[v1.0.227](https://github.com/laurent22/joplin/releases/tag/v1.0.227) | 2020-07-07T20:44:54Z | 40,350 | 15,273 | 9,627 | 65,250 [v1.0.227](https://github.com/laurent22/joplin/releases/tag/v1.0.227) | 2020-07-07T20:44:54Z | 40,384 | 15,273 | 9,627 | 65,284
[v1.0.226](https://github.com/laurent22/joplin/releases/tag/v1.0.226) (p) | 2020-07-04T10:21:26Z | 4,905 | 2,252 | 688 | 7,845 [v1.0.226](https://github.com/laurent22/joplin/releases/tag/v1.0.226) (p) | 2020-07-04T10:21:26Z | 4,905 | 2,252 | 688 | 7,845
[v1.0.224](https://github.com/laurent22/joplin/releases/tag/v1.0.224) | 2020-06-20T22:26:08Z | 24,767 | 11,005 | 6,006 | 41,778 [v1.0.224](https://github.com/laurent22/joplin/releases/tag/v1.0.224) | 2020-06-20T22:26:08Z | 24,774 | 11,005 | 6,006 | 41,785
[v1.0.223](https://github.com/laurent22/joplin/releases/tag/v1.0.223) (p) | 2020-06-20T11:51:27Z | 186 | 112 | 78 | 376 [v1.0.223](https://github.com/laurent22/joplin/releases/tag/v1.0.223) (p) | 2020-06-20T11:51:27Z | 186 | 112 | 78 | 376
[v1.0.221](https://github.com/laurent22/joplin/releases/tag/v1.0.221) (p) | 2020-06-20T01:44:20Z | 856 | 205 | 210 | 1,271 [v1.0.221](https://github.com/laurent22/joplin/releases/tag/v1.0.221) (p) | 2020-06-20T01:44:20Z | 856 | 205 | 210 | 1,271
[v1.0.220](https://github.com/laurent22/joplin/releases/tag/v1.0.220) | 2020-06-13T18:26:22Z | 31,677 | 9,915 | 6,410 | 48,002 [v1.0.220](https://github.com/laurent22/joplin/releases/tag/v1.0.220) | 2020-06-13T18:26:22Z | 31,712 | 9,916 | 6,411 | 48,039
[v1.0.218](https://github.com/laurent22/joplin/releases/tag/v1.0.218) | 2020-06-07T10:43:34Z | 14,535 | 6,968 | 2,954 | 24,457 [v1.0.218](https://github.com/laurent22/joplin/releases/tag/v1.0.218) | 2020-06-07T10:43:34Z | 14,535 | 6,968 | 2,954 | 24,457
[v1.0.217](https://github.com/laurent22/joplin/releases/tag/v1.0.217) (p) | 2020-06-06T15:17:27Z | 226 | 93 | 54 | 373 [v1.0.217](https://github.com/laurent22/joplin/releases/tag/v1.0.217) (p) | 2020-06-06T15:17:27Z | 226 | 93 | 54 | 373
[v1.0.216](https://github.com/laurent22/joplin/releases/tag/v1.0.216) | 2020-05-24T14:21:01Z | 37,228 | 14,267 | 10,177 | 61,672 [v1.0.216](https://github.com/laurent22/joplin/releases/tag/v1.0.216) | 2020-05-24T14:21:01Z | 37,277 | 14,268 | 10,177 | 61,722
[v1.0.214](https://github.com/laurent22/joplin/releases/tag/v1.0.214) (p) | 2020-05-21T17:15:15Z | 6,515 | 3,466 | 760 | 10,741 [v1.0.214](https://github.com/laurent22/joplin/releases/tag/v1.0.214) (p) | 2020-05-21T17:15:15Z | 6,529 | 3,466 | 760 | 10,755
[v1.0.212](https://github.com/laurent22/joplin/releases/tag/v1.0.212) (p) | 2020-05-21T07:48:39Z | 210 | 66 | 46 | 322 [v1.0.212](https://github.com/laurent22/joplin/releases/tag/v1.0.212) (p) | 2020-05-21T07:48:39Z | 210 | 66 | 46 | 322
[v1.0.211](https://github.com/laurent22/joplin/releases/tag/v1.0.211) (p) | 2020-05-20T08:59:16Z | 300 | 131 | 86 | 517 [v1.0.211](https://github.com/laurent22/joplin/releases/tag/v1.0.211) (p) | 2020-05-20T08:59:16Z | 300 | 131 | 86 | 517
[v1.0.209](https://github.com/laurent22/joplin/releases/tag/v1.0.209) (p) | 2020-05-17T18:32:51Z | 1,393 | 851 | 147 | 2,391 [v1.0.209](https://github.com/laurent22/joplin/releases/tag/v1.0.209) (p) | 2020-05-17T18:32:51Z | 1,393 | 851 | 147 | 2,391
[v1.0.207](https://github.com/laurent22/joplin/releases/tag/v1.0.207) (p) | 2020-05-10T16:37:35Z | 1,187 | 263 | 1,016 | 2,466 [v1.0.207](https://github.com/laurent22/joplin/releases/tag/v1.0.207) (p) | 2020-05-10T16:37:35Z | 1,187 | 263 | 1,016 | 2,466
[v1.0.201](https://github.com/laurent22/joplin/releases/tag/v1.0.201) | 2020-04-15T22:55:13Z | 53,279 | 20,043 | 18,180 | 91,502 [v1.0.201](https://github.com/laurent22/joplin/releases/tag/v1.0.201) | 2020-04-15T22:55:13Z | 53,311 | 20,043 | 18,180 | 91,534
[v1.0.200](https://github.com/laurent22/joplin/releases/tag/v1.0.200) | 2020-04-12T12:17:46Z | 9,552 | 4,892 | 1,903 | 16,347 [v1.0.200](https://github.com/laurent22/joplin/releases/tag/v1.0.200) | 2020-04-12T12:17:46Z | 9,552 | 4,892 | 1,903 | 16,347
[v1.0.199](https://github.com/laurent22/joplin/releases/tag/v1.0.199) | 2020-04-10T18:41:58Z | 19,332 | 5,884 | 3,788 | 29,004 [v1.0.199](https://github.com/laurent22/joplin/releases/tag/v1.0.199) | 2020-04-10T18:41:58Z | 19,339 | 5,884 | 3,788 | 29,011
[v1.0.197](https://github.com/laurent22/joplin/releases/tag/v1.0.197) | 2020-03-30T17:21:22Z | 22,269 | 9,538 | 5,719 | 37,526 [v1.0.197](https://github.com/laurent22/joplin/releases/tag/v1.0.197) | 2020-03-30T17:21:22Z | 22,280 | 9,540 | 5,726 | 37,546
[v1.0.195](https://github.com/laurent22/joplin/releases/tag/v1.0.195) | 2020-03-22T19:56:12Z | 18,890 | 7,948 | 4,506 | 31,344 [v1.0.195](https://github.com/laurent22/joplin/releases/tag/v1.0.195) | 2020-03-22T19:56:12Z | 18,890 | 7,948 | 4,506 | 31,344
[v1.0.194](https://github.com/laurent22/joplin/releases/tag/v1.0.194) (p) | 2020-03-14T00:00:32Z | 1,284 | 1,375 | 508 | 3,167 [v1.0.194](https://github.com/laurent22/joplin/releases/tag/v1.0.194) (p) | 2020-03-14T00:00:32Z | 1,285 | 1,375 | 511 | 3,171
[v1.0.193](https://github.com/laurent22/joplin/releases/tag/v1.0.193) | 2020-03-08T08:58:53Z | 28,641 | 10,906 | 7,390 | 46,937 [v1.0.193](https://github.com/laurent22/joplin/releases/tag/v1.0.193) | 2020-03-08T08:58:53Z | 28,641 | 10,907 | 7,392 | 46,940
[v1.0.192](https://github.com/laurent22/joplin/releases/tag/v1.0.192) (p) | 2020-03-06T23:27:52Z | 472 | 122 | 89 | 683 [v1.0.192](https://github.com/laurent22/joplin/releases/tag/v1.0.192) (p) | 2020-03-06T23:27:52Z | 472 | 122 | 89 | 683
[v1.0.190](https://github.com/laurent22/joplin/releases/tag/v1.0.190) (p) | 2020-03-06T01:22:22Z | 373 | 90 | 85 | 548 [v1.0.190](https://github.com/laurent22/joplin/releases/tag/v1.0.190) (p) | 2020-03-06T01:22:22Z | 373 | 90 | 85 | 548
[v1.0.189](https://github.com/laurent22/joplin/releases/tag/v1.0.189) (p) | 2020-03-04T17:27:15Z | 342 | 96 | 90 | 528 [v1.0.189](https://github.com/laurent22/joplin/releases/tag/v1.0.189) (p) | 2020-03-04T17:27:15Z | 342 | 96 | 90 | 528
[v1.0.187](https://github.com/laurent22/joplin/releases/tag/v1.0.187) (p) | 2020-03-01T12:31:06Z | 919 | 230 | 263 | 1,412 [v1.0.187](https://github.com/laurent22/joplin/releases/tag/v1.0.187) (p) | 2020-03-01T12:31:06Z | 919 | 230 | 263 | 1,412
[v1.0.179](https://github.com/laurent22/joplin/releases/tag/v1.0.179) | 2020-01-24T22:42:41Z | 71,011 | 28,538 | 22,531 | 122,080 [v1.0.179](https://github.com/laurent22/joplin/releases/tag/v1.0.179) | 2020-01-24T22:42:41Z | 71,023 | 28,545 | 22,534 | 122,102
[v1.0.178](https://github.com/laurent22/joplin/releases/tag/v1.0.178) | 2020-01-20T19:06:45Z | 17,539 | 5,962 | 2,584 | 26,085 [v1.0.178](https://github.com/laurent22/joplin/releases/tag/v1.0.178) | 2020-01-20T19:06:45Z | 17,539 | 5,962 | 2,584 | 26,085
[v1.0.177](https://github.com/laurent22/joplin/releases/tag/v1.0.177) (p) | 2019-12-30T14:40:40Z | 1,943 | 438 | 678 | 3,059 [v1.0.177](https://github.com/laurent22/joplin/releases/tag/v1.0.177) (p) | 2019-12-30T14:40:40Z | 1,943 | 438 | 678 | 3,059
[v1.0.176](https://github.com/laurent22/joplin/releases/tag/v1.0.176) (p) | 2019-12-14T10:36:44Z | 3,124 | 2,534 | 467 | 6,125 [v1.0.176](https://github.com/laurent22/joplin/releases/tag/v1.0.176) (p) | 2019-12-14T10:36:44Z | 3,124 | 2,534 | 467 | 6,125
[v1.0.175](https://github.com/laurent22/joplin/releases/tag/v1.0.175) | 2019-12-08T11:48:47Z | 72,487 | 16,895 | 16,508 | 105,890 [v1.0.175](https://github.com/laurent22/joplin/releases/tag/v1.0.175) | 2019-12-08T11:48:47Z | 72,519 | 16,905 | 16,509 | 105,933
[v1.0.174](https://github.com/laurent22/joplin/releases/tag/v1.0.174) | 2019-11-12T18:20:58Z | 30,399 | 11,722 | 8,221 | 50,342 [v1.0.174](https://github.com/laurent22/joplin/releases/tag/v1.0.174) | 2019-11-12T18:20:58Z | 30,401 | 11,722 | 8,221 | 50,344
[v1.0.173](https://github.com/laurent22/joplin/releases/tag/v1.0.173) | 2019-11-11T08:33:35Z | 5,071 | 2,077 | 743 | 7,891 [v1.0.173](https://github.com/laurent22/joplin/releases/tag/v1.0.173) | 2019-11-11T08:33:35Z | 5,072 | 2,077 | 743 | 7,892
[v1.0.170](https://github.com/laurent22/joplin/releases/tag/v1.0.170) | 2019-10-13T22:13:04Z | 27,408 | 8,752 | 7,675 | 43,835 [v1.0.170](https://github.com/laurent22/joplin/releases/tag/v1.0.170) | 2019-10-13T22:13:04Z | 27,413 | 8,752 | 7,675 | 43,840
[v1.0.169](https://github.com/laurent22/joplin/releases/tag/v1.0.169) | 2019-09-27T18:35:13Z | 17,096 | 5,921 | 3,754 | 26,771 [v1.0.169](https://github.com/laurent22/joplin/releases/tag/v1.0.169) | 2019-09-27T18:35:13Z | 17,097 | 5,921 | 3,754 | 26,772
[v1.0.168](https://github.com/laurent22/joplin/releases/tag/v1.0.168) | 2019-09-25T21:21:38Z | 5,332 | 2,273 | 717 | 8,322 [v1.0.168](https://github.com/laurent22/joplin/releases/tag/v1.0.168) | 2019-09-25T21:21:38Z | 5,332 | 2,273 | 717 | 8,322
[v1.0.167](https://github.com/laurent22/joplin/releases/tag/v1.0.167) | 2019-09-10T08:48:37Z | 16,789 | 5,704 | 3,703 | 26,196 [v1.0.167](https://github.com/laurent22/joplin/releases/tag/v1.0.167) | 2019-09-10T08:48:37Z | 16,790 | 5,704 | 3,703 | 26,197
[v1.0.166](https://github.com/laurent22/joplin/releases/tag/v1.0.166) | 2019-09-09T17:35:54Z | 1,956 | 560 | 236 | 2,752 [v1.0.166](https://github.com/laurent22/joplin/releases/tag/v1.0.166) | 2019-09-09T17:35:54Z | 1,956 | 560 | 236 | 2,752
[v1.0.165](https://github.com/laurent22/joplin/releases/tag/v1.0.165) | 2019-08-14T21:46:29Z | 18,891 | 6,972 | 5,462 | 31,325 [v1.0.165](https://github.com/laurent22/joplin/releases/tag/v1.0.165) | 2019-08-14T21:46:29Z | 18,898 | 6,972 | 5,462 | 31,332
[v1.0.161](https://github.com/laurent22/joplin/releases/tag/v1.0.161) | 2019-07-13T18:30:00Z | 19,284 | 6,352 | 4,136 | 29,772 [v1.0.161](https://github.com/laurent22/joplin/releases/tag/v1.0.161) | 2019-07-13T18:30:00Z | 19,285 | 6,352 | 4,136 | 29,773
[v1.0.160](https://github.com/laurent22/joplin/releases/tag/v1.0.160) | 2019-06-15T00:21:40Z | 30,523 | 7,745 | 8,101 | 46,369 [v1.0.160](https://github.com/laurent22/joplin/releases/tag/v1.0.160) | 2019-06-15T00:21:40Z | 30,531 | 7,745 | 8,101 | 46,377
[v1.0.159](https://github.com/laurent22/joplin/releases/tag/v1.0.159) | 2019-06-08T00:00:19Z | 5,194 | 2,178 | 1,110 | 8,482 [v1.0.159](https://github.com/laurent22/joplin/releases/tag/v1.0.159) | 2019-06-08T00:00:19Z | 5,194 | 2,178 | 1,112 | 8,484
[v1.0.158](https://github.com/laurent22/joplin/releases/tag/v1.0.158) | 2019-05-27T19:01:18Z | 9,814 | 3,538 | 1,936 | 15,288 [v1.0.158](https://github.com/laurent22/joplin/releases/tag/v1.0.158) | 2019-05-27T19:01:18Z | 9,815 | 3,538 | 1,936 | 15,289
[v1.0.157](https://github.com/laurent22/joplin/releases/tag/v1.0.157) | 2019-05-26T17:55:53Z | 2,179 | 844 | 291 | 3,314 [v1.0.157](https://github.com/laurent22/joplin/releases/tag/v1.0.157) | 2019-05-26T17:55:53Z | 2,179 | 844 | 291 | 3,314
[v1.0.153](https://github.com/laurent22/joplin/releases/tag/v1.0.153) (p) | 2019-05-15T06:27:29Z | 850 | 102 | 106 | 1,058 [v1.0.153](https://github.com/laurent22/joplin/releases/tag/v1.0.153) (p) | 2019-05-15T06:27:29Z | 850 | 102 | 106 | 1,058
[v1.0.152](https://github.com/laurent22/joplin/releases/tag/v1.0.152) | 2019-05-13T09:08:07Z | 13,871 | 4,427 | 4,061 | 22,359 [v1.0.152](https://github.com/laurent22/joplin/releases/tag/v1.0.152) | 2019-05-13T09:08:07Z | 13,873 | 4,427 | 4,061 | 22,361
[v1.0.151](https://github.com/laurent22/joplin/releases/tag/v1.0.151) | 2019-05-12T15:14:32Z | 1,954 | 533 | 957 | 3,444 [v1.0.151](https://github.com/laurent22/joplin/releases/tag/v1.0.151) | 2019-05-12T15:14:32Z | 1,954 | 533 | 957 | 3,444
[v1.0.150](https://github.com/laurent22/joplin/releases/tag/v1.0.150) | 2019-05-12T11:27:48Z | 423 | 136 | 68 | 627 [v1.0.150](https://github.com/laurent22/joplin/releases/tag/v1.0.150) | 2019-05-12T11:27:48Z | 423 | 136 | 68 | 627
[v1.0.148](https://github.com/laurent22/joplin/releases/tag/v1.0.148) (p) | 2019-05-08T19:12:24Z | 133 | 58 | 96 | 287 [v1.0.148](https://github.com/laurent22/joplin/releases/tag/v1.0.148) (p) | 2019-05-08T19:12:24Z | 133 | 58 | 96 | 287
[v1.0.145](https://github.com/laurent22/joplin/releases/tag/v1.0.145) | 2019-05-03T09:16:53Z | 7,007 | 2,861 | 1,437 | 11,305 [v1.0.145](https://github.com/laurent22/joplin/releases/tag/v1.0.145) | 2019-05-03T09:16:53Z | 7,008 | 2,861 | 1,437 | 11,306
[v1.0.143](https://github.com/laurent22/joplin/releases/tag/v1.0.143) | 2019-04-22T10:51:38Z | 11,918 | 3,550 | 2,779 | 18,247 [v1.0.143](https://github.com/laurent22/joplin/releases/tag/v1.0.143) | 2019-04-22T10:51:38Z | 11,918 | 3,550 | 2,779 | 18,247
[v1.0.142](https://github.com/laurent22/joplin/releases/tag/v1.0.142) | 2019-04-02T16:44:51Z | 14,662 | 4,565 | 4,727 | 23,954 [v1.0.142](https://github.com/laurent22/joplin/releases/tag/v1.0.142) | 2019-04-02T16:44:51Z | 14,663 | 4,565 | 4,727 | 23,955
[v1.0.140](https://github.com/laurent22/joplin/releases/tag/v1.0.140) | 2019-03-10T20:59:58Z | 13,629 | 4,171 | 3,217 | 21,017 [v1.0.140](https://github.com/laurent22/joplin/releases/tag/v1.0.140) | 2019-03-10T20:59:58Z | 13,629 | 4,171 | 3,223 | 21,023
[v1.0.139](https://github.com/laurent22/joplin/releases/tag/v1.0.139) (p) | 2019-03-09T10:06:48Z | 123 | 63 | 46 | 232 [v1.0.139](https://github.com/laurent22/joplin/releases/tag/v1.0.139) (p) | 2019-03-09T10:06:48Z | 123 | 63 | 46 | 232
[v1.0.138](https://github.com/laurent22/joplin/releases/tag/v1.0.138) (p) | 2019-03-03T17:23:00Z | 150 | 86 | 84 | 320 [v1.0.138](https://github.com/laurent22/joplin/releases/tag/v1.0.138) (p) | 2019-03-03T17:23:00Z | 150 | 86 | 84 | 320
[v1.0.137](https://github.com/laurent22/joplin/releases/tag/v1.0.137) (p) | 2019-03-03T01:12:51Z | 591 | 58 | 83 | 732 [v1.0.137](https://github.com/laurent22/joplin/releases/tag/v1.0.137) (p) | 2019-03-03T01:12:51Z | 591 | 58 | 83 | 732
[v1.0.135](https://github.com/laurent22/joplin/releases/tag/v1.0.135) | 2019-02-27T23:36:57Z | 12,513 | 3,958 | 4,077 | 20,548 [v1.0.135](https://github.com/laurent22/joplin/releases/tag/v1.0.135) | 2019-02-27T23:36:57Z | 12,514 | 3,958 | 4,077 | 20,549
[v1.0.134](https://github.com/laurent22/joplin/releases/tag/v1.0.134) | 2019-02-27T10:21:44Z | 1,468 | 568 | 219 | 2,255 [v1.0.134](https://github.com/laurent22/joplin/releases/tag/v1.0.134) | 2019-02-27T10:21:44Z | 1,468 | 568 | 219 | 2,255
[v1.0.132](https://github.com/laurent22/joplin/releases/tag/v1.0.132) | 2019-02-26T23:02:05Z | 1,088 | 451 | 95 | 1,634 [v1.0.132](https://github.com/laurent22/joplin/releases/tag/v1.0.132) | 2019-02-26T23:02:05Z | 1,088 | 451 | 95 | 1,634
[v1.0.127](https://github.com/laurent22/joplin/releases/tag/v1.0.127) | 2019-02-14T23:12:48Z | 9,780 | 3,171 | 2,929 | 15,880 [v1.0.127](https://github.com/laurent22/joplin/releases/tag/v1.0.127) | 2019-02-14T23:12:48Z | 9,785 | 3,171 | 2,929 | 15,885
[v1.0.126](https://github.com/laurent22/joplin/releases/tag/v1.0.126) (p) | 2019-02-09T19:46:16Z | 932 | 73 | 117 | 1,122 [v1.0.126](https://github.com/laurent22/joplin/releases/tag/v1.0.126) (p) | 2019-02-09T19:46:16Z | 932 | 73 | 117 | 1,122
[v1.0.125](https://github.com/laurent22/joplin/releases/tag/v1.0.125) | 2019-01-26T18:14:33Z | 10,251 | 3,559 | 1,703 | 15,513 [v1.0.125](https://github.com/laurent22/joplin/releases/tag/v1.0.125) | 2019-01-26T18:14:33Z | 10,251 | 3,559 | 1,703 | 15,513
[v1.0.120](https://github.com/laurent22/joplin/releases/tag/v1.0.120) | 2019-01-10T21:42:53Z | 15,605 | 5,201 | 6,517 | 27,323 [v1.0.120](https://github.com/laurent22/joplin/releases/tag/v1.0.120) | 2019-01-10T21:42:53Z | 15,605 | 5,201 | 6,517 | 27,323
@@ -151,14 +155,14 @@ Version | Date | Windows | macOS | Linux | Total
[v1.0.116](https://github.com/laurent22/joplin/releases/tag/v1.0.116) | 2018-11-20T19:09:24Z | 3,474 | 1,121 | 714 | 5,309 [v1.0.116](https://github.com/laurent22/joplin/releases/tag/v1.0.116) | 2018-11-20T19:09:24Z | 3,474 | 1,121 | 714 | 5,309
[v1.0.115](https://github.com/laurent22/joplin/releases/tag/v1.0.115) | 2018-11-16T16:52:02Z | 3,658 | 1,303 | 799 | 5,760 [v1.0.115](https://github.com/laurent22/joplin/releases/tag/v1.0.115) | 2018-11-16T16:52:02Z | 3,658 | 1,303 | 799 | 5,760
[v1.0.114](https://github.com/laurent22/joplin/releases/tag/v1.0.114) | 2018-10-24T20:14:10Z | 11,397 | 3,500 | 3,830 | 18,727 [v1.0.114](https://github.com/laurent22/joplin/releases/tag/v1.0.114) | 2018-10-24T20:14:10Z | 11,397 | 3,500 | 3,830 | 18,727
[v1.0.111](https://github.com/laurent22/joplin/releases/tag/v1.0.111) | 2018-09-30T20:15:09Z | 12,034 | 3,307 | 3,668 | 19,009 [v1.0.111](https://github.com/laurent22/joplin/releases/tag/v1.0.111) | 2018-09-30T20:15:09Z | 12,041 | 3,307 | 3,668 | 19,016
[v1.0.110](https://github.com/laurent22/joplin/releases/tag/v1.0.110) | 2018-09-29T12:29:21Z | 962 | 409 | 118 | 1,489 [v1.0.110](https://github.com/laurent22/joplin/releases/tag/v1.0.110) | 2018-09-29T12:29:21Z | 962 | 409 | 118 | 1,489
[v1.0.109](https://github.com/laurent22/joplin/releases/tag/v1.0.109) | 2018-09-27T18:01:41Z | 2,102 | 706 | 328 | 3,136 [v1.0.109](https://github.com/laurent22/joplin/releases/tag/v1.0.109) | 2018-09-27T18:01:41Z | 2,102 | 706 | 328 | 3,136
[v1.0.108](https://github.com/laurent22/joplin/releases/tag/v1.0.108) (p) | 2018-09-29T18:49:29Z | 31 | 22 | 14 | 67 [v1.0.108](https://github.com/laurent22/joplin/releases/tag/v1.0.108) (p) | 2018-09-29T18:49:29Z | 31 | 22 | 14 | 67
[v1.0.107](https://github.com/laurent22/joplin/releases/tag/v1.0.107) | 2018-09-16T19:51:07Z | 7,151 | 2,137 | 1,708 | 10,996 [v1.0.107](https://github.com/laurent22/joplin/releases/tag/v1.0.107) | 2018-09-16T19:51:07Z | 7,151 | 2,137 | 1,708 | 10,996
[v1.0.106](https://github.com/laurent22/joplin/releases/tag/v1.0.106) | 2018-09-08T15:23:40Z | 4,559 | 1,458 | 318 | 6,335 [v1.0.106](https://github.com/laurent22/joplin/releases/tag/v1.0.106) | 2018-09-08T15:23:40Z | 4,559 | 1,458 | 318 | 6,335
[v1.0.105](https://github.com/laurent22/joplin/releases/tag/v1.0.105) | 2018-09-05T11:29:36Z | 4,657 | 1,590 | 1,455 | 7,702 [v1.0.105](https://github.com/laurent22/joplin/releases/tag/v1.0.105) | 2018-09-05T11:29:36Z | 4,657 | 1,590 | 1,455 | 7,702
[v1.0.104](https://github.com/laurent22/joplin/releases/tag/v1.0.104) | 2018-06-28T20:25:36Z | 15,054 | 4,701 | 7,344 | 27,099 [v1.0.104](https://github.com/laurent22/joplin/releases/tag/v1.0.104) | 2018-06-28T20:25:36Z | 15,055 | 4,702 | 7,345 | 27,102
[v1.0.103](https://github.com/laurent22/joplin/releases/tag/v1.0.103) | 2018-06-21T19:38:13Z | 2,054 | 888 | 680 | 3,622 [v1.0.103](https://github.com/laurent22/joplin/releases/tag/v1.0.103) | 2018-06-21T19:38:13Z | 2,054 | 888 | 680 | 3,622
[v1.0.101](https://github.com/laurent22/joplin/releases/tag/v1.0.101) | 2018-06-17T18:35:11Z | 1,311 | 608 | 409 | 2,328 [v1.0.101](https://github.com/laurent22/joplin/releases/tag/v1.0.101) | 2018-06-17T18:35:11Z | 1,311 | 608 | 409 | 2,328
[v1.0.100](https://github.com/laurent22/joplin/releases/tag/v1.0.100) | 2018-06-14T17:41:43Z | 882 | 435 | 246 | 1,563 [v1.0.100](https://github.com/laurent22/joplin/releases/tag/v1.0.100) | 2018-06-14T17:41:43Z | 882 | 435 | 246 | 1,563
@@ -167,14 +171,14 @@ Version | Date | Windows | macOS | Linux | Total
[v1.0.96](https://github.com/laurent22/joplin/releases/tag/v1.0.96) | 2018-05-26T16:36:39Z | 2,721 | 1,225 | 1,700 | 5,646 [v1.0.96](https://github.com/laurent22/joplin/releases/tag/v1.0.96) | 2018-05-26T16:36:39Z | 2,721 | 1,225 | 1,700 | 5,646
[v1.0.95](https://github.com/laurent22/joplin/releases/tag/v1.0.95) | 2018-05-25T13:04:30Z | 420 | 220 | 120 | 760 [v1.0.95](https://github.com/laurent22/joplin/releases/tag/v1.0.95) | 2018-05-25T13:04:30Z | 420 | 220 | 120 | 760
[v1.0.94](https://github.com/laurent22/joplin/releases/tag/v1.0.94) | 2018-05-21T20:52:59Z | 1,134 | 586 | 397 | 2,117 [v1.0.94](https://github.com/laurent22/joplin/releases/tag/v1.0.94) | 2018-05-21T20:52:59Z | 1,134 | 586 | 397 | 2,117
[v1.0.93](https://github.com/laurent22/joplin/releases/tag/v1.0.93) | 2018-05-14T11:36:01Z | 1,791 | 1,149 | 759 | 3,699 [v1.0.93](https://github.com/laurent22/joplin/releases/tag/v1.0.93) | 2018-05-14T11:36:01Z | 1,791 | 1,157 | 759 | 3,707
[v1.0.91](https://github.com/laurent22/joplin/releases/tag/v1.0.91) | 2018-05-10T14:48:04Z | 828 | 552 | 307 | 1,687 [v1.0.91](https://github.com/laurent22/joplin/releases/tag/v1.0.91) | 2018-05-10T14:48:04Z | 828 | 552 | 307 | 1,687
[v1.0.89](https://github.com/laurent22/joplin/releases/tag/v1.0.89) | 2018-05-09T13:05:05Z | 495 | 232 | 111 | 838 [v1.0.89](https://github.com/laurent22/joplin/releases/tag/v1.0.89) | 2018-05-09T13:05:05Z | 495 | 232 | 111 | 838
[v1.0.85](https://github.com/laurent22/joplin/releases/tag/v1.0.85) | 2018-05-01T21:08:24Z | 1,654 | 951 | 633 | 3,238 [v1.0.85](https://github.com/laurent22/joplin/releases/tag/v1.0.85) | 2018-05-01T21:08:24Z | 1,654 | 951 | 633 | 3,238
[v1.0.83](https://github.com/laurent22/joplin/releases/tag/v1.0.83) | 2018-04-04T19:43:58Z | 4,872 | 2,532 | 2,658 | 10,062 [v1.0.83](https://github.com/laurent22/joplin/releases/tag/v1.0.83) | 2018-04-04T19:43:58Z | 4,886 | 2,532 | 2,658 | 10,076
[v1.0.82](https://github.com/laurent22/joplin/releases/tag/v1.0.82) | 2018-03-31T19:16:31Z | 694 | 406 | 122 | 1,222 [v1.0.82](https://github.com/laurent22/joplin/releases/tag/v1.0.82) | 2018-03-31T19:16:31Z | 694 | 406 | 122 | 1,222
[v1.0.81](https://github.com/laurent22/joplin/releases/tag/v1.0.81) | 2018-03-28T08:13:58Z | 1,001 | 597 | 783 | 2,381 [v1.0.81](https://github.com/laurent22/joplin/releases/tag/v1.0.81) | 2018-03-28T08:13:58Z | 1,001 | 597 | 783 | 2,381
[v1.0.79](https://github.com/laurent22/joplin/releases/tag/v1.0.79) | 2018-03-23T18:00:11Z | 932 | 539 | 380 | 1,851 [v1.0.79](https://github.com/laurent22/joplin/releases/tag/v1.0.79) | 2018-03-23T18:00:11Z | 932 | 539 | 381 | 1,852
[v1.0.78](https://github.com/laurent22/joplin/releases/tag/v1.0.78) | 2018-03-17T15:27:18Z | 1,313 | 870 | 872 | 3,055 [v1.0.78](https://github.com/laurent22/joplin/releases/tag/v1.0.78) | 2018-03-17T15:27:18Z | 1,313 | 870 | 872 | 3,055
[v1.0.77](https://github.com/laurent22/joplin/releases/tag/v1.0.77) | 2018-03-16T15:12:35Z | 179 | 105 | 46 | 330 [v1.0.77](https://github.com/laurent22/joplin/releases/tag/v1.0.77) | 2018-03-16T15:12:35Z | 179 | 105 | 46 | 330
[v1.0.72](https://github.com/laurent22/joplin/releases/tag/v1.0.72) | 2018-03-14T09:44:35Z | 407 | 258 | 57 | 722 [v1.0.72](https://github.com/laurent22/joplin/releases/tag/v1.0.72) | 2018-03-14T09:44:35Z | 407 | 258 | 57 | 722
@@ -195,7 +199,7 @@ Version | Date | Windows | macOS | Linux | Total
[v0.10.43](https://github.com/laurent22/joplin/releases/tag/v0.10.43) | 2018-01-08T10:12:10Z | 3,442 | 2,357 | 1,209 | 7,008 [v0.10.43](https://github.com/laurent22/joplin/releases/tag/v0.10.43) | 2018-01-08T10:12:10Z | 3,442 | 2,357 | 1,209 | 7,008
[v0.10.41](https://github.com/laurent22/joplin/releases/tag/v0.10.41) | 2018-01-05T20:38:12Z | 1,038 | 1,549 | 243 | 2,830 [v0.10.41](https://github.com/laurent22/joplin/releases/tag/v0.10.41) | 2018-01-05T20:38:12Z | 1,038 | 1,549 | 243 | 2,830
[v0.10.40](https://github.com/laurent22/joplin/releases/tag/v0.10.40) | 2018-01-02T23:16:57Z | 1,596 | 1,790 | 339 | 3,725 [v0.10.40](https://github.com/laurent22/joplin/releases/tag/v0.10.40) | 2018-01-02T23:16:57Z | 1,596 | 1,790 | 339 | 3,725
[v0.10.39](https://github.com/laurent22/joplin/releases/tag/v0.10.39) | 2017-12-11T21:19:44Z | 5,823 | 4,294 | 3,195 | 13,312 [v0.10.39](https://github.com/laurent22/joplin/releases/tag/v0.10.39) | 2017-12-11T21:19:44Z | 5,824 | 4,294 | 3,195 | 13,313
[v0.10.38](https://github.com/laurent22/joplin/releases/tag/v0.10.38) | 2017-12-08T10:12:06Z | 1,050 | 1,231 | 307 | 2,588 [v0.10.38](https://github.com/laurent22/joplin/releases/tag/v0.10.38) | 2017-12-08T10:12:06Z | 1,050 | 1,231 | 307 | 2,588
[v0.10.37](https://github.com/laurent22/joplin/releases/tag/v0.10.37) | 2017-12-07T19:38:05Z | 266 | 845 | 82 | 1,193 [v0.10.37](https://github.com/laurent22/joplin/releases/tag/v0.10.37) | 2017-12-07T19:38:05Z | 266 | 845 | 82 | 1,193
[v0.10.36](https://github.com/laurent22/joplin/releases/tag/v0.10.36) | 2017-12-05T09:34:40Z | 1,016 | 1,356 | 439 | 2,811 [v0.10.36](https://github.com/laurent22/joplin/releases/tag/v0.10.36) | 2017-12-05T09:34:40Z | 1,016 | 1,356 | 439 | 2,811
@@ -204,9 +208,9 @@ Version | Date | Windows | macOS | Linux | Total
[v0.10.33](https://github.com/laurent22/joplin/releases/tag/v0.10.33) | 2017-12-02T13:20:39Z | 62 | 659 | 22 | 743 [v0.10.33](https://github.com/laurent22/joplin/releases/tag/v0.10.33) | 2017-12-02T13:20:39Z | 62 | 659 | 22 | 743
[v0.10.31](https://github.com/laurent22/joplin/releases/tag/v0.10.31) | 2017-12-01T09:56:44Z | 893 | 1,451 | 407 | 2,751 [v0.10.31](https://github.com/laurent22/joplin/releases/tag/v0.10.31) | 2017-12-01T09:56:44Z | 893 | 1,451 | 407 | 2,751
[v0.10.30](https://github.com/laurent22/joplin/releases/tag/v0.10.30) | 2017-11-30T20:28:16Z | 724 | 1,369 | 420 | 2,513 [v0.10.30](https://github.com/laurent22/joplin/releases/tag/v0.10.30) | 2017-11-30T20:28:16Z | 724 | 1,369 | 420 | 2,513
[v0.10.28](https://github.com/laurent22/joplin/releases/tag/v0.10.28) | 2017-11-30T01:07:46Z | 1,341 | 1,701 | 874 | 3,916 [v0.10.28](https://github.com/laurent22/joplin/releases/tag/v0.10.28) | 2017-11-30T01:07:46Z | 1,342 | 1,701 | 874 | 3,917
[v0.10.26](https://github.com/laurent22/joplin/releases/tag/v0.10.26) | 2017-11-29T16:02:17Z | 188 | 701 | 261 | 1,150 [v0.10.26](https://github.com/laurent22/joplin/releases/tag/v0.10.26) | 2017-11-29T16:02:17Z | 188 | 701 | 261 | 1,150
[v0.10.25](https://github.com/laurent22/joplin/releases/tag/v0.10.25) | 2017-11-24T14:27:49Z | 150 | 696 | 6,454 | 7,300 [v0.10.25](https://github.com/laurent22/joplin/releases/tag/v0.10.25) | 2017-11-24T14:27:49Z | 150 | 696 | 6,461 | 7,307
[v0.10.23](https://github.com/laurent22/joplin/releases/tag/v0.10.23) | 2017-11-21T19:38:41Z | 134 | 647 | 28 | 809 [v0.10.23](https://github.com/laurent22/joplin/releases/tag/v0.10.23) | 2017-11-21T19:38:41Z | 134 | 647 | 28 | 809
[v0.10.22](https://github.com/laurent22/joplin/releases/tag/v0.10.22) | 2017-11-20T21:45:57Z | 86 | 645 | 19 | 750 [v0.10.22](https://github.com/laurent22/joplin/releases/tag/v0.10.22) | 2017-11-20T21:45:57Z | 86 | 645 | 19 | 750
[v0.10.21](https://github.com/laurent22/joplin/releases/tag/v0.10.21) | 2017-11-18T00:53:15Z | 53 | 638 | 13 | 704 [v0.10.21](https://github.com/laurent22/joplin/releases/tag/v0.10.21) | 2017-11-18T00:53:15Z | 53 | 638 | 13 | 704