Compare commits
126 Commits
android-v3
...
frontmatte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4ea277d17 | ||
|
|
26ce17e0d8 | ||
|
|
c278b45c78 | ||
|
|
0dafd21db0 | ||
|
|
490d35919c | ||
|
|
4c1ca5480d | ||
|
|
d414c6354a | ||
|
|
7651d8e3c4 | ||
|
|
d5c72c13cb | ||
|
|
4377634e7b | ||
|
|
69ec5c7f86 | ||
|
|
f02b0f48d8 | ||
|
|
4d77c1385f | ||
|
|
c83f9ddeac | ||
|
|
1b9c11df7b | ||
|
|
333a8723e8 | ||
|
|
e030c8271d | ||
|
|
560bc31445 | ||
|
|
c71aeb74b2 | ||
|
|
ffaf2acb66 | ||
|
|
f442f1fb23 | ||
|
|
81a1451820 | ||
|
|
b3a3d71461 | ||
|
|
1db38c3232 | ||
|
|
42e645eb70 | ||
|
|
3860f44d06 | ||
|
|
4df0f8668d | ||
|
|
306d0fddd8 | ||
|
|
56d12b28f2 | ||
|
|
6c5ea4872a | ||
|
|
9856e8ae93 | ||
|
|
5712da4c0f | ||
|
|
4f7ee56444 | ||
|
|
8e2b6ca296 | ||
|
|
0172bb0ad8 | ||
|
|
1d38e443ba | ||
|
|
5ad19b7261 | ||
|
|
70293478a2 | ||
|
|
3aaa20254f | ||
|
|
42c248f7ca | ||
|
|
ac1e94a8df | ||
|
|
daff4496cf | ||
|
|
1e00078228 | ||
|
|
03a1de9370 | ||
|
|
55ef256c65 | ||
|
|
6d115db16f | ||
|
|
5853031fde | ||
|
|
47db2ae962 | ||
|
|
b960a2a8b0 | ||
|
|
fcaa7d2a98 | ||
|
|
99284ae135 | ||
|
|
66ae58c81b | ||
|
|
484d6a866d | ||
|
|
b45fd09e38 | ||
|
|
903a369c13 | ||
|
|
1fb79315e4 | ||
|
|
4dc021b523 | ||
|
|
bbb4b46dd9 | ||
|
|
063dc46f50 | ||
|
|
aa400b52be | ||
|
|
be7de2f08a | ||
|
|
f8a129e4dc | ||
|
|
c5d9646908 | ||
|
|
876ec80911 | ||
|
|
4051f88ce7 | ||
|
|
f194c111e4 | ||
|
|
e386246bc9 | ||
|
|
292b269f1d | ||
|
|
b2fc43da2b | ||
|
|
4a23a1ed3e | ||
|
|
c8878a18bf | ||
|
|
340fba7af5 | ||
|
|
271c4f4a2a | ||
|
|
c9dba20f59 | ||
|
|
b474cc206a | ||
|
|
9d4df8cc6e | ||
|
|
a4ddfe1f58 | ||
|
|
7d15215e66 | ||
|
|
449555c8e9 | ||
|
|
5b74e206ed | ||
|
|
9873d02b0b | ||
|
|
57b7d98d8a | ||
|
|
f075b561a2 | ||
|
|
483d051de0 | ||
|
|
106cd2778f | ||
|
|
c3aea2db80 | ||
|
|
3f067b0f77 | ||
|
|
15cf025bc2 | ||
|
|
4677586e3b | ||
|
|
b8c5b7a153 | ||
|
|
e46e634c2e | ||
|
|
b3cf4e5a35 | ||
|
|
8589e10d6e | ||
|
|
18942f0d6a | ||
|
|
3be354cdcb | ||
|
|
0575f1aa3e | ||
|
|
caa9baa460 | ||
|
|
b5284804d8 | ||
|
|
6053b4296c | ||
|
|
615fec1d2c | ||
|
|
0bbcd9a59b | ||
|
|
6931b32f17 | ||
|
|
17ac501ddb | ||
|
|
94161c5f93 | ||
|
|
196255e960 | ||
|
|
f936390ee4 | ||
|
|
5638c4b812 | ||
|
|
4222caa423 | ||
|
|
bc705acc5c | ||
|
|
f1c968c19a | ||
|
|
26c5a6181e | ||
|
|
a3bf0cfdeb | ||
|
|
606b397326 | ||
|
|
fbd157283d | ||
|
|
2e879f65fc | ||
|
|
c727156a46 | ||
|
|
4e31f1918d | ||
|
|
a1cdf67779 | ||
|
|
5cb1db197f | ||
|
|
05c3065c72 | ||
|
|
25a5be09bf | ||
|
|
f0a3f73ddb | ||
|
|
3bba2f6b2a | ||
|
|
ca9addcda0 | ||
|
|
c42a49c1cf | ||
|
|
a1e056670d |
@@ -1045,6 +1045,8 @@ packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/openLink.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
|
||||
@@ -1061,6 +1063,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 +1105,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,8 +1810,11 @@ 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/frontmatter.test.js
|
||||
packages/renderer/MdToHtml/rules/frontmatter.js
|
||||
packages/renderer/MdToHtml/rules/highlight_keywords.js
|
||||
packages/renderer/MdToHtml/rules/html_image.js
|
||||
packages/renderer/MdToHtml/rules/image.js
|
||||
@@ -1840,22 +1848,29 @@ 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/model/ResourceRecord.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/extractResourceIds.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/hangingIndent.js
|
||||
packages/tools/fuzzer/utils/logDiffDebug.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/randomId.test.js
|
||||
packages/tools/fuzzer/utils/randomId.js
|
||||
packages/tools/fuzzer/utils/randomString.js
|
||||
packages/tools/fuzzer/utils/retryWithCount.js
|
||||
packages/tools/generate-database-types.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
|
||||
18
.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
|
||||
@@ -1018,6 +1019,8 @@ packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/openLink.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
|
||||
@@ -1034,6 +1037,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 +1079,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,8 +1784,11 @@ 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/frontmatter.test.js
|
||||
packages/renderer/MdToHtml/rules/frontmatter.js
|
||||
packages/renderer/MdToHtml/rules/highlight_keywords.js
|
||||
packages/renderer/MdToHtml/rules/html_image.js
|
||||
packages/renderer/MdToHtml/rules/image.js
|
||||
@@ -1813,22 +1822,29 @@ 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/model/ResourceRecord.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/extractResourceIds.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/hangingIndent.js
|
||||
packages/tools/fuzzer/utils/logDiffDebug.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/randomId.test.js
|
||||
packages/tools/fuzzer/utils/randomId.js
|
||||
packages/tools/fuzzer/utils/randomString.js
|
||||
packages/tools/fuzzer/utils/retryWithCount.js
|
||||
packages/tools/generate-database-types.js
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add a minSdkVersion to prevent the dangerous READ_PHONE_STATE
|
||||
# permission from being added.
|
||||
# See:
|
||||
# - Upstream issue report: https://github.com/oblador/react-native-vector-icons/issues/1861
|
||||
# - About the permission: https://developer.android.com/reference/android/Manifest.permission#READ_PHONE_STATE
|
||||
# - StackOverflow post with discussion and alternate workarounds: https://stackoverflow.com/questions/39668549/why-has-the-read-phone-state-permission-been-added
|
||||
diff --git a/android/build.gradle b/android/build.gradle
|
||||
index a16b4ad6d1871cf5cf73ef7ebeaf8bd4d662b134..9871afb5fbf8e687370e08f54d884ecd7dde7e7c 100644
|
||||
--- a/android/build.gradle
|
||||
+++ b/android/build.gradle
|
||||
@@ -37,6 +37,10 @@ android {
|
||||
}
|
||||
|
||||
compileSdkVersion safeExtGet('compileSdkVersion', 31)
|
||||
+
|
||||
+ defaultConfig {
|
||||
+ minSdkVersion safeExtGet('minSdkVersion', 24)
|
||||
+ }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add a minSdkVersion to prevent the dangerous READ_PHONE_STATE
|
||||
# permission from being added.
|
||||
# See:
|
||||
# - Upstream issue report: https://github.com/oblador/react-native-vector-icons/issues/1861
|
||||
# - About the permission: https://developer.android.com/reference/android/Manifest.permission#READ_PHONE_STATE
|
||||
# - StackOverflow post with discussion and alternate workarounds: https://stackoverflow.com/questions/39668549/why-has-the-read-phone-state-permission-been-added
|
||||
diff --git a/android/build.gradle b/android/build.gradle
|
||||
index d42bd23123644cc324051e9c7ec4635de286315a..640996df60fe7769f69b30b35f771eb9cf0b75d4 100644
|
||||
--- a/android/build.gradle
|
||||
+++ b/android/build.gradle
|
||||
@@ -37,6 +37,10 @@ android {
|
||||
}
|
||||
|
||||
compileSdkVersion safeExtGet('compileSdkVersion', 31)
|
||||
+
|
||||
+ defaultConfig {
|
||||
+ minSdkVersion safeExtGet('minSdkVersion', 24)
|
||||
+ }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add a minSdkVersion to prevent the dangerous READ_PHONE_STATE
|
||||
# permission from being added.
|
||||
# See:
|
||||
# - Upstream issue report: https://github.com/oblador/react-native-vector-icons/issues/1861
|
||||
# - About the permission: https://developer.android.com/reference/android/Manifest.permission#READ_PHONE_STATE
|
||||
# - StackOverflow post with discussion and alternate workarounds: https://stackoverflow.com/questions/39668549/why-has-the-read-phone-state-permission-been-added
|
||||
diff --git a/android/build.gradle b/android/build.gradle
|
||||
index 170ec0ff9befe0f9155aaf5e1b84133cfd87be99..e6a0ab4a019ee67c5af7761ae8bb35f18b05c590 100644
|
||||
--- a/android/build.gradle
|
||||
+++ b/android/build.gradle
|
||||
@@ -37,6 +37,10 @@ android {
|
||||
}
|
||||
|
||||
compileSdkVersion safeExtGet('compileSdkVersion', 31)
|
||||
+
|
||||
+ defaultConfig {
|
||||
+ minSdkVersion safeExtGet('minSdkVersion', 24)
|
||||
+ }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add a minSdkVersion to prevent the dangerous READ_PHONE_STATE
|
||||
# permission from being added.
|
||||
# See:
|
||||
# - Upstream issue report: https://github.com/oblador/react-native-vector-icons/issues/1861
|
||||
# - About the permission: https://developer.android.com/reference/android/Manifest.permission#READ_PHONE_STATE
|
||||
# - StackOverflow post with discussion and alternate workarounds: https://stackoverflow.com/questions/39668549/why-has-the-read-phone-state-permission-been-added
|
||||
diff --git a/android/build.gradle b/android/build.gradle
|
||||
index 3b22f9de66795ee01dbaa29655727ee7ddba3cc8..325daa88d33f066b3826e5031ce281793710af2d 100644
|
||||
--- a/android/build.gradle
|
||||
+++ b/android/build.gradle
|
||||
@@ -37,6 +37,10 @@ android {
|
||||
}
|
||||
|
||||
compileSdkVersion safeExtGet('compileSdkVersion', 31)
|
||||
+
|
||||
+ defaultConfig {
|
||||
+ minSdkVersion safeExtGet('minSdkVersion', 24)
|
||||
+ }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
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>
|
||||
@@ -9,7 +9,7 @@
|
||||
"vips.dev": {
|
||||
"platforms": ["aarch64-darwin"],
|
||||
},
|
||||
"nodejs": "24.5.0",
|
||||
"nodejs": "24.8.0",
|
||||
"pkg-config": "latest",
|
||||
"python": "3.13.3",
|
||||
"bat": "latest",
|
||||
|
||||
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,
|
||||
|
||||
@@ -86,9 +86,9 @@
|
||||
"gulp": "4.0.2",
|
||||
"husky": "9.1.7",
|
||||
"lerna": "3.22.1",
|
||||
"lint-staged": "16.1.6",
|
||||
"lint-staged": "16.2.6",
|
||||
"madge": "8.0.0",
|
||||
"npm-package-json-lint": "8.0.0",
|
||||
"npm-package-json-lint": "9.0.0",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -22,4 +22,8 @@ export const joplinCommandToTinyMceCommands: JoplinCommandToTinyMceCommands = {
|
||||
'search': { name: 'SearchReplace' },
|
||||
'attachFile': { name: 'joplinAttach' },
|
||||
'insertDateTime': true,
|
||||
'textCopy': true,
|
||||
'textCut': true,
|
||||
'textPaste': true,
|
||||
'textSelectAll': true,
|
||||
};
|
||||
|
||||
@@ -51,6 +51,15 @@ function newBlockSource(language = '', content = '', previousSource: SourceInfo
|
||||
} else {
|
||||
fence = '$$';
|
||||
}
|
||||
} else if (language === 'frontmatter') {
|
||||
// Frontmatter uses --- delimiters instead of code fences
|
||||
return {
|
||||
openCharacters: '---\n',
|
||||
closeCharacters: '\n---\n',
|
||||
content: content,
|
||||
node: null,
|
||||
language: language,
|
||||
};
|
||||
}
|
||||
|
||||
const fenceLanguage = language === 'katex' ? '' : language;
|
||||
|
||||
@@ -17,19 +17,19 @@ describe('editorCommandDeclarations', () => {
|
||||
test.each([
|
||||
[
|
||||
{},
|
||||
true,
|
||||
{ textBold: true },
|
||||
],
|
||||
[
|
||||
{
|
||||
markdownEditorPaneVisible: false,
|
||||
},
|
||||
false,
|
||||
{ textBold: false },
|
||||
],
|
||||
[
|
||||
{
|
||||
noteIsReadOnly: true,
|
||||
},
|
||||
false,
|
||||
{ textBold: false },
|
||||
],
|
||||
[
|
||||
// In the Markdown editor, but only the viewer is visible
|
||||
@@ -37,7 +37,7 @@ describe('editorCommandDeclarations', () => {
|
||||
markdownEditorPaneVisible: false,
|
||||
richTextEditorVisible: false,
|
||||
},
|
||||
false,
|
||||
{ textBold: false },
|
||||
],
|
||||
[
|
||||
// In the Markdown editor, and the viewer is visible
|
||||
@@ -45,7 +45,7 @@ describe('editorCommandDeclarations', () => {
|
||||
markdownEditorPaneVisible: true,
|
||||
richTextEditorVisible: false,
|
||||
},
|
||||
true,
|
||||
{ textBold: true },
|
||||
],
|
||||
[
|
||||
// In the RT editor
|
||||
@@ -53,7 +53,7 @@ describe('editorCommandDeclarations', () => {
|
||||
markdownEditorPaneVisible: false,
|
||||
richTextEditorVisible: true,
|
||||
},
|
||||
true,
|
||||
{ textBold: true },
|
||||
],
|
||||
[
|
||||
// In the Markdown editor, and the command palette is visible
|
||||
@@ -63,14 +63,57 @@ describe('editorCommandDeclarations', () => {
|
||||
gotoAnythingVisible: true,
|
||||
modalDialogVisible: true,
|
||||
},
|
||||
true,
|
||||
{ textBold: true },
|
||||
],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
])('should create the enabledCondition', (context: Record<string, any>, expected: boolean) => {
|
||||
const condition = enabledCondition('textBold');
|
||||
const wc = new WhenClause(condition);
|
||||
const actual = wc.evaluate({ ...baseContext, ...context });
|
||||
expect(actual).toBe(expected);
|
||||
[
|
||||
// In the Markdown editor, and the command palette is visible
|
||||
{
|
||||
markdownEditorPaneVisible: true,
|
||||
richTextEditorVisible: false,
|
||||
gotoAnythingVisible: true,
|
||||
modalDialogVisible: true,
|
||||
},
|
||||
{ textBold: true },
|
||||
],
|
||||
[
|
||||
// Rich Text Editor, HTML note
|
||||
{
|
||||
markdownEditorPaneVisible: false,
|
||||
richTextEditorVisible: true,
|
||||
noteIsMarkdown: false,
|
||||
},
|
||||
{
|
||||
textCopy: true,
|
||||
textPaste: true,
|
||||
textSelectAll: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
// Rich Text Editor, read-only note
|
||||
{
|
||||
markdownEditorPaneVisible: false,
|
||||
richTextEditorVisible: true,
|
||||
noteIsReadOnly: true,
|
||||
},
|
||||
{
|
||||
textBold: false,
|
||||
textPaste: false,
|
||||
|
||||
// TODO: textCopy should be enabled in read-only notes:
|
||||
// textCopy: false,
|
||||
},
|
||||
],
|
||||
])('should correctly determine whether command is enabled (case %#)', (context, expectedStates) => {
|
||||
const actualStates = [];
|
||||
for (const commandName of Object.keys(expectedStates)) {
|
||||
const condition = enabledCondition(commandName);
|
||||
const wc = new WhenClause(condition);
|
||||
const actual = wc.evaluate({ ...baseContext, ...context });
|
||||
actualStates.push([commandName, actual]);
|
||||
}
|
||||
|
||||
const expectedStatesArray = Object.entries(expectedStates);
|
||||
expect(actualStates).toEqual(expectedStatesArray);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -4,6 +4,10 @@ import { joplinCommandToTinyMceCommands } from './NoteBody/TinyMCE/utils/joplinC
|
||||
|
||||
const workWithHtmlNotes = [
|
||||
'attachFile',
|
||||
'textCopy',
|
||||
'textCut',
|
||||
'textPaste',
|
||||
'textSelectAll',
|
||||
];
|
||||
|
||||
export const enabledCondition = (commandName: string) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -128,7 +131,9 @@ module.exports = {
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
testEnvironmentOptions: {
|
||||
customExportConditions: ['node', 'require'],
|
||||
},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: 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",
|
||||
@@ -163,7 +169,7 @@
|
||||
"debounce": "1.2.1",
|
||||
"electron": "39.2.3",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-updater": "6.6.2",
|
||||
"electron-updater": "6.6.8",
|
||||
"electron-window-state": "5.0.3",
|
||||
"esbuild": "^0.25.3",
|
||||
"formatcoords": "1.1.3",
|
||||
@@ -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",
|
||||
|
||||
@@ -140,7 +140,10 @@ export default class AutoUpdaterService implements AutoUpdaterServiceInterface {
|
||||
// electron's autoUpdater appends automatically the platform's yml file to the link so we should remove it
|
||||
assetUrl = assetUrl.substring(0, assetUrl.lastIndexOf('/'));
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: assetUrl });
|
||||
await autoUpdater.checkForUpdates();
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
|
||||
// Wait for the installation to finish. By default, .checkForUpdates runs in the background
|
||||
await result.downloadPromise;
|
||||
} catch (error) {
|
||||
this.logger_.error(`Update download url failed: ${error.message}`);
|
||||
this.isUpdateInProgress = false;
|
||||
|
||||
90
packages/app-desktop/tools/notarizeFile.js
Normal file
@@ -0,0 +1,90 @@
|
||||
'use strict';
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
exports.default = notarizeFile;
|
||||
const fs_1 = require('fs');
|
||||
const notarize_1 = require('@electron/notarize');
|
||||
const execCommand = require('./execCommand');
|
||||
const child_process_1 = require('child_process');
|
||||
const util_1 = require('util');
|
||||
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
||||
// Same appId in electron-builder.
|
||||
const appId = 'net.cozic.joplin-desktop';
|
||||
function isDesktopAppTag(tagName) {
|
||||
if (!tagName) { return false; }
|
||||
return tagName[0] === 'v';
|
||||
}
|
||||
async function notarizeFile(filePath) {
|
||||
if (process.platform !== 'darwin') { return; }
|
||||
console.info(`Checking if notarization should be done on: ${filePath}`);
|
||||
if (!process.env.IS_CONTINUOUS_INTEGRATION || !isDesktopAppTag(process.env.GIT_TAG_NAME)) {
|
||||
console.info(`Either not running in CI or not processing a desktop app tag - skipping notarization. process.env.IS_CONTINUOUS_INTEGRATION = ${process.env.IS_CONTINUOUS_INTEGRATION}; process.env.GIT_TAG_NAME = ${process.env.GIT_TAG_NAME}`);
|
||||
return;
|
||||
}
|
||||
if (!process.env.APPLE_ID || !process.env.APPLE_ID_PASSWORD) {
|
||||
console.warn('Environment variables APPLE_ID and APPLE_ID_PASSWORD not found - notarization will NOT be done.');
|
||||
return;
|
||||
}
|
||||
if (!(0, fs_1.existsSync)(filePath)) {
|
||||
throw new Error(`Cannot find file at: ${filePath}`);
|
||||
}
|
||||
// Every x seconds we print something to stdout, otherwise CI may timeout
|
||||
// the task after 10 minutes, and Apple notarization can take more time.
|
||||
const waitingIntervalId = setInterval(() => {
|
||||
console.info('.');
|
||||
}, 60000);
|
||||
const isPkg = filePath.endsWith('.pkg');
|
||||
console.info(`Notarizing ${filePath}`);
|
||||
try {
|
||||
if (isPkg) {
|
||||
await execAsync(`xcrun notarytool submit "${filePath}" ` +
|
||||
`--apple-id "${process.env.APPLE_ID}" ` +
|
||||
`--password "${process.env.APPLE_ID_PASSWORD}" ` +
|
||||
`--team-id "${process.env.APPLE_ASC_PROVIDER}" ` +
|
||||
'--wait', { maxBuffer: 1024 * 1024 });
|
||||
} else {
|
||||
await (0, notarize_1.notarize)({
|
||||
appBundleId: appId,
|
||||
appPath: filePath,
|
||||
// Apple Developer email address
|
||||
appleId: process.env.APPLE_ID,
|
||||
// App-specific password: https://support.apple.com/en-us/HT204397
|
||||
appleIdPassword: process.env.APPLE_ID_PASSWORD,
|
||||
// When Apple ID is attached to multiple providers (eg if the
|
||||
// account has been used to build multiple apps for different
|
||||
// companies), in that case the provider "Team Short Name" (also
|
||||
// known as "ProviderShortname") must be provided.
|
||||
//
|
||||
// Use this to get it:
|
||||
//
|
||||
// xcrun altool --list-providers -u APPLE_ID -p APPLE_ID_PASSWORD
|
||||
// ascProvider: process.env.APPLE_ASC_PROVIDER,
|
||||
// In our case, the team ID is the same as the legacy ASC_PROVIDER
|
||||
teamId: process.env.APPLE_ASC_PROVIDER,
|
||||
tool: 'notarytool',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
clearInterval(waitingIntervalId);
|
||||
// It appears that electron-notarize doesn't staple the app, but without
|
||||
// this we were still getting the malware warning when launching the app.
|
||||
// Stapling the app means attaching the notarization ticket to it, so that
|
||||
// if the user is offline, macOS can still check if the app was notarized.
|
||||
// So it seems to be more or less optional, but at least in our case it
|
||||
// wasn't.
|
||||
console.info('Stapling notarization ticket to the file...');
|
||||
const staplerCmd = `xcrun stapler staple "${filePath}"`;
|
||||
console.info(`> ${staplerCmd}`);
|
||||
console.info(await execCommand(staplerCmd));
|
||||
console.info(`Validating stapled file: ${filePath}`);
|
||||
try {
|
||||
await execAsync(`spctl -a -vv -t install "${filePath}"`);
|
||||
} catch (error) {
|
||||
console.error(`Failed validating stapled file: ${filePath}:`, error);
|
||||
}
|
||||
console.info(`Done notarizing ${filePath}`);
|
||||
}
|
||||
// # sourceMappingURL=notarizeFile.js.map
|
||||
@@ -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"
|
||||
}
|
||||
@@ -132,6 +132,17 @@ android {
|
||||
minifyEnabled enableProguardInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
}
|
||||
profileable {
|
||||
// Release-like build that allows profiling with Android Studio Profiler
|
||||
initWith release
|
||||
signingConfig signingConfigs.debug
|
||||
// Required for Android Studio Profiler to attach
|
||||
debuggable false
|
||||
// Keeps symbols for better stack traces in profiler
|
||||
minifyEnabled false
|
||||
// Use release variants of dependencies that don't have profileable
|
||||
matchingFallbacks = ['release']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
android:theme="@style/AppTheme"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- Enable profiling in release builds (Android 10+) -->
|
||||
<profileable android:shell="true" />
|
||||
|
||||
<!--
|
||||
2018-12-16: Changed android:launchMode from "singleInstance" to "singleTop" for Firebase notification
|
||||
Previously singleInstance was necessary to prevent multiple instance of the RN app from running at the same time, but maybe no longer needed.
|
||||
|
||||
@@ -438,9 +438,8 @@ const useInputEventHandlers = ({
|
||||
const onSubmit = useCallback(() => {
|
||||
if (selectedResult) {
|
||||
onItemSelected(selectedResult, selectedIndex);
|
||||
setSearch('');
|
||||
}
|
||||
}, [onItemSelected, selectedResult, selectedIndex, setSearch]);
|
||||
}, [onItemSelected, selectedResult, selectedIndex]);
|
||||
|
||||
// For now, onKeyPress only works on web.
|
||||
// See https://github.com/react-native-community/discussions-and-proposals/issues/249
|
||||
|
||||
@@ -694,10 +694,17 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
</Menu>
|
||||
);
|
||||
|
||||
// Updating the state of this component can result in the left most element becoming hidden, so add a dummy as the first element to prevent this
|
||||
// See https://github.com/laurent22/joplin/issues/14153
|
||||
const zeroWidthSpacer = (
|
||||
<View style={{ width: 0 }} pointerEvents="none"/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={this.styles().outerContainer}>
|
||||
<View style={this.styles().aboveHeader}/>
|
||||
<View style={this.styles().innerContainer}>
|
||||
{zeroWidthSpacer}
|
||||
{sideMenuComp}
|
||||
{backButtonComp}
|
||||
{renderUndoButton()}
|
||||
|
||||
@@ -511,7 +511,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 148;
|
||||
CURRENT_PROJECT_VERSION = 149;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
@@ -520,7 +520,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.2;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -546,7 +546,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 148;
|
||||
CURRENT_PROJECT_VERSION = 149;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
@@ -554,7 +554,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.2;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -747,7 +747,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 148;
|
||||
CURRENT_PROJECT_VERSION = 149;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -758,7 +758,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.2;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -790,7 +790,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 148;
|
||||
CURRENT_PROJECT_VERSION = 149;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -801,7 +801,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.2;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -1406,7 +1406,7 @@ PODS:
|
||||
- React-jsiexecutor
|
||||
- React-RCTFBReactNativeSpec
|
||||
- ReactCommon/turbomodule/core
|
||||
- react-native-alarm-notification (3.5.0):
|
||||
- react-native-alarm-notification (3.6.0):
|
||||
- React
|
||||
- react-native-document-picker (10.1.7):
|
||||
- DoubleConversion
|
||||
@@ -1514,7 +1514,7 @@ PODS:
|
||||
- Yoga
|
||||
- react-native-rsa-native (2.0.5):
|
||||
- React
|
||||
- react-native-saf-x (3.5.1):
|
||||
- react-native-saf-x (3.6.0):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (5.6.1):
|
||||
- React-Core
|
||||
@@ -1904,7 +1904,7 @@ PODS:
|
||||
- React-Core
|
||||
- RNDateTimePicker (8.4.5):
|
||||
- React-Core
|
||||
- RNDeviceInfo (14.0.4):
|
||||
- RNDeviceInfo (14.1.1):
|
||||
- React-Core
|
||||
- RNExitApp (2.0.0):
|
||||
- React-Core
|
||||
@@ -1912,7 +1912,7 @@ PODS:
|
||||
- React-Core
|
||||
- RNFS (2.20.0):
|
||||
- React-Core
|
||||
- RNLocalize (3.5.2):
|
||||
- RNLocalize (3.5.4):
|
||||
- React-Core
|
||||
- RNQuickAction (0.3.13):
|
||||
- React
|
||||
@@ -2355,7 +2355,7 @@ SPEC CHECKSUMS:
|
||||
React-logger: 8edfcedc100544791cd82692ca5a574240a16219
|
||||
React-Mapbuffer: c3f4b608e4a59dd2f6a416ef4d47a14400194468
|
||||
React-microtasksnativemodule: 054f34e9b82f02bd40f09cebd4083828b5b2beb6
|
||||
react-native-alarm-notification: a4326a743df72a94d361a4c3a21515556f650341
|
||||
react-native-alarm-notification: 846df1df72eca38e711409b9c064a5c635ff1c32
|
||||
react-native-document-picker: b6419b766863408dacbdf5e97b2f3a694c611150
|
||||
react-native-geolocation: ec15ffebc53790314885eb9e5f2132132fbc2600
|
||||
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
|
||||
@@ -2364,7 +2364,7 @@ SPEC CHECKSUMS:
|
||||
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
|
||||
react-native-quick-crypto: b475b71e7fa4dbf3446be55e8ad4ef2c58ac4f7f
|
||||
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
|
||||
react-native-saf-x: 404f0f9a29cc6bf21d88582e054c45a11b28c22b
|
||||
react-native-saf-x: 50d176763ed692b379c190bf55ae7293a3ee09bb
|
||||
react-native-safe-area-context: 2243039f43d10cb1ea30ec5ac57fc6d1448413f4
|
||||
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
|
||||
react-native-vector-icons: a45ecc326ec090450f152dfc7076ce1173331ce5
|
||||
@@ -2409,11 +2409,11 @@ SPEC CHECKSUMS:
|
||||
RNCClipboard: f6679d470d0da2bce2a37b0af7b9e0bf369ecda5
|
||||
RNCPushNotificationIOS: 6c4ca3388c7434e4a662b92e4dfeeee858e6f440
|
||||
RNDateTimePicker: 8c12d12e8660697c2e176d2f98775764431c141f
|
||||
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
|
||||
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
|
||||
RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4
|
||||
RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251
|
||||
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
|
||||
RNLocalize: 3c4d0abd777a546fa77bdb6caef85a87fb9ea349
|
||||
RNLocalize: d7859f87f1083349c73aa089e360af33ef89efc2
|
||||
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
|
||||
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
|
||||
RNShare: 40ace3f87cd881869e8085aced9dc16b425c74aa
|
||||
|
||||
@@ -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,24 +22,24 @@
|
||||
},
|
||||
"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",
|
||||
"@react-native-community/datetimepicker": "8.4.7",
|
||||
"@react-native-community/geolocation": "3.4.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-community/push-notification-ios": "1.11.0",
|
||||
"@react-native-documents/picker": "10.1.7",
|
||||
"@react-native-vector-icons/fontawesome5": "12.3.0",
|
||||
"@react-native-vector-icons/fontawesome5": "patch:@react-native-vector-icons/fontawesome5@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-fontawesome5-npm-12.3.0-a1ca46610f.patch",
|
||||
"@react-native-vector-icons/get-image": "12.3.0",
|
||||
"@react-native-vector-icons/ionicons": "12.3.0",
|
||||
"@react-native-vector-icons/material-design-icons": "12.4.0",
|
||||
"@react-native-vector-icons/material-icons": "12.4.0",
|
||||
"@react-native-vector-icons/ionicons": "patch:@react-native-vector-icons/ionicons@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-ionicons-npm-12.3.0-9bd4746f3f.patch",
|
||||
"@react-native-vector-icons/material-design-icons": "patch:@react-native-vector-icons/material-design-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-design-icons-npm-12.4.0-890f7f618b.patch",
|
||||
"@react-native-vector-icons/material-icons": "patch:@react-native-vector-icons/material-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-icons-npm-12.4.0-94138e627b.patch",
|
||||
"assert-browserify": "2.0.0",
|
||||
"buffer": "6.0.3",
|
||||
"color": "3.2.1",
|
||||
@@ -59,21 +59,21 @@
|
||||
"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",
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
"react-native-quick-crypto": "0.7.17",
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "5.6.1",
|
||||
"react-native-safe-area-context": "5.6.2",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "12.2.0",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
@@ -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.164",
|
||||
"@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.12",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fs-extra": "11.3.2",
|
||||
"gulp": "4.0.2",
|
||||
@@ -131,8 +131,8 @@
|
||||
"nodemon": "3.1.10",
|
||||
"punycode": "2.3.1",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native-web": "0.21.1",
|
||||
"react-refresh": "0.17.0",
|
||||
"react-native-web": "0.21.2",
|
||||
"react-refresh": "0.18.0",
|
||||
"react-test-renderer": "19.0.0",
|
||||
"sharp": "0.34.4",
|
||||
"sqlite3": "5.1.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import PluginAssetsLoader from '../PluginAssetsLoader';
|
||||
import AlarmService from '@joplin/lib/services/AlarmService';
|
||||
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||
import Logger, { LogLevel, TargetType } from '@joplin/utils/Logger';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import BaseService from '@joplin/lib/services/BaseService';
|
||||
import ResourceService from '@joplin/lib/services/ResourceService';
|
||||
@@ -200,11 +200,8 @@ const buildStartupTasks = (
|
||||
const mainLogger = new Logger();
|
||||
mainLogger.addTarget(TargetType.Database, { database: logDatabase, source: 'm' });
|
||||
mainLogger.setLevel(Logger.LEVEL_INFO);
|
||||
|
||||
if (Setting.value('env') === 'dev') {
|
||||
mainLogger.addTarget(TargetType.Console);
|
||||
mainLogger.setLevel(Logger.LEVEL_DEBUG);
|
||||
}
|
||||
mainLogger.addTarget(TargetType.Console);
|
||||
mainLogger.setLevel(Setting.value('env') === 'dev' ? LogLevel.Debug : LogLevel.Info);
|
||||
|
||||
Logger.initializeGlobalLogger(mainLogger);
|
||||
initLib(mainLogger);
|
||||
|
||||
@@ -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": {
|
||||
@@ -13,13 +13,13 @@
|
||||
"url": "git+https://github.com/laurent22/joplin.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/yargs": "17.0.33",
|
||||
"joplin-plugin-freehand-drawing": "4.2.0",
|
||||
"@types/yargs": "17.0.34",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { deleteMarkupBackward, markdown, markdownLanguage } from '@codemirror/la
|
||||
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
|
||||
import markdownMathExtension from './extensions/markdownMathExtension';
|
||||
import markdownHighlightExtension from './extensions/markdownHighlightExtension';
|
||||
import markdownFrontMatterExtension from './extensions/markdownFrontMatterExtension';
|
||||
import lookUpLanguage from './utils/markdown/codeBlockLanguages/lookUpLanguage';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { defaultKeymap, emacsStyleKeymap } from '@codemirror/commands';
|
||||
@@ -30,6 +31,9 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
|
||||
extensions: [
|
||||
GitHubFlavoredMarkdownExtension,
|
||||
|
||||
// FrontMatter support (YAML blocks at start of document)
|
||||
markdownFrontMatterExtension,
|
||||
|
||||
settings.markdownMarkEnabled ? markdownHighlightExtension : [],
|
||||
|
||||
// Don't highlight KaTeX if the user disabled it
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -32,6 +32,10 @@ const mathBlockDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-mathBlock', ...noSpellCheckAttrs },
|
||||
});
|
||||
|
||||
const frontMatterDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-frontMatter', ...noSpellCheckAttrs },
|
||||
});
|
||||
|
||||
const inlineMathDecoration = Decoration.mark({
|
||||
attributes: { class: 'cm-inlineMath', ...noSpellCheckAttrs },
|
||||
});
|
||||
@@ -116,6 +120,7 @@ const nodeNameToLineDecoration: Record<string, Decoration> = {
|
||||
'FencedCode': codeBlockDecoration,
|
||||
'CodeBlock': codeBlockDecoration,
|
||||
'BlockMath': mathBlockDecoration,
|
||||
'FrontMatter': frontMatterDecoration,
|
||||
'Blockquote': blockQuoteDecoration,
|
||||
'OrderedList': orderedListDecoration,
|
||||
'BulletList': unorderedListDecoration,
|
||||
@@ -152,6 +157,7 @@ const multilineNodes = {
|
||||
'FencedCode': true,
|
||||
'CodeBlock': true,
|
||||
'BlockMath': true,
|
||||
'FrontMatter': true,
|
||||
'Blockquote': true,
|
||||
'OrderedList': true,
|
||||
'BulletList': true,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { frontMatterTagName, frontMatterContentTagName, frontMatterMarkerTagName } from './markdownFrontMatterExtension';
|
||||
|
||||
import createTestEditor from '../testing/createTestEditor';
|
||||
import findNodesWithName from '../testing/findNodesWithName';
|
||||
|
||||
// Creates an EditorState with FrontMatter and markdown extensions
|
||||
const createEditorState = async (initialText: string, expectedTags: string[]): Promise<EditorState> => {
|
||||
return (await createTestEditor(initialText, EditorSelection.cursor(0), expectedTags)).state;
|
||||
};
|
||||
|
||||
describe('MarkdownFrontMatterExtension', () => {
|
||||
|
||||
jest.retryTimes(2);
|
||||
|
||||
it('should parse a basic FrontMatter block at the start of the document', async () => {
|
||||
const documentText = '---\ntitle: Test\n---\n\n# Heading';
|
||||
const editor = await createEditorState(documentText, [frontMatterTagName, 'ATXHeading1']);
|
||||
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
|
||||
|
||||
expect(frontMatterNodes.length).toBe(1);
|
||||
expect(frontMatterNodes[0].from).toBe(0);
|
||||
expect(frontMatterNodes[0].to).toBe('---\ntitle: Test\n---'.length);
|
||||
});
|
||||
|
||||
it('should parse FrontMatter with multiple properties', async () => {
|
||||
const frontMatter = '---\ntitle: Test\ndate: 2024-01-01\ntags: [one, two]\n---';
|
||||
const documentText = `${frontMatter}\n\nContent here.`;
|
||||
const editor = await createEditorState(documentText, [frontMatterTagName]);
|
||||
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
|
||||
|
||||
expect(frontMatterNodes.length).toBe(1);
|
||||
expect(frontMatterNodes[0].from).toBe(0);
|
||||
expect(frontMatterNodes[0].to).toBe(frontMatter.length);
|
||||
});
|
||||
|
||||
it('should not parse FrontMatter if not at document start', async () => {
|
||||
const documentText = 'Some text\n\n---\ntitle: Test\n---';
|
||||
const editor = await createEditorState(documentText, ['Paragraph']);
|
||||
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
|
||||
|
||||
// Should not be recognized as FrontMatter since it's not at the start
|
||||
expect(frontMatterNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not parse FrontMatter without closing delimiter', async () => {
|
||||
// Test document with --- at start but no closing delimiter
|
||||
// This should be parsed as a horizontal rule followed by content
|
||||
const documentText = '# Heading\n\n---\ntitle: Test';
|
||||
const editor = await createEditorState(documentText, ['ATXHeading1', 'HorizontalRule']);
|
||||
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
|
||||
|
||||
// FrontMatter only works at the very start of the document, so this should not be recognized
|
||||
expect(frontMatterNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty FrontMatter block', async () => {
|
||||
const documentText = '---\n---\n\n# Heading';
|
||||
const editor = await createEditorState(documentText, [frontMatterTagName, 'ATXHeading1']);
|
||||
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
|
||||
|
||||
expect(frontMatterNodes.length).toBe(1);
|
||||
expect(frontMatterNodes[0].from).toBe(0);
|
||||
expect(frontMatterNodes[0].to).toBe('---\n---'.length);
|
||||
});
|
||||
|
||||
it('should have FrontMatterContent as child node', async () => {
|
||||
const documentText = '---\nkey: value\n---';
|
||||
const editor = await createEditorState(documentText, [frontMatterTagName]);
|
||||
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
|
||||
const contentNodes = findNodesWithName(editor, frontMatterContentTagName);
|
||||
|
||||
expect(frontMatterNodes.length).toBe(1);
|
||||
// Content node may be replaced by YAML parser, but if not, it should exist
|
||||
// The presence depends on whether YAML language was loaded
|
||||
expect(contentNodes.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should not confuse horizontal rules with FrontMatter', async () => {
|
||||
const documentText = '# Title\n\n---\n\nSome text';
|
||||
const editor = await createEditorState(documentText, ['ATXHeading1', 'HorizontalRule']);
|
||||
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
|
||||
const hrNodes = findNodesWithName(editor, 'HorizontalRule');
|
||||
|
||||
expect(frontMatterNodes.length).toBe(0);
|
||||
expect(hrNodes.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should create FrontMatterMarker nodes for the delimiters', async () => {
|
||||
const documentText = '---\ntitle: Test\n---\n\n# Heading';
|
||||
const editor = await createEditorState(documentText, [frontMatterTagName, frontMatterMarkerTagName]);
|
||||
const markerNodes = findNodesWithName(editor, frontMatterMarkerTagName);
|
||||
|
||||
// Should have two markers: opening and closing ---
|
||||
expect(markerNodes.length).toBe(2);
|
||||
|
||||
// Opening marker
|
||||
expect(markerNodes[0].from).toBe(0);
|
||||
expect(markerNodes[0].to).toBe(3); // '---'.length
|
||||
|
||||
// Closing marker
|
||||
expect(markerNodes[1].from).toBe('---\ntitle: Test\n'.length);
|
||||
expect(markerNodes[1].to).toBe('---\ntitle: Test\n---'.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// Extension for parsing and highlighting YAML FrontMatter blocks at the start of a document.
|
||||
//
|
||||
// A FrontMatter block is delimited by --- at the very start of the document:
|
||||
// ---
|
||||
// title: My Document
|
||||
// date: 2024-01-01
|
||||
// ---
|
||||
|
||||
import { Tag } from '@lezer/highlight';
|
||||
import { parseMixed, SyntaxNodeRef, Input, NestedParse, ParseWrapper } from '@lezer/common';
|
||||
import { MarkdownConfig, BlockContext, Line, LeafBlock, MarkdownExtension } from '@lezer/markdown';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
|
||||
export const frontMatterTagName = 'FrontMatter';
|
||||
export const frontMatterContentTagName = 'FrontMatterContent';
|
||||
export const frontMatterMarkerTagName = 'FrontMatterMarker';
|
||||
|
||||
export const frontMatterTag = Tag.define();
|
||||
|
||||
// Create the YAML language parser using the legacy mode
|
||||
const yamlLanguage = StreamLanguage.define(yaml);
|
||||
|
||||
// Wraps a YAML parser for the FrontMatter content.
|
||||
// This replaces [nodeTag] from the syntax tree with a region handled by the YAML parser.
|
||||
const wrappedYamlParser = (nodeTag: string): ParseWrapper => {
|
||||
return parseMixed((node: SyntaxNodeRef, _input: Input): NestedParse => {
|
||||
if (node.name !== nodeTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
parser: yamlLanguage.parser,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Regex to match the FrontMatter delimiter (--- at start of line)
|
||||
const frontMatterDelimiterRegex = /^---\s*$/;
|
||||
|
||||
const frontMatterConfig: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{
|
||||
name: frontMatterTagName,
|
||||
style: frontMatterTag,
|
||||
},
|
||||
{
|
||||
name: frontMatterContentTagName,
|
||||
},
|
||||
{
|
||||
name: frontMatterMarkerTagName,
|
||||
style: frontMatterTag,
|
||||
},
|
||||
],
|
||||
parseBlock: [{
|
||||
name: frontMatterTagName,
|
||||
before: 'HorizontalRule',
|
||||
parse(cx: BlockContext, line: Line): boolean {
|
||||
// FrontMatter must be at the very start of the document
|
||||
if (cx.lineStart !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the first line is ---
|
||||
if (!frontMatterDelimiterRegex.test(line.text)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store the opening delimiter position
|
||||
const openingMarkerStart = cx.lineStart;
|
||||
const openingMarkerEnd = cx.lineStart + line.text.length;
|
||||
|
||||
const contentStart = openingMarkerEnd + 1; // Start after the opening --- and newline
|
||||
let foundEnd = false;
|
||||
|
||||
// Consume lines until we find the closing ---
|
||||
while (cx.nextLine()) {
|
||||
if (frontMatterDelimiterRegex.test(line.text)) {
|
||||
foundEnd = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundEnd) {
|
||||
// No closing delimiter found - not a valid FrontMatter block
|
||||
return false;
|
||||
}
|
||||
|
||||
// The content is between the two --- delimiters
|
||||
const contentEnd = cx.lineStart; // Start of the closing --- line
|
||||
|
||||
// Closing delimiter positions
|
||||
const closingMarkerStart = cx.lineStart;
|
||||
const closingMarkerEnd = cx.lineStart + line.text.length;
|
||||
|
||||
// Create marker elements for the --- delimiters
|
||||
const openingMarkerElem = cx.elt(frontMatterMarkerTagName, openingMarkerStart, openingMarkerEnd);
|
||||
const closingMarkerElem = cx.elt(frontMatterMarkerTagName, closingMarkerStart, closingMarkerEnd);
|
||||
|
||||
// Create the content element (the YAML content between delimiters)
|
||||
const contentElem = cx.elt(frontMatterContentTagName, contentStart, contentEnd);
|
||||
|
||||
// Create the container element spanning from start of first --- to end of last ---
|
||||
const containerElement = cx.elt(
|
||||
frontMatterTagName,
|
||||
0, // Start at document beginning
|
||||
closingMarkerEnd, // End after closing ---
|
||||
[openingMarkerElem, contentElem, closingMarkerElem],
|
||||
);
|
||||
|
||||
cx.addElement(containerElement);
|
||||
|
||||
// Move past the closing delimiter
|
||||
cx.nextLine();
|
||||
|
||||
return true;
|
||||
},
|
||||
// FrontMatter blocks can end leaf blocks like paragraphs
|
||||
endLeaf(_cx: BlockContext, line: Line, _leaf: LeafBlock): boolean {
|
||||
return frontMatterDelimiterRegex.test(line.text);
|
||||
},
|
||||
}],
|
||||
wrap: wrappedYamlParser(frontMatterContentTagName),
|
||||
};
|
||||
|
||||
const markdownFrontMatterExtension: MarkdownExtension = [frontMatterConfig];
|
||||
|
||||
export default markdownFrontMatterExtension;
|
||||
@@ -6,6 +6,33 @@ import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
|
||||
|
||||
const imageClassName = 'cm-md-image';
|
||||
|
||||
class ImageHeightCache {
|
||||
private readonly cache = new Map<string, number>();
|
||||
private readonly maxEntries = 500;
|
||||
|
||||
public get(key: string): number | undefined {
|
||||
const value = this.cache.get(key);
|
||||
if (value !== undefined) {
|
||||
// Refresh recency
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public set(key: string, height: number): void {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key);
|
||||
} else if (this.cache.size >= this.maxEntries) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey) this.cache.delete(firstKey);
|
||||
}
|
||||
this.cache.set(key, height);
|
||||
}
|
||||
}
|
||||
|
||||
const imageHeightCache = new ImageHeightCache();
|
||||
|
||||
class ImageWidget extends WidgetType {
|
||||
private resolvedSrc_: string;
|
||||
|
||||
@@ -41,9 +68,16 @@ class ImageWidget extends WidgetType {
|
||||
|
||||
const updateImageUrl = () => {
|
||||
if (this.resolvedSrc_) {
|
||||
// Use a background-image style property rather than img[src=]. This
|
||||
// simplifies setting the image to the correct size/position.
|
||||
image.src = this.resolvedSrc_;
|
||||
// When the image loads, measure and cache the height
|
||||
image.onload = () => {
|
||||
// Measure container height (what CodeMirror uses for scroll calculations).
|
||||
if (dom.isConnected) {
|
||||
imageHeightCache.set(this.cacheKey, dom.offsetHeight);
|
||||
}
|
||||
|
||||
dom.style.minHeight = '';
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -56,10 +90,16 @@ class ImageWidget extends WidgetType {
|
||||
updateImageUrl();
|
||||
}
|
||||
|
||||
// Apply cached height as min-height to prevent collapse during load.
|
||||
const cached = imageHeightCache.get(this.cacheKey);
|
||||
if (cached) {
|
||||
dom.style.minHeight = `${cached}px`;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public toDOM() {
|
||||
public toDOM(_view: EditorView) {
|
||||
const container = document.createElement('div');
|
||||
container.classList.add(imageClassName);
|
||||
|
||||
@@ -72,8 +112,12 @@ class ImageWidget extends WidgetType {
|
||||
return container;
|
||||
}
|
||||
|
||||
private get cacheKey() {
|
||||
return `${this.src_}_${this.width_ ?? ''}_${this.reloadCounter_}`;
|
||||
}
|
||||
|
||||
public get estimatedHeight() {
|
||||
return -1;
|
||||
return imageHeightCache.get(this.cacheKey) ?? -1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ class DividerWidget extends WidgetType {
|
||||
|
||||
const dividerLineMark = Decoration.line({ class: dividerLineClassName });
|
||||
|
||||
// Node names that should be rendered as dividers
|
||||
const dividerNodeNames = ['HorizontalRule', 'FrontMatterMarker'];
|
||||
|
||||
const replaceDividers = [
|
||||
EditorView.theme({
|
||||
[`& .cm-line.${dividerLineClassName}`]: {
|
||||
@@ -47,7 +50,7 @@ const replaceDividers = [
|
||||
}),
|
||||
makeInlineReplaceExtension({
|
||||
createDecoration: (node) => {
|
||||
if (node.name === 'HorizontalRule') {
|
||||
if (dividerNodeNames.includes(node.name)) {
|
||||
return new DividerWidget();
|
||||
}
|
||||
return null;
|
||||
@@ -55,7 +58,7 @@ const replaceDividers = [
|
||||
}),
|
||||
makeInlineReplaceExtension({
|
||||
createDecoration: (node) => {
|
||||
if (node.name === 'HorizontalRule') {
|
||||
if (dividerNodeNames.includes(node.name)) {
|
||||
return dividerLineMark;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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>', expectedDomTags: 'sup' },
|
||||
{ markdown: '<strike>Test</strike>', expectedDomTags: 'strike' },
|
||||
{ markdown: 'Test: <span style="color: red;">Test</span>', expectedDomTags: 'span[style]' },
|
||||
{ markdown: 'Test: <span style="color: rgb(123, 0, 0);">Test</span>', expectedDomTags: 'span[style]' },
|
||||
{
|
||||
markdown: '<sup>Test *test*...</sup>',
|
||||
expectedDomTags: 'sup',
|
||||
initialSyntaxTags: ['HTMLTag', 'Emphasis'],
|
||||
},
|
||||
])('should render inline HTML (case %#)', async ({ markdown, expectedDomTags: expectedTagsQuery, initialSyntaxTags }) => {
|
||||
// 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`, initialSyntaxTags);
|
||||
|
||||
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 (info && isMatchingOpeningTag(info)) {
|
||||
nestedTagCounter ++;
|
||||
} else if (info && 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();
|
||||
@@ -7,6 +7,7 @@ import forceFullParse from './forceFullParse';
|
||||
import loadLanguages from './loadLanguages';
|
||||
import markdownMathExtension from '../extensions/markdownMathExtension';
|
||||
import markdownHighlightExtension from '../extensions/markdownHighlightExtension';
|
||||
import markdownFrontMatterExtension from '../extensions/markdownFrontMatterExtension';
|
||||
|
||||
// Creates and returns a minimal editor with markdown extensions. Waits to return the editor
|
||||
// until all syntax tree tags in `expectedSyntaxTreeTags` exist.
|
||||
@@ -26,7 +27,7 @@ const createTestEditor = async (
|
||||
selection: EditorSelection.create(initialSelection),
|
||||
extensions: [
|
||||
markdown({
|
||||
extensions: [markdownMathExtension, markdownHighlightExtension, GithubFlavoredMarkdownExt],
|
||||
extensions: [markdownMathExtension, markdownHighlightExtension, markdownFrontMatterExtension, GithubFlavoredMarkdownExt],
|
||||
addKeymap: addMarkdownKeymap,
|
||||
}),
|
||||
indentUnit.of('\t'),
|
||||
|
||||
47
packages/editor/CodeMirror/theme.ts
vendored
@@ -139,25 +139,7 @@ const createTheme = (theme: EditorTheme): Extension[] => {
|
||||
},
|
||||
|
||||
'& .cm-codeBlock': {
|
||||
'&.cm-regionFirstLine, &.cm-regionLastLine': {
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&:not(.cm-regionFirstLine)': {
|
||||
borderTop: 'none',
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
},
|
||||
'&:not(.cm-regionLastLine)': {
|
||||
borderBottom: 'none',
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: theme.colorFaded,
|
||||
backgroundColor: 'rgba(155, 155, 155, 0.1)',
|
||||
|
||||
backgroundColor: 'rgba(155, 155, 155, 0.07)',
|
||||
...monospaceStyle,
|
||||
},
|
||||
|
||||
@@ -269,8 +251,8 @@ const createTheme = (theme: EditorTheme): Extension[] => {
|
||||
},
|
||||
{
|
||||
tag: tags.comment,
|
||||
opacity: 0.9,
|
||||
fontStyle: 'italic',
|
||||
color: isDarkTheme ? '#b18eb1' : '#6d7086',
|
||||
},
|
||||
{
|
||||
tag: tags.link,
|
||||
@@ -281,26 +263,23 @@ const createTheme = (theme: EditorTheme): Extension[] => {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
// Content of code blocks
|
||||
// Content of code blocks. This should roughly match the colors used by the default
|
||||
// highlight.js theme in the note viewer, while also preserving at least 4.5:1 contrast.
|
||||
{
|
||||
tag: tags.keyword,
|
||||
color: isDarkTheme ? '#ff7' : '#740',
|
||||
},
|
||||
{
|
||||
tag: tags.operator,
|
||||
color: isDarkTheme ? '#f7f' : '#805',
|
||||
color: isDarkTheme ? '#F92672' : '#a626a4',
|
||||
},
|
||||
{
|
||||
tag: tags.literal,
|
||||
color: isDarkTheme ? '#aaf' : '#037',
|
||||
},
|
||||
{
|
||||
tag: tags.operator,
|
||||
color: isDarkTheme ? '#fa9' : '#490',
|
||||
tag: tags.number,
|
||||
color: isDarkTheme ? '#d19a66' : '#986801',
|
||||
},
|
||||
{
|
||||
tag: tags.typeName,
|
||||
color: isDarkTheme ? '#7ff' : '#a00',
|
||||
color: isDarkTheme ? '#d19a66' : '#986801',
|
||||
},
|
||||
{
|
||||
tag: tags.inserted,
|
||||
@@ -312,13 +291,21 @@ const createTheme = (theme: EditorTheme): Extension[] => {
|
||||
},
|
||||
{
|
||||
tag: tags.propertyName,
|
||||
color: isDarkTheme ? '#d96' : '#940',
|
||||
color: isDarkTheme ? '#61aeee' : '#406be5',
|
||||
},
|
||||
{
|
||||
tag: tags.string,
|
||||
color: isDarkTheme ? '#98c379' : '#50a14f',
|
||||
},
|
||||
{
|
||||
// CSS class names (and class names in other languages)
|
||||
tag: tags.className,
|
||||
color: isDarkTheme ? '#d8a' : '#904',
|
||||
},
|
||||
{
|
||||
tag: tags.macroName,
|
||||
color: isDarkTheme ? '#e6c07b' : '#986801',
|
||||
},
|
||||
]);
|
||||
|
||||
return [
|
||||
|
||||
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",
|
||||
@@ -28,21 +28,21 @@
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "6.18.3",
|
||||
"@codemirror/commands": "6.7.1",
|
||||
"@codemirror/lang-html": "6.4.9",
|
||||
"@codemirror/lang-markdown": "6.3.1",
|
||||
"@codemirror/autocomplete": "6.20.0",
|
||||
"@codemirror/commands": "6.10.1",
|
||||
"@codemirror/lang-html": "6.4.11",
|
||||
"@codemirror/lang-markdown": "6.5.0",
|
||||
"@codemirror/language": "6.10.4",
|
||||
"@codemirror/language-data": "6.3.1",
|
||||
"@codemirror/legacy-modes": "6.4.2",
|
||||
"@codemirror/lint": "6.8.3",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/lint": "6.9.2",
|
||||
"@codemirror/search": "6.5.8",
|
||||
"@codemirror/state": "6.4.1",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/view": "6.35.0",
|
||||
"@joplin/fork-uslug": "^2.0.0",
|
||||
"@lezer/common": "1.2.3",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@lezer/markdown": "1.3.2",
|
||||
"@lezer/common": "1.5.0",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lezer/markdown": "1.6.3",
|
||||
"@replit/codemirror-vim": "6.2.1",
|
||||
"dompurify": "3.2.7",
|
||||
"orderedmap": "2.1.1",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export default class PerformanceLogger {
|
||||
|
||||
const startTime = performance.now();
|
||||
this.lastLogTime_ = startTime;
|
||||
PerformanceLogger.logDebug_(`${name}: Start at ${formatAbsoluteTime(startTime)}`);
|
||||
PerformanceLogger.log_(`${name}: Start at ${formatAbsoluteTime(startTime)}`);
|
||||
|
||||
const onEnd = () => {
|
||||
const now = performance.now();
|
||||
@@ -140,12 +140,7 @@ export default class PerformanceLogger {
|
||||
performance.measure(name, `${uniqueTaskId}-start`, `${uniqueTaskId}-end`);
|
||||
}
|
||||
|
||||
const duration = now - startTime;
|
||||
// Increase the log level for long-running tasks
|
||||
const isLong = duration >= Second / 10;
|
||||
const log = isLong ? PerformanceLogger.log_ : PerformanceLogger.logDebug_;
|
||||
|
||||
log(`${name}: End at ${formatAbsoluteTime(now)} (took ${formatTaskDuration(now - startTime)})`);
|
||||
PerformanceLogger.log_(`${name}: End at ${formatAbsoluteTime(now)} (took ${formatTaskDuration(now - startTime)})`);
|
||||
};
|
||||
return {
|
||||
onEnd,
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -520,6 +520,50 @@ describe('models/Folder.sharing', () => {
|
||||
expect(note4.user_updated_time).toBe(userUpdatedTimes[note4.id]);
|
||||
});
|
||||
|
||||
it('should prefer duplicating resources in unshared folders to shared folders', async () => {
|
||||
const resourceService = new ResourceService();
|
||||
|
||||
const folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1', // Share 1
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder 2', // Not shared
|
||||
children: [
|
||||
{
|
||||
title: 'note 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
let note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
let note2: NoteEntity = await Note.loadByTitle('note 2');
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'share1' });
|
||||
|
||||
note1 = await shim.attachFileToNote(note1, testImagePath);
|
||||
note2 = await Note.save({ id: note2.id, body: note1.body });
|
||||
|
||||
await msleep(1);
|
||||
|
||||
await resourceService.indexNoteResources(); // Populate note_resources
|
||||
await Folder.updateAllShareIds(resourceService, []);
|
||||
|
||||
// After
|
||||
expect(await Resource.all()).toHaveLength(2);
|
||||
|
||||
// note1 should have the same body
|
||||
expect(await Note.load(note1.id)).toMatchObject({ body: note1.body, share_id: 'share1' });
|
||||
// note2's body should be updated
|
||||
expect(await Note.load(note2.id)).not.toMatchObject({ body: note2.body, share_id: '' });
|
||||
});
|
||||
|
||||
it('should clear share_ids for items that are no longer part of an existing share', async () => {
|
||||
await createFolderTree('', [
|
||||
{
|
||||
|
||||
@@ -639,12 +639,21 @@ export default class Folder extends BaseItem {
|
||||
// one note. If it is not, we create duplicate resources so that
|
||||
// each note has its own separate resource.
|
||||
|
||||
// Order unshared items first: This makes conflicts less likely, since shared
|
||||
// items are more likely to be duplicated by multiple users.
|
||||
const orderingSql = 'ORDER BY is_shared ASC';
|
||||
|
||||
const noteResourceAssociations = await this.db().selectAll(`
|
||||
SELECT resource_id, note_id, notes.share_id
|
||||
SELECT
|
||||
resource_id,
|
||||
note_id,
|
||||
notes.share_id,
|
||||
(notes.share_id != '') AS is_shared
|
||||
FROM note_resources
|
||||
LEFT JOIN notes ON notes.id = note_resources.note_id
|
||||
WHERE resource_id IN (${this.escapeIdsForSql(resourceIds)})
|
||||
AND is_associated = 1
|
||||
${orderingSql}
|
||||
`) as NoteResourceRow[];
|
||||
|
||||
const resourceIdToNotes: Record<string, NoteResourceRow[]> = {};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -48,7 +48,20 @@ describe('services/ResourceService', () => {
|
||||
expect(!(await NoteResource.all()).length).toBe(true);
|
||||
}));
|
||||
|
||||
it('should not delete resource if still associated with at least one note', (async () => {
|
||||
it.each([
|
||||
{
|
||||
linkStyle: 'image 1',
|
||||
markupTag: (id: string) => ``,
|
||||
},
|
||||
{
|
||||
linkStyle: 'image 2',
|
||||
markupTag: (id: string) => `![image][image]\n\n[image]: :/${id}`,
|
||||
},
|
||||
{
|
||||
linkStyle: 'html link',
|
||||
markupTag: (id: string) => `<a href=":/${id}">test</a>`,
|
||||
},
|
||||
])('should not delete resource if still associated with at least one note (link style: $linkStyle)', (async ({ markupTag }) => {
|
||||
const service = new ResourceService();
|
||||
|
||||
const folder1 = await Folder.save({ title: 'folder1' });
|
||||
@@ -63,7 +76,7 @@ describe('services/ResourceService', () => {
|
||||
|
||||
await service.indexNoteResources();
|
||||
|
||||
await Note.save({ id: note2.id, body: Resource.markupTag(resource1) });
|
||||
await Note.save({ id: note2.id, body: markupTag(resource1.id) });
|
||||
|
||||
await service.indexNoteResources();
|
||||
|
||||
|
||||
@@ -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}]');
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('urlUtils', () => {
|
||||
['Bla [](:/11111111111111111111111111111111 "Some title") bla [](:/22222222222222222222222222222222 "something else") bla', ['11111111111111111111111111111111', '22222222222222222222222222222222']],
|
||||
['Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla [](:/22222222222222222222222222222222) bla', ['fcca2938a96a22570e8eae2565bc6b0b', '22222222222222222222222222222222']],
|
||||
['Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla <a href=":/33333333333333333333333333333333"/>Some note link</a> blu [](:/22222222222222222222222222222222) bla', ['fcca2938a96a22570e8eae2565bc6b0b', '33333333333333333333333333333333', '22222222222222222222222222222222']],
|
||||
['Link to [a test note] and [another] note.\n\n[a test note]: :/fcca2938a96a22570e8eae2565bc6b0b\n[another]: :/f04a2938a26822570e8eae2505bc6b0c', ['fcca2938a96a22570e8eae2565bc6b0b', 'f04a2938a26822570e8eae2505bc6b0c']],
|
||||
['nothing here', []],
|
||||
['', []],
|
||||
];
|
||||
|
||||
@@ -94,13 +94,20 @@ export const fileUrlToResourceUrl = (fileUrl: string, resourceDir: string) => {
|
||||
};
|
||||
|
||||
export const extractResourceUrls = (text: string) => {
|
||||
const markdownLinksRE = /\]\((.*?)\)/g;
|
||||
const markdownLinkRegexes = [
|
||||
// Standard [link](...)-style links
|
||||
/\]\((.*?)\)/g,
|
||||
// Reference links
|
||||
/\]:(.*?)(?:[\n]|$)/g,
|
||||
];
|
||||
const output = [];
|
||||
let result = null;
|
||||
|
||||
while ((result = markdownLinksRE.exec(text)) !== null) {
|
||||
const resourceUrlInfo = parseResourceUrl(result[1]);
|
||||
if (resourceUrlInfo) output.push(resourceUrlInfo);
|
||||
for (const regex of markdownLinkRegexes) {
|
||||
while ((result = regex.exec(text)) !== null) {
|
||||
const resourceUrlInfo = parseResourceUrl(result[1].trim());
|
||||
if (resourceUrlInfo) output.push(resourceUrlInfo);
|
||||
}
|
||||
}
|
||||
|
||||
const htmlRegexes = [
|
||||
|
||||
@@ -459,7 +459,7 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla
|
||||
featureLabelsOn: getFeatureLabelsByPlan(PlanName.JoplinServerBusiness, true),
|
||||
featureLabelsOff: getFeatureLabelsByPlan(PlanName.JoplinServerBusiness, false),
|
||||
cfaLabel: _('Get a quote'),
|
||||
cfaUrl: 'mailto:jsb-inquiry@joplin.cloud?subject=Joplin%20Server%20Business%20inquiry',
|
||||
cfaUrl: 'https://tally.so/r/D4BlOE',
|
||||
footnote: '',
|
||||
learnMoreUrl: 'https://joplinapp.org/help/apps/joplin_server_business',
|
||||
hostingType: PlanHostingType.Self,
|
||||
|
||||
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>;
|
||||
|
||||
@@ -767,14 +767,9 @@ impl AttachmentInfo {
|
||||
.into())
|
||||
} else if self.data_ref.starts_with("<invfdo>") {
|
||||
// "invalid"
|
||||
log_warn!("Attempted to load an invalid {} file", self.extension);
|
||||
Err(parser_error!(
|
||||
ResolutionFailed,
|
||||
"Unable to load invalid file reference: {} (ext: {})",
|
||||
self.data_ref,
|
||||
self.extension
|
||||
)
|
||||
.into())
|
||||
log_warn!("Attempted to load an invalid {} file. Importing an empty file.", self.extension);
|
||||
// Return empty data
|
||||
Ok(FileBlob::default())
|
||||
} else {
|
||||
Err(parser_error!(
|
||||
ResolutionFailed,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||