Compare commits
60 Commits
android-v3
...
v3.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5d9646908 | ||
|
|
876ec80911 | ||
|
|
4051f88ce7 | ||
|
|
f194c111e4 | ||
|
|
e386246bc9 | ||
|
|
292b269f1d | ||
|
|
b2fc43da2b | ||
|
|
4a23a1ed3e | ||
|
|
c8878a18bf | ||
|
|
340fba7af5 | ||
|
|
271c4f4a2a | ||
|
|
c9dba20f59 | ||
|
|
b474cc206a | ||
|
|
9d4df8cc6e | ||
|
|
a4ddfe1f58 | ||
|
|
7d15215e66 | ||
|
|
5b74e206ed | ||
|
|
9873d02b0b | ||
|
|
57b7d98d8a | ||
|
|
f075b561a2 | ||
|
|
483d051de0 | ||
|
|
106cd2778f | ||
|
|
c3aea2db80 | ||
|
|
3f067b0f77 | ||
|
|
15cf025bc2 | ||
|
|
b8c5b7a153 | ||
|
|
e46e634c2e | ||
|
|
b3cf4e5a35 | ||
|
|
8589e10d6e | ||
|
|
18942f0d6a | ||
|
|
3be354cdcb | ||
|
|
0575f1aa3e | ||
|
|
caa9baa460 | ||
|
|
b5284804d8 | ||
|
|
6053b4296c | ||
|
|
615fec1d2c | ||
|
|
0bbcd9a59b | ||
|
|
6931b32f17 | ||
|
|
17ac501ddb | ||
|
|
94161c5f93 | ||
|
|
196255e960 | ||
|
|
f936390ee4 | ||
|
|
5638c4b812 | ||
|
|
4222caa423 | ||
|
|
f1c968c19a | ||
|
|
26c5a6181e | ||
|
|
a3bf0cfdeb | ||
|
|
606b397326 | ||
|
|
fbd157283d | ||
|
|
2e879f65fc | ||
|
|
c727156a46 | ||
|
|
4e31f1918d | ||
|
|
a1cdf67779 | ||
|
|
5cb1db197f | ||
|
|
05c3065c72 | ||
|
|
25a5be09bf | ||
|
|
3bba2f6b2a | ||
|
|
ca9addcda0 | ||
|
|
c42a49c1cf | ||
|
|
a1e056670d |
@@ -1061,6 +1061,8 @@ packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceInlineHtml.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceInlineHtml.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
@@ -1101,6 +1103,7 @@ packages/editor/CodeMirror/utils/getSearchState.js
|
||||
packages/editor/CodeMirror/utils/growSelectionToNode.js
|
||||
packages/editor/CodeMirror/utils/handleLinkEditRequests.js
|
||||
packages/editor/CodeMirror/utils/handlePasteEvent.js
|
||||
packages/editor/CodeMirror/utils/htmlNodeInfo.js
|
||||
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
|
||||
packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
|
||||
@@ -1805,6 +1808,7 @@ packages/renderer/MdToHtml/renderMedia.js
|
||||
packages/renderer/MdToHtml/rules/abc.js
|
||||
packages/renderer/MdToHtml/rules/checkbox.js
|
||||
packages/renderer/MdToHtml/rules/code_inline.js
|
||||
packages/renderer/MdToHtml/rules/externalEmbed.js
|
||||
packages/renderer/MdToHtml/rules/fence.js
|
||||
packages/renderer/MdToHtml/rules/fountain.js
|
||||
packages/renderer/MdToHtml/rules/highlight_keywords.js
|
||||
@@ -1840,17 +1844,19 @@ packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/fuzzer/ActionRunner.js
|
||||
packages/tools/fuzzer/ActionTracker.js
|
||||
packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/doRandomAction.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/ProgressBar.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
|
||||
40
.github/workflows/build-macos-m1.yml
vendored
@@ -48,6 +48,7 @@ jobs:
|
||||
CSC_LINK: ${{ secrets.APPLE_CSC_LINK }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
IS_CONTINUOUS_INTEGRATION: 1
|
||||
BUILD_SEQUENCIAL: 1
|
||||
PUBLISH_ENABLED: ${{ env.PUBLISH_ENABLED }}
|
||||
@@ -57,25 +58,38 @@ jobs:
|
||||
yarn install
|
||||
cd packages/app-desktop
|
||||
npm pkg set 'build.mac.artifactName'='${productName}-${version}-${arch}.${ext}'
|
||||
|
||||
npm pkg delete 'build.mac.target'
|
||||
npm pkg set 'build.mac.target[0].target'='dmg'
|
||||
npm pkg set 'build.mac.target[0].arch[0]'='arm64'
|
||||
npm pkg set 'build.mac.target[1].target'='zip'
|
||||
npm pkg set 'build.mac.target[1].arch[0]'='arm64'
|
||||
|
||||
if [[ "$PUBLISH_ENABLED" == "true" ]]; then
|
||||
echo "Building and publishing desktop application..."
|
||||
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64
|
||||
# Only enable pkg build in the main repository CI. As of 01/15/2026, pkg
|
||||
# build fails when running on external pull requests.
|
||||
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
|
||||
npm pkg set 'build.mac.target[2].target'='pkg'
|
||||
npm pkg set 'build.mac.target[2].arch[0]'='arm64'
|
||||
fi
|
||||
|
||||
yarn modifyReleaseAssets --repo="$GH_REPO" --tag="$GIT_TAG_NAME" --token="$GITHUB_TOKEN"
|
||||
else
|
||||
echo "Building but *not* publishing desktop application..."
|
||||
build_dist() {
|
||||
if [[ "$PUBLISH_ENABLED" == "true" ]]; then
|
||||
echo "Building and publishing desktop application..."
|
||||
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64
|
||||
|
||||
# We also want to disable signing the app in this case, because
|
||||
# it doesn't work and we don't need it.
|
||||
# https://www.electron.build/code-signing#how-to-disable-code-signing-during-the-build-process-on-macos
|
||||
yarn modifyReleaseAssets --repo="$GH_REPO" --tag="$GIT_TAG_NAME" --token="$GITHUB_TOKEN"
|
||||
else
|
||||
echo "Building but *not* publishing desktop application..."
|
||||
|
||||
export CSC_IDENTITY_AUTO_DISCOVERY=false
|
||||
npm pkg set 'build.mac.identity'=null --json
|
||||
# We also want to disable signing the app in this case, because
|
||||
# it doesn't work and we don't need it.
|
||||
# https://www.electron.build/code-signing#how-to-disable-code-signing-during-the-build-process-on-macos
|
||||
|
||||
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64 --publish=never
|
||||
fi
|
||||
export CSC_IDENTITY_AUTO_DISCOVERY=false
|
||||
npm pkg set 'build.mac.identity'=null --json
|
||||
|
||||
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64 --publish=never
|
||||
fi
|
||||
}
|
||||
|
||||
build_dist || build_dist
|
||||
9
.gitignore
vendored
@@ -53,6 +53,7 @@ lerna-debug.log
|
||||
docs/**/*.mustache
|
||||
.idea
|
||||
/readme/i18n
|
||||
.watchman-cookie-*
|
||||
|
||||
# Yarn stuff
|
||||
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
|
||||
@@ -1034,6 +1035,8 @@ packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceInlineHtml.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceInlineHtml.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
@@ -1074,6 +1077,7 @@ packages/editor/CodeMirror/utils/getSearchState.js
|
||||
packages/editor/CodeMirror/utils/growSelectionToNode.js
|
||||
packages/editor/CodeMirror/utils/handleLinkEditRequests.js
|
||||
packages/editor/CodeMirror/utils/handlePasteEvent.js
|
||||
packages/editor/CodeMirror/utils/htmlNodeInfo.js
|
||||
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
|
||||
packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
|
||||
@@ -1778,6 +1782,7 @@ packages/renderer/MdToHtml/renderMedia.js
|
||||
packages/renderer/MdToHtml/rules/abc.js
|
||||
packages/renderer/MdToHtml/rules/checkbox.js
|
||||
packages/renderer/MdToHtml/rules/code_inline.js
|
||||
packages/renderer/MdToHtml/rules/externalEmbed.js
|
||||
packages/renderer/MdToHtml/rules/fence.js
|
||||
packages/renderer/MdToHtml/rules/fountain.js
|
||||
packages/renderer/MdToHtml/rules/highlight_keywords.js
|
||||
@@ -1813,17 +1818,19 @@ packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/fuzzer/ActionRunner.js
|
||||
packages/tools/fuzzer/ActionTracker.js
|
||||
packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/doRandomAction.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/ProgressBar.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
|
||||
BIN
Assets/WebsiteAssets/images/news/20260111-abc.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-lowercase-tags.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-mobile-tags.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-multi-select.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-profiles.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-rte1.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-rte2.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
@@ -1,4 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 22 Sep 2025 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 22 Sep 2025 00:00:00 GMT</pubDate><item><title><![CDATA[What's new in Joplin 3.4]]></title><description><![CDATA[<p>Joplin 3.4 includes many bug fixes and improvements, with a focus on the mobile app.</p>
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Sun, 11 Jan 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Sun, 11 Jan 2026 00:00:00 GMT</pubDate><item><title><![CDATA[What's new in Joplin 3.5]]></title><description><![CDATA[<h2>Improvements across desktop and mobile<a name="improvements-across-desktop-and-mobile" href="#improvements-across-desktop-and-mobile" class="heading-anchor">🔗</a></h2>
|
||||
<h3>More stable and consistent Markdown editing<a name="more-stable-and-consistent-markdown-editing" href="#more-stable-and-consistent-markdown-editing" class="heading-anchor">🔗</a></h3>
|
||||
<p>The Markdown editor has been refined to feel more stable and closer to the final rendered view. Headings in the editor now more closely match how they appear when viewing a note, reducing the visual jump between editing and reading. Layout issues have also been addressed so elements like rendered checkboxes and images no longer cause the editor to shift unexpectedly while typing.</p>
|
||||
<p>The ABC music notation plugin appeared to be popular but had some limitations. With this new version, ABC is now part of the app, which means it can now work from published notes, and from the Rich Text editor!</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-abc.png" alt="ABC music notation rendered directly in Joplin, showing a short musical phrase displayed from plain-text ABC syntax"></p>
|
||||
<h3>Smoother switching between notes<a name="smoother-switching-between-notes" href="#smoother-switching-between-notes" class="heading-anchor">🔗</a></h3>
|
||||
<p>Switching between notes is now less disruptive. Joplin restores cursor position and scroll location more reliably, making it easier to move back and forth between notes—especially when working with longer documents or comparing content—without losing your place.</p>
|
||||
<h3>Case insensitive tags<a name="case-insensitive-tags" href="#case-insensitive-tags" class="heading-anchor">🔗</a></h3>
|
||||
<p>Tags are now treated in a case-insensitive way, which helps prevent duplicate tags caused by differences in capitalisation, while still allowing mixed-case tag names. All this time we were hoping that @dpoulton <a href="https://discourse.joplinapp.org/t/tags-lower-case-only/4220/106">would just get used to lowercase tags</a>, but 5 years later it looks like it's not happening ;) So thank you @mrjo118 for implementing it!</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-lowercase-tags.png" alt="Joplin tag list demonstrating case-insensitive tags, with mixed-case tag names merged into a single tag."></p>
|
||||
<h3>More reliable syncing and sharing<a name="more-reliable-syncing-and-sharing" href="#more-reliable-syncing-and-sharing" class="heading-anchor">🔗</a></h3>
|
||||
<p>Syncing and sharing have been made more robust in everyday use. Joplin now handles repeated syncs more efficiently, avoids unnecessary data usage, and is better at detecting and syncing all changes, particularly when using WebDAV and S3 sync targets.</p>
|
||||
<p>Moreover filesystem synchronisation is now more reliable, in particular when used alongside tools like SyncThing on both mobile and desktop.</p>
|
||||
<h3>Accessibility and readability improvements<a name="accessibility-and-readability-improvements" href="#accessibility-and-readability-improvements" class="heading-anchor">🔗</a></h3>
|
||||
<p>Accessibility has seen further refinements in this release. Dark mode readability has been improved, common editor elements are clearer, and animations are reduced or disabled when system “reduce motion” settings are enabled, making the app more comfortable to use for a wider range of users. Keyboard navigation has also been improved on the desktop application.</p>
|
||||
<h2>Desktop-specific improvements<a name="desktop-specific-improvements" href="#desktop-specific-improvements" class="heading-anchor">🔗</a></h2>
|
||||
<h3>Easier profile management<a name="easier-profile-management" href="#easier-profile-management" class="heading-anchor">🔗</a></h3>
|
||||
<p>Managing multiple profiles on desktop is now simpler thanks to a new, more user-friendly profile management interface. This removes the need to manually edit configuration files and makes switching between different setups easier and safer.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-profiles.png" alt="Desktop profile management screen in Joplin showing multiple profiles with options to rename or delete them."></p>
|
||||
<h3>Significantly improved OneNote import<a name="significantly-improved-onenote-import" href="#significantly-improved-onenote-import" class="heading-anchor">🔗</a></h3>
|
||||
<p>Importing content from OneNote is now more reliable and accurate. Support has been expanded to cover more OneNote file formats, and many edge cases have been addressed so imported notes more closely match their original structure and content. This makes migrating from OneNote to Joplin smoother and more trustworthy.</p>
|
||||
<h3>Better tools for organising large note collections<a name="better-tools-for-organising-large-note-collections" href="#better-tools-for-organising-large-note-collections" class="heading-anchor">🔗</a></h3>
|
||||
<p>Desktop users can now select multiple notebooks at once, making it easier to reorganise notebook structures, move groups of notes, or clean up larger collections without working notebook by notebook.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-multi-select.png" alt="Joplin desktop sidebar with several notebooks selected at the same time for bulk organisation."></p>
|
||||
<h3>Polished editing experience on desktop<a name="polished-editing-experience-on-desktop" href="#polished-editing-experience-on-desktop" class="heading-anchor">🔗</a></h3>
|
||||
<p>Both the Markdown and Rich Text editors have been further refined. Cursor behaviour is more predictable, visual consistency between editing and viewing has improved, and several layout and rendering issues have been fixed to reduce interruptions while writing.</p>
|
||||
<h3>More reliable search and navigation<a name="more-reliable-search-and-navigation" href="#more-reliable-search-and-navigation" class="heading-anchor">🔗</a></h3>
|
||||
<p>Search and navigation on desktop have been improved with fixes that ensure search results behave consistently and remain visible when moving between windows or views.</p>
|
||||
<h3>Improved math support in WebClipper<a name="improved-math-support-in-webclipper" href="#improved-math-support-in-webclipper" class="heading-anchor">🔗</a></h3>
|
||||
<p>The WebClipper is not forgotten in this release - clipping certain math formulas, in particular from Wikipedia but also other websites, has been improved. Additionally, certain scientific articles are now also better handled by the WebClipper.</p>
|
||||
<h2>Mobile-specific improvements<a name="mobile-specific-improvements" href="#mobile-specific-improvements" class="heading-anchor">🔗</a></h2>
|
||||
<h3>A more powerful Rich Text Editor on mobile<a name="a-more-powerful-rich-text-editor-on-mobile" href="#a-more-powerful-rich-text-editor-on-mobile" class="heading-anchor">🔗</a></h3>
|
||||
<p>The mobile Rich Text Editor continues to improve, with new and expanded support for tables, code blocks, and other structured content. These changes make it easier to create and edit more complex notes directly on mobile devices.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-rte1.png" alt="Joplin mobile Rich Text Editor showing table editing controls and an embedded code block inside a note."></p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-rte2.png" alt="Mobile code block editor in Joplin with a Python code snippet displayed in an editable dialog."></p>
|
||||
<h3>Easier tag management on mobile<a name="easier-tag-management-on-mobile" href="#easier-tag-management-on-mobile" class="heading-anchor">🔗</a></h3>
|
||||
<p>Managing tags on mobile is now more practical. You can rename and delete tags directly from the app, and searching through tags is easier, helping keep large tag lists organised over time.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-mobile-tags.png" alt="Joplin mobile tag management screen showing a tag options menu with rename and delete actions."></p>
|
||||
<h3>Improved stability and usability on mobile devices<a name="improved-stability-and-usability-on-mobile-devices" href="#improved-stability-and-usability-on-mobile-devices" class="heading-anchor">🔗</a></h3>
|
||||
<p>Several fixes improve overall stability and usability on mobile, particularly on smaller screens. Issues causing UI elements to appear off-screen have been addressed, and the app behaves more consistently in situations that previously caused hangs or visual glitches.</p>
|
||||
<h2>Bug fixes and security fixes across platforms<a name="bug-fixes-and-security-fixes-across-platforms" href="#bug-fixes-and-security-fixes-across-platforms" class="heading-anchor">🔗</a></h2>
|
||||
<h3>A large number of stability, correctness and security fixes<a name="a-large-number-of-stability-correctness-and-security-fixes" href="#a-large-number-of-stability-correctness-and-security-fixes" class="heading-anchor">🔗</a></h3>
|
||||
<p>Joplin 3.5 includes about 114 bug fixes across desktop and mobile, addressing issues in editing, syncing, importing, rendering, and general stability. Many fixes target edge cases that could lead to crashes, inconsistent behaviour, or rare data loss scenarios. Moreover, this version includes several vulnerability fixes to make the applications more secure.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20260111-release-3-5</link><guid isPermaLink="false">20260111-release-3-5</guid><pubDate>Sun, 11 Jan 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[What's new in Joplin 3.4]]></title><description><![CDATA[<p>Joplin 3.4 includes many bug fixes and improvements, with a focus on the mobile app.</p>
|
||||
<h2>Mobile<a name="mobile" href="#mobile" class="heading-anchor">🔗</a></h2>
|
||||
<h3>Rich Text Editor<a name="rich-text-editor" href="#rich-text-editor" class="heading-anchor">🔗</a></h3>
|
||||
<p>The mobile app now includes a beta <a href="https://joplinapp.org/help/apps/rich_text_editor">Rich Text Editor</a>! The new editor renders formatting/math/images within the editor:</p>
|
||||
@@ -481,42 +524,4 @@ sys 0m38.013s</p>
|
||||
<p>This is a bit of an extra constraint but it is hard to avoid. Contributor License Agreements are very common for GPL or AGPL projects. For example Apache, Canonical or Python all require their contributors to sign a CLA.</p>
|
||||
<h2>Questions?<a name="questions" href="#questions" class="heading-anchor">🔗</a></h2>
|
||||
<p>If you have any questions please let us know. Overall we believe this is a positive improvements for Joplin as it means any work derives from it will also benefit the project.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20221221-agpl</link><guid isPermaLink="false">20221221-agpl</guid><pubDate>Wed, 21 Dec 2022 00:00:00 GMT</pubDate><twitter-text>Joplin is switching to the GNU Affero General Public License v3 (AGPL-3.0)</twitter-text></item><item><title><![CDATA[What's new in Joplin 2.9]]></title><description><![CDATA[<h2>Proxy support<a name="proxy-support" href="#proxy-support" class="heading-anchor">🔗</a></h2>
|
||||
<p>Both the desktop and mobile application now support proxies thanks to the work of Jason Williams. This will allow you to use the apps in particular when you are behind a company proxy.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20221216-proxy-support.png" alt=""></p>
|
||||
<h2>New PDF viewer<a name="new-pdf-viewer" href="#new-pdf-viewer" class="heading-anchor">🔗</a></h2>
|
||||
<p>The desktop application now features a new PDF viewer thanks to the work of Asrient during GSoC.</p>
|
||||
<p>The main advantage for now is that this viewer preserves the last PDF page that was read. In the next version, the viewer will also include a way to annotate PDF files.</p>
|
||||
<h2>Multi-language spell checking<a name="multi-language-spell-checking" href="#multi-language-spell-checking" class="heading-anchor">🔗</a></h2>
|
||||
<p>The desktop app include a multi-language spell checking features, which allows you, for example, to spell-check notes in your native language and in English.</p>
|
||||
<h2>New mobile text editor<a name="new-mobile-text-editor" href="#new-mobile-text-editor" class="heading-anchor">🔗</a></h2>
|
||||
<p>Writing formatted notes on mobile has always been cumbersome due to the need to enter special format characters like <code>*</code> or <code>[</code>, etc.</p>
|
||||
<p>Thanks to the work of Henry Heino during GSoC, writing notes on the go is now easier thanks to an improved Markdown editor.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20221216-mobile-beta-editor.png" alt=""></p>
|
||||
<p>The most visible feature is the addition of a toolbar, which helps input those special characters, like on desktop.</p>
|
||||
<p>Moreover Henry made a lot of subtle but useful improvements to the editor, for example to improve the note appearance, to improve list continuation, etc. Search within a note is now also supported as well as spell-checking.</p>
|
||||
<p>At a more technical level, Henry also added many test units to ensure that the editor remains robust and reliable.</p>
|
||||
<p>To enable the feature, go to the configuration screen and selected "Opt-in to the editor beta". It is already very stable so we will probably promote it to be the main editor from the next version.</p>
|
||||
<h2>Improved alignment of notebook icons<a name="improved-alignment-of-notebook-icons" href="#improved-alignment-of-notebook-icons" class="heading-anchor">🔗</a></h2>
|
||||
<p>Previously, when you would assign an icon to a notebook, it would shift the title to the right, but notebook without an icon would not. It means that notebooks with and without an icon would not be vertically aligned.</p>
|
||||
<p>To tidy things up, this new version adds a default icons to notebooks without an explicitly assigned icon. This result in the notebook titles being correctly vertically aligned.</p>
|
||||
<p>Note that this feature is only enabled if you use custom icons - otherwise it will simply display the notebook titles without any default icons, as before.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20221216-notebook-icons.png" alt=""></p>
|
||||
<h2>Improved handling of file attachments<a name="improved-handling-of-file-attachments" href="#improved-handling-of-file-attachments" class="heading-anchor">🔗</a></h2>
|
||||
<p>Self Not Found made a number of small but useful improvements to attachment handling, including increasing the maximum size to 200MB, adding support for attaching multiple files, and fixing issues with synchronising attachments via proxy.</p>
|
||||
<h2>Fixed filesystem sync on mobile<a name="fixed-filesystem-sync-on-mobile" href="#fixed-filesystem-sync-on-mobile" class="heading-anchor">🔗</a></h2>
|
||||
<p>This was a long and complex change due to the need to support new Android APIs but hopefully that should now be working again, thanks to the work of jd1378.</p>
|
||||
<p>So you can now sync again your notes with Syncthing and other file-based synchronisation systems.</p>
|
||||
<h2>And more...<a name="and-more" href="#and-more" class="heading-anchor">🔗</a></h2>
|
||||
<p>In total this new desktop version includes 36 improvements, bug fixes, and security fixes.</p>
|
||||
<p>As always, a lot of work went into the Android and iOS app too, which include 37 improvements, bug fixes, and security fixes.</p>
|
||||
<p>See here for the changelogs:</p>
|
||||
<ul>
|
||||
<li><a href="https://joplinapp.org/help/about/changelog/desktop">Desktop app changelog</a></li>
|
||||
<li><a href="https://joplinapp.org/help/about/changelog/android/">Android app changelog</a></li>
|
||||
</ul>
|
||||
<h2>About the Android version<a name="about-the-android-version" href="#about-the-android-version" class="heading-anchor">🔗</a></h2>
|
||||
<p>Unfortunately we cannot publish the Android version because it is based on a framework version that Google does not accept. To upgrade the app a lot of changes are needed and another round of pre-releases, and therefore there will not be a 2.9 version for Google Play. You may however download the official APK directly from there: <a href="https://github.com/laurent22/joplin-android/releases/tag/android-v2.9.8">Android 2.9 Official Release</a></p>
|
||||
<p>This is the reality of app stores in general - small developers being imposed never ending new requirements by all-powerful companies, and by the time a version is finally ready we can't even publish it because yet more requirements are in place.</p>
|
||||
<p>For the record the current 2.9 app works perfectly fine. It targets Android 11, which is only 2 years old and is still supported (and installed on millions of phones). Google requires us to target Android 12 which only came out last year.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20221216-release-2-9</link><guid isPermaLink="false">20221216-release-2-9</guid><pubDate>Fri, 16 Dec 2022 00:00:00 GMT</pubDate><twitter-text>What's new in Joplin 2.9</twitter-text></item></channel></rss>
|
||||
]]></description><link>https://joplinapp.org/news/20221221-agpl</link><guid isPermaLink="false">20221221-agpl</guid><pubDate>Wed, 21 Dec 2022 00:00:00 GMT</pubDate><twitter-text>Joplin is switching to the GNU Affero General Public License v3 (AGPL-3.0)</twitter-text></item></channel></rss>
|
||||
6
jest.config.base.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// This is the base Jest configuration - all
|
||||
// jest.config.js files should inherit from it.
|
||||
|
||||
module.exports = {
|
||||
watchman: false,
|
||||
};
|
||||
@@ -16,6 +16,7 @@
|
||||
"./packages/app-cli/**/*.mo": true,
|
||||
"./packages/app-cli/**/build/": true,
|
||||
"./packages/app-cli/**/config.json": true,
|
||||
"**/.watchman-cookie-*": true,
|
||||
"./packages/app-cli/**/linkToLocal.sh": true,
|
||||
"./packages/app-cli/**/node_modules/": true,
|
||||
"./packages/app-cli/**/out.txt": true,
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
// 4. Remove tests one by one to narrow it down to the one with the async
|
||||
// call that's causing problem.
|
||||
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
testMatch: [
|
||||
'**/tests/HtmlToHtml.js',
|
||||
'**/tests/HtmlToMd.js',
|
||||
|
||||
@@ -35,15 +35,15 @@
|
||||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "3.5.1",
|
||||
"version": "3.6.0",
|
||||
"bin": "./main.js",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/lib": "~3.5",
|
||||
"@joplin/renderer": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@joplin/lib": "~3.6",
|
||||
"@joplin/renderer": "~3.6",
|
||||
"@joplin/utils": "~3.6",
|
||||
"aws-sdk": "2.1340.0",
|
||||
"chalk": "4.1.2",
|
||||
"compare-version": "0.1.2",
|
||||
@@ -70,7 +70,7 @@
|
||||
"yargs-parser": "21.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~3.5",
|
||||
"@joplin/tools": "~3.6",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.130",
|
||||
|
||||
8
packages/app-cli/tests/md_to_html/external_embed.html
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
<div class="joplin-editable">
|
||||
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
|
||||
<div class="joplin-youtube-player-rendered">
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/iJqe9pC-z-Y" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1
packages/app-cli/tests/md_to_html/external_embed.md
Normal file
@@ -0,0 +1 @@
|
||||
https://www.youtube.com/watch?v=iJqe9pC-z-Y
|
||||
BIN
packages/app-cli/tests/support/onenote/test.onepkg
Normal file
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Joplin Web Clipper [DEV]",
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.0",
|
||||
"description": "Capture and save web pages and screenshots from your browser to Joplin.",
|
||||
"homepage_url": "https://joplinapp.org",
|
||||
"content_security_policy": {
|
||||
|
||||
@@ -260,6 +260,15 @@ export default class ElectronAppWrapper {
|
||||
|
||||
require('@electron/remote/main').enable(this.win_.webContents);
|
||||
|
||||
// Add Referer header for YouTube embeds to fix Error 153
|
||||
this.win_.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
{ urls: ['*://*.youtube.com/*', '*://*.youtube-nocookie.com/*'] },
|
||||
(details, callback) => {
|
||||
details.requestHeaders['Referer'] = 'https://joplinapp.org/';
|
||||
callback({ requestHeaders: details.requestHeaders });
|
||||
},
|
||||
);
|
||||
|
||||
if (!screen.getDisplayMatching(this.win_.getBounds())) {
|
||||
const { width: windowWidth, height: windowHeight } = this.win_.getBounds();
|
||||
const { width: primaryDisplayWidth, height: primaryDisplayHeight } = screen.getPrimaryDisplay().workArea;
|
||||
|
||||
@@ -95,6 +95,9 @@ export default class InteropServiceHelper {
|
||||
// Allows users to override the CSS page size.
|
||||
// See https://github.com/laurent22/joplin/issues/13096
|
||||
preferCSSPageSize: true,
|
||||
|
||||
// Include accessibility information in the output:
|
||||
generateTaggedPDF: true,
|
||||
});
|
||||
resolve(data);
|
||||
} catch (error) {
|
||||
|
||||
@@ -742,7 +742,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
'media-src \'self\' blob: data: *', // Audio and video players
|
||||
|
||||
// Disallow certain unused features
|
||||
'child-src \'none\'', // Should not contain sub-frames
|
||||
'child-src https://*.youtube.com https://*.youtube-nocookie.com', // Allow YouTube embeds
|
||||
'object-src \'none\'', // Objects can be used for script injection
|
||||
'form-action \'none\'', // No submitting forms
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
default-src 'self' joplin-content://* ;
|
||||
connect-src 'self' * http://* https://* joplin-content://* blob: ;
|
||||
style-src 'unsafe-inline' 'self' blob: joplin-content://* https://* http://* ;
|
||||
child-src 'self' joplin-content://* ;
|
||||
child-src 'self' joplin-content://* https://*.youtube.com https://*.youtube-nocookie.com ;
|
||||
script-src 'self' 'unsafe-inline' joplin-content://* ;
|
||||
media-src 'self' * blob: data: https://* http://* joplin-content://* ;
|
||||
img-src 'self' blob: data: http://* https://* joplin-content://* ;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// For a detailed explanation regarding each configuration property, visit:
|
||||
// https://jestjs.io/docs/en/configuration.html
|
||||
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.5.11",
|
||||
"version": "3.6.2",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
@@ -92,6 +92,12 @@
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "pkg",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
@@ -139,19 +145,19 @@
|
||||
"@electron/rebuild": "3.7.2",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
"@joeattardi/emoji-button": "4.6.4",
|
||||
"@joplin/default-plugins": "~3.5",
|
||||
"@joplin/editor": "~3.5",
|
||||
"@joplin/lib": "~3.5",
|
||||
"@joplin/renderer": "~3.5",
|
||||
"@joplin/tools": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@joplin/default-plugins": "~3.6",
|
||||
"@joplin/editor": "~3.6",
|
||||
"@joplin/lib": "~3.6",
|
||||
"@joplin/renderer": "~3.6",
|
||||
"@joplin/tools": "~3.6",
|
||||
"@joplin/utils": "~3.6",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@sentry/electron": "4.24.0",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "18.3.25",
|
||||
"@types/react": "18.3.26",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
@@ -208,7 +214,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/remote": "2.1.3",
|
||||
"@joplin/onenote-converter": "~3.5",
|
||||
"@joplin/onenote-converter": "~3.6",
|
||||
"fs-extra": "11.3.2",
|
||||
"keytar": "7.9.0",
|
||||
"node-fetch": "2.6.7",
|
||||
|
||||
@@ -90,7 +90,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097788
|
||||
versionName "3.5.8"
|
||||
versionName "3.6.0"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -345,7 +345,6 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/react-native-image-picker/RNImagePickerPrivacyInfo.bundle",
|
||||
"${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Brands.ttf",
|
||||
"${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Regular.ttf",
|
||||
@@ -365,7 +364,6 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNImagePickerPrivacyInfo.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf",
|
||||
@@ -520,7 +518,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.3;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -554,7 +552,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.3;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -758,7 +756,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.3;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -801,7 +799,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.3;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -2306,7 +2306,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
|
||||
EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e
|
||||
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
|
||||
Expo: c8f323f74218c45c46e27eed40d8a53ba50667c3
|
||||
@@ -2319,7 +2319,7 @@ SPEC CHECKSUMS:
|
||||
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
||||
FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975
|
||||
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
||||
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
||||
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
|
||||
hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe
|
||||
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
|
||||
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
|
||||
preset: 'react-native',
|
||||
|
||||
'moduleFileExtensions': [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@joplin/app-mobile",
|
||||
"description": "Joplin for Mobile",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "BROWSERSLIST_IGNORE_OLD_DATA=true react-native start --reset-cache",
|
||||
@@ -22,12 +22,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bam.tech/react-native-image-resizer": "3.0.11",
|
||||
"@joplin/editor": "~3.5",
|
||||
"@joplin/lib": "~3.5",
|
||||
"@joplin/react-native-alarm-notification": "~3.5",
|
||||
"@joplin/react-native-saf-x": "~3.5",
|
||||
"@joplin/renderer": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@joplin/editor": "~3.6",
|
||||
"@joplin/lib": "~3.6",
|
||||
"@joplin/react-native-alarm-notification": "~3.6",
|
||||
"@joplin/react-native-saf-x": "~3.6",
|
||||
"@joplin/renderer": "~3.6",
|
||||
"@joplin/utils": "~3.6",
|
||||
"@js-draw/material-icons": "1.33.0",
|
||||
"@react-native-clipboard/clipboard": "1.16.3",
|
||||
"@react-native-community/datetimepicker": "8.4.5",
|
||||
@@ -59,14 +59,14 @@
|
||||
"punycode": "2.3.1",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.2",
|
||||
"react-native-device-info": "14.0.4",
|
||||
"react-native-device-info": "14.1.1",
|
||||
"react-native-dropdownalert": "5.2.0",
|
||||
"react-native-exit-app": "2.0.0",
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-image-picker": "8.2.1",
|
||||
"react-native-localize": "3.5.2",
|
||||
"react-native-localize": "3.5.4",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-paper": "5.14.5",
|
||||
"react-native-popup-menu": "0.17.0",
|
||||
@@ -97,7 +97,7 @@
|
||||
"@babel/plugin-transform-export-namespace-from": "7.27.1",
|
||||
"@babel/preset-env": "7.25.3",
|
||||
"@babel/runtime": "7.25.0",
|
||||
"@joplin/tools": "~3.5",
|
||||
"@joplin/tools": "~3.6",
|
||||
"@joplin/turndown": "~4.0.80",
|
||||
"@joplin/turndown-plugin-gfm": "~1.0.62",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.0",
|
||||
@@ -114,13 +114,13 @@
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.0.14",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.157",
|
||||
"@types/serviceworker": "0.0.158",
|
||||
"@types/tar-stream": "3.1.4",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-module-resolver": "4.1.0",
|
||||
"babel-plugin-react-native-web": "0.21.1",
|
||||
"esbuild": "0.25.10",
|
||||
"babel-plugin-react-native-web": "0.21.2",
|
||||
"esbuild": "0.25.11",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fs-extra": "11.3.2",
|
||||
"gulp": "4.0.2",
|
||||
@@ -131,7 +131,7 @@
|
||||
"nodemon": "3.1.10",
|
||||
"punycode": "2.3.1",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native-web": "0.21.1",
|
||||
"react-native-web": "0.21.2",
|
||||
"react-refresh": "0.17.0",
|
||||
"react-test-renderer": "19.0.0",
|
||||
"sharp": "0.34.4",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
default-src 'self' ;
|
||||
connect-src 'self' * http://* https://* blob: ;
|
||||
style-src 'unsafe-inline' 'self' blob: ;
|
||||
child-src 'self' ;
|
||||
child-src 'self' https://*.youtube.com https://*.youtube-nocookie.com ;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' ;
|
||||
media-src 'self' blob: data: https://* http://* ;
|
||||
img-src 'self' blob: data: http://* https://* ;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/default-plugins",
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.0",
|
||||
"description": "Default plugins bundler",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -14,12 +14,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/yargs": "17.0.33",
|
||||
"joplin-plugin-freehand-drawing": "4.2.0",
|
||||
"joplin-plugin-freehand-drawing": "4.3.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/utils": "~3.5",
|
||||
"@joplin/utils": "~3.6",
|
||||
"fs-extra": "11.3.2",
|
||||
"yargs": "17.7.2"
|
||||
}
|
||||
|
||||
@@ -3,39 +3,17 @@ import { EditorSelection } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import uslug from '@joplin/fork-uslug/lib/uslug';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import htmlNodeInfo from '../utils/htmlNodeInfo';
|
||||
|
||||
const jumpToHash = (view: EditorView, hash: string) => {
|
||||
const state = view.state;
|
||||
const timeout = 1_000; // Maximum time to spend parsing the syntax tree
|
||||
let targetLocation: number|undefined = undefined;
|
||||
|
||||
const removeQuotes = (quoted: string) => quoted.replace(/^["'](.*)["']$/, '$1');
|
||||
|
||||
const makeEnterNode = (offset: number) => (node: SyntaxNodeRef) => {
|
||||
const nodeToText = (node: SyntaxNodeRef) => {
|
||||
return state.sliceDoc(node.from + offset, node.to + offset);
|
||||
};
|
||||
// Returns the attribute with the given name for [node]
|
||||
const getHtmlNodeAttr = (node: SyntaxNodeRef, attrName: string) => {
|
||||
if (node.from === node.to) return null; // Empty
|
||||
const content = node.node.resolveInner(node.from + 1);
|
||||
|
||||
// Search for the "id" attribute
|
||||
const attributes = content.getChildren('Attribute');
|
||||
for (const attribute of attributes) {
|
||||
const nameNode = attribute.getChild('AttributeName');
|
||||
const valueNode = attribute.getChild('AttributeValue');
|
||||
|
||||
if (nameNode && valueNode) {
|
||||
const name = nodeToText(nameNode).toLowerCase().replace(/^"(.*)"$/, '$1');
|
||||
if (name === attrName) {
|
||||
return removeQuotes(nodeToText(valueNode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const found = targetLocation !== undefined;
|
||||
if (found) return false; // Skip this node
|
||||
@@ -46,13 +24,14 @@ const jumpToHash = (view: EditorView, hash: string) => {
|
||||
.replace(/^#+\s/, '') // Leading #s in headers
|
||||
.replace(/\n-+$/, ''); // Trailing --s in headers
|
||||
matches = hash === uslug(nodeText);
|
||||
} else if (node.name === 'HTMLTag' || node.name === 'HTMLBlock') {
|
||||
} else if (node.name === 'HTMLBlock') {
|
||||
// CodeMirror adds HTML information to Markdown documents using overlays attached
|
||||
// to HTMLTag and HTMLBlock nodes.
|
||||
// Use .enter to enter the overlay and visit the HTML nodes:
|
||||
node.node.enter(node.from, 1).toTree().iterate({ enter: makeEnterNode(node.from) });
|
||||
} else if (node.name === 'OpenTag') {
|
||||
matches = getHtmlNodeAttr(node, 'id') === hash || getHtmlNodeAttr(node, 'name') === hash;
|
||||
} else if (node.name === 'OpenTag' || node.name === 'HTMLTag') {
|
||||
const htmlNodeDetails = htmlNodeInfo(node, state);
|
||||
matches = htmlNodeDetails.getAttr('id') === hash || htmlNodeDetails.getAttr('name') === hash;
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import replaceBulletLists from './replaceBulletLists';
|
||||
import replaceCheckboxes from './replaceCheckboxes';
|
||||
import replaceDividers from './replaceDividers';
|
||||
import replaceFormatCharacters from './replaceFormatCharacters';
|
||||
import replaceInlineHtml from './replaceInlineHtml';
|
||||
|
||||
export default () => {
|
||||
return [
|
||||
@@ -13,5 +14,6 @@ export default () => {
|
||||
replaceBackslashEscapes,
|
||||
replaceDividers,
|
||||
addFormattingClasses,
|
||||
replaceInlineHtml,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import createTestEditor from '../../testing/createTestEditor';
|
||||
import replaceInlineHtml from './replaceInlineHtml';
|
||||
|
||||
const createEditor = async (initialMarkdown: string, expectedTags: string[] = ['HTMLTag']) => {
|
||||
const editor = await createTestEditor(
|
||||
initialMarkdown,
|
||||
EditorSelection.cursor(0),
|
||||
expectedTags,
|
||||
[replaceInlineHtml],
|
||||
);
|
||||
return editor;
|
||||
};
|
||||
|
||||
describe('replaceInlineHtml', () => {
|
||||
test.each([
|
||||
{ markdown: '<sup>Test</sup>', expectedTagsQuery: 'sup' },
|
||||
{ markdown: '<strike>Test</strike>', expectedTagsQuery: 'strike' },
|
||||
{ markdown: 'Test: <span style="color: red;">Test</span>', expectedTagsQuery: 'span[style]' },
|
||||
{ markdown: 'Test: <span style="color: rgb(123, 0, 0);">Test</span>', expectedTagsQuery: 'span[style]' },
|
||||
])('should render inline HTML (case %#)', async ({ markdown, expectedTagsQuery }) => {
|
||||
// Add additional newlines: Ensure that the cursor isn't initially on the same line as the content to be rendered:
|
||||
const editor = await createEditor(`\n\n${markdown}\n\n`);
|
||||
|
||||
expect(editor.contentDOM.querySelector(expectedTagsQuery)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension';
|
||||
import { Decoration } from '@codemirror/view';
|
||||
import htmlNodeInfo, { HtmlNodeInfo } from '../../utils/htmlNodeInfo';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
const hideDecoration = Decoration.replace({});
|
||||
|
||||
type OnRenderTagContent = (openingTag: HtmlNodeInfo)=> Decoration;
|
||||
const createHtmlReplacementExtension = (tagName: string, onRenderContent: OnRenderTagContent) => {
|
||||
const isMatchingTag = (info: HtmlNodeInfo) => {
|
||||
return info.tagName().toLowerCase() === tagName;
|
||||
};
|
||||
const isMatchingOpeningTag = (info: HtmlNodeInfo) => {
|
||||
return isMatchingTag(info) && info.opening;
|
||||
};
|
||||
const isMatchingClosingTag = (info: HtmlNodeInfo) => {
|
||||
return isMatchingTag(info) && info.closing;
|
||||
};
|
||||
|
||||
const findClosingTag = (openingTag: SyntaxNodeRef, state: EditorState) => {
|
||||
const openingTagInfo = htmlNodeInfo(openingTag, state);
|
||||
// Self-closing?
|
||||
if (openingTagInfo.closing) {
|
||||
return openingTag;
|
||||
}
|
||||
|
||||
let cursor = openingTag.node.nextSibling;
|
||||
let nestedTagCounter = 1;
|
||||
|
||||
// Find the matching closing tag
|
||||
for (; !!cursor && nestedTagCounter > 0; cursor = cursor.nextSibling) {
|
||||
const info = htmlNodeInfo(cursor, state);
|
||||
if (isMatchingOpeningTag(info)) {
|
||||
nestedTagCounter ++;
|
||||
} else if (isMatchingClosingTag(info)) {
|
||||
nestedTagCounter --;
|
||||
}
|
||||
|
||||
if (nestedTagCounter === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return cursor;
|
||||
};
|
||||
|
||||
const hideTags = makeInlineReplaceExtension({
|
||||
createDecoration: (node, state) => {
|
||||
const info = htmlNodeInfo(node, state);
|
||||
return info && isMatchingTag(info) ? hideDecoration : null;
|
||||
},
|
||||
});
|
||||
|
||||
const styleContent = makeInlineReplaceExtension({
|
||||
createDecoration: (node, state) => {
|
||||
const info = htmlNodeInfo(node, state);
|
||||
if (!info || !isMatchingOpeningTag(info)) return null;
|
||||
return onRenderContent(info);
|
||||
},
|
||||
getDecorationRange(node, state) {
|
||||
const closingTag = findClosingTag(node, state);
|
||||
|
||||
if (closingTag) {
|
||||
return [node.to, closingTag.from];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return [hideTags, styleContent];
|
||||
};
|
||||
|
||||
|
||||
export default [
|
||||
createHtmlReplacementExtension('sub', () => Decoration.mark({ tagName: 'sub' })),
|
||||
createHtmlReplacementExtension('sup', () => Decoration.mark({ tagName: 'sup' })),
|
||||
createHtmlReplacementExtension('strike', () => Decoration.mark({ tagName: 'strike' })),
|
||||
createHtmlReplacementExtension('span', (info) => {
|
||||
const styles = info.getAttr('style') ?? '';
|
||||
const colorMatch = styles.match(/color:\s*(#?[a-z0-9A-Z]+|rgba?\([0-9, ]+\))(;|$)/);
|
||||
|
||||
return Decoration.mark({
|
||||
attributes: {
|
||||
style: colorMatch ? `color: ${colorMatch[1]};` : '',
|
||||
},
|
||||
});
|
||||
}),
|
||||
].flat();
|
||||
88
packages/editor/CodeMirror/utils/htmlNodeInfo.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
|
||||
export interface HtmlNodeInfo {
|
||||
node: SyntaxNodeRef;
|
||||
opening: boolean;
|
||||
closing: boolean;
|
||||
from: number;
|
||||
to: number;
|
||||
tagName: ()=> string;
|
||||
getAttr: (attributeName: string)=> string;
|
||||
}
|
||||
|
||||
type OnGetNodeContent = (node: SyntaxNodeRef)=> string;
|
||||
|
||||
const removeQuotes = (quoted: string) => quoted.replace(/^["'](.*)["']$/, '$1');
|
||||
|
||||
const getHtmlNodeAttr = (node: SyntaxNodeRef, attrName: string, getText: OnGetNodeContent) => {
|
||||
if (node.from === node.to) return null; // Empty
|
||||
const content = node.node.resolveInner(node.from + 1);
|
||||
|
||||
// Search for the "id" attribute
|
||||
const attributes = content.getChildren('Attribute');
|
||||
for (const attribute of attributes) {
|
||||
const nameNode = attribute.getChild('AttributeName');
|
||||
const valueNode = attribute.getChild('AttributeValue');
|
||||
|
||||
if (nameNode && valueNode) {
|
||||
const name = getText(nameNode).toLowerCase().replace(/^"(.*)"$/, '$1');
|
||||
if (name === attrName) {
|
||||
return removeQuotes(getText(valueNode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Utility function to access CodeMirror HTML node information, based on
|
||||
// the corresponding Markdown node.
|
||||
const htmlNodeInfo = (node: SyntaxNodeRef, state: EditorState, offset = 0): HtmlNodeInfo|null => {
|
||||
// Already an HTML node?
|
||||
if (node.name === 'OpenTag' || node.name === 'CloseTag' || node.name === 'SelfClosingTag') {
|
||||
const getNodeText = (childNode: SyntaxNodeRef) => state.sliceDoc(childNode.from + offset, childNode.to + offset);
|
||||
const selfClosing = node.name === 'SelfClosingTag';
|
||||
|
||||
return {
|
||||
node,
|
||||
opening: node.name === 'OpenTag' || selfClosing,
|
||||
closing: node.name === 'CloseTag' || selfClosing,
|
||||
from: node.from + offset,
|
||||
to: node.to + offset,
|
||||
tagName: () => {
|
||||
const nodeText = getNodeText(node).trim();
|
||||
const tagNameMatch = nodeText.match(/^<\/?([^>\s]+)/);
|
||||
if (tagNameMatch) {
|
||||
return tagNameMatch[1];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getAttr: (name: string) => {
|
||||
return getHtmlNodeAttr(node, name, getNodeText);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Convert Markdown HTML nodes to HTML nodes
|
||||
if (node.name === 'HTMLTag' || node.name === 'HTMLBlock') {
|
||||
const globalOffset = node.from + offset;
|
||||
let resolved: HtmlNodeInfo|null = null;
|
||||
|
||||
// CodeMirror adds HTML information to Markdown documents using overlays attached
|
||||
// to HTMLTag and HTMLBlock nodes.
|
||||
// Use .enter to enter the overlay and visit the HTML nodes:
|
||||
node.node.enter(node.from, 1).toTree().iterate({
|
||||
enter: (subNode) => {
|
||||
resolved ??= htmlNodeInfo(subNode, state, globalOffset);
|
||||
return !resolved;
|
||||
},
|
||||
});
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default htmlNodeInfo;
|
||||
@@ -1,4 +1,8 @@
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
|
||||
'moduleFileExtensions': [
|
||||
'ts',
|
||||
'tsx',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/editor",
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.0",
|
||||
"description": "Web-based markdown editor",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -14,12 +14,12 @@
|
||||
"url": "git+https://github.com/laurent22/joplin.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/lib": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@joplin/lib": "~3.6",
|
||||
"@joplin/utils": "~3.6",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "18.3.25",
|
||||
"@types/react": "18.3.26",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"jest": "29.7.0",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
|
||||
testMatch: [
|
||||
'**/*.test.js',
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "<%= pluginId %>",
|
||||
"app_min_version": "3.5",
|
||||
"app_min_version": "3.6",
|
||||
"version": "1.0.0",
|
||||
"name": "<%= pluginName %>",
|
||||
"description": "<%= pluginDescription %>",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
|
||||
testMatch: [
|
||||
'**/*.test.js',
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "generator-joplin",
|
||||
"version": "3.5.1",
|
||||
"version": "3.6.0",
|
||||
"description": "Scaffolds out a new Joplin plugin",
|
||||
"homepage": "https://github.com/laurent22/joplin/tree/dev/packages/generator-joplin",
|
||||
"author": {
|
||||
@@ -30,8 +30,8 @@
|
||||
"yosay": "2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/lib": "~3.5",
|
||||
"@joplin/tools": "~3.5",
|
||||
"@joplin/lib": "~3.6",
|
||||
"@joplin/tools": "~3.6",
|
||||
"jest": "29.7.0",
|
||||
"ts-node": "10.9.2"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
|
||||
testMatch: ['**/*.test.js'],
|
||||
testPathIgnorePatterns: ['<rootDir>/node_modules/'],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/htmlpack",
|
||||
"version": "3.5.1",
|
||||
"version": "3.6.0",
|
||||
"description": "Pack an HTML file and all its linked resources into a single HTML file",
|
||||
"main": "dist/index.js",
|
||||
"types": "index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Resource from './models/Resource';
|
||||
import shim from './shim';
|
||||
import Database from './database';
|
||||
import Database, { Row } from './database';
|
||||
import { SqlQuery } from './services/database/types';
|
||||
import addMigrationFile from './services/database/addMigrationFile';
|
||||
import sqlStringToLines from './services/database/sqlStringToLines';
|
||||
@@ -314,34 +314,44 @@ export default class JoplinDatabase extends Database {
|
||||
throw new Error(`\`notes_fts\` (${countFieldsNotesFts} fields) must have the same number of fields as \`items_fts\` (${countFieldsItemsFts} fields) for the search engine BM25 algorithm to work`);
|
||||
}
|
||||
|
||||
const tableRows = await this.selectAll('SELECT name FROM sqlite_master WHERE type=\'table\'');
|
||||
interface TableRow {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const tableRows: TableRow[] = await this.selectAll('SELECT name FROM sqlite_master WHERE type=\'table\'');
|
||||
|
||||
for (let i = 0; i < tableRows.length; i++) {
|
||||
let pragmas: Row[] = [];
|
||||
const tableName = tableRows[i].name;
|
||||
if (tableName === 'android_metadata') continue;
|
||||
if (tableName === 'table_fields') continue;
|
||||
if (tableName === 'sqlite_sequence') continue;
|
||||
if (tableName.indexOf('notes_fts') === 0) continue;
|
||||
if (tableName.indexOf('items_fts') === 0) continue;
|
||||
if (tableName === 'notes_spellfix') continue;
|
||||
if (tableName === 'search_aux') continue;
|
||||
try {
|
||||
if (tableName === 'android_metadata') continue;
|
||||
if (tableName === 'table_fields') continue;
|
||||
if (tableName.startsWith('sqlite_')) continue;
|
||||
if (tableName.indexOf('notes_fts') === 0) continue;
|
||||
if (tableName.indexOf('items_fts') === 0) continue;
|
||||
if (tableName === 'notes_spellfix') continue;
|
||||
if (tableName === 'search_aux') continue;
|
||||
|
||||
const pragmas = await this.selectAll(`PRAGMA table_info("${tableName}")`);
|
||||
pragmas = await this.selectAll(`PRAGMA table_info("${tableName}")`);
|
||||
|
||||
for (let i = 0; i < pragmas.length; i++) {
|
||||
const item = pragmas[i];
|
||||
// In SQLite, if the default value is a string it has double quotes around it, so remove them here
|
||||
let defaultValue = item.dflt_value;
|
||||
if (typeof defaultValue === 'string' && defaultValue.length >= 2 && defaultValue[0] === '"' && defaultValue[defaultValue.length - 1] === '"') {
|
||||
defaultValue = defaultValue.substr(1, defaultValue.length - 2);
|
||||
for (let i = 0; i < pragmas.length; i++) {
|
||||
const item = pragmas[i];
|
||||
// In SQLite, if the default value is a string it has double quotes around it, so remove them here
|
||||
let defaultValue = item.dflt_value;
|
||||
if (typeof defaultValue === 'string' && defaultValue.length >= 2 && defaultValue[0] === '"' && defaultValue[defaultValue.length - 1] === '"') {
|
||||
defaultValue = defaultValue.substr(1, defaultValue.length - 2);
|
||||
}
|
||||
const q = Database.insertQuery('table_fields', {
|
||||
table_name: tableName,
|
||||
field_name: item.name,
|
||||
field_type: Database.enumId('fieldType', item.type),
|
||||
field_default: defaultValue,
|
||||
});
|
||||
queries.push(q);
|
||||
}
|
||||
const q = Database.insertQuery('table_fields', {
|
||||
table_name: tableName,
|
||||
field_name: item.name,
|
||||
field_type: Database.enumId('fieldType', item.type),
|
||||
field_default: defaultValue,
|
||||
});
|
||||
queries.push(q);
|
||||
} catch (error) {
|
||||
error.message = `On table: ${tableName}: Pragma: ${JSON.stringify(pragmas)}: ${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,6 @@ export interface ArchiveExtractOptions {
|
||||
extractTo: string;
|
||||
}
|
||||
|
||||
export interface CabExtractOptions extends ArchiveExtractOptions {
|
||||
// Only files matching the pattern will be extracted
|
||||
fileNamePattern: string;
|
||||
}
|
||||
|
||||
export interface ZipEntry {
|
||||
entryName: string;
|
||||
name: string;
|
||||
@@ -276,8 +271,4 @@ export default class FsDriverBase {
|
||||
public async zipExtract(_options: ArchiveExtractOptions): Promise<ZipEntry[]> {
|
||||
throw new Error('Not implemented: zipExtract');
|
||||
}
|
||||
|
||||
public async cabExtract(_options: CabExtractOptions) {
|
||||
throw new Error('Not implemented: cabExtract.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import AdmZip = require('adm-zip');
|
||||
import FsDriverBase, { Stat, ZipEntry, ArchiveExtractOptions, CabExtractOptions } from './fs-driver-base';
|
||||
import FsDriverBase, { Stat, ZipEntry, ArchiveExtractOptions } from './fs-driver-base';
|
||||
import time from './time';
|
||||
import { execCommand } from '@joplin/utils';
|
||||
import { extname } from 'path';
|
||||
const md5File = require('md5-file');
|
||||
const fs = require('fs-extra');
|
||||
|
||||
@@ -218,25 +216,4 @@ export default class FsDriverNode extends FsDriverBase {
|
||||
zip.extractAllTo(options.extractTo, false);
|
||||
return zip.getEntries();
|
||||
}
|
||||
|
||||
public async cabExtract(options: CabExtractOptions) {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('Extracting CAB archives is only supported on Windows.');
|
||||
}
|
||||
|
||||
const source = this.resolve(options.source);
|
||||
const extractTo = this.resolve(options.extractTo);
|
||||
|
||||
if (extname(source).toLowerCase() !== '.cab') {
|
||||
throw new Error(`Invalid file extension. Expected .CAB. Was ${extname(source)}`);
|
||||
}
|
||||
|
||||
// See https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/expand
|
||||
await execCommand([
|
||||
'expand.exe',
|
||||
source,
|
||||
`-f:${options.fileNamePattern}`,
|
||||
extractTo,
|
||||
], { quiet: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
const testPathIgnorePatterns = [
|
||||
'<rootDir>/node_modules/',
|
||||
@@ -11,6 +12,7 @@ if (!process.env.IS_CONTINUOUS_INTEGRATION) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
testMatch: [
|
||||
'**/*.test.js',
|
||||
],
|
||||
|
||||
@@ -1105,6 +1105,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
'markdown.plugin.emoji': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable markdown emoji')}${wysiwygNo}` },
|
||||
'markdown.plugin.insert': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` },
|
||||
'markdown.plugin.multitable': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
|
||||
'markdown.plugin.externalEmbed': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable external embeds (e.g. YouTube Videos)')}${wysiwygYes}` },
|
||||
|
||||
// For now, applies only to the Markdown viewer
|
||||
'renderer.fileUrls': {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/lib",
|
||||
"version": "3.5.1",
|
||||
"version": "3.6.0",
|
||||
"description": "Joplin Core library",
|
||||
"author": "Laurent Cozic",
|
||||
"homepage": "",
|
||||
@@ -27,7 +27,7 @@
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "18.19.130",
|
||||
"@types/node-rsa": "1.1.4",
|
||||
"@types/react": "18.3.25",
|
||||
"@types/react": "18.3.26",
|
||||
"@types/uuid": "10.0.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-expect-message": "1.1.3",
|
||||
@@ -46,12 +46,12 @@
|
||||
"@joplin/fork-htmlparser2": "^4.1.60",
|
||||
"@joplin/fork-sax": "^1.2.64",
|
||||
"@joplin/fork-uslug": "^2.0.3",
|
||||
"@joplin/htmlpack": "^3.5.1",
|
||||
"@joplin/onenote-converter": "^3.5.1",
|
||||
"@joplin/renderer": "^3.5.1",
|
||||
"@joplin/htmlpack": "~3.6",
|
||||
"@joplin/onenote-converter": "~3.6",
|
||||
"@joplin/renderer": "~3.6",
|
||||
"@joplin/turndown": "^4.0.82",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.64",
|
||||
"@joplin/utils": "^3.5.1",
|
||||
"@joplin/utils": "~3.6",
|
||||
"adm-zip": "0.5.16",
|
||||
"async-mutex": "0.5.0",
|
||||
"base-64": "1.0.0",
|
||||
|
||||
@@ -145,8 +145,7 @@ export default class InteropService {
|
||||
fileExtensions: [
|
||||
'zip',
|
||||
'one',
|
||||
// .onepkg is a CAB archive, which Joplin can currently only extract on Windows
|
||||
...(shim.isWindows() ? ['onepkg'] : []),
|
||||
'onepkg',
|
||||
],
|
||||
sources: [FileSystemItem.File],
|
||||
isNoteArchive: false, // Tells whether the file can contain multiple notes (eg. Enex or Jex format)
|
||||
|
||||
@@ -42,6 +42,19 @@ const normalizeNoteForSnapshot = (body: string) => {
|
||||
return removeItemIds(removeDefaultCss(body));
|
||||
};
|
||||
|
||||
// A single Markdown string is much easier to visually compare during snapshot testing.
|
||||
// Prefer notesToMarkdownString to normalizeNoteForSnapshot when the exact output HTML
|
||||
// doesn't matter.
|
||||
const notesToMarkdownString = (notes: NoteEntity[]) => {
|
||||
const converter = new HtmlToMd();
|
||||
return notes.map(note => {
|
||||
return [
|
||||
`# Note: ${note.title}`,
|
||||
converter.parse(normalizeNoteForSnapshot(note.body)),
|
||||
].join('\n\n');
|
||||
}).sort().join('\n\n\n');
|
||||
};
|
||||
|
||||
// This file is ignored if not running in CI. Look at onenote-converter/README.md and jest.config.js for more information
|
||||
describe('InteropService_Importer_OneNote', () => {
|
||||
let tempDir: string;
|
||||
@@ -329,4 +342,10 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
|
||||
expect(normalizeNoteForSnapshot(importedNote.body)).toMatchSnapshot('EmbeddedFiles');
|
||||
});
|
||||
|
||||
it('should correctly import .onepkg notebooks', async () => {
|
||||
const notes = await importNote(`${supportDir}/onenote/test.onepkg`);
|
||||
|
||||
expect(notesToMarkdownString(notes)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,30 +47,13 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
|
||||
if (fileExtension === '.zip') {
|
||||
logger.info('Unzipping files...');
|
||||
await shim.fsDriver().zipExtract({ source: sourcePath, extractTo: targetPath });
|
||||
} else if (fileExtension === '.one') {
|
||||
} else if (fileExtension === '.one' || fileExtension === '.onepkg') {
|
||||
logger.info('Copying file...');
|
||||
|
||||
const outputDirectory = join(targetPath, fileNameNoExtension);
|
||||
await shim.fsDriver().mkdir(outputDirectory);
|
||||
|
||||
await shim.fsDriver().copy(sourcePath, join(outputDirectory, basename(sourcePath)));
|
||||
} else if (fileExtension === '.onepkg') {
|
||||
// Change the file extension so that the archive can be extracted
|
||||
const archivePath = join(targetPath, `${fileNameNoExtension}.cab`);
|
||||
await shim.fsDriver().copy(sourcePath, archivePath);
|
||||
|
||||
const extractPath = join(targetPath, fileNameNoExtension);
|
||||
await shim.fsDriver().mkdir(extractPath);
|
||||
|
||||
await shim.fsDriver().cabExtract({
|
||||
source: archivePath,
|
||||
extractTo: extractPath,
|
||||
// Only the .one files are used--there's no need to extract
|
||||
// other files.
|
||||
fileNamePattern: '*.one',
|
||||
});
|
||||
|
||||
await this.fixIncorrectLatin1Decoding_(extractPath);
|
||||
} else {
|
||||
throw new Error(`Unknown file extension: ${fileExtension}`);
|
||||
}
|
||||
@@ -101,7 +84,7 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
|
||||
const notebookFilePath = join(unzipTempDirectory, notebookFile.path);
|
||||
// In some cases, the OneNote zip file can include folders and other files
|
||||
// that shouldn't be imported directly. Skip these:
|
||||
if (!['.one', '.onetoc2'].includes(extname(notebookFilePath).toLowerCase())) {
|
||||
if (!['.one', '.onepkg', '.onetoc2'].includes(extname(notebookFilePath).toLowerCase())) {
|
||||
logger.info('Skipping non-OneNote file:', notebookFile.path);
|
||||
skippedFiles.push(notebookFile.path);
|
||||
continue;
|
||||
@@ -323,47 +306,4 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Works around a decoding issue in which file names are extracted as latin1 strings,
|
||||
// rather than UTF-8 strings. For example, OneNote seems to encode filenames as UTF-8 in .onepkg files.
|
||||
// However, EXPAND.EXE reads the filenames as latin1. As a result, "é.one" becomes
|
||||
// "é.one" when extracted from the archive.
|
||||
// This workaround re-encodes filenames as UTF-8.
|
||||
private async fixIncorrectLatin1Decoding_(parentDir: string) {
|
||||
// Only seems to be necessary on Windows.
|
||||
if (!shim.isWindows()) return;
|
||||
|
||||
const fixEncoding = async (basePath: string, fileName: string) => {
|
||||
const originalPath = join(basePath, fileName);
|
||||
let newPath;
|
||||
|
||||
let fixedFileName = Buffer.from(fileName, 'latin1').toString('utf8');
|
||||
if (fixedFileName !== fileName) {
|
||||
// In general, the path shouldn't start with "."s or contain path separators.
|
||||
// However, if it does, these characters might cause import errors, so remove them:
|
||||
fixedFileName = fixedFileName.replace(/^\.+/, '');
|
||||
fixedFileName = fixedFileName.replace(/[/\\]/g, ' ');
|
||||
|
||||
// Avoid path traversal: Ensure that the file path is contained within the base directory
|
||||
const newFullPathSafe = shim.fsDriver().resolveRelativePathWithinDir(basePath, fixedFileName);
|
||||
await shim.fsDriver().move(originalPath, newFullPathSafe);
|
||||
|
||||
newPath = newFullPathSafe;
|
||||
} else {
|
||||
newPath = originalPath;
|
||||
}
|
||||
|
||||
if (await shim.fsDriver().isDirectory(originalPath)) {
|
||||
const children = await shim.fsDriver().readDirStats(newPath, { recursive: false });
|
||||
for (const child of children) {
|
||||
await fixEncoding(originalPath, child.path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stats = await shim.fsDriver().readDirStats(parentDir, { recursive: false });
|
||||
for (const stat of stats) {
|
||||
await fixEncoding(parentDir, stat.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,133 @@ jeudi 23 octobre 2025
|
||||
- [x] Documenter configuration synchro JBS saml pour un utilisateur (case cochée)"
|
||||
`;
|
||||
|
||||
exports[`InteropService_Importer_OneNote should correctly import .onepkg notebooks 1`] = `
|
||||
"# Note: A
|
||||
|
||||
A
|
||||
|
||||
- [Test](:/id-here "Test")
|
||||
|
||||
|
||||
# Note: A test
|
||||
|
||||
A test
|
||||
|
||||
A test
|
||||
|
||||
Tuesday, January 13, 2026
|
||||
|
||||
1:44 PM
|
||||
|
||||
…test…
|
||||
|
||||
|
||||
# Note: Another section
|
||||
|
||||
Another section
|
||||
|
||||
- [Page 1](:/id-here "Page 1")
|
||||
- [Page 2](:/id-here "Page 2")
|
||||
|
||||
|
||||
# Note: B
|
||||
|
||||
B
|
||||
|
||||
- [Test page](:/id-here "Test page")
|
||||
|
||||
|
||||
# Note: Page 1
|
||||
|
||||
Page 1
|
||||
|
||||
Page 1
|
||||
|
||||
Tuesday, January 13, 2026
|
||||
|
||||
1:42 PM
|
||||
|
||||
Test
|
||||
|
||||
|
||||
# Note: Page 2
|
||||
|
||||
Page 2
|
||||
|
||||
Page 2
|
||||
|
||||
Tuesday, January 13, 2026
|
||||
|
||||
1:42 PM
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
# Note: Test
|
||||
|
||||
Test
|
||||
|
||||
Test
|
||||
|
||||
Tuesday, January 13, 2026
|
||||
|
||||
1:44 PM
|
||||
|
||||
|
||||
# Note: Test page
|
||||
|
||||
Test page
|
||||
|
||||
Test page
|
||||
|
||||
Tuesday, January 13, 2026
|
||||
|
||||
1:45 PM
|
||||
|
||||
|
||||
# Note: Testing…
|
||||
|
||||
Testing…
|
||||
|
||||
Testing…
|
||||
|
||||
Friday, November 28, 2025
|
||||
|
||||
2:47 PM
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Link to page: [Page 2](onenote:https://d.docs.live.net/4c230b31b0dfb50f/Documents/OneNote%20Notebooks/test/Another%20section.one#Page%202§ion-id={C271F3B1-5F22-457F-9DEA-F2B938D9B3D7}&page-id={62800B88-EC08-4170-BDB6-885CBB47FF99}&end)
|
||||
|
||||
|
||||
# Note: Tést!
|
||||
|
||||
Tést!
|
||||
|
||||
- [Testing…](:/id-here "Testing…")
|
||||
- [A test](:/id-here "A test")
|
||||
|
||||
|
||||
# Note: Untitled Page 1
|
||||
|
||||
Untitled Page
|
||||
|
||||
Tuesday, January 13, 2026
|
||||
|
||||
1:45 PM
|
||||
|
||||
|
||||
# Note: ⅀⸨ Unicode ⸩
|
||||
|
||||
⅀⸨ Unicode ⸩
|
||||
|
||||
- [Untitled Page 1](:/id-here "Untitled Page 1")"
|
||||
`;
|
||||
|
||||
exports[`InteropService_Importer_OneNote should correctly import math formulas: Math 1`] = `
|
||||
" Math
|
||||
|
||||
|
||||
@@ -214,12 +214,10 @@ async function tryToGuessExtFromMimeType(response: any, mediaPath: string) {
|
||||
return newMediaPath;
|
||||
}
|
||||
|
||||
|
||||
const getFileExtension = (url: string, isDataUrl: boolean) => {
|
||||
let fileExt = isDataUrl ? mimeUtils.toFileExtension(mimeUtils.fromDataUrl(url)) : safeFileExtension(fileExtension(url).toLowerCase());
|
||||
if (!mimeUtils.fromFileExtension(fileExt)) fileExt = ''; // If the file extension is unknown - clear it.
|
||||
if (fileExt) fileExt = `.${fileExt}`;
|
||||
|
||||
return fileExt;
|
||||
};
|
||||
|
||||
|
||||
@@ -253,16 +253,16 @@ describe('Synchronizer.revisions', () => {
|
||||
const getNoteRevisions = () => {
|
||||
return Revision.allByType(BaseModel.TYPE_NOTE, note.id);
|
||||
};
|
||||
jest.advanceTimersByTime(200);
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
await Note.save({ id: note.id, title: 'note REV0' });
|
||||
jest.advanceTimersByTime(200);
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
await revisionService().collectRevisions(); // REV0
|
||||
expect(await getNoteRevisions()).toHaveLength(1);
|
||||
|
||||
const interimTime = Date.now();
|
||||
jest.advanceTimersByTime(200);
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
await Note.save({ id: note.id, title: 'note REV1' });
|
||||
await revisionService().collectRevisions(); // REV1
|
||||
@@ -273,6 +273,10 @@ describe('Synchronizer.revisions', () => {
|
||||
await switchClient(2);
|
||||
await synchronizerStart();
|
||||
|
||||
// Prevent a race condition whereby a revision is downloaded via the sync, then one of the same revisions is updated within the same millisecond via
|
||||
// deleteOldRevisions, and therefore is not uploaded via the sync because the sync_time matches
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
const revisions = await getNoteRevisions();
|
||||
expect(revisions).toHaveLength(2);
|
||||
expect(revisions[0].title_diff).toBe('[{"diffs":[[1,"note REV0"]],"start1":0,"start2":0,"length1":0,"length2":9}]');
|
||||
|
||||
108
packages/onenote-converter/Cargo.lock
generated
@@ -17,6 +17,12 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "0.7.15"
|
||||
@@ -93,7 +99,7 @@ dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"miniz_oxide",
|
||||
"miniz_oxide 0.4.4",
|
||||
"object",
|
||||
"rustc-demangle",
|
||||
]
|
||||
@@ -113,12 +119,30 @@ version = "3.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
|
||||
[[package]]
|
||||
name = "cab"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171228650e6721d5acc0868a462cd864f49ac5f64e4a42cde270406e64e404d2"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"flate2",
|
||||
"lzxd",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.0.96"
|
||||
@@ -168,6 +192,24 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.11.0"
|
||||
@@ -204,6 +246,16 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
@@ -270,6 +322,12 @@ version = "0.4.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "lzxd"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b29dffab797218e12e4df08ef5d15ab9efca2504038b1b32b9b32fc844b39c9"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
@@ -302,6 +360,22 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -455,6 +529,12 @@ version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.17"
|
||||
@@ -553,6 +633,7 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"askama",
|
||||
"bytes",
|
||||
"cab",
|
||||
"color-eyre",
|
||||
"console_error_panic_hook",
|
||||
"encoding_rs",
|
||||
@@ -652,6 +733,12 @@ dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@@ -710,6 +797,25 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.40"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"access": "public"
|
||||
},
|
||||
"description": "Used to import a OneNote archive into Joplin",
|
||||
"version": "3.5.1",
|
||||
"version": "3.6.0",
|
||||
"license": "MPL-2.0-no-copyleft-exception",
|
||||
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/onenote-converter",
|
||||
"main": "./renderer/pkg/renderer.js",
|
||||
|
||||
@@ -28,10 +28,31 @@ function normalizeAndWriteFile(filePath, data) {
|
||||
fs.writeFileSync(filePath, data);
|
||||
}
|
||||
|
||||
function fileReader(path) {
|
||||
const fd = fs.openSync(path);
|
||||
const size = fs.fstatSync(fd).size;
|
||||
return {
|
||||
read: (position, length) => {
|
||||
const data = Buffer.alloc(length);
|
||||
const sizeRead = fs.readSync(fd, data, { length, position });
|
||||
|
||||
// Make data.size match the number of bytes read:
|
||||
return data.subarray(0, sizeRead);
|
||||
},
|
||||
size: () => {
|
||||
return size;
|
||||
},
|
||||
close: () => {
|
||||
fs.closeSync(fd);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mkdirSyncRecursive,
|
||||
isDirectory,
|
||||
readDir,
|
||||
removePrefix,
|
||||
normalizeAndWriteFile,
|
||||
fileReader,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::io::{Read, Seek};
|
||||
|
||||
pub type ApiResult<T> = std::result::Result<T, std::io::Error>;
|
||||
pub trait FileHandle: Read + Seek {}
|
||||
|
||||
pub trait FileApiDriver: Send + Sync {
|
||||
fn is_directory(&self, path: &str) -> ApiResult<bool>;
|
||||
@@ -7,6 +10,7 @@ pub trait FileApiDriver: Send + Sync {
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> ApiResult<()>;
|
||||
fn make_dir(&self, path: &str) -> ApiResult<()>;
|
||||
fn exists(&self, path: &str) -> ApiResult<bool>;
|
||||
fn open_file(&self, path: &str) -> ApiResult<Box<dyn FileHandle>>;
|
||||
|
||||
// These functions correspond to the similarly-named
|
||||
// NodeJS path functions and should behave like the NodeJS
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod api;
|
||||
pub use api::ApiResult;
|
||||
pub use api::FileApiDriver;
|
||||
pub use api::FileHandle;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::ApiResult;
|
||||
use super::FileApiDriver;
|
||||
use super::FileHandle;
|
||||
use std::fs;
|
||||
use std::path;
|
||||
use std::path::Path;
|
||||
@@ -26,6 +27,10 @@ impl FileApiDriver for FileApiDriverImpl {
|
||||
fs::read(path)
|
||||
}
|
||||
|
||||
fn open_file(&self, path: &str) -> ApiResult<Box<dyn FileHandle>> {
|
||||
Ok(Box::new(fs::File::open(path)?))
|
||||
}
|
||||
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> ApiResult<()> {
|
||||
fs::write(path, data)
|
||||
}
|
||||
@@ -72,6 +77,8 @@ impl FileApiDriver for FileApiDriverImpl {
|
||||
}
|
||||
}
|
||||
|
||||
impl FileHandle for fs::File {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::file_api::FileApiDriver;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::ApiResult;
|
||||
use super::FileApiDriver;
|
||||
use super::FileHandle;
|
||||
use std::io::{BufReader, Read, Seek, SeekFrom};
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
use web_sys::js_sys;
|
||||
@@ -31,6 +33,27 @@ extern "C" {
|
||||
|
||||
#[wasm_bindgen(js_name = readDir, catch)]
|
||||
fn read_dir_js(path: &str) -> std::result::Result<JsValue, JsValue>;
|
||||
|
||||
#[wasm_bindgen(js_name = fileReader, catch)]
|
||||
fn open_file_handle(path: &str) -> std::result::Result<JsFileHandle, JsValue>;
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
type JsFileHandle;
|
||||
|
||||
#[wasm_bindgen(structural, method, catch)]
|
||||
fn read(
|
||||
this: &JsFileHandle,
|
||||
offset: usize,
|
||||
size: usize,
|
||||
) -> std::result::Result<Uint8Array, JsValue>;
|
||||
|
||||
#[wasm_bindgen(structural, method)]
|
||||
fn size(this: &JsFileHandle) -> usize;
|
||||
|
||||
#[wasm_bindgen(structural, method, catch)]
|
||||
fn close(this: &JsFileHandle) -> std::result::Result<(), JsValue>;
|
||||
}
|
||||
|
||||
#[wasm_bindgen(module = "fs")]
|
||||
@@ -97,6 +120,16 @@ impl FileApiDriver for FileApiDriverImpl {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_file(&self, path: &str) -> ApiResult<Box<dyn FileHandle>> {
|
||||
match open_file_handle(path) {
|
||||
Ok(handle) => {
|
||||
let file = BufReader::new(SeekableFileHandle { handle, offset: 0 });
|
||||
Ok(Box::new(file))
|
||||
}
|
||||
Err(e) => Err(handle_error(e, &format!("opening file {}", path))),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> ApiResult<()> {
|
||||
if let Err(error) = write_file(path, data) {
|
||||
Err(handle_error(error, &format!("writing file {}", path)))
|
||||
@@ -138,3 +171,87 @@ impl FileApiDriver for FileApiDriverImpl {
|
||||
join_path(path_1, path_2).unwrap().as_string().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
struct SeekableFileHandle {
|
||||
handle: JsFileHandle,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl Read for SeekableFileHandle {
|
||||
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
|
||||
let file_size = self.handle.size();
|
||||
let bytes_remaining = if self.offset < file_size {
|
||||
file_size - self.offset
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let maximum_read_size = bytes_remaining.min(out.len());
|
||||
match self.handle.read(self.offset, maximum_read_size) {
|
||||
Ok(data) => {
|
||||
let data = data.to_vec();
|
||||
let size = data.len();
|
||||
self.offset += size;
|
||||
|
||||
// Verify that handle.read respected the maximum length:
|
||||
if size > out.len() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"Invariant violation: Size read must be less than or equal to the maximum_read_size.",
|
||||
));
|
||||
}
|
||||
|
||||
let (target_mem, padding) = out.split_at_mut(size);
|
||||
target_mem.copy_from_slice(&data);
|
||||
padding.fill(0);
|
||||
|
||||
Ok(size)
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Read failed: {:?}.", error),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for SeekableFileHandle {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
match pos {
|
||||
SeekFrom::Start(pos) => {
|
||||
self.offset = pos as usize;
|
||||
}
|
||||
SeekFrom::Current(offset) => {
|
||||
// Disallow seeking to a negative position
|
||||
if offset < 0 && (-offset) as usize > self.offset {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Attempted to seek before the beginning of the file.",
|
||||
));
|
||||
}
|
||||
|
||||
self.offset = (self.offset as i64 + offset) as usize;
|
||||
}
|
||||
SeekFrom::End(offset) => {
|
||||
self.offset = self.handle.size();
|
||||
self.seek(SeekFrom::Current(offset))?;
|
||||
}
|
||||
}
|
||||
Ok(self.offset as u64)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SeekableFileHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Err(error) = self.handle.close() {
|
||||
// Use web_sys directly -- log_warn! can't be used from within the parser-utils package:
|
||||
let message: JsValue =
|
||||
format!("OneNote converter: Failed to close file: Error: {error:?}").into();
|
||||
web_sys::console::warn_1(&message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileHandle for BufReader<SeekableFileHandle> {}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod parse;
|
||||
pub mod reader;
|
||||
|
||||
pub use errors::Result;
|
||||
pub use file_api::FileHandle;
|
||||
pub use file_api::fs_driver;
|
||||
|
||||
pub type Reader<'a, 'b> = &'b mut crate::reader::Reader<'a>;
|
||||
|
||||
@@ -76,10 +76,17 @@ impl Parser {
|
||||
pub fn parse_section(&mut self, path: String) -> Result<Section> {
|
||||
log!("Parsing section: {:?}", path);
|
||||
let data = fs_driver().read_file(path.as_str())?;
|
||||
self.parse_section_from_data(&data, &path)
|
||||
}
|
||||
|
||||
/// Parse a OneNote section file from a byte array.
|
||||
/// The [path] is used to provide debugging information and determine
|
||||
/// the name of the section file.
|
||||
pub fn parse_section_from_data(&mut self, data: &[u8], path: &str) -> Result<Section> {
|
||||
let store = parse_onestore(&mut Reader::new(&data))?;
|
||||
|
||||
if store.get_type() != OneStoreType::Section {
|
||||
return Err(ErrorKind::NotASectionFile { file: path }.into());
|
||||
return Err(ErrorKind::NotASectionFile { file: String::from(path) }.into());
|
||||
}
|
||||
|
||||
let filename = fs_driver()
|
||||
|
||||
@@ -30,6 +30,7 @@ uuid = "1.1.2"
|
||||
widestring = "1.0.2"
|
||||
wasm-bindgen = "0.2"
|
||||
lazy_static = "1.4"
|
||||
cab = "0.6.0"
|
||||
parser = { path = "../parser" }
|
||||
parser-utils = { path = "../parser-utils" }
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
pub use parser::Parser;
|
||||
use std::panic;
|
||||
use sanitize_filename::sanitize;
|
||||
use std::{io::Read, panic};
|
||||
use wasm_bindgen::{JsError, prelude::wasm_bindgen};
|
||||
|
||||
use parser_utils::{fs_driver, log};
|
||||
use parser_utils::{FileHandle, fs_driver, log};
|
||||
|
||||
mod errors;
|
||||
mod notebook;
|
||||
@@ -34,8 +35,6 @@ fn _main(input_path: &str, output_dir: &str, base_path: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
pub fn convert(path: &str, output_dir: &str, base_path: &str) -> Result<()> {
|
||||
let mut parser = Parser::new();
|
||||
|
||||
let extension: String = fs_driver().get_file_extension(path);
|
||||
|
||||
match extension.as_str() {
|
||||
@@ -47,7 +46,7 @@ pub fn convert(path: &str, output_dir: &str, base_path: &str) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let section = parser.parse_section(path.to_owned())?;
|
||||
let section = Parser::new().parse_section(path.to_owned())?;
|
||||
|
||||
let section_output_dir = fs_driver().get_output_path(base_path, output_dir, path);
|
||||
section::Renderer::new().render(§ion, section_output_dir.to_owned())?;
|
||||
@@ -56,7 +55,7 @@ pub fn convert(path: &str, output_dir: &str, base_path: &str) -> Result<()> {
|
||||
let _name: String = fs_driver().get_file_name(path).expect("Missing file name");
|
||||
log!("Parsing .onetoc2 file: {}", _name);
|
||||
|
||||
let notebook = parser.parse_notebook(path.to_owned())?;
|
||||
let notebook = Parser::new().parse_notebook(path.to_owned())?;
|
||||
|
||||
let notebook_name = fs_driver()
|
||||
.get_parent_dir(path)
|
||||
@@ -71,8 +70,66 @@ pub fn convert(path: &str, output_dir: &str, base_path: &str) -> Result<()> {
|
||||
|
||||
notebook::Renderer::new().render(¬ebook, ¬ebook_name, ¬ebook_output_dir)?;
|
||||
}
|
||||
".onepkg" => {
|
||||
let file_data = fs_driver().open_file(path)?;
|
||||
convert_onepkg(file_data, output_dir)?;
|
||||
}
|
||||
ext => return Err(eyre!("Invalid file extension: {}, file: {}", ext, path)),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn convert_onepkg(file_data: Box<dyn FileHandle>, output_dir: &str) -> Result<()> {
|
||||
// .onepkg files are cabinet files
|
||||
let mut cabinet = cab::Cabinet::new(file_data)?;
|
||||
|
||||
let file_paths: Vec<String> = cabinet
|
||||
.folder_entries()
|
||||
.flat_map(|folder| folder.file_entries())
|
||||
.map(|entry| String::from(entry.name()))
|
||||
.collect();
|
||||
|
||||
let build_output_dir = |file_path_in_archive: &str| -> Result<(String, String)> {
|
||||
let mut output_path = String::from(output_dir);
|
||||
|
||||
// Split on both "\"s and "/"s since CAB archives seem to use Windows-style paths,
|
||||
// where both / and \ are valid path separators.
|
||||
let is_path_separator = |c| c == '\\' || c == '/';
|
||||
let path_segments: Vec<&str> = file_path_in_archive.split(is_path_separator).collect();
|
||||
|
||||
let path_segments_without_filename = &path_segments[0..path_segments.len() - 1];
|
||||
for part in path_segments_without_filename {
|
||||
output_path = fs_driver().join(&output_path, &sanitize(part));
|
||||
fs_driver().make_dir(&output_path)?;
|
||||
}
|
||||
|
||||
let file_name = path_segments.last().unwrap_or(&"");
|
||||
Ok((output_path, sanitize(file_name)))
|
||||
};
|
||||
|
||||
let mut parser = Parser::new();
|
||||
for file_path in file_paths {
|
||||
log!("File path {file_path}");
|
||||
|
||||
if !file_path.ends_with(".one") {
|
||||
log!("Skipping non-section file {file_path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
log!("Rendering {file_path}");
|
||||
|
||||
let data = {
|
||||
let mut file_data = cabinet.read_file(&file_path)?;
|
||||
let mut data = Vec::new();
|
||||
file_data.read_to_end(&mut data)?;
|
||||
data
|
||||
};
|
||||
|
||||
let (output_path, file_name) = build_output_dir(&file_path)?;
|
||||
let section = parser.parse_section_from_data(&data, &file_name)?;
|
||||
section::Renderer::new().render(§ion, output_path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// For a detailed explanation regarding each configuration property, visit:
|
||||
// https://jestjs.io/docs/en/configuration.html
|
||||
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/pdf-viewer",
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.0",
|
||||
"description": "Provides embedded PDF viewers for Joplin",
|
||||
"main": "dist/main.js",
|
||||
"types": "src/main.ts",
|
||||
@@ -27,7 +27,7 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/pdfjs-dist": "2.10.378",
|
||||
"@types/react": "18.3.25",
|
||||
"@types/react": "18.3.26",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"babel-jest": "29.7.0",
|
||||
@@ -45,7 +45,7 @@
|
||||
"@fortawesome/fontawesome-svg-core": "6.1.2",
|
||||
"@fortawesome/free-solid-svg-icons": "6.1.2",
|
||||
"@fortawesome/react-fontawesome": "0.2.6",
|
||||
"@joplin/lib": "~3.5",
|
||||
"@joplin/lib": "~3.6",
|
||||
"async-mutex": "0.5.0",
|
||||
"pdfjs-dist": "2.16.105",
|
||||
"react": "18.3.1",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
|
||||
testMatch: [
|
||||
'**/*.test.js',
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/plugin-repo-cli",
|
||||
"version": "3.5.3",
|
||||
"version": "3.6.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"bin": "./dist/index.js",
|
||||
@@ -18,9 +18,9 @@
|
||||
"author": "",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@joplin/lib": "^3.5.1",
|
||||
"@joplin/tools": "^3.5.1",
|
||||
"@joplin/utils": "^3.5.1",
|
||||
"@joplin/lib": "~3.6",
|
||||
"@joplin/tools": "~3.6",
|
||||
"@joplin/utils": "~3.6",
|
||||
"fs-extra": "11.3.2",
|
||||
"gh-release-assets": "2.0.1",
|
||||
"node-fetch": "2.6.7",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@joplin/react-native-alarm-notification",
|
||||
"title": "React Native Alarm Notification for Joplin. Forked from https://github.com/emekalites/react-native-alarm-notification",
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.0",
|
||||
"description": "schedule alarm with notification in react-native",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/react-native-saf-x",
|
||||
"version": "3.5.1",
|
||||
"version": "3.6.0",
|
||||
"description": "a module to help work with scoped storages on android easily",
|
||||
"main": "src/index",
|
||||
"react-native": "src/index",
|
||||
|
||||
@@ -55,6 +55,7 @@ const rules: RendererRules = {
|
||||
fountain: require('./MdToHtml/rules/fountain').default,
|
||||
abc: require('./MdToHtml/rules/abc').default,
|
||||
mermaid: require('./MdToHtml/rules/mermaid').default,
|
||||
externalEmbed: require('./MdToHtml/rules/externalEmbed').default,
|
||||
source_map: require('./MdToHtml/rules/source_map').default,
|
||||
tableHorizontallyScrollable: require('./MdToHtml/rules/tableHorizontallyScrollable').default,
|
||||
};
|
||||
|
||||
108
packages/renderer/MdToHtml/rules/externalEmbed.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type * as MarkdownIt from 'markdown-it';
|
||||
|
||||
const extractVideoId = (url: string) => {
|
||||
const pattern = /^https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/;
|
||||
const match = url.match(pattern);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const plugin = (markdownIt: MarkdownIt) => {
|
||||
const defaultLinkOpenRender = markdownIt.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (self.renderToken as any)(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
const defaultTextRender = markdownIt.renderer.rules.text || function(tokens, idx) {
|
||||
return tokens[idx].content;
|
||||
};
|
||||
|
||||
const defaultLinkCloseRender = markdownIt.renderer.rules.link_close || function(tokens, idx, options, env, self) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (self.renderToken as any)(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
// Track active embed state
|
||||
let activeEmbedVideo: { videoId: string; originalUrl: string } | null = null;
|
||||
|
||||
markdownIt.renderer.rules.link_open = function(tokens, idx, options, env, self) {
|
||||
const token = tokens[idx];
|
||||
const href = token.attrGet('href');
|
||||
|
||||
// Check if this is a standalone YouTube link (next token is text matching href, then link_close)
|
||||
if (href &&
|
||||
idx + 2 < tokens.length &&
|
||||
tokens[idx + 1].type === 'text' &&
|
||||
tokens[idx + 1].content === href &&
|
||||
tokens[idx + 2].type === 'link_close') {
|
||||
|
||||
const videoId = extractVideoId(href);
|
||||
|
||||
if (videoId) {
|
||||
activeEmbedVideo = { videoId, originalUrl: href };
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLinkOpenRender(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
markdownIt.renderer.rules.text = function(tokens, idx, options, env, self) {
|
||||
// Skip text content if we're in an active embed
|
||||
if (activeEmbedVideo) {
|
||||
return '';
|
||||
}
|
||||
return defaultTextRender(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
markdownIt.renderer.rules.link_close = function(tokens, idx, options, env, self) {
|
||||
// Check if we have an active embed to close
|
||||
if (activeEmbedVideo) {
|
||||
const videoId = activeEmbedVideo.videoId;
|
||||
const originalUrl = activeEmbedVideo.originalUrl;
|
||||
activeEmbedVideo = null; // Clear state
|
||||
|
||||
const embedUrl = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}`;
|
||||
const escapedUrl = markdownIt.utils.escapeHtml(originalUrl);
|
||||
|
||||
return `
|
||||
<div class="joplin-editable">
|
||||
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">${escapedUrl}</span>
|
||||
<div class="joplin-youtube-player-rendered">
|
||||
<iframe src="${embedUrl}" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return defaultLinkCloseRender(tokens, idx, options, env, self);
|
||||
};
|
||||
};
|
||||
|
||||
const assets = () => {
|
||||
return [
|
||||
{
|
||||
inline: true,
|
||||
mime: 'text/css',
|
||||
text: `
|
||||
.joplin-youtube-player-rendered {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.joplin-youtube-player-rendered iframe {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
`,
|
||||
},
|
||||
].map(e => {
|
||||
return {
|
||||
source: 'youtube',
|
||||
...e,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
plugin,
|
||||
assets,
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
const fountain = require('../../vendor/fountain.min.js');
|
||||
|
||||
const pluginAssets = function() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Theme is defined in @joplin/lib and we don't import it here
|
||||
const pluginAssets = function(theme: any) {
|
||||
return [
|
||||
{
|
||||
inline: true,
|
||||
@@ -15,13 +16,32 @@ const pluginAssets = function() {
|
||||
}
|
||||
|
||||
.fountain .title-page,
|
||||
.fountain .page {
|
||||
box-shadow: 0 0 5px rgba(0,0,0,0.1);
|
||||
border: 1px solid #d2d2d2;
|
||||
padding: 10%;
|
||||
.fountain .page {
|
||||
padding: 1em 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.fountain .title-page {
|
||||
border-bottom: 1px solid ${theme.dividerColor};
|
||||
}
|
||||
|
||||
@media print {
|
||||
.fountain .title-page,
|
||||
.fountain .page {
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.fountain .title-page {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fountain hr {
|
||||
border: none;
|
||||
border-top: 1px solid ${theme.dividerColor};
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
.fountain h1,
|
||||
.fountain h2,
|
||||
.fountain h3,
|
||||
@@ -121,6 +141,7 @@ function renderFountainScript(markdownIt: any, content: string) {
|
||||
}
|
||||
|
||||
return `
|
||||
<!-- joplin-metadata-print-title = false -->
|
||||
<div class="fountain joplin-editable">
|
||||
<pre class="joplin-source" data-joplin-language="fountain" data-joplin-source-open="\`\`\`fountain " data-joplin-source-close=" \`\`\` ">${markdownIt.utils.escapeHtml(content)}</pre>
|
||||
${titlePageHtml}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// For a detailed explanation regarding each configuration property, visit:
|
||||
// https://jestjs.io/docs/en/configuration.html
|
||||
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/renderer",
|
||||
"version": "3.5.1",
|
||||
"version": "3.6.0",
|
||||
"description": "The Joplin note renderer, used the mobile and desktop application",
|
||||
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/renderer",
|
||||
"main": "index.js",
|
||||
@@ -32,7 +32,7 @@
|
||||
"dependencies": {
|
||||
"@joplin/fork-htmlparser2": "^4.1.60",
|
||||
"@joplin/fork-uslug": "^2.0.3",
|
||||
"@joplin/utils": "^3.5.1",
|
||||
"@joplin/utils": "~3.6",
|
||||
"@types/json5": "2.2.0",
|
||||
"abcjs": "6.5.2",
|
||||
"font-awesome-filetypes": "2.1.0",
|
||||
|
||||
1
packages/server/.gitignore
vendored
@@ -7,4 +7,5 @@ db-*.sqlite
|
||||
logs/
|
||||
tests/temp/
|
||||
temp/
|
||||
resource/
|
||||
.env
|
||||
@@ -1,4 +1,8 @@
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
|
||||
testMatch: [
|
||||
'**/*.test.js',
|
||||
],
|
||||
|
||||
@@ -8,6 +8,6 @@ shimInit({ nodeSqlite });
|
||||
// tests can take more time since we do integration testing too. The share tests
|
||||
// in particular can take a while.
|
||||
|
||||
jest.setTimeout(60 * 1000);
|
||||
jest.setTimeout(120 * 1000);
|
||||
|
||||
process.env.JOPLIN_IS_TESTING = '1';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "3.5.2",
|
||||
"version": "3.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "yarn build && JOPLIN_IS_TESTING=1 nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
@@ -25,10 +25,11 @@
|
||||
"@authenio/samlify-xmllint-wasm": "1.0.1",
|
||||
"@aws-sdk/client-s3": "3.928.0",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
"@joplin/lib": "~3.5",
|
||||
"@joplin/renderer": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@joplin/lib": "~3.6",
|
||||
"@joplin/renderer": "~3.6",
|
||||
"@joplin/utils": "~3.6",
|
||||
"@koa/cors": "3.4.3",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/uuid": "10.0.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"bulma": "1.0.4",
|
||||
@@ -47,21 +48,23 @@
|
||||
"node-os-utils": "1.3.7",
|
||||
"nodemailer": "6.10.1",
|
||||
"nodemon": "3.1.10",
|
||||
"otplib": "12.0.1",
|
||||
"pg": "8.16.3",
|
||||
"pretty-bytes": "5.6.0",
|
||||
"prettycron": "0.10.0",
|
||||
"qrcode": "1.5.4",
|
||||
"query-string": "7.1.3",
|
||||
"rate-limiter-flexible": "7.2.0",
|
||||
"rate-limiter-flexible": "7.3.2",
|
||||
"raw-body": "3.0.1",
|
||||
"samlify": "2.10.1",
|
||||
"sqlite3": "5.1.6",
|
||||
"stripe": "8.222.0",
|
||||
"stripe": "13.9.0",
|
||||
"uuid": "11.1.0",
|
||||
"yargs": "17.7.2",
|
||||
"zxcvbn": "4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~3.5",
|
||||
"@joplin/tools": "~3.6",
|
||||
"@rmp135/sql-ts": "1.18.1",
|
||||
"@types/bcryptjs": "2.4.6",
|
||||
"@types/formidable": "2.0.6",
|
||||
@@ -82,6 +85,7 @@
|
||||
"jest-expect-message": "1.1.3",
|
||||
"jsdom": "26.1.0",
|
||||
"node-mocks-http": "1.17.2",
|
||||
"short-uuid": "4.2.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
|
||||
@@ -6,12 +6,17 @@ html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.is-admin-page div.main-container,
|
||||
.is-admin-page div.navbar-container {
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
div.navbar-container {
|
||||
.is-admin-page div.navbar-container {
|
||||
padding: 0 3rem;
|
||||
}
|
||||
|
||||
@@ -102,3 +107,25 @@ abbr[title] {
|
||||
text-underline-offset: 2px;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
#login-form {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
#recovery-codes li {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#recovery-codes li>span {
|
||||
font-size: 1.3em;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#recovery-codes li>span[data-is-code-used="1"] {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.application-item:nth-of-type(odd) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ChangeType, Event } from './services/database/types';
|
||||
import { DatabaseConfig, DatabaseConfigClient } from './utils/types';
|
||||
import { createDb } from './tools/dbTools';
|
||||
import { msleep } from './utils/time';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
const eventId1 = '4f405391-bd72-4a4f-809f-344fc6cd4b31';
|
||||
const eventId2 = '4f405391-bd72-4a4f-809f-344fc6cd4b32';
|
||||
@@ -115,7 +116,7 @@ describe('db.replication', () => {
|
||||
expect(result.items.length).toBe(0);
|
||||
|
||||
// But we still get the item because it doesn't use the slave database
|
||||
expect((await models().item().loadAsJoplinItem(folderItem.id)).title).toBe('title 1');
|
||||
expect((await models().item().loadAsJoplinItem<FolderEntity>(folderItem.id)).title).toBe('title 1');
|
||||
|
||||
// After sync, we should get the change
|
||||
await sqliteSyncSlave(db(), dbSlave());
|
||||
@@ -130,7 +131,7 @@ describe('db.replication', () => {
|
||||
expect(result.items.length).toBe(0);
|
||||
|
||||
// But we get the latest item if requesting it directly
|
||||
expect((await models().item().loadAsJoplinItem(folderItem.id)).title).toBe('title 2');
|
||||
expect((await models().item().loadAsJoplinItem<FolderEntity>(folderItem.id)).title).toBe('title 2');
|
||||
|
||||
// After sync, we should get the change
|
||||
await sqliteSyncSlave(db(), dbSlave());
|
||||
|
||||
@@ -158,6 +158,12 @@ export const isSqlite = (db: DbConnection) => {
|
||||
return clientType(db) === DatabaseConfigClient.SQLite;
|
||||
};
|
||||
|
||||
export const getEmptyIp = (db: DbConnection): string | null => {
|
||||
// PostgreSQL uses inet type which doesn't accept empty strings, only null or valid IPs
|
||||
// SQLite uses string type with NOT NULL constraint, so we use empty strings
|
||||
return isPostgres(db) ? null : '';
|
||||
};
|
||||
|
||||
export const setCollateC = async (db: DbConnection, tableName: string, columnName: string): Promise<void> => {
|
||||
if (!isPostgres(db)) return;
|
||||
await db.raw(`ALTER TABLE ${tableName} ALTER COLUMN ${columnName} SET DATA TYPE character varying(32) COLLATE "C"`);
|
||||
|
||||
@@ -56,6 +56,8 @@ const defaultEnvValues: EnvVariables = {
|
||||
USER_CONTENT_BASE_URL: '',
|
||||
API_BASE_URL: '',
|
||||
JOPLINAPP_BASE_URL: 'https://joplinapp.org',
|
||||
TERMS_URL: '',
|
||||
PRIVACY_URL: '',
|
||||
|
||||
// ==================================================
|
||||
// Database config
|
||||
@@ -130,6 +132,13 @@ const defaultEnvValues: EnvVariables = {
|
||||
USER_DATA_AUTO_DELETE_ENABLED: false,
|
||||
USER_DATA_AUTO_DELETE_AFTER_DAYS: 90,
|
||||
|
||||
// ==================================================
|
||||
// ==================================================
|
||||
// MFA - 32+ bytes hex string
|
||||
// ==================================================
|
||||
MFA_ENCRYPTION_KEY: '',
|
||||
MFA_ENABLED: 0,
|
||||
|
||||
// ==================================================
|
||||
// Events deletion
|
||||
// ==================================================
|
||||
@@ -205,6 +214,8 @@ export interface EnvVariables {
|
||||
USER_CONTENT_BASE_URL: string;
|
||||
API_BASE_URL: string;
|
||||
JOPLINAPP_BASE_URL: string;
|
||||
TERMS_URL: string;
|
||||
PRIVACY_URL: string;
|
||||
|
||||
DB_CLIENT: string;
|
||||
DB_SLOW_QUERY_LOG_ENABLED: boolean;
|
||||
@@ -253,6 +264,9 @@ export interface EnvVariables {
|
||||
USER_DATA_AUTO_DELETE_ENABLED: boolean;
|
||||
USER_DATA_AUTO_DELETE_AFTER_DAYS: number;
|
||||
|
||||
MFA_ENCRYPTION_KEY: string;
|
||||
MFA_ENABLED: number;
|
||||
|
||||
EVENTS_AUTO_DELETE_ENABLED: boolean;
|
||||
EVENTS_AUTO_DELETE_AFTER_DAYS: number;
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import { AppContext, KoaNext } from '../utils/types';
|
||||
import { contextSessionId } from '../utils/requestUtils';
|
||||
|
||||
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
|
||||
const models = ctx.joplin.models;
|
||||
const sessionId = contextSessionId(ctx, false);
|
||||
const owner = sessionId ? await ctx.joplin.models.session().sessionUser(sessionId) : null;
|
||||
const owner = sessionId ? await models.session().sessionUser(sessionId) : null;
|
||||
ctx.joplin.owner = owner;
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export const up = async (db: DbConnection) => {
|
||||
await db.schema.alterTable('users', (table: Knex.CreateTableBuilder) => {
|
||||
table.string('totp_secret').defaultTo('').notNullable();
|
||||
});
|
||||
};
|
||||
|
||||
export const down = async (db: DbConnection) => {
|
||||
await db.schema.alterTable('users', (table: Knex.CreateTableBuilder) => {
|
||||
table.dropColumn('totp_secret');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection, isPostgres } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<void> {
|
||||
await db.schema.createTable('applications', (table: Knex.CreateTableBuilder) => {
|
||||
table.uuid('id').unique().notNullable();
|
||||
table.string('user_id', 32).notNullable().defaultTo('');
|
||||
table.text('password', 'mediumtext').notNullable().defaultTo('');
|
||||
|
||||
table.string('version', 16).notNullable().defaultTo('');
|
||||
table.integer('platform').notNullable();
|
||||
if (isPostgres(db)) {
|
||||
table.specificType('ip', 'inet');
|
||||
} else {
|
||||
table.string('ip', 64).notNullable();
|
||||
}
|
||||
table.integer('type').notNullable();
|
||||
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
table.bigInteger('last_access_time').nullable().defaultTo(0);
|
||||
|
||||
table.index('user_id');
|
||||
});
|
||||
|
||||
await db.schema.alterTable('sessions', (table: Knex.CreateTableBuilder) => {
|
||||
table.uuid('application_id').nullable().defaultTo(null);
|
||||
|
||||
table.index('application_id');
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(db: DbConnection): Promise<void> {
|
||||
await db.schema.dropTable('applications');
|
||||
await db.schema.alterTable('sessions', (table: Knex.CreateTableBuilder) => {
|
||||
table.dropColumn('application_id');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<void> {
|
||||
await db.schema.createTable('recovery_codes', (table: Knex.CreateTableBuilder) => {
|
||||
table.uuid('id').unique().notNullable();
|
||||
table.string('user_id', 32).notNullable().defaultTo('');
|
||||
table.string('code', 16).notNullable().defaultTo('');
|
||||
table.specificType('is_used', 'smallint').defaultTo(1).notNullable();
|
||||
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(db: DbConnection): Promise<void> {
|
||||
await db.schema.dropTable('recovery_codes');
|
||||
}
|
||||
48
packages/server/src/models/ApplicationModel.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../utils/testing/testUtils';
|
||||
import { AccountType } from './UserModel';
|
||||
|
||||
describe('ApplicationModel', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('ApplicationModel');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should throw if applicationAuthId is not an uuid', async () => {
|
||||
expect(models().application().createAppPassword('not-uuid')).rejects.toThrow('Application not authorized yet.');
|
||||
});
|
||||
|
||||
test('should generate a notification after an application is authorized', async () => {
|
||||
const user = await models().user().save({
|
||||
email: 'test@example.com',
|
||||
password: '111111',
|
||||
});
|
||||
await models().application().createPreLoginRecord('mock-application-id', '127.0.0.1', 'mock-version', 'mock-platform', 'mock-type');
|
||||
await models().application().onAuthorizeUse('mock-application-id', user.id);
|
||||
|
||||
const notifications = await models().notification().allUnreadByUserId(user.id);
|
||||
expect(notifications.length).toBe(1);
|
||||
expect(notifications[0].message).toBe('You have successfully authorised your application');
|
||||
});
|
||||
|
||||
test('should register the application with the subscription when creating the app password', async () => {
|
||||
const { user } = await models().subscription().saveUserAndSubscription(
|
||||
'toto@example.com',
|
||||
'Toto',
|
||||
AccountType.Pro,
|
||||
'STRIPE_USER_ID',
|
||||
'STRIPE_SUB_ID',
|
||||
);
|
||||
|
||||
const appId = 'mock-application-id';
|
||||
await models().application().createPreLoginRecord(appId, '127.0.0.1', 'mock-version', 'mock-platform', 'mock-type');
|
||||
await models().application().onAuthorizeUse(appId, user.id);
|
||||
});
|
||||
});
|
||||
256
packages/server/src/models/ApplicationModel.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
|
||||
import BaseModel, { AclAction, UuidType } from './BaseModel';
|
||||
import { Application, NotificationLevel, User, Uuid } from '../services/database/types';
|
||||
import { createSecureRandom } from '@joplin/lib/uuid';
|
||||
import { hashPassword, checkPassword } from '../utils/auth';
|
||||
import { ErrorBadRequest, ErrorForbidden, ErrorUnprocessableEntity } from '../utils/errors';
|
||||
import { ApplicationPlatform, ApplicationType } from '@joplin/lib/types';
|
||||
import { validate } from 'uuid';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { NotificationKey } from './NotificationModel';
|
||||
import { getEmptyIp } from '../db';
|
||||
|
||||
const logger = Logger.create('ApplicationModel');
|
||||
|
||||
export type ActiveApplication = Pick<Application, 'id' | 'version' | 'platform' | 'ip' | 'created_time' | 'last_access_time'>;
|
||||
|
||||
export type AppAuthResponse = {
|
||||
password: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
|
||||
type Client = {
|
||||
ip: string;
|
||||
version?: string;
|
||||
platform?: ApplicationPlatform;
|
||||
type?: ApplicationType;
|
||||
};
|
||||
|
||||
type ApplicationNotFound = {
|
||||
status: 'unfinished';
|
||||
message: string;
|
||||
};
|
||||
|
||||
type ApplicationCredential = {
|
||||
id: string;
|
||||
password: string;
|
||||
status: 'finished';
|
||||
};
|
||||
|
||||
export type CreateAppPasswordResponse = ApplicationNotFound | ApplicationCredential;
|
||||
|
||||
const getPlatform = (platform: string) => {
|
||||
const platformAsInt = parseInt(platform, 10);
|
||||
if (ApplicationPlatform.Linux === platformAsInt) return ApplicationPlatform.Linux;
|
||||
if (ApplicationPlatform.Windows === platformAsInt) return ApplicationPlatform.Windows;
|
||||
if (ApplicationPlatform.MacOs === platformAsInt) return ApplicationPlatform.MacOs;
|
||||
if (ApplicationPlatform.Android === platformAsInt) return ApplicationPlatform.Android;
|
||||
if (ApplicationPlatform.Ios === platformAsInt) return ApplicationPlatform.Ios;
|
||||
return ApplicationPlatform.Unknown;
|
||||
};
|
||||
|
||||
const getType = (type: string) => {
|
||||
const typeAsInt = parseInt(type, 10);
|
||||
if (ApplicationType.Desktop === typeAsInt) return ApplicationType.Desktop;
|
||||
if (ApplicationType.Mobile === typeAsInt) return ApplicationType.Mobile;
|
||||
if (ApplicationType.Cli === typeAsInt) return ApplicationType.Cli;
|
||||
return ApplicationType.Unknown;
|
||||
};
|
||||
|
||||
export default class ApplicationModel extends BaseModel<Application> {
|
||||
|
||||
protected get tableName(): string {
|
||||
return 'applications';
|
||||
}
|
||||
|
||||
protected uuidType(): UuidType {
|
||||
return UuidType.Native;
|
||||
}
|
||||
|
||||
private applicationAuthIdKey = (applicationAuthId: string) => `ApplicationAuthId::${applicationAuthId}`;
|
||||
|
||||
public async createPreLoginRecord(applicationAuthId: string, ip: string, version?: string, platform?: string, type?: string) {
|
||||
const client: Client = {
|
||||
ip: ip,
|
||||
version: version || '',
|
||||
platform: getPlatform(platform),
|
||||
type: getType(type),
|
||||
};
|
||||
return this.models().keyValue().setValue(
|
||||
this.applicationAuthIdKey(applicationAuthId),
|
||||
JSON.stringify(client),
|
||||
);
|
||||
}
|
||||
|
||||
private async getByApplicationAuthId(applicationAuthId: string) {
|
||||
const clientUnparsed = await this.models().keyValue().value<string>(this.applicationAuthIdKey(applicationAuthId));
|
||||
|
||||
let client = null;
|
||||
|
||||
try {
|
||||
client = JSON.parse(clientUnparsed);
|
||||
} catch (error) {
|
||||
// Mostly likely this is failing because the application was already authorized
|
||||
// and the value stored in the keyValue now is the ID to an application record
|
||||
throw new ErrorUnprocessableEntity(`Application Auth Id has already been used, go back to the Joplin application to finish the login process: ${applicationAuthId}`);
|
||||
}
|
||||
|
||||
return client as Client;
|
||||
}
|
||||
|
||||
// Joplin now has 2 methods of login, the one where the user uses
|
||||
// his email as the identifier and other where the client application
|
||||
// will use a generate id as the identifier
|
||||
//
|
||||
// If the id is a uuid means that is an application login
|
||||
public isApplicationId(id: string) {
|
||||
return validate(id);
|
||||
}
|
||||
|
||||
private async createApplicationRecord(userId: Uuid, client: Client) {
|
||||
return this.save({
|
||||
user_id: userId,
|
||||
ip: client.ip || getEmptyIp(this.db),
|
||||
version: client.version,
|
||||
platform: client.platform,
|
||||
type: client.type,
|
||||
});
|
||||
}
|
||||
|
||||
// if password is already set it means that the credentials retrieval
|
||||
// for this application has already happened
|
||||
private async getValidApplicationBeforeFirstLogin(applicationAuthId: string): Promise<Application> {
|
||||
const applicationAuthIdInformation = await this.models().keyValue().value<string>(this.applicationAuthIdKey(applicationAuthId));
|
||||
if (!validate(applicationAuthIdInformation)) throw new ErrorForbidden('Application not authorized yet.');
|
||||
|
||||
const application = await this.db(this.tableName)
|
||||
.select(this.defaultFields)
|
||||
.where({ id: applicationAuthIdInformation, password: '' })
|
||||
.first();
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
private generatePassword() {
|
||||
return createSecureRandom();
|
||||
}
|
||||
|
||||
public async createAppPassword(applicationAuthId: string): Promise<CreateAppPasswordResponse> {
|
||||
return this.withTransaction(async () => {
|
||||
const application = await this.getValidApplicationBeforeFirstLogin(applicationAuthId);
|
||||
if (!application) return { status: 'unfinished', message: 'Application not found from Application Auth Id.' };
|
||||
|
||||
const password = this.generatePassword();
|
||||
const hashedPassword = await hashPassword(password);
|
||||
await this.db(this.tableName)
|
||||
.update({ password: hashedPassword })
|
||||
.where({ id: application.id });
|
||||
|
||||
await this.models().keyValue().deleteValue(this.applicationAuthIdKey(applicationAuthId));
|
||||
|
||||
return { id: application.id, password, status: 'finished' };
|
||||
}, 'ApplicationModel::createAppPassword');
|
||||
}
|
||||
|
||||
public async updateOnNewLogin(applicationId: string, client: Client) {
|
||||
if (!this.isApplicationId(applicationId)) return;
|
||||
|
||||
const ip = client.ip;
|
||||
const platform = client.platform ?? ApplicationPlatform.Unknown;
|
||||
const type = client.type ?? ApplicationType.Unknown;
|
||||
const version = client.version ?? '';
|
||||
await this.db(this.tableName)
|
||||
.update({ last_access_time: Date.now(), ip, platform, type, version })
|
||||
.where({ id: applicationId });
|
||||
}
|
||||
|
||||
public async login(id: string, password: string) {
|
||||
const application = await this.load(id, { fields: ['id', 'password', 'user_id'] });
|
||||
|
||||
if (!application) {
|
||||
throw new ErrorForbidden(`Could not find application with id: "${id}"`);
|
||||
}
|
||||
|
||||
if (!(await checkPassword(password, application.password))) {
|
||||
throw new ErrorForbidden('Invalid application or application password', { details: { application: id } });
|
||||
}
|
||||
|
||||
const user = await this.models().user().load(application.user_id);
|
||||
if (!user) {
|
||||
logger.error(`Login was successful, but user was not found. User id: ${application.user_id}`);
|
||||
throw new ErrorUnprocessableEntity('Login was successful, but user was not found');
|
||||
}
|
||||
|
||||
return { user, application };
|
||||
}
|
||||
|
||||
public async onAuthorizeUse(applicationAuthId: string, userId: string) {
|
||||
return this.withTransaction(async () => {
|
||||
const client = await this.getByApplicationAuthId(applicationAuthId);
|
||||
|
||||
if (!client) {
|
||||
throw new ErrorBadRequest(`Check if you are not already logged in on your Joplin application, client associated with this application auth id not found: ${applicationAuthId}`);
|
||||
}
|
||||
|
||||
const application = await this.createApplicationRecord(userId, client);
|
||||
|
||||
await this.models().keyValue().setValue(this.applicationAuthIdKey(applicationAuthId), application.id);
|
||||
await this.models().notification().add(userId, NotificationKey.Any, NotificationLevel.Important, 'You have successfully authorised your application');
|
||||
|
||||
return application;
|
||||
}, 'ApplicationModel::onAuthorizeUse');
|
||||
}
|
||||
|
||||
public async activeApplications(userId: Uuid): Promise<ActiveApplication[]> {
|
||||
if (!userId) return [];
|
||||
|
||||
const result = await this.db
|
||||
.select(
|
||||
'a.id',
|
||||
'a.version',
|
||||
'a.platform',
|
||||
'a.ip',
|
||||
'a.created_time',
|
||||
'a.last_access_time',
|
||||
)
|
||||
.from('applications as a')
|
||||
.where('a.user_id', userId)
|
||||
.orderBy('a.last_access_time', 'desc');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async delete(applicationId: Uuid) {
|
||||
await this.withTransaction(async () => {
|
||||
await super.delete(applicationId);
|
||||
await super.models().session().deleteByApplicationId(applicationId);
|
||||
}, 'ApplicationModel::delete');
|
||||
}
|
||||
|
||||
public getPlatformName(platform: number) {
|
||||
if (ApplicationPlatform.Linux === platform) return 'Linux';
|
||||
if (ApplicationPlatform.Windows === platform) return 'Windows';
|
||||
if (ApplicationPlatform.MacOs === platform) return 'MacOS';
|
||||
if (ApplicationPlatform.Android === platform) return 'Android';
|
||||
if (ApplicationPlatform.Ios === platform) return 'iOS';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
public getTypeName(type: number) {
|
||||
if (ApplicationType.Desktop === type) return 'Desktop';
|
||||
if (ApplicationType.Mobile === type) return 'Mobile';
|
||||
if (ApplicationType.Cli === type) return 'Cli';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
public async checkIfAllowed(user: User, _action: AclAction, resource: Application = null): Promise<void> {
|
||||
if (user.is_admin) return;
|
||||
if (resource.user_id !== user.id) throw new ErrorForbidden();
|
||||
}
|
||||
|
||||
public async deleteByUserId(userId: Uuid) {
|
||||
const query = this.db(this.tableName).where('user_id', '=', userId);
|
||||
await query.delete();
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,10 @@ export default abstract class BaseModel<T> {
|
||||
return this.defaultFields_.slice();
|
||||
}
|
||||
|
||||
protected get defaultFieldsWithPrefix(): string[] {
|
||||
return this.defaultFields.map(f => `${this.tableName}.${f}`);
|
||||
}
|
||||
|
||||
public async checkIfAllowed(_user: User, _action: AclAction, _resource: T = null): Promise<void> {
|
||||
throw new Error('Must be overriden');
|
||||
}
|
||||
@@ -302,13 +306,17 @@ export default abstract class BaseModel<T> {
|
||||
return output;
|
||||
}
|
||||
|
||||
protected objectToApiOutput(object: T): T {
|
||||
protected async objectToApiOutput(object: T): Promise<T> {
|
||||
return { ...object };
|
||||
}
|
||||
|
||||
public toApiOutput(object: T | T[]): T | T[] {
|
||||
public async toApiOutput(object: T | T[]): Promise<T | T[]> {
|
||||
if (Array.isArray(object)) {
|
||||
return object.map(f => this.objectToApiOutput(f));
|
||||
const output: T[] = [];
|
||||
for (let i = 0; i < object.length; i++) {
|
||||
output.push(await this.objectToApiOutput(object[i]));
|
||||
}
|
||||
return output;
|
||||
} else {
|
||||
return this.objectToApiOutput(object);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Uuid, Email, EmailSender } from '../services/database/types';
|
||||
import BaseModel from './BaseModel';
|
||||
|
||||
export interface EmailToSend {
|
||||
sender_id: EmailSender;
|
||||
sender_id?: EmailSender;
|
||||
recipient_email: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
@@ -28,6 +28,11 @@ export default class EmailModel extends BaseModel<Email> {
|
||||
}
|
||||
|
||||
public async push(email: EmailToSend): Promise<Email | null> {
|
||||
email = {
|
||||
sender_id: EmailSender.NoReply,
|
||||
...email,
|
||||
};
|
||||
|
||||
if (email.key) {
|
||||
const existingEmail = await this.byRecipientAndKey(email.recipient_email, email.key);
|
||||
if (existingEmail) return null; // noop - the email has already been sent
|
||||
|
||||
@@ -23,65 +23,6 @@ describe('ItemModel', () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
// test('should find exclusively owned items 1', async function() {
|
||||
// const { user: user1 } = await createUserAndSession(1, true);
|
||||
// const { session: session2, user: user2 } = await createUserAndSession(2);
|
||||
|
||||
// const tree: any = {
|
||||
// '000000000000000000000000000000F1': {
|
||||
// '00000000000000000000000000000001': null,
|
||||
// },
|
||||
// };
|
||||
|
||||
// await createItemTree(user1.id, '', tree);
|
||||
// await createItem(session2.id, 'root:/test.txt:', 'testing');
|
||||
|
||||
// {
|
||||
// const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
|
||||
// expect(itemIds.length).toBe(2);
|
||||
|
||||
// const item1 = await models().item().load(itemIds[0]);
|
||||
// const item2 = await models().item().load(itemIds[1]);
|
||||
|
||||
// expect([item1.jop_id, item2.jop_id].sort()).toEqual(['000000000000000000000000000000F1', '00000000000000000000000000000001'].sort());
|
||||
// }
|
||||
|
||||
// {
|
||||
// const itemIds = await models().item().exclusivelyOwnedItemIds(user2.id);
|
||||
// expect(itemIds.length).toBe(1);
|
||||
// }
|
||||
// });
|
||||
|
||||
// test('should find exclusively owned items 2', async function() {
|
||||
// const { session: session1, user: user1 } = await createUserAndSession(1, true);
|
||||
// const { session: session2, user: user2 } = await createUserAndSession(2);
|
||||
|
||||
// await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', {
|
||||
// '000000000000000000000000000000F1': {
|
||||
// '00000000000000000000000000000001': null,
|
||||
// },
|
||||
// });
|
||||
|
||||
// await createFolder(session2.id, { id: '000000000000000000000000000000F2' });
|
||||
|
||||
// {
|
||||
// const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
|
||||
// expect(itemIds.length).toBe(0);
|
||||
// }
|
||||
|
||||
// {
|
||||
// const itemIds = await models().item().exclusivelyOwnedItemIds(user2.id);
|
||||
// expect(itemIds.length).toBe(1);
|
||||
// }
|
||||
|
||||
// await models().user().delete(user2.id);
|
||||
|
||||
// {
|
||||
// const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
|
||||
// expect(itemIds.length).toBe(2);
|
||||
// }
|
||||
// });
|
||||
|
||||
test('should find all items within a shared folder', async () => {
|
||||
const { user: user1, session: session1 } = await createUserAndSession(1);
|
||||
const { session: session2 } = await createUserAndSession(2);
|
||||
|
||||
@@ -119,16 +119,23 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
}
|
||||
|
||||
public async checkIfAllowed(user: User, action: AclAction, resource: Item = null): Promise<void> {
|
||||
if (action === AclAction.Create) {
|
||||
if (!(await this.models().shareUser().isShareParticipant(resource.jop_share_id, user.id))) throw new ErrorForbidden('user has no access to this share');
|
||||
}
|
||||
if ([AclAction.Create, AclAction.Update, AclAction.Delete].includes(action) && resource.jop_share_id) {
|
||||
const share = await this.models().share().load(resource.jop_share_id, { fields: ['id', 'owner_id'] });
|
||||
|
||||
// if (action === AclAction.Delete) {
|
||||
// const share = await this.models().share().byItemId(resource.id);
|
||||
// if (share && share.type === ShareType.JoplinRootFolder) {
|
||||
// if (user.id !== share.owner_id) throw new ErrorForbidden('only the owner of the shared notebook can delete it');
|
||||
// }
|
||||
// }
|
||||
if (!share) {
|
||||
// Don't warn in the case where the share doesn't exist. This can happen, for example, when
|
||||
// unsharing a folder.
|
||||
// See https://github.com/laurent22/joplin/issues/14107.
|
||||
if (resource.owner_id !== user.id) {
|
||||
modelLogger.warn('cannot find the share associated with this item. Action:', action, 'User:', user.email, 'Resource:', resource);
|
||||
}
|
||||
} else {
|
||||
if (share.owner_id !== user.id) {
|
||||
const shareUser = await this.models().shareUser().byShareAndUserId(share.id, user.id);
|
||||
if (!shareUser) throw new ErrorForbidden('user has no access to this share');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fromApiInput(item: Item): Item {
|
||||
@@ -141,7 +148,7 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
return output;
|
||||
}
|
||||
|
||||
protected objectToApiOutput(object: Item): Item {
|
||||
protected async objectToApiOutput(object: Item): Promise<Item> {
|
||||
const output: Item = {};
|
||||
const propNames = ['id', 'name', 'updated_time', 'created_time'];
|
||||
for (const k of Object.keys(object)) {
|
||||
@@ -562,10 +569,9 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
return item;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public async loadAsJoplinItem(id: Uuid): Promise<any> {
|
||||
public async loadAsJoplinItem<T>(id: Uuid): Promise<T> {
|
||||
const raw = await this.loadWithContent(id);
|
||||
return this.itemToJoplinItem(raw);
|
||||
return this.itemToJoplinItem(raw) as T;
|
||||
}
|
||||
|
||||
public async saveFromRawContent(user: User, rawContentItemOrItems: SaveFromRawContentItem[] | SaveFromRawContentItem, options: ItemSaveOption = null): Promise<SaveFromRawContentResult> {
|
||||
@@ -590,14 +596,35 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
interface ExistingItem {
|
||||
id: Uuid;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
jop_share_id: string;
|
||||
}
|
||||
|
||||
return this.withTransaction(async () => {
|
||||
const existingItems = await this.loadByNames(user.id, rawContentItems.map(i => i.name), { fields: ['id', 'name'] }) as ExistingItem[];
|
||||
const existingItems = await this.loadByNames(user.id, rawContentItems.map(i => i.name), { fields: ['id', 'name', 'owner_id', 'jop_share_id', 'jop_type', 'jop_parent_id'] }) as ExistingItem[];
|
||||
const itemsToProcess: Record<string, ItemToProcess> = {};
|
||||
|
||||
for (const rawItem of rawContentItems) {
|
||||
try {
|
||||
const existingItem = existingItems.find(i => i.name === rawItem.name);
|
||||
|
||||
// Check if the user is allowed to modify the item - in
|
||||
// particular it would be disabled if the user has only
|
||||
// read-only access to a share. Later, once we have
|
||||
// unserialized the content, and got all the relevant
|
||||
// information, we check if the user is allowed to create
|
||||
// the item.
|
||||
//
|
||||
// Normally only one such check is needed to know if the
|
||||
// item can be updated... except if share_id is changed, in
|
||||
// which case we need to check again with the new share_id
|
||||
// (see below)
|
||||
let previousShareId = '';
|
||||
if (existingItem) {
|
||||
await this.checkIfAllowed(user, AclAction.Update, existingItem);
|
||||
previousShareId = existingItem.jop_share_id;
|
||||
}
|
||||
|
||||
const isJoplinItem = isJoplinItemName(rawItem.name);
|
||||
let isNote = false;
|
||||
|
||||
@@ -636,11 +663,34 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
item.content = rawItem.body;
|
||||
}
|
||||
|
||||
const existingItem = existingItems.find(i => i.name === rawItem.name);
|
||||
if (existingItem) item.id = existingItem.id;
|
||||
|
||||
if (options.shareId) item.jop_share_id = options.shareId;
|
||||
|
||||
// Check if the user is allowed to create an item here - in
|
||||
// particular it would be disabled if the user has only
|
||||
// read-only access to a share.
|
||||
|
||||
const itemToCheck = { ...item };
|
||||
if (!isJoplinItem) {
|
||||
// The checked item must have these properties,
|
||||
// otherwise isRootSharedFolder() will fail. If it's not
|
||||
// a Joplin item, it means it's a regular file, such as
|
||||
// info.json or the content of a resource, so we set the
|
||||
// type to `ModelType.Resource`, which is not strictly
|
||||
// correct but will make it work with the
|
||||
// isRootSharedFolder() check.
|
||||
if (!itemToCheck.jop_parent_id) itemToCheck.jop_parent_id = '';
|
||||
if (!itemToCheck.jop_type) itemToCheck.jop_type = ModelType.Resource;
|
||||
}
|
||||
|
||||
if (!existingItem) {
|
||||
await this.checkIfAllowed(user, AclAction.Create, itemToCheck);
|
||||
} else {
|
||||
const newShareId = item.jop_share_id || '';
|
||||
if (previousShareId !== newShareId) await this.checkIfAllowed(user, AclAction.Update, itemToCheck);
|
||||
}
|
||||
|
||||
await this.models().user().checkMaxItemSizeLimit(user, rawItem.body, item, joplinItem);
|
||||
|
||||
itemsToProcess[rawItem.name] = {
|
||||
@@ -851,6 +901,11 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
}
|
||||
|
||||
public isRootSharedFolder(item: Item): boolean {
|
||||
if (!('jop_type' in item) || !('jop_parent_id' in item) || !('jop_share_id' in item)) {
|
||||
const itemInfo = { ...item };
|
||||
delete itemInfo.content;
|
||||
throw new Error(`Missing jop_type, jop_parent_id or jop_share_id property: ${JSON.stringify(itemInfo)}`);
|
||||
}
|
||||
return item.jop_type === ModelType.Folder && item.jop_parent_id === '' && !!item.jop_share_id;
|
||||
}
|
||||
|
||||
@@ -912,7 +967,13 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
public async deleteForUser(userId: Uuid, item: Item, options: DeleteOptions = {}): Promise<void> {
|
||||
if (this.isRootSharedFolder(item)) {
|
||||
const share = await this.models().share().byItemId(item.id);
|
||||
if (!share) throw new Error(`Cannot find share associated with item ${item.id}`);
|
||||
if (!share) {
|
||||
// In that case we don't do anything - the item is going to be
|
||||
// deleted locally anyway. And we can't delete a root folder,
|
||||
// otherwise it will potentially delete it for other users too.
|
||||
modelLogger.warn(`Trying to delete a root folder associated with a share that no longer exists: ${item.id}`);
|
||||
return;
|
||||
}
|
||||
const userShare = await this.models().shareUser().byShareAndUserId(share.id, userId);
|
||||
|
||||
if (userShare) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resourceBlobPath } from '../utils/joplinUtils';
|
||||
import { Item, ItemResource, Uuid } from '../services/database/types';
|
||||
import BaseModel from './BaseModel';
|
||||
import { ItemLoadOptions } from './ItemModel';
|
||||
|
||||
export interface TreeItem {
|
||||
item_id: Uuid;
|
||||
@@ -63,9 +64,9 @@ export default class ItemResourceModel extends BaseModel<ItemResource> {
|
||||
return rows.map(r => r.item_id);
|
||||
}
|
||||
|
||||
public async blobItemsByResourceIds(userIds: Uuid[], resourceIds: string[]): Promise<Item[]> {
|
||||
public async blobItemsByResourceIds(userIds: Uuid[], resourceIds: string[], options: ItemLoadOptions = {}): Promise<Item[]> {
|
||||
const resourceBlobNames = resourceIds.map(id => resourceBlobPath(id));
|
||||
return this.models().item().loadByNames(userIds, resourceBlobNames);
|
||||
return this.models().item().loadByNames(userIds, resourceBlobNames, options);
|
||||
}
|
||||
|
||||
public async itemTree(rootItemId: Uuid, rootJopId: string, currentItemIds: string[] = []): Promise<TreeItem> {
|
||||
|
||||
161
packages/server/src/models/RecoveryCodeModel.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
|
||||
import BaseModel, { UuidType } from './BaseModel';
|
||||
import { EmailSender, RecoveryCode, Uuid } from '../services/database/types';
|
||||
import { createSecureRandom, customAlphabetSecure } from '@joplin/lib/uuid';
|
||||
import { ErrorForbidden } from '../utils/errors';
|
||||
import { isValidMFACode } from '../utils/crypto';
|
||||
import recoveryCodesAccessedTemplate from '../views/emails/recoveryCodesAccessedTemplate';
|
||||
import { forgotPasswordUrl } from '../utils/urlUtils';
|
||||
import { formatDateOnServer } from '../utils/time';
|
||||
import { DbConnection } from '../db';
|
||||
import { NewModelFactoryHandler } from './factory';
|
||||
import { Config } from '../utils/types';
|
||||
|
||||
type RecoveryCodeAccess = {
|
||||
isValid: boolean;
|
||||
isNewlyCreated: boolean;
|
||||
};
|
||||
|
||||
export default class RecoveryCodeModel extends BaseModel<RecoveryCode> {
|
||||
|
||||
private readonly nanoid;
|
||||
|
||||
public constructor(db: DbConnection, dbSlave: DbConnection, modelFactory: NewModelFactoryHandler, config: Config) {
|
||||
super(db, dbSlave, modelFactory, config);
|
||||
|
||||
this.nanoid = customAlphabetSecure('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 10);
|
||||
}
|
||||
|
||||
protected get tableName(): string {
|
||||
return 'recovery_codes';
|
||||
}
|
||||
|
||||
protected uuidType(): UuidType {
|
||||
return UuidType.Native;
|
||||
}
|
||||
|
||||
public generateNewCodes() {
|
||||
const quantity = 10;
|
||||
const codes = [];
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const code = this.nanoid();
|
||||
codes.push(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
public async saveCodes(codes: string[], userId: Uuid) {
|
||||
await this.withTransaction(async () => {
|
||||
await super.db(this.tableName)
|
||||
.where({ user_id: userId })
|
||||
.delete();
|
||||
|
||||
for (const code of codes) {
|
||||
await super.save({
|
||||
user_id: userId,
|
||||
code,
|
||||
is_used: 0,
|
||||
});
|
||||
}
|
||||
}, 'RecoveryCodeModel::saveCodes');
|
||||
}
|
||||
|
||||
private userFriendlyFormat(codeRecords: Partial<RecoveryCode>[]) {
|
||||
return codeRecords
|
||||
.map(record => {
|
||||
return {
|
||||
...record,
|
||||
code: `${record.code.slice(0, 5)}-${record.code.slice(5)}`.toUpperCase(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a.is_used - b.is_used;
|
||||
});
|
||||
}
|
||||
|
||||
public async loadByUserId(userId: Uuid) {
|
||||
const codes: Partial<RecoveryCode>[] = await this.db(this.tableName).select(['user_id', 'code', 'is_used']).where({ user_id: userId });
|
||||
return this.userFriendlyFormat(codes);
|
||||
}
|
||||
|
||||
private normalizeRecoveryCode(recoveryCode: string) {
|
||||
return recoveryCode
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
public async verify(userId: Uuid, recoveryCode: string) {
|
||||
const normalized = this.normalizeRecoveryCode(recoveryCode);
|
||||
await this.withTransaction(async () => {
|
||||
const code = await super.db(this.tableName)
|
||||
.select(['id', 'user_id', 'code', 'is_used'])
|
||||
.where({ user_id: userId, is_used: 0, code: normalized })
|
||||
.first();
|
||||
|
||||
if (!code) throw new ErrorForbidden('The recovery code is not valid or has already been used.');
|
||||
|
||||
await super.db(this.tableName).update({ is_used: 1 }).where({ user_id: userId, code: normalized });
|
||||
}, 'RecoveryCode::verify');
|
||||
}
|
||||
|
||||
public async checkCredentials(userId: Uuid, password?: string, mfaCode?: string) {
|
||||
const user = await this.models().user().load(userId, { fields: ['totp_secret'] });
|
||||
|
||||
if (password) {
|
||||
const isPasswordValid = await this.models().user().isPasswordValid(userId, password);
|
||||
if (isPasswordValid) return;
|
||||
}
|
||||
|
||||
if (mfaCode) {
|
||||
const isMfaCodeValid = await isValidMFACode(user.totp_secret, mfaCode);
|
||||
if (isMfaCodeValid) return;
|
||||
}
|
||||
|
||||
throw new ErrorForbidden('Invalid password or authentication code');
|
||||
}
|
||||
|
||||
public async saveRecoveryCodeAccessKey(userId: Uuid) {
|
||||
const accessKey = createSecureRandom();
|
||||
await this.models().keyValue().setValue(`RecoveryCode::accessKey::${userId}`, accessKey);
|
||||
return accessKey;
|
||||
}
|
||||
|
||||
public async isRecoveryCodeAccessKeyValid(userId: Uuid, accessKey: string) {
|
||||
const recoveryCodeAccess = await this.withTransaction<RecoveryCodeAccess>(async () => {
|
||||
const record = await super.models().keyValue().value(`RecoveryCode::accessKey::${userId}`);
|
||||
|
||||
if (record !== accessKey) return { isValid: false, isNewlyCreated: false };
|
||||
|
||||
const isNewlyCreated = await super.models().keyValue().value(`RecoveryCode::isNewlyCreated::${userId}`);
|
||||
|
||||
await super.models().keyValue().deleteValue(`RecoveryCode::accessKey::${userId}`);
|
||||
await super.models().keyValue().deleteValue(`RecoveryCode::isNewlyCreated::${userId}`);
|
||||
|
||||
return { isValid: true, isNewlyCreated: !!isNewlyCreated };
|
||||
}, 'RecoveryCode::isRecoveryCodeAccessKeyValid');
|
||||
|
||||
if (!recoveryCodeAccess.isValid) return recoveryCodeAccess;
|
||||
|
||||
// We don't send email notification if it is just after MFA was enabled
|
||||
if (recoveryCodeAccess.isNewlyCreated) return recoveryCodeAccess;
|
||||
|
||||
const user = await this.models().user().load(userId, { fields: ['email', 'full_name'] });
|
||||
await this.models().email().push({
|
||||
...recoveryCodesAccessedTemplate({
|
||||
accessTime: formatDateOnServer(Date.now()),
|
||||
changePasswordUrl: forgotPasswordUrl(),
|
||||
}),
|
||||
recipient_email: user.email,
|
||||
recipient_name: user.full_name,
|
||||
recipient_id: userId,
|
||||
sender_id: EmailSender.NoReply,
|
||||
});
|
||||
|
||||
return recoveryCodeAccess;
|
||||
}
|
||||
|
||||
public async regenerate(userId: Uuid) {
|
||||
const codes = this.generateNewCodes();
|
||||
await this.saveCodes(codes, userId);
|
||||
}
|
||||
}
|
||||